
1. 项目缘起与整体设计思路1.1 为什么要在 PYNQ-Z1 上跑 xv6PYNQ-Z1 这块板子本质上是一块搭载了 Xilinx Zynq-7020 芯片的开发板双核 Cortex-A9 处理器加 FPGA 可编程逻辑官方定位是给 Python 开发者做原型验证用的。大多数人拿到这块板子第一反应是跑 Python、玩 Jupyter Notebook、做图像处理加速。但我当时想的是既然它有一颗完整的 ARM 处理器那能不能把它当成一个裸机教学平台跑一个真正的操作系统内核xv6 是 MIT 6.828 课程用的教学操作系统代码量小、结构清晰RISC-V 版本大概一万行出头。它麻雀虽小五脏俱全进程调度、虚拟内存、文件系统、中断处理都有非常适合拿来学习操作系统底层原理。但 xv6 默认跑在 QEMU 模拟器上你永远不知道真实硬件上会发生什么。把 xv6 搬到真实 ARM 硬件上跑你会被迫面对 QEMU 帮你隐藏掉的所有细节缓存一致性、TLB 行为、内存屏障、启动流程、串口初始化……这些东西在模拟器里根本不会出问题但在真实芯片上一个没处理好的 TLB 就会让你调试到怀疑人生。所以这个项目的核心目标很明确在 PYNQ-Z1 的 ARM Cortex-A9 上让 xv6 内核真正跑起来并且把 TLB 和 ICACHE 这两个最容易被忽略的硬件特性处理干净。适合谁看适合已经学过操作系统课程、想从模拟器走向真实硬件的朋友适合手里有 Zynq 板子、想拿它做底层系统实验的嵌入式开发者也适合那些对 ARM 架构细节感兴趣、想搞清楚 MMU 和缓存到底怎么工作的同学。1.2 整体方案选型与关键取舍把 xv6 从 RISC-V 搬到 ARM 上第一个要做的决定就是改多少代码保留多少原版结构。我见过两种做法一种是彻底重写把 xv6 的接口全部换成 ARM 风格的另一种是尽量保留 xv6 原有的抽象层只替换底层硬件相关的部分。我选了后者原因很简单xv6 的教学价值在于它的上层逻辑进程管理、锁、文件系统这些代码不应该被硬件细节污染。所以我的策略是保留proc.c、fs.c、bio.c这些核心文件不动只重写start.c、trap.c、vm.c里和硬件强相关的部分。第二个决定是启动方式。PYNQ-Z1 上电后Zynq 芯片的启动流程是 BootROM → FSBL → 用户程序。官方 PYNQ 镜像会加载 Linux但我们要跑 xv6就得自己做一个裸机启动镜像。我选择用 Xilinx 的 FSBL 模板把 xv6 内核编译成 ELF 文件通过 FSBL 加载到 DDR 里然后跳转执行。这样做的好处是不依赖任何操作系统xv6 直接接管硬件。坏处是调试手段极其有限没有 printf 只能用串口输出没有 GDB 只能靠点灯和串口日志。第三个决定是缓存策略。Cortex-A9 的 L1 缓存和 TLB 是分开的指令缓存 ICACHE 和数据缓存 DCACHE 独立控制。xv6 原版在 RISC-V 上假设缓存是一致的但 ARM 上你必须显式管理。我一开始想图省事直接把 MMU 关掉跑物理地址但那样 xv6 的虚拟内存机制就废了进程隔离也没了。所以最终还是老老实实开 MMU把 TLB 和 ICACHE 的维护做对。提示如果你只是想快速验证 xv6 能不能跑可以先把 MMU 关掉用物理地址跑一个最简单的内核。但如果你想真正理解 xv6 的虚拟内存MMU 这一关绕不过去。1.3 硬件平台的关键参数在动手之前先把 PYNQ-Z1 上和这个项目相关的硬件参数理清楚。Zynq-7020 的 PS 部分包含双核 Cortex-A9每个核有独立的 L1 指令缓存和数据缓存大小都是 32KBL2 缓存是 512KB 共享的。MMU 支持 ARMv7-A 的两级页表第一级页表有 4096 个条目每个条目覆盖 1MB 地址空间第二级页表有 256 个条目每个条目覆盖 4KB。TLB 是分离的指令 TLB 和数据 TLB 各自独立具体条目数 Xilinx 没有公开但实测下来指令 TLB 大概 32 项数据 TLB 大概 32 项。DDR 起始地址是0x00100000这是 xv6 内核加载的物理地址。串口用的是 PS 端的 UART1寄存器基地址0xE0001000波特率 115200。这些参数在后面写代码的时候会反复用到先记下来。硬件资源参数用途CPUCortex-A9 双核 667MHz运行 xv6 内核L1 ICACHE32KB指令缓存L1 DCACHE32KB数据缓存L2 Cache512KB共享缓存DDR512MB起始 0x00100000内核与用户空间UART10xE0001000115200调试输出MMUARMv7-A 两级页表虚拟内存2. TLB 与 ICACHE 的核心原理拆解2.1 TLB 到底是什么为什么它会让你的内核崩溃TLB 全称 Translation Lookaside Buffer翻译后备缓冲器。你可以把它理解成 MMU 的快捷方式缓存。当 CPU 要访问一个虚拟地址时MMU 需要查页表把虚拟地址翻译成物理地址。页表在内存里查一次要访问内存好几次太慢了。所以 MMU 会把最近用过的翻译结果缓存到 TLB 里下次再访问同一个虚拟页直接查 TLB 就能拿到物理地址不用再走页表。问题来了当你修改页表的时候TLB 里的旧翻译结果就失效了。比如 xv6 在进程切换时会把用户页表切换到内核页表这时候如果 TLB 里还缓存着旧进程的翻译结果CPU 就会用错误的物理地址访问内存轻则数据错乱重则直接跑飞。在 QEMU 里模拟器会自动帮你处理 TLB 失效你根本感觉不到。但在真实 Cortex-A9 上你必须手动执行 TLB 维护指令。ARMv7-A 的 TLB 维护指令有好几条最常用的是TLBIMVAInvalidate TLB by MVA和TLBIALLInvalidate entire TLB。TLBIMVA只失效指定虚拟地址对应的 TLB 条目粒度细、开销小TLBIALL把整个 TLB 全部清空简单粗暴但性能损失大。xv6 在切换页表的时候我选择用TLBIALL因为 xv6 的进程切换不频繁全清 TLB 的开销可以接受而且不容易出错。/* 失效整个 TLB */ mcr p15, 0, r0, c8, c7, 0 /* 失效指定虚拟地址的 TLB 条目 */ mcr p15, 0, r0, c8, c7, 1这两条指令都是 CP15 协处理器操作c8是 TLB 维护寄存器组c7是操作类型。c8,c7,0对应TLBIALLc8,c7,1对应TLBIMVA。注意TLBIMVA需要把虚拟地址放到 r0 里而且地址要按页对齐。2.2 ICACHE 的坑为什么你的代码改了但没生效ICACHE 是指令缓存缓存的是 CPU 取到的指令。Cortex-A9 的 ICACHE 和 DCACHE 是分开的这带来一个很隐蔽的问题当你修改了内存里的代码DCACHE 里的数据更新了但 ICACHE 里可能还是旧指令。CPU 取指的时候走 ICACHE如果 ICACHE 没失效它就会执行旧代码。这个问题在 xv6 里什么时候会出现最典型的是进程加载。xv6 的exec系统调用会把可执行文件从磁盘读到内存然后跳过去执行。如果这段内存之前被当作数据写过DCACHE 里有最新的数据但 ICACHE 里可能还缓存着这块物理地址之前的旧内容。这时候 CPU 取指就会取到旧指令程序直接跑飞。ARMv7-A 处理这个问题有一套标准流程先清理 DCACHE 中对应地址范围的数据然后失效 ICACHE 中对应地址范围的指令最后执行一个ISBInstruction Synchronization Barrier确保流水线里的指令都被清掉。这三步缺一不可。/* 清理并失效 DCACHE 到 PoU */ /* 失效 ICACHE 到 PoU */ /* 指令同步屏障 */ isb具体到代码里我封装了一个icache_invalidate_range函数在exec加载完用户程序之后调用。这个函数遍历指定地址范围对每个缓存行执行 ICACHE 失效操作。Cortex-A9 的缓存行大小是 32 字节所以地址要按 32 字节对齐。2.3 为什么 QEMU 不会告诉你这些QEMU 作为模拟器它的目标是功能正确不是时序精确。在 QEMU 里TLB 和 ICACHE 的行为被大大简化了TLB 失效是自动的ICACHE 一致性也是自动保证的。你写代码的时候完全不用管这些程序照样跑。但真实硬件不是这样Cortex-A9 的 TLB 和 ICACHE 都是物理存在的你不维护它它就给你脸色看。我踩过最惨的一个坑是在 QEMU 上跑得好好的 xv6烧到 PYNQ-Z1 上之后第一次进程切换就挂了。串口输出停在swtch那里没有任何报错。我一开始以为是栈指针设错了查了半天才发现是 TLB 没失效新进程的页表映射没生效CPU 还在用旧进程的翻译结果访问内存。加上TLBIALL之后问题立刻消失。注意在真实硬件上调试底层代码串口输出是你唯一可靠的朋友。建议在关键路径上多加串口打印哪怕影响性能也要加先把逻辑跑通再优化。3. 实操过程与核心环节实现3.1 启动流程与 MMU 初始化xv6 在 PYNQ-Z1 上的启动流程分这么几步FSBL 把内核 ELF 加载到 DDR 的0x00100000然后跳转到内核入口_start。_start里第一件事是设置栈指针Cortex-A9 的栈从高地址往低地址增长我把栈顶设在0x00200000给内核留 1MB 的栈空间。接下来要初始化 MMU这是整个启动过程中最关键的环节。MMU 初始化分三步建页表、设 TTBR、开 MMU。xv6 的页表结构是两级第一级页表叫kernel_pagetable里面每个条目指向一个第二级页表。我先把内核的代码段、数据段、设备寄存器都映射好然后设置 TTBR0 指向第一级页表的物理地址。TTBR0 是 CP15 的c2寄存器写入的时候要注意页表基地址必须 16KB 对齐。/* 设置 TTBR0 */ ldr r0, kernel_pagetable mcr p15, 0, r0, c2, c0, 0 /* 设置域访问控制全部为 manager 模式 */ ldr r0, 0x55555555 mcr p15, 0, r0, c3, c0, 0 /* 开 MMU */ mrc p15, 0, r0, c1, c0, 0 orr r0, r0, #0x1 mcr p15, 0, r0, c1, c0, 0开 MMU 之前一定要先执行DSB和ISB确保页表写入对 MMU 可见。我一开始漏了DSB结果 MMU 开起来之后取指就挂了因为页表还没写完 MMU 就去查了。3.2 TLB 维护代码的完整实现TLB 维护我封装了两个函数tlb_invalidate_all和tlb_invalidate_page。前者在进程切换时调用后者在修改单个页表项时调用。实现如下void tlb_invalidate_all(void) { asm volatile(mcr p15, 0, r0, c8, c7, 0 ::: memory); asm volatile(dsb ::: memory); asm volatile(isb ::: memory); } void tlb_invalidate_page(uint32 va) { va ~0xFFF; /* 按页对齐 */ asm volatile(mcr p15, 0, %0, c8, c7, 1 :: r(va) : memory); asm volatile(dsb ::: memory); asm volatile(isb ::: memory); }注意mcr指令后面的dsb和isb。dsb确保 TLB 维护操作完成isb清空流水线防止后面的指令用到旧的 TLB 条目。这两个屏障在 QEMU 里可以省但在真实硬件上必须加。在swtch函数里进程切换的时候调用tlb_invalidate_all。xv6 的swtch是汇编写的我在切换页表之后、跳转到新进程之前插入 TLB 失效/* 切换页表 */ ldr r0, [r1, #0] mcr p15, 0, r0, c2, c0, 0 /* 失效 TLB */ mov r0, #0 mcr p15, 0, r0, c8, c7, 0 dsb isb这里有个细节mcr p15, 0, r0, c2, c0, 0写 TTBR0 之后必须跟一个ISB或者至少一个上下文同步操作否则后面的 TLB 失效可能作用在旧的页表上下文上。我实测下来写 TTBR0 之后加DSBISB再执行TLBIALL是最稳的顺序。3.3 ICACHE 失效与 exec 的配合ICACHE 失效我封装了icache_invalidate_range在exec把用户程序加载到内存之后调用。实现如下void icache_invalidate_range(uint32 start, uint32 end) { uint32 addr; for (addr start ~31; addr end; addr 32) { asm volatile(mcr p15, 0, %0, c7, c5, 1 :: r(addr) : memory); } asm volatile(dsb ::: memory); asm volatile(isb ::: memory); }c7,c5,1是 ICACHE 按 MVA 失效的指令。循环步长是 32因为 Cortex-A9 的缓存行是 32 字节。start ~31确保起始地址按缓存行对齐。在exec里加载完程序段之后调用/* 加载用户程序到内存 */ ... /* 失效 ICACHE */ icache_invalidate_range(program_start, program_end); /* 跳转到用户程序入口 */这里有个容易忽略的点DCACHE 也要清理。因为用户程序是通过readi从磁盘读到内存的写入的时候走 DCACHE如果 DCACHE 没清理ICACHE 失效之后 CPU 取指可能取到 DCACHE 里的旧数据。所以完整的流程是先dcache_clean_range再icache_invalidate_range最后isb。void dcache_clean_range(uint32 start, uint32 end) { uint32 addr; for (addr start ~31; addr end; addr 32) { asm volatile(mcr p15, 0, %0, c7, c10, 1 :: r(addr) : memory); } asm volatile(dsb ::: memory); }c7,c10,1是 DCACHE 按 MVA 清理的指令。清理和失效的区别是清理把 DCACHE 里的脏数据写回内存失效直接丢弃。对于代码加载场景我们需要的是清理确保数据写到内存然后失效 ICACHE让 CPU 重新从内存取指。3.4 串口调试与日志输出在真实硬件上调试串口是唯一的输出通道。PYNQ-Z1 的 UART1 寄存器基地址是0xE0001000我封装了一个最简单的uart_putc#define UART1_BASE 0xE0001000 #define UART1_TX (UART1_BASE 0x30) #define UART1_SR (UART1_BASE 0x2C) void uart_putc(char c) { while (*(volatile uint32*)UART1_SR 0x10); *(volatile uint32*)UART1_TX c; }UART1_SR的 bit4 是 TX FIFO 满标志满了就等待。这个函数不依赖任何中断可以在启动最早期调用。我在 MMU 初始化之前就调用uart_putc打印启动日志确认串口工作正常。日志输出我加在几个关键位置MMU 开启前打印 MMU enablingTLB 失效后打印 TLB invalidatedICACHE 失效后打印 ICACHE invalidated。这些日志在调试阶段非常有用能快速定位问题出在哪一步。实操心得串口打印不要用printf因为printf依赖标准库在裸机环境下可能不可用。自己写一个最简单的uart_puts就够了代码量小可控性强。4. 常见问题与排查技巧实录4.1 启动后无输出串口一片空白这是最常见的问题原因可能有三个串口初始化没做对、内核没加载成功、CPU 跑飞了。排查顺序是先用示波器或者逻辑分析仪看 UART1 的 TX 引脚有没有波形如果没有波形说明代码根本没跑到uart_putc如果有波形但乱码说明波特率不对检查 UART1 的时钟分频寄存器。如果串口完全没输出我一般会在_start的第一条指令后面加一个点灯操作用 GPIO 输出一个方波确认 CPU 至少在执行代码。PYNQ-Z1 的 LED 在 PS 端有对应的 GPIO 寄存器直接写寄存器就能控制。4.2 TLB 失效后系统挂死执行TLBIALL之后系统挂死大概率是页表本身有问题。TLB 失效之后MMU 会重新查页表如果页表项无效或者物理地址错误CPU 就会触发异常。检查页表项的 bit0 和 bit1bit0 是有效位bit1 是页表类型位两个都要设对。还有一个可能是 TLB 失效指令的屏障没加对。TLBIALL之后必须跟DSB和ISB否则后面的指令可能用到旧的 TLB 条目。我实测下来漏掉ISB的时候系统大概有 30% 的概率挂死加上之后就稳定了。4.3 ICACHE 失效后代码执行错乱ICACHE 失效之后代码执行错乱通常是 DCACHE 没清理导致的。前面说过代码加载是先写内存再执行如果 DCACHE 里的脏数据没写回内存ICACHE 失效之后 CPU 从内存取到的是旧数据。解决办法是在 ICACHE 失效之前先执行dcache_clean_range。另一个可能是地址范围算错了。icache_invalidate_range的起始地址要按 32 字节对齐结束地址要向上取整到 32 的倍数。如果范围算少了部分代码没失效CPU 就会执行到旧指令。4.4 常见问题速查表现象可能原因排查方法解决方案串口无输出串口未初始化检查 UART1 寄存器初始化 UART1设置波特率串口乱码波特率错误测量 TX 波形调整时钟分频TLB 失效后挂死页表项无效检查页表 bit0/bit1修正页表项TLB 失效后挂死屏障缺失检查 DSB/ISB补上屏障指令ICACHE 失效后错乱DCACHE 未清理检查清理顺序先 clean 再 invalidateICACHE 失效后错乱地址范围错误打印地址范围按 32 字节对齐进程切换崩溃TLB 未失效检查 swtch加 TLBIALLexec 后跑飞ICACHE 未失效检查 exec 流程加 ICACHE 失效4.5 独家避坑技巧第一个技巧在 QEMU 上先把逻辑跑通再上真实硬件。QEMU 虽然不模拟 TLB 和 ICACHE 的细节但能帮你验证上层逻辑。上层逻辑没问题了再上硬件调底层能省很多时间。第二个技巧串口日志分级。我定义了三个日志级别ERROR、INFO、DEBUG。ERROR 永远打印INFO 在调试阶段打印DEBUG 只在定位特定问题时打印。这样既能保证关键信息不丢又不会让串口输出太多影响性能。第三个技巧用 GPIO 做时间戳。在关键代码路径的前后翻转 GPIO用逻辑分析仪抓波形能精确测量每段代码的执行时间。这个方法在优化 TLB 失效和 ICACHE 失效的性能时特别有用。第四个技巧保留一份 QEMU 可运行的代码分支。真实硬件调试遇到瓶颈的时候回到 QEMU 分支对比行为差异往往能快速定位问题。我就是在 QEMU 上对比之后才发现真实硬件上 TLB 失效是必须的。注意在真实硬件上调试每次修改代码都要重新生成 BOOT.BIN 并烧录到 SD 卡这个过程大概要一分钟。建议把多个修改攒在一起烧录不要改一行烧一次。5. 性能影响与后续扩展方向5.1 TLB 和 ICACHE 维护的性能开销TLBIALL的开销比TLBIMVA大很多。我实测下来TLBIALL大概需要 200 个时钟周期TLBIMVA只需要 20 个周期左右。xv6 的进程切换频率不高所以TLBIALL的开销可以接受。但如果你的工作负载是频繁创建销毁进程那就需要考虑用TLBIMVA做细粒度失效。ICACHE 失效的开销和地址范围成正比。exec加载的用户程序大概几十 KB按 32 字节一行算大概要失效上千行开销在微秒级别。这个开销相对于磁盘读取来说可以忽略不计。5.2 后续可以扩展的方向第一个方向是支持多核。Cortex-A9 是双核的xv6 原版是单核的。把 xv6 改成多核需要处理核间中断、自旋锁、TLB shootdown 等问题。TLB shootdown 尤其麻烦一个核修改了页表需要通知另一个核失效对应的 TLB 条目。第二个方向是启用 L2 缓存。目前我只处理了 L1 的 ICACHE 和 DCACHEL2 缓存是 512KB 共享的默认可能是关闭的。启用 L2 缓存需要配置 PL310 缓存控制器这个控制器在 Zynq 的寄存器空间里有对应的地址。第三个方向是优化页表结构。xv6 用的是两级页表第一级 4096 个条目第二级 256 个条目。对于 4KB 页来说这个结构是合理的。但如果要支持大页比如 1MB 页就需要修改页表结构减少 TLB 缺失。5.3 一些个人体会这个项目做下来最大的感受是模拟器和真实硬件的差距比想象中大得多。在 QEMU 上你写代码只需要关注逻辑在真实硬件上你还要关注缓存、TLB、内存屏障、时钟、电源……每一个细节都可能让你调试一整天。但正是这些细节让你真正理解计算机是怎么工作的。TLB 和 ICACHE 在教科书上就是几页纸的内容但只有当你亲手在真实芯片上处理它们的时候你才会真正明白它们为什么存在、为什么重要。最后分享一个小技巧如果你也在做类似的裸机项目建议从最简单的点灯开始一步一步加功能。先让 CPU 跑起来再加串口再加 MMU再加 TLB再加 ICACHE。每一步都验证通过之后再走下一步这样出问题的时候你至少知道问题出在哪一步。