Hw1 配套阅读:在 NexOS 中添加系统调用¶
阅读目标¶
- 作为
Hw1与实验一的配套阅读材料,帮助你理解系统调用相关机制; - 理解 NexOS 中用户态与内核态的关系;
- 掌握 NexOS 系统调用的整体执行流程;
- 为完成
Hw1中与系统调用相关的作业题,以及实验一中的后续实践任务做好准备。
章节一 在 NexOS 中添加系统调用¶
1.0 若干名词解释¶
1.0.1 用户空间、内核空间¶
- 用户空间:用户空间是操作系统为用户级应用程序分配的虚拟内存区域。应用程序(如文本编辑器、浏览器等)在此空间中运行,仅能访问受限的资源和内存。
- 内核空间:内核空间是操作系统内核及核心功能运行的虚拟内存区域,负责硬件管理、进程调度、内存分配等核心任务。
1.0.2 系统调用¶
系统调用 (System call, Syscall)是操作系统提供给用户程序访问内核空间的合法接口。
系统调用运行于内核空间,可以被用户空间调用,是内核空间和用户空间划分的关键所在,它保证了两个空间必要的联系。从用户空间来看,系统调用是一组统一的抽象接口,用户程序无需考虑接口下面是什么;从内核空间来看,用户程序不能直接进行敏感操作,所有对内核的操作都必须通过功能受限的接口间接完成,保证了内核空间的稳定和安全。
我们平时的编程中为什么没有意识到系统调用的存在?这是因为应用程序现在一般通过应用编程接口 (API)来间接使用系统调用,比如我们如果想要C程序打印内容到终端,只需要使用C的标准库函数API
printf()就可以了,而不是使用系统调用write()将内容写到终端的输出文件 (STDOUT_FILENO) 中。
1.0.3 用户态封装(对比 glibc)¶
在 Linux 用户空间,我们通常通过 glibc 提供的封装函数(例如 printf、read、write 等)间接调用系统调用;
在 NexOS 中,课程已经为大家在 user/syscall.c 中写好了类似的最小版封装层,并通过 user/user.h 暴露给所有用户程序使用。
你可以将 user/syscall.c + user/user.h 看作是一个“迷你版的 C 运行时 + 系统调用封装库”。在 Hw1 与实验一中新增的系统调用,也需要按照同样的方式对外暴露。
1.1 NexOS 中系统调用的大致执行流程¶
这一部分,我们将以 NexOS 目前已经实现的系统调用为例,来理解“系统调用是如何从用户态一路走到内核态、再返回用户态”的。
- 相关文件:
kernel/include/syscall.h:系统调用号定义(SYS_write、SYS_fork等);kernel/core/syscall.c:内核态系统调用实现与分发逻辑;user/syscall.c:用户态触发系统调用的封装;user/user.h:对用户程序暴露的声明。
整体流程可以概括为:
- 用户程序调用封装函数
例如,用户在user/下的程序里写: 这里的write是在user/syscall.c中定义、并在user/user.h中声明的用户态封装。 我们打开user/user.h,即可看见write函数的声明,这里声明了封装函数的返回值类型、函数名以及参数类型。 我们打开user/syscall.c,即可看见write用户态封装函数的定义: 这里的函数只包含一行代码,直接调用了__syscall函数,对于该函数我们传入了4个参数,其中后面三个都是用户进程调用write时传入的参数,第一个参数则是我们在kernel/include/syscall.h中定义的系统调用号,可以在该文件中找到系统调用号的定义,系统调用号用来作为系统调用的数字标识,SYS_write的定义如下所示: - 封装函数设置寄存器并执行
ecall
现在我们查看上一步调用的核心函数__syscall。在user/syscall.c中,可以看到该函数的定义: - 第一个参数
num是我们上面所说的系统调用号,这个函数将系统调用号写入寄存器a7; - 接下来是用户态传入的参数,最多为三个,三个参数分别写入
a0、a1、a2; -
最后使用 RISC‑V 指令
ecall从用户态陷入内核。 -
陷入内核后,根据系统调用号进行分发
内核陷入处理的具体细节在架构相关代码中完成,最终会调用kernel/core/syscall.c中的: - 可以把
trapframe理解成“CPU 状态快照”:一旦发生系统调用或异常,内核会把用户态各个寄存器的值都先存进这个结构体里,方便之后恢复现场; - 由于我们在陷入内核前,将系统调用号保存在了
a7寄存器中,因此从当前进程的trapframe->a7中可以取出系统调用号num,用来确定需要调用的系统调用; - 所有可用的系统调用实现函数都被放在一个数组
syscalls[]里,该数组的定义就在kernel/core/syscall.c中,如下面所示 ,你可以把它想象成“系统调用菜单”:下标是系统调用号,数组元素是对应的内核函数指针; -
内核通过
syscalls[num]()找到并调用对应的系统调用实现函数,把返回值写回到trapframe->a0中,等返回用户态时,这个值就会出现在用户程序看到的返回值里。 -
系统调用实现函数在内核空间中完成具体工作
上一步,通过syscalls[]数组,内核成功查询到SYS_write这个系统调用号对应的系统调用函数为sys_write,该函数的定义同样位于kernel/core/syscall.c中,sys_write会从当前进程的 trapframe 中取出参数,在内核空间完成文件/设备写入工作:前面我们说过,用户态的参数会按顺序保存在寄存器static uint64 sys_write(void) { struct proc *p = myproc(); int fd = (int)p->trapframe->a0; uint64 va = p->trapframe->a1; int n = (int)p->trapframe->a2; ... return (uint64)filewrite(f, va, n); }a0、a1、a2中,因此sys_write按照对应关系取出参数,然后实现相应的写入功能。 -
返回用户态
完成系统调用后,内核将trapframe->a0作为返回值恢复到用户态寄存器a0,随后返回到用户程序,调用方看到的就是write的返回值。
总结:相比 Linux 4.9 使用的
int 0x80/syscall指令和庞大的syscall_64.tbl机制,NexOS 的系统调用路径要简单得多,核心就是:
user/syscall.c中通过__syscall+ecall触发;kernel/include/syscall.h中的SYS_xxx号;kernel/core/syscall.c中的sys_xxx实现 +syscalls[]分发表。
1.2 在 NexOS 中添加系统调用的流程¶
在实现系统调用之前,要先考虑好函数原型,确定函数名称、要传入的参数个数和类型、返回值的意义。
本节我们以一个简单示例系统调用 hello_id(int tag) 作为演示,它的行为是:
- 在内核中打印当前进程 PID 和
tag; - 将
tag原样返回给用户态。
本节各步没有严格顺序,但在编译前都要完成:
1.2.1 在 syscall.h 中注册系统调用号¶
打开 kernel/include/syscall.h,里面是 NexOS 中所有系统调用号的定义:
我们需要为新的系统调用选择一个尚未使用的编号,例如 25,在文件末尾添加:
提示:
- 请不要与已有编号冲突;
1.2.2 在 kernel/core/syscall.c 中实现内核函数¶
在 kernel/core/syscall.c 中,已有多个 static uint64 sys_xxx(void) 形式的系统调用实现,我们也仿照它们添加一个新的实现,例如可以放在 sys_kill 附近:
static uint64 sys_hello_id(void) {
struct proc *p = myproc();
if (p == 0) {
return (uint64)-1;
}
// 从 trapframe 的 a0 寄存器中取出第一个参数
int tag = (int)p->trapframe->a0;
printf("[sys_hello_id] pid=%d, tag=%d\n", p->pid, tag);
// 将 tag 作为返回值返回给用户态
return (uint64)tag;
}
要点说明:
- NexOS 中约定:用户态传入的第 1~3 个参数放在
a0、a1、a2,所以在内核中从p->trapframe->a0/1/2取参即可; myproc()返回当前正在执行进程的结构体struct proc *;printf是 NexOS 内核提供的简单输出函数,可以直接在 QEMU 控制台看到输出。
若你的系统调用需要两个或三个参数,可以依次使用:
此处我们只需使用1个即可。
1.2.3 在系统调用表中注册新系统调用¶
在同一个文件 kernel/core/syscall.c 的底部,有一个系统调用分发表:
我们需要把新添加的系统调用号 SYS_hello_id 映射到实现函数 sys_hello_id 上:
static uint64 (*syscalls[])(void) = {
[SYS_write] = sys_write,
...
[SYS_getcwd] = sys_getcwd,
[SYS_hello_id] = sys_hello_id,
};
如果忘记这一步,用户态虽然可以执行
ecall,但会命中Unknown syscall分支,返回 -1。
1.2.4 在 user/syscall.c 中添加用户态封装¶
打开 user/syscall.c,可以看到已有的封装,例如:
我们为 hello_id 添加一个类似的封装:
1.2.5 在 user/user.h 中声明接口¶
最后,在 user/user.h 末尾添加函数声明,让所有用户程序都能直接使用:
到这里,一个完整的 NexOS 系统调用就已经在“内核 + 用户态”两侧打通了。
1.3 在 NexOS 中测试新系统调用¶
1.3.1 在 user/ 目录中新建测试程序¶
接下来,我们编写一个简单的用户程序来调用 hello_id。在 NexOS 源码目录下:
在文件中写入如下内容(tag 可以改成你学号的后几位):
#include "user.h"
int main(void) {
int tag = 12345; // TODO: 可以改成你自己的学号后几位
int ret = hello_id(tag);
printf("hello_id(%d) returned %d\n", tag, ret);
exit(0);
}
保存退出后,确认文件存在:
1.3.2 将测试程序加入构建和文件系统镜像¶
参考 NexOS 用户态程序创建指南 一节中的教程修改 Makefile,此处简述步骤。
打开 NexOS 根目录下的 Makefile:
- 在
UPROGS中加入test_hello_id(注意添加\):
- 在
fsimg规则中,把test_hello_id打包进fs.img,在行尾追加:
保存退出。
1.3.3 重新编译并运行 NexOS¶
在 NexOS 根目录下执行:
系统启动成功后,在 NexOS 的 Shell 中输入:
预期现象:
- 内核在 QEMU 控制台中打印类似:
- 用户态程序输出:
说明:
- 新增系统调用的“内核端 + 用户端 + 构建脚本”都已正确配置;
- 你已经掌握了在 NexOS 中添加和调用一个简单系统调用的完整过程。
1.4 小结:在 NexOS 中添加系统调用的完整步骤¶
回顾一下我们在 NexOS 中添加系统调用的通用流程:
- 设计接口:确定系统调用要做什么、需要哪些参数、返回值含义;
- 分配调用号:在
kernel/include/syscall.h中添加新的SYS_xxx宏; - 实现内核函数:在
kernel/core/syscall.c中实现static uint64 sys_xxx(void),从trapframe中取参数,并返回结果; - 注册到分发表:在
syscalls[]中添加[SYS_xxx] = sys_xxx,; - 添加用户态封装:
- 在
user/syscall.c中添加int xxx(...){ return (int)__syscall(SYS_xxx, ...); }; - 在
user/user.h中声明该函数; - 添加测试程序:在
user/下新建.c文件,调用新系统调用进行验证; - 更新构建脚本:
- 在根
Makefile的UPROGS中加入新程序名; - 在
fsimg规则中添加--add name=$(UBUILD)/name.elf; - 重新编译并运行:
make && make fsimg && make qemu,在 NexOS Shell 中运行测试程序。
掌握了这一套流程后,你就可以在 NexOS 中尝试实现更有趣的系统调用(例如查询进程信息、统计系统资源使用情况、实现简单的跟踪工具等)。