csapp读书笔记——链接
链接
链接是将各种代码和数据收集并组合成一个文件的过程,最终得到的文件可以被加载到内存执行
在大型应用程序的开发过程中,我么不可能将所有的功能实现都放在一个源文件中,而是将它分解为更小、更容易管理的模块。
当我们修改其中一个模块时,我们只需要重新编译这个修改后的模块,而其他模块时不需要重新编译的。
链接在以下三个阶段都可以执行:
- 编译时,即在源代码被翻译成机器代码时
- 加载时,即程序被加载器加载到内存并执行时
- 运行时,即由应用程序来执行
现代系统中,链接是由链接器自动执行的。
链接器使分离编译成为可能。
编译器驱动程序#
编译器驱动程序可以使用户根据需要调用语言预处理器、编译器、汇编器和链接器。
通过静态链接,链接器将多个可重定位目标文件组合形成一个可执行目标文件。
静态链接#
静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。
在构造可执行文件的过程中,链接器主要完成两个任务:
-
符号解析
-
什么是符号:目标文件定义和引用符号,每个符号对应着一个函数、全局变量或静态变量
-
符号解析的目的:将每个符号引用和一个符号定义关联起来**
-
-
重定位
-
由编译器和汇编器生成的可重定位目标文件中的代码和数据节是从 0 开始的。可重定位目标文件中还包含重定位条目。
-
如何实现重定位:链接器通过把每个符号定义和一个内存位置(运行时地址)关联起来以实现重定位。然后修改所有对这些符号的引用,使它们指向这个内存位置。
-
静态库的制作#
制作静态库(Static Library)通常包括以下步骤:
-
编写代码:首先,需要编写一个源代码文件(通常是C或C++),这些文件将包含在静态库中。这些源文件可以包含不同的功能和模块,以满足特定的需求。
-
编译源代码:使用编译器(如GCC)来编译源代码文件,生成目标文件(Object Files)。这些目标文件包含了编译后的代码,但尚未链接到可执行文件或库中。
例如,使用以下命令来编译单个C源文件并生成目标文件:
gcc -c mylib.c -o mylib.o
你可以重复这个步骤,以编译多个源文件。
-
创建静态库:使用静态库工具(通常是
ar
,在Linux和Unix系统上)来创建静态库。将所有目标文件打包到一个静态库文件中。例如,使用以下命令来创建名为
libmylib.a
的静态库:ar rcs libmylib.a mylib.o
这将把
mylib.o
添加到libmylib.a
中。 -
使用静态库:可以将静态库用于其他项目。在编译和链接应用程序时引用静态库。
例如,如果有一个名为
myapp.c
的应用程序,可以使用以下命令编译并链接它:-L :指定路径
-l :指定静态库文件(掐头去尾)。
-l
选项用于链接共享库时,指定库的名称,但通常不需要包括库名称的前缀(通常是"lib")和后缀(通常是".so"或".a"),因为链接器会自动查找匹配的库文件。gcc myapp.c -o myapp -L. -lmylib
这将链接
libmylib.a
并生成名为myapp
的可执行文件。
注意事项:
- 静态库包含了目标文件的副本,因此每个应用程序都有自己的库副本。这可能导致较大的可执行文件和库的多个副本。
- 静态库在编译时被链接到可执行文件中,因此需要重新编译可执行文件才能更新库。
- 静态库通常用于小型项目或需要快速执行的嵌入式系统,但在大型项目中可能会导致代码重复和较大的可执行文件。
目标文件#
一个目标文件又称目标模块。目标文件纯粹是字节块的集合。目标文件本身是一个字节序列。目标文件是按照特定的目标文件格式来组织的,各个系统的目标文件格式都不相同。Windows 使用 PE 格式,Linux 使用 ELF 格式。
这些字节块中有些包含程序代码或程序数据,其他的则包含引导链接器和加载器的数据结构。
链接器把这些块连接起来,确定被连接块的运行时位置,并修改代码和数据块中的各种位置。
目标文件有三种形式:
-
可重定位目标文件:包含二进制的代码和数据。可以与其他可重定位目标文件合并成可执行目标文件。又称 obj 文件,gcc 经过预处理、编译、汇编后生成的 .o 文件即为可重定位目标文件。
-
可执行目标文件:包含二进制的代码和数据。可以被直接复制到内存并执行。简称可执行文件,gcc 经过链接后生成的 .out 文件以及无后缀名文件都是可执行文件。
-
在 linux 中,.out 文件和无后缀名文件基本意义一样,只是命名习惯的不一致而已,即 main.out 和 main 两个文件是一样的。
-
共享目标文件:特殊类型的可重定位目标文件,即动态链接库。可以在加载或者运行时被动态地加载进内存并链接。
编译器和汇编器生成可重定位目标文件和共享目标文件,链接器生成可执行目标文件。
静态链接库属于可重定位目标文件的一种形式。静态链接库包含了编译后的代码和数据,可以与其他可重定位目标文件合并,最终生成可执行目标文件。它不同于可执行目标文件,因为静态链接库本身不是一个可以直接执行的文件,而是一个包含多个目标文件的归档文件(通常以.a
为扩展名)。
静态链接库中的代码和数据可以在编译时与应用程序一起链接,从而形成一个包含所有所需功能的独立可执行文件。这种方式会在可执行文件中包含库的副本,因此它们被称为静态链接库。静态链接库的使用可以减少对外部库的依赖,但可能导致可执行文件的体积较大。
相反,动态链接库(共享目标文件)是一种特殊类型的可重定位目标文件,它包含了可共享的代码和数据,可以在加载或运行时被动态地加载到内存中,并在多个应用程序之间共享。动态链接库通常以.so
(在Linux中)或.dll
(在Windows中)为扩展名。这使得多个应用程序可以共享相同的库的单一副本,减少内存使用和磁盘空间。
因此,静态链接库和动态链接库属于不同的库形式,静态链接库是可重定位目标文件,而动态链接库是一种特殊的可重定位目标文件,用于共享代码和数据。
可重定位目标文件#
本书以 Linux 中的 ELF 可重定位目标文件为例。
可重定位目标文件由多个不同的节组成,每一节都是一个连续的字节序列。指令、初始化了的全局变量、未初始化的的变量分别位于不同的节。
一个 ELF 可重定位文件中包含以下节(按位置顺序排列):
-
ELF 头:特殊的节,包含文件的一些基本属性信息,用来解释目标文件和帮助链接器进行语法分析。包含内容:生成该文件的系统的字的大小和字节顺序,ELF 头的大小,目标文件的类型,机器类型(如 x86-64),节头部表的文件偏移,节头部表中条目的大小和数量。
-
.text:包含已编译程序的机器代码。即存放的是指令代码。
-
.rodata:包含一些特殊的只读数据。
-
.data:包含已初始化的全局和静态变量。
-
.bbs:包含未初始化的全局和静态变量,以及所有被初始化为 0 的全局或静态变量。注意:.bss 节在目标文件中仅是一个占位符,不占据实际空间。这两类变量都是运行时在内存中为其分配变量,并初始化为 0
-
.symtab:包含一个符号表。存放了在程序中定义和引用的符号 (即函数和全局变量) 的信息。注意:与可编译器中的符号表不同,.symtab 中的符号表不包含局部变量的条目。
-
.rel.text:包含一个 .text 节中位置的列表,当链接器把此目标文件与其他文件组合时,需要修改这些位置。
一般任何调用外部函数或引用全局变量的指令都需要修改,而调用本地函数的指令则不需要修改。
-
注意:可执行目标文件不需要重定位,一般不包含 .rel.text 和 .rel.data 节。
-
理解:.rel.text 中包含的实际上是代码的重定位条目。
-
-
.rel.data:包含被模块引用或定义的所有全局变量的重定位信息。
-
如果一个已初始化的全局变量其初始值是一个全局变量地址或外部定义函数的地址,就需要被修改。
-
理解:.rel.data 中包含的实际上是已初始化的数据的重定位条目。
-
-
.debug:一个调试符号表,内部包含的条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,还有原始的 C 源文件。
- 注意:.debug 节并不总是存在,只有用 -g 选项来调用编译器驱动程序时,才会有这一节。
-
.line:包含原始 C 源程序中的行号和 .text 节中机器指令之间的映射。
- 注意:.line 节和 .debug 节一样,并不总是存在,只有用 -g 选项来调用编译器驱动程序时,才会有这一节。
-
.strtab:包含一个字符串表,其中包括 .symtab 和 .debug 节中的符号表,以及节头部中的节名字。
-
节头部表:特殊的节,是一个用来描述目标文件的节。
- 内容:含有与目标文件中每个节相对应的一个条目,描述了对应节的位置和大小等信息。
注意局部变量在运行时保存在栈中,既不出现在 .data 节中,也不出现在 .bss 节中。
伪节
有三个特殊的伪节,它们在节头部表中是没有条目的:
- ABS:代表不该被重定位的符号
- UNDEF:代表未定义的符号,即在本目标模块中引用,但在其他地方定义的符号
- COMMON:表示还未被分配位置的未初始化的数据目标。
这些伪节只有可重定位文件中才有,可执行文件中没有。
COMMON 和 .bss 的区别很细微:
- COMMON:未初始化的全局变量
- .bss:未初始化的静态变量,初始化为 0 的全局或静态变量
原因:未初始化的全局变量是全局符号中的弱符号,编译器将其分配为 COMMON 以表明是弱符号。
可执行目标文件#
可执行目标文件是一个二进制文件,包含加载程序到内存并运行它所需的所有信息。
可执行目标文件的格式与可重定位目标文件的格式类似。
其中 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 开始是留给内核的。
在分配栈、共享库、堆的运行时地址的时候,链接器还会使用地址空间布局随机化,所以每次程序运行时这些区域的地址都会改变。
加载器的工作过程
加载器运行时,创建一个内存映像(虚拟地址空间),在程序头部表的引导下,将可执行文件的片复制到代码段和数据段。然后加载器跳转到程序的入口点,即 _start 函数的地址(函数在系统目标文件 ctrl.o 中定义),_start 函数调用系统启动函数 __libc_start_main(定义在 libc.o 中),__libc_start_main 初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并在需要时把控制返回给内核。
符号和符号表#
重定位的核心就是对符号表进行符号解析
每个可重定位目标模块 m 都有一个符号表(即 .symtab 节),包含着 m 定义和引用的符号的信息。
有三种不同的符号:
- 由模块 m 定义并能被其他模块引用的全局符号。包括非静态的函数和全局变量
- 由其他模块定义并被 m 引用的全局符号,称之为外部符号。对应其他模块中定义的非静态函数和全局变量。
- 由模块 m 定义且只能被 m 引用的局部符号****。包括带 static 属性的函数和全局变量。
对照 C++ 的语法来理解什么是全局符号和局部符号(static 对全局变量和函数的隐藏效果是一样的):
- C++ 中,static 变量只能在本文件中使用,即使外其他文件中用 extern 中声明也不行。属于这里的局部符号
- C++ 中,非 static 的全局变量在其他文件中也能使用,只需在该文件中用 extern 声明即可。属于这里的全局符号
注意:符号表中没有非 static 局部变量的符号,非 static 局部变量在运行时在栈中被管理。这里的局部符号和程序中的局部变量是不同的。
编译器在 .data 或 .bss 中为每个全局变量和 static 变量的定义分配空间,并在符号表中创建一个有唯一名字的符号。
符号表中的条目
符号表实际上是一个条目的数组,每个条目描述一个符号的信息。
typedef struct{
int name;//name 是一个字符串表(.strtab节)中的字节偏移,指向符号的名字(用一个以 null 结尾的字符串表示)
char type:4;//表明符号的类型:数据或函数(4 bits)
binding:4;//表明符号是本地的还是全局的(4 bits)//这里的意思似乎是 type 和 binding 分别是一个 char 类型的高四位和低四位
char reserved;//
short section;//表明符号位于文件的哪个节中,section 是一个到节头部表的索引。
long value;//对于可重定位文件而言,value 是距定义目标的节的起始位置的偏移;对于可执行文件而言,value 是一个绝对运行时地址
long size;//对象的大小,以字节为单位
}
符号表中的条目除了符号外,还可以包含各个节的条目,对应原始源文件的路径名的条目。
链接器如何解析多重定义的全局符号#
编译器和汇编器会把每个全局符号区分为强或弱,并将之隐含地编码在可重定位文件地符号表里。
- 强符号:函数和已初始化的全局变量
- 弱符号:未初始化的全局变量
Linux 链接器使用以下规则来处理多重定义的全局符号:
- 规则1:不允许有多个同名的强符号
- 规则2:如果一个全符号和多个弱符号同名,那么选择强符号
- 规则3:如果有多个弱符号同名,任意选择其中一个
注意:vs 的链接器并未遵守规则2,规则3:如果定义了同名的全局变量,链接器会直接报错,不论是强符号还是弱符号。
与静态库链接#
可以将多个相关的目标模块打包成一个单独的文件,称为静态库。
通过静态库,相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。
静态库可以用作链接器的输入。链接器在构造可执行文件时,从静态库中复制被应用程序引用的目标模块,其他未用到的模块则不会复制。
在 Linux 系统中,静态库以一种称为存档的特殊文件格式存放磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件后缀名为 .a 。
理解:静态库和存档文件可以当作一个东西。存档是文件层面的描述,静态库是模块层面的描述。
在 linux 中,静态链接库是 .a 文件,动态链接库是 .so 文件。在windows 中,静态链接库是 .lib 文件,动态链接库是 .dll 文件。
静态库的应用实例
通过如下命令创建静态库:
linux> gcc -c addvec.c multvec.c //将 addvec.c 和 multvec 两个文件编译成两个可重定位目标文件
linux> ar rcs libvector.a addvec.o multvec.o //采用 ar 工具将上一步生成的两个可重定位目标文件 addvec.o 和 multvec.o 封装到静态库 libvector.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 重定位条目的格式
typedef struct{
long offset; //需要被修改的引用的节偏移(即该符号引用距离所在节的初始位置的偏移)。
long type:32, //重定位类型,不同的重定位类型会用不同的方式来修改引用
symbol:32; //symbol table index,指向被修改引用应该指向的符号
long addend; //一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整
}
ELF 定义了 30 种不同的重定位类型。以下是其中最基本的两种:
-
R_X86_64_PC32
:重定位一个使用 32 位 PC 相对地址的引用。 -
- 什么是 PC 相对地址:一个 PC 相对地址就是距程序计数器的值的偏移量。当 CPU 执行到一条使用 PC 相对寻址的指令时,就将在指令中编码的 32 位偏移量值加上 PC 的当前运行时值,得到有效地址,PC 值通常是下一条指令在内存中的地址。
-
R_X86_64_32
:重定位一个使用 32 位绝对地址的引用。通过绝对寻址,CPU 直接使用在指令中编码的 32 位值作为有效地址。
这两种类型都使用了 x86-64 小型代码模型,该模型假设可执行目标文件中的代码和数据的总体大小小于 2GB,因此可以通过 32 位地址来访问。GCC 默认使用小型代码模型。此外还有中型代码模型和大型代码模型。
重定位符号引用#
1、重定位 PC 相对引用
PC 相对引用的机制:在引用中存放着与 PC 的值偏移量。这实际上是符号定义的地址与符号引用的地址差。在实际运行时,当执行到了符号引用的指令时,PC 中的值就是符号引用的地址,加上 与 PC 的偏移量(即符号定义与符号引用的地址差)就得到了符号定义的地址。
备注:这里是相对粗糙的解释,详细的细节还要考虑到 attend 的值,具体的要看书上 481 页。
2、重定位绝对引用
绝对引用的机制:引用中存放的就是符号定义的绝对地址
动态链接共享库#
静态库解决了如何让大量相关函数对应用程序可用的问题。
静态库的缺点:
- 静态库需要定期维护和更新。如果想要使用一个更新后的静态库,必须显式地将程序与更新了的静态库重新链接。
- 调用的静态库中的函数在运行时会被复制到每个运行进程的文本段中。
共享库是为了解决静态库缺陷的产物。也就是说共享库的主要目的就是
- 共享库与可执行文件相独立,只要输出接口不变(即名称、参数、返回值类型和调用约定不变),共享库更新不会对可执行文件造成任何影响。
- 允许多个正在运行的进程共享内存中相同的库代码,从而节约宝贵的内存资源。
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和内存中的程序链接起来。
动态链接:在程序运行或加载时,动态链接器将共享库加载到内存中并和程序链接起来。
共享库在 Linux 中以 .so 后缀表示,在 Windows 中以 .dll 表示。Windows 操作系统中大量使用了共享库。
共享库共享的方式:
- 一个共享库只有一个 .so 文件,所有引用该库的可执行目标文件共享这个 .so 文件中的代码和数据,而不是像静态库那样复制和嵌入到引用它们的文件中。
- 在内存中,一个共享库 .text 节的一个副本可以被不同的正在运行的进程共享。
共享库实例
生成共享库的方式:
$ gcc -shared -fpic -o libvector.so addvec.c multvec.c
#将 addvec.c 和 multvec.c 封装到动态库 libvector.so 中 -fpic 选项指示编译器生成与位置无关的代码。 -shared 选项指示链接器创建一个共享的目标文件。
链接共享库
在 main2.c
函数中,调用了共享库libvector.so
中的 addvec
函数,因此要将 main2.c
和共享库 libvector.so
链接起来。
$ gcc -o prog21 main2.c ./libvector.so
#//创建了一个可执行目标文件 demo
将 main.o
和 libvector.so
链接并不是将 libvector.so
中的内容拷贝到了可执行文件 demo 中,而是链接器复制了一些 libvector.so
中的重定位和符号表信息,以便运行时可以解析对 libvector.so
中代码和数据的引用。
理解:动态链接库是在程序运行或加载时才动态链接的,但并不意味着在执行之前不需要进行其他操作。在链接时链接器要与动态链接库进行一次部分链接以获取到它的重定位和符号表信息。
理解:要在程序中使用动态链接库,也需要在源文件中包含相关的头文件。
下图是动态链接的过程,其中的 libc.so
是包含所有标准 C 函数的动态库。
动态链接器完成链接的操作:
- 重定位
libc.so
的文本和数据到某个内存段。(理解:这里的意思是将 libc.so 的内容加载到内存中?) - 重定位
libvector.so
的文本和数据到另一个内存段。 - 重定位
prog21
中所有对由libc.so
和libvector.so
定义的符号的引用。
上述操作完成后,共享库的位置就固定了,且程序执行的过程中都不会改变。
从应用程序中加载和链接共享库#
动态链接:应用程序在运行时要求动态链接器加载和链接某个共享库(共享库即动态链接库)。
动态链接的应用:
- 分发软件。软件开发者常利用共享库来分发软件更新,它们生成共享库的新版本,用户只需要下载共享库并替代当前版本,下一次运行应用程序时,应用将自动链接和加载新的共享库。
- 构建高性能 Web 服务器:许多 Web 服务器使用基于动态链接的方法来生成动态内容。将每个生成动态内容的函数打包在共享库中,当一个浏览器请求达到时,服务器就动态加载并链接相应函数,然后直接调用它,而非创建新的进程来运行函数。
dlopen 函数#
dlopen 函数允许在不重新编译程序的情况下,通过加载新的共享库来扩展或修改程序的功能。
#include <dlfcn.h>
void *dlopen(const char *filename, int flag); //若成功就返回指向句柄的指针,否则返回 NULL。
参数:
filename
:要打开的共享库文件的名称或路径。通常,你可以提供库的相对或绝对路径,或者使用系统默认的库搜索路径。flags
:一个标志位,用于指定dlopen
的行为。以下是一些常见的标志:RTLD_LAZY
:懒加载共享库,只有在需要时解析符号。这可以提高加载速度,但可能导致在运行时出现未解析的符号错误。RTLD_NOW
:立即加载共享库,立即解析库中的所有符号。这可以降低运行时出现未解析的符号错误的可能性,但可能导致加载速度较慢。RTLD_GLOBAL
:将共享库中的符号添加到全局符号表,使它们可以被其他共享库访问。RTLD_LOCAL
:使共享库中的符号仅对本地可见,不会被其他共享库访问。
返回值:
-
如果
dlopen
调用成功,它会返回一个指向共享库的句柄(句柄是一个不透明的指针),可以用于后续的操作。 -
如果
dlopen
调用失败,它会返回NULL
,并且你可以使用dlerror
函数来获取错误信息。
dlsym 函数#
dlsym 函数用于动态加载共享库后,通过库中的符号名称来获取符号的地址,从而调用函数或访问变量。
#include <dlfcn.h>
void *dlsym(void *handle, char *symbol); //若成功,返回指向符号 symbol 的指针,若出错返回 NULL
参数:
handle
:一个指向已加载共享库的句柄,通常是由dlopen
函数返回的。symbol
:要查找的符号的名称,通常是函数名或全局变量名。
返回值:
- 如果
dlsym
调用成功,它会返回指向所请求符号的地址,可以将其转换为适当的函数指针或变量指针并进行调用或访问。 - 如果
dlsym
调用失败,它会返回NULL
,并且你可以使用dlerror
函数来获取错误信息。
注意:要求提前知道动态链接库中的函数名及形参列表,返回类型。
dlclose 函数#
dlclose
用于关闭已加载的共享库(动态链接库)的函数。dlclose 函数会卸载该共享库。
#include <dlfcn.h>
int dlclose(void* handle);
参数:
handle
:一个指向已加载共享库的句柄,通常是由dlopen
函数返回的。
返回值:
- 如果
dlclose
调用成功,它会返回 0,表示共享库已成功卸载。 - 如果
dlclose
调用失败,它会返回非零值,表示出现了错误。你可以使用dlerror
函数来获取错误信息。
dlerror 函数#
dlerror
函数用于获取关于动态链接库(共享库)操作的错误信息
#include <dlfcn.h>
const char *dlerror(void); //如果前面对 dlopen, dlsym, dlclose 的调用失败,则返回用字符串表示的错误消息,否则返回 NULL。
一个例子
动态链接的过程需要依次调用 dlopen, dlsym, dlclose, dlerror 函数。
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main()
{
void *handle;
void (*addvec)(int *, int *, int *, int);
char *error;
/* 动态加载包含 addvec() 函数的共享库 */
handle = dlopen("./libvector.so", RTLD_LAZY);
if (!handle){
// 如果加载共享库失败,打印错误信息并退出
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
/* 获取指向刚刚加载的 addvec() 函数的指针 */
addvec = dlsym(handle, "addvec");
if ((error = dlerror()) != NULL) {
// 如果 dlsym 调用失败,打印错误信息并退出
fprintf(stderr, "%s\n", error);
exit(1);
}
/* 现在我们可以像调用普通函数一样调用 addvec() */
addvec(x, y, z, 2);
printf("z = [%d %d]\n", z[0], z[1]);
/* 卸载共享库 */
if (dlclose(handle) < 0) {
// 如果卸载失败,打印错误信息并退出
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
return 0;
}
Java 就是通过 dlopen 接口加载共享库来调用 C 和 C++ 函数的。
位置无关代码#
共享库的一个主要目的就是允许多个正在运行的进程共享内存中相同的库代码,从而节约宝贵的内存资源。
多个进程如何共享动态库的同一个副本,两种方法:
- 给每个共享库分配一个事先预备的专用的地址空间片,然后要求加载器总是在这个地方加载共享库。这种方法问题很多。
- 使用位置无关代码(Position Independent Code,PIC)。这种方法才是实际采用的方法,列出上面那个就是为了用来衬托这个方法的。
位置无关代码(PIC)可以加载而无需重定位。
用户可以对 GCC 使用 -fpic 选项来生成 PIC 代码。共享库的编译必须总是使用此选项。
库打桩机制#
库打桩(Library Interposition)是一种高级技术,允许你在不修改应用程序或库源代码的情况下,拦截和修改库函数的行为。这是通过引入自定义库(也称为打桩库)来实现的,该库包含与原始库中的函数相匹配的函数,并允许你对它们进行重定向或替换。库打桩的主要目的包括调试、性能分析、监控、安全增强、行为修改等。以下是库打桩机制的详细介绍:
基本步骤:
-
创建自定义库:首先,你需要创建一个自定义库,其中包含要打桩的函数的定义,这些函数的名称和参数列表必须与原始库中的函数匹配。这些自定义函数将成为拦截和修改函数行为的入口点。
-
编译自定义库:使用适当的编译器选项,如
-shared
(生成共享库)和-fPIC
(生成位置无关代码),编译自定义库。这将生成一个共享库文件,你可以将其加载到应用程序中。 -
动态链接:将自定义库与应用程序进行动态链接,可以通过以下方式之一实现:
- 使用环境变量
LD_PRELOAD
:在启动应用程序之前,设置LD_PRELOAD
环境变量,以将自定义库加载到应用程序的地址空间中。 - 使用
dlopen
函数:在应用程序运行时,使用dlopen
函数来加载自定义库。
- 使用环境变量
-
函数重定向:在自定义库中,你可以实现与原始库中的函数同名的函数。这些自定义函数将覆盖原始库函数的行为。你可以在自定义函数中添加额外的逻辑、修改参数、调用原始函数(如果需要)或采取其他操作。
-
调用原始函数:如果在自定义函数中需要调用原始库函数,你可以使用
dlsym
函数来获取原始函数的指针,然后通过该指针调用原始函数。
示例代码:
以下是一个简单的示例,演示了如何使用库打桩拦截和修改 malloc
函数的行为:
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <stdlib.h>
// 原始库函数指针
static void* (*real_malloc)(size_t size) = NULL;
// 自定义的malloc函数
void* malloc(size_t size) {
if (!real_malloc) {
// 获取原始库函数的地址
real_malloc = dlsym(RTLD_NEXT, "malloc");
}
// 在调用原始malloc之前可以添加自定义逻辑
printf("Custom malloc called with size: %zu\n", size);
// 调用原始malloc函数
void* ptr = real_malloc(size);
// 在调用原始malloc之后可以添加自定义逻辑
return ptr;
}
这个示例中,使用库打桩机制自定义了 malloc
函数,拦截了它的调用,添加了自定义的输出和逻辑,并最终调用了原始的 malloc
函数。
注意事项:
- 库打桩是一种强大的技术,但要小心使用,以确保不会引入不稳定性或不必要的风险。
- 库打桩通常需要为特定的应用程序和库进行定制。
- 在某些情况下,库打桩可能需要操作系统或编译器的特定支持。
- 库打桩可以用于调试、性能分析、监控、安全增强、行为修改等不同的用途。
处理目标文件的工具#
Linux 系统中可以帮助处理目标文件的工具:
- AR:创建静态库,插人、围除、列出和提取成员
- STRINGS:列出一个目标文件中所有可打印的字符串。
- STRIP:从目标文件中附除符号表信息。
- NM:列出一个目标文件的符号表中定义的符号。
- SIZE:列出目标文件中节的名字和大小。
- READELF:显示一个目标文件的完整结构,包括ELF头中编码的所有信息,包含SIZE和NM的功能,
- OBJDUMP:所有二进制工具之母,能够显示一个目标文件中所有的信总。它最大的作用是反汇编.text节中的二进制指令。
Linux系统为操作共享库还提供了LDD程序:
- LDD:列出一个可执行文件在运行时所需要的共享库。
总结#
链接可以在编译时由静态编译器完成(静态库的链接),也可以在加载和运行时由动态链接器完成(动态库的链接)。
链接器处理的文件是目标文件,目标文件是一种二进制文件,有 3 种不同形式:
- 可重定位目标文件:
- 可执行目标文件:静态链接器将多个可重定位目标文件合并成一个可执行目标文件,它可以加载到内存中并执行。.exe 文件就是可执行目标文件。
- 共享目标文件(共享库):运行时由动态链接器链接和加载。
链接器的两个主要任务:
- 符号解析:将目标文件中的每个全局符号都绑定到一个唯一的定义。
- 重定位:确定每个符号的最终内存地址,并修改对那些目标的引用。
静态链接器是由 GCC 这样的编译驱动程序调用的。它们将多个可重定位目标文件合并成一个单独的可执行目标文件。多个目标文件可以定义相同的符号,链接器可以按照一定规则来解析这些相同的符号。
多个目标文件可以被连接到一个单独的静态库中。链接器用库来解析其他目标模块中的符号引用。许多链接器都是通过从左到右的顺序扫描库来解析符号引用。
加载器将可执行文件的内容映射到内存,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的例程和数据的为解析的引用。在加载时,加载器将部分链接的可执行文件映射到内存,然后调用动态链接器,动态链接器通过加载共享库和重定位程序中的引用来完成链接任务。
被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数和数据,应用程序也可以在运行时使用动态链接器。
作者:keep--fighting
出处:https://www.cnblogs.com/keep--fighting/p/18073058
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!