ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux --库制作与原理

Linux --库制作与原理 什么是库库的本质库Library是写好的、成熟的、可复用的代码的二进制形式。本质上库是一种可执行代码的二进制形式可以被操作系统载入内存执行。试想一下如果每次编写程序都需要从零开始实现printf、scanf这些基础功能那开发效率将何其低下。库的出现让我们能够站在巨人的肩膀上编程。库的两种类型类型LinuxWindows特点静态库.a.lib编译时链接到可执行文件运行时不再需要动态库.so.dll运行时才加载多个程序可共享在Linux系统中我们可以通过以下命令查看系统中的C/C库# 查看C动态库 ls -l /lib/x86_64-linux-gnu/libc-2.31.so # 查看C静态库 ls -l /lib/x86_64-linux-gnu/libc.a # 查看C动态库 ls -l /usr/lib/gcc/x86_64-linux-gnu/9/libstdc.so # 查看C静态库 ls -l /usr/lib/gcc/x86_64-linux-gnu/9/libstdc.a静态库的制作与使用制作静态库静态库的制作需要两个步骤将源文件编译成目标文件.o使用ar归档工具将目标文件打包成静态库Makefilelibmystdio.a: my_stdio.o my_string.o ar -rc $ $^ echo build $ to $^...done %.o: %.c gcc -c $ echo compiling $ to $...done .PHONY: clean clean: rm -rf *.a *.o stdc* echo clean ... done .PHONY: output output: mkdir -p stdc/include mkdir -p stdc/lib cp -f *.h stdc/include cp -f *.a stdc/lib tar -czf stdc.tgz stdc echo output stdc ... donear命令说明-r表示替换(replace)-c表示创建(create)。合起来-rc表示创建或替换归档文件中的成员。查看静态库内容# 列出静态库中的文件 ar -tv libmystdio.a # 输出 # rw-rw-r-- 1000/1000 2848 Oct 29 14:35 my_stdio.o # rw-rw-r-- 1000/1000 1272 Oct 29 14:35 my_string.o使用静态库// main.c #include my_stdio.h #include my_string.h #include stdio.h int main() { const char *s abcdefg; printf(%s: %d\n, s, my_strlen(s)); mFILE *fp mfopen(/log.txt, a); if(fp NULL) return 1; mfwrite(s, my_strlen(s), fp); mfwrite(s, my_strlen(s), fp); mfwrite(s, my_strlen(s), fp); mfclose(fp); return 0; }三种使用场景# 场景1头文件和库文件安装到系统路径 gcc main.c -lmystdio # 场景2头文件和库文件在当前目录 gcc main.c -L. -lmystdio # 场景3头文件和库文件在独立路径 gcc main.c -I头文件路径 -L库文件路径 -lmystdio编译选项说明-I指定头文件搜索路径-L指定库文件搜索路径-l指定库名去掉lib前缀和.a/.so后缀静态库的特点程序在编译链接时就把库的代码复制到可执行文件中程序运行时不再需要静态库。删除静态库后程序依然可以运行。动态库的制作与使用制作动态库动态库的制作需要两个关键点-shared生成共享库格式-fPIC生成位置无关代码Position Independent CodeMakefilelibmystdio.so: my_stdio.o my_string.o gcc -o $ $^ -shared %.o: %.c gcc -fPIC -c $ .PHONY: clean clean: rm -rf *.so *.o stdc* echo clean ... done .PHONY: output output: mkdir -p stdc/include mkdir -p stdc/lib cp -f *.h stdc/include cp -f *.so stdc/lib tar -czf stdc.tgz stdc echo output stdc ... done使用动态库使用方法与静态库类似# 场景1安装到系统路径 gcc main.c -lmystdio # 场景2当前目录 gcc main.c -L. -lmystdio # 场景3独立路径 gcc main.c -I头文件路径 -L库文件路径 -lmystdio动态库的运行时搜索问题编译成功后运行程序可能会遇到问题$ ./a.out ./a.out: error while loading shared libraries: libmystdio.so: cannot open shared object file: No such file or directory $ ldd a.out linux-vdso.so.1 (0x00007fff4d396000) libmystdio.so not found libc.so.6 /lib64/libc.so.6 (0x00007fa2aef30000) /lib64/ld-linux-x86-64.so.2 (0x00007fa2af2fe000)解决方案拷贝到系统库路径/usr/lib、/usr/local/lib、/lib64创建软链接到系统库路径设置环境变量LD_LIBRARY_PATHexport LD_LIBRARY_PATH$LD_LIBRARY_PATH:.配置ldconfig# 编辑配置文件 cat /etc/ld.so.conf.d/bit.conf /root/tools/linux # 重新加载配置 ldconfig深入理解ELF文件格式什么是ELFELF 是 Linux 下“程序”在磁盘上的统一封装格式——它规定了代码、数据、符号、链接信息、加载信息怎么摆放让链接器能链接它、加载器能加载它、调试器能调试它。可以把它类比成PDF是文档的统一格式阅读器都能打开ELF是程序的统一格式链接器/加载器/调试器都能处理有以下四种类型类型扩展名说明可重定位文件.o链接时使用的目标文件可执行文件无/.out可运行的程序共享目标文件.so动态库核心转储文件core dump进程崩溃时的内存快照ELF文件结构一个ELF文件由以下四部分组成------------------ | ELF Header | ← 文件开始描述文件主要特性 ------------------ | Program Headers | ← 告诉操作系统如何加载执行视图 | (Segment Header) | ------------------ | | | Sections/ | ← 实际数据 | Segments | | | ------------------ | Section Headers | ← 描述各个节的信息链接视图 ------------------关键点文件头里记录了“两张表在哪、各有多少项”。工具先读文件头就知道该去哪找自己需要的信息。e_phoff // 程序头表偏移 e_phnum // 程序头数量 e_shoff // 节头表偏移 e_shnum // 节头数量所以任何工具拿到一个 ELF第一步都能用同样的方式打开它——这就是统一的第一层。两种视图链接视图与执行视图ELF文件提供了两种不同的视角来理解文件结构链接视图Linking View对应节头表Section Header Table粒度更细按功能模块划分链接器关注的是链接视图包含.text、.data、.bss、.rodata等节执行视图Execution View对应程序头表Program Header Table告诉操作系统如何加载可执行文件将具有相同属性的节合并成段Segment例如所有可读可执行的节合并为一个LOAD段这是ELF 最巧妙的一点同一份内容用两套表描述。节头表细粒度给链接器用要精确到.text、.rela.text程序头表粗粒度给加载器用只要知道“这块内存 R-X、那块 RW-”节视角链接器 .text | .rodata | .data | .bss | ... └──────┬──────┘ └──┬──┘ 段视角加载器 PT_LOAD(R E) PT_LOAD(RW)两套表指向同一批字节只是划分方式不同。这样链接器不用关心内存映射加载器不用关心重定位细节二者读同一个文件各看各的表这就是“统一格式”的第二层不强迫所有工具用同一种视角而是并存两种视角。ELF 把文件内容切成一个个有名字、有类型、有属性的节每类信息放在固定的节里常见的节Section节名说明.text代码节存放机器指令.data数据节存放已初始化的全局变量和静态变量.bss存放未初始化的全局变量和静态变量不占磁盘空间.rodata只读数据节如字符串常量.symtab符号表记录函数名、变量名与代码的对应关系.got全局偏移表Global Offset Table.plt过程链接表Procedure Linkage Table统一体现在每个节在节头表里都有一条固定格式的描述名字、类型、偏移、大小、对齐工具不需要理解整个文件只要按名字找到对应节即可新增信息加一个新节旧工具忽略它不破坏兼容这就像快递按类别分箱每箱贴标准标签谁需要什么就取哪一箱。查看ELF文件信息查看ELF头信息readelf -h hello.o输出示例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: REL (Relocatable file) Machine: Advanced Micro Devices X86-64 Entry point address: 0x0 Start of program headers: 0 (bytes into file) Start of section headers: 728 (bytes into file)查看节头表readelf -S a.out查看程序头表段信息readelf -l a.out输出示例Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align LOAD 0x000000 0x400000 0x400000 0x000d24 0x000d24 R E 200000 LOAD 0x000e10 0x600e10 0x600e10 0x000254 0x000258 RW 200000 DYNAMIC 0x000e28 0x600e28 0x600e28 0x0001d0 0x0001d0 RW 8查看符号表readelf -s hello.o为什么要把Section合并成Segment主要原因是为了减少内存碎片提高内存使用效率。假设页面大小为4096字节如果.text为4097字节.init为512字节不合并占用3个页面4096 4096 512合并后只需2个页面4096 4096此外将相同属性的节合并成一个段可以统一设置访问权限可读、可写、可执行。为什么需要 ELF它解决什么问题在没有统一格式的年代每种可执行文件、每个系统各有各的格式工具无法通用。ELF 要解决三件事1. 让“编译产物”能被链接a.c编译成a.o后里面有很多未确定的地址和符号比如调用了printf但不知道它在哪。ELF 用节 符号表 重定位表记录这些信息链接器才能把它们拼起来。2. 让“可执行文件”能被加载运行内核要能把文件里的代码、数据映射到内存正确的位置设置好权限跳到入口。ELF 用程序头表段告诉内核怎么装。3. 让“共享库”能被动态链接多个程序共用libc.so运行时才确定地址。ELF 用.dynamic、.got、.plt等结构支持动态链接和延迟绑定。所以 ELF 的本质作用是在“源码 → 目标文件 → 可执行文件 → 运行进程”这条链上提供一个各阶段工具都能读写的统一格式。ELF 在程序生命周期中的角色源码 a.c │ 编译 ▼ a.o ← ELF可重定位目标文件 ← 链接器读它 │ 链接 ▼ a.out ← ELF可执行文件 ← 加载器读它 │ 运行 ▼ 进程 ← 内存里的映像 ← 调试器读它同一种格式贯穿始终这就是 ELF 设计的巧妙之处编译器输出 ELF链接器输入/输出 ELF加载器读 ELF调试器gdb读 ELFnm、objdump、readelf、ldd都读 ELFELF 的三大作用对应三类使用者使用者关心什么用 ELF 的哪部分链接器符号、重定位、节节头表、.symtab、.rela.*加载器怎么映射内存、入口程序头表、e_entry、PT_INTERP调试器/工具符号、行号、类型.symtab、.debug_*、.strtab同一份 ELF不同工具看不同的部分各取所需。类比统一格式像“标准集装箱”集装箱外形固定ELF 骨架固定→ 任何吊车都能抓箱内货物分类贴标节→ 不同收货人各取所需有装箱单文件头 两张表→ 不用开箱就知道里面有什么标明目的地/用途e_type、e_machine→ 知道该送到哪、给谁用于是港口链接器、卡车加载器、海关调试器都能处理同一个箱子不需要为每种货物换一种箱子。程序编译与链接的完整过程编译过程回顾# 源代码 → 编译 → 目标文件 gcc -c hello.c # 生成 hello.o gcc -c code.c # 生成 code.o # 链接 → 可执行文件 gcc hello.o code.o -o main.exe目标文件中的地址占位使用objdump -d反汇编目标文件objdump -d hello.o0000000000000000 main: 0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi f: e8 00 00 00 00 callq 14 main0x14 # 调用printf地址为0 14: b8 00 00 00 00 mov $0x0,%eax 19: e8 00 00 00 00 callq 1e main0x1e # 调用run地址为0 1e: b8 00 00 00 00 mov $0x0,%eax 23: 5d pop %rbp 24: c3 retq注意到callq指令后面的地址都是0x00000000这是因为编译器不知道printf和run函数在内存中的位置。这些地址会在链接时被修正。符号表与未定义符号查看目标文件的符号表readelf -s hello.oSymbol table .symtab contains 14 entries: Num: Value Size Type Bind Vis Ndx Name 10: 0000000000000000 37 FUNC GLOBAL DEFAULT 1 main 11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts 13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND runUND表示未定义Undefined即在本目标文件中找不到该符号puts是printf的实现来自C标准库run定义在code.c中链接后的地址修正链接器会将多个目标文件合并并修正所有外部符号的地址objdump -d main.exe | grep -A5 -B5 run:0000000000001149 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 1158: e8 f3 fe ff ff callq 1050 putsplt 115d: 90 nop 115e: 5d pop %rbp 115f: c3 retq 0000000000001160 main: 1160: 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 116f: e8 dc fe ff ff callq 1050 putsplt 1174: b8 00 00 00 00 mov $0x0,%eax 1179: e8 cb ff ff ff callq 1149 run # 修正为实际地址0x1149 117e: b8 00 00 00 00 mov $0x0,%eax 1183: 5d pop %rbp 1184: c3 retq可以看到run函数的调用地址已被修正为0x1149。链接的本质将编译后的所有目标文件连同静态库组合、拼装成一个独立的可执行文件过程中需要根据重定位表修正外部符号的地址。虚拟地址空间与程序加载ELF中的地址ELF程序在未加载到内存时就已经有地址了。当代计算机采用平坦模式Flat Mode对代码和数据统一编址。查看反汇编结果最左侧的地址就是ELF的虚拟地址逻辑地址objdump -S a.out0000000000400640 _start: 400640: f3 0f 1e fa endbr64 400644: 31 ed xor %ebp,%ebp 400646: 49 89 d1 mov %rdx,%r9 ...将一个可执行文件变成进程操作系统需要完成两件大事且顺序严格第一步创建进程的内核数据结构先有容器操作系统必须先创建一个空的进程壳子包括分配task_struct进程描述符PCB进程控制块分配mm_struct内存描述符分配一个空的页表还没映射任何物理内存分配vm_area_struct虚拟内存区域描述符记录代码段、数据段、堆、栈的布局分配PID进程ID此时的进程有壳无肉——它有了身份证和地址空间的框架但里面空空如也什么代码都没加载什么数据都没有。第二步将程序的内容加载到进程的地址空间中操作系统将ELF可执行文件的各个段Segment映射到刚刚创建的虚拟地址空间中并填充页表。.text代码段→ 映射到虚拟地址的代码区.data数据段→ 映射到虚拟地址的数据区记录程序入口点Entry Point如0x1060设置EIP/PC指向Entry Point进程地址空间的初始化进程的mm_struct和vm_area_struct在创建时数据从哪里来从ELF的各个Segment来每个Segment都有自己的起始地址和长度用来初始化内核结构中的[start, end]范围数据。程序入口地址也记录在ELF Header中readelf -h a.outEntry point address: 0x400640程序加载过程图解-------------------------------------------------- | 内核空间 | (操作系统内核代码) -------------------------------------------------- | | 进程虚拟地址空间 | | -------------------------------------------- | | 栈 (Stack) | | ↓ 向下增长 | -------------------------------------------- | | | | 共享库区域 (共享库映射到此) | | (如 libc.so, ld-linux.so) | | | -------------------------------------------- | | 堆 (Heap) ↑ 向上增长 | -------------------------------------------- | | | | .data (已初始化数据) | | .bss (未初始化数据) | | .text (代码段) | | | -------------------------------------------- | | 保留区域 --------------------------------------------------动态链接的深入剖析动态链接的动机静态链接的缺点文件体积大每个程序都包含所有依赖库的代码内存浪费相同库代码在内存中重复出现更新不便库更新后需要重新链接所有程序动态链接的优势节省空间同一份库在内存中只保留一份被所有进程共享更新方便替换库文件即可无需重新链接程序按需加载程序运行时才加载需要的库_start 入口点C/C程序的入口点不是main函数而是_start函数由glibc或链接器提供设置堆栈为程序创建初始堆栈环境初始化数据段复制已初始化数据清零未初始化数据动态链接调用动态链接器解析和加载依赖的动态库调用__libc_start_main执行额外初始化调用main函数执行用户代码处理返回值调用_exit终止程序动态库的相对地址动态库为了能够加载到任意进程的任意位置采用相对编址方案# 查看库的反汇编 objdump -S /lib64/libc-2.17.so | less所有地址都是相对于库起始地址的偏移量这就是-fPIC参数的作用。进程如何找到动态库进程虚拟地址空间 ------------------ | | | 代码段 (.text) | ← 跳转到共享区 | ↓ | | 调用库函数 | | ↓ | ------------------ | | | 共享库区域 | ← 动态库映射到此 | (libc.so) | | (ld-linux.so) | | | ------------------动态库被映射到进程地址空间的共享库区域后通过库起始虚拟地址 方法偏移量即可定位任意方法。GOT全局偏移表与PLT过程链接表为什么需要GOT问题代码段.text是只读的但动态库加载地址不固定需要修改函数跳转地址。解决方案在可读写的.data段中预留一片区域存放函数跳转地址这就是GOTGlobal Offset Table。GOT工作原理代码段调用流程 call putsplt ↓ PLT桩代码 jmp *GOT[puts] ↓ GOT表项 存有puts的真实地址运行时填入延迟绑定Lazy Binding为了优化性能动态链接采用延迟绑定策略函数第一次被调用时才进行地址解析。第一次调用流程调用putspltPLT跳转到GOTGOT指向PLT中的桩代码桩代码调用动态链接器解析puts的真实地址动态链接器将真实地址填入GOT跳转到真实的puts函数后续调用流程调用putspltPLT跳转到GOTGOT已存有真实地址直接跳转到puts函数查看PLT和GOTobjdump -S a.out | grep -A5 putsplt0000000000001050 putsplt: 1050: f3 0f 1e fa endbr64 1054: f2 ff 25 75 2f 00 00 bnd jmpq *0x2f75(%rip) # 跳转到GOT表项 105b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)库之间的依赖不仅可执行程序调用库库也可能调用其他库。每个库都有自己的GOT表因为代码段只读不能直接修改不同进程的地址空间不同库的绝对地址不同每个进程、每个库都有独立的GOT表库间调用原理与可执行程序调用库完全一致都是通过GOT表跳转。静态链接与动态链接的对比总结对比维度静态链接动态链接链接时机编译时程序加载时可执行文件大小大包含所有库代码小只有地址表内存占用高每份程序独立低共享一份库库更新需重新链接替换库文件即可启动速度快无需解析稍慢需加载解析性能高直接调用稍低GOT查表代码复用二进制级别二进制级别且共享操作命令速查表文件查看命令说明file hello.o查看文件类型readelf -h a.out查看ELF头readelf -l a.out查看程序头段信息readelf -S a.out查看节头信息readelf -s a.out查看符号表objdump -d a.out反汇编所有代码段objdump -S a.out反汇编并显示源代码ldd a.out查看依赖的动态库size a.out查看各段大小text/data/bss库操作命令说明ar -rc lib.a *.o创建静态库ar -tv lib.a列出静态库内容gcc -shared -fPIC -o lib.so *.o创建动态库gcc -L. -lmylib main.c链接库export LD_LIBRARY_PATH.设置库搜索路径ldconfig更新库缓存图解完整过程时间线 ────────────────────────────────────────────────────────────────► 用户输入 ./a.out │ │ shell 进程 │ ▼ 【1】fork() ├── 内核分配 task_struct ├── 内核分配 mm_struct复制父进程的 ├── 内核分配 PID 1234 └── 子进程创建完成但运行的是shell代码 ▼ 【2】子进程调用 execve(./a.out) ◄── 此时进程已存在PID1234 │ ├── 内核打开 a.out ├── 读取 ELF Header ├── 读取 Program Headers ├── 清空旧的 mm_struct ├── 建立新的 mm_struct │ ├── 代码区域vm_start0x400000, vm_end0x401000 │ ├── 数据区域vm_start0x600000, vm_end0x601000 │ ├── 堆区域 │ └── 栈区域 ├── 建立文件到虚拟地址的映射vma → 文件偏移 ├── 设置 Entry Point 0x1060 └── 设置 PC 0x1060 ▼ 【3】CPU 开始执行 a.out 的指令 │ ├── PC 0x1060 ├── MMU 查页表0x1060 对应的物理页还没分配 → 缺页中断 ├── 内核从磁盘读取 .text 段到物理内存帧 ├── 更新页表映射 ├── CPU 继续执行 └── 程序真正跑起来 ▼ 【4】_start → 动态链接器 → __libc_start_main → main() 进程 PID1234 正式成为运行 a.out 的进程关键问答把知识点串起来Q1: 为什么目标文件中的地址是0而可执行文件中有具体地址因为编译时不知道链接时才确定。链接器把所有目标文件合并后才能知道每个符号的具体位置然后修正地址。这就是重定位。Q2: 为什么动态库要用-fPIC因为加载地址不固定。-fPIC让代码使用相对寻址无论库被映射到哪个地址偏移量不变。否则如果代码中使用了绝对地址加载到不同位置就无法运行。Q3: GOT表为什么在数据段而不在代码段因为需要被修改。代码段只读而GOT表在程序运行时需要被动态链接器写入真实地址。放在可读写的数据段就可以修改。Q4: 为什么需要PLT直接用GOT不行吗为了延迟绑定。如果直接用GOT程序启动时就要解析所有库函数地址严重影响启动速度。PLT提供了用到才解析的机制只解析实际调用的函数。Q5: 物理内存中动态库真的只有一份吗是的通过虚拟内存机制。物理内存中只有一份libc.so的代码但被映射到不同进程的不同虚拟地址。每个进程有自己独立的GOT表在物理内存中是独立的但代码段是共享的。Q6: 静态链接和动态链接的本质区别是什么静态链接编译时解决所有地址问题 → 独立运行动态链接运行时解决所有地址问题 → 需要依赖库静态重定位链接时修正地址一次搞定动态重定位加载时修正GOT每次加载都要做
返回列表