【Linux】7. 进程概述

发布于:2025-07-23 ⋅ 阅读:(18) ⋅ 点赞:(0)

  本文会介绍进程的概念、进程调度(调度算法(了解)、优先级、上下文切换)、进程状态、进程地址空间。其中进程的概念进程状态是基础,上下文切换十分重要。
  进程地址空间是Linux操作系统的核心内容,里面涉及虚拟地址、页表等概念,难度高并且十分重要。用个夸张的说法:学会进程地址空间,就等于掌握了1/3的Linux基础知识。

进程概述

  • 一、进程概念
  • 二、进程调度与切换
    • 1. 进程调度的概念及算法
    • 2. 优先级
    • 3. 上下文切换
  • 三、进程状态
    • 1. 运行状态(Running)
    • 2. 阻塞状态
      • 2.1 睡眠状态(sleeping)
      • 2.2 暂停状态(stopped)
      • 2.3 追踪状态(tracing stop)
      • 2.4 挂起状态
      • 2.5 深度睡眠(disk sleep)
    • 3. 死亡状态(dead)
    • 4. 僵尸状态(zombie)
    • 5. 孤儿进程
  • 四、进程地址空间
    • 1. 进程地址空间的概念
    • 2. 虚拟地址与页表
      • 2.1 物理地址与逻辑地址
      • 2.2 页表
      • 2.3 虚拟地址
    • 3. 虚拟地址空间中的内核空间
    • 4. 页表的映射方法

一、进程概念

(1)程序的概念

  对于用户而言,计算机作为一种工具,使用它是为了帮我们完成一些任务,如计算数值、查阅图片、远程聊天等,这些任务有大有小、有多有少。为了让计算机能看懂我们的需求,用户会将这些任务用高级编程语言表达出来并写入文件中,再由编译器帮我们将这些文件编译成计算机可以执行的指令,最后将我们编译好的任务交给计算机执行,此时计算机便帮助我们完成了一个任务。我们编译好的一个可执行的任务文件就是程序,对于用户而言,一个程序就是我们需要完成的一个任务。

程序:可以被计算机直接执行的文件,程序内写着二进制可执行指令,程序作为一个文件被存储在磁盘中。

注意:程序中的指令在执行过程中,还需要空间来存储变量,局部变量和malloc申请的堆区变量在运行时才由操作系统分配空间,而全局变量、静态变量、常量则是直接在程序中存储,因此一个程序是由代码和数据组成的

(2)进程的概念

  由于CPU只能与内存直接交互数据,所以磁盘中的程序中只有被加载进内存才能被执行。大量的程序被加载进内存,操作系统需要将它们进行统一管理,如给这些程序分配存储空间、计算它们在CPU上的执行顺序、将执行完毕的程序从内存中清理掉等。

  有很多的程序被加载到内存,操作系统直接对这些大小不一且体积庞大的程序进行管理是非常费劲的。所以操作系统用一个结构体变量来记录每个程序的 (属性) 以及 (它们的数据、代码等各种资源的地址),通过这一个结构体变量就能获取该程序的所有信息以及它所需要的资源,操作系统则只需要对这些结构体变量进行管理即可。

  PCB(process control block)进程控制块:这个结构体变量就叫进程控制块(PCB),PCB的结构体类型为struct task_struct,该结构体类型非常复杂,存储了一个程序的所有属性以及数据、代码等各种资源的地址

  进程(process)就是 PCB + 程序的代码和数据。还有一个更加广泛的说法:被加载进内存的程序就是进程,也没什么问题,被加载进内存后操作系统会自动为其创建PCB,此时它就是进程。
进程=内核数据结构PCB+进程对应的代码和数据进程 = 内核数据结构PCB + 进程对应的代码和数据=PCB+

  • 操作系统对进程的管理实质上是对进程控制块PCB的管理,而不是直接管理该进程的代码和数据。
  • 每一个进程都有一个唯一的进程编号,这个进程编号存储在进程PCB中,操作系统根据进程编号区分每个进程。进程编号通常被称为pid(process id)。

在这里插入图片描述

二、进程调度与切换

1. 进程调度的概念及算法

  由于一个计算机的CPU数量少,进程的数量多。所以进程是轮流在CPU上运行的,操作系统为进程计算出顺序让进程依次执行的过程叫做进程调度,而操作系统分配进程执行顺序的调度算法类型很多且十分复杂。

  进程调度分为抢占式调度和非抢占式调度。

  • 抢占式调度:CPU需要被多个进程争夺,只有争取到的进程才能在CPU上运行。
  • 非抢占式调度:进程之间按一定的顺序依次在CPU上运行,不存在争抢现象。

以下是几种基础且常见调度算法(了解):

  • 先来先服务(FCFS):按照进程到来的先后顺序运行,哪个进程先来,哪个进程就先运行。

    • 优点:对每个进程公平,算法实现简单。
    • 缺点:占用时间长的进程运行时,会导致其他进程等待太久。
  • 短作业优先(SPF):按照当前所有进程的剩余时间由短到长依次运行,谁快谁先来。

    • 优点:能让执行时间短的进程快速运行完,提高进程的完成率。
    • 缺点:对长进程不利,若是一直有源源不断的短进程来,会让长进程产生饥饿(长时间得不到运行)现象。
  • 高响应比优先(HRRN)响应比 = (等待时间 + 运行时间) / 运行时间。就是等待时间越久、运行时间越短的进程响应比越大,响应比越高的程序优先级越高。

    • 优点:综合考虑了等待时间和运行时间,保证运行时间短的优先执行外,也能保证等待时间久的进程优先级越来越高。
  • 时间片轮转(RR):让每个进程依次执行一个时间片段,时间片到了后若没有执行结束则中断它,让它重新在后面排队,等再次轮到它则继续接着刚刚的执行一个时间片,反复如此。

    • 优点:公平,且能兼顾每个进程。
    • 缺点:高频地切换进程会产生一定的开销。
  • 优先级调度算法:每个进程设置有优先级,优先级高的进程会被优先执行。

    • 优点:可以区分任务的紧急程度,灵活调整进程的执行顺序。
    • 缺点:若是源源不断的高优先级进程到来,低优先级进程会产生饥饿现象。

2. 优先级

(1)优先级概念

  优先级:进程的优先级就是获取CPU执行权先后顺序的能力,优先级本质就是PCB里的一个属性,这个属性是一个整型数字(也可能是几个整型数字一起调整)。

在实际开发过程中很少会对进程的优先级做出修改,所以本段内容了解即可。

在Linux操作系统中,优先级是用两个整型变量PRINI来决定的:

  • PRI(priority):代表这个进程的优先级,其值越小越早被执行。PRI默认值是80。
  • NI(nice):用户用来调整优先级的变量,它的范围是-20到19,一共40个级别。NI默认值为0。
    • NI值的修改超出-20到19范围时,只会取-20或19。
    • Linux操作系统支持进程在运行中调整NI值。

优先级PRI=80+NI值优先级PRI = 80 + NI值PRI=80+NI

  1. 一个进程当前PRI为80,NI为0。
  2. 调整NI值为2,则最终PRI为82,NI为2。
  3. 继续调整NI值为3,最终PRI为83,NI为3。

(3)查看进程优先级

可以使用ps指令查看进程的优先级。

ps -la:查看进程信息,其中会显示PRINI的值。

ps -la		# 查看进程优先级
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1001 14082 13910  0  80   0 - 37400 poll_s pts/2    00:00:00 vim
0 R  1001 14201 14115  0  80   0 - 38332 -      pts/3    00:00:00 ps

(3)修改进程优先级top

更改进程优先级有很多办法,这里我们使用top工具:

  1. 使用sudoroot用户运行top命令。进入一个有各种进程信息的界面,输入r进行对优先级的修改。
  2. 输入需要修改优先级的进程pid,然后输入NI值修改进程优先级。
    在这里插入图片描述

3. 上下文切换

  在CPU中有一套寄存器,这是集成在CPU内部的存储器,是距离CPU最近、访问速度最快的存储介质。用来临时存储数据、指令等,方便运算器与控制器快速存取数据。

寄存器有以下几种:

  • 数据寄存器:存储被运算的数据及运算结果。
  • 地址寄存器:存储内存的地址,方便CPU通过地址找到内存中的数据。
  • 指令寄存器:存储当前执行的指令。
  • 程序计数器:存储下一条指令的地址。每执行一条指令,程序计数器都会加一,自动指向下一条命令。
  • 状态计数器:存储CPU当前的状态码,用来记录CPU当前的状态。
  • 堆栈指针寄存器:记录栈顶位置,用于管理函数调用和返回。

  寄存器是所有进程共用一套,由于寄存器中的内容包含着当前执行到的指令、正在运算中的数据、下一条指令地址等信息,当进程切换时,下一个进程的这些数据就会写入寄存器中,被切走进程的寄存器数据则会被覆盖,导致该进程下一次来时无法继续从中断处运行。因此进程在切走时必须要将它们的寄存器数据存储到内存中,下次运行时会将寄存器中数据重新写入寄存器中。

上下文:寄存器中的数据就被称为上下文。(上下文是寄存器中的数据,不是寄存器本身)

  每个进程都有自己的上下文数据、操作系统通过进程控制块PCB管理每个进程,为了能更快速地将上下文数据与对应进程联系起来,操作系统会将上下文数据直接存储到进程PCB中,因此PCB专门设有字段存储上下文数据。

  • 上下文保护:进程切走时,会将上下文数据写入进程PCB中,这个过程称为上下文保护。
  • 上下文恢复:进程重新占用CPU时,会将上下文数据写入寄存器中,这个过程称为上下文恢复。

​ 上下文恢复后,进程就能根据上下文数据继续接着上次中断处执行。

  上下文保护是非常快的,因为寄存器数量少,数据量也少,而且与之交互的内存也比磁盘快很多。

进程什么时候切换?

——时间片到了、有更高优先级的进程到来、进程进入等待状态会切换下一个进程上场,等。

进程什么时候检测需不需要切换?

——进程从内核态变成用户态时,会自动检测进程是否需要切换

为什么用内核态变成用户态时检测这些?

——检测进程是否需要切换只能在内核态进行,进程进入内核态需要很大的开销。进程调用系统调用接口进入内核态,执行完毕后要转变成用户态,反正检测进程是否切换也要在内核态下进行,所以在进入用户态之前就检测一下是否要切换,若是满足切换条件就直接切换。
  因此原因就2个字:顺手(顺便)。
  从内核态进入用户态时,同样会顺手做的还有进程信号的处理。(在《进程信号》中会详细说明)

三、进程状态

(1)进程状态简介

  进程状态:是进程内部的一个属性,在进程控制块PCB中保存,它表示进程当前处于什么状态。

在Linux中,进程状态是由一个task_state_array数组表示:

static const char* task_state_array[] = {
    "R (running)",		/* 0 */		// 运行状态
    "S (sleeping)",		/* 1 */		// 睡眠状态
    "D (disk sleep)",	/* 2 */		// 深度睡眠
    "T (stopped)",		/* 4 */		// 暂停状态
    "t (tracing stop)",	/* 8 */		// 追踪状态
    "Z (zombie)",		/* 16 */	// 僵尸状态
    "X (dead)"			/* 32 */	// 死亡状态
}
  • 每个状态都有一个队列,进程在对应的队列中排队,等待某种资源。
  • 进程排队实质上是进程的PCB在队列里面排队

(2)三态模型和五态模型

在有些地方我们会看到进程的三态模型和五态模型:

三态模型:

  • 运行状态:进程正在CPU上运行。
  • 就绪状态:进程可以运行,但是CPU正在被其他进程使用,该进程正在就绪队列排队等待的状态。
  • 阻塞状态:进程需要某种资源,如请求从磁盘中获取数据,此时它就会进入阻塞队列等待且无法运行,阻塞状态下进程请求到资源后,便会重新变成就绪状态,排队准备到CPU上运行。这个状态叫阻塞状态,也叫等待状态睡眠状态

五态模型:就是三态模型中多个2个。

  • 新建状态:进程正在被创建时的状态。正在被创建的进程PCB会在创建队列排队。
  • 终止状态:进程运行结束,操作系统回收资源时该进程处于终止状态,运行结束的进程PCB会在终止队列排队。也叫死亡状态

  三态模型和五态模型都是理论上的进程状态模型,Linux当中的进程状态与它们不同,但也是基于这个理论上设计出来的,我们学习的是Linux实际上的进程状态。

(3)查看进程状态

ps axj:可以显示所有进程的信息,其中包括进程状态。

ps axj										# 显示所有进程状态
ps axj | grep [proc]						# 用grep筛选出proc进程的信息
ps axj | head -1 & ps axj | grep [proc]		# 用grep筛选出proc进程的信息,并带上每列说明

1. 运行状态(Running)

  运行状态(Running):进程在运行队列的状态。实际上Linux没有就绪队列,进程准备就绪后就会在运行队列上排队,此时它就已经是运行状态。运行状态用R表示。

  运行队列:CPU只有一个,但是要使用的进程很多,它们需要排队使用,所以操作系统用运行队列对这些进程进行管理。运行状态下的进程等待的是CPU

在这里插入图片描述

  当我们写了一个死循环的程序running,用ps指令查看它的STAT属性为R+,是处于运行状态

  • 前台进程+表示该进程是前台进程,前台进程可以被ctrl + c终止。
  • 后台进程:不带+的就是后台进程,后台进程不能被ctrl + c终止,只能被kill -9终止。

在这里插入图片描述

2. 阻塞状态

  阻塞状态:进程在等待某种资源时,暂时无法直接上CPU运行,等待的状态称为阻塞状态。

  这里的阻塞状态是广义上的概念,只要进程不能动了,无论是在那个队列等待资源,它就是阻塞状态,Linux针对阻塞状态做了四个细分。目录里带英文的都是Linux中真正写出来的进程状态。

为什么存在阻塞状态:

  • 由于CPU和外设的速度相差悬殊,CPU等不了那么长时间,就会将该进程的PCB设置为阻塞状态,然后继续执行其他进程的代码。
  • 由于外设资源不足以被多个进程同时使用,所以它们需要排队等待。每个外设会有一个自己的等待队列,当进程访问的外设资源已经被占用时,CPU会将进程的PCB放到外设的等待队列中。
    在这里插入图片描述

  进程需要等待的资源不仅仅只有外设,也有其他资源,如A进程必须要B进程完成一些事情后才能继续往下执行,所以A进程必须等待。在Linux操作系统中有4种状态来描述阻塞状态,这些阻塞状态用来表示进程不同的阻塞场景:

  • 睡眠状态(sleeping)
  • 暂停状态(stopped)
  • 追踪状态(tracing stop)
  • 深度睡眠(disk sleep,也叫磁盘休眠状态)

2.1 睡眠状态(sleeping)

  睡眠状态(sleeping):进程等待某个事件完成(如;访问磁盘,访问外设等)。睡眠状态用S表示。

  我们写一个从控制台输入的程序sleeping(如scanf函数),在我们输入内容之前进程处于阻塞状态,此时用ps指令查看它的STAT属性为S+
在这里插入图片描述

2.2 暂停状态(stopped)

  暂停状态(stopped):进程运行过程中被暂停执行的状态就是暂停状态。它与睡眠状态不同,不一定是因为等待某些资源,只是单纯让他停一下。暂停状态用T表示。

让进程暂停需要通过进程信号控制,使用kill指令就能发送对应的信号

  1. kill -19 [pid]:暂停正在运行中进程编号为pid的进程。
  2. kill -18 [pid]:恢复已经被暂停的进程。

实例演示

  1. 我们写一个每隔两秒循环打印进程的ID的程序stopped,并用ps指令查看它的状态为S+
    在这里插入图片描述

  2. 使用kill -19 8139指令将它暂停掉,并用ps指令查看它的状态为T
    在这里插入图片描述

  3. 使用kill -18 8139指令将它恢复执行,并用ps指令查看它的状态为S
    在这里插入图片描述

  4. 程序的执行结果:
    在这里插入图片描述

  注意:当一个前台进程被暂停后,恢复执行时会变成后台进程,无法被ctrl + c杀死,必须使用kill -9指令。

2.3 追踪状态(tracing stop)

  追踪状态(tracing stop):Debug程序被调试时,会在断点处停止,此时进程就会处于追踪状态。追踪状态存在的意义就是让Debug程序可以暂停调试。追踪状态用t表示。

  1. 写一个打印多个hello world的程序tracing,并将其编译成Debug程序。用gdb调试器对其进行调试。在第10行打上断点并运行。
    在这里插入图片描述

  2. 使用ps指令查看该进程的状态,进程状态为t
    在这里插入图片描述

2.4 挂起状态

  挂起状态:内存空间不够时,会将一些程序的代码和数据暂时保存在磁盘上,并将它的PCB置为挂起状态。

  1. 如果运行进程时内存空间不够用了,会将阻塞进程的代码和数据暂时保存到磁盘上。
  2. 写入磁盘进程的PCB仍在内存中,操作系统会将该进程置为挂起状态
  3. 挂起的进程准备调度时,会将该进程重新写入内存。若是内存空间还不够,也会将其他进程挂起。这个过程叫做内存数据的换入换出

注意

  • 内存还是不够时,除了运行状态其余都有可能挂起,一般阻塞状态的进程会优先挂起。就绪挂起,新建挂起都是可能存在的。
  • Linux中的挂起状态不暴露给用户,用户无法知道进程是否进入挂起状态,对于用户来说它只是一种阻塞状态。(所以挂起状态没有被显示表达,task_state_array数组中没有存储该状态,但它是真实存在的)

2.5 深度睡眠(disk sleep)

  深度睡眠状态(disk sleep)进程在磁盘中写入重要数据时不允许被中断,它就会被设置为磁盘睡眠状态。这是一个Linux一个特有的状态,也叫磁盘睡眠状态不可中断睡眠状态(uninterruptible sleep)。深度睡眠状态用D表示。

  睡眠状态、暂停状态和追踪状态都是浅度睡眠,该进程可以被中断。但是深度睡眠的进程不可以被中断。

为什么存在深度睡眠状态:

  • 当内存不足,挂起也无法解决问题时,操作系统会结束一些进程,保证操作系统的运行,这也是服务器宕机的主要原因。
  • 有些进程访问磁盘时会写入极其重要的数据,如银行的金额等,它处于阻塞状态等待访问磁盘时不能终止,操作系统会将其置为深度睡眠状态。
  • 深度睡眠中的进程是不可以被操作系统杀死的,只能等它自己运行结束或者断电重启。

注意

  • D状态是软件与硬件深度联动的一种状态,防止进程在IO过程中出现意外。
  • 当一台服务器中存在大量的处于D状态的进程时,它就已经处于崩溃的边缘了。
  • dd命令是将操作系统的临时文件存储到磁盘中,这个数据量将非常庞大且处于D状态无法终止,非常容易将服务器整宕机。(了解就行,不建议试试,所以我也不展示了)

3. 死亡状态(dead)

  死亡状态(dead):进程结束时,操作系统需要回收进程资源,此时它便处于死亡状态。死亡状态用X表示。

  • 当进程死亡时,操作系统会立即对该进程回收资源,或者延迟回收该进程资源,在这个时间段内,该进程PCB的状态就是X
  • 我们无法查看到进程的死亡状态,无论是立即还是延迟回收资源,时间都是非常短暂的,我们无法一瞬间捕捉到进程的死亡状态。

4. 僵尸状态(zombie)

  僵尸状态(zombie):子进程退出时,父进程没有读取到子进程的退出码,子进程PCB无法被释放,就会处于僵尸状态。僵尸状态也叫僵死状态。僵尸状态用Z表示。

为什么存在僵尸状态:

  • 子进程被创建是用来帮助自己执行任务,当子进程执行完任务时,父进程要知道子进程执行的怎么样,所以子进程的退出码要反馈给父进程
  • 子进程的代码执行完毕,代码和数据都不再使用,操作系统会释放子进程的代码和数据所占的内存空间,它的退出码只能保存在PCB中。因此子进程的PCB必须等父进程获取子进程的退出码后才能释放
  • 当父进程没有获取子进程的退出码时,子进程的PCB就一直存在且无法得到释放,子进程就处于僵尸状态。

注意

  • 僵尸进程无法被kill -9杀死,因为该进程的代码和数据已经被释放,无法杀死一个已经死亡的进程。
  • 僵尸进程对操作系统而言是一个危害,进程PCB无法释放,造成内存泄漏。(我们将在《进程控制》章节解决这个问题)

  那父进程有没有办法可以不获取子进程退出码,让僵尸进程被回收的方法?——有,利用进程信号中的SIG_IGN处理捕获到的17号信号SIGCHLD,让父进程忽略子进程的退出信号及等待过程,在子进程退出时就被操作系统回收(在《进程信号》中说明)。

实例演示

  以下会进行创建进程等操作,在《进程控制》章节中有说明,代码中有注释,可以看注释知道代码干了些什么

  1. 我们写如下代码,创建子进程,让其执行完毕,但是父进程不回收。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    // 创建子进程,id为子进程编号
    pid_t id = fork();
    if (id == -1)
    {
        // 创建失败,打印错误信息
        perror("fork fail\n");
        exit(1);
    }
    else if (id == 0)
    {
        // 子进程执行的代码:打印自己的id,打印父进程id
        printf("I am son, my id is %d, my father's id is %d\n", getpid(), getppid());
        exit(0);
    }
    else
    {
        // 父进程执行的代码:打印自己id,不获取子进程执行结果
        printf("I am father, my id is %d\n", getpid());
        sleep(20);			// 休眠20秒,用来方便我们输入ps指令查看子进程信息
        exit(0);
    }
    return 0;
}
  1. 将以上代码编译并执行,在父进程休眠结束前用ps指令查看其父进程状态为S+,子进程状态为Z+
    在这里插入图片描述

5. 孤儿进程

  孤儿进程:当父进程死亡时,子进程依然继续执行,此时子进程就称为孤儿进程。

  • 父进程退出时,子进程的退出码无法反馈给父进程。如果不处理,这将会导致子进程无法被释放。
  • 此时操作系统会领养孤儿进程,它的父进程就是操作系统(也叫init进程,进程id为1)
  • 孤儿进程被领养后会自动变成后台进程,无法用ctrl + c结束,只能用kill -9结束。

  对于操作系统而言,孤儿进程不是危害,操作系统会自动领养孤儿进程。因此孤儿进程没有专门的进程状态。

为啥操作系统不自动领养僵尸进程?

——僵尸进程有爹,只是它爹不接受它的退出码,它的PCB无法得到释放。

为啥操作系统不帮助僵尸进程接受退出码?

  • 僵尸进程是给它爹干活的,它的退出码是要反馈给它爹的,它的退出码对于操作系统没啥用,操作系统只能直接结束该进程并回收PCB。
  • 但是操作系统不能这么做,如果它爹是等一会儿再回收它的退出码,那操作系统的自作主张就会导致父进程任务执行失败。

  僵尸状态的真正意义是:让父进程还未获取子进程退出码时,提前把运行结束的子进程代码和数据空间先释放了,再将子进程状态置为z,表示这是一个运行结束但还要给父进程反馈退出码的进程。所以设置僵尸进程不是纯粹为了祸害操作系统的。

实例演示

  1. 编写代码,先结束父进程,再结束子进程
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    pid_t id = fork();		// 创建子进程
    if (id == -1)
    {
        // 创建失败打印提示信息并退出
        perror("fork fail\n");
        exit(1);
    }
    else if (id == 0)
    {
        // 子进程打印父子进程id
        printf("I am son ,my id is %d, my father is %d\n", getpid(), getppid());
        
        // 三秒后再次打印父子进程id,并退出
        sleep(3);
        printf("I am son ,my id is %d, my father is %d\n", getpid(), getppid());
        exit(0);
    }
    else
    {
        // 父进程打印自己id,并在1秒后退出
        printf("I am father, my id is %d\n", getpid());
        sleep(1);
        exit(0);
    }
    return 0;
}

  1. 编译成为程序orphan,发现父进程结束之前,子进程的父进程pid为6414;父进程结束后,子进程现在的父进程pid为1。
    在这里插入图片描述

  2. 在父进程结束前,我们用ps指令查看进程信息,发现进程状态为S+。父进程结束后,子进程被领养,进程状态为S,领养之后的进程变成了后台进程。
    在这里插入图片描述

四、进程地址空间

1. 进程地址空间的概念

(1)广义的进程地址空间概念

  程序运行是需要占用内存空间的,由于进程中的数据有多种类型,为了方便这些数据的存储与使用,操作系统会按照数据的类型,将它们放置在对应的内存空间中,这个空间就是进程地址空间。

  这个是广义上的进程地址空间概念,它是进程的各种数据在内存中的分布位置,后面我们还会直戳进程地址空间的本质。

进程地址空间结构如下图:
在这里插入图片描述

  • 内核空间:存储着操作系统的内核资源(在本文下面会说明)。
  • 栈区(stack):存储着函数内部定义的局部变量,栈区的大小是动态变化的。
  • 共享区:用于进程间通信,即与其他进程共享数据。
  • 堆区(heap)malloc等申请的空间就在堆区,这里存储着进程动态申请的空间数据。
  • 数据区:未初始化数据和已初始化数据合起来称为数据段,这里存储的都是进程的全局变量静态变量,已初始化和未初始化的变量分开存储。也叫数据段静态区、全局数据区等。
  • 代码区:也叫代码段,进程的代码就在这里存储。代码段中存储的不是我们写的高级语言代码,而是编译好后的代码,是二进制可执行指令。
  • 栈区和堆区中的变量在运行过程中是动态变化的,让它们分别从高地址和低地址反方向增长,就能提高空间的利用率。
  • 代码区,数据区的大小在编译好后就已经确定了,运行过程中大小一般不会在变化。
#include <stdio.h>
#include <stdlib.h>

int a;						// 未初始化全局变量a,存储在数据段的未初始化数据区
int b = 10;					// 已初始化全局变量b,存储在数据段的已初始化数据区
static int c;				// 未初始化静态全局变量c,存储在数据段的未初始化数据区
static int d = 20;			// 已初始化静态全局变量d,存储在数据段的已初始化数据区

// 函数代码,存储在代码区
int add(int a, int b)		// 函数参数,也是局部变量,存储在栈区
{
    return a + b;
}

// 以下函数代码也存储在代码区,但里面的变量不在代码区。
int main(void)
{
    int e;					// 局部变量e,存储在栈区
    int f = 30;				// 局部变量f,存储在栈区
    static int g;			// 未初始化静态局部变量g,存储在数据段的未初始化数据区
    static int h = 40;		// 已初始化静态局部变量h,存储在数据段的已初始化数据区
    
    int* i = (int*)malloc(sizeof(int));	// 局部变量i,存储在栈区,i指向的空间存储在堆区
    free(i);
    return 0;
}

(2)狭义的进程地址空间概念

  操作系统将进程的数据空间分割为这些结构之后,必然是要管理这些内存空间的,但是进程数量多,且这些每个空间中存储的数据大小不一,直接管理起来非常费劲,所以操作系统会创建一个结构体变量来记录这些空间的信息(如起始地址、结束地址等),操作系统只需要管理这些结构体变量,就能管理好每个进程的空间。

  每个进程都对应一个记录内存空间信息的结构体变量,因此只要在每个进程的PCB中设置一个指针,指向这个结构体变量,操作系统就不需要单独管理这些进程的内存空间了,只需要管理好进程PCB即可。

  进程地址空间本质只是操作系统的一个结构体变量,存储着进程的各种资源在内存空间中的地址,这个结构体类型为struct mm_struct,在进程PCB中由mm指针指向进程地址空间。

这是狭义的进程地址空间概念,它就是一个结构体变量。

进程地址空间struct mm_struct结构体的部分代码:

struct mm_struct
{
    unit32_t code_start, code_end;		// 代码区起始地址
    unit32_t data_start, data_end;		// 数据区起始地址
    unit32_t heap_start, heap_end;		// 堆区起始地址
    unit32_t stack_start, stack_end;	// 栈区起始地址
    // ... ...
};

在这里插入图片描述

2. 虚拟地址与页表

2.1 物理地址与逻辑地址

  物理地址:内存的基本空间大小是字节,每一个字节都有唯一地址,这个地址被称为物理地址,物理地址是硬件本身具有的,它是固定不变的。

  32位计算机的地址寄存器大小为32位,它可以表示2322^{32}232个物理地址,它能拥有的内存范围就在4GB以内。

  程序每次加载进内存时,操作系统会按照当前内存的使用情况,给该进程分配一个合理的内存空间,由于进程每次运行时内存的使用情况都不一样,所以进程的存储位置也会发生变化。

  编译器在编译程序时,会将程序中的符号变成地址,利用地址找到变量、函数等。程序在编译好的那一刻里面的地址就已经固定不变了,若是代码中使用内存的物理地址,编译器在无法知道哪些地址已经被使用或未被使用,那就会出现有些空间已经被其他进程占用、有些空间一直都无人使用的问题。

  为了解决这个问题,编译器在程序中使用的并不是内存的物理地址。而是使用其他的编址方式(如偏移量编址:程序内每个区域的数据都是从0开始编址,加载进内存时,利用该区域的首物理地址加偏移量就能找到对应的数据),实际上的编址方式需要考虑的因素很多,也更为复杂。以下是2种编址方式的图。
在这里插入图片描述
  逻辑地址:编译器在程序内分配的地址称为逻辑地址,通过逻辑地址能让程序每次在不同位置的物理内存上精准地找到数据的物理地址。

  • 逻辑地址是编译器编译时分配给程序的,在程序内部使用,程序存储在磁盘中。
  • 程序的函数代码、全局数据、静态数据等才会存储在程序文件中。栈区与堆区的数据也会被分配一个逻辑地址,在实际运行时会利用偏移量动态计算出实际的物理地址。

2.2 页表

(1)页帧与页框

  现在进程每次加载到不同位置导致找不到数据与代码的问题解决了,又迎来了一个新的问题。物理内存是一个巨大的存储空间,每个进程像一块砖一个一个往里放,但是有的进程占用空间大,有的进程占用空间小,这导致内存相差大的进程之间会存在过大的缝隙,只要没有更小的进程可以填满这个缝隙,积累起来就会造成一笔不小的内存浪费。

  为了解决进程间的内存缝隙问题,操作系统不会将一个完整的进程放置在一个连续的物理内存空间内,而是将物理内存分成固定大小的块,程序被分为同样大小的块,将程序按块放入物理内存中可用的空间块。

  • 页框:物理内存被分割成固定大小的块(通常是4KB),这个空间块就被称为页框。
  • 页帧:磁盘中的数据被分割成与页框同样大小的块,它被称为页帧。
    • 页帧是磁盘数据加载到内存的最小单位,即磁盘数据一次最少加载一页(4KB)。

在这里插入图片描述

(2)页表

  但是页帧与页框的出现又让程序中原先拥有的逻辑地址又失效了,物理内存不连续,进程无法计算出数据的物理地址。为了解决这个新问题,操作系统专门开辟了一片空间,用来记录这些逻辑地址与物理地址的映射,即通过这片空间记录的数据,进程使用逻辑地址访问不连续的物理空间时,它会帮助进程找到下一片物理空间的数据。

  页表:这个存储“逻辑地址”与物理地址映射的空间称为页表,它能让进程通过“逻辑地址”找到实际上的物理地址。
(这里的”逻辑地址“被打上了双引号,聪明的朋友已经想到为什么了,没错,这依然不是最终版本,还能继续优化)

  • 即使进程的物理地址不连续,但是从用户的角度来看使用的依然是连续的“逻辑地址”。
  • 每一个进程都有一个单独的页表。

在这里插入图片描述
  页表的映射方法在后面,毕竟这里暂时用的是”逻辑地址“,不是页表最终使用的地址。

2.3 虚拟地址

  由于进程在内存中占用的空间大小不一,操作系统无法提前知道该进程需要多大的内存空间,因此只能在进程运行过程中动态给予进程内存空间。对于用户而言,编写代码时自然要考虑进程的空间大小及地址范围,但是用户无法知道程序会运行在什么环境中,地址最多能使用多大数,所以用户会凭借自己的需求写。

  但是操作系统可不会看你的需求,虽然编译器会从0开始分配逻辑地址,但是你一上来就访问*(1008610086)这么长一串地址(地址是胡诌的,只要看起来大就行了),就算内存真有这么大,为了考虑其他进程的正常运行,也不会真的给你1008610086大小的空间。此时编译器分配的逻辑地址就不那么好使用了。

  为了解决这个问题,操作系统采取画大饼的方式,无论如何访问多大地址,只要在当前计算机的容量之内,它都会假装同意给你,只有在这个空间被使用时,该空间的物理内存才会被真正开辟出来使用,如1008610086这个地址真的可以读写数据,存在真实的物理内存空间,但是1008610086之前的没有使用的地址是不会开辟出真实的物理内存的,这样看起来好像你真有1008610086个空间似的,事实上只有真正使用的数据才占用物理内存空间。这种利用虚拟的内存地址分配内存空间的技术叫做虚拟内存技术
在这里插入图片描述
  虚拟地址:就是操作系统分配给进程的假的内存空间地址,用户可以访问计算机允许范围内的任何虚拟地址,但只有使用到该空间的时候才会真正从物理内存上开辟空间。虚拟地址也叫线性地址(因为是从0开始线性分配)。

  以上是虚拟地址狭义的概念,它与程序中的逻辑地址、物理地址是完全不同的东西。

  虚拟地址还有一个广义的概念:任何虚拟的、不存在的地址都可以叫虚拟地址,程序中的逻辑地址也可以称作虚拟地址。但物理地址是真实存在的,是内存每个字节都具备的属性编号,不能被称为虚拟地址。

  因为虚拟地址的存在,进程会认为自己拥有内存的全部空间,一个32位的地址能表示2322^{32}232个字节的空间,所以一个32位计算机上的进程以为自己有4GB的空间。

  当程序被加载进内存时,操作系统会给进程中的数据分配虚拟地址(但逻辑地址并不是没有意义,它的偏移量能快速让进程定位到代码、全局变量等的地址,操作系统会根据这个特性为其分配虚拟地址)。页表使用虚拟地址与物理地址建立映射关系。

  页表的真正定义:存储虚拟地址与物理地址映射关系的称为页表,它能让进程通过虚拟地址找到实际上的物理地址。

  • 计算机有专门的硬件MMU处理页表,MMU集成在CPU中,该硬件节省了CPU的运算,提高访问数据的性能。
  • (进程中使用的地址、CPU访问的地址、用户代码中的地址)均是虚拟地址
  • 物理地址只能通过页表获取,页表会通过映射关系将虚拟地址转换成真实的物理地址。

因此进程地址空间也叫虚拟地址空间
在这里插入图片描述

虚拟地址空间的特点:

  • 解耦:用户和进程只需要使用虚拟地址即可,真实的物理内存由操作系统管理,操作系统会利用页表为虚拟地址开辟内存空间。
  • 虚拟:由于虚拟地址连续且覆盖广,所以在进程看来,自己使用的是计算机中一整片连续的所有内存空间。
  • 独立:由于页表中只会出现该进程使用的物理地址,所以进程不可能访问到其他进程的数据,即使该进程访问了很大的地址,也会因为虚拟内存技术访问到操作系统为自己开辟的空间。这保证了每个进程之间的独立性与安全性。

3. 虚拟地址空间中的内核空间

  进程地址空间给了一个进程”4GB“的内存空间,但是其中有”1GB“是内核空间。这是操作系统表示自己的空间,即任何一个进程都知道这个计算机中不止有自己一个,操作系统也有自己的代码和数据,也会占用空间。操作系统这1GB也是虚拟空间,实际上操作系统的内存大小并不固定,只有部分虚拟地址才会有真正的物理空间。

虚拟地址空间里面的内核空间表示的是操作系统内核的代码和数据

  进程在执行代码中会调用系统调用接口,系统调用接口的具体实现代码就存储在操作系统内核中,进程需要通过进程地址空间访问到操作系统的代码和数据。由于进程地址空间中使用的虚拟地址,所以内核所在的物理空间也是需要通过页表找到的。

  由于一台设备只有一个操作系统在运行,所以内核空间的代码和数据只有一份,如果每个进程都为内核空间在页表中建立一份映射关系,则会造成数据的冗余,浪费内存空间。所以将内核空间在页表中的映射关系单独建立一张页表,所有进程共用一份内核空间的页表

  • 内核级页表:存储着操作系统内核的虚拟地址与物理地址映射关系的页表就叫内核级页表,内核级页表所有进程共用一份。
  • 用户级页表:存储着每个进程独立空间的映射关系的页表就叫用户级页表,用户级页表每个进程都有一份。

每个进程的虚拟地址使用情况都不一样,内核级页表如何确保每个进程在内核空间上的虚拟地址一样?

——内核空间的虚拟地址大小是固定的,即1GB大小的高地址空间,这部分虚拟地址直接被划分为内核空间,每个进程都一样。所以即使进程的虚拟地址使用情况不同,也只能在3GB大小的低地址空间中有区别。

当某个进程让内核级页表中的映射关系发生变化时,其他进程会受影响吗?

——操作系统加载进内存的那一刻起,它的代码和全局数据的物理地址在关机之前就不会发生改变,这些在页表中的映射关系不会发生变化。操作系统中的一些可变数据会在运行中发生变化,页表也会实时更新映射关系。若是某个进程在操作系统中修改了某些资源,其他进程也能看得见,会受到影响。不过这些修改都只能经过系统调用接口,只能执行操作系统允许的操作。

以下这张图就是完整的进程地址空间了(页表的具体实现未被展开):
在这里插入图片描述

4. 页表的映射方法

  一个32位的地址能表示2322^{32}232个字节(4GB)的空间,4个字节刚好能存储一个32位的地址,存储每个字节的地址需要所有空间4倍的内存(16GB),这显然是无法实现的,所以虚拟地址与物理地址不是将每个地址都一一映射并存储到内存空间中的。

页表将32位地址分别划分位10、10、12位:

  • 页目录:物理地址的前10位存储在内存空间中,组成一个页目录。虚拟地址需要先匹配前10位,前10位匹配成功后会查对应的页表项,也称一级索引
  • 页表项:物理地址的中间10位会存储在一个页表项中,页目录中的每个前10位地址都对应一个页表项,虚拟地址匹配成功前10位后,会在对应的页表项中继续匹配中间10位,也称为二级索引

  最后12位不在页表中存储,12位能表示4kB的内存空间,刚好是一个页帧的大小,所以页表中存储的地址可以直接定位到一个页帧。在页帧内利用虚拟地址后12位作为页内偏移量,就能找到页帧内的数据。

物理地址=页目录10位物理地址+页表项10位物理地址+12位虚拟地址页内偏移量物理地址 = 页目录10位物理地址 + 页表项10位物理地址 + 12位虚拟地址页内偏移量=10+10+12

  每个页表项的大小为4字节(32位),存储虚拟地址的10位和物理地址的10位绰绰有余,剩余比特位会用来表示权限等数据。

  • 4GB的总空间为232=4,294,967,2962^{32} = 4,294,967,296232=4,294,967,296 B
  • 页帧大小为212=40962^{12} = 4096212=4096B
  • 总页数一共有232/212=220=1,048,5762^{32} / 2^{12} = 2^{20} = 1,048,576232/212=220=1,048,576
  • 所有页表项为220×4=222=4,194,304B≈42^{20} \times 4 = 2^{22} = 4,194,304 B ≈ 4220×4=222=4,194,304B4MB
  • 页目录为210×4=4096B=42^{10} \times 4 = 4096B = 4210×4=4096B=4kB

整个页表大小为4MB+4kB4MB + 4kB4MB+4kB

  实际上进程不会为每个地址创建页表项,只有内存中已经使用的页帧才会被创建页表项,所以一个进程的页表通常会更小。

实际上的计算机可能会有更复杂的多级页表,花费的内存可能会更小。

CPU使用页表来找到内存中的物理空间,页表存储在物理空间中,那CPU如何找到页表的位置?

——在CPU有一个寄存器CR3专门用来存储页表的地址,寄存器中的数据在上下文中会一直跟随进程。

在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到