1.1 C编译过程
二进制文件是通过编译生成的,编译是将人类可读的源代码(如C或C++)转换为处理器可以执行的机器码的过程。[1]图1-1显示了典型C代码编译过程(C++代码编译过程类似)。编译C代码包括4个阶段(确实很不方便),分为预处理、编译、汇编及链接。在实践中,现代编译器经常合并部分或者全部阶段,但为了演示,我将会一一介绍它们。
图1-1 C代码编译过程
1.1.1 预处理阶段
编译过程从你要编译的各种源文件开始(可能只有一个源文件,但大型程序通常由许多文件组成,在图1-1中显示为file-1.c到file-n.c)。这不仅使得项目更易于管理,而且加快了编译速度,因为如果一个文件发生了更改,你只需重新编译该文件而不是所有代码。
C的源文件包含宏(用#define
表示)和#include
指令。你可以使用#include
指令包含源文件所依赖的头文件(扩展名为.h)。预处理阶段扩展了源文件中的所有#define
和#include
指令,因此剩下的就是准备编译的纯C代码。
让我们通过一个示例来说明这一点。此示例使用GCC编译器,这是许多Linux发行版(包括Ubuntu,常在虚拟机上安装的操作系统)的默认编译器。其他编译器(如Clang或者Visual Studio)编译的结果与之相似。我会将本书中的所有代码示例(包括当前示例)编译成x86-64代码,除非另有说明。
假设你想要编译一个C源文件,如清单1-1所示,它将“Hello, world!”消息输出到屏幕。
清单1-1 compilation_example.c
#include <stdio.h>
#define FORMAT_STRING "%s"
#define MESSAGE "Hello, world!\n"
int
main(int argc, char * argv[]) {
printf(FORMAT_STRING, MESSAGE);
return 0;
}
稍后你将看到在后续的编译过程中会发生什么,但是现在,我们只考虑预处理阶段的输出。在默认情况下,GCC会自动执行所有的编译阶段,因此你必须明确告诉它在预处理后停止,并显示中间输出。对GCC来说,这可以使用命令gcc-E-P
来完成,其中-E
告诉GCC在预处理后停止,-P
使GCC忽略调试信息,以便输出更清晰。清单1-2显示了预处理器的输出,为了简洁,我对该输出进行了编辑。启动虚拟机(Virtual Machine,VM),并查看预处理器的完整输出。
清单1-2 “Hello, world!”程序的C预处理器输出
$ gcc -E -P compilation_example.c
typedef long unsigned int size_t;
typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;
/ * ...*/
extern int sys_nerr;
extern const char *const sys_errlist[];
extern int fileno (FILE * __stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern int fileno_unlocked (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern FILE *popen (const char *__command, const char *__modes) ;
extern int pclose (FILE *__stream);
extern char *ctermid (char *__s) __attribute__ ((__nothrow__ , __leaf__));
extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
int
main(int argc, char* argv[]) {
printf(❶"%s",❷"Hello, world!\n");
return 0;
}
stdio.h头文件全部包含在内,其所有的类型定义、全局变量及函数原型都被“复制”到源文件中。因为每个#include
指令都会发生这种情况,所以预处理器输出可能非常冗长。预处理器还完整地扩展了#define
定义的任何宏的所有用法。在示例中,这意味着对printf
(FORMAT_STRING
❶和MESSAGE
❷)的两个参数进行计算,并用它们所代表的常量字符串进行替换。
1.1.2 编译阶段
预处理阶段完成后,就可以编译源代码了。编译阶段采用预处理代码并将代码转换为汇编语言。大多数编译器也会在此阶段进行大量的优化,通常可以通过命令行开关配置优化级别,如GCC中的选项-O0
到-O3
。正如你将在第6章中看到的那样,编译过程中的优化级别会对反汇编产生深远的影响。
为什么编译阶段会将代码转换为汇编语言而不是机器代码?这个设计决策似乎在一种语言(在本例中为C)的上下文中没有意义,但是当你考虑其他语言的时候,这样做确实是有意义的。对一些编译语言如C、C++、Objective-C、Common Lisp、Delphi、Go及Haskell等,编写一种能够直接为每种语言翻译(emit)机器代码的编译器是一项极其苛刻且耗时的任务,因此最好先为其翻译出汇编代码(已经是一项足够具有挑战性的任务了),并且需要有一个专用的汇编程序,以处理每种语言的汇编代码到机器代码的转换。
因此,编译阶段的输出,是以人类可读的形式进行汇编的,其中的符号信息完整无缺。如前所述,GCC通常会自动调用所有的编译阶段,因此要从编译阶段查看已翻译的汇编,你要告诉GCC在此阶段后必须停止,并将汇编文件存储到磁盘。你可以使用-S
标志执行此操作(.s是汇编文件的常规扩展名)。你还可以将选项-masm-intel
传递给GCC,让它以Intel语法而不是默认的AT&T语法翻译汇编语言。清单1-3显示了示例程序中编译阶段生成的汇编代码。[2]
清单1-3 由“Hello, world!”程序在编译阶段生成的汇编代码
$ gcc -S -masm=intel compilation_example.c
$ cat compilation_example.s
.file "compilation_example.c"
.intel_syntax noprefix
.section .rodata
❶ .LC0:
.string "Hello, world!"
.text
.globl main
.type main, @function
❷ main:
.LFB0:
.cfi_startproc
push rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp
.cfi_def_cfa_register 6
sub rsp, 16
mov DWORD PTR [rbp-4], edi
mov QWORD PTR [rbp-16], rsi
mov edi, ❸ OFFSET FLAT:.LC0
call puts
mov eax, 0
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
现在,我不会给你详细介绍汇编代码。有趣的是,在清单1-3中汇编代码相对容易阅读,因为符号和函数已经被保留下来了。如常量和变量拥有符号名称而不仅仅是内存地址(即使只是一个自动生成的名称,如“Hello, world!”的匿名字符串是LC0
❶),并且main函数有一个显式标签(该情况下的唯一函数)❷。对代码的任何引用都是有符号的,如对“Hello, world!”字符串❸的引用。在本书后文介绍的处理剥离的二进制文件中,你就没有这样奢侈了!(因为没有符号。)
1.1.3 汇编阶段
在汇编阶段,最终你可以得到真正的机器代码!汇编阶段的输入是在编译阶段生成的汇编语言集,输出是一组对象文件,有时简称为模块。对象文件原则上包含可由处理器执行的机器指令。但像我刚才所说的,你需要一个可执行文件。通常,每个源文件对应一个汇编文件,每个汇编文件对应一个对象文件。为了生成对象文件,传递-c
标志给GCC,如清单1-4所示。
清单1-4 使用GCC生成对象文件
$ gcc -c compilation_example.c
$ file compilation_example.o
compilation_example.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
你可以使用file
工具(将会在第5章介绍的一款工具)来确认生成的compilation_example.o文件确实是对象文件。如清单1-4所示,文件显示这是一个ELF 64位重定位文件。
这到底是什么意思?file
输出的第一部分显示了该文件符合二进制可执行文件的ELF规范(我将在第2章中详细讨论)。更具体地说,它是一个64位的ELF二进制文件(因为在这个示例中你编译的是x86-64),并且是最低有效位(Least Significant Bit,LSB),这意味着数字在内存中的排序是以最低有效字节优先的。但最重要的是,你可以看到该文件是可重定位的。
可重定位文件不依赖于放置在内存中的任何特定地址,相反,它们可以随意移动,而不会破坏代码中的任何假设。当你在文件输出中看到术语“可重定位”时,你就知道你正在处理的是对象文件而不是可执行文件。[3]
对象文件相互独立编译,因此汇编程序在组装对象文件时无法知道其他对象文件的内存地址。这就是对象文件需要可重定位的原因,这样你就可以按照任意顺序将对象文件链接在一起,形成完整的二进制可执行文件。如果对象文件不可重定位,则无法实现文件的任意顺序组合。
当你准备第一次反汇编文件的时候,你将会在后文看到对象文件的内容。
1.1.4 链接阶段
链接阶段是编译过程的最后阶段。顾名思义,此阶段将所有对象文件链接到一个二进制可执行文件中。在现代系统中,链接阶段有时会包含额外的优化过程,被称为链接时优化(Link-Time Optimization,LTO)。
执行链接阶段的程序被称为链接器或者链接编辑器。通常链接器与编译器相互独立,编译器通常实现前面所有的步骤。
正如我前面已经提到的,对象文件是可重定位的,因为它们是相互独立编译的,这使得编译器无法假设对象最终会出现在任意特定的基址上。此外,对象文件可以引用其他对象文件或程序外部库中的函数或者变量。在链接阶段之前,引用代码和数据的地址尚不清楚,因此对象文件只包含重定位符号,这些符号指定最终如何解析函数和变量引用。在链接上下文中,依赖于重定位符号的引用称为符号引用。当一个对象文件通过绝对地址引用自己的函数或变量时,该引用也会被符号化。
链接器的工作是获取属于程序的所有对象文件,并将它们合并为一个连贯的可执行文件,然后加载到特定的内存地址。既然现在已经知道可执行文件中所有模块的排列,链接器也就可以解析大多数的符号引用了。根据库文件的类型,对库文件的引用可能会、也可能不会完全解析。
静态库(在Linux操作系统上扩展名通常为.a,见图1-1)被合并到二进制可执行文件中,允许完全解析对静态库的任何引用。还有动态(共享)库,它们在系统上运行的所有程序的内存中共享。换句话说,不是将库文件复制到使用它的每个二进制文件中,而是仅将动态库加载到内存中一次,并且任何想要使用该库的二进制文件都需要使用此共享副本。在链接阶段,动态库将驻留的内存地址尚不清楚,因此无法解析对它们的引用。相反,即使在最终的可执行文件中,链接器也会对这些库文件留下符号引用,并且在将二进制文件实际加载到要执行的内存中之前,不会解析这些引用。
大多数编译器(包括GCC)在编译过程结束时会自动调用链接器。因此,要生成完整的二进制可执行文件,只需在没有任何特殊开关的情况下调用GCC,如清单1-5所示。
清单1-5 使用GCC生成二进制可执行文件
$ gcc compilation_example.c
$ file a.out
a.out: ❶ ELF 64-bit LSB executable, x86-64, version 1 (SYSV), ❷ dynamically
linked, ❸ interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=d0e23ea731bce9de65619cadd58b14ecd8c015c7, ❹ not stripped
$ ./a.out
Hello, world!
默认情况下,可执行文件名为a.out,但你可以通过-o
选项给GCC重写此命名,选项后跟输出文件的名称。file
实用程序现在告诉你正在处理的是一个ELF 64位LSB可执行文件❶,而不是在汇编阶段结束时看到的可重定位文件。此外还有一个重要信息是文件是动态链接的❷,这意味着它使用的某些库未合并到可执行文件中,而是在同一系统上运行的所有程序之间共享。最后,解释器/lib64/ld-linux-x86-64.so.2❸的文件输出会告诉你,当可执行文件加载到内存中执行时,哪个动态链接器将会被用来解析动态库的最终依赖关系。当你运行二进制文件(使用./a.out)时,你可以看到文件产生了预期的输出,打印“Hello, world!”到标准输出,这证实你已经生成了一个可以正常运行的二进制文件。
但是这个二进制文件没有被“剥离”,这是什么意思呢❹?接下来我会讨论这个问题!