GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序的每一条指令,然后GCC调用汇编器和链接器,根据汇编代码生成可执行的机器代码。汇编代码是机器代码的文本表示,对于严谨的程序员来说,能够阅读和理解汇编代码是一项很重要的技能
《深入理解计算机系统》
程序编码
对于机器级编程,两种抽象极其重要
- 一种是指令集体系结构或指令集架构:定义了处理器状态、指令格式,以及每条指令对状态的影响
- 另一种是使用的内存地址是虚拟地址,提供的内存模型看上去是一个连续很大的字节数组
一些对程序员隐藏的处理器状态将会在机器代码中可见
- 程序计数器(PC,%rip):表示将要执行的下一条指令在内存的地址
- 整数寄存器文件:存储地址(对应与c语言的指针)或是整数数据,有的记录重要的程序状态,有的保存临时数据,如过程参数、局部变量和函数返回值
- 条件码寄存器:保存算术或者逻辑指令的状态信息,实现控制或者数据流的条件变化
- 向量寄存器:存放整数或浮点数值
c语言中可以声明各种数据类型,但在机器代码中,这些对象只是一个很大的字节数组,机器代码不区分有符号无符号,也不区分指针和整数
假设有一个mstore.c文件,包含以下内容1
2
3
4
5
6
7long mult2(long,long);
void multstore(long x,long y ,long *dest)
{
long t = mult2(x,y);
*dest = t;
}
输入以下命令,就将产生一个汇编文件mstore.s1
[xzy]# gcc -Og -S mstore.c
1 | //汇编文件mstore.s,所有以"."开头的都是指导汇编器和链接器工作的伪指令,阅读时可忽略 |
如果进一步使用以下指令,将产生目标代码文件mstore.o,对于这类二进制格式,一般是无法查看的1
[xzy]# gcc -Og -c mstore.c
但我们可以通过反汇编器,根据机器代码产生类似于汇编代码的格式,例如我们可以输入以下命令,查看mstore.o1
[xzy]# objdump -d mstore.o
产生如下文件1
2
3
4
5
6
7
8
9
10
11mstore.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
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;
}
通过以下命令就能生成可执行文件prog1
[xzy]# gcc -Og -o prog main.c mstore.c
通过反汇编查看prog文件1
[root@localhost xzy]# objdump -d prog
1 | prog: file format elf64-x86-64 |
这份完整代码不仅包括两个函数,还包括启动和终止程序的代码,以及用来与操作系统交互的代码,其中一段节选和之前看到的mstore.c反汇编产生的代码几乎一样1
2
3
4
5
6
7
80000000000400570 <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
5movl $Ox4050,%eax 立即数->寄存器
movw %bp,%sp 寄存器->寄存器
movb (%rdi,%rcx),%al 内存->寄存器
movb $-17,(%rsp) 立即数->内存
movq %rax,-12(%rbp) 寄存器->内存
通过指令将较小的源值复制到较大的目的,这种指令分为两类:MOVZ类和MOVS类。MOVZ类将目的中的剩余字节填充为0,而MOVS类通过符号扩展来填充。
1 | //注意-1的十六进制表示为FF...FFF |
1 | //展示了MOVS和MOVZ指令的区别 |
下面是寄存器的错误使用1
2
3
4
5
6
7movb $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 | long exchange(long *xp,long y) |
汇编后产生以下节选代码,函数由三条指令构成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
4str_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
2subq %8,%rsp
movq %rbq,(%rsp)
一条popq %rax
相当于1
2movq (%rsp),%rax
addq $8,%rsp
无论如何%rsp
一定指向内存中的栈顶,因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标准的内存寻址方式访问栈内的任意位置。