一个可执行文件的生成
我们先来复习一下一个可执行的程序是如何从源代码转变为一个可以运行的二进制文件的。
在这一部分,我们以简单的hello.c
程序来说明:
1 |
|
预处理
首先是预处理部分。
预处理命令:
gcc -E hello.c -o hello.i
cpp hello.c > hello.i
经过预处理的程序大致如下:
1 | # 1 "hello.c" |
可见经过预编译处理后,得到的文件还是一个可读的文本文件 ,但不包含任何宏定义
编译
编译过程就是将预处理后得到的预处理文件(如 hello.i)进行词法分析、语法分析、语义分析、优化后,生成汇编代码文件
编译命令:
gcc -S hello.i -o hello.s
gcc -S hello.c -o hello.s
经过编译的程序如下:
1 | .file "hello.c" |
同样的,经过编译后,得到的汇编代码文件还是可读的文本文件,CPU仍然无法理解和执行它
汇编
汇编过程就是将编译后得到的汇编代码文件
转换为机器指令序列,生成可重定位目标文件。
汇编指令和机器指令一一对应,前者是后者的符号表示,它们都属于机器级指令
,所构成的程序称为机器级代码
。
汇编命令:
gcc -c hello.s -o hello.o
gcc -c hello.c -o hello.o
汇编结果是一个可重定位目标文件,其中包含的是不可读的二进制代码,我们用工具查看它的大致内容:objdump hello.o -D
1 |
|
目标文件
目标文件有三种形式:
- 可重定位目标文件 Relocatable object file (
.o
file)- 由对应的 .c 文件通过编译器和汇编器生成的二进制代码,包含代码和数据,可以与其他可重定位目标文件合并创建一个可执行或共享的目标文件
- 可执行目标文件 Executable object file (
a.out
file)- 由链接器生成,可以直接通过加载器加载到内存中充当进程执行的二进制文件,包含代码和数据
- 共享目标文件 Shared object file (
.so
file)- 一种类型特殊的可重定位目标文件,可以在加载或者运行时被动态的加载进内存并连接的二进制文件。Windows下被称为 Dynamic Link Libraries(DLLs)。
格式 [1]
上面提到的三种对象文件有统一的格式,即 Executable and Linkable Format(ELF),因为,我们把它们统称为 ELF binaries,具体的文件格式如下
下面分别介绍一下各个部分:
- ELF header
- 包含 word size, byte ordering, file type (.o, exec, .so), machine type, etc
- Segment header table
- 包含 page size, virtual addresses memory segments(sections), segment sizes
- .text section
- 代码部分
- .rodata section
- 只读数据部分,例如跳转表
- .data section
- 初始化的全局变量
- .bss section
- 未初始化的全局变量
- .symtab section
- 包含 symbol table, procudure 和 static variable names 以及 section names 和 location
- .rel.txt section
- .text section 的重定位信息
- .rel.data section
- .data section 的重定位信息
- .debug section
- 包含 symbolic debugging (
gcc -g
) 的信息
- 包含 symbolic debugging (
- Section header table
- 每个 section 的大小和偏移量
符号解析
下面我们换用一个程序来进行说明:
1 | /* main.c */ |
在进行链接的时候,我们首先得做的一件事情就是符号的解析:
符号指的是我们在代码中声明的变量、函数,所有的符号声明都会被保存在符号表(symbol table)中,而符号表会保存在由汇编器生成的object
文件中(也就是.o
文件)。
每一个可重定位目标模块都有一个符号表,符号表实际上是一个结构体数组,每一个元素包含名称、大小和符号的位置。
在连接器的上下文中,有三种不同的符号:
- 全局符号 Global symbols
- 由模块内定义,并能够被其他模块引用的符号
- 非静态的
C函数
与全局变量
是全局符号 - 对于
main.o
来说,array
是全局符号
- 外部符号 External symbols
- 由其他模块内定义,并被本模块引用的符号
- 在其他源代码之中定义的非静态的
C函数
与全局变量
是外部符号 - 对于
sum.o
来说,array
是外部符号
- 本地符号 Local symbols
- 在当前模块中定义,只能被当前模块引用的是本地符号
- 带有属性的
C函数
与全局变量
是本地符号 - 对于
sum.o
来说,sta
是外部符号
对于连接器来说,局部的非静态变量不在它的管辖范畴之内,因为局部的非静态变量不是符号,它一般都是保存在栈里面。而静态的变量,如果已经初始化,则会保存在.data
节之中,否则保存在.bss
节之中。
如果遇到在同一个模块的不同函数里面出现了相同名字的静态变量,则会给同名的本地符号加上一个唯一的编号,用于标识这一个变量。
但是如果遇到了同名的全局符号或是外部符号则怎么做呢?
首先我们得知道,不同的符号是有强弱之分的:
- 函数和已初始化的全局变量是强符号
main.o
里面的array
是强符号,因为它已经初始化
- 而未初始化的全局变量是弱符号
sum.o
里面的array
是弱符号,因为它未初始化
所以,根据强弱符号的定义,Linux的连接器在处理多重定义的符号时,采用以下三条规则:
- 规则1:不能出现多个同名的强符号,不然就会出现链接错误
- 规则2:如果有同名的强符号和弱符号,选择强符号
- 规则3:如果有多个弱符号,随便选择一个
所以在进行main.o
与sum.o
的链接时,连接器选择的是main.o
中的符号array
。
我们来看一个额外的例子:
1 | // 文件 h1.c |
1 | // 文件 h2.c |
运行的结果是:
1 | linux> ./h |
在h1
中x
定义为int
型数据占用4个字节,而在h2
中x
定义为double
型数据,在x86-64/Linux
下,占用8个字节,则h2
中的x = -1;
不仅修改了x
的内容,也修改了y
的内容,这一种错误,在编译的时候只会触发一条警告。当程序到达了一定的规模后,这种类型的错误相当难以修正。
所以说,如果可能,尽量避免使用全局变量。
如果一定要用的话,注意下面几点:
- 使用静态变量
- 定义全局变量的时候初始化
- 注意使用
extern
关键字
重定位
在符号解析完成后,进行重定位工作,主要分为三步:
- 合并相同的节
- 将所有目标模块中的代码段与数据段分别合并为一个单独的代码段和数据段
- 例如,所有
.text
节合并作为可执行文件中的.text
节
- 对定义符号进行重定位(确定地址)
- 确定新节中所有定义符号在虚拟地址空间中的绝对地址
- 例如,为函数确定首地址,进而确定每条指令的地址;为变量确定首地址
- 对引用符号进行重定位(确定地址)
- 将可执行文件中符号引用处的地址修改为重定位后的地址信息
- 修改
.text
节和.data
节中对每个符号的引用(地址)
在完成这三步工作后,链接也就基本完成了。