2、物理内存与虚拟内存:物理页帧管理、虚拟地址空间布局、MMU与页表机制
好,咱们进入第二个大块。说实话,搞Android内核的人,如果搞不懂物理内存和虚拟内存的关系,那基本就是在瞎调。我见过太多人把OOM killer的阈值改来改去,结果问题根本没解决——因为他们连内存是怎么映射的都没搞明白。
这一节,咱们就把地基打牢。说白了,就是三件事:物理内存怎么管、虚拟地址怎么布局、MMU怎么翻译。
2.1 物理页帧管理:内核的“内存仓库”
物理内存,就是板子上焊的那几颗DDR颗粒。内核把它切成一个个4KB大小的块,每个块叫一个页帧(Page Frame)。嗯,4KB是默认大小,ARM64上也支持64KB的大页,但咱们先按4KB讲。
内核怎么管理这些页帧?靠一个叫struct page的结构体。每个物理页帧对应一个struct page,几百万个页帧就是几百万个这样的结构体,放在一个叫mem_map的全局数组里。
核心数据结构:struct page
// 简化版,实际有几十个字段
struct page {
unsigned long flags; // 页的状态:脏页、锁定、正在回写...
atomic_t _refcount; // 引用计数,为0时可回收
struct address_space *mapping; // 映射到的文件(如果是文件页)
pgoff_t index; // 在文件内的偏移
struct list_head lru; // LRU链表节点,用于页面回收
void *virtual; // 内核虚拟地址(如果已映射)
};
你想想看,每个物理页帧都有这么一套“身份证”。flags告诉你这页是干嘛的——是匿名页(进程堆栈)还是文件页(代码段、mmap文件)。_refcount是引用计数,内核里谁在用这页,就加一。当引用计数降到0,这页就可以被回收了。
我在项目中遇到过一个问题:某个进程的内存一直降不下来,查了半天,发现是内核驱动里有人拿了get_page()之后忘了put_page(),导致引用计数永远不为0。嗯,这种内存泄漏最难查,因为工具上看不到谁在引用。
个人经验:排查物理页泄漏,我习惯用/proc/pagetypeinfo和/proc/buddyinfo。如果某个order的连续块越来越少,大概率是有碎片问题或者泄漏。
2.2 虚拟地址空间布局:每个进程的“幻觉”
每个进程都以为自己拥有整个地址空间——32位下是4GB,64位下是256TB(ARM64)。但这当然是假的。虚拟地址只是“地图”,真正的物理内存是“土地”。
咱们看看Linux在ARM64上的虚拟地址空间布局。以48位虚拟地址为例:
| 区域 | 地址范围 | 用途 |
|---|---|---|
| 用户空间 | 0x0000 0000 0000 0000 - 0x0000 FFFF FFFF FFFF | 进程代码、堆、栈、mmap |
| 内核空间 | 0xFFFF 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF | 内核代码、直接映射区、vmalloc |
| 直接映射区 | 0xFFFF 8000 0000 0000 - ... | 线性映射所有物理内存 |
| vmalloc区 | ... - 0xFFFF FFFF FFFF FFFF | 非连续内存分配 |
注意看,用户空间和内核空间之间有一条巨大的“鸿沟”——地址范围完全不重叠。用户态程序永远看不到内核地址,这就是保护机制。
为什么会这样设计?说白了,就是为了隔离。用户进程崩溃了,不能把内核也带崩。我刚开始学内核时,总觉得这多浪费地址空间啊,后来才明白,安全比什么都重要。
2.3 MMU与页表机制:地址翻译的“流水线”
MMU(内存管理单元)是CPU内部的一个硬件模块。它的工作很简单:把虚拟地址翻译成物理地址。但翻译过程可不简单。
ARM64上用的是4级页表:
- PGD(Page Global Directory):顶级页表,每个进程一个
- PUD(Page Upper Directory):第二级
- PMD(Page Middle Directory):第三级
- PTE(Page Table Entry):最后一级,指向物理页帧
翻译过程是这样的:虚拟地址被拆成5段——4个索引+1个页内偏移。MMU拿着PGD的基地址,一级一级往下查,最后找到物理页帧号,加上偏移,就得到了物理地址。
举个例子:虚拟地址0x7F00_1234,在4KB页、4级页表下:
- Bit[47:39] → PGD索引
- Bit[38:30] → PUD索引
- Bit[29:21] → PMD索引
- Bit[20:12] → PTE索引
- Bit[11:0] → 页内偏移
你可能会问:每次访问内存都要查4次表,那得多慢?嗯,这里就有TLB(Translation Lookaside Buffer)的用武之地了。TLB是MMU内部的一个高速缓存,专门缓存最近用过的页表项。命中TLB的话,一次翻译就搞定了。
避坑指南:我曾经在调优一个Android游戏时,发现频繁的上下文切换导致TLB被刷了个干净,每次切换回来都要重新查页表,性能直接掉30%。解决方案是启用ASID(地址空间标识符),让TLB能区分不同进程的条目,不用每次都全刷。
2.4 页表项里藏了什么?
一个PTE(页表项)通常是8字节(64位),里面包含的信息远不止物理地址:
// ARM64 PTE格式(简化)
Bit[47:12] : 物理页帧号(PFN)
Bit[11] : NX(不可执行)
Bit[10] : AF(访问标志)
Bit[7:6] : 权限(AP[2:1])
Bit[5] : 脏页标志(DBM)
Bit[4] : 可共享(SH)
Bit[3] : 可缓存(C)
Bit[2] : 可缓存(B)
Bit[1] : 可写(PXN)
Bit[0] : 有效位(Present)
注意那个脏页标志。当进程写了一个页面,MMU会自动把脏位置1。内核在回收页面时,如果发现脏位是1,就得先把内容写回磁盘(swap或文件),否则数据就丢了。
我在项目中遇到过一个问题:某个进程的匿名页明明没写操作,但脏位总是被置1。查到最后,发现是驱动里用kmap()映射了物理页,然后直接写内存,绕过了MMU的脏位跟踪。嗯,这种问题只能靠代码审查来发现。
2.5 物理内存与虚拟内存的映射关系
最后,咱们把整个流程串起来:
- 进程访问虚拟地址0x7F00_1234
- MMU查TLB,没命中
- MMU走4级页表,找到PTE
- PTE里存着物理页帧号,比如PFN=0xABC00
- 物理地址 = 0xABC00 * 0x1000 + 0x234 = 0xABC0_0234
- MMU把物理地址发到内存总线,读取数据
- 同时,MMU把这次翻译结果缓存到TLB
整个过程在纳秒级别完成。你想想看,你写一行a = b + c,背后可能触发几十次这样的翻译。没有MMU,现代操作系统根本跑不起来。
个人建议:如果你在调内存性能问题,先看看/proc/meminfo里的PageTables字段。这个值表示所有进程的页表占了多少内存。如果这个值特别大(比如几百MB),说明进程太多或者虚拟地址空间碎片化严重,可以考虑调整vm.max_map_count。
好了,这一节的内容就到这儿。物理页帧管理、虚拟地址空间布局、MMU与页表机制,这三者是内存管理的基石。下一节,咱们会讲页面分配与回收的详细流程——到时候你会看到,今天讲的这些概念全都会用上。