Linux下的链接库包括静态链接库和动态链接库,本文首先从库的制作者角度讨论两种库的制作方法,再从库的使用者角度讨论两种库的使用方法。最后会重点讨论动态库的加载过程。为了更清晰地展现整个过程,本文的代码都以C/C++语言为例。

静态库

原理、制作和发布

C/C++语言的编译包含三个阶段:

  • 预编译(.c/.cpp -> -i) 这个过程会对源代码做文本处理,进行宏替换和去掉注释。
  • 编译(.i -> .s) 这个过程将源代码转换成汇编代码。
  • 汇编(.s -> .o) 这个过程将汇编代码转换为机器码。

关于这个过程的更多讨论,请参考浅析C语言预处理。静态库的原理,即是将一个或多个 .o 文件进行打包以形成一个单独的库文件,在使用时,仅需要包含相应的头文件,即可运行原先编写的功能。

Linux库与装载_位置无关码

承上,一般使用工具 ar 对编译形成的 .o 文件进行打包。静态库制作的 makefile 脚本如下:

lib=libmyStdio.a #静态库名称的前缀lib和后缀.a

$(lib):myStdio.o #指明目标库的依赖关系和依赖方法 打包.o文件
    ar -rc $@ $^ #ar -rc -> repalce and create   ar [aimfile] [curfiles]
myStdio.o:myStdio.c #编译生成 .o 文件
    gcc $^ -c -std=c99

.PHONY:clean
clean:
    rm -rf *.a *.o

生成的静态链接库要在原先命名的基础上,加上 lib 前缀.a 后缀。打包工具 ar 的用法为 ar [欲生成的文件] [现有文件],使用 -c 和 -r 选项新建和替换旧的库文件。运行上面的脚本,可以得到一个 libmyStdio.a 的静态链接库。

为了便于用户使用,库的制作者需要对库的相关文件的目录结构进行管理,这个过程称为库的发布。静态库的发布过程大致如下:

.PHONY:output #静态库的发布
output:
    mkdir -p lib/include #将.h文件放到./lib/include目录下
    mkdir -p lib/libmyStdio #将相关的.a文件放到./lib/libmyStdio目录下
    cp *.h lib/include
    cp *.a lib/libmyStdio

使用和安装

在Linux中,库无非分为三种:操作系统库、语言标准库和第三方库。编译器(链接器)使用一个静态库,首先必须要知道这个静态库文件所在的位置,对于前两种库,这是毋庸置疑的,编译器会自动找到这些静态库;而对于第三方库,需要用户自己指定所使用的第三方库所在的位置

承上,使用上文生成的 libmyStdio.a 静态库时,需要额外做三件事:

  • 使用 gcc 的-I选项指明静态库头文件所在的位置;
  • 使用 gcc 的-L选项指明库文件所在的位置;
  • 使用 gcc 的-l选项指明库文件的链接名称,这个名称是去掉 lib 前缀和 .a 后缀后的名称,并且l与这个名称之间不能有空格。

承上,当对使用了 libmyStdio.a 库的源文件进行编译时,需要这样做:

out:main.c
	gcc $^ -I ./lib/include -L ./lib/libmyStdio -lmyStdio -o $@ 
	@#-I选项:指明头文件的位置
	@#-L选项:指明静态库的位置
	@#-l选项:指明静态库的名称
	@#静态库的名称:去掉前缀lib和后缀.a

.PHONY:clean
clean:
	rm -f out

可以将静态库的头文件和库文件分别拷贝至/usr/include//lib64/目录下,这样链接时就无需使用-I-L选项指明头文件的位置和静态库的位置,但是依然需要指明静态库的名称。这即为静态库的安装,但是一般不建议这样做,因为可能会污染系统的其他库。

在Linux中,gcc/g++ 默认进行的是动态链接,可以使用 -static 选项强制进行静态链接。如果用户只提供了静态链接库并且未使用 -static 选项,则编译器只能进行静态链接。在 centos 中,一般不内置 C/C++ 的静态链接库,用户可以使用 yum 下载 C 的静态链接库glibc-static和 C++ 的静态链接库libstdc++-static

静态库直接拷贝到用户的可执行程序中,不存在加载问题。使用静态库的可执行文件,链接后不依赖静态库,可以独立运行,但是会增大可执行文件的体积,占用资源。可以通过file命令查看一个可执行文件的链接属性。

[@shr Tue Jan 16 14:18:11 11.9_lib_usage_test]$ file out 
out: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=88cc06bd2a9baad512cfe24420e5bd422580acfc, not stripped

动态库

原理、制作和发布

动态库又称共享库。动态库的基本原理与静态库类似,都是将一个或多个 .o 文件打包形成一个库文件。动态库的打包不需要借助其它工具,只需要使用 gcc/g++ 的-shared选项即可。与静态库制作不同的是,制作动态库时,需要在编译生成 .o 文件时带上-fPIC选项生成位置无关代码,具体原因将会在讨论动态库的加载时说明。

承上,若要将上文中的 myStdio 制作成动态库,makefile 脚本可以这样写:

lib=libmyStdio.so #注意动态库的命名格式

$(lib):myStdio.o
    gcc $^ -shared -o $@ #gcc在生成动态库时,会自动进行打包

myStdio.o:myStdio.c
    gcc -c -fPIC -std=c99 $^ #在生成.o文件时,生成地址无关码(fPIC)

.PHONY:clean
clean:
    rm -rf *.o *.so

生成的动态库在原先命名的基础上,需要额外加上 lib 前缀.so 后缀。运行上面的脚本,可以得到一个 libmyStdio.so 的动态链接库。

与静态库类似,若要发布动态库,可以这样做:

.PHONY:output
output:
    mkdir -p lib/include #将.h文件放到./lib/include下
    mkdir -p lib/libmyStdio #将.so文件放到./lib/libmyStdio下
    cp *.h lib/include
    cp *so lib/libmyStdio

使用和安装

与静态库一样,使用动态库时,需要用户指明动态库头文件的所在位置、动态库的所在位置以及动态库的名称。动态库的名称为去掉 lib 前缀和 .so 后缀之后的名称。

out:main.c
	gcc $^ -I ./lib/include -L ./lib/libmyStdio -lmyStdio -o $@ 
	@#-I选项:指明头文件的位置
	@#-L选项:指明动态库的位置
	@#-l选项:指明动态库的名称
	@#静态库的名称:去掉前缀lib和后缀.so

.PHONY:clean
clean:
	rm -f out

运行上面的脚本,可以生成一个 libmyStdio.so 的动态库。此时这个库是不能直接用的,这是因为动态库是即用即加载的,除了给链接器指明动态库的相关信息外,还需要让加载器知道动态库所在的位置,一般有四种常用的方法:

  • 安装 即是分别将头文件和动态库拷贝至/usr/include//lib64/目录下。
  • 软链接 不同于安装,这个做法是在/usr/include//lib64/ 目录下分别建立头文件和动态库的软链接。
  • 更改环境变量 将库文件所在的目录路径添加到LD_LIBRARY_PATH环境变量中。这个做法一般是临时的,若要长期有效,需要将 LD_LIBRARY_PATH 的内容更新到环境变量的配置文件中。
  • 更改配置文件/etc/ld.so.conf.d/路径下新建一个 .conf 配置文件,保存动态库所在目录的路径。

给加载器指明动态库的位置后,即可正常运行可执行程序。使用动态库可以节省磁盘空间,内存空间等资源,但是由于动态库是共享的,一旦丢失,可能会影响多个程序

除此之外,可以发现生成的动态库文件具有可执行权限,这表明动态库是可执行的,但是不能单独执行。当程序需要某个动态库时,会将动态库以可执行程序的形式加载到内存中,而且动态库没有所谓的程序入口,即传统的 main 函数。

-rwxrwxr-x 1 shr shr 17672 Jan 16 22:02 libmyStdio.so

动态库的加载

要讨论动态库的加载,首先需要对可执行文件的加载过程有所了解。

在之前讨论linux平台下的ELF文件结构时,我们已经知道在一个可执行文件内部有地址的概念,这个地址被称为逻辑地址。在可执行文件被加载并被映射到进程的地址空间后,这个逻辑地址又被称为虚拟地址。逻辑地址与程序在地址空间中的虚拟地址是一一对应的,即可执行程序的逻辑地址与可执行程序在地址空间中的虚拟地址相同,在现代计算机中,逻辑地址和虚拟地址指的大致是同一个概念。可执行程序的每条指令本身具有自己的逻辑地址,被加载到内存后,也会具有自己的物理地址。

#使用 objdump 命令对可执行程序进行反汇编后,观察到的可执行文件的指令的逻辑地址
[@shr Tue Jan 16 22:13:44 1.16_lib_test]$ objdump -d a.out

a.out:     file format elf64-x86-64


Disassembly of section .init:

00000000004003c8 <_init>:
  4003c8:	48 83 ec 08          	sub    $0x8,%rsp
  4003cc:	48 8b 05 25 0c 20 00 	mov    0x200c25(%rip),%rax        # 600ff8 <__gmon_start__>
  4003d3:	48 85 c0             	test   %rax,%rax
  4003d6:	74 05                	je     4003dd <_init+0x15>
  4003d8:	e8 43 00 00 00       	callq  400420 <.plt.got>
  4003dd:	48 83 c4 08          	add    $0x8,%rsp
  4003e1:	c3                   	retq   

/*……此处省略……*/

Disassembly of section .text:

/*……此处省略……*/

000000000040051d <func>:
  40051d:	55                   	push   %rbp
  40051e:	48 89 e5             	mov    %rsp,%rbp
  400521:	89 7d ec             	mov    %edi,-0x14(%rbp)
  400524:	89 75 e8             	mov    %esi,-0x18(%rbp)
  400527:	8b 45 e8             	mov    -0x18(%rbp),%eax
  40052a:	8b 55 ec             	mov    -0x14(%rbp),%edx
  40052d:	01 d0                	add    %edx,%eax
  40052f:	89 45 fc             	mov    %eax,-0x4(%rbp)
  400532:	8b 45 fc             	mov    -0x4(%rbp),%eax
  400535:	5d                   	pop    %rbp
  400536:	c3                   	retq   

0000000000400537 <main>:
  400537:	55                   	push   %rbp
  400538:	48 89 e5             	mov    %rsp,%rbp
  40053b:	be 02 00 00 00       	mov    $0x2,%esi
  400540:	bf 01 00 00 00       	mov    $0x1,%edi
  400545:	e8 d3 ff ff ff       	callq  40051d <func>
  40054a:	89 c6                	mov    %eax,%esi
  40054c:	bf 00 06 40 00       	mov    $0x400600,%edi
  400551:	b8 00 00 00 00       	mov    $0x0,%eax
  400556:	e8 a5 fe ff ff       	callq  400400 <printf@plt>
  40055b:	b8 00 00 00 00       	mov    $0x0,%eax
  400560:	5d                   	pop    %rbp
  400561:	c3                   	retq   
  400562:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
  400569:	00 00 00 
  40056c:	0f 1f 40 00          	nopl   0x0(%rax)

在Linux平台下,一个可执行程序被加载大致有以下几个步骤:

  • 首先,进程相关的内核数据结构被创建,包括 task_struct、mm_struct 和 files_struct 等结构体。
  • 可执行程序的代码和数据一开始不被加载到物理内存,而是将程序的入口地址(这个地址是虚拟地址)加载到 CPU 的 eip 寄存器,这是个与进程上下文有关的寄存器,保存了下一条要执行的指令的地址。eip 中保存的地址都是虚拟地址
  • 程序开始运行,此时进程通过查询页表发现对应的代码在物理内存中不存在,此时触发缺页中断,将磁盘中的可执行程序的一部分加载到物理内存中,并形成虚拟地址-物理地址的映射。这时,可执行程序才真正意义上的被加载。
  • 指令在物理内存中存在后,就可以被 CPU 顺利执行。重复上面的过程,程序就会在不断的缺页中断和加载中被执行。

Linux库与装载_库的制作_02

承上,可执行程序在被加载之前,它在地址空间中的虚拟地址已经被确定为了它的逻辑地址,因而可以被加载到固定的位置。在系统加载动态库时,为了节省资源,每个动态库只会在物理内存中存在一份,当某个进程需要这个动态库时,系统会通过页表将动态库的代码和数据映射到进程的地址空间的共享区,这个进程继而可以使用这个动态库。同时,因为物理内存中只有一份动态库的代码和数据,为了保证数据安全,进程对动态库中数据的修改会触发写时拷贝。同时,系统中可能会同时存在多个动态库,操作系统一定可以对这些动态库进行管理。

现在的问题是,系统要如何将物理内存中的动态库映射到进程的地址空间的正确位置。因为在一个动态库被映射之前,无法得知地址空间中的哪些部分是空闲的,并且每个进程的地址空间的使用和分布情况都不尽相同,所以动态库被加载(映射)到地址空间的固定位置是不可能的。所以,动态库必须可以被加载到地址空间的任意位置。为了做到这一点,动态库中不像普通的可执行程序那样保存绝对的逻辑地址,而是保存每个函数在库中的偏移量,这个偏移量即为位置无关码(PIC),这即是在制作动态库时,使用 gcc 的 -fPIC 选项的原因。有了位置无关码,无论动态库被映射到地址空间的哪个位置,只要知道首地址和函数偏移量,就能找到库的完整内容。