第8章:系统调用(System Calls)
一、导读
8.1 本章学习目标
第8章深入讲解系统调用的实现机制。系统调用是用户程序请求内核服务的唯一合法途径——从文件读写到进程创建,从网络通信到内存分配,所有需要内核介入的操作都通过系统调用完成。理解系统调用的实现细节,对于编写高效的系统程序、诊断性能问题、以及理解内核安全模型都至关重要。
本章的核心学习目标包括:
- 掌握系统调用的完整执行路径:从用户空间到内核空间再返回的每一步
- 理解系统调用的参数传递机制:参数如何通过寄存器传递,如何验证用户空间指针
- 了解系统调用表的结构:内核如何根据系统调用号找到处理函数
- 掌握vDSO的优化原理:某些系统调用如何避免内核态切换
- 理解系统调用的安全边界:内核如何验证用户空间的参数
- 了解系统调用的性能开销:上下文切换的代价和优化方法
8.2 为什么本章如此重要
系统调用是用户程序和内核之间的桥梁,也是安全边界的守护者。每一次系统调用都涉及特权级的切换、参数的验证和权限的检查。理解这些机制,有助于编写更安全、更高效的程序。
性能方面,系统调用的开销不可忽视。每次系统调用需要数百个时钟周期的开销,频繁的系统调用会显著降低程序性能。vDSO等优化技术通过避免内核态切换来加速特定的系统调用,理解这些优化有助于编写高性能代码。
安全方面,系统调用是攻击者从用户空间进入内核的主要入口。内核必须严格验证所有来自用户空间的参数,防止恶意输入导致安全漏洞。理解系统调用的安全边界,对于编写安全的系统程序和理解内核安全模型都很重要。
8.3 核心问题引导
在阅读本章时,请带着以下问题思考:
当程序调用read(fd, buf, 100)时,从用户态到内核态经历了哪些步骤?
内核如何验证用户传入的buf指针是合法的?如果传入一个无效指针会怎样?
为什么gettimeofday()系统调用比普通系统调用快得多?vDSO是如何实现的?
系统调用号是如何定义的?为什么系统调用的编号不能随意更改?
为什么内核在复制用户空间数据时需要使用copy_from_user而不是直接指针访问?
一次系统调用的开销大约是多少?与普通的函数调用相比如何?
二、核心概念详解
2.1 系统调用的触发
在x86-64 Linux中,系统调用通过syscall指令触发。这条指令将CPU从Ring 3(用户态)切换到Ring 0(内核态),并跳转到预定义的内核入口。
/* glibc 中的系统调用封装(简化) */
/* 以 write 为例 */
/* 用户代码调用 */
write(fd, buf, count);
/* glibc 封装(汇编) */
/*
* SYSCALL_DEFINE 宏展开:
*
* mov $1, %rax # 系统调用号 1 = write
* mov fd, %rdi # 第1个参数
* mov buf, %rsi # 第2个参数
* mov count, %rdx # 第3个参数
* syscall # 触发系统调用
*
* syscall 指令的效果:
* - RIP → RCX # 保存返回地址
* - RFLAGS → R11 # 保存标志寄存器
* - RIP ← MSR_LSTAR # 跳转到内核入口
* - CS.SEL ← MSR_STAR # 切换到内核代码段
* - CPL ← 0 # 切换到 Ring 0
*//* 直接使用 syscall() 函数 */
#include <sys/syscall.h>
#include <unistd.h>
#include <stdio.h>
int main() {
/* 直接调用 write 系统调用 */
long ret = syscall(SYS_write, STDOUT_FILENO,
"Hello via syscall!\n", 19);
printf("write returned: %ld\n", ret);
/* 直接调用 getpid */
pid_t pid = syscall(SYS_getpid);
printf("PID: %d\n", pid);
/* 直接调用 gettid */
pid_t tid = syscall(SYS_gettid);
printf("TID: %d\n", tid);
return 0;
}2.2 系统调用入口处理
当syscall指令执行后,CPU跳转到内核的入口处理函数。入口处理需要保存用户态的上下文,然后分发到具体的系统调用处理函数。
/* 系统调用入口(x86-64,简化) */
/* arch/x86/entry/entry_64.c */
/*
* 内核入口地址存储在 MSR_LSTAR 寄存器中
* 在系统初始化时设置:
* wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
*/
/* entry_SYSCALL_64 的主要工作:
*
* 1. 保存用户态寄存器到内核栈
* - push %rcx (返回地址)
* - push %r11 (RFLAGS)
* - push 所有通用寄存器
*
* 2. 切换到内核栈
* - 从 per-cpu 变量获取内核栈指针
* - mov %rsp, PER_CPU_VAR(cpu_current_top_of_stack)
*
* 3. 保存线程信息
* - SWAPGS 指令切换 GS 基址
* - 获取当前进程的 task_struct
*
* 4. 系统调用分发
* - cmp $NR_syscalls, %eax
* - ja bad_syscall
* - call *sys_call_table(, %rax, 8)
*
* 5. 返回用户态
* - 恢复寄存器
* - mov %rax, %rdi (返回值)
* - sysretq
*/
/* 系统调用表 */
/* arch/x86/entry/syscalls/syscall_64.tbl */
/*
* 编号 名称 入口
* 0 read sys_read
* 1 write sys_write
* 2 open sys_open
* 3 close sys_close
* 4 stat sys_stat
* 5 fstat sys_fstat
* 9 mmap sys_mmap
* 10 mprotect sys_mprotect
* 11 munmap sys_munmap
* 12 brk sys_brk
* 56 clone sys_clone
* 57 fork sys_fork
* 59 execve sys_execve
* 60 exit sys_exit
* 231 exit_group sys_exit_group
* ...
*/2.3 系统调用处理函数
每个系统调用都有一个对应的处理函数。内核使用SYSCALL_DEFINE宏定义这些函数,宏处理参数验证和类型转换。
/* 系统调用处理函数的定义 */
/* include/linux/syscalls.h */
/* SYSCALL_DEFINE 宏展开 */
/* 以 sys_read 为例 */
/* 定义方式 */
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf,
size_t, count) {
struct fd f = fdget_pos(fd);
ssize_t ret;
if (!f.file)
return -EBADF;
ret = vfs_read(f.file, buf, count, &f.file->f_pos);
fdput_pos(f);
return ret;
}
/* 宏展开后(简化): */
/*
* asmlinkage long sys_read(unsigned int fd,
* char __user *buf,
* size_t count) {
* // 参数已经在寄存器中:
* // rdi = fd, rsi = buf, rdx = count
*
* struct fd f = fdget_pos(fd);
* if (!f.file)
* return -EBADF;
*
* ret = vfs_read(f.file, buf, count, &f.file->f_pos);
* fdput_pos(f);
* return ret;
* }
*/
/* 参数验证示例 */
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count) {
/* 验证 buf 指针的合法性 */
if (!access_ok(buf, count))
return -EFAULT;
/* 从用户空间复制数据到内核空间 */
char kernel_buf[4096];
if (copy_from_user(kernel_buf, buf, min(count, sizeof(kernel_buf))))
return -EFAULT;
/* 处理写入... */
return count;
}2.4 用户空间数据访问
内核不能直接访问用户空间的指针,因为用户空间指针可能无效(指向未映射的内存、指向内核空间等)。内核使用专门的函数安全地访问用户空间数据。
/* 用户空间数据访问函数 */
/* include/linux/uaccess.h */
/* 检查指针是否可访问 */
static inline int access_ok(const void __user *addr, unsigned long size) {
/* 检查地址范围是否在用户空间 */
/* 在 x86-64 上,用户空间是 0 ~ 0x7fff_ffff_ffff */
return likely(range_ok(addr, size));
}
/* 从用户空间复制数据到内核空间 */
unsigned long copy_from_user(void *to, const void __user *from,
unsigned long n) {
/* 如果复制失败(如地址无效),返回未复制的字节数 */
/* 成功返回0 */
unsigned long res = n;
if (access_ok(from, n)) {
res = __copy_from_user(to, from, n);
}
if (unlikely(res))
memset(to + (n - res), 0, res);
return res;
}
/* 从内核空间复制数据到用户空间 */
unsigned long copy_to_user(void __user *to, const void *from,
unsigned long n) {
if (access_ok(to, n)) {
return __copy_to_user(to, from, n);
}
return n;
}
/* 安全地读取用户空间的一个值 */
#define get_user(x, ptr) \
__get_user_check(x, ptr, sizeof(*(ptr)))
/* 安全地写入用户空间的一个值 */
#define put_user(x, ptr) \
__put_user_check(x, ptr, sizeof(*(ptr)))
/*
* 为什么不能直接访问用户空间指针?
*
* 1. 指针可能无效(指向未映射的内存)
* → 直接访问会触发缺页异常,内核Oops
*
* 2. 指针可能指向内核空间(恶意攻击)
* → 直接访问会泄露或破坏内核数据
*
* 3. 页面可能被换出(swap out)
* → 需要处理缺页异常
*
* copy_from_user/copy_to_user 内部处理了这些情况:
* - 检查地址范围
* - 使用特殊的异常表处理缺页异常
* - 如果出错,返回错误码而不是崩溃
*/2.5 vDSO(Virtual Dynamically Shared Object)
vDSO是Linux内核提供的一种优化机制。某些系统调用(如gettimeofday)不需要真正的内核态切换,因为内核可以将所需数据映射到每个进程的用户空间。vDSO通过共享库的方式将这些"系统调用"实现为用户空间函数。
/* vDSO 的工作原理 */
/*
* 内核在启动时将 vDSO 页面映射到每个进程的用户空间
*
* /proc/self/maps 中可以看到:
* 7ffd123fe000-7ffd12400000 r-xp 00000000 00:00 0 [vdso]
*
* vDSO 中包含的函数:
* - clock_gettime()
* - gettimeofday()
* - time()
* - getcpu()
*
* 这些函数读取内核映射到用户空间的数据页面
* (包含当前时间、CPU信息等),无需切换到内核态
*/
/* 查看 vDSO 的映射 */
/* cat /proc/self/maps | grep vdso */
/* glibc 自动使用 vDSO */
/* 当调用 gettimeofday() 时,glibc 实际调用的是 vDSO 中的实现 */
/* 验证 vDSO 的使用 */
#include <sys/time.h>
#include <stdio.h>
int main() {
struct timeval tv;
/* 这个调用会使用 vDSO,不会切换到内核态 */
gettimeofday(&tv, NULL);
printf("Time: %ld.%06ld\n", tv.tv_sec, tv.tv_usec);
/* 使用 strace 验证 */
/* strace ./program | grep gettimeofday */
/* 不会看到 gettimeofday 系统调用,因为它在用户空间完成 */
return 0;
}# 验证 vDSO 的效果
# 使用 strace 跟踪 gettimeofday
strace -e trace=gettimeofday ./program
# 输出中没有 gettimeofday 系统调用 → 说明使用了 vDSO
# 对比一个需要真正内核态的系统调用
strace -e trace=read ./program
# read(0, ...) = ... ← 可以看到 read 系统调用
# 查看 vDSO 导出的符号
vdso = $(cat /proc/self/maps | grep vdso | awk '{print $1}')
# 或使用 readelf
readelf -s /proc/self/exe | grep vdso2.6 系统调用的性能
系统调用的开销主要包括:
系统调用开销分解:
1. 用户态 → 内核态切换:~100-200 时钟周期
- syscall 指令执行
- 寄存器保存
- 栈切换
- GS基址切换(SWAPGS)
2. 参数验证:~50-100 时钟周期
- access_ok 检查
- copy_from_user/copy_to_user
3. 系统调用处理:变化很大
- 简单的(getpid):~50 周期
- 复杂的(fork):~数千周期
- 涉及I/O的:~数万-数百万周期
4. 内核态 → 用户态切换:~100-200 时钟周期
- 寄存器恢复
- sysret 指令执行
总计:简单的系统调用约 300-500 时钟周期
复杂的系统调用可能数千到数百万周期/* 系统调用性能测试 */
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>
#define ITERATIONS 10000000
int main() {
struct timespec start, end;
double elapsed;
pid_t pid;
clock_gettime(CLOCK_MONOTONIC, &start);
for (int i = 0; i < ITERATIONS; i++) {
pid = getpid(); /* 最简单的系统调用之一 */
}
clock_gettime(CLOCK_MONOTONIC, &end);
elapsed = (end.tv_sec - start.tv_sec) +
(end.tv_nsec - start.tv_nsec) / 1e9;
printf("%d getpid() calls in %.3f seconds\n", ITERATIONS, elapsed);
printf("Average: %.1f ns per call\n",
elapsed * 1e9 / ITERATIONS);
printf("Estimated cycles: %.1f\n",
elapsed * 3e9 / ITERATIONS); /* 假设 3GHz CPU */
/* 对比:普通函数调用 */
clock_gettime(CLOCK_MONOTONIC, &start);
for (int i = 0; i < ITERATIONS; i++) {
pid = syscall(SYS_getpid); /* 直接 syscall */
}
clock_gettime(CLOCK_MONOTONIC, &end);
elapsed = (end.tv_sec - start.tv_sec) +
(end.tv_nsec - start.tv_nsec) / 1e9;
printf("\nDirect syscall: %.1f ns per call\n",
elapsed * 1e9 / ITERATIONS);
return 0;
}三、实战案例
3.1 使用 strace 分析系统调用
# 跟踪 ls 命令的所有系统调用
strace ls 2>&1 | head -30
# 统计系统调用的次数和时间
strace -c ls /usr/lib
# 只跟踪文件相关的系统调用
strace -e trace=file ls
# 只跟踪网络相关的系统调用
strace -e trace=network curl -s http://example.com
# 跟踪子进程
strace -f make
# 按时间排序
strace -T ls
# 输出到文件
strace -o trace.log ./program3.2 编写系统调用性能对比程序
/* syscall_overhead.c */
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#define ITERATIONS 100000
static inline void benchmark(const char *name, void (*func)(void)) {
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
for (int i = 0; i < ITERATIONS; i++)
func();
clock_gettime(CLOCK_MONOTONIC, &end);
double elapsed = (end.tv_sec - start.tv_sec) +
(end.tv_nsec - start.tv_nsec) / 1e9;
printf("%-30s: %8.1f ns/call\n", name,
elapsed * 1e9 / ITERATIONS);
}
/* 空函数(基线) */
static void empty_func(void) { }
/* getpid 系统调用 */
static void do_getpid(void) { getpid(); }
/* getppid 系统调用 */
static void do_getppid(void) { getppid(); }
/* time 系统调用 */
static void do_time(void) { time(NULL); }
/* gettimeofday(可能使用 vDSO) */
static void do_gettimeofday(void) {
struct timeval tv;
gettimeofday(&tv, NULL);
}
/* write 系统调用 */
static int pipefd[2];
static void do_write(void) {
write(pipefd[1], "x", 1);
}
/* read 系统调用 */
static void do_read(void) {
char c;
read(pipefd[0], &c, 1);
}
int main() {
pipe(pipefd);
printf("System call overhead benchmark (%d iterations):\n\n",
ITERATIONS);
benchmark("Empty function", empty_func);
benchmark("getpid()", do_getpid);
benchmark("getppid()", do_getppid);
benchmark("time(NULL)", do_time);
benchmark("gettimeofday()", do_gettimeofday);
/* 先写入管道 */
for (int i = 0; i < ITERATIONS; i++)
write(pipefd[1], "x", 1);
benchmark("read() from pipe", do_read);
close(pipefd[0]);
close(pipefd[1]);
return 0;
}3.3 分析 vDSO 的使用
# 查看 vDSO 映射
cat /proc/self/maps | grep vdso
# 7ffc123fe000-7ffc12400000 r-xp 00000000 00:00 0 [vdso]
# 使用 strace 验证哪些系统调用使用了 vDSO
strace -e trace=gettimeofday,clock_gettime,time,getcpu bash -c "true" 2>&1
# 如果输出中没有这些系统调用,说明使用了 vDSO
# 通过 LD_SHOW_AUXV 查看 vDSO 信息
LD_SHOW_AUXV=1 /bin/true 2>&1 | grep VDSO
# AT_SYSINFO_EHDR: 0x7ffc123ff000
# 使用 objdump 反汇编 vDSO
cp /proc/self/mem /tmp/vdso_dump 2>/dev/null || true
# 或直接使用 vDSO 文件
objdump -d /lib/modules/$(uname -r)/vdso/vdso.so 2>/dev/null | head -50四、本章小结
本章深入讲解了Linux系统调用的实现机制。系统调用通过syscall指令触发,CPU从Ring 3切换到Ring 0,内核保存用户态上下文后根据系统调用号在系统调用表中查找处理函数。内核使用copy_from_user/copy_to_user安全地访问用户空间数据,防止恶意输入导致安全漏洞。
vDSO是一种重要的优化机制,它将某些不需要真正内核态切换的系统调用(如gettimeofday)实现为用户空间函数,通过内核映射到用户空间的数据页面获取信息,避免了昂贵的特权级切换。
系统调用的开销主要包括上下文切换(约300-500时钟周期)、参数验证和实际处理。对于频繁调用的简单系统调用,使用vDSO可以显著降低开销。理解系统调用的性能特征,有助于编写更高效的系统程序。
五、思考与练习
思考题
如果一个程序频繁调用gettimeofday(),但不使用vDSO,对系统性能会有什么影响?
为什么内核不能直接解引用用户空间指针?在什么情况下用户空间指针可能无效?
系统调用表和中断描述符表(IDT)有什么区别?它们分别在什么时候使用?
为什么系统调用的编号一旦定义就不能更改?如果需要添加新的系统调用,应该怎么做?
编程练习
编写一个程序,使用strace统计一个常用命令(如gcc编译一个小程序)的系统调用次数和类型分布。
编写一个程序,对比使用vDSO和不使用vDSO(通过syscall()直接调用)的gettimeofday()性能差异。
编写一个简单的系统调用封装库,实现my_read、my_write、my_open、my_close函数,直接使用syscall()而不依赖glibc。