一、什么是库
库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。 本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:
本文介绍了 Linux 下库的制作与原理。涵盖静态库(.a)和动态库(.so)的生成与使用方法,对比了两者在链接时的区别及依赖关系。深入解析了 ELF 文件格式,包括头部、节表与段表的作用,以及编译器与操作系统视角的差异。详细阐述了静态链接与动态链接的过程,涉及虚拟地址映射、重定位机制、GOT 表与 PLT 延迟绑定技术,帮助理解程序加载与内存管理的底层原理。

库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。 本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:
库本质就是 Linux 指定目录下的普通文件,我们可以像对常规文件读写那样将库文件内容拷贝到我的可执行文件里,或者将库文件内容加载到内存里。
例如在 Ubuntu 下查看动静态库:
# C
ls -l /lib/x86_64-linux-gnu/libc-2.31.so
ls -l /lib/x86_64-linux-gnu/libc.a
# C++
ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so
ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a
注意以下几点:
如果我们运行程序时引入的外部文件是以单个.o 文件的形式时,是可以直接链接所有目标文件形成可执行文件的。但是如果外部的文件是以静态库的形式引入当前程序时,我们直接链接程序是会报错的,因为要使用一个静态库必须要先找到,所以 gcc 编译时需要带 -l(库名) 选项,但是这还不够,因为库文件不同于头文件,系统一般不会去当前工作路径找静态库,而是由 gcc/g++编译器中的 /usr/bin/ld(加载器)去指定路径如 /lib64->usr/lib64 这样的链接文件中找,所以需要指明到库在当前路径下。
下面是编译自己库文件的完整指令示例:
gcc -c *.o
ar rc libmyc.a *.o
而 C 标准库却不用我们指定特定路径就能链接,因为 C 标准库本身就在编译器会去找的默认路径 /lib64->usr/lib64 下,当我们把自己的库文件拷贝到默认路径下后,就可以不带-L 指定路径,只用带-l 指定文件名就可以完成链接了。所以所谓库的安装,就是把库文件拷贝到系统默认路径下。
这里还有一个问题,那既然我们自己的文件和 C 标准库的文件都在默认路径下了,为什么 gcc 链接自己的库还需要 -l 指定文件名,而链接 C 标准库却不用?这是因为 gcc 本来就是用来编译 C 语言的,所以 gcc 默认认识 C 标准库,不用手动指定。所以未来我们使用任何第三方库,至少需要带 -l(小写 l)指定文件名。
以前我们讲过带 "" 的头文件是告诉编译器先到当前路径查,若没找到再到系统默认路径中查。带 < > 的头文件是告诉编译器直接到系统默认路径中查。如果我们就想用 < > 包含当前文件下自己创建的头文件,可以用两种方法:
库 = 头文件 + 库文件。库使用需要搜索:
ar 是 gnu 归档工具,一种指令,类似 zip,它专门用来打包形成静态库。 rc 选项表示 (replace and create),已存在则替换,不存在则创建。
Makefile 示例含义:
Makefile 中的顺序问题:即使 libmyc.a 的规则写在前面,%.o:%.c 写在后面,Make 也能正确找到依赖的编译规则,顺序不会导致逻辑错误。不过从可读性角度,通常会把'最终目标'(比如 libmyc.a)放在前面,依赖的规则(比如.o 的编译)放在后面。
形成库文件后还需要创建一个目录 mylibc,把头文件和库文件分别拷贝进目录的 include 和 lib 目录中,在把 mylibc 打包压缩,就可以把压缩包传给别人使用啦。
总结:给别人提供库就是以特定目录结构组织好的头文件和库文件。
由于动态库使用频率远远高于静态库,所以 gcc 天然支持将.o 文件打包形成动态库,不像静态库需要用 ar 指令打包。 shared: 表示生成共享库格式 fPIC:产生位置无关码 (position independent code)
动态库使用时和静态库有一些区别,当我们把自己形成的动态库链接形成可执行程序时带的 -L 选项是告诉了链接器库文件的路径在哪里,但当我们运行动态库形成的可执行程序时系统会报找不到库文件,这是因为我们没有告诉系统(加载器)库在哪里,因为动态库不像静态库在程序内部,运行可执行程序时动态库不会随着程序加载进内存,而是需要加载器去磁盘里找到动态库把它单独再加载进内存。 但是为什么在运行时不用指定 C 标准库的路径却能正常运行程序呢?这和其实和链接时一样,不仅链接时链接器会去默认路径 lib64 中找库文件,运行加载程序时加载器也会到默认路径 lib64 中找库文件。
这里解决运行找不到库文件报错有两种方法:
虽然查找动态库我们介绍了 4 种方法,但是最佳实践是前两种,推荐大家优先使用前两种。
我们在测试上面原理时会发生一个问题,只要加-static 就会报错,这是因为大部分 linux 系统只给我们安装了 C/C++ 的动态库,没有安装静态库。所以加-static 时就会因为找不到 C/C++ 的静态库而报错。所以需要给系统安装静态库,下面是安装指令:
# CentOS 7
sudo yum install glibc-static
# CentOS 8 及以上
sudo dnf install glibc-static
# Ubuntu
sudo apt-get update
sudo apt-get install libc6-dev
我们前面提到过,可执行程序也是文件,它们里面存放着程序的代码和数据,但是感觉这些概念都很虚,我们来落实落实,了解一下二进制可执行文件中的代码和数据在内存和磁盘中的组织形式。首先我们来认识一下 ELF 文件。
查看 ELF Header: 指令:readelf -h 文件名 (ELF 头 (ELF header) :描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。)
下面是 ELF header 内部结构:
typedef struct elf32_hdr {
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry; /* Entry point */
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
编译器和操作系统都要认识这个 ELF header: 编译器在编译源文件形成可执行程序时编译器会为我们创建 ELF header 结构体,所以编译器天然认识 ELF header。而操作系统未来要将可执行程序加载到内存里,就需要读取可执行程序的 ELF header,所以操作系统也认识 ELF header。
查看 Section Header Table: 指令:readelf -S 文件名 (节头表 (Section header table) :包含对节 (sections) 的描述,例如节的偏移量和大小,节头表中记录节的偏移量和大小可以说明每个节大小不是 4kb,否则就不用记录了。)
查看 Program Header Table: 指令:readelf -l 文件名 (列举了所有有效的段 (segments) 和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中,需要段表的描述信息,才能把他们每个段分割开。)
这里小编先介绍一下说明是数据段,它和数据节之间有什么关系: 前面我们介绍过了,在编译器角度,一个数据节不一定是 4kb,一般小于 4kb,具体大小由代码长度和数据大小决定,并且多个数据节可能会有相同的属性。在 OS 角度,磁盘和内存进行 IO 的时候,必须是 4kb,所以必然就需要将多个数据节合并对齐为 4kb,这个合并多个数据节大小为 4kb 的内容就是数据段,合并的时机是将磁盘数据加载到内存时。
至此我们明白了未来看待 ELF 文件有两种视角:
这里的 Program Header Table 就是一张合并方法表。
查看具体的 sections 信息: 指令:objdump -S 文件名
查看编译后的.o 目标文件: 指令:objdump -d 文件名 (查看反汇编)
step-1:将多份 C/C++ 源代码,翻译成为目标 .o 文件。 step-2:将多份 .o 文件的 section 进行合并,最后合并为一个可执行程序,该过程就是链接。
为什么要将 section 合并成为 segment:
总结:section 在链接时作用,segment 在运行加载时作用。
无论是自己的.o, 还是静态库中的.o,本质都是把.o 文件进行连接的过程,所以研究静态链接,本质就是研究.o 文件是如何链接的。静态链接的行为如下:两个.o 的代码段合并到了一起,并进行了统一的编址。链接的时候,会修改.o 中没有确定的函数地址,在合并完成之后,进行 call 具体地址,完成代码调用。这个过程叫做链接时地址重定位。所以我们把.o/.obj 文件称为可重定位目标文件。
所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的可执行文件。当所有模块组合在一起之后,链接器会根据我们的.o 文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。所以,链接过程中会涉及到对.o 中外部符号进行地址重定位。
首先要明确动静态链接方式影响'虚拟地址的确定时机',不影响'虚实地址的转换机制',所以不论是动态链接还是静态链接都遵守下面要介绍的虚实地址的转换机制。
一个 ELF 可执行程序在没有被加载到内存的时候,其实它内部的每行代码和对应数据就已经有地址了,链接重定向可以间接证明这个结论,因为重定向需要拿到函数的地址(函数的本质就是相邻地址的集合)。
当代的计算机对 ELF 进行编址的时候,都是用一种线性的平坦模式进行编址,就是按照代码和数据出现的次序依次从低到高编址。
上面介绍的这种地址,其实是虚拟地址。在 linux 系统和平坦模式下,在磁盘/ELF 文件中称这种地址为逻辑地址,在内存中,这种地址称为虚拟地址。 (补充:ELF 的逻辑地址是编译链接时确定的'预期虚拟地址',进程的虚拟地址是运行时实际的地址空间;加载时通过映射和重定位,让逻辑地址适配到虚拟地址空间中(可能因地址空间随机化存在偏移))
所以在磁盘的数据被加载到磁盘之前,磁盘中的数据就已经被统一编址过了,只不过这时的地址是逻辑地址。在磁盘数据加载到内存后,这些数据就会有物理地址,物理地址、逻辑地址都有了,就可以对它们的地址做物理到虚拟的映射,就可以将映射地址填入到页表中。
现在物理、虚拟地址都有了,那么程序加载到内存后从哪里开始执行呢?这里就要引入之前介绍 ELF Header 时没有介绍的一个成员:Entry point address,它是当前可执行程序的入口虚拟地址。当程序加载到内存后,就会把 Entry point address 的值加载到 OS 的程序计数器 EIP 寄存器中。
除了 EIP 外,CPU 内还有一个 CP3 寄存器,它指向当前进程页表的首地址,还有一个 CPU 内部的硬件组件 MMU,它可以查页表完成从虚拟地址到物理地址的映射。有了上面这些,所以 MMU 就可以当着 EIP 的虚拟地址,拿着 CR3 指向的页表,就可以查页表将虚拟地址转换为物理地址、拿着物理地址开始访问物理地址指向的代码和数据了。不仅仅是程序入口虚拟地址是这样转换的,程序中遇到的所有虚拟地址比如程序内部的函数互相调用都是这样转换成物理地址的。所以以后进入 CPU 的是虚拟地址,出 CPU 的是物理地址(通过 MMU 进行转化)。
所以虚拟空间技术,需要操作系统(创建内核数据结构)、编译器(平坦模式编址)、CPU 硬件(寄存器、MMU)三者协同支持实现。
我们前面还说过,进程创建时要先创建 PCB、进程地址空间和页表,那进程地址空间进行划分时各个段的初始值从哪来呢?其实虚拟内存空间的段是从 ELF 文件中多个 section 合并成的 segment 的地址得来的,因为 ELF 文件中有多个有 section 合并成的 segment,我们可以用指令看一下。
并且每一个加载进内存的 segment 都对应一个虚拟内存中的 vm_area_struct。
首先小编先说明一点,为什么我们不讲进程如何看到静态库,因为静态库在链接时和可执行程序合并到一起了,进程天然就能看到。
那么动态库呢?首先程序要调用动态库,所以程序运行时动态库也需要加载到内存中,加载的内存中的动态库会提供页表映射到进程地址空间堆栈之间的共享区中,这样进程就能看到动态库了。 一个进程是可能同时映射多个库到共享区的。
动态链接其实远比静态链接要常用得多,因为静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源,这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。
那么动态链接到底是如何工作的? 首先要交代一个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。 当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
首先明确_start 是 C 运行时库(如 glibc)提供的程序初始入口函数,它会调用动态链接器(ld-linux-x86-64.so)解析并加载程序依赖的动态库,ld-linux-x86-64.so 库如下图所示。
在 C/C++ 程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上,程序的入口点是_start,这是一个由 C 运行时库(通常是 glibc)或链接器(如 ld)提供的特殊函数。在_start 函数中,会执行一系列初始化操作,这些操作包括:设置堆栈:为程序创建一个初始的堆栈环境。初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。动态链接:这是关键的一步,_start 函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。
进程如何把动态库映射到自己的进程地址空间里: 首先要把动态库加载到内存中,其中动态库也是文件,所以我们要先打开库文件,因为打开文件后才能把文件加载到内存中。打开文件后 OS 会为该文件在内存中创建一个 struct file,struct file 初始化后会间接关联文件的 inode。然后 OS 会创建一个 vm_area_struct,并且 vm_area_struct 会关联 struct file。当我们拿到 inode 后就能拿到 inode 中的文件数据块信息,然后就能将文件的内容读取到物理内存中(这里把文件内容读取到物理内存中的空间就是文件内核缓冲区),这时动态库的物理地址就有了。接着 OS 还会在进程地址空间的共享区申请一段连续的空间来和物理地址做映射,然后我们就能拿到库在进程地址空间中的起始虚拟地址。
(提炼:先通过文件系统将库文件加载到内存里,加载到内存中后就能拿到该库对应的 inode 文件,然后就能通过 inode 拿到文件的数据块,就能把文件数据加载到内存里,这时物理地址就有了,然后再进程地址空间的共享区建立一段连续的虚拟空间,再将物理与虚拟地址建立映射)
下图是示意图,具体流程分 4 步走。
但是这里还有一个问题,如上图所示,运行时地址重定向本质是将代码区原本的库文件名修改为库的起始虚拟地址,但是代码区不是只读的吗?为什么可以修改呢?这里就要引入一个概念:全局偏移量表,下面我们来详细介绍。
动态链接采用的做法是在进程地址空间的数据段(可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表 GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。因为.data 区域是可读写的,所以可以支持动态进行修改。
不仅仅有可执行程序调用库库也会调用其他库!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢?库中也有.GOT,和可执行一样!这也就是为什么大家都是 ELF 的格式!
由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,我们的操作系统还做了一些其他的优化,比如延迟绑定,或者也叫 PLT(过程连接表(Procedure Linkage Table))。与其在程序一开始就对所有函数进行重定位,不如将这个推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。 思路是:GOT 中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码/stup。在我们第一次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新 GOT 表。于是我们再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。
静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位 (也叫做静态重定位)。而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT 方式进行调用 (运行重定位,也叫做动态地址重定位)。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online