在计算机系统基础实验中,加载用户程序并实现系统调用是理解操作系统内核与用户态交互的关键一步。本文基于PA3.2阶段的实践,深入剖析ELF加载器、系统调用分发、标准输出与堆区管理的实现细节,帮助你从零构建一个可运行用户程序的微型操作系统内核。

加载第一个用户程序:ELF Loader 的核心逻辑

当操作系统启动后,需要从ramdisk中读取用户程序。可执行文件位于ramdisk偏移为0处,访问它即可获得用户程序的第一个字节。但如何知道代码和数据在文件中的位置以及大小?答案藏在ELF格式中。

ELF文件通过PT_LOAD类型的segment明确告知loader:“只有这些段才是运行时必须加载的”。每个PT_LOAD段包含四个关键参数:Offset(在文件中的偏移)、VirtAddr(目标内存地址)、FileSiz(文件中的字节数)和MemSiz(内存中的字节数)。loader需要为每个这样的段分配内存区间[VirtAddr, VirtAddr + MemSiz),并从文件偏移处拷贝FileSiz字节到内存起始位置,然后清零剩余部分。

size_t ramdisk_read(void *buf, size_t offset, size_t len);
static uintptr_t loader(PCB *pcb, const char *filename) {
// 利用<elf.h>里的结构体,类似ftrace
  Elf_Ehdr eh;
  ramdisk_read(&eh, 0, sizeof(eh));
  // 检查ELF文件的魔数
  assert(eh.e_ident[0] == 0x7f && eh.e_ident[1] == 'E' &&
  eh.e_ident[2] == 'L' && eh.e_ident[3] == 'F');
  for (int i = 0; i < eh.e_phnum; i++) {
  Elf_Phdr ph;
  size_t ph_off = eh.e_phoff + (size_t)i * eh.e_phentsize;
  ramdisk_read(&ph, ph_off, sizeof(ph));
  // 判断一个segment是否需要加载
  if (ph.p_type != PT_LOAD)
  continue;
  if (ph.p_filesz > 0) {
  ramdisk_read((void *)(uintptr_t)ph.p_vaddr, ph.p_offset, ph.p_filesz);
  }
  // .bss段置0
  if (ph.p_memsz > ph.p_filesz) {
  memset((void *)((uintptr_t)ph.p_vaddr + ph.p_filesz), 0,
  ph.p_memsz - ph.p_filesz);
  }
  }
  return (uintptr_t)eh.e_entry;
  }

为什么FileSiz通常小于MemSiz?因为内存中有一部分数据在文件里根本不存在,比如.bss段(未初始化的全局变量和静态变量)。这些变量在C语言规范中要求初始值为0,因此必须清零。

⚠️ 清零的必要性:除了满足语言规范,清零还能保证程序从一个干净、确定的初始状态开始,避免新程序读取到上一个进程遗留的敏感数据(如密码、密钥、内核指针),这是基本的安全保障。

系统调用识别:从ecall到do_syscall

用户态程序通过yield()syscall()陷入内核,两者都使用同一条自陷指令ecall。关键在于:在执行ecall之前,用户态会将某个寄存器设置成不同的值,CTE(陷阱处理模块)根据这个标记区分事件类型。

当CTE检测到来自用户态的ecall后,读取上下文中的通用寄存器(如GPR1)来获取系统调用号。Nanos-lite的do_syscall()根据这个调用号分发处理。初始阶段未实现任何系统调用,因此会触发panic,这正好验证了调用路径的正确性。

Context *__am_irq_handle(Context *c) {
if (user_handler) {
Event ev = {0};
switch (c->mcause) {
case 0xb: { // ecall
c->mepc = c->mepc + 4;
if (c->GPR1 == (uintptr_t)-1)
ev.event = EVENT_YIELD;
else // OS中分发
ev.event = EVENT_SYSCALL;
break;
}
default:
ev.event = EVENT_ERROR;
break;
}
c = user_handler(ev, c);
assert(c != NULL);
}
return c;
}

实现SYS_yield需要三步:

  1. 在架构相关的头文件中实现正确的GPR宏,从上下文c中获取系统调用参数寄存器。
  2. do_syscall()中添加对SYS_yield的处理分支。
  3. 设置返回值——通过修改上下文中的寄存器,将结果返回给用户态的_syscall_()调用者。
#ifdef __riscv_e
#define GPR1 gpr[15] // a5
#else
#define GPR1 gpr[17] // a7
#endif
#define GPR2 gpr[10] // a0 = x10, arg0
#define GPR3 gpr[11] // a1 = x11, arg1
#define GPR4 gpr[12] // a2 = x12, arg2
#define GPRx gpr[10] // a0 = x10, return value
void do_syscall(Context *c) {
uintptr_t a[4];
a[0] = c->GPR1;
intptr_t ret = 0;
switch (a[0]) {
case SYS_yield:
yield();
ret = 0;
break;
case SYS_exit:
halt(c->GPRx);
break;
default:
panic("Unhandled syscall ID = %d", a[0]);
}
c->GPRx = ret;
}
}
static Context *do_event(Event e, Context *c) {
switch (e.event) {
case EVENT_YIELD: {
printf("EVENT_YIELD!\n");
break;
}
case EVENT_SYSCALL: {
printf("EVENT_SYSCALL!\n");
do_syscall(c);
break;
}
default:
panic("Unhandled event ID = %d", e.event);
}
return c;
}

通过临时添加的打印分析,可以看到三个关键事件:第一个是sys_yield(作为系统调用进入内核),第二个是yield事件(CTE自陷),第三个是sys_exit(程序结束)。

RISC-V系统调用号为何不用a0?

在RISC-V架构中,系统调用号存放在a7寄存器(RV_E是a5),而非a0。原因在于:a0在函数调用中承载第一个参数,同时作为返回值寄存器。如果用它既存系统调用号又返回结果,就会在返回时产生冲突。系统调用被刻意设计成“看起来像函数调用”,因此需要独立的寄存器来传递调用号。

这一设计也影响了后续的调试工具。例如,strace工具用于追踪系统调用踪迹,但为了避免“系统调用→strace→系统调用”的套娃问题,实现时只能使用snprintfputstr这类不依赖系统调用的基础函数。

make ARCH=riscv32-nemu run > build/log.txt 2>&1
#include "syscall.h"
#include <common.h>
  #include <fs.h>
    void strace_write(int sysid, uintptr_t arg0, uintptr_t arg1, uintptr_t arg2,
    intptr_t ret) {
    char buf[256];
    switch (sysid) {
    case SYS_brk: {
    (void)snprintf(buf, sizeof(buf), "STRACE: sys_brk(increment:0x%lx)\n",
    (unsigned long)arg0);
    break;
    }
    case SYS_open: {
    const char *path = (const char *)(uintptr_t)arg0;
    (void)snprintf(buf, sizeof(buf), "STRACE: fs_open(path:%s)\n",
    (path ? path : "(null)"));
    break;
    }
    case SYS_lseek: {
    int fd = (int)arg0;
    const char *name = fd_to_name(fd);
    (void)snprintf(buf, sizeof(buf),
    "STRACE: fs_lseek(%s, offset:0x%lx, whence:%lu)\n", name,
    (unsigned long)arg1, (unsigned long)arg2);
    break;
    }
    case SYS_read: {
    int fd = (int)arg0;
    const char *name = fd_to_name(fd);
    (void)snprintf(buf, sizeof(buf),
    "STRACE: fs_read(%s, buf:0x%lx, len:%lu) = %ld\n", name,
    (unsigned long)arg1, (unsigned long)arg2, (long)ret);
    break;
    }
    case SYS_write: {
    int fd = (int)arg0;
    const char *name = fd_to_name(fd);
    (void)snprintf(buf, sizeof(buf),
    "STRACE: fs_write(%s, buf:0x%lx, len:%lu) = %ld\n", name,
    (unsigned long)arg1, (unsigned long)arg2, (long)ret);
    break;
    }
    case SYS_close: {
    int fd = (int)arg0;
    const char *name = fd_to_name(fd);
    (void)snprintf(buf, sizeof(buf), "STRACE: fs_close(%s) = %ld\n", name,
    (long)ret);
    break;
    }
    case SYS_exit: {
    (void)snprintf(buf, sizeof(buf), "STRACE: sys_exit(code:%ld)\n",
    (long)arg0);
    break;
    }
    case SYS_yield: {
    (void)snprintf(buf, sizeof(buf), "STRACE: sys_yield()\n");
    break;
    }
    case SYS_gettimeofday: {
    struct timeval *tv = (struct timeval *)(uintptr_t)arg0;
    (void)snprintf(buf, sizeof(buf),
    "STRACE: sys_gettimeofday(tv:{sec=%ld,usec=%ld})\n",
    (long)tv->tv_sec, (long)tv->tv_usec);
    break;
    }
    default: {
    (void)snprintf(buf, sizeof(buf),
    "STRACE: unknown sys_%d(arg0=0x%lx, arg1=0x%lx, arg2=0x%lx) = %ld\n",
    sysid, (unsigned long)arg0, (unsigned long)arg1,
    (unsigned long)arg2, (long)ret);
    break;
    }
    }
    putstr(buf);
    }

操作系统之上的TRM:标准输出与堆区管理

TRM(微型运行时)为用户程序提供最基本的运行环境,包括标准输出和动态内存分配。

标准输出的实现分为用户态和内核态两层。用户态的_write()函数封装系统调用,将缓冲区数据传给内核;内核态的SYS_write则负责将数据输出到串口或显示器。

int _write(int fd, void *buf, size_t count) {
return _syscall_(SYS_write, fd, (intptr_t)buf, count);
}
case SYS_write: {
int fd = (int)a[1];
const char *buf = (const char *)(uintptr_t)a[2];
size_t len = (size_t)a[3];
if (fd == 1 || fd == 2) {
for (size_t i = 0; i < len; i++) {
putch(buf[i]);
}
ret = (intptr_t)len;
} else {
ret = -1; // 返回失败
}
break;
}

堆区管理通过_sbrk()实现。用户态的_sbrk()维护一个程序断点(brk),当需要更多堆空间时,通过系统调用请求内核增加内存。内核态的SYS_brk负责更新内存分配边界,并返回新断点地址。

extern char _end; // abstract-machine/scripts/linker.ld
void *_sbrk(intptr_t increment) {
static intptr_t cur_brk = 0;
if (cur_brk == 0)
cur_brk = (intptr_t)&_end;
intptr_t old = cur_brk;
intptr_t new_brk = cur_brk + (intptr_t)increment;
intptr_t ret = _syscall_(SYS_brk, (intptr_t)new_brk, 0, 0);
if (ret == 0) {
cur_brk = new_brk;
return (void *)old;
}
return (void *)-1;
}
case SYS_brk:
ret = 0; // 这阶段:总是成功
break;

观察实现前后的输出差异:未实现_sbrk时,printf因无法申请缓冲区,只能逐个字符通过write()输出;实现后,格式化完毕的字符串一次性输出,效率显著提升。

实践建议:在调试系统调用时,建议在do_event中临时添加printf打印事件类型和系统调用号,这能极大加速问题定位。另外,如果你熟悉Java、TypeScript或Go,会发现系统调用的分发机制与接口多态有异曲同工之妙——都是通过一个“标识”来选择不同的处理逻辑。

[AFFILIATE_SLOT_1]

支持多个ELF的ftrace与调试技巧

虽然ftrace(函数追踪)在本阶段可以跳过,但它是理解程序执行流的强大工具。实现时需要注意:ftrace本身也可能触发系统调用,因此要避免递归追踪。建议先实现基础的系统调用,再逐步添加ftrace支持。

在调试过程中,我总结出几个实用技巧:

  • ✅ 使用strace风格打印每个系统调用的参数和返回值,但仅限关键路径。
  • ✅ 在do_syscall中设置断点,观察寄存器上下文。
  • ✅ 对比用户态和内核态的寄存器变化,确认参数传递正确。

[AFFILIATE_SLOT_2]

总结

本文从ELF加载器、系统调用识别、RISC-V调用约定、标准输出和堆区管理五个维度,剖析了PA3.2阶段的核心实现。关键点在于:理解用户态与内核态的边界,通过寄存器传递参数和返回值,以及正确清零内存保证安全。这些机制不仅适用于Nanos-lite,也是理解Linux、Windows等现代操作系统的基础。

如果你正在学习操作系统或计算机系统基础课程,建议动手实现一遍,并尝试用C++或JavaScript模拟这些流程,会获得更深的领悟。