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 物理内存与虚拟内存的映射关系

最后,咱们把整个流程串起来:

  1. 进程访问虚拟地址0x7F00_1234
  2. MMU查TLB,没命中
  3. MMU走4级页表,找到PTE
  4. PTE里存着物理页帧号,比如PFN=0xABC00
  5. 物理地址 = 0xABC00 * 0x1000 + 0x234 = 0xABC0_0234
  6. MMU把物理地址发到内存总线,读取数据
  7. 同时,MMU把这次翻译结果缓存到TLB

整个过程在纳秒级别完成。你想想看,你写一行a = b + c,背后可能触发几十次这样的翻译。没有MMU,现代操作系统根本跑不起来。

个人建议:如果你在调内存性能问题,先看看/proc/meminfo里的PageTables字段。这个值表示所有进程的页表占了多少内存。如果这个值特别大(比如几百MB),说明进程太多或者虚拟地址空间碎片化严重,可以考虑调整vm.max_map_count

好了,这一节的内容就到这儿。物理页帧管理、虚拟地址空间布局、MMU与页表机制,这三者是内存管理的基石。下一节,咱们会讲页面分配与回收的详细流程——到时候你会看到,今天讲的这些概念全都会用上。