Linux --库制作与原理

什么是库?

库的本质

库(Library)是写好的、成熟的、可复用的代码的二进制形式。本质上,库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。

试想一下,如果每次编写程序都需要从零开始实现printfscanf这些基础功能,那开发效率将何其低下。库的出现,让我们能够站在巨人的肩膀上编程。

库的两种类型

类型 Linux Windows 特点
静态库 .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

静态库的制作与使用

制作静态库

静态库的制作需要两个步骤:

  1. 将源文件编译成目标文件(.o

  2. 使用ar归档工具将目标文件打包成静态库

Makefile

复制代码
libmystdio.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 ... done"

ar命令说明-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后缀)

静态库的特点:程序在编译链接时就把库的代码复制到可执行文件中,程序运行时不再需要静态库。删除静态库后程序依然可以运行。

动态库的制作与使用

制作动态库

动态库的制作需要两个关键点:

  1. -shared:生成共享库格式

  2. -fPIC:生成位置无关代码(Position Independent Code)

Makefile

复制代码
libmystdio.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_PATH

复制代码
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:.

配置ldconfig

复制代码
# 编辑配置文件
cat /etc/ld.so.conf.d/bit.conf
/root/tools/linux

# 重新加载配置
ldconfig

深入理解ELF文件格式

什么是ELF?

ELF(Executable and Linkable Format)是Linux系统下可执行文件、目标文件和共享库的标准文件格式。有以下四种类型:

类型 扩展名 说明
可重定位文件 .o 链接时使用的目标文件
可执行文件 无/.out 可运行的程序
共享目标文件 .so 动态库
核心转储文件 core dump 进程崩溃时的内存快照

ELF文件结构

一个ELF文件由以下四部分组成:

复制代码
+------------------+
|   ELF Header     |  ← 文件开始,描述文件主要特性
+------------------+
| Program Headers  |  ← 告诉操作系统如何加载(执行视图)
| (Segment Header) |
+------------------+
|                  |
|   Sections/      |  ← 实际数据
|   Segments       |
|                  |
+------------------+
| Section Headers  |  ← 描述各个节的信息(链接视图)
+------------------+

两种视图:链接视图与执行视图

ELF文件提供了两种不同的视角来理解文件结构:

链接视图(Linking View) :对应节头表(Section Header Table)

  • 粒度更细,按功能模块划分

  • 链接器关注的是链接视图

  • 包含.text.data.bss.rodata等节

执行视图(Execution View) :对应程序头表(Program Header Table)

  • 告诉操作系统如何加载可执行文件

  • 将具有相同属性的节合并成段(Segment)

  • 例如:所有可读可执行的节合并为一个LOAD段

常见的节(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:                              2's 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)

此外,将相同属性的节合并成一个段,可以统一设置访问权限(可读、可写、可执行)。

程序编译与链接的完整过程

编译过程回顾

复制代码
# 源代码 → 编译 → 目标文件
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.o

0000000000000000 <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 <main+0x14>  # 调用printf,地址为0
  14:   b8 00 00 00 00        mov    $0x0,%eax
  19:   e8 00 00 00 00        callq  1e <main+0x1e>  # 调用run,地址为0
  1e:   b8 00 00 00 00        mov    $0x0,%eax
  23:   5d                    pop    %rbp
  24:   c3                    retq

注意到callq指令后面的地址都是0x00000000,这是因为编译器不知道printfrun函数在内存中的位置。这些地址会在链接时被修正。

符号表与未定义符号

查看目标文件的符号表:

复制代码
readelf -s hello.o

Symbol 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 run
  • UND表示未定义(Undefined),即在本目标文件中找不到该符号

  • putsprintf的实现,来自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 <puts@plt>
    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 <puts@plt>
    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.out

0000000000400640 <_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_structvm_area_struct在创建时,数据从哪里来?

从ELF的各个Segment来!

每个Segment都有自己的起始地址和长度,用来初始化内核结构中的[start, end]范围数据。

程序入口地址也记录在ELF Header中:

复制代码
readelf -h a.out

Entry point address:               0x400640

程序加载过程图解

复制代码
+--------------------------------------------------+
|                    内核空间                        
|           (操作系统内核代码)                       
+--------------------------------------------------+
|                                                    
|           进程虚拟地址空间                          
|                                                    
|  +--------------------------------------------+  
|  |  栈 (Stack)                                
|  |  ↓ 向下增长                               
|  +--------------------------------------------+ 
|  |                                            
|  |  共享库区域 (共享库映射到此)                
|  |  (如 libc.so, ld-linux.so)                 
|  |                                             
|  +--------------------------------------------+  
|  |  堆 (Heap)  ↑ 向上增长                      
|  +--------------------------------------------+  
|  |                                               
|  |  .data  (已初始化数据)                       
|  |  .bss   (未初始化数据)                        
|  |  .text  (代码段)                          
|  |                                               
|  +--------------------------------------------+  
|  |  保留区域                                    
+--------------------------------------------------+

动态链接的深入剖析

动态链接的动机

静态链接的缺点:

  1. 文件体积大:每个程序都包含所有依赖库的代码

  2. 内存浪费:相同库代码在内存中重复出现

  3. 更新不便:库更新后需要重新链接所有程序

动态链接的优势:

  1. 节省空间:同一份库在内存中只保留一份,被所有进程共享

  2. 更新方便:替换库文件即可,无需重新链接程序

  3. 按需加载:程序运行时才加载需要的库

_start 入口点

C/C++程序的入口点不是main函数,而是_start函数(由glibc或链接器提供):

  1. 设置堆栈:为程序创建初始堆栈环境

  2. 初始化数据段:复制已初始化数据,清零未初始化数据

  3. 动态链接:调用动态链接器解析和加载依赖的动态库

  4. 调用__libc_start_main:执行额外初始化

  5. 调用main函数:执行用户代码

  6. 处理返回值 :调用_exit终止程序

动态库的相对地址

动态库为了能够加载到任意进程的任意位置,采用相对编址方案:

复制代码
# 查看库的反汇编
objdump -S /lib64/libc-2.17.so | less

所有地址都是相对于库起始地址的偏移量,这就是-fPIC参数的作用。

进程如何找到动态库

复制代码
进程虚拟地址空间
+------------------+
|                  |
|  代码段 (.text)   |  ← 跳转到共享区
|      ↓           |
|  调用库函数        |
|      ↓           |
+------------------+
|                  |
|  共享库区域        |  ← 动态库映射到此
|  (libc.so)       |
|  (ld-linux.so)   |
|                  |
+------------------+

动态库被映射到进程地址空间的共享库区域后,通过库起始虚拟地址 + 方法偏移量即可定位任意方法。

GOT(全局偏移表)与PLT(过程链接表)

为什么需要GOT?

问题 :代码段(.text)是只读的,但动态库加载地址不固定,需要修改函数跳转地址。

解决方案 :在可读写的.data段中预留一片区域存放函数跳转地址,这就是GOT(Global Offset Table)

GOT工作原理
复制代码
代码段调用流程:
call puts@plt
    ↓
PLT桩代码:
jmp *GOT[puts]
    ↓
GOT表项:
存有puts的真实地址(运行时填入)
延迟绑定(Lazy Binding)

为了优化性能,动态链接采用延迟绑定策略:函数第一次被调用时才进行地址解析。

第一次调用流程

  1. 调用puts@plt

  2. PLT跳转到GOT,GOT指向PLT中的桩代码

  3. 桩代码调用动态链接器解析puts的真实地址

  4. 动态链接器将真实地址填入GOT

  5. 跳转到真实的puts函数

后续调用流程

  1. 调用puts@plt

  2. PLT跳转到GOT,GOT已存有真实地址

  3. 直接跳转到puts函数

查看PLT和GOT
复制代码
objdump -S a.out | grep -A5 "puts@plt"

0000000000001050 <puts@plt>:
    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表,因为:

  1. 代码段只读,不能直接修改

  2. 不同进程的地址空间不同,库的绝对地址不同

  3. 每个进程、每个库都有独立的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")   ◄── 此时进程已存在(PID=1234)
    │
    ├── 内核打开 a.out
    ├── 读取 ELF Header
    ├── 读取 Program Headers
    ├── 清空旧的 mm_struct
    ├── 建立新的 mm_struct:
    │      ├── 代码区域:vm_start=0x400000, vm_end=0x401000
    │      ├── 数据区域:vm_start=0x600000, vm_end=0x601000
    │      ├── 堆区域
    │      └── 栈区域
    ├── 建立文件到虚拟地址的映射(vma → 文件偏移)
    ├── 设置 Entry Point = 0x1060
    └── 设置 PC = 0x1060

    ▼
【3】CPU 开始执行 a.out 的指令
    │
    ├── PC = 0x1060
    ├── MMU 查页表:0x1060 对应的物理页还没分配 → 缺页中断
    ├── 内核从磁盘读取 .text 段到物理内存帧
    ├── 更新页表映射
    ├── CPU 继续执行
    └── 程序真正"跑起来"

    ▼
【4】_start → 动态链接器 → __libc_start_main → main()

进程 PID=1234 正式成为运行 a.out 的进程

关键问答:把知识点串起来

Q1: 为什么目标文件中的地址是0,而可执行文件中有具体地址?

因为编译时不知道 ,链接时才确定。链接器把所有目标文件合并后,才能知道每个符号的具体位置,然后修正地址。这就是重定位

Q2: 为什么动态库要用-fPIC

因为加载地址不固定-fPIC让代码使用相对寻址,无论库被映射到哪个地址,偏移量不变。否则,如果代码中使用了绝对地址,加载到不同位置就无法运行。

Q3: GOT表为什么在数据段而不在代码段?

因为需要被修改。代码段只读,而GOT表在程序运行时需要被动态链接器写入真实地址。放在可读写的数据段就可以修改。

Q4: 为什么需要PLT,直接用GOT不行吗?

为了延迟绑定。如果直接用GOT,程序启动时就要解析所有库函数地址,严重影响启动速度。PLT提供了"用到才解析"的机制,只解析实际调用的函数。

Q5: 物理内存中动态库真的只有一份吗?

是的,通过虚拟内存机制 。物理内存中只有一份libc.so的代码,但被映射到不同进程的不同虚拟地址。每个进程有自己独立的GOT表(在物理内存中是独立的),但代码段是共享的。

Q6: 静态链接和动态链接的本质区别是什么?

静态链接:编译时解决所有地址问题 → 独立运行

动态链接:运行时解决所有地址问题 → 需要依赖库

静态重定位:链接时修正地址(一次搞定)

动态重定位:加载时修正GOT(每次加载都要做)

相关推荐
uxiang_blog13 分钟前
Linux修改计算名字和添加删除用户名。
linux·运维·服务器
深念Y18 分钟前
06-移动端三技术栈优劣对比-理论推演
服务器·云原生·架构·音视频·短视频
大模型码小白25 分钟前
MCP协议开发实战:从零搭建AI Agent工具链
运维·人工智能·算法·机器学习·自动化
Syc1102g28 分钟前
在 Linux CentOS 系统中设置静态 IP
linux·网络·计算机网络·个人开发
2601_9622035128 分钟前
Nginx 安装配置
运维·nginx
YQ_0130 分钟前
ROS 2 Humble Nav2 生命周期教程:启动流程、状态监控、超时诊断与自愈设计
linux·机器人·ros2·nav2
loong_XL1 小时前
Windows WSL2 Ubuntu 搭建 Xvfb 虚拟桌面环境,完美支持多 Agent 隔离
linux·windows·ubuntu
大熊背1 小时前
树莓派 libcamera IPA 框架解析
linux·ipa·isppipeline
Horn Still Sounds1 小时前
Linux进程间通信:信号、消息队列、共享内存完整笔记
linux·网络·笔记