操作系统概论

操作系统的主要特征

并发性 (Concurrency)

🔍 核心定义

并发性是指两个或两个以上的事件或活动在同一时间间隔内发生。

  • 关键点:“同一时间间隔” ≠ “同一时刻”。它强调的是“看起来同时”,而不是“真正同时”。

📌 操作系统中的体现

  1. 多个 I/O 设备同时工作
    • 你的键盘在输入,打印机在打印,网卡在收发数据。这些设备都在“同时”工作。
  2. I/O 和 CPU 计算同时进行
    • 当 CPU 在计算时,I/O 设备可能在后台传输数据。CPU 不需要等待 I/O 完成,可以去处理其他任务。
  3. 内存中多个程序交替执行
    • 这是最核心的体现。操作系统通过时间片轮转(Time-Slicing)等调度算法,让多个程序“轮流”使用 CPU,从而实现“宏观上的并发”。

🖼️ 并发 vs 并行

这是你 PPT 中提出的关键问题!

特性 并发 (Concurrency) 并行 (Parallelism)
定义 多个任务在同一时间间隔内交替执行。 多个任务在同一时刻真正同时执行。
物理基础 单 CPU 系统即可实现。 需要多核 CPU 或多处理器系统。
效果 “看起来”同时进行。 “真正”同时进行。
类比 一个人在厨房里,一会儿切菜,一会儿炒菜,一会儿洗碗。 三个人在厨房里,一个人切菜,一个人炒菜,一个人洗碗。

一句话总结并行是并发的一种特例。并发是“逻辑上的同时”,并行是“物理上的同时”。

共享性 (Sharing)

🔍 核心定义

共享性指操作系统中的资源(包括硬件资源和软件资源)可被多个并发执行的进程共同使用,而不是被一个进程所独占。

  • 关键点:共享不等于“无限制访问”,它必须在操作系统管理下进行,以保证安全和有序。

📌 资源共享的方式

1️⃣ 透明资源共享 / 同时共享方式

  • 含义:允许多个进程在同一时间段内对资源进行访问,好像每个进程都独占资源一样。
  • 特点
    • 访问的次序对结果无影响。
    • 通常用于可重入只读的资源。
  • 例子
    • CPU:通过时间片轮转,让多个进程“同时”使用 CPU。
    • 主存 (RAM):多个进程的代码和数据可以同时存在于内存中。
    • 磁盘:多个进程可以同时读取磁盘上的不同文件。
    • 打印机:虽然物理上一次只能打印一个任务,但操作系统可以通过“打印队列”实现逻辑上的“同时共享”。

2️⃣ 独占资源共享 / 互斥共享方式

  • 含义:在同一时间段内只允许一个进程访问资源。
  • 特点
    • 这类资源称为临界资源 (Critical Resource)
    • 必须通过互斥机制(如锁、信号量)来保护。
  • 例子
    • 磁带机:一次只能由一个进程控制。
    • 扫描仪:一次只能扫描一份文档。
    • 数据库中的某一行记录:如果两个事务同时修改同一行,会导致数据不一致。

🛠️ 操作系统如何管理共享?

  • 提供显式资源共享机制:如 fork(), semaphore, mutex, lock 等系统调用。
  • 将互斥访问下放给用户决策:程序员需要自己负责加锁和解锁,操作系统提供工具。

异步性 (Asynchrony)

🔍 核心定义

异步性指在多道程序环境中,由于资源有限而进程众多,多数情况下,进程的执行不是一气呵成,而是“走走停停”。

  • 关键点:进程的执行是不可预测的,它的推进速度取决于系统调度、I/O 等待、中断等多种因素。

📌 异步性的表现

  1. 作业到达系统的时间和类型不确定
    • 用户随时可能启动一个新程序。
  2. 操作员发出命令或操作的时间和类型不确定
    • 用户可能随时按下键盘或点击鼠标。
  3. 程序运行发生错误或异常的类型和时刻不确定
    • 程序可能因为除零、内存溢出等原因崩溃。
  4. 中断事件发生的时刻不确定
    • 时钟中断、I/O 中断、硬件故障中断等都是随机发生的。
1
2
3
4
5
6
7
graph TD
A[并发性] --> B[多个任务同时执行]
C[共享性] --> D[资源被多个任务共同使用]
E[异步性] --> F[任务执行“走走停停”]

B & D & F --> G[现代操作系统的核心特征]
G --> H[实现多任务、多用户环境]

多道程序设计

核心思想

多道程序设计是指允许多个程序同时驻留在内存中,并由操作系统统一管理和调度,使它们交替(并发)地使用 CPU 和其他系统资源。

  • 核心目的掩盖 I/O 等待时间,提高 CPU 和系统资源的利用率
  • 终极目标:让昂贵的 CPU 永远不要闲着
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
gantt
title 单道程序 vs 多道程序
dateFormat HH:mm:ss
axisFormat %Ss

section 单道程序
作业A-CPU :crit, a1, 00:00:00, 2s
作业A-I/O :active, a2, after a1, 8s
作业A-CPU :crit, a3, after a2, 2s
作业A-I/O :active, a4, after a3, 8s

section 多道程序
作业A-CPU :crit, b1, 00:00:00, 2s
作业B-CPU :crit, b2, after b1, 2s
作业C-CPU :crit, b3, after b2, 2s
作业A-I/O :active, b4, after b1, 8s
作业B-I/O :active, b5, after b2, 8s
作业C-I/O :active, b6, after b3, 8s

对比:在多道程序中,当作业 A 在等待 I/O 时,CPU 立刻去执行作业 B 和 C。CPU 几乎没有空闲时间,利用率接近 100%!

cpu利用率

CPU利用率 = 1 - p^n

🔍 假设条件

  1. 系统中有 n 个程序 同时在内存中。
  2. 每个程序平均有 p 的概率在等待 I/O 操作
    • 例如,p = 0.8 表示一个程序有 80% 的时间在等磁盘读写、键盘输入等,只有 20% 的时间在真正使用 CPU。
  3. 各个程序的等待操作是相互独立的
    • 这是一个关键假设,意味着一个程序是否在等 I/O,不影响其他程序。

💡 公式推导

  • CPU 空闲的概率:当且仅当所有 n 个程序都在等待 I/O 时,CPU 才会空闲。
  • 因为每个程序等待 I/O 的概率是 p,且它们相互独立,所以所有 n 个程序都等待 I/O 的概率是 p^n
  • 因此,CPU 空闲的概率 = p^n
  • CPU 利用率 = 1 - CPU 空闲的概率 = 1 - p^n

若进程平均花费 80% 的时间等待 I/O,则为了使得 CPU 利用率不低于 80%,应至少有多少道程序在主存中运行?

计算过程

根据公式:

CPU利用率 = 1 - p^n ≥ 0.8

移项得:

p^n ≤ 0.2

代入 p = 0.8

0.8^n ≤ 0.2

两边取对数(以 10 为底或自然对数均可):

n * log(0.8) ≤ log(0.2)

注意:log(0.8) 是负数,所以在除的时候要反转不等号方向

n ≥ log(0.2) / log(0.8)

计算数值:

  • log(0.2) ≈ -0.69897
  • log(0.8) ≈ -0.09691
  • n ≥ (-0.69897) / (-0.09691) ≈ 7.21

因为 n 必须是整数,且要满足 n ≥ 7.21,所以:

n = 8

✅ 最终答案

为了使得 CPU 利用率不低于 80%,应至少有 8 道程序在主存中运行。

是不是同时运行的程序越多越好?

不是!同时运行的程序(道数)并不是越多越好。存在一个最优的“道数”,超过这个值,系统的整体效率反而会下降。

当道数 n 超过某个临界值后,系统性能会急剧下降。主要原因有:

1️⃣ 上下文切换开销 (Context Switching Overhead)

什么是上下文切换?

  • 当操作系统从一个进程切换到另一个进程时,它需要保存当前进程的状态(寄存器、内存映射、程序计数器等),并加载下一个进程的状态。

2️⃣ 内存压力 (Memory Pressure)

  • 每个进程都需要内存:代码段、数据段、堆、栈、页表等。

3️⃣ 资源竞争加剧 (Resource Contention)

  • 锁竞争:多个进程同时访问共享资源(如数据库连接池、文件锁),需要排队等待,增加了延迟。
  • 缓存失效:多个进程的指令和数据交替进入 CPU 缓存,导致缓存命中率降低,CPU 需要更频繁地从内存读取数据。

处理器状态

为什么需要两种处理器状态?

现代计算机是一个多用户、多任务的环境。如果所有程序都能随意执行任何指令,那么一个不小心的 bug 或一个恶意程序就可能:

  • 格式化硬盘。
  • 修改系统时间。
  • 访问其他用户的隐私数据。
  • 导致系统崩溃。

为了避免这种情况,CPU 被设计成有两种工作模式:

  1. 用户态 (User Mode):普通程序运行的状态,只能执行“安全”的指令。
  2. 核心态 (Kernel Mode / Supervisor Mode):操作系统内核运行的状态,可以执行所有指令,包括“危险”的特权指令。

程序状态字 (PSW)

Program Status Word (PSW) 是一个非常重要的寄存器。

  • 定义:PSW 是 CPU 内部的一个特殊寄存器,用于存储当前处理器的各种状态信息。
  • 关键作用:PSW 中有一个标志位(通常是最高位或某一位),用来标识当前 CPU 处于用户态还是核心态
    • PSW[bit] = 0 → 用户态
    • PSW[bit] = 1 → 核心态

这就是 CPU 判断当前是否可以执行特权指令的依据!

当 CPU 执行一条指令时,它会检查 PSW 中的这个标志位:

  • 如果是用户态,并且指令是特权指令,则触发一个异常 (Exception),操作系统会介入处理(通常是终止该程序)。
  • 如果是核心态,则允许执行。

CPU 如何判断当前是否可以执行特权指令?

答案:CPU 通过检查 程序状态字 (PSW) 中的一个特定标志位来判断。

  • 如果该标志位表示当前处于用户态,并且遇到的是特权指令,则 CPU 会触发一个异常(通常是“非法指令”或“特权指令违规”),并将控制权交给操作系统内核。
  • 操作系统内核会根据情况决定是终止该程序,还是进行其他处理。
image-20251116191358137

进程控制和管理

进程定义与属性

进程(Process)是程序在计算机上的一次执行实例,是操作系统进行资源分配、调度和保护的基本单位

为什么要引入“进程”?

1️⃣ 刻画系统的动态性(Dynamic Nature)

  • 问题:程序是静态的代码,无法描述“执行中”的状态。
  • 解决方案:进程是一个动态实体,它有生命周期(创建 → 运行 → 阻塞 → 终止)。
  • 意义:操作系统可以精确跟踪每个任务的当前状态,做出调度决策。

2️⃣ 发挥系统的并发性(Concurrency)

  • 问题:CPU 和 I/O 设备速度不匹配。程序在等待 I/O(如读文件、网络请求)时,CPU 就空闲了。
  • 解决方案:通过进程切换,让 CPU 在等待期间去执行其他任务。
  • 意义:提高了 CPU 利用率和系统吞吐量。

3️⃣ 解决资源共享与隔离的矛盾

  • 问题:多个程序可能需要共享资源(如文件、打印机),但又不能互相干扰。
  • 解决方案
    • 共享性:进程可以通过合法机制(如共享内存、消息队列)共享资源。
    • 独立性/保护性:每个进程拥有独立的地址空间,操作系统通过内存管理单元(MMU)确保 A 进程不能访问 B 进程的内存。
  • 意义:既实现了协作,又保证了安全和稳定。

进程的五大核心属性

属性 含义 举例说明
1. 动态性 进程是动态的,有生命周期(创建 → 运行 → 阻塞 → 终止)。 uvicorn 启动时创建进程,Ctrl+C 终止时销毁进程。
2. 并发性 多个进程可以“同时”运行(宏观并发,微观交替)。 一台服务器同时处理成百上千个用户的 HTTP 请求。
3. 独立性 每个进程有独立的地址空间和资源,互不干扰。 一个 Python 进程崩溃,不会导致另一个 Python 进程退出。
4. 制约性 进程间可能存在同步或互斥关系(如竞争资源、等待结果)。 多个进程写同一个日志文件,需要用文件锁避免内容错乱。
5. 共享性 进程可以通过操作系统提供的机制共享资源(如内存、文件)。 多个 FastAPI worker 进程共享一个 Redis 缓存连接池。

进程状态转换

五态模型

image-20251115204410504

七态模型

image-20251115204451732

七态模型在五态模型的基础上,显式增加了“挂起(Suspend)”的概念

挂起 = 进程被换出到外存(Swap)

  • 目的:当系统内存紧张时,操作系统会将一些暂时不活跃的进程(比如长时间阻塞的进程)从内存移到硬盘上的“交换区(Swap Space)”,以腾出内存给更紧急的任务。

挂起就绪态 (Ready/Suspend)

定义:

进程具备运行条件(即它已经准备好执行),但目前在外存中。只有当它被换入内存后,才能被调度器选中运行。

挂起等待态 (Blocked/Suspend)

定义:

进程正在等待某一个事件发生(如 I/O 完成、用户输入、网络响应),并且目前在外存中。

进程描述和组成

进程映像

进程映像(Process Image)是指进程在内存中的完整内容,包括代码、数据、堆、栈以及内核数据结构(如 PCB)等所有组成部分的集合。

image-20251115210257885

进程上下文

image-20251115210715693

寄存器上下文 (Register Context)存储在 PCB 中 包含:通用寄存器、程序计数器、栈指针、程序状态字

这是进程“灵魂”的一部分——CPU 执行时最直接依赖的状态。

PCB(Process Control Block,进程控制块)

PCB 是操作系统为每个进程创建的一个数据结构,用来记录和刻画该进程的所有状态和相关信息。

1️⃣ 进程标识信息 (Identification Information)

字段 说明
PID (Process ID) 进程的唯一数字标识,如 12345
PPID (Parent PID) 父进程的 PID,用于构建进程树。
UID/GID (User/Group ID) 进程所属用户的 ID 和组 ID,用于权限控制。

🌰 你的例子

1
2
3
import os
print(f"当前进程 PID: {os.getpid()}")
print(f"父进程 PID: {os.getppid()}")
这些值就是从 PCB 中读取的!

2️⃣ 处理器状态信息 (Processor State Information) —— 这就是“寄存器上下文”

这是 PCB 最关键的部分,用于上下文切换

字段 说明
程序计数器 (PC) 下一条要执行的指令地址。
通用寄存器 (AX, BX, CX…) 存放临时计算结果、变量地址等。
程序状态字 (PSW) 包含标志位(零标志 Z、进位标志 C、溢出标志 O 等)、中断允许位、特权级别。
栈指针 (SP) 指向当前函数调用栈的顶部。

关键点:每次进程切换时,操作系统都会将当前 CPU 寄存器的值“倾倒”进 PCB,再从新进程的 PCB “倒回”寄存器。这就是“上下文切换”的核心开销。

3️⃣ 进程调度信息 (Scheduling Information)

字段 说明
进程状态 就绪、运行、阻塞、挂起等。
进程优先级 决定调度顺序。
时间片剩余量 用于时间片轮转调度。
等待事件 如等待键盘输入、网络数据包到达等。

🌰 你的例子: 在 FastAPI 中,当一个请求在 await httpx.get(...) 时,其对应协程/线程的状态会被标记为“阻塞”,并被放入等待队列。这就是 PCB 中“进程状态”字段的作用。

4️⃣ 内存管理信息 (Memory Management Information)

字段 说明
页表基址 / 段表指针 用于虚拟内存到物理内存的地址转换。
内存分配情况 代码段、数据段、堆、栈的起始地址和大小。

💡 关键点:确保进程访问的是自己的内存空间,实现“内存保护”。

5️⃣ I/O 状态信息 (I/O Status Information)

字段 说明
打开的文件列表 文件描述符(fd)、文件指针、访问模式等。
分配的 I/O 设备 如打印机、网卡等。

🌰 你的例子: 当你在 Python 中 f = open("log.txt", "a") 时,操作系统会在 PCB 的“打开文件列表”中添加一条记录,记录这个文件句柄 f 对应的 fd。

6️⃣ 记账信息 (Accounting Information)

字段 说明
CPU 使用时间 进程已使用的 CPU 时间总和。
累计运行时间 从创建到现在的总时间。
最大内存使用量 历史峰值。

📊 用途:用于性能监控、计费、调试等。

进程队列

链接方式

image-20251115212223846

索引方式

image-20251115212241091

进程切换和处理器状态转换

image-20251115212521578

模式切换 vs. 进程切换

  1. 模式切换 (Mode Switch)

定义:CPU 在“用户态(User Mode)”和“核心态(Kernel Mode)”之间的切换。 触发方式:由中断(Interrupt)或系统调用(System Call) 引起。 目的:让操作系统获得控制权,执行特权指令(如访问硬件、修改内存映射)。

  1. 进程切换 (Process Switch / Context Switch)

定义:操作系统暂停当前正在运行的进程,保存其状态,并加载另一个进程的状态,使其开始运行。 触发方式:通常发生在核心态下,由中断或系统调用引发。 目的:实现多任务并发,公平分配 CPU 时间。

image-20251115215300054

当进程开始运行时,操作系统如何重新获得控制?

果进程一直在运行,操作系统就永远没机会调度其他进程了,系统就会卡死。

答案:中断 (Interrupt) 是关键!

  • 什么是中断? 中断就像一个“紧急电话”,它能打断 CPU 当前正在执行的程序,强制 CPU 去处理一个更高优先级的事情——通常是操作系统内核。
    • 硬件中断:由外部设备触发,比如键盘敲击、鼠标移动、网络数据包到达、定时器到期。
    • 软件中断/异常:由程序自身触发,比如除零错误、访问非法内存地址、或者程序主动发起的系统调用(如 open(), read())。

进程需要保存哪些状态?

当操作系统获得控制权后,它必须把当前正在运行的进程(比如进程0)的“工作状态”完整地记录下来,以便将来能恢复执行。这个过程叫做“保存现场 (Save Context)”。

需要保存哪些状态?

这些状态主要存储在一个叫做 PCB (Process Control Block, 进程控制块) 的数据结构里。PCB 就像是进程的“身份证 + 工作日志 + 资源清单”。

如何选择下一个待执行的进程/线程?

当操作系统保存完当前进程的状态后,它需要决定“接下来该让谁干活”。这个决策过程叫做“进程调度 (Process Scheduling)”。

如何选择?

这取决于操作系统的调度算法 (Scheduling Algorithm)

线程

为什么需要线程?—— 引入线程的动机

❓ 问题:进程模型有什么不足?

  1. 切换开销大:进程切换需要保存/恢复整个内存空间(代码、数据、堆、栈)和 PCB,开销大。
  2. 通信困难:进程间通信(IPC)需要管道、消息队列、共享内存等复杂机制,效率低。
  3. 不适合细粒度并发:比如一个 Web 服务器,每个请求都创建一个进程,成本太高。

✅ 解决方案:引入线程!

线程是进程内的一个执行单元,是 CPU 调度和分派的基本单位。

  • 同一个进程内的所有线程
    • 共享:代码段、数据段、堆、打开的文件等进程资源
    • 私有:各自的寄存器上下文

💡 核心价值实现进程内部的并发,降低切换和通信开销

什么是线程?—— 核心定义

线程(Thread)是进程中一个可并发执行的控制流,它拥有自己独立的栈和寄存器状态,但与其他线程共享进程的地址空间和资源。

线程如何工作?—— 线程的生命周期与切换

  1. 线程的生命周期状态

和进程类似,线程也有状态:新建 → 就绪 → 运行 → 阻塞 → 终止

  1. 线程切换(Thread Switching)
  • 开销远小于进程切换!因为不需要切换地址空间(页表),只需要保存/恢复寄存器上下文和栈指针
  • 切换由线程调度器(在 OS 内核或用户态库中)管理。
  1. 线程的实现方式
类型 说明 例子
用户级线程 (User-Level Threads) 由用户态线程库(如 Java Green Threads)管理,内核 unaware Python 的 greenlet(非标准)
内核级线程 (Kernel-Level Threads) 由操作系统内核直接管理,每个线程对应一个内核调度实体 Python 的 threading 模块
混合模式 用户级线程映射到少量内核线程 Go 的 Goroutine

💡 Python 的 threading 是内核级线程,但受 GIL 限制,无法真正并行执行 Python 字节码。

处理器调度

调度层次

1️⃣ 高级调度(High-Level Scheduling)—— 作业调度 / 长程调度

目标:决定哪些“作业”被允许进入系统参与 CPU 竞争。 对象:作业(Job)→ 通常是一个完整的程序或任务(如编译一个文件、运行一个脚本)。 发生频率(几分钟到几小时一次)。 执行者:操作系统内核。

🔍 核心功能:

  • 选作业进内存:从后备队列中选择作业,将其加载到内存,创建进程。
  • 控制多道程序的道数:决定同时在内存中运行多少个作业(即并发度)。太多会耗尽内存,太少会浪费 CPU。

2️⃣ 中级调度(Medium-Level Scheduling)—— 平衡调度 / 内存调度

目标:根据内存状态,决定哪些进程可以在内存中运行,哪些需要换出到外存。 对象:进程(Process)。 发生频率中等(几秒到几分钟一次)。 执行者:操作系统内核。

🔍 核心功能:

  • 选进程进出内存:当内存紧张时,将一些不活跃的进程(如长时间阻塞的进程)换出到 Swap 分区;当内存空闲时,再换回。
  • 平衡系统负载:防止内存溢出,提高系统吞吐量。

3️⃣ 低级调度(Low-Level Scheduling)—— 进程调度 / CPU 调度

目标:决定哪个就绪队列中的进程/线程获得 CPU 执行权。 对象:进程或线程(内核级线程)。 发生频率(毫秒级,每几十到几百毫秒一次)。 执行者:操作系统内核 → 这是操作系统最核心的部分

🔍 核心功能:

  • 选进程分配 CPU:从就绪队列中选出下一个要运行的进程/线程。
  • 执行上下文切换:保存当前进程上下文,恢复新进程上下文。
  • 实现公平与效率:通过调度算法(如 RR、优先级、MLFQ)保证所有进程都能得到 CPU 时间。
1
2
3
4
5
6
7
8
graph LR
A[高级调度] -->|选作业进内存| B[中级调度]
B -->|选进程进出内存| C[低级调度]
C -->|选进程分配 CPU| D[CPU 执行]

style A fill:#f9d5e5,stroke:#333
style B fill:#e3eaa7,stroke:#333
style C fill:#b2d3c2,stroke:#333

调度算法评价指标

image-20251116131448708

七种调度策略

先来先服务 (First Come First Serverd, FCFS)

image-20251116132753390

短作业优先 (Shortest Job First, SJF)

image-20251116132925377

最短剩余时间优先 (Shortest Remaining Time First, SRTF)

image-20251116133352988

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

image-20251116133432719

优先级调度 (Priority Scheduling)

image-20251116141512909

轮转调度 (Round Robin Scheduling, RR)

image-20251116141611150

多级反馈队列调度 (Multi-Level Feedback Queue, MLFQ)

image-20251116144745818

并发:互斥与同步

进程交互

为什么需要“进程交互”?

在单进程时代,程序是“独占”的——它不需要考虑别人。但在现代操作系统中:

  • 多个进程/线程同时运行。
  • 它们可能共享资源(如内存、文件、数据库连接)。
  • 它们可能需要协同完成一个复杂任务(如一个 Web 请求涉及多个微服务)。

这就产生了两个根本性问题:

  1. 竞争(Competition):多个进程争抢同一个资源,导致结果不可预测。
  2. 协作(Cooperation):多个进程需要按特定顺序执行,才能完成共同目标。

进程交互就是解决这两个问题的机制

竞争关系(进程互斥)

✅ 核心定义:

进程互斥是指若干进程因相互争夺独占型资源而产生的竞争制约关系。

📌 关键词解析:

  • “相互争夺”:多个进程都想使用同一个资源。
  • “独占型资源”:一次只能被一个进程使用的资源,如打印机、临界区代码、全局变量、数据库连接等。
  • “竞争制约关系”:一个进程的执行会制约另一个进程的执行。

🧱 两个核心控制问题:

  1. 死锁问题(Deadlock)
    • 定义:多个进程互相等待对方释放资源,导致所有进程都无法继续执行。
    • 经典例子:“哲学家就餐问题”——五个哲学家围坐圆桌,每人左右各有一根筷子。他们必须拿到两根筷子才能吃饭。如果每个人都拿起左边的筷子,然后等待右边的筷子,就会陷入死锁。
    • 四个必要条件
      • 互斥条件
      • 请求与保持条件
      • 不剥夺条件
      • 环路等待条件
  2. 饥饿问题(Starvation)
    • 定义:某个进程因为优先级低或资源分配策略不当,长时间得不到所需资源,导致无法执行。
    • 例子:在一个高优先级任务永远不结束的系统中,低优先级任务可能永远得不到 CPU。

协作关系(进程同步)

✅ 核心定义:

进程同步是指为完成共同任务的并发进程基于某个条件来协调其活动,因为需要在某些位置上排定执行的先后次序而等待、传递信号或消息所产生的协作制约关系。

📌 关键词解析:

  • “完成共同任务”:多个进程/线程需要合作才能达成目标。
  • “协调活动”:它们需要按特定顺序执行。
  • “排定执行先后次序”:比如 A 必须在 B 之前执行。
  • “等待、传递信号或消息”:通过同步机制(如信号量、条件变量、管道)实现通信和协调。

🧱 核心思想:

  • “生产者-消费者”模型:生产者生成数据,消费者消费数据,它们必须同步。
  • “读者-写者”模型:读者可以同时读,但写者必须独占。
  • “屏障(Barrier)”:所有进程到达某个点后才能继续执行。

临界区管理

什么是“临界区”?

✅ 核心定义:

并发进程中,与共享变量有关的程序段叫做“临界区”(Critical Section)。

📌 关键词解析:

  • “并发进程”:多个进程/线程同时运行。
  • “共享变量”:多个进程都能访问和修改的变量(如全局变量、数据库连接、文件句柄)。
  • “程序段”:一段代码,比如 counter += 1 这样的操作。

💡 简单说临界区 = 操作共享资源的那一小段代码。

🎯 为什么重要?

因为这段代码如果被多个进程同时执行,会导致竞态条件(Race Condition),产生不可预测的结果。

如何避免错误?—— 互斥访问临界区

如果保证进程在临界区执行时,不让另一个进程进入临界区,即各进程对共享变量的访问是互斥的,就不会造成与时间有关的错误。

这就是“进程互斥”的核心思想。

临界区调度的三个原则(经典!)

这是解决临界区问题的黄金法则,任何同步机制都必须满足这三个条件:

✅ 原则 1:一次至多一个进程能够进入临界区内执行

互斥性(Mutual Exclusion)

  • 这是最基本的要求。任何时候,最多只能有一个进程在临界区内。
  • 如果 A 在临界区,B 就不能进入,必须等待。

✅ 原则 2:如果已有进程在临界区,其他试图进入的进程应等待

忙则等待(Progress)

  • 如果临界区空闲,想进入的进程可以立即进入。
  • 如果临界区被占用,其他进程必须等待,不能“自旋”浪费 CPU(虽然有些实现会自旋,但理想情况下应该阻塞等待)。

✅ 原则 3:进入临界区内的进程应在有限时间内退出,以便让等待进程中的一个进入

有限等待(Bounded Waiting)

  • 防止“饥饿”。不能让某个进程永远等下去。
  • 例如,使用队列来管理等待的进程,确保每个进程最终都能获得进入临界区的机会。

实现临界区管理的软件方法一Peterson方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int turn;           // turn 表示轮到谁进入
boolean flag[2]; // flag[i] 表示进程 i 想进入临界区

// 初始化
flag[0] = flag[1] = false;

Process P0() {
flag[0] = true;
turn = 1; // 谦让给 P1
while (flag[1] && turn == 1); // 等待 P1 退出或谦让
/* critical section */
flag[0] = false;
/* remainder section */
}

Process P1() {
flag[1] = true;
turn = 0; // 谦让给 P0
while (flag[0] && turn == 0); // 等待 P0 退出或谦让
/* critical section */
flag[1] = false;
/* remainder section */
}

✅ 1. 互斥性 (Mutual Exclusion)

定义:一次至多一个进程能进入临界区。

📌 证明思路:

  • 假设 P0 和 P1 同时进入临界区。
  • 那么 flag[0] = trueflag[1] = true
  • 根据算法,P0 在进入前设置了 turn = 1,P1 设置了 turn = 0
  • 由于 turn 只能取值 0 或 1,不可能同时为 0 和 1。
  • 所以,当 P0 检查 while (flag[1] && turn == 1) 时,如果 turn == 0,它就会阻塞。
  • 同理,P1 也会被阻塞。
  • 结论:不可能同时进入。

✅ 2. 空闲让进 (Progress)

定义:如果临界区空闲,且有进程想进入,则该进程应该能进入。

📌 证明思路:

  • 如果 P1 不想进入临界区,则 flag[1] = false
  • 此时,无论 turn 是多少,P0 的 while (flag[1] && turn == 1) 条件都会失败(因为 flag[1]false),所以 P0 可以立即进入临界区。

✅ 3. 有限等待 (Bounded Waiting)

定义:一个进程最多等待另一个进程执行完临界区一次,就能获得进入的机会。

📌 证明思路:

  • 假设 P0 被阻塞,说明 turn = 1flag[1] = true,即 P1 在临界区。
  • 当 P1 执行完临界区后,它会设置 flag[1] = false
  • 此时,如果 P0 还想进入,它的 while 条件会失败,从而进入临界区。
  • 如果 P1 在 flag[1] = false 后又想进入,则它会设置 flag[1] = trueturn = 0
  • 此时,P0 会被阻塞,但 P1 执行完后,P0 就能进入。
  • 结论:P0 最多等待 P1 执行一次临界区,就能进入。

信号量与PV操作

信号量(Semaphore)

✅ 核心定义:

信号量是一种软件资源,用于表示物理资源的实体,是一个与队列有关的整型变量。

📌 关键词解析:

  • “表示物理资源”:比如打印机、数据库连接池、线程池中的可用线程数。
  • “整型变量”:信号量的值代表当前可用资源的数量
  • “与队列有关”:当资源不足时,等待的进程会被放入一个等待队列

P/V 操作:信号量的“原子操作”

✅ 定义:

P (Proberen, 尝试) 和 V (Verhogen, 增加) 是对信号量进行操作的原语。

  • P 操作:尝试获取资源。如果资源可用(信号量 > 0),则减 1;否则,进程进入等待队列。
  • V 操作:释放资源。增加信号量值,并唤醒一个等待的进程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// P 操作 (Wait)
void P(semaphore s) {
s.value = s.value - 1;
if (s.value < 0) {
// 资源不足,将当前进程加入等待队列并阻塞
block(current_process);
}
}

// V 操作 (Signal)
void V(semaphore s) {
s.value = s.value + 1;
if (s.value <= 0) {
// 有进程在等待,唤醒一个
wakeup(one_waiting_process);
}
}

⚠️ 关键点:P/V 操作必须是原子操作(Atomic Operation),即在执行过程中不能被中断。否则会导致竞态条件。

哲学家进餐问题

哲学家进餐问题:核心描述

✅ 问题设定:

  • 5 位哲学家 围坐在一张圆桌旁。
  • 每位哲学家面前有一盘意大利面
  • 桌子上有 5 把叉子,每两位哲学家之间放一把。
  • 哲学家的生活只有两件事:
    • 思考(Think):什么都不做。
    • 吃饭(Eat):必须同时拿到左右两边的叉子才能吃。
  • 吃完后,会放下叉子,继续思考。

💡 目标:设计一个算法,让所有哲学家都能吃饱,且不会发生死锁或饥饿。

为什么会出现死锁?

📌 死锁的四个必要条件:

  1. 互斥条件:叉子一次只能被一个人使用。
  2. 请求与保持条件:哲学家拿起一把叉子后,会继续等待另一把。
  3. 不剥夺条件:不能强行从哲学家手中拿走叉子。
  4. 环路等待条件:每位哲学家都在等右边的人放下叉子,形成一个循环等待链。

解决方案:打破死锁的四个条件之一

要避免死锁,只需破坏其中一个必要条件即可。以下是几种经典的解决方案:

✅ 解决方案 1:限制同时就餐的哲学家数量(破坏“环路等待”)

最多允许 4 位哲学家同时吃面。

📌 原理:

  • 如果只有 4 个人尝试拿叉子,那么至少有一把叉子是空闲的。
  • 这样,总会有一个人能拿到两把叉子并吃完,从而释放资源。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import threading
import time

# 5 把叉子(信号量)
forks = [threading.Semaphore(1) for _ in range(5)]
# 限制同时就餐人数为 4
dining_room = threading.Semaphore(4)

def philosopher(i):
while True:
think()
dining_room.acquire() # 进入餐厅(最多 4 人)

forks[i].acquire() # 拿起左边叉子
forks[(i + 1) % 5].acquire() # 拿起右边叉子

eat(i)

forks[i].release() # 放下左边叉子
forks[(i + 1) % 5].release() # 放下右边叉子

dining_room.release() # 离开餐厅

def think():
time.sleep(0.1)

def eat(i):
print(f"Philosopher {i} is eating...")
time.sleep(0.1)

# 创建 5 个哲学家线程
threads = []
for i in range(5):
t = threading.Thread(target=philosopher, args=(i,))
threads.append(t)
t.start()

for t in threads:
t.join()

✅ 解决方案 2:奇偶号哲学家取叉子顺序不同(破坏“环路等待”)

奇数号哲学家先取左边叉子,再取右边;偶数号哲学家先取右边叉子,再取左边。

📌 原理:

  • 这样就不会形成环路等待。
  • 例如,哲学家 0(偶数)先拿右边叉子(叉子 1),哲学家 1(奇数)先拿左边叉子(叉子 1)→ 他们争抢同一把叉子,但最终只会有一个成功,另一个等待,从而打破环路。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def philosopher(i):
while True:
think()

if i % 2 == 0: # 偶数号哲学家
forks[(i + 1) % 5].acquire() # 先拿右边叉子
forks[i].acquire() # 再拿左边叉子
else: # 奇数号哲学家
forks[i].acquire() # 先拿左边叉子
forks[(i + 1) % 5].acquire() # 再拿右边叉子

eat(i)

forks[i].release() # 放下左边叉子
forks[(i + 1) % 5].release() # 放下右边叉子

✅ 解决方案 3:拿起两把叉子才开始吃(破坏“请求与保持”)

每位哲学家必须同时拿到两把叉子才能开始吃,否则一把也不拿。

1️⃣ 全局变量定义

1
2
3
4
5
6
7
#define THINKING 0
#define HUNGRY 1
#define EATING 2

semaphore s[5]; // 用于阻塞哲学家的信号量
semaphore mutex = 1; // 互斥锁,保护 state 和 s
int state[5]; // 哲学家的状态
  • s[i] 初始值为 0,因为一开始没有人需要等待。
  • state[i] 初始化为 THINKING

2️⃣ take_fork(int i) 函数

1
2
3
4
5
6
7
void take_fork(int i) {
P(mutex); // 获取互斥锁
state[i] = HUNGRY; // 哲学家 i 变成饥饿状态
test(i); // 尝试让 i 吃饭
V(mutex); // 释放互斥锁
P(s[i]); // 如果 test(i) 没有让 i 吃上饭,这里会阻塞
}

📌 关键点:

  • state[i] = HUNGRY: 告诉“管家”,我饿了。
  • test(i): “管家”检查我是否能吃。
    • 如果能吃,test(i) 会执行 V(s[i]),唤醒我。
    • 如果不能吃,test(i) 不做任何事。
  • P(s[i]): 如果我没被唤醒,我就在这里阻塞,等待邻居放叉子。

这个函数是“非阻塞”的:它只负责声明“我饿了”,然后立即返回。真正的等待发生在 P(s[i])

3️⃣ put_fork(int i) 函数

1
2
3
4
5
6
7
void put_fork(int i) {
P(mutex); // 获取互斥锁
state[i] = THINKING; // 哲学家 i 变成思考状态
test((i + 1) % 5); // 检查右边邻居
test((i + 4) % 5); // 检查左边邻居((i+4)%5 == (i-1)%5)
V(mutex); // 释放互斥锁
}

📌 关键点:

  • state[i] = THINKING: 我吃饱了,不再占用叉子。
  • test((i+1)%5)test((i+4)%5): 告诉“管家”,我的邻居们可能现在可以吃饭了。
    • 例如,哲学家 0 放下叉子后,哲学家 1 和 4 可能现在能拿到两把叉子了。
    • “管家”会检查他们是否处于 HUNGRY 状态,并且邻居都不在吃,如果是,就唤醒他们。

4️⃣ test(int i) 函数 —— 核心逻辑!

1
2
3
4
5
6
7
8
void test(int i) {
if (state[i] == HUNGRY &&
state[(i + 1) % 5] != EATING &&
state[(i + 4) % 5] != EATING) {
state[i] = EATING; // 可以吃了!
V(s[i]); // 唤醒哲学家 i
}
}

📌 关键点:

  • 检查三个条件
    1. state[i] == HUNGRY: 我确实想吃饭。
    2. state[(i+1)%5] != EATING: 我右边的邻居没在吃。
    3. state[(i+4)%5] != EATING: 我左边的邻居没在吃。
  • 如果都满足:说明我现在可以拿到两把叉子!
    • 设置 state[i] = EATING
    • 执行 V(s[i]),唤醒我自己(因为我在 take_forkP(s[i]) 阻塞了)。

这个函数是“原子”的:因为它在 mutex 保护下执行,不会被其他哲学家打断。

生产者消费者问题

mutex 的作用就是:

保护对共享变量(或临界区)的访问只在真正操作这些共享资源的前后“加锁”和“解锁”

(防死锁铁律):

永远不要在持有互斥锁(mutex)的情况下,调用可能阻塞的操作(如 P(empty)、P(full)、sleep、wait 等)。

什么是生产者-消费者问题?

这是一个经典的多线程同步问题,用于模拟现实中的“生产”与“消费”场景:

  • 生产者 (Producer):负责制造数据或产品。
  • 消费者 (Consumer):负责处理或消费这些数据/产品。
  • 缓冲区 (Buffer):一个有限大小的共享空间,用来暂存生产者的产品,供消费者取用。

📌 核心挑战

  1. 互斥 (Mutual Exclusion):多个生产者/消费者不能同时操作缓冲区的同一个位置,否则数据会错乱。
  2. 同步 (Synchronization)
    • 生产者不能在缓冲区满时继续生产(要等待)。
    • 消费者不能在缓冲区空时继续消费(要等待)。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
item B[n];
Semaphore empty; /*可用的空缓冲区个数*/
Semaphore full; /*可用的产品数*/
Semaphore mutex; /*互斥信号量*/
empty = n; full = 0; mutex = 1;
int in = 0; out = 0; /*in为放入缓冲区指针, out为取出缓冲区指针*/

Process producer_i( ) {
while(true) {
item product = produce();
P(empty);
P(mutex);
B[in] = product;
in = (in+1) % n;
V(mutex);
V(full);
}
}

Process consumer_i( ) {
while(true) {
P(full);
P(mutex);
Item product = B[out];
out = (out+1) % n;
V(mutex);
V(empty);
consume(product);
}
}

问题:如果将P操作的顺序交换,会出现什么情况?

生产者霸占着 mutex 锁,等待 empty,消费者等待 mutex 锁,导致死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sequenceDiagram
participant Producer as 生产者 P1
participant Consumer as 消费者 C1
participant Mutex as 互斥锁 (mutex)
participant Empty as 空闲缓冲区 (empty)

Note over Producer,Consumer: 初始状态: empty=0 (缓冲区满), mutex=1

Producer->>Mutex: P(mutex) // 成功获取锁,mutex=0
Producer->>Empty: P(empty) // empty=0,阻塞!等待空位...
Note over Producer: 生产者 P1 霸占 mutex 锁,等待 empty

Consumer->>Full: P(full) // full=1,成功,full=0
Consumer->>Mutex: P(mutex) // mutex=0,阻塞!等待锁...
Note over Consumer: 消费者 C1 等待 mutex 锁

Note over Producer,Consumer: 💥 死锁!
Note right of Producer: 我要等 empty (需 C1 消费)
Note left of Consumer: 我要等 mutex (需 P1 释放)

问题:当前生产者消费者共用一个互斥锁会造成竞争

1
2
3
4
Semaphore pmutex, cmutex; // 两个独立的互斥锁
...
P(pmutex); // 生产者只锁自己的写入区域
P(cmutex); // 消费者只锁自己的读取区域

优势

  • 生产者之间:仍然需要 pmutex 来互斥,因为多个生产者可能同时想写入 in 指针指向的位置。
  • 消费者之间:仍然需要 cmutex 来互斥,因为多个消费者可能同时想读取 out 指针指向的位置。
  • 生产者 vs 消费者它们可以并行! 只要生产者在写一个位置,消费者在读另一个位置,两者互不干扰,完全可以同时进行。

死锁

死锁产生

什么是死锁

在多进程/多线程系统中,死锁是指两个或多个进程因竞争资源而造成的一种互相等待的现象,若无外力作用,它们都将无法向前推进。

简单说:A 等 B,B 等 C,C 又等 A,大家谁也不让步,结果全都卡住。

死锁的4个必要条件

只要系统发生死锁,以下4个条件必然同时成立。缺一不可!

1️⃣ 互斥访问 (Mutual Exclusion)

  • 定义:系统中存在临界资源,进程应互斥地使用这些资源。
  • 通俗解释:资源一次只能被一个进程使用。比如,打印机、文件、数据库连接、内存中的某个变量等。
  • 为什么是必要条件?如果资源可以被多个进程同时共享(如只读文件),那就不存在竞争,也就不会死锁。

2️⃣ 占有和等待 (Hold and Wait)

  • 定义:进程在请求资源得不到满足而等待时,不释放已占有的资源。
  • 通俗解释:一个进程已经拿着一些资源,但它还需要其他资源才能完成工作,于是它一边等着新资源,一边还紧紧攥着自己手里的旧资源,不肯放手。
  • 为什么是必要条件?如果一个进程在等待新资源时能主动释放旧资源,那么它就不会阻塞别人,死锁也就不会形成。

3️⃣ 不剥夺 (No Preemption)

  • 定义:已被占用的资源只能由属主进程自愿释放,而不允许被其他进程剥夺。
  • 通俗解释:资源一旦被某个进程拿走,除非它自己愿意还回来,否则谁也不能强行抢走。这保证了进程的“自主性”,但也为死锁埋下了隐患。
  • 为什么是必要条件?如果系统能强行剥夺资源(比如操作系统强制回收),那么就可以打破死锁链。

4️⃣ 循环等待 (Circular Wait)

  • 定义:存在循环等待链,每个进程在链中等待下一个进程所持有的资源,造成这组进程处于永远等待状态。
  • 通俗解释:这是一个闭环。A 等 B 的资源,B 等 C 的资源,C 又等 A 的资源,形成了一个“等待环”。
  • 为什么是必要条件?如果没有循环,等待链最终会指向一个“不等待”的进程,这个进程完成后会释放资源,从而解开整个等待链。

死锁防止

image-20251116173459401

死锁避免

银行家算法

image-20251116181426684

死锁检测和解除

资源分配图

image-20251116182605135
  • 阻塞节点 (Blocked Node):一个进程,它正在请求一个或多个资源,但这些资源当前都被其他进程占用,且没有空闲实例可用。它必须等待。
  • 非阻塞节点 (Non-blocked Node):一个进程,它要么没有请求任何资源,要么它请求的资源当前有空闲实例可以立即满足。它可以继续执行。

如何通过资源分配图判断死锁?

✅ 死锁的充分条件(当资源类型只有一个实例时)

如果资源分配图中存在一个环,则系统一定发生死锁。

  • 原因:在一个环中,每个进程都在等待下一个进程所持有的资源,而下一个进程又在等待再下一个……形成一个无限等待的闭环。

⚠️ 当资源类型有多个实例时

环的存在是死锁的必要条件,但不是充分条件。

  • 原因:即使图中有环,但如果环中的某个资源类型有多个实例,那么可能还有空闲实例可以满足某个进程的需求,从而打破死锁。

资源分配图的简化

image-20251116182901353

死锁检测算法

与银行家算法的安全性检测类似

image-20251116184516361

存储管理

内存是什么?

从物理上讲,内存通常指的是随机存取存储器(RAM - Random Access Memory)

从操作系统的角度来看,内存是CPU(大脑)和硬盘(仓库)之间的“高速中转站”。它是计算机暂时存放数据的地方,用来存储当前正在运行的程序和正在处理的数据。

存储层次结构

计算机的存储设备像一个金字塔,越往上速度越快、价格越贵、容量越小

  1. 寄存器 (Registers): 在 CPU 内部,极快,容量极小(纳秒级)。
  2. 高速缓存 (Cache): 在 CPU 旁边,非常快(L1/L2/L3)。
  3. 内存 (Main Memory/RAM): 我们今天的主角,速度适中,容量适中。
  4. 本地磁盘 (Local Disk): 机械硬盘或固态硬盘,慢,容量巨大(毫秒级)。

操作系统的任务: 主要是管理第3层(内存),并负责在内存和磁盘之间搬运数据。

逻辑地址和物理地址

逻辑地址 (Logical Address)

  • 别名: 虚拟地址 (Virtual Address)。
  • 谁生成的? CPU(在执行程序时)。
  • 是什么? 这是程序“眼中”的地址
    • 当程序员写代码或者编译器编译程序时,它们看到的都是逻辑地址。
    • 程序觉得:“我拥有从 0Max 的一整块连续内存。”

物理地址 (Physical Address)

  • 谁看到的? 内存条(硬件)。
  • 是什么? 这是数据在内存条上真正的“门牌号”
    • 它对应着内存芯片中某个具体的存储单元。
    • 只有操作系统和硬件知道数据真正藏在哪里。

内存复用方式

为了支持多道程序设计(让电脑同时跑微信、浏览器、游戏),内存必须被复用。复用只有两种基本手段:

  1. 切大块(按照分区复用):
    • 把内存切成几个大块(分区)。
    • 规矩: 一个程序必须完整地塞进一个分区里(连续存放)。
  2. 切碎块(按照页框复用):
    • 把内存切成无数个一样大的小格子(页框)。
    • 规矩: 一个程序可以被切碎,散落在多个页框里(离散存放)。

四大具体方法

image-20251221140228398

这张图非常精彩,它展示了从“逻辑空间”(你以为的样子)到“物理空间”(实际的样子)的四种映射路径。请看着图中的 ①、②、③、④

1. 路径 ①:单连续/分区存储管理 (The “Old School”)

  • 逻辑上: 程序认为自己是一条连续的直路(单连续逻辑地址空间)。
  • 物理上: 放入“分区”(Partition)。
  • 解释:
    • 这是最原始的方法。程序多大,就在内存里找个多大的坑填进去。
    • 缺点: 必须连续。如果你有 100MB 内存,中间断断续续空了 50MB,但没有一块连续的 50MB,那 50MB 的程序就跑不起来。这叫“外部碎片”。

2. 路径 ②:页式存储管理 (Paging - 现代主流的基础)

  • 逻辑上: 程序依然认为自己是一条连续的直路(单连续逻辑地址空间)。
  • 物理上: 放入“页框”(Page Frames)。
  • 变化:
    • 虽然程序觉得自己是连续的,但操作系统偷偷拿把剪刀,把程序切成标准大小的“页”,然后随便塞进内存里任意位置的“框”里。
    • 优点: 彻底解决了“必须连续”的问题。内存利用率极高。

3. 路径 ③:段式存储管理 (Segmentation - 符合人类直觉)

  • 逻辑上: 程序认为自己是由不同的“功能块”组成的(多连续逻辑地址空间/段表)。
    • 比如:主程序段、数据段、栈段。
  • 物理上: 放入“分区”。
  • 解释:
    • 程序员喜欢这种方式。因为我们可以说:“把我的代码段设为只读,把数据段设为可写”。
    • 但是,每个段在物理内存里还是要占一块连续的地盘,所以依然会有碎片问题。

4. 路径 ④:段页式存储管理 (Segmented Paging - 集大成者)

  • 逻辑上: 先分段(符合程序员视角,便于管理和保护)。
  • 物理上: 再分页(符合硬件视角,便于内存利用)。
  • 解释:
    • 这是最强形态
    • 先把程序按逻辑分成“段”(比如代码段)。
    • 再把这个“段”切碎成“页”,丢进物理内存的“页框”里。
    • 结果: 既有了分段的逻辑优势(保护、共享),又有了分页的物理优势(没有碎片)。

存储管理的功能

地址转换 (Address Translation)

  • 核心:逻辑地址映射为物理地址(又称重定位)。
  • 方式: 分为静态重定位(装入时确定,不可动)和动态重定位(运行时确定,灵活)。

分配与去配 (Allocation & Deallocation)

  • 核心: 掌管内存的“借”与“还”。
  • 动作: 进程装入时分配空间并记录;进程撤离时回收空间并去配。

存储保护 (Storage Protection)

  • 核心: 确保进程互不干扰,防止越界访问。
  • 规则: 自己的随便用,共享的按权限用,别人的严禁用。

内存共享 (Sharing)

  • 核心: 允许多个进程共同使用同一块内存区域(如公共代码库)。
  • 目的: 提高内存利用率,支持进程协作。

内存扩充 (Expansion)

  • 核心: 利用磁盘空间“欺骗”程序,实现虚拟内存
  • 技术: 对换(整进整出)与虚拟技术(部分装入,按需调页)。

一句话总结: 操作系统通过转换地址让程序能跑,通过分配回收管理空间,通过保护防止打架,通过共享节省资源,通过扩充让小内存跑大程序。

连续分配管理方式 (Continuous Allocation)

核心定义

所谓“连续”,就是一个程序在物理内存中必须占据一块连在一起的地盘

  • 就像一群人去电影院看电影,必须买连座票,中间不能断开。

连续分配主要经历了三个阶段的进化,我们重点掌握后两个:

第一阶段:单一连续分配 (Single Continuous Allocation)

  • 这是什么: 整个内存只有你(操作系统)我(用户程序)两个人。
  • 情况: 内存分为系统区和用户区。用户区一次只能跑一个程序。
  • 结局: 这种方式太浪费了(如果你只有 10MB 程序,却占用了 8GB 内存),现代通用操作系统已经不用了。我们直接跳过。

第二阶段:固定分区分配 (Fixed Partitioning)

为了能多道程序并行,我们开始切蛋糕。

  • 做法: 系统启动时,就把内存切成若干个固定大小的区域(分区)。这些格子的大小一旦切好,就不能变了
  • 两种切法:
    1. 分区大小相等: 比如全是 10MB 的格子。
    2. 分区大小不等: 有小的(4MB)、中等的(8MB)、大的(16MB),这样更灵活。
  • 问题(致命伤):内部碎片 (Internal Fragmentation)
    • 假设有一个分区是 10MB
    • 你的程序只有 6MB
    • 虽然程序装进去了,但剩下的 4MB 被锁在这个分区里,别的程序进不来,你也用不了。这就叫内部碎片(在分区内部浪费的空间)。
image-20251221141919972
image-20251221142102685

第三阶段:动态分区分配 (Dynamic Partitioning)

为了解决“内部碎片”,操作系统决定:现吃现切

  • 做法: 初始时内存是一整块。当程序 A 来了,它需要 5MB,我就切 5MB 给它;程序 B 来了要 10MB,我紧接着切 10MB 给它。
  • 优点: 没有内部碎片!因为我是按需分配的,你需要多少我给多少。
  • 问题(致命伤):外部碎片 (External Fragmentation)
    • 随着时间推移,有的程序运行完走了(释放内存),内存里会留下一个个“坑”。
    • 比如:程序 B 走了,留下了 10MB 的空坑。
    • 这时来了一个 12MB 的新程序,它塞不进这个 10MB 的坑里。虽然内存里可能总共有 100MB 的空闲空间,但因为它们不连续(都是些碎小的坑),导致大程序跑不起来。这就叫外部碎片(在分区外部浪费的空间)。

“紧凑”技术 (Compaction)

image-20251221143811148

操作系统不能看着这 16M 内存白白浪费。为了让 P5 跑起来,操作系统必须进行“内存搬家”,专业术语叫紧凑拼接

  • 做法: 操作系统让现有的进程(P2, P4, P3)全部往上挪动,挤在一起。
  • 效果:
    • 所有的空闲小坑(6M, 6M, 4M)会被“挤”到内存的最底部,汇合成一个 16M 的大坑
    • 这时候,10M 的 P5 就可以舒舒服服地住进去了!

代价: “搬家”是非常耗时的。CPU 要暂停所有工作,疯狂地搬运数据(复制内存),这会严重拖慢电脑速度。这也解释了为什么以前的 Windows 98/XP 用久了要进行“磁盘碎片整理”(虽然那是磁盘,但原理类似,都是为了合并碎片)。

可变分区内存管理-分配算法

为什么需要分配算法?

因为“紧凑”(内存搬家)的代价太高了,会让电脑变卡。所以我们尽量通过聪明的分配算法,减少碎片的产生,不到万不得已不搬家。

以下是四种算法的详细解析:

1. 最先适应算法 (First Fit)

这是最简单、最自然的策略。

  • 核心思想: 不管别的,按地址从低到高挨个找,找到第一个能装下的坑,就立刻分给它。
  • 数据结构: 空闲分区表是按地址递增排列的。
  • 优点:
    • 保留了大空间: 因为它总是先填低地址的坑,所以高地址的大片连续空间通常会被保留下来,有利于以后装入大作业
  • 缺点:
    • 忙闲不均: 低地址部分会被切得稀碎,用得很频繁;而查找时每次都从头开始,导致查找开销大,且低地址端充满了小碎片。
image-20251221144426233

2. 下次适应算法 (Next Fit)

这是对“最先适应算法”的改良。

  • 核心思想: 既然每次从头找太累,那就从上次分配结束的位置开始往下找。找到队尾如果还没找到,就绕回队头接着找(循环扫描)。
  • 优点:
    • 速度快: 缩短了平均查找时间。
    • 均衡: 内存里的每个坑被选中的概率差不多,空间利用更均衡。
  • 缺点(致命):
    • 因为它把内存里的“大坑”都截断用了,导致缺乏大的连续空间。如果有大作业来了,可能反而找不到足够大的地盘了。
image-20251221144511133

3. 最优适应算法 (Best Fit)

听名字觉得是最好的,其实通常是最烂的。

  • 核心思想: 扫描整个内存,找到能装下该进程、且大小最接近(最小)的那个坑。
    • 比如你要 5MB,有一个 6MB 的坑和一个 20MB 的坑,它会选 6MB 的那个。
  • 数据结构: 为了找得快,通常把空闲分区按大小递增顺序排列。
  • 优点:
    • 它确实不想浪费大空间,尽量保留了大分区给大作业用。
  • 缺点:
    • 制造碎片: 每次切完后,剩下来的那点空间(比如 6MB 切走 5MB,剩 1MB)实在太小了,谁也用不了。日积月累,内存里全是这种微小的、无法利用的外部碎片
image-20251221144613218

4. 最坏适应算法 (Worst Fit)

这是“最优适应”的反面。

  • 核心思想: 每次都挑最大的那个坑给进程。
    • 比如你要 5MB,它偏偏要把那个 100MB 的大坑切给你。
  • 数据结构: 空闲分区按大小递减顺序排列,这样查表时只要看第一个满不满足就行了。
  • 优点:
    • 减少碎片: 切完剩下的一般还比较大(100MB 切走 5MB,剩 95MB),还可以继续给别的程序用。所以它对中小型作业非常有利
  • 缺点:
    • 大作业哭了: 最大的坑很快就被消耗掉了,真来了个超级大的程序,往往就没地方放了。
image-20251221144633595

分页存储管理 (Paging Storage Management)

为什么要引入分页?

  • 分区的痛: 程序必须完整地塞进连续的内存里。如果有 3 个 2MB 的小坑,想装一个 5MB 的程序,装不进去。
  • 分页的药: 允许程序“打散”。把 5MB 的程序切成很多小块,分别塞进那 3 个 2MB 的坑里(甚至更小的坑),只要总空间够,就能跑。

核心思想: 逻辑上连续(程序看来是完整的),物理上不连续(内存里是散落的)。

三大概念

A. 页框 (Page Frame) —— 物理内存的“格子”

  • 定义: 操作系统把物理内存切成一个个大小完全固定的块。
  • 大小: 通常较小且是 2 的幂,比如 4KB(4096字节)。
  • 编号: 从 0 开始编号,叫页框号物理块号
  • 类比: 就像是一个巨大的药柜,里面全是大小一样的标准小抽屉。

B. 页面 (Page) —— 程序的“切片”

  • 定义:用户的程序(逻辑地址空间)*也切成和页框*一模一样大小的块。
  • 编号: 从 0 开始编号,叫页号
  • 类比: 你把药材(程序)切成标准的小方块,每一块刚好能塞进一个抽屉。

C. 页表 (Page Table) —— 寻宝图

  • 定义:既然程序被切碎散落在内存的各个角落,CPU 怎么知道程序的“第 1 页”在哪个“抽屉”里?
  • 我们需要一张映射表,这就是页表
  • 内容: 记录了 逻辑页号 -> 物理页框号 的对应关系。
image-20251221150141354

分页是如何解决碎片问题的?

没有“外部碎片”:

  • 因为内存被切成了标准的 4KB 格子,程序也是 4KB 的块。只要内存里有空闲的格子,程序就能塞进去,完全不用担心“坑太小”的问题。

只有微小的“内部碎片”:

  • 产生原因: 程序的最后一部分可能填不满一页。
  • 例子: 页面大小是 4KB。你的程序是 4.1KB。
    • 第 1 页(4KB)填满。
    • 第 2 页(0.1KB)只占了一点点,剩下的 3.9KB 就是浪费的。
  • 结论: 这种浪费主要发生在程序的最后一页,平均只有半页大小,相比于固定分区的浪费,这简直可以忽略不计。

分页存储管理的地址转换

image-20251221151214942
image-20251221151234013

TLB(快表)

1. 核心痛点:为什么要引入快表?

请看图,它指出了一个严重的问题:

  • 页表放在哪里? 放在内存中。
  • 这就导致了一个尴尬的后果: 每次 CPU 想读取一个数据,必须访问两次内存
    1. 第一次访问: 去查内存里的页表,把逻辑地址翻译成物理地址。
    2. 第二次访问: 根据物理地址,真正去取数据。

2. 解决方案:TLB (快表)

为了解决“慢”的问题,科学家发明了 TLB (Translation Look-aside Buffer),中文叫快表联想寄存器

  • 它是什么? CPU 内部的一种极其快速、但容量很小的高速缓存。
  • 它存什么? 它存放当前最常访问的那一小部分页表项(Page # 到 Frame # 的映射)。
  • 原理: 利用程序的“局部性原理”。如果你刚看了第 5 页,你很可能马上又要看第 5 页。

升级后的比喻:

  • 你在手边放了一张小纸条(TLB)
  • 每次找书前,先看小纸条。如果纸条上有位置信息(TLB 命中),直接去拿书,不用跑去查目录了!
image-20251221154628477

3. 加入 TLB 后的工作流程

请看图 的流程图,这是现在的标准动作:

  1. CPU 发出请求: 逻辑地址(页号 p)。
  2. 先查 TLB(快):
    • 情况 A:命中 (Hit)
      • 直接拿到物理块号。
      • 耗时: 仅需 1 次内存访问(取数据)。
    • 情况 B:未命中 (Miss)
      • 没办法,只能老老实实去访问内存里的页表(慢)。
      • 拿到块号后,顺手把这一项存进 TLB(以备下次用)。
      • 耗时: 2 次内存访问。

分页存储空间的分配与去配

安全性问题: 怎么防止进程去访问不属于它的内存?

之前学的页表,只是告诉 CPU “第 X 页在第 Y 块”。但这里有个漏洞:如果一个程序只有 5 页,但恶意(或错误)代码试图去访问“第 6 页”,会发生什么?

为了防止这种越界访问,操作系统在页表的每一行后面加了一个小尾巴,叫 “有效-无效位” (Valid-invalid bit)

1. 机制详解
  • v (valid):有效。表示这一页是合法的,确实在物理内存里,且属于当前进程。
  • i (invalid):无效。表示这一页不属于该进程(或者该页还在磁盘上没调入内存)。
  • 后果: 如果 CPU 试图访问一个标记为 i 的页面,硬件会立即触发一个异常 (Exception/Trap),操作系统会终止该进程或进行处理。
2. 图中的数学例子(非常重要)

让我们拆解一下图中的计算题,看看它是怎么判定“第 6 页”是非法的:

  • 前提条件:
    • 页大小 = 2KB (211 = 2048 字节)。
    • 这意味着地址的低 11 位是偏移量。
  • 进程需求:
    • 进程实际使用的地址范围是 0 ~ 10468
  • 计算需要多少页:
    • $$\frac{10468}{2048} \approx 5.11$$
    • 这意味着填满了第 0, 1, 2, 3, 4 页,并且第 5 页占用了一点点(0.11的部分)。
    • 所以,总共需要 0~5 号页(共 6 个页)。
  • 查看页表状态:
    • 你看右边的页表,页号 0~5 的状态位都是 v(允许访问)。
    • 页号 6, 7 的状态位是 i(禁止访问)。
    • 如果程序试图访问地址 12287(属于第 6 页),MMU 检查到是 ‘i’,直接报错拦截。
image-20251221155409519

管理问题: 操作系统怎么快速知道哪些物理格子是空的?

操作系统手里握着几千几万个物理页框,当新程序来的时候,怎么快速找到哪里有空位?位图 (Bitmap) 是最常用的方法。

1. 核心思想
  • 一串二进制位 (0和1) 来代表物理内存的格局。
  • 每一位 (bit) 对应一个 物理页框
  • 映射规则:
    • 第 1 个 bit 对应 第 1 个页框。
    • 第 2 个 bit 对应 第 2 个页框…以此类推。
2. 状态表示
  • 1: 表示该页框被占用

  • 0: 表示该页框空闲。

    (注:有些系统可能反过来,但根据这张PPT的文字“找出为 0 的那些位”,说明这里 0 代表空闲)

3. 分配算法流程

当一个程序需要申请 3 个页框时:

  1. 扫描: 操作系统扫描这个位图,寻找哪里有 “0”
  2. 计算: 比如发现第 5、6、8 位是 0。
  3. 计算页框号:
    • 发现第 i 位是 0,那么对应的物理页框号就是 i(或者基于基址计算)。
  4. 修改状态: 把这几位从 0 变成 1(标记为占用)。
  5. 分配: 把算出来的物理页框号填到进程的页表里。
image-20251221155532240

多级页表 (Multi-level Page Table)

1. 为什么要引入多级页表?(The Problem)

让我们做一个简单的数学计算:

  • 在 32 位系统下,页面大小 4KB。

  • 这意味着总共有 220 (约 100 万) 个页面。

  • 如果每个页表项占 4 字节,那么一张完整的页表需要:

    100万 × 4B = 4MB

痛点:

  1. 必须连续: 在单级页表中,这 4MB 的空间必须在物理内存中连续存放
  2. 难找: 在内存紧张时,很难找到一块完整的、连续的 4MB 空间给页表住。
  3. 浪费: 很多程序其实只用了一点点内存,但为了维持结构,你不得不把这 100 万个坑位都建好(哪怕大部分是空的)。

解决方案:

把这 4MB 的大页表,也切成小块(页),散落在内存里!既然切碎了,就需要再建立一张表来管理这些碎块——这就是二级页表(页表的页表)。

2. 逻辑地址的重新划分

请看图,为了支持二级页表,逻辑地址被切得更碎了:

  • 旧模式(单级): 20位页号 (p) + 12位偏移 (d)。
  • 新模式(二级):
    • p1 (10位):一级页号(外层页号)。用来找“目录的目录”。
    • p2 (10位):二级页号(内层页号)。用来找“具体的页表”。
    • d (12位):页内偏移。保持不变。

为什么是 10 位?

210 = 1024。每个页表项 4 字节。

1024 × 4B = 4KB

妙处: 这样切割后,每一张二级页表的大小刚好是一个页面 (4KB)!这让页表本身也可以完美地塞进普通的内存页框里。

地址变换过程 (The Walk)

image-20251221161021945

请看图,这是一个“三步跳”的过程:

  1. 第一跳(查目录):
    • CPU 拿着 p1,去查一级页表
    • 得到结果:知道“这一段地址对应的二级页表”在内存的什么位置。
  2. 第二跳(查页表):
    • CPU 拿着 p2,去刚才找到的那张二级页表里查。
    • 得到结果:终于拿到了真正的物理块号 (Frame #)
  3. 第三跳(取数据):
    • 用物理块号 + 偏移量 d,去访问物理内存,拿到真正的数据。

优点:

  • 离散存储页表: 页表不需要 4MB 连续空间了,可以打散放在各种角落。
  • 节省空间: 如果一个程序只用了很小的内存,我们只需要建立“一级页表”和“少量二级页表”即可。其他的二级页表根本不用创建(或者可以留在磁盘上)。

缺点(如图片右上角文字所示):

  • 变慢了!
    • 单级页表:访存 2 次(查表+取数)。
    • 二级页表:访存 3 次(查一级+查二级+取数)。
    • 多级页表级数越多,访问越慢。

补救措施: 这就是为什么上一节讲的 TLB (快表) 极其重要!如果有 TLB,大部分时候我们直接能拿到结果,不需要走这漫长的三步跳。

分段存储管理

1. 为什么要分段?

请看图,这解释了分段的初衷:

  • 程序员眼中的程序:
    • 程序员写代码时,不会认为程序是一堆 4KB 的碎片。
    • 我们认为程序是由不同的功能块组成的:比如 主程序 (main)子程序 (subroutine)栈 (stack)变量数组 (array) 等。
  • 分页的尴尬: 分页像碎纸机,可能把一个完整的函数切成两半,一半在第 1 页,一半在第 20 页。这让共享和保护变得很麻烦。
  • 分段的解决: 保持逻辑完整。
    • 主程序 单独一段。
    • 单独一段。
    • 数据表 单独一段。
    • 特点: 每个段的大小不固定(主程序可能 50KB,栈可能 1KB)。

黄金类比:

  • 分页 像是把一本书的每一页撕下来,胡乱塞进一个个一样大的信封里。
  • 分段 像是把书按“章”分开。第一章放一个文件夹,第二章放一个文件夹。有的章长,有的章短。

2. 逻辑地址的变化 (2D Address)

在分段管理中,地址不再是一维的线性的,而是二维的。

请看图 的底部:

  • 逻辑地址 = 段号 (Segment Number) + 段内偏移 (Offset)
  • 例子: “第 1 段 的 第 500 行”。

关键区别:

  • 分页: 只要给出一个物理地址,机器自动切分。
  • 分段: 用户(编译器)必须显式地指定“段号”和“段内偏移”。

3. 核心机制:段表 (Segment Table)

既然段的大小不固定,操作系统怎么管理呢?请看图。

我们需要一张段表,用来记录每个“章”在内存里的位置。因为每一章长度不一样,所以段表必须包含两列核心信息

  1. 段起始地址 (Base): 这个段在物理内存是从哪里开始的?(比如从 6300 开始)。
  2. 段长度 (Limit/Length): 这个段到底有多大?(比如只有 400 大小)。
    • 注意:页表不需要记录“长度”,因为页表默认全是 4KB。但在分段中,这个长度至关重要!
image-20251221162559750

4. 地址变换与保护 (The Translation & Safety)

这是分段管理最厉害的地方:越界保护

我们模拟一次 CPU 访问(结合图 的数据):

  • 场景: CPU 要访问 段号 1偏移量 500 的数据。

步骤:

  1. 查表: 找到段表里的第 1 项。
  2. 获取信息:
    • 基址 (Base) = 6300
    • 限长 (Length) = 400
  3. 越界检查 (Critical Step):
    • CPU 会拿你的偏移量 500 和 限长 400 比较。
    • 500 > 400,说明你试图访问这一段之外的地方!
    • 结果: 硬件直接拦截,抛出 “段错误” (Segmentation Fault)。这是编程时最常见的报错之一。
  4. 正常情况: 如果偏移量是 100(小于 400),则物理地址 = 6300 + 100 = 6400

虚拟内存 (Virtual Memory)

1. 痛点:实存管理的问题

请看图,传统的实存管理(Real Memory Management)有一个硬性规定:

  • 必须全部装入: 作业(程序)如果要运行,必须把它的全部信息一次性装入内存。
  • 后果:
    1. 大程序跑不了: 程序的体积受限于物理内存的大小。
    2. 浪费资源: 其实程序里很多代码是很少用到的(比如“异常处理代码”、“巨大的未填满的数组”)。把这些一辈子都不一定跑一次的代码一直放在昂贵的内存里,是极大的浪费。

2. 解决方案:虚拟内存

虚拟内存的核心思想就是“欺骗”

  • 核心动作:部分装入 (Partial Loading)
    • 操作系统不再把整个程序塞进内存,而是只把当前立刻要用的那几页装进去,剩下的留在硬盘上。
    • 程序运行到哪里,就动态地把哪里的数据从硬盘“拉”进内存(请求调页)。
    • 如果内存满了,就把暂时不用的数据“踢”回硬盘(页面置换)。
  • 效果:
    • 逻辑 > 物理: 给用户提供一个比物理主存大得多的“虚拟主存”。
    • 以小博大: 8GB 的物理内存,可以流畅运行 20GB 的游戏,甚至可以同时运行总共 100GB 的多个程序。

3. 理论基石:局部性原理

你可能会问:“这样频繁地在内存和硬盘之间倒腾数据,电脑不会卡死吗?” 答案是:通常不会。因为程序运行有一个神奇的规律,叫“局部性原理”

请看图,它解释了为什么我们敢“只装入一部分”:

  1. 时间局部性 (Temporal Locality):
    • 现象: 如果一条指令被执行了,那么不久之后它很有可能再次被执行
    • 原因: 程序里充满了大量的循环 (Loop)。一旦进入循环,CPU 就会盯着这几行代码反复跑,完全不需要访问其他页面的代码。
  2. 空间局部性 (Spatial Locality):
    • 现象: 如果一个存储单元被访问了,那么它附近的单元也很有可能马上被访问。
    • 原因: 指令通常是顺序执行的;数据通常是聚集成群的(比如数组)。

结论: 在一段较短的时间内,程序实际上只需要访问极小一部分内存就能正常工作。所以我们完全可以把剩下的 90% 扔在硬盘里睡觉,而不影响运行速度。

如何实现虚拟内存技术

要实现虚拟内存,不能使用我们最早学的“连续分配方式”(因为要频繁地把一大块程序搬进搬出,效率太低且不仅能保证连续空间)。

因此,虚拟内存必须建立在离散分配(非连续分配)的基础上。

根据课件,实现虚拟内存主要有以下三个流派和两个核心“新技能”:

1. 三种实现方式

其实就是把你之前学的“基本款”升级为“虚拟款”:

基础版本 (全部装入) 虚拟版本 (部分装入,按需调页)
基本分页存储管理 页式虚拟存储管理 (最主流)
基本分段存储管理 段式虚拟存储管理
基本段页式存储管理 段页式虚拟存储管理

2. 两个新增的核心功能

这是“虚拟系统”和“普通系统”最本质的区别。为了实现“空手套白狼”,操作系统必须具备两项新能力:

A. 请求调页/调段功能 (Demand Paging/Segmentation)
  • 动作: “缺了就拿”
  • 描述: 在程序执行过程中,当 CPU 发现要访问的数据不在内存时(缺页),操作系统负责把所需的信息从硬盘(外存)调入内存,然后让程序继续执行。
B. 页面置换/段置换功能 (Page/Segment Replacement)
  • 动作: “满了就扔”
  • 描述: 当内存空间不够的时候,操作系统负责利用某种算法,把内存中暂时用不到的信息(页面或段)换出到硬盘上,腾出地方给新进来的页面用。

页式虚拟存储管理

1. 核心思想:按需调入 (Demand Paging)

  • 启动时: 操作系统只把进程的第一页(或者极少量的几页)装入内存,然后就让 CPU 开始跑。
  • 运行时:
    • CPU 执行着执行着,发现要访问的下一行代码在“第 5 页”。
    • 一查页表,发现第 5 页不在内存里。
    • 动作: 暂停程序,去硬盘把第 5 页抓进来,然后继续跑。
  • 地位: 这是现代 OS 的主流存储管理技术

2. 基础设施升级:扩充页表 (Expanded Page Table)

为了支持这种“有的在内存,有的在硬盘”的复杂情况,原来的页表(只记录页号->块号)已经不够用了。我们需要给页表加很多“状态栏”。

请看图,现在的页表项变得很宽,包含了以下关键信息:

  1. 驻留标识 (Present/Resident bit): 最重要的一位
    • 1: 表示该页在内存中(可以直接访问)。
    • 0: 表示该页在磁盘上(需要触发中断去调入)。
  2. 写回标志 (Dirty/Modify bit):
    • 记录这一页在内存里有没有被修改过。
    • 作用: 当这一页要被踢出内存时,如果是“脏”的(被改过),必须写回硬盘保存;如果是“干净”的,直接扔掉就行(硬盘里有原版),省了一次磁盘 IO。
  3. 引用标志 (Reference bit):
    • 记录这一页最近有没有被访问过。
    • 作用:置换算法参考。最近刚被用过的,最好别踢它(LRU 算法的基础)。
image-20251221170033104

3. 核心机制:缺页中断 (Page Fault)

当 CPU 想要访问一个“驻留标识为 0”(不在内存)的页面时,就会触发缺页中断。这是虚拟内存运转的“引擎”。

  1. CPU 访问: 给出逻辑地址。
  2. 硬件检查: 发现页表里这一项显示“不在内存”。
  3. 产生中断: 硬件立刻产生缺页中断,CPU 暂停当前进程,把控制权交给操作系统。
  4. OS 处理 (关键分支):
    • 情况 A(内存有空位):
      • 直接从磁盘找到该页,读入空闲的页框。
      • 修改页表(把驻留位置 1,填入块号)。
    • 情况 B(内存满了):
      • 执行页面置换算法,挑一个倒霉蛋(淘汰页)踢出去。
      • 如果那个倒霉蛋被修改过,先把它写回磁盘。
      • 把腾出来的空位给新页面用。
  5. 恢复执行: 操作系统更新完页表后,重新执行刚才那条导致中断的指令。这一次,CPU 就能顺利找到数据了。
image-20251221170552446

虚存地址转换过程

image-20251221170856192

第一阶段:启动与分流

1. 地址分解 (Step 1)

  • 动作: CPU 发出指令,MMU(内存管理单元)截获逻辑地址,自动把它切成两半:页号 (p)页内偏移 (d)

2. 查快表 (Step 2)

  • 动作: 以页号 p 为索引,先去查 CPU 内部的 快表 (TLB)
  • 耗时: t1(非常短,纳秒级)。

第二阶段:三种可能的命运

命运一:快表命中 (TLB Hit) —— 极速模式

这是图中 (3) 的路径。

  • 情况: 在 TLB 里直接找到了页号对应的物理块号。
  • 结果:
    • 直接取出块号。
    • 拼接: 块号 + 偏移量 = 物理地址。
    • 访问数据: 去访问主存(耗时 t2)。
  • 总耗时: t1(查TLB) + t2(取数据)
命运二:快表未命中,但页表命中 —— 普通模式

这是图中 (4) -> (5) 的路径。

  • 情况: TLB 里没找到(Miss),但MMU 去查内存里的 页表 时,发现该页在内存中(驻留位为 1)。

  • 动作:

    1. 从页表中读出物理块号。
    2. 关键动作:装入快表 (Load TLB)。为了下次能快点,把这一项复制到 TLB 里。
    3. 形成物理地址,访问数据。
  • 总耗时: t1(查TLB) + t2(查页表) + t2(取数据)

    (比命运一多了一次访存的时间)

命运三:缺页中断 —— 龟速模式

这是图中 (6) -> (7) -> (8) 的路径(红色箭头)。

  • 情况: TLB 没找到,查内存页表发现也不在内存中(驻留位为 0,失效)。
  • 动作:
    1. Step (6) 缺页中断: MMU 发出信号,CPU 暂停,操作系统接管。
    2. Step (7) 调页: 操作系统去 辅助存储器 (磁盘) 里把这一页找出来,读入内存。
      • 注意:这一步耗时是 t3,通常是毫秒级,比前两种慢几万倍。
    3. Step (8) 装入/改表: 数据读进内存后,操作系统更新 页表快表,把状态改为“在内存”。
  • 结局: 重启指令,这次就会走“命运一”或“命运二”了。

缺页中断率 (Page Fault Rate)

怎么衡量虚拟内存效率高不高?就看缺页中断率 (f)

  • 公式:

    $$f = \frac{F}{S + F}$$

    • F: 缺页次数(去磁盘拿的次数)。
    • S: 成功在内存找到的次数。
    • 目标: 我们希望 f 无限接近于 0。
  • 影响 f 的四大因素:

    1. 内存页框数: 给进程分的“房间”越多,缺页率越低。
    2. 页面大小: 页面越大,缺页率通常越低(因为一次拉进来更多数据),但浪费也可能变大。
    3. 页面替换算法: 算法越聪明(踢得越准),缺页率越低。
    4. 程序特性: 这不仅是 OS 的事,也是程序员的事!

页面调度算法

最佳页面调度算法(OPT, OPTimal replacement)

image-20251221173735698

先进先出页面调度算法(FIFO, First-In First-Out replacement)

image-20251221173753487
Belady异常
image-20251221174117271

为什么会发生这种事?

Belady 异常的根本原因在于 FIFO 算法的“无脑”

  • FIFO 的逻辑: 只看进入内存的时间,谁来得早谁滚蛋。
  • 问题所在: 它完全不考虑页面的使用频率访问模式
    • 在上面的 4 页框例子中,43 是经常要被访问的热门页面。
    • 但是因为它们进来得早,FIFO 总是优先把它们踢出去。
    • 当页框变多时,页面的驻留时间变长了,队列的结构变了,导致原本能“巧合”命中的页面(比如在3页框时,踢出的顺序刚好避开了马上要用的页),在4页框时反而被“精准”地踢出去了。

最近最少使用页面调度算法(LRU, Least Recently Used replacement)

image-20251221174219298

最不常用页面调度算法(LFU, least frequently used)

image-20251221174254213

时钟页面调度算法(Clock, Clock policy replacement)

1. 核心道具 (The Setup)

要玩转这个算法,需要三个关键道具:

  1. 环形队列 (Circular Queue):
    • 把内存里的所有页面首尾相连,排成一个圆圈,就像钟面一样。
  2. 表针 (Pointer):
    • 有一个指针,指向当前要检查的那个页面(下一号淘汰候选人)。
  3. 引用标志位 (Reference/Use Bit):
    • 每个页面都有一个标志位。
    • 1 = 最近被访问过(免死金牌)。
    • 0 = 最近没被访问(可以杀)。
2. 算法规则:给一次“改过自新”的机会

Clock 算法的核心逻辑就是“二次机会” (Second Chance)

当内存满了,需要踢人时,指针开始顺时针扫描:

  • 情况 A:遇到标志位是 1 的页面
    • 动作: 操作系统心软了。它把标志位改为 0(没收免死金牌),然后指针移向下一页
    • 潜台词: “我看你最近刚被用过,这次先不杀你,但我把你的牌子没收了。如果下次我转回来你还没被访问,那你就在劫难逃了。”
  • 情况 B:遇到标志位是 0 的页面
    • 动作: 操作系统不客气了。直接淘汰这个页面!
    • 后续: 把新页面装进这个坑位,把标志位置为 1,指针移向下一页
image-20251221193222438

段式虚拟存储管理

如果有需要再学

设备管理

设备控制方式

为什么要有设备控制方式

1. 巨大的速度鸿沟 (The Speed Gap)

这是最根本的原因。

  • CPU:是电子设备,运算速度以纳秒 (ns) 计,一秒钟能执行几十亿次指令。
  • I/O设备:大多包含机械部件(如硬盘磁头转动、打印机喷墨),速度以毫秒 (ms) 甚至秒计。

差距有多大? 如果把 CPU 比作一列高铁(时速 300公里),那 I/O 设备就像是一只蜗牛。 如果没有优化的“控制方式”(比如让 CPU 傻等的轮询方式),就相当于让高铁停下来等蜗牛爬过铁轨。这简直是暴殄天物,极大地浪费了昂贵的 CPU 资源。

所以,设备控制方式的演进,本质上就是为了不让高铁等蜗牛。

2. 实现“并行操作” (Parallelism)

操作系统的核心目标之一是效率。我们希望计算机能同时做多件事。

  • 没有好的控制方式时:CPU 必须亲自指挥设备每一个动作。CPU 在忙 I/O 的时候,就不能做计算;做计算的时候,就不能管 I/O。这是串行的。
  • 有了好的控制方式(如 DMA、通道)
    • CPU 说:“你去把电影拷贝一下。”(发指令)
    • CPU 转头去运行游戏逻辑。(做计算)
    • 设备控制器自己在旁边慢慢拷贝电影。(做 I/O)

这就是CPU 与设备的并行。只有通过先进的设备控制方式,才能把 CPU 从繁琐的搬运工作中解放出来,让它去处理更有价值的逻辑运算。

3. 屏蔽设备的复杂性 (Abstraction)

世界上的设备千奇百怪:鼠标、键盘、显卡、网卡、打印机、VR眼镜……

  • 它们的物理原理完全不同。
  • 它们的数据格式完全不同。

如果让 CPU 直接去控制每一个物理细节(比如控制硬盘电机转几圈、控制打印机喷头往哪喷),CPU 的指令集会变得无比复杂,且操作系统无法通用。

设备控制方式(配合硬件控制器)起到了一层“翻译”和“管家”的作用:

  • CPU 只需要下达统一的命令(读、写)。
  • 具体的脏活累活(如何控制电压、如何校验数据、如何按顺序传输)交给设备控制器控制逻辑(如 DMA 控制器)去完成。
方式 谁在搬运数据? CPU 干预频率 传输单位 效率 (并行程度)
轮询 CPU 极高 (时刻在检查) 字/字节
中断 CPU 高 (每单位数据一次) 字/字节
DMA DMA控制器 低 (每块数据一次) 数据块
通道 通道处理器 极低 (每组任务一次) 多组数据块 极高

四者之间差异在于:CPU和设备并行工作的方式和程度不同。

轮询(Polling)

流程解析

  1. 发出命令:CPU 告诉设备(控制器)“我要读数据”。
  2. 读状态:CPU 紧接着去读设备的状态寄存器。
  3. 检查状态(关键点)
    • 如果设备“未就绪”(还在忙着准备数据),CPU 顺着红色箭头跳回去,再次读取状态。
    • CPU 会一直在“读状态 -> 检查 -> 未就绪 -> 读状态”这个圈里打转。
  4. 读写数据:只有当设备终于显示“就绪”了,CPU 才会跳出循环,亲自把数据读进来。

核心特点:CPU 全程陪跑 (CPU 全程参与)

忙等 (Busy Waiting)

  • 这就是上面说的那个“死循环”。在设备准备数据的这段时间(对于 CPU 来说极其漫长),CPU 没有去干别的更有意义的事,而是像个复读机一样一直问“好了没?”。
  • 结果:CPU 的利用率被严重拉低。

串行工作

  • PPT 文字提到:“在设备接受 I/O 命令之前处理器不能执行其他操作”。
  • 这意味着 计算任务I/O 任务 是完全串行的(排队做)。CPU 不能在设备忙的时候去算别的题。

中断驱动方式 (Interrupt-Driven I/O)

当设备准备好了(状态变为“就绪”),控制器会向 CPU 发送一个电信号,这个信号就叫中断

CPU 的反应

  1. 收到信号,暂停当前正在做的工作(保存现场)。
  2. 跳到 “中断处理程序”(流程图中的黄色框)。
  3. 传送数据:CPU 亲自把数据从设备搬运到内存。
  4. 恢复:搬运完后,CPU 回到刚才暂停的地方继续工作。

直接存储器访问(DMA, Direct Memory Access)

image-20251223153957188

图展示了 DMA 模块的内部结构,这解释了它是如何独立工作的:

  • 地址寄存器:记住了数据要搬到内存的哪个位置。
  • 数据计数:记住了还有多少数据要搬。
  • 控制逻辑:负责指挥总线进行读写。
  • 关键点:CPU 一旦把这些寄存器填好,DMA 就拥有了工作的全部信息,不再需要 CPU 指导。

CPU -> DMA(下达命令)

  • CPU 告诉 DMA:“把硬盘里这 1MB 数据搬到内存地址 X。”

继续执行后续指令(红色虚线)

  • 关键时刻:CPU 下完命令直接走人,去处理其他进程。
  • 与此同时,DMA 控制器接管总线,一车一车地往内存搬数据。这是真正的并行!

中断(最后一步)

  • 只有当这 1MB 数据全部搬完了,DMA 才会发一个中断信号告诉 CPU:“老板,活干完了。”
什么是“周期窃取”?

“DMA 和 CPU 同时通过总线访问内存,CPU 会把总线的占有权让给 DMA 一个/几个主存周期”。

形象理解

  • 总线 (Bus) 就像是一条独木桥。内存是桥对面的仓库。
  • CPU 是一个一直在过桥搬东西的人。
  • DMA 是另一个也要过桥搬东西的人。
  • “周期窃取”:当 DMA 需要搬运一个数据字时,它不会把 CPU 彻底赶走,而是趁着 CPU 正在思考(译码)或者还没上桥的间隙“偷” 用一下这个桥(总线周期),快速搬运一个字,然后立马把桥还给 CPU。
image-20251223155431045

图中的方框(取指令、译码、取操作数…)代表 CPU 执行一条指令的各个微步骤。

中断断点 (最右侧):请看最右边的箭头。中断是非常“讲礼貌”的。它必须等到 CPU 把当前这条指令完全执行完(存结果之后),才会去响应中断。

DMA 断点 (中间的箭头):请看指向“译码”和“取操作数”之间的那个箭头。DMA 是“急脾气”。它不需要等指令执行完。只要 CPU 当前这个微操作(比如译码)不占用总线,DMA 就可以见缝插针地插入进来,偷一个周期传数据。

结论:DMA 的响应速度比中断快得多,因为它不需要等指令结束。

为什么“窃取”不会严重拖慢 CPU?

Cache 的功劳:PPT 最后一行提到“CPU 大部分情况下与 Cache 进行数据交换”。CPU 也就是在这一瞬间不能访问主存(内存),但它依然可以访问Cache(高速缓存)。只要 Cache 里有数据,CPU 就算没了总线也能继续干活,完全感觉不到 DMA 在偷东西。

不连续性:DMA 只是偶尔偷一个周期,而不是长时间霸占,所以对 CPU 的宏观影响很小。

三种方式对比

  • ① 轮询方式: CPU需要主动等待设备就绪,并且全程参与内存数据的交换。
  • ② 中断方式: CPU无需主动等待设备就绪。当设备准备好后,会向CPU发送一个中断信号,CPU响应中断后再参与内存数据交换。
  • ③ DMA方式: CPU只在I/O操作开始前和结束后参与(例如,初始化DMA控制器),在实际的数据传输过程中,由DMA控制器直接在内存和设备间搬运数据,CPU完全不参与主存数据交换。
CPU作用 等待设备 内存数据交换
轮询方式 需要 参与
中断方式 不需要 参与
DMA方式 不需要 不参与

从轮询到中断再到DMA,CPU的效率越来越高,它能更早地从I/O等待中解放出来去执行其他任务,从而提高了系统的并行处理能力。然而,这种并行仅限于物理层面的I/O操作,即CPU和I/O设备可以同时工作,而不是指CPU内部或软件层面的并行计算。

通道控制方式 (Channel Control)

1. 核心定义:什么是“通道”?
  • 别名:它又被称为 I/O 处理器 (I/O Processor)
  • 地位:它不再是一个简单的硬件控制器,而是一个“弱智版 CPU”
  • 能力:它拥有执行逻辑独立 I/O 任务的能力。这意味着它不仅仅能“搬运”,还能做简单的“决策”(比如:先读这个,再写那个,如果错了重试)。
2. 核心机制:通道程序 & 四级连接

这里有两个关键概念需要理解:

A. 通道程序 (Channel Program)

PPT 提到,处理器不再执行具体的 I/O 指令,而是“在主存中组织通道程序”

  • DMA 方式:CPU 给的是参数(源地址、目的地址、数据量)。
  • 通道方式:CPU 给的是一段代码(通道程序)。
    • 比喻
      • DMA:老板说“把这堆砖搬到后院”。
      • 通道:老板写了一张任务清单:“1. 先把砖搬到后院;2. 然后去买水泥;3. 最后把墙砌好。” 通道拿着这张清单,自己去执行一系列复杂的动作,不需要老板再插手。

B. 四级连接 (Four-level Connection)

PPT 中提到了 “处理器、通道、控制器、设备” 的四级连接。

  • 这是一个层级管理结构:
    • CPU 指挥 通道
    • 通道 指挥 设备控制器
    • 设备控制器 控制 设备
  • 目的:一个通道可以控制多台设备,大大节省了 CPU 的接口资源。
3. 工作流程:高度并行 (High Parallelism)

详细展示了工作步骤,这里有几个考试常考的缩写

  1. CPU 启动
    • CPU 遇到 I/O 任务。
    • OS 组织好通道程序,把这个程序的地址放在 CAW (Channel Address Word,通道地址字) 中。
    • CPU 启动通道,然后立即走人(去干别的事)。
  2. 通道执行
    • 通道从 CAW 里拿到清单(通道程序),开始指挥设备干活。
    • 此时,CPU 和 通道 真正实现了“高度并行”
  3. 结束汇报
    • 活干完了,通道发出中断。
    • CPU 响应中断,从 CSW (Channel Status Word,通道状态字) 中读取执行情况(比如是成功了还是出错了)。

总线与I/O

什么是总线

从慢吞吞的键盘到快如闪电的显卡,它们都需要和 CPU 或内存交换数据。总线就是它们共同行走的通道。

想象计算机的主板是一个繁忙的城市

  • CPU、内存、硬盘、网卡 就是城市里的建筑物
  • 总线 就是连接这些建筑物的主干道
  • 数据 就是路上跑的车辆

总线的三大组成部分

  1. 数据总线 (Data Bus) —— 运货车
    • 作用:用来传输实际的数据(比如你的文档内容、游戏画面)。
    • 特点:是双向的(能发能收)。路越宽(比如 32位、64位),一次能拉的货就越多。
  2. 地址总线 (Address Bus) —— 导航员
    • 作用:用来告诉大家“我要去哪里”。比如 CPU 要读内存,必须先通过地址总线广播:“我要找 0x0012 号房间的数据”。
    • 特点:通常是单向的(由 CPU 发出)。
  3. 控制总线 (Control Bus) —— 红绿灯/交警
    • 作用:用来指挥交通。比如发送“读”、“写”、“中断”或“请求占用总线”的信号。
    • 关联:刚才我们学的 “DMA 周期窃取”,其实就是 DMA 通过控制总线向 CPU 申请:“把路权借我用一下”。

解决 I/O 速度不匹配问题

这里的“不匹配”体现在两个维度:

  • I/O 和 CPU 的不匹配:CPU 是光速运行的,而 I/O 设备相对较慢。
  • 各设备之间的不匹配:这是这张图的重点。键盘和显卡虽然都是 I/O 设备,但它们的速度简直是云泥之别。

总线概念结构

单总线结构模型 (Single Bus Structure Model)

image-20251223162354483
  • 一条总线:中间那根橙色的双向大箭头。
  • 全员接入CPU主存(内存) 和所有的 I/O 模块 都直接挂在这同一根线上。

这意味着什么? 这意味着 CPU 想和内存说话,要走这条路;硬盘想把数据传给内存,也要走这条路。大家共享这一条通信通道。

优点:简单粗暴

  1. 结构简单:硬件设计非常容易,成本低。
  2. 易于扩充:这是最大的好处。如果你想加一台打印机,只需要把它“挂”在总线上就行,不需要改动 CPU 或内存的架构。就像在路边盖新房子一样容易。

缺点:致命的“堵车”

这种设计在早期计算机中很流行,但随着设备越来越多,它的弊端完全暴露出来:

  • 共用总线导致压力大
    • 因为只有一条路,同一时刻只能有一组对话。如果硬盘正在传电影,CPU 就不能读内存,必须干等。这就构成了瓶颈
  • 传输时延长
    • 设备多了,大家都要申请路权,排队时间自然变长。
  • 最痛的点:慢速外设占用带宽多
    • 这是单总线最大的硬伤。
    • 比喻:想象这是一条单车道高速公路。如果前面有一辆拖拉机(慢速 I/O 设备)在慢慢开,后面性能再好的法拉利(CPU/内存)也得跟在屁股后面慢慢爬。
    • 这就导致了高速设备的性能被低速设备严重拖累。

三级总线模型 (Three-level Bus Model)

image-20251223162608032
第一级:局部总线 (Local Bus) —— “VIP 专用通道”
  • 位置:最上方,连接 CPUCache (高速缓存)
  • 作用:这是系统中最快的一条路。
  • 意义:CPU 和 Cache 之间的交互极其频繁且速度极快。把它们单独划在这个“小圈子”里,让 CPU 能全速运行,完全不受外界打扰。
第二级:主存总线 (System Bus) —— “城市主干道”
  • 位置:中间层,连接 主存 (Main Memory)Cache局部 I/O 控制器
  • 作用:负责内存数据的吞吐。
  • 意义:当 Cache 没命中时,需要从主存调数据,就走这条路。它比局部总线慢一点,但依然很快。
第三级:扩展总线 (Expansion Bus) —— “社区辅路”
  • 位置:最下方,连接各种 I/O 设备(如 LAN网卡、SCSI设备、打印机等)。
  • 作用:专门用来挂载各种速度参差不齐的外设。
  • 关键组件扩展总线接口 (Expansion Bus Interface)。它是连接“主干道”和“辅路”的立交桥/收费站。它不仅负责传递数据,还起到缓冲的作用,防止慢速设备的信号干扰高速总线。
优点:各行其道

分流 (Isolation)

  • “主存与 I/O 之间的数据传送”“处理器的内存活动” 被分离开了。
  • 简单说:硬盘往内存传数据(走扩展总线 -> 主存总线)的时候,CPU 依然可以在局部总线上和 Cache 玩得很开心,互不影响。

扩展性强

  • “支持更多的 I/O 设备”。因为有了扩展总线这一层,你可以挂很多慢速设备,而不会增加主存总线的负载(电容负载)。
缺点:木桶效应

PPT 也提到了一个缺点:“不适用于 I/O 设备数据速率相差太大的情形”

  • 解读:你看最下面的“扩展总线”,上面既挂了高速的 LAN (网卡)SCSI (高速硬盘接口),又可能挂慢速的 字符设备 (键盘/鼠标)
  • 如果所有外设都挤在这一根扩展总线上,高速设备(如千兆网卡)可能会觉得这条路太慢,或者被慢速设备抢占时隙,导致性能发挥不出来。
  • 解决思路:这就是为什么现代电脑(如你现在的 PC)其实演进到了更高级的结构(如 桥接芯片组架构,分为北桥和南桥,或者现在的 PCH),把高速 I/O (PCIe) 和低速 I/O (USB) 进一步分开。

南北桥架构” (Northbridge and Southbridge Architecture)

image-20251223163719210
核心理念:快慢彻底分家

PPT 顶部的文字点出了核心思路:“通过存储总线、PCI总线、E(ISA)总线分别连接主存、高速I/O设备和低速I/O设备”

  • 为什么要分南北?
    • 有些设备太快(内存、显卡),必须贴着 CPU 跑。
    • 有些设备太慢(鼠标、键盘),离 CPU 远点没关系。
    • 于是,主板芯片组被劈成了两半:北桥负责快,南桥负责慢
北桥 (Northbridge) —— 速度担当

地位:它是离 CPU 最近的芯片(通常在主板上方,故称北桥)。

  • 别名:PPT 中标注为 “主存控制器” (Memory Controller)
  • 连接对象(贵族圈)
    1. CPU:通过“处理器总线”直接连接,带宽最高。
    2. 主存 (RAM):通过“存储总线”连接。这是北桥最重要的任务——管理内存读写。
    3. 高速 I/O 设备:PPT 中展示了 图形设备 (显卡)SCSI (服务器硬盘)LAN (网卡) 挂接在 PCI 总线 上。虽然 PCI 总线在物理上通常由南桥管理或作为南北桥的桥梁,但逻辑上它们属于高速区,需要通过北桥与 CPU 高速交换数据。
南桥 (Southbridge) —— 管家担当

地位:离 CPU 较远(通常在主板下方,故称南桥)。

  • 别名:PPT 中标注为 “I/O 控制器”
  • 连接对象(平民圈)
    1. 低速 I/O 设备:PPT 下方展示的 COM 口鼠标键盘
    2. 慢速总线:这些设备挂在 E(ISA) 总线 上。这是一种非常古老且缓慢的总线标准。
  • 职责:南桥负责处理这些琐碎、慢速的输入输出,整理好之后,再通过“桥间接口”统一汇报给北桥。
关键接口:桥间接口 (Hub Interface)

请看图中连接北桥和南桥的那根竖线——“桥间接口”

  • 这是连接“CBD”和“郊区”的高速公路。
  • 所有的鼠标点击、键盘输入,都要先汇聚到南桥,通过这个接口传给北桥,最后才能到达 CPU。

北桥:负责 CPU总线存储总线(内存)以及 PCI总线(高速 I/O,如显卡、网卡)。

南桥:负责 E(ISA)总线(慢速 I/O,如键盘、鼠标),并通过桥间接口与北桥通信。

优点:支持不同速率 (Heterogeneity)

PPT 明确指出了这种架构的优点:“可以支持不同数据速率的 I/O 设备”

  • 各司其职
    • CPU 想读内存,直接找北桥,路径极短,速度极快。
    • CPU 想读鼠标,指令传给南桥,南桥慢慢去读,不会占用北桥的高速通道。
  • 消除瓶颈:慢速的 ISA 设备再多,也不会拖慢 CPU 访问内存的速度,因为它们在物理上被隔绝在南桥下面了。

第一梯队(最快):北桥负责

  • 不仅仅是内存:你说得对,北桥不仅连接主存,还连接了 图形设备 (显卡)。显卡是 I/O 设备中对速度要求最高的(比如打游戏时的数据吞吐量极大),所以它被提拔到了“北桥”这个 VIP 区域,直接和 CPU、内存享受高速通道。

第二梯队(较快):PCI 总线

  • LAN (网卡)SCSI (硬盘) 挂在 PCI 总线 上。这是一条高速公路,专门给这些吞吐量大的设备跑。

第三梯队(慢速):南桥负责

  • 鼠标、键盘 被赶到了最下面的 E(ISA) 总线,由 南桥 统一管理。
  • 南桥像一个过滤器,把这些慢速设备的琐碎请求整理好,再通过中间的接口汇报上去。

I/O 软件的层次结构

image-20251223165755764

第 1 层:用户空间的 I/O 软件 (User-Space I/O Software)

“我要打印一份文件”

  • 位置:最顶层,直接和用户打交道。
  • 是谁:你写的 C 语言代码(printf, scanf),或者具体的应用程序(Word, 浏览器)。
  • 核心功能
    • I/O 系统调用:发起请求。
    • SPOOLing:比如你点打印时,电脑没卡死,而是把任务放到了后台队列里,这就是 SPOOLing 技术(后面会细讲)。
    • I/O 格式化:比如把你的数字 100 转换成字符 '1', '0', '0' 显示在屏幕上。
  • 特点:它根本不知道硬盘是圆的还是方的,它只知道“我要读/写数据”。

第 2 层:独立于设备的 I/O 软件 (Device-Independent I/O Software)

“好的,不管你是存到 U 盘还是硬盘,逻辑都一样”

  • 位置:第二层,这是操作系统的核心通逻辑
  • 核心功能
    • 设备的命名:把物理设备映射成逻辑名(比如 /dev/sdaC: 盘)。
    • 设备保护:检查你有没有权限读这个文件。
    • 缓冲 (Buffering):非常关键!为了解决速度差异,数据会先暂存在这里。
    • 设备分配与释放:决定这个打印机现在归谁用。
  • 特点:这一层抹平了硬件差异。对于它来说,所有设备都是“文件”。

第 3 层:I/O 设备驱动程序 (Device Drivers)

“收到,正在指挥西部数据硬盘的磁头移动”

  • 位置:第三层,这是最懂硬件的软件。
  • 核心功能
    • 翻译:把上层的“读第 5 个块”这种抽象命令,翻译成具体的硬件指令(如“设置寄存器 X 为 1,向端口 Y 发送数据”)。
    • 检查状态:看设备是不是忙,是不是出错了。
  • 特点每一类设备都有专门的驱动。显卡有显卡驱动,网卡有网卡驱动。它是操作系统和硬件之间的“翻译官”。

第 4 层:I/O 中断处理程序 (Interrupt Handlers)

“老板,活干完了!(叫醒 CPU)”

  • 位置:最底层软件,紧贴硬件。
  • 核心功能
    • 处理中断:当硬件干完活(比如 DMA 搬运完了),会发个电信号触发中断,这层软件负责响应。
    • 唤醒驱动:告诉上面的驱动程序“数据到了,你可以继续往下跑了”。
  • 特点:它是在硬件事件发生后被动触发的“急救员”。

总结:数据流动的全过程

想象你在 Word 里点击“保存”(写硬盘):

  1. 用户层:Word 调用系统函数 write()
  2. 独立层:OS 检查权限,分配缓存,找到文件对应的逻辑地址。
  3. 驱动层:驱动程序把逻辑地址算出具体的磁道和扇区,向硬盘控制器发指令。
  4. 硬件层:硬盘疯狂转动,磁头写入数据。
  5. 中断层:硬盘写完,发中断。中断程序告诉驱动“搞定”,驱动告诉上层“搞定”,最后 Word 提示你“保存成功”。

I/O 缓冲区

1. 核心定义:什么是 I/O 缓冲区?

给出了明确定义:“在内存中开辟的存储区,专门用于临时存放 I/O 操作的数据”

  • 通俗理解
    • 没有缓冲:CPU 直接伸手向硬盘要数据,硬盘没给,CPU 就手悬在半空等着。
    • 有了缓冲:在内存里放一个“快递柜”。硬盘把数据慢慢填进柜子,填满了通知 CPU 来取。CPU 取数据的时候,硬盘可以继续往下一个柜子里填。

2. 为什么要引入缓冲?(五大目的)

PPT 左侧详细列出了目的,每一条都直击痛点:

  1. 解决速度不匹配:这是最核心的。CPU 是跑车,I/O 是拖拉机。缓冲让跑车不用停车等拖拉机。
  2. 协调记录大小不一致
    • 逻辑记录:你的代码可能想读一行字(10个字节)。
    • 物理记录:硬盘一次只能读一个扇区(4KB)。
    • 缓冲的作用:把 4KB 读到缓冲区,然后把其中的 10个字节 拿给你的程序。
  3. 提高并行性:CPU 算它的,设备传它的,互不干扰。
  4. 减少中断次数
    • 如果没有缓冲,每来一个字符就要中断 CPU 一次。
    • 有了缓冲,填满一整个缓冲区(比如 4KB)才中断一次。CPU 舒服多了。
  5. 放宽响应时间要求:数据来了先进缓冲区待着,CPU 忙完手头的事再来取,不用秒回。

3. 缓冲技术的进化史(看右边的图)

image-20251223171004487

PPT 右侧的四张小图展示了缓冲技术是如何一步步变强的。

(a) 无缓冲 (No Buffering)
  • 状态:CPU 和设备直接对接。
  • 后果:完全串行。设备忙,CPU 就要等;CPU 忙,设备就要停。效率最低
image-20251223171623306

1. 三个关键变量 (T, M, C)

首先,你要搞清楚这三个字母代表什么,它们是一个数据块处理流程的三个步骤:

  • T (Time for Input)输入时间。从磁盘把一块数据读到缓冲区。这是 I/O 设备的活。
  • M (Time for Move)传送时间。系统把数据从缓冲区“搬”到用户的内存区。这是内存复制的操作。
  • C (Time for Computation)计算时间。CPU 对这块数据进行处理。这是 CPU 的活。

核心逻辑

  • 没有缓冲时:这三步是串行的,总时间 = T + C
  • 有了单缓冲T(I/O)C(CPU计算) 可以并行了(同时进行)。

2. 两种场景分析

PPT 下方展示了两种可能的情况,取决于“谁更慢”

情况一:T > C (I/O 慢,CPU 快)

这是左边的图。

  • 场景:硬盘读得很慢 (T 长),CPU 算得很快 (C 短)。
  • 过程
    • CPU 很快算完了上一块数据 (C),但是下一块数据还没传完 (T)。
    • 结果:CPU 必须等待硬盘。
  • 瓶颈:在于 T
  • 处理一块数据的总时间T + M
    • (注:虽然也有 C,但 C 包含在 T 的时间段里并行做完了,没有增加额外耗时,所以只算长的那段 T,加上必须要做的搬运 M。)

情况二:T < C (I/O 快,CPU 慢)

这是右边的图。

  • 场景:硬盘读得飞快 (T 短),但 CPU 计算很复杂,算得很慢 (C 长)。
  • 过程
    • 硬盘很快把缓冲区填满了 (T),但是 CPU 还在算上一块 (C),没空来取。
    • 结果:硬盘必须等待 CPU 腾出缓冲区。
  • 瓶颈:在于 C
  • 处理一块数据的总时间C + M
(b) 单缓冲 (Single Buffer)
  • 原理:操作系统在内存里建一个仓库。
    • 设备 -> 仓库:设备把数据搬进仓库(CPU 此时可以干别的)。
    • 仓库 -> 用户进程:仓库满了,CPU 把数据从仓库挪走。
  • 局限不能同时进出。当 CPU 正在从仓库取货时,设备不能往里面送货(因为只有一个门),设备必须暂停等待仓库腾空。
(c) 双缓冲 (Double Buffering) —— 也叫“乒乓缓冲”
  • 原理:建两个仓库(1号和2号)。
  • 玩法
    • 设备往 1号 装货。
    • 与此同时,CPU 从 2号 取货。
    • 大家都不用停!等 1号满了、2号空了,交换一下。
  • 优点:实现了极致的并行。除非 CPU 处理太慢或者设备太慢导致一方严重积压,否则数据流几乎是连续的。
image-20251223171959180

答案是:Max(C + M, T)

这里的逻辑是:

  • T (Input):设备往“缓冲区1”里送货的时间。
  • C + M (Compute + Move):CPU 从“缓冲区2”取货 (M) 并处理 (C) 的时间。
  • 因为有两个缓冲区,这两件事是完全并行的。谁慢(时间长),整体速度就取决于谁。
(d) 多缓冲/循环缓冲 (Circular Buffering)
  • 原理:建很多个仓库,连成一个圈。
  • 场景:适用于阵发性的大量数据传输(比如看高清视频,网速忽快忽慢)。
  • 玩法
    • 设备拼命往空仓库里填(生产者)。
    • CPU 拼命追着满仓库取(消费者)。
    • 只要圈里还有空位,设备就不用停;只要圈里还有满位,CPU 就不用停。这是弹性最大的方案。

独占型外围设备的分配

设备独立性 (Device Independence)

1. 核心痛点:如果把代码写死会怎样?

以前的做法 (物理设备绑定)

  • 你在写程序时,如果直接写死:“我要用编号为 001 的那台惠普打印机”。
  • 后果:如果这台 001 号打印机坏了,或者被搬走了,你的程序就直接报错崩溃了,即使旁边还有一台一模一样的 002 号打印机空闲着,你也用不了。

总结:绑定具体物理设备虽然简单,但灵活性极差,一坏就瘫痪。

2. 解决方案:引入“逻辑设备”

为了解决这个问题,操作系统引入了设备独立性

  • 核心思想
    • 用户(程序员):只负责说“我要用一台打印机”(这就是逻辑设备)。
    • 操作系统:负责去仓库看哪台打印机是好的、空闲的(比如 003 号),然后把它分配给你(这就是物理设备)。
  • PPT 定义:用户不指定物理设备,而是指定逻辑设备,使得用户作业和物理设备分离开来。
3. 实现机制:映射表 (The Mapping Table)

操作系统是怎么把你的“空头支票”(逻辑设备)兑现成“真金白银”(物理设备)的呢?

  • PPT 中间红字提到:系统需要提供逻辑设备名到物理设备名的映射表
  • 类比
    • 你打车时输入“我要去机场”(逻辑请求)。
    • 打车软件(操作系统)查一下数据库(映射表),指派了“京B·12345”这辆车(物理设备)给你。
    • 这一层映射关系,就是设备独立性的核心。
4. 三大优点 (考试必考)

代码不用改 (应用程序与具体物理设备无关)

  • 你换了个新鼠标,不需要去改你的游戏代码。因为游戏只调用“逻辑鼠标”,操作系统会自动把新鼠标映射上去。系统增减或变更设备时不需要修改源程序。

系统更可靠 (易于应对故障)

  • 如果打印机 A 坏了,操作系统自动把任务导向打印机 B。用户根本感觉不到故障的存在。

资源分配更灵活

  • 谁闲着就给谁用,实现了多道程序设计,不再出现“旱的旱死,涝的涝死”的情况。
设备分配的数据结构
image-20251223173127932

共享型外围设备的驱动

image-20251223192427585

1. 物理组件:搭建“多层蛋糕”

请看右上角的 3D 图:

  • 盘片 (Platters):磁盘里不只有一张盘,而是有多张盘片叠在一起,穿在中间的上高速旋转。
  • 盘面 (Surfaces):每个盘片就像硬币一样,有正反两面。通常每一面都可以存数据,所以 2 个盘片就有 4 个盘面。
  • 磁头 (Heads):看那个像梳子一样的移动臂,它的每一个齿尖上都有一个磁头。
    • 关键点:所有磁头是共进退的。移动臂一动,所有磁头一起动。

2. 数据划分:画圈圈

请看右下角的平面图:

  • 磁道 (Track):盘面被划分成无数个同心圆,每一个圈就是一个磁道
    • 比喻: 就像操场上的跑道。
  • 扇区 (Sector):每个磁道又像切披萨一样,被切分成很多小块,每一块叫扇区
    • 地位:扇区是磁盘读写的最小物理单位(通常是 512 字节或 4KB)。

3. 核心概念:柱面 (Cylinder) —— 考试必考

这是最抽象但最重要的概念。请看右上角图中标注红色的虚线圆柱体。

  • 定义:所有盘面上,半径相同的那些磁道,在垂直方向上叠在一起,就构成了一个柱面
  • 为什么要有这个概念?
    • 因为所有磁头是固定在同一个移动臂上的。
    • 当你把磁头移动到最外圈(磁道 0)时,所有盘面的磁头都同时停在了磁道 0 上。
    • 性能秘诀:如果不移动机械臂,只通过电子切换磁头来读写不同盘面上的数据,速度是极快的。
    • 结论柱面是操作系统优化读写速度的关键。把相关联的数据存在同一个柱面上,就能减少机械臂的移动。

4. 磁盘寻址:如何找到一个数据块?

PPT 左下角红字列出了物理块地址的编码方法,最经典的是 CHS 寻址法

1. 柱面号 (Cylinder) —— 找圈

  • 首先,操作系统指挥移动臂,把磁头移动到指定的半径位置(比如第 100 号柱面)。
  • 动作:机械移动(最慢,叫“寻道时间”)。

2. 磁头号 (Head) —— 找面

  • 磁头臂到了位置,但有 4 个盘面,我要读哪一个?通过激活具体的磁头来选择盘面。
  • 动作:电子切换(极快)。

3. 扇区号 (Sector) —— 找块

  • 盘面在疯狂旋转,磁头不动,等着指定的那个扇区(披萨块)转到磁头底下。
  • 动作:机械旋转等待(较慢,叫“旋转延迟”)。

地址转换关系(扇区编号从0开始)

image-20251223194324624

磁盘存取时间

$$T_a = T_s + \frac{1}{2r} + \frac{b}{rN}$$

公式左边的 Ta 代表 磁盘一次存取的总时间 (Total Access Time)。它由三部分组成:

第一部分:寻道时间 (Ts) —— “最耗时的赶路”

  • 对应变量Ts (Seek Time)
  • 含义:机械臂把磁头移动到指定柱面(磁道)所花的时间。
  • 特点
    • 这是物理机械运动
    • 在计算题中,通常会直接给你一个平均值(比如 “平均寻道时间为 10ms”),或者让你根据移动了多少个磁道来算。
    • 它是性能杀手:通常占总时间的大头。

第二部分:旋转延迟 ($\frac{1}{2r}$) —— “平均运气”

  • 对应变量:图中蓝色的 $\frac{1}{2r}$
  • 含义平均旋转等待时间
  • 推导逻辑(非常重要):
    • r:磁盘旋转速度(单位:转/秒)。比如 7200转/分 = 120转/秒。
    • 1/r:转一整圈需要的时间。
    • 为什么是 1/2:因为当你磁头到了磁道时,目标扇区可能刚过去(要等一整圈),也可能正好就在下面(不用等)。平均来看,你需要等半圈
    • 这就是 $\frac{1}{2} \times (\text{转一圈的时间})$ 的由来。

第三部分:传输时间 ($\frac{b}{rN}$) —— “真正的读写”

  • 对应变量:图中橙色的 $\frac{b}{rN}$
  • 含义平均传输时间。也就是磁头扫过数据块并读取内容的时间。
  • 变量详解
    • b:你要读写的字节数 (Bytes to transfer)。
    • N:一个磁道上总共有多少字节 (Bytes per track)。
    • r:转速。
  • 推导逻辑
    • r × N = 磁盘一秒钟能扫过多少数据(数据传输率)。
    • 时间 = 总量 / 速度 = b/(r × N)
    • 或者另一种理解: $\frac{b}{N}$ 表示你要读的数据占了这一圈的百分之多少(比如占了 1/10 圈),然后乘以转一圈的时间 (1/r)。结果是一样的。

移臂调度及算法

先来先服务算法

image-20251223195351262

最短查找时间优先算法

image-20251223195424991

单向扫描算法

image-20251223195452951

双向扫描算法

image-20251223195516927

电梯调度算法

image-20251223195602421

旋转调度

循环排序

image-20251223201528047

优化分布(交替排序)

image-20251223204707858

参考资料

操作系统原理 (2025 春季学期)

01 - AI 时代的操作系统课2025 南京大学操作系统原理]_哔哩哔哩_bilibili

第一章作业

1

假设同一套指令集用不同的方法设计了两种机器 M1 和 M2。机器 M1 的时钟周期为 0.8ns,机器 M2 的时钟周期为 1.2ns。某个程序 P 在机器 M1 上运行时的 CPI 为 4,在 M2 上的 CPI 为 2。对于程序 P 来说,哪台机器的执行速度更快?快多少?

image-20251011140015756

2

假定编译器对某段高级语言程序编译生成两种不同的指令序列 S1 和 S2,在时钟频率为 500MHz 的机器 M 上运行,目标指令序列中用到的指令类型有 A、B、C 和 D 四类。每类指令在 M 上的 CPI 和两个指令序列所用的各类指令条数如下表所示。

指令类型 A B C D
各指令的 CPI 1 2 3 4
S1 的指令条数 5 2 2 1
S2 的指令条数 1 1 1 5
image-20251011140100523

3

假定机器 M 在运行程序 P 的过程中,共执行了 500×10⁶ 条浮点数指令、4000×10⁶ 条整数指令、3000×10⁶ 条访存指令、1000×10⁶ 条分支指令,这 4 种指令的 CPI 分别是 2、1、4、1。若要使程序 P 的执行时间减少一半,浮点指令的 CPI 应如何改进?若要使程序 P 的执行时间减少一半,访存指令和分支指令的 CPI 应如何改进?若浮点指令和整数指令的 CPI 减少 20%,访存指令和分支指令的 CPI 减少 40%,则程序 P 的执行时间会减少多少?

image-20251011140106600
image-20251011140123204

第二章作业

1

假定某计算机的总线采用奇校验,每8位数据有一位校验位,若在32位数据线上传输的信息是 8F 3C AB 96H,则对应的4个校验位应为什么? 若接收方收到的数据信息和校验位分别为87 3C AB 96H0101B,则说明发生了什么情况,并给出验证过程。.0

第一部分:计算原始数据 8F 3C AB 96H 对应的 4 个校验位

前提条件: - 使用奇校验:每个字节(8位)中,1的个数必须是奇数。 - 每8位数据配1位校验位,所以32位数据分成4个字节,对应4个校验位。 - 数据是十六进制:8F 3C AB 96H

我们需要对每一个字节单独计算其奇校验位。

第一步:把每个十六进制字节转换成二进制

  • 8F H = 1000 1111
  • 3C H = 0011 1100
  • AB H = 1010 1011
  • 96 H = 1001 0110

第二步:数每个字节中“1”的个数,然后确定校验位

奇校验规则: 如果当前字节中“1”的个数是偶数,则校验位设为 1,使总数变为奇数;如果是奇数,则校验位设为 0,保持奇数。

我们逐个来看:

  1. 字节 8F = 1000 1111
    • 数“1”:位置0, 4,5,6,7 → 共 5个1 → 是奇数
    • 所以校验位 = 0
  2. 字节 3C = 0011 1100
    • 数“1”:位置2,3,4,5 → 共 4个1 → 是偶数
    • 所以校验位 = 1
  3. 字节 AB = 1010 1011
    • 数“1”:位置0,2,4,6,7 → 共 5个1 → 是奇数
    • 所以校验位 = 0
  4. 字节 96 = 1001 0110
    • 数“1”:位置0,3,5,6 → 共 4个1 → 是偶数
    • 所以校验位 = 1

所以,对应的4个校验位是:0 1 0 1,即 0101B

第二部分:接收方收到的数据是 87 3C AB 96H 和校验位 0101B,发生了什么?验证过程

现在接收方收到: - 数据:87 3C AB 96H - 校验位:0101B

我们怀疑有错误,因为原始发送的是 8F,但收到的是 87 —— 很可能第一个字节出错了!

我们来逐字节验证奇校验是否成立

第一步:将接收到的数据转为二进制

  • 87 H = 1000 0111
  • 3C H = 0011 1100 (没变)
  • AB H = 1010 1011 (没变)
  • 96 H = 1001 0110 (没变)

校验位:0101B → 对应四个字节的校验位分别是:第1字节 0,第2字节 1,第3字节 0,第4字节 1

第二步:验证每个字节 + 校验位 是否满足奇校验

注意:我们验证的是“数据位 + 校验位”一共9位中,1的个数是否为奇数。

  1. 第一字节 87 + 校验位 0
    • 数据位:1000 0111 → “1”的个数:位置0, 5,6,7 → 4个1
    • 加上校验位 0 → 总共还是 4个1 → 是偶数
    • 不满足奇校验!→ 出错!
  2. 第二字节 3C + 校验位 1
    • 数据位:0011 1100 → “1”的个数:4个(偶数)
    • 加上校验位 1 → 总共 4+1=5 → 奇数
    • 正确
  3. 第三字节 AB + 校验位 0
    • 数据位:1010 1011 → “1”的个数:5个(奇数)
    • 加上校验位 0 → 总共 5 → 奇数
    • 正确
  4. 第四字节 96 + 校验位 1
    • 数据位:1001 0110 → “1”的个数:4个(偶数)
    • 加上校验位 1 → 总共 5 → 奇数
    • 正确

结论:只有第一个字节校验失败!说明在传输过程中,第一个字节发生了错误。

进一步分析:哪里出错了?

原始发送的是 8F H = 1000 1111

接收的是 87 H = 1000 0111

对比:

1
2
3
4
原始: 1 0 0 0  1 1 1 1
接收: 1 0 0 0 0 1 1 1

第5位(从左数第5位,或从右数第4位)由1变成了0

所以,第5位发生了翻转错误(bit flip)

🧠 总结

  1. 原始数据 8F 3C AB 96H 的校验位是 0101B
  2. 接收方收到 87 3C AB 96H0101B 后,发现第一个字节校验失败,说明该字节在传输中发生了错误(具体是第5位由1变0)。
  3. 奇校验能检测单个比特错误,但不能纠正它,也不能检测偶数个比特错误。
IMG_20251029_093747

第三章作业

已知 x = 10, y = -6,采用 6位机器数表示。请按如下要求计算并把结果还原成真值。

(1)求 [x + y]补[x - y]补 (2)用原码一位乘法计算 [x × y]原 (3)用布斯算法计算 [x × y]补 (4)用加减交替法计算 [x / y]原 的商和余数

image-20251029194504411
image-20251029185146990
IMG_20251031_171709

第七章作业

1

image-20251126152551121

DRAM 的全称是 Dynamic Random Access Memory,中文叫 动态随机存取存储器

它是计算机中最常用的主存储器(Main Memory / RAM),也就是我们平时常说的“内存条”上的核心芯片。

为什么 DRAM 单元要刷新?(物理原理)

核心原因:漏电。

你可以把 DRAM 的存储单元想象成一个底部有个小针眼的“水桶”(电容),而数据就是“水”(电荷)。

  • 存入数据 “1”: 你给桶里倒满水。
  • 存入数据 “0”: 你把桶倒空。
  • 问题来了: 因为那个小针眼(晶体管的漏电流),满桶的水会慢慢往下漏。
    • 如果你不管它,过一会儿(比如 2ms 后),桶里的水漏干了,原本的 “1” 就变成了 “0”,数据就丢了。

这里最容易混淆的是两个时间概念,必须分清楚:

  1. 最大刷新时间(题目中的 2ms):
    • 这是死线 (Deadline)
    • 意思是:对于每一个水桶,管理员最迟必须每隔 2ms 回来检查一次。如果超过 2ms 没管它,水就漏光了。
  2. 产生刷新信号的间隔(题目要求的答案):
    • 这是管理员处理下一行水桶的频率
    • 关键点: 管理员不能一次性同时刷新几百万个水桶(那样电路会过载,电流太大)。他必须一批一批地(一行一行地)轮流刷新。
adf3c730cec214bcbd0b690a8632d0e1

2

image-20251127234744026

ROM 的全称是 Read-Only Memory,中文叫 只读存储器

核心特点:

  • 字面意思: “Read-Only” 意味着只能读,不能写(或者说在正常工作状态下不能随意写入)。
  • 实际特性(最重要):非易失性 (Non-Volatile)
    • RAM (DRAM/SRAM): 一断电,数据立马消失。
    • ROM: 断电后,数据依然保存。哪怕你把电脑关机放一年,ROM 里的数据也不会丢。

为什么 ROM 总是放在内存地址的最前面(从 0 开始)?

  1. 开机第一件事: 当你按下电脑电源键时,RAM(内存条)里是空的(全是乱码或 0),CPU 无法从 RAM 里读取指令。
  2. 启动引导: CPU 设计为通电后自动去地址 0000H(或其他固定地址)找第一条指令。
  3. 固化程序: 我们必须在这个位置放一个断电也不丢数据的存储器(ROM),里面存着电脑的启动程序(BIOS/UEFI)
    • 它负责检测硬件、初始化系统,然后把操作系统(Windows/Linux)从硬盘搬到 RAM 里。

$\overline{\text{MREQ}} = 0$(即处于低电平/有效状态)时,它的主要作用是通知系统,CPU 当前正打算对内存(RAM 或 ROM)进行访问。

$R/\overline{W}$ (Read / Write Control)

  • 角色:这是 CPU 发出的控制信号(总线信号)。
  • 含义:它告诉整个系统,CPU 当前想要控制数据流向的方向。
  • 状态逻辑
    • 高电平 (1) = Read (读):CPU 准备从总线上接收数据(数据流向:内存 CPU)。
    • 低电平 (0) = Write (写):CPU 准备向总线上发送数据(数据流向:CPU 内存)。

通俗理解:这是 CPU 在喊话:“大家都听好了,我现在是要‘收东西’(Read)还是要‘发东西’(Write)。”

$\overline{WE}$ (Write Enable)

  • 角色:这是 RAM 芯片上的输入引脚(控制端)。
  • 含义:允许写入信号。它决定了内存芯片是否允许将数据总线上的电平记录到存储单元中。
  • 符号含义:顶部的横线表示低电平有效 (Active Low)
  • 状态逻辑
    • 低电平 (0) = 允许写入:内存芯片打开“大门”,将数据总线上的数据存入当前地址指向的单元。
    • 高电平 (1) = 禁止写入:内存芯片处于读取模式(配合其他信号)或待机模式,不会修改内部存储的数据。

通俗理解:这是内存芯片身上的一个开关。只有把这个开关拉下来(变低),内存才会乖乖地把数据“记下来”。

d11ce94cd3db7edfb1cd4a3fa6dadf39

3

image-20251127234735106
91e3e3a11a15b2854641380b834e3326

4

image-20251127234807626
DECFBD2ECFC7C36C6D9BB49DFD0C15C2

5

image-20251127234908094
bc48f7ff158bdc49537455d0a2d5efbd
d4cd56bf63b45d5da02ce2f13eb06edf

6

image-20251127234930226
90b6c6f2d8d12022fa0bfa2d8bbcf775

第四章作业

1

哪些寻址方式下的操作数在寄存器中?哪些在存储器中?

  • 操作数(Operand):指令执行时要处理的数据。
  • 寻址方式(Addressing Mode):指明操作数在哪里(寄存器?内存?立即数?)以及如何找到它。
  • 寄存器(Register):CPU内部的高速存储单元。
  • 存储器(Memory):主存(RAM),速度比寄存器慢,但容量大。
寻址方式 操作数位置 举例
寄存器寻址 ✅ 寄存器 MOV AX, BX
立即寻址 ❌ 不在寄存器/存储器(在指令中) MOV AX, 5
直接寻址 ✅ 存储器 MOV AX, [2000H]
寄存器间接寻址 ✅ 存储器 MOV AX, [BX]
寄存器相对寻址 ✅ 存储器 MOV AX, [BX + 10]
基址变址寻址 ✅ 存储器 MOV AX, [BX + SI]
相对基址变址寻址 ✅ 存储器 MOV AX, [BX + SI + 5]

⚠️ 注意:立即数(Immediate) 不是寄存器也不是存储器,它直接嵌入在指令中。

2

什么是 RISC?

RISC = Reduced Instruction Set Computer(精简指令集计算机)

与之相对的是 CISC(Complex Instruction Set Computer,复杂指令集计算机),比如 Intel x86。

1️⃣ 指令集精简(Reduced Instruction Set)

  • 指令数量少(通常几十到几百条),每条指令功能单一。
  • 指令长度固定(如 32 位),便于流水线处理。

💡 类比:就像厨房里只有几把多功能刀具,每把刀只做一件事,但做得又快又好。

例子:

  • RISC:ADD R1, R2, R3 → 把 R2 和 R3 相加,结果存入 R1
  • CISC:MOV [BX+SI+10], AX → 一条指令完成地址计算+内存写入

2️⃣ 指令执行周期短(Single-Cycle Execution)

  • 大多数指令在一个时钟周期内完成。
  • 通过简化指令和硬件设计实现。

3️⃣ 大量通用寄存器(Large Register File)

  • 寄存器数量多(如 ARM 有 16 个通用寄存器,RISC-V 有 32 个)。
  • 减少对内存的访问,提高速度。

4️⃣ 采用流水线技术(Pipelining)

  • 指令执行分为多个阶段(取指、译码、执行、访存、写回),并行处理。
  • RISC 指令简单统一,非常适合流水线。

5️⃣ 加载/存储架构(Load/Store Architecture)

  • 只有 LOADSTORE 指令可以访问内存。
  • 其他运算指令只能在寄存器之间进行。

3

假定某计算机中有一条转移指令,采用相对寻址方式,共占两字节,第一字节是操作码,第二字节是 相对位移量(用补码表示),CPU每次从内存只能取一字节。假设执行到某转移指令时 PC的内容为200, 执行该转移指令后要求转移到100开始的一段程序执行,则该转移指令第二字节的内容应该是多少?

QQ20251206-170833

4

4.假设地址为1200H的内存单元中的内容为12FCH,地址为12FCH的内存单元的内容为38B8H,而 38B8H单元的内容为88F9H。说明以下各情况下操作数的有效地址是多少? (1)操作数采用变址寻址,变址寄存器的内容为252,指令中给出的形式地址为1200H。 (2)操作数采用一次间接寻址,指令中给出的地址码为1200H。 (3)操作数采用寄存器间接寻址,指令中给出的寄存器编号为8,8号寄存器的内容为1200H。

QQ20251206-170838

5

6.某计算机指令系统采用定长指令字格式,指令字长16位,每个操作数的地址码长6位。指令分二 地址、单地址和零地址3类。若二地址指令有k2条,零地址指令有k0条,则单地址指令最多有多少条?

QQ20251206-170843

第五章作业

1.

CPU的基本组成和基本功能各是什么?

1. CPU 的基本组成 (Basic Composition)

CPU 在硬件上主要由 数据通路 (Datapath)控制器 (Control Unit) 两大部分组成。

  • 数据通路 (Datapath)
    • 定义:它是 CPU 中负责数据处理和信号传递的路径,“干活的肌肉”。
    • 包含的核心部件
      • 运算部件 (ALU):负责算术运算(加减)和逻辑运算(与或)。
      • 寄存器堆 (Register File):CPU 内部的高速存储单元,用于暂存操作数和运算结果(如 rs, rt, rd)。
      • 程序计数器 (PC):存着当前或下一条指令的地址,是指令流动的“指针”。
      • 其他连接部件:包括多路选择器 (MUX)、加法器 (Adder)、符号扩展单元 (SignExt) 等,用于连通电路和辅助计算。
  • 控制器 (Control Unit)
    • 定义:它是 CPU 的“大脑”,指挥数据通路工作。
    • 功能:根据指令的操作码 (Opcode) 和功能码 (Func),生成各种控制信号(如 RegDst, ALUSrc, MemWr 等),控制 MUX 的选通和寄存器的读写。

2. CPU 的基本功能 (Basic Functions)

CPU 的功能就是执行指令,这一过程通常被称为 “指令周期”。结合 PPT 中的流程,基本功能可以概括为以下四点:

  1. 指令控制 (Instruction Control)
    • 取指令:控制程序按照顺序执行 (PC + 4) 或进行跳转 (beq/j)。确保 CPU 能自动地、连续地从存储器中取出指令。
  2. 操作控制 (Operation Control)
    • 译码与指挥:对取出的指令进行译码(分析 Opcode),产生相应的控制信号,指挥各个部件按规定的动作工作(例如:看到 lw 就打开内存读开关,看到 add 就指挥 ALU 做加法)。
  3. 时间控制 (Time Control)
    • 时序同步:对各种操作实施时间上的定时。通过 时钟信号 (Clk) 来同步所有部件的动作(例如:规定在时钟上升沿更新 PC,在下降沿写入寄存器),保证计算机有条不紊地工作。
  4. 数据加工 (Data Processing)
    • 运算:利用 ALU 对数据进行算术运算(如 add)和逻辑运算(如 ori),这是 CPU 最根本的功能。

2. 取指令部件的功能是什么?

根据 PPT image_373184.png,取指令部件 (Instruction Fetch Unit) 主要有两个核心功能:

  1. 取指令:根据程序计数器 (PC) 中的地址,从指令存储器 (Instruction Memory) 中读取出当前的指令字 (M[PC])。
  2. 更新 PC:计算下一条指令的地址。通常情况下是顺序执行 (PC <- PC + 4);如果是跳转或分支指令,则更新为目标跳转地址。

3. 控制器的功能是什么?

根据 PPT image_2f0f80.png 和 image_2f0cd7.png 中展示的真值表逻辑:

控制器的功能是“翻译”和“指挥”。它接收指令中的 操作码 (op) 和 功能码 (func),通过译码逻辑,产生一系列的 控制信号 (如 RegDst, ALUSrc, RegWr, MemtoReg 等)。这些信号控制着数据通路中各个多路选择器 (MUX)、ALU 和寄存器的动作,确保指令按预定逻辑执行。


4. 单周期处理器相关问题

  • 单周期处理器的 CPI 是多少?
    • CPI = 1
    • 原因:顾名思义,单周期处理器每条指令都在一个时钟周期内完成。
  • 时钟周期如何确定?
    • 时钟周期取决于 最复杂指令(通常是 lw)的执行时间,也就是数据通路中的 关键路径 (Critical Path)
    • 计算公式Tcycle 取指时间 + 译码/读寄存器时间 + ALU运算时间 + 读数据存储器时间 + 写回时间。必须保证最慢的指令也能在一个周期内跑完。
  • 为什么单周期处理器的性能差?
    • 因为 时钟周期被“最慢的指令”拖累了
    • 解释:虽然像 addbeq 这样的简单指令本来可以跑得很快(比如只要 400ps),但因为时钟周期必须迁就最慢的 lw 指令(比如 600ps),所以简单指令执行完后必须 空转等待,直到周期结束。这就造成了巨大的时间浪费,导致整体效率低下。

5. 单周期方式下,在一个指令周期内某个部件能否被重复使用多次?为什么?

  • 回答不能
  • 为什么
    • 在单周期设计中,指令的执行像水流一样流过数据通路,每个部件在每个周期内只能处理这一个指令流过的数据。
    • 如果需要重复使用某个功能(例如需要做两次加法:一次算 PC+4,一次算 ALU 运算),硬件上必须 设置多份独立的部件(例如设置专门的加法器用于 PC 更新,见 image_2dc282.png 中的 Adder),而不能复用同一个 ALU。
    • 原因总结:如果复用,会产生 资源冲突 (Structural Hazard),因为在一个时钟周期内,一个部件无法同时处理两个不同的任务。

6

image-20251227162818244
image-20251227162825596
指令 含义 格式 核心动作 关键区分点
add 加法 op rs rt rd shamt func Reg + Reg -> Reg rd
sub 减法 op rs rt rd shamt func Reg - Reg -> Reg rd
ori 逻辑或 op rs rt imm Reg OR ZeroExt(Imm) 零扩展
lw 取数 op rs rt imm Mem[Base+Off] -> Reg 读内存, 写 rt
sw 存数 op rs rt imm Reg -> Mem[Base+Off] 写内存
beq 分支 op rs rt imm if (Reg==Reg) Jump 比较, 条件跳
j 跳转 op target Goto Target 无条件, 拼接地址

1. RegWr (寄存器写使能)

  • 正常功能
    • 1:允许将数据写入寄存器堆(用于 R-type, ori, lw)。
    • 0:禁止写入(用于 sw, beq, j)。
  • 若恒为 0:寄存器堆的大门永远关上了,任何数据都写不进去。
  • 受影响指令:所有需要写回结果的指令,包括 R型指令 (add, sub 等)orilw。它们虽然能算出结果,但存不下来,相当于白忙活。

2. RegDst (写入目标寄存器选择)

  • 正常功能
    • 1:写入目标是 rd (指令 11-15 位),用于 R型指令
    • 0:写入目标是 rt (指令 16-20 位),用于 lw, ori
  • 若恒为 0:写入数据的目的地永远被锁定在 rt
  • 受影响指令R型指令 (add, sub 等)。这些指令本该把结果存入 rd,现在却错误地覆盖了 rt 寄存器里的内容。
    • 注:lwori 本来就要选 0,所以它们不受影响。

3. ALUSrc (ALU 源操作数选择)

  • 正常功能
    • 1:ALU 的第二个输入选 立即数 (用于 lw, sw, ori)。
    • 0:ALU 的第二个输入选 寄存器 B (用于 R-type, beq)。
  • 若恒为 0:ALU 永远看不见立即数,只能看见寄存器 B。
  • 受影响指令lw, sw, ori。这些指令需要用到立即数(算地址或做逻辑运算),信号错误会导致 ALU 使用错误的操作数进行计算。

4. Branch (分支信号)

  • 正常功能
    • 1:表示当前是分支指令,如果 Zero=1 则更新 PC 为跳转目标。
    • 0:表示顺序执行 PC+4。
  • 若恒为 0:与门永远输出 0,PC 永远无法加载分支目标地址。
  • 受影响指令beq。即使比较结果相等(应跳转),CPU 也会无视,继续执行下一条指令,导致分支失效。

5. MemWr (存储器写使能)

  • 正常功能
    • 1:允许向数据存储器写入数据 (用于 sw)。
    • 0:只读不写。
  • 若恒为 0:数据存储器变成了“只读”模式。
  • 受影响指令sw。该指令的功能就是存数,写不进去就完全失效了。

6. ExtOp (扩展模式选择)

  • 正常功能
    • 1:进行 符号扩展 (Sign Extension),用于 lw, sw (处理负偏移量)。
    • 0:进行 零扩展 (Zero Extension),用于 ori
  • 若恒为 0:所有立即数都按“零扩展”处理。
  • 受影响指令lwsw
    • 如果偏移量是正数,运气好还能对;但如果偏移量是 负数,零扩展会把它变成一个巨大的正数,导致计算出的内存地址完全错误。

7. R-type (R型指令标识)

  • 正常功能:这通常是一个译码信号。当它为 1 时,ALU 控制器会去查看 func 字段来决定具体做什么运算(加、减、与、或等)。当它为 0 时,ALU 控制器通常执行默认操作(如加法,服务于 lw/sw)。
  • 若恒为 0:ALU 控制器认为当前“不是 R型指令”,因此忽略 func 字段,直接执行默认操作(通常是 ADD)。
  • 受影响指令除了 add 以外的所有 R型指令 (sub, and, or, slt 等)。它们会被错误地执行成加法。

8. MemtoReg (写回数据来源选择)

  • 正常功能
    • 1:写回寄存器的数据来自 存储器 (用于 lw)。
    • 0:写回寄存器的数据来自 ALU (用于 R-type, ori)。
  • 若恒为 0:写回通路永远连着 ALU,断开了存储器。
  • 受影响指令lw。该指令本想把从内存读出的数据存进寄存器,结果却错误地把内存地址(ALU 计算结果)存进去了。

信号 (恒为1) 不能正确执行的指令 简要原因
RegWr sw, beq, j 意外修改寄存器内容
RegDst lw, ori 结果存错寄存器 (rd 而非 rt)
ALUSrc R-type, beq 误用立即数代替寄存器运算
Branch 运算结果为0的指令 意外发生跳转
MemWr 除 sw 外所有指令 意外修改内存数据
ExtOp ori 逻辑运算的高位扩展错误
R-type lw, sw, beq, ori ALU 执行了错误的操作
MemtoReg R-type, ori 写入了内存值而非计算结果

第六章作业

2(1)

1. 为什么“一条指令”的执行时间没有缩短?

流水线技术并不是让“单条指令跑得更快”,而是将一条指令拆分成多个步骤(如 IF, ID, EX, MEM, WB)。

  • 寄存器延迟(Overhead):在流水线的每个阶段之间,都需要加入流水线寄存器来保存中间结果。这些寄存器本身有读写延迟(建立时间、保持时间)。
  • 木桶效应:流水线的时钟周期取决于最慢的那一个阶段。
    • 假设非流水线执行一条指令需要 800ps。
    • 将其切分为 5 段流水线,每段 160ps。但因为要加上寄存器延迟(假设 10ps),周期可能变成了 170ps。
    • 那么一条指令走完 5 个阶段总共需要 170 × 5 = 850ps
  • 结论:单条指令从进入流水线到流出,所花费的总时间(潜伏期,Latency)反而因为寄存器的开销而略微增加了。

2. 为什么“程序”的执行时间缩短了?

流水线的核心优势在于并行(Parallelism)*和*吞吐率(Throughput)

  • 并行处理:虽然一条指令要 5 个周期才做完,但当第一条指令在做“第二步”时,第二条指令已经开始做“第一步”了。

  • 吞吐率提升

    • 非流水线:每 800ps 才能完成一条指令。
    • 流水线:一旦流水线填满,每 170ps 就能有一条指令完成(理想情况下)。
  • 数据证明:

    参考你之前的 PPT image_edf843.png:

    • 单周期方式执行 N 条指令:时间 = 600N ps。
    • 流水线方式执行 N 条指令:时间 = 234N ps。
    • 结论234N < 600N,程序的总执行时间被显著缩短了。

2(5)

(1) 为什么要各流水段之间加寄存器?

简单来说,流水线寄存器(Pipeline Registers)是实现“流水线”功能的物理基础。

  • 保存中间结果(记忆功能):每个时钟周期结束时,当前阶段完成的工作(比如计算出的地址、读出的寄存器值)必须被保存下来,才能在下一个时钟周期传给下一阶段使用。如果没有寄存器,信号会瞬间流过整个电路,变成单周期处理器,无法并行。
  • 隔离各个阶段(解耦):寄存器像一堵墙,把复杂的组合逻辑电路切成了 5 小段。这样,第 1 段在取指时,第 2 段可以同时在译码,互不干扰。
  • 同步时钟:所有的流水线寄存器由同一个时钟信号控制,确保所有指令像排队一样,整齐划一地每过一个周期向前挪一步。

(2) 各流水段寄存器的宽度是否都一样?

不一样。

(3) 为什么?

因为每个阶段需要传递给后续阶段的信息量是不同的

流水线寄存器的宽度取决于后面所有的阶段需要用到哪些信息。所有的信息(包括数据信号和控制信号)都必须像接力棒一样,一层一层往下传。

我们可以具体拆解一下(以标准的 MIPS 32位 5级流水线为例):

  1. IF/ID 寄存器(通常最窄)
    • 只需要保存:指令本身(32位) + 下一条指令地址 PC+4(32位)。
    • 总宽约 64位
  2. ID/EX 寄存器(通常最宽)
    • 这一步刚把指令翻译完,需要把所有原材料都带上:读出的两个寄存器数据(32+32位)、扩展后的立即数(32位)、PC+4(32位)、写回的目标寄存器号(5位),再加上一大堆控制信号(WB, MEM, EX 段的信号)。
    • 总宽往往超过 100位
  3. EX/MEM 寄存器
    • ALU用完的数据不需要传了,但算出的结果要传:ALU计算结果(32位)、准备写入内存的数据(32位)、写回目标寄存器号(5位)以及剩下的控制信号。
    • 宽度变小。
  4. MEM/WB 寄存器
    • 内存操作完,只需要传:读取的内存数据(32位)、ALU结果(32位)、写回目标寄存器号(5位)以及最后的 WB 控制信号。
    • 宽度进一步变化。

3

image-20260103173952347
image-20260103173719006

首先,我们根据题目给出的数据,列出当前各个阶段的耗时:

  • IF (取指):使用存储单元 200ps
  • ID (译码):寄存器堆读 50ps
  • EX (执行):ALU 和加法器 150ps
  • MEM (访存):使用存储单元 200ps
  • WB (写回):寄存器堆写 50ps

当前基准时钟周期:

Tclk = max (200, 50, 150, 200, 50) = 200ps

目前的瓶颈是 IF 和 MEM 阶段。

(1) 若 EX 阶段的 ALU 时间缩短 20%

  • 计算:新的 ALU 时间 = 150ps × (1 − 20%) = 120ps
  • 分析:此时流水线各段最长时间依然是 IF 和 MEM 的 200ps。EX 阶段本来就不是瓶颈(150ps < 200ps),缩短它只会让它等待的时间更长(气泡更多)。
  • 结论不能加快流水线执行速度。时钟周期仍然维持在 200ps

(2) 若 ALU 操作时间增加 20%

  • 计算:新的 ALU 时间 = 150ps × (1 + 20%) = 180ps
  • 分析:虽然 ALU 变慢了,但 180ps 仍然小于最慢的存储器访问时间(200ps)。流水线的“短板”依然是存储器。
  • 结论:对流水线性能没有影响。时钟周期仍然维持在 200ps

(3) 若 ALU 操作时间增加 40%

  • 计算:新的 ALU 时间 = 150ps × (1 + 40%) = 210ps
  • 分析:此时 EX 阶段的时间(210ps)超过了原本的瓶颈(200ps)。
    • 旧瓶颈:200ps
    • 新瓶颈:210ps
    • 根据木桶效应,时钟周期必须迁就最慢的阶段。
  • 结论:流水线性能会下降。时钟周期必须调整为 210ps,导致整体运行速度变慢。

4

image-20260103175033363

(1) 在非流水线处理器上执行该程序需要花多长时间?

非流水线处理器采用串行执行方式,上一条指令完全做完,下一条才开始。

  • 计算公式总时间 = 指令条数 × 单条指令执行时间

  • 计算过程:

    Tnon-pipe = 106 × 100ps = 108ps

  • 单位换算:

    108ps = 100, 000ns = 100μs

答案: 需要花 100μs(或者写 108ps)。

(2) 若新 CPU 采用 20 级流水线,理想情况下,它比非流水线处理器快多少?

这道题可以从两个角度回答,一个是计算加速比,一个是计算具体时间。题目问的是“快多少”(倍数),通常指加速比。

  • 理想流水线假设

    1. 流水线各段 perfectly balanced(完全平衡),每段耗时相等。
    2. 没有流水线填充时间的损耗(当 N 很大时,k − 1 个填充周期可忽略)。
  • 流水线时钟周期:

    $$T_{\text{clk}} = \frac{\text{非流水线指令时间}}{\text{流水线级数}} = \frac{100\text{ps}}{20} = 5\text{ps}$$

  • 流水线总执行时间:

    Tpipe ≈ N × Tclk = 106 × 5ps = 5 × 106ps

    (注:精确公式为 (N + k − 1) × Tclk,但 106 ≫ 19,故忽略不计)

  • 计算加速比 (Speedup):

    $$\text{Speedup} = \frac{T_{\text{non-pipe}}}{T_{\text{pipe}}} = \frac{100 \times 10^6\text{ps}}{5 \times 10^6\text{ps}} = 20$$

答案: 理想情况下,它比非流水线处理器快 20 倍。

(一句话结论:理想情况下,多少级流水线,就获得多少倍的加速比)

(3) 实际流水线并是不是理想的,流水段之间的数据传送会有额外开销。这些开销是否会影响指令执行时间和指令吞吐率?

这是一个考察对流水线“潜伏期(Latency)”和“吞吐率(Throughput)”概念理解的问题。

回答:是的,会有影响。 具体分析如下:

  1. 对“指令执行时间”(Latency)的影响: 会变长。
    • 定义:指一条指令从进入流水线第一段到最后一段完成所需要的总时间。
    • 分析:在非流水线中,时间纯粹是逻辑电路的操作时间(100ps)。在流水线中,为了保存中间结果,每一级之间都要加入流水线寄存器。这些寄存器有建立时间、保持时间等物理延迟(Overhead)。
    • 结果:一条指令在流水线中走完 20 级,总耗时 = 20 × (理想每级时间 + 寄存器延迟)。这个总和一定大于原来的 100ps
  2. 对“指令吞吐率”(Throughput)的影响: 会降低(相比理想流水线)。
    • 定义:指单位时间内完成的指令数量($\text{TP} = \frac{1}{T_{\text{clk}}}$)。
    • 分析:流水线的时钟周期 Tclk 必须足够长,以容纳最慢的一段逻辑操作加上寄存器的延迟开销。
    • 结果:实际时钟周期 Tactual = 5ps + Overhead。因为分母变大了,所以吞吐率 $\frac{1}{T_{\text{actual}}}$ 会比理想吞吐率 $\frac{1}{5\text{ps}}$ 要低。

总结答案:

会影响。

  • 指令执行时间(单条延迟):会增加(因为加入了流水线寄存器的延迟开销)。
  • 指令吞吐率:会降低(相比于没有开销的理想流水线,因为时钟周期被迫变长了)。

6

image-20260103175023088

1. 哪些指令对之间发生数据相关(Data Dependency)?

我们要找出“写后读”(RAW, Read After Write)的依赖关系。

  • 第一组相关:$s3
    • 产生者:第 1 条指令 addu $s3, $s1, $s0 (写入 $s3
    • 消费者:第 2 条指令 addu $t2, $s3, $s3 (读取 $s3
    • 描述:第 2 条指令依赖第 1 条指令的结果。
  • 第二组相关:$t2
    • 产生者:第 2 条指令 addu $t2, $s3, $s3 (写入 $t2
    • 消费者:第 3 条指令 lw $t1, 0($t2) (读取 $t2 作为基地址)
    • 以及:第 4 条指令 add $t3, $t1, $t2 (读取 $t2
    • 描述:第 3 条和第 4 条指令都依赖第 2 条指令的结果。
  • 第三组相关:$t1
    • 产生者:第 3 条指令 lw $t1, 0($t2) (写入 $t1
    • 消费者:第 4 条指令 add $t3, $t1, $t2 (读取 $t1
    • 描述:第 4 条指令依赖第 3 条指令的结果。

2. 如果不用“转发”技术,需要加几条 NOP?

规则分析: 在没有转发机制的标准 5 段流水线中,数据必须在 WB(写回) 阶段写入寄存器堆后,才能在 ID(译码) 阶段被后续指令读取。

  • 写操作发生在第 5 个时钟周期(WB)。
  • 读操作发生在第 2 个时钟周期(ID)。
  • 这意味着,产生数据的指令必须先完成 WB 阶段,使用数据的指令才能进入 ID 阶段。对于相邻的两条相关指令,中间通常需要间隔 2 个时钟周期(即插入 2 条 NOP)。

具体插入情况

  1. 在第 1 条和第 2 条之间(解决 $s3 相关): 需要插入 2 条 NOP

    Plaintext

    1
    2
    3
    4
    addu $s3, $s1, $s0
    nop
    nop
    addu $t2, $s3, $s3
  2. 在第 2 条和第 3 条之间(解决 $t2 相关): 需要插入 2 条 NOP

    Plaintext

    1
    2
    3
    4
    addu $t2, $s3, $s3
    nop
    nop
    lw $t1, 0($t2)
  3. 在第 3 条和第 4 条之间(解决 $t1 相关): 需要插入 2 条 NOP

    Plaintext

    1
    2
    3
    4
    lw   $t1, 0($t2)
    nop
    nop
    add $t3, $t1, $t2

    (注:虽然第 4 条也依赖第 2 条的 $t2,但因为中间已经隔了第 3 条指令和它的 NOP,所以 $t2 的数据早就准备好了,不需要额外处理)

结论(无转发): 需要在每组发生数据相关的相邻指令前加入 2 条 NOP 指令。

3. 如果采用“转发”,是否可以完全解决数据冒险?

回答:不可以。

原因分析(Load-Use 冒险)

  • ALU 到 ALU 的冒险(如第 1、2 条之间,第 2、3 条之间)可以通过转发完全解决。数据在 EX 阶段算出后,可以直接从流水线寄存器转发给下一条指令的 EX 阶段输入。
  • Load 到 ALU 的冒险(即第 3 条 lw 和第 4 条 add 之间)无法完全解决。
    • lw 指令的数据是在 MEM(访存) 阶段结束时才从内存读出的(时钟周期 4)。
    • add 指令需要在 EX(执行) 阶段开始时使用数据进行计算(时钟周期 3)。
    • 时间悖论:数据在未来(第 4 周期)才产生,但现在(第 3 周期)就要用。这是物理上做不到的,这被称为 Load-Use Hazard

4. 如果不行,需要加入几条 NOP?

针对上述的 Load-Use 冒险,必须让流水线暂停一个周期,等待数据从内存读出。

  • 位置:在第 3 条 lw 和第 4 条 add 之间。
  • 数量1 条 NOP
    • 加了 1 条 NOP 后,add 指令晚一个周期进入 EX 阶段,此时 lw 刚好走完 MEM 阶段,数据可以通过 MEM/WB 寄存器转发给 add

最终代码序列(采用转发)

MIPS Assembler

1
2
3
4
5
addu $s3, $s1, $s0
addu $t2, $s3, $s3 # 转发解决,无 NOP
lw $t1, 0($t2) # 转发解决,无 NOP
nop # 【必须加 1 条 NOP 解决 Load-Use 冒险】
add $t3, $t1, $t2

结论(有转发): 需要在发生数据相关的第 4 条指令(add)之前加入 1 条 NOP 指令。

真值 (X)补码 ([X]) 之间的三大转换规则

1. 真值 补码 (X → [X])

这是最基础的编码过程。

  • 正数: 符号位设为 0,数值部分完全照抄,不变。
  • 负数: 符号位设为 1,数值部分执行 “各位取反,末位加 1”

2. 补码 真值 ([X] → X)

这是解码过程。PPT 强调了正负数的处理逻辑是对称的:

  • 符号位为 0(正数): 数值部分直接就是真值。
  • 符号位为 1(负数): 数值部分再次执行 “各位取反,末位加 1”
    • 点评:这验证了你之前的发现——求负数补码的原码,操作逻辑和求补码是一模一样的!

3. 求相反数的补码 ([X] → [−X])

这是数学上的变号操作(例如从 5 的补码求 −5 的补码)。

  • 规则:[X]所有位(注意 PPT 写的:符号位参与运算)执行 “各位取反,末位加 1”
    • 注意: 这里就是我们刚才说的“整体取反法”。

💡 核心规律总结 (PPT 右下角方框的含义)

PPT 右下角的方框其实在告诉你,以下三个操作用的是同一个核心算法(取反加一):

  1. X → [−X]:把一个正数真值变成负数补码。
  2. [X] → X:把一个负数补码还原成真值(数值部分)。
  3. [X] → [−X]:已知一个数的补码,求它相反数的补码。

一句话总结这张 PPT:

在补码系统中,只要涉及“负数”或者“变号”,核心法宝就是 “各位取反,末位加 1”。

奇偶校验法

为了让你彻底明白,我们用一个最简单的ASCII 字符传输作为例子。

假设我们要传输字母 “A”。

字母 “A” 的二进制 ASCII 码是:1000001(共 7 位)。

现在,我们要给它加上第 8 位——校验位


1. 原始数据分析

  • 数据: 1 0 0 0 0 0 1
  • 数一数“1”的个数: 这里有 2 个 1。
  • 判断: 2 是偶数

2. 奇校验 (Odd Parity) 的例子

目标: 加上校验位后,整个串里“1”的总数必须是 奇数

  • 现状: 现在有 2 个 1(偶数)。

  • 操作: 既然还差一个 1 才能变成奇数,所以校验位必须填 1

  • 最终发送的数据:

    1000001 + 1 (校验位) 10000011

  • 验证: 总共有 3 个 1 奇数 ✅ 合格。


3. 偶校验 (Even Parity) 的例子

目标: 加上校验位后,整个串里“1”的总数必须是 偶数

  • 现状: 现在有 2 个 1(已经由偶数了)。

  • 操作: 不需要再加 1 了(否则就变成奇数了),所以校验位填 0

  • 最终发送的数据:

    1000001 + 0 (校验位) 10000010

  • 验证: 总共有 2 个 1 偶数 ✅ 合格。


4. 它是怎么发现错误的?(以奇校验为例)

假设我们使用 奇校验,发送了 10000011(3个1,正常)。

场景一:传输正常

  • 接收方收到: 10000011
  • 检查: 数一下有 3 个 1(奇数)。
  • 结论: 校验通过,数据应该是对的。

场景二:发生 1 位错误(比如第1位传丢了,1变成了0)

  • 接收方收到: 00000011 (原本的第一个1变成了0)
  • 检查: 数一下,现在只有 2 个 1(偶数)。
  • 结论: 不对! 我们约定好是奇校验的,怎么变成偶数了?报错!

场景三:发生 2 位错误(这是奇偶校验的死穴)

  • 假设第1位和最后一位都翻转了。
  • 接收方收到: 00000010
  • 检查: 数一下,有 1 个 1(奇数)。
  • 结论: 接收方会以为数据是对的(因为1的个数还是奇数),但实际上数据已经错得离谱了。

总结表格

原始数据 (1的个数) 校验方式 校验位填什么? 最终结果 (数据+校验) 最终1的个数
1000001 (2个, 偶) 奇校验 1 10000011 3 (奇)
1000001 (2个, 偶) 偶校验 0 10000010 2 (偶)
1110000 (3个, 奇) 奇校验 0 11100000 3 (奇)
1110000 (3个, 奇) 偶校验 1 11100001 4 (偶)

海明校验码(略有涉及)

基于我们刚才的深度探讨,我为你整理了一份“海明校验码核心总结”。这份总结略过了死板的教科书定义,专门针对你刚才产生的核心顿悟(关于位置、倒序、多位错误等)进行梳理。

你可以把它当作一份复习备忘录:


1. 核心本质:它是一个“坐标定位系统”

海明码不只是简单的加减法,而是利用二进制权重(1, 2, 4, 8…)构建了一套坐标系。

  • 目的: 不仅告诉你“错了”,还能精准地告诉你“第几位错了”。

2. 生成阶段:如何布阵?

  • 确定人数: 使用公式 2r ≥ k + r + 1
    • 你的理解: 必须有足够的校验位(状态数),才能覆盖所有可能出错的位置 + 1种全对的情况。
  • 站位: 校验位必须站在 2n (1, 2, 4, 8…) 的位置上。
    • 原因: 它们是二进制的“基石”,负责构建坐标轴。
  • 计算: 每一个校验位负责监管“二进制位索引中包含自己权重”的那些数据位(利用偶校验原则填充 0 或 1)。

3. 校验阶段:如何抓内鬼?

这是你刚才理解最深刻的部分。

  • 操作逻辑: 不区分数据位和校验位,将收到的所有位作为一个整体进行计算。
  • 计算校正子(故障码):
    • 重新计算 P1, P2, P4… 各自辖区内 1 的个数。
    • 偶数 记 0。
    • 奇数 记 1。
  • 关键步骤: 倒序排列S4S2S1)。
    • 原因: 二进制习惯高位在左。

4. 判决阶段:结果意味着什么?

根据算出的“故障码”(二进制数转十进制):

  • 结果 = 0:
    • 结论: 传输成功(或者发生了极小概率的偶数位相互抵消)。
  • 结果 = X (非0):
    • 结论:X 位出错了。
    • 动作: 无论第 X 位是数据还是校验码,直接将其取反(0变1,1变0),数据即修复。

5. 致命盲区:如果错了 2 位怎么办?

这是我们最后讨论的重点。

  • 普通海明码: 会“撒谎”。两个错误叠加会算出一个错误的坐标,导致系统去修改一个无辜的正确位(越改越错)。
  • 解决方案(SECDED): 增加第 8 个“全校验位”
    • 如果海明码说有错(故障码非0),但全校验位说没错(整体偶校验正常)。
    • 结论: 发生了双比特错误。系统会报警拒绝处理,而不是乱修。

一句话心法:

海明码就是把校验位安插在二进制权重点上,接收时重算一遍奇偶性,算出来的二进制数就是出错位置的坐标。

现在的你已经完全具备了手算海明码甚至去写代码实现它的逻辑基础了!需要我给你出一道终极测试题练练手吗?

原码一位乘法

image-20260104224832194

它的核心思想只有一句话:“符号单独算,数值当无符号数算”。


1. 核心步骤

原码一位乘法把整个过程拆成了两个独立的任务:

任务一:处理符号位 (简单)

  • 规则: 同号得正,异号得负。

  • 运算: 用异或门 (XOR) 完成。

    Ps = Xs ⊕ Ys

    (例如:0 ⊕ 1 = 1,结果为负)

任务二:处理数值位 (核心)

  • 规则: 既然符号已经拿走了,剩下的绝对值 |X||Y| 其实就是两个正整数(或者说无符号数)。
  • 算法: 直接套用你最开始学的“无符号乘法”(列竖式法)。
    • Y 的最后一位。
    • 是 1 |X|
    • 是 0 加 0。
    • 逻辑右移(前面补0,因为是绝对值运算,不涉及负数符号维持)。

2. 举例演示

假设我们要计算 X × Y

  • X = −0.1101 (原码 1.1101)
  • Y = +0.1011 (原码 0.1011)

第一步:定符号

1(X符号) ⊕ 0(Y符号) = 1(负)

把这个 1 存起来,最后贴到结果上。

第二步:算数值 (1101 × 1011)

这部分完全就是无符号乘法

  • 被乘数 |X| = 1101
  • 乘数 |Y| = 1011
  • 部分积 P 初始化为 0000
轮次 Y 末位 动作 P (部分积) Y (乘数) 说明
初始 1 0000 1011
1 1 +X 1101 0000 + 1101
逻辑右移 0110 1101 高位补0!
2 1 +X 10011 0110 + 1101 = 10011
逻辑右移 1001 1110 进位1移进来
3 0 +0 1001 不加
逻辑右移 0100 1111 高位补0
4 1 +X 10001 0100 + 1101 = 10001
逻辑右移 1000 1111 进位1移进来

注意:这里的右移全部是逻辑右移(左边补0),因为我们在算绝对值,绝对值永远是正的。

第三步:拼接结果

  • 数值部分: PY 拼起来 1000 1111
  • 加上小数点: 0.10001111
  • 加上符号(第一步算的): 1.10001111

布斯算法

布斯乘法算法(Booth’s Multiplication Algorithm)是一种用于计算带符号二进制数(通常使用补码表示)的乘法算法。

相较于你之前了解的“原码一位乘法”(主要处理无符号数或需要单独处理符号位),布斯乘法可以直接对补码进行操作,不需要将符号位和数值位分开计算,这使得硬件电路设计更加统一和高效。

1. 核心原理

布斯乘法通过检查乘数的最后一位Q0)和辅助位Q−1,初始为0)的状态变化来决定操作。它利用了“连续的1序列可以转换为一次减法和一次加法”的数学性质(例如 99 = 100 − 1),从而减少运算次数。

2. 运算规则表

每次检查乘数的最后两位(Q0, Q−1),执行以下操作,然后进行算术右移

Q0 (当前位) Q−1 (辅助位) 操作 (针对部分积 A) 说明
0 0 不操作,直接右移 连续的0,无需处理
0 1 + 被乘数 (M),然后右移 遇到1序列的结束
1 0 - 被乘数 (M),然后右移 遇到1序列的开始
1 1 不操作,直接右移 连续的1,中间无需处理

注意:这里的“- 被乘数”通常通过“+ [-M]补”来实现。

3. 计算步骤演示

假设我们用 4位补码计算:2 × (−3) = −6

  • 被乘数 (M) = 2 0010
    • (−M)补 = 1110 (用于减法操作)
  • 乘数 (Q) = −3 1101
  • 累加器 (A) = 0000 (初始化为0)
  • 辅助位 (Q−1) = 0
  • 计数器 = 4 (因为是4位)

演算过程:

步骤 Q0,Q−1 操作 累加器 A 乘数 Q 辅助位 Q−1 说明
初始 0000 1101 0
1 1, 0 A = A - M (即 A + 1110) 0000 + 1110 = 1110 1110 1101 0 减被乘数
算术右移 1111 0110 1 符号位保持不变
2 0, 1 A = A + M (即 A + 0010) 1111 + 0010 = 0001 0001 0110 1 加被乘数
算术右移 0000 1011 0
3 1, 0 A = A - M (即 A + 1110) 0000 + 1110 = 1110 1110 1011 0 减被乘数
算术右移 1111 0101 1
4 1, 1 无操作 1111 0101 1
算术右移 1111 1010 1

最终结果:

将 A 和 Q 拼接起来(不看 Q−1):1111 1010。

这是补码形式,转换为十进制:

  • 符号位是1,取反加1得到原码:-(0000 0101 + 1) = - (0000 0110) = -6
  • 结果正确。
image-20260105213531851

恢复余数法

image-20260105094107032

加减交替法

原码除法 恢复余数法和不恢复余数法(加减交替法) 计组_哔哩哔哩_bilibili

整数除法处理过程_哔哩哔哩_bilibili

整数不恢复余数除法中,被除数通常要进行位扩展

不恢复余数除法(加减交替法)*中,**整数除法**和*小数除法的核心算法是一样的,都是根据余数的正负决定商位和下一步操作。但它们在初始设置、精度控制、终止条件、结果处理等方面有明显区别。

定点整数的除法 - 知乎

空间局部性和时间局部性

这两个概念是计算机系统(特别是缓存 Cache)能够高效工作的基石。如果程序员写的代码符合这两个特性,程序的运行速度可能会快几十倍甚至上百倍。

简单来说,CPU 跑得飞快,内存(RAM)跑得比较慢。为了不让 CPU 傻等,我们需要猜测 CPU 接下来要用什么数据,并提前把它搬到离 CPU 最近的缓存里。

局部性原理(Principle of Locality) 就是我们用来猜测的“依据”。


1. 时间局部性 (Temporal Locality)

一句话解释: “如果一个数据现在被访问了,那么它在不久的将来很可能再次被访问。”

  • 关键词: 重复利用 (Reuse)。

  • 生活类比:

    你正在写作业(CPU工作),桌子上放着一本参考书。如果你刚查阅了某一页,你通常不会马上把它放回书架(内存),而是留就在手边(缓存)。因为你下一分钟大概率还会再看它一眼。

代码实例

看看下面这个简单的循环:

C

1
2
3
4
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
  • 变量 sumi 在每一次循环中,这两个变量都被反复读取和修改。
  • 指令本身: 循环体内的机器指令(代码本身)也是被一遍又一遍地执行。

硬件是怎么做的?

因为有时间局部性,CPU 会把 sum 和 i 以及循环内的指令死死地抓在 L1 Cache(一级缓存) 或者 寄存器 里,绝对不会每次都去慢吞吞的内存条里找。


2. 空间局部性 (Spatial Locality)

一句话解释: “如果一个数据被访问了,那么它附近的数据在不久的将来很可能也会被访问。”

  • 关键词: 邻居 (Nearby)。

  • 生活类比:

    你去图书馆(内存)借一本《哈利波特第1部》。虽然你现在只要第1部,但管理员(缓存控制器)通常会顺手把第2、3、4部也一起拿给你。因为按常理,看完第1部的人,大概率会接着看第2部,而不是突然跳去看旁边书架的《母猪的产后护理》。

代码实例

这是最典型的数组遍历:

C

1
2
3
4
5
6
7
int arr[1000];
// ... 假设数组里有数据 ...

for (int i = 0; i < 1000; i++) {
// 按顺序访问:arr[0], arr[1], arr[2]...
printf("%d", arr[i]);
}
  • 数组 arr 在内存中,数组的元素是连续存放的。arr[0] 的隔壁就是 arr[1]
  • 访问模式: 当程序请求 arr[0] 时,CPU 知道你有“空间局部性”的倾向。

硬件是怎么做的?

当 CPU 去内存取 arr[0] 时,内存条并不是只给一个数字,而是直接给一个 “缓存行” (Cache Line)(通常是 64 字节)。

这意味着,虽然你只喊了 arr[0],但 arr[1] 到 arr[15] 可能已经被顺手打包带进了 CPU 的缓存。当你下一秒要用 arr[1] 时,它已经在缓存里等着你了,速度极快。


3. 一个经典的“反面教材”

为了让你感受到空间局部性的威力,我们看一个经典的二维数组遍历的例子。

假设有一个巨大的二维数组 matrix[10000][10000]。在 C/C++ 语言中,数组是按行存储的(Row-major),也就是说,第一行的数据在内存里是连在一起的,接着是第二行。

写法 A(利用空间局部性 - 快):

1
2
3
4
5
6
// 按行遍历:外层是行(i),内层是列(j)
for (int i = 0; i < 10000; i++) {
for (int j = 0; j < 10000; j++) {
sum += matrix[i][j];
}
}
  • 分析: 访问顺序是 [0][0], [0][1], [0][2]...。这在内存里是连续的。缓存一次加载一行,命中率极高。

写法 B(破坏空间局部性 - 慢):

1
2
3
4
5
6
// 按列遍历:外层是列(j),内层是行(i)
for (int j = 0; j < 10000; j++) {
for (int i = 0; i < 10000; i++) {
sum += matrix[i][j];
}
}
  • 分析: 访问顺序是 [0][0], [1][0], [2][0]...
  • 问题: [0][0][1][0] 在内存中相隔了整整一行(10000个元素)!
  • 后果: 每次访问都像是“大跳跃”。CPU 加载了 [0][0] 及其附近的缓存行,结果你下一步要的是十万八千里外的 [1][0]缓存全部失效(Cache Miss)。这会导致程序运行极其缓慢,甚至慢上几十倍。

cache行和主存块之间的映射方式

1. 直接映射 (Direct Mapping)

口诀: “死板、对号入座”

这是最简单的规则。我们规定:根据车牌号的末尾数字,只能停在固定的车位。

规则公式

Cache =  mod  Cache

举个例子

假设 Cache 只有 4 个车位(0, 1, 2, 3)。

主存有很多块(0, 1, 2, 3, 4, 5…)。

  • 第 0 号主存块: 0 mod  4 = 0 只能停 0号 车位。
  • 第 1 号主存块: 1 mod  4 = 1 只能停 1号 车位。
  • 第 4 号主存块: 4 mod  4 = 0 也要停 0号 车位!
  • 第 8 号主存块: 8 mod  4 = 0 还要停 0号 车位!

优缺点

  • 优点: 简单!你想找第4号块,只用去0号车位看一眼,不在就是不在,不用找别处。电路实现最便宜。

  • 缺点(致命): 冲突太容易发生了。

    如果你写了一段程序,反复需要访问“第0块”和“第4块”。

    • CPU要用第0块 把0停进0号位。
    • CPU要用第4块 把0踢走,把4停进0号位。
    • CPU又要用第0块 把4踢走,把0停进0号位。
    • 这就是“抖动”(Thrashing),明明旁边 1, 2, 3 号车位空着,但它们死活不能停。

2. 全相联映射 (Fully Associative Mapping)

口诀: “自由、随便停”

这是最灵活的规则。我们规定:只要有空位,想停哪儿停哪儿。

规则

没有公式。主存块可以放在 Cache 的任意一个行中。

举个例子

还是 4 个车位。

  • 第 0 号块来了 停在 0 号位。
  • 第 4 号块来了 0号位有人了?没关系,停在 1 号位。
  • 第 8 号块来了 停在 2 号位。

优缺点

  • 优点: 空间利用率极高,只要Cache没满,就不会发生冲突踢人的情况。

  • 缺点: 找车太慢(太贵)。

    当你(CPU)想找“第4号块”时,你不知道它在哪里。你必须同时检查所有的车位(0,1,2,3…)。

    这需要非常复杂的硬件电路(并行比较器),如果 Cache 很大,这种电路极其昂贵且耗电。


3. 组相联映射 (Set Associative Mapping)

口诀: “中庸之道、分组管理”

这是现代 CPU(如 Intel Core, AMD Ryzen)普遍采用的方式。它折中了前两者的方案。

它把 Cache 分成了若干个“组” (Set),每个组里包含几个车位(比如 2 个或 4 个)。

规则

  1. 先定位组(像直接映射): 你必须去指定的组。

     =  mod  

  2. 再定位置(像全相联): 到了那个组之后,组内的车位随便停

这被称为 N-路 组相联 (N-way Set Associative),这里的 N 就是一个组里有几个车位。

举个例子(2路组相联)

假设 Cache 还是 4 个车位,但我们将它们分为 2 个组(Set 0 和 Set 1)。

  • Set 0 包含:车位0、车位1
  • Set 1 包含:车位2、车位3

现在我们要停 第 4 号主存块

  1. 算组号: 4 mod  2(组数) = 0。所以必须去 Set 0
  2. 找位子: 到了 Set 0,发现里面有“车位0”和“车位1”。只要这两个有一个是空的,第4号块就能停进去。

优缺点

  • 优点:
    • 比直接映射灵活:就算 Set 0 里已经停了“第0号块”,我“第4号块”来了还能停在 Set 0 的另一个空位,不会打架。
    • 比全相联便宜:查找时,只需要在特定的组内(比如搜4个位置)找,不需要全场搜。
  • 地位: 这是工业界的标准答案

直接映射中,主存地址的结构

image-20260104151532082

1. 地址结构的宏观样子

假设 CPU 发出的地址是 N 位二进制数(比如 32位)。在直接映射模式下,它被切分为:

$$\text{主存地址} = \underbrace{\text{标记 (Tag)}}_{\text{高位}} \quad | \quad \underbrace{\text{行索引 (Index)}}_{\text{中间}} \quad | \quad \underbrace{\text{块内偏移 (Offset)}}_{\text{低位}}$$

每一段都有其特定的使命。


2. 深度拆解:每一段是干嘛的?

为了方便理解,我们设定一个具体的场景:

  • 地址总长度: 32位
  • Cache 大小: 4 KB (212 字节)
  • 块大小 (Block Size): 64 Byte (26 字节)

根据这个配置,我们来计算每一段的长度和作用。

第一部分:块内偏移 (Block Offset) —— “具体的字节在哪?”

  • 位置: 地址的最低位。

  • 作用: Cache 和主存交换数据是以“块”为单位的(一拿就是 64B)。但 CPU 通常只需要其中的某 1 个字节。偏移量就是告诉 CPU,你要的数据在这个“块”里的第几个位置。

  • 怎么算位数?

    看块大小。

    这里块大小是 64 Byte = 26 Byte。

    所以,需要 6 位 来表示 0~63 的位置。

    • Offset = 6 bits

第二部分:行索引 (Cache Line Index) —— “停在哪个车位?”

  • 位置: 地址的中间部分。

  • 作用: 这是直接映射的核心。它决定了这块数据必须存放在 Cache 的第几行(第几个车位)。

  • 怎么算位数?

    看 Cache 一共有多少行。

    $$\text{Cache行数} = \frac{\text{Cache总容量}}{\text{块大小}}$$

    在本例中:4KB/64B = 4096/64 = 64 行。

    64 行 = 26

    所以,需要 6 位 来定位这 64 个行(000000 到 111111)。

    • Index = 6 bits

第三部分:标记 (Tag) —— “你是谁?”

  • 位置: 地址的最高位。

  • 作用: 因为有很多个主存块都会映射到同一个 Cache 行(冲突)。当 CPU 拿着地址去查看那个 Cache 行时,发现里面已经存了数据,它怎么知道里面的数据是不是它想要的那个?

    靠比对 Tag! Tag 就像身份证,证明身份。

  • 怎么算位数?

    剩下的位数都是 Tag。

    Tag位数 = 总位数 − 索引位 − 偏移位

    在本例中:32 − 6 − 6 = 20 位。

    • Tag = 20 bits

直写和回写

1. 直写 (Write-Through)

口诀: “老实人、立刻同步”

原理

每当 CPU 要往 Cache 里写数据时,它会同时把数据写回主存。 即:写 Cache + 写主存 同时进行。

  • 生活比喻: 你每花一笔钱,不仅记在草稿本上,还立刻跑去老板办公室,把正式账本也更新了。

优缺点

  • 优点(简单可靠):
    • 一致性极好: 主存里的数据永远是最新的。
    • 实现简单: 既然主存总是新的,当 Cache 里的这块数据需要被踢走(替换)时,直接丢弃就行,不需要做任何操作。
  • 缺点(慢):
    • 速度受拖累: CPU 跑得快,但主存太慢了。每次写操作 CPU 都要等主存写完,大大降低了速度。
    • 总线太忙: 哪怕只改了一个字节,都要往主存发一次信号,总线带宽被占满了。

补救措施:写缓冲 (Write Buffer)

为了不让 CPU 傻等主存,通常会在 CPU 和主存之间加一个“写缓冲队列”。 CPU 把数据扔进缓冲队列就立刻溜走去干别的,由硬件慢慢把队列里的数据写进主存。


2. 回写 (Write-Back)

口诀: “懒人智慧、秋后算账”

原理

当 CPU 要写数据时,只修改 Cache 里的内容立刻去改主存。 只有当这块数据在 Cache 里待不住了,要被踢走(替换/Evict)的时候,才把它写回主存。

  • 关键角色:脏位 (Dirty Bit) Cache 行中需要增加一个标记位,叫“脏位”。
    • Dirty = 1: 说明这行数据被修改过,和主存不一样(脏了)。
    • Dirty = 0: 说明这行数据没被改过,和主存一样(干净)。
  • 生活比喻: 你花钱时,只在手里的草稿本上改。老板那边的正式账本先不管。 等到你的草稿本写满了,必须换新本子时(发生替换),你才看一眼旧本子:
    • 如果本子上全是乱涂乱画(脏位=1),你就把最终结果一次性汇报给老板。
    • 如果本子上很干净没动过(脏位=0),直接把本子扔了就行,不用找老板。

优缺点

  • 优点(极快):

    • 速度快: 写操作都在高速 Cache 里完成,CPU 爽飞了。
    • 带宽省: 如果你对同一个变量 i 做了 100 次 i++,直写要访问主存 100 次;而回写只需要在最后踢出时访问 1 次主存。
  • 缺点(复杂、有风险):

    • 硬件复杂: 需要维护“脏位”。

    • 短暂的不一致: 在被写回之前,主存里的数据是旧的。

    • I/O 风险: 如果此时磁盘(DMA)直接去读主存,可能会读到旧数据(需要额外的同步机制来解决)。

    • 1. 直写 (Write-Through)

      口诀: “老实人、立刻同步”

      原理

      每当 CPU 要往 Cache 里写数据时,它会同时把数据写回主存。 即:写 Cache + 写主存 同时进行。

      • 生活比喻: 你每花一笔钱,不仅记在草稿本上,还立刻跑去老板办公室,把正式账本也更新了。

      优缺点

      • 优点(简单可靠):
        • 一致性极好: 主存里的数据永远是最新的。
        • 实现简单: 既然主存总是新的,当 Cache 里的这块数据需要被踢走(替换)时,直接丢弃就行,不需要做任何操作。
      • 缺点(慢):
        • 速度受拖累: CPU 跑得快,但主存太慢了。每次写操作 CPU 都要等主存写完,大大降低了速度。
        • 总线太忙: 哪怕只改了一个字节,都要往主存发一次信号,总线带宽被占满了。

      补救措施:写缓冲 (Write Buffer)

      为了不让 CPU 傻等主存,通常会在 CPU 和主存之间加一个“写缓冲队列”。 CPU 把数据扔进缓冲队列就立刻溜走去干别的,由硬件慢慢把队列里的数据写进主存。


      2. 回写 (Write-Back)

      口诀: “懒人智慧、秋后算账”

      原理

      当 CPU 要写数据时,只修改 Cache 里的内容立刻去改主存。 只有当这块数据在 Cache 里待不住了,要被踢走(替换/Evict)的时候,才把它写回主存。

      • 关键角色:脏位 (Dirty Bit) Cache 行中需要增加一个标记位,叫“脏位”。
        • Dirty = 1: 说明这行数据被修改过,和主存不一样(脏了)。
        • Dirty = 0: 说明这行数据没被改过,和主存一样(干净)。
      • 生活比喻: 你花钱时,只在手里的草稿本上改。老板那边的正式账本先不管。 等到你的草稿本写满了,必须换新本子时(发生替换),你才看一眼旧本子:
        • 如果本子上全是乱涂乱画(脏位=1),你就把最终结果一次性汇报给老板。
        • 如果本子上很干净没动过(脏位=0),直接把本子扔了就行,不用找老板。

      优缺点

      • 优点(极快):
        • 速度快: 写操作都在高速 Cache 里完成,CPU 爽飞了。
        • 带宽省: 如果你对同一个变量 i 做了 100 次 i++,直写要访问主存 100 次;而回写只需要在最后踢出时访问 1 次主存。
      • 缺点(复杂、有风险):
        • 硬件复杂: 需要维护“脏位”。
        • 短暂的不一致: 在被写回之前,主存里的数据是旧的。
        • I/O 风险: 如果此时磁盘(DMA)直接去读主存,可能会读到旧数据(需要额外的同步机制来解决)。

7 种基本的寻址方式

1. 立即寻址 (Immediate Addressing)

  • 算法: 操作数 = A
  • 通俗解释: “现钞交易”。数据不存放在内存或寄存器里,而是直接包含在指令代码中。
  • 优点: 速度最快(因为取指令的时候顺便就把数据取到了,不用再去查内存)。
  • 缺点: 数值的大小受限(因为指令字长是有限的,放不下太大的数)。

2. 直接寻址 (Direct Addressing)

  • 算法: EA = A (有效地址 = 指令中给出的地址 A)
  • 通俗解释: “拿钥匙开柜子”。指令直接告诉你数据在内存的哪个房间号(地址)。
  • 优点: 计算简单,不用复杂的变换。
  • 缺点: 地址范围有限(指令里的位数有限,可能访问不了太大的内存空间)。

3. 间接寻址 (Indirect Addressing)

  • 算法: EA = (A) (指令给出的地址 A 里存的不是数据,而是数据的真实地址
  • 通俗解释: “寻宝图的寻宝图”。你去地址 A,发现里面是一张纸条,纸条上写的才是数据真正的藏身之处。
  • 优点: 有效地址范围大(哪怕指令短,也能访问大内存)。
  • 缺点: 慢!因为要访问两次存储器(第一次取地址,第二次取数据)。

4. 寄存器寻址 (Register Addressing)

  • 算法: 操作数 = (R) (数据在寄存器 R 中)
  • 通俗解释: “翻口袋”。数据就在 CPU 自己的口袋(寄存器)里,伸手就来。
  • 优点: 执行极快(不需要访问慢速的内存),指令也很短(寄存器编号很短)。
  • 缺点: 寄存器数量很少,也就是口袋太少,装不下多少东西。

5. 寄存器间接寻址 (Register Indirect Addressing)

  • 算法: EA = (R) (寄存器 R 里存的是数据的内存地址)
  • 通俗解释: “口袋里的钥匙”。CPU 口袋(寄存器)里没数据,但有一把通往内存的钥匙(地址)。
  • 优点: 扩大了寻址范围(比直接寻址强)。
  • 缺点: 比纯寄存器寻址慢,因为还是要访问一次存储器。

6. 偏移寻址 (Displacement Addressing)

这是现代计算机最常用的方式,包含相对寻址、基址寻址、变址寻址三种。

  • 算法: EA = A + (R) (地址 = 形式地址 + 寄存器内容)
  • 通俗解释: “基准点 + 偏移量”。比如“从学校大门(基准)往东走 100 米(偏移)”。它结合了直接寻址和寄存器间接寻址的特点。
  • 优点: 非常灵活(方便做数组访问、程序浮动等)。
  • 缺点: 计算稍微复杂一点。

7. 堆栈寻址 (Stack Addressing)

  • 算法: EA = 
  • 通俗解释: “拿最上面的盘子”。不需要给地址,默认就是操作堆栈最顶端的数据。
  • 优点: 指令极短(甚至不需要地址码)。
  • 缺点: 不灵活,只能按先进后出 (LIFO) 的顺序拿数据。

寻址方式

1. 立即寻址 (Immediate Addressing)

  • 概念: 数据直接包含在指令中。不需要去别处找。

  • 特点: 速度最快,但只能用于常数。

  • 公式: 操作数 = 指令中的立即数

  • 生活比喻: 纸条上写着:“吃掉这个苹果”。苹果直接粘在纸条上。

  • 代码举例 (x86风格):

    代码段

    1
    MOV AX, 2000H  ; 将数字 2000H 直接放入寄存器 AX

    (这里 2000H 就是操作数本身)


2. 寄存器寻址 (Register Addressing)

  • 概念: 数据存放在CPU内部的通用寄存器中。指令给出寄存器编号。

  • 特点: 速度非常快(不需要访问慢速的内存),指令短(寄存器编号短)。

  • 公式: 操作数 = 寄存器内容

  • 生活比喻: 纸条上写着:“吃掉左口袋里的东西”。

  • 代码举例:

    代码段

    1
    MOV AX, BX    ; 将寄存器 BX 里的数据复制到 AX

    (数据在 BX 寄存器里)


3. 直接寻址 (Direct Addressing)

  • 概念: 指令中直接给出数据在内存中的物理地址(或逻辑地址)。

  • 特点: 简单直观,但指令字较长(因为地址通常很长),地址固定,不灵活。

  • 公式: 有效地址 EA = 指令中的形式地址 A

  • 生活比喻: 纸条上写着:“去打开101号柜子,吃掉里面的东西”。

  • 代码举例:

    代码段

    1
    MOV AX, [2000H] ; 将内存地址 2000H 里的数据取出,放入 AX

    (CPU 直接拿着 2000H 去内存找数据)


4. 寄存器间接寻址 (Register Indirect Addressing)

  • 概念: 指令给出一个寄存器,这个寄存器里存的不是数据,而是数据的内存地址

  • 特点: 比直接寻址灵活,只需要修改寄存器的值,就可以访问不同的内存单元(适合遍历数组)。

  • 公式: 有效地址 EA = (寄存器内容)

  • 生活比喻: 纸条上写着:“去查看左口袋里的纸条,那上面写着柜子号”。(假设左口袋里纸条写着101,你就去101号柜子找苹果)。

  • 代码举例:

    代码段

    1
    2
    MOV BX, 2000H   ; 先把地址放入 BX
    MOV AX, [BX] ; CPU去读 BX,发现是 2000H,于是去内存 2000H 取数

5. 隐含寻址 (Implied Addressing)

  • 概念: 指令中不明显给出操作数地址,而是由操作码隐含规定了操作数在哪里。

  • 特点: 指令极短。

  • 生活比喻: 纸条上只写了一个字:“吃!”。(默认你自己知道要吃嘴里的东西,或者拿手里的东西吃,不需要指明)。

  • 代码举例:

    代码段

    1
    CLA   ; Clear Accumulator (清空累加器 ACC)

    (这里没有给出操作数,但CPU默认知道要操作的是 ACC 寄存器)


6. 偏移寻址类 (Displacement Addressing)

这类寻址方式非常重要,它们的共同点是:有效地址 = 寄存器内容 + 形式地址(偏移量)。 根据使用的寄存器不同,分为以下三种:

A. 基址寻址 (Base Addressing)

  • 侧重点: 搬家(程序重定位)
  • 机制: 基址寄存器 (BR) 存放程序的起始地址(由操作系统管理),指令中给出偏移量
  • 公式: EA = (BR) + A
  • 举例:
    • 你的程序被加载到内存的 10000H 位置(BR=10000H)。
    • 指令说“访问第 5 个字节”(A=5)。
    • CPU 实际访问:10005H。

B. 变址寻址 (Indexed Addressing)

  • 侧重点: 数组遍历
  • 机制: 变址寄存器 (IX) 存放数组下标(由用户改变),指令中给出数组首地址
  • 公式: EA = (IX) + A
  • 举例:
    • 指令指定数组首地址是 2000H(A=2000H)。
    • 循环第一次,变址寄存器 IX = 1。CPU 访问 2001H。
    • 循环第二次,变址寄存器 IX = 2。CPU 访问 2002H。

C. 相对寻址 (Relative Addressing)

  • 侧重点: 跳转(分支指令)
  • 机制: 以程序计数器 (PC) 当前的值为基准,向前或向后跳多少步。
  • 公式: EA = (PC) + A
  • 举例:
    • 当前执行到第 100 行代码 (PC=100)。
    • 指令是 JMP +10(向下跳10行)。
    • CPU 计算出目标地址:110 行。

中央处理器:数据通路和控制器

CPU 的工作流程

取指令 (Fetch):去仓库(内存)里把订单(指令)拿出来。

PC+1 送 PC:订单拿到手了,把指针指向下一张订单的位置。(注意:在 MIPS 真实硬件中通常是 PC+4,因为一条指令占4个字节,这里 PPT 用 “PC+1” 是为了简化逻辑,假设按“个”来数指令)。

指令译码 (Decode):看懂订单。比如 “001010” 代表什么?哦,原来是“加法”。

取操作数:加法需要两个数,去寄存器里把这两个数拿出来。

运算/访存:ALU 开始干活(算加法),或者去读写内存。

存结果:把算好的数写回寄存器。

异常/中断检查:干完活了,检查一下有没有出乱子(异常),或者外面有没有人敲门(中断)。

组成指令功能的四种基本操作

Load:内存 寄存器 (去仓库拿材料)。

Store:寄存器 内存 (把成品放回仓库)。

Move:寄存器 寄存器 (左右手倒腾)。

ALU:运算 寄存器 (加工处理)。

读内存R[r] ← M[addr] —— 控制器指挥数据从 Memory 流向 Register。

写内存M[addr] ← R[r] —— 控制器指挥数据从 Register 流向 Memory。

内部搬运R[a] ← R[b] —— 寄存器之间倒手。

运算R[a] ← R[b] + R[c] —— 数据流经 ALU 进行加工。

CPU 的内部架构

CPU 拆成了两个独立但协作的部分:Control(控制器)Datapath(数据通路)

Datapath(数据通路)—— 它是“肢体”

  • 定义:数据流经的路径和部件(如 ALU、寄存器、多路选择器)。
  • 作用:它负责干脏活累活
  • 比喻:它就像是工厂里的流水线设备和工人。你要它搬砖,它就搬砖;要它算加法,它就只能算加法。它自己没有思想,不知道什么时候该干什么。

Control(控制器)—— 它是“大脑”

  • 定义:根据指令(0和1的代码),生成控制信号。
  • 作用:它负责发号施令
  • 比喻:它就是拿着大喇叭的工头。
    • 它看一眼指令(比如是 add),就会对 Datapath 喊:“ALU 准备做加法!寄存器准备存结果!”
    • 它看一眼指令(比如是 load),就会喊:“内存接口打开!把数据读进来!”

总结:我们后面要学的“设计 CPU”,其实就是先画出Datapath(把路铺好),然后设计Control(让信号指挥数据怎么走)。

数据通路——组合逻辑元件

1. 加法器 (Adder)

  • 作用:最简单的数学工。只干一件事:把输入的 A 和 B 相加。
  • 用在哪:专门用来算 PC + 4(计算下一条指令地址),或者算内存地址。
  • 特点:不需要复杂的控制,给电就由 A+B 出结果。

2. 多路选择器 (MUX) —— 非常关键!

  • 通俗理解:它是“铁路道岔”或者“单刀双掷开关”
  • 作用:它有两个输入(A 和 B),但出口(Y)只有一个。谁能通过? 由红色的 Select(控制信号) 决定。
    • 如果 Select 是 0,A 通过。
    • 如果 Select 是 1,B 通过。
  • 为什么重要:CPU 内部经常面临选择。例如:写回寄存器的数据,是来自 ALU 的计算结果,还是来自内存的读取结果?这时候就需要用 MUX 来选。

3. 算术逻辑部件 (ALU)

  • 作用:全能数学工。能做加减乘除,也能做与或非。
  • 控制信号 (OP):这是关键!ALU 到底做“加法”还是“减法”,全靠红色的 OP 信号来指挥。这个 OP 信号就是“控制器”发给它的。
  • 输出 Zero:这是一个反馈信号。如果计算结果是 0,Zero 线这就变 1。这通常用于判断 beq(相等跳转)指令。

4. 译码器 (Decoder)

  • 作用:翻译官。
  • 原理:输入 n 位二进制,输出 2n 条线。比如输入 001,它就让第 1 号线通电;输入 111,就让第 7 号线通电。
  • 用在哪:主要用于寄存器堆(Register File)的选择。比如指令说“读 3 号寄存器”,译码器就把 3 号寄存器的门打开。

存储元件: 寄存器和寄存器组

image-20251224083750131

1. 从“存 1 位”进阶到“存 32 位”

你刚才已经学会了 D 触发器(只能存 1 个 bit,比如 0 或 1)。但现代 CPU(比如 32 位 MIPS)处理数据是一次性处理 32 位的。

  • 寄存器 (Register): 其实就是把 32 个 D 触发器 并排捆在一起。
    • Data In/Out:一次吞吐 32 位数据。
    • Write Enable (WE, 写使能):这是一个“安全锁”
      • 如果是 0:就算时钟边沿(Clock)来了,也不许修改里面的数据。
      • 如果是 1:时钟边沿一来,允许写入新数据。
    • 为什么要这个锁? 因为时钟一直在跳,但我们不是每个周期都要改写这个寄存器,只有需要存结果时才打开锁。

2. 寄存器堆:CPU 的“办公桌”

CPU 只有 1 个寄存器是不够的,MIPS 架构里有 32 个通用寄存器。把这 32 个寄存器堆放在一起,就叫 寄存器堆 (Register File)

这一页重点讲了它的端口(Interface),也就是它的“进出口”:

  • 两个“读”端口 (Read Ports)
    • 输入RA (Read Address A) 和 RB。比如你给 RA 输入 5,给 RB 输入 8。
    • 输出busAbusB。它会立刻吐出 5 号和 8 号寄存器里存的数据。
    • 性质组合逻辑。意味着不需要时钟,不需要排队,只要地址给对了,数据立马出来(类似于你去查字典,翻到哪页字就在那)。
  • 一个“写”端口 (Write Port)
    • 输入RW (Write Address,你要改几号箱子?)、busW (Write Data,你要存什么数据?)、Write Enable (允许修改吗?)。
    • 性质时序逻辑。意味着必须等时钟边沿 (Clock)。就算你把数据准备好了,也没人理你,直到时钟“咔嚓”一声(上升沿),数据才会真正写进那个寄存器。

MIPS的三种指令类型

A. R-Type (Register Type) —— 纯寄存器操作

  • 代表指令add, sub
  • 格式特点
    • 3 个寄存器:它需要读两个源寄存器 (rs, rt),把结果写进目的寄存器 (rd)。
    • func 字段:对于 R 型指令,op(操作码)通常都是 0,真正决定是加还是减的是最后的 func 字段。
  • 硬件暗示:我们需要寄存器堆提供两个读端口,一个写端口。

B. I-Type (Immediate Type) —— 带常数的操作

  • 代表指令
    • 运算类:ori (和常数做或运算)。
    • 访存类:lw, sw (基于偏移量访问内存)。
    • 分支类:beq (如果不相等,跳过多少条指令)。
  • 格式特点
    • 2 个寄存器rsrt
    • 1 个立即数:最后 16 位是一个常数 (immediate)。
  • 硬件暗示
    • 这里没有 rd 了!写回的目标变了(通常变成了 rt)。
    • 这 16 位的数字太短了,而 CPU 是 32 位的。所以硬件里必须有一个 “扩展单元 (Sign/Zero Extender)” 把 16 位变成 32 位。

C. J-Type (Jump Type) —— 飞跃

  • 代表指令j (Jump)
  • 格式特点
    • 除了开头的 op,剩下 26 位全是地址。
  • 硬件暗示:这需要一个特殊的电路,直接把这 26 位塞进 PC (程序计数器) 里,让程序“瞬移”。
image-20251224085707125

取指令部件(Instruction Fetch Unit)

image-20251224100438935

路径一:去拿指令 (Fetch Instruction)

  • PC 寄存器 输出当前的地址(比如 1000)。
  • 这个地址顺着线连到了 Instruction Memory (指令存储器) 的 Address 接口。
  • 存储器立刻吐出地址 1000 里的那行代码 —— Instruction Word (32位指令字)
  • 结果:指令拿到手了,准备送去译码。这就是 RTL 里的 M[PC]

路径二:准备下一条 (Update PC)

  • PC 寄存器 的输出(还是 1000)同时也连到了右边的 Next Address Logic (下地址逻辑)
  • 对于大多数指令,这里的逻辑很简单,就是个加法器:1000 + 4 = 1004
  • 算出来的 1004 会绕回到 PC 的输入端,等待下一个时钟信号到来。
  • 结果:为下一轮循环做好了准备。这就是 RTL 里的 PC <- PC + 4

RR(R-type)型指令的数据通路

image-20251224100901373

阶段一:读数据(原材料进场)

  • 指令拆解
    • 指令的 21-25位 (rs) 连接到寄存器堆的 Ra (Read Address 1) 接口。
    • 指令的 16-20位 (rt) 连接到寄存器堆的 Rb (Read Address 2) 接口。
  • 动作
    • 寄存器堆收到地址后,立刻把这两个寄存器里的数据吐出来,分别送到 busAbusB 线路上。

阶段二:算数据(加工)

  • 流入 ALU
    • busAbusB 里的数据顺着线流进右边的 ALU
  • 控制信号 ALUctr
    • 这时候,控制器(图中虽未画出,但红字提到了)会给 ALU 发一个命令。
    • 如果是 add 指令,ALUctr 信号就是“做加法”。
    • 如果是 sub 指令,ALUctr 信号就是“做减法”。
  • 结果产生:ALU 算完后,结果出现在 Result 线路上。

阶段三:写回结果(成品入库)

这是最关键的一步,形成了一个闭环

  • 数据回流
    • 你看那条长长的线,从 ALU 的 Result 绕了一大圈,回到了寄存器堆左边的 busW (Write Data) 接口。
  • 确定写给谁
    • 指令的 11-15位 (rd) 连接到了 Rw (Write Address) 接口。这告诉寄存器堆:“把结果存进 rd 号格子里”。
  • 关键钥匙 RegWr
    • 光把数据送回来还不行,必须有人开门。
    • RegWr (Register Write) 信号必须置为 1
    • 时钟边沿 (Clk):当时钟“咔嚓”一响(下降沿或上升沿),数据就被永久地锁进 rd 寄存器了。

带立即数的逻辑指令的数据通路

image-20251227130648203

1. 右边的蓝色 MUX:解决“跟谁运算”的问题

请看 ALU 前面的那个蓝色梯形(多路选择器)。

  • 冲突点
    • add 指令想计算:寄存器 A + 寄存器 B
    • ori 指令想计算:寄存器 A or 立即数
    • ALU 的第二个输入端(下端)很为难:我到底该连寄存器 busB,还是连立即数 imm16
  • 解决方案 (ALUSrc MUX)
    • 加一个 MUX!
    • 0号通道busB(给 R-Type 用)。
    • 1号通道ZeroExt 后的立即数(给 I-Type 用)。
    • 控制信号 ALUSrc:如果是 ori 指令,控制器就把这个信号设为 1,强行把立即数送进 ALU。

2. 左边的蓝色 MUX:解决“结果存哪”的问题

请看寄存器堆 Rw(写地址)前面的那个蓝色梯形。这是初学者最容易晕的地方!

  • 冲突点
    • R-Type (add rd, rs, rt):结果要存进 rd(指令的第 11-15 位)。
    • I-Type (ori rt, rs, imm):结果要存进 rt(指令的第 16-20 位)。因为 I 型指令里根本就没有 rd 这个字段!
    • 寄存器堆的 Rw 接口很为难:我到底听谁的?
  • 解决方案 (RegDst MUX)
    • 加一个 MUX!
    • 0号通道rd (11-15位)。
    • 1号通道rt (16-20位)。
    • 控制信号 RegDst:如果是 ori 指令,我们必须把结果存回 rt,所以控制器把信号设为 1

3. 还有一个细节:ZeroExt (零扩展)

  • 看 ALU 下方那个细长的蓝色方框。
  • 为什么是 ZeroExt?
    • 正如我们上一轮讨论的,ori 是逻辑运算,不关心正负号。它只需要把 16 位的立即数前面补 16 个 0,变成 32 位即可。
    • 如果是 lw 指令,这里就会换成 SignExt(符号扩展)。

总结:一张控制信号表

根据这张图,如果要执行 ori rt, rs, imm16,我们的 “大脑”(控制器) 需要发出什么样的指令?(看 PPT 最下方蓝字)

  1. RegDst = 1:告诉寄存器堆,“把结果写进 rt (输入1)”。
  2. RegWr = 1:告诉寄存器堆,“请开门,我要写数据”。
  3. ALUSrc = 1:告诉 ALU,“你的第二个操作数是立即数 (输入1),别看寄存器 B”。
  4. ALUctr = or:告诉 ALU,“做或运算”。

lw (Load Word) 指令的 RTL (寄存器传输级) 深度剖析

1. RTL 流程再回顾 (Standard Steps)

这一页中间的蓝色字展示了 lw 指令执行的四个标准步骤:

  1. 取指 (Fetch)M[PC]。老规矩,把指令搬进 CPU。
  2. 算地址 (Address Calc)Addr <- R[rs] + SignExt(imm16)
    • 这是我们讨论过的重点。用基址寄存器 rs 加上扩展后的偏移量。
  3. 取数据 (Memory Access)R[rt] <- M[Addr]
    • 拿着刚才算的地址,去 数据存储器 (Data Memory) 里把数据读出来,写进 rt 寄存器。
  4. 更新 PCPC <- PC + 4。准备下一条。

2. 核心考点:为什么要“符号扩展”?(Sign Extension)

这是这张图最核心的技术细节。请看 PPT 下方的两个 32 位二进制条形图。

  • 问题:上一张 PPT 讲 ori 指令时用的是 ZeroExt (零扩展),为什么这里非要用 SignExt (符号扩展)
  • 答案:因为 地址偏移量可能是负数
    • 场景 A (正向偏移):如果你想访问 rs 后面 4 字节的地方,imm1600...0100。符号位(第 15 位)是 0。扩展成 32 位时,高位全补 0。这和零扩展没区别。
    • 场景 B (反向偏移):如果你想访问 rs 前面 4 字节的地方,imm16 是负数(比如 -4,补码形式)。符号位(第 15 位)是 1
      • 如果用零扩展:高位补 0,这个负数瞬间变成了一个巨大的正数,地址就指到九霄云外去了。
      • 必须用符号扩展复制符号位。如果第 15 位是 1,那么高 16 位全部填 1。这样 FFFF... 依然代表 -4,保证了数学上的正确性。

装入(lw)指令的数据通路

image-20251227132938059

1. 为什么要加蓝色部分?(The “Why”)

PPT 中间有个大大的问号:“加蓝色部分。为什么?”

  • 新增部件:Data Memory (数据存储器)
    • 原因lw 的核心任务是从内存取数。之前的电路只有 ALU 和寄存器,根本没有“仓库”可去。所以必须加上这个存储器单元。
    • 动作:ALU 算出的结果(比如 1004)不再被当作计算结果,而是变成了地址 (Address) 输入给存储器。
  • 新增部件:MemtoReg MUX (右侧的多路选择器)
    • 原因:这是数据回流的关键路口。
      • 以前做 add 时,写回寄存器的是 ALU 的结果
      • 现在做 lw 时,写回寄存器的是 Memory 的结果
    • 冲突:一条写回线 (busW) 不能同时接两个输入。
    • 解决:加一个开关(MUX)。
      • 通道 0:来自 ALU(给 R-Type 用)。
      • 通道 1:来自 Memory(给 lw 用)。
      • 控制信号 MemtoReg 决定走哪条路。

2. 数据的“长征”路线 (Data Flow)

跟着箭头走,看 lw rt, rs, imm16 是怎么执行的:

  1. 准备数据
    • 从寄存器堆读出 rs (基地址)
    • imm16 (偏移量) 进行 符号扩展 (Sign Ext)(注意图下方的 ExtOp=1,代表 SignExt)。
  2. 计算地址 (ALU Stage)
    • ALUSrc = 1:ALU 的下端输入选择扩展后的立即数。
    • ALUctr = add:ALU 执行加法,计算出 基址 + 偏移
  3. 访问内存 (Memory Stage)
    • ALU 的计算结果连到了 Data Memory 的 Addr 端。
    • MemWr = 0:我们是读内存,不是写内存,所以写使能关闭。
    • 数据从 Data Out 吐出来。
  4. 写回寄存器 (Write Back Stage)
    • MemtoReg = 1:右边的蓝色 MUX 拨到 1 号位,允许内存的数据通过。
    • RegDst = 0:左上角的 MUX 拨到 0 号位。为什么?因为 lw 的目标寄存器是 rt (16-20位),而不是 rd
    • RegWr = 1:最后,寄存器堆的大门打开,数据写入 rt

3. 底部红字:控制信号大揭秘

PPT 最下方列出了执行 lw 所需的一整套“密码”(控制信号),我们来逐个破解:

  • RegDst = 0
    • 写回目标是 rt (指令的 16-20 位),所以选 0 通道。
  • RegWr = 1
    • 需要把取回来的数存进寄存器。
  • ALUctr = add
    • 虽然是取数,但中间需要用加法算地址。
  • ExtOp = 1
    • 关键! lw 的偏移量是有符号的(可能往前偏移),所以必须用符号扩展 (Sign Extension),不能用零扩展。
  • ALUSrc = 1
    • ALU 的第二个操作数是立即数(偏移量),不是寄存器 B。
  • MemWr = 0
    • 保护内存数据,只读不写。
  • MemtoReg = 1
    • 最关键的区别位。这一位告诉 CPU:“这次写回的数据来自内存,不是 ALU”。

存数(sw)指令的数据通路

1. 核心动作:存数 (Store)

看看 PPT 顶部的公式:

M[R[rs] + SignExt[imm16]] ← R[rt]

这句话的意思是:

  1. 算地址:用 rs 里的基地址 + 扩展后的偏移量,算出内存地址(和 lw 一模一样)。
  2. 存数据:把 rt 寄存器里的值,写进刚才算出的那个内存地址里。

2. 蓝色部分:数据怎么流进内存?

图中间有一条醒目的蓝色折线,这是 sw 指令独有的特征:

  • 起点busB
    • 寄存器堆的 Rb 端口读取了 rt 寄存器的数据,送到了 busB 线路上。
    • 注意:在 R-Type 指令里,busB 是送去 ALU 做运算的;但在 sw 指令里,busB 里装的是要存进内存的货物
  • 终点Data Memory 的 Data In 接口
    • 这条线绕过了 ALU,直接插进了数据存储器的“写入口”。
  • 为什么加这一条?
    • 因为 ALU 忙着算地址去了(看 busA 和扩展后的立即数进了 ALU),它没空处理数据。
    • 所以我们需要一条“旁路”,把寄存器里的数据直接送到内存门口等待写入。

3. 控制信号大反转 (The Control Signals)

PPT 底部那一排蓝色的控制信号,有很多独特之处,特别是出现了 x (Don’t Care)

  • MemWr = 1 (最关键!)
    • 这是 sw 的灵魂。必须把“写内存使能”打开,否则数据只会在门口蹭蹭,进不去。
  • RegWr = 0 (千万别写寄存器!)
    • sw 是往外存东西,不修改 CPU 内部的寄存器。
    • 如果这里设为 1,那你就会意外地破坏寄存器里的数据。
  • RegDst = x (无关项)
    • 既然 RegWr 已经是 0 了(寄存器大门紧闭),那么 RegDst 到底是选 rt 还是 rd 根本无所谓,反正也写不进去。这就是“Don’t Care”。
  • MemtoReg = x (无关项)
    • 同理,既然不写回寄存器,那么右边那个 MUX 选内存的数据还是 ALU 的数据也无所谓了。
  • ALUctr = add & ALUSrc = 1 & ExtOp = 1
    • 这三个信号和 lw 完全一样
    • 因为不管是存还是取,“计算内存地址”这个步骤是一模一样的(都是基址 + 符号扩展偏移量)。

条件转移指令的数据通路

image-20251227142847129

第一部分:逻辑分析 (RTL) —— CPU 是怎么“思考”的?

(对应图 image_2da7d8.png)

beq rs, rt, imm16 的核心逻辑分三步走:

  1. 比较 (Compare)
    • RTL: Cond <- R[rs] - R[rt]
    • 原理:计算机比较两个数是否相等,最常用的办法就是做减法
    • 如果 rs - rt 的结果是 0,说明它们相等;否则就不相等。
  2. 决策 (Decision)
    • RTL: if (COND eq 0)
    • 看刚才减法的结果。如果是 0,就跳转;如果不是 0,就忽略,继续执行下一条。
  3. 计算目标地址 (Target Address) —— 这是最难懂的地方!
    • 公式: PC <- PC + 4 + (SignExt(imm16) x 4)
    • 为什么要 PC + 4:MIPS 的分支是相对于“下一条指令”而言的。
    • 为什么要 SignExt:因为你可能往回跳(循环),也就是立即数可能是负数,所以要符号扩展。
    • 为什么要 x 4?(红字思考题)
      • 指令里的 imm16 是以 “字 (Word)” 为单位的(比如“往下跳 2 条指令”)。
      • 但内存地址是以 “字节 (Byte)” 为单位的(每条指令占 4 字节)。
      • 所以硬件上必须把它 左移 2 位 (相当于 ×4),才能变成真实的物理地址偏移量。

第二部分:硬件连线 (Data Path) —— 电路是怎么连的?

(对应图 image_2da7de.png)

为了实现上面的逻辑,我们需要对电路做几个关键调整(看图中的蓝色连线):

1. ALU 的新用法:判零

  • 输入busA (来自 rs) 和 busB (来自 rt)。
  • ALUSrc = 0注意! 这里必须选 0。因为我们是比较两个寄存器,不再是寄存器和立即数运算了(区别于 orilw)。
  • ALUctr = sub:让 ALU 做减法。
  • 输出 Zero:看 ALU 右侧那根蓝色的线 Zero
    • 这是 ALU 的一个特殊输出端口。
    • 当运算结果为 0 时,这根线变成高电平(1)。这根线就是“相等”的信号灯

2. Next Addr Logic(下地址逻辑):CPU 的方向盘

  • 图右边的那个 蓝色方框 是这一页的新增核心。
  • 输入
    1. PC (当前地址)
    2. imm16 (偏移量)
    3. Branch (控制信号:我是分支指令!)
    4. Zero (状态信号:它们相等吗?)
  • 逻辑
    • 如果 Branch = 1 Zero = 1:说明是分支指令且条件满足 跳! (PC = Target Address)。
    • 否则:不跳! (PC = PC + 4)。

3. 控制信号 (Control Signals)

看 PPT 最下方的蓝字设置,非常讲究:

  • RegWr = 0千万别忘了这个! beq 只是看一眼寄存器的值做比较,不修改 任何寄存器。如果设为 1,数据就乱了。
  • ALUSrc = 0:我们要比较 busB (寄存器),不比较立即数。
  • Branch = 1:告诉下地址逻辑,“准备好,可能要变道了”。
  • RegDst, MemtoReg = x:因为都不写寄存器,所以这些无关紧要 (Don’t Care)。

下址逻辑设计方案

image-20251227143858127

1. 核心原理:为什么敢扔掉最后 2 位?

(对应 image_2dc27e.png 的中间文字)

  • 规律:MIPS 指令长 32 位(4 字节)。因此,所有指令的内存地址一定是 4 的倍数(0, 4, 8, 12, …)。
  • 二进制特征
    • 0 -> 0000
    • 4 -> 0100
    • 8 -> 1000
    • 12 -> 1100
  • 发现:不管怎么变,二进制的最后两位永远是 00
  • 优化思路:既然最后两位永远是 0,存在寄存器里也是浪费空间,算加法时还要多算两位。不如 PC 寄存器只存高 30 位

2. 新的数学逻辑:把“米”变成“步”

(对应 image_2dc27e.png 的蓝色公式)

如果我们把地址看作“步数”(Words)而不是“字节数”(Bytes):

A. 顺序执行 (PC + 4 变成 PC + 1)

  • 旧逻辑 (32位)PC ← PC + 4
    • (比如从 1000 跳到 1004)。
  • 新逻辑 (30位)PC ← PC + 1
    • (因为丢掉了两个 0,二进制的 100 00 变成了 100。加 1 就变成了 101,也就是原来的 101 00)。
    • 好处:加法器只需要加 1,电路更简单。

B. 分支跳转 (x 4 消失了?)

  • 旧逻辑PC + 4 + (Imm16 × 4)
  • 新逻辑PC + 1 + Imm16
    • 还记得立即数 Imm16 本来存的就是“几条指令”吗?
    • 以前我们需要把它 ×4 变成字节地址,再跟 32 位 PC 相加。
    • 现在 PC 本身存的就是“几条指令”(30位),所以直接相加就行了!不用移位了!

3. 硬件电路怎么连?(视觉解读)

(对应 image_2dc282.png)

请跟着图里的线走,看看这 30 位 PC 是怎么还原成 32 位地址去取指的:

  1. PC 寄存器 (左上角)
    • 你看那个 PC 盒子,旁边写着 30。它只存 30 个 bit。
  2. 第一个加法器 (蓝色 “Adder”)
    • 输入是 “1”
    • 它计算 PC + 1,也就是下一条指令的“行号”。
  3. 第二个加法器 (Branch Adder)
    • PC + 1 的结果,加上 SignExt 出来的立即数。
    • 注意:这里没有 Shift Left 2 模块了!直接加。
  4. 最右边的拼接 (Concatenation)
    • 这是最骚的操作。
    • 我们拿着算好的 30 位地址去访问 Instruction Memory。
    • Addr<31:2>:来自 PC 算出来的 30 位。
    • Addr<1:0>:硬连线接死,就是 “00”
    • 结果:30 位 + “00” = 完整的 32 位物理地址。

取指令部件 (Instruction Fetch Unit)

image-20251227144444179

第一步:逻辑分析 (RTL) —— j 指令是怎么跳的?

(对应图 image_2e277d.png)

指令格式j target

  • Target (26位):指令中剩下的 26 位全部用来存目标地址。

核心公式:

PC < 31 : 2 >  ← PC < 31 : 28> concat target < 25 : 0>

这个公式在做什么?

这是一个 “拼凑法” (Pseudo-direct Addressing)。

  1. 高 4 位 (PC<31:28>):保留当前 PC 的高 4 位。这意味着 j 指令不能跳得太远,只能在当前 256 MB (228 字节) 的区域内跳转。
  2. 低 26 位 (target<25:0>):直接用指令里带的 26 位地址替换掉原来的低位。
  3. 拼接 (concat):把这 4 位和 26 位拼起来,刚好组成新的 30 位 PC 值。

这也是个优化点!

在标准 32 位设计中,target 通常需要左移 2 位 (×4) 才能用。但在这里,因为我们的 PC 本身存的就是“字地址”(第几行),所以不需要移位,直接拿过来拼上去就行!


第二步:硬件实现 —— 最终版取指部件

(对应图 image_2e27ba.png)

这张图展示了集大成者的电路。请注意图中新增的蓝色部分

1. 拼接逻辑 (Top Blue Oval)

  • 输入 1:从 PC 寄存器引出的线,取最高 4 位 (30位里的高4位)。
  • 输入 2:从指令 (Instruction Word) 里直接截取的低 26 位。
  • 动作:这两组线在电路板上物理合并,变成一束 30 位的线。

2. Jump MUX (右边的那个多路选择器)

这是 “最高优先级” 的开关。

  • 位置:它放在了 Branch MUX 的后面。
  • 逻辑
    • 0号通道:来自前面的 Branch 逻辑(可能是 PC+1,也可能是 beq 跳转)。
    • 1号通道:来自刚才拼出来的 Jump 目标地址。
  • 控制信号 Jump
    • 一旦控制器发现是 j 指令,把 Jump 设为 1
    • 后果:不管前面算出了什么(比如 beq 想不想跳),Jump 说了算,强制把 PC 修改为跳转目标。

指令与控制信号的关系表

1. 主控制信号真值表 (Main Control Table)

这是根据 op (操作码) 产生的控制信号:

信号名 (Signal) R-type(add, sub) ori lw sw beq jump
Opcode 000000 001101 100011 101011 000100 000010
RegDst 1 (rd) 0 (rt) 0 (rt) x x x
ALUSrc 0 (RegB) 1 (Imm) 1 (Imm) 1 (Imm) 0 (RegB) x
MemtoReg 0 (ALU) 0 (ALU) 1 (Mem) x x x
RegWrite 1 (写) 1 (写) 1 (写) 0 (不写) 0 (不写) 0 (不写)
MemWrite 0 (不写) 0 (不写) 0 (不写) 1 (写) 0 (不写) 0 (不写)
Branch 0 0 0 0 1 (分支) 0
Jump 0 0 0 0 0 1 (跳转)
ExtOp x 0 (零扩) 1 (符扩) 1 (符扩) x x
ALUOp (注1) R-Type Or Add Add Sub xxx

图例说明:

  • 1: 信号有效(如选中 MUX 的 1 号通道,或使能写入)。
  • 0: 信号无效(如选中 MUX 的 0 号通道,或禁止写入)。
  • x (Don’t Care): 任意值。因为在该指令下,这个信号控制的部件结果不会被使用(例如 sw 指令不写寄存器,所以 RegDst 选谁都无所谓)。

2. ALU 控制逻辑 (ALU Control Logic)

对于 R-Type 指令,ALU 具体做什么操作(加还是减),不仅取决于主控制信号,还取决于指令末尾的 func (功能码)

这是 PPT image_2f0f80.png 底部展示的二级解码逻辑:

指令类型 ALUOp (来自主控) func (来自指令) ALUctr (输出给ALU) 操作含义
lw / sw Add (000) xxxxxx Add 计算地址
beq Sub (100) xxxxxx Sub 比较相等
ori Or (010) xxxxxx Or 逻辑或
R-type (add) R-Type (XXX) 100000 Add (001) 加法运算
R-type (sub) R-Type (XXX) 100010 Sub (101) 减法运算

3. 关键信号复习 (根据表格总结)

  • RegDst (寄存器目标)
    • 1 (R-Type):结果存入 rd (11-15位)。
    • 0 (lw/ori):结果存入 rt (16-20位)。
  • ALUSrc (ALU源)
    • 0 (R-Type/beq):ALU 第二个操作数来自 寄存器
    • 1 (lw/sw/ori):ALU 第二个操作数来自 立即数
  • ExtOp (扩展操作)
    • 1 (lw/sw):符号扩展 (SignExt),用于计算地址偏移。
    • 0 (ori):零扩展 (ZeroExt),用于逻辑运算。
  • MemtoReg (内存到寄存器)
    • 1 (lw):写入寄存器的数据来自 内存
    • 0 (R-Type/ori):写入寄存器的数据来自 ALU

什么是流水线处理器

. 核心原理:从“串行”到“并行”

在传统的单周期处理器中,一条指令必须彻底跑完“取指、译码、执行、访存、写回”所有环节,下一条指令才能开始。而流水线处理器将这些环节切分开:

  • 并行化:当第一条指令进入“执行”阶段时,第二条指令可以同时进入“译码”阶段,第三条指令进入“取指”阶段。
  • 理想状态:在每个时钟周期的末尾,流水线的终点都会“吐出”一条执行完毕的指令。

2. MIPS 的标准五级流水线

典型的 MIPS 处理器将指令处理分为 5 个标准步骤(Stage):

  1. IF (Instruction Fetch):从指令存储器中读取指令。
  2. ID (Instruction Decode):翻译指令含义,并从寄存器堆读取操作数。
  3. EX (Execute):使用 ALU 进行算术/逻辑运算,或计算内存地址。
  4. MEM (Memory Access):如果是 lwsw 指令,则访问数据存储器。
  5. WB (Write Back):将运算结果或读出的数据写回寄存器堆。

3. 为什么流水线更高效?

  • 缩短时钟周期:单周期的时钟频率受限于最慢的指令(如 lw)。流水线将长路径切成短路径,使得 CPU 可以运行在更高的频率下。
  • 提高吞吐率:虽然单条指令从开始到结束的时间(潜伏期)没有减少,但单位时间内完成的指令总数大大增加了。

4. 流水线面临的挑战:冒险(Hazards)

流水线虽然快,但也会带来副作用,即不同指令在流水线中“追尾”或争抢资源的情况:

  • 结构冒险:多个指令同时需要同一个硬件资源(例如同时要读指令和写数据)。
  • 数据冒险:后面的指令需要前面指令还没算出来的结果。
  • 控制冒险:遇到 beqj 指令时,CPU 还没确定跳不跳,后面的指令已经提前进场了。

指令流水线阶段汇总表

指令类型 IF (取指) ID (译码/读寄存器) EX (执行) MEM (访存) WB (写回)
Load (lw) 取指令 读基址寄存器 计算内存地址 读取内存数据 写回目标寄存器
Store (sw) 取指令 读基址及源数据 计算内存地址 写入数据到内存 NOOP (空操作)
R-type (add/sub) 取指令 读两个源寄存器 算术/逻辑运算 NOOP (空操作) 写回运算结果
Beq (分支) 取指令 读两个源寄存器 比较并算目标地址 条件成立则写PC NOOP (空操作)

五阶段流水线数据通路

image-20251228142859241

1. 核心物理组件:流水线寄存器

在电路图中,你可以看到四个明显的垂直红色矩形,它们是流水线的“灵魂”:

  • IF/ID Register:位于取指与译码之间。
  • ID/Ex Register:位于译码与执行之间。
  • Ex/Mem Register:位于执行与访存之间。
  • Mem/Wr Register:位于访存与写回之间。

为什么要增加这些寄存器?

  • 保存执行结果:用于保存每个阶段产生的中间数据(如运算结果、读取的数据、目标寄存器编号等),防止新指令进入流水线时覆盖旧指令的数据。
  • 透明性:它们属于内部寄存器,程序员不可见,也不需要作为程序现场保存。

2. 五阶段数据流向详解

在每个时钟周期的上升沿,数据通过流水线寄存器同步向右“迈进”一步:

  • IF (Ifetch):通过 IUnit 取出指令并存入 IF/ID Register,同时传递 PC + 4 地址。
  • ID (Reg/Dec):从 RFile 读取 rsrt 寄存器的值(busA/busB),进行立即数扩展,并将这些值连同目标寄存器编号存入 ID/Ex Register
  • Ex (Exec)ALU 使用来自 ID/Ex 的数据进行计算。结果连同需要存入内存的数据(busB)存入 Ex/Mem Register
  • Mem:根据 Ex/Mem 传递的地址访问 Data Mem。读取出的数据或 ALU 结果存入 Mem/Wr Register
  • Wr (WB):数据流到最右侧,通过 Mux 选择最终要写回的数据,通过长长的绿线导回到左侧 RFile 的 RwDi 端 完成写回。

3. 控制信号的“接力”传输

在流水线中,控制信号(如 RegWrALUSrcMemWr)不再是全局共享的,而是随指令一同移动

  1. 所有控制信号在 ID 阶段 被一次性生成。
  2. 信号被存入流水线寄存器中,像接力棒一样随着该指令向右传递。
  3. 例如,MemWr 信号必须等到该指令流动到 Mem 阶段 才会真正去控制数据存储器的写入。
  4. 最特殊的 RegWr(寄存器写使能)必须一路传到最后的 Mem/Wr 寄存器,才能确保在第五个周期准确控制写回操作。

4. 关键点:目标寄存器编号的同步

请特别注意 image_d2b224.png 下方的绿线。指令在 ID 阶段就知道要写回哪个寄存器(rdrt),但这个编号必须一路通过 ID/Ex -> Ex/Mem -> Mem/Wr 传递,最后在 WB 阶段才作为 Rw(写地址)提供给寄存器堆。如果直接在 ID 阶段连线到 Rw,会导致当前在 WB 阶段的指令写错地方(写到了新进场指令的目标地址里)。

指令部件 IUnit的设计

image-20251228143309558

1. IUnit 的两大核心功能

在每个时钟周期内,IUnit 必须自动且确定地完成以下操作:

  • 读取指令 (Instr <- Mem[PC]):根据程序计数器(PC)提供的地址,从指令存储器(Instruction Memory)中取出当前要执行的指令字。
  • 更新地址 (PC <- PC + 4):使用专门的加法器(Adder)计算当前地址加 4 的结果,为读取下一条顺序执行的指令做准备。

2. IUnit 内部的关键硬件组件

结合 image_d3133f.png 的电路连线,IUnit 主要由以下部件构成:

  • PC (Program Counter):保存当前正在执行指令的地址(例如图中显示的 PC = 16)。
  • Instruction Memory (指令存储器):以 PC 的值为地址输入,输出对应的指令字(例如 lw $1, 100($2))。
  • Adder (加法器):专门用于执行自增 4 操作。请注意,PC + 4 的值除了用于更新下一次取指地址,还会被存入流水线寄存器中,用于后续阶段计算转移目标地址。
  • MUX (多路选择器):位于 PC 输入端。其控制信号通常由其他阶段产生,用于在“顺序执行 (PC+4)”和“发生跳转 (Target Address)”之间做出选择。

3. 取指阶段的特殊逻辑:无需控制信号

这是一个非常关键的设计细节:在取指阶段(Ifetch),不需要根据指令的不同来控制执行不同的操作

  • 确定性:因为无论是什么指令(add、lw 还是 beq),第一步动作都是一样的——取指并加 4。
  • 自动运行:该阶段的功能是预先确定的,无需控制部件介入。

4. 数据的中转:IF/ID 流水线寄存器

取指完成后,IUnit 将其成果存入 IF/ID 寄存器 中:

  • 存储内容:必须保存指令字(用于后续译码)和 PC + 4 的值(用于计算分支跳转地址)。
  • 输出时机:这些信息总是存放在流水线段寄存器中,并在下一个时钟周期到来后的 Clock-to-Q 时刻输出给译码阶段。

流水线中的Control Signals如何获得?

image-20251228145439346

1. 为什么只有后三个阶段有控制信号?

观察 image_d39301.png 可以发现,控制信号仅存在于 Exec (执行)Mem (访存)Wr (写回) 这三个阶段。

  • Ifetch (取指)Reg/Dec (译码) 阶段没有控制信号。
  • 原因:在这两个阶段,所有指令执行的功能完全一样(都是取指、加 4、译码、读寄存器),是确定性的操作,无需根据指令不同来控制硬件。

2. 控制信号的“生命周期”

控制信号的产生与流动像流水线上的货物一样有序:

  • 产生 (ID 阶段):控制器根据从 IF/ID 寄存器传来的指令操作码,一次性生成该指令在后续所有阶段需要的全部信号。
  • 传递 (Pipeline Registers):这些信号被锁存在流水线寄存器中,随着指令一同向右泵送。
    • Exec 阶段使用ALUSrcALUOpRegDst 等。
    • Mem 阶段使用MemWrBranch
    • Wr 阶段使用RegWrMemtoReg

3. 以 Load 指令为例的信号路径

参考 image_d39301.png,看 lw 指令如何携带信号:

  • 在 Exec 段:从 ID/Ex 寄存器取出 ALUSrc=1 控制 Mux 选择立即数,取出 ALUOp=Add 让 ALU 算地址。
  • 在 Mem 段:信号跨过 Ex/Mem 寄存器。由于 lw 是读内存,MemWr 信号在此时应为 0。
  • 在 Wr 段:信号跨过 Mem/Wr 寄存器。此时 RegWr=1 有效,控制数据最终写回寄存器堆。

4. 关键点:反向数据流与冒险

PPT 提醒我们,流水线中存在反向数据流(如 Wr 阶段写回寄存器堆,或 Mem 阶段将目标地址写回 PC)。

  • 物理挑战:这种反向流动不同于洗衣流水线,它可能引起数据冒险(后面的指令需要前面还没写回的数据)或控制冒险(跳转指令改变了取指顺序)。
  • 解决方案:这将引出后续关于旁路前递(Forwarding)和流水线停顿(Stalling)的讨论。

Load指令:流水线中的控制信号

image-20251228145740672

1. 信号的产生:集中于 ID 阶段

所有的控制信号都是在 取数/译码(Reg/Dec)阶段 由主控制器(Main Control)根据指令的操作码一次性产生的。

  • 延迟使用:虽然信号在第 2 阶段就产生了,但 Load 指令在后续各个阶段(Exec, Mem, Wr)需要的操作各不相同,因此信号必须被“打包”送往后续阶段。

2. 信号的接力传递与使用

由于各阶段的操作是在不同的时钟周期完成的,控制信号必须保存在 流水线段寄存器 中,并随着指令一同向右泵送。

所属阶段 关键控制信号 使用时机 功能说明
Exec ExtOp, ALUSrc, ALUOp, RegDst 1 个周期后使用 控制 ALU 计算内存地址。例如 ALUSrc=1 选择立即数作为操作数。
Mem MemWr, Branch 2 个周期后使用 控制数据存储器的读写。对于 Load 指令,MemWr=0(只读)。
Wr MemtoReg, RegWr 3 个周期后使用 控制最后的数据回写。RegWr=1 开启寄存器堆写入开关。

3. 为什么信号也要保存在寄存器中?

PPT 明确指出:“控制信号也要保存在流水段寄存器中!

  • 同步性:流水线中同时运行着多条指令。如果 RegWr 信号直接从 ID 阶段连到最后的写回端,那么当前正在 ID 阶段的指令就会错误地控制正在 WB 阶段的指令是否写回寄存器。
  • 逻辑一致性:通过流水线寄存器(如 ID/Ex, Ex/Mem, Mem/Wr),控制信号与它所控制的数据始终保持“同步前进”,确保每个功能部件在处理某条指令时,拿到的是属于该指令的控制指令。

4. 关键点:1st 和 2nd 阶段为何没有信号?

image_d39301.png 中,我们可以看到取指(Ifetch)和译码(Reg/Dec)阶段上方没有控制信号线条。

  • 原因:这两个阶段的功能对每一条指令来说都是一样的(都是取指、加 4、译码、读寄存器)。
  • 特征:这些操作是确定性的,不需要根据指令的不同来控制硬件执行不同的操作。

流水线的三种冲突/冒险(Hazard)情况

1. 结构冒险 (Structural Hazards)

结构冒险也称为资源冲突,是指硬件资源不足,导致多条指令在同一周期内试图使用同一个物理部件。

  • 典型现象
    • 两条指令试图同时写入寄存器堆。例如,Load指令在第5阶段写回,而后续的R-type指令若在第4阶段就写回,会发生“写口”竞争。
  • 解决方法
    • 规整化设计:规定每个功能部件每条指令只能使用1次,且必须在特定周期使用。例如,强制所有指令都在第5阶段(Wr)使用寄存器写口,即使是原本只需4阶段的指令也需加入“空NOP”阶段来对齐。
    • 增加硬件资源:设置多个独立的部件以避免冲突。如将指令存储器(IM)和数据存储器(DM)分开设计。
image-20251228154130162

2. 数据冒险 (Data Hazards)

数据冒险也称为数据相关,是指后面的指令需要用到前面指令尚未产生或尚未写回的结果数据。

  • 典型现象
    • 前面的指令计算结果还没写回寄存器,后面指令就已经进入译码阶段准备读取该寄存器了。
  • 解决方法
    • 转发/旁路技术 (Forwarding/Bypassing):在结果产生后直接通过硬件连线传给需要的部件,而不必等待写回寄存器。
    • 流水线阻塞 (Stall):对于某些情况(如Load-use冒险),必须停顿一个周期。
    • 编译器优化:通过重新排列指令顺序来减少冲突。

3. 控制冒险 (Control Hazards / Branch Hazards)

控制冒险是指由于程序流方向发生改变(如执行分支、跳转或异常),导致已经在取指阶段取出的指令变得无效。

  • 典型现象
    • 在条件分支指令(如beq)的目标地址确定之前,后续指令已经被取进流水线了。
  • 解决方法
    • 分支预测:采用静态或动态预测技术预估分支是否跳转。
    • 编译器优化:利用“分支延迟槽(Branch Delay Slot)”技术,在分支指令后放置一条无论跳转与否都要执行的有用指令。

数据冒险

image-20251228154532023

1. 什么是数据冒险?

数据冒险是指在流水线执行过程中,当后面的指令需要用到前面指令尚未产生或尚未写回的结果数据时,所引发的冲突。这种现象会导致流水线无法正常执行后续指令,进而引起阻塞或停顿。

2. 核心实例分析:寄存器 r1 的冲突

参考 image_d4efbc.png 中的指令序列,我们可以清楚地看到 RAW(写后读) 冒险是如何发生的:

  • add r1, r2, r3:这条指令的目标是将计算结果写入寄存器 r1
  • sub r4, r1, r3:紧随其后的指令需要读取 r1 的值。
    • 冲突点:当 sub 指令在 Reg/Dec 阶段读取 r1 时,add 指令正处于 Exec 阶段执行加法。此时,r1 中的值还是旧的,新值尚未写回。
  • and r6, r1, r7
    • 冲突点:当 and 读取 r1 时,add 处于 Mem 阶段传递结果。此时读取的依然是旧值。
  • or r8, r1, r9
    • 冲突点:当 or 读取 r1 时,add 正处于 Wr (WB) 阶段。虽然正在写回,但在时钟前半周期读取时,拿到的往往还是旧值。
  • xor r10, r1, r11
    • 正常点:直到这条指令,add 已经彻底完成了写回操作,xor 才能读到 r1 的新值

方案1: 在硬件上采取措施,使相关指令延迟执行

image-20251228154832346

1. 方案核心原理:插入“气泡”

当硬件检测到指令间存在数据依赖(例如后续指令需要使用尚未写回的数据)时,会强制阻止后续指令继续执行,直到数据产生并写回为止。

  • 流水线阻塞 (Stall):这种做法形象地被称为插入 “气泡 (Bubble)”
  • 执行逻辑:相关指令会被延迟,直到前序指令完成写回(WB)操作,新值已存入寄存器堆后,后续指令才开始取数(Reg)阶段。

2. 实例分析:延迟三个时钟周期

参考 image_d4f7a2.png 中的时序图,我们可以看到 add 指令与后续 sub 指令的冲突处理:

  • 冲突源add r1, r2, r3 要在第 5 个周期(WB)才能将新值写回寄存器 r1
  • 阻塞过程
    • 为了确保 sub r4, r1, r3 能读到 r1 的新值,硬件在 add 执行期间强制插入了 3 个周期 的阻塞(stall)。
    • 在图中,这表现为 sub 指令在 ID/RF(译码/读寄存器)阶段原地踏步,直到 add 指令完成红色的 Reg 写回操作。
  • 结果sub 指令最终被延迟了三个时钟周期才开始正式执行。

3. 该方案的优缺点评价

根据 PPT 的总结,虽然该方案解决了数据正确性问题,但也带来了明显的代价:

  • 优点:保证了数据的绝对正确,能够处理所有类型的数据冒险。
  • 缺点
    • 性能下降:指令被大幅延迟,导致流水线的整体效率(吞吐率)显著降低。
    • 设计复杂:控制逻辑变得非常复杂,因为需要对数据通路进行大量修改,以实现“暂停”和“等待”的功能。

方案 2: 软件上插入无关指令

image-20251228155023271

1. 方案核心原理:显式插入 NOP

当编译器检测到指令序列中存在数据依赖(例如 sub 指令必须等待 add 指令写回结果才能开始读取)时,它会在两条有冲突的指令之间强行插入若干条 NOP(空操作)指令

  • 执行逻辑:这些 NOP 指令在流水线中正常“空转”,唯一的作用就是拉开冲突指令之间的时间距离。
  • 目的:确保后续指令在进入译码阶段(ID/RF)时,前序指令已经完成了写回(WB)操作,从而能读到寄存器中的最新值。

2. 实例分析:插入三条 NOP 指令

参考 image_d54dd5.png 中的时序图,我们可以看到对 r1 寄存器冲突的处理:

  • 冲突源add r1, r2, r3 的结果直到第 5 个周期(WB)才会正式存入寄存器。
  • 软件干预:编译器在 add 指令之后连续插入了 三条 nop 指令
  • 结果
    • sub r4, r1, r3 被推后到了第 5 个周期才进入取数阶段。
    • 此时,add 指令正好在写回(图中红色的 Reg 块),sub 指令能够顺利拿到 r1 的新值。

3. 方案评价:优缺点对比

根据 PPT 的总结,这是一种牺牲效率换取简单的做法:

  • 优点
    • 数据通路简单:硬件不需要复杂的冲突检测逻辑或暂停机制。
    • 零硬件改造成本:直接在原有数据通路上运行即可。
  • 缺点
    • 浪费严重:正如 PPT 所说,这“浪费三条指令的空间和时间,是最差的做法”。
    • 代码膨胀:生成的二进制文件会因为包含大量没意义的 NOP 而变得臃肿。

方案3: 同一周期内寄存器堆先写后读

image-20251228155252715

1. 方案核心原理:利用时钟脉冲的边缘

该方案的核心在于对寄存器堆(Register File)读写时序的精细控制。

  • 硬件基础:寄存器堆的读口和写口是相互独立的物理部件。
  • 操作逻辑:在一个时钟周期内,我们将操作分为两个阶段:
    • 前半周期:进行写操作。让处于写回(WB)阶段的指令将数据存入寄存器。
    • 后半周期:进行读操作。让处于译码(ID/RF)阶段的指令读取寄存器中的数据。
  • 结果:刚写入的数据可以被立即读出,从而在同一个时钟周期内完成数据的“接力”。

2. 实例分析:r1 寄存器的即时传递

参考 image_d5515c.png 中的时序图,观察 add 指令与后续指令的交互:

  • 冲突点缓解:在 Cycle 5 时,add r1, r2, r3 正在进行 WB(图中深红色的写块)。
  • 即时读取:与此同时,or r8, r1, r9 正在进行 ID/RF(图中淡红色的读块)。
  • 成功匹配:由于采用了“先写后读”策略,or 指令在 Cycle 5 的后半部分读取时,拿到的是 add 在前半部分刚刚存入 r1 的新值。

3. 方案评价:局限性与价值

  • 优点:不需要增加额外的复杂连线(如转发电路),也不需要插入 NOP 或阻塞,利用现有的寄存器堆结构就能解决一部分冒险。
  • 局限性
    • 只能解决部分冒险:正如 PPT 所言,它“只能解决部分数据冒险”。
    • 时间距离限制:它只能解决那些“读”和“写”刚好发生在同一周期的冲突(即指令间距为 2 的情况,如 addor)。
    • 无法处理相邻冲突:如果 sub 指令紧跟在 add 后面(间距为 1),在 sub 需要读取数据时,add 还处于执行阶段,数据根本还没算出来,这种方案就无能为力了。

方案4: 利用DataPath中的中间数据:转发+阻塞

image-20251228160014592

1. 方案核心原理:数据“抄近路”

转发技术,也称为旁路(Bypassing),其核心思想是:不等结果写回寄存器堆,直接从流水段寄存器中把数据“截胡”送给需要的部件

  • 观察点:虽然 add r1, r2, r3 还没把结果存入 r1 寄存器,但实际上计算好的值已经存在于 Ex/MemMem/Wr 级流水段寄存器中了。
  • 动作:硬件通过增加专门的连线(旁路),直接把这些中间值引回到 ALU 的输入端。

2. 实例分析:r1 的实时传递

参考 image_d5609b.png 中的时序图,我们可以看到红色箭头代表的转发路径:

  • sub 的冲突:当 sub 处于 EX 阶段需要 r1 时,add 刚算完结果并存放在 Ex/Mem 寄存器中。硬件直接将该值通过红色斜线传给 ALU 的输入。
  • and 的冲突:当 and 处于 EX 阶段时,add 的结果已经流动到了 Mem/Wr 寄存器。硬件同样通过旁路连线将其直接喂给 and 指令的 ALU。
  • 结果:这两条原本需要停顿 3 个周期的指令,现在可以紧跟在 add 后面执行,无需任何停顿

3. 为什么还需要“阻塞(Stall)”?

虽然转发能解决大部分问题,但有一种特殊情况无法单纯靠转发解决,即 Load-use 冒险

  • 冲突点:如果前一条指令是 lw r1, 0(r2),数据直到 Mem 阶段结束(即 Cycle 4 结束)才从存储器读出。
  • 物理限制:如果下一条指令在 Cycle 4 的 Exec 阶段就要用这个值,由于时间轴上“读出”晚于“计算”,数据无法向前穿越时间。
  • 对策:此时硬件必须强制插入一个 Stall(气泡),将后续指令推后一个周期,然后再配合转发技术获取数据。

4. 方案评价

  • 优点:极大地提高了流水线的吞吐率。在大多数 R 型指令相关的冲突中,它消除了所有停顿,让 CPU 保持满速运行。
  • 代价:增加了硬件设计的复杂性。需要增加大量的转发多路选择器(Mux)和复杂的转发控制逻辑(用于判断当前 ALU 需要的数据是否正在流水线的后面几级中流动)。

硬件上的改动以支持“转发”技术

image-20251228161825182

1. 核心硬件改动:引入多级反馈路径

image_d567fe.png 中,你可以看到 ALU 的输入端增加了显著的硬件变动:

  • 增加多路选择器 (MUX):在 ALU 的两个操作数输入端,原本直接连接 ID/Ex 寄存器的地方,现在各插入了一个 3 选 1 的 MUX
  • 物理连线(旁路路径)
    • EX 阶段转发(绿线):从 Ex/Mem 寄存器 的输出(即刚算好的 ALUout)引回一根线,连接到 ALU 输入端的 MUX。
    • MEM 阶段转发(红线):从 Mem/Wr 寄存器 的输出(即上一条指令读出的内存数据或算好的值)引回一根线,同样连接到该 MUX。

2. 不同冒险场景下的数据流向

根据图中提供的指令序列示例,硬件通过切换 MUX 的选择信号来实时捕获数据:

  • 场景 1:R-R 型相邻指令冲突
    • 指令:add r3, r2, r1 紧接 sub r5, r3, r4
    • 转发逻辑:当 sub 在 ALU 计算时,add 的结果刚存入 Ex/Mem 寄存器。此时硬件切换 MUX,通过绿线r3 的新值直接喂给 ALU,解决冒险。
  • 场景 2:间距为 2 的 R 型指令冲突
    • 指令:add r3, r2, r1sub r5, r3, r4
    • 转发逻辑:此时 add 的结果已经流动到 Mem/Wr 寄存器。硬件切换 MUX,通过红线将数据传回,确保 sub 拿到最新值。

3. 特殊限制:Load-use 冒险

PPT 在右侧提出了一个关键问题:lw r3, 100(r1) 后跟 or r6, r3, r1 能单纯靠转发解决吗?

  • 物理瓶颈lw 指令的数据直到 Mem 阶段结束才从数据存储器(DM)中流出。
  • 时间轴冲突:如果下一条指令 or 在同一个周期内就需要这个值进行 Exec 运算,数据在物理上还没读出来,无法“逆转时间”传回。
  • 结论:这种情况下,硬件必须先执行一次阻塞(Stall)*插入气泡,让 or 指令延后一个周期,再通过*红线路径完成转发。

Load指令引起的延迟现象

image-20251228161915840

1. 现象分析:为什么会有延迟?

Load 指令(如 lw)与普通的 R 型指令不同,它的数据产出时间点非常靠后:

  • 数据产出时刻:Load 指令在 Mem(访存) 阶段结束时,才能从数据存储器中读出数据。
  • 物理限制:实际上,在第四周期结束时,流水段寄存器(Mem/Wr)中才真正拥有后续指令需要的值。
  • 冲突点:如果紧随其后的第一条指令(Plus 1)在自己的 Exec 阶段(即第四周期)就需要用到这个值进行计算,此时数据尚未从存储器读出,转发技术也无法“逆转时间”将还没产生的数据传回去

2. 转发技术能解决什么?

通过 image_d5cd56.png 的时序图可以看到转发技术的局限与作用:

  • 能解决的:转发技术可以使 Load 指令后面 第二条指令(Plus 2)得到所需的值。
    • 图中蓝色的实线箭头显示:Load 产生的数据在第四周期结束时就位,正好可以转发给处于第五周期 Exec 阶段的 Plus 2 指令。
  • 不能解决的:它不能解决 Load 指令与随后的第一条指令(Plus 1)之间的数据冒险。
    • 图中红色的虚线箭头表示一个物理上不可能实现的请求:数据在第四周期末才出来,不可能传给在第四周期初就开始工作的 Plus 1。

3. 核心定义:装入-使用数据冒险

这种 Load 指令与其紧跟的指令之间因数据生产滞后而产生的冲突,被称为 “装入-使用数据冒险 (Load-use Data Hazard)”

4. 最终对策:延迟执行

为了保证程序的正确性,硬件或软件必须采取补救措施:

  • 硬件阻塞 (Stall):硬件会自动插入一个周期的“气泡”,将 Plus 1 指令及其后续指令全部推迟一个周期执行。
  • 编译器优化:编译器可以通过调整指令顺序,在 Load 指令后插入一条无关指令,从而利用这个间隙消除延迟。

流水线处理器的五个阶段

1. 五阶段流水线详解

阶段缩写 全称 核心任务
IF Ifetch (取指) 根据 PC 地址从指令存储器中读取指令,并计算 PC+4
ID Reg/Dec (译码) 翻译指令含义,同时从寄存器堆(RFile)中读取操作数 rsrt 的值。
EX Exec (执行) 使用 ALU 进行运算。对于 Load/Store 计算内存地址;对于 R 型指令执行算术逻辑运算。
MEM Mem (访存) 如果是 Load 则从内存读数据;如果是 Store 则往内存写数据。非访存指令此阶段仅传递结果。
WB Wr (写回) 将最终结果(来自 ALU 或内存)写回到目标寄存器中,正式更新 CPU 状态。

2. 深度解析:EX/MEM 这种斜杠名称是什么意思?

你提到的 EX/MEM(以及 IF/ID, ID/EX, MEM/WB)并不是一个阶段,而是流水线寄存器(Pipeline Register)

  • 命名逻辑:它的名称代表它位于哪两个阶段之间。例如,EX/MEM 就位于 执行(EX)访存(MEM) 阶段的交界处。
  • 物理本质:它是一组触发器,用于锁存(保存)前一个阶段产生的成果。
  • 具体到 EX/MEM 的作用
    • 保存结果:它保存了 EX 阶段 ALU 刚刚算出来的地址或运算结果。
    • 传递控制信号:它还保存了这条指令在后续 MEM 级(如 MemWr)和 WB 级(如 RegWr)所需要的控制信号。
    • 隔离保护:它确保当 EX 阶段开始处理下一条新指令时,当前指令算出的结果已经安全地存在 EX/MEM 里,供 MEM 阶段慢慢使用。

一、 转发路径总结 (Forwarding Paths)

转发技术通过在硬件中增加多路选择器(MUX)和反馈连线,使 ALU 能够直接从流水线后级寄存器中获取尚未写回的数据。

路径名称 数据来源 数据去向 解决的冲突类型
EX 级转发 (C1) EX/MEM 流水线寄存器 ALU 的输入端 MUX 相邻指令间的 RAW 冒险(如 add 紧跟 sub
MEM 级转发 (C2) MEM/WB 流水线寄存器 ALU 的输入端 MUX 间隔一条指令间的 RAW 冒险

二、 转发条件总结 (Forwarding Conditions)

为了确保转发的准确性,硬件判定逻辑必须满足“三看”:一看写使能,二看 Rd 是否为 0,三看寄存器编号匹配。

1. EX 级转发条件 (相邻指令)

当满足以下条件时,开启从 EX/MEM 到当前指令 EX 阶段的转发:

  • C1(a) 转发 Rs: EX/MEM.RegWr and EX/MEM.RegisterRd != 0 and EX/MEM.RegisterRd == ID/EX.RegisterRs
  • C1(b) 转发 Rt: EX/MEM.RegWr and EX/MEM.RegisterRd != 0 and EX/MEM.RegisterRd == ID/EX.RegisterRt

2. MEM 级转发条件 (间隔指令 + 优先级修正)

为了处理如 image_dfe02b.png 中连续多条指令写同一个寄存器的复杂情况,必须保证“最新数据优先”。因此,只有在 EX 级不需要转发时,才考虑 MEM 级转发。

Rs 为例,完善后的 C2(a) 条件为:

  1. 基础匹配: MEM/WB.RegWr and MEM/WB.RegisterRd != 0 and MEM/WB.RegisterRd == ID/EX.RegisterRs
  2. 优先级限制(关键): and NOT (EX/MEM.RegWr == 1 and EX/MEM.RegisterRd != 0 and EX/MEM.RegisterRd == ID/EX.RegisterRs)

三、 转发技术的特殊限制

即使拥有完善的转发逻辑,仍有两种情况需要注意:

  1. Load-use 冒险:lw 指令紧跟一条需要该数据的指令时,由于数据在物理上直到 MEM 阶段结束才产生,无法直接转发,必须配合阻塞(Stall)一个周期
  2. 无需写回的指令:beqsw,由于其 RegWr 信号为 0,即便寄存器编号匹配也不会触发转发。

Load-use Data Hazard(硬件阻塞方式)

image-20251228171523618

1. 为什么 Load-use 必须阻塞?

在 MIPS 五级流水线中,lw 指令产出的数据非常晚。

  • 数据就绪点lw 指令直到 Mem(访存) 阶段结束(即第四时钟周期末)才拿到内存数据。
  • 数据使用点:紧随其后的指令(如 sub)在自己的 Exec(执行) 阶段(即第四周期初)就需要数据进行 ALU 运算。
  • 物理瓶颈:由于使用点早于生产点,即便有转发技术也无法“逆转时间”完成数据传递。因此,硬件必须介入,强制将后续指令推后一个周期。

2. 硬件如何检测阻塞?

硬件中有一个专门的 “冒险检测单元”。它在 ID(译码) 阶段通过以下逻辑判断是否需要阻塞:

  • 判定条件:
    1. 上一条指令是 Load:即 ID/EX.MemRead == 1
    2. 目标寄存器冲突:上一条 Load 的目的寄存器(ID/EX.RegisterRt)等于当前正在译码指令的源寄存器(IF/ID.RegisterRsIF/ID.RegisterRt)。

3. 阻塞的具体实现动作

一旦检测到 Load-use 冒险,硬件会执行以下三步操作来制造一个“气泡(Bubble)”:

  1. 插入气泡 (清除 ID/EX 寄存器): 将 ID/EX 段寄存器中所有的控制信号清零(相当于执行了一条 nop 指令)。这样,原本该进入执行阶段的指令就不会对寄存器或内存产生任何修改。
  2. 冻结 IF/ID 寄存器: 保持 IF/ID 寄存器中的信息不变。这意味着当前被阻塞的指令(如 sub)会被留在译码阶段,并在下一个周期重新译码执行。
  3. 冻结 PC 寄存器: 保持 PC 的值不变。这确保了再下一条指令(如 and)在下一个周期会被重新取出,而不会因为 PC 增加而跳过。

方案5:编译器进行指令顺序调整来解决数据冒险

image-20251228181938973

1. 为什么需要编译器优化?

虽然硬件可以自动阻塞(Stall),但阻塞意味着 CPU 在“原地踏步”,会白白浪费时钟周期。

  • 硬件方案:发现冲突 -> 停顿 1 周期 -> 总耗时变长。
  • 编译器方案:调整顺序 -> 消除冲突 -> 0 停顿,满速运行。

2. 实战演练:从 Slow Code 到 Fast Code

参考 image_e126bb 中的代码逻辑:

目标计算:a = b + c;d = e - f;

❌ 优化前 (Slow Code):频繁阻塞

Plaintext

1
2
3
4
5
6
7
8
lw  $2, b
lw $3, c <-- 紧接着要用 $3
add $1, $2, $3 <-- [冒险!] 必须在这里阻塞 1 周期
sw $1, a
lw $5, e
lw $6, f <-- 紧接着要用 $6
sub $4, $5, $6 <-- [冒险!] 必须再次阻塞 1 周期
sw $4, d

分析:这段代码会触发两次硬件阻塞,总共浪费 2 个周期。

✅ 优化后 (Fast Code):无缝衔接

编译器通过观察发现,a=b+cd=e-f 是互不干涉的两组运算。它将指令重新排序:

MIPS Assembler

1
2
3
4
5
6
7
8
lw  $2, b
lw $3, c
lw $5, e # [巧妙插入] 在等待 c 加载时,先去加载 e
add $1, $2, $3 # [冒险消除!] 此时 c 早已加载完毕,直接计算
lw $6, f # [巧妙插入] 在等待 e 加载时,先去加载 f
sw $1, a # [无关指令] 进一步拉开 lw $6 和 sub 的距离
sub $4, $5, $6 # [冒险消除!] 此时 f 也加载好了,直接计算
sw $4, d

分析:调整后,Load 指令和它对应的运算指令之间都被“拉开了距离”。通过这种方式,硬件不再需要任何阻塞,CPU 始终保持满载


3. 编译器优化的“底层逻辑”

我们可以用 ASCII 时序图看看这个“拉开距离”的效果:

1
2
3
4
5
6
7
8
优化前 (有阻塞):
lw $3: [IF][ID][EX][MEM][WB]
add: [IF][ID]----[ID][EX] <-- 被迫停顿 (Stall)

优化后 (无阻塞):
lw $3: [IF][ID][EX][MEM][WB]
lw $5: [IF][ID][EX][MEM][WB] <-- 利用这个周期干别的活
add: [IF][ID][EX][MEM][WB] <-- 等到这里用 $3 时,数据早已出炉!

控制冒险

image-20251228182258608

1. 什么是控制冒险?

控制冒险,也称为分支冒险。它的核心矛盾在于:处理器在还没确定下一条指令该去哪取的时候,就已经开始取指了

在 MIPS 五级流水线中,以 Beq(相等则转移)指令为例:

  • 判定延迟:指令是否转移是在 Mem(访存) 阶段确定的。
  • 结果:如图 image_e2f459 所示,当 Beq 到达第七周期确定跳转地址并送往 PC 时,流水线已经默认按顺序取出了后面 3 条 错误的指令。
  • 代价:如果发生转移,这 3 条已经进入流水线的指令必须被清除(Flush),这导致了 3 个时钟周期的延迟损失(C=3)

2. 解决控制冒险的 4 种方法

针对这种性能损失,PPT 提出了四种主要的解决方案:

方法 1:硬件阻塞(Stall)

  • 做法:一旦检测到分支指令,强行让流水线停顿,直到分支结果确定。
  • 缺点:每遇到分支就插入 3 条 NOP 指令,效率极低。

方法 2:软件插入 NOP

  • 做法:由编译器在分支指令后手动加入三条无关的 NOP 指令。
  • 评价:与硬件阻塞类似,虽然保证了正确性,但浪费了大量周期。

方法 3:分支预测(Branch Prediction)

这是现代处理器最常用的高效手段:

  • 静态预测:总是预测“不发生转移”(继续执行后续指令)。如果预测错了,再清空流水线重新开始。
  • 动态预测:根据程序执行的历史记录(如循环统计)实时调整预测策略,准确率可高达 90%

方法 4:延迟分支(Delayed Branch)

  • 做法:编译器寻找一条与分支无关的指令,将其移动到分支指令之后执行。
  • 效果:无论分支是否成功,这条处于“延迟槽”中的指令都会执行,从而利用了原本会被浪费掉的一个时钟周期。

简单(静态)分支预测方法

1. 静态预测的主要策略:总是预测“不发生”(Predict Not Taken)

这是 MIPS 流水线中最常用、最简单的静态预测方法。

  • 做法:当流水线遇到一条分支指令(如 beq)时,硬件默认认为分支不会跳转,于是继续按照 PC+4 的地址去取下一条指令。
  • 预测成功:如果 beq 的条件真的不成立(不跳转),流水线就完美地避开了停顿,实现零延迟
  • 预测失败:如果 beq 的条件成立(需要跳转),那么之前已经取进来的指令就是错误的,必须被清空(Flush)

2. 预测失败时的“清空”过程(ASCII 时序图)

假设 beq 发生了跳转,但我们预测它“不跳转”:

Plaintext

1
2
3
4
5
6
周期:     C1    C2    C3    C4    C5
beq: [IF] [ID] [EX] [MEM] <-- 在MEM阶段发现:wc!预测错了,要跳转
instr+1: [IF] [ID] [EX] [清空] <-- 错误取到的指令,必须作废(变气泡)
instr+2: [IF] [ID] [清空] <-- 错误取到的指令,作废
instr+3: [IF] [清空] <-- 错误取到的指令,作废
target: [IF] <-- 第5周期才真正取到正确的目标指令

代价:在基础流水线中,预测失败会造成 3 个时钟周期 的损失。


3. 如何优化预测失败的代价?

正如 image_e2f8f9.png 所提到的,为了让静态预测更划算,硬件会把“分支判定”的时机提前到 ID 阶段

  • 硬件改动:在 ID 阶段增加专门的比较器和加法器。
  • 优化后的效果
    • 预测失败只需清空 1 条 指令(即正在 IF 阶段的那条)。
    • 代价:损失从 3 周期降到了 1 周期

动态分支预测方法

动态分支预测的核心思想是:利用分支指令最近的执行历史,来预测下一次是否会发生转移。它不像静态预测那样死板,而是会根据程序的实际运行情况动态调整预测结果。


1. 核心组件:分支历史表 (BHT)

动态预测主要依靠一个特殊的硬件结构——BHT (Branch History Table),也常被称为 BPB (Branch Prediction Buffer)

  • 工作流程
    1. 查找:在 IF (取指) 阶段,利用分支指令地址的低位作为索引去查找 BHT。
    2. 预测:从表中读出“预测位”,决定是“跳转取指”还是“顺序取指”。
    3. 修正:指令实际执行完后,将真实的跳转结果反馈给 BHT,更新该表项的预测位。

2. 预测算法:从 1 位到 2 位

A. 1 位预测位 (1-bit Predictor)

  • 逻辑:总是按上一次实际发生的情况来预测下一次。
    • 1 表示最近一次发生了转移,下次预测跳转 (taken)。
    • 0 表示最近一次没发生转移,下次预测顺序执行 (not taken)。
  • 缺点:在循环边界(第一次和最后一次)会产生两次连续的预测错误,因为循环的状态改变得太快,1 位逻辑反应不过来。

B. 2 位预测位 (2-bit Predictor)——主流方案

为了提高准确率,现代处理器(如 Pentium 4)多采用 2 位或更多位的预测位。

  • 逻辑:用 2 位组合四种情况来表示预测状态(如“强跳转”、“弱跳转”、“弱不跳转”、“强不跳转”)。
  • 优点:只有在连续两次分支情况改变时,才会改变预测方向。这使得它在处理循环时,只会产生一次预测错误。

一位动态预测

1. 一位预测的核心逻辑:惯性思维

一位预测器的核心思想是:“上次发生什么,这次就猜什么”。它只有两个状态,由 1 位二进制数表示:

  • 状态 1(预测发生/Taken):如果上次分支跳转了,状态变为 1。下次遇到该指令,默认选择“跳转取指”。
  • 状态 0(预测不发生/Not Taken):如果上次分支没跳转,状态变为 0。下次遇到该指令,默认选择“顺序取指”。

2. 状态转换图详解

结合 image_e3705c.png 中的圆圈和箭头,我们可以看到状态是如何随着实际执行结果而“反转”的:

  • 预测正确时:状态保持不变。例如,当前在“状态 1”,实际执行结果也是“发生”,则继续留在“状态 1”。
  • 预测错误时:状态发生反转。
    • 如果在“状态 1”但实际“不发生”,状态立刻切换到 0。
    • 如果在“状态 0”但实际“发生”,状态立刻切换到 1。

3. 致命弱点:循环边界的“连错两次”

一位预测器最典型的局限性体现在循环程序中。假设有一个执行了 10 次的循环:

  • 退出循环时(第 10 次):之前一直是跳转的(状态 1),但最后一次不再跳转。此时预测器猜错第一次,并把状态改为 0。
  • 再次进入循环时(下一次外层迭代):因为状态变成了 0,它会预测“不跳转”。但实际上循环的第一步通常是要跳转的。此时预测器又猜错第二次,再把状态改回 1。

结论:只要本次执行和上次的情况不同,就会出现一次预测错误。在嵌套循环中,内层循环每结束一次,都会导致下一次进入时多错一次。


4. 实例计算:双重循环的准确率

参考 image_e37076.png 的例子:

  • 外循环执行 N 次,内循环执行 N
  • 预测错误总数1 + 2 × (N − 1) 次。
    • 第一次内循环结束错 1 次。
    • 后面每次重新进入内循环(第一步)错 1 次,结束时又错 1 次。
  • 趋势:当 N = 10 时,准确率约 90.9%;当 N = 100 时,准确率提高到 99%。这意味着 N 越大,预测准确率越高,因为中间正确预测的次数显著增多。

两位动态预测

1. 两位预测状态机 (2-bit State Machine)

结合 image_e37fa0.png,两位预测器由四个状态组成,可以看作是一个有“缓冲带”的决策系统:

状态码 含义 预测动作 容错性
11 强预测发生 (Strongly Taken) 预测跳转 错一次后降级为 10,但下次依然猜跳转。
10 弱预测发生 (Weakly Taken) 预测跳转 再错一次才彻底放弃,转为 01。
01 弱预测不发生 (Weakly Not Taken) 预测顺序 再错一次(即实际发生)才转为 10。
00 强预测不发生 (Strongly Not Taken) 预测顺序 错一次后升温为 01,下次依然猜顺序。

2. 为什么它比一位预测更强?

它的绝活在于处理循环边界。我们用 ASCII 时序图来对比它们处理同一个循环(循环 10 次)的表现:

  • 一位预测器
    • 退出循环时:猜错第 1 次,状态变 0。
    • 下次进入循环时:根据状态 0 猜不跳,结果跳了,猜错第 2 次
  • 两位预测器 (初始为 11)
    • 退出循环时:猜错第 1 次,状态从 11 降级到 10(弱预测发生)。
    • 下次进入循环时:由于状态是 10,它依然预测“跳转”。结果真的跳了,预测正确! 状态升回 11。

结论:两位预测器成功地通过“缓冲状态”过滤掉了循环结束时的那次偶然失误,保证了下次进入循环时的首跳准确。


3. 实例计算:双重循环的准确率

  • 内循环每次结束都会预测错误,一共有n次,外循环结束还有一次,一共是n+1次

分支延迟时间片的调度

根据您提供的资料(特别是 image_edec08.png),我为您详细讲解分支延迟时间片的调度

这是一种通过编译器重排指令顺序来消除控制冒险的静态调度技术。


1. 核心概念:分支延迟槽 (Branch Delay Slot)

在流水线中,分支指令确定跳转方向和目标地址需要时间。在结果确定之前,流水线已经取出的指令位置被称为分支延迟时间片(或分支延迟槽)。

  • 基本思想:与其在延迟槽中插入浪费周期的 nop,不如让编译器找一条在分支指令之前、且与分支结果无关的指令,将其移动到分支指令后面执行。
  • 特性:处于延迟槽中的指令,无论分支是否发生转移,都一定会被执行

2. 实例解析:如何进行调度

参考 image_edec08.png 中的代码优化过程:

优化前(有浪费):

原本的代码顺序如下,假设分支判定提前到了 ID 阶段,延迟时间片为 1:

MIPS Assembler

1
2
3
4
5
6
7
lw  $1, 0($2)
lw $3, 0($2)
add $6, $4, $2
beq $3, $5, 2 # 分支指令
nop # 延迟槽:如果不调度,这里必须放一个空操作
add $3, $3, $2
sw $1, 0($2)

优化后(满速运行):

编译器发现 lw $1, 0($2) 这条指令与分支指令 beq $3, $5, 2 完全无关,因此将其“挪”到了分支指令之后:

MIPS Assembler

1
2
3
4
5
6
lw  $3, 0($2)
add $6, $4, $2
beq $3, $5, 2 # 分支指令
lw $1, 0($2) # 【调度后】这条指令填补了延迟槽,不再需要 nop!
add $3, $3, $2
sw $1, 0($2)

效果:原本会浪费的一个时钟周期被有效指令填满,流水线效率大幅提升。

异常(Exception)和中断(Interrupt)

异常(Exception)和中断(Interrupt)是另一种形式的控制冒险,因为它们会改变程序的正常执行流程。


1. 异常的发生与检测

当流水线中的某条指令发现异常时(例如 ALU 指令发现“溢出”),后面的多条指令可能已经进入流水线并在执行中。

  • 实例:指令 add r1, r2, r3 产生溢出。
  • 检测点:溢出通常在 EXE(执行)阶段 被检测出来。此时,该指令后面的两条指令已经分别进入了 ID 和 IF 阶段。

2. 流水线处理异常的步骤

为了保证程序的正确性,硬件必须确保发生异常的指令及其后续指令不会对寄存器或内存造成永久性修改。

  1. 清除指令 (Flush)
    • EX.Flush:将 EXE 阶段指令的控制信号清 0(特别是 RegWr),避免将错误的溢出结果写回寄存器。
    • ID.Flush:将 IF/ID 寄存器中的指令清 0,转变为 nop 指令。
    • IF.Flush:清除正在取指阶段的指令。
  2. 关中断:将中断允许触发器清 0。
  3. 保存断点:将当前的 PC 或 PC-4 保存到 EPC (Exception Program Counter) 寄存器中,以便之后能返回执行。
  4. 跳转到处理程序:将 MIPS 异常处理程序的首地址 0x8000 0180 送入 PC,从该地址开始取指执行。

3. 处理过程图解 (ASCII)

add 指令在 EXE 阶段报错时,流水线会瞬间插入多个“气泡(bubble)”:

Plaintext

1
2
3
4
5
周期:     C1    C2    C3(报错!)   C4        C5
add: [IF] [ID] [EXE] ----> [bubble] [bubble] (防止写回r1)
sub: [IF] [ID] ----> [bubble] [bubble] (被冲刷)
and: [IF] ----> [bubble] [bubble] (被冲刷)
Handler: [0x8000 0180] (异常处理开始)

参考资料

「小白debug」如何用开关造出一台计算机_哔哩哔哩_bilibili

convertapi

ConvertAPI: Powerful File Conversion API for Developers & Businesses

档次 官方名称 月费 (CNY) 每月包含转换次数 单文件上限 并发任务数
1 Developer ¥249 1,000 次 200 MB 1
2 Startup ¥677 5,000 次 300 MB 2
3 Growth ¥1,247 15,000 次 500 MB 3
4 Business ¥2,495 50,000 次 1 GB 无限制

CloudConvert

模式 价格 包含内容
一次性购买积分 $9 美元 500 个转换积分
月度订阅 $9 美元/月 每月 1000 个转换积分,未用完可滚存

每天免费 10 次转换

pdf转ppt,一次要花费4积分,平均下来一份需要0.47元,付费情况下可以做到 5个并发任务

定价 |云转换

PDF to PowerPoint | CloudConvert

GroupDocs.Conversion/Aspose.PDF Cloud

每个月1000次以内的api调用是30美金,平均下来是一份0.21元,但是如果超过1000次每个月,就要0.09美金一次转换

付费默认5并发

https://products.groupdocs.cloud/conversion/python/pdf-to-ppt/

Dashboard

Dashboard

Pricing Guide - Purchase - groupdocs.cloud

Adobe PDF

每个月五百次的免费转换

Adobe PDF Services API Pricing | PDF Embed API Pricing | Adobe Acrobat Services Pricing - Adobe Developers

度慧科技

这个很便宜,我在腾讯云上看,300r可以买五千次,500r可以买5万次转换。阿里云,100r可以买3000次,有效期一个月

并发数为200

度慧文档转换

[度慧]PDF转Word,PPT,Excel,TXT,OFD(OCR高级版)-腾讯云市场

【度慧文档转换】PDF转Word/PPT/Excel/TXT/OFD - 支持扫描版OCR【最新版】_数据API_OCR_API-_云市场-阿里云

技术路线

方案 A:html → PDF → pptx(我尝试下来不大可行,无法再进行编辑了)

方案 B:html → PPTXGenJS

方案 C:html → python-pptx (主流)

方案 D:markdown → Slidev

我尝试了当前市面的ppt生成产品,在网页里面展示还会有良好的动画效果,但是一旦导出成pptx,都是会变成静态页面,没有动画

reveal.js可以利用页面生成丰富的ppt动画效果;Slidev可以将markdown语法转化成ppt,可以导出为pdf或pptx,需要注意的是,PPTX 文件中的所有幻灯片都会被导出为图片。

PPTXGenJS和python-pptx的原理基本一致,区别一个是使用js一个是python,使用方法都是通过解析HTML标签内容,定义一个ppt实例,将html的内容一点点加入这个示例中,最后导出pptx。这样都仅能实现最基本的ppt演示,不会有复杂的结构,而且经常会出现一个问题——某个标签内文字太多往往会超出ppt演示范围

直接让AI来生成非常自由的PPT,最终的效果一般来说都比较烂,大部分都是预定义一个html模板,然后让AI来自动的选择模板往里面填充内容

相关工具

Slidev 是一个为开发者设计的基于 Web 的幻灯片制作工具。它帮助您以 Markdown 的形式专注于编写幻灯片的内容,并制作出具有交互式演示功能的、高度可自定义的幻灯片。

reveal.js 是一个开源的 HTML 演示框架,用 JavaScript 写成。只要你会写 HTML/CSS/JS,就可以像做网页一样做出 酷炫、响应式、支持键盘/鼠标/触控交互 的幻灯片。

PptxGenJS 允许您使用 JavaScript 生成专业的 PowerPoint 演示文稿——直接从 Node、React、Vite、Electron,甚至浏览器中生成。

python-pptx 是一个用于创建、读取和更新 PowerPoint (.pptx)文件的 Python 库。典型的使用场景是从动态内容(如数据库查询、分析输出或 JSON 负载)生成 PowerPoint 演示文稿,可能是在响应 HTTP 请求时生成 PPTX 文件并下载。

python-pptx使用方式:根据标签解析html文件,如h1,div等,然后一点点添加到定义的页中

市面同类产品

  1. Genspark
    • Genspark 是 MainFunc 公司(由前小度 CEO 景鲲和前小度 CTO 朱凯华联合创立)推出的 AI Agent 搜索引擎(或称“AI 原生搜索引擎”)。
  2. skywork
    • Skywork 是昆仑万维(Kunlun Inc.)旗下 SkyWork AI 推出的一系列 开源大模型AI 技术品牌
  3. manus
  4. Gamma 是一个 “AI 驱动的在线内容工作站”:输入一句话、一段大纲或任何资料,它就能在 1-3 分钟内 帮你生成 高颜值、品牌化、可互动 的演示文稿、网站、社媒图文或 PDF,并可一键导出为 PPT / Google Slides / PDF / 网站链接

manus是每页ppt都是一个html文件,我猜测应该是使用像python-pptx的库生成

ppt-mcp

可以参考其中的工具实现,这两个我看下来都是使用python-pptx包

GongRzhe/Office-PowerPoint-MCP-Server: A MCP (Model Context Protocol) server for PowerPoint manipulation using python-pptx. This server provides tools for creating, editing, and manipulating PowerPoint presentations through the MCP protocol.

ltc6539/mcp-ppt: A mcp server supporting you to generate powerpoint using LLM and natural language automatically.

架构思考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
flowchart TB
subgraph "Plan-and-Execute阶段"
A["用户输入"] --> B["Planner Agent"]
B --> C["Agent Executor"]
C --> D["Replanner"]
D -->|"需要更多信息"| C
D -->|"信息充足"| E["输出结构化信息"]
end

subgraph "内容生成阶段"
E --> F["大纲设计节点"]
F --> G["页面内容生成节点"]
G --> H["HTML代码生成节点"]
end

subgraph "文件转换阶段"
H --> I["html演示生成"]
I --> J["转换pptx节点"]
J --> K["输出PPTX文件"]
end

利用APRYSE将pdf转成pptx

Apryse(曾用名 PDFTron)是一家加拿大公司推出的商用 SDK 家族,专注 “任何格式进、任何格式出” 的文档处理。

获取apikeyFree trial key for Apryse SDK | Apryse documentation

Python 3.X PDF Library for Windows, Linux and Mac | Apryse documentation

安装 Apryse SDK 的“结构化输出模块”(Structured Output Module)。该模块是一个可选的扩展包,PDF → PPTX、PDF → Word 等高级转换功能都依赖它。库插件:OCR、CAD 转 PDF - 适用于服务器/桌面 SDK | Apryse 文档 — Library Add-ons: OCR, CAD to PDF - for Server/Desktop SDK | Apryse documentation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from apryse_sdk import PDFNet, PDFDoc, Convert, StructuredOutputModule

# 1. 初始化(许可证)
PDFNet.Initialize("demo:1756369085114:")

# 2. 告诉 SDK 模块放在哪里
PDFNet.AddResourceSearchPath(r"F:\project python\test\StructuredOutputWindows\Lib\Windows")

# 3. 可选:确认模块已就位
if not StructuredOutputModule.IsModuleAvailable():
raise RuntimeError("StructuredOutput module not found!")

# 4. 正常调用
doc = PDFDoc("input.pdf")
Convert.ToPowerPoint(doc, "output.pptx")

Overview

参考资料

动手实现一个做PPT的MCP服务器_哔哩哔哩_bilibili

前言

这个agentic rag主要是作用于检索部分,由是否需要调用检索工具判定是否进入检索阶段,当检索到相关的文章,则进行回答,否则对问题进行改写,再次检索

代码见learn-rag-langchain/agentic-rag at main · zxj-2023/learn-rag-langchain

在这个教程中,我们将构建一个检索代理。当您希望 LLM 决定是否从向量存储中检索上下文或直接响应用户时,检索代理非常有用。

完成教程后,我们将完成以下工作:

  1. 获取并预处理用于检索的文档。
  2. 为这些文档建立语义索引,并为代理创建一个检索工具。
  3. 构建一个能够决定何时使用检索工具的代理式 RAG 系统。
image-20250819165309335

1. 预处理文档

获取用于我们 RAG 系统的文档。我们将使用 Lilian Weng 优秀博客中最新的三页。我们将从使用 WebBaseLoader 工具获取页面内容开始:

1
2
3
4
5
6
7
8
9
from langchain_community.document_loaders import WebBaseLoader

urls = [
"https://lilianweng.github.io/posts/2024-11-28-reward-hacking/",
"https://lilianweng.github.io/posts/2024-07-07-hallucination/",
"https://lilianweng.github.io/posts/2024-04-12-diffusion-video/",
]

docs = [WebBaseLoader(url).load() for url in urls]
1
docs[0][0].page_content.strip()[:1000]

将获取的文档分割成更小的块,以便索引到我们的向量存储中:

1
2
3
4
5
6
7
8
from langchain_text_splitters import RecursiveCharacterTextSplitter

docs_list = [item for sublist in docs for item in sublist]

text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
chunk_size=100, chunk_overlap=50
)
doc_splits = text_splitter.split_documents(docs_list)
1
doc_splits[0].page_content.strip()

2. 创建检索工具

现在我们已经有了分割的文档,我们可以将它们索引到一个向量存储中,我们将使用这个向量存储进行语义搜索。

使用内存向量存储和 OpenAI 嵌入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from langchain_chroma import Chroma  # 导入 Chroma
from langchain_openai import OpenAIEmbeddings
import os

# 确保安装了 langchain-chroma
# pip install langchain-chroma

embedding = OpenAIEmbeddings(
api_key="sk-",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
model="text-embedding-v4",
check_embedding_ctx_length=False,
dimensions=1536,
chunk_size=5 # 设置较小的批次大小
)

# 使用 Chroma 替代 InMemoryVectorStore
vectorstore = Chroma.from_documents(
documents=doc_splits,
embedding=embedding,
persist_directory="./chroma_db" # 指定持久化目录
)
1
2
3
4
5
6
7
# 重新加载已存在的 Chroma 数据库
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embedding
)

retriever = vectorstore.as_retriever()

使用 LangChain 的预构建 create_retriever_tool 创建检索工具

1
2
3
4
5
6
7
from langchain.tools.retriever import create_retriever_tool

retriever_tool = create_retriever_tool(
retriever, # 【输入】一个已经配置好的检索器(例如:向量数据库的检索器)
"retrieve_blog_posts", # 【工具名称】这个工具的唯一标识名(供模型内部调用)
"Search and return information about Lilian Weng blog posts." # 【工具描述】模型看到的说明,用于决定是否调用它
)
1
retriever_tool.invoke({"query": "types of reward hacking"})

3. 生成查询

现在我们将开始构建我们智能体 RAG 图中的组件(节点和边)。

构建一个 generate_query_or_respond 节点。它将调用 LLM 来根据当前图状态(消息列表)生成响应。根据输入消息,它将决定使用检索工具进行检索,或直接响应用户。请注意,我们通过 .bind_tools 向聊天模型提供了先前创建的 retriever_tool 访问权限:

1
2
3
4
5
6
7
from langchain_community.chat_models import ChatTongyi
llm = ChatTongyi(
model="qwen3-235b-a22b",
api_key="sk-",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
model_kwargs={"enable_thinking": False} # 关键在这里
)
1
2
3
4
5
6
7
8
9
10
from langgraph.graph import MessagesState

def generate_query_or_respond(state: MessagesState):
"""调用模型,根据当前状态生成响应。根据问题,模型将决定是使用检索工具进行检索,还是直接回复用户。
"""
response = (
llm
.bind_tools([retriever_tool]).invoke(state["messages"])
)
return {"messages": [response]}

提出一个需要语义搜索的问题:

1
2
3
4
5
6
7
8
9
input = {
"messages": [
{
"role": "user",
"content": "What does Lilian Weng say about types of reward hacking?",
}
]
}
generate_query_or_respond(input)["messages"][-1].pretty_print()

4.评定文件

添加一个条件边 — grade_documents — 来判断检索到的文档是否与问题相关。

我们将使用一个具有结构化输出模式 GradeDocuments 的模型来对文档进行评分。 grade_documents 函数将根据评分决策( generate_answerrewrite_question )返回要前往的节点的名称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from pydantic import BaseModel, Field
from typing import Literal

# 定义评分提示模板
GRADE_PROMPT = (
"你是一个评分员,负责评估检索到的文档与用户问题的相关性。\n "
"以下是检索到的文档内容:\n\n {context} \n\n"
"以下是用户的问题:{question} \n"
"如果文档包含与用户问题相关的关键词或语义含义,则将其评为相关。\n"
"请给出一个二元评分:'yes'(是)表示相关,'no'(否)表示不相关。"
)

# 定义用于评估文档相关性的 Pydantic 模型
class GradeDocuments(BaseModel):
"""使用二元评分对文档进行相关性评估。"""

binary_score: str = Field(
description="相关性评分:'yes' 表示相关,'no' 表示不相关"
)

# 初始化用于评分的聊天模型
grader_model = llm

def grade_documents(
state: MessagesState,
) -> Literal["generate_answer", "rewrite_question"]:
"""
判断检索到的文档是否与用户问题相关。

参数:
state: 包含消息历史的状态对象,其中第一条消息是用户问题,
最后一条消息是检索到的文档内容。

返回:
如果文档相关,返回 "generate_answer";
如果不相关,返回 "rewrite_question",表示需要重写问题并重新检索。
"""
question = state["messages"][0].content # 获取用户问题
context = state["messages"][-1].content # 获取检索到的文档内容

# 将问题和文档内容填入提示模板
prompt = GRADE_PROMPT.format(question=question, context=context)

# 调用模型,并以结构化输出(Pydantic 模型)的形式获取评分结果
response = (
grader_model
.with_structured_output(GradeDocuments)
.invoke([{"role": "user", "content": prompt}])
)
#print(response)
score = response.binary_score # 获取二元评分结果

# 根据评分决定下一步操作
if score == "yes":
return "generate_answer" # 文档相关,生成答案
else:
return "rewrite_question" # 文档不相关,重写问题后重新检索
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from langchain_core.messages import convert_to_messages

input = {
"messages": convert_to_messages(#将一系列消息转换为 BaseMessage 类型的消息列表。
[
{
"role": "user",
"content": "What does Lilian Weng say about types of reward hacking?",
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "1",
"name": "retrieve_blog_posts",
"args": {"query": "types of reward hacking"},
}
],
},
{"role": "tool", "content": "meow", "tool_call_id": "1"},
]
)
}
grade_documents(input)

5. 重写问题

构建 rewrite_question 节点。

检索工具可能会返回潜在的不相关文档,这表明需要改进原始用户问题。为此,我们将调用 rewrite_question 节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
REWRITE_PROMPT = (
"Look at the input and try to reason about the underlying semantic intent / meaning.\n"
"Here is the initial question:"
"\n ------- \n"
"{question}"
"\n ------- \n"
"Formulate an improved question:"
)

def rewrite_question(state: MessagesState):
"""
重写用户最初的提问,以更好地表达其语义意图。

参数:
state: 包含消息历史的状态对象,其中第一条消息是用户原始问题。

返回:
一个字典,包含一条新的用户消息,内容为改写后的问题。
该消息将用于后续的检索步骤,以提高检索结果的相关性。
"""
messages = state["messages"]
question = messages[0].content # 获取用户最初的提问
prompt = REWRITE_PROMPT.format(question=question) # 将问题填入提示模板
response = llm.invoke([{"role": "user", "content": prompt}]) # 调用模型生成改写后的问题

# 返回新的消息结构,内容为改写后的问题
return {"messages": [{"role": "user", "content": response.content}]}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
input = {
"messages": convert_to_messages(
[
{
"role": "user",
"content": "What does Lilian Weng say about types of reward hacking?",
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "1",
"name": "retrieve_blog_posts",
"args": {"query": "types of reward hacking"},
}
],
},
{"role": "tool", "content": "meow", "tool_call_id": "1"},
]
)
}

response = rewrite_question(input)
print(response["messages"][-1]["content"])

6. 生成答案

构建 generate_answer 节点:如果我们通过了评分器的检查,我们可以根据原始问题和检索到的上下文生成最终答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GENERATE_PROMPT = (
"You are an assistant for question-answering tasks. "
"Use the following pieces of retrieved context to answer the question. "
"If you don't know the answer, just say that you don't know. "
"Use three sentences maximum and keep the answer concise.\n"
"Question: {question} \n"
"Context: {context}"
)


def generate_answer(state: MessagesState):
"""Generate an answer."""
question = state["messages"][0].content
context = state["messages"][-1].content
prompt = GENERATE_PROMPT.format(question=question, context=context)
response = llm.invoke([{"role": "user", "content": prompt}])
return {"messages": [response]}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
input = {
"messages": convert_to_messages(
[
{
"role": "user",
"content": "What does Lilian Weng say about types of reward hacking?",
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "1",
"name": "retrieve_blog_posts",
"args": {"query": "types of reward hacking"},
}
],
},
{
"role": "tool",
"content": "reward hacking can be categorized into two types: environment or goal misspecification, and reward tampering",
"tool_call_id": "1",
},
]
)
}

response = generate_answer(input)
response["messages"][-1].pretty_print()

7. 组装图表

generate_query_or_respond 开头,并确定是否需要调用 retriever_tool

使用 tools_condition 跳转到下一步:

  • 如果 generate_query_or_respond 返回 tool_calls ,调用 retriever_tool 获取上下文
  • 否则,直接回复用户

对检索到的文档内容按与问题的相关性( grade_documents )进行评分,并路由到下一步:

  • 如果不相关,使用 rewrite_question 重写问题,然后再次调用 generate_query_or_respond
  • 如果相关,请继续到 generate_answer 并使用检索到的文档上下文生成最终响应 ToolMessage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode
from langgraph.prebuilt import tools_condition

# 创建一个基于状态图(StateGraph)的流程,用于管理对话或任务的执行流程
workflow = StateGraph(MessagesState)

# 定义流程中将循环执行的各个节点
workflow.add_node(generate_query_or_respond) # 判断是生成检索查询还是直接回复用户
workflow.add_node("retrieve", ToolNode([retriever_tool])) # 检索节点:使用检索工具(retriever_tool)从知识库中查找相关文档
workflow.add_node(rewrite_question) # 重写问题节点:当检索结果不相关时,优化并重写用户的问题
workflow.add_node(generate_answer) # 生成答案节点:基于检索到的信息生成最终回答

# 设置流程的起始点:从 `generate_query_or_respond` 节点开始
workflow.add_edge(START, "generate_query_or_respond")

# 添加条件边:决定是否进行文档检索
workflow.add_conditional_edges(
"generate_query_or_respond",
# 使用 `tools_condition` 函数判断 LLM 的输出意图:
# 如果 LLM 决定调用 `retriever_tool` 工具,则进入检索;如果选择直接回复,则结束流程
tools_condition,
{
# 将条件判断结果映射到图中的具体节点
"tools": "retrieve", # 若需调用工具,则跳转到检索节点
END: END # 若无需调用工具(即可以直接回答),则结束流程
},
)

# 在 `retrieve` 节点执行后,根据文档相关性判断下一步操作
workflow.add_conditional_edges(
"retrieve",
# 调用 `grade_documents` 函数评估检索到的文档是否与问题相关
grade_documents,
# 根据评分结果决定流向:
# - 如果相关,进入 `generate_answer`
# - 如果不相关,进入 `rewrite_question`
# (该逻辑在 `grade_documents` 函数中返回 "generate_answer" 或 "rewrite_question")
)

# 添加固定边:生成答案后流程结束
workflow.add_edge("generate_answer", END)

# 重写问题后,回到初始节点重新判断是否需要检索
workflow.add_edge("rewrite_question", "generate_query_or_respond")

# 编译整个工作流,生成可执行的图结构
graph = workflow.compile()
image-20250826170834571

参考资料

《Agentic RAG》 — Agentic RAG

前言

代码见learn-rag-langchain/multi-agent at main · zxj-2023/learn-rag-langchain

什么是多智能体

当我们谈论”多智能体”时,我们指的是由llm驱动的多个独立的agent以特定方式连接在一起。

每个agent可以拥有自己的提示、LLM、工具和其他自定义代码,以最佳方式与其他智能体协作。

这种思维方式非常适合用图来表示,就像 langgraph 所提供的那样。在这种方法中,每个智能体都是图中的一个节点,而它们之间的连接则表示为一条边控制流由边管理,它们通过向图的状态中添加信息来进行通信

多智能体架构梳理

langgraph给我们提供了几种多智能体架构

image-20250818163359517

Network: 每个智能体可以与其他所有智能体通信。任何智能体都可以决定下一步调用哪个其他智能体。

Multi-agent network

Supervisor:每个智能体与一个单一的监督者智能体通信。监督者智能体决定下一步应该调用哪个智能体。

代理监督者 — Agent Supervisor

Hierarchical: 你可以定义一个具有监督者监督者的多代理系统。这是监督者架构的泛化,并允许更复杂的控制流程。

层级代理团队 — Hierarchical Agent Teams

Custom multi-agent workflow: 每个代理只与代理子集通信。流程的部分是确定的,只有一些代理可以决定下一步调用哪些其他代理。

Agent Supervisor

在本教程中,你将构建一个包含两个代理的监督者系统——一个研究专家和一个数学专家。

环境

1
pip install -U langgraph langgraph-supervisor langchain-tavily "langchain[openai]"

1. 创建工作代理

首先,让我们创建我们的专业工作代理——研究代理和数学代理:

研究代理

对于网络搜索,我们将使用 TavilySearch 工具来自 langchain-tavily :

1
2
3
4
5
6
from langchain_tavily import TavilySearch

web_search = TavilySearch(max_results=3,tavily_api_key="tvly-dev-")
web_search_results = web_search.invoke("南京在哪")

print(web_search_results["results"][0]["content"])

为了创建单个工作代理,我们将使用 LangGraph 的预构建代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from langgraph.prebuilt import create_react_agent
from langchain_openai import ChatOpenAI

llm=ChatOpenAI(
model="qwen3-235b-a22b-thinking-2507",
api_key="sk-",
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)

research_agent = create_react_agent(
model=llm,
tools=[web_search],
prompt=(
"你是一个研究代理。\n\n指令:\n- 仅协助与研究相关的任务,不得进行任何数学计算\n- 完成任务后,直接向主管回复\n- 仅回复你的工作结果,不得包含任何其他文字。"
),
name="research_agent",
)

让我们运行代理来验证它的行为是否符合预期。我们将使用 pretty_print_messages 辅助工具来美观地渲染流式代理输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
from langchain_core.messages import convert_to_messages


def pretty_print_message(message, indent=False):
"""
美化打印单条消息

Args:
message: 要打印的消息对象
indent: 是否需要缩进打印
"""
# 将消息转换为美观的HTML格式表示
pretty_message = message.pretty_repr(html=True)
if not indent:
# 如果不需要缩进,直接打印
print(pretty_message)
return

# 如果需要缩进,为每一行添加制表符前缀
indented = "\n".join("\t" + c for c in pretty_message.split("\n"))
print(indented)


def pretty_print_messages(update, last_message=False):
"""
美化打印消息更新

Args:
update: 包含消息更新的数据结构
last_message: 是否只打印最后一条消息
"""
is_subgraph = False # 标记是否为子图更新

# 检查更新是否为元组格式(包含命名空间信息)
if isinstance(update, tuple):
ns, update = update
# 如果命名空间为空,跳过父图更新的打印
if len(ns) == 0:
return

# 提取图ID并打印子图更新信息
graph_id = ns[-1].split(":")[0]
print(f"来自子图 {graph_id} 的更新:")
print("\n")
is_subgraph = True

# 遍历每个节点的更新
for node_name, node_update in update.items():
# 构造更新标签
update_label = f"来自节点 {node_name} 的更新:"
if is_subgraph:
# 如果是子图,添加缩进
update_label = "\t" + update_label

print(update_label)
print("\n")

# 将节点更新中的消息转换为消息对象列表
messages = convert_to_messages(node_update["messages"])
# 如果只要求最后一条消息,则截取最后一条
if last_message:
messages = messages[-1:]

# 打印每条消息
for m in messages:
pretty_print_message(m, indent=is_subgraph)
print("\n")
1
2
3
4
for chunk in research_agent.stream(
{"messages": [{"role": "user", "content": "南京在哪?"}]}
):
pretty_print_messages(chunk)
数学代理

对于数学代理工具,我们将使用纯 Python 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def add(a: float, b: float):
"""将两个数字相加。"""
return a + b


def multiply(a: float, b: float):
"""将两个数字相乘。"""
return a * b


def divide(a: float, b: float):
"""将两个数字相除。"""
return a / b


math_agent = create_react_agent(
model=llm,
tools=[add, multiply, divide],
prompt=(
"你是一个数学代理。\n\n"
"指令:\n"
"- 仅协助处理数学相关任务\n"
"- 完成任务后,直接回复给主管\n"
"- 仅回复你的工作结果,不要包含任何其他文字。"
),
name="math_agent",
)

让我们运行数学代理:

1
2
3
4
for chunk in math_agent.stream(
{"messages": [{"role": "user", "content": "what's (3 + 5) x 7"}]}
):
pretty_print_messages(chunk)

2.创建监督者 langgraph-supervisor

为了实现我们的多智能体系统,我们将使用预构建的 langgraph-supervisor 库中的 create_supervisor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from langgraph_supervisor import create_supervisor
from langchain.chat_models import init_chat_model

supervisor = create_supervisor(
model=llm,
agents=[research_agent, math_agent],
prompt=(
"你是一个管理两个代理的主管:\n"
"- 一个研究代理。将研究相关任务分配给这个代理\n"
"- 一个数学代理。将数学相关任务分配给这个代理\n"
"一次只分配工作给一个代理,不要并行调用代理。\n"
"不要自己做任何工作。"
),
add_handoff_back_messages=True,
output_mode="full_history",
).compile()
1
2
3
from IPython.display import display, Image

display(Image(supervisor.get_graph().draw_mermaid_png()))
image-20250819113009108

现在让我们用一个需要两个代理的查询来运行它:

研究代理将查找必要的 GDP 信息;数学代理将执行除法以找到纽约州 GDP 的百分比,如所请求

3.从头创建监督者

现在让我们从头实现这个多智能体系统。我们需要:

  1. 设置主管如何与各个代理进行沟通
  2. 创建监督代理
  3. 将监督代理和工作代理组合成一个多代理图。
设置代理通信

我们需要定义一种方式,让监督代理能够与工作代理进行通信。在多代理架构中,实现这一功能的一种常见方法是使用handoffs,即一个代理将控制权交给另一个代理。交接允许你指定:

  • destination:要转移到的目标代理
  • payload:要传递给该智能体的信息

我们将通过handoff tools(转接工具)实现转接,并将这些工具交给监督代理:当监督代理调用这些工具时,它将控制权转交给工作代理,并将完整消息历史传递给该代理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
from typing import Annotated
from langchain_core.tools import tool, InjectedToolCallId
from langgraph.prebuilt import InjectedState
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.types import Command


def create_handoff_tool(*, agent_name: str, description: str | None = None):
"""
创建一个“交接”工具函数,用于在 LangGraph 的 Supervisor-Worker 架构中
把当前对话状态移交给指定名称的子 Agent。

参数
----
agent_name : str
目标子 Agent 的名称,必须与 Supervisor 图中注册的节点名一致。
description : str | None
工具的描述文本。如果为 None,则使用默认描述 "Ask {agent_name} for help."。

返回
----
handoff_tool : Callable
一个已用 @tool 装饰的函数,可直接注入到 Supervisor 的工具列表。
"""
# 动态生成工具名,例如 agent_name="math_agent" -> "transfer_to_math_agent"
name = f"transfer_to_{agent_name}"

# 如果调用者没有提供描述,则使用默认描述
description = description or f"Ask {agent_name} for help."

# 用 LangGraph 的 @tool 装饰器注册工具
@tool(name, description=description)
def handoff_tool(
state: Annotated[MessagesState, InjectedState],
tool_call_id: Annotated[str, InjectedToolCallId],
) -> Command:
"""
实际执行交接逻辑的工具函数。

参数
----
state : MessagesState
当前对话状态,由 LangGraph 注入。
tool_call_id : str
本次工具调用的唯一 ID,由 LangGraph 注入。

返回
----
Command
一个 LangGraph Command 对象,告诉框架:
- goto=agent_name : 跳转到哪个子 Agent
- update : 更新后的状态
- graph=Command.PARENT : 在父图(Supervisor)作用域内执行
"""
# 构造一条工具消息,记录交接动作
tool_message = {
"role": "tool",
"content": f"Successfully transferred to {agent_name}",
"name": name,
"tool_call_id": tool_call_id,
}

# 使用 Command 把对话状态连同新消息一起发送到目标 Agent
return Command(
goto=agent_name,
update={**state, "messages": state["messages"] + [tool_message]},
graph=Command.PARENT,
)

# 返回已装饰的工具函数,供 Supervisor 添加进 tools 列表
return handoff_tool


# 创建研究代理的交接工具
assign_to_research_agent = create_handoff_tool(
agent_name="research_agent",
description="Assign task to a researcher agent.",
)

# 创建数学代理的交接工具
assign_to_math_agent = create_handoff_tool(
agent_name="math_agent",
description="Assign task to a math agent.",
)
创建监督代理

然后,我们使用刚刚定义的交接工具来创建监督代理。我们将使用预构建的 create_react_agent :

1
2
3
4
5
6
7
8
9
10
11
12
supervisor_agent = create_react_agent(
model=llm,
tools=[assign_to_research_agent, assign_to_math_agent],
prompt=(
"你是一个管理两个代理的主管:\n"
"- 一个研究代理。将研究相关任务分配给这个代理\n"
"- 一个数学代理。将数学相关任务分配给这个代理\n"
"一次只分配工作给一个代理,不要并行调用代理。\n"
"不要自己做任何工作。"
),
name="supervisor",
)
创建多智能体图

将这些内容整合起来,让我们为我们的整体多代理系统创建一个图。我们将添加监督代理和各个代理作为子图节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from langgraph.graph import END

# 定义多代理主管图
supervisor = (
StateGraph(MessagesState)
# 注意:`destinations` 仅用于可视化,不影响运行时行为
.add_node(supervisor_agent, destinations=("research_agent", "math_agent", END))
.add_node(research_agent)
.add_node(math_agent)
.add_edge(START, "supervisor")
# 总是返回到主管
.add_edge("research_agent", "supervisor")
.add_edge("math_agent", "supervisor")
.compile()
)

在这个代码中,去 research_agentmath_agent 的条件边是通过工具调用实现的,而不是显式的条件边。

工作机制:

  1. 工具作为交接手段

    • assign_to_research_agentassign_to_math_agent 这两个工具被添加到 supervisor_agent
    • 当 supervisor_agent 决定需要某个代理帮助时,它会调用相应的工具
  2. 工具内部实现交接

    1
    2
    3
    4
    5
    6
    def handoff_tool(...) -> Command:
    return Command(
    goto=agent_name, # 这里指定了要跳转到哪个代理
    update={...},
    graph=Command.PARENT,
    )
  3. 隐式的条件边

    • 当 supervisor_agent 调用 assign_to_research_agent 工具时 → 自动跳转到 research_agent
    • 当 supervisor_agent 调用 assign_to_math_agent 工具时 → 自动跳转到 math_agent

什么是 Command 机制

Command 机制是 LangGraph 提供的一种显式控制流程跳转的方式。它允许工具或节点直接指定下一步要执行什么操作,而不需要通过传统的条件边路由。

Command 的核心概念

1
2
3
4
5
6
7
from langgraph.types import Command

Command(
goto=agent_name, # 要跳转到的目标节点
update=state_update, # 要更新的状态
graph=Command.PARENT # 在哪个图中执行(父图/子图)
)

请注意,我们已经从工作代理添加了明确的边回到主管——这意味着它们保证会将控制权返回给主管。如果你希望代理直接响应用户(即,将系统转变为路由器),你可以移除这些边。

Multi-agent network

一个单一智能体通常可以使用单个领域内的一小批工具来有效运作,但即使使用像 gpt-4 这样强大的模型,使用多个工具时也可能效果不佳。

处理复杂任务的一种方法是采用“分而治之”的方法:为每个任务或领域创建一个专门的智能体,并将任务路由到正确的“专家”。这是一个多智能体网络架构的例子。

image-20250819150039818

这个多agent架构,就像多个agent进行讨论,所以也叫Multi Agent Collaboration,但是给我的感觉,比较混乱,agent直接的路由很难去定义,agent一多就搞不清楚了,所以这里也不实战了。

Hierarchical Agent Teams

对于某些应用,如果工作按层次分布,系统可能会更有效。你可以通过组合不同的子图,并创建一个顶层监督者以及中层监督者来实现这一点。

image-20250819151813264

使用预设的supervisor构建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# 1. 定义研究团队的代理
@tool
def web_search(query: str) -> str:
"""执行网络搜索"""
return f"搜索结果:关于'{query}'的最新信息..."

@tool
def analyze_data(data: str) -> str:
"""分析数据"""
return f"数据分析结果:{data}的趋势显示..."

research_agent = create_react_agent(
model=llm,
tools=[web_search, analyze_data],
prompt="你是一个研究专家,负责进行网络搜索和数据分析。",
name="research_specialist"
)

# 2. 定义数学团队的代理
@tool
def calculate_statistics(numbers: list[float]) -> str:
"""计算统计值"""
if not numbers:
return "错误:数据列表为空"
avg = sum(numbers) / len(numbers)
return f"统计结果:平均值={avg:.2f},数据点数量={len(numbers)}"

@tool
def solve_equation(equation: str) -> str:
"""解方程"""
return f"方程 {equation} 的解为:x = 42"

math_agent = create_react_agent(
model=llm,
tools=[calculate_statistics, solve_equation],
prompt="你是一个数学专家,负责统计计算和方程求解。",
name="math_specialist"
)

# 3. 创建研究团队主管
research_supervisor = create_supervisor(
model=llm,
agents=[research_agent],
prompt=(
"你是研究团队的主管。\n"
"你的团队有一个研究专家,负责网络搜索和数据分析。\n"
"根据任务需求,将工作分配给研究专家。\n"
"等待专家完成任务后,总结结果并报告给上级主管。"
),
name="research_supervisor"
).compile(name="research_supervisor")

# 4. 创建数学团队主管
math_supervisor = create_supervisor(
model=llm,
agents=[math_agent],
prompt=(
"你是数学团队的主管。\n"
"你的团队有一个数学专家,负责统计计算和方程求解。\n"
"根据任务需求,将工作分配给数学专家。\n"
"等待专家完成任务后,总结结果并报告给上级主管。"
),
name="math_supervisor"
).compile(name="math_supervisor")

# 5. 创建顶层主管
top_supervisor = create_supervisor(
model=llm,
agents=[research_supervisor, math_supervisor],
prompt=(
"你是顶层主管,管理两个专业团队:\n"
"- 研究团队:负责市场调研、数据分析等任务\n"
"- 数学团队:负责统计计算、方程求解等任务\n"
"根据任务的性质,将工作分配给相应的团队主管。\n"
"等待团队完成任务后,整合所有结果并给出最终报告。"
),
name="top_supervisor"
).compile(name="top_supervisor")

参考资料

LangGraph:多智能体工作流 — LangGraph: Multi-Agent Workflows

Overview

前言

为了后续自己搭建全栈项目做准备,对react做一定的了解

学习目标:大致看懂react的基本语法,可以在ai的协助下完成前端的搭建

介绍

React 是 Facebook(现 Meta)于 2013 年开源的一套用于构建用户界面的 JavaScript 库,现由 React 核心团队与社区共同维护。

项目搭建

项目创建

1
npx create-react-app my-app

npx 是什么?

npm 5.2+ 自带的“包运行器”(Node Package eXecute)。类似uv

脚手架(Scaffold / Boilerplate)是什么?

  1. 定义:官方或社区提供的“项目模板生成器”,一条命令就能创建带目录结构、配置、脚本、依赖的完整项目骨架。
  2. 目的: • 省掉繁琐的初始化、Webpack/Rollup/Vite 配置、ESLint/TypeScript/测试等环境搭建。 • 统一团队规范,降低新人上手成本。

启动开发服务器

1
2
cd my-app
npm start # 或 yarn start

目录速览(核心)

1
2
3
4
5
6
my-app
├─ public/ # 静态资源,index.html 是页面模板
├─ src/
│ ├─ App.js # 根组件
│ ├─ index.js # 应用入口(ReactDOM.createRoot)
└─ package.json # 依赖与脚本

JSX

JSX(JavaScript XML 的缩写)是 React 引入的一种语法糖(syntactic sugar)。它让你在 JavaScript 文件里直接写类 HTML 标记,然后由构建工具(Babel、TypeScript、esbuild、swc)把它翻译成普通的 JavaScript 函数调用

如下

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 找到 public/index.html 中 id="root" 的 DOM 节点,作为 React 应用的挂载点
const root = ReactDOM.createRoot(document.getElementById('root'));

// 2. 将根组件 <App /> 渲染到该挂载点
root.render(
// 3. <React.StrictMode> 是 React 提供的开发模式辅助工具
// 作用:在开发阶段自动检测潜在问题(如过时的 API、副作用重复执行等)
// 注意:它仅在开发环境生效,生产环境不会渲染任何额外 DOM
<React.StrictMode>
{/* 4. 项目真正的根组件 App,所有业务逻辑都从这里开始 */}
<App />
</React.StrictMode>
);

箭头函数

React(以及所有现代 JavaScript)里,“箭头”指的是 箭头函数(Arrow Function),语法是:

1
const 函数名 = (参数) => 返回值或语句块

它的作用可以概括为 “更简洁的函数声明 + 词法作用域的 this”

通俗理解:把小括号的内容变成箭头后的内容

函数组件

函数组件 + JSX 的组合作用是: 以函数的形式返回“虚拟 DOM 描述”,交由 React 渲染成真实 DOM,而不是直接返回 HTML 组件或字符串。

  1. 函数组件的“返回值”
1
2
3
function Welcome(props) {
return <h1>Hello {props.name}</h1>;
}

经过 Babel 编译后等价于:

1
2
3
function Welcome(props) {
return React.createElement('h1', null, 'Hello ', props.name);
}

React.createElement 会生成一个纯 JS 对象(虚拟节点),而不是一段 HTML 字符串。

使用示例

1
2
3
4
5
6
7
8
9
10
// 1. 接收父组件传来的 props
function Card({ title, children }) {
// 2. 返回一段 JSX(最终会被编译成虚拟 DOM)
return (
<div className="card">
<h2>{title}</h2>
{children}
</div>
);
}

使用:

1
2
3
<Card title="函数组件">
<p>Hello, world!</p>
</Card>

DOM(Document Object Model,文档对象模型)是浏览器在内存里把一份 HTML/XML 文档表示成树形结构编程接口(API)。

每个节点(元素、文本、注释…)都是一个对象,拥有属性与方法,例如:

1
2
3
const title = document.getElementById('title');
title.textContent = 'Hi React'; // 改文本
title.style.color = 'red'; // 改样式

插值写法

在 React 中,“插值”专指把一段 JavaScript 表达式的实时结果塞进 JSX 的写法。 核心符号只有一对花括号 { },记住口诀:“JSX 里凡是 {} 包起来的,就是 JavaScript 运行后的值。”

基本文本插值

1
2
const name = 'React';
<h1>Hello, {name}!</h1> // → Hello, React!

属性插值

1
2
3
4
5
6
function App() {
const mytitle="hello"
return (
<div title={mytitle}></div>
);
}

数据渲染

条件渲染

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function App() {
const mytitle="hello"

let mycontent=null
const flag=true
if(flag){
mycontent=<h2>hello</h2>
}
else{
mycontent=<h2>world</h2>
}
return (
<div title={mytitle}>{mycontent}</div>
);
}

列表渲染

1
2
3
4
5
6
7
8
9
function App() {
const list=['1','2','3']
const mycontent=list.map((item)=>{
return <li>{item}</li>
})
return (
<div>{mycontent}</div>
);
}
  1. .map((item) => { ... })Array.prototype.map:遍历数组,把每个元素依次交给回调函数处理,并返回一个新数组。 ‑ (item) 是每次循环拿到的当前元素。
  2. return <li>{item}</li> ‑ 每一次循环里,把当前元素 item 用 JSX 插值语法 {item} 放进 <li> 标签里。

状态处理

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useState } from 'react';
function App() {
const [mycontent,setmycontent]=useState("hello world");
function changeContent(){
setmycontent("hello world2");
}
return (
<>
<div>{mycontent}</div>
<button onClick={changeContent}>change</button>
</>
);
}

useState 是 React 提供的 Hook,让函数组件也能拥有内部状态(state)。可以通过更新函数,调用后触发重新渲染。

对象的状态更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { useState } from 'react';
function App() {
const [mycontent,setmycontent]=useState({
title:'hello world',
content :'hello world content'
});
function changeContent(){
setmycontent({
...mycontent,
content:'new content'
});
}
return (
<>
<div title={mycontent.title}>{mycontent.content}</div>
<button onClick={changeContent}>change</button>
</>
);
}

...mycontent 是 ES6 的 对象展开运算符(object spread)。 一句话:把 mycontent 里所有“旧属性”先抄出来,然后再覆盖/新增你后面写的属性。

react组件的使用

1
2
3
4
5
6
7
8
import { useState } from 'react';
function App() {
return (
<>
<img src={logo} className="App-logo" alt="logo" style={{ width: '100px',backgroundColor: 'grey'}}/>
</>
);
}
  1. className 代替 class 传统 HTML 写 <img class="App-logo">;React 组件里必须用 className,因为 JSX 最终会被编译成 JavaScript 对象,而 class 是 JS 的保留关键字。

  2. 样式写成对象

HTML 写行内样式:style="width:100px;background-color:grey" React 必须写成对象:

1
2
3
4
style={{
width: '100px',
backgroundColor: 'grey' // 驼峰命名
}}

因为 JSX 属性最终会变成 JS 对象的键值对,键名必须合法(驼峰),值可以是任何 JS 值(数字、变量、计算结果)。

  1. 最终产物是虚拟 DOM 节点

<img src={logo} ... /> 在浏览器里不会直接变成 <img> 标签,而是先被编译成:

1
2
3
4
5
6
React.createElement('img', {
src: logo,
className: 'App-logo',
alt: 'logo',
style: { width: '100px', backgroundColor: 'grey' }
});

React 再拿这个对象去做 diff、更新真实 DOM,而不是直接 innerHTML。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function App() {

const imgdata={
className:"App-logo",
style:{
width:'100px',
backgroundColor:'grey'
}
}

return (
<>
<img src={logo} alt="logo" {...imgdata}/>
</>
);
}

利用 JSX 展开运算符(spread attributes)imgdata 里的所有键值一次性“拍平”到 <img> 标签上

组件复用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Article(props) {
return (
<div>
<h2>{props.title}</h2>
<p>{props.content}</p>
</div>
);
}

function App() {
return (
<>
<Article title="标签1" content="内容1" />
<Article title="标签2" content="内容2" />
</>
);
}

组件通信

组件通信的 4 条主线

1️⃣ 父 → 子:props 2️⃣ 子 → 父:回调函数 3️⃣ 隔代/任意:Context 4️⃣ 全局/远端:状态管理库(Zustand、Redux、React Query)

父 → 子

1
2
3
4
5
6
7
8
function Parent() {
const title = 'Hello React';
return <Child title={title} />;
}

function Child({ title }) {
return <h1>{title}</h1>;
}

子 → 父

1
2
3
4
5
6
7
8
9
10
11
12
13
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<p>父:{count}</p>
<Child onInc={() => setCount(c => c + 1)} />
</>
);
}

function Child({ onInc }) {
return <button onClick={onInc}>子按钮 +1</button>;
}

父组件把“修改函数”通过 props 传给子组件,子组件在合适的时机调用它,把数据作为参数传回去。

react hooks

Hook 是什么? Hook 是 React 16.8 引入的 函数级 API,让函数组件拥有

  • 状态(useState)
  • 生命周期(useEffect)
  • 上下文(useContext)
  • 自定义逻辑(自定义 Hook) 而不必写 class。

参考资料

20分钟学会React Hooks 前端开发必看 AI编程工具 CodeGeeX 体验_哔哩哔哩_bilibili

0%