第3章 程序的机器级表示
机器代码的一些简单操作
使用 -S 选项可以让 gcc 生成汇编代码。使用 -C 选项生成二进制代码文件.o。
反汇编器可以根据机器代码产生类似汇编代码的格式。在 linux 中一般使用 objdump,即 objdump -d filename。
关于及其代码及反汇编表示:
x86-84的指令长度从1到15字节不等,根据使用的频繁程度赋予对应的字节长度。
从某个给定位置开始,字节解码成机器码是唯一的。
反汇编器根据机器代码中的字节序列来确定汇编代码,不需要访问源代码或原汇编代码。
以.开头的都是指导汇编器和连接器工作的伪指令,通常可以忽略。
数据格式与操作
称8位数据类型为字节byte,16位数据类型为字word,32位数据类型为双字double words,64位数据类型为四字quad words。
大部分 GCC 生成的汇编代码指令都有一个字符后缀,来表示操作数的大小,如movb,movw,movl,movq分别传输字节、字、双字和四字。
寄存器
一个 x86-64 的中央处理单元包括一组16分存储64位值的通用目的寄存器,用来存储整数数据和指针。

使用不同的指令可以操作寄存器中不同的低位字节。
注:当生成小于 8 字节结果的指令以寄存器作为目标时,寄存器剩下的字节会如何变化与操作数大小有关:
若生成的是1字节或者2字节的数,则剩下的字节保持原样。
若生成的是4字节的数,则会把高位的4个字节置为0。
操作数指示符
操作数分为三种:立即数,寄存器和内存。
立即数用来表示常规值,其书写方式是$后根标准 C 表示法表示的整数。注意到不同的指令允许的立即数的范围不同,汇编器会自动选择最紧凑的方式进行编码。
寄存器则是表示寄存器中存储的内容,用表示任意寄存器,用引用表示它的值。
内存引用是根据计算出来的地址访问某个内存位置,在这里我们将内存看作一个很大的字节数组。
通常用来这一通用形式来寻址。其中表示立即数偏移, 是基址寄存器, 是变址寄存器,是比例因子且只能为,最终的地址计算为 。注意 和 都必须是 64 位寄存器。
数据传输指令
使用指令 mov* A B 将 A 中的内容移动到 B 中,其中 * 根据移动操作的大小不同进行选择。
通常,mov指令只会更新目的操作数指定的寄存器字节或内存位置,但例外movl当以寄存器为目的时,会把该寄存器的高 4 字节设置为 0。
mov 指令的两个地址不能同时是内存地址。如果需要将一个值从内存的某个位置移动到另一个,需要先使用一次mov加载到寄存器中,再移动到内存中。
特殊指令movabsq以任意64位立即数作为源操作数,且只能以寄存器为目的。常规的movq指令只能以32位补码的立即数作为源操作数,然后把这个值的符号扩展到高位。
movz和movs两类指令是在从较小的源复制到较大的目的时使用,其中movz把剩余的字节填充为0,而movs把剩余的字节填充为最高符号位。
并不存在
movzlq这一指令,因为movl已经能将高4字节设置为 0。
特殊指令cltq没有操作数,直接将%eax符号扩展到%rax。等价于movslq %eax, %rax。
栈操作
指令pushq将数据压入程序栈中,而指令popq从程序栈中弹出数据。这两条指令操作的都是栈指针%rsp。
算术和逻辑操作
总览

指令leaq本来是用来计算内存位置的,但由于其可以简单地进行一些算术运算,所以很多时候编译器会将其用于算术。
所有的二元操作均是以第一个操作数为源操作数,第二个操作数为目的操作数。例如subq %rax, %rdx的解释为:将%rdx中的数减去%rax中的数,结果存放到%rdx中。
移位操作的第一个操作数k可以是立即数,也可以是存放在寄存器%cl中的数(只能为%cl)。当移动位长的数据时,只会取寄存器%cl的低位参与运算,其中。例如当%cl中的值是时,salb会移动 7 位,salw会移动15位,sall会移动31位,salq会移动63位。
特殊的算术操作
称16字节的数为八字oct word,Intel 提供了产生两个 64 位数字的全128位乘法以及整数除法的相关指令。

注意到指令imulq有两种形式:当其有两个操作数时,执行的是64位乘法;当只有一个操作数时,执行的是128位乘法。
对于除法,不论有无符号都将%rdx作为高64位、%rax作为低64位组成被除数,将指令的操作数作为除数,并将最终的商存放在%rax中,将余数存放在%rdx中。
特殊指令
cqto,读出%rax的符号位并复制到%rdx的所有位。
控制
条件码
CPU维护了一组单个位的条件码寄存器,用于描述最近的算术或者逻辑操作的属性。
常用条件码:
CF:进位标志。最高位是否发生了进位,用于判断无符号数的溢出。
ZF:零标志。最近操作的结果是否为 0 。
SF:符号标志。最近操作的结果是否是负数。
OF:溢出标志。最近的操作是否导致了补码溢出(正溢出和负溢出)
leaq指令用于进行地址计算所以不会改变任何条件码。对于逻辑运算,进位标志和溢出标志会设置为 0。对于移位操作,进位标志将设置为最后一个被移除的标志,溢出标志会设置为 0 。inc和dec指令会设置溢出和零标志,但不会改变进位标志。
使用 cmp和test两组指令可以仅修改条件码而不进行运算,其中cmp等价于sub,而test则等价于and。
test的典型用法:
两个操作数相同。通常用于与 0 进行比较。
一个操作数是掩码,用于指示哪些位应该被测试。
条件码通常不是直接读取的,而是通过间接的方式参与程序,共有三种方式。这三类方式在命名上有着相似的规则。
其一是根据条件码的某种组合设置指定字节。这是通过 set 指令组完成的。

其二是跳转指令,通过jmp指令组和标签实现跳转。值得注意的是,jmp跳转指令既可以以给定的标号作为跳转目标,也可以从内存或者寄存器中读取跳转目标。前者称为直接跳转,后者则称为间接跳转。

当执行 PC 相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。这在计算相对跳转时需要格外注意。
在很多时候,我们可以看到
jg等指令后面常常跟着rep然后才是ret。根据 AMD 的说法,当jg类似指令不发生跳转时,若后面直接跟着ret会使处理器不能预测ret指令的目的。故建议添加rep作为一个空指令。
其三是条件传送。根据条件码判断是否执行传送,在现代处理器的流水线架构下,这一方式在某些情况下尤为高效。

条件传送指令的目的只能是寄存器。汇编器可以从目标寄存器的名字推断出传送的操作数长度,所以无需在指令中指明。
跳转表
在 C 语言中,使用switch来实现跳转相关功能。而在汇编中,对应语句会翻译为间接跳转。具体来说,会生成一个只读的跳转地址表,当进行跳转时,只需要进行一次计算再根据结果进行一次跳转。这样能够更快地实现多重分支。
过程
过程将代码封装,用一组指定地参数和一个可选的返回值实现某种功能。同时,过程可以在程序的不同地方调用以实现复用。
通常,与过程相关的机器级机制包括:
传递控制:从过程 P 进入到过程 Q,要将程序计数器设置为 Q 的起始地址,而返回时要设置为 P 调用 Q 的后一条语句的地址。
传递数据:过程 P 要能向 Q 传递若干个数据,而 Q 可以向 P 返回一个数据。
分配和释放内存:过程 Q 在开始时可能需要对局部变量分配空间,而在返回时,又要释放空间。
运行时栈
在 x86-64中,栈向低地址方向增长,栈指针%rsp指向栈顶元素。
当过程需要的存储空间超过寄存器能够存储的大小时,就会在栈上分配空间,这部分称为栈帧。

转移控制
指令call和ret分别负责发起过程调用和从调用中返回。同跳转一样,call指令也有直接跳转和间接跳转两种。
call指令将返回地址压入栈中,然后跳转到调用的过程的第一条指令。而ret指令则从栈中弹出要返回的地址,然后返回。
数据传送
x86-64中最多通过寄存器传递 6 个整型参数,更多的参数则需要依靠栈来传递。
具体来说,假设有 个参数需要传递,前 6 个参数通过寄存器传递,超过的部分按照序号压入栈中,参数 7 位于栈顶。在栈中的数据都需要向 8 字节对齐。

返回值则通常是通过寄存器 %rax 传送。
寄存器
为确保被调用者不会覆盖调用者稍后需要使用的寄存器值,x86-64设定寄存器%rbx,%rbp,%r12-%r15为被调用者保存寄存器,即当过程 P 调用过程 Q 时,Q必须保证这些寄存器中的值在 Q 返回的时候与调用的时候一致,若 Q 需要用到这些寄存器,则必须先把原值压到栈中,返回时再取出旧值。
剩下的寄存器除了%rsp是栈指针外,均为调用者保存器,这意味着任何函数将都可以修改且不用关心后果。那么若 P 在调用 Q 后仍想使用原来里面的数值,就必须在调用 Q 之前自己保存好这些寄存器中的值。
数组
C 中的数组操作在汇编中大部分翻译为内存操作,从这里就可以看出x86-64定义的地址计算的便利之处。
对于多维数组,编译器在处理时先要计算偏移量。
编译器对于定长数组的寻址通常可以通过很聪明的指针操作来避免多次地址运算。当允许优化时,GCC 能够识别出程序访问多维数组的元素的步长,然后生成避免乘法的代码来加速运算。
异质数据结构——结构和联合
需要指出的是,不论是结构struct还是联合union都是 C 语言支持的,机器代码中不包含相关的声明。C 语言编译器在翻译的时候会将相关内容直接翻译为地址计算,并通过指令名字进行区分。
对于结构,指向结构的指针就是结构第一个字节的地址。C 语言维护其中每个字段的字节偏移,在访问的时候进行地址计算。
对于联合,C 语言同样维护对应的字节偏移。
数据对齐
无论数据是否对齐,x86-64硬件都应该能够正常工作。但一般而言,建议将数据对齐以提高内存系统的性能。
对齐原则:任何 K 字节的基本对象的地址都必须是 K 的倍数。编译器在汇编代码中会放入命令以指定全局数据所需的对齐,如.align 8保证它后面的数据的起始地址是 8 的倍数。
若代码中包含结构,编译器则可能会在字段中插入间隙以保证每个结构元素都满足对齐的要求。此外,结构本身对它的起始地址也有一些对齐要求。当结构作为数组存在时,编译器还需要考虑在结构尾部填充以使得数组中的每个元素都满足对齐要求。
内存越界攻击及其防治
内存越界引用和缓冲区溢出
注意到 C 语言对于数组引用不进行任何形式的边界检查,各种局部信息和调用状态信息都存储在栈中。当越界的数组元素修改破坏栈中信息时,有可能产生严重的后果。
攻击者可以利用这一特性,插入攻击代码并用指向攻击代码的指针覆盖返回地址。
对抗攻击
有多种方法用于避免遭受这样的攻击,或增大攻击的难度。
栈随机化
这一方法使得栈的位置在程序每次执行的时候都有所变化,而攻击者插入指向攻击代码的指针需要知道这个字符串放置的栈地址,如果这个地址是不可预测的,攻击就很难实施。
具体来说,程序开始时,在栈上分配一段 0-n 字节的随机大小空间,例如在 C 中使用分配函数 alloca。这段空间不会被使用,但是它会导致程序每次执行时后续的所有地址发生变化。
在 Linux 系统中,这一做法已经规定为标准行为,它是更大的一类技术——地址空间局部随机化
ASLR——中的一种。
采用这一技术,每次运行时,程序的不同部分(程序代码,库代码,栈,全局变量和堆数据)都会被加载到内存的不同位置。
然而,这一方法也并不是完全可靠的。攻击者可以在实际的攻击代码前加入一段很长的nop指令,只要攻击者能够猜中这段序列中的任何一个位置,程序就会被导向攻击代码。这一攻击方法被称为“空操作雪橇”(nop sled)。
栈破坏检测
这一方法在局部缓冲区和栈的状态之间存储一个特殊的哨兵值(金丝雀值canary),这一数值在程序每次运行时随机生成。在恢复寄存器状态时,程序先检测哨兵值是否被破坏,以此来检验程序是否被攻击。
在最新的 GCC 版本中,已经加入这一机制。特别的是,GCC 只在函数中有局部 char 类型缓冲区时才进行这类操作。
限制可执行代码区域
这一方法可以消除攻击者向代码中插入可执行代码的能力。限制哪些内存区域能够存放可执行代码。
在过去x86体系结构认为被标记为可读的页也是可以执行的。最新的 AMD 和 Intel 处理器均引入了机制将读和执行这两种访问模式分开,这一特性使得对于页是否可以执行的检查由硬件完成,与过去相比不再有性能损失。
变长栈帧
x86-64代码使用寄存器%rbp作为帧指针(基指针),在整个函数调用过程中,%rbp始终指向那个时刻栈的位置,然后用固定长度的局部变量相对于%rbp的偏移量来引用它们。
浮点代码
这里基于 AVX2 介绍浮点代码。在 gcc 中使用参数 -mavx2 生成对应代码。
这一体系允许数据存储在 16 个 YMM 寄存器中,每个均为 位(32字节)。当对标量数据操作时,这些寄存器只保存浮点数,且只使用低32位(float)或64位(double)。可以使用对应的 XMM 寄存器访问其低 128 位。

浮点数也有对应的传送指令。这类指令中引用内存的军事标量操作,这意味着它们只对单个而不是一组封装好的数据值进行操作。
代码优化建议,32位内存数据满足 4 字节对齐,64位内存数据满足 8 字节对齐。

浮点数之间的互相转换以及与整数类型之间的转换。把浮点值转化为整数时,会进行截断,即把值向 0 舍入。
注意其中不常见的三操作数指令。在这里我们可以忽略第二个操作数,因为它的值只对结果的高位字节有影响

在调用和返回的时候,XMM 寄存器用来传递浮点参数,最多可以用%xmm0~%xmm7共 8 个浮点参数,额外的浮点参数使用栈来传递。
寄存器 %xmm0用于返回浮点值。需要指出的是,所有的 XMM 寄存器都是调用者保存的。
当函数包含指针、整数和浮点数混合的参数时,指针和整数通过通用寄存器传递,浮点值通过XMM寄存器传递。
浮点运算:

与整数运算不同的是,AVX浮点操作不能以立即数作为操作数,编译器必须为所有的常量值分配和初始化空间,代码中需要用到这些值的时候再从内存中读入。一个例子如下:

浮点数的位级操作。通常用于一些简化计算。

浮点数的比较,与 cmp 类指令相似,同样是设置条件码。这里浮点比较码会设置零标志位 ZF,进位标志位 CF 和奇偶标志位 PF。对于整数操作,奇偶标志位当且仅当最近的一次算术或逻辑运算产生的值的最低位字节是偶校验的(即这个字节中有偶数个 1)。对于浮点比较,奇偶标志位当且仅当两个数中至少有一个是 NaN 时设置。
用 jp 跳转指令来得到 NaN 的情况。