1 syscall 介绍
众所周知,操作系统的大部分关键能力都是实现在内核中。那么操作系统是如何把内核的能力提供给应用程序使用呢?是通过一个叫做"系统调用"(system call,简称 syscall)的机制。
我的专业课课本里面对系统调用是这么解释的:
操作系统的一项主要功能是为程序设计者提供易于使用的计算机访问接口。现代操作系统内核提供一系列具有预定功能的服务例程,称为系统调用。系统调用把应用程序的访问请求传送至内核,调用相应的服务例程完成所需处理,再将处理结果返回给应用程序。------《操作系统教程(第6版)》骆斌 葛季栋 费翔林 著

2 应用程序如何调用 syscall
对操作系统内核而言,它对应用程序的感知就是一段机器指令。在它眼中是没有编程语言的概念的,所以它也就必然不会以编程语言 API 的方式来对应用程序提供 syscall 接口。
PS:这里的机器指令也可以说是汇编指令。因为机器指令和汇编指令是一一对应的,一条汇编指令就是一条机器指令的别名,只需要照着映射表翻译就能成机器指令。因此很多地方都会把机器指令和汇编指令这两个词混用,即使是 Linux 官方的资料也会这么做。因此,我这里也会根据合适的上下文,从这两个词选择其一来使用。
它提供的唯一的调用方式就是汇编接口,调用步骤是这样的:
1. 明确 syscall 信息:应用程序的开发者需要先明白自己要调用的是哪个 syscall,它的编号是什么,它接收什么样的参数。
这个信息可以通过查看各个操作系统的官方文档得到。
2. 准备参数:开发者需要编写汇编代码进行操作,将需要传递的参数放置在寄存器中。
以 x86-64 架构为例,我们传给 syscall 的前 6 个参数依次会被放置在 rdi、rsi、rdx、r10、r8、r9 这 6 个寄存器中。
如果一个 syscall 使用的参数超过 6 个,那就不能用寄存器直接传递,就得通过其他方案,比如栈。不过很少有 syscall 会使用那么多参数。
在 x86-64 架构上,每个寄存器支持存放 64 bit 的数据,通常我们是往里面放一个整数或者放一个指针(内存地址)
3. 准备系统调用号:开发者需要编写汇编代码进行操作,将需要调用的 syscall 的编号放置在寄存器中。
这个编号会因操作系统的不同和硬件的不同而有所区别。
以 Linux x86-64 平台为例,"kill"这个 syscall 的系统调用号是 62。我们需要将62 这个系统调用号放置在 rax 寄存器中。
4. 调用特殊汇编指令:开发者需要编写汇编代码进行操作,调用一些特殊的汇编指令,使应用程序陷入内核态。
CPU 厂商在设计指令集的时候就已经考虑到大家会用来跑操作系统,比较新的指令集里面都会专门涉设计了特殊指令给操作系统厂商用来作为 syscall 入口。而在旧的指令集上则要通过绕弯子的方式去搞。
以 x86-64 架构为例,这是一种比较新的指令集,我们需要调用的特殊汇编指令叫做"syscall"。 从命名上就能看得出来,它就是为了实现 syscall 而生的。这个指令物理上的作用是用来改变特权级的,从 CPU 的视角来看它就是一个改变特权级的指令。但这个行为确实非常适合用来实现 syscall 入口,操作系统厂商就遵从 CPU 厂商的推荐用法,用它去做 syscall 的触发入口了。
3 几种方式?
既然内核只提供了唯一的调用方式,那么为什么我这篇文章又有好几种方式可以讲呢?
这是因为,我们在实际的工程场景中,为了可移植性等目的,人们往往会在这一层汇编代码之上套上各种封装层。我们可以在业界看到有各种五花八门的封装层实现,这样一来,从高级编程语言开发者的视角来看,它们可以选择使用不同的封装方案去间接调用 syscall。把封装方案也算上的话,自然就有"多种方式"了。
视封装方案不同,我们可以有这些调用方式
- 写纯汇编程序是一种方式,只不过很少有人会用纯汇编去开发应用程序
- 用高级语言代码(C 语言也是高级语言)调汇编又是一种方式
- 用封装程度更高的高级语言代码来调 C 语言代码,再间接调用汇编,这也是一种方式
- ......
4 我要讲的场景
如文章标题所示,在这篇文章中我会聚焦场景,我只会展开讲 C 语言程序调用 syscall 的几种方式。不讲纯汇编开发应用程序的场景,也不讲封装程度更高的那些语言。
我会介绍以下几种调用 syscall 的方式,并给出编程演示:
- 方式 1:在 C 代码调用汇编
- 方式 1.1:单独写一个汇编文件作为封装层
- 方式 1.2:写内联汇编作为封装层
- 方式 2:使用 libc
- 方式 2.1:使用 libc 包装函数
- 方式 2.2:使用 libc 里面的
syscall()函数
在演示的过程中,我选用 Linux 系统的 kill 这个 syscall 来作为示例。
kill 的作用就是向某个进程发送一个信号。不同的信号有不同的作用,比如信号 9 就是强杀进程。我们平时用的那个同名的命令行工具 kill,它底层就是调用了这个 syscall。
为了方便大家复现,我基于常用的 Linux x86-64 平台进行演示,这些代码在上面都可以顺利编译运行。
5 在 C 代码调用汇编
5.1 单独写一个汇编文件作为封装层
这个做法,通俗一点的说就是这样:把调用 syscall 的操作写成一个汇编函数,单独写在一个汇编代码文件中,然后把它跟 C 代码"编在一起",这样我们就可以在 C 语言代码中调用 syscall 了。
首先,把调用 syscall 的操作写成汇编代码。
syscall.s
nasm
.section .text
.global syscall_kill
syscall_kill:
mov %rdi, %rdi
mov %rsi, %rsi
mov $62, %rax
syscall
ret
汇编代码里面写的 62 就是 Linux x86-64 平台上 kill 这个 syscall 的系统调用号。
然后是写 C 代码,调用这个汇编函数。
kill.c
c
extern unsigned long syscall_kill(long pid, long sig); // 声明汇编函数
int main() {
long pid = 768827; // 要发送信号的进程 id
long sig = 9; // 要发送的信号,这里使用 SIGKILL 作为示例
unsigned long raw_result;
raw_result = syscall_kill(pid, sig); // 调用汇编函数
return 0;
}
由于这段 C 语言程序没有使用任何 libc 库函数,所以你会看到我没有引用任何头文件。一个 C 语言代码不用任何头文件,这种场景平时是很少见的。
编译
sh
gcc kill.c syscall.s -o kill
(因为汇编指令集是跟硬件强绑定的,这份代码仅支持在 Linux x86-64 平台上编译、运行。)
功能验证:自行修改代码里面的进程 id,换成机器上存在的某个进程的 id。然后把代码编译、运行,你能观察到进程已经被强杀。
介绍两个扩展的知识点:
- C 代码里面我使用的整数用的都不是 int,而是故意声明成 64 位的 long。这是因为我用的服务器是 x86-64 架构的机器,用的汇编指令集是 x86-64 指令集,操作的寄存器是 64 位寄存器。如果使用的是 int,有可能会在往寄存器存取数据的过程中因数据截断而产生一些异常情况。为了避免这一个坑,我使用的是最严谨的写法。下面的内联汇编案例也是这样的状况。
- 这个 raw_result 是 kill syscall 最原始的返回值,内核将 syscall 返回值放在 rax 寄存器中,所以我们可以通过函数返回值的方式取到它。它的使用方式是有点复杂的,比如在出错的时候我们能从中提取出错误码。操作逻辑有点繁琐,所以我的程序里面就没写了,干脆就不拿这个值出来使用了。
5.2 写内联汇编作为封装层
如果单独写汇编代码文件不方便你传参和取值,你也可以选择把它以内联汇编的形式直接写在 C 语言代码文件中。
我们平常所用的 glibc、musl libc,他们的源码就是这么实现的,这两款 libc 就是以这种方式把 syscall 封装成库函数给我们使用的。
kill.c
c
int main() {
long pid = 768827; // 要发送信号的进程 id
long sig = 9; // 要发送的信号,这里使用 SIGKILL 作为示例
unsigned long raw_result;
__asm__ volatile (
"mov %1, %%rdi\n" // 设置第一个参数:pid
"mov %2, %%rsi\n" // 设置第二个参数:sig
"mov $62, %%rax\n" // 设置系统调用号(kill)
"syscall\n"
"mov %%rax, %0\n" // 将返回值存储到 raw_result 变量
: "=r" (raw_result) // 输出操作数:%0 是 raw_result
: "r" (pid), "r" (sig) // 输入操作数:%1 是 pid,%2 是 sig
: "%rax", "%rdi", "%rsi" // 被 clobbered 的寄存器
);
return 0;
}
编译
sh
gcc kill.c -o kill
(因为汇编指令集是跟硬件强绑定的,这份代码仅支持在 Linux x86-64 平台上编译、运行。)
功能验证:自行修改代码里面的进程 id,换成机器上存在的某个进程的 id。然后把代码编译、运行,你能观察到进程已经被强杀。
6 使用 libc
6.1 使用 libc 包装函数
这个就是 C 语言开发者调用 syscall 的最常用的方式了,也是最标准的实践。大部分 syscall 在 libc 里面都有包装函数,直接调用包装函数就行。
由于 libc 帮我们屏蔽掉了底层的硬件差异,所以我们也不需要关心我们要写哪种汇编指令集,也不需要关心系统调用号是多少。这可以让我们的应用程序有更高的跨平台性。
关于 libc 有哪些包装函数,文档怎么找,我有写过一篇文档介绍:Linux man pages的使用
总之代码写起来就是这样:
kill.c
c
#include <signal.h>
int main() {
int pid = 768983; // 要发送信号的进程 id
int sig = 9; // 要发送的信号,这里使用 SIGKILL 作为示例
int result;
result = kill(pid, sig); // 发送信号
return 0;
}
编译
sh
gcc kill.c -o kill
(因 libc 作为一个封装层已经对软硬件进行了解耦,所以这段代码能在所有受支持的 Linux 设备上编译运行,不受 CPU 架构所影响)
功能验证:自行修改代码里面的进程 id,换成机器上存在的某个进程的 id。然后把代码编译、运行,你能观察到进程已经被强杀。
6.2 使用 libc syscall() 函数
有些 syscall 没有包装函数,这种情况下你可以直接调用这个"万能库函数"来调用它。
Linux man pages 是这么解释这个库函数的:
syscall() 是一个小型库函数,它根据系统调用号和参数找到与之匹配的汇编语言接口,执行相应的系统调用。例如,在调用 C 库中没有包装函数的系统调用时,使用 syscall() 非常有用。
比如 Linux 内核在 2009 年就已经实现了 gettid 这个 syscall,但 glibc 从 2019 年开始才为它提供包装函数。
在中间的这 10 年时间内,开发者要调用 gettid 的话,就只能通过这个"万能库函数"来调用。
我这里也演示一下怎么用 syscall() 来调用 kill。
kill.c
c
#include <unistd.h>
#include <sys/syscall.h>
int main() {
int pid = 769051; // 要发送信号的进程 id
int sig = 9; // 要发送的信号,这里使用 SIGKILL 作为示例
long result;
result = syscall(SYS_kill, pid, sig); // 使用 syscall() 函数,调用 kill
return 0;
}
编译
sh
gcc kill.c -o kill
(因 libc 作为一个封装层已经对软硬件进行了解耦,所以这段代码能在所有受支持的 Linux 设备上编译运行,不受 CPU 架构所影响)
功能验证:自行修改代码里面的进程 id,换成机器上存在的某个进程的 id。然后把代码编译、运行,你能观察到进程已经被强杀。