OS学习之路------动静态库制作与原理
什么是库
- 写一个输出
"hello world"的程序,学过高级编程语言的你很快就能写出来。但你想过没有------为什么点击运行后,终端就能显示出那行字符?这背后其实是库 在起作用。编程语言会借助标准库,调用不同操作系统封装的硬件抽象接口,最终把字符送到屏幕上。如果没有这些库,你就得自己区分Windows、Linux、macOS等不同系统的底层调用,还要适配终端、图形界面、串口等不同的输出方式------工作量会大得惊人。 - 正是因为有了这些库, 我们才可以通过简短的几行代码就完成了打印字符到屏幕的工作. 库实际上就是一些成熟的、可以复用的代码, 正是有了大量库的存在, 大大简化了软件的开发速度, 计算机行业才得以发展的如此迅速
库的种类
- 因为不同
OS的差异, 不同OS的库的格式也不同, 库主要分为两个动态库 和静态库 .windows上动静态库的后缀分别为.lib,.dll, 在linux上的后缀分别为.a,.so,macOS上则是.a和.dylib - 不同的
OS中有不同的生成工具, 接下来我们学习如何在Linux上生成静态库和动态库
生成动态库和静态库
- 二者的区别 : 既然区分了动态库和静态库, 那么它们的生成方式也是有不同的
- 动态库 生成需要两步, 第一步首先要使用
gcc -c -fPIC mymath.c -o mymath.o生成和位置无关 的文件, 接着使用gcc -shared -o libmymath.so mymath.o, 这样就生成了一个动态库 - 静态库生成也需要两步: 首先使用
gcc -c mymath.c -o mymath.o得到二进制目标 文件, 然后使用ar rcs libmymath.a mymath.o打包静态库 - 生成最终的二进制可执行文件 时, 如果使用静态库 , 整个静态库会被全部打包到可执行程序内, 这会导致可执行文件体积变大 ; 使用动态库 时, 不会把库打包到最终的可执行文件内, 而是在运行时把库加载到内存里并映射到到对应进程的的地址空间 中, 这样可以减少可执行文件的大小 , 可以让多个进程使用同一个动态库, 减少了可执行文件的体积和内存占用, 因此动态库也叫共享库.
- 动态库 生成需要两步, 第一步首先要使用
- 动态库生成命令解析:
-fPIC的作用是生成位置无关码 , 程序编译成二进制但是还没有执行前, 其实每个函数都有了自己的地址, 但是动态库需要在运行时加载, 无法给库里的函数生成有效的地址 , 那么就需要让编译器生成PIC, 在运行时进行地址的替换, 然后使用-shared生成一个共享对象文件
- 静态库生成命令解析:
- 静态库是直接打包到可执行文件里的 , 任何函数的地址都是有效 的, 不需要使用
PIC,ar是Linux的生成静态库的工具,r:将文件插入库中, c:创建库, s:创建索引,加快链接速度
- 静态库是直接打包到可执行文件里的 , 任何函数的地址都是有效 的, 不需要使用
- 不管是静态库还是动态库, 实际的库名都是去除
lib前缀和后缀名, 例如libmy.a,libmy.so, 它们的名字都是my
使用静态库和动态库
- 现在有一个
add.h,sub.h,add.cc,sub.cc,main.cc, 我们来使用刚才所学的知识试验一下
c
// add.h
extern int add(int a, int b);
c
// add.cc
int add(int a, int b) { return a + b; }
c
// sub.h
extern int sub(int a, int b);
c
// sub.cc
int sub(int a, int b) { return a - b; }
cpp
// main.cc
#include <iostream>
#include "add.h"
#include "sub.h"
int main()
{
std::cout << add(10, 10) << std::endl;
std::cout << sub(10, 10) << std::endl;
return 0;
}
-
静态库
- 分别执行以下命令, 读者可以自行验证输出结果, 库是需要进行链接的,
L是库的路径,l是库名,不要lib前缀和文件后缀名
bashg++ -c add.cc -o add_S.o g++ -c sub.cc -o sub_S.o ar rcs libmath.a add_S.o sub_S.o g++ main.cc -L. -lmath -o main_S - 分别执行以下命令, 读者可以自行验证输出结果, 库是需要进行链接的,
-
动态库
- 分别执行以下命令
bashg++ -c -fPIC add.cc -o add_D.o g++ -c -fPIC sub.cc -o sub_D.o g++ -shared -o libmath.so add_D.o sub_D.o g++ main.cc -L. -lmath -o main_D- 你要是按照这个步骤走下去, 当你运行可执行程序时, 编译器会告诉你
./main_D: error while loading shared libraries: libmath.so: cannot open shared object file: No such file or directory, 我们使用ldd(可以检查某个可执行文件链接了哪些库)来看一下为什么呢? 如下图, 发现libmath.so是notfound, 因为编译器 和链接器 不是同一个东西, 加载库的时候是链接器 在干活(严格说是静态链接器 , 动态链接器 会在程序运行时找到库并计算库函数的偏移), 它只会去默认的路径下找动态库, 你编译时指定的参数它是不知道的, 解决方法有几种, 我们选个最简单的, 在-o前加一个编译参数-Wl,-rpath=., 他的意思是把Wl后的参数传递给链接器, 这样编译得到的程序就可以正常运行了 - 其他更改库链接路径的方法:
- 修改环境变量
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH - 拷贝动态库文件到
/usr/lib, /usr/local/lib, /lib64目录下, 然后sudo ldconfig刷新
- 修改环境变量
bashldd main_D linux-vdso.so.1 (0x00007fff525dc000) libmath.so => not found libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f491986e000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f491965c000) libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f4919573000) /lib64/ld-linux-x86-64.so.2 (0x00007f4919afd000) libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f4919545000) -
但是你看一下最后生成的两个可执行文件, 大小居然是一样的, 这和我们前面讲的不太一样. 那说明现在不是真正的静态连接, 还是动态链接, 所以我们需要加上
-static参数, 这样得到的才是真正的静态库 , 他会把libc++打包进去,libc是c++的一个动态库, 为日常编写的c++函数提供支持

-
一下可执行文件就变大了, 这也说明
Linux下默认使用动态链接, 也就是当存在同名的库时, 优先链接动态库 , 还有一个现象可以证明, 我们不加-static参数试验一下

-
可以看到出现了和前面动态库一样的错误, 可以验证
Linux下默认使用的是动态链接

动静态库原理
- 说了这么多, 我们已经了解了库的基本使用了, 接下来我们该讲讲原理了, 前面我们了解到链接了库的可执行文件涉及到了两个阶段, 一个是编译 , 一个是链接 , 涉及到了
.o文件和库文件, 接下来我们来了解一下这两个文件 - 使用
file filename可以得到一个文件的类型信息, 我们使用它来看一下前面生成的文件
cpp
//先看目标文件
file add_S.o
add_S.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
// 看库文件
file libmath.a
libmath.a: current ar archive
file libmath.so
libmath.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=ce6934e55cc350be39255452c4a3ab4eee52e1b4, not stripped
// 可执行文件
file main_S
main_S: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=15ff0bc5dc8516009c2b60df2f3dc779d9b14b4c, for GNU/Linux 3.2.0, not stripped
file main_D
main_D: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=183b910d232815916fd9aebb038f8435adaf55ca, for GNU/Linux 3.2.0, not stripped
- 可以看到基本都有一个
ELF 64-bit前缀, 从这里可以看到有三种elf文件, 分别是relocatable,shared object,execute, 其实还有core dump文件也是 - 一个
elf文件有4部分- 程序头表 :列举了所有有效的段(
segments)和他们的属性。表⾥记着每个段的开始的位置和位移(offset)、⻓度,这些段,都是紧密的放在⼆进制⽂件中,需要段表的描述信息,才能把他们每个段分割开。 ELF头: 描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂件的其他部分。- 节:
ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。常见的节有.text和.data, 前者保存机器指令, 是程序的主要执行部分, 后者存储已初始化的全局变量和局部静态变量 - 节表头: 包含对节(
sections)的描述

- 程序头表 :列举了所有有效的段(
- 可执行程序的形成需要对每个
elf中相同的节进行合并 , 合并成一个段Segment, 一般一个section只会存储一种属性的节, 因为不同的节的权限不同 ,.text一般是可读和可执行的,.data是需要可读可写的, 每个section只存储一种节有助于相同节进行合并 , 最后合并成一个elf execute文件. 可以通过readelf命令查看每个elf文件,-S可以查看每个文件的节,-l可以查看节合并得到的段. - 把
section合并成segment的好处- 在编译程序时,会将具有相同属性的
section合并成⼀个⼤的segment,而其他具有相同属性的section也会合并出一个segment, 这样OS就可以为每个页表设置不同的访问权限, 而不需要为segment的不同的节设置精细的访问权限控制, 从⽽优化内存管理和权限访问控制。 Section合并的主要原因是为了减少⻚⾯碎⽚,提⾼内存使⽤效率 。如果不进⾏合并,假设⻚⾯⼤⼩为4096字节(内存块基本⼤⼩,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占⽤3个⻚⾯,⽽合并后,它们只需2个⻚⾯
- 在编译程序时,会将具有相同属性的
静态库链接
- 接下来我们学习静态库是如何链接的, 我们使用
objdump来对目标文件进行反汇编, 这里我们使用g++ main.o add_S.o sub_S.o -o main_Obj来进行编译可执行程序
cpp
#include "add.h"
#include "sub.h"
#include <iostream>
int main()
{
add(10, 10);
sub(10, 10);
return 0;
}
- 使用
objdump查看反汇编, 这里可以看到.o文件中add和sub函数的地址为0,main.o对应的两处call指令的地址也为0, 说明在编译阶段是不知道其他.o文件里的函数的存在的
bash
# objdump -d add_S.o
Disassembly of section .text:
0000000000000000 <_Z3addii>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 8b 55 fc mov -0x4(%rbp),%edx
11: 8b 45 f8 mov -0x8(%rbp),%eax
14: 01 d0 add %edx,%eax
16: 5d pop %rbp
17: c3 ret
# objdump -d sub_S.o
Disassembly of section .text:
0000000000000000 <_Z3subii>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 8b 45 fc mov -0x4(%rbp),%eax
11: 2b 45 f8 sub -0x8(%rbp),%eax
14: 5d pop %rbp
15: c3 ret
# objdump -d main.o
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: be 0a 00 00 00 mov $0xa,%esi
d: bf 0a 00 00 00 mov $0xa,%edi
12: e8 00 00 00 00 call 17 <main+0x17>
17: be 0a 00 00 00 mov $0xa,%esi
1c: bf 0a 00 00 00 mov $0xa,%edi
21: e8 00 00 00 00 call 26 <main+0x26>
26: b8 00 00 00 00 mov $0x0,%eax
2b: 5d pop %rbp
2c: c3 ret
- 查看可执行程序符号表, 可以看到
30和32那里函数的地址被修正了, 这就是静态链接的本质: 把不同的.o文件链接到一起, 在形成elf execute file时修正函数地址 , 读者可以尝试使用objdump -S main_Obj检查可执行文件中对应函数的地址是否还是全0. 所谓静态链接就是在编译时把不同.o文件和静态库中的.o文件进行组合, 合并.o文件(也是elf文件)中的.text,.data等, 然后在链接时修正可执行文件中函数的地址, 最终形成可执行文件.
cpp
Symbol table '.symtab' contains 43 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS Scrt1.o
2: 000000000000038c 32 OBJECT LOCAL DEFAULT 4 __abi_tag
3: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c
4: 0000000000001070 0 FUNC LOCAL DEFAULT 14 deregister_tm_clones
5: 00000000000010a0 0 FUNC LOCAL DEFAULT 14 register_tm_clones
6: 00000000000010e0 0 FUNC LOCAL DEFAULT 14 __do_global_dtors_aux
7: 0000000000004010 1 OBJECT LOCAL DEFAULT 24 completed.0
8: 0000000000003de8 0 OBJECT LOCAL DEFAULT 20 __do_global_dtor[...]
9: 0000000000001120 0 FUNC LOCAL DEFAULT 14 frame_dummy
10: 0000000000003de0 0 OBJECT LOCAL DEFAULT 19 __frame_dummy_in[...]
11: 0000000000000000 0 FILE LOCAL DEFAULT ABS add.cc
12: 0000000000000000 0 FILE LOCAL DEFAULT ABS sub.cc
13: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.cc
14: 0000000000002004 1 OBJECT LOCAL DEFAULT 16 _ZNSt8__detail30[...]
15: 0000000000002005 1 OBJECT LOCAL DEFAULT 16 _ZNSt8__detail30[...]
16: 0000000000002006 1 OBJECT LOCAL DEFAULT 16 _ZNSt8__detail30[...]
17: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c
18: 0000000000002118 0 OBJECT LOCAL DEFAULT 18 __FRAME_END__
19: 0000000000000000 0 FILE LOCAL DEFAULT ABS
20: 0000000000002008 0 NOTYPE LOCAL DEFAULT 17 __GNU_EH_FRAME_HDR
21: 0000000000003df0 0 OBJECT LOCAL DEFAULT 21 _DYNAMIC
22: 0000000000003fc0 0 OBJECT LOCAL DEFAULT 22 _GLOBAL_OFFSET_TABLE_
23: 0000000000004010 0 NOTYPE GLOBAL DEFAULT 23 _edata
24: 0000000000004000 0 NOTYPE WEAK DEFAULT 23 data_start
25: 0000000000002000 4 OBJECT GLOBAL DEFAULT 16 _IO_stdin_used
26: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@G[...]
27: 0000000000001157 45 FUNC GLOBAL DEFAULT 14 main
28: 0000000000004008 0 OBJECT GLOBAL HIDDEN 23 __dso_handle
29: 0000000000001184 0 FUNC GLOBAL HIDDEN 15 _fini
30: 0000000000001129 24 FUNC GLOBAL DEFAULT 14 _Z3addii
31: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_mai[...]
32: 0000000000001141 22 FUNC GLOBAL DEFAULT 14 _Z3subii
动态库链接
- 静态库 会把库全部打包到最终的可执行文件内, 这样会导致可执行文件的体积膨胀 . 如果同一个库被多个程序打包了, 这些程序同时运行, 这个静态库就会在内存里同时存在
10份, 非常浪费空间, 动态库可以解决这个问题, 多个程序依赖一个库, 这个库只需要在内存中加载一次, 所有依赖这个库的程序都可以正常运行, 接下来我们探究一下是怎么做的 - 为了让每个进程认为自己独占系统资源, 每个进程都有自己的进程地址空间 , 依赖于虚拟地址空间实现, 通过起始地址+偏移量 , 然后通过页表转换 , 就可以访问到物理地址, 那么一个进程如何知道并初始化地址空间这些信息呢? 这就关系到前面讲的
elf文件的组成部分之一的elf头了, 进程会在这里获取到自己需要的信息. 看下面这个例子. 下面的输出里存在一个Entry point address, 这里就是程序的入口地址, 知道了这个地址, 就可以开始执行程序了.
bash
readelf -h main_Obj
ELF Header:
Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - GNU
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x405200
Start of program headers: 64 (bytes into file)
Start of section headers: 2329104 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 10
Size of section headers: 64 (bytes)
Number of section headers: 28
Section header string table index: 27
- 动态库实际上也是一个文件, 会被加载到内存 (有了物理地址), 在生成可执行文件时, 我们会指明链接的库的名称和路径, 进程就可以在自己的地址空间内开辟一段空间(虚拟地址), 通过页表建立映射关系, 将库文件的代码段和数据等映射到进程的地址空间. 这样每个进程可以建立自己的映射关系, 从而实现一个库可以被多个进程使用
- 和静态库一样, 库函数的地址也是需要修正的, 但是因为库的加载是在运行时开始的, 所以库函数的地址修正也就推迟到了运行时 . 上面我们讲了进程的地址空间映射了库在内存中的物理地址, 在进程的地址空间内表现为虚拟地址, 现在计算机采用平坦模式编址, 采用起始地址+偏移量表示某个地址, 如果运行时修正地址, 实际上修改的是代码段的部分, 但是代码段是不可写的, 所以引入了
plt和GOT(全局偏移量表) . 程序运行时, 动态库被加载, 通过动态库的符号表知道了库函数在库内的偏移, 为了节省资源, 可能会在第一次调用函数时才将其写入GOT表, 或者立即写入. 所以修正地址完整的步骤是加载库时先call到plt的地址, 然后在其内部查找GOT表, 然后根据GOT表得到函数实际的地址 .如果一个库函数调用了两次, 下次就可以直接根据GOT表进行调用, 而不需要重新确定函数的实际地址. plt充当一个中间层, 因为我们程序没运行起来前, 我们既不知道库函数的起始虚拟地址, 也不知道其偏移量, 所以对库函数的调用实际转化为了call plt. - 库与库之间的依赖也是这样的, 每个库也有自己的
GOT表, 按照上述流程进行链接
好了, 这篇文章就到这里了, 断断续续写了两天, 思路不太连贯了, 如果觉得写的还不错的话, 欢迎点赞关注, 如果有写的不对的地方, 还请批评指正.