库制作与原理(下)

发布于:2025-08-18 ⋅ 阅读:(14) ⋅ 点赞:(0)

库制作与原理 (下)

1. 目标文件

编译和链接这两个步骤,在 Windows 下被我们的 IDE 封装的很完美,我们一般都是一键构建非常方便,但一旦遇到错误的时候呢,尤其是链接相关的错误,很多人就束手无策了。在 Linux 下,我们之前也学习过如何通过 gcc 编译器来完成这一系列操作。

在这里插入图片描述

接下来我们深入探讨一下编译和链接的整个过程,来更好的理解动静态库的使用原理。

先来回顾下什么是编译呢?

编译的过程其实就是将我们程序的源代码翻译成 CPU 能够直接运行的机器代码。比如:在一个源文件 hello.c 里简单输出 “helloworld!”,并且调用一个 run 函数,而这个函数被定义在另一个源文件 code.c 中。这里我们就可以调用 gcc -c 来分别编译这两个源文件。

// hello.c
#include<stdio.h>
// 声明run函数,告知编译器该函数在其他文件中定义
void run();
int main() 
{
    printf("hello world!\n");  // 打印"hello world!"
    run();  // 调用run函数
    return 0;
}
// code.c
#include<stdio.h>
// 定义run函数,实现打印"running..."的功能
void run() 
{
    printf("running...\n");
}
// 编译两个源文件 
// gcc -c 选项表示只编译不链接,生成目标文件(.o)
$ gcc -c hello.c
$ gcc -c code.c
$ ls  # 查看当前目录下的文件
code.c code.o hello.c hello.o

可以看到,在编译之后会生成两个扩展名为 .o 的文件,它们被称作目标文件。要注意的是如果我们修改了一个源文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程。目标文件是一个二进制的文件,文件的格式是 ELF ,是对二进制代码的一种封装。

# 使用file命令查看文件类型,这里查看hello.o的类型
$ file hello.o 
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
## file命令用于辨识文件类型。 

2. ELF 文件

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

  • 可重定位文件(Relocatable File):即 xxx.o 文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。
  • 可执行文件(Executable File):即可执行程序。
  • 共享目标文件(Shared Object File):即 xxx.so 文件。
  • 内核转储 (core dumps):存放当前进程的执行上下文,用于 dump 信号触发。

补充说明:这四种 ELF 文件分别对应程序开发和运行的不同阶段,可重定位文件是编译后的中间产物,可执行文件是最终能运行的程序,共享目标文件是动态库,内核转储用于程序调试分析。

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

  • ELF 头 (ELF header):描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。
  • 程序头表 (Program header table):列举了所有有效的段 (segments) 和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段都是紧密的放在二进制文件中,需要段表的描述信息,才能把他们每个段分割开。
  • 节头表 (Section header table):包含对节 (sections) 的描述。
  • 节(Section):ELF 文件中的基本组成单位,包含了特定类型的数据。ELF 文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。

补充说明:ELF 文件的这四部分相互配合,ELF 头是 “导航员”,指引找到程序头表和节头表;程序头表和节头表分别从运行和链接的角度描述文件结构;节则是实际存储数据和代码的地方。

最常见的节:

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

在这里插入图片描述

3. ELF 从形成到加载轮廓

3-1 ELF 形成可执行

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

在这里插入图片描述

注意:

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

3-2 ELF 可执行文件加载

  • 一个 ELF 会有多种不同的 Section,在加载到内存的时候,也会进行 Section 合并,形成 segment;
  • 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等;
  • 这样,即便是不同的 Section,在加载到内存中,可能会以 segment 的形式,加载到一起;
  • 很显然,这个合并工作也已经在形成 ELF 的时候,合并方式已经确定了,具体合并原则被记录在了 ELF 的程序头表 (Program header table) 中.
# 查看可执行程序的section(节)信息,使用readelf -S命令
$ 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
 0000000000000000 0000000000000000 0 0 0
 [ 1] .interp PROGBITS 0000000000400238 00000238
 000000000000001c 0000000000000000 A 0 0 1
 [ 2] .note.ABI-tag NOTE 0000000000400254 00000254
 0000000000000020 0000000000000000 A 0 0 4
 [ 3] .note.gnu.build-i NOTE 0000000000400274 00000274
 0000000000000024 0000000000000000 A 0 0 4
 [ 4] .gnu.hash GNU_HASH 0000000000400298 00000298
 000000000000001c 0000000000000000 A 5 0 8
 [ 5] .dynsym DYNSYM 00000000004002b8 000002b8
 0000000000000048 0000000000000018 A 6 1 8
 [ 6] .dynstr STRTAB 0000000000400300 00000300
 0000000000000038 0000000000000000 A 0 0 1
 [ 7] .gnu.version VERSYM 0000000000400338 00000338
 0000000000000006 0000000000000002 A 5 0 2
 [ 8] .gnu.version_r VERNEED 0000000000400340 00000340
 0000000000000020 0000000000000000 A 6 1 8
 [ 9] .rela.dyn RELA 0000000000400360 00000360
 0000000000000018 0000000000000018 A 5 0 8
 [10] .rela.plt RELA 0000000000400378 00000378
 0000000000000018 0000000000000018 AI 5 24 8
 [11] .init PROGBITS 0000000000400390 00000390
 ...
# 查看section合并成的segment(段)信息,使用readelf -l命令
$ 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 Flags Align
 PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040
 0x00000000000001f8 0x00000000000001f8 R E 8
 INTERP 0x0000000000000238 0x0000000000400238 0x0000000000400238
 0x000000000000001c 0x000000000000001c R 1
 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]  # 动态链接器路径
 LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
 0x0000000000000744 0x0000000000000744 R E 200000  # 可读可执行的段
 LOAD 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
 0x0000000000000218 0x0000000000000220 RW 200000  # 可读可写的段
 DYNAMIC 0x0000000000000e28 0x0000000000600e28 0x0000000000600e28
 0x00000000000001d0 0x00000000000001d0 RW 8
 NOTE 0x0000000000000254 0x0000000000400254 0x0000000000400254
 0x0000000000000044 0x0000000000000044 R 4
 GNU_EH_FRAME 0x00000000000005a0 0x00000000004005a0 0x00000000004005a0
 0x000000000000004c 0x000000000000004c R 4
 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
 0x0000000000000000 0x0000000000000000 RW 10
 GNU_RELRO 0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
 0x00000000000001f0 0x00000000000001f0 R 1
 Section to Segment mapping:  # 节到段的映射关系
 Segment Sections...
 00 
 01 .interp 
 02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr 
.gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text 
.fini .rodata .eh_frame_hdr .eh_frame 
 03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss 
 04 .dynamic 
 05 .note.ABI-tag .note.gnu.build-id 
 06 .eh_frame_hdr 
 07 
 08 .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节 :保存了初始化的全局变量和局部静态变量等数据。

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

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

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

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

从 执行视图 来看:

  • 告诉操作系统哪些模块可以被加载进内存。

  • 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的。

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

// 查看目标文件hello.o的ELF头信息,使用readelf -h命令
$ readelf -h hello.o 
ELF Header:
 Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00  # ELF文件标识
 Class: ELF64 # 文件类型为64位ELF
 Data: 2's complement, little endian # 数据采用小端字节序
 Version: 1 (current)
 OS/ABI: UNIX - System V
 ABI Version: 0
 Type: REL (Relocatable file) # 指出ELF文件的类型为可重定位文件
 Machine: Advanced Micro Devices X86-64 # 该程序需要的体系结构为x86-64
 Version: 0x1
 Entry point address: 0x0 # 入口点地址(可重定位文件无入口点,为0)
 Start of program headers: 0 (bytes into file) # 程序头表起始位置(可重定位文件无程序头表,为0)
 Start of section headers: 728 (bytes into file) # 节头表起始位置(文件偏移量)
 Flags: 0x0
 Size of this header: 64 (bytes) # ELF头大小
 Size of program headers: 0 (bytes) # 程序头表条目大小(可重定位文件为0)
 Number of program headers: 0 # 程序头表条目数量(可重定位文件为0)
 Size of section headers: 64 (bytes) # 节头表条目大小
 Number of section headers: 13 # 节头表条目数量
 Section header string table index: 12 # 节名字符串表在节头表中的索引
 
// 查看可执行程序a.out的ELF头信息
$ gcc *.o
$ 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: 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

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

  • 每个 ELF 区域和文件偏移量之间的关系是什么?

ELF 文件中,每个区域(如 ELF 头、程序头表、节头表、节 / 段等)在文件内的位置通过文件偏移量(offset) 确定,偏移量是区域起始位置相对于文件开头的字节数。

具体关系:

1.ELF 头固定位于文件起始位置(偏移量 0),其中记录了程序头表和节头表的偏移量(e_phoff和e_shoff),用于定位这两个表。

2.程序头表和节头表的位置由 ELF 头中的偏移量字段指定,通过这些偏移量可从文件中读取表的内容。

3.节(Section) 和段(Segment) 的位置由各自的头表描述:节头表中的sh_offset字段记录每个节在文件中的偏移量;程序头表中的p_offset字段记录每个段在文件中的偏移量。

简言之,偏移量是 ELF 文件内各区域的 “坐标”,通过它可准确定位并读取各部分内容,是 ELF 文件解析和加载的基础。

4. 理解链接与加载

4-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

# 编译所有.c文件生成目标文件(.o)
$ gcc -c *.c

# 链接目标文件生成可执行文件main.exe
$ gcc *.o -o main.exe

$ ll  # 查看生成的文件(增加了目标文件和可执行文件)
-rw-rw-r-- 1 whb whb 62 Oct 31 15:36 code.c
-rw-rw-r-- 1 whb whb 1672 Oct 31 15:46 code.o  # code.c编译生成的目标文件
-rw-rw-r-- 1 whb whb 103 Oct 31 15:36 hello.c
-rw-rw-r-- 1 whb whb 1744 Oct 31 15:46 hello.o  # hello.c编译生成的目标文件
-rwxrwxr-x 1 whb whb 16752 Oct 31 15:46 main.exe*  # 链接生成的可执行文件

查看编译后的.o 目标文件的反汇编代码:

# 对code.o进行反汇编,查看.text节(代码节)的机器指令
$ objdump -d code.o 
code.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <run>:  # run函数的汇编代码
 0: f3 0f 1e fa endbr64  # 指令:增强指令集安全
 4: 55 push %rbp  # 保存基址指针
 5: 48 89 e5 mov %rsp,%rbp  # 设置栈帧
 8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 加载字符串地址到rdi寄存器(printf的参数)
 # f <run+0xf>
 f: e8 00 00 00 00 callq 14 <run+0x14>  # 调用printf函数(地址暂为0,待链接时修正)
 14: 90 nop  # 空操作
 15: 5d pop %rbp  # 恢复基址指针
 16: c3 retq  # 函数返回
 
 
# 对hello.o进行反汇编,查看.text节的机器指令
$ objdump -d hello.o 
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:  # main函数的汇编代码
 0: f3 0f 1e fa endbr64 
 4: 55 push %rbp 
 5: 48 89 e5 mov %rsp,%rbp 
 8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 加载"hello world!\n"地址到rdi(printf参数)
 # f <main+0xf>
 f: e8 00 00 00 00 callq 14 <main+0x14>  # 调用printf(地址暂为0)
 14: b8 00 00 00 00 mov $0x0,%eax  # 置0到eax寄存器
 19: e8 00 00 00 00 callq 1e <main+0x1e>  # 调用run函数(地址暂为0)
 1e: b8 00 00 00 00 mov $0x0,%eax  # 函数返回值0
 23: 5d pop %rbp 
 24: c3 retq 
  • objdump -d 命令:将代码段(.text)进行反汇编查看
  • hello.o 中的 main 函数不认识 printf 和 run 函数
$ cat hello.c 
#include<stdio.h>
void run();  // 声明run函数(仅声明,未定义)
int main() 
{
 printf("hello world!\n");  // 调用printf(来自标准库,当前未确定地址)
 run();  // 调用run(在code.c中定义,当前未确定地址)
 return 0;
}
  • code.o 不认识 printf 函数
$ cat code.c 
#include<stdio.h>
void run() 
{
 printf("running...\n");  // 调用printf(来自标准库,当前未确定地址)
}

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

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

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

注意:

  • printf 涉及到动态库,这里暂不做说明

整个过程:

# 查看code.o的代码段反汇编
$ objdump -d code.o
code.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <run>:
 0: f3 0f 1e fa endbr64 
 4: 55 push %rbp
 5: 48 89 e5 mov %rsp,%rbp
 8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # f 
<run+0xf>
 f: e8 00 00 00 00 callq 14 <run+0x14>  # 调用printf,地址暂为0
 14: 90 nop
 15: 5d pop %rbp
 16: c3 retq 

# 查看hello.o的代码段反汇编
$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
 0: f3 0f 1e fa endbr64 
 4: 55 push %rbp
 5: 48 89 e5 mov %rsp,%rbp
 8: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # f 
<main+0xf>
 f: e8 00 00 00 00 callq 14 <main+0x14>  # 调用printf,地址暂为0
 14: b8 00 00 00 00 mov $0x0,%eax
 19: e8 00 00 00 00 callq 1e <main+0x1e>  # 调用run,地址暂为0
 1e: b8 00 00 00 00 mov $0x0,%eax
 23: 5d pop %rbp
 24: c3 retq
 
至此就是之前的结论:多个.o彼此不知道对方的函数地址

# 读取code.o的符号表(.symtab节),使用readelf -s命令
$ readelf -s code.o
Symbol table '.symtab' contains 13 entries:
 Num: Value Size Type Bind Vis Ndx Name
 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 
 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS code.c  # 关联的源文件
 2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 
 ...
 10: 0000000000000000 23 FUNC GLOBAL DEFAULT 1 run  # run函数(已定义,在节1中)
 11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_  # 未定义符号
 12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts  # puts函数(printf的实现,未定义)
 
# puts是printf的简化版,这里显示UND表示在当前.o文件中未定义(需要从其他文件或库中查找)
 
# 读取hello.o的符号表
whb@bite:~/test/test/test$ readelf -s hello.o
Symbol table '.symtab' contains 14 entries:
 Num: Value Size Type Bind Vis Ndx Name
 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 
 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS hello.c  # 关联的源文件
 ...
 10: 0000000000000000 37 FUNC GLOBAL DEFAULT 1 main  # main函数(已定义,在节1中)
 11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
 12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts  # puts未定义
 13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND run  # run未定义(在code.o中定义)
 
# 读取可执行程序main.exe的符号表
$ readelf -s main.exe 
Symbol table '.dynsym' contains 7 entries:  # 动态符号表(与动态库相关)
 Num: Value Size Type Bind Vis Ndx Name
 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 
 1: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab
 2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5 (2)  # 找到puts(来自glibc)
 3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2)
 ...
Symbol table '.symtab' contains 67 entries:  # 完整符号表
 ...
 52: 0000000000001149 23 FUNC GLOBAL DEFAULT 16 run  # run函数已找到,地址为0x1149,位于节16
 ...
 63: 0000000000001160 37 FUNC GLOBAL DEFAULT 16 main  # main函数地址为0x1160,位于节16
 
# 两个.o进行合并之后,在最终的可执行程序中,run和main的地址都已确定
# 0000000000001149是run函数的最终地址,16表示其所在的节在节头表中的索引

# 读取可执行程序main.exe的所有节清单
$ readelf -S main.exe 
There are 31 section headers, starting at offset 0x39b0:
Section Headers:
 [Nr] Name Type Address Offset
 Size EntSize Flags Link Info Align
 [ 0] NULL 0000000000000000 00000000
 ...
 [16] .text PROGBITS 0000000000001060 00001060
 00000000000001a5 0000000000000000 AX 0 0 16  # 合并后的.text节(索引16)
 ...
Key to Flags:
 W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
 L (link order), O (extra OS processing required), G (group), T (TLS),
 C (compressed), x (unknown), o (OS specific), E (exclude),
 l (large), p (processor specific)

# 结论:hello.o和code.o的.text节被合并到了main.exe的第16个节(.text)中

# 验证call指令的地址是否被修正:查看main.exe的反汇编
$ objdump -d main.exe 
main.exe: file format elf64-x86-64
...
Disassembly of section .text:
...
0000000000001149 <run>:  # run函数的最终地址
 1149: f3 0f 1e fa endbr64 
 114d: 55 push %rbp
 114e: 48 89 e5 mov %rsp,%rbp
 1151: 48 8d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi # 加载"running...\n"地址
 1158: e8 f3 fe ff ff callq 1050 <puts@plt>  # 调用puts的地址已修正(0x1050)
 115d: 90 nop
 115e: 5d pop %rbp
 115f: c3 retq 

0000000000001160 <main>:  # main函数的最终地址
 1160: f3 0f 1e fa endbr64 
 1164: 55 push %rbp
 1165: 48 89 e5 mov %rsp,%rbp
 1168: 48 8d 3d a0 0e 00 00 lea 0xea0(%rip),%rdi # 加载"hello world!\n"地址
 116f: e8 dc fe ff ff callq 1050 <puts@plt>  # 调用puts的地址已修正(0x1050)
 1174: b8 00 00 00 00 mov $0x0,%eax
 1179: e8 cb ff ff ff callq 1149 <run>  # 调用run的地址已修正(0x1149)
 117e: b8 00 00 00 00 mov $0x0,%eax
 1183: 5d pop %rbp
 1184: c3 retq 
...
 
// 最终结论:
// 1. 两个.o的代码段合并到了一起,并进行了统一的编址
// 2. 链接的时候,会修改.o中没有确定的函数地址,在合并完成之后,设置正确的call地址,完成代码调用

静态链接就是把库中的.o 进行合并,和上述过程一样。

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

在这里插入图片描述

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

4-2 ELF 加载与进程地址空间

4-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 要支持,编译器也要支持.

4-2-2 重新理解进程虚拟地址空间

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

$ gcc *.o  # 链接生成可执行文件a.out
$ readelf -h a.out  # 查看a.out的ELF头
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)

补充说明:程序入口地址是操作系统加载程序后开始执行的第一个指令的地址,由编译器在编译链接时确定,并存储在 ELF 头中,操作系统通过读取该字段来启动程序执行。

素材1

在这里插入图片描述

素材2

在这里插入图片描述

4-3动态链接与动态库加载

4-3-1进程如何看到动态库

在这里插入图片描述

4-3-2进程间如何共享库的

在这里插入图片描述

4-3-3 动态链接
4-3-3-1 概要

动态链接其实远比静态链接要常用得多。比如我们查看下 hello 这个可执行程序依赖的动态库,会发现它就用到了一个 C 动态链接库:

$ ldd hello
 linux-vdso.so.1 => (0x00007fffeb1ab000)
 libc.so.6 => /lib64/libc.so.6 (0x00007ff776af5000)
 /lib64/ld-linux-x86-64.so.2 (0x00007ff776ec3000)
 
# ldd命令⽤于打印程序或者库⽂件所依赖的共享库列表。 

这里的libc.so是 C 语言的运行时库,里面提供了常用的标准输入输出、文件操作、字符串处理等功能。那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对,是吧?

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

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

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

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

补充说明:动态链接的核心是 “延迟链接”,通过将链接过程从编译阶段推迟到运行阶段,实现了代码共享和灵活更新,这也是现代操作系统中主流的链接方式。

4-3-3-2 我们的可执行程序被编译器动了手脚
# 查看ls命令依赖的动态库
$ ldd /usr/bin/ls
 linux-vdso.so.1 (0x00007fffdd85f000)
 libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f42c025a000)
 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f42c0068000)
 libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f42bffd7000)
 libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f42bffd1000)
 /lib64/ld-linux-x86-64.so.2 (0x00007f42c02b6000)
 libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f42bffae000)

# 查看我们自己编译的程序main.exe依赖的动态库
$ ldd main.exe 
 linux-vdso.so.1 (0x00007fff231d6000)
 libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f197ec3b000)
 /lib64/ld-linux-x86-64.so.2 (0x00007f197ee3e000)

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

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

  1. 设置堆栈:为程序创建初始的堆栈环境,确保函数调用、局部变量等能正常工作。
  2. 初始化数据段:将程序的数据段(如全局变量、静态变量)从初始化数据段复制到内存,并清零未初始化的数据段(.bss 段)。
  3. 动态链接:这是关键步骤,_start会调用动态链接器的代码解析和加载程序依赖的动态库,完成符号解析和重定位,确保函数调用和变量访问能映射到动态库的实际地址。

动态链接器:

  • 动态链接器(如ld-linux.so)负责在程序运行时加载动态库。
  • 程序启动时,动态链接器会解析程序的动态库依赖,并将这些库加载到内存。

环境变量和配置文件:

  • Linux 通过环境变量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf)指定动态库的搜索路径。
  • 动态链接器加载动态库时会搜索这些路径。

缓存文件:

  • 系统维护/etc/ld.so.cache缓存文件,包含所有已知动态库的路径和信息,动态链接器会优先搜索该缓存以提高效率。
  1. 调用__libc_start_main:动态链接完成后,_start会调用__libc_start_main(glibc 提供的函数),执行额外初始化(如设置信号处理函数、初始化线程库等)。
  2. 调用main函数:最后,__libc_start_main调用程序的main函数,程序执行控制权正式交给用户代码。
  3. 处理main返回值main返回后,__libc_start_main处理返回值并调用_exit终止程序。

上述过程对大多数程序员是透明的,但了解这些细节有助于理解程序执行流程和调试问题。

补充说明main函数并非程序的真正起点,_startmain的初始化过程是操作系统和 C 库为程序运行做的准备工作,确保程序能在正确的环境中执行。

4-3-3-3 动态库中的相对地址

动态库为了支持随时加载并映射到任意进程的任意位置,对库中的方法采用相对编址方案(可执行程序也遵循类似的 “平坦模式”,只是直接加载)。

# Ubuntu下查看任意一个库的反汇编(查看库中函数的汇编实现)
objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less

# CentOS下查看任意一个库的反汇编
$ objdump -S /lib64/libc-2.17.so | les
4-3-3-4 我们的程序,怎么和库具体映射起来的

注意:

  • 动态库也是一个文件,访问前需要被加载和打开。
  • 进程找到动态库的本质:通过文件操作将动态库映射到进程的地址空间,再通过虚拟地址跳转访问库函数。

下图解释了程序与动态库的映射关系:

在这里插入图片描述

4-3-3-6 全局偏移量表 GOT(Global Offset Table)

注意:

  • 程序运行前,需先加载并映射所有依赖的库,且所有库的起始虚拟地址需提前确定。
  • 然后对程序中库函数调用的地址进行修改(加载地址重定位)。
  • 但代码区在进程中是只读的,如何修改地址?

动态链接的解决方案是:在.data段(可执行程序或库自身)中专门预留一片区域存放函数的跳转地址,即全局偏移表 GOT。表中每一项都是本运行模块要引用的全局变量或函数的地址。

  • 由于.data区域是可读写的,因此支持动态修改。
# 查看可执行文件a.out的节信息,找到GOT表
$ readelf -S a.out 
...
 [24] .got PROGBITS 0000000000003fb8 00002fb8
 0000000000000048 0000000000000008 WA 0 0 8  # WA表示可写(W)、已分配(A)
...

# 查看程序头表,可见GOT与.data合并为一个段加载
$ readelf -l a.out 
...
 05 .init_array .fini_array .dynamic .got .data .bss 
...

在这里插入图片描述

  1. 由于代码段只读,不能直接修改代码,但有了 GOT 表,代码可被所有进程共享。不同进程中,动态库的绝对地址和相对位置不同,因此每个进程的每个动态库都有独立的 GOT 表,进程间不能共享 GOT 表。
  2. 在单个.so中,GOT 表与.text的相对位置固定,可利用 CPU 的相对寻址找到 GOT 表。
  3. 调用函数时会首先查询 GOT 表,根据表中的地址跳转,这些地址在动态库加载时会被修改为真正的地址。
  4. 这种方式实现的动态链接称为 PIC(地址无关代码)。动态库无需修改即可被加载到任意内存地址运行,并能被所有进程共享,这也是编译动态库时需指定-fPIC参数的原因(PIC = 相对编址 + GOT)。
# 查看可执行文件a.out的反汇编,观察GOT的使用
$ objdump -S a.out
...
# puts函数的PLT入口(间接跳转至GOT表中的地址)
0000000000001050 <puts@plt>:
 1050: f3 0f 1e fa endbr64 
 1054: f2 ff 25 75 2f 00 00 bnd jmpq *0x2f75(%rip) # 跳转到GOT表中0x3fd0位置(puts的实际地址)
3fd0 <puts@GLIBC_2.2.5>
...
...
# main函数中调用puts的指令
0000000000001149 <main>:
 1149: f3 0f 1e fa endbr64 
 114d: 55 push %rbp
 114e: 48 89 e5 mov %rsp,%rbp
 1151: 48 8d 3d ac 0e 00 00 lea 0xeac(%rip),%rdi # 加载字符串地址到rdi(puts的参数)
2004 <_IO_stdin_used+0x4>
 1158: e8 f3 fe ff ff callq 1050 <puts@plt>  # 调用puts的PLT入口
...

备注:

  • PLT(Procedure Linkage Table,过程链接表)是与 GOT 配合使用的结构,用于实现延迟绑定(函数第一次被调用时才解析地址),后续会详细说明。

补充说明:GOT 表是动态链接的核心机制,通过将地址存储在可读写的数据段,解决了代码段只读无法修改的问题,同时结合相对编址实现了动态库的位置无关性。

4-3-3-7 库间依赖

注意:

  • 不仅可执行程序会调用库。
  • 库也会调用其他库!库之间存在依赖关系,库与库之间的调用同样通过地址无关的方式实现。
  • 库中也有.GOT,与可执行程序的机制相同,这也是所有文件都采用 ELF 格式的原因。

在这里插入图片描述

由于 GOT 表中的映射地址会在运行时修改,我们可以通过 gdb 调试观察 GOT 表的地址变化(有兴趣的同学可参考 “使用 gdb 调试 GOT”)。

动态链接在程序加载时需要对大量函数进行重定位,这一过程较耗时。为降低开销,操作系统采用了延迟绑定(PLT,过程连接表)优化:将函数重定位推迟到第一次被调用时(多数动态库函数可能从未被使用)。

思路是:GOT 中的跳转地址默认指向一段辅助代码(桩代码 /stub)。第一次调用函数时,这段代码负责查询真正的函数地址并更新 GOT 表;再次调用时,直接跳转到动态库中真正的函数实现。

在这里插入图片描述

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

解析依赖关系的过程,本质上是加载并完善各模块间 GOT 表的过程。

4-3-4 总结
  • 静态链接的出现提高了程序的模块化水平。对于大型项目,不同开发者可独立测试和开发自己的模块,通过静态链接生成最终的可执行文件。
  • 静态链接会将所有目标文件和用到的库合并成一个独立的可执行文件,期间会修正模块间函数的跳转地址(编译重定位 / 静态重定位)。
  • 动态链接将链接过程推迟到程序加载时:运行程序时,操作系统先将程序的代码、数据及依赖的动态库加载到内存(动态库加载地址不固定,但会映射到进程地址空间),再通过.GOT方式调用(运行重定位 / 动态地址重定位)。

补充说明:静态链接和动态链接各有适用场景,静态链接适合对运行环境有严格控制、需独立部署的场景;动态链接适合需共享代码、频繁更新库的场景,二者共同构成了现代程序的链接机制。


网站公告

今日签到

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