Linux(四) 进程

目录

一、操作系统OS(Operator System)

1.什么是操作系统

2.为什么要有操作系统

3.可以使用操作系统吗

二、进程

1.什么是进程

1-1.为什么要有PCB结构体

1-2.task_struct 结构体里内容分类

 1-3.一些查看进程的方法

2.一些系统调用接口

3.fork

4.进程调度

5.进程状态 

​5-1.前后台进程

5-2.僵尸进程

5-3.孤儿进程 

6.进程优先级

6-1.什么是优先级

6-2.为什么要有优先级

6-3.查看优先级

6-4.怎么设置优先级

7. 阻塞队列和运行队列

三、环境变量 

1.什么是环境变量

2.常见的环境变量有哪些

3.环境变量的常见指令

export(本地变量vs环境变量)

unset 

4.环境变量的基本布局

5.如何获取环境变量

四、进程地址空间

1.回顾

进程地址空间的区域划分

2.地址空间是如何设计的

3.为什么要有进程地址空间

1.安全性

2.使得无需内存数据变得有序

3.将进程管理和内存管理更好的解耦

重新理解挂起

4.深入理解虚拟地址


一、操作系统OS(Operator System)

1.什么是操作系统

任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。

笼统的理解,操作系统包括:

内核(进程管理,内存管理,文件管理,驱动管理)
其他程序(例如函数库, shell程序等等)

感性的理解:操作系统是一个搞管理的软件

例如:管理进程,管理硬件(将显卡、键盘、显示器抽象成一个个文件),管理文件

那么操作系统是怎么管理的呢?

先描述再组织

2.为什么要有操作系统

  1. 最早期计算机是没有操作系统的,每次要变更一下功能,都要手动去调整硬件,费时费力。
  2. 保护硬件和内存的安全,在操作系统看来所有人都不值得信任
  3. 可以为程序提供一个良好的执行环境,抹平不同电脑和硬件之间的差异

即对上可以提供一个良好的运行环境(目的),对下可以管理好软硬件资源(手段) 

3.可以使用操作系统吗

既然操作系统是为了保护安全,提供便利,那用户可以使用操作系统吗?

可以的,通过操作系统提供的系统调用接口

C++的优势便在于可以兼容C语言,而Linux操作系统就是用C语言写的,Linux操作系统提供的系统调用接口便是用C语言实现的函数,所以C++可以使用系统调用接口,可以大大提高访问硬件的效率。

编程语言提供的库函数就是对系统调用函数的进一步封装

二、进程

1.什么是进程

内核观点:担当分配系统资源(CPU时间,内存)的实体。
当可执行程序从磁盘加载到内存就成为了进程即正在执行程序,或在等待队列中的程序

进程 = 描述进程属性的PCB结构体 + 进程的代码和数据

1-1.为什么要有PCB结构体

首先我们要知道进程也要被操作系统管理起来,进程也需要被先描述再组织。

C++里一切皆对象,人是通过事物的属性了解世界的,一个类或者对象包含了一个事物的所有信息,即描述。

PCB即包含了一个进程的所有信息,用于描述一个进程,再将不同进程的PCB结构体组织成一个链表放在内核地址空间统一进行管理。

在Linux中PCB结构体为task_struct

1-2.task_struct 结构体里内容分类

标示符: 描述本进程的唯一标示符,用来区别其他进程。


状态: 任务状态,退出代码,退出信号等。


优先级: 相对于其他进程的优先级。


程序计数器: 程序中即将被执行的下一条指令的地址。


内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针


上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。


I/ O状态信息: 包括显示的I/O请求,分配给进程的I/ O设备和被进程使用的文件列表。


记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。

struct task_struct {
	volatile long state;	/* -1 unrunnable, 0 runnable, >0 stopped */
	void *stack;
	atomic_t usage;
	unsigned int flags;	/* per process flags, defined below */
	unsigned int ptrace;
    int exit_state;
	int exit_code, exit_signal;
	int pdeath_signal;  /*  The signal sent when the parent dies  */
	unsigned int jobctl;	/* JOBCTL_*, siglock protected */

	/* Used for emulating ABI behavior of previous Linux versions */
	unsigned int personality;

	unsigned did_exec:1;
	unsigned in_execve:1;	/* Tell the LSMs that the process is doing an
				 * execve */
	unsigned in_iowait:1;

	/* task may not gain privileges */
	unsigned no_new_privs:1;

	/* Revert to default priority/policy when forking */
	unsigned sched_reset_on_fork:1;
	unsigned sched_contributes_to_load:1;

	pid_t pid;
	pid_t tgid;
    int status;
    // ....
};

 1-3.一些查看进程的方法

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

ls /proc

top

 

 进程的当前工作路径不是保存在PCB结构体中,尔是由操作系统内核维护的

2.一些系统调用接口

getpid()

getppid()

fork()

3.fork

fork():creat a child process

创建成功:子进程返回0,父进程返回子进程的pid

创建失败:返回-1

int main()
{

  pid_t id = fork();
  if (id < 0)
  {
    // 创建失败
    perror("fork");
    return 1;
  }
  else if (id == 0)
  {
    // child process
    while (1)
    {
      printf("i am child pid:%d ppid:%d\n", getpid(), getppid());
    }
  }

  else
  {
    // parent child
    while (1)
    {
      printf("i am parent pid:%d ppid:%d\n", getpid(), getppid());
      sleep(1);
    }
  }

  return 0;
}

为什么fork会有两个不同的返回值?

先看一段fork伪代码实现(fork的实现在操作系统里)

pid_t fork()
{
    // 创建子进程逻辑
    return id;
}

首先我们要知道fork之后父子代码共享,当我们准备return时,我们的核心代码已经执行完了,return id已经被共享了,并且此时发生了写时拷贝。

4.进程调度

父子进程被创建出来哪一个先执行?不一定,谁先执行是由操作系统的调度器决定的

5.进程状态 

先看一下内核源代码

/*
* 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 */
};
  1. R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
  2. S睡眠状态(sleeping): 意味着进程在等待事件完成,等待非CPU资源,也叫阻塞态(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep)。
  3. D磁盘休眠状态(Disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep),不可以被主动唤醒,在这个状态的进程通常会等待IO的结束。
  4. T停止状态(stopped):可以通过发送 SIGSTOP 信号给进程来停止(T)进程,kill -18,kill -19。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
  5. X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态
  6. Z僵尸状态(zombie):一个进程已经退出,但是不允许被OS释放,处于一个被检测的状态(一般是被OS或父进程检测(使用wait()系统调用接口)),可以理解为进程的代码和数据已经被释放了但是PCB结构体还在。
  7. 挂起状态:当内存不足时,CPU置换进程的代码和数据到磁盘,此时的状态就叫挂起

5-1.前后台进程

我们来解释一下为什么我们看到的进程状态是 S+ 或者 R+?在Linux中,存在着 前台进程 和 后台进程 之分。

我们在命令行运行起来的程序一般都为前台进程,前台进程的进程状态一般都会带 ‘+’ 号。后台进程 一般为后台独立运行的进程,一般不接收终端的输入。

  前台进程只需要加上特殊符号,也可转化为后台进程,比如:

./myproc & #特殊符号,表示将进程后台运行。

这个时候,就将进程变为后台进程了,S+ 也变为了 S,但是这里又出现了一个问题,我们 Ctrl + C 终止不掉这个进程。

其实,后台进程是不能用 Ctrl + C 直接杀死的,我们需要使用 kill -9 进程标识符来杀死进程

5-2.僵尸进程

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

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

5-3.孤儿进程 

父进程退出,子进程还在,此时子进程就为孤儿进程

此时子进程会被1号进程领养(init,系统本身)

为什么要被领养?因为子进程需要被回收,此时父进程已经不在,所以需要领养进程进行回收

6.进程优先级

6-1.什么是优先级

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

6-2.为什么要有优先级

因为CPU资源有限,需要通过某种方式竞争资源

6-3.查看优先级

ps -l

  • UID : 代表执行者的身份
  • PID : 代表这个进程的代号
  • PPID :父进程的代号
  • PRI :代表这个进程可被执行的优先级,其值越小越早被执行
  • NI :代表这个进程的nice值

6-4.怎么设置优先级

优先级值越小,越先执行

Linux优先级 = 老的优先级(PRI) + nice值

老的优先级都是80,nice值范围(-20 ~ 19)

用top命令可以修改进程的nice值 进入top后按“r”–>输入进程PID–>输入nice值

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

7. 阻塞队列和运行队列

  1. 阻塞队列

    • 阻塞队列用于存放当前因为某些原因(如等待I/O完成、等待某个事件发生等)而无法继续执行的进程。
    • 进程在阻塞队列中等待的时间取决于引起其阻塞的事件何时发生。一旦事件发生,进程就会从阻塞队列中移出,并放入就绪队列,等待CPU资源。
    • 阻塞队列的管理涉及事件的等待和唤醒,通常需要操作系统提供的同步原语和系统调用来管理。
  2. 运行队列

    • 运行队列是存放已经就绪且等待CPU执行的进程的队列。
    • 运行队列中的进程通常按照某种调度算法进行排序,以确定下一个将被分配CPU时间的进程。常见的调度算法包括先来先服务(FCFS)、最短作业优先(SJF)、时间片轮转(Round Robin)等。
    • 操作系统根据调度算法从运行队列中选择下一个进程来执行,并将其分配给CPU执行。

三、环境变量 

1.什么是环境变量

环境变量是系统的一些变量,它可以帮助我们做一些事情,例如PATH可以帮我们找命令,还有我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但
是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。

环境变量具有全局属性,可以被子进程继承。

2.常见的环境变量有哪些

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

例如PATH

在Linux中运行代码时我们就会发现,同样是可执行程序,我们自己写的代码就需要带 ./ 才能运行,但是系统中的命令,比如whami 、ls等却不用带 ./ 照样可以执行呢?其实,这里的 ./表示了我们的当前目录,带着目录运行一个可执行程序当然是可以的,我们知道,要执行一个可执行,必须找到自己的可执行在哪里,但是,为什么系统的指令就可以不带目录而独自运行呢?所以,只能有一种解释:系统指令有自己的默认的查找路径!

添加环境变量

这里的$PATH:代表将目录追加到当前的PATH,如果直接等于,就会导致其他目录被覆盖

这里需要注意我们更改的环境变量只在当前这次登录有效,如果用户重新退出登入此次更改就会消失,环境变量就会恢复初始状态。

这是因为子进程的环境变量都是从父进程继承来的,每次用户登陆时,操作系统都会创建一个命令行解释器(shell,在Linux下通常是bash)子进程,我们修改的环境变量是在bash进程中修改的,这个子进程bash的环境变量是从配置文件中获取的(系统级配置文件,用户级配置文件..),我们想要修改bash中的环境变量需要通过修改配置文件

我们的配置文件是用户级的,配置完只在自己的用户有效,在其他用户或root用户都无效

3.环境变量的常见指令

1. echo: 显示某个环境变量值
2. export: 设置一个新的环境变量
3. env: 显示所有环境变量
4. unset: 清除环境变量
5. set: 显示本地定义的shell变量和环境变量,显示全部变量

export(本地变量vs环境变量)

本地变量的一般形式为: [名称] = [内容] ,其只能在当前bash进程的内部有效,也就是说,由bash创建的子进程就不能再使用这个本地变量。

而export后便可以被子进程继承

但是echo命令竟然可以获取本地变量的内容,这是为什么呢?

在 Linux 或类 Unix 系统中,命令通常可以分为两类:常规命令(External Commands)和内建命令(Built-in Commands)。它们之间有一些区别:

  1. 常规命令(External Commands)

    • 常规命令是由可执行文件或脚本组成的,它们位于系统的文件系统中,通常存储在标准的二进制路径(如 /bin/usr/bin 等)下。
    • 当你在终端中输入一个常规命令时,操作系统会搜索这些路径,找到对应的可执行文件,并在一个新的子进程中执行该命令。
    • 常规命令的执行是由操作系统负责的,并且它们与 shell 没有直接的关联。一旦命令执行完成,子进程将会退出。
  2. 内建命令(Built-in Commands)

    • 内建命令是 shell 程序内置的命令,它们直接由 shell 解释器执行,而不需要调用外部可执行文件。
    • 由于内建命令是作为 shell 的一部分实现的,所以它们的执行速度通常比常规命令快,因为不需要启动新的子进程。
    • 内建命令提供了一些常见的操作,例如改变当前目录、设置环境变量、控制作业、执行条件判断等。
    • 由于内建命令是 shell 解释器的一部分,所以它们的行为和语法可能因 shell 的不同而有所差异。

所以echo属于内建命令,它并没有创建子进程,所以可以获取本地变量的内容 

环境变量在把你的环境变量添加到系统环境变量里的时候,不是把你的长字符串拷贝到你对应的指针数组空间里,而是把这个字符串的地址添加到指针数组当中。

unset 

要删除名为EXAMPLE_VAR的环境变量,可以执行以下命令:

unset EXAMPLE_VAR

如果要删除多个环境变量,可以将它们逐个列出:

unset VAR1 VAR2 VAR3

 请注意,使用unset命令只会在当前shell中删除指定的环境变量。如果希望永久性地从系统中删除环境变量,可以在适当的shell配置文件(如.bashrc.profile)中移除对应的export语句,并重新加载该配置文件或重新启动系统。

4.环境变量的基本布局

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

5.如何获取环境变量

  1. env 查看所有环境变量
  2. int main(int argc, char *argv[], char *env[])
    {
        int i = 0;
        while(env[i])
        {
          printf("env[%d]:%s\n",i,env[i]);
          i++;
        }
    }
    
  3. int main(int argc, char *argv[], char *env[])
    {
      extern char** environ;
      int i = 0;
      while(environ[i])
      {
    
         printf("env[%d]:%s\n",i,environ[i]);
         i++;
      }
    }

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

上面代码argc为命令行参数个数,argv为储存命令行参数的指针数组 ,命令行参数也来自于父进程。

   if(argc != 2)
   {
  
       printf("至少有一个选项\n");
       return 1;
   }

   if(strcmp(argv[1],"-a")==0)
   {
       printf("此为功能一\n");
   }
   if(strcmp(argv[1],"-b")==0)
   {
       printf("此为功能二\n");
   }

四、进程地址空间

1.回顾

先看这一段代码

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

int un_g_val;
int g_val = 10;

int main(int argc, char* argv[], char* env[])
{
    char* str = "aaaaaaa";   // 这两个都不可以修改
    const char* str1 = "bbbbaaaaaaa";
    printf("string addr: %p\n",str);
    printf("read only string addr: %p\n",str1);
    printf("code addr: %p\n",main);
    printf("init addr: %p\n",&g_val);
    printf("un_init addr: %p\n",&un_g_val);

    static int test = 10;
    printf("static int addr: %p\n",&test);
    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_mem addr: %p\n",heap_mem);
    printf("heap_mem1 addr: %p\n",heap_mem1);
    printf("heap_mem2 addr: %p\n",heap_mem2);
    printf("heap_mem3 addr: %p\n",heap_mem3);

    printf("stack_mem addr: %p\n",&heap_mem);
    printf("stack_mem1 addr: %p\n",&heap_mem1);
    printf("stack_mem2 addr: %p\n",&heap_mem2);
    printf("stack_mem3 addr: %p\n",&heap_mem3);

    int i = 0;
    while(i < argc)
    {
        printf("argv[%d]: %p\n",i,argv[i]);
        i++;
    }
    int j = 0;
    while(env[j])
    {
        printf("env[%d]: %p\n",j,env[j]);
        j++;
    }
    
    
    return 0;
}

 

在32位下,一个进程的地址空间是0x0000 0000 ~ 0xFFFF FFFF

堆栈相对而生,堆栈之间有一段共享区

这里我们需要注意的是,对于栈区变量的定义,虽然其是向下生长的,但是这只是对于变量的定义来说的,比如数组,结构体之类的具有连续空间的结构,其在栈区的使用是向上增长的,我们平常看到的经验来说,我们定义数组a[10],都是下标小的对应的地址比下标大的要小,对于结构体也是一样,数据成员中,最后一个声明的往往具有较大的地址值,换句话来说,栈区的使用是整体向下增长,但是对于局部变量来说,是向上增长的。

上面的代码在windows下可能会有不同的结果,上面的结论只在Linux下有效

进程地址空间的区域划分

每个进程都有一个地址空间,操作系统为每一个进程画了一个大饼,它们都认为自己在独占物理内存,系统中存在大量进程,需要管理地址空间,那么就需要先描述、再组织,进程地址空间本质上在内核中是一个数据类型 ,可以定义具体的进程地址空间变量,在Linux当中进程地址空间具体由结构体mm_struct实现。


struct mm_struct
{
 	//进程地址空间   
};

  我们的进程地址空间在开辟时就已经为每一段空间做出了区域划分,像我们上面所介绍的代码区、常量区、栈区、堆区等等,从而我们可以这样描述一个进程地址空间的结构体,我们在描述进程地址空间时,是以虚拟地址为标准来描述的,

struct mm_struct
{
    unsigned int code_start;  
    unsigned int code_end;  
 
    unsigned int init_data_start;
    unsigned int init_data_end;
 
    unsigned int uninit_data_start;
    unsigned int uninit_data_end;
 
    //....
    unsigned int stack_start;
    unsigned int stack_end;
};

2.地址空间是如何设计的

在回答这个问题前,先看一段代码

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

int g_val = 10;

int main()
{
    pid_t id = fork();

    if(id == 0)
    {
        int cut = 5;
        while(1)
        {
            printf("i am a child  pid = %d ppid = %d g_val = %d  &g_val = %p\n",getpid(),getppid(),g_val,&g_val);
            cut--;
            if(cut == 0)
            {
                g_val = 20;
                printf("child:g_val 10 -> 20\n");
    
            }
        sleep(1);
        }
    }
    else 
    {

        while(1)
        {
           
            printf("i am a father  pid = %d ppid = %d g_val = %d  &g_val = %p\n",getpid(),getppid(),g_val,&g_val);
            sleep(1);
        }
    }

    return 0;
}

 为什么同一个地址会出现两个不同的值?这个地址绝不可能是真实的物理地址,那就只可能是虚拟地址,几乎所有的语言,只要涉及“地址”这个概念,这个地址一定不是物理地址而是虚拟地址。

在计算机系统中,虚拟地址空间是每个进程所拥有的地址空间的抽象表示。在这个抽象的地址空间中,包含了所有进程可能需要访问的地址范围,包括内存地址和外设地址。当进程访问虚拟地址空间中的某个地址时,操作系统会将虚拟地址映射到物理地址,从而实现实际的内存访问。对于外设地址,操作系统也可以将虚拟地址映射到相应的外设控制器进行访问。

那为什么会出现这种现象呢?

因为子进程会继承父进程的很多数据,其中虚拟地址空间和页表也被继承了,甚至指向同一份g_val,但当子进程想要修改g_val时,操作系统会在物理内存上重新开辟一份空间储存g_val,并修改子进程页表的映射关系,虚拟地址不会变,因为虚拟地址来自不同的虚拟地址空间。

这种最初创建时一样,当准备要修改时才会重新拷贝一份的现象叫写时拷贝

fork()
{
    // 生成子进程的逻辑
    return id;
}

其中return id 本质就是对id进行写入,发生了写时拷贝

3.为什么要有进程地址空间

1.安全性

直接访问物理内存特别不安全,并且此时进程不具有独立性。因为用户对物理内存可以随意使用,进程加载到物理内存时,会加载到物理内存的任意位置,如果此时用户的代码出现野指针等问题,可能会导致数据出现问题。此时每个进程会相互影响。

不知道你是否有这样一个疑问,就是进程地址空间是如何区分这些划分出来的区域的?比如字符常量区的数据为什么不能被修改这样的访存权限问题。事实上,这和我们的页表结构有关,我们的页表除了有虚拟地址和物理地址间的映射关系结构以外,对于每个映射关系,还存在一个访问权限字段,这个字段就可以表示规定好的区域划分后的各个空间所具有的特殊的权限问题,比如我们的字符常量区,页表在创建时,就会识别对应的字符常量区的虚拟地址范围,并将其权限设置为只读(r),这样,我们在通过虚拟地址经过页表寻找物理地址并试图进行修改操作时,就因为页表的访问权限字段而被拦截下来,换句话说,物理地址是无法直接拦截访存操作的,是通过页表进行拦截的。

凡是非法的访问,OS都会识别,并终止这个进程

如何识别在多线程说,如何终止在信号

具体来说,页表(哈希表)权限位可以根据进程地址空间中不同区域的特性和需求来设置。例如:

  1. 代码区

    对于代码区,页表权限通常设置为只读(Read-Only),以防止程序在运行时被修改。这可以防止一些安全漏洞,如代码注入攻击。
  2. 数据区

    对于数据区,权限可能会根据实际需要设置为可读写(Read-Write),以允许程序对全局变量、静态变量等进行读写操作。
  3. 对于堆区,权限通常需要设置为可读写,以允许动态分配和释放内存。
  4. 对于栈区,权限通常也需要设置为可读写,以允许程序对函数调用的局部变量等进行读写操作。

OS是如何将进程与页表相对应的,又是如何将虚拟地址转换为物理地址的?
      操作系统中可能会有众多的页表,那么操作系统是如何找到当前进程所对应的那个页表的呢?事实上,我们知道,每一个进程在需要页表的时候一定是进程正在被执行,此时CPU内部有一个特殊的寄存器(CR3),该寄存器存储的就是当前正在执行的进程的页表的物理地址 ,当该进程在CPU上执行时,通过CR3就能找到进程对应的页表信息。页表信息本质上也是保存在进程PCB中的一个结构体数据,这就使得在进程切换时,页表也能够根据上下文信息进行对应的切换,从而实现页表随进程的动态切换。

     CPU通过内存管理单元(MMU)来执行虚拟地址到物理地址的转换。CPU使用虚拟地址进行内存访问, CPU内部的MMU获取虚拟地址,并将其拆分成不同的部分,通过对其进行相应的查找和执行过程找到对应的物理地址,这个过程我们不展开,后续的内容会深入的了解其转换机制。

2.使得无需内存数据变得有序

首先,我们知道,进程在加载到内存的时候是随机选择内存地址的,一个进程的代码和数据在内存中被分别随机加载到任意位置,这就让我们的进程的寻址变得困难,但是引入了进程地址空间,通过进程地址空间+页表的方式,我们可以用统一的虚拟地址空间来映射物理内存的地址,可以将乱序的内存数据变得有序,方便统一管理和规划。

同时,如果进程在执行期间发生了挂起或者进程切换导致了代码和数据的内存地址发生改变,我们只需要将页表的物理地址部分进行修改即可,无需对虚拟地址进程修改,方便了管理。

进程独立性可以通过进程地址空间+页表的方式实现

3.将进程管理和内存管理更好的解耦

可执行程序在执行时,不一定需要全部加载到内存,当可执行程序过大,操作系统并不需要将这个可执行全部加载到内存,甚至是根据需要再进行加载到内存。

        在PCB的页表结构中,还存在着这样一个字段,该字段的作用就是标识在当前映射关系下,虚拟地址(在页表中,虚拟地址是可以是固定在页表左栏的,因为虚拟地址是固定的,但是映射关系是不固定的,也就是虚拟地址所映射的物理地址是不固定的,需要靠操作系统进行分配)是否分配了对应的物理地址。在执行可执行程序的部分代码时,能够根据需要将部分代码片段加载内存中,申请部分物理空间,然后将该物理地址加入到映射关系中去,而我们能看到的,只是进程地址空间上的虚拟地址,至于程序的部分加载分配到内存,和将物理地址加入到进程页表中的操作,也就是内存管理模块,对我们来说是透明的,其中,进程在访问虚拟地址时,发现其物理地址还没有被分配,从而进入暂停等待物理内存分配的过程,叫做缺页中断,这个我们后续再做深入了解,现在,我们只是知道,这是Linux进程挂起的一种状态即可。

当我们在C/C++使用malloc或者new去申请空间时,实际上是在进程地址空间上申请,物理内存可能一个字节都不会给用户,只有当用户使用这份空间时,真正对这份物理内存进行访问时,才会执行相关的内存管理算法将内存分配给用户,再通过页表建立映射关系,让用户进行访问。

重新理解挂起

加载的时候并不是一下全部将进程的代码和数据放到物理内存,极端的情况下甚至只创建了内核数据结构。既然可以分批加载,那么在物理内存不足时,也可以分批换出。

一个游戏100G,内存只有8G,就要分批加载,比如进入游戏时加载进入界面进入后释放掉了,或者匹配时等待网络资源进入阻塞状态。

这种延迟分配的策略可以提高整机效率,内存使用率几乎100%。

操作系统怎么知道虚拟地址空间分配了,物理内存没有分配呢?

当进程请求虚拟地址空间时,操作系统会为其分配一段连续的虚拟地址空间,但实际上并不会立即分配对应的物理内存。相反,操作系统会将这些虚拟地址空间映射到一个特殊的状态,称为虚拟内存,而不是直接映射到物理内存。在这种情况下,当进程尝试访问分配的虚拟地址空间时,如果相应的物理内存尚未分配,则会触发一个页面错误(page fault)。操作系统会捕获这个页面错误,并在需要时为进程分配物理内存,并将虚拟地址重新映射到物理内存上。这个页面错误就是缺页中断。页面错误通常也称为缺页中断(Page Fault)。当一个进程试图访问虚拟地址空间中的某个页面(页)时,如果相应的物理内存尚未分配或者未加载到内存中,操作系统会捕获这个访问,触发一个页面错误(缺页中断)。页面错误是一种异常情况,它通常会导致进程的当前执行被中断,控制权交给操作系统内核。操作系统内核会处理页面错误,并采取相应的措施来解决这个异常。

4.深入理解虚拟地址

地址空间不要仅仅理解为操作系统要遵守的,编译器也要遵守,即编译器在编译的时候就已经给我们形成了各个区域代码区,数据区....并且,采用和Linux一样的编址方式,给每一个变量每一行代码都进行了编址,故,程序在编译时,每个字段已经具备了虚拟地址。

可执行程序有地址吗?

有地址,在编译的时候内部就有了。

objdump -ahf youfile #可以告诉我们可执行程序是有地址的

地址空间和页表最开始的数据是从哪里来的?

地址空间:将进程(在内存中)(可执行程序(在磁盘中))里形成的各个代码段的地址的首尾放到mm_struct里。

页表:可执行程序被加载到物理内存里,每个变量或函数都有了物理内存的地址,将此地址放在页表右侧,虚拟地址对应放在左侧。

程序内部的地址用的仍然是虚拟地址(物理地址为外地址),当程序加载到内存里,每行代码每个数据便拥有了一个物理地址

  • 26
    点赞
  • 56
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值