作业一

1

题目:

在某个计算机系统中有一台输入机和一台打印机,现有两道程序投入运行,且程序A先运行,程序B后开始运行。

  • 程序A的运行轨迹为:
    计算50ms,打印100ms,再计算50ms,打印100ms。

  • 程序B的运行轨迹为:
    计算50ms,输入80ms,再计算100ms,结束。

问题:

  1. 两道程序运行时,CPU是否空闲等待?_(有空闲等待 / 无空闲等待)
    若有,在哪段时间内等待?
    ms - ___ms(两个空,第一个填起始时间,第二个填结束时间,仅数字)

  2. 程序A,B是否有等待CPU的情况?
    A:(有等待 / 无等待)
    B:
    (有等待 / 无等待)
    若有,指出发生等待的时刻(若无等待,空格填0)
    (若有两段以上等待时间,用“/”标表示不同等待时间,如0/30和20/40表示0ms-20ms是第一段空闲;30ms-40ms是第二段空闲)
    A:ms - ms
    B:ms - ms

IMG_20250917_090110

2

题目:

在单CPU和两台I/O设备(I1和I2)的多道程序设计环境下,同时投入三个作业运行,其执行轨迹如下:

  • Job1:I2(30ms),CPU(10ms),I1(30ms),CPU(10ms)
  • Job2:I1(20ms),CPU(20ms),I2(40ms)
  • Job3:CPU(30ms),I1(20ms)

已知条件:

  • CPU、I1、I2可以并行工作;
  • 优先级从高到低依次为:Job1、Job2、Job3;
  • 优先级高的作业可以抢占优先级低的作业(抢占式调度);
  • 所有作业同时投入运行。

问题:

  1. 每个作业从投入到完成分别所需的时间。

    • Job1:__ ms
    • Job2:__ ms
    • Job3:__ ms
  2. 从作业投入到完成,CPU的利用率是:

    • / = __%(保留小数点后两位)
  3. I/O设备利用率:

    • I1 利用率: / = __%(保留小数点后两位)
    • I2 利用率: / = __%(保留小数点后两位)

IMG_20250917_090158

3

题目:

在单机系统中,有同时到达的两个程序A、B,若每个程序单独运行,则使用CPU,DEV1(设备1)、DEV2(设备2)的顺序和时间如下表所示。

运行情况 程序A 程序B
CPU 25 20
DEV1 39 50
CPU 20 20
DEV2 20 20
CPU 20 10
DEV1 30 20
CPU 20 45

给定条件:

  1. DEV1和DEV2是不同的I/O设备,它们能够同时工作。
  2. 程序B优先级高于程序A,非抢占式。当程序A占用CPU时,即使程序B需要使用CPU,也不能打断程序A的执行,而应等待。
  3. 当使用CPU之后控制转向I/O设备,或者使用I/O设备之后控制转向CPU,由控制程序执行中断处理,但这段处理时间可以忽略不计。

问题:

  1. 哪个程序先结束?(A / B)
  2. 程序全部执行结束需要多少时间?(__ ms)
  3. 程序全部执行完毕时,CPU利用率是多少?(__%)
  4. A程序等待CPU的累积时间是多少?(__ ms)
  5. B程序等待CPU的累积时间是多少?(__ ms)

IMG_20250917_090145

作业二

1

image-20251010094314155

IMG_20251010_101637

IMG_20251010_101643

2

image-202510101021044143.

IMG_20251010_103349

3

image-20251010110615738

IMG_20251010_110541

作业三

1

有一个盒子里混装了数量相等的黑白围棋子,现在利用自动分拣系统把黑子、白子分开,设分拣系统有两个进程P1和P2,其中进程P1拣白子,进程P2拣黑子。规定每个进程每次拣一子;当一个进程在拣时,不允许另一个进程去拣;当一个进程拣了一子时,必须让另一个进程去拣。试写出进程P1和P2能够正确并发执行的程序。

image-20251111211928061

2

请用信号量和PV操作解决以下问题:桌上有一只盘子,最多可以容纳两个水果,每次仅能放入或取出一个水果。爸爸专向盘子中放苹果(apple),妈妈专向盘子中放橘子(orange),两个儿子专等吃盘子中的桔子,两个女儿专等吃盘子里的苹果。写出爸爸(father)、妈妈(mother)、儿子(son)和女儿(daughter)进程及所需定义的变量和信号量。用PV操作实现爸爸、妈妈、儿子、女儿间的同步与互斥关系。

image-20251111211937533

3

设当前的系统状态如下,此时Available=(1,1,2)

进程 Claim (R1, R2, R3) Allocation (R1, R2, R3) Available (R1, R2, R3)
P1 3, 2, 2 1, 0, 0 1, 1, 2
P2 6, 1, 3 5, 1, 1
P3 3, 1, 4 2, 1, 1
P4 4, 2, 2 0, 0, 2

image-20251111211949933

作业四

1

数组int A[100][100];元素按行存储,在虚拟系统中,采用LRU淘汰算法,一个进程有3页内存空间,每页可以存放200个整数,其中第1页存放程序,假定程序已在内容中,问:

A程序缺页次数为:__次;

B程序缺页次数为:__次。

程序A:

for(int i=0; i<100; i++)
   for(int j=0; j<100; j++)
        A[i,j]=0;

程序B:

for(int j=0;j<100;j++)
   for(int i=0;i<100;i++)   
           A[i,j]=0;

程序 A 分析

代码逻辑

1
2
3
for(int i=0; i<100; i++)    // 外层循环:行
for(int j=0; j<100; j++) // 内层循环:列
A[i][j]=0;

访问顺序:A[0][0], A[0][1], …, A[0][99], A[1][0], …

这是按行访问,与数组的按行存储顺序一致。

缺页计算过程

  1. 访问第0、1行:都在虚拟页0中。
    • 第一次访问 A[0][0] 时,内存为空,发生 1次缺页,调入页0。
    • 后续访问第0行和第1行的剩余199个元素时,都在页0中,直接命中(不缺页)。
  2. 访问第2、3行:都在虚拟页1中。
    • 访问 A[2][0] 时,页1不在内存,发生 1次缺页,调入页1。
    • 后续元素全部命中。
  3. 以此类推
    • 程序按照顺序访问:页0 $\to$ 页1 $\to$ 页2 … $\to$ 页49。
    • 由于我们有2个物理块,LRU算法会淘汰最久未使用的旧页面,但因为访问是单向顺序向前的,每一页只需要调入一次

结果:共有50个页面,每页调入一次。

Total = 50 次缺页。

程序 B 分析

代码逻辑

1
2
3
for(int j=0; j<100; j++)    // 外层循环:列
for(int i=0; i<100; i++) // 内层循环:行
A[i][j]=0;

访问顺序:A[0][0], A[1][0], A[2][0], …

这是按列访问,即“跳跃式”访问。

缺页计算过程

  1. 分析内层循环(i 从 0 到 99,处理第 j 列)
    • i=0, 1: 访问第0、1行 $\to$ 需要 虚拟页0
    • i=2, 3: 访问第2、3行 $\to$ 需要 虚拟页1
    • i=98, 99: 访问第98、99行 $\to$ 需要 虚拟页49
    • 一轮内循环(i=0~99)会依次访问:页0, 页1, 页2, …, 页49。
  2. 内存状态与置换
    • 我们只有 2个 物理块用于数据。
    • 当访问页0、页1时,填满内存。
    • 当需要页2时,根据LRU,淘汰页0。
    • 当需要页3时,根据LRU,淘汰页1。
    • 因为循环访问的页面数量(50页)远大于物理内存(2页),导致内存中的页面不断被替换(颠簸/Thrashing现象)。
    • 结论:在处理每一列(内层循环)时,需要访问所有50个页面。因为内存存不下,这 50个页面每一次都需要重新从磁盘调入
    • 单列(一次外层循环)产生的缺页次数 = 50次
  3. 结合外层循环
    • 外层循环 j 从 0 到 99,共执行 100次
    • 每次外层循环都要重新把这50个页面遍历一遍。由于上一轮留下的页面(最后访问的页48、49)对下一轮开头需要的页(页0)没有帮助。
    • 总缺页次数 = 每列缺页数 $\times$ 列数
    • Total = $50 \times 100 = 5000$。

结果5000 次缺页。

2

在一个请求分页虚存管理系统中,一个程序运行的页面走向是:1 2 3 4 2 1 5 6 2 1 2 3 7 6 3 2 1 2 3 6

分别用FIFO、OPT、和LRU算法,对于分配给程序3个页框的情况,求出缺页异常次数和缺页中断率:

(1)FIFO:缺页异常次数:_次, 缺页中断率:__%.

(2)OPT:缺页异常次数:_次, 缺页中断率:__%.

(3)LRU:缺页异常次数:_次, 缺页中断率:__%.

image-20251205102751318

3

一个页式虚拟存储管理系统中的用户空间为1024KB,页面大小为4KB,内存空间为512KB。已知用户的10、11、12、13号虚页分得的内存页框号为62、78、25、36,试将逻辑地址0BEBCH转换为对应的物理地址: _H。

名词 英文 所在空间 本质 通俗解释(类比)
页面 (Page) Page 逻辑/虚拟空间 (程序里) 程序被切分成的“块” 客人 (要住店的人)
页号 (Page No.) VPN 逻辑/虚拟空间 页面的编号/索引 客人的身份证号
页框 (Page Frame) Page Frame 物理内存 (硬件里) 内存条被切分成的“格” 酒店的房间 (物理存在的空间)
页块 (Block) Physical Block 物理内存 完全等同于“页框” (别名) 完全等同于“房间”
页框号 (PFN) PFN 物理内存 页框的物理编号 房间号码 (如 302 号房)
页表 (Page Table) Page Table 内存中 (系统管理) 映射表 (记录对应关系) 前台登记簿 (记录谁住哪个房间)

image-20251205104211485

作业五

image-20260105153644180

前言

本文讲解matlab与opencv对图像处理的基础操作,代码会有matlab与python两版,可对比学习。

读写

读入图像

matlab

1
I = imread('cameraman.jpg');

python

1
I = cv2.imread('cameraman.jpg', cv2.IMREAD_COLOR)

cv2.IMREAD_COLOR = 强制读成 3 通道 BGR 彩色图。

存入图像

matlab

1
imwrite(J,'cameramanC.jpg');

python

1
cv2.imwrite('cameramanC.jpg', J)

读图并转 double

matlab

1
Image1 = im2double(imread('lotus.jpg'));
  • imread 把图像读成 0-255 的 uint8
  • im2double 把像素值线性缩放到 0–1 浮点,方便后续计算。

为什么要转成double

如果像素是 0–255,你在代码里写 0.299*r 就永远只用到 0.299×255≈76 灰度级,结果会整体偏暗甚至直接截断。

几级灰度的含义是什么

几级灰度”这句话里的“级”就是“台阶”的意思:
把黑→白这段连续亮度等间隔切成多少份,就有多少个离散台阶,叫多少灰度级

  • 2 级灰度 → 纯黑 + 纯白,一共 2 个台阶(1 bit)
  • 8 级灰度 → 0, 36, 73, 109, 146, 182, 219, 255(3 bit)
  • 256 级灰度 → 0–255,共 256 个台阶(8 bit)

uint8 是“Unsigned Integer, 8 bit”的缩写,含义一句话:

无符号、8 位、整型数字,只能放 0–255 的整数。

图像操作

反色

1
J=255-I;

提取通道

matlab

1
2
3
r = Image1(:,:,1);
g = Image1(:,:,2);
b = Image1(:,:,3);

分别提取rgb三个通道

opencv

1
2
matlab:g = Image1(1,1,2)提取G通道的第一个像素点
opencv:g = image[0,0,1]

opencv是从下标0开始

合并通道

NTSC 标准

1
Y = 0.299*r + 0.587*g + 0.114*b;

0.299、0.587、0.114 是 NTSC 在 1953 年定下的“亮度加权系数”,源于人眼三种视锥细胞对 R、G、B 光谱的灵敏度——绿最亮、红次之、蓝最暗

二值化(阈值 0.3)

1
2
BW = zeros(size(Y));   % 先全黑
BW(Y > 0.3) = 1; % 亮度>0.3 的像素置 1(白)

从RGB颜色空间转换成HSI颜色空间

matlab

1
hsi = rgb2hsv(img);

HSI 颜色空间是把一幅彩色图像的每个像素拆成 3 个独立、且更符合人眼感知习惯的物理量:

  1. H(Hue,色调)
    用 0°–360° 的“角度”表示“到底是什么颜色”。
    例:0°≈红,120°≈绿,240°≈蓝,绕一圈回到红。
  2. S(Saturation,饱和度)
    0–1(或 0–100%)表示“颜色有多鲜艳”。
    0 → 灰,1 → 最纯、最艳。
  3. I(Intensity,亮度/强度)
    0–1(或 0–255)表示“有多亮”,与颜色本身无关,只反映明暗。
  • RGB 是“机器友好”的立方体坐标,但人眼很难从 (R,G,B) 直接说出“颜色、多艳、多亮”。

matlab基本操作

获取图像行列和通道数

1
[N,M,~]=size(r);

把二维矩阵 r 的“行数”赋给 N,“列数”赋给 M

截取图像

1
newimage=img(H/4:H*3/4,W/4:W*3/4,:);

人脸肤色检测

YCrCb 阈值法

  1. Y
    “Luma”——亮度(Luminance)。
    对应人眼最敏感的黑↔白信息,决定了你看到的“明暗”。
    计算公式近似:

    1
    Y = 0.299·R + 0.587·G + 0.114·B
  2. Cr
    “Chroma-red”——红色色度
    表示 红色与亮度的差值Cr = R – Y

  3. Cb
    “Chroma-blue”——蓝色色度
    表示 蓝色与亮度的差值Cb = B – Y

人眼对 绿色最敏感,对 红、蓝最迟钝
把“绿色”信息扔掉,只保留 R-YB-Y 两个差值,就能 最小化数据量,同时 还能把颜色还原回来

  • 人类肤色在 YCrCb 颜色空间呈明显的“聚类”特性:无论人种如何,Cb、Cr 两个色度分量都落在狭长带状区域。
  • 选取经验区间(Cr ∈ [133,173],Cb ∈ [77,127])直接做 2D 门限,亮度 Y 不参与判断,因此对光照强度变化不敏感,但对色偏敏感。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def skin_YCrCb(img):
#把一张 BGR 彩色图像从“蓝-绿-红”颜色空间转换到“亮度-红度-蓝度”颜色空间(YCrCb)
ycrcb = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)
min_YCrCb = np.array([0, 133, 77])
max_YCrCb = np.array([255, 173, 127])
'''
把落在 [min_YCrCb, max_YCrCb] 立方体内的像素标为 255(白),其余标为 0(黑),返回一张单通道掩膜图。
执行过程(逐像素):
取 ycrcb 的一个像素 (y, cr, cb)
检查是否同时满足
min_Y ≤ y ≤ max_Y
min_Cr ≤ cr ≤ max_Cr
min_Cb ≤ cb ≤ max_Cb
满足 → 输出 255;任一通道不满足 → 输出 0
'''
skin = cv2.inRange(ycrcb, min_YCrCb, max_YCrCb)
return skin

HSV 阈值法

肤色聚类现象
大量统计表明:

  • 不同人种、不同光照 的肤色在 RGB 空间里沿对角线“灰度轴”散开 → 亮度影响大。
  • 转到 HSV 后,Hue 坐标紧紧挤在 0-20° 之间(红-橙-黄),S 中等偏高V 中等偏亮
1
2
3
4
5
6
def skin_HSV(img):
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
low = np.array([0, 30, 60])
high = np.array([20, 150, 255])
skin = cv2.inRange(hsv, low, high)
return skin

椭圆模型法

原理:将RGB图像转换到YCRCB空间,肤色像素点会聚集到一个椭圆区域。先定义一个椭圆模型,然后将每个RGB像素点转换到YCRCB空间比对是否再椭圆区域,是的话判断为皮肤。

image-20250923091302411

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ---------------- 3. 椭圆模型法 ----------------
def skin_ellipse(img):
ycrcb = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)
# 如果本地没有模型图,现场画一张 256×256 查找表
ellipse_model = cv2.imread('ellipse_skin_model.png', 0)
if ellipse_model is None:
ellipse_model = np.zeros((256, 256), dtype=np.uint8)
cv2.ellipse(ellipse_model, (113, 155), (23, 15),
43, 0, 360, 255, -1)
cr = ycrcb[:, :, 1].astype(np.uint16) # 防止溢出
cb = ycrcb[:, :, 2].astype(np.uint16)
indices = cr * 256 + cb
skin = np.take(ellipse_model, indices)
return skin

将彩色图像 Image1 的 R、B 通道互换

1
img_swap = img(:,:,[3 1 2]);

MATLAB 中,读取彩色图像(如用 imread)默认是 RGB 顺序

  • 第1通道:Red(红)
  • 第2通道:Green(绿)
  • 第3通道:Blue(蓝)

但在 OpenCV(Python/C++) 中,图像默认是 BGR 顺序

  • 第1通道:Blue
  • 第2通道:Green
  • 第3通道:Red

使用或操作,进行图像的嵌入

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
clear; clc; close all;  % 清空所有变量
% 读取两张图片
background = imread('图片2.png');
butterfly = imread('图片1.png');

% 设置缩放比例
scale = 0.5;
small_butterfly = imresize(butterfly, scale, 'bilinear');

% 获取尺寸
[ih, iw, ~] = size(small_butterfly);
x = 200; y = 400;

% 提取背景中对应区域
bg_patch = background(x:x+ih-1, y:y+iw-1, :);

is_black = any(small_butterfly~=0,3); % 蝴蝶主体区域

%只把背景中“蝴蝶主体对应位置”设为 0
% 获取尺寸
[ih, iw, ~] = size(bg_patch);

% 双重循环遍历每个像素
for i = 1:ih
for j = 1:iw
if is_black(i, j) % 如果该位置是蝴蝶主体(非黑)
bg_patch(i, j, 1) = 0; % R 通道设为 0
bg_patch(i, j, 2) = 0; % G 通道设为 0
bg_patch(i, j, 3) = 0; % B 通道设为 0
end
end
end

%现在做 bitor:0 | butterfly = butterfly,背景黑区 | 0 = 背景
patch_bitor = bitor(bg_patch, small_butterfly);

% 写回背景
background(x:x+ih-1, y:y+iw-1, :) = patch_bitor;

% 显示结果
figure;
subplot(1,3,1), imshow(small_butterfly), title(['缩小后的蝴蝶 (', num2str(scale*100), '%)']);
subplot(1,3,2), imshow(bg_patch), title('处理后的背景块(蝴蝶位置清黑)');
subplot(1,3,3), imshow(background), title('最终合成图(bitor)');

image-20251016114415946

利用单尺度和多尺度 Retinex 增强方法实现图像增强

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
I = imread('gugong1.jpg');

if ndims(I) == 3
I_gray = rgb2gray(I);
else
I_gray = I;
end

I_double = double(I_gray) + eps;

% SSR
sigma = 15;
L_ssr = log(conv2(I_double, fspecial('gaussian', [31, 31], sigma), 'same'));
R_ssr = log(I_double) - L_ssr;
ssr_img = mat2gray(R_ssr);

% MSR
sigmas = [15, 80, 250];
weights = [0.33, 0.34, 0.33];
R_msr = zeros(size(I_double));
for k = 1:length(sigmas)
sigma = sigmas(k);
kernel_size = round(6 * sigma) + 1;
if mod(kernel_size, 2) == 0
kernel_size = kernel_size + 1;
end
L = log(conv2(I_double, fspecial('gaussian', [kernel_size, kernel_size], sigma), 'same'));
R_msr = R_msr + weights(k) * (log(I_double) - L);
end
msr_img = mat2gray(R_msr);

figure;
subplot(1,3,1); imshow(I); title('Original');
subplot(1,3,2); imshow(ssr_img); title('SSR');
subplot(1,3,3); imshow(msr_img); title('MSR');

image-20251104095521796

直方图均衡化计算

image-20251112090645146

image-20251112090723928

image-20251112090714359

5.4代码

opencv

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
import numpy as np
import matplotlib.pyplot as plt

# --- Step 1: 构造原始图像 ---
gray_levels = np.array([0, 1/7, 2/7, 3/7, 4/7, 5/7, 6/7, 1]) # 原始灰度值 (0~1)
pixel_counts = np.array([560, 920, 1046, 705, 356, 267, 170, 72])
total_pixels = 64 * 64 # 4096

# 将灰度值映射到整数 0~7 用于图像存储
int_gray_levels = np.round(gray_levels * 7).astype(int) # [0,1,2,3,4,5,6,7]

# 创建图像数组
img = np.zeros(total_pixels, dtype=np.uint8)

start_idx = 0
for i, count in enumerate(pixel_counts):
img[start_idx:start_idx + count] = int_gray_levels[i]
start_idx += count

img = img.reshape((64, 64)) # 重塑为 64x64 图像

# --- Step 2: 直方图均衡化 ---
hist, _ = np.histogram(img, bins=8, range=(0, 8))
cdf = np.cumsum(hist) / total_pixels
mapping = np.round(cdf * 7).astype(int)
equalized_img = mapping[img]

# --- Step 3: 可视化对比 ---
plt.figure(figsize=(8, 4))

plt.subplot(1, 2, 1)
plt.imshow(img, cmap='gray', vmin=0, vmax=7)
plt.title('Original')
plt.axis('off')

plt.subplot(1, 2, 2)
plt.imshow(equalized_img, cmap='gray', vmin=0, vmax=7)
plt.title('Equalized')
plt.axis('off')

plt.tight_layout()
plt.show()

landingpage的组成

1.开始界面,大概说明产品的定位和作用,让用户可以快速注册后进入

Grammarly: Free AI Writing Assistance

image-20250914185941887

image-20250914194430975

2.展示产品的特点功能和使用范例:1.特点功能这块可以像manus把使用过程中比较有特点的功能截屏,做成下面这样;2.范例部分结构可以参考manus或者lovart垂直滑动的效果,效果可以参考genspackGenspark - AI 幻灯片

image-20250914190352267

image-20250914192344320

专为演示文稿打造的 Gamma | 利用 AI 立即构建演示文稿 | Gamma

image-20250914194941544

3.用户的声音,价格。用户声音我觉得参考lovart就可以,声音这边也可以使用一个滚动的效果

TRAE - Collaborate with Intelligence

image-20250914193756695

image-20250914200906675

我感觉我们的风格应该还是要简约大气

PPtYoda技术调用

这个项目的核心就是基于python-pptx的这个包,其实跟我们不是很符合。

他们ppt生成的逻辑核心是模板,一定要有pptx模板,并带有每个部分的备注,上传后解析,把ppt的各个结构转化成json存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
file = models.FileField(
upload_to=get_template_path,
storage=ppt_storage,
)
created_at = models.DateTimeField(auto_now_add=True)

cover_template = models.JSONField(null=True, blank=True, default=dict)
toc_template = models.JSONField(null=True, blank=True, default=dict)
chapter_L1_template = models.JSONField(null=True, blank=True, default=dict)
chapter_L2_template = models.JSONField(null=True, blank=True, default=dict)
blank_template = models.JSONField(null=True, blank=True, default=dict)

slide_templates = models.JSONField(null=True, blank=True, default=list)
components = models.JSONField(null=True, blank=True, default=list)
sections = models.JSONField(null=True, blank=True, default=dict)

生成ppt时,也是让大模型生成符合这种规范的json,然后通过python-pptx填入。

这样好处确实是解决了使用python-pptx时,生成的ppt结构混乱的问题,但是这样生成的ppt完全依赖于你输入的模板,灵活性上有所缺陷。

后续任务

1.导出speaknotes-pdf后端

2.生成完整presentation

docker部署

1
2
3
4
5
6
7
8
9
docker pull postgres

docker run -d --name pgsql \
-p 5432:5432 \
-e POSTGRES_PASSWORD=123456 \
-v D:\database\postgresql:/var/lib/postgresql/data \
postgres

docker run -d --name pgsql -p 5432:5432 -e POSTGRES_PASSWORD=123456 -v D:\database\postgresql:/var/lib/postgresql/data postgres

docker 运行postgresql 极限简洁教程 - 刘老六 - 博客园

恢复数据库

使用pg_restore

1
2
3
4
5
6
7
# 1. 删除现有数据库
psql -U postgres -c "DROP DATABASE dvdrental;"

# 2. 重建空数据库
psql -U postgres -c "CREATE DATABASE dvdrental;"

pg_restore --dbname=postgresql://postgres:123456@localhost:5432/dvdrental --no-owner /workspace/dvdrental

以下是数据库导入的原理

  1. toc.dat(目录文件)

作用 :Table of Contents,包含备份的元数据信息

内容 :数据库结构、表定义、索引、约束、函数等的描述

格式 :二进制格式,记录了所有数据库对象的信息

  1. 数据文件(3038.dat, 3040.dat, …)

作用 :存储实际的表数据

命名规则 :数字对应数据库对象的 OID(Object Identifier)

  1. restore.sql(可选的SQL脚本)

作用 :包含恢复数据库的 SQL 命令

内容 :CREATE TABLE、INSERT、索引创建等语句

pg_restore 工作流程

  1. 读取 toc.dat
  2. 创建数据库结构
  3. 创建约束和索引

SQLAlchemy ORM

范例

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
---
config:
layout: elk
---
erDiagram
零件(Part) ||--o{ 库存(Inventory) : "存放"
库房(Warehouse) ||--o{ 库存(Inventory) : "包含"
供应商(Supplier) ||--o{ 采购(Purchase) : "供应"
零件(Part) ||--o{ 采购(Purchase) : "被采购"
库房(Warehouse) ||--o{ 采购(Purchase) : "接收"
库房(Warehouse) }|--|| 职工(Staff) : "组长"
库房(Warehouse) ||--o{ 职工(Staff) : "雇佣"
零件(Part) {
string part_id PK "零件编号"
string name "名称"
decimal unit_price "单价"
string type "类型"
}
供应商(Supplier) {
string supplier_id PK "供应商编号"
string name "名称"
string address "地址"
string phone "电话"
}
库房(Warehouse) {
string warehouse_id PK "库房号"
string address "地址"
}
职工(Staff) {
string staff_id PK "职工号"
string name "姓名"
string gender "性别"
date hire_date "进厂时间"
string title "职称"
string warehouse_id FK "所属库房"
}
库存(Inventory) {
string warehouse_id PK,FK "库房号"
string part_id PK,FK "零件编号"
int stock_quantity "库存数量"
}
采购(Purchase) {
string purchase_id PK "采购单号"
string part_id FK "零件编号"
string supplier_id FK "供应商编号"
string warehouse_id FK "库房号"
date purchase_date "采购日期"
int quantity "进货数量"
decimal actual_price "实际单价"
}

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
85
86
87
88
# models.py
from sqlalchemy import (
create_engine, Column, String, Integer, DECIMAL, Date, CHAR,
ForeignKey, CheckConstraint, UniqueConstraint
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship

# SQLAlchemy 的基类,所有模型类都需要继承这个 Base
# declarative_base() 创建了一个基类,用于定义数据库表结构的映射
Base = declarative_base()

class Part(Base):
__tablename__ = 'part'

part_id = Column(String(20), primary_key=True)
name = Column(String(100), nullable=False)
unit_price = Column(DECIMAL(10, 2), nullable=False)
type = Column(String(50), nullable=False)

# 约束:单价 >= 0
__table_args__ = (
CheckConstraint(unit_price >= 0, name='check_unit_price_positive'),
)

class Supplier(Base):
__tablename__ = 'supplier'

supplier_id = Column(String(20), primary_key=True)
name = Column(String(100), nullable=False)
address = Column(String(200))
phone = Column(String(20))

class Warehouse(Base):
__tablename__ = 'warehouse'

warehouse_id = Column(String(20), primary_key=True)
address = Column(String(200), nullable=False)

# 可选:未来加组长时在此添加
# leader_staff_id = Column(String(20), ForeignKey('staff.staff_id'), unique=True)

class Staff(Base):
__tablename__ = 'staff'

staff_id = Column(String(20), primary_key=True)
name = Column(String(50), nullable=False)
gender = Column(CHAR(1))
hire_date = Column(Date, nullable=False)
title = Column(String(50))
warehouse_id = Column(String(20), ForeignKey('warehouse.warehouse_id'), nullable=False)

# 约束:性别只能是 M/F
__table_args__ = (
CheckConstraint("gender IN ('M', 'F')", name='check_gender'),
)

class Inventory(Base):
__tablename__ = 'inventory'

warehouse_id = Column(String(20), ForeignKey('warehouse.warehouse_id'), primary_key=True)
part_id = Column(String(20), ForeignKey('part.part_id'), primary_key=True)
stock_quantity = Column(Integer, nullable=False)

# 表级约束:确保库存数量不能为负数
# 当尝试插入或更新为负数时会触发数据库错误
__table_args__ = (
CheckConstraint(stock_quantity >= 0, name='check_stock_non_negative'),
)

class Purchase(Base):
__tablename__ = 'purchase'

purchase_id = Column(String(30), primary_key=True)
part_id = Column(String(20), ForeignKey('part.part_id'), nullable=False)
supplier_id = Column(String(20), ForeignKey('supplier.supplier_id'), nullable=False)
warehouse_id = Column(String(20), ForeignKey('warehouse.warehouse_id'), nullable=False)
purchase_date = Column(Date, nullable=False)
quantity = Column(Integer, nullable=False)
actual_price = Column(DECIMAL(10, 2), nullable=False)

# 表级约束:确保采购业务逻辑的合理性
# - 采购数量必须为正数(不能为零或负数)
# - 实际采购价格必须为正数(不能为零或负数)
__table_args__ = (
CheckConstraint(quantity > 0, name='check_quantity_positive'),
CheckConstraint(actual_price > 0, name='check_price_positive'),
)

建立表

1
DATABASE_URL = "postgresql://postgres:123456@localhost:5432/factory_db"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# create_tables.py
from sqlalchemy import create_engine
from models import Base
from dotenv import load_dotenv
import os

load_dotenv()

# 替换为你的 Docker PostgreSQL 地址
DATABASE_URL = os.getenv("DATABASE_URL")

# 创建数据库引擎,用于连接数据库
# create_engine 会根据 DATABASE_URL 创建对应的数据库连接池
engine = create_engine(DATABASE_URL)
Base.metadata.create_all(engine) # 自动建表!
print("✅ 所有表已创建")

各类 RAG 增强技术

如何提高 RAG 管道的性能 | Milvus 文档

可以通过此GitHub 链接获得本文所列主要方法的简单实现。

我们可以根据 RAG 管道各阶段的作用对不同的 RAG 增强方法进行分类。

  • 查询增强:修改和操作 RAG 输入的查询过程,以便更好地表达或处理查询意图。
  • 增强索引:使用多分块、分步索引或多向索引等技术优化分块索引的创建。
  • 检索器增强:在检索过程中应用优化技术和策略。
  • 生成器增强:在为 LLM 生成提示时调整和优化提示,以提供更好的响应。
  • 增强 RAG 管道:在整个 RAG 管道中动态切换流程,包括使用 Agents 或工具来优化 RAG 管道中的关键步骤。

接下来,我们将介绍每个类别下的具体方法。

查询增强

共有四种方式:假设问题、假设文档嵌入、子查询和回溯提示。接下来我将选取几个具体说明。

HyDE(假设文档嵌入)

HyDE 是假设文档嵌入的缩写。它利用 LLM 制作一个“假设文档\“或虚假\答案,以回应没有上下文信息的用户查询。然后,这个假答案会被转换成向量嵌入,并用于查询向量数据库中最相关的文档块。随后,向量数据库会检索出 Top-K 最相关的文档块,并将它们传送给 LLM 和原始用户查询,从而生成最终答案。

image-20250908101159982

这种方法在解决向量搜索中的跨域不对称问题方面与假设问题技术类似。不过,它也有缺点,如增加了计算成本和生成虚假答案的不确定性。

创建子查询

当用户查询过于复杂时,我们可以使用 LLM 将其分解为更简单的子查询,然后再将其传递给向量数据库和 LLM。让我们来看一个例子。

想象一下,用户会问“Milvus 和 Zilliz Cloud 在功能上有什么不同?\“这个问题相当复杂,在我们的知识库中可能没有直接的答案。为了解决这个问题,我们可以将其拆分成两个更简单的子查询:

  • 子查询 1:”Milvus 有哪些功能?”
  • 子查询 2:”Zilliz Cloud 有哪些功能?”

有了这些子查询后,我们将它们全部转换成向量嵌入后发送给向量数据库。然后,向量数据库会找出与每个子查询最相关的 Top-K 文档块。最后,LLM 利用这些信息生成更好的答案。

image-20250908122807696

增强索引

增强索引是提高 RAG 应用程序性能的另一种策略。让我们来探讨三种索引增强技术:自动合并文档块,构建分层索引,混合检索和重新排名

构建分层索引

在创建文档索引时,我们可以建立两级索引:一级是文档摘要索引,另一级是文档块索引。向量搜索过程包括两个阶段:首先,我们根据摘要过滤相关文档,随后,我们在这些相关文档中专门检索相应的文档块。

image-20250908123520672

在涉及大量数据或数据分层的情况下,例如图书馆 Collections 中的内容检索,这种方法证明是有益的。

混合检索和重新排名

混合检索和重排技术将一种或多种辅助检索方法与向量相似性检索相结合。然后,Reranker会根据检索结果与用户查询的相关性对检索结果重新排序。

常见的补充检索算法包括基于词频的方法(如BM25)或利用稀疏嵌入的大模型(如SPLADE)。重新排序算法包括 RRF 或更复杂的模型,如Cross-Encoder(类似于 BERT 的架构)。

image-20250908123544207

改进检索器

改进 RAG 系统中的检索器组件也能改进 RAG 应用。让我们来探讨一些增强检索器的有效方法:句子窗口检索,元数据过滤

生成器增强

让我们通过改进 RAG 系统中的生成器来探索更多 RAG 优化技术:压缩 LLM 提示,调整提示中的块顺序

调整提示中的块顺序

在论文Lost in the Middle“中,研究人员观察到,LLMs 在推理过程中经常会忽略给定文档中间的信息。相反,他们往往更依赖于文档开头和结尾的信息。

根据这一观察结果,我们可以调整检索知识块的顺序来提高答案质量:在检索多个知识块时,将置信度相对较低的知识块放在中间,而将置信度相对较高的知识块放在两端。

image-20250908124338278

增强 RAG 管道

我们还可以通过增强整个 RAG 管道来提高 RAG 应用程序的性能。

自我反思

这种方法在人工智能 Agents 中融入了自我反思的概念。那么,这种技术是如何工作的呢?

一些最初检索到的 Top-K 文档块是模棱两可的,可能无法直接回答用户的问题。在这种情况下,我们可以进行第二轮反思,以验证这些文档块是否能真正解决查询问题。

我们可以使用高效的反思方法(如自然语言推理(NLI)模型)进行反思,也可以使用互联网搜索等其他工具进行验证。

image-20250908124655326

使用代理进行查询路由选择

有时,我们不必使用 RAG 系统来回答简单的问题,因为它可能会导致更多的误解和对误导信息的推断。在这种情况下,我们可以在查询阶段使用代理作为路由器。这个 Agents 会评估查询是否需要通过 RAG 管道。如果需要,则启动后续的 RAG 管道;否则,LLM 直接处理查询。

image-20250908124734144

Agents 可以有多种形式,包括 LLM、小型分类模型,甚至是一组规则。

通过根据用户意图路由查询,可以重新定向部分查询,从而显著提高响应时间,并明显减少不必要的噪音。

我们可以将查询路由技术扩展到 RAG 系统内的其他流程,例如确定何时利用网络搜索等工具、进行子查询或搜索图片。这种方法可确保 RAG 系统中的每个步骤都能根据查询的具体要求进行优化,从而提高信息检索的效率和准确性。

操作系统概论

操作系统的主要特征

并发性 (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 ($2^{11} = 2048$ 字节)。
    • 这意味着地址的低 11 位是偏移量。
  • 进程需求:
    • 进程实际使用的地址范围是 0 ~ 10468
  • 计算需要多少页:
    • 这意味着填满了第 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。

  • 这意味着总共有 $2^{20}$ (约 100 万) 个页面。

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

痛点:

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

解决方案:

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

2. 逻辑地址的重新划分

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

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

为什么是 10 位?

$2^{10} = 1024$。每个页表项 4 字节。

$1024 \times 4\text{B} = 4\text{KB}$。

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

地址变换过程 (The Walk)

image-20251221161021945

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

  1. 第一跳(查目录):
    • CPU 拿着 $p_1$,去查一级页表
    • 得到结果:知道“这一段地址对应的二级页表”在内存的什么位置。
  2. 第二跳(查页表):
    • CPU 拿着 $p_2$,去刚才找到的那张二级页表里查。
    • 得到结果:终于拿到了真正的物理块号 (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)
  • 耗时: $t_1$(非常短,纳秒级)。

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

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

这是图中 (3) 的路径。

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

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

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

  • 动作:

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

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

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

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

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

缺页中断率 (Page Fault Rate)

怎么衡量虚拟内存效率高不高?就看缺页中断率 ($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$ 代表 磁盘一次存取的总时间 (Total Access Time)。它由三部分组成:

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

  • 对应变量$T_s$ (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 \times N$ = 磁盘一秒钟能扫过多少数据(数据传输率)。
    • 时间 = 总量 / 速度 = $b / (r \times 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 准备从总线上接收数据(数据流向:内存 $\to$ CPU)。
    • 低电平 (0) = Write (写):CPU 准备向总线上发送数据(数据流向:CPU $\to$ 内存)。

通俗理解:这是 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)
    • 计算公式:$T_{cycle} \ge$ 取指时间 + 译码/读寄存器时间 + 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 \times 5 = 850\text{ps}$。
  • 结论:单条指令从进入流水线到流出,所花费的总时间(潜伏期,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 (取指):使用存储单元 $\rightarrow$ 200ps
  • ID (译码):寄存器堆读 $\rightarrow$ 50ps
  • EX (执行):ALU 和加法器 $\rightarrow$ 150ps
  • MEM (访存):使用存储单元 $\rightarrow$ 200ps
  • WB (写回):寄存器堆写 $\rightarrow$ 50ps

当前基准时钟周期:

$T_{clk} = \max(200, 50, 150, 200, 50) = \mathbf{200\text{ps}}$。

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

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

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

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

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

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

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

4

image-20260103175033363

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

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

  • 计算公式:$\text{总时间} = \text{指令条数} \times \text{单条指令执行时间}$

  • 计算过程:

  • 单位换算:

答案: 需要花 $100\mu\text{s}$(或者写 $10^8\text{ps}$)。

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

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

  • 理想流水线假设

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

  • 流水线总执行时间:

(注:精确公式为 $(N + k - 1) \times T_{\text{clk}}$,但 $10^6 \gg 19$,故忽略不计)

  • 计算加速比 (Speedup):

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

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

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

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

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

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

这是最基础的编码过程。

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

2. 补码 $\rightarrow$ 真值 ($[X]_{补} \rightarrow X$)

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

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

3. 求相反数的补码 ($[X]{补} \rightarrow [-X]{补}$)

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

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

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

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

  1. $X \rightarrow [-X]_{补}$:把一个正数真值变成负数补码。
  2. $[X]_{补} \rightarrow X$:把一个负数补码还原成真值(数值部分)。
  3. $[X]{补} \rightarrow [-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 (校验位) $\rightarrow$ 10000011

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


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

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

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

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

  • 最终发送的数据:

    1000001 + 0 (校验位) $\rightarrow$ 10000010

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


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. 生成阶段:如何布阵?

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

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

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

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

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

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

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

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

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

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

一句话心法:

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

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

原码一位乘法

image-20260104224832194

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


1. 核心步骤

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

任务一:处理符号位 (简单)

  • 规则: 同号得正,异号得负。

  • 运算: 用异或门 ($XOR$) 完成。

(例如:$0 \oplus 1 = 1$,结果为负)

任务二:处理数值位 (核心)

  • 规则: 既然符号已经拿走了,剩下的绝对值 $|X|$ 和 $|Y|$ 其实就是两个正整数(或者说无符号数)。
  • 算法: 直接套用你最开始学的“无符号乘法”(列竖式法)。
    • 看 $Y$ 的最后一位。
    • 是 1 $\rightarrow$ 加 $|X|$。
    • 是 0 $\rightarrow$ 加 0。
    • 逻辑右移(前面补0,因为是绝对值运算,不涉及负数符号维持)。

2. 举例演示

假设我们要计算 $X \times Y$

  • $X = -0.1101$ (原码 1.1101)
  • $Y = +0.1011$ (原码 0.1011)

第一步:定符号

把这个 1 存起来,最后贴到结果上。

第二步:算数值 ($1101 \times 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),因为我们在算绝对值,绝对值永远是正的。

第三步:拼接结果

  • 数值部分: $P$ 和 $Y$ 拼起来 $\rightarrow$ 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 \times (-3) = -6$

  • 被乘数 (M) = $2$ $\rightarrow$ 0010
    • $(-M)$补 = 1110 (用于减法操作)
  • 乘数 (Q) = $-3$ $\rightarrow$ 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 只有 4 个车位(0, 1, 2, 3)。

主存有很多块(0, 1, 2, 3, 4, 5…)。

  • 第 0 号主存块: $0 \mod 4 = 0$ $\rightarrow$ 只能停 0号 车位。
  • 第 1 号主存块: $1 \mod 4 = 1$ $\rightarrow$ 只能停 1号 车位。
  • 第 4 号主存块: $4 \mod 4 = 0$ $\rightarrow$ 也要停 0号 车位!
  • 第 8 号主存块: $8 \mod 4 = 0$ $\rightarrow$ 还要停 0号 车位!

优缺点

  • 优点: 简单!你想找第4号块,只用去0号车位看一眼,不在就是不在,不用找别处。电路实现最便宜。

  • 缺点(致命): 冲突太容易发生了。

    如果你写了一段程序,反复需要访问“第0块”和“第4块”。

    • CPU要用第0块 $\rightarrow$ 把0停进0号位。
    • CPU要用第4块 $\rightarrow$ 把0踢走,把4停进0号位。
    • CPU又要用第0块 $\rightarrow$ 把4踢走,把0停进0号位。
    • 这就是“抖动”(Thrashing),明明旁边 1, 2, 3 号车位空着,但它们死活不能停。

2. 全相联映射 (Fully Associative Mapping)

口诀: “自由、随便停”

这是最灵活的规则。我们规定:只要有空位,想停哪儿停哪儿。

规则

没有公式。主存块可以放在 Cache 的任意一个行中。

举个例子

还是 4 个车位。

  • 第 0 号块来了 $\rightarrow$ 停在 0 号位。
  • 第 4 号块来了 $\rightarrow$ 0号位有人了?没关系,停在 1 号位。
  • 第 8 号块来了 $\rightarrow$ 停在 2 号位。

优缺点

  • 优点: 空间利用率极高,只要Cache没满,就不会发生冲突踢人的情况。

  • 缺点: 找车太慢(太贵)。

    当你(CPU)想找“第4号块”时,你不知道它在哪里。你必须同时检查所有的车位(0,1,2,3…)。

    这需要非常复杂的硬件电路(并行比较器),如果 Cache 很大,这种电路极其昂贵且耗电。


3. 组相联映射 (Set Associative Mapping)

口诀: “中庸之道、分组管理”

这是现代 CPU(如 Intel Core, AMD Ryzen)普遍采用的方式。它折中了前两者的方案。

它把 Cache 分成了若干个“组” (Set),每个组里包含几个车位(比如 2 个或 4 个)。

规则

  1. 先定位组(像直接映射): 你必须去指定的组。
  1. 再定位置(像全相联): 到了那个组之后,组内的车位随便停

这被称为 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(\text{组数}) = 0$。所以必须去 Set 0
  2. 找位子: 到了 Set 0,发现里面有“车位0”和“车位1”。只要这两个有一个是空的,第4号块就能停进去。

优缺点

  • 优点:
    • 比直接映射灵活:就算 Set 0 里已经停了“第0号块”,我“第4号块”来了还能停在 Set 0 的另一个空位,不会打架。
    • 比全相联便宜:查找时,只需要在特定的组内(比如搜4个位置)找,不需要全场搜。
  • 地位: 这是工业界的标准答案

直接映射中,主存地址的结构

image-20260104151532082

1. 地址结构的宏观样子

假设 CPU 发出的地址是 $N$ 位二进制数(比如 32位)。在直接映射模式下,它被切分为:

每一段都有其特定的使命。


2. 深度拆解:每一段是干嘛的?

为了方便理解,我们设定一个具体的场景:

  • 地址总长度: 32位
  • Cache 大小: 4 KB ($2^{12}$ 字节)
  • 块大小 (Block Size): 64 Byte ($2^6$ 字节)

根据这个配置,我们来计算每一段的长度和作用。

第一部分:块内偏移 (Block Offset) —— “具体的字节在哪?”

  • 位置: 地址的最低位。

  • 作用: Cache 和主存交换数据是以“块”为单位的(一拿就是 64B)。但 CPU 通常只需要其中的某 1 个字节。偏移量就是告诉 CPU,你要的数据在这个“块”里的第几个位置。

  • 怎么算位数?

    看块大小。

    这里块大小是 64 Byte = $2^6$ Byte。

    所以,需要 6 位 来表示 0~63 的位置。

    • Offset = 6 bits

第二部分:行索引 (Cache Line Index) —— “停在哪个车位?”

  • 位置: 地址的中间部分。

  • 作用: 这是直接映射的核心。它决定了这块数据必须存放在 Cache 的第几行(第几个车位)。

  • 怎么算位数?

    看 Cache 一共有多少行。

    在本例中:$4 \text{KB} / 64 \text{B} = 4096 / 64 = 64$ 行。

    64 行 = $2^6$。

    所以,需要 6 位 来定位这 64 个行(000000 到 111111)。

    • Index = 6 bits

第三部分:标记 (Tag) —— “你是谁?”

  • 位置: 地址的最高位。

  • 作用: 因为有很多个主存块都会映射到同一个 Cache 行(冲突)。当 CPU 拿着地址去查看那个 Cache 行时,发现里面已经存了数据,它怎么知道里面的数据是不是它想要的那个?

    靠比对 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:内存 $\rightarrow$ 寄存器 (去仓库拿材料)。

Store:寄存器 $\rightarrow$ 内存 (把成品放回仓库)。

Move:寄存器 $\rightarrow$ 寄存器 (左右手倒腾)。

ALU:运算 $\rightarrow$ 寄存器 (加工处理)。

读内存:$R[r] \leftarrow M[addr]$ —— 控制器指挥数据从 Memory 流向 Register。

写内存:$M[addr] \leftarrow R[r]$ —— 控制器指挥数据从 Register 流向 Memory。

内部搬运:$R[a] \leftarrow R[b]$ —— 寄存器之间倒手。

运算:$R[a] \leftarrow 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 位二进制,输出 $2^n$ 条线。比如输入 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 顶部的公式:

这句话的意思是:

  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 位 (相当于 $\times 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:说明是分支指令且条件满足 $\rightarrow$ 跳! (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 \leftarrow PC + 4$。
    • (比如从 1000 跳到 1004)。
  • 新逻辑 (30位):$PC \leftarrow PC + 1$。
    • (因为丢掉了两个 0,二进制的 100 00 变成了 100。加 1 就变成了 101,也就是原来的 101 00)。
    • 好处:加法器只需要加 1,电路更简单。

B. 分支跳转 (x 4 消失了?)

  • 旧逻辑:$PC + 4 + (Imm16 \times 4)$。
  • 新逻辑:$PC + 1 + Imm16$。
    • 还记得立即数 Imm16 本来存的就是“几条指令”吗?
    • 以前我们需要把它 $\times 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 位全部用来存目标地址。

核心公式:

这个公式在做什么?

这是一个 “拼凑法” (Pseudo-direct Addressing)。

  1. 高 4 位 (PC<31:28>):保留当前 PC 的高 4 位。这意味着 j 指令不能跳得太远,只能在当前 256 MB ($2^{28}$ 字节) 的区域内跳转。
  2. 低 26 位 (target<25:0>):直接用指令里带的 26 位地址替换掉原来的低位。
  3. 拼接 (concat):把这 4 位和 26 位拼起来,刚好组成新的 30 位 PC 值。

这也是个优化点!

在标准 32 位设计中,target 通常需要左移 2 位 ($\times 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 \times (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

交叉熵

交叉熵(Cross-Entropy)主要用于衡量两个概率分布之间的差异。在分类任务中,它被广泛用作损失函数,来评估模型预测结果与真实标签之间的“不匹配程度”。

  1. 信息量(Information Content)

一个事件 ( x ) 发生的概率为 ( p(x) ),其信息量定义为:
[
I(x) = -\log p(x)
]

  • 概率越小的事件,发生时带来的信息量越大(比如“太阳从西边升起”)。
  • 单位通常是 比特(bit)(以 2 为底)或 纳特(nat)(以自然对数 ( e ) 为底)。
  1. 熵(Entropy)

熵是一个概率分布的平均信息量,表示该分布的不确定性:
[
H(p) = -\sum_{x} p(x) \log p(x)
]

  • 熵越大,不确定性越高(比如公平硬币的熵比偏硬币大)。
  1. 交叉熵(Cross-Entropy)

现在有两个分布:

  • 真实分布 ( p(x) )(比如真实标签)
  • 模型预测分布 ( q(x) )(比如神经网络输出的概率)

交叉熵衡量的是:当我们用分布 ( q ) 来编码来自分布 ( p ) 的事件时,平均需要多少比特

数学定义为:
[
H(p, q) = -\sum_{x} p(x) \log q(x)
]

🔍 注意:交叉熵 ≠ 熵。

  • 熵:( H(p) = -\sum p \log p )
  • 交叉熵:( H(p, q) = -\sum p \log q )

Sigmoid的交叉熵损失函数

Sigmoid 的交叉熵损失函数(通常称为 二元交叉熵损失,Binary Cross-Entropy Loss)是用于二分类问题中,结合 Sigmoid 激活函数使用的损失函数。

  • 在二分类任务中,模型输出一个实数值 ( z )(logit)。
  • 通过 Sigmoid 函数将其映射到概率区间 ([0, 1]):
    [
    \hat{y} = \sigma(z) = \frac{1}{1 + e^{-z}}
    ]
    其中 (\hat{y}) 表示预测为正类(标签为 1)的概率。

  • 真实标签 ( y \in {0, 1} )。

二元交叉熵损失(Binary Cross-Entropy, BCE)

对于单个样本,损失函数定义为:

[
\mathcal{L}(y, \hat{y}) = -\left[ y \log(\hat{y}) + (1 - y) \log(1 - \hat{y}) \right]
]

其中:

  • 若 ( y = 1 ),损失为 ( -\log(\hat{y}) )
  • 若 ( y = 0 ),损失为 ( -\log(1 - \hat{y}) )

Softmax 的交叉熵损失函数

Softmax 函数

给定 logits 向量 ( \mathbf{z} = [z_1, z_2, …, z_C] )(C 为类别数),Softmax 输出预测概率:

[
\hat{y}i = \text{softmax}(z_i) = \frac{e^{z_i}}{\sum{j=1}^{C} e^{z_j}}
]

交叉熵损失(单个样本)

真实标签为 one-hot 向量 ( \mathbf{y} = [y_1, y_2, …, y_C] ),其中只有真实类别位置为 1,其余为 0。

损失函数为:

[
\mathcal{L} = -\sum_{i=1}^{C} y_i \log(\hat{y}_i)
]

由于 ( y_i ) 只有一个为 1(设真实类别为 ( c )),上式简化为:

[
\mathcal{L} = -\log(\hat{y}c) = -\log\left( \frac{e^{z_c}}{\sum{j=1}^{C} e^{z_j}} \right)
]

进一步化简:

[
\mathcal{L} = -zc + \log\left( \sum{j=1}^{C} e^{z_j} \right)
]

Sigmoid和Softmax区别

1.Sigmoid(逐元素

对输入 ( x )(标量或向量中的每个元素):
[
\sigma(x) = \frac{1}{1 + e^{-x}} \in (0, 1)
]

📌 如果输入是向量 ( \mathbf{x} = [x_1, x_2, …, x_n] ),则输出:
[
[\sigma(x_1), \sigma(x_2), …, \sigma(x_n)]
]
每个元素独立计算,彼此无关

2. Softmax(整体归一化)

对输入向量 ( \mathbf{z} = [z1, z_2, …, z_C] ):
[
\text{softmax}(z_i) = \frac{e^{z_i}}{\sum
{j=1}^{C} e^{z_j}} \in (0, 1)
]

✅ 满足:( \sum_{i=1}^{C} \text{softmax}(z_i) = 1 )

应用场景对比

Sigmoid 适用场景:

多标签分类(Multi-label)

  • 每个样本可属于多个类别(如一张图同时有“猫”和“狗”)
  • 输出 C 个独立概率,每个用 Sigmoid 判断是否属于该类
  • 例如:输出 [0.9, 0.2, 0.8] 表示属于类别 0 和 2

Softmax 适用场景:

多分类问题(Multi-class, 互斥)

  • 每个样本只属于一个类别(如 MNIST 手写数字 0~9)
  • 输出 C 个概率,和为 1,最大值对应预测类别
  • 例如:输出 [0.1, 0.7, 0.2] 表示最可能是类别 1

简单来说

  • “多选一” → Softmax
  • “可多选” 或 “是/否” → Sigmoid

为什么多分类要用 Softmax,而不是对每个类别用 Sigmoid 再取最大值?

问题在于 损失函数

如果你用 Binary Cross-Entropy (BCE)(Sigmoid 的配套损失):

  • 损失 = - [y₁·log(p₁) + y₂·log(p₂) + y₃·log(p₃)]
  • 对于标签 [0, 1, 0],损失只惩罚“狗”的预测(希望 p₂→1),但不惩罚“猫”和“鸟”是否太高
  • 结果:模型可能输出 [0.9, 0.95, 0.8],虽然选对了“狗”,但对其他类也过于自信,泛化差。

Softmax + Cross-Entropy

  • 损失 = -log(p₂)
  • 但因为 p₂ = e^{z₂}/(e^{z₁}+e^{z₂}+e^{z₃})要让 p₂ 变大,必须让 z₂ 相对于 z₁、z₃ 更大
  • 所以模型会主动压制错误类别的 logit,学习更清晰的决策边界。

参考资料

《动手学深度学习》 — 动手学深度学习 2.0.0 documentation

课程安排 - 动手学深度学习课程

为什么使用milvus

非结构化数据(如文本、图像和音频)格式各异,蕴含丰富的潜在语义,因此分析起来极具挑战性。为了处理这种复杂性,Embeddings 被用来将非结构化数据转换成能够捕捉其基本特征的数字向量。然后将这些向量存储在向量数据库中,从而实现快速、可扩展的搜索和分析。

Milvus 提供强大的数据建模功能,使您能够将非结构化或多模式数据组织成结构化的 Collections。它支持多种数据类型,适用于不同的属性模型,包括常见的数字和字符类型、各种向量类型、数组、集合和 JSON,为您节省了维护多个数据库系统的精力。

image-20250908091053654

部署(windows)

在 Docker(Linux)中运行 Milvus | Milvus 文档

1
2
3
4
5
# Download the configuration file and rename it as docker-compose.yml
C:\>Invoke-WebRequest https://github.com/milvus-io/milvus/releases/download/v2.6.0/milvus-standalone-docker-compose.yml -OutFile docker-compose.yml

# Start Milvus
C:\>docker compose up -d

注意设置环境变量DOCKER_VOLUME_DIRECTORY来决定卷映射的路径

容器 镜像 在 Milvus 中的角色 一句话说明
etcd quay.io/coreos/etcd:v3.5.18 元数据与协调中心 负责“记帐”——存索引结构、集合信息、节点心跳等,相当于 Milvus 的“大脑备忘录”。
minio minio/minio:RELEASE.2024-12-18T13-15-44Z 对象存储 负责“存文件”——把向量索引文件、大字段、日志快照等落地成对象,相当于 Milvus 的“硬盘”。
standalone milvusdb/milvus:v2.6.0 计算节点(单机版) 负责“干活”——接受 SDK 请求、做向量检索、构建索引,相当于 Milvus 的“工人”。

安装

1
pip install -U pymilvus

基本概念

数据库

在 Milvus 中,数据库是组织和管理数据的逻辑单元。为了提高数据安全性并实现多租户,你可以创建多个数据库,为不同的应用程序或租户从逻辑上隔离数据。例如,创建一个数据库用于存储用户 A 的数据,另一个数据库用于存储用户 B 的数据。

collections

在 Milvus 上,您可以创建多个 Collections 来管理数据,并将数据作为实体插入到 Collections 中。Collections 和实体类似于关系数据库中的表和记录

Collection 是一个二维表,具有固定的列和变化的行。每列代表一个字段,每行代表一个实体。

下图显示了一个有 8 列和 6 个实体的 Collection。

image-20250908094418551

schema

Schema 定义了 Collections 的数据结构。在创建一个 Collection 之前,你需要设计出它的 Schema。

设计良好的 Schema 至关重要,因为它抽象了数据模型,并决定能否通过搜索实现业务目标。此外,由于插入 Collections 的每一行数据都必须遵循 Schema,因此有助于保持数据的一致性和长期质量。从技术角度看,定义明确的 Schema 会带来组织良好的列数据存储和更简洁的索引结构,从而提升搜索性能。

一个 Collection Schema 有一个主键、最多四个向量字段和几个标量字段。下图说明了如何将文章映射到模式字段列表。

image-20250908094645183

与langchain集成

Milvus | 🦜️🔗 LangChain

使用 Milvus 和 LangChain 的检索增强生成(RAG) | Milvus 文档

本人实现的用于分块后存储入milvus的类

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
class MilvusStorage:
"""Milvus向量存储管理类

负责将分块后的文档内容存储到Milvus向量数据库中,
支持向量检索和BM25全文检索。
"""

def __init__(self,
embedding_function: Embeddings,
uri: Optional[str] = None,
db_name: Optional[str] = None,
token: Optional[str] = None,
collection_name: Optional[str] = None):
"""初始化Milvus存储客户端

Args:
embedding_function: LangChain embedding模型实例(必需)
uri: Milvus服务地址,默认从环境变量MILVUS_URI获取
db_name: 数据库名称,默认从环境变量MILVUS_DB_NAME获取
token: 认证令牌,默认从环境变量MILVUS_TOKEN获取(可选)
collection_name: 集合名称,默认从环境变量MILVUS_COLLECTION_NAME获取
"""
# 验证必需参数
if not embedding_function:
raise ValueError("embedding_function是必需参数,必须提供LangChain Embeddings实例")

# 从环境变量读取配置,如果参数没有提供的话
self.uri = uri or os.getenv('MILVUS_URI', 'http://localhost:19530')
self.db_name = db_name or os.getenv('MILVUS_DB_NAME', 'rag')
self.token = token or os.getenv('MILVUS_TOKEN') or None
self.collection_name = collection_name or os.getenv('MILVUS_COLLECTION_NAME', 'chunks')

# 设置embedding函数
self.embedding_function = embedding_function

# 初始化LangChain Milvus向量存储
self.vector_store = Milvus(
embedding_function=self.embedding_function,
connection_args={
"uri": self.uri,
"db_name": self.db_name,
"token": self.token
} if self.token else {
"uri": self.uri,
"db_name": self.db_name
},
collection_name=self.collection_name,
index_params={"index_type": "HNSW", "metric_type": "COSINE", "params": {"M": 16, "efConstruction": 200}}
)

def store_chunks(self, chunk_result: ChunkResult) -> Dict[str, Any]:
"""存储分块结果到Milvus

Args:
chunk_result: 分块结果对象

Returns:
Dict: 插入结果,包含插入状态和记录数

Raises:
ValueError: 当向量存储未初始化时
Exception: Milvus操作异常
"""
if not self.vector_store:
raise ValueError("向量存储未初始化")

if not chunk_result.chunks:
return {
"status": "success",
"inserted_count": 0,
"message": "无数据需要插入"
}

try:
# 转换为LangChain Document格式
documents = self._convert_chunks_to_langchain_docs(chunk_result)

# 为每个文档生成UUID作为主键
from uuid import uuid4
uuids = [str(uuid4()) for _ in range(len(documents))]

# 使用LangChain Milvus添加文档,指定IDs
ids = self.vector_store.add_documents(documents=documents, ids=uuids)

return {
"status": "success",
"inserted_count": len(documents),
"document_ids": ids,
"document_name": chunk_result.document_name,
"strategy": chunk_result.strategy.value,
"collection_name": self.collection_name
}

except Exception as e:
raise Exception(f"Milvus插入失败: {str(e)}")

def _convert_chunks_to_langchain_docs(self, chunk_result: ChunkResult) -> List[Document]:
"""为现有Documents添加存储所需的元数据

Args:
chunk_result: 分块结果,chunks已经是Document列表

Returns:
List[Document]: 添加了元数据的Document列表
"""
documents = []

for idx, chunk in enumerate(chunk_result.chunks):
# 创建符合Milvus集合schema的元数据
# 注意:page_content会自动映射到text_content字段
updated_metadata = {
**chunk.metadata, # 保留原有元数据
"document_name": chunk_result.document_name,
"chunk_index": idx,
"chunk_size": len(chunk.page_content)
}

# 创建新Document以避免修改原始数据
# LangChain会自动将page_content映射到Milvus的text_content字段
# embedding字段会由embedding_function自动生成
doc = Document(
page_content=chunk.page_content,
metadata=updated_metadata
)
documents.append(doc)

return documents

def store_chunks_batch(self, chunk_results: List[ChunkResult]) -> Dict[str, Any]:
"""批量存储多个分块结果到Milvus

Args:
chunk_results: 分块结果列表

Returns:
Dict: 批量插入结果

Raises:
ValueError: 当向量存储未初始化时
Exception: Milvus操作异常
"""
if not self.vector_store:
raise ValueError("向量存储未初始化")

if not chunk_results:
return {
"status": "success",
"message": "没有分块结果需要存储",
"total_chunks": 0,
"document_count": 0
}

try:
# 收集所有文档
all_documents = []
total_chunks = 0

for chunk_result in chunk_results:
if chunk_result.chunks:
documents = self._convert_chunks_to_langchain_docs(chunk_result)
all_documents.extend(documents)
total_chunks += len(documents)

if not all_documents:
return {
"status": "success",
"message": "没有文档需要存储",
"total_chunks": 0,
"document_count": len(chunk_results)
}

# 为所有文档生成UUID作为主键
from uuid import uuid4
uuids = [str(uuid4()) for _ in range(len(all_documents))]

# 批量添加所有文档,指定IDs
ids = self.vector_store.add_documents(documents=all_documents, ids=uuids)

return {
"status": "success",
"message": f"成功存储 {len(chunk_results)} 个文档的 {total_chunks} 个分块",
"total_chunks": total_chunks,
"document_count": len(chunk_results),
"ids": ids,
"collection_name": self.collection_name
}

except Exception as e:
raise Exception(f"Milvus批量插入失败: {str(e)}")

def delete_document(self,
document_name: str,
collection_name: Optional[str] = None) -> Dict[str, Any]:
"""删除指定文档的所有chunks

Args:
document_name: 文档名称
collection_name: collection名称

Returns:
Dict: 删除结果
"""
target_collection = collection_name or self.collection_name

try:
if not self.vector_store:
raise ValueError("向量存储未初始化")

# 使用LangChain Milvus删除功能
# 注意:LangChain Milvus可能不支持按元数据过滤删除,这里提供基本实现
return {
"status": "error",
"document_name": document_name,
"error": "LangChain Milvus不支持按文档名删除,请使用其他方式",
"collection_name": target_collection
}

except Exception as e:
return {
"status": "error",
"document_name": document_name,
"error": str(e)
}

def get_document_stats(self,
document_name: Optional[str] = None,
collection_name: Optional[str] = None) -> Dict[str, Any]:
"""获取文档统计信息

Args:
document_name: 文档名称,None则统计所有文档
collection_name: collection名称

Returns:
Dict: 统计信息
"""
target_collection = collection_name or self.collection_name

try:
if not self.vector_store:
raise ValueError("向量存储未初始化")

# 使用LangChain Milvus获取基本信息
# 注意:LangChain Milvus没有直接的统计方法,这里提供基本信息
return {
"status": "success",
"collection_name": target_collection,
"vector_store_type": "LangChain Milvus",
"embedding_function": str(type(self.embedding_function).__name__),
"connection_uri": self.uri,
"database_name": self.db_name,
"message": "详细统计信息需要通过其他方式获取"
}

except Exception as e:
return {
"status": "error",
"error": str(e)
}

基本ANN搜索

近似近邻(ANN)搜索以记录向量嵌入排序顺序的索引文件为基础,根据接收到的搜索请求中携带的查询向量查找向量嵌入子集,将查询向量与子群中的向量进行比较,并返回最相似的结果。

ANN 和 k-Nearest Neighbors (kNN) 搜索是向量相似性搜索的常用方法。在 kNN 搜索中,必须将向量空间中的所有向量与搜索请求中携带的查询向量进行比较,然后找出最相似的向量,这既耗时又耗费资源。

与 kNN 搜索不同,ANN 搜索算法要求提供一个索引文件,记录向量 Embeddings 的排序顺序。当收到搜索请求时,可以使用索引文件作为参考,快速找到可能包含与查询向量最相似的向量嵌入的子组。然后,你可以使用指定的度量类型来测量查询向量与子组中的向量之间的相似度,根据与查询向量的相似度对组成员进行排序,并找出前 K 个组成员。

ANN 搜索依赖于预建索引,搜索吞吐量、内存使用量和搜索正确性可能会因选择的索引类型而不同。您需要在搜索性能和正确性之间取得平衡。

混合检索

多向量混合搜索 | Milvus 文档

image-20250908092720695

让我们考虑一个真实世界的使用案例,其中每个产品都包含文字描述和图片。根据可用数据,我们可以进行三种类型的搜索:

  • 语义文本搜索:这涉及使用密集向量查询产品的文本描述。可以使用BERTTransformers等模型或OpenAI 等服务生成文本嵌入。
  • 全文搜索:在这里,我们使用稀疏向量的关键词匹配来查询产品的文本描述。BM25等算法或BGE-M3SPLADE等稀疏嵌入模型可用于此目的。
  • 多模态图像搜索:这种方法使用带有密集向量的文本查询对图像进行查询。可以使用CLIP 等模型生成图像嵌入。

混合检索的构建流程:

  1. 创建具有多个向量场的 Collections

    • 定义 Collections Schema
    • 配置索引参数
    • 创建 Collections
  2. 插入数据‘

  3. 执行混合搜索

    • 创建多个 AnnSearchRequest 实例

      混合搜索是通过在hybrid_search() 函数中创建多个AnnSearchRequest 来实现的,其中每个AnnSearchRequest 代表一个特定向量场的基本 ANN 搜索请求。因此,在进行混合搜索之前,有必要为每个向量场创建一个AnnSearchRequest

    • 配置 Rerankers 策略

Rerankers 策略

RRF 排序器 | Milvus 文档

要对 ANN 搜索结果集进行合并和重新排序,选择适当的重新排序策略至关重要。Milvus 提供两种重排策略:

  • 加权排名:如果结果需要强调某个向量场,请使用该策略。WeightedRanker 可以为某些向量场赋予更大的权重,使其更加突出。
  • RRFRanker(互易排名融合排名器):在不需要特别强调的情况下选择此策略。RRFRanker 能有效平衡每个向量场的重要性。

加权排名

加权排名器通过为每个搜索路径分配不同的重要性权重,智能地组合来自多个搜索路径的结果并确定其优先级。与技艺高超的厨师平衡多种配料以制作完美菜肴的方式类似,加权排名器也会平衡不同的搜索结果,以提供最相关的综合结果。这种方法非常适合在多个向量场或模式中进行搜索,其中某些场对最终排名的贡献应比其他场更大。

image-20250908092538807

RRFRanker

互惠排名融合(RRF)排名器是 Milvus 混合搜索的一种重新排名策略,它根据多个向量搜索路径的排名位置而不是原始相似度得分来平衡搜索结果。就像体育比赛考虑的是球员的排名而不是个人统计数据一样,RRF Ranker 根据每个项目在不同搜索路径中的排名高低来组合搜索结果,从而创建一个公平、均衡的最终排名。

RRF Ranker 专门设计用于混合搜索场景,在这种场景中,您需要平衡来自多个向量搜索路径的结果,而无需分配明确的重要性权重。

image-20250908092346065

多租户

实施多租户 | Milvus 文档

Milvus 支持四个级别的多租户:数据库CollectionPartitionPartition Key

数据库级 Collections 级 分区级 分区 Key 级
数据隔离 物理 物理 物理 物理 + 逻辑
最大租户数 默认为 64 个。您可以通过修改 Milvus.yaml 配置文件中的maxDatabaseNum 参数来增加租户数。 默认为 65,536。可以通过修改 Milvus.yaml 配置文件中的maxCollectionNum 参数来增加。 每个 Collection 最多 1,024 个。 百万
数据 Schema 灵活性
RBAC 支持 支持 支持 支持 不支持
搜索性能 中等 中等
跨租户搜索支持 不支持 支持 支持
支持有效处理冷热数据 支持 否 目前不支持 Partition Key 级策略。

比较有意思的教程

多模态rag用 Milvus 制作多模态 RAG | Milvus 文档

使用 Milvus 进行文本到图像搜索 | Milvus 文档

使用 Milvus 搜索图像 | Milvus 文档

0%