『Linux』 第四章 进程—— 进程状态讲解

embedded/2024/11/23 8:02:01/

目录

1.1.1 通过系统调用创建进程-fork初识

 1.2 进程状态

      1.2.1 Linux内核源代码怎么说

1.2.2 进程状态查看

1.2.3  Z(zombie)-僵尸进程

1.2.4 僵尸进程危害

1.2.5 孤儿进程

1.3 进程优先级

1.3.1 基本概念

1.3.2 查看系统进程

1.3.3 PRI and NI 

1.3.4 PRI  vs NI

1.3.5 查看进程优先级的命令

1.3.6 补充概念-竞争、独立、并行、并发

 1.4 进程切换

1.4.1 Linux2.6内核进程O(1)调度队列

1.4.2 一个CPU拥有一个runqueue

1.4.3 优先级

1.4.4 活动队列

1.4.5 过期队列

1.4.6 acttive指针和expired指针

1.4.7 总结

2. 环境变量

2.1 基本概念

2.2 常见环境变量

2.3 查看环境变量方法

2.4 和环境变量相关的命令

2.5环境变量的组织方式

2.6  通过代码如何获取环境变量

1.   argc(Argument Count):

2. argv(Argument Vector)

3.envp(Environment Pointers) (非标准,但某些环境支持): 

 2.7 通过系统调用获取或设置环境变量

2.8 环境变量通常是具有全局属性的

2.9 实验

3. 程序地址空间

3.1 程序地址空间回顾

3.3. 虚拟地址

3.4 进程地址空间

3.5 虚拟内存管理 - 第一讲

 3.6 为什么要有虚拟地址空间


     

  通过之前的学习,我们已经了解了进程的概念,下面我们来创建一个进程,再进行进一步的了解进程

1.1.1 通过系统调用创建进程-fork初识

  • 运行 man fork 认识fork

  • fork有两个返回值
    • 父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝)
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int ret = fork();
printf("hello proc : %d!, ret: %d\n", getpid(), ret);
sleep(1);
return 0;
}
  • fork之后通常要用 if 进行分流 
include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int ret = fork();
if(ret < 0){
perror("fork");
return 1;
}
else if(ret == 0){ //child
printf("I am child : %d!, ret: %d\n", getpid(), ret);
}else{ //father
printf("I am father : %d!, ret: %d\n", getpid(), ret);
}
sleep(1);
return 0;
}

         这里对于fork为什么会返回两个返回值,我们可以看下man中对于fork返回值的说明

        首先我们需要知道,父子进程之间代码共享,数据写时拷贝,接着结合上面man出来的解释,所以,我们知道了如果fork成功了,在父进程中fork的返回值是子进程的pid值,而在子进程中fork的返回值是0,如果fork失败了,那么在父进程中返回值为-1,不会创建出子进程,并且错误值(errno)被设置

        对于一个变量为何能够让 if 和 else if 同时成立这个问题,我们可以理解为fork成功之后,是由父子进程同时执行接下来的相同的代码,但是两个父子进程中的数据不同,所以我们需要写一个 if... else if  来使得父子进程所执行的代码不同

 1.2 进程状态

      1.2.1 Linux内核源代码怎么说

     为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。

        下面的状态在kernel源代码里定义:

​​​​/**The task state array is a strange "bitmap" of*reasons to sleep. Thus "running" is zero, and*you can test for combinations of others with*simple bit tests.*/static  const char*const task_state_array[] ={"R (running)", /*0 */"S (sleeping)", /*1 */"D (disk sleep)", /*2 */"T (stopped)", /*4 */"t (tracing stop)", /*8 */"X (dead)", /*16 */"Z (zombie)", /*32 */
};
  •  R 运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
  • S 睡眠状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
  • D 磁盘休眠状态(Disk sleep) 有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
  • T 停止状态(stopped):可以通过发送 SIGSTOP信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号进程继续运行。
  • X 死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。

1.2.2 进程状态查看

  ps aux / ps axj 命令
  •  a:  显示一个终端所有的进程,包括其他用户的进程。
  •  x:显示没有控制终端的进程,例如后台运行的守护进程。
  •  j: 显示进程归属的进程组ID、会话ID、父进程ID,以及与作业控制相关的信息
  •  u:以用户为中心的格式显示进程信息,提供进程详细信息,如用户、CPU和内存使用情况等。

1.2.3  Z(zombie)-僵尸进程

  •  僵死状态(Zombies)是一个比较特殊的状态。 当进程退出并且父进程(使用wait()系统调用,后面会说)没有读取得到子进程退出的返回代码时就会产生僵尸进程
  • 僵尸进程会议终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
  • 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程就会进入Z状态

下面我们来创造一个维持30秒的僵尸进程例子来看下:

#include <stdio.h>
#include <stdlib.h>
int main()
{pid_t id = fork();if(id < 0){perror("fork");return 1;}else if(id > 0){ //parentprintf("parent[%d] is sleeping...\n", getpid());sleep(30);}else{printf("child[%d] is begin Z...\n", getpid());sleep(5);exit(EXIT_SUCCESS);}return 0;
}

编译并在另一个终端下启动监控

 开始测试

看到结果

 ptrace系统调用追踪进程运行,有兴趣可以研究一下。

1.2.4 僵尸进程危害

  •  进程的退出状态必须被维持下去,因为它要告诉它的进程(父进程),你交给我任务,我办的怎么样了。可父进程如果一直不读取,那么子进程就一直处于Z状态? 是的! 
  • 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护? 是的!
  • 那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费? 是的! 因为数据结构对象本身就要占用内存,想一想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间!
  • 内存泄漏? 是的!
  • 如何避免? 后面讲

至此,值得关注的进程状态就全部讲解结束,下面来认识另一种进程。

1.2.5 孤儿进程

  •  父进程如果提前退出,那么子进程后退出,进入Z状态之后,那该如何处理呢?
  • 父进程如果提前退出,子进程就被称之为“孤儿进程”
  • 孤儿进程会被1号init进程领养,当然就由init进程进行会回收喽。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 1;
}
else if(id == 0){//child
printf("I am child, pid : %d\n", getpid());
sleep(10);
}else{//parent
printf("I am parent, pid: %d\n", getpid());
sleep(3);
exit(0);
}
return 0;
}

 监控窗口:

这也就证实了孤儿进程是会被系统1号init进程管理的

1.3 进程优先级

1.3.1 基本概念

  •  cpu资源分配的先后顺序,就是指进程的优先权(priority)。
  • 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
  • 还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。

1.3.2 查看系统进程

linux或者unix系统中,用ps -l 命令则会类似输出以下几个内容:

 我们很容易注意到其中的几个重要信息,有下几点:

  •  UID: 代表执行者的身份
  •  PID: 代表这个进程的代号
  •  PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
  • PRI:代表这个进程可被执行的优先级,其值越小越早被执行
  • NI:代表这个进程的nice值

1.3.3 PRI and NI 

  •  PRI也是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高
  • 那NI呢? 就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
  • PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为: PRI(new)=PRI(old)+nice
  • 这样,当nice为负值的时候,那么该程序的PRI值将会变小,即其优先级会变高,则其越快被执行
  • 所以,调整进程优先级,在linux下,就是挑战那个进程nice值
  • nice其取值范围是-20至19,一共40个级别。

1.3.4 PRI  vs NI

  • 需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响进程的优先级变化。
  • 可以理解nice值是进程优先级的修正数据

1.3.5 查看进程优先级的命令

        用top命令更改已存在进程的nice:

  •  top
  • 进入top后按 ‘‘r’’ -> 输入进程PID -> 输入nice值

注意:

  •  其他调整优先级的命令: nice,renice
  • 系统函数: nice(),setpriority(),getpriority()

1.3.6 补充概念-竞争、独立、并行、并发

  • 竞争性: 系统进程数目众多,而CPU资源少量,甚至只有一个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
  • 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰
  • 并行:多个进程在多个CPU下分别,同时进行运行,这称之为并行
  • 并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程得以推进,称之为并发

 1.4 进程切换

        CPU上下文切换:其实际含义是任务切换,或者CPU寄存器切换。当多任务内核决定运行另外的任务时,它保存正在运行任务的当前状态,也就是CPU寄存器中的全部内容。这些内容被保存在任务自己的堆栈中,入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器,并开始下一个任务的运行,这一过程就是context switch。

 参考一下Linux内核0.11代码

注意:

        时间片: 当代计算机都是分时操作系统,每个进程都有它合适的时间片(其实就是一个计数器)。时间片到达,进程就被操作系统从CPU上剥离下来。

1.4.1 Linux2.6内核进程O(1)调度队列

上图是Linux2.6内核中进程队列的数据结构,之间关系也已经给大家画出来的,方便大家理解

更加详细的关于进程队列的说明可以看这篇博客: Runqueue原理的深度阐述-CSDN博客

1.4.2 一个CPU拥有一个runqueue

  • 如果有多个CPU就要考虑个数的负载均衡问题

这里想要详细了解的可以跳转这里👇探索Linux内核中的Runqueue:从O(n)到O(1)的演进与负载均衡应用-CSDN博客

1.4.3 优先级

  • 普通优先级:100~139(我们都是普通的优先级,想想nice的取值范围(-19~20),可与之对应!)
  • 实时优先级:0~99(不关心)

1.4.4 活动队列

  • 时间片还没有结束的所有进程都按照优先级放在该队列
  • nr_active:总共有多少个运行状态的进程
  • queue[140]:一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以,数组下标就是优先级!
  • 从该结构中选择一个最合适的进程,过程是什么样的呢?

        1. 从0下标开始便利queue[140]

        2. 找到第一个非空队列,该队列必定为优先级最高的队列

        3. 拿到选中队列的第一个进程,开始运行,调度完成!

        4.遍历queue[140]时间复杂度是常数! 但还是太低效了!

  •  bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5*323个比特位表示队列是否为空,这样,便可以大大提高查找效率!

1.4.5 过期队列

  • 过期队列和活动队列结构一模一样
  • 过期队列上放置的进程,都是时间片耗尽的进程
  • 当活动队列上面的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算

1.4.6 acttive指针和expired指针

  • active指针永远指向活动队列
  • expired指针永远指向过期队列
  • 可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直都存在的。
  • 没关系,在合适的时候,只要交换active指针和expired指针的内容,就相当于又具有了一批新的活动进程!

1.4.7 总结

  • 在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度O(1)算法。

关于上面runqueue的完整过程感兴趣的可以在下面了解

Runqueue的添加进程,切换进程,退出进程完整过程-CSDN博客

2. 环境变量

2.1 基本概念

  • 环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数
  • 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器就进行查找。
  • 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。

2.2 常见环境变量

  • PATH: 指定命令的搜索路径
  • HOME: 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
  • SHELL:当前shell,它的值通常是/bin/bash。

2.3 查看环境变量方法

 echo $NAME  //NAME:你的环境变量名称

#include <stdio.h>int main()
{
printf("hello world!\n");
return 0;
}

        编译之后,我们发现在命令行直接输入hello,运行不了,但是输入./hello却可以运行,可到底是为什么有些指令可以直接执行,不需要带路径,而我们的二进制程序需要带路径才能执行?

       这就涉及到我们的环境变量的,那怎么查看Linux的环境变量呢?

        我们可以在命令行输入env,就可以显示所有的环境变量了。

        以下就是我们Linux系统中默认的全部环境变量了,其中我所画出来的PATH,这个变量就是我们系统中默认的系统路径,我们所有的指令也都在其中,所以系统指令之所以可以不加路径,就是因为Linux系统内核默认只会从PATH所指向的路径搜索,而我们创建的二进制文件,正常情况下是不会在那些路径中创建的,系统找不到我们所创建的文件,所以就需要指明路径了。

           那么有什么方法,可以将我们程序所在的路径也加入到环境变量PATH中,这样我们使用时也就可以不带路径了。

           当然有,在我们的命令行中输入 export PATH=$PATH:(hello程序所在路径)即可

           

         可是,当我们重启shell时,我们发现PATH又还原了,那这是为什么呢?

        其实,当我们启动shell,登陆时,首先启动的是bash进程,之后会根据我们当前用户的uid读取相对应的它的环境变量配置文件,在里面也就得到了PATH变量了,至于更加详细的解释,需要等到我们学到父子进程之后再进行讲解。

        那么,我们如何修改默认的环境变量配置文件,还有什么方法可以不带路径,直接运行文件呢?

        为了不浪费篇幅,我将所有关于修改环境变量的方法写在了下面这个文章中

         Linux下,修改环境变量的几种方法-CSDN博客

        那还有什么方法可以不带路径,直接就可以运行呢?

        其实,我们也可以将我们所建立的二进制程序移动或者拷贝到PATH所指向的位置,这样程序也是可以不加路径运行的。

2.4 和环境变量相关的命令

        1. echo: 显示某个环境变量值

        2. export:设置一个新的环境变量

        3. env:显示所有环境变量

        4. unset:清除环境变量

        5. set:显示本地定义的shell变量和环境变量

2.5环境变量的组织方式

        每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以 ‘\0’ 结尾的环境字符串

2.6  通过代码如何获取环境变量

  • 命令行第三个参数

        这里就涉及到main函数的三个参数了,main函数虽然最常见的形式是不带参数的,但它实际上可以接受参数,而这些参数允许程序与外部世界进行交互。而这也是指令的实现原理,main函数可以接受三个参数: argc、argv 和envp(envp并不是所有环境都支持的标准参数)。在实际编程中最常见的还是前两个参数 argc和argv

1.   argc(Argument Count):

  •  argc 是一个整数,表示传递给程序的命令行参数的数量。
  • 它至少为1,因为第一个命令行参数总是程序的名称或路径。

2. argv(Argument Vector)

  •  argv 是一个字符指针数组,其中包含了传递给程序的命令行参数的字符串(按空格打散,形成一张表argv)。
  • argv[0] 是程序的名称(或路径),argv[1]是第一个参数,argv[2]是第二个参数,以此类推。
  • argv[argc]是一个空指针(NULL),用于标记数组的结束。

3.envp(Environment Pointers) (非标准,但某些环境支持): 

        envp 是一个字符指针数组,包含了环境变量的字符串,每一个字符串都是“键=值”对的形式

#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
int i = 0;
for(; env[i]; i++){
printf("%s\n", env[i]);
}
return 0;
}
  •  通过第三方变量environ获取

 我们更常用的用法是下面这种

#include <stdio.h>extern char **environ;int main(int argc, char *argv[])
{
int i = 0;
for(; environ[i]; i++){
printf("%s\n", environ[i]);
}
return 0;
}

        libc中定义的全局变量 environ 指向环境变量表,environ 没有包含在任何头文件中,所以在使用时,要用extern声明。

 2.7 通过系统调用获取或设置环境变量

  •   putenv,用于设置或修改环境变量,成功返回0,失败则非0值,并设置错误值
  •  getenv, 用于获得指定环境变量的值。如果指定环境变量存在,则返回一个指向该环境变量值的指针;如果不存在,则返回NULL
#include <stdio.h>
#include <stdlib.h>
int main()
{
if(putenv((char*)"MY_VAR=HelloWord")!=0)
{perror("putenv failed");return 1;
}
printf("%s\n",getenv("MY_VAR"));
printf("%s\n", getenv("PATH"));
return 0;
}

注: putenv(char* name ) 参数内为char* 

2.8 环境变量通常是具有全局属性的

  •  环境变量通常是具有全局属性,可以被子进程继承下去
#include <stdio.h>
#include <stdlib.h>
int main()
{
char *env = getenv("MYENV");
if(env){
printf("%s\n", env);
}
return 0;
}

        直接查看,发现没有结果,说明该环境变量根本不存在

  • 在命令行导出环境变量   export MYENV="hello world"
  • 再次运行程序,发现结果就有了 

        说明: 环境变量是可以被子进程继承下去的! 

        因为,我们每一个执行的程序都是bash的子进程,当我们改变父进程的资源,之后,在执行再执行子进程,这时子进程所继承的父进程的资源,里面就有了我们想要的变量了。

2.9 实验

  •  如果只进行 MYENV=“helloworld“,不调用export导出,在用我们的程序查看,
  • 我们发现并没有发生改变,这说明普通变量并不具有全局性,也就是子进程不会继承普通变量

3. 程序地址空间

3.1 程序地址空间回顾

之前,我们在讲解C语言的时候,老师给大家画出过这样的空间布局图

 可是,我们对他并不理解! 可以先对其进行各区域分布验证:

include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
const char *str = "helloworld";
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
static int test = 10;
char *heap_mem = (char*)malloc(10);
char *heap_mem1 = (char*)malloc(10);
char *heap_mem2 = (char*)malloc(10);
char *heap_mem3 = (char*)malloc(10);
printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
printf("read only string addr: %p\n", str);
for(int i = 0 ;i < argc; i++)
{
printf("argv[%d]: %p\n", i, argv[i]);
}
for(int i = 0; env[i]; i++)
{
printf("env[%d]: %p\n", i, env[i]);
}
return 0;
}

 由此,我们可以看出

  • 函数,常量是位于代码区
  • 函数内的变量位于栈上
  • malloc申请的变量位于堆上
  • 全局变量和静态变量位于静态区上

3.3. 虚拟地址

这里我们引入下虚拟地址的概念

include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ //child
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}else{ //parent
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}

输出

我们发现,输出出来的变量值和地址是一模一样的, 很好理解,因为子进程按照父进程为模板,父子并没有对变量进行任何修改,可是将代码稍加改动

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ 
//child,⼦进程肯定先跑完,也就是⼦进程先修改,完成之后,⽗进程再读取
g_val=100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}else{ //parent
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}

输出:

 我们发现,父子进程,输出地址是一致的,但是变量内容却不一样!所以我们可以得出如下结论:

  • 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
  • 但是地址值却一样,这说明,该地址绝对不是物理地址! 因为一个地址不可能存储两个值
  • 其实,在Linux地址下,这种地址叫做 虚拟地址
  • 我们在使用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理 。

        所以,OS 必须负责将 虚拟地址 转化成 物理地址

3.4 进程地址空间

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

        分页& 虚拟地址空间

 说明: 

  •  上面的图就足以说明问题,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址!

        那么该如何理解虚拟地址空间呢?

        我们可以理解为,OS在物理内存中找了一块空闲的不常用的地址空间,通过页表映射,映射为整个虚拟地址空间,进而使得进程认为自己得到了整个物理内存空间,而映射的这块虚拟地址空间,也是需要OS进行管理的,那么怎么管理呢?

        先描述再组织!

        下面有两篇文章,对虚拟地址和物理地址之间映射的原理,以及页表内部结构的感兴趣的可以看一下 

  • 虚拟内存和物理内存,虚拟地址和物理内存地址之间的映射机制 - codemelo - 博客园
  • 操作系统:页表中的页表项_什么是pte page table entries-CSDN博客

3.5 虚拟内存管理 - 第一讲

上面我们说了,OS要对虚拟地址空间进行管理,一定要先描述,那么该怎么进行描述呢?

描述Linux下进程的地址空间的所有的信息的结构是 mm_struct (内存描述符)。每个进程只有一个mm_struct 结构,在每个进程的task_struct 结构中,有一个指向该进程的结构。 

struct task_struct{/*...*/struct mm_struct *mm; //对于普通的⽤⼾进程来说该字段指向他的
虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。struct mm_struct *active_mm; // 该字段是内核线程使⽤的。当该
进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所
有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。/*...*/
}

可以说,mm_struct 结构是对整个用户空间的描述。每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰。先来看看task_struct 到mm_struct ,进程地址空间的分布情况:

 定位 mm_struct 文件所在位置和task_struct 所在路径是一样的,不过他们所在文件是不一样的,mm_struct 所在文件是mm_types.h。

struct mm_struct{/*...*/struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */struct rb_root mm_rb; /* red_black树 */unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的⼤⼩*//*...*/// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack;unsigned long arg_start, arg_end, env_start, env_end;/*...*/
}

 那既然每一个进程都会有自己独立的mm_struct,操作系统肯定是要将这么多进程的mm_struct 组织起来的!

虚拟空间的组织方是有两种:

1. 当虚拟区较少时采取单链表,由mmap指针指向这个链表;

2. 当虚拟区间多时采取红黑树进行管理,由mm_rb指向这棵树。

Linux 内核使用 vm_area_struct 结构来表示一个独立的虚拟内存区域(VMA),由于每个不同值的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内容区域。上面提到了两种组织方式使用的就是vm_area_struct 结构来连接各个VMA,方便进程快速访问。

struct vm_area_struct {
unsigned long vm_start; //虚存区起始
unsigned long vm_end; //虚存区结束
struct vm_area_struct *vm_next, *vm_prev; //前后指针
struct rb_node vm_rb; //红⿊树中的位置
unsigned long rb_subtree_gap;
struct mm_struct *vm_mm; //所属的 mm_struct
pgprot_t vm_page_prot;
unsigned long vm_flags; //标志位
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
struct list_head anon_vma_chain;
struct anon_vma *anon_vma;
const struct vm_operations_struct *vm_ops; //vma对应的实际操作
unsigned long vm_pgoff; //⽂件映射偏移量
struct file * vm_file; //映射的⽂件
void * vm_private_data; //私有数据
atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMU
struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;

所以我们对上图进行更细致的描述,如下图所示:

 3.6 为什么要有虚拟地址空间

这个问题其实可以转化为: 如果程序直接可以操作物理内存会造成什么问题?

在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。

那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢? 例如某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在程序分配内存时会采取这样的方法:现将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。

这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。

  • 安全风险
    • 每个进程都可以访问任意的内存空间,这也就意味着任何一个进程都能够去读写系统相关内存区域,如果是一个穆玛病毒,那么他就能随意的修改内存空间,让设备直接瘫痪。
  • 地址不确定
    • 众所周知,编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中去运行, 如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝的实际内存地址每一次运行都是不确定的,比如:第一次执行a.out的时候,内存当中一个进程都没有运行,所以搬移到内存地址是0x00000000,但是第二次的时候,内存已经有了10个进程在运行了,那这个时候执行a.out的时候,内存地址就不一定了。
  • 效率低下
    • 如果直接使用物理内存的话,一个进程就是作为一个整体(内存块)操作的,如果出现物理内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区中,好腾出内存,但是如果是物理地址的话,就需要将整个进程一起拷走,这样,在内存和磁盘之间拷贝时间太长,效率较低。

     那么,存在这么多问题,有了虚拟地址空间和分页机制就能都解决了吗? 当然!

  • 地址空间和页表是OS创建并维护的!是不是也意味着,凡是想使用地址空间和页表进行映射,也一定要在OS的监管下来进行访问!! 这也就顺便保护了物理内存中的所有的合法数据,包括各个进程以及内核的相关有效数据!
  • 因为有地址空间的存在和页表的映射的存在,我们的物理内存中可以对未来的数据进行任意位置的加载!物理内存的分配和进程的管理就可以做到没有关系,进程管理模块和内存管理模块就完成了解耦合。
    • ​​​​​​​因为有地址空间的存在,所以我们在C、C++语言上new,malloc空间的时候,其实是在地址空间上申请的,物理内存甚至可以一个字节都不给你。而当你真正对物理地址进行空间访问的时候,才会执行内存的相关管理算法,帮你申请内存,构建页表映射关系(延迟分配),是由操作系统自动完成,用户包括进程完全0感知!!
  • 因为页表的映射的存在,程序在物理内存中理论上就可以实现在任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射,在进程视角所有的内存分布都是可以有序的。


http://www.ppmy.cn/embedded/139807.html

相关文章

【LeetCode面试150】——1两数之和

博客昵称&#xff1a;沈小农学编程 作者简介&#xff1a;一名在读硕士&#xff0c;定期更新相关算法面试题&#xff0c;欢迎关注小弟&#xff01; PS&#xff1a;哈喽&#xff01;各位CSDN的uu们&#xff0c;我是你的小弟沈小农&#xff0c;希望我的文章能帮助到你。欢迎大家在…

十七:Web内容协商与资源表述

在现代Web架构中,随着用户设备、语言和网络环境的多样化,如何高效地传递和获取适合的内容变得尤为重要。Web内容协商(Content Negotiation)和资源表述(Representation of Resources)是解决这一问题的重要技术手段。它们帮助服务器根据客户端的需求动态提供最合适的资源,…

Cesium教程03_加载b3dm高度

使用 Vue3 和 Cesium 构建三维地球场景并实现高度调整功能 引言 在现代 Web GIS&#xff08;地理信息系统&#xff09;开发中&#xff0c;Cesium 是一款功能强大的三维地球可视化工具。本文展示了如何使用 Vue3 与 Cesium 集成&#xff0c;实现一个支持调整高度功能的三维地球…

力扣刷题--21.合并两个有序链表

I am the best &#xff01;&#xff01;&#xff01; 题目描述 将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。 示例 1&#xff1a; 输入&#xff1a;l1 [1,2,4], l2 [1,3,4] 输出&#xff1a;[1,1,2,3,4,4] 示例 2…

音频档案批量拷贝:专业SD拷贝机解决方案

批量音频档案拷贝最佳方案&#xff1a;解决播放错误与拷贝不完全问题 在现今数字化生产需求越来越高的时代&#xff0c;专业的拷贝机为大量数据复制提供了高效、安全的解决方案&#xff0c;特别是在批量拷贝音频档案至MicroSD卡并应用于播放器时&#xff0c;拷贝机具有无与伦比…

Python + 深度学习从 0 到 1(00 / 99)

希望对你有帮助呀&#xff01;&#xff01;&#x1f49c;&#x1f49c; 如有更好理解的思路&#xff0c;欢迎大家留言补充 ~ 一起加油叭 &#x1f4a6; 欢迎关注、订阅专栏 【深度学习从 0 到 1】谢谢你的支持&#xff01; ⭐ 什么是深度学习&#xff1f; 人工智能、机器学习与…

【Python TensorFlow】进阶指南(续篇三)

在前几篇文章中&#xff0c;我们探讨了TensorFlow的高级功能&#xff0c;包括模型优化、分布式训练、模型解释等多个方面。本文将进一步深入探讨一些更具体和实用的主题&#xff0c;如模型持续优化的具体方法、异步训练的实际应用、在线学习的实现细节、模型服务化的最佳实践、…

利用图像识别给CAD图纸找不同

文章目录 论文地址一、背景及意义介绍背景介绍意义介绍 二、概述三、论文思路具体步骤 四、方法介绍基于图像处理的CAD图纸比对算法的方法介绍 五、复现过程&#xff08;1&#xff09;CAD图纸转换为PDF&#xff08;2&#xff09;图纸边缘切割对齐&#xff08;3&#xff09;高斯…