Linux的库制作和原理
一、静态库Static Library概念静态库在Linux下以.aArchive为后缀。在编译链接阶段链接器会将静态库中被调用的代码完整拷贝到最终的可执行文件中。因此程序一旦编译完成运行时便不再依赖原库文件。其优点是运行时无需额外依赖、执行速度快缺点是生成的可执行文件体积较大且库更新后需要重新编译所有依赖它的程序。生成与使用示例假设项目目录结构如下project/ ├── include/ │ └── math_func.h ├── src/ │ └── math_func.c └── main.c生成静态库使用gcc -c将源文件编译为目标文件然后使用ar命令将其打包为静态库。gcc -c src/math_func.c -o math_func.o ar rcs libmath.a math_func.o使用静态库编译主程序时需要用到三个关键参数-I指定头文件的搜索路径编译预处理阶段-L指定库文件的搜索路径链接阶段-l指定要链接的库名省略lib前缀和.a后缀gcc main.c -I./include -L. -lmath -o main_static二、动态库Dynamic Library概念动态库也称共享库在 Linux 下以.soShared Object为后缀。在编译阶段它仅将依赖关系记录在可执行文件中并不拷贝代码直到程序运行阶段才由系统的动态链接器将库加载到内存。其优点是多个程序可共享同一份库内存镜像节省磁盘和内存资源且支持热更新缺点是运行时必须确保库文件可用否则程序无法启动。生成与使用示例同样基于上述项目结构生成动态库编译时必须添加-fPIC选项生成位置无关代码Position Independent Code以确保动态库可被加载到任意内存地址随后使用-shared选项生成动态库gcc -fPIC -c src/math_func.c -o math_func.o gcc -shared -o libmath.so math_func.o使用动态库编译主程序的命令与静态库完全一致gcc main.c -I./include -L. -lmath -o main_dynamic动态库运行时找不到库的四种解决方法编译顺利通过但运行./main_dynamic时却报错./main_dynamic: error while loading shared libraries: libmath.so: cannot open shared object file: No such file or directory原因-L参数仅在编译链接阶段告诉编译器去哪里找库但程序运行阶段由系统的动态链接器ld-linux.so负责加载动态库它只会在以下固定路径中搜索环境变量LD_LIBRARY_PATH指定的路径系统缓存/etc/ld.so.cache中记录的路径系统默认路径/lib、/lib64、/usr/lib、/usr/lib64当前目录.不在上述任何路径中因此运行时报错。下面介绍四种解决方法。方法一配置/etc/ld.so.conf.d/永久生效生产环境推荐这是最规范的方式。通过向系统库配置目录中添加自定义配置文件让动态链接器永久识别我们的库路径且不污染系统目录。# 1. 在 /etc/ld.so.conf.d/ 下创建配置文件文件名自定义后缀必须为 .conf sudo vim /etc/ld.so.conf.d/mylib.conf # 2. 在文件中写入动态库所在的绝对路径例如 # /home/user/myproject/lib # 3. 刷新系统库缓存必须执行否则配置不生效 sudo ldconfig # 4. 验证系统是否已识别该库 ldconfig -p | grep libmath # 输出示例libmath.so (libc6,x86-64) /home/user/myproject/lib/libmath.so # 5. 运行程序成功 ./main_dynamic优点永久生效、不污染系统目录、支持多版本库管理。缺点需要 root 权限。方法二拷贝动态库至系统默认路径永久生效直接将动态库文件复制到系统默认的库搜索路径下如/usr/lib或/lib64简单粗暴。# 将动态库拷贝到系统库目录需要 root 权限 sudo cp libmath.so /usr/lib/ # 刷新系统库缓存可选但建议执行 sudo ldconfig # 运行程序成功 ./main_dynamic优点操作简单永久生效。缺点会污染系统目录库更新后需要重新拷贝不便于管理多个版本的库。方法三建立软链接到系统目录永久生效与拷贝类似但不复制文件本身而是在系统库目录下创建一个指向原始库文件的符号链接软链接节省磁盘空间且方便更新。# 在系统库目录下创建软链接必须使用绝对路径 sudo ln -s /home/user/myproject/lib/libmath.so /usr/lib/libmath.so # 刷新系统库缓存 sudo ldconfig # 运行程序成功 ./main_dynamic优点不占用额外磁盘空间源文件更新后软链接自动生效。缺点需要 root 权限如果源文件被移动或删除软链接会失效变成断链。方法四设置LD_LIBRARY_PATH环境变量临时生效开发调试推荐LD_LIBRARY_PATH是动态链接器在搜索系统默认路径之前优先检查的环境变量。设置后仅在当前终端会话中生效关闭终端即失效适合开发阶段的快速测试。# 临时生效仅当前终端 export LD_LIBRARY_PATH/home/user/myproject/lib:$LD_LIBRARY_PATH ./main_dynamic # 成功运行 # 若希望永久生效当前用户可写入 ~/.bashrc echo export LD_LIBRARY_PATH/home/user/myproject/lib:\$LD_LIBRARY_PATH ~/.bashrc source ~/.bashrc # 刷新配置使其立即生效优点无需 root 权限设置简单适合临时调试。缺点export方式仅对当前终端有效写入.bashrc虽可持久化但会影响该用户的所有程序可能造成意外加载。三、ELF文件在Linux环境下开发C/C程序时我们经常会遇到.o目标文件、a.out可执行文件、.so共享库等文件格式。它们看似不同但实际上都遵循同一种底层格式标准——ELFExecutable and Linkable Format。1. ELF文件的四种类型根据用途不同ELF文件可以分为四类文件类型文件后缀/示例作用可重定位文件.o包含代码和数据用于和其他目标文件链接生成可执行文件或共享库可执行文件a.out包含可直接加载执行的程序共享目标文件.so动态链接库可在程序运行时被加载核心转储文件core dump存放进程崩溃时的内存快照用于事后调试2. ELF文件的核心结构------------------------ | ELF Header | - 文件头描述文件整体信息 ------------------------ | Program Header Table | - 段表执行视图加载时使用 | (可选) | ------------------------ | | | Sections / Segments | - 实际数据区 | | ------------------------ | Section Header Table | - 节表链接视图链接时使用 ------------------------2.1 ELF头ELF HeaderELF头位于文件最开头描述了文件的整体信息包括文件类型、目标架构、入口点地址、程序头表和节头表的位置和大小等。通过readelf -h命令可以查看$ readelf -h hello.o ELF Header: Magic: 7f 45 4c 46 ... # ELF魔数 Class: ELF64 # 64位架构 Data: 2s complement, little endian Type: REL (Relocatable file) # 可重定位文件 Machine: Advanced Micro Devices X86-64 Entry point address: 0x0 # 目标文件无入口 Start of program headers: 0 # 程序头表无 Start of section headers: 728 # 节头表728字节处 Number of section headers: 13而对于可执行文件$ readelf -h a.out Type: DYN (Shared object file) # 位置无关可执行文件 Entry point: 0x1060 # 程序入口虚拟地址 Start of program headers: 64 # 程序头表存在 Number of program headers: 13核心要点ELF头最重要的作用是定位文件的其他部分它是解析整个文件的入口。2.2 节头表Section Header Table—— 链接视图节Section是ELF文件的基本组成单位每个节包含特定类型的数据常见节名描述.text代码节保存程序机器指令.data数据节保存已初始化的全局变量和静态变量.rodata只读数据节保存常量字符串等.bss未初始化数据节程序加载时由系统置零.symtab符号表记录函数名、变量名与地址的映射查看目标文件的节信息$ readelf -S hello.o2.3 程序头表Program Header Table—— 执行视图程序头表也叫段表告诉操作系统如何将文件内容加载到内存中。每个条目描述了一个段Segment的类型、偏移、虚拟地址、文件大小、内存大小和访问权限。查看可执行文件的程序头$ readelf -l a.out Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x000040 0x400040 0x400040 0x1f8 0x1f8 RE 8 INTERP 0x000238 0x400238 0x400238 0x1c 0x1c R 1 LOAD 0x000000 0x400000 0x400000 0x... 0x... R E 0x1000 LOAD 0x000e10 0x600e10 0x600e10 0x... 0x... RW 0x1000 DYNAMIC 0x000e28 0x600e28 0x600e28 0x1d0 0x1d0 RW 8 ...可以看到两个关键的LOAD段第一个R E读执行包含.text等代码段第二个RW读写包含.data、.bss等数据段3. Section 合并为 Segment3.1 链接时的合并多个.o文件链接时链接器会将相同属性的Section合并到一起code1.o code2.o code3.o ------ ------ ------ ------------------ | .text | | .text | | .text | | .text | ------ ------ ------ ------------------ | .data | | .data | | .data | | .data | ------ ------ ------ ------------------ | ... | | ... | | ... | | ... | ------ ------ ------ ------------------3.2 合并原因减少内存碎片提高效率操作系统管理内存的基本单位是页面Page通常是4KB。假设不进行合并.text大小为512字节 → 占用1页4KB浪费3.5KB.init大小为512字节 → 又占用1页浪费3.5KB总共浪费7KB如果合并后为1024字节 → 仅占用1页节省了1页内存本质将多个小Section合并成大的Segment使得内存分配更紧凑减少页内碎片提高内存使用效率。3.3 权限控制合并时具有相同访问权限如可读可执行的Section会被合并到同一个Segment。这样在加载时OS只需为这个Segment统一设置权限而不是逐节设置简化了内存管理。Section权限合并后的Segment.textRXLOAD (RX).rodataRLOAD (RX) 或 LOAD (R).dataRWLOAD (RW).bssRWLOAD (RW)4. 链接视图 vs 执行视图ELF文件提供了两个视角视图对应表使用者用途链接视图Section Header Table链接器ld按功能模块划分进行符号解析和重定位执行视图Program Header Table加载器Loader告诉OS如何将文件段加载到内存设置权限------------------ | ELF Header | ------------------ | Program Header | - 执行视图Loader使用 | Table | ------------------ | | | Segments | | | ------------------ | Section Header | - 链接视图Linker使用 | Table | ------------------四、理解连接和加载1. 链接的“胶水”重定位与符号解析假设我们有两个文件main.c和utils.c。// utils.c int hidden 42; int add(int a, int b) { return a b; } // main.c extern int hidden; int add(int, int); int main() { int sum add(1, 2); return hidden sum; }当我们分别编译它们gcc -c后得到了main.o和utils.o。此时main.o中并不知道add函数和hidden变量存在于何处。链接器ld的工作就是收集所有.o文件进行符号解析找到每个符号的定义然后进行重定位把代码中的占位符替换为真实的内存地址。在静态链接中链接器会把所有.o中的代码段 (.text)和数据段 (.data)合并起来形成一个大文件。这一步不仅仅是机械地拼接链接器还会计算出每个符号在合并后的新文件中的最终运行时虚拟地址比如在 Linux 下通常从0x400000开始。2. 为什么反汇编 .o 时call 地址是 0这是理解链接过程的关键一步。让我们反汇编main.o$ objdump -d main.o 0000000000000000 main: 0: f3 0f 1e fa endbr64 4: 48 83 ec 08 sub $0x8,%rsp 8: be 02 00 00 00 mov $0x2,%esi d: bf 01 00 00 00 mov $0x1,%edi 12: e8 00 00 00 00 callq 17 main0x17 ; 这里的地址是 0 17: 89 c2 mov %eax,%edx 19: 8b 05 00 00 00 00 mov 0x0(%rip),%eax ; 这里的地址也是 0 1f: 01 d0 add %edx,%eax 21: 48 83 c4 08 add $0x8,%rsp 25: c3 retq你会发现callq 17后面的机器码是e8 00 00 00 00。e8是call的操作码后面的00 00 00 00是偏移量占位符。mov 0x0(%rip), %eax后面的偏移也是00 00 00 00。原因在于编译阶段编译器并不知道add函数在最终内存中的绝对地址也不知道hidden变量相对于当前指令RIP的距离。因为此时还未进行合并和地址分配所以编译器暂时用 0 填充并在.rela.text段中记录一条重定位条目告诉链接器“这里需要修正请把正确的地址填进来”。3. 地址是如何被修正的当我们进行静态链接后gcc main.o utils.o -o prog再反汇编可执行文件prog$ objdump -d prog 0000000000401126 main: 401126: f3 0f 1e fa endbr64 40112a: 48 83 ec 08 sub $0x8,%rsp 40112e: be 02 00 00 00 mov $0x2,%esi 401133: bf 01 00 00 00 mov $0x1,%edi 401138: e8 13 00 00 00 callq 401150 add ; 被修正为 0x13 40113d: 89 c2 mov %eax,%edx 40113f: 8b 05 c3 2e 00 00 mov 0x2ec3(%rip),%eax ; 被修正为 0x2ec3 401145: 01 d0 add %edx,%eax 401147: 48 83 c4 08 add $0x8,%rsp 40114b: c3 retq链接器做了两件关键的事1对于call指令相对跳转call的机器码e8后面跟的是相对偏移。链接器找到了add的最终地址0x401150。call指令本身的地址是0x401138该指令长度为 5 字节。公式目标地址 - (当前地址 指令长度)0x401150 - (0x401138 5)0x401150 - 0x40113D0x13。所以机器码从e8 00 00 00 00变成了e8 13 00 00 00。CPU 在运行到这一行时会用RIP 0x13跳转到add函数。2对于mov hiddenRIP 相对寻址hidden是一个全局变量存放在.data段。链接器计算得出hidden的绝对地址在0x404010。当前指令mov的地址是0x40113f指令长度 6 字节所以RIP在运行时指向0x401145。公式目标地址 - 当前RIP0x404010 - 0x4011450x2ECB这里机器码显示为2ec3是因为小端序和其他对齐微调但原理完全一致。4. 总结静态链接的本质静态链接的过程本质上就是将分散在多个.o文件中的零散逻辑通过符号表映射聚合成一个统一的线性地址空间。在.o中看到 0是因为此时处于未分配地址状态编译器只负责生成骨架把寻址工作留给了链接器通过重定位表。在可执行文件中看到具体值是链接器完成了地址绑定将所有符号引用替换为最终内存布局中的实际地址绝对地址或基于 RIP 的偏移五、理解链接与加载1. ELF未加载时就有地址很多人直觉上认为程序只有被加载到内存后才有地址。但事实恰恰相反——ELF文件在编译阶段就已经完成了统一编址。我们用objdump -S反汇编一个程序最左侧那列就是ELF的虚拟地址严格说是逻辑地址起始地址偏移量但我们视起始地址为00000000000001149 run: 1149: f3 0f 1e fa endbr64 114d: 55 push %rbp 114e: 48 89 e5 mov %rsp,%rbp 1151: 48 8d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi # 2004 _IO_stdin_used0x4 1158: e8 f3 fe ff ff callq 1050 putsplt ... 0000000000001163 main: 1163: f3 0f 1e fa endbr64 1164: 55 push %rbp 1165: 48 89 e5 mov %rsp,%rbp 1168: 48 8d 3d a0 0e 00 00 lea 0xea0(%rip),%rdi # 200f _IO_stdin_used0xf 116f: e8 e2 fe ff ff callq 1056 putsplt 1174: b8 00 00 00 00 mov $0x0,%eax 1179: e8 cb ff ff ff callq 1149 run ...这些地址在编译链接阶段就已经确定下来了。当代计算机采用平坦模式工作要求ELF对自己的代码和数据进行统一编址这为后续的加载执行奠定了基础。2. mm_struct 和 vm_area_struct 初始化数据从哪来当进程刚刚创建内核需要为它初始化内存描述符mm_struct和虚存区域vm_area_struct。这些数据不是凭空产生的而是从ELF文件的各个Segment中解析出来的ELF Segment→mm_struct / vm_area_struct每个Segment的起始地址→vm_area_struct-vm_start每个Segment的长度→vm_area_struct-vm_end段的权限读/写/执行→vm_area_struct-vm_flagsEntry point地址→mm_struct-start_code等此外这些详细地址还会用来填充页表建立虚拟地址到物理地址的映射关系。3. ELF Header 中的入口点编译完成后程序的入口地址会被记录在ELF Header的Entry point address字段中$ readelf -h a.out ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2s complement, little endian Version: 1 (current) OS/ABI: UNIX - System V Type: DYN (Shared object file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x1060 ← 入口点 Start of program headers: 64 (bytes into file) Start of section headers: 14768 (bytes into file) Number of program headers: 13 Number of section headers: 31这个入口点地址就是进程启动后CPU第一条指令所在的虚拟地址。CPU怎么知道你的可执行程序的起始地址EIP (指令指针寄存器)存放了下一条要执行的指令地址。当进程启动时操作系统会将 ELF 头中的入口点地址0x1060写入 EIP。4. 一张图看懂ELF加载与进程地址空间┌─────────────────────────────────────────────────────────────────────┐ │ ELF 文件 (磁盘) │ ├─────────────────────────────────────────────────────────────────────┤ │ ELF Header │ Program Headers │ Segments (代码/数据/RODATA) │ │ │ (Segment信息) │ 已包含虚拟地址 │ │ Entry: 0x1060│ 起始地址、长度、权限 │ │ └───────────────────────────┬─────────────────────────────────────────┘ │ 加载时解析 ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ 内核加载器 │ │ 1. 读取ELF Header获取Entry point │ │ 2. 遍历Program Headers提取每个Segment的起始地址、长度、权限 │ │ 3. 分配物理页框建立页表映射 │ │ 4. 初始化mm_struct / vm_area_struct │ └───────────────────────────┬─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ 进程虚拟地址空间 │ ├─────────────────────────────────────────────────────────────────────┤ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ mm_struct │ │ │ │ ├─ start_code, end_code ← 从ELF的代码段解析 │ │ │ │ ├─ start_data, end_data ← 从ELF的数据段解析 │ │ │ │ ├─ start_brk, brk ← 堆的起始和边界 │ │ │ │ └─ mmap_base ← 内存映射基址 │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ 代码段 │ │ 数据段 │ │ RODATA │ │ 堆 │ │ 栈 │ │ │ │ (RX) │ │ (RW) │ │ (RO) │ │ (RW) │ │ (RW) │ │ │ │vm_start │ │vm_start │ │vm_start │ │ │ │ │ │ │ │ ↓ │ │ ↓ │ │ ↓ │ │ │ │ │ │ │ │vm_end │ │vm_end │ │vm_end │ │ │ │ │ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ ↑ ↑ ↑ │ │ └────────────┴────────────┘ │ │ 每个vm_area_struct的start/end │ │ 都来自ELF的Segment信息 │ └─────────────────────────────────────────────────────────────────────┘5. 从磁盘到内存进程运行的完整流程第一步磁盘上的可执行程序ELF文件存放在磁盘中其中的代码和数据已经包含了虚拟地址编译链接时确定。例如虚拟地址指令内容0x1000f3 0f 1e fa(endbr64)0x100431 ed(xor %ebp,%ebp)0x100649 89 d1(mov %rdx,%r9)......这些地址是逻辑地址并非物理内存的真实位置。第二步加载器解析ELF并初始化进程当执行./a.out时内核的加载器会读取ELF Header获取入口点地址如0x1060遍历Program Headers获取每个Segment的起始虚拟地址、长度和权限创建进程的mm_struct用Segment信息填充各区域代码段、数据段等为每个Segment创建vm_area_struct记录vm_start、vm_end和权限第三步建立页表——虚拟地址到物理地址的桥梁此时虚拟地址已经确定如0x1000但物理内存尚未分配。加载器通过按需分页Demand Paging机制在页表中为虚拟地址0x1000创建条目但暂时不分配物理页框将页表项标记为缺页状态Present位 0当CPU真正执行到该地址时会触发缺页异常Page Fault第四步缺页异常处理——真正加载到物理内存当程序开始执行CPU访问虚拟地址0x1000时MMU查页表发现该页不在物理内存中 → 触发缺页异常内核的缺页处理程序被调用从磁盘的ELF文件中读取对应的页面如从文件偏移量0x1000处读取一页在物理内存中分配一个空闲页框如物理地址0x0000001000000000将磁盘内容拷贝到该物理页框更新页表将虚拟地址0x1000映射到物理地址0x0000001000000000标记页表项为有效Present 1CPU重新执行引起缺页的指令此时访问命中物理内存第五步正常执行——通过页表完成地址转换之后CPU每次访问虚拟地址时MMU自动查询页表将虚拟地址转换为物理地址从物理内存中取指/取数进程正常运行