拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

【Linux指南】动静态库系列(九):动态库如何进入进程地址空间:从磁盘 .so 到共享内存映射

【Linux指南】动静态库系列(九):动态库如何进入进程地址空间:从磁盘 .so 到共享内存映射

文章目录

    • 一、动态库为什么比静态库更常用
    • 二、动态库也是文件
    • 三、动态库加载的整体流程
    • 四、从磁盘 .so 到物理内存
    • 五、从物理内存到进程虚拟地址空间
    • 六、多个进程如何共享同一个动态库
    • 七、共享的是代码,不是什么都共享
    • 八、为什么动态库加载地址不固定
    • 九、使用 /proc 查看动态库映射
    • 十、使用 pmap 查看进程映射
    • 十一、动态链接和静态链接再对比一次
    • 十二、为什么动态库需要后续重定位
    • 十三、总结

前面我们已经知道,动态库.so不是在编译链接阶段完整拷贝进可执行程序,而是在程序运行时被动态链接器加载。

那么问题来了:磁盘上的libxxx.so到底是怎么进入进程地址空间的?多个程序都依赖同一个动态库时,系统真的会给每个进程都复制一份库代码吗?为什么说动态库可以节省内存?

这篇文章就围绕“动态库如何映射到进程地址空间”展开。

一、动态库为什么比静态库更常用

静态链接会把用到的库代码合并进可执行文件。这样程序可以独立运行,但也带来两个问题:

  1. 可执行文件变大。
  2. 多个程序使用同一个库时,会重复保存同一份代码。

假设系统里有 100 个程序都用到了 C 标准库。如果每个程序都把 libc 的代码静态链接进去,磁盘和内存都会浪费大量空间。

动态库的思路是:

把公共代码单独放在 .so 文件中; 程序运行时再加载; 多个进程尽量共享同一份库代码。

这就是动态库广泛使用的重要原因。

二、动态库也是文件

先明确一点:动态库不是内存里凭空出现的东西,它首先是磁盘上的一个普通文件。

例如:

ls-l/lib64/libc.so.6file/lib64/libc.so.6

你会发现它是一个 ELF shared object。

所以加载动态库的第一步,仍然是文件操作:

找到 .so 文件 打开 .so 文件 读取 ELF 信息 把需要的 segment 映射进内存

只不过这个过程通常由动态链接器和内核协作完成,程序员平时不直接感知。

三、动态库加载的整体流程

一个动态链接程序启动时,大致流程如下:

1. 内核加载主程序 ELF 2. 发现主程序需要动态链接器 3. 动态链接器读取主程序依赖的 .so 列表 4. 找到每个 .so 文件 5. 将 .so 的代码段、数据段等映射到进程地址空间 6. 完成必要的符号解析和重定位 7. 进入程序 main 函数

如果第 4 步找不到库,就会出现我们前一篇讲的:

libxxx.so => not found

如果能找到库,下一步就是把库映射到进程地址空间中。

四、从磁盘 .so 到物理内存

当程序依赖libmyc.so时,动态链接器会找到磁盘上的库文件。

然后系统会把库中需要加载的 segment 放入物理内存,尤其是代码段.text对应的内容。

可以先粗略理解为:

磁盘 libmyc.so -> 加载到物理内存中的若干页

现代系统很多时候还会用文件映射、按需分页等机制,并不是一开始就把整个库所有内容都读入内存。但从理解动态库共享的角度,我们先抓住核心:

库文件的内容最终要对应到物理内存页。

五、从物理内存到进程虚拟地址空间

进程不能直接使用物理地址。每个进程看到的是自己的虚拟地址空间。

所以动态库加载后,还需要在当前进程的虚拟地址空间中划出一段区域,用来映射这份库。

可以理解为:

进程虚拟地址空间中的一段共享区 -> 通过页表 -> 映射到物理内存中的 libmyc.so 代码页

例如,进程 A 中libmyc.so可能映射到:

0x7f1000000000 ~ 0x7f1000010000

这只是进程 A 自己看到的虚拟地址范围。

真正的物理内存在哪里,进程并不直接关心。CPU 会通过页表完成转换。

六、多个进程如何共享同一个动态库

假设进程 A 和进程 B 都依赖libmyc.so。

进程 A 启动时:

磁盘 libmyc.so -> 物理内存代码页 进程 A 的虚拟共享区 -> 映射到这些物理页

进程 B 启动时,如果系统发现这份库的代码页已经在物理内存中,就不必再加载一份完整代码。

它只需要:

给进程 B 分配自己的虚拟共享区 让进程 B 的页表也映射到同一批物理代码页

于是形成:

进程 A 虚拟地址 0x7f1000000000 -> 同一份物理库代码 进程 B 虚拟地址 0x7f2000000000 -> 同一份物理库代码

注意:

两个进程的虚拟地址可以不同; 但它们可以映射到同一份物理内存。

这就是动态库节省内存的关键。

七、共享的是代码,不是什么都共享

动态库中并不是所有内容都能随便共享。

一般来说:

只读代码段可以共享; 只读数据可以共享; 可写数据通常不能直接在进程间共享。

原因很简单:如果动态库中的全局变量被多个进程共享,那进程 A 修改变量会影响进程 B,这显然不符合普通进程隔离原则。

所以动态库的可写数据部分通常会为每个进程提供独立映射,或者通过写时拷贝等机制保证进程隔离。

动态库节省内存主要依赖:

代码段只读,可以被多个进程共享。

这也是为什么动态库代码要尽量做到位置无关,不能随便修改代码段本身。

八、为什么动态库加载地址不固定

每个进程都有自己的虚拟地址空间。不同进程加载了不同的程序、不同的库,地址空间中空闲区域也不同。

所以动态链接器不能假设所有进程都把libmyc.so放到同一个虚拟地址。

它通常会在当前进程地址空间中选择一段合适的空闲区域,把动态库映射进去。

这就带来一个关键问题:

动态库如果被加载到任意地址,里面的函数调用和全局变量访问怎么保证正确?

这就是下一篇 GOT/PIC 要解决的问题。

九、使用 /proc 查看动态库映射

Linux 提供了/proc/[pid]/maps,可以查看进程地址空间映射。

先运行一个程序,让它保持一段时间:

./main

如果程序很快退出,可以在代码中加sleep(100)。

然后查看:

pidof maincat/proc/进程PID/maps

你会看到类似:

7f... r-xp ... /lib64/libc.so.6 7f... r--p ... /lib64/libc.so.6 7f... rw-p ... /lib64/libc.so.6

这些行说明libc.so.6的不同部分被映射到了进程虚拟地址空间中,并且权限不同:

r-xp:可读可执行,通常对应代码段 r--p:只读 rw-p:可读可写,通常对应数据段

这能非常直观地看到动态库确实进入了进程地址空间。

十、使用 pmap 查看进程映射

也可以使用:

pmap 进程PID

它会以更简洁的形式展示进程内存映射。

例如:

pmap$(pidof main)

可以看到主程序、堆、栈、动态库等区域。

学习动态库时,/proc/[pid]/maps是非常有价值的观察工具。

十一、动态链接和静态链接再对比一次

静态链接:

库代码在链接阶段进入可执行文件; 程序运行时不再找库; 多个程序各自包含一份库代码。

动态链接:

可执行文件记录依赖; 程序运行时加载 .so; 多个进程可以共享同一份库代码物理页。

动态链接本质上把一部分链接工作推迟到了程序加载和运行阶段。

这带来灵活性,也带来复杂性。

十二、为什么动态库需要后续重定位

动态库被映射到进程地址空间后,它的加载地址才真正确定。

但库代码中可能需要访问:

库内函数 库内全局变量 其他动态库函数 主程序中的符号

有些地址必须在加载后才能知道。

所以动态链接器还要做符号解析和重定位,让这些引用指向正确位置。

问题是:代码段通常是只读且共享的,不能每个进程都直接修改代码段里的地址。

因此动态链接需要一种更巧妙的机制:

把可能变化的地址放到可写表中,代码通过查表跳转。

这张表就是 GOT,全局偏移表。

十三、总结

动态库加载不是简单地“把.so拷贝进进程”。更准确的理解是:

动态链接器找到 .so 内核把 .so 的 segment 映射进进程虚拟地址空间 多个进程可以通过各自页表映射到同一份物理代码页 动态链接器再完成必要的符号解析和重定位

动态库节省内存的关键在于:只读代码段可以共享。

但动态库加载地址不固定,这就要求动态库不能把绝对地址写死在代码里。下一篇我们继续讲 GOT 与 PIC,看动态库如何做到“加载到哪里都能运行”。

返回列表