Skip to content

LV100-linux动态库

来了解一下动态库。

一、文件准备

1. 目录结构

测试文件目录以及内容如下所示:

image-20260325143050618

example.c 和 example.h 文件将被编译为 libexample.so 动态库文件,example.c 文件中定义了两个函数(hello 函数和 add 函数)以及一个全局变量 global。main.c 文件将被编译成可执行文件,main 函数调用 hello 和 add 函数,并将 global 变量重新赋值。可执行文件编译的过程需要链接 libexample.so 动态库文件。

2. 测试源码

2.1 源文件

c
/* example.c */
#include <stdio.h>
int global = 100;
void hello(){
    printf("hello\n");
}
int add(int a, int b){
    return (a+b);
}
c
/* example.h */
extern void hello();
extern int add(int a, int b);
c
/* main.c */
#include "example.h"
extern int global;
int main(int argc, char **argv){
    global = 200;
    
    hello();
    
    int a = 10;
    int b = 20;
    add(a, b);
    return 0;
}

2.2 Makefile

makefile
CC = gcc
CFLAGS = -I.
LDFLAGS = -L. -lexample -Wl,-rpath,'$$ORIGIN'

# 目标文件列表
OBJS = example.o main.o

all: a.out

# 使用隐含规则编译 .c -> .o
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

# 构建共享库
libexample.so: example.o
	$(CC) -shared -fPIC -o $@ $<

# 构建可执行文件,依赖所有目标文件和共享库
a.out: main.o libexample.so
	$(CC) $(CFLAGS) -o $@ main.o $(LDFLAGS)

# 清理
clean:
	rm -f *.o *.so a.out

.PHONY: all clean

二、动态库文件

1. 是什么?

其实在前面大概已经了解过动态库了,这里再回顾一下。动态库文件是 在程序运行时才被加载和链接的共享代码模块。Linux 系统中通常以 .so (Shared Object) 结尾,例如 libm.so(数学库)、libc.so(C 标准库)。

动态库文件有以下几大特点:

  • 代码共享:多个程序可以共享同一份动态库代码,节省磁盘空间和内存资源。
  • 运行时加载:动态库在程序运行时才被加载到内存。
  • 位置无关代码(PIC):动态库可加载到不同的内存地址,避免地址冲突。

2. 是一个 ELF 文件

动态库文件其实并不神秘,它也是一种 ELF 文件。动态库文件 ELF 格式如图所示。右侧部分通过下面的命令查看:

shell
readelf -h libexample.so
20260325-0001

通过 readelf -h 动态库文件 命令查看 ELF 文件头,可以看到动态库文件类型是 DYN(Shared object file)。DYN(Shared object file)表示文件包含位置无关代码(通过 gcc -fPIC 编译)。

动态库文件的程序入口点(Entry point address)为 0,表示动态库不是独立程序,没有自己的入口点,这点和可执行文件不同。

动态库文件无 INTERP 段(不指定动态链接器路径),动态库的主要功能是提供函数和数据供其他程序调用,而不是独立运行。因此,动态库不需要指定动态链接器的路径。

除了以上几个点,动态库 ELF 文件和可执行 ELF 文件并没有很大的区别。

三、编译和链接

1. 编译链接

动态库的编译必须要加上-fPIC 编译选项,-fPIC 选项告诉编译器生成位置无关代码,命令如下:

shell
gcc -fPIC -shared -o libexample.so example.c

编译可执行 ELF 文件链接动态库示例命令如下:

shell
gcc main.c -o a.out -I. -L. -lexample -Wl,-rpath='$ORIGIN'
  • -L 选项:用于编译时指定动态库搜索路径。
  • -l 选项:用于编译时指定动态库文件名(去掉 lib 前缀和扩展名)。
  • -Wl,-rpath 选项:用于向可执行文件中嵌入一个运行时搜索路径(Runpath)。

2. 是什么格式?

编译完后,可执行文件(a.out)ELF 格式如下图所示。

20260325-0002

可执行文件如果链接了动态库,ELF 文件中会有几个特殊的节:.interp 节、.dynsym 节、dynamic 节。

2.1 .interp 节

.interp 节是一个包含动态链接器路径的节,用于指定可执行文件运行时使用哪个动态链接器来加载和解析动态库。通过 readelf -d 可执行文件 命令可以查看.interp 节信息,输出示例如下:

shell
# readelf -p .interp a.out
String dump of section '.interp':
  [ 0] /lib/ld-linux-aarch64.so.1

/lib/ld-linux-aarch64.so.1 为动态链接器路径,当操作系统启动一个可执行文件时,它会读取.interp 节中的路径,找到并加载指定的动态链接器。

注意:动态链接器也是一个动态库,负责在程序运行时加载和解析动态库,并将动态库定义的符号(函数和变量)绑定到程序中。

2.2 .dynsym 节

.dynsym 节是 ELF 文件中的动态符号表,它包含了动态链接时所需的符号信息。这些符号通常是全局变量和函数,它们在程序运行时被动态加载和解析。 通过 readelf -sD 可执行文件 命令可以查看.dynsym 节,输出示例如下:

20260325-0003

相关字段解析如下:

  • Num:符号表中的条目编号。
  • Value:符号的地址或值。对于已定义符号,这通常是一个虚拟地址;对于未定义的符号(如 UND),该值为 0。
  • Size:符号的大小,以字节为单位。函数的大小是其指令长度,变量的大小是其占用的字节数。如果大小为 0,通常表示该符号的大小未知或不是数据对象(如节名或未定义符号)。
  • Type:符号的类型,常见的类型有:
类型说明
NOTYPE类型未指定
SECTION该符号关联到一个节
FUNC表示是一个函数
OBJECT表示是一个数据对象
  • Bind:符号的绑定属性,表示符号的可见范围,常见的绑定属性有:
属性说明
LOCAL局部符号,只在当前目标文件内可见。
GLOBAL全局符号,可被其他目标文件引用。
WEAK弱符号,类似于全局符号,但优先级较低。如果存在同名的全局符号,则全局符号优先;如果没有全局符号,则使用弱符号。
  • Vis:符号的可见性,常见类型如下:
类型说明
DEFAULT默认可见性,符号可以被其他模块引用。
HIDDEN符号在模块外不可见。
INTERNAL内部可见性。
PROTECTED受保护的,该符号在模块外可见,但不可被覆盖(即不能重定位)。
  • Ndx:符号所在的节的索引,其中:
描述说明
数字对应节的索引。
UND未定义符号(需动态链接)。
ABS绝对值符号。
  • Name:符号的名称及版本信息。

2.3 .dynamic 节

.dynamic 节用于存储动态链接器在运行时需要的信息,包括动态库依赖关系、动态库搜索路径、节的位置等。通过 readelf -d 可执行文件 命令可以查看.dynamic 节,输出示例如下:

20260325-0004

.dynamic 节中的信息比较多,我们这里只需要关注下面两个 :

  • NEEDED 指的是程序运行时需要加载的动态库,如测试项目中,可执行文件依赖 libexample.so 库和 libc.so.6 库(C 标准库)。

  • RUNPATH 指运行时库搜索路径,优先级低于 LD_LIBRARY_PATH,高于系统默认路径,$ORIGIN 表示可执行文件所在目录,多个路径通过冒号分隔。

四、运行时动态链接

当一个可执行文件链接了动态库,系统启动可执行程序时,除了要加载可执行文件的 LOAD 段(包括:.interp、.dynsym、.text、.dynamic、.data、.bss 等节),同时也需要通过动态连接器加载动态库文件的 LOAD 段,这个过程比较复杂,我们通过下图来学习。

20260325-0005

可执行文件的加载过程是这部分笔记的重点,了解了这个过程,就会深入理解动态库。

用户程序想要执行新代码,需要调用 execve 系统调用(或 execve 家族函数)来加载可执行文件。execve 会用新程序的代码、数据、堆和栈来替换当前进程的虚拟地址空间的内容,并执行新程序。

要想知道内核是如何加载 ELF 文件的,就要搞清楚 execve 的工作流程。execve 加载 ELF 文件的主要流程在 load_elf_binary 函数中实现,该函数主要做了两件事情:加载可执行文件加载动态库文件

1. 加载可执行文件

加载可执行文件具体步骤如下。

  • (1)加载程序头表:程序头表每个条目都是一个段(如 INTERP 段、LOAD 段等),内核通过解析段将 ELF 文件相关数据加载至内存,所以加载程序头表是加载 ELF 文件的第一步。

  • (2)解析 INTERP 段:INTERP 段只包含一个节(.interp 节),前面介绍过.interp 节中存储的是动态链接器路径(如 /lib/ld-linux-aarch64.so.1)。解析 INTERP 段的目的是加载动态链接器,为后续加载动态库文件做准备。

  • (3)设置栈区:执行新程序,需要重新设置栈区,保证新程序有正确的执行环境。

  • (4)加载 LOAD 段:LOAD 段记录的是需要加载进内存的节(如.text、.data、.bss 等)。内核会通过 mmap 文件或匿名映射将可执行文件的 LOAD 段加载至内存。

  • (5)加载动态链接器 LOAD 段:为了确保动态链接器能够正常启动,通常需要将动态链接器通过 mmap 文件映射方式映射至内存(内存映射区)。动态链接器也是动态库,加载其 LOAD 段即可。

  • (6)设置堆区:同栈区。

  • (7)跳转至动态链接器入口点:完成以上工作后,可执行文件已经被加载至内存。接下来是加载动态库,动态库的加载工作由动态链接器完成,所以需要跳转至动态库链接器入口点启动动态链接器。

2. 加载动态库

程序控制权转移至动态链接器后,动态链接器开始加载可执行文件依赖的动态库,具体步骤如下。

  • (1)解析 ELF 可执行文件 DYNAMIC 段

可执行文件 DYNAMIC 段同样只包含一个节(.dynamic),该节记录了可执行文件依赖的动态库路径以及自定义动态库搜索路径(RUNPATH)。

  • (2)顺序搜索动态库

根据 DYNAMIC 段提供的信息,动态链接器将在指定的路径下搜索动态库。按照优先级从高到低的顺序,动态库搜索路径排序如下:

text
- LD_PRELOAD 指定路径。
- LD_LIBRARY_PATH 指定路径。
- ELF 文件 RUNPATH 指定路径。
- ld.so.cache 缓存文件中查找。
- /lib 、/usr/lib 等默认路径。

只要我们编译的动态库处于以上几种方式指定的路径中,就能够被动态链接器搜索到,也就能够正确解析动态库符号。

这里我们看到了几个熟悉的身影(如:LD_LIBRARY_PATH、/lib、/usr/lib 等),我们平时做软件开发时经过会用到它们,但很少关注其底层原理和作用。通过本文的学习,我们就能够彻底搞懂这些基础知识了。

搜索到动态库后,我们需要解析动态库,并将其 LOAD 段通过 mmap 方式加载至内存(内存映射区)。

  • (3)外部符号重定位

所谓外部符号是指 ELF 可执行文件 .dynsym 节中未定义(UND)的符号,这些符号通常在动态库中定义。动态库文件中定义的是位置无关代码(-fPIC),这些代码只有相对地址,没有绑定实际虚拟地址,需要由动态链接器进行重定位绑定实际虚拟地址,才能够正常调用。

  • (4)跳转至 ELF 可执行程序入口点

动态链接器完成全部工作后,程序会跳转至 ELF 可执行程序入口点,开始执行新程序。这样链接了动态库的可执行文件就加载完毕了。

3. 进程内存布局

我们通过 cat /proc/<pid>/maps 命令查看进程虚拟地址空间内存布局,来验证上面学习的理论知识,输出示例如下:

20260325-0006

参考资料:

图文详解 Linux 动态库(.so 文件)工作原理