一、基本概念
1. 静态库(Static Library)
静态库是把多个编译后的目标文件(.o)打包成的单一文件,后缀在 Linux 下为 .a,Windows 下为 .lib。它包含了程序运行所需的全部二进制指令。其特点是在编译时会被完整复制到最终的可执行文件中,所以生成的可执行文件体积较大,但运行时不依赖外部库文件,移植性强。库文件更新后,所有使用该库的程序都需要重新编译链接。
2. 动态库(Dynamic Library)
动态库是编译好的共享二进制文件,Linux 下后缀为 .so,Windows 下为 .dll。它不会被复制到可执行文件中,而是在程序运行时才加载。它在编译链接时仅记录 “依赖关系”,不复制代码,所以其可执行文件体积小。多个程序可共享同一份动态库文件,能够节省磁盘和内存空间。库文件更新后,无需重新编译程序,直接替换库文件即可生效。
3. 静态链接(Static Linking)
链接器将程序的目标文件与静态库中的代码完整合并,生成独立可执行文件的过程。链接器把 .o 和静态库 .a 中用到的代码全部拷贝到可执行文件,解决所有函数 / 变量的引用关系。静态链接的可执行文件独立运行,无需依赖任何外部库。
4. 动态链接(Dynamic Linking)
链接器仅记录程序对动态库的依赖关系,不复制库代码,程序运行时由操作系统加载动态库并完成链接的过程。链接器仅在可执行文件中标记 “需要加载哪些动态库”,不拷贝库代码;操作系统的动态链接器(如 Linux 下的 ld-linux.so)加载对应的 .so 文件,解析函数 / 变量地址,让程序正常运行。c/c++使用gcc、g++编译时,默认优先使用的是动态链接
使用ldd命令会得到库依赖解析结果,如下图:
/lib64/libc.so.6 本身通常是一个软链接,它指向真正的 C 标准库文件
库名提取规则:lib 前缀 + 库名 + .so(或版本号后缀)。所以除去lib和.so之后的内容,剩下的就是库名称,图中的就是c标准库。
我们想要把所写的c/c++源代码变为可执行程序时,可以直接将源代码变为可执行程序,也可以先将源代码各自编译成.o文件,然后再和库链接合为一个可执行程序。最佳实践就是把不同功能拆成多个 .c 文件,各自编译成 .o,最后统一链接。这样修改一个模块时,只需要重新编译那个 .c,不用编译整个项目,能够大大提升效率。
库的作用:把常用的功能提前编译成库文件,不用每次都重新编译源码,方便复用和维护。
二、静态库的制作与使用
要将多个编译好的目标文件打包成静态库,我们可以使用 ar 归档工具执行如下命令:
这条命令会将当前目录下所有 .o 文件打包为名为 libmystdio.a 的静态库。其中 -rc 参数的含义是 “创建并替换”。如果 libmystdio.a 不存在则新建,若已存在则用新的目标文件覆盖更新库中的同名模块。最终生成的 libmystdio.a 就是一个可被链接器直接使用的静态库,它本质上是多个 .o 文件的归档集合,为后续链接生成可执行程序做好准备。
在完成静态库的打包后,我们就可以在项目中使用它来生成可执行程序了。以libmystdio.a为例,假设我们已经将主程序编译为main.o,此时可以通过gcc命令完成链接:
这条命令的核心逻辑是:-o target 指定最终可执行文件名为 target,main.o 是包含程序入口的目标文件,-lmystdio 告诉编译器要链接名为 mystdio 的静态库(编译器会自动补全为 libmystdio.a),而 -L./ 则明确告知链接器在当前目录下查找库文件,避免它只去系统默认路径搜索。执行成功后,静态库中的代码会被完整复制到 target 可执行文件中,生成一个不依赖外部库文件、可直接独立运行的程序,这正是静态链接 “一次编译、随处运行” 的优势体现。
为了避免手动重复执行编译、打包命令,我们可以编写一份 Makefile 来自动化静态库的构建流程
libmystdio.a: mystudio.o mystring.o ar -rc $@ $^ %.o: %.c gcc -c $< .PHONY: clean clean: rm -f *.o *.alibmystdio.a 依赖于 mystdio.o 和 mystring.o。$@ 代表当前目标(即 libmystdio.a),$^ 代表所有依赖文件(即两个 .o 文件),$< 代表第一个依赖文件(即对应的 .c 源文件)
作为库的制作者,我们应该给使用者提供什么呢?
1. .a库文件,在链接时提供函数实现 2. .h头文件,相当于库的使用手册
我们可以将对外提供的头文件和静态库文件,按规范分文件夹组织在 mylib/ 目录下:
mylib/include/:存放 mystdio.h、myststring.h 等头文件,作为库的 “使用接口”。
mylib/lib/:存放 libmystdio.a 静态库文件,封装了函数的二进制实现。
这种结构相当于将静态库 “打包发布”,他人使用时主要有两种方式:
1.本地引用:保留 mylib/ 目录结构,编译时通过 -I 指定头文件路径、-L 指定库文件路径,无需修改系统目录。(-I表示除了当前和系统目录,-I所指定的路径下也有头文件需要搜索)
2.系统级安装:将头文件拷贝到 /usr/local/include,库文件拷贝到 /usr/local/lib,之后用户可直接通过 -l 链接库,无需额外指定路径。
三、动态库的制作与使用
要自动化构建动态库(.so),我们可以在 Makefile 中定义如下规则:
libmystdio.so: mystdio.o mystring.o gcc -shared -o $@ $^ %.o: %.c gcc -c -fPIC $< .PHONY: clean clean: rm -f *.o *.solibmystdio.so 是目标动态库,依赖 mystdio.o 和 mystring.o 两个目标文件。
-shared:告诉编译器生成共享动态库,而非可执行程序。$@:代表目标文件 libmystdio.so。$^:代表所有依赖的 .o 文件。这条命令会将多个目标文件链接为一个动态库。
-fPIC:生成位置无关代码(Position-Independent Code),这是动态库的必备选项,确保代码能在进程任意地址空间加载运行。
从制作动态库的过程不用ar,而是用gcc/g++,说明动态库的使用场景更常见,大部分都是用动态库,而静态库使用频率没有这么高所以用一个ar命令来区分。
编译运行动态库时,会发现没办法找到对应的库。但是在使用静态库时却没有这个问题。
是因为在链接阶段,静态库的二进制代码会被完整拷贝到最终的可执行文件中。程序启动时,所有需要的函数实现都已经在自己的二进制文件里了,不需要再去外部找任何库文件。而动态库的代码只在 “运行时才去加载”。在链接阶段,动态库的代码不会被拷贝到可执行文件里,编译器只在程序里记录了 “我需要依赖 libmystdio.so” 这个信息,-L告诉的是编译器要到哪个地方找,系统并不知道。运行阶段的系统动态加载器完全不识别 -L 参数,它只会按自己的规则(系统默认路径、LD_LIBRARY_PATH 等)查找动态库。程序启动后,系统的动态加载器(ld-linux.so) 会去系统指定路径里寻找 libmystdio.so,如果找不到就会报错。图中编译时用 -L./mylib/lib/ 告诉了编译器库在哪里,所以链接成功;但在运行时,动态加载器不知道 ./mylib/lib/ 这个路径,自然找不到libmystdio.so。
那怎么样让系统快速找到这个库呢?
系统有四种确定库路径的方法:
方法一:拷贝 .so 文件到系统共享库路径下,一般指 /usr/lib、/usr/local/lib、/lib64 或者开篇指明的库路径等
方法二:向系统共享库路径下建立同名软连接
方法三:更改环境变量:LD_LIBRARY_PATH。通过 export LD_LIBRARY_PATH=自定义库路径:$LD_LIBRARY_PATH,临时告诉动态加载器额外的库搜索路径。该方法仅在当前终端会话生效,适合本地测试或临时使用,不会影响系统全局配置。
LD_LIBRARY_PATH 是 Linux 系统中专门给动态加载器用的环境变量。作用是告诉动态加载器,在查找动态库时,优先去这个变量指定的路径里找,再去系统默认路径找。
方法四:ldconfig 方案:vim配置 /etc/ld.so.conf.d/,ldconfig 更新。在 /etc/ld.so.conf.d/ 目录下新建配置文件,写入自定义库路径,然后执行 sudo ldconfig 更新系统库缓存。这种方式能永久生效,且不会污染系统默认路径。
ldconfig 是 Linux 系统中管理动态库缓存的核心命令,作用是更新系统动态库的缓存文件,让动态加载器(ld-linux.so)能快速找到新增 / 修改的动态库,无需重启系统或修改全局配置。
只有动态库,才需要在运行时搜索!
当同时存在同名的动静态库,系统默认使用的是动态库。如果想要使用静态库,要加上-static选项。-static选项是强制要求我们所有的库,必须要有对应的静态库版本,并且全部静态链接到我们的可执行程序。当我们只提供静态库时,即便默认采用动态链接,对于该库来讲,也只能静态链接;当我们只提供动态库时,只能使用动态链接。
四、目标文件
目标文件是编译完成后生成的.o文件(Windows 下为.obj),全称是可重定位目标文件(Relocatable Object File)。它是一个二进制文件,在 Linux/Unix 系统中遵循 ELF格式,本质是对源代码编译后机器码的封装,包含了代码段、数据段、符号表等关键信息,是链接器生成最终可执行程序的原材料。
五、ELF文件
我们所说的可执行程序都是二进制文件,但它们并不是随意堆放在一起的二进制,是有自己的结构和格式的。这个格式称为ELF格式(Executable and Linkable Format)。可执行程序、.o目标文件、动静态库都是ELF格式。
如果要深入了解编译链接的过程,就必须要了解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 文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。
六、ELF文件从形成到加载轮廓
.o文件可以形成可执行程序和.so库文件,是因为他们都可执行,他们都是ELF格式。
一个ELF格式包含以下几个部分:
1.ELF Header(ELF 头部):文件的 “身份证”,记录了 ELF 版本、目标架构、文件类型(可重定位 / 可执行 / 共享库)、段表 / 节表的偏移等关键元信息。
2.Program Header Table(程序头表):目标文件中通常是可选的,它描述了段如何被加载到内存,可执行文件中则是必需的。
3.Sections(节区):核心数据区域,比如:
.text:存放机器指令(代码段)
.data:存放已初始化的全局变量和静态变量
其他自定义节区(如 .bss、.rodata、.symtab 等)
4.Section Header Table(节头表):记录每个节区的位置、大小、权限等信息,是链接器和调试器的 “导航图”。
所有目标文件的 .text 节区会被合并为可执行文件的 .text 新 节区,将分散的代码片段整合为连续的代码段。所有目标文件的 .data 节区会被合并为 .data 新 节区,将分散的已初始化数据整合为连续的数据段。其他节区(如 .bss、.symtab 等)也会遵循同样的合并规则,最终形成可执行文件的完整节区布局。
合并后,链接器会为合并后的 .text、.data 等节区分配虚拟内存地址。修正所有符号引用的地址,将相对偏移替换为最终的虚拟地址,并更新 ELF Header 和 Section Header Table,使其指向新的节区位置和大小。这个过程也被称为地址重定位。
这个图告诉我们,ELF 链接的本质,就是将多个分散的可重定位目标文件,通过同类节区合并 + 地址重定位,整合成一个具备完整虚拟内存布局、可被操作系统直接加载执行的 ELF 可执行文件。
七、理解链接与加载
1静态链接
静态链接的本质是把库中的所有代码,拷贝到你的程序中。所以静态链接所形成的可执行程序会比较大。静态链接的核心过程分为两个阶段。分别为代码段合并与编址和地址重定位。代码段合并与编址将多个目标文件的代码段整合到同一个可执行文件中,并为整合后的代码分配统一的内存地址空间,完成整体编址。地址重定位在链接阶段修正目标文件中未确定的函数的地址(如之前的地址是call 0000 0000),在代码段合并完成后,进行call地址,完成代码调用。
简而言之,静态链接就是把所有编译后的目标文件和依赖库拼装成一个独立的可执行文件,完成代码整合与地址修正的过程。静态链接时,我们要加载运行程序之前所要调用的函数的地址就已经确定了。
静态链接没有加载过程,因为程序已经把所有我们要调用到的函数已经全部拷贝进来了。静态链接的链接过程,在编译器进行链接的时候,在程序加载之前,就已经完成了。
2.动态链接与加载
动态库的加载与进程有关,而且需要被映射到进程的虚拟地址空间的共享区。首先在动态库链接时需要在共享区开辟一个空间,然后将动态库的物理地址空间与虚拟地址空间填充到页表中建立映射联系。而不止一个进程可以这样使用动态库,所有需要使用动态库的进程都可以以这种方式与动态库建立映射关系。动态库在内存中只需要一份,这也就是为什么动态库也被称为共享库的原因。
动态链接的程序,加载的时候是需要先加载所对应的动态库,然后才加载我们的程序。要加载对应的库,第一件事就是要找到对应的库。而库又是磁盘文件,所以要找到这个磁盘文件就必须找到库的路径。而找到库的路径,就需要用到前面讲到过的系统找到库的路径的四种方法。
那系统怎么知道某个程序所需要的库是否被加载到内存中了呢?
在不同进程的运行过程中,肯定会需要不同的库的资源,所以操作系统就需要对这些动态库进行管理。也就是说操作系统需要创建struct结构体来管理这些已经被加载到内存中的库。
动态链接的过程实际上将链接的整个过程推迟到了程序加载的时候。
动态库是没有main函数的,动态库内部包含了大量的方法,每一个方法都要有自己的地址。通过基地址加上对应方法的逻辑偏移地址,我们就能找到需要的某个库方法。动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统一编译,采用相对地址的方案进行编制的(其实可执行程序也一样,都要遵守平坦模式,只不过 exe 是链接加载的链接)。
在加载动态库时,首先需要找到库对应的路径,通过struct dentry找到对应库的目录。然后再在目录下保存的inode信息中找到对应的库在磁盘中的数据块,然后将这个数据块加载到内存中,得到库在内存中的物理地址。而库在加载到程序时,操作系统会给库在共享区分配一个虚拟地址空间和对应的虚拟地址。有了虚拟地址和物理地址后,将这两个地址填充进页表中,建立物理地址和虚拟地址的映射关系。当我们的程序需要访问库中的某个方法时,就会根据起始虚拟地址,再根据库中方法的偏移量,找到这个特定的方法并跳转使用。同理,物理地址也是如此,我们可以通过起始物理地址,再根据库中方法的偏移量,找到对应方法的代码和数据。通过上面这个过程,我们就可以得到到库中任意一个方法的虚拟地址和物理地址,并且可以访问库中的任意一个方法了!这个过程我们就称为地址动态链接的过程。
程序再运行期间才和库动态关联的过程我们就称之为动态链接的过程!
程序运行时有三个部分:1.内核结构 2.动态库 3.代码和数据。这三个部分的的加载顺序是,首先需要加载内核结构,然后加载动态库,最后再加载代码和数据。在加载代码和数据的过程中,可以边加载代码,边完成地址重定位。
也就是说静态链接和动态链接的区别就在于,静态链接是在链接时就将所有地址重定位了,而动态链接是在程序加载过程中动态进行地址的重定位。
在动态进行地址重定位的过程,是要需修改代码区的数据的,但是代码区不是只读的吗?怎么能被修改呢?
所以:动态链接采用的做法是在 .data(可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表 GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。
因为.data区域是可读写的,所以可以支持动态进行修改。
由于代码段只读,我们不能直接修改代码段。但有了 GOT 表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到 GOT 表上,就是每个进程的每个动态库都有独立的 GOT 表,所以进程间不能共享 GOT 表。
在单个.so 下,由于 GOT 表与 .text 的相对位置是固定的,我们完全可以利用 CPU 的相对寻址来找到 GOT 表。在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
这种方式实现的动态链接就被叫做 PIC(地址无关代码)。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定 -fPIC 参数的原因,PIC = 相对编址 + GOT。
由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,我们的操作系统还做了一些其他的优化,比如延迟绑定,或者也叫 PLT(过程连接表(Procedure Linkage Table))。与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。
八、相关查看操作
用 readelf -h 查看 ELF Header:
typedef struct { unsigned char e_ident[16]; // 魔数+标识信息 uint16_t e_type; // 文件类型(可执行/目标/共享库) uint16_t e_machine; // 目标架构(x86-64/ARM等) uint32_t e_version; // ELF版本 uint64_t e_entry; // 程序入口虚拟地址 uint64_t e_phoff; // Program Header Table 的文件偏移 uint64_t e_shoff; // Section Header Table 的文件偏移 uint32_t e_flags; // 处理器相关标志 uint16_t e_ehsize; // ELF Header 自身大小 uint16_t e_phentsize; // Program Header 表项大小 uint16_t e_phnum; // Program Header 表项数量 uint16_t e_shentsize; // Section Header 表项大小 uint16_t e_shnum; // Section Header 表项数量 uint16_t e_shstrndx; // 节名字符串表的索引 } Elf64_Ehdr;用 readelf -l 查看 Program Header Table:
typedef struct { uint32_t p_type; // 段类型(LOAD/DYNAMIC/INTERP等) uint32_t p_flags; // 段权限(读/写/执行,对应RWX) uint64_t p_offset; // 段在文件中的偏移 uint64_t p_vaddr; // 段加载后的虚拟地址 uint64_t p_paddr; // 物理地址(一般不用) uint64_t p_filesz; // 段在文件中的大小 uint64_t p_memsz; // 段在内存中的大小(可能大于文件大小,比如BSS段) uint64_t p_align; // 段对齐要求 } Elf64_Phdr;用 readelf -S 查看 Section Header Table:
typedef struct { uint32_t sh_name; // 节名在字符串表中的偏移 uint32_t sh_type; // 节类型(PROGBITS/SYMTAB/STRTAB等) uint64_t sh_flags; // 节属性(写/分配/执行等) uint64_t sh_addr; // 节加载后的虚拟地址(未分配则为0) uint64_t sh_offset; // 节在文件中的偏移 uint64_t sh_size; // 节大小 uint32_t sh_link; // 关联的其他节(比如符号表关联字符串表) uint32_t sh_info; // 附加信息(比如重定位表关联的符号表索引) uint64_t sh_addralign; // 节对齐要求 uint64_t sh_entsize; // 节中每个表项的大小(比如符号表项大小) } Elf64_Shdr;查看具体的 sections 信息:
1.查看代码节 .text 的机器指令
命令:objdump -d ./test 或 readelf -x .text ./test
把 .text 节的十六进制数据反汇编成汇编指令,验证程序的入口点和代码逻辑,比如 main 函数对应的机器码。
2.查看数据节 .data 和只读数据节 .rodata
命令:readelf -x .data ./test / readelf -x .rodata ./test
查看已初始化变量和常量字符串在文件中的存储形式,对比 .bss 节的文件大小为 0 的特点。
3.查看符号表 .symtab 和动态符号表 .dynsym
命令:readelf -s ./test
理解符号表的字段(符号名、值、大小、类型、绑定信息等),区分本地符号和全局符号,以及 .symtab 和 .dynsym 的区别(前者包含调试符号,后者仅包含动态链接需要的符号)。
4.查看字符串表 .strtab 和 .dynstr
命令:readelf -p .strtab ./test / readelf -p .dynstr ./test
查看符号名、节名等字符串的存储形式,理解 sh_name 字段的偏移如何对应到字符串表中的实际字符串。
5.查看重定位节 .rel.dyn / .rel.plt
命令:readelf -r ./test
理解动态链接中如何通过重定位表修正外部函数(比如 printf)的地址,区分 .rel.dyn(数据段重定位)和 .rel.plt(PLT 表重定位)的作用。