Linux:进程地址空间的奥秘

目录

程序地址空间

虚拟地址和物理地址

地址空间

写实拷贝原理


程序地址空间

        顾名思义,程序地址空间,就是程序的地址空间,在C/C++中,一直都存在一个观点是,程序中的各个变量等都会有地址空间,因为才会有取地址,通过地址访问等操作。我们知道,内存会被划分为几个区域:栈区,堆区,全局/静态区,代码区,字符常量区。在刚接触语言层面时的疑问:这些数据和所谓的地址是内存中的地址吗?内存中的地址存储排列形式如此整齐吗?不会造成内存浪费吗?但是真正意义上它是进程地址空间,是虚拟内存。请继续往下学习。

1.进程地址空间不是物理内存。

2.进程地址空间会在进程的整个生命周期内一直存在,直到进程终止退出。

所以也非常好的诠释了为什么全局/静态变量的声明周期是存在于整个程序。 

为什么这么说?依据是什么?请看下面的实验:

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

int g_val = 100;
int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        int cnt = 5;
        while(1)
        {
            printf("child, Pid: %d, Ppid: %d, g_val: %d, &g_val=%p\n", getpid(), getppid(), g_val, &g_val);
            sleep(1);
            if(cnt == 0)
            {
                g_val=200;
                printf("child change g_val: 100->200\n");
            }
            cnt--;
        }
    }
    else
    {
        //father
        while(1)
        {
            printf("father, Pid: %d, Ppid: %d, g_val: %d, &g_val=%p\n", getpid(), getppid(), g_val, &g_val);
            sleep(1);
        }
    }

    sleep(100);
    return 0;
}

 实验结果如下:

child, Pid: 6781, Ppid: 6780, g_val: 100, &g_val=0x60105c
father, Pid: 6780, Ppid: 30413, g_val: 100, &g_val=0x60105c
child change g_val: 100->200
child, Pid: 6781, Ppid: 6780, g_val: 200, &g_val=0x60105c
father, Pid: 6780, Ppid: 30413, g_val: 100, &g_val=0x60105c

这就出现了一个很神奇的现象,父进程和子进程的g_val,取地址后地址显示是一样的,子进程肯定先跑完,但是子进程修改g_val之后,他们读取出来的值却又不一样了,但是后面地址一模一样呀!所以就很好的诠释了,这只是虚拟地址,并不是真正的物理地址!要相信程序代码和数据一定是存在物理内存上的,这个地址分布图也是正确的,没有任何问题,可是出现的情况来看,推测就是OS要负责把虚拟地址转化成物理地址。

虚拟地址和物理地址

那么操作系统是如何将虚拟地址和物理地址互相转换的呢?

我们知道,进程由进程代码、数据以及内核数据结构共同构成。当一个进程被创建时,系统会为之生成对应的进程控制块(PCB),PCB 用于对进程数据进行管理。在进程内部,数据会依据其具体类型,被放置于不同的地址空间,诸如栈区、堆区、代码区等。

需要注意的是,这些地址空间本质上属于虚拟地址。鉴于诸多因素(后续将详细阐述),系统中存在一种名为页表的映射机制,它能够实现虚拟地址与物理地址之间的一一映射。具体的映射表现形式如下所示:

此图片展示了页表的映射关系,前面的图片是虚拟地址,打印信息并非实际物理地址。真正的物理地址通过页表与虚拟地址一一映射,通过映射找到的物理地址才是实际存储信息之处。 

一开始学习进程的时候,学过当使用fork创建子进程的时候,为子进程创建自己的PCB。对于代码和数据呢?其实是继承了父进程的,但是如果子进程发生了数据变化就使用写时拷贝完成一份拷贝,这样可以保证所有进程的互不干扰的独立性,因此对于上面的场景,当对于创建子进程的时候,本质上就是直接复制了一份上面图片中的内容,并将这个task_struct变成子进程。这样我们就知道了,子进程是继承父进程的,父子进程的代码共享。如图:

如果当子进程和父进程发生改变数据,就会发生写时拷贝。如图:

那么也就很好的诠释了第一个程序为什么会出现一个变量产生两个结果,但是地址是一样的现象了。如图:

写时拷贝是发生在物理内存中的拷贝过程,整个过程是由操作系统来完成的,保证了进程之间的独立性。

地址空间

看完图后还是不能完好的理解它,它到底是什么呢? 

举例: 

一位拥有 10 亿美元家产的富豪,有 3 个彼此不知对方存在的私生子。他对每个私生子都承诺 10 亿家产未来归其所有,使得每个私生子都认为自己能拥有 10 亿。若私生子同时一次性索要 10 亿,富豪无法拿出,但实际私生子一般是几千几万地要钱,富豪有就会给,若不给,私生子也只会觉得是父亲不愿给,而不是认为他没有。

富豪在每个私生子脑海中建立起了自己拥有 10 亿美元的虚拟概念。

类比:

富豪:操作系统

私生子:进程

画的饼:进程地址空间

操作系统默认会给每个进程构建一个地址空间的概念(比如在 32 位下,把物理内存资源抽象成了从 0x00000000 ~ 0xFFFFFFFF 共 4G 的一个线性的虚拟地址空间)
假设系统中有 10 个进程,每个进程都会认为自己有 4G 的物理内存资源。(这里可以理解成 OS 在画大饼)

既然每一个进程都会有这么一个空间,那么操作系统就要对其进行管理,那么OS如何进行管理呢?先描述,再组织。在 Linux 中,地址空间其实是内核中的一种数据结构。OS 除了会为每个进程创建对应的 PCB(即 struct task_struct 结构体),还会创建对应的进程地址空间,即内核中的 struct mm_struct 结构体。

Linux内核源码中,来查看这个结构体的存在性:

 转到关于它的定义,观看它内部的定义实现方式:

空间的本质无非就是多个区域(栈、堆…)的集合。对于mm_struct来说,它通过定义各个区域的起止位置来进行管理数据。

struct mm_struct {
    // ...
    
    unsigned long code_start;   // 代码区起始虚拟地址,比如 0x10000000h
    unsigned long code_end;     // 代码区结束虚拟地址,比如 0x00001111h
    
    unsigned long init_start;   // 已初始化数据区
    unsigned long init_end;
    
    unsigned long uninit_start; // 未初始化数据区
    unsigned long uninit_end;
    
    unsigned long heap_start;   // 堆区
    unsigned long heap_end;
    
    // ...
};

为什么要进行区域划分呢?可以通过 [start, end] 进行初步判断访问某个虚拟地址时,是否越界访问了。因为可执行程序在磁盘中是被划分成一个个的区域存储起来的,所以进程的地址空间才有了区域划分这样的概念,方便进程找到代码和数据。虚拟地址的本质:每个区域 [start, end] 之间的各个地址就是虚拟地址,之间的虚拟地址是连续的。

进而就诠释了这张表的形成:

为什么要有地址空间呢?

直接让进程去访问物理内存不行吗?早期,操作系统是没有进程地址空间的,这就导致物理内存暴露,恶意程序可以直接通过物理地址来进行内存数据的读取,甚至篡改。后来,随着操作系统的发展迭代,有了进程地址空间(虚拟地址),由操作系统完成虚拟地址和物理地址之间的转化。

先说结论:

  • 让进程以统一的视角看待内存,任意一个进程,都可以通过地址空间和页表,将杂乱无序的内存数据变成有序的空间,也就是说这是一个变无序为有序的过程
  • 存在虚拟地址空间,可以有效的进行进程访问内存的安全检查
  • 将进程管理和内存管理进行解耦合
  • 通过页表,可以让进程映射到不同的物理内存中,从而实现进程的独立性

解释:

1.变无序为有序的过程:

由于页表的存在,因此具体的实际内存中的数据不必排放到一块,而是可以进行不同位置的存储,但是在管理的角度来看,通过虚拟地址来进行管理是相当方便的,每一个地方都被分门别类的具体一一列举了出来,这样不仅便于管理,同时也可以最大化的利用内存中的空间。

2.访问内存的安全检查:

讲到这点,就必须对页表进行进一步的补充说明了,实际上页表中存储的不仅仅有虚拟地址和物理地址,还有其他很多的信息,例如这里的访问权限字段:

那么首先是解释访问权限字段存在的意义:可以有效避免进行修改,保护进程的数据等功能,例如下面的这个具体事例:

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

int main()
{
	char* str = "hello linux";
	*str = 'H';
	return 0;
}

在gcc的编译器下,这是可以通过编译的,原因是这里的一个常量字符串的起始地址交给了一个字符指针str,而对于str来说将它的指向内容改成H,这个本身是可以的,但是问题出现在,str指向的内容实际上是一个字符常量区,而这个区域内的数据是不可以被修改的,因此如果要进行修改的话是不被允许的,那么页表是如何进行保护的呢? 在执行程序的时候会引发段错误,这就是页表的功劳,当使用虚拟地址进行映射到物理地址的过程中,在进行页表的权限访问字段的时候会发现这个字段的访问权限是只读权限,但是现在要进行写入,很明显这是不被允许的行为,因此就会终止这种行为,因此页表中的权限访问字段就有这样的功能,可以进行访问内存的安全检查。

3.进行解耦合 

操作系统的核心功能:内存管理、进程管理、文件管理、驱动管理。

1.没有进程地址空间时,内存管理必须得知道所有的进程的生命状态(创建、退出等)才能为每个进程分配和释放相关内存资源。所以内存管理模块和进程管理模块是强耦合的。
2.而现在有了进程地址空间,内存管理只需要知道哪些内存区域(page)是被页表映射的(已使用),哪些是没有被页表映射的(未使用),不需要知道每个进程的生命状态。当进程管理想要申请内存资源时,让内存管理通过页表建立映射即可;想要释放内存资源时,通过页表取消映射即可。解耦的本质也就是减少模块与模块之间的关联性,所以就是将内存管理模块和进程管理模块进行解耦了。

 页表:

写实拷贝原理

我们知道,fork 创建子进程后,子进程与父进程共享代码和数据。一旦其中一方修改数据,就会触发写时拷贝,将原数据复制一份,让修改数据的进程的数据段指向新拷贝的数据段,以此维护进程独立性。那么该过程具体如何实现的呢?操作系统又是如何执行拷贝操作的呢?

原理其实就是上图所展示的原理,当进程没有遇到fork之前都按照正常的逻辑进行运行,代码段和数据段对应页表中的访问权限是默认的情况,而当遇到fork这一系统调用的时候,在进行创建子进程的这个过程中,就会将数据段和代码段对应到页表中地址空间内的访问权限字段全部改成只读的权限,当进程运行到需要进行修改数据的操作的时候,就会通过页表去物理地址空间内进行修改,但是此时页表对应的访问权限字段的访问权限是只读,不允许发生写入的操作,此时操作系统就会去辨别这是什么原因导致的出错

也就是说,当页表的转换发生权限问题进行报错的时候,实际上是有两种可能的,一种是说真的出错了,比如要在字符常量区发生写入的改变,这肯定是不允许的,但还有一种情况是不是真的出错,而是触发了要让操作系统进行写时拷贝内容的一种策略,操作系统在观察到进程在运行到某个地方出现异常的时候就会去看具体的原因是什么,发现是触发了这个策略后,操作系统在这个时候就介入了这个阶段进行修改,进行拷贝等等的一系列操作。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值