1. Linux内存管理全景:虚拟内存、物理内存、MMU、页表、TLB、内存映射、伙伴系统、slab分配器
各位好,我是老李。做嵌入式Linux内核开发十几年了,每次带新人,我总喜欢先让他们看一张全景图。为什么?因为内存管理这摊子事,知识点太碎。你单独看MMU、看页表、看伙伴系统,每个都能讲三天三夜,但串不起来,遇到问题照样抓瞎。
这一章,咱们先把整个内存管理体系的骨架搭起来。说白了,就是搞清楚:CPU发出的虚拟地址,到底是怎么一步步变成物理地址,最终落到DRAM颗粒上的?
核心一句话:Linux内存管理 = 虚拟地址空间抽象 + 物理内存高效分配 + 硬件MMU透明转换。这三者缺一不可。
1.1 虚拟内存:为什么每个进程都觉得自己独占4GB?
我刚入行时,有个问题困扰了我很久:为什么一个只有256MB内存的嵌入式板子,能同时跑十几个进程,每个进程还都觉得自己有4GB空间?这不科学啊。
答案就是虚拟内存。它给每个进程一个独立的虚拟地址空间,进程以为自己独占整片内存,实际上物理内存可能只有256MB。多余的虚拟页,要么没分配物理页框,要么被换出到磁盘(swap)了。
我的经验:在嵌入式Linux中,我一般不建议开swap。Flash寿命扛不住频繁读写,而且实时性会崩。老老实实用ramdisk或者zram压缩内存,比swap靠谱得多。
虚拟内存带来的好处很明显:
- 隔离性:进程A访问地址0x1000,进程B也访问0x1000,互不干扰。因为页表不同,映射到的物理页框不同。
- 简化内存管理:malloc返回的地址是虚拟地址,内核可以在物理内存碎片化的情况下,通过页表把不连续的物理页映射成连续的虚拟空间。
- 按需分配:进程申请了1GB内存,但只用了10MB,物理内存只分配10MB。剩下的只是虚拟地址空间,没有物理页框。
1.2 物理内存与MMU:硬件怎么完成地址转换?
虚拟地址要变成物理地址,靠的是MMU(Memory Management Unit)。它是CPU内部的一个硬件模块,每个内存访问指令,MMU都会介入。
MMU的工作流程,说白了就三步:
- 拆分地址:把虚拟地址拆成页号(VPN)和页内偏移(Offset)。
- 查页表:用VPN去查当前进程的页表,找到对应的物理页框号(PFN)。
- 合成物理地址:PFN左移页大小位数,加上Offset,得到最终物理地址。
举个例子,ARMv8 4KB页、四级页表的情况下:
虚拟地址 [63:0] = [63:48] 保留 | [47:39] L0索引 | [38:30] L1索引 | [29:21] L2索引 | [20:12] L3索引 | [11:0] 页内偏移
页表遍历:TTBR0_EL1 → L0表 → L1表 → L2表 → L3表 → 物理页框基址 + 偏移
我曾经踩过的坑:在某ARM64平台上,我配置了TTBR0_EL1指向的页表基地址,但忘了配置TCR_EL1的TG0(颗粒度)字段。结果MMU按64KB颗粒度解析页表,而我实际构建的是4KB页表,系统直接挂掉。查了两天才发现,MMU的配置寄存器一个都不能错。
1.3 页表与TLB:为什么需要缓存?
页表是存在内存里的。每次地址转换都要查内存,性能太差了。你想想看,一次内存访问就要多一次甚至四次内存读取(多级页表),这谁受得了?
所以硬件引入了TLB(Translation Lookaside Buffer),它是MMU内部的一个高速缓存,专门缓存最近用过的虚拟地址到物理地址的映射关系。
| 特性 | 页表(Page Table) | TLB |
|---|---|---|
| 存储位置 | 主存(DRAM) | CPU内部(SRAM) |
| 访问速度 | 几十ns ~ 几百ns | 1~2个CPU周期 |
| 容量 | 可很大(MB级别) | 很小(几十~几百条) |
| 管理方式 | 内核维护 | 硬件自动管理 + 软件刷新 |
| 命中率影响 | — | TLB miss 导致页表遍历,性能骤降 |
TLB miss了怎么办?硬件自动去遍历页表,把结果填进TLB。这个过程叫页表遍历(Page Table Walk)。在x86上,硬件自动完成;在ARM上,也有硬件遍历引擎。但不管谁来做,TLB miss一次,代价就是几十到几百个CPU周期。
我个人的优化习惯:在嵌入式场景下,如果进程的TLB miss率超过1%,我会考虑启用大页(HugeTLB或透明大页)。比如一个2MB的大页,只需要一条TLB条目就能映射,而4KB小页需要512条。TLB命中率能提升不少。
1.4 内存映射:mmap到底干了什么?
内存映射(mmap)是Linux最核心的机制之一。它把文件或者设备的一部分,直接映射到进程的虚拟地址空间。之后进程读写这段内存,就相当于读写文件或设备。
mmap有两种类型:
- 文件映射:把磁盘文件映射到内存。比如动态库的加载,就是通过mmap把.so文件映射到进程地址空间。
- 匿名映射:不关联文件,用于进程间共享内存(MAP_ANONYMOUS | MAP_SHARED)或者malloc大块内存(MAP_ANONYMOUS | MAP_PRIVATE)。
mmap的精髓在于按需调页(Demand Paging)。mmap只是建立了VMA(虚拟内存区域),并没有分配物理页框。真正分配物理内存的时刻,是进程第一次访问这段虚拟地址时,触发缺页异常,内核才去分配物理页并建立页表。
// 一个典型的mmap用法:映射物理地址到用户空间(比如操作GPIO寄存器)
#define GPIO_BASE 0x01C20800
#define MAP_SIZE 0x1000
int fd = open("/dev/mem", O_RDWR | O_SYNC);
void *gpio_map = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, GPIO_BASE);
// 现在 gpio_map 指向的虚拟地址,操作它就是在操作物理地址 0x01C20800
// 注意:/dev/mem 映射的是物理内存,生产环境慎用,调试时很方便
1.5 伙伴系统:物理内存的“大管家”
物理内存怎么分配和回收?Linux内核用的是伙伴系统(Buddy System)。它的核心思想很简单:把物理页框按2的幂次方分组管理。
举个例子,系统有1024个页框(4MB,假设4KB一页):
- order 0: 1页(4KB)的块,有1024个
- order 1: 2页(8KB)的块,有512个
- order 2: 4页(16KB)的块,有256个
- ... 一直到 order 10: 1024页(4MB)的块,有1个
分配时,如果请求order 2(16KB),就从order 2的链表里取一个块。如果order 2空了,就从order 3借一个块,拆成两个order 2的块,一个分配出去,另一个放回order 2链表。回收时,检查它的“伙伴”(相邻的同大小块)是否空闲,如果是,就合并成更大的块。
关键点:伙伴系统解决了外碎片问题,但无法避免内碎片。比如你只申请1字节,伙伴系统也得给你一页(4KB)。这就是为什么需要slab分配器。
1.6 Slab分配器:小对象的“便利店”
内核里大量分配小对象,比如task_struct、inode、dentry。如果每次都通过伙伴系统分配一页,太浪费了。而且频繁分配释放会导致页框内碎片。
Slab分配器就是干这个的。它基于伙伴系统分配的物理页框,再细分成固定大小的对象缓存。每个slab缓存管理一种特定大小的对象。
举个例子,task_struct缓存:
// 查看系统slab信息
$ cat /proc/slabinfo
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab>
task_struct 512 600 2112 30 4
# 解释:每个task_struct对象2112字节,每个slab放30个对象,占用4个物理页框
Slab分配器的好处:
- 对象复用:对象释放后不归还伙伴系统,而是留在缓存里,下次分配直接取,速度快。
- 硬件缓存友好:同类型对象在物理上连续,CPU缓存命中率高。
- 着色(Coloring):通过偏移调整,让不同slab的对象落在不同的CPU缓存行,减少冲突miss。
我曾经遇到的性能问题:在某网络设备上,每秒创建/销毁大量socket连接,slab缓存频繁分配释放。我发现slab的partial链表太长,导致分配时遍历开销大。后来调整了slab的batch count和limit参数,性能提升了15%。
1.7 全景回顾:一条内存访问指令的完整旅程
咱们把整个流程串起来。假设用户态进程执行了一条指令:mov r0, [0x7f00a000]
- CPU发出虚拟地址 0x7f00a000,MMU开始工作。
- TLB查找:MMU先查TLB,看有没有0x7f00a000对应的物理地址。如果命中,直接得到物理地址,跳到第5步。
- TLB miss:硬件自动遍历当前进程的页表(多级页表,从PGD到PTE),找到对应的页表项。
- 检查页表项:
- 如果页表项有效(Present位=1),取出物理页框号,合成物理地址,同时把映射关系填入TLB。
- 如果页表项无效(缺页),触发缺页异常(Page Fault),内核介入。
- 缺页异常处理:
- 内核检查VMA,确认访问地址是否合法。
- 如果合法,通过伙伴系统分配一个物理页框。
- 如果是文件映射,从磁盘读取数据到该页框。
- 建立页表映射,设置Present位。
- 刷新TLB(如果需要)。
- 返回用户态,重新执行那条指令。
- 最终访问:物理地址送到内存控制器,从DRAM中读取数据,返回给CPU寄存器。
整个过程,从虚拟地址到物理地址,再到数据返回,通常只需要几十纳秒(如果TLB命中且Cache命中)。但如果缺页,可能需要几毫秒甚至更久(磁盘I/O)。
总结一下:虚拟内存提供抽象,MMU提供转换,页表存储映射,TLB加速查找,伙伴系统管理物理页框,slab分配器管理小对象,内存映射连接文件与内存。这七个组件,构成了Linux内存管理的完整全景。后面的章节,咱们会一个一个深入拆解。