【Linux】库制作与原理
本文主题内容理解库的概念以及动静态库的区别掌握 Linux 下静态库的制作与使用掌握 Linux 下动态库的制作与使用理解动态库运行时的搜索路径认识目标文件和 ELF 文件结构理解链接视图与执行视图理解静态链接、程序加载与动态链接理解位置无关码、GOT 与动态库共享引言只会使用gcc main.c遇到undefined reference或libxxx.so: cannot open shared object file时就很难定位问题。要真正理解库不能只记住几个编译选项还需要知道源文件怎样形成目标文件链接器怎样解析符号和修正地址操作系统又怎样把 ELF 装入进程地址空间。本文从库的制作开始一直分析到动态链接的基本原理。一、库的基本概念1.1 什么是库库是已经写好、相对成熟并且可以重复使用的代码集合。一个程序通常会依赖大量基础功能如果每个项目都从零实现字符串处理、输入输出、网络通信等功能开发成本会非常高。库对外通常提供两部分内容头文件给编译器提供函数声明、类型和宏定义库文件保存已经编译完成的二进制代码从使用者的角度看只需要包含头文件并在链接时指定对应的库就可以调用库中提供的函数。Linux 下常见的库分为两类类型Linux 后缀Windows 后缀主要特点静态库.a.lib链接时把需要的代码复制进可执行程序动态库.so.dll运行时加载多个进程可以共享库的本质是可被其他程序复用的二进制代码。1.2 库名规则Linux 库文件通常遵循下面的命名规则lib库名.a lib库名.so链接时使用-l选项需要去掉前缀lib和后缀.a或.so。例# 库文件名为 libmystdio.a 或 libmystdio.sogcc main.c-lmystdio因此libc.so对应的链接选项是-lclibpthread.so对应的链接选项是-lpthread。1.3 编译与链接源代码不能直接被 CPU 执行需要先经过编译形成目标文件再由链接器把目标文件和库组织成可执行程序。例//hello.c#includestdio.hvoidrun(void);intmain(){printf(hello world!\n);run();return0;}//code.c#includestdio.hvoidrun(void){printf(running...\n);}gcc-chello.c gcc-ccode.c gcc hello.o code.o-omain其中gcc -c只编译不执行最终链接hello.o和code.o是目标文件最后一条命令由链接器解析两个目标文件之间的符号引用并生成可执行程序二、静态库的制作与使用2.1 静态库的特点静态库以.a结尾。程序链接静态库时链接器会从库中找到当前程序需要的目标模块并把相关代码合并到最终的可执行程序中。静态链接具有下面的特点可执行程序形成后不再依赖原来的静态库文件程序部署比较方便多个程序会各自保存一份库代码可执行文件和磁盘占用更大库更新后使用它的程序需要重新链接2.2 准备库代码例//my_string.h#pragmaonceintmy_strlen(constchar*str);//my_string.c#includemy_string.hintmy_strlen(constchar*str){constchar*endstr;while(*end!\0){end;}returnend-str;}先把源文件编译成目标文件gcc-cmy_string.c-omy_string.o2.3 使用 ar 生成静态库GNU 的ar工具可以把多个目标文件归档成一个静态库。例ar-rclibmystring.a my_string.o常用选项r将目标文件插入库中存在同名成员时进行替换c库不存在时创建库t查看库中的成员v显示详细信息例ar-tvlibmystring.a一个静态库并不是新格式可以把它理解为若干.o文件的归档。链接器真正处理的仍然是目标文件中的 Section、符号表和重定位信息。2.4 使用 Makefile 制作静态库例libmystring.a: my_string.o ar -rc $ $^ echo build $ done my_string.o: my_string.c my_string.h gcc -c my_string.c -o my_string.o .PHONY: clean clean: rm -f *.o *.a如果需要把库交给其他人使用还应同时整理头文件和库文件。例mystring/ ├── include/ │ └── my_string.h └── lib/ └── libmystring.a2.5 使用静态库例//main.c#includestdio.h#includemy_string.hintmain(){constchar*strabcdefg;printf(%s: %d\n,str,my_strlen(str));return0;}gcc main.c -I./mystring/include -L./mystring/lib-lmystring-omain三个选项分别表示-I指定头文件搜索路径-L指定库文件搜索路径-l指定要链接的库执行完成后即使删除libmystring.a已经形成的main仍然可以运行因为需要的代码已经进入可执行程序。注意库的链接顺序会影响符号解析。使用传统链接器时通常把依赖其他目标文件的库放在命令后部例如gcc main.o -lmystring。三、动态库的制作与使用3.1 动态库的特点动态库以.so结尾。使用动态库的可执行程序不会保存库函数的完整机器码而是保留动态依赖和相关的符号信息。程序启动时动态链接器负责把所需动态库映射到进程地址空间并完成必要的重定位。动态链接具有下面的特点可执行程序通常更小多个进程可以共享同一份只读库代码库可以独立升级但必须注意 ABI 兼容程序运行时仍然需要找到对应的动态库3.2 生成位置无关的目标文件动态库可能被映射到不同进程地址空间中的不同位置因此编译库代码时要使用位置无关码。例gcc-fPIC-cmy_string.c-omy_string.o-fPIC表示生成 Position Independent Code也就是位置无关码。3.3 生成动态库例gcc-sharedmy_string.o-olibmystring.so对应的 Makefile 可以写成libmystring.so: my_string.o gcc -shared $^ -o $ my_string.o: my_string.c my_string.h gcc -fPIC -c my_string.c -o my_string.o .PHONY: clean clean: rm -f *.o *.so3.4 链接动态库例gcc main.c -I./mystring/include -L./mystring/lib-lmystring-omain这条命令与静态库的写法很相似。如果同一路径中同时存在同名的.so和.aGCC 默认优先进行动态链接。需要强制静态链接时可以根据实际环境使用-static但系统必须提供相应的静态库。可以使用ldd查看程序的动态依赖。例ldd ./main编译时能找到动态库只能说明链接器知道它在哪里程序运行时动态链接器还需要再次找到这个库。四、动态库的运行时搜索路径4.1 典型问题程序链接成功后直接运行可能出现下面的错误error while loading shared libraries: libmystring.so: cannot open shared object file: No such file or directory使用ldd查看时会看到libmystring.so not found出现这个问题的原因是-L只负责告诉链接器去哪里找库并没有永久改变运行时动态链接器的搜索范围。4.2 临时设置 LD_LIBRARY_PATH例exportLD_LIBRARY_PATH$PWD/mystring/lib:$LD_LIBRARY_PATH./main该方法只影响当前 shell 及其子进程适合开发和测试。4.3 配置系统动态库路径可以把库目录写入/etc/ld.so.conf或/etc/ld.so.conf.d/下的配置文件然后执行ldconfig更新缓存。例echo/opt/mystring/lib|sudotee/etc/ld.so.conf.d/mystring.confsudoldconfig也可以把动态库安装到/lib、/usr/lib或/usr/local/lib等系统默认搜索路径但自定义库通常更适合放在独立目录中再进行配置。4.4 rpath还可以在链接时把运行时搜索路径记录到可执行程序中。例gcc main.c -L./mystring/lib-lmystring\-Wl,-rpath,$ORIGIN/mystring/lib-omain$ORIGIN表示可执行程序所在目录。部署自带动态库的应用时这种相对路径比较方便。注意不要为了省事长期把不可信目录加入全局动态库搜索路径否则可能加载到错误甚至恶意的同名库。五、目标文件与 ELF5.1 什么是目标文件gcc -c生成的.o文件叫作目标文件。它已经包含机器指令但其中对外部函数或变量的引用可能还没有确定最终地址因此暂时不能直接运行。例gcc-chello.cfilehello.o可能得到hello.o: ELF 64-bit LSB relocatable, x86-64目标文件的类型是可重定位文件。修改某一个源文件时只需要重新编译对应的目标文件最后重新链接即可这也是大型工程进行增量构建的基础。5.2 ELF 的四种常见文件ELF 是 Executable and Linkable Format 的缩写。Linux 下常见的 ELF 文件包括可重定位文件.o可执行文件普通可执行程序共享目标文件.soCore 文件进程异常时保存的运行上下文5.3 ELF 的整体结构ELF 主要由下面几部分组成ELF Header描述文件类型、目标架构、入口地址以及其他表的位置Program Header Table描述运行时应该怎样把文件映射成 SegmentSection保存代码、数据、符号、重定位信息等内容Section Header Table描述各个 Section 的名称、类型、偏移和大小ELF Header 可以理解为整个文件的索引入口它告诉工具和操作系统到哪里寻找其他结构。5.4 常见的 SectionSection作用.text保存可执行机器指令.rodata保存字符串常量等只读数据.data保存已经初始化的全局变量和静态变量.bss为未初始化的全局变量和静态变量预留空间.symtab保存符号表.strtab保存符号名称等字符串.rela.text保存需要对代码进行修正的重定位信息.got、.got.plt为动态链接保存运行时地址可以使用下面的命令查看 ELFreadelf-hhello.o readelf-Shello.o readelf-shello.o objdump-dhello.o六、链接视图与执行视图6.1 链接视图链接器关注的是 Section。不同目标文件中的同类 Section 会被合并符号会被解析重定位位置会被修正。例如hello.o中调用了run但编译hello.c时编译器并不知道run最终位于哪里。目标文件会把run记录为未定义符号并在重定位表中记录需要修正的位置。链接器找到code.o中的run定义后再完成地址修正。例readelf-shello.o|greprun readelf-rhello.o如果所有输入文件和库中都没有找到所需定义就会出现undefined reference。6.2 执行视图操作系统加载程序时更关心 Segment。Segment 会把权限相同、加载方式相近的多个 Section 组织在一起。例如.text和部分只读内容通常进入可读、可执行的 Segment.data和.bss通常进入可读、可写的 Segment不需要加载到内存的调试信息可以不进入LOADSegment使用下面的命令可以查看程序头表以及 Section 到 Segment 的映射关系readelf-l./main6.3 为什么要把 Section 合成 Segment内存以页为基本管理单位常见页大小是4KB。如果每个很小的 Section 都单独按页映射会产生较多页内碎片。把权限相同的 Section 合并成 Segment可以减少浪费也便于操作系统统一设置读、写、执行权限。Section 主要服务于编译和链接Segment 主要服务于程序加载和运行。七、静态链接原理7.1 符号解析每个目标文件都有自己的符号表。符号可能处于下面两种状态已定义当前目标文件提供了函数或变量实体未定义当前目标文件使用了符号但定义在其他目标文件或库中链接器会建立全局符号关系把未定义符号与其他输入文件中的定义进行匹配。7.2 Section 合并链接器会把多个目标文件中的同类 Section 合并例如把各个.text合成最终代码区把各个.data合成最终数据区并为它们分配新的文件偏移和虚拟地址。7.3 重定位编译器单独处理某个源文件时无法知道外部符号的最终地址因此会先生成占位地址同时留下重定位项。链接器完成布局和符号解析后根据重定位表修改指令或数据中的地址。例objdump-drhello.o-d用来反汇编-r同时显示重定位信息。把二者放在一起观察就能看到哪条指令需要链接器修正。静态库的链接仍然遵循这一过程只不过链接器会先从.a中按需提取提供目标符号的成员而不是简单地把整个静态库无条件复制进去。八、ELF 的加载过程8.1 创建进程地址空间执行程序时内核根据 ELF 的 Program Header Table 建立相应的虚拟内存区域。常见区域包括代码区、只读区、数据区、堆、共享区和栈。Program Header 中的LOAD项描述了文件中的起始偏移映射到进程中的虚拟地址文件中实际存在的数据长度内存中需要占用的长度读、写、执行权限.bss在文件中通常不保存大量连续的零只记录所需内存大小。加载时内核为它准备相应的零初始化内存因此p_memsz可能大于p_filesz。8.2 文件并不是一次性全部读入内存建立虚拟地址映射后程序访问某个尚未进入物理内存的页面时会触发缺页异常内核再把对应内容载入。这种按需加载减少了启动时不必要的 IO。所以所谓加载 ELF更准确地说是先根据 ELF 建立虚拟内存映射和权限关系随后再由缺页机制按需准备物理页面。8.3 从入口地址开始执行ELF Header 中记录入口地址。动态链接程序通常先经过运行时加载器和启动代码准备参数、环境变量、动态库和 C 运行环境之后由__libc_start_main等启动流程调用main。注意进程最先执行的并不一定是自己编写的mainmain只是 C/C 运行时完成初始化之后调用的用户入口。九、动态链接原理9.1 动态库怎样进入进程地址空间可执行程序的.interpSection 会指出需要使用的动态加载器例如/lib64/ld-linux-x86-64.so.2内核启动程序后动态加载器根据可执行程序的动态依赖查找.so并使用类似mmap的方式把动态库映射到进程地址空间。同一个动态库可以出现在多个进程各自的虚拟地址空间中但只读代码页可以映射到同一份物理内存因此既保持了进程地址空间的独立性又实现了库代码共享。9.2 为什么需要位置无关码不同进程的地址空间布局可能不同同一个.so不应依赖某个固定虚拟地址。位置无关码尽量使用相对寻址使代码无论被映射到哪里都可以执行。如果动态库的只读代码需要为每个进程修改大量绝对地址就无法安全地共享物理代码页。PIC 把需要运行时确定的地址集中到可写数据结构中代码本身保持只读和可共享。9.3 GOT 与间接访问GOT 是 Global Offset Table也就是全局偏移表。动态库或可执行程序需要访问外部函数、全局变量时可以先找到 GOT 中对应的表项再通过表项中的真实地址完成访问。动态加载器在重定位阶段把真实地址写入 GOT。之后代码只需要使用相对寻址找到自己的 GOT再进行一次间接访问。9.4 PLT 与延迟绑定PLT 是 Procedure Linkage Table也就是过程链接表。调用外部函数时程序通常会经过 PLT再根据 GOT 中保存的地址跳转到真实函数。为了减少程序启动时的解析开销部分函数可以采用延迟绑定第一次调用时进入动态加载器动态加载器查找真实函数地址把地址写回对应的 GOT 表项后续调用直接跳转到真实函数如果设置LD_BIND_NOW1可以要求动态链接器在启动阶段尽早完成符号解析。9.5 动态链接过程从整体上看动态链接包括根据依赖项查找并映射动态库为各个模块建立地址关系解析动态符号执行必要的重定位并更新 GOT完成运行环境初始化进入程序入口并最终调用main动态库之间也可能存在依赖。动态加载器会继续处理依赖图但如果出现缺少库、版本不兼容或符号冲突程序仍可能在启动或第一次调用相关函数时失败。十、常用排查命令10.1 查看库和 ELF 信息# 判断文件类型filelibmystring.so# 查看动态依赖ldd ./main# 查看ELF头readelf-h./main# 查看Sectionreadelf-S./main# 查看Segmentreadelf-l./main# 查看符号readelf-s./main nm-C./main# 查看重定位项readelf-r./main# 反汇编objdump-d./main10.2 常见问题定位现象主要阶段常见原因头文件找不到预处理/编译-I路径错误undefined reference链接缺少目标文件或-l链接顺序错误libxxx.so not found运行加载动态库搜索路径未配置undefined symbol动态链接库版本或 ABI 不匹配程序段错误运行函数声明与实际 ABI 不一致、地址使用错误十一、总结库把成熟代码以二进制形式提供给其他程序复用。静态库本质上是目标文件的归档链接器会按需取出代码并合入可执行程序动态库则在运行时被动态加载器映射多个进程可以共享其只读代码页。从文件格式看.o、.so和可执行程序都属于 ELF。Section、符号表和重定位表支撑链接过程Program Header 和 Segment 支撑加载过程。静态链接要解决符号解析、Section 合并与重定位动态链接还要解决运行时查找、地址无关、GOT/PLT 和库共享问题。把编译、链接和加载看成连续过程就能从根本上理解库为什么这样制作、这样使用也能更快定位链接错误和动态库加载错误。