机器级代码
在计算机中最终执行的都是机器代码,汇编代码、C 语言代码和高级语言的代码都需要转换成机器代码来执行。文章涉及的机器语言主要指 Intel IA32。
如下一段 C 语言代码:
1 int accum = 0; 2 3 int sum(int x, int y) 4 { 5 int t = x +y; 6 accum += t; 7 return ; 8 }
通过 gcc -m32 -O1 -o code.o -c code.c 生成二进制格式的目标代码文件 code.o,通过 hexdump 查看文件内容,在计算机中最终执行的字节指令是:
通过 objdump -d code.o 反汇编查看这段二进制对应的汇编内容:
这里的指令 55 对应了汇编代码 push %ebp,汇编代码非常接近于机器代码,它用可读性更好的文本表示处理器执行的指令。
除了像上面那样通过反汇编目标文件查看对应的汇编代码,还可以通过 gcc 查看 C 编译器产生的汇编代码,如下命令会产生一个汇编文件 code.s 。
# -m32 表示用 32 位模式编译 gcc -O1 -m32 -S code.c
数据格式
虽然 C 语言可以在存储器中声明和分配各种数据类型的对象,但是机器代码只是简单地将存储器看成是一个很大的、按字节寻址的数组。C 语言中的聚合数据类型,例如数组和结构,在机器代码中只用连续的一组字节来表示。
下面是 C 语言基本数据类型对应的 IA32 表示
C声明 | Intel数据类型 | 汇编代码后缀 | 字节大小 |
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long int | 双字 | l | 4 |
char * | 双字 | l | 4 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
long double | 扩展精度 | t | 10/12 |
访问信息
寄存器
一个 IA32 CPU 包含一组 8 个存储 32 位值的寄存器,这些寄存器用来存储整数数据和指针。它们的名字都以 %e 开头,在最初的 8086 中,寄存器都是 16 位的。
%eax 是 32 位的,%ax 是 16 位的。所有 8 个寄存器可以作为 16 位或 32 位来访问,可以访问前 4 个寄存器的两个低位字节。
大多数系统的指令有一个或多个操作数,源数值可以是 常数、从寄存器或存储器中读出,结果可以存放在寄存器或存储器中。操作数可以分为 3 中类型:
1. 立即数,也就是常数值。用 “$”加一个整数表示,如 $550;
2. 寄存器,它表示某个寄存器的内容。用 Ea 来表示任意寄存器 a,用 R[Ea] 表示它的值;
3. 存储器引用,它会根据计算出来的地址,访问某个存储器位置。用 Mb[Addr] 表示对存储在存储器中从地址 Addr 开始的 b 个字节的引用。
通用形式:Imm(Ea, Eb, s) 表示的操作数的有效地址为 M[Imm + R[Ea] + R[Eb]*s ] 。
数据传送指令
将数据从一个位置复制到另一个位置的指令是最频繁使用的指令。
指令 | 描述 |
MOV S,D | 传送 |
MOVS S,D | 传送符号扩展的字节 |
MOVZ S,D | 传送零扩展的字节 |
这里两个操作数不能都指向存储器位置。
算术和逻辑操作
指令 | 效果 | 描述 |
leal S,D | D <- &D |
将有效地址读入寄存器 |
INC D | D <- D+1 | 加 1 |
DEC D | D <- D - 1 | 减 1 |
NEG D | D <- -D | 取负 |
NOT D | D <- ~D | 取补 |
ADD S,D | D <- D+S | 加 |
SUB S,D | D <- D-S | 减 |
IMUL S,D | D <- D*S | 乘 |
XOR S,D | D <- D^S | 异或 |
OR S,D | D <- D | S | 或 |
AND S,D | D <- D & S | 与 |
SAL k, D | D <- D <<k | 左移 |
SHL k, D | D <- D <<k | 左移(同上) |
SAR k, D | D <- D >>k | 算术右移 |
SHR k, D | D <- D >>k | 逻辑右移 |
与其他语言一样,程序的任何逻辑都是由基本指令在顺序执行、条件执行、循环执行组合得到。
除了整数寄存器,CPU 还维护着一组单个位的寄存器,它们描述了最近的算术或逻辑操作的属性(上面指令只有 leal 不改变任何条件码),可以检测这些寄存器来执行条件分支指令。
条件码 | 描述 |
CF |
进位标志。检测 无符号溢出。 最近的操作使最高位产生了进位。 |
ZF |
零标志。 最近操作得出的结果位0。 |
SF |
符号标志。检测 负数。 最近操作得到的结果为负数。 |
OF |
溢出标志。检测 有符号溢出。 最近的操作导致一个补码溢出 - 正溢出或负溢出。 |
除了算术和逻辑操作指令,还有下面两个指令只设置条件码而不改变任何其他寄存器。
指令 | 效果 | 描述 |
CMP S2, S1 | S1 - S2 | 比较两个操作数大小 |
TEST S2, S1 | S1 & S2 | 测试 |
条件码通常不会直接读取,常用使用方法有 3 种:
1. 根据条件码的某个组合,将一个字节设置为 0 或者 1;这类指令称为 SET 指令,它们之间的区别在于考虑的条件码组合情况。
2. 根据条件码跳转到程序的某个其他部分;
3. 可以有条件地传送数据。
跳转指令
指令 | 跳转条件 | 描述 |
jmp Label | 1 | 无条件直接跳转 |
jmp *Operand | 1 | 无条件间接跳转 |
je Label | ZF | 相等/零 时跳转 |
jge Label | ~(SF^OF) | 大于或等于 时 |
... |
jmp *%eax 用寄存器 %eax 中的值作为跳转目标; jmp *(%eax) 以 %eax 中的值作为读地址,从存储器中读出跳转目标。
C 语言提供了多种循环结构,即 do-while、while 和 for,汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。
如通用 do-while 形式
1 do 2 body-statement 3 while(test-expr)
可以通过下面组合完成
loop: body-statement t = test-expr; if (t) goto loop;
switch 语句
switch 通过一个整数索引值进行多重分支,处理具有多种可能结果的测试时特别有用,不仅提高了代码的可读性,通过跳转表使得实现更加高效。使用跳转表的优点是执行开关语句的时间与开关数量无关。
跳转表是一个数组,表项 i 是一个代码段的地址,当开关索引等于 i 时进行此部分代码段的操作。
过程(函数)
一个过程调用包括将数据(参数和返回值)和控制从代码的一部分传递到另一部分,还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。
大多数机器,包括 IA32 只提供转移控制到过程和从过程中转移出控制这种简单的指令。数据传递、局部变量的分配和释放通过操纵程序栈来实现。
为单个过程分配的那部分栈称为栈帧(stack frame),栈帧的最顶端以两个指针界定,寄存器 %ebp 为帧指针(在一个过程中不变),寄存器 %esp 为栈指针(在一个过程执行中会移动)。
过程调用参数的常见步骤:
1. 保存原来的帧指针地址。在调用结束后,才能恢复调用者的帧指针,常见下面的代码
# 保存调用者的帧指针pushl %ebp# 将 %ebp 设置为栈帧开始的位置 movl %esp, %ebp
2. 为当前栈帧分配内存,常见汇编代码如下
subl $16, %esp
3. 备份调用者寄存器内容到栈,在返回时才能恢复调用者需要继续使用的寄存器内容。常见汇编代码如下
pushl %ebx
转移控制指令
指令 | 描述 |
call Label | 过程直接调用。返回地址入栈,并跳转到被调用过程的起始处。 |
call *Operand | 过程间接调用 |
leave | 为返回准备栈 |
ret | 从过程调用中返回。从栈中弹出地址,并跳转到这个位置。 |
寄存器的使用惯例
寄存器:%eax、%edx 和 %ecx 是调用者保存寄存器,当过程 P 调用 Q 时,Q 可以覆盖这些寄存器,而不会破坏任何 P 所需要的数据;
寄存器:%ebx、%esi 和 %edi 是被调用者保存寄存器,意味着 Q 必须在覆盖这些寄存器的值之前,先把它们保存到栈中,并在返回前恢复它们。
数组的分配和访问
对于数组 T A[N], L 为数据类型 T 的大小,首先它在存储器中分配一个 L*N 字节的连续区域,用 Xa 指向数组开头的指针,数组元素 i 会被存放在地址为 Xa+L*i 的地方。
嵌套数组是一个 T 为数组的数组,所以它满足数组分配和引用的一般原则。
结构
C 语言中用 struct 声明创建一个数据类型,将可能不同类型的对象聚合在一个对象中,结构的各个组成部分用名字来引用。
类似于数组,结构的所有组成部分都存放在存储器的一段连续区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段的偏移。机器代码不包含关于字段声明或字段名字的信息。
联合
允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,只不过语义相差比较大。它们是用不同的字段来引用相同的存储器块。
数据对齐
指针
以一种统一的方式,对不同数据结构中的元素产生引用。
1. 每个指针都对应一个类型,表明指针指向那一类对象。不过指针类型不是机器代码中的一部分;它指示 C 语言提供的一种抽象,帮助程序员避免寻址错误。
2. 每个指针都有一个值。是某个指定类型对象的地址。
3. 指针用 & 运算符创建。机器代码常常用 lead 来计算存储器引用的地址。
4. 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
5. 指针也可以指向函数。