Linux 程序地址空间
目录
虚拟地址空间示意图
验证地址分布
struct mm_struct部分成员
概念
注意
目的
页表
进程的独立性
问题
1.fork子进程时为什么会打印出一样的地址?
2.32位下虚拟地址空间有4G大小?如果每个进程都有,那内存怎么放的下?
3.为什么页表在物理地址?
4.CPU在运行的时候这个CR3里的页表地址是去进程的上下文(内核栈)还是地址空间里拿呢?
5.如果exe非常大,内存无法一次性加载完,怎么办?
6.如果一个虚拟地址要被访问了,但是没有分配物理内存空间,没有代码和数据,这时候怎么办?
7.页表映射是什么时候构建的?
补充知识
空间概念
C/C++地址
C/C++中变量的概念
编辑 vm_area_struct(简)
写时拷贝
过程
优点
静态变量
字符常量区
内核空间
进程地址空间中的内核区域
缺页中断
原因
主要两种情况
软硬缺页
重点
虚拟地址空间示意图
验证地址分布
1 #include <stdio.h>
2 #include <stdlib.h>
3
4 const char* str = "hello";
5 int init_glo = 0;
6 int uninit_glo;
7
W> 8 int main(int argc, char* argv[], char* env[])
9 {
10 static int c;
11 static int d = 1;
12 int* ptr = (int*)malloc(100);
13 printf("code addr:%p\n", main);
14 printf("only read char addr:%p\n", str);
15 printf("init gloabl addr:%p\n", &init_glo);
16 printf("init gloabl addr:%p\n", &d);
17 printf("uninit gloavl addr:%p\n", &uninit_glo);
18 printf("uninit gloavl addr:%p\n", &c);
19 printf("head addr:%p\n", ptr);
20 printf("stack addr:%p\n", &ptr);
21 for(int i = 0; argv[i]; i++)
22 printf("argv[%d]:%p\n", i, argv[i]);
23 for(int i = 0; env[i]; i++)
24 printf("env[%d]:%p\n", i, env[i]);
25 }
- 结论堆栈相对而生,堆和栈中间有一大块空间
struct mm_struct部分成员
概念
- 主要用于描述和管理进程的用户空间地址信息,内核空间的管理并不依赖于
mm_struct
- 是一个内核数据结构,内核结构体,始终驻留在物理内存中
- tesk_struct通过mm_struct类型的指针来描述和管理进程地址空间
- mm_struct结构体对象中 有一个指向页表的指针:pgd_t * pgd;
- 通过区域划分进程地址空间
注意
- 虚拟地址空间用户看起来是连续的空间,实际在内存上是零散分布的
- mm_struct中维护虚拟地址空间相关的指针也是虚拟地址
- 不仅仅是PCB,内核数据结构 都存储在物理内存中的
- 进程在CPU上运行,并不是把task_struct放到CPU上,而是 task_struct的数据
目的
- 更好的管理每一个进程,为每个进程提供一个独立的虚拟内存视图,以便OS更有效的管理内存资源,同时提供内存保护机制,防止一个进程无意中修改另一个进程的内存数据
- 它并不存储数据,但是划分区域,是为了让程序以一个统一的视角看待数据的分布,让每个进程都能认为自己拥有独立、连续的内存空间;不然要是真放在物理内存里,放不下!
- 每个进程都有自己独立的进程地址空间,使得每个进程可以独立运行,不需要担心内存地址的冲突,不仅提高了系统的安全性和稳定性,还允许多个进程同时使用相同的虚拟地址而不冲突
页表
- 程序地址空间虚拟地址与实际物理地址映射
- 访问权限字段存在于每一个映射条目,比如说代码区的映射就是只读权限,如果对这个区域的内存发生一个写的信号,那么就会报段错误,很好的安全控制
- 内存是可以随意读写的,但是进程要访问的时候加了读写的控制条件
- 页表在物理地址
- CPU的CR3寄存器用于页表的地址
进程的独立性
- 在系统层面上因为每一个进程都有自己 独立的内核数据结构
- 因为有了地址空间的存在,让不同的进程经过各自页表映射到物理内存的不同处,来支持进程独立性的特点
- 在进程运行的时候,被映射访问的物理内存也是独立的
问题
1.fork子进程时为什么会打印出一样的地址?
- 因为子进程的创建往往是通过拷贝父进程,那么虚拟地址空间和页表也要拷贝,所以虚拟地址是一样的
- 我们打印出的就是虚拟地址
- 这个拷贝是浅拷贝,页表映射的物理内存还是一样的
2.32位下虚拟地址空间有4G大小?如果每个进程都有,那内存怎么放的下?
- 首先要谈论的是虚拟地址空间的目的,就是以一个统一的视角来分配虚拟空间
- 其二内存里就没有存放虚拟地址空间的地方,这个空间是通过mm_struct里的指针维护的一个区域,所以说虚拟地址空间更像是一种视角
- C/C++中访问变量,使用函数都是通过地址的形式访问,函数就映射到代码区。。。加载的代码和数据,就是用来映射的;同理,代码运行过程中,堆栈申请的空间也是映射到物理内存
3.为什么页表在物理地址?
- 页表中是需要访问的数据
- 页表并没有进程地址空间的统一视角的目的
- 上面也说了页表是在内核空间
4.CPU在运行的时候这个CR3里的页表地址是去进程的上下文(内核栈)还是地址空间里拿呢?
- 从设计的角度思考,程序地址空间这个结构里确实有一个指向页表的指针,那么如果上下文里也有,是不是有点多余了
- 没错,是多余了,且上下文里并没有页表的指针
- 那么进一步猜测,上下文更多的是保存进程每一次时间片运行后的动态的信息
5.如果exe非常大,内存无法一次性加载完,怎么办?
- OS会多态的在内存中不断地申请内存,加载局部程序,重新再页表里构建映射,就可以让程序边加载,边执行
6.如果一个虚拟地址要被访问了,但是没有分配物理内存空间,没有代码和数据,这时候怎么办?
- 话有点挂起的意思,但是Linux里没有这个状态
- 当发生页面缺失,CPU 会触发一个缺页中断,暂停这个进程,并将控制权交给操作系统内核
- 内存分配:操作系统首先检查这个虚拟地址是否有效,然后为该虚拟地址分配物理内存
- 从磁盘加载数据:如果这个页面对应的数据已经存在磁盘上(例如,之前被换出到交换区),操作系统会将相应的数据从磁盘加载到分配的物理内存中
- 更新页表:操作系统更新页表,将这个虚拟地址映射到新分配的物理内存地址,并设置相应的标志位为有效
- 恢复进程执行:完成页面加载后,操作系统恢复进程的执行,进程可以继续从暂停的位置执行,访问刚刚加载到内存中的数据
- 一个进程试图访问某个虚拟地址时,如果该地址没有映射到物理内存,就会发生页面缺失(缺页中断)
7.页表映射是什么时候构建的?
- 二进制文件加载到内存后,操作系统为每个进程分配虚拟地址空间,并通过页表将虚拟地址映射到物理内存
-
初步映射:在进程初始化(加载二进制文件)时,操作系统会为进程的关键段(如代码段、数据段、初始堆和栈)分配虚拟地址,并创建初步的映射。这个映射是在进程启动时建立的,但通常只是部分虚拟地址空间的映射。
-
按需映射:大部分的映射是在进程运行时按需建立的。当进程试图访问尚未映射的虚拟地址时,操作系统通过缺页中断机制分配物理内存并更新页表来完成映射。
-
映射的构建既发生在二进制文件加载时,也在进程运行时通过按需映射进行扩展
补充知识
空间概念
数据的01在计算机中通常用高低电频来表示,CPU的寄存器、内存对应的硬件上有触发器,这样的硬件单元可以实现充放电;所以可以想象成电池,有电代表1,没电代表0,一个字节8个电池,本质就是充放电的过程,电池之间用线连起来;这样就以进行数据和信号的交互
C/C++地址
- &a[0] < &a[9];1.在栈上 向下(低地址)申请连续的空间,但是向上使用;所以数组的地址,也是这块区域中地址最低的地址;指针的++正好就是地址的++
- struct str{a, b ,c}test;&test.a<&test.b<&test.c;和数组的规则一样
- int a; &a;这个a的地址是这四个字节里最低的地址
- static修饰的局部变量,编译器会将其编译为全局变量,在已初始化的全局数据区
- char* str = "hello";这个会报警告,就是告诉要加上const,防止后序被更改;str指向的字符串在字符常量区与代码区较近,所以在编译时,字符常量区就被编译到代码区里,而代码区是不能写入的;但我更倾向GPT:字符常量区通常与代码区相邻,都是只读的内存区域;;;如果尝试修改,通常会导致段错误(
segmentation fault
)或类似的运行时异常。 - 类型的本质:偏移量+起始地址的形式访问任何对象
C/C++中变量的概念
粗略:在编译后形成的可执行文件在系统中就没有变量名的概念,变量名会转变为地址;变量名是程序员看的
vm_area_struct(简)
- 可以划分更多的子区域,每个区域都有虚拟地址可以映射
- 和mm_struct共同构成地址空间
- 以一个链表或红黑树的形式组织,每一个节点都是一个vm_area_struct
写时拷贝
过程
- 当父进程使用
fork()
系统调用创建子进程时,操作系统会复制父进程的页表,而不是实际复制物理内存中的数据。这样,父进程和子进程共享相同的物理内存页;操作系统将这些共享的内存页的权限设置为只读(包括本来可写的页)。这是为了确保一旦任意进程尝试写入这些共享页,写时拷贝机制能够生效 - 子进程或父进程中的代码可能会尝试向这些共享的内存页进行写操作,由于这些页的权限被设置为只读,写操作会导致一个页错误
- 操作系统介入:检查是否真出错,尝试写入不可写的内存区域,则视为真正的访问错误,操作系统将终止进程并发出信号;如果该页是因为写时拷贝而只读的,并且写操作是合法的,操作系统会为触发写操作的进程分配一个新的物理内存页,然后将原内存页的内容复制到新页;更新页表,将这个新页的地址映射到进程的虚拟地址空间,并设置为可写
优点
- 写时拷贝机制保证了只有在需要时才会进行内存复制,节省了系统资源
问:为什么要拷贝,反正都是要修改,申请后修改就行了啊?
- 可能只改变其中的一部分数据,大部分不用改
静态变量
- 在一个函数内定义的静态变量是局部于该函数的,其他函数不能直接访问它
- 函数内定义的静态变量在再次调用该函数时不会再被赋初始值。静态变量在程序的生命周期内只会初始化一次
字符常量区
- 常量字符串(字符串字面量)在编译时被存储在只读数据段(
.rodata
),也就是只读的内存区 - 常量字符串不一定需要显式地加上
const
关键字,但它们本质上是不可修改的,所以通常应该将指向它们的指针声明为const
。如果不使用const
修饰符,程序仍然能够编译并运行,但尝试修改常量字符串会导致未定义行为,通常会导致程序崩溃(比如段错误)
内核空间
- 操作系统用于管理进程内核数据结构的区域,例如每个进程独有的 task_struct 和 mm_struct
- 这些数据结构在物理内存中分散分配,由内核通过指针和链表管理,并不直接映射到进程虚拟地址空间的高地址区域
进程地址空间中的内核区域
- 我们通常所说的 “内核空间” 是指 进程的虚拟地址空间中高地址部分的区域
- 而 mm_struct 所在的内核空间 是在这个地址空间中由内核专门管理的区域
- 在每个进程的虚拟地址空间中,有一部分高地址区域分配给内核,这部分称为进程虚拟地址空间的内核区域,通常包含内核代码、全局内核数据等共享内容,对所有进程来说,这个区域是相同的
- 但它仍然是虚拟地址,用户地址空间中的内核区域也需要通过页表映射;所有进程的内核区域都映射到 相同的物理内存区域
- 内核区域的页表映射在所有进程中是相同的,每个进程的页表中的内核区域部分,虚拟地址和物理地址的映射关系一致
缺页中断
原因
- 写时拷贝机制中,内存页最初是共享的并被标记为只读。当进程尝试写入这些页时,触发了硬件级的保护机制,导致缺页中断
主要两种情况
- (没有页)没有对应的物理内存页:当进程访问一个虚拟地址,该地址在页表中没有对应的物理页映射时,会发生缺页中断,这通常意味着该页尚未被分配物理内存,或者该页可能被换出到磁盘上(即页表中对应的页不在物理内存中,而在交换区中)
- (页不够)试图写入只读页(写时拷贝情况)
软硬缺页
- 目前觉得就是,是否有从磁盘或外部存储中加载数据到内存中
重点
- mm_struct 与 虚拟地址空间的联系
- 内核空间 与 进程地址空间中的内核区域的区别
- 了解页面缺失与缺页中断
- 掌握 写实拷贝、问题1、2、4、6