深入 main 函数 — 参数怎么进寄存器,局部变量怎么住进栈
深入 main 函数 — 参数怎么进寄存器,局部变量怎么住进栈
这一章讲什么: 你在 C 里写
write(STDOUT_FILENO, "Hello, World!\n", 14);, 这一行在机器层是「把三个数依次放进三个寄存器,然后 call 一个地址」。 本章把这一行拆开:参数怎么进寄存器、字符串的地址在程序被搬到内存任意位置后还算不算得对、 函数跑完怎么找回家、局部变量住在哪里。这四件事合起来就是「调用约定」的地基,第 14 章还会往上盖。 主线例子: 一个从键盘读一个字符再回显的程序,我们会跟着它从按键一路走到栈里那个字节的地址。
1. 顶层全景:一次函数调用在机器层是四步
先看整章的地图,再逐节走:
C 源码:write(STDOUT_FILENO, "Hello, World!\n", 14);
│
│ ① 参数进寄存器(第 2 节)
▼
rdi = 1 ← 写到哪儿(标准输出)
rsi = 字符串首地址
rdx = 14 ← 写几个字符
│
│ ② 字符串地址怎么算(第 3 节)
▼
lea rsi, message[rip] ← 「rip 所在地址 + 链接器填的偏移量」
│
│ ③ call 与返回地址(第 4 节)
▼
call write@plt ← 把「下一条指令的地址」压进栈,然后跳走
……write 干完活……
ret ← 从栈顶弹出那个地址,跳回去
│
│ ④ 局部变量住栈帧(第 5 节)
▼
sub rsp, 16 ← 栈指针下移 16 字节,腾出局部变量的房间
-9[rbp] ← 那个字符就住这里
图说:每一步都是后一步的前提。①②让被调函数拿到输入,③让它回得来,④让它有自己的地盘。2. 先绕开 printf:直接用 write 和 read
先看本章在全书链条里的位置。 第 10 章末尾我们已经能读懂编译器生成的汇编, 但看到的只是「一个孤零零的 main」。真实的程序全是函数调函数——本章就解决 「函数与函数之间怎么接头」:参数怎么递、结果怎么回、各家地盘怎么划。 这套接头规则一旦建立,第 13 章的控制流、第 14 章的变量传递、第 15 章的递归, 都只是往这套规则上挂新花样。
为什么绕开
之前章节用 C 标准库的 printf 和 scanf 做输入输出。它们方便,但内部做了很多事:
把内存里的二进制数转成字符、处理格式串、再调用更底层的函数1。
本章起,书里改用 write 和 read——这是操作系统直接提供的系统调用函数,中间没有翻译层,
在 gdb 里一步步跟的时候,看到的每一行都是我们自己写的代码。
系统调用(system call)这个词,提前解释一下:
操作系统不允许普通程序直接碰硬件(原因见第 21 章的特权级),
但它提供了一小批「打电话叫内核替你办事」的入口,write、read 就是其中最常用的两个。
C 标准库里的 printf 是"语言内的管家"——它帮你把数字格式化成字符,再替你打这个电话;
本章起我们绕过管家,自己打。绕过管家不是因为它不好,而是管家的动作太多,
会挡住你想看的东西。
三个现成的「文件」
Linux 把屏幕和键盘也当成文件。程序一启动,操作系统就替它打开三个文件, 各发一个整数当门牌号,这个整数叫文件描述符2:
| 名字 | 编号 | 连的是什么 |
|---|---|---|
STDIN_FILENO | 0 | 键盘 |
STDOUT_FILENO | 1 | 屏幕 |
STDERR_FILENO | 2 | 屏幕(专发错误消息) |
这两个函数的 C 形态是:
int write(int fd, char *buf, int n);
int read(int fd, char *buf, int n);fd 就是文件描述符,buf 是内存地址(要写的字符放哪/读到的字符存哪),n 是字符个数3。
主线例子的第一站
后面贯穿全章的程序清单 11-4 里,最核心的一句是:
read(STDIN_FILENO, &aLetter, 1);意思是:从键盘(描述符 0)读 1 个字符,存到局部变量 aLetter 的地址里。
「从键盘敲下的一个字符,最终变成一个字节,落在栈上某个具体地址」——这就是主线例子要走到的地方。
再补两个背景,免得出门撞见不认得:
- POSIX(Portable Operating System Interface,可移植操作系统接口)是操作系统该提供
哪些 C 接口的一套行业标准,
write/read的形态就由它指定3。你换一台遵循 POSIX 的 Unix 系机器(各种 Linux、macOS),这两行代码原样能跑。 - 想知道任何一个系统调用函数的详情,不用翻书:
man 2 write、man 2 read—— 手册第 2 节专讲系统调用,这是比任何教材都新的第一手文档3。
3. 参数怎么进寄存器:六个约定好的座位
一个简单粗暴的问题
x86-64 有 16 个通用寄存器。调用 write(fd, buf, n) 时,把三个参数放进哪三个寄存器?
理论上随便选都行——但只要调用方和被调方选的不一样,程序就崩。
书里有句话把「为什么需要标准」说得很直白:避免出错的最好方法就是制定标准并严格遵循,
如果写代码的不止一个人,这一点尤其重要——每个人各按各的习惯存参数,
程序会在最莫名其妙的地方翻车,而且翻车时汇编看着「每行都对」4。
所以这一行的人定了一套人人遵守的标准:System V ABI(应用二进制接口),
gcc 和我们手写的汇编都照它办事4。
六个座位的排法
前 6 个参数按顺序坐进这 6 个寄存器,第 7 个起才轮到栈(第 14 章讲)5:
| 第几个参数 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| 寄存器 | rdi | rsi | rdx | rcx | r8 | r9 |
返回值默认放 rax(第二个返回值放 rdx)。
表 11-2 还有一列「是否保存」,先记住它的含义:标「是」的寄存器(rbx、rsp、rbp、r12–r15), 被调函数如果要用,必须先存起来、用完再恢复;标「否」的随便用6。 这条是第 14 章「为什么函数开头常有 push r12」的原因,这里先记个名。
十六个寄存器,各有编制
表里其实把 16 个通用寄存器的「编制」全列了,值得整张望一眼6:
| 寄存器 | 干什么用 | 被调方要保存吗 |
|---|---|---|
| rax | 函数的第 1 个返回值 | 否 |
| rbx | 通用 | 是 |
| rcx | 第 4 个函数参数 | 否 |
| rdx | 第 3 个函数参数;第 2 个返回值 | 否 |
| rsp | 栈指针 | 是 |
| rbp | 可选的帧指针 | 是 |
| rdi | 第 1 个函数参数 | 否 |
| rsi | 第 2 个函数参数 | 否 |
| r8 | 第 5 个函数参数 | 否 |
| r9 | 第 6 个函数参数 | 否 |
| r10 | 函数的静态链指针(本书用不到) | 否 |
| r11 | 无特殊任务,临时用 | 否 |
| r12–r15 | 无特殊任务 | 是 |
这张表的两个看点:
- 参数、返回值占用了「不需要保存」的那一组——它们本来就是「用完即弃」的通道, 和「传完话就走」的语义正好匹配;而六个要保存的寄存器是「调用方的私有财产」, 被调方借之前得先打招呼。
- rsp 被排除在传参寄存器之外(原则上有 16 个通用寄存器可用,表 11-2 里唯独它不能传参): 因为 call 指令要靠它压返回地址,栈指针有且只有一份,谁都不能挪用7。
另一个书中顺带提醒的事实:Windows 的 C 环境只给 4 个寄存器传参7—— 同一个 C 程序,Linux 和 Windows 下的机器码不一样,根源就在这。
两条新指令:lea 与 call
清单 11-2 里还有两个没讲过的指令,书里用「指令卡片」的格式给出,照单接住8:
- lea(load effective address,加载有效地址):
lea reg, mem把 mem 的有效地址算出来, 放进 reg。注意它不做内存访问——只算地址,是个「只拿钥匙不开门」的指令; 而且不影响 rflags 的状态标志。 - call(过程调用):
call 函数名把下一条指令的地址压入栈,然后跳转到函数。 同样不影响状态标志。它的搭档 ret 做逆操作:弹出栈顶到 rip。 这对「压/弹」组合,就是第 5 节栈的全景里「③ call 与返回地址」的完整内容。
lea 最常干的活就是上面这种「给字符串算地址」;第 17 章会看到它另一个用途——
数组元素的寻址。call 与 rip 配合还有一层:ret 弹回的那个地址,
保证程序从 call 的下一行继续,因此 call 和 ret 之间的所有代码
(包括被调函数内部再 call 别人)天然形成嵌套,和括号配平是同一个结构。
主线例子的第二站:三个数落座
回头看 write(STDOUT_FILENO, "Hello, World!\n", 14);,编译出的汇编是(注释是作者加的)9:
mov edx, 14 ## number of chars
lea rsi, .LC0[rip] ## address of string
mov edi, 1 ## STDOUT_FILENO
call write@PLT注意存参的顺序:书里说,用汇编手写时通常从最后一个参数开始,从后往前存10—— 因为最后存进来的第 1 个参数,位置最「靠上」,和 C 的阅读顺序正好对上,不容易乱。 三个数落座:rdx = 14(写 14 个字符)、rsi = 字符串首地址、rdi = 1(标准输出)。 下一节解决一个问题:那个「字符串首地址」到底怎么算出来的?
4. 位置无关代码:程序被搬到内存哪里都能跑
问题从哪来
第 10 章说过,链接器负责给每个函数、每个数据项分配内存地址。 但更安全的做法是:让操作系统在加载时才决定程序放哪(可以防止一些攻击手段), 于是链接器不能写死绝对地址,只能写「从引用处到目标处的距离(偏移量)」。 这样的程序叫位置无关可执行文件,gcc 默认就生成它11。
这个「更安全」值得多说一句为什么:如果每个程序的地址在编译时就钉死, 攻击者就能照着可执行文件里的固定地址猜出「栈金丝雀存在哪」「函数跳到哪」, 写 exploit 像查地图;加载地址每次随机化,地图就废了。 书里只提了「更安全」三个字和防止攻击的方向,没有展开攻击面11—— 记住结论就行:位置无关不是你选的优化,是现代编译器默认的安全姿态。
rip 相对寻址:距离的尺子
距离需要一个「此刻站在哪」的参照点,这个参照点就是指令指针 rip—— 不管程序被加载到哪,rip 里永远是当前指令的地址。 所以汇编里出现这样的写法:
lea rsi, message[rip] # 读作:「rip 处的 message」lea(load effective address,加载有效地址)把「rip 当前的值 + 链接器算好的偏移量」
加在一起,结果(字符串的真实地址)放进 rsi8。
有一个细节容易错:CPU 取完指令会把 rip 更新成下一条指令的地址,
所以偏移量是从「下一条指令」起算的——链接器算距离时算的也是这个12。
字符串本身住在 .rodata 节(只读数据区),由操作系统加载成只读内存13。
write 不在我们的程序里:PLT 和 GOT
call write@PLT 里的 @PLT 又是什么?write 在 C 共享库里,不在我们写的源文件里,
链接器算不出它离我们多远。解决办法是多转一道手14:
- PLT(过程链接表):程序里一张小的跳板表,
call write@PLT就是跳到 1 号跳板; - GOT(全局偏移量表):另一张表,每个格子存一个外部函数的真实地址。
程序第一次调用 write 时,操作系统的动态加载器才把共享库里的 write 真正载进内存,
把它的地址填进 GOT,PLT 跳板以后就照着 GOT 直接跳,不用再麻烦加载器15。
结论:你平时用的 C 库函数,地址是在程序跑起来之后才确定的——编译时根本没人知道 write 在哪。
主线例子的第三站:现在只剩「跳过去、跳回来」
参数已在寄存器里坐好,字符串地址也算出来了。
call write@PLT 做两件事:把下一条指令的地址压进栈,然后把目标地址放进 rip。
write 干完活,最后一条 ret 从栈顶弹出那个地址放回 rip——程序接着 call 的下一行继续跑。
压进去、弹出来,同一个地址。栈在这里第一次登场,下一节让它唱主角。
5. 调用栈:一块「向下生长」的内存
栈是什么
栈是内存里划出的一块连续区域,加一个叫栈指针的寄存器(rsp)指着它的顶。
规矩只有两条:push 放一个数据上去,pop 拿走最上面那个。
书里用一摞餐盘打比方:你只能从最上面放盘子、从最上面取盘子,
所以栈也叫后进先出(LIFO)结构16。
使用纪律(书里的三条,加上括号匹配那条)值得原样记住:
先压后弹;弹出的不许比压入的多;函数结束前弹干净。
push 像左括号,pop 像右括号,必须成对匹配;
压太多叫栈溢出,从空栈往外弹叫栈下溢17。
为什么是「满递减」:从高地址往低地址长
栈其实有四种实现方式,两个维度各两种:增长方向(向高地址 = 递增,向低地址 = 递减) 和栈指针指哪(指着栈顶数据本身 = 满,指着下一个要入栈的空位 = 空)。 x86-64 选了满递减:向低地址增长,rsp 指着栈顶那个数据本身18。 书里的图 11-3 拿「依次压入 1、2、3」演示四种实现的区别:同样是三个数, 栈指针指的位置、数据排的朝向各不相同,取数的算法也跟着变18。 理由很朴素:程序指令从低地址放起,大小又不固定,把栈放在内存高地址、往下长, 两边各长各的,最不容易打架19。
餐盘规则的三个例外条款
书里在使用纪律之外还点了几件事,一并记下:
- 如果不需要弹出数据,可以直接挪栈指针把它「丢」掉,相当于快速清盘17;
- 栈顶以外的数据经常会被「直接访问」——不是非得 pop 出来才碰得到, 前提是访问方式和栈指针的移动都受控。这一句是下一节「局部变量住在栈里却不用 push/pop」的预告20;
- gdb 里查看内存时地址是向下递增显示的(高地址在上),和你平时画的图可能上下相反,别慌21。
.equ:把「1」起名叫 STDOUT,让汇编器替你算长度
清单 11-3(汇编版 "Hello, World!")里还有两个我们没见过的汇编器指令,都是「写给汇编器看的」, 不产生机器码:
.equ STDOUT, 1 # 此后 STDOUT 就是 1 的别名
.equ msgLength, .-message-1 # 字符串长度让汇编器算第一条的意思是:把符号 STDOUT 定义为表达式 1 的值,代码里写 mov edi, STDOUT,
汇编器翻译时直接替换成 1——起名字不是为了机器,是为了读代码的人22。
第二条更妙:表达式里的 . 代表「当前这行的内存地址」,所以 . - message - 1 就是
「字符串末尾地址 − 字符串开头地址 − 1(NUL 终止字符)」= 可打印字符数。
长度不用你数,也不会数错——改字符串时程序自动跟着对。23
函数序言与结语:一进一出的两段固定动作
每个函数开头几行几乎是抄出来的模板,叫函数序言:
push rbp # 把调用方的帧指针存进栈
mov rbp, rsp # 把帧指针挪到当前栈顶结尾对应的两行(或一行 leave)叫函数结语,把这两个动作原样撤销24。
帧指针 rbp 的作用在这一章还看不出来,下一节见分晓:它是栈帧里所有局部变量的「地址原点」。
用 gdb 亲眼看一遍(主线例子的第四站)
书里用 gdb 对汇编版 "Hello, World!" 单步,我们把关键几帧拿出来走:
(gdb) i r rbp rsp
rbp 0x0 0x0
rsp 0x7fffffffde88 0x7fffffffde88 ← 此刻栈顶在这(里面是返回地址)
(gdb) x/2xg 0x7fffffffde80 ← 往栈顶下面看 16 字节
0x7fffffffde80: 0x0000555555555160 0x00007ffff7de70b3
↑ 这块将被占用 ↑ 返回地址(C 运行时环境的 call 留下的)
(gdb) si ← 执行 push rbp
(gdb) si ← 执行 mov rbp, rsp
(gdb) i r rbp rsp
rbp 0x7fffffffde80 0x7fffffffde80
rsp 0x7fffffffde80 0x7fffffffde80 ← rsp 减了 8,rbp 跟着指向新栈顶
(gdb) x/2xg 0x7fffffffde80
0x7fffffffde80: 0x0000000000000000 0x00007ffff7de70b3
↑ rbp 的旧值(0)被压进来了,返回地址纹丝没动注意一个事实:压栈没有「搬动」任何已有数据,只是把 rsp 往下挪、在新位置写一个值。 旧的返回地址还在原处,这是函数能找回家的全部依靠。
再看 call 之前的一帧——参数落座的样子,书里的 gdb 会话原样摘录:
(gdb) i r rdx rsi rdi
rdx 0xe 14 ← 第 3 个参数:14 个字符
rsi 0x555555556004 93824992239620 ← 第 2 个参数:字符串首地址
rdi 0x1 1 ← 第 1 个参数:标准输出不放心 rsi 里那个地址?直接看内存。C 风格字符串以 NUL(值 0)结尾,看 15 个字节:
(gdb) x/15c 0x555555556004
0x555555556004: 72 'H' 101 'e' 108 'l' 108 'l' 111 'o' 44 ',' 32 ' ' 87 'W'
0x55555555600c: 111 'o' 114 'r' 108 'l' 100 'd' 33 '!' 10 '\n' 0 '\000'这就是第 2 章 ASCII 表的实战现场: H 是 72、e 是 101、换行是 10,最后一个 0 是没人教过你的
「字符串到此为止」标记。所谓「传一个字符串给 write」,传到手的其实只是一个地址,
0x55555555600425。
继续走到 ret:栈顶那个 0x00007ffff7de70b3 被弹进 rip,gdb 显示我们回到了
__libc_start_main——这就是「main 也是被调用的」的直接证据:是 C 运行时环境里的启动函数
call 了 main,main 的「调用方」不是我们写的任何代码25。
6. 局部变量:把栈指针再往下挪一点
一个字符也要占 16 字节的房间
清单 11-4 的程序只有一个局部变量 char aLetter;——一个字节。
但 gcc 生成的序言里,在 push rbp / mov rbp, rsp 之后还有一行:
sub rsp, 16 # 栈指针下移 16 字节为什么 1 字节的变量要配 16 字节?因为 x86-64 有一组 128 位寄存器 (浮点和向量指令用,第 18 章细讲),这些指令要求 rsp 停在 16 字节的边界上, 协议索性规定栈指针永远保持 16 字节对齐,比到处临时对齐省心26。
为什么不用 push/pop 来管变量
直觉上,既然栈能存数据,局部变量直接 push 进去不就行了? 书里点破了这么做的后果:栈的规则只允许你碰栈顶,变量要是只能从栈顶进出, 那么函数里再 call 一次别的函数、往栈里压一点东西,你的变量位置就全乱了—— 跟踪起来「即使有办法,也会很快变得棘手」27。
所以实际做法是另一套:在序言里一次性把栈指针挪好,留出整片空间, 之后用「帧指针 + 偏移量」在这片空间里随便指,不再依赖进出顺序。 push/pop 退居二线,只管返回地址、保存寄存器这类「天然成对进出」的事务。 这个区分值得记牢:栈帧的内存是「批发出租」的(一次 sub 到位), push/pop 只是「零售窗口」——两者共用同一块内存,记账方式完全不同。
栈帧从此有了形状:rbp 指着帧底(存着调用方的 rbp),rbp 之下是栈金丝雀、 局部变量,rsp 指着帧顶。多出来的字节不是浪费,是对齐的代价。 按清单 11-5 的画法,echoChar 的栈帧长这样(地址上高下低,和 gdb 的显示方向一致):
高地址
┌─────────────────────┐
│ 返回地址 │ ← call 压进来的,main 的「回家凭据」
├─────────────────────┤
│ 调用方的 rbp │ ← push rbp 压进来的
├─────────────────────┤ ← rbp 指着这里(0x7fffffffdeXX 那一带)
│ 栈金丝雀 │ ← -8[rbp],fs:40 的随机数副本
├─────────────────────┤
│ 未用(对齐填充) │
├─────────────────────┤
│ aLetter(1 字节) │ ← -9[rbp],你敲的那个字符就住这
├─────────────────────┤
│ 未用 │ ← rsp 指着这下面(16 字节对齐)
└─────────────────────┘
低地址
图说:一个 1 字节的变量,住着 16 字节的「公寓」;金丝雀站在
局部变量和「返回地址 + 调用方 rbp」之间,谁想改写回家凭据,必先惊动它。注意金丝雀的位置为什么选在那儿:攻击者想篡改返回地址,溢出的字节得从局部变量一路 往上漫过 saved rbp——金丝雀夹在必经之路上,先被踩响28。
顺带认识 add、sub、neg 三兄弟
sub rsp, 16 里的 sub(subtract,减法)属于一个三兄弟家族,形态都一样:
「目标 操作 源,结果存回目标」29:
add a, b—— a = a + b(加法)sub a, b—— a = a − b(减法)neg a—— a = −a(取反)
三条指令都会按结果设置 rflags 寄存器里的状态标志(OF、SF、ZF、AF、PF、CF)。 「设置状态标志」是什么意思、谁读它,第 13 章讲条件跳转时会变成主角,现在只需知道: 这些标志是「上一次运算的化验单」,后面的指令可以照单抓药。
变量的地址:帧指针 + 一个负数
现在帧指针的用处显出来了。局部变量没有名字(机器码里没有 aLetter 这三个字母),
它只有一个地址:rbp 的值加一个负的偏移量。编译器把这个字符安排在了 -9[rbp]——
栈帧 16 字节里的第 9 字节30。
读字符的那句 read(STDIN_FILENO, &aLetter, 1); 在汇编里是:
lea rax, -9[rbp] ## 算出 aLetter 的地址
mov rsi, rax ## 作为第 2 个参数
mov edi, 0 ## STDIN
call read@PLT你可能会问:一个 16 字节的房间,为什么把字符放在第 9 字节,而不是贴着 rbp 的第 1 字节?
看上面的栈帧图就明白了:金丝雀占了 -8[rbp] 开始的 8 个字节(它是 64 位随机数),
剩下能自由支配的是 −16 到 −9 这一段,字符住进 −9——这不是随意摆放,
是「金丝雀优先、变量随后」的固定次序,和你房间先放衣柜再放床是同一个道理30。
主线例子的最后一站,完整走一遍: 你在键盘上敲下 a →
read 从键盘拿到这个字符 → 因为 rsi 里放着 -9[rbp] 算出的地址,
这个字节(0x61)被写进栈帧的那个格子 → 程序再执行 write(STDOUT_FILENO, &aLetter, 1),
同一个地址被交给 write → 屏幕上的 a,就是从栈里那个格子原样搬出来的31。
从你敲键到字符落地,中间没有任何「变量名」,只有两个寄存器里的一串地址和三次函数调用。
栈金丝雀:编译器替你加的一道安检
清单 11-5 里有几行我们没写过的代码:
mov rax, QWORD PTR fs:40 # 取一个只有内核知道的随机数
mov QWORD PTR -8[rbp], rax # 存进栈帧
……函数体……
mov rcx, QWORD PTR -8[rbp] # 取出来
xor rcx, QWORD PTR fs:40 # 和原件比对
je .L3 # 一样就放行
call __stack_chk_fail@PLT # 不一样,说明栈被改写过,直接终止程序这个随机数叫栈金丝雀(stack canary)。逻辑一句话:函数存进栈的「返回地址」和「调用方的 rbp」
是程序能不能正常回家的命根子,如果它们被改(典型场景:输入超长,溢出的字节漫过局部变量,
把返回地址覆盖了),这个夹在中间的随机数也必然面目全非——
返回前查一遍,对不上就拉响警报28。金丝雀这名字来自 17 世纪英国矿工带下井测瓦斯的小鸟32。
可以用 -fno-stack-protector 关掉它,但没必要33。
QWORD PTR:当汇编器猜不出数据有多大
清单 11-5 里有几行奇怪记法:mov rax, QWORD PTR fs:40。
平常汇编器能凭寄存器名字猜出操作数的大小(rax = 64 位、eax = 32 位……),
但当一个操作数是内存地址、另一个是普通整数时,它猜不出来——
mov -8[rbp], 123 里的 123,存 1 字节还是 8 字节?所以要用指针类型前缀明说34:
| 记法 | 大小 | 位数 |
|---|---|---|
byte ptr | 字节 | 8 |
word ptr | 字 | 16 |
dword ptr | 双字 | 32 |
qword ptr | 四字 | 64 |
(qword 的 q 是 quad,四——四个 16 位的字,即 64 位。)
其实凭 rax 就能推出是 64 位,这里的 QWORD PTR 属于编译器多写的保险,作者自己也说「多写点也没坏处」35。
这条是本章的"纪律税":遵守它没有奖励,违反它必崩溃。 栈协议的全部价值 都体现在违反它的代价上——压错一个字节,返回地址就是垃圾,rip 跳过去, gdb 里看到的现象千奇百怪,根源却都是同一处。书里那句「必须严格遵守指定函数 之间如何交互的协议,否则程序容易崩溃」说得轻,实际调试过的都知道: 这是全书少有的"零容错"条款。36
主线例子的暗线:helloWorld 与 echoChar 用的是同一副骨架
把本章两个程序叠在一起看,会发现它们共享同一副骨架——这也是作者的教学设计:
helloWorld.s:序言 → 3 个参数进寄存器 → call write@PLT → 结语
echoChar.s: 序言 → 开 16 字节房间 → call read@PLT → call write@PLT → 结语
└── 多出来的就这两样:房间 + 金丝雀 ──┘换言之,echoChar = helloWorld + 局部变量 + 安全检查——新概念永远只比 旧程序多一点点。遇到任何新机制,先问"它比上一个程序多了哪几行", 是读这本书(以及读任何代码)的最短路径。
不用 C 运行时环境,也行
本章所有程序都站在 C 运行时环境的肩膀上:main 是被它的启动函数调起来的,
write/read 是它链接进来的。书里明确说,也可以写完全独立的程序,
不依赖这一切、直接用系统调用——做法放在第 20 章(输入/输出)37。
之所以不在这里做,是因为本章的目的是看清「高级语言运行时底下发生了什么」,
有脚手架看得更清楚。
把清单 11-6 从头到尾读一遍(主线例子的完整复盘)
主线例子的最后一步:抛开 C,用纯汇编把 echoChar 重写一遍,逐段读:
.intel_syntax noprefix # 全篇用 Intel 语法(第 10 章讲过它和 AT&T 的差异)
.equ STDIN,0 / .equ STDOUT,1 # 起名字:0 是键盘,1 是屏幕
.section .rodata # 常量数据住只读区
prompt: .string "Enter one character: "
.equ promptSz, .-prompt-1 # 提示语长度,汇编器算
.text # 指令住文本区
main:
push rbp # ① 序言:存调用方的帧指针
mov rbp, rsp # ② 序言:rbp 指向当前栈顶
add rsp, localSize # ③ localSize = -16,栈指针下移 16 字节开房间
mov rax, fs:40 # ④ 取金丝雀
mov -8[rbp], rax # ⑤ 存进栈帧
mov edx, promptSz # 参数3:提示语长度
lea rsi, prompt[rip] # 参数2:提示语地址(rip 相对)
mov edi, STDOUT # 参数1:屏幕
call write@plt # 打印 "Enter one character: "
mov edx, 1 # 参数3:读 1 个字符
lea rsi, aLetter[rbp] # 参数2:目的地 = 栈帧里的 -9[rbp]
mov edi, STDIN # 参数1:键盘
call read@plt # 等你按键……
……(打印 "You entered: ",再把这个字符 write 出去)……
mov rcx, -8[rbp] # ⑥ 取回金丝雀
xor rcx, fs:40 # ⑦ 和原件比对
je goodCanary # ⑧ 一致就放行
call __stack_chk_fail@PLT # ⑨ 不一致,报栈损坏并终止
goodCanary:
mov rsp, rbp # ⑩ 撤房间(等价于 leave 的前半)
pop rbp # ⑪ 恢复调用方的帧指针
ret # ⑫ 弹出返回地址,回家每一行都和前面某一节对应:①②是序言(第 5 节),③是开房间(第 6 节),
④–⑨是金丝雀安检(第 6 节),⑩⑪是结语——作者特意没用 leave 指令,
而是手动写这两行,「以此强调背后发生的具体操作」38。
整条主线例子到此闭环:敲一个字符,它穿过 read 落进 -9[rbp],
再从同一个格子被 write 搬上屏幕——中间每一次「传参」都只是三个寄存器在搬家。
顺带认识两条指令:xor 按位异或(相同得 0,所以拿它「比对两个值是否相等」);
je 在零标志 ZF=1 时跳转——第 13 章会系统讲条件跳转,这里只用一次39。
还有 leave,等价于 mov rsp, rbp + pop rbp 两行,一键拆除整个栈帧40。
7. 作者的判断与证据
三个容易翻车的地方,书里都埋了答案
翻车点一:echoChar 练手题里的"多出来的命令行提示符"。
11.6 的练习故意让程序输出末尾缺换行:屏幕上 $ 提示符紧跟在回显字符后面,
再多一行孤零零的 $41。修法是补一个 write(STDOUT_FILENO, "\n", 1)——
但作者紧接着问「看看你的修改会不会导致错误?」:答案是会的,
如果你顺手把输出缓冲/对齐搞坏。这个练习在教一件比答案重要的事:
I/O 的每个字节都是你自己发的,系统不会替你补。
翻车点二:theMessage 练习里的越界伏笔。
练习 3 让你把 theMessage 里的字符串从 "Hello.\n" 改成 "Greetings.\n"——
字符串变长了,存储它的数组如果没跟着改大,就会写出界41。
这是第 11 章的栈金丝雀第一次真正上岗的机会:写出界的字节会踩到金丝雀,
__stack_chk_fail 当场报警。两个练习连起来看,就是"没护栏的代码 → 有护栏的代码"的
对照实验。
翻车点三:gdb 里 b main 停在哪,各版本不一样。
书里坦白:用标签下断点,有的 gdb 版本停在序言之前、有的执行完序言才停42。
稳妥做法是用行号或单步确认。这是所有"以工具行为为准"的教训的缩影:
手册写的是语义,你机器上跑的是某个具体版本。
翻车点四(本章最大的暗礁):练习 2 的"修好了吗?"
11.6 练习 2 让你把多余的命令行提示符修掉,再追问「看看你的修改会不会导致错误?
如果出错,需要怎么修正?」——答案是:简单补一个换行 write 就好,但很多人会顺手
把 write 的参数顺序写反(长度与地址对调),或者忘了 mov eax, 0 之类的约定,
而错误要到运行时、在屏幕输出的形状上才显形。这类"协议型错误"的共同点是:
编译全绿,gdb 单步全对,唯独结果不对——它们只能靠"对照协议逐格核对"来防,
这也是本章反复强调画栈帧图、核对寄存器的根本原因。
| 说法 | 性质 |
|---|---|
| 前 6 个参数走 rdi/rsi/rdx/rcx/r8/r9、返回值走 rax | 书外事实:System V ABI 文档明写,书里引了原文4 |
| gcc 默认生成位置无关代码、默认启用栈金丝雀 | 作者在自己 Ubuntu 20.04 + gcc 9.3.0 上观察所得,属「该版本默认行为」1133 |
| 「栈从内存高地址往下长,和指令互不干扰」 | 作者的简化模型,明说是「高度简化」,真实内存布局复杂得多19 |
| 栈指针 16 字节对齐 | 协议规定(为 128 位寄存器的指令服务)26 |
| 「必须严格遵守交互协议,否则程序容易崩溃」 | 作者的经验判断,没有给出反例实验36 |
判断(我们的,不是书里的): 这一章最值得带走的不是任何一条规则, 而是「规则先于实现」这个次序——System V ABI 是人写给人遵守的约定, 硬件根本不关心参数在 rdi 还是 rsi。所谓「机器级编程」,一半是在给 CPU 下指令, 另一半是在给「下一个读这段代码的人(或编译器)」留路标。 你平时写 C 不觉得, 是因为编译器替你守了全部规则。 如果错,会错在: 如果只用一个人写、一个人读的一次性代码,遵守协议的收益确实不大, 这条判断会把「约定」的价值夸大——但只要是两个人以上的协作,或者代码要交给 另一个工具(链接器、调试器)读,协议立刻从美德变成前提。
判断(我们的,不是书里的): 用系统调用函数(write/read)而不是 printf 当教学入口, 是本章最聪明的决定。printf 的便利恰恰是一堵墙:格式化、缓冲、locale…… 全藏在墙后面,初学者看到的墙皮,会误以为是机器本身。 如果错,会错在: 真实工程里没人绕过 printf 家族,从系统调用学起的人 需要自己再补一层「库函数到底加了什么」的认识——好在第 2 章其实已经给了这一层。
8. 边界与局限
- 只讲了六个参数以内的世界。 第 7 个参数起走栈、被调方怎么把它们搬回寄存器,第 14 章才讲。
- 栈金丝雀只防「返回地址被改写」这一类事故,对不越界但算错的程序无能为力——它是安全网,不是正确性保证。
- 书里栈帧的讲法是「rbp 当地址原点」的经典模型。 开优化(
-O1以上)后,编译器常常放弃帧指针, 局部变量改由 rsp 寻址,gdb 里看到的会是另一副样子。书全程用-O0,口径一致但不代表编译器真实产出的全部形态。 - write/read 是 Linux 系统调用,Windows 下不存在——本章所有汇编只在遵循 System V ABI 的系统上成立。
- 「调用方压栈、被调方弹栈」的配对纪律,本章只用到了 call/ret 与序言结语这几对; 更多参数、被调方保存寄存器的完整规则在第 14 章,本章刻意只开了个头。
- 栈金丝雀只在「编译器选择生成它」时存在。 换编译器、换版本、换选项,你眼前的汇编可能就没有 那几行安检代码——它是编译器的安全网,不是指令集的组成部分。这也是看别人的汇编输出时 「同样的 C 代码,汇编不一样」的常见原因之一(另一个原因是优化级别,上面那条说过)。
- 书中 gdb 会话里的地址(0x7fffffffde88 之类)是作者机器上的实况,你跑出来的地址一定不同; 值得对的不是地址值,是「rsp 比 rbp 低 8」「压栈后旧值原处不动」这些关系25。
9. 可带走的
write(1, buf, n)/read(0, buf, n)是绕过 C 库的系统调用:键盘是文件 0,屏幕是文件 1、2;- 前 6 个参数按 rdi、rsi、rdx、rcx、r8、r9 的顺序落座,返回值在 rax;
- 「是否保存」列标「是」的寄存器,被调函数用前必须先存后取;
lea x[rip]是位置无关代码的核心:rip + 链接器填的偏移 = 真实地址;- 共享库函数的地址运行时才定,
call xxx@PLT经 PLT/GOT 两表转跳; call把下一条指令地址压栈、ret把它弹回 rip——返回地址是函数回家的唯一凭据;- x86-64 是满递减栈,从高地址往低地址长;栈溢出=压太多,栈下溢=从空栈弹;
- 函数序言 =
push rbp; mov rbp, rsp,结语 = 逆操作;局部变量 = rbp 加负偏移; - 哪怕只用一个 1 字节变量,栈帧也按 16 字节对齐开间(128 位寄存器指令的要求);
- 栈金丝雀是内核持有的随机数,函数进出时各查一次,专抓「栈被改写」;
.equ给常数起名字、还能让汇编器用.算表达式(如字符串长度 = 末尾 − 开头 − 1);- gdb 三板斧:
b设断点、i r看寄存器、x/看内存——本章的每个结论都是这么验出来的; - 「传一个字符串」传给函数的只是个地址;NUL(0)是字符串的句号,
x/nc能看见它25。
10. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| write/read、文件描述符 0/1/2 | 第11章 深入main函数 | text/17-ch11-11-main.txt:30(搜「文件描述符」) · text/17-ch11-11-main.txt:50(搜「STDIN_FILENO」) |
| System V ABI、6 个寄存器传参 | 第11章 深入main函数 | text/17-ch11-11-main.txt:86(搜「System V」) · text/17-ch11-11-main.txt:96(搜「通用寄存器用途」) |
| 从后往前存参数 | 第11章 深入main函数 | text/17-ch11-11-main.txt:150(搜「从后向前」) |
| 位置无关代码、rip 相对寻址 | 第11章 深入main函数 | text/17-ch11-11-main.txt:157(搜「位置无关代码」) · text/17-ch11-11-main.txt:174(搜「加载有效地址」) |
| PLT 与 GOT | 第11章 深入main函数 | text/17-ch11-11-main.txt:192(搜「过程链接表」) · text/17-ch11-11-main.txt:196(搜「动态加载器」) |
| 栈、LIFO、三条纪律 | 第11章 深入main函数 | text/17-ch11-11-main.txt:209(搜「栈是一种」) · text/17-ch11-11-main.txt:218(搜「后进先出」) · text/17-ch11-11-main.txt:231(搜「严格的使用规则」) |
| 满递减栈、从高地址增长 | 第11章 深入main函数 | text/17-ch11-11-main.txt:251(搜「满递减」) · text/17-ch11-11-main.txt:257(搜「动态结构」) |
| gdb 跟踪序言/结语 | 第11章 深入main函数 | text/17-ch11-11-main.txt:338(搜「i r rbp rsp」) · text/17-ch11-11-main.txt:410(搜「弹出到 rip」) |
| sub rsp 16、16 字节对齐 | 第11章 深入main函数 | text/17-ch11-11-main.txt:492(搜「sub」) · text/17-ch11-11-main.txt:526(搜「16 字节」) |
| 栈金丝雀 | 第11章 深入main函数 | text/17-ch11-11-main.txt:559(搜「栈金丝雀」) · text/17-ch11-11-main.txt:566(搜「fs:40」) |
| 主线例子 echoChar 的汇编 | 第11章 深入main函数 | text/17-ch11-11-main.txt:649(搜「直接使用汇编语言编写的单字符回显程序」) |
| .equ 与表达式算长度 | 第11章 深入main函数 | text/17-ch11-11-main.txt:307(搜「.equ symbol」) · text/17-ch11-11-main.txt:314(搜「代表此处的内存地址」) |
| add / sub / neg | 第11章 深入main函数 | text/17-ch11-11-main.txt:530(搜「sub —— 减法」) |
| QWORD PTR 大小记法 | 第11章 深入main函数 | text/17-ch11-11-main.txt:615(搜「额外的记法」) · text/17-ch11-11-main.txt:627(搜「数据项大小记法」) |
| 不用 C 运行时的独立程序 | 第11章 深入main函数 | text/17-ch11-11-main.txt:717(搜「不使用 C 运行时环境」) |
Footnotes
-
出处:「第11章 深入main函数」第 27 段(
text/17-ch11-11-main.txt:27,搜「printf 将数据」)。原文:printf「将数据由内存存储格式转换为字符格式,并调用 write 系统调用函数」。 ↩ -
出处:「第11章 深入main函数」第 30–31 段(
text/17-ch11-11-main.txt:29,搜「视为文件」;text/17-ch11-11-main.txt:30,搜「文件描述符」)。 ↩ -
出处:「第11章 深入main函数」第 36–37 段(
text/17-ch11-11-main.txt:36,搜「int write」;text/17-ch11-11-main.txt:29,搜「Linux 将屏幕和键盘」)。 ↩ ↩2 ↩3 -
出处:「第11章 深入main函数」第 86–92 段(
text/17-ch11-11-main.txt:86,搜「Application Binary Interface」)。原文给出的全称是 System V Application Binary Interface AMD64 Architecture Processor Supplement,并说明 gcc 与我们手写的汇编都遵循它。 ↩ ↩2 ↩3 -
出处:「第11章 深入main函数」第 118 段(
text/17-ch11-11-main.txt:118,搜「前 6 个参数」)。 ↩ -
出处:「第11章 深入main函数」第 96–117 段(
text/17-ch11-11-main.txt:96,搜「通用寄存器用途」)与第 116 段(text/17-ch11-11-main.txt:116,搜「是否保存」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 60–61 段(
text/17-ch11-11-main.txt:60,搜「6 个函数参数」;text/17-ch11-11-main.txt:61,搜「4 个参数」)。rsp 不能用于传参见第 80–82 段(text/17-ch11-11-main.txt:80,搜「write 接受 3 个」;text/17-ch11-11-main.txt:81,搜「不能使用 rsp」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 174–177 段(
text/17-ch11-11-main.txt:174,搜「加载有效地址」;text/17-ch11-11-main.txt:182,搜「LC0」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 126–152 段(
text/17-ch11-11-main.txt:139,搜「number of chars」;text/17-ch11-11-main.txt:298,搜「call write@PLT」)。注释以 ## 开头是作者自己加的,原文第 121 段(text/17-ch11-11-main.txt:121,搜「##开头」)有说明。 ↩ -
出处:「第11章 深入main函数」第 150 段(
text/17-ch11-11-main.txt:150,搜「从后向前」)。 ↩ -
出处:「第11章 深入main函数」第 162–164 段(
text/17-ch11-11-main.txt:163,搜「任何内存位置」;text/17-ch11-11-main.txt:164,搜「默认生成位置无关代码」)。 ↩ ↩2 ↩3 -
出处:「第11章 深入main函数」第 185–188 段(
text/17-ch11-11-main.txt:185,搜「紧随 lea 指令之后」)。 ↩ -
出处:「第11章 深入main函数」第 189–190 段(
text/17-ch11-11-main.txt:189,搜「rodata 节」)。 ↩ -
出处:「第11章 深入main函数」第 191–195 段(
text/17-ch11-11-main.txt:192,搜「PLT 代表」;text/17-ch11-11-main.txt:194,搜「全局偏移量表」)。 ↩ -
出处:「第11章 深入main函数」第 196–200 段(
text/17-ch11-11-main.txt:196,搜「第一次调用 write」)。 ↩ -
出处:「第11章 深入main函数」第 209–218 段(
text/17-ch11-11-main.txt:212,搜「一摞餐盘」;text/17-ch11-11-main.txt:218,搜「后进先出」)。 ↩ -
出处:「第11章 深入main函数」第 231–240 段(
text/17-ch11-11-main.txt:231,搜「严格的使用规则」;text/17-ch11-11-main.txt:239,搜「栈溢出」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 244–251 段(
text/17-ch11-11-main.txt:251,搜「满递减栈」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 257–261 段(
text/17-ch11-11-main.txt:259,搜「向下」;text/17-ch11-11-main.txt:259,搜「高度简化」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 241–243 段(
text/17-ch11-11-main.txt:241,搜「其他操作」;text/17-ch11-11-main.txt:243,搜「直接访问」)。 ↩ -
出处:「第11章 深入main函数」第 250 段(
text/17-ch11-11-main.txt:250,搜「向下递增」)。 ↩ -
出处:「第11章 深入main函数」第 305–313 段(
text/17-ch11-11-main.txt:307,搜「.equ symbol」;text/17-ch11-11-main.txt:313,搜「设置为整数 1」)。 ↩ -
出处:「第11章 深入main函数」第 314–318 段(
text/17-ch11-11-main.txt:314,搜「代表此处的内存地址」;text/17-ch11-11-main.txt:317,搜「终止字符 NUL」)。 ↩ -
出处:「第11章 深入main函数」第 319 段(
text/17-ch11-11-main.txt:319,搜「帧指针」)。 ↩ -
出处:「第11章 深入main函数」第 410–424 段(
text/17-ch11-11-main.txt:414,搜「__libc_start_main」;text/17-ch11-11-main.txt:410,搜「弹出到 rip」)。 ↩ ↩2 ↩3 ↩4 -
出处:「第11章 深入main函数」第 525–528 段(
text/17-ch11-11-main.txt:526,搜「128 位寄存器」;text/17-ch11-11-main.txt:527,搜「16 字节」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 446–451 段(
text/17-ch11-11-main.txt:446,搜「局限在入栈和出栈」;text/17-ch11-11-main.txt:448,搜「移动栈指针」;text/17-ch11-11-main.txt:451,搜「参考点」)。 ↩ -
出处:「第11章 深入main函数」第 559–568 段(
text/17-ch11-11-main.txt:559,搜「栈损坏」;text/17-ch11-11-main.txt:566,搜「fs:40」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 529–551 段(
text/17-ch11-11-main.txt:530,搜「sub —— 减法」;text/17-ch11-11-main.txt:538,搜「add —— 加法」;text/17-ch11-11-main.txt:546,搜「neg —— 取反」)。 ↩ -
出处:「第11章 深入main函数」第 500–504 段(
text/17-ch11-11-main.txt:500,搜「lea」;text/17-ch11-11-main.txt:554,搜「第 9 字节」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 649–709 段(
text/17-ch11-11-main.txt:686,搜「aLetter[rbp]」)——主线例子的汇编实现;read/write 的参数含义见第 36–39 段(text/17-ch11-11-main.txt:36,搜「int write」)。 ↩ -
出处:「第11章 深入main函数」第 598–602 段(
text/17-ch11-11-main.txt:598,搜「17 世纪的英国矿井工人」)。 ↩ -
出处:「第11章 深入main函数」第 570–572 段(
text/17-ch11-11-main.txt:570,搜「默认启用」;text/17-ch11-11-main.txt:572,搜「fno-stack-protector」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 615–633 段(
text/17-ch11-11-main.txt:616,搜「推算出操作数的大小」;text/17-ch11-11-main.txt:627,搜「数据项大小记法」)。 ↩ -
出处:「第11章 深入main函数」第 643–644 段(
text/17-ch11-11-main.txt:644,搜「没坏处」)。 ↩ -
出处:「第11章 深入main函数」第 425 段(
text/17-ch11-11-main.txt:425,搜「必须严格遵守」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 717–721 段(
text/17-ch11-11-main.txt:717,搜「不使用 C 运行时环境」;text/17-ch11-11-main.txt:721,搜「独立程序」)。 ↩ -
出处:「第11章 深入main函数」第 711–713 段(
text/17-ch11-11-main.txt:711,搜「偏移量命名」;text/17-ch11-11-main.txt:712,搜「读性。在撤销栈帧时,我没」)。 ↩ -
出处:「第11章 深入main函数」第 576–590 段(
text/17-ch11-11-main.txt:576,搜「按位异或」;text/17-ch11-11-main.txt:585,搜「如果相等则跳转」)。 ↩ -
出处:「第11章 深入main函数」第 604–614 段(
text/17-ch11-11-main.txt:606,搜「删除栈帧」;text/17-ch11-11-main.txt:610,搜「mov rsp, rbp」)。 ↩ -
出处:「第11章 深入main函数」第 723–763 段(动手实践,
text/17-ch11-11-main.txt:724,搜「多出一个命令行提示符」;text/17-ch11-11-main.txt:763,搜「Greetings」)。 ↩ ↩2 -
出处:「第11章 深入main函数」第 327–329 段(
text/17-ch11-11-main.txt:327,搜「行号」;text/17-ch11-11-main.txt:329,搜「不同的行为」)。 ↩