教科书讲的是”逻辑地址 → 线性地址 → 物理地址”的完整链路,工程上你真正要回答的是另外四个问题: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 | 63 48 47 39 31 30 21 20 12 11 0 |
地址转换 = 硬件(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 | echo always > /sys/kernel/mm/transparent_hugepage/enabled # 或 madvise |
代价: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 | /* faults.c — 观察按需分页:malloc 不分配,写才分配 */ |
1 | gcc -O2 faults.c -o faults && ./faults |
实时控制程序:mlock 锁页
机器人控制循环(如 1kHz 电流环/速度环)里任何一次 major fault 都是灾难:卡顿几毫秒 = 丢几个控制周期。解法:启动时把所有内存锁进物理内存,杜绝 swap 和按需分配抖动:
1 |
|
注意 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 | cat /proc/<pid>/oom_score # 分数越高越先被杀 |
保护关键节点的手段(按推荐顺序):
- 修泄漏:用
valgrind、-fsanitize=address,或监控 RSS 增长趋势 - cgroup 限额:容器/服务限定
memory.max(超限被杀)或memory.high(超限只限流,不杀) oom_score_adj = -1000:关键节点免疫——但慎用:如果它就是泄漏源,免疫 = 系统被它拖死- earlyoom/systemd-oomd:提前干预而不是等到内核最后一刀
判断”是不是内存泄漏”
1 | free -h # Cached 高是好事(page cache),不是泄漏 |
要点: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 | struct { int a; int b; } shared; /* a、b 相邻,同一 cache 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)把大部分问题隐藏了,但共享内存跨进程通信时仍要留意内存序。
八、踩坑清单(实战总结)
- 用 MemFree 判断内存不足:应该看 MemAvailable;Cached 高是正常的
- malloc 成功 = 内存够:错,物理内存是写的时候才分配的,分配成功只说明虚拟地址空间够
- 控制循环里第一次访问大缓冲区:按需分页的 minor fault 风暴就发生在最不该发生的时刻——启动阶段预热(touch 一遍)+ mlockall
- 关键节点被 OOM 杀:先查泄漏(RSS 单调上涨),别急着 oom_score_adj=-1000 掩盖问题
- THP defrag 延迟尖峰:实时节点用 madvise 模式 + 显式 MADV_HUGEPAGE,别用 always
- mmap 后文件被截断:访问 SIGBUS,不是 SIGSEGV,排查方向完全不同
- unsigned 时间差/长度差回绕:内存和时间的”负数”问题同源,用有符号差或半程比较
- 多线程热点变量相邻:伪共享,性能莫名差一个量级,先查 cache line 对齐
- DMA 缓冲未对齐 cache line:数据偶发错乱,嵌入式场景经典疑难杂症
- 大文件 read 反复读:用 mmap 吃 page cache 红利,bag 回放二倍速的秘密