3. VxWorks虚拟内存映射:MMU初始化流程、页表建立、虚实地址转换机制

好,咱们今天聊点硬核的——MMU。说实话,我早年刚接触VxWorks时,觉得MMU就是个可有可无的东西。直到有一次,一个野指针把整个系统的异常向量表给踩了,系统直接挂死,连个错误日志都没留下。嗯,从那以后,我再也不敢轻视MMU了。

MMU这东西,说白了就是给CPU装了个「翻译官」。CPU发出的地址是虚拟地址,经过MMU翻译成物理地址,才能去访问内存。这个翻译过程,就是咱们今天要讲的核心。

3.1 MMU初始化流程

VxWorks的MMU初始化,我个人习惯把它分成三个阶段:准备阶段、建表阶段、使能阶段。咱们一个一个来看。

3.1.1 准备阶段

在MMU正式工作之前,你得先准备好「翻译手册」——也就是页表。页表放在哪?当然是内存里。但问题是,MMU还没开,你用的还是物理地址。

我建议你在系统启动的早期,在sysLib.c或者sysHwInit()里就把页表的物理地址固定下来。比如这样:

/* 页表基地址,我习惯放在SDRAM的起始位置之后 */
#define PAGE_TABLE_BASE    0x80200000
#define PAGE_TABLE_SIZE    0x00010000  /* 64KB,够用了 */

这里有个坑——页表必须对齐到16KB边界。为什么?因为ARM的TTBR(Translation Table Base Register)寄存器要求低14位为0。我曾经在这个问题上折腾了半天,最后发现是地址没对齐。

3.1.2 建表阶段

页表建立,我一般用VxWorks提供的vmLib库。但如果你想自己动手,也完全可行。咱们以ARMv7为例,它用的是二级页表结构。

第一级页表叫L1页表,每个条目覆盖1MB空间。第二级叫L2页表,每个条目覆盖4KB空间。结构是这样的:

级别 条目大小 覆盖范围 页表大小
L1 4字节 1MB 16KB(4096个条目)
L2 4字节 4KB 1KB(256个条目)

初始化时,我通常先把整个L1页表清零,然后逐个建立映射。代码大概长这样:

void mmuInit(void)
{
    UINT32 *pL1Table = (UINT32 *)PAGE_TABLE_BASE;
    UINT32 va, pa;
    
    /* 先清零L1页表 */
    bzero(pL1Table, 16 * 1024);
    
    /* 建立1:1映射:虚拟地址 = 物理地址 */
    for (va = 0; va < 0x20000000; va += 0x100000) {
        pa = va;
        pL1Table[va >> 20] = (pa & 0xFFF00000) | 0x0C02;
    }
    
    /* 使能MMU */
    armMmuOn();
}

你可能会问:为什么要做1:1映射?说白了,就是让系统先跑起来。等系统稳定了,再根据需要调整映射关系。

3.1.3 使能阶段

使能MMU这一步,不同架构差别很大。ARM上你得操作CP15协处理器,x86上你得写CR0寄存器。我以ARM为例:

void armMmuOn(void)
{
    UINT32 ttbr = PAGE_TABLE_BASE;
    UINT32 sctlr;
    
    /* 设置页表基地址 */
    __asm__ volatile("mcr p15, 0, %0, c2, c0, 0" : : "r"(ttbr));
    
    /* 使能MMU */
    __asm__ volatile("mrc p15, 0, %0, c1, c0, 0" : "=r"(sctlr));
    sctlr |= 0x1;  /* 设置M位 */
    __asm__ volatile("mcr p15, 0, %0, c1, c0, 0" : : "r"(sctlr));
}

注意!使能MMU的瞬间,所有地址都会变成虚拟地址。如果你的代码还在物理地址空间里跑,那直接就崩了。我建议你在使能MMU之前,确保代码段已经映射好了。

3.2 页表建立详解

页表建立,说白了就是填表。但填表也有讲究,不是随便填的。

3.2.1 段映射 vs 页映射

ARMv7支持两种映射方式:段映射(Section)和页映射(Page)。

  • 段映射:L1条目直接指向1MB的物理内存块。优点是快,只需要一次查表。缺点是粒度粗,1MB起步。
  • 页映射:L1条目指向L2页表,L2条目再指向4KB的物理页。优点是粒度细,可以精细控制。缺点是慢,需要两次查表。

我个人的经验是:代码段、数据段这种大块连续的内存,用段映射。外设寄存器、共享内存这种需要精细权限控制的,用页映射。

3.2.2 权限与属性设置

每个页表条目里,除了地址信息,还包含了访问权限和缓存属性。比如:

/* 段映射条目格式(ARMv7) */
#define SECTION_AP_RW_RW    (0x0 << 10)  /* 特权/用户均可读写 */
#define SECTION_AP_RW_NA    (0x1 << 10)  /* 仅特权读写 */
#define SECTION_CACHEABLE   (0x1 << 3)   /* 可缓存 */
#define SECTION_BUFFERABLE  (0x1 << 2)   /* 可缓冲 */

这里有个容易踩的坑——外设寄存器绝对不能设置成Cacheable。否则你读寄存器时,可能读到的是Cache里的旧值。我曾经调试一个网卡驱动,死活收不到数据包,最后发现是MMU把寄存器区域给Cache了。

3.3 虚实地址转换机制

虚实地址转换,听起来高大上,其实原理很简单。CPU发出一个虚拟地址,MMU把它拆成三部分:

  • L1索引:虚拟地址的[31:20]位,共12位,用来查L1页表
  • L2索引:虚拟地址的[19:12]位,共8位,用来查L2页表(仅页映射时使用)
  • 页内偏移:虚拟地址的[11:0]位,共12位,直接作为物理地址的低12位

举个例子,假设虚拟地址是0x12345678

L1索引 = 0x12345678 >> 20 = 0x123
L2索引 = (0x12345678 >> 12) & 0xFF = 0x45
页内偏移 = 0x12345678 & 0xFFF = 0x678

如果是段映射,MMU直接拿L1索引去查表,得到物理基地址,然后加上页内偏移,就得到了物理地址。如果是页映射,MMU先查L1表得到L2页表的基地址,再查L2表得到物理页基地址,最后加上页内偏移。

关键点:整个转换过程是硬件完成的,软件只需要把页表填好。但页表填错了,系统就崩了。所以调试MMU时,我建议你先用1:1映射,确认系统能跑,再逐步调整。

3.4 VxWorks中的MMU操作接口

VxWorks提供了一套完整的MMU操作接口,封装在vmLibmmuLib里。常用的有:

函数 功能 我常用的场景
vmMap() 建立虚拟地址到物理地址的映射 映射外设寄存器
vmUnmap() 解除映射 释放不再使用的内存
vmSetProt() 修改页的访问权限 设置代码段为只读
vmState() 查询页的状态 调试时查看映射关系

举个例子,映射一个外设寄存器:

void mapUartReg(void)
{
    STATUS status;
    void *pVirtAddr;
    
    /* 把UART的物理地址0x10000000映射到虚拟地址0xA0000000 */
    status = vmMap(
        0xA0000000,     /* 虚拟地址 */
        0x10000000,     /* 物理地址 */
        0x1000,         /* 大小:4KB */
        VM_STATE_MASK_VALID | VM_STATE_MASK_WRITABLE,
        VM_STATE_VALID | VM_STATE_WRITABLE
    );
    
    if (status != OK) {
        printf("映射失败!\n");
    }
}

小技巧:调试MMU问题时,可以用vmState()查看当前页表的状态。我曾经用它发现了一个映射重叠的问题——两个虚拟地址映射到了同一个物理地址,导致数据互相覆盖。

3.5 避坑指南

做MMU开发这么多年,我踩过的坑不少。总结几条经验:

  • TLB一致性:修改页表后,一定要刷新TLB。否则MMU可能还在用旧的缓存。ARM上用mcr p15, 0, %0, c8, c7, 0,x86上用invlpg指令。
  • 页表位置:页表本身必须在非Cacheable的内存里。否则你写页表时,数据可能还在Cache里,MMU读到的却是旧值。
  • 中断处理:使能MMU后,中断向量表也要映射到虚拟地址空间。我见过有人忘了这茬,一开中断就死机。
  • 调试技巧:如果系统在MMU使能后崩溃,先检查页表地址对不对,再检查映射有没有遗漏。我一般会在使能MMU前,打印出页表的前几个条目,确认没问题再开。

嗯,MMU这块内容确实不少。但只要你理解了虚实地址转换的本质,剩下的就是填表、查表、刷TLB这三板斧。多练几次,你也能成为MMU高手。