编译和链接
将编译和链接合并到一起的过程称为构建(Build)。
从源文件生成最终可执行目标文件共有4个步骤:
- 预处理(Prepressing)
- 编译(Compilation)
- 汇编(Assembly)
- 链接(Linking)
预处理
命令行指令:
gcc -E hello.c -o hello.i
预处理实际上使用的是cpp
程序:
cpp hello.c > hello.i
预编译过程主要处理那些源代码文件中的以#
开始的预编译指令。处理规则如下:
- 将所有的
#define
删除,并且展开所有的宏定义。 - 处理所有条件预编译指令,比如
#if
、#ifdef
、#elif
、#else
、#endif
。 - 处理
#include
预编译指令,将被包含的文件插入到该预编译指令的位置。这个过程是递归进行的。 - 删除所有的注释
//
和/* */
。 - 添加行号和文件名标识,比如
#2 hello.c 2
,便于编译器产生调试信息。 - 保留所有的
#pragma
编译器指令,因为编译器需要使用它们。
编译
命令行指令:
gcc -S hello.c -o hello.s
# 或者基于预处理得到的.i文件
gcc -S hello.i -o hello.s
编译实际上使用的是cc1
程序:
/usr/lib/gcc/x86_64-linux-gnu/9/cc1 hello.i
汇编
命令行指令:
gcc -c hello.s -o hello.o
汇编实际上使用的是as
程序:
as hello.s -o hello.o
链接
链接使用的是ld
程序(命令过于复杂,不再列出)。
实际上gcc这个命令只是对cpp、cc1、as、ld程序的封装。
编译器
不予记录,感兴趣可以选修《编译原理》课程。
链接器
当程序修改时,一些指令的地址会发生改变。重新计算各个目标的地址的过程叫做重定位。
链接的主要工作就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。
链接过程主要包括了地址和空间分配、符号决议和重定位等这些步骤。
符号决议有时也被称为符号绑定。
决议的意思偏向于静态链接,而绑定的意思偏向于动态链接。
每个模块的源代码文件经过编译器编译成目标文件(.o文件),目标文件和库(Library)一起链接形成最终可执行文件。
最常见的库是运行时库(Runtime Library)。
库其实是一组目标文件的包。
目标文件里有什么
目标文件的格式
现在PC平台流行的可执行文件格式主要是:
- Windows下的PE(Portable Executable)。
- Linux的
ELF
(Executable Linkable Format)。
它们都是COFF(Common file format)格式的变种。
除此之外,动态链接库(Dynamic Linking Library)和静态链接库(Static Linking Library)文件也都按照可执行文件格式存储。
Windows下的动态链接库文件后缀为**.dll**,静态链接库文件后缀为**.lib**。
Linux下的静态链接库文件后缀为**.so**,静态链接库文件后缀为**.a**。
ELF文件的分类
ELF文件可以分为以下几类:
-
可重定位文件:包含代码和数据,可以用来链接成可执行文件或共享目标文件。静态链接库可以归为该类。Windows下的文件后缀为**.obj**,Linux下的文件后缀为**.o**。
-
可执行文件:包含了可以直接执行的程序。Linux下一般没有拓展名,Windows下后缀为**.exe**。
-
共享目标文件:包含了代码和数据。用途有以下两种:
- 链接器使用这种文件和其他的可重定位文件和共享目标文件链接,产生新的目标文件。
- 动态链接器可以将几个共享目标文件与可执行文件结合,作为进程映像的一部分来运行。
Linux下的文件后缀为**.so**,Windows下文件后缀为**.dll**。
-
核心转储文件:进程意外终止时,系统可将进程相关的一些信息转储到核心转储文件。Linux有一种core dump行为(WSL1似乎并未实现该功能)。
我们可以通过file <filename>
命令来查看相应的文件格式信息。
目标文件是什么样的
目标文件中包含了编译后的机器指令代码、数据,还包括了链接时所须的一些信息,比如符号表、调试信息、字符串等。
目标文件将这些信息按不同的属性以节(Section)的形式存储,有时候也叫做段(Segment):
- 程序源代码编译后的机器指令经常被放在代码段里,代码段一般叫
.text
。 - 全局变量和局部静态变量数据经常放在数据段,数据段一般叫
.data
。 - 未初始化的全局变量和局部静态变量(默认值均为0)一般放在一个叫
.bss
的段里。 .rodata
段用来存放只读数据,如const常量和字符串常量等。
.bss
段只是为未初始化的全局变量和局部静态变量预留位置,它并没有内容,在文件中也不占据空间。
有些编译器会将全局的未初始化变量存放在目标文件
.bss
段,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss
段分配空间。
总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss段属于程序数据。
数据和指令分段的好处有:
- 程序被装载后,数据和指令分别被映射到两个虚存区域。这样就可以分别设置这两个区域的读写权限。进而防止程序的指令被有意或无意的更改。
- 指令区和数据区的分离有利于提高程序的局部性。(现代CPU中的缓存也被设计为数据缓存和指令缓存分离)。
- 系统中运行多个该程序的副本时,可以共享一些只读区域,如指令部分,进而大量的节省空间。这也是最重要的原因。
挖掘.o文件
我们可以使用如下命令来查看一个.o
文件的分段情况;
objdump -h SimpleSection.o
使用
-x
选项可以查看更多的信息。
可以使用size <object-file>
命令来查看ELF文件的各个段的长度。
objdump的-s
参数可以将所有段的内容以十六进制的方式打印出来,-d
参数可以将所有包含指令的段反汇编。
除了前面提到的一些最常用的段之外,还有一些其他常见的段:
自定义段
gcc提供了一个拓展机制,使程序员可以定义变量所处的段:
__attribute__((section("FOO"))) int global = 42;
__attribute__((section("BAR"))) void foo() {
}
全局变量或函数之前加上__attribute__((section("name")))
属性就可以把相应的变量或函数放到以"name"作为段名的段中。
ELF文件结构描述
ELF的大致结构如图所示:

我们可以使用readelf命令来查看ELF文件的信息。
ELF文件头(ELF Header)
ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等信息。
我们可以使用如下命令来查看ELF文件头:
readelf -h SimpleSection.o
输出信息大致如下:
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 488 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 12
Section header string table index: 11
32位版本的文件头结构Elf32_Ehdr
如下:
typedef struct {
unsigned char e_ident[16];
Elf32_Half e_type; // ELF文件类型,ET_REL表示可重定位文件、ET_EXEC表示可执行文件,ET_DYN表示共享目标文件
Elf32_Half e_machine; // ELF文件的CPU平台属性
Elf32_Word e_version; // ELF版本号
Elf32_Addr e_entry; // ELF程序的入口地址(操作系统加载完程序后,从该地址开始执行指令)
Elf32_Off e_phoff;
Elf32_Off e_shoff; // 段表在文件中的偏移
Elf32_Word e_flags; // ELF标志位,用来标识ELF文件平台的属性
Elf32_Half e_ehsize; // ELF文件头本身的大小
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize; // 段表描述符的大小,一般等于sizeof(Elf32_Shdr)
Elf32_Half e_shnum; // 段表描述符数量,即ELF文件中段的数量。
Elf32_Half e_shstrndx; // 段表字符串表所在段在段表中的下标
} Elf32_Ehdr;
ELF魔数
最开始的4个字节是所有ELF文件都必须相同的标识码,分别为0x7F、0x45、0x4c、0x46,第一个字节对应ASCII字符里面的DEL控制符,后面3个字节刚好是ELF这3个字母的ASCII码。
第5个字节用于标识ELF的文件类,0x01表示是32位的,0x02表示是64位的;第6个字是字节序,规定该ELF文件是大端的还是小端的。第7个字节规定ELF文件的主版本号,一般是1,因为ELF标准自1.2版以后就再也没有更新了。后面的9个字节ELF标准没有定义,一般填0,有些平台会使用这9个字节作为扩展标志。
段表
段表是以Elf32_Shdr
结构体为元素的数组,数组元素的个数等于段的个数。
typedef struct {
Elf32_Word sh_name; // 段名
Elf32_Word sh_type; // 段的类型
Elf32_Word sh_flags; // 段的标志位
Elf32_Addr sh_addr; // 段虚拟地址,即该段被加载后在进程地址空间中的虚拟地址
Elf32_Off sh_offset; // 段偏移,该段在文件中的偏移
Elf32_Word sh_size; // 段的长度
Elf32_Word sh_link; // 段链接信息
Elf32_Word sh_info; // 段链接信息
Elf32_Word sh_addralign; // 段地址对其
Elf32_Word sh_entsize; // 项的长度。如符号表每一项的长度。
} Elf32_Shdr;
这些字段的可选值请参考程序员的自我修养P77-79。
可以使用以下命令查看目标文件的段表结构:
readelf -S SimpleSection.o
输