链接(linking)是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,这个文件可被加载到存储器并执行。链接可以执行于编译时(compile time),也可以执行于加载时(load time),甚至执行于运行时(run time)。
链接器(linker)在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。
了解链接的好处:
- 模块化编译有助于构建大型应用
- 了解链接的规则有助于意识到报错原因
- 与程序的运行紧密相关,有助于了解基本概念
- 有能力应用共享库、动态链接机制
编译器驱动程序
大多数编译系统提供编译驱动程序(compiler driver),为用户根据需求调用语言预处理器、编译器、汇编器和链接器。
静态链接
以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出。
cpp->c preprocesser; cc1->c compiler; as->assembler.
为了创建可执行文件,链接器必须完成两个主要任务:
- 符号解析(symbol resolution),将一个符号引用和一个符号定义结合起来
- 重定位(relocation),编译器和汇编器生成从地址 0 开始的代码和数据段。链接器通过把每个符号定义与一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些段。
目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些则包含程序数据,而其他的则包含指导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器对目标机器了解甚少。产生目标文件的编译器和汇编器已经完成了大部分工作。
目标文件
目标文件有三种形式:
- 可重定位目标文件,包含二进制文件和代码,其形式在编译时和其他可重定位目标文件合并起来,创建一个可执行目标文件
- 可执行目标文件,可被拷贝到存储器并执行
- 共享目标文件,一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载到存储器并链接。
编译器和汇编器生成可重定位目标文件(包括共享目标文件),链接器生成可执行目标文件
可重定位目标文件
- ELF header,字长、字节序、链接器信息、文件类型(relocatable, executable, shared)、机器型号等
- .text,程序机器码
- .rodata,只读数据,例如字符串、跳转表
- .data,初始化了的global/static变量,注意本地变量存在栈中,而非数据段里
- .bss,未初始化或者零值的global/static变量
- .symtab,符号表,存储被定义或者引用的函数、全局变量
- .rel.text,代码段中需要被替换为直接引用的地方,外部函数调用、全局变量引用
- .rel.data,数据段中需要被替换为直接引用的地方
- .debug,对应-g选项,调试信息
- .line,行号
- .strtab,字符串表
- Section header table,各个段(section)的位置和大小信息
符号和符号表
每个重定位的目标模块都有一个符号表,包含所定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
- 本模块定义并能够被其他模块引用的全局符号
- 由其他模块定义并被当前模块所引用的全局符号
- 只被本模块定义和引用的本地符号
C 程序使用static属性在模块内部隐藏变量和函数声明。关键词的用法确实不同语言有一定差异,问下chatgpt🤪。
顺带着唠叨一句,新技术还是得用。如果有时间会出两期工作上的智能工具使用体验。
copilot 绝对是被国内码农低估的东西
copilot最多算个能帮你节省些体力的sidekick,ChatGPT真的是能分忧解难的consultant
符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义联系起来。
当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,它会假设符号是在其他模块中定义的,生成一个链接器符号表表目,并把它交给链接器处理。
注:函数和变量由于重载等缘故需要进行编码(mangling)和解码(demangling),转换为符号。
链接器如何解析多处定义的全局符号
编译器输出每个全局符号给汇编器,或者是强(stong),或者是弱(week),而汇编器把这些信息隐含地编码在可重定位目标文件的符号表里。函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
根据强弱符号的定义,Unix使用一下规则来处理多处定义的符号:
- 不允许有多个强符号
- 如果有一个强符号和多个弱符号,则选择强符号
- 如果有多个弱符号,则从中随机选择一个
选择的随机性容易导致意想不到的结果,自己编码的时候要注意全局变量的重名问题,尽可能避免。如果程序出现意外的结果,debug要关注(watch)全局变量的变化。
与静态库链接
所有编译系统都提供了一种机制,将所有相关的目标模块打包成一个单独的文件,成为静态库(static library),它也可以作为链接器的输入。
在Unix系统中,静态库以一种存档(archive)的特殊文件格式存储在磁盘中。
gcc -static -o prog2c main2.o ./libvector.a
基于静态库解析引用
链接器维护一组数据:E,可重定位目标文件集合;U,未解析符号;D,已定义。
gcc命令行按顺序处理参数,每处理一个参数(file or lib)就更新这组数据。直至U为空集,否则报错。所以编译命令行中,库文件一般都是放在最后,含有需要解析的引用的源文件放前面。如果出现库文件相互依赖,那么其顺序也要对应调整。
重定位
一旦链接器完成符号解析这一步,就把代码中每一个符号的引用和确定的一个符号定义结合起来。链接器就知道他输入目标模块中的代码段和数据段的确定大小。后面就是重定位的步骤了,
重定位由两个部分组成,核心简单来说就是先分配地址,再将引用替换为地址
- 重定位段和符号定义:聚合相同类型的段,并给段和符号赋予运行时内存地址
- 重定位段中的符号引用,链接器修改代码段和数据段中的每一个符号引用,使得他们指向第一步中设置的内存地址
重定向表目
无论何时汇编器遇到对最终位置未知的目标引用,他就会生成一个重定位表目(relocation entry),告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码段的重定向表目存在.rel.text,数据段的存在.rel.data。
有两种最基本的重定位类型:
- R_X86_64_PC32 : 重定位一个使用32位PC相关的地址引用
- R_X86_64_32 : 重定位一个使用32位绝对地址的引用
可执行目标文件
一个典型的ELF可执行文件中的各类信息
加载可执行文件
每个Unix程序都有一个运行时的存储器映像。一般我们用shell运行二进制文件,这是通过调用操作系统loader代码完成的,loader将文件加载到内存中并跳转到第一条指令,即entry-point。
动态链接共享库
共享库(shared library)是致力于解决静态库缺陷(重复copy同一个库浪费内存、不能动态加载)的一个现代创新产物。共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并和一个在存储器中的程序链接起来。这个过程称为动态链接(dynamic linking),是由一个叫做动态链接器的程序来执行的。
共享库也称为共享目标(shared object),在 Unix 系统中通常用 .so 后缀来表示。微软的操作系统大量地利用了共享库,它们称为 DLL。
动态链接的应用场景:
- 发布版本更新时,用新的so或者dll替换即可,重启软件后动态加载生效
- 构建高效的网站服务器,将动态内容封装到so里,提升响应速度,运行时动态链接也可以避免网页强制下线重编译
如何应用动态链接:用系统接口
Linux systems provide a simple interface to the dynamic linker that allows
application programs to load and link shared libraries at run time.
例如在Java中就有JNI(java native interface)调用的正是基于c或者cpp编译得到的so文件。Java解释器利用操作系统接口动态链接加载so并调用函数。
小结
链接可以在编译时由静态编译器来完成,也可以在加载时和运行时由动态链接器来完成。链接器处理称为目标文件的二进制文件,它又三种不同的形式:可重定位的、可执行的和共享的。
链接器的两个主要任务是符号解析和重定位,符号解析将目标文件中的每个全局符号都绑定到一个唯一的定义,而重定位确定每个符号的最终存储器地址,并修改对那些目标的引用。
加载器将可执行文件的内容映射到存储器,并运行这个程序。链接器还可能生成部分链接的可执行目标文件,这样的文件中有对定义在共享库中的程序和数据的未解析的引用。在加载时,加载器将部分链接的可执行文件映射到存储器,然后调用动态链接器,它通过加载共享库和重定位程序中的引用来完成链接任务。
被编译为位置无关代码的共享库可以加载到任何地方,也可以在运行时被多个进程共享。为了加载、链接和访问共享库的函数和数据,应用程序还可以在运行时使用动态链接器。
评论区