首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >从ELF的沉默到进程的喧嚣:解读Linux中动态链接如何激活一个可执行文件的完整生命

从ELF的沉默到进程的喧嚣:解读Linux中动态链接如何激活一个可执行文件的完整生命

作者头像
海棠蚀omo
发布2026-01-12 17:30:02
发布2026-01-12 17:30:02
1250
举报

承接于上一篇对ELF文件的剖析,我们今天这一篇就来讲解动态链接与动态库加载的相关知识,与我们之前将讲解的知识完整的串联在一起,对库这一章的内容来个圆满收尾。

动态链接与动态库加载

一.进程如何看到动态库?

代码语言:javascript
复制
#include <stdio.h>

int main()
{
    printf("hello world\n");
    return 0;
}

在讲解这一部分的内容之前我们先回顾一下在库的制作与使用中我们关于动态库的内容。

在那一篇中我们讲进行链接的时候默认就是动态链接,而动态链接与静态链接不同的是,静态链接在链接的过程中是将静态库中的代码拷贝到可执行文件中,所以当形成可执行文件后,其不再依赖静态库。

动态链接则不一样,并不会将动态库中的代码拷贝到可执行文件中,这也就导致即使形成可执行文件后照样也要依赖动态库,如何体现呢?

当我们运行可执行文件,也就是加载可执行文件到内存的时候,需要同时把动态库文件也加载到内存中,那么此时就可以引出我们这部分要讲的内容了:可执行文件加载到内存中形成进程后,是如何看到动态库的?

一张图就可以将上面的问题给回答了,当动态库加载到内存中,也会有其对应的物理地址,那么就可以在页表建立虚拟地址和物理地址之间的映射关系,进而映射到进程虚拟地址空间的共享区!!!

我们应该见过这张图,而动态库就映射到虚拟地址空间中位于栈和堆之间的共享区中!!!

并且通过上面的ldd命令查看a.out可执行文件所依赖的库,可以看到其并不只依赖一个动态库文件,而有多个动态库文件,而它们加载到内存时都会映射到虚拟地址空间的共享区中,也就是说一个进程的虚拟地址空间的共享区可以同时映射很多的库!!!

所以我们现在就知道了上面问题的答案:进程是通过自己的虚拟地址空间的共享区看到动态库的!!!

二.进程间如何共享库的?

可是当我们查看linux中内置命令所依赖的库时,可以发现有很多库都是重复的,就拿C语言标准库来说,这里面每一个命令的实现都依赖于C语言标准库。

那么当我们执行这些命令时,它们加载到内存中后也是一个进程,也要把它们所依赖的动态库加载到内存中,那么我问大家一个问题:就如上面的C语言标准库,每启动一个进程都需要把它在内存中重新加载一份吗?

答案肯定不会的,如果这样做,上面的每个命令所依赖的C语言标准库是一样的,即使都加载到内存中那也是一样的内容,但是在内存中不就出现了重复的代码吗?进而不就造成了内存空间的浪费吗?

所以为什么动态库映射到虚拟地址空间的区域叫做共享区啊?

就是因为一个动态库可以映射到多个进程的虚拟地址空间中,换句话说就是一个动态库可以被多个进程所共享!!!

那具体结构是什么样的呢?我们来看看

动态库可以加载到内存的任意位置,而只要动态库的物理地址确定了,那么在页表中其虚拟地址和物理地址的映射关系也就确定了,不同的进程页表中的映射关系也不同,但是动态库的物理地址是不变的,到最后每个进程通过共享区看到的动态库是一样的

而如果是静态库,在链接时就将自己的代码拷贝到可执行文件中,就比如将printf函数拷贝进去,那么当可执行文件加载到内存中形成进程后,内存中的进程A代码就有了printf函数的内容,而如果进程B也调用了printf函数呢?

那么是不是在内存中就有了两份printf函数的代码,并且是一样的,这样不就造成了内存空间的浪费了吗?

而动态库只有一份,动态库的代码也只有一份,就没有上面的问题了,也就是动态库或者共享库,可以有效节省内存空间!!!

三.动态链接

3.1动态库中的相对地址

动态库为了随时进行加载,为了支持并映射到任意进程的的任意位置,对动态库中的方法,进行统一编址,采用相对编址的方案进行编址的(其实可执行程序也一样,都要遵守平坦模式,只不过exe是直接加载的)。

这里拿我们比较熟悉的C语言标准库来演示,那什么叫做相对编址呢?

相对编址简单理解就是我不关心你动态库最终被加载到了内存的什么位置,映射到了虚拟地址空间的什么位置,我只关心库中的每个方法相对于库起始位置的偏移量。

而只要你动态库的起始位置确定了,再加上每个方法相对于库起始位置的偏移量,我就能找到每个函数的地址,进而就能调用库中的每个函数!!!

了解动态库的相对编址有助于我们理解下面的知识。

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

在最上面我们只说了动态库将来会加载到内存中,进而映射到进程虚拟地址空间的共享区中,但这中间到底是怎样映射的呢?下面我们就来探讨一下:

通过上面这张图我们就能了解将动态库映射的完整过程,下面我来阐述这整个过程。

当动态库将要被加载的时候,操作系统会在进程虚拟地址空间的共享区选择一块空间给这个动态库,并构建一个vm_area_struct结构体,vm_area_struct描述了这一段连续的虚拟地址空间区域及其属性,然后在页表中建立了一个无效的页表项。

而既然要使用动态库,那么就要打开这个文件,通过vm_area_struct中的struct file*指针可以找到操作系统为动态库所创建的struct file结构体,通过struct file一直往下找,就能找到库文件所对应的inode,找到了inode就能找到其文件内容。

最后将库文件的内容加载到内存中,此时库就有了相应的物理地址,也就能够在页表中建立真正的映射关系了,这整个过程就是如此。

通过这张图我们就与前面讲的内容给串联起来,在这幅图中左边就是虚拟地址空间,中间就是我们前面讲过的文件系统,大家要弄清楚这之间的各种关系。

3.2.1补充知识

这里面有个问题不知道大家有没有想过:同样是打开一个文件,打开动态库文件,操作系统会为其在虚拟地址空间中选择一块区域,并构建相应的vm_area_struct,那么我们打开一个普通文件,操作系统会为其做这种工作吗?

答案并不会,我们要弄清楚打开这两种文件的不同:打开动态库文件是要用它的内容,也就是进程希望以“ 直接内存访问 ”的方式使用文件的内容,这种方式操作系统才会为其做上面的工作。

而我们平常打开的普通文件,都是通过read或者write去读或者往文件写入内容,在进程看来它只是调用一个函数,让内核去“取一些数据来”或“把这些数据存下去”。数据在内核的页缓存和用户提供的缓冲区之间来回拷贝。

这两种打开文件的目的是不同的,大家要弄清楚这二者之间的区别。

3.3我们的程序是怎么进行库函数调用的?

既然弄清楚了我们的程序和库是怎样映射起来的过程,那么我们的程序具体是如何调用库中的各种函数的呢?这里我们同样以一张图来阐述这整个过程。

有了上面内容的铺垫,我们就接着往下讲,我们来看:

就拿这个例子当作我们自己写的代码,代码很简单,我们都能看懂,那么接着看:

而将其链接成可执行文件后,里面调用动态库函数的指令就变为了call 库名称@库函数的偏移量的形式

我们上面讲的动态库采用相对编址的方式让大家理解偏移量就是为了这里,我们要知道在编译形成动态库的时候,每个方法的偏移量值已经有了,但这个工作不是我们来做的,是由编译器来完成的。

而要形成我们的可执行文件,也就是进行链接的时候,库名称保留,修改我们自己程序中调用函数的地址,也就是写成:call 库名称@偏移量的形式,而偏移量我们已经知道,所以这里我们可以将偏移量直接改为实际的库函数偏移量。

而当库加载到内存后,也就是动态加载时,库就有了自己的起始地址,也就是在虚拟地址空间中的起始地址,那么此时就可以计算出我们调用的库函数的完整地址了:

有了库的起始地址,又有库函数的偏移量,直接就是" 左零右火,雷公助我 ",什么都有了,不就得到了库函数的地址了吗?

大家是否感觉上面的操作有点眼熟呢?

眼熟就对了,这不就是我们上一篇讲的静态链接时的地址重定向嘛,不过静态链接是在可执行文件加载到内存之前,而动态链接进行地址重定向是在可执行文件加载到内存之后,两者进行地址重定向的时机是不一样的。

通过地址重定向,我们位于虚拟地址空间中代码区的代码将整个调用过程从代码区就跳转到了共享区,调用完毕后再返回到代码区,整个过程就完全在虚拟地址空间中进行了。

注意:上面的这种写法并不是真实的反汇编信息中的写法,这样写是为了让大家更好的去理解,先用简单的模型让大家建立直觉

讲到这里就终于可以向大家输出结论了:

结论1:库函数调用,也是在虚拟地址空间范围内调用的!!!这在我们上面已经证实过了

结论2:动态库被映射到进程的任意位置(一般是共享区),我们的进程都能调用。因为不管映射到哪个位置,只要映射完我们就知道了库的起始位置,又知道库函数的偏移量,那么不管调用哪个函数都能定位其位置。

结论3:多进程映射的时候,每个进程都会把动态库映射到自己的虚拟地址空间中,但是起始位置可能不同,毕竟每个进程的虚拟地址空间的使用情况也不尽相同,但是不妨碍,任意进程访问库函数。

这个结论3其实还说明了什么问题呢?

我们不关心你动态库加载到了内存的什么位置,又映射到了虚拟地址空间的哪个位置,只要你的位置确定了,我们根据每个库函数的偏移量,就能访问到每一个库函数。

那么上面的这种特性叫什么呢?

这不就是与位置无关嘛,所以为什么我们在形成动态库时,要加上-fPIC的选项啊?

就是为了产生位置无关码,而有了位置无关码,我们访问库函数就与库的位置无关了!!!

3.4我们的可执行程序被编译器动了手脚

问大家一个问题:上面的所有工作是谁在做啊?

可能大家的回答都是操作系统(OS),但是今天我告诉你,上面所有的工作并不是由操作系统自己一个人完成的,它是与别人合作完成的,那么是谁呢?

在我们展示不同的可执行文件的所依赖库信息时,除了上面我们说的C语言标准库,还有这个动态库。

这个动态库叫做动态链接器,你也可以称之为加载器,它就是上面问题的答案,操作系统正是和它一起完成了上面的所有工作!!!

不知道大家有没有想过一个问题:

为什么我们程序的入口地址不是我们的main函数而是_start函数呢?

或者换个问题:既然我们的编译器可以编译我们的文件,那么它是否有修改我们文件内容的权力呢?

答案当然是有的,_start函数编译器加在我们的文件中的,那么这个_start函数有什么用呢?

这是⼀个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数,在 _start 函数中,会执⾏⼀系列初始化操作,这些操作包括: 1. 设置堆栈:为程序创建⼀个初始的堆栈环境。 2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。 3. 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调⽤和变量访问能够正确地映射到动态库中的实际地址。

完成了这些前置工作,_start函数就为main函数创建了一个合法、稳定、标准的运行时环境,没有_start函数做这些工作,我们main函数根本无法正常执行

所以为什么_start函数是我们可执行程序的入口地址啊?

就是为了先执行_start函数,让它完成设置堆栈等工作,为执行main函数做最后的铺垫!!!

3.5全局偏移量表GOT(global offset table)

有了上面的知识,我们知道了在我们的程序运行之前,要先把所有库加载并映射,所有库的起始虚拟地址都应该知道。

然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中地址重定位,但是大家不觉得这个过程哪里有点问题吗?

我们修改的是代码区?代码不是只读吗?怎么修改?

这就是最大的问题,代码区是只读的,是修改不了的,那么上面的地址重定位是如何做到的呢?

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

用图来表示就是如此,所以我们此时要将上面的call 库函数@偏移量的思维转变为call .got地址+表中偏移量

通过call指令就能跳转到位于数据区的全局偏移量表GOT中,再通过表中偏移量就能找到我们实际要调用的函数地址,也就是:

代码区只读,我们不能直接修改代码段,但有了GOT表,它在数据区中,是可读也可写的,所以可以支持动态进行修改

那么有人就要问了:你说的全局偏移量表GOT在数据区我怎么没见过啊?那么下面我们就来见一见:

可以看到在链接时就已经有.got也就是全局偏移量表GOT了,我们接着看:

当可执行程序加载到内存时,可以看到.got和.data合并在了一起,所以这也就证实了我们上面说的全局偏移量表GOT是在数据区中。

所以我们在调用函数的时候首先查表,然后根据表中的地址来进行跳转,而这些地址在动态库加载的时候会在全局偏移量表GOT中被修改为真正的地址!!!

3.6库间依赖(了解即可)

我们要知道不仅仅是可执行程序会调用库,库也会调用其他库!!!

库之间也是有依赖的,那么如何做到库与库之间互相调用也是与地址无关的呢?

答案和我们的可执行文件是一样的,库中也有全局偏移量表GOT,这也就是为什么大家都是ELF的文件格式!!!

最后附上图来帮助大家更好地理解。

以上就是从ELF的沉默到进程的喧嚣:解读Linux中动态链接如何激活一个可执行文件的完整生命的全部内容。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-11-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 动态链接与动态库加载
    • 一.进程如何看到动态库?
    • 二.进程间如何共享库的?
    • 三.动态链接
      • 3.1动态库中的相对地址
      • 3.2我们的程序是怎么和库具体映射起来的?
      • 3.3我们的程序是怎么进行库函数调用的?
      • 3.4我们的可执行程序被编译器动了手脚
      • 3.5全局偏移量表GOT(global offset table)
      • 3.6库间依赖(了解即可)
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档