程序地址空间回顾(这真的是吗?)

我们之前学习 C/C++ 的时候是否听过:对于自己的 C/C++ 程序,我们默认认为自己的内存地址空间是:

代码区(Text Segment:存放程序的机器指令代码,这部分内存通常是只读的,以防止程序意外修改自身的指令。

数据区(Data Segment:进一步细分为初始化数据区和未初始化数据区(BSS段)。初始化数据区存放程序中已初始化的全局变量和静态变量;未初始化数据区(BSS段)存放未初始化的全局变量和静态变量,这些变量在程序启动时会被自动初始化为零。

堆区(Heap Segment:用于动态内存分配,如通过malloccallocrealloc等函数分配的内存。堆区的大小在程序运行时可以动态增长或缩小。

栈区(Stack Segment:用于存储函数的局部变量、函数调用的上下文信息(如返回地址、函数参数等)。栈区的大小通常在函数调用时自动分配和释放,遵循后进先出(FIFO)的原则。

这些区域共同构成了程序的内存布局,每个区域都有其特定的用途和管理方式,确保程序能够高效、安全地运行。

下面是我们的空间布局图:

可是我们对他并不理解!可以先对其进行各区域分布验证:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int g_unval;//未初始化的全局变量
int g_val = 100;//已初始化的全局变量


int main(int argc, char* argv[], char* env[])
{
	const char* str = "helloworld";//字符串常量
	printf("code addr: %p\n", main);
	printf("init global addr: %p\n", &g_val);
	printf("uninit global addr: %p\n", &g_unval);

	static int test = 10;
	char* heap_mem = (char*)malloc(10);
	char* heap_mem1 = (char*)malloc(10);
	char* heap_mem2 = (char*)malloc(10);
	char* heap_mem3 = (char*)malloc(10);
	printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
	printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
	printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
	printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)

	printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
	printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
	printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
	printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
	printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)

	printf("read only string addr: %p\n", str);
	for (int i = 0; i < argc; i++)
	{
		printf("argv[%d]: %p\n", i, argv[i]);
	}
	for (int i = 0; env[i]; i++)
	{
		printf("env[%d]: %p\n", i, env[i]);
	}

	return 0;

}

我们编译后./code运行:

我们栈的地址空间是减小的,向低地址的地方增长,堆区向上增长,向高地址增长;

我们发现栈和堆的地址差异很大,因为有一个共享区,这是一大段的镂空空间;

我们发现字符串常量的地址和代码地址差异不大,其实我们平时定义的字符串是被编译器硬编码到代码的,代码是只读的,所以字符串常量就是只读的;

一个局部变量是在栈上的,加了一个static修饰后,整体变量还是局部的,只是生命周期变成全局的了,其实static修饰的地址是和全局变量的地址接近的,所以static就是全局变量区的,不过只是只能在自己的作用域活动罢了。

虚拟地址

我们曾经学习的程序地址空间,是内存吗?

答案是:他根本就不是内存,为什么呢?你想象一下,如果这货是内存,我们把代码按照这种方式排布了,这让其他进程怎么办!一个Linux里一次跑十几二十几进程很正常,我们将内存这么有规律的排,那么其他进程应该怎么办!而且有进程在创建,就有进程在退出,所以这货是内存的话,我们对应的内存空间是无法保证进程都是这样布局的,所以他不是内存。

在谈他到底是什么的时候,我们来更改一下我们之前的错误认识:

他不是内存地址空间,他被称为:进程地址空间,也叫做虚拟地址空间!!! 他是一个系统的概念,不是一个语言层的概念。

我们通过下面的代码来证明他不是物理内存:

#include <stdio.h>
#include <unistd.h>

int gval = 100;

int main()
{
    int id = fork();
    if(id == 0)
    {
        //child
        while(1)
        {
            printf("子进程:gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());
            sleep(1);
            gval++;
        }
    }
    else
    {
        //parent
        while(1)
        {
            printf("父进程:gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());
            sleep(1);
        }
    }
    return 0;
}

由我们之前的认识:子进程会修改变量,父进程能看到吗?我们知道父进程看不到,因为有写时拷贝(进程要保持独立性),所以我们的重点是:那地址呢?

我们编译运行:

我们发现父子进程中的变量的地址是一样的,这意味着:如果父子进程对应的gval变量地址一样,如果对应的地址是内存地址,那么此时,我们就出 bug 了!:你读的是 105,我读的是 100?!

所以我们可以断定,这个地址,一定不是内存地址,根本就不是物理内存的地址,他称为虚拟地址!我们之前C/C++指针用到的地址,全部都是虚拟地址!!!(只要是进程,用到的都是虚拟地址)

总结:

变量内容不一样,所以父子进程输出的变量绝对不是同一个变量;但地址值是一样的,说明,该地址绝对不是物理地址!在Linux地址下,这种地址叫做虚拟地址;我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。OS必须负责将虚拟地址转化成物理地址。【这个转化时候面页表相关的话题了】

一个进程,一个虚拟地址空间

现代操作系统通常为每个进程分配一个独立的虚拟地址空间这意味着每个进程都有自己的地址空间,进程之间的地址空间是隔离的,一个进程无法直接访问另一个进程的地址空间。这种隔离机制有助于提高系统的稳定性和安全性,防止进程之间的相互干扰。

虚拟地址空间在32 位的机器下,虚拟地址空间的范围是 2^{32} 个地址(0X00000000-0XFFFFFFFF),所以32 位的机器:2^{32} 字节 =4GB,对应的,64 位机器,就是 个地址,总的地址空间的容量就非常大了。

0G3G】是用户空间,我们用户能够拿到地址 / 名字(环境变量)的话,就可以直接访问对应的地址的内容;【3G4G】是内核空间

实际上我们代码在编译的时候,编译之后的变量名还在吗?int a=10;.......

实际上,经过编译后,变量名会被转换成对应的内存地址或寻址方式。在访问自己写的代码、命令行参数、环境变量以及堆和栈上的数据时,这些资源都位于用户空间。通过获取相应的内存地址,我们可以直接访问这些资源,因此我们能够使用变量名进行操作。这就是所谓的用户空间概念。

进程地址空间 

所以之前说‘程序的地址空间’是不准确的,准确的应该说成 进程地址空间,那该如何理解呢?

我们对于虚拟地址空间会谈到 4 次,每一次都会在原有基础上新增部分知识,因为虚拟地址空间这个东西,我们要将他一次性谈清楚比较难受的,因为他即跟硬件有关,比如说跟CPU有关,他又跟操作系统有关,比如说它内部又有用户态,内核态这样的概念,他又跟编译器,可执行程序,以及动静态库也有关,同时也跟线程有关系,所以虚拟地址空间这个话题在我们今天讲的重点是建立起来虚拟地址空间这个事实,是什么?为什么?这两个话题。(后续谈及:本篇 - 动静态库 - 进程间通信 - 进程信号)

分页 & 虚拟地址空间

页表(Page Table是操作系统中用于实现虚拟内存管理的一种数据结构。它的主要作用是将虚拟地址转换为物理地址,从而允许程序使用虚拟内存地址而不是直接使用物理内存地址进行操作。(页表是用来做虚拟地址和物理地址映射的!!!)

之前我们认识到:子进程的很多东西,包括对应的task_struct都是拷贝自父进程的,他把自己父进程的task_struct数据给自己拷贝一份,把个别的属性自己更改,上面也知道了,我们一个进程,一个虚拟地址空间,一套页表,所以我们的子进程也要有自己对应的的虚拟地址空间和页表。

子进程的task_struct是拷贝自父进程的,那么虚拟地址空间也是拷贝自父进程的,那我们一个进程,一套页表,子进程的页表内容也是拷贝自父进程的页表内容,所以,子进程的PCB,虚拟地址空间,还有页表都是要从父进程那里拷贝,一旦拷贝,就意味着:

在子进程的初始化全局数据区里面,也同样会存在一个叫做全局变量g_val,有对应的g_val的虚拟地址。同时,因为页表内容是地址级别的,所以我们拷贝发生的是浅拷贝,上面的测试发现父子进程的全局变量地址相同是因为子进程拷贝了父进程的虚拟地址。

到这,我们就可以理解了,为什么全局变量默认的时候是被父子共享的,因为他们从虚拟地址到物理地址的映射关系是一样的,导致父子进程g_val的地址同时指向同一个物理内存,后来,我们的故事就发展成子进程要对变量要修改,触发了写时拷贝【页表映射的键值地址不变,指向的物理地址发生申请创建,使用】

所以我们要清楚:进程具有独立性,子进程通过g_val对应的虚拟地址,通过页表,找到对应的物理地址,让后对g_val值进行修改,但是这样的话,父进程对应的g_val值不也发生改变了吗?

所以,操作系统会为我们做:

操作系统为了保证进程间的独立性,会采用 写时复制(Copy-On-Write, COW 策略。在这种策略下,当创建子进程时,父子进程最初共享相同的物理内存页,但操作系统会延迟复制这些页,直到其中一个进程尝试修改它们。这样,当子进程修改全局变量 g_val 时,操作系统会自动创建 g_val 的一个新副本,确保子进程的修改不会影响父进程。(映射的指向的物理空间变成新副本的位置了,修改了映射关系)

此外,操作系统还通过内存保护、独立的地址空间和页表隔离等机制,确保进程间的内存操作是独立的。每个进程都有自己的虚拟地址空间页表,操作系统会确保这些映射是独立的,一个进程对映射的修改不会影响另一个进程。当进程需要共享数据时,操作系统提供了 进程间通信(IPC 机制,如管道、消息队列、共享内存等,允许进程在保持独立性的同时安全地共享数据。通过这些机制,操作系统确保了进程的独立性和安全性,防止了一个进程的操作对其他进程造成不良影响。

上面的图就足以说明问题,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址

下面需要赶紧过渡一下了

如何理解虚拟地址空间?

大富翁的例子在北美,有一个手里有10亿$的大富翁,私生活比较混乱:

这个大富翁的私生子之间互相不认识,有一天:大富翁对私生子 1 说:

你现在 30 多岁了,我听说你在做生意,你好好做,将来你老爹我要是去天堂了的话,我的十个亿美元的家产就是你的了。

私生子 1 老开心了。过了几天,大富翁又找到他的私生子 2,说:

姑娘,你现在是不是在读博呢?你好好读,做一份优秀的博士论文,将来我要是驾鹤西去了,我的的10亿美元的家产就是你的了。

私生子 2 老开心了。过了几天,大富翁又找到他的私生子 3,说:

把你的头发收拾一下,天天听见你的舍友说你天天不出宿舍们门,天天打游戏,打什么游戏呢!你要是这样,我那10亿没有的家产就不给你了。

私生子 3 老开心了,说要励志好好读书。过了几天,大富翁又找到他的私生子 4,说:

儿子呀,听你们的老师说,你喜欢弹钢琴,你是不是以后想成为演奏家呢?成为了我以后飞了,那十亿美元的家产就是你的了。私生子 4 老开心了。

私生子们都想着以后能够有 10 个亿,但是不可能直接找大富翁要,还没有挂呢。大富翁本质是在给这些私生子画大饼!!!

图中显示了一个操作系统(大富翁)和多个进程(私生子)。每个进程都有自己的虚拟地址空间(饼),并且操作系统管理着物理内存(10 亿美元)

随着时间的推移,大富翁(操作系统)开始考虑如何公平地分配他的财富(物理内存)。他意识到,虽然他有10亿美元(大量的物理内存),但是这些财富需要被合理地分配给每个私生子(进程),以确保他们都能成功。

私生子 1(进程 1)继续经营他的生意,他需要更多的资源来扩展业务。大富翁(操作系统)通过虚拟内存管理,为他分配了更多的虚拟地址空间(饼),让他感觉像是拥有了更多的财富(内存)。

私生子 2(进程 2)在学术上取得了进展,她需要运行复杂的模拟程序。大富翁(操作系统)同样通过虚拟内存技术,为她提供了所需的资源,尽管物理内存是有限的。

私生子 3(进程 3)和私生子 4(进程 4)也各自在学业和音乐上有所追求,大富翁(操作系统)也都给予了他们相应的支持。

然而,私生子们(进程们)并不知道,他们所感觉到的财富(虚拟内存)实际上是大富翁(操作系统)通过巧妙的内存管理技术 “借” 给他们的。操作系统通过分页、分段、以及写时复制等技术,确保每个进程都能高效地使用内存,而不会相互干扰。

最终,大富翁(操作系统)成功地管理了他的财富(物理内存),使得每个私生子(进程)都能在他们各自的领域中取得成功,而不会感觉到资源的匮乏。私生子们(进程们)也意识到,他们之所以能够成功,不仅仅是因为大富翁(操作系统)的慷慨,更是因为他的智慧和高效的资源管理能力。

这个故事说明了操作系统如何通过虚拟内存管理技术,为每个进程提供独立的虚拟地址空间,使得它们能够高效、安全地运行,即使在物理内存有限的情况下。

所以管理就是先描述,再组织,每个饼用链表链接,对饼的管理就变成了对链表的增删查改!所以虚拟地址空间本质就是一个数据结构,这个数据结构在Linux当中叫做:struct mm_struct

如何理解区域划分?--- 38线的例⼦

虚拟内存区域划分是操作系统中用于管理进程地址空间的一种机制。通过将虚拟地址空间划分为多个区域(VMA),操作系统可以更灵活地管理内存,实现内存保护、隔离、共享和动态调整等功能。以下是对区域划分的深入理解:

逻辑分离:每个区域(VMA)代表进程地址空间中的一个逻辑部分,如代码段、数据段、堆、栈等。这种划分确保了不同类型的内存访问不会互相干扰。

权限控制:每个区域可以设置不同的访问权限,如可读(R)、可写(W)、可执行(X)等。例如,代码段通常是只读和可执行的,而数据段是可读和可写的。

内存映射:区域划分支持将文件或设备映射到进程的地址空间中。例如,可以将一个文件映射到内存中,使得进程可以直接访问文件内容,而无需频繁的磁盘I/O操作。

这个 mmap 我们提前说一下:

内存映射(mmap)的核心本质,是操作系统先将磁盘文件数据载入内核物理内存页缓存,再通过修改页表,把进程虚拟地址空间的虚拟页映射绑定到这块物理内存页并不是磁盘直接映射到虚拟内存。它的优势不是省去磁盘 IO,而是省去 read/write 里「内核物理页缓存再拷贝一份到用户态缓冲区」的 CPU 内存拷贝,同时减少频繁系统调用。

先看普通 read/write 的读写流程:读文件时,首先需要通过磁盘 IO 将数据从磁盘加载到内核页缓存,接着还要经过一次 CPU 拷贝,把内核页缓存中的数据复制到应用程序自己的用户态缓冲区,应用程序才能从缓冲区中读取数据;写文件时流程则相反,应用程序先将数据写入自身的用户态缓冲区,再通过 CPU 拷贝把数据传递到内核页缓存,最后通过磁盘 IO 将内核页缓存中的数据刷回磁盘,整个过程不仅有两次数据拷贝,还需要每次读写都进行系统调用,频繁陷入内核态,IO 开销较大。

mmap 的工作机制完全不同:每个进程都拥有独立的虚拟地址空间,分为用户态空间(包含代码、堆、栈、动态库和专门的内存映射区)和内核态空间(包含页缓存、内核代码、驱动等)。当进程调用 mmap() 时,内核会先打开目标文件,并将文件的部分内容通过磁盘 IO 载入内核页缓存,随后关键动作是修改页表,把进程虚拟地址空间中一段指定的虚拟地址,直接映射到内核页缓存对应的物理内存页,这个过程中并不会进行数据拷贝,只是建立起 “虚拟地址→物理页→文件磁盘块” 的映射关系。

当进程通过指针访问这片映射后的虚拟内存时,第一次访问会触发缺页异常,内核会按需将磁盘对应区块通过磁盘 IO 载入物理页缓存,之后的访问就可以直接通过页表寻址,像操作普通内存一样读写文件;写数据时,进程修改的其实就是内核页缓存本身的物理页,这些被修改过的 “脏页”,会由内核通过定期刷盘或手动调用 msync 函数的方式后台异步刷回磁盘,无需应用程序手动执行 write 操作。

值得注意的是,mmap 并没有省去磁盘 IO,磁盘该读的还是要读、该写的还是要写,物理 IO 一次都无法省略,它改变的是 IO 的时机和方式:read/write 是主动、即时的 IO,且每次读写都需要系统调用;而 mmap 是缺页时才触发读 IO,写 IO 则由内核后台异步完成,对应用程序来说几乎无感。此外,mmap 还具备内核页缓存全局共享的特性,多个进程映射同一个文件时,会共享同一份物理页缓存,能有效节省内存;同时采用懒加载机制,不会一映射就将整个文件载入内存,而是用到哪一页才加载哪一页,进一步提升了资源利用效率。

简单来说,mmap 就像管理员直接把图书馆的原书借给你,你可以直接在原书上写写画画,看完后管理员会后台帮你把书放回书架,省去了复印书本(对应 CPU 数据拷贝)的步骤,而书从书架取出、放回书架的过程(对应磁盘 IO)依然存在;而 read/write 则像是管理员先把书复印一份递给你,你在复印件上操作,之后还要把复印件还给管理员,管理员再根据复印件修改原书并归档,多了一次拷贝的额外开销。

# ============================================== 完整对比总图 ==============================================
# 左侧:传统 read/write 读写流程                右侧:mmap 内存映射读写流程
# ==========================================================================================================

【应用程序】                                    【应用程序】
      │                                               │
      │ 1.调用read/write系统调用                      │ 1.调用mmap建立映射,得到虚拟地址指针
      ▼                                               ▼
【用户态缓冲区】                                【进程虚拟地址空间 · 映射区】
      │                      无CPU数据拷贝               │
      │ 2.CPU内存拷贝                                   │ 2.内核修改页表
      ▼                                               ▼
【内核态 · 页缓存(物理内存)】                   【内核态 · 页缓存(物理内存)】
      │                                               │
      │ 3.磁盘IO读写                                   │ 3.缺页异常按需磁盘IO / 脏页异步刷盘
      ▼                                               ▼
【磁盘外设 · 文件】                             【磁盘外设 · 文件】

# ==========================================================================================================
# ===================== 左边:read/write 拆解每一步(读流程)=====================
1 应用 → 发起 read() 系统调用,陷入内核态
2 磁盘 --(磁盘IO)--> 内核页缓存(物理内存)
3 内核页缓存 --(CPU拷贝)--> 用户态缓冲区
4 应用直接读取自己的用户态缓冲区

# 关键代价:
# 两次搬运:磁盘IO + 一次CPU内存拷贝
# 每次读写都要系统调用、切换用户态/内核态

# ===================== 右边:mmap 拆解每一步(读流程)=====================
1 应用调用 mmap(),内核不拷贝数据
2 内核建立【进程虚拟地址】 ↔ 【内核页缓存物理页】页表映射
3 应用用指针直接访问虚拟地址
4 首次访问触发缺页异常 → 磁盘 --(磁盘IO)--> 内核页缓存
5 后续访问:MMU硬件页表寻址,直接操作物理页,**无CPU拷贝**

# 关键特点:
# 只有磁盘IO,**省去用户态↔内核态CPU拷贝**
# 仅第一次映射一次系统调用,后续读写无系统调用

# ==========================================================================================================
# ===================== write 写流程对比 =====================
# 左:read/write 写
应用 → 用户态缓冲区 --(CPU拷贝)--> 内核页缓存 --(磁盘IO)--> 磁盘

# 右:mmap 写
应用直接修改 虚拟地址 → 对应内核物理页(脏页) → 内核后台异步 --(磁盘IO)--> 磁盘

内存保护:通过区域划分,操作系统可以实现内存保护,防止进程访问或修改不应该访问的内存区域。例如,用户进程无法直接访问内核空间,从而避免了用户进程对内核内存的非法修改。

动态调整:操作系统可以根据进程的内存需求动态地创建、扩展或收缩内存区域。例如,堆的大小可以动态增长,栈的大小也可以动态调整。

共享内存:进程之间可以通过共享内存区域来交换数据。这些共享区域在每个进程的虚拟地址空间中都有对应的映射。例如,多个进程可以共享同一块内存区域,用于进程间通信(IPC)。

隔离机制:每个进程的虚拟地址空间是独立的,通过虚拟地址到物理地址的映射进行隔离。这确保了进程之间的内存访问是隔离的,一个进程的错误不会影响其他进程的稳定性。

将虚拟内存区域划分类比为小时候男女同桌之间的 “38 线”,可以更直观地理解其作用:

楚河汉界:每个区域(VMA)就像课桌上的 “38 线”,将整个虚拟地址空间划分为不同的 “领土”。每个区域有其特定的用途和权限,确保不同类型的内存访问不会互相干扰。

互不侵犯:不同的区域之间是隔离的,一个区域的访问不会影响其他区域。这就像 “38 线” 确保了同桌之间互不干扰一样。

动态调整:随着进程的运行,虚拟内存区域可能会动态地创建、扩展或收缩,就像 “38 线” 的位置可能会根据实际情况进行调整。

共享区域:在某些情况下,进程之间可能需要共享某些内存区域,就像同桌之间可能会共享某些学习用品。操作系统提供了共享内存等机制来支持进程间的内存共享。

保护机制:虚拟内存区域的划分也起到了保护作用,防止进程访问或修改不应该访问的内存区域,就像 “38 线” 防止同桌之间发生 “领土纠纷”。

通过这种类比,我们可以更直观地理解虚拟内存区域划分的概念。每个区域都有其特定的用途和权限,区域之间是隔离的,操作系统通过这种划分来实现内存保护、隔离和共享等功能。

虚拟内存管理 [ 第一讲 ]

虚拟内存的工作原理

虚拟地址空间的申请物理内存的分配与映射。它们之间的关系是通过页表来实现的。我来详细解释一下:

虚拟地址空间中申请指定大小的空间:

虚拟地址空间是操作系统为每个进程分配的一块逻辑内存空间。它与物理内存是分离的,进程只能通过虚拟地址来访问内存。当进程需要使用内存时,它会向操作系统请求一块指定大小的虚拟内存空间。

例如:程序运行时需要动态分配内存(如使用malloc函数),操作系统会在进程的虚拟地址空间中分配一块内存区域,并返回一个虚拟地址。


虚拟地址空间的特点:

虚拟地址空间是连续的,但实际的物理内存可能并不连续。

虚拟地址空间的大小通常远大于物理内存的大小,因为操作系统可以通过交换空间(swap分区)(如磁盘)来扩展可用内存。


加载程序,申请物理空间

当进程需要使用虚拟地址空间中的内存时,操作系统需要将虚拟地址映射到物理内存。这个过程通常发生在以下情况:

程序加载时:操作系统将程序的代码和数据加载到物理内存中,并建立虚拟地址到物理地址的映射。

页面故障(Page Fault)时:当进程访问的虚拟地址对应的物理内存页不在内存中时,操作系统会触发页面故障,加载相应的页面到物理内存,并更新页表。

虚拟和物理两者通过页表进行映射关联:

页表是虚拟内存管理的核心数据结构,它记录了虚拟地址到物理地址的映射关系。

页表的作用:从虚拟地址到物理地址的映射。当进程访问虚拟地址时,CPU的内存管理单元(MMU)会通过页表查找对应的物理地址。


假设一个进程请求了 1024 字节的内存:

虚拟地址空间申请:操作系统在进程的虚拟地址空间中分配了一块大小为 1024 字节的区域,返回一个虚拟地址(如0x1000)。

物理空间申请:操作系统在物理内存中找到一块空闲的 1024 字节区域(假设物理地址为0x2000)。

页表映射:操作系统在页表中创建一个条目,将虚拟地址0x1000映射到物理地址0x2000

当进程访问虚拟地址0x1000时,MMU会通过页表找到对应的物理地址0x2000,并从物理内存中读取数据。

虚拟地址空间申请:操作系统在逻辑上为进程分配内存。

物理空间申请:操作系统在物理内存中分配实际的内存空间。

页表映射:页表将虚拟地址映射到物理地址,使得进程可以通过虚拟地址访问物理内存。


内存描述符 mm_struct 结构解析

mm_struct 是 Linux 内核中用于描述进程的虚拟内存空间的数据结构。它内嵌在 task_struct 结构中,表示一个进程虚拟地址空间。mm_struct 结构体中包含了许多成员,用于描述和管理虚拟内存区域(VMA)。

struct task_struct
{
    /* ... 其他进程信息 ... */

    // 指向进程【拥有】的虚拟地址空间(用户态 + 内核态)
    // 普通用户进程:有效(非NULL)
    // 内核线程:NULL(内核线程不拥有独立的用户地址空间)
    struct mm_struct *mm;

    // 指向【正在使用】的地址空间
    // 普通进程:等于 mm
    // 内核线程:运行时借用上一个进程的 active_mm(所有内核线程共享内核地址空间)
    struct mm_struct *active_mm;

    /* ... 其他进程信息 ... */
};

可以说,mm_struct结构是对整个用户空间的描述。每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰。先来看看由task_structmm_struct,进程的地址空间的分布情况:

mm_struct 结构体定义在 include/linux/mm_types.h 中,其中的域抽象了进程的地址空间。它包含了指向虚拟内存区域(VMA)的链表、指向线性区对象红黑树的根、任务虚拟内存的大小等信息。此外,mm_struct 还包含了代码段和数据段的起始和结束地址,以及堆和栈的起始地址等。

在虚拟内存管理中,每个进程或线程都包含一个 mm_struct 结构体,该结构体描述了进程的用户虚拟地址空间。mm_struct 中重要的成员包括 mmap,它指向虚拟内存区域(VMA)的链表,以及 task_size,表示进程虚拟内存的大小。mm_struct 还负责管理页表,页表是虚拟内存与物理内存映射关系的核心数据结构,它确保了每个进程的虚拟地址能够被正确地转换为物理地址,从而实现内存保护和隔离。

struct mm_struct {
    /* 虚拟内存区域链表 */
    struct list_head mmap;               /* 虚拟内存区域链表头 */
    struct rb_root mm_rb;                /* 虚拟内存区域红黑树根 */

    /* 页表基地址 */
    pgd_t *pgd;                          /* 页表基地址 */

    /* 任务虚拟内存大小 */
    unsigned long task_size;             /* 用户空间的大小 */

    /* 代码段起始和结束地址 */
    unsigned long start_code, end_code;  /* 代码段的起始和结束地址 */

    /* 数据段起始和结束地址 */
    unsigned long start_data, end_data;  /* 数据段的起始和结束地址 */

    /* 栈起始地址 */
    unsigned long start_stack;           /* 栈的起始地址 */

    /* 内存映射区域引用计数 */
    atomic_t map_count;                  /* 内存映射区域的引用计数 */

    /* 内存锁 */
    spinlock_t lock;                     /* 保护 mm_struct 的锁 */

    /* 其他字段 */
    unsigned long arg_start, arg_end;    /* 命令行参数的起始和结束地址 */
    unsigned long env_start, env_end;    /* 环境变量的起始和结束地址 */
    unsigned long brk;                   /* 堆的当前大小 */
    unsigned long rss;                   /* 常驻内存集大小 */
    unsigned long total_vm;              /* 总虚拟内存大小 */
    unsigned long nr_ptes;               /* 页表项数量 */
};

上面是 mm_struct 的代码定义及其主要字段的解释。mm_struct 是 Linux 内核中用于描述进程虚拟地址空间的核心数据结构,定义在 include/linux/mm_types.h 中。 

所以是什么:我们重点要知道的是虚拟地址空间是一个结构体,他不是内存,通过页表的映射实现将虚拟地址转换为物理地址,从而实现对物理内存的访问。这种映射机制使得每个进程都拥有独立且连续的虚拟地址空间,而物理内存的分配可以是分散的,操作系统通过页表来管理这种映射关系。

页表的映射功能不仅实现了虚拟地址到物理地址的转换,还提供了内存保护和访问权限控制,防止进程访问不属于它的内存区域。此外,这种机制允许程序在物理内存中的任意位置加载,简化了程序员的内存管理工作。


虚拟空间的组织方式

进程虚拟地址空间被切成很多块 VMA(虚拟内存区域)比如:

代码段、数据段、堆、栈、共享库、文件映射、mmap 区域……

每一块 一个 VMA 对象

这么多 VMA,内核要快速遍历、快速查找、快速插入合并,所以搞了 链表 + 红黑树 双结构

在Linux操作系统中,每个进程都有一个独立的mm_struct结构,用于描述其虚拟地址空间。操作系统通过mm_struct来管理进程的虚拟内存区域(VMA),每个VMA由vm_area_struct结构表示。

vm_area_struct 一个对象 = 一段连续虚拟地址区间比如:

  • 0x08048000 ~ 0x08049000 代码段
  • 0x08049000 ~ 0x08050000 数据段
  • 堆、栈、libc 共享库、mmap 映射区……

一个进程十几甚至几十个 VMA 很正常。

为了高效管理这些VMA,Linux内核采用两种方式组织虚拟空间:

  1. 当虚拟区较少时,使用单链表来管理,mm_struct中的mmap指针指向这个链表。

  2. 当虚拟区较多时,使用红黑树进行管理,mm_struct中的mm_rb指针指向这棵树。

链表用途:从头到尾挨个遍历所有 VMA

适合场景:

  1. 进程退出 / 销毁地址空间内核要把这个进程所有 VMA 全部释放直接遍历链表,逐个 unmap、回收页表、释放物理页。

  2. 拷贝地址空间(fork 子进程)fork 时要复制父进程每一段 VMA 属性(权限、映射文件、私有 / 共享)顺着链表从头走到尾,逐个复制 VMA。

  3. 遍历校验、统计内存查看进程所有虚拟区间、统计总虚拟内存、遍历检查权限是否合法。

  4. VMA 区间合并你 mmap 两次相邻、权限一样的区域,内核会合并成一个 VMA需要顺着链表前后找相邻区间,判断能不能合并。

链表定位:适合 顺序遍历、批量操作、从头到尾过一遍

链表有致命缺点:

链表查找某一个地址属于哪一段 VMA,是 O(n)进程 VMA 多了、频繁缺页异常时,太慢了!

红黑树用途:按虚拟地址 快速查找 O (logn)

最核心高频场景:

场景 1:缺页异常 最关键

CPU 访问某个虚拟地址 0x12345678触发缺页异常,内核必须立刻知道:这个地址属于哪一个 VMA?权限是什么?是不是合法地址?

如果用链表:从头一个个比对地址区间,慢死。红黑树按起始地址排序,直接 O (logn) 定位到对应 VMA。

场景 2:mmap /munmap 分配、释放区间

申请一块虚拟地址、释放一块区间:内核要快速找空闲区间、冲突区间、前后相邻 VMA红黑树快速定位,不用遍历整条链表。

场景 3:地址合法性检查、权限检查

每次访问内存、系统调用校验地址,都要快速查所属 VMA 的读写执行权限。

红黑树定位:按地址快速查找、快速定位区间,O (logn)


结构 时间复杂度 核心作用 典型场景
双向链表 O (n) 遍历 从头到尾遍历所有 VMA fork 复制、进程销毁、遍历合并 VMA、内存统计
红黑树 O (logn) 查找 按虚拟地址快速定位某一段 VMA 缺页异常、mmap 地址查找、权限校验、地址冲突检测

vm_area_struct结构用于描述一个独立的虚拟内存区域,包括代码段、数据段、堆、栈等。通过这两种组织方式,Linux内核能够快速查找和管理进程的虚拟内存区域,从而提高内存管理的效率。

在Linux内核中,vm_area_struct结构体的定义位于内核源代码的<linux/mm.h>头文件中。以下是vm_area_struct结构体的代码内容及其注释,展示了它如何描述一个虚拟内存区域(VMA):

struct vm_area_struct {
    struct mm_struct *vm_mm;         // 指向拥有这个VMA的进程的内存描述符
    unsigned long vm_start;          // VMA的起始虚拟地址
    unsigned long vm_end;            // VMA的结束虚拟地址
    struct vm_area_struct *vm_next;  // 指向下一个VMA的指针,用于链表或红黑树结构
    pgprot_t vm_page_prot;           // 页面保护标志,定义了对该区域的访问权限
    unsigned long vm_flags;          // VMA的标志,如是否可读、可写、可执行等
    struct rb_node vm_rb;            // 红黑树节点,用于快速查找VMA

    // 文件映射相关
    struct file *vm_file;            // 如果VMA是文件映射,则指向对应的文件结构
    unsigned long vm_pgoff;          // 文件映射的偏移量,以页为单位

    // 内存管理相关
    struct anon_vma *anon_vma;      // 如果是匿名内存,则指向匿名VMA结构
    struct vm_operations_struct *vm_ops; // VMA的操作函数集合,用于处理特定类型的VMA
    unsigned long vm_private_data;   // 私有数据,用于存储VMA特定的附加信息

    // 内核内部使用
    struct list_head vm_lru;         // LRU链表节点,用于内存回收
    atomic_t vm_usage;               // VMA的使用计数,防止被过早释放
};

在Linux内核中,vm_area_struct结构用于表示一个独立的虚拟内存区域(VMA),它连接各个VMA的原因在于:

  1. 虚拟内存的多样性:进程的虚拟地址空间被划分为多个功能不同的区域,例如代码段、数据段、堆、栈、共享内存、映射文件等。每个区域的访问权限、属性和用途都可能不同。vm_area_struct结构能够详细描述这些区域的特性,包括起始地址、结束地址、访问权限、是否可共享等。

  2. 高效管理虚拟内存:通过vm_area_struct结构,Linux内核可以高效地管理这些不同的虚拟内存区域。内核需要快速定位和操作这些区域,例如在进程访问虚拟地址时,内核需要根据页表找到对应的VMA,以确定访问是否合法以及如何处理。vm_area_struct结构提供了必要的信息,使得内核能够快速完成这些操作。

  3. 动态内存管理:进程的虚拟地址空间是动态变化的,例如堆的扩展、文件映射的增加等。vm_area_struct结构允许内核动态地添加、删除或修改虚拟内存区域,而不需要重新组织整个虚拟地址空间。

  4. 支持复杂的内存操作vm_area_struct结构支持复杂的内存操作,例如内存映射(mmap)、内存解映射(munmap)、内存保护修改(mprotect)等。这些操作需要对虚拟内存区域进行精确的管理和控制,vm_area_struct结构提供了必要的灵活性和功能支持。

  5. 优化内存访问性能:通过将虚拟内存区域组织为链表或红黑树,Linux内核可以根据需要快速查找和操作特定的VMA。这种组织方式不仅提高了内存管理的效率,还减少了内存访问的延迟,从而提升了系统的整体性能。

总之,vm_area_struct结构是Linux内核管理虚拟内存的关键数据结构,它通过连接各个VMA,使得内核能够高效、灵活地管理进程的虚拟地址空间,支持复杂的内存操作,并优化内存访问性能。

为什么要有虚拟地址空间?

将地址从【无序】变【有序】

虚拟地址空间通过页表映射机制,将用户程序中的代码、数据等逻辑上连续的地址映射到物理内存中可能分散的地址上。对于用户来说,访问代码或初始化数据时,其地址始终是连续的,但实际的物理内存分配可以是分散的。这种映射关系将“无序”的物理内存变为“有序”的虚拟地址空间,使得用户无需关心代码和数据在物理内存中的具体位置,从而简化了程序员的内存管理工作,提高了编程的便捷性和效率。

地址转换的过程中,也可以对你的地址和操作进行合法性判定,进而保护物理内存!

写这篇的时候是除夕啦,新年快乐呀大家!!!

我们小时候收红包的时候可真的不是真收到了,妈妈会骗我说帮我保管的,说好话,说你想要买什么我给你买就对了,这钱就放我这,买的多少我给你多少就对了。

过了几天,我去找妈妈要💴买辣条,妈妈说:“吃什么辣条呢?!不许!”,直接对我的请求做了拦截,也就是对于今天虚拟地址去访问我们代码的时候,这时候OS要去查页表,而页表当中,还有几个对应的小条目,除了有虚拟地址条目,物理地址条目,还有rwx权限:

也就是我们去访问一个页表的时候,如果要对一个代码区进行写入,那么操作系统查看对应的页表,地址正常做转化,但是我们要进行w操作,但是我们对于代码区只有r权限,此时操作系统直接不给我们转化了,甚至将我们这个进程杀掉,这时候就可以实现对物理内存的保护!

总的来说:地址转换的过程中,也可以对你的地址和操作进行合法性判定,进而保护物理内存!

野指针问题可能会出现在页表映射过程中,当访问的虚拟地址没有对应的页表条目或映射到无效的物理地址时,就会导致类似 “野指针” 的问题。以下是一个具体的示例和解释:

示例:访问未映射的虚拟地址

假设一个进程的虚拟地址空间中,某些虚拟地址尚未被映射到物理内存。此时,如果程序尝试访问这些未映射的地址,就会触发页面错误(Page Fault),类似于野指针的行为。

#include <stdio.h>
#include <stdlib.h>

int main() {
    int *ptr = (int *)0x10000000; // 假设这是一个未映射的虚拟地址
    *ptr = 10; // 尝试访问该地址,触发页面错误
    return 0;
}

 对于下面代码:

int main()
{
    const char* str="helloworld";
    str="H";
    return 0;
}

对于这种代码是可以编译通过的,但是经过上面的学习,我们知道该进程会崩溃,就是运行会崩溃,因为字符串常量不可以被修改,常量区的权限是只读的(查找页表的时候,权限拦截了),另外,const修饰是编译器级别的保护。

另外,假设我们的代码虽然有3GB,但操作系统会通过分页机制把它分成一个个小页面(比如每页4KB)。程序运行时,只有真正需要执行的部分页面会被加载到物理内存中,其他部分依然保存在磁盘上。当程序访问某个还未加载到内存的页面时,就会触发缺页中断,操作系统会去磁盘上找到这个页面,把它加载到物理内存的空闲位置,并更新页表记录。这样,程序就能按需动态加载代码,既节省了内存空间,又保证了运行效率。

地址空间和页表是OS创建并维护的!是不是也就意味着,凡是想使用地址空间和页表进行映射,也⼀定要在OS的监管之下来进行访问!!也顺便 ,包括各个进程以及内核的相关有效数据!保护了物理内存中的所有的合法数据

因为有地址空间的存在和页表的映射的存在,我们的物理内存中可以对未来的数据进行任意位置的加载!物理内存的分配和进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合

因为有地址空间的存在,所以我们在C、C++语⾔上new, malloc空间的时候,其实是在地址空间上申请的,物理内存可以甚至⼀个字节都不给你。而当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),这是由操作系统自动完成,用户包括进程完全 0 感知!!因为页表的映射的存在,程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射,在进程视角所有的内存分布都可以是有序的。

总结

因为有了虚拟地址空间和页表的存在,物理内存中的数据可以被加载到任意位置,而进程管理模块和内存管理模块可以解耦合。这是因为虚拟地址空间为每个进程提供了独立的地址映射,而页表则负责将虚拟地址映射到物理地址。这种机制使得物理内存的分配和进程的管理可以独立进行。

  • 虚拟地址空间:每个进程都有自己的虚拟地址空间,操作系统通过页表将虚拟地址映射到物理地址,从而实现了进程之间的隔离。

  • 按需加载:操作系统采用按需加载的方式,只有当进程实际访问某个虚拟地址时,才会触发缺页中断,操作系统才会将相应的数据从磁盘加载到物理内存中。

  • 页表的作用:页表不仅负责地址映射,还提供了内存保护功能,例如设置访问权限(如只读、可写等),从而保护物理内存。

  • 解耦合:由于进程管理模块只关心虚拟地址空间的管理和调度,而内存管理模块只负责物理内存的分配和回收,两者通过页表进行交互,从而实现了解耦合。

这种机制使得操作系统能够高效地管理内存资源,同时保证了进程之间的独立性和安全性。

澄清一些问题 

我们可以不加载代码和数据,只有task_struct,mm_struct,页表

在操作系统中,即使程序的代码和数据没有被加载到物理内存中,系统也可以通过虚拟内存管理机制仅加载必要的结构(如task_structmm_struct和页表)来创建一个进程。这种机制的核心在于虚拟地址空间和页表的映射关系,使得物理内存的分配和进程的管理可以完全解耦

具体来说,当创建一个新进程时,操作系统会首先初始化task_struct,这是进程控制块,用于存储进程的所有信息。接着,系统会创建mm_struct,它描述了进程的虚拟地址空间。此时,代码和数据尚未被加载到物理内存中,但页表已经建立,用于记录虚拟地址和物理地址之间的映射关系。

由于页表的存在,操作系统可以在需要时通过缺页中断机制动态加载代码和数据到物理内存。这意味着,只有当进程真正访问某个虚拟地址时,操作系统才会触发缺页中断,然后从磁盘加载相应的代码或数据到物理内存,并更新页表。这种方式不仅节省了物理内存资源,还提高了系统的整体效率。

因此,即使程序的代码和数据没有预先加载到物理内存中,通过虚拟内存管理和页表映射,操作系统仍然可以有效地管理和运行进程。

如何理解进程挂起

当一个进程因为某些原因(如等待I/O操作完成、资源不足等)无法继续运行时,它会被操作系统标记为阻塞状态。如果系统需要释放内存资源,或者为了更好地管理进程,操作系统可能会进一步将这个阻塞的进程挂起。挂起的进程会被移出内存,其状态信息和部分数据可能会被写入磁盘的swap分区。操作系统通过更新页表,标记这些页面已不在内存中,并记录它们在磁盘上的位置。当内存资源允许或进程等待的事件完成时,操作系统会将挂起的进程重新调回内存,更新页表,使其恢复到阻塞或就绪状态。

Logo

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

更多推荐