
简介面向操作系统课程与 NEMU 实验学习者的 TJU NEMU PA3 Stage2 可运行源码包聚焦 NEMU 模拟器中分段与分页两大内存管理机制的落地实现。包内共 3 个文件含 HTML 说明页面、Git 忽略规则配置与 inscode 在线运行配置压缩包仅 7KB结构精简便于快速下载对照。源码覆盖 GDTR/CR0/CR3 寄存器设置、lgdt 与 ljmp 指令实现、swaddr/lnaddr 读写改造、sreg_translate/page_translate 地址转换以及 loader 函数调整等关键步骤可服务 PA3 Stage2 实验调试、代码复现与教学演示。目前已有 310 人学习下载对于正在独立完成实验或准备答辩的读者这套源码提供了一条可运行的参照路径也能帮助深入理解模拟器内存管理原理。 如果你在搜索引擎里敲“PA3”大概率会先看到一大片STM32的PA3引脚配置什么PWMDMA、PA1/PA3之类的帖子。但我今天要聊的“PA3”是另一个物种NEMU的第三个编程作业。标题里的TJU指天津大学NEMU是南京大学计算机系统基础课程配套的RISC-V教学模拟器PA3则是这门课里最让人头皮发麻的一座山。这篇文章围绕“TJU NEMU PA3 Stage2 可运行源码”展开把Stage2该实现的系统调用、用户程序加载、进程调度全部拆开讲清楚每个模块都给可参考的代码和设计依据正在做这个实验的同学可以直接把思路拿走。1. 整体设计Stage2到底要交什么1.1 PA3在课程体系里的位置NEMU这套东西是一条完整的链路NEMU是模拟硬件底层的模拟器AMAbstract Machine是运行在NEMU上的硬件抽象层Nanos是一个极简操作系统跑在AM之上最上面的Navy-apps是用户程序库。PA3要做的就是让这整条链路转起来从“CPU能跑指令”推进到“操作系统能加载并调度多个用户程序”。PA3一般分成两个阶段。Stage1的目标相对简单让Nanos通过loader把单个用户程序加载到内存用户程序通过系统调用请求内核服务能跑通一个hello就及格。Stage2就不一样了它要求Nanos支持多个用户程序并且能轮换执行本质上是实现一个多进程系统的最小内核。标题里“可运行源码”这几个字其实就是指这个阶段需要有一个能同时在NEMU上跑多个程序的完整代码。1.2 Stage1到Stage2的核心跨越从Stage1到Stage2最关键的变化不是代码量变多而是抽象层多了一层进程。Stage1里只有一个用户程序所有系统调用处理完直接返回就行。Stage2里有了多个进程每次系统调用之后都要面临一个选择接下来CPU该执行谁。这个选择就是调度器的工作。我采用的是最简单的round-robin策略进程按固定顺序排成一个循环链表每次系统调用返回前都切换到链表里的下一个活着的进程。为什么选这个而不是优先级调度、时间片轮转之类的复杂策略两个原因。一是PA3的实验目标不是做高性能调度而是让学生理解“上下文切换”“进程状态”这些核心概念round-robin足够说明问题二是实现复杂度低PC B链表加一个next指针就能转起来调试时心智负担小很多。2. 系统调用内核和用户程序的唯一“信箱”2.1 一条write命令怎么从用户程序走到串口系统调用是整个PA3的枢纽。用户程序里调用printf一路走到Navy-apps的_write最后执行ecall指令陷入NEMU的异常处理Nanos的CTE捕获异常后把控制权交给do_syscall。所以系统调用本质上是一个“信箱”用户程序往里投递请求内核取出请求并处理再把结果放回去。NEMU在RISC-V架构下用户程序执行ecall时会把当前模式切到machine模式同时把mcause设置成对应的异常编号。AM的CTEContext Tracking Extension负责保存现场到栈上Nanos在irq_handle里判断如果是系统调用事件就构造出参数数组并分发处理。2.2 syscall.c核心代码我实现里的do_syscall长这样关键点在于用寄存器传递参数这也是RISC-V的惯例void do_syscall(Context *c) { uintptr_t a[4] {c-GPR1, c-GPR2, c-GPR3, c-GPR4}; switch (a[0]) { case SYS_yield: c-GPRx 0; break; case SYS_exit: current-dead true; c-GPRx a[1]; break; case SYS_write: c-GPRx do_write((const char *)a[1], a[2]); break; case SYS_brk: c-GPRx do_brk(a[1]); break; default: panic(Unhandled syscall ID %d, a[0]); } current-cp c; }注意这里没有显式调用切换函数因为切换统一放在中断处理返回前的schedule里。每个系统调用处理完后current-cp c把当前上下文记录到PCB中schedule再来决定返回哪个进程的上下文。do_write是第一阶段就写过的核心就是循环调_putcint do_write(const char *buf, size_t len) { size_t i; for (i 0; i len; i) { _putc(buf[i]); } return i; }_putc由AM的IOE提供底层是往NEMU的串口寄存器写字节。真正容易出问题的是do_brk很多同学对堆区的理解不够就会在这里踩坑。2.3 为什么brk的返回值能当堆顶用brk系统调用做的事很简单调整进程堆区顶部的位置。用户程序的malloc会调用sbrk来申请堆空间最终走到SYS_brk。我的实现里用一个静态变量brk记录当前堆顶static uintptr_t brk 0; int do_brk(uintptr_t addr) { if (addr 0) { return brk; } if (addr brk) { return -1; } brk addr; return 0; }第一个if是标准做法sbrk(0)用来查询当前堆顶不需要修改。第二个if是防止堆顶倒退虽然PA3没有真正的内存隔离但保持这个约束能让行为更接近真实内核。有一个细节值得单独提出来brk初始值是0但用户程序的堆其实是从用户程序.bss段结束后的地址开始的。所以Navy-apps的malloc初始化时第一次sbrk会传入当前cur_brk此时do_brk内部要做的不是直接返回0而是把用户程序的初始堆顶设置好。我踩过这个坑直接让brk0结果malloc拿到的地址跟用户程序数据段重叠程序跑着跑着就花屏。解决方式是让do_brk在第一次调用时把brk初始化为heap.start这个值来自用户程序的链接脚本loader加载ELF时会把堆起始地址同步到内核。如果框架没给也可以约定一个固定地址比如0x50000000和用户程序的0x40000000链接地址错开。3. loader与ELF解析把程序从镜像里捞出来3.1 ramdisk和ELF镜像的关系用户程序编译出来是ELF格式但NEMU没有磁盘怎么把用户程序喂给Nanos答案是ramdisk。原理是把所有用户程序打包成一个二进制镜像数组编译Nanos时链接进去loader从内存中的这个数组里读取ELF并解析。我的Makefile里会把Navy-apps编译出的多个ELF文件用objcopy转成二进制再通过ld合并成ramdisk镜像Nanos代码里通过ramdisk_read函数访问void ramdisk_read(void *buf, off_t offset, size_t count) { assert(offset count RAMDISK_SIZE); memcpy(buf, (void *)(RAMDISK_BASE offset), count); }RAMDISK_BASE和RAMDISK_SIZE由框架生成或者在NEMU内存布局里预留一段空间。Stage2不需要完整文件系统我直接用一个文件表把用户程序名映射到ramdisk的偏移和大小Finfo file_table[] { {hello, 0x0000, 0x1000}, {loop, 0x1000, 0x1000}, {matrix,0x2000, 0x4000}, };文件表可以硬编码但前提是ramdisk镜像的布局和文件表一致。为了可维护我建议写一个小脚本按顺序生成避免每次加减用户程序都要手动改偏移量。3.2 loader完整实现loader要做的事就是解析ELF把可加载段搬到对应的虚拟地址。ELF格式并不复杂关键是program header table里的PT_LOAD段。我的实现uintptr_t loader(Context *c, const char *filename) { size_t offset fsize_open(filename); // 从文件表拿到ramdisk偏移 Elf_Ehdr *elf (Elf_Ehdr *)ramdisk_read(offset, sizeof(Elf_Ehdr)); Elf_Phdr *phdr (Elf_Phdr *)((uint8_t *)elf elf-e_phoff); for (int i 0; i elf-e_phnum; i) { if (phdr[i].p_type PT_LOAD) { ramdisk_read((void *)phdr[i].p_vaddr, offset phdr[i].p_offset, phdr[i].p_filesz); memset((void *)(phdr[i].p_vaddr phdr[i].p_filesz), 0, phdr[i].p_memsz - phdr[i].p_filesz); } } return elf-e_entry; }memset那行尤其重要它负责把.bss段清零。.bss段在ELF里p_filesz为0但p_memsz不为0如果不主动清零全局变量初始会带着ramdisk里的垃圾数据。我一开始偷懒没写结果用户程序里的全局计数器一上来就是随机值排查了很久。loader返回的是ELF入口地址也就是用户程序_start的虚拟地址这个地址由用户程序的链接脚本决定通常是0x40000000。3.3 用户程序的链接脚本与内存布局用户程序为什么固定链接在0x40000000因为loader把程序段直接加载到这个地址而且PA3还没有VME整个地址空间是平坦的用户程序虚拟地址就是物理地址。用户程序的链接脚本类似OUTPUT_ARCH( riscv ) ENTRY( _start ) SECTIONS { . 0x40000000; .text : { *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } .bss : { *(.bss) } . ALIGN(0x1000); . . 0x1000; _heap_start .; }_heap_start就是用户程序堆的起始地址内核do_brk初始堆顶应该从这里取。stack目录下的Navy-apps已经把这段脚本写好但理解它有助于你排查地址冲突问题比如用户程序链接在0x80000000但ramdisk镜像正好占了0x80000000附近加载就会互相覆盖表现是“程序一开始能跑跑到一半随机panic”。4. 进程管理PCB、上下文与调度4.1 PCB的结构设计进程控制块是整个Stage2的骨架。我的PCB非常朴素教学用途不需要红黑树、不需要链表池就是一个数组加next指针#define MAX_TASK 8 #define STACK_SIZE (8 * PGSIZE) typedef struct _PCB { struct _PCB *next; union { struct { uint8_t stack[STACK_SIZE]; }; Context *cp; }; bool dead; uint32_t pid; } PCB; static PCB tasks[MAX_TASK]; PCB *current NULL;注意union的用法每个进程在内核里有一块独立的内核栈cp指针就落在这块栈的顶部。当进程运行时cp保存的是用户程序陷入内核时保存的上下文也就是__am_asm_trap写入的Context结构体。这相当于把“内核栈”和“当前上下文保存区”合二为一是PA框架里比较巧妙的简化。4.2 创建进程从ELF到可运行上下文创建进程的函数kload负责分配一个空闲PCB调用loader加载用户程序然后构造出第一个“上下文”。这个上下文不是真实的寄存器快照而是一个手工拼出来的初始状态Context *ucontext(uintptr_t entry, uintptr_t stack_top) { Context *ctx (Context *)(stack_top - sizeof(Context)); memset(ctx, 0, sizeof(Context)); ctx-mepc entry; // 入口地址 ctx-mstatus 0x1800; // MPP11, 返回M模式 ctx-gpr[2] stack_top; // sp return ctx; } PCB *kload(const char *filename) { PCB *p new_pcb(); if (!p) panic(no free PCB); p-pid next_pid; p-dead false; uintptr_t entry loader(NULL, filename); p-cp ucontext(entry, (uintptr_t)(p-stack STACK_SIZE)); p-next current-next; current-next p; return p; }mstatus设为0x1800意味着mret后CPU会回到M模式。PA3里进程不需要真正用U模式去跑NEMU的权限模型也没强制隔离所以直接让用户程序在M模式运行可以省掉一轮模式切换的麻烦。如果你希望用户程序在U模式就把低两位清零并确保NEMU的PMP配置允许U模式访问内存但一般没必要我建议直接M模式跑通主线再考虑权限问题。4.3 schedule与上下文切换调度器是Stage2“多进程”的题眼。我采用的是最简单的“每次系统调用后切换下一个”策略这样不需要时钟中断就能看到多进程交替执行的效果Context *schedule(Context *prev) { current-cp prev; current current-next; while (current-dead) { current current-next; } return current-cp; }这个函数的输入prev是刚陷入内核时保存的上下文返回的是下一个进程应该恢复的上下文。__am_asm_trap拿到这个返回值后直接把它当成新上下文恢复寄存器并执行mret这样就完成了进程切换。SYS_yield在这种设计里其实不需要额外代码因为任何系统调用返回前都会切走。但为了语义清晰我会保留它并且在调度时如果遇到SYS_yield就只切换不返回值。如果后续你想改造成时间片抢占式调度只需要在NEMU里加一个时钟中断在irq_handle里识别EVENT_IRQ并调用schedule其他地方完全不用动。上下文切换最核心的是保存哪些寄存器。RISC-V调用约定里临时寄存器t0-t6和参数寄存器a0-a7由调用者保存s0-s11、sp、ra由被调用者保存。AM的__am_asm_trap入口汇编会把所有通用寄存器、mepc、mstatus、mcause都压到栈上形成一个完整的Context。这样虽然看起来存了很多冗余但好处是中断处理代码可以随便使用临时寄存器而不担心破坏现场。4.4 初始化和第一个进程系统启动时没有“当前进程”我需要先创建一个idle进程作为链表的起点void ct_init() { memset(tasks, 0, sizeof(tasks)); current tasks[0]; current-pid 0; current-dead false; current-next current; for (int i 1; i MAX_TASK; i) { tasks[i].dead true; } }kload创建进程时会把新进程插到idle后面构成循环链表。注意idle本身不能参与调度所以它的dead要特殊处理或者在schedule里跳过pid为0的进程。我测试时在idle里放过一句panic(idle should not be scheduled)很快就能发现调度链表有没有闭环问题。5. 常见问题与排查技巧实录5.1 我踩过的坑第一个坑是上下文保存不完整导致随机崩溃。我最初只保存了sp和ra结果进程从系统调用返回后循环变量全乱了。解决方式是直接信任AM框架的__am_asm_trap把所有寄存器都保存不要自己精简。第二个坑是loader的p_filesz和p_memsz混淆。p_memsz包含.bss如果直接把p_memsz长度的数据从ramdisk拷贝到内存就会把ramdisk里无关的字节拷到程序数据区。正确做法是只拷贝p_filesz长度剩余部分清零。第三个坑是用户程序栈溢出。用户程序链接脚本里栈区通常只有一页如果程序里定义了较大的局部数组栈会直接溢出到堆区表现为变量被莫名改值。我用-fno-stack-protector关闭栈保护后还手动在用户程序里加过大的递归来测试栈溢出从sp的值能明显看出异常。第四个坑和NEMU本身有关。NEMU的-s选项跑单步执行时如果在异常处理入口打断点si很多次才能看到结果因为会自动跳过很多汇编。建议直接在NEMU的cpu_exec里加一个nemu_state.state NEMU_END的判断程序崩溃时能快速定位。5.2 排查问题时的调试方法调试PA3我的心得是三层下来基本能解决90%的问题。第一层是NEMU内置的info reg命令每次异常时检查mepc、mcause和gpr。比如mcause是11意味着M模式ecall如果用户程序跑在M模式这个值是正常的如果mcause是2说明非法指令基本能断定PC跳飞了。第二层是在Nanos里加调试输出。AM提供了_putc我还会封装一个debug_printf在do_syscall入口打印系统调用号和参数在schedule切换时打印下一个进程的pid。多进程交替执行时看着pid有规律地变化心里会踏实很多。第三层才是看源码。PA框架的代码量不大但__am_asm_trap这种汇编比较劝退。我建议对着AM自带的cte.c一行行读把每条指令都注释一遍很快就能理解“保存现场—分发—恢复现场”的完整流程。5.3 问题速查表现象可能原因解决思路程序load完第一条指令就panicloader加载地址错误或入口地址错误检查ELF头e_entry和p_vaddr程序输出一段后卡死系统调用处理返回后上下文错乱检查schedule返回值是否正确输出中文乱码do_write打印长度不对检查write的len是否为负数malloc分配的内存被覆盖do_brk初始堆顶不对检查_heap_start是否通过kernel heap传入多个程序交替但有一个总不运行PCB链表插入顺序有问题检查kload的链表插入逻辑执行用户ecall后无反应NEMU未触发异常或CTE未初始化检查mtvec是否设置为__am_asm_trap6. 一些可运行性优化建议6.1 让多个程序同时initStage2验收时通常会跑多个用户程序比如hello、loop、matrix同时“启动”。实现方式是在main函数里连续调用kload三次每次kload之后不立即切换而是等所有进程都创建完毕再进入调度循环。这是因为kload内部依赖current-next来插入节点如果把schedule放在kload里可能会在进程还没全部创建时就切走。int main() { ct_init(); kload(/hello); kload(/loop); kload(/matrix); while (1) { yield(); // 让调度器跑起来 } return 0; }这段代码跑起来后终端应该能看到三个程序交替输出pid顺序循环。如果想要更直观的可视化效果可以让每个用户程序打印自己的pid和循环次数比如loop程序每次迭代都是“pid 2 tick 3”这样的格式。6.2 如何验证进程确实“并行”了有些同学跑完三个程序发现输出顺序是AAAABBBBCCCC就以为失败了其实不是。PA3 Stage2没有时间片抢占进程只在系统调用点才会切换所以“同时”是逻辑上的并发不是严格意义上的时间交错。只要调度器每次系统调用后切到不同进程并且多个进程都能推进就算达标。如果你想更明显看到交错效果可以在用户程序里多调用_yield()让每个进程频繁让出CPU输出就会变成ABCABCABC。Navy-apps里一般提供了yield测试可以直接拿来做调度验证。我个人在实际操作中的体会是Stage2最难的不是写代码而是建立“当前执行流”的心智模型。每当你看到NEMU执行到mret你都要问自己这行指令恢复的是哪个进程的现场这个问题的答案清楚了系统调用、调度器、上下文切换就都串起来了。最后再分享一个小技巧把do_syscall里每个系统调用记录到日志文件用less回看比在终端用print大法稳得多尤其是遇到NEMU和终端输出混在一起的情况。Stage2跑通了后面再往上加虚拟内存、文件系统就不会再被基础调度问题绊住。本文还有配套的精品资源点击获取