第一章:什么是库
库是写好的现有的、成熟的、可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。
本质上来说,库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:
- 静态库:.a [Linux]、.lib [Windows]
- 动态库:.so [Linux]、.dll [Windows]
// ubuntu 动静态库
// C
$ ls -l /lib/x86_64-linux-gnu/libc-2.31.so
-rwxr-xr-x 1 root root 2029592 May 1 02:20 /lib/x86_64-linux-gnu/libc-2.31.so
$ ls -l /lib/x86_64-linux-gnu/libc.a
-rw-r--r-- 1 root root 5747594 May 1 02:20 /lib/x86_64-linux-gnu/libc.a
//C++
$ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so -l
lrwxrwxrwx 1 root root 40 Oct 24 2022 /usr/lib/gcc/x86_64-linuxgnu/9/libstdc++.so -> ../../../x86_64-linux-gnu/libstdc++.so.6
$ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a
/usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a
// Centos 动静态库
// C
$ ls /lib64/libc-2.17.so -l
-rwxr-xr-x 1 root root 2156592 Jun 4 23:05 /lib64/libc-2.17.so
[whb@bite-alicloud ~]$ ls /lib64/libc.a -l
-rw-r--r-- 1 root root 5105516 Jun 4 23:05 /lib64/libc.a
// C++
$ ls /lib64/libstdc++.so.6 -l
lrwxrwxrwx 1 root root 19 Sep 18 20:59 /lib64/libstdc++.so.6 ->
libstdc++.so.6.0.19
$ ls /usr/lib/gcc/x86_64-redhat-linux/4.8.2/libstdc++.a -l
-rw-r--r-- 1 root root 2932366 Sep 30 2020 /usr/lib/gcc/x86_64-redhatlinux/4.8.2/libstdc++.a
第二章:静态库
- 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。
- 一个可执行程序可能用到许多的库,这些库运行时有的是静态库,有的是动态库,而我们的编译默认为动态链接库,只有在该库下找不到同名的动态
.so时才会采用静态库。我们也可以使用 gcc 的-static强制设置链接静态库。
2-1 静态库生成
mymath.h
#pragma once
#include <stdio.h>
extern int myerrno;
int add(int x, int y);
int sub(int x, int y);
int mul(int x, int y);
int div(int x, int y);
mymath.c
#include "mymath.h"
int myerrno = 0;
int add(int x, int y) { return x + y; }
int sub(int x, int y) { return x - y; }
int mul(int x, int y) { return x * y; }
int div(int x, int y) {
if (y == 0) {
myerrno = 1;
return -1;
}
return x / y;
}
Makefile
lib=libmymath.a
$(lib):mymath.o
ar -rc $@ $^
mymath.o:mymath.c
gcc -c $^
.PHONY:clean
clean:
rm -rf *.o *.a lib
.PHONY:output
output:
mkdir -p lib/include
mkdir -p lib/mymathlib
cp *.h lib/include
cp *.a lib/mymathlib
生成静态库
- ar 是 gnu 归档工具, rc 表示 (replace and create)
- t: 列出静态库中的文件
- v:verbose 详细信息
2-2 静态库使用
// 任意目录下,新建
// main.c,引入库头文件
#include "mymath.h"
int main() {
//printf("1+1=%d\n", add(1,1));
//printf("10/0=%d, errno=%d\n", div(10, 0), myerrno);
//上面这种会导致myerrno错误,因为myerrno的更改在div中
//且传参实例化又是从左向右的。
int n = div(10, 0);
printf("10/0=%d, errno=%d\n", n, myerrno);
return 0;
}
//场景1:头文件和库文件安装到系统路径下
$ gcc main.c -lmymath
//场景2:头文件和库文件与我们自己的源文件在同一路径下
$ gcc main.c -L. -lmymath
//场景3:头文件和库文件有自己的独立路径
$ gcc main.c -I 头⽂件路径 -L 库⽂件路径 -lmymath
场景1:头文件和库文件安装到系统路径下
不用-I -L指定路径的方式1,安装到系统路径下
不用-I -L指定路径的方式2,建立软链接
头文件路径添加软链接

给库文件添加软链接


场景3:头文件和库文件有自己的独立路径
使用make 和 make output生成带独立路径的静态库

创建test目录,将带独立路径的静态库移动到test目录下



编译,发现报错。

因为gcc只会到默认路径或当前目录下(即mian.c所在目录)查找头文件
![]()
-I选项 指定头文件路径,但链接报错,找不到add的实现

直接编译,可以生成main.o,所以肯定是链接报错

gcc编译时,只会去默认库路径下找库文件

-L选项 指定库路径,依然找不到。

该路径下可能有多个库文件,所以还需要显示指定链接哪个库。头文件不需要这么做,因为在main.c中已经指定了头文件。

完整指令

- -L : 指定库文件搜索路径
- -I : 指定头文件搜索路径
- -l : 指定库名
- 测试目标文件生成后,如果使用的是 静态库,即使把静态库文件删除,程序也能正常运行(因为库代码已经被拷贝进可执行文件)。
- 关于 -static 选项,稍后介绍。
- 库文件名称与引入库的名称规则:去掉前缀 lib,去掉后缀 .so / .a,例如: libc.so → -lc
第三章:动态库
- 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
- 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。
- 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为 动态链接(dynamic linking)。
- 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制,允许物理内存中的一份动态库被要用到该库的所有进程共用,从而节省了内存和磁盘空间。
3-1 动态库生成
dy-lib=libmymethod.so
static-lib=libmymath.a
.PHONY:all
all:$(dy-lib) $(static-lib)
$(static-lib):mymath.o
ar -rc $@ $^
mymath.o:mymath.c
gcc -c $^
$(dy-lib):mylog.o myprint.o
gcc -shared -o $@ $^
mylog.o:mylog.c
gcc -fPIC -c $^
myprint.o:myprint.c
gcc -fPIC -c $^
.PHONY:clean
clean:
rm -rf *.o *.a *.so mylib
.PHONY:output
output:
mkdir -p mylib/include
mkdir -p mylib/lib
cp *.h mylib/include
cp *.a mylib/lib
cp *.so mylib/lib
mylog.h
#pragma once
#include <stdio.h>
void Log(const char*);
mylog.c
#include "mylog.h"
void Log(const char* info) {
printf("Warning:%s/n", info);
}
myprint.h
#pragma once
#include <stdio.h>
void Print();
myprint.c
#include "myprint.h"
void Print() {
printf("hello new world\n");
printf("hello new world\n");
printf("hello new world\n");
printf("hello new world\n");
}
- shared: 表示生成共享库格式
- fPIC:产生位置无关码(position independent code)
- 库名规则:libxxx.so

3-2 动态库使用
// 场景1:头⽂件和库⽂件安装到系统路径下
$ gcc main.c -lmystdio
// 场景2:头⽂件和库⽂件和我们⾃⼰的源⽂件在同⼀个路径下
$ gcc main.c -L. -lmymath // 从左到右搜索-L指定的⽬录
// 场景3:头⽂件和库⽂件有⾃⼰的独⽴路径
$ gcc main.c -I头⽂件路径 -L库⽂件路径 -lmymath


3-3-1 问题

3-3-2 解决方案
1. 拷贝 .so 文件到系统共享库路径下,一般指 /usr/lib、/usr/local/lib、/lib64 或者开篇指明的库路径等。
2. 向系统共享库路径下建立同名软连接。

3. 更改环境变量: LD_LIBRARY_PATH。

4. ldconfig 方案:配置 /etc/ld.so.conf.d/ ,执行 ldconfig 更新。

实际情况,我们用的库都是别人的成熟的库,都采用直接安装到系统的方式。
第四章:目标文件
编译和链接这两个步骤,在 Windows 下被我们的 IDE 封装得很完美,我们一般都是一键构建,非常方便。但一旦遇到错误的时候呢,尤其是 链接相关的错误,很多人就束手无策了。在 Linux 下,我们之前也学习过如何通过 gcc 编译器 来完成这一系列操作。

接下来我们深入探讨一下编译和链接的整个过程,来更好地理解动静态库的使用原理。
先来回顾下什么是编译呢?编译的过程其实就是将我们的源代码翻译成CPU能够直接执行的机器指令。
比如:在一个源文件 hello.c 里输出"hello world!",并且调用一个run函数,而这个函数被定义在另一个源文件 code.c 中。这时我们就可以调用 gcc -c 来分别编译这两个源文件。
// 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 ,是一种对二进制代码的封装。
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
## file命令⽤于辨识⽂件类型。
第五章:理解连接与加载
5-1 静态链接
- 无论是自己的 .o,还是静态库中的 .o,本质都是把 .o 文件进行连接的过程。
- 所以:研究静态链接,本质就是研究 .o 是如何链接的。
静态链接就是把库中的 .o 文件进行合并。
所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库、运行时库组合,拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的 .o 文件或者静态库中的重定位表找到那些需要被重定位的函数和全局变量,从而修正它们的地址。这其实就是静态链接的过程。
5-2 ELF加载与进程地址空间
问题:
- 一个ELF程序,在没有被加载到内存的时候,有没有地址呢?
- 进程 mm_struct、vm_area_struct 在进程刚刚创建的时候,初始化数据从哪里来的?
答案:
一个ELF程序,在没有被加载到内存的时候,本来就有地址。当代计算机工作的时候,都采用 “平坦模式” 进行工作。所以也要求ELF对自己的代码和数据进行统一编址。下面是 objdump -S 反汇编之后的代码。

最左侧的就是ELF的虚拟地址,其实严格意义上应该叫做逻辑地址(起始地址+偏移量),但是我们认为起始地址是0。也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行了统一编址。
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的[start, end]等范围数据,另外用详细地址,填充页表。
所以:虚拟地址机制,不光光OS要支持,编译器也要支持。
5-2-2 重新理解进程虚拟地址空间
ELF 在被编译好之后,会把自己未来程序的入口地址记录在 ELF header 的 Entry 字段中。


5-3 动态链接与动态库加载
5-3-1 进程如何看到动态库

5-3-2 进程间如何共享库的

5-3-3 动态链接
5-3-3-1 概要
动态链接其实远比静态链接要常用得多。比如我们查看下 hello 这个可执行程序依赖的动态库,会发现它就用到了一个 C 动态链接库:
$ ldd main.exe
linux-vdso.so.1 => (0x00007ffefd43f000)
libc.so.6 => /lib64/libc.so.6 (0x00007f533380b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f5333bd9000)
# ldd命令⽤于打印程序或者库⽂件所依赖的共享库列表。
这里的 libc.so 是 C 语言的运行时库,里面提供了常用的标准输入输出、文件、字符串处理等等这些功能。
那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧?
静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。
动态链接到底是如何工作的??
首先要交代一个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据、代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。
当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
5-3-3-2 我们的可执行程序被编译器动了手脚
$ ldd /usr/bin/ls
linux-vdso.so.1 (0x00007fffdd85f000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
(0x00007f42c025a000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f42c0068000)
libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0
(0x00007f42bffd7000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f42bffd1000)
/lib64/ld-linux-x86-64.so.2 (0x00007f42c02b6000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0
(0x00007f42bffae000)
$ ldd main.exe
linux-vdso.so.1 (0x00007fff231d6000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f197ec3b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f197ee3e000)
在 C/C++ 程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上,程序的入口点是 _start,这是一个由 C 运行时库(通常是 glibc)或链接器(如 ld)提供的特殊函数。
在 _start 函数中,会执行一系列初始化操作,这些操作包括:
1. 设置堆栈:为程序创建一个初始的堆栈环境。
2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
3. 动态链接:这是关键的一步,_start 函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。
动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。
动态链接器:
◦ 动态链接器(如 ld-linux.so)负责在程序运行时加载动态库。
◦ 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
环境变量和配置文件:
◦ Linux 系统通过环境变量(如 LD_LIBRARY_PATH)和配置文件(如 /etc/ld.so.conf 及其子配置文件)来指定动态库的搜索路径。
◦ 这些路径会被动态链接器在加载动态库时搜索。
缓存文件:
◦ 为了提高动态库的加载效率,Linux 系统会维护一个名为 /etc/ld.so.cache 的缓存文件。
◦ 该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。
4. 调用 __libc_start_main:一旦动态链接完成,_start 函数会调用 __libc_start_main(这是 glibc 提供的一个函数)。__libc_start_main 函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。
5. 调用 main 函数:最后,__libc_start_main 函数会调用程序的 main 函数,此时程序的执行控制权才正式交给用户编写的代码。
6. 处理 main 函数的返回值:当 main 函数返回时,__libc_start_main 会负责处理这个返回值,并最终调用 _exit 函数来终止程序。
上述过程描述了 C/C++ 程序在 main 函数之前执行的一系列操作,但这些操作对于大多数程序员来说是透明的。程序员通常只需要关注 main 函数中的代码,而不需要关心底层的初始化过程。然而,了解这些底层细节有助于更好地理解程序的执行流程和调试问题。
5-3-3-3 动态库中的相对地址
动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统一编址,采用相对编址的方案进行编制的(其实可执行程序也一样,都要遵守平坦模式,只不过 exe 是直接加载的)。
5-3-3-4 我们的程序,怎么和库具体映射起来的
注意:
- 动态库也是一个文件,要访问也是要先加载,要加载也是要被打开的。
- 让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中。
一张图解释清楚

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

5-3-3-6 全局偏移量表 GOT (global offset table)

注意:
- 也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道。
- 然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位)。
- 等等,修改的是代码区?不是说代码区在进程中是只读的吗?能修改吗?
所以:动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表 GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。
因为.data区域是可读写的,所以可以支持动态进行修改

-
由于代码段只读,我们不能直接修改代码段。但有了 GOT 表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到 GOT 表上,就是每个进程的每个动态库都有独立的 GOT 表,所以进程间不能共享 GOT 表。
-
在单个 .so 下,由于 GOT 表与 .text 的相对位置是固定的,我们完全可以利用 CPU 的相对寻址来找到 GOT 表。
-
在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
-
这种方式实现的动态链接就被叫做 PIC 地址无关代码。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定 -fPIC 参数的原因,PIC = 相对编址 + GOT。
动态库调用流程

5-3-3-7 库间依赖(简单说明即可)
注意:
- 不仅仅有可执行程序调用库,
- 库也会调用其他库!!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢??
- 库中也有 GOT,和可执行程序一样!这也就是为什么大家都是 ELF 的格式!

由于 GOT 表中的映射地址会在运行时被修改,我们可以通过 gdb 调试来观察 GOT 表的地址变化。这里我们只需要理解原理即可,有兴趣的同学可以参考:使用 gdb 调试 GOT。
由于动态链接在程序加载时需要对大量函数进行重定位,这一步显然非常耗时。为了进一步降低开销,操作系统还做了一些优化,比如延迟绑定(Lazy Binding),也叫 PLT(过程连接表,Procedure Linkage Table)。与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候。毕竟,大多数动态库中的函数在程序运行期间可能一次都不会被使用到。
思路是:GOT 中的跳转地址默认会指向一段辅助代码(桩代码 / stub)。当我们第一次调用函数时,这段桩代码会负责查询真正函数的跳转地址,并更新 GOT 表。这样在后续再次调用该函数时,就会直接跳转到动态库中真正的函数实现。
作业
1. 关于动态库和静态库以下描述正确的有()
A.gcc test.c -o libmytest.so 命令可以生成一个动态库
B.库文件中可以包含main函数
C.静态库使用gcc命令生成
D.库文件的链接可以使用-L选项指定所在路径,使用-l命令链接指定的库文件
答案:D
- A选项错误,默认情况下,gcc test.c - o libmytest.so 生成的是一个可执行程序,而并非动态库, 若要生成动态库需要使用 --shared 选项进行指定
- B选项错误,库文件是被其他程序引入使用的,因此不能有main函数,否则会与程序中的main函数产生冲突
- C选项错误,静态库使用 ar 指令生成
- D选项正确,gcc的 - L选项用于指定链接库路径, - l选项用于链接指定库文件
2. 下面哪一个不是动态链接库的优点?
A.共享
B.装载速度快
C.开发模式好
D.减少页面交换
答案:B
动态链接链接的是动态库,而动态库中包含了大量的常用的功能接口指令代码
这种链接方式,是用于解决静态库存在的浪费内存和磁盘空间,以及模块更新困难等问题。
动态链接生成可执行程序,可执行程序中会记录自己依赖的库列表以及库中的函数地址信息,等到运行程序的时候,由操作系统将库加载到内存中(多个程序可以共享,不需要加载多份相同实例),然后根据库加载后的地址在对每个程序内部用到的库函数的地址进行偏移计算。
基于这么一种思想,动态链接具有以下优缺点:
- 更加节省内存并减少页面交换;
- 库文件与程序文件独立,只要输出接口不变,更换库文件不会对程序文件造成任何影响,因而极大地提高了可维护性和可扩展性;
- 不同编程语言编写的程序只要按照函数调用约定就可以调用同一个库函数;
- 适用于大规模的软件开发,使开发过程独立、耦合度小,便于不同开发者和开发组织之间进行开发和测试。
- 运行时依赖,否则找不到库文件就会运行失败
- 运行加载速度相较静态库慢一些
- 需要对库版本之间的兼容性做出更多处理
根据以上理解,题目为选择错误选项,因此选择B选项。
3. 关于静态库与动态库的区别,以下说法错误的是()
A.加载动态库的程序运行速度相对较快
B.静态库会被添加为程序的一部分进行使用
C.动态库可用节省内存和磁盘空间
D.静态库重新编译,需要将应用程序重新编译
答案:A
动态库也叫运行时库,是运行时加载的库,将库中数据加载到内存中后,每个使用了动态库的程序都要根据加载的起始位置计算内部函数以及变量地址,因此动态链接动态库加载及运行速度相较静态链接是较为不如的,但是它也有好处,就是多个程序在内存中只需要加载一份动态库就可以共享使用。
静态链接,链接静态库,每个程序将自己在库中用到的指令代码单独写入自己可执行程序中,程序运行时无依赖,加载运行速度快,但是程序运行后有可能会有冗余代码在内存中
根据以上理解分析:
- A错误 加载动态库的程序运行速度相对较慢,因为动态库运行时加载,映射到虚拟地址空间后需要重新根据映射起始地址计算函数 / 变量地址
- B正确
- C正确
- D正确 动态链接的程序一旦库中代码发生改变,重新加载一次动态库即可,但是静态链接代码是写入程序中的,因此库中代码发生改变,必须重新链接生成程序才可以
而题目为选择错误选项,因此选择A选项
3万+

被折叠的 条评论
为什么被折叠?



