第3章 程序的机器级表示
3.2 程序编码
3.2.1 机器级代码
对于机器级编程来说,其中两种抽象尤为重要。
- 第一种是机器级程序的格式和行为,定义为指令集体系结构(Instruction set architecture,ISA),它定义了处理器状态、指令的格式,以及每条指令的对状态的影响。大多数 ISA,包括 IA32 和 x86-64,将程序的行为描述成好像每条指令是按顺序执行的,一条指令结束后,下一条再开始。
- 第二种抽象是,机器级程序使用的存储器地址是虚拟地址,提供的存储器模型看上去是一个非常大的字节数组。
IA32 机器代码和原始的 C 代码差别非常大。一些通常对 C 语言程序隐藏的处理器状态是可见的:
- 程序计数器 (在 IA32 中,通常称为“PC“,用 %eip 表示)指示将要执行的下一条指令在存储器中的地址。
- 整数寄存器 包含 8 个命名的位置,分别存储 32 位的值。这些寄存器可以存储地址(对应于 C 语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器则用来保存临时数据,例如过程的局部变量和函数的返回值。
- 条件码寄存器 保存着最近执行的算术或者逻辑指令的状态信息。他们用来实现控制或数据流中的条件变化。
- 浮点数寄存器 一组浮点数寄存器存放浮点数据。
程序存储器包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的存储器块(比如说用 malloc 库函数分配的)。
程序存储器用虚拟地址来寻址。在任意给定时刻,只认为有限的一部分虚拟地址是合法的。
操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器存储器中的物理地址。
3.2.2 代码示例
code.c
int accum = 0;
int sum(int x, int y)
{
int t = x + y;
accum += t;
return t;
}
生成汇编文件 code.s
unix> gcc -O1 -S code.c
code.s
.file "code.c"
.text
.globl sum
.type sum, @function
sum:
.LFB0: # local function beginning
.cfi_startproc
movl 8(%esp), %eax
addl 4(%esp), %eax
addl %eax, accum
ret
.cfi_endproc
.LFE0: # local function ending
.size sum, .-sum
.globl accum
.bss
.align 4
.type accum, @object
.size accum, 4
accum:
.zero 4
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
汇编代码文件包含各种声明,包括下面几行:
sum:
movl 8(%esp), %eax # y
addl 4(%esp), %eax # t = x+y
addl %eax, accum # accum += t
ret
生成目标代码文件 code.o
unix> gcc -O1 -c code.c
反汇编器(disassembler),根据目标代码产生一种类似于汇编代码的格式。
unix> objdump -d code.o
00000000 <sum>:
0: 8b 44 24 08 mov 0x8(%esp),%eax
4: 03 44 24 04 add 0x4(%esp),%eax
8: 01 05 00 00 00 00 add %eax,0x0
e: c3 ret
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个 main 函数。
main.c
int main()
{
return sum(1, 3);
}
生成可执行文件 prog:
unix> gcc -O1 -o prog code.o main.c
反汇编 prog 文件:
unix> objdump -d prog
反汇编器会抽取除各种代码序列,包括下面这段:
080483db <sum>:
80483db: 8b 44 24 08 mov 0x8(%esp),%eax
80483df: 03 44 24 04 add 0x4(%esp),%eax
80483e3: 01 05 1c a0 04 08 add %eax,0x804a01c
80483e9: c3 ret
3.2.3 关于格式的注解
simple.c
int simple(int *xp, int y)
{
int t = *xp + y;
*xp = t;
return t;
}
unix> gcc -O1 -S simple.c
simple.s
.file "simple.c"
.text
.globl simple
.type simple, @function
simple:
.LFB0:
.cfi_startproc
movl 4(%esp), %edx
movl (%edx), %eax
addl 8(%esp), %eax
movl %eax, (%edx)
ret
.cfi_endproc
.LFE0:
.size simple, .-simple
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
所有以 “.” 开头的行都是指导汇编器和链接器的命令。
省略了大部分指令,带注释的汇编代码:
simple:
movl 4(%esp), %edx # Retrieve xp
movl (%edx), %eax # t = *xp;
addl 8(%esp), %eax # t = t + y;
movl %eax, (%edx) # *xp = t;
ret # Return
gcc 默认生成 AT&T 汇编代码格式。
使用如下命令,可以生成 intel 汇编代码格式:
gcc -O1 -S -masm=intel simple.c
.file "simple.c"
.intel_syntax noprefix
.text
.globl simple
.type simple, @function
simple:
.LFB0:
.cfi_startproc
mov edx, DWORD PTR [esp+4]
mov eax, DWORD PTR [edx]
add eax, DWORD PTR [esp+8]
mov DWORD PTR [edx], eax
ret
.cfi_endproc
.LFE0:
.size simple, .-simple
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
省略了大部分指令,带注释的 intel 汇编代码:
simple:
mov edx, DWORD PTR [esp+4] # Retrieve xp
mov eax, DWORD PTR [edx] # t = *xp;
add eax, DWORD PTR [esp+8] # t = t + y;
mov DWORD PTR [edx], eax # *xp = t;
ret # Return
3.3 数据格式
由于是从 16 位体系结构扩展成 32 位的,Intel 用术语“字”(word)表示 16 位数据类型。因此,称 32 位数为“双字”(double words),称 64 位数为四字(quad words)。
C 语言数据类型对应的 IA 32 表示
C 声明 | Intel 数据类型 | 汇编代码后缀 | 大小(字节) |
---|---|---|---|
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long int | 双字 | l | 4 |
long long int | - | - | 4 |
char* | 双字 | l | 4 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
long double | 扩展精度 | t | 10/12 |
3.4 访问信息
一个 IA32 中央处理单元(CPU)包含一组 8 个存储 32 位值的寄存器。这些寄存器用来存储整数数据和指针。
在大多数情况(有些指令以固定的寄存器作为源寄存器或目的寄存器),前 6 个寄存器都可以看成通用寄存器,对他们的使用没有限制。
最后 2 个寄存器(%ebp 和 %esp)保存着指向程序栈中重要位置的指针。
- %eax 用于做累加
- %ecx 用于计数
- %edx 用于保存数据
- %ebx 用于做内存查找的基础地址
- %esi 用于保存源索引值
- %edi 用于保存目标索引值
- %esp 栈指针
- %ebp 帧指针
Page 112
图 3-2 IA32 的整数寄存器
字节操作指令可以独立地读或者写前 4 个寄存器的 2 个低位字节。
字操作指令可以独立地读或者写每个寄存器的低16位。
3.4.1 操作数指示符
大多数指令有一个或者多个操作数(operand),指示出执行一个操作中要引用的源数据值,以及放置结果的目标位置。
操作数分为三种类型:
- 第一种类型是立即数(immediate),也就是常数值。
- 第二种类型是寄存器(register),它表示某个寄存器的内容。
- 第三种类型是存储器(memory),它会根据计算出来的地址(通常称为有效地址)访问某个存储器的位置。
Page 113
图 3-3 操作数格式
3.4.2 数据传送指令
Page 114
图 3-4 数据传送指令
mov
movs 符号扩展
movz 零扩展
IA32 加了一条限制,传送指令的两个操作数不能都指向存储器位置。
IA32 的栈向低地址方向增长,所以压栈是减小栈指针(寄存器 %esp)的值,并将数据存放到存储器中,而出栈是从存储器中读,并增加栈指针的值。
指令 pushl %eax 等价于以下两条指令:
subl $4, %esp # Decrement stack pointer
movl %eax, (%esp) # Store %eax on stack
指令 popl %eax 等价于以下两条指令:
movl (%esp), %eax # Read %eax from stack
addl $4, %esp # Increment stack pointer
3.4.3 数据传送示例
a)exchange.c
int exchange(int *xp, int y)
{
int x = *xp;
*xp = y;
return x;
}
b)exchange.s
# xp at %esp+4, y at %esp+8
movl 4(%esp), %edx # Get xp
movl (%edx), %eax # Get x at xp
movl 8(%esp), %ecx # Get y
movl %ecx, (%edx) # Store y at xp
3.5 算术和逻辑操作
Page 119
图 3-7 整数算术操作
3.5.1 加载有效地址
加载有效地址(load effective address)指令 leal,将有效地址写入到目的操作数。另外,它还可以简洁地描述普通的算术操作。目的操作数必须是一个寄存器。
3.5.2 一元操作和二元操作
inc 加1
dec 减1
neg 取负
not 取补
第二组是一元操作。
add 加
sub 减
imul 乘
xor 异或
or 或
and 与
第三组是二元操作:同 movl 指令一样,两个操作数不能同时是存储器位置。
3.5.3 移位操作
sal 左移
shl 左移(等同于sal)
sar 算术右移
shr 逻辑右移
最后一组是移位操作,先给出移位量,然后第二项给出要移位的数值。它可以进行算术和逻辑右移。移位量用单个字节编码,因为只允许进行 0 到 31 位的移位(只考虑移位量的低 5 位)。移位量可以是一个立即数,或者放在单字节寄存器元素 %cl 中。(这些指令很特别,因为只允许以这个特定的寄存器作为操作数)。
3.5.4 讨论
a)arith.c
int arith(int x, int y , int z)
{
int t1 = x + y;
int t2 = z * 48;
int t3 = t1 & 0xFFFF;
int t4 = t2 * t3;
return t4;
}
b)arith.s
# x at %esp+4, y at %esp+8, z at %esp+12
movl 12(%esp), %ecx # z
movl 8(%esp), %eax # y
addl 4(%esp), %eax # t1 = x+y
movzwl %ax, %edx # t3 = t1&0xFFFF
leal (%ecx,%ecx,2), %eax # z*3
sall $4, %eax # t2 = z*48
imull %edx, %eax # return t4 = t2*t3
3.5.5 特殊的算术操作
Page 122
图 3-9 特殊的算术操作
imull 有符号全64位乘法
mull 无符号全64位乘法
idivl 有符号除法
divl 无符号除法
cltd指令
3.6 控制
C 语言中的某些结构,比如条件语句、循环语句和分支语句,要求有条件的执行,根据数据测试的结果来决定操作执行的顺序。机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。
3.6.1 条件码
除了整数寄存器, CPU 还维护一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或者逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:
- CF:进位标志。最近的操作使最高位产生了进位。用来检查无符号操作数的溢出。
- ZF:零标志。最近的操作的得出的结果为 0。
- SF:符号标志。最近的操作得到的结果为负数。
- OF:溢出标志。最近的操作导致一个补码溢出-正溢出或负溢出。
除了只设置条件码而不更新目标寄存器之外:
- cmp 指令与 sub 指令的行为一致
- test 指令与 and 指令的行为一致
Page 122
图 3-10 比较和测试指令
3.6.2 访问条件码
条件码通常不会直接读取,常用的使用方法有三种:
- 可以根据条件码的某个组合,将一个字节设置为 0 或者 1。
- 可以条件跳转到程序的某个其他的部分。
- 可以有条件地传送数据。
set指令
3.6.3 跳转指令及其编码
jmp指令:无条件跳转
在汇编代码中,跳转目标用符号标号书写。
汇编器,以及后面的链接器,会产生跳转目标的适当编码。
跳转指令有几种不同的编码,但是最常用的都是 PC 相关的(PC-relative,PC = Program Counter,程序计数器)。它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。
第二种编码方法是给出绝对地址,用 4 个字节直接指定目标。
汇编器和链接器会选择适当的跳转目的编码。
Page 128
图 3-12 jump指令
3.6.4 翻译条件分支
将条件表达式和语句从 C 语言翻译成机器语言,最常用的方式是结合有条件和无条件跳转。
a) absdiff.c
int absdiff(int x, int y)
{
if (x < y)
return y - x;
else
return x - y;
}
b) gotodiff.c
int gotodiff(int x, int y)
{
int result;
if (x >= y)
goto x_ge_y;
result = y - x;
goto done;
x_ge_y:
result = x - y;
done:
return result;
}
c) absdiff.s
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %eax
cmpl 12(%ebp), %eax
jge .L2
movl 12(%ebp), %eax
subl 8(%ebp), %eax
jmp .L3
.L2:
movl 8(%ebp), %eax
subl 12(%ebp), %eax
.L3:
popl %ebp
ret
C 语言中的 if-else 语句的通用形式模版:
if (test-expr)
then-statement
else
else-statement
goto 形式:
t = test-expr;
if (!t)
goto false;
then-statement
goto done;
false:
else-statement
done:
3.6.5 循环
C 语言提供了多种循环结构,即 do-while、while 和 for。汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。大多数编译器根据一个循环的 do-while 形式来产生循环代码,即使在实际程序中这种形式用的相对较少。其他的循环会首先转换成 do-while 形式,然后再编译成机器代码。
1. do-while 循环
do-while 语句的通用形式如下:
do {
body-statement
} while (test-expr);
goto 形式:
loop:
body-statement
t = test-expr;
if (t)
goto loop;
a) fact_do.c
int fact_do(int n)
{
int result = 1;
do {
result *= n;
n = n - 1;
} while (n > 1);
return result;
}
b) fact_do_goto.c
int fact_do_goto(int n)
{
int result = 1;
loop:
result *= n;
n = n - 1;
if (n > 1)
goto loop;
}
c) fact_do.s
movl 4(%esp), %edx
movl $1, %eax
.L2:
imull %edx, %eax
subl $1, %edx
cmpl $1, %edx
jg .L2
rep ret
2. while 循环
while 语句的通用形式如下:
while (test-expr)
body-statement
do-while 形式:
if (!test-expr)
goto done;
do {
body-statement
} while (test-expr);
done:
goto 形式:
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = testexpr;
if (t)
goto loop;
done:
a) fact_while.c
int fact_while(int n)
{
int result = 1;
while (n > 1)
{
result *= n;
n = n - 1;
}
return result;
}
b) fact_while_goto.c
int fact_while_goto(int n)
{
int result = 1;
if (n <= 1)
goto done;
loop:
result *= n;
n = n - 1;
if (n > 1)
goto loop;
done:
return result;
}
c) fact_while.s
movl 4(%esp), %edx
cmpl $1, %edx
jle .L4
movl $1, %eax
.L3:
imull %edx, %eax
subl $1, %edx
cmpl $1, %edx
jne .L3
rep ret
.L4:
movl $1, %eax
ret
3. for 循环
for 循环的通用形式如下:
for (init-expr; test-expr; update-expr)
body-statement
while 形式:
init-expr;
while (test-expr)
{
body-statement
update-expr;
}
do-while 形式:
init-expr;
if (!test-expr)
goto done;
do {
body-statement
update-expr;
} while (test-expr);
done;
goto 形式:
init-expr;
t = test-expr;
if (!t)
goto done;
loop:
body-statement
upadate-expr;
t = test-expr;
if (t)
goto loop;
done:
a) fact_for.c
int fact_for(int n)
{
int i;
int result = 1;
for (i = 2; i <= n; i++)
result *= i;
return result;
}
b) fact_for_goto.c
int fact_for_goto(int n)
{
int i = 2;
int result = 1;
if (!(i <= n))
goto done;
loop:
result *= i;
i++;
if (i <= n)
goto loop;
done:
return result;
}
c) fact_for.s
movl 4(%esp), %ecx
cmpl $1, %ecx
jle .L4
movl $1, %eax
movl $2, %edx
.L3:
imull %edx, %eax
addl $1, %edx
cmpl %edx, %ecx
jge .L3
rep ret
.L4:
movl $1, %eax
ret