Linux 系统调用

最后更新: 2024-07-15 08:53:17 +0800 +0800
原文以英文撰写。您正在阅读AI翻译版本。

Linux 系统调用构成进程与内核之间、特定于体系结构的二进制接口。应用程序代码通常改为面向 C 库 API,因为库提供了类型、特性测试宏、兼容性行为以及特定于机器的入口序列。

库 API 与内核 ABI #

像 open(path, flags) 这样的源代码级调用,并不承诺执行名为 open 的内核系统调用。glibc 可以通过 openat 实现它,添加取消检查,或选择与时间宽度兼容的入口点。像 clock_gettime 这样的函数可以在 vDSO 中执行,而无需进入内核。反过来,Linux 也公开了诸如 gettid 这样的系统调用,而较旧的 C 库并未为其提供封装器。

内核 ABI 包括:

  • 适用于进程体系结构和 ABI 的系统调用号;
  • 以寄存器表示的参数,以及在某些 ABI 中部分位于栈上的参数;
  • 诸如 syscall、int $0x80、svc 或 ecall 这样的入口指令;以及
  • 用于表示成功和错误的原始返回约定。

来自 <sys/syscall.h> 的 SYS_name 常量标识目标构建 ABI 所使用的编号。它们不会跨体系结构保持稳定:x86-64 的 write 为 1,而 i386 的 write 为 4。Compat 和 x32 进程在同一个内核上还可能使用另一张表。内核通过该 ABI 的系统调用表分派编号,交由通常使用 SYSCALL_DEFINE* 宏声明的实现处理。

寄存器、结果与 errno #

下面通过两个常见的 Linux x86 入口 ABI 展示这种映射:

ABI入口编号参数 1–6结果
x86-64syscallraxrdi、rsi、rdx、r10、r8、r9rax
i386int $0x80eaxebx、ecx、edx、esi、edi、ebpeax

x86-64 指令会覆盖 rcx 和 r11,因此第四个参数使用 r10,而不是 C 函数 ABI 中的 rcx。其他体系结构具有不同的对齐和寄存器对规则;例如,某些 32 位 ABI 会拆分并对齐 64 位参数。

在 x86 上,内核通过结果寄存器中的负值(范围从 -1 到 -4095)报告错误。libc 封装器会将该原始结果转换为 -1,并将正的错误编号存储到线程本地的 errno 中。具有独立错误指示器的体系结构会由其 libc 封装器进行规范化。因此,代码必须测试文档规定的封装器返回值,而不是在调用成功后读取 errno。

直接使用 syscall #

glibc 为没有常规封装器的入口提供了间接封装器:

c
#define _GNU_SOURCE
#include <sys/syscall.h>
#include <unistd.h>

long tid = syscall(SYS_gettid);
if (tid == -1) {
    /* errno describes the failure. */
}

原型为 long syscall(long number, ...);。该函数执行体系结构入口序列,并将内核错误转换为 errno,但其可变参数不会进行类型检查。请将值转换为精确的 ABI 宽度,并遵循拆分 64 位参数的体系结构特定规则。如果正在运行的内核未实现相应编号,新的调用还可能返回 ENOSYS。

当存在 libc 封装器时,应优先使用它,因为直接使用可能绕过以下机制:

  • libc 实现的结构和时间宽度兼容性;
  • 由封装器定义的旧系统调用回退;
  • POSIX 线程取消行为;以及
  • vDSO 加速。

原始内联汇编还会引入寄存器约束、破坏描述、内存排序和错误转换等风险。只有在实现 C 库或其他 ABI 级运行时时,使用它才是合理的。

体系结构与版本可移植性 #

除非更高层标准也定义了相应的库行为,否则内核系统调用仅适用于 Linux。编译时 SYS_name 的可用性只能证明头文件描述了某个 ABI;不能证明正在运行的内核实现了该调用。应通过调用它并处理 ENOSYS 来进行探测,然后在存在语义正确的回退方案时使用该方案。

不要从其他体系结构复制编号,也不要将主机编号表硬编码到交叉编译的程序中。通过边界传递的结构还取决于所选 ABI 的字段宽度、对齐方式、字节序和 UAPI 定义。出于同样的原因,Seccomp 和审计会同时识别体系结构标记和编号。

重启、取消、跟踪与 Seccomp #

信号可能会中断阻塞调用。在返回用户空间期间,内核使用内部的 -ERESTART* 结果来决定是否重新发出调用;用户空间通常看到的结果要么是调用已完成,要么是 -1/EINTR。SA_RESTART、具体的系统调用以及数据是否已经传输,共同决定最终结果。因此,只有当操作的 API 允许安全重试时,应用程序才应重试 EINTR,并且在重试前必须处理正的短结果。

POSIX 线程取消是 libc 的属性,而不是内核系统调用的属性。用于取消点的 glibc 封装器会在阻塞操作周围协调取消;直接调用 syscall() 可能绕过这种协调。

strace 和 ptrace 会观察内核入口和退出、参数以及原始结果。完全由 vDSO 满足的调用不会生成系统调用停止。跟踪器还可以修改编号、参数或结果,因此被跟踪的行为不是独立的安全边界。

Seccomp BPF 会在内核执行前评估系统调用的体系结构、编号和原始参数值。健壮的允许列表会同时检查体系结构标记和编号,因为不同体系结构的编号空间会重叠。过滤器无法解引用指针参数,而 libc 升级可能会选择与 API 名称所暗示的调用不同的底层系统调用。因此,Seccomp 可以缩小内核攻击面,但并不构成完整的沙箱。

上游的 syscall(2) 页面列出了入口和寄存器约定,而内核的 seccomp documentation 定义了过滤语义。

评论

在 GitHub 上阅读或评论(发表评论需要登录)