张智泓(学号 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)
四件事依次发生:
- 取
thread_info:算出当前线程的信息结构,ebp从此指向它。 - 检查是否需要跟踪 :
_TIF_WORK_SYSCALL_ENTRY是跟踪、仿真、审计、seccomp、单步这几个标志的集合。只要有一个位置位,就走syscall_trace_entry那条慢路径。本次没有开跟踪,这一跳没走。 - 越界检查 :
cmpl $(NR_syscalls), %eax+jae syscall_badsys。用户传进来的调用号必须小于表长度,否则跳到syscall_badsys返回-ENOSYS。这是系统调用入口唯一的参数校验 ------注意它只校验了调用号本身,没有校验参数,参数校验是各个sys_xxx函数自己的责任。 - 查表 :
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 原版。