【Linux】第6期 动静态库制作与原理

发布时间:2026/6/30 22:27:12

【Linux】第6期 动静态库制作与原理 目录开头一.库的基础认知什么是库二.静态库1.静态库的生成1归档工具ara.基本语法b.高频使用场景与示例I.创建静态库最核心用法II.查看归档内容III.提取归档中的成员VI.删除归档中的成员2makefile的实现2.静态库的使用3. 静态库的特点三.动态库1.动态库的生成1-fpic 和 -shared 选项2makefile的实现2.动态库的使用1拷贝库文件到系统共享库目录2建立软链接到系统路径3配置环境变量 LD_LIBRARY_PATH4ldconfig 配置文件3.动态库的特点四.ELF1. ELF合并2.ELF的具体结构1readelf2第一层ELF 头ELF Header3第二层程序头表Program Header Table4第三层节Section5第四层节头表Section Header Table6两种视图链接视图 vs 执行视图五. 理解连接与加载1.静态连接1编译阶段每个 .o 都是 “半成品”2静态链接如何形成可执行文件a.第一步空间与地址分配b.第二步符号解析Symbol Resolutionc.第三步重定位Relocation2.ELF加载与进程地址空间1逻辑地址与线性地址平坦模式编址2ELF加载的具体过程2.动态库与动态连接1动态库2动态连接a.动态库中的相对地址b.程序和库的具体映射c.库函数调⽤d.全局偏移量表GOT(global offset table)结尾往期回顾开头ok了本人也是成功熬过了痛苦的期末周了也祝愿大家永不挂科今天我们继续Linux的学习这一期要和大家一起学习的是动静态库的制作与原理废话不多说直接开始一.库的基础认知什么是库在 Linux 开发中「库」是代码复用、工程模块化的核心载体我们每天写的 C/C 代码底层都依赖着 libc、libstdc 等系统库所以什么是库库是预先编写好的、成熟可复用的二进制代码集合。比如我们之前使用过的 printf、strlen… 等等库函数都是C语言库提供的函数库的存在极大降低了开发成本提升了代码复用率从本质上说库是一种可执行代码的二进制格式可以被操作系统载入内存执行。根据链接时机和使用方式的不同Linux 下将库分为两类•静态库. a[Linux]、.lib[windows]•动态库(共享库).so[Linux]、.dll[windows]预备代码实现一个自定义迷你库:为了后续讲解库的制作与原理我们先实现一个极简的自定义库包含两部分功能模拟 C 标准 IO 的my_stdio实现带缓冲区的文件读写字符串工具my_string实现my_strlen函数头文件 my_stdio.h源文件 my_stdio.c头文件 my_string.h源文件 my_string.c后续我们将基于这四个文件分别制作静态库和动态库二.静态库静态库的本质是程序在编译链接阶段把库中用到的代码完整复制到可执行文件中程序运行时不再依赖原始静态库文件我们可以把静态库理解成一个「代码压缩包」里面打包了多个.o目标文件。链接静态库的过程本质就是把需要的.o文件拿出来和我们自己的代码合并成最终的可执行程序1.静态库的生成1归档工具arararchive是 GNU Binutils 工具集中的经典归档工具核心用途是创建、修改、提取静态库归档文件通常后缀为 .a也可用于普通文件的打包归档。它会将多个目标文件.o整合为单个归档文件并支持生成符号索引供链接器ld快速寻址是 C/C 静态库开发的核心工具a.基本语法核心选项汇总主操作选项必须指定一个定义执行的核心动作常用修饰选项配合主操作使用增强功能了解b.高频使用场景与示例I.创建静态库最核心用法ar 最经典的组合是 rc创建 替换 也是静态库的标准生成方式• rreplace添加 / 替换目标文件到归档• ccreate静默创建归档不输出冗余提示II.查看归档内容选项 -t 和 -tv 可以进行查看具体示例III.提取归档中的成员VI.删除归档中的成员2makefile的实现因为静态库的实现涉及到多个 .o 文件的生成以及要将所有的 .o 文件通过 ar 打包形成 .a 的静态库一个个手动实现显然效率低下所以最好用 makefile但是我们想一下假如说有人要使用我们实现的静态库一定要有 .h 文件做方法的声明以及我们的 .a 库做方法的实现我们可以一并写入到 makefile 中当然也可以添加生成压缩包的指令方便传输如上图我们执行 make 就生成了 lib 目录里面打包好了 .h 文件和 .a 静态库以及生成了压缩包2.静态库的使用现在我们的身份从静态库的制作者转变为库的使用者ok现在我们就是张三我们现在已经拿到了压缩包我们现在来解压我们现在来写一个用户程序使用这两个头文件现在我们来编译这个文件怎么报错了看看报错内容怎么是找不到头文件啊仔细一想好像是的我们把头文件放在 lib目录中的include子目录中你都没告诉编译器头文件的路径那怎么办使用-I选项指定具体的头文件路径如上图当我们指明具体的头文件路径后就可以正常生成 .o 文件下面我们来链接生成可执行文件怎么又报错了报错内容是我们实现的方法函数怎么都是未定义呢前面报错是未指定头文件的具体路径那这次就是未指定静态库的具体路径以及未指定是那个库使用-L选项指定具体的头文件路径-l指定是哪一个库最终我们成功生成了可执行文件并且执行成功了完全体写法当然我们也可以在 usercode.cpp 包头文件的时候就把路径带上汇总3. 静态库的特点✅ 验证静态库链接完成后即使删除libmystdio.a可执行程序依然可以正常运行因为代码已经被完整复制进去了三.动态库动态库与静态库完全相反程序编译时并不会把库代码复制进去只在可执行文件中记录函数入口地址表程序运行时由操作系统将动态库加载到内存多个进程可以共享同一份库代码动态库的核心价值多个进程共享内存中的同一份库代码极大节省内存可执行文件体积小节省磁盘空间库升级无需重新编译程序替换库文件即可1.动态库的生成Linux 下动态库后缀 .soShared Object的制作分为两个阶段两个关键参数分别对应不同编译阶段必须配合使用才能生成标准可用的动态库•编译阶段给每个源文件添加 -fpic生成位置无关的目标文件.o•链接阶段添加 -shared将多个目标文件打包为最终的动态库文件1-fpic 和 -shared 选项-fpic• 全称Position Independent Code位置无关代码• 作用阶段编译期选项必须配合 -c编译不链接使用作用于单个源文件生成 .o 的过程• 核心目的让生成的机器码不依赖固定的内存绝对地址使动态库可以被加载到内存的任意位置正常运行-shared• 作用阶段链接期选项用于将多个 .o 文件生成最终的 .so 动态库• 核心作用告诉链接器ld当前输出目标是共享目标文件动态库而非可执行程序2makefile的实现2.动态库的使用现在我们的身份从静态库的制作者转变为库的使用者ok现在我们又是张三我们现在已经拿到了压缩包我们现在来解压依旧创建 usercode.cpp然后编译链接怎么回事我可执行文件 usercode 都成功生成了怎么运行不了为啥突然说找不到怎么不认了根本原因是链接阶段和运行阶段的库搜索机制相互独立、互不感知编译链接阶段执行 g 命令时• 执行者GCC 内置的链接器 ld•-L ./lib/mylib 仅在这个阶段生效它告诉链接器 “去这个目录下查找 libmyc.so”完成符号校验• 地址绑定最终生成可执行文件链接成功只代表 “找到了库文件、函数符号能对应上”但可执行文件本身不会保存 -L 指定的路径程序运行阶段执行 ./usercode 时• 执行者系统动态链接器 ld-linux.so• 它负责在程序启动时加载所有依赖的动态库有自己固定的搜索路径优先级完全不知道你编译时写的 -L 路径• 当它在系统默认路径里找不到 libmyc.so就会抛出 cannot open shared object file: No such file or directory 错误一句话总结-L 是 “给编译器看的路径”不是 “给运行系统看的路径”二者不互通我们可以用ldd命令查看可执行程序依赖的动态库我们现在又4种解决方案1拷贝库文件到系统共享库目录•原理把 .so 文件复制到系统默认的动态库搜索目录如 /usr/lib、/lib64动态链接器天然会扫描这些路径•操作命令•特点永久生效但会污染系统原生库目录自定义库过多后难以管理2建立软链接到系统路径•原理与方案 1 效果一致区别是不复制原文件仅创建软链接指向真实库文件库更新时无需重复复制•操作命令•特点比直接复制更灵活方便库版本迭代但依然会占用系统目录易与系统原生库混淆3配置环境变量 LD_LIBRARY_PATH•原理给动态链接器临时新增搜索路径仅对当前终端会话生效•操作命令•特点操作最简单无需 root 权限关闭终端后配置自动失效不会影响系统环境4ldconfig 配置文件•原理在系统动态库配置目录下新增自定义配置文件将你的库目录注册给系统ldconfig 会将路径写入全局缓存动态链接器会按配置自动搜索•操作步骤•特点永久生效不污染系统库文件目录路径集中管理是工业界最规范的部署方式3.动态库的特点当动静态库同时存在时系统优先使用动态库如果要强制使用静态库要加 -static 修饰四.ELFELF 全称 Executable and Linkable Format可执行与可链接格式是 Linux/Unix 系统下二进制程序的标准容器格式它本质上是一套 “二进制数据的组织规范”格式统一规定了代码、数据、符号、调试信息等内容在文件里怎么存放、怎么索引、怎么被系统识别让编译器、链接器、操作系统加载器都能按照同一套规则读写二进制文件ELF 覆盖的四类文件所有符合 ELF 规范的二进制文件按用途分为四类它们共享同一套底层结构1. ELF合并这张图的核心是静态链接阶段多个 .o 可重定位目标文件通过链接器合并段最终生成一个完整 ELF 可执行文件的过程它是 “可执行文件加载” 的前提 —— 只有先把分散的目标文件链接成完整的可执行文件操作系统才能把它加载到内存运行链接前每个 .o 都是独立的 ELF 单元图中上半部分的 code1.o、code2.o、main.o都是 C 源文件编译后生成的可重定位目标文件每个都拥有完整的 ELF 结构•ELF Header文件头标记文件类型为可重定位文件REL记录各类头表的偏移•Program Header Table可选程序头表对 .o 没有实际意义因为目标文件不需要被加载运行因此通常为空或直接省略• 独立的各类Section节◦ .text存放当前文件的函数机器指令◦ .data存放当前文件已初始化的全局变量、静态变量◦ 还有 .bss、.rodata、重定位表、符号表等其他段。此时所有段的地址都是从 0 开始的相对偏移没有最终的虚拟地址•Section Header Table段头表记录当前文件所有段的名称、大小、偏移、属性链接核心动作同名段合并 地址重定位链接器Linux 下为 ld的核心工作就是把所有输入的 .o 文件按规则合并对应图中横向的合并线1按段名 属性合并同类段所有文件中功能、权限相同的同名段会被合并成一个大的全局段• 所有 .text 合并为一个新的 .text 段集中存放全部函数代码• 所有 .data 合并为一个新的 .data段集中存放全部已初始化全局数据• .bss、.rodata、符号表等其他段也遵循同样的合并规则2符号解析 地址重定位合并后为所有段分配统一的虚拟地址修正所有函数、变量的引用地址把原来的相对偏移改成最终的虚拟内存地址3生成程序头表Program Header Table最终的可执行文件必须包含程序头表 —— 它是操作系统加载程序的 “说明书”描述了文件如何映射到内存链接后最终的 ELF 可执行文件对应图中下半部分的 main 的 ELF 格式就是可以直接运行的可执行文件• ELF Header 标记文件类型为可执行文件EXEC包含程序入口地址• 拥有完整的 Program Header Table定义了加载时的内存映射规则• 所有段都是合并后的全局版本拥有确定的虚拟地址• 保留 Section Header Table用于调试、分析但程序运行加载时并不依赖它问题为什么要把多个 Section 合并成一个 Segment这是一个典型的 “空间换效率” 的设计核心原因有两个减少内存页碎片提高内存利用率操作系统的内存是以 ** 页Page通常 4KB** 为单位分配和管理的权限也是按页设置的。如果每个小节都单独映射一页比如 .text 4097 字节、.init 512 字节两者都是只读可执行但分开就要占 3 个物理页合并成一个代码段后只占 2 个物理页。程序里有几十个小节合并后节省的空间非常可观统一内存保护粒度把所有 “只读可执行” 的节.text、.plt、.rodata、.init 等合并成一个代码段一次性设置为 “只读 可执行”把所有 “可读可写” 的节.data、.bss、.got 等合并成一个数据段一次性设置为 “可读可写”。既简化了加载逻辑也符合内存保护的最小粒度2.ELF的具体结构在介绍 ELF结构前我们要先了解下 readelf1readelfreadelf 是 GNU Binutils 工具集 中的专用工具专门用来解析 ELF 格式文件的底层结构信息是分析目标文件.o、动态库.so、可执行程序、静态库的核心工具它的特点是只专注 ELF 结构本身ELF 结构图里的每一部分都可以用 readelf 对应的参数精准查看。2第一层ELF 头ELF Header位于文件最开头的固定位置是整个文件的 “总目录索引”。它的核心使命是告诉读取者文件里其他所有重要表格在什么位置、有多大、有多少项查看命令readelf -h 文件名ELF 头里的关键字段•魔数Magic文件最开头 16 字节固定以 \x7f E L F 开头是系统识别 ELF 文件的标识• 位数Class标识是 32 位还是 64 位 ELF。•文件类型Type标识是上面四种里的哪一种可重定位 / 可执行 / 共享库 /core•入口点地址Entry point address程序加载后第一条要执行的指令的虚拟地址可重定位文件.o没有入口点值为 0• 程序头表偏移e_phoff程序头表在文件里的字节偏移量• 节头表偏移e_shoff节头表在文件里的字节偏移量•表项大小与数量程序头表、节头表每个条目的大小以及总共有多少条3第二层程序头表Program Header Table这是一张 “段描述表”每一项描述一个 Segment段 的信息• Segment 是程序加载时的单位操作系统加载程序时不关心细枝末节的功能分区只关心 “哪块内 容要加载到内存的哪个位置、占多大、有什么权限”• 程序头表只对可执行文件和动态库有意义纯目标文件.o通常没有程序头表查看命令readelf -l 文件名每个 Segment 描述的核心信息•段类型Type最常见的是 LOAD需要加载进内存的段、DYNAMIC动态链接信息段、INTERP指定动态链接器路径•文件偏移Offset这段内容在 ELF 文件里的起始字节位置偏移量•虚拟地址VirtAddr加载到进程虚拟地址空间后的起始地址•文件大小FileSiz这段内容在文件里的字节长度•内存大小MemSiz加载到内存后占用的字节长度.bss 段会比文件里大因为要预留零初始化空间•权限标志Flags读 R / 写 W / 执行 X4第三层节SectionSection节 是 ELF 文件里功能最细的划分单位按 “内容类型” 把二进制数据分门别类存放。它是链接阶段的核心单位——链接器合并目标文件时就是按节来同类合并的最核心的几类节5第四层节头表Section Header Table节头表就是所有节的 “目录表”每一项对应一个节记录节的名称、偏移、大小、属性、对齐等信息。链接器通过节头表快速定位到任意一个节查看命令readelf -S 文件名查看具体节的反汇编objdump -d 文件名只看代码节如上图注意 code.cpp 中 haha() 函数只在 func.h 中进行声明但是未实现Ndx:标记当前这个符号到底定义在 ELF 文件的哪一个节Section里• 数值型代表这个符号有明确的定义实体存放在「段头表中第 N 号节」里• UND:这个符号在当前文件里只有声明没有具体的实现 / 定义查看 code.o 和 func.o 的节头表可以看到依赖 func.h 的两个函数 func() 和 haha()都是 UND 状态但是在 func.o 中 func 是在第 1 节中那么在后续的链接过程中func 就可以成功调用而haha 没有具体的实现就会报错6两种视图链接视图 vs 执行视图链接视图Linking View—— 以节为单位• 服务对象编译器、链接器• 特点粒度细按功能划分。链接器需要知道哪块是代码、哪块是数据、哪• 块是符号、哪块需要重定位才能完成合并、地址修正等精细操作• 对应结构节头表 所有 Section执行视图Execution View—— 以段为单位• 服务对象操作系统加载器• 特点粒度粗按内存权限划分。操作系统加载程序时只需要把相同权限的内容打包映射到内存设置好页权限即可不需要关心内部功能细分• 对应结构程序头表 所有 Segment五. 理解连接与加载1.静态连接静态链接的本质就是把多个分散的 .o 目标文件以及静态库里的 .o“拼接” 成一个完整的、地址确定的可执行文件。整个过程分两大步空间与地址分配 → 符号解析与重定位1编译阶段每个 .o 都是 “半成品”每个 .c 文件被 gcc -c 编译成 .o 时编译器只处理当前这一个文件。它会做两件事把源代码翻译成机器指令放进 .text 节把全局变量放进 .data / .bss 节对于当前文件里找不到定义的函数和全局变量全部标记为 “未定义符号UND”并把调用它们的地址先填成 0同时在重定位表里记下 “这个位置将来要修正”反汇编指令 objdump -d 文件名依旧拿上面的代码举例反汇编结果如下反汇编后可以看到call 指令后面的目标地址全是 00 00 00 00—— 这就是占位符编译单个文件时无法确定外部函数的真实地址只能先用 0 作为占位符等链接阶段通过**「重定位」机制**回填真正的地址2静态链接如何形成可执行文件a.第一步空间与地址分配链接器拿到所有 .o 文件后第一件事是扫描所有输入文件收集所有节按类型合并然后给合并后的节分配统一的虚拟地址空间具体过程收集所有 .o 的 .text 节合并成一个大的 .text 节同理合并 .data、.bss、.rodata 等所有同类节按照 “代码段 → 数据段” 的布局给合并后的每个节分配虚拟地址确定每个符号最终的虚拟地址生成新的 ELF 结构新的 ELF 头、程序头表、节头表这一步做完后整个程序的内存布局就定了每个函数、每个全局变量最终在虚拟地址空间的哪个位置就已经确定了b.第二步符号解析Symbol Resolution空间分配完后链接器要解决所有 “未定义符号”给每个 UND 符号找到它的定义位置静态库的符号解析有一个 “按需提取” 规则链接器只会从 .a 库里提取那些能解决当前未定义符号的 .o 文件没用到的 .o 不会被链接进最终程序(这一步了解即可)c.第三步重定位Relocation符号解析完成后所有符号的最终地址都已知了。接下来链接器遍历所有重定位表把之前所有填了 0 的占位地址全部修正为最终的虚拟地址—— 这个过程就叫重定位如上图将 code.o 和 func.o 连接形成 main 可执行程序对其进行反汇编可以看到此时 main函数中的 printffunc 函数都有地址了还有一个要注意的地方观察左侧的地址发现就是依次从小到大进行编址如上图多个 .o 打包形成一个ELF一旦合并每一个函数地址就确定了地址值就会修改call 后的全0 就修改成了指定函数的入口地址这就完成了链接的过程所以 .o 文件叫做可重定位目标文件因为在链接的过程中地址会被重新修改2.ELF加载与进程地址空间很多人误以为 “程序加载到内存后才分配地址”事实恰恰相反可执行文件在编译链接完成后磁盘上的 ELF 就已经拥有完整的虚拟地址了反汇编任何一个可执行文件最左侧一列就是虚拟地址上面就讲过了1逻辑地址与线性地址平坦模式编址2ELF加载的具体过程如上图还记得我们上面介绍 ELF Header 时里面有一个变量Entry Point Address这就记录了程序的开始位置-start在可执行程序开始运行时就交给 CPU中的EIP当磁盘中的可执行程序加载到物理内存时可执行程序在物理内存中的每一条指令都要占据具体的物理内存那么通过构建磁盘中可执行程序的虚拟地址与物理内存中的真实物理地址之间的映射关系也就是页表交给CPU中的CP3对于CPU来说执行 text 程序就可以只看虚拟地址就可以执行对应真实物理地址的指令2.动态库与动态连接1动态库动态库要想被使用要满足两个条件;• 被进程看到动态库映射到进程的地址空间• 被进程调用在进程的地址空间中进行跳转进程如何看到动态库如上图磁盘上的动态库先要加载到内存中然后要在对应要调用动态库的程序的页表处建立映射关系以便在进程的地址空间中进行跳转进程间如何共享库的如上图动态库只需要在内存中加载一份就可以被多个进程通过页表映射来使用所以动态库也被称为共享库2动态连接在C/C程序中当程序开始执⾏时它⾸先并不会直接跳转到 main 函数。实际上程序的⼊⼝点是 _start 这是⼀个由C运⾏时库通常是glibc或链接器如ld提供的特殊函数在 _start 函数中会执⾏⼀系列初始化操作这些操作包括设置堆栈为程序创建⼀个初始的堆栈环境初始化数据段将程序的数据段如全局变量和静态变量从初始化数据段复制到相应的内存位置并清零未初始化的数据段动态链接这是关键的⼀步 _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的动态库shared libraries。动态链接器会处理所有的符号解析和重定位确保程序中的函数调⽤和变量访问能够正确地映射到动态库中的实际地址动态链接把「符号解析、地址重定位」的完整链接过程从编译链接阶段推迟到了程序运行阶段• 库代码独立保存在 .so共享目标文件中不会打包进可执行文件• 程序启动时由专门的动态链接器ld-linux.so把依赖的动态库加载到内存并完成符号绑定• 物理内存中只保留一份库的代码所有进程通过虚拟内存机制共享这一份物理副本a.动态库中的相对地址动态库为了随时进⾏加载为了⽀持并映射到任意进程的任意位置对动态库中的⽅法统⼀编址采⽤相对编址的⽅案进⾏编制的(其实可执⾏程序也⼀样都要遵守平坦模式只不过exe是直接加载的)b.程序和库的具体映射• 动态库也是⼀个⽂件要访问也是要被先加载要加载也是要被打开的• 让我们的进程找到动态库的本质也是⽂件操作不过我们访问库函数通过虚拟地址进⾏跳转访问的所以需要把动态库映射到进程的地址空间中c.库函数调⽤• 库已经被我们映射到了当前进程的地址空间中• 库的虚拟起始地址我们也已经知道了• 库中每⼀个⽅法的偏移量地址我们也知道• 访问库中任意⽅法只需要知道库的起始虚拟地址⽅法偏移量即可定位库中的方法• 整个调⽤过程是从代码区跳转到共享区调⽤完毕在返回到代码区整个过程完全在进程地址空间中进⾏的现在我们的可执行文件要执行C标准库中的 put 函数那么假设 put 在库中的偏移量是 0x112233因为动态库也是使用平坦模式进行编址我们就可以通过偏移量找到 put 函数在动态库中的具体位置就可以进行调用执行d.全局偏移量表GOT(global offset table)也就是说我们的程序运⾏之前先把所有库加载并映射所有库的起始虚拟地址都应该提前知道然后对我们加载到内存中的程序的库函数调⽤进⾏地址修改在内存中⼆次完成地址设置(这个叫做加载地址重定位)等等修改的是代码区不是说代码区在进程中是只读的吗怎么修改能修改吗既然代码段不能改那我们就把「需要动态修改的地址」单独提取出来放到数据段里。数据段本身就是可读可写的而且每个进程有自己独立的数据段副本修改不会影响其他进程这张专门存放外部符号真实地址的表就叫做GOTGlobal Offset Table全局偏移表以调用外部函数 puts 为例编译阶段编译器不知道 puts 的真实地址于是在 GOT 中预留一项 GOT[n]专门用来存放 puts 的地址代码生成代码里调用 puts 的地方不直接跳转到某个绝对地址而是先通过相对寻址找到 GOT[n]读取里面的值再间接跳转加载阶段动态库加载完成后动态链接器找到 puts 的真实虚拟地址把地址写入 GOT[n]运行阶段函数调用时读 GOT 项就能拿到真实地址直接跳转通过这种方式实现的动态连接就叫做PIC与地址无关码动态库不需要做任何修改被加载到任意内存地址都能够正常运⾏并且能够被所有进程共享这也是为什么之前我们给编译器指定-fPIC参数的原因PIC相对编址GOT结尾ok了今天这一期就到这里了内容有些复杂大家可以收藏起来方便以后好好回忆理解如果对你有所帮助谢谢你的点赞我们下期再见我主页里有更好康的呦往期回顾【Linux】第5期 深入理解 Linux 基础 IO 与 Ext 文件系统从系统调用到磁盘底层【学习篇】第23期 C11 核心特性 异常机制 智能指针【Linux】第4期 Linux 进程与进程控制一篇搞定所有核心知识点

相关新闻