Linux系统编程--库的制作与原理

库的制作与原理

  • 库的制作与原理
    • 1. 什么是库
    • 2. 静态库
      • 2.1 静态库的制作
        • 2.1.1 场景设定
        • 2.1.2 制作步骤
      • 2.2 静态库的使用
        • 2.2.1 库的命名规范
        • 2.2.2 编译命令详解
      • 2.3 静态库的安装和发布
        • 2.3.1 “安装”的本质
        • 2.3.2 安装后的编译
        • 2.3.3 使用 Makefile 实现自动化
    • 3. 动态库
      • 3.1 动态库的制作
        • 3.1.1 场景设定
        • 3.1.2 制作步骤
          • 3.1.2.1 第一步:生成与位置无关的目标文件 (.o)
          • 3.1.2.2 第二步:打包目标文件为动态库 (.so)
      • 3.2 动态库的使用
        • 3.2.1 编译链接
        • 3.2.2 运行失败:问题的出现
        • 3.2.3 问题分析
        • 3.2.4 问题解决
          • 3.2.4.1 方法一:安装库到系统标准目录(最直接)
          • 3.2.4.2 方法二:在系统标准目录中创建软链接
          • 3.2.4.3 方法三:配置环境变量 `LD_ARY_PATH`(临时性)
          • 3.2.4.4 方法四:配置系统链接存(推荐的永久方案)
      • 3.3 动态库的 makefile 自动化处理
    • 4. 链接器的行为与选择
    • 5. 目标文件
      • 5.1 引入
      • 5.2 什么是目标文件
      • 5.3 为何需要独立编译成目标文件
      • 5.3 目标文件与库的关系
    • 6. EIF 文件
      • 6.1 EIF 文件分类
      • 6.2 EIF 文件基本结构
      • 6.3 EIF 从形成到加载轮廓(宏观介绍)
        • 6.3.1 EIF 形成可执行文件
        • 6.3.2 EIF 可执行文件加载
          • 6.3.2.1 加载的结构基础:节(Section)合并为段(Segment)的机制
          • 6.3.2.2 使用 `readelf` 观察加载布局
            • 6.3.2.2.1 视角一:链接器视角 (Section Header Table)
            • 6.3.2.2.2 视角二:加载器视角 (Program Header Table)
            • 6.3.2.2.3 总结:
          • 6.3.2.3 操作系统与加载文件
    • 7. 理解链接与加载(详细介绍)
      • 7.1 静态链接
        • 7.1.1 问题的起点:独立编译与未定义符号
        • 7.1.2 链接器的核心任务:符号解析与地址重定位
          • 7.1.2.1 第一步:符号解析
          • 7.1.2.2 地址重定位 (Address Relocation)
      • 7.2 EIF 文件的加载和形成进程地址空间的过程
        • 7.2.1 地址的“三位一体”:逻辑、虚拟与物理
        • 7.2.1.1 逻辑地址与绝对编址
          • 7.2.1.2 虚拟地址:编译时就已注定
          • 7.2.1.3 物理地址:运行时才被分配
        • 7.2.2 虚拟地址空间的初始化机制
        • 7.2.3 EIF 可执行文件的加载
      • 7.3 动态链接与动态库加载
        • 7.3.1 单个进程如何“看见”动态库
        • 7.3.2多进程间如何共享库
        • 7.3.3 动态链接
          • 7.3.3.1 基本概要
            • 7.3.3.1.1 为什么需要动态链接?——静态链接的局限性
            • 7.3.3.1.2 动态链接的核心思想:推迟链接
          • 7.3.3.2 运行前的“隐形”准备工作
            • 7.3.3.2.1 第一步:`ld-linux.so`:不可或缺的动态链接器
            • 7.3.3.2.2 第二步:`_start` 函数:真正的程序入口
            • 7.3.3.2.3 第三步:从 `__libc_start_main` 到 `main`
          • 7.3.3.3 动态库中的相对地址
          • 7.3.3.4 可执行程序与动态库的映射
            • 7.3.3.4.1 第一步:**寻根溯源:ELF文件中的依赖信息**
            • 7.3.3.4.2 第二步:映射过程:为动态库划分虚拟内存空间
            • 7.3.3.4.3 第三步:**访问机制:虚拟地址 + 偏移量**
            • 7.3.3.5 可执行程序与库函数调用
          • 7.3.3.6 全局偏移量表GOT
            • 7.3.3.6.1 代码区的只读困境
            • 7.3.3.6.2 全局偏移量表 (Global Offset Table, GOT)
          • 7.3.3.7 库间依赖

库的制作与原理

1. 什么是库

库是写好的现有的、成熟的、可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。

本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:

  • 静态库:.a [Linux].lib [Windows]
  • 动态库:.so [Linux].dll [Windows]
# ubuntu 动态库
# C
$ ls -l /lib/x86_64-linux-gnu/libc-2.31.so
-rwxr-xr-x 1 root root 2029592 May  1 02:20 /lib/x86_64-linux-gnu/libc-2.31.so

$ ls -l /lib/x86_64-linux-gnu/libc.a
-rw-r--r-- 1 root root 5747594 May  1 02:20 /lib/x86_64-linux-gnu/libc.a

# C++
$ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so -l
lrwxrwxrwx 1 root root 40 Oct 24  2022 /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so -> ../../../x86_64-linux-gnu/libstdc++.so.6

$ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a
/usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a

# Centos 动态库
# C
$ ls /lib64/libc-2.17.so -l
-rwxr-xr-x 1 root root 2156592 Jun  4 23:05 /lib64/libc-2.17.so

[whb@bite-alicloud ~]$ ls /lib64/libc.a -l
-rw-r--r-- 1 root root 5105516 Jun  4 23:05 /lib64/libc.a

# C++
$ ls /lib64/libstdc++.so.6 -l
lrwxrwxrwx 1 root root 19 Sep 18 20:59 /lib64/libstdc++.so.6 -> libstdc++.so.6.0.19

$ ls /usr/lib/gcc/x86_64-redhat-linux/4.8.2/libstdc++.a -l
-rw-r--r-- 1 root root 29323266 Sep 30  2020 /usr/lib/gcc/x86_64-redhat-li_

2. 静态库

2.1 静态库的制作

为了理解静态库的制作过程,这里通过一个场景来逐步演进。假设已经编写了一些自定义的加减函数,希望将它们提供给他人使用。

为了清楚的演示,首先再当前目录下创建一个 Developer 目录,再次目录下存放开发的 .c.h 文件。

2.1.1 场景设定

有两个源文件 add.csub.c,以及对应的头文件 add.hsub.h

  • .h 文件:包含函数声明、类型定义和宏定义。
  • .c 文件:包含函数的具体实现。

add.c:

//add.c
#include "add.h"

int Add(int a, int b) { return a + b; }

sub.c:

//sub.c
#include "sub.h"

int Sub(int a, int b) { return a - b; }

add.h:

//add.h
#pragma once
#include <stdio.h>

extern int Add(int a, int b);

sub.h:

//sub.h
#pragma once
#include <stdio.h>

extern int Sub(int a, int b);
2.1.2 制作步骤

第一步:将源文件编译成目标文件(.o)

使用 gcc-c 选项,将每个 .c 文件只编译成目标文件,而不进行链接。

gcc -c add.c -o add.o
gcc -c sub.c -o sub.o

执行后,可以会得到 add.osub.o 两个二进制目标文件。

在这里插入图片描述

第二步:将目标文件打包成静态库(.a)

使用 ar(archiver)命令将所有目标文件打包成一个静态库。

# -r: 若静态库中已存在同名目标文件,则替换它;若不存在,则新增。
# -c: 若静态库不存在,则创建它。
ar -rc libcalc.a add.o sub.o

通过这条命令,将 add.osub.o 打包进了 libcalc.a 这个静态库文件中。现在,我们只需要向使用者提供 add.hsub.hlibcalc.a 这三个文件即可。

在这里插入图片描述

2.2 静态库的使用

当使用者拿到了头文件和静态库文件后,如何编译自己的程序呢?这需要 gcc 的特定链接选项。

为了便于演示,仍在当前目录下创建一个 User 目录,用于进行静态库的使用演示。

2.2.1 库的命名规范

在Linux中,静态库的命名遵循 lib<name>.a 的格式。例如,库文件名为 libcalc.a,那么这个库的“真实”名称就是 calc。链接器在查找库时,会自动加上 lib 前缀和 .a.so 后缀。

2.2.2 编译命令详解

User 目录下有使用者的使用文件 main.c 。要想给使用者使用开发者需要提供头文件和 libcalc.a 文件。

main.c

//mian.c
#include "add.h"
#include "sub.h"

int main() 
{
    int a = 20;
    int b = 10;
    int ret = Add(a, b);
    printf("%d + %d = %d\n", a, b, ret);

    ret = Sub(a, b);
    printf("%d - %d = %d\n", a, b, ret);

    return 0;
}

在这里插入图片描述

编译命令如下:

# -I. : 指定头文件的搜索路径为当前目录(I 是 Include 的缩写)。
# -L. : 指定库文件的搜索路径为当前目录(L 是 Library 的缩写)。
# -lcalc: 告诉链接器要链接名为 calc 的库(l 是 link 的缩写)。
gcc main.c -I. -L. -lcalc -o my_app
  • -I<path>:指定头文件的搜索路径。
  • -L<path>:指定库文件的搜索路径。
  • -l<name>:指定要链接的库的名称。

编译器在处理 -lcalc 时,会在 -L 指定的路径(以及系统默认路径)下查找 libcalc.a 文件,并将其中的目标文件链接到最终的可执行程序中。

在这里插入图片描述

2.3 静态库的安装和发布

每次编译都带着 -I-L 选项依然有些繁琐。为什么我们使用C标准库(如 printf)时,无需指定任何路径?原因是系统库已被“安装”到了编译器默认会查找的标准路径下。

2.3.1 “安装”的本质

所谓的“安装”库,本质上就是将库的头文件库文件分别拷贝到系统的标准目录中。

  • 头文件标准路径:通常是 /usr/include
  • 库文件标准路径:通常是 /lib64/usr/lib64 (64位系统)
2.3.2 安装后的编译

如果将 add.hsub.h 拷贝到 /usr/include,将 libcalc.a 拷贝到 /usr/lib64,那么编译命令就可以简化为:

# -I 和 -L 选项不再需要,因为编译器会自动搜索标准路径
gcc main.c -lcalc -o my_app

这样,用户自己制作的库就和系统库有了类似的“待遇”。但是因为 gcc 是编译C语言的专属编译器,他会自动调用C语言的标准库进行编译程序,如果需要使用用户自己定义的静态库,还是需要使用 -l 选项指明静态库的名称。

2.3.3 使用 Makefile 实现自动化

在实际项目中,基本会使用 Makefile 来自动化整个编译、打包、发布和清理的过程。一个典型的 Makefile 可能包含以下部分:

# Makefile for libcalc.a

libcalc.a: add.o sub.o
	@ar -rc $@ $^
	@echo "build $^ to $@ ... done"

%.o:%.c
	@gcc -c $<
	@echo "compiling $< to $@ ... done"

.PHONY:output
output:
	@mkdir -p calc_dist/include
	@mkdir -p calc_dist/lib
	@cp -f *.h calc_dist/include
	@cp -f *.a calc_dist/lib
	@tar -czf calc_dist.tgz calc_dist
	@echo "output calc_dist package ... done"

.PHONY:clean
clean:
	@rm -rf *.a *.o calc_dist*
	@echo "clean ... done"

补充:

  • argnu 归档工具rc 表示(replace and create)

  • 查看静态库信息:

    $ ar -tv libmystdio.a
    rw-rw-r--  1000/1000   2848 Oct 29 14:35 2024 add.o
    rw-rw-r--  1000/1000   1272 Oct 29 14:35 2024 sub.o
    

    t:列出静态库中的文件

    v: verbose:详细信息

这个 Makefile 定义了几个关键操作:

  • make:默认情况下,执行第一个目标 libcalc.a。它会自动寻找 add.csub.c,将它们编译成 add.osub.o,然后将这两个目标文件打包成 libcalc.a

    在这里插入图片描述

  • make output:这个命令用于生成一个可供发布的压缩包。它会创建一个 calc_dist 目录,并将头文件(.h)和库文件(.a)分别拷贝到 includelib 子目录中,最后将整个 calc_dist 目录打包成 calc_dist.tgz

    Developer:

    在这里插入图片描述

    User:

    ![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://img-home.csdnimg.cn/images/2

  • make clean:用于清理工作目录,删除所有生成的目标文件(.o)、库文件(.a)以及 output 命令生成的发布包。

3. 动态库

3.1 动态库的制作

这里动态库的讲解和静态库的讲解场景是一样的,还是在 Developer 目录下进行文件开发和动态库的制作,然后打包给 User 模拟动态库的发布和使用。

3.1.1 场景设定

这里的场景仍然使用和静态库的场景一致,仍然使用 有两个源文件 add.csub.c,以及对应的头文件 add.hsub.h 作为动态库的组成内容。

3.1.2 制作步骤
3.1.2.1 第一步:生成与位置无关的目标文件 (.o)

与静态库不同,生成用于动态库的目标文件时,必须添加 -fPIC 选项。

  • -fPIC (Position-Independent Code):该选项告诉编译器生成“与位置无关的代码”。这意味着编译出的代码在被加载到内存的任何地址时都能正确执行,这是动态库能够被多个进程共享的基础。的基础。
# 使用 -fPIC 选项将源文件编译成目标文件
gcc -fPIC -c add.c -o add.o
gcc -fPIC -c sub.c -o sub.o

在这里插入图片描述

3.1.2.2 第二步:打包目标文件为动态库 (.so)

静态库使用 ar 命令进行归档,而动态库的生成则直接由 gcc 完成,通过 -shared 选项。

  • -shared:该选项告诉 gcc,本次链接的目标是生成一个共享库(动态库),而不是一个可执行文件。
# 使用 -shared 选项将 .o 文件打包成 .so 文件
# 库名同样遵循 lib<name>.so 的规范
gcc -shared -o libcalc.so add.o sub.o

在这里插入图片描述

执行完毕后,就得到了动态库文件 libcalc.so。可以使用 file 命令来验证其类型:

$ file libcalc.so
libcalc.so: ELF 64-bit LSB shared object, ...

$ file libcalc.a
libcalc.a: current ar archive

可以看到,.so 文件被明确标识为 shared object(共享对象),而 .a 文件是 ar archive(归档文件)。

3.2 动态库的使用

3.2.1 编译链接

假设使用者拿到了 add.h, sub.hlibcalc.so,并编写了 main.c。编译命令依然是使用 -I, -L, -l 选项:

gcc main.c -I. -L. -lcalc -o my_app
  • -I./include: 指定头文件搜索路径。
  • -L./lib: 指定库文件搜索路径。
  • -lcalc: 链接名为 calc 的库(编译器会自动查找 libcalc.so)。

这条命令会成功执行,并生成可执行文件 my_app。至此,一切似乎都和静态库一样。

在这里插入图片描述

3.2.2 运行失败:问题的出现

当运行这个可执行程序的时候,问题就出现了:

$ ./my_app
./my_app: error while loading shared libraries: libcalc.so: cannot open shared object file: No such file or directory

系统报错,提示在加载共享库时找不到 libcalc.so 文件。

3.2.3 问题分析

这是动态库和静态库最核心的区别:

  • 静态链接:在编译链接时,静态库 (.a) 中的代码被完整地复制到最终的可执行文件中。一旦编译完成,可执行文件就不再需要原始的 .a 文件了,它是自包含的。
  • 动态链接:在编译链接时,动态库 (.so) 中的代码不会被复制到可执行文件中。链接器只会在可执行文件中记录一个标记,指明“该程序运行时需要依赖 libcalc.so 这个库”。

因此,-L-l 选项仅仅是告诉了编译器 (gcc) 在编译时去哪里寻找库文件以完成链接,但并没有告诉操作系统在程序运行时去哪里寻找这个依赖的库。当程序 ./my_app 开始执行时,操作系统(动态链接器)会根据自身的默认搜索路径去查找 libcalc.so,如果找不到,就会导致上述的加载错误。

这里可以使用 ldd (List Dynamic Dependencies) 命令来查看一个可执行程序依赖的动态库:

$ ldd my_app 
		linux-vdso.so.1 =>  (0x00007ffcf8124000)
		libcalc.so => not found
		libc.so.6 => /lib64/libc.so.6 (0x00007f3587f12000)
		/lib64/ld-linux-x86-64.so.2 (0x00007f35884e2000)

ldd 的输出明确显示,系统标准库 libc.so.6 能找到,而这里自己的 libcalc.sonot found

3.2.4 问题解决

要让操作系统在运行时能找到自己的动态库,有以下几种常见的解决方法:

3.2.4.1 方法一:安装库到系统标准目录(最直接)

将自定义的库文件拷贝到系统默认的库路径下(如 /lib4/usr/lib64)。

sudo cp ./libcalc.so /lib64

拷贝完成后,操作系统就能在默认路径下找到它,程序便可正常运行。这是软件通过包管理器(如 yum, apt)安装时最常用的方式。

在这里插入图片描述

3.2.4.2 方法二:在系统标准目录中创建软链接

如果不希望直接拷贝文件,也可以在系统库目录中创建一个指向实际库文件的软链接(Symbolic Link)。

sudo ln -s /home/tcq/linux-training-code/lesson19/User/libcalc.so /lib64/libcalc.so

这种方式同样能让系统找到库,且便于管理。

在这里插入图片描述

3.2.4.3 方法三:配置环境变量 LD_ARY_PATH(临时性)

系统在查找动态库时,除了默认路径,还会检查一个名为 LD_LIBRARY_PATH 环境变量。zheli1可以将库所在的目录添加到这个变量中。

# 将当前目录下的 lib 目录添加到环境变量中
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/tcq/linux-training-code/lesson19/User/libcalc.so

执行该命令后,在当前终端会话中,系统就能找到库,程序可以成功运行。

注意:这种方法是临时的,只在当前终端生效。关闭终端后,环境变量会失效。若要使其永久生效,需要将 export 命令写入 shell 的配置文件中(如 ~/.bashrc)。

3.2.4.4 方法四:配置系统链接存(推荐的永久方案)

Linux 提供了一套动态链接器的缓存配置机制。我们可以通过修改配置文件,让系统永久地记住用户自己的库路径。

  1. /etc/ld.so.conf.d/ 目录下创建一个新的配置文件(需要 sudo 权限),文件名可自定义,以 .conf 结尾。

    sudo vim /etc/ld.so.conf.d/my-custom-libs.conf
    
  2. 在该文件中,写入当前库文件所在的绝对路径

    /home/tcq/linux-training-code/lesson19/User/libcalc.so
    
  3. 保存文件后,执行 ldconfig 命令来更新系统的动态链接器缓存。

    sudo ldconfig
    

完成这三步后,系统就永久地知道了这个新的库搜索路径,程序可以随时正常运行。这种方法比修改环境变量更规范,是部署服务的常用方式。

3.3 动态库的 makefile 自动化处理

# Makefile for libcalc.so

libcalc.so: add.o sub.o
	@gcc -shared -o $@ $^
	@echo "build $^ to $@ ... done"

%.o:%.c
	@gcc -fPIC -c $<
	@echo "compiling $< to $@ ... done"

.PHONY:output
output:
	@mkdir -p calc_dist/include
	@mkdir -p calc_dist/lib
	@cp -f *.h calc_dist/include
	@cp -f *.so calc_dist/lib
	@tar -czf calc_dist.tgz calc_dist
	@echo "output calc_dist package ... done"

.PHONY:clean
clean:
	@rm -rf *.so *.o calc_dist*
	@echo "clean ... done"

这个 Makefile 的行为与您提供的静态库版本完全对应:

  • make: 会编译 add.csub.c 生成 add.osub.o(带有 -fPIC 选项),然后将它们链接成动态库 libcalc.so

    在这里插入图片描述

  • make output: 会创建一个 calc_dist 目录,并将头文件和生成的 libcalc.so 库文件打包成 calc_dist.tgz,方便分发。

    在这里插入图片描述

    calc_dist 中的结构如下:

    在这里插入图片描述

  • make clean: 会清除所有生成的目标文件 (.o)、库文件 (.so) 以及发布包。

4. 链接器的行为与选择

如果同一个目录下同时存在一个库的静态版 (libcalc.a) 和动态版 (libcalc.so),链接器会如何选择?

  • 默认行为:gcc 在链接时,会优先选择动态库进行链接。

  • 强制静态链接:如果你希望在动态库存在的情况下,仍然强制使用静态库,可以在编译命令中添加 -static 选项。

    # 即使 libcalc.so 存在,也会强制链接 libcalc.a
    gcc main.c -I. -L. -lcalc -static -o my_app_static
    
  • 依赖缺失:如果只存在动态库,但却使用了 -static 选项,链接器会因为找不到静态库而报错。

5. 目标文件

5.1 引入

在软件开发过程中,从源代码到可执行程序的转换通常涉及预处理、编译、汇编和链接四个阶段。尽管像Visual Studio这样的集成开发环境(IDE)在Windows平台下将这些步骤封装得非常完善,使得开发者可以一键构建,但这种便利性也隐藏了底层的复杂性。一旦遇到链接相关的错误,许多开发者便会感到束手无策。

为了更深刻地理解静态库与动态库的使用原理,我们需要深入探讨这个过程。从宏观上看,整个构建过程可以归纳为两个核心步骤:编译(Compilation)链接(Linking)

在这里插入图片描述

5.2 什么是目标文件

编译 的本质是将人类可读的源代码(如C/C++文件)翻译成计算机CPU能够直接理解和执行的机器代码。

在这个过程中,会生成一种关键的中间文件,称之为 目标文件(Object File)。在Linux环境下,它的扩展名通常是 .o;在Windows环境下,则对应为 .obj 文件。

目标文件的全称是 可重定位目标文件(Relocatable Object File)。这个名称暗示了它在后续的链接和程序加载过程中的重要角色。简单来说,它包含了已翻译好的机器代码和数据,但其中的地址引用(如函数调用、全局变量访问)都是相对的、待确定的,因此它可以在内存的任何位置被“重定位”,具体介绍会在后面介绍链接与加载的时候介绍。

这里通过一个简单的例子来直观感受一下。假设有两个源文件:

hello.c

// hello.c
#include <stdio.h>

void run(); // 声明在别处定义的函数

int main() 
{
    printf("hello world!\n");
    run();
    return 0;
}

code.c

// code.c
#include <stdio.h>

void run() 
{
    printf("running...\n");
}

这里可以使用 gcc 编译器的 -c 选项,将每个源文件 独立地 编译成目标文件,而不进行链接:

# -c 选项告诉gcc只编译,不链接
$ gcc -c hello.c
$ gcc -c code.c

# 查看生成的文件
$ ls
code.c  code.o  hello.c  hello.o

执行后,即得到了 hello.ocode.o 这两个目标文件。

在这里插入图片描述

5.3 为何需要独立编译成目标文件

可能会疑惑,为什么不直接将所有 .c 文件一次性编译链接成可执行文件,而是要多此一举先生成 .o 文件呢?

根本原因在于:为了在大型项目中实现高效的增量编译,以最小的成本重新构建代码。

  • 编译的独立性:编译单个源文件(如 hello.c 编译成 hello.o)的过程,通常与其他源文件无关。编译器只需要处理当前文件及其包含的头文件即可。
  • 链接的全局性:链接则是一个全局性的工作,它需要解析所有目标文件之间的符号引用(比如 hello.o 中对 run 函数的调用,需要在 code.o 中找到其定义),并将它们和所需的库文件组合成一个单一的可执行文件。

设想一个大型项目,例如操作系统内核或大型应用程序,它可能包含数万甚至数十万个源文件,代码量高达数百万乃至上千万行。

  • 如果直接编译链接:对任何一个源文件哪怕只做了一个微小的改动(比如增加一个空格),都将触发整个项目的完全重新编译和链接,这个过程可能需要数十分钟甚至数小时。
  • 如果采用分离编译:当只有一个源文件被修改时,只需要重新编译这一个被修改的文件,生成新的 .o 文件。然后,将这个新的 .o 文件与项目中其他 未曾改动过的、早已存在的 .o 文件 一起重新链接即可。链接过程远快于编译,从而极大地缩短了开发和调试的周期。

因此,将源文件各自编译成目标文件,是现代软件工程中管理复杂项目、提升构建效率的基石。

5.3 目标文件与库的关系

现在,可以将目标文件的概念与之前讨论的“库”联系起来。无论是静态库(.a 文件)还是动态库(.so 文件),它们的本质都是目标文件(.o 文件)的集合

  • 一个库文件,无非是库的开发者将他们的源文件编译成一系列 .o 文件后,通过特定工具打包而成的。
  • 因此,开发者常说的“链接一个库”,其实质就是“将用户自己代码的 .o 文件与库中所包含的一系列 .o 文件进行链接”。

这也解释了为什么库具有平台相关性。即使是同一份C语言源代码,库的作者也必须:

  1. 在Windows系统上编译,生成Windows平台可用的库文件(如 .lib, .dll)和 .obj
  2. 在Linux系统上编译,生成Linux平台可用的库文件(如 .a, .so)和 .o

开发者在不同平台上使用时,需要安装对应平台的二进制库文件。但是无论是库还是目标文件其本质都是 EIF 文件

在这里插入图片描述

6. EIF 文件

通过前面的讨论,已经明确了一个核心观点:无论是C/C++源代码编译后生成的 可重定位目标文件(.o,还是链接时用到的 共享目标文件(.so,即动态库),亦或是最终产生的 可执行程序,它们在Linux系统下都遵循一种统一的二进制文件格式——ELF (Executable and Linkable Format)

6.1 EIF 文件分类

其实有以下四种文件其实是ELF文件:

  • 可重定位文件 (Relocatable File):即 xxx.o 文件。包含适合与其他目标文件链接来创建可执行文件或共享目标文件的代码和数据。

  • 可执行文件 (Executable File):即可执行程序。

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

  • 内核转储 (core dumps):存储当前进程的执行上下文,用于 dump 信号触发。

6.2 EIF 文件基本结构

一个ELF格式的文件,其内容可以被划分为不同的区域,用于存放不同类型的信息。宏观上,一个完整的可执行ELF文件主要包含以下几个部分:

  1. ELF Header (ELF头):位于文件的最开始,它描述了整个ELF文件的核心属性。这包括文件的类型(是可重定位文件、可执行文件还是共享文件)、目标硬件架构(如x86-64)、程序入口地址等元数据。

  2. Program Header Table (程序头表):这个表对可执行文件共享文件来说至关重要。它从“程序执行”的角度描述了文件。它告诉系统如何将文件的各个部分加载到内存中,并形成一个进程的虚拟地址空间。后面会详细讨论它与“段”(Segment)的关系。

  3. Section Header Table (节头表):这个表则从“链接”的角度描述文件。它包含对“节”(Section)的描述,例如节的名称、大小、在文件中的偏移量等。链接器在处理.o文件时,主要就是依赖这张表来工作。

  4. Sections (节):这是ELF文件中最基本的内容单元。文件的主体由一系列的“节”组成,每个节都存放着特定类型的信息,例如机器指令、已初始化的全局变量、只读数据等据等。

    常见的节:

    • **代码节(.text):**用于保存机器指令,是程序主要执行的部分。
    • **数据节(.data):**保存已初始化的全局变量和局部静态变量。
    • .bss: 为程序中未初始化的全局变量静态变量预留空间。这个节在文件中并不占用实际大小,但在加载到内存时会被分配空间并清零。
    • .rodata: 存放只读数据,例如字符串常量 ("hello world")。

在这里插入图片描述

6.3 EIF 从形成到加载轮廓(宏观介绍)

6.3.1 EIF 形成可执行文件
  • step-1: 将多份 C/C++ 源代码,翻译成目标 .o 文件
  • step-2: 将多份 .o 文件和动静态库 section 进行合并(链接)

上面的第二步其实本质就是链接操作,这里在理解了“节”是ELF文件的基本构成单元后从宏观视角理解链接,对“链接”过程的认知就可以提升到一个新的高度。

链接的本质,就是将多个输入ELF文件(.o文件、库文件)中具有相同属性的节进行合并,最终形成一个输出F文件(可执行程序)的过程。

例如,链接器在工作时:

  • 合并代码节:它会收集所有输入文件中的 .text 节(代码节),并将它们合并成一个单一的、更大的 .text 节,存放在最终的可执行文件中。
  • 合并数据节:同样,它会合并所有输入文件中的 .data 节,形成一个新的 .data 节。
  • **合并其他节:**这个过程会针对所有类型的节进行。

在这里插入图片描述

6.3.2 EIF 可执行文件加载
6.3.2.1 加载的结构基础:节(Section)合并为段(Segment)的机制

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

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

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

    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,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。

6.3.2.2 使用 readelf 观察加载布局

可以通过 readelf 工具,从两个不同的视角来审视一个可执行文件,从而清晰地理解 Section 和 Segment 的关系。

6.3.2.2.1 视角一:链接器视角 (Section Header Table)

使用 readelf -S <executable> 命令可以查看文件的节头表(Section Header Table),它展示了文件包含的所有 Section。了文件包含的所有 Section。

$ readelf -S a.out
There are 30 section headers, starting at offset 0x1998:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  ...
  [14] .text             PROGBITS         0000000000004810  00004810
       0001a145          00               AX       0     0     16
  [15] .rodata           PROGBITS         000000000001e960  0001e960
       00006212          00               A        0     0     32
  ...
  [24] .data             PROGBITS         0000000000027540  00027540
       00001220          00               WA       0     0     32
  [25] .bss              NOBITS           0000000000028760  00028760
       00000958          00               WA       0     0     32
  ...

节头表的认识:

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

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

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

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

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

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

Flag:

  • Flags 列中的 A 代表 ALLOC(需要加载到内存),W 代表 WRITEABLE(可写),X 代表 EXECUTABLE(可执行)。
  • .text 节是 AX(可执行),.data.bss 节是 WA(可写)。

Symbol Table:

.bss 节的优化:(重点!!)

.bss节存储的是未初始化的全局变量或静态变量。与已经初始化的全局变量(通常存储在.data节)不同,这些变量在程序编译时并没有预先设定值。它们的默认值通常为零

  • Type 的值为 NOBITS,意味着该节没有实际的数据内容。它不包含任何具体的字节值,只是在链接时记录了这些变量所需的内存空间大小。NOBITS 表明在生成的可执行文件中,该节不会占用实际的磁盘空间,只是描述了这些变量应该占用多少内存。

  • NOBITS节的存在:虽然这些变量在源代码中已经声明,但它们未初始化,因此在磁盘文件中不会实际存储它们的值。只有当程序在运行时,操作系统会在内存中为它们分配空间,并将其初始化为零。

  • Size 代表 .bss 节中所有未初始化变量所需的空间大小。比如,如果程序中声明了 100 个整型变量,每个变量占 4 字节,那么 .bss 节的 Size 就会是 400 字节。这个大小在ELF文件中被记录,以便操作系统在加载时为这些变量分配内存。

  • 由于 .bss 节是 NOBITS 类型的,它在磁盘文件中并不占用实际空间。链接器只是记录了这些变量需要的内存大小,而不会将它们的默认值(通常为0)存储在文件中,从而节省了磁盘空间。

内存加载时的操作:

  • 加载时,操作系统会分配内存并将其清零:当程序加载到内存中时,操作系统会为 .bss 节分配足够的内存,并将其全部初始化为零。这使得即使程序中的这些未初始化的全局变量在磁盘文件中没有实际占用空间,程序在运行时仍然能够正确地访问它们。
6.3.2.2.2 视角二:加载器视角 (Program Header Table)

使用 readelf - <executable> 命令可以查看文件的程序头表(Program Header Table),它告诉加载器如何将 Section 合并成 Segment 并加载到内存。

$ readelf -l a.out

Elf file type is EXEC (Executable file)
Entry point 0x400440
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
                 0x00000000000007f4 0x00000000000007f4  R E    200000
  LOAD           0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
                 0x0000000000000224 0x0000000000000228  RW     200000
  DYNAMIC        0x0000000000000e28 0x0000000000600e28 0x0000000000600e28
                 0x00000000000001d0 0x00000000000001d0  RW     8
  NOTE           0x0000000000000254 0x0000000000400254 0x0000000000400254
                 0x0000000000000044 0x0000000000000044  R      4
  GNU_EH_FRAME   0x000000000000067c 0x000000000040067c 0x000000000040067c
                 0x0000000000000044 0x0000000000000044  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 .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 

分析:

  • TypeLOAD 的段: 这些是需要从文件加载到内存的段。

  • Flags:R (Read), W (Write), E (Execute) 定义了该内存段的访问权限。

    • 第一个 LOAD 段的权限是 R E (可读、可执行),对应代码段。

    • 第二个 LOAD 段的权限是 RW (可读、可写),对应数据段。

  • Section to Segment mapping 这部分清晰地展示了哪些 Section 被打包进了哪个 Segment。可以看到,.text.rodata 等只读节被映射到了 0号段(可读、可执行),而.data.bss等可写节被映射到了03 号段(可读、可写)。

6.3.2.2.3 总结:

对于 程序头表节头表 又有什么用呢,根据上文 ELF 文件提供了 2 个不同的视图/视角来理解这两个部分:

  • 链接视图 (Linking view) - 对应节头表 (Section header table)
    • 文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解 ELF 文件中包含的各个部分的信息。
    • 为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,整理成执行的段(segment)、可读的段、只读段等。合并后,空间利用率就高了,否则,很小的很小的一段,未加处理内存浪费巨大(物理内存页分配一般是整数倍一块给你,比如 4k),所以,链接器趋向链接就把小块们都合并了。
  • 执行视图 (Execution view) - 对应程序头表 (Program header table)
    • 告诉操作系统,如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中,一定有 program header table
  • 说白了就是:一个在链接时作用,一个在运行时加载时作用

在这里插入图片描述

6.3.2.3 操作系统与加载文件

操作系统在加载一个文件前,首先需要识别它是什么类型的文件。ELF 文件的“身份证”就是位于文件最开始的 ELF 头(ELF Header)。可以使用 readelf -h <executable> 看。

$ 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
  ...
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Entry point address:               0x56a0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          159576 (bytes into file)
  ...

分析:

  • Magic Number (魔数): 文件的前 16 个字节是魔数。其中前四个字节固定为 0x7FELF (45 4c 46)。操作系统通过检查这个魔数,就能迅速确认这是一个 ELF 文件,并调用相应的加载器来处理它。
  • Entry point address (入口点地址): 这是整个 ELF 文件中至关重要的信息之一。它告诉操作系统,当文件被成功加载到内存后,应该从哪个内存地址开始执行第一条指令。
  • 其他关键信息: ELF 头还包含了程序头表(Program Headers)和节头表(Section Headers)在文件中的起始位置、大小、数量等元信息。加载器依赖这些信息来解析文件的整体结构

7. 理解链接与加载(详细介绍)

上面从宏观简单介绍了 ELF 文件的基本结构,和其构成可执行文件的步骤,以及它是如何被加载器映射到内存的。本节将针对构成可执行文件也就是链接和其加载到内存中的知识做详细介绍。

7.1 静态链接

这里首先先深入目标文件和静态库链接形成可执行文件的静态链接过程。

静态链接的本质,可以理解为将一堆各自独立的目标文件 (.o) “拼接”成一个完整、独立的可执行文件的过程。这其中既包括用户自己代码编译成的 .o 文件,也包括从静态库 (.a) 中解压出来的 .o 文件。因此,理解了多个 .o 文件如何合并,就抓住了静态链接的核心。

7.1.1 问题的起点:独立编译与未定义符号

假设这里有两个源文件:hello.ccode.c

  • hello.c: 包含 main 函数,它调用了标准库的 printf 函数和另一个模块中定义的 run 函数。

    // hello.c
    #include <stdio.h>
    
    void run(); // 声明在别处定义的函数
    
    int main() 
    {
        printf("hello world!\n");
        run();
        return 0;
    }
    
  • code.c: 负责实现 run 函数,并且它内部也调用了内部也调用了 printf 函数。

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

下面将这两个文件独立编译成目标文件:

gcc -c hello.c -o hello.o
gcc -c code.c -o code.o

现在得到了两个 ELF 格式的目标文件:hello.ocode.o。它们是“可重定位目标文件”,这意味着它们虽然包含了编译好的机器码,但还不完整,无法独立运行

为什么不完整? 可以通过反汇编工具 objdump 来一探究竟。

# -d 选项表示反汇编代码段
objdump -d hello.o

在输出的汇编代码中,会看到类似这样的 main 函数:

在这里插入图片描述

这里的关键在于 callq 指令(函数调用指令)。e8call 指令对应的机器码,其后跟随的 00 00 00 00目标函数的地址

可以发现,无论是调用 printf 还是 run,其目标地址都是 全零。这是因为在编译 hello.c 时,编译器只知道需要调用这两个函数,但它完全不知道 run 函数(在 code.o 中)和 printf 函数(在C标准库中)的最终内存地址。因此,编译器只能在 call 指令后面填充一个临时的、无效的地址(0),等待链接器来修正。

7.1.2 链接器的核心任务:符号解析与地址重定位

将这两个 .o 文件链接成一个可执行程序时,链接器开始工作,它的核心任务分为两步。

# 将 hello.o 和 code.o 链接成可执行文件 my_app
gcc hello.o code.o -o my_app
7.1.2.1 第一步:符号解析

每个目标文件都有一个符号表 (Symbol Table),记录了该文件能提供给其他文件的符号(如函数名、全局变量名)和它需要从其他文件引用的未定义符号。可以用 readelf -s 查看。

  • readelf -s hello.o: 它的符号表会显示 main 函数是已定义的全局符号,而 runprintf 都是是 未定义 (UND) 的符号。

    在这里插入图片描述

  • readelf -s code.o: 它的符号表会显示 run 函数是已定义的全局符号,而 printf(puts)未定义 (UND) 的符号。

    在这里插入图片描述

链接器的工作就像一个拼图大师。它收集所有 .o 文件(包括从静态库 libc.a 中提取的 printf 对应的 .o 文件),然后遍历它们的符号表:

  1. 它看到 hello.o 需要 run,就在 code.o 中找到了 run 的定义。
  2. 它看到 hello.ocode.o 都需要 printf,就在 C 标准库的 .o 文件中找到了 printf 的定义。

通过这个过程,所有模块间的引用关系都被建立起来,链接器确认了每一个符号引用都能找到其唯一的定义。

7.1.2.2 地址重定位 (Address Relocation)

在符号解析完成后,链接器会执行另一个关键步骤:合并和重定位

  1. 合并 Sections: 链接器将所有输入 .o 文件中类型相同的节(Section)合并。例如,所有文件的 .text(代码)节被合并成一个大的 .text 节,所有 .data(数据)节合并成一个大的 .data 节。
  2. 确定最终地址: 在合并之后,程序在虚拟地址空间中的布局就确定了。链接器现在知道了每个函数、每个全局变量的最终地址
  3. 修正地址 (Relocation): 链接器回到合并后的代码节,找到所有之前填充为 00 00 00 00 的地址占位符。它将这些占位符替换为在第二步中确定的真实函数地址。这个“填空”的过程,就叫做地址重定位

现在,如果反汇编最终生成的可执行文件 my_app

在这里插入图片描述

可以看到,main 函数中的 call 指令后面,已经不再是全零,而是具体的、有意义的内存地址了。这样也就完成静态链接形成执行程序的过程

并且正是因为目标文件 (.o) 内部的地址是“待定”的,需要被链接器“重新定位”,所以它才被称为可重定位目标文件 (Relocatable Object File)。一旦静态链接完成,生成的可执行文件就包含了所有需要运行的代码和数据,不再依赖于原始的 .o 文件或静态库,可以独立运行。

在这里插入图片描述

7.2 EIF 文件的加载和形成进程地址空间的过程

在理解了静态链接如何将多个目标文件 (.o) “拼接”成一个可执行文件后,紧接着需要思考的是这个静静躺在磁盘上的 ELF 文件,是如何被加载到内存,并最终幻化成一个活跃的进程的?

这个过程是操作系统、编译器和 CPU 协同工作的典范。要彻底理解它,必须首先理清几个核心的“地址”概念,并重新审视我们对“进程地址空间”的认知。

7.2.1 地址的“三位一体”:逻辑、虚拟与物理

在探讨加载之前,必须先回答一个根本性问题:一个尚未加载到内存的可执行文件,它内部有“地址”吗?

答案是:。这个地址,就是理解整个加载过程的钥匙。

7.2.1.1 逻辑地址与绝对编址

相对地址:

在早期的编程模型中,每个模块(.o 文件)在编译时,都是编译器单独处理单个源文件(如 hello.c)并生成目标文件(hello.o),它是在一个独立的世界里工作的。在这个 hello.o 文件内部,main 函数可能位于地址 0x20,这意味着它距离 hello.o 文件内容的开头有32个字节。同样,在另一个目标文件 code.o 中,run 函数可能位于地址 0x50,距离 code.o 文件内容的开头有80个字节。

这些地址被称为相对地址(Relative Addresses)偏移量(Offsets)。它们的意义完全依赖于其所在的特定文件,离开了这个文件,这个地址就失去了意义。

绝对编址:

现在,链接器需要将 hello.ocode.o 以及其他所有目标文件合并成一个单一、完整的可执行程序。这时,“地址 0x80”就变得模棱两可——它究竟是距离整个新程序的开头80字节,还是距离原先 code.o 内容在新程序中被放置的位置80字节?

为了解决这个根本性的歧义,链接器引入了一套全新的、统一的规划方案。这个过程可以分为两步:

  • 合并与布局: 链接器首先将所有目标文件中相同类型的部分(例如,所有的代码 .text 部分、所有的数据 .data 部分)合并在一起,形成一个巨大的、连续的代码区和数据区。它为最终的程序设计了一个单一、完整的内存布局蓝图。
  • 绝对编址(Absolute Addressing): 在这个统一的蓝图上,链接器会从一个共同的起点(在概念上可以视为地址 0)开始,为合并后的每一条指令、每一个变量重新分配一个唯一的、明确的地址。这个新地址不再是相对于某个小文件的偏移量,而是相对于整个程序起点的绝对位置。例如,run 函数的地址可能被重新计算为 0x40114f

这种将所有代码和数据都放在一个单一、连续、无层次的地址空间中的模型,就是所谓的平坦模式(Flat Model)。这种编址方式叫做绝对编址

磁盘文件中的“逻辑地址”

链接器进行“绝对编址”的过程,其本质就是在进行链接,其产物就是最终将要保存在磁盘上的那个可执行文件的二进制内容和结构

换句话说,这种在磁盘文件上,由编译器和链接器“规划”好的地址,称之为逻辑地址(Logical Address)

7.2.1.2 虚拟地址:编译时就已注定

现在,揭示一个至关重要的联系:在 Linux 系统中,编译时生成的逻辑地址,在进程运行时,就是其使用的虚拟地址(Virtual Address)。它们是同一个地址空间概念在不同阶段的两种称呼。

  • 逻辑地址 是这个地址空间在**静态文件(磁盘上)**中的名字。它是链接器为程序精心绘制的“建筑蓝图”,详细规定了每一段代码、每一个变量应该在哪个“房间”(地址)。

  • 虚拟地址 是这个地址空间在动态进程(内存中)中的名字。它是操作系统完全遵照这份“建筑蓝图”,为进程构建起来的“虚拟房屋”。

    操作系统在加载程序时,完全信任并采纳了链接器已经完成的编址工作。它会直接读取 ELF 文件中的逻辑地址布局(记录在 Program Header Table 中),并原封不动地用它来设定进程虚拟地址空间的结构(即初始化内核中的 vm_area_struct 等结构)

换言之,虚拟地址空间并非在程序运行时才凭空创建,它的蓝图在编译链接阶段就已经被绘制好了。编译器以“虚拟地址”的视角来编排整个程序。之前通过 objdump 反汇编看到的那些地址,正是这些已经确定下来的虚拟地址。

在这里插入图片描述

这些地址就是程序运行时 CPU 将会使用的虚拟地址。这套地址体系从编译时诞生,无缝衔接地在运行时被操作系统和 CPU 继续使用,贯穿了程序的整个生命周期。

7.2.1.3 物理地址:运行时才被分配

当程序的数据和代码被实际加载到计算机的物理内存(RAM)中时,它们会占据一个个具体的内存槽位。这些槽位在物理内存条上的真实地址,就是物理地址(Physical Address)。这个地址是在运行时由操作系统决定的,每次运行都可能不同。

在这里插入图片描述

总结一下它们的关系:

  • 逻辑地址/虚拟地址:由编译器和链接器在生成可执行文件时确定。它是线性的、从 0 开始的,是程序内部视角和 CPU 指令使用的地址。在 Linux 中,这两个概念可以视为同义词。
  • 物理地址:由操作系统在程序运行时动态分配。它是代码和数据在物理内存中的最终落脚点。
程序代码中的相对地址(偏移量)
    │
    ▼
逻辑地址(段选择符:偏移量) → 分段机制 → 线性地址 → 分页机制 → 物理地址
    │                                │                   │
    │                                ▼                   ▼
    └─── 虚拟地址(进程视角)───────┘               绝对地址(硬件固定地址)
7.2.2 虚拟地址空间的初始化机制

有了上述概念,现在可以更深刻地理解操作系统是如何为进程创建虚拟地址空间的。

已经知道,内核使用 mm_struct 结构体来描述一个进程的地址空间,其中包含了一系列 vm_area_struct 结构体,用以划分代码区、数据区、堆、栈等。

那么,当进程被创建时,这些 vm_area_struct 的起始(start)和结束(end)地址从何而来?

答案就在 ELF 文件的程序头表(Program Header Table) 中。

操作系统加载器会读取 ELF 文件的程序头表,找到所有类型为 LOAD 的段(Segment)。每个段的头信息中都明确记录了它应该被加载到的虚拟地址(VirtAddr 和它在内存中应占的大小(MemSiz和文件的访问权限(Flags)。操作系统在读取这些字段后,执行以下操作:

  • 创建内存管理结构体(vm_area_struct),包含 vm_startvm_endvm_flags 字段。
  • segment 的起始地址初始化的 vm_start 值,用 segment 的结束地址初始化 vm_end 值。vm_flags 字段根据 Flags 被设置为对应的 VM_READ, VM_WRITE, VM_EXEC 权限。
  • 最后填写完毕的 vm_area_struct 会被链接到进程的 mm_struct 结构中,成为其内存版图的一部分。

加载器重复上面的步骤,直到处理完所有 LOAD 段。至此,进程的虚拟地址空间就完全建立起来了。

最终,内核层面构建出的这个虚拟地址空间,并非操作系统的即兴创作,而是对 ELF 文件中由链接器预设好的布局蓝图的一次完美复刻。 这确保了从编译器、链接器到操作系统、CPU,所有参与方都共享同一套地址空间的“世界观”,使得程序能够无缝地从静态文件过渡到动态进程。

7.2.3 EIF 可执行文件的加载

现在,可以将所有碎片化的知识串联起来,完整地描绘出程序运行的全过程。

  1. 定位文件 (文件系统):当用户在 shell 中输入 ./my_app 并回车时,操作系统通过文件系统进行路径解析,找到该文件在磁盘上的 inode 和数据块位置。
  2. 创建核心结构 (进程管理):内核创建一个新的进程描述符(PCB, task_struct)和一个地址空间描述符 (mm_struct)。
  3. 构建虚拟地址空间蓝图 (内存管理)
    • 加载器读取 my_appELF Header,确认文件类型并找到 Program Header Table 的位置。
    • 加载器遍历程序头表,根据每个 LOAD 段的虚拟地址和大小,在 mm_struct 中创建并初始化对应的 vm_area_struct(代码区、数据区等)。
  4. 建立映射关系 (内存管理)
    • 内核为新进程创建一套页表(Page Table)。
    • 操作系统将 vm_area_struct 中定义的虚拟地址范围与磁盘上 ELF 文件内对应段的物理位置建立起映射关系,并填充到页表中。此时,代码和数据尚未被真正拷贝到物理内存,但这层映射关系已经建立。
  5. 启动执行 (CPU)
    • 加载器从 ELF Header 中读取一个至关重要的地址——入口点地址(Entry Point Address)。这是一个虚拟地址
    • 操作系统将这个入口虚拟地址加载到 CPU 的指令指针寄存器(EIP/RIP) 中。
    • 进程被调度,CPU 开始工作。
  6. CPU 与 MMU 的协同
    • CPU 从指令指针寄存器中取出地址(例如,入口地址 0x400430),准备取指令。
    • CPU 将这个虚拟地址交给内存管理单元(MMU)
    • MMU 根据内核设置好的页表,进行地址转换。如果发现该虚拟地址对应的数据还不在物理内存中,就会触发一个缺页异常
    • 操作系统捕获此异常,从磁盘中加载那一页(4KB)的代码到物理内存的某个空闲帧,然后更新页表,将虚拟地址映射到这个新分配的物理地址。
    • MMU 完成地址转换,将物理地址发送到内存总线。
    • 指令从物理内存中被取回到 CPU。
  7. 循环往复
    • CPU 执行这条指令。如果指令中包含其他地址(例如 call 一个函数,或访问一个全局变量),这些地址同样是虚拟地址
    • CPU 会再次将这些虚拟地址交给 MMU 进行转换,重复第 6 步。

在这里插入图片描述

这个过程周而复始,进程就在 CPU、MMU 和操作系统的精密协作下,一句句地执行下去,完成了从磁盘上的静态文件到内存中动态进程的转变。

7.3 动态链接与动态库加载

上面已经深入探讨了静态链接的“拼接”过程以及一个独立的 ELF 可执行文件是如何被加载并形成进程地址空间的。现在,可以将注意力转向现代软件开发中更为普遍的模式:动态链接

一个关键的区别在于,静态库在链接后已成为可执行文件的一部分,因此它不涉及独立的“加载”问题(加载可执行文件就等于加载了静态库的代码)。然而,动态库 (.so 文件) 是一个独立于主程序的实体,这就引出了一系列新的问题:运行中的进程是如何“看到”并使用这些独立的库文件的?它们又是如何实现“共享”的?

7.3.1 单个进程如何“看见”动态库

想象一下,一个进程的虚拟地址空间是它能够感知到的所有数据。任何代码或数据,如果想被这个进程访问,就必须先被纳入这个进程的虚拟地址空间之中。

  1. 主程序加载:当一个依赖动态库的可执行程序启动时,操作系统首先像往常一样加载主程序。它会为进程创建 PCB、mm_struct 地址空间,并根据主程序 ELF 文件的程序头表(Program Header Table)来初始化代码区、数据区等,并建立初始的页表映射。

  2. 动态库的加载与映射:当主程序的代码执行到需要调用动态库中的函数时(例如 printf),操作系统发现这个函数并不在主程序自己的代码区内。此时,动态链接器(Dynamic Linker)介入,执行以下操作:

    • 定位并加载:它会根据设定的规则(我们之前讨论过的 LD_LIBRARY_PATH 等)在磁盘上找到对应的动态库文件(如 libc.so.6)。然后,它将这个库文件的代码和数据加载到一块物理内存中。

    • 纳入进程地址空间:为了让当前进程能够使用这块物理内存中的库代码,操作系统必须更新该进程的页表。它会在进程的虚拟地址空间中分配一片新的、尚未使用的区域(通常称为共享区,如下图),然后建立新的页表项(下图蓝色页表即为新表项),将这片虚拟地址映射到刚刚加载了库到刚刚加载了库文件的那块物理内存上。

    通过这个“映射”操作,动态库就被成功地纳入了进程的虚拟地址空间。从进程的角度看,它现在有了一片新的内存区域,里面存放着库函数的代码。

  3. 函数调用:一旦映射完成,原先的函数调用就变得非常直观。当主程序的代码区需要调用一个库函数时,它会在自身虚拟地址空间的跳转——从代码区的当前虚拟地址,跳转到共享库区的指定函数的虚拟地址去执行代码。执行完毕后,再跳转回当前的虚拟地址,继续执行程序。

在这里插入图片描述

7.3.2多进程间如何共享库

动态库最强大的特性,也是其被称为“共享库”(Shared Library)的根本原因,在于它在多进程环境下的内存效率。

假设现在有两个进程,进程 A 和进程 B,它们都需要使用 C 标准库。

  • 物理内存只加载一次:操作系统会将 C 标准库的代码段加载到物理内存中,且仅此一份
  • 各自映射,共享实体
    • 进程 A 的页表会被修改,将其虚拟地址空间中的共享库区映射到这份唯一的物理内存上。
    • 进程 B 的页表也同样被修改,将其虚拟地址空间中的共享库区也映射到份物理内存上。

多个进程共享同一个动态库,并且都有各自独立的虚拟地址空间,但是它们均“认为”自己独占了整个动态库,但在物理层面,它们指向并执行的是完全相同的机器指令

在这里插入图片描述

共享库的实现逻辑和优势

这种高效的共享机制得以实现,关键在于代码是**只读(Read-Only)**的。因为所有进程都只能读取和执行库代码,而不能修改它,所以不存在一个进程的错误操作会污染其他进程所依赖的库代码的风险。这保证了共享的安全性。

相比之下,如果采用静态链接,每个进程的内存中都会有一份独立、完整的 C 库代码副本。如果系统中有 20 个 C 程序在运行,物理内存中就会存在 20 份 printf 函数的机器码,造成巨大的内存浪费。而动态链接通过共享机制,将这份开销缩减为一份,极大地节省了宝贵的物理内存资源。

7.3.3 动态链接

我们已经理解了动态库是如何通过地址空间映射被多个进程共享的,这揭示了其在内存效率上的巨大优势。现在,我们需要从一个更高的维度来理解动态链接(Dynamic Linking)本身的工作机制。它与静态链接最根本的区别在于,它将“链接”这一动作的发生时间,从编译时推迟到了程序运行时

7.3.3.1 基本概要
7.3.3.1.1 为什么需要动态链接?——静态链接的局限性

在深入探讨动态链接之前,有必要再次回顾静态链接的固有缺陷,这也正是动态链接所要解决的问题:

  1. 空间浪费:静态链接会将所有用到的库代码完整地复制到最终的可执行文件中。如果系统中有多个程序都静态链接了同一个库(例如 C 标准库),那么磁盘上就会存储多份该库代码的副本,造成显著的磁盘空间浪费。
  2. 内存冗余:当这些静态链接的程序同时运行时,每个进程的内存中都会加载一份独立的库代码副本。这同样会导致物理内存资源的巨大浪费,尤其是在现代操作系统中,几乎所有程序都依赖于像 C 标准库这样的通用库。

动态链接的出现,正是为了将这些公共的、可共享的代码部分从各个程序中剥离出来,实现资源的最大化复用。

7.3.3.1.2 动态链接的核心思想:推迟链接

动态链接的本质,可以概括为一句话:将大部分链接工作从编译时推迟到程序加载时(Load-time)甚至运行时(Run-time)进行。

这个过程大致如下:

  1. 编译阶段的“标记”:在编译一个依赖动态库的程序时,链接器并不会将库代码复制进来。取而代之的是,它会在生成的可执行文件中仅仅记录一个“标记”,指明 “我这个程序在运行时需要依赖某某动态库(例如 libc.so.6)”。我们可以通过 ldd 命令查看到这些依赖关系。

    ldd my_app 
    	linux-vdso.so.1 =>  (0x00007ffcea194000)
    	libc.so.6 => /lib64/libc.so.6 (0x00007fbbab909000)
    	/lib64/ld-linux-x86-64.so.2 (0x00007fbbabcd7000)
    
  2. 加载阶段的“真正链接”:当用户执行这个程序时,操作系统的加载器(Loader)和动态链接器(Dynamic Linker)开始工作:

    • 加载实体:加载器首先将主程序的可执行文件加载到内存。然后,它会检查程序中记录的依赖标记,找到所有需要的动态库文件,并将它们也加载到内存中
    • 地址确定:与主程序一样,每个动态库被加载到内存的具体地址(物理地址和虚拟地址)都是在此时由操作系统根据当前内存使用情况动态分配的,并非事先固定
    • 运行时重定位与链接:一旦主程序和所有它依赖的动态库在内存中的位置都已确定,动态链接器就会执行最后的链接步骤。它会修正主程序代码中所有调用库函数的位置(这些位置在编译时只是占位符),将它们指向动态库函数在内存中的真实地址

这个在程序加载时才最终完成符号解析和地址重定位的过程,就是动态链接的核心。

7.3.3.2 运行前的“隐形”准备工作

上面已经了解了一个可执行文件如何被加载到内存,以及动态库如何被映射到进程的地址空间。然而,在编写的 main 函数获得控制权,打印出第一行 “Hello, World!” 之前,操作系统和 C 运行时库已经为用户完成了一系列至关重要的“幕后”准备工作。

一个普遍的误解是,程序的执行始于 main 函数。但事实并非如此。main 函数只是与 C/C++ 运行时环境约定的一个用户代码入口,真正的程序第一行指令位于一个由链接器和 C 运行时库提供的、名为 _start 的特殊函数中。

7.3.3.2.1 第一步:ld-linux.so:不可或缺的动态链接器

在深入 _start 之前,必须先认识一个几乎所有动态链接的 Linux 程序都依赖的动态库:ld-linux-x86-64.so.2 (在64位系统上)。

通过 ldd 命令查看任何一个普通的动态链接程序,会发现它作为第一个依赖项存在。它并非一个普通的动态库,而是程序解释器(Program Interpreter),也被称为动态链接器(Dynamic Linker)加载器(Loader)

当内核加载一个可执行文件后,它并不会立即跳转到程序的入口点,而是会检查 ELF 头中指定的程序解释器。如果存在,内核会首先加载并运行这个解释器,并将主程序的控制权完全交给它。接下来的一切初始化工作,都由 ld-linux.so 来主导完成。

$ ldd /usr/bin/ls
		linux-vdso.so.1 => (0x00007ffffd85f000)
		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 (0x00007f42bfffd7000)
		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)

$ ldd main.exe
		linux-vdso.so.1 => (0x00007fff231d6000)
		libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f19f7ec3b000)
		/lib64/ld-linux-x86-64.so.2 (0x00007f197e3e0000)
7.3.3.2.2 第二步:_start 函数:真正的程序入口

_start 函数是 C 运行时库(Glibc)的一部分,它被链接器设定为可执行文件的正式入口点。当动态链接器 ld-linux.so 完成了最核心的准备工作后,便会跳转到 _start 函数开始执行。_start 的职责是建立一个C语言程序能够正常运行所必需的完整环境。

其执行流程可以概括为以下几个关键步骤:

  1. 调用动态链接器进行工作_start 函数的首要任务之一,就是调用 ld-linux.so(动态连接器) 提供的功能来处理动态链接。这个过程包括:
    • 解析依赖:读取可执行文件中记录的所有依赖库列表(例如 libc.so.6)。
    • 搜索与加载:根据系统预设的规则(如环境变量 LD_LIBRARY_PATH、配置文件 /etc/ld.so.conf 及其缓存 /etc/ld.so.cache、以及默认的 /lib, /usr/lib 等路径)在文件系统中查找并加载所有需要的动态库到内存中。
    • 符号解析与重定位:这是动态链接的核心。链接器会检查主程序中所有对外部库函数的调用(这些调用在编译时地址是未定的占位符),并将其修正为这些库函数在内存中的真实地址。确保程序中的函数调用和变量访问能够访问正确地映射到动态库中的实际地址。
  2. 环境初始化:在库加载和链接完成后,_start 会进行一系列环境设置,例如:
    • 设置堆栈:为程序的主线程准备好初始的栈环境。
    • 初始化数据段:处理 .data.bss 段,将已初始化的全局变量从文件中复制到内存,并将未初始化的全局变量清零。
7.3.3.2.3 第三步:从 __libc_start_mainmain

在完成所有准备工作后,_start 并不会直接调用 main 函数。它会调用另一个名为 __libc_start_main 的 Glibc 内部函数。这个函数是 main 函数的“直接监护人”,它负责:

  1. 执行额外的初始化:进行一些收尾的初始化工作,例如设置线程、初始化信号处理机制等。
  2. 调用 main:在一切就绪之后,__libc_start_main 最终会调用我们所编写的 main 函数,此时,程序的控制权才正式交由用户代码。
  3. 处理 main 的返回:当 main 函数执行完毕并返回一个整数时,控制权会回到 __libc_start_main
  4. 调用 exit 结束进程__libc_start_main 会接收 main 的返回值,并将其作为参数传递给 exit 系统调用,从而正常终止进程,并完成所有必要的清理工作。
7.3.3.3 动态库中的相对地址

问题引入:

与主可执行程序不同,动态库的“使命”是被多个不同的进程共享。每个进程都有自己独立的虚拟地址空间。当动态链接器为一个进程加载 libc.so.6 时,可能会将其映射到地址 A;而为另一个进程加载时,地址 A 可能已被占用,只能映射到地址 B

如果动态库中的代码和数据引用使用了绝对地址(例如,直接跳转到内存地址 0x12345678),那么一旦加载的基地址发生变化,所有这些硬编码的地址都会失效,程序将立即崩溃。因此,动态库必须采用一种不依赖于绝对加载地址的寻址方案。

相对地址:

前面在介绍地址的相关概念的时候已经知道了,EIF 文件在链接形成可执行文件的时候链接器使用的绝对编址的方式,也就是直接记录一个固定的地址;而之后进行程序加载的时候,则要求将动态库映射到虚拟地址空间的时候采用的是记录偏移量也就是相对编址的方式。

这样动态库内部的所有代码和数据构成了一个整体。在这个整体内部,所有元素的相对位置都是固定的。运行时,无论这个整体被“搬运”到内存的哪个位置,其内部结构和相对关系都不会改变。

运行时的地址重定位:

当动态链接器 (ld-linux.so) 将一个动态库加载到某个进程的内存中时,会确定一个基地址(Base Address)。这是该库在当前进程虚拟地址空间中的起始位置。

随后,链接器会进行地址的重定位(Relocation)。对于库中任何一个函数或变量,其在内存中的最终绝对地址可以通过一个简单的公式计算得出:

最终绝对地址 = 基地址 + 相对偏移量

这个过程确保了无论动态库被加载到哪里,程序总能通过基地址和编译时确定的相对偏移量,准确无误地找到它需要调用的函数和访问的数据。

7.3.3.4 可执行程序与动态库的映射

上面已经知道了动态库可以通过相对编址的方式,灵活的加载进进程虚拟空间的任意位置。下面将深入探讨动态库是如何从一个磁盘上的文件,转变为进程可以访问和执行的内存区域的?

7.3.3.4.1 第一步:寻根溯源:ELF文件中的依赖信息

一个进程并非在运行时才“突然”决定需要某个动态库。这个需求在程序被链接生成可执行文件时,就已经被明确地记录下来了。

在ELF(Executable and Linkable Format)文件内部,有一个专门的段(section)记录了程序的所有动态链接信息。其中,就包含了该程序所依赖的动态库列表(例如,libc.so.6, libm.so.6等)。

因此,当动态链接器 ld-linux.so 开始工作时,它的首要任务之一就是解析这份“依赖清单”。这份清单为链接器指明了需要去文件系统中查找并加载哪些动态库文件。

7.3.3.4.2 第二步:映射过程:为动态库划分虚拟内存空间

一旦动态链接器根据依赖清单在文件系统中定位到了具体的动态库文件(例如 /lib/x86_64-linux-gnu/libc.so.6),加载过程便正式开始。这个过程可以分解为以下几个关键步骤,其原理与加载主程序完全相同:

  1. 文件操作:动态库本质上也是一个ELF格式的文件。因此,加载的第一步是在文件系统层面找到并“打开”这个文件,获取其inode等元信息,并定位到存储其实际内容的数据块(data blocks)。
  2. 创建虚拟内存区域(VMA):内核会在当前进程的地址空间(由mm_struct结构体管理)中,为即将加载的动态库创建一个或多个新的vm_area_struct节点,我们通常称之为VMA(Virtual Memory Area)。每一个VMA都代表着一段连续的虚拟地址空间。
  3. 初始化VMA:新创建的VMA会被初始化,其核心属性包括:
    • vm_startvm_end:定义了这段虚拟内存区域的起始和结束地址。其大小由动态库文件中需要被映射的段(如代码段 .text、数据段 .data)的大小决定。
    • 权限位:设置该内存区域的访问权限,例如代码段是“可读、可执行”,数据段是“可读、可写”。
  4. 建立映射关系:这是最关键的一步。在vm_area_struct结构体中,有一个名为 vm_file 的指针。内核会将这个指针指向一个代表已打开的动态库文件的内核对象。通过这个指针,内核就成功地在“一段虚拟内存”和“一个磁盘文件”之间建立了映射关系。

至此,动态库已经成功地在进程的虚拟地址空间中确定了位置。它拥有了一块专属的、逻辑上连续的内存地址。

7.3.3.4.3 第三步:访问机制:虚拟地址 + 偏移量

一旦映射完成,进程就可以访问库中的代码和数据了。访问的原理结合了之前讨论过的两个概念:

函数/变量最终虚拟地址=库的加载基地址+函数/变量的相对偏移量函数/变量最终虚拟地址=库的加载基地址+函数/变量的相对偏移量/=+/

  • 库的加载基地址:就是上一步中为该库创建的VMA的起始地址(vm_start)。这是一个在加载时才确定的虚拟地址。
  • 函数/变量的相对偏移量:这是在编译形成动态库时,由编译器生成的位置无关代码(PIC)所确定的、相对于库起始位置的固定偏移量。

当程序需要调用库中某个函数时,动态链接器已经通过重定位(Relocation)的过程,将这个公式计算出的最终虚拟地址填入到了调用指令中。CPU执行到该指令时,就能通过这个地址,准确无误地跳转到动态库在内存中的对应位置。

动态库加载机制与程序加载原理相同,因为两者均基于ELF格式。

在这里插入图片描述

7.3.3.5 可执行程序与库函数调用

上面已经了解了动态库如何通过VMA机制被映射到进程的虚拟地址空间中。一旦映射完成,库的虚拟基地址也就确定了。理论上,我们现在可以通过 “库基地址 + 函数偏移量” 的方式定位并调用库中的任何函数。整个调用过程,是从我们自己程序的代码区跳转到作为共享区的动态库内存中,调用完毕后再返回到代码区,全程都在当前进程的地址空间内完成。

让我们通过一个具体的例子来理解这个理想化的模型

在这里插入图片描述

在编译链接阶段,就已经知道puts函数在其库文件内部的相对偏移量是0x112233。此时,编译器生成的汇编指令在概念上可以理解为一条符号化的调用,形如 call libc.so@0x112233

当程序启动,动态链接器将libc.so加载并映射到内存后,会为其分配一个虚拟基地址,例如0x4433211。为了让CPU能够执行这个函数调用,程序必须知道puts函数在当前进程虚拟地址空间中的绝对地址。这个地址可以通过简单的加法得到:0x44332211 (基地址) + 0x11223 (偏移量)。因此,之前那条符号化的调用指令,最终需要被解析成一条CPU能够直接执行的指令:call0x44332211+0x112233

以上便是可执行程序调用动态库函数最直观的流程。然而,这个看似简单的模型引出了一个至关重要的实现难题:这个计算和地址替换的过程,究竟应该在何时、何生?

7.3.3.6 全局偏移量表GOT
7.3.3.6.1 代码区的只读困境

最直接的想法是在库加载完成后,直接修改主程序代码区(.text段)中所有类似 call 的指令,将计算出的最终虚拟地址“回填”进去。但这个方案面临一个无法逾越的障碍。

为了安全和实现代码共享,操作系统的内存管理机制会将进程的代码区映射为只读(Read-only)。这意味着在程序运行期间,任何试图修改这部分内存的操作都会被硬件拒绝,并导致段错误(Segmentation Fault)。因此,直接在代码区进行“二次修改”以重定位函数地址的方案是行不通的。

7.3.3.6.2 全局偏移量表 (Global Offset Table, GOT)

既然代码区不可修改,又要如何实现动态的函数地址链接呢?

所以:动态链接采用的做法是在 .data(可执行程序或者库自己)中专门预留一片区域用来存放函数跳转地址,它也被叫做全局偏移表 GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。

$ readelf -S a.out
...
	[24] .got              PROGBITS          00000000000003fb8 	00002fb8
		 0000000000000048  0000000000000008  WA        0     0      8
...

$ readelf -l a.out    # .got在加载的时候,会和 .data 合并成一个 segment,然后加载在一起
···
	05 		.init_array .fini_array .dynamic .got .data .bss
...

在这里插入图片描述

GOT本质上是一个位于数据区(.data段) 的指针数组。数据区是可读可写的,因此可以在程序运行时被动态修改。GOT的作用就是集中存放所有外部函数或全局变量的最终地址。

其工作流程发生了根本性的转变:

  1. 编译链接时:当编译器遇到一个对外部库函数的调用(如 puts),它不再生成一个直接跳转到 puts 的指令。取而代之的是,它会生成一个跳转指令,该指令的目标是跳转到GOT表中为puts预留的那个条目。同时,链接器会在可执行文件的 .data 段中创建GOT,并为程序需要引用的每一个外部符号(函数、变量)在表中创建一个空位或占位符。
  2. 映射时:当动态链接器 (ld-linux.so) 将所有依赖的动态库加载到内存并确定其基地址后,它会执行一个关键的加载时重定位操作。链接器会遍历GOT,根据库的基地址和函数的偏移量,计算出每个外部函数的最终虚拟地址,然后将这个地址填写到GOT表中对应的条目里
  3. 程序执行时:当主程序调用 puts 时,它会先跳转到GOT中 puts 对应的条目。由于这个条目此时已经被动态链接器填上了 puts 在内存中的真实地址,程序会从该条目再进行一次跳转,最终成功抵达并执行库函数的代码。

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

7.3.3.7 库间依赖

注意:

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

一个程序可能会链接大量的库,引用成百上千个外部函数。如果在程序启动时就对GOT中的所有条目进行重定位,会消耗可观的启动时间。然而,很多函数在一次程序的运行中可能根本不会被调用。

为了优化启动性能,系统引入了延迟绑定(Lazy Binding)机制,这里的关键就是过程链接表(Procedure Linkage Table, PLT)

PLT可以被看作是GOT的一个“前端代理”,它位于代码区。当程序调用外部函数时,实际上是先跳转到该函数在PLT中的一个条目。

  • 第一次:PLT条目中的代码会调用动态链接器的一个特殊辅助函数。这个函数负责查找该函数的真实地址,然后将这个真实地址回填到该函数在GOT中的条目,最后再跳转到该函数的真实地址去执行。
  • 后续调用:当再次调用这个函数时,PLT条目的逻辑会发生变化。它会直接跳转到GOT中对应的条目。由于GOT条目此时已经被填上了真实地址,程序就可以直接跳转到最终的函数代码,不再需要动态链接器的介入。

通过PLT,函数地址的解析和重定位工作被推迟到了它第一次被实际调用时才进行,极大地加快了程序的启动速度。这就是所谓的“用时解析”(Resolve-on-first-use)。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值