0.导读
假设一个C程序,有两个文件 p1.c和 p2.c。我们用Unix命令行编译这些代码:
linux> gcc -og -o p p1.c p2.c
命令 gcc指的就是GCC C编译器。因为这是Linux上默认的编译器,我们也可以简单地用cc来启动它。编译选项-Og告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。因此我们会使用-Og 优化作为学习工具,然后当我们增加优化级别时,再看会发生什么。实际中,从得到的程序的性能考虑,较高级别的优化(例如,以选项-O1或-O2指定)被认为是较好的选择。
实际上gcc命令调用了一整套的程序,将源代码转化成可执行代码。首先,C预处理器扩展源代码,插人所有用#include命令指定的文件,并扩展所有用#define声明指定的宏。其次,编译器产生两个源文件的汇编代码,名字分别为p1.s和p2.s。接下来,汇编器会将汇编代码转化成二进制目标代码文件p1.o和p2.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址。最后,链接器将两个目标代码文件与实现库函数(例如printf)的代码合并,并产生最终的可执行代码文件p(由命令行指示符-o p指定的)。可执行代码是我们要考虑的机器代码的第二种形式,也就是处理器执行的代码格式。我们会在第7章更详细地介绍这些不同形式的机器代码之间的关系以及链接的过程。
1.机器级代码
正如在1.9.3节中讲过的那样,计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要。第一种是由指令集体系结构或指令集架构(Instruction Set Architecture,ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA,包括x86-64,将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行的行为完全一-致。第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来,这会在第9章中讲到。
在整个编译过程中,编译器会完成大部分的工作,将把用C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。能够理解汇编代码以及它与原始C代码的联系,是理解计算机如何执行程序的关键一步。
x86-64的机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的处理器状态都是可见的:
- 程序计数器(通常称为“PC”,在x86-64中用%rip表示)给出将要执行的下一条指令在内存中的地址。
- 整数寄存器文件包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
- 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和 while语句。
- 一组向量寄存器可以存放一个或多个整数或浮点数值。
虽然C语言提供了一种模型,可以在内存中声明和分配各种数据类型的对象,但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。C语言中的聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。
程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块(比如说用malloc库函数分配的)。正如前面提到的,程序内存用虚拟地址来寻址。在任意给定的时刻,只有有限的一部分虚拟地址被认为是合法的。例如,x86-64的虚拟地址是由64位的字来表示的。在目前的实现中,这些地址的高16位必须设置为0,所以一个地址实际上能够指定的是248或64TB范围内的一个字节。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。
一条机器指令只执行一个非常基本的操作。例如,将存放在寄存器中的两个数字相加,在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些指令的序列,从而实现(像算术表达式求值、循环或过程调用和返回这样的)程序结构。
旁注 不断变化的生成代码的格式
在本书的表述中,我们给出的代码是由特定版本的GCC在特定的命令行选项设置下产生的。如果你在自己的机
器上编译代码,很有可能用到其他的编译器或者不同版本的GCC,因而会产生不同的代码。支持GCC的开源社区一
直在修改代码产生器,试图根据微处理器制造商提供的不断变化的代码规则,产生更有效的代码。
本书示例的目标是展示如何查看汇编代码,并将它反向映射到高级编程语言中的结构。你需要将这些技术应用
到你的特定的编译器产生的代码格式上。
2.代码示例
假设我们写了一个C语言代码文件 mstore.c,包含如下的函数定义:
long mult2(long,long);
void multstor(long x,long y,long*dest)
{
long t = mult2(x,y);
*dest = t ;
}
在命令行上使用“-S”选项,就能看到C语言编译器产生的汇编代码:
linux>gcc -Og -S mstore.c
这会使GCC运行编译器,产生一个汇编文件mstore.s,但是不做其他进一步的工作。(通常情况下,它还会继续调用汇编器产生目标代码文件)。汇编代码文件包含各种声明,包括下面几行:

上面代码中每个缩进去的行都对应于一条机器指令。比如,pushq指令表示应该将寄存器%rbx的内容压入程序栈中。这段代码中已经除去了所有关于局部变量名或数据类型的信息。
如果我们使用“-c”命令行选项,GCC会编译并汇编该代码:
linux> gcc -Og -c mstore.c
这就会产生目标代码文件mstore.o,它是二进制格式的,所以无法直接查看。1368字节的文件mstore.o中有一段14字节的序列,它的十六进制表示为:
53 48 89 d3 e8 00 o0 o0 o0 48 89 03 5b c3
这就是上面列出的汇编指令对应的目标代码。从中得到一个重要信息,即机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。
要查看机器代码文件的内容,有一类称为反汇编器(disassembler)的程序非常有用。这些程序根据机器代码产生一种类似于汇编代码的格式。在Linux系统中,带'-d'命令行标志的程序OBJDUMP(表示“object dump")可以充当这个角色:
linux> objdump -d mstore.o
结果如下(这里,我们在左边增加了行号,在右边增加了斜体表示的注解):

在左边,我们看到按照前面给出的字节顺序排列的14个十六进制字节值,它们分成了若干组,每组有1~5个字节。每组都是一条指令,右边是等价的汇编语言。
其中一些关于机器代码和它的反汇编表示的特性值得注意:
- x86-64的指令长度从1到15个字节不等。常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。
- 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令pushq %rbx是以字节值53开头的。
- 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
- 反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。在我们的示例中,它省略了很多指令结尾的‘q'。这些后缀是大小指示符,在大多数情况中可以省略。相反,反汇编器给call和ret指令添加了‘q'后缀,同样,省略这些后缀也没有问题。
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数。假设在文件 main.c 中有下面这样的函数:
#include <stdio.h>
void multstore(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:
linux> gcc -Og -o prog main.c mstore.c
反汇编器会抽取出各种代码序列,包括下面这段:

这段代码与 mstore.c 反汇编产生的代码几乎完全一样。其中 一个主要的区别是左边列出的地址不同——链接器将这段代码的地址移到了一段不同的地址范围中。 第二个不同之处在于链接器填上了 callq 指令调用函数 mult2 需要使用的地址(反汇编代码第 4 行)。链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置。 最后一个区别是多了两行代码(第 8 和 9 行)。这两条指令对程序没有影响,因为它们出现在返回指令后面(第 7 行)。插入这些指令是为了使函数代码变为 16 字节,使得就存储器系统性能而言,能更好地放置下一个代码块。
Notice :我自己编的和书上的差了一点点,差了最后两行nop的汇编。

旁注 如何展示程序的字节表示
要展示程序(比如说mstore)的二进制目标代码,我们用反汇编器(后面会讲到)确定该过程的代码长度是14
字节。然后,在文件mstore.o上运行GNU调试工具GDB,输入命令:
(gdb) x/14xb multstore
这条命令告诉GDB显示(简写为’x’)从函数multstore所处地址开始的14个十六进制格式表示(也简写为’x’)
的字节(简写为‘b')。你会发现,GDB有很多有用的特性可以用来分析机器级程序,我们会在3.10.2节中讨论。
3.关于格式的注解
GCC产生的汇编代码对我们来说有点儿难读。一方面,它包含一些我们不需要关心的信息,另一方面,它不提供任何程序的描述或它是如何工作的描述。例如,假设我们用如下命令生成文件mstore.s。
linux>gcc -Og -s mstore.c
mstore.s的完整内容如下:
.file "3.2.2-mstore.c"
.text
.globl multstor
.type multstor, @function
multstor:
.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
.LFE0:
.size multstor, .-multstor
.ident "GCC: (GNU) 7.3.1 20180303 (Red Hat 7.3.1-5)"
.section .note.GNU-stack,"",@progbits
所有以‘.’开头的行都是指导汇编器和链接器工作的伪指令。我们通常可以忽略这些行。另一方面,也没有关于指令的用途以及它们与源代码之间关系的解释说明。
为了更清楚地说明汇编代码,我们用这样一种格式来表示汇编代码,它省略了大部分伪指令,但包括行号和解释性说明。对于我们的示例,带解释的汇编代码如下:
void multstore(long x, long y,long *dest)
x in %rdi, y in %rsi , dest in %rdx
1 multstore:
2 pushq %rbx Save %rbx
3 movq %rdx,%rbx Copy dest to %rbx
4 call mult2 Call mult2(x,y)
5 movq %rax,(%rbx) Store result at *dest
6 popq %rbx Restore %rbx
7 ret Return
通常我们只会给出与讨论内容相关的代码行。每一行的左边都有编号供引用,右边是注释,简单地描述指令的效果以及它与原始C语言代码中的计算操作的关系。这是一种汇编语言程序员写代码的风格。
我们还提供网络旁注,为专门的机器语言爱好者提供一些资料。一个网络旁注描述的是IA32机器代码。有了x86-64的背景,学习IA32会相当简单。另外一个网络旁注简要描述了在C语言中插入汇编代码的方法。对于一些应用程序,程序员必须用汇编代码来访问机器的低级特性。一种方法是用汇编代码编写整个函数,在链接阶段把它们和C函数组合起来。另一种方法是利用GCC的支持,直接在C程序中嵌入汇编代码。
旁注 ATT与Intel汇编代码格式
我们的表述是ATT(根据“AT&T”命名的,AT&T是运营贝尔实验室多年的公司)格式的汇编代码,这是
GCC、OBJDUMP 和其他一些我们使用的工具的默认格式。
其他一些编程工具,包括 Microsoft的工具,以及来自Intel的文档,其汇编代码都是Intel格式
的。这两种格式在许多方面有所不同。例如,使用下述命令行,GCC可以产生multstore函数的Intel格
式的代码:
linux> gcc -Og -S -masm=intel mstore.c
这个命令得到下列汇编代码:
multstore:
push rbp
mov rbx,rdx
call mult2
mov QWORD PTR [rbx],rax
pop rbx
ret
我们看到Intel和ATT格式在如下方面有所不同:
1.Intel代码省略了指示大小的后缀。我们看到指令push和mov,而不是pushq和movq。
2.Intel代码省略了寄存器名字前面的‘%’符号,用的是rbx,而不是%rbx。
3.Intel代码用不同的方式来描述内存中的位置,例如是‘QWORD PTR[rbx]’而不是‘(%rbx)’。
4.在带有多个操作数的指令情况下,列出操作数的顺序相反。当在两种格式之间进行转换的时候,
这一点非常令人困惑。
虽然在我们的表述中不使用Intel格式,但是在来自Intel和Microsoft的文档中,你会遇到它。
网络旁注 ASM:EASM 把C程序和汇编代码结合起来
虽然C编译器在把程序中表达的计算转换到机器代码方面表现出色,但是仍然有一些机器特性是C程序访
问不到的。例如,每次 x86-64处理器执行算术或逻辑运算时,如果得到的运算结果的低8位中有偶数个1,
那么就会把一个名为PF的1位条件码(condition code)标志设置为1,否则就设置为0。这里的PF表示“parity
flag(奇偶标志)”。在C语言中计算这个信息需要至少7次移位、掩码和异或运算(参见习题2.65)。即使作为
每次算术或逻辑运算的一部分,硬件都完成了这项计算,而C程序却无法知道PF条件码标志的值。在程序中插
入几条汇编代码指令就能很容易地完成这项任务。
在C程序中插入汇编代码有两种方法。第一种是,我们可以编写完整的函数,放进一个独立的汇编代码文
件中,让汇编器和链接器把它和用C语言书写的代码合并起来。第二种方法是,我们可以使用GCC的内联汇编
(inline assembly)特性,用asm伪指令可以在C程序中包含简短的汇编代码。这种方法的好处是减少了与机
器相关的代码量。
当然,在C程序中包含汇编代码使得这些代码与某类特殊的机器相关(例如x86-64),所以只应该在想要
的特性只能以此种方式才能访问到时才使用它。
本文详细介绍了C程序从源代码到可执行文件的编译过程,包括预处理、编译、汇编和链接的步骤。讨论了编译器的优化选项,如-Og,以及它如何影响生成的机器代码。文章通过一个示例展示了汇编代码与C代码的对应关系,解释了机器级编程中的关键概念,如指令集架构、程序计数器、寄存器和内存模型。此外,还探讨了如何通过反汇编器查看和理解目标代码。
4482

被折叠的 条评论
为什么被折叠?



