Linux 内核内存管理:从寻址到分页

6.2k 词

教科书讲的是”逻辑地址 → 线性地址 → 物理地址”的完整链路,工程上你真正要回答的是另外四个问题:malloc 之后物理内存真的分配了吗(没有,要等到写)→ 我的机器人节点为什么被 OOM killer 杀了 → 为什么同样的代码第一次跑得慢第二次快 → 实时控制程序怎么避免缺页抖动。本文保留地址转换的内核主线,但把重点放在页表 walk、TLB、缺页、overcommit/OOM、page cache、多核缓存一致性这些你能直接观测和调优的地方,全部配可执行的命令和代码。

一、地址转换:分段的历史包袱

四类地址与转换链

地址类型 说明
逻辑地址 段选择符 + 段内偏移(程序视角)
线性/虚拟地址 分段后的中间层,x86-64 下就是 48 位虚拟地址
物理地址 最终落到内存条上的地址

转换链:逻辑地址 →(分段,查 GDT/LDT)→ 虚拟地址 →(分页,查页表)→ 物理地址。

Linux 为什么”没有分段”

x86-64 下段机制基本被废弃(只剩 FS/GS 用于线程局部存储),Linux 把所有段的基址设为 0、限长设为全空间,逻辑地址 = 线性地址。四个经典段描述符:

段 Base Limit DPL
__USER_CS / __USER_DS 0 全空间 3
__KERNEL_CS / __KERNEL_DS 0 全空间 0

分段真正剩下的作用只有特权级隔离(DPL 0 vs 3)。现代内存保护几乎全部由分页完成——每个进程一张页表,进程间物理隔离;内核态/用户态靠页表项的 U/S 位隔离。这也是 Linux 把”进程地址空间”叫做”虚拟地址空间”的原因。

二、分页:一切的核心

四级页表 walk:48 位虚拟地址的解剖

x86-64 用 4 级页表(PML4 → PDPT → PD → PT → 页内偏移),每级索引 9 位:

1
2
3
4
63              48 47    39 31    30 21    20 12 11          0
+------------------+--------+--------+--------+--------------+
| 符号扩展(16位) | PML4(9)| PDPT(9)| PD(9) | PT(9) |偏移(12)|
+------------------+--------+--------+--------+--------------+

地址转换 = 硬件(MMU)拿虚拟地址的每 9 位去查对应页表项,找到下一级页表物理地址,逐级下钻,最后一级页表项给出物理页框号,拼上低 12 位页内偏移得到物理地址。每个进程的 CR3 寄存器指向自己的 PML4——这就是进程切换时页表切换的本质。

关键页表项标志位:P(存在)、RW(可写)、US(用户可访问)、A(被访问过)、D(被写过,脏页)。A/D 位是 Linux 页面回收、swap、写时复制的基础。

为什么页大小是 4KB:内碎片与页表开销的折中

页越小,内碎片越少(平均浪费半页);但页表项越多。4KB 页下,一个进程占 1GB 虚拟内存就需要 256K 个页表项、约 2MB 页表本身。两级/四级页表的意义就是让稀疏地址空间不必为不存在的区域分配页表——这也是为什么”页表是树”而不是”一张大数组”。

大页:TLB 覆盖率才是关键

每次地址转换都要 walk 页表(4 次内存访问),硬件用 TLB(转译后备缓冲) 缓存最近用过的映射。TLB 只有几十~几百项,4KB 页下 TLB 只能覆盖几百 KB 到几 MB,遍历大数组时 TLB miss 频繁,性能损失可达 20%+。

页大小 TLB 64 项覆盖 典型用途
4KB 256KB 默认
2MB(大页) 128MB 大内存应用、数据库
1GB(巨页) 64GB HPC

对机器人/实时场景(Jetson、工控机跑感知+控制):2MB 大页能把 TLB miss 降到接近零。开启透明大页(THP):

1
2
3
echo always > /sys/kernel/mm/transparent_hugepage/enabled   # 或 madvise
grep -i thp /proc/meminfo # AnonHugePages 统计
cat /proc/<pid>/smaps | grep -A3 THPeligible

代价:THP 的后台折叠(khugepaged)和 defrag 会带来毫秒级延迟尖峰——实时控制节点如果对延迟敏感,反而建议 madvise 模式 + 自己 madvise(MADV_HUGEPAGE) 精准控制。

三、缺页与按需分页:malloc 的真相

按需分页:虚拟内存为什么能”超额”

malloc(1GB) 返回成功,但物理内存一个字节都没分配——内核只是把地址空间映射到”未分配”状态(PTE 的 P 位清零)。直到进程第一次写某个页,才触发缺页异常,内核分配物理页、填页表、恢复执行。这就是”按需分页”。

缺页分两类,观测手段完全不同:

  • minor fault(轻微缺页):页在物理内存/页缓存里,只是当前进程页表没有映射(如刚 malloc 的匿名页、共享库首次映射)。处理耗时 ~1µs 级
  • major fault(严重缺页):页在磁盘上,必须读盘(如程序代码段首次加载、被 swap 出去的页)。耗时毫秒级,是延迟抖动的头号来源

测量:ps -o min_flt,maj_flt,cmd 或程序内 getrusage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* faults.c — 观察按需分页:malloc 不分配,写才分配 */
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>

static void report(const char *tag) {
struct rusage ru;
getrusage(RUSAGE_SELF, &ru);
printf("%-14s minor=%6ld major=%3ld maxrss=%ldKB\n",
tag, ru.ru_minflt, ru.ru_majflt, ru.ru_maxrss);
}

int main(void) {
report("start");
size_t n = 128lu * 1024 * 1024 / sizeof(long); /* 128MB */
long *p = malloc(n * sizeof(long));
report("after malloc"); /* 缺页数不变! */
for (size_t i = 0; i < n; i += 4096 / sizeof(long))
p[i] = i; /* 每页写一次 */
report("after touch"); /* minor 暴涨 ~32K */
return 0;
}
1
2
3
4
gcc -O2 faults.c -o faults && ./faults
# start minor= 2 major= 0 maxrss=2596KB
# after malloc minor= 2 major= 0 maxrss=2596KB <- 物理内存没动
# after touch minor= 32836 major= 0 maxrss=133312KB <- 按需分配触发

实时控制程序:mlock 锁页

机器人控制循环(如 1kHz 电流环/速度环)里任何一次 major fault 都是灾难:卡顿几毫秒 = 丢几个控制周期。解法:启动时把所有内存锁进物理内存,杜绝 swap 和按需分配抖动:

1
2
#include <sys/mman.h>
mlockall(MCL_CURRENT | MCL_FUTURE); /* 含未来分配 */

注意 RLIMIT_MEMLOCK 默认限制非 root 用户锁 ~64KB,要 ulimit -l unlimited 或设 prlimit。这也解释了为什么实时框架(如 PREEMPT_RT 文档)都要求关 swap、锁内存。

四、malloc、mmap 与 page cache

malloc 的两条路:brk 与 mmap

glibc 的 malloc 对小分配(默认阈值 128KB 以内)用 brk 扩展堆顶——释放后地址空间不还给内核,下次分配复用,快但会产生堆碎片;大分配直接 mmap 匿名映射——释放即 munmap,地址空间立刻归还,但每次分配有系统调用+缺页成本。这就是”大块分配释放不会撑大 RSS 但慢”的原因。

read() 为什么比 mmap 慢:双拷贝问题

读文件时,磁盘数据先被 DMA 进 page cache(内核页),然后 read() 再把它 CPU 拷贝到用户缓冲区——同一份数据搬了两次。mmap 直接把文件页映射进进程地址空间,第一次访问缺页后零拷贝,后续读写就是普通内存访问。

实践含义:

  • rosbag 回放第一遍慢、第二遍快:第一遍把 bag 读进 page cache,第二遍全部命中缓存(free 里 Cached 上涨就是这个)
  • 大数据(点云、图像、bag)处理优先 mmap;小数据 read 更省事
  • mmap 的坑:文件被 truncate 后访问已映射区域会 SIGBUS(不是 SIGSEGV);多进程共享映射要注意同步

环形缓冲:控制程序的堆碎片解药

控制节点高频分配小对象会让 glibc 堆碎片化,RSS 只涨不降。工程做法是启动时一次性预分配环形缓冲(rm_pid_tuner 的 data_buffer 就是这种结构):固定容量、读写指针、满则覆盖或丢弃——没有 malloc/free,就没有碎片,也没有锁竞争。

五、内存压力与 OOM Killer

overcommit:Linux 为什么敢”超卖”

按需分页决定了:分配虚拟内存不花物理内存。所以 Linux 默认允许进程申请远超物理内存的虚拟地址空间(启发式 overcommit),赌的是”不是所有页都会同时被写”。/proc/sys/vm/overcommit_memory:

值 模式 说明
0 启发式 默认,明显超卖才拒绝
1 总是 永远允许,谁敢申请都行
2 严格 不允许超过 CommitLimit(swap + RAM×overcommit_ratio)

OOM killer 杀谁:不是随机的

物理内存耗尽时内核选”最该杀”的进程:oom_score = 进程占用内存(RSS + 页表 + swap)× 惩罚因子(root 进程、特权进程加分,即更难被杀)。谁 RSS 大杀谁——所以失控的机器人节点(内存泄漏)通常第一个死。

1
2
cat /proc/<pid>/oom_score          # 分数越高越先被杀
cat /proc/<pid>/oom_score_adj # -1000 免疫,+1000 优先被杀

保护关键节点的手段(按推荐顺序):

  1. 修泄漏:用 valgrind、-fsanitize=address,或监控 RSS 增长趋势
  2. cgroup 限额:容器/服务限定 memory.max(超限被杀)或 memory.high(超限只限流,不杀)
  3. oom_score_adj = -1000:关键节点免疫——但慎用:如果它就是泄漏源,免疫 = 系统被它拖死
  4. earlyoom/systemd-oomd:提前干预而不是等到内核最后一刀

判断”是不是内存泄漏”

1
2
3
free -h                    # Cached 高是好事(page cache),不是泄漏
watch -n1 'grep VmRSS /proc/<pid>/status'
ps aux --sort=-rss | head

要点:free 里 Cached 占大头是 Linux 的正常姿态(缓存随时可回收);真正的泄漏特征是单个进程 RSS 单调上涨、永不回落。进程退出后内存没释放干净(僵尸/内核泄漏)则是另一类问题,看 slabtop。

六、/proc/meminfo 实战解读

字段 含义 排查意义
MemTotal / MemFree 物理内存总量 / 完全空闲 MemFree 低≠内存不够
MemAvailable 估算的可用量 看这个,别看 MemFree
Buffers / Cached 块设备缓冲 / page cache 可回收,不算”占用”
AnonPages 匿名页(堆、栈) 涨 = 进程真的在用
Mapped 文件映射页 mmap 的文件
Shmem 共享内存/tmpfs ROS 共享内存通信在这里
Slab / SReclaimable 内核对象缓存 slabtop 查内核泄漏
CommitLimit / Committed_AS overcommit 上限 / 已承诺 overcommit_memory=2 时看这两个

一句话判读:MemAvailable 掉到几百 MB 且 AnonPages 持续上涨 → 有进程在吃内存;Cached 高不用管。

七、多核与缓存一致性:看不见的坑

伪共享(false sharing)

多核 CPU 缓存以 64 字节 cache line 为粒度同步。两个线程各自累加不同的 int,如果两个 int 恰好落在同一 cache line:

1
2
3
struct { int a; int b; } shared;   /* a、b 相邻,同一 cache line */
/* 线程1 狂写 a,线程2 狂写 b → 每次写入都要跨核同步整条 line,
性能可以差一个数量级,锁都省了还是慢 */

修复:__attribute__((aligned(64))) 把热点变量分开,或用 atomic<>, padding。控制程序里”两个核各跑一个 1kHz 循环、共享几个标志位”的场景最容易踩。

DMA 与 cache 一致性

CPU 写的数据可能还在 cache 里没落内存,DMA 直接读内存就拿到旧数据。Linux 驱动用 dma_alloc_coherent(一致性映射,禁用 cache 或硬件自动维护)或 dma_map_single + 手动 dma_sync_*。对嵌入式裸机(STM32)同理:DMA 缓冲区要按 cache line 对齐,否则 cache 回写会踩掉 DMA 刚写入的数据——这是”ADC 数据偶尔错乱”的经典原因。x86 上 IOMMU 和硬件一致性协议(MESI)把大部分问题隐藏了,但共享内存跨进程通信时仍要留意内存序。

八、踩坑清单(实战总结)

  1. 用 MemFree 判断内存不足:应该看 MemAvailable;Cached 高是正常的
  2. malloc 成功 = 内存够:错,物理内存是写的时候才分配的,分配成功只说明虚拟地址空间够
  3. 控制循环里第一次访问大缓冲区:按需分页的 minor fault 风暴就发生在最不该发生的时刻——启动阶段预热(touch 一遍)+ mlockall
  4. 关键节点被 OOM 杀:先查泄漏(RSS 单调上涨),别急着 oom_score_adj=-1000 掩盖问题
  5. THP defrag 延迟尖峰:实时节点用 madvise 模式 + 显式 MADV_HUGEPAGE,别用 always
  6. mmap 后文件被截断:访问 SIGBUS,不是 SIGSEGV,排查方向完全不同
  7. unsigned 时间差/长度差回绕:内存和时间的”负数”问题同源,用有符号差或半程比较
  8. 多线程热点变量相邻:伪共享,性能莫名差一个量级,先查 cache line 对齐
  9. DMA 缓冲未对齐 cache line:数据偶发错乱,嵌入式场景经典疑难杂症
  10. 大文件 read 反复读:用 mmap 吃 page cache 红利,bag 回放二倍速的秘密