PA 3-3 用户程序和系统调用

PA 3-3 用户程序和系统调用

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 # 启动 logo

2. 跑在 NEMU+AM 上的最简单操作系统

在未开启任何实验模块时,Nanos-lite 就是一个最小的、能跑起来的操作系统。其行为:

  1. 打印 Project-N logo,并通过Log()输出欢迎信息和编译时间。
  2. 调用init_device(),完成基本的 I/O 设备初始化。
  3. 初始化 ramdisk:将一段内存作为模拟磁盘。
  4. 调用init_fs()init_proc()(目前为空)。
  5. 调用panic()结束运行。

编译与运行:

cdnanos-litemakeARCH=riscv32-nemu run

也可用 native 调试:

makeARCH=native run

Nanos-lite 在 AM 看来就是一个普通 C 程序,这体现了 AM 屏蔽硬件细节的优点。


3. 加载第一个用户程序

3.1 用户程序的来源:Navy-apps

用户程序由 Navy-apps 子项目编译生成:

cdics2025bashinit.sh navy-apps

Navy 的结构包含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;}

这里的GPR1GPR2GPR3GPR4GPRxSYSCALL都是通过宏定义来适配不同 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 寄存器作用说明
GPR1a7传递系统调用号告诉内核用户程序想执行哪个系统调用(如SYS_writeSYS_brk
GPR2a0传递第 1 个参数例如write(fd, buf, count)中的fd
GPR3a1传递第 2 个参数例如write(fd, buf, count)中的buf
GPR4a2传递第 3 个参数例如write(fd, buf, count)中的count
GPRxa0存放返回值内核处理完毕后,将返回值写入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之前,a7a0a1a2已经被正确赋值。
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()从上下文的GPR1a7)获取调用号,分发处理。关键代码:

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 == 1fd == 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 机器准备一大段物理内存(比如从0x800000000x87ffffff,共 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;}

跟踪函数:检测jaljalrret指令,计算目标地址,在符号表中查找并记录调用/返回。

6.3 使用示例

  1. 编译 bird 用户程序:
cdnavy-apps/apps/birdmakeISA=riscv32
  1. 运行 Nanos-lite 并指定用户 ELF:
makeARCH=riscv32-nemu runUSER_ELF=../../navy-apps/apps/bird/build/bird-riscv32
  1. 退出 NEMU 后,查看ftrace.txt即可看到内核和用户程序的完整调用链。

7. 总结

通过 PA3-3 的实践,我们从零构建了一个可加载 ELF 用户程序、处理系统调用的微型操作系统 Nanos-lite。实现了 loader、系统调用分发、基本 I/O、堆管理、strace 以及支持多 ELF 的 ftrace。这些组件揭示了操作系统在硬件抽象、资源管理、程序加载与执行中的核心角色。整个过程中,AM 提供了硬件无关的接口,使得同一套内核代码可运行在不同 ISA 上。NEMU 作为模拟器,其内置的 ftrace 经过扩展,能够跨越内核与用户程序的边界,提供深入的函数级调试能力。