系统调用
系统调用
系统调用是系统为用户程序提供的高特权操作接口。在本实验中,用户程序通过
svc指令进入内核模式。在内核模式下,首先操作系统代码和硬件将保存用户程序的状态。操作系统根据系统调用号码执行相应的系统调用处理代码,完成系统调用的实际功能,并保存返回值。最后,操作系统和硬件将恢复用户程序的状态,将系统调用的返回值返回给用户程序,继续用户程序的执行
书接上回,在异常管理的部分已经讲了系统调用的整体流程。本部分内容将讲解其实现细节,并以 printf 函数为例探究一次系统调用的逻辑关系链
系统调用流程
我们在异常管理部分已经分析了系统调用的大体流程:
- 保存上下文,即当前线程的各个寄存器值,该工作由
exception_enter完成。结合上回分解我们知道它们是直接被保存在内核栈上的 - 切换到内核栈,即
switch_to_cpu_stack宏,此时由用户态进入内核态 - 根据系统调用表进行跳转,并执行相应的函数
- 处理返回值,恢复上下文,该工作由
exception_exit完成 - 结束系统调用
内核栈切换
这里重点再分析一下之前没有讲到的内核栈切换,先看源码:
1 | |
注意到这个寄存器 TPIDR_EL1 ,Lab 文档告诉我们它可以读取到当前核的 per_cpu_info ,我们作更深一步的了解:
TPIDR_EL1(Thread Process ID Register for EL1)是 ARM 架构中一个特殊的寄存器,用于存储当前执行线程或进程的上下文信息。在操作系统内核中,这个寄存器经常被用来存储指向per_cpu_data结构的指针,该结构包含了特定于 CPU 的数据,比如 CPU 的局部变量和栈指针
实质上,这是个“保留寄存器”,硬件上没有对其的直接操作,留给操作系统实现者自行使用。具体的初始化和设置在 smp 之中,chcore 将其设置为指向 per_cpu_info 结构体的指针,并且在之后不再变化
CPU 信息结构体
现在让我们来看看这个结构体是个什么东东:
1 | |
其中 FPU 指浮点运算单元,这个指针即表示当前使用 FPU 的线程,最后的 pad 以及结尾的编译器声明则旨在让结构体按照 64 字节大小对齐,从而避免多个 CPU 核心访问同一缓存行导致的性能问题
那么 TPIDR_EL1 又是在哪里被设置的呢?我们顺着看其 init 函数:
1 | |
这样一来,切换内核栈的那部分汇编代码就好理解了:系统直接按照结构体的大小读出 CPU 的栈指针,然后一把塞到 sp 寄存器里,即完成了栈的切换。那个 #OFFSET_XXX 宏的定义自然也能猜到是什么了,事实上,它就定义在 smp.h 中:
1 | |
用户态 libc 支持
接下来我们尝试分析 printf 这个用户态函数,文档已经给出了他在 musl-libc 之中的调用链,而跟踪这个调用链,我们就可以一窥 API 和 ABI 的边界
从 printf 到__stdio_write
由 Lab 文档知,printf 经过一系列调用,会来到 __stdout_write 函数,并进一步去到 __stdio_write 函数
1 | |
这里的 SYS_writev 是一个用户态中的宏,负责表示系统调用编号,从而和系统调用联系起来
用户态 syscall 宏展开
继续深究这里的 syscall 宏,其中暗藏大量玄机:
1 | |
这里循环套圈了很多,我们做一个拆解:
1 | |
遵循这个链继续到 syscall_dispatcher.c 文件,会发现它先经历了 __syscall3 后到 __syscall6 的调用,并进入 chcore_writev
这个函数只有三个参数,但是为什么会调用到有 6 个参数的 syscall 呢?这里既可能是为了灵活性的考量,也可能是不同架构下的 write 对应的 syscall 参数不同,选了比较大的那个(例如 pwrite 就需要 5 个参数)
继续追踪,来到 chcore_write 函数,这里调用了 stdout 这一个初始化的 fd 的 fd_ops 的 write 函数
1 | |
找寻 chcore_stdout_write 真身
诶,发现找不下去了!这是因为这时候我们并不知道这里的 write 函数是什么!于是我们考虑反向搜寻,从 chcore_stdout_write 函数往前找:
1 | |
注意到这里使用了 put 函数,它则有了对 syscall_table 直接的调用,正式打破了用户态的边界:
1 | |
但是还有个问题:我们的 chcore_stdout_write 又是如何从 printf 调用到的呢?
继续反向追踪,我们可以发现如下的结构体定义:
1 | |
继续顺藤摸瓜,我们就能找到用这个结构体来初始化 fd_dic 的函数了:
1 | |
这样一来,我们就打通了 printf 的整个调用函数链条,最终通过 put 函数向内核态调用 syscall,从 api 过渡到 abi
分析 FILE*的 write 函数
上面是 printf 的整个流程,最终得到了 chcore_stdout_write 和内核交互。但是我们熟知的 fopen 等 FILE*的 write 又在哪里呢
FILE 是 一个等效于 _IO_FILE 结构体的宏,而后者在 user/system-services/chcore-libc/musl-libc/src/internal/stdio_impl.h 中有着声明
1 | |
实际上,这个 _IO_FILE 是 OS 实现者自己完成的,与 POSIX 对接只需要有 read, write, seek, close 四个方法,它的实现可以用这个函数来说明
1 | |
我们从 write 往回找可以看到,在调用 __fdopen 的时候,我们由一个 fd,动态地生成了这个 _IO_FILE 结构体,并把他的方法用 __stdio_xx 赋值
在 __stdio_xx 内部是 libc 库实现的逻辑,但落到最后是调用 SYS_readv, SYS_read 的 syscall
1 | |
最后给到用户的就是 fopen 了
1 | |
由此我们可以得出, 内核里面始终只维护 fd, 而用户态的 FILE*其实是 libc 做的一层包装,而如果想要自定义 kernel, 只需要保证 SYS_readv, SYS_writev, SYS_read, SYS_write 这些宏存在,并处理对应参数的 syscall 就行
关于 stdout
众所周知,stdout 只是一个 stdout 文件的宏,而 stdout 文件就是 FILE*类型的
1 | |
1 | |
调用逻辑图
最后,我们用一张逻辑链条图来结束今天的旅程:

用户态程序编写
万事具备,现在我们可以尝试自己动手编写我们的用户态程序了:
1 | |
按照文档指示,用已经生成好的工具链编译
不要忘了结果放到 build/ramdisk 文件夹里面,这样内核启动时将自动运行
1 | |
然后 ./chbuild rambuild 重新生成内核镜像,再 ./build/simulate.sh 重新进入 chcore
便可以看到我们编写的 Hello-world!成功运行了
1 | |
至此,系统调用部分的源码解析到此为止