IE4756 习题册
IE4756 · Tutorial Solutions

Tutorial 1–5 · 逐题详解

五份 Tutorial,
十七道题,全部拆到底

从图灵机的状态转移表,到 MIPS 机器码的每一个比特,再到分支预测要多准才划算——每题都给出完整推理过程,而不只是一个结论。数值题的答案都经过程序验算。

TM / RAM / PM 模型 缓存与中断 加法器与触发器 MIPS 编码 性能与存储
TUTORIAL 1

抽象机器模型与早期存储

对应模块 08(TM / RAM / PM 三种抽象机器模型)与模块 01(早期计算机的输入介质)。

T1 · Q1

Specify a TM that treats the input binary string as a little-endian binary integer n and decrements n by 1 if n > 0 but leaves it unchanged if n = 0.

先把算法想清楚,再翻译成状态转移表。

① 二进制减一的本质

小端序(little-endian)意味着磁带最左边那一格是最低位,读写头初始就停在这一格——这对我们极其有利,因为减一恰好是从最低位开始处理的。

十进制里 ...000 − 1 要借位;二进制的规则更简单:从最低位往高位扫,把遇到的每一个 0 改成 1,直到遇见第一个 1,把它改成 0,然后就结束了,更高位一律不动。

验证一下:n = 12,大端 1100,小端写成 0011。从左(最低位)扫:0→1、0→1,遇到第一个 1→改成 0,停。得到小端 1101,还原成大端就是 1011 = 11 = 12−1。✓

那 n = 0 怎么办?全是 0,一路把它们都翻成了 1,却始终没遇到 1——扫到磁带末尾的空白 − 时才发现。所以需要一个撤销阶段:往回走,把刚才翻出来的 1 全部改回 0。

② 还有一个容易漏掉的约束

课程的 TM 定义里,输出是运行结束时读写头所在格子往后的那串二进制。所以不能"算完就地停下"——必须把读写头送回最低位那一格再停机,否则输出会少几位。这就是下面 B、C 两个"回程"状态存在的理由。

③ 状态转移表

TM:小端二进制减一(A = 初始状态,H = 终止状态)
当前状态读到写入移动转到含义
A01右 →A还没遇到 1,把这个 0 翻成 1,继续往高位找
A10左 ←B找到了:把它改成 0,减法完成,开始回程
A−−左 ←C扫到头都没有 1 ⇒ n = 0,进入撤销阶段
B0 或 1不变左 ←B回程:一路左移,不改动任何内容
B−−右 →H越过左边界了,退回第一格,停机
C10左 ←C撤销:把刚才翻出来的 1 改回 0
C−−右 →H撤销完毕,退回第一格,停机

一共 3 个工作状态 + 1 个终止状态,转移表 7 条。注意状态 B 里不需要区分读到 0 还是 1——因为经过状态 A 之后,左边的格子要么是被翻成 1 的,要么是那个刚被改成 0 的格子,两种情况都只需要路过。

④ 三种情况各走一遍

执行轨迹(下划线标出读写头位置)
输入过程输出
0011
n = 12
0011 →A 1011 →A 1111 →A(找到) 1101 →B 1101 →B(越界)→H 1101 1101
= 11 ✓
1
n = 1
1 →A(找到,左移越界) →B →H 0 0
= 0 ✓
000
n = 0
000 →A 100 →A 110 →A 111− →A(空白) 111 →C 110 →C 100 →C(越界)→H 000 000
原封不动 ✓
为什么这题要用小端序

如果输入是大端序(最高位在左),同样一道减一题会麻烦得多:读写头得先跑到最右端找到最低位,再往左处理借位,还要处理"最高位变 0 后要不要截断"的问题。小端序让"借位方向"和"读写头自然移动方向"一致,程序短了一半——这正是 TM 这类模型的典型特征:数据的物理排布方式直接决定算法复杂度,因为 TM 完全局部,换个方向就要多跑一趟。

T1 · Q2

Write a RAM program that finds the maximum of all the input integers and stores it in the next memory cell after the input.

RAM 模型的约定:开始时 reg[0] = n(输入个数),输入放在 mem[0..n),指令只有三类——[条件]跳转、基本运算、内存读写(reg[k] ← mem[reg[j]] / mem[reg[j]] ← reg[k],注意地址必须先放在某个寄存器里)。

// reg[0] = n(输入个数,已给定)
// reg[1] = i(当前下标)   reg[2] = max(当前最大值)
// reg[3],reg[4],reg[5] = 临时

reg[1] ← 0
reg[2] ← mem[reg[1]]      // max := mem[0],先拿第一个元素打底
reg[1] ← reg[1] + 1       // i := 1

maxloop:
reg[3] ← reg[1] < reg[0]  // i < n ?
goto done if reg[3] = 0
reg[4] ← mem[reg[1]]      // x := mem[i]
reg[5] ← reg[2] < reg[4]  // max < x ?
goto skip if reg[5] = 0
reg[2] ← reg[4]           // max := x
skip:
reg[1] ← reg[1] + 1       // i := i+1
goto maxloop

done:
mem[reg[1]] ← reg[2]      // 此刻 reg[1] 恰好 = n,所以写的就是 mem[n]
最关键的一个细节

最后一行为什么能直接写 mem[reg[1]]?因为循环的退出条件是 i < n 不成立,也就是 i 恰好等于 n 的那一刻跳出。于是 reg[1] 天然就等于 n,mem[n] 正好是"输入之后的下一格"。不需要再算一次地址,也不需要额外的寄存器——这是循环变量复用的典型写法,考试时能省掉两行。

  • 为什么要先取 mem[0] 打底,而不是把 max 初始化成 0?因为输入可能全是负数,初始化成 0 会得到错误答案。用"第一个元素"打底对任何取值范围都安全。
  • 边界情况:n = 0 时不存在最大值,上面的程序会读 mem[0](越界读一个没定义的值)。严谨的写法应在开头加一句 goto end if reg[0] = 0。考试时写一句注释说明即可。
  • 复杂度:循环体是常数条指令,执行 n−1 轮,Θ(n) 时间、O(1) 额外空间。注意 RAM 模型里 mem[reg[1]] 这种"随机访问"只算一步——这正是模块 08.3 说的"完全非局部",也是 RAM 比 TM 高效得多的根本原因(同样一道求最大值,TM 需要来回搬运比特,远不止 Θ(n))。
T1 · Q3

Explain how to translate any PM to some other PM in which each memory node has only 3 fields.

核心思路一句话:把一个"胖节点"拆成一小棵由"瘦节点"搭成的树,原来指向胖节点的指针改成指向树根。

① 为什么这件事一定做得到

关键前提:字段数 f 是由原 PM 的程序固定下来的常数,不随输入规模变化。这是 PM 模型定义的一部分("节点有固定数量的字段")。正因为 f 是常数,下面所有"多花几步"的开销都是常数倍,不会改变任何算法的渐进复杂度。

② 构造:把 f 个字段挂到一棵二叉树的叶子上

新 PM 的节点统一有 3 个字段,我们把它们用作 (left, right, value)。对原来那个有 f 个字段的节点:

  • 造一棵有 ⌈f⌉ 个叶子的完全二叉树,深度 d = ⌈log₂ f⌉。内部节点只用 left/right 两个字段,value 空着;
  • 第 j 个叶子的 value 字段,存原节点第 j 个字段的内容(原字段存的是字还是指针都无所谓——PM 允许一个字段存字或指针);
  • 原来任何"指向该节点"的指针,现在一律改成指向这棵树的根。
Fig. 1 · 一个 8 字段节点 → 一棵 3 字段节点搭成的二叉树
原 PM:1 个节点 = 8 个字段 f₁f₂f₃f₄ f₅f₆f₇f₈ 新 PM:每个节点只有 3 个字段 (left, right, value) root ·· ···· f₁f₂f₃f₄ f₅f₆f₇ ⋯ f₈ 叶子的 value 存原来的字段 指针改为指向树根

内部节点只借用 left / right 两个字段做导航,叶子只借用 value 字段存数据——三个字段就够了。

③ 指令怎么翻译

  • 读/写字段 j:原来是一步,现在变成"从根出发,按 j 的二进制位依次走 left/right,走 d 步到叶子,再读/写它的 value"。因为 j 是指令里写死的常数,这条路径也是固定的——编译期就能展开成 d+1 条指令,没有循环、没有判断。
  • 新建一个节点:原来一步,现在变成新建 2f−1 个 3 字段节点并把 left/right 接好——同样是固定长度的一段代码。
  • 指针的比较与赋值:完全不变。PM 里指针只能"存储"和"判等",而两个原节点相等 ⟺ 它们的树根相等,所以判等语义自动保持。
  • 寄存器:走路径时需要几个临时寄存器存中间指针。PM 允许固定数量 c 个寄存器,把 c 增大一个常数即可。

④ 代价

每一步原指令 → 至多 d+1 = ⌈log₂ f⌉+1 步新指令。f 是常数 ⇒ 这是 O(1) 的常数倍减速,任何算法的渐进复杂度完全不变。空间上从 f 个字段变成约 2f 个字段,也是常数倍。

用链表更简单,但慢一点

也可以不用树,直接把 f 个字段串成一条链表:节点用作 (value, next, 空),访问字段 j 就 next 走 j 次。实现更简单,代价是每次访问最多 f 步而不是 log f 步——反正 f 是常数,两种都合格。树的写法在 f 较大时常数更小,也更能体现"3 个字段能干什么"这个题眼:2 个字段做链表就够了,第 3 个字段才让你能做分叉。

T1 · Q4

Name benefits and drawbacks of punched cards.

穿孔卡片(IBM 80 列卡是事实标准,一张卡 80 个字符 ≈ 80 字节)是第 1~3 代计算机的主力输入介质。评价它要放回当时的语境:那个年代机时极其昂贵,而人力和纸张便宜。

穿孔卡片的两面
优点缺点
完全机械、不需要电子学
读卡机就是一套机械/光学装置,断电也不会丢数据,是天然的非易失性介质,也不怕磁场干扰。
信息密度低得离谱
一张卡 ≈ 80 字节。今天一张 1 MB 的照片要一万两千多张卡,重几十公斤——物理体积是它的死穴。
人眼可读、可单张修改
孔的位置肉眼可见;某一行写错了,只需重新打这一张卡插回去,不用重做整副牌。这是最早的"编辑"能力。
速度极慢
高速读卡机约 1000 张/分钟 ≈ 1.3 KB/s,和当时 CPU 的速度差了好几个数量级,I/O 成为绝对瓶颈。
程序天然模块化
一副卡就是一个有序序列,可以插入、删除、重排——这直接催生了"子程序库"这种复用方式。
只能顺序访问,且实质上一次性写入
打好的孔补不回去,改一个字符要整张重打;也谈不上随机访问。
标准化、便宜、可离线准备
程序员在打孔机上离线备好卡,昂贵的主机只负责跑——这正是批处理(batch processing)模式的物理基础,让一台机器能服务很多人。
物理上很脆弱
会折、会撕、怕潮(湿度变化会让卡片尺寸改变而卡纸);一副卡掉在地上顺序就全乱了——所以 73–80 列通常专门打上序号,就为了能用分拣机排回去。
保存寿命长
干燥保存几十年仍可读,不像磁介质会退磁。
周转慢、纠错差
交卡 → 排队 → 批量运行 → 几小时后拿到打印结果,一个小错就要再等一轮;校验要靠专门的 verifier 机器和第二个操作员重新键入一遍。
答这道题的加分点

不要只罗列"慢、容量小"。真正的判分点是说出它为什么在当时是合理选择:在机时比人力贵得多的年代,"离线准备 + 批处理"把昂贵资源的利用率拉满;而它最终被淘汰,不是因为某一条缺点,而是因为磁带和终端同时解决了密度和交互性,让"离线打孔"这个前提本身消失了。

TUTORIAL 2

通用性、缓存、分支预测与中断

对应模块 08.5(程序即字符串)、模块 02–03(缓存与分支预测)、模块 04(中断机制)。

T2 · Q1

Design one way to encode a TM as a binary string.

这题的意义远大于题面:只有能把 TM 编码成字符串,才可能有通用图灵机(UTM),也才有"程序可以被当作数据"这件事——模块 08.5 那一跃的技术基础就在这里。

① 要编码的东西

一台 TM 由这些成分决定:状态集合 Q、转移函数 δ、初始状态、终止状态。磁带字母表固定为 {0, 1, −},移动方向为 {左, 右, 停}。

② 关键技巧:用固定顺序消灭分隔符

最省事的做法是给转移表规定一个标准顺序,这样就不必编码"这条转移属于哪个状态、读到什么符号"——位置本身已经说明了一切:

  • 把状态编号为 1…|Q|,约定状态 1 是初始状态、状态 |Q| 是终止状态(终止状态没有转移),这样两者都不用额外编码。
  • 按 (状态 1, 读到 0) , (状态 1, 读到 1) , (状态 1, 读到 −) , (状态 2, 读到 0) , … 的顺序,依次列出每条转移的输出部分。一共 3(|Q|−1) 条。

③ 每条转移的比特布局

写入符号
2 bit
00=0 01=1 10=−
移动方向
2 bit
00=左 01=右 10=停
下一状态
k bit
k = ⌈log₂|Q|⌉,状态编号的定长二进制

④ 完整串的格式

E(M) =  ⟨|Q|⟩  ‖  T₁,₀ T₁,₁ T₁,−  ‖  T₂,₀ T₂,₁ T₂,−  ‖ … ‖  T|Q|−1,−

其中 ⟨m⟩ 是 m 的自定界编码,例如一元码:m 个 1 后跟一个 0
     (读到第一个 0 就知道 |Q| 是多少,于是 k = ⌈log₂|Q|⌉ 也确定了,
      后面每条转移的长度 4+k 就固定了,整串可以无歧义地切开)

总长度 = |Q| + 1 + 3(|Q|−1)(4+k) = O(|Q| log |Q|)。

⑤ 举个完整的例子

用 T1·Q1 那台减一 TM:状态 A=1(初始)、B=2、C=3、H=4(终止),|Q|=4 ⇒ k=2。按标准顺序列出 3×3 = 9 条转移:

减一 TM 的编码(|Q| 的一元码 11110 之后接 9 条转移)
顺序(状态, 读到)写入移动下一状态这 6 个比特
1(A, 0)1 → 01右 → 01A=1 → 0101 01 01
2(A, 1)0 → 00左 → 00B=2 → 1000 00 10
3(A, −)− → 10左 → 00C=3 → 1110 00 11
4(B, 0)0 → 00左 → 00B=2 → 1000 00 10
5(B, 1)1 → 01左 → 00B=2 → 1001 00 10
6(B, −)− → 10右 → 01H=4 → 00*10 01 00
7(C, 0)0 → 00停 → 10C=3 → 1100 10 11
8(C, 1)0 → 00左 → 00C=3 → 1100 00 11
9(C, −)− → 10右 → 01H=4 → 00*10 01 00

* 4 号状态在 2 比特里写成 00(按模 4 编号,或把 k 取 3 位写成 100——只要发送方和接收方约定一致即可,这是编码方案的自由度)。

另一种常见写法:加分隔符

如果不想规定标准顺序,就得显式写出每条转移的 (旧状态, 读到, 写入, 移动, 新状态) 五元组,并用分隔符隔开。经典做法是把每个比特加倍(0→00,1→01),于是 11 这个模式在数据内部永远不会出现,可以安全地当分隔符用。代价是长度翻倍,好处是格式灵活、转移表可以稀疏。两种方案都正确,考试写清楚"怎么保证可以无歧义地解码"才是得分点。

T2 · Q2

Explain why we separate instruction/data caches.

有五个层次的理由,第一个是决定性的,其余是加成。

① 消除结构冒险:同一拍里要取两次

这是最根本的原因。在流水线 CPU 里,IF(取指令)阶段和 MEM(访存)阶段是同时工作的:当第 4 条指令在取指时,第 1 条 lw 正好在访存。如果只有一块统一缓存、一个访问端口,这两件事必须排队——也就是每遇到一条 load/store 就要停一拍。这种因为硬件资源不够而被迫停顿的情况叫结构冒险(structural hazard)。拆成两块物理独立的缓存 = 两个独立端口,取指和访存各走各的,冲突从根上消失。

② 两种访问模式的特性完全不同,可以分别优化

指令流与数据流的差异
指令(I-Cache)数据(D-Cache)
读写几乎只读(不考虑自修改代码)读写都有
局部性空间局部性极强,基本顺序执行分散得多,取决于数据结构
可省掉的电路不需要脏位、不需要写回逻辑 ⇒ 更简单更快必须有写回/写直达、脏位、写缓冲
可独立调的参数可用更大的 cache line(顺序预取收益高)可用更高的相联度(减少冲突失效)

③ 互相不干扰,避免"数据把代码冲掉"

设想一个遍历大数组的循环:如果共用一块缓存,数据流会像洪水一样把整个缓存冲刷一遍,连循环体自己的指令都会被挤出去,于是每一轮迭代都要重新取指令——这叫缓存颠簸(thrashing)。分开之后,数据流量再大也碰不到指令缓存,循环体的指令能稳稳驻留。

④ 物理布局:线更短,频率更高

I-Cache 可以直接放在取指单元旁边,D-Cache 放在访存单元旁边,两段导线都很短。共用一块缓存则必须放在两者之间,两边都要拉长线——在 GHz 级频率下,导线延迟是实打实的时钟周期限制(回想模块 12.5 那条"线越长越难同步"的物理规律)。

⑤ 总带宽直接翻倍

两块缓存各有自己的通路,每拍能同时供给一条指令和一个数据字,聚合带宽是统一缓存的两倍。

为什么只在 L1 这么做

代价是两块缓存要各占面积,而且引入了一致性问题:如果程序修改了自己的代码(JIT 编译器就会这么干),新写的内容进了 D-Cache,而 I-Cache 里还缓存着旧指令——必须显式执行"指令缓存失效"操作。所以 L2 及以下一律是统一缓存:那里离核心远、访问频率低,结构冒险早已不是瓶颈,统一起来反而更省面积、更灵活。这也正是模块 09.4 说的:底层是冯·诺依曼(统一),只在最顶层借用哈佛架构(分离)的速度红利。

T2 · Q3

Explain how branch prediction is helpful for loops.(提示:查 Wikipedia 的 “branch predictor”,重点看 2-bit saturating counter)

① 为什么非猜不可

流水线 CPU 在分支指令还没算出结果时,就必须决定下一拍取哪条指令。等结果出来意味着流水线要空转好几拍(分支惩罚)。所以只能猜——猜对了零代价,猜错了才付出清空流水线的代价。

② 循环是"几乎完全可预测"的完美案例

一个执行 n 轮的循环,它末尾那条向后跳的分支会被执行 n 次,其中 n−1 次跳转、只有最后 1 次不跳。也就是说结果的规律性高达 (n−1)/n——n=100 时是 99%。而循环恰恰占据了程序绝大部分执行时间,所以"把循环分支猜准"几乎等于"把整个程序猜准"。

③ 1 位预测器:每进一次循环错两次

最朴素的做法是记住"上次跳没跳",这次就照做。对循环而言:

  • 最后一轮:预测器说"跳"(因为上次跳了),实际不跳 → 错 1 次;
  • 预测器因此被改写成"不跳"。等下次再进入这个循环(比如外层循环的下一轮)时,第一轮它说"不跳",实际要跳 → 又错 1 次。

所以每执行一遍循环要错 2 次。对于跑在外层循环里的内层循环,这个代价会被放大 m 倍。

④ 2 位饱和计数器:每进一次循环只错一次

用 2 个比特记 4 个状态,跳转就加一、不跳就减一,到两端就饱和不再变;预测结果只看最高位。

Fig. 2 · 2-bit saturating counter 状态机
这两个状态 → 预测「不跳转」 这两个状态 → 预测「跳转」 0001 1011 强不跳弱不跳 弱跳强跳 taken taken taken not taken not taken not taken not taken taken

向右是"跳转"事件,向左是"不跳转"事件,两端饱和自环。预测值 = 最高位。

现在重看循环:跑了几轮之后,计数器早已饱和在 11(强跳)。循环退出时那唯一一次"不跳"只把它拉到 10(弱跳)——最高位仍然是 1,仍然预测跳转。于是下次再进入这个循环时,第一轮预测"跳",正确。

结论 2 位饱和计数器把每次循环的误预测从 2 次降到 1 次(只错在退出那一刻);循环轮数 n 越大,准确率 (n−1)/n 越接近 100%。
这一位比特买到的是什么

多出来的那一位提供了迟滞(hysteresis):单次异常的结果不足以翻转预测方向,必须连错两次才改口。而循环正是"绝大多数时候结果相同、只有一次例外"的典型模式——这一位比特刚好就是为这种模式设计的。嵌套循环收益最明显:内层循环执行 m 次、每次 n 轮,1 位预测器错 2m 次,2 位只错 m 次,直接砍半。

T2 · Q4

Explain why the CPU and not the OS must save the PC before jumping to the interrupt handler.

一句话 这是个"先有鸡还是先有蛋"的死结:要执行任何一条保存 PC 的指令,PC 必须已经被改成处理程序的地址——那时旧值早就没了。

① 把时间轴摊开看

中断发生时,硬件必须做一件事:把 PC 改成中断处理程序的入口地址,否则处理程序根本不会被执行。问题在于,PC 只有一个,一旦写进新值,被中断程序的"下一条指令地址"就被覆盖掉了。

假设保存 PC 是操作系统(软件)的责任,那么这段保存代码只可能出现在中断处理程序的开头。但等到处理程序的第一条指令开始执行时,PC 里装的已经是处理程序自己的地址了。软件根本没有任何一个指令槽可以插进"跳转之前"这个时刻——那个时刻在定义上就不属于软件。

② 中断是"非自愿"的,被中断方也没机会自救

中断与函数调用有本质区别:函数调用是程序自己发起的,所以 jal 可以顺手把返回地址存进 $31。而中断是异步的,可能发生在任意两条指令之间,被中断的程序完全不知情、也没做任何准备,当然不可能提前保存什么。

③ 还必须是原子的

"保存 PC"和"载入处理程序地址"这两步必须不可分割。如果它们之间存在任何一个可被打断的间隙,另一个中断恰好在此时到达,就会把刚保存的 PC 覆盖掉,被中断的程序永远回不去了。只有硬件能提供这种原子性——它在取指周期的边界上一次性完成整个动作序列(压栈保存 PC 与状态字、关中断、载入向量地址),中间不存在任何"指令边界"。

对照 MIPS 的具体实现

MIPS 用一个专用寄存器 EPC(Exception Program Counter)接住旧 PC,x86 则是把 PC 和标志寄存器一起压进栈。两者都是硬件在跳转的同一个动作里完成的,软件在这件事上没有任何发言权。同样的逻辑也适用于状态标志寄存器——这正好引出下一题。

T2 · Q5

Explain why the return-from-interrupt instruction is necessary if there are read-only CPU flags.

先说清楚"只读标志位"指什么:CPU 内部有一批状态位——中断使能位、特权/内核模式位、条件码等——它们对普通指令而言是不可写的,软件读得到、改不了。

① 中断进入时,硬件改动了这些标志位

接受中断时硬件不只改 PC,还会顺手:关闭中断使能(免得处理程序刚开头就被再次打断)、切到内核/特权模式。所以要正确返回,必须把这些标志位连同 PC 一起恢复原样。

② 它们是只读的 ⇒ 根本没有普通指令能写回去

这是最直接的理由:既然没有任何一条普通指令能改写这些位,就必须专门提供一条指令,它的定义就是"把先前保存的标志位和 PC 一起恢复"。这条指令就是 return-from-interrupt(MIPS 的 eret、x86 的 iret)。

③ 就算能分开写,拆成两条指令也必然出错

这是更深一层、也是考试最想看到的论证。假设我们有"恢复标志位"和"跳回去"两条独立指令,无论怎么排都是坏的:

为什么必须原子
顺序会发生什么
先恢复标志位
再跳转
标志位一恢复,就立刻回到用户模式、并重新开放中断——可此刻还停在中断处理程序里!接下来那条跳转指令将以用户权限执行,可能没有权限做这次跳转;更糟的是,中断已开放,一个新中断可能正好在这个缝隙里到达,把还没用上的返回地址冲掉。
先跳转
再恢复标志位
跳转一旦执行,控制权已经回到用户程序,第二条指令永远不会被执行——标志位就停留在"内核模式、中断关闭"的状态,系统从此再也响应不了任何中断。

结论:恢复标志位与恢复 PC 必须在同一个不可分割的步骤里完成,而这正是 return-from-interrupt 这条指令存在的全部意义。

"只读"本身是一道安全设计

这些标志位被设成只读并不是偷懒,而是刻意的安全边界:如果用户程序能随手写"特权模式位",那它一句话就能把自己提权成内核,整个保护模式(模块 02.2)就形同虚设。所以 ISA 的做法是——彻底禁止普通指令写它们,只留 return-from-interrupt 这一个受控出口,而且这个出口只能把标志位恢复成先前由硬件保存过的值,不能任意设定。权限只能"还回去",不能"拿上来"。

TUTORIAL 3

加法器与触发器:从门到寄存器

对应模块 05(加法器)、模块 06(晶体管与传输门)、模块 07(触发器)。这一份是纯动手题,下面给出设计结构与分析,实际连线在 Logisim 里按此搭建即可。

T3 · P1

1. Implement a 32-bit slow-adder in Logisim. 2. Implement a 32-bit fast-adder in Logisim. 3. Compare the gate-delays between the two adders.

① 慢加法器(行波进位 ripple-carry)的结构

地基是全加器:输入 aᵢ、bᵢ、cᵢ,输出

sᵢ    = aᵢ ⊕ bᵢ ⊕ cᵢ
cᵢ₊₁  = (aᵢ · bᵢ) + (cᵢ · (aᵢ ⊕ bᵢ))     // 两个都为1必进位;恰一个为1则把进位传下去

把 32 个全加器串成一排,cᵢ₊₁ 接到下一位的 cᵢ,最低位的 c₀ 接 0(或减法时接 1)。

关键路径在进位链上:aᵢ⊕bᵢ 在第 0 级就能算好(不依赖进位),所以每一级从 cᵢ 到 cᵢ₊₁ 只经过 AND → OR 两级门。32 级串联:

延迟 ≈ 1 (先算所有 aᵢ⊕bᵢ) + 2×32 (进位链) + 1 (末位求和的 XOR) ≈ 66 级门延迟,即 Θ(n)

② 快加法器(进位超前 / 并行前缀)的结构

课程的表述是定义 carry(k,m,c) = "第 k 位到第 m 位这一段,在外部进位为 c 的假设下产生的进位",并同时预先算出 c=0 和 c=1 两个答案。合并公式是

carry(k,m,c) = carry(k,p, carry(p+1,m,c))      // p 是区间中点

硬件上,这个"合并"就是一个 2 选 1 MUX:用低半区在外部进位 c 下的输出,去选择高半区两个预算好的答案之一。等价的经典写法是生成/传播对:

gᵢ = aᵢ · bᵢ          // generate:本位必定产生进位
pᵢ = aᵢ ⊕ bᵢ          // propagate:本位会把进位原样传下去

合并:  G = G_hi + P_hi · G_lo          P = P_hi · P_lo

因为这个合并操作满足结合律,32 位可以排成一棵平衡二叉树:自底向上 log₂32 = 5 层算出所有区间的 (G,P),再自顶向下 5 层把真实进位分发到每一位。

延迟 ≈ 1 (算 g/p) + 2×5 (上行合并) + 2×5 (下行分发) + 1 (求和 XOR) ≈ 22 级门延迟,即 Θ(log n)

③ 两者对比

32 位加法器:两种实现的代价对比
慢加法器(ripple-carry)快加法器(carry-lookahead)
关键路径Θ(n) ≈ 2n+2 ≈ 66 级Θ(log n) ≈ 4·log₂n+2 ≈ 22 级
加速比1×约 3×
门数量Θ(n),约 5 门/位 ≈ 160 门Θ(n log n),约 2–3 倍于前者
最大扇出很小(每个进位只驱动下一级)需要留意——所以进位沿树逐层分发,而不是一根线扇出到 32 位
结构一维链,好搭好懂二叉树,层次化子电路

位宽越大差距越夸张:n=8 时约 18 : 14,优势还不明显;n=64 时变成 130 : 26,5 倍。这就是为什么现代 64 位 CPU 里绝不可能用行波进位——它会直接锁死整颗芯片的时钟频率。

在 Logisim 里的实操建议

用层次化子电路,不要在一张图上铺 32 位:先做 full-adder(1 位)→ 再做 adder-4(4 位块)→ 再拼 32 位。快加法器同理,先做合并单元(输入两组 (G,P),输出一组 (G,P))这一个子电路,再用它搭树。

Logisim 不会自动报告门延迟,验证方法有两条:一是数——沿最长路径数经过几级门;二是测——给两个加法器同一组输入,用 Logisim 的单步时钟(tick)观察输出稳定需要多少个传播步。功能正确性则用几组关键向量验:0+0、全1 + 1(测最长进位链)、0x7FFFFFFF + 1(测溢出)、以及若干随机值。

T3 · P2 · Q1

Translate the D Flip-Flop with Enable completely to transistors (i.e. implement the multiplexer). Hint: use transmission gates just like for the Reset.

① Enable 到底是什么电路

先想清楚 Enable 的语义:Enable = 1 时,时钟上升沿把 Data 存进去;Enable = 0 时,上升沿照样发生,但存进去的值必须和原来一样。

注意后半句——触发器每个上升沿总要采样一次,我们没法"取消"时钟沿。所以实现方式不是"挡住时钟",而是换掉它采样的内容:Enable=0 时,让它采样自己当前的输出 Q,于是存回去的还是同一个值,等效于没变。

这正好是一个 2 选 1 多路选择器:

D_internal = Enable ? Data : Q          // Q 是触发器自己的输出,反馈回来

② 用传输门实现这个 MUX:只要 6 个晶体管

  • TG₁ 接在 Data 与内部节点之间:NMOS 栅极接 Enable,PMOS 栅极接 Enable′ ⇒ Enable=1 时导通。
  • TG₂ 接在 Q(反馈)与同一个内部节点之间:NMOS 栅极接 Enable′,PMOS 栅极接 Enable ⇒ Enable=0 时导通。
  • 一个反相器从 Enable 生成 Enable′(2 个晶体管)。

两个 TG 的输出直接短接成内部节点 D_internal。因为控制信号互补,任意时刻恰好有一个导通、另一个呈高阻——绝不会两个源同时驱动同一个节点,没有冲突。

Fig. 3 · 带 Enable 的 D 触发器:Enable 就是输入端的一个传输门 MUX
Data TG₁ En=1 导通 TG₂ En=0 导通 D_internal Enable Enable′ En 控制 TG₁/TG₂ 的互补栅极 主从 D 触发器 (2 个 TG 锁存器 + 时钟反相器,18 管) Clock Q 反馈:Enable=0 时把 Q 重新存一遍 = 保持不变

传输门画成一对相向的三角形——这是它的标准符号,表示"双向导通的开关",而不是有方向的逻辑门。

③ 为什么用传输门而不是 AND/OR 门

同一个 2:1 MUX 的两种做法
传输门实现AND-OR 门实现
表达式两个互补开关直接短接D·En + Q·En′
晶体管数2 TG + 1 反相器 = 62 AND + 1 OR + 1 NOT ≈ 20
门延迟≈ 0(只是导通一条通路)2 级
信号质量NMOS+PMOS 并联,传 0 传 1 都不掉阈值电压正常
会不会冲突控制信号互补 ⇒ 永远只有一条通路 ⇒ 不会不会

这也正是题目提示里说的"just like for the Reset"——Reset 用的是同一招:Reset=1 时用一个传输门把内部节点拉到 0(或选中常量 0 这一路),Reset=0 时让正常通路导通。凡是"二选一"的地方,在晶体管级都优先考虑一对互补传输门。

总账 基础主从 D 触发器 18 管 + Enable 的 MUX 6 管 + Reset 的传输门 ≈ 4 管 ≈ 28 个 MOSFET。
T3 · P2 · Q2–3

2. Use 4 D Flip-Flops with Enable to make a 4-bit counter that increases on each clock rising edge. 3. Use 32 D Flip-Flops with Enable to make a 32-bit register PC, and combine it with a fast-adder with constant second input 1 to get a program counter.

这两题是同一个模式的小号版和大号版,一起看最清楚:寄存器存"现在",组合逻辑算"下一步",时钟沿把"下一步"变成"现在"。这就是所有时序电路的通用骨架。

① 4 位计数器

把 4 个带 Enable 的 D 触发器并排放,它们共用同一个 Clock 和同一个 Enable,构成一个 4 位寄存器,输出记作 Q[3:0]。然后用组合逻辑算出 Q+1,接回各触发器的 Data 端:

D[3:0]  =  Q[3:0] + 1        // 组合逻辑,一个 4 位加一电路

每个上升沿,4 个触发器同时把各自的 D 端锁存进去,于是 Q 整体变成 Q+1。加一电路可以直接写成半加器链(比通用加法器省很多门):

Q₀′ = ¬Q₀
Q₁′ = Q₁ ⊕ Q₀
Q₂′ = Q₂ ⊕ (Q₀·Q₁)
Q₃′ = Q₃ ⊕ (Q₀·Q₁·Q₂)        // 第 i 位翻转 ⟺ 比它低的位全是 1
为什么一定要用"同步"计数器

另一种更省事的写法是行波计数器:把每一位的输出当作下一位的时钟。它能工作,但各位翻转的时刻是错开的(一级一级传下去),中间会短暂出现错误的中间值(比如 0111 → 1111 → 1011 → …→ 1000 这样的毛刺),而且整体延迟随位数线性增长。所以一旦这个计数值要被别的电路读取(PC 恰恰就是),必须用上面那种所有触发器共享同一个时钟的同步写法。

② 32 位程序计数器 PC

结构一模一样,只是把规模放大、把"加一电路"换成上一题做好的快加法器:

  • 32 个带 Enable 的 D 触发器并排 ⇒ 32 位寄存器 PC,共享 Clock 与 Enable;
  • PC 的输出接到快加法器的第一个输入,第二个输入接常量 1(字节寻址的真实 MIPS 里接常量 4,因为每条指令占 4 字节);
  • 加法器的输出接回 32 个触发器的 Data 端。

于是每个上升沿 PC ← PC + 1,程序计数器就诞生了。

这里才看出前面那些题的用处

为什么第二个输入是常量就仍然值得用快加法器?因为 PC 的更新处在 CPU 的关键路径上——每一拍都要做一次,它的延迟直接决定时钟周期的下限。这里省下的 40 多级门延迟,是整颗 CPU 主频的一部分。

另外注意 Enable 端在这里终于派上用场了:流水线停顿(stall)时需要让 PC 保持不动,靠的就是把 Enable 拉低;而 Reset 端则对应模块 07.3 的上电复位——保证开机时 PC 从一个已知地址开始,否则 CPU 会从随机地址取指令。真实 CPU 还会在加法器之后加一个多路选择器,用来在 PC+4、分支目标、跳转目标之间选择——那就是模块 10.6 里控制单元做的事了。

TUTORIAL 4

MIPS 编码与递归比较器

对应模块 10(指令集架构与 MIPS)。前三题是纯编解码训练,第四题是 ALU 内部的一个漂亮电路。

T4 · Q1

What do the following MIPS instructions do? a) 0x00000000 b) 0x8c82fffc

解码的第一步永远一样:先看最前面 6 位 opcode,它决定后面 26 位怎么切。

a) 0x00000000

32 位全是 0。opcode = 000000 ⇒ R 型,于是要看最后 6 位 funct = 000000 ⇒ sll(逻辑左移)。其余字段:rs=0, rt=0, rd=0, shamt=0。

opcode
000000
R 型
rs
00000
$0
rt
00000
$0
rd
00000
$0
shamt
00000
移 0 位
funct
000000
sll

逐字翻译是 sll $0, $0, 0,语义为 R[0] := R[0] << 0。而 $0 硬接线为常数 0、写它没有任何效果,移 0 位本身也什么都不做——

答案 a 什么也不做,这是 MIPS 的标准空指令 NOP。

顺带一个设计观察:MIPS 特意让"全 0"这个最容易出现的比特模式恰好是无害的 NOP。这样一来,一段被清零的内存被当作代码执行时不会造成破坏,而且流水线插入气泡时只要往指令寄存器里塞 0 即可,不需要额外电路。

b) 0x8c82fffc

先展开成二进制:

0x8c82fffc = 1000 1100 1000 0010 1111 1111 1111 1100

取前 6 位:100011 = 35 ⇒ lw,这是 I 型指令,按 6-5-5-16 切开:

opcode
100011
lw
rs(基址)
00100
= 4 → $4
rt(目的)
00010
= 2 → $2
immediate
1111111111111100
最高位是 1 ⇒ 负数;0xFFFC − 0x10000 = −4
答案 b lw $2, -4($4) ⇒ R[2] := mem[(R[4] − 4) / 4]

也就是"把 $4 所指地址往前一个字的内容装进 $2"。−4 这个偏移在真实代码里非常常见:数组元素是 4 字节,−4 就是"前一个元素",通常出现在倒序遍历或访问栈上的局部变量时。

符号扩展不是"看出来"的

这里把 1111111111111100 读成 −4 而不是 65532,依据不是比特模式本身——同样 16 个比特,在 andi 里就必须当作 65532(零扩展)。真正的依据是 opcode:ISA 规定 lw 的立即数做符号扩展,控制单元据此把 Signed-Imm 信号置 1。比特永远是中立的,怎么解释它是指令说了算。

T4 · Q2

Encode the following MIPS instructions (in hex): a) sw $1,-4($4) b) add $3,$1,$4

a) sw $1, -4($4)

sw 的 opcode = 101011,I 型。注意汇编写法 sw $rt, imm($rs) 里,括号里的是基址寄存器 rs,前面那个才是 rt——这是最容易写反的地方。所以 rs = $4,rt = $1,imm = −4 = 1111111111111100。

opcode
101011
sw
rs
00100
$4
rt
00001
$1
immediate
1111111111111100
−4 的补码
1010 1100 1000 0001 1111 1111 1111 1100
  a    c    8    1    f    f    f    c
答案 a 0xac81fffc

和上一题的 0x8c82fffc(lw $2,-4($4))对照着看很有意思:两条指令只差 opcode 的一个比特和 rt 字段,因为 lw 和 sw 的寻址方式、立即数处理完全相同,区别只在数据流方向。这正是 RISC 规整编码的体现。

b) add $3, $1, $4

R 型:opcode = 000000,funct = 100000。汇编写法 add $rd, $rs, $rt ⇒ rd = $3,rs = $1,rt = $4。注意编码顺序是 rs, rt, rd,和汇编书写顺序(rd 在最前)正好错开——这是第二个高频失分点。shamt 不用,填 0。

opcode
000000
R 型
rs
00001
$1
rt
00100
$4
rd
00011
$3
shamt
00000
不用
funct
100000
add
000000 00001 00100 00011 00000 100000
→ 0000 0000 0010 0100 0001 1000 0010 0000
    0    0    2    4    1    8    2    0
答案 b 0x00241820
T4 · Q3

MIPS R-type instructions should have shamt = 0 except for sll, srl, sra. How many new instructions can we add to MIPS with opcode 000000 and 3 input registers by using shamt ≠ 0?

这题考的是编码空间的算法:题干已经告诉我们一个事实——除了三条移位指令,所有 R 型指令的 shamt 字段都是 0。这意味着"shamt ≠ 0"的那一整片编码目前是空的,可以拿来当 opcode 的扩展位用。

① 数一数可用的格子

  • shamt 是 5 位 ⇒ 共 32 种取值,其中 shamt ≠ 0 的有 31 种。
  • funct 是 6 位 ⇒ 共 64 种取值。
  • 但 sll (000000)、srl (000010)、sra (000011) 这三个 funct 值不能用——它们的 shamt 字段承载的是真实的移位量,32 种取值全部已被占用。

② 算式

可用的 funct 值 = 64 − 3 = 61
每个 funct 值下 shamt≠0 的编码 = 32 − 1 = 31

新指令数 = 61 × 31 = 1891
答案 1891 条

③ 两个容易搞错的地方

  • 不需要排除已被占用的 funct 值(add、sub、and…)。因为那些指令是以 shamt = 0 的形式定义的,同一个 funct 配上 shamt ≠ 0 是一个完全不同、且当前未使用的比特模式。这正是题干那句"should have shamt = 0"想让你注意到的。
  • "3 input registers" 这个条件的作用:它说明我们保留 rs、rt、rd 三个字段原样作为寄存器编号,只把 shamt 挪作他用。如果连某个寄存器字段也牺牲掉,编码空间会大得多,但指令就只剩两个寄存器操作数了。
这不是纸上谈兵

真实的 MIPS 演进正是这么干的:MIPS32 Release 2 新增的一批指令(如 ins、ext 位域操作、rotr 循环右移)就是靠复用 shamt/sa 字段的比特作为额外操作码塞进既有编码空间的。1891 个空位意味着这套指令集在不破坏任何向后兼容的前提下,还有非常充裕的扩展余地——编码空间的预留,是 ISA 设计中最需要远见的部分之一,因为它一旦用完就再也收不回来了。

T4 · Q4

Explain how the recursive eq/lt unit works.Hint: To compute ( A✻B < C✻D ) where A,B,C,D are bit strings of equal length and ✻ denotes concatenation, we can compute ( A < C or ( A = C and B < D ) ).

这个单元的任务是比较两个 32 位数,它出现在 ALU 里是为了实现 slt/sltu,也为分支指令 beq/bne 提供判等结果。库名里的 "no-adder" 是关键提示:它不做减法。

① 为什么必须同时算 eq 和 lt

这是整道题的题眼。假设我们只想要 lt(小于),能不能由两个半区的 lt 合并出来?不能。因为高半区如果平手,结论完全取决于低半区;高半区如果分出胜负,低半区就完全无关。所以"高半区是否相等"这个信息必须一起往上传——于是每个单元输出一对信号:

eq = (左操作数这一段 == 右操作数这一段)
lt = (左操作数这一段 <  右操作数这一段)

② 基本情形(1 位)

eq = ¬(a ⊕ c)        // 同或:两位相同则为 1
lt = ¬a · c          // 只有 0 < 1 这一种情况

③ 合并规则(把提示翻译成电路)

设高半区比较的是 A 与 C、低半区比较的是 B 与 D,完整操作数是 A✻B 与 C✻D:

eq = eq_hi · eq_lo                       // 整体相等 ⟺ 两半都相等
lt = lt_hi + ( eq_hi · lt_lo )           // 正是提示里的 (A<C) or (A=C and B<D)
Fig. 4 · 合并单元与 4 位比较树
① 一个合并单元的内部 eq_hilt_hi eq_lolt_lo AND eq AND OR lt eq = eq_hi · eq_lo lt = lt_hi + eq_hi · lt_lo 每级只有 2 级门延迟 ② 4 位比较树(32 位就是 5 层) bit3bit2bit1bit0 ↑ 高位 低位 ↑ 合并 bit3:2合并 bit1:0 合并 bit3:0 → (eq, lt) 深度 = log₂n 层,每层 2 级门 ⇒ 32 位总延迟 ≈ 2 × 5 = 10 级门 对比:先做 32 位减法再看符号 ≈ 66 级

高位在左:合并时高半区拥有绝对优先权,低半区只有在高半区 eq=1(平手)时才有发言权。

④ 为什么是"递归"、以及它为什么快

合并操作满足结合律——先合并 bit3:2 和 bit1:0 再合并,与从左到右逐位合并,结果完全一样。满足结合律就意味着可以摆成平衡二叉树而不是一条链:32 位只需 log₂32 = 5 层,每层 2 级门,总共约 10 级门延迟。

相比之下,如果用"算 a−b 再看符号位"的办法,就得先付一次完整的 32 位减法(行波的话 66 级),而且还要额外处理溢出。这就是这个库叫 no-adder 的原因:比较大小根本不需要算出差值。

它和快加法器是同一个把戏

把两者并排看:

  • 快加法器:每段输出 (G, P)——G = 本段必产生进位,P = 本段会传递进位;合并式 G = G_hi + P_hi·G_lo。
  • 比较器:每段输出 (lt, eq)——lt = 本段已分出胜负,eq = 本段平手需往下问;合并式 lt = lt_hi + eq_hi·lt_lo。

两个公式形状完全一样:lt 扮演 generate(拍板),eq 扮演 propagate(传递)。这不是巧合——它们都是并行前缀(parallel prefix)这一通用模式的实例:只要一个"从右往左的依赖"可以写成结合律运算,就能用二叉树把 Θ(n) 压成 Θ(log n)。认出这个模式,两道题就变成了一道题。

⑤ 有符号 vs 无符号(slt 和 sltu 的差别)

上面的电路直接给出的是无符号比较(sltu)。对补码有符号数,只有最高位的含义相反——符号位为 1 表示负数、反而更小。所以:

slt:若两数符号位不同 ⇒ 结果 = 左操作数的符号位(它是 1 就说明左边是负数,更小)
      若符号位相同 ⇒ 直接用剩下 31 位的无符号比较结果

等价的省事写法:把两个操作数的最高位取反,再做无符号比较。

所以 ALU 里 slt 和 sltu 可以共用同一棵比较树,只在最高位那一级做一点特殊处理——这也是为什么它们在控制表里的 ALU-Ctrl 编码只差一位(0100 与 0101)。

TUTORIAL 5

性能计算与存储介质选型

对应模块 11(性能评估与阿姆达尔定律)与模块 12(存储系统)。两道计算题都给出完整代数过程。

T5 · Q1

We wish to add branch prediction to a CPU. This would make each branch 3 times as fast if the predictor is correct but 2 times as slow if the predictor is wrong. How accurate must the predictor be to achieve a speedup of 1.25 if the original CPU spends 40% of time on branches?

① 建模

把原来的总时间设为 1。其中分支占 0.4,非分支占 0.6(非分支部分完全不受影响,这一点是阿姆达尔定律的核心)。设预测准确率为 p:

  • 猜对(概率 p):该次分支耗时变成原来的 1/3;
  • 猜错(概率 1−p):该次分支耗时变成原来的 2 倍。
新的分支耗时 = 0.4 × [ p × (1/3) + (1−p) × 2 ]
新的总耗时   = 0.6 + 0.4 × [ p/3 + 2(1−p) ]

② 解方程

加速比 1.25 ⇒ 新总耗时 = 1 / 1.25 = 0.8:

0.6 + 0.4 × [ p/3 + 2 − 2p ]  =  0.8
       0.4 × [ 2 − 5p/3 ]      =  0.2
              2 − 5p/3         =  0.5
                   5p/3        =  1.5
                      p        =  0.9

回代验算:p=0.9 ⇒ 0.9/3 + 0.1×2 = 0.3 + 0.2 = 0.5;0.6 + 0.4×0.5 = 0.8;1/0.8 = 1.25 ✓

答案 预测器准确率必须达到 90%

③ 把整条曲线画出来,能看到两件更重要的事

把 p 当变量,加速比 = 1 / (1.4 − 2p/3):

Fig. 5 · 加速比随预测准确率的变化
1.401.20 1.000.80 0.70 加速比 0%20%40% 60%80%100% 预测准确率 p 加速比 = 1.0 盈亏平衡线 理论上限 1.364(p = 100%) p = 60% p = 90% 目标 1.25

曲线并非直线——因为加速比是耗时的倒数。准确率越高,每提高一个百分点带来的收益越大。

① 预测器不是"猜不准也无妨"

令加速比 = 1 解得 p = 60%。也就是说,准确率低于 60% 的分支预测器会让 CPU 变得更慢——猜错的惩罚(2 倍)比猜对的收益(1/3)更重,所以必须有足够高的命中率才能把这笔账扳回来。这就是为什么现代 CPU 宁可花掉可观的芯片面积去做复杂的预测器(回想 T2·Q3 的 2 位饱和计数器:正是因为要把准确率从 6 成拉到 9 成以上,才值得多花那一位比特)。

② 天花板就在那里:阿姆达尔定律

令 p = 1(预测器完美无缺)得新耗时 = 0.6 + 0.4/3 = 11/15,加速比 = 1.364。这就是这项优化的理论上限:无论预测器多神,那 60% 的非分支时间纹丝不动。题目要的 1.25 已经用掉了这个上限的九成——再想往上挤,必须去优化别的部分,而不是继续砸钱在分支预测上。做性能题时先算一下天花板,往往比解方程本身更有价值。

T5 · Q2

For magnetic hard disk that spins at 15000 RPM with seek time 5 ms, how long would it take to retrieve from it 100 files each of size 1 MB?

官方答案 700 ms (= 100 × 7 ms,只计 latency,不计传输时间)

① 旋转延迟

15000 RPM = 15000 转/分 = 250 RPS(转/秒)
每转耗时 = 1/250 秒 = 4 ms
平均旋转延迟 = 半转 = 4 ms / 2 = 2 ms

为什么是半转?磁头移到目标磁道之后,目标扇区可能刚好就在磁头下方(等 0),也可能刚刚转过去(要等整整一圈)。位置均匀分布,期望值就是半圈。

② 平均延迟(average latency)

平均延迟 = 寻道 + 旋转延迟 = 5 ms + 2 ms = 7 ms

注意课程对 latency 的定义(模块 12.5):「要多久才能开始传输」——它由控制器 + 寻道 + 旋转三段构成,本身不包含传输时间。控制器耗时是微秒级,忽略。

③ 总时间

100 个文件 × 7 ms = 700 ms
Fig. 6 · 单次访问的耗时构成
寻道 5 ms 旋转 2 ms 传输时间 题目未给速率 ⇒ 本题不计入 average latency = 7 ms ×100 个文件 = 700 ms 纯机械等待,与文件大小无关
为什么"1 MB"这个条件没被用上

这是本题最容易困惑的地方——题目特意说了每个文件 1 MB,答案里却没出现。原因是题目没有给出传输速率,没有速率就算不出传输时间,所以标准答案只统计 latency 这一部分。

换句话说,"1 MB"在这里是一个干扰项,它的作用是提醒你:这些文件不是同一个连续块,每取一个文件都要重新寻道一次,所以要乘 100。如果卷子上给了传输速率(比如讲义里的典型值 100 MB/s),那才需要再补上 100 × 10 ms = 1000 ms,总计 1.7 秒。考试按老师的口径答 700 ms。

如果题目给出了传输速率,答案会变成多少
传输速率传 1 MB单文件合计100 个文件
未给出 (本题)不计7 ms700 ms
200 MB/s5 ms12 ms1.2 s
100 MB/s (讲义典型值)10 ms17 ms1.7 s
50 MB/s20 ms27 ms2.7 s
顺带看懂 SSD 为什么"快得不讲道理"

这 7 ms 全部花在"等机械零件动到位"上,而且完全不随文件大小变化——读 1 KB 也要等这 7 ms,读 1 MB 还是这 7 ms。SSD 没有任何机械部件,这一段直接塌缩到几十微秒。

所以 SSD 在顺序读大文件时可能只比机械盘快几倍(那时瓶颈在带宽),但在大量小文件上能快上百倍——差距几乎全在这个固定的 7 ms 里。本题算出来的 700 ms 正是这个固定开销的纯粹体现:一个字节的数据都还没开始传,就已经花掉 0.7 秒了。这也再次印证模块 12.3 那条:带宽能堆,延迟堆不上去。

另一个常见追问:如果文件是连续存放的呢

那就只需要一次寻道和一次旋转等待,总 latency = 7 ms 而不是 700 ms——相差 100 倍。这正是"磁盘碎片整理"存在的意义,也是为什么数据库和文件系统都拼命想让相关数据在物理上相邻。本题乘以 100,隐含的前提就是这 100 个文件散落在盘面各处。

T5 · Q3

Which storage media (magnetic disk / optical disk / magnetic tape / flash memory) are suitable for: a) Documents for work? b) Server data backup? c) Computer games? d) Camera storage? e) Medical scans?

选型的依据就是模块 12.4 的四个指标——容量、数据率、延迟、成本——再加两个实际因素:访问方式(随机 / 顺序)和耐久性 / 便携性。先把四种介质的性格摆出来:

四种介质的性格速查
介质随机访问单位容量成本耐用/寿命最擅长的场景
闪存SSD / SD 卡极好,延迟微秒级最贵无机械部件、抗震;写入次数有上限频繁随机读写、需要便携抗震
磁盘HDD可以,但有 ~10 ms 机械延迟便宜怕震动,机械件会磨损大容量在线存储
光盘CD/DVD/BD差,寻道很慢低(介质本身很便宜)介质极耐存放;可做一次写入(WORM)分发、归档、不可篡改的留存
磁带没有——只能顺序访问最便宜寿命长,适合离线保存整卷写入、整卷读回的冷备份
五个场景的选型与理由
场景选择理由
a) 工作文档 闪存(SSD) 文件小而多、一天要开关无数次,主导因素是随机访问延迟而不是容量或带宽——这正是磁盘那 10 ms 机械延迟最难受的场景。总容量需求也不大(几十 GB),SSD 更贵的单位成本无所谓。笔记本还能顺带获得抗震和静音。
b) 服务器数据备份 磁带(近线层可配磁盘) 备份的访问模式是整卷写入、极少读回、真要读就整卷恢复——磁带"只能顺序访问"这个致命缺点在这里完全不构成缺点。于是它的优势全部兑现:单卷约 10 TB、单位容量成本最低、离线保存寿命长、断电不耗任何电力。
实践中常做分层:最近几天的备份放磁盘(恢复快),更久远的转到磁带(便宜、离线,还能防勒索软件加密)。
c) 电脑游戏 闪存(SSD)为主
磁盘作大容量仓库
现代游戏一边玩一边随机流式加载贴图和场景资源,读取延迟直接决定卡不卡、加载屏多久。这是随机读密集型负载,SSD 优势巨大。但单个游戏动辄上百 GB,一整个游戏库用 SSD 太贵,所以常见做法是正在玩的放 SSD、不常玩的放 HDD。光盘则只剩下"分发介质"这一个历史角色。
d) 相机存储 闪存(SD / CF 卡) 几乎是唯一答案,而且理由不只是速度:没有机械部件意味着能承受拍摄时的震动、跌落和姿态变化——机械硬盘在运动中读写会直接损坏。此外还要体积极小、功耗低(靠电池供电)、可插拔更换。连拍和视频录制需要的是持续顺序写入带宽,这恰好是闪存卡的强项。
e) 医学影像 分两个阶段:
在线用磁盘/闪存
归档用光盘或磁带
这题的题眼是它有两种截然不同的生命周期:
诊断期——医生要随时调阅、影像文件很大(一次 CT 可达数 GB),需要随机访问和高带宽 ⇒ 磁盘或闪存。
归档期——法规通常要求病历保存很多年甚至几十年,这时需要的是介质寿命长、单位成本低,而且最好是一次写入不可改(WORM),以便在医疗纠纷中证明记录未被篡改 ⇒ 光盘(或专用归档磁带)。
把影像刻成光盘交给病人带去别家医院,也是这一用途的延伸。
答这类题的通用套路

不要凭印象答"哪个快选哪个"。把每个场景先问三句话:① 访问模式是随机还是顺序?② 数据规模有多大、成本敏感吗?③ 有没有速度之外的硬约束(抗震、法规、寿命、便携、不可篡改)?答案几乎总是被第三个问题决定——磁带不是因为"慢"才只能做备份,而是因为备份根本不需要随机访问,于是它的成本优势才成为决定性因素。