虚拟内存的另一条路:在没有MMU的芯片上“造”一个页表系统 🧠💡

你有没有想过,当我们在谈“虚拟内存”、“页表”、“缺页异常”的时候,其实大多数嵌入式设备压根不支持这些高大上的机制?比如我们手里的 ESP32-S3 —— 它跑的是 FreeRTOS,能做 Wi-Fi、语音识别、边缘计算,但它 没有 AARCH64 那样的硬件 MMU ,也没有自动遍历页表的能力。

那是不是就意味着它只能用原始的线性映射、全局变量满天飞、任务之间随便踩内存?

当然不是!✨
哪怕是一块没有原生页表机制的芯片,我们也可以 通过软件层“模拟”出一套类 AARCH64 的内存管理系统 。这不仅能让代码更安全、更模块化,还能为未来向复杂操作系统迁移打下基础。

今天我们就来干一件“反向工程”的事: 在一个不支持页表的架构上,亲手搭建一个多级页表框架,并让它真正运转起来。


从一场中断说起:为什么我们需要虚拟内存?⚡️

想象一下这个场景:

你的 ESP32-S3 正在运行两个任务:
- 一个是音频采集任务,负责从麦克风读数据;
- 另一个是网络上传任务,把音频发到云端。

突然,音频任务里有个指针越界了,写到了网络任务的堆栈区域……结果呢?程序没崩溃,但上传的数据变成了乱码。几天后客户投诉:“你们的产品说话像机器人!” 😵‍💫

这就是裸奔时代的典型问题: 所有任务共享同一片物理地址空间,谁都可以改任何地方。

而在现代操作系统中(比如 Linux),这种情况几乎不可能发生 —— 因为每个进程都有自己的 虚拟地址空间 。即使两个进程使用相同的虚拟地址(比如 0x8000_0000 ),它们指向的也是不同的物理页。这种隔离靠的就是 MMU + 页表机制

但在 ESP32-S3 上呢?默认情况下,一切都是平铺直叙的 32 位线性地址。你想搞隔离?得自己动手!

于是我们问自己一个问题:

💬 “能不能在 Xtensa LX7 这种非 AARCH64 架构上,实现类似 AARCH64 的页表行为?”

答案是: 完全可以,只要我们愿意付出一点性能代价。


先看看人家是怎么做的:AARCH64 的四级页表长什么样?📚

在进入“山寨模式”之前,先来看看标准选手怎么玩。

AARCH64 使用四级页表结构(L0 ~ L3),配合 4KB 页面大小时,虚拟地址被拆成这样:

| 15 bits |  9 bits  |  9 bits  |  9 bits  |  9 bits  | 12 bits |
   [VA]     L0索引     L1索引     L2索引     L3索引    页内偏移

起始地址由 TTBR0_EL1 寄存器指定根页目录位置,CPU 硬件自动完成页表遍历。如果命中 TLB 就快如闪电;如果不命中,就触发页表 walker 自动查找并填入 TLB。

同时,每项页表还携带丰富的元信息:
- 是否有效(Valid)
- 可读/可写/可执行(AP / UXN/PXN)
- 内存属性(Cache 策略、共享性等)

这一切都由硬件完成,开发者只需要配置好页表结构即可。

// 示例:设置 TCR_EL1 控制页表行为
tcr_el1 = (0b10 << 30) |        // T0SZ: 使用 48-bit 地址空间
          (0b10 << 16) |        // TG0: 4KB 页面粒度
          (0b11 << 10) |        // ORGN0: 回写缓存策略
          (0b11 << 8)  |        // IRGN0: 同上
          (0b11 << 0);           // DS: 禁用扩展页

整个过程对程序员透明,高效且强大。

但问题是:ESP32-S3 是 Xtensa LX7,不是 ARMv8-A。它没有 TTBR0_EL1 ,也没有 page walker,甚至连基本的页表遍历逻辑都没有。

所以——我们要自己造。🔧


ESP32-S3 的现实:没有 MMU,只有 IMMU/DMMU 和 MPU 🛠️

别急着失望。虽然 ESP32-S3 没有完整的 MMU,但它也不是完全赤手空拳。

它有哪些“武器”?

组件 功能说明
IMMU / DMMU Instruction/Data Memory Management Unit,本质是带 TLB 的地址重映射单元,主要用于将 Flash 映射到可执行区域
MPU Memory Protection Unit,提供最多 8 个区域的访问控制,可用于保护栈、内核空间等
Software-loaded TLB TLB 条目需手动插入,不能自动填充
ICache / DCache 支持缓存,但粒度较粗

这意味着我们可以做到:
- 手动建立虚拟 → 物理映射
- 实现权限检查(通过软件判断)
- 利用 TLB 提升访问速度(虽然是手动管理)

只是——一切都得靠软件驱动。


那它的内存布局到底是怎样的?🗺️

ESP32-S3 的物理存储分为几类关键区域:

区域 起始地址 类型 是否可执行 用途
IRAM 0x4000_0000 RAM ✅ 是 存放 ISR、高速函数
DRAM 0x3FC0_0000 RAM ❌ 否(默认) 全局变量、堆栈
DROM 0x3F40_0000 Flash 映射 ❌ 否 只读常量(rodata)
IROM 0x4200_0000 Flash 映射 ✅ 是(XIP) 主程序代码

其中 IROM 和 DROM 利用了 XIP(eXecute In Place)技术,允许直接从 Flash 执行代码,节省宝贵的 RAM。

但这带来一个问题:Flash 访问慢啊!怎么办?加 Cache!

于是 ESP32-S3 配备了:
- 32KB ICache :用于加速指令取指
- 32KB DCache :用于加速数据读写

不过 Cache 不是万能的。如果你用 DMA 传输数据,就必须小心缓存一致性问题。这也是为什么会有“非缓存别名区”。


内存别名:同一个物理地址,多个访问路径 🔄

ESP32-S3 支持内存别名机制。例如 DRAM 可以通过以下两种方式访问:

虚拟地址 Cache 状态 适用场景
0x3FC0_0000 Uncached DMA 缓冲区
0x4080_0000 Cached 普通变量、堆栈

这就相当于给了程序员选择权:你要性能还是确定性?

举个例子:

// 定义一个 DMA 安全的缓冲区(放在非缓存区)
uint8_t __attribute__((section(".dram_nocache"))) dma_buffer[512];

链接脚本会确保这段内存落在非缓存映射区,避免因 Cache 导致的数据撕裂或延迟。

这其实是某种意义上的“内存类型分离”,虽然不像 AARCH64 那样通过 MAIR_EL1 + 页表项显式编码,但也达到了类似效果。


TLB 是怎么工作的?手动加载才是王道!🧩

Xtensa 架构采用的是 Software-loaded TLB ,也就是说每次映射变更都要程序员亲自下场写寄存器。

来看一段典型的 ITLB 插入代码:

.text
.global load_itlb_entry
load_itlb_entry:
    movi    a2, 0x40000000         // 虚拟地址(代码段)
    movi    a3, 0x10000000         // 物理地址(Flash 映射)
    or      a3, a3, 0x1f           // 设置属性:有效+缓存+可执行
    itlbp   a2                     // 查询是否已存在
    itlba   a2, a3                 // 插入新条目
    isync                          // 同步流水线
    retw

看到没?连最简单的映射都要写五条汇编!而且每次上下文切换还得手动刷新 TLB,否则旧任务的映射会影响新任务。

这和 AARCH64 的“ASID + 自动维护”相比,简直是石器时代 😅

但我们不怕,因为—— 我们可以封装它!


开始造轮子:设计一个类 AARCH64 的软件页表系统 🛠️🔥

既然硬件不给力,那就靠软件补。我们的目标是:

✅ 实现虚拟地址到物理地址的动态映射
✅ 支持按需分页(Demand Paging)
✅ 提供读/写/执行权限控制
✅ 实现任务间内存隔离
✅ 尽可能兼容 AARCH64 的编程模型

听起来很宏大?没关系,我们一步步来。


第一步:定义多级页表结构 👷‍♂️

AARCH64 有四级页表,但我们是 32 位系统,没必要照搬。我们设计一个三级页表就够了:

Virtual Address: [9 bits][9 bits][9 bits][5 bits]
                   L1       L2       L3     offset

每一级对应一个页目录,共 512 项( 1<<9 ),每项 4 字节,正好占一页(4KB)。

数据结构如下:

#define PAGE_SHIFT      12
#define PTRS_PER_TABLE  (1 << 9)
#define LEVEL_BITS      9

typedef struct {
    uint32_t valid     : 1;
    uint32_t writable  : 1;
    uint32_t executable: 1;
    uint32_t user      : 1;
    uint32_t cacheable : 1;
    uint32_t accessed  : 1;
    uint32_t dirty     : 1;
    uint32_t reserved  : 1;
    uint32_t phy_page_base : 20;  // 支持最大 1GB RAM
} pte_t;

// 页表层级
typedef pte_t pgd_t[PTRS_PER_TABLE];   // L1: Page Global Directory
typedef pte_t pmd_t[PTRS_PER_TABLE];   // L2: Page Middle Directory
typedef pte_t pte_t_array[PTRS_PER_TABLE]; // L3: Page Table Entry Array

全局根目录:

pgd_t *kernel_pgd;  // 内核页表根

当你访问一个虚拟地址 vaddr 时,翻译流程如下:

pte_t* walk_page_table(uint32_t vaddr) {
    int l1_idx = (vaddr >> 21) & 0x1FF;
    int l2_idx = (vaddr >> 12) & 0x1FF;
    int l3_idx = (vaddr >> 12) & 0x1FF;

    if (!PTE_VALID(kernel_pgd[l1_idx])) return NULL;
    pmd_t *pmd = (pmd_t*)PTE_ADDR(kernel_pgd[l1_idx]);
    if (!PTE_VALID(pmd[l2_idx])) return NULL;
    pte_t_array *pte_arr = (pte_t_array*)PTE_ADDR(pmd[l2_idx]);
    return &pte_arr[l3_idx];
}

虽然每次要三次内存访问,有点慢,但至少结构清晰,易于调试。


第二步:页面大小选多大合适?⚖️

常见选项:4KB、16KB、64KB。

我们选择 4KB ,理由很实在:

优点 说明
✅ 兼容性强 大多数工具链、RTOS 默认按 4KB 对齐
✅ 易于管理 分配释放灵活,适合小对象
✅ 减少浪费 内部碎片平均 2KB,在几百 KB RAM 下可接受

当然,对于大块内存(如帧缓冲、DMA 区),可以引入“巨页”优化,比如一次性映射 64KB,减少页表项数量。


第三步:页表项格式尽量贴近 AARCH64 ⚙️

为了让以后移植更容易,我们让 pte_t 的语义尽量接近 AARCH64:

字段 对应 AARCH64 含义
valid Valid bit
writable AP[0]: Read/Write
executable PXN/XN: 是否禁止执行
user AP[1]: Privilege/User
cacheable AttrIndex 或显式标记
accessed/dirty AF/DBM: 供页面置换算法使用

甚至可以定义宏来统一操作:

#define PTE_ADDR(pte) (((uint32_t)(pte).phy_page_base) << PAGE_SHIFT)
#define PTE_VALID(pte) ((pte).valid)
#define PTE_WRITABLE(pte) ((pte).writable)

static inline int pte_is_accessible(const pte_t *pte, int user_mode, int write_access) {
    if (!pte->valid) return 0;
    if (write_access && !pte->writable) return 0;
    if (user_mode && !pte->user) return 0;
    return 1;
}

这样一来,哪怕将来换到真 AARCH64 平台,很多逻辑都能复用。


核心机制:如何实现“缺页异常”?🚨

在标准系统中,CPU 发现页表无效就会触发 Page Fault Exception ,跳转到内核处理函数。

但在 ESP32-S3 上,没有这样的异常机制。所以我们得“模拟”出来。

怎么做?

方案一:主动检查(Polling-based)

在每次内存访问前调用一个翻译函数:

int translate_address(uint32_t vaddr, uint32_t* paddr) {
    pte_t *pte = walk_page_table(vaddr);
    if (!pte || !PTE_VALID(*pte)) {
        return handle_page_fault(vaddr);  // 模拟缺页
    }
    *paddr = PTE_ADDR(*pte) | (vaddr & ~PAGE_MASK);
    mark_accessed_dirty(pte);
    return 0;
}

缺点很明显:太慢了!每次访问都要走一遍软件查表。

方案二:利用 TLB Miss 异常(推荐)

Xtensa 支持 TLB Miss Exception !这才是真正的突破口!

当 CPU 查 TLB 失败时,会跳转到异常向量,我们可以在这里拦截并处理。

void tlb_miss_exception_handler(void) {
    uint32_t vaddr = GET_CAUSE_VADDR();  // 从特殊寄存器获取触发地址
    int is_instruction_fetch = IS_ITLB_MISS();

    handle_tlb_miss_exception(vaddr, is_instruction_fetch);
}

然后在 handle_tlb_miss_exception 中:

  1. 查找软件页表是否有对应映射
  2. 若无,则分配物理页并建立映射(即“按需分页”)
  3. 手动调用 xtensa_itlb_insert() xtensa_dtlb_insert() 填入 TLB

这样就能实现真正的“懒加载”:第一次访问才分配内存!


物理页从哪来?malloc 也能当“物理内存池”?💾

ESP32-S3 没有专门的页帧管理器,所以我们自己建一个池子:

#define PHYS_POOL_SIZE (128 * 1024)
static uint8_t phys_page_pool[PHYS_POOL_SIZE] __attribute__((aligned(PAGE_SIZE)));
static uint8_t page_usage_bitmap[PHYS_POOL_SIZE / PAGE_SIZE / 8];

分配函数基于位图:

void* alloc_physical_page(void) {
    for (int i = 0; i < PHYS_POOL_SIZE / PAGE_SIZE; ++i) {
        int idx = i / 8, off = i % 8;
        if (!(page_usage_bitmap[idx] & (1 << off))) {
            page_usage_bitmap[idx] |= (1 << off);
            return &phys_page_pool[i * PAGE_SIZE];
        }
    }
    return NULL;
}

虽然不能用 PSRAM(除非 SDK 支持映射),但至少 DRAM 能池化管理,避免碎片。

更重要的是: 虚拟页和物理页彻底解耦了!

你可以把 0x8000_0000 映射到任意物理页,再也不用担心地址冲突。


API 设计:给用户一个友好的接口 🎯

最终我们要暴露一组简洁的 API,就像 Linux 的 mmap() 一样:

int vm_map(uint32_t vaddr, size_t len, int prot, int attr, int user);
int vm_unmap(uint32_t vaddr, size_t len);
int vm_protect(uint32_t vaddr, size_t len, int new_prot);

使用示例:

// 为任务栈分配独立虚拟空间
vm_map(0x80000000, 8192, VM_READ | VM_WRITE, CACHE_WB, TASK_USER_MODE);

// 映射设备寄存器(只读)
vm_map(0x90002000, 4096, VM_READ, CACHE_UNCACHED, TASK_KERNEL_MODE);

内部自动完成:
- 页表创建(必要时分配中间目录)
- 物理页分配
- TLB 插入
- 权限校验

对外完全透明。


多任务环境下的应用:实现任务内存隔离 🔐

FreeRTOS 每个任务有自己的 TCB,我们可以在里面加一个内存上下文:

typedef struct {
    pgd_t *pgd;           // 当前页表根
    uint32_t asid;        // 模拟 ASID
} mm_context_t;

typedef struct tcb {
    char *pcTaskName;
    StackType_t *pxStack;
    mm_context_t mmu_ctx;   // 新增字段
    UBaseType_t uxPriority;
} TaskHandle_t;

任务切换时,在 vPortSwitchContext() 中更新当前页表基址:

    /* 伪代码 */
    save_regs:
        sw ra, (sp)
        ...

    switch_pgd:
        lw t0, offset_of_pgd(a0)    // a0 = 新任务 TCB
        csrw TTBR0_SIM, t0         // 写入模拟寄存器
        call flush_software_tlb     // 清空软件 TLB 缓存

    restore_regs:
        lw ra, (sp)
        retw

从此,每个任务都有自己独立的虚拟视图。哪怕两个任务都用 0x8000_0000 作为栈顶,实际访问的物理页也完全不同。

安全性瞬间拉满!🔐


外设映射:让驱动开发更安全 🛡️

以前写驱动,经常直接操作物理地址:

*(volatile uint32_t*)0x60000000 = 1;  // GPIO enable

现在我们可以映射到虚拟地址:

const struct device_mapping dev_map[] = {
    {0x90000000, 0x60000000, 0x1000, PERM_RWX, CACHE_UNCACHED}, // GPIO
    {0x90001000, 0x60001000, 0x1000, PERM_RW_, CACHE_UNCACHED}, // UART0
};

驱动代码变成:

#define VIRT_GPIO_BASE 0x90000000
write32(VIRT_GPIO_BASE + OFFSET, val);  // 安全访问

如果用户态任务试图写 EFUSE(只读),系统会在 handle_page_fault 中检测并阻止:

if (is_device_addr(vaddr) && !has_permission()) {
    panic("非法访问外设:%#x", vaddr);
}

既提升了安全性,又增强了可移植性。


性能优化:不能只讲功能,还得快!🚀

当然,纯软件页表是有代价的。我们必须想办法提速。

1. 软件 TLB 缓存(STLB)

既然硬件 TLB 不够用,我们就做个哈希缓存:

#define STLB_SIZE 64
typedef struct {
    uint32_t vtag;
    uint32_t paddr;
    int valid;
} stlb_entry;

static stlb_entry stlb[STLB_SIZE];

uint32_t stlb_lookup(uint32_t vpage) {
    for (int i = 0; i < STLB_SIZE; ++i)
        if (stlb[i].valid && stlb[i].vtag == vpage)
            return stlb[i].paddr;
    return INVALID;
}

void stlb_insert(uint32_t vpage, uint32_t ppage) {
    int idx = vpage % STLB_SIZE;
    stlb[idx] = (stlb_entry){.vtag=vpage, .paddr=ppage, .valid=1};
}

实测命中率可达 70%+,大大减少页表遍历次数。

2. 快速分配器:bitmap + freelist

把物理页分配从 O(n) 优化到 O(1):

static LIST_HEAD(free_page_list);
static spinlock_t alloc_lock;

void* fast_alloc_page() {
    spin_lock(&alloc_lock);
    if (!list_empty(&free_page_list)) {
        struct list_head *entry = free_page_list.next;
        list_del(entry);
        spin_unlock(&alloc_lock);
        return entry;
    }
    spin_unlock(&alloc_lock);
    return NULL;
}

特别适合中断上下文中的紧急分配。

3. 调试工具:dump_vm_info()

开发期间一定要能看到内部状态:

void dump_vm_info() {
    printf("=== VM Map Summary ===\n");
    for (int i = 0; i < PTRS_PER_TABLE; ++i) {
        if (PTE_VALID(kernel_pgd[i])) {
            printf("L1[%d]: -> L2@%p\n", i, (void*)PTE_ADDR(kernel_pgd[i]));
        }
    }
}

还可以导出 JSON 给上位机分析,排查内存泄漏、越界等问题。


最终效果:我们得到了什么?🎯💥

经过这一番折腾,我们在 ESP32-S3 上实现了:

功能 是否实现
虚拟地址空间
多任务内存隔离
按需分页(Demand Paging)
读/写/执行权限控制
外设安全映射
用户/内核态模拟
缺页异常处理 ✅(软件模拟)
性能优化(STLB、快速分配)

虽然比不上真正的 MMU,但在资源受限的嵌入式系统中,这套方案已经足够强大。

更重要的是: 它为我们打开了通往更复杂系统的大门

未来如果要移植 Zephyr、RT-Thread SMP,甚至是轻量级 Linux,这套内存抽象可以直接复用。


结语:没有条件,创造条件也要上!💪🌈

技术的本质不是等待完美的硬件,而是在有限条件下做出最优解。

ESP32-S3 没有 MMU?没关系。
我们用软件模拟页表,用手动 TLB 插入替代硬件 walker,用 MPU + 软件权限检查实现保护。

这就像在沙漠里种花——土壤贫瘠,水源稀缺,但我们依然可以搭起温室,引来滴灌,最终开出一朵属于工程师的花。

所以,下次当你面对一块“功能简陋”的芯片时,别急着放弃。

问问自己:

🤔 “我能不能在这上面,造一个更大的世界?”

答案往往是: 能,只要你敢想,敢写,敢调。

而现在,你已经有了工具箱。🛠️💻
去吧,造你的虚拟内存宇宙!🌌✨

更多推荐