☰
ELF与库加载
2026/10/20 1:43:00 网站建设 项目流程

对上一篇博客的补充:
结论1:当编译时,链接器发现同一个库同时存在动态库(.so)和静态库(.a)版本时,默认会优先链接动态库。

结论2:如果想强制使用静态库进行链接,需要在编译命令中加上-static选项。

结论3:使用-static选项的前提是,对应库必须确实提供了静态库版本。如果某个库只有动态库,那么即使加了-static,对于该库,链接器也只能进行动态链接。

结论4:如果某个库只提供了静态库(没有对应的动态库),那么即使你采用默认的动态链接方式,对于这个特定的库,链接器也只能进行静态链接。但程序仍然可以对其他提供了动态库的库进行动态链接。

结论5:-static选项是一个全局性要求。它强制要求程序所依赖的所有库都必须有对应的静态库版本,并且全部以静态链接的方式打包进最终的可执行程序中。

图1:链接器选择库的流程示意图

结论6:以fopen函数为例,其返回值FILE*指向的结构体对象,其内存空间实际上是由库函数内部调用malloc等函数为用户申请的。也就是说,申请内存这个动作的源代码实现在库的内部,但最终申请内存的“责任人”和消耗系统资源的仍然是调用该库函数的用户进程。

这里写目录标题

  • 真正调用别人的库
  • 编译过程
    • 预处理阶段(Preprocessing)
    • 编译阶段(Compilation)
    • 汇编阶段(Assembly)
  • 可执行程序与ELF格式
    • ELF格式简介
      • ELF格式细节
        • 程序头表中的“段”与进程地址空间
        • CPU 如何找到第一条指令?
      • 查看 ELF 文件结构
        • 1. 查看 ELF 头(ELF Header)
        • 2. 查看节头表(Section Headers)
        • 3. 查看程序头表(Program Headers)
    • 节vs段
  • 可执行程序的加载与虚拟地址空间
    • 加载顺序:先有数据结构还是先加载文件?
    • 编译时地址:程序在磁盘上有地址吗?
    • 深入理解:平坦模式与地址概念辨析
  • 可执行程序的加载与地址空间映射
  • 动态库的加载机制
    • 多进程共享动态库

真正调用别人的库

编译过程

预处理阶段(Preprocessing)

  • 文件后缀:.i
  • 阶段任务:处理源代码中以#开头的预处理指令。例如,将#include <ncurses.h>包含的头文件内容完整展开到代码中、替换#define定义的宏、删除代码中的注释等。

编译阶段(Compilation)

  • 文件后缀:.s
  • 阶段任务:将预处理后的.i文件进行词法分析、语法分析和语义分析,最终翻译成汇编语言代码(人类勉强能看懂的低级语言)。

汇编阶段(Assembly)

  • 文件后缀:.o(在 Windows 系统下通常是.obj)
  • 阶段任务:将汇编代码翻译成机器能直接识别的二进制机器码。这个阶段的产物称为目标文件(全称:可重定位目标文件)。此时生成的文件还不能直接运行,因为其中引用的外部函数(例如ncurses库里的函数)地址尚未最终确定。

可执行程序与ELF格式

可执行程序是一个二进制文件,但它并非随意堆砌的二进制数据,而是具有特定的内部结构和格式。

这个格式,我们称之为 ELF 格式。

ELF格式简介

ELF(Executable and Linkable Format),即可执行与可链接格式,是 Unix/Linux 系统下可执行文件、目标文件、共享库和核心转储(core dumps)的标准文件格式。

ELF 文件主要分为四种类型:

  1. 可重定位文件(Relocatable File):即.o目标文件。包含代码和数据,适合于与其他目标文件链接以创建可执行文件或共享库。
  2. 可执行文件(Executable File):即可直接运行的程序。
  3. 共享目标文件(Shared Object File):即.so动态链接库文件。
  4. 核心转储(Core Dumps):存放进程意外终止时的执行上下文(内存映像、寄存器状态等),主要用于调试和信号分析。

ELF格式细节

一个 ELF 文件由以下四个核心部分组成:

  1. ELF 头(ELF Header)
    位于文件起始位置,描述了文件的主要特性(如文件类型、机器架构、入口点地址等)。它的主要作用是定位文件的其他部分,并管理整个 ELF 文件的属性信息。

  2. 程序头表(Program Header Table)
    列举了所有有效的**段(Segments)**及其属性。表中记录了每个段在文件中的起始位置(偏移量)、长度等信息。由于这些段在二进制文件中是紧密排列的,需要依赖程序头表的描述信息才能将它们正确分割和识别。

  3. 节头表(Section Header Table)
    包含了对所有**节(Sections)**的描述信息。节是 ELF 文件的基本组成单位。

  4. 节(Section)
    ELF 文件的基本组成单元,用于存储特定类型的数据。文件的各种信息(如可执行代码、全局变量、字符串常量等)都分类存储在不同的节中。一个节内的数据通常具有相同的类型。

    • 代码节(.text):存储程序的机器指令,是主要的可执行部分。
    • 数据节(.data):存储已初始化的全局变量和静态变量。

    注: ELF的节是给编译链接器使用的,用来划分代码、常量、符号等原始数据;段是给操作系统加载程序使用的,链接过程会把权限属性相同的多个节合并成一个段,内核以段为单位映射到内存运行。
    汇编生成含节的 .o 文件;链接器合并目标文件,将节打包成段、生成程序头表;内核加载ELF,读取程序头表,按段映射到虚拟内存。

图2:ELF 文件结构示意图

程序头表中的“段”与进程地址空间

程序头表描述的“段”(Segment)与进程的虚拟地址空间密切相关。当执行一个可执行程序时,操作系统会为其创建进程控制块(PCB),并建立虚拟地址空间。那么,这个地址空间应该如何划分?具体有多少个区域?每个区域又对应什么内容?这些问题的答案都与 ELF 文件中的程序头表信息直接相关。

图3:进程虚拟地址空间与 ELF 段映射示意图

CPU 如何找到第一条指令?

ELF 文件本质上是一种存储在磁盘上的文件格式。结合对磁盘存储的理解,我们可以将 ELF 文件视为一个“数组”,只不过这个“数组”的每个“元素”(即磁盘块)大小通常为 4KB。

那么,CPU 是如何知道从哪里开始执行第一条指令的呢?这需要通过特定的工具来查看 ELF 文件头中的关键信息。

查看 ELF 文件结构

1. 查看 ELF 头(ELF Header)

使用readelf -h命令可以查看目标文件的 ELF 头信息。

readelf-hhello.o

其中,Entry point address(入口点地址)是最重要的字段之一。它指明了未来可执行程序加载到内存后,CPU 开始执行的第一条指令的地址。

2. 查看节头表(Section Headers)

使用readelf -S命令可以查看目标文件包含的所有节(Section)的详细信息。

readelf-Shello.o

  • Offset(偏移量):表示该节相对于文件起始位置的字节偏移量。
  • Size(大小):表示该节所占的字节数。
  • Flags(标志位):表示该节的访问权限(如可读R、可写W、可执行E)。

节在文件中的布局规律:通常情况下,当前节的起始偏移量(Offset)等于上一个节的起始偏移量加上其大小(Size),再加上可能存在的对齐填充(Alignment Padding)。

符号与引用:.symtab节(符号表)存储了程序中定义的函数和变量名(符号)及其地址信息。当不同的变量或函数引用同一个地址时,相关的信息就记录在这个节中。

3. 查看程序头表(Program Headers)

使用readelf -l命令可以查看目标文件的程序头表,它描述了文件中的各个段(Segment)。

readelf-lhello.o


在程序头表的上下文中,Segment 通常被翻译为“段”,它描述了文件内容在加载到内存时应如何被组织成具有特定权限(如可读、可写、可执行)的内存区域。

节vs段

在ELF文件中,并不直接存在“段”(Segment)这一物理存储单元,而是存在一个个的“节”(Section)。这些节的大小各不相同,且可以拥有相同的访问权限。

“节”(Section)确实是存储在磁盘上的,更准确地说,它是可执行文件(如Windows的.exe文件或Linux的ELF文件)在磁盘上的一种内部组织结构。

ELF文件本身存储在磁盘上。当操作系统(OS)需要加载可执行程序时,会以4KB(页大小)为单位将文件内容读取到内存中。如果ELF文件中包含大量独立的节,直接逐个加载会占用大量内存空间。为了高效利用宝贵的内存资源,加载器(Loader)会在加载过程中,将多个具有相同访问权限(如可读、可写、可执行)的节合并成一个更大的内存区域,这个合并后的区域就称为“段”(Segment)。图3中展示的代码段、数据段等,正是这些合并后的内存段。

因此,在加载并运行可执行程序时,操作系统首先查看的是程序头表(Program Headers Table),它描述了这些最终将被加载到内存中的“段”的信息。

可执行程序的加载与虚拟地址空间

在理解了 ELF 文件的结构之后,我们继续探讨可执行程序是如何被加载到内存中并运行的。这涉及到进程地址空间的创建与映射。

加载顺序:先有数据结构还是先加载文件?

一个常见的疑问是:操作系统在运行一个程序时,是先创建内核进程相关的数据结构(如 PCB、页表、地址空间),还是先加载 ELF 二进制文件?

答案是:先创建内核进程相关的数据结构,再加载 ELF 的二进制文件。

操作系统首先会为即将运行的程序创建一个“空壳”进程,即初始化进程控制块(PCB)、建立空的页表和虚拟地址空间等核心数据结构。这个过程确定了进程在内核中的身份和资源框架。然后,加载器(Loader)才会根据 ELF 文件的程序头表(Program Header Table),将文件中的各个段(Segment)映射到这个已经创建好的虚拟地址空间中,并填充页表,最终使程序代码和数据变得可访问、可执行。

编译时地址:程序在磁盘上有地址吗?

第二个关键问题是:程序在编译链接完成之后,尚未被加载到内存时,它自身有没有地址?如果有,是什么地址?

答案是:“都有地址”。

编译器在编译可执行程序时,会采用一种称为平坦模式(Flat Mode)的编址方式。在这种模式下,编译器会对程序中的每一条指令、每一个数据(即“每一行”代码或数据单元)都分配一个逻辑地址。原则上,这个地址空间从 0 开始编号。

  • 在 32 位系统下,这个地址范围理论上是从0x00000000到0xFFFFFFFF(即从全 0 到全 1)。
  • 这个在编译时就确定好的、连续的地址空间,被称为虚拟地址(Virtual Address)。

重要理解:这些虚拟地址在编译和链接阶段就已经存在于磁盘上的 ELF 文件中了(例如,存储在.text节和.data节里的指令和数据都带有虚拟地址)。程序此时还在磁盘上,并未加载到物理内存。只有当程序运行时,操作系统才会将其从磁盘加载到物理内存,并通过页表机制,将这些预先编好的虚拟地址映射到实际的物理内存地址上。

因此,虚拟地址空间可以理解为一套“标准”或“约定”:

  1. 操作系统(OS)遵守它:OS 负责在加载程序时,建立并维护虚拟地址到物理地址的映射(页表)。
  2. 编译器也遵守它:编译器按照这套地址规范来生成目标代码,确保程序中的指令和数据引用都使用正确的虚拟地址。

深入理解:平坦模式与地址概念辨析

上文提到的“平坦模式”可以简单理解为从 0 开始加上一个偏移量(0 + 偏移量)的连续线性地址空间。需要明确的是,“平坦模式”本身并不是地址,而是一种内存寻址的规则或模型。正是由于采用了这种规则,逻辑地址和虚拟地址在数值上才得以完全等同。

我们可以从以下三个层面来理解它们之间的关系:

  1. 平坦模式(规则)
    它的核心思想是将整个内存空间视为一条从 0 到最大值(例如 32 位系统下的 4GB)的、连续且不分段的“直线”。在这条直线上,没有“几号楼几零几(段:偏移量)”的复杂层级概念,只有“第几号房间(线性地址)”这一个维度。这种模型简化了地址的表示和计算。

  2. 磁盘上:逻辑地址(程序发出的原始地址)
    在传统的 x86 架构中,逻辑地址由“段基址 + 偏移量”两部分组成。然而,在平坦模式下,操作系统为了简化内存管理,将所有段的“段基址”都强制设置为 0。此时,逻辑地址的计算公式就简化为:0 + 偏移量 = 偏移量。因此,逻辑地址的数值就退化为了单纯的偏移量。

  3. 操作系统:虚拟地址(操作系统呈现的线性地址)
    由于逻辑地址计算出的结果就是那个单纯的“偏移量”,而这个偏移量恰好对应平坦模式下那条“直线”上的某个位置。因此,逻辑地址的偏移量就直接等于虚拟地址(线性地址)。可以说,在平坦模式下,逻辑地址经过段机制的“零基址”处理后,其表现形式与虚拟地址完全一致。

为了更清晰地辨析这些概念,下表对比了逻辑地址与虚拟地址的关键差异:

维度逻辑地址 (Logical Address)虚拟地址 (Virtual Address)
核心组成段选择符 (Segment Selector) + 段内偏移量 (Offset)单一的、连续的线性地址(没有段结构)
处理阶段处于段式内存管理阶段,是 CPU 硬件分段机制的产物处于页式内存管理阶段,是分段地址转换后的结果
谁来生成由程序本身(编译器生成的代码或 CPU 指令)直接生成由硬件的段管理单元 (Segment Unit) 将逻辑地址转换而来
直观理解程序员或编译器视角的“相对位置”,需要结合段基址才能最终定位操作系统为每个进程绘制的“平坦大饼”,是一个统一的、从 0 开始的连续视图

简单来说,在 x86 架构的保护模式下,程序代码中直接出现的是逻辑地址(例如CS:IP)。CPU 的段管理单元会将其转换为线性地址(即虚拟地址),随后页管理单元再将这个虚拟地址转换为最终的物理地址。现代操作系统普遍采用“平坦模式”,即通过将所有段的基址设为 0,使得逻辑地址中的偏移量部分直接等同于虚拟地址,从而极大地简化了内存管理。

可执行程序的加载与地址空间映射

当可执行程序被加载到物理内存时,其指令和数据既需要虚拟地址,也需要对应的物理地址来存储。然而,在程序尚未加载之前,物理地址空间尚未分配,但虚拟地址空间已经存在。正如前文所述,进程的task_struct结构体中的mm_struct成员负责管理虚拟地址空间,其初始化数据来源于磁盘上的可执行文件。加载器(Loader)正是根据 ELF 文件的程序头表信息,将文件中的各个段(Segment)映射到这个预先建立的虚拟地址空间中,并建立虚拟地址到物理地址的页表映射,最终完成程序的加载过程。

当mm_struct结构体初始化完毕,加载器(Loader)便开始工作。它根据 ELF 文件的程序头表,将文件的各个段(Segment)映射到进程的虚拟地址空间中。在这个过程中,操作系统会为这些虚拟地址区域分配物理内存页,并动态地建立虚拟地址到物理地址的页表映射。映射的建立是随着加载过程逐步完成的,并非在所有物理地址分配完毕后才一次性填充。

当所有必要的段都加载完毕,页表映射也建立完成后,CPU 便可以从可执行程序的 ELF 头(ELF Header)中读取入口点地址(Entry Point Address)。操作系统会将这个入口点的虚拟地址加载到指令指针寄存器(EIP/RIP)中。随后,CPU 便从这个地址开始,通过程序计数器(PC)指针依次取指并执行程序指令。

在程序内部的时候,要调用函数地址,这个地址是什么地址?虚拟地址,cpu进来的地址,都是虚拟地址。cpu内部还有个CR3,他会指向页表,还有一个MMU当CPU拿到了虚拟地址,会把虚拟地址交给MMU,MMU会通过CR3去访问页表,至此,从CPU中出来的地址,就全是物理地址了。从虚拟地址转化到物理地址,全是在CPU中完成的。
总结一下:虚拟地址空间,不仅仅让OS参与,编译器也要参与这个过程。

总结一下整个流程:
程序加载的本质,就是把 ELF 文件的各个段,通过 mm_struct 规划到进程的虚拟地址空间中,并在 CPU 真正访问时,由操作系统通过缺页异常分配物理内存,最终利用页表建立起“虚拟地址 -> 物理地址”的映射关系。这样,程序以为自己独占了一整块连续的内存,而实际上它的代码和数据可能散落在物理内存的各个角落。

动态库的加载机制

动态库的加载与进程的虚拟地址空间管理密切相关。每个进程的进程控制块(PCB)中都有一个mm指针,它指向一个mm_struct结构体。这个结构体负责管理进程的虚拟地址空间,其中包含了描述地址空间布局的数据以及指向各个内存区域的指针。

动态库在加载时,会被映射到进程虚拟地址空间的共享区。如下图所示,动态库的代码和数据被映射到多个进程共享的虚拟地址区域:

多进程共享动态库

对于多个进程同时使用同一个动态库的情况,操作系统采用了高效的共享机制:在物理内存中,同一个动态库只需要存在一份副本。

当多个进程加载同一个动态库时,操作系统会将这个动态库的代码段(.text)映射到各个进程的虚拟地址空间共享区。由于代码段通常是只读的,多个进程可以安全地共享同一份物理内存中的代码。对于数据段,如果包含可写数据,操作系统会使用写时复制(Copy-On-Write)技术,确保进程间的数据隔离。

这种设计带来了显著的优势:

  1. 节省内存:多个进程共享同一份物理内存中的库代码,大幅减少内存占用。
  2. 提高加载速度:后续进程加载同一动态库时,可以直接映射已加载到内存的库,无需从磁盘重复读取。
  3. 便于更新:更新动态库时,只需替换磁盘上的库文件,新启动的进程会自动使用新版本。

下图展示了多个进程共享同一动态库的示意图:

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询