type
status
date
slug
summary
tags
category
icon
password

概览

所谓虚拟化, 就是将硬件资源虚拟化为通用(泛用)且易于使用的虚拟形式.
完成虚拟化之后, 在用户视角机器由裸机(bare machine)变为虚拟机(virtual machine/abstract machine?), 通过虚拟机提供的API来调用虚拟机的功能.
在此视角下, 操作系统≈硬件资源管理器, 可感知形式接近于标准库.

虚拟化CPU——进程

概述

进程就是运行中的程序.
一个系统中可能有多个进程在”同时”运行, 而这实际上是由操作系统通过虚拟化CPU提供的假象.
“操作系统通过让一个进程只运行一个时间片, 然后切换到其他进程, 来提供存在多个虚拟CPU的假象, 是为时分共享CPU技术, 看起来就像多个进程在并行运行. ”
进程的组成/机器状态: 进程的内存(虚存), 寄存器, 持久存储设备(I/O设备)
进程在虚拟内存中的存在形式
进程在虚拟内存中的存在形式

进程创建

  1. 将程序的所有代码和静态数据(从持久化存储设备)(惰性)加载到进程的地址空间(内存中的某个页框)
    1. 惰性: 分页机制, 用时加载
  1. 在内存上分配堆和运行时栈(并初始化)以备用
  1. 其他初始化任务(如与I/O相关的文件描述符fd的初始化)
  1. 跳转到程序的main()例程, 启动程序

进程状态

三状态模型

notion image
  1. 运行态: 正在处理器上运行(在调度列表中并获得了调度)
  1. 就绪态: 可以运行, 准备运行(在调度列表中但未获得调度)
  1. 阻塞态/等待态: 不具备运行条件, 等待条件被满足(不在调度列表中)
运行态和就绪态之间可以相互切换(调度列表内轮流获得调度), 而等待态不能直接获得调度, 需要先进入列表(达到就绪态)

七状态模型: 引入挂起(suspend)

notion image
  • 一般选择等待态进程进入挂起等待态, 选择就绪态进程进入挂起就绪态
  • 运行态进程可以挂起自己
  • 等待事件结束后, 挂起等待态进入挂起就绪态
  • 选择挂起就绪态进程恢复为就绪态
💡
“suspended” vs “blocked”
进程的挂起态(Suspended State)和等待态(Waiting/Blocked State)都是进程不能被调度执行的状态, 但它们的本质和目的不同, 区别如下:
1. 等待态(Waiting/Blocked State)
  • 定义: 进程正在等待某种资源或事件完成(例如等待I/O操作、等待信号、等待子进程结束等), 不能继续执行.
  • 特征:
    • 进程仍在内存中.
    • 只要等待的事件发生, 操作系统就可以立即将其转为就绪态.
    • 已经占有的资源在进入等待时不会释放(但可能后续被抢占)
  • 触发原因:
    • 程序主动调用阻塞式系统调用(如 read())等待外部事件.
    • 必要资源暂时不可用.
  • 处理机制: 由内核自动管理, 一旦条件满足自动唤醒.
2. 挂起态(Suspended State / Swapped Out State)
  • 定义: 进程被操作系统人为地中断和移出主存(例如因为内存不足), 暂停其执行, 暂时存储在磁盘上.
  • 特征:
    • 进程不在主存中, 不能被调度.
    • 分为就绪挂起态等待挂起态:
      • 就绪挂起态: 原本是就绪态, 被挂起.
      • 等待挂起态: 原本是等待态, 被挂起.
    • 不占有任何资源(全部释放)
  • 触发原因:
    • 操作系统需要腾出内存空间.
    • 管理员或系统策略(如进程优先级)决定将其暂停.
  • 处理机制: 需要操作系统显式将进程重新调入内存, 恢复执行.
总结对比
区别点
等待态
挂起态
是否在内存
否(已被调出到磁盘)
原因
等待资源或事件完成
内存管理或人为控制
唤醒方式
资源就绪后自动唤醒
必须先调入内存, 恢复为就绪/等待
控制方
操作系统(被动等待)
操作系统/用户(主动挂起)
因此, 等待态强调的是进程自身条件未满足, 而挂起态强调的是系统资源管理人为中止执行的需要. 两者可能重叠, 比如一个等待态的进程也可能被挂起.

进程存储形式(i.e.数据结构)

💡
在 struct proc 中的字段:
chan 是 “channel”(通道)的缩写. 它是一个指针, 用于标识进程正在等待的资源或事件. 常用于 sleep/wakeup 机制, 例如:
  • 进程因为某个条件(如 I/O 完成、锁释放)尚未满足而进入 SLEEPING 状态;
  • 此时, 操作系统会将 proc->chan 设置为一个用于识别该等待条件的地址(可以是任意唯一的指针值, 如锁对象的地址);
  • 其他进程或中断处理程序可以通过 wakeup(chan) 唤醒所有 proc->chan == chan 的进程.
例如:
假设有个锁 lock1, 进程 A 在获取失败后调用:
稍后某个释放该锁的线程会调用:
保存所有进程状态的数据结构 → 进程控制块PCB(Process Control Block)
PCB的内容
PCB的内容
💡
注意区分进程控制块PCB和程序状态字PSW
相似点
方面
描述
都是保存进程状态的信息
无论是 PCB 还是 PSW, 都用于记录一个进程当前的运行状态, 尤其在进程切换时要保存与恢复.
都涉及 CPU 执行上下文
它们都包括与 CPU 执行相关的内容, 例如程序计数器、标志寄存器等.
都与进程调度和切换密切相关
操作系统在进程切换时必须使用它们来保存和恢复执行状态.
不同点
对比维度
进程控制块(PCB)
程序状态字(PSW)
本质类型
操作系统内核中的一个数据结构
CPU 内部的一个特殊寄存器集合
作用范围
表示整个进程的完整信息
表示当前进程的执行现场(CPU层面)的一部分
内容复杂度
非常全面, 包括: 进程标识、状态、CPU现场、内存管理、I/O 等
内容较少, 主要包括程序计数器、状态标志、中断使能位等
存储位置
常驻内存, 由操作系统维护(每个进程一个 PCB)
常驻 CPU 寄存器中, 只保存当前正在运行进程的状态
保存/恢复方式
在进程切换时由操作系统主动保存与加载(包括保存 PSW)
被动保存到 PCB 中或由硬件自动保存到栈中(依平台而异)
可见性
操作系统可随时访问并操作
用户程序通常无法直接访问, 只能通过系统调用或特权指令间接操作
PCB 示例结构(进程级信息):
PSW 示例结构(CPU级状态):
总结
PCB 是进程的“全息身份证”, 保存进程在整个系统中的各种状态信息;而 PSW 是 CPU 执行当前进程的“运行现场”, 属于 PCB 中记录的一部分.
PCB+程序块+数据块+内核栈 → 进程的完整映像(在内存中的物理实体)
进程的物理实体和支持进程运行的环境合成进程上下文.
notion image

进程API

  • fork(): 复制当前进程作为子进程, 从调用fork()的行开始执行. fork()在父进程中返回子进程的进程号, 在子进程中返回0, 可以在代码逻辑中用于区分父子进程.
  • exec() / execve(): 移交进程控制权到指定的程序, 覆盖当前进程控制流. 成功的exec()调用永远不会返回.
  • wait(): 阻塞父进程, 等待子进程返回, 用于同步父子进程的执行.
  • kill(): 发送信号要求杀死/睡眠进程.
  • ps/pstree: 查看当前系统进程
  • top: 查看占用资源最多的进程(自身开销很大)

底层机制: 受限直接执行

目标: 高性能地虚拟化CPU, 同时保留OS对CPU/关键行为/访问权限的控制权
方案: 受限(控制权)直接(高性能)执行

直接执行

直接进行调用-返回, 移交控制权
直接进行调用-返回, 移交控制权
这样没有多余步骤, 性能较好, 但是在程序执行期间OS失去对机器的控制, 且无法让程序停下来进行进程的上下文切换

受限

两个模式(权限等级): 用户模式/内核模式(特权模式)
用户模式下不能发出I/O请求, 不能执行受限指令; 内核模式下可以.
用户程序通过执行trap指令跳转(陷入)到内核并提升特权等级, 从而调用特权功能(系统调用). 完成后通过RET等指令返回到用户程序, 同时将特权等级降回用户模式.
trap期间用户程序(进程)的信息被压入内核栈保存, 返回时再出栈还原.
💡
系统调用的代码遵守系统调用的ABI, 将数据放入约定的位置之后执行trap指令跳转到系统调用例程. 返回时trap例程也需要将返回值放到周知的约定处.
C库中进行系统调用的部分是用汇编手工编码的!
系统调用流程
系统调用流程
用户程序不能拥有跳转到指定例程的权限(进程隔离), 跳转到正确的trap例程需要参考陷阱表(trap table). 陷阱表在机器启动时、特权模式下设置(systemd?).
notion image

进程间切换(上下文切换)

💡
问题: 单个CPU同时只能执行一个进程, 如何在运行其他进程的同时保持操作系统的运行以保证OS的控制?
策略:
  1. 信任策略(不安全): 用户程序主动进行系统调用(如yield), 定期放弃CPU, 将控制权交还到OS;
  1. 非协作方式(安全): 操作系统利用时钟中断定期中断用户程序, 抢占CPU控制权以进行控制.
    1. 💡
      *补充: 中断控制流程(了解即可)
      检查中断时捕获中断信号(用户自愿中断/时钟中断)
      检查中断时捕获中断信号(用户自愿中断/时钟中断)
      中断过程的控制流
      中断过程的控制流
      notion image
机制(需要特权):
  1. 进入上下文切换的系统调用例程
  1. 通过系统调用, 保存当前执行的进程信息(如PCB或特定寄存器的值), 压入内核栈;
  1. 通过系统调用, 为即将执行的进程恢复/准备信息(如PCB或特定寄存器的值), 切换内核栈.
  1. 从系统调用(中断例程)返回, 则返回到切换后的进程开始执行, 上下文切换完毕,
notion image
💡
中断是激活操作系统控制的唯一方式. 狭义的中断: 来源于处理器外, 与当前运行的指令无关; 异常: 运行指令引起的中断事件(地址异常, 算数异常, 越权指令等); 系统异常: 主动执行Trap指令触发系统调用引起的中断.
notion image
中断响应过程
  • 外部设备向中断控制器发出中断请求IRQ, 在中断寄存器中设置已发生的中断
  • 系统发现中断源, 提出中断请求
    • 发现中断寄存器中记录的中断
    • 决定这些中断是否应该屏蔽
    • 当有多个要响应的中断源时, 根据规定的优先级选择一个
  • 中断当前程序的执行, 保存当前程序的PSW/PC到核心栈
  • 查询中断向量表, 转向操作系统的中断处理程序

上层策略: 进程调度

💡
调度的层次:
  • 高级调度决定进程能否被创建
  • 中级调度决定进程能否加入调度队列(不能则为挂起/阻塞)
  • 低级调度决定接下来调度哪个进程

调度指标

  • 周转时间(高效性) = 任务完成时刻 - 任务到达时刻
  • 响应时间(交互性) = 任务开始运行时刻 - 任务到达时刻
  • 调度的公平性 → 各进程受调度的机会均等
  • 调度的优先级 → 根据重要性分配不同的调度机会

FIFO(First In, First Out)

先来先服务(First In, First Out), 按进程到达顺序依次调度, 简单但有护航效应(convoy effect), 短时任务排在长时任务之后等待很久.

SJF/SPN(Shortest Job First)

最短作业优先(Shortest Job First), 优先调度运行时间最短的进程, 可最小化平均等待时间, 但需准确预测运行时间(做不到), 且SJF为非抢占式调度, 在短时进程在长时进程之后到达的情况下护航效应依然存在(但不会饿死, 因为是非抢占式调度).

STCF/SRTF(Shortest Time to Completion First/Shortest Remaining Time First)

最短剩余时间优先(Shortest Time to Completion First), SJF 的抢占式版本, 当前执行的进程如果剩余时间更长会被新进程抢占. 问题在于长时进程会饿死(不断被短时进程插队).

轮转(Round-Robin, RR)

又称时间片轮转, 每个进程按固定时间片周期性轮流执行, 公平简单, 为简单的时分共享系统, 但会频繁产生切换开销, 损失性能. 所以需要权衡时间片的长度, 平衡切换开销与响应速度.
💡
考虑到Cache, TLB, 分支预测和其他片上硬件的状态, 切换开销是很高昂的.
时间片越长, 切换开销平摊下来越小;
时间片越短, 响应速度越快.
进程轮流到达: 维护一个就绪队列, 当获得调度时从就绪队列中移除, 当取消调度或者新建进程进入就绪态时加入到就绪队列的队尾, 然后在队列内轮转.
轮转实例
轮转实例
💡
考虑I/O: 当进程因等待I/O而阻塞时应该调入其他进程, 以免空转浪费CPU资源.

多级反馈队列(SJF和轮转的混合)

💡
问题: 无法预知每个进程的运行时长, 在保证一定周转时间的情况下降低响应时间
使用多个优先级队列, 根据进程行为动态评估/调整其优先级, 兼顾响应性和吞吐量, 能防止进程饿死.
规则:
  1. 若A的优先级>B的优先级, 则运行A不运行B(高级优先);
  1. 如果A的优先级=B的优先级, 则轮转运行A和B(同级轮转);
  1. 工作进入系统时放在最高优先级队列(接受评估);
  1. 一旦工作用完了在某一层的时间配额(时间片)就降低其优先级, 移入低一级队列(对其需要运行的市场的评估值增加, 根据SJF的思想应该靠边站)
    1. 💡
      高优先级被评估为短工作, 低优先级被评估为长工作.
      一个短进程很快就会执行完, 不会在就绪队列中降很多级.
  1. 经过一段时间S, 就将系统中所有工作重新加入最高优先级队列(防止长时进程饿死)
    1. 💡
      S的值需要考量(性能与公平的平衡).
优化: 优先级/队列的个数, 每一层时间片的长度(实时优先级层次: 每层时间片长度相同/分时优先级层次: 高优先级短降低短任务响应时间, 低优先级长降低长任务周转时间), 使用数学公式调整优先级, 将最高优先级留给操作系统, 让用户建议高优先级的任务, etc.

比例份额调度(彩票调度)

为进程分配“彩票”数量作为调度权重, 调度时随机抽奖, 概率性公平, 易实现优先级和资源控制.
💡
问题在于权重的合理分配.
“彩票转让” “彩票通胀”
💡
随机性: 没有边角情况, 不需要维护状态, 决策迅速.
当工作长度足够长, 时间片足够多时才能体现出公平性.

步长调度(Stride Scheduling)

步长调度是一种确定性的比例份额调度算法, 用于实现类似“彩票调度”的公平资源分配, 但不依赖随机性.
  • 每个进程分配一个步长(stride), 步长值与其份额(tickets, 即彩票张数)成反比.
    • 步长 = 大常数 / tickets
  • 每个进程还维护一个通行值(pass value) 初始为0.
  • 每次调度选择通行值最小的进程运行, 并将该进程的通行值增加其步长.
示例:
进程
tickets
stride
pass
P1
100
100
0
P2
200
50
0
调度顺序将趋近于:
  • P2 → P1 → P2 → P2 → P1 → P2 → ...
P2 以较小步长获得更多执行机会, 实现了近似2:1的调度比例.
特点:
  • 比随机的彩票调度更可控、更可预测;
  • 适用于实时或对调度公平性有严格要求的场景;
  • 实现简单, 仅需维护整数值.
💡
彩票调度需要再多个周期累积后在概率上实现分配的比例, 而步长调度算法可以做到每一个调度周期内严格符合该比例.
彩票调度的优势在于不需要维护全局状态pass, 当新进程加入时可以立即与其他进程同等调度, 而步长调度则不便设置新进程的pass(若设为0则该进程独占CPU, 不妥)

HRRF(Highest Response Ratio First, 最高响应比优先)

是一种非抢占式的作业调度算法, 用于提高系统对短作业的响应速度, 同时避免长期等待的作业饥饿现象.
基本思想: 每次选择“响应比最高”的作业进入运行状态.
💡
响应比(Response Ratio, R)计算公式:
R =(等待时间 + 服务时间) / 服务时间 = 1 + 等待时间 / 服务时间
响应比的本质: 任务越短, 对于等待的容忍度越小, 越长则反之. 选择响应比大的在优先调度短任务的同时尝试兼顾长任务不让其饿死.
每次调度时从就绪队列中选取响应比最大的作业运行, 响应比越高, 作业越优先被调度.
等待时间长的作业响应比会逐渐变高, 能够防止饥饿.
算法特点
特点
说明
非抢占式
一旦作业开始执行, 直到结束不被打断
动态优先级
响应比随时间变化, 优先级自动调整
平衡公平性和效率
兼顾短作业优先防止长作业饥饿
实现复杂度中等
需要实时计算等待时间和响应比
举例
假设有三个作业到达时间均为 0, 它们的服务时间如下:
作业
服务时间
等待时间
响应比计算
响应比
A
3
0
(0+3)/3
1.00
B
6
0
(0+6)/6
1.00
C
1
0
(0+1)/1
1.00
初始时三个作业响应比一样, 任选一个. 假设先执行 C:
  • 执行后, A 和 B 的等待时间分别为 1.
  • 再次计算响应比:
作业
服务时间
等待时间
响应比
A
3
1
(1+3)/3 = 1.33
B
6
1
(1+6)/6 = 1.17
选择 A(响应比高), 以此类推.
适用场景: 批处理系统, 作业调度器, 希望兼顾响应速度和公平性的场合.

多处理器调度

在多个CPU间调度进程, 包括全局队列调度(简单但易冲突)和每核独立调度(减少开销但需负载均衡).

虚拟化内存——虚存

需求

💡
操作系统为每个进程提供一个假象: 它拥有大量的连续私有内存, 用户程序的每个地址都是虚拟的.
早期系统: 单道程序, 操作系统以一个库的形式存在, 没有复杂功能.
多道程序系统: 多个进程共享机器, 由操作系统负责切换进程.
时分共享系统: 注重交互性, 以各用户时间片轮转的形式保证系统对多个用户任务的及时响应.
💡
在进程切换时若将原进程的信息写入磁盘保存则效率太低, 只能让原进程信息留在内存中.
但是这样做引入了新的问题: 我们不希望一个进程能够修改另一个进程的数据, 故需要进程间的内存隔离机制. 所以我们引入了地址空间.

地址空间

物理内存的抽象, 是运行中的程序(进程)看到的内存. 一个进程的地址空间中的内容大致包括代码, 堆, 栈等.
notion image
虚拟内存的特性: 透明(进程不可察觉), 保护(进程隔离), 效率(保证性能).
💡
虚拟内存/存储管理的功能:
  • 地址转换(重定位): 将虚拟地址转换为物理地址;
  • 主存分配: 进程装入主存或撤出时分配或收回主存空间, 维护主存分配表等信息;
  • 主存共享: 多个进程同时驻留在主存, 各自占用一部分/多个进程共享主存的某些区域;
  • 存储保护: 避免同时驻留在主存中的多个进程相互干扰, 软硬件协同保障, 私有信息可读写, 共享信息看情况, 非本进程信息不可读写;
  • 主存扩充: 软硬件协同, 利用对换技术(不用的调出)和虚拟技术(只调入部分)将磁盘作为主存扩充, 只把部分进程或进程的部分内容装入主存.

内存管理API

库调用:
  • malloc(size_t size)
  • calloc(size_t size) → 分配并置零
  • realloc()
  • free(void *ptr)
底层系统调用:
  • brk(): 改变程序分断的位置, 即堆结束的位置. 需要一个参数即新分断的地址来判断是增大还是减小堆的大小.
  • sbrk(): 与brk大致相同, 参数是堆大小的增量.
  • mmap(): 在程序中创建一个匿名区域, 与swap空间相关联/

底层机制:(基于硬件的)地址转换

使用硬件对每次内存访问进行处理, 将指令中的虚拟地址(逻辑地址, 即用户编程使用的地址空间)转换为物理地址(程序执行时使用的地址空间)再执行. 硬件需要被操作系统正确设置.

基址+界限/动态重定位(原始思想)

💡
即单连续存储管理, 一维逻辑地址空间的程序占用主存中的一个固定或可变分区.
动态: 在运行时计算物理地址, 而不是在代码中静态写死物理地址.
每个CPU需要两个寄存器: 基址寄存器和界限寄存器, 指示进程地址空间在物理内存中的起始位置和大小. 这连个寄存器由操作系统设置.
虚拟地址和基址相加得到物理地址, 与界限比较检查是否越界访问非法内存区域.
该方案对硬件的要求:
notion image
问题: 空闲内存列表管理, 进程创建时分配内存, 进程终止时回收内存, 上下文切换时保存/恢复机制寄存器和界限寄存器(将其加入进程控制块PCB), 进程物理地址迁移(停止-复制-恢复), 异常处理机制(处理越界等情况)
运行实例:
notion image
notion image

分段式(进步)

💡
问题: 堆和栈之间有大片未分配空白内存, 浪费了内存空间(内部碎片). 剩余的物理内存可能无法提供大段连续的区域.
分段: 泛化的基址/界限. 为一个地址空间不止分配一对基址/界限寄存器, 而是给地址空间内的每个逻辑段一对基址/界限寄存器, 再维护一个段表来记录各个段的基址和界限. 这样只占用了已使用的内存区域, 解放了堆栈之间的空白空间.
不同进程各自拥有段表.
notion image
💡
段不等长, 空闲空间管理比较麻烦, 会产生外部碎片.
访问超出段界限的非法地址则触发段错误(Segmentation Fault). 这个术语在当下不支持分段的机器上仍然保留, 表示访问了非法内存.
具体实现: 可以用虚拟地址的高位代表段号, 剩下的位表示段内偏移, 每个段内部都可以从0开始编址, 段内的地址是连续的.
另外, 栈所在段由高地址向低地址反向增长, 需要专门记录.
进程之间可以共享内存段(需要有一定权限制约 → 段寄存器/段表中加入权限位/保护位)
对操作系统的要求: 上下文切换时保存/恢复段寄存器, 管理空闲空间(紧凑物理内存/空闲列表管理算法如伙伴算法)
💡
“段覆盖技术”
在引入虚拟内存之前, 计算机内存管理方式相对简单, 程序通常需要一次性全部装入内存才能运行(“一次性”限制), 而且一旦装入就会一直驻留在内存中直到运行结束(“驻留性”限制). 当程序变得越来越大, 超过了物理内存的实际大小时, 就出现了问题.
为了解决这个问题, 出现了覆盖技术(Overlay):
  • 基本原理: 覆盖技术的核心思想是, 一个程序可以被划分为若干个段(或模块), 这些段在程序执行的不同阶段可以轮流占用内存中的同一块区域. 例如, 一个程序可能有一个主程序段和多个子功能段. 在执行某个子功能时, 将对应的子功能段调入内存, 执行完毕后可以将其覆盖掉, 再调入下一个需要的子功能段.
  • “打破一次性”: 覆盖技术打破了程序必须一次性全部装入内存的限制, 允许程序只将当前执行所需的部分放入内存.
  • 手动管理: 覆盖技术通常需要程序员手动将程序划分为覆盖块, 并指定它们的覆盖关系. 这增加了编程的复杂性.
与之相对, 段式虚拟存储管理由操作系统负责段的调入和调出, 对用户透明.
💡
notion image
*补充: “单连续”和”多连续”逻辑地址空间(初学者可以跳过)
在理解“单连续逻辑地址空间”和“多连续地址空间”以及“段式逻辑地址的二维性”时, 我们需要从操作系统对内存的抽象和管理方式来看.
单连续逻辑地址空间
定义:
单连续逻辑地址空间(Single Contiguous Logical Address Space)是指一个程序或进程在运行时, 认为自己拥有一个从0开始, 连续且完整(物理上完全连续)的地址空间. 在这个模型下, 程序内部的所有代码、数据、堆栈等都被看作是存储在这个单一的、线性排列的地址空间中.
特点:
  • 线性结构: 地址从0递增, 形成一个一维的线性序列.
  • 整体性: 程序认为其所有部分都紧密地排列在这个空间内.
  • 简单性(对程序员): 程序员在编写程序时, 可以假设所有内存访问都是相对于这个0地址的偏移量.
  • 物理实现可能不连续: 尽管逻辑上是连续的, 但在实际的物理内存中, 这些数据可能被分散存储在不连续的物理块中, 由操作系统通过内存管理单元(MMU)进行地址映射和管理.
例子:
在早期没有虚拟内存的系统中, 或者在某些简化模型中, 一个进程的内存就是一块连续的物理内存. 即使在现代操作系统中, 每个进程的虚拟地址空间也通常被抽象为一个巨大的单连续地址空间(例如, 32位系统通常是4GB, 64位系统远超此值), 程序在这个虚拟空间内进行寻址.
多连续地址空间
定义:
多连续地址空间(Multiple Contiguous Logical Address Spaces), 有时也被称为分段(Segmentation), 是指一个程序或进程的逻辑地址空间被划分为多个独立的、逻辑上连续的段(Segment). 每个段都有其独立的起始地址和长度, 并且这些段在逻辑上可以是相互独立的, 不要求它们在逻辑地址空间中连续(这里放一段, 那里放一段).
特点:
  • 结构化: 程序可以根据其逻辑结构(如代码段、数据段、堆栈段、子程序段等)被划分为不同的段.
  • 独立性: 每个段都可以被独立地加载、保护和管理. 例如, 代码段可以只读, 数据段可读写.
  • 更灵活的分配: 各个段可以独立地分配到物理内存中的不同位置, 甚至可以不全部加载到内存中.
  • 对程序员可见: 程序员在编写程序时, 需要显式地指定要访问哪个“段”以及该段内的“偏移量”.
例子:
传统的分段内存管理就是典型的多连续地址空间. 一个程序可能有:
  • 代码段: 存放程序的指令.
  • 数据段: 存放全局变量和静态变量.
  • 堆段: 动态分配内存区域.
  • 栈段: 存放函数调用信息和局部变量. 这些段在逻辑上是独立的, 可能在程序员看来, 它们是独立的内存块, 而不是一个统一的巨大线性空间.
为什么说段式逻辑地址是二维的?
这是因为一个段式逻辑地址通常由两部分组成:
  1. 段号(Segment Number): 指定要访问哪个逻辑段.
  1. 段内偏移量(Offset within Segment): 指定在该段内部的哪个位置.
因此, 一个完整的段式逻辑地址可以表示为: [段号, 段内偏移量].
二维性体现:
地址转换过程: 当CPU要访问一个段式逻辑地址时, 它首先需要根据段号去查找段表(Segment Table). 段表中存放着每个段的基地址(即该段在物理内存中的起始地址)和长度(限长). 然后, 将段的基地址与段内偏移量相加, 才能得到最终的物理地址.
  • 第一维(段号): 用于选择程序中的哪个逻辑单元(代码、数据、堆栈等).
  • 第二维(段内偏移量): 用于定位该逻辑单元内部的具体位置.
对比一维地址:
相比之下, 一个简单的线性地址(如在纯分页系统中)就是一维的, 它只有一个地址值, 这个值直接代表了从0开始的某个偏移量. 你不需要先选择一个“段”再在其内部进行偏移.

空闲空间管理算法(缝缝补补)

💡
用于处理分段式/可变分区内存分配带来的外部碎片(不连续的空闲空间).
头块(Header): 维护空闲列表(形式类似链表, 维护一个next指针)
notion image
基本策略:
  1. 最优匹配/最优适应分配: 遍历整个空闲列表, 找到最小的能满足请求大小的块并分配(遍历开销大);
  1. 首次匹配/最先适应分配: 遍历, 分配首个找到的满足要求大小的块(优点速度快, 缺点碎片多);
  1. 下次匹配/循环最先适应分配: 多维护一个指针, 从上次遍历结束的位置开始遍历找到并分配首个满足要求的块, 遍历到末尾则回到开头, 将对空闲空间的查找操作扩散到整个列表中去, 避免频繁分割开头;
  1. 最差适应分配: 总是选择最大的内存块分割后分配(无实际意义);
  1. 分离空闲列表: 使用专门的列表来管理常用大小的内存块(这种内存的分配和释放都很快), 其他照旧管理;
  1. (二分)伙伴系统: 分配2的整数次幂大小的空间, 形成一个伙伴树, 树中的兄弟节点互为伙伴. 若节点大小大于待分配内存的两倍则递归对半分割为两个兄弟节点, 若兄弟节点均空闲则向上递归合并为更大的内存块.

分页式(最终解)

不等长分段必然导致外部碎片 → 将内存划分为等长的单元(page, 页), 将物理内存看成是定长槽块的序列(page frame, 页框). 每一个页框恰好容纳一个页大小的数据.
💡
这样只会有内部碎片, 不会有外部碎片. 只要页大小合适则内部碎片就可以得到合理控制.
如此, 虚拟内存的高位即为虚拟页号, 剩下地位为页内偏移.
notion image
地址转换中, 使用(每个进程专有的)页表来存储(进程的)虚拟页号到物理页号的映射, 再将页内偏移与物理页号拼起来, 就得到物理地址.
页表实例:
虚拟页号(VPN)
页框号(PFN)
有效位(Valid)
修改位(Dirty)
访问位(Accessed)
权限(Permissions)
0
5
1
0
1
R/W
1
3
1
1
1
R
2
0
3
8
1
0
0
R/W
页表字段说明:
  • VPN: 虚拟页号(由虚拟地址计算得出)
  • PFN: 页框号(物理内存中对应的页帧)
  • 有效位: 标记该页是否有效(即是否已映射)
  • 修改位: 标记该页是否被写过
  • 访问位: 标记该页是否被访问过(用于页面置换)
  • 权限: 读写权限控制
notion image
主存中的进程表中登记了所有进程的页表的起始地址和长度. 硬件中有一组页表控制寄存器, 进程被调度时其页表起始地址和长度被送入其中, 切换时改写.
💡
页大小的设计权衡:
  • 大页 → 命中率高, 缺页异常少, I/O效率高, 内部碎片较多, 页数更少页表更小;
  • 小页 → 命中率更低, 缺页异常多, I/O效率更低, 内部碎片少, 页数多页表更大.
notion image
页大小达到P时退化为固定分区;
主存内可驻留页面数达到N时代表页面全装载, 不会发生SWAP/缺页异常.
💡
*补充: 内部碎片问题与小对象支持
内核需要频繁创建小对象, 例如进程描述符, 文件句柄等. 如果每次都分配完整的内存页来存放, 而只使用其中的一小部分, 就会造成大量内部碎片.
Linux的解决方案: 基于伙伴系统的Slab(厚块)分配器, 核心思想是对象缓存, 为经常使用的小对象建立缓存, 小对象的申请和释放都通过slab分配器管理, 仅当缓存满时才向伙伴系统申请更多空间.
对象管理局部化, 尽可能少与伙伴系统打交道.
主要组成部分:
  • 对象(Object): 内核需要分配和管理的数据结构实例, 例如一个 task_struct.
  • Slab(块): 由一个或多个连续的物理内存页组成, 这些页被划分为许多大小相同的(比单个页更小的)对象(用于存放数据结构实例). 一个Slab可以处于以下三种状态:
    • 满(Full): Slab中的所有对象都在使用中.
    • 部分满(Partial): Slab中有部分对象在使用, 部分对象是空闲的.
    • 空(Empty): Slab中的所有对象都是空闲的.
  • Slab Cache(缓存): 用于管理相同类型或相同大小对象的集合. 每个Slab Cache都会维护一个Slab列表, 根据Slab的状态(满、部分满、空)进行组织.
    • notion image
优势
  1. 减少内存碎片:
      • 内部碎片: 由于Slab将内存页划分为固定大小的对象, 避免了“大材小用”的情况, 从而减少了内存浪费.
      • 外部碎片: 通过重复利用已分配但空闲的对象, 减少了频繁分配和释放导致的内存孔洞.
  1. 提高分配性能:
      • 对象缓存: Slab分配器将已释放但仍处于初始化状态的对象保留在缓存中, 下次需要相同类型的对象时可以直接重用, 省去了分配、初始化和销毁对象的时间.
      • 局部性: 相同类型的对象通常被放在同一个Slab中, 这有助于提高CPU缓存的命中率, 因为访问相关对象时, 它们很可能已经在缓存中了.
  1. 针对特定对象优化: Slab 分配器可以根据不同内核对象的特性(如大小、访问频率)创建专门的缓存, 从而实现更精细的内存管理和性能优化.
示例
当内核需要分配一个特定大小的对象时, Slab分配器会执行以下步骤:
  1. 查找对应大小或类型的Slab Cache.
  1. 优先从该Cache中查找部分满的Slab, 并在其中找到一个空闲对象进行分配.
  1. 如果部分满的Slab中没有空闲对象, 则查找的Slab并从中分配一个对象.
  1. 如果所有Slab都已满, 或者没有空闲Slab, Slab分配器会向底层页分配器(如伙伴系统)申请新的物理内存页, 将其划分为新的Slab, 然后从中分配对象.
当对象被释放时, 它并不会立即返回给底层内存, 而是被标记为空闲并留在Slab中, 以便后续重用.
简而言之, Slab分配器就像一个“对象池”, 它预先分配了一定的内存空间, 管理着许多相同大小的小内存块, 并根据需要进行高效的分配和回收, 从而优化了内核的内存使用和性能.
💡
分段是逻辑划分策略, 根据源程序的逻辑单元(如代码段, 数据段, 堆栈段等)来划分, 长度可以根据需要来规定, 可以从任意地址处起始,(段号, 段内偏移)组成二维结构(因为还要根据段号查询段基址, 再加偏移量才能得到物理地址);
分页是物理划分策略, 与源程序逻辑结构无关, 由系统唯一确定, 只能从页大小的整数倍地址开始.(页号, 页内偏移)经过装配后变成一维地址(都是物理地址的成分).
💡
问题1: 地址转换机制使得每次内存引用都至少需要两次访存, 速度太慢;
问题2: 页表用于管理单个进程的地址转换, 若为每个进程都存储一个完整的页表则存储开销过大.

快表(TLB) → 解决问题1(速度太慢)

地址转换缓存, 大大提高效率, 使得虚拟内存成为可能.
快表TLB: 存储在快速的小存储器中, 存储部分页表项的信息. 若常用的页表项的内容绝大多数时候能在TLB中获取则能大大提升速度. 地址转换时先查TLB, 若TLB命中(hit)则直接返回, 若TLB缺失(miss)则再查页表并将缺失项载入TLB再重试.
💡
性能提升大小取决于TLB命中率/缺失率.
命中 → 大大缩短(1次TLB访问)
缺失 → 雪上加霜(1次TLB访问+1次页表访问+1次TLB访问)
TLB实例:
虚拟页号(VPN)
页框号(PFN)
有效位(Valid)
权限(Permissions)
最近使用时间(LRU)
1
3
1
R
5
3
8
1
R/W
3
0
5
1
R/W
1
📌 TLB 字段说明:
  • 通常是页表的缓存, 只缓存部分映射
  • LRU 表示最近使用时间或顺序, 用于替换策略
TLB缺失用硬件或软件处理均可, CISC倾向于设计一个指令来处理, RISC倾向于中断并调用trap例程来处理.
💡
处理TLB缺失的代码必须永久保存在TLB中, 否则会引起无限递归的TLB缺失.
另一种解决方法: 将TLB未命中处理程序等必要的程序直接放在物理内存中, 不经过地址转换.
notion image
考虑到进程间切换, 需要清空TLB或加上地址空间标识符(ASID)来避免混淆不同进程的地址映射.
💡
若频繁切换进程则简单清空TLB会引起大量TLB缺失.

多级页表 → 解决问题2(页表太大)

引入页表的表 →(n级)页目录(Page Directory)
💡
这种间接管理形式与inode中n级间接指针相似.
以页为单位来存储页表, 从而用树的形式组织页的存储. 优势在于紧凑且支持稀疏的地址空间.
若整页的页表/页目录都没有有效项, 则干脆不分配子表/子目录以节省空间.
notion image
notion image
💡
时间-空间折中: 多级页表带来了更小的内存开销, 但是TLB缺失时需要跨多级页目录查询, 时间开销更大.
控制流(考虑TLB):
notion image
涉及多级页表时的虚拟地址字段分割:
notion image

反向页表IPT

💡
面向物理页框而非虚拟页设置映射.
反向页表: 极端的空间节省, 将物理页框号映射到虚拟页号, 这样就只用维护一个页表(表项索引为物理页框号, 反向页表项与物理页框一一对应, 页表大小与物理地址空间而非虚拟地址空间成正比), 可以用于地址管理单元MMU等结构.
反向页表的表项:
  • 该页框中数据在对应进程中的虚拟页号;
  • 进程标识符, 即使用该页的进程号;
  • 标志位(有效位, 引用位, 脏位, 保护位, 锁定位等);
  • 链指针: 哈希链.
反向页表由物理页框号直接索引, 故不需要物理页框号字段.
地址转换相关过程:
  1. 维护工作: MMU通过哈希表把进程标识和虚页号转换成一个哈希值作为该进程的该页在哈希表中的索引, 而哈希表的表项为一个指向反向页表中某一表项的指针;
  1. 查询工作: MMU遍历哈希链找到所需进程页在反向页表中的表项, 该项的索引就是页框号, 通过拼接页内偏移即可生成物理地址;
  1. 若遍历整个反置页表中未能找到匹配页表项, 说明该页不在内存, 产生缺页中断, 请求操作系统调入.
notion image

段页式(分段和分页的混合策略)

  • 段式存储管理可以基于页式存储管理实现;
  • 每一段不必占据连续的存储空间, 可存放在不连续的主存页框中;
  • 能够扩充为段页式虚拟存储管理, 装入部分段, 或者装入段中部分页面;
段页式中段表项为页表的基址和长度, 页表中为标准页表项.
notion image
notion image

高速缓存Cache

💡
计组知识, 用于提升访问主存内容的速度, 属于外挂, 与地址转换不直接相关, 此处不做赘述.

Swap机制

💡
将内存视为磁盘的缓存, 随用随调入, 从而能够运行内存要求比内存更大的任务, 超越物理内存限制.
页踢出/调入物理内存的过程和机制对进程透明, 不可察觉.
底层思想: 程序的时间/空间局部性.
swap空间: 是磁盘上的一块空间, 用于在内存不足时临时保存被换出的页面, 相当于虚拟内存的后备存储. 页面从物理内存换出时会被写入swap区域.
页表中, 每个页表项中设置一个装入位, 用于指示该页是否当前存在于物理内存中. 如果存在位为 0, 说明该页在swap空间中或根本尚未装入(还在磁盘中).
缺页异常(Page Fault): 当程序访问的页不在物理内存中(即装入位为0), 就会触发缺页异常. 操作系统会从swap空间(或其他来源如磁盘)将该页读入内存, 并更新页表, 然后重试.
页替换策略: 当在物理内存已满的情况下发生缺页异常时, 必须选择一个已加载的页将其从物理内存中踢出, 即触发“页替换策略”.
💡
不一定要等完全满了再踢出, 比如可以设置高低水位线: 页数达到高水位线就开始踢出, 直到降至低水位线.
notion image
可能的几种流程:
  1. 查TLB命中 → 返回
  1. 查TLB未命中 → 查页表装入 → 更新TLB → 重试 → 查TLB命中
  1. 查TLB未命中 → 查页表未装入 → 访问磁盘取回页 → 更新页表和TLB → 重试 → 查TLB命中

上层策略: 全局页替换策略(页面调度)

最优替换策略(OPT, 理想情况)

  • 替换将来最久不会被访问的页面;
  • 理论上页面置换最少, 但需要预知未来访问序列, 实际上只能模拟不能实现;
  • 实际中不可实现, 用于评估其他算法性能.
notion image

先进先出(FIFO)

  • 替换最早进入内存的页面;
  • 简单但容易产生“Belady 异常”——增加内存反而页错误更多;
  • 用队列实现, 每次插入新页时, 从队首换出.
notion image
💡
notion image
notion image
💡
另一个不好的现象: 抖动.
页在快要被调用时被踢出, 马上又被调入, 反复出入内存, 导致系统在换入换出页方面的开销增大, 性能下降.

随机替换(避免边界)

  • 随机选择一个页面替换;
  • 简单易实现, 避免特定访问模式导致最坏情况;
  • 平均性能不稳定, 完全看运气, 但在某些场景下表现良好.
notion image

最不常用(LFU)

💡
替换最近一段时间内访问次数最少的页面.
对最优策略的模拟性比LRU更佳.
  • 给每个页设置一个计数器;
  • 设置一个中断时间间隔, 每次发生中断就将所有页的计数器清零;
  • 每访问页一次就给计数器+1;
  • 踢出页时选择计数器值最小的页.

最近最少用(LRU)

💡
替换最长时间未被访问的页面;
指标: 近期性(时间局部性原则)
常见误区: 不是”近期用得最少”而是”近期最久未被访问”, 指标是使用时刻而不是使用频率, 容易被译名误导!
  • 近似模拟最优策略;
  • 精确实现需要硬件支持(如访问时间戳), 开销较大.
实现: 维护一个队列, 将最近使用的条目提到首位, 需要踢出时淘汰末位条目.
notion image
💡
缺陷: 每次访问TLB都需要更新时间戳和列表, 且在页数较多时要找到绝对最旧的页开销较大.

近似LRU(找差不多最旧的页, 提高效率)

💡
只在处理缺页异常时才转动指针, 其余时间(页命中时)指针在原地不动, 只维护访问位的值.
  • 常用Clock算法模拟LRU;
  • 每个页加一个访问位(Reference Bit), 页被访问时将访问位设为1(说明最近被使用过);
  • 要替换页时类似时钟指针循环扫描, 路过访问位为1的页时将其清零, 找到访问位为0的页(最近未用)就进行替换, 替换后指针指向下一页但是不清零(即: 运行周期是考察访问位-移动指针);
  • 每次指针扫描都从当前指向的页开始检查访问位.
💡
任何周期性清除使用位, 然后根据使用位判断是否替换的方法都可以实现近似LRU.
💡
还有一种模拟实现是设置一个固定的中断时间间隔, 中断时拨动指针并清零访问位, 这样就引入了访问频率的指标.

脏页+写回/写直达

  • 脏页(Dirty Page): 在装入期间被修改过的页.
  • 写回策略: 写时设置脏位, 脏页被换出前写回磁盘以维持数据一致. 提高效率但增加一致性管理复杂度;
  • 写直达: 每次写都直接写入主存或磁盘, 不设置脏位, 开销较大, 适用于不频繁修改的数据.

💡
最简单有效的做法: 买更大的内存!
地址空间随编译阶段的转换
地址空间随编译阶段的转换

上层策略: 局部页面替换策略

💡
思想: 相比全局更关注近期的效率(更短视, 注重当下任务的效率).

局部最佳页面替换算法

需要预知全部的页调用序列(只能模拟不能实现, 类似OPT算法).
  • 设置一个滑动窗口, 假设覆盖时间区间为;
  • 不论发生缺页与否, 在每一次引用页面时都考察一次: 若某页面在覆盖区间内未被引用则将其移出内存, 否则保留.
值决定主存中使用页框数的上限.
运行实例
运行实例

工作集置换算法

💡
任何给定时刻, 进程不久的将来所需主存页框书可以通过考察其过去最近的时间内的主存需求来估计.
模拟最优局部替换策略.
无法预知未来, 基于局部性向过去看.
工作集: 在某一段时间间隔内进程运行所需访问的页面集合. 进程在时刻t-△, 其中是工作集窗口尺寸, 由系统定义, 而工作集中的页面数目称为工作集尺寸.
工作集置换算法是一种基于程序局部性原理的页面置换算法. 它试图识别并维护当前时刻进程所需要的页面集合, 即“工作集”, 以尽量减少缺页中断的发生.
核心概念:
  • 工作集(Working Set, WS): 在某一给定时刻 t, 进程的工作集 是指在时间间隔 内进程所访问的页面的集合. 其中, 是一个被称为工作集窗口(Working Set Window) 的参数, 它定义了时间窗口的大小.
  • 局部性原理(Principle of Locality): 程序在执行过程中, 在一段时间内, 其访问的地址往往集中在某个局部区域. 这包括时间局部性(最近访问过的页面很可能再次被访问)和空间局部性(最近访问过的页面附近的页面很可能被访问). 工作集算法正是基于时间局部性来设计.
执行流程:
工作集置换算法通常需要硬件或软件(操作系统)的支持来记录页面的访问历史, 并且需要定期扫描页表或页框表来更新工作集信息.
  1. 维护页面访问时间戳:
      • 对于内存中的每一个页面, 操作系统会维护一个上次访问时间戳(Last Accessed Time, LAT). 当页面被访问时其对应的LAT会被更新为当前的系统时间 t.
      • 这个时间戳可以是精确的系统时钟值, 或者为了简化实现, 可以使用一个逻辑时钟或计数器, 每次访问页时递增.
  1. 定义工作集窗口 :
      • 操作系统需要预设一个工作集窗口 的大小. 是一个时间单位, 表示我们关注的“最近”时间段. 这个值的选择对算法的性能至关重要.
      • 可以是固定的, 也可以是动态调整的.
  1. 确定当前工作集:
      • 在任意时刻 t, 为了确定进程 P 的工作集 , 操作系统会遍历进程P当前在内存中的所有页面(即其页表项或对应的页框).
      • 对于每个在内存中的页面 , 检查其上次访问时间 .
      • 如果 , 则页面 被认为是工作集的一部分, 因为它在最近 时间内被访问过.
      • 如果 , 则页面 不在当前工作集中, 它是一个“陈旧”的页面.
  1. 页面置换决策(当发生缺页中断时):
      • 当进程 P 发生缺页中断, 需要从磁盘调入一个新页面 时, 如果物理内存已满:
        • 首先, 确定进程 P 的当前工作集. 识别出所有不在工作集中的页面.
        • 选择置换页: 在所有不在当前工作集中的页面中, 选择一个页面作为牺牲品. 通常会选择其中上次访问时间最早的页面进行置换.
        • 如果所有内存中的页面都在当前工作集中, 则通常会选择工作集中的某个页面进行置换(此时算法效果与LRU相似), 但这种情况较少发生, 因为目标是保持工作集在内存中. 在这种极端情况下, 可能需要牺牲工作集中的一个页面(比如LRU策略).
  1. 页面淘汰(Trimming/Aging):
      • 除了在缺页时进行置换, 工作集算法也可以定期进行“页面淘汰”操作.
      • 操作系统会周期性地扫描内存中的所有页面(或某个进程的页面), 将那些长时间不在任何进程工作集中的页面从内存中移除(如果它是一个修改过的脏页, 则先写回磁盘), 以释放物理内存. 这有助于在系统内存不足时提前清理不活跃的页面.
优点:
  • 高度适应程序局部性: 能够有效地识别并保留程序当前最可能需要的页面, 从而显著降低缺页率.
  • 性能优越: 通常能提供比FIFO、LRU等简单算法更好的性能, 尤其是在程序的局部性较强时.
缺点:
  • 实现复杂: 需要维护每个页面的上次访问时间戳, 并在缺页或定期扫描时进行复杂的计算和判断.
  • 的选择困难: 的大小对算法性能有很大影响. 过小可能导致频繁置换, 过大可能导致内存浪费. 选择合适的 值通常需要经验或动态调整机制.
  • 开销较大: 维护时间戳和定期扫描的工作会带来一定的系统开销.
工作集置换算法是一种理论上非常优秀, 但在实际实现中需要权衡开销的页面置换算法. 许多实际的操作系统页面置换策略(如Linux的LRU近似算法)都融合了工作集的概念来优化性能.
示例(页面在活动窗口之外时立刻踢出, 且将所有页框重排(连续排列压实))
示例(页面在活动窗口之外时立刻踢出, 且将所有页框重排(连续排列压实))
 
征服OS的第二座大山-并发为安装在Parallels Desktop上的OpenEuler虚拟机扩容
Loading...
JAY
JAY
Textbooks shouldn’t require a cryptologist to decode -- welcome to readable software engineering.