预处理(Prepressing)—>编译(Compilation)—>汇编(Assembly)—>链接(Linking)
各个过程中实际执行任务的程序名称:预处理器cpp、编译器cc1、汇编器as以及最后的链接器ld。
partI gcc工具链
- GCC
- GCC(GNU C Compiler)是编译工具。本文所要介绍的将C/C++语言编写的程序转换成为处理器能够执行的二进制代码的过程即由编译器完成。
- Binutils:
一组二进制程序处理工具,包括:addr2line、ar、objcopy、objdump、as、ld、ldd、readelf、size等。这一组工具是开发和调试不可缺少的工具,分别简介如下:
addr2line:用来将程序地址转换成其所对应的程序源文件及所对应的代码行,也可以得到所对应的函数。该工具将帮助调试器在调试的过程中定位对应的源代码位置。
as:主要用于汇编,。
ld:主要用于链接,。
ar:主要用于创建静态库。为了便于初学者理解,在此介绍动态库与静态库的概念:
-
如果要将多个.o目标文件生成一个库文件,则存在两种类型的库,一种是静态库,另一种是动态库。
-
在windows中静态库是以 .lib 为后缀的文件,共享库是以 .dll 为后缀的文件。在linux中静态库是以.a为后缀的文件,共享库是以.so为后缀的文件。
-
静态库和动态库的不同点在于代码被载入的时刻不同。静态库的代码在编译过程中已经被载入可执行程序,因此体积较大。
-
动态库(共享库)的代码是在可执行程序运行时才载入内存的,在编译过程中仅简单的引用,因此代码体积较小。在Linux系统中,可以用ldd命令查看一个可执行程序依赖的共享库。
-
如果一个系统中存在多个需要同时运行的程序且这些程序之间存在共享库,那么采用动态库的形式将更节省内存。
ldd:可以用于查看一个可执行程序依赖的共享库。
objcopy:将一种对象文件翻译成另一种格式,譬如将.bin转换成.elf、或者将.elf转换成.bin等。
objdump:主要的作用是反汇编。有关反汇编的详细介绍,请参见后文。
readelf:显示有关ELF文件的信息,请参见后文了解更多信息。
size:列出可执行文件每个部分的尺寸和总尺寸,代码段、数据段、总大小等,请参见后文了解使用size的具体使用实例。
part II: C运行时库
运行时库的概念
C语言标准主要由两部分组成:一部分描述C的语法,另一部分描述C标准库。C标准库定义了一组标准头文件,每个头文件中包含一些相关的函数、变量、类型声明和宏定义,譬如常见的printf函数便是一个C标准库函数,其原型定义在stdio头文件中。
其实,任何一个程序,它的背后都有一套庞大的代码在支撑着它,以使得该程序能够正常运行。这套代码至少包括入口函数、以及其所依赖的函数构成的函数集合。
当然,它还包含了各种标准库函数的实现。
这个“支撑模块”就叫做运行时库(Runtime Library)。而C语言的运行库,即被称为C运行时库(CRT)。
C语言标准仅仅定义了C标准库函数原型,并没有提供实现。因此,C语言编译器通常需要一个C运行时库(C Run Time Libray,CRT)的支持。C运行时库又常简称为C运行库。与C语言类似,C++也定义了自己的标准,同时提供相关支持库,称为C++运行时库。
CRT大致包括:
- 启动与退出相关的代码(包括入口函数及入口函数所依赖的其他函数)、
- 标准库函数(ANSI C标准规定的函数实现)、
- I/O相关、
- 堆的封装实现、
- 语言特殊功能的实现以及调试相关。
其中标准库函数的实现占据了主要地位。标准库函数大家想必很熟悉了,而我们平时常用的printf,scanf函数就是标准库函数的成员。
C语言标准库在不同的平台上实现了不同的版本,我们只要依赖其接口定义,就能保证程序在不同平台上的一致行为。C语言标准库有24个,囊括标准输入输出、文件操作、字符串操作、数学函数以及日期等等内容。大家有兴趣的可以自行搜索。
运行时库中的标准库
既然C语言提供了标准库函数供我们使用,那么以什么形式提供呢?源代码吗?当然不是了。下面我们引入静态链接库的概念。我们几乎每一次写程序都难免去使用库函数,那么每一次去编译岂不是太麻烦了。干嘛不把标准库函数提前编译好,需要的时候直接链接呢?我很负责任的说,我们就是这么做的。
标准库的存在形式
那么,标准库以什么形式存在呢?一个目标文件?
我们知道,链接的最小单位就是一个个目标文件,如果我们只用到一个printf函数,就需要和整个库链接的话岂不是太浪费资源了么?
但是,如果把库函数分别定义在彼此独立的代码文件里,这样编译出来的可是一大堆目标文件,有点混乱吧?
所以,编辑器系统提供了一种机制,将所有的编译出来的目标文件打包成一个单独的文件,叫做静态库(static library)。
当链接器和静态库链接的时候,链接器会从这个打包的文件中“解压缩”出需要的部分目标文件进行链接。这样就解决了资源浪费的问题。
----------------------- 代码实例---------------------------------------------------------------
此本文也将以Linux系统作为工作环境。为了能够演示编译的整个过程,本节先准备一个C语言编写的简单Hello程序作为示例,其源代码如下所示:
#include <stdio.h>
//此程序很简单,仅仅打印一个Hello World的字符串。
int main(void)
{
printf("Hello World! \n");
return 0;
}
1. 预处理
预处理器处理的大致范围
1. 展开所有的宏定义并删除 #define
2. 处理所有的条件编译指令,例如 #if #else #endif #ifndef …
3. 把所有的 #include 替换为头文件实际内容,递归进行
4. 把所有的注释 // 和 / / 替换为空格
5. 添加行号和文件名标识以供编译器使用
6. 保留所有的 #pragma 指令,因为编译器要使用
$ gcc -E hello.c -o hello.i
打开预处理后的代码文件,前面多出很多行是什么呢?
extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
# 868 "/usr/include/stdio.h" 3 4
# 2 "hello.c" 2
# 4 "hello.c"
int main(void)
{
printf("Hello World! \n");
return 0;
}
其实它就是 /usr/include/stdio.h
文件的所有内容,预处理器把所有的#include
替换为实际文件的内容了。
这个过程是递归进行的,所以stdio.h
里面的#include
也被实际内容所替换了。
而且在HelloWorld.c里面的所有注释被预处理器全部删除了。
就连printf语句前的Tab缩进也被替换为一个空格了,显得代码都不美观了。
在代码中有时候宏定义比较复杂的时候我们很难判断其处理后的结构是否正确。这个时候我们呢就可以使用gcc的-E参数输出处理结果来判断了。
2.编译(生成汇编代码)
编译就是把预处理之后的文件进行一系列词法分析、语法分析、语义分析以及优化后生成的相应汇编代码文件。
$ gcc -S hello.i -o hello.s // 将预处理生成的hello.i文件编译生成汇编程序hello.s
// GCC的选项-S使GCC在执行完编译后停止,生成汇编程序
这里生成的汇编是AT&T风格的汇编代码,如果大家更熟悉Intel风格,可以在命令行加上参数 -masm=intel
,这样gcc就会生成Intel风格的汇编代码了
gcc的内联汇编只支持AT&T风格。
.file "hello.c"
.text
.section .rodata
.LC0:
.string "Hello World! "
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
leaq .LC0(%rip), %rdi
call puts@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
.section .note.GNU-stack,"",@progbits
3. 汇编(生成.o 目标文件)
汇编过程调用对汇编代码进行处理,生成处理器能识别的指令,保存在后缀为.o的目标文件中。
由于每一个汇编语句几乎都对应一条处理器指令,因此,汇编相对于编译过程比较简单,通过调用Binutils中的汇编器as根据汇编指令和处理器指令的对照表一一翻译即可。
$ gcc -c hello.s -o hello.o // 将编译生成的hello.s文件汇编生成目标文件hello.o
// GCC的选项-c使GCC在执行完汇编后停止,生成目标文件
//或者直接调用as进行汇编
$ as -c hello.s -o hello.o //使用Binutils中的as将hello.s文件汇编生成目标文件
当程序由多个源代码文件构成时,每个文件都要先完成汇编工作,生成.o目标文件后,才能进入下一步的链接工作。注意:目标文件已经是最终程序的某一部分了,但是在链接之前还不能执行。
注意:hello.o目标文件为ELF(Executable and Linkable Format)格式的可重定向文件。
Linux下的可执行文件以及目标文件的格式叫作ELF(Executable Linkable Format)。其实Windows下的PE(Portable Executable)也好,ELF也罢,都是COFF(Common file format)格式的一种变种,甚至Windows下的目标文件就是以COFF格式去存储的。
不同的操作系统之间的可执行文件的格式通常是不一样的,所以造成了编译好的HelloWorld没有办法直接复制执行,而需要在相关平台上重新编译。当然了,不能运行的原因自然不是这一点点,不同的操作系统接口(windows API和Linux的System Call)以及相关的类库不同也是原因之一。
4. 链接
链接过程。这一步是将汇编产生的目标文件和所使用的库函数的目标文件链接生成一个可执行文件的过程。
链接也分为静态链接和动态链接,其要点如下:
4.1 静态链接
是指在编译阶段直接把静态库加入到可执行文件中去,这样可执行文件会比较大。链接器将函数的代码从其所在地(不同的目标文件或静态链接库中)拷贝到最终的可执行程序中。
为创建可执行文件,链接器必须要完成的主要任务是:符号解析(把目标文件中符号的定义和引用联系起来)和重定位(把符号定义和内存地址对应起来然后修改所有对符号的引用)。
4.2 动态链接
则是指链接阶段仅仅只加入一些描述信息,而程序执行时再从系统中把相应动态库加载到内存中去。
-
在Linux系统中,gcc编译链接时的动态库搜索路径的顺序通常为:首先从gcc命令的参数-L指定的路径寻找;再从环境变量
LIBRARY_PATH
指定的路径寻址;再从默认路径/lib、/usr/lib、/usr/local/lib
寻找。 -
在Linux系统中,执行二进制文件时的动态库搜索路径的顺序通常为:首先搜索编译目标代码时指定的动态库搜索路径;
-
再从环境变量
LD_LIBRARY_PATH
指定的路径寻址;再从配置文件/etc/ld.so.conf
中指定的动态库搜索路径;再从默认路径/lib、/usr/lib
寻找。 -
在Linux系统中,可以用ldd命令查看一个可执行程序依赖的共享库。
4.1 优先动态链接库
由于链接动态库和静态库的路径可能有重合,所以如果在路径中有同名的静态库文件和动态库文件,比如libtest.a和libtest.so,gcc链接时默认优先选择动态库,会链接libtest.so,如果要让gcc选择链接libtest.a则可以指定gcc选项-static,该选项会强制使用静态库进行链接。以Hello World为例:
- 如果使用命令
gcc hello.c -o hello
则会使用动态库进行链接,生成的ELF可执行文件的大小(使用Binutils的size命令查看)和链接的动态库(使用Binutils的ldd命令查看)如下所示:
$ gcc hello.c -o hello
$ size hello //使用size查看大小
text data bss dec hex filename
1516 600 8 2124 84c hello
$ ldd hello //可以看出该可执行文件链接了很多其他动态库,主要是Linux的glibc动态库
linux-vdso.so.1 (0x00007ffc569f4000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f471100f000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4711602000)
- 如果使用命令
gcc -static hello.c -o hello
则会使用静态库进行链接, - 生成的ELF可执行文件的大小(使用Binutils的size命令查看)和链接的动态库(使用Binutils的ldd命令查看)如下所示:
$ gcc -static hello.c -o hello
$ size hello
text data bss dec hex filename
743557 20876 5984 770417 bc171 hello
$ ldd hello
not a dynamic executable
5. 分析可执行文件
链接器链接后生成的最终文件为ELF格式可执行文件,一个ELF可执行文件通常被链接为不同的段,常见的段譬如.text、.data、.rodata、.bss等段。
5.1 ELF文件的段
ELF文件格式如下图所示,位于ELF Header和Section Header Table之间的都是段(Section)。一个典型的ELF文件包含下面几个段:
.text:已编译程序的指令代码段。
.rodata:ro代表read only,即只读数据(譬如常数const)。
.data:已初始化的C程序全局变量和静态局部变量。
.bss:未初始化的C程序全局变量和静态局部变量。
.debug:调试符号表,调试器用此段的信息帮助调试。
$ readelf -S hello
There are 33 section headers, starting at offset 0xcdd78:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .note.ABI-tag NOTE 0000000000400190 00000190
0000000000000020 0000000000000000 A 0 0 4
[ 2] .note.gnu.build-i NOTE 00000000004001b0 000001b0
0000000000000024 0000000000000000 A 0 0 4
readelf: Warning: [ 3]: Link field (0) should index a symtab section.
[30] .symtab SYMTAB 0000000000000000 000bc940
000000000000a998 0000000000000018 31 679 8
[31] .strtab STRTAB 0000000000000000 000c72d8
0000000000006928 0000000000000000 0 0 1
[32] .shstrtab STRTAB 0000000000000000 000cdc00
0000000000000176 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
5.2 反汇编ELF
由于ELF文件无法被当做普通文本文件打开,如果希望直接查看一个ELF文件包含的指令和数据,需要使用反汇编的方法。
使用objdump -D对其进行反汇编如下:
objdump -D hello
……
0000000000400526 <main>: // main标签的PC地址
//PC地址:指令编码 指令的汇编格式
400526: 55 push %rbp
400527: 48 89 e5 mov %rsp,%rbp
40052a: bf c4 05 40 00 mov $0x4005c4,%edi
40052f: e8 cc fe ff ff callq 400400 <puts@plt>
400534: b8 00 00 00 00 mov $0x0,%eax
400539: 5d pop %rbp
40053a: c3 retq
40053b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
使用objdump -S将其反汇编并且将其C语言源代码混合显示出来
$ gcc -o hello -g hello.c //要加上-g选项
0000000000000630 <frame_dummy>:
630: 55 push %rbp
631: 48 89 e5 mov %rsp,%rbp
634: 5d pop %rbp
635: e9 66 ff ff ff jmpq 5a0 <register_tm_clones>
000000000000063a <main>:
#include <stdio.h>
//此程序很简单,仅仅打印一个Hello World的字符串。
int main(void)
{
63a: 55 push %rbp
63b: 48 89 e5 mov %rsp,%rbp
printf("Hello World! \n");
63e: 48 8d 3d 9f 00 00 00 lea 0x9f(%rip),%rdi # 6e4 <_IO_stdin_used+0x4>
645: e8 c6 fe ff ff callq 510 <puts@plt>
return 0;
64a: b8 00 00 00 00 mov $0x0,%eax
}
64f: 5d pop %rbp
650: c3 retq
651: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
658: 00 00 00
65b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
0000000000000660 <__libc_csu_init>:
660: 41 57 push %r15
662: 41 56 push %r14
reference: