Linux 库制作与原理:静态库、动态库、ELF 与动态链接完全指南
🧑💻 博主主页 · 学代码的CJY
一个从零开始学代码的 Linux 学习者,专注 Linux 系统编程与运维实战,
也在持续探索 Python、数据库、前端与高效开发工具。
坚持"边学边写",把每一篇博客都当作学习的沉淀,
用最通俗的语言把复杂的原理讲清楚,和读者一起成长。
📚 个人专栏传送门:
专栏导航 收录内容 《Linux 篇》 Linux 系统编程与运维实战(8篇)🔥 《Python 篇》 Python 入门与基础语法 《开发工具篇》 Claude Code、MinGW 等效率工具 《数据库篇》 MySQL 安装与实战 《前端基础篇》 HTML 核心要点与代码示例 《计算机网络篇》 网络基础与知识小结 《文件系统篇》 Ext 文件系统深度解析 🎯 座右铭:
路虽远,行则将至;事虽难,做则必成。
📌 关于我:
码龄 154 天 · 16 篇原创 · 获赞 345 · 收藏 186 · 粉丝 155
欢迎关注,一起在代码的世界里乘风破浪!
本博客基于《Linux 系统编程》课程第 7 章《库制作与原理》整理总结,并适当补充了示例代码与图解,帮助大家从"会用"到"懂原理"。
文章目录
- [Linux 库制作与原理:静态库、动态库、ELF 与动态链接完全指南](#Linux 库制作与原理:静态库、动态库、ELF 与动态链接完全指南)
-
- [🧑💻 博主主页 · 学代码的CJY](#🧑💻 博主主页 · 学代码的CJY)
- 一、为什么要学习"库"?
- 二、动手准备:封装一个"迷你库"
-
- [2.1 头文件 my_stdio.h](#2.1 头文件 my_stdio.h)
- [2.2 实现文件 my_stdio.c](#2.2 实现文件 my_stdio.c)
- [2.3 字符串工具 my_string](#2.3 字符串工具 my_string)
- 三、静态库(.a)的制作与使用
-
- [3.1 什么是静态库](#3.1 什么是静态库)
- [3.2 静态库的生成](#3.2 静态库的生成)
- [3.3 静态库的使用](#3.3 静态库的使用)
- 四、动态库(.so)的制作与使用
-
- [4.1 什么是动态库](#4.1 什么是动态库)
- [4.2 动态库的生成](#4.2 动态库的生成)
- [4.3 动态库的使用](#4.3 动态库的使用)
- [4.4 运行时"找不到库"?------ 动态库的搜索路径](#4.4 运行时"找不到库"?—— 动态库的搜索路径)
- [五、进阶示例:使用外部库 ncurses 做彩色进度条](#五、进阶示例:使用外部库 ncurses 做彩色进度条)
-
- [5.1 安装 ncurses](#5.1 安装 ncurses)
- [5.2 示例:带边框的彩色进度条](#5.2 示例:带边框的彩色进度条)
- [六、深入原理:目标文件与 ELF 格式](#六、深入原理:目标文件与 ELF 格式)
-
- [6.1 目标文件(.o)](#6.1 目标文件(.o))
- [6.2 ELF 文件的四种类型](#6.2 ELF 文件的四种类型)
- [6.3 ELF 文件的四大部分](#6.3 ELF 文件的四大部分)
- [6.4 链接视图 vs 执行视图](#6.4 链接视图 vs 执行视图)
- [6.5 链接视图下常见的节](#6.5 链接视图下常见的节)
- [6.6 查看 ELF 信息的利器:readelf / objdump / file](#6.6 查看 ELF 信息的利器:readelf / objdump / file)
- [七、静态链接原理:.o 是怎么拼成可执行文件的](#七、静态链接原理:.o 是怎么拼成可执行文件的)
-
- [7.1 反汇编:外部的 call 地址为什么是 0?](#7.1 反汇编:外部的 call 地址为什么是 0?)
- [7.2 链接到底做了什么?](#7.2 链接到底做了什么?)
- [7.3 一句话总结静态链接](#7.3 一句话总结静态链接)
- [八、ELF 加载与进程虚拟地址空间](#八、ELF 加载与进程虚拟地址空间)
-
- [8.1 程序还没加载进内存时,有地址吗?](#8.1 程序还没加载进内存时,有地址吗?)
- [8.2 进程地址空间的数据从哪里来?](#8.2 进程地址空间的数据从哪里来?)
- [8.3 入口点(Entry Point)](#8.3 入口点(Entry Point))
- 九、动态链接与动态库加载
-
- [9.1 为什么默认使用动态链接?](#9.1 为什么默认使用动态链接?)
- [9.2 程序启动流程:main 之前发生了什么?](#9.2 程序启动流程:main 之前发生了什么?)
- [9.3 动态库的相对地址:PIC 与映射](#9.3 动态库的相对地址:PIC 与映射)
- [9.4 全局偏移表 GOT(Global Offset Table)](#9.4 全局偏移表 GOT(Global Offset Table))
- [9.5 延迟绑定:PLT(Procedure Linkage Table)](#9.5 延迟绑定:PLT(Procedure Linkage Table))
- [十、总结:静态链接 vs 动态链接](#十、总结:静态链接 vs 动态链接)
- 附录:命令操作大全
一、为什么要学习"库"?
写代码的时候,几乎不可能每个功能都从零开始。现实中的每个程序都要依赖很多基础的底层库,库就是写好的、成熟的、可以复用的代码。
本质上,库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库分两种:
| 类型 | Linux | Windows | 特点 |
|---|---|---|---|
| 静态库 | .a |
.lib |
编译链接时把库代码链接进可执行文件中,运行时不再需要库 |
| 动态库 | .so |
.dll |
程序运行时才加载链接,多个程序共享同一份库代码 |
先来看看系统里 C / C++ 标准库的动静库长什么样(Ubuntu 为例):
bash
# C 标准库:libc 同时提供了动态库和静态库
$ ls -l /lib/x86_64-linux-gnu/libc-2.31.so
-rwxr-xr-x 1 root root 2029592 May 1 02:20 /lib/x86_64-linux-gnu/libc-2.31.so
$ ls -l /lib/x86_64-linux-gnu/libc.a
-rw-r--r-- 1 root root 5747594 May 1 02:20 /lib/x86_64-linux-gnu/libc.a
# C++ 标准库:动态库往往是一个软链接,指向带版本号的真实文件
$ ls -l /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so
lrwxrwxrwx 1 root root 40 Oct 24 2022 libstdc++.so -> ../../../x86_64-linux-gnu/libstdc++.so.6
💡 小知识:动态库
libstdc++.so通常只是个软链接 ,真正的文件是libstdc++.so.6、libstdc++.so.6.0.x。这种"主版本号"命名方式是为了在升级库时保持向后兼容。
二、动手准备:封装一个"迷你库"
为了演示库的制作,我们先准备两个历史封装好的模块:简易文件 IO(模仿 fopen/fwrite/fflush/fclose)和字符串工具。后面就以它们为原料,分别打包成静态库和动态库。
2.1 头文件 my_stdio.h
c
// 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);
2.2 实现文件 my_stdio.c
c
// my_stdio.c
#include "my_stdio.h"
#include <string.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
mFILE *mfopen(const char *filename, const char *mode)
{
int fd = -1;
if (strcmp(mode, "r") == 0)
{
fd = open(filename, O_RDONLY);
}
else if (strcmp(mode, "w") == 0)
{
fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
}
else if (strcmp(mode, "a") == 0)
{
fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
}
if (fd < 0) return NULL;
mFILE *mf = (mFILE *)malloc(sizeof(mFILE));
if (!mf)
{
close(fd);
return NULL;
}
mf->fileno = fd;
mf->flag = FLUSH_LINE;
mf->size = 0;
mf->cap = SIZE;
return mf;
}
void mfflush(mFILE *stream)
{
if (stream->size > 0)
{
// 写到内核文件的缓冲区中
write(stream->fileno, stream->outbuffer, stream->size);
// 刷新到外设
fsync(stream->fileno);
stream->size = 0;
}
}
int mfwrite(const void *ptr, int num, mFILE *stream)
{
// 1. 拷贝到用户级缓冲区
memcpy(stream->outbuffer + stream->size, ptr, num);
stream->size += num;
// 2. 检测是否需要刷新(行缓冲:遇到 \n 就刷)
if (stream->flag == FLUSH_LINE && stream->size > 0 &&
stream->outbuffer[stream->size - 1] == '\n')
{
mfflush(stream);
}
return num;
}
void mfclose(mFILE *stream)
{
if (stream->size > 0)
{
mfflush(stream);
}
close(stream->fileno);
}
2.3 字符串工具 my_string
c
// my_string.h
#pragma once
int my_strlen(const char *s);
c
// 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)的制作与使用
3.1 什么是静态库
静态库(.a):程序在编译链接的时候,把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。
几个关键结论:
- 一个可执行程序可能用到很多库,有的静态、有的动态;
- gcc 默认使用动态链接 ,只有在该库下找不到动态
.so时,才会采用同名静态库; - 我们也可以使用
gcc -static强制链接静态库。
3.2 静态库的生成
静态库本质上是把多个 .o 目标文件用 ar 归档工具打包在一起。
bash
# 1. 先把源文件编译成目标文件
gcc -c my_stdio.c
gcc -c my_string.c
# 2. 用 ar 打包成静态库(r=replace 替换,c=create 创建)
ar -rc libmystdio.a my_stdio.o my_string.o
# 3. 查看静态库里的文件
# t:列出库中的文件;v:显示详细信息(verbose)
$ ar -tv libmystdio.a
rw-rw-r-- 1000/1000 2848 Oct 29 14:35 2024 my_stdio.o
rw-rw-r-- 1000/1000 1272 Oct 29 14:35 2024 my_string.o
ar是 GNU 归档工具,-rc中r表示 replace(替换同名成员),c表示 create(创建新库)。
工程中通常用 Makefile 一键完成编译、打包、发布:
makefile
# Makefile
libmystdio.a:my_stdio.o my_string.o
@ar -rc $@ $^
@echo "build $^ to $@ ... done"
%.o:%.c
@gcc -c $<
@echo "compling $< 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"
make output 会把头文件和库文件整理成 stdc/include、stdc/lib 的标准目录结构,并打成压缩包 stdc.tgz,方便分发。
3.3 静态库的使用
写一个测试程序 main.c,引入我们的库:
c
// 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;
}
使用库时有三种典型场景:
bash
# 场景1:头文件和库文件已安装到系统路径下
$ gcc main.c -lmystdio
# 场景2:头文件和库文件与源文件在同一目录下
$ gcc main.c -L. -lmystdio
# 场景3:头文件和库文件有自己独立的路径
$ gcc main.c -I头文件路径 -L库文件路径 -lmystdio
三个关键编译选项:
| 选项 | 作用 |
|---|---|
-I |
指定头文件搜索路径(Include) |
-L |
指定库文件搜索路径(Library) |
-l |
指定库名 (去掉前缀 lib、去掉后缀 .a/.so) |
💡 库名规则:
-l后面跟的是库的"逻辑名"。例如系统库libc.so,用-lc即可;我们自己的libmystdio.a,用-lmystdio。
验证静态链接:目标文件生成后,把静态库删掉,程序照样可以运行------因为库代码已经被"焊"进了可执行文件里。
四、动态库(.so)的制作与使用
4.1 什么是动态库
动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
关键结论:
- 一个与动态库链接的可执行文件,仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码;
- 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的动态库中复制到内存 ,这个过程称为动态链接(dynamic linking);
- 操作系统采用虚拟内存机制,允许物理内存中的一份动态库被所有需要它的进程共享,从而节省内存和磁盘空间,也使得可执行文件更小。
4.2 动态库的生成
makefile
# Makefile
libmystdio.so:my_stdio.o my_string.o
gcc -o $@ $^ -shared
%.o:%.c
gcc -fPIC -c $<
两个关键参数:
| 参数 | 作用 |
|---|---|
-shared |
生成共享库格式 |
-fPIC |
产生位置无关码(position independent code) |
💡 为什么动态库要
-fPIC?因为动态库的加载地址不固定,可能被映射到任意进程的任意位置。位置无关码保证代码无论加载到哪个地址都能正常运行,这也是后面 GOT 机制的基础(PIC = 相对编址 + GOT)。
4.3 动态库的使用
使用方式和静态库完全一样:
bash
# 场景1:库安装到系统路径
$ gcc main.c -lmystdio
# 场景2:库与源文件同目录(-L 指定从左到右搜索目录)
$ gcc main.c -L. -lmystdio
# 场景3:独立路径
$ gcc main.c -I头文件路径 -L库文件路径 -lmystdio
可以用 ldd 查看库或可执行程序的依赖:
bash
$ ldd libmystdio.so
linux-vdso.so.1 => (0x00007fffacbbf000)
libc.so.6 => /lib64/libc.so.6 (0x00007f8917335000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8917905000)
4.4 运行时"找不到库"?------ 动态库的搜索路径
编译、链接都成功了,可运行 ./a.out 却报错:
bash
$ ./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)
原因 :编译时我们用 -L 告诉编译器去哪找库,但运行时 的搜索是由动态链接器 (ld-linux.so)负责的,它默认只在系统路径里找,并不知道我们自定义库的位置。
四种解决方案:
bash
# 方案1:把 .so 拷贝到系统共享库路径(/usr/lib、/usr/local/lib、/lib64)
sudo cp libmystdio.so /usr/lib/
# 方案2:在系统共享库路径下建立同名软链接
sudo ln -s $(pwd)/libmystdio.so /usr/lib/libmystdio.so
# 方案3:修改环境变量 LD_LIBRARY_PATH(仅当前 shell 生效,可写入 ~/.bashrc)
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$(pwd)
./a.out
# 方案4:ldconfig 方案(推荐,一劳永逸)
# 在 /etc/ld.so.conf.d/ 下新建配置文件,写入库所在目录
$ cat /etc/ld.so.conf.d/bit.conf
/root/tools/linux
# 重新加载库搜索路径
$ sudo ldconfig
💡
ldconfig会更新/etc/ld.so.cache缓存,动态链接器加载动态库时会优先搜索这个缓存文件,所以修改配置后必须执行ldconfig才会生效。
五、进阶示例:使用外部库 ncurses 做彩色进度条
平时我们用得最多的库是 C/C++ 标准库,其实系统里还有很多有意思的库,比如用于处理屏幕显示的函数集合------ncurses 库。
5.1 安装 ncurses
bash
# CentOS
$ sudo yum install -y ncurses-devel
# Ubuntu
$ sudo apt install -y libncurses-dev
5.2 示例:带边框的彩色进度条
c
#include <stdio.h>
#include <string.h>
#include <ncurses.h>
#include <unistd.h>
#define PROGRESS_BAR_WIDTH 30
#define BORDER_PADDING 2
#define WINDOW_WIDTH (PROGRESS_BAR_WIDTH + 2 * BORDER_PADDING + 2) // 加边框的宽度
#define WINDOW_HEIGHT 5
#define PROGRESS_INCREMENT 3
#define DELAY 300000 // 微秒(300毫秒)
int main()
{
initscr();
start_color();
init_pair(1, COLOR_GREEN, COLOR_BLACK); // 已完成部分:绿色前景,黑色背景
init_pair(2, COLOR_RED, COLOR_BLACK); // 剩余部分:红色背景(仅演示用)
cbreak();
noecho();
curs_set(FALSE);
int max_y, max_x;
getmaxyx(stdscr, max_y, max_x);
int start_y = (max_y - WINDOW_HEIGHT) / 2;
int start_x = (max_x - WINDOW_WIDTH) / 2;
WINDOW *win = newwin(WINDOW_HEIGHT, WINDOW_WIDTH, start_y, start_x);
box(win, 0, 0); // 加边框
wrefresh(win);
int progress = 0;
int max_progress = PROGRESS_BAR_WIDTH;
while (progress <= max_progress)
{
werase(win); // 清除窗口内容
int completed = progress;
int remaining = max_progress - progress;
int bar_x = BORDER_PADDING + 1; // 进度条在窗口中的 x 坐标
int bar_y = 1; // 进度条在窗口中的 y 坐标(居中)
// 已完成部分
attron(COLOR_PAIR(1));
for (int i = 0; i < completed; i++)
mvwprintw(win, bar_y, bar_x + i, "#");
attroff(COLOR_PAIR(1));
// 剩余部分(用背景色填充)
attron(A_BOLD | COLOR_PAIR(2));
for (int i = completed; i < max_progress; i++)
mvwprintw(win, bar_y, bar_x + i, " ");
attroff(A_BOLD | COLOR_PAIR(2));
// 显示百分比
char percent_str[10];
snprintf(percent_str, sizeof(percent_str), "%d%%",
(progress * 100) / max_progress);
int percent_x = (WINDOW_WIDTH - strlen(percent_str)) / 2;
mvwprintw(win, WINDOW_HEIGHT - 1, percent_x, percent_str);
wrefresh(win);
progress += PROGRESS_INCREMENT;
usleep(DELAY);
}
// 清理并退出 ncurses 模式
delwin(win);
endwin();
return 0;
}
编译链接时记得加 -lncurses:
bash
$ gcc progress.c -o progress -lncurses
$ ./progress
至此,"库的制作与使用"就已经完全掌握了。接下来是本文的重头戏------原理篇,带大家从 ELF 文件格式出发,彻底搞懂静态链接和动态链接到底做了什么。
六、深入原理:目标文件与 ELF 格式
6.1 目标文件(.o)
编译和链接这两个步骤,在 Windows 下被 IDE 封装得很完美,一键构建非常方便。但一旦遇到链接相关的错误,很多人就束手无策了。在 Linux 下,理解编译和链接的整个过程,能帮助我们更好地理解动静态库的使用原理。
编译:将程序源代码翻译成 CPU 能够直接运行的机器代码。
bash
# hello.c 调用了一个定义在 code.c 里的 run 函数
$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c code.o hello.c hello.o
编译之后会生成两个扩展名为 .o 的目标文件。修改了哪个源文件,就只需要单独编译它那一个,而不用重新编译整个工程。
目标文件是二进制文件,格式为 ELF(Executable and Linkable Format),是对二进制代码的一种封装:
bash
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
6.2 ELF 文件的四种类型
| 类型 | 说明 | 典型文件 |
|---|---|---|
| 可重定位文件(Relocatable File) | 包含适合与其他目标文件链接来创建可执行文件或共享目标文件的代码和数据 | xxx.o |
| 可执行文件(Executable File) | 即可执行程序 | a.out |
| 共享目标文件(Shared Object File) | 即动态库 | xxx.so |
| 内核转储(core dumps) | 存放当前进程的执行上下文,用于 dump 信号触发 | core |
6.3 ELF 文件的四大部分
一个 ELF 文件由以下四部分组成:
┌─────────────────────────────────┐
│ ELF Header(ELF头) │ ← 描述文件主要特性,位于文件开始,定位其他部分
├─────────────────────────────────┤
│ Program header table(程序头表) │ ← 列举所有有效的段(segments)和它们的属性
├─────────────────────────────────┤
│ Section header table(节头表) │ ← 包含对节(sections)的描述
├─────────────────────────────────┤
│ Sections(节) │ ← 基本组成单位,代码节、数据节等
└─────────────────────────────────┘
- ELF Header :位于文件开始位置,主要目的是定位文件的其他部分;
- Program header table(程序头表):列举了所有有效的段(segments)和它们的属性,记录每个段的起始位置、位移(offset)和长度。因为段都是紧密放在二进制文件中的,需要段表的描述信息才能把每个段分割开;
- Section header table(节头表):包含对节(sections)的描述;
- Sections(节):ELF 文件的基本组成单位,如代码节存放可执行代码,数据节存放全局变量和静态数据。
6.4 链接视图 vs 执行视图
ELF 文件提供 2 个不同的视图,分别对应程序头表和节头表:
| 视图 | 对应表 | 作用 |
|---|---|---|
| 链接视图(Linking view) | Section header table(节头表) | 文件结构粒度更细,按功能模块划分,静态链接分析时关注 |
| 执行视图(Execution view) | Program header table(程序头表) | 告诉操作系统如何加载可执行文件、完成进程内存初始化,运行时使用 |
一句话总结:一个在链接时起作用,一个在运行加载时起作用。
为什么要将 section 合并成 segment?
- 减少页面碎片,提高内存使用效率 :假设页面大小为 4096 字节(内存块基本大小,也是加载、管理的基本单位),如果
.text部分是 4097 字节,.init部分是 512 字节,那么它们将占用 3 个页面;而合并后只需 2 个页面; - 相同属性合并成一个大 segment,便于权限控制:操作系统加载程序时,会把具有相同属性的 section 合并成一个大 segment,实现不同的访问权限(可读、可写、可执行),从而优化内存管理和权限访问控制。
6.5 链接视图下常见的节
| 节 | 作用 |
|---|---|
.text |
保存程序代码指令的代码节 |
.data |
保存已初始化的全局变量和局部静态变量等数据 |
.rodata |
保存只读数据,如 C 代码中的字符串字面量(只能存在于只读段中) |
.bss |
为未初始化的全局变量和局部静态变量预留位置 |
.symtab |
符号表(Symbol Table),即源码里的函数名、变量名与代码的对应关系 |
.got.plt |
全局偏移表-过程链接表,.got 和 .plt 一起提供对导入的共享库函数的访问入口 |
6.6 查看 ELF 信息的利器:readelf / objdump / file
bash
# 查看 ELF 头(-h 或 --file-header)
$ readelf -h hello.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 # ELF 魔数
Class: ELF64 # 64位架构
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 # 目标文件入口为 0
Start of program headers: 0 (bytes into file) # 无程序头表
Start of section headers: 728 (bytes into file) # 节头表偏移
Number of section headers: 13 # 13 个节
对比一下可执行文件 a.out:
bash
$ gcc *.o
$ readelf -h a.out
Type: DYN (Shared object file) # 现代 gcc 默认生成 PIE
Entry point address: 0x1060 # 程序入口
Start of program headers: 64 (bytes into file)
Number of program headers: 13 # 有程序头表了
Number of section headers: 31
常用命令速查:
| 命令 | 作用 |
|---|---|
file 文件 |
辨识文件类型 |
readelf -h |
查看 ELF 文件头 |
readelf -l |
查看程序头表(段表,执行视图) |
readelf -S |
查看节头表(节表,链接视图) |
objdump -d |
反汇编代码段 |
objdump -S |
反汇编并混合显示源码 |
ldd 文件 |
打印程序或库所依赖的共享库列表 |
七、静态链接原理:.o 是怎么拼成可执行文件的
7.1 反汇编:外部的 call 地址为什么是 0?
bash
$ objdump -d hello.o
Disassembly of section .text:
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> # ← call printf,地址为 0!
...
19: e8 00 00 00 00 callq 1e <main+0x1e> # ← call run,地址为 0!
为什么跳转地址都被设成了 0?
因为在编译 hello.c 的时候,编译器完全不知道 printf 和 run 函数的存在------不知道它们位于内存的哪个区块、代码长什么样,只能先把跳转地址临时设为 0。
这个地址会在链接的时候 被修正。为了让链接器将来能正确定位到这些需要被修正的地址,在代码块(.data)中还存在一张重定位表(relocation table),链接时链接器就会根据表里记录的地址将其修正。
7.2 链接到底做了什么?
bash
$ gcc *.o -o main.exe
最终的结果:
- 两个
.o的代码段(.text)合并到了一起,并进行统一编址 。比如hello.o和code.o的.text合并后,成为main.exe的某个 section; - 链接时,会修改
.o中没有确定的函数地址,在合并完成之后,进行相关 call 地址的修正,完成代码调用。
通过符号表可以验证:合并后,在最终的可执行程序中找到了 run 函数:
bash
$ readelf -s main.exe
# 截取关键行
52: 0000000000001149 23 FUNC GLOBAL DEFAULT 16 run
63: 0000000000001160 37 FUNC GLOBAL DEFAULT 16 main
0000000000001149是run函数在合并后代码段中的地址;FUNC表示该符号是一个函数;16表示它所在的 section 下标(即合并后的.text)。
7.3 一句话总结静态链接
静态链接就是把库中的 .o 进行合并 ------将编译之后的所有目标文件连同用到的静态库、运行时库组合、拼装成一个独立可执行文件。当所有模块组合在一起之后,链接器会根据 .o 文件或静态库中的重定位表,找到那些需要被重定位的函数和全局变量,从而修正它们的地址。
链接过程中会涉及到对
.o中外部符号进行地址重定位(也叫做静态重定位 / 编译重定位)。
八、ELF 加载与进程虚拟地址空间
8.1 程序还没加载进内存时,有地址吗?
有!
当代计算机工作的时候,都采用"平坦模式 "(flat model),因此要求 ELF 对自身的代码和数据进行统一编址 。反汇编之后(objdump -S),最左侧那一列就是 ELF 的虚拟地址------严格来说应该叫做逻辑地址(起始地址 + 偏移量),只不过我们通常认为起始地址是 0。
也就是说:虚拟地址在程序还没有被加载到内存的时候,就已经把可执行程序统一编址了。
8.2 进程地址空间的数据从哪里来?
- 进程的
mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据来自 ELF 的各个 segment; - 每个 segment 都有自己的起始地址和长度,用来初始化内核结构中的
[start, end]等范围数据,再用详细地址填充页表。
所以:虚拟地址机制,不光是操作系统要支持,编译器也要支持。
8.3 入口点(Entry Point)
ELF 编译好后,会把程序未来的入口地址记录在 ELF header 的 Entry 字段中:
bash
$ readelf -h a.out
ELF Header:
Entry point address: 0x1060 # 程序入口点虚拟地址
这个入口并不是 main,而是 _start,具体见下一章的启动流程。
九、动态链接与动态库加载
9.1 为什么默认使用动态链接?
静态链接会把编译产生的所有目标文件连同用到的各种库,合并形成一个独立可执行文件,不需要额外依赖就能运行,看起来更方便。那为什么默认不用它?
静态链接最大的问题在于:生成的文件体积大,并且相当耗费内存资源。
随着软件复杂度提升,操作系统越来越臃肿,不同的软件可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。
而动态链接的优势:
- 将需要共享的代码单独提取出来,保存成独立的动态链接库,程序运行时再加载到内存;
- 同一个模块在内存中只需保留一份副本,可以被不同的进程共享;
- 既节省磁盘空间,也节省内存空间,还极大方便了代码的更新和维护,实现了二进制级别的代码复用。
9.2 程序启动流程:main 之前发生了什么?
在 C/C++ 程序中,程序开始执行时,首先并不会直接跳转到 main 函数 。程序的入口点是 _start------一个由 C 运行时库(通常是 glibc)或链接器(如 ld)提供的特殊函数。
_start 中会执行一系列初始化操作:
_start
├─ 1. 设置堆栈:创建初始堆栈环境
├─ 2. 初始化数据段:复制初始化数据、清零未初始化数据(.bss)
├─ 3. 动态链接:调用动态链接器解析并加载依赖的动态库
│ · 动态链接器(ld-linux.so)负责运行时加载动态库
│ · 搜索路径:环境变量 LD_LIBRARY_PATH、配置文件 /etc/ld.so.conf
│ · 缓存:/etc/ld.so.cache(加速加载)
├─ 4. 调用 __libc_start_main:额外的初始化(信号处理、线程库等)
├─ 5. 调用 main:执行控制权正式交给用户代码
└─ 6. main 返回后:处理返回值,调用 _exit 终止程序
这些操作对大多数程序员是透明的,但理解底层细节有助于更好地理解程序的执行流程和调试问题。
9.3 动态库的相对地址:PIC 与映射
- 动态库为了随时可以加载、可以映射到任意进程的任意位置,对库中的方法统一采用相对编址(可执行程序也一样,都要遵守平坦模式,只不过 exe 是直接加载的);
- 动态库也是一个文件,要访问它就得先加载它,加载的本质就是文件操作 + 把库映射到进程的地址空间中;
- 一旦库的虚拟起始地址和库中每个方法的偏移量已知,访问库中任意方法 = 库的起始虚拟地址 + 方法偏移量;
- 整个调用过程:从代码区跳转到共享区,调用完毕再返回代码区,整个过程完全在进程地址空间中进行。
9.4 全局偏移表 GOT(Global Offset Table)
问题:代码区在进程中是只读的,运行时不能直接修改代码里的跳转地址;而动态库的加载地址又是不固定的,该怎么办?
动态链接的做法 :在可执行程序(或库自己)的 .data 区域(可读写的)中,专门预留一片区域用来存放函数的跳转地址,它就叫全局偏移表 GOT(Global Offset Table)。表中每一项都是本运行模块要引用的一个全局变量或函数的地址。
流程是这样的:
- 程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址提前知道;
- 对加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置 (称为加载地址重定位 / 动态地址重定位);
- 因为
.data区域是可读写的,所以可以支持这种运行时的修改,而代码区保持只读; - 库与库之间也有依赖:库中也有 GOT,解析依赖关系的过程,就是加载并完善互相之间 GOT 表的过程------这也是为什么大家(可执行程序和库)都是 ELF 格式的原因!
💡 PIC = 相对编址 + GOT。给编译器指定
-fPIC参数,就是为了生成位置无关的、能够被加载到任意地址并共享的代码。
9.5 延迟绑定:PLT(Procedure Linkage Table)
动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然非常耗时。为了进一步降低开销,操作系统做了优化------延迟绑定(lazy binding) ,也叫 PLT(过程链接表)。
思路:
- 与其在程序一开始就对所有函数进行重定位,不如把过程推迟到函数第一次被调用的时候------因为绝大多数动态库中的函数,可能在程序运行期间一次都不会被使用到;
- GOT 中的跳转地址默认会指向一段辅助代码,叫做桩代码(stub);
- 第一次调用函数时,这段桩代码会负责查询真正函数的跳转地址,并更新 GOT 表;
- 于是再次调用该函数时,就会直接跳转到动态库中真正的函数实现,不再走桩代码。
用 objdump 反汇编就能看到 puts@plt 的"跳板"结构:
bash
$ objdump -S a.out
...
0000000000001050 <puts@plt>:
1050: f3 0f 1e fa endbr64
1054: f2 ff 25 75 2f 00 00 bnd jmpq *0x2f75(%rip) # 3fd0 <puts@GLIBC_2.2.5>
...
程序中的 call puts@plt 会先跳到 PLT 桩,再由 PLT 通过 GOT 间接跳转到 libc 中真正的 puts。
十、总结:静态链接 vs 动态链接
| 对比项 | 静态链接 | 动态链接 |
|---|---|---|
| 链接时机 | 编译时 | 运行时(程序加载时) |
| 可执行文件 | 大,包含库代码 | 小,只包含函数入口地址表 |
| 运行时依赖 | 不依赖库 | 依赖动态库存在且可被找到 |
| 内存占用 | 每个进程各一份 | 一份副本,多进程共享 |
| 更新维护 | 需重新编译链接 | 替换 .so 即可,二进制级代码复用 |
| 重定位 | 静态重定位(编译重定位) | 动态地址重定位(加载地址重定位) |
| 核心机制 | 合并 .o + 重定位表修正地址 | 库映射 + GOT/PLT 间接跳转 |
| 文件后缀 | .a(Linux)/ .lib(Windows) |
.so(Linux)/ .dll(Windows) |
核心结论:
- 静态链接的出现,提高了程序的模块化水平------大项目中不同的人可以独立测试和开发自己的模块,再通过静态链接生成最终的可执行文件;
- 静态链接会将所有目标文件和用到的各种库合并成独立可执行文件,并修正模块间函数的跳转地址(编译重定位 / 静态重定位);
- 动态链接把链接的整个过程推迟到程序加载的时候------先加载程序和动态库到内存,动态库的加载地址不固定,但无论加载到什么地方,都要映射到进程对应的地址空间,然后通过 GOT 方式进行调用(运行重定位 / 动态地址重定位);
- 动态链接牺牲了一定的性能和程序加载时间,但更有效地利用了磁盘和内存资源,极大方便了代码更新维护,实现了二进制级别的代码复用,物有所值。
附录:命令操作大全
bash
# ELF 基本信息
file hello.o # 辨识文件类型
readelf -h hello.o # 查看 ELF 文件头(-h / --file-header)
readelf -l main # 查看程序头表(段表,-l / --program-headers)
readelf -S main # 查看节头表(节表,-S / --section-headers)
readelf -s main # 查看符号表
# 反汇编
objdump -d hello.o # 反汇编代码段
objdump -S a.out # 反汇编并混合显示源码
# 动态库相关
ldd a.out # 查看程序依赖的动态库
export LD_LIBRARY_PATH=... # 设置运行时库搜索路径
sudo ldconfig # 更新 ld.so.cache 缓存
cat /etc/ld.so.conf.d/*.conf # 查看动态库搜索路径配置
# 库打包
ar -rc libxxx.a a.o b.o # 生成静态库(r=replace, c=create)
ar -tv libxxx.a # 查看静态库内容(t=list, v=verbose)
gcc -shared -fPIC -o libxxx.so *.o # 生成动态库
# 编译链接
gcc main.c -I头文件路径 -L库路径 -l库名 # 使用库
gcc -static ... # 强制静态链接
参考资料:ncurses 使用指南可参考 CSDN 相关教程;GOT 表运行时变化可用 gdb 调试观察。
如果你觉得这篇博客对你有帮助,欢迎点赞、收藏、评论交流~ 下期见!