跳转至

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 提供的封装函数(例如 printfreadwrite 等)间接调用系统调用;
在 NexOS 中,课程已经为大家在 user/syscall.c 中写好了类似的最小版封装层,并通过 user/user.h 暴露给所有用户程序使用。

你可以将 user/syscall.c + user/user.h 看作是一个“迷你版的 C 运行时 + 系统调用封装库”。在 Hw1 与实验一中新增的系统调用,也需要按照同样的方式对外暴露。

1.1 NexOS 中系统调用的大致执行流程

这一部分,我们将以 NexOS 目前已经实现的系统调用为例,来理解“系统调用是如何从用户态一路走到内核态、再返回用户态”的。

  • 相关文件:
  • kernel/include/syscall.h:系统调用号定义(SYS_writeSYS_fork 等);
  • kernel/core/syscall.c:内核态系统调用实现与分发逻辑;
  • user/syscall.c:用户态触发系统调用的封装;
  • user/user.h:对用户程序暴露的声明。

整体流程可以概括为:

  1. 用户程序调用封装函数
    例如,用户在 user/ 下的程序里写:
    write(1, "hello\n", 6);
    
    这里的 write 是在 user/syscall.c 中定义、并在 user/user.h 中声明的用户态封装。 我们打开user/user.h,即可看见 write 函数的声明,这里声明了封装函数的返回值类型、函数名以及参数类型。
    int write(int fd, const void *buf, int n);
    
    我们打开 user/syscall.c ,即可看见 write 用户态封装函数的定义:
    int write(int fd, const void *buf, int n) {
       return (int)__syscall(SYS_write, fd, (long)buf, n);
    }
    
    这里的函数只包含一行代码,直接调用了 __syscall 函数,对于该函数我们传入了4个参数,其中后面三个都是用户进程调用 write 时传入的参数,第一个参数则是我们在 kernel/include/syscall.h 中定义的系统调用号,可以在该文件中找到系统调用号的定义,系统调用号用来作为系统调用的数字标识,SYS_write 的定义如下所示:
    #define SYS_write 1
    
  2. 封装函数设置寄存器并执行 ecall
    现在我们查看上一步调用的核心函数 __syscall。在 user/syscall.c 中,可以看到该函数的定义:
    static inline long __syscall(long num, long a0, long a1, long a2) {
        register long _a0 asm("a0") = a0;
        register long _a1 asm("a1") = a1;
        register long _a2 asm("a2") = a2;
        register long _a7 asm("a7") = num;
        asm volatile("ecall" : "+r(_a0)" : "r"(_a1), "r"(_a2), "r"(_a7) : "memory");
        return _a0;
    }
    
  3. 第一个参数 num 是我们上面所说的系统调用号,这个函数将系统调用号写入寄存器 a7
  4. 接下来是用户态传入的参数,最多为三个,三个参数分别写入 a0a1a2
  5. 最后使用 RISC‑V 指令 ecall 从用户态陷入内核。

  6. 陷入内核后,根据系统调用号进行分发
    内核陷入处理的具体细节在架构相关代码中完成,最终会调用 kernel/core/syscall.c 中的:

    void syscall(void) {
        struct proc *p = myproc();
        int num = (int)p->trapframe->a7;
    
        if (num > 0 && num < (int)(sizeof(syscalls) / sizeof(syscalls[0])) && syscalls[num]) {
            p->trapframe->a0 = syscalls[num]();
        } else {
            printf("Unknown syscall %d\n", num);
            p->trapframe->a0 = (uint64)-1;
        }
    }
    

  7. 可以把 trapframe 理解成“CPU 状态快照”:一旦发生系统调用或异常,内核会把用户态各个寄存器的值都先存进这个结构体里,方便之后恢复现场;
  8. 由于我们在陷入内核前,将系统调用号保存在了 a7 寄存器中,因此从当前进程的 trapframe->a7 中可以取出系统调用号 num,用来确定需要调用的系统调用;
  9. 所有可用的系统调用实现函数都被放在一个数组 syscalls[] 里,该数组的定义就在 kernel/core/syscall.c中,如下面所示 ,你可以把它想象成“系统调用菜单”:下标是系统调用号,数组元素是对应的内核函数指针;
    static uint64 (*syscalls[])(void) = {
       [SYS_write] = sys_write,
       ...
       [SYS_getcwd] = sys_getcwd,
    };
    
  10. 内核通过 syscalls[num]() 找到并调用对应的系统调用实现函数,把返回值写回到 trapframe->a0 中,等返回用户态时,这个值就会出现在用户程序看到的返回值里。

  11. 系统调用实现函数在内核空间中完成具体工作
    上一步,通过 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);
    }
    
    前面我们说过,用户态的参数会按顺序保存在寄存器 a0a1a2 中,因此 sys_write 按照对应关系取出参数,然后实现相应的写入功能。

  12. 返回用户态
    完成系统调用后,内核将 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 中所有系统调用号的定义:

#define SYS_write 1
...
#define SYS_getcwd 24

我们需要为新的系统调用选择一个尚未使用的编号,例如 25,在文件末尾添加:

#define SYS_getcwd 24
#define SYS_hello_id 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 个参数放在 a0a1a2,所以在内核中从 p->trapframe->a0/1/2 取参即可;
  • myproc() 返回当前正在执行进程的结构体 struct proc *
  • printf 是 NexOS 内核提供的简单输出函数,可以直接在 QEMU 控制台看到输出。

若你的系统调用需要两个或三个参数,可以依次使用:

int a = (int)p->trapframe->a0;
int b = (int)p->trapframe->a1;
uint64 uptr = p->trapframe->a2;

此处我们只需使用1个即可。

1.2.3 在系统调用表中注册新系统调用

在同一个文件 kernel/core/syscall.c 的底部,有一个系统调用分发表:

static uint64 (*syscalls[])(void) = {
    [SYS_write] = sys_write,
    ...
    [SYS_getcwd] = sys_getcwd,
};

我们需要把新添加的系统调用号 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,可以看到已有的封装,例如:

int getpid(void) {
    return (int)__syscall(SYS_getpid, 0, 0, 0);
}

我们为 hello_id 添加一个类似的封装:

int hello_id(int tag) {
    return (int)__syscall(SYS_hello_id, tag, 0, 0);
}

1.2.5 在 user/user.h 中声明接口

最后,在 user/user.h 末尾添加函数声明,让所有用户程序都能直接使用:

int printf(const char *fmt, ...);
int hello_id(int tag);

到这里,一个完整的 NexOS 系统调用就已经在“内核 + 用户态”两侧打通了。

1.3 在 NexOS 中测试新系统调用

1.3.1 在 user/ 目录中新建测试程序

接下来,我们编写一个简单的用户程序来调用 hello_id。在 NexOS 源码目录下:

cd ~/nexos
cd user
vim test_hello_id.c

在文件中写入如下内容(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);
}

保存退出后,确认文件存在:

ls test_hello_id.c

1.3.2 将测试程序加入构建和文件系统镜像

参考 NexOS 用户态程序创建指南 一节中的教程修改 Makefile,此处简述步骤。

打开 NexOS 根目录下的 Makefile

cd ~/nexos
vim Makefile
  1. UPROGS 中加入 test_hello_id(注意添加\):
UPROGS = \
   init \
   sh \
   hello \
   ... \
   touch \
   test_hello_id
  1. fsimg 规则中,把 test_hello_id 打包进 fs.img,在行尾追加:
--add test_hello_id=$(UBUILD)/test_hello_id.elf

保存退出。

1.3.3 重新编译并运行 NexOS

在 NexOS 根目录下执行:

cd ~/nexos
make
make fsimg
make qemu

系统启动成功后,在 NexOS 的 Shell 中输入:

test_hello_id

预期现象:

  • 内核在 QEMU 控制台中打印类似:
[sys_hello_id] pid=..., tag=12345
  • 用户态程序输出:
hello_id(12345) returned 12345

说明:

  • 新增系统调用的“内核端 + 用户端 + 构建脚本”都已正确配置;
  • 你已经掌握了在 NexOS 中添加和调用一个简单系统调用的完整过程。

1.4 小结:在 NexOS 中添加系统调用的完整步骤

回顾一下我们在 NexOS 中添加系统调用的通用流程:

  1. 设计接口:确定系统调用要做什么、需要哪些参数、返回值含义;
  2. 分配调用号:在 kernel/include/syscall.h 中添加新的 SYS_xxx 宏;
  3. 实现内核函数:在 kernel/core/syscall.c 中实现 static uint64 sys_xxx(void),从 trapframe 中取参数,并返回结果;
  4. 注册到分发表:在 syscalls[] 中添加 [SYS_xxx] = sys_xxx,
  5. 添加用户态封装
  6. user/syscall.c 中添加 int xxx(...){ return (int)__syscall(SYS_xxx, ...); }
  7. user/user.h 中声明该函数;
  8. 添加测试程序:在 user/ 下新建 .c 文件,调用新系统调用进行验证;
  9. 更新构建脚本
  10. 在根 MakefileUPROGS 中加入新程序名;
  11. fsimg 规则中添加 --add name=$(UBUILD)/name.elf
  12. 重新编译并运行make && make fsimg && make qemu,在 NexOS Shell 中运行测试程序。

掌握了这一套流程后,你就可以在 NexOS 中尝试实现更有趣的系统调用(例如查询进程信息、统计系统资源使用情况、实现简单的跟踪工具等)。