mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5779 字
16 分钟
系统调用
2024-09-15

一、引言:用户空间与内核空间的桥梁#

上一章中,我们建立了 Linux 内核的整体架构认知:用户空间与内核空间通过 CPU 的特权级机制严格隔离,应用程序无法直接操作硬件或访问内核数据结构。那么,当程序需要读文件、创建进程、分配内存时,它如何跨越这道”鸿沟”?

答案就是——系统调用(System Call)

系统调用是内核向用户程序暴露的唯一合法入口,是用户空间通向内核空间的”城门”。每一个你熟悉的 C 库函数——printf()malloc()open()fork()——最终都会通过系统调用请求内核服务。理解系统调用,就是理解用户程序与内核交互的完整机制,这是深入 Linux 内核的第一道门槛。

本章将沿着一条系统调用的完整执行路径,从用户态的函数调用出发,穿越特权级切换,抵达内核态的处理函数,再原路返回。逐环节剖析每一个环节的细节,并探讨 VDSO 加速、ioctl 扩展、系统调用开销等进阶话题。

二、系统调用的本质:特权级切换#

1.1 为什么需要系统调用?#

现代操作系统通过 CPU 的**特权级(Privilege Level)**机制实现空间隔离。x86_64 架构定义了 4 个特权级(Ring 0 ~ Ring 3),Linux 只使用其中两个:

  • Ring 0(内核态):拥有完全的硬件访问权限,可以执行所有指令、访问所有内存
  • Ring 3(用户态):受限的执行环境,无法执行特权指令(如 clioutmov cr0),无法直接访问内核内存

用户程序运行在 Ring 3,但许多操作必须由 Ring 0 完成——读写磁盘、收发网络包、分配物理内存、创建新进程……如果用户程序需要这些服务,它必须主动请求内核代为执行,这就是系统调用的根本动机。

Note

系统调用不是”函数调用”。函数调用在同一个特权级内完成,不涉及权限切换;系统调用则必须从 Ring 3 切换到 Ring 0,这是一次受控的特权级提升,由 CPU 硬件机制保证安全。

1.2 特权级切换的硬件机制#

x86_64 提供了两种从用户态进入内核态的机制:

机制触发方式典型场景
中断/异常外部信号或 CPU 异常自动触发时钟中断、缺页异常、除零错误
系统调用程序主动执行 syscall 指令read()write()fork()

两者的共同点是:都通过**中断描述符表(IDT)MSR(Model-Specific Register)**找到内核入口地址,完成栈切换和特权级提升。不同点在于:中断/异常是”被动”的,系统调用是”主动”的。

1.3 从 int 0x80syscall:演进史#

x86 架构的系统调用指令经历了三代演进:

  1. int 0x80:最早的方式,通过软件中断触发。Linux 早期使用,所有系统调用统一走中断向量 0x80。缺点是中断处理开销大(需要查 IDT、权限检查等),速度慢。

  2. sysenter(Intel)/ syscall(AMD):Pentium II / K7 时代引入的快速系统调用指令,跳过中断处理的部分开销,直接通过 MSR 寄存器指定内核入口地址。

  3. syscall(x86_64 统一):在 64 位模式下,x86_64 统一使用 syscall 指令。它比 sysenter 更高效,是现代 Linux x86_64 的唯一系统调用入口。

# 查看你的系统是否支持 syscall 指令
grep -o 'syscall' /proc/cpuinfo | head -1 && echo "支持 syscall"
Tip

32 位兼容模式下,Linux 仍保留 int 0x80sysenter 入口,以支持 32 位程序。但 64 位程序统一使用 syscall

三、系统调用完整链路追踪#

这是本章的核心:一条系统调用从用户态发起,到内核态执行,再返回用户态的完整路径。以 write(1, "hello", 5) 为例,逐环节剖析。

sequenceDiagram participant App as 用户程序 participant Libc as glibc wrapper participant CPU as CPU (syscall指令) participant Entry as 入口汇编<br/>entry_64.S participant Table as sys_call_table participant Handler as sys_write() App->>Libc: write(1, "hello", 5) Note over Libc: 将 fd→rdi, buf→rsi<br/>count→rdx, NR_write→rax Libc->>CPU: syscall 指令 Note over CPU: 保存 RIP→RCX<br/>保存 RFLAGS→R11<br/>从 MSR_LSTAR 加载 RIP<br/>切换到内核栈 CPU->>Entry: 跳转到 entry_SYSCALL_64 Entry->>Entry: 保存用户态寄存器到 pt_regs Entry->>Table: 查表: sys_call_table[rax] Table->>Handler: 调用 sys_write() Handler->>Handler: 执行内核操作<br/>(写入文件/VFS) Handler-->>Entry: 返回值存入 rax Entry->>Entry: 恢复用户态寄存器 Entry->>CPU: sysret 指令 Note over CPU: 从 RCX 恢复 RIP<br/>从 R11 恢复 RFLAGS<br/>切换回用户栈 CPU-->>Libc: 返回用户态 Libc-->>App: 返回写入字节数(或 -1)

2.1 第一站:glibc Wrapper#

用户程序通常不会直接使用 syscall 指令,而是调用 glibc 提供的封装函数(wrapper)。以 write() 为例,glibc 中的实现大致如下:

// glibc: sysdeps/unix/sysv/linux/x86_64/syscall.S(简化版)
ssize_t write(int fd, const void *buf, size_t count)
{
// 将系统调用号存入 rax
// 将参数按约定存入寄存器
// 执行 syscall 指令
// 检查返回值,设置 errno
register long rax __asm__("rax") = __NR_write; // 系统调用号 1
register long rdi __asm__("rdi") = fd;
register long rsi __asm__("rsi") = (long)buf;
register long rdx __asm__("rdx") = count;
__asm__ volatile ("syscall"
: "+r" (rax)
: "r" (rdi), "r" (rsi), "r" (rdx)
: "memory", "rcx", "r11");
// 返回值处理:负值表示错误
if (rax < 0) {
errno = -rax;
return -1;
}
return rax;
}

glibc wrapper 做了三件关键的事:

  1. 设置系统调用号:将 __NR_write(值为 1)加载到 rax
  2. 按约定传递参数:将 fdbufcount 分别加载到 rdirsirdx
  3. 处理返回值:内核返回负值表示错误,glibc 将其转换为 -1 并设置 errno

2.2 第二站:syscall 指令#

syscall 指令是 CPU 硬件实现的,它完成以下操作:

  1. 保存返回地址:将当前 RIP 存入 RCX,将 RFLAGS 存入 R11
  2. 加载内核入口:从 MSR 寄存器 IA32_LSTAR(地址 0xC0000082)加载内核入口地址到 RIP
  3. 切换栈:从 MSR IA32_KERNEL_GS_BASE 加载内核 GS 段基址,通过 swapgs 指令切换 GS 基址,然后从 per-CPU 数据结构获取内核栈指针
  4. 清除标志位:清除 RFLAGS 中的中断标志(IF),禁止中断
  5. 跳转执行:CPU 开始从新的 RIP(即内核入口)取指执行
# 查看你的系统上 syscall 入口地址
sudo rdmsr -d 0xC0000082 # 读取 IA32_LSTAR MSR

2.3 第三站:入口汇编 entry_SYSCALL_64#

内核入口定义在 arch/x86/entry/entry_64.S,核心逻辑如下:

// arch/x86/entry/entry_64.S(简化版)
SYM_CODE_START(entry_SYSCALL_64)
swapgs // 切换 GS 基址到内核 GS
movq %rsp, %gs:cpu_current_top_of_stack // 保存用户态栈指针
movq %gs:cpu_current_top_of_stack, %rsp // 切换到内核栈
// 保存用户态寄存器到内核栈上的 pt_regs 结构
pushq $__USER_DS // pt_regs->ss
pushq PER_CPU_VAR(cpu_current_top_of_stack) // pt_regs->sp
pushq %r11 // pt_regs->flags (从 syscall 保存的 R11)
pushq $__USER_CS // pt_regs->cs
pushq %rcx // pt_regs->ip (从 syscall 保存的 RCX)
pushq $-1 // pt_regs->orig_ax (占位,标记系统调用)
pushq %rdi // 保存参数寄存器
pushq %rsi
pushq %rdx
pushq %r10 // 注意:r10 而非 rcxrcx 已被 syscall 使用)
pushq %r8
pushq %r9
// 切换到内核栈后,调用 C 函数
movq %rsp, %rdi // 第一个参数: pt_regs 指针
call do_syscall_64 // 进入 C 代码
SYM_CODE_END(entry_SYSCALL_64)

这段汇编的核心任务是保存用户态上下文(所有通用寄存器)到内核栈上的 pt_regs 结构中,然后跳转到 C 函数 do_syscall_64()

Important

pt_regs 是内核栈帧上保存用户态寄存器快照的结构体,定义在 arch/x86/include/asm/ptrace.h。系统调用返回时,内核从 pt_regs 恢复用户态寄存器。

2.4 第四站:do_syscall_64()sys_call_table#

// arch/x86/entry/common.c(简化版)
__visible noinstr void do_syscall_64(struct pt_regs *regs, int nr)
{
// 安全检查:系统调用号是否合法
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
// 从分派表中查找并调用对应的内核函数
regs->ax = sys_call_table[nr](regs);
} else {
regs->ax = __x64_sys_ni_syscall(regs); // 返回 -ENOSYS
}
}

这里出现了系统调用分发的核心数据结构——sys_call_table,一个函数指针数组,索引就是系统调用号。

2.5 第五站:具体内核函数 sys_write()#

最终,控制流转到 sys_write()(或更准确地说,__x64_sys_write()),这是真正执行写入操作的内核函数:

// fs/read_write.c(简化版)
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
if (f.file) {
loff_t pos = file_pos_read(f.file);
ret = vfs_write(f.file, buf, count, &pos);
file_pos_write(f.file, pos);
fdput_pos(f);
}
return ret;
}

SYSCALL_DEFINE3 是一个宏,展开后生成函数签名 __x64_sys_write(struct pt_regs *regs),从 regs 中提取参数,然后调用 VFS 层的 vfs_write() 完成实际写入。

2.6 返回路径#

系统调用完成后,返回路径是入口路径的逆过程:

  1. sys_write() 返回值存入 regs->ax
  2. 汇编代码从 pt_regs 恢复用户态寄存器
  3. 执行 sysret 指令(或 iret),CPU 从 RCX 恢复 RIP,从 R11 恢复 RFLAGS
  4. 回到用户态,glibc wrapper 检查返回值

四、系统调用号与分派表#

3.1 系统调用号:__NR_*#

每个系统调用都有一个唯一的编号,定义在内核头文件中:

// include/uapi/asm-generic/unistd.h(部分示例)
#define __NR_io_setup 0
#define __NR_io_destroy 1
#define __NR_io_submit 2
#define __NR_io_cancel 3
#define __NR_io_getevents 4
#define __NR_setxattr 5
#define __NR_lsetxattr 6
#define __NR_fsetxattr 7
#define __NR_getxattr 8
// ...
#define __NR_write 1
#define __NR_read 0
#define __NR_open 2
#define __NR_close 3

在用户空间,这些宏可以通过 <sys/syscall.h> 头文件访问:

#include <sys/syscall.h>
#include <stdio.h>
int main() {
printf("NR_read = %ld\n", __NR_read); // 0
printf("NR_write = %ld\n", __NR_write); // 1
printf("NR_open = %ld\n", __NR_open); // 2
printf("NR_close = %ld\n", __NR_close); // 3
printf("NR_fork = %ld\n", __NR_fork); // 57
printf("NR_execve= %ld\n", __NR_execve); // 59
return 0;
}
Note

x86_64 和 x86(32 位)的系统调用号不同!例如 __NR_open 在 x86_64 上是 2,在 x86 32 位上是 5。这是因为 x86_64 重新编排了系统调用号。

3.2 sys_call_table:分派表#

sys_call_table 是一个函数指针数组,定义在 arch/x86/entry/syscall_64.c

arch/x86/entry/syscall_64.c
#include <asm/syscalls.h>
// 这个宏展开为函数指针数组
sys_call_ptr_t sys_call_table[] = {
#include <asm/syscalls_64.h> // 每行一个函数指针
};

其中 syscalls_64.h 是构建时自动生成的,每行格式如下:

// include/generated/asm/syscalls_64.h(自动生成,部分示例)
[0] = __x64_sys_read,
[1] = __x64_sys_write,
[2] = __x64_sys_open,
[3] = __x64_sys_close,
// ... 约 400+ 个条目
# 查看当前内核支持的系统调用数量
grep -c __x64_sys_ /boot/System.map-$(uname -r)
# 查看所有系统调用符号
grep __x64_sys_ /proc/kallsyms | head -20

3.3 系统调用号分配规则#

Linux 系统调用号的分配遵循以下规则:

范围用途
0 ~ 449已分配的系统调用(稳定 ABI)
450 ~ 511保留给未来使用
512 ~ 543x32 ABI 专用(32 位指针的 64 位程序,已弃用)

系统调用号一旦分配,永不回收。即使某个系统调用被标记为弃用(如 __NR_uselib),其编号也不会被重新分配,以保证二进制兼容性。

五、参数传递约定#

4.1 x86_64 寄存器约定#

x86_64 Linux 系统调用的参数传递遵循 System V AMD64 ABI 的调用约定,但有一个关键差异:

寄存器用途说明
rax系统调用号入口:__NR_*;出口:返回值
rdi第 1 个参数对应函数的第 1 个参数
rsi第 2 个参数对应函数的第 2 个参数
rdx第 3 个参数对应函数的第 3 个参数
r10第 4 个参数注意:不是 rcx
r8第 5 个参数-
r9第 6 个参数-

为什么第 4 个参数用 r10 而不是 rcx 因为 syscall 指令会自动将返回地址存入 rcx,覆盖了原来的值。所以 glibc wrapper 在执行 syscall 前会将第 4 个参数从 rcx 复制到 r10

4.2 参数数量限制#

系统调用最多支持 6 个参数,全部通过寄存器传递。如果某个系统调用需要超过 6 个参数(极少见),则将参数打包到一个结构体中,通过指针传递该结构体。

例如 mmap() 有 6 个参数,刚好用满所有寄存器:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
// rdi rsi rdx r10 r8 r9

4.3 用户空间指针的安全性#

内核函数接收的用户空间指针(如 write()buf 参数)是不可信的——用户程序可能传入空指针、非法地址或内核地址。内核必须使用 copy_from_user() / copy_to_user() 等专用函数来安全地访问用户空间内存:

// 内核中安全访问用户空间内存的方式
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
{
// __user 标注表明这是用户空间指针
// copy_from_user() 会做地址合法性检查
// 如果指针非法,返回非零值(表示未拷贝的字节数)
}

copy_from_user() 内部会调用 access_ok() 检查地址是否在用户空间范围内,并处理缺页异常。这是内核安全的第一道防线。

六、系统调用的开销分析#

系统调用比普通函数调用慢得多,这是由其本质决定的。下面量化分析每一项开销。

5.1 开销来源#

开销项典型耗时说明
syscall 指令本身~20-50 ns保存/恢复寄存器、切换入口
栈切换~10-20 ns用户栈 → 内核栈 → 用户栈
pt_regs 保存/恢复~10-20 ns保存所有通用寄存器
上下文切换开销~100-500 ns包括 TLB 刷新、缓存污染
内核代码执行视系统调用而定实际执行逻辑
sysret 返回~10-20 ns恢复用户态

一次简单的 getpid() 系统调用(几乎不做任何实际工作),总开销大约在 200ns ~ 1μs 之间,而一次普通函数调用仅需 2-5ns。也就是说,系统调用比函数调用慢 50 ~ 500 倍

5.2 为什么系统调用比函数调用慢?#

具体来说,系统调用的额外开销来自以下几个方面:

1. 特权级切换的硬件成本

syscall / sysret 指令需要做大量工作:保存/恢复 RIPRFLAGS,切换 GS 基址,切换栈指针。这些操作涉及多个 MSR 寄存器的读写,而 MSR 访问是相对较慢的串行化操作。

2. 栈切换与寄存器保存

函数调用只需保存返回地址(call 指令自动压栈),而系统调用需要保存所有通用寄存器pt_regs 结构——x86_64 上至少 21 个字段。这是因为内核代码可能修改任何寄存器,必须完整保存用户态上下文。

3. TLB 刷新与缓存污染

进入内核态后,内核使用不同的页表(内核页表),这可能导致 TLB(Translation Lookaside Buffer)条目失效。虽然 x86_64 使用 PCID(Process-Context Identifier)技术减少了 TLB 刷新,但开销仍然存在。此外,内核代码执行会污染 L1/L2 缓存,导致返回用户态后缓存未命中增加。

4. 安全检查

内核必须验证所有来自用户空间的参数(地址合法性、权限检查等),这些检查在普通函数调用中是不需要的。

5.3 开销的实践影响#

// 对比:系统调用 vs 函数调用
#include <sys/syscall.h>
#include <unistd.h>
#include <time.h>
#define ITERATIONS 1000000
int main() {
struct timespec start, end;
// 测试 1: 系统调用 getpid()
clock_gettime(CLOCK_MONOTONIC, &start);
for (int i = 0; i < ITERATIONS; i++) {
syscall(__NR_getpid);
}
clock_gettime(CLOCK_MONOTONIC, &end);
double sys_time = (end.tv_sec - start.tv_sec) * 1e9 +
(end.tv_nsec - start.tv_nsec);
// 测试 2: 普通函数调用
clock_gettime(CLOCK_MONOTONIC, &start);
for (int i = 0; i < ITERATIONS; i++) {
getpid(); // glibc 可能缓存了结果,不会每次都系统调用
}
clock_gettime(CLOCK_MONOTONIC, &end);
double func_time = (end.tv_sec - start.tv_sec) * 1e9 +
(end.tv_nsec - start.tv_nsec);
printf("syscall(__NR_getpid): %.1f ns/call\n", sys_time / ITERATIONS);
printf("getpid() (glibc): %.1f ns/call\n", func_time / ITERATIONS);
return 0;
}
Tip

glibc 对 getpid() 做了缓存优化——首次调用后会将 PID 存入全局变量,后续调用直接返回缓存值,不再触发系统调用。这是用户态库减少系统调用开销的常见策略。

七、vsyscall 与 VDSO:加速高频只读系统调用#

某些系统调用(如 gettimeofday()getcpu())具有两个特点:高频调用 + 只读数据。每次都走完整的系统调用路径太浪费了。Linux 提供了两种机制来加速这类调用。

6.1 vsyscall:最早的尝试#

vsyscall(Virtual System Call)是最早的加速机制,其原理是将一个特殊的内核映射页面映射到每个进程的固定虚拟地址 0xffffffffff600000

用户空间地址布局:
0xffffffffff600000 ┌─────────────────────┐
│ vsyscall 页面 │
│ - gettimeofday() │
│ - time() │
│ - getcpu() │
0xffffffffff601000 └─────────────────────┘

用户程序直接调用这个页面中的代码,无需切换到内核态。但 vsyscall 有严重的安全问题:

  • 地址固定:所有进程的 vsyscall 页面都在同一地址,容易成为攻击目标
  • 代码固定:vsyscall 页面的指令是静态的,无法动态更新
  • ASLR 绕过:固定地址破坏了地址空间随机化(ASLR)

因此 vsyscall 已被弃用,现代内核默认使用 VDSO 替代。

6.2 VDSO:现代加速方案#

VDSO(Virtual Dynamic Shared Object)是 vsyscall 的安全替代方案。它将一个**共享库(.so)**映射到每个进程的地址空间,但与普通共享库不同,这个库由内核提供,内容随内核更新而变化。

graph TB subgraph 用户空间 App[应用程序] -->|"调用 gettimeofday()"| Libc[glibc] Libc -->|"检查 VDSO 是否可用"| VDSO_Check{VDSO 可用?} VDSO_Check -->|"是"| VDSO_Page["VDSO 页面<br/>(内核映射到用户空间)"] VDSO_Check -->|"否"| Syscall["syscall 指令<br/>(传统路径)"] VDSO_Page -->|"直接读取内核数据<br/>无需特权级切换"| Result["返回时间值"] Syscall -->|"进入内核态"| Kernel["内核处理"] Kernel --> Result2["返回时间值"] end subgraph 内核空间 VDSO_Data["vdso_data<br/>(内核维护的时间数据<br/>映射到用户空间)"] VDSO_Page -.->|"读取"| VDSO_Data end style VDSO_Page fill:#4ade80,stroke:#166534,color:#000 style VDSO_Data fill:#60a5fa,stroke:#1e40af,color:#000 style Syscall fill:#f87171,stroke:#991b1b,color:#000

VDSO 的工作原理:

  1. 内核在启动时创建 VDSO 共享库(arch/x86/entry/vdso/ 目录下的源码编译生成)
  2. 每个新进程execve() 时,内核将 VDSO 映射到进程地址空间(地址随机化,支持 ASLR)
  3. glibc 在初始化时通过 auxv(辅助向量)找到 VDSO 的地址,将 gettimeofday() 等函数重定向到 VDSO 实现
  4. VDSO 中的代码直接读取内核映射到用户空间的数据页(vdso_data),无需系统调用
# 查看 VDSO 在进程地址空间中的映射
cat /proc/self/maps | grep vdso
# 输出示例:
# 7f8e4a7fe000-7f8e4a7ff000 r-xp 00000000 00:00 0 [vdso]
# 查看 VDSO 导出的符号
readelf -s /proc/self/exe 2>/dev/null | grep -i vdso
# 或者
objdump -T /lib64/ld-linux-x86-64.so.2 | grep vdso

6.3 VDSO 加速了哪些系统调用?#

系统调用VDSO 是否加速说明
clock_gettime()最常用,读取高精度时间
gettimeofday()读取秒级+微秒级时间
time()读取秒级时间
getcpu()获取当前 CPU 编号和 NUMA 节点
clock_getres()获取时钟精度
read() / write()需要内核执行 I/O 操作
mmap() / fork()需要修改内核数据结构
Important

VDSO 只能加速只读且不涉及特权操作的系统调用。任何需要修改内核状态或访问硬件的操作,都必须走完整的系统调用路径。

6.4 VDSO 的性能收益#

# 对比 VDSO 加速前后的 gettimeofday 性能
# 测试 1: 通过 VDSO(默认路径)
strace -c -e trace=gettimeofday ./test_gettime
# 测试 2: 绕过 VDSO,强制走系统调用
# 设置环境变量禁用 VDSO
LD_PRELOAD=/path/to/disable_vdso.so ./test_gettime

典型结果:通过 VDSO 的 gettimeofday() 耗时约 20-40ns,而走系统调用路径约 200-500ns,加速 5-10 倍

八、ioctl:系统调用的”万能扩展口”#

7.1 为什么需要 ioctl?#

Linux 系统调用号是有限的(目前约 400+),不可能为每个设备的每种操作都定义一个专用系统调用。ioctl(I/O Control)的设计思想是:提供一个通用的系统调用,通过请求码(request code)区分不同的操作

int ioctl(int fd, unsigned long request, ...);
  • fd:已打开的设备文件描述符
  • request:操作请求码,编码了设备类型、操作方向(读/写)、数据大小和命令编号
  • ...:可选的第三个参数,通常是指向数据结构的指针

7.2 ioctl 请求码的编码规则#

ioctl 请求码是一个 32 位整数,按以下方式编码:

方向 | 大小 | 类型 | 编号
(2 bit) | (14bit) | (8 bit) | (8 bit)
  • 方向_IOC_NONE(0,无数据传输)、_IOC_WRITE(1,从用户写入内核)、_IOC_READ(2,从内核读取到用户)
  • 大小:第三个参数的字节数
  • 类型:设备类型的魔数(magic number),避免不同设备的请求码冲突
  • 编号:具体操作编号

内核提供了宏来构造请求码:

// 构造 ioctl 请求码的宏
#define _IO(type, nr) // 无数据传输
#define _IOR(type, nr, datatype) // 从内核读数据
#define _IOW(type, nr, datatype) // 向内核写数据
#define _IOWR(type, nr, datatype) // 双向数据传输
// 示例:终端相关的 ioctl
#define TCGETS _IOR('T', 0x01, struct termios) // 获取终端属性
#define TCSETS _IOW('T', 0x02, struct termios) // 设置终端属性

7.3 ioctl 的典型应用场景#

场景设备文件示例请求码
终端控制/dev/ttyTCGETSTCSETS
网络接口配置socket fdSIOCGIFADDRSIOCSIFADDR
块设备操作/dev/sdaBLKGETSIZEBLKRRPART
显卡驱动/dev/dri/card0DRM_IOCTL_GEM_CLOSE
输入设备/dev/input/event0EVIOCGBITEVIOCGNAME
USB 设备/dev/bus/usb/xxxUSBDEVFS_IOCTL
Note

ioctl 被称为”系统调用的万能扩展口”,但也因此饱受争议——它缺乏统一的类型安全,请求码命名混乱,是内核中 bug 的高发区。Linus Torvalds 本人也多次批评 ioctl 的滥用。但对于设备驱动的扩展需求,它仍然是最实用的方案。

九、新增系统调用的完整流程#

理解了系统调用的完整机制后,实践一下:如何为 Linux 内核添加一个自定义系统调用?这个练习将帮你把所有知识点串联起来。

8.1 目标:添加 sys_hello 系统调用#

添加一个简单的系统调用,接收一个字符串参数,在内核日志中打印问候信息。

8.2 步骤一:定义系统调用号#

编辑 include/uapi/asm-generic/unistd.h,在末尾添加:

include/uapi/asm-generic/unistd.h
#define __NR_hello 451 // 使用下一个可用编号
__SYSCALL(__NR_hello, sys_hello)
#undef __NR_syscalls
#define __NR_syscalls 452 // 更新总数

8.3 步骤二:添加系统调用表条目#

对于 x86_64,编辑 arch/x86/entry/syscalls/syscall_64.tbl

arch/x86/entry/syscalls/syscall_64.tbl
451 common hello sys_hello

8.4 步骤三:实现系统调用函数#

kernel/sys.c(或新建文件)中添加实现:

kernel/sys.c
#include <linux/syscalls.h>
#include <linux/printk.h>
SYSCALL_DEFINE1(hello, const char __user *, message)
{
char buf[256];
long len;
// 安全地从用户空间拷贝字符串
len = strncpy_from_user(buf, message, sizeof(buf) - 1);
if (len <= 0)
return -EFAULT;
buf[len] = '\0';
pr_info("sys_hello: received '%s'\n", buf);
return 0;
}

8.5 步骤四:编译并测试#

# 编译内核
make -j$(nproc)
# 在 QEMU 中启动新内核
qemu-system-x86_64 -kernel arch/x86/boot/bzImage -m 512M
# 编写测试程序
cat > test_hello.c << 'EOF'
#include <sys/syscall.h>
#include <stdio.h>
#include <unistd.h>
int main() {
long ret = syscall(451, "Hello from userspace!");
if (ret < 0) {
perror("syscall failed");
return 1;
}
printf("syscall returned %ld\n", ret);
return 0;
}
EOF
gcc -o test_hello test_hello.c
./test_hello
# 查看内核日志
dmesg | grep sys_hello

8.6 完整流程总结#

flowchart TD A["1. 定义系统调用号<br/>include/uapi/asm-generic/unistd.h"] --> B["2. 添加分派表条目<br/>arch/x86/entry/syscalls/syscall_64.tbl"] B --> C["3. 实现内核函数<br/>SYSCALL_DEFINEx()"] C --> D["4. 编译内核<br/>make -j$(nproc)"] D --> E["5. 编写用户态测试程序<br/>syscall(__NR_hello, ...)"] E --> F["6. 测试验证<br/>dmesg | grep sys_hello"] style A fill:#60a5fa,stroke:#1e40af,color:#000 style B fill:#818cf8,stroke:#3730a3,color:#000 style C fill:#a78bfa,stroke:#5b21b6,color:#000 style D fill:#c084fc,stroke:#7e22ce,color:#000 style E fill:#e879f9,stroke:#a21caf,color:#000 style F fill:#4ade80,stroke:#166534,color:#000
Warning

修改内核源码有风险,建议在虚拟机或 QEMU 中进行实验,不要在你日常使用的主机内核上直接操作。

十、strace 工具详解#

strace 是追踪程序系统调用行为的利器,是理解系统调用最直观的实践工具。

9.1 strace 原理#

strace 基于 ptrace() 系统调用实现。ptrace() 允许一个进程(tracer)观察和控制另一个进程(tracee)的执行,包括拦截系统调用的入口和出口。

strace 的工作流程:

  1. fork() 创建子进程
  2. 子进程调用 ptrace(PTRACE_TRACEME) 标记自己为被追踪者
  3. 子进程 exec() 目标程序
  4. 每次系统调用时,内核暂停子进程,通知父进程(strace)
  5. strace 读取系统调用号、参数、返回值,格式化输出

9.2 常用选项#

选项说明示例
-c统计系统调用的次数和耗时strace -c ls
-e trace=SET只追踪指定的系统调用strace -e trace=openat,read,write cat /etc/hosts
-p PID追踪正在运行的进程strace -p 1234
-o FILE输出到文件strace -o trace.log ./myapp
-f追踪子进程strace -f make
-T显示每个系统调用的耗时strace -T ls
-yy解码文件描述符和路径strace -yy cat /dev/null

9.3 实战示例#

示例 1:统计 ls 命令的系统调用

$ strace -c ls /tmp
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
28.45 0.000324 3 102 mmap
18.72 0.000213 3 72 mprotect
12.34 0.000140 4 32 openat
9.87 0.000112 3 34 read
8.64 0.000098 3 32 close
7.21 0.000082 3 28 fstat
5.43 0.000062 2 30 brk
...
------ ----------- ----------- --------- --------- ----------------
100.00 0.001139 456 12 total

可以看到,一个简单的 ls 命令就触发了 456 次系统调用!其中 mmap 最多(动态链接器加载共享库),openat/read/close 是文件操作。

示例 2:追踪特定系统调用

$ strace -e trace=openat cat /etc/hosts
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/etc/hosts", O_RDONLY) = 3
+++ exited with 0 +++

示例 3:追踪正在运行的进程

# 先找到目标进程 PID
pgrep -f nginx
# 追踪该进程(需要 root 权限)
sudo strace -p $(pgrep -f nginx | head -1) -e trace=accept,read,write

9.4 strace 的性能影响#

Caution

strace 会显著降低被追踪程序的性能(通常慢 5-50 倍),因为每次系统调用都需要暂停进程、切换到 strace、再恢复执行。绝对不要在生产环境直接 strace 高负载服务,应使用 perfeBPF 等低开销工具(将在第 18 章详细介绍)。

十一、直接使用 syscall() 编程#

除了通过 glibc wrapper 间接调用,C 程序也可以直接使用 syscall() 函数发起系统调用:

#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <fcntl.h>
int main() {
// 方式 1: 通过 glibc wrapper
int fd1 = open("/etc/hostname", O_RDONLY);
// 方式 2: 直接使用 syscall()
int fd2 = syscall(__NR_openat, AT_FDCWD, "/etc/hostname", O_RDONLY);
if (fd1 >= 0) {
char buf[256];
ssize_t n;
// 用 syscall() 读取文件内容
n = syscall(__NR_read, fd1, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("hostname: %s", buf);
}
syscall(__NR_close, fd1);
}
if (fd2 >= 0) syscall(__NR_close, fd2);
return 0;
}

直接使用 syscall() 的场景:

  1. glibc 未封装的系统调用:如 getrandom()memfd_create() 等新系统调用,glibc 可能尚未提供 wrapper
  2. 需要绕过 glibc 的缓存行为:如 getpid() 在 glibc 中被缓存
  3. 教学与调试:直接控制系统调用号和参数
Note

日常编程中应优先使用 glibc wrapper,它提供了错误处理、线程安全、兼容性等保障。直接使用 syscall() 需要自行处理返回值和错误。

十二、动手实践#

以下练习按难度递增排列,帮助你将本章知识转化为实践能力。

实践 1:strace 基础(5 分钟)#

# 1. 统计 ls 命令的系统调用
strace -c ls /
# 2. 只追踪文件打开操作
strace -e trace=openat cat /etc/hostname
# 3. 追踪带耗时信息的系统调用
strace -T echo "hello"

思考题strace -c ls / 的输出中,哪个系统调用被调用次数最多?为什么?

实践 2:查看系统调用表(5 分钟)#

# 1. 查看内核符号表中的系统调用
sudo cat /proc/kallsyms | grep __x64_sys_ | head -30
# 2. 统计系统调用总数
sudo cat /proc/kallsyms | grep -c __x64_sys_
# 3. 查看系统调用号定义
grep __NR_ /usr/include/asm/unistd_64.h | head -20

实践 3:VDSO 探索(10 分钟)#

# 1. 查看 VDSO 在进程地址空间中的映射
cat /proc/self/maps | grep vdso
# 2. 阅读 VDSO 手册
man 7 vdso
# 3. 查看 VDSO 导出的函数
# 方法一:通过 /proc/self/maps 找到 VDSO 地址,然后 readelf
VDSO_ADDR=$(cat /proc/self/maps | grep vdso | head -1 | cut -d'-' -f1)
# 方法二:使用 ldd
ldd /bin/ls | grep vdso

实践 4:用 syscall() 编写程序(15 分钟)#

编写一个 C 程序,使用 syscall() 直接调用以下系统调用,不使用任何 glibc wrapper(除 printf 外):

  1. openat() — 打开 /etc/hostname
  2. read() — 读取内容
  3. write() — 输出到标准输出
  4. close() — 关闭文件
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>
int main() {
char buf[256];
long fd, n;
// openat(AT_FDCWD, "/etc/hostname", O_RDONLY)
fd = syscall(__NR_openat, -100, "/etc/hostname", 0);
if (fd < 0) {
printf("openat failed\n");
return 1;
}
// read(fd, buf, sizeof(buf))
n = syscall(__NR_read, fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
// write(STDOUT_FILENO, buf, n)
syscall(__NR_write, 1, buf, n);
}
// close(fd)
syscall(__NR_close, fd);
return 0;
}

实践 5:系统调用开销测量(20 分钟)#

编写程序对比以下三种方式获取时间的性能:

  1. syscall(__NR_clock_gettime, ...) — 直接系统调用
  2. clock_gettime() — glibc wrapper(可能走 VDSO)
  3. gettimeofday() — 传统接口(可能走 VDSO)

每种方式执行 100 万次,计算平均耗时。思考:为什么结果不同?

实践 6:strace 追踪实战(15 分钟)#

# 1. 追踪一个网络程序的 connect 调用
strace -e trace=connect curl -s https://example.com > /dev/null
# 2. 追踪进程创建
strace -e trace=clone3,execve bash -c 'ls'
# 3. 追踪内存映射
strace -e trace=mmap,munmap,brk ls
# 4. 附加到运行中的进程(需要 root)
sudo strace -p 1 -e trace=epoll_wait -c

本章小结#

系统调用是用户空间与内核空间之间的唯一合法通道。沿着一条系统调用的完整执行路径,从 glibc wrapper 出发,经过 syscall 指令的特权级切换,进入内核入口汇编,通过 sys_call_table 分派到具体的内核函数,最终原路返回用户态。

关键要点回顾:

  1. 本质:系统调用是通过 syscall 指令触发的受控特权级切换,不是普通函数调用
  2. 链路:glibc wrapper → syscall 指令 → entry_SYSCALL_64do_syscall_64()sys_call_table[]sys_xxx()
  3. 分派sys_call_table 是函数指针数组,以系统调用号为索引;系统调用号一旦分配永不回收
  4. 参数:x86_64 通过 rdi/rsi/rdx/r10/r8/r9 传递最多 6 个参数;第 4 个参数用 r10 而非 rcx
  5. 开销:系统调用比函数调用慢 50-500 倍,主要来自特权级切换、栈切换、TLB/缓存影响
  6. VDSO:通过将只读内核数据映射到用户空间,加速 gettimeofday() 等高频只读系统调用 5-10 倍
  7. ioctl:通过请求码机制实现”一个系统调用,无限种操作”,是设备驱动的万能扩展口
  8. strace:基于 ptrace() 的系统调用追踪工具,是调试和理解程序行为的利器

参考资料#

内核源码#

文件路径说明
arch/x86/entry/entry_64.Sx86_64 系统调用入口汇编
arch/x86/entry/common.cdo_syscall_64() C 函数
arch/x86/entry/syscall_64.csys_call_table 定义
arch/x86/entry/syscalls/syscall_64.tbl系统调用号分派表
include/linux/syscalls.h系统调用函数声明
include/uapi/asm-generic/unistd.h系统调用号宏定义
arch/x86/entry/vdso/VDSO 实现源码
arch/x86/entry/vdso/vdso.lds.SVDSO 链接脚本

手册页#

命令说明
man 2 intro系统调用概述
man 2 syscallsyscall() 库函数
man 2 ioctlioctl 系统调用
man 7 vdsoVDSO 机制详解
man 1 stracestrace 工具手册
man 2 ptraceptrace 系统调用

书籍与文档#

  • 《Linux 内核设计与实现》 第 5 章——系统调用的经典教材讲解
  • 《深入理解 Linux 内核》 第 10 章——系统调用的底层实现细节
  • 《Systems Performance》 第 3 章——系统调用开销与性能分析
  • Adding a New System Call — kernel.org 官方文档
  • x86_64 ABI — System V AMD64 ABI 规范

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

系统调用
https://blog.souloss.com/posts/linux-internals/system-calls/
作者
Souloss
发布于
2024-09-15
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时