关于Linux的编译过程,其实只需要使用gcc这个功能,gcc并非一个编译器,是一个驱动程序。其编译过程也很熟悉:预处理–编译–汇编–链接。在接触底层开发甚至操作系统开发时,我们都需要了解这么一个知识点,如何从我们的代码到机器码。这段过程经历了什么,我们的函数变量又是在哪里?一个个好奇心驱使着我写下这篇文章。于博客中有提及Linux的安装以及gcc基本环境搭建、gcc编译流程、常用gcc指令集:https://blog.csdn.net/Alkaid2000/article/details/128036290?spm=1001.2014.3001.5501
文章目录
- 0x01 编译过程
- 0x02 预编译
- 0x03 编译
- 0x04 汇编
- 目标文件ELF
- 翻译机器指令
- 操作数地址通过ModR/M中的Mod+R/M指定
- 操作数通过ModR/M中的Reg/Opcode指定
- 操作数地址直接嵌入在机器指令中
- 操作数直接嵌入在指令中
- 操作数隐含在Opcode中
- 回到代码
- 重定位表
- 符号表
- 0x05 链接
- 合并目标文件
- 符号重定位
- 链接静态库
- 链接动态库
0x01 编译过程
可以使用这么一句指令来观察一个.c文件所需要经历的编译过程:gcc -v main.c
。
根据gcc的输出可见,对于一个C程序来说,从源代码构建出可执行文件经历了三个阶段:
- 编译
gcc使用编译器ccl.exe
进行编译,产生的编译代码保存在目录/temp
下的文件ccelFAGc.s
中。
- 汇编
gcc使用汇编器as.exe
进行汇编,汇编过程产生汇编文件ccZfpupi.o
,将上面生成的ccelFAGc.s
进行汇编。
- 链接
调用collect2.exe
进行链接。实际上这个collect2
只是一个辅助程序,最终他将调用链接器ld
来完成真正的链接过程。包括框出来的crtend.o
、以及启动文件等等,本质上都是ld
在进行链接。
事实上,从gcc看到只有这三个过程,但是对于C程序来说,编译过程也分为两个阶段:预编译和编译。所以软件构建过程通常分为四个阶段:预编译、编译、汇编、链接。
可以通过gcc手动控制以上的编译流程,从而留下中间文件以方便研究:
gcc HelloWorld.c -E -o HelloWorld.i
预处理:加入头文件,替换宏。gcc HelloWorld.c -S -c -o HelloWorld.s
编译:包含预处理,将 C 程序转换成汇编程序。gcc HelloWorld.c -c -o HelloWorld.o
汇编:包含预处理和编译,将汇编程序转换成可链接的二进制程序。gcc HelloWorld.c -o HelloWorld
链接:包含以上所有操作,将可链接的二进制程序和其它别的库链接在一起,形成可执行的程序文件。
那么接下来使用下面这段程序对于编译过程来做个总结:
hello.c:
#include <stdio.h>
#include "foo.h"
extern int foo2;
int main(int argc,char *argv[])
{
int result;
int r = 5;
#ifdef AREA
result = PI*r*r;
#else
result = PI*r*2;
#endif
return 0;
}
foo.h
#ifndef _FOO_H
#define _FOO_H
#define PI 3.1415926
#define AREA
struct foo_struct{
int a;
};
#endif
fool2.c
int foo2 = 20;
void foo2_func(int x)
{
int ret = foo2;
}
fool1.c
int fool = 10;
void fool_func()
{
int ret = fool;
}
0x02 预编译
C语言中的预编译是以#
开头,常用的预编译指令包括#include
、#define
、#if
等等。在工具链中,一般都提供单独的编译器,比如GCC中提供的编译器为cpp。但是预编译也可以看作编译过程的第一遍,是为编译做的一些工作,所以通常编译器中也包含了预编译的功能。比如前面的gcc并没有单独调用cpp,而是直接调用ccl
进行编译,原因就是如上。
gcc -E hello.c -o hello.i
编译之后可以查看文件hello.i
:
# 7 "foo.h"
struct foo_struct{
int a;
};
# 3 "hello.c" 2
extern int foo2;
int main(int argc,char *argv[])
{
int result;
int r = 5;
result = 3.1415926*r*r;
return 0;
}
根据编译后的结果可以总结出预编译指令的处理步骤:
- 文件包含:指示预编译器将一个源文件的内容全部复制到当前源文件中。
- 宏定义:预编译器将宏名替换为具体的值。
- 条件编译:保留用户希望编译的代码。(使用#if #else这种形式)
0x03 编译
编译程序对预处理过的结果进行词法分析、语法分析、语义分析,然后生成中间代码,对中间代码进行优化,目标是使最终生成的可执行代码时间更短、占用的空间更小,最后生成相应的汇编代码。
gcc -S fool2.c
其内容如下:
int foo2 = 20;
void foo2_func(int x)
{
int ret = foo2;
}
.file "fool2.c"
.text
.globl foo2
.data
.align 4
.type foo2, @object
.size foo2, 4
foo2:
.long 20
.text
.globl foo2_func
.type foo2_func, @function
foo2_func:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -20(%rbp)
movl foo2(%rip), %eax
movl %eax, -4(%rbp)
nop
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size foo2_func, .-foo2_func
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
在此源文件中,定义了一个全局变量以及函数,区区一行代码却出现了这么多汇编语言。其实里面相当多的代码是伪指令。伪指令不参与CPU运行,只指导编译链接过程。
就像上面所生成的cfi
指令,这个指令主要的作用是辅助汇编器创建栈帧信息的。
这些伪指令也有其他的作用,比如说中断后会输出回溯信息,比如在debug的时候,需要查找一些变量或者是查看函数调用信息。这个过程称为栈的回卷。
在上面的程序中注意到了有个寄存器rbp
保存了frame pointer
、base pointer
均指向了栈的底部。对于main函数来说,他并非程序中第一个运行的程序,所以main其实也是一个被调函数,他也有自己的栈帧。在理论上可以使用这些指针来遍历调用过程中各个函数的栈帧,但是由于gcc代码的优化,可能导致调试器或异常处理很难甚至不能正常回溯栈帧,所以这些伪指令的目的就是辅助编译器创建栈帧信息,并且保存在目标文件的段.eh_frame
中,这样就不会被编译器优化所影响。
去除伪指令后,可以看到代码如下:
foo2_func:
pushq %rbp
movq %rsp, %rbp
movl %edi, -20(%rbp)
movl foo2(%rip), %eax
movl %eax, -4(%rbp)
nop
popq %rbp
ret
在汇编代码中,在函数的开头和结尾处分别会插入一小段代码,分别称为Prologue
和Epilogue
,比如上面1~3句是Prologue
,最后两句是Epilogue
。
-
Prologue
:保存主调函数的frame pointer
,这是为了在子函数调用结束后,恢复主调函数的栈帧。同时为子函数准备栈帧。pushq %rbp movq %rsp, %rbp movl %edi, -20(%rbp)
上面这三句话起了一种构造函数的作用,首先需要保存主调函数的
frame pointer
,之后保存在寄存器中金压栈,在退出主函数时可以从栈中恢复主调函数frame pointer
;将rsp
赋值给rbp
,即将子函数的frame pointer
指向主调函数的栈顶,这行代码记录了子函数栈帧的底部,从这里就开始了主函数的栈帧。下面那句是为本地变量分配栈空间。 -
Epilogue
功能是恰恰相反的,如果说Prologue
是构造函数,那么这个部分则是析构函数。popq %rbp ret
当前栈帧的栈底,是
Prologue
保存的主调函数的frame pointer
,将其pop
出回到了主调函数的main
栈帧,之后,CPU就返回主调函数继续执行。
中间程序的执行部分,也就是int ret = foo2
这段,从第四行开始,CPU从数据段中读取了全局变量foo2的值将其放在寄存器eax
中,之后在第五行代码,将eax
的内容,赋值到栈中局部变量ret
的位置。之后代码根据局部变量相对于栈的frame pointer
的偏移来访问局部变量,如变量ret
位于相对于栈底偏移为-4的内存处。
0x04 汇编
汇编器将汇编代码翻译为机器指令,每一条汇编语句几乎都对应一条机器指令,所以汇编器的汇编过程相对于比较简单,只需要根据汇编指令和机器指令的对照表进行翻译即可。除了生成机器码外,汇编器还要再目标文件中创建辅助链接时需要的信息,包括符号表、重定位表等。
目标文件ELF
目标文件是汇编过程的产物。对于32位的ELF文件来说,其最前部是文件头部信息,描述了整个文件的基本属性,除了包括该文件运行在什么操作系统中、运行在什么硬件体系结构上、程序入口地址是什么等基本信息外,最重要的是记录了两个表格的相关信息,如表格所在的位置、其中包括了条目数等。这两个表格为:
Section Header Table
:主要是供编译时链接使用的,表格中定义了各个段的位置、长度、属性等信息。Program Header Table
:主要是供内核和动态加载器从磁盘加载ELF文件到内存时使用的。
对于目标文件,由于其只是编译过程中的一个中间产物,不涉及装载运行,因此在目标文件中不会创建Program Header Table
。
如何列出目标文件:
gcc -c hello.c fool.c fool2.c
readelf -h fool2.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: 672 (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: 13
Section header string table index: 12
- 可以看到ELF占了64字节,通过ELF头可见该文件是64位的ELF文件。
- 使用
little endian
字节序存储字节。 - ABI遵循UNIX - System V标准,运行在类UNIX系统上。
- 该文件为REL类型文件:通常可执行文件的类型是
EXEC
;静态库和目标文件的类型是REL
;动态共享库的类型是DYN
。 Entry point address
为程序入口,由于是目标文件,则不存在执行的概念。Start of section headers
:在偏移264字节处。Size of section headers
:每个Section Header
占用了40字节,Section Header Table
一共包含了12个Section Header
。
看完头信息后,就可以看到各个段的信息。ELF即各个段的组合。大体上,段可以分为如下几种类型:一类是存储指令的,通常称为代码段;第二类是存储数据的,通常称为数据段。数据段又细分为两个段:
.bss
:未初始化的全局数据。.data
:已初始化的全局数据。
这两个段本质并没有什么不同,但是因为未初始化的变量不包含是数据,所以在ELF文件中并不需要占用空间,在程序装载时进行分配即可。
使用命令:
readelf -S fool2.o
可以看到fool2.o
中Section Header Table
中包含的12个Section Header
:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000017 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000200
0000000000000018 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 00000058
0000000000000004 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 0000005c
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .comment PROGBITS 0000000000000000 0000005c
000000000000002c 0000000000000001 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 0000000000000000 00000088
0000000000000000 0000000000000000 0 0 1
[ 7] .note.gnu.propert NOTE 0000000000000000 00000088
0000000000000020 0000000000000000 A 0 0 8
[ 8] .eh_frame PROGBITS 0000000000000000 000000a8
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000218
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 000000e0
0000000000000108 0000000000000018 11 9 8
[11] .strtab STRTAB 0000000000000000 000001e8
0000000000000018 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 00000230
000000000000006c 0000000000000000 0 0 1
-
.text
段存储在文件中偏移0x40处,占据了0x17个字节。.text
段并不全是代码段,在链接时,.init
、.fini
等存储的代码都属于代码段,这些都被映射到了Program Header Table
中的一个段,在ELF加载时,统一作为进程的代码段。 -
.data
段存储在文件中偏移0x58字节处,占据了0x04个字节的空间。 -
.bss
段虽然包含着,但是他不必要记录数据,所以并没有对应的段。在加载程序时,加载器将依据.bss
段的Section Header
中的信息,在内存中为其分配空间。所以占用着0x00字节空间。 -
.symtab
段记录的是符号表。因为符号的名字字串长度可变,所以目标文件将符号的名字字符串剥离出来,记录在另一个段.strtab
中,符号表使用符号名字的索引在段.strtab
中的偏移来确定符号名字。 -
.strtab
段则是用于记录段的名字 -
rel
开头的文件,如rel.text
、rel.eh_frame
,记录的是段中需要重定位的符号。 -
.eh_frame
段中记录的是调试和异常处理时用到的信息。 -
.comment
、.note.GNU-stack
等都是在链接或者时装载都不会用到的数据,不需要关心。
那么综上,ELF文件所有的内容可以用如下的表所示:
翻译机器指令
机器指令由操作码和操作数组成,操作码指明该指令要完成的操作,即指令的功能;操作数是参与操作的参数,主要以寄存器或存储器地址的形式指明数据的来源或者计算存放的位置等。
汇编过程就是将操作码翻译为对应的0和1的机器指令,这也是操作码和操作数的编码过程。这个过程也比较简单,对应关系可以查看对应的CPU指令手册。但是对于操作数翻译为机器码复杂一些,操作数并没有直接嵌入在指令编码中,而是根据汇编指令使用的具体寻址方式,设置ModR/M
、SIB
、Displacement
和Immediate
各项的值,这个过程称为操作数的解码,CPU根据ModR/M
、SIB
、Displacement
和Immediate
各项的值解码出操作数。
IA32机器指令的格式:
下面是操作数的编码方式:
操作数地址通过ModR/M中的Mod+R/M指定
ModR/M
占用1字节,包含三个域:Mod
、Reg/Opcode
和R/M
,其中Mod
占2位,R/M
占3位,Reg/Opcode
占3位。操作数可以使用ModR/M
中的Mod
和R/M
字段联合起来定义。
其中第二列表示寻址方式生成的有效地址;第三列和第四列表示对应于某个寻址方式,Mod
和R/M
分别表示对应的编码。
在上面的表中,包含了直接寻址、寄存器寻址、寄存器间接寻址、基址寻址以及基址变址寻址等寻址方式下Mod
和R/M
对应的编码。如果汇编指令使用的是基址变址寻址,那么机器指令中也需要字段SIB
。
以第七行的指令为例,假设汇编指令使用的寻址方式是[EAX]+disp8
,那么Mod
应该取值01,R/M
应该取值位000。偏移disp8表示八位的Displacement
,根据机器指令的格式,Displacement
直接嵌入在指令中即可。Displacement
取值可以为8位、16位、32位,选择取决于尺寸方面,Displacement
需要使用补码的形式。当CPU执行指令时,当解析到ModR/M
这个字节时,一旦发现Mod
的值是01,R/M
的值是000,那么CPU就到寄存器EAX
中取到其中的内容,然后再取出嵌入在指令中的8位偏移Displacement
,将这两个值相加作为操作数的内存地址,从而完成操作数的解码过程。
操作数通过ModR/M中的Reg/Opcode指定
ModR/M
中的字段Reg/Opcode
占据3位,如果在汇编指令中使用了寄存器作为操作数,那么编码时也可以使用Reg/Opcode
指定操作数使用的寄存器。如果操作数不需要使用字段Reg/Opcode
编码,字段Reg/Opcode
也可以作为操作码的编码,下面是32位寄存器与字段Reg/Opcode
取值的对应关系:
操作数地址直接嵌入在机器指令中
这就是所谓的直接寻址方式,那么在翻译为机器指令时,直接使用机器指令中的Displacement
字段表示操作数的地址。
操作数直接嵌入在指令中
如果在汇编指令中,操作数就是参与计算的数据,即所谓的立即寻址,那么在翻译为机器指令时,直接使用机器指令中的Immediate
表示操作数。
操作数隐含在Opcode中
这就是所谓的隐含寻址。其实就是通过一些其他子指令来区分功能相同,但是操作数类型不同的作用:
mov r/m16,r16
mov r/m32,r32
Intel并没有为上述两个分类操作分别定义两个操作码,而是使用了同一个操作码。但是使用了Instruction Prefixes
来区分指令中的操作数是16位的还是32为的,比如在32位环境下使用了16位的操作数,那么需要在指令前使用0x66进行标识。
回到代码
那么回到代码,fool2中:
movl foo2(%rip), %eax
movl %eax, -4(%ebp)
这两条使用的都是mov指令,IA32架构的mov指令可以简单了解如下:
需要关注Opcode
以及Op/En
(操作数的编码方式)。
对于MOV指令,不仅仅只有一个操作码,对于同一类操作,可能使用不同的操作数,操作数可能是寄存器,也可能是内存地址,同时操作数还会有长度之分,比如8位、16位、32位。Intel采取的策略是为同一指令设计了多个操作码来细分这些指令。
对于Op/En
操作数的编码方式,具有六种:
需要注意的是,编译器生成的汇编代码使用的是AT&T
的格式,其操作数的顺序与Intel的汇编指令正好相反,所以指令movl foo2(%rip), %eax
中,foo2是Intel语法中的第二个操作数,%eax是第一个操作数。那么可以查表,第七行,mov eax,moffs32
,根据该指令说明,操作码0xa1隐含地指出了指令中第一个操作数是寄存器EAX,也就是寻址方式中所谓地操作数隐含寻址。
该指令地操作数编码方式是C,C类编码方式不需要ModR/M,也不需要SIB,而且也没有使用立即数作为操作数,也不需要指令前缀进行修饰,所以第一个操作数寄存器EAX是通过操作码隐含指明,所以该条汇编代码最后转换为如下形式地机器指令:Opcode+Displacement
。
第二个操作数是通过Displayment
来进行表示地,由于还没有进行链接,所以foo2
的地址尚未确定,所以暂时填充0占位,在链接时根据实际地址修改。因为是运行在32位的环境下,所以地址是32位的,Displayment
占用了4字节,综上所述,该指令的机器码可以翻译为:
movl foo2(%rip), %eax
|
|
opcode + displayment
|
|
a1 00 00 00 00
下一条指令是movl %eax, -4(%ebp)
,这条指令也是有两个操作数,第一个操作数-4(%ebp)相当于是[EBP]+dis8
,用8位是因为表示-4使用1个字节就够了。根据A类编码的要求,第一个操作数需要使用的寄存器需要由ModR/M中的Mod和R/M共同指明,根据寻址模式可匹配表的第十行,mod为01,r/m为101.且第一个操作数中的偏移-4由displayment来表示,在机器指令中需要使用数的补码来表示,-4补码为fc。
根据A类编码的方式要求,第二个操作数由ModR/M
中的Reg/Opcode
指明。汇编指令第二个操作数使用的寄存器位EAX,对照表位000,那么第二条指令:
movl %eax, -4(%ebp)
|
|
Opcode + ModR/M + displayment
|
|
0x89 01 000 101 fc
|
|
89 45 fc
可以使用指令objdump -d fool2.o
来分析机器码翻译过程:
可以使用工具hexdump -e ' "%4_ax:" 16/1 " %02x" "\n"' fool2.o
原汁原味的进行分析,%4_ax
表示使用4位十六进制进行偏移;16/1
表示每行显示16字节,逐字解析,%02x
表示以十六进制显示,每个字符占据两位。
可以看到截取到的.text
段以及.data
段:
可以注意到起始于偏移0x40处,于我们ELF文件中看到的描述相同!!占据的字节数也是相同的,指令也是相同的!!
对于数据,0x58开始的数据段,正好是0x14对应的十进制数20,信息也可以对的上。
重定位表
在进行汇编时,在一个模块内,如果引用了其他模块或者时库中的变量或者函数,汇编器并不会解析引用的外部符号。汇编器基本上是留空引用的外部符号的地址;然后在链接时,在符号地址确定后,链接器再来修订这些位置,这个修订的过程,被称之为重定位(编译时、加载/运行时,这里说的是前者)。
这些需要修订的位置并不是全都置为0,有时候这里填充的是一个Addend
,这就是之所以使用引号将空引用起来的原因。
但是链接器并不能自动找到目标文件中引用外部符号的地方,所以在目标文件中需要建立一个表格,这个表格中的每一条记录对应的就是一共需要重定位的符号,这个表格通常称为重定位表,汇编器将为可重定位文件中每个包含需要重定位符号的段都建立一个重定位表。
ELF标准规定,重定位表中的表项可以使用如下两种格式:
唯一不同的成员即r_addend
,这个成员一般是个常量,用来辅助计算修订值;若使用了第一种格式,那么r_addend
将被填充在引用外部符号的地址处,也就是留空处。
r_offset
为需要重定位的符号在目标文件中的偏移;对于目标文件,r_offset
是相对于段的,是段内偏移;对于执行文件或者动态库,r_offset
是虚拟地址。r_info
中包含重定位类型和此处引用的外部符号在符号表中的索引。根据符号在符号表中的索引,链接器就可以从符号表中解析出符号的地址。
可以使用命令readelf -r hello.o
查看文件的重定位表。
可以看到段.text
以及.eh_frame
段中都有符号需要重定位,所以建立了两重定位表。在.text
段的重定位表中,引用了两个外部符号,并且可以在第一列得到他们的偏移为0x15以及0x23。
根据objdump的输出可见,在偏移0x15处,则是变量foo2的地址,汇编器填充的addend是0;在偏移0x23处,foo2_func填充addend的也是0。
符号表
在链接时需要重定位目标文件中引用的外部符号,显然链接器也需要指定这些符号的定义是在哪里,所以汇编器在每个目标文件中创建了一个符号表,符号表中记录了这个模块定义的可以提供给其他模块引用的全局符号。
查看符号表readelf -s fool2.o
:
根据输出可见,fool2.o
符号表包含了10个符号。
- value列表示的时符号的地址,由于链接时链接器才会分配地址,所以现在看到的符号地址全都是0。
- Size列代表的时申请内存的大小,可以看到变量
foo2
占据了4个字节,foo2_func
占据了23个字节。 - Type列表示符号的类型。如
foo2
类型为OBJECT
表示的是变量;FUNC
表示的是函数。 - Bind列表示符号绑定的相关信息,
LOCAL
表示模块内部符号,对外不可见;GLOBAL
表示全局符号,属于全局变量。 - Ndx列表示该符号在哪个段,3为
.data
段,1为.text
段。
那么对于引用外部符号的符号表,可以看看hello.o:
由于符号foo2以及foo2_func都在模块foo2中定义,对于模块hello来说是外部符号,没有在任何一个段中,所以在列Ndx中,他们的值都是UND
。UND
是Undefined
的缩写,表示其是未定义的。
在链接时,对于模块中引用的外部符号,链接器将根据符号表进行符号的重定位。如果将符号表删除,那么链接器在链接时将找不到符号的定义,从而不能进行正确的符号解析。可以看到下面的操作:
0x05 链接
链接时编译过程的最后一个阶段,链接将一个或者多个目标文件和库,包括动态库和静态库,链接为一个单独的文件(通常为可执行文件、动态库或者静态库)。
链接器的工作可以分为两个阶段:
- 第一阶段是将多个文件合并为一个单独的文件。对于可执行文件,还需要为指令以及符号分配运行时的地址。
- 第二阶段进行符号重定位。
合并目标文件
合并多个目标文件其实就是将多个目标文件的相同类型的段合并到一个段中:
可以试着查看所有文件的目标文件以及链接后可执行文件的.text
段:
hello.o:
fool1.o:
foo2.o:
hello:
根据上面输出的结果可见,对于目标文件,并没有为目标文件的机器指令及符号分配运行时的地址,而对于可执行文件hello,链接器已经为其机器指令及符号分配了运行时地址,并且申请了对应的内存空间。
理论上,三个目标文件的.text
段加起来应该与可执行文件的hello的.text
段的尺寸大小是相等的。三个可执行文件加起来的大小是0x70,但是远小于可执行文件0x1a5。
可以注意到在编译时会向gcc传递了参数-v,细心可以发现,实际上链接时链接器自作主张地链接了一些特别的文件,包括crtl.o\crti.0\crtn.o\crtbegin.o\ctrend.o
,其实就是我们前面提到的启动文件。所以会增加了.text
段的大小。
也可以手动调用ld
,不链接这些启动文件,再来对比一下.text
段的尺寸。在默认情况下,链接器将使用函数_start
作为可执行文件的入口,但是这个函数的实现在启动文件ctrl.o
中,因此,在这里我们通过给链接器ld传递参数-e main
,明确告诉链接器不适用默认的启动函数_start
了,否则链接器会找不到符号_start
,直接使用函数main
作为可执行文件的入口。当然main
函数中并没有实现启动代码的功能,在这里这是为了方便查看.text
段,尺寸是所有目标文件size的总和。如果不是等于总和,差别有几个字节的话,是由内存对齐所引起的。
符号重定位
上面为链接的第一阶段,目标文件已经合并完成了,并且已经为符号分配了运行时的地址,链接器将符号进行重定位。
可以看到汇编器已经将这两处需要重定位的符号记录在了重定位表中。
R_386_32
,ELF标准规定的计算修订值得公式是:S+A
;其中,S表示符号的运行地址,A就是汇编器填充在引用外部符号处的Addend
。R_386_PC32
,ELF标准规定的计算修订值的公式是:S+A-P
;其中,S,A与前面的意义完全相同,P为修订处的运行地址或者偏移。对于可执行文件和动态库,P为修订处的运行时地址。
首先确定S,运行时地址在链接时才分配:
可以看到foo2
、foo2_func
的运行时的地址。
之后再捋捋汇编器为这两个符号填充的Addend
是多少,可以使用objdump
反汇编hello.o
,也可以看到上面图中的-8以及-4。
需要注意的是,对于函数占据的运行时地址小于main函数,那么这里的函数地址与PC相对地址将是负数,其实就是将PC跳回去执行。在机器指令中,使用的是数的补码形式。
对于R_386_32这种重定位类型,是绝对地址重定位,链接器只要解析符号运行时地址替换修订处即可。而对于R_386_PC32,这是一个PC相对地址重定位,当 执行当前指令时,PC中已经加载了下一条指令的地址,并不是当前指令的地址。
在链接时,链接器在需要重定位的符号所在的偏移处直接进行了编辑修订,所以链接器也被形象地称为link editor
。
链接静态库
静态库其实就是多个目标文件的打包,因此与合并多个目标文件并没有什么区别。但是在链接静态库时,并不是将整个静态库中包含的目标文件全部复制一份到最终的可执行文件中,而是仅仅链接库中使用的目标文件。
可以将两个源文件编译为静态库libfoo.a
,然后将其链接到hello
:
可以看到静态库的符号表:
可以看到就是两个目标文件的合体,但是在hello中可不是什么都有:
链接动态库
与静态库不同,动态库不会在可执行文件中有任何副本,那么为什么编译链接依然需要指定动态库?
- 动态加载器需要知道可执行程序依赖的动态库,这样在加载可执行程序时才能加载其依赖的动态库。
在链接时会根据可执行程序引用的动态库中的符号的情况,在dynamic
段中记录可执行程序依赖的动态库。
gcc -c -fPIC fool1.c fool2.c #产生与地址无关的目标文件
gcc -shared -o libfoo.so fool1.o fool2.o
gcc hello.c -o hello -L./ -lfoo
readelf -d hello | grep Shared
- 链接器需要在重定位表中创建重定位记录,这样当动态链接器加载hello时,将依据重定位记录重定位hello引用的这些外部符号。
重定位记录存储在ELF文件的重定位段中,ELF文件中可能有多个段包含需要重定位的符号,所以可能会包含多个重定位段。
rel.dyn
段中记录的是加载时需要重定位的变量。
rel.plt
段中记录的是需要重定位的函数。
虽然编译时不需要链接共享库,但是可执行文件中需要记录其依赖的共享库以及加载/运行时需要重定位的条目,在加载程序时,动态加载器需要这些信息来完成加载时的重定位。