本章主要讲汇编。
- 通过阅读汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。此外,也有些时候,高级语言提供的抽象层会隐藏我们想要了解的程序运行时行为。另外,了解一些漏洞的出现及如何防御,需要具备程序机器级表示的知识。
- 对于机器级编程来说,有两种抽象尤为重要。第一种是由指令集体系结构或指令集架构(Instruction Set Architecture,ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。
- 一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。
- 大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。各种不同的操作数的可能性被分为三种类型。第一种类型是立即数,用来表示常数值。第二种类型是寄存器,它表示某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节数分别对应于8位、16位、32位或64位。第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。
下图是多种不同的寻址模式,允许不同形式的内存引用。
- 下图列出的是最简单形式的数据传送指令——MOV类。这些指令把数据从源位置复制到目的位置,不做任何变化。
源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个寄存器,要么是一个内存地址。x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。
- 压入和弹出栈数据。
pushq指令的功能是把数据压入到栈上,而popq指令是弹出数据。这些指令都只有一个操作数——压入的数据源和弹出的数据目的。
将一个四字值压入栈中,首先要将栈指针减8,然后将值写到新的栈顶地址。因此,指令pushq %rbq的行为等价于下面两条指令:
弹出一个四字的操作包括从栈顶位置读出数据,然后将栈指针加8。因此,指令popq %rax等价于下面两条指令:
- 下图列出了x86-64的一些整数和逻辑操作。这些操作被分为四组:加载有效地址、一元操作、二元操作和移位。
- 除了整数寄存器,CPU还维护着一组单个位的条件码寄存器,它们描述了最近的算术或逻辑操作的属性。还可以检测这些寄存器来执行条件分支指令。最常见的条件码有:
- CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
- ZF:零标志。最近的操作得到的结果为0.
- SF:符号标志。最近的操作导致一个补码溢出——正溢出或负溢出。
还有两类指令,它们只设置条件码而不改变任何其他寄存器。
CMP指令根据两个操作数之差来设置条件码。
TEST指令的行为与AND指令一样,除了它们只设置条件码而不改变目的寄存器的值。
例如,testq %rax,%rax 用来检查%rax 是负数、零,还是正数。
- 正常执行的情况下,指令按照它们出现的顺序一条一条地执行。跳转(jump)指令会导致执行切换到程序中一个全新地位置。
- 汇编中没有可以实现循环的指令存在,但是可以用条件测试和跳转组合起来实现循环的效果。
看一下while循环:
翻译到goto代码:
再看个例子,可以更深入的理解
11. switch语句可以根据一个整数索引值进行多重分支。它不仅提高了C代码的可读性,而且通过使用跳转表这种数据结构使得实现更加高效。跳转表是一个数组,表项i 是一个代码段的地址,这个代码段实现当开关索引值等于i 时程序应该采取的动作。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。
12. 过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。不同的编程语言中,过程的形式多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等。
为了讨论方便,假设过程P调用过程Q,Q执行后返回到P。这些动作包括下面一个或多个机制:
传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。
分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
当P调用Q时,控制和数据信息添加到栈尾。当P返回时,这些信息会释放掉。
当x86-64过程需要的存储空间超出存储器能够存放的大小时,就会在栈上分配空间。这部分称为过程的栈帧(stack frame)。
- 将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q地代码地起始位置。不过,当稍后从Q返回时,处理器必须记录好它需要继续P地执行的代码位置。在x86-64机器中,这个信息是用指令call Q调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call 指令后面的那条指令的地址。对应的指令ret 会从栈中弹出地址A,并把PC设置为A。
- 有些时候,局部数据必须存放在内存中,常见的情况包括:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变量使用地址运算符’&’,因此必须能够为它产生一个地址。
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
-
寄存器组是唯一被所有过程共享的资源。虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值。为此,x86-64采用了一组统一的寄存器使用惯例,所有的过程都必须遵循。
根据惯例,寄存器%rbx、%rbq 和 %r12~%r15 被划分为被调用者保存寄存器。当过程P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回到P时与Q被调用时是一样的。
所有其他的寄存器,存了栈指针%rsp,都分类为调用者保存寄存器。这就意味着任何函数都能修改它们。可以这样来理解“调用者保存”这个名字:过程P在某个此类寄存器中又局部数据,然后调用过程Q。因为Q可以随意修改这个寄存器,所以在调用之前首先保存好这个数据是P(调用者)的责任。 -
在递归过程中,每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不会相互影响。
-
许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4、8)的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。对齐原则是任何K字节的基本对象的地址必须是K的倍数。可以看到这条原则会得到如下对齐:
举个例子,比如下面的结构声明:
struct S1 {
int i;
char c;
int j;
};
假设编译器用最小的9字节分配,如图:
它是不可能满足字段i(偏移为0)和j(偏移为5)的4字节对齐要求的。取而代之,编译器在字段c和j之间插入一个3字节的间隙。
18. 理解指针:
- 每个指针都对应一个类型。这个类型表明该指针指向的是哪一类对象。
- 每个指针都有一个值。这个值是某个指定类型的对象的地址。
- 指针用
&
运算符创建。 *
操作符用于间接引用指针。其结果是一个值,它的类型与该指针的类型一致。- 数组与指针紧密联系。
- 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
- 指针也可以指向函数。
- C对于数组引用不进行任何边界检查,而且局部变量和状态信息都存放在栈中。这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。
一种特别常见的状态破坏称为缓冲区溢出(buffer overflow)。通常,在栈中分配某个字符数组来保存一个字符串,但是字符串长度超出了为数组分配的空间。