【Linux】库制作与链接原理:静态库、动态库、ELF、GOT/PLT 一文吃透

前言

在 Linux 下写 C/C++,迟早会遇到这些问题:

  • gcc main.c -lm 里的 -l 到底是什么意思?
  • .a.so 有什么区别?
  • 为什么静态库链接后删掉库文件,程序还能运行?
  • 为什么动态库明明编译通过,运行时却提示 not found
  • -fPIC 为什么是动态库常客?
  • lddreadelfobjdump 到底在看什么?
  • 动态库到底是怎么被加载到进程地址空间里的?

这篇文章就围绕“库制作与原理”展开,先讲静态库和动态库怎么做、怎么用,再深入到目标文件、ELF 格式、静态链接、动态链接、GOT/PLT、PIC 和程序加载过程。

一句话先放在前面:

库不是神秘黑盒,本质上就是可复用的二进制代码;静态库在链接期被合入可执行程序,动态库在运行期被映射到进程地址空间。

一、什么是库

库就是已经写好的、成熟的、可以复用的代码。

现实中的程序不可能每个人都从零开始写。比如字符串处理、文件 IO、网络通信、数学计算、界面绘制,这些通用能力通常都会被封装成库。

从本质上说:

库是一种二进制形式的可执行代码,可以被操作系统加载到内存中执行。

Linux 下常见两类库:

类型 Linux Windows
静态库 .a .lib
动态库 .so .dll

例如 C 标准库、C++ 标准库在系统中通常都有静态库和动态库两种形态:

ls /lib/x86_64-linux-gnu/libc-*.so
ls /lib/x86_64-linux-gnu/libc.a

ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so
ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a

不同发行版路径可能不一样,但核心思想一致。

二、准备一套待封装代码

假设我们已经写了一套简化版 IO 接口和字符串接口。

头文件:

// my_stdio.h
#pragma once

#define SIZE 1024
#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2

struct IO_FILE {
    int flag;
    int fileno;
    char outbuffer[SIZE];
    int cap;
    int size;
};

typedef struct IO_FILE mFILE;

mFILE *mfopen(const char *filename, const char *mode);
int mfwrite(const void *ptr, int num, mFILE *stream);
void mfflush(mFILE *stream);
void mfclose(mFILE *stream);

再来一个字符串接口:

// my_string.h
#pragma once

int my_strlen(const char *s);

实现:

// my_string.c
#include "my_string.h"

int my_strlen(const char *s)
{
    const char *end = s;
    while (*end != '\0') {
        end++;
    }
    return end - s;
}

这些源文件本来可以直接参与项目编译,但如果想给别人复用,就可以打包成库。

三、静态库是什么

静态库通常以 .a 结尾。

它的特点是:

程序在编译链接时,会把静态库中需要的代码拷贝进最终可执行文件。程序运行时不再需要原来的静态库文件。

也就是说,静态库参与的是“链接期”。

如果最终可执行程序已经生成,即使删除 .a 文件,程序仍然可以运行。

这就是静态库最直观的特征。

四、制作静态库

静态库的制作步骤很简单:

  1. .c 编译成 .o
  2. ar 把多个 .o 归档成 .a

Makefile 可以这样写:

libmystdio.a: my_stdio.o my_string.o
	@ar -rc $@ $^
	@echo "build $^ to $@ ... done"

%.o: %.c
	@gcc -c $<
	@echo "compile $< 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 -rc libmystdio.a my_stdio.o my_string.o

其中:

  • ar:GNU 归档工具
  • r:replace,替换已有成员
  • c:create,不存在则创建

查看静态库内容:

ar -tv libmystdio.a

你会看到静态库里其实装的是多个 .o 文件。

所以静态库可以理解成:

静态库就是一组目标文件 .o 的归档包。

五、使用静态库

用户代码:

#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;
}

如果头文件和库都在当前目录:

gcc main.c -I. -L. -lmystdio

如果头文件和库在独立路径:

gcc main.c -I./stdc/include -L./stdc/lib -lmystdio

这里几个选项很重要:

  • -I:指定头文件搜索路径
  • -L:指定库文件搜索路径
  • -l:指定链接哪个库

库名规则也要注意:

libmystdio.a  -> -lmystdio
libc.so       -> -lc
libpthread.so -> -lpthread

也就是说,使用 -l 时要去掉前缀 lib 和后缀 .a/.so

六、静态库的几个关键结论

第一,静态库参与链接期。

它会把需要的目标代码合入可执行程序。

第二,静态库发布时一般要同时提供头文件。

头文件用于编译阶段,让编译器知道函数声明;库文件用于链接阶段,让链接器找到函数实现。

第三,链接完成后,程序运行不依赖原来的 .a 文件。

这就是为什么你删掉静态库后,可执行程序仍然能跑。

第四,gcc 默认优先动态链接。

如果同目录下同时存在 .so.a,通常优先链接 .so。如果想强制静态链接,可以使用:

gcc main.c -static -L. -lmystdio

七、动态库是什么

动态库通常以 .so 结尾。

它的特点是:

程序运行时才去加载和链接动态库,多个进程可以共享同一份动态库代码。

动态库参与的是“运行期”。

动态链接的可执行文件通常不会把库函数的完整机器码拷贝进自己内部,而是保存一套用于找到库函数的元信息和入口表。

运行时,动态链接器会把需要的 .so 加载到进程地址空间,并完成符号解析和地址重定位。

动态库的优势:

  • 可执行程序更小
  • 多个程序共享库代码,节省内存
  • 库升级更方便
  • 二进制级别复用能力更强

缺点也有:

  • 运行依赖 .so
  • 部署时要处理库搜索路径
  • 加载和符号解析有一定开销

八、制作动态库

动态库制作一般需要两个关键选项:

  • -fPIC
  • -shared

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"

核心命令:

gcc -fPIC -c my_stdio.c
gcc -fPIC -c my_string.c
gcc -shared -o libmystdio.so my_stdio.o my_string.o

选项解释:

  • -shared:生成共享库格式
  • -fPIC:生成位置无关代码,Position Independent Code
  • libxxx.so:动态库命名规则

-fPIC 非常关键,后面讲 GOT/PIC 时会解释它为什么存在。

九、使用动态库

编译链接命令和静态库很像:

gcc main.c -I./stdc/include -L./stdc/lib -lmystdio

但动态库有一个额外问题:编译通过不代表运行就能找到库。

查看依赖:

ldd a.out

可能会看到:

libmystdio.so => not found
libc.so.6 => /lib64/libc.so.6
/lib64/ld-linux-x86-64.so.2

这说明链接阶段找到了库,但运行阶段动态链接器没找到这个 .so

这是很多人第一次用动态库时踩的坑。

十、动态库运行时搜索路径

解决动态库 not found,常见有几种方案。

第一种,把 .so 拷贝到系统库路径:

/usr/lib
/usr/local/lib
/lib64

第二种,在系统库路径下建立软链接。

第三种,设置环境变量:

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/your/lib/path

第四种,配置 ldconfig

sudo vim /etc/ld.so.conf.d/mystdio.conf

写入:

/your/lib/path

然后执行:

sudo ldconfig

ldconfig 会更新动态库缓存,动态链接器后续可以通过缓存更快找到库。

开发测试时,LD_LIBRARY_PATH 很方便;生产部署时,更推荐规范安装到系统库路径或使用 ldconfig 管理。

十一、使用外部库:以 ncurses 为例

系统中有很多外部库,比如处理终端界面的 ncurses

安装:

# CentOS
sudo yum install -y ncurses-devel

# Ubuntu
sudo apt install -y libncurses-dev

代码里引入:

#include <ncurses.h>

编译时链接:

gcc main.c -lncurses

这和我们自己写 libmystdio.so 的使用方式完全一致。

所以学会自己制作库后,再使用第三方库就会顺很多,因为本质都是:

头文件解决编译期声明问题
库文件解决链接期或运行期实现问题

十二、目标文件:.o 不是临时垃圾

很多人把 .o 文件当成编译过程中的临时文件,其实它非常重要。

例如:

// hello.c
#include <stdio.h>

void run();

int main()
{
    printf("hello world!\n");
    run();
    return 0;
}
// code.c
#include <stdio.h>

void run()
{
    printf("running...\n");
}

分别编译:

gcc -c hello.c
gcc -c code.c

得到:

hello.o
code.o

使用 file 查看:

file hello.o

可能输出:

hello.o: ELF 64-bit LSB relocatable

这说明 .o 是 ELF 格式的可重定位文件。

它不是最终可执行程序,但它已经包含机器码、符号表、重定位信息等内容。

十三、ELF 文件的几种形态

ELF 是 Linux 下非常核心的二进制文件格式。

常见 ELF 文件包括:

  • 可重定位文件:.o
  • 可执行文件:如 a.out
  • 共享目标文件:.so
  • core dump:进程崩溃时保存的上下文

也就是说:

.o 是 ELF
a.out 是 ELF
.so 也是 ELF
core 还是 ELF

只是它们在 ELF Header 中的类型不同。

使用:

readelf -h hello.o
readelf -h a.out

可以查看 ELF Header。

十四、ELF 的基本结构

一个 ELF 文件大致包含:

  • ELF Header
  • Program Header Table
  • Section Header Table
  • Sections

ELF Header 描述整个文件的基本信息,比如:

  • 文件类型
  • 目标机器架构
  • 入口地址
  • 程序头表偏移
  • 节头表偏移
  • 表项大小和数量

常用命令:

readelf -h a.out

Section 是链接视角下的基本单位,常见有:

  • .text:代码段,保存机器指令
  • .data:已初始化的全局变量、静态变量
  • .bss:未初始化的全局变量、静态变量
  • .rodata:只读数据,比如字符串常量
  • .symtab:符号表
  • .got:全局偏移表
  • .plt:过程链接表

查看 section:

readelf -S a.out

十五、Section 和 Segment 的区别

这是理解 ELF 的关键。

Section 是链接视图。

它给链接器看,粒度比较细,用来描述代码、数据、符号、重定位表等不同功能区域。

Segment 是执行视图。

它给操作系统加载器看,告诉操作系统哪些内容应该加载到内存,加载到哪里,权限是什么。

查看 segment:

readelf -l a.out

你会看到 LOAD 段,以及 section 到 segment 的映射关系。

为什么要把多个 section 合并成 segment?

因为内存管理通常以页为单位,比如 4KB。如果每个小 section 单独加载,会造成大量页碎片。链接器会把权限相同、加载属性相同的 section 合并成 segment。

例如:

.text + .rodata + .plt -> 可读可执行 segment
.data + .bss + .got    -> 可读可写 segment

所以:

Section 面向链接,Segment 面向加载。

十六、静态链接到底在做什么

先看 .o 文件的反汇编:

objdump -d hello.o

你可能看到类似:

callq  14 <main+0x14>
callq  1e <main+0x1e>

某些 call 后面的地址暂时是 00 00 00 00

为什么?

因为编译 hello.c 时,编译器不知道 run() 的最终地址,也不知道 printf() 最终在哪里。

对当前 .o 来说,run 是外部符号。

查看符号表:

readelf -s hello.o

可能看到:

UND run
UND puts

UND 就是 undefined,表示本目标文件中找不到定义。

链接器的工作就是:

  1. 合并多个 .o 的 section
  2. 查符号表,找到未定义符号的真正定义
  3. 根据重定位表修正 call/jmp/global variable 等地址
  4. 生成最终可执行文件

链接后再看:

readelf -s main.exe

会发现 run 已经有了确定地址。

所以静态链接的核心就是:

把多个目标文件和静态库中的目标文件合并,并对外部符号做地址重定位。

十七、静态库为什么本质上也是 .o 链接

静态库 .a 里装的是多个 .o

当程序链接静态库时,链接器会从 .a 中取出需要的 .o,和用户自己的 .o 一起进行链接。

所以:

main.o + code.o + libxxx.a 中需要的 .o -> main.exe

静态库并没有改变链接本质。

它只是把一堆 .o 打包起来,方便复用和分发。

十八、ELF 加载与进程地址空间

一个 ELF 文件在没有加载到内存之前,有没有地址?

有。

ELF 文件中已经记录了虚拟地址或者说逻辑地址。

使用:

objdump -d a.out
readelf -h a.out
readelf -l a.out

都能看到入口地址、段虚拟地址、段大小等信息。

程序加载时,操作系统会根据 ELF Program Header Table,把需要加载的 segment 映射到进程地址空间中。

进程的 mm_structvm_area_struct 中各段范围,也可以根据 ELF 的 segment 信息初始化。

所以:

虚拟地址机制不只是操作系统支持,编译器和链接器也要参与。

ELF 里记录了未来程序如何布局,操作系统加载时再把这些布局落到进程地址空间中。

十九、动态链接为什么是默认选择

现在绝大多数程序默认使用动态链接。

原因很现实。

静态链接会把所有需要的代码合并到可执行文件中。这样虽然独立,但会导致:

  • 文件体积变大
  • 多个程序重复携带相同库代码
  • 内存浪费
  • 库升级困难

动态链接把公共代码放到 .so 中,程序运行时加载。

多个进程可以共享同一份动态库的只读代码段,节省磁盘和内存。

这也是为什么 ldd 一个普通程序,通常能看到它依赖 libc.so.6

ldd hello

输出类似:

linux-vdso.so.1
libc.so.6 => /lib64/libc.so.6
/lib64/ld-linux-x86-64.so.2

其中 ld-linux-x86-64.so.2 就是动态链接器。

二十、程序不是一上来就执行 main

C/C++ 程序并不是一启动就进入 main()

真正入口通常是 _start

_start 由 C 运行时库或链接器提供,负责一系列初始化工作,比如:

  • 设置初始栈环境
  • 初始化数据段
  • 清理 BSS
  • 触发动态链接器加载依赖库
  • 调用 __libc_start_main
  • 最后由 __libc_start_main 调用用户写的 main
  • main 返回后,再处理退出逻辑

也就是说:

_start -> __libc_start_main -> main -> exit

动态链接发生在 main 之前。

这也是为什么程序还没执行到用户代码,动态库找不到就已经报错了。

二十一、动态库如何进入进程地址空间

动态库也是文件。

要使用动态库,本质上要先找到这个 .so 文件,然后把它加载并映射到当前进程地址空间中。

流程大致是:

启动可执行程序
    |
    v
动态链接器读取依赖信息
    |
    v
查找 .so 文件
    |
    v
把 .so 映射到进程地址空间
    |
    v
完成符号解析和重定位
    |
    v
调用 main

由于每个进程的地址空间情况不同,同一个动态库在不同进程中的虚拟地址可能不同。

这就引出一个问题:

如果动态库加载地址不固定,库中的函数调用地址怎么写死?

答案是:不能写死,所以需要位置无关代码。

二十二、为什么动态库需要 -fPIC

-fPIC 表示生成位置无关代码。

动态库可能被映射到任意虚拟地址,如果代码中写死绝对地址,就会导致每个进程加载后都要修改代码段。

但代码段通常是只读的,而且多个进程要共享动态库代码段。如果每个进程都修改代码段,就无法共享了。

所以动态库要尽量做到:

不管被加载到哪里,都能正常运行。

这就是 PIC。

PIC 的核心思路是:

  • 代码使用相对寻址
  • 需要变化的绝对地址放到可写区域
  • 通过表间接访问函数或全局变量

这张表就是 GOT。

二十三、GOT:全局偏移表

GOT 全称是 Global Offset Table,全局偏移表。

动态链接中,代码段不能随便修改,但 .data 这类数据段是可读写的。

于是系统采用一种间接方案:

代码不直接写死函数地址,而是通过 GOT 表读取真正地址。

GOT 表中每一项保存某个外部函数或全局变量的实际地址。

动态库加载后,动态链接器会把 GOT 中的条目修正为真实地址。

调用函数时,代码通过 GOT 间接跳转。

这样好处很明显:

  • .text 代码段可以保持只读
  • 代码段可以被多个进程共享
  • 每个进程可以拥有自己的 GOT
  • 动态库可以加载到任意地址

所以:

PIC = 相对寻址 + GOT。

这也是动态库编译时需要 -fPIC 的根本原因。

二十四、PLT:延迟绑定的入口

如果程序启动时把所有动态库函数都解析一遍,会有明显开销。

但很多函数可能程序运行期间根本不会调用。

于是系统引入延迟绑定,也就是 PLT。

PLT 全称 Procedure Linkage Table,过程链接表。

大致思路:

  1. 第一次调用某个库函数时,先跳到 PLT 桩代码。
  2. PLT 触发动态链接器查找真正函数地址。
  3. 动态链接器更新 GOT。
  4. 下一次再调用该函数时,直接从 GOT 跳到真实函数。

可以用反汇编看到:

objdump -d a.out

可能出现:

callq puts@plt
jmpq *GOT_ENTRY

所以:

GOT 保存地址,PLT 负责首次解析和跳转流程。

二十五、库和库之间也会依赖

动态库不只是被可执行程序调用。

动态库也可以依赖其他动态库。

比如:

a.out -> libA.so -> libB.so -> libc.so

这些库本质上都是 ELF 文件,因此它们都可以有:

  • ELF Header
  • Program Header
  • Section Header
  • .text
  • .data
  • .got
  • .plt

动态链接器解析依赖关系时,会递归加载依赖库,并完善各自 GOT 表。

这也是为什么动态库之间依然可以保持位置无关和运行时链接。

二十六、静态链接和动态链接对比

对比项 静态链接 动态链接
链接时机 编译链接期 程序加载/运行期
库代码位置 合入可执行文件 保留在 .so
可执行文件大小 较大 较小
运行依赖 不依赖原 .a 依赖 .so
内存占用 多程序可能重复 多进程可共享库代码
更新维护 更新库需重新链接 可替换动态库
核心动作 编译期重定位 运行期重定位

静态链接像是把所有零件焊死进最终机器。

动态链接像是运行时把公共模块挂接进来。

两者没有绝对优劣,只有使用场景差异。

二十七、常用排查命令

查看文件类型:

file hello.o
file a.out
file libmystdio.so

查看动态库依赖:

ldd a.out
ldd libmystdio.so

查看 ELF Header:

readelf -h a.out

查看 Section Header Table:

readelf -S a.out

查看 Program Header Table:

readelf -l a.out

查看符号表:

readelf -s hello.o
nm hello.o

反汇编:

objdump -d a.out
objdump -S a.out

查看静态库成员:

ar -tv libmystdio.a

这些命令不是“装高级”,而是排查链接错误、库路径错误、符号未定义、动态库依赖问题的基本工具。

总结

库的本质是可复用的二进制代码。静态库 .a 是一组 .o 文件的归档,程序在链接期会把需要的目标代码合入可执行文件;动态库 .so 在运行期由动态链接器加载和映射,多个进程可以共享库代码。

制作静态库的核心是 gcc -c 生成 .o,再用 ar -rc 打包成 .a。制作动态库的核心是 gcc -fPIC -c 生成位置无关目标文件,再用 gcc -shared 生成 .so。使用库时,-I 指定头文件路径,-L 指定库路径,-l 指定库名。

进一步看,.o、可执行程序、.so 都是 ELF 文件。ELF 有链接视图和执行视图:Section 面向链接器,Segment 面向操作系统加载器。静态链接本质是合并 .o 并根据符号表和重定位表修正外部符号地址;动态链接则把符号解析和地址重定位推迟到程序加载和运行阶段。

动态库之所以需要 -fPIC,是因为它可能被映射到任意进程地址空间。通过相对寻址、GOT 和 PLT,动态库能够保持代码段只读共享,同时让每个进程拥有自己的运行时地址表。

一句话收尾:

静态库解决的是“把代码合进来”,动态库解决的是“运行时把代码映射进来”,ELF、符号表、重定位、GOT/PLT 则是这套机制能够跑起来的底层骨架。

理解到这一层,再看 gcc -L -lldd not foundreadelf -Sobjdump -d,就不是在背命令,而是在观察 Linux 程序从源码走向进程的真实路径。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐