编程语言研究书库

深入 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_FILENO0键盘
STDOUT_FILENO1屏幕
STDERR_FILENO2屏幕(专发错误消息)

这两个函数的 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:

第几个参数123456
寄存器rdirsirdxrcxr8r9

返回值默认放 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. 可带走的

  1. write(1, buf, n) / read(0, buf, n) 是绕过 C 库的系统调用:键盘是文件 0,屏幕是文件 1、2;
  2. 前 6 个参数按 rdi、rsi、rdx、rcx、r8、r9 的顺序落座,返回值在 rax;
  3. 「是否保存」列标「是」的寄存器,被调函数用前必须先存后取;
  4. lea x[rip] 是位置无关代码的核心:rip + 链接器填的偏移 = 真实地址;
  5. 共享库函数的地址运行时才定,call xxx@PLT 经 PLT/GOT 两表转跳;
  6. call 把下一条指令地址压栈、ret 把它弹回 rip——返回地址是函数回家的唯一凭据;
  7. x86-64 是满递减栈,从高地址往低地址长;栈溢出=压太多,栈下溢=从空栈弹;
  8. 函数序言 = push rbp; mov rbp, rsp,结语 = 逆操作;局部变量 = rbp 加负偏移;
  9. 哪怕只用一个 1 字节变量,栈帧也按 16 字节对齐开间(128 位寄存器指令的要求);
  10. 栈金丝雀是内核持有的随机数,函数进出时各查一次,专抓「栈被改写」;
  11. .equ 给常数起名字、还能让汇编器用 . 算表达式(如字符串长度 = 末尾 − 开头 − 1);
  12. gdb 三板斧:b 设断点、i r 看寄存器、x/ 看内存——本章的每个结论都是这么验出来的;
  13. 「传一个字符串」传给函数的只是个地址;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

  1. 出处:「第11章 深入main函数」第 27 段(text/17-ch11-11-main.txt:27,搜「printf 将数据」)。原文:printf「将数据由内存存储格式转换为字符格式,并调用 write 系统调用函数」。 ↩

  2. 出处:「第11章 深入main函数」第 30–31 段(text/17-ch11-11-main.txt:29,搜「视为文件」;text/17-ch11-11-main.txt:30,搜「文件描述符」)。 ↩

  3. 出处:「第11章 深入main函数」第 36–37 段(text/17-ch11-11-main.txt:36,搜「int write」;text/17-ch11-11-main.txt:29,搜「Linux 将屏幕和键盘」)。 ↩ ↩2 ↩3

  4. 出处:「第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

  5. 出处:「第11章 深入main函数」第 118 段(text/17-ch11-11-main.txt:118,搜「前 6 个参数」)。 ↩

  6. 出处:「第11章 深入main函数」第 96–117 段(text/17-ch11-11-main.txt:96,搜「通用寄存器用途」)与第 116 段(text/17-ch11-11-main.txt:116,搜「是否保存」)。 ↩ ↩2

  7. 出处:「第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

  8. 出处:「第11章 深入main函数」第 174–177 段(text/17-ch11-11-main.txt:174,搜「加载有效地址」;text/17-ch11-11-main.txt:182,搜「LC0」)。 ↩ ↩2

  9. 出处:「第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,搜「##开头」)有说明。 ↩

  10. 出处:「第11章 深入main函数」第 150 段(text/17-ch11-11-main.txt:150,搜「从后向前」)。 ↩

  11. 出处:「第11章 深入main函数」第 162–164 段(text/17-ch11-11-main.txt:163,搜「任何内存位置」;text/17-ch11-11-main.txt:164,搜「默认生成位置无关代码」)。 ↩ ↩2 ↩3

  12. 出处:「第11章 深入main函数」第 185–188 段(text/17-ch11-11-main.txt:185,搜「紧随 lea 指令之后」)。 ↩

  13. 出处:「第11章 深入main函数」第 189–190 段(text/17-ch11-11-main.txt:189,搜「rodata 节」)。 ↩

  14. 出处:「第11章 深入main函数」第 191–195 段(text/17-ch11-11-main.txt:192,搜「PLT 代表」;text/17-ch11-11-main.txt:194,搜「全局偏移量表」)。 ↩

  15. 出处:「第11章 深入main函数」第 196–200 段(text/17-ch11-11-main.txt:196,搜「第一次调用 write」)。 ↩

  16. 出处:「第11章 深入main函数」第 209–218 段(text/17-ch11-11-main.txt:212,搜「一摞餐盘」;text/17-ch11-11-main.txt:218,搜「后进先出」)。 ↩

  17. 出处:「第11章 深入main函数」第 231–240 段(text/17-ch11-11-main.txt:231,搜「严格的使用规则」;text/17-ch11-11-main.txt:239,搜「栈溢出」)。 ↩ ↩2

  18. 出处:「第11章 深入main函数」第 244–251 段(text/17-ch11-11-main.txt:251,搜「满递减栈」)。 ↩ ↩2

  19. 出处:「第11章 深入main函数」第 257–261 段(text/17-ch11-11-main.txt:259,搜「向下」;text/17-ch11-11-main.txt:259,搜「高度简化」)。 ↩ ↩2

  20. 出处:「第11章 深入main函数」第 241–243 段(text/17-ch11-11-main.txt:241,搜「其他操作」;text/17-ch11-11-main.txt:243,搜「直接访问」)。 ↩

  21. 出处:「第11章 深入main函数」第 250 段(text/17-ch11-11-main.txt:250,搜「向下递增」)。 ↩

  22. 出处:「第11章 深入main函数」第 305–313 段(text/17-ch11-11-main.txt:307,搜「.equ symbol」;text/17-ch11-11-main.txt:313,搜「设置为整数 1」)。 ↩

  23. 出处:「第11章 深入main函数」第 314–318 段(text/17-ch11-11-main.txt:314,搜「代表此处的内存地址」;text/17-ch11-11-main.txt:317,搜「终止字符 NUL」)。 ↩

  24. 出处:「第11章 深入main函数」第 319 段(text/17-ch11-11-main.txt:319,搜「帧指针」)。 ↩

  25. 出处:「第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

  26. 出处:「第11章 深入main函数」第 525–528 段(text/17-ch11-11-main.txt:526,搜「128 位寄存器」;text/17-ch11-11-main.txt:527,搜「16 字节」)。 ↩ ↩2

  27. 出处:「第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,搜「参考点」)。 ↩

  28. 出处:「第11章 深入main函数」第 559–568 段(text/17-ch11-11-main.txt:559,搜「栈损坏」;text/17-ch11-11-main.txt:566,搜「fs:40」)。 ↩ ↩2

  29. 出处:「第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 —— 取反」)。 ↩

  30. 出处:「第11章 深入main函数」第 500–504 段(text/17-ch11-11-main.txt:500,搜「lea」;text/17-ch11-11-main.txt:554,搜「第 9 字节」)。 ↩ ↩2

  31. 出处:「第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」)。 ↩

  32. 出处:「第11章 深入main函数」第 598–602 段(text/17-ch11-11-main.txt:598,搜「17 世纪的英国矿井工人」)。 ↩

  33. 出处:「第11章 深入main函数」第 570–572 段(text/17-ch11-11-main.txt:570,搜「默认启用」;text/17-ch11-11-main.txt:572,搜「fno-stack-protector」)。 ↩ ↩2

  34. 出处:「第11章 深入main函数」第 615–633 段(text/17-ch11-11-main.txt:616,搜「推算出操作数的大小」;text/17-ch11-11-main.txt:627,搜「数据项大小记法」)。 ↩

  35. 出处:「第11章 深入main函数」第 643–644 段(text/17-ch11-11-main.txt:644,搜「没坏处」)。 ↩

  36. 出处:「第11章 深入main函数」第 425 段(text/17-ch11-11-main.txt:425,搜「必须严格遵守」)。 ↩ ↩2

  37. 出处:「第11章 深入main函数」第 717–721 段(text/17-ch11-11-main.txt:717,搜「不使用 C 运行时环境」;text/17-ch11-11-main.txt:721,搜「独立程序」)。 ↩

  38. 出处:「第11章 深入main函数」第 711–713 段(text/17-ch11-11-main.txt:711,搜「偏移量命名」;text/17-ch11-11-main.txt:712,搜「读性。在撤销栈帧时,我没」)。 ↩

  39. 出处:「第11章 深入main函数」第 576–590 段(text/17-ch11-11-main.txt:576,搜「按位异或」;text/17-ch11-11-main.txt:585,搜「如果相等则跳转」)。 ↩

  40. 出处:「第11章 深入main函数」第 604–614 段(text/17-ch11-11-main.txt:606,搜「删除栈帧」;text/17-ch11-11-main.txt:610,搜「mov rsp, rbp」)。 ↩

  41. 出处:「第11章 深入main函数」第 723–763 段(动手实践,text/17-ch11-11-main.txt:724,搜「多出一个命令行提示符」;text/17-ch11-11-main.txt:763,搜「Greetings」)。 ↩ ↩2

  42. 出处:「第11章 深入main函数」第 327–329 段(text/17-ch11-11-main.txt:327,搜「行号」;text/17-ch11-11-main.txt:329,搜「不同的行为」)。 ↩

On this page

深入 main 函数 — 参数怎么进寄存器,局部变量怎么住进栈1. 顶层全景:一次函数调用在机器层是四步2. 先绕开 printf:直接用 write 和 read为什么绕开三个现成的「文件」主线例子的第一站3. 参数怎么进寄存器:六个约定好的座位一个简单粗暴的问题六个座位的排法十六个寄存器,各有编制两条新指令:lea 与 call主线例子的第二站:三个数落座4. 位置无关代码:程序被搬到内存哪里都能跑问题从哪来rip 相对寻址:距离的尺子write 不在我们的程序里:PLT 和 GOT主线例子的第三站:现在只剩「跳过去、跳回来」5. 调用栈:一块「向下生长」的内存栈是什么为什么是「满递减」:从高地址往低地址长餐盘规则的三个例外条款.equ:把「1」起名叫 STDOUT,让汇编器替你算长度函数序言与结语:一进一出的两段固定动作用 gdb 亲眼看一遍(主线例子的第四站)6. 局部变量:把栈指针再往下挪一点一个字符也要占 16 字节的房间为什么不用 push/pop 来管变量顺带认识 add、sub、neg 三兄弟变量的地址:帧指针 + 一个负数栈金丝雀:编译器替你加的一道安检QWORD PTR:当汇编器猜不出数据有多大主线例子的暗线:helloWorld 与 echoChar 用的是同一副骨架不用 C 运行时环境,也行把清单 11-6 从头到尾读一遍(主线例子的完整复盘)7. 作者的判断与证据三个容易翻车的地方,书里都埋了答案8. 边界与局限9. 可带走的10. 原文地图Footnotes