Linux系统22:——文件(六):目标文件与ELF深度解析:从编译到加载的全景揭秘

上一期介绍了动静态库的使用原理以及两者的区别,但是你是否困惑过:gcc -c 出来的**.o 文件到底是什么?它.soa.out 有什么区别**?ELF 又是何方神圣?链接器凭什么能修正函数地址?这篇文章从目标文件讲起,带你层层深入 ELF 的骨髓。


目录

开篇:一段代码的编译之旅

一、目标文件(.o)------编译的"半成品"

[1.1 为什么需要目标文件?](#1.1 为什么需要目标文件?)

[1.2 如何生成目标文件](#1.2 如何生成目标文件)

[1.3 目标文件的本质](#1.3 目标文件的本质)

[二、ELF 文件------统一一切的二进制格式](#二、ELF 文件——统一一切的二进制格式)

[2.1 ELF 的定义](#2.1 ELF 的定义)

[三、ELF 文件的解剖------四大部分](#三、ELF 文件的解剖——四大部分)

[3.1 ELF 头(ELF Header)------文件的身份证](#3.1 ELF 头(ELF Header)——文件的身份证)

1.读取目标文件的ELF文件头部信息

[3.2 程序头表(Program Header Table)------执行视图](#3.2 程序头表(Program Header Table)——执行视图)

[1.查看可执行文件的段 (Segment)信息](#1.查看可执行文件的段 (Segment)信息)

[3.3 节头表(Section Header Table)------链接视图](#3.3 节头表(Section Header Table)——链接视图)

[1.打印【节头表 Section Header Table】,列出 ELF 内部所有节 (Section)](#1.打印【节头表 Section Header Table】,列出 ELF 内部所有节 (Section))

[3.4 节(Sections)------真正的数据仓库](#3.4 节(Sections)——真正的数据仓库)

[四、ELF 的形成------从源代码到可执行文件](#四、ELF 的形成——从源代码到可执行文件)

[4.1 编译与链接的两步走](#4.1 编译与链接的两步走)

[4.2 Section 的合并------为什么需要 Segment?](#4.2 Section 的合并——为什么需要 Segment?)

1.为什么要合并?

[五、ELF 的两个视图------链接器看 vs 加载器看](#五、ELF 的两个视图——链接器看 vs 加载器看)

1.从链接视图看

2.从执行视图看

3.三条命令终极汇总

六、透彻理解链接------静态链接的全过程

[6.1 目标文件之间"互不相识"](#6.1 目标文件之间"互不相识")

[6.2 符号表------标记"未定义"的符号](#6.2 符号表——标记"未定义"的符号)

[6.3 链接时的重定位](#6.3 链接时的重定位)

[6.4 静态链接的本质](#6.4 静态链接的本质)

七、虚拟地址空间------链接时就已编好址

[7.1 一个反直觉的事实](#7.1 一个反直觉的事实)

[7.2 进程地址空间的数据从哪里来?](#7.2 进程地址空间的数据从哪里来?)

八、动态链接------运行时才见真章

[8.1 为什么需要动态链接?](#8.1 为什么需要动态链接?)

[8.2 程序启动的完整流程](#8.2 程序启动的完整流程)

[8.3 动态库的PIC原理](#8.3 动态库的PIC原理)

[8.4 GOT(全局偏移表)------动态链接的基石](#8.4 GOT(全局偏移表)——动态链接的基石)

[8.5 PLT(过程链接表)------延迟绑定](#8.5 PLT(过程链接表)——延迟绑定)

[九、总结对比------静态链接 vs 动态链接](#九、总结对比——静态链接 vs 动态链接)

它们共同的根------ELF


开篇:一段代码的编译之旅

复制代码
$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c  code.o  hello.c  hello.o

当你执行 gcc -c,编译器把源代码翻译成 CPU 能直接运行的机器码,输出一个扩展名为 .o 的文件------这就是目标文件(Object File)

但问题来了:

  • 为什么不是直接生成可执行文件 ,而是先生成 .o
  • .o 里面到底装了什么?
  • 为什么用 file 命令查看,说它是 "ELF"?

别急,这篇文章全部给你讲清楚。

一、目标文件(.o)------编译的"半成品"

1.1 为什么需要目标文件?

假设一个项目有几十个源文件,你只修改了其中一个 。如果每次都要重新编译所有文件 ,**效率极低。**而引入目标文件后:

修改 hello.c → 只需要重新编译 hello.c → 重新链接即可

这就是增量编译的思想------编译是单个文件的事,链接才是全局的事

1.2 如何生成目标文件

cpp 复制代码
// hello.c
#include<stdio.h>
void run();
int main() {
    printf("hello world!\n");
    run();
    return 0;
}

// code.c
#include<stdio.h>
void run() {
    printf("running...\n");
}
cpp 复制代码
$ gcc -c hello.c    # → hello.o
$ gcc -c code.c     # → code.o
$ ls
code.c  code.o  hello.c  hello.o

**gcc -c**的含义是:只编译(compile),不链接(no link)。

1.3 目标文件的本质

cpp 复制代码
file hello.o

file 命令用于辨识文件类型。

目标文件是一个二进制的文件 ,它的格式是 ELF(Executable and Linkable Format)------对二进制代码的一种封装。

注意这里的描述:relocatable(可重定位) ,这是目标文件的灵魂属性。意味着其中的地址****尚未固定,可以在链接时被重新定位。

二、ELF 文件------统一一切的二进制格式

2.1 ELF 的定义

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

要理解编译和链接的细节,我们必须了解 ELF。

实际上,以下四种文件都是 ELF 文件:

文件类型 扩展名/名称 说明
可重定位文件 .o 包含适合与其他目标文件链接来创建可执行文件或共享目标文件的代码和数据
可执行文件 无扩展名 / a.out 即可执行程序,经过完整链接,可以直接加载运行
共享目标文件 .so 动态链接库,包含可在运行时被动态链接器加载和链接的代码和数据
内核转储 core 存放当前进程的执行上下文,用于 dump 信号触发后的调试

核心思想:无论是 .o、.so、a.out,它们骨子里都是 ELF!

三、ELF 文件的解剖------四大部分

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

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

补充:一个 Segment(段)通常由若干个 Section(节)合并构成。

最常见的节:

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

3.1 ELF 头(ELF Header)------文件的身份证

位置文件的最开始位置

作用:描述文件的主要特性,定位文件的其他部分

1.读取目标文件的 ELF文件头部信息

cpp 复制代码
$ readelf -h hello.o
ELF Header:
Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00  # ELF魔数,7f+ELF,用来标识这是ELF文件
  Class:                             ELF64                  # ELF类别:64位程序;ELF32代表32位
  Data:                              2's complement, little endian  # 字节序:小端序,补码存储
  Version:                           1 (current)            # ELF格式版本号,固定为1
  OS/ABI:                            UNIX - System V        # 目标操作系统ABI规范,Linux遵循System V
  ABI Version:                       0                      # ABI版本
  Type:                              REL (Relocatable file) # 文件类型:REL=可重定位文件(*.o目标文件)
  Machine:                           Advanced Micro Devices X86-64 # 目标CPU架构 x86_64
  Version:                           0x1                    # 目标文件版本
  Entry point address:               0x0                    # 程序入口地址;.o文件没有入口,填0
  Start of program headers:          0 (bytes into file)    # 程序头表在文件内偏移;当前为0=不存在程序头表
  Start of section headers:          672 (bytes into file)  # 节头表在文件中的起始偏移位置
  Flags:                             0x0                    # 架构相关标志位,x86_64一般为0
  Size of this header:               64 (bytes)             # ELF头部自身大小,64位ELF固定64字节
  Size of program headers:           0 (bytes)              # 单个程序头表项大小;0代表无程序头
  Number of program headers:         0                      # 程序头表条目数量,0=没有程序头
  Size of section headers:           64 (bytes)             # 单个节头表项大小,64位ELF固定64字节
  Number of section headers:         13                     # 节头表总共有13个条目(13个节)
  Section header string table index: 12                     # 节名字符串表,是第12号节头

作为对比,看下可执行文件的 ELF 头:

cpp 复制代码
$ readelf -h a.out
ELF Header:
Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00  # ELF魔数,0x7f+"ELF",标识该文件为ELF格式
Class:                             ELF64                  # ELF类型:64位ELF文件
Data:                              2's complement, little endian  # 数据编码:补码、小端字节序
Version:                           1 (current)            # ELF标准版本,固定为1
OS/ABI:                            UNIX - System V        # 遵循System V ABI标准(Linux使用)
ABI Version:                       0                      # ABI版本号
Type:                              EXEC (Executable file) # 文件类型:EXEC=可执行文件(a.out,能直接./运行)
Machine:                           Advanced Micro Devices X86-64 # 目标CPU架构 x86_64
Version:                           0x1                    # 对象文件版本
Entry point address:               0x401040               # 程序入口虚拟地址,内核从此处开始执行代码
Start of program headers:          64 (bytes into file)   # 程序头表在文件内的起始偏移(ELF头大小64字节,紧跟其后)
Start of section headers:          23928 (bytes into file)# 节头表在文件内的起始偏移
Flags:                             0x0                    # 处理器相关标志位,x86_64平台通常为0
Size of this header:               64 (bytes)             # ELF头部自身大小,64位ELF固定64字节
Size of program headers:           56 (bytes)             # 单个程序头表项的大小,64位ELF固定56字节
Number of program headers:         11                     # 一共有11个程序头(对应11个Segment段)
Size of section headers:           64 (bytes)             # 单个节头表项大小,64位ELF固定64字节
Number of section headers:         30                     # 节头表一共30个条目(30个Section节)
Section header string table index: 29                     # 节名称字符串表,是第29号节

hello.o 是REL 可重定位目标文件无程序头表、程序入口地址为 0,仅提供节信息供链接器使用;

a.out 是EXEC 可执行文件拥有程序头表与有效程序入口地址,同时包含节与段信息,可供操作系统加载运行。

总结:

ELF 头中最核心的信息:文件类型(REL/EXEC/DYN)+ 入口点地址 + 程序头表和节头表的位置。

3.2 程序头表(Program Header Table)------执行视图

作用:列举所有有效的段(Segments)和它们的属性 。标记每个段的起始位置、偏移量、长度和权限。

这正是操作系统的加载器所关注的内容------它告诉 OS 怎么把这个文件加载到内存。

1.查看可执行文件的段 (Segment)信息

cpp 复制代码
​readelf -l a.out
Elf file type is EXEC (Executable file)          # 文件类型:可执行程序
Entry point 0x401040                             # 进程启动虚拟入口地址
There are 11 program headers, starting at offset 64  # 共11个段描述项,程序头表在文件偏移0x40处

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040
                 0x0000000000000268 0x0000000000000268  R      0x8
  # PHDR段:程序头表自身,加载到内存,只读

  INTERP         0x00000000000002a8 0x00000000004002a8 0x00000000004002a8
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  # INTERP段:指定动态链接器(解释器),负责加载libc等动态库

  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x0000000000000438 0x0000000000000438  R      0x1000
  # PT_LOAD 可读内存段,映射辅助信息节

  LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000
                 0x00000000000001e5 0x00000000000001e5  R E    0x1000
  # PT_LOAD 可读+可执行段【代码段】,承载 .text .plt 机器指令

  LOAD           0x0000000000002000 0x0000000000402000 0x0000000000402000
                 0x0000000000000168 0x0000000000000168  R      0x1000
  # PT_LOAD 只读段【常量段】,承载 .rodata 字符串常量

  LOAD           0x0000000000002e10 0x0000000000403e10 0x0000000000403e10
                 0x0000000000000220 0x0000000000000228  RW     0x1000
  # PT_LOAD 可读可写段【数据段】,承载 .data .bss 全局变量;MemSiz > FileSiz 对应.bss(文件不占空间,内存清零)

  DYNAMIC        0x0000000000002e20 0x0000000000403e20 0x0000000000403e20
                 0x00000000000001d0 0x00000000000001d0  RW     0x8
  # DYNAMIC段:动态链接信息,符号、依赖库、重定位相关数据

  NOTE           0x00000000000002c4 0x00000000004002c4 0x00000000004002c4
                 0x0000000000000044 0x0000000000000044  R      0x4
  # NOTE段:程序版本、ABI标记等附加信息

  GNU_EH_FRAME   0x000000000000201c 0x000000000040201c 0x000000000040201c
                 0x0000000000000044 0x0000000000000044  R      0x4
  # GNU_EH_FRAME:C++异常处理栈展开信息

  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  # GNU_STACK:标识栈权限 RW,说明栈**不可执行**(防护缓冲区溢出)

  GNU_RELRO      0x0000000000002e10 0x0000000000403e10 0x0000000000403e10
                 0x00000000000001f0 0x00000000000001f0  R      0x1
  # GNU_RELRO:延迟重定位区域,重定位完成后内存改为只读,安全防护

 Section to Segment mapping:
  # 【重点!节(Section) 映射到 段(Segment)】直观印证:多个节打包合并成一个段
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 
   03     .init .plt .text .fini               # 代码段R+E:多个代码相关节合并
   04     .rodata .eh_frame_hdr .eh_frame      # 只读常量节集合
   05     .init_array .fini_array .dynamic .got .got.plt .data .bss # 读写数据相关节
   06     .dynamic 
   07     .note.gnu.build-id .note.ABI-tag 
   08     .eh_frame_hdr 
   09

程序头的类型含义:

类型 说明
LOAD 需要加载到内存中的段,是加载的核心
INTERP 指定动态链接器的路径(如 /lib64/ld-linux-x86-64.so.2
DYNAMIC 动态链接信息
NOTE 辅助信息
GNU_STACK 栈的可执行属性
GNU_RELRO 只读重定位

3.3 节头表(Section Header Table)------链接视图

作用 :包含对所有节(Sections)的详细描述,包括节名称、类型、地址、偏移、大小等信息。

这是链接器和编译器所关注的内容。

1.打印【节头表 Section Header Table】,列出 ELF 内部所有节 (Section)

cpp 复制代码
readelf -S hello.o

There are 13 section headers, starting at offset 0x2e0:  # 一共13个节,节头表在文件偏移0x2e0位置

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  # 0号节固定是空节,占位保留

  [ 1] .text             PROGBITS         0000000000000000  00000040
       000000000000001f  0000000000000000  AX       0     0     1
  # .text代码节:存放函数机器指令;AX=可分配内存、可执行

  [ 2] .rela.text        RELA             0000000000000000  00000218
       0000000000000048  0000000000000018   I      10     1     8
  # .rela.text:.text节对应的重定位表,链接时修正符号地址

  [ 3] .data             PROGBITS         0000000000000000  0000005f
       0000000000000000  0000000000000000  WA       0     0     1
  # .data数据节:存放已初始化全局/静态变量;WA=可分配、可写;当前size为0代表没有这类变量

  [ 4] .bss              NOBITS           0000000000000000  0000005f
       0000000000000000  0000000000000000  WA       0     0     1
  # .bss:未初始化全局/静态变量;NOBITS=磁盘文件不占用空间;size=0说明无对应变量

  [ 5] .rodata           PROGBITS         0000000000000000  0000005f
       000000000000000d  0000000000000000   A       0     0     1
  # .rodata只读数据节:存放字符串常量、const常量;A=运行时分配内存

  [ 6] .comment          PROGBITS         0000000000000000  0000006c
       0000000000000036  0000000000000001  MS       0     0     1
  # .comment:编译器版本注释信息,运行时不加载

  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000a2
       0000000000000000  0000000000000000           0     0     1
  # 栈属性标记,标识栈不可执行(安全保护)

  [ 8] .eh_frame         PROGBITS         0000000000000000  000000a8
       0000000000000038  0000000000000000   A       0     0     8
  # C/C++异常处理、栈回溯信息

  [ 9] .rela.eh_frame    RELA             0000000000000000  00000260
       0000000000000018  0000000000000018   I      10     8     8
  # .eh_frame节对应的重定位信息

  [10] .symtab           SYMTAB           0000000000000000  000000e0
       0000000000000120  0000000000000018          11     9     8
  # .symtab符号表:保存函数名、变量名等符号信息,供链接器使用

  [11] .strtab           STRTAB           0000000000000000  00000200
       0000000000000017  0000000000000000           0     0     1
  # .strtab:符号名字符串表,配合symtab使用

  [12] .shstrtab         STRTAB           0000000000000000  00000278
       0000000000000061  0000000000000000           0     0     1
  # .shstrtab:节名称字符串表,保存.text/.data这类节名字

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)

查看可执行文件的节数

cpp 复制代码
readelf -S a.out

There are 30 section headers, starting at offset 0x5d78:
# 一共有30个节,节头表在ELF文件内偏移0x5d78的位置

注意对比 :hello.o 有 13 个节,而 a.out 有 31 个节------链接后的可执行文件更复杂

3.4 节(Sections)------真正的数据仓库

节是 ELF 文件中的基本组成单位,包含了特定类型的数据。

下面是最常见的几个节:

各节详细说明

节名称 全称 作用
.text 代码节 保存程序指令(机器码),是主要执行部分
.data 数据节 保存已初始化的全局变量和局部静态变量
.rodata 只读数据节 保存只读数据,如字符串常量。只在只读段(text段)中
.bss 未初始化数据节 为未初始化的全局变量和局部静态变量预留空间,加载时清零
.symtab 符号表 源码中的函数名、变量名与地址的对应关系
.strtab 字符串表 存放符号名称的字符串
.got / .got.plt 全局偏移表 保存全局偏移表,提供对导入共享库函数的访问入口

查看各节大小

cpp 复制代码
size code.o

四、ELF 的形成------从源代码到可执行文件

4.1 编译与链接的两步走

  • Step 1:将多份 C/C++ 源代码,翻译成为目标 .o 文件 + 动静态库(ELF)
  • Step 2:将多份 .o 文件的 Section 进行合并和重定位,形成可执行文件

4.2 Section 的合并------为什么需要 Segment?

合并原则:相同属性合并------可读、可写、可执行、需要加载时申请空间等。

1.为什么要合并?

两个核心原因:

|------------|------------------------------------------------------------------------------------------|
| 原因 | 说明 |
| 减少内存碎片 | 假设页面大小为 4096 字节(内存块基本大小,加载,管理的基本单位),.text 为 4097 字节,.init 为 512 字节。不合并会用 3 个页面,合并后只用 2 个 |
| 统一权限控制 | 将所有只读可执行的 Section 放在一起统一设置为 RE,所有可读写的放在一起设置为 RW,优化内存管理和权限控制 |

合并规则在 ELF 编绎时就已经确定,被记录在程序头表(Program Header Table)的 Section to Segment mapping 中:

cpp 复制代码
Section to Segment mapping:
  Segment Sections...
   02   .interp .note.ABI-tag .gnu.hash .dynsym .dynstr .init .plt .text .fini .rodata ...  # 第02号段,集合各类只读、可读可执行节,内存权限R/RE
   03   .init_array .fini_array .dynamic .got .got.plt .data .bss                            # 第03号段,集合各类运行时需要读写的节,内存权限RW

五、ELF 的两个视图------链接器看 vs 加载器看

ELF 提供 2 个不同的视角来观察自己:

视图 对应表 使用场景 粒度 作用
链接视图 节头表 编译/链接时 细(Section) 按功能模块划分,链接器分析符号依赖
执行视图 程序头表 加载/运行时 粗(Segment) 告诉 OS 如何加载、设置权限

一句话总结 :一个在链接时 用,一个在运行时用。

1.从链接视图看

cpp 复制代码
$ readelf -S hello.o    # 查看目标文件的节头表

链接器关注的是一个个的 Section

  • 将同名 Section 合并(如多个 .o 的 .text 合并成一个大的 .text)
  • 解析符号表,定位外部符号
  • 根据重定位表修正地址

2.从执行视图看

cpp 复制代码
$ readelf -l a.out       # 查看可执行文件的程序头表

操作系统关注的是一个个的Segment

  • 哪些模块需要加载进内存(LOAD 类型的 Segment)
  • 加载进内存后,哪些段可读可写,哪些只读,哪些可执行
  • 入口点地址在哪

**3.**三条命令终极汇总

cpp 复制代码
readelf -h 文件名  # -h:查看ELF头部(文件基础元信息)
readelf -S 文件名  # 大写S:查看节头表 Section(链接器视角)
readelf -l 文件名  # 小写L:查看程序头表 Segment(操作系统加载视角)

六、透彻理解链接------静态链接的全过程

6.1 目标文件之间"互不相识"

以之前的 hello.c 和 code.c 为例。编译后的两个 .o 文件各自独立,彼此不知道对方的存在

对目标文件 hello.o 进行反汇编:

复制代码
objdump -d hello.o

可以看到,这里的 call 指令对应的 printf 和 run 函数,跳转地址都被设为了 0x00000000。因为编译器在编译 hello.c 时,完全不知道 printf 和 run 函数位于内存的哪个位置,甚至根本不知道它们的代码长什么样。

6.2 符号表------标记"未定义"的符号

复制代码
readelf -s hello.o

注意:S和s对应的不同的命令

复制代码
readelf -s hello.o   # 小写s:--symbols,【查看符号表】(符号名、全局/局部、函数/变量)
readelf -S hello.o   # 大写S:--section-headers,【查看段头表(节表)】.text/.data/.bss/.rodata这些section

再看 code.o文件:

UND(undefine) 表示本 .o 文件找不到这个符号。hello.o 的 run 是 UND,code.o 的 run 是已定义的------链接器在链接时会将它们匹配起来。

6.3 链接时的重定位

再看看两者链接形成的可执行文件:(这里太长,只展示部分)

复制代码
readelf -s a.out

run 函数所在的 section 被合并最终的那一个 section 中了,13就是下标(和main函数在一起)

看一下a.out的反汇编代码(已经有了地址)

对比 链接前(hello.o) 链接后(a.out)
run 的地址 00 00 00 00 0x401145
printf 的地址 00 00 00 00 0x401130(puts@plt)
Section 编号 各自的 .text(Nr 1) 合并后的 .text(Nr 13)

6.4 静态链接的本质

复制代码
静态链接 = 合并所有 .o 的 Section + 地址修正

无论是你自己的 .o,还是静态库中的 .o,本质上都是把 .o 文件进行连接。链接器会根据重定位表找到那些需要被重定位的函数和全局变量,修正它们的地址。

七、虚拟地址空间------链接时就已编好址

7.1 一个反直觉的事实

一个 ELF 程序,在没有被加载到内存的时候,就已经有地址了!

复制代码
$ readelf -h a.out
  Entry point address:               0x1060

可执行文件的入口地址在编译链接时就已确定,这不是物理内存地址,而是虚拟地址

当代计算机都采用 平坦模式 工作,所以 ELF 对自己的代码和数据进行了统一编址。反汇编出来的最左侧一列就是 ELF 的虚拟地址(逻辑地址=起始地址+偏移量)

7.2 进程地址空间的数据从哪里来?

答案:从 ELF 的各个 Segment 来。

每个 Segment 有自己的起始地址和长度,操作系统用它来初始化内核结构中的 [start, end] 范围数据,另外还用更详细的地址填充页表。

结论:虚拟地址机制,不光 OS 要支持,编译器也要支持。

八、动态链接------运行时才见真章

8.1 为什么需要动态链接?

静态链接会产生巨大的可执行文件,并且多个程序如果都包含相同的库代码(如 printf),内存中会有大量副本:

动态链接将符号解析和地址重定位从编译时推迟到程序加载运行的时候,这是性价比极高的取舍

8.2 程序启动的完整流程

你以为是 main() 开始一切?实际上在 main 之前有一段"隐藏剧情":

阶段 说明
_start C 运行时库(glibc)或链接器提供的特殊函数,程序的真正入口
动态链接器 ld-linux.so,解析程序依赖的所有动态库并加载到内存
__libc_start_main glibc 提供的函数,完成额外初始化后调用 main
main 程序员编写的入口函数

8.3 动态库的PIC原理

动态库为了能加载到任意进程的任意地址,必须采用相对地址编址 ------PIC(Position Independent Code,位置无关码)所有地址都是相对于当前指令指针的偏移量。这也就是为什么编译动态库时必须指定 -fPIC

复制代码
libmystdio.so: my_stdio.o my_string.o
    gcc -o $@ $^ -shared
%.o:%.c
    gcc -fPIC -c $<

8.4 GOT(全局偏移表)------动态链接的基石

动态链接面临一个核心矛盾:代码段是只读的不能改,但函数地址又需要在加载时修正

解决方案就是在 .data 段(可读写)中预留一块区域来存放函数的跳转地址------这就是 GOT(Global Offset Table,全局偏移表)

复制代码
$ readelf -S a.out
  [24] .got    PROGBITS  0000000000003fb8  00002fb8
       0000000000000048  0000000000000008  WA  0  0  8   # WA = 可写+可分配

GOT 的四大要点

要点 说明
1 由于代码段只读,不能直接修改。GOT 在可读写段,可在运行时更新
2 在单个 .so 下,GOT 与 .text 的相对位置固定,CPU 可通过相对寻址找到 GOT
3 调用函数时先查 GOT 表,根据表中地址跳转,地址在加载时被修改
4 这种机制就是 PIC(位置无关码)= 相对编址 + GOT

8.5 PLT(过程链接表)------延迟绑定

如果程序启动时对所有库函数都进行重定位,启动速度会非常慢。因此引入了 PLT(Procedure Linkage Table) ------延迟绑定

与其一开始就对所有函数重定位,不如将这个过程推迟到函数第一次被调用的时候因为绝大多数动态库中的函数可能在程序运行期间一次都不会被用到

九、总结对比------静态链接 vs 动态链接

维度 静态链接 动态链接
链接时机 编译时链接 加载运行时链接
可执行文件大小 大(包含所有库代码) 小(只包含引用信息)
磁盘空间占用 每个程序独立包含库代码 → 浪费 多个程序共享库文件 → 节省
内存占用 每个程序独享一份库代码 物理内存中一份库,被所有进程共享
运行时依赖 无依赖,独立运行 需要系统中存在对应的 .so 文件
更新维护 替换库后需重新链接整个程序 替换 .so 文件即可,无需重新编译
地址修正方式 编译重定位(静态重定位) 加载重定位(动态重定位)
性能 无额外加载开销 需启动动态链接器,有额外开销
核心机制 重定位表 + Section 合并 GOT + PLT + 相对编址(PIC)

它们共同的根------ELF

回顾全文,你会发现:

无论是 .o 目标文件、.a 静态库、.so 动态库,还是 a.out 可执行文件------它们全部都是 ELF 文件!

只不过同一份 ELF 格式,通过**链接视图(节头表)服务于编译器和链接器,通过执行视图(程序头表)**服务于操作系统的加载器:

掌握了 ELF,就掌握了 Linux 下程序从源代码 → 编译 → 链接 → 加载 → 运行的完整链路。

相关推荐
MrDJun1 小时前
长期稳定跑网页监控:TLS 指纹、代理选路与请求节流的工程实践
运维·爬虫·python·网络协议·网站监控
AAA@峥1 小时前
Ceph 集群配置管理完整指南
运维·数据库·分布式·ceph
kidwjb1 小时前
Linux内核-内核信号处理函数
linux·内核·信号处理
爱吃龙利鱼1 小时前
K8s 运维体系搭建:全组件监控与日志收集实战(victoria-metrics-k8s-stack 0.87.0)
运维·容器·kubernetes
weixin_446729161 小时前
Nginx简单学习与了解
运维·nginx
其实防守也摸鱼2 小时前
镜像校验完成iso完整操作流程(Windows 环境,适配 Ubuntu 22.04 / 24.04)
linux·服务器·windows·学习·ubuntu·教程
努力努力再努力wz2 小时前
【分布式系统与 RPC 框架系列】从单机瓶颈到远程调用:一文理解分布式架构与 RPC 原理
linux·网络·c++·分布式·网络协议·rpc·架构
沉默de荒年 对月影成2 小时前
机房断电搞崩服务器 | 人大金仓 V8 全量备份跨实例完整恢复实录
运维·服务器·oracle
GIS数据转换器2 小时前
智慧灌区管理平台
大数据·服务器·网络·数据库·人工智能·生活