竹子建站教程,福州网站开发风格,手机设计软件拉图,深圳官方网站新闻关于Linux的编译过程#xff0c;其实只需要使用gcc这个功能#xff0c;gcc并非一个编译器#xff0c;是一个驱动程序。其编译过程也很熟悉#xff1a;预处理–编译–汇编–链接。在接触底层开发甚至操作系统开发时#xff0c;我们都需要了解这么一个知识点#xff0c;如何… 关于Linux的编译过程其实只需要使用gcc这个功能gcc并非一个编译器是一个驱动程序。其编译过程也很熟悉预处理–编译–汇编–链接。在接触底层开发甚至操作系统开发时我们都需要了解这么一个知识点如何从我们的代码到机器码。这段过程经历了什么我们的函数变量又是在哪里一个个好奇心驱使着我写下这篇文章。于博客中有提及Linux的安装以及gcc基本环境搭建、gcc编译流程、常用gcc指令集https://blog.csdn.net/Alkaid2000/article/details/128036290?spm1001.2014.3001.5501 文章目录 0x01 编译过程0x02 预编译0x03 编译0x04 汇编目标文件ELF翻译机器指令操作数地址通过ModR/M中的ModR/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.hextern int foo2;int main(int argc,char *argv[])
{int result;int r 5;
#ifdef AREAresult PI*r*r;
#elseresult PI*r*2;
#endifreturn 0;
}foo.h
#ifndef _FOO_H
#define _FOO_H#define PI 3.1415926
#define AREAstruct foo_struct{int a;
};#endiffool2.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 2extern 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_startprocendbr64pushq %rbp.cfi_def_cfa_offset 16.cfi_offset 6, -16movq %rsp, %rbp.cfi_def_cfa_register 6movl %edi, -20(%rbp)movl foo2(%rip), %eaxmovl %eax, -4(%rbp)noppopq %rbp.cfi_def_cfa 7, 8ret.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 %rbpmovq %rsp, %rbpmovl %edi, -20(%rbp)movl foo2(%rip), %eaxmovl %eax, -4(%rbp)noppopq %rbpret在汇编代码中在函数的开头和结尾处分别会插入一小段代码分别称为Prologue和Epilogue比如上面1~3句是Prologue最后两句是Epilogue。 Prologue保存主调函数的frame pointer这是为了在子函数调用结束后恢复主调函数的栈帧。同时为子函数准备栈帧。 pushq %rbpmovq %rsp, %rbpmovl %edi, -20(%rbp)上面这三句话起了一种构造函数的作用首先需要保存主调函数的frame pointer之后保存在寄存器中金压栈在退出主函数时可以从栈中恢复主调函数frame pointer将rsp赋值给rbp即将子函数的frame pointer指向主调函数的栈顶这行代码记录了子函数栈帧的底部从这里就开始了主函数的栈帧。下面那句是为本地变量分配栈空间。 Epilogue功能是恰恰相反的如果说Prologue是构造函数那么这个部分则是析构函数。 popq %rbpret当前栈帧的栈底是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 00Class: ELF64Data: 2s complement, little endianVersion: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: REL (Relocatable file)Machine: Advanced Micro Devices X86-64Version: 0x1Entry point address: 0x0Start of program headers: 0 (bytes into file)Start of section headers: 672 (bytes into file)Flags: 0x0Size of this header: 64 (bytes) Size of program headers: 0 (bytes)Number of program headers: 0Size of section headers: 64 (bytes)Number of section headers: 13Section 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 OffsetSize EntSize Flags Link Info Align[ 0] NULL 0000000000000000 000000000000000000000000 0000000000000000 0 0 0[ 1] .text PROGBITS 0000000000000000 000000400000000000000017 0000000000000000 AX 0 0 1[ 2] .rela.text RELA 0000000000000000 000002000000000000000018 0000000000000018 I 10 1 8[ 3] .data PROGBITS 0000000000000000 000000580000000000000004 0000000000000000 WA 0 0 4[ 4] .bss NOBITS 0000000000000000 0000005c0000000000000000 0000000000000000 WA 0 0 1[ 5] .comment PROGBITS 0000000000000000 0000005c000000000000002c 0000000000000001 MS 0 0 1[ 6] .note.GNU-stack PROGBITS 0000000000000000 000000880000000000000000 0000000000000000 0 0 1[ 7] .note.gnu.propert NOTE 0000000000000000 000000880000000000000020 0000000000000000 A 0 0 8[ 8] .eh_frame PROGBITS 0000000000000000 000000a80000000000000038 0000000000000000 A 0 0 8[ 9] .rela.eh_frame RELA 0000000000000000 000002180000000000000018 0000000000000018 I 10 8 8[10] .symtab SYMTAB 0000000000000000 000000e00000000000000108 0000000000000018 11 9 8[11] .strtab STRTAB 0000000000000000 000001e80000000000000018 0000000000000000 0 0 1[12] .shstrtab STRTAB 0000000000000000 00000230000000000000006c 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中的ModR/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应该取值01R/M应该取值位000。偏移disp8表示八位的Displacement根据机器指令的格式Displacement直接嵌入在指令中即可。Displacement取值可以为8位、16位、32位选择取决于尺寸方面Displacement需要使用补码的形式。当CPU执行指令时当解析到ModR/M这个字节时一旦发现Mod的值是01R/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,r32Intel并没有为上述两个分类操作分别定义两个操作码而是使用了同一个操作码。但是使用了Instruction Prefixes来区分指令中的操作数是16位的还是32为的比如在32位环境下使用了16位的操作数那么需要在指令前使用0x66进行标识。
回到代码
那么回到代码fool2中 movl foo2(%rip), %eaxmovl %eax, -4(%ebp)这两条使用的都是mov指令IA32架构的mov指令可以简单了解如下 需要关注Opcode以及Op/En操作数的编码方式。
对于MOV指令不仅仅只有一个操作码对于同一类操作可能使用不同的操作数操作数可能是寄存器也可能是内存地址同时操作数还会有长度之分比如8位、16位、32位。Intel采取的策略是为同一指令设计了多个操作码来细分这些指令。
对于Op/En操作数的编码方式具有六种 需要注意的是编译器生成的汇编代码使用的是ATT的格式其操作数的顺序与Intel的汇编指令正好相反所以指令movl foo2(%rip), %eax中foo2是Intel语法中的第二个操作数%eax是第一个操作数。那么可以查表第七行mov eax,moffs32根据该指令说明操作码0xa1隐含地指出了指令中第一个操作数是寄存器EAX也就是寻址方式中所谓地操作数隐含寻址。
该指令地操作数编码方式是CC类编码方式不需要ModR/M也不需要SIB而且也没有使用立即数作为操作数也不需要指令前缀进行修饰所以第一个操作数寄存器EAX是通过操作码隐含指明所以该条汇编代码最后转换为如下形式地机器指令OpcodeDisplacement。
第二个操作数是通过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为01r/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标准规定的计算修订值得公式是SA;其中S表示符号的运行地址A就是汇编器填充在引用外部符号处的Addend。R_386_PC32,ELF标准规定的计算修订值的公式是SA-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段中记录的是需要重定位的函数。
虽然编译时不需要链接共享库但是可执行文件中需要记录其依赖的共享库以及加载/运行时需要重定位的条目在加载程序时动态加载器需要这些信息来完成加载时的重定位。