zl程序教程

您现在的位置是:首页 >  系统

当前栏目

【Linux修炼】10.进程地址空间

Linux进程地址 10 空间 修炼
2023-06-13 09:17:43 时间

进程地址空间

本节目标

纠正一直以来的对地址(指针)的层次上的错误观点!深入学习进程地址空间并克服Linux学习的第一道险关:4.1中的3:统一性!

1. 回顾C/C++地址空间

1.1 提出问题

在我们以往学习的C/C++中,对于变量分配相对地址中的格局也就是C/C++的地址空间已经有了大致的印象:

那么对于这个所谓的C/C++地址空间是什么呢?是内存吗?在我们以前学习指针的刻板印象中的确如此,但现在提到了,就足以说明当初因知识储备的不足从而错误的理解了这个问题。

1.2 见问题产生的现象

在此之前,有一个额外的知识需要了解:对于我们在pid_t id = fork()所创建出的父子进程,这两个的先后进行顺序是不确定的,因为这是由调度器决定的。

那接下来就好好研究从哪里发现了这个问题:打开Linux环境,创建一个如下的mytest.c:

#include<stdio.h>
#include<unistd.h>

int global_val = 100;

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 == 10)
		    {
				  global_value = 300;
				  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;
}

执行这个程序:

首先,对于子进程改变成300,父进程仍然是100,我们可以理解,毕竟是两个不同的进程,执行的情况也一定是独立的,但当我们同时看两个进程中global_val的变量的地址以及变量的值,有了一个惊人的发现,同一个地址的值竟然不相同!这就与我们之前理解的指针大相径庭。我们之前所理解的指针,是内存中变量的地址,但是我们现在打印的地址同样是以指针的形式打印的,那么到现在发生的情况也证实了我们之前理解的指针指向的空间是错误的,指针指向的位置并不是物理内存!(如果是物理内存,那么不可能发生同一个地址的变量的值不相同的情况)

1.3 解释现象

那么多个进程在读取同一个地址的时候,怎么可能出现不同的结果?我们打印时,地址并没有发生变化,但是值不同,也就说明了我们所看到的地址绝对不是物理地址!曾经我们学习C/C++的基本的地址(指针)也一定不是对应的物理地址!因此对于这个现象得出了一个结论:我们所看到的打印出来的地址空间分布都是虚拟地址(也可称为线性地址、逻辑地址)! 我们称这种地址为虚拟地址空间。

因此上述我们所看到的地址分布也不是真正的物理内存上的空间。

2. 虚拟地址空间

2.1 感性理解虚拟地址空间

设计进程的理念——进程它会认为自己是独占系统资源的(事实上并不是)

就好比当你游戏打累睡觉的时候,你的室友悄悄地把你从床上挪到外面,等你将要醒的时候再挪回来,你仍会以为你的床的位置是一直被你占用的,但事实上并不是。

为了更加的深入理解虚拟地址空间,我们引入一段小故事(很重要哦): 1. 背景: 在美国有一位拥有十亿美金的大富翁peter,其有三个私生子:son1(大儿子:工厂老板);son2(二儿子: 金融CEO);son3(小儿子:在MIT念书),并且三个私生子互相不知道对方的存在。peter为了让三个儿子都好好的各司其职,都对他们单独许下同一个承诺:好好干,我就你这么一个儿子,我的十亿美金等我噶了就由你继承了。三个儿子分别听到这句话后都非常开心,并都对peter说自己会认真工作学习。 2. 经过: 由于最近疫情严重,大儿子的工厂资金链出现了问题,于是大儿子向peter要50万美金周转;二儿子作为金融界的大佬,需要豪车和名表撑一下场面,也找peter要了几十万美金;小儿子由于需要上学的学费以及谈npy需要花很多钱,于是向peter要了2万美金。对于这些小钱,peter并不在意,也都分别给了他们。就这样每次三个儿子都是一点一点要钱,peter对此也都不在意。但过了一段时间,大儿子觉得反正这十亿美金总有一天肯定会到自己的手里,仗着艺高人胆大,于是就迫不及待的和peter表明:已经找你要了这么多次钱了,你个糟老头子反正也花不了这么多钱,而且也没几天活头了,不如提前把财产都交给我,我好尽早干一番大事业。听了这话peter恼火了:给老子滚蛋,我还没死呢,这钱早晚是你的,现在还不行!(peter心想:要是给你了,老子我怎么继续画大饼)。这时候大儿子会生气吗?不会的,因为他知道迟早有一天这些资产都是自己的,毕竟大富翁就他这么一个私生子,自己也确实太着急了。上演了这么一波父慈子孝之后,这件事也就不了了之。

那么这其中,大富翁对应的就是操作系统,它所拥有的十亿美金就相当于内存,而一看三个儿子伸手要钱的嘴脸,这三个儿子就分别对应三个进程(正如我们在编写程序时的malloc或者new时朝着系统要内存的时候,就好比这三个儿子找peter要钱,emm……写着写着自己变成了儿子,安能辨我是谁儿?)而这三个儿子朝peter要钱,就相当于进程找操作系统要内存(也可称为对象空间),而对于经过的最后一个情节,就好比进程找操作系统要16G的内存空间(一般的电脑一共就16G),操作系统会直接给你16G的空间吗?那是不可能的,因为操作系统还要兼顾其他的进程,但每一个进程被操作系统拒绝后,也仍然会认为自己拥有全部的内存的使用权。因此,操作系统给进程画的大饼,就是进程(虚拟)地址空间。

即我们可以感性的认为:进程(虚拟)地址空间就是操作系统给进程画的“大饼”。

现实也是如此,就比如银行,我们所存的钱一定是在银行原封不动的存放着吗?事实上是不可能的,你的钱可能会被放贷,也可能会被用来理财,但你的余额在你看来仍是那些没有变化,但如果有人说银行要倒闭,这时候一旦所有人都去取钱,这时银行是不能实现将每一个人的钱都取出来的,这与上面的故事的意思是一样的。可见,多年来所涉及出的计算机系统,都是基于我们身边的现实发生的真实案例的思想来设计的。

2.2 如何“画饼”

被画饼的对象的记性要好,否则再大的饼也是白化。画饼的本质:在你的大脑中构建一个蓝图 ——数据结构对象:

struct 蓝图
{
    char* who;
    char* when;
    char* target;
    char* money;
    //岗位
}

如果画的饼太多,记不住了,怎么办?比如:老板:今年好好干,明年升你为部门总监;一天之后,对同一个人说:今年好好干,明年升你为经理,员工:……你个老b登。因此为了避免这样被群众吃瓜的事情发生,不仅员工需要被管理,饼也需要被管理。

地址空间的本质:是内核的一种数据结构:mm_struct,因此就可以通过管理mm_struct结构体从而管理地址空间。

2.3 地址空间的区域划分

在此之前,大家要确定一个共识:

  1. 地址空间描述的基本空间大小是字节。
  2. 32位下,有2的32次方个地址。
  3. 2的32次方个地址 * 1字节 = 4GB的空间范围
  4. 每一个字节都有唯一的一个地址,并且都是虚拟地址(unsigned int (32bits))

确定共识之后,接下来就需要引入地址空间区域的划分了,为了能够更好理解,这里举个例子: 男孩与女孩为同桌,因男孩经常欺负女孩,于是女孩讨厌这个男孩。一天,女孩发现自己无法容忍,于是画了一条边界平分桌子的空间,并且对男孩说:你要是过了这个边界,我就揍你,男孩不以为意,随口答应了下来。

但是,这个男孩仍然经常不注意,由于体型原因总是越过这条线,于是就和女孩解释他也不是故意的,为了避免出现这种情况,女孩想了一个办法,各自将线退后5cm,余下的10cm就是缓冲地带,两个人都可以使用,这样也可以防止越界的情况发生。

但即便这样,一段时间后,男孩更加肆无忌惮,女生忍无可忍,又提出改变了一次划分,只给男孩3/10的空间,越过了就揍男孩。

那实际上,上面的区域的划分我们可以通过一个结构体去表示:

struct Destop
{
    //给男孩划分了区域
    unsigned int boy_start;
    unsigned int boy_end;
    
    //给女孩划分的区域
    unsigned int girl_start;
    unsigned int girl_end;
}

那么上面多次对桌子分配的区域所进行的调整,就可以看成定义的结构体初始化变量不断改变内部成员变量的过程:

struct Destop d = {1, 50, 51, 100};//一开始
struct Destop fix1 = {1, 45, 55, 100};//第一次改变
struct Destop fix2 = {1, 30, 31, 100};//第二次改变

这个女孩做的就是所谓的区域的扩大 , 而我们实际上的地址空间的划分以及调整,与我们所举的例子是一模一样的,就是通过改变边界的大小,从而实现地址空间的动态分配! 而桌子在规定的界限范围内,放置书包、笔记本等物品,也就对应了我们在计算机系统的虚拟内存中指定的区域进行数据的分配。而男孩和女孩也就对应mm_struct结构体。

那么我们所理解的虚拟地址空间的区域划分,实际上也就是根据这个结构体中的变量划分的:

2的32次方个地址,每一个都是虚拟地址,整个空间也都是虚拟空间。而我们知道堆和栈都有可能进行空间的扩大(malloc, new),本质上就是修改各个区域的start或者end。free空间的时候也就对应着缩小stack/heap。

我们上面也提到过,操作系统会给进程画大饼,也就是说,每一个进程被创建出来,形成对应的task_struct(进程控制块),都会有2的32次方个空间(4GB)里面包括进程的pid、ppid、进程优先级、进程属性等,每个task_struct都会对应一个mm_struct(每一个都是大饼),task_struct通过其中的指针变量指向对应的mm_struct

纠正根深蒂固的错误:

我们通过查看struct task_struct的源码,发现确实有这么一个指针mm:

那我们继续看mm指向的内容究竟是什么:

正如我们所料,其中指向的空间里面的内容,就是各种进程的边界变量(截取部分)

这也就恰恰证明了,我们之前一直所谈的C/C++地址空间这个叫法是个错误的,其实际上是进程的地址空间! (学语言的时候没办法,涉及不到这些没办法解释)

3. 进程地址空间与内存的关系

3.1 虚拟地址和物理地址

1. 虚拟地址的排布

通过上述的描述,我们已经知道,我们所编写的代码的位置都是在进程地址空间上,也就是虚拟的地址。虚拟地址都是连续的,因此我们也称之为线性地址。就比如二维数组,其地址也都是连续的,我们可以两个for循环打印,也可以一个for循环打印。

2. 物理地址的排布

那么我们通常所说的物理地址也就是内存与磁盘经常会产生联系,即数据在内存与磁盘间传输的过程我们称为IO,IO的单位是4KB,那么我们就将内存中4KB的大小空间看成一个page页,因此对于内存的数据来说,如果内存的全部大小为4GB,那么我们可以把内存分割成4GB/4KB个page页,即我们可以将内存想象为一个结构体数组:struct page mem[4GB/4KB],通过偏移量就可以访问内存中所有的page页,也就可以访问到内存的所有数据。

3. 二者之间的关联

而对于这些虚拟的地址实际上作为数据来说,也需要存放在物理地址的某一个位置,因此这就会与内存产生关联。而虚拟地址与物理地址产生关联的媒介就这样产生了,我们将这个媒介称之为页表。(由于页表的内容过于复杂,在这里仅仅是引出这么个名词方便后续解释)

在这里解释一下为什么页表过于复杂,通过下面的页表我们可以看到,每一个地址就有一个页表需要映射,一个页表中有两个地址也就是8个字节需要存储,那么2^32*8想一想也是个非常复杂的空间,并且其中的页表还是以树状的形式存在,因此关于页表的知识这里不做重点,后续会讲。

即如果内存中的某一个位置c=10,当我们编写代码时,代码的数据首先会被加载到虚拟地址中,通过页表的映射,映射到了相应的物理地址,假设机缘巧合下恰好映射到了如上图的位置,就会将原有的数据修改为新的数据,而这个映射的虚拟地址和物理地址之间也肯定是不同的(毕竟一个是虚拟的,一个是物理的)

因此我们能做的,就是编辑代码让其在虚拟地址上保存,而通过页表映射到内存等其他的所有工作,都是由操作系统自动帮你完成的。

3.2 多进程的映射关系

在上面我们所讲解的内容,是基于一个进程的逻辑对应的关系,那如果我们有两个进程,我们先来看看逻辑图:

到了多个进程,我们就以最先举的大富翁的例子来解释,当进程1和进程2同时编辑代码数据时,当我们开始运行,其不可能直接看到内存,这两个进程只能看到自己所对应的mm_struct(虚拟地址空间),这也正对应了大富翁并不允许儿子们直接看到他的十亿资产,而正如大富翁给儿子们画大饼一样,操作系统在处理这两个进程时将其编译到虚拟地址空间以及页表的过程就是操作系统给进程画的大饼,因为mm_struct都对应着2^32个地址,实际上操作系统并不允许任何一个进程完全占用所有的内存空间。

4. 如何理解进程地址空间

4.1 为什么存在地址空间

1:保证安全性

1. 如果让进程直接访问物理内存,万一进程越界非法操作呢?这其实是非常不安全的。通过页表可以对非法的虚拟地址进行拦截,相当于变相的保护物理内存。

2:保证独立性

在谈第二点之前,有件事情需要我们处理——>解释1.2中的现象

那为什么相同地址下父进程和子进程的数值不同呢? 当我们创建完mytest.c文件时,说明已经将数据存放到了磁盘中,而当程序开始运行时,其数据就会被加载到物理内存中,global_val=100也就被存放在了内存的某一块地址,由于父进程和子进程都需要访问global_val,于是global的内存中的地址就会通过页表映射到虚拟空间的某一个地址中,于是父进程和子进程就可以通过虚拟空间中的地址去访问global,并且打印时父进程和子进程对应的global对应的虚拟地址也是相同的,因此开始时我们能看到父进程和子进程对应的global_val的数值和地址都相同。

当子进程要改变global_val的值,由于进程与进程之间的独立性,一个进程改变变量不能影响另一个进程的同一个变量的改变,因此子进程一旦要改变global_val,操作系统就会将子进程页表与内存的物理地址之间的联系断开,并在物理内存的另一个位置将原来物理地址的数据拷贝过来,这一操作被称为写时拷贝。 这样子进程改变global_val的值也不会影响到父进程的global_val,这个操作与虚拟地址也没有任何关系。因此我们所看到的子进程与父进程的虚拟地址仍是相同的地址。

即我们所提到的进程的独立性,就是因为:进程=内核数据结构+进程对应的代码和数据 ,内核数据结构是独立的,不同进程对应的代码和数据也是不一样的,因此进程就是独立的。因此我们也总结出了第二点:

2. 地址空间的存在,可以更方便的进行进程和进程的数据代码的解耦,保证了进程独立性这样的特征。

3:保证统一性(最难点)

接下来就要引入第三点,这一点也是从初学Linux到现在所碰到的第一个难关!(在Linux下,虚拟地址和逻辑地址是一样的。)

  • 先来个灵魂拷问:当我们写了一个程序在磁盘中,当他未载入到磁盘时,其内部的函数和变量有地址吗?

答案当然是肯定的。在程序编译链接的时候,磁盘中的程序就有了地址,这个地址也被我们称为逻辑地址(虚拟地址)

  • 虚拟地址空间的规则只有操作系统会遵守吗?

当然不是,不仅OS需要遵守,编译器同样需要遵守!也就是说上个问题中(编译链接的过程)编译器在编译你的代码的时候,就是按照虚拟地址空间的方式进行编址的!

下面就看一下具体的划分:

假设是以32位地址空间编址的。在编译时,main中的fun()会通过逻辑地址跳转到定义的fun()函数,当代码加载到内存时,这个逻辑地址仍然存在,也就是程序内部使用的地址在加载到内存中时仍然存在,但当我们将代码加载到内存时,代码既然也是数据,那么就一定需要在物理内存中的某个物理地址进行保存,此时这段代码既有外部的物理地址,也有内部的逻辑地址,相当于有了两套地址!

那么,当这段代码通过页表的映射加载到进程的mm_struct(虚拟地址)时,这段代码就被存放在这个进程对应的进程地址空间中,这个过程就是物理地址通过映射传输的,也就是说物理地址此时扮演的只是交通工具的作用;**那么当CPU的寄存器比如pc指针通过指令读取此代码时,出来的是物理地址还是虚拟地址呢?一定是虚拟地址!**深思熟虑许久,我觉得可以同时从两个方面去理解:其一是因为在Linux系统中的指令,天然的CPU指令读取的自然是虚拟地址;其二是因为物理地址在这个过程中只有映射作用,也就是说程序加载到虚拟地址空间时,原本的物理地址还停留在内存中,代码剩下的仅仅是虚拟地址。

当CPU找到了虚拟地址之后,就会通过页表的映射,按照来时的路线去寻找内存中的main()函数的代码,将这个实际存在的代码通过CPU读取:

当从main函数中出来再次调用fun()函数,出来的是物理地址还是虚拟地址呢?答案当然还是虚拟地址!原因与上述的理解相同,那么此时在cpu中执行完了main()函数,通过找到的fun()函数的虚拟地址再次原路返回映射到内存,这时候CPU就调用了内存中fun()函数的代码,因为内存中的fun()函数的代码才是实际存在的。

我们发现,通过上述的物理内存的映射与寄存器的读取,整个代码跳转的逻辑就那么一点点的转起来了,这个过程确实抽象!我们也发现CPU在运行中根本接触不到物理地址,接触到的都是虚拟地址!

  • 那么第三点也就出来了:

3.让进程以统一的视角,来看待进程对应的代码和数据等各个区域,方便编译器也以统一的视角来进行编译代码(使用和编译的统一是指都是在虚拟地址空间的统一,因为规则一样,所以编完即可使用。)

4.2 存在地址空间的总结

就是4.1中所谈到的:(3条)

  • 1. 如果让进程直接访问物理内存,万一进程越界非法操作呢?这其实是非常不安全的。通过页表可以对非法的虚拟地址进行拦截,相当于变相的保护物理内存。
  • 2. 地址空间的存在,可以更方便的进行进程和进程的数据代码的解耦,保证了进程独立性这样的特征。
  • 3.让进程以统一的视角,来看待进程对应的代码和数据等各个区域,方便编译器也以统一的视角来进行编译代码(使用和编译的统一是指都是在虚拟地址空间的统一,因为规则一样,所以编完即可使用。)