一、引子
上篇文章介绍了目标文件,也就是讲到编译过程中的汇编这个阶段。本篇要讲目标文件怎么变成一个可执行文件的,介绍编译过程中的链接。
链接主要分为两种,静态链接和动态链接。它们本质上的区别,是在程序的编译和运行过程中使用库的方式不同。
特性 | 静态链接 | 动态链接 |
---|---|---|
文件大小 | 较大 | 较小 |
运行独立性 | 高(无需外部库) | 低(需要外部库) |
启动速度 | 较快 | 稍慢 |
内存使用 | 不共享库,可能使用更多内存 | 库共享,节省内存 |
更新和维护 | 更新库需要重新编译和分发整个程序 | 更新库只需替换库文件 |
兼容性 | 无库版本问题,较高 | 可能存在库版本冲突问题 |
二、静态链接要做什么
在编译时将所有必要的库和模块直接嵌入到生成的可执行文件中,这是静态链接的定义。这意味着可执行文件包含了所有运行所需的代码,因此无需依赖外部库文件。
下面将用两个Demo来示范,1.c和2.c
这是1.c
extern int a;
extern void func1(void);int main()
{int b = 0;func1();return 0;
}
这是2.c
int a = 0;void func1(void)
{int c = 0;c++;
}
objdump -h 1.o1.o: 文件格式 elf64-x86-64节:
Idx Name Size VMA LMA File off Algn0 .text 0000001f 0000000000000000 0000000000000000 00000040 2**0CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE1 .data 00000000 0000000000000000 0000000000000000 0000005f 2**0CONTENTS, ALLOC, LOAD, DATA2 .bss 00000000 0000000000000000 0000000000000000 0000005f 2**0ALLOC3 .comment 0000002c 0000000000000000 0000000000000000 0000005f 2**0CONTENTS, READONLY4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 0000008b 2**0CONTENTS, READONLY5 .note.gnu.property 00000020 0000000000000000 0000000000000000 00000090 2**3CONTENTS, ALLOC, LOAD, READONLY, DATA6 .eh_frame 00000038 0000000000000000 0000000000000000 000000b0 2**3CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATAobjdump -h 2.o2.o: 文件格式 elf64-x86-64节:
Idx Name Size VMA LMA File off Algn0 .text 00000016 0000000000000000 0000000000000000 00000040 2**0CONTENTS, ALLOC, LOAD, READONLY, CODE1 .data 00000000 0000000000000000 0000000000000000 00000056 2**0CONTENTS, ALLOC, LOAD, DATA2 .bss 00000004 0000000000000000 0000000000000000 00000058 2**2ALLOC3 .comment 0000002c 0000000000000000 0000000000000000 00000058 2**0CONTENTS, READONLY4 .note.GNU-stack 00000000 0000000000000000 0000000000000000 00000084 2**0CONTENTS, READONLY5 .note.gnu.property 00000020 0000000000000000 0000000000000000 00000088 2**3CONTENTS, ALLOC, LOAD, READONLY, DATA6 .eh_frame 00000038 0000000000000000 0000000000000000 000000a8 2**3CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
ld 1.o 2.o -e main -o a.out
objdump -h a.out a.out: 文件格式 elf64-x86-64节:
Idx Name Size VMA LMA File off Algn0 .note.gnu.property 00000020 00000000004001c8 00000000004001c8 000001c8 2**3CONTENTS, ALLOC, LOAD, READONLY, DATA1 .text 00000035 0000000000401000 0000000000401000 00001000 2**0CONTENTS, ALLOC, LOAD, READONLY, CODE2 .eh_frame 00000058 0000000000402000 0000000000402000 00002000 2**3CONTENTS, ALLOC, LOAD, READONLY, DATA3 .bss 00000008 0000000000404000 0000000000404000 00003000 2**2ALLOC4 .comment 0000002b 0000000000000000 0000000000000000 00002058 2**0CONTENTS, READONLY
2.1 地址和空间的分配
我们将上面两个文件变成目标文件后,每个目标文件都是有自己的代码段,数据段,bss段等。但可执行文件是一个文件,那也就是说链接时要找到各文件中的各符号地址和各段地址,并将它们合并起来,并且分配运行时候的空间。
链接器会为可执行文件或共享库分配内存空间,还会为每个符号分配一个唯一的内存地址。
2.1.1 各段依次堆叠(Sequential Stacking)
最简单的一个方案,是将输入的目标文件按照次序依次叠加起来,生成输出的可执行文件或共享库。
步骤:
扫描目标文件: 链接器首先会扫描所有输入的目标文件,提取每个目标文件的信息,包括代码段、数据段、BSS 段以及符号表等。
地址分配: 链接器会为可执行文件或共享库分配内存空间,并为每个符号分配一个唯一的内存地址。
生成输出文件: 链接器会将所有输入的目标文件的内容依次复制到输出文件中,并根据符号表修正符号地址。
特点:
实现简单: 按序叠加的实现代码比较简单,易于理解和开发。
效率低下: 按序叠加会导致输出文件体积较大,并且可能会产生大量的内存碎片,降低程序的运行效率。对于一些硬件来讲,段的装载和空间对齐单位是页,也就是4KB大小。即便只有1B大小也会分配出4KB的内存。
不适用于大型项目: 对于大型项目,按序叠加会导致链接过程非常耗时,并且可能无法有效利用内存空间。
2.1.2 相似段合并(Merge Similar Segments)
各段依次堆叠会产生各种问题。所以在链接目标文件的过程中,可能会执行相似段合并的优化步骤,以减少可执行文件的大小并提高执行效率。相似段合并的步骤通常在优化和合并阶段进行,其主要目标是识别并合并具有相似代码或数据的段,以消除重复并节省空间。
步骤:
识别相似段:链接器分析目标文件中的代码段和数据段,寻找具有相似内容的段。相似内容可能是相同的代码片段、常量数据或其他类型的数据。
合并相似段:对于找到的相似段,链接器将其合并成一个单一的段。合并过程可能涉及将重复的代码片段或数据项替换为单个引用,并确保所有引用指向合并后的段。
更新引用:
在合并后的段被创建后,链接器需要更新目标文件中的所有引用,以便指向合并后的段。这可能涉及修改目标文件中的地址引用或符号表条目。
删除冗余段:完成相似段的合并后,链接器可能会删除原始目标文件中的冗余段,以释放空间并简化可执行文件的结构。
2.1.3 示例分析
a.out的.text段的size就是目标文件的.text段段的size总和。a.out的VMA和LMA是有数值的,但目标文件并没有的。VMA是虚拟地址,是在链接后根据各段的大小被分配的。LMA是加载地址,在确定每个段的大小、对齐要求以及其他系统布局策略并并保证各段在内存中不会相互冲突的情况下,链接器为每个段分配一个加载地址。
有些资料会讲Linux中的.text段默认会分配到0x08048094,.data段默认会分配到0x08049108,说是ELF文件默认从该地址分配。但目前新一点版本的静态链接的可执行文件的默认加载地址也不是固定的,它的加载地址同样是由操作系统的动态链接器 ld.so(或者其替代品)在可执行文件被加载时动态确定的。如果强制指定可执行文件的加载地址,可以使用一些链接选项来指定加载地址,例如-Wl,-Ttext-segment=地址 。该技术是地址空间布局随机化(ASLR,Address Space Layout Randomization),主要目的是在每次加载可执行文件时,随机选择加载地址,从而增加攻击者利用程序漏洞的难度。
File off是各个段的起始偏移量。这个偏移量表示了每个段在文件中的位置,即相对于文件的起始位置的偏移量。在链接时,File off的作用主要是用来确定每个段在最终生成的可执行文件中的位置,并且在重定位阶段被使用。在生成最终的可执行文件时,File off 信息通常会被包含在可执行文件的头部或段表中,以便操作系统在加载可执行文件时正确地映射每个段到内存中的位置。
2.2 符号解析和重定位
符号解析和重定位对于程序的正确运行至关重要。如果没有符号解析,程序将无法找到符号定义的变量、函数等对象。如果没有重定位,程序将无法在不同的内存地址上运行。
一般出现在该步骤的错误:
符号未定义: 程序中使用了未定义的符号。
符号重复定义: 程序中有多个符号定义使用了相同的名称。
符号地址冲突: 两个或多个符号被分配了相同的地址。
2.2.1 符号解析(Symbol Resolution)
通过符号解析,链接器能够确保所有的符号引用都能正确指向其在其他目标文件或库中的定义,从而使得最终生成的可执行文件能够正确地执行。
链接器首先解析所有目标文件中的符号(函数和变量)。每个目标文件包含的符号可以分为以下两类:
定义的符号:在目标文件中定义的符号(例如函数实现或变量定义)。
引用的符号:目标文件中使用但未定义的符号(例如外部函数或变量)。
链接器需要确保所有引用的符号都能在某个目标文件或库中找到定义。
主要过程:
定义和引用:在程序中,符号可以是函数、变量或其他命名实体。在目标文件中,这些符号可以被定义(有实际的实现)或引用(被使用但未定义)。
扫描目标文件:链接器首先会扫描所有的目标文件和库文件,从中提取出所有的符号。这个过程包括解析目标文件的符号表、重定位表等信息。
符号解析:对于每个引用的符号,链接器需要在其他目标文件或库中找到对应的定义。这可能涉及搜索多个目标文件和库文件,直到找到符号的定义。如果找不到符号的定义,链接器将会报错并提示未定义的符号。
符号表管理:链接器维护一个符号表,记录了每个符号的定义和引用情况。对于每个引用的符号,链接器会在符号表中查找其定义,以确定其在可执行文件中的实际位置。
生成符号表:在符号解析过程完成后,链接器会生成一个最终的符号表,其中包含了所有解析后的符号信息。这个符号表将被用于后续的符号重定位等操作。
2.2.2 符号重定位(Symbol Relocation)
目的是将程序中使用的符号引用(例如函数地址、变量地址等)修正为实际的内存地址。符号重定位是确保程序在不同环境下能够正确运行的关键步骤之一。
在目标文件中,符号的地址通常是相对的或未确定的。链接器需要将这些符号地址调整到它们在最终可执行文件中的实际内存地址。这一步包括以下过程:
地址计算:在符号解析完成后,链接器需要计算每个符号在最终可执行文件中的内存地址。这个地址通常是相对于程序的起始地址的偏移量。
地址调整:链接器将目标文件中的所有引用符号的地址调整为其在最终可执行文件中的实际内存地址。这包括修改目标文件中的指令、数据、符号表等,以便在程序执行时正确地指向符号的位置。
重定位表:重定位表记录了需要进行重定位的符号及其偏移量。链接器根据重定位表中的信息,将引用的符号地址进行调整,以确保程序能够正确执行。
代码和数据重定位:对于代码段和数据段中的引用符号,链接器会修改指令或数据中的地址,使其指向正确的内存位置。这可能涉及修改指令中的跳转地址、函数调用地址,或者修改数据中的符号地址引用。
符号表更新:在完成符号重定位后,链接器可能会更新符号表中的符号信息,以反映符号在最终可执行文件中的实际位置。
2.2.3 示例分析
objdump -d a.out
a.out: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000401000 <main>:401000: f3 0f 1e fa endbr64 401004: 55 push %rbp401005: 48 89 e5 mov %rsp,%rbp401008: 48 83 ec 10 sub $0x10,%rsp40100c: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)401013: e8 07 00 00 00 callq 40101f <func1>401018: b8 00 00 00 00 mov $0x0,%eax40101d: c9 leaveq 40101e: c3 retq 000000000040101f <func1>:40101f: f3 0f 1e fa endbr64 401023: 55 push %rbp401024: 48 89 e5 mov %rsp,%rbp401027: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)40102e: 83 45 fc 01 addl $0x1,-0x4(%rbp)401032: 90 nop401033: 5d pop %rbp401034: c3 retq
例子1.o中.text段的大小是1F,2.o中的.text段的大小是16。并且我们将程序的入口函数定位了main函数。所以a.out中.text段的起始位置也就是main函数的位置。那func1函数的位置就是main + 1F了。查看a.out的汇编代码,发现callq 40101f ,这就是调用了func1函数。这样可执行文件就可以正常运行了。
三、静态链接过程注意点
3.1 强符号和弱符号不一致
在链接器的世界中,符号可以被分为两种类型:强符号(Strong Symbol)和 弱符号(Weak Symbol)。这些术语通常用于描述符号的可见性和优先级。
强符号(Strong Symbol):强符号是具有全局可见性和固定地址的符号。当多个目标文件或库文件中存在相同名称的强符号时,链接器会选择其中的一个作为最终的定义。通常情况下,全局变量和函数都是强符号。强符号的定义具有最高的优先级,将覆盖所有弱符号和普通符号。
弱符号(Weak Symbol):弱符号是具有全局可见性但没有固定地址的符号。当多个目标文件或库文件中存在相同名称的弱符号时,链接器会选择其中的一个作为最终的定义。弱符号的定义具有较低的优先级,当没有找到强符号时,链接器才会考虑使用弱符号。通常情况下,全局变量的声明和函数的声明会被视为弱符号,因为它们没有固定的地址。
在程序中强弱符号定义不一致,主要分三种:
两个及以上强符号类型不一致:多个强符号定义,会在链接时报重定义,因为链接器无法判断该符号的类型。
类型不一致的一个强符号和多个弱符号:在程序分配内存时,会按强符号的类型分配内存,同时报告警:size of symbole 符号 change from 。在连接的时候,要是弱符号的类型大于强符号,将报alignment 1 of symbol 符号 in 目标文件 is smaller than 8 in 目标文件。
两个及以上弱符号类型不一致:这个时候,编译器不会给出要符号内存,只能到链接过程中看有没有强符号,在分配内存。这种情况下不会报告警和错误,但运行时会有未定义符号或者直接段错误。
综上,我们应该可以清楚了,为什么目标文件的bss段在磁盘中的大小是0。因为编译器无法判断其他编译单元中是否有比本单元该符号所占的内存更大的符号,所以给不出内存空间。
3.2 静态库链接
将静态库(Static Library)嵌入到可执行文件(Executable File)中的过程。静态库是一种预编译的代码库,包含函数、变量和其他代码对象,但并非直接可执行。在静态库链接过程中,链接器会将静态库中的所有代码和数据复制到可执行文件中,使其成为可执行文件的一部分。
优点:
可执行文件独立性: 静态链接的可执行文件无需依赖外部库,即使在没有库的情况下也能运行。
提高安全性: 静态链接的可执行文件包含所有代码,因此更不容易受到外部库漏洞的影响。
减小可执行文件大小: 在某些情况下,静态链接可使可执行文件更小,因为无需包含额外的库文件。
缺点:
增加可执行文件大小: 在大多数情况下,静态链接会使可执行文件更大,因为包含了所有静态库代码。
浪费资源: 如果多个可执行文件使用相同的静态库,则每个可执行文件都包含该库的副本,这会导致资源浪费。
更新困难: 当静态库更新时,需要重新编译所有依赖它的可执行文件。