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和x86,ARMv7-A的移植资料少得可怜,更别提在Zynq这种异构SoC上跑起来。
这个系列我打算分几篇来写,Part 1先啃最硬的两块骨头:TLB(Translation Lookaside Buffer)和ICACHE(指令缓存)。为什么先讲这两个?因为xv6在ARMv7-A上跑不起来的第一个拦路虎就是地址翻译和指令一致性——你写的页表项CPU根本看不到,你改的代码CPU可能还在执行旧指令。这两个问题不解决,后面什么进程调度、文件系统都是空中楼阁。
PYNQ-Z1的核心是Xilinx Zynq-7020,双核Cortex-A9,主频667MHz,带NEON和VFPv3,L1缓存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的短描述符格式是两级页表:第一级叫PGD(Page Global Directory),有4096个条目,每个条目4字节,覆盖1GB地址空间(每个条目对应1MB);第二级叫PTE(Page Table Entry),有256个条目,每个条目4字节,覆盖1MB地址空间(每个条目对应4KB)。
这意味着ARMv7-A的TTBR0(Translation 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的组织结构:CAM+RAM,不是简单的数组
TLB不是一块普通内存,它是CAM(Content-Addressable Memory)加RAM的结构。CAM负责并行比较虚拟地址的Tag,RAM存物理地址和属性。Cortex-A9的L1 TLB有32个指令条目和32个数据条目,L2 TLB有512个统一条目。当CPU访问一个虚拟地址时,MMU先查L1 TLB,miss再查L2 TLB,再miss才走页表walk。
关键点来了:TLB不是cache,它没有一致性协议。你改了页表项,TLB里可能还缓存着旧的映射。ARMv7-A提供了TLB维护操作,比如TLBIMVA(Invalidate by MVA)、TLBIALL(Invalidate All)。在xv6里,每次切换页表或者修改映射后,必须执行对应的TLB失效指令,否则CPU会继续用旧映射访问内存,轻则数据错乱,重则直接跑飞。
我踩过的坑:在switchuvm函数里只改了TTBR0,忘了执行TLBIALL,结果新进程跑起来访问的还是上一个进程的地址空间。调试了整整一个下午,用DS-5看MMU的TLB条目才发现问题。所以记住一条铁律:改TTBR必刷TLB,改页表项必刷对应MVA。
2.3 页表项格式:短描述符的位域陷阱
ARMv7-A的短描述符格式和RISC-V的PTE完全不同。RISC-V的PTE是PPN+Flags,ARMv7-A的Section描述符是[31:20]物理地址基址、[19:12]为0、[11:10]为0、[9]为0、[8:5]为域(Domain)、[4]为1、[3]为C(Cacheable)、[2]为B(Bufferable)、[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个域,每个域的访问权限由DACR(Domain Access Control Register)控制。如果DACR里对应域设成"Manager",则忽略页表项里的AP位;设成"Client",则检查AP位;设成"No Access",直接触发权限错误。xv6移植时我建议全部用Domain 0,DACR设成Client,这样权限检查完全由页表项的AP位决定,逻辑最清晰。
另一个坑是C和B位。C=1表示该区域可缓存,B=1表示可缓冲写。对于内核代码段,应该设C=1、B=0(Write-Through);对于数据段,设C=1、B=1(Write-Back);对于设备寄存器,必须设C=0、B=0(Strongly Ordered)。我在映射UART时忘了设C=0,结果串口输出全是乱码,因为写操作被缓存合并了。
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, 1,Rt里放虚拟地址。
第三,释放页表时。xv6的freevm函数回收整个页表,此时应该执行TLBIALL,因为所有映射都失效了。但注意,TLBIALL只影响当前CPU的TLB,如果有多核,还需要用TLBIALLIS(Inner Shareable)来同步其他核。PYNQ-Z1是双核,xv6默认只跑单核,所以暂时用TLBIALL就够了,但代码里最好留好SMP的扩展点。
3.2 用内联汇编封装TLB操作:别直接写MCR
ARMv7-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"); }注意"memory"clobber,它告诉编译器这些指令会修改内存状态,防止编译器把后面的内存访问重排到TLB失效之前。我一开始漏了这个,结果编译器把mappages里的页表写入优化到了tlb_invalidate_mva之后,导致TLB失效时页表还没更新,白刷了。
提示:
TLBIALL的MCR编码是c8, c7, 0,TLBIMVA是c8, c7, 1,TLBIASID是c8, c7, 2。这些编码在ARM ARM的B4.1.3节有详细表格,建议打印出来贴在显示器边上。
3.3 ASID机制:让TLB失效更精准
ARMv7-A支持ASID(Address Space Identifier),每个进程分配一个8位的ASID,TLB条目里会带上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 为什么改了代码必须刷ICACHE
Cortex-A9的L1指令缓存是32KB,4路组相联,缓存行32字节。当CPU执行代码时,指令先从L2缓存或DDR取到L1 ICACHE,然后译码执行。如果你在运行时修改了某段代码(比如xv6的exec加载新程序、或者动态打补丁),新指令写入了DDR,但ICACHE里可能还是旧指令。CPU取指时命中ICACHE,执行的就是旧代码。
这个问题在xv6里特别突出,因为exec系统调用会把ELF文件加载到内存,然后跳转执行。如果加载的地址之前被缓存过,ICACHE里就是脏数据。ARMv7-A提供了ICIALLU(Invalidate All Instruction Caches)和ICIMVAU(Invalidate by MVA)指令来失效ICACHE。
但注意,ICACHE失效必须配合DCCACHE清理。因为新指令可能还在DCCACHE里没写回DDR,如果只刷ICACHE,CPU取指时从DDR读到的是旧数据。正确顺序是:先DCCMVAC(Clean by MVA)把数据缓存写回DDR,再DSB(Data Synchronization Barrier)等待写回完成,再ICIMVAU失效指令缓存,再ISB(Instruction 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, 1,ICIMVAU是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的XN(Execute Never)位,代码段必须设成可执行,否则取指时触发权限错误。ARMv7-A的Section描述符里没有XN位,但Page描述符的[0]位是XN位(0表示可执行,1表示不可执行)。所以用户代码页必须设XN=0,数据页设XN=1。
还有一个隐蔽的坑:分支预测器和预取指。即使你刷了ICACHE,CPU的分支预测器可能还缓存着旧的分支目标。ARMv7-A没有直接刷分支预测器的指令,但ISB会刷新流水线,间接影响分支预测。实测下来,在ISB之后加几个NOP能提高稳定性,但这不是架构保证的,只是经验做法。
5. 实测中遇到的三个诡异现象和排查过程
5.1 现象一:串口输出随机丢失字符
第一次跑通xv6的printf时,串口输出大概每10个字符丢1个。一开始怀疑是UART波特率配置错误,查了Zynq的UART时钟是100MHz,波特率115200,分频系数应该是868。算了一遍没问题。后来用逻辑分析仪抓TX线,发现丢字符时TX线上根本没有波形,说明CPU根本没写UART寄存器。
进一步排查发现,UART的映射用了C=1、B=1(Write-Back),写操作被缓存在DCCACHE里,没有立即写到UART寄存器。改成C=0、B=0(Strongly 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 | 页表物理基址 | 用户空间页表 |
| TTBR1 | 0 | 不使用 |
| TTBCR | 0 | 所有地址走TTBR0 |
| DACR | 0x55555555 | 所有域设为Client |
| SCTLR | 0x00C0083D | 使能MMU、ICACHE、DCACHE、分支预测 |
| CONTEXTIDR | ASID | 如果启用ASID |
SCTLR的bit 0是MMU使能,bit 2是DCACHE使能,bit 12是ICACHE使能,bit 11是分支预测使能。我建议先只使能MMU,DCACHE和ICACHE先关掉,等系统跑稳了再逐个打开。因为缓存问题最难调试,关掉缓存能排除很多干扰。
6.2 用DS-5调试TLB和ICACHE的实用技巧
DS-5(ARM 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的Page,TLB条目数减少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上遇到了奇怪的问题,欢迎在评论区交流,我踩过的坑可能正好能帮你省一个下午。