链接
部分借鉴 note
链接执行的三种情况
链接在以下三种情况都可以执行:
- 编译时,即在源代码被翻译成机器代码时
- 加载时,即程序被加载器加载到内存并执行时
- 运行时,即由应用程序来执行
现代系统中,链接是由链接器自动执行的。
链接器使分离编译成为可能。
编译时
编译时链接是指在程序编译过程中完成的链接。在这个阶段,链接器(Linker)将多个编译后的目标文件(Object Files)和库(Libraries)合并成一个单一的可执行文件。这些目标文件通常是由编译器从源代码文件转换成的机器代码,包含了程序的各个模块。
- 目的:创建一个独立的、可以直接执行的程序文件。
- 过程:解析外部符号引用,将所有必需的模块和库函数拼接在一起,处理全局变量和函数的内存地址分配。
- 优点:生成的可执行文件可以直接分发和执行,无需任何额外的处理。
- 限制:编译时链接的程序在分发后难以修改,因为任何改动都需要重新链接和分发。
编译过程
示例
1:预处理
预处理作用:
- 处理源代码中所有以
#
开头的预处理指令 - 处理
#include
指令,将指定的头文件内容插入到指令位置 - 展开
#define
指令定义的宏 - 根据
#if
、#ifdef
、#ifndef
和相关指令处理条件编译 - 从源代码中移除所有注释
- 添加特定的编译器指令或标记、
- 输出一个纯净的源代码版
cpp -o main.i main.c
或
gcc -E -o main.i main.c
(-E代表gcc只进行预处理,不做编译、汇编和链接)
2:编译
cc -S -o main.s main.i
或
gcc -S -o main.s main.-
(-S代表gcc只进行编译)
3:汇编
4:链接
链接是编译的最后一步,产生可执行文件
NOTE注意:当手动链接的时候,除了源文件,还需要加上 5 个运行所需要的文件。
可重定位目标文件
什么是可重定位目标文件
整个编译过程
. i
-> . s
-> . o
-> . out
. o 称作可重定位目标文件
包含二进制的代码和数据。可以与其他可重定位目标文件合并成可执行目标文件。又称 obj 文件,gcc 经过预处理、编译、汇编后生成的 .o 文件即为可重定位目标文件
查看可重定位目标文件内容
ELF header, Sections,Sections header table
每个可重定位目标文件可大致分为三部分:ELF header,不同的 Sections,以及描述这些 Sections 的表。
其中 ELF 是可执行可链接格式的首字母缩写 (Executable and Linkable Format, ELF)
通过 readelf -h 命令查看. o 文件的 ELF header
包含文件的一些基本属性信息,用来解释目标文件和帮助链接器进行语法分析。
- 包含内容:生成该文件的系统的字的大小和字节顺序,ELF 头的大小,目标文件的类型,机器类型(如 x86-64),节头部表的文件偏移,节头部表中条目的大小和数量。
通过 readelf -S 命令查看. o 文件的 Section 信息
符号和符号表
重定位的核心就是对符号表进行符号解析
每个可重定位目标模块 m 都有一个符号表(即 .symtab 节),包含着 m 定义和引用的符号的信息
- Type
- NOTYPE 未指定类型
- FILE 文件
- SECTION 代表这个是一个 Section
- OBJECT 变量
- FUNC 函数
- Bind
- GLOBAL 全局变量或函数
- LOCAL 局部变量或函数
- Vis
- 未用到
- Ndx :表示符号的节索引(Section Index)
- COM:COMMON
- 不同数字代表归属不同的 Section
在链接器的上下文种,共有三种符号
符号解析
链接器解析符号引用的方法:将每个引用和它输入的可重定位文件的符号表中的一个确定的符号定义关联起来。
符号解析可以分为对局部符号的解析和对全局符号的解析:
局部符号:简单明了
- 备注:在每个模块中,编译器只允许每个局部符号有一个定义。并且会确保每个静态变量有唯一的名字。
全局符号:更复杂一些
- 方式:编译器遇到一个不是在当前模块定义的符号时,会假设该符号是在其他某个模块中定义的,在可重定位目标文件中生成一个符号表条目,并把它交给链接器处理。
- 特殊情况:多个目标文件中定义了相同名字的的全局符号。
链接器如何解析多重定义的全局符号
编译器和汇编器会把每个全局符号区分为强或弱,并将之隐含地编码在可重定位文件地符号表里。
- 强符号:函数和已初始化的全局变量
- 弱符号:未初始化的全局变量
Linux 链接器使用以下规则来处理多重定义的全局符号:
- 规则1:不允许有多个同名的强符号。导致如下
- 规则2:如果一个全符号和多个弱符号同名,那么选择强符号
结果打印出”x = 15212”,导致错误。
- 规则3:如果有多个弱符号同名,任意选择其中一个。导致如下覆盖
注意:vs 的链接器并未遵守规则2,规则3:如果定义了同名的全局变量,链接器会直接报错,不论是强符号还是弱符号。
静态库
可以将多个相关的目标模块打包成一个单独的文件,称为静态库。
通过静态库,相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。
静态库可以用作链接器的输入。链接器在构造可执行文件时,从静态库中复制被应用程序引用的目标模块,其他未用到的模块则不会复制。
在 Linux 系统中,静态库以一种称为存档的特殊文件格式存放磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件后缀名为 .a (archive)
理解:静态库和存档文件可以当作一个东西。存档是文件层面的描述,静态库是模块层面的描述。
在 linux 中,静态链接库是 .a 文件,动态链接库是 .so 文件。在windows 中,静态链接库是 .lib 文件,动态链接库是 .dll 文件。
通过如下命令创建静态库:
//将 addvec.c 和 multvec 两个文件编译成两个可重定位目标文件
linux> gcc -c addvec.c multvec.c
//采用 ar 工具将上一步生成的两个可重定位目标文件 addvec.o 和 multvec.o 封装到静态库 libvector.o 中。
linux> ar rcs libvector.a addvec.o multvec.o
链接器如何解析引用
符号解析的过程
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。
在扫描中,链接器会维护一个可重定位目标文件的集合 E,一个未解析的符号 (即引用了但尚未定义的符号) 集合 U,已定义的符号集合 D。初始时 E, U, D 都为空。
对于命令行上的每个输入文件 f,链接器会判断 f 是一个目标文件还是一个存档文件。
如果 f 是一个目标文件,链接器会把 f 添加到 E,修改 U 和 D 来反映 f 中的符号定义和引用,并继续下一个输入文件。
如果 f 是一个存档文件,链接器会尝试匹配 U 中未解析的符号和存档文件成员定义的符号。
如果 f 中的某个成员 m 定义了一个符号来解析 U 中的一个引用,就把 m 加到 E 中,并修改 U 和 D 来反映 m 中的符号定义和引用。
对存档文件中所有的成员目标文件都依次进行这个过程。之后任何不包含在 E 中的成员目标文件都简单地被丢弃。
处理完 f,链接器会继续处理下一个输入文件。
当链接器扫描完所有输入文件后,如果 U 是非空的,链接器会输出一个错误并终止。
- 库在命令行中放在什么位置
在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件前,引用就不能被解析,链接会失败。因为初始时 U 是空的。
一般把库放在命令行的结尾。如果库之间相互依赖,则依赖者在前,被依赖者在后。如果双向引用,可以在命令行上重复库。
重定位
重定位分为两步:
重定位节和符号定义。分为两步:
- 链接器将所有相同类型的节合并为同一类型的新的聚合节。
- 链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。
- 上面两步完成后,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
重定位节中的符号引用。
- 链接器修改代码节和数据节中对每个符号的引用,是他们指向正确的运行时地址。链接器依赖于可重定位目标模块中的重定位条目。
重定位条目
重定位条目用来解决符号引用和符号定义的运行时地址的关联问题。
当汇编器遇到对最终位置的目标引用时,就会生成一个重定位条目,告诉链接器在合并目标文件为可执行文件时如何修改这个引用。
代码的重定位条目放在 .rel.text 中,已初始化数据的重定位条目放在 .rel.data 中。
每个重定位条目都代表了一个必须被重定位的引用。
可执行目标文件
可执行目标文件是一个二进制文件,包含加载程序到内存并运行它所需的所有信息。
可执行目标文件的格式与可重定位目标文件的格式类似。
其中 ELF头 描述了文件的总体格式,还包括程序的入口点,即程序运行时要执行的第一条指令的地址。
段头部表和节头部表描述了可执行文件中的片到内存映像中的段的映射关系。它描述了各节在可执行文件中的偏移、长度、在内存映射中的偏移等。
.text, .rodata, .data 节与可重定位目标文件中的节相似,但已经重定位到它们最终的运行时内存地址。
_init 节定义了一个小函数 _init,程序的初始化代码会调用它。
可执行文件是完全链接的,因此比可重定位目标文件少了 .rel 节。
加载可执行目标文件
Linux shell 中运行可执行目标文件的方式:在命令行中输入文件的名字(用带 ./ 的相对路径表示)。
./prog.o
上面运行了文件 prog.o,因为 prog 不是一个内置的 shell 命令,所以 shell 会认为 prog 是一个可执行目标文件,通过调用加载器(是操作系统中的一个程序)来运行它。
- 加载:加载器将可执行目标文件的代码和数据从磁盘复制到内存,然后跳转到程序的第一条指令或入口点来运行程序。
- 任何 Linux 程序都可以通过 execve 函数来调用加载器。
- 每个 Linux 程序都有一个运行时内存映像。代码段总是从 0x400000 处开始,后面是数据段,然后是运行时堆段,通过调用 malloc 库往上增长。堆后面的区域是为共享模块保留的。用户栈总是从最大的用户地址 2^48-1 开始,向较小内存地址增长。从地址 2^48 开始是留给内核的。
- 在分配栈、共享库、堆的运行时地址的时候,链接器还会使用地址空间布局随机化,所以每次程序运行时这些区域的地址都会改变。
加载器的工作过程
加载器的工作过程可以概括为以下几个主要步骤:
- 创建内存映像
- 加载器为即将运行的程序分配一个虚拟地址空间,作为程序的内存映像。
- 读取程序头部表
- 加载器读取可执行文件的程序头部表(Program Header Table),获取各个段的加载地址、文件偏移、段大小及权限等信息。
- 复制段到内存
- 根据程序头部表的指导,加载器将可执行文件中的代码段(包含程序指令)和数据段(包含已初始化的全局和静态变量)复制到内存中的指定位置。
- 分配并初始化BSS段
- 加载器为BSS段(未初始化数据段)分配内存,并将其初始化为零。BSS段用于存储未初始化的全局和静态变量。
- 设置入口点
- 完成内存映像的创建后,加载器将程序的入口点设置为
_start
函数的地址。
- 完成内存映像的创建后,加载器将程序的入口点设置为
- 初始化运行环境
_start
函数负责初始化程序的运行环境,包括设置堆栈指针和初始化C库。
- 调用
__libc_start_main
- 初始化完成后,
_start
函数调用__libc_start_main
,这是C库的启动函数,负责进一步初始化C运行时环境。
- 初始化完成后,
- 执行
main
函数__libc_start_main
初始化完毕后,调用用户编写的main
函数,程序的主体逻辑在这里执行。
- 程序结束
main
函数执行完毕后,返回一个整数值,__libc_start_main
获取这个返回值,并调用exit
函数进行清理工作,最后将控制权返回给内核,程序结束运行。
通过上述步骤,加载器成功地将可执行文件加载到内存中,并启动程序的执行。
动态链接共享库
静态库
优点:
- 简单性:由于库的代码在编译时已经被包含在可执行文件中,因此部署简单,不需要额外的库文件。
- 性能:没有运行时载入或符号解析的开销,可以微小地提升性能。
缺点:
- 体积:每个使用静态库的程序都包含了一个库的副本,这使得最终的可执行文件体积增大。
- 更新不便:一旦静态库更新,所有使用该库的应用程序都需要重新编译和链接,以便使用新版本的库。
共享库(动态库)
优点:
- 内存与存储效率:多个程序可以共享同一内存中的库副本,节省了系统资源。
- 易于更新:库的更新不需要重新编译使用这个库的程序,只要保持接口不变,新版本库可以透明替换旧版本。
- 模块化:程序在运行时只加载需要的库,有利于模块化和按需加载。
动态链接的过程:
- 在程序启动时或者在运行时,动态链接器(如 Linux 的
ld.so
)负责读取可执行文件的动态链接部分,解析需要加载的动态库,并将它们载入内存。 - 链接器处理重定位表和符号表,确保程序中的符号引用正确地指向其在动态库中的对应项。
- 此过程通常涉及查找环境变量或系统配置文件中指定的库路径。
示例命令
创建共享库:
gcc -shared -fpic -o libvector.so addvec.c multvec.c
-fpic
生成位置无关的代码(Position Independent Code),这对于动态库是必要的,因为动态库需要能在任何地址正常运行。-shared
创建共享库。
链接共享库:
gcc -o prog21 main2.c ./libvector.so
- 这条命令不是将
libvector.so
的内容复制到prog21
中,而是配置了程序在运行时如何找到和使用libvector.so
。 动态链接共享库:
Linux 中动态访问共享库:
dlopen
, dlsym
, dlclose
, 和 dlerror
函数提供了一个强大的接口,允许应用程序在运行时动态地加载、访问、调用和卸载共享库(动态链接库,如 .so 文件)。
动态链接的视觉解释
想象一个场景,程序在启动时,动态链接器读取程序的元数据,发现需要 libc.so
,然后查找这个库文件的位置,加载它到内存,并将程序中对 libc.so
的引用指向这个内存地址。这个过程确保了当程序调用标准 C 函数时,能够执行到正确的代码。
总结
静态库和动态库(共享库)各有优缺点,适用于不同的应用场景。静态库简化了部署和执行,但不便于更新和共享。动态库虽然复杂,但更灵活,节省资源,易于更新,是大多数现代应用程序的选择。在使用动态库时,开发者需要了解如何创建、链接和管理这些库,以便充分利用其优势。
总结
链接可以在编译时由静态编译器完成(静态库的链接),也可以在加载和运行时由动态链接器完成(动态库的链接)。
链接器处理的文件是目标文件,目标文件是一种二进制文件,有 3 种不同形式:
- 可重定位目标文件:
- 可执行目标文件:静态链接器将多个可重定位目标文件合并成一个可执行目标文件,它可以加载到内存中并执行。.exe 文件就是可执行目标文件。
- 共享目标文件(共享库):运行时由动态链接器链接和加载。
链接器的两个主要任务:
- 符号解析:将目标文件中的每个全局符号都绑定到一个唯一的定义。
- 重定位:确定每个符号的最终内存地址,并修改对那些目标的引用。
静态链接器是由 GCC 这样的编译驱动程序调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,链接器可以按照一定规则来解析这些相同的符号。
多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器都是通过从左到右的顺序扫描库来解析符号引用。
加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的例程和数据的为解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,动态链接器通过加载共享库和重定位程序中的引用来完成链接任务。
被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。