【Linux学习笔记】深入理解ELF和动静态库加载原理

发布于:2025-05-26 ⋅ 阅读:(26) ⋅ 点赞:(0)

【Linux学习笔记】深入理解ELF和动静态库加载原理

🔥个人主页大白的编程日记

🔥专栏Linux学习笔记


前言

哈喽,各位小伙伴大家好!上期我们讲了动静态库 今天我们讲的是深入理解ELF和动静态库加载原理。话不多说,我们进入正题!向大厂冲锋!
在这里插入图片描述

一. 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文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。

最常见的节:

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

二. ELF从形成到加载轮廓

2.1 ELF形成可执行

  • step-1:将多份 C/C++ 源代码,翻译成为目标 .o 文件
  • step-2:将多份 .o 文件section进行合并

注意:

  • 实际合并是在链接时进行的,但是并2.2不是这么简单的合并,也会涉及对库合并,此处不做过多追究

2.2 ELF可执行文件加载

一个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成Segment

  • 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等。
  • 这样,即便是不同的Section,在加载到内存中,可能会以Segment的形式,加载到一起
  • 很显然,这个合并工作也已经在形成ELF的时候,合并方式已经确定了,具体合并原则被记录在了ELF的程序头表(Program header table)中
# 查看可执行程序的section
$ readelf -S a.out

There are 31 section headers, starting at offset 0x19d8:

Section Headers:
[Nr] Name              Type             Address           Offset            Size              EntSize          Flags  Link  Info  Align
[ 0]                   NULL             0000000000000000  00000000          00000000          00000000          0      0      0
[ 1] .interp           PROGBITS         0000000000400238  00000238          0000001c          00000000          A       0      0     1
[ 2] .note.ABI-tag     NOTE             0000000000400254  00000254          00000020          00000000          A       0      0     4
[ 3] .note.gnu.build-id NOTE             0000000000400274  00000274          00000024          00000000          A       0      0     4
[ 4] .gnu.hash         GNU_HASH         0000000000400298  00000298          0000001c          00000000          A       5      0     8
[ 5] .dynsym           DYNSYM           00000000004002b8  000002b8          00000048          00000010          A       6      1     8
[ 6] .dynstr           STRTAB           0000000000400300  00000300          00000038          00000000          A       0      0     1
[ 7] .gnu.version      VERSYM           0000000000400338  00000338          00000006          00000000          A       5      0     2
[ 8] .gnu.version_r    VERNEED          0000000000400340  00000340          00000028          00000000          A       6      1     8
[ 9] .rel.dyn          RELA             0000000000400360  00000360          00000018          00000008          A       5      0     8
[10] .rel.plt          RELA             0000000000400378  00000378          00000018          00000008          A       5      24    8
[11] .init            PROGBITS         0000000000400390  00000390          00000000          00000000          AX      0      0     4
$ readelf -l a.out

Elf file type is EXEC (Executable file)
Entry point 0x4003e0
There are 9 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000040 0x0000000400040 0x0000000400040 0x01f8 0x01f8 R E 8
  INTERP         0x000038 0x0000000400238 0x0000000400238 0x001c 0x001c R   1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000400000 0x0000000400000 0x0744 0x0744 R E 200000
  LOAD           0x000e10 0x0000000600e10 0x0000000600e10 0x0218 0x0220 RW  200000
  DYNAMIC        0x000e28 0x0000000600e28 0x0000000600e28 0x001d 0x001d RW  8
  NOTE           0x000254 0x0000000400254 0x0000000400254 0x0024 0x0024 R   4
  GNU_EH_FRAME    0x0005a0 0x00000004005a0 0x00000004005a0 0x004c 0x004c R   4
  GNU_STACK      0x000000 0x0000000000000 0x0000000000000 0x0000 0x00000000 RW  10
  GNU_RELRO      0x000e10 0x0000000600e10 0x0000000600e10 0x001f 0x001f R   1

Section to Segment mapping:
  Segment Sections...
    00     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .got .text .fini .rodata .eh_frame_hdr .eh_frame
    01     .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
    02     .dynamic
    03     .note.ABI-tag .note.gnu.build-id
    04     .eh_frame_hdr
    05     .init_array .fini_array .jcr .dynamic .got
...

为什么要将section合并成为segment

  • Section合并的主要原因是为了减少页面碎片,提高内存使用率。如果不进行合并,假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分为4097字节,init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个页面。
  • 此外,操作系统在加载程序时,会将具有相同属性的section合并成一个大的segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。

对于程序头表和节头表有什么用呢,其实 ELF 文件提供2个不同的视图/视角来让我们理解这两个部分:
在这里插入图片描述

  • 链接视图(Linking view)- 对应节头表 Section header table

    • 文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解 ELF 文件中包含的各个部分的信息。
    • 为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等。合并了后,空间利用率就高了,否则,很小的很小的一段,未来物理内存页浪费很大(物理内存页分配一般都是整数倍一块给你,比如4k),所以,链接器趁着链接就把小块(们都合并了。
  • 执行视图(execution view)- 对应程序头表 Program header table

    • 告诉操作系统,如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中,一定有program header table。
    • 说白了就是:一个在链接时作用,一个在运行时加载时作用。

从链接视图 来看:

命令 readelf -S hello.o 可以帮助查看ELF文件的节头表。

  • .text节:是保存了程序代码指令的代码节。

  • .data节:保存了初始化的全局变量和局部静态变量等数据。

  • .bss节:为未初始化的全局变量和局部静态变量等预留位置

  • .rodata节:保存了只读的数据,如一行C语言代码中的字符串。由于.rodata节是只读的,所以只能存在于一个可执行文件的只读段中。因此,只能在text段(不是data段)中找到.rodata节。

  • .BSS节:为未初始化的全局变量和局部静态变量预留位置

  • .symtab节:Symbol Table 符号表,就是源码里面那些函数名、变量名和代码的对应关系。

  • .got.plt节(全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改。对于GOT的理解,我们后面会说。

    • 使用 readelf 命令查看.so文件可以看到该节。

从执行视图 来看:

  • 告诉操作系统哪些模块可以被加载进内存。
  • 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的。

我们可以在 hello.o 文件 中找到文件的基本信息,以及可以看到ELF头是如何定位程序头表和节头表的。例如我们查看下hello.o这个可重定位文件的主要信息:

// 查看目标文件
$ readelf -h hello.o
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:   ELF64
  Data:    2's complement, little endian
  Version: 1 (current)
  OS/ABI:  UNIX - System V
  ABI Version: 0
  Type:    REL (Relocatable file)
  Machine: Advanced Micro Devices X86-64
  Version: 0x1
  Entry point address: 0x0
  Start of program headers: 64 (bytes into file)
  Start of section headers: 14768 (bytes into file)
  Flags: 0x0
  Size of this header: 64 (bytes)
  Size of program headers: 56 (bytes)
  Number of program headers: 0
  Size of section headers: 64 (bytes)
  Number of section headers: 13
  Section header string table index: 12
$ gcc -o a.out
$ readelf -h a.out
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:   ELF64
  Data:    2's complement, little endian
  Version: 1 (current)
  OS/ABI:  UNIX - System V
  ABI Version: 0
  Type:    DYN (Shared object file)
  Machine: Advanced Micro Devices X86-64
  Version: 0x1
  Entry point address: 0x4003e0
  Start of program headers: 64 (bytes into file)
  Start of section headers: 14768 (bytes into file)
  Flags: 0x0
  Size of this header: 64 (bytes)
  Size of program headers: 56 (bytes)
  Number of program headers: 13
  Size of section headers: 64 (bytes)
  Number of section headers: 31
  Section header string table index: 30

对于 ELF HEADER 这部分来说,我们只用知道其作用即可,它的主要目的是定位文件的其他部分。

在这里插入图片描述

三. 理解连接与加载

3.1 静态链接

  • 无论是自己的.o,还是静态库中的.o,本质都是把.o文件进行连接的过程
  • 所以:研究静态链接,本质就是研究.o是如何链接的
$ ll
-rw-rw-r-- 1 whb whb 62 Oct 31 15:36 code.c
-rw-rw-r-- 1 whb whb 103 Oct 31 15:36 hello.c
-rw-rw-r-- 1 whb whb 103 Oct 31 15:36 hello.o
-rw-rw-r-- 1 whb whb 1744 Oct 31 15:46 main.exe

$ objdump -d code.o
code.o:     file format elf64-x86-64

查看编译后的.o目标文件

```bash
Disassembly of section .text:
0:   55                      push   %ebp
1:   48 89 e5                mov    %rsp,%ebp
2:   48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
3:   e8 00 00 00 00          callq  8 <run+0x14>
4:   c9                      leaveq 
5:   c3                      retq   
$ objdump -d hello.o
hello.o:     file format elf64-x86-64

Disassembly of section .text:
0:   55                      push   %ebp
1:   48 89 e5                mov    %rsp,%ebp
2:   48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
3:   e8 00 00 00 00          callq  8 <main+0x14>
4:   c9                      leaveq 
5:   c3                      retq   
  • objdump -d命令:将代码段(.text)进行反汇编查看
  • hello.o中的 main函数不认识printfrun函数
$ cat hello.c
#include<stdio.h>
void run();
int main()
{
	printf("hello world! (n");
	run();
	return 0;
}

code.o 不认识 printf 函数

$ cat code.c
#include<stdio.h>
void run()
{
	printf("running...\n");
}

我们可以看到这里的call指令,它们分别对应之前调用的printf和run函数,但是你会发现他们的跳转地址都被设成了0。这是为什么呢?

其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run
函数的存在的,比如他们位于内存的哪个区块,代码长什么样都是不知道的。因此,编译器只能将这两个函数的跳转地址先暂时设为0。

这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块 (.data)
中还存在一个重定位表,这张表将来在链接的时候,就会根据表里记录的地址将其修正。

静态链接就是把库中的.o进行合并,和上述过程一样
所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。

在这里插入图片描述

在这里插入图片描述

  • 所以,链接过程中会涉及到对.o中外部符号进行地址重定位。

3.2 ELF加载与进程地址空间

在这里插入图片描述

3.2.1 虚拟地址/逻辑地址

问题:

  • 一个ELF程序,在没有被加载到内存的时候,有没有地址呢?
  • 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?

答案:

  • 一个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机工作的时候,都采用“平坦模式”进行工作。所以也要求ELF对自己的代码和数据进行统一编址,下面是objdump -S 反汇编之后的代码

    最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量),但是我们认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统一编址了。

  • 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的[start, end]等范围数据,另外在用详细地址,填充页表。

所以:虚拟地址机制,不光光OS要支持,编译器也要支持。
在这里插入图片描述

3.2.2 重新理解进程虚拟地址空间

ELF在被编译好之后,会把自己未来程序的入口地址记录在ELF header的Entry字段中:

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:   ELF64
  Data:    2's complement, little endian
  Version: 1 (current)
  OS/ABI:  UNIX - System V
  ABI Version: 0
  Type:    DYN (Shared object file)
  Machine: Advanced Micro Devices x86-64
  Version: 0x1
  Entry point address: 0x1060

  Start of program headers: 64 (bytes into file)
  Start of section headers: 14768 (bytes into file)
  Flags: 0x0
  Size of this header: 64 (bytes)
  Size of program headers: 56 (bytes)
  Number of program headers: 13
  Size of section headers: 64 (bytes)
  Number of section headers: 31
  Section header string table index: 30

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

四.动态链接

4.1 概要

  • 动态链接其实远比静态链接要常用得多。比如我们查看下 hello 这个可执行程序依赖的动态库,会发现它就用到了一个动态链接库:
$ ld hello
linux-vdso.so.1 => (0x00007fffeba000)
libc.so.6 => /lib64/libc.so.6 (0x00007f7f76af5000)
/lib64/ld-linux-x86-64.so.2 (0x00007f7f77ec3000)

这里的 libc.so 是 C 语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等等这些功能。

那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。那理来说应该更加方便才对是吧?

静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。

这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程共享。

动态链接到底是如何工作的?

  • 首先要交代一个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。

4.2 我们的可执行程序被编译器动了手脚

$ ld /usr/bin/ls
linux-vdso.so.1 => (0x00007fffdd85f00)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f42c025a000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f42c068000)
libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f42bfd7000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f42bfda1000)
/lib64/ld-linux-x86-64.so.2 (0x00007f42c2b60000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f42bfea000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f197ee3b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f197ee3b000)

在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上,程序的入口点是 _start,这是一个由编译器提供的函数(通常是glibc)或连接器(如ld)提供的特殊函数。

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

  1. 设置堆栈:为程序创建一个初始的堆栈环境。
  2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始的数据段复制到相应的内存位置,并清零未初始化的数据段。
  3. 动态链接:这是关键的一步,_start 函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared
    libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。

动态链接器:

  1. 动态链接器(如ld-linux.so)负责在程序运行时加载动态库。

  2. 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。

  3. 环境变量和配置文件:

  4. Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf)及其子配置文件(如/etc/ld.so.conf.d/*)来指定动态库的搜索路径。

  5. 缓存文件

  6. 为了提高动态库的加载效率,Linux系统会维护一个名为/etc/ld.so.cache的缓存文件。

  7. 该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。

  8. 调用 __libc_start_main(这是glibc提供的一个函数)。__libc_start_main
    函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。

  9. 调用 main 函数:最后,__libc_start_main 函数会调用程序的 main
    函数,此时程序的执行控制权才正式交给用户编写的代码。

  10. 处理 main 函数的返回值:当 main 函数返回时,__libc_start_main 会负责处理这个返回值,并最终调用 _exit 函数来终止程序。

上述过程描述了C/C++程序在启动之前执行的一系列操作,但这些操作对于大多数程序员来说是透明的。程序员通常只需要关注 main 函数中的代码,而不需要关心底层的初始化过程。然而,了解这些底层细节有助于更好地理解程序的执行流程和调试问题。

在这里插入图片描述

4.3 动态库中的相对地址

动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统一编址,采用相对编址的方案进行编制的(其实可执行程序也一样,都要遵守平坦模式,只不过是直接加载的)。

# ubuntu下查看任意一个库的反汇编
objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less
# Centos下查看任意一个库的反汇编
objdump -S /lib64/libc-2.17.so | less

4.4 我们的程序,怎么和具体映射起来的

🔥 注意:

  1. 动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的
  2. 让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中

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

⭐ 注意:

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

一张图解释清楚

  • 让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中

4.6 我们的程序,怎么进行库函数调用

⭐ 注意:

  • 库已经被我们映射到了当前进程的地址空间中
  • 库的虚拟起始地址我们也已经知道了
  • 库中每一个方法的偏移量地址我们也知道了
  • 所有:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法
  • 而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的。

在这里插入图片描述
在这里插入图片描述

  1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
  2. 在单个.so下,由于GOT表与.text的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。
  3. 在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
  4. 这种方式实现的动态链接就叫做 PIC 地址无关代码。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
0000000000001050 <puts@plt>:
4      1050:    f3 0f 1e fa    endbr64
5      1054:    f2 ff 25 75 2f 00 00    jmpq    *0x2f75(%rip)
6      105b:    0f 1f 40 00    nopl    0x0(%rax)
7      105f:    68 2a 2a 2a 2a    pushq   $0x2a2a2a2a
8      1064:    68 2a 2a 2a 2a    pushq   $0x2a2a2a2a
9      1069:    e9 3d ff ff ff    jmpq    9f0 <__libc_start_main@plt>
10     106e:    0f 1f 40 00    nopl    0x0(%rax)
11
12     1070:    e9 3d ff ff ff    jmpq    9f0 <__libc_start_main@plt>
13     1075:    66 2e 0f 1f 84    nopw    %cs:0x0(%rax,%rbx,1)
14     107a:    0f 1f 40 00    nopl    0x0(%rax)
15
16     107e:    e9 3d ff ff ff    jmpq    9f0 <__libc_start_main@plt>
17     1083:    2e 0f 1f 84    bnd    0x90 <__libc_start_main@plt>
18     1087:    0f 1f 80 00 00 00 00    nop    0x0(%rax)
19     108e:    e9 3d ff ff ff    jmpq    9f0 <__libc_start_main@plt>
20     1093:    e8 f3 fe ff ff    callq   1058 <puts@plt>
21     1098:    e8 f3 fe ff ff    callq   1058 <puts@plt>
22     109d:    e8 f3 fe ff ff    callq   1058 <puts@plt>
23     ...

4.7 库间依赖(简单说明即可)

注意:

  • 不仅仅有可执行程序调用库
  • 库也会调用其他库!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的吗??
  • 库中也有GOT,和可执行一样!这也就是为什么大家为什么都是ELF的格式!

在这里插入图片描述

由于GOT表中的映射地址会在运行时去修改,我们可以通过gdb调试去观察GOT表的地址变化。在这里我们只想知道原理即可,有兴趣的同学可以参考:使用gdb调试GOT

  • 由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,我们的操作系统还做了一些其他的优化,比如延迟绑定,或者也叫PLT(Procedure
    Linkage
    Table)。与在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。

思路是:GOT中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码/stub。在我们第一次调用函数的时候,这段代码会负责查询真正的函数的跳转地址,并且去更新GOT表。于是我们再次调用函数时,就会直接跳转到动态库中真正的函数实现。

在这里插入图片描述
在这里插入图片描述

总而言之,动态链接实际上将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时,它虽然牺牲了一定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利用磁盘空间和内存资源,以极大方便了代码的更新和维护,更关键的是,它实现了二进制级别的代码复用。

⭐ 解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程。

五.总结

  • 静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。
  • 我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
  • 而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用(运行时重定位,也叫做动态地址重定位)。

后言

这就是深入理解ELF和动静态库加载原理。大家自己好好消化!今天就分享到这! 感谢各位的耐心垂阅!咱们下期见!拜拜~


网站公告

今日签到

点亮在社区的每一天
去签到