扒开系统调用的三层皮:从 getpid 到手写 int 0x80

作者:张智泓

版权声明:原创作品,转载请注明出处课程:《Linux 内核分析》MOOC 课程 http://mooc.study.163.com/course/USTC-1000029000


引子:一件看起来"多此一举"的事

课上老师说了一句让我印象很深的话------"我们要扒开系统调用的三层皮"。

听着挺玄乎。翻译成人话就是:我们平时写下的那行 getpid(),从用户态一路走到内核,中间到底经过了哪些层?libc 说它是个"函数",内核说它是个"系统调用",这中间那层薄薄的膜,究竟是什么?

为了把这件事弄明白,我做了一件看起来很傻的事:同一个 getpid(),我写了两遍。

  • 第一遍,规规矩矩调用 libc 提供的 getpid();
  • 第二遍,把 libc 踢到一边,在自己的 C 代码里内嵌汇编,手动把系统调用号塞进寄存器,然后亲手执行 int $0x80。

结果两边拿到的 PID 都对。但过程,完全是两个世界。

这篇博客,就是这次"扒皮"的完整记录。


一、实验环境

|--------|---------------------------------|
| 项目 | 配置 |
| 平台 | Windows 11 + WSL2(Ubuntu) |
| 编译器 | gcc 15.2.0 |
| 关键编译参数 | -m32(生成 32 位程序) |
| 观察工具 | strace(追踪系统调用)、objdump(反汇编) |

为什么一定要加 -m32?

这是本实验里第一个容易被忽略、但很关键的设定。

x86 架构上存在两套进入内核的方式:

  • 32 位经典方式 :int $0x80,系统调用号放 EAX,参数放 EBX/ECX/EDX/ESI/EDI/EBP;
  • 64 位原生方式 :syscall 指令,系统调用号放 RAX,参数放 RDI/RSI/RDX/R10/R8/R9。

课程里讲的是第一种(int $0x80 那套 32 位约定),所以实验也统一用 -m32 编译,让生成的机器码老老实实走 32 位路径,和课堂内容严丝合缝对上。

先确认环境能不能编 32 位程序:

gcc 15.2.0 + [OK] 支持 32 位编译,环境没问题,可以开工。


二、第一层皮:规规矩矩调用 libc 的 getpid()

第一版程序,就是所有人学 C 时都会写的样子------直接用库函数:

复制代码

编译、运行:

输出 [libc] getpid() 返回 = 687。

到这里,一切都"平平无奇"------我写下 getpid(),它就把 PID 给我了。但恰恰是这种平平无奇,才是系统调用设计得成功的地方:复杂性被彻底藏起来了。

那么,藏起来的部分长什么样?我们把 libc 的封装撕开看看。


三、第二、三层皮:绕过 libc,自己撞中断门

第二版程序,完全不用 libc 的 getpid(),而是用 C 内嵌汇编,手动完成系统调用的全部动作:

这几行汇编,其实就是 libc 内部干的事的"裸照"。逐句拆开看:

|------------------|--------------------------------------------|
| 汇编 | 作用 |
| movl $20, %eax | 20 是 __NR_getpid。内核要区分上百个系统调用,全靠这个号 |
| int $0x80 | 核心一步:主动触发 0x80 号软中断,CPU 从 ring 3 切到 ring 0 |
| movl %eax, pid | 内核执行完把结果放回 EAX,这里把它取出来存进 C 变量 |

编译、运行:

输出 [asm ] getpid() 返回 = 729。

两个 PID 数值不同很正常------每次运行都是全新的进程,内核自然分配新的 PID。真正值得注意的是:两版程序,一个走 libc、一个手写汇编,最后都拿到了内核给出的真实 PID。


四、证据一:strace 之下,殊途同归

口说无凭。凭什么断定"手工汇编那版"真的进了内核,而不是在用户态自己瞎转悠?

最直接的证据是 strace------它能打印出进程每一次真实发生的系统调用:

结果非常干净:

复制代码
===== libc 版 =====
[ Process PID=933 runs in 32 bit mode. ]
getpid()                                = 933
[libc] getpid() 返回 = 933
+++ exited with 0 +++
===== asm  版 =====
[ Process PID=941 runs in 32 bit mode. ]
getpid()                                = 941
[asm ] getpid() 返回 = 941
+++ exited with 0 +++

两个关键点:

  1. 内核视角下,两个程序都发生了同一个 getpid 系统调用,且返回值与 C 层打印的完全一致(933 / 941);
  1. strace 还贴心地标注了 runs in 32 bit mode,印证了我们 -m32 编译出来的确实是 32 位程序。

结论:libc 版和手写汇编版,走的压根就是同一条内核路径。 libc 只是替你写了那两行汇编而已。


五、证据二:objdump 之下,两条用户态路径

既然内核路径相同,那差异必然都在"用户态这一层"。用 objdump 反汇编两个可执行文件,取 main 函数看看:

复制代码
objdump -d -M intel getpid_asm  | sed -n '/<main>:/,/^$/p'
objdump -d -M intel getpid_libc | sed -n '/<main>:/,/^$/p'

手写汇编版的 main(节选),要的东西全在明面上:

libc 版的 main(节选),同样的功能,却只有一行"转交":

复制代码
11c5:  b8 14 00 00 00    mov  eax, 0x14     ; 0x14 = 20,系统调用号
11ca:  cd 80             int  0x80          ; 撞中断门,进内核
11cc:  89 45 f0          mov  [ebp-0x10], eax  ; 返回值写回 pid

对比非常直观:

  • 手写汇编版把 "报调用号 → 撞门 → 取返回值" 三个动作全摆在了 main 里,所以看得清清楚楚;
  • libc 版只有一句 call getpid@plt,int $0x80 那一步被藏进了 glibc 的函数实现中。

这就是"第一层皮"的本质 :libc 的 getpid() 只是一个"包装纸"。它替你完成了准备寄存器、执行 int $0x80、检查返回值这一整套流程,让程序员完全不必碰汇编。


六、把三层皮拼起来:一次系统调用到底怎么走完

到这里,课上的"三层皮"终于能拼成一张完整的图了:

第一层皮 · 用户态 API。 也就是 getpid() 这个函数。它的职责很单纯:把系统调用号 20 写进 EAX,然后执行 int $0x80。

第二层皮 · 中断向量对应的内核入口。 int $0x80 会产生一个编号 128(即 0x80)的编程异常(trap,软中断) 。CPU 拿着这个编号去 IDT(中断描述符表)里查第 0x80 项------这个描述符的 DPL 被设成了 3,所以用户态可以合法地触发它 。跳转发生后,CPU 完成特权级切换(ring 3 → ring 0),改用内核栈,跳到内核入口 system_call(arch/x86/entry/entry_32.S)。

system_call 干两件事:保存用户态现场;然后以 EAX 里的值为下标,去 sys_call_table 这张函数指针表里取地址。

第三层皮 · 具体的系统调用服务程序。 表里第 20 项指向 sys_getpid()(kernel/sys.c)。这才是真正干活的函数------它读取当前进程 task_struct 里的 PID,把结果放回 EAX。

最后是返回路径:syscall_exit → 恢复现场 → iret 回到用户态。注意,返回用户态这个瞬间,正是内核检查"是否需要重新调度"的时机------如果此时此刻有更高优先级的任务在等,或者当前进程的时间片耗尽,就会在这里切去执行别的进程;否则,直接回到断点继续往下跑。

一句话概括这条链路:

复制代码
用户态 getpid()
   │  调用号 20 → EAX
   ▼
int $0x80  ──trap──►  system_call(查 IDT 0x80 项,切到 ring 0)
   │                    │
   │                    └─► sys_call_table[20]
   │                            │
   │                            └─► sys_getpid()   ← 真正干活
   ▼                                 │
返回值 ◄──── EAX ◄────────────────────┘
  (iret 返回用户态,此处是调度时机)

七、参数是怎么"塞"进内核的------补一个 write 实验

getpid 是无参系统调用,"参数传递"这一块它讲不了。为了把课上那条规则(参数依次放进 EBX、ECX、EDX... )验证一遍,我补了 write(4 号系统调用,三个参数)做对照。

同样两版:libc 版和手写汇编版。

手写汇编版的关键在于"参数落位":

复制代码
asm volatile(
    "movl $4, %%eax\n\t"    /* 系统调用号 4 = __NR_write  → EAX */
    "movl %1, %%ebx\n\t"    /* 参数1 fd    → EBX */
    "movl %2, %%ecx\n\t"    /* 参数2 buf   → ECX */
    "movl %3, %%edx\n\t"    /* 参数3 count → EDX */
    "int  $0x80\n\t"
    "movl %%eax, %0\n\t"
    : "=m"(ret)
    : "m"(fd), "m"(msg), "m"(len)
    : "eax", "ebx", "ecx", "edx", "memory"
);

编译运行两版,输出除提示前缀外完全一致,write() 都返回了正确的字节数:

再看反汇编,参数传递的过程一目了然:

复制代码
ov  eax, 0x4                 ; 调用号 __NR_write = 4
mov  eax, [ebp-0x28]
mov  ebx, eax                 ; 参数1 fd    → EBX
mov  eax, [ebp-0x2c]
mov  ecx, eax                 ; 参数2 buf   → ECX
mov  eax, [ebp-0x1c]
mov  edx, eax                 ; 参数3 count → EDX
int  0x80                     ; 撞门
mov  [ebp-0x20], eax          ; 返回值(写入字节数)→ ret

(编译器在这里经由 eax 中转了一下,是因为我把输入约束写成了 "m" 内存操作数,稍后会解释为什么必须这么写。但**"EBX → ECX → EDX 依次落位"这个顺序,看得清清楚楚**。)

作为对照,libc 版走的是另一条路------老老实实压栈再调用:

复制代码
push 0x1               ; 参数 1
lea  eax, [ebp-0x10]   ; 参数 2
push eax
push 1                 ; 参数 3
call 1050 <write@plt>  ; 交给库

这里正好点出普通函数调用和系统调用的分野:

  • 用户态内部调用函数,参数走栈 (push 再 call);
  • 用户态调用内核,参数走寄存器 ------因为特权级切换的同时也换了栈,用户栈上的东西内核不能直接拿来当"参数约定",必须用一套双方都认的规则:系统调用号放 EAX,参数依次放 EBX、ECX、EDX、ESI、EDI、EBP,最多 6 个。

那超过 6 个参数怎么办?答案是:退化成"一个指针"------把这些参数先在用户态内存里排好,只往一个寄存器里放"指向这块内存的指针",内核进来后按指针去读整块参数。本质上是"寄存器不够,内存来凑"。


八、踩坑记录

实验过程并不顺,这里如实记两个坑。

坑一:strace 没装

第一次跑 strace 直接报 command not found。解决办法很简单:

复制代码
sudo apt install -y strace

小提示:这一步其实很能说明"用户态 API 是库的一部分"------strace 并不属于内核,需要额外安装。

坑二:内嵌汇编报 impossible constraints(这个坑更有价值)

write 版第一次编译,直接失败:

复制代码

排查过程: 报错说的是"寄存器不够"。可我明明只用了几个寄存器,怎么会不够?

原因找到了 :i386 一共 8 个通用寄存器,其中 ESP(栈指针)、EBP(帧指针)被 C 运行时占用,编译器实际能自由支配的只剩 6 个。而我最初的写法里,一方面在破坏列表里声明了 eax/ebx/ecx/edx 四个,另一方面又用 "r" 约束要求编译器再额外分配 3 个寄存器来装载输入 ------ 3 + 4 = 7 > 6,编译器凑不出来,于是直接罢工。

解决办法 :把输入约束从 "r"(寄存器)改成 "m"(内存操作数)。内存操作数不占寄存器 ,值直接从栈上取;汇编模板里仍显式 mov 到 EBX/ECX/EDX。改完之后编译顺畅,结果也完全正确。

收获 :内嵌汇编的"寄存器账"必须自己算清楚。破坏列表(clobber list)里声明的寄存器,编译器在分配输入/输出时是会避开的------你声明得越多,留给输入的就越少。这条规则看着简单,不亲手踩一次是真记不住。


九、我对"系统调用工作机制"的理解

做完这两组实验,我对"系统调用"这件事的理解,从"记住概念"变成了"看见动作"。总结成四点:

第一,系统调用的本质是"一次受控的特权级穿越"。 用户态永远不能直接碰硬件和内核数据,但进程又必须能读写文件、申请内存、获取时间。系统调用就是那道被正式批准的"门":平时锁着,只有通过 int $0x80(或 syscall)这个特定动作,CPU 才会打开它,并同时完成"换栈 + 换特权级"。所谓安全,不是不让进,而是"只准从这个门、按这个姿势进"。

第二,所有系统调用共享"一个门、一张表"。 上百个系统调用并没有上百个入口,它们全都挤在同一个入口 system_call 上,靠 EAX 里的编号分流。这个设计非常经济:内核只需要维护一张 sys_call_table 函数指针表,新增系统调用无非是"表里加一项、编号往后排"。用户态和内核态之间因此形成了一份极其稳定的契约------编号 + 寄存器约定。

第三,参数走寄存器,是特权级切换的必然结果。 我一开始天真地以为"参数当然走栈",直到看见 libc 的 push 和汇编版的 mov ebx/ecx/edx 并排出现,才明白:切换栈之后,用户栈对内核来说不再是"约定的调用帧"。于是必须另立一套规矩,把参数搬进寄存器。六个寄存器不够,就用内存块加指针------这不是随意规定,而是"寄存器数量"这个硬约束逼出来的工程折中。

第四,返回的那一刻并不平凡。 课程里那句"返回用户态时是进程调度的触发时机",我一开始没太在意。做实验时盯着 strace 的输出才反应过来:系统调用返回前,内核会检查是否有"待处理的工作"(信号、需要重新调度等)。也就是说,每一次 write、每一次 getpid,在把结果交还给你之前,都可能顺手换一个进程上去跑。系统调用不只是"用户请求服务",它同时也是内核介入调度、维持公平的天然窗口。

如果用一句话收尾:

libc 的 API 是"自动挡",int $0x80 是"手动挡"。踩下离合器那一刻(mov $20, %eax)、挂挡进门的动作(int $0x80),才是系统调用真正发生的地方。


十、小结

  • 同一个 getpid,用 libc API 与内嵌汇编两种方式调用,结果一致;strace 证明两者在内核眼中是同一个系统调用。
  • objdump 反汇编揭示差异全在用户态:libc 只是替我们写了 int $0x80 那两行汇编。
  • 系统调用分三层:用户态 API → 0x80 中断向量对应的内核入口 system_call → 具体的服务程序 sys_getpid。
  • 参数传递规则:调用号放 EAX,参数依次放 EBX/ECX/EDX/ESI/EDI/EBP,最多 6 个;超过 6 个用内存块指针。
  • 实验踩坑两条:strace 需单独安装;内嵌汇编必须算清"寄存器账","m" 约束不占寄存器。

最后再强调一句:AI 是辅助,不是代笔。 本文的实验思路、代码和每一步结论都经过我本人在 WSL 环境中实际编译、运行、反汇编验证;文中出现的每一张截图、每一个 PID、每一条 strace 输出,都可以按文中命令原样复现。技术这东西,看别人写一百遍,不如自己撞一次中断门。

作者:张智泓 原创作品,转载请注明出处 《Linux 内核分析》MOOC 课程 http://mooc.study.163.com/course/USTC-1000029000

相关推荐
木子n11 小时前
车载以太网与SOME/IP服务化通信实战-03.事件通知与丢包语义:三类丢包模型、AoI时效账与缺口检测阈值
嵌入式硬件·网络协议·车载系统·车载以太网·some/ip
qq_401700411 小时前
CAN 开源库大全(嵌入式/上位机,适配STM32、Linux、Qt,附带Github地址)
linux·stm32·开源
SKH.1 小时前
Linux驱动-I2C子系统驱动
嵌入式硬件
BT-BOX2 小时前
2026最新Keil 5软件下载安装详细教程含MDK和C51(附下载链接)
stm32·proteus
HRTOS2 小时前
HRTOS 4.0 实测:8051 中断响应延迟与中断嵌套性能测试
单片机·嵌入式硬件·51单片机
NendoZen2 小时前
万用板焊接LM2940实现5V电源模块
单片机·嵌入式硬件
FPGA小徐2 小时前
硬件面试真题:运放滞回比较器波形分析
单片机·嵌入式硬件·pcb
沐欣工作室_lvyiyi2 小时前
基于单片机的智能婴儿摇篮控制系统设计(论文+源码)
单片机·嵌入式硬件·物联网·数据库设计·物联网毕业设计·智能婴儿摇篮
霖霖7143 小时前
【ESP32笔记】ESP中的分区表
笔记·单片机·mcu·物联网