// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
在一个 Linux 操作系统上,我们可以简单地使用 gcc 和 objdump 这样两条命令,把对应的汇编代码和机器码都打印出来。
test.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
#include<stdio.h>
int main()
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 48 83 ec 10 sub rsp,0x10
int a = 1;
8: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
f: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = a + b;
16: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
19: 01 45 fc add DWORD PTR [rbp-0x4],eax
printf("a=%d\n",a);
1c: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1f: 89 c6 mov esi,eax
21: bf 00 00 00 00 mov edi,0x0
26: b8 00 00 00 00 mov eax,0x0
2b: e8 00 00 00 00 call 30 <main+0x30>
return 0;
30: b8 00 00 00 00 mov eax,0x0
}
35: c9 leave
36: c3 ret
因为汇编代码其实就是“给程序员看的机器码”,也正因为这样,机器码和汇编代码是一一对应的。
常见的指令可以分成五大类
一个 CPU 里面会有很多种不同功能的寄存器,三种比较特殊的
除了这些特殊的寄存器,CPU 里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。
#include <time.h>
#include <stdlib.h>
int main()
{
srand(time(NULL));
int r = rand() % 2;
int a = 10;
if (r == 0)
{
a = 1;
} else {
a = 2;
}
}
if…else 条件判断语句。对应的汇编代码是这样的:
if (r == 0)
33: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0
/*
cmp 指令比较了前后两个操作数的值,这里的 DWORD PTR 代表操作的数据类型是 32 位的整数,而 [rbp-0x4] 则是一个寄存器的地址。所以,
第一个操作数就是从寄存器里拿到的变量 r 的值。第二个操作数 0x0 就是我们设定的常量 0 的 16 进制表示。cmp 指令的比较结果,会存入到
条件码寄存器当中去。
如果比较的结果是 True,也就是 r == 0,就把零标志条件码(对应的条件码是 ZF,Zero Flag)设置为 1。除了零标志之外,Intel 的 CPU
下还有进位标志(CF,Carry Flag)、符号标志(SF,Sign Flag)以及溢出标志(OF,Overflow Flag),用在不同的判断条件下。
*/
37: 75 09 jne 42 <main+0x42>
/*
jne 指令,是 jump if not equal 的意思,它会查看对应的零标志位。如果为 0,会跳转到后面跟着的操作数 42 的位置。这个 42,对应这
里汇编代码的行号,也就是上面设置的 else 条件里的第一条指令。当跳转发生的时候,PC 寄存器就不再是自增变成下一条指令的地址,而是被
直接设置成这里的 42 这个地址。这个时候,CPU 再把 42 地址里的指令加载到指令寄存器中来执行。
*/
{
a = 1;
39: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
40: eb 07 jmp 49 <main+0x49>
/*
if 条件,如果满足的话,在赋值的 mov 指令执行完成之后,有一个 jmp 的无条件跳转指令。跳转的地址就是这一行的地址 49。我们的 main 函
数没有设定返回值,而 mov eax, 0x0 其实就是给 main 函数生成了一个默认的为 0 的返回值到累加器里面。if 条件里面的内容执行完成之后也
会跳转到这里,和 else 里的内容结束之后的位置是一样的。
*/
} else {
a = 2;
42: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
/*
跳转到执行地址为 42 的指令,实际是一条 mov 指令,第一个操作数和前面的 cmp 指令一样,是另一个 32 位整型的寄存器地址,以及对应的2
的16进制值 0x2。mov 指令把 2 设置到对应的寄存器里去,相当于一个赋值操作。然后,PC 寄存器里的值继续自增,执行下一条 mov 指令
*/
}
return 0;
49: b8 00 00 00 00 mov eax,0x0
/*
这条 mov 指令的第一个操作数 eax,代表累加寄存器,第二个操作数 0x0 则是 16 进制的 0 的表示。这条指令其实没有实际的作用,它的作用
是一个占位符。
*/
}
4e: c9 leave
4f: c3 ret
“C 语言代码 - 汇编代码 - 机器码” 这个过程,在我们的计算机上进行的时候是由两部分组成的。
ELF其实是一种文件格式的标准,ELF文件有三类:可重定向文件、可执行文件、共享目标文件。代码经过预处理、编译、汇编后形成可重定向文件,可重定向文件经过链接后生成可执行文件。
Linux 下的 ELF 文件格式,而 Windows 的可执行文件格式是一种叫作PE(Portable Executable Format)的文件格式。Linux 下的装载器只能解析 ELF 格式而不能解析 PE 格式。
装载器需要满足两个要求:
把指令里用到的内存地址叫作虚拟内存地址(Virtual Memory Address),实际在内存硬件里面的空间地址叫物理内存地址(Physical Memory Address)。
要满足装载器的两个要求,我门可以在内存里面,找到一段连续的内存空间,然后分配给装载的程序,然后把这段连续的内存空间地址,和整个程序指令里指定的内存地址做一个映射。