从 int $0x80 到 iret:用 gdb 把 getpid 走一遍

张智泓(学号 20262826)

原创作品转载请注明出处

《Linux 内核分析》MOOC 课程 http://mooc.study.163.com/course/USTC-1000029000


一、为什么要跟这一趟

用户态程序里写一句 getpid(),看起来轻飘飘的,实际上这一句背后发生了:CPU 从 ring 3 掉进 ring 0、换了一套栈、十几个寄存器被逐个保存、一张函数指针表被索引、内核函数执行、返回值被写回、一串状态标志被检查、可能还要切一次进程、最后五个寄存器一次性弹出、CPU 回到 ring 3 继续跑原来的指令。

这些动作,任何一本讲操作系统的书都会画成框图。但框图是"应该这样",我想知道"实际上是不是这样"。所以这次不看书,直接让 gdb 停在现场,一个一个寄存器、一个一个内存字地看。

跟踪的对象是 getpid,也就是 i386 的 20 号系统调用。选它有两个理由:一是上周的系统调用实验已经用它做过用户态验证,这次要往内核里再钻一层;二是它的内核实现短得几乎不存在------

c 复制代码
SYSCALL_DEFINE0(getpid)
{
	return task_tgid_vnr(current);
}

kernel/sys.c 第 816 行开始,就这三行。函数体越短,入口和出口的骨架就越干净,不会被业务逻辑糊住视线。这趟要看的是"房子怎么盖的",不是"屋里摆了什么东西"。


二、进入跟踪现场

要跟这套代码,内核必须是 32 位 3.x。system_call 只活在 arch/x86/kernel/entry_32.S 里,从 4.0 起 x86 入口被整体重写,符号换成 entry_INT80_32 / do_int80_syscall_32,返回也不再走原来那条 iret。所以先确认符号在不在:

bash 复制代码
nm vmlinux | grep -wE "system_call|sys_getpid|work_pending|restore_all|syscall_exit"

实测输出:

复制代码
c174d8e6 t restore_all
c174d8d6 t syscall_exit
c10511b0 T sys_getpid
c174d884 T system_call
c174d938 t work_pending

这五行本身就说明了不少问题:system_call 在 0xc174d884,syscall_exit 在 0xc174d8d6,中间只隔 0x52 字节 ------入口那一整段(保存寄存器、取 thread_info、两项检查、查表调用)总共才 82 字节机器码。work_pending 离入口 0xb4 字节。整个从入口到出口的骨架,撑死三百来字节。

跟踪靠 qemu 的 gdb stub,起步只要两个参数:

bash 复制代码
qemu-system-i386 -kernel bzImage -initrd rootfs.img \
  -append "root=/dev/ram init=/init console=ttyS0" -nographic -s -S

-s 在 1234 端口开 stub,-S 让 CPU 停在复位向量等着。执行完窗口卡住不动,不是卡死,是在等 gdb。另开终端接进去:

bash 复制代码
gdb -q vmlinux -ex "target remote:1234" -ex "set disassembly-flavor intel"

0x0000fff0 in ?? (),BIOS 复位向量,接管成功。然后下断点放行:

复制代码
(gdb) b sys_getpid
Breakpoint 1 at 0xc10511b0: file kernel/sys.c, line 817.
(gdb) c
Continuing.
Breakpoint 1, sys_getpid () at kernel/sys.c:817

从这一刻起,栈上的东西就是实打实的现场了。


三、入口:软件在硬件之上补了 12 个寄存器

断在 sys_getpid 时,第一件想做的事是看看栈长什么样。

复制代码
(gdb) info registers ebp esp eax
ebp            0xc783e000
esp            0xc783ffb0
eax            0x14

eax = 0x14 就是 20,系统调用号还在寄存器里。esp 落在内核栈高地址端。而 ebp 的值 0xc783e000 有个特点:它正好是 8KB 对齐的------

复制代码
0xc783ffb0 & ~0x1fff = 0xc783e000

这不是巧合。thread_info 结构就存在内核栈的底部,由一个固定的掩码从 esp 算出来。所以 system_call 里那句 GET_THREAD_INFO(%ebp),干的事情就是把 ebp 从"调用者的栈帧指针"改写成"当前线程信息结构地址"。进入系统调用之后,ebp 就不再是 ebp 了 ------这是个很容易看漏的点,后面所有 TI_flags(%ebp) 都靠它取 thread_info->flags。

不过这时 esp 还没指向 pt_regs 的起点,因为 call *sys_call_table(,%eax,4) 刚把返回地址压进去了。bt 的输出也印证了这一点:

复制代码
#0  sys_getpid () at kernel/sys.c:817
#1  <signal handler called>
#2  0x080480e8 in ?? ()
#3  0x08048213 in ?? ()

gdb 展不开汇编帧,只能标一句 <signal handler called> 然后直接跳到用户态地址。真正的证据在栈顶那个值上。

所以要先把 sys_getpid 跑完,让 call 压的返回地址弹掉:

复制代码
(gdb) finish
Run till exit from #0  sys_getpid () at kernel/sys.c:817
<signal handler called>
Value returned is $1 = 1

返回值是 1。init 是内核启动的第一个用户进程,pid 恒为 1,符合预期。

finish 之后 esp 正好落在 pt_regs 的起点,于是把 17 个字全打出来:

复制代码
(gdb) x/17wx $esp
0xc783ffb4: 0x00000000  0x08048230  0x00000011  0x00000000
0xc783ffc4: 0x00000000  0xbfc1cdd0  0x00000014  0x0000007b
0xc783ffd4: 0x0000007b  0x00000000  0x00000000  0x00000014
0xc783ffe4: 0x080480e8  0x00000073  0x00000286  0xbfc1cdc0
0xc783fff4: 0x0000007b

对着 entry_32.S 开头那段栈布局注释,逐项认领:

偏移 地址 值 字段 判读
0x00 0xc783ffb4 0x00000000 ebx 用户态 ebx
0x04 0xc783ffb8 0x08048230 ecx 用户态 ecx
0x08 0xc783ffbc 0x00000011 edx 用户态 edx
0x0C 0xc783ffc0 0x00000000 esi ---
0x10 0xc783ffc4 0x00000000 edi ---
0x14 0xc783ffc8 0xbfc1cdd0 ebp 用户态栈帧指针
0x18 0xc783ffcc 0x00000014 eax 返回值回写处(此刻还是 20)
0x1C 0xc783ffd0 0x0000007b ds __USER_DS
0x20 0xc783ffd4 0x0000007b es __USER_DS
0x24 0xc783ffd8 0x00000000 fs 用户态未用
0x28 0xc783ffdc 0x00000000 gs 用户态未用
0x2C 0xc783ffe0 0x00000014 orig_eax = 20,系统调用号
0x30 0xc783ffe4 0x080480e8 eip 用户态返回地址
0x34 0xc783ffe8 0x00000073 cs __USER32_CS
0x38 0xc783ffec 0x00000286 eflags IF=1(用户态开中断)
0x3C 0xc783fff0 0xbfc1cdc0 oldesp 用户栈指针
0x40 0xc783fff4 0x0000007b oldss __USER32_DS

这张表里有几处细节,比背结论有意思得多。

第一,0x30 以上是硬件压的,0x2C 以下是软件补的。

cs = 0x73、ss = 0x7b 这两个值只可能是 CPU 从用户态切到内核态时自动写进去的------int $0x80 触发特权级切换,CPU 根据 TSS 找到内核栈,然后自动压入 SS、ESP、EFLAGS、CS、EIP 这五样。这五样正好占据 0x30 到 0x40。

而 0x00 到 0x28 这十一个(ebx~gs),是 SAVE_ALL 宏里一条条 push 出来的:

asm 复制代码
.macro SAVE_ALL
	cld
	PUSH_GS
	pushl_cfi %fs
	pushl_cfi %es
	pushl_cfi %ds
	pushl_cfi %eax
	pushl_cfi %ebp
	pushl_cfi %edi
	pushl_cfi %esi
	pushl_cfi %edx
	pushl_cfi %ecx
	pushl_cfi %ebx
	...
.endm

数一下压栈顺序:gs、fs、es、ds、eax、ebp、edi、esi、edx、ecx、ebx。栈是向下生长的,先压的在高地址,所以倒过来排列,最低地址处正好是 ebx------和实测表完全一致。

一个 68 字节的栈帧,前 20 字节是 CPU 干的,后 48 字节是内核自己补的。 这就是"硬件只做最少的事,剩下的内核自己来"最直观的一次体现。

第二,orig_eax 那一格是整个实验最硬的证据。

0x2C 处的 0x14 来自 system_call 入口第二条指令:

asm 复制代码
ENTRY(system_call)
	RING0_INT_FRAME
	ASM_CLAC
	pushl_cfi %eax			# save orig_eax
	SAVE_ALL

用户态调 getpid 时,%eax 里放的是 20。这条 pushl %eax 把它压进栈,从此这一格再也不动了。后面哪怕 eax 被函数返回值覆盖成 1,orig_eax 依然是 20。

这两个字段回答了不同的问题 :orig_eax 记录"当初请求的是哪个系统调用",eax 记录"这个调用返回了什么"。内核把两者分开存,是因为返回路径上还要用调用号做审计(audit)、做 seccomp 过滤,必须留个底。


四、查表调用:那一条 call

SAVE_ALL 之后是这几条:

asm 复制代码
	GET_THREAD_INFO(%ebp)
	testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp)
	jnz syscall_trace_entry
	cmpl $(NR_syscalls), %eax
	jae syscall_badsys
syscall_call:
	call *sys_call_table(,%eax,4)
syscall_after_call:
	movl %eax,PT_EAX(%esp)

四件事依次发生:

  1. 取 thread_info :算出当前线程的信息结构,ebp 从此指向它。
  2. 检查是否需要跟踪 :_TIF_WORK_SYSCALL_ENTRY 是跟踪、仿真、审计、seccomp、单步这几个标志的集合。只要有一个位置位,就走 syscall_trace_entry 那条慢路径。本次没有开跟踪,这一跳没走。
  3. 越界检查 :cmpl $(NR_syscalls), %eax + jae syscall_badsys。用户传进来的调用号必须小于表长度,否则跳到 syscall_badsys 返回 -ENOSYS。这是系统调用入口唯一的参数校验 ------注意它只校验了调用号本身,没有校验参数,参数校验是各个 sys_xxx 函数自己的责任。
  4. 查表 :call *sys_call_table(,%eax,4)。sys_call_table 是个函数指针数组(arch/x86/kernel/syscall_32.c),%eax=20 时取第 20 项,也就是 sys_getpid 的地址。这是间接调用,* 表示取地址里的内容。

到这里,用户态的一次 getpid 已经变成了内核里的一次函数调用。


五、返回值怎么回到用户态

sys_getpid 把 task_tgid_vnr(current) 的结果放进 %eax,然后 ret 回到 syscall_after_call。紧接着这条指令只有一句:

asm 复制代码
syscall_after_call:
	movl %eax,PT_EAX(%esp)		# store the return value

PT_EAX 就是 pt_regs 里 eax 的偏移,0x18。也就是说,返回值不是"直接带回用户态",而是先写回栈上的寄存器镜像。

这一点可以抓个现行。停在 syscall_after_call:

复制代码
(gdb) info registers eax eip
eax            0x1        1
eip            0xc174d8d2 0xc174d8d2 <syscall_after_call>
(gdb) x/4i $pc
0xc174d8d2 <syscall_after_call>:  mov DWORD PTR [esp+0x18],eax
0xc174d8d6 <syscall_exit>:        cli
0xc174d8d7 <syscall_exit+1>:      mov ecx,DWORD PTR [ebp+0x8]
0xc174d8da <syscall_exit+4>:      test ecx,0x1008feff

此时 eax = 1(返回值在寄存器里),栈上 esp+0x18 还是 0x14(老值)。单步走过那条 mov:

复制代码
(gdb) si
(gdb) x/wx $esp+0x18
0xc783ffcc:  0x00000001

从 0x14 变成 0x00000001。 一次写内存,把寄存器里的值搬进了栈帧。

为什么非要绕这一道?因为后面 RESTORE_REGS 是一串 pop,它只认栈上的值。pop eax 弹出来的必须已经是返回值,而不是调用号。所以"写回栈帧"不是多余动作,而是让返回值能搭上后面那串 pop 的车。


六、返回路径:两次判断,一个关口

syscall_after_call 之后立刻进入 syscall_exit:

asm 复制代码
syscall_exit:
	LOCKDEP_SYS_EXIT
	DISABLE_INTERRUPTS(CLBR_ANY)	# make sure we don't miss an interrupt
					# setting need_resched or sigpending
					# between sampling and the iret
	TRACE_IRQS_OFF
	movl TI_flags(%ebp), %ecx
	testl $_TIF_ALLWORK_MASK, %ecx	# current->work
	jne syscall_exit_work

restore_all:
	...

三条指令值得逐条拆。

cli 先关中断。 这不是随手加的。源码注释说得很直白:要防止在"采样 TI_flags"和"执行 iret"之间被中断插进来。如果这中间来了一次中断,中断处理程序可能给进程打上 need_resched 或 sigpending 标记,而刚才采样到的 ecx 里没有这些位,于是 iret 直接返回用户态,调度请求和信号就被漏掉了,只能等下一次进内核才处理。所以必须先关中断,把"检查---返回"做成一个原子区间。

movl TI_flags(%ebp), %ecx ------ 这就是 ebp 必须指向 thread_info 的原因。thread_info 结构的布局是:

c 复制代码
struct thread_info {
	struct task_struct	*task;		/* 偏移 0 */
	struct exec_domain	*exec_domain;	/* 偏移 4 */
	__u32			flags;		/* 偏移 8 */
	...
};

flags 在偏移 8,所以反汇编出来是 mov ecx,DWORD PTR [ebp+0x8]。

testl $_TIF_ALLWORK_MASK, %ecx ------ 0x1008feff 这个魔数可以还原:_TIF_ALLWORK_MASK 定义为

c 复制代码
#define _TIF_ALLWORK_MASK						\
	((0x0000FFFF & ~_TIF_SECCOMP) | _TIF_SYSCALL_TRACEPOINT |	\
	_TIF_NOHZ)

_TIF_SECCOMP 是 bit 8(0x100),_TIF_SYSCALL_TRACEPOINT 是 bit 28,_TIF_NOHZ 是 bit 19。算下来 0xFEFF | 0x10000000 | 0x80000 = 0x1008FEFF,和反汇编里的立即数一字不差。看到这种魔数别跳过,它一般都能从宏定义反推出来,反推成功说明你和对源代码的理解对上了。

这三条指令在 gdb 里长这样:


七、调度时机:这次真的调度了

题目特别点出"注意系统调用返回 iret 之前的进程调度时机"。这是这一节的落点,也是整个实验里我最想抓到的东西。

先看实测。停在 syscall_exit,直接读 thread_info->flags:

复制代码
(gdb) x/wx $ebp+0x8
0xc783e008:  0x00000008
(gdb) si
(gdb) si
(gdb) p/x $ecx
$3 = 0x8

TI_flags = 0x8。 对照位定义:

c 复制代码
#define TIF_SIGPENDING		2	/* bit 2 = 0x4  */
#define TIF_NEED_RESCHED	3	/* bit 3 = 0x8  */

0x8 就是 _TIF_NEED_RESCHED。也就是说,在这次 getpid 返回之前,内核已经判定"该换人了"。

内存里读到的值和寄存器装载后的值是同一个(都是 0x8),顺带证明了 mov ecx,[ebp+0x8] 这条指令确实是把 flags 搬进 ecx。

标志置位之后,判断链是这样走的:

复制代码
test ecx,0x1008feff  →  0x8 & 0x1008feff = 0x8 ≠ 0  →  jne syscall_exit_work

进 syscall_exit_work 后先看是不是"退出跟踪"那些事:

asm 复制代码
syscall_exit_work:
	testl $_TIF_WORK_SYSCALL_EXIT, %ecx
	jz work_pending

_TIF_WORK_SYSCALL_EXIT = 0x10080091(trace、audit、singlestep、tracepoint、nohz 的集合),0x8 & 0x10080091 = 0,所以 jz 跳去 work_pending。调用号 20 和跟踪无关,直接放过。

work_pending 才是调度真正发生的地方:

asm 复制代码
work_pending:
	testb $_TIF_NEED_RESCHED, %cl
	jz work_notifysig
work_resched:
	call schedule
	LOCKDEP_SYS_EXIT
	DISABLE_INTERRUPTS(CLBR_ANY)
	TRACE_IRQS_OFF
	movl TI_flags(%ebp), %ecx
	andl $_TIF_WORK_MASK, %ecx	# is there any work to be done other
					# than syscall tracing?
	jz restore_all
	testb $_TIF_NEED_RESCHED, %cl
	jnz work_resched

testb $_TIF_NEED_RESCHED, %cl 拿 0x8 一测,非零,jz 不跳,于是落到 work_resched: call schedule。进程在这里被切走了。

而 schedule() 返回之后,代码做了一件很关键的事:重新读一次 TI_flags,而不是沿用之前那份:

asm 复制代码
	movl TI_flags(%ebp), %ecx
	andl $_TIF_WORK_MASK, %ecx
	jz restore_all

为什么要重读?因为 schedule() 可能耗时很久,这期间可能又来了信号、又需要再调度一次。所以它用一个循环 处理:jnz work_resched 会跳回去再调一次 schedule,直到 _TIF_WORK_MASK 里没有待办位为止。

这个"重读"在实测里留下了痕迹。等程序走到 restore_all 的时候:

复制代码
(gdb) info registers ecx eip
ecx            0x0        0
eip            0xc174d8e6 0xc174d8e6 <restore_all_notrace>

ecx 变成 0 了。 而刚才在 syscall_exit 采样到的还是 0x8。

这两个值放在一起,整条逻辑就闭合了:

复制代码
syscall_exit 采样 → 0x8(需要调度)→ call schedule → 重读 → 0x0(已处理完)→ restore_all

need_resched 这个标记是"一次性的":schedule() 完成切换后就把它清掉,所以重读时已经归零。如果程序不重读而直接沿用 0x8,就会在没有任何待办工作的情况下绕回 work_resched,陷入死循环。 这就是重读的必要性。

至于 0x8 是怎么来的:getpid 本身一纳秒就执行完了,不可能自己触发调度。合理的解释是在系统调用执行期间,时钟中断进来跑了 scheduler_tick(),判定当前进程该让出 CPU,于是给 thread_info 打了 _TIF_NEED_RESCHED 标记 。因为内核不支持抢占(非 CONFIG_PREEMPT),中断处理程序不能当场切进程,只能留个标记,等"最靠近用户态的那个安全点"再动手。

这个"最靠近用户态的安全点",就是 work_pending。

这一点是理解整套机制的关键:内核里绝大部分代码运行在"不可被打断"的语境下(关中断、持锁、处于中断上下文),在这些地方切进程会破坏数据结构。所以调度被推迟,推迟到进程即将返回用户态的那一刻------此时内核栈即将被丢弃、所有锁都已释放,是唯一安全的切换窗口。系统调用返回路径和中断返回路径共用同一段 work_pending,就是因为它们面临同一个问题:马上要回用户态了,回之前把该做的事做完。

顺便说,如果 _TIF_NEED_RESCHED 没置位(大部分系统调用都是这种情况),work_pending 里那条 jz work_notifysig 会直接跳过 schedule,然后检查有没有待处理信号,没有就直接 restore_all。每进一次内核都检查一遍调度,这是常态;真正发生切换,是例外。 这次能抓到一次真实调度,算运气不错------init 刚被唤醒、正在首次运行,调度器确实有理由换人。


八、restore_all 与 iret:一次弹掉五样

restore_all 这一段比想象中复杂,因为它里面藏了一个"老 CPU 的官方 bug"的绕行处理:

asm 复制代码
restore_all:
	TRACE_IRQS_IRET
restore_all_notrace:
#ifdef CONFIG_X86_ESPFIX32
	movl PT_EFLAGS(%esp), %eax	# mix EFLAGS, SS and CS
	movb PT_OLDSS(%esp), %ah
	movb PT_CS(%esp), %al
	andl $(X86_EFLAGS_VM | (SEGMENT_TI_MASK << 8) | SEGMENT_RPL_MASK), %eax
	cmpl $((SEGMENT_LDT << 8) | USER_RPL), %eax
	je ldt_ss			# returning to user-space with LDT SS
#endif
restore_nocheck:
	RESTORE_REGS 4			# skip orig_eax/error_code
irq_return:
	INTERRUPT_RETURN

反汇编和源码逐条对得上:

复制代码
0xc174d8e6 <restore_all_notrace>:      mov eax,DWORD PTR [esp+0x38]
0xc174d8ea <restore_all_notrace+4>:    mov ah,BYTE PTR [esp+0x40]
0xc174d8ee <restore_all_notrace+8>:    mov al,BYTE PTR [esp+0x34]
0xc174d8f2 <restore_all_notrace+12>:   and eax,0x20403
0xc174d8f7 <restore_all_notrace+17>:   cmp eax,0x403
0xc174d8fc <restore_all_notrace+22>:   je 0xc174d90d <ldt_ss>
0xc174d8fe <restore_nocheck>:          pop ebx
0xc174d8ff <restore_nocheck+1>:        pop ecx
0xc174d900 <restore_nocheck+2>:        pop edx
0xc174d901 <restore_nocheck+3>:        pop esi
0xc174d902 <restore_nocheck+4>:        pop edi
0xc174d903 <restore_nocheck+5>:        pop ebp

这段在干什么?它把 eflags 的低字节、cs、oldss 拼成一个 32 位数,然后和 0x403 比较。0x403 拆开是 SEGMENT_LDT(4) << 8 | USER_RPL(3),意思是"要返回的是使用 LDT 的用户态,段选择子带 LDT 位"。

为什么要单独判断这个?源码注释写得很清楚:

We're returning to userspace with a 16 bit stack. The CPU will not restore the high word of ESP for us on executing iret... This is an "official" bug of all the x86-compatible CPUs, which we can work around to make dosemu and wine happy.

x86 的 iret 在返回 16 位栈时,不会恢复 ESP 的高 16 位。 这是所有 x86 兼容 CPU 的"官方 bug",为了兼容 DOSEMU 和 Wine 才需要绕。绕法是切换到 ESPFIX 段,用段基址去补偿那个差异。本次返回的是普通 32 位用户态(oldss=0x7b、cs=0x73),拼出来的值不等于 0x403,所以 je ldt_ss 不跳,直接落到 restore_nocheck。

看到这种分支别急着跳过,它背后往往是一个具体的历史包袱。 这也是读老内核最有意思的地方:每一段看着奇怪的代码,都是某个真实问题留下的疤。

然后是 RESTORE_REGS 4。那个 4 是个容易看漏的参数:

asm 复制代码
.macro RESTORE_REGS pop=0
	RESTORE_INT_REGS
	popl_cfi %ds
	popl_cfi %es
	popl_cfi %fs
	POP_GS \pop
.endm

RESTORE_INT_REGS 依次 pop 出 ebx、ecx、edx、esi、edi、ebp、eax 七个,再 pop 出 ds、es、fs、gs 四个。最后 POP_GS 4 里的 4 是"额外跳过 4 个字节"------因为 SAVE_ALL 只压了 11 个寄存器,而栈上第 12 个是 orig_eax,那个值不能弹进任何寄存器,必须跳过去。所以 pop=4 的意思是跳过 orig_eax 这 4 个字节。

数一下这串 pop 之后 esp 的位置:11 个寄存器 44 字节 + orig_eax 4 字节 = 48 字节。此时 esp 正好指向 pt_regs 里的 eip(偏移 0x30)。

然后就是 INTERRUPT_RETURN,在 32 位上它展开成 iret。iret 的行为是从栈上连续弹出五样东西:EIP、CS、EFLAGS、ESP、SS。这和进入时 CPU 自动压入的五样正好对称------

复制代码
进:CPU 压 SS, ESP, EFLAGS, CS, EIP
出:iret 弹 EIP, CS, EFLAGS, ESP, SS

一次指令完成特权级降回、栈指针切换、标志恢复。 从这一刻起,CPU 又回到 ring 3,eip = 0x080480e8,接着执行 init 里 sys_getpid() 之后的那条指令。而内核栈上那 68 字节的 pt_regs,随着栈指针重置,就这么被丢掉了------下一次进内核会重新覆写它。

整个过程没有任何"清理"动作。栈就是这样:不需要擦,只需要把指针挪回去。


九、流程图

把上面这些串起来,从 int $0x80 到 iret 的完整路径如下。第一张是进入路径:

第二张是返回路径,重点标出了 work_pending 这个调度关口:

第三张是 SAVE_ALL 之后栈上的 17 项布局,也就是那张实测表的图形式:


十、总结:从一次 getpid 看到所有中断

这一趟跟下来,我对"系统调用处理过程"的理解可以归结成一句话:它不是"调用一个函数",而是"一次受控的、可恢复的特权级穿越"。

拆开说,有五层意思。

第一层,系统调用本质上是中断的一种。

int $0x80 是软中断指令,走的是和硬件中断同一套硬件机制:查 IDT、特权级切换、换栈、压五样。区别只在于触发源不同------一个来自软件主动执行指令,一个来自外部设备。所以 system_call、interrupt、error_code 这些入口,前半段几乎一模一样:压栈、SAVE_ALL、取 thread_info、调处理函数。

第二层,硬件只做最少的事,其余全是软件补的。

CPU 只压 SS、ESP、EFLAGS、CS、EIP 五样,因为它只负责"能回到刚才那条指令"所需的最小信息。通用寄存器、段寄存器这些它一概不管,全交给 SAVE_ALL 用 11 条 push 补齐。这个分工不是设计得精巧,而是历史演化的结果:硬件保持最小,软件负责周全,这样内核才能在不改 CPU 的前提下自己决定要保存什么。

第三层,返回路径不是入口路径的逆运算,它多了判断。

入口是"照做",返回是"决策"。返回路径上要做三件事:判断有没有待办工作(_TIF_ALLWORK_MASK)、判断要不要换进程(_TIF_NEED_RESCHED)、判断有没有待处理信号。而且这些判断被 cli 保护成一个原子区间,还要在 schedule() 返回后重读重判,形成循环。入口可以无条件执行,返回必须有条件执行------因为返回意味着"要暴露给用户态了",任何遗漏都会变成用户可见的错误行为。

第四层,调度被推迟到返回用户态前的最后一刻,这不是偷懒,是必须。

内核里到处是关中断、持锁、用着别人栈帧的代码段,在这些地方切进程,等于把一个正在搭到一半的架子连人一起搬走。所以中断处理程序只能打个 need_resched 标记就退出,真正的切换推迟到 work_pending------此时内核栈马上就要丢弃、所有锁都已释放、pt_regs 完整可用,是唯一安全且完整的切换点。系统调用和硬件中断共用这段代码,因为它们面临同一个约束:马上要离开内核了,离开之前把账结清。

第五层,这套结构可以推广到一般的中断处理。

把 system_call 里的"查表调用 sys_getpid"换成一个中断处理函数,其余全部成立:

环节 系统调用 一般中断
触发 int $0x80 硬件引脚 / 异常
硬件压栈 SS/ESP/EFLAGS/CS/EIP 完全相同
保存现场 SAVE_ALL SAVE_ALL(同一套宏)
找到处理函数 sys_call_table[eax] IDT 表项里的地址
执行 sys_getpid 中断服务程序
返回前检查 syscall_exit → work_pending restore_all → work_pending(同一段)
恢复现场 RESTORE_REGS 4 RESTORE_REGS
返回 iret iret

差别只在中间那一步:系统调用按调用号索引一张函数指针表,中断按中断号索引 IDT。入口、保存、检查、恢复、返回这五段,是同一套代码。

所以理解了 system_call,等于理解了整个 x86 内核的中断出口机制。反过来说,如果只把系统调用当成"内核提供的一组 API",就会永远想不通为什么返回之前要检查调度、为什么要关中断、为什么栈上有两个"eax"。这些细节不属于"接口",属于"穿越"------而穿越是有代价、有约束、有安全点的。

最后留一个我自己还没完全想透的问题:work_pending 里的重读循环,理论上可以无限循环(如果每轮都有人置 need_resched)。实际中不会饿死,因为 schedule() 至少会把 CPU 让给别的进程一次。但"重读几次才够"这件事,代码里没有上限,靠的是调度器自身的公平性。这种"不设上限,靠机制保证收敛"的写法在内核里很常见,值得单独琢磨。


文中的寄存器值、内存值、反汇编均为本次实测,源码引用对应 linux-3.18.6 原版。

相关推荐
小超人诞生日记1 小时前
Linux:环境变量 & 进程虚拟地址空间
linux·算法
杜 硕1 小时前
从零开始的C++生活——第三讲
开发语言·c++·生活
handonch1 小时前
金仓 KingbaseES 时区问题根治指南:从「总是差 8 小时」到全链路统一
linux·docker·postgresql·信创
tianbin9111 小时前
批处理与微调辅助场景:Claude 中转并发、链路缓存与压测对比
java·开发语言·缓存
Rabitebla1 小时前
【Linux 底层原理】工具篇(一):掌握 Ubuntu apt,让软件安装与管理更简单
linux·笔记·算法·ubuntu
chde2Wang1 小时前
Hadoop集群无datanode进程
linux·运维·服务器
frjc1 小时前
Spring AI 里 ChatClient 和 ChatModel 有什么区别?什么时候该用哪个?
java·开发语言·spring·ai
小成202303202651 小时前
C++~05异常处理
开发语言·c++
叼烟扛炮1 小时前
Linux 学习笔记(十八):进程控制——优雅地退出与等待
linux·运维·服务器