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

PA 3-3 用户程序和系统调用
1. 操作系统的结构以 Nanos-lite 为例在 PA 中我们使用的操作系统是Nanos-lite它是南京大学操作系统 Nanos 的裁剪版专为 PA 量身定制。Nanos-lite 运行在AMAbstract Machine之上而 AM 又直接构建在NEMU硬件模拟器提供的 ISA 机制上。从层次化的角度看整个系统的结构如下------------------- | 用户程序/进程 | ← 通过系统调用请求操作系统服务 ------------------- | Nanos-lite | ← 操作系统内核 ------------------- | AM抽象机 | ← 提供架构无关的 APIioe, 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. 跑在 NEMUAM 上的最简单操作系统在未开启任何实验模块时Nanos-lite 就是一个最小的、能跑起来的操作系统。其行为打印 Project-N logo并通过Log()输出欢迎信息和编译时间。调用init_device()完成基本的 I/O 设备初始化。初始化 ramdisk将一段内存作为模拟磁盘。调用init_fs()和init_proc()目前为空。调用panic()结束运行。编译与运行cdnanos-litemakeARCHriscv32-nemu run也可用 native 调试makeARCHnative runNanos-lite 在 AM 看来就是一个普通 C 程序这体现了 AM 屏蔽硬件细节的优点。3. 加载第一个用户程序3.1 用户程序的来源Navy-apps用户程序由 Navy-apps 子项目编译生成cdics2025bashinit.sh navy-appsNavy 的结构包含apps用户程序、libsNewlib、libos 等、tests等。用户程序的入口在libs/libos/src/crt0/start.S的_start()。3.2 第一个用户程序dummy我们使用navy-apps/tests/dummy/dummy.c。为避免冲突用户程序链接到0x83000000riscv32。编译cdnavy-apps/tests/dummymakeISAriscv32然后将生成的可执行文件复制为 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, VirtAddrFileSiz)将[VirtAddrFileSiz, VirtAddrMemSiz)清零对应 .bss 段3.4 loader 实现在loader.c中实现#includefs.h#includeproc.h#includeelf.h#defineElf_EhdrElf32_Ehdr#defineElf_PhdrElf32_Phdrstaticuintptr_tloader(PCB*pcb,constchar*filename){intfdfs_open(filename,0,0);Elf_Ehdr ehdr;fs_read(fd,ehdr,sizeof(ehdr));// 魔数检查assert(ehdr.e_ident[0]0x7fehdr.e_ident[1]Eehdr.e_ident[2]Lehdr.e_ident[3]F);// ISA 检查#ifdefined(__riscv)#defineEXPECT_TYPEEM_RISCV#else#errorUnsupported ISA#endifassert(EXPECT_TYPEehdr.e_machine);for(inti0;iehdr.e_phnum;i){Elf_Phdr phdr;fs_lseek(fd,ehdr.e_phoffi*sizeof(phdr),SEEK_SET);fs_read(fd,phdr,sizeof(phdr));if(phdr.p_typePT_LOAD){uint32_tp_vaddrphdr.p_vaddr;uint32_tp_memszphdr.p_memsz;uint32_tp_fileszphdr.p_filesz;uint32_tp_offsetphdr.p_offset;fs_lseek(fd,p_offset,SEEK_SET);fs_read(fd,(void*)(uintptr_t)p_vaddr,p_filesz);memset((void*)(uintptr_t)(p_vaddrp_filesz),0,p_memsz-p_filesz);}}returnehdr.e_entry;}voidnaive_uload(PCB*pcb,constchar*filename){uintptr_tentryloader(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#defineSYSCALLecall// RISC-V 的自陷指令#defineGPR1a7// 系统调用号存放寄存器#defineGPR2a0// 第 1 个参数#defineGPR3a1// 第 2 个参数#defineGPR4a2// 第 3 个参数#defineGPRxa0// 返回值存放寄存器展开后的实际代码等价于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_write、SYS_brkGPR2a0传递第 1 个参数例如write(fd, buf, count)中的fdGPR3a1传递第 2 个参数例如write(fd, buf, count)中的bufGPR4a2传递第 3 个参数例如write(fd, buf, count)中的countGPRxa0存放返回值内核处理完毕后将返回值写入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()从上下文的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-GPRx0;break;caseSYS_write:c-GPRxsys_write(a[1],(void*)a[2],a[3]);break;caseSYS_brk:c-GPRxsys_brk();break;caseSYS_open:c-GPRxsys_open((constchar*)a[1],a[2],a[3]);break;caseSYS_read:c-GPRxsys_read(a[1],(void*)a[2],a[3]);break;caseSYS_lseek:c-GPRxsys_lseek(a[1],a[2],a[3]);break;caseSYS_close:c-GPRxsys_close(a[1]);break;caseSYS_execve:sys_execve((constchar*)a[1]);break;caseSYS_gettimeofday:c-GPRxsys_gettimeofday((structtimeval*)a[1],(structtimezone*)a[2]);break;default:panic(Unhandled syscall ID %d,a[0]);}#ifdef__riscvc-mepc4;// 避免重复执行 ecall#endif}4.4 实现 SYS_yield 和 SYS_exitSYS_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 与 _sbrk5.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_brkcurrent_brk;intptr_tnew_brkcurrent_brkincrement;if(0_syscall_(SYS_brk,new_brk,0,0)){// 请求内核设置新边界current_brknew_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替换 ramdiskNanos-lite 会输出 hello 信息且printf使用缓冲区一次性输出而不是逐字符write。5.5 缓冲区与系统调用开销C 库使用缓冲区将多次输出累积后一次write极大降低系统调用开销。行缓冲模式下\n触发刷新。至此Nanos-lite 已提供 TRM 所需的基本能力。6. 支持多个 ELF 的 ftrace6.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 ARCHriscv32-nemu run USER_ELF/path/to/bird-riscv32传入。NEMU 内部修改monitor.cstaticchar*user_elf_fileNULL;// parse_args 中:casee:user_elf_fileoptarg;break;// init_monitor 调用 trace_init:trace_init(img_file,user_elf_file);ftrace 初始化trace.cbooltrace_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/birdmakeISAriscv32运行 Nanos-lite 并指定用户 ELFmakeARCHriscv32-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 经过扩展能够跨越内核与用户程序的边界提供深入的函数级调试能力。