Linux操作系统:再谈虚拟地址空间

发布于:2025-07-09 ⋅ 阅读:(19) ⋅ 点赞:(0)

目录

前言:

一、编译与链接

二、ELF加载

三、再谈虚拟地址空间 

四、动态库的加载

总结:


前言:

我们在上篇文章中,已经为大家介绍了动静态库的相关知识。那么本篇文章,将会为大家带来ELF加载与虚拟地址空间的联系等相关内容,希望对大家有所帮助!!

废话不多说,我们直接进入主题。

一、编译与链接

我们都知道编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们⼀般都是⼀键构建⾮常⽅便, 但⼀旦遇到错误的时候呢,尤其是链接相关的错误,很多⼈就束手无策了。

在Linux系统中,将源代码转换为可执行程序通常需要四个主要步骤: 

  1. 预处理:处理源代码中的宏定义、头文件包含等

  2. 编译:将预处理后的代码转换为汇编代码

  3. 汇编:将汇编代码转换为机器码(目标文件)

  4. 链接:将一个或多个目标文件合并为最终的可执行文件

而在编译之后,就会生成拓展名为.o的文件,这个叫做目标文件。目标文件是一个二进制文件,文件的格式是ELF,是一种对二进制代码的封装。

比如我们现在有test.c该文件,内容为:
#include<stdio.h>

void test()

{
    printf("hello world");
}

int main()

{
    test();
    return 0;
}

 我们可以使用以下命令使其编译成一个目标文件:

同时,我们可以使用file命令来查看一个文件的类型:
 可以看见,目标我呢间的类型,正是ELF,那么什么是ELF文件呢?

二、ELF加载

其实有以下四种文件其实都是ELF⽂件:
1、可重定位⽂件( Relocatable File ) :即 xxx.o ⽂件。包含适合于与其他⽬标⽂件链接来创建可执⾏⽂件或者共享⽬标⽂件的代码和数据。
2、可执⾏⽂件( Executable File ) :即可执⾏程序。
3、共享⽬标⽂件( Shared Object File ) :即 xxx.so⽂件。
4、内核转储 (core dumps) ,存放当前进程的执⾏上下⽂,用于dump信号触发。

 

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

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

 

我们可以通过readelf命令来查看:
 

⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment
即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
很显然,这个合并⼯作也已经在形成ELF的时候,合并⽅式已经确定了,具体合并原则被记录在了
ELF的 程序头表 (Program header table) 中。合并原则:相同属性,比如:可读,可写,可执⾏,需要加载时申请空间等.

所以我们多个目标文件,链接形成一个可执行文件时,是怎么形成的呢?

他们都是ELF文件,都有着一样的结构,我们只需要把相同结构里的数据进行合并,就生成了一个可执行文件的ELF文件

三、再谈虚拟地址空间 

我们以前曾经谈论了有关虚拟地址空间的概念,说我们%p打印出的地址,都不是真正的物理地址,而是虚拟的地址空间。

但是当时还是说的太浅显了,现在我们给大家带来更具体的说法。

我们都知道,没打开的文件存储在磁盘上,等待着被记载到物理内存, 进程通过task_struct,找到mm_struct,通过页表映射,就能找到物理地址上的内容。

这是我们已经梳理清楚的一条完整的线路。

那么,假如我们要加载一个文件到物理地址上,他的加载过程与虚拟地址空间有什么关联呢?

我想提出两个疑问:
1、mm_struct,是由谁初始化的?
2、可执行程序编译好后,没有加载到内存的时候,是否有自己的地址呢?

我先来回答第二个问题:

答案是,有的!

 

如图,这是我们上面那个test.o链接形成的可执行文件的反汇编,可以看到,它内部其实是含有地址空间的。

在我们对可执行文件进行编址的时候,我们一般都是采用的平坦模式,而随之带来的效果就是,编出来的地址,逻辑地址=起始地址+偏移量,而起始地址全部为0.

也就是说,逻辑上的地址只会与偏移量有关。

从而允许程序将整个内存空间视为一个连续的、线性的地址空间,也就是说,地址就是从00000000.....开始,一直到FFFFFFFFFFFFF....,这样的形式,不就正是我们的虚拟地址空间吗?

所以,虚拟地址不仅仅是你在操作系统内形成进程形成的,而是在编译器编译时就已经形成的。

所以虚拟地址与编译器也有关系!!!!!

这句话就是我们想告诉大家的事情。

你的mm_struct怎么来的?

进程在新建时要加载可执行程序,加载可执行程序的时候就要初始化地址空间。

请问,初始化地址空间时,你的正文代码初始化未初始化,你的各种数据区的字段从哪里来的?

:从可执行程序中,加载可执行程序的各个数据节的具体地址得来的,所以形成了mm_strcut。

 这里的0x1060,就是我们test可执行文件的入口地址啊!


而我们CPU在执行程序的时候,都用的是什么地址呢?
答案是虚拟地址,为什么可以使用虚拟地址来运行程序呢?

:页表

页表的映射关系怎么来的呢?

:把可执行加载到内存时,每个语句就放在他的物理地址上。此时,我们加载到内存前有虚拟地址,加载到内存上有了物理地址,就可以开始完善页表了

在CPU内部,有着cr3寄存器与MMU硬件,rr3寄存器会帮我们找到页表起始地址,你觉得保存的是什么地址?(cr3是操作系统自己用的寄存器不会暴露给你 )

:物理地址

随后MMU硬件加cr3就可以进行查表了。

我们最后可以得出结论:虚拟地址,是操作系统,CPU,编译器共同协作下的产物。


四、动态库的加载

在Linux内核中,每个进程的虚拟地址空间都由一个关键的数据结构mm_struct来管理。这个结构体远比我们最初想象的复杂,它不仅保存了地址空间的边界信息,还通过精巧的链式结构实现了高效的内存管理。

struct mm_struct 
{
    struct vm_area_struct *mmap;      // VMA链表头
    struct rb_root mm_rb;            // VMA红黑树根
    pgd_t *pgd;                     // 页全局目录(Page Table)
    unsigned long start_code, end_code; // 代码段边界
    unsigned long start_data, end_data; // 数据段边界
    unsigned long start_brk, brk;    // 堆边界
    unsigned long start_stack;       // 栈起始地址
    struct list_head mmlist;        // 全局mm_struct链表
};

 

mmap 是一个指向 虚拟内存区域(VMA)链表 的头节点的指针,用于:

  1. 串联所有内存映射区域:包括代码段、数据段、堆、栈、动态库映射、文件映射等。

  2. 提供线性遍历能力:内核可以通过该链表顺序访问所有 VMA,执行批量操作(如内存回收、权限修改)。

每个VMA代表进程地址空间中一段连续的虚拟内存区域,其定义如下:

struct vm_area_struct 
{
    unsigned long vm_start;         // 起始虚拟地址(包含)
    unsigned long vm_end;           // 结束虚拟地址(不包含)
    struct vm_area_struct *vm_next; // 指向下一个 VMA(单链表)
    struct rb_node vm_rb;           // 红黑树节点(用于快速查找)
    pgprot_t vm_page_prot;          // 访问权限(如 READ/WRITE/EXEC)
    unsigned long vm_flags;         // 标志位(如 VM_SHARED、VM_IO)
    struct file *vm_file;           // 关联的文件(若为文件映射)
    // ... 其他字段(如反向映射、操作函数集等)
};

 所以,我们的虚拟地址空间,哪些被用到了,实际上都会形成个链表,按照一定顺序串连起来进行管理,所有 VMA 按 虚拟地址升序 通过 vm_next 指针串联,形成单向链表。

动态库的加载过程完美体现了Linux内存管理的精妙设计,过程是一样的,由磁盘加载到内存中,虚拟地址与物理地址形成页表。 

 但是我们说动态库是共享库,可以多个进程同时访问,动态库的加载,本质上起始就是新建了一个节点,里面有该动态库的起始地址与结束地址,还有各种调用的虚拟起始地址。随后把这个节点连入了该链表中,就做到了共享。

在我们加载的动态库的时候,会有一个叫做动态链接器的东西,会解析符号(如 printf),通过 mm_struct.mm_rb 红黑树快速定位包含该符号的库 VMA,随后计算符号在 VMA 中的偏移量,得到最终虚拟地址。

  1. 文件映射:动态链接器通过mmap将库文件映射到虚拟地址空间

  2. VMA创建:为每个库段(代码段、数据段等)创建新的VMA

  3. 结构更新

    • 将新VMA插入mmap链表(保持地址有序)

    • 同步更新红黑树mm_rb

  4. 符号解析:通过红黑树快速定位符号所在VMA

  5. 页表建立:内核建立虚拟地址到物理内存的映射

我们描述可能太过抽象,大家听不懂,这很正常,我们可以举一个例子为大家描述一下这个过程,大家体会一下这个例子就行了,不需要了解很清楚:

想象你有一个大仓库(虚拟地址空间),里面放着各种货物(内存区域)。仓库有个管理员叫老张(mm_struct),他手里拿着:

  • 一个记事本(链表):按顺序记录每个货物区的起始和结束位置

  • 一个智能地图(红黑树):能快速找到某个位置的货物

每个货物区有个标签(vm_area_struct),写着:

  • 起始和结束位置(如A区:1号架-100号架)

  • 货物类型(代码、数据、堆、栈等)

  • 使用规则(可读?可写?可执行?)

当多个车间(进程)要用同一批原料(动态库):

  1. 老张在记事本上新增一条记录(创建VMA)

  2. 实际货物仍放在中央仓库(物理内存只存一份)

  3. 各车间用自己的记事本记录位置(每个进程有自己的VMA链表)

有个专门的送货员(动态链接器)负责:

  1. 查清单(.dynamic段)看需要哪些原料

  2. 去仓库取货(加载动态库)

  3. 贴位置标签(符号重定位)

  4. 第一次用才拆箱(延迟绑定)

 


总结:

本文的内容有些过于硬核,对于新手来说肯定看不懂(因为我讲的也很差),大有兴趣可以下来对该过程进行更加深入的探讨,但本篇文章你只需要理解1,2,3节的知识就行了。

如果有讲的不对的地方欢迎大家进行讨论指正!!


网站公告

今日签到

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