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.指针的本质
也许之前也曾听过,
指针本质上就是一个内存地址。但之前没有顿悟,现在通过研究底层知识来强化理解。从下图可以看出,指针取值实际上是一种很自然的操作,因为大多数时候我们
没法在一个寄存器里放下一个变量表示的全部数据,例如数组或结构。如果寄存器能够放下整个数组和结构,那我们当然没必要用指针了。所以很自然地,我们就会先加载数据的首地址的内存地址(就是指针!)到寄存器,然后再去访问寄存器指向的内存位置。