对机器而言,程序只是一个字节序列,它对你写的源代码一无所知。
3.1 历史观点
- 每个后继处理器的设计都是向后兼容的——较早版本上编译的代码可以在较新的处理器上运行;
3.2 程序编码
- 编译选项
-Og
告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级; - 对于机器级编程来说,两种重要抽象分别是
- 由指令集架构(ISA=Instruction Set Architechture)来定义机器级程序的格式和行为。大多数 ISA 将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条再开始。实际上处理器的硬件可以并发的执行许多指令,但是可以采取措施保证整体行为与 ISA 指定的顺序执行行为完全一致;
- 机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去像一个非常大的字节数组。存储器的实际实现是将多个硬件存储器和操作系统软件组合起来的;
- 一些重要的处理器状态
- 程序计数器(PC):在 x86-64 中用%rip表示,里面存放的值代表着将要执行的下一条指令在内存中的地址;
- 整数寄存器文件包含 16 个命名的位置,分别存储64位的值。这些寄存器可以存储地址或整数数据;
- 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息,每个通常只有一个 bit;
- 一组向量寄存器可以存放一个或多个整数或者浮点数值;
- ATT 与 Intel 汇编代码格式
- ATT 汇编代码格式是 GCC、OBJDUMP 和其他一些我们使用的工具的默认格式(本书中,采用ATT格式);
- 其他一些编程工具,包括 Microsoft 的工具,以及来自 Intel 的文档,其汇编代码都是 Intel 格式的;
- Intel 和 ATT 格式的不同
- Intel 代码省略了指示大小的后缓,指令 push 而不是 pushq;
- Intel 代码省略了寄存器名字前面的 ‘%’ 符号,用的是 rbx,而不是 %rbx;
- Intel 代码用不同的方式来描述内存中的位置,例如是 ‘QWORD PTR [rbx]’而不是‘(%rbx)’;
- 在带有多个操作数的指令情况下,列出操作数的顺序相反;
- 对于一些应用程序,程序员必须用汇编代码来访问机器的低级特性
- 一种方法是用汇编代码编写整个函数,在链接阶段把它们和C函数组合起来;
- 另一种方法是利用 GCC 的支持,直接在C程序中嵌入汇编代码;
- 在C程序中包含汇编代码会使得这些代码与某类特殊的机器相关,所以只应该在想要的特性只能以此种方式才能访问到的时候使用这种方式;
3.3 数据格式
- 大多数 GCC 生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。比如 b,w,l,q;
3.4 访问信息

- 机器指令可以对这 16 个寄存器的低位字节中存放的不同大小的数据进行操作。对于生成小于 8 字节结果的指令有两条规则:
- 生成 1 字节和 2 字节数字的指令会保持剩下的字节不变;
- 生成 4 字节数字的指令会把高位的 4 个字节置为 0;
- 各种不同的操作数的可能性被分为三种类型:
- 立即数;
- 寄存器;
- 内存引用;
- 寻址模式
- 语法 $ Imm(r_b,r_i,s) $ 表示的是最常用的形式。这样的引用有四个组成部分:一个立即数偏移Imm,一个基址寄存器 $ r_b $ ,一个变址寄存器 $ r_i $ 和一个比例因子 s,这里 s 必须是 1、2、4 或者 8;
- 基址和变址寄存器都必须是 64 位寄存器。有效地址被计算为 $ Imm+R[r_b]+R[r_i]*s $ 引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分;
- 数据传送指令
- 最频繁使用的指令是将数据从一个位置复制到另一个位置的指令;
- 源操作数指定的值是一个立即数,存储在寄存器或者内存中;
- 目的操作数指定一个位置,可以是寄存器或者内存地址;
- x86-64 加了一条限制,传送指令的两个操作数不能都指向内存位置;
- 数据传送示例
- C语言中所谓的指针其实就是地址。间接引用就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器;
- 局部变量通常是保存在寄存器中,而不是内存中。访问寄存器比访问内存要快得多;
- 压入和弹出栈数据
- 栈向下增长,所有栈顶元素的地址是所有栈中元素地址最低的;
- 因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标准的内存寻址方法访问栈内的任意位置;
3.5 算术和逻辑操作

- 加载有效地址指令 leaq 实际上是 movq 指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。
- 左移指令有两个名字: SAL 和 SHL,两者效果一样,都是将右边填上 0;
- 右移指令不同,SAR 执行算术移位(填上符号位),SHR 执行逻辑移位(填上0);
3.6 控制
- 除了整数寄存器,CPU 还维护着一组单个位的条件码寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令;
- 常用条件码
- CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出;
- ZF:零标志。最近的操作得到的结果为 0;
- SF:符号标志。最近的操作得到的结果为负数;
- OF:溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出;
- leaq 指令不改变任何条件码,因为它是用来进行地址计算的;
- CMP、TEST 指令只设置条件码而不改变任何其他寄存器;
- CMP 指令和 SUB 指令的行为是一样的,如果两个操作数相等,这些指令会将零标志设为 1;
- TEST 指令的行为和 AND 指令一样,除了它只设置条件码而不改变目的寄存器的值;其典型用法是,两个操作数是一样的,如:testq %rax, %rax 用来检查 %rax 是负数、零、还是正数;
- 条件码通常不会直接读取,常用的使用方法有三种:
- 可以根据条件码的某种组合,将一个字节设置为 0 或 1;
- 可以条件跳转到程序的某个其他部分;
- 可以有条件地传送数据;
- 大多数情况下,机器代码对于有符号和无符号两种情况都使用一样的指令,这是因为许多算术运算对无符号和补码算术都有一样的位级行为;
- 跳转指令有几种不同的编码,最常用的是“PC相对的”(PC-relative),它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码;第二种编码方法是给出“绝对”地址,用 4 个字节直接指定目标。汇编器和链接器会选择适当的跳转目的编码;
- rep/repz 指令是一种空操作,用 rep 后面跟 ret 的组合来避免使 ret 指令成为条件跳转指令的目标;
- 实现条件操作
- 控制的条件转移(传统):根据代码的条件结果来选择执行的路径;
- 数据的条件传送(更符合现代处理器的性能特性):指先把结果执行,在根据条件结果选择结果值;
- 为什么基于条件数据传送的代码会比基于条件控制转移的代码性能要好?
- 我们必须了解一些关于现代处理器如何运行的知识。处理器通过使用流水线(pipelining)来获得高性能,在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。这种方法通过重叠连续指令的步骤来获得高性能,例如,在取一条指令的同时,执行它前面一条指令的算术运算。要做到这一点,要求能够事先确定要执行的指令序列,这样才能保持流水线中充满了待执行的指令。当机器遇到条件跳转(也称为“分支”)时,只有当分支条件求值完成之后,才能决定分支往哪边走。处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行。只要它的猜测还比较可靠(现代微处理器设计试图达到 90% 以上的成功率),指令流水线中就会充满着指令。另一方面,错误预测一个跳转,要求处理器丢掉它为该跳转指令后所有指令已做的工作,然后再开始用从正确位置处起始的指令去填充流水线。正如我们会看到的,这样一个错误预测会招致很严重的惩罚,浪费大约 15~30 个时钟周期,导致程序性能严重下降。
- 另一方面,无论测试的数据是什么,编译出来使用条件传送的代码所需的时间都是约8个时钟周期。控制流不依赖于数据,这使得处理器更容易保持流水线是满的。
- 使用条件传送也不总会提高代码的效率。总的来说,条件数据传送提供了一种用条件控制转移来实现条件操作的替代策略。它们只能用于非常受限的情况,但是这些情况还是相当常见的,而且与现代处理器的运行方式更契合。
- 逆向工程循环:理解产生的汇编代码与原始代码之间的关系,关键是找到程序值和寄存器之间的映射关系。对逆向工程循环来说,看看在循环之前如何初始化寄存器,在循环中如何更新和测试寄存器,以及在循环之后又如何使用寄存器是一个通用的策略。
- 跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索引值等于i时程序应该采取的动作。
- 程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。和使用一组很长的 if-else 语句相比,使用跳转表的优点是执行开关语句的时间和开关情况的数量无关。甚至当 switch 语句有上百种情况的时候,也可以只用一次跳转表访问去处理。
- GCC 根据开关情况的数量和开关情况值的稀疏程度来翻译开关语句。当开关情况数量比较多,并且值的范围跨度比较小时,就会使用跳转表。
3.7 过程
- 过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后可以在程序中不同的地方调用这个函数;
- 运行时栈:C语言过程调用机制的一个关键特性(大多数其他语言也是如此)在于使用了栈数据结构提供的后进先出的内存管理原则。当 x86-64 过程需要的存储空间超出寄存器能够存放的大小的时候,就会在栈上面分配空间。这个部分成为过程的栈帧;
- 当前正在执行的过程的帧总是在栈顶。大多数过程的栈帧都是定长的,在过程的开始就分配好了。为了提高空间和时间效率,x86-64 过程只分配自己所需要的栈帧部分;
- 实际上,许多函数甚至不需要栈帧。当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其它的函数时,就可以这样处理;
- 转移控制:可以看到,这种把返回地址压入栈的简单机制能够让函数在稍后返回到程序中正确的点;
- 数据传送:x86-64 中,大部分过程的数据传送是通过寄存器实现的;
- 如果一个函数有大于 6 个整型参数,超出 6 个的部分就要通过栈来传递。通过栈传递参数时,所有的数据大小都向8的倍数对齐;
- 栈上的局部存储:一般来说,过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分,标号为局部变量;运行时栈提供了一种简单的、在需要时分配、函数完成时释放局部存储的机制;
- 寄存器中的局部存储空间:寄存器是唯一被所有过程共享的资源。虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器的值。为此,x86-64 采用了一组统一的寄存器使用惯例,所有过程都必须遵循:
寄存器 %rbx,%rbp 和 %r12 到 %r15 被划分为被调用者保存寄存器。过程 P 调用 Q 时,Q 必须保存这些寄存器的值,保证它们的值在 Q 返回到 P 时与 Q 被调用时是一样的。所有其它的寄存器,除了栈指针寄存器%rsp以外,都被分类成调用者保存寄存器。意味着任何被调用者都能修改它们,所以调用者要事先保存好它们的值,再去开始调用函数; - 递归过程:前面已经描述的寄存器和栈的惯例使得 x86-64 过程能够递归的调用它们自身。每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不会互相影响;
3.8 数组分配和访问
- C语言中的数组是一种将标量数据聚集成更大数据类型的方式;
- 基本原则:指向数组开头的指针为x,则数组元素i会被存放在地址为
x + L * i
的地方,其中 L 为该数组的数据类型的大小; - 指针运算:同一个数据结构中的两个指针之差等于两个地址之差除以该数据类型的大小;
- 嵌套的数组:当我们创建数组的数组时,数组分配和引用的一般原则也是成立的;
- 定长数组:当程序要用一个常数作为数组的维度或者缓冲区的大小时,最好通过
# define
声明将这个常数与一个名字联系起来,然后在后面一直使用这个名字代替常数的数值; - 变长数组:引用变长数组只需要对定长数组做点概括。动态的版本必须用乘法指令对i伸缩n倍,而不能使用一系列的移位和加法。在一些处理器中,乘法会招致严重的性能处罚,但在这种情况中无可避免;
3.9 异质的数据结构
- 结构(strcut)将多个对象集合到一个单位中;联合(union)允许用几种不同的类型来引用一个对象;
- 结构:结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址;
- 将指向结构的指针从一个地方传递到另一个地方,而不是复制它们,这是很常见的;
- 结构的各个字段的选取完全是在编译时处理的。机器代码不包含关于字段声明或字段名字的信息;
- 联合:联合提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对象;
- 一个联合的总的大小等于它最大字段的大小;
- 它能引起一些讨厌的错误,因为它们绕过了C语言类型系统提供的安全措施;
- 一种应用情况是如果我们事先知道对一个数据结构中的两种不同字段的使用是互斥的,那么将这两个字段声明为联合的一部分,而不是结构的一部分,会减小分配空间的总量。对于有较多字段的数据结构,这样的节省会更吸引人;
- 联合还可以用来访问不同数据类型的位模式;
- 数据对齐:许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计;
- 例如一个处理器总是能从内存中取 8 个字节,则地址必须为 8 的倍数;如果我们能保证所有的 double 类型数据的地址对齐成 8 的倍数,那么就可以用一个内存操作来读或者写值了。否则我们可能需要执行两次内存访问,因为对象可能被存放在两个 8 字节内存块中;
- 无论数据是否对齐,x86-64 硬件都能正确工作。不过,Intel 还是建议要对齐数据以提高内存系统的性能;
- 对齐原则是任何K字节的基本对象的地址必须是K的倍数:
- 对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足它的对齐要求;而且结构本身对它的起始地址也有一些对齐要求;
3.10 在机器级程序中将控制与数据结合起来
- 缓冲区溢出,这是现实世界中许多系统中的一种很重要的安全漏洞;
- 理解指针:每个指针都对应一个类型。特殊的
void *
表示通用指针,可以被显式或者隐式的转换成有类型的指针;每个指针都有一个值。特殊的NULL(0)
值表示该指针没有指向任何地方; - 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值;
- 指针也可以指向函数,函数指针的值是该函数机器代码表示中第一条指令的地址;
- 应用-使用 GDB 调试器:GNU 的调试器 GDB 提供了许多有用的特性,支持机器级程序的运行时评估和分析;
- 内存越界引用和缓冲区溢出:我们已经看到,C语言对于数组引用不进行任何边界检查,而且局部变量和状态信息都存放在栈中。这两种结合到一起就能导致严重的程序错误;
- 缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法;
- 对抗缓冲区溢出的攻击:
- 栈随机化:栈随机化的思想使得栈的位置在程序每次运行时都有变化。在 Linux 系统中,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这类技术被称为地址空间布局随机化(ASLR=Address-Space Layout Randomization),ASLR 技术能够增加成功攻击一个系统的难度;
- 栈破坏检测:破坏通常发生在当超越局部缓冲区的边界时。最近的 GCC 版本在产生的代码中加入了一种栈保护者机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值(canary,这里是引申金丝雀能够当作哨兵提前察觉到危险存在),栈保护很好的防止了缓冲区溢出攻击破坏存储在程序栈上的状态;
- 限制可执行代码区域:一种方法是限制哪些内存区域能够存放可执行的代码;
- 支持变长的帧:为了管理变长的帧,x86-64 代码使用寄存器 %rbp 作为帧指针,有时也被称为基指针(base point);