4、内存管理与保护:虚拟内存、地址空间隔离、MMU配置、内存保护单元(MPU)的应用

内存管理,说白了就是给每个进程画个圈。圈里的事它自己说了算,圈外的事它碰都不能碰。在车机系统里,这个「圈」画得好不好,直接决定了系统会不会崩。

我做过一个项目,仪表盘和中控屏跑在同一个SoC上。中控屏的第三方应用崩了,结果仪表盘的指针开始乱跳。嗯,这就是内存隔离没做好。从那以后,我对内存保护这块格外上心。

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

虚拟内存是个很有意思的设计。每个进程都以为自己独占4GB空间(32位下),实际上物理内存可能只有512MB。谁在用、谁被换出去了,进程自己完全不知道。

为什么要这么干?三个原因:

  • 隔离:进程A的地址0x1000和进程B的地址0x1000,物理上根本不是同一个地方。A崩了不会影响B。
  • 简化:程序员不用管物理内存碎片,直接连续地址写代码就行。
  • 保护:内核空间用户态不能碰,用户空间互相也不能碰。

在QNX里,虚拟内存是通过mmapmunmap来管理的。我个人习惯在初始化阶段就把关键内存映射好,运行时尽量少动页表。

核心要点:虚拟内存不是「假」的,它是通过MMU硬件翻译出来的「真」隔离。每个进程的页表都是独立的。

4.2 地址空间隔离:车机系统的生命线

地址空间隔离,就是让进程之间互相看不见。在QNX里,每个进程都有自己的地址空间,内核通过MMU切换页表来实现上下文切换。

我遇到过一个问题:一个后台服务因为内存越界,把另一个服务的堆给踩了。查了两天才发现,原来这两个服务跑在同一个进程里。后来我强制要求:不同安全等级的功能,必须分进程部署

地址空间隔离的层级:

层级 描述 典型场景
进程级 每个进程独立4GB空间 仪表盘 vs 中控屏
线程级 同进程内线程共享地址空间 音频解码线程
分区级 通过QNX分区技术隔离 安全域 vs 非安全域

你想想看,如果仪表盘的渲染进程和中控屏的导航进程共享地址空间,导航崩了,仪表盘也跟着黑屏——这谁敢开?

4.3 MMU配置:硬件翻译官

MMU(内存管理单元)是CPU里的硬件模块。它的工作很简单:把虚拟地址翻译成物理地址。但配置起来,细节很多。

QNX的MMU配置通常在系统启动时由内核完成。但作为应用开发者,你也能通过mmap的flag来控制映射属性:

// 映射一段物理内存到用户空间
// 注意:车机系统里慎用物理地址映射
uintptr_t phys_addr = 0xA0000000;
size_t size = 0x1000;

void *virt_addr = mmap(
    NULL,
    size,
    PROT_READ | PROT_WRITE,  // 读写权限
    MAP_SHARED | MAP_PHYS,   // 物理映射
    NOFD,
    phys_addr
);

if (virt_addr == MAP_FAILED) {
    // 处理错误
}

这里有个坑:MAP_PHYS映射会绕过虚拟内存保护。我曾经在项目里用它来访问GPU寄存器,结果一个野指针直接写坏了显示控制器的配置。嗯,后来我加了一层封装,所有物理映射都走一个统一接口,方便审计。

警告:不要在生产代码里随意使用MAP_PHYS。它破坏了虚拟内存的保护机制。如果必须用,请确保映射范围最小化,并且加上访问权限检查。

4.4 内存保护单元(MPU):轻量级守护者

MPU和MMU不一样。MMU做地址翻译,MPU只做权限检查。在QNX里,MPU通常用在没有MMU的微控制器上,或者作为MMU的补充。

我记得有个项目用的是Cortex-R系列芯片,没有MMU。所有代码跑在物理地址上,一个指针越界就能把整个系统搞死。后来我启用了MPU,给关键任务划了保护区:

  • 代码区:只读、可执行
  • 数据区:读写、不可执行
  • 外设区:按需配置、强顺序访问

配置MPU的代码大概长这样:

// 伪代码:配置MPU区域
// 区域0:代码区 0x00000000 - 0x000FFFFF
MPU->RBAR = 0x00000000 | REGION_0;
MPU->RASR = (READ_ONLY | EXEC_ALLOW | SIZE_1MB);

// 区域1:数据区 0x00100000 - 0x001FFFFF
MPU->RBAR = 0x00100000 | REGION_1;
MPU->RASR = (READ_WRITE | EXEC_NEVER | SIZE_1MB);

配置完MPU后,任何非法访问都会触发异常。这比裸奔安全多了。

个人经验:MPU的区域数量有限(通常8-16个)。我建议把最关键的几个区域先配好,比如中断向量表、栈、关键数据。剩下的区域可以合并成一个大区,权限设严一点。

4.5 避坑指南:我踩过的那些坑

做内存保护这么多年,我总结了几条血泪教训:

  • 别信默认配置:QNX默认的MMU配置可能不够严格。我建议在启动脚本里显式设置每个进程的地址空间范围。
  • 栈溢出是隐形杀手:MMU能保护堆,但栈溢出经常被忽略。我习惯在每个线程栈的末尾放一个保护页(guard page),溢出就触发段错误。
  • DMA绕过MMU:DMA控制器直接访问物理内存,不经过MMU。如果DMA缓冲区被破坏,MMU也拦不住。解决方案是:DMA缓冲区用物理连续内存,并且只给DMA控制器最小权限。
  • 多核下的TLB一致性:修改页表后,要确保所有核的TLB都失效了。QNX的CacheFlush函数可以帮忙,但别忘了调用。

我曾经在一个项目里,因为忘了刷新TLB,导致一个核看到的是旧页表,另一个核看到的是新页表。两个核同时访问同一个物理地址,数据全乱了。查了三天才找到原因。

4.6 总结:内存保护不是可选项

在车机系统里,内存保护不是「锦上添花」,而是「雪中送炭」。没有它,一个第三方应用的bug就能让仪表盘黑屏、让刹车系统失灵。

我的建议是:

  1. 能用MMU就用MMU,别省那点性能开销
  2. 没有MMU的芯片,必须配MPU
  3. 关键数据要加保护页,宁可多占点内存
  4. DMA和MMU要配合好,别留后门

嗯,内存管理这块就聊到这儿。下一章我们聊聊进程间通信的安全设计,那又是另一个有意思的话题。