4. 内存管理:虚拟内存、物理内存、页表映射、内存分配与回收机制

内存管理,说白了就是操作系统怎么管好这块「地盘」。我刚开始接触鸿蒙内核时,第一感觉就是:这玩意儿比 Linux 的某些设计要「轻」得多,但该有的硬骨头一块不少。今天咱们就聊聊虚拟内存、物理内存、页表映射,还有分配与回收这些核心话题。

4.1 虚拟内存:给每个进程一个「假」世界

你想想看,如果所有进程都直接操作物理内存,那不乱套了?A 进程不小心写了个野指针,直接把 B 进程的数据给覆盖了。所以鸿蒙内核给每个进程都画了一个「大饼」——虚拟地址空间。

每个进程看到的地址,都是从 0 开始的连续空间。但实际上,这些地址是假的。鸿蒙内核在背后偷偷做了一件事:把虚拟地址映射到真正的物理地址上。我习惯把这个过程叫做「地址翻译」。

虚拟内存的好处很明显:

  • 隔离性:进程之间互不干扰。你写你的,我写我的。
  • 安全性:用户态程序没法直接碰内核空间。
  • 灵活性:物理内存不够?没关系,换页出去就行。

核心要点:鸿蒙内核的虚拟地址空间布局,用户空间和内核空间是严格分开的。用户空间从 0x0 开始,内核空间通常在高地址区域。我个人建议你在写驱动时,一定要搞清楚当前代码跑在哪个特权级,否则一个非法访问就能让你蓝屏。

4.2 物理内存:真正的「硬通货」

虚拟地址再花哨,最终还是要落到物理内存上。鸿蒙内核对物理内存的管理,用的是「页」的概念。一页通常是 4KB,也有大页(2MB、1GB)的选项。

我在项目中遇到过一个问题:某个多媒体驱动申请了一大块连续物理内存,结果系统跑着跑着就 OOM 了。后来一查,是内存碎片化太严重。嗯,这里要注意:鸿蒙内核的物理内存分配器,默认用的是伙伴系统(Buddy System)。

伙伴系统的核心思想很简单:把内存按 2 的幂次分成块。比如 4KB、8KB、16KB……你申请 5KB,它给你 8KB 的块。这样分配快,回收也快,但会有内部碎片。

阶数 块大小 典型用途
0 4KB 小对象、页表
1 8KB 小缓冲区
4 64KB 驱动 DMA 缓冲区
9 2MB 大页映射、多媒体数据

避坑指南:我曾经在调试一个摄像头驱动时,发现申请 1MB 连续内存总是失败。后来发现是伙伴系统的最高阶数配置得太小。鸿蒙内核允许你通过设备树或 Kconfig 调整 MAX_ORDER,但别调太大,否则内存浪费严重。

4.3 页表映射:地址翻译的「地图」

虚拟地址怎么变成物理地址?靠页表。鸿蒙内核用的是多级页表,ARM64 架构下通常是 4 级页表(PGD、PUD、PMD、PTE)。

你想想看,如果只用一级页表,那得有多大?每个进程 4GB 虚拟空间,每页 4KB,就需要 100 万个页表项。每个页表项 8 字节,光页表就得 8MB。多级页表的好处是:按需分配,用多少建多少。

页表项里存了什么?除了物理页帧号(PFN),还有各种权限位:

  • 可读/可写/可执行:控制访问权限
  • 用户/内核:区分特权级
  • 脏位/访问位:用于换页和回收
  • 全局位:标记是否所有进程共享
// 鸿蒙内核中页表项的结构(简化版)
typedef struct {
    uint64_t pfn : 40;      // 物理页帧号
    uint64_t reserved : 10; // 保留位
    uint64_t global : 1;    // 全局页
    uint64_t dirty : 1;     // 脏位
    uint64_t accessed : 1;  // 访问位
    uint64_t user : 1;      // 用户态可访问
    uint64_t rw : 1;        // 可读写
    uint64_t present : 1;   // 页是否存在
} pte_t;

我习惯在调试内存问题时,直接打印页表内容。鸿蒙内核提供了 dump_page_table() 接口,但需要开启调试选项。有一次我发现某个驱动映射的页表权限配错了,把只读配成了可执行,结果被 SMAP/SMEP 机制给拦下来了。嗯,这种问题查起来很费劲。

4.4 内存分配与回收:谁申请,谁释放

内存分配,说白了就是「谁申请,谁释放」。但现实往往没那么美好。鸿蒙内核提供了多种分配接口:

  • kmalloc:分配小于一页的小块内存,基于 slab 分配器。
  • vmalloc:分配虚拟地址连续、物理地址不连续的大块内存。
  • dma_alloc_coherent:分配 DMA 用的连续物理内存。

我在项目中遇到过最头疼的问题就是内存泄漏。驱动加载后,每次调用某个 ioctl 就申请一块内存,但忘记释放了。系统跑个几天,内存就耗光了。鸿蒙内核有 kmemleak 机制可以检测,但默认没开。

警告:千万不要在中断上下文里调用 kmalloc(GFP_KERNEL)!因为 GFP_KERNEL 可能会触发睡眠等待。中断上下文里只能用 GFP_ATOMIC。我曾经因为这个 bug 导致系统随机死机,查了整整两天才找到原因。

内存回收机制,鸿蒙内核用的是「页面回收」+「OOM Killer」。当内存紧张时,内核会尝试回收:

  1. 先回收文件缓存页(干净页直接丢,脏页写回磁盘)。
  2. 再回收匿名页(换出到 swap 分区)。
  3. 如果还不行,就触发 OOM Killer,杀掉最「肥」的进程。

你想想看,OOM Killer 选进程是有算法的。它算一个「badness」分数,分数最高的进程被干掉。我建议你在写关键服务时,设置 oom_score_adj 为负值,避免被误杀。

4.5 实战经验:一次内存踩踏的排查

最后分享一个我亲身经历的案例。有个网络驱动,跑着跑着就报「kernel panic」,错误信息指向了 sk_buff 结构体被写坏。

我一开始怀疑是 DMA 地址错了,但检查了好几遍都没问题。后来用 slabinfo 查看 slab 分配器状态,发现 sk_buff 缓存池里有很多「红色区域」被踩了。嗯,红色区域是鸿蒙内核用来检测越界写的一种机制。

最终定位到:驱动在接收数据包时,计算缓冲区长度少算了 4 字节,导致后续数据写到了下一个 sk_buff 的头部。修复方法很简单:把长度计算加 4 就行。但排查过程花了我整整一个周末。

我的建议:写驱动时,一定要用 memset 初始化所有分配的内存,并且多用 BUG_ONWARN_ON 做断言检查。鸿蒙内核的 KASAN(内核地址消毒剂)能帮你自动检测越界和 use-after-free,强烈建议在开发阶段开启。

好了,内存管理这块内容不少,但核心就是三件事:虚拟地址怎么来、物理地址怎么管、映射怎么建。你把这些搞清楚了,写驱动时遇到的内存问题基本都能迎刃而解。