文章目录
- 3.2 程序编码
- 3.2.1 机器级代码
- 3.2.2 代码示例
- 3.2.3 关于格式的注解
- 3.3 数据格式
3.2 程序编码
假设写一个 C 程序,有两个文件 p1.c
和 p2.c
。然后用 Unix 命令行编译这段代码:
unix> gcc -O2 -o p p1.c p2.c
- 命令
gcc
表明的就是 GNU C 编译器 GCC。因为这是 Linux 上默认的编译器,也可以简单地用 CC 来启动它。 - 编译选项
-O2
告诉编译器使用第二级优化。通常,提高优化级别会使最终程序运行得更快,但是编译时间可能会变长,对代码进行调试会更困难。第二级优化是性能优化和使用方便之间的一种很好的妥协。本书的所有代码都是用这个优化级别进行编译的。
这个命令实际上调用了一系列程序,将源代码转化成可执行代码。
- 首先,C预处理器 会扩展源代码,插入所有用
#include
命令指定的文件,并扩展所有的宏。 - 其次,编译器 产生两个源文件的汇编代码,名字分别为
p1.s
和p2.s
。 - 接下来,汇编器 会将汇编代码转化成二进制目标代码文件
p1.o
和p2.o
。 - 最后,链接器 将两个目标文件与实现标准 Unix 库函数(例如
printf
)的代码合并,并产生最终的可执行文件。
3.2.1 机器级代码
在整个编译过程中,编译器会完成大部分的工作,将把用 C 提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代码。与目标代码的二进制格式相比,汇编代码的主要特点是用可读性更好的文本格式表示的。能够理解汇编代码以及它是如何与原始的 C 代码相对应的,是理解计算机如何执行程序的关键一步。
汇编程序员看到的机器与 C 程序员看到的机器差别很大。一些通常对 C 程序员屏蔽的处理器状态是可见的:
- 程序计数器(称为
%eip
)表示将要执行的下一条指令在存储器中的地址。 - 整数寄存器文件包含 8 个被命名的位置,分别存储 32 位的值。这些寄存器可以存储地址(对应于 C 的指针)或整数数据。有的寄存器用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的局部变量。
- 条件码寄存器保存着最近执行的算术指令的状态信息。它们用来实现控制流中的条件变化,比如说用来实现
if
或while
语句。 - 浮点寄存器文件包含 8 个位置,用来存放浮点数据。
虽然 C 提供了一种模型,可以在存储器中声明和分配各种数据类型的对象,但是汇编代码只是简单地将存储器看成一个很大的、按字节寻址的数组。C中的聚集数据类型,例如数组和结构,在汇编代码中是用连续的字节表示的。即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。
程序存储器(program memory)包含程序的目标代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的存储器块(比如说用 malloc
库函数分配的)。
程序存储器是用虚拟地址来寻址的。在任意给定的时刻,只有有限的一部分虚拟地址是合法的。例如,虽然 IA32 的 32 位地址可以寻址 4GB 的地址范围,但是一个通常的程序只会访问几 M 字节。操作系统负责管理虚拟地址空间,将虚拟地址转换成实际处理器存储器(processor memory)中的物理地址。
一条机器指令只执行非常基本的操作。例如,将两个存放在寄存器中的数字相加,在存储器和寄存器之间传递数据,或是条件分支转移到新的指令地址。编译器必须产生这些指令序列,从而实现像算术表达式求值、循环或过程调用和返回这样的程序结构。
3.2.2 代码示例
假设有如下的 C 代码文件 code.c
,包含下面这样的过程定义:
int accum = 0;
int sum(int x, int y)
{
int t = x + y;
accum += t;
return t;
}
在命令行上使用 “-S
” 选项,就能看到 C 编码器产生的汇编代码:
unix> gcc -O2 -S code.c
这会使编译器产生一个汇编文件 code.s
,但是不做其他进一步的工作(通常情况下,它还会调用汇编器产生目标代码文件)。
GCC 是按照它自己的格式产生汇编代码的,这种格式称为 GAS(Gnu ASsembler,GNU 汇编器)。 后续的讲述是基于这种格式的,它同 Intel 文档中的格式以及微软编译器使用的格式差异很大。
汇编代码文件包含各种声明,包括下面所示:
sum:
pushl %ebp
movl %esp,%ebp
movl 12(%ebp), %eax
addl 8(%ebp), %eax
addl %eax, accum
movl %ebp, %esp
popl %ebp
ret
上面代码中的每个缩进去的行都对应于一条机器指令。比如,pushl
指令表示应该将寄存器 %ebp
的内容压入程序栈中。这段代码中已经除去了所有关于局部变量名或数据类型的信息,但我们还是看到了一个对全局变量 accum
的引用,这是因为编译器还不能确定这个变量会放在存储器中的哪个位置。
如果使用 -c
命令行选项,GCC 会编译并汇编该代码:
unix> gcc -O2 -c code.c
这就会产生目标代码文件 code.o
,它是二进制格式的,所以无法直接读。852字节的文件 code.o
中有一段 19 字节的十六进制表示的序列:
这就是对应于上面列出的汇编指令的目标代码。从中得到的重要信息就是,机器实际执行的程序只是对一系列指令进行编码的字节序列。机器对产生这些指令的源代码几乎一无所知。
如何找到程序的字节表示?
首先,用反汇编器来确定函数 sum
的代码长是 19 字节。
然后,在文件 code.o
上运行 GNU 调试工具GDB,输入命令:
(gdb) x/19xb sum
这条命令告诉 GDB 检查(简写为 “x”)19 个十六进制格式(也简写为 “x”)的字节(简写为“b”)。
要查看目标代码文件的内容,有一类称为反汇编器(disassembler)的程序的价值无法估量,这些程序根据目标代码生成一种类似于汇编代码的格式。【注:反汇编器根据目标代码生成汇编代码】。
在 Linux 系统中,带 “-d” 命令行选项的程序 OBJDUMP (代表 “object dump”)可以充当这个角色。【注:程序 OBJDUMP 用 “-d” 命令行选项可充当反汇编器。】
unix> objdump -d code.o
结果是(这里,在左边增加了行号,在右边增加了注解):
在左边,看到按照前面给出的字节顺序排列的 19 个十六进制字节值,它们分成了一些组,每组有 1 ~ 6 个字节。每组都是一条指令,右边是等价的汇编语言。其中一些特性值得说明:
- IA32 指令长度从1 ~ 15 个字节不等。指令编码被设计成使常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。
- 指令格式是按照这样一种方式设计的,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令
pushl %ebp
是以字节值 55 开头的。 - 反汇编器只是根据目标文件中的字节序列来确定汇编代码的。它不需要访问程序的源代码或汇编代码。
- 反汇编器使用的指令命名规则与 GAS 使用的有些细微的差别。示例中,省略了很多指令结尾的 “
l
”。 - 与
code.s
中的汇编代码相比,结尾多了一条nop
指令。这条指令根本不会被执行(它在过程返回指令之后),即使执行了也不会有任何影响(所以称之为nop
,是 “no operation” 的简写,通常读作“no op”)。编译器插入这样的指令是为了填充存储该过程的空间。
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个 main
函数。假设在文件 main.c
中有下面这样的函数:
int main()
{
return sum(1, 3);
}
然后,用如下方法生成可执行文件 test:
unix> gcc -O2 -o prog code.o main.c
文件 prog 变成了 11667 个字节,因为它不仅包含两个 过程的代码,还包含了用来启动和终止程序的信息,以及用来与操作系统交互的信息。也可以反汇编 prog 文件:
unix> objdump -d prog
反汇编器会抽取出各种代码序列,包括下面这段:
注意,这段代码与 code.o
反汇编产生的代码几乎一样。
一个主要的区别是左边列出的地址不同——链接器将代码的地址移到一段不同的地址范围。
第二个不同之处在于链接器终于确定存储全局变量 accum
的地址。code.o
反汇编代码的第6行中,accum 的地址还是 0。prog 的反汇编代码中,地址就设成了 0x8049464。这可以从指令的汇编代码格式中看到,还可以从指令的最后四个字节中看出来,从最低位到最高位列出的就是64 94 04 08。
3.2.3 关于格式的注解
GCC 产生的汇编代码有点难读,它包含一些我们不需要关心的信息。另外,它不提供任何程序的描述或它是如何工作的描述。例如,假设文件 simple.c 包含下列代码:
int simple(int *xp, int y)
{
int t = *xp + y;
*xp = t;
return t;
}
当带选项 “-S” 运行 GCC 时,它产生下面的文件 simple.s
:
文件包含的信息多于我们实际需要的。所有以“.” 开头的行都是指导汇编器和链接器的命令(directive),不过通常可以忽略这些行。 另一方面,也没有关于这些指令是干什么用的以及它们与源代码之间关系的解释说明。
为了更清楚地说明汇编代码,将给出汇编代码的格式,包括行号和解释性说明。对于上文的示例,带解释的汇编代码是如下这样的:
通常我们只会给出与要讨论内容相关的代码行。每一行的左边都有编号供引用,右边是注释,简单地描述指令的效果以及它与原始 C 代码中的计算操作的关系。这是一种汇编语言程序员写代码的风格。
3.3 数据格式
由于是从 16 位体系结构扩展成 32 位的,Intel 用术语“字” 表示 16 位数据类型。 因此,称 32 位数为 “双字(double words)”,称 64 位数为 “四字(quad words)”。 我们将遇到的大多数指令都是对字节或双字操作的。
下图给出了对应 C 基本数据类型的机器表示。
注意,大多数常用数据类型都是作为双字存储的。 其中,包括普通整数(int)和长整数(long int),无论它们是否有符号。
此外,所有的指针(在此用 char *
表示)都是 4 字节的双字【注:32位系统指针占 4 字节,64位系统指针占 8 字节】。
处理字符串数据时,通常用到字节。
浮点数有三种形式:
- 单精度(4字节)值,对应于 C 数据类型
float
; - 双精度(8字节)值,对应于 C 数据类型
double
; - 和扩展精度(10字节)值。
GCC 用数据类型 long double
来表示扩展精度的浮点值。为了提高存储器系统的性能,它将这样的浮点数存储成 12 字节数。虽然 ANSI C 标准包括 long double
数据类型,但是对大多数编译器和机器组合来说,它的实现和普通 double
的 8 字节格式是一样的。对 GCC 和 IA32 的组合来说,支持扩展精度是很少见的。
如上图所示,GAS 中的每个操作都有一个字符后缀,表明操作数的大小。例如,mov
(传送数据)指令有三种形式:movb
(传送字节),movw
(传送字)和 movl
(传送双字)。后缀 “l
” 用来表示双字,因为在许多机器上,32 位数都称为 “长字(long word)”,这是沿用以 16 位字为标准的时代的习惯造成的。注意,GAS 使用后缀 “l
” 来同时表示 4 字节的整数和 8 字节的双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。