源文件生成可执行文件的过程?
源文件经过预处理、编译、汇编、链接生成一个可执行的目标文件。
编译器驱动程序,包括预处理器、编译器、汇编器和链接器。Linux用户可以调用GCC驱动程序来完成整个编译流程。
使用GCC驱动程序将示例程序从ASCII码源文件转换成可执行目标文件的步骤。
编译执行:
gcc -Og -o prog main.c sum.c ./prog
- 预处理:调用预处理器,对源文件main.c进行预处理,去除注释、展开宏定义等,生成一个中间的ASCII码文件main.i。
- 编译:调用C编译器,将预处理后的中间文件main.i转换成ASCII码的汇编语言文件main.s。
编译阶段是指编译器读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码。
- 汇编:调用汇编器,将汇编语言文件main.s翻译成可重定位目标文件main.o。同样的过程也会应用于sum.c,生成相应的sum.o。
- 链接:调用链接器将main.o和sum.o以及其他必要的系统库文件链接在一起,创建出最终的可执行目标文件prog。
- 加载:将可执行程序加载到内存并进行执行。当shell执行./prog命令时,操作系统内的加载器(loader)将会把可执行文件prog中的代码和数据加载到内存中,并设置好运行环境,然后将控制权转移给程序的入口点,从而启动程序执行(详细见程序的加载)
什么是链接?
链接是将不同目标模块整合为单一的可执行文件。链接可以在程序的不同阶段进行。静态链接编译期间进行的链接,动态链接在运行期间进行的链接。
通过链接可以把程序分散到不同的小的源代码中,而不是一个巨大的类中。这样可以复用常见的功能。
目标文件(模块)由段组成(见ELF格式),主要有以下三种类型:
(1)可重定位目标文件
由汇编器处理后的机器码和数据(.s->.o),还没有进行重定位和符号解析。多个可重定位目标文件可以合并为一个单一的可执行目标文件。
(2)可执行目标文件
可以直接被操作系统加载到内存中执行。
共享目标文件
指动态链接库,在Linux系统中表现为.so文件,在Windows系统中表现为.dll文件。它们同样是可重定位目标文件的一种,但在程序运行时动态加载到内存,并与应用程序进行链接。这种机制允许多个进程共享同一份代码,节省资源并提高内存利用率。
目标文件有统一的格式ELF,可重定位目标文件常见的段包括:
.text | 代码段(指令、机器码) |
.rodata | 只读数据 |
.data | 已初始化的全局和静态变量。 |
.bss | 未初始化(或初始化为0)的全局变量和静态变量。 |
symtab | 符号表,存放在程序中定义和引用的函数与全局变量的信息(局部变量不存放) |
.rel.text | .text节的重定位信息,当链接时,指令的位置就被修改 |
.rel.data | .data节的重定位信息,当链接时,变量的位置就被修改 |
.debug | 调试符号表 |
Section Header Table | 节头部表,存放每个节的偏移和大小 |
符号表记录了模块内定义和引用的各种符号信息。符号表存放如下三种符号:全局变量、外部符号,静态全局变量,这些符号对本模块是随处可见的,但不能被其他模块所引用。局部变量会由栈来管理。局部静态变量,会存放到.bss或者.data节中,也不由符号表来管理。
链接的工作:符号解析和重定位。
(1)符号解析
符号解析把所有目标文件中每一个符号引用都能够绑定到对应的符号定义上。按顺序需要遍历所有输入的目标文件,找出并解决符号依赖关系,确保没有未定义的外部引用。
(2)重定位(Relocation)
编译器和汇编器在生成目标文件时,会为代码和数据分配相对的虚拟地址空间。链接器需要确定每个符号的实际内存地址,并据此更新目标文件中所有的地址引用。利用汇编器产生的重定位表,对每个引用该符号的位置进行调整,使它们指向正确的运行时内存位置。
符号解析:链接器会处理三种符号全局符号(全局变量)、外部符号(外部变量)、本地符号(静态符号)。
编译器只允许模块中的本地符号只有一个定义。当编译器遇到一个不是在本模块中定义的符号时,假设在某个其他模块中定义的,并把后续工作交给链接器处理。当编译器遇到不在当前模块中定义的符号引用时,链接器遍历所有输入目标文件,寻找匹配的全局符号定义。若找不到定义,则会报错终止链接过程。
符号冲突:具有相同全局符号名但在不同目标文件中都有定义的情况,链接器需要解决符号冲突。一次定义原则:即同一个全局符号只能有一个有效定义。
强符号函数和初始化的全局变量。弱符号未初始化的全局变量。不允许多个强符号名称重复。若一个强符号和多个弱符号名称重复,选择强符号;若多个弱符号名称重复,从中任选其一。
关于重载函数的链接问题,译器将函数的名称和参数类型信息一起编码成一个独一无二的、对链接器友好的名称。
重定位
重定位的任务是将输入的各个目标模块合并成一个单一的可执行文件,并为每个符号分配运行时内存地址。链接器将所有输入模块中相同类型的节合并成一个新的聚合节。(所有输入模块的.data节会被合并成输出可执行文件中的一个.data节)。链接器为这个新的聚合节、输入模块定义的每个节以及每个符号赋予运行时内存地址。这样一来,程序中的每条指令和全局变量都将拥有一个唯一的运行时内存地址。
链接器会对代码节和数据节中的每个符号引用进行修改,指向正确的运行时地址。为此,链接器依赖于可重定位目标模块中预先定义好的重定位条目(relocation entry)。重定位条目是一种数据结构,它记录了目标模块中需要被修改的位置以及如何修改这些位置以适应运行时环境的信息。链接器根据这些重定位条目,逐一修改符号引用,确保在程序执行时能够准确寻址到对应的符号定义。重定位条目:在汇编器生成目标模块时,由于它无法预知模块中的数据和代码在内存中的具体加载位置,也无法确定模块所引用的外部函数或全局变量的具体位置,因此每当遇到这类不确定位置的引用时,汇编器会在目标文件中生成相应的重定位条目。这些重定位条目指示链接器在合并目标文件生成可执行文件的过程中如何正确地修正这些引用。
静态链接
静态链接在程序运行之前,将目标模块和所需的静态库链接成一个完整的可执行文件。静态库将一组相关的可重定位目标文件(.o文件)打包成单个.a文件。(Linux系统中,静态库以一种存档的特殊文件存放在磁盘中,存档文件由.a标识)。
模块化:将函数编译为独立的目标模块,便于管理和更新。
(2) 按需链接:链接器在链接过程中仅将应用程序实际引用的库模块加入到最终可执行文件中,避免了资源冗余。
链接器是如何使用静态库解析引用?
如果静态库之间是相互独立的,库可以任意顺序排列在命令行的末尾。如果库之间存在依赖关系,即某个库的成员引用了另一个库中定义的符号,那么必须按照依赖关系正确排序库的顺序。因为链接是按顺序扫描目标文件和静态库文件,所以可能存在引用依赖问题的,写编译命令的时候,需要按顺序写。在命令行中,如果定义一个符号的库在引用这个符号的目标文件之前,引用就不会被解析(因为加到了未解析符号引用的集合),因此链接会失败。
动态链接共享库
静态库虽然有效地解决了函数库复用的问题,但存在一些局限性:
(1)如果静态库中有bug 修复时,所有依赖它的应用程序都需要重新编译并链接到更新后的库版本。(改动后需要重写编译)
(2)内存资源浪费:静态链接导致每个使用标准库的应用程序都会包含库函数的副本。当系统运行很多进程时,这些重复的代码片段占用内存资源。
动态链接
程序运行时第一次调用某个库函数的时候,由动态链接器负责将程序与共享库链接起来。共享库是存放在磁盘上的对象(unix .so,Windows DLL),可以在运行时使用内存映射的方式加载到程序的共享内存映射区域。
动态链接也可以在程序开始执行的时候完成,在Linux 中使用 dlopen()接口来完成(会使用函数指针),而且共享库也可以在多个进程间共享。
共享库(动态链接)的优点
(1)内存优化:共享库中的代码在物理内存中只有一份拷贝,所有需要它的进程都可以共享同一份代码,显著减少了内存开销。
(2)当共享库有更新时,只需要替换系统上的库文件即可。已安装的应用程序在下次运行时会自动链接到新版本的库,无需重新编译或重新发布整个应用程序。
在Linux系统中,运行时动态加载和链接共享库的功能通常通过dlopen() 和dlsym() 等函数实现,这些函数属于动态链接器接口的一部分。dlopen() 用于打开并加载指定的共享库,而 dlsym() 则用于从已加载的库中获取函数指针,以便后续调用。 C标准库默认使用动态链接。编译时候加-static选项可以使用静态链接。
动态链接库如何加载到内存中的?
当动态库第一次被链接器加载到内存参与动态链接时,动态库映射到了当前进程虚拟空间的mmap区域,动态链接和重定位结束后,程序就开始运行。当程序访问mmap映射区域,去调用动态库的一些函数时,发现此时还没有为这片虚拟空间分配物理内存,就会产生一个请页异常。内核接着会为这片映射内存区域分配物理内存,将动态库文件libtest.so加载到物理内存,并将虚拟地址和物理地址之间的映射关系更新到进程的页表项,此时动态库才真正加载到物理内存,程序才可以正常运行。
对于已经加载到物理内存的文件,Linux内核会通过一个radix tree(基数树)的树结构来管理这些页缓存对象。当进程B运行也需要加载动态库libtest.so时,动态链接器会将库文件libtest.so映射到进程B的一片虚拟内存空间上,链接重定位完成后进程B开始运行。当通过映射内存地址访问libtest.so时也会触发一个请页异常,Linux内核在分配物理内存之前会先从radix tree树中查询libtest.so是否已经加载到物理内存,当内核发现libtest.so库文件已经加载到内存后就不会给进程B分配新的物理内存,而是直接修改进程B的页表项,进程B中的这片映射区域直接映射到libtest.so所在的物理内存上。
基数树阅读材料:Radix Tree用法-CSDN博客
打包静态库:
gcc add.c -c -o add.o
gcc swap.c -c -o swap.o
ar rcs打包静态库
ar rcs libcalc.a swap.o add.o
打包动态库:使用gcc在链接的时候生成共享库文件(.so)