ELF文件和动态链接与动态库加载

1.ELF文件

1.1如何理解ELF文件

要理解编译链链接的细节,我们不得不了解⼀下ELF⽂件。其实有以下四种⽂件其实都是ELF⽂件:

• 可重定位⽂件(Relocatable File) :即xxx.o⽂件。包含适合于与其他⽬标⽂件链接来创

建可执⾏⽂件或者共享⽬标⽂件的代码和数据。

• 可执⾏⽂件(Executable File) :即可执⾏程序。

• 共享⽬标⽂件(Shared Object File) :即xxx.so⽂件。

• 内核转储(core dumps) ,存放当前进程的执⾏上下⽂,⽤于dump信号触发。

⼀个ELF⽂件由以下四部分组成:

• ELF头(ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂

件的其他部分。

• 程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表⾥

记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,

需要段表的描述信息,才能把他们每个段分割开。

• 节头表(Section header table) :包含对节(sections)的描述。

• 节(Section ):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和

数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。

bash 复制代码
readelf -h 文件名

可以查看到拥有elf文件的ELF文件头,接下来解释一下里面重要的信息

(Entry Point Address),它在程序执行过程中扮演着重要角色: 入口点地址的作用

ELF文件头字段解释

1. Start of program headers: 64 (bytes into file)

  • 含义 :程序头表在文件中的起始位置
  • 作用 :程序头表包含了加载程序所需的段信息,操作系统加载器从这个位置开始读取段信息
  • 值 :从文件开头偏移64字节处

2. Start of section headers: 6480 (bytes into file)

  • 含义 :节头表在文件中的起始位置
  • 作用 :节头表描述了文件中的各个节(section),如代码节、数据节等
  • 值 :从文件开头偏移6480字节处

3. Flags: 0x400

  • 含义 :ELF文件的标志位
  • 作用 :表示文件的特性,如处理器架构等
  • 值 :0x400通常表示这是一个64位文件

4. Size of this header: 64 (bytes)

  • 含义 :ELF文件头本身的大小
  • 作用 :告诉加载器文件头占用多少字节
  • 值 :64字节(64位ELF文件头的标准大小)

5. Size of program headers: 56 (bytes)

  • 含义 :每个程序头表项的大小
  • 作用 :加载器需要知道每个段描述符的大小
  • 值 :56字节(64位ELF程序头的标准大小)

6. Number of program headers: 9

  • 含义 :程序头表中的表项数量
  • 作用 :表示文件中有多少个加载段
  • 值 :9个段

7. Size of section headers: 64 (bytes)

  • 含义 :每个节头表项的大小
  • 作用 :链接器需要知道每个节描述符的大小
  • 值 :64字节(64位ELF节头的标准大小)

8. Number of section headers: 30

  • 含义 :节头表中的表项数量
  • 作用 :表示文件中有多少个节
  • 值 :30个节

9. Section header string table index: 29

  • 含义 :节头字符串表在节头表中的索引
  • 作用 :用于存储节名称的字符串表
  • 值 :第29个节是字符串表


所以ELF文件可以看成一维数组,偏移量就相当于数组下标

2.2ELF从形成到加载轮廓

1.ELF形成可执⾏

• step-1:将多份 C/C++ 源代码,翻译成为⽬标 .o ⽂件?+?动静态库(ELF)

• step-2:将多份 .o ⽂件section进⾏合并

2.ELF可执⾏⽂件加载

• ⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment

• 合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等.

• 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起

• 很显然,这个合并⼯作也已经在形成 ELF 的时候,合并⽅式已经确定了,具体合并原则被记录在

了 ELF 的 程序头表(Program header table) 中

1. 不同.o文件在链接时的合并

这是最主要的合并过程:

  • 输入 :多个目标文件(.o文件),每个都有自己的section(如.text、.data等)
  • 过程 :链接器将所有输入文件中相同类型的section合并
    • 所有.o文件的.text节合并成一个大的.text节
    • 所有.o文件的.data节合并成一个大的.data节
    • 依此类推...
  • 输出 :一个包含合并后section的最终ELF文件

2. 同一ELF文件内部的section组织成segment

在生成最终ELF文件时:

  • 输入 :合并后的各个section
  • 过程 :链接器根据section的属性(如读写权限、是否可执行等)将它们组织成segment
    • 可执行的只读section(如.text、.rodata)通常合并到同一个代码段
    • 可读写的数据section(如.data、.bss)通常合并到同一个数据段
    • 其他特殊section也会根据需要组织到相应的segment
  • 输出 :包含多个segment的ELF文件,每个segment包含一个或多个section

为什么需要这样的合并

合并不同.o文件的section

  1. 减少开销 :减少section数量,降低ELF文件头大小
  2. 提高效率 :相同类型的代码/数据集中存储,提高内存访问效率
  3. 符号解析 :便于跨文件的符号引用解析

2.理解连接与加载

2.1静态链接

• ⽆论是⾃⼰的 .o ,还是静态库中的 .o ,本质都是把.o⽂件进⾏连接的过程

• 所以:研究静态链接,本质就是研究 .o 是如何链接的

链接重定位,就是在链接生成的最终可执行文件中,把所有"待填地址的坑"(比如 main.o 里的 call run

),用正确的地址或偏移量填上。

2.2ELF加载与进程地址空间

• ⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机⼯作的时候,都采⽤"平坦

模式"进⾏⼯作。所以也要求ELF对⾃⼰的代码和数据进⾏统⼀编址,下⾯是 objdump -S 反汇编

之后的代码

最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量),但是我们

认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执

⾏程序进⾏统⼀编址了.

• 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?从ELF各个

segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start,end]

等范围数据,另外在⽤详细地址,填充⻚表.
所以:虚拟地址机制,不光光OS要⽀持,编译器也要⽀持.

  1. 链接时:链接器为代码和数据分配虚拟地址,生成 ELF 文件。

  2. 加载时:内核根据 ELF 创建虚拟地址空间(VMA)和空页表。

    读可执行文件的程序头(program header)

    按照里面记录的 虚拟地址、长度、权限

    把 .text / .data / .rodata 等映射到内存

    跳去入口地址执行Entry Point Address

  3. 运行时:CPU 执行虚拟地址,MMU 通过页表翻译成物理地址,缺页时才真正加载数据到物理内存。

  4. 调用时:所有函数调用和数据访问,用的都是虚拟地址,由 MMU 负责翻译。

2.3动态链接与动态库加载

1.进程如何看到动态库

库的加载 :动态库通过映射机制从磁盘加载到物理内存,然后映射到进程的虚拟地址空间中的共享区

2.进程间如何共享库的

3.动态链接

动态链接其实远⽐静态链接要常⽤得多。⽐如我们查看下 hello 这个可执⾏程序依赖的动态库,会发

现它就⽤到了⼀个c动态链接库:

bash 复制代码
$ ldd main.exe
linux-vdso.so.1 => (0x00007ffefd43f000)
libc.so.6 => /lib64/libc.so.6 (0x00007f533380b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f5333bd9000)

那为什么编译器默认不使⽤静态链接呢?

静态链接会将编译产⽣的所有⽬标⽂件,连同⽤到的各种

库,合并形成⼀个独⽴的可执⾏⽂件,它不需要额外的依赖就可以运⾏。照理来说应该更加⽅便才对

是吧?

静态链接最⼤的问题在于⽣成的⽂件体积⼤,并且相当耗费内存资源。随着软件复杂度的提升,我们

的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费⼤量的硬盘

空间。

这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独

⽴的动态链接库,等到程序运⾏的时候再将它们加载到内存,这样不但可以节省空间,因为同⼀个模

块在内存中只需要保留⼀份副本,可以被不同的进程所共享。

动态链接到底是如何⼯作的

⾸先要交代⼀个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候 。⽐如我们去运⾏

⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动

态库的加载地址都是不固定的,操作系统会根据当前地址空间的使⽤情况为它们动态分配⼀段内存。

当动态库被加载到内存以后,⼀旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转

地址了。

动态链接完整流程(GOT/PLT机制)

1. 编译阶段(留坑)

  • 调用 printf() 时,编译器生成 call printf@PLT 而非直接调用
  • PLT(过程链接表)是小段代码,负责跳转到 .got.plt 中的地址
  • 初始时, .got.plt 中的地址指向动态链接器的解析函数

2. 首次调用(填坑)

  1. 执行 call printf@PLT 跳转到PLT代码
  2. PLT跳转到 .got.plt 中的地址(此时指向动态链接器)
  3. 动态链接器查找 libc.so 中 printf 的真实虚拟地址
  4. 将真实地址填入 .got.plt 对应条目
  5. 跳转到 printf 真实地址执行

3. 后续调用(直接跳)

  1. 执行 call printf@PLT
  2. PLT跳转到 .got.plt 中的地址(已是真实地址)
  3. 直接执行,无需动态链接器介入

地址计算机制

  • 动态库基地址 :加载时分配的虚拟地址
  • 函数偏移量 :函数在动态库文件内的相对位置(编译时确定)
  • 真实函数地址 = 动态库基地址 + 函数偏移量
  • 此真实地址就是填入 .got.plt 的值

核心原理

  • 延迟绑定 :首次调用才解析地址,提高启动速度
  • 缓存机制 :解析后缓存地址,后续调用直接使用
  • 位置无关 :通过偏移量计算,支持动态库在不同地址加载

在C/C++程序中,当程序开始执⾏时,它⾸先并不会直接跳转到 main 函数。实际上,程序的⼊⼝点

是 _start ,这是⼀个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数。

在 _start 函数中,会执⾏⼀系列初始化操作,这些操作包括:

  1. 设置堆栈:为程序创建⼀个初始的堆栈环境。
  2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位
    置,并清零未初始化的数据段。
  3. 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的
    动态库(sharedlibraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调
    ⽤和变量访问能够正确地映射到动态库中的实际地址。
    动态链接器:
    ◦ 动态链接器(如ld-linux.so)负责在程序运⾏时加载动态库。
    ◦ 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
    环境变量和配置⽂件:
    ◦ Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置⽂件(如/etc/ld.so.conf及其⼦配置
    ⽂件)来指定动态库的搜索路径。
    ◦ 这些路径会被动态链接器在加载动态库时搜索。
    缓存⽂件:
    ◦ 为了提⾼动态库的加载效率,Linux系统会维护⼀个名为/etc/ld.so.cache的缓存⽂件。
    ◦ 该⽂件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会⾸先
    搜索这个缓存⽂件。
  4. 调⽤ __libc_start_main :⼀旦动态链接完成, _start 函数会调⽤
    __libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执⾏
    ⼀些额外的初始化⼯作,⽐如设置信号处理函数、初始化线程库(如果使⽤了线程)等。
  5. 调⽤ main 函数:最后, __libc_start_main 函数会调⽤程序的 main 函数,此时程序的执
    ⾏控制权才正式交给⽤⼾编写的代码。
  6. 处理 main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回
    值,并最终调⽤ _exit 函数来终⽌程序。

上述过程描述了C/C++程序在 main 函数之前执⾏的⼀系列操作,但这些操作对于⼤多数程序员来说

是透明的。程序员通常只需要关注 main 函数中的代码,⽽不需要关⼼底层的初始化过程。然⽽,了

解这些底层细节有助于更好地理解程序的执⾏流程和调试问题。

4.我们的程序,怎么和库具体映射起来的?

• 动态库也是⼀个⽂件,要访问也是要被先加载,要加载也是要被打开的

• 让我们的进程找到动态库的本质:也是⽂件操作,不过我们访问库函数,通过虚拟地址进

⾏跳转访问的,所以需要把动态库映射到进程的地址空间中

相关推荐
大尚来也2 小时前
跨平台全局键盘监听实战:基于 JNativeHook 在 Java 中捕获 Linux 键盘事件
java·linux
Trouvaille ~2 小时前
【Linux】数据链路层与以太网详解:从 MAC 地址到 ARP 的完整指南
linux·运维·服务器·网络·以太网·数据链路层·arp
Ronin3053 小时前
【Linux网络】Socket编程:UDP网络编程实现ChatServer
linux·网络·udp
面向对象World4 小时前
正点原子Mini Linux 4.3寸800x480触摸屏gt115x驱动
linux·服务器·数据库
17(无规则自律)4 小时前
LubanCat 2烧录一个新镜像后开发环境搭建
linux·嵌入式硬件·考研·软件工程
『往事』&白驹过隙;5 小时前
浅谈PC开发中的设计模式搬迁到ARM开发
linux·c语言·arm开发·设计模式·iot
闲人编程7 小时前
Redis分布式锁实现
redis·分布式·wpf·进程··死锁·readlock
Hello.Reader7 小时前
从 0 到 1 理解硬盘数据恢复工具原理与工程实现
linux·运维·服务器·网络·数据库
『往事』&白驹过隙;9 小时前
C/C++中的格式化输出与输入snprintf&sscanf
linux·c语言·c++·笔记·学习·iot·系统调用