Linux:虚拟地址空间
1. 程序地址空间
1.1 回顾

我们在学习C/C++的时候,就见过这样一幅图,它描述了栈、堆、常量区....等等,向我们简略的解释了我们平时写的变量、代码会存放在哪个位置。但是这幅图具体是什么,这样画的到底对不对,我们还没有考究过。
我们简单回顾一下栈区:

栈区的数据在内存中整体遵循从高地址向低地址的方向排布,也就是从上向下增长。以两个相邻的 int 型变量为例,每个 int 占用 4 个字节,且每个字节都拥有独立的地址。在获取一个变量的地址时,我们实际得到的是它所占用的 4 字节空间中地址数值最小的那个字节的地址。
与此同时,在单个变量内部,字节的存储顺序是从低地址到高地址,也就是自下向上排列。因此综合来看,对栈上的变量取地址,最终得到的就是该变量所占据内存区域起始位置、地址最低的字节地址。
那么,这一整块空间到底是什么呢?它既然存储了变量什么的,那它是内存吗?
其实,我们所指的这段空间的名字叫做:进程地址空间,或者说叫做虚拟地址空间。它是操作系统的概念。
2. 虚拟地址
我们用这段代码举例,来看看什么是虚拟地址:
1 #include <stdio.h>
2 #include <unistd.h>
3
4 int main()
5 {
6 int g_val = 100;
7 pid_t id = fork();
8 if(id == 0)
9 {
10 while(1)
11 {
12 printf("我是子进程,pid:%d,ppid:%d,g_val:%d,&g_val:%p\n",getpid(),getppid(),g_val,&g_val);
13 sleep(1);
14 }
15 }
16 else
17 {
18 while(1)
19 {
20 printf("我是父进程,pid:%d,ppid:%d,g_val:%d,&g_val:%p\n",getpid(),getppid(),g_val,&g_val);
21 sleep(1);
22 }
23 }
24 return 0;
25 }
这是一段普通的父子进程分流执行的代码,执行结果是这样的:

可以看到,父子进程获取g_val的值都一样,并且地址也都一样。现在对代码进行一些修改:

我们让子进程每次打印的时候,再修改一下g_val的值:

我们会发现,父子进程里的g_val的值发生了不同的变化,父进程里的g_val一直不变,但是子进程的g_val一直在加一。我们之前学习过父子进程具有独立性,所以子进程发生了改变,不会影响父进程的代码操作。但这段显示的内容,让我们最震惊的是:父子进程的g_val值不一样,但是g_val的地址竟然一样!!!难道说,一个变量可以存储两个不同的值吗??!!
那我们就要思考一个问题,我们所看到的地址难道真的是内存当中的物理地址吗?这显然是不可能的,因为这违背了现实的物理常识。就好比如说,你不可能同时出现在南极和北极。
其实,我们所说的所谓的“地址”,本质上都是指针,从客观角度上来讲,指针当然不是物理地址。所以我们可以下结论:我们之前在语言上所学到的地址,根本不是真正的物理地址,而是虚拟地址。
3. 虚拟地址空间
3.1 由现象观察虚拟地址空间
所以说,我们之前学习的那幅图是不太准确的,在操作系统内部存在的实际上是虚拟地址空间,以下面的图做讲解:

比如说对于一个进程,它首先有自己的 task_struct ,这里面存储了虚拟地址空间的信息,并且地址排列也是从 0 到 全F 的地址排列。而我们的程序文件包含代码和数据,是存储在磁盘上的,磁盘就代表了物理内存,所以我们的代码和数据在真实的物理内存空间里是各自占据一定空间的。
而在虚拟地址空间和物理内存空间之间,存在着一个叫做页表的东西,可以把它理解成为哈希表,里面存储的是虚拟地址到物理地址的映射。比如真实物理地址里面这个 g_val 的地址是 0x12345678,但是通过页表映射的虚拟地址空间就是我们所看到的 0x601054 ,所以说,我们所能看到的地址,都是操作系统经过处理之后得到的虚拟地址空间,真实的物理地址用户是看不到的!
那如果用户现在将 g_val 的值进行修改,原来是 100 ,修改成 102 ,那么就会通过虚拟地址映射到物理地址,然后再将其对应的值改成 102 。

那么当我们进行 fork() 之后,创建出一个子进程。我们之前说过, 子进程是会继承父进程的task struct信息,除了pid和pid不会继承,其他都会参照父进程的task struct进行初始化。所以自然而然的也就得到了来自于父进程的虚拟地址空间,存储的变量、变量地址、页表信息,即对于所有进程来说,每个进程都有各自的虚拟地址空间和页表。但是此时要注意的是:因为页表信息也是直接继承下来的,所以子进程通过页表映射的物理地址,和父进程通过页表映射的物理地址,是同一块空间!这就类似于我们在学习C++时遇到的问题:浅拷贝。这也是父子进程能共享所有的代码和数据的原因。
3.2 写时拷贝
那么为什么都共享代码和数据了,在我们修改子进程里的 g_val 的时候,父进程的 g_val 还是没有发发生变化呢?这就是OS做的规定:当父子进程共享数据时,如果有一方要修改数据,不能直接修改,而是要先进行“写时拷贝”,拷贝过后再修改。

比如我们要修改子进程的 g_val 值,发生写时拷贝时,操作系统会在物理内存当中开辟一个新的空间,产生一个新的地址比如说:0x11223344 ,那么此时子进程的页表中原来老的地址空间就会被替换成新的地址空间,也就是子进程里的数据的真实物理地址空间的指向发生了改变,但是虚拟地址空间还是不会发生改变的。这也就是为什么我们能看到刚刚举例时发生的地址一样,但是值不一样的情况。这种情况就类似于我们学过的“深拷贝”。
因此我们也能解释之前的场景,当进行父子进程分流的时候,有这样一段代码:
pid_t id = fork();
if(id == 0)
{
}
else if(id > 0)
{
}
我们用户看到的像是一个变量存储了两个值,实际上是相同的变量名但是映射的是两个不同的物理地址。

3.3 基本概念
通过上面的内容,我们初步对虚拟地址空间有了一定的了解,接下来我们进一步的介绍它的概念, 我们可以用一个例子来更好的理解虚拟地址空间:

从前有一个大富翁,他非常的有钱,一共有十亿美元。这个大富翁还有五个私生子,彼此都不知道对方的存在。大富翁为了表现他非常爱自己的孩子,所以对每个私生子都说:“你现在好好努力加油干,等以后我死了我的财产全部都是你的”。对于每个私生子来说,他们都觉得这个大富翁确实非常疼爱他们,愿意把自己的财产都留给自己。
但是作为21世纪新时代社会有为青年的我们来说,一眼就可以看出来,这个大富翁是在给他的私生子们画大饼。但是大富翁很聪明,他肯定不想露馅,所以要对每个大饼都做好“管理”。比如今天私生子1问他要了100块钱上网吧,私生子2问他要了1万块钱出去旅游,大富翁都要记清楚。不然万一哪天记错了,比如私生子1说, 爸,我要200块钱给我们班小美买淀粉肠吃。然后大富翁甩手给了私生子1说,我不是才给了你1万块钱出去旅游吗,现在又问我要200块钱。那私生子1就会很委屈,自己明明没有要过1万块钱,同时私生子1也会起疑惑,说不定就会暗中调查发现大富翁的秘密让他身败名裂。
所以大富翁管理他所画的饼很重要。
那么对于我们所学习的知识来说,操作系统就相当于是大富翁;大富翁的10亿美金就相当于是物理内存空间;大富翁画的饼就是虚拟地址空间;而各个私生子,就相当于是进程。
所以我们可以这样说:虚拟地址空间就是操作系统给各个进程画的大饼。
操作系统向每个进程承诺,你拥有一块独立、完整、连续且巨大的内存空间,进程全程只需要使用这块虚拟地址进行代码运行、变量存储,无需关心真实物理内存的大小、分布与使用情况,也感知不到其他进程的存在。
但这块内存空间并非真实存在的物理内存,只是内核在软件层面规划出的逻辑地址范围。内核通过 mm_struct 结构体记录这块 “大饼” 的整体布局,划分好代码段、数据段、堆、栈等各个区域,同时为其建立专属页表。当进程真正访问内存时,CPU 内部的 MMU 硬件会根据页表,把虚拟地址实时映射到真实的物理内存地址,实现按需分配、真实内存使用。
进程始终只看得见这张完整的虚拟大饼,物理内存的真实分配、碎片处理、多进程共享等底层细节全部由操作系统隐藏管理。
那么书面上的正式概念是这样介绍虚拟地址空间的:
虚拟地址空间是操作系统为每个进程分配的独立、连续的逻辑地址区域,它并非真实的物理内存地址,而是由操作系统通过 MMU(内存管理单元,属于硬件,作用是将虚拟地址转换成物理地址)与页表机制,在虚拟地址和物理内存地址之间建立映射,让进程以为自己独占整块连续内存。在软件层面上,每一个进程的虚拟地址空间由一个叫做 mm_struct 的结构体描述:

对进程而言,虚拟地址空间是一个统一、规整的内存布局,通常从低地址到高地址依次分为:代码段、数据段、BSS 段、堆区、共享库 / 文件映射区、栈区,以及内核空间。这种设计让每个进程都拥有独立的地址空间,进程之间互不干扰、无法直接访问彼此内存,极大提升了系统的稳定性与安全性。
3.4 区域划分
那这个虚拟地址空间就如我们所看到的这样划分的吗?

为什么要这样划分?又是如何划分的呢?
首先我们来看一下Linux内核的源码:

我们会发现有 start_code、end_code、start_data 、end_data这样的变量,这实际上存储的就是每一块区域的起始位置和结束位置。当你修改变量值的时候,就可以控制虚拟地址空间里的每一块区域的大小。但是大家会发现,这里的变量类型都是unsigned long类型,但是在虚拟地址空间里里面,不应该存储的都是地址吗,那这里起止位置为什么都不是char* 类型的指针呢?
实际上:mm_struct 这里存储的所有起止地址,仅仅是地址的「数值编号」,用来描述虚拟地址空间各个段的边界:比如 [start_code, end_code) 描述代码段的整个区间、[start_brk, brk) 描述堆的范围、[arg_start, arg_end) 描述命令行参数区域。内核只用这些变量做区间大小计算、地址大小比较、边界校验(比如判断一个地址落在哪个段里),永远不会通过这些变量去解引用访问内存。而 char* 属于指针类型,自带内存访问的语义,会造成类型语义混淆,甚至引发非法内存访问风险。
并且,内核需要频繁对地址做差值计算、大小比较、地址对齐运算,unsigned long原生支持无符号整数运算,无需额外强制类型转换;若使用char*指针,指针减法会生成有符号差值,极易在大地址跨度计算中出现溢出错误。
3.5 虚拟地址空间的意义
首先最重要的一点:
进程本身无法直接访问真实的物理内存地址,程序运行过程中使用的全部都是虚拟地址。所有虚拟地址想要访问物理内存,都必须经过MMU 硬件结合内核页表进行地址转换。操作系统通过页表对每一块虚拟地址都设置访问权限、映射关系与合法范围,mm_struct 结构体则统一管理进程各内存段的边界范围。
当进程出现越界访问、非法地址访问、访问其他进程内存、访问内核受限区域等违规行为时,地址转换过程会直接失败,MMU 会触发缺页异常或保护异常,直接拦截本次内存访问,由内核终止非法操作甚至直接终止异常进程。
其次还有如下几点:
-
实现进程内存隔离
每个进程都拥有一套完全独立、互不干扰的虚拟地址空间,进程之间无法随意访问彼此内存,单个进程内存出错、越界访问都不会影响其他进程与操作系统内核,从底层避免进程相互破坏,提升系统稳定与安全。
-
实现内存地址统一
操作系统统一规划好地址布局,固定划分代码段、数据段、BSS 段、堆、栈、参数环境区、内核空间。所有程序运行时都使用固定布局的虚拟地址,程序编译、链接时无需考虑物理内存真实地址,也不用关心物理内存如何分布、是否连续,极大降低程序开发难度。
-
解决物理内存碎片问题
物理内存是零散分配的,而虚拟地址空间是连续完整的。通过 MMU 地址映射,可以把物理上分散不连续的内存块,映射成进程视角下连续完整的虚拟地址。进程无需感知物理内存碎片,操作系统可以灵活调度、复用物理内存,提升整体内存利用率。
-
实现内存共享与动态内存扩展
多个进程可以将同一块物理内存(如动态库、系统库)映射到各自不同的虚拟地址,实现内存共享,减少内存重复占用。同时内存按需分配,进程不需要一次性申请全部内存,真正使用时再由内核分配物理页,实现内存动态扩容。
-
统一地址权限管理
所有进程虚拟地址空间高地址统一共享内核空间,所有进程无需切换即可访问内核资源。同时操作系统可以通过页表统一管理每一块内存的访问权限,严格限制进程访问范围,完成权限校验。
-
支持程序灵活加载与交换分区
借助虚拟地址映射,系统可以将暂时不用的内存数据换出到磁盘交换区,需要时再换入物理内存,实现内存超用,让程序可用内存远超实际物理内存大小。
3.6 虚拟内存管理
我们知道:mm_struct 作为进程虚拟地址空间的整体内存描述符,内部通过不同结构分别管理各类内存区域。其中代码段、数据段、堆、栈等传统固定内存段,通过独立的起止地址变量进行描述。
而结构体中还有一个成员:mmap。 mmap 成员是虚拟内存区域链表的头指针,其指向类型为 vm_area_struct 的链表结构。每一个 vm_area_struct 节点都会描述一段独立的虚拟地址区间,记录该区域的起止地址、访问权限、映射来源等信息。
该链表主要用于统一管理进程虚拟地址空间内所有动态分配、非传统的内存区域,包括动态链接库、文件内存映射、匿名映射、共享内存以及用户通过 mmap 系统调用申请的各类映射区域。也就是虚拟地址空间里,堆和栈之间的大片共享区,全部全部由这个 mmap 链表来管理。内核通过遍历该链表,即可管理进程全部动态虚拟地址区间,结合页表完成地址映射,实现对进程整个虚拟地址空间完整全面的管理。
用一段大白话说就是:
mm_struct 是整个虚拟空间大管家。代码、数据、堆、栈这些固定老区域,单独用变量记位置。剩下所有动态新加的、共享库、文件映射、自己申请的大片内存,全部串成一条链表,mmap 就是这条链表的开头。
但是,我们刚刚所说的mmap 是 Linux 内核旧版本的逻辑!
在现代Linux 内核,整个进程虚拟地址空间里,所有的内存区域,没有例外,全部都挂在 mmap 链表上。也就是:代码段、数据段、BSS、堆、文件映射区、栈,每一块独立的虚拟内存区域,全都是一个独立的 vm_area_struct 结构体节点。
而中间每一个 vm_area_struct = 虚拟地址里一块独立连续内存区域,我们把它称之为:VMA = Virtual Memory Area,虚拟内存区域。

首先大家肯定会疑惑一个问题:为什么vm_area_struct 这个结构体里面,明明它只是存放信息的结构体,为什么不叫 “虚拟内存区域描述符”,反而直接把它叫做虚拟内存区域(VMA)本身?
是因为在 Linux 内核内存管理机制中,进程虚拟地址空间内的每一块连续地址区间本身不存在独立的实体结构,仅依靠该结构体完成定义、标识与管理。每一个vm_area_struct结构体实例与一块独立的虚拟地址区间严格一一对应,结构体所记录的各项信息完整包含了该内存区域的全部边界、权限与属性特征,结构体存在则代表对应内存区域存在,结构体被释放则代表该区域销毁,二者完全绑定不可分割。因此内核领域直接将struct vm_area_struct结构体节点统称为 VMA,也就是对应的虚拟内存区域。
那么为什么需要VMA?为什么每一个单独的区域都要划分一个VMA呢?
其根本原因在于进程虚拟地址空间中不同内存区域的访问权限、数据来源、属性特征及管理逻辑存在显著差异,无法通过统一的结构进行管控。
首先,每一块内存区域的访问权限完全不同,代码段需设置为只读可执行以防止程序指令被篡改,数据段、BSS 段、堆和栈需设置为可读可写以支持数据的存储与修改,共享库映射区则需结合只读与可执行权限,独立的 VMA 节点可单独绑定一套访问权限,内核与 MMU 在进行内存访问校验时,只需定位到地址所属的 VMA,就能快速判断访问行为是否合法,从底层规避非法访问风险。
其次,不同区域的数据来源和缺页异常处理逻辑截然不同,代码段和数据段的数据来源于磁盘上的 ELF 可执行文件,缺页时需从磁盘加载对应数据;堆和栈属于匿名内存区域,缺页时内核需直接分配空白物理内存页;文件映射区则需从绑定的磁盘文件中读取数据,独立的 VMA 节点会存储自身的数据来源、文件偏移及缺页处理函数,确保缺页时能精准执行对应逻辑,保障程序正常运行。其中所谓的缺页异常是指:CPU 通过虚拟地址访问内存,MMU 去页表查找映射时,发现这个虚拟地址没有对应的物理内存页(没有建立映射、物理页不在内存),于是 CPU 触发异常,暂停当前进程,主动通知操作系统内核来处理,这个事件就叫做缺页异常。
此外,各内存区域的地址增长方式和属性也相互独立,代码段、数据段的地址范围固定不变,堆区向上增长、栈区向下增长,映射区可动态新增、删除或调整,独立的 VMA 节点能单独管理各自的起止地址,避免不同区域的管理相互干扰。同时,这种设计实现了内核对虚拟内存的统一化管理,现代 Linux 内核已摒弃早年零散的内存区域描述变量,将所有内存区域(包括代码段、数据段、堆、栈等)均统一视为 VMA 节点,通过双向链表串联挂载于 mm_struct 的 mmap 指针之下,内核只需遍历该链表,即可完成地址合法性检查、内存权限管控、缺页异常处理等所有核心操作。
本文到此结束,感谢各位读者的阅读,如果有讲解的不到位或者错误的地方,欢迎各位读者批评或指正。
更多推荐




所有评论(0)