系统核心解析:深入链接与装载机制——动静态库全揭秘:从ELF格式解析到虚拟内存布局

♥♥♥~~~~~~欢迎光临知星小度博客空间~~~~~~♥♥♥
♥♥♥零星地变得优秀~也能拼凑出星河~♥♥♥
♥♥♥我们一起努力成为更好的自己~♥♥♥
♥♥♥如果这一篇博客对你有帮助~别忘了点赞分享哦~♥♥♥
♥♥♥如果有什么问题可以评论区留言或者私信我哦~♥♥♥
✨✨✨✨✨✨ 个人主页✨✨✨✨✨✨
这一篇博客,我们进一步学习动静态库,准备好了吗~我们发车去探索Linux的奥秘啦~🚗🚗🚗🚗🚗🚗
目录
📊 2. Program Header Table(程序头表)【描述段(Segment)信息,用于程序加载和执行】
📦 3. Sections(节区)【ELF文件的基本组成单位】
📋 4. Section Header Table(节区头表)
第一章:库的基本概念与分类😜
1.1 什么是库?
库是预先编写好的、成熟的、可以复用的代码集合。在现实编程中,每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始编写【这样效率太低了】。
库的本质:一种可执行代码的二进制形式,可以被操作系统载入内存执行。
1.2 库的两种类型
| 类型 | Linux扩展名 | Windows扩展名 | 特点 |
|---|---|---|---|
| 静态库 | .a | .lib | 编译时链接到可执行文件 |
| 动态库 | .so | .dll | 运行时动态加载 |
第二章:静态库的制作与使用😁
2.1 静态库的基本概念
在前面我们以及提到过动静态库,我们首先来看看静态库的特点~
静态库的特点:
①程序在编译链接时把库的代码链接到可执行文件中
②程序运行时不再需要静态库
③生成的可执行文件包含所有需要的库代码
④程序体积较大,但运行时无外部依赖
2.2 准备工作:编写库代码
这里我们使用之前IO部分文件再加上我们自己简单实现加减乘除的头文件及源文件~
mystdio.h:
#ifndef __MYSTDIO_H_
#define __MYSTDIO_H_
#define FLUSH_NONE 1
#define FLUSH_LINE 2
#define FLUSH_FULL 4
#define SIZE 1024
#define UMASK 0666
#define FORCE 1
#define NORMAL 2
typedef struct MY_IO_FILE
{
int fileno;//文件描述符
int flag;//刷新方式
char outbuffer[SIZE];//缓冲区
int cur;//当前已经使用的空间
int cap;//容量大小
}MYFILE;
MYFILE* myfopen(const char* name,const char* mode);
int mywrite(const char* s,int size,MYFILE* fp);
void myfclose(MYFILE* fp);
void myfflush(MYFILE* fp);
#endif
mystdio.c:
#ifndef __MYSTDIO_H_
#define __MYSTDIO_H_
#define FLUSH_NONE 1
#define FLUSH_LINE 2
#define FLUSH_FULL 4
#define SIZE 1024
#define UMASK 0666
#define FORCE 1
#define NORMAL 2
typedef struct MY_IO_FILE
{
int fileno;//文件描述符
int flag;//刷新方式
char outbuffer[SIZE];//缓冲区
int cur;//当前已经使用的空间
int cap;//容量大小
}MYFILE;
MYFILE* myfopen(const char* name,const char* mode);
int mywrite(const char* s,int size,MYFILE* fp);
void myfclose(MYFILE* fp);
void myfflush(MYFILE* fp);
#endif
[xiaodu@zhixinxd mylib]$ cat mystdio.c
#include "mystdio.h"
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>
#include <stdlib.h>
MYFILE* myfopen(const char* name,const char* mode)
{
int fd = -1;
//不同打开方式打开文件
if(strcmp(mode,"w")==0)
{
fd = open(name,O_CREAT | O_WRONLY | O_TRUNC,UMASK);
}
else if(strcmp(mode,"r")==0)
{
fd = open(name,O_RDONLY);
}
else if(strcmp(mode,"a")==0)
{
fd = open(name,O_CREAT | O_WRONLY | O_APPEND,UMASK);
}
else if(strcmp(mode,"a+")==0)
{
fd = open(name,O_CREAT | O_RDWR | O_APPEND,UMASK);
}
else
{
//...
}
if(fd < 0)
{
return NULL;
}
MYFILE* fp = (MYFILE*)malloc(sizeof(MYFILE));
if(fp == NULL)
{
return NULL;
}
fp->fileno = fd;
fp->flag = FLUSH_LINE;
fp->cur = 0;
fp->cap = SIZE;
fp->outbuffer[0]=0;
return fp;
}
static void my_fflush_core(MYFILE* fp,int f)
{
if(fp->cur<0)
{
return;
}
if(f == FORCE)//强制刷新
{
write(fp->fileno,fp->outbuffer,fp->cur);
fp->cur=0;
return;
}
else
{
if((fp->flag & FLUSH_LINE)&&(fp->outbuffer[fp->cur-1]=='\n'))
{
write(fp->fileno,fp->outbuffer,fp->cur);
fp->cur=0;
return;
}
else if((fp->flag & FLUSH_FULL)&&(fp->cur==fp->cap))
{
write(fp->fileno,fp->outbuffer,fp->cur);
fp->cur=0;
return;
}
else
{
//....
}
}
}
int mywrite(const char* s,int size,MYFILE* fp)
{
//fwrite的本质是拷贝
memcpy(fp->outbuffer + fp->cur, s ,size);
fp->cur += size;
my_fflush_core(fp,NORMAL);
return size;
}
void myfclose(MYFILE* fp)
{
if(fp->fileno>0)
{
myfflush(fp);//用户-->C
fsync(fp->fileno);//C-->内核
close(fp->fileno);
free(fp);
}
}
void myfflush(MYFILE* fp)
{
my_fflush_core(fp,NORMAL);
}
mymath.h:
double add(double a,double b);
double subtract(double a, double b);
double multiply(double a, double b);
double divide(double a, double b);
mymath.c:
#include <stdio.h>
#include "mymath.h"
// 加法函数
double add(double a, double b) {
return a + b;
}
// 减法函数
double subtract(double a, double b) {
return a - b;
}
// 乘法函数
double multiply(double a, double b) {
return a * b;
}
// 除法函数
double divide(double a, double b) {
if (b != 0) {
return a / b;
} else {
printf("错误:除数不能为零!\n");
return 0;
}
}
我们编写库代码相当于是库的制作者,现在如果另外有个人需要使用我们的库代码,应该怎么办呢?
方法一:给别人源代码,直接发给他让他使用,这个是最简单粗暴的,但是这样会有一点安全的隐患~

方法二:给别人.o文件【可重定位目标文件】和.h文件【头文件】
这是因为:
.h 文件 = 说明书(告诉你怎么用)
.o 文件 = 实际零件(包含实现代码)
两者结合 + 你的主程序 = 完整的可执行程序

方法三:给别人静态库,这个是我们需要重点说明的,我们来看看下面的内容~
2.3 静态库的制作过程
首先,命令行输入【ar rcs libmylib.a *.o】将.o文件封装成静态库~
参数说明【先了解一下】:
r - 替换/添加文件到归档
c - 创建归档(如果不存在)
s - 创建索引(相当于 ranlib)

把库和头文件给其他用户:

我们可以看到虽然库在当前用户路径下,但是静态库还是没有链接到程序中!
接下来,一种方法是直接链接静态库,命令行输入【gcc test.c libmylib.a -o test】

还有一种方法是使用 -l【指定库名——去掉前缀lib和后缀.a】 和 -L 【指定库路径】选项,命令行输入【gcc test.c -L. -lmylib -o test】

不同的链接方式对比:
| 方法 | 命令 | 说明 |
|---|---|---|
| 直接链接 | gcc test.c libmylib.a -o test | 直接指定库文件 |
| 使用选项 | gcc test.c -L. -lmylib -o test | -L指定库路径,-l(小L)指定库名,-I(大i)指定头文件路径 |
| 多文件编译 | gcc test.c file1.o file2.o -o test | 有源码,可以直接编译.o文件 |
事实上,我们就可以理解成 库 = 头文件 + 库文件(是.o文件的集合)~
我们来试一下使用Makefile自动化构建:
libmylib.a: mystdio.o mymath.o
@ar -rc $@ $^
@echo "build $^ to $@ ... done"
#匹配所有.c文件生成对应的.o文件
%.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"
我们重点说明一下【打包发布】这一部分
功能:创建标准目录结构并打包
命令:
mkdir -p stdc/include:创建include目录(-p:如果已存在不报错)
mkdir -p stdc/lib:创建lib目录
cp -f *.h stdc/include:复制所有头文件
cp -f *.a stdc/lib:复制所有静态库
tar -czf stdc.tgz stdc:打包压缩
-czf:创建gzip压缩的tar包
三种使用场景
# 场景1:头文件和库文件已安装到系统路径
$ gcc test.c -lmylib【只需要指明库名就好】# 场景2:头文件和库文件在当前目录下【即和我们的源文件在同一个路径下】
$ gcc test.c -L. -lmylib【需要指明库名以及库路径.】# 场景3:库文件和头文件在自定义路径下
$ gcc test.c -I头文件路径 -L库文件路径 -lmylib【需要指明库名以及库路径以及头文件路径】
编译选项说明:
-L:指定库文件搜索路径
-I:指定头文件搜索路径
-l:指定要链接的库名(去掉lib前缀和.a后缀)
为什么安装到系统路径就那么方便呢,我们使用C标准库里面的怎么就不需要再指定路径呢?
这是因为编译器默认链接C标准库(无需-lc),第三方库需用-l指定名称。若库不在默认路径(/lib64等),需用-L指定路径。库安装即拷贝到系统默认路径,此后编译就只需-l库名即可自动链接,使用第三方库必须明确指定库名称。
我们来模拟一下场景三进行测试:

第三章:动态库的制作与使用😝
3.1 动态库的基本概念
动态库的特点:
程序在运行时才去链接动态库的代码
多个程序共享使用库的代码
可执行文件仅包含函数入口地址表
库的机器码在运行时会从磁盘复制到内存
优势:
节省磁盘空间和内存
库更新方便,不需要重新编译程序
支持运行时加载
3.2 动态库的制作过程
有了前面的基础,接下来我们来看看如何制作动态库,在Linux下,静态库扩展名为.a,动态库扩展名为.so,那么生成动态库的~现在我们新创建一个目录来进行实验:

生成动态库,首先需要【编译.c文件生成.o文件】,命令行输入【gcc -fPIC -c mystdio.c mymath.c】我们可以看到多了一个选项【-fPIC】这是一个关键选项,生成位置无关代码(Position Independent Code),是动态库必需的,确保代码可以被加载到任意内存地址。

接下来,把.o文件打包形成我们需要的动态库,命令行输入【gcc -o libmylib.so *.o -shared】,
【-shared】是生成共享库(动态库)的关键选项

现在我们把动态库给别人进行使用,把库和相应的头文件都给到~

命令行输入【ldd test】,结果会显示 test 可执行文件的动态库依赖关系。我们可以看到test成功依赖了我们的动态库~

【命令介绍:ldd 命令用于显示可执行文件与共享库(动态库)的依赖关系】
接下来,我们使用Makefile来进行自动化构建动态库~
Makefile
libmylib.so: mystdio.o mymath.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"

接下来我们把库让其他人试用一下~

按照静态库的方法,我们写明了头文件路径以及库文件路径以及库名,成功生成了可执行程序,但是当我们想运行可执行程序的时候并不能运行,通过【ldd a.out】可以发现我们找不到自己创建的动态库~即:
编译阶段(成功):
-L../mylibd/stdc/lib:告诉链接器在编译时去哪里找库文件
-lmylib:链接 libmylib.so
结果:链接成功,生成 a.out
运行阶段(失败):
动态链接器(ld.so)不知道去哪里找 libmylib.so
系统默认搜索路径不包括 ../mylibd/stdc/lib
结果:运行时找不到库,程序崩溃
那么怎么解决这个问题呢?有下面的四种方法~
方案1:拷贝到系统库目录
命令行输入【sudo cp libmylib.so /usr/lib64/】

可以看到成功运行~我们来看看这个方法的优缺点:
优点:
-
全局生效,所有用户和程序都能使用
-
永久有效,重启后依然有效
-
符合Linux标准文件系统布局
缺点:
-
需要root权限
-
可能覆盖系统已有同名库文件
-
卸载时需要手动删除
为了不影响后面的操作,我们对其进行删除~

方案2:建立软链接
命令行输入【sudo ln -s /......./libmystdio.so /usr/lib64/libmystdio.so】

可以看到成功运行~我们来看看这个方法的优缺点:
优点:
-
节省空间,不复制文件
-
更新库只需替换原文件,链接自动生效
-
可以跨文件系统链接
缺点:
-
如果原文件被移动或删除,链接会失效
-
需要root权限创建系统目录的链接
-
链接层次过多可能影响性能
为了不影响后面的操作,我们对其进行删除~

方案3:设置环境变量
命令行输入【export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/........】

可以看到成功运行~我们来看看这个方法的优缺点:
优点:
-
无需root权限
-
临时有效,不影响系统
-
可以针对不同用户设置不同版本
-
支持相对路径
缺点:
-
只在当前shell会话有效(除非写入配置文件)
-
可能被某些安全程序忽略(如setuid程序)
-
环境变量可能被其他脚本覆盖

方案4:配置系统库路径
# 创建配置文件
sudo echo "/path/to/library" > /etc/ld.so.conf.d/mylib.conf
# 更新缓存
sudo ldconfig
小编这里就不进行测试了,感兴趣的小伙伴可以试一试~
优点:
-
永久全局生效
-
比复制文件更整洁(不污染系统目录)
-
可以管理多个自定义路径
-
易于维护和删除
缺点:
-
需要root权限
-
需要手动运行
ldconfig更新缓存 -
如果路径错误,可能导致系统问题
当然这四种方法各有适用场景,没有绝对的最佳方案:
-
方案1(复制):最传统,适合稳定库的正式部署
-
方案2(软链接):适合频繁更新的大库
-
方案3(环境变量):最灵活,适合开发和测试
-
方案4(系统配置):最规范,适合系统级库管理
3.3 动静态库优先级
1.默认链接行为
-
动态优先:当同一个库同时存在动态库(
.so)和静态库(.a)时,GCC/G++ 默认优先使用动态库,进行动态链接。
2. 混合链接情况
-
如果某个库只提供静态库,即使整体是动态链接,GCC 也会对该库进行静态链接。
3. 强制静态链接选项
-
-static:强制所有库进行静态链接。-
要求:所有依赖的库都必须有对应的静态库版本,否则链接失败【多数 Linux 系统默认只安装 C/C++ 的动态库,不安装静态库;因此,使用 -static 时容易因缺失静态库而链接失败】
-
Centos静态库安装,命令行输入【sudo yum install glibc-static】
Ubuntu静态库安装,命令行输入【sudo apt-get install libc6-dev】

第四章:ELF文件格式深度解析😋
接下来,我们来学习了解一下什么是ELF文件~
4.1 ELF文件概述
ELF(Executable and Linkable Format)是Linux系统下可执行文件、目标文件、共享库的标准格式。
ELF文件的四种类型:
可重定位文件(.o文件):包含适合于与其他目标文件链接的代码和数据
可执行文件:可直接执行的程序
共享目标文件(.so文件):动态库
内核转储:存放进程执行上下文,用于dump信号触发
4.2 ELF文件结构
ELF文件组成包含四个部分:
┌─────────────────────────┐
│ ELF Header (ELF头) │ ← 描述文件基本信息和组织结构
├─────────────────────────┤
│ Program Header Table │ ← 执行视图:告诉系统如何创建进程映像
│ (程序头表,可选) │
├─────────────────────────┤
│ Sections │ ← 链接视图:包含各种类型的数据段
│ (节区,代码/数据等) │
├─────────────────────────┤
│ Section Header Table │ ← 描述每个节区的详细信息
│ (节区头表) │
└─────────────────────────┘

🔍 1. ELF Header(ELF 头)
ELF 文件的开头,固定大小(32位:52字节,64位:64字节)
包含的关键信息:
-
魔数:
7F 45 4C 46(0x7F + 'E' 'L' 'F') -
文件类型:
-
ET_REL(1):可重定位文件(.o 目标文件) -
ET_EXEC(2):可执行文件 -
ET_DYN(3):共享目标文件(.so) -
ET_CORE(4):核心转储文件
-
-
机器架构:x86、ARM、MIPS 等
-
入口地址:程序执行的起始地址
-
程序头表偏移和大小
-
节区头表偏移和大小
-
节区头表字符串表索引
📊 2. Program Header Table(程序头表)【描述段(Segment)信息,用于程序加载和执行】
主要的段类型:
| 段类型 | 说明 |
|---|---|
| LOAD | 可加载段(代码段、数据段) |
| DYNAMIC | 动态链接信息 |
| INTERP | 指定动态链接器路径 |
| NOTE | 附加信息 |
| TLS | 线程局部存储 |
| GNU_STACK | 栈权限(可执行栈标志) |
每个段包含:
-
段类型、权限(R/W/X)
-
文件中的偏移和大小
-
内存中的虚拟地址和大小
-
内存对齐方式
📦 3. Sections(节区)【ELF文件的基本组成单位】
ELF 文件的实际内容区域,用于链接和调试
主要节区分类:
A. 代码和数据节区
| 节区名 | 内容 | 权限 |
|---|---|---|
| .text | 可执行代码 | R-X |
| .rodata | 只读数据(字符串常量等) | R-- |
| .data | 已初始化的全局/静态变量 | RW- |
| .bss | 未初始化的全局/静态变量 | RW- |
| .plt | 过程链接表(动态链接跳板) | R-X |
| .got/.got.plt | 全局偏移表(动态链接地址) | RW- |
B. 符号和重定位节区
| 节区名 | 内容 |
|---|---|
| .symtab | 符号表(需用 -g 编译) |
| .dynsym | 动态符号表 |
| .strtab/.dynstr | 字符串表 |
| .rel/.rela | 重定位信息 |
| .hash/.gnu.hash | 符号哈希表 |
C. 调试信息节区
| 节区名 | 内容 |
|---|---|
| .debug_* | DWARF 调试信息 |
| .line | 行号信息 |
| .comment | 编译器版本信息 |
D. 特殊节区
| 节区名 | 内容 |
|---|---|
| .shstrtab | 节区名称字符串表 |
| .interp | 动态链接器路径 |
| .dynamic | 动态链接信息 |
| .eh_frame | 异常处理框架 |
ELF 文件中包含多个具有不同属性的节(如代码节、数据节等),每一个节的大小是不确定的,但是从操作系统的角度,IO的时候必须是4KB~所以在加载时,操作系统并不会直接按原有节结构映射到内存,而是依据程序头表(Program Header Table)中预定义的规则,将属性相同(例如可读、可写、可执行、是否需要申请内存空间等)的节【4KB对齐】合并为更少数量的段(Segment)。这种合并既减少了内存管理的开销,也符合操作系统内存页面对齐与权限控制的要求。因此,ELF 文件的加载过程实际上是按段而非按节进行的,其合并逻辑在编译链接阶段就已确定,并记录在程序头表中。
📋 4. Section Header Table(节区头表)
描述每个节区的详细信息:
-
节区名称(在
.shstrtab中的索引) -
节区类型
-
标志(可写、可执行、分配内存等)
-
虚拟地址
-
文件偏移
-
大小
-
链接和信息字段
-
对齐要求
4.3一个 ELF 文件,要有两种视角
-
编译器视角:section
-
从编译器(编译、链接)的角度看,ELF 文件由多个 section 组成,例如
.text(代码段)、.data(已初始化数据)、.bss(未初始化数据)、.rodata(只读数据)等。 -
这些 section 用于组织代码、数据、符号表等信息,便于编译器和链接器处理。
-
-
OS 视角:Segment
-
从操作系统(加载、执行)的角度看,ELF 文件由多个 segment 组成,例如 LOAD 类型的段,用于指示操作系统如何将文件映射到内存。
-
一个 segment 可能包含一个或多个 section,操作系统根据段头表(Program Header Table)来加载文件到内存并执行。
-
4.4ELF工具使用🛠️
我们首先来介绍两个指令~
| 特性 | readelf | objdump |
|---|---|---|
| 主要用途 | 专门分析 ELF 格式 | 通用二进制分析 + 反汇编 |
| 依赖库 | 不依赖 BFD 库,直接解析 ELF | 依赖 BFD(Binary File Descriptor)库 |
| 文件格式支持 | 仅 ELF 格式 | 多种格式(ELF、COFF、PE 等) |
| 反汇编能力 | ❌ 不支持 | ✅ 强大支持 |
| 显示完整性 | ✅ ELF 信息更完整准确 | ⚠️ 受 BFD 库限制,某些信息可能不全 |
| 推荐场景 | 查看 ELF 结构、段、节、符号表 | 反汇编、查看代码、混合分析 |
查看ELF头信息
命令行输入【readelf -h 文件名】查看ELF文件头——显示完整的ELF头,包含所有字段。
命令行输入【objdump -f 文件名】查看文件头信息——只显示关键信息:格式、架构、标志、入口地址。

我们可以看到它们输出结果是不一样的,同时也可以看出readelf更专业:对ELF格式显示更完整、准确;objdump更通用:适合快速查看和多格式支持。
查看程序头表(执行视图)
命令行输入【readelf -l 文件名】或者【objdump -p 文件名】

查看节头表(链接视图)
命令行输入【readelf -S 文件名】或者【objdump -h 文件名】

这里对前面提到的🔗 两种视图进行简单对比
| 特性 | 链接视图 (Linker View) | 执行视图 (Loader View) |
|---|---|---|
| 主要使用者 | 链接器(ld) | 加载器/操作系统 |
| 组织单位 | 节区(Section) | 段(Segment) |
| 关注点 | 如何合并、重定位 | 如何加载到内存,加载到内存后分段的权限 |
| 典型应用 | .o 目标文件 | 可执行文件、共享库 |
-
在 ELF 文件中,节(Section) 是链接视图中的最小单位(如
.text、.data等),而 段(Segment) 是执行视图中内存映射的单位。 -
操作系统加载 ELF 时,按照程序头表(Program Header Table) 的描述,将多个属性相同的节合并到一个段中,映射到内存【节合并为段】
查看符号表【符号表就像程序的 "字典" 或 "地图",它建立了符号名称和内存地址之间的映射关系】

反汇编代码段
命令行输入【objdump -d test.o】查看前面test.o的汇编代码~

第五章:静态链接原理详解🐷
源码文件 编译 目标文件 链接 可执行程序
add.c ────→ add.o ───────────┐
sub.c ────→ sub.o ───────────┼───→ a.out
main.c ────→ main.o ───────────┘
静态库.a ───────┘
接下来,我们写两段代码来进行测试:

编译形成.o文件,命令行输入【 readelf -s 文件名】查看目标文件~

我们发现:main函数已定义(在.text节中),puts和run函数未定义(UND),地址暂时为0
接下来,命令行输入【objdump -d 文件名】查看它们的反汇编:

可以看出,编译时不知道外部函数的真实地址,就会将调用地址暂时设为0,在目标文件中记录需要重定位的符号。
接下来,我们进行命令行输入【gcc test.o run.o -o myprogram】链接形成可执行程序

我们接着查看链接后的符号表,可以看到run函数已经有了确定的地址

查看可执行程序的反汇编代码,可以看到调用地址已被修正

那么链接器的主要任务【链接静态库和.o文件,做地址重定位】
符号解析:将符号引用与符号定义关联
地址分配:为所有符号分配运行时地址
重定位:修正代码中的地址引用,把把要调用的函数地址从 【0】重定位到最终目标函数的地 址。这也就是【.o】文件叫做可重定位目标文件!
核心问题:
“一个ELF可执行程序在没有加载到内存的时候有没有地址呢?为什么?是什么地址?”
答案与论证:
有地址。 这个地址在链接过程中就已经被确定了。
-
证据:提供的反汇编代码显示,函数
run和main的入口地址以及内部指令的地址在磁盘上的可执行文件中就已经明确存在。 -
地址性质:这些地址是虚拟地址,更具体地说是相对于进程地址空间基址的虚拟地址(假设没有地址空间随机化ASLR)。链接器在最终合并、重定位所有代码和数据时,已经根据链接脚本或默认布局为它们分配了预期的运行虚拟地址。
-
为什么需要:只有这样,当程序被加载时,操作系统才能知道“应该把代码段的
.text部分映射到进程虚拟空间的哪个位置”,CPU才能正确执行函数跳转指令。这些地址是程序内在逻辑的一部分。
第六章:动态链接与加载机制😀
6.1 进程虚拟地址空间
我们之前就提到过进程虚拟地址空间布局:
┌─────────────────┐ 0xFFFFFFFF
│ 内核空间 │ (1GB)
├─────────────────┤ 0xC0000000
│ 栈区 │
│ ↓ │
│ ... │
│ ↑ │
│ 堆区 │
├─────────────────┤
│ BSS段 │
│ 数据段 │
│ 代码段 │
├─────────────────┤
│ 共享库映射区 │ ← 动态库加载到这里
└─────────────────┘ 0x40000000

-
代码即数据:程序代码本身也是数据,被加载到内存后,每条指令都有其对应的物理地址。
-
操作系统管理:操作系统通过
vm_area_struct这样的数据结构来管理进程的虚拟地址空间,记录每个内存区域(如代码段、数据段)的起止地址(start,end)。 -
虚拟地址的来源:这些内存区域的虚拟地址范围初始值是从哪里来的?答案是:来自ELF可执行文件。链接器将多个目标文件合并后,确定了各个
Segment(程序段,如可加载的代码段、数据段)的最终虚拟地址。 -
地址转换链条:
-
程序视角:程序内部函数调用和变量访问使用的是虚拟地址。
-
系统视角:CPU通过查询页表,将虚拟地址转换为物理地址,从而访问真实的物理内存。
-
根源:这一切虚拟地址的蓝图,都记录在存储在磁盘上的ELF文件中。
-
核心问题与解答:
“我们程序内部互相调用,互相访问的地址是什么地址?”
答案是:虚拟地址。 程序在编译链接后,其内部地址引用(如函数调用call的地址)已经被链接器确定为一个统一的虚拟地址。当程序运行时,操作系统根据ELF文件中的信息为其建立虚拟地址空间,CPU再通过MMU和页表动态地将这些虚拟地址映射到物理地址。

6.2 程序启动详细流程
程序启动流程图如下:

内核加载可执行文件到内存 → 执行 _start 函数 → 调用动态链接器 → 动态链接器工作:
1. 加载所有依赖的动态库
2. 进行符号解析和地址重定位
3. 更新 GOT 表
→ 调用 __libc_start_main → 最终调用 main 函数
6.3 动态链接器的作用
命令行输入【readelf -l 可执行程序名 | grep interpreter】查看程序的动态链接器

动态链接器负责:
解析程序依赖的动态库
加载库文件到内存
进行运行时符号解析
管理全局偏移表(GOT)
接下来介绍一下全局偏移表(GOT)机制
为什么需要GOT?
问题:
代码段(.text)是只读的,不能在运行时修改
动态库的加载地址在运行时才确定
需要一种机制在运行时确定函数地址
解决方案:在数据段(.data)预留可写区域存储函数地址
GOT的工作原理
// 编译时生成的代码
call puts@plt // 不直接调用puts,而是通过PLT// PLT(过程链接表)中的代码
puts@plt:
jmp *GOT[1] // 跳转到GOT表中存储的地址
push index // 第一次调用时,压入函数索引
jmp resolve // 跳转到解析例程
第一次调用过程:GOT中的地址指向动态链接器 →动态链接器查找函数真实地址 →更新GOT表中的地址 →跳转到真实函数
后续调用:直接通过GOT表跳转到真实函数。
前面我们还提到过位置无关代码(PIC),动态库必须使用位置无关代码,使其可以被加载到任意内存地址。
PIC的实现方式:
使用相对寻址而不是绝对地址
通过GOT表访问全局数据和函数
编译时使用-fPIC选项
# 生成位置无关代码
$ gcc -fPIC -c mylib.c -o mylib.o
第七章:动态库的进程间共享😁
7.1 共享机制原理
物理内存布局:
┌─────────────────┐
│ 进程A数据 │
│ 进程A代码 │
├─────────────────┤
│ 进程B数据 │
│ 进程B代码 │
├─────────────────┤
│ libc.so代码 │ ← 多个进程共享同一份物理内存
└─────────────────┘
共享的优势
内存节省:多个进程共享同一份库代码
磁盘节省:不需要在每个可执行文件中包含库代码
更新方便:更新库文件即可,不需要重新编译程序
加载快速:后续进程可以快速映射已加载的库
7.2 地址空间映射
每个进程通过页表将相同的物理内存映射到各自的虚拟地址空间:
进程A虚拟空间 进程B虚拟空间
↓ ↓
┌───────────┐ ┌───────────┐
│ 共享库映射 │ │ 共享库映射 │
└───────────┘ └───────────┘
↘ ↙
┌───────────────┐
│ 库的物理内存页 │
└───────────────┘
第八章:总结与最佳实践
8.1 动静态库对比总结
| 特性 | 静态库 | 动态库 |
|---|---|---|
| 链接时机 | 编译时 | 运行时 |
| 文件大小 | 较大(库代码直接嵌入可执行文件) | 较小(可执行文件只包含引用) |
| 内存使用 | 每个进程独立副本,浪费内存 | 多个进程共享同一份库代码 |
| 更新维护 | 需要重新编译整个程序 | 直接替换库文件,程序自动使用新版本 |
| 加载速度 | 较快(无需运行时链接) | 稍慢(需要动态链接器加载和重定位) |
| 依赖关系 | 无外部依赖,部署简单 | 需要确保库文件在系统中存在 |
8.2 选择建议
使用静态库的场景:
-
✅ 对启动性能要求高 - 无需运行时链接,启动更快
-
✅ 部署环境复杂 - 不希望依赖外部库,简化部署
-
✅ 库代码较小 - 不介意可执行文件体积增大
-
✅ 需要独立的可执行文件 - 适合嵌入式系统或单文件分发
使用动态库的场景:
-
✅ 库代码较大 - 多个程序共享,节省磁盘和内存
-
✅ 需要频繁更新库 - 无需重新编译主程序
-
✅ 希望节省内存和磁盘空间 - 多进程共享同一份代码
-
✅ 支持插件架构 - 运行时动态加载功能模块
8.3 最佳实践
库的命名规范:
-
静态库:
libname.a(如libmath.a) -
动态库:
libname.so.x.y.z(版本号:主版本.次版本.修订版本)
版本管理:
-
语义化版本控制:主版本(不兼容更新)、次版本(向下兼容新功能)、修订版本(向下兼容问题修复)
-
ABI兼容性:保持向后兼容性,避免破坏现有程序
-
符号版本控制:使用
.symver指令管理不同版本的符号
部署考虑:
-
静态库:简单部署,无外部依赖,但可执行文件体积大
-
动态库:
-
需要设置正确的库搜索路径(
LD_LIBRARY_PATH或/etc/ld.so.conf) -
使用
ldconfig更新动态链接器缓存 -
考虑版本冲突问题(如 "DLL地狱")
-
调试技巧:
-
检查依赖:
ldd <可执行文件>查看动态库依赖关系 -
分析ELF结构:
readelf -d <可执行文件>查看动态段信息 -
反汇编调试:
objdump -d <可执行文件>分析代码段 -
跟踪库加载:
strace -e openat <程序>跟踪库文件打开过程 -
性能分析:使用
perf或gprof分析库函数调用性能
♥♥♥本篇博客内容结束,期待与各位优秀程序员交流,有什么问题请私信♥♥♥
♥♥♥如果这一篇博客对你有帮助~别忘了点赞分享哦~♥♥♥
✨✨✨✨✨✨个人主页✨✨✨✨✨✨
更多推荐


所有评论(0)