当前位置: 首页 > article >正文

动静态链接与加载

目录

静态链接

ELF加载与进程地址空间(静态链接)

动态链接与动态库加载

GOT表


静态链接

对于多个.o文件在没有链接之前互相是不知到对方存在的,也就是说这个.o文件中调用函数的的跳转地址都会被设定为0(当然这个函数是在其他.o文件中定义的)这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正 的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表⾥记录的地址将其修正。这也就是为什么.o文件叫做可重定位文件。

所以,链接过程中会涉及到对.o中外部符号进⾏地址重定位。

ELF加载与进程地址空间(静态链接)

从上面的连接过程可以看到,在我们链接完成的之后形成的可执行程序中是有地址的,这个时候程序显然没有加载到内存中那这个地址就不可能是内存中的物理地址。事实上这个地址是一种逻辑地址,其思想与虚拟地址类似,也与虚拟地址对应,也就是说磁盘上的逻辑地址就是以后运行可执行程序时的虚拟地址。在当代计算机内部,这个逻辑地址采用平坦模式进行编址(也就是从0开始编址)。所以也要求ELF文件对自己的代码和数据进行统一编址。

简直巧妙,原来虚拟地址跟磁盘中可执行文件的逻辑地址是对应的。我们知道可执行程序的执行需要os创建子进程来执行,那么mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来?就从逻辑地址来。从ELF各个segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start, end] 等范围数据,另外再⽤详细地址,填充⻚表。

mm_struct  描述进程的整个虚拟地址空间,包含所有 vm_area_struct 的链表或红黑树。例如:

struct mm_struct {
    struct vm_area_struct *mmap;  // 虚拟内存区域链表
    unsigned long start_code;      // 代码段起始地址(ELF的 .text)
    unsigned long end_code;
    unsigned long start_data;     // 数据段起始地址(ELF的 .data)
    unsigned long end_data;
    // ...
};

vm_area_struct   描述一个连续的虚拟内存区域(如一个ELF段),包括权限、文件映射信息等。

struct vm_area_struct {
    unsigned long vm_start;        // 起始虚拟地址(ELF的 p_vaddr)
    unsigned long vm_end;          // 结束虚拟地址
    struct file *vm_file;          // 关联的ELF文件
    unsigned long vm_pgoff;        // 文件中的偏移(对应ELF段在文件中的位置)
    pgprot_t vm_page_prot;         // 访问权限(如可读、可执行)
    // ...
};

示例:ELF加载到虚拟地址空间

假设一个ELF文件有两个可加载段:

  1. 代码段:.textp_vaddr = 0x400000p_memsz = 0x1000

  2. 数据段:.datap_vaddr = 0x401000p_memsz = 0x2000

进程创建时,内核会:

  1. 创建两个 vm_area_struct

    • 代码段:vm_start=0x400000vm_end=0x401000, 权限为 RX(读+执行)。

    • 数据段:vm_start=0x401000vm_end=0x403000, 权限为 RW(读+写)。

  2. 通过 mmap 将这两个段映射到虚拟地址空间,但物理内存尚未分配。

  3. 程序先加载到内存,用虚拟地址初始化了mm_struct,当进程首次执行 0x400000 处的指令时,触发缺页中断,内核将 .text 段的内容从磁盘加载到物理内存,并更新页表。

问题是cpu怎么知道从哪里开始执行呢?ELF文件的LEF Header中有一个Entry point address 这个就是程序的入口地址。cpu中有一个寄存器EIP其中存放的是当前执行指令的下一条指令的地址,CR3寄存器执行页表。所以当程序开始执行的就时候就将Entry point address中的地址load到cpu中的EIP寄存器中,然后程序从入口开始执行。

动态链接与动态库加载

我们知道动态库跟我们编译链接好的可执行和程序之间是独立的存在于磁盘的。

我们的所有依赖于动态库的可执行文件都依赖于一个这个库:/lib64/ld-linux-x86-64.so.2,lib64/ld-linux-x86-64.so.2 是 Linux 系统中的一个动态链接器库文件,主要用于在程序运行时动态加载和链接共享库(.so 文件)

在我们要运行可执行程序时,我们先是跟静态库一样的过程,先通过Entry point address找到程序的入口,事实上程序的入口就是_start函数,这是一个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数。在_start函数中会执行一下一系列操作:

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

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

3.动态链接:_start函数会调用动态链接器的代码来解析和加载程序运行所需要的动态库,动态连接器会处理所有的符号解析和重定位,确保程序中的调用函数和变量访问能够正确的映射到动态库中的实际地址。(动态链接实际上将链接的整个过程推迟到了程序加载的时候

动态连接的优点:可以看到对于不同的进程如果需要同一个库中的函数,我们只需要在内存中加载一份动态库,分配一份物理地址即可,但是对于静态库来说,其可执行文件就是已经包含静态库中的函数的了,所以其磁盘空间和内存空间都是会产生浪费的。

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

当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
环境变量和配置⽂件
Linux系统通过 环境变量(如LD_LIBRARY_PATH) 配置⽂件(如/etc/ld.so.conf 及其⼦配置
⽂件)来指定动态库的搜索路径。 这些路径会被动态链接器在加载动态库时搜索。
缓存文件
为了提⾼动态库的加载效率,Linux系统会维护⼀个名为 /etc/ld.so.cache的缓存⽂件 。 该⽂件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会⾸先 搜索这个缓存⽂件。
4.调⽤ __libc_start_main :⼀旦动态链接完成, _start 函数会调⽤ __libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执⾏ ⼀些额外的初始化⼯作,⽐如设置信号处理函数、初始化线程库(如果使⽤了线程)等。
5. 调⽤ main 函数:最后, __libc_start_main 函数会调⽤程序的 main 函数,此时程序的执⾏控制权才正式交给⽤⼾编写的代码。
6. 处理 main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回
值,并最终调⽤ _exit 函数来终⽌程序。
上述过程描述了C/C++程序在 main 函数之前执⾏的⼀系列操作,但这些操作对于⼤多数程序员来说 是透明的。程序员通常只需要关注 main 函数中的代码,⽽不需要关⼼底层的初始化过程。然⽽,了解这些底层细节有助于更好地理解程序的执⾏流程和调试问题。

但是我们的程序具体是怎么和库映射起来的?

首先可执行程序中存有依赖的动态库的路径,通过这个路径可以将动态库加载到物理内存。动态库也是采用了平坦模式进行编址,我们叫做库中方法的偏移量。然后通过创建新的mm_area_struct用库的大小开辟一段新的进程地址空间,就能得到库的虚拟地址,并建立页表映射关系。通过库的虚拟地址和库中的偏移量就能找到对应的方法。


 所以库函数的调用机制如下:                                                                                                         库已经被我们映射到了当前进程的地址空间中 库的虚拟起始地址我们也已经知道了,库中每⼀个   ⽅法的偏移量地址我们也知道

 所有:访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅法

 ⽽且:整个调⽤过程,是从 代码区跳转到共享区,调⽤完毕在返回到代码区 ,整个过程完全在进   程地址空间中进⾏的。

GOT表

也就是说,我们的程序运⾏之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道
然后对我们加载到内存中的程序的库函数调⽤进⾏地址修改,在内存中⼆次完成地址设置 (这个叫做加载地址重定位) 但是内存中的代码段是不可写的。所以:动态链接采⽤的做法是在.data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。

那GOT具体是怎么工作的呢?     比如,程序在编译时,对于外部函数比如printf,编译器并不知道它运行时的具体地址,所以会在GOT中生成一个条目。当程序第一次调用printf时,动态链接器(如ld-linux.so)会找到printf的实际地址并填入GOT中,之后的调用就直接使用这个地址了。这样可以实现延迟绑定,也就是PLT(Procedure Linkage Table)和GOT配合使用。PLT负责跳转到GOT中的地址,而GOT存储实际的地址。第一次调用时,GOT中的地址可能指向PLT中的解析代码,由动态链接器完成地址解析后,GOT中的条目会被更新为正确的地址。另外,GOT还可能用于全局变量的访问,因为动态库中的全局变量地址在加载时确定,也需要通过GOT来间接访问。

但在不 同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上 ,就是每个进程的 每个动态库都有独⽴的GOT表 ,所以进程间不能共享GOT表。
在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找
到GOT表。
在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会
被修改为真正的地址。
这种⽅式实现的动态链接就被叫做 PIC 地址⽆关代码 。换句话说,我们的动态库不需要做任何修
改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给
编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
总结: GOT表中存储的地址应该是虚拟地址。当程序执行跳转指令时,使用这个虚拟地址,然后由MMU通过页表将其转换为物理地址,从而访问实际的内存位置其他时候都是直接通过页表维持虚拟地址跟物理地址之间的关系。

http://www.kler.cn/a/553712.html

相关文章:

  • GPT-SoVITS更新V3 win整合包
  • 【云原生】SpringCloud-Spring Boot Starter使用测试
  • CST案例:UAV无人机RCS --- 双站,I求解器,比例缩放
  • 大模型驱动的业务自动化
  • 轻量级5G核心网:适应未来网络需求的关键方案
  • 基于VLC的Unity视频播放器(三)
  • DeepSeek VS OpenAI:AI巨头应用对比
  • node.js里的bind,apply, call的区别是什么
  • MoE 与 FFN、Transformer 的关系
  • 以太网交换基础(涵盖二层转发原理和MAC表的学习)
  • 组学数据分析实操系列 |(四) 富集气泡图的绘制
  • Vue 3 使用 Vue-ECharts 的实践心得
  • 用python进行二分法查找(python实例三十)
  • 20250219 隨筆 [特殊字符] 查看短鏈的實現方式與解決方案優化
  • 【Linux】认识协议、Mac/IP地址和端口号、网络字节序、socket套接字
  • 【架构】分层架构 (Layered Architecture)
  • RT-Thread+STM32L475VET6——ADC采集电压
  • 挑选出行数足够的excel文件
  • 同步异步日志系统-日志落地模块的实现
  • 【进阶】redis篇