第三章 程序的机器级表示
本章通过对比C语言程序代码和汇编程序代码了解程序的机器级表示
3.1 历史的观点
Intel处理器的发展历史,后文基于IA32指令集。
3.2 程序编码
基于unix系统gcc编译器;
在linux系统使用如下命令编译c文件,它会调用一些列程序(参考1.2),将c转化为可执行代码。
gcc -O2 -o helloworld helloworld.c
gcc是linux默认编译器
-O2 表示使用第二级优化,优化级别越高,程序运行的越快,但编译时间会更久。
3.2.1 机器级代码
汇编代码非常接近机器代码,但可读性更高,能够理解汇编代码以及它是如何与c代码对应的,是理解计算机如何执行程序非常关键的一步。
汇编程序可以看到如下c程序中无法提现的内容:
1. 程序计数器(%eip)
2. 整数寄存器:8个,用于存储32位值,可以存贮地址(对应c的指针),整数数据。比如程序状态,或临时数据(如局部变量)。
3. 条件码寄存器:保存最近执行的算数指令的状态信息。主要用来实现控制流中的条件变化(如实现if,while语句)
4. 浮点寄存器:8个,用于存储浮点数据
c可以在存储器中声明和分配各种类型的数据,但汇编只把存储器看成一个按字节寻址的大数组,不区分数据类型。
一条机器指令只能执行非常简单的基本操作。如将两个寄存器的数据相加,在存储器和寄存器之间传递数据,条件分支转移到新的指令等,编译器就是把c转换成这种简单的序列来实现c中复杂的代码。
3.2.2 代码示例
这本书太老了,所有的示例,在12代i5,ubuntu18的系统上运行和本书都对不上,所以强行理解吧。
int accum = 0;
int sum(int x, int y)
{
int t = x + y;
accum += t;
return t;
}
通过-S参数编译汇编代码code.s
gcc -O2 -S code.c
GCC是用过GAS(Gnu ASsembler) 格式产生汇编代码的,这种格式和intel文档和微软编译器使用的格式差异很大。
生成的汇编代码如下
sum:
pushl %ebp
movl %esp,%ebp
movl 12(%ebp), %eax
addl 8(%ebp), %eax
addl %eax, accum
movl %ebp,%esp
popl %ebp
ret
使用-c参数可以生成目标文件code.o
gcc -O2 -c code.c
使用反汇编器objdump可以将目标文件反汇编成一种类似于汇编代码的格式,输入如下命令
objdump -d code.o
直接输出结果
通过这个结果可以发现以下特性:
1. IA32指令长度从1~15个字节不等。指令编码被设计成常用的指令以及操作数较少的指令所需的字节数少,而那些不常用的或操作数较多的指令所需字节数较多。
2. 指令格式:从某个给定位置开始,可以将字节唯一地解码成机器指令。如只有指令push1 %ebp是以字节值55开头的。
3. 反汇编器只是根据目标文件中的字节序列来确定汇编代码的。它不需要访问程序的源代码或汇编代码。
4. 反汇编器使用的指令命名规则与GAS有细微差别。上面结果中省略了很多指令结尾的“l”。
5. 与code.s中的汇编代码相比,结尾多了nop指令。它根本不会被执行(它在过程返回指令之后),即使执行也不会有任何影响(nop,即no operation)。编译器插入这条指令的目的是为了填充存储该过程的空间。
实际的可执行代码必须包含main函数
int main()
{
return sum(1, 3);
}
使用如下命令生成可执行文件
gcc -O2 -o prog code.o main.c
3.2.3 关于格式的注释
GCC产生的汇编包含一些程序员不需要关系的信息,比如以“.”开头的行都是指导汇编器和链接器的命令(directive),后面的代码示例将会添加行号和注释。
3.3 数据格式
C基本数据类型的机器表示,GAS的每个操作都有一个字符后缀,表面操作数的大小。例如movb传送字节,movw传送字,movl传送双字。
3.4 访问信息
IA32的cpu包含8个32位寄存器大多数情况,前六个寄存器可作为通用寄存器,最后两个寄存器保存着指向程序栈中重要位置的指针,只有根据栈管理的标准惯例才能修改这两个寄存器的值。
如图所示,字节操作指令可以独立地读写前四个寄存器的两个低位字节,类似ah和al被称为寄存器的元素
3.4.1 操作数指令符
IA32支持多种操作数格式
操作数可以分为三种类型:
1. 立即数(immediate):在GAS中用$接整数,如$-577或$0xF;
2. 寄存器(register):表中用Ea表示任意寄存器a,用R[Ea]表示它的值,相当于将寄存器集看成数组R,Ea看做索引。
3. 存储器引用:根据计算出来的地址访问存储器的位置,用Mb[Addr]表示对存储在存储器中从地址Addr开始的b字节值的引用。为简便,通常省去b。
表中最下方是最通常的形式,由4部分组成,一个立即数便宜Imm,一个基址寄存器Eb,一个变址或索引寄存器Ei,一个伸缩因子s(scale factor),s必须是1、2、4、8;其他形式是这种通用形式的特殊情况,省略了某些部分。
练习题和答案
3.4.2 数据传送指令
数据传送指令注意点:
源操作数:可以使立即数,寄存器,存储器地址;
目的操作数:寄存器,存储器
源操作数和目的操作数不能同时是存储器地址,所以如果要实现从存储器的一个地址传输数据到另一个地址的功能,需要拆分成两条指令实现。
movsbl:单字节传输,将前面24位设置为源字节的最高位扩展成32位,然后传输到目的操作数。
movzbl:单字节传输,将前面加24个0,扩展成32位,然后传输到目的 操作数中。
pushl和popl用来压栈和出栈。栈指针是前面的8个寄存器中的倒数第二个%esp。栈向下增长,即栈顶地址是最低的。
push1 %ebp
等同于
subl $4, %esp
movl %ebp, (%esp)
流程图
3.4.3 数据传送示例
从上面的代码可知:
1. 过程参数xp和y存储在寄存器%ebp中地址偏移8和12的地方。
3.5 算数和逻辑操作
下表列出一些双字整数操作。
3.5.1 加载有效地址
加载有效地址(Load Effective Address) 指令leal实际是movl指令的变形,它是将有效地址写入目的地址。假设%edx的值是x,则下面指令的作用是把%eax的值设为7+5x
leal 7(%edx, %edx,4), %eax
练习题3.3 假设%eax值为x,%ecx值为y,填写结果。
3.5.2 一元和二元操作
一元操作:只有一个操作数,既做源,又做目的。这个操作数可以是寄存器和存储器地址。比如incl(%esp)会使栈顶元素加1。
二元操作:第二个操作数既是源,又是目的,如subl %eax,%edx
3.5.3 移位操作
移位量用单个字节编码,只允许0~31位的移位,移位量可以是立即数,也可以放在单字节寄存器元素%cl中。
算数左移和逻辑左移一致,都是低位补0。
sarl执行算数右移 ,高位补符号位,shrl执行逻辑右移,高位补0
3.5.4 讨论
3.5.5 特殊的算数操作
3.6 控制
3.6.1 条件码
除了上面介绍的8个整数寄存器,cpu还包含一组单个位的条件码(condition code)寄存器,它描述了最近的算数或逻辑操作的属性。常用条件码如下:
CF:进位标志。最近的操作使最高位产生了进位,可用来检查无符号操作数的溢出。
ZF:0标志。最近的操作得出的结果为0.
SF:符号标志。最近的操作得到的结果为负数。
OF:溢出标志。最近的操作导致一个二进制补码溢出--正溢出或负溢出。
比如用addl实现t=a+b功能,会根据下面表达式设置条件码
CF: (unsigned t) < (unsigned a) 无符号溢出
ZF:(t == 0) 零
SF: (t < 0) 负数
OF:(a<0 == b<0)&& (t<0 != a<0)
除了leal指令,前面表格中所有的算数,逻辑操作都会设置条件码。
leal指令:不改变条件码,因为它只进行地址计算。
逻辑操作:主要是设置ZF,其他条件码都为0.
移位操作:CF将被设置为最后一个被移除的位,OF设置为0
下面的操作只设置条件码,不改变寄存器和存储器的值。
cmp*指令根据两个操作数之差来设置条件码
test*指令根据两个操作数的与(AND)逻辑设置0标志ZF和符号标志SF.通常两个操作数是一样的,如
testl %eax %eax
用来检查%eax是整数、负数还是0
或者一个操作数用来作为掩码,测试另一个操作数的某些位。
3.6.2 访问条件码
通常条件码不单独使用,而是组合使用。下表各种set指令,是根据条件码的组合,将一个字节设置成0或1.
3.6.3 跳转指令和他们的编码
下表是跳转指令,其中jmp是无条件跳转,其它都是条件跳转,条件跳转只能是直接跳转。
直接跳转:跳转目标是作为指令的一部分编码的。使用一个标号作为跳转目标。
jmp .L1
间接跳转:跳转目标是从寄存器或存储器读取的。使用*后面接一个操作数指示符
jmp *%eax
跳转指令的目标是如何编码的?
方式1:将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。
方式2:给出绝对地址,用四个字节直接指定目标。
下图是.o文件反汇编的结果,解释了方式1的编码。0x11+0xa=0x1b, 0xf5+0x1b=0x1b,0xf5是负数十进制-11.
3.6.4 翻译条件分支
本小节介绍c中if else对应的机器码。使用goto更便于汇编语言和c语言对照,理解下图例子即可
3.6.5 循环
汇编使用条件测试和跳转的组合实现循环,对照下面代码学习,汇编代码中只包含循环内的代码
3.6.6 switch语句
介绍汇编如何实现Switch语句的
3.7 过程
主要介绍c中函数调用对应的汇编
过程调用:包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它必须在进入时为过程的局部变量分配空间,并在退出时释放。
3.7.1 栈帧结构
IA32用程序栈来支持过程调用。栈用来传递过程参数,存储返回信息,保存寄存器以供以后恢复只用,以及用于本地存储。
为单个过程分配的栈称为栈帧(stack frame)。通用栈帧结构如下图所示,当程序执行时,栈指针是可以移动的,所以大多数的信息访问都是相对于栈指针的。
假如过程P调用过程Q,Q的参数保存在P的栈帧中。
当P调用Q时,P中的返回地址被压入栈中。
3.7.2 转移控制
下表是支持过程调用和返回的指令:
call指令有一个目标,知名被调用过程起始的指令地址 ,可以是直接的Label也可以是间接的*Operand。
call指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。返回地址是紧跟在程序中call后面的那条指令的地址。
ret指令从栈中弹出地址,并跳转到那条地址。要正确使用ret,需要栈准备好,将栈指针指向前面call指令存储的返回地址。
leave指令就是用来使栈做好返回准备的。
3.7.3 寄存器使用惯例
寄存器%edx,%eax,%eac被划分为调用者保存(caller save)寄存器。
寄存器%ebx,%esi,%edi被划分为被调用者保存(callee save)寄存器。
3.7.4 过程示例
3.7.5 递归过程
通过斐波那契函数说明过程调用
3.8 数据分配和访问
3.8.1 基本原则
3.8.2 指针运算
指针运算对应汇编就是地址运算
3.8.3 数组与循环
汇编是使用指针的方式实现数组的循环的。
3.8.4 嵌套数组
多维数组的汇编表示
3.8.5 固定大小的数组
主要是介绍编译器对多维数组是如何优化的。
3.8.6 动态分配的数组
编译器通过循环的方式提高效率,表示动态分配的数组
3.9 异类的数据结构
3.9.1 结构
c语言的struct可以保存不同类型的数据。编译器保存关于每个结构类型的信息,指示每个域(field)的字节偏移。它以这些偏移作为存储器引用指令中的位移,从而产生对结构元素的引用。
3.9.2 联合
3.10 对齐(alignment)
和c对齐原理一样
3.11 综合:理解指针
介绍概念,和c一样
3.12 现实生活:使用GDB调试
简单介绍GDB,想深入理解,可以找专门的文章。