Linux 库制作与原理(三)ELF和可执行程序的加载(进程虚拟空间第二讲)

目录

一、库的补充

二、使用其他库

三、目标文件

四、ELF文件

什么是ELF?

链接的本质:

[ELF的细节 :](#ELF的细节 :)

[ELF Header(ELF 文件头)](#ELF Header(ELF 文件头))

节(Section))

程序头表/段头表 (Program header table)

节和段的区别:

[五、可执行程序的加载 : 进程地址空间第二讲](#五、可执行程序的加载 : 进程地址空间第二讲)

平坦模式

[什么是"平坦模式"(Flat Memory Model)?](#什么是“平坦模式”(Flat Memory Model)?)

平坦模式和虚拟地址的关系

虚拟地址和逻辑地址

[视角1:程序/ELF 文件里的虚拟地址:](#视角1:程序/ELF 文件里的虚拟地址:)

视角2:进程地址空间里的虚拟地址:

视角3:CPU视角

六、完整流程

七、总结


上篇文章我们主要讲了动静态库的封装及使用,本篇文章我们将继续展开进行讲解,继续深入库的本质以及探讨可执行程序的加载及与前面进程虚拟地址空间之间的关系。

一、库的补充

如果同时存在动静态两种库,默认用动态库 ,如果只提供静态库,即便我们采用动态链接,对于库来讲,也只能静态链接,只提供动态库也是同理。

有个问题 :库能帮助我们申请空间或者对象吗?

答案是不能,库本身不能"主动申请空间",但库可以提供申请/管理内存的函数,由我们在代码里调用。首先我们要明白库(静态库 .a / 动态库 .so)只是函数/数据的二进制集合,它不会自己跑,也不会主动做任何事。申请空间本质是调用 malloc / calloc / realloc / new 这类内存管理函数,向操作系统申请堆内存。库能做的只是封装内存申请的逻辑,让你更方便地管理内存,比如标准C库(libc.so)提供 malloc / free 等函数,你调用它们就能申请/释放内存。再或者说我们自定义的库,比如我们写的 mystdio 库,可以封装一个 my_string_create 函数,内部调用 malloc 申请字符串空间,对外只暴露简洁接口。库不会自动申请空间,只有当你在代码里调用库中的内存管理函数时,才会触发内存申请。库也不能替你释放空间,如果库申请了内存,就必须记得调用对应的释放函数,否则会造成内存泄漏。库不能越权申请,内存申请的主体始终是你的进程,库只是封装了申请逻辑,最终还是由操作系统分配给你的进程。最终申请动作的发起者是用户,用户必须显式调用库提供的函数,才会触发内存申请。

系统的默认库都有哪些?

  1. C 标准库(libc),比如 glibc(Linux 主流)、musl 等,是操作系统的核心组件,预装在系统里,所有 C 程序都依赖它,头文件在 /usr/include/,库文件在 /lib64/ 或 /lib/,直接 #include <stdio.h>、#include <stdlib.h> 就能用,编译时也不需要 -l 链接。
  2. 系统基础库(POSIX / 系统级),这些也是系统自带,不需要 yum install / apt install 下载,比如说数学库 libm(-lm 只是链接标记,库本身已安装),线程库 libpthread (-pthread 是编译选项,库已预装),动态链接库 libdl( -ldl,用于动态加载),网络库libresolv 、libnsl 等(网络相关系统调用依赖),它们的头文件也在 /usr/include/ ,库文件在系统默认库路径。
  3. 编译器自带的辅助库,比如 GCC 自带的 libgcc (运行时辅助),libstdc++ C++ 标准库(C++ 程序默认链接),这些也都是系统/编译器自带,不用手动安装。

当不是 C 标准库,也不是系统基础库:比如 ncurses 、libcurl 、openssl 、gtk,还有是第三方小众库,比如你自己写的库、或者非官方维护的库,需要手动编译安装。

二、使用其他库

上篇文章我们自己封装并使用了一个第三方库,现在我们再使用一下系统级的第三方库ncurses,这里系统级的第三方库我们也可以使用其他的第三方库,这里我们使用第三方库ncurses,ncurses(new curses)是一个字符界面(终端 UI)开发库,专门用来在 Linux/Unix 终端里创建图形化、可交互的文本界面。

ncurses库不是 C 语言标准库(libc)的一部分,也不是 POSIX 标准强制要求的库。需要手动安装(比如 yum install ncurses-devel)才能使用,系统默认不带开发头文件。本质上和我们自己写的 .so / .a 库一样,都是预编译的二进制代码 + 头文件,供其他程序调用。

本质和标准库相同,都是头文件(.h)+ 库文件(.so/.a)的组合,头文件声明函数、宏、数据结构(比如 ncurses.h ),库文件实现具体代码(比如 libncurses.so )。

安装好之后我们可以让AI帮我们形成ncurses库的demo,帮我们绘制一个心形图案,形成C语言代码:

bash 复制代码
#include <ncurses.h>
#include <math.h>

#define M_PI 3.14f

// 心形曲线函数
float heart_function(float t) {
    return pow(sin(t), 3);
}

int main() {
    initscr();          // 初始化 ncurses
    curs_set(0);        // 隐藏光标
    noecho();           // 不显示输入字符
    
    int height, width;
    getmaxyx(stdscr, height, width);  // 获取终端尺寸
    
    // 心形参数
    float scale = 8.0;
    int offset_x = width / 2;
    int offset_y = height / 2;
    
    // 绘制心形
    for (float t = 0; t < 2 * M_PI; t += 0.01) {
        float x = scale * heart_function(t);
        float y = scale * (0.8125 * cos(t) - 0.3125 * cos(2*t) - 0.125 * cos(3*t) - 0.0625 * cos(4*t));
        
        int screen_x = offset_x + (int)(x * 2);  // 乘以2调整宽高比
        int screen_y = offset_y - (int)y;        // 注意Y轴方向
        
        if (screen_x >= 0 && screen_x < width && screen_y >= 0 && screen_y < height) {
            mvprintw(screen_y, screen_x, "❤");  // 使用心形符号
        }
    }
    
    // 添加提示信息
    mvprintw(height - 1, 0, "Press any key to exit...");
    refresh();          // 刷新屏幕显示
    
    getch();            // 等待按键
    endwin();           // 结束 ncurses
    
    return 0;
}

下面我们直接使用 gcc 进行编译链接 : gcc 先把 main.c 编译成目标文件 main.o(机器码片段),再处理 #include <ncurses.h> ,检查函数/类型声明是否正确,链接阶段:把 main.o 和系统 C 标准库(libc.so)、以及你指定的 libncurses.so 合并。

-lncurses 就是告诉链接器去系统库路径里找 libncurses.so,把它和你的代码绑在一起,生成最终可执行文件。如果下不写 -lncurses 就会报错

并且这个库最常用的接口我们也可以让AI帮我们罗列出来:

三、目标文件

目标文件(Object File) 就是我们说的 .o 文件,它是源代码编译后、但还没完成链接的"半成品机器码"。 .o 文件也叫可重定位目标文件(Relocatable Object File),意思就是里面的地址、位置还没定死,可以被链接器重新搬位置、重新安排地址。它是机器码,但还不能直接运行,里面只有你代码里的函数、变量的二进制指令,地址还没最终确定,它也是编译和链接之间的中间产物,编译阶段 gcc -c main.c → 只生成 main.o (目标文件),链接阶段 gcc main.o -lncurses → 把目标文件和库文件合并,生成可执行文件(比如 a.out)

可执行程序是目标文件( .o )吗?

不是,可执行程序是二进制文件,而且是可被操作系统直接加载执行的二进制文件。二进制文件是指内容以机器码/字节流形式存储的文件,不是人类可读的纯文本。我们写的 .c 源码是文本文件,而编译后生成的 .o、.a、.so、可执行程序,都属于二进制文件。

可执行程序的本质是ELF 格式的二进制文件(Linux下)
动静态库都是文件吗?

  • 静态库 .a和动态库 .so 都是文件!都是磁盘上的二进制文件! 静态库 .a 是归档文件(把多个 .o 打包在一起),本质上还是文件。动态库 .so 是 ELF 格式的共享对象文件,本质也是文件。

四、ELF文件

什么是ELF?

ELF(Executable and Linkable Format,可执行与可链接格式) 是Linux中的标准二进制文件格式,它是一种格式,是为操作系统提供统一的程序结构描述,实现加载、执行、链接、调试 的标准化。ELF 本质是一种文件格式 ,是类 Unix 系统下定义二进制文件结构的标准规范 。符合这个规范的二进制文件,就被称为 ELF 文件

在 Linux 里:

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

都是 ELF 文件。

那动态库 .so 是 ELF 格式文件,静态库 .a 是吗?

  • 静态库 .a 本身不是 ELF 格式的文件,它是 ar 归档打包文件,本身不是 ELF 文件,但里面装的每一个 .o 都是 ELF 文件。

链接的本质:

在 Linux 下,.o 目标文件、动态库 .so 文件、可执行程序都是标准 ELF 格式的文件,静态库 .a 则是由多个 .o 打包而成的归档文件,内部同样是 ELF 格式的目标文件。因为整个编译、链接流程里的所有模块都基于同一种ELF格式,所以从源码编译成 .o、再打包成静态库或动态库、最终链接成可执行程序,整个过程是完全贯通、格式兼容的,链接器只需要把这些 ELF 模块按规则合并段、解析符号、完成重定位,就能把各个模块融合成一个可正常运行的程序。

ELF的细节 :

;

我们来看一下 ELF 格式文件的组成,ELF 文件有固定 4 大组成,不管什么 ELF,结构从上到下一定是:

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

下面我们具体查看各个部分的内容:

ELF Header(ELF 文件头)

我们可以用 readelf -h 文件名命令来查看程序的 ELF Header :

ELF Header 是 ELF 文件最开头的 64 字节固定结构,作用是告诉系统:我是什么文件以及怎么解析。其中最重要的是 Type 和 Entry point address

Type 显示这是什么类型的 ELF:

  1. REL (Relocatable file) → .o 文件
  2. EXEC (Executable file) → 可执行程序
  3. DYN (Shared object file) → .so 动态库

我们可以通过 Type 知道它是 .o / 可执行 / 动态库。

Entry point address是可执行程序的入口地址,它告诉操作系统程序加载到内存后,从哪一条指令开始执行 。对于可执行文件来说,这个地址是最终虚拟地址,操作系统直接跳转到这里开始运行。

Machine 显示运行的是什么CPU,一般是:Advanced Micro Devices X86-64 (英特尔的AMDCPU)

就是 x86_64 架构。

节(Section)

什么是节?

节就是ELF 文件里,按"功能"分好的一个个数据块。代码放一块、数据放一块、符号放一块......每一块就是一个节。 我们可以使用命令 readelf -S a.out 来查看节:

我们可以看出这个编译好的 a.out 程序的ELF文件中有30个节,一般情况下编译好的程序的ELF文件都是30个节,每个节里面都装有固定的内容,我们看一下几个重要的节:

ELF 常见的节(Section):

🔹 代码与数据类

  • .text代码段 ,存放可执行指令(函数实现),运行时只读、可执行。
  • .rodata :只读数据段,存放常量(字符串字面量、const 全局变量),运行时只读。
  • .data :已初始化数据段,存放已初始化的全局 / 静态变量,运行时可读写。
  • .bss :未初始化数据段,存放未初始化的全局 / 静态变量,不占文件空间,加载时自动清 0。

🔹 符号与链接类

  • .symtab :符号表,记录函数、变量的名称、地址、类型等信息,用于链接和调试
  • .strtab :字符串表,存放符号名、节名等字符串,和 .symtab 配合使用。
  • .shstrtab :节名字符串表,专门存放节的名称
  • .rel.text / .rel.data :重定位节,记录需要修正的地址引用,用于链接时地址重定位

我们可以用反汇编的形式查看具体的节的信息:

bash 复制代码
objdump -S ./a.out > sections.s

我们可以使用 objdump -S 指令查看可执行文件的反汇编代码,并能直观看到 C/C++ 源码对应的机器指令。然后输出重定向到 section.s 文件中方便查看。

这是 objdump -S 生成的 ELF 可执行文件反汇编结果 (文件为 sections.s),展示了 ELF 文件中 节(Section)的具体机器指令与汇编代码。我们能看到第一个节是.init,第二个节是.plt,等等后面的节就不展示了,我们下面将两张图进行对比:

这张图直观展示了 ELF 节头表(Section Header Table)反汇编代码(sections.s) 之间的地址对应关系 : .init 节在节头表中的起始虚拟地址0000000000400728,在反汇编中的入口地址也是 0000000000400728,表示代码从该地址开始,.init 是程序初始化节,在 main() 之前执行,负责全局初始化逻辑。.plt 节也是同理,在节头表中起始虚拟地址0000000000400750,反汇编中也是 0000000000400750,代码从该地址开始。

下面我们来看最重要的一个节:.text节

.text 节 存储了程序的所有机器指令,包括 _start、main() 以及你写的所有函数,是 CPU 真正执行的内容。 <_start> 就是 .text 节里的第一个函数!是程序的真正起点,图中 _start 函数就位于 .text 节,它是程序的实际入口点**。与入口地址完全对齐右侧 ELF 头显示 Entry point address: 0x400820,这个地址正好是 .text 节中 <_start> 函数的起始地址。操作系统加载程序后,会直接跳转到这个地址,开始执行 .text 节里的指令。**.text 节的权限通常是 R E(只读 + 可执行),这是为了防止代码被意外修改,保证程序执行安全。其他节都是为 .text 节服务的:.data/.bss 提供变量,.rodata 提供常量,.plt/.got 提供动态链接支持。


程序头表/段头表 (Program header table)

下面我们用 readelf -l a.out 命令查看下一个组成部分:

段(Segment):

下面就涉及到了段的概念:

段(Segment)是操作系统加载程序时的"加载单位", 它把多个功能/权限相近的节(Section)打包在一起,告诉系统:这段数据要加载到内存的哪个位置、多大、权限是什么(读/写/执行)。多个节(Section)会被合并到同一个段(Segment)中,比如 .text(代码)、.rodata(只读常量)等节,会被合并到一个 LOAD 段,权限设为 R E(只读可执行),.data(已初始化数据)、.bss(未初始化数据)等节,会被合并到另一个 LOAD 段,权限设为 RW(可读可写)。

各个段的地址和进程的虚拟地址有很大的关系,下面我们会详细介绍

节和段的区别:

  1. 节是链接时概念:由节头表(Section Header Table)描述,告诉链接器"这段数据是什么类型、怎么合并"。
  2. 段是运行时概念:由程序头表(Program Header Table)描述,告诉操作系统"这段数据要加载到内存哪里、权限是什么"。
  3. 一个段通常包含多个节:比如代码段(.text 段)会包含 .text 、 .rodata 等节,数据段会包含 .data 、 .bss 等节。
  4. 一定是先有节,后有段, 节在编译阶段就生成了。编译器把 .c 源码编译成 .o 目标文件时,就已经按功能划分出 .text 、.data 、.symtab 等节,.o 文件里只有节,没有段。而段是在链接阶段才生成。链接器( ld )读取多个 .o 的节,按内存权限+加载需求把同类节打包成段,写入最终的 .so 或可执行程序中。
  5. 简单说:运行时加载完全不依赖节,节是给链接/调试用的。加载运行时,操作系统加载器完全不看节,加载时只读取 ELF Header 和 Program Header Table(段表),按段(Segment)把数据映射到内存,根本不会去读 Section Header Table(节头表),也不关心有哪些节。节只在链接阶段( .o → .so /可执行)起作用,链接器读取 .o 的节头表,把多个 .o 的.text 、.data 等节合并,再打包成段。

介绍ELF文件中的所有组成部分,那么加载运行可执行程序时的 ELF 读取顺序是什么?(重要)

  1. 第一步:读 ELF Header(ELF 文件头),读文件最开头的 64 字节(固定大小)获取关键信息:是 32 位还是 64 位,是可执行程序( EXEC )还是动态库( DYN ),Program Header Table 的文件偏移和大小,Section Header Table 的文件偏移和大小,这是加载的入口,所有后续操作都依赖它提供的地址信息。
  2. 第二步:读 Program Header Table(程序头表/段头表),位置由 ELF Header 中的 e_phoff 字段指定,这是程序加载运行的核心,描述了所有段(Segment)的信息。对每个段,它会告诉加载器: 这段数据在文件中的偏移,要映射到进程虚拟地址空间的哪个地址,这段数据在文件和内存中的大小,内存权限(读/写/执行),是否需要对齐,加载器只关心这张表,它决定了内存布局。
  3. 第三步:按 Program Header Table 加载段到内存,遍历程序头表中的每一个段,将对应的文件数据映射(mmap)到进程虚拟地址空间。代码段( .text 所在段)映射为 只读 + 可执行 权限。数据段( .data / .bss 所在段)映射为 可读 + 可写 权限。要注意的是加载运行时完全不需要读 Section Header Table(节头表),那是给链接器和调试器用的。
  4. 第四步:跳转到程序入口点执行,从 ELF Header 的 e_entry 字段获取程序入口地址( _start 函数的地址)。把 CPU 的指令指针(PC)设置为这个地址,开始执行程序。

五、可执行程序的加载 : 进程地址空间第二讲

在讲之前,有两个问题:

1.创建一个进程,先创建内核进程相关的数据结构,还是先加载ELF格式的二进制文件?

  • 答案是先建立内核进程相关数据结构,再加载 ELF二进制文件 。第一步先创建进程内核数据结构,操作系统在执行 execve 等系统调用时,会先为新进程分配并初始化核心数据结构,task_struct (Linux 进程描述符)记录 PID、状态、内存管理信息、文件描述符表等。mm_struct 管理进程虚拟地址空间,为后续 ELF 加载做准备。页表、信号处理、权限等基础环境。第二步解析并加载 ELF 二进制文件,内核数据结构就绪后,才开始处理 ELF 文件,先读取 ELF Header,验证文件合法性。解析 Program Header Table(程序头表),按段(Segment)的描述将代码、数据等映射到进程虚拟地址空间。处理动态链接(若有),加载依赖的 .so 库。最后设置程序入口点(e_entry),准备执行。第三步启动执行,所有加载完成后,内核将 CPU 上下文切换到新进程,跳转到 ELF 入口点开始执行第一条指令。
  1. 程序没有被记载到内存之前,程序自己有没有地址,什么地址?
  • 程序没有被记载到内存之前有地址,这个地址就是虚拟地址(也叫逻辑地址/链接地址)。我们把这个虚拟地址叫做程序/ELF 文件里的虚拟地址,程序/ELF 文件里的虚拟地址 在ELF格式的可执行文件中,编译器和链接器会在程序被加载到内存之前,就为代码、数据等段分配好虚拟地址,比如 .text 代码段、 .data 数据段的起始地址,会被写进ELF文件的程序头(Program Header)里。这个地址是逻辑上的虚拟地址,和物理内存无关,只是程序被编译链接时就确定好的。当程序被加载到内存时,操作系统会把这些预定义的虚拟地址,通过页表映射到实际的物理内存地址上。所以即使程序还没被加载到内存,它本身已经被赋予了虚拟地址,而不是物理地址。

平坦模式

什么是"平坦模式"(Flat Memory Model)?

  • 平坦模式是一种内存寻址模型,它把整个虚拟地址空间看作一个连续、不分段的线性地址范围。我们可以理解为整个地址空间就是一条从 0 到最大值的"直线",所有代码、数据、栈、堆都放在这条直线上,没有分段(比如代码段、数据段的地址隔离)。32位系统下地址范围是 0x00000000 ~ 0xFFFFFFFF (0 ~ 4GB),64位系统下地址范围更大。

平坦模式和虚拟地址的关系

  1. 虚拟地址是平坦模式的"地址单位",在平坦模式下,每个虚拟地址都是一个线性地址,直接对应到这个连续地址空间的某个位置。比如 .text 代码段从 0x400000 开始,.data 数据段从 0x600000开始,它们都在同一个连续的虚拟地址空间里,没有"段基址+偏移"的分段寻址方式。
  2. 编译器/链接器在平坦模式下,会直接为程序的每条指令、每个变量分配虚拟地址,原则上从 0 开始(实际会被加载器调整到具体偏移)。程序看到的是一个连续的虚拟地址空间,不需要关心物理内存在哪,也不需要关心分段机制。
  3. 与分段模式相比,在分段模式下,地址 = 段选择子 + 段内偏移,地址空间被分成多个段(代码段、数据段等)。而平坦模式下,地址 = 线性虚拟地址,整个空间是连续的,现代操作系统(Linux、Windows)都采用这种模式,配合分页机制管理内存。

平坦模式是虚拟地址的"组织方式"------它把虚拟地址空间变成一条连续的直线,让程序可以用统一的线性虚拟地址来访问所有代码和数据。

虚拟地址和逻辑地址

所以 程序/ELF 文件里的虚拟地址 和 进程地址空间里的虚拟地址 到底有什么关系? 它们是同一个地址吗?

答案 : 我们看到的"程序里的虚拟地址"和"进程地址空间里的虚拟地址",本质是一回事,只是视角不同。它们是同一个虚拟地址!

理由:

视角1:程序/ELF 文件里的虚拟地址:

在磁盘上的 ELF 可执行文件里,程序头表中的 VirtAddr 就是每个段的起始虚拟地址(段地址),这是编译链接时就确定好的逻辑地址;当操作系统加载程序时,会严格按照这个地址把段映射到进程的虚拟地址空间,地址数值保持不变,直接成为 CPU 执行时使用的虚拟地址。这个地址是逻辑上的"预期地址",所以我们也把它成为逻辑地址,它告诉操作系统:"等我加载到内存时,请把我放在这个虚拟地址上"。所以本质上虚拟地址就是逻辑地址。

视角2:进程地址空间里的虚拟地址:

当操作系统加载这个 ELF 时,会严格按照 ELF 里程序头表中的 VirtAddr,也就是每个段的起始虚拟地址(段地址),在进程的虚拟地址空间里划出对应的区域,这个时候,程序里写死的虚拟地址,就变成了进程地址空间里真实可用的虚拟地址。

程序/ELF 文件里的虚拟地址是"编译时的约定",进程里的虚拟地址是"运行时的实现"。操作系统加载时,会把 ELF 里的虚拟地址原封不动地映射到进程的虚拟地址空间,所以程序/ELF 文件里 0x400820 这个地址,在进程运行时,就是进程虚拟地址空间里的 0x400820 。CPU 执行指令时用的虚拟地址,和 ELF 里写的地址,完全一致。

换句话说,在现代平坦模式下,逻辑地址就是虚拟地址(几乎可以看作同一个东西),程序里的地址(ELF 里的地址)就是逻辑地址(链接地址),它本身就是一个线性地址。加载到进程后,这个逻辑地址直接变成进程虚拟地址空间里的虚拟地址,数值完全不变。此时逻辑地址 = 虚拟地址(线性地址),只是叫法不同:编译/链接阶段叫逻辑地址,运行/进程阶段叫虚拟地址**。**

视角3:CPU视角

在 CPU 视角中,它读到的是虚拟地址,CPU 执行指令时,看到的永远是虚拟地址,不会直接看到物理地址。物理地址是内存管理单元(MMU)通过页表,把虚拟地址翻译后得到的,对CPU是透明的。

CPU 用虚拟地址(比如 0x1060 )发起访存,MMU 查页表(由 mm_struct 管理),把虚拟地址翻译成物理地址,最终访问物理内存 。所以CPU将虚拟地址翻译成物理地址是由 MMU 完成。

需要注意的是。CPU 里有一个东西叫 EIP,它的本质就是一个 PC 指针,就是程序计数器在 x86 32位下是 EIP, 在 x86 64位下是 RIP,就是程序计数器。这个PC指针指向的地址就是就是程序入口地址(Entry point address),也就是 .text 节中 <_start> 函数的起始地址。

在 CPU 里还有一个叫 CR3 的寄存器,它存的是当前进程页表的基地址(PGD 物理地址),不是虚拟地址。它的作用是帮 MMU 找到页表,而不是"访问虚拟地址"。 首先 CPU 要访问一个虚拟地址(比如 0x400820 )。 MMU 拿到这个虚拟地址,去读 CR3 寄存器,找到页表的物理起始位置。

然后 MMU 遍历页表(PGD → PUD → PMD → PTE),把虚拟地址翻译成物理地址。用物理地址去访问内存。

完整流程 :

当 CPU 拿到虚拟地址(比如从 EIP 里拿到程序入口地址 0x400500)时,它会这样继续运行:

  1. CPU 把虚拟地址交给 MMU,虚拟地址是程序用的,CPU 自己不能直接访问内存,所以它把这个地址直接送到内部的 MMU(内存管理单元)。
  2. MMU 主动读取 CR3 寄存器,获取页表物理基址,CR3 里存的是当前进程页表的物理基址(物理地址)。MMU 必须先找到页表,才能翻译虚拟地址 → 所以它去读 CR3。
  3. MMU 根据物理基址,在物理内存中查找页表,页表本身是放在物理内存里的,MMU 拿着 CR3 给的物理基址,去真实内存里读出这张页表。
  4. MMU 用虚拟地址去查页表,得到物理页号,虚拟地址拆成:虚拟页号 + 页内偏移,MMU 用虚拟页号作为索引查页表,页表返回对应的 物理页框号(物理地址的一部分)
  5. MMU 拼接出真正的物理地址,交给内存访问,物理地址 = 物理页框号 + 页内偏移,然后 CPU 通过这个物理地址,从内存里真正读出指令或数据。
  6. CPU 执行指令,然后 EIP 自动指向下一条虚拟地址,执行完当前指令,EIP 自动加一(或被跳转指令修改),下一次循环继续取指、执行、翻译,循环不断

六、完整流程

  1. 先看最右边磁盘上的可执行程序(ELF),是编译链接好的 ELF 格式文件,里面的地址是每个段的逻辑地址,也叫虚拟地址的前身,这些地址(比如 0x1060 )是链接器预先分配的,写在 ELF 程序头表(Program Header)里。

  2. 加载到内存后,逻辑地址就变为了虚拟地址,加载到内存后,逻辑地址的数值完全不变,只是身份变成了进程虚拟地址,操作系统会把这些地址映射到进程的虚拟地址空间,磁盘上 0x1060 是逻辑地址(ELF 里的地址),内存里的 0x1060 就是虚拟地址(进程里的地址),两者数值完全一样,只是阶段不同叫法不同。

  3. 最左边是 task_struct 和 mm_struct,task_struct 是进程的 PCB(进程控制块),里面包含 mm_struct,mm_struct 是虚拟地址空间的管理者,它记录了所有虚拟内存区域(VMA),它维护页表根CR3( pgd ),MMU 靠它找到页表,CPU 用虚拟地址(比如 0x1060 )发起访存,MMU 查页表(由 mm_struct 管理),把虚拟地址翻译成物理地址,最终通过物理地址访问到内存中的数据内容。

  4. CPU 里的 EIP(PC 指针)就是程序入口地址(Entry point address),这个地址是虚拟地址,CPU 直接用它取指令,然后交给 MMU 翻译成物理地址。

七、总结

本文深入探讨了库的本质和可执行程序的加载机制。首先解释了动静态库的区别与使用规则,指出库本身不能主动申请空间,但能提供内存管理函数。详细介绍了ELF文件格式的组成结构(ELF头、程序头表、节头表和节),阐述了目标文件与可执行程序的区别。重点分析了程序加载流程:操作系统先建立进程数据结构,再按ELF程序头表将段映射到进程虚拟地址空间。解释了平坦内存模式下虚拟地址的本质,说明程序中的逻辑地址与进程虚拟地址实质相同。最后完整描述了从ELF文件加载到CPU执行的全过程,包括MMU通过页表将虚拟地址转换为物理地址的机制。

相关推荐
汀、人工智能16 小时前
[特殊字符] 第79课:分割等和子集
数据结构·算法·数据库架构·位运算·哈希表·分割等和子集
北方的流星16 小时前
华三网络设备的路由重定向配置
运维·网络·华三
河南博为智能科技有限公司16 小时前
蓄电池在线监测系统-守护数据中心安全防线
运维·边缘计算
SkyWalking中文站16 小时前
使用 TraceQL 查询 SkyWalking 和 Zipkin 链路追踪数据并在 Grafana 中可视化
运维·grafana·监控
独小乐16 小时前
009.中断实践之实现按键测试|千篇笔记实现嵌入式全栈/裸机篇
linux·c语言·驱动开发·笔记·嵌入式硬件·arm
山甫aa16 小时前
List 容器 -----C++的stl学习
开发语言·c++·学习
汀、人工智能16 小时前
[特殊字符] 第74课:完全平方数
数据结构·算法·数据库架构·图论·bfs·完全平方数
kobe_OKOK_16 小时前
S7 adapter Docker run
运维·docker·容器
披着羊皮不是狼16 小时前
将Ubuntu从C盘移动到D盘
linux·运维·ubuntu
CoderCodingNo16 小时前
【GESP】C++四、五级练习题 luogu-P1177 【模板】排序
数据结构·c++·算法