深入理解程序链接与加载—从动静态链接到库加载

目录

一、静态链接

二、ELF加载与进程地址空间

三、动态库加载

四、动态链接

五、库间依赖

六、结语


一、静态链接

code.c

cpp 复制代码
#include<stdio.h>
void run()
{
    printf("running...\n");
}

hello.c

cpp 复制代码
#include<stdio.h>
void run();
int main()
{
    printf("hello world!\n");
    run();
    return 0;
}

无论是自己的.o文件还是静态库中的.o文件,本质都是把.o文件进行连接的过程。因此,研究静态连接,本质就是研究.o文件是如何连接。

objdump -d命令用于将代码段.text进行反汇编查看:

bash 复制代码
objdump -d code.o # 查看编译后的code.o目标文件
bash 复制代码
objdump -d hello.o # 查看编译后的hello.o目标文件

通过objdump反汇编查看code.o、hello.o目标文件,如下图所示,可以看到code.o、hello.o都调用了call指令:

code.o

hello.o

call分别对应调用的printf和run函数,但可以看到其函数跳转地址都设置成了0。这是因为在编译hello.c时,编译器完全不知道printf和run函数的存在。因此,编译器只能将这两个函数的跳转地址先暂时设置为0。

这个地址将在链接时被修正,为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在一个重定位表,这张表将来在链接的时候,就会根据表里记录的地址将其修正。

bash 复制代码
readelf -s code.o # 读取code.o符号表

code.o目标文件的符号表如上图所示,puts即为printf的实现,UND表示undefine,意思是未定义,通俗地讲就是code.o文件找不到printf的实现。

bash 复制代码
readelf -s hello.o # 读取hello.o符号表

hello.o目标文件符号表如上图所示,puts即为printf的实现,run函数在hello.o中未定义,二者均为UND,表示在hello.o目标文件中找不到printf、run函数的实现。

bash 复制代码
readelf -s main.exe # 读取main.exe的符号表

main.exe可执行程序的符号表如上图所示,可以看到code.o和hello.o两个目标文件进行合并之后,在最终的main.exe可执行程序中,就找到了run。0000000000401126为run函数的地址,FUNC表示run符号类型是个函数,13就是run函数所在的section被合并最终的那一个section中了,13就是下标。

bash 复制代码
readelf -S main.exe # 读取可执行程序最终的所有的section清单

可以看到,hello.o和code.o的.text被合并了,是main.exe的第13个section。

bash 复制代码
objdump -d main.exe # 反汇编查看main.exe代码段

如上图所示,可以看到两个.o的代码段合并到了一起,并进行了统一的编址。链接的时候,会修改.o中没有确定的函数地址,在合并完成之后,进行相关call地址,完成代码调用。

静态链接就是把库中的.o进行合并,和上述过程一致。链接本质上就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼接成一个独立的可执行文件,其中就包括地址修正。当所有模块组合在一起之后,链接器会根据.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址,这就是静态链接的过程,链接过程中会涉及到对.o中外部符号进行地址重定位。

二、ELF加载与进程地址空间

一个ELF程序,在没有被加载到内存的时候,本身就有地址,当代计算机工作的时候,都采用平坦模式进行工作。所以也要求ELF对自己的代码和数据进行统一编址,下图为objdump -S反汇编之后的结果:

最左侧的就是ELF的虚拟地址,严格意义上称为逻辑地址(起始地址+偏移量),但一般认为起始地址是0。也就是说,虚拟地址在程序还没有加载到内存的时候,就已经把可执行程序进行统一编址了。

进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的start,end等范围数据,另外再用详细地址,填充页表。

因此:虚拟地址机制,不光光OS要支持,编译器也要支持。

如上图所示,ELF在被编译好之后,会把自己未来程序的入口地址记录在ELF header的Entry字段中。

以上两幅图分别展示了程序加载到内存的物理映射过程和进程虚拟地址空间的组织结构。

三、动态库加载

上图展示了操作系统中进程A通过虚拟地址空间、页表映射到物理内存以及从磁盘加载动态库的过程。进程A拥有包含共享区、数据区和代码区的内存管理结构mm_struct,其虚拟地址空间中的共享区通过页表映射机制指向物理内存。在物理内存中,不仅加载了进程A的数据和代码,还映射了动态链接库XXX.so。当需要该库时,系统会从磁盘中将XXX.so库通过库加载机制载入物理内存,从而实现动态库的调用。

上图展示了进程A和进程B通过各自的页表,将虚拟地址空间中的共享区映射到同一块物理内存,从而实现内存共享,同时系统会按需从磁盘加载共享库XXX.so到物理内存中。

四、动态链接

动态链接比静态链接更常用,例如查看main.exe这个可执行程序依赖的动态库,就会发现它就用到了一个C动态链接库:

bash 复制代码
ldd main.exe # 查看main.exe所依赖的动态库列表

如上图所示,libc.so是C语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等等这些功能。那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。但静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,操作系统也会越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。

这个时候,动态链接的优势就体现出来了,可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。

动态链接实际上将链接的整个过程推迟到了程序加载的时候。例如运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。当动态库被加载到内存以后,一旦它的内存地址被确定,就可以去修正动态库中的那些函数跳转地址了。

在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到main函数。实际上,程序的入口点是_start,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。

在_start函数中,会执行一系列初始化操作,这些操作包括:

1、设置堆栈:为程序创建一个初始的堆栈环境

2、初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。

3、动态链接:这是关键的一步,_start函数会调用动态链接器的代码来解析和加载程序所依赖的动态库。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。

动态链接器:

动态链接库(如ld-linux.so)负责在程序运行时加载动态库。

当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。

环境变量和配置文件:

Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置文件来指定动态库的搜索路径。

这些路径会被动态链接器在加载动态库时搜索。

缓存文件:

为了提高动态库的加载效率,Linux系统会维护一个名为/etc/ld.so.cache的缓存文件。

该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。

4、调用__libc_start_main:一旦动态链接完成,_start函数会调用__libc_start_main。__libc_start_main函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库等。

5、调用main函数:最后,__libc_start_main函数会调用程序的main函数,此时程序的执行控制权才正式交给用户编写的代码。

6、处理main函数的返回值:当main函数返回时,__libc_start_main会负责处理这个返回值,并最终调用_exit函数来终止程序。

动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统一编址,采用相对编址的方案进行编制。

动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的。

让进程找到动态库的本质:也是文件操作,访问库函数,通过虚拟地址进行跳转访问,所以需要把动态库映射到进程的地址空间中。

上图展示了Linux系统中进程通过虚拟内存区域VMA和页表映射,将磁盘上的动态库libc.so按需加载到物理内存的机制。对于程序,进行库函数调用,需满足以下几个条件:

1、库已经被映射到了当前进程的地址空间中

2、库的虚拟起始地址已知

3、库中每一个方法的偏移量地址已知

所有访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法。而且整个调用过程,是从代码区跳转到共享区,调用完毕再返回到代码区,整个过程完全在进程地址空间中进行的。

上图展示了进程调用动态库时从编译链接到加载执行的内存映射全过程。

动态链接会在.data中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。

因为.data区域是可读写的,所以可支持动态进行修改

bash 复制代码
readelf -S a.out # 查看ELF文件节头表
bash 复制代码
readelf -l a.out # 查看ELF文件程序头表及段到节的映射关系

如上图所示,.got在加载的时候,会和.data合并成为一个segment,然后加载在一起。

1、由于代码段只读,不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。

2、在单个.so下,由于GOT表与.text的相对位置是固定的,因此可以利用CPU的相对寻址来找到GOT表。

3、在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。

4、这种方式实现的动态链接就被叫做PIC地址无关代码。即动态库不需要做任何修改,就能被加载到任意内存地址正常运行,并且能够被所有进程共享,PIC=相对编址+GOT。

五、库间依赖

不仅仅有可执行程序调用库,库也会调用其他库,即库之间是有依赖的,那么如何做到库和库之间互相调用也是与地址无关的呢?库中也有.GOT,和可执行一样,这也就是库也是ELF格式的原因。

由于GOT表中的映射地址会在运行时修改,可以通过gdb调试去观察GOT表的地址变化。

由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,操作系统做了一些优化,例如延迟绑定,也叫PLT。与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。

具体思路是:GOT中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码/stup。在第一次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。当再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。

动态链接实际上将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时,它虽然牺牲了一定的性能和程序加载时间,但能够更有效的利用磁盘空间和内存资源,极大方便了代码的更新和维护,更关键的是,它实现了二进制级别的代码复用。解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程。

六、结语

静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立的测试和开发自己的模块。通过静态链接,生成最终的可执行文件。静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,其中可以去修正模块间函数的跳转地址,也被叫做编译重定位。而动态链接实际上将链接的整个过程推迟到了程序加载的时候。例如运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用,即运行重定位。深刻理解这一机制,不仅是系统级开发与性能调优的基石,也是在更底层、更宏观的维度上建立起对程序生命周期完整而清晰的认知。

相关推荐
影视飓风TIM1 小时前
Linux 进程信号原理与剖析
linux·运维·服务器
缘友一世2 小时前
开源 Linux截图工具闪构(SnapForge)
linux·运维·ubuntu·开源·截图
朱包林3 小时前
iTerm2终端配置指南
linux·javascript·python·云原生·容器·kubernetes·云计算
祖力553 小时前
Linux系统移植、烧录与环境的搭建
linux·运维·服务器
张小姐的猫4 小时前
【Linux】网络编程 —— 多路转接:epoll
linux·运维·网络
宋明河4 小时前
五种 IO 模型与 select、poll、epoll 对比
linux
玄芯散人4 小时前
【金丹·81】性能分析:你的程序慢在哪
linux·性能优化·perf
天意__4 小时前
Linux部署Qwen轻量模型
linux·服务器·qwen
木卫二号Coding5 小时前
第八十六篇-Strata 部署指南(Ubuntu 22.04 + Tesla V100【单卡】)
linux·运维·人工智能·ubuntu