汇编语言编程 — 从 C 到机器码的完整旅程
汇编语言编程 — 从 C 到机器码的完整旅程
这一章讲什么: 一个 C 程序要经过几步才变成内存里的 0 和 1? 答案是四步(预处理、编译、汇编、链接),每一步都产生一个看得见的 中间文件。本章把这条流水线走通,然后停下来读懂流水线的产物—— 编译器为「什么都不做的程序」生成的汇编,核心只有六行,却藏着 所有函数共用的最小骨架(序言 + 结语)。最后用调试器一条指令一条 指令地执行它,亲眼看寄存器被改写。 读完这一章,汇编语言从「天书」降级为「一门有点啰嗦但规则很简单的语言」。
1. 顶层全景:四步流水线与一张行格式
filename.c ──预处理(cpp)──► filename.i ──编译(cc1)──► filename.s
──汇编(as/gas)──► filename.o ──链接(ld)──► a.out(可执行文件)
│
加载进内存 → CPU 取指执行(第 9 章的循环)
每一步的产物:
.i 展开 #include / #define 后的纯 C -E 选项停在这
.s 汇编语言(人还能读的机器码) -S 选项停在这
.o 目标文件(机器码,地址未定) -c 选项停在这
可执行文件(地址已定,含 C 运行时启动代码) 默认走完四步这条流水线是第 1 章「编辑-编译-执行」的具体化。每一步一个独立程序、 各产一个中间文件:
- 预处理(cpp):解析编译器指令——
#include(文件包含)、#define(宏定义)、#if(条件编译)。gcc -E停在这步, 把展开后的 C 写到标准输出(重定向成 .i 文件); - 编译(cc1):把预处理后的 C 翻译成汇编语言;
- 汇编(as,也叫 gas / GNU 汇编器):把汇编翻译成机器码,写进 .o;
- 链接(ld):确定每个函数和数据项在内存中的位置,用地址替换符号名; 把 C 运行时环境的启动代码(打开标准输入/输出路径那些)拼进来; 外部库的函数先记个账,程序执行时再定地址1。
设计意图是可组合:gcc 能接手别人已经完成的前几步
——只给 .s,它就做汇编 + 链接;只给 .o,它只做链接2。
这条性质正是本书后半部的命门:用汇编写的程序可以和 C 库函数链接到一起,
printf 从第一章起就随叫随到。
想看清每一步?把中间文件全留下来
默认跑完四步,中间产物自动删掉,只剩可执行文件。两个选项能反悔:
-save-temps:四步的每个中间文件(.i/.s/.o)都留在当前目录, 文件名和源文件同名,只换后缀;- 分步手动:
gcc -E … > file.i→gcc -S file.i→as … -o file.o→ld …——每一步都换成对应的工具(cpp/cc1/as/ld),亲手跑一遍2。
这条流水线还有个隐含性质:每一步都是独立的程序、独立的格式约定。 .c 是人写的,.i 是机器展开后的 .c,.s 是给人读的机器码,.o 是机器读的 机器码——四种形态四种读者。学会区分"这一层是给谁看的", 是本书后面读清单(编译器产物、汇编清单、gdb 输出)的基本功。
不加任何停止选项时,四步一口气走完、中间文件自动删除,只留下可执行程序;
想要留档就用 -save-temps。外部函数的处理值得多看一句:被调函数在
外部库时,「该函数被调用的位置会被记录下来,外部函数的地址在程序执行
过程中确定」——这就是第 20 章动态链接的伏笔1。
编译选项的口径(后文全程遵守):-O0 不优化(「我们的目标是展示
机器层面上发生的事情」)、-Wall 开警告、-masm=intel 用 Intel 语法、
-g 带调试信息3。文件扩展名必须守规矩——编译器按扩展名决定每一步
干什么,乱起名「可能不会执行你想要的操作,甚至还会覆盖所需的文件」4。
2. 主线例子:一个「什么都不做」的程序的解剖
C 源码(清单 10-1)小得可怜:int main(void) { return 0; }。
主线例子的第 ① 步:让它过流水线。
$ gcc -O0 -Wall -masm=intel -S doNothingProg.c ← 停在第 2 步,产物 doNothingProg.s编译器吐出的 .s 文件(清单 10-2)吓人的地方不在多,而在杂:
一堆 .cfi_xxx 汇编器指令、一条 endbr64、几行 .section .note.gnu.property。
原书的做法是逐层剥噪音,每剥一层讲一层是什么:
.cfi_*和.LF*→ 调试/错误处理用的信息,「这方面的讨论超出了本书 的范围」,用-fno-asynchronous-unwind-tables关掉5;endbr64和.note.gnu.property→ Intel CET(控制流保护)新指令, 「如果 CPU 不支持 CET,则该指令无效」;用-fcf-protection=none关掉6;.file、.size、.ident、.section .note.GNU-stack→ 各是记录 源文件名、算函数机器码大小、标注编译器版本、声明栈属性的; 作者对后两条的解说只有一句:「老实说,我也不知道为什么要使用 .ident 和 .section 汇编器指令」——又一次「行为记住,执念放下」7。
剥完之后,清单 10-4 只剩八行。这就是主线例子的第 ② 步:噪音散尽后的真相——
.file "doNothingProg.c"
.intel_syntax noprefix
.text
.globl main
.type main, @function
main:
push rbp
mov rbp, rsp
mov eax, 0
pop rbp
ret
.size main, .-main
.ident "GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0"
.section .note.GNU-stack,"",@progbits
图说:一个「什么都不做」的 C 程序,机器层的真相是:
六条指令 + 两条元信息指令。其中四条只为一件事服务——把栈摆好再还原。3. 汇编器指令与程序的四段内存
以 . 开头(后面不带 :)的标识符是汇编器指令(assembler directive,
也叫伪操作 pseudo-op)——给汇编器看的,不翻译成机器指令、不占程序
内存;以 : 结尾的是标签,标记内存位置8。真正常用的只有四条:
| 指令 | 作用 |
|---|---|
.text | 后面的内容进文本节(代码和常量) |
.globl main | 把 main 声明为全局——C 运行时环境要按这个名字找到入口 |
.type main, @function | 把 main 登记为函数名 |
.intel_syntax noprefix | 语法开关:用 Intel 语法 |
为什么代码和变量要分「节」?GNU/Linux 的目标文件用 ELF(可执行与 链接格式),链接器把节归拢成段,操作系统按段加载并区别对待9:
文本段(代码段):指令 + 常量;操作系统设为只读——「避免程序修改其中的内容」
数据段:全局变量、静态局部变量;整个执行期间活着
栈段:自动局部变量 + 函数链接信息;调用时创建、返回时销毁(动态)
堆段:malloc/new 的内存池;free/delete 显式归还
图说:第 2 章的「程序也是数据」在这里细化:程序的不同部分,
在内存里按「谁能写、活多久」分成四种公民,各有各的街区。四个段各管一类数据,规矩不同:
| 段 | 住什么 | 谁能写 | 活多久 |
|---|---|---|---|
| 文本段 | 指令 + 常量 | 只读(OS 强制) | 程序全程 |
| 数据段 | 全局变量、静态局部变量 | 程序读写 | 程序全程 |
| 栈段 | 自动局部变量 + 函数链接信息 | 程序读写 | 函数调用期间(动态创建/销毁) |
| 堆段 | malloc/new 分配的内存 | 程序读写 | 显式 free/delete 之前 |
栈段是四个段里最特别的一个——它的空间不是程序启动时就分好的, 而是每次函数调用"临时搭建"、返回时"当场拆除"(第 11 章的栈帧)。 另外三个段的大小在程序运行期间基本固定,栈和堆则会动态伸缩。 补充(不在书里,来自通用知识):多数实现里栈从内存高地址往低地址长、 堆从低地址往高地址长,中间的未用空间是两个方向的缓冲。
main 必须 .globl:启动代码(链接时自动加入)是按名字调用 main 的10。
想脱离 C 运行时单干也行——-c 停在汇编步、用 ld 自己链接、第一个函数
随便起名(第 20 章的做法);但本书的程序都借 C 运行时的启动设施,
所以都从 main 开始。
主线例子登场:一个「什么都不做」的程序
为了看清骨架,原书用了一个故意「什么都不做」的 C 程序(只有一个空 main),
看编译器为它生成什么。清单 10-1 是它的 C 源码,清单 10-2 是 gcc -S 的输出。
把 10-2 摊开,你会看到它远不止六行——main: 上下包着十几行没见过的
汇编器指令。这些多出来的行不是程序的一部分,是编译器替程序交的三份
「辅助材料」,下一节逐层剥给你看。
先认一眼骨架在哪:所有变体(带不带噪音)里,不变的核心永远是这六行——
main:
push rbp
mov rbp, rsp
mov eax, 0
mov rsp, rbp
pop rbp
ret**编译器给最小程序生成的不是"最少的代码",而是"最守规矩的代码": 序言、结语、返回码,一样不缺。**守规矩不是浪费——它保证了任意函数 (不管多复杂)都能接进同一个调用协议。后面十九章的每一个程序, 都是在这六行骨架上长出来的。
4. 自己动手写:行格式与四条指令
4.1 一行的四个字段
汇编语言按行组织,一条语句一行,绝不跨行。每行最多四段11:
label: operation operand(s) # comment
❶ label 符号名,对应「下一条语句的内存地址」;只给会被引用的行起名
❷ operation 操作码(mov/push……)或汇编器指令(.text……)
❸ operand 0~3 个,可以是字面值、寄存器名、程序员起的标识符
❹ comment # 之后全忽略——优秀程序员几乎每行都写注释注释的品位原书单独点了一句:写「要做什么」,别写「计算机做什么」。
counter = 1; /* let x = 1 */ 是废话;counter = 1; /* need to start at 1 */ 才是注释——给读者留「为什么」,不是复述代码12。
标识符规则类似 C(字母数字 + ._$,首字符非数字);标签区分大小写,
自己起名别用 . 或 _ 开头,免得撞上系统标签。
空行与缩进的「可读性预算」
原书在汇编语言概述里有一条容易被略过的建议:优秀的汇编程序员用空行 分隔算法的各个部分,几乎每行都写注释13。这和高级语言的习惯正好相反—— C 里"空行+缩进"是结构提示,汇编里空行是"段落分隔符",注释是"为什么的存档"。
这个建议的深层理由是:汇编没有高级语言的视觉结构(没有花括号、 没有缩进即语法),唯一的可读性来源就是程序员自己留下的路标。 写汇编时省下的注释和空行,读的时候会连本带利地讨回来—— 而且读的人往往就是三个月后的你自己。
4.3 四条指令的共同怪癖:都不动 rflags
把 mov/push/pop/ret 摆在一起,会发现一个共同点:这四条都不改 rflags 寄存器里的状态标志14。对比第 16 章的 add/sub/and——那些算术和逻辑 指令会把结果的状态(零、负、进位)写进 rflags,而这四条纯粹的"搬运工" 对标志位秋毫无犯。
这个性质在第 13 章会变成主角:条件跳转指令靠标志判断,而「一次 mov 会不会把我刚 cmp 出来的标志踩掉」的答案,由这条性质保证——不会。 设计指令集时刻意让搬运类指令"安静",就是为了让"比较 → 分支"这个 最常见的组合无后顾之忧。
4.4 把四条指令串起来:主线例子的最小循环
mov/push/pop/ret 四条各管一件事:搬运数据、存现场、取现场、交还控制。 把它们串成一个最小协作场景——函数调用,正好是第 11 章主线的预告15:
调用方: 被调方:
push ……(保存) → pop ……(取回) ← push/pop 配对联运
call ……(下节讲) → ret ← call/ret 配对联运
└──── 两者的粘合剂都是栈:数据走 push/pop,控制走 call/ret ────┘这个预告在第 11 章会全部兑现:你会看到 call 压返回地址、ret 弹返回地址, 和这里 push/pop 保存数据的机制完全同源——栈既是数据通道,也是控制通道。
4.2 Intel 语法与 mov
Intel 语法的一般格式:操作 目标, 源——结果放目标,读数从源,
和 C 的赋值方向一致(a = b 对应 mov a, b)14。助记符是「类似
英语的短字符串」,对应一条机器指令:机器码 0x4889e5 和 mov rbp, rsp
是同一件事的两种写法16。
四条起步指令,配齐常用变体:
mov —— 复制(不影响 rflags)
mov reg, reg / mov reg, mem / mov mem, reg / mov reg, imm / mov mem, imm
规矩:源和目标大小必须一致(寄存器名自带大小:eax = 32 位,al = 8 位);
写 32 位部分会把高 32 位清零(第 9 章的怪癖);
立即数进内存要报大小:mov byte ptr x, 123 / mov qword ptr y, 123;
内存 → 内存 不存在:必须经寄存器中转。
push —— 压栈(不影响 rflags)
push reg / push mem 64 位值压入栈顶,rsp 自动指向新栈顶
pop —— 弹栈(不影响 rflags)
pop reg / pop mem 栈顶 64 位值取出,rsp 指向下一项
ret —— 返回(不影响 rflags)
无操作数。把栈顶 64 位值弹出到 rip ——程序控制跳去那个地址。栈的直觉(一次性脚手架):「把餐盘一个一个地叠放在架子上,然后在需要的
时候把盘子拿走」——后进先出;rsp 永远指着栈顶那「个盘子」17。
ret 的语义值得单独咀嚼:函数返回不是「往回走」,而是从栈顶取一个
地址塞进 rip——下一章会看到,这个地址是 call 指令替你压好的。
5.5 编译器塞进来的噪音:三层剥离
清单 10-2(编译器为迷你 C 程序生成的汇编)里,核心的六行被三层「噪音」包着。 书里当着你的面逐层剥掉,每一层都先说是什么、再说怎么关7:
第一层:.cfi_* 系列汇编器指令(清单里 ❶ 那几行)。cfi 是
"Call Frame Information"(调用帧信息)的缩写——告诉调试器和异常处理机制
「栈帧长什么样、寄存器存在哪」,发生错误时才能还原现场。-fno-asynchronous-unwind-tables
关掉它,程序照常运行,只是崩溃时的诊断信息变少。
第二层:endbr64 与 CET。即便剥掉 cfi,还有一个不明指令和几个配套指令。
这是 Intel 的 CET(控制流强制技术)——专门防"返回到不该返回的地方"这类攻击
(第 11 章的栈金丝雀防的是数据被改写,CET 防的是控制流被劫持,两道工序)。
endbr64 必须是函数第一条指令,CPU 用它来确认"程序流是正常走到这儿的";
老 CPU 不认识它也没关系,这条指令在不支持的芯片上是空转7。
第三层:.ident、.note.GNU-stack 等。版本签名和"本程序不需要可执行栈"
的声明,纯信息,不影响逻辑。
三层剥完,才是清单 10-4 的六行核心。这个"先给全貌、再逐层剥噪音"的顺序, 比直接给干净清单多花一倍篇幅,换来的是:你在真实编译产物里再撞见它们,认识。 这也是本章的教学法主线——不是教你背一个最小样板,而是教你「见到陌生行, 有能力分类:核心协议 / 可关的辅助 / 可忽略的信息」。
诚实的边界:作者也不知道的行
清单 10-4 里还有两行,书里留了一句值得记住的坦白: 「老实说,我也不知道为什么要使用 .ident 和 .section .note.GNU-stack 汇编器指令。 根据指令参数,我猜想它们与版本信息和栈执行权限有关」18。
这个坦白比答案本身更值钱。遇到看不懂的机器产物,先分类("这是协议必须 /
这是辅助信息 / 我不知道"),比硬猜一个错误解释安全。 .note.GNU-stack 后来
知道是「标记本程序不需要可执行栈」的安全声明;.ident 是编译器版本签名——
都属于"知道了锦上添花、不知道无害"的那一类。
5. 函数的最小骨架:序言与结语
清单 10-5(手写版)和编译器版逐行对应。把六条指令按职责分组19:
main:
push rbp # ❶ 序言①:把调用方的帧指针存进栈(保存)
mov rbp, rsp # ❷ 序言②:把自己的帧指针立在这里(建立)
mov eax, 0 # ❸ 本体:返回值 0 放进 rax(eax 写 32 位,高 32 位自动清 0)
mov rsp, rbp # ❹ 结语①:栈指针恢复到序言开始时的位置
pop rbp # ❺ 结语②:从栈顶取回调用方的帧指针
ret # ❻ 结语③:栈顶取出返回地址,跳回去
图说:序言 = push rbp; mov rbp, rsp,结语 = mov rsp, rbp; pop rbp; ret ——
严格镜像,一个 function prologue(序言)一个 function epilogue(结语)。为什么每個函数都要这套?函数要在内存里存「返回地址」(第 9 章: 寄存器有限,不够放),用完即弃——调用栈就是存这类临时信息的地方; 函数在栈里占的那一片叫栈帧(stack frame),帧指针 rbp 指着它15。
- ❶ 保存调用方的 rbp(等会儿要借来当自己的,先替人家存好);
- ❷ 立界碑:rbp = 当前 rsp,「这块栈区从此是本函数的」;
- ❹ 拆界碑:rsp 拉回 rbp——无论栈中间堆了多少局部变量,一键复原;
- ❺ 归还 rbp;❻ ret 取回返回地址。 序言和结语是「严格的协议」——「如果帧指针和栈指针没有在返回 调用函数之前恢复,你的程序一定会崩溃」。原书甚至给了个调试习惯: 「我经常会在 ret 指令处设置断点,检查函数是否正确恢复了这两个寄存器」20。
mov eax, 0 而不是 mov rax, 0?因为操作系统只期待一个 32 位错误码,
写 eax 顺手清零高 32 位(第 9 章的怪癖在这里是特性)。
6. 用 gdb TUI 亲眼执行:主线例子的第 ③ 步
工具链两步走:as --gstabs 把 .s 汇编成带调试信息的 .o;
gcc 接手 .o 直接链接(认出目标文件就跳过前两步)21。
然后进 gdb,开 TUI(文本用户界面)模式——终端上半屏显示代码,
下半屏是命令区;layout regs 再切出寄存器窗格。逐条执行 s,
每步之后被改写的寄存器会高亮:
(gdb) set disassembly-flavor intel ← gdb 默认 AT&T,切回 Intel
(gdb) b main ← 标签断点:保证停在函数第一条指令
(gdb) r
(gdb) tui enable
(gdb) layout regs
(gdb) s ← 执行一条,高亮变化的寄存器走查一遍(原书图 10-2 ~ 10-6 的浓缩,每步对照寄存器窗口看)22:
执行前(Breakpoint 1 停住):
> 指向 push rbp 行,PC = 0x...129;rsp = 0x7fffffffde98(栈顶在高位)
① push rbp 执行后:
rsp 0x7fffffffde98 → 0x7fffffffde90 ← 减 8(64 位值占 8 字节),rsp 高亮
rip(PC) 0x...129 → 0x...12a ← 只前进 1 字节:push rbp 是 1 字节指令
命令区验证:x/1xg $rsp → 看到旧 rbp 的值已经躺在栈顶
(> 移到 mov rbp, rsp 行)
② mov rbp, rsp 执行后:
rbp 变成 0x7fffffffde90,和 rsp 同值——界碑立下了:栈帧从这里开始
③ mov eax, 0 执行后:
rax 整寄存器高亮(写 eax 清高 32 位,第 9 章的怪癖当场演示)
④ mov rsp, rbp;pop rbp;ret 三连:
rsp 拉回 0x7fffffffde98、rbp 恢复成调用方的值——
和入口时逐位相同;ret 后程序回到调用方,会话结束
图说:六条指令,三种「可见的效果」:rsp 的位移、rbp 的交接、rip 的步进;
TUI 的高亮让「哪条指令动了谁」一目了然。三个顺手记住的 gdb 要点:程序计数器(PC)就是指令指针 rip 的别名;
b main 用标签断点是因为「每条 C 语句通常翻译成多条汇编指令」,
行号断点落不准;s 单步是指令级的——配合 TUI,汇编学习从此
有了显微镜23。
TUI 走查的完整剧本(主线例子第 ③ 步)
把走查写成一份可以直接照做的剧本,每一步都带"该看什么、看什么变化":
1. as --gstabs -o mini.o mini.s # 带调试信息汇编
2. gcc -o mini mini.o # 链接(自动补 C 运行时)
3. gdb ./mini
4. set disassembly-flavor intel # gdb 默认 AT&T,切回 Intel
5. b main # 标签断点,停在函数第一条指令
6. run
7. tui enable + layout regs # 上半屏代码 + 下半屏寄存器
8. 反复:s(单步一条)→ 看高亮寄存器 → x/1xg $rsp(看栈顶)
预期效果(和 ② 的剧本一致):
push rbp 后:rsp 减 8,x/1xg $rsp 能看到旧 rbp
mov rbp,rsp 后:rbp = rsp,两者同值
ret 后:程序退出,gdb 显示进程结束第 8 步的循环是整个工具链教学的核心:每条指令执行前,先猜"它会改谁", 执行后对照高亮验证。 猜-验-修正的节奏,比读十遍定义更接近学会。
原书还交代了 TUI 的边界:「TUI 增强没有提供内存的数据或地址视图,
只有反汇编视图。我们需要在命令区查看存储在内存中的数据和地址」——
看栈顶存了什么,得回命令区用 x 命令24。而每次执行前后对比
rbp/rsp 是否复原,是「函数有没有按协议办事」的最快体检——
这就是「在 ret 处设断点」习惯的由来。
7. AT&T 语法:另一套方言的三座差异
Linux 世界默认的汇编方言是 AT&T(gdb 默认也是);同一段程序写出来 长这样(清单 10-7)25:
Intel: AT&T:
push rbp pushq %rbp ← 差异① 大小变成指令后缀(b/w/l/q)
mov rbp, rsp movq %rsp, %rbp ← 差异② 寄存器加 %,立即数加 $
mov eax, 0 movl $0, %eax ← 差异③ 操作数顺序整个反过来!
图说:同一个 mov rbp, rsp:Intel 读作「把 rsp 给 rbp」,
AT&T 写成 %rsp, %rbp 读作「从源 %rsp 到目标 %rbp」——方向相反,翻译时最坑。「如果在 Intel 和 AT&T 这两种语法之间来回切换,很容易就会搞错操作数
顺序,尤其是用到两个寄存器的那些指令。」作者选 Intel 的理由:
与 Intel/AMD 官方手册保持一致——手册是最终权威,语法和权威同侧,
查起来不吃两遍苦;GNU as 是唯一默认 AT&T 的汇编器26。
出门撞见 AT&T(gcc 不加 -masm=intel 时的默认产物、大量 Linux 资料)
时,记住三座差异就能现场翻译。
主线例子的第 ④ 步:从"看懂"到"能写"
学到这里,主线例子("一个什么都不做的程序")已经走完前三步: 看懂 C(第 2 章)、看懂编译产物(本章)、亲手 TUI 执行(本章)。 第四步是自己写——清单 10-5 就是手写版:
# doNothing.s
.intel_syntax noprefix
.text
.globl main
.type main, @function
main:
push rbp
mov rbp, rsp
mov eax, 0
mov rsp, rbp
pop rbp
ret和编译器版(清单 10-4)逐行对比:除了被剥掉的噪音,核心六行一模一样。 这就是本章的结论性一幕:**编译器和你的目标相同(遵守同一个协议), 区别只在它多交了几份辅助材料。**手写一遍这个 10 行的程序, 是对「汇编语言 = 机器码的助记符」最具体的体认——你写的每一行, 都能在第 12 章逐字节地对上机器码。
汇编器指令补充:.section 与 .data 的预告
清单 10-5 只用 .text,但原书在 ELF 一节里预告了后续章节要用的另外三个
汇编器指令:
.data:把后面的内容放进数据段(全局变量、静态局部变量)—— 第 14 章定义全局变量时会用到;.bss:为未初始化的数据预留空间(加载时由操作系统清零)—— 同样第 14 章;.section .rodata:放进只读数据段(常量字符串、查表数据)—— 第 11 章的字符串 "Hello, World!\n" 就住在这里。
这三条和第 3 节的四段内存表一一对应:.text 填文本段,.data/.bss 填
数据段,.rodata 填文本段的只读角落。记住这个对应关系,后面十九章的
每个程序布局你都能一眼认出。
小结:本章的完整工具清单
原书小结收成本章带走的五件工具,对着查一遍27:
| # | 工具 | 一句话 |
|---|---|---|
| 1 | gcc 四步流水线 | 预处理→编译→汇编→链接;每步一个程序、一个中间文件 |
| 2 | 汇编器指令 | .text/.globl/.type 描述程序特征;.intel_syntax 定语法 |
| 3 | 四段内存 | 文本/数据/栈/堆,按「谁能写、活多久」分公民 |
| 4 | 行格式 + 四条指令 | label: op src, dst # comment;mov/push/pop/ret |
| 5 | 序言/结语 + gdb TUI | push rbp; mov rbp,rsp … mov rsp,rbp; pop rbp; ret;TUI 逐条看高亮 |
小结的末句是预告:「现在你已经了解了汇编语言的基本元素,我们准备好深入研究 在 C/C++程序中使用它们的方法了。」——下一章,从 main 函数开始。
8. 作者的判断与证据
判断一:「先给你能跑的骨架,再逐层讲血肉。」 本章的结构本身就是 教学法:完整程序(清单 10-5)先亮相 → 行格式 → 四条指令 → 序言/结语 协议 → TUI 走查。「你应该花时间熟悉这个程序的基本结构」——因为此后 十一章的每个程序都是它的变体。
判断二:噪音要剥,而且要当着你的面剥。 .cfi、CET、.ident 三层
噪音每一层都先说「是什么」再说「怎么关」,连「老实说,我也不知道为什么」
都留在正文里7。这比直接给干净清单多费了一倍篇幅,换来的是:
你日后在真实编译产物里再撞见它们,认识。
判断三:gdb TUI 是汇编教学的被低估资产。 「gdb 有价值的用途之一是 作为一种学习工具。它提供了一种特别有助于学习汇编语言指令操作的模式」 ——寄存器高亮这个设计,把「指令的效果」从抽象定义变成了视觉事件; 原书还顺手给了「在 ret 处设断点查 rbp/rsp」这种可迁移的调试习惯20。
9. 边界与局限
- 只教四条指令(mov/push/pop/ret)加一批汇编器指令;x86-64 全指令 超过 2000 条(视统计口径),权威是 Intel SDM 第 2 卷 / AMD 手册第 3 卷, 「虽然读起来有些难度,但是在本书和手册之间交叉参考应该会帮助你学会 如何阅读参考手册」28;
.cfi与 CET 被关掉而非讲解——生产环境的安全机制,教学环境的噪音; 用 gdb 调试真实程序时它们会回来;- ELF 的节/段只到「四种段」的概览,
readelf被点名当自学工具9; - 立即数、内存操作数的完整寻址方式(第 12 章)、栈的全面用法(第 11 章) 都是「随后详述」——本章刻意只给最小可用集;
- TUI 模式在 Emacs 内运行效果不佳(原书注意框)23。
10. 可带走的
全章主线例子,一行走完: 三行 C → 四步流水线 → 剥掉 .cfi/CET 噪音的 八行汇编 → 手写同款 → gdb TUI 逐条执行,亲见 rsp 减 8、rbp 立界碑、 结尾全部复原 ——「C 到机器」第一次全程无黑箱。
- gcc 四步:预处理(-E/.i)→ 编译(-S/.s)→ 汇编(-c/.o)→ 链接(可执行); 可接手半成品:只交 .s 做后两步,只交 .o 只做链接;
- 汇编能和 C 库链接——
printf在汇编程序里随叫随到; - 汇编器指令(伪操作)是给汇编器的,不进机器码;标签是给程序员引用的内存地址;
- ELF 四种段:文本(只读)、数据(全局/静态)、栈(局部变量,动态)、 堆(malloc,手动 free)——按「谁能写、活多久」分街区;
- 行格式
label: operation operands # comment;注释写「为什么」, 不复述代码; - Intel 语法:
操作 目标, 源;mov 不走 rflags;内存↔内存必须经寄存器; - push/pop 动 rsp(64 位,每次 8 字节);ret 把栈顶弹进 rip;
- 函数骨架 = 序言(push rbp; mov rbp, rsp)+ 本体 + 结语 (mov rsp, rbp; pop rbp; ret)——镜像结构,协议严格,不恢复必崩;
- 返回值走 rax(本例 eax 顺手清高 32 位);
- gdb TUI:
tui enable+layout regs看寄存器高亮;b 标签精确断点;s指令级单步;set disassembly-flavor intel修语法; - AT&T 三差异:大小后缀(q/l/w/b)、%/$ 前缀、操作数反序; gdb 默认 AT&T,gcc 不加选项默认 AT&T;
- 「老实说,我也不知道为什么」也是有效知识:行为记住,疑问存档。
11. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 四步流水线与各选项 | 第10 章 汇编语言编程 | text/16-ch10.txt:27(搜「预处理」) · text/16-ch10.txt:92(搜「-O0」) |
| 扩展名约定 | 第10 章 汇编语言编程 | text/16-ch10.txt:62(搜「文件扩展名」) |
| .cfi 噪音与关闭选项 | 第10 章 汇编语言编程 | text/16-ch10.txt:153(搜「cfi」) |
| CET 与 endbr64 | 第10 章 汇编语言编程 | text/16-ch10.txt:198(搜「CET」) · text/16-ch10.txt:201(搜「endbr64」) |
| 「老实说,我也不知道为什么」 | 第10 章 汇编语言编程 | text/16-ch10.txt:237(搜「老实说」) |
| ELF 与四种段 | 第10 章 汇编语言编程 | text/16-ch10.txt:247(搜「ELF」) · text/16-ch10.txt:254(搜「文本段」) |
| .globl main 的理由 | 第10 章 汇编语言编程 | text/16-ch10.txt:269(搜「全局可用」) |
| 行格式四字段 | 第10 章 汇编语言编程 | text/16-ch10.txt:318(搜「label」) |
| 注释写「为什么」 | 第10 章 汇编语言编程 | text/16-ch10.txt:342(搜「need to start at 1」) |
| 助记符与 Intel 格式 | 第10 章 汇编语言编程 | text/16-ch10.txt:364(搜「助记符」) · text/16-ch10.txt:374(搜「destination」) |
| mov 指令与变体 | 第10 章 汇编语言编程 | text/16-ch10.txt:386(搜「mov ——」) |
| 内存↔内存必须经寄存器 | 第10 章 汇编语言编程 | text/16-ch10.txt:421(搜「从一个位置直接移动到另一个位置」) |
| 餐盘比方与 push/pop/ret | 第10 章 汇编语言编程 | text/16-ch10.txt:426(搜「餐盘」) · text/16-ch10.txt:443(搜「ret ——」) |
| 序言/结语协议 | 第10 章 汇编语言编程 | text/16-ch10.txt:493(搜「函数序言」) · text/16-ch10.txt:23(搜「10.1 编译 C 」) |
| ret 处设断点查 rbp/rsp | 第10 章 汇编语言编程 | text/16-ch10.txt:600(搜「崩溃」) |
| as --gstabs 与 gcc 接手 .o | 第10 章 汇编语言编程 | text/16-ch10.txt:514(搜「gstabs」) |
| TUI 模式与寄存器高亮 | 第10 章 汇编语言编程 | text/16-ch10.txt:524(搜「TUI」) · text/16-ch10.txt:571(搜「高亮」) |
| push 使 rsp 减 8 | 第10 章 汇编语言编程 | text/16-ch10.txt:573(搜「减去」) |
| AT&T 三差异 | 第10 章 汇编语言编程 | text/16-ch10.txt:651(搜「后缀」) · text/16-ch10.txt:656(搜「相反的操作数顺序」) |
| 选 Intel 语法的理由 | 第10 章 汇编语言编程 | text/16-ch10.txt:668(搜「保持一致」) |
| 小结(序言/结语/gdb TUI) | 第10 章 汇编语言编程 | text/16-ch10.txt:671(搜「小结」) |
Footnotes
-
出处:「第10 章 汇编语言编程」第 27 段(
text/16-ch10.txt:27,搜「预处理」)至第 43 段。原文:「预处理是第一步。这一步通过调用程序 cpp,解析各种编译器指令,如#include(文件包含)、#define(宏定义)、#if(条件编译)」「ld 确定程序执行时每个函数和数据项在内存中的位置。然后,使用内存地址替换程序员引用这些函数和数据项时的符号名称」「编译器指导 ld 将相关代码添加到建立 C 运行时环境的可执行文件中。这包括打开标准输出(屏幕)和标准输入(键盘)路径等操作,以供程序使用」「如果被调用的函数位于外部库,则该函数被调用的位置会被记录下来,外部函数的地址在程序执行过程中确定。」 ↩ ↩2 -
出处:「第10 章 汇编语言编程」第 27 段(
text/16-ch10.txt:27,搜「预处理」)至第 57 段。原文:「预处理是第一步……可以使用-E 选项在预处理阶段结束时停止编译过程」「-S 选项(大写 S)在编译阶段结束时停止」「-c 选项在汇编阶段结束时停止」;「如果我们用汇编语言写了一个文件,gcc 会跳过预处理和编译步骤,执行汇编和链接步骤」见第 57 段(text/16-ch10.txt:57,搜「跳过预处理」)。 ↩ ↩2 -
出处:「第10 章 汇编语言编程」第 94 段(
text/16-ch10.txt:94,搜「-O0」)。原文:「-O0(大写的 O 和 0)选项告诉编译器不要使用任何优化。本书的目标是展示在机器层面上发生的事情。如果编译器优化代码,可能会掩盖一些重要的细节。」 ↩ -
出处:「第10 章 汇编语言编程」第 62 段(
text/16-ch10.txt:62,搜「文件扩展名」)。原文:「命名文件时,注意使用 GNU 编程环境要求的文件扩展名。编译器在每一步的默认操作取决于适合该步骤的文件扩展名。」 ↩ -
出处:「第10 章 汇编语言编程」第 153 段(
text/16-ch10.txt:153,搜「cfi」)。原文:「以.cfi开头的汇编器指令告诉汇编器生成可用于调试以及某些错误场景的信息……这方面的讨论超出了本书的范围,因此,我们通过-fno-asynchronous-unwind-tables 选项告诉编译器不要将其加入汇编语言文件中。」 ↩ -
出处:「第10 章 汇编语言编程」第 198 段(
text/16-ch10.txt:198,搜「CET」)与第 201 段(text/16-ch10.txt:201,搜「endbr64」)。原文:「Intel 开发了一项名为 CET(Control-flow Enforcement Technology)的技术,用于更好地防御劫持程序流的计算机程序的安全攻击」「这项技术包括一条新指令 endbr64,被用作函数的第一条指令,检查程序流是否到达此处。如果 CPU 不支持 CET,则该指令无效。」 ↩ -
出处:「第10 章 汇编语言编程」第 237 段(
text/16-ch10.txt:237,搜「老实说」)。原文:「老实说,我也不知道为什么要使用.ident 和.section 汇编器指令。根据指令参数,我猜想是为了在用户报告 bug 的时候为 gcc 的开发人员提供信息。没错,就算是编译器也有 bug!」 ↩ ↩2 ↩3 ↩4 -
出处:「第10 章 汇编语言编程」第 146 段(
text/16-ch10.txt:146,搜「.字符」)。原文:「所有这些,除了后面还有:的,都是汇编器指令(assembler directive),也称为伪操作(pseudo-ops)。它们是汇编器本身的指令,并非计算机指令。」「以:结尾的标识符是内存位置标签。」 ↩ -
出处:「第10 章 汇编语言编程」第 247 段(
text/16-ch10.txt:247,搜「ELF」)至第 264 段。原文:「汇编器生成的目标文件采用可执行和链接格式(Executable and Linking Format,ELF)」「文本段:程序指令和常量数据存储在文本段。操作系统将该段设为只读」「栈段:自动局部变量和函数链接信息都存储在调用栈」「堆段:堆是程序运行期间可用的内存池。C 程序调用 malloc 函数(C++程序调用 new)可以从堆中获得一块内存。」 ↩ ↩2 -
出处:「第10 章 汇编语言编程」第 269 段(
text/16-ch10.txt:269,搜「全局可用」)。原文:「.globl 指令有一个参数:标识符 main。.globl 使该名称全局可用,因此在其他文件中定义的函数也能够引用此名称。设置 C 运行时环境的代码是为了调用名为 main 的函数而编写的。」 ↩ -
出处:「第10 章 汇编语言编程」第 318 段(
text/16-ch10.txt:318,搜「label」)。原文:「每行汇编语言都被组织成 4 个可能的字段:label: operation operand(s) # comment」。 ↩ -
出处:「第10 章 汇编语言编程」第 342 段(
text/16-ch10.txt:342,搜「need to start at 1」)。原文:「初学者往往只注释语句的用途,而没有写明目的……你在书写注释时应该描述你要做什么,而不是计算机做什么。」 ↩ -
出处:「第10 章 汇编语言编程」第 310 段(
text/16-ch10.txt:310,搜「几乎每一行」)。原文:「优秀的汇编语言程序员使用空行来帮助分隔算法的各个部分,对几乎每一行都会添加注释。」 ↩ -
出处:「第10 章 汇编语言编程」第 374 段(
text/16-ch10.txt:374,搜「destination」)。原文:「我们所用的汇编器的汇编语言指令语法(Intel 语法)的一般格式为:operation destination, source1, source2。其中,destination(目标)是存储 operation(操作)结果的位置。」 ↩ ↩2 -
出处:「第10 章 汇编语言编程」第 459 段(
text/16-ch10.txt:459,搜「栈帧」)。原文:「每个函数都要用到调用栈的一部分存储相关信息,这部分称为栈帧(stack frame)。函数需要引用其栈帧,这个地址存储在 rbp 寄存器中,通常称为帧指针。」 ↩ ↩2 -
出处:「第10 章 汇编语言编程」第 364 段(
text/16-ch10.txt:364,搜「助记符」)。原文:「汇编语言提供了一组直接对应于机器语言指令的助记符……例如,mov 表示将一个值从一个位置复制(移动)到另一个位置的指令;机器指令 0x4889e5 将 rsp 寄存器中的整个 64 位值复制到 rbp 寄存器。」 ↩ -
出处:「第10 章 汇编语言编程」第 426 段(
text/16-ch10.txt:426,搜「餐盘」)。原文:「(想象一下把餐盘一个一个地叠放在架子上,然后在需要的时候把盘子拿走。)rsp 寄存器总是包含栈顶的地址,因此也被称为栈指针。」 ↩ -
出处:「第10 章 汇编语言编程」第 237 段(
text/16-ch10.txt:237,搜「老实说」)与第 128 段(text/16-ch10.txt:128,搜「note.GNU-stack」)。原文:「老实说,我也不知道为什么要使用 .ident 和 .section 汇编器指令。根据指令参数,我猜想是为了在用户报告 bug 的时候为 gcc 的开发人员提供信息。」 ↩ -
出处:「第10 章 汇编语言编程」第 466 段(
text/16-ch10.txt:466,搜「清单 10-6」)。原书清单 10-6 给出汇编版迷你程序的逐行注释;「函数序言(function prologue)」「函数结语(function epilogue)实质上是函数序言的镜像操作」见第 493、499 段(text/16-ch10.txt:493,搜「函数序言」;text/16-ch10.txt:23,搜「10.1 编译 C 」)。 ↩ -
出处:「第10 章 汇编语言编程」第 599 段(
text/16-ch10.txt:599,搜「恢复」)。原文:「如果帧指针和栈指针没有在返回调用函数之前恢复,你的程序一定会崩溃。为此,我经常会在 ret 指令处设置断点,这样就能检查函数是否正确地恢复了这两个寄存器。」 ↩ ↩2 -
出处:「第10 章 汇编语言编程」第 514 段(
text/16-ch10.txt:514,搜「gstabs」)。原文:「--gstabs 选项(注意这里有两个连字符)告诉汇编器在目标文件中包含调试信息。gcc 发现唯一的输入文件已经是目标文件,所以直接进入链接阶段。」 ↩ -
出处:「第10 章 汇编语言编程」第 571 段(
text/16-ch10.txt:571,搜「高亮」)至第 581 段。原文:「执行第一条指令 push rbp 会导致 gdb 在寄存器显示窗口中高亮显示 rsp 寄存器及其内容」「将一个 64 位寄存器的内容压栈,使得栈指针从 0x7fffffffde98(图 10-2)变为 0x7fffffffde90」「rip 寄存器的地址(右下方的 PC)从 0x555555555129 变为 0x55555555512a。这一变化表明刚刚执行过的指令 push rbp 只占用了一字节的内存。」 ↩ -
出处:「第10 章 汇编语言编程」第 524 段(
text/16-ch10.txt:524,搜「TUI」)与第 571 段(text/16-ch10.txt:571,搜「高亮」)。原文:「文本用户界面(Text User Interface,TUI)模式将终端窗口分为顶部的显示区域和底部的常用命令区域」「执行第一条指令 push rbp 会导致 gdb 在寄存器显示窗口中高亮显示 rsp 寄存器及其内容」。Emacs 下效果不佳见第 534 段(text/16-ch10.txt:534,搜「Emacs」)。 ↩ ↩2 -
出处:「第10 章 汇编语言编程」第 582 段(
text/16-ch10.txt:582,搜「内存的数据或地址视图」)。原文:「TUI 增强没有提供内存的数据或地址视图,只有反汇编视图。我们需要在命令区查看存储在内存中的数据和地址。例如,如果我们想要查看 push rbp 指令在栈中存储了什么,需要使用 x 命令来查看栈指针 rsp 所指向的内存。」 ↩ -
出处:「第10 章 汇编语言编程」第 651 段(
text/16-ch10.txt:651,搜「后缀」)。原文:「指定操作数大小的字符是作为大多数汇编器指令的后缀出现的」「每个寄存器都使用%字符作为前缀」。 ↩ -
出处:「第10 章 汇编语言编程」第 656 段(
text/16-ch10.txt:656,搜「相反的操作数顺序」)与第 668 段(text/16-ch10.txt:668,搜「保持一致」)。原文:「最明显的不同就是相反的操作数顺序」「AT&T 语法中的目标放在最后,而不是最前面」「我选择在本书中使用 Intel 语法,以便与 Intel 和 AMD 手册保持一致。据我所知,GNU 汇编器 as 是唯一默认使用 AT&T 语法的汇编器。」 ↩ -
出处:「第10 章 汇编语言编程」第 671–687 段(
text/16-ch10.txt:671,搜「小结」)。原文:「预处理……编译……汇编……链接」「文本段、数据段、栈段、堆段」「行格式 label: operation operand(s) # comment」「函数序言和函数结语」「TUI 模式」。 ↩ -
出处:「第10 章 汇编语言编程」第 357 段(
text/16-ch10.txt:357,搜「2000」)。原文:「我不会列出所有的 x86-64 指令(超过 2000 条指令,这取决于你是如何统计的),而是一次介绍几条……对于指令及其所有变体的详细描述,可参考 Intel® 64 and IA-32 Architectures Software Developer’s Manual 的第 2 卷,或者 AMD64 Architecture Programmer’s Manual 的第 3 卷。」 ↩