【Liunx】进程地址空间

您所在的位置:网站首页 linux内存空间查看命令是什么 【Liunx】进程地址空间

【Liunx】进程地址空间

2023-05-18 12:38| 来源: 网络整理| 查看: 265

文章目录 📖 前言1. 环境变量收尾1.1 认识bash进程:1.2 环境变量具有全局属性:1.3 内建命令: 2. 进程地址空间2.1 Liunx — 地址空间验证:2.2 感知地址空间的存在:2.3 认识地址空间:2.3 - 1:究竟什么是进程地址空间2.3 - 2:程序如何变成进程的 2.4 写时拷贝:2.5 fork()函数遗留问题:2.6 为什么要有虚拟地址空间(三大理由):保护内存:Linux内存管理:让进程统一视角看内存: 3. 虚拟地址空间和进程地址空间一样吗

📖 前言

上节我们讲完了环境变量,本章我们来给环境变量收个尾,讲解一下进程优先级🙋🙋🙋……

本文实验系统:CentOS 7.6~

1. 环境变量收尾 1.1 认识bash进程:

在我们之前将进程状态的时候讲过,当一个进程将其杀死再重启时,进程的id是在变化的,但是它们的父进程的id是一直不变的。

进程状态前情复习~

一旦通过指令kill -9将bash进程给干掉之后,整个命令行就挂掉了。

在这里插入图片描述 命令行中启动的进程,父进程全部都是BASH。

bash是个进程,它是如何启动一个进程的呢?它底层就是用的fork创建子进程的。

正常使用命令行是因为这些命令本身是先被bash进程先获得了,bash也是进程也有自己的代码在/usr/bin路径底下。

在这里插入图片描述

当系统登录的时候,用shell等登录的时候,系统就会给用户创建bash进程,该进程是用C/C++写的。bash内的代码cin scanf可以获取输入的命令行。 1.2 环境变量具有全局属性:

本地变量和全局变量,在上一节我们已经讲过了,复习传送~

所谓得本地变量,本质就是在bash内部定义的变量。不会被子进程继承下去! 不带export就是本地变量一旦带上export就会被子进程继承下去

在这里插入图片描述

我们来看一下本地变量和环境变量的区别:

#include #include #include int main() { while(1) { printf("Hello World, pid: %d, ppid: %d, myenv=%s\n", getpid(), getppid(), getenv("hehe")); sleep(1); } return 0; }

在这里插入图片描述 将本地变量导成环境变量: 在这里插入图片描述 环境变量是会被子进程继承下去的!!

环境变量被其后的所有子进程都能看得到。

1.3 内建命令:

在这里插入图片描述 命令行中启动的所有程序,基本上都是要创建子进程,echo也是一条命令也是子进程。

那么问题来了:

bash内定义的local_val变量怎么可能被于进程读到呢? export叫导出环境变量,export也是子进程,那么它导出的环境变量只能在子进程的上下文当中,怎么能在bash上下文呢?(因为要先创建子进程)

Linux下大部分命令都是通过子进程的方式执行的!但是,还有一部分命令,不通过子进程的方式执行,而是由bash自己执行(调用自己的对应的函数来完成特定的功能),我们把这种命令叫做【内建命令】!!

信任度非常高的命令,bash不会让子进程去帮它执行,而是自己去执行。

例如cd指令也是如此,如果这里不理解我们在以后的手写bash中再来理解感受一遍(其实我也不太理解😅😅😅)

2. 进程地址空间 首先先要明确,我们之前学的程序地址空间是内存吗? 答案是否定的,不是内存。程序地址空间,不是内存!它叫程序地址空间都不准确,应该叫进程地址空间。进程地址空间根本就不是C/C++的概念,而是操作系统的概念。

在这里插入图片描述

一般在4G空间当中,0到3G空间是给用户的,还有1G空间给操作系统的。低地址全0,高地址全F 2.1 Liunx — 地址空间验证: #include #include #include int un_g_val; int g_val = 100; int main(int argc, char* argv[], char* env[]) { printf("code addr : %p\n", main); printf("init global val addr : %p\n", &g_val); printf("uninit global addr : %p\n", &un_g_val); char* m1 = (char*)malloc(sizeof(char) * 100); char* m2 = (char*)malloc(sizeof(char) * 100); char* m3 = (char*)malloc(sizeof(char) * 100); char* m4 = (char*)malloc(sizeof(char) * 100); static int s = 100; printf("heap addr : %p\n", m1); printf("heap addr : %p\n", m2); printf("heap addr : %p\n", m3); printf("heap addr : %p\n", m4); printf("stack addr : %p\n", &m1); printf("stack addr : %p\n", &m2); printf("stack addr : %p\n", &m3); printf("stack addr : %p\n", &m4); printf("s stack addr : %p\n", &s); int i = 0; for(i = 0; i printf("env addr : %p\n", env[i]); } return 0; }

运行结果:

在这里插入图片描述 我们发现堆和栈之间有非常大的地址镂空,我们来看看chatgpt是怎么回答这个问题的吧:

Linux中的堆和栈是两个不同的内存区域,它们的地址空间是分开的。堆是动态分配的内存区域,由程序员手动申请和释放,而栈是由系统自动分配和释放的内存区域,用于存储函数调用时的局部变量和函数调用的返回地址等信息。由于堆和栈的使用方式不同,它们的地址空间也需要分开,以避免相互干扰。在Linux中,堆和栈之间留有一定的地址空间,这个地址空间被称为“地址空洞”或“地址镂空”。这个地址空洞的大小取决于操作系统的实现和硬件架构,通常是几百MB到几GB不等。这个地址空洞的存在是为了保证堆和栈之间的内存不会互相覆盖,从而保证程序的正常运行。如果堆和栈之间没有地址空洞,那么当堆和栈的内存使用量增加时,它们就会相互覆盖,导致程序崩溃或出现不可预测的错误。因此,为了保证程序的稳定性和安全性,Linux中的堆和栈之间必须留有一定的地址空洞。

静态变量:

生命周期一直存在是因为他本来就是全局变量。一个被写在函数内的全局变量。

函数内定义的变量用static修饰,本质是编译器会把该变量编译进全局数据区!

在这里插入图片描述

堆区空间是从低地址向高地址使用的栈区空间是从高地址向低地址使用的堆,栈相对而生

我们一般在C函数中定义的变量,通常在栈上保存,那么先定义的一定是地址比较高的!

2.2 感知地址空间的存在:

当父子进程没有人修改全局数据的时候,父子是共享该数据的!

#include #include #include int g_val = 100; int main() { pid_t id = fork(); if(id == 0) { //child int flag = 0; while(1) { printf("我是子进程:%d, ppid: %d, g_val: %d, &g_val: %p\n\n", getpid(), getppid(), g_val, &g_val); sleep(1); flag++; if(flag == 5) { g_val = 200; printf("我是子进程,全局数据我已经改了,用户你注意查看!\n"); } } } else { //parent while(1) { printf("我是父进程:%d, ppid: %d, g_val: %d, &g_val: %p\n\n", getpid(), getppid(), g_val, &g_val); sleep(2); } } }

在运行上述代码时,我们会遇到一个非常奇怪的现象:

在这里插入图片描述

父子进程读取同一个变量(因为地址一样),但是后续没有人修改的情况下,父子进程读取到的内容却不一样!!这就说明了,我们在C/C++中使用的地址,绝对不是物理地址!如果是物理地址,这种现象不可能产生! 那不是物理地址是什么呢?? 虚拟地址线性地址逻辑地址

三个概念完全不一样,但是在Liunx下是一样的。

为什么我的操作系统不让我直接看到物理内存呢?? 内存就是一个硬件,不能阻拦你访问!只能被动的进行读取和写入!

操作系统在程序和内存之前加了一层软件层,它来帮我做控制。

2.3 认识地址空间:

每一个进程在启动的时候,都会让操作系统给他创建一个地址空间,该地址空间就是进程地址空间。

每一个进程都会有一个自己的进程地址空间!! 操作系统要不要管理这些进程地址空间呢??既然是管理,那么就是:先描述,在组织。进程地址空间,其实是内核的一个数据结构,struct mm_struct。

进程和内存中间构建一层软件层,叫做地址空间。

在这里插入图片描述

2.3 - 1:究竟什么是进程地址空间

我们之前讲过进程的独立性:

独立性多进程运行,需要独享各种资源,多进程运行期间互不干扰。进程相关的数据结构是独立的,进程的代码和数据是独立的。

进程地址空间不是物理上存在的概念而是,逻辑上抽象的概念。

进程地址空间,就是操作系统给进程画的大饼,为了更好的维护进程彼此之间的独立性(每个进程都认为内存是从全0到全F的)。

地址空间存在的最大意义: 让每一个进程都认为自己是独占系统中的所有资源的!!

每次要都是要一点,不是要完的,所以一直都这么认为,操作系统骗了进程。

为什么操作系统让每一个进程都认为自己是独占系统中的所有资源?我们来看看chatgpt的回答。 操作系统让每一个进程都认为自己是独占系统中的所有资源的,是因为这种抽象方式可以简化程序员的编程工作,同时也可以提高系统的安全性和稳定性。如果每个进程都知道自己只是系统中的一个小部分,那么程序员需要考虑如何与其他进程共享资源,如何避免资源竞争等问题。这将使编程变得更加复杂,容易出错。另一方面,如果每个进程都认为自己是独占系统中的所有资源的,那么操作系统可以更好地控制和管理资源的分配和使用。操作系统可以使用各种技术,如时间片轮转、优先级调度等,来确保每个进程都能够获得足够的资源,并且不会对其他进程造成影响。总之,操作系统让每一个进程都认为自己是独占系统中的所有资源的,是为了简化编程工作,提高系统的安全性和稳定性。

所谓的地址空间:其实就是OS通过软件的方式,给进程提供一个软件视角,认为自己会独占系统的所有资源(内存)。

每个进程都会创建一个task struct,每个进程都会维护一个mm_struct。 在这里插入图片描述

页表:将程序加载到内存,由程序变成进程之后,由操作系统会给每一个进程构建一个页表结构(程序加载到内存时自动构建的,构建虚拟地址到物理地址的映射关系)。

我们看到的各种地址,是在虚拟地址空间当中给我们分配好的地址,这样的地址经过虚拟地址,然后转化成物理地址到达物理内存。

区域:既然我们之前学习了内存中各种区域,区域是如何划分的呢?

是通过两个指针,start和end指针来维护一块区域每块区域用之间用链表联系到一起,如上图所示 2.3 - 2:程序如何变成进程的 程序被编译出来,没有被加载的时候,程序内部,有地址吗??有的!! 程序被编译出来,没有被加载的时候,程序内部,有没有区域??有的!!

在这里插入图片描述 虚拟地址:

把代码区的所有地址都加一个偏移量此时就已经把可执行程序的部分地址加载到内存里了在内存里就形成了全新的地址叫做虚拟地址

相对地址(相对于程序的起始地址),又叫做:逻辑地址。

详解第一阶段:

可执行程序在编译好之后,它本身就有了自己对应的一套地址,可执行程序内部是有区域的,所有的区域最终都在磁盘中已经划分好了。所以所谓加载到内存,无非就是按照区域,将其加载到物理内存当中。加载之后,在内存里,可以把所有的可执行程序内部的代码、数据等,所对应的地址全部转化成,我认为我是从0地址开始占内存的。

详解第二阶段:

加载完之后,程序到内存里了,操作系统会给每一个对应的进程创建一个PCB。PCB指向地址空间,地址空间再构建所对应的页表,再经过页表映射到物理内存。程序就开始读取对应的代码当中的数据了。此时代码的地址已经被转化成虚拟地址,所以CPU读到的都是虚拟地址。再进行寻址的时候,自动的会做页表转化,一定会找到它对应的物理地址的。

详解第三阶段:

加载到内存的时候我们就可以认为它的起始偏移量是从0开始。至于加载到内存当中的什么位置,由页表映射,但是程序内部的地址全部已经是以地址空间的方式排好的,可以在磁盘的任意位置去加,因为页表可以随便去映射,但是程序内部所有代码当中的函数跳转、变量的地址, 全部已经以线性地址或虚拟地址的方式呈现好。当CPU通过页表找到对应的代码之后,读取到的指令里面包含的地址就是虛拟地址,然后经过页表再转化,找到在内存当中对应的地址,进而找到代码继续执行。

详解第四阶段(总结):

以下内容了解,只需要记住,每个进程会创建一个task_struct,每个进程都会维护一个mm_ struct有自己对应的区域。当程序加载到内存时,程序有加载到物理内存的物理地址,将虚拟地址和物理地址建立映射关系,最终进程访问的是某个区域当中的地址的时候,直接经过页表映射到物理内存,找到对应的代码,当找到对应的代码和数据时,要将其加载到CPU里面。加载到CPU里面,这个代码里面也有地址,该地址早已经在加载的时候被转换成了线性地址或虚拟地址,所以CPU可以继续照着这个逻辑继续向后运行。 虚拟地址空间是个体系工程,不仅仅在操作系统层面上替我们考虑,它也在编译阶段替我们考虑好了。 编译程序的时候,就认为程序是按照0000~FFFF进行编址的。虚拟地址空间,不仅仅是操作系统会考虑,编译器也会考虑。

举个例子:

程序的内部找printf的地址是用相对地址的方案,而加载到内存,整个代码在物理内存上有地址 而这个地址需要在页表的右侧维护起来,能够找到就可以。实际CPU读到的地址,在加载的时候就转化成了虚拟地址或逻辑地址或线性地址。然后读到的就全都是虚拟地址,所以此时才能够再进行页表转化找到物理内存。 2.4 写时拷贝:

有了上述知识,我们这里来解决之前的问题:为什么相同的地址在没修改子进程之前是一样的值,在修改过子进程之后却能相同的地址打印出两个不同的值??

此时就能体现出来,父子进程fork之后,代码是共享的。因为他们的虚拟地址到物理地址映射的页表其实是一样的。所以指向的代码和数据都是一样的。所以在没人写入的时候,这两个打印的虚拟地址和内容都一样。

为什么在子进程写入值之后,相同的地址打印出两个不同的值:

在这里插入图片描述

进程也有自己的代码,父进程和子进程指向的也是同一块代码。创建子进程的时候,子进程的相关数据结构内容初始化以父进程为模版。子进程的页表大部分情况下也是以父进程为模板的。这个物理地址依旧映射到的是上面的物理地址。

这是为什么没写入值之前是一样的值的原因。

重点:因为进程具有独立性!!要做到互不影响!

所以操作系统是不允许一个进程将另一个进程的数据给修改了!

因为进程具有独立性,如果子进程把变量改了,就可能导致父进程识别这个变量有问题,子进程影响了父进程。

写时拷贝引入: 当识别到子进程修改了,操作系统会重新给子进程开辟一块空间。并且把刚刚的值(100)拷贝下来,并且重新对这个进程建立映射关系。所以子进程的页表就不再指向父进程对应的变量(100),而子进程指向的就是新的空间了。所以我们在改的时候改的永远是页表的右侧,也就是映射关系,而左侧不变。所以最终看到虚拟地址一样,但是经过页表映射已经被映射到不同的物理内存。所以读到了两个不同的值,虚拟地址却是一样的。

总结:

没修改时用的是用一物理地址,一旦父子有任何一个进程尝试修改对应变量的时候,此时就发生了写时拷贝。只是从新构建页表的映射关系,虚拟地址是不发生任何变化的。当父子进程对数据做修改的时候,操作系统会给修改的一方重新开辟空间,并且把原始数据拷贝到新空间当中,这种行为叫做写时拷贝。

通过页表,将父子进程的数据就可以通过写时拷贝的方式,进行了分离!!!

每个进程的地址空间页表是互相独立的,每个进程的数据也是独立的,代码因为不可被修改,即便是共享也不影响,这样就做到了父子进程具有独立性!!!

补充:

代码也是共享的,父好进程一般不会对代码进行写入。万一有一天代码发生变化,也要写时拷贝的。

所以之前说 “ 程序的地址空间 ” 是不准确的,准确的应该说成 “进程地址空间 ” ,那该如何理解呢?看图: 在这里插入图片描述

2.5 fork()函数遗留问题: fork有两个返回值,pid_t id,同一个变量,怎么会有不同的值?? pid_t id是属于父进程栈空间中定义的变量,fork内部,return会被执行两次。return的本质,就是通过寄存器将返回值写入到接受返回值的变量中!!当id = fork()的时候,谁先返回,谁就要发生写时拷贝,所以,同一个变量,会有不同的内容值,本质是因为大家的虚拟地址是一样的,但是大家对应的物理地址是不一样的!!

补充:

这里所说的 “ 谁先返回,谁就要发生写实拷贝 ” ,(一开始我也很疑惑,后来询问了老师)这里解释一下: 因为这个变量的值一开始和先返回的值不同,所以第一次返回的时候就要改值,这时就发生了写时拷贝。 比如一开始id是-1,调用fork后,第一个返回的不管父子进程都要去改这个id,-1现在两个进程使用,是不是就发生写时拷贝了。 抽象说:一开始只有一个,谁修改谁新创建一个,剩下一个用最开始的。 详细说:一开始父程修改-1这块空间,发生了写时拷贝,新开辟了一块空间(父进程用),然后子进程用的是-1那块空间。 内核就是这样去实现的,已经到我知识边界了,硬记吧~ 2.6 为什么要有虚拟地址空间(三大理由): 保护内存:

如果不存在虚拟地址空间的话(非常不安全):

那就可以随便访问物理内存了一旦出现野指针的行为,我们写的代码有bug,野指针越界了就有可能将别的进程内容给改了甚至还有可能直接将操作系统内核改了

在这里插入图片描述 虚拟地址保护内存:

因为页表的存在,如果是野指针没有映射关系就访问不到物理内存。页表转化失败,进程想访问内存也就不可访问,操作系统就会直接将进程杀掉。此时物理内存没有被进行任何写入操作,因为有虚拟地址空间的存在。

坊问内存添加了一层软硬件层,可以对转化过程进行审核,非法的访问,就可以直接拦截了。

Linux内存管理: 一个进程在申请内存的时候,本质只需要在地址空间中将空间开辟好。后半部分内存申请可以暂时不申请。当真正用到这块内存的时候再在内存上对内存进行申请。这样做的好处是,需要时立马就可以访问,不会出现占着茅坑不拉屎的现象(即便是现在申请,但是不用,内存中的一些空间并没有被拿走,此时这块空间可用作他用)。无形当中提高了Linux操作系统运行的效率。

Linux只要有虚拟地址空间的存在,它可以把对进程的调度,执行代码和Linux内存管理通过页表就彻进行了解耦!!

补充:

调度进程,运行进程,正常执行进程代码都叫做操作系统中的进程管理。进程当中代码申请空间时,操作系统只是将地址空间扩大了。 当你真正需要时,系统才会做内存管理的申请,填充页表。 进程管理和内存管理,这两者就通过地址空间,进行功能模块的解耦!!

什么是没有解耦?

如果一个进程想malloc一段空间,不解耦就必须立马调用由进程调度模块,必须调度malloc底层代码,在物理内存上真正的把物理内存malloc出来。可以正在调度调度进程,突然跑过去就要执行内存管理的代码,这就叫做这两者没有解耦。

什么是解耦?

现在是有了地址空间,需要malloc内存,直接在地址空间上在堆区将end指针扩大一点,这个区域就相当于允许访问了,当不访问时不分配内存,当访问时再触发内存管理模块,来访问(ps:在真正写入的时候操作系统可能直接将进程直接挂起,或者阻塞掉,然后将对应的内存开辟好,映射关系建立好,再将该进程唤醒,然后再通过虚拟地址转成物理地址经过页表,映射到物理内存,然后再访问物理内存)。 内存管理压根就不关心是什么原因要申请空间的,只关心要空间要多大,然后申请好,再建立好映射关系,此时维护好,上层再进行访问就可以了。这叫做将操作系统中的模块通过地址空间进行解耦。

让进程统一视角看内存:

让进程或者程序可以以统一视角看待内存:

如果没有虚拟地址空间, 如果要将可执行程序加载到内存里,代码内部也有代码和数据,每一个都有地址。加载到内存时就要想办法将这些地址变成物理地址,每次加载的时候这个程序的地址都要发生变化,因为程序会被加载到不同物理内存处。如果以统一的视角的话,将来进程加载到物理内存的任何位置都不怕,因为看到的虚拟地址一点都不变。但是可以被加载到物理内存的任意地址,因为可以通过虚拟地址映射到物理内存,改的永远是页表右侧(物理地址),而页表左侧(虚拟地址)没有任何变化。例如代码区永远不变(永远在那个区域),如果将可执行程序加载到物理内存的一个区域,当以后加载到物理内存的其他区域时候,代码区就变了,这就让CPU调度和操作系统管理都会增加很大成本。

进程以统一的视角看待内存,让每一个区域的位置相对确定,让进程上层,包括申请内存,各种进程管理,给上层编写进程的接口等都变得容易了。不用考虑指针是否非法访问,接口在使用的时候也不用确认代码在哪里,可以大大减少编码成本。

方便以统一的方式,来编译和加载所有的可执行程序。

3. 虚拟地址空间和进程地址空间一样吗

cahtgpt的回答:

虚拟地址空间和进程地址空间不完全相同,但它们之间有密切的关系。

进程地址空间是指进程在运行时所使用的地址空间,包括代码段、数据段、堆栈等。而虚拟地址空间是指操作系统为每个进程分配的一段虚拟地址范围,它是由操作系统管理的,不同的进程拥有不同的虚拟地址空间。

虚拟地址空间和进程地址空间之间的关系是,进程地址空间中的地址是虚拟地址,它需要通过地址映射机制转换成物理地址才能被处理器访问。操作系统负责管理虚拟地址空间和物理地址空间之间的映射关系,以及进程之间的地址隔离,保证每个进程只能访问自己的虚拟地址空间,从而保证系统的安全和稳定性。

本章很不好理解,需要时间的沉淀~

至此进程概念到此全部结束,第一座大山已经翻过去,可以歇口气了,进程控制见~



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3