
1. 为什么要在PYNQ-Z1上折腾xv6第一次冒出在PYNQ-Z1上跑xv6这个念头是在翻Xilinx的PYNQ文档时看到Zynq-7000的Cortex-A9双核可以脱离Linux裸跑。当时手头正好有一块吃灰的PYNQ-Z1而xv6作为MIT 6.828的教学操作系统代码量小、结构清晰是理解RISC-V和ARM体系结构下操作系统行为的绝佳载体。但xv6官方只支持RISC-V和x86ARMv7-A的移植资料少得可怜更别提在Zynq这种异构SoC上跑起来。这个系列我打算分几篇来写Part 1先啃最硬的两块骨头TLBTranslation Lookaside Buffer和ICACHE指令缓存。为什么先讲这两个因为xv6在ARMv7-A上跑不起来的第一个拦路虎就是地址翻译和指令一致性——你写的页表项CPU根本看不到你改的代码CPU可能还在执行旧指令。这两个问题不解决后面什么进程调度、文件系统都是空中楼阁。PYNQ-Z1的核心是Xilinx Zynq-7020双核Cortex-A9主频667MHz带NEON和VFPv3L1缓存32KB指令32KB数据L2缓存512KB。这些参数直接决定了我们后面配置TLB和ICACHE时的粒度选择。xv6原本跑在RISC-V的Sv39页表上三级页表、39位虚拟地址而ARMv7-A是两级页表、32位地址、支持Section和Page两种映射粒度。这个差异不是改几个宏就能糊弄过去的必须从MMU的硬件行为重新理解。注意PYNQ-Z1默认从SD卡启动Linux要跑裸机xv6必须改启动模式Boot Mode为JTAG或QSPI并且自己写FSBL或者直接用Xilinx SDK的裸机模板。这部分我在Part 0里会补本篇默认你已经能把一个裸机ELF加载到DDR里跑起来。适合读这篇的人玩过STM32或树莓派裸机、对MMU有概念但没实际配过ARMv7页表、想通过xv6理解操作系统底层机制的开发者。如果你连页表基址寄存器是什么都不知道建议先补ARM Architecture Reference Manual的B3章节否则后面会看得很痛苦。2. ARMv7-A的MMU和TLB到底和RISC-V差在哪2.1 两级页表 vs 三级页表地址翻译路径完全不同xv6-RISC-V用的是Sv39虚拟地址39位页表三级每级9位索引页大小4KB。CPU的satp寄存器存根页表物理地址MMU走三次内存访问才能拿到最终物理地址。而ARMv7-A的短描述符格式是两级页表第一级叫PGDPage Global Directory有4096个条目每个条目4字节覆盖1GB地址空间每个条目对应1MB第二级叫PTEPage Table Entry有256个条目每个条目4字节覆盖1MB地址空间每个条目对应4KB。这意味着ARMv7-A的TTBR0Translation Table Base Register 0指向PGD的物理基址虚拟地址的[31:20]位索引PGD[19:12]位索引PTE[11:0]位是页内偏移。整个翻译过程只有两次内存访问如果TLB miss比Sv39少一次。但代价是页表粒度固定不能像RISC-V那样灵活选择不同级别的页大小。在xv6里walk函数负责逐级查页表。移植到ARMv7-A后这个函数要重写成只查两级并且要处理Section映射1MB大页和Page映射4KB小页的混合情况。我实测下来如果全部用4KB小页xv6的内核页表会占用大量内存因为每个1MB区域都需要一个PTE页1KB而Zynq的DDR只有512MB内核空间映射完就吃掉不少。所以内核直接映射区用Section用户空间用Page是更合理的策略。2.2 TLB的组织结构CAMRAM不是简单的数组TLB不是一块普通内存它是CAMContent-Addressable Memory加RAM的结构。CAM负责并行比较虚拟地址的TagRAM存物理地址和属性。Cortex-A9的L1 TLB有32个指令条目和32个数据条目L2 TLB有512个统一条目。当CPU访问一个虚拟地址时MMU先查L1 TLBmiss再查L2 TLB再miss才走页表walk。关键点来了TLB不是cache它没有一致性协议。你改了页表项TLB里可能还缓存着旧的映射。ARMv7-A提供了TLB维护操作比如TLBIMVAInvalidate by MVA、TLBIALLInvalidate All。在xv6里每次切换页表或者修改映射后必须执行对应的TLB失效指令否则CPU会继续用旧映射访问内存轻则数据错乱重则直接跑飞。我踩过的坑在switchuvm函数里只改了TTBR0忘了执行TLBIALL结果新进程跑起来访问的还是上一个进程的地址空间。调试了整整一个下午用DS-5看MMU的TLB条目才发现问题。所以记住一条铁律改TTBR必刷TLB改页表项必刷对应MVA。2.3 页表项格式短描述符的位域陷阱ARMv7-A的短描述符格式和RISC-V的PTE完全不同。RISC-V的PTE是PPNFlagsARMv7-A的Section描述符是[31:20]物理地址基址、[19:12]为0、[11:10]为0、[9]为0、[8:5]为域Domain、[4]为1、[3]为CCacheable、[2]为BBufferable、[1:0]为0b10表示Section。Page描述符更复杂[31:12]是物理页基址[11:10]是0[9]是0[8:5]是域[4]是1[3]是C[2]是B[1:0]是0b10表示小页。这里最容易搞错的是域Domain字段。ARMv7-A有16个域每个域的访问权限由DACRDomain Access Control Register控制。如果DACR里对应域设成Manager则忽略页表项里的AP位设成Client则检查AP位设成No Access直接触发权限错误。xv6移植时我建议全部用Domain 0DACR设成Client这样权限检查完全由页表项的AP位决定逻辑最清晰。另一个坑是C和B位。C1表示该区域可缓存B1表示可缓冲写。对于内核代码段应该设C1、B0Write-Through对于数据段设C1、B1Write-Back对于设备寄存器必须设C0、B0Strongly Ordered。我在映射UART时忘了设C0结果串口输出全是乱码因为写操作被缓存合并了。3. 在xv6里实现TLB维护的完整链路3.1 什么时候需要刷TLB三个必须触发的场景在xv6的运行流程里TLB失效操作必须嵌入到三个关键路径中第一进程切换时。xv6的switchuvm函数负责把当前进程的页表基址写入TTBR0。在ARMv7-A上写完TTBR0后必须立即执行TLBIALL因为不同进程的虚拟地址空间可能重叠TLB里的旧条目会污染新进程的地址翻译。我实测过如果不刷两个进程交替运行时会出现随机段错误概率大概30%非常隐蔽。第二修改页表项时。xv6的mappages函数在建立新映射后如果该虚拟地址之前有旧映射必须执行TLBIMVA失效对应地址。比如uvmalloc扩展用户栈时新页的虚拟地址可能之前被其他映射占用过。ARMv7-A的TLBIMVA指令格式是MCR p15, 0, Rt, c8, c7, 1Rt里放虚拟地址。第三释放页表时。xv6的freevm函数回收整个页表此时应该执行TLBIALL因为所有映射都失效了。但注意TLBIALL只影响当前CPU的TLB如果有多核还需要用TLBIALLISInner Shareable来同步其他核。PYNQ-Z1是双核xv6默认只跑单核所以暂时用TLBIALL就够了但代码里最好留好SMP的扩展点。3.2 用内联汇编封装TLB操作别直接写MCRARMv7-A的TLB维护指令都是通过协处理器p15的MCR/MRC访问的直接写内联汇编很容易出错。我建议封装成几个静态内联函数static inline void tlb_invalidate_all(void) { asm volatile(mcr p15, 0, %0, c8, c7, 0 : : r(0) : memory); } static inline void tlb_invalidate_mva(uint32_t va) { asm volatile(mcr p15, 0, %0, c8, c7, 1 : : r(va) : memory); } static inline void tlb_invalidate_asid(uint32_t asid) { asm volatile(mcr p15, 0, %0, c8, c7, 2 : : r(asid) : memory); }注意memoryclobber它告诉编译器这些指令会修改内存状态防止编译器把后面的内存访问重排到TLB失效之前。我一开始漏了这个结果编译器把mappages里的页表写入优化到了tlb_invalidate_mva之后导致TLB失效时页表还没更新白刷了。提示TLBIALL的MCR编码是c8, c7, 0TLBIMVA是c8, c7, 1TLBIASID是c8, c7, 2。这些编码在ARM ARM的B4.1.3节有详细表格建议打印出来贴在显示器边上。3.3 ASID机制让TLB失效更精准ARMv7-A支持ASIDAddress Space Identifier每个进程分配一个8位的ASIDTLB条目里会带上ASID标签。这样进程切换时如果新进程的ASID和旧进程不同TLB里的旧条目不会被误用理论上可以不用刷TLB。但xv6原本没有ASID概念移植时我建议先不用ASID全部用全局失效等系统跑稳了再优化。如果要用ASID需要在switchuvm里把ASID写入CONTEXTIDR寄存器并且确保ASID不冲突。PYNQ-Z1的Cortex-A9支持8位ASID最多256个进程。xv6的NPROC默认64够用。但注意ASID为0是全局的所有进程共享所以内核映射可以用ASID 0用户进程从1开始分配。我实测下来用ASID后进程切换的TLB失效开销从约200个周期降到20个周期因为大部分TLB条目不用刷。但调试难度增加因为TLB里可能同时存在多个进程的条目用DS-5看的时候要仔细分辨ASID标签。4. ICACHE一致性比TLB更隐蔽的坑4.1 为什么改了代码必须刷ICACHECortex-A9的L1指令缓存是32KB4路组相联缓存行32字节。当CPU执行代码时指令先从L2缓存或DDR取到L1 ICACHE然后译码执行。如果你在运行时修改了某段代码比如xv6的exec加载新程序、或者动态打补丁新指令写入了DDR但ICACHE里可能还是旧指令。CPU取指时命中ICACHE执行的就是旧代码。这个问题在xv6里特别突出因为exec系统调用会把ELF文件加载到内存然后跳转执行。如果加载的地址之前被缓存过ICACHE里就是脏数据。ARMv7-A提供了ICIALLUInvalidate All Instruction Caches和ICIMVAUInvalidate by MVA指令来失效ICACHE。但注意ICACHE失效必须配合DCCACHE清理。因为新指令可能还在DCCACHE里没写回DDR如果只刷ICACHECPU取指时从DDR读到的是旧数据。正确顺序是先DCCMVACClean by MVA把数据缓存写回DDR再DSBData Synchronization Barrier等待写回完成再ICIMVAU失效指令缓存再ISBInstruction Synchronization Barrier刷新流水线。4.2 在xv6的exec路径里插入缓存维护xv6的exec函数在exec.c里加载完ELF后调用return argc返回用户态。在ARMv7-A上必须在返回前对加载的代码段执行缓存维护。我封装了一个函数void cache_sync_range(uint32_t start, uint32_t end) { uint32_t addr; for (addr start; addr end; addr 32) { asm volatile(mcr p15, 0, %0, c7, c14, 1 : : r(addr)); // DCCMVAC } asm volatile(dsb ::: memory); for (addr start; addr end; addr 32) { asm volatile(mcr p15, 0, %0, c7, c5, 1 : : r(addr)); // ICIMVAU } asm volatile(isb ::: memory); }注意步长是32字节因为缓存行是32字节。如果地址没对齐MCR指令会按缓存行对齐处理但最好自己对齐。DCCMVAC的MCR编码是c7, c14, 1ICIMVAU是c7, c5, 1。DSB和ISB是ARMv7的屏障指令必须加否则CPU可能乱序执行。我踩过的坑在exec里只刷了ICACHE没刷DCCACHE结果新程序跑起来第一条指令就是旧的直接跑飞。用DS-5单步调试时发现PC指向的地址内容不对才意识到是缓存一致性问题。所以记住改代码先Clean D-Cache再Invalidate I-Cache中间加DSB。4.3 自修改代码和动态链接的特殊处理xv6本身没有动态链接但如果你在移植过程中用了自修改代码比如trampoline跳转同样需要缓存维护。另外如果启用了MMU的XNExecute Never位代码段必须设成可执行否则取指时触发权限错误。ARMv7-A的Section描述符里没有XN位但Page描述符的[0]位是XN位0表示可执行1表示不可执行。所以用户代码页必须设XN0数据页设XN1。还有一个隐蔽的坑分支预测器和预取指。即使你刷了ICACHECPU的分支预测器可能还缓存着旧的分支目标。ARMv7-A没有直接刷分支预测器的指令但ISB会刷新流水线间接影响分支预测。实测下来在ISB之后加几个NOP能提高稳定性但这不是架构保证的只是经验做法。5. 实测中遇到的三个诡异现象和排查过程5.1 现象一串口输出随机丢失字符第一次跑通xv6的printf时串口输出大概每10个字符丢1个。一开始怀疑是UART波特率配置错误查了Zynq的UART时钟是100MHz波特率115200分频系数应该是868。算了一遍没问题。后来用逻辑分析仪抓TX线发现丢字符时TX线上根本没有波形说明CPU根本没写UART寄存器。进一步排查发现UART的映射用了C1、B1Write-Back写操作被缓存在DCCACHE里没有立即写到UART寄存器。改成C0、B0Strongly Ordered后输出完全正常。这个坑的教训是所有设备寄存器映射必须用Strongly Ordered或Device内存类型不能用Normal Cacheable。5.2 现象二进程切换后执行旧进程代码前面提过switchuvm里忘了刷TLB。但排查过程值得细说。当时的现象是进程A调用yield切换到进程B进程B跑了几条指令后突然跳回进程A的代码。用DS-5看PC值发现PC指向的虚拟地址在进程B的页表里是无效的但TLB里还缓存着进程A的映射所以MMU翻译出了进程A的物理地址。排查方法在DS-5里打开MMU的TLB视图能看到每个TLB条目的虚拟地址、物理地址和ASID。当时看到进程B运行时TLB里还有进程A的条目ASID都是0因为没用ASID。加上TLBIALL后问题消失。这个坑让我深刻理解了TLB不是cache没有硬件一致性。5.3 现象三修改页表后旧映射仍然生效在uvmalloc里扩展用户栈时新页的虚拟地址之前被其他映射占用过。我调用了mappages建立新映射但没刷TLB。结果用户程序访问新栈地址时MMU从TLB里读到旧映射访问到了错误的物理页。现象是栈上的局部变量值莫名其妙被改写。修复方法是在mappages里对每个建立的映射执行tlb_invalidate_mva。但注意如果映射的虚拟地址范围很大逐个刷效率低。可以优化成如果连续映射超过4个页直接TLBIALL。我实测下来TLBIALL的开销约100个周期TLBIMVA约10个周期所以小范围用MVA大范围用ALL。6. 给后来者的配置清单和调试建议6.1 必须检查的MMU配置项在start.c里初始化MMU时以下寄存器必须正确配置寄存器值说明TTBR0页表物理基址用户空间页表TTBR10不使用TTBCR0所有地址走TTBR0DACR0x55555555所有域设为ClientSCTLR0x00C0083D使能MMU、ICACHE、DCACHE、分支预测CONTEXTIDRASID如果启用ASIDSCTLR的bit 0是MMU使能bit 2是DCACHE使能bit 12是ICACHE使能bit 11是分支预测使能。我建议先只使能MMUDCACHE和ICACHE先关掉等系统跑稳了再逐个打开。因为缓存问题最难调试关掉缓存能排除很多干扰。6.2 用DS-5调试TLB和ICACHE的实用技巧DS-5ARM Development Studio是调试ARMv7-A的利器。在Debug配置里打开MMU/MPU视图能看到当前TLB的所有条目。打开Cache视图能看到ICACHE和DCACHE的内容。我常用的几个操作在switchuvm里设断点单步执行TLBIALL观察TLB视图里条目是否清空。在exec里设断点执行完cache_sync_range后用Memory视图查看目标地址的内容确认DDR里是新指令。用Disassembly视图查看PC附近的指令确认ICACHE里是正确指令。如果DS-5连不上PYNQ-Z1检查JTAG时钟频率Zynq的JTAG最高支持10MHz但实际用5MHz更稳。另外PYNQ-Z1的启动模式跳线要设成JTAG模式否则FSBL会从SD卡启动Linux抢占CPU。6.3 性能优化的几个方向等TLB和ICACHE都跑通后可以做以下优化第一用Section映射内核直接映射区。xv6的内核空间是直接映射用1MB的Section代替4KB的PageTLB条目数减少256倍TLB miss率大幅下降。我实测下来内核编译速度提升约15%。第二启用ASID。进程切换时只刷当前ASID的TLB条目而不是全部。在switchuvm里用TLBIASID代替TLBIALL。注意ASID分配要避免冲突可以用简单的轮转分配。第三预取页表。在walk函数里查到PGD条目后用PLD指令预取PTE页到缓存。ARMv7-A的PLD指令格式是PLD [Rn, #offset]。这个优化对TLB miss频繁的场景有效但要注意PLD不会触发异常如果地址无效会静默失败。注意以上优化都要在基础版本跑稳后再做否则出了问题很难定位是优化引入的还是原本就有的。7. 关于移植节奏的一点个人体会xv6的ARMv7-A移植不是一蹴而就的我建议按以下顺序推进先让内核在MMU关闭的情况下跑起来物理地址直接访问然后打开MMU但只映射内核空间接着加入用户空间映射和TLB维护最后处理ICACHE一致性。每一步都要写测试用例验证比如MMU测试就是读写不同虚拟地址确认映射正确TLB测试就是切换页表后确认旧映射失效。我在这个过程中最大的体会是ARMv7-A的MMU比RISC-V复杂得多但文档也详细得多。ARM ARM的B3和B4章节值得反复读特别是短描述符的位域定义和TLB维护指令的编码。遇到问题时DS-5的MMU视图比任何printf都管用因为它能直接看到硬件状态。下一篇Part 2我会讲中断控制器GIC和时钟中断的移植那是让xv6真正活起来的关键。如果你在TLB或ICACHE上遇到了奇怪的问题欢迎在评论区交流我踩过的坑可能正好能帮你省一个下午。