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

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

目录

大家好,这里是物联网心球。

说到Linux动态库(.so文件)大家并不陌生,我想每个Linux开发者都应该使用过Linux动态库。动态库的使用其实并不简单,我们平时习以为常的动态库操作背后都隐藏了哪些秘密?本文我们一一来揭晓。

1.测试项目简介

学习Linux动态库时,最好先准备一个实际的测试项目,边调试边学习,这样才能够做到深入理解。笔者使用的测试项目目录结构如图1所示。

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

图1 测试项目目录结构

example.c和example.h文件将被编译为libexample.so动态库文件,example.c文件中定义了两个函数(hello函数和add函数)以及一个全局变量global。

main.c文件将被编译成可执行文件,main函数调用hello和add函数,并将global变量重新赋值。可执行文件编译的过程需要链接libexample.so动态库文件。

2.动态库文件

动态库文件是在程序运行时才被加载和链接的共享代码模块 。Linux系统中通常以 .so (Shared Object) 结尾,例如 libm.so(数学库)、libc.so(C标准库)。

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

  • 代码共享:多个程序可以共享同一份动态库代码,节省磁盘空间和内存资源。

  • 运行时加载:动态库在程序运行时才被加载到内存。

  • 位置无关代码(PIC):动态库可加载到不同的内存地址,避免地址冲突。

动态库文件其实并不神秘,它也是一种ELF文件。ELF文件基础介绍请参考我的这篇文章:从ELF文件到Linux进程。 动态库文件ELF格式如图2所示。

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

图2 动态库文件ELF格式

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

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

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

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

3.编译和链接阶段

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

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

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

gcc main.c -o a.out -I. -L. -lexample -Wl,-rpath='$ORIGIN':/opt
  • -L选项:用于编译时指定动态库搜索路径。

  • -l选项:用于编译时指定动态库文件名(去掉lib前缀和扩展名)。

  • -Wl,-rpath选项:用于向可执行文件中嵌入一个运行时搜索路径(Runpath)。

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

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

图3 可执行文件ELF格式

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

(1).interp节

.interp节是一个包含动态链接器路径的节,用于指定可执行文件运行时使用哪个动态链接器来加载和解析动态库。

通过 readelf -d 可执行文件 命令可以查看.interp节信息,输出示例如下:

# 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).dynsym节

.dynsym节是 ELF 文件中的动态符号表,它包含了动态链接时所需的符号信息。这些符号通常是全局变量和函数,它们在程序运行时被动态加载和解析。

通过 readelf -sD 可执行文件 命令可以查看.dynsym节,输出示例如下:

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

相关字段解析如下:

  • Num:符号表中的条目编号。

  • Value:符号的地址或值。对于已定义符号,这通常是一个虚拟地址;对于未定义的符号(如UND),该值为0。

  • Size:符号的大小,以字节为单位。函数的大小是其指令长度,变量的大小是其占用的字节数。如果大小为0,通常表示该符号的大小未知或不是数据对象(如节名或未定义符号)。

  • Type:符号的类型,常见的类型有:

    • NOTYPE:类型未指定。

    • SECTION:该符号关联到一个节。

    • FUNC:表示是一个函数。

    • OBJECT:表示是一个数据对象。

  • Bind:符号的绑定属性,表示符号的可见范围,常见的绑定属性有:

    • LOCAL:局部符号,只在当前目标文件内可见。
    • GLOBAL:全局符号,可被其他目标文件引用。
    • WEAK:弱符号,类似于全局符号,但优先级较低。如果存在同名的全局符号,则全局符号优先;如果没有全局符号,则使用弱符号。
  • Vis:符号的可见性,常见类型如下:

    • DEFAULT:默认可见性,符号可以被其他模块引用。
    • HIDDEN:符号在模块外不可见。
    • INTERNAL:内部可见性。
    • PROTECTED:受保护的,该符号在模块外可见,但不可被覆盖(即不能重定位)。
  • Ndx:符号所在的节的索引,其中:

    • 数字:对应节的索引。
    • UND:未定义符号(需动态链接)。
    • ABS:绝对值符号。
  • Name:符号的名称及版本信息。

(3).dynamic节

.dynamic节用于存储动态链接器在运行时需要的信息,包括动态库依赖关系、动态库搜索路径、节的位置等。

通过 readelf -d 可执行文件 命令可以查看.dynamic节,输出示例如下:

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

.dynamic节中的信息比较多,我们只需要关注和本文相关的信息。

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

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

4.运行时动态链接阶段

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

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

图4 运行时动态连接阶段

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

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

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

4.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)跳转至动态链接器入口点

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

4.2 加载动态库

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

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

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

(2)顺序搜索动态库

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

  • 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可执行程序入口点,开始执行新程序。这样链接了动态库的可执行文件就加载完毕了。

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

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

← 返回文章列表