《Linux-0.12 源码篇》- 04 系统调用机制
字数 5987,阅读大约需 30 分钟
第四章:系统调用机制
系统调用是操作系统提供给用户程序的接口,是用户态和内核态之间的桥梁。用户程序无法直接访问硬件或内核数据,必须通过系统调用请求内核代理完成特权操作。Linux 0.12实现了一套优雅的系统调用机制:用户程序通过int 0x80中断陷入内核,内核根据系统调用号查表执行相应的内核函数,然后将结果返回用户态。这套机制保证了操作系统的安全性,同时也为用户程序提供了强大而统一的接口。
4.1 系统调用的本质
系统调用本质上是一种特殊的软件中断。在x86架构下,Linux使用int 0x80指令作为系统调用的统一入口。用户程序在调用系统函数(如read、write、fork等)时,实际上是执行了一段内嵌汇编代码,这段代码将系统调用号放入eax寄存器,将参数放入ebx、ecx、edx寄存器,然后执行int 0x80指令。
int 0x80指令触发CPU的中断机制,CPU自动完成以下操作:首先查找IDT(中断描述符表)的第0x80项,获取system_call函数的地址和段选择符;然后检查特权级,发现当前在用户态(CPL=3),而system_call在内核态(DPL=0),需要切换特权级;于是CPU自动将用户态的ss和esp压入内核栈,然后将eflags、cs、eip压栈,最后切换到内核态并跳转到system_call执行。
这个过程就像公民(用户程序)向政府(内核)申请办事(系统调用),必须通过指定的窗口(int 0x80),提交申请表(系统调用号和参数),然后等待政府工作人员(内核函数)办理并返回结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 用户程序调用 read(fd, buf, count) │ ▼ _syscall3 宏展开 │ ┌──────────┼──────────┐ │ │ │ ▼ ▼ ▼ eax = __NR_read ebx = fd ecx = buf (系统调用号) (第1个参数) (第2个参数) │ ▼ edx = count (第3个参数) │ └──────────┼──────────┘ │ ▼ 执行 int 0x80 指令 │ ▼ CPU 查找 IDT 第 0x80 项 (Interrupt Descriptor Table) │ ▼ 检测特权级:CPL=3 (用户态) DPL=0 (内核态) │ ▼ 切换到内核栈 │ ▼ 压入上下文到内核栈 (从高到低) │ ┌─────────┼─────────┐ │ │ │ ▼ ▼ ▼ ss esp eflags (用户栈段) (用户栈指针) (标志寄存器) ┌─────────┼──────────┐ │ │ │ ▼ ▼ │ cs eip │ (用户代码段) (返回地址) │ ▼ 跳转到 system_call
4.2 系统调用的完整流程
system_call是所有系统调用的汇编入口函数,定义在kernel/system_call.s中。为了真正看懂这段关键代码,我们有必要先通读它的实现,再结合文字说明理解每一条指令的作用。
system_call汇编代码逐行注释
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 # kernel/system_call.s - Linux 0.12 系统调用入口.globl system_call, sys_call_table, nr_system_calls.align 2system_call: push %ds # 保存原ds段寄存器 push %es # 保存原es段寄存器 push %fs # 保存原fs段寄存器 pushl %eax # 保存eax(系统调用号) pushl %ecx # 保存ecx(第2个参数) pushl %edx # 保存edx(第3个参数) movl $0x10,%edx # 0x10 = GDT中内核数据段选择符 mov %dx,%ds # ds = 内核数据段 mov %dx,%es # es = 内核数据段 movl $0x17,%edx # 0x17 = LDT中用户数据段选择符 mov %dx,%fs # fs = 用户数据段(用于访问用户空间) cmpl $nr_system_calls,%eax # eax中是系统调用号 ja bad_sys_call # 如果eax >= nr_system_calls,跳转到错误处理 call *sys_call_table(,%eax,4) # 调用sys_call_table[eax] # 也就是对应的sys_xxx内核函数 pushl %eax # 将返回值压栈,稍后再恢复到eax movl current,%eax # eax = current,指向当前进程的task_struct cmpl $0,state(%eax) # 检查当前进程state是否为TASK_RUNNING jne reschedule # 如果不是就绪态,则需要重新调度 cmpl $0,counter(%eax) # 检查时间片counter是否为0 je reschedule # 时间片用完也需要重新调度restore: movl current,%eax # 再次获取current指针ret_from_sys_call: # 处理信号之前,先恢复返回值 popl %eax # 恢复sys_xxx的返回值到eax # 这里(在真正代码中)会检查是否有未处理信号 # 并在需要时调用do_signal(),本章后续详细分析 popl %edx # 恢复edx popl %ecx # 恢复ecx popl %ebx # 恢复ebx pop %fs # 恢复fs pop %es # 恢复es pop %ds # 恢复ds iret # 从中断返回,恢复eip、cs、eflags、esp、ss # 完成从内核态到用户态的切换reschedule: call schedule # 调用调度器选出下一个要运行的进程 jmp restore # 调度完成后回到restore标签bad_sys_call: movl $-ENOSYS,%eax # 系统调用号非法,返回-ENOSYS jmp ret_from_sys_call # 走统一的返回路径
说明:上面的代码基于Linux 0.12源码略作整理,省略了部分与调试相关的微小细节,重点突出系统调用路径中的关键步骤。
system_call执行过程文字版
有了完整的汇编代码,我们再回到整体流程来理解system_call在做什么:
-
1. 保存现场: -
• 通过 push指令将ds、es、fs、eax、ecx、edx依次压入内核栈。 -
• 保存它们的原因是:内核代码会自由使用这些寄存器,返回用户态时必须恢复原值。 -
2. 切换段寄存器: -
• 将 ds和es设置为内核数据段选择符0x10,内核之后通过这两个段寄存器访问自身数据。 -
• 将 fs设置为用户数据段选择符0x17,约定“凡是通过fs段访问的地址都是用户空间地址”。 -
• 这种约定让“访问内核数据”和“访问用户数据”在汇编层面清晰分离,避免混淆。 -
3. 检查系统调用号合法性: -
• 系统调用号存放在 eax中。 -
• 用 cmpl $nr_system_calls,%eax和ja bad_sys_call判断是否越界。 -
• 越界则跳转到 bad_sys_call,直接返回-ENOSYS(功能未实现)。 -
4. 通过sys_call_table间接调用内核函数: -
• sys_call_table是一个函数指针数组,下标就是系统调用号。 -
• 指令 call *sys_call_table(,%eax,4)等价于: -
• 计算地址 sys_call_table + eax*4(每个指针4字节), -
• 取出该地址处的函数指针并调用。 -
• 例如 eax=3时,调用的是sys_read()。 -
5. 检查是否需要调度: -
• 内核函数执行完后返回到system_call,返回值在 eax。 -
• 先把 eax压栈保存,然后通过current指针访问当前进程的state和counter字段: -
• 如果 state!=TASK_RUNNING,说明进程睡眠/停止/退出,不应继续运行,需要调度; -
• 如果 counter==0,说明时间片用完,也需要调度。 -
• 需要调度时调用 schedule(),选出新的进程,然后再跳回restore继续后续流程。 -
6. 返回路径与ret_from_sys_call: -
• 无论是否调度,最终都会走到 ret_from_sys_call路径: -
• 先从栈中弹出之前保存的返回值 eax; -
• 真实代码中在此处调用 do_signal(),检查并处理信号; -
• 再依次恢复 edx、ecx、ebx、fs、es、ds; -
• 最后执行 iret,一次性恢复eip、cs、eflags、esp、ss,回到用户态。 -
7. 错误处理路径bad_sys_call: -
• 如果系统调用号非法,直接将 eax设置为-ENOSYS,然后复用同一条返回路径: -
• 即跳转到 ret_from_sys_call,像正常系统调用一样恢复现场并返回用户态。
system_call整体时序图(更新版)
有了上面的代码分析,现在再看系统调用的时序,就更加清晰了:
通过这一节,我们从“纯文字描述”升级到了“源码级理解”:不仅知道system_call大致做了什么,更能精确对应到每一条汇编指令,为后续分析参数传递、信号处理、返回值等细节打下坚实基础。
4.3 系统调用宏_syscall0-3
用户程序不会直接编写int 0x80汇编代码,而是通过C库函数(如read、write)来调用。这些库函数的实现使用了系统调用宏_syscall0、_syscall1、_syscall2、_syscall3,定义在include/unistd.h中。这些宏根据参数个数不同而命名:_syscall0用于无参数的系统调用(如getpid),_syscall1用于一个参数(如close),_syscall2用于两个参数(如dup2),_syscall3用于三个参数(如read)。
下面是Linux 0.12中这些宏的真实定义,并加入了详细中文注释:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 // include/unistd.h - 系统调用封装宏// 无参数系统调用,例如 getpid()#define _syscall0(type,name) \ type name(void) \ { \ long __res; \ __asm__ volatile ( \ "int $0x80" \ : "=a" (__res) /* 输出: eax -> __res */ \ : "0" (__NR_##name) /* 输入: __NR_name -> eax (约束"0"复用上面的eax) */ \ ); \ if (__res >= 0) /* 返回值>=0表示成功 */ \ return (type) __res; \ errno = -__res; /* 返回值<0,取其相反数作为errno */ \ return -1; /* 函数返回-1表示出错 */ \ }// 1个参数系统调用,例如 close(fd)#define _syscall1(type,name,atype,a) \ type name(atype a) \ { \ long __res; \ __asm__ volatile ( \ "int $0x80" \ : "=a" (__res) /* eax输出到__res */ \ : "0" (__NR_##name), /* eax中是系统调用号 */ \ "b" ((long)(a)) /* 第1个参数a传入ebx */ \ ); \ if (__res >= 0) \ return (type) __res; \ errno = -__res; \ return -1; \ }// 2个参数系统调用,例如 mkdir(path, mode)#define _syscall2(type,name,atype,a,btype,b) \ type name(atype a, btype b) \ { \ long __res; \ __asm__ volatile ( \ "int $0x80" \ : "=a" (__res) \ : "0" (__NR_##name), /* eax=系统调用号 */ \ "b" ((long)(a)), /* ebx=第1个参数 */ \ "c" ((long)(b)) /* ecx=第2个参数 */ \ ); \ if (__res >= 0) \ return (type) __res; \ errno = -__res; \ return -1; \ }// 3个参数系统调用,例如 read(fd, buf, count)#define _syscall3(type,name,atype,a,btype,b,ctype,c) \ type name(atype a, btype b, ctype c) \ { \ long __res; \ __asm__ volatile ( \ "int $0x80" \ : "=a" (__res) \ : "0" (__NR_##name), /* eax=系统调用号 */ \ "b" ((long)(a)), /* ebx=第1个参数 */ \ "c" ((long)(b)), /* ecx=第2个参数 */ \ "d" ((long)(c)) /* edx=第3个参数 */ \ ); \ if (__res >= 0) \ return (type) __res; \ errno = -__res; \ return -1; \ }
有了这些宏,内核或库开发者只需用一行宏定义,就能生成完整的系统调用封装函数。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 在某个源文件中使用_syscall3定义read_syscall3(int, read, int, fd, char*, buf, int, count)// 展开后等价于:int read(int fd, char* buf, int count){ long __res; __asm__ volatile ( "int $0x80" /* 触发系统调用入口 */ : "=a" (__res) /* eax输出到__res */ : "0" (__NR_read), /* eax = __NR_read */ "b" ((long)(fd)), /* ebx = fd */ "c" ((long)(buf)), /* ecx = buf */ "d" ((long)(count)) /* edx = count */ ); if (__res >= 0) return (int) __res; /* 成功,直接返回结果 */ errno = -__res; /* 失败,设置errno */ return -1; /* 返回-1表示出错 */}
约束字符串解释小结:
-
• “=a” (__res): 表示 __res是一个输出操作数,对应寄存器eax(a),=表示只写。 -
• “0” (_NR##name): 表示这个输入操作数使用与第0号约束(即上面的”=a”)相同的寄存器,因此系统调用号会被放入eax。 -
• “b”、”c”、”d”: 分别指定参数放入ebx、ecx、edx寄存器,对应系统调用约定的前三个参数位置。
这套宏机制非常精巧,它隐藏了系统调用的底层细节(寄存器操作、中断指令等),让程序员可以像调用普通C函数一样调用系统函数。同时,通过内嵌汇编的方式,避免了额外的函数调用开销,提高了效率。这就像银行为客户提供了ATM机:客户只需要按照屏幕提示操作(调用API),无需了解内部的复杂流程(系统调用机制),就能完成取款(系统功能)。
4.4 系统调用表sys_call_table
sys_call_table数组是系统调用机制的核心索引表,定义在include/linux/sys.h中。这个数组中的每个元素是一个函数指针,指向对应系统调用号的内核函数。数组的定义格式是 sys_fn_ptr sys_call_table[] = {sys_setup, sys_exit, sys_fork, sys_read, ...},其中sys_fn_ptr是函数指针类型的别名。
Linux 0.12共有82个系统调用,因此sys_call_table有82个元素。系统调用号从0开始编号:0是sys_setup,1是sys_exit,2是sys_fork,3是sys_read,4是sys_write,等等。每个系统调用都有一个对应的宏定义__NR_xxx,例如__NR_read值为3,__NR_write值为4,这些宏定义在include/unistd.h中。
当system_call执行 call *sys_call_table(,%eax,4)时,如果eax是3(__NR_read),就会调用sys_call_table[3],即sys_read函数。sys_read定义在fs/read_write.c中,它的函数签名是 int sys_read(unsigned int fd, char * buf, int count),参数从栈中获取(system_call已经将ebx、ecx、edx压栈)。
系统调用表的设计体现了查表法的威力:只需要一个简单的数组,就能将系统调用号映射到具体的内核函数,无需复杂的if-else或switch-case分支。这种方式不仅高效,而且易于扩展,添加新系统调用只需要在数组末尾添加一项,并增加nr_system_calls计数。
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4.5 参数传递机制
系统调用的参数传递遵循固定的约定:最多支持3个参数,分别通过ebx、ecx、edx寄存器传递。这个约定是GCC编译器和Linux内核共同遵守的ABI(Application Binary Interface)的一部分。第一个参数在ebx中,第二个在ecx中,第三个在edx中。如果系统调用的参数少于3个,未使用的寄存器内容会被忽略。
system_call在保存现场时,会依次将edx、ecx、ebx压入内核栈。由于栈是向下增长的(从高地址向低地址),压栈顺序是edx、ecx、ebx,因此在栈中的布局从低地址到高地址是ebx、ecx、edx。C语言函数调用时参数是从右往左压栈,因此内核函数声明的参数顺序(从左到右)正好对应栈中参数的顺序(从低地址到高地址)。
例如sys_read的声明是 int sys_read(unsigned int fd, char * buf, int count),调用时fd在ebx中,buf在ecx中,count在edx中。system_call将edx、ecx、ebx依次压栈后,栈中从低地址到高地址依次是fd、buf、count,正好匹配sys_read的参数顺序。这样sys_read函数可以像普通C函数一样访问参数,无需关心参数从哪里来。
对于超过3个参数的系统调用(Linux 0.12中极少),通常采用间接传递的方式:将多个参数打包成一个结构体,然后将结构体的指针作为参数传递。例如sys_socketcall就是这样处理的,它的第二个参数是一个指向参数数组的指针。
4.6 返回值与错误处理
系统调用的返回值通过eax寄存器传递。按照Linux的约定,如果返回值大于等于0,表示调用成功,返回值的具体含义取决于系统调用(例如read返回实际读取的字节数,fork在父进程中返回子进程PID)。如果返回值是负数,表示调用失败,返回值的绝对值是错误码(定义在include/errno.h中)。
内核函数直接返回负的错误码,例如sys_open打开不存在的文件会返回-ENOENT(值为-2)。system_call将这个返回值保存在eax中并传递给用户态。_syscall宏中的代码检测到eax为负数后,将其绝对值赋给全局变量errno,然后让系统调用函数返回-1。
用户程序通过检查函数返回值是否为-1来判断系统调用是否出错,如果出错则查看errno获取具体错误码。例如以下代码片段:调用open打开文件,如果返回值是-1,说明出错,此时errno可能是ENOENT(文件不存在)、EACCES(权限不足)或其他错误码,通过判断errno可以确定错误原因并采取相应的处理措施。
1 2 3 4 5 6 7 8 9 10 11 12 int fd = open("/path/to/file", O_RDONLY);if (fd == -1) { if (errno == ENOENT) { printf("文件不存在\n"); } else if (errno == EACCES) { printf("权限不足\n"); } else { printf("其他错误:%d\n", errno); } return -1;}// 使用fd进行文件操作
这种错误处理机制简洁而统一:所有系统调用都使用相同的返回值约定,用户程序可以用统一的方式处理错误。这就像快递配送,成功时返回包裹(正数或0),失败时返回错误单(-1),错误单上注明原因代码(errno),客户可以根据代码查询失败原因。
4.7 信号处理与系统调用的关系
系统调用返回前会检查并处理信号,这是在ret_from_sys_call段完成的。如果当前进程有待处理的信号(signal & ~blocked不为0),就会调用do_signal()函数。do_signal()会检查每个待处理信号对应的sigaction,如果设置了信号处理函数,就在用户态栈上构造一个调用信号处理函数的栈帧,并修改内核栈中保存的eip,使其指向信号处理函数。
当system_call执行iret返回用户态时,用户程序会先执行信号处理函数,处理完信号后再返回到被中断的位置继续执行。这样就实现了异步信号处理:信号可以在任何时候到达,但只在从内核态返回用户态时才被处理,不会打断内核代码的执行。
对于某些系统调用(如read等待键盘输入),如果在等待过程中收到信号,系统调用会被中断,提前返回错误码-EINTR。用户程序需要检测到这个错误并决定是否重试。这种机制保证了信号的及时响应,避免进程因等待I/O而无法处理紧急信号(如SIGINT终止信号)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 系统调用执行完毕 │ ▼ 返回到 ret_from_sys_call │ ▼ ┌─────────────────┐ │ 有待处理信号? │ └────────┬────────┘ │ ┌────────┴────────┐ │有 │无 ▼ ▼ 调用 do_signal 恢复寄存器 │ │ ▼ │ 检查 sigaction │ (信号处理函数注册信息) │ │ │ ▼ │ 在用户栈构造信号处理栈帧 │ (保存返回地址、寄存器等) │ │ │ ▼ │ 修改保存的 eip 指向信号处理函数 │ │ │ └────────┬────────┘ │ ▼ 执行 iret 返回用户态 │ ▼ ┌─────────────────┐ │ 修改过 eip? │ └────────┬────────┘ │ ┌────────┴────────┐ │是 │否 ▼ ▼ 先执行信号处理函数 返回原中断位置 (用户定义的信号处理逻辑) (read 等系统调用返回处) │ ▼ 信号处理完毕后返回原位置 (通过 sigreturn 再次 iret)
4.8 实际示例:一个完整的write系统调用
让我们追踪一个完整的write系统调用过程。用户程序执行 write(1, "Hello", 5),意图向标准输出(文件描述符1)写入字符串”Hello”。这行代码经过编译后,实际上调用的是_syscall3宏生成的write函数。
write函数首先将__NR_write(值为4)放入eax,将1放入ebx,将”Hello”的地址放入ecx,将5放入edx。然后执行int 0x80指令,CPU查IDT表项0x80,切换到内核态,压入用户态的ss、esp、eflags、cs、eip到内核栈,跳转到system_call。
system_call将ds、es、fs、eax、edx、ecx、ebx依次压栈,设置ds=es=0x10(内核数据段),fs=0x17(用户数据段)。检查eax(4)小于nr_system_calls(82),合法。然后执行 call *sys_call_table(,%eax,4),即调用sys_call_table[4],也就是sys_write函数。

sys_write定义在fs/read_write.c中,它从栈中取出参数:fd=1,buf=”Hello”的地址,count=5。sys_write首先通过fd=1查找file结构,发现fd 1是标准输出,对应的是终端设备文件。于是调用file->f_op->write(实际是tty_write函数)将数据写入终端。tty_write将字符逐个放入终端输出队列,由终端驱动程序负责实际显示到屏幕上。写入完成后sys_write返回5(成功写入5字节)。

返回到system_call,eax=5被压栈保存。检查当前进程状态,进程仍在就绪状态且时间片未用完,无需调度。检查信号,无待处理信号。恢复寄存器,从栈中弹出ebx、ecx、edx、eax(eax=5)、fs、es、ds。执行iret,弹出eip、cs、eflags、esp、ss,返回到用户态write函数的下一条指令。write函数中的内嵌汇编返回,__res=5。检查__res>=0,成功,返回5。用户程序得到返回值5,确认写入成功。
4.9 系统调用的性能考虑
系统调用虽然提供了强大的功能,但也引入了不小的开销。每次系统调用都需要:执行int 0x80指令触发中断,CPU切换特权级和栈,保存和恢复寄存器,查找sys_call_table,调用内核函数,处理信号,再切换回用户态。整个过程涉及大量的上下文切换和内存访问,比普通函数调用慢得多。
因此,高性能程序会尽量减少系统调用的次数。例如,与其多次调用write每次写一个字节,不如一次write写多个字节。标准C库提供了缓冲I/O(如fwrite、fprintf),在用户空间维护缓冲区,积累一定数据后再一次性调用系统调用写入,大大减少了系统调用次数。
在后来的Linux版本中,引入了vsyscall和vDSO机制,将某些简单的系统调用(如gettimeofday、clock_gettime)的代码映射到用户空间,允许用户程序直接调用这些函数而无需陷入内核,进一步提高了性能。这些优化体现了操作系统设计中的权衡:在安全性和性能之间寻找平衡点。
通过本章的学习,我们深入理解了Linux 0.12的系统调用机制:从int 0x80中断到system_call汇编函数,从系统调用表到参数传递,从返回值处理到信号处理,以及完整的调用流程。系统调用是操作系统内核与用户程序交互的唯一正规途径,理解它的实现原理对于深入掌握操作系统至关重要。
4.10 参考资料
本章内容基于以下资料编写:
Intel官方文档
-
• Intel 80386 Programmer’s Reference Manual (1986) -
• Chapter 9: Exceptions and Interrupts – 详细描述了软件中断int指令的执行过程 -
• Chapter 6: Protection – 介绍了特权级检查和栈切换机制 -
• Chapter 5: Registers – 说明了寄存器在过程调用中的使用约定 -
• 在线阅读:https://css.csail.mit.edu/6.858/2014/readings/i386.pdf
Linux 0.12源代码文件
-
• kernel/sys_call.s – system_call汇编入口函数、ret_from_sys_call -
• include/unistd.h – _syscall0-3宏定义、系统调用号__NR_xxx -
• include/linux/sys.h – sys_call_table系统调用表定义 -
• fs/read_write.c – sys_read、sys_write等文件系统相关系统调用 -
• kernel/fork.c – sys_fork系统调用实现 -
• kernel/exit.c – sys_exit系统调用实现 -
• kernel/signal.c – do_signal信号处理函数
“上善若水,水善利万物而不争。” —— 老子
夜雨聆风