简介:面向操作系统课程与 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引脚配置,什么PWM+DMA、PA1/PA3之类的帖子。但我今天要聊的“PA3”是另一个物种:NEMU的第三个编程作业。标题里的TJU指天津大学,NEMU是南京大学计算机系统基础课程配套的RISC-V教学模拟器,PA3则是这门课里最让人头皮发麻的一座山。这篇文章围绕“TJU NEMU PA3 Stage2 可运行源码”展开,把Stage2该实现的系统调用、用户程序加载、进程调度全部拆开讲清楚,每个模块都给可参考的代码和设计依据,正在做这个实验的同学可以直接把思路拿走。
1. 整体设计:Stage2到底要交什么
1.1 PA3在课程体系里的位置
NEMU这套东西是一条完整的链路:NEMU是模拟硬件底层的模拟器,AM(Abstract 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的CTE(Context 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是第一阶段就写过的,核心就是循环调_putc:
int 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,而是把用户程序的初始堆顶设置好。我踩过这个坑:直接让brk=0,结果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; // MPP=11, 返回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完第一条指令就panic | loader加载地址错误或入口地址错误 | 检查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_trap |
6. 一些可运行性优化建议
6.1 让多个程序同时init
Stage2验收时通常会跑多个用户程序,比如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跑通了,后面再往上加虚拟内存、文件系统,就不会再被基础调度问题绊住。
本文还有配套的精品资源,点击获取