程序地址空间回顾(这真的是吗?)
我们之前学习C/C++的时候是否听过:对于自己的C/C++程序,我们默认认为自己的内存地址空间是:
-
代码区(Text Segment):存放程序的机器指令代码,这部分内存通常是只读的,以防止程序意外修改自身的指令。
-
数据区(Data Segment):进一步细分为初始化数据区和未初始化数据区(BSS段)。初始化数据区存放程序中已初始化的全局变量和静态变量;未初始化数据区(BSS段)存放未初始化的全局变量和静态变量,这些变量在程序启动时会被自动初始化为零。
-
堆区(Heap Segment):用于动态内存分配,如通过
malloc
、calloc
、realloc
等函数分配的内存。堆区的大小在程序运行时可以动态增长或缩小。 -
栈区(Stack Segment):用于存储函数的局部变量、函数调用的上下文信息(如返回地址、函数参数等)。栈区的大小通常在函数调用时自动分配和释放,遵循后进先出(LIFO)的原则。
这些区域共同构成了程序的内存布局,每个区域都有其特定的用途和管理方式,确保程序能够高效、安全地运行。
下面是我们的空间布局图:
#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){//childwhile(1){printf("子进程:gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());sleep(1);gval++;}}else{//parentwhile(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位的机器下,虚拟地址空间的范围是 个地址(0X00000000-0XFFFFFFFF),所以32位的机器:字节=4GB,对应的,64位机器,就是 个地址,总的地址空间的容量就非常大了。
- 【0G,3G】是用户空间,我们用户能够拿到地址/名字(环境变量)的话,就可以直接访问对应的地址的内容。
- 【3G,4G】是内核空间。
实际上我们代码在编译的时候,编译之后的变量名还在吗?(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)机制,如管道、消息队列、共享内存等,允许进程在保持独立性的同时安全地共享数据。通过这些机制,操作系统确保了进程的独立性和安全性,防止了一个进程的操作对其他进程造成不良影响。
如何理解虚拟地址空间? --- ⼤富翁的例⼦
这个大富翁的私生子之间互相不认识,有一天:大富翁对私生子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操作。
-
内存保护:通过区域划分,操作系统可以实现内存保护,防止进程访问或修改不应该访问的内存区域。例如,用户进程无法直接访问内核空间,从而避免了用户进程对内核内存的非法修改。
-
动态调整:操作系统可以根据进程的内存需求动态地创建、扩展或收缩内存区域。例如,堆的大小可以动态增长,栈的大小也可以动态调整。
-
共享内存:进程之间可以通过共享内存区域来交换数据。这些共享区域在每个进程的虚拟地址空间中都有对应的映射。例如,多个进程可以共享同一块内存区域,用于进程间通信(IPC)。
-
隔离机制:每个进程的虚拟地址空间是独立的,通过虚拟地址到物理地址的映射进行隔离。这确保了进程之间的内存访问是隔离的,一个进程的错误不会影响其他进程的稳定性。
将虚拟内存区域划分类比为小时候男女同桌之间的“38线”,可以更直观地理解其作用:
-
楚河汉界:每个区域(VMA)就像课桌上的“38线”,将整个虚拟地址空间划分为不同的“领土”。每个区域有其特定的用途和权限,确保不同类型的内存访问不会互相干扰。
-
互不侵犯:不同的区域之间是隔离的,一个区域的访问不会影响其他区域。这就像“38线”确保了同桌之间互不干扰一样。
-
动态调整:随着进程的运行,虚拟内存区域可能会动态地创建、扩展或收缩,就像“38线”的位置可能会根据实际情况进行调整。
-
共享区域:在某些情况下,进程之间可能需要共享某些内存区域,就像同桌之间可能会共享某些学习用品。操作系统提供了共享内存等机制来支持进程间的内存共享。
-
保护机制:虚拟内存区域的划分也起到了保护作用,防止进程访问或修改不应该访问的内存区域,就像“38线”防止同桌之间发生“领土纠纷”。
通过这种类比,我们可以更直观地理解虚拟内存区域划分的概念。每个区域都有其特定的用途和权限,区域之间是隔离的,操作系统通过这种划分来实现内存保护、隔离和共享等功能。
虚拟内存管理-第一讲
虚拟内存的工作原理
虚拟地址空间的申请和物理内存的分配与映射。它们之间的关系是通过页表来实现的。我来详细解释一下:
1. 在虚拟地址空间中申请指定大小的空间:
虚拟地址空间是操作系统为每个进程分配的一块逻辑内存空间。它与物理内存是分离的,进程只能通过虚拟地址来访问内存。当进程需要使用内存时,它会向操作系统请求一块指定大小的虚拟内存空间。
-
例如:程序运行时需要动态分配内存(如使用
malloc
函数),操作系统会在进程的虚拟地址空间中分配一块内存区域,并返回一个虚拟地址。 -
虚拟地址空间的特点:
-
虚拟地址空间是连续的,但实际的物理内存可能并不连续。
-
虚拟地址空间的大小通常远大于物理内存的大小,因为操作系统可以通过交换空间(swap分区)(如磁盘)来扩展可用内存。
-
2. 加载程序,申请物理空间:
当进程需要使用虚拟地址空间中的内存时,操作系统需要将虚拟地址映射到物理内存。这个过程通常发生在以下情况:
-
程序加载时:操作系统将程序的代码和数据加载到物理内存中,并建立虚拟地址到物理地址的映射。
-
页面故障(Page Fault)时:当进程访问的虚拟地址对应的物理内存页不在内存中时,操作系统会触发页面故障,加载相应的页面到物理内存,并更新页表。
1<->2 -> 通过页表进行映射:
页表是虚拟内存管理的核心数据结构,它记录了虚拟地址到物理地址的映射关系。
-
页表的作用:从虚拟地址到物理地址的映射。当进程访问虚拟地址时,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
{/*...*/struct mm_struct* mm; //对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。struct mm_struct* active_mm; // 该字段是内核线程使⽤的。当该进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。/*...*/
};
可以说,mm_struct结构是对整个用户空间的描述。每⼀个进程都会有⾃⼰独⽴的mm_struct,这样每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。先来看看由task_struct到mm_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
中。
所以是什么:我们重点要知道的是虚拟地址空间是一个结构体,他不是内存,通过页表的映射实现将虚拟地址转换为物理地址,从而实现对物理内存的访问。这种映射机制使得每个进程都拥有独立且连续的虚拟地址空间,而物理内存的分配可以是分散的,操作系统通过页表来管理这种映射关系。
页表的映射功能不仅实现了虚拟地址到物理地址的转换,还提供了内存保护和访问权限控制,防止进程访问不属于它的内存区域。此外,这种机制允许程序在物理内存中的任意位置加载,简化了程序员的内存管理工作。
虚拟空间的组织方式
在Linux操作系统中,每个进程都有一个独立的mm_struct
结构,用于描述其虚拟地址空间。操作系统通过mm_struct
来管理进程的虚拟内存区域(VMA),每个VMA由vm_area_struct
结构表示。为了高效管理这些VMA,Linux内核采用两种方式组织虚拟空间:
-
当虚拟区较少时,使用单链表来管理,
mm_struct
中的mmap
指针指向这个链表。 -
当虚拟区较多时,使用红黑树进行管理,
mm_struct
中的mm_rb
指针指向这棵树。
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的操作函数集合,用于处理特定类型的VMAunsigned long vm_private_data; // 私有数据,用于存储VMA特定的附加信息// 内核内部使用struct list_head vm_lru; // LRU链表节点,用于内存回收atomic_t vm_usage; // VMA的使用计数,防止被过早释放
};
在Linux内核中,vm_area_struct
结构用于表示一个独立的虚拟内存区域(VMA),它连接各个VMA的原因在于:
-
虚拟内存的多样性:进程的虚拟地址空间被划分为多个功能不同的区域,例如代码段、数据段、堆、栈、共享内存、映射文件等。每个区域的访问权限、属性和用途都可能不同。
vm_area_struct
结构能够详细描述这些区域的特性,包括起始地址、结束地址、访问权限、是否可共享等。 -
高效管理虚拟内存:通过
vm_area_struct
结构,Linux内核可以高效地管理这些不同的虚拟内存区域。内核需要快速定位和操作这些区域,例如在进程访问虚拟地址时,内核需要根据页表找到对应的VMA,以确定访问是否合法以及如何处理。vm_area_struct
结构提供了必要的信息,使得内核能够快速完成这些操作。 -
动态内存管理:进程的虚拟地址空间是动态变化的,例如堆的扩展、文件映射的增加等。
vm_area_struct
结构允许内核动态地添加、删除或修改虚拟内存区域,而不需要重新组织整个虚拟地址空间。 -
支持复杂的内存操作:
vm_area_struct
结构支持复杂的内存操作,例如内存映射(mmap
)、内存解映射(munmap
)、内存保护修改(mprotect
)等。这些操作需要对虚拟内存区域进行精确的管理和控制,vm_area_struct
结构提供了必要的灵活性和功能支持。 -
优化内存访问性能:通过将虚拟内存区域组织为链表或红黑树,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_struct
、mm_struct
和页表)来创建一个进程。这种机制的核心在于虚拟地址空间和页表的映射关系,使得物理内存的分配和进程的管理可以完全解耦。
具体来说,当创建一个新进程时,操作系统会首先初始化task_struct
,这是进程控制块,用于存储进程的所有信息。接着,系统会创建mm_struct
,它描述了进程的虚拟地址空间。此时,代码和数据尚未被加载到物理内存中,但页表已经建立,用于记录虚拟地址和物理地址之间的映射关系。
由于页表的存在,操作系统可以在需要时通过缺页中断机制动态加载代码和数据到物理内存。这意味着,只有当进程真正访问某个虚拟地址时,操作系统才会触发缺页中断,然后从磁盘加载相应的代码或数据到物理内存,并更新页表。这种方式不仅节省了物理内存资源,还提高了系统的整体效率。
因此,即使程序的代码和数据没有预先加载到物理内存中,通过虚拟内存管理和页表映射,操作系统仍然可以有效地管理和运行进程。
如何理解进程挂起
当一个进程因为某些原因(如等待I/O操作完成、资源不足等)无法继续运行时,它会被操作系统标记为阻塞状态。如果系统需要释放内存资源,或者为了更好地管理进程,操作系统可能会进一步将这个阻塞的进程挂起。挂起的进程会被移出内存,其状态信息和部分数据可能会被写入磁盘的swap分区。操作系统通过更新页表,标记这些页面已不在内存中,并记录它们在磁盘上的位置。当内存资源允许或进程等待的事件完成时,操作系统会将挂起的进程重新调回内存,更新页表,使其恢复到阻塞或就绪状态。