linux 共享库

解析共享库工作机理,灵活组织大型项目

共享库的使用在当今的各类计算机系统中都已经非常常见,它的出现让大型项目的组织形式更加灵活,管理更加方便,而且极大地节省了需要的存储和运行空间。其工作的细节,主要包括以下几方面内容:

1.共享库产生的渊源
2.位置独立代码的工作原理
3.动态链接器的工作过程
4.共享库的版本维护

1 共享库产生的渊源
1.1 静态库
在共享库出现之前,公用功能是以静态库的形式存在的,它把通用功能模块的多个目标文件打包在一起,用到它的程序只需要在链接时指定这个库文件,链接器就会从这个库中抽取出用到的功能代码拷贝到目标程序中,而不需要每次都对这些通用功能代码重新编译。

静态库体现出了很好的模块化思想,但是随着计算机产业规模的发展,静态库逐渐暴露出了自身两个比较严重的问题。

一是磁盘和内存空间占用大。静态库虽然加快了编译速度,提高了不同部门间的协作效率,但是在每个与静态库链接的程序中,都会保存一份引用到的通用功能代码的拷贝,而且在运行时,每一份拷贝都要占用相应的物理内存。

二是库的版本升级非常麻烦。一旦公用库有修改,每个引用到它的程序都需要与新版本的库重新链接。在库与应用是由不同的公司或组织维护的场景下,升级工作将变得异常复杂。通用库中如果有 Bug 修复,使用该库的所有应用都需要分别升级。

1.2 共享库
为了解决这个问题,共享库技术应运而生。
首先,使用共享库的应用在编译链接时,并不把库中的功能代码拷贝到目标文件中,而只在目标文件中记录一条引用信息,标记引用到的库函数,直到程序运行时才由动态链接器去定位功能代码的位置,因此生成的可执行程序的体积得以明显地减小。

其次,每个共享库在物理内存中只有一份副本,多个应用会在各自的虚拟地址空间内映射这同一份可执行文件,因此可以节省可观的内存空间。

共享库的这种工作方式大大方便了库的升级,当共享库发布新版本时,用户只需要升级这个共享库,所有使用这个库的应用就可以自动获得新库中的特性或 Bug 修复,而不需要单独升级每个应用。

共享库工作的关键在于运行时的动态加载和链接,所以共享库也叫做动态链接库。它动态性的主要表现是,同一个库在不同进程中的映射地址是不同的,这是为了解决多个库加载地址可能冲突的问题。

因为统一给每个库都分配一个固定的映射地址和范围几乎是不可能的。一是地址空间没有那么多,二是共享库的数量和每个库需要的内存大小都不好预估,所以动态库干脆让每个进程根据自己的内存使用情况来自由决定库的映射地址,甚至同一个应用的多次运行也可能会把同一个库映射到不同的虚拟地址。所以,动态库技术需要解决两个主要的问题:

一是库代码需要能在任意内存地址运行;
二是映射之后库之间互相引用的代码能定位到正确的地址。

本节:

虽然这里对共享库的产生由来和实现思路的介绍只用了寥寥数言,但是实际上,这个过程经过了前人大量的摸索和尝试,才最终定型为今天这种形式。

1.3 静态共享库

历史上,为了解决模块装载地址的问题,还曾经出现过叫做静态共享库的解决方案,它是让操作系统统一管理各种模块的加载地址,操作系统划分出一些地址块预留给那些已知的模块。这样的做法让模块可以继续使用固定的加载地址,所以在代码生成和进程装载方面不需要做什么额外的事情,所有的事情都由操作系统在后台处理了。

当功能模块较少,每个模块的规模也比较固定的时候,这种做法还可以勉强应付。但是当模块数量变多,每个模块占用的内存空间不能预估的时候,这种做法就显得力不从心了。

而且,这种做法的模块升级也面临很严重的问题,因为应用程序在链接阶段就已经绑定了库中函数和变量的地址,所以,如果库有升级,原有的函数和变量地址都不能发生改变,否则会造成地址引用错误,而且这种错误非常隐蔽。由于它的种种限制和弊端,目前静态共享库已经完全被动态链接库所取代,只有在一些非常老的系统上,还遗留着它们的身影。

1.4 位置独立的代码
当今的共享库可以支持在任意地址加载运行,这种技术叫做位置独立代码。要让 GCC 生成位置独立的代码,需要使用 -fPIC 选项,它告诉编译器,需要以特定的方式生成获取变量和函数地址的代码。

特定的方式具体是什么呢?这需要按照访问的变量或函数的不同情况分别讨论。如:

#include <stdio.h>

static int static_val;
extern int extern_val;
int global_val;

extern void external();

static void internal() {
    static_val = 0x11223344;
}

void sharefunc(int caller) {
    internal();
    extern_val = 0x55667788;
    global_val = 0x99AABBCC;
    external();
}

这个示例中,包含了以下四种函数和数据的访问方式。

模块内部函数调用:sharefunc() 中调用 internal() 函数。
模块内部数据访问:访问变量 static_val。
模块间的数据访问:访问变量 external_val。
模块间的函数调用:调用函数 external()。
把上面的代码编译成动态链接库,代码如下:

gcc -fPIC -shared -o libshared.so shared.c

然后把生成的动态链接库用 objdump 反汇编出来,查看生成的每种情况的汇编代码:

objdump -SD libshared.so > libshared.dump

模块内部的函数调用
这种情况比较简单,虽然模块的加载地址会变化,但是模块内部各个函数之间的相对位置是固定的,所以只要使用相对地址跳转来调用模块内部函数,就可以做到与加载地址无关。

来看看生成的动态库中 sharefunc() 是如何调用内部函数 internal() 的:

00000000000006b0 <internal>:
 6b0:   55                      push   %rbp
 6b1:   48 89 e5                mov    %rsp,%rbp
 6b4:   c7 05 6e 09 20 00 44    movl   $0x11223344,0x20096e(%rip)        # 20102c <static_val>
 6bb:   33 22 11
 6be:   90                      nop
 6bf:   5d                      pop    %rbp
 6c0:   c3                      retq

00000000000006c1 <sharefunc>:
 6c1:   55                      push   %rbp
 6c2:   48 89 e5                mov    %rsp,%rbp
 6c5:   48 83 ec 10             sub    $0x10,%rsp
 6c9:   89 7d fc                mov    %edi,-0x4(%rbp)
 6cc:   b8 00 00 00 00          mov    $0x0,%eax
 6d1:   e8 da ff ff ff          callq  6b0 <internal>
 ......

调用 internal() 函数的汇编代码在 6d1 位置处,可以看到,生成的汇编码是 e8 da ff ff ff ,从 Intel 的编程手册中可以查到,e8 是相对地址跳转的 call 指令的操作码,其功能是调用从下一条指令地址算起偏移指定的偏移量的函数,指令的操作数 daffffff 是一个 32 位的偏移量,是 -38 的补码形式。计算一下,从 call 的下一条指令地址算起,0x6d6 - 38 = 0x6b0,刚好定位到 internal() 函数的开始地址。

这种寻址方式天生就是与加载地址无关的,所以不需要对动态库做特殊处理。

模块内部的数据访问

这种情况也不太复杂。在共享库被加载到内存时,同一模块内的代码段与数据段的相对地址也是固定的,因此也可以用相对地址寻址,用当前指令地址加某个固定偏移量的方式就可以定位到目标数据。

如上面 internal() 函数访问 static_val 的代码(6b4)所示,它用当前 rip 加偏移量 0x20096e 来定位 static_val ,因为当一条指令在 CPU 中执行时,rip 已经被指向下一条指令了,所以当前 rip 的值就是下一条指令的地址 6be,6be + 0x20096e = 0x20102c。模块内未初始化的静态变量的存储位置应该在 .bss 段内,dump 文件的内容也能印证这一点(dump 内容中的汇编指令可以忽略,出现汇编指令仅仅是因为我们使用了 objdump 的 -D 选项,.bss 段中其实没有指令,只有数据,这里初始数据都是 0):

Disassembly of section .bss:
0000000000201028 <__bss_start>:
  201028:       00 00                   add    %al,(%rax)
        ...
000000000020102c <static_val>:
  20102c:       00 00                   add    %al,(%rax)
        ...
0000000000201030 <global_val>:
        ...

所以,模块内数据的访问也是采用相对地址偏移的方式完成的,偏移位置是在编译时由编译器计算得到的。
它也天生是与加载地址无关的,也不需要对动态库做特殊处理。

但要知道的是,x86 架构的 CPU 到了 64 位的时候才有这种数据访问方式,在 32 位的 x86 CPU 中,指令指针寄存器 EIP 是只在 CPU 内部使用的,不允许应用程序直接访问。所以,在 32 位系统上,编译器用了一个小技巧来获取当前指令地址,把示例程序编译成 32 位共享库(-m32),反汇编会看到如下所示的代码:

00000585 <sharefunc>:
 585:   55                      push   %ebp
 586:   89 e5                   mov    %esp,%ebp
 588:   53                      push   %ebx
 589:   83 ec 04                sub    $0x4,%esp
 58c:   e8 af fe ff ff          call   440 <__x86.get_pc_thunk.cx>
 591:   81 c3 6f 1a 00 00       add    $0x1a6f,%ecx
 597:   e8 cf ff ff ff          call   56b <internal>
 ......

000005bf <__x86.get_pc_thunk.cx>:
 5bf:   8b 0c 24                mov    (%esp),%ecx
 5c2:   c3                      ret

可以看到,在 58c 行,使用本地相对地址跳转调用了一个函数 __x86.get_pc_thunk.cx ,因为 call 指令会把它的下一条指令的地址保存到栈上,用于函数执行完成之后的返回,所以,在函数 __x86.get_pc_thunk.cx 中把存在栈上的值保存到寄存器 ECX 中,在函数返回之后寄存器 ECX 中的值就是当前指令的地址了。

设计非常巧妙,但是这样一来,可能会影响 CPU 预加载指令的缓存命中率,对性能造成一定的影响。所以,在 x86 的 CPU 上,在模块内变量访问这一点上,64 位程序的性能要优于 32 位程序。

模块之间的数据访问
模块间的数据访问就要稍微复杂一些了,因为不同模块加载之后,它们之间的相对位置不再是固定的了,只能等到所有模块都加载完成之后,才能根据实际的加载位置动态调整访问地址。同时,因为有多个进程共享同一份共享库代码,所以不能直接修改代码段中数据访问的地址值。那怎么办呢?

考虑到共享库的数据段在多个进程中是每个进程独立维护的,同时,数据段与代码段的相对地址是固定的,所以,可以让代码段到数据段的某个位置去获取变量的实际地址,程序运行时动态调整该数据段相应位置的值,使之指向正确的目标变量地址。

在 ELF 中,这个存放外部变量真实地址的区域是 .got(全局偏移表),模块内引用到的每个外部变量在该段中都会被预留出相应的位置,访问外部变量的代码会用相对位置寻址的方式找到这个位置,再从这个位置上读取变量真实的存放地址。程序启动时由动态链接器负责修改 .got 中的每一项,使之指向正确的外部变量虚拟地址。

示例动态库中访问变量 external_val 的代码为:

6d6:   48 8b 05 f3 08 20 00    mov    0x2008f3(%rip),%rax        # 200fd0 <extern_val>
 6dd:   c7 00 88 77 66 55       movl   $0x55667788,(%rax)

可以看到,代码中首先使用当前指令地址 rip 加一个固定偏移 0x2008f3 来获得一个内存地址,然后用存放在该位置处的地址值来访问变量 external_val 。按照上面的理论,此处的目标地址 6dd + 0x2008f3 = 0x200fd0 应该位于 .got 段内,进一步查看目标文件的内容可以印证这一点:

Disassembly of section .got:
0000000000200fc8 <.got>:
        ...
Disassembly of section .got.plt:
0000000000201000 <_GLOBAL_OFFSET_TABLE_>:
因为这个动态库还没有被可执行程序加载到内存,所以只能看到这个地址落在了 .got 内。读者可以自己写一个依赖这个共享库的示例程序,在运行时用 GDB 查看相应位置的内容,看看被填入的地址值到底是什么,它应该正好指向外部变量 external_val 的实际存储地址。

所以,模块间的数据访问是生成了访问固定相对位置的代码,然后在这个位置上保存变化的地址的方式实现的。

后面需要再研究,记不住了。。。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值