目录
一、概念的引入
我们曾经学习C/C++的时候,叫做C/C++地址空间,但是我们不能理解什么意思,反正慢慢就记住了。 我们在学习语言的时候有这个基础概念对我们学习C/C++是有帮助的,我们在学习语言期间知道有这个是C/C++地址空间,那么这个地址空间究竟是什么???是内存吗???
接下来我们通过代码来观察现象
创建子进程也就是系统中多了一个进程,操作系统要管理这个进程,先描述、再组织。也是同样的使用PCB进行管理,子进程也有自己的PCB结构,谁先执行,不确定,由调度器决定的。
观察结果会发现:
多进程读取相同地址,结果却不同,原因是这并不是物理地址(不是内存地址)。
早期学基础时接触的指针指向的基本地址,实则为虚拟地址(也叫线性地址,Linux 中常称逻辑地址),像编写 C/C++ 程序打印出的地址,都属于虚拟地址空间,而非物理内存地址。
mytest.c
#include <stdio.h>
#include <unistd.h>
int global_value = 200;
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("fork error\n");
return 1;
}
else if(id == 0)
{
//子进程
int cnt = 0;
while(1)
{
printf("我是子进程,pid:%d,ppid:%d | global_value:%d,&global_value:%p\n",getpid(),getppid(),global_value,&global_value);
sleep(1);
cnt++;
if(cnt == 8)
{
global_value = 500;
printf("子进程已经改变了全局变量..........\n");
}
}
}
else
{
//父进程
while(1)
{
printf("我是父进程,pid:%d,ppid:%d | global_value:%d,&global_value:%p\n",getpid(),getppid(),global_value,&global_value);
sleep(2);
}
}
return 0;
}
二、理解虚拟地址空间
在大学校园里,有个富二代阿杰,家境优渥、一表人才,还风趣幽默,身边从不缺追求者,而他竟同时与小美、小丽、小萱谈起了恋爱。
小美,文学院的才女,写得一手好文章,浑身散发着文艺气息。两人相识于一场诗歌朗诵会,此后阿杰常陪小美在图书馆的静谧角落谈诗论文,小美也会精心为阿杰准备饱含心意的手写信件,将平日的思念与诗意生活点滴记录其中,阿杰总会温柔地对小美说:“宝贝,等咱们结婚了,我家的钱可就都是你的,你就安心创作,定能成为大作家。” 小美听得心里甜滋滋的,坚信阿杰只钟情于她,每日沉浸在这份浪漫爱意里,勤勤恳恳地经营着他们的感情,盼着美好未来。
小丽来自医学院,成绩优异,心怀救死扶伤的大志向。阿杰在一次志愿者活动中和小丽结识,当时小丽正专注地为社区老人义诊,阿杰被她的专注与善良打动。之后,阿杰常常陪着小丽泡在实验室,帮她准备实验器材,小丽则会贴心地为忙碌后的阿杰熬煮营养汤羹,照顾他的身体。阿杰总会坚定地对小丽承诺:“亲爱的,等咱们结婚了,家里的钱随你支配,不管是购买昂贵的医学典籍,还是去参加顶尖的学术研讨会,我都全力支持你,你将来必是医学界的中流砥柱。” 小丽听着暖心话,满心以为找到了携手一生的伴侣,从未怀疑过阿杰的真心,每日忙碌于学业与这份感情之间,认认真真地做着阿杰的女朋友,憧憬着婚后生活。
小萱是艺术学院的灵动女孩,能歌善舞,艺术天赋极高。一场校园文艺晚会,让阿杰和小萱有了精彩的合作,此后感情迅速升温。阿杰常陪小萱去看画展、听音乐会,小萱也会为阿杰独家创作舞蹈,用灵动舞姿表达爱意,沉醉在艺术氛围里的小萱,听着阿杰深情告白:“宝贝,以后咱们成了家,家里所有的钱都归你管,你就放心大胆地去追求你的艺术梦想,办画展、出专辑,我就是你最坚实的经济后盾。” 小萱感动得热泪盈眶,满心欢喜地畅想着两人美好的未来,每日为这份感情投入诸多精力,勤勤恳恳,如同珍视艺术创作一般。
而阿杰呢,就像个 “情感操控大师”,周旋在三个女孩之间。面对小美时,他文艺范十足,谈诗论文;陪着小丽,他变身贴心助手,忙前忙后;碰到小萱,他瞬间成了艺术鉴赏家,与她探讨艺术的精妙。
有一回,小美看到一本限量版诗集,价格 200 块,她略带羞涩地向阿杰求助:“亲爱的,我看到一本超喜欢的诗集,对我来说意义非凡,你能不能帮我买下呀?” 阿杰眼睛都不眨一下,立马掏钱:“宝贝,只要你开心就好。”
小丽为了参加一场重要的医学技能竞赛,需要购买一套专业的模拟手术器械,要价 500 块。她焦急地给阿杰打电话:“宝贝,我马上要参加竞赛了,急需这套器械,你能不能赞助一下?” 阿杰连忙安抚:“亲爱的,别着急,我马上给你转钱,你肯定没问题的,竞赛加油!”
小萱得知一场期待已久的知名音乐会将在学校附近举办,门票 300 块,她兴奋地给阿杰发消息:“亲爱的,这场音乐会我盼了好久好久,你陪我一起去好不好嘛?帮我把只的门票买了吧。” 阿杰迅速回复:“宝贝,当然没问题,等会儿就给我转账,咱们一起去享受这场音乐盛宴。”
在计算机的世界里,阿杰就如同操作系统,而小美、小丽、小萱就像是一个个进程。每个女朋友都以为自己独占阿杰这个 “男朋友资源”,并且深信婚后阿杰家的巨额财富都将归自己所有,如同每个进程都以为自己独占系统资源一样。她们勤勤恳恳付出感情,如同进程在各自的 “任务轨道” 上努力运行。实际上,阿杰要同时应对三人的需求,操作系统也要同时处理多个进程的事务。阿杰给予每个女朋友的专属浪漫与承诺,相对于对每个女朋友画的饼,也是在他们大脑中描绘一幅蓝图,就如同操作系统给每个进程分配的虚拟地址空间,让她们(它们)都能在自己的 “小天地” 里安心生活(运行)。当女朋友们(进程)有需求,比如想要购买心仪之物(访问内存资源),她们(它们)不会一次性索要阿杰的全部财富(就像程序不会一次性申请海量内存资源),而是像使用 new、malloc 函数那样,按需以兆为单位一点点地申请,阿杰(操作系统)则凭借自身的 “本事”,在后方合理调配金钱,使她们(它们)的需求得到满足,确保每个女朋友(进程)都能开开心心,使整个 “情感系统”(计算机系统)相对稳定地运行下去。
不过在现实生活中,脚踏多只船的行为是极不道德的,只是借助这个故事方便大家理解,希望大家都能真诚对待感情,拥有属于自己的美好爱情。
我们以前在学习C/C++的时候使用malloc和new申请空间的时候,一次都是一点一点的申请,不会一次性申请几十个G,就算申请这么多操作系统也会直接拒绝。如果阿杰的女朋友一次问100w被拒绝了,请问阿杰女朋友会怎么想?她只会想:我是不是提的要求有点儿过分了啊?其中把给每个女朋友画的这一个个大饼,叫做进程地址空间 —— 也就是操作系统给各个进程画的“大饼”。
三、如何“画饼”
就拿阿杰和他女朋友们的事儿,阿杰喜欢给女生画饼,承诺未来给她们好处,像对小美说好好学习,明年给买漂亮裙子,对小丽又说给买项链。但这画饼得有个前提,女生得脑子好、记性好,要是转头就忘了阿杰是谁,那画饼可就没意义了,因为画饼本质上是在对方大脑里构建一幅蓝图,让她记住谁、在什么时候、达成什么目标能得到什么。这其实就类似一种数据结构的对象,比如用 struct 在计算机里表示,就是记录什么人、什么时间、有什么目标、能获得什么之类的信息,得让大脑记住这些。
可要是阿杰有 500 个女朋友,问题就大了。这边跟小美说的,和跟小丽承诺的不一样,一混淆,自己就尴尬了。所以不光女朋友得管理,给她们画的饼更得管理,阿杰得拿个小本本把给每个女生的承诺记下来,防止搞混。
回到咱们计算机操作系统这边,这和阿杰的情况特别像。要是操作系统里同时运行 500 个进程,操作系统要给每个进程创建虚拟地址空间,这就好比阿杰给每个女朋友画的 “财富大饼”,让进程以为能独占资源。那进程本身肯定得管理,不然乱套了。同样,这虚拟地址空间要不要管理呢?答案是肯定的。那怎么管理呢?首先得描述,把虚拟地址空间的各个属性、范围、包含哪些区域(像程序代码区、数据区、堆区、栈区等)都清晰地界定出来,让操作系统知道它到底啥样;接着得组织,按照一定规则和结构把这些信息整合起来,就像阿杰用小本本有条理地记录给各个女朋友的承诺一样,操作系统也得把各个进程的虚拟地址空间信息规整好,这样才能保证系统合理地运行,让每个进程正常干活,互不干扰。
进程地址空间的本质:是内核的一种数据结构 mm_struct。
PCB除了管理着进程的代码和数据还管理着进程地址空间。
四、区域划分
以 32 位计算机系统为例,地址是用 32 位二进制数来表示的。因为每一位都有两种可能(0 或者 1),根据排列组合的原理,总共就有
种不同的组合方式。这就意味着系统能够表示个不同的地址。
由于每个地址对应一个字节,而 32 位系统下有
个地址,所以总的空间范围就是
个字节。在计算机存储容量的换算中,1GB 等于
字节,那么字节换算后就是
* 1GB = 4GB。
- 地址空间描述的基本空间大小单位是字节。
- 32位下有
个地址。
个地址 *1字节 = 4GB空间范围,大约是42亿多 。
- 每一个字节都要有唯一的地址,一共要表示
个地址,地址的最大的意义:保证唯一性。
- 要对 4GB 进程地址空间编址,也就有了42亿多个地址。
进程地址空间有很多区域,代码区,已初始化数据区、未初始化数据区、堆区、栈区,在内核数据结构 mm_struct 中,我们最关心它内部有哪些成员。
区域的理解
以小学或幼儿园同桌场景为例,有个爱干净的小女孩和邋遢的小男孩同桌。小男孩不仅个人卫生习惯差,还经常欺负小女孩。小女孩不堪其扰,在 100 厘米宽的桌子中间画了一条 “三八线”。她规定小男孩只能使用从 1 到 50 的区域(以桌子宽度刻度编号),自己使用 51 到 100 的区域,以此划分个人空间,阻止小男孩越界捣乱,若越界就会受到惩罚。小男孩起初嚣张,挨揍后逐渐听话,尊重了区域划分。
区域的调整
有一天,小男孩忍不住跟小女孩说,他比较胖,有时不小心越过 “三八线” 就会被揍,感觉很委屈,希望能解决这个问题。小女孩看他听话,就决定调整区域:把桌子两边各让出 5 厘米当作缓冲地带,小男孩的区域从 1 到 45,小女孩的从 55 到 100。可后来小男孩还是总把胳膊伸过 45 厘米的界限欺负小女孩,小女孩生气了,再次调整,直接给小男孩划了 1 到 30 厘米的区域,自己占 31 到 100 厘米,这相当于小女孩扩大了自己的空间。总的来说,区域调整本质就是改变对应区域起始(start)和结束(end)变量里保存的值。
进程地址空间区域划分的理解
32 位系统下的进程地址空间想象成一张宽度固定的桌子,就如同系统天然设定好了范围,这里它默认有
个地址,相当于规定这张桌子宽 100 厘米。
对于这张 “桌子”,我们以厘米为单位进行细分,每个厘米位置就是一个独特编址,能借此精准定位放置物品的地方。类似地,计算机的 4GB 地址空间(因为每个地址对应一个字节,
字节即 4GB)经编址后,可精确访问每个字节单元。就像男孩在桌子上 0 - 1 厘米放铅笔、1 - 2 厘米放铅笔盒、2 - 5 厘米放书包,进程地址空间也划分成不同区域,像代码区存放可执行代码、数据区存放各类数据,每个区域各司其职。这就是区域划分,依据程序运行需求,把地址空间合理切分成不同功能板块,让数据和指令各得其所。
而区域调整又如同在这张桌子上重新分配两人的使用范围。起初女孩和男孩划分好区域,男孩总越界,女孩便调整:先两边各让出 5 厘米作缓冲,男孩区域从 1 - 45,女孩从 55 - 100;后来男孩依旧不改,女孩再次调整,将男孩区域缩至 1 - 30,自己扩大到 31 - 100。对应到进程地址空间,程序运行时情况多变,例如进程运行中途可能要加载新模块,就得扩大相关区域(如堆区,用于动态内存分配,类似女孩扩大自己地盘);或者释放用过的内存,相应缩小区域。总之,区域调整就是根据进程运行动态需求,灵活改变各区域的起始与结束位置。
最后,考虑如何表示这
个地址所界定区域的起始和结束地址。由于 32 位系统地址空间用 32 位二进制数表示,在编程实现中,像 C 语言里,合适的数据类型是
unsigned int
(无符号整数类型),它通常占 32 位,恰好能表示 0 到-1 的数值范围,精准对应所有地址,从而清晰界定区域起止,如同用厘米数界定桌子上的区域范围那般准确。
注意:对各个区域作调整,本质:就是修改start和end的值。
定义局部变量,malloc new申请对空间,扩展栈区或堆区。
函数调用完毕,free,缩小栈区或者堆区。
五、页表
- 1. 进程地址空间的准确称谓:
不能将程序运行所需存放代码和数据的空间称为 “C/C++ 的空间”,这种叫法不准确。实际上,更为精准的名称是 “进程地址空间”,因为无论虚拟地址空间如何复杂多样地被定义,最终代码和数据都得落脚于内存,以进程为依托来管理这片地址范畴。
- 2. 页表的引入意义:
依据冯・诺依曼体系结构,程序运行前需加载至内存。但进程如何精准定位内存中的代码与数据呢?此时引入 “页表” 这一关键概念,尽管它后续有诸多深入细节,当下只需明晰它作为进程在内存中查找代码、数据的媒介而存在。
- 3. 内存与磁盘 I/O 及内存结构剖析:
I/O 基本单位:内存与磁盘进行输入输出(I/O)操作时,通常以 4KB 作为基本单位。鉴于 1KB 等于 1024Byte,4KB 换算后大致对应 4000 多个地址,意味着内存是以 4KB 为一页进行组织划分的。
内存的数组式类比:可以将内存形象地看作一个大型数组,用struct page mem[4GB/4KB]表示。也就是把 4GB 的内存总量按照每页 4KB 的规格细分,恰似数组元素一样规整排列,便于管理与操作。
- 4. 基于虚拟地址访问数据的流程:
虚拟与物理地址映射:操作系统构建起虚拟地址与物理地址之间的映射桥梁 —— 页表,左侧为虚拟地址,右侧对应物理地址。
访问实例详解:例如定义变量char c = 100;,取该变量的地址&c,此即为虚拟地址。当要访问这个变量时,系统首先查询对应的页表,借助页表找到相应的物理内存位置,进而访问该物理内存的数据。像下图给变量赋值为 100 的操作,就是依照此流程,先定位物理内存空间,再实现数据写入,完成基于虚拟地址对物理内存中数据的访问。
注意:在 32 位机器下,页表连接虚拟地址和物理地址总地址占 8 字节,若简单算页表大小是
* 8 字节,但因太大有问题,所以操作系统用多级页表管理,现在先记住:进程虚拟地址通过页表能映射到物理内存空间。并且从虚拟地址到物理内存的一系列工作,像数据加载、映射等,都由操作系统完成。
再次理解进程地址空间
一、虚拟地址特点及映射过程
虚拟地址是连续的,也叫线性地址。每个进程都有自己的地址空间,会通过页表映射,将相应数据映射到物理内存里。
二、进程对物理内存的可见性
进程1 和 进程2 都没办法直接看到物理内存,它们只能凭借自己的地址空间去访问对应的数据。
三、虚拟地址空间的实质
就好像给每个进程画了张大饼,每个进程都觉得自己拥有整个地址空间(即认为自己独占很大内存),可实际上,像堆和栈之间有很大镂空区域,进程能用的没那么多。操作系统心里有数,预料到进程一次不会申请太多内存(比如最多给几十兆、了不起给 128 兆等),但还是让每个进程误以为自己独占系统空间,这就是虚拟地址空间的特点。
四、页表的拦截作用类比
就像压岁钱本可以直接花,但经过妈妈的手是为了起到保护作用一样,进程直接访问物理内存容易出问题,而通过页表进行访问就类似经过 “把关”。
五、页表对非法访问的处理
进程如果有访问空间的需求,向页表发起映射请求时,正常合理的请求,页表会给予映射,要是不合理的非法请求,页表就会直接拦截。一旦被拦截,这个进程当下就终止访问行为,不会再触及物理内存,不管是非法读取还是非法写入都无法进行。
六、操作系统的管控及规则普遍性
这种拦截并处理的机制是由操作系统来实现的,而且这是所有进程都要遵循的规则。正因为有页表的存在以及这样的规则约束,每个进程只会被映射到合法的内存区域,如此一来,就不用担心物理内存会被恶意写坏或者遭到非法读取了。
六、写时拷贝
一、初始状态(父进程情况)
最开始只有一个父进程,它有自己的地址空间,其中某变量(假设地址为0x1111 2222)通过页表映射到物理内存相应位置,这样就能成功读取到该变量的值。
二、子进程创建及初始表现
使用
fork
创建子进程时,子进程是以父进程为模板生成的,会把父进程的进程控制块(PCB)拷贝过来,也会拷贝一份父进程的地址空间(地址空间作为数据结构,大部分内容相同)。所以子进程在同样位置也有对应的地址(上层语言层面取地址得到,比如globl global
,称为value
),其虚拟地址同样经过映射,也指向和父进程相同的物理内存地址处,因此正常打印时,两者看到的该变量值都是初始值(比如 200)。三、子进程写入数据时的情况及进程独立性问题
后来,子进程在八秒之后尝试向该变量写入新值(比如改成 500),要是直接修改,那父进程读取时也会看到变成 500 了。但由于进程具有独立性,一个进程对共享数据的修改不能影响到其他进程。
四、操作系统的处理方式及 “写时拷贝” 技术
为保证进程独立性,当父进程或子进程任何一方尝试对共享数据进行写入时,操作系统会采取以下操作:
- 在物理内存上重新开辟一段空间(比如还是初始值 200 的拷贝)。
- 把原来的值拷贝进新开辟的空间里。
- 更改相应的映射关系,也就是修改页表(页表左右侧虚拟地址不变,但让要写入的进程对应的虚拟地址指向新开辟的物理内存空间)。
经过这样的处理,进程就可以进行修改操作了,而且修改的过程和原来的虚拟地址本身没有关系,只是底层通过页表映射到了内存的不同区域。这样就出现了上层看起来地址一样,但内容却不同的现象,而这种技术就被称之为 “写时拷贝”。
七、进程地址空间的扩展
一、编译阶段的地址情况
在编译阶段生成汇编代码时,代码内部就已存在地址,像函数跳转时便有相应地址,此阶段产生的地址称为逻辑地址。经过链接后形成可执行程序,而可执行程序才能够被执行。
二、编译器的编址规则
虚拟地址空间规则不仅操作系统要遵循,编译器编译代码时同样按此规则对代码和数据编址,这些编址形成的是程序内部地址。
三、以 32 位可执行程序 “myexe” 为例说明
以 32 位可执行程序 “myexe” 来说,编制时按虚拟地址空间方式进行,代码、数据等区域在磁盘上已划分好,编译期间部分地址已确定,如fun函数地址可能为 0x1234、main 函数地址为 0x1122、全局变量地址为 0x2222 等,但像栈空间、堆空间这类需在内存中动态申请的,编译时不会分配地址。实际上,此时程序内代码和数据对应的地址准确说是逻辑地址(在 Linux 中与虚拟地址基本等同)。
四、程序加载到物理内存后的地址变化
当程序加载到物理内存后,其指令和数据会自动具备物理地址,这样程序就有了两套地址:一套是用于标识在物理内存中代码和数据位置的物理地址;另一套是程序内部相互跳转时使用的虚拟地址(之前叫逻辑地址)。且加载完成后,代码各区域对应的地址也就明晰了。
五、整体流程
代码在未加载到内存前已有虚拟地址,加载内存时页表映射关系已存在。CPU 先获取 main 函数的虚拟地址,经页表映射找到物理内存地址开始执行,之后若 main 函数中要执行 fun 函数,CPU 拿着虚拟地址通过页表映射去物理内存找到对应的值,从而执行程序。例如,CPU 先拿着 main 函数的虚拟地址 0x1122,通过页表找到物理内存中 main 函数的地址 0x6666 开始执行,当遇到调用 fun 函数时,再拿着 fun 函数的虚拟地址 0x1234 通过页表找到物理内存中 fun 函数所在区域的地址 0x7777,进而执行 fun 函数的代码和使用相关数据(如使用地址为 0x2222 的变量 a)程序就是这样跑起来的。