1. 库的定义
库是写好的现有的,成熟的,可以复用的代码。每个程序都要依赖很多基础的底层库。本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行
库有两种:
- 静态库 .a[Linux]、.lib[windows]
- 动态库**.so[Linux]、.dll[windows]**
2. 静态库
- 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库
- 一个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,编译默认为动态链接库,只有在该库下找不到动态.so的时候才会采用同名静态库
- 也可以使用 gcc 的-static 强转设置链接静态库
静态库生成
cs
// Makefile
libmystdio.a:my_stdio.o my_string.o
@ar -rc $@ $^
@echo "build $^ to $@ ... done"
%.o:%.c
@gcc -c $<
@echo "compling $< to $@ ... done"
.PHONY:clean
clean:
@rm -rf *.a *.o stdc*
@echo "clean ... done"
.PHONY:output
output:
@mkdir -p stdc/include
@mkdir -p stdc/lib
@cp -f *.h stdc/include
@cp -f *.a stdc/lib
@tar -czf stdc.tgz stdc
@echo "output stdc ... done"
查看GNU ar 归档工具
bash
$ ar -tv libmystdio.a
rw-rw-r-- 1000/1000 2848 Oct 29 14:35 2024 my_stdio.o
rw-rw-r-- 1000/1000 1272 Oct 29 14:35 2024 my_string.o
- ar:GNU 归档工具,用于创建 / 管理静态库(
.a文件) - -t:列出归档文件中的成员列表(table of contents)
- -v:verbose 模式,显示详细信息(权限、所有者、大小、时间等)
静态库的使用
cs
// 任意⽬录下,新建
// main.c,引⼊库头⽂件
#include "my_stdio.h"
#include "my_string.h"
#include <stdio.h>
int main()
{
const char *s = "abcdefg";
printf("%s: %d\n", s, my_strlen(s));
mFILE *fp = mfopen("./log.txt", "a");
if(fp == NULL) return 1;
mfwrite(s, my_strlen(s), fp);
mfwrite(s, my_strlen(s), fp);
mfwrite(s, my_strlen(s), fp);
mfclose(fp);
return 0;
}
bash
// 场景1:头⽂件和库文件安装到系统路径下
$ gcc main.c -lmystdio
// 场景2:头文件和库文件的源⽂件在同⼀个路径下
$ gcc main.c -L. -lmymath
// 场景3:头⽂件和库文件有自己的独⽴路径
$ gcc main.c -I头文件路径 -L库⽂件路径 -lmymath
- -L: 指定库文件搜索路径
- -I: 指定头文件搜索路径
- -l(小写 L): 指定要链接的库名
- 测试目标文件生成后,静态库就不再被依赖,静态库删掉程序照样可以运行
- 库文件名称和引入库的名称:去掉前缀 lib ,去掉后缀 .so ,.a ,如: libc.so -> c
3. 动态库
- 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码
- 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在⽬ 标文件的整个机器码
- 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中, 这个过程称为动态链接(dynamic linking)
- 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采 用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间
动态库生成
bash
// Makefile
libmystdio.so:my_stdio.o my_string.o
gcc -o $@ $^ -shared
%.o:%.c
gcc -fPIC -c $<
.PHONY:clean
clean:
@rm -rf *.so *.o stdc*
@echo "clean ... done"
.PHONY:output
output:
@mkdir -p stdc/include
@mkdir -p stdc/lib
@cp -f *.h stdc/include
@cp -f *.so stdc/lib
@tar -czf stdc.tgz stdc
@echo "output stdc ... done"
- shared: 表示生成共享库格式
- fPIC:产生位置无关码(position independent code)
- 库名规则:libxxx.so
动态库使用
bash
// 场景1:头⽂件和库⽂件安装到系统路径下
$ gcc main.c -lmystdio
// 场景2:头⽂件和库⽂件和我们⾃⼰的源⽂件在同⼀个路径下
$ gcc main.c -L. -lmymath // 从左到右搜索-L指定的⽬录
// 场景3:头⽂件和库⽂件有⾃⼰的独⽴路径
$ gcc main.c -I头⽂件路径 -L库⽂件路径 -lmymath
$ ldd libmystdio.so // 查看库或者可执⾏程序的依赖
linux-vdso.so.1 => (0x00007fffacbbf000)
libc.so.6 => /lib64/libc.so.6 (0x00007f8917335000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8917905000)
库运行搜索路径
问题
bash
$ ldd a.out
linux-vdso.so.1 => (0x00007fff4d396000)
libmystdio.so => not found
libc.so.6 => /lib64/libc.so.6 (0x00007fa2aef30000)
/lib64/ld-linux-x86-64.so.2 (0x00007fa2af2fe000)
解决
- 方案一:拷贝 .so 文件到系统共享库路径下,一般指 /usr/lib、/usr/local/lib、/lib64 或者开篇指明的库路径等
bash
# 示例:将你的 libxxx.so 文件拷贝到 /usr/local/lib
cp /root/tools/linux/libxxx.so /usr/local/lib/
# 拷贝后建议执行 ldconfig 让系统刷新缓存
ldconfig
- 方案二:向系统共享库路径下建立同名软连接
bash
# 示例:为 /root/tools/linux/libxxx.so 建立软连接到 /usr/local/lib
ln -s /root/tools/linux/libxxx.so /usr/local/lib/libxxx.so
# 同样需要执行 ldconfig 生效
ldconfig
- 方案三:更改环境变量: LD_LIBRARY_PATH
- 方案四:ldconfig方案:配置/ etc/ld.so.conf.d/ ,ldconfig更新
bash
# 在 /etc/ld.so.conf.d/ 下新建 .conf 文件(名称自定义,比如 bit.conf)
echo "/root/tools/linux" > /etc/ld.so.conf.d/bit.conf
cat /etc/ld.so.conf.d/bit.conf
# 输出:/root/tools/linux
ldconfig
4. 目标

什么是编译呢?
编译的过程其实就是将程序的源代码翻译成CPU能够直接运行的机器 代码。 如:在一个源文件 hello.c 里便简单输出"hello world!",并且调用一个run函数,而这个函数被定义在另一个原文件 code.c 中。这里我们就可以调 gcc -c 来分别编译这两个原文件
cs
// hello.c
#include<stdio.h>
void run();
int main() {
printf("hello world!\n");
run();
return 0;
}
// code.c
#include<stdio.h>
void run() {
printf("running...\n");
}
// 编译两个源⽂件
$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c code.o hello.c hello.o
在编译之后会生成两个扩展名为**.o** 的文件,它们被称作目标文件 。要注意的是如果我们修改了一个原文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程。目标文件是一个二进制的文件,文件的格式是 ELF,是对二制代码的一种封装
5. ELF文件
以下四种文件其实都是ELF文件:
- 可重定位文件(Relocatable File) :即xxx.o文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。
- 可执行文件(Executable File) :即可执行程序。
- 共享目标文件(Shared Object File) :即xxx.so文件
- 内核转储(core dumps) ,存放当前进程的执行上下文,用于dump信号触发
⼀个ELF文件由以下四部分组成:
- ELF头(ELF header) :描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分
- 程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。里面记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中, 需要段表的描述信息,才能把他们每个段分割开
- 节头表(Section header table) :包含对节(sections)的描述
- 节(Section ):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。
最常见的节:
- 代码节(.text):用于保存机器指令,是程序的主要执行部分
- 数据节(.data):保存已初始化的全局变量和局部静态变量
6. ELF从形成到加载轮廓
6-1 ELF形成可执行
- step-1:将多份 C/C++ 源代码,翻译成为目标 .o 文件
- step-2:将多份 .o 文件section进行合并

6-2 ELF可执行文件加载
- 一个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment
- 合并原则:相同属性,如:可读,可写,可执行,需要加载时申请空间等
- 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到一起
- 很显然,这个合并工作也已经在形成ELF的时候,合并方式已经确定了,具体合并原则被记录在了ELF的程序头表(Program header table) 中
为什么要将section合并成为segment?
- Section合并的主要原因是为了减少页面碎片,提⾼内存使用效率。如果不进行合并, 假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分 为4097字节,.init部分为512字节,那么它们将占⽤3个页面,合并后,它们只需2个页面。
- 此外,操作系统在加载程序时,会将具有相同属性的section合并成一个大的 segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制
对于程序头表 和 节头表有什么用,其实ELF文件提供2个不同的视图/视角来理解这两个部分:
- 执行视图(execution view) - 对应程序头表 Program header table
- 告诉操作系统,如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中, 一定有 program header table
- 链接视图(Linking view) -对应节头表 Section header table
- 文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解 ELF 文件中包含的各个部分的信息
- 为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整 成可执行的段(segment)、可读写的段、只读段等。合并了后,空间利用率就高了,否 则,很小的很小的⼀段,未来物理内存页浪费太大,链接器趁着链接就把小块们都合并了
- 就是:一个在运行加载时作用,一个在链接时作

从链接视图来看:
- 命令 readelf -S hello.o 可以帮助查看ELF文件的节头表
- .text节 :是保存了程序代码指令的代码节
- .data节 :保存了初始化的全局变量和局部静态变量等数据
- .rodata节 :保存了只读的数据,如一行C语言代码中的字符串。由于.rodata节是只读的,所以只能存在于一个可执行文件的只读段中。因此,只能是在text段(不是data段)中找到.rodata 节
- .BSS节 :为未初始化的全局变量和局部静态变量预留位置
- .symtab节 : Symbol Table符号表,就是源码里面那些函数名、变量名和代码的对应关系
- .got.plt节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改
使用readelf 命令查看.so文件可以看到该节
从 执行视图来看:
- 告诉操作系统哪些模块可以被加载进内存
- 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的
7. 理解连接与加载
7-1 静态链接
- 无论是自己的.o,还是静态库中的.o,本质都是把.o文件进行连接的过程
- 所以:研究静态链接,本质就是研究.o是如何链接的
查看编译后的.o目标文件
bash
$ objdump -d code.o
code.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <run>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # f <run+0xf>
f: e8 00 00 00 00 callq 14 <run+0x14>
14: 90 nop
15: 5d pop %rbp
16: c3 retq
$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # f <main+0xf>
f: e8 00 00 00 00 callq 14 <main+0x14>
14: b8 00 00 00 00 mov $0x0,%eax
19: e8 00 00 00 00 callq 1e <main+0x1e>
1e: b8 00 00 00 00 mov $0x0,%eax
23: 5d pop %rbp
24: c3 retq
能看到几个关键特征:
- 地址都是相对的(0 开头):目标文件里的代码地址是 "虚拟的"(从 0 开始),链接器最终会给它们分配真实的内存地址。
- 所有外部调用都是 0x0 占位 :
code.o中e8 00 00 00 00 callq 14:e8是 call 指令,后面的 4 个 0 表示 "调用的函数地址暂时未知",只是占位。hello.o中同样有e8 00 00 00 00 callq:同理,调用的函数地址还没被解析
- objdump -d命令:将代码段(.text)进行反汇编查看
- hello.o 中的 main 函数不认识 printf和run 函数
- code.o 不认识 printf 函数
可以看到这里的call指令,它们分别对应之前调用的printf和run函数,但是你会发现他们的跳转地 址都被设成了0。那这是为什么呢?
其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的,如他们位于内存的哪个区块,代码什么样都是不知道的。
因此,编译器只能将这两个函数的跳转地址先暂时设为0。这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在一个重定位表,这张表将来在链接的时候,就会根据表⾥记录的地址将其修正
总结:
- 静态链接就是把库中的.o进行合并,和上述过程⼀样
- 所以链接其实就是将编译之后的所有目标文件连用到的一些静态库运行时库组合,拼装成⼀个独立的可执行文化。其中就包括地址修正,当所有模块组合在一起之后,链接器会根据 .o 文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程
- 静态重定位 = 链接器给程序里的函数 / 变量 "分配最终的虚拟地址",并把所有调用处的地址改成这个固定值
7-2 ELF加载与进程地址空间
7.2.1 虚拟地址/逻辑地址问题:
- 一个ELF程序,在没有被加载到内存的时候,有没有地址呢?
- 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来?
答案:
- 一个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机工作的时候,都采用"平坦模式"进行工作。所以也要求ELF对自己的代码和数据进行统⼀编址,下⾯是 objdump -S 反汇编 之后的代码
最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量),但是我们 认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执 行程序进行统一编址了 - 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?从ELF各个segment来,每个segment有自己的起始地址和长度,用来初始化内核结构中的[start,end] 等范围数据,另外在用详细地址,填充页表
7.2.2 重新理解进程虚拟地址空间
ELF 可执行文件在编译完成后,会将程序的入口地址记录在 ELF 头部(ELF header)的 Entry 字段中
cpu如何拿到可执行程序的起始地址? 
- 内核读取入口 :执行程序时,内核从 ELF 文件头中读取
e_entry(如0x1060),并为新进程创建task_struct和mm_struct。 - 构建虚拟空间 :内核根据 ELF 程序头表,将代码段、数据段等加载到虚拟地址空间,每个段对应一个
vm_area_struct,并由mm_struct统一管理。 - 设置指令指针 :内核在进程切换时,将指令指针寄存器(RIP/EIP)设置为
e_entry。 - 地址翻译执行:CPU 从 RIP/EIP 获得虚拟地址,MMU 结合页表完成地址翻译,CPU 从物理内存中取出指令并执行。
一句话总结:内核从 ELF 中读取入口地址,通过 mm_struct 构建虚拟地址空间,再将入口地址写入指令指针寄存器,CPU 便从该地址开始执行
7-3 动态链接与动态库加载
7.3.1 进程如何看到动态库
- 库加载 :当进程 A 需要使用
xxx.so时,内核将共享库从磁盘加载到物理内存(只加载一次,供所有进程共享)。 - 虚拟映射 :在进程 A 的
mm_struct中创建一个共享区 的vm_area_struct,并通过页表将该共享区的虚拟地址映射到物理内存中共享库的位置。 - 地址透明:进程 A 通过虚拟地址访问共享库,对其而言,共享库就像自己地址空间的一部分,但实际物理内存只存在一份,实现了代码共享
7.3.2 进程间如何共享库
7.3.3 动态链接
**静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。**随着软件复杂度的提升,操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费硬盘空间。
这个时候,动态链接的优势就体现出来了,可以将需要共享的代码单独提取出来,保存成一个独 立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不用的进程所共享。
动态链接到底是如何工作?? 首先要交代一个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。如我们去运行一个程序,操作系统会⾸先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。 当动态库被加载到内存以后,一旦它的内存地址被确定,就可以去修正动态库中的那些函数跳转地址了。
1)执行可执行程序工作
在C/C++程序中,当程序开始执行时,并不会直接跳转到 main 函数。实际上,程序的入口点是 _start ,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。在 _start 函数中,会执行一系列初始化操作,这些操作包括:
- 设置堆栈:为程序创建一个初始的堆栈环境。
- 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位 置,并清零未初始化的数据段。
- 动态链接 :这是关键的⼀步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的 动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址
- 调用 __libc_start_main :一旦动态链接完成, _start 函数会调用 __libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执行一些额外的初始化工作,如设置信号处理函数、初始化线程库(如果使用了线程)等
- 调用 main 函数 :最后, __libc_start_main 函数会调⽤程序的 main 函数,此时程序的执行控制权才正式交给用户编写的代码
- 处理 main 函数的返回值 :当 main 函数返回时, __libc_start_main 会负责处理这个返回 值,并最终调用 _exit 函数来终止程序
- 动态链接器:◦ 动态链接器(如ld-linux.so)负责在程序运行时加载动态库。 ◦ 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
- 环境变量和配置文件: ◦ Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf及其子配置文件)来指定动态库的搜索路径。 ◦ 这些路径会被动态链接器在加载动态库时搜索。
- 缓存文件: ◦ 为了提高动态库的加载效率,Linux系统会维护一个名为/etc/ld.so.cache的缓存⽂件。◦ 该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件
2)动态库中的相对地址
动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统一编址, 采用相对编址的方案进行编制
bash
# ubuntu下查看任意⼀个库的反汇编
objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less
# Cetnos下查看任意⼀个库的反汇编
$ objdump -S /lib64/libc-2.17.so | less
3)程序,怎么和库具体映射
- 动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的
- 进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中

4)程序,怎么进行库函数调用
- 库已经映射到了当前进程的地址空间中
- 库的虚拟起始地址也已经知道了
- 库中每一个方法的偏移量地址我们也知道
- 所以:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法
- 而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的

5)全局偏移量表GOT(global offset table)
- 程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道
- 然后对加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置 (叫加载地址重定位)
- 代码区在进程中是只读的吗?怎么修改地址?
动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址 。因为.data区域是可读写的,所以可以支持动态进行修改
- 由于代码段只读,不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的 每个动态库都有独立的GOT表,所以进程间不能共享GOT表
- 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找 到GOT表
- 在调用函数的时候会首先查表,然后根据表中的地址来跳转,这些地址在动态库加载的时候会被修改为真正的地址
- 这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前给编译器指定-fPIC参数的原因,PIC=相对编址+GOT
总结:物理内存里只有一份动态库的代码段,每个进程并不会 "重新加载" 这份代码,而是把自己虚拟地址空间里的一段地址,通过页表映射到这份共享的物理内存上;GOT 里的 "指针",指向就是当前进程这段共享物理内存的 "虚拟地址"
6)库间依赖
- 不仅可执行程序调用库
- 库也会调用其他库,库之间是有依赖的
- 库中也有.GOT,和可执行一样!这也就是为什么都是ELF的格式

8. 动静态链接总结
- 静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件
- 静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,会去修正模块间函数的跳转地址,也被叫做编译重定位 (也叫做静态重定位)
- 而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的(被加载到内存时,映射到当前进程虚拟地址空间的 "虚拟地址起始位置",不是编译 / 链接时固定好的,而是程序运行时由操作系统动态分配的),但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT 方式进行调用 (运行重定位,也叫做动态地址重定位)