1.前言
IA32机器码以及汇编代码都与原始的C代码有很大不同,因为一些状态对于C程序员来说是隐藏的。例如包含下一条要执行代码的内存位置的程序指针(program counter or PC)以及8个寄存器。还要注意的一点是:汇编代码的ATT格式和Intel格式。ATT格式是GCC和objdump等工具的默认格式,在CSAPP中一律使用这种格式。而Intel格式则通常会在Intel的IA32架构文档以及微软的Windows技术文档中碰到。两者的主要区别有:
- Intel格式忽略指令中暗示操作数长度的后缀,例如mov而不是ATT格式的movl。
- Intel格式忽略寄存器名称前的%,例如esp而不是ATT格式的%esp。
- Intel格式用不同的方式描述内存位置,例如DWORD PTR [ebp+8]而不是ATT格式的8(%ebp)。
- Intel格式指令的操作数顺序与ATT格式的完全相反,ATT格式总是最后一个操作数是目标,例如movl %eax, (%edx)。
此外,作为16位处理器架构的遗留产物,如今的指令依旧用word指2个字节16位,而用double word指4个字节。所以指令中通常使用B、W、L表示操作数是1、2、4个字节的指令,例如数据移动指令的三个版本movb、movw、movl。
这一章通过学习程序的机器级底层表示,学会阅读底层代码。为什么逆向工程很难?因为源代码与编译后的代码往往不是一一对应的。编译器会引入源代码中不存在的新变量,同时为了节约寄存器的使用,编译器也经常将多个值映射到一个寄存器。对于循环来说,通过观察寄存器是如何在循环前初始化,在循环内的更新和条件检测以及循环后的使用,能够得到一些线索。
2.寄存器与寻址
第一章的笔记中我们看到,程序执行的很大一部分时间都是在将数据挪来挪去的。所以处理器支持只使用寄存器的1、2、4个字节,同时并且支持多种寻址方式。如下图右半边的表格中所示,这样我们就可以灵活地从内存中加载数据到寄存器,或者将寄存器中的值保存到内存。
虽然看起来有些眼花缭乱,但实际上最基本的形式就是最后一种:Imm(Eb, Ei, s)=Imm+R[Eb]+R[Ei]*s (R[X]指寄存器X的值)。一共四个参数控制寻址,看起来有些过于灵活,那就让我们想象一下它的应用场景。先不考虑Imm,那么最典型的应用就是访问数组中某个数据项。假如数组为int x[4],则此时Eb就是数组的首地址,相当于x,而Ei就是要访问数据项的下标,而s就是数组中数据类型的长度。例如我们要访问x[3],那么就相当于(x, 3, sizeof(int))=x+3*4。用C语言来写就是*(x+3),因为C语言自动按照指针的类型长度进行移动(编译器自动生成正确的代码),所以我们并不用自己计算偏移量乘以sizeof(int),但这都是后话了。那再加上Imm又能有何种应用场景,其实很简单,就是访问struct中的数组中的某一项。如下图所示,直接一条指令就能访问到结构中的数组中的某一项。
3.常用指令
下面是一些最常见的汇编指令及其含义:
- mov:数据移动。IA32强加了一条限制:一条移动指令的两个操作数不能都是内存地址。所以从一个内存位置拷贝数据到另一个内存位置是需要两条指令的。
- leal:加载地址。效果就是mov Imm(%a, %b, s), %x会将%x赋值为Imm+%a+s*%b,而不是M[Imm+%a+s*%b],所以有两个很有用的场景:1)拷贝地址。例如int *x=a汇编为mov (%eax), %edx,那么int x=&a汇编为leal (%eax), %edx。所以leal不会真的将a的值(即(%eax))保存到x(即%edx),而只是将a的地址(其实就是%eax)保存到x。2)简单算术运算。第二个很自然会想到的应用就是使用leal一条指令压缩简单的算术运算,例如leal 7(%edx, %edx, 4)=5x+7。
- jmp:直接跳转到标签,或间接跳转到寄存器中指定的地址。对于直接跳转,在汇编语言中通常就是符号化的标签表示。但之后汇编器或链接器要对其进行编码,最常见的编码方式就是PC相对地址。即用1、2、4字节的偏移量表示跳转目标地址与jmp指令紧接着的下一条指令的地址,如下图所示。但为什么是紧接着jmp指令的下一条指令的地址而不是jmp这一条的?其实也是有历史原因的,因为早期的处理器实现是先更新PC计数器作为第一步,然后再执行当前指令的。所以指令在执行的时候,其实PC已经指向下一条指令了,因此跳转的偏移量也就要相对下一条指令来说了。
4.类型转换时发生了什么
有符号转成无符号整数时,我们期望着编译器能将负数变成0,正数保留不变,长过最大长度的正数赋值成TMax。然而实际上相同长度的整数转换其实只是简单拷贝,什么都不做。并且当同时需要长度转换和类型转换时,C语言首先进行长度转换。长度转换后两个整数就都变成相同长度了,所以我们只需关注不同长度整数间的扩展和截断是如何进行的:
- 扩展:无符号进行零扩展,即用零填充高位。有符号进行符号扩展,即用最高位-符号位填充高位。
- 截断:简单地扔掉高位字节。对于小尾端来说,就是反过来,拷贝寄存器的高位如%al。
因为有符号整数在大部分机器上都是用反码进行编码的,对反码进行有符号扩展是不会改变其值的,在第二章中有过证明。反码就是这样神奇!0有唯一表示,并且有符号扩展时值还不变!关键就在于:高位扩展出一个1后,-2w+2w-1=-2w-1,还是等于扩展前的原值。
5.逻辑运算为什么要短路
第二章笔记中曾说过位运算和逻辑运算的两个区别,一是逻辑运算的眼中只有TRUE和FALSE,非0的不管是几都会被看做TRUE。而第二个区别就是逻辑运算的短路效果。那为什么逻辑运算会短路?因为逻辑运算是用jmp实现的。在汇编语言中,逐一判断条件表达式中的各个部分的真假,当某一部分判断出结果就直接跳转了。正因为逻辑运算是决定朝哪里运行,而不像位运算得出一个最终结果,所以汇编语言可以用跳转实现,所以就产生了高级语言中短路的性质。
6.局部变量其实就在寄存器里
其实局部变量是直接存储在寄存器的,大部分情况下都会一直在寄存器中,而不会落地到内存。例如第7部分中的函数swap_add(),函数运行时栈帧(内存)实际上没有保存任何局部变量。整个函数的局部变量和逻辑都在寄存器和ALU中执行完成。
在以下情况,局部变量会被保存在内存中(栈上):
- 当没有足够的寄存器来保存所有局部变量时。毕竟寄存器只有八个。
- 一些局部变量是数组或struct,因此必须通过指针访问。
- 当对局部变量进行取地址&运算时,因此必须产生一个内存地址给它。
7.运行时的代码与栈
下面来看一个函数调用的例子,深入学习代码底层是如何运行的。
caller()代码如下:
swap_add()代码如下:
编译器生成的代码会遵守一定的规则,这样在执行各种跳转、函数调用时才不会发生数据覆盖等问题,从而使程序正确的运行。
8.指针的本质
也许之前也曾听过,指针本质上就是一个内存地址。但之前没有顿悟,现在通过研究底层知识来强化理解。从下图可以看出,指针取值实际上是一种很自然的操作,因为大多数时候我们没法在一个寄存器里放下一个变量表示的全部数据,例如数组或结构。如果寄存器能够放下整个数组和结构,那我们当然没必要用指针了。所以很自然地,我们就会先加载数据的首地址的内存地址(就是指针!)到寄存器,然后再去访问寄存器指向的内存位置。