【Linux】动静态库的链接与加载过程

前言:上一篇介绍了动静态库的制作,也简单认识了 ELF 文件。接着这篇接看看函数调用是怎样链接起来的,磁盘上的程序怎样运行起来,以及动态库为什么能被多个进程共享。


1.静态链接是怎么完成的

1.1链接前的目标文件

还是用上篇简单的程序,main 调用另一个文件中的 my_string,再把返回的字符串打印出来。

head.h:

c 复制代码
#pragma once
#include <stdio.h>

const char* my_string();

code.c:

c 复制代码
#include "head.h"

const char* my_string()
{
    return "hello Linux!\0";
}

main.c:

c 复制代码
#include "head.h"

int main()
{
    printf("%s\n", my_string());
    return 0;

}

先看链接之前的 main.o:

bash 复制代码
objdump -d main.o

可以看到两条 call,但后面用于跳转的位移还是占位值。编译 main.c 时,编译器知道要调用 my_string,却还不知道它在最终程序中会被安排到哪里。

于是先生成调用指令,同时记下:这个位置以后需要根据 my_string 的位置进行修正。 这些待处理的位置和对应的符号,记录在重定位信息中。

再看看符号表:

bash 复制代码
readelf -s main.o

main 在这个文件中有定义,而 my_string 和 puts 的 Ndx 是 UND,表示当前目标文件里没有它们的定义,需要到其他地方寻找。

在源码中我们写的是 printf,这里出现 puts,是因为编译器把这种输出字符串再换行的写法改成了 puts 调用。

1.2找到函数,再把调用位置补好

多个目标文件各自都有代码和数据,链接器要先把需要的内容组织到一起。

像图中这样,把各个目标文件里相同用途的节组织到输出文件中,并为它们安排位置。代码放在哪里、数据放在哪里,都需要在这一步确定下来;.bss 则记录运行时需要的空间,不用在磁盘文件里放上一大块零。

接着就能把引用和定义对应起来:main.o 需要 my_string,code.o 正好提供了这个函数,链接器就把两者联系起来。这个过程叫符号解析。

位置确定以后,再根据重定位信息修正相应的引用,让调用能够到达目标函数,这就是重定位。这也是为什么 .o 是可重定向文件的一种。

查看链接后的符号表:

bash 复制代码
readelf -s main.exe

这份程序中,my_string 的地址已经是 0x40052d。再查看反汇编:

bash 复制代码
objdump -d main.exe

main 中对应的 call 已经指向 0x40052d <my_string>。这里的指令保存的是相对位移,CPU 从下一条指令的位置算出要跳到哪里。也就是说,链接器已经把原来空着的跳转信息补好了。

图中的地址对应这一份可执行文件,重新编译或调整链接顺序后,具体数值可能不同,观察它们之间的关系就行。

这样,my_string 的代码已经放进了可执行文件,运行时不需要再去打开 libmydio.a。不过图里的 puts 仍然通过 puts@plt 调用,它使用的是动态库,后面再接着看这部分。

2.程序加载进内容的过程

2.1程序还在磁盘上,为什么就有地址了

刚才还没运行程序,就已经能看到 my_string 的地址。这个地址来自链接时对程序布局的安排。

程序里的虚拟地址可以提前安排,实际使用哪块物理内存,要由操作系统在运行时管理。

下面寻址的核心概念:

名称 表示什么
文件偏移 某段内容在磁盘文件中,距离文件开头有多少字节
虚拟地址 程序运行时,在自己的地址空间里使用的地址
物理地址 内容实际落在物理内存的什么位置

上面的图中的 0x40052d 是给函数安排的虚拟地址。程序运行时,只要把相应的虚拟地址映射到存放函数代码的物理页,就可以找到这些指令。

这是具体的过程抽象图:

2.2建立映射,然后开始执行

执行程序时,exec 会让内核读取 ELF 文件,根据其中的信息建立新程序的内存布局。前面介绍的程序头表就在这里派上用场。

它描述了哪些部分需要加载、内容在文件里的位置、应该放到什么虚拟地址,以及是否可读、可写、可执行。内核主要按其中的可加载段,也就是 PT_LOAD,建立映射;像 .bss 需要的额外空间,则在内存中准备为零。

图中用 libc.so 举例。程序文件和动态库都可以通过这种文件映射,与进程的地址空间联系起来。

对于文件映射,还能沿着文件对象等信息找到背后的磁盘文件。这样,操作系统就知道某个虚拟地址范围,对应文件中的哪一部分内容。

加载时可以先把这些关系建立好,等程序访问到某个尚未准备好的页面,再通过缺页处理把所需内容准备进来。程序很大,也不必一开始就把整个文件全部读进物理内存。

真正执行指令、访问数据时,由 CPU 的 MMU 按页表完成虚拟地址到物理地址的转换。

内存布局准备好以后,还要确定从哪条指令开始执行。

ELF 文件头里记录了入口地址,可以用 readelf -h main.exe 查看 Entry point address。本例的入口是 0x400440,对应 _start;随后由启动代码进入我们写的 main。

对于本例这样的动态链接程序,中间还有动态链接器参与。启动过程可以顺着下面看:

  1. 内核读取可执行文件,建立所需的映射、栈等运行环境。
  2. 根据 ELF 中 PT_INTERP 指定的路径,准备并先运行动态链接器。本例使用 /lib64/ld-linux-x86-64.so.2。
  3. 动态链接器找到程序依赖的共享库,建立映射,完成启动所需的地址修正。
  4. 把执行权交给程序入口 _start,再由启动代码进入 main。

这里内核和动态链接器共同完成了启动准备,程序开始执行自己的代码时,所需的库已经能在它的地址空间里访问了。

3.动态库怎样被加载和调用

3.1多个进程怎样共享一份库

先看看这个程序依赖哪些动态库:

bash 复制代码
ldd main.exe

自己的 my_string 已经静态链接进程序,但输出功能仍然需要 libc.so.6。所以,使用了一个静态库,不代表整个程序都是静态链接的。

动态链接时,生成可执行文件的阶段会留下所需库、符号等信息,并安排好相关的调用入口。等运行时库的加载位置确定下来,再把需要的地址补好。

动态库本身也是 ELF 文件,动态链接器可以读取它的信息,把其中的代码和数据映射到进程的地址空间。程序通过虚拟地址,就能访问库中的内容。

如果好几个进程都要使用同一个库,代码没有必要在物理内存中各放一份。

像图中这样,进程 A 和进程 B 各自维护页表,却可以把相应的虚拟地址映射到同一批物理页。这样,两个进程都能执行库中的代码。

共享的主要是只读代码等内容;各个进程需要修改的普通数据,仍然各自保持独立。 比如库里有一个可写的全局变量,一个进程修改它,不会顺带改掉另一个进程的那份值。

两个进程也不要求把库放在相同的虚拟地址。页表把它们分别联系到同一份物理代码页,就能达到共享的效果。

3.2通过 GOT 和 PLT 找到函数

库已经映射进来了,接下来就是找到要调用的函数。

对于库中一个普通的已定义函数,可以先按下面的关系理解它的位置:

函数运行时的虚拟地址 = 库的加载基址 + 函数在库中的相对位置。

动态链接器根据符号名找到函数的定义,再结合实际加载位置,得到运行时的虚拟地址。

如果每个进程都把这些可能不同的地址改进库的代码里,共享同一份代码就不方便了。因此,需要把可以共同使用的代码 和运行时才确定的地址分开处理。

这就用到了 GOT,Global Offset Table,全局偏移表。可以把它看成一张存放地址的表,需要在运行时确定的目标地址,放到对应表项中。代码通过相对寻址等方式找到这张表,再从表里取出目标地址。

因为代码区是只读的,所以需要这个表来更新并查找动态库里面的方法地址:

这样,地址变化时主要更新相关表项,代码就能够适应不同的加载位置。这也是前面制作动态库时使用 -fPIC,生成位置无关代码的作用。各个进程有自己的地址表和可写数据,同时共享库的代码。

库和库之间也会相互调用,的 liba.so 也可以调用 libb.so 里的函数:在自己的 GOT 表项里记录目标函数的地址,再通过这个地址找到对方。可执行程序调用动态库、动态库调用另一个动态库,都有这样的联系。

再回头看前面反汇编中的 puts@plt,这里的 PLT,Procedure Linkage Table,过程链接表,可以理解成一组负责跳转的短代码。

在这种调用方式中,main 先调用 puts 对应的 PLT 入口,PLT 再读取 GOT 中的地址,跳到真正的函数。

如果采用延迟绑定 ,还可以把某个外部函数的地址解析,留到第一次调用它时再做。以 puts 为例:

  • 第一次调用 :进入 PLT,读取 GOT;此时表项会引向解析流程,由动态链接器找到 puts,把得到的地址写入对应表项,然后执行函数。
  • 后续调用:仍然经过 PLT 和 GOT,但表项已经保存了目标地址,可以直接跳过去,不用再重复查找。

所以,PLT 提供跳转入口,GOT 保存跳转所需的地址,动态链接器负责找到真正的目标。这个过程可以对照 x64 PLT 的说明 来看。


完

相关推荐
多弗朗皮卡丘1 小时前
Linux 进程信号
linux·运维·服务器
^乘风破浪^1 小时前
Kali配置i3wm教程1
linux·kali·i3wm
重生之小比特2 小时前
【Linux系统】17 库制作与原理(上)
linux·运维·服务器
wzg20162 小时前
ubuntu 离线安装 docker-compose
linux·ubuntu·docker
Shadow(⊙o⊙)2 小时前
仿muduo实现高并发服务器——EchoServer完成
运维·服务器
行走的陀螺仪2 小时前
Claude Code 从入门到实战(三):Hooks、Skills、SubAgents 高级扩展
linux·编辑器·vim
Brilliantwxx3 小时前
【STM32】 内部 Flash 的 HAL 库函数详解与自定义封装实战
linux·服务器·网络
时间售卖铺3 小时前
Docker镜像构建全解析:从原理到实战优化
运维·docker·云原生·容器
小豆庄3 小时前
会议录屏忘打码后的技术复盘:本地自动化隐私脱敏方案
运维·opencv·目标检测·自动化·视频·纹理贴图