《程序员的自我修养--链接装载与库》学习笔记 Part 3 装载与动态链接

《程序员的自我修养--链接装载与库》学习笔记 Part 3 装载与动态链接

[TOC]

本文为《程序员的自我修养–链接装载与库》学习笔记 Part 3 装载与动态链接.

第6章 可执行文件的装载与进程

6.1 进程虚拟地址空间

  • 4 GB 的进程虚拟地址空间是怎样的分配状态呢?

    • Linux 操作系统

      • 操作系统 1GB
      • 进程最多可以使用 3 GB 的虚拟空间
    • Windows 操作系统

      • 操作系统占用 2 GB, 那么进程只剩下 2 GB 空间
      • Windows 系统盘根目录下的 Boot.ini, 加上 /3G 参数, 可以扩展到 3 GB.
  • 程序和进程有什么区别

    • 程序 (或者狭义上讲可执行文件) 是一个静态的概念, 它就是一些预先编译好的指令和数据集合的一个文件. 进程则是一个动态的概念, 它是程序运行时的一个过程, 很多时候把动态库叫做运行时 (Runtime) 也有一定的含义.
  • 一般来说, C 语言指针大小的位数与虚拟空间的位数相同.

  • PAE

    • 32 位的 CPU 下, 程序使用的空间能不能超过 4 GB 呢?

      • 首先, 问题里面的 “空间” 如果是指虚拟地址空间, 那么答案是 “否” . 因为 32 位的 CPU 只能使用 32 位的指针, 它最大的寻址范围是 0 到 4 GB.

      • 如果问题里面的 “空间” 指计算机的内存空间, 那么答案为 “是” . Intel 自从 1995 年的 Pentium Pro CPU 开始采用了 36 位的物理地址, 也就是可以访问高达 64 GB 的物理内存.

        • Intel 修改了页映射的方式, 使得新的映射方式可以访问到更多的物理内存. Intel 把这个地址扩展方式叫做PAE (Physical Address Extension) .

6.2 装载的方式

  • 静态装入

    • 将程序运行所需要的指令和数据全都装入内存中.
  • 动态装入

    • 将程序最常用的部分驻留在内存中, 而将一些不太常用的数据存放在磁盘里面.
    • 程序运行时是有局部性原理的, 所以我们可以将程序最常用的部分驻留在内存中, 而将一些不太常用的数据存放在磁盘里面, 这就是动态装入的基本原理.

6.2.1 覆盖装入

  • 模块 main 依赖于模块 A 和 B, 模块 A 依赖于 C 和 D. 模块 B 依赖于 E 和 F, 则它们在内存中的覆盖方式如图中所示

  • .

  • 覆盖管理器 (Overlay Manager)

    • 程序员在编写程序的时候必须手工将程序分割成若干块, 然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉.
  • 覆盖管理器需要保证两点

    • 整个调用路径上的模块必须都在内存中.
    • 禁止跨树间调用.
  • 由于跨模块间的调用都需要经过覆盖管理器, 以确保所有被调用到的模块都能够正确地驻留在内存, 而且一旦模块没有在内存中, 还需要从磁盘或其他存储器读取相应的模块, 所以覆盖装入的速度肯定比较慢, 不过这也是一种折中的方案, 是典型的利用时间换取空间的方法.

6.2.2 页映射

  • 所有的装载和操作的单位就是页.
  • 装载管理器就是现代的操作系统, 更加准确地讲就是操作系统的存储管理器.

6.3 从操作系统角度看可执行文件的装载

6.3.1 进程的建立

  • 第1步: 创建一个独立的虚拟地址空间.

    • 页映射机制
  • 第2步: 读取可执行文件头, 并且建立虚拟空间与可执行文件的映射关系.

    • 由于可执行文件在装载时实际上是被映射的虚拟空间, 所以可执行文件很多时候又被叫做映像文件 (Image).
    • 这种映射关系只是保存在操作系统内部的一个数据结构. Linux 中将进程虚拟空间中的一个段叫做虚拟内存区域 (VMA, Virtual Memory Area) . 在 Windows 中将这个叫做虚拟段 (Virtual Section).
  • 第3步: 将 CPU 的指令寄存器设置成可执行文件的入口地址, 启动运行.

6.3.2 页错误

6.4 进程虚存空间分布

6.4.1 ELF 文件链接视图和执行视图

  • 操作系统只关心最主要的是段的权限 (可读, 可写, 可执行) .

    • 对于相同权限的段, 把它们合并到一起当作一个段进行映射.

    • 段的权限往往只有为数不多的几种组合, 基本上是三种:

      • 以代码段为代表的权限为可读可执行的段.
      • 以数据段和 BSS 段为代表的权限为可读可写的段.
      • 以只读数据段为代表的权限为只读的段.
  • Segment 的概念实际上是从装载的角度重新划分了 ELF 的各个段.

  • ELF 可执行文件与进程虚拟空间映射关系

  • SegmentSection 是从不同的角度来划分同一个 ELF 文件. 这个在 ELF 中被称为不同的视图 (View) , 从 Section 的角度来看 ELF文件就是链接视图 (Linking View), 从 Segment 的角度来看就是执行视图 (Execution View).

  • ELF 可执行文件和共享库文件中有一个专门的数据结构叫做程序头表 (Program Header Table) 用来保存 Segment 的信息.

    • p_memsz 的值不可以小于 p_filesz, 否则就是不符合常理的. 但是, 如果 p_memsz 的值大于 p_filesz 又是什么意思呢?

      • 表示该 “Segment” 在内存中所分配的空间大小超过文件中实际的大小, 这部分 “多余” 的部分则全部填充为 “0” .
      • 这样做的好处是, 我们在构造 ELF 可执行文件时不需要再额外设立 BSS 的 “Segment” 了, 可以把数据 “Segment” 的 p_memsz 扩大, 那些额外的部分就是 BSS.
      • 这也就是我们在前面的例子中只看到了两个 “LOAD” 类型的段, 而不是三个, BSS 已经被合并到了数据类型的段里面.

6.4.2 堆和栈

  • 一个进程中的栈和堆分别都有一个对应的 VMA.

  • Linux 下, 我们可以通过查看 /proc 来查看进程的虚拟空间分布.

    • cat /proc/21963/maps

      1
      2
      3
      4
      5
      08048000-080b9000 r-xp 00000000 08:01 2801887    ./SectionMapping.elf 
      080b9000-080bb000 rwxp 00070000 08:01 2801887 ./SectionMapping.elf
      080bb000-080de000 rwxp 080bb000 00:00 0 [heap]
      bf7ec000-bf802000 rw-p bf7ec000 00:00 0 [stack]
      ffffe000-fffff000 r-xp 00000000 00:00 0 [vdso]
      • 第一列是 VMA 的地址范围.
      • 第二列是 VMA 的权限, r 表示可读, w 表示可写, x 表示可执行, p 表示私有 (COW, Copy on Write) , s 表示共享.
      • 第三列是偏移, 表示 VMA 对应的 Segment 在映像文件中的偏移.
      • 第四列表示映像文件所在设备的主设备号和次设备号.
      • 第五列表示映像文件的节点号.
      • 最后一列是映像文件的路径.
    • 另外三个段的文件所在设备主设备号和次设备号及文件节点号都是 0, 则表示它们没有映射到文件中, 这种 VMA 叫做匿名虚拟内存区域 (Anonymous Virtual Memory Area) .

    • 另外有一个很特殊的 VMA 叫做 vdso , 它的地址已经位于内核空间了 (即大于 0xC0000000 的地址) , 事实上它是一个内核的模块, 进程可以通过访问这个 VMA 来跟内核进行一些通信.

  • 操作系统通过给进程空间划分出一个个 VMA 来管理进程的虚拟空间. 基本原则是将相同权限属性的, 有相同映像文件的映射成一个 VMA.

  • 一个进程基本上可以分为如下几种 VMA 区域

    • 代码 VMA, 权限只读, 可执行, 有映像文件.
    • 数据 VMA, 权限可读写, 可执行, 有映像文件.
    • 堆 VMA, 权限可读写, 可执行, 无映像文件, 匿名, 可向上扩展.
    • 栈 VMA, 权限可读写, 不可执行, 无映像文件, 匿名, 可向下扩展.
  • ELF 与 Linux 进程虚拟空间映射关系

  • 我们在 Linux 的 /proc 目录里面看到的 VMA2 的结束地址跟原先预测的不一样, 按照计算应该是 0x080bc000, 但实际上显示出来的是 0x080bb000. 这是怎么回事呢?

    • Linux 的进程虚拟空间管理的 VMA 的概念并非与 “Segment” 完全对应, Linux 规定一个 VMA 可以映射到某个文件的一个区域, 或者是没有映射到任何文件.
    • 第二个 “Segment” 要求是, 前面部分映射到文件中, 而后面一部分不映射到任何文件, 直接为0, 也就是说前面的从 .tdata 段到 .data 段部分要建立从虚拟空间到文件的映射, 而 .bsslibcfreeres_ptrs 部分不要映射到文件.
    • Linux 实际上采用了一种取巧的办法, 它在映射完第二个 “Segment” 之后, 把最后一个页面的剩余部分清0, 然后调用内核中的 do_brk(), 把 .bss__libcfreeres_ptrs 的剩余部分放到堆段中.

6.4.3 堆的最大申请数量

  • Linux 机器上, 运行上面这个程序的结果大概是 2.9 GB 左右的空间. 在 Windows 下运行这个程序的结果大概是 1.5 GB.

  • 那么 malloc 的最大申请数量会受到哪些因素的影响呢?

    • 具体的数值会受到操作系统版本, 程序本身大小, 用到的动态/共享库数量, 大小, 程序栈数量, 大小等.
    • 甚至有可能每次运行的结果都会不同, 因为有些操作系统使用了一种叫做随机地址空间分布的技术 (主要是出于安全考虑, 防止程序受恶意攻击) , 使得进程的堆空间变小.

6.4.4 段地址对齐

  • 让那些各个段接壤部分共享一个物理页面, 然后将该物理页面分别映射两次. 文件段合并以节省内存防止过多页面碎片.

  • 可执行文件段未合并情况

  • ELF文件段合并情况

  • 在 ELF 文件中, 对于任何一个可装载的 “Segment” , 它的 p_vaddr 除以对齐属性的余数等于 p_offset 除以对齐属性的余数.

6.4.5 进程栈初始化

  • 栈顶寄存器 esp 指向的位置是初始化以后堆栈的顶部, 最前面的 4 个字节表示命令行参数的数量, 我们的例子里面是两个, 即 prog123 , 紧接的就是分布指向这两个参数字符串的指针. 后面跟了一个 0. 接着是两个指向环境变量字符串的指针, 它们分别指向字符串 HOME=/home/userPATH=/usr/bin. 后面紧跟一个 0 表示结束.

6.5 Linux 内核装载 ELF 过程简介

  • bash 进程会调用 fork() 系统调用创建一个新的进程, 然后新的进程调用 execve() 系统调用执行指定的 ELF 文件, 原先的 bash 进程继续返回等待刚才启动的新进程结束, 然后继续等待用户输入命令.

  • execve() 系统调用被定义在 unistd.h, 它的原型如下: int execve(const char *filename, char *const argv[], char *const envp[]);
    它的三个参数分别是被执行的程序文件名, 执行参数和环境变量.

    • Glibc对 execvp() 系统调用进行了包装, 提供了 execl(), execlp(), execle(), execv() 和 execvp() 等5个不同形式的exec系列API, 它们只是在调用的参数形式上有所区别, 但最终都会调用到 execve() 这个系统中.
    • 在内核中, execve() 系统调用相应的入口是 sys_execve()
    • sys_execve() 进行一些参数的检查复制之后, 调用 do_execve(). do_execve() 会首先查找被执行的文件, 如果找到文件, 则读取文件的前128个字节.判断文件的格式, 每种可执行文件的格式的开头几个字节都是很特殊的, 特别是开头4个字节, 常常被称做魔数 (Magic Number)
  • do_execve() 读取了这 128 个字节的文件头部之后, 然后调用 search_binary_handle() 去搜索和匹配合适的可执行文件装载处理过程.

    • search_binary_handle() 会通过判断文件头部的魔数确定文件的格式, 并且调用相应的装载处理过程.

    • ELF 可执行文件的装载处理过程叫做 load_elf_binary(). load_elf_binary() 被定义在 fs/Binfmt_elf.c, 这个函数的代码比较长, 它的主要步骤是:

      • (1) 检查 ELF 可执行文件格式的有效性, 比如魔数, 程序头表中段 (Segment) 的数量.
      • (2) 寻找动态链接的 .interp 段, 设置动态链接器路径 (参照动态链接) .
      • (3) 根据 ELF 可执行文件的程序头表的描述, 对 ELF 文件进行映射, 比如代码, 数据, 只读数据.
      • (4) 初始化 ELF 进程环境, 比如进程启动时 edx 寄存器的地址应该是 DT_FINI 的地址 (参照动态链接) .
      • (5) 将系统调用的返回地址修改成 ELF 可执行文件的入口点, 这个入口点取决于程序的链接方式, 对于静态链接的 ELF 可执行文件, 这个程序入口就是 ELF 文件的文件头中 e_entry 所指的地址. 对于动态链接的 ELF 可执行文件, 程序入口点是动态链接器.
    • load_elf_binary() 执行完毕, 返回至 do_execve() 再返回至 sys_execve() 时, 上面的第5步中已经把系统调用的返回地址改成了被装载的 ELF 程序的入口地址了.

6.6 Windows PE 的装载

  • 省略

第7章 动态链接

7.1 为什么要动态链接

  • 静态链接的问题

    • 内存和磁盘空间: 静态连接的方式对于计算机内存和磁盘的空间浪费非常严重.
    • 程序开发和发布: 静态链接对程序的更新, 部署和发布也会带来很多麻烦.
  • 动态链接

    • 简单地讲, 就是不对那些组成程序的目标文件进行链接, 等到程序要运行时才进行链接. 也就是说, 把链接这个过程推迟到了运行时再进行, 这就是动态链接 (Dynamic Linking) 的基本思想.
    • 好处不仅仅是节省内存, 它还可以减少物理页面的换入换出, 也可以增加 CPU 缓存的命中率, 因为不同进程间的数据和指令访问都集中在了同一个共享模块上.
    • 当我们要升级程序库或程序共享的某个模块时, 理论上只要简单地将旧的目标文件覆盖掉, 而无须将所有的程序再重新链接一遍. 当程序下一次运行的时候, 新版本的目标文件会被自动装载到内存并且链接起来, 程序就完成了升级的目标.
    • 当一个程序产品的规模很大的时候, 往往会分割成多个子系统及多个模块, 每个模块都由独立的小组开发, 甚至会使用不同的编程语言. 动态链接的方式使得开发过程中各个模块更加独立, 耦合度更小, 便于不同的开发者和开发组织之间独立进行开发和测试.
  • 程序可扩展性和兼容性

    • 动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块, 这个优点就是后来被人们用来制作程序的插件 (Plug-in) .
    • 比如某个公司开发完成了某个产品, 它按照一定的规则制定好程序的接口, 其他公司或开发者可以按照这种接口来编写符合要求的动态链接文件. 该产品程序可以动态地载入各种由第三方开发的模块, 在程序运行时动态地链接, 实现程序功能的扩展.
    • 动态链接还可以加强程序的兼容性. 一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库, 这些动态链接库相当于在程序和操作系统之间增加了一个中间层, 从而消除了程序对不同平台之间依赖的差异性.
  • “DLL Hell”

    • 缺少一种有效的共享库版本管理机制, 使得用户经常出现新程序安装完之后, 其他某个程序无法正常工作的现象。
  • 动态链接的基本实现

    • 直接使用目标文件进行动态链接呢?这个问题的答案是: 理论上是可行的, 但实际上动态链接的实现方案与直接使用目标文件稍有差别.
    • 在 Linux 系统中, ELF 动态链接文件被称为动态共享对象 (DSO, Dynamic Shared Objects) , 简称共享对象, 它们一般都是以 .so 为扩展名的一些文件. 而在 Windows 系统中, 动态链接文件被称为动态链接库 (Dynamical Linking Library) , 它们通常就是我们平时很常见的以 .dll 为扩展名的文件.
  • 链接工作是由动态链接器完成的, 而不是由我们前面看到过的静态链接器 ld 完成的. 也就是说, 动态链接是把链接这个过程从本来的程序装载前被推迟到了装载的时候.

  • 动态链接会导致程序在性能的一些损失, 但是对动态链接的链接过程可以进行优化.

    • 据估算, 动态链接与静态链接相比, 性能损失大约在5%以下.

7.2 简单的动态链接例子

  • GCC命令中的参数 -shared 表示产生共享对象.

  • 动态链接程序运行时地址空间分布

    • 这里, Lib.o 没有被链接进来, 链接的输入目标文件只有 Program1.o (当然还有 C 语言运行库, 我们这里暂时忽略) . 但是从前面的命令行中我们看到, Lib.so 也参与了链接过程. 这是怎么回事呢?

      • 当程序模块 Program1.c 被编译成为 Program1.o 时, 编译器还不不知道 foobar() 函数的地址, 如果 foobar() 是一个定义与其他静态目标模块中的函数, 那么链接器将会按照静态链接的规则, 将 Program1.o 中的 foobar 地址引用重定位. 如果 foobar() 是一个定义在某个动态共享对象中的函数, 那么链接器就会将这个符号的引用标记为一个动态链接的符号, 不对它进行地址重定位, 把这个过程留到装载时再进行.
      • 链接器如何知道 foobar 的引用是一个静态符号还是一个动态符号?这实际上就是我们要用到 Lib.so 的原因. Lib.so 中保存了完整的符号信息 (因为运行时进行动态链接还须使用符号信息) , 把 Lib.so 也作为链接的输入文件之一, 链接器在解析符号时就可以知道: foobar 是一个定义在 Lib.so 的动态符号. 这样链接器就可以对 foobar 的引用做特殊的处理, 使它成为一个对动态符号的引用.
    • ld-2.6.so, 它实际上是 Linux 下的动态链接器. 动态链接器与普通共享对象一样被映射到了进程的地址空间, 在系统开始运行 Program1 之前, 首先会把控制权交给动态链接器, 由它完成所有的动态链接工作以后再把控制权交给 Program1, 然后开始执行.

    • 共享对象的最终装载地址在编译时是不确定的, 而是在装载时, 装载器根据当前地址空间的空闲情况, 动态分配一块足够大小的虚拟地址空间给相应的共享对象.

    • lib.soProgram1 一样, 它们都是被操作系统用同样的方法映射至进程的虚拟地址空间, 只是它们占据的虚拟地址和长度不同.

7.3 地址无关代码

7.3.1 固定装载地址的困扰

  • 共享对象在被装载时, 如何确定它在进程虚拟地址空间中的位置?

  • 首先会遇到的问题就是共享对象地址的冲突问题.

  • 静态共享库 (Static Shared Library)

    • 静态共享库的做法就是将程序的各种模块统一交给操作系统来管理, 操作系统在某个特定的地址划分出一些地址块, 为那些已知的模块预留足够的空间.
    • 静态共享库的升级也很成问题, 因为升级后的共享库必须保持共享库中全局函数和变量地址的不变, 如果应用程序在链接时已经绑定了这些地址, 一旦更改, 就必须重新链接应用程序, 否则会引起应用程序的崩溃.
    • 即使升级静态共享库后保持原来的函数和变量地址不变, 只是增加了一些全局函数或变量, 也会受到限制, 因为静态共享库被分配到的虚拟地址空间有限, 不能增长太多, 否则可能会超出被分配的空间.
  • 让共享对象在任意地址加载: 共享对象在编译时不能假设自己在进程虚拟地址空间中的位置.

7.3.2 装载时重定位

  • 静态链接时提到过重定位, 那时的重定位叫做链接时重定位 (Link Time Relocation) , 而现在这种情况经常被称为装载时重定位 (Load Time Relocation) , 在 Windows 中, 这种装载时重定位又被叫做基址重置 (Rebasing).

  • 但是装载时重定位的方法并不适合用来解决上面的共享对象中所存在的问题.

    • 因为指令被重定位后对于每个进程来讲是不同的.
    • 当然, 动态连接库中的可修改数据部分对于不同的进程来说有多个副本, 所以它们可以采用装载时重定位的方法来解决.
    • Linux 和 GCC 支持这种装载时重定位的方法, 我们前面在产生共享对象时, 使用了两个 GCC 参数 -shared-fPIC , 如果只使用 -shared , 那么输出的共享对象就是使用装载时重定位的方法.

7.3.3 地址无关代码

  • 地址无关代码 (PIC, Position-independent Code)

    • 共享的指令部分在装载时不需要因为装载地址的改变而改变, 所以实现的基本想法就是把指令中那些需要被修改的部分分离出来, 跟数据部分放在一起, 这样指令部分就可以保持不变, 而数据部分可以在每个进程中拥有一个副本.
  • 模块中各种类型的地址引用方式

    • 把共享对象模块中的地址引用按照是否为跨模块分成两类: 模块内部引用和模块外部引用. 按照不同的引用方式又可以分为指令引用和数据访问.

    • 第一种是模块内部的函数调用, 跳转等.

      • 相对地址调用, 或者是基于寄存器的相对调用, 所以对于这种指令是不需要重定位的.
      • 相对偏移调用指令: 目的地址相对于当前指令的下一条指令的偏移.
      • 那么只要 barfoo 的相对位置不变, 这条指令是地址无关的. 即无论模块被装载到哪个位置, 这条指令都是有效的.
      • 实际上这种方式还有一定的问题, 这里存在一个叫做共享对象全局符号介入 (Global Symbol Interposition) 问题.
    • 第二种是模块内部的数据访问, 比如模块中定义的全局变量, 静态变量.

      • 对于当前指令(PC)加上固定的偏移量就可以访问模块内部数据.
    • 第三种是模块外部的函数调用, 跳转等.

      • ELF 的做法是在数据段里面建立一个指向这些变量的指针数组, 也被称为全局偏移表 (Global Offset Table, GOT) , 当代码需要引用该全局变量时, 可以通过 GOT 中相对应的项间接引用.

      • 当指令中需要访问变量 b 时, 程序会先找到 GOT, 然后根据 GOT 中变量所对应的项找到变量的目标地址. 每个变量都对应一个 4 个字节的地址, 链接器在装载模块的时候会查找每个变量所在的地址, 然后填充 GOT 中的各个项, 以确保每个指针所指向的地址正确. 由于 GOT 本身是放在数据段的, 所以它可以在模块装载时被修改, 并且每个进程都可以有独立的副本, 相互不受影响.

      • GOT 如何做到指令的地址无关性: 模块在编译时可以确定模块内部变量相对与当前指令的偏移, 那么我们也可以在编译时确定 GOT 相对于当前指令的偏移.

    • 第四种是模块外部的数据访问, 比如其他模块中定义的全局变量.

      • GOT 中相应的项保存的是目标函数的地址, 当模块需要调用目标函数时, 可以通过 GOT 中的项进行间接跳转.
      • 这种方法很简单, 但是存在一些性能问题, 实际上 ELF 采用了一种更加复杂和精巧的方法, 我们将在后面关于动态链接优化的章节中进行更为具体的介绍.
  • 地址无关代码小结

  • -fpic-fPIC

    • 唯一的区别是, -fPIC 产生的代码要大, 而 -fpic 产生的代码相对较小, 而且较快.
    • 由于地址无关代码都是跟硬件平台相关的, 不同的平台有着不同的实现, -fpic 在某些平台上会有一些限制, 比如全局符号的数量或者代码的长度等, 而 -fPIC 则没有这样的限制. 所以为了方便起见, 绝大部分情况下我们都使用 -fPIC 参数来产生地址无关代码.
  • 如何区分一个 DSO 是否为 PIC

    • readelf –d foo.so | grep TEXTREL
      如果上面的命令有任何输出, 那么 foo.so 就不是 PIC 的, 否则就是 PIC 的. PIC 的 DSO 是不会包含任何代码段重定位表的, TEXTREL 表示代码段重定位表地址.
  • PIC 与 PIE

    • 一个以地址无关方式编译的可执行文件被称作地址无关可执行文件 (PIE, Position-Independent Executable) . 与 GCC 的 -fPIC-fpic 参数类似, 产生 PIE 的参数为 -fPIE-fpie.

7.3.4 共享模块的全局变量问题

  • 无法根据这个上下文判断 global 是定义在同一个模块的的其他目标文件还是定义在另外一个共享对象之中, 即无法判断是否为跨模块间的调用.

  • 由于可执行文件在运行时并不进行代码重定位, 所以变量的地址必须在链接过程中确定下来. 为了能够使得链接过程正常进行, 链接器会在创建可执行文件时, 在它的 .bss 段创建一个 global 变量的副本. 那么问题就很明显了, 现在 global 变量定义在原先的共享对象中, 而在可执行文件的 .bss 段还有一个副本. 如果同一个变量同时存在于多个位置中, 这在程序实际运行过程中肯定是不可行的.

  • 所有的使用这个变量的指令都指向位于可执行文件中的那个副本.

  • ELF 共享库在编译时, 默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量, 也就是说当作前面的类型四, 通过 GOT 来实现变量的访问.

    • 当共享模块被装载时, 如果某个全局变量在可执行文件中拥有副本, 那么动态链接器就会把 GOT 中的相应地址指向该副本, 这样该变量在运行时实际上最终就只有一个实例.
    • 如果变量在共享模块中被初始化, 那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本.
    • 如果该全局变量在程序主模块中没有副本, 那么 GOT 中的相应地址就指向模块内部的该变量副本.
  • 假设 module.c 是一个共享对象的一部分, 那么 GCC 编译器在 -fPIC 的情况下, 就会把对global 的调用按照跨模块模式产生代码.

  • Q: 如果一个共享对象 lib.so 中定义了一个全局变量 G, 而进程 A 和进程 B 都使用了 lib.so, 那么当进程 A 改变这个全局变量 G 的值时, 进程B 中的 G 会受到影响吗?

    • A: 不会. 因为当 lib.so 被两个进程加载时, 它的数据段部分在每个进程中都有独立的副本, 从这个角度看, 共享对象中的全局变量实际上和定义在程序内部的全局变量没什么区别.
    • 多进程共享全局变量又被叫做 “共享数据段”.
    • 多个线程访问不同的全局变量副本又被叫做 “线程私有存储” (Thread Local Storage).

7.3.5 数据段地址无关性

  • 数据部分是不是也有绝对地址引用的问题呢?

    1
    2
    static int a; 
    static int* p = &a;
    • 指针p的地址就是一个绝对地址, 它指向变量a, 而变量a的地址会随着共享对象的装载地址改变而改变. 那么有什么办法解决这个问题呢?
  • 可以选择装载时重定位的方法来解决数据段中绝对地址引用问题. 对于共享对象来说, 如果数据段中有绝对地址引用, 那么编译器和链接器就会产生一个重定位表, 这个重定位表里面包含了 “R_386_RELATIVE” 类型的重定位入口, 用于解决上述问题. 当动态链接器装载共享对象时, 如果发现该共享对象有这样的重定位入口, 那么动态链接器就会对该共享对象进行重定位.

  • 装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快, 因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程.

7.4 延迟绑定 (PLT)

  • 影响动态链接性能的两个主要问题

    • 动态链接下对于全局和静态的数据访问都要进行复杂的 GOT 定位, 然后间接寻址. 对于模块间的调用也要先定位 GOT, 然后再进行间接跳转, 如此一来, 程序的运行速度必定会减慢.
    • 另外一个减慢运行速度的原因是动态链接的链接工作在运行时完成, 即程序开始执行时, 动态链接器都要进行一次链接工作, 正如我们上面提到的, 动态链接器会寻找并装载所需要的共享对象, 然后进行符号查找地址重定位等工作, 这些工作势必减慢程序的启动速度.
  • 延迟绑定实现

    • ELF 采用了一种叫做延迟绑定 (Lazy Binding) 的做法, 基本的思想就是当函数第一次被用到时才进行绑定 (符号查找, 重定位等) , 如果没有用到则不进行绑定.

    • ELF 使用 PLT (Procedure Linkage Table) 的方法来实现.

      • 当我们调用某个外部模块的函数时, 如果按照通常的做法应该是通过 GOT 中相应的项进行间接跳转. PLT 为了实现延迟绑定, 在这个过程中间又增加了一层间接跳转. 调用函数并不直接通过 GOT 跳转, 而是通过一个叫作 PLT 项的结构来进行跳转. 每个外部函数在 PLT 中都有一个相应的项.

      • ELF 将 GOT 拆分成了两个表叫做 .got.got.plt.

        • 其中 .got 用来保存全局变量引用的地址, .got.plt 用来保存函数引用的地址, 也就是说, 所有对于外部函数的引用全部被分离出来放到了 .got.plt 中.

        • 另外 .got.plt 还有一个特殊的地方是它的前三项是有特殊意义的, 分别含义如下:

          • 第一项保存的是 .dynamic 段的地址, 这个段描述了本模块动态链接相关的信息.
          • 第二项保存的是本模块的 ID.
          • 第三项保存的是 _dl_runtime_resolve() 的地址.
      • 实际的 PLT 基本结构:

      • PLT 在 ELF 文件中以独立的段存放, 段名通常叫做 .plt , 因为它本身是一些地址无关的代码, 所以可以跟代码段等一起合并成同一个可读可执行的 “Segment” 被装载入内存.

7.5 动态链接相关结构

  • 在动态链接情况下, 操作系统还不能在装载完可执行文件之后就把控制权交给可执行文件, 因为我们知道可执行文件依赖于很多共享对象. 这时候, 可执行文件里对于很多外部符号的引用还处于无效地址的状态, 即还没有跟相应的共享对象中的实际位置链接起来. 所以在映射完可执行文件之后, 操作系统会先启动一个动态链接器 (Dynamic Linker) .

  • 在 Linux 下, 动态链接器 ld.so 实际上是一个共享对象, 操作系统同样通过映射的方式将它加载到进程的地址空间中. 操作系统在加载完动态链接器之后, 就将控制权交给动态链接器的入口地址 (与可执行文件一样, 共享对象也有入口地址) . 当动态链接器得到控制权之后, 它开始执行一系列自身的初始化操作, 然后根据当前的环境参数, 开始对可执行文件进行动态链接工作. 当所有动态链接工作完成以后, 动态链接器会将控制权转交到可执行文件的入口地址, 程序开始正式执行.

    7.5.1 .interp

  • 那么系统中哪个才是动态链接器呢, 它的位置由谁决定?

  • ELF 可执行文件中, 有一个专门的段叫做 .interp 段,决定动态链接器.

  • .interp 的内容很简单, 里面保存的就是一个字符串, 这个字符串就是可执行文件所需要的动态链接器的路径, 在 Linux 下, 可执行文件所需要的动态链接器的路径几乎都是 /lib/ld-linux.so.2.

1
2
$ readelf -l a.out | grep interpreter 
[Requesting program interpreter: /lib/ld-linux.so.2]

7.5.2 .dynamic

  • 保存了动态链接器所需要的基本信息, 比如依赖于哪些共享对象, 动态链接符号表的位置, 动态链接重定位表的位置, 共享对象初始化代码的地址等.

  • 结构定义在 elf.h

  • .dynamic 段可以看成是动态链接下 ELF 文件的 “文件头” .

  • 使用 readelf 工具可以查看 .dynamic 段的内容: $ readelf -d Lib.so .

  • Linux 还提供了一个命令用来查看一个程序主模块或一个共享库依赖于哪些共享库: ldd.

7.5.3 动态符号表 (Dynamic Symbol Table)

  • ELF 专门有一个叫做动态符号表 (Dynamic Symbol Table) 的段用来保存这些信息, 这个段的段名通常叫做 .dynsym (Dynamic Symbol) .
  • 很多时候动态链接的模块同时拥有 .dynsym.symtab 两个表, .symtab 中往往保存了所有符号, 包括 .dynsym 中的符号.
  • 动态符号表也需要一些辅助的表, 比如用于保存符号名的字符串表. 静态链接时叫做符号字符串表 .strtab (String Table) , 在这里就是动态符号字符串表 .dynstr (Dynamic String Table) .
  • 由于动态链接下, 我们需要在程序运行时查找符号, 为了加快符号的查找过程, 往往还有辅助的符号哈希表 ( .hash ) .
  • 动态链接符号表的结构与静态链接的符号表几乎一样, 我们可以简单地将导入函数看作是对其他目标文件中函数的引用. 把导出函数看作是在本目标文件定义的函数就可以了.

7.5.4 动态链接重定位表

  • PIC 模式的共享对象也需要重定位.

    • 对于使用 PIC 技术的可执行文件或共享对象来说, 虽然它们的代码段不需要重定位 (因为地址无关) , 但是数据段还包含了绝对地址的引用, 因为代码段中绝对地址相关的部分被分离了出来, 变成了 GOT, 而 GOT 实际上是数据段的一部分.
  • 重定位表 .rel.dyn 实际上是对数据引用的修正, 它所修正的位置位于 .got 以及数据段. 而 .rel.plt 是对函数引用的修正, 它所修正的位置位于 .got.plt.

  • 在静态链接中我们已经碰到过两种类型的重定位入口 R_386_32R_386_PC32, 这里可以看到几种新的重定位入口类型: R_386_RELATIVE, R_386_GLOB_DATR_386_JUMP_SLOT. 实际上这些不同的重定位类型表示重定位时有不同的地址计算方法.

    • R_386_GLOB_DATR_386_JUMP_SLOT, 这两个类型的重定位入口表示, 被修正的位置只需要直接填入符号的地址即可.

    • R_386_RELATIVE 类型的重定位入口, 这种类型的重定位实际上就是基址重置 (Rebasing) . 我们在前面已经分析过, 共享对象的数据段是没有办法做到地址无关的, 它可能会包含绝对地址的引用, 对于这种绝对地址的引用, 我们必须在装载时将其重定位.

      • 在编译时, 共享对象的地址是从 0 开始的, 我们假设该静态变量 a 相对于起始地址 0 的偏移为 B, 即 p 的值为 B. 一旦共享对象被装载到地址 A, 那么实际上该变量 a 的地址为 A+B, 即 p 的值需要加上一个装载地址 A.
  • PIC 与非 PIC 模式下重定位表的区别

    • 如果某个 ELF 文件是以 PIC 模式编译的 (动态链接的可执行文件一般是 PIC 的) , 并调用了一个外部函数 bar, 则 bar 会出现在 .rel.plt 中. 而如果不是以 PIC 模式编译, 则 bar 将出现在 .rel.dyn 中.

    • 为什么这个字符串常量的地址在 PIC 时不需要重定位而在非 PIC 时需要重定位呢?

      • PIC 时, 这个字符串可以看作是普通的全局变量, 它的地址是可以通过 PIC 中的相对当前指令的位置加上一个固定偏移计算出来的. 而在非 PIC 中, 代码段不再使用这种相对于当前指令的 PIC 方法, 而是采用绝对地址寻址, 所以它需要重定位.

7.5.5 动态链接时进程堆栈初始化信息

  • 进程初始化的时候, 堆栈里面保存了关于进程执行环境和命令行参数等信息. 事实上, 堆栈里面还保存了动态链接器所需要的一些辅助信息数组 (Auxiliary Vector) . 辅助信息的格式也是一个结构数组.

  • 栈上位置: 位于环境变量指针的后面.

7.6 动态链接的步骤和实现

7.6.1 动态链接器自举

  • 动态链接器本身也是一个共享对象.

    • 动态链接器本身不可以依赖于其他任何共享对象. 其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成.
    • 动态链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时又不能用到全局和静态变量. 这种具有一定限制条件的启动代码往往被称为自举 (Bootstrap) .
  • 动态链接器入口地址即是自举代码的入口.

  • 实际上在动态链接器的自举代码中, 除了不可以使用全局变量和静态变量之外, 甚至不能调用函数, 即动态链接器本身的函数也不能调用.

  • 自举代码便可以获得动态链接器本身的重定位表和符号表等, 从而得到动态链接器本身的重定位入口, 先将它们全部重定位. 从这一步开始, 动态链接器代码中才可以开始使用自己的全局变量和静态变量. 动态链接器本身的函数才可以调用.

7.6.2 装载共享对象

  • 列出可执行文件所需要的所有共享对象, 并将这些共享对象的名字放入到一个装载集合中.

  • 装载过程就是一个图的遍历过程, 链接器可能会使用深度优先或者广度优先或者其他的顺序来遍历整个图, 这取决于链接器, 比较常见的算法一般都是广度优先的.

  • 符号的优先级

    • 这种一个共享对象里面的全局符号被另一个共享对象的同名全局符号覆盖的现象又被称为共享对象全局符号介入 (Global Symbol Interpose) .
    • 它定义了一个规则, 那就是当一个符号需要被加入全局符号表时, 如果相同的符号名已经存在, 则后加入的符号被忽略.
    • 由于存在这种重名符号被直接忽略的问题, 当程序使用大量共享对象时应该非常小心符号的重名问题, 如果两个符号重名又执行不同的功能, 那么程序运行时可能会将所有该符号名的引用解析到第一个被加入全局符号表的使用该符号名的符号, 从而导致程序莫名其妙的错误.
  • 全局符号介入与地址无关代码

    • 如果希望自己模块内部的函数可能被共享对象的重名函数覆盖的话

      • 由于可能存在全局符号介入的问题, foo 函数对于 bar 的调用不能够采用第一类模块内部调用的方法, 因为一旦 bar 函数由于全局符号介入被其他模块中的同名函数覆盖, 那么 foo 如果采用相对地址调用的话, 那个相对地址部分就需要重定位, 这又与共享对象的地址无关性矛盾. 所以对于 bar() 函数的调用, 编译器只能采用第三种, 即当作模块外部符号处理, bar() 函数被覆盖, 动态链接器只需要重定位 .got.plt, 不影响共享对象的代码段.
    • 如果想要不被覆盖

      • 为了提高模块内部函数调用的效率, 有一个办法是把 bar() 函数变成编译单元私有函数, 即使用 static 关键字定义 bar() 函数, 这种情况下, 编译器要确定 bar() 函数不被其他模块覆盖, 就可以使用第一类的方法, 即模块内部调用指令, 可以加快函数的调用速度.

7.6.3 重定位和初始化

7.6.4 Linux 动态链接器实现

  • 对于不同链接形式的 ELF 可执行文件, 这个程序的入口是有区别的. 对于静态链接的可执行文件来说, 程序的入口就是 ELF 文件头里面的
    e_entry 指定的入口. 对于动态链接的可执行文件来说, 如果这时候把控制权交给 e_entry 指定的入口地址, 那么肯定是不行的, 因为可执行文件所依赖的共享库还没有被装载, 也没有进行动态链接. 所以对于动态链接的可执行文件, 内核会分析它的动态链接器地址 (在 .interp 段) , 将动态链接器映射至进程地址空间, 然后把控制权交给动态链接器.
  • Linux 的内核在执行 execve() 时不关心目标 ELF 文件是否可执行 (文件头 e_typeET_EXEC 还是 ET_DYN ) , 它只是简单按照程序头表里面的描述对文件进行装载然后把控制权转交给 ELF 入口地址 (没有 .interp 就是ELF文件的 e_entry . 如果有 .interp 的话就是动态链接器的 e_entry ) . 这样我们就很好理解为什么动态链接器本身可以作为可执行程序运行.
  • 共享库和可执行文件实际上没什么区别.
  • 动态链接器本身应该是静态链接的, 它不能依赖于其他共享对象.
  • ld.so 的装载地址跟一般的共享对象没区别, 即为 0x00000000. 这个装载地址是一个无效的装载地址, 作为一个共享库, 内核在装载它时会为其选择一个合适的装载地址.

7.7 显式运行时链接 (Explicit Run-time Linking)

7.7.0 Intro

  • 支持动态链接的系统往往都支持一种更加灵活的模块加载方式, 叫做显式运行时链接 (Explicit Run-time Linking) , 有时候也叫做运行时加载. 也就是让程序自己在运行时控制加载指定的模块, 并且可以在不需要该模块时将其卸载.

  • 动态装载库 (Dynamic Loading Library)

    • 运行时加载使得程序的模块组织变得很灵活, 可以用来实现一些诸如插件, 驱动等功能.
  • 动态库的装载则是通过一系列由动态链接器提供的 API. 这几个 API 的实现是在 /lib/libdl.so.2 里面, 它们的声明和相关常量被定义在系统标准头文件 <dlfcn.h>.

7.7.1 dlopen()

  • 原型: void * dlopen(const char * filename, int flag);

  • 第一个参数是被加载动态库的路径

    • 如果这个路径是绝对路径 (以 / 开始的路径) , 则该函数将会尝试直接打开该动态库.

    • 如果是相对路径, 那么 dlopen() 会尝试在以一定的顺序去查找该动态库文件:

      • (1) 查找有环境变量 LD_LIBRARY_PATH 指定的一系列目录 (我们在后面会详细介绍LD_LIBRARY_PATH 环境变量) .
      • (2) 查找由 /etc/ld.so.cache 里面所指定的共享库路径.
      • (3) /lib, /usr/lib 注意: 这个查找顺序与旧的 a.out 装载器的顺序刚好相反, 旧的 a.out 的装载器在装载共享库的时候会先查找 /usr/lib, 然后是 /lib.
    • dlopen() 函数用来打开一个动态库, 并将其加载到进程的地址空间, 完成初始化过程.

  • 第二个参数 flag 表示函数符号的解析方式

    • 常量 RTLD_LAZY 表示使用延迟绑定.
    • RTLD_NOW 表示当模块被加载时即完成所有的函数绑定工作.
    • RTLD_GLOBAL 可以跟上面的两者中任意一个一起使用, 它表示将被加载的模块的全局符号合并到进程的全局符号表中, 使得以后加载的模块可以使用这些符号.
  • dlopen 的返回值是被加载的模块的句柄, 这个句柄在后面使用 dlsym 或者 dlclose 时需要用到. 如果加载模块失败, 则返回 NULL. 如果模块已经通过 dlopen 被加载过了, 那么返回的是同一个句柄.

  • 事实上 dlopen 还会在加载模块时执行模块中初始化部分的代码.

7.7.2 dlsym()

  • dlsym 函数基本上是运行时装载的核心部分, 我们可以通过这个函数找到所需要的符号.

  • 原型: void * dlsym(void *handle, char *symbol);

    • 第一个参数是由 dlopen() 返回的动态库的句柄.

    • 第二个参数即所要查找的符号的名字, 一个以 \0 结尾的 C 字符串.

    • 如果 dlsym() 找到了相应的符号, 则返回该符号的值. 没有找到相应的符号, 则返回 NULL.

      • 如果查找的符号是个函数, 那么它返回函数的地址.
      • 如果是个变量, 它返回变量的地址.
      • 如果这个符号是个常量, 那么它返回的是该常量的值.
      • 如果常量的值刚好是 NULL 或者 0 呢, 我们如何判断 dlsym() 是否找到了该符号呢?这就要用到我们下面介绍的 dlerror() 函数了. 如果符号找到了, 那么 dlerror() 返回 NULL, 如果没找到, dlerror() 就会返回相应的错误信息.
  • 符号优先级

    • 当多个同名符号冲突时, 先装入的符号优先, 我们把这种优先级方式称为装载序列 (Load Ordering) .

      • 不管是之前由动态链接器装入的还是之后由 dlopen 装入的共享对象, 动态链接器在进行符号的解析以及重定位时, 都是采用装载序列.
    • dlsym() 对符号的查找优先级分两种类型.

      • 第一种情况是, 如果我们是在全局符号表中进行符号查找, 即 dlopen() 时, 参数 filenameNULL, 那么由于全局符号表使用的装载序列, 所以 dlsym() 使用的也是装载序列.
      • 第二种情况是如果我们是对某个通过 dlopen() 打开的共享对象进行符号查找的话, 那么采用的是一种叫做依赖序列 (Dependency Ordering) 的优先级. 对它所有依赖的共享对象进行广度优先遍历, 直到找到符号为止.

7.7.3 dlerror()

  • 每次我们调用 dlopen(), dlsym()
    dlclose() 以后, 我们都可以调用 dlerror() 函数来判断上一次调用是否成功.
  • dlerror() 的返回值类型是 char* , 如果返回
    NULL, 则表示上一次调用成功. 如果不是, 则返回相应的错误消息.

7.7.4 dlclose()

  • 将一个已经加载的模块卸载. 系统会维持一个加载引用计数器, 每次使用 dlopen() 加载某模块时, 相应的计数器加一. 每次使用 dlclose() 卸载某模块时, 相应计数器减一. 只有当计数器值减到 0 时, 模块才被真正地卸载掉. 卸载的过程跟加载刚好相反, 先执行 .finit 段的代码, 然后将相应的符号从符号表中去除, 取消进程空间跟模块的映射关系, 然后关闭模块文件.

7.7.5 运行时装载的演示程序

  • rundll 程序可以把 Windows 的 DLL 当作程序来运行.
  • 当我们需要运行某个指定的函数时, 仅仅知道它的地址是不够的, 还必须知道它的函数签名. 这些信息是无法通过运行时加载获得的 (很多高级语言 (平台) 如 Java, .NET 里面的反射功能可以实现运行时获得函数的额外信息, 包括参数, 返回值类型等) , 因为 C/C++ 编译器在编译时并没有把这些信息也保存到目标文件, 可执行文件或者共享对象等, 我们仅仅能获得的是函数的地址. 从这一点来看, C/C++ 的确不能被称为 “高级” 语言.

第8章 Linux 共享库的组织

这里先澄清一个说法, 即共享库 (Shared Library) 的概念. 其实从文件结构上来讲, 共享库和共享对象没什么区别.

8.1 共享库版本

8.1.1 共享库兼容性

  • 共享库的更新可以被分为两类.

    • 兼容更新. 所有的更新只是在原有的共享库基础上添加一些内容, 所有原有的接口都保持不变.
    • 不兼容更新. 共享库更新改变了原有的接口, 使用该共享库原有接口的程序可能不能运行或运行不正常.
  • 导致 C 语言的共享库 ABI 改变的行为主要有如下 4 个

    • 导出函数的行为发生改变, 也就是说调用这个函数以后产生的结果与以前不一样, 不再满足旧版本规定的函数行为准则.
    • 导出函数被删除.
    • 导出数据的结构发生变化.
    • 导出函数的接口发生变化.
  • 对于 Linux 来说, 如果你要开发一个导出接口为 C++ 的共享库 (当然我十分不推荐这么做, 使用 C 的接口会让事情变得简单得多) , 需要注意以下事项, 以防止 ABI 不兼容.

    • 不要在接口类中使用虚函数, 万不得已要使用虚函数时, 不要随意删除, 添加或在子类中添加新的实现函数, 这样会导致类的虚函数表结构发生变化.
    • 不要改变类中任何成员变量的位置和类型.
    • 不要删除非内嵌的 publicprotected 成员函数.
    • 不要将非内嵌的成员函数改变成内嵌成员函数.
    • 不要改变成员函数的访问权限.
    • 不要在接口中使用模板.

8.1.2 共享库版本命名

  • libname.so.x.y.z

    • 最前面使用前缀 lib , 中间是库的名字和后缀 .so , 最后面跟着的是三个数字组成的版本号. x 表示主版本号 (Major Version Number) , y 表示次版本号 (Minor Version Number) , z 表示发布版本号 (Release Version Number).
  • 不同主版本号的库之间是不兼容的, 依赖于旧的主版本号的程序需要改动相应的部分, 并且重新编译, 才可以在新版的共享库中运行. 或者, 系统必须保留旧版的共享库, 使得那些依赖于旧版共享库的程序能够正常运行.

  • 次版本号表示库的增量升级, 即增加一些新的接口符号, 且保持原来的符号不变. 在主版本号相同的情况下, 高的次版本号的库向后兼容低的次版本号的库.

  • 发布版本号表示库的一些错误的修正, 性能的改进等, 并不添加任何新的接口, 也不对接口进行更改.

8.1.3 SO-NAME

  • 共享库的文件名去掉次版本号和发布版本号, 保留主版本号.

  • 在 Linux 系统中, 系统会为每个共享库在它所在的目录创建一个跟 “SO-NAME” 相同的并且指向它的软链接 (Symbol Link) .

  • 总之, SO-NAME 表示一个库的接口, 接口不向后兼容, SO-NAME 就发生变化, 这是基本的原则.

  • Linux中 提供了一个工具叫做 ldconfig , 当系统中安装或更新一个共享库时, 就需要运行这个工具, 它会遍历所有的默认共享库目录, 比如 /lib, /usr/lib 等, 然后更新所有的软链接, 使它们指向最新版的共享库. 如果安装了新的共享库, 那么 ldconfig 会为其创建相应的软链接.

  • 需要链接一个 libXXX.so.2.6.1 的共享库, 只需要在编译器命令行里面指定 -lXXX 即可, 可省略所有其他部分. 这个 XXX 又被称为共享库的链接名 (Link Name) .

    • 同类型的库可能会有同样的链接名, 比如 C 语言运行库有静态版本 (libc.a) 和动态版本 (libc.so.x.y.z) 的区别, 如果在链接时使用参数 -lc , 那么链接器会根据输出文件的情况 (动态/静态) 来选择适合版本的库.

8.2 符号版本

  • 次版本号交会问题 (Minor-revision Rendezvous Problem)

    • 如果系统中只有低次版本号的共享库, 那么这些程序就不能运行.
    • 次版本号交会问题并没有因为SO-NAME而解决.

8.2.1 基于符号的版本机制

  • 符合的版本机制 (Symbol Versioning)

    • 让每个导出和导入的符号都有一个相关联的版本号, 它的实际做法类似于名称修饰的方法.
    • 添加的那些全局符号打上一个标记, 比如 VERS_1.3.
  • SO-NAME 并行起作用

8.2.2 Solaris 中的符号版本机制

  • Solaris 的 ld 链接器为共享库新增了版本机制 (Versioning)范围机制 (Scoping).

  • 版本机制 (Versioning)

    • 定义一些符号的集合, 这些集合本身都有名字, 比如叫 VERS_1.1 , VERS_1.2 等, 每个集合都包含一些指定的符号.
    • 一个集合还可以包含另外一个集合.
    • 程序员可以在链接共享库时编写一种叫做符号版本脚本的文件, 在这个文件中指定这些符号与集合之间及集合与集合之间的继承依赖关系. 链接器在链接时根据符号版本脚本中指定的关系来产生共享库, 并且设置符号的集合与它们之间的关系.
  • 范围机制 (Scoping)

    • 链接器会把原先是全局的符号全部变成局部的, 这样一来, 共享库外部的应用程序或其他的共享库将无法访问这些符号. 这种方式可以用于保护那些共享库内部的公用实用函数, 但是共享库的作者又不希望共享库的使用者能够有意或无意地访问这些函数. 这种方法又被称为范围机制 (Scoping) , 它实际上是对 C 语言没有很好的符号可见范围的控制机制的一种补充, 或者说是一种补救性质的措施.
  • 在程序运行时, 动态链接器会通过程序内记录的它所依赖的所有共享库的符号集合版本信息, 然后判定当前系统共享库中的符号集合版本是否满足这些被依赖的符号集合.

    • 通过这样的机制, 就可以保证那些在高次版本共享库的系统中编译的程序在低次版本共享库中运行. 如果该低次版本的共享库满足符号集合的要求, 比如 app_foolibfoo.so.1 次版本号大于等于 3 的系统中运行, 就没有任何问题. 如果低次版本共享库不满足要求, 如 app_foolibfoo.so.1 次版本号小于 3 的系统中运行, 动态链接器就会意识到当前系统的共享库次版本号不满足要求, 从而阻止程序运行, 以防止造成进一步的损失.

8.2.3 Linux中的符号版本

  • Linux 系统下共享库的符号版本机制并没有被广泛应用, 主要使用共享库符号版本机制的是 Glibc 软件包中所提供的 20 多个共享库.

  • GCC 在 Solaris 系统中的符号版本机制的基础上还提供了两个扩展

    • 第一个扩展是, 除了可以在符号版本脚本中指定符号的版本之外, GCC 还允许使用一个叫做 .symver 的汇编宏指令来指定符号的版本.

    • 第二个扩展是 GCC 允许多个版本的同一个符号存在于一个共享库中, 也就是说, 在链接层面提供了某种形式的符号重载机制.

      • 为什么要提供这种符号多版本重载机制呢?有时候当我们对共享库进行升级的时候, 可能仅仅更改了一个符号的接口或含义, 那么, 如果仅仅为了这个符号的更改而升级主版本号, 那么将会对系统带来很大的影响. 理想的情况是, 当共享库发生比较小的变化时, 新版的共享库能够在原来的基础上做些补充, 而并不影响旧版的功能, 即能完全保持向后兼容性, 争取做到不更改共享库的 SO-NAME, 即不更改主版本号.
  • Linux 系统中符号版本机制实践

    • 当我们使用 ld 链接一个共享库时, 可以使用 -- version-script 参数. 如果使用 GCC, 则可以使用 - Xlinker 参数加 -- version-script , 相当于把 -- version-script 传递给 ld 链接器.
      1
      gcc -shared -fPIC lib.c -Xlinker --version-script lib.ver -o lib.so

8.3 共享库系统路径

  • 大多数包括 Linux 在内的开源操作系统都遵守一个叫做 FHS (File Hierarchy Standard) 的标准.

    • 规定了一个系统中的系统文件应该如何存放, 包括各个目录的结构, 组织和作用, 这有利于促进各个开源操作系统之间的兼容性.
  • /lib: 这个位置主要存放系统最关键和基础的共享库, 比如动态链接器, C 语言运行库, 数学库等.

  • /usr/lib: 这个目录下主要保存的是一些非系统运行时所需要的关键性的共享库, 主要是一些开发时用到的共享库, 这些共享库一般不会被用户的程序或 shell 脚本直接用到. 这个目录下面还包含了开发时可能会用到的静态库, 目标文件等.

  • /usr/local/lib: 这个目录用来放置一些跟操作系统本身并不十分相关的库, 主要是一些第三方的应用程序的库.

8.4 共享库查找过程

  • ld.so.conf 是一个文本配置文件, 存放着目录信息.
  • 直接从 /etc/ld.so.cache 里面查找. 而 /etc/ld.so.cache 的结构是经过特殊设计的, 非常适合查找, 所以这个设计大大加快了共享库的查找过程.
  • 所以理论上讲, 如果我们在系统指定的共享库目录下添加, 删除或更新任何一个共享库, 或者我们更改了 /etc/ld.so.conf 的配置, 都应该运行ldconfig 这个程序, 以便调整 SO-NAME 和 /etc/ld.so.cache.

8.5 环境变量

  • LD_LIBRARY_PATH

    • 使用 LD_LIBRARY_PATH 环境变量, 这个方法可以临时改变某个应用程序的共享库查找路径, 而不会影响系统中的其他程序, 默认情况下,
      LD_LIBRARY_PATH 为空.

      • 类似的效果: 直接运行动态链接器来启动程序, 比如: $/lib/ld-linux.so.2 –library-path /home/user /bin/ls.
    • 动态链接器会按照下列顺序依次装载或查找共享对象:

      • 由环境变量 LD_LIBRARY_PATH 指定的路径.
      • 由路径缓存文件 /etc/ld.so.cache 指定的路径.
      • 默认共享库目录, 先 /usr/lib, 然后 /lib.
    • LD_LIBRARY_PATH 不应该被滥用. 也就是说, 普通用户在正常情况下不应该随意设置
      LD_LIBRARY_PATH 来调整共享库搜索目录. 随意修改 LD_LIBRARY_PATH 并且将其导出至全局范围, 将可能引起其他应用程序运行出现的问题. LD_LIBRARY_PATH 也会影响 GCC 编译时查找库的路径, 它里面包含的目录相当于链接时 GCC 的 -L 参数.

  • LD_PRELOAD

    • LD_LIBRARY_PATH 里面所指定的目录中的共享库还要优先.
    • 无论程序是否依赖于它们, LD_PRELOAD 里面指定的共享库或目标文件都会被装载.
    • 全局符号就会覆盖后面加载的同名全局符号, 这使得我们可以很方便地做到改写标准 C 库中的某个或某几个函数而不影响其他函数, 对于程序的调试或测试非常有用.
    • 正常情况下应该尽量避免使用 LD_PRELOAD, 比如一个发布版本的程序运行不应该依赖于LD_PRELOAD.
    • /etc/ld.so.preload, 它的作用与 LD_PRELOAD 一样.
  • LD_DEBUG

    • 当我们设置这个变量时, 动态链接器会在运行时打印出各种有用的信息, 对于我们开发和调试共享库有很大的帮助.

    • 可以设置的值

      • files 打印出了整个装载过程.
      • bindings 显示动态链接的符号绑定过程.
      • libs 显示共享库的查找过程.
      • versions 显示符号的版本依赖关系.
      • reloc 显示重定位过程.
      • symbols 显示符号表查找过程.
      • statistics 显示动态链接过程中的各种统计信息.

8.6 共享库的创建和安装

8.6.1 共享库的创建

  • GCC 选项

    • - shared 表示输出结果是共享库类型的 .

    • -fPIC 表示使用地址无关代码 (Position Independent Code) 技术来生产输出文件.

    • -Wl 参数可以将指定的参数传递给链接器.

      • 比如当我们使用 -Wl , - soname , my_soname 时, GCC 会将 -soname my_soname 传递给链接器, 用来指定输出共享库的 SO-NAME.
      • 如果我们不使用 -soname 来指定共享库的 SO-NAME, 那么该共享库默认就没有 SO-NAME, 即使用 ldconfig 更新 SO-NAME 的软链接时, 对该共享库也没有效果.
  • 注意事项

    • 不要把输出共享库中的符号和调试信息去掉, 也不要使用 GCC 的 -fomit-frame-pointer 选项, 这样做虽然不会导致共享库停止运行, 但是会影响调试共享库.

    • 使用链接器的 -rpath 选项 (或者 GCC 的 -Wl,-rpath) 指定链接产生的目标程序的共享库查找路径, 类似于 LD_LIBRARY_PATH.

    • 默认情况下, 链接器在产生可执行文件时, 只会将那些链接时被其他共享模块引用到的符号放到动态符号表, 这样可以减少动态符号表的大小.

      • 当程序使用 dlopen() 动态加载某个共享模块, 而该共享模块须反向引用主模块的符号时, 有可能主模块的某些符号因为在链接时没有被其他共享模块引用而没有被放到动态符号表里面, 导致了反向引用失败.
      • ld 链接器提供了一个 -export-dynamic 的参数, 这个参数表示链接器在生产可执行文件时, 将所有全局符号导出到动态符号表, 以防止出现上述问题. 我们也可以在 GCC 中使用 -Wl,-export-dynamic 将该参数传递给链接器.

8.6.2 清除符号信息

  • strip 的工具

    • 清除掉共享库或可执行文件的所有符号和调试信息.
    • $strip libfoo.so
  • 使用 ld-s-S 参数, 使得链接器生成输出文件时就不产生符号信息.

    • -s-S 的区别是: -S 消除调试符号信息, 而 -s 消除所有符号信息. 我们也可以在 gcc 中通过 -Wl,-s-Wl,-Sld 传递这两个参数.

8.6.3 共享库的安装

  • 有 root 权限

    • 标准的共享库目录, 如 /lib, /usr/lib 等, 然后运行 ldconfig 即可.
  • 无 root 权限

    • 建立相应的 SO-NAME 软链接, 并告诉编译器和程序如何查找该共享库, ldconfig –n shared_library_directory.
    • GCC 提供了两个参数 -L-l, 分别用于指定共享库搜索目录和共享库的路径. 当然也可以使用前面提到过的 -rpath 参数.

8.6.4 共享库构造和析构函数

  • 共享库在被装载时能够进行一些初始化工作.

    • GCC 提供了一种共享库的构造函数, 只要在函数声明时加上 __ attribute__((constructor)) 的属性.
  • 析构函数, 我们可以使用在函数声明时加上 __ attribute__((destructor)) 的属性.

  • 如果我们使用了这种析构或构造函数, 那么必须使用系统默认的标准运行库和启动文件, 即不可以使用 GCC 的 -nostartfiles- nostdlib 这两个参数.

  • 多个构造函数, GCC 为我们提供了一个参数叫做优先级.

    1
    2
    void __attribute__((constructor(5))) init_function1(void); 
    void __attribute__((constructor(10))) init_function2(void);
    • 对于构造函数来说, 属性中优先级数字越小的函数将会在优先级大的函数之前运行. 而对于析构函数来讲, 则刚好相反.

8.6.5 共享库脚本

  • 共享库还可以是符合一定格式的链接脚本文件. 通过这种脚本文件, 我们可以把几个现有的共享库通过一定的方式组合起来, 从用户的角度看就是一个新的共享库.

    • GROUP( /lib/libc.so.6 /lib/libm.so.2)
  • 这里的脚本与 LD 的脚本从语法和命令上来讲没什么区别, 它们的作用也相似, 即将一个或多个输入文件以一定的格式经过变换以后形成一个输出文件. 我们也可以将这种共享库脚本叫做动态链接脚本, 因为这个链接过程是动态完成的, 也就是运行时完成的.

第9章 Windows下的动态链接

略过.

《程序员的自我修养--链接装载与库》学习笔记 Part 3 装载与动态链接

https://www.chuxin911.com/linkage_loading_lib_part3_loading_dynamic_linkage_20220625/

作者

cx

发布于

2022-06-25

更新于

2022-07-16

许可协议