ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从裸机到内核:ysyx PA批处理系统与RISC-V特权级切换解析

从裸机到内核:ysyx PA批处理系统与RISC-V特权级切换解析 卡在 ysyx 的 PA 批处理系统这一段时间比我做 PA1 和 PA2 加起来还长。前面写 NEMU、写指令模拟的时候CPU 在我眼里就是一个裸的执行引擎——PC 怎么走完完全全由当前程序自己决定。但一进入批处理系统整个视角必须反过来你得站在内核的位置决定什么时候把控制权交给用户程序、什么时候收回来。这个视角的翻转才是真正理解操作系统的起点。这篇文章把我在一生一芯ysyxPA 批处理系统阶段的理解、代码结构、以及排查问题的思路完整记下来。无论你正在做 PA 被卡在某个诡异 bug还是单纯好奇一个最简单的操作系统到底怎么把一个用户程序跑起来这篇应该都能帮到你。我不会只贴结论而是连同为什么是这个方案一起讲清楚。1. 批处理系统为什么是 ysyx PA 里最像操作系统的一段1.1 从 TRM 到 CTE你亲手把程序拆成了两种角色PA 前面的阶段你一直在跟裸机打交道。TRMTiny Runtime Machine阶段AMAbstract Machine给你的抽象极其简单一个程序从 entry point 开始执行跑完就 halt。NEMU 模拟器一启动PC 就在 0x80000000跑的就是你编译出来的那个测试程序。程序跟模拟器之间没有任何中间层程序就是上帝它对 CPU 拥有完全的控制权。批处理系统把这件事彻底改变了。从这一刻起程序这个词被拆成了两种截然不同的角色内核kernelCPU 上电后首先执行的部分。它负责初始化、加载用户程序、处理用户程序发来的请求。用户程序user program被内核挑选、加载到内存然后运行在较低特权级的一段代码。它不直接操作硬件所有需要特权的事都得通过一条指令请求内核代办。这个拆分意味着你不能再把 NEMU 当成一个跑 C 程序的玩具了。它必须变成一台真正能区分当前运行在哪个特权级的机器。RISC-V 体系结构里最小也是最有意义的配置就是 M 态机器态和 U 态用户态两级。内核跑在 M 态用户程序跑在 U 态二者之间靠异常trap机制来回切换。我当时的感受是这就像你以前是单机玩家自己做饭自己吃现在突然要开一个食堂。你是食堂管理者内核有人来吃饭你得给他端菜加载用户程序他吃完把盘子递回来exit 系统调用你再端下一份——整个过程就是批处理。1.2 批处理的本质加载、运行、回收循环往复批处理系统Batch System这个名字听起来很古老但它的逻辑其实特别朴素一次性把一批作业交给计算机计算机按顺序一个一个执行执行完了再换下一个整个过程不需要人盯着交互。对应到 PA 里就是内核在循环里反复做三件事从 ramdisk一个存放在内存里的磁盘镜像中读取下一个用户程序的 ELF 文件把 ELF 中可加载的段拷贝到约定的内存地址清理 BSS设置入口地址跳入用户程序运行等它通过系统调用退出后回到第 1 步加载下一个程序。这里最难的点不是循环本身而是第 3 步的跳入和退出。你不能让用户程序直接call内核的函数因为用户程序是不可信的而且它也没有能力去执行特权指令。用户程序唯一能做的就是执行一条ecall把 CPU 拉进异常处理流程让 PC 强制跳到内核设置的异常入口。这个入口是唯一的、受控的内核在此处做现场保存、系统调用分发、现场恢复最后用mret把 CPU 交回用户态。所以 PA 批处理系统这一章看起来叫操作系统实际练的是处理器体系结构里最核心的特权机制。你写的每一行代码都在为后面 PA 的分时多任务、虚拟内存、甚至将来在 FPGA/NPC 上跑 Linux 做准备。2. 写代码前必须想清楚的三件事内存、特权级和入口2.1 内核与用户程序凭什么不会互相踩踏NEMU 里的物理内存本质上就是一个大字节数组。内核要占地方用户程序也要占地方怎么保证它们不互相覆盖答案是链接脚本linker script。PA 讲义一般会给你一套 Makefile 和链接脚本模板。内核的链接脚本会把内核镜像放在一个固定基地址比如 0x80000000用户程序的链接脚本则把用户程序放在另一个约定好的地址比如 0x83000000。这两个区间在编译时就被锁死了谁也不会去碰对方的地盘。但光靠链接脚本还不够。用户程序在 ramdisk 里是作为 ELF 文件存在的内核想运行它必须自己做一次加载——这是整个章节里第一个隐性门槛。ELF 不是普通二进制镜像它里面包含文件头、程序头表、节头表。其中程序头表program header table里的每个PT_LOAD段才真正描述了哪一段文件内容应该被搬到内存的哪个地址。我当时为了偷懒用了一个很粗暴的做法直接把整个 ELF 文件从 ramdisk 拷到内存入口地址。结果用户程序当然跑不起来。正确的 loader 逻辑应该类似void naive_uload(ELFHeader *elf) { ProgramHeader *ph (ProgramHeader *)((uint8_t *)elf elf-e_phoff); for (int i 0; i elf-e_phnum; i) { if (ph[i].p_type ! PT_LOAD) continue; uint8_t *dst (uint8_t *)ph[i].p_vaddr; uint8_t *src (uint8_t *)elf ph[i].p_offset; memcpy(dst, src, ph[i].p_filesz); // 拷贝文件内容 memset(dst ph[i].p_filesz, 0, // 清零 BSS ph[i].p_memsz - ph[i].p_filesz); } }这个函数看起来很短但如果对它没有敬畏心后面会遇到各种妖魔鬼怪。比如我见过有人把p_offset当成目标地址结果把 ELF 文件头拷到了物理内存最前面继续往下跑内核直接崩了。还有人不做 BSS 清零程序里未初始化的全局变量全是旧数据出来的结果跟薛定谔的全局变量一样。注意p_vaddr是运行地址p_offset只是文件内的偏移。加载用户程序时源是文件中的位置目标是内存中的地址这两者不能混淆。写完之后用readelf -l对照一下程序头表基本能发现自己错在哪。2.2 U 态和 M 态NEMU 里多出来的一个运行模式NEMU 本质上就是一段 C 程序CPU 的状态是什么完全由你的结构体定义决定。在 PA1/PA2CPU 状态可能就是pc和一组通用寄存器gpr[]。但到了批处理系统你必须引入当前运行模式这个概念一般用cpu.mode表示取值为MODE_U或MODE_M。这里有一个极其关键的设计选择究竟是只靠cpu.mode还是通过读 mstatus CSR 来推导我建议你两个都保留原因后面会讲。简单地说mstatus 寄存器里有几个位非常重要MPP记录进入异常之前CPU 处于哪个特权级。ecall发生时硬件会把原特权级写进 MPP。MPIE记录进入异常前中断是否使能。MIE当前机器态中断使能位批处理阶段主要用它的语义不一定真正实现中断。cpu.mode更像是模拟器内部为了方便调试和维护而维护的影子变量。每次执行ecall、mret或写 mstatus 时同步更新它。这样你在日志里随时能打印出cpu.mode快速判断当前处于什么状态。特权级的引入看起来只是加了一个字段但它改变了很多东西。比如U 态执行的指令集合和 M 态不完全一样。在真正的 RISC-V 处理器里U 态访问某些 CSR、执行特权指令会触发非法指令异常。批处理系统阶段PA 通常不会强制你实现完整的内存访问权限检查但ecall和mret的特权级转换语义必须做对否则后面加虚拟内存的时候你会寸步难行。2.3 mtvec 是唯一合法的跳进内核的口子用户程序不能直接跳转到内核函数它只能执行ecall。CPU 收到ecall异常后会强制把 PC 改成mtvec寄存器指向的地址。因此内核在启动时第一件事就是把异常入口地址写入mtvec。异常入口通常是一个汇编小函数它要做的事情是保存用户程序现场、切换到内核栈、调用 C 语言 handler。保存现场非常原始本质上就是把所有通用寄存器压栈同时把mepc异常返回地址和mstatus也保存好。这一步不做好后面系统调用返回时用户程序的状态就全乱了。RISC-V 里常见的异常编号mcause我们心里要有数mcause含义3breakpointebreak8environment call from U-mode用户态 ecall11environment call from M-mode机器态 ecall批处理系统主要处理的是 cause 8。NEMU 在实现ecall时可以根据当前cpu.mode来决定写入哪个 cause。如果你不管模式一律写 8暂时也能跑但会埋下隐患——后面做中断和多任务时会分不清来源。3. NEMU 侧的最小实现ecall、CSR 和 mret3.1 识别 SYSTEM 指令别让 ecall 被当成普通指令批处理系统阶段你要在 NEMU 的指令解码里新增 SYSTEM 类指令。RISC-V 中这类指令的 opcode 是0x73。继续看 funct3 字段当 funct3 0 时这条指令属于系统指令具体行为由 12 位立即数决定0x000ecall0x302mret0x001ebreak而 funct3 1、2、3、5、6、7 时分别是csrrw、csrrs、csrrc、csrrwi、csrrsi、csrrci这批 CSR 访存指令在 PA 阶段也是必须实现的。因为内核代码里要通过csrw这类伪指令去写mtvec、mepc、mstatus。我踩过的第一个坑就是 decode 阶段的掩码写错。RISC-V 指令编码里ecall和mret的空闲字段位置不同如果你只做了 opcode 匹配而没有检查 funct3很可能把mret当成ecall处理PC 突然跳到异常入口整个内核就莫名其妙跑飞了。一个比较稳妥的识别方式是在 NEMU 的decode_exec分支里单独拉一个 caseINSTR_ID(SYSTEM) { switch (decinfo.imm) { case 0x000: // ecall … case 0x302: // mret … default: // csrrw/csrrs/csrrc 等 } }3.2 异常现场mstatus、mepc、mcause 三个人各管什么ecall触发的异常处理流程NEMU 应该在一个函数里集中完成模拟 RISC-V 硬件的特权级切换void isa_raise_intr(uint32_t NO, vaddr_t epc) { // 1. 记录异常原因和返回地址 cpu.csr-mcause NO; cpu.csr-mepc epc; // 2. 保存异常前的特权级与中断使能状态 cpu.csr-mstatus (cpu.csr-mstatus ~MSTATUS_MPP_MASK) | (cpu.mode MSTATUS_MPP_SHIFT); cpu.csr-mstatus (cpu.csr-mstatus ~MSTATUS_MPIE_MASK) | ((cpu.csr-mstatus MSTATUS_MIE_MASK) 4); cpu.csr-mstatus ~MSTATUS_MIE_MASK; // 关中断 // 3. 切换到 M 态跳到异常入口 cpu.mode MODE_M; cpu.pc cpu.csr-mtvec; }这里唯一需要特别注意的是epc到底是什么。RISC-V 的规定是ecall指令本身把当前 PC 放入mepc也就是说mepc指向的是那条 ecall 指令而不是下一条指令。这个行为和函数调用不同和很多其他架构的异常返回地址也不同。它的实际效果是当mret恢复 PC 时你会回到 ecall 这一句然后再次触发 ecall无限循环。所以内核里系统调用处理完成后软件需要主动把mepc加 4跳过这条 ecall。这个操作可以在 AM 的 trap 处理逻辑里完成而不是在 NEMU 的mret指令里做。因为从架构语义上看mret就应该忠实地把 PC 恢复到mepc原值至于要不要跳过 ecall是软件策略不是硬件行为。3.3 mret也是第一次进入用户态的工具mret的处理逻辑正好是ecall的逆过程void exec_mret() { cpu.pc cpu.csr-mepc; // 从 MPP 恢复特权级 cpu.mode (cpu.csr-mstatus MSTATUS_MPP_MASK) MSTATUS_MPP_SHIFT; // 恢复中断使能状态 cpu.csr-mstatus (cpu.csr-mstatus ~MSTATUS_MIE_MASK) | ((cpu.csr-mstatus MSTATUS_MPIE_MASK) 4); cpu.csr-mstatus | MSTATUS_MPIE_MASK; // 置回 MPIE }但有一个特别反直觉的用法你可能想不到第一次进入用户态不是某种进入用户态指令而是靠mret来实现的。内核把用户程序的入口地址写进mepc把mstatus.MPP置为 U 态然后执行mret。CPU 一看好mepc有值MPP是 U 态于是跳过去并且切换到 U 态。这一手跟假装刚从异常返回很像——虽然并没有发生异常但你构造出了一个异常返回的假象。RISC-V 的设计里没有jump-to-user-mode指令mret就是唯一的降级通道。理解这一点之后再看 AM 的 CTE 实现就不会觉得别扭了trap 入口保存现场、调用 handler、最后mret恢复程序这套机制天然地也能用来启动一个用户程序。4. AM 的 CTE 化腐朽为神奇系统调用分发的完整链路4.1 用户程序侧参数进寄存器一行 ecall 完事用户程序想要打印一个字符串它不知道什么叫内核、什么叫显存它只知道自己调用了 AM 提供的_putstr。而 AM 的库函数_putstr实现得非常薄薄到几乎只有几行int _putstr(const char *s) { return _syscall_(SYS_putstr, (uintptr_t)s, 0, 0, 0, 0, 0); }这里的_syscall_最终是通过内联汇编把系统调用编号放进a7参数依次放进a0、a1……然后执行ecallstatic int _syscall_(int num, ...) { // 实际项目中用 variadic 方法从寄存器取值 __asm__ volatile(ecall : r(a0) : r(a0), r(a7) /* ... */); return a0; }用户程序完全没有必要知道 PC 怎么跳转、现场怎么保存。它只要把参数放进约定好的寄存器再执行ecall之后醒来时a0里就是系统调用的返回值。这种薄封装是 AM 设计里非常漂亮的地方——用户程序侧看到的是一套标准 C API底层换成 NEMU 还是 NPCAPI 都不用变。顺便说一句PA 讲义里系统调用编号通常是写在公共头文件里比如SYS_putstr 0、SYS_exit 1、SYS_yield 2之类。你不需要死记硬背但要保证用户态_syscall_和内核态do_syscall用的是同一份定义。4.2 内核侧保存现场、分发调用、返回结果内核侧的异常入口第一件事是把当前所有寄存器的值压到一个Context结构体里。这个结构体就是用户程序现场的完整快照。AM 的Context类型通常包含所有通用寄存器gpr[]mepc对应的epc字段mstatus对应的status字段保存完现场后入口汇编调用 C 语言函数__am_irq_handle把Context *传进去。这个函数再调用cte_init时注册的 handler批处理系统阶段对应的就是内核的do_syscallvoid do_syscall(Context *c) { uintptr_t sysnum c-gpr[17]; // a7 switch (sysnum) { case SYS_exit: halt(c-gpr[10]); // a0 是退出码 break; case SYS_putstr: _putstr((char *)c-gpr[10]); // a0 是字符串地址直接打印 break; case SYS_yield: // 暂时可空转或者什么都不做 break; default: printf(Unhandled syscall: %lx\n, sysnum); break; } c-gpr[10] 0; // 返回值放进 a0 c-epc 4; // 跳过当前 ecall 指令 }这个do_syscall可以说是 PA 里操作系统味道最浓的一个函数。它让你第一次感觉到用户程序不是随便想干嘛就干嘛它只能通过这 6 个括号里的编号来申请服务。想不通过系统调用直接访问硬件在无虚拟内存、无权限检查的批处理系统阶段虽然实际没有硬件拦截但这条软件边界已经立起来了。4.3 系统调用的返回值是怎么绕过复杂的 trap 回来的从do_syscall返回后AM 的 CTE 逻辑要负责把恢复后的Context写回 CPU。核心就是从Context中取出epc、status、所有 gpr重新填回 NEMU 的寄存器文件然后设置好 mstatus执行mret。这里有个非常关键的细节handler 里修改了c-epc和c-gpr[10]这个修改必须能一路带回到mret。如果你在 trap 入口保存现场时是把寄存器原样压栈然后handler直接使用栈上的Context那没问题。但如果你图省事保存的是寄存器的拷贝副本handler 改了副本返回时恢复的是旧现场那用户程序拿到的返回值永远是 0 或者垃圾数据。AM 的trap-return汇编看起来大概这样__am_iret: lw a0, offset_status(a0) # 恢复 mstatus csrw mstatus, a0 lw a0, offset_epc(a0) # 恢复 mepc csrw mepc, a0 # 从 Context 中逐个恢复通用寄存器 ... mret一路追下去你会发现用户程序执行ecall到内核做完服务再到用户态拿到返回值整个过程其实经历了保存现场 → 构造现场 → 恢复现场的循环。批处理系统阶段这个循环每秒钟可能只发生几次但它把中断/系统调用的完整链路彻底打通了。之后你再看任何 RTOS 或者 Linux 内核里的syscall处理都能从这段代码里找到影子。5. 踩坑实录我调过的几个最隐蔽的问题5.1 症状mret 之后用户程序又从原地开始跑这是我印象最深的 bug。用户程序第一次打印字符串是正常的但是mret之后它没有执行下一条指令而是回到同一句 ecall然后再次陷入内核无限循环。NEMU 控制台上同一个字符串刷屏刷得飞快。我当时的排查流程先怀疑是 NEMU 的mret写错了打印cpu.pc、mepc。发现mepc指向的地址每次都是同一个也就是 ecall 自己。再回头看do_syscall发现整个 switch 结束后我只设置了返回值忘了c-epc 4。加上这一行之后问题立刻消失。这个 bug 的根源是对 RISC-V 异常语义理解不足。ecall保存的是当前指令自己的 PC而不是下一条指令的 PC。硬件不帮你加 4软件必须负责跳过。这是体系结构手册里可能只有一行字的规定但实际跑起来就是会咬你一口。注意epc 4只适用于指令长度固定为 32 位的 RISC-V 基础指令集。如果哪天你用了压缩指令RVC每条指令长度可能是 16 位那这里就不能简单加 4。PA 阶段一般不用压缩指令所以还算安全。5.2 症状用户程序一直处于 M 态系统调用的行为不对劲另一个让我折腾半天的现象用户程序在 NEMU 里跑是没有报错但行为怪怪的比如某些本该被拦截的特权指令居然成功执行了。我打着日志发现cpu.mode在mret之后依然是MODE_M根本没有切回 U 态。排查到最后问题出在 NEMU 的mret实现里掩码和移位搞错了。我当时直接从mstatus里抠MPP位时取出来的值是(mstatus 0x1800) 11但mstatus的布局里MPP占两位如果之前 MPP 里存的是MODE_U值为 0被复位或写错成MODE_M值为 3恢复出来的就是 M 态。另外还有一个隐藏的坑如果 trap 入口在保存status时读取 NEMU 内部mstatus的时机不对可能在ecall已经把 MPP 更新成 M 态之后才去保存导致保存下来的现场其实已经是内核态现场。等返回时再恢复就把用户程序错误地恢复成了 M 态。正确的保存时机是在ecall更新 mstatus 之前也就是保存在异常发生那一瞬间的状态。排查这个问题的通用方法就是在exec_mret前后各打印一条日志Log(before mret: pc0x%lx mode%d mstatus0x%lx mepc0x%lx, ...); // 执行恢复 Log(after mret: pc0x%lx mode%d mstatus0x%lx, ...);看到mode没有从 M 变成 U基本就是恢复逻辑的问题。再看一眼MPP位被读出来是多少就能定位是掩码错误还是保存阶段错误。5.3 症状字符串打印出来是乱码内核自己打印日志时一切正常但只要系统调用打印用户态字符串就出现一段正常一段乱码的情况。乱码模式很有规律似乎某几个字符是对的但偏移不对。排查链路先确认用户程序字符串在内存里的数据是什么。我在 NEMU 里加了一个简单的内存 dump 函数打印目标地址前后 64 字节。跟用户程序 ELF 里的字符串对比发现内存里的内容跟我预期的运行地址差了一整个段偏移。再回头看 loader发现我拷贝数据时用的是p_offset而不是p_vaddr。也就是说我把 ELF 文件里偏移 0x1000 的文件内容原封不动搬到了物理地址 0x1000而不是用户程序链接脚本规定的运行地址。这个错误属于ELF loader 理解不够透彻。每个PT_LOAD段有四个关键字段p_offset文件内偏移、p_vaddr运行地址、p_filesz文件大小、p_memsz内存大小。正确的加载逻辑是把文件内偏移处的内容搬到运行地址处中间做一个偏移换算而不是简单地用memcpy(dst, src, ...)直接以p_vaddr为源。修复后我还特意检查了 BSS 清零。很多用户程序里会定义全局数组比如缓冲区如果不把p_memsz和p_filesz的差值清零这些全局变量就带着 ramdisk 里的残留数据打印出来自然是灵异乱码。5.4 症状用户程序第一条指令就反汇编不出来这个 bug 更早一些发生在mret跳到用户程序入口之后。NEMU 的调试器显示 PC 值是对的但当前指令反汇编出来是0x00000000明显不是合法指令。我感觉用户程序的入口地址处放的并不是代码。排查过程先用readelf -h user确认e_entry字段。发现入口地址是 0x83000000。dump 内存 0x83000000 处的字节发现全是 0。再看 ramdisk 读取发现自己从 ramdisk 里读入用户程序时用的是一次读一个 32 位字word_t的方式但写入内存时用的也是word_t的存储而 NEMU 物理内存是小端序。如果 ramdisk 的字节序和 NEMU 的内存字节序不一致整个镜像的字节顺序就会被翻转等于代码全错了。这个问题的典型特征是e_entry看着没问题但入口地址处的内容跟 ELF 里e_entry对应的字节完全对不上。解决办法是加载 ELF 时按字节读取、按字节写入或者统一用memcpy从 ramdisk 缓冲区拷贝到目标地址。如果你在 NEMU 里用vaddr_read/vaddr_write循环读写也要小心它们内部有字节序转换逻辑避免转两次。另外我后来发现一个非常实用的排查工具在你自己的 loader 函数里加几个assert。比如检查 ELF magic、检查p_vaddr p_memsz是否落在用户程序的地址区间内。这些 assert 看着朴素但在后续 PA 阶段改内存布局时能帮你第一时间发现加载位置和链接位置不一致的问题。5.5 附加提醒diff-test 在这个阶段会误报如果你一直开着 PA1 的 diff-test差分测试到批处理系统阶段很容易被寄存器不匹配的报告干扰。因为 QEMU 里 CSR 的可见状态、异常返回时的细节语义跟 NEMU 的实现不完全一致一旦进入异常处理diff-test 就会疯狂报错。这不是说 NEMU 一定是错的。真实项目里如果实现了完整的 CSR 和异常语义diff-test 在ecall/mret之后也可能对齐。但在 PA 的有限实现下讲义通常会在某个节点提示你关闭某些检查项。我的建议是先确认自己的实现符合 RISC-V 手册里 ecall/mret 的核心语义再决定要不要忽略 diff-test 的异常相关报错。不要让 diff-test 的误报掩盖真正的 bug。6. 跑通 hello 之后别急着庆祝6.1 把批处理变成多道批处理的第一次进阶当你能让两个、三个用户程序按顺序加载运行批处理系统阶段的核心目标就算达成了。这时候你会发现所谓操作系统的最简模型其实已经完整了有加载器、有系统调用、有特权级切换、有退出机制。后面的 PA 往哪个方向走分时多任务。核心变化是用户程序不再运行到 exit 才切回内核而是运行一段时间后通过时钟中断或者yield主动让出 CPU。内核在多个程序之间轮流切换。概念上只需要在批处理循环的基础上把加载下一个程序改成切换到下一个程序的上下文。但实现起来可一点不简单因为你要管理多个进程控制块PCB、多个上下文。不过经过批处理系统的历练再看到进程切换你就知道它本质上还是保存现场 → 换现场 → mret只是现场的载体从一个栈变成了一个数组。6.2 NEMU 上的运行契约如何原封不动搬去 NPCysyx 的最终目标是流片而 PA 的批处理系统通常在 NEMU 上跑通。但你不应该只在模拟器上爽一把就完了。NEMU 是一个 C 语言写的参考模型NPC 是你用 Verilog 写的真实处理器。二者的关系很像同一个程序跑在仿真器和真实 CPU 上。验证它们行为一致靠的就是同一套 AM、同一份系统调用约定NEMU 跑通用户程序记录每次系统调用时的寄存器、内存变化NPC 执行同样的镜像逐条指令对比输出。这个过程本质上和你在芯片测试里做的事情一模一样。我之前折腾射频模块时测芯片上 PA功率放大器匹配电路的 S 参数也是先在仿真软件里搭好匹配网络、定好端口阻抗再上台架实测反射系数。仿真模型的输出就是你的标准答案DUT待测设备的实测结果必须跟它对齐。NEMU 之于 NPC就是那个标准答案。你要是连 NEMU 里的批处理系统都没跑对就别指望 Verilog 第一次上板就能打出 hello world。所以我强烈建议PA 跑到批处理系统后回头把自己的 NEMU 代码清一遍把每个ecall/mret/CSR分支的日志写好。这些日志就是你之后调 NPC 的S 参数标准值。等到 NPC 阶段如果行为不一致你至少知道是哪个环节偏离了参考模型。6.3 个人体会中断从玄学变成工具的那一瞬间打通批处理系统的整个链路之后我最大的收获不是我写了个操作系统而是对中断和系统调用彻底脱敏了。以前看 Linux 内核或者 RTOS 的源码一碰到syscall、irq、trap就头大总觉得这些是黑魔法。但亲手从 NEMU 的ecall解码开始一路做到内核do_syscall分发、再mret回到用户态每一级都变成了自己写过的东西。再回头看任何操作系统的异常处理看到的不是神秘寄存器而是一连串哦这里相当于我当时的 XX 函数。按照我自己的经验这一步你不需要追求把所有边界条件都做完美比如中断嵌套、高精度时钟这些都可以留到后面。但核心语义——ecall 的 mepc 行为、mret 的特权级恢复、系统调用返回值的传递——这三件事必须一条链通到底。它们通了批处理系统的灵魂也就通了。
返回列表