1、什么是库
库就是已经写好的可以复用的代码。库是一种可执行代码的二进制形式,可以被操作系统载入到内存执行。库有两种:
- 静态库 .a[Linux]、.lib[windows]
- 动态库 .so[Linux]、.dll[windows]
2、静态库
程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。
我们首先自己写一个库来理解一下什么是库:准备三个文件,一个是创建库的头文件,一个是实现库的源文件,还有一个是使用库的主程序。
Linux 库分为静态库和动态库。静态库在编译链接时将代码合并到可执行文件,运行时不再依赖;动态库在运行时加载,支持共享。文章讲解了使用 ar 和 g++ 创建库的方法,以及 Makefile 构建流程。此外还深入分析了 ELF 文件格式结构,包括头部、程序头表、节头表和节,解释了静态链接过程中的符号解析、重定位及虚拟地址映射机制。

库就是已经写好的可以复用的代码。库是一种可执行代码的二进制形式,可以被操作系统载入到内存执行。库有两种:
程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。
我们首先自己写一个库来理解一下什么是库:准备三个文件,一个是创建库的头文件,一个是实现库的源文件,还有一个是使用库的主程序。
#ifndef MYLIBRARY_H
#define MYLIBRARY_H
namespace MyLibrary {
class Calculator {
public:
static int add(int a, int b);
static int subtract(int a, int b);
};
void greet(const char* name);
}
#endif // MYLIBRARY_H
#include "mylibrary.h"
#include <iostream>
namespace MyLibrary {
int Calculator::add(int a, int b) {
return a + b;
}
int Calculator::subtract(int a, int b) {
return a - b;
}
void greet(const char* name) {
std::cout << "Hello, " << name << "!" << std::endl;
}
}
#include "mylibrary.h"
#include <iostream>
int main() {
// 使用库中的类
std::cout << "5 + 3 = " << MyLibrary::Calculator::add(5, 3) << std::endl;
std::cout << "5 - 3 = " << MyLibrary::Calculator::subtract(5, 3) << std::endl;
// 使用库中的函数
MyLibrary::greet("World");
return 0;
}
要将上述代码编译为库并链接到主程序,可以按照以下步骤操作:
运行程序:
./myprogram
编译主程序并链接库:
g++ main.cpp -L. -lmylibrary -o myprogram
将对象文件打包为静态库:
ar rcs libmylibrary.a mylibrary.o
编译库文件为对象文件(注意是把库的实现文件编译成.o):
g++ -c mylibrary.cpp -o mylibrary.o
库其实就是把程序员写好的源代码,编译成.o,然后打了个包。

此时如果我们使用 ldd 命令去查看 myprogram 的依赖库信息,会发现查不到。这是因为静态库会把自己的代码合并到目标文件,即一旦形成可执行程序,就不再依赖静态库了。
一般官方的库都被装在了系统的默认路径下,而 gcc/g++ 编译程序时,一般会在默认路径下查找。所以,假设我们的可执行程序中只包含了 stdio.h 这个头文件,那么我们不需要-L 来指示库的位置,因为 C 标准库就在系统的默认路径中。所谓的库的安装,主要就是把库文件拷贝到系统的默认路径下。
又因为 gcc 是专门编译 C 语言的,所以他默认就要认识 libc。因此在编译的时候,我们不需要带着-lc。第三方的库都需要带-l 来表示库名称。
假设我们现在想把自己写的静态库中的头文件在 include 时用<>包含起来,我们需要怎么做呢?
第一种方法就是在执行命令时,加上-I.,表示在当前路径搜索头文件。第二种方式就是把我们的头文件也拷贝到系统的指定目录下。我们把 libmylibrary 移动到默认路径下,再把头文件拷贝到系统的指定目录之后,我们就可以通过执行以下命令成功编译程序:
g++ main.cpp -lmylibrary -o myprogram
库=头文件 + 库文件。库的使用需要搜索找到头文件(-I),库路径(-L),库是谁(-l)。库如果不想过多使用上面的选项,就需要把库安装到指定了系统特定路径。一般情况下,头文件拷贝到/usr/include,而库文件拷贝到/lib64。
现在我们正儿八经的用 make 构建制作静态库的流程:
libmyc.a:mymath.o mystdio.o
ar -rc $@ $^
%.o:%.c
gcc -c $<
.PHONY:clean
clean:
rm -f *.a *.o
.PHONY:output
output:
mkdir mylibc
mkdir -p mylibc/include
mkdir -p mylibc/lib
cp *.h mylibc/include
cp *.a mylibc/lib
tar czf mylibc.tgz mylibc

执行 make,会自动生成静态库的压缩包。
如果想创建动态库(共享库),可以使用以下命令:
运行前需要设置库路径:
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
./myprogram
链接动态库:
g++ main.cpp -L. -lmylibrary -o myprogram
编译为动态库:
g++ -shared -fPIC mylibrary.cpp -o libmylibrary.so
现在我们自动化构建动态库:
libmyc.so:mymath.o mystdio.o
gcc -o $@ $^ -shared
%.o:%.c
gcc -fPIC -c $<
.PHONY:clean
clean:
rm -rf *.so *.o mylibc *.tgz
.PHONY:output
output:
mkdir mylibc
mkdir -p mylibc/include
mkdir -p mylibc/lib
cp *.h mylibc/include
cp *.so mylibc/lib
tar czf mylibc.tgz mylibc

使用 libmyc.so 时,我们需要指示库的路径,头文件的路径,以及库的名字:
gcc test.c -I mylibc/include -L mylibc/lib/ -lmyc
在我们配置编译器环境时,比如 vs,它会自动给我们下载一堆东西,这堆东西中就有各种我们需要用的的库和头文件。
接着我们运行程序,发现会报错,这是因为我们的动态链接要求在运行时也要能找到动态库。我们时刻需要访问动态库。而之前在编译时,我们只是告诉了编译器动态库在哪里,但是系统(中的加载器)并不知道动态库在哪。
我们可以把动态库拷贝到 lib64(即系统默认路径)中,这样系统和编译器就都能找到了。当然我们还有别的方法,我们还可以在 lib64 目录下,以软连接的方式进行关联。软连接的名字应当与库名称一样。其实一般情况下,我们所有的库都是放在 lib64 中的。
除了这两种方式以外,还有一种方式就是通过环境变量。我们可以配置指定的环境变量,让系统找到指定的动态库。即上面第 4 条介绍的那种方式。这种方式是临时方式。
除此之外,我们还可以将动态库查找路径全局有效,更改系统配置文件。在这里就不演示了,因为我们常用的是第一二种方法。
动态库和静态库同时存在的时候,gcc/g++ 默认优先使用动态库。默认进行动态链接。一个可执行程序,可能会依赖多个库。如果我们只提供静态库,即使我们是动态链接,gcc 也没有办法,只能对只提供静态库的库进行静态链接。
我们在编译时可以使用--static 选项,要求必须采用静态链接。这种情况下,静态库必须存在。
大部分系统只安装了 C/C++的动态库,没有安装静态库。我们可以通过以下指定手动安装静态库:
#C 语言
sudo apt/yum install glibc-static
#C++
sudo apt/yum install libstdc++-static
在过去的一段时间里,我们接触过几种特殊的文件,包括可重定位文件(.o 为后缀),可执行文件,共享目标文件(.so 为后缀),内核转储。以上这些文件都是 ELF 文件。
以人类的眼光看,我们直接打开 ELF 文件,里面的内容都是乱码。其实这些乱码也是有固定的存储规则的。

ELF 文件由以下部分组成:
描述文件的基本属性,如文件类型(可执行、目标文件等)、目标架构(如 x86、ARM)和程序入口地址。
仅存在于可执行文件和共享库中,定义如何将文件加载到内存。每个条目描述一个段(如代码段、数据段)的内存布局。
包含文件中所有节(section)的描述信息,如代码节(.text)、数据节(.data)和符号表(.symtab)。目标文件通常依赖此表进行链接。
存储实际内容,例如:
.text:可执行代码。.data:已初始化的全局变量。.bss:未初始化的全局变量(不占文件空间)。.rodata:只读数据(如字符串常量)。我们可以通过 readelf 命令来获取 ELF 文件的各种信息,比如我们可以查看 ELF Header 中的信息:

ELF(Executable and Linkable Format)文件的开头是一个固定大小的头部结构(ELF Header),它描述了整个文件的基本属性和布局。以下是 ELF Header 中存储的关键信息:
0x7F、'E'、'L'、'F',用于标识文件是否为 ELF 格式。ET_REL(可重定位文件,如 .o 文件)ET_EXEC(可执行文件)ET_DYN(共享库,如 .so 文件)EM_X86_64(x86-64)EM_ARM(ARM)EM_RISCV(RISC-V)EV_CURRENT(当前版本)。_start 函数的地址)。e_phoff:Program Header 表在文件中的偏移量。e_shoff:Section Header 表在文件中的偏移量。我们可以通过指令来读一下节头表中的内容:

其中包含了各个节的名称,比如 text(文本),rodate(已初始化全局数据区) 等等。Section Header 会记录一共有多少个节,并且记录每个节的大小、偏移量和权限等等。
节的大小不一定是 4KB,但操作系统每次读取数据都是以 4KB 为单位的。而且,不同的节也可能会有相同的属性。所以,我们每次 IO 的时候,都需要将多个属性相同的 section 合并,对齐 4KB(对齐 4KB 的倍数)。合并之后的数据节叫数据段(segment)。没错,我们之前学过的什么 BSS 段,数据段,代码段,都是从 ELF 文件中加载进来的。ELF 加载到内存的时候,会被 OS 自动合并成为多个 segment,加载到内存中。而程序头表(Program Header Table)就是合并成为 segment 的方法表。也就是说,合并的方式在 ELF 文件被创建的时候就已经规定好了。
Section 合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为 4096 字节(内存块基本大小,加载,管理的基本单位),如果.text 部分 为 4097 字节,.init 部分为 512 字节(.init 和.text 属性相同),那么它们将占用 3 个页面,而合并后,它们只需 2 个页面。此外,操作系统在加载程序时,会将具有相同属性的 section 合并成一个大的 segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问。
我们可以通过指令查看程序头表(Program Header Table)中的内容:

合并之后就变成了八个数据段,分别对应编号 01 到 08,最后合并成的数据段的个数是不固定的,不同文件可能不一样。
一个 ELF 文件要有两种视角,一种视角是编译器视角(section),另一种是系统视角(segment)。
链接其实就是把多个 ELF 文件中对应的属性相同的 section 合并到一起:

当然实际上的链接要复杂的多。
符号解析
链接器读取所有输入的目标文件,检查每个目标文件中的符号定义与引用。确保每个符号有且仅有一个定义,解决所有未解析的符号引用。
地址与空间分配
为输出文件中的代码段、数据段等分配运行时内存地址。合并所有目标文件的同类段(如.text、.data),并确定每个段在最终可执行文件中的大小和位置。
重定位
修改目标文件中的符号引用地址,使其指向正确的运行时地址。根据段合并后的新基址,调整代码和数据中的相对地址或绝对地址引用。
生成可执行文件
将重定位后的代码、数据及必要的头部信息(如 ELF 头、段表)写入输出文件,形成可直接加载运行的静态可执行文件。
所以,链接过程中会涉及到对.o 中外部符号进行地址重定位。

一个 ELF 可执行程序,在没有加载到内存的时候,就已经有地址了!Linux 系统编译形成可执行程序的时候,需要对代码和数据进行编址。当代 CPU 和计算机和操作系统,在对 ELF 编址的时候采用的做法都是平坦模式进行的,我们仍可以看作是段起始地址 + 偏移量的方式,只不过段起始地址是 0。
这种从全零到全 F 的线性编址得到的地址起始就是我们之前讲的虚拟地址。在磁盘中的可执行文件,它的地址是以逻辑地址的方式表示的,而在内存中,它是以虚拟地址的方式表示的。尽管在磁盘中是逻辑地址,在内存中是虚拟地址,但是他们都是从全 0 到全 F 的。
当我们把可执行程序加载到内存中时,我们的程序内部就建立起了物理地址到虚拟地址的映射关系。这个映射关系被记录在页表之中。cpu 可以根据存储在 EIP 中的程序地址入口(即 ELF Header 中的 entry point address)去访问物理地址。需要注意的是,这个程序地址入口存储的地址是程序在内存中的虚拟地址。当 cpu 拿到虚拟地址之后,在根据页表得到物理地址再进行访问。当 CPU 需要将虚拟地址转换为物理地址时,会从 CR3 寄存器获取页表基址开始查找。
CR3 寄存器为 MMU 提供了页表的起始地址。在 x86 分页机制中,虚拟地址到物理地址的转换需要多级页表(如页目录和页表)。CR3 存储的是页目录的物理地址,MMU 使用 CR3 的值作为起点,逐级查找页表项,最终完成地址转换。转换完成后,cpu 和内存又通过系统总线连接起来,进行数据的传输。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 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