5. 目标文件

什么是目标文件

编译器把 .c 源文件编译后生成 .o 文件,这就是目标文件(Object File)

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

$ gcc -c hello.c    # 编译,不链接
$ gcc -c code.c
$ ls
hello.c  hello.o  code.c  code.o

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

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

注意关键词 relocatable(可重定位的),说明这个文件还不能直接运行,需要链接后才行。

目标文件的意义

  1. 模块化:一个大项目分成多个 .c 文件,各自编译成 .o
  2. 增量编译:修改了哪个 .c,只需要重新编译那一个 .o,其他不动
  3. 链接合并:最后把所有 .o 链接成一个可执行程序
hello.c ──gcc -c──→ hello.o ──┐
                               ├── gcc ──→ a.out(可执行程序)
code.c  ──gcc -c──→ code.o  ──┘

6. ELF文件(重点)

四种ELF文件

ELF不只是可执行文件,有四种类型:

类型 后缀 说明
可重定位文件 .o 目标文件,需要链接后才能运行
可执行文件 无固定后缀 可以直接运行的程序
共享目标文件 .so 动态库
核心转储 core

存放当前进程的执行上下文,用于dump信号触发。

进程崩溃时的内存转储,用于调试

它们都是ELF格式,只是类型不同。

ELF文件的内部结构

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

• ELF头(ELF header) :描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文 件的其他部分。

• 程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表里 记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中, 需要段表的描述信息,才能把他们每个段分割开。

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

• 节(Section ):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和 数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。

最常见的节:

• 代码节(.text):用于保存机器指令,是程序的主要执行部分。

• 数据节(.data):保存已初始化的全局变量和局部静态变量

经典问题:“ELF 文件中的‘程序头表(Program Header Table)’和‘节头表(Section Header Table)’有什么区别?”

  1. 视角不同

    • 节头表(Section Header Table):是给编译器和链接器看的(静态视角)。它描述的是文件内部的逻辑结构,比如哪里是代码(.text),哪里是数据(.data),哪里是符号表。

    • 程序头表(Program Header Table):是给操作系统(OS)加载器看的(运行视角)。它描述的是文件加载到内存时的物理布局,比如哪些数据需要被映射为“只读可执行”的内存页,哪些需要“可读可写”。

  2. 段(Segment)与节(Section)的关系

    • 一个“段(Segment)”在内存中是一块连续的、具有相同权限的内存区域。

    • 为了节省内存和提高加载效率,操作系统在运行时会把多个具有相同属性的“节(Section)”打包成一个“段(Segment)”一次性加载进内存。

+-------------------+
|   ELF Header      |   ← 文件头:描述文件基本信息
+-------------------+
| Program Header    |   ← 程序头表:告诉OS如何加载(可选,.o没有)
| Table             |
+-------------------+
|   Section 1       |
+-------------------+
|   Section 2       |   ← 节:代码、数据、符号表等
+-------------------+
|      ...          |
+-------------------+
|   Section n       |
+-------------------+
| Section Header    |   ← 节头表:描述每个Section的信息
|      Table        |
+-------------------+

ELF Header (ELF 头部)

位置:文件最开头。

作用:整个 ELF 文件的“身份证”。

关键信息:

e_ident:魔数(Magic Number),前4个字节通常是 7f 45 4c 46,表示这是一个 ELF 文件。

e_type:文件类型(如可执行文件、共享库、重定位文件)。

e_machine:架构类型(如 x86-64)。

e_entry:程序入口点,即程序开始执行时的虚拟内存地址。

e_phoff / e_shoff:分别指向“程序头表”和“节头表”在文件中的偏移位置。

Program Header Table (程序头表)

别名:段表(Segment Header Table)。

作用:专门给操作系统加载器(Loader)看的,描述如何将文件加载到内存中运行。

组成:由多个 Program Header (程序头 / Elf_Phdr) 组成的数组。

Elf_Phdr 详解:

p_type:段类型(如 LOAD表示可加载到内存,PHDR表示程序头表本身)。

p_offset:该段在文件中的偏移量。

p_vaddr:该段加载到内存后的虚拟起始地址。

p_filesz / p_memsz:段在文件中的大小和在内存中的大小(内存大小通常 >= 文件大小,多余部分用0填充)。

p_flags:权限标志(读 R、写 W、执行 X)。

Sections (节)

位置:位于 ELF 头和程序头表之后。

作用:给编译器、链接器和调试器看的。源代码被编译后会分割成不同的逻辑块,称为“节”。

常见节举例:

.text:存放程序的机器指令(代码)。

.data:存放已初始化的全局变量和静态变量。

.bss:存放未初始化的全局变量(运行时清零,文件中不占空间)。

.rodata:存放只读数据(如字符串常量)。

.symtab:符号表(存放函数名、变量名等)。

.debug:调试信息。

Section Header Table (节头表)

作用:类似于目录,列出了文件中所有的“节(Sections)”。

组成:由多个 Section Header (节头 / Elf_Shdr) 组成的数组。

Elf_Shdr 详解:

sh_name:节的名称索引。

sh_type:节的类型(如代码节、数据节等)。

sh_addr:节加载到内存后的虚拟地址(如果适用)。

sh_offset:节在文件中的偏移量。

sh_size:节的大小。

sh_link / sh_info:链接信息(如符号表对应的字符串表索引)。

核心逻辑总结

程序执行时:操作系统读取 ELF Header,找到 Program Header Table。根据表中的信息,将标记为 LOAD的段映射到内存中,然后从 e_entry指定的地址开始执行程序。

程序链接/调试时:工具(如 ldgdb)读取 ELF Header,找到 Section Header Table。通过节头表找到 .text(代码)、.data(数据)和 .symtab(符号表)等,进行链接或调试操作。

最常见的Section

Section名 内容
.text 代码段,存放机器指令
.data 数据段,存放已初始化的全局变量和静态变量
.bss 存放未初始化的全局变量和静态变量(不占文件空间,加载时清零)
.rodata 只读数据,比如字符串常量 "hello world\n"
.symtab 符号表,函数名、变量名和地址的对应关系
.strtab 字符串表,存放符号名等字符串
.rel.text .text 的重定位表,记录需要链接时修正的地址
.got 全局偏移表,动态链接用(后面讲)
.plt 过程链接表,动态链接用(后面讲)

两个视图

ELF提供两个视角来理解同一份文件:

链接视图(Section Header Table)

  • 面向链接器
  • 把文件按功能细分成很多Section
  • 链接时关注这个视图

执行视图(Program Header Table)

  • 面向操作系统
  • 告诉OS怎么把文件加载到内存
  • 把相同属性的Section合并成Segment(段)
  • 运行时关注这个视图

为什么要合并成Segment?

假设 .text 是4097字节,.init 是512字节,它们都是只读可执行的。如果不合并,需要3个内存页(4096×3=12288字节)。合并后只需2个页(4096×2=8192字节),节省内存

Section视图(细粒度):          Segment视图(粗粒度):
┌──────────┐                   ┌─────────────────────────────┐
│ .text    │ 4097字节           │  LOAD(只读可执行)          │
├──────────┤                   │  .text + .init + .rodata    │
│ .init    │ 512字节    ──→    ├─────────────────────────────┤
├──────────┤                   │  LOAD(可读可写)            │
│ .rodata  │ 100字节           │  .data + .bss + .got        │
├──────────┤                   └─────────────────────────────┘
│ .data    │ 64字节
├──────────┤
│ .bss     │ 128字节
└──────────┘
占用3个页                      占用2个页

查看ELF信息的命令

# 查看Section信息
$ readelf -S a.out

# 查看Segment信息(Program Header Table)
$ readelf -l a.out

# 查看ELF Header
$ readelf -h a.out

# 反汇编代码段
$ objdump -d a.out

# 查看符号表
$ readelf -s a.out

ELF Header关键字段

$ readelf -h hello.o
ELF Header:
  Magic:   7f 45 4c 46 ...     # ELF魔数,固定开头
  Class:    ELF64               # 64位
  Type:     REL (Relocatable)   # 类型:可重定位文件(.o)
  Entry point address: 0x0      # 入口地址:0(.o没有入口,不能运行)
  Number of program headers: 0  # 没有程序头表(.o不需要)
  Number of section headers: 13 # 有13个Section

$ readelf -h a.out
ELF Header:
  Type:     DYN (Shared object) # 类型:可执行(现代Linux用PIE)
  Entry point address: 0x1060   # 入口地址:_start的位置
  Number of program headers: 13 # 有程序头表
  Number of section headers: 31 # 有31个Section

对比关键差异

  • .o 文件类型是 REL(可重定位),没有入口地址,没有Program Header
  • 可执行文件类型是 DYN/EXEC,有入口地址,有Program Header

Section Header结构体

typedef struct elf64_shdr {
    Elf64_Word  sh_name;        // Section名(在字符串表中的索引)
    Elf64_Word  sh_type;        // Section类型
    Elf64_Xword sh_flags;       // 标志(可写、可执行等)
    Elf64_Addr  sh_addr;        // 加载到内存的虚拟地址
    Elf64_Off   sh_offset;      // 在文件中的偏移量
    Elf64_Xword sh_size;        // Section大小
    Elf64_Word  sh_link;        // 关联的另一个Section索引
    Elf64_Word  sh_info;        // 额外信息
    Elf64_Xword sh_addralign;   // 对齐要求
    Elf64_Xword sh_entsize;     // 表项大小(如果是表的话)
} Elf64_Shdr;

最重要的字段

  • sh_offset:这个Section在文件的什么位置
  • sh_addr:加载到内存的什么虚拟地址
  • sh_size:多大
  • sh_flags:权限(W=可写,X=可执行,A=需要加载)

Program Header结构体

typedef struct elf64_phdr {
    Elf64_Word  p_type;     // 段类型(LOAD、DYNAMIC等)
    Elf64_Word  p_flags;    // 权限标志
    Elf64_Off   p_offset;   // 在文件中的偏移
    Elf64_Addr  p_vaddr;    // 加载到内存的虚拟地址
    Elf64_Addr  p_paddr;    // 物理地址(现代OS不用关心)
    Elf64_Xword p_filesz;   // 在文件中的大小
    Elf64_Xword p_memsz;    // 在内存中的大小(可能比文件大,比如.bss)
    Elf64_Xword p_align;    // 对齐
} Elf64_Phdr;

最重要的字段

  • p_typeLOAD 表示需要加载到内存
  • p_vaddr:加载到哪个虚拟地址
  • p_filesz / p_memsz:文件大小和内存大小(.bss导致内存大小更大)
  • p_flagsR=可读,W=可写,X=可执行

读取Program Header示例

$ readelf -l a.out

Program Headers:
  Type    Offset   VirtAddr           FileSiz   MemSiz   Flags
  LOAD    0x000000 0x0000000000400000 0x000744  0x000744  R E   # 代码段(只读可执行)
  LOAD    0x000e10 0x0000000000600e10 0x000218  0x000220  RW    # 数据段(可读可写)

Section to Segment mapping:
  Segment 02: .text .rodata ...       # 代码段包含这些Section
  Segment 03: .data .bss .got ...     # 数据段包含这些Section

LOAD 类型的Segment才会被加载到内存。R E = 只读可执行(代码段),RW = 可读可写(数据段)。


4~6节总结

  • 第4节:体验第三方库的使用流程(安装→包含头文件→-l链接)
  • 第5节.o 目标文件 = 编译产物,ELF格式,还不能运行
  • 第6节:ELF格式详解 —— Header定位其他部分,Section是链接时的单位,Segment是加载时的单位,相同属性的Section合并成Segment节省内存

7. ELF从形成到加载轮廓

7-1 ELF如何形成可执行程序

整个过程分两步:

Step 1:源代码编译成 .o 文件

$ gcc -c hello.c    # → hello.o
$ gcc -c code.c     # → code.o

每个 .o 文件都有自己的 .text(代码段)、.data(数据段)、.symtab(符号表)等Section。每个.o的地址都是从0开始编址的,因为编译器不知道这个.o最终会被放在内存的什么位置。

Step 2:链接器把多个 .o 的Section合并

$ gcc hello.o code.o -o main.exe

链接器做两件事:

1. 合并同类Section

2. 地址重定位(最关键)

编译hello.o时,main 函数调用了 runprintf,但编译器不知道它们在哪,所以call指令后面填的是 00 00 00 00

$ objdump -d hello.o

0000000000000000 <main>:
   9:   e8 00 00 00 00    callq  14 <main+0x14>   # run(),地址未知
   ...
  13:   e8 00 00 00 00    callq  1e <main+0x1e>   # printf(),地址未知

链接合并后,所有函数的最终地址都确定了:

$ objdump -d main.exe

0000000000001149 <run>:        # run函数的最终地址
    ...
    callq  1050 <puts@plt>     # printf内部调用puts,地址已确定

0000000000001160 <main>:       # main函数的最终地址
    ...
    callq  1050 <puts@plt>     # printf地址已修正
    ...
    callq  1149 <run>          # run地址已修正!不再是00000000

重定位的依据是什么? 符号表。

每个 .o 都有符号表,记录了"我定义了哪些符号"和"我引用了哪些未定义的符号":

# hello.o的符号表
$ readelf -s hello.o
    10: 0000000000000000  37 FUNC  GLOBAL DEFAULT  1 main   # main定义在hello.o
    12: 0000000000000000   0 NOTYPE GLOBAL DEFAULT  UND puts  # puts未定义
    13: 0000000000000000   0 NOTYPE GLOBAL DEFAULT  UND run   # run未定义

# code.o的符号表
$ readelf -s code.o
    10: 0000000000000000  23 FUNC  GLOBAL DEFAULT  1 run    # run定义在code.o
    12: 0000000000000000   0 NOTYPE GLOBAL DEFAULT  UND puts  # puts未定义

链接器看到:hello.o 需要 runcode.o 提供了 run → 把 run 的实际地址填到 hello.o 中call指令的空白处。

puts 两个.o都没有定义 → 去库里找 → 动态库libc.so提供 → 标记为动态链接。

合并后的符号表确认一切就绪:

$ readelf -s main.exe
    52: 0000000000001149  23 FUNC  GLOBAL DEFAULT  16 run    # 已找到,地址0x1149
    63: 0000000000001160  37 FUNC  GLOBAL DEFAULT  16 main   # 已找到,地址0x1160

静态链接的本质:把 .o 的Section合并 + 修正未确定的函数/变量地址(重定位)。静态库 .a 就是一堆 .o 的打包,链接过程完全一样。


7-2 ELF加载到内存

可执行文件在磁盘上是ELF格式,加载到内存时要经过一次"变身"。

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

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

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

• 很显然,这个合并工作也已经在形成ELF的时候,合并方式已经确定了,具体合并原则被记录在了 ELF的 程序头表(Program header table) 中

为什么要合并Section成Segment?

前面讲过,加载时不是按Section一个个加载,而是把相同属性的Section合并成Segment再加载。

原因:内存页对齐

内存以页(page)为单位管理,通常是4KB(4096字节)。

如果不合并:

.text = 4097字节  → 占2页(第2页只用了1字节,剩余4095字节浪费)
.init = 512字节   → 占1页(剩余3584字节浪费)
总计:占3页,浪费大量空间

合并后:

LOAD(只读可执行)= .text + .init = 4097 + 512 = 4609字节 → 占2页
总计:占2页,浪费少得多
Section到Segment的映射
$ readelf -l a.out

Section to Segment mapping:
  Segment Sections...
   02     .interp .note.ABI-tag .gnu.hash .dynsym .dynstr
          .gnu.version .rela.dyn .rela.plt .init .plt .text
          .fini .rodata .eh_frame
   03     .init_array .fini_array .dynamic .got .got.plt .data .bss

Segment 02:所有只读可执行的Section合并(代码段) Segment 03:所有可读可写的Section合并(数据段)

谁来做这个合并?

链接器在链接时就确定了合并规则,记录在可执行文件的Program Header Table中。OS加载时只需要按Program Header的描述把对应内容搬到内存即可。

ELF加载的完整流程
磁盘上的ELF文件                    内存中的进程
┌──────────────┐                ┌──────────────────────┐
│ ELF Header   │                │  虚拟地址空间          │
│ Prog Header  │ ──→ OS加载 ──→ │                      │
│ LOAD(R E)    │    按Segment   │  0x400000: 代码段     │
│ LOAD(RW)     │    映射到      │  (只读可执行)          │
│ ...          │    虚拟地址    │                      │
│ Section Table│                │  0x600000: 数据段     │
└──────────────┘                │  (可读可写)           │
                                └──────────────────────┘
ELF在没有加载前就有地址

关键点:ELF文件在编译好之后,里面的代码和数据就已经有了虚拟地址

$ objdump -d a.out

0000000000001160 <main>:     # main的虚拟地址是 0x1160
    ...

这个地址不是加载后才有的,而是编译链接时就确定了。编译器和链接器按照"平坦模式"对整个程序统一编址。

这就是为什么ELF Header里有 Entry point address: 0x1060 —— 程序还没加载,入口地址就已经写好了。

Entry Point(入口地址)
$ readelf -h a.out
  Entry point address: 0x1060

这个地址就是程序启动时CPU跳转的第一条指令的位置。对于C程序,它不是 main,而是 _start(由C运行时库提供)。

进程虚拟地址空间的初始化从哪来?

OS创建进程时,需要初始化 mm_structvm_area_struct 等内核结构。这些数据来自ELF的Program Header Table

  • 每个LOAD Segment的 p_vaddr(虚拟地址)和 p_memsz(内存大小)→ 初始化 vm_area_struct 的 [start, end]
  • 用这些信息建立页表,把虚拟地址映射到物理内存

所以:虚拟地址机制不只是OS要支持,编译器也要支持。编译器在编译时就给程序分配好了虚拟地址,OS加载时按照这些地址建立映射。


第7节总结

源代码 (.c)
    ↓ gcc -c
目标文件 (.o)          ← 有自己的Section,地址从0开始,符号表有UND
    ↓ gcc(链接器)
可执行文件 (ELF)       ← Section合并,地址统一编址,符号地址全部确定
    ↓ OS加载
内存中的进程           ← Section合并成Segment,按虚拟地址映射到内存

三个核心过程

  1. 编译:源代码 → .o(有UND未定义符号,call地址为0)
  2. 链接:多个.o合并,Section合并 + 地址重定位(填上真正的地址)
  3. 加载:OS按Program Header把Segment映射到虚拟地址空间

面试:

考点1:ELF文件有哪几种类型?

答案

  • 可重定位文件(.o):目标文件,需要链接后才能运行
  • 可执行文件:可以直接运行的程序
  • 共享目标文件(.so):动态库
  • 核心转储(core dump):进程崩溃时的内存转储
考点2:ELF文件由哪几部分组成?

答案

  • ELF Header:文件头,描述文件基本信息,定位其他部分
  • Program Header Table:程序头表,告诉OS如何加载(执行视图)
  • Section:代码、数据等基本组成单位
  • Section Header Table:节头表,描述每个Section(链接视图)
考点3:Section和Segment的区别?

答案

  • Section是链接时的概念,粒度细,按功能划分(.text、.data、.bss等)
  • Segment是加载时的概念,粒度粗,把相同属性的Section合并
  • 链接器在链接时就确定了合并规则,记录在Program Header Table中
  • 为什么要合并:减少内存页碎片,提高内存利用率。内存以4KB页为单位分配,小Section各自占一页会浪费大量空间
考点4:常见的Section有哪些?各自存什么?

答案

Section 内容
.text 机器指令(代码)
.data 已初始化的全局变量和静态变量
.bss 未初始化的全局变量和静态变量(不占文件空间,加载时清零)
.rodata 只读数据(字符串常量等)
.symtab 符号表(函数名、变量名和地址的对应关系)
.rel.text 重定位表(记录需要链接时修正的地址)
.got 全局偏移表(动态链接用)
考点5:静态链接的过程做了什么?

答案

  1. 合并Section:把多个.o的同类Section合并(如各自.text合并成一个大的.text)
  2. 地址重定位:根据符号表,把.o中未确定的函数/变量地址(call后面的00000000)修正为合并后的最终地址
考点6:为什么.o文件中call指令的地址是0?

答案: 因为编译单个 .c 文件时,编译器不知道外部函数(如其他文件定义的函数、库函数)的最终地址。所以暂时填0,记录在重定位表中。链接时,链接器根据符号表找到这些函数的实际地址,再把0修正为正确的地址。

考点7:什么是符号表?有什么用?

答案: 符号表记录了文件中所有符号(函数名、全局变量名)的信息:

  • 定义的符号:本文件提供的函数/变量及其地址
  • 未定义符号(UND):本文件引用了但找不到的符号,需要链接时从其他.o或库中查找

链接器靠符号表完成跨文件的地址解析。

考点8:ELF在没有加载到内存前有没有地址?

答案。编译器和链接器按照"平坦模式"对整个程序统一编址,代码和数据在编译链接完成后就有了虚拟地址。ELF Header中的 Entry point address 就是入口虚拟地址。OS加载时,按照这些预设的虚拟地址建立页表映射。

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

考点9:.bss段为什么不占文件空间?

答案.bss 存的是未初始化的全局变量和静态变量,初始值都是0。既然全是0,就不需要在文件中实际存储,只需要在Program Header中记录"内存中需要多少字节"(p_memsz),OS加载时自动清零即可。所以 p_filesz(文件大小)为0,p_memsz(内存大小)大于0。

考点10:readelf -h、readelf -l、readelf -S 分别看什么?

答案

  • readelf -h:看ELF Header(文件类型、入口地址、Section和Program Header的位置和数量)
  • readelf -l:看Program Header Table(Segment信息,OS如何加载)
  • readelf -S:看Section Header Table(Section信息,链接时用)
考点11:程序的入口是main吗?

答案不是。ELF Header中的Entry Point指向的是 _start(由C运行时库或链接器提供)。_start 做完初始化工作(设置堆栈、初始化数据段、完成动态链接)后,再调用 __libc_start_main,最后才调用 mainmain 返回后,__libc_start_main 处理返回值并调用 _exit 终止进程。

8. 理解链接与加载

8-1 静态链接(回顾+深入)

静态链接的本质就是把多个 .o 合并成一个可执行文件。用一个具体例子走一遍:

# 两个源文件
$ cat hello.c
#include<stdio.h>
void run();
int main() {
    printf("hello world!\n");
    run();
    return 0;
}

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

# 编译成.o
$ gcc -c hello.c
$ gcc -c code.c

# 链接成可执行程序
$ gcc hello.o code.o -o main.exe
编译后的.o长什么样

hello.o 反汇编

$ objdump -d hello.o

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

code.o 反汇编

$ objdump -d code.o

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
   f:   e8 00 00 00 00          callq  14 <run+0x14>        # printf,地址=0
  14:   90                      nop
  15:   5d                      pop    %rbp
  16:   c3                      retq

关键观察

  • 所有call指令后面的地址都是 00 00 00 00 —— 因为编译时不知道外部函数在哪
  • 每个.o的地址都从0开始 —— 因为不知道自己最终在内存什么位置
符号表揭示了"谁定义了谁需要了"

hello.o 的符号表

$ readelf -s hello.o
Symbol table '.symtab' contains 14 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
    10: 0000000000000000    37 FUNC    GLOBAL DEFAULT    1 main      # main定义在这里
    12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND puts      # puts找不到
    13: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND run       # run找不到

code.o 的符号表

$ readelf -s code.o
Symbol table '.symtab' contains 13 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
    10: 0000000000000000    23 FUNC    GLOBAL DEFAULT    1 run       # run定义在这里
    12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND puts      # puts找不到

UND = undefined,表示"我需要这个符号但在我这里找不到"。

链接器看到:

  • hello.o 需要 run(UND),code.o 提供了 run(Ndx=1)→ 配对成功
  • 两个.o都需要 puts(UND)→ 去库里找 → 动态库libc.so提供
链接后的结果

main.exe 反汇编

$ objdump -d main.exe

0000000000001149 <run>:              # run的最终地址:0x1149
    ...
    callq  1050 <puts@plt>           # printf→puts,地址已修正为0x1050

0000000000001160 <main>:             # main的最终地址:0x1160
    ...
    callq  1050 <puts@plt>           # printf→puts,地址已修正
    ...
    callq  1149 <run>                # run,地址已修正为0x1149

main.exe 的符号表

$ readelf -s main.exe
    52: 0000000000001149    23 FUNC    GLOBAL DEFAULT   16 run     # 找到了,地址确定
    63: 0000000000001160    37 FUNC    GLOBAL DEFAULT   16 main    # 找到了,地址确定

第16号Section就是合并后的.text段

$ readelf -S main.exe
  [16] .text    PROGBITS    0000000000001060    AX    # 代码段,可读可执行

静态链接总结

  1. 合并所有.o的同类Section(.text合并成一个大的.text)
  2. 统一编址(每个函数、变量都有了最终的虚拟地址)
  3. 根据符号表和重定位表,修正所有call指令后面的目标地址

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

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


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

8-2-1 虚拟地址/逻辑地址

问题:ELF程序还没加载到内存时,有没有地址?

答案

$ objdump -d main.exe

0000000000001160 <main>:    # 0x1160就是main的虚拟地址
    ...

这个地址是编译链接时就分配好的,不是加载后才有的。编译器按照"平坦模式"对整个程序从0开始统一编址:

0x00000000:  程序起始
0x00400000:  代码段起始(典型值)
0x00600000:  数据段起始(典型值)
...

问题:进程的 mm_structvm_area_struct 刚创建时,初始化数据从哪来?

答案从ELF的Program Header Table来

每个LOAD Segment有自己的起始虚拟地址(p_vaddr)和长度(p_memsz),OS用这些信息初始化内核结构中的 [start, end] 范围,然后填充页表。

ELF Program Header:
  LOAD  VirtAddr=0x400000  MemSiz=0x744  Flags=RE   → 代码段
  LOAD  VirtAddr=0x600000  MemSiz=0x220  Flags=RW   → 数据段
           ↓ 加载时
进程 mm_struct:
  vm_area_struct { start=0x400000, end=0x400744, flags=RE }
  vm_area_struct { start=0x600000, end=0x600220, flags=RW }
           ↓ 建立页表
页表: 虚拟地址 0x400000 → 物理内存页X
      虚拟地址 0x600000 → 物理内存页Y

核心结论:虚拟地址机制,OS要支持,编译器也要支持。编译器负责分配虚拟地址,OS负责建立映射。

8-2-2 重新理解进程虚拟地址空间
                进程虚拟地址空间
        ┌──────────────────────────┐ 高地址
        │        栈(Stack)        │  ↓ 向下增长
        ├──────────────────────────┤
        │       共享库映射区        │  ← 动态库加载到这里
        ├──────────────────────────┤
        │        堆(Heap)        │  ↑ 向上增长
        ├──────────────────────────┤
        │        .bss              │
        │        .data             │  ← 数据段(可读可写)
        ├──────────────────────────┤
        │        .text             │  ← 代码段(只读可执行)
        ├──────────────────────────┤
        │        保留区域           │
        └──────────────────────────┘ 低地址

ELF加载后,代码段映射到低地址的 .text 区域,数据段映射到 .data/.bss 区域。这些地址在编译时就确定了。


8-3 动态链接与动态库加载(最难的部分)

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

动态库被映射到进程的共享区(栈和堆之间的区域)。进程通过虚拟地址访问共享库。

8-3-2 进程间如何共享库

多个进程的页表都映射到同一份物理内存中的动态库代码。所以动态库在内存中只有一份,节省内存。

8-3-3 动态链接的原理
静态链接的问题

静态链接把库代码拷贝进可执行文件,导致:

  1. 文件体积大
  2. 多个程序包含相同代码,浪费磁盘和内存
  3. 库更新后需要重新编译所有程序
动态链接的优势

动态链接把链接过程推迟到程序运行时

  • 可执行文件不包含库代码,只记录"我需要哪个库的哪个函数"
  • 运行时,OS把动态库加载到内存,再修正地址
程序启动时发生了什么

在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上,程序的入口点 是 _start ,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。 在 _start 函数中,会执行一系列初始化操作,这些操作包括:

1. 设置堆栈:为程序创建一个初始的堆栈环境。

2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位 置,并清零未初始化的数据段。

3. 动态链接:这是关键的一步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的 动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调 用和变量访问能够正确地映射到动态库中的实际地址。

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程序的入口不是 main,而是 _start。启动过程:

_start
  │
  ├── 1. 设置堆栈
  │
  ├── 2. 初始化数据段(.data从文件复制,.bss清零)
  │
  ├── 3. 动态链接(关键!)
  │     ├── 动态链接器(ld-linux.so)被加载
  │     ├── 解析程序依赖的所有动态库
  │     ├── 把动态库加载到内存
  │     ├── 完成符号解析和地址重定位
  │
  ├── 4. 调用 __libc_start_main
  │
  ├── 5. __libc_start_main 调用 main
  │
  └── 6. main返回后,__libc_start_main 调用 _exit 终止
动态库为什么用相对地址

注意:

• 我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该 提前知道

• 然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置 (这个叫做加载地址重定位)

• 等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?

动态库加载到内存的地址每次可能不同(由OS决定)。如果用绝对地址,加载到不同位置就出错了。

所以动态库内部用相对地址(偏移量)编址:

动态库 libc.so 内部:
  函数A在偏移 0x100 处
  函数B在偏移 0x200 处

不管库加载到什么地址:
  加载到 0x7f000000 → A的实际地址 = 0x7f000000 + 0x100
  加载到 0x7f100000 → A的实际地址 = 0x7f100000 + 0x100

相对偏移 0x100 永远不变
问题:代码段是只读的,怎么修正地址?

动态链接需要在运行时修改函数地址。但代码段(.text)是只读可执行的,不能修改!

解决方案:GOT(全局偏移表)

1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不 同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的 每个动态库都有独立的GOT表,所以进程间不能共享GOT表。

2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找 到GOT表。

3. 在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会 被修改为真正的地址。

4. 这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,我们的动态库不需要做任何修 改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给 编译器指定-fPIC参数的原因,PIC=相对编址+GOT。

GOT放在 .data(可读可写),不在 .text 段。代码通过GOT间接跳转:

代码段(只读)                    数据段(可读写)
┌──────────────────┐            ┌──────────────────┐
│ main:            │            │ GOT:             │
│   call printf    │───────────→│   printf地址      │──→ libc.so中的printf
│   ...            │            │   run地址         │──→ code.o中的run
└──────────────────┘            └──────────────────┘

工作流程

  1. 程序调用 printf → 先跳转到GOT中记录的地址
  2. GOT中的地址在动态库加载后被OS修改为真正的地址
  3. 通过GOT跳转到真正的 printf 函数

因为GOT在.data段,可读写,所以可以动态修改。代码段不需要改。

每个进程有自己的GOT表(因为不同进程加载动态库的地址不同),但代码段(.text)可以被所有进程共享。

PIC = 位置无关代码

PIC(Position Independent Code) = 相对编址 + GOT

  • 动态库用相对地址编址(不依赖绝对地址)
  • 通过GOT间接访问外部函数和全局变量
  • 所以动态库可以加载到任意内存位置都能正确运行

这就是为什么编译动态库要加 -fPIC

$ gcc -fPIC -c my_stdio.c    # 产生位置无关码
$ gcc -shared -o libmystdio.so my_stdio.o    # 生成共享库
PLT(过程链接表)— 延迟绑定优化

问题:程序启动时就要解析所有动态库函数的地址,但很多函数可能运行一次都不会被调用,白解析了。

解决方案:PLT(Procedure Linkage Table)延迟绑定

思想:函数第一次被调用时才解析地址,没调用过的不解析。

工作原理

代码中:call printf
         ↓
    跳转到 PLT 中的 printf 条目
         ↓
    PLT 查看 GOT 中 printf 的地址
         ↓
    第一次调用:GOT中存的是PLT的桩代码地址
         ↓
    桩代码调用动态链接器,解析printf的真正地址
         ↓
    把真正地址写入GOT
         ↓
    跳转到真正的printf
         
    第二次调用:GOT中已经是真正地址了
         ↓
    直接跳转,不需要再解析

反汇编验证

$ objdump -d a.out

# PLT表
0000000000001050 <puts@plt>:
    1050:   ff 25 75 2f 00 00    jmpq   *0x2f75(%rip)    # 跳转到GOT中puts的地址
    1056:   68 01 00 00 00       pushq  $0x1              # 桩代码:压入符号索引
    105b:   e9 e0 ff ff ff       jmpq   1040 <.plt>       # 跳转到PLT[0],触发动态链接器

# main函数中调用puts
0000000000001160 <main>:
    ...
    callq  1050 <puts@plt>    # 调用PLT中的puts条目
    ...

第一次调用 puts@plt

  1. jmpq *0x2f75(%rip) → 跳转到GOT中puts的地址
  2. GOT中此时存的是 1056(桩代码地址)
  3. 执行桩代码:push符号索引,跳转到PLT[0]
  4. PLT[0]调用动态链接器
  5. 动态链接器解析puts的真正地址,写入GOT
  6. 跳转到真正的puts

第二次调用 puts@plt

  1. jmpq *0x2f75(%rip) → 跳转到GOT中puts的地址
  2. GOT中已经是真正地址了
  3. 直接跳转到puts,一步到位
库之间的依赖

不只有可执行程序调用库,库也会调用其他库。库之间也有依赖关系。

每个动态库内部也有自己的GOT表,结构和可执行程序一样。这就是为什么动态库也是ELF格式 —— 统一的格式才能支持统一的链接机制。

a.out → 调用 libc.so → 调用 libpthread.so
  │         │                │
  │    libc.so也有GOT   libpthread.so也有GOT
  ↓
 解析依赖关系 = 加载并完善互相之间的GOT表


8-3-4 总结

静态链接 动态链接
链接时机 编译时 运行时
地址修正 编译时一次完成(静态重定位) 加载时完成(动态重定位)
修正方式 直接修改代码段的call地址 通过GOT间接跳转,修改GOT中的地址
可执行文件大小 大(包含库代码) 小(只有函数入口表)
内存占用 每个进程各一份 多进程共享一份
更新维护 需要重新编译 替换.so即可

GOT的作用:代码段只读不能改,所以把需要修改的跳转地址放到数据段的GOT中。代码通过GOT间接跳转到动态库函数。

PLT的作用:延迟绑定,函数第一次调用时才解析地址,避免启动时解析所有函数的开销。

PIC的本质:相对编址 + GOT,让动态库可以加载到任意位置。


8-3 节面试考点

考点1:动态链接和静态链接的区别?

答案

  • 静态链接在编译时把库代码拷贝进可执行文件(静态重定位)
  • 动态链接在运行时加载动态库,通过GOT间接调用(动态重定位)
  • 静态链接文件大、更新难;动态链接文件小、多进程共享、更新方便
考点2:什么是GOT?为什么需要它?

答案: GOT(全局偏移表)存放在.data段(可读写),存放动态库函数的跳转地址。因为代码段是只读的,不能在运行时修改,所以需要GOT作为"跳板",代码通过GOT间接跳转到动态库函数。OS在加载动态库后把真正地址写入GOT。

考点3:什么是PLT?为什么需要它?

答案: PLT(过程链接表)实现延迟绑定。GOT中函数地址默认指向PLT中的桩代码,第一次调用时桩代码触发动态链接器解析真正地址并更新GOT,之后直接跳转。好处是避免程序启动时解析所有动态库函数的开销,只有被调用的函数才解析。

考点4:什么是PIC(位置无关代码)?

答案: PIC = 相对编址 + GOT。动态库内部用相对地址(偏移量)编址,不依赖绝对地址,配合GOT间接访问外部符号。所以动态库可以加载到任意内存位置都能正确运行。编译动态库时必须加 -fPIC

考点5:动态库加载到进程的哪个区域?

答案: 共享区(也叫内存映射区),位于栈和堆之间。通过 mmap 系统调用映射到进程的虚拟地址空间。

考点6:多个进程如何共享同一份动态库?

答案: 动态库的代码在物理内存中只有一份。多个进程的页表都映射到同一份物理内存。但每个进程有各自的GOT表(因为不同进程加载地址不同),GOT不能共享。

考点7:程序启动时的完整流程?

答案_start → 设置堆栈 → 初始化数据段 → 动态链接(加载动态库、解析符号)→ __libc_start_mainmain__libc_start_main_exit

考点8:为什么动态库也是ELF格式?

答案: 因为库之间也有依赖关系,库也会调用其他库。动态库需要和可执行程序一样的结构(GOT、符号表、重定位表等)来支持动态链接。统一的ELF格式让链接器和动态链接器可以用同一套机制处理所有文件。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐