1. 操作系统的结构(以 Nanos-lite 为例)
在 PA 中,我们使用的操作系统是Nanos-lite,它是南京大学操作系统 Nanos 的裁剪版,专为 PA 量身定制。
Nanos-lite 运行在AM(Abstract Machine)之上,而 AM 又直接构建在NEMU(硬件模拟器)提供的 ISA 机制上。
从层次化的角度看,整个系统的结构如下:
+-------------------+ | 用户程序/进程 | ← 通过系统调用请求操作系统服务 +-------------------+ | Nanos-lite | ← 操作系统内核 +-------------------+ | AM(抽象机) | ← 提供架构无关的 API(ioe, cte, vme 等) +-------------------+ | NEMU(硬件模拟) | ← 模拟 riscv32 ISA +-------------------+Nanos-lite 本身只是一个普通的 AM 程序,它调用 AM 的 API 来实现功能。正因为 AM 屏蔽了硬件细节,Nanos-lite 的实现是架构无关的——同一份代码可以方便地在不同 ISA 上运行(当前我们仅聚焦于 riscv32)。
Nanos-lite 的源码结构如下:
nanos-lite/ ├── include/ │ ├── common.h # 通过宏控制功能,配合实验进度逐步开启 │ ├── debug.h │ ├── fs.h # 文件系统接口 │ ├── memory.h # 内存管理 │ └── proc.h # 进程管理 ├── src/ │ ├── main.c # 内核入口 │ ├── device.c # 设备抽象 │ ├── ramdisk.c # ramdisk 驱动(用内存模拟磁盘) │ ├── fs.c # 文件系统实现 │ ├── loader.c # 用户程序加载器 │ ├── mm.c # 存储管理 │ ├── proc.c # 进程调度 │ ├── irq.c # 中断与异常处理 │ └── syscall.c # 系统调用处理 └── resources/ └── logo.txt # 启动 logo2. 跑在 NEMU+AM 上的最简单操作系统
在未开启任何实验模块时,Nanos-lite 就是一个最小的、能跑起来的操作系统。其行为:
- 打印 Project-N logo,并通过
Log()输出欢迎信息和编译时间。 - 调用
init_device(),完成基本的 I/O 设备初始化。 - 初始化 ramdisk:将一段内存作为模拟磁盘。
- 调用
init_fs()和init_proc()(目前为空)。 - 调用
panic()结束运行。
编译与运行:
cdnanos-litemakeARCH=riscv32-nemu run也可用 native 调试:
makeARCH=native runNanos-lite 在 AM 看来就是一个普通 C 程序,这体现了 AM 屏蔽硬件细节的优点。
3. 加载第一个用户程序
3.1 用户程序的来源:Navy-apps
用户程序由 Navy-apps 子项目编译生成:
cdics2025bashinit.sh navy-appsNavy 的结构包含apps(用户程序)、libs(Newlib、libos 等)、tests等。用户程序的入口在libs/libos/src/crt0/start.S的_start()。
3.2 第一个用户程序:dummy
我们使用navy-apps/tests/dummy/dummy.c。为避免冲突,用户程序链接到0x83000000(riscv32)。
编译:
cdnavy-apps/tests/dummymakeISA=riscv32然后将生成的可执行文件复制为 ramdisk 镜像:
cpbuild/dummy-riscv32../../nanos-lite/build/ramdisk.img在nanos-lite/下编译时,resources.S会将ramdisk.img嵌入内核映像。
3.3 ELF 加载与 loader 实现
Loader 需要回答四个问题:
- 可执行文件在哪? → 在 ramdisk 偏移 0 处。
- 代码和数据在哪?有多少? → 由 ELF 程序头表描述。
- "正确的内存位置"在哪? → 由
VirtAddr指明(dummy 为0x83000000)。
加载PT_LOADsegment 时,需要:
- 读取
FileSiz字节到[VirtAddr, VirtAddr+FileSiz) - 将
[VirtAddr+FileSiz, VirtAddr+MemSiz)清零(对应 .bss 段)
3.4 loader 实现
在loader.c中实现:
#include<fs.h>#include<proc.h>#include<elf.h>#defineElf_EhdrElf32_Ehdr#defineElf_PhdrElf32_Phdrstaticuintptr_tloader(PCB*pcb,constchar*filename){intfd=fs_open(filename,0,0);Elf_Ehdr ehdr;fs_read(fd,&ehdr,sizeof(ehdr));// 魔数检查assert(ehdr.e_ident[0]==0x7f&&ehdr.e_ident[1]=='E'&&ehdr.e_ident[2]=='L'&&ehdr.e_ident[3]=='F');// ISA 检查#ifdefined(__riscv)#defineEXPECT_TYPEEM_RISCV#else#errorUnsupported ISA#endifassert(EXPECT_TYPE==ehdr.e_machine);for(inti=0;i<ehdr.e_phnum;i++){Elf_Phdr phdr;fs_lseek(fd,ehdr.e_phoff+i*sizeof(phdr),SEEK_SET);fs_read(fd,&phdr,sizeof(phdr));if(phdr.p_type==PT_LOAD){uint32_tp_vaddr=phdr.p_vaddr;uint32_tp_memsz=phdr.p_memsz;uint32_tp_filesz=phdr.p_filesz;uint32_tp_offset=phdr.p_offset;fs_lseek(fd,p_offset,SEEK_SET);fs_read(fd,(void*)(uintptr_t)p_vaddr,p_filesz);memset((void*)(uintptr_t)(p_vaddr+p_filesz),0,p_memsz-p_filesz);}}returnehdr.e_entry;}voidnaive_uload(PCB*pcb,constchar*filename){uintptr_tentry=loader(pcb,filename);Log("Jump to entry = %p",entry);((void(*)())entry)();}在init_proc()中调用naive_uload(NULL, NULL)即可加载并跳转。成功后会触发未处理的系统调用事件。
4. 操作系统的运行时环境与系统调用
4.1 资源管理与系统调用必要性
多任务环境下,操作系统必须统一管理资源,用户程序通过系统调用请求服务。系统调用将运行时环境分为内核区和用户区,内核实现特权操作,用户程序通过自陷指令进入内核。
4.2 RISC-V 的系统调用机制
4.2.1 用户层封装:_syscall_()
在 RISC-V 中,使用ecall触发系统调用。用户层的_syscall_()函数位于navy-apps/libs/libos/src/syscall.c,它是所有用户程序发起系统调用的统一入口:
intptr_t_syscall_(intptr_ttype,intptr_ta0,intptr_ta1,intptr_ta2){registerintptr_t_gpr1asm(GPR1)=type;registerintptr_t_gpr2asm(GPR2)=a0;registerintptr_t_gpr3asm(GPR3)=a1;registerintptr_t_gpr4asm(GPR4)=a2;registerintptr_tretasm(GPRx);asmvolatile(SYSCALL:"=r"(ret):"r"(_gpr1),"r"(_gpr2),"r"(_gpr3),"r"(_gpr4));returnret;}这里的GPR1、GPR2、GPR3、GPR4、GPRx和SYSCALL都是通过宏定义来适配不同 ISA 的。它们由navy-apps/libs/libos/src/syscall.h(实际指向 AM 的架构相关头文件)根据编译目标架构自动展开。
对于 RISC-V(非 E 扩展),宏展开如下:
// 宏定义(来自 arch/riscv.h 或 syscall.h)#defineSYSCALL"ecall"// RISC-V 的自陷指令#defineGPR1"a7"// 系统调用号存放寄存器#defineGPR2"a0"// 第 1 个参数#defineGPR3"a1"// 第 2 个参数#defineGPR4"a2"// 第 3 个参数#defineGPRx"a0"// 返回值存放寄存器展开后的实际代码等价于:
intptr_t_syscall_(intptr_ttype,intptr_ta0,intptr_ta1,intptr_ta2){registerintptr_t_gpr1asm("a7")=type;// 系统调用号放入 a7registerintptr_t_gpr2asm("a0")=a0;// 第 1 个参数放入 a0registerintptr_t_gpr3asm("a1")=a1;// 第 2 个参数放入 a1registerintptr_t_gpr4asm("a2")=a2;// 第 3 个参数放入 a2registerintptr_tretasm("a0");// 返回值将从 a0 读取asmvolatile("ecall":"=r"(ret):"r"(_gpr1),"r"(_gpr2),"r"(_gpr3),"r"(_gpr4));returnret;}各寄存器的含义与作用:
| 宏 | RISC-V 寄存器 | 作用 | 说明 |
|---|---|---|---|
GPR1 | a7 | 传递系统调用号 | 告诉内核用户程序想执行哪个系统调用(如SYS_write、SYS_brk) |
GPR2 | a0 | 传递第 1 个参数 | 例如write(fd, buf, count)中的fd |
GPR3 | a1 | 传递第 2 个参数 | 例如write(fd, buf, count)中的buf |
GPR4 | a2 | 传递第 3 个参数 | 例如write(fd, buf, count)中的count |
GPRx | a0 | 存放返回值 | 内核处理完毕后,将返回值写入a0,用户程序读取 |
内联汇编解析:
asmvolatile("ecall":"=r"(ret):"r"(_gpr1),"r"(_gpr2),"r"(_gpr3),"r"(_gpr4));"ecall":执行 RISC-V 自陷指令,CPU 从用户态(U-mode)陷入机器态(M-mode),跳转到mtvec寄存器指向的异常处理入口。"=r" (ret):输出约束,表示ecall返回后,从a0寄存器读取返回值存入 C 变量ret。"r"(_gpr1) ~ "r"(_gpr4):输入约束,保证在执行ecall之前,a7、a0、a1、a2已经被正确赋值。
4.2.2 为什么 RISC-V 用a7而不是a0传递系统调用号?
RISC-V Linux 约定用a7存放系统调用号,a0~a5传递最多 6 个参数。这样设计的优势在于:
- 与函数调用接口统一:普通函数调用时
a0就是第一个参数。如果系统调用号也放在a0,那么第一个真正的参数就要挪到a1,导致系统调用封装和普通函数调用的参数位置不一致。 - 便于内核快速分发:内核从上下文中取出
a7即可立即知道调用号,不用额外做偏移计算。
4.2.3 从用户层到内核的完整路径
用户程序调用 _write(fd, buf, count) │ ▼ _write() 调用 _syscall_(SYS_write, fd, buf, count) │ ├─ 将 SYS_write 放入 a7 ├─ 将 fd 放入 a0 ├─ 将 buf 放入 a1 ├─ 将 count 放入 a2 └─ 执行 ecall │ ▼ CPU 陷入 M-mode,跳转到异常处理入口 │ CTE (AM) 保存上下文,打包为 EVENT_SYSCALL │ ▼ Nanos-lite 的 do_syscall(Context *c) │ ├─ 从 c->GPR1 (即 a7) 获取系统调用号 ├─ 从 c->GPR2 (即 a0) 获取第 1 个参数 ├─ 从 c->GPR3 (即 a1) 获取第 2 个参数 ├─ 从 c->GPR4 (即 a2) 获取第 3 个参数 ├─ 根据调用号分发到具体处理函数 ├─ 将返回值写入 c->GPRx (即 a0) └─ c->mepc += 4 (避免重复执行 ecall) │ ▼ mret 返回用户态 │ _syscall_() 从 a0 读取返回值,返回给 _write()4.3 内核侧系统调用分发
do_syscall()从上下文的GPR1(a7)获取调用号,分发处理。关键代码:
voiddo_syscall(Context*c){uintptr_ta[4];a[0]=c->GPR1;// 系统调用号 (a7)a[1]=c->GPR2;// 第 1 个参数 (a0)a[2]=c->GPR3;// 第 2 个参数 (a1)a[3]=c->GPR4;// 第 3 个参数 (a2)switch(a[0]){caseSYS_exit:sys_exit();break;caseSYS_yield:sys_yield();c->GPRx=0;break;caseSYS_write:c->GPRx=sys_write(a[1],(void*)a[2],a[3]);break;caseSYS_brk:c->GPRx=sys_brk();break;caseSYS_open:c->GPRx=sys_open((constchar*)a[1],a[2],a[3]);break;caseSYS_read:c->GPRx=sys_read(a[1],(void*)a[2],a[3]);break;caseSYS_lseek:c->GPRx=sys_lseek(a[1],a[2],a[3]);break;caseSYS_close:c->GPRx=sys_close(a[1]);break;caseSYS_execve:sys_execve((constchar*)a[1]);break;caseSYS_gettimeofday:c->GPRx=sys_gettimeofday((structtimeval*)a[1],(structtimezone*)a[2]);break;default:panic("Unhandled syscall ID = %d",a[0]);}#ifdef__riscvc->mepc+=4;// 避免重复执行 ecall#endif}4.4 实现 SYS_yield 和 SYS_exit
SYS_yield:直接调用yield()(让出 CPU)并返回 0。SYS_exit:最初可调用halt(0),后续改为加载菜单或新程序,例如sys_execve("/bin/menu")。
5. 系统调用的踪迹与 TRM 支持
5.1 strace:跟踪系统调用
在do_syscall中添加printf打印调用号和参数、返回值,可以简单实现 strace,帮助调试。
5.2 实现 SYS_write
标准输出的支持:当fd == 1或fd == 2时,将buf中的count字节通过putch()输出,返回count。这使printf等函数可用。
5.3 堆区管理:SYS_brk 与 _sbrk
5.3.1 用户程序的内存布局
在理解堆区管理之前,我们先看一个简化后的用户程序地址空间:
用户程序地址空间(简化) ┌──────────────┐ 高地址 │ 栈 (stack) │ ├──────────────┤ │ ↓ │ │ (空闲) │ │ ↑ │ ├──────────────┤ ← new_brk = current_brk + increment (扩展后) │ 堆 (heap) │ ← 新分配的内存区域 │ │ [old_brk, new_brk) 交给 malloc 管理 ├──────────────┤ ← old_brk / 上一次的 current_brk │ BSS 段 │ ├──────────────┤ ← _end (链接器符号,标记数据段末尾) │ 数据段 │ ├──────────────┤ │ 代码段 │ └──────────────┘ 低地址_end:由链接器在链接时自动生成的符号,指向数据段(包括 BSS)的结束位置。- 堆的起始位置就是
_end,堆向高地址方向增长(向栈的方向靠近)。 - 堆顶的当前位置称为program break,用
current_brk记录。
5.3.2 职责分离的设计理念
堆区管理涉及到两个角色的协作:
| 角色 | 位置 | 职责 |
|---|---|---|
_sbrk() | 用户层(libos) | 记录"已经用了多少堆空间"(维护current_brk指针) |
sys_brk() | 内核层(Nanos-lite) | 决定"能不能用更多内存"(检查物理内存是否充足) |
这种设计将**"能不能用"的决策权交给内核**,用户层只负责**"已经用了多少"的记录**。虽然当前是单任务系统、所有物理内存都空闲,内核直接返回 0 批准一切请求,但这个接口为 PA4 多任务环境下的真正内存管控预留了入口。
5.3.3 _sbrk 的工作流程
一次sbrk(Δ)调用的完整流程如下:
malloc 需要更多堆内存 │ ▼ 调用 sbrk(Δ) → 进入 _sbrk(increment) │ ├─ static current_brk 初始值 = &_end (记录当前堆顶) ├─ new_brk = current_brk + increment (想扩展到这里) ├─ _syscall_(SYS_brk, new_brk, 0, 0) (请求内核批准) │ │ │ ▼ ecall → 内核 sys_brk() │ │ 当前: 直接 return 0 (总是批准) │ │ 未来: 检查物理内存是否足够 │ ▼ ├─ 返回值 == 0 ? → 成功 │ ├─ 更新 current_brk = new_brk (记住新边界) │ └─ return (void*)old_brk; (返回旧边界 = 新区域的起始地址) │ └─ 返回值 != 0 ? → 失败 └─ return (void*)-1; (返回错误) │ ▼ malloc 拿到一块连续的 [old_brk, new_brk) 内存5.3.4 用户层实现
在用户层的_sbrk()函数(位于libos/src/syscall.c)中实现:
externchar_end;void*_sbrk(intptr_tincrement){staticintptr_tcurrent_brk=(intptr_t)&_end;// 初始 program breakintptr_told_brk=current_brk;intptr_tnew_brk=current_brk+increment;if(0==_syscall_(SYS_brk,new_brk,0,0)){// 请求内核设置新边界current_brk=new_brk;// 内核同意,更新记录return(void*)old_brk;// 返回旧边界,即新分配区域的起始地址}else{return(void*)-1;// 内核不同意,返回错误}}代码解读:
static intptr_t current_brk:用静态变量保存当前堆顶,初始值为_end。首次调用时,堆的大小为 0。increment:希望增加的字节数(可正可负)。new_brk = current_brk + increment:计算新的堆顶位置。_syscall_(SYS_brk, new_brk, 0, 0):通过系统调用请求内核批准。参数传new_brk而不是increment,因为内核需要知道"绝对地址"来判断是否合法。- 成功:更新
current_brk,返回old_brk(旧边界 = 新分配区域的起始地址)。 - 失败:返回
(void*)-1,表示堆区调整失败,malloc会据此返回NULL。
5.3.5 内核侧实现
在 Nanos-lite 的syscall.c中:
size_tsys_brk(){return0;// 当前单任务系统,始终批准}目前直接返回 0 表示总是成功。到 PA4 时,这里会加入物理内存的分配和映射逻辑。
5.3.6 那么,空间到底在哪?—— 理解"裸机"上的内存分配
你可能会疑惑:内核只是return 0,似乎并没有真正分配任何物理内存,那么malloc到底申请到哪里的空间了?
答案在于:在当前的简化内存模型中,整个物理内存都是直接可用的,无需内核显式分配。
我们来看更具体的物理内存布局。NEMU 启动时,会为模拟的 riscv32 机器准备一大段物理内存(比如从0x80000000到0x87ffffff,共 128MB)。Nanos-lite 加载用户程序时,会将其代码、数据、BSS 等段直接放置在物理内存中(比如用户程序的虚拟地址0x84000000实际上就直接映射到相同的物理地址)。
物理内存布局(简化) ┌──────────────────┐ 0x87ffffff(高地址) │ Nanos-lite │ ← 内核代码、数据、栈等 ├──────────────────┤ │ ... │ ├──────────────────┤ │ 用户程序栈 │ │ ↓ │ │ (空闲) │ │ ↑ │ │ 用户程序堆 │ ← current_brk 之后的区域可被动态分配 ├──────────────────┤ ← _end / current_brk (初始) │ BSS/数据/代码 │ ← 用户程序加载位置 (例如 0x84000000) ├──────────────────┤ │ ramdisk │ ├──────────────────┤ │ Nanos-lite │ └──────────────────┘ 0x80000000(低地址)_sbrk做的事情,仅仅是在用户程序的地址空间中标记出"这段区域已经被堆占用了"。它通过current_brk指针,从_end开始向高地址方向移动,将[old_brk, new_brk)这段地址范围划归堆区。
因为:
- 当前没有页表机制,也没有虚拟内存保护。
- 用户程序加载后,整个物理内存空间对它都是可读可写的(事实上,AM 提供的环境就是"裸机")。
- 堆区和栈区之间没有硬件隔离,两者向中间增长时若发生重叠,会导致未定义行为(但没有操作系统介入保护)。
所以,空间本身就一直存在——就是那段空闲的物理内存。_sbrk仅仅是"宣示主权",告诉 libc 的malloc:“从_end到这个地址之间的内存,你可以放心用。” 当malloc在这些地址上写入数据时,硬件(NEMU)会直接访问对应的物理内存,完全不需要内核预先"分配"。
换句话说,此时的sys_brk只是一个"橡皮图章"。它存在的意义是为未来的真实内存管理预留接口。到了 PA4,当我们需要支持多个进程、需要隔离和保护时,sys_brk内部会真正调用物理页面分配器,并设置页表映射。那时,sbrk返回的内存才是经过内核"背书"的、受保护的。
总结:在 PA3 阶段,malloc申请的空间就是_end往上的那一片物理内存,_sbrk只是移动一个指针,内核的批准只是个形式,空间一直都静静躺在那里。
5.3.7 完整调用链
用户程序调用 printf() │ ▼ printf 内部需要缓冲区 → 调用 malloc(缓冲区大小) │ ▼ malloc 首次被调用 → 调用 sbrk(0) 获取初始 current_brk │ → 然后调用 sbrk(需要的总量) │ ▼ sbrk → _sbrk → ecall(SYS_brk) → 内核 sys_brk() → return 0 │ ▼ _sbrk 更新 current_brk,返回旧边界给 malloc │ ▼ malloc 将这块内存管理起来,分一块给 printf │ ▼ printf 格式化字符串到缓冲区,最后一次性 write 输出有了堆区支持后,printf不再逐字符调用write,而是将格式化后的字符串存入 malloc 分配的缓冲区,然后一次性输出。这就是为什么在 strace 中会看到"整行输出"而非逐字符输出。
调试提示:在_sbrk中不要直接使用printf打印调试信息,因为printf本身可能触发malloc,进而再次调用_sbrk,形成死递归。可以改用sprintf将信息写入临时缓冲区,再通过_write(2, buf, len)输出到 stderr。
5.4 运行 hello 程序
编译navy-apps/tests/hello,替换 ramdisk,Nanos-lite 会输出 hello 信息,且printf使用缓冲区一次性输出(而不是逐字符write)。
5.5 缓冲区与系统调用开销
C 库使用缓冲区将多次输出累积后一次write,极大降低系统调用开销。行缓冲模式下,\n触发刷新。
至此,Nanos-lite 已提供 TRM 所需的基本能力。
6. 支持多个 ELF 的 ftrace
6.1 多 ELF ftrace 的必要性
用户程序与内核是两个独立的 ELF 文件,NEMU 的 ftrace 若只能解析一个 ELF,就无法同时翻译两个程序中的函数调用地址。为了实现跨内核和用户程序的完整函数调用追踪,需要支持加载多个 ELF 的符号表。
6.2 设计与实现
整体思路:
- 在 ftrace 模块中维护一个全局的符号表数组
func_syms。 - 提供
parse_elf_file()函数,从 ELF 的.symtab中提取函数符号(STT_FUNC)并追加到全局数组。 - 在 NEMU 启动时,先解析内核 ELF,再解析用户程序 ELF,合并符号。
- 函数地址查找时,顺序遍历符号表,匹配起始/结束地址。
传递用户 ELF 路径的方法:
- 在 NEMU 的
parse_args中添加-e选项({"elf", required_argument, NULL, 'e'}),将参数存入全局变量user_elf_file。 - 修改
nemu/Makefile,仅当USER_ELF变量非空时才在命令行添加-e $(USER_ELF):
USER_ELF ?= NEMU_EXEC := $(BINARY) $(ARGS) $(if $(USER_ELF), -e $(USER_ELF)) $(IMG)- 在
nanos-lite下运行时,可通过make ARCH=riscv32-nemu run USER_ELF=/path/to/bird-riscv32传入。
NEMU 内部修改(monitor.c):
staticchar*user_elf_file=NULL;// parse_args 中:case'e':user_elf_file=optarg;break;// init_monitor 调用 trace_init:trace_init(img_file,user_elf_file);ftrace 初始化(trace.c):
booltrace_init(char*bin_path,char*user_elf_file){// ... 解析内核 ELF ...parse_elf_file(elf_path,func_syms);if(user_elf_file!=NULL){parse_elf_file(user_elf_file,func_syms);}returntrue;}跟踪函数:检测jal、jalr、ret指令,计算目标地址,在符号表中查找并记录调用/返回。
6.3 使用示例
- 编译 bird 用户程序:
cdnavy-apps/apps/birdmakeISA=riscv32- 运行 Nanos-lite 并指定用户 ELF:
makeARCH=riscv32-nemu runUSER_ELF=../../navy-apps/apps/bird/build/bird-riscv32- 退出 NEMU 后,查看
ftrace.txt即可看到内核和用户程序的完整调用链。
7. 总结
通过 PA3-3 的实践,我们从零构建了一个可加载 ELF 用户程序、处理系统调用的微型操作系统 Nanos-lite。实现了 loader、系统调用分发、基本 I/O、堆管理、strace 以及支持多 ELF 的 ftrace。这些组件揭示了操作系统在硬件抽象、资源管理、程序加载与执行中的核心角色。整个过程中,AM 提供了硬件无关的接口,使得同一套内核代码可运行在不同 ISA 上。NEMU 作为模拟器,其内置的 ftrace 经过扩展,能够跨越内核与用户程序的边界,提供深入的函数级调试能力。