OS学习之路——动静态库制作与原理

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前缀和文件后缀名
    bash 复制代码
    g++ -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
  • 动态库

    • 分别执行以下命令
    bash 复制代码
    g++ -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.sonotfound, 因为编译器链接器 不是同一个东西, 加载库的时候是链接器 在干活(严格说是静态链接器 , 动态链接器 会在程序运行时找到库并计算库函数的偏移), 它只会去默认的路径下找动态库, 你编译时指定的参数它是不知道的, 解决方法有几种, 我们选个最简单的, 在-o前加一个编译参数 -Wl,-rpath=., 他的意思是把Wl后的参数传递给链接器, 这样编译得到的程序就可以正常运行了
    • 其他更改库链接路径的方法:
      • 修改环境变量export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
      • 拷贝动态库文件到/usr/lib, /usr/local/lib, /lib64目录下, 然后sudo ldconfig刷新
    bash 复制代码
    ldd 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++打包进去, libcc++的一个动态库, 为日常编写的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文件中addsub函数的地址为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
  • 查看可执行程序符号表, 可以看到3032那里函数的地址被修正了, 这就是静态链接的本质: 把不同的.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
  • 动态库实际上也是一个文件, 会被加载到内存 (有了物理地址), 在生成可执行文件时, 我们会指明链接的库的名称和路径, 进程就可以在自己的地址空间内开辟一段空间(虚拟地址), 通过页表建立映射关系, 将库文件的代码段和数据等映射到进程的地址空间. 这样每个进程可以建立自己的映射关系, 从而实现一个库可以被多个进程使用
  • 和静态库一样, 库函数的地址也是需要修正的, 但是因为库的加载是在运行时开始的, 所以库函数的地址修正也就推迟到了运行时 . 上面我们讲了进程的地址空间映射了库在内存中的物理地址, 在进程的地址空间内表现为虚拟地址, 现在计算机采用平坦模式编址, 采用起始地址+偏移量表示某个地址, 如果运行时修正地址, 实际上修改的是代码段的部分, 但是代码段是不可写的, 所以引入了pltGOT(全局偏移量表) . 程序运行时, 动态库被加载, 通过动态库的符号表知道了库函数在库内的偏移, 为了节省资源, 可能会在第一次调用函数时才将其写入GOT表, 或者立即写入. 所以修正地址完整的步骤是加载库时先call到plt的地址, 然后在其内部查找GOT表, 然后根据GOT表得到函数实际的地址 .如果一个库函数调用了两次, 下次就可以直接根据GOT表进行调用, 而不需要重新确定函数的实际地址. plt充当一个中间层, 因为我们程序没运行起来前, 我们既不知道库函数的起始虚拟地址, 也不知道其偏移量, 所以对库函数的调用实际转化为了call plt.
  • 库与库之间的依赖也是这样的, 每个库也有自己的GOT表, 按照上述流程进行链接

好了, 这篇文章就到这里了, 断断续续写了两天, 思路不太连贯了, 如果觉得写的还不错的话, 欢迎点赞关注, 如果有写的不对的地方, 还请批评指正.

相关推荐
kcuwu.3 小时前
从0到1:VMware搭建CentOS并通过FinalShell玩转Linux命令
linux·运维·centos
red_redemption3 小时前
自由学习记录(160)
学习
南無忘码至尊3 小时前
Unity学习90天-第2天-认识Unity生命周期函数并用 Update 控制物体移动,FixedUpdate 控制物理
学习·unity·游戏引擎
s6516654963 小时前
linux-内核结构体
linux
.柒宇.3 小时前
MySQL双主同步
linux·数据库·mysql·docker
格林威3 小时前
AI视觉检测:INT8 量化对工业视觉检测精度的影响
linux·运维·人工智能·数码相机·计算机视觉·视觉检测·工业相机
报错小能手3 小时前
ios开发方向——swift错误处理:do/try/catch、Result、throws
开发语言·学习·ios·swift
万山寒3 小时前
linux日志查询,查找某个关键词后面的内容
linux·运维·服务器
LX567773 小时前
传统销售如何系统学习成为AI智能销售顾问?认证指南
人工智能·学习