自学内容网 自学内容网

Linux——进程

1.冯·诺依曼结构

   

        冯·诺依曼结构作为现代计算机发展所遵循的结构,其主要思想包括:

①将计算机分为了五个基本部件:

        运算器:进行四种基本算术运算和一些逻辑运算等;

        控制器:协调和控制计算机中各个组件的工作;

        存储器:存放数据与指令;

        输入输出设备。

其中,运算器和控制器组成了中央处理器(CPU),而存储器则是主存(内存)。

②采用“存储程序”的工作方式。

        引入“存储”是冯·诺依曼结构的重要特点之一,数据在计算机体系结构中流动并被加工。设备之间相互连接,数据在设备之间的流动本质就是拷贝操作,所以拷贝效率决定着计算机整体的基本效率。如存储金字塔所示,位于更高层次的存储设备速度更快,存储单位内容价格也更高。

        和CPU相比,输入输出设备的效率要慢很多,因而引入了存储。外设的数据不会直接给cpu,而是先存入内存,cpu也不和外设交换数据,只和内存进行数据流通。这样就使得在高效率cpu和低效率外设之间增加一个“蓄水池”作为缓冲作用,整体的效率就提高了。

③程序在执行时会被加载到内存中,指令和数据都是以二进制的方式存储。

2.操作系统

        要理解进程,我们首先需要先来理解操作系统是什么。此处只对操作系统做一个简单的介绍,实际上操作系统包含了诸如内存管理、进程管理、文件管理、驱动管理等多种内容。

         以操作系统为界限,其下是硬件资源,其上是软件资源,因而操作系统就是一个对软硬件资源进行管理的软件

        操作系统需要完成的第一件事就是管理好硬件资源,使得可以高效、安全地完成工作。每一种硬件都有自己对应的驱动程序,而操作系统面对众多硬件驱动,则是通过某种数据结构将其信息组织起来,将对硬件的管理转化为了数据结构中增删查改的操作。这就体现了操作系统的管理逻辑:先描述,再组织:将需要管理的东西描述为一种对象(如结构体)记录其关键信息,然后再通过一种特别的数据结构将众多对象组织起来。

        操作系统的第二件事就是给用户提供一个高效安全的运行环境。①操作系统将其自身与下层的硬件封装起来,形成操作系统内核,所有用户层对硬件的访问行为都必须通过操作系统才可以实现。②在封装之后,为了安全考虑,不允许用户直接对操作系统进行操作(因为操作系统管理的各种信息可能被破坏),因此再封装一层,只提供一些系统调用的接口来用于访问操作系统。自此,用户便可以通过系统调用接口来对操作系统进行操作了。③但是使用系统调用接口需要对系统有一定的了解,因此操作门槛较高。于是再进行封装一层, 形成了如shell外壳程序、库(如c标准库等)、图形化界面等一系列用户操作接口。在此之上再进行管理和使用就容易不少了。

3.进程

        进程,一般的理解就是正在运行的程序的实例,即程序被载入了内存就形成了一个进程。实际上进程在内存中体现为两部分:内核数据结构(PCB)程序的代码及数据

3.1 PCB(process control block)

        操作系统的工作之一就是管理进程,而管理进程我们仍然需要“先描述,再组织”。PCB意为进程管理块,每一个进程都有一个PCB,其作用就是作为一种描述进程各种信息属性的数据结构存储在操作系统里,便于操作系统对进程进行管理。而数据代码部分则存入了内存中。

struct PCB

{

        //所有进程相关的属性

        //如pid、内存指针(指向自己内存的数据与代码)、进程执行中寄存器值(调度暂存)等

        struct PCB* next;//链表式结构

}

        于是OS可以借助PCB来完成多种控制操作,如进程调度,实际在OS的视角下就是对一个个的PCB进行排队。以具体的操作系统Linux来深入地了解一下操作系统中的PCB。在Linux中,PCB被定义为一个叫作task_struct的结构体。

3.2 进程的创建(父子进程)

        创建进程需要用到系统调用函数fork()。

        fork的作用就是创建一个新的子进程,返回值是pid,对于父进程返回子进程的pid,对于子进程返回0。

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

size_t val = 0;
int main()
{
    pid_t id;
    id = fork();
    if(id > 0)
    {
        while(1)
        {
            printf("我是父进程,pid:%d,ppid:%d,val:%d\n",getpid(),getppid(),val);
            val += 1;
            sleep(2);

        }
    }
    else
    {
        while(1)
        {
            printf("我是子进程,pid:%d,ppid:%d,val:%d\n",getpid(),getppid(),val);
            val += 2;
            sleep(2);
        }
    }
    return 0;
}

        对于以上代码,可以看到执行结果如下。可以看到一份代码中的if和else语句同时生效了,这就是创建了父子进程的原因。

        对于一个进程可以通过命令查看相关信息:

ps axj | head -1 && ps axj | grep xxx

ps axj——查看进程信息

head -1——打印第一行

&&——同时执行两条命令(;也具有同样的效果)

grep xxx——过滤

        可以发现显示了一些信息,其中PID是进程的唯一标识符,可以通过系统调用getpid()获得当前进程的pid;PPID是父进程的PID,一个进程创建了另一个进程,父进程即为创造者,子进程就是被创造者。如图所示,可以看出第一个进程的pid和第二个进程的ppid一样,这就说明他们是父子进程关系,这就是fork实现的。而第三行则是查找grep命令的进程(查找并显示这些信息也是一个进程完成的)。

        首先需要明确,在fork函数后父子进程共享代码,即二者执行的是同一份代码。不同的是对于数据二者相互独立各有一份,于是对于父进程的数据修改不会影响到子进程(在修改时进行写时拷贝将父子数据独立起来)。

        在fork函数内,进程已经被创建,进程之间相互独立,各自返回各自的返回值(父进程返回子进程PID,子进程返回0)并向下继续执行代码。因而两个进程进入到了不同的分支语句中。

        对于每一个进程,都会在/proc目录(非磁盘级文件,可以将内存中的数据以文件的形式呈现)下创建一个以PID命名的文件夹,目录中包括多种信息。其中,cwd指的是当前工作路径(current work dir),当如fopen在当前工作目录新建文件时就会参考这个值,可以使用系统调用chdir()来修改cwd。另外还有exe,标识程序所在路径。还有uid表示进程的执行者,可以用来比较判断是否有操作权限。

3.3 进程的状态

        对于一个进程,可以定义出其所处的状态。笼统的状态定义如下图,一个进程被创建后进入就绪状态,等待CPU进行调度;在运行时可以被中断(可能是时间片到达)从而再次等待调度,也可能由于IO设备没有就绪而处于等待(阻塞);当IO设备就绪后就转换为就绪,等待调度。状态信息也在PCB结构体中存储记录。

        运行一般指的是该进程的指令正在被执行,即CPU正在处理该进程的代码数据。

        就绪则是已经可以执行,准备收到CPU调度的状态。OS中可以使用一个运行队列来组织这些待执行进程的PCB,当进程PCB在运行队列中,则说明进程已经准备就绪,可以被调度了,等待CPU取出。

        等待(阻塞)是程序正在等待IO或时间的完成,一般发生在当硬件资源竞争时,等待资源的过程。在此阶段,OS中的PCB实际上已经不在运行队列,而是被取出到了硬件的等待队列中,等待硬件资源。

        挂起则是在内存资源严重不足时,OS为了释放内存压力,会选择对一些进程进行挂起,即将其在内存中的代码数据等唤出到磁盘的一个swap分区,此时就是挂起态。因为PCB还留在OS中,当一切就绪,可运行时再将swap分区中的代码数据唤入内存。

         在计算机内部,单个CPU执行代码并不是完全执行完一份再去执行下一份,而是给每个进程预分配一个时间片(10~100ms)。根据这个时间片,进程之间进行调度轮转,每个进程执行完预定时间片后就封存当前上下文,然后调度下一个进程来执行,如此循环往复。

        这也就是并发的概念,即多个进程在一个CPU下采用进程切换的方式,在一段时间内让多个进程同时推进。因为时间片和CPU切换速度很快,所以用户感受仿佛CPU同时处理了多个进程一样。以此为理念的操作系统称为分时操作系统,它追求调度任务的公平,即推进速率相同。而相对的实时操作系统可能会更偏向于优先执行某些进程。

        与此相似的还有并行,即多个进程在多个CPU下同时运行。此时存在多个CPU,是真正同时处理了多个进程。

        对于特定的操作系统Linux,可能和笼统定义有区别,可以具体分为如下几种状态:

①R(running)0

        R状态是进程处在运行的状态,即对应着在运行队列的情况,可以被CPU执行。

②S(sleeping)1

        S状态是可中断睡眠状态(浅睡眠),正在阻塞等待IO设备等就绪。当内存严重不足时OS有权利杀死进程来释放空间,处于S状态的睡眠认为可以被OS杀死。

③D(disk sleep)2

        D状态是不可中断睡眠状态(深睡眠),也是阻塞状态,但是实在等待IO的反馈,不可以被杀死。

④T(stopped)4

        T状态是暂停状态,是进程做了非法但不致命的操作而被OS暂停。亦可以使用信号kill -19 pid和kill -18 pid来暂停或唤醒进程。

⑤t(tracing stop)8

        t状态指程序被追踪时停下,如在调试中遇到断点。

⑥X(dead)16

        X状态是被OS释放的状态,是一瞬间的状态。

⑦Z(zombie)32

        Z状态是僵死状态,是进程退出但是父进程并没有对其返回信息进行接受处理的状态。

3.3.1 僵尸进程

        僵尸进程是一种特殊的进程状态,就是处在Z状态下的进程。

        我们首先要理解一下进程执行完成退出的过程。

释放代码和数据:我们已知进程=内核数据结构+代码和数据,当进程退出时,就说明不可能再执行其代码了,因此首先做的事就是立即释放进程的代码和数据。

退出信息存储到PCB:在进程退出后会有退出信息,即进程的退出码。如main函数最后的return返回值就是其退出码,当为0时表示正常退出。这个退出信息将会保存在自己的task_struct内部。

父进程读取退出信息:进程的退出信息需要父进程来读取处理,在未被读取之前,OS会一直维护着task_struct,直到被父进程处理。

        很明显,如果父进程始终不处理僵尸进程,那么僵尸进程的PCB则会一直留存在内存中,这就导致了内存泄漏。任何进程都有父进程,他们的退出信息都需要父进程来读取处理,如果在命令行中运行起来的进程其父进程就是bash,bash会自动回收其Z状态的子进程。

3.3.2 孤儿进程

        还有另外一种比较特殊的进程状态,称为孤儿状态。

        孤儿进程顾名思义就是没有父进程的进程,实际上这种进程是因为父进程在子进程之前先退出了。为了能够正常的让进程结束回收,1号进程(OS本身)会“领养”孤儿进程,从而将其正常结束。

3.4 进程优先级

        查询系统进程信息,我们得到如下结果:

        其中UID、PID、PPID已经介绍过了。本次要介绍进程优先级,就需要用到PRI和NI两个值。

        进程优先级表示的是进程获取某资源的先后顺序,也会体现在PCB的某一字段中,因为资源是有限的,所以才需要引入进程优先级的概念来控制调度顺序。PRI(new)=PRI(default/old)+nice,进程优先级PRI越小表示优先级越高。nice值,实际就是NI,是进程优先级的修正值。因为进程在运行时无法直接修改优先级,所以需要一个nice值来进行动态修改,在下一时间片时PRI将会加上nice值而更新。

        通过nice指令可以查看nice值;通过renice可以修改指定进程的nice值;通过top可以选择想要修改优先级的进程,然后输入r后修改nice值。在修改中我们发现nice值的范围是[-20,19]一共40个级别,PRI的值则是默认的PRI和nice值加和得到的[60,99],因此也具有40个级别。

        这40个级别实际上与进程调度息息相关,我们将在下文介绍。

3.5 进程切换

       我们已经知道了CPU处理进程是基于时间片的进程切换轮转的方式进行的。我们深入了解进程切换的工作,事实上进程切换就是进程上下文数据的保存和恢复。以下一步步理解进程切换的步骤。

       ①进程A正在CPU中执行,其中涉及到很多临时数据,在CPU中以寄存器来保存这些数据。我们假设有如下指令,以X86为例,虽然X86的指令不是定长的,但是我们为了叙述简单,假设出其地址。

00  mov eax,100

04  mov ebx,200

08  add eax,ebx

        ②用简单的单周期CPU来考虑(实际上流水线CPU道理也相同)。首先程序计数器pc从00地址处开始,指令寄存器ir根据pc取出了第一条指令并进行译码,pc自增变为04,然后成功向eax中存入了立即数100。

        ③ir继续根据pc取出第二条指令,pc变为08,这一次向ebx中写入了立即数200。

        ④在执行完两步mov指令后,eax、ebx中存储了将要被用于add的操作数。此时的pc寄存器存储的值是08,同样的还有其他许许多多寄存器如标识栈顶栈底的esp和ebp等等都和进程执行息息相关,也有各自的值。

        ⑤此时,CPU宣布A进程时间片到了,需要将A进程从CPU上剥离下去,然后加载另一个进程B去执行。

        进程A并没有执行完自己所有的代码,又因为CPU处理进程B一定会覆盖原来进程A的寄存器存储的值。为了在下一次轮到A的时候可以继续从中断处继续向下执行,因此需要保存A进程的上下文。当一个进程在执行中CPU的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文

        ⑥对于要保存的上下文数据,一般存储在所属进程的PCB中。Linux则是定义了一个任务状态段数据结构tss_struct,将上下文数据以结构体的形式存储在task_struct中。

        ⑦当进程调度再次轮到A进程时,操作系统将tss中的上下文拷贝回CPU中,这样就恢复了A进程退出时的环境。ir继续从pc=08中拿到下一步add的指令,并完成加法运算。而操作数所在的寄存器也因为上下文写回而正确无误。

        如上我们就简单的模拟了核心通过将进程的上下文数据切走和切回的操作,完成了进程切换的目的。

3.6 Linux的调度算法

        我们之前提到了常规的CPU调度算法就是FIFO,即对CPU的一个运行队列runqueue进行task_struct的顺序出队入队。但实际上的调度算法并不如此,以Linux为例,实际上是考虑了进程优先级后设计的调度算法。

        对于Linux而言,其CPU的运行队列runqueue实际上如下图所示,其中尤为重要的是一个struct queue结构体数组,包含两个元素即分别是活跃进程队列和过期进程队列。

        对于这样的一个runqueue,我们可以拿到如下的示意图,我们从小到大地分析每一部分的含义。

        ①首先是task_struct* queue[140]。这部分是进程的排队队列,一共有140个位置,其中[0,99]前100个位置归属实时执行的进程,即一旦开始执行就必须执行完才会调度;而[100,139]这40个位置刚刚好对应了进程优先级的40个级别,分时执行的进程根据PRI以哈希桶的方式(单链表)按顺序一一映射到对应位置,如PRI=80的进程task_struct就应该在下标120的位置。

        ②然后是bit_map[5]。这部分是一个位图,以5*32=160个比特位表示task_struct* queue[140]对应位置处是否为空。这样可以通过以32位为单位遍历位图,快速找到不为空的位置从而调度出进程执行。

        ③nr_active表示task_struct* queue[140]数组中存在的进程PCB数量,用来快速判空。

        ④将task_struct* queue[140]、bit_map[5]、nr_active三者封装起来即可得到struct queue这个队列结构体,这也是runqueue中进程调度真正需要访问的位置。

        ⑤不难发现在runqueue中给出了struct queue array[2]这样一个包含两个队列结构体的数组,之所以用两个完全一样的队列结构体是为了调度效率起见。

        其中一个由struct queue* active指针指示,标识活跃的队列,当进程需要调度搜索进程时就会进入这个指针所指队列中,遍历寻找task_struct* queue[140]。当找到后就从队列中解绑,拿到CPU执行,如果进程代码全部结束PCB就不再放回队列,否则就放入第二个由struct queue* expired指示的过期的队列中。这样就保证了队列下次再遍历搜索不会再在active队列中再次提取出来同样的进程,保证了均衡调度的要求,避免了进程饥饿问题。

        ⑥这样active中进程越来越少,当通过active->nr_active判断到当前队列已经为空后,就证明所有进程完成了一轮调度,于是将active和expire指针进行交换,即expired队列成为新的active队列,于是继续开始下一次调度。

        ⑦当遇到新的进程插入时,会将其放在expired队列中,以保证active均衡安全地调度。

        如上所示,通过runqueue中的两个struct queue一个只出不进,一个只进不出,再加上struct queue中的位图快速遍历队列的操作,形成了Linux内核O(1)调度算法

        我们发现在调度过程中以及之前的等待状态等情况下,进程的task_struct会四处链接到不同的位置下,甚至有可能同时位于不同的队列中,难道这种情况下我们要对PCB进行拷贝然后链接吗?

        实际上task_struct中的双链表的链式结构是独立出来的,即定义了一个专门用于链接的将结构体struct node:

//只有链接字段,没有属性字段

struct node

{
        struct node* next;

        struct node* prev;

}

        这样再在task_struct定义这样的node成员即可作为链接的一环:

struct task_struct

{

        ...//进程相关属性

        ...

        struct node listnode;

        struct node queuenode;

        struct node waitnode;

        ...

}

        因此就可以为一个task_struct加入多个链接小单元,实现仅一份task_struct代码,通过增加链接字段(struct node成员)就可以同时被链接在不同的数据结构中。

        这样操作当访问next寻找后会发现得到的地址不是结构体的地址,而是结构体中对应的链接字段的地址。通过某一字段的地址得到结构体的地址并不困难,我们曾在C语言中使用过宏来计算偏移量得到结构体的地址。

        对于结构体task_struct,拿到了其链接字段link的地址p,那么对应的结构体地址就是:((task_struct*)(p-&((task_struct*)0->link))

        通过将0地址强转为task_struct,拿到其link字段的地址,因为结构体从0开始,所以取得的link地址就是link字段相对于结构体的偏移量。再用已知的link字段地址p减去偏移量即可得到结构体的地址,将这个数值强转为结构体指针类型就相当于得到了一个结构体指针。

4.命令行参数

        对于C语言而言,我们过去所写的main函数是没有参数的只是一个简单的int main()作为程序的入口。但在实际上main函数时具有参数的,我们在这里先介绍两个,分别是int argc和char* argv[]。

#include <stdio.h>

int main(int argc,char* argv[])
{
    for(int i=0;i<argc;i++)
    {
        printf("argv[%d]:%s\n",i,argv[i]);
    }
    return 0;
}

        对于以上代码我们通过不同的进程执行命令会产生不一样的效果:

        我们发现,main函数似乎将我们命令行中运行code程序的选项作为自己的参数了。事实也就是如此,argc和argv都是命令行参数列表,argc——参数的个数argv[]——参数的清单。我们在以往执行某些指令的时候都会附加一些参数,而这些个调用进程的命令作为一个字符串(如上图的./code -a -b -c -d),shell在拿到这个字符串后按照空格进行分割。于是形成了一个记录分割出来的元素个数的变量argc,还有一张包含分割后内容的表argv,表中最后一个位置的元素是NULL。

        因为命令行启动的进程的父进程都是shell,所以shell可以将命令行参数作为选项传递给子进程的main函数,而子进程在写main函数时,可以利用字符串匹配的方法,匹配不同的选项从而提供不同功能。

#include <stdio.h>
#include <string.h>

int main(int argc,char* argv[])
{
    if(argc != 2)
    {
        printf("wrong using\n");
        return 1;
    }
    if(strcmp(argv[1],"-a")==0)
    {
        printf("功能a\n");
    }
    else if(strcmp(argv[1],"-b")==0)
    {
        printf("功能b\n");
    }

    else if(strcmp(argv[1],"-c")==0)
    {
        printf("功能c\n");
    }
    else
    {
        printf("默认功能\n");
    }
    return 0;
}

5.环境变量

        环境变量,一般是指存在于系统中的全局设置具有全局属性,用于指定操作系统运行的一些参数。其形式类似于键值对,即“环境变量(均为大写)=值(环境变量的内容)”。通过env指令,我们可以看到当前系统中的所有环境变量。

        通过 echo $环境变量 可以打印指定环境变量的内容。以下重点介绍几个环境变量的例子:

①PATH:指示命令行解释器寻找可执行程序的路径

        我们在执行进程时都会在命令行输入./xxx,而这个./就是为了告诉shell所执行的进程处在当前路径下。但是我们平时所使用的各种cd、ls等指令却不需要./而可以直接使用,这就是PATH这个环境变量的功劳了。PATH通过 : 分割并记录了多个路径,当输入指令后,shell便会在PATH的所有路径中搜索对应的可执行程序。

        问题1:环境变量在系统中的存在形式

        那么PATH的这些路径是哪里来的呢,我们能否使我们的程序也和系统的指令一样可以随处执行呢。

        首先,不只是PATH,所有的环境变量最开始都是在系统的配置文件中,一般是位于家目录下的.bash_profile、.bashrc等文件中。我们在登陆系统后会启动shell进程,shell进程则会读取用户和系统相关的环境变量的配置文件,将其加载到了shell自己的进程中,于是我们在shell中就可以使用这些环境变量了。

        通过我们上面这么一说,就有了一个新的启发:既然环境变量会在shell进程中有,那么我们所写的代码中是否也可以有环境变量呢。

        答案是肯定的。环境变量在系统中以一个char** environ[ ]这样的一个表所管理着,其中每个元素都是环境变量字符串"key=value"的形式,表以NULL结尾。就这样,系统将众多的环境变量以表的方式组织起来了。对于shell而言,他就具有这样的一个表。

        当从shell启动一个进程,也就是启动一个shell的子进程,这时这个表会复制一份到子进程的中,于是子进程便可以拿到系统的环境变量了。

        其实说明白一点,一共两张表会在创建子进程的时候由shell递交给子进程:环境变量表、命令行参数表。于是main函数实际上有三个参数:main(int argc,char* argv[],char* env[]),分别是参数个数、参数列表、环境变量列表

        所以在我们的程序代码中想要访问环境变量有以下方法:

①由于子进程和父进程共享代码数据,所以可以通过extern char** environ;声明环境变量表后访问环境变量表获取。

②通过main函数的参数env也可以拿到环境变量的字符串数组,访问方式和参数列表相同。

③可以通过系统调用接口getenv(环境变量名)的方法,获得指定环境变量的内容。

        问题2:如何不带路径执行自己的可执行程序

        回答这个问题不难。因为命令行中输入的指令都是在PATH环境变量的路径下寻找,所以想要我们的程序也有这个能力,只需要将我们自己的可执行程序放在指定路径下,或者把自己的路径添加到PATH中即可。

新建环境变量的指令:export name=value

取消环境变量的指令:unset name

        当我们需要修改环境变量时(如追加PATH),只需要指令:PATH=$PATH:新增路径。其中$PATH就是原来的PATH内容(这是因为=是覆盖写,所以需要写入原内容),用冒号分隔不同的路径。

        但是发现这样操作后虽然添加成功了,但是下次再启动更改就会消失。这是因为在操作系统启动时PATH是从配置文件中载入shell的,此处的修改、新建、删除都是针对内存中的环境变量的修改,所以关闭再打开后修改会消失。要想能够永久修改,那就需要去上文提到的配置文件中修改了。

        问题3:本地变量vs环境变量

        上文提到了环境变量具有全局属性,这种全局的特性的来源就是因为shell中有环境变量表,而子进程会继承父进程的环境变量表,所以在shell之后的所以进程都具有一份环境变量表的拷贝,因此可以在全局任何一个进程中访问从而具有了全局属性。环境变量也因此作为只读数据可以在各个进程之间传递数据。

        与之相对的有本地变量,本地变量可以通过 name=value 的指令形式进行定义。本地变量也会在当前进程中以一个本地变量表的形式进行组织,但与环境变量不同的是,本地变量表不会随着子进程的创建而拷贝给子进程,所以只有该进程自己可见,所以是本地变量。

②SHELL:当前的命令行解释器

③USER:当前登录的用户

        通过这个环境变量可以作为权限判断的依据。

④PWD:当前工作路径

        pwd指令的来源。可以发现当删除全部PATH环境变量后,pwd依然可以运行。

⑤HOME:当前用户家目录

⑥OLDPWD:上一次工作路径

        指令“cd -”的来源。

6.进程地址空间

        在理解进程地址空间前,我们对于如下代码进行现象感知。

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

int val = 10;
int main()
{
    printf("一个进程,PID:%d,PPID:%d,val:%d,&val:%p\n",getpid(),getppid(),val,&val);
    pid_t id = fork();
    if(id>0)
    {
        while(1)
        {
            printf("父进程,PID:%d,PPID:%d,val:%d,&val:%p\n",getpid(),getppid(),val,&val);
            sleep(3);
        }
    }
    else
    {
        while(1)
        {
            printf("子进程,PID:%d,PPID:%d,val:%d,&val:%p\n",getpid(),getppid(),val,&val);
            sleep(3);
            val+=3;
        }
    }
    return 0;
}

 

        通过执行结果我们发现,父子进程的val值不同,这一点我们已经在上文提到。但是,我们发现,这两个不同值的val位于同样的地址下。这就让人费解,同样的地址处怎么会存储了两个不同的值呢,这就不得不提到虚拟地址空间的话题了。 

6.1 进程地址空间 vs 物理地址空间

        上面同一个地址存储不同值的最合理解释就是这个所谓的地址是虚假的。事实也是如此,这里所给出的地址实际上是虚拟地址,和真正内存中的物理地址是不同的。

        

        对于每一个进程而言,他们都有一个独属于自己的进程地址空间(如上图所示),在此之前我们在描述一个进程的堆栈、代码段、数据段等概念都是基于这样一个虚拟地址空间的。因为每一个进程都有自己的独立的进程地址空间,所以在每个进程看来自己完全占用了物理内存,于是无需考虑其他进程的行为,因而具有了独立性。

        但是实际上数据还是要落在实处,存储在真正的物理地址下,这就需要页表发挥作用。页表也是各个进程独有一份的,存储了虚拟地址和物理地址的映射关系,可以通过虚拟地址找到实际存储的物理地址

        通过上述结构,进程的所有内存操作都以进程地址空间的视角展开,其产生的虚拟地址通过页表可以找到物理地址,因此进程可以通过虚拟地址存取访问实际物理地址的数据。这样,当有多个进程同时使用内存的时候,因为进程使用虚拟地址空间,所以就不用关心和其他进程的内存冲突问题,而使用虚拟地址完成自己的工作。至于协调物理地址的问题就交给了页表,页表通过地址映射帮助管理虚拟地址。

6.2 进程地址空间的形式

        进程地址空间的本质就是内核中的一个结构体对象mm_struct,这个结构体对象也是task_struct的成员之一,mm_struct中的成员完成了各个区域的划分。

struct mm_struct{

        ...

        unsigned long total_vm, locked_vm, shared_vm, exec_vm;

        unsigned long stack_vm, reserved_vm, def_flags, nr_ptrs;

        unsigned long start_code, end_code, start_data, end_data;

}

        地址空间的地址虽然是地址,但本质上也就是一个无符号的整形数字。mm_struct结构体中使用了很多unsigned long类型的成员界定了各个区域的大小,如start_code, end_code就记录了代码区的开始位置和结束位置。32位机器可以标记2^32次方个地址,所以实际上虚拟地址就是数字,所以进程地址空间就使用数字来框定分区组织自己的虚拟空间,之后的事情就交由页表处理,找到实际的物理空间。

        mm_struct在进程创建时就被创建了,它的初始化需要划分各个区域的大小,而区域大小信息在编译时由编译器计算好了,就像程序编译后生成的指令esp申请栈空间一样。

6.3 子进程和写时拷贝

        回到上面的示例中,父进程创建了子进程,这时子进程首先会将父进程中包括链表在内的大部分内核数据结构拷贝一份。因为页表也是拷贝的,所以会导致父进程和子进程同样虚拟地址映射到同一个物理地址。

        这时当遇到对数据进行修改,因为映射同一块空间,如果直接修改那么会影响到另一个进程的数据。所以设计在对子进程的某一数据修改时,操作系统会为数据在物理内存中新建空间并修改页表映射,这就是写时拷贝。通过这样的设计可以在不修改时父子共用一块空间,直到修改后再分开存储,这样按需使用内存的写时拷贝技术,可以有效的节省空间。

         在CPU中有专用的寄存器如CR3,可以存放页表的物理内存基址,然后通过CPU的MMU单元将虚拟地址转化为物理地址再进行数据取用。对于页表而言,其实际上其还有在许多其他标志位,标识数据的读写执行权限、是否存在内存中(可能因为节省内存被唤入磁盘)。写时拷贝即可利用这些标志位,在没有拷贝前数据只读,当发生写只读数据的情形则可判断发生写时拷贝。

6.4 地址空间的意义

        进程地址空间按区域划分空间,很好的组织了进程所使用的内存,为进程提供了统一的视角来看待有序的区域。实际上所谓堆区、栈区在物理空间是乱序存在的,但是由于页表的存在可以完成映射的工作。

        进程管理模块与内存管理模块解耦,进程的内存申请不会立即执行只会先给出虚拟地址,直到使用时才分配物理地址,灵活给养提高了内存效率。

        虚拟地址访问会经过页表映射,当非法的地址空间输入时由于页表无法完成映射而被拦截,保护了物理内存。

        


原文地址:https://blog.csdn.net/XLZ_44847/article/details/142673664

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!