第三章:程序的机器级表示(1)

GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序的每一条指令,然后GCC调用汇编器和链接器,根据汇编代码生成可执行的机器代码。汇编代码是机器代码的文本表示,对于严谨的程序员来说,能够阅读和理解汇编代码是一项很重要的技能


《深入理解计算机系统》

程序编码

对于机器级编程,两种抽象极其重要

  • 一种是指令集体系结构或指令集架构:定义了处理器状态、指令格式,以及每条指令对状态的影响
  • 另一种是使用的内存地址是虚拟地址,提供的内存模型看上去是一个连续很大的字节数组

一些对程序员隐藏的处理器状态将会在机器代码中可见

  • 程序计数器(PC,%rip):表示将要执行的下一条指令在内存的地址
  • 整数寄存器文件:存储地址(对应与c语言的指针)或是整数数据,有的记录重要的程序状态,有的保存临时数据,如过程参数、局部变量和函数返回值
  • 条件码寄存器:保存算术或者逻辑指令的状态信息,实现控制或者数据流的条件变化
  • 向量寄存器:存放整数或浮点数值

c语言中可以声明各种数据类型,但在机器代码中,这些对象只是一个很大的字节数组,机器代码不区分有符号无符号,也不区分指针和整数

假设有一个mstore.c文件,包含以下内容

1
2
3
4
5
6
7
long mult2(long,long);

void multstore(long x,long y ,long *dest)
{
long t = mult2(x,y);
*dest = t;
}

输入以下命令,就将产生一个汇编文件mstore.s

1
[xzy]# gcc -Og -S mstore.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//汇编文件mstore.s,所有以"."开头的都是指导汇编器和链接器工作的伪指令,阅读时可忽略

multstore:
.LFB0:
.cfi_startproc
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
.cfi_def_cfa_offset 8
ret
.cfi_endproc

如果进一步使用以下指令,将产生目标代码文件mstore.o,对于这类二进制格式,一般是无法查看的

1
[xzy]# gcc -Og -c mstore.c

但我们可以通过反汇编器,根据机器代码产生类似于汇编代码的格式,例如我们可以输入以下命令,查看mstore.o

1
[xzy]# objdump -d mstore.o

产生如下文件

1
2
3
4
5
6
7
8
9
10
11
mstore.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <multstore>:
0: 53 push %rbx
1: 48 89 d3 mov %rdx,%rbx
4: e8 00 00 00 00 callq 9 <multstore+0x9>
9: 48 89 03 mov %rax,(%rbx)
c: 5b pop %rbx
d: c3 retq

其中左边给出的就是二进制文件中的14个十六进制字节值,每行都对应右边的汇编语言,表示一条指令,且这些写反汇编语言具有以下特性:

  • 指令长度从1到15个字节不等
  • 从某一位置开始,将字节唯一地解码成机器指令,例如push %rbx就是从字节值53开头
  • 反汇编器是根据机器代码文件中(mstore.o)的字节序列确定的汇编代码,不需要访问源代码或者汇编代码
  • 反汇编代码和汇编代码还是有一些细微差别,如push和pushq,q表示操作数大小指示符大多数情况可以省略

要生成可执行代码还需要运行链接器去链接一组目标代码文件,并且这组目标代码文件中有且只有一个main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>
void mulstore(long,long,long*);
int main()
{
long d;
multstore(2,3,&d);
printf("2*3 --> &ld\n",d);
return 0;
}

long mult2(long a,long b)
{
long s = a*b;
return s;
}

通过以下命令就能生成可执行文件prog

1
[xzy]# gcc -Og -o prog main.c mstore.c

通过反汇编查看prog文件

1
[root@localhost xzy]# objdump -d prog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
prog:     file format elf64-x86-64


Disassembly of section .init:

00000000004003e0 <_init>:
4003e0: 48 83 ec 08 sub $0x8,%rsp
4003e4: 48 8b 05 0d 0c 20 00 mov 0x200c0d(%rip),%rax
4003eb: 48 85 c0 test %rax,%rax
4003ee: 74 05 je 4003f5 <_init+0x15>
4003f0: e8 3b 00 00 00 callq 400430 <__gmon_start__@plt>
4003f5: 48 83 c4 08 add $0x8,%rsp
4003f9: c3 retq

Disassembly of section .plt:

...

000000000040052d <main>:
40052d: 48 83 ec 18 sub $0x18,%rsp
400531: 48 8d 54 24 08 lea 0x8(%rsp),%rdx
400536: be 03 00 00 00 mov $0x3,%esi
40053b: bf 02 00 00 00 mov $0x2,%edi
400540: b8 00 00 00 00 mov $0x0,%eax
400545: e8 26 00 00 00 callq 400570 <multstore>
40054a: 48 8b 74 24 08 mov 0x8(%rsp),%rsi
40054f: bf 10 06 40 00 mov $0x400610,%edi
400554: b8 00 00 00 00 mov $0x0,%eax
400559: e8 b2 fe ff ff callq 400410 <printf@plt>
40055e: b8 00 00 00 00 mov $0x0,%eax
400563: 48 83 c4 18 add $0x18,%rsp
400567: c3 retq

0000000000400568 <mult2>:
400568: 48 89 f8 mov %rdi,%rax
40056b: 48 0f af c6 imul %rsi,%rax
40056f: c3 retq

0000000000400570 <multstore>:
400570: 53 push %rbx
400571: 48 89 d3 mov %rdx,%rbx
400574: e8 ef ff ff ff callq 400568 <mult2>
400579: 48 89 03 mov %rax,(%rbx)
40057c: 5b pop %rbx
40057d: c3 retq
40057e: 66 90 xchg %ax,%ax

...

这份完整代码不仅包括两个函数,还包括启动和终止程序的代码,以及用来与操作系统交互的代码,其中一段节选和之前看到的mstore.c反汇编产生的代码几乎一样

1
2
3
4
5
6
7
8
0000000000400570 <multstore>:
400570: 53 push %rbx
400571: 48 89 d3 mov %rdx,%rbx
400574: e8 ef ff ff ff callq 400568 <mult2>
400579: 48 89 03 mov %rax,(%rbx)
40057c: 5b pop %rbx
40057d: c3 retq
40057e: 66 90 xchg %ax,%ax

但是依旧有几点很重要的区别

  • 左边的地址不同,链接器将这段代码的地址移到了一段不同的地址范围中
  • 链接器为callq指令调用函数mult2填写了需要的地址400568
  • 多了最后一行代码,虽然和书中实例不同,但应该是出于存储器系统性能考虑,使函数代码变为16字节

数据格式

用字(word)表示16位数据类型,32位数为双字,64位为四字。汇编代码指令都有一个字符的后缀,表明操作数的大小。如movb(传送字节),movw(传送字),movl(传送双字),movq(传送四字)

访问信息

一个CPU包含一组16个存储64位值的通用目的寄存器,用来存储整数数据和指针。图中不同颜色的方框标明,指令可以对这16个寄存器的低位字节中存放的不同大小的数据进行操作,字节级操作可以访问最低的字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,64位则可以访问整个寄存器。
所有寄存器中最特别的是栈指针%rsp,用来标明运行时栈的结束位置

操作数

一个指令往往有多于一个的操作数,操作数可分为三种类型

  • 立即数:用来表示常数值,如$-577或者$Ox1F
  • 寄存器:R[r]表示寄存器的值
  • 内存引用:根据计算出来的地址访问某个内存位置,M[Addr]表示地址Addr的值,并且有多种寻址模式,如下图所示

指令

我们将具有相同操作效果但操作数不同的指令划为一类
最简单的数据传输指令-MOV类,把数据从源位置复制到目的位置,有movb、movw、movl、movq,分别对应操作数大小为1、2、4、8字节。
源操作数指定的值是一个立即数,存储在寄存器或者内存中。目的操作数指定一个寄存器或者一个内存位置。指令最后一个一个字符必须与寄存器的大小匹配。

1
2
3
4
5
movl $Ox4050,%eax		立即数->寄存器
movw %bp,%sp 寄存器->寄存器
movb (%rdi,%rcx),%al 内存->寄存器
movb $-17,(%rsp) 立即数->内存
movq %rax,-12(%rbp) 寄存器->内存

通过指令将较小的源值复制到较大的目的,这种指令分为两类:MOVZ类和MOVS类。MOVZ类将目的中的剩余字节填充为0,而MOVS类通过符号扩展来填充。

1
2
3
4
5
6
//注意-1的十六进制表示为FF...FFF
movabsq $Ox0011223344556677,%rax %rax = 0011223344556677 //初始化为位模式
movl $-1,%al %rax = 00112233445566FF //把%rax的低位字节设置为FF
movl $-1,%ax %rax = 001122334455FFFF //把低2位字节设置为FFFF
movl $-1,%eax %rax = 00000000FFFFFFFF //movl以寄存器作为目的时,会把寄存器的高位4字节设为0
movl $-1,%rax %rax = FFFFFFFFFFFFFFFF ///则把整个寄存器设为F
1
2
3
4
5
6
//展示了MOVS和MOVZ指令的区别
movabsq $Ox0011223344556677,%rax %rax = 0011223344556677
movb $OxAA,%dl %dl = AA
movb %dl,%al %rax = 0011223344556677
movsbq %dl,%rax %rax = FFFFFFFFFFFFFFAA
movzbq $dl,%rax %rax = 00000000000000AA

下面是寄存器的错误使用

1
2
3
4
5
6
7
movb $OxF,(%ebx)     //%ebx不能作为地址寄存器
movl %rax,(%rsp) //指令后缀和寄存器ID%rax不匹配,应该是movq
movw (%rax),4(%rsp) //不能同时指向内存位置
movb %al,%sl //不存在%sl
movq %rax,$Ox123 //立即数不能作为目的位置
movl %eax,%rdx //目的操作数大小不对
movb %si,8(%rbp) //指令后缀和寄存器ID%si不匹配,应该是movw

实例

1
2
3
4
5
6
long exchange(long *xp,long y)
{
long x = *xp;
*xp = y;
return x;
}

汇编后产生以下节选代码,函数由三条指令构成

1
2
3
4
5
6
7
8
9
//参数xp和y分别存储在%rdi和%rsi中

exchange:
.LFB0:
.cfi_startproc
movq (%rdi), %rax //把x的值放在%rax或其低位部分,而%rax是返回值的寄存器,因此最后返回x
movq %rsi, (%rdi) //将y写入到寄存器%rdi的xp指向的内存位置
ret
.cfi_endproc

这段汇编代码有几点注意:

  • 说明了从内存读值到寄存器,和从寄存器写到内存
  • xp指针对应的就是地址,即将该指针放在寄存器%rdi,在内存中引用给寄存器(%rdi)就是读取指针指向地址的值
  • x局部变量是保存在寄存器(%rax)中的,而不是内存中,因为寄存器访问快
    假设sp和dp被申明为以下类型
    1
    2
    3
    4
    str_t *sp;
    dest_t *dp;
    怎么选取适当的数据传送指令实现下面的操作
    *dp = (dest_t) *sp;

假设sp和dp的值分别存储在寄存器%rdi和%rsi中,给出下列实际类型写出对应的指令
其中第一条指令从内存中读数,并做适当的转换,第二条把%rax的适当部分写到内存

重点

当强制类型转换涉及大小和符号变化时,先改变大小从这张图的第五行看出,从int强制转换成char时,第一步从内存的读取到%eax,第二步却从%eax的低位寄存器%al中读取到内存,显然%eax的高位被自动舍弃了,这就是C语言强制类型转换出错的原因

压入和弹出栈数据

程序栈放在内存中的某个区域,在处理过程调用中起着至关重要的作用。栈指针%rsp保存着栈顶元素的地址

pushq指令的功能将数据压入栈中,popq指令弹出数据。将一个四字值压入栈中,首先将栈指针减8,然后将值写入新的栈顶地址

一条pushq %rbq就相当于

1
2
subq %8,%rsp
movq %rbq,(%rsp)

一条popq %rax相当于

1
2
movq (%rsp),%rax
addq $8,%rsp

无论如何%rsp一定指向内存中的栈顶,因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标准的内存寻址方式访问栈内的任意位置。