1. 内存管理全景:Linux内存管理子系统概览
大家好,我是你们的讲师。今天咱们来聊聊Linux内存管理。说实话,内存管理是整个内核里最核心、也最容易让人头疼的模块之一。我当年刚接触内核时,就被各种术语绕得晕头转向——什么页表、TLB、缺页异常、伙伴系统……但后来我发现,只要把几个关键概念理清楚,整个体系就豁然开朗了。
这一节,咱们先搭个框架。说白了,就是让你脑子里先有一张“内存地图”。后面再深入细节时,你就知道每个知识点该往哪儿放了。
1.1 物理内存与虚拟内存:两个世界的故事
先问个问题:你写程序时,用到的内存地址是真实的吗?
答案是:不是。你写的每个地址,都是虚拟地址。CPU看到的、进程看到的,全是虚拟地址。真正的物理内存,藏在背后,由内核统一管理。
为什么要搞这么复杂?我举个例子。假设你的系统只有512MB物理内存,但你要运行一个需要1GB内存的程序。如果没有虚拟内存,这程序根本跑不起来。但有了虚拟内存,内核可以把暂时不用的数据换到磁盘上,让程序以为自己有1GB的连续空间。
说白了,虚拟内存就是给每个进程画了个“大饼”——每个进程都以为自己独占整个地址空间(32位下是4GB,64位下更大)。而物理内存呢,是那个真正分饼的人。
核心要点:
- 物理内存:硬件上实实在在的RAM芯片,容量有限
- 虚拟内存:每个进程看到的独立地址空间,由内核+MMU共同实现
- 映射关系:虚拟地址 → 物理地址,由页表维护
我在项目中遇到过一件事:有个同事调试一个内存泄漏问题,怎么都找不到原因。后来发现,他一直在打印虚拟地址,但物理内存的占用却在疯涨。这就是没搞清楚虚拟和物理的区别。你想想看,虚拟地址只是“门牌号”,物理内存才是“房子”本身。
3.2 MMU:那个默默工作的硬件管家
MMU,全称Memory Management Unit,内存管理单元。它是CPU内部的一个硬件模块,专门负责地址转换。
它的工作流程,我简单描述一下:
- CPU执行指令,访问一个虚拟地址
- MMU截获这个地址,去查页表(Page Table)
- 页表里记录了虚拟页到物理页的映射关系
- 如果找到了映射,MMU直接转换成物理地址,访问内存
- 如果没找到,触发缺页异常(Page Fault),内核介入处理
嗯,这里要注意:MMU查页表不是每次都去内存里翻。它内部有个小缓存叫TLB(Translation Lookaside Buffer),专门缓存最近用过的地址映射。我习惯把TLB比作“快递柜”——常用的地址直接取,不用每次都去仓库翻。
个人经验:在嵌入式开发中,TLB miss对性能的影响非常大。我曾经在一个网络设备驱动中,因为频繁切换进程导致TLB刷来刷去,性能直接掉了30%。后来改成使用大页(Huge Pages),TLB命中率上去了,问题才解决。
1.3 页表:地址转换的“字典”
页表是MMU工作的核心数据结构。它本质上是一个多级索引的表格。
以32位ARM为例,虚拟地址被分成几段:
- 一级页表索引(PGD)
- 二级页表索引(PMD)
- 三级页表索引(PTE)
- 页内偏移
为什么要搞多级?直接一张大表不行吗?
你想想看,32位地址空间有4GB,如果按4KB一页,需要1M个页表项。每个页表项4字节,光页表就要4MB。每个进程都来4MB,系统根本扛不住。多级页表的好处是:按需分配。进程只用了一小部分地址空间,那只需要创建对应的页表层级,其他层级可以留空。
// 一个简化的页表遍历示例(伪代码)
unsigned long virt_to_phys(pgd_t *pgd, unsigned long virt_addr) {
unsigned long pgd_idx = virt_addr >> 22; // 一级索引
unsigned long pmd_idx = (virt_addr >> 12) & 0x3FF; // 二级索引
unsigned long offset = virt_addr & 0xFFF; // 页内偏移
pgd_t *pgd_entry = pgd + pgd_idx;
pmd_t *pmd_entry = pmd_offset(pgd_entry, virt_addr);
pte_t *pte_entry = pte_offset(pmd_entry, virt_addr);
return (pte_val(*pte_entry) & PAGE_MASK) | offset;
}
这段代码看着复杂,其实逻辑很简单:一级一级往下查,最后拿到物理页框号,加上偏移量,就是物理地址。
避坑指南:我曾经在移植内核时,发现某个外设的DMA总是访问到错误的内存。查了两天才发现,是页表映射时没有设置“缓存属性”。对于DMA内存,必须设置为“非缓存”或“写透”模式,否则CPU缓存和DMA看到的数据不一致。嗯,这个坑我踩过,你们别踩了。
1.4 缺页异常:内核的“救火队员”
当MMU查页表发现没有映射时,就会触发缺页异常。这时候,内核的缺页异常处理函数就登场了。
缺页异常分几种情况:
| 类型 | 原因 | 处理方式 |
|---|---|---|
| 有效缺页 | 页被换出到磁盘 | 从swap分区读回内存 |
| 写时复制 | fork后父子进程共享页面 | 复制页面,建立新映射 |
| 无效访问 | 访问了未映射的地址 | 发送SIGSEGV信号,杀死进程 |
我刚开始学内核时,总觉得缺页异常是个“错误”。后来才明白,它其实是虚拟内存的核心机制。没有缺页异常,就没有按需加载,就没有写时复制,就没有内存的灵活管理。
说白了,缺页异常不是bug,是feature。
1.5 内存管理子系统全景图
好了,咱们把前面讲的串起来。Linux内存管理子系统,大致分这么几层:
- 用户空间:malloc/free,brk,mmap——这些是应用程序看到的接口
- 内核空间:kmalloc,vmalloc,slab分配器——驱动开发者常用的
- 页分配器:伙伴系统(Buddy System)——管理物理页的分配与回收
- 虚拟内存层:VMA(虚拟内存区域),页表管理——维护进程地址空间
- 硬件层:MMU,TLB,物理内存——真正的执行者
每一层都有自己职责,但又紧密配合。比如你调用malloc,glibc会通过brk或mmap向内核申请内存;内核在VMA中创建映射,分配物理页,建立页表;MMU在运行时负责地址转换;如果物理内存不够,内核还会启动回收机制。
一句话总结:虚拟内存让每个进程都觉得“我有无限内存”,MMU负责把这种“幻觉”变成现实,而内核在背后默默处理所有脏活累活。
我个人习惯,每学一个新模块,先画一张全景图。把各个组件的关系理清楚,再深入细节就不会迷路。这一节的内容,就是你的第一张图。后面咱们会逐个深入:伙伴系统、slab分配器、VMA管理、缺页异常处理……每一个都是驱动开发中的硬骨头。
好,这一节就到这儿。记住这张全景图,后面咱们一步步往里填细节。