Linux Kernel 4
系统调用(System Calls)
一、Linux 系统调用实现(System Call Implementation)
在宏观层面,系统调用是内核向用户程序提供的“服务”接口,其形式类似于库函数调用:
- 具有函数名、参数列表和返回值;
- 例如: r e a d ( ) read() read()、 w r i t e ( ) write() write()、 o p e n ( ) open() open()等
📌 但本质上,系统调用不是普通函数调用,而是特定的汇编指令(依赖于架构与内核实现),它们完成以下操作:
- 设置标识系统调用及其参数的信息;
- 触发从用户态到内核态的模式切换;
- 在内核中处理系统调用,并返回结果。
二、系统调用参数传递机制
在 Linux 中:
- 每个系统调用通过一个编号system call number来唯一标识;
- 系统调用参数的数量最多为 6 个;
- 所有参数及编号均通过寄存器传递,且每个参数为一个机器字大小(32 或 64 位)。
示例:32 位 x86 架构
内容 | 所用寄存器 |
---|---|
系统调用编号 | E A X EAX EAX |
参数 1 ~ 参数 6 | E B X , E C X , E D X , E S I , E D I , E B P EBX, ECX, EDX, ESI, EDI, EBP EBX,ECX,EDX,ESI,EDI,EBP |
三、系统调用与用户态接口(libc)
系统调用较底层,因此标准库(如 l i b c libc libc)提供了包装函数,使用户应用更容易使用:
例如:
// 实际调用 read 系统调用
ssize_t read(int fd, void *buf, size_t count);
该函数最终会触发底层汇编,向内核请求服务。
三、用户态到内核态的切换流程
当用户程序调用系统调用时:
-
当前执行流会被中断;
-
跳转至内核预定义的系统调用入口点(entry point);
-
执行流程如下:
用户态执行
↓
系统调用触发(如 int 0x80 或 syscall 指令)
↓
切换至内核态
↓
保存用户寄存器状态(包括系统调用编号和参数)
↓
调用系统调用分发器(dispatcher)
↓
进入对应的内核服务函数
💡 这种切换方式类似于中断/异常机制(某些架构中确实是通过异常触发)。
📌 栈的切换
在用户态到内核态的切换过程中:
-
栈也会从用户栈切换到内核栈(kernel stack);
-
这是为了保护内核空间,避免用户栈被恶意或错误修改;
-
内核为每个进程准备了单独的内核栈,见中断与异常章节。
系统调用流程分析图(以 dup2 为例)
如图, Linux 32 位架构下,用户态程序通过 libc
触发 dup2()
系统调用后,如何通过 int $0x80
中断进入内核,并最终完成系统调用处理
1️⃣ 应用层(Application)
用户程序调用 $dup2(oldfd, newfd)$
,这是标准的 POSIX API,用于复制文件描述符。
2️⃣ C 函数库(libc)
在标准库中(如 glibc),dup2
被封装为一个普通的 C 函数。在汇编层面,做了如下工作:
C7592 movl 0x8(%esp),%ecx ; 将第2个参数 newfd 放入寄存器 %ecx
C7596 movl 0x4(%esp),%ebx ; 将第1个参数 oldfd 放入寄存器 %ebx
C759a movl $0x3f,%eax ; 设置系统调用号 eax = 0x3f(即 dup2)
C759f int $0x80 ; 执行软中断,触发系统调用
🧠 int $0x80 是 32 位 Linux 中用于触发系统调用的中断号。
3️⃣ 内核入口点(Kernel Entry)
内核中断处理程序的入口函数是 entry_INT80_32:
ENTRY(entry_INT80_32)
ASM_CLAC ; 清除 AC 标志位,防止用户空间访问 SMAP 内存
pushl %eax ; 保存 eax
SAVE_ALL pt_regs_ax $ENOSYS ; 保存全部寄存器到内核栈(构造 pt_regs)
movl %esp,%eax ; 将当前栈顶(pt_regs 结构)传给 eax
call do_int80_syscall_32 ; 跳转至系统调用分发函数
RESTORE_REGS 4 ; 恢复寄存器,跳过 orig_eax 和 error_code
INTERRUPT_RETURN ; 从中断返回,回到用户态
🧠 在进入内核时,内核使用专用的内核栈,并通过 SAVE_ALL 宏保存用户态寄存器状态。
系统调用调度器(System Call Dispatcher)
🎯 系统调用调度器的主要任务是:
- 检查系统调用号是否合法;
- 调用与该系统调用号对应的内核函数(即执行实际的内核服务逻辑)。
🔧 内核实现分析(int 0 x 80 0x80 0x80 调用路径)
以下为处理 $int $0x80$
的简化代码流程:
1️⃣ 内核入口函数:do_int80_syscall_32
__visible void do_int80_syscall_32(struct pt_regs *regs)
{
enter_from_user_mode(); // 切换用户态到内核态的准备
local_irq_enable(); // 开启中断
do_syscall_32_irqs_on(regs); // 执行系统调用调度器
}
2️⃣ 简化调度器函数:do_syscall_32_irqs_on
static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)
{
unsigned int nr = regs->orig_ax; // 获取系统调用号
if (nr < IA32_NR_syscalls) {
regs->ax = ia32_sys_call_table[nr](regs->bx, regs->cx,
regs->dx, regs->si,
regs->di, regs->bp);
}
syscall_return_slowpath(regs); // 返回用户态处理(含错误检查)
}
系统调用表(System Call Table)
系统调用表是系统调用调度器(dispatcher)使用的核心数据结构,用于将系统调用编号映射到对应的内核函数实现。
在 Linux 32 位(x86)内核中,这张表定义如下:
#define __SYSCALL_I386(nr, sym, qual) [nr] = sym,
const sys_call_ptr_t ia32_sys_call_table[] = {
[0 ... __NR_syscall_compat_max] = &sys_ni_syscall,
#include <asm/syscalls_32.h>
};
上述代码中:
ia32_sys_call_table[] 是一个函数指针数组;
-
每个数组下标 nr 对应一个系统调用号;
-
数组中初始用 sys_ni_syscall 填充所有项,表示默认“不支持该调用”;
-
然后通过宏 __SYSCALL_I386(nr, sym, qual) 替换特定编号为具体函数,如:
__SYSCALL_I386(0, sys_restart_syscall)
__SYSCALL_I386(1, sys_exit)
__SYSCALL_I386(2, sys_fork)
__SYSCALL_I386(3, sys_read)
__SYSCALL_I386(4, sys_write)
当用户程序执行如 $int 0 x 80 0x80 0x80 并传入系统调用号 e a x = 3 eax=3 eax=3(即 read()),内核就会通过:
ia32_sys_call_table[3](arg1, arg2, ..., arg6);
来调用 s y s _ r e a d sys\_read sys_read 函数。
另外,根据架构配置不同,同一个调用号可能映射到不同函数。例如:
#ifdef CONFIG_X86_32
__SYSCALL_I386(5, sys_open)
#else
__SYSCALL_I386(5, compat_sys_open)
#endif
系统调用参数处理(System Call Parameters Handling)
系统调用参数的处理是一个非常棘手且关键的问题。由于这些参数由用户空间传入,内核不能假设它们是合法的,因此必须进行严格验证,尤其是指针类型的参数。
📌 指针参数的特殊检查要求:
- 绝不能允许指针指向内核空间(kernel space)
- 必须检测无效指针(如未映射地址、只读内存等)
由于系统调用在内核态执行,拥有对整个内核地址空间的访问权限,因此如果未正确检查用户提供的指针,可能造成严重的安全问题或系统崩溃。
🚨 示例:read/write 系统调用中的漏洞风险
- 如果用户向
$write$
传入一个指向内核空间的指针,系统会把内核数据写到用户可读的文件中,从而泄露内核信息; - 如果用户向
$read$
传入内核空间指针,读取的数据将写入该区域,可能破坏内核内存。
🚧 无效指针的危害
- 用户传入的指针如果指向:
- 未映射内存
- 只读页面但用于写操作
- 空指针
都可能导致内核崩溃或异常行为。
✅ 两种处理策略:
- 主动检查法:在使用指针前,对其进行地址空间范围验证(是否在用户空间)
- 延迟触发法:不主动检查,而是依赖 MMU 产生页错误(page fault),然后通过异常处理器判断是否非法
🧠 页错误处理机制(Fault Handling)
尽管延迟触发法实现更复杂,但 Linux 更倾向于使用该方式,并配合以下机制提升健壮性和安全性:
- 所有访问用户空间的函数都使用专门的 API,如
$copy_to_user()$
、$copy_from_user()$
; - 内核会记录访问用户空间的精确指令地址,保存在异常表exception table中;
- 当发生页错误时,内核检查该地址是否存在于异常表中,决定是否是用户空间访问导致的错误;
- 如果命中异常表,则认为是“受控”的合法页错误,否则可能是内核 bug。
📊 成本对比分析
情况 | 主动检查法(地址范围) | 延迟触发法(异常表) |
---|---|---|
有效地址 | 地址空间查找 | 成本几乎为 0 |
无效地址 | 地址空间查找 | 异常表查找(略慢) |
✅ 为什么 Linux 采用延迟触发策略?
虽然异常处理路径略复杂,但:
- 在常见路径中(即参数有效时)性能开销非常低;
- 减少每次调用前的地址空间遍历;
- 更加通用、集中、可维护,适用于多种系统调用场景;
- 因此是 Linux 内核中处理用户空间指针的首选策略。
虚拟动态共享对象(VDSO:Virtual Dynamic Shared Object)
VDSO 机制的设计初衷是优化系统调用的性能,同时避免让 $libc$
必须同时考虑 CPU 功能与内核版本的匹配问题。
📌 背景:x86 系统调用的两种方式
在 $x86$
架构中,有两种主要的系统调用触发方式:
$int\ 0x80$
:传统方法,兼容性强,但性能较低;$sysenter$
:新方法,性能显著更好,但仅在 Pentium II 之后的处理器与$Linux\ kernel\ >2.6$
中才可用。
为了根据实际情况动态选择调用方式,同时不影响 $libc$
的实现与维护,Linux 引入了 VDSO 机制。
⚙️ VDSO 的工作原理
- 内核在用户地址空间的高地址区域映射一块特殊内存,该内存区域:
- 由内核动态生成;
- 以 ELF 格式组织;
- 类似共享库(shared object),但不依赖磁盘文件;
$libc$
在运行时会搜索 VDSO 页面,如果找到:- 将使用其中的汇编指令流来执行系统调用;
- 从而避开
$int\ 0x80$
等传统中断路径,提高调用效率。
🚀 VDSO 的扩展:虚拟系统调用(vsyscall)
VDSO 的一个重要演进是虚拟系统调用机制(vsyscall),它允许某些“系统调用”完全在用户态执行,无需切换到内核态。
✅ vsyscall 的特性:
- 是 VDSO 的一部分;
- 访问的数据来自 VDSO 页面中的:
- 静态数据区(如
$getpid()$
) - 动态更新区域(如
$gettimeofday()$
、$time()$
),这些区域由内核维护一个独立的读写映射页。
- 静态数据区(如
🧠 举例说明:
$getpid()$
:- 返回当前进程的 PID;
- 该值在进程创建时就确定,不变,因此可通过 VDSO 提供的只读静态值直接获取;
$gettimeofday()$
或$time()$
:- 由于涉及到实时信息,数据由内核定期更新;
- 用户程序读取的是映射到 VDSO 的“共享内存”区域,效率远高于真正发起系统调用。
从系统调用中访问用户空间(Accessing User Space from System Calls)
在内核中访问用户空间是非常敏感的操作,必须通过专门的 API 函数来完成,如: g e t _ u s e r ( ) get\_user() get_user()、 p u t _ u s e r ( ) put\_user() put_user()、 c o p y _ f r o m _ u s e r ( ) copy\_from\_user() copy_from_user()、 c o p y _ t o _ u s e r ( ) copy\_to\_user() copy_to_user()。这些 API 会检查用户传入的指针是否落在用户空间,并在指针无效时优雅失败(返回非零值),从而避免内核崩溃。
例如:
// 正确做法:若 user_ptr 非法则返回 -EFAULT
if (copy_from_user(&kernel_buffer, user_ptr, size))
return -EFAULT;
// 错误做法:若 user_ptr 非法直接崩溃内核
memcpy(&kernel_buffer, user_ptr, size);
🧱 get_user 简要实现(x86 架构)
get_user 使用内联汇编实现,根据变量大小自动选择调用 _ g e t _ u s e r _ 1 \_get\_user\_1 _get_user_1、 _ _ g e t _ u s e r _ 2 \_\_get\_user\_2 __get_user_2 或 _ _ g e t _ u s e r _ 4 \_\_get\_user\_4 __get_user_4:
#define get_user(x, ptr) ({ \
int __ret_gu; \
register __inttype(*(ptr)) __val_gu asm("%"_ASM_DX); \
__chk_user_ptr(ptr); \
might_fault(); \
asm volatile("call __get_user_%P4" \
: "=a" (__ret_gu), "=r" (__val_gu), \
ASM_CALL_CONSTRAINT \
: "0" (ptr), "i" (sizeof(*(ptr)))); \
(x) = (__force __typeof__(*(ptr))) __val_gu; \
__builtin_expect(__ret_gu, 0); \
})
其等效伪代码如下:
movl ptr, %eax // 将用户指针加载到 eax
call __get_user_1 // 调用访问函数
movl %edx, x // 结果保存至变量 x
movl %eax, result // 保存返回码
🧠 __get_user_1 实现逻辑(x86)
ENTRY(__get_user_1)
mov PER_CPU_VAR(current_task), %edx
cmp TASK_addr_limit(%edx), %eax
jae bad_get_user
ASM_STAC
1: movzbl (%eax), %edx
xor %eax, %eax
ASM_CLAC
ret
bad_get_user:
xor %edx, %edx
mov $(-EFAULT), %eax
ASM_CLAC
ret
ENDPROC(__get_user_1)
解释如下:
-
检查当前进程的 a d d r _ l i m i t addr\_limit addr_limit 是否大于用户传入指针地址 e a x eax eax,防止指针越界访问内核空间;
-
调用 A S M _ S T A C ASM\_STAC ASM_STAC(开启用户空间访问权限,解除 SMAP 限制);
-
使用 m o v z b l movzbl movzbl 指令从用户空间读取数据;
-
若读取成功,返回值存于 e d x edx edx,将 e a x eax eax 置零表示成功;
-
若非法访问则跳转到 b a d _ g e t _ u s e r bad\_get\_user bad_get_user,返回 − E F A U L T -EFAULT −EFAULT 表示出错
🧾 异常表机制(Exception Table)
对于访问用户空间的指令,内核通过“异常表”机制保证安全:
# define _ASM_EXTABLE_HANDLE(from, to, handler) \
.pushsection "__ex_table","a"; \
.balign 4; \
.long (from) - .; \
.long (to) - .; \
.long (handler) - .; \
.popsection
-
每个访问用户空间的指令地址(如 movzbl)被标记为异常表项的 from;
-
如果访问失败,将跳转至 to 地址并调用 handler;
-
所有异常表项集中保存在 _ _ e x _ t a b l e \_\_ex\_table __ex_table section;
-
链接脚本通过 _ _ s t a r t _ _ _ e x _ t a b l e \_\_start\_\_\_ex\_table __start___ex_table 与 _ _ s t o p _ _ _ e x t a b l e \_\_stop\_\_\_ex_table __stop___extable 定位整张表。
🛠️ 异常处理函数示例
bool ex_handler_default(const struct exception_table_entry *fixup,
struct pt_regs *regs, int trapnr)
{
regs->ip = ex_fixup_addr(fixup); // 设置跳转地址
return true;
}
当用户空间访问失败触发页错误时:
-
f i x u p _ e x c e p t i o n fixup\_exception fixup_exception 函数会查询 _ _ e x _ t a b l e \_\_ex\_table __ex_table;
-
找到对应项后跳转至 b a d _ g e t _ u s e r bad\_get\_user bad_get_user;
-
返回 − E F A U L T -EFAULT −EFAULT,避免内核崩溃。