在一个典型的系统中,会运行许多程序。每个程序都依赖于一些函数,其中一些是标准的C库函数,如printf()、malloc()、write()等。
如果每个程序都使用标准的C库,那么每个程序通常都有这个特定库的惟一副本。不幸的是,这导致了资源的浪费。由于C库是公共的,所以让每个程序引用该库的公共实例比让每个程序包含该库的副本更有意义。这种方法有几个优点,其中最重要的是节省了所需的系统总内存。
术语静态链接意味着程序和它所链接的特定库在链接时由链接器组合在一起。这意味着程序和特定库之间的绑定是固定的,并且在程序运行之前就已经知道了。这也意味着我们不能改变这个绑定,除非我们用库的新版本重新链接程序。
如果您不确定某个库的正确版本在运行时是否可用,或者您正在测试某个库的新版本,但又不希望将其安装为共享的,那么您可以考虑静态地链接一个程序。
静态链接的程序是针对对象(库)的归档文件链接的,这些对象(库)的扩展名通常是.a。这种对象集合的一个例子是标准C库libc.a
动态链接这个术语意味着程序和它引用的特定库在链接时不会被链接器组合在一起。相反,链接器将信息放入可执行文件中,告诉加载程序共享对象模块代码所在位置,以及应该使用哪个运行时链接器来查找和绑定引用。这意味着程序和共享库之间的绑定是在运行时完成的——在程序启动之前,找到并绑定适当的共享库。
这种类型的程序称为部分绑定的可执行程序,因为它没有被完全解析——链接器在链接时并没有导致程序中所有引用的符号都与库中的特定代码相关联。相反,链接者只是简单地说:这个程序在一个特定的共享对象中调用了一些函数,所以我只需要记下这些函数在哪个共享对象中,然后继续。实际上,这将绑定延迟到运行时。
动态链接的程序被链接到具有扩展名的共享对象上。此类对象的一个示例是标准C库的共享对象版本,即libc.so。
您可以使用编译器驱动程序qcc的命令行选项来告诉工具链是静态链接还是动态链接。然后,此命令行选项确定所使用的扩展名(.a或.so)。
更进一步说,程序在运行之前可能不知道需要调用哪些函数。虽然这一开始看起来有点奇怪(毕竟,一个程序怎么可能不知道它将调用什么函数呢?),但它确实是一个非常强大的功能。
考虑一个通用的磁盘驱动程序。它启动、探测硬件并检测硬盘。然后驱动程序将动态加载io-blk代码来处理磁盘块,因为它发现了一个面向块的设备。现在驱动程序已经在块级别访问了磁盘,它发现磁盘上有两个分区:一个DOS分区和一个电力安全分区。我们没有强制磁盘驱动程序包含它可能遇到的所有可能的分区类型的文件系统驱动程序,而是保持简单:它没有任何文件系统驱动程序!在运行时,它检测到两个分区,然后知道应该加载fs-dos.so和fs-qnx6.so文件系统代码来处理这些分区。通过延迟决定哪个函数调用,我们增强了磁盘驱动程序的灵活性(并减少了它的大小).
为了理解程序如何使用共享库,我们首先查看可执行文件的格式,然后检查程序启动时发生的步骤。
QNX中微子RTOS使用ELF(可执行和链接格式)二进制格式,该格式目前在SVR4 Unix系统中使用。ELF不仅简化了创建共享库的任务,而且增强了模块在运行时的动态加载。
在下面的关系图中,我们展示了ELF文件的两个视图:链接视图和执行视图。链接视图,当程序或库被链接时使用,涉及各种sections(包含object文件)。Section含大量的object文件信息:数据data、指令instruction、重定位信息relocation info、符号symbol、调试信息debug info等。执行视图,程序运行时使用,涉及各种segments。
在链接时,程序或库是通过将具有相似属性的section合并到段Segments中来构建的。通常,所有可执行的和只读的数据section被合并到一个“text”段segments中,而数据和“BSS”被合并到“data”段segments中。这些段称为加载段,因为它们需要在进程创建时加载到内存中。其他部分(如符号信息和调试部分)被合并到其他非加载段中。
ELF加载器的大多数实现都派生于COFF(Common Object File Format)加载器。它们在加载时使用ELF对象的链接视图。这是低效的,因为程序加载器必须使用节来加载可执行文件。一个典型的程序可能包含大量的节,每个节都必须位于程序中,并分别装入内存。
然而,QNX中微子完全不依赖于COFF加载sections的技术。在开发我们的ELF实现时,我们直接按照ELF规范工作,并将效率放在首位。ELF加载器使用程序的“执行视图”。通过使用执行视图,加载器的任务大大简化了:它所要做的就是将程序或库的加载段(通常是两个)复制到内存中。因此,流程创建和库加载操作要快得多。
下图显示了一个典型进程的内存布局。进程加载段(对应于图中的文本和数据)在进程的基本地址加载。主堆栈位于下面并向下扩展。创建的任何其他线程都有自己的堆栈,位于主堆栈之下。每个堆栈由一个保护页分隔,以检测堆栈溢出。堆位于进程之上,并向上增长。
在进程地址空间的中间,为共享对象保留了一个大区域。共享库位于地址空间的顶部,并向下扩展。创建新进程时,进程管理器首先将可执行文件中的两个段映射到内存中。然后对程序的ELF头进行解码。如果程序头指示可执行文件链接到共享库,则进程管理器将从程序头提取动态解释器的名称。动态解释器指向一个包含运行时链接共享库。进程管理器将在内存中加载这个共享库,然后将控制权传递给这个库中的代码。
当针对共享对象链接的程序启动时,或者当程序请求动态加载共享对象时,将调用运行时链接器。运行时链接器包含在C运行时库中。
运行时链接器在加载共享库时执行几个任务(.so file):
通过使用dlopen()调用,进程可以在运行时加载共享库,该调用指示运行时链接器加载该库。加载库之后,程序可以使用dlsym()调用来确定其地址,从而调用库中的任何函数。
Note:请记住:共享库只对动态链接的进程可用。
该程序还可以使用dladdr()调用来确定与给定地址相关联的符号。最后,当进程不再需要共享库时,它可以调用dlclose()从内存中卸载该库。
当运行时链接器加载共享库时,必须解析该库中的符号。符号解析的顺序和范围很重要。如果共享库调用的函数恰好在程序加载的多个库中以相同的名称存在,则搜索这些库中此符号的顺序至关重要。这就是为什么OS定义了几个加载库时可以使用的选项。
所有具有全局作用域的对象(可执行程序和库)都存储在一个内部列表(全局列表)中。默认情况下,任何全局作用域对象都将其所有符号提供给任何加载的共享库。全局列表最初包含可执行文件和在程序启动时加载的任何库。
默认情况下,当使用dlopen()调用加载一个新的共享库时,该库中的符号通过以下顺序搜索解析: