实验:
1 #include <stdio.h>2 #include <sys/types.h>3 #include <unistd.h>4 #include <string.h>5 6 int g_val = 100;7 8 int main()9 {10 printf("我是一个进程: pid:%d,ppid:%d\n",getpid(),getppid());11 pid_t id = fork();12 if(id<0)13 {14 perror("fork");15 return 0;16 }else if(id==0){17 //child process18 while(1)19 {20 printf("child process: pid:%d,ppid:%d,g_val:%d,add_of_v:%p\n",getpid(),getppid(),g_val++,&g_val);21 sleep(1);22 }23 }else{24 //parent process25 while(1){26 printf("parent process: pid:%d,ppid:%d,g_val:%d,add_of_v:%p\n",getpid(),getppid(),g_val,&g_val);27 sleep(1); 28 }29 }30 31 32 return 0;33 }
实验结果:
父子进程之间按理来说应该是写时拷贝,但是此时改变子进程的g_val的值,按理来说是拷贝到其他空间,但是打印出来的地址竟然是一样的。
1. 理解地址空间
一个抽象例子:一个富豪有十亿美金,他有十个儿子,儿子们之间互相不知道、不认识,都认为自己可以继承所有财产,富豪给每个儿子都画饼,告诉他们自己可以继承十亿美金,不过鉴于他们年龄不同,目前从事的事业不同,这个先给200美金,那个先给3万,另一个先给10块.........
操作系统给每个进程都“画饼”,让每个进程就算现在只分300MB,但还是认为自己会有四个G的空间
每个进程都认为有2^32个地址空间给自己继承
2. 理解内存划分
在学习指针的时候,我们有如下认知(总大小为4GB):
由”画饼“的思想结合上图可得:每个程序还需要把自己的空间分划出具体结构。
struct mm_struct是task_struct中的一个指针。
linux下进程的地址空间的所有的信息的结构体是 mm_struct (内存描述符)每个进程只有⼀个mm_struct结构,在每个进程的task_struct结构中,有⼀个指向该结构的指针。
mm_struct用于描述每个进程在每个虚拟区域的地址划分,逻辑结构如上图,真实存储如下图:
tip:
virtual memory就是vm的缩写
地址的本质是数字,所以用unsigned long来描述即可。
区域划分的本质:只要知道了开始和结束的位置即可,这中间的空间我们都可以随意使用。
3. 初识页表 (Page Table)
简单来说,页表就是一个进程中虚拟地址和真实物理地址的映射
利用页表回答:fork返回值为什么能让id的数值不一样?
"对于最基础的单级页表,每个进程一个页表,父子进程使用的是不同的页表,所以id的数值不一样"
大概感受一下页表带来的作用:
页表的设计带来了虚拟内存,而虚拟内存存在的原因(大概有个感觉):
1. 地址空间隔离
虚拟内存可以将每个进程的内存空间隔离开来,确保一个进程不能直接访问或修改另一个进程的内存。
2. 扩展可用内存
现代计算机的物理内存有限,而程序的内存需求可能远超过物理内存的大小。虚拟内存允许操作系统将不常用的部分数据存储到磁盘上(例如硬盘的交换空间或交换文件中),而将常用的数据留在内存中。这样,操作系统能够:在物理内存不足时,利用磁盘空间(虽然访问速度慢),扩展可用内存的总量。通过分页机制或分段机制,高效地利用物理内存,确保程序仍然能够运行。
回到最开始的实验代码,我们继续了解页表:
这是一个父进程的task_struct所指向的mm_struct
g_val这样的全局变量是放在数据区的,假设红框就是他的虚拟地址
红框对应的地址一定存在页表中,映射着其真实地址。
这时我们调用了fork函数:
创建子进程时,本来是“浅拷贝”父进程的模板
子进程的PCB(我们前面学习过,先建立PCB才会导入代码)不仅需要开出空间,还需要有初始化的内容,就像c++的内容一样
当然,拷贝肯定不是全拷,比如时间片/上下文信息/PID等内容就不会拷贝。
提示:
进程=代码和数据+PCB
其中,继承的时候,代码是只读的,存储于代码区。
页表当中有很多的标记位:
第一种:记录rwx的标记位
这样就能解释为什么下述代码的编译器不会报错,但是运行起来有问题:
这种问题编译器拦不住,因为hello bit是存在于常量区(代码区)的,这一块在页表的映射中有(注意,是常量区,不是数据区)
标记为“不能w”,所以在*str='H'时发生权限错误,并且操作系统会认为一个进程去"w"一个不应该被写的空间是一定有错的,所以会直接杀掉这次进程(也就是这次程序运行)。因此这种错误是不能被编译器检查出来的,只是运行的时候被直接杀掉,语法上为了解决这个问题,就提出了const关键字
char* 的str本来就是不可修改的(操作系统层面),为什么还要加一个const?
基于上述解释,就能理解了:有const修饰,这下再进行*str='H'的操作,
就会在编译器层面上报错。
第二种:isexists标记位(is exists)
程序内容不一定随时都加载到了内存当中,可能会出现挂起的情况。
例如:
一个100个G的大程序要从磁盘加载到内存运行(比如玩黑神话悟空),内存本身的大小可能都没有100个G,必须分批将这个程序加载进来,比如先加载前2g,此时剩下的98G在页表中对应的isexists就写成false,虽然虚拟和真实有映射关系,但是这段内容并没有真正加载进捏内存。这2g的内容(2g对应的isexists是true)跑完之后,假设暂时不需要这部分内容,将其的isexists从true标记为false。
另一个例子:你的一个程序在等待scanf时被操作系统变成阻塞状态了,内容被交换到磁盘中去,这个时候的页表就要将对应的数据的isexists改为0
因此,每次映射不一定都是能映射到的,对应的数据和代码可能没有加载到内存中去。
页表能帮助我们把没有加载到内存中的数据给换入到内存中去。
页表可以用来分批分页加载
因此,一个进程不是一加载就全部拷到内存上,而是分批加载的
4. mm_struct
mm_struct
是 Linux 内核中用于描述进程虚拟地址空间的核心数据结构(页表就包含在里面)。它包含了进程的内存管理信息,是进程虚拟内存的抽象表示。
mm_struct也是结构体,是结构体就需要初始化。
比如程序A是一个3A大作,程序B是你写的hello world,正文部分(代码区)大小区别非常大,总不能每个mm_struct的代码区都初始化设置的一样大吧?
代码和数据空间是提前规划好的
有一个命令叫readelf,即read ELF
可执行与链接格式(Executable and Linkable Format,简称ELF)
readelf后面接一个elf格式的文件(注意,此时没有执行这个elf)
描述的都是各个区域的大小。
比如以下这个是read only data的空间大小。
结论:编译的时候每个区域的大小已经记录下来了。从这个进程的各个区域的大小属性处得到数据,用于初始化该进程的mm_struct
所以,操作系统需要有能力去读懂编译器在编译的时候很多属性,并且编译器和操作系统是有关联的!
虚拟空间中每一部分的大小都是在编译的时候就知道了。
进程的代码和数据加载进来,对应的页表isexitst就置1,没加载就置0。
而像堆,栈这些区域,都是操作系统在程序运行起来之后再创建的。
整个进程的虚拟空间是由:磁盘中拷贝+操作系统自行动态创建的
自行创建:
堆区:每次malloc或者new的时候扩展一点虚拟内存,而不是立即在物理内存中找空间。
操作系统在“欺骗”进程。只要真正要使用这个空间时,操作系统才会去开空间。
栈区:用了才扩展出来栈区
【虚拟栈内存:】
每个进程都会有一个虚拟的栈区,这个栈区是进程的虚拟地址空间的一部分。mm_struct 中会保存该栈区的虚拟内存的大小和位置。
【物理栈内存:】
只有当进程使用栈空间时,操作系统才会真正为栈区分配物理内存。这意味着栈区的虚拟内存大小可能远大于进程实际使用的物理内存。
进程栈区的实际物理内存是动态分配的,操作系统可能会通过 页面分配将虚拟栈内存映射到物理内存中,只有当栈区被实际访问时(比如函数调用时,栈帧被推入栈中),操作系统才会分配物理页面并映射。
5. 虚拟地址的作用
1.野指针等安全问题
为什么C/CPP语言访问一个野指针的时候会使得程序崩溃呢
因为虚拟地址在映射时的地址不对或者权限不对!这样就能保护内存
如果没有虚拟地址,一个进程在就是在直接访问内存的物理空间地址,意味着每一个进程都能访问整个空间地址,如果这是一个木马病毒,一个病毒就能使得整个内存瘫痪。
大概意思就是:“你买零食的时候你妈拿着钱”
2. 使得进程管理和内存管理在系统层面解耦合
让进程管理,比如malloc的时候,不再需要多考虑内存是否足够大等问题。
内存管理也只需要负责把空间给你就行,不需要管你是拿来作什么的。
因为有地址空间的存在,所以我们在C、C++语⾔上new, malloc空间的时候,其实是在地址空间上申请的,物理内存可以甚⾄⼀个字节都不给你。⽽当你真正进⾏对物理地址空间访问的时候,才执⾏内存的相关管理算法,帮你申请内存,构建⻚表映射关系(延迟分配),这是由操作系统⾃动完成,⽤⼾包括进程完全0感知!!
3.让进程以统一视角看待内存
物理内存可以认为不存在读写权限等问题,只要空间给了就行。
并且物理内存是延迟、惰性加载
其他作用:继承环境变量
环境变量的继承和全局变量的继承是一样的道理, 环境变量和命令行参数是在栈之上单独的虚拟地址,在页表上有自己单独的映射。子进程在继承时,拷贝PCB,拷贝 页表等等,自然就继承下去了