剖析共享程序库
初识共享程序库
共享程序库是现代 UNIX® 系统中有效利用空间和资源的基础。SUSE 系统中的 C 程序库大约有 1.3 MB。为 /usr/bin 中每一个程序(我有 2,569 个)制作副本将占去几个 G 的空间。
当然这个数字有一些夸张 —— 静态链接程序只合并它们使用的那部分程序库。 尽管如此, printf()
的所有副本所占用的空间数量也会让系统 显得非常臃肿。
共享程序库不仅可以节省磁盘空间,而且还可以节省内存。内核可以在内存中保持某个共享程序库的一个惟一副本,并在多个应用程序间共享这个副本。所以,我们不但可以在磁盘上只有 printf()
的 一个副本,而且在内存中也只需要一个副本。这对性能有很大的影响。
在本文中,我们将讨论共享程序库所使用的底层技术,以及在共享程序库版本号帮助下预防兼容性难题的方法,过去,本机共享程序库实现也曾遇到过这些难题。首先来看一下共享程序库的工作原理。
这个概念理解起来非常简单。拥有一个程序库;然后共享这个程序库。但是,当您的程序尝试调用 printf()
时,也就是说实际操作的时候,具体发生的事情却稍微有点复杂。
这个过程在静态链接系统中比在动态链接系统中更简单。在静态链接系统中,生成的代码会持有对某个 函数的引用。链接器使用加载该函数的真实地址去替换这个引用,以便生成的二进制代码 在适当的位置会有正确的地址。然后,在运行代码时,只需要跳转到相应的地址即可。对管理员来说, 这是一项简单的任务,因为它允许您对只在程序中的某个位置上实际引用的那些对象进行链接。
但是大部分共享程序库都是动态链接的。这具有一些更深层次的意义。其中一方面是,您不能事先预计 某个函数在调用时的确切地址!(以及静态链接的共享程序库模式,比如 BSD/OS 中的,但是它们 不在本文讨论范围之内。)
动态链接器可以为每个被链接的函数做相当多的工作,所以大部分链接器都是不积极的。只有在函数被调用时,它们才实际做一些工作。C 程序库中有一千多个外部可见的符号,有大约三千多个本地符号,因此这种 方法可以节省非常多的时间。
实现此奇妙功能的是一个称为 过程链接表(Procedure Linkage Table)(PLT)的数据块,它是程序中 的一个表,列出了程序所调用的每一个函数。当程序开始运行时,PLT 包含每个函数的代码,以便查询运行期链接器,从而获得已加载某个函数的地址。然后它会在表中填入这个条目并跳转到那个已加载函数。当每个函数被 调用时,它的 PLT 中的条目就会被简化为一个到那个已加载函数的直接跳转。
不过,重要的是,要注意到还有一个间接的额外层次 —— 可以通过跳转到某个表来解析每个函数调用。
这意味着您最终要链接的程序库最好与调用它的代码相兼容。使用静态链接的可执行文件,可以在某种程度上 保证不会发生任何改变。如果使用动态链接,就得不到这样的保证。
当出现新版本的程序库时会怎样?特别是新版本改变了某个给定函数的调用次序时,又会怎样?
版本号可以解决这个问题 —— 共享的程序库将拥有一个版本号。当一个程序链接到某个程序库时,程序中 会存储一个它计划支持的版本号。如果更改程序库,那么版本号就会不匹配,程序也就不会被链接到较 新版本的程序库。
不过,动态链接的可能优势之一在于修正缺陷。如果可以修正程序库中的缺陷,而且不必重新编译上千个程序,就 可以利用这一修正功能,这将是非常令人愉快的。有时,需要链接到某个较新的版本。
不幸的是,这会导致在某些情况下,您希望链接到较新的版本,而在另外一些情况下,您宁愿坚持使用较老的版本。 不过,有一个解决方案 —— 使用两类版本号:
- 主版本号表明程序库版本之间的潜在不兼容性。
- 次要版本号表明只是修正了缺陷。
这样,在大部分情形下,加载具有相同主版本号和更高次要版本号的程序库是安全的;而加载主版本号更高的程序是不安全的行为。
为了让用户(和程序员)不必追踪程序库版本号和更新,系统提供了大量的符号链接。 通常,其模式是:
libexample.so
将是一个指向
libexample.so.N
的链接,其中 N 是在系统中可以找到的最高的 主 版本号。
对受支持的每一个主版本号而言,
libexample.so.N
将是一个指向
libexample.so.N.M
的链接,其中 M 是最高的 次要 版本号。
这样,如果为链接器指定了 -lexample
,那么它会去寻找 libexample.so
,这是一个符号链接,指向某个指向最新版本的符号链接。 另一方面,当加载某个现有程序时,它将尝试去加载 libexample.so.N
, 其中 N 是它先前链接的版本。各得其所!
为了调试使用共享程序库的问题,对它们如何编译有更多一些了解会对您有所帮助。
在传统的静态程序库中,生成的代码通常封装在一个程序库文件中(其名称以 .a
结尾),然后传递给链接器。在动态程序库中,程序库文件的名称通常以 .so
结尾。 文件结构稍有不同。
常规的静态程序库的格式是 ar
工具(一个非常简单的存档程序,类似于 tar
,但是更简单)所创建的那种格式。 相反,共享程序库通常以更复杂的文件格式存储。
在现代 Linux 系统中,这一格式通常是 ELF 二进制格式(可执行与可链接格式(Executable and Linkable Format))。 在 ELF 中,每个文件的组成包括:一个 ELF 头,随后是零或者一些段(segments),以及零或者一些区段(sections)。 段 中包含文件的运行时执行所需要的信息,而 区段 中包含用于链接和重定位的重要数据。 整个文件中的每个字节每次只能由一个区段使用,不过可以存在不被任何区段所包含的孤立字节。 通常,在 UNIX 可执行文件中,一个或多个区段会封装在一个段内。
ELF 格式中包含用于应用程序和程序库的规范。但程序库格式要复杂得多,不仅仅是对象模块的简单存档。
链接器将所有对符号的引用进行分类,标识出它们是在哪个程序库中找到的。将静态程序库的符号添加到最终的 可执行文件中;然后将共享程序库的符号放入 PLT 中,最后创建对 FLT 的引用。在完成这些任务之后,生成的可执行文件 会拥有一个列表,该列表列出了计划从运行期将加载的程序库中找出的那些符号。
在运行期间,应用程序将加载动态链接器。实际上,动态链接器本身使用与共享程序库相同种类的版本号。 例如,在 SUSE Linux 9.1 中, /lib/ld-linux.so.2
文件是一个指向 /lib/ld-linux.so.2.3.3
的符号链接。另一方面,寻找 /lib/ld-linux.so.1
的程序不会尝试使用新的版本。
然后动态链接器开始进行所有有趣的工作。它会查明某个程序先前链接到了哪些程序库(以及哪个版本), 然后加载它们。加载程序库的步骤包括:
- 找到程序库(它可能在系统中若干个目录中的任意一个目录中)。
- 将程序库映射到程序的地址空间。
- 分配程序库可能需要的由零填充的内存块。
- 添加程序库的符号表。
调试这一过程可能会比较困难。您可能会遇到多种问题。例如,如果动态链接器不能找到某个给定的 程序库,那么它将停止加载程序。如果它找到了所有需要的程序库,但却无法找到某个符号,那么它也可能会因此而停止加载操作 (但是可能直到真正尝试去引用那个符号时才会发生这种情形) —— 这是一种很少见的情况,因为通常如果 不存在某个符号,那么在初始化链接的时候就会被警告。
当链接某个程序时,在运行期您可以指定另外的搜索路径。在 gcc
中,其 语法是 -Wl,-R/path
。如果程序已经被链接,那么您也可以设置环境变量 LD_LIBRARY_PATH
来改变这一行为。通常只是在应用程序需要搜索的路径 不是系统级默认路径的一部分时才需要这样做,对大部分 Linux 系统来说,这种情况很少见。 理论上,Mozilla 用户可以发布某个使用这个路径设置所编译的二进制程序,但是他们 更倾向于发布包装器(wrapper)脚本,在启动可执行程序之前正确地设置程序库路径。
设置程序库路径可以为两个应用程序需要同一程序库的不兼容版本的这种罕见情况提供一个迂回解决方案。可以使用包装器脚本使某一应用程序在使用特殊版本程序库的目录中进行搜索。这称不上是一个 完美的解决方案,但是在某些情况下,这是您能采用的最佳方法。
如果出于不得已的原因需要为很多程序添加某个路径,那么也可以修改系统的默认搜索路径。通过 /etc/ld.so.conf
控制动态链接器,该文件包含默认搜索路径的列表。 对 LD_LIBRARY_PATH
中指定的任何路径的搜索都要先于 ld.so.conf
中列出的路径,所以用户可以覆盖这些设置。
大部分用户没有理由修改系统默认程序库搜索路径;通常环境变量更适用于修改搜索路径,比如 连接某个工具包中的程序库,或者使用某个程序库的较新版本的测试程序。
ldd
是调试共享程序库问题的一个实用工具。其名称来自 list dynamic dependencies。这个程序会查看某个给定的可执行程序或者共享程序库,并 指出它需要加载哪些共享程序库以及要使用哪些版本。输出类似如下:
清单 1. /bin/sh 的依赖
$ ldd /bin/sh linux-gate.so.1 => (0xffffe000) libreadline.so.4 => /lib/libreadline.so.4 (0x40036000) libhistory.so.4 => /lib/libhistory.so.4 (0x40062000) libncurses.so.5 => /lib/libncurses.so.5 (0x40069000) libdl.so.2 => /lib/libdl.so.2 (0x400af000) libc.so.6 => /lib/tls/libc.so.6 (0x400b2000) /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000) |
看到一个“简单的”的程序使用了这么多个程序库,可能会有些令人惊讶。或许是 libhistory
需要 libncurses
。 为了查明真相,我们只需要运行另一个 ldd
命令:
清单 2. libhistory 的依赖
$ ldd /lib/libhistory.so.4 linux-gate.so.1 => (0xffffe000) libncurses.so.5 => /lib/libncurses.so.5 (0x40026000) libc.so.6 => /lib/tls/libc.so.6 (0x4006b000) /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x80000000) |
在某些情况下,可能需要为应用程序指定另外的程序库路径。例如,对 Mozilla 二进制程序尝试 运行 ldd
所得到输出的前几行如下所示:
清单 3. 运行 dll 查找不在搜索路径中的 程序库的结果
$ ldd /opt/mozilla/lib/mozilla-bin linux-gate.so.1 => (0xffffe000) libmozjs.so => not found libplds4.so => not found libplc4.so => not found libnspr4.so => not found libpthread.so.0 => /lib/tls/libpthread.so.0 (0x40037000) |
为什么找不到这些程序库?因为它们不在常见的程序库搜索路径中。实际上,它们在 /opt/mozilla/lib
中,所以,解决方案之一是将这个目录添加到 LD_LIBRARY_PATH
中。
另一个选项是将路径设置为 .
,并在这个目录下运行 ldd
, 尽管这样做更危险 —— 将当前目录添加到程序库路径中与将它添加到可执行程序路径中一样有着潜在的危险。
在这种情况下,将这些程序库所在的目录添加到系统级搜索路径中显然不是一个好办法。只有 Mozilla 需要这些程序库。
说起 Mozilla,如果您觉得自己从未见过超过几行的程序库,那么在某种程度上,Mozilla 是一个更为典型的 大型应用程序。现在您可以明白为什么 Mozilla 的启动需要那么长时间了吧!
清单 4. mozilla-bin 的依赖性
linux-gate.so.1 => (0xffffe000) libmozjs.so => ./libmozjs.so (0x40018000) libplds4.so => ./libplds4.so (0x40099000) libplc4.so => ./libplc4.so (0x4009d000) libnspr4.so => ./libnspr4.so (0x400a2000) libpthread.so.0 => /lib/tls/libpthread.so.0 (0x400f5000) libdl.so.2 => /lib/libdl.so.2 (0x40105000) libgtk-x11-2.0.so.0 => /opt/gnome/lib/libgtk-x11-2.0.so.0 (0x40108000) libgdk-x11-2.0.so.0 => /opt/gnome/lib/libgdk-x11-2.0.so.0 (0x40358000) libatk-1.0.so.0 => /opt/gnome/lib/libatk-1.0.so.0 (0x403c5000) libgdk_pixbuf-2.0.so.0 => /opt/gnome/lib/libgdk_pixbuf-2.0.so.0 (0x403df000) libpangoxft-1.0.so.0 => /opt/gnome/lib/libpangoxft-1.0.so.0 (0x403f1000) libpangox-1.0.so.0 => /opt/gnome/lib/libpangox-1.0.so.0 (0x40412000) libpango-1.0.so.0 => /opt/gnome/lib/libpango-1.0.so.0 (0x4041f000) libgobject-2.0.so.0 => /opt/gnome/lib/libgobject-2.0.so.0 (0x40451000) libgmodule-2.0.so.0 => /opt/gnome/lib/libgmodule-2.0.so.0 (0x40487000) libglib-2.0.so.0 => /opt/gnome/lib/libglib-2.0.so.0 (0x4048b000) libm.so.6 => /lib/tls/libm.so.6 (0x404f7000) libstdc++.so.5 => /usr/lib/libstdc++.so.5 (0x40519000) libgcc_s.so.1 => /lib/libgcc_s.so.1 (0x405d5000) libc.so.6 => /lib/tls/libc.so.6 (0x405dd000) /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000) libX11.so.6 => /usr/X11R6/lib/libX11.so.6 (0x406f3000) libXrandr.so.2 => /usr/X11R6/lib/libXrandr.so.2 (0x407ef000) libXi.so.6 => /usr/X11R6/lib/libXi.so.6 (0x407f3000) libXext.so.6 => /usr/X11R6/lib/libXext.so.6 (0x407fb000) libXft.so.2 => /usr/X11R6/lib/libXft.so.2 (0x4080a000) libXrender.so.1 => /usr/X11R6/lib/libXrender.so.1 (0x4081e000) libfontconfig.so.1 => /usr/lib/libfontconfig.so.1 (0x40826000) libfreetype.so.6 => /usr/lib/libfreetype.so.6 (0x40850000) libexpat.so.0 => /usr/lib/libexpat.so.0 (0x408b9000) |
有兴趣深入了解 Linux 中的动态链接的用户有很多选择。GNU 编译器和链接器工具链(linker tool chain)文档都非常好, 虽然其内容是以 info
格式存储的,而且也没有在标准手册页中提及。
ld.so
的手册页包含有一个非常详尽的列表,列出了改变动态链接器行为的变量, 以及对过去曾经使用的不同版本的动态链接器的说明。
大部分 Linux 文档都假定所有共享程序库都是动态链接的,因为在 Linux 系统上,它们通常是这样的。 实现静态链接的共享程序库需要做的工作非常多,而且大部分用户不会因此获得任何好处,尽管支持这个特性的系统的性能会有显著改变。
如果您正在使用现成的预先包装好的系统,那么您可能不会遇到太多的共享程序库版本 —— 系统可能只附带它要链接的那些共享程序库版本。另一方面,如果您做过很多次更新和源代码构建,那么您可能最终得到 多个版本的共享程序库,因为老版本依然会被保留,“以防万一”。
像平时一样,如果想了解更多,那么就去亲自实践吧。记住,在某个系统上,几乎所有程序都会引用一些相同的共享程序库,所以,如果破坏了系统的某个核心共享程序库,那么您就得去求助系统恢复工具了。
Linux 动态库剖析
进程与 API
库用于将相似函数打包在一个单元中。然后这些单元就可为其他开发人员所共享,并因此有了模块化编程这种说法 — 即,从模块中构建程序。Linux 支持两种类型的库,每一种库都有各自的优缺点。静态库包含在编译时静态绑定到一个程序的函数。动态库则不同,它是在加载应用程序时被加载的,而且它与应用程序是在运行时绑定的。图 1 展示了 Linux 中的库的层次结构。
图 1. Linux 中的库层次结构
使用共享库的方法有两种:您既可以在运行时动态链接库,也可以动态加载库并在程序控制之下使用它们。本文对这两种方法都做了探讨。
静态库较适宜于较小的应用程序,因为它们只需要最小限度的函数。而对于需要多个库的应用程序来说,则适合使用共享库,因为它们可以减少应用程序对内存(包括运行时中的磁盘占用和内存占用)的占用。这是因为多个应用程序可以同时使用一个共享库;因此,每次只需要在内存上复制一个库。要是静态库的话,每一个运行的程序都要有一份库的副本。
GNU/Linux 提供两种处理共享库的方法(每种方法都源于 Sun Solaris)。您可以动态地将程序和共享库链接并让 Linux 在执行时加载库(如果它已经在内存中了,则无需再加载)。另外一种方法是使用一个称为动态加载的过程,这样程序可以有选择地调用库中的函数。使用动态加载过程,程序可以先加载一个特定的库(已加载则不必),然后调用该库中的某一特定函数(图 2 展示了这两种方法)。这是构建支持插件的应用程序的一个普遍的方法。我稍候将在本文探讨并示范该应用程序编程接口(API)。
图 2. 静态链接与动态链接
现在,让我们深入探讨一下使用 Linux 中的动态链接的共享库的过程。当用户启动一个应用程序时,它们正在调用一个可执行和链接格式(Executable and Linking Format,ELF)映像。内核首先将 ELF 映像加载到用户空间虚拟内存中。然后内核会注意到一个称为.interp
的 ELF 部分,它指明了将要被使用的动态链接器(/lib/ld-linux.so),如清单 1 所示。这与 UNIX® 中的脚本文件的解释器定义(#!/bin/sh)很相似:只是用在了不同的上下文中。
清单 1. 使用 readelf 来显示程序标题
mtj@camus:~/dl$ readelf -l dl Elf file type is EXEC (Executable file) Entry point 0x8048618 There are 7 program headers, starting at offset 52 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4 INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1 [Requesting program interpreter: /lib/ld-linux.so.2] LOAD 0x000000 0x08048000 0x08048000 0x00958 0x00958 R E 0x1000 LOAD 0x000958 0x08049958 0x08049958 0x00120 0x00128 RW 0x1000 DYNAMIC 0x00096c 0x0804996c 0x0804996c 0x000d0 0x000d0 RW 0x4 NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4 GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4 ... mtj@camus:~dl$ |
注意,ld-linux.so 本身就是一个 ELF 共享库,但它是静态编译的并且不具备共享库依赖项。当需要动态链接时,内核会引导动态链接(ELF 解释器),该链接首先会初始化自身,然后加载指定的共享对象(已加载则不必)。接着它会执行必要的再定位,包括目标共享对象所使用的共享对象。LD_LIBRARY_PATH
环境变量定义查找可用共享对象的位置。定义完成后,控制权会被传回到初始程序以开始执行。
再定位是通过一个称为 Global Offset Table(GOT)和 Procedure Linkage Table(PLT)的间接机制来处理的。这些表格提供了 ld-linux.so 在再定位过程中加载的外部函数和数据的地址。这意味着无需改动需要间接机制(即,使用这些表格)的代码:只需要调整这些表格。一旦进行加载,或者只要需要给定的函数,就可以发生再定位(稍候在 用 Linux 进行动态加载 小节中会看到更多的差别)。
再定位完成后,动态链接器就会允许任何加载的共享程序来执行可选的初始化代码。该函数允许库来初始化内部数据并备之待用。这个代码是在上述 ELF 映像的 .init
部分中定义的。在卸载库时,它还可以调用一个终止函数(定义为映像的 .fini
部分)。当初始化函数被调用时,动态链接器会把控制权转让给加载的原始映像。
Linux 并不会自动为给定程序加载和链接库,而是与应用程序本身共享该控制权。这个过程就称为动态加载。使用动态加载,应用程序能够先指定要加载的库,然后将该库作为一个可执行文件来使用(即调用其中的函数)。但是正如您在前面所了解到的,用于动态加载的共享库与标准共享库(ELF 共享对象)无异。事实上,ld-linux
动态链接器作为 ELF 加载器和解释器,仍然会参与到这个过程中。
动态加载(Dynamic Loading,DL)API 就是为了动态加载而存在的,它允许共享库对用户空间程序可用。尽管非常小,但是这个 API 提供了所有需要的东西,而且很多困难的工作是在后台完成的。表 1 展示了这个完整的 API。
表 1. Dl API
函数 | 描述 |
---|---|
dlopen | 使对象文件可被程序访问 |
dlsym | 获取执行了 dlopen 函数的对象文件中的符号的地址 |
dlerror | 返回上一次出现错误的字符串错误 |
dlclose | 关闭目标文件 |
该过程首先是调用 dlopen
,提供要访问的文件对象和模式。调用 dlopen
的结果是稍候要使用的对象的句柄。mode
参数通知动态链接器何时执行再定位。有两个可能的值。第一个是 RTLD_NOW
,它表明动态链接器将会在调用 dlopen
时完成所有必要的再定位。第二个可选的模式是 RTLD_LAZY
,它只在需要时执行再定位。这是通过在内部使用动态链接器重定向所有尚未再定位的请求来完成的。这样,动态链接器就能够在请求时知晓何时发生了新的引用,而且再定位可以正常进行。后面的调用无需重复再定位过程。
还可以选择另外两种模式,它们可以按位 OR
到 mode
参数中。RTLD_LOCAL
表明其他任何对象都无法使加载的共享对象的符号用于再定位过程。如果这正是您想要的的话(例如,为了让共享的对象能够调用原始进程映像中的符号),那就使用 RTLD_GLOBAL
吧。
dlopen
函数还会自动解析共享库中的依赖项。这样,如果您打开了一个依赖于其他共享库的对象,它就会自动加载它们。函数返回一个句柄,该句柄用于后续的 API 调用。dlopen
的原型为:
#include <dlfcn.h> void *dlopen( const char *file, int mode ); |
有了 ELF 对象的句柄,就可以通过调用 dlsym
来识别这个对象内的符号的地址了。该函数采用一个符号名称,如对象内的一个函数的名称。返回值为对象符号的解析地址:
void *dlsym( void *restrict handle, const char *restrict name ); |
如果调用该 API 时发生了错误,可以使用 dlerror
函数返回一个表示此错误的人类可读的字符串。该函数没有参数,它会在发生前面的错误时返回一个字符串,在没有错误发生时返回 NULL:
char *dlerror(); |
最后,如果无需再调用共享对象的话,应用程序可以调用 dlclose
来通知操作系统不再需要句柄和对象引用了。它完全是按引用来计数的,所以同一个共享对象的多个用户相互间不会发生冲突(只要还有一个用户在使用它,它就会待在内存中)。任何通过已关闭的对象的 dlsym
解析的符号都将不再可用。
char *dlclose( void *handle ); |
了解了 API 之后,下面让我们来看一看 DL API 的例子。在这个应用程序中,您主要实现了一个 shell,它允许操作员来指定库、函数和参数。换句话说,也就是用户能够指定一个库并调用该库(先前未链接于该应用程序的)内的任意一个函数。首先使用 DL API 来解析该库中的函数,然后使用用户定义的参数(用来发送结果)来调用它。清单 2 展示了完整的应用程序。
清单 2. 使用 DL API 的 Shell
#include <stdio.h> #include <dlfcn.h> #include <string.h> #define MAX_STRING 80 void invoke_method( char *lib, char *method, float argument ) { void *dl_handle; float (*func)(float); char *error; /* Open the shared object */ dl_handle = dlopen( lib, RTLD_LAZY ); if (!dl_handle) { printf( "!!! %s\n", dlerror() ); return; } /* Resolve the symbol (method) from the object */ func = dlsym( dl_handle, method ); error = dlerror(); if (error != NULL) { printf( "!!! %s\n", error ); return; } /* Call the resolved method and print the result */ printf(" %f\n", (*func)(argument) ); /* Close the object */ dlclose( dl_handle ); return; } int main( int argc, char *argv[] ) { char line[MAX_STRING+1]; char lib[MAX_STRING+1]; char method[MAX_STRING+1]; float argument; while (1) { printf("> "); line[0]=0; fgets( line, MAX_STRING, stdin); if (!strncmp(line, "bye", 3)) break; sscanf( line, "%s %s %f", lib, method, &argument); invoke_method( lib, method, argument ); } } |
要构建这个应用程序,需要通过 GNU Compiler Collection(GCC)使用如下的编译行。选项 -rdynamic
用来通知链接器将所有符号添加到动态符号表中(目的是能够通过使用 dlopen
来实现向后跟踪)。-ldl
表明一定要将 dllib
链接于该程序。
gcc -rdynamic -o dl dl.c -ldl |
再回到 清单 2,main
函数仅充当解释器,解析来自输入行的三个参数(库名、函数名和浮点参数)。如果出现 bye
的话,应用程序就会退出。否则的话,这三个参数就会传递给使用 DL API 的 invoke_method
函数。
首先调用 dlopen
来访问目标文件。如果返回 NULL 句柄,表示无法找到对象,过程结束。否则的话,将会得到对象的一个句柄,可以进一步询问对象。然后使用 dlsym
API 函数,尝试解析新打开的对象文件中的符号。您将会得到一个有效的指向该符号的指针,或者是得到一个 NULL 并返回一个错误。
在 ELF 对象中解析了符号后,下一步就只需要调用函数。要注意一下这个代码和前面讨论的动态链接的差别。在这个例子中,您强行将目标文件中的符号地址用作函数指针,然后调用它。而在前面的例子是将对象名作为函数,由动态链接器来确保符号指向正确的位置。虽然动态链接器能够为您做所有麻烦的工作,但这个方法会让您构建出极其动态的应用程序,它们可以再运行时被扩展。
调用 ELF 对象中的目标函数后,通过调用 dlclose
来关闭对它的访问。
清单 3 展示了一个如何使用这个测试程序的例子。在这个例子中,首先编译程序而后执行它。接着调用了 math 库(libm.so)中的几个函数。完成演示后,程序现在能够用动态加载来调用共享对象(库)中的任意函数了。这是一个很强大的功能,通过它还能够给程序扩充新的功能。
清单 3. 使用简单的程序来调用库函数
mtj@camus:~/dl$ gcc -rdynamic -o dl dl.c -ldl mtj@camus:~/dl$ ./dl > libm.so cosf 0.0 1.000000 > libm.so sinf 0.0 0.000000 > libm.so tanf 1.0 1.557408 > bye mtj@camus:~/dl$ |
Linux 提供了很多种查看和解析 ELF 对象(包括共享库)的工具。其中最有用的一个当属 ldd
命令,您可以使用它来发送共享库依赖项。例如,在 dl
应用程序上使用 ldd
命令会显示如下内容:
mtj@camus:~/dl$ ldd dl linux-gate.so.1 => (0xffffe000) libdl.so.2 => /lib/tls/i686/cmov/libdl.so.2 (0xb7fdb000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7eac000) /lib/ld-linux.so.2 (0xb7fe7000) mtj@camus:~/dl$ |
ldd
所告诉您的是:该 ELF 映像依赖于 linux-gate.so(一个特殊的共享对象,它处理系统调用,它在文件系统中无关联文件)、libdl.so(DL API)、GNU C
库(libc.so)以及 Linux 动态加载器(因为它里面有共享库依赖项)。
readelf
命令是一个有很多特性的实用程序,它让您能够解析和读取 ELF 对象。readelf
有一个有趣的用途,就是用来识别对象内可再定位的项。对于我们这个简单的程序来说(清单 2 展示的程序),您可以看到需要再定位的符号为:
mtj@camus:~/dl$ readelf -r dl Relocation section '.rel.dyn' at offset 0x520 contains 2 entries: Offset Info Type Sym.Value Sym. Name 08049a3c 00001806 R_386_GLOB_DAT 00000000 __gmon_start__ 08049a78 00001405 R_386_COPY 08049a78 stdin Relocation section '.rel.plt' at offset 0x530 contains 8 entries: Offset Info Type Sym.Value Sym. Name 08049a4c 00000207 R_386_JUMP_SLOT 00000000 dlsym 08049a50 00000607 R_386_JUMP_SLOT 00000000 fgets 08049a54 00000b07 R_386_JUMP_SLOT 00000000 dlerror 08049a58 00000c07 R_386_JUMP_SLOT 00000000 __libc_start_main 08049a5c 00000e07 R_386_JUMP_SLOT 00000000 printf 08049a60 00001007 R_386_JUMP_SLOT 00000000 dlclose 08049a64 00001107 R_386_JUMP_SLOT 00000000 sscanf 08049a68 00001907 R_386_JUMP_SLOT 00000000 dlopen mtj@camus:~/dl$ |
从这个列表中,您可以看到各种各样的需要再定位(到 libc.so)的 C
库调用,包括对 DL API(libdl.so)的调用。函数__libc_start_main
是一个 C
库函数,它优先于程序的 main
函数(一个提供必要初始化的 shell)而被调用。
其他操作对象文件的实用程序包括:objdump
,它展示了关于对象文件的信息;nm
,它列出来自对象文件(包括调试信息)的符号。还可以将 EFL 程序作为参数,直接调用 Linux 动态链接器,从而手动启动映像:
mtj@camus:~/dl$ /lib/ld-linux.so.2 ./dl > libm.so expf 0.0 1.000000 > |
另外,可以使用 ld-linux.so 的 --list
选项来罗列 ELF 映像的依赖项(ldd
命令也如此)。切记,它仅仅是一个用户空间程序,是由内核在需要时引导的。
本文只涉及到了动态链接器功能的皮毛而已。在下面的 参考资料 中,您可以找到对 ELF 映像格式和过程或符号再定位的更详细的介绍。而且和 Linux 其他所有工具一样,你也可以下载动态链接器的源代码(参见 参考资料)来深入研究它的内部。
linux静态链接库与动态链接库的区别及动态库的创建(转)
通常情况下,对函数库的链接是放在编译时期(compile time)完成的。所有相关的对象 文件(object file)与牵涉到的函数库(library)被链接合成一个可执行 文件(executable file)。 程序在 运行时,与函数库再无瓜葛,因为所有需要的函数已拷贝到自己门下。所以这些函数库被成为静态库(static libaray),通常 文件名为“libxxx.a”的形式。
其实,我们也可以把对一些库函数的链接载入推迟到程序运行的时期(runtime)。这就是如雷贯耳的动态链接库(dynamic link library)技术。
二、动态链接库的特点与优势
首先让我们来看一下,把库函数推迟到程序运行时期载入的好处:
1. 可以实现进程之间的资源共享。
什么概念呢?就是说,某个程序的在运行中要调用某个动态链接库函数的时候,操作 系统首先会查看所有正在运行的程序,看在 内存里是否已有此库函数的拷贝了。如果有,则让其共享那一个拷贝;只有没有才链接载入。这样的模式虽然会带来一些“动态链接”额外的开销,却大大的节省了 系统的 内存资源。C的标准库就是动态链接库,也就是说 系统中所有运行的程序共享着同一个C标准库的代码段。
2. 将一些程序升级变得简单。用户只需要升级动态链接库,而无需重新编译链接其他原有的代码就可以完成整个程序的升级。Windows 就是一个很好的例子。
3. 甚至可以真正坐到链接载入完全由程序员在程序代码中控制。
程序员在编写程序的时候,可以明确的指明什么时候或者什么情况下,链接载入哪个动态链接库函数。你可以有一个相当大的 软件,但每次运行的时候,由于不同的操作需求,只有一小部分程序被载入内存。所有的函数本着“有需求才调入”的原则,于是大大节省了系统资源。比如现在的 软件通常都能打开若干种不同类型的文件,这些读写操作通常都用动态链接库来实现。在一次运行当中,一般只有一种类型的文件将会被打开。所以直到程序知道文件的类型以后再载入相应的读写函数,而不是一开始就将所有的读写函数都载入,然后才发觉在整个程序中根本没有用到它们。
三、动态链接库的创建
由于动态链接库函数的共享特性,它们不会被拷贝到可执行文件中。在编译的时候,编译器只会做一些函数名之类的检查。在程序运行的时候,被调用的动态链接库 函数被安置在内存的某个地方,所有调用它的程序将指向这个代码段。因此,这些代码必须实用相对地址,而不是绝对地址。在编译的时候,我们需要告诉编译器, 这些对象文件是用来做动态链接库的,所以要用地址不无关代码(Position Independent Code (PIC))。
对gcc编译器,只需添加上 -fPIC 标签,如:
gcc -fPIC -c file1.c
gcc -fPIC -c file2.c
gcc -shared libxxx.so file1.o file2.o
注意到最后一行,-shared 标签告诉编译器这是要建立动态链接库。这与静态链接库的建立很不一样,后者用的是 ar 命令。也注意到,动态链接库的名字形式为 “libxxx.so” 后缀名为 “.so”
四、动态链接库的使用
使用动态链接库,首先需要在编译期间让编译器检查一些语法与定义。
这与静态库的实用基本一样,用的是 -Lpath 和 -lxxx 标签。如:
gcc file1.o file2.o -Lpath -lxxx -o program.exe
编译器会先在path文件夹下搜索libxxx.so文件,如果没有找到,继续搜索libxxx.a(静态库)。
在程序运行期间,也需要告诉系统去哪里找你的动态链接库文件。在UNIX下是通过定义名为 LD_LIBRARY_PATH 的环境变量来实现的。只需将path赋值给此变量即可。csh 命令为:
setenv LD_LIBRARY_PATH your/full/path/to/dll
一切安排妥当后,你可以用 ldd 命令检查是否连接正常。
ldd program.exe
动态链接库*.so的编译与使用- -
动态库*.so在linux下用c和c++编程时经常会碰到,最近在网站找了几篇文章介绍动态库的编译和链接,总算搞懂了这个之前一直不太了解得东东,这里做个笔记,也为其它正为动态库链接库而苦恼的兄弟们提供一点帮助。
1、动态库的编译
下面通过一个例子来介绍如何生成一个动态库。这里有一个头文件:so_test.h,三个.c文件:test_a.c、test_b.c、test_c.c,我们将这几个文件编译成一个动态库:libtest.so。
so_test.h:
#include
#include
void test_a();
void test_b();
void test_c();
test_a.c:
#include "so_test.h"
void test_a()
{
printf("this is in test_a...\n");
}
test_b.c:
#include "so_test.h"
void test_b()
{
printf("this is in test_b...\n");
}
test_a.c:
#include "so_test.h"
void test_c()
{
printf("this is in test_c...\n");
}
将这几个文件编译成一个动态库:libtest.so
$ gcc test_a.c test_b.c test_c.c -fPIC -shared -o libtest.so
2、动态库的链接
在1、中,我们已经成功生成了一个自己的动态链接库libtest.so,下面我们通过一个程序来调用这个库里的函数。程序的源文件为:test.c。
test.c:
#include "so_test.h"
int main()
{
test_a();
test_b();
test_c();
return 0;
}
l 将test.c与动态库libtest.so链接生成执行文件test:
$ gcc test.c -L. -ltest -o test
l 测试是否动态连接,如果列出libtest.so,那么应该是连接正常了
$ ldd test
l 执行test,可以看到它是如何调用动态库中的函数的。
3、编译参数解析
最主要的是GCC命令行的一个选项:
-shared 该选项指定生成动态连接库(让连接器生成T类型的导出符号表,有时候也生成弱连接W类型的导出符号),不用该标志外部程序无法连接。相当于一个可执行文件
l -fPIC:表示编译为位置独立的代码,不用此选项的话编译后的代码是位置相关的所以动态载入时是通过代码拷贝的方式来满足不同进程的需要,而不能达到真正代码段共享的目的。
l -L.:表示要连接的库在当前目录中
l -ltest:编译器查找动态连接库时有隐含的命名规则,即在给出的名字前面加上lib,后面加上.so来确定库的名称
l LD_LIBRARY_PATH:这个环境变量指示动态连接器可以装载动态库的路径。
l 当然如果有root权限的话,可以修改/etc/ld.so.conf文件,然后调用 /sbin/ldconfig来达到同样的目的,不过如果没有root权限,那么只能采用输出LD_LIBRARY_PATH的方法了。
4、注意
调用动态库的时候有几个问题会经常碰到,有时,明明已经将库的头文件所在目录 通过 “-I” include进来了,库所在文件通过 “-L”参数引导,并指定了“-l”的库名,但通过ldd命令察看时,就是死活找不到你指定链接的so文件,这时你要作的就是通过修改 LD_LIBRARY_PATH或者/etc/ld.so.conf文件来指定动态库的目录。通常这样做就可以解决库无法链接的问题了。
Linkers and Loaders
Linking is the process of combining various pieces of code and data together to form a single executable that can be loaded in memory. Linking can be done at compile time, at load time (by loaders) and also at run time (by application programs). The process of linking dates back to late 1940s, when it was done manually. Now, we have linkersthat support complex features, such as dynamically linked shared libraries. This article is a succinct discussion of all aspects of linking, ranging from relocation and symbol resolution to supporting position-independent shared libraries. To keep things simple and understandable, I target all my discussions to ELF (executable and linking format) executables on the x86 architecture (Linux) and use the GNU compiler (GCC) and linker (ld). However, the basic concepts of linking remain the same, regardless of the operating system, processor architecture or object file format being used.
Consider two program files, a.c and b.c. As we invoke the GCC on a.c b.c at the shell prompt, the following actions take place:
gcc a.c b.c
-
Run preprocessor on a.c and store the result in intermediate preprocessed file.
cpp other-command-line options a.c /tmp/a.i
-
Run compiler proper on a.i and generate the assembler code in a.s
cc1 other-command-line options /tmp/a.i -o /tmp/a.s
-
Run assembler on a.s and generate the object file a.o
as other-command-line options /tmp/a.s -o /tmp/a.o
cpp, cc1 and as are the GNU's preprocessor, compiler proper and assembler, respectively. They are a part of the standard GCC distribution.
Repeat the above steps for file b.c. Now we have another object file, b.o. The linker's job is to take these input object files (a.o and b.o) and generate the final executable:
ld other-command-line-options /tmp/a.o /tmp/b.o -o a.out
The final executable (a.out) then is ready to be loaded. To run the executable, we type its name at the shell prompt:
./a.out
The shell invokes the loader function, which copies the code and data in the executable file a.out into memory, and then transfers control to the beginning of the program. The loader is a program called execve, which loads the code and data of the executable object file into memory and then runs the program by jumping to the first instruction.
a.out was first coined as the Assembler OUTput in a.out object files. Since then, object formats have changed variedly, but the name continues to be used.
Linkers and loaders perform various related but conceptually different tasks:
-
Program Loading. This refers to copying a program image from hard disk to the main memory in order to put the program in a ready-to-run state. In some cases, program loading also might involve allocating storage space or mapping virtual addresses to disk pages.
-
Relocation. Compilers and assemblers generate the object code for each input module with a starting address of zero. Relocation is the process of assigning load addresses to different parts of the program by merging all sections of the same type into one section. The code and data section also are adjusted so they point to the correct runtime addresses.
-
Symbol Resolution. A program is made up of multiple subprograms; reference of one subprogram to another is made through symbols. A linker's job is to resolve the reference by noting the symbol's location and patching the caller's object code.
So a considerable overlap exists between the functions of linkers and loaders. One way to think of them is: the loader does the program loading; the linker does the symbol resolution; and either of them can do the relocation.
Object files comes in three forms:
-
Relocatable object file, which contains binary code and data in a form that can be combined with other relocatable object files at compile time to create an executable object file.
-
Executable object file, which contains binary code and data in a form that can be directly loaded into memory and executed.
-
Shared object file, which is a special type of relocatable object file that can be loaded into memory and linked dynamically, either at load time or at run time.
Compilers and assemblers generate relocatable object files (also shared object files). Linkers combine these object files together to generate executable object files.
Object files vary from system to system. The first UNIX system used the a.out format. Early versions of System V used the COFF (common object file format). Windows NT uses a variant of COFF called PE (portable executable) format; IBM uses its own IBM 360 format. Modern UNIX systems, such as Linux and Solaris use the UNIX ELF (executable and linking format). This article concentrates mainly on ELF.
The above figure shows the format of a typical ELF relocatable object file. The ELF header starts with a 4-byte magic string, \177ELF. The various sections in the ELF relocatable object file are:
-
.text, the machine code of the compiled program.
-
.rodata, read-only data, such as the format strings in printf statements.
-
.data, initialized global variables.
-
.bss, uninitialized global variables. BSS stands for block storage start, and this section actually occupies no space in the object file; it is merely a placer holder.
-
.symtab, a symbol table with information about functions and global variables defined and referenced in the program. This table does not contain any entries for local variables; those are maintained on the stack.
-
.rel.text, a list of locations in the .text section that need to be modified when the linker combines this object file with other object files.
-
.rel.data, relocation information for global variables referenced but not defined in the current module.
-
.debug, a debugging symbol table with entries for local and global variables. This section is present only if the compiler is invoked with a -g option.
-
.line, a mapping between line numbers in the original C source program and machine code instructions in the .text section. This information is required by debugger programs.
-
.strtab, a string table for the symbol tables in the .symtab and .debug sections.
Linkers and Loaders
Every relocatable object file has a symbol table and associated symbols. In the context of a linker, the following kinds of symbols are present:
-
Global symbols defined by the module and referenced by other modules. All non-static functions and global variables fall in this category.
-
Global symbols referenced by the input module but defined elsewhere. All functions and variables with extern declaration fall in this category.
-
Local symbols defined and referenced exclusively by the input module. All static functions and static variables fall here.
The linker resolves symbol references by associating each reference with exactly one symbol definition from the symbol tables of its input relocatable object files. Resolution of local symbols to a module is straightforward, as a module cannot have multiple definitions of local symbols. Resolving references to global symbols is trickier, however. At compile time, the compiler exports each global symbol as either strong or weak. Functions and initialized global variables get strong weight, while global uninitialized variables are weak. Now, the linker resolves the symbols using the following rules:
-
Multiple strong symbols are not allowed.
-
Given a single strong symbol and multiple weak symbols, choose the strong symbol.
-
Given multiple weak symbols, choose any of the weak symbols.
For example, linking the following two programs produces linktime errors:
/* foo.c */ /* bar.c */ int foo () { int foo () { return 0; return 1; } } int main () { foo (); }
The linker will generate an error message because foo (strong symbol as its global function) is defined twice.
gcc foo.c bar.c /tmp/ccM1DKre.o: In function 'foo': /tmp/ccM1DKre.o(.text+0x0): multiple definition of 'foo' /tmp/ccIhvEMn.o(.text+0x0): first defined here collect2: ld returned 1 exit status
Collect2 is a wrapper over linker ld that is called by GCC.
A static library is a collection of concatenated object files of similar type. These libraries are stored on disk in an archive. An archive also contains some directory information that makes it faster to search for something. Each ELF archive starts with the magic eight character string !<arch>\n, where \n is a newline.
Static libraries are passed as arguments to compiler tools (linker), which copy only the object modules referenced by the program. On UNIX systems, libc.a contains all the C library functions, including printf and fopen, that are used by most of the programs.
gcc foo.o bar.o /usr/lib/libc.a /usr/lib/libm.a
libm.a is the standard math library on UNIX systems that contains the object modules for math functions such as like sqrt, sin, cos and so on.
During the process of symbol resolution using static libraries, linker scans the relocatable object files and archives from left to right as input on the command line. During this scan, linker maintains a set of O, relocatable object files that go into the executable; a set U, unresolved symbols; and a set of D, symbols defined in previous input modules. Initially, all three sets are empty.
-
For each input argument on the command line, linker determines if input is an object file or an archive. If input is a relocatable object file, linker adds it to set O, updates U and D and proceeds to next input file.
-
If input is an archive, it scans through the list of member modules that constitute the archive to match any unresolved symbols present in U. If some archive member defines any unresolved symbol that archive member is added to the list O, and U and D are updated per symbols found in the archive member. This process is iterated for all member object files.
-
After all the input arguments are processed through the above two steps, if U is found to be not empty, linker prints an error report and terminates. Otherwise, it merges and relocates the object files in O to build the output executable file.
This also explains why static libraries are placed at the end of the linker command. Special care must be taken in cases of cyclic dependencies between libraries. Input libraries must be ordered so each symbol is referenced by a member of an archive and at least one definition of a symbol is followed by a reference to it on the command line. Also, if an unresolved symbol is defined in more than one static library modules, the definition is picked from the first library found in the command line.
Once the linker has resolved all the symbols, each symbol reference has exactly one definition. At this point, linker starts the process of relocation, which involves the following two steps:
-
Relocating sections and symbol definitions. Linker merges all the sections of the same type into a new single section. For example, linker merges all the .data sections of all the input relocatable object files into a single .data section for the final executable. A similar process is carried out for the .code section. The linker then assigns runtime memory addresses to new aggregate sections, to each section defined by the input module and also to each symbol. After the completion of this step, every instruction and global variable in the program has a unique loadtime address.
-
Relocating symbol reference within sections. In this step, linker modifies every symbol reference in the code and data sections so they point to the correct loadtime addresses.
Whenever assembler encounters an unresolved symbol, it generates a relocation entry for that object and places it in the .relo.text/.relo.data sections. A relocation entry contains information about how to resolve the reference. A typical ELF relocation entry contains the following members:
-
Offset, a section offset of the reference that needs to be relocated. For a relocatable file, this value is the byte offset from the beginning of the section to the storage unit affected by relocation.
-
Symbol, a symbol the modified reference should point to. It is the symbol table index with respect to which the relocation must be made.
-
Type, the relocation type, normally R_386_PC32, that signifies PC-relative addressing. R_386_32 signifies absolute addressing.
The linker iterates over all the relocation entries present in the relocatable object modules and relocates the unresolved symbols depending on the type. For R_386_PC32, the relocating address is calculated as S + A - P; for R_386_32 type, the address is calculated as S + A. In these calculations, S denotes the value of the symbol from the relocation entry, P denotes the section offset or address of the storage unit being relocated (computed using the value of offset from relocation entry) and A is the address needed to compute the value of the relocatable field.
Static libraries above have some significant disadvantages; for example, consider standard functions such as printf and scanf. They are used almost by every application. Now, if a system is running 50-100 processes, each process has its own copy of executable code for printf and scanf. This takes up significant space in the memory. Shared libraries, on the other hand, address the disadvantages of static libraries. A shared library is an object module that can be loaded at run time at an arbitrary memory address, and it can be linked to by a program in memory. Shared libraries often are called as shared objects. On most UNIX systems they are denoted with a .so suffix; HP-UX uses a .sl suffix and Microsoft refer to them as DLLs (dynamic link libraries).
To build a shared object, the compiler driver is invoked with a special option:
gcc -shared -fPIC -o libfoo.so a.o b.o
The above command tells the compiler driver to generate a shared library, libfoo.so, comprised of the object modules a.o and b.o. The -fPIC option tells the compiler to generate position independent code (PIC).
Now, suppose the main object module is bar.o, which has dependencies on a.o and b.o. In this case, the linker is invoked with:
gcc bar.o ./libfoo.so
This command creates an executable file, a.out, in a form that can be linked to libfoo.so at load time. Here a.out does not contain the object modules a.o and b.o, which would have been included had we created a static library instead of a shared library. The executable simply contains some relocation and symbol table information that allow references to code and data in libfoo.so to be resolved at run time. Thus, a.out here is a partially executable file that still has its dependency in libfoo.so. The executable also contains a .interp section that contains the name of the dynamic linker, which itself is a shared object on Linux systems (ld-linux.so). So, when the executable is loaded into memory, the loader passes control to the dynamic linker. The dynamic linker contains some start-up code that maps the shared libraries to the program's address space. It then does the following:
-
relocates the text and data of libfoo.so into memory segment; and
-
relocates any references in a.out to symbols defined by libfoo.so.
Finally, the dynamic linker passes control to the application. From this point on, location of shared object is fixed in the memory.
Shared libraries can be loaded from applications even in the middle of their executions. An application can request a dynamic linker to load and link shared libraries, even without linking those shared libraries to the executable. Linux, Solaris and other systems provides a series of function calls that can be used to dynamically load a shared object. Linux provides system calls, such as dlopen, dlsym and dlclose, that can be used to load a shared object, to look up a symbol in that shared object and to close the shared object, respectively. On Windows, LoadLibrary and GetProcAddress functions replace dlopen and dlsym, respectively.
Here's a list of Linux tools that can be used to explore object/executable files.
-
ar: creates static libraries.
-
objdump: this is the most important binary tool; it can be used to display all the information in an object binary file.
-
strings: list all the printable strings in a binary file.
-
nm: lists the symbols defined in the symbol table of an object file.
-
ldd: lists the shared libraries on which the object binary is dependent.
-
strip: deletes the symbol table information.
Linkers and Loaders by John Levine
Linkers and Libraries Guide from Sun
Sandeep Grover works for QuickLogic Software (India) Pvt. Ltd.