系统调用
系统调用
系统调用是系统为用户程序提供的高特权操作接口。在本实验中,用户程序通过
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 |
|
至此,系统调用部分的源码解析到此为止