交互式学习手册 · COMPUTER ARCHITECTURE PART 1
从真空管到超标量流水线,
拆开一台计算机的全部秘密
这是基于 NTU IE4756/IE6156/EE6156《Computer Architecture》课程 Part 1 全部讲义整理的自学手册:从计算机世代变迁、抽象机器模型、CPU 电路设计,到指令集架构、性能评估、总线与存储系统,带你把"一台计算机是怎么算出答案的"这条链路完整走一遍,搞懂每一层的"为什么"。
计算机世代简史
在谈论"CPU 怎么设计"之前,先搞清楚它是怎么变成今天这个样子的。计算机的世代划分不是按年份硬切的,而是按"用什么物理元件搭出逻辑门"划分——每一次元件的更新,都会把体积、功耗、成本压低一个数量级,进而把计算机从"整间房子"压缩成"口袋里的芯片"。这条主线,就是理解后面所有模块的地图。
01.1 世代 0~2:从差分机到晶体管
第 0 代(约 1640–1940)里根本没有"电子"元件——Babbage 的差分机(1833)靠齿轮和曲柄做十进制运算,Zuse 的 Z1(1936)用机械继电器搭出了二进制逻辑。它们证明了"可编程计算"这件事在电子管出现前就已经被想清楚了,只是造价和速度都还停留在钟表匠的水平。
第 1 代(约 1950–1960)用电子管(真空管)取代了机械齿轮——EDSAC(1949)、UNIVAC I(1951)都是这个时代的代表。电子管确实比继电器快得多,但它体积大、极易烧坏、耗电惊人,一台计算机常常要占满一整个房间。1950 年有人曾预言"未来的计算机重量不会超过 1.5 吨",这句今天看来的笑话,恰恰说明当时的人是以"房间"和"吨"作为计算机的度量单位的。
第 2 代(约 1960–1970)迎来了晶体管(BJT,双极结型晶体管)。晶体管不需要真空、不会像灯泡一样烧丝,体积和功耗都大幅下降。PDP-1(1959)是这代的明星:它拥有史上第一个文本编辑器、第一个文字处理器,甚至第一款电子游戏《Spacewar!》——计算机第一次显得"好玩"而不只是"能算"。IBM 1130(1965–约1980)则代表了这个时代工程和商用市场的另一条线。
01.2 世代 3~4:微处理器与 VLSI
第 3 代(约 1970–1980)是真正的分水岭:MOSFET(金属氧化物半导体场效应晶体管)加上固态存储器,让"把一整颗 CPU 刻在一块芯片上"第一次成为可能。
| 型号 | 年份 | 位宽 | 内存 | 晶体管数 | 意义 |
|---|---|---|---|---|---|
| Intel 4004 | 1971 | 4-bit | 0.6 KB | 2,300 | 史上首款商用单芯片微处理器 |
| Intel 8080 | 1974 | 8-bit | 64 KB | 4,500 | 首款廉价通用微处理器(衍生型号至今仍在生产) |
| Intel 8086 | 1978 | 16-bit | 1 MB | 29,000 | 首款商用 16 位微处理器,x86 架构的奠基者,率先使用微码,AMD 8088 是它的近亲兄弟 |
第 4 代(约 1980 至今)靠VLSI(超大规模集成)把晶体管密度再拉高好几个数量级——密到人工画版图已经不现实,于是现代 CPU 几乎全部靠软件自动综合电路(也就是把 CMOS 电路的构建也变成了一种"编程")。Pentium 1(1993)已经有 300 万晶体管,到 Pentium 4(2006)时暴涨到 1.88 亿——13 年里翻了 60 多倍,这正是"摩尔定律"在真实产品上的体现。
01.3 第 5 代?量子计算机的真相
如果说有"第 5 代",最常被提名的候选人是量子计算机。但它离取代经典 CPU 还很远,原因值得说清楚:
- 它需要大量稳定且带纠错能力的量子比特(qubit),这在工程上极难实现——2024 年底才出现第一批商用级"双轨量子比特"硬件。
- 量子计算机依然需要一台经典计算机来验证它的输出——这意味着它无法被用来高效解决那些"连验证都很难"的问题。
- 量子计算目前可以被经典计算机模拟,只是效率很低。用复杂度理论的语言来说:
P ⊆ BQP ⊆ PSPACE(P=多项式时间可解,BQP=有界错误量子多项式时间,PSPACE=多项式空间可解)。
P 是否等于 PSPACE?换句话说:像「推箱子(Rush Hour)」这类谜题,是否存在一个多项式时间算法总能求解?这至今仍是计算机科学最大的未解之谜之一,量子计算机能否改写它,同样没有答案。
第 0 代
机械计算:Babbage 差分机、Zuse Z1
第 1 代
电子管:EDSAC、UNIVAC I
第 2 代
晶体管 BJT:PDP-1、IBM 1130
第 3 代
MOSFET:4004 → 8080 → 8086
第 4 代
VLSI:软件自动综合 CMOS 电路
第 5 代
量子计算机,仍需经典机验证输出
自测 · Self-Check
为什么"晶体管"取代"电子管"是划时代的一步,而不只是"更小的电子管"?
Intel 8086 为什么被称为"x86 架构的奠基者"?
量子计算机为什么不能简单地被称为"下一代通用计算机"?
x86 处理器演化史
如果把一颗 CPU 的芯片版图(die)拍成照片,你会发现历代 x86 处理器的进化史,本质上是一部"缓存不断长大、核心不断变多"的历史。下面这条芯片示意图,按时间顺序展示了同一批经典处理器内部版图的构成变化——颜色越暖代表运算核心,越冷代表缓存。
02.1 8086 / 8088:x86 的起点
1981 年的第一台 IBM PC 用的是 AMD 8088(Intel 8086 的低成本变体,外部数据总线砍半到 8 位以降低系统成本)。剥开它的主板,你会看到 CPU、浮点协处理器(FP Coprocessor,负责浮点运算,因为那时 FPU 还没被塞进 CPU 本体)分别是两块独立芯片。CPU 内部已经能看出现代处理器的雏形:寄存器堆、内存控制、总线控制、ALU,以及微码引擎 + 微码 ROM——这是理解"CPU 如何执行指令"的关键组件,我们会在模块 03 详细展开。
02.2 Intel 386:32 位与保护模式
386(1985–1990)是 Intel 第一颗32 位 CPU,更重要的是它第一次完整支持了保护模式(protected mode)——这是"健壮多任务"的硬件地基:每个程序可以拥有自己独立的内存地址空间,一个程序跑飞了不会轻易踩到别的程序或操作系统的内存。芯片内部也第一次出现了片上 Cache 和独立的预取(Pre-Fetch)单元,为流水线化执行做准备。
02.3 Pentium 1 / 2:缓存开始搬进芯片
Pentium 1(1993–1999)第一次采用分离式缓存架构(split-cache)——指令缓存(Instruction Cache)和数据缓存(Data Cache)物理上是两块独立的存储,互不干扰,这样取指令和读写数据可以同时进行,不会互相排队。芯片里还首次出现了独立的分支预测器(Branch Predictor)。Pentium 2(1997–1999)则更进一步,把原本需要通过主板单独芯片提供的 L2 缓存直接搬上了 CPU 本体,大幅缩短了二级缓存的访问延迟。
02.4 P4 Extreme Edition / AMD 64 X2:L3 与多核元年
2003 年的 Pentium 4 Extreme Edition 是第一款面向消费者、带片上 L3 缓存的处理器——但它依然只有单核。真正的转折点在 2005 年,AMD 64 X2 成为最早一批亲民价位的双核处理器:两个核心(Core 1 / Core 2)各自拥有独立的 L2 缓存,彼此独立执行程序,这也是"多核并行"第一次走进普通消费者的电脑。
02.5 Core i5 Nehalem:共享末级缓存
2008 年的 Intel Core i5(Nehalem)把多核架构又推进了一步:多个核心不再各自为战,而是共享同一块片上 L3 缓存,配合内存控制器、指令队列和 PCIe 控制器直接集成在芯片上。共享末级缓存的好处是——如果一个核心刚访问过的数据,另一个核心恰好也要用,就不必再穿过缓慢的主存,这为后续所有多核 CPU 的缓存层级设计定了基调,一直沿用至今。
自测 · Self-Check
为什么"分离式缓存"(指令缓存 / 数据缓存分开)比"统一缓存"更快?
"共享 L3 缓存"和"每个核心各自的 L2 缓存"分别解决什么问题?
386 的"保护模式"为什么是"健壮多任务"的前提?
CPU 的组成部件
把模块 02 里那些芯片版图拆成"零件清单",会发现零件本身其实分两批:早期就有的"标配三件套",以及后来为了榨取速度而陆续加上的"性能加速件"。
03.1 早期标准组件
早期一代微处理器的内部,基本上只需要三块核心电路就能跑起来:
- ALU(算术逻辑单元):负责整数加减乘、位运算等"算术逻辑"层面的计算,是 CPU 真正"做数学题"的地方。
- FPU(浮点单元):专门负责浮点数运算。
- I/O 总线控制:负责和 I/O 总线(外部设备、内存等)对接、收发数据。
03.2 现代组件的三大升级
后期世代在这三件套之上,陆续叠加了三种"用电路换速度"的加速机制——它们都不改变 CPU 能做什么,只改变它能多快做:
① 独立的数据缓存与指令缓存
如模块 02 所述,把缓存拆成两块独立的物理存储,让取指令和存取数据互不阻塞。
② 微码引擎 + 微码 ROM
每一条 CPU 指令并不是直接"硬接线"执行的,而是先被翻译成若干条更简单的微指令(micro-instruction),再交给微码引擎逐条执行;这些翻译规则存放在微码 ROM里。这样做的好处是:把复杂指令拆成简单的微操作后,可以把时钟频率拉得更高,让"简单指令"跑得更快,代价是复杂指令需要多个时钟周期完成。
③ 分支预测(Branch Prediction)
CPU 在执行到一个条件跳转(if/循环判断)指令时,还不知道跳转结果就已经猜测接下来最可能执行哪条指令,并提前开始执行——完全不等分支指令本身算完。如果猜错了,就把猜测执行的结果撤销重来。这看起来像"赌博",但现代 CPU 的分支预测命中率其实非常高(通常在 90% 以上),一旦猜对就是纯赚的时钟周期。
分支预测的本质是一笔期望值划算的赌注:预测对了,流水线不用停顿,白赚好几个周期;预测错了,撤销重来的代价也就是"多等一会儿",并不会算错结果。只要命中率够高,这笔账长期看必然是划算的。
自测 · Self-Check
微码引擎存在的意义是什么?直接用硬件"死接线"执行每条指令不是更快吗?
分支预测猜错了,CPU 怎么"撤销"已经执行的结果?
操作系统与多任务
CPU 硬件本身其实一次只能跑一个程序。今天我们习惯一边听歌一边写代码一边挂着下载,靠的完全是操作系统在"欺骗"我们——它让 CPU 在极短时间内飞速切换任务,快到人眼完全察觉不到。这套"欺骗术"到底是怎么设计的,正是本模块的主题。
04.1 现代计算机全景
- 操作系统(OS):本质上也(大致)是一个程序,它借助 CPU 核心去调度、运行成百上千个用户程序。
- CPU:一块硬件,内含 1 个或多个核心(core),每个核心在任意时刻只能运行 1 个程序。
- RAM 模型:一种抽象模型,近似地把"1 个用户程序 + 主存"当作一台独立的 RAM 机器来分析算法复杂度。
- 分离缓存架构:把 RAM 模型进一步具体化成"带缓存的 CPU"这一更贴近真实硬件的模型,用于分析程序在真实缓存层级下的表现。
04.2 合作式 vs 抢占式多任务
让多个程序"看起来同时运行",历史上出现过两种思路:
| 方式 | 原理 | 代表系统 | 致命问题 |
|---|---|---|---|
| 合作式多任务 Cooperative | 每个程序运行一小段后,主动把控制权交还给操作系统 | Windows 3.1 / 95 / 98 上的旧版 16 位程序 | 只要一个程序耍赖不归还控制权(或直接卡死),整个系统一起崩溃 |
| 抢占式多任务 Pre-emptive | 操作系统定期强制收回控制权,切换到别的程序 | Windows 95 起用于全部 32 位程序,Windows XP 起用于所有程序 | 需要 CPU 硬件专门支持(见下) |
04.3 抢占式多任务:从"笨办法"到中断机制
笨办法(不可行):让操作系统自己去逐条解释执行每个用户程序的指令。问题是——操作系统既不能直接跳进用户程序的代码执行(跳过去就再也拿不回控制权了),也不能自己在 CPU 上直接运行用户程序(这样它自己就成了唯一在跑的程序)。只能自己写一套子程序去"模拟执行"每一条用户指令,这慢到完全不能用。
正确做法:CPU 硬件层面提供两条特殊指令——set-interrupt(设置中断)和 return-from-interrupt(从中断返回)。操作系统在运行任何用户程序之前,先执行一次 set-interrupt,设定"多久之后触发一次中断"以及"中断触发时要跳去哪个地址"(即中断处理程序的入口)。设置好之后,操作系统就可以放心地直接跳到用户程序的地址去运行——因为它知道,无论如何,CPU 都会在设定的时间点自动把控制权还给它。
04.4 中断触发的五个步骤
每当中断计时器时间到,CPU 会把当前内部状态(包括程序计数器 PC)压入栈中,并跳转到中断处理程序,依次完成:
04.5 优先级与时间片轮转
现代操作系统通常把程序分成从高到低的五个优先级层级:
柱长代表典型优先级高低,右侧为该层级常见的时间片长度(并非精确比例)。
- Real-time:留给 OS 自身代码和 I/O 驱动程序代码。
- Interactive:留给需要即时响应用户输入的交互式程序。
- Normal:普通用户程序的默认层级。
- Background:后台任务。
- Idle:不重要、可以随时被拖延的任务。
在同一优先级层级内部,各程序按时间片轮转(round-robin)依次获得一段固定的小时间片。这个时间片长度是个精细的权衡:
过多 CPU 时间被浪费在"处理中断和调度"这件事本身上——切换的管理开销比真正干活的时间还多。
系统会变得反应迟钝、明显卡顿——你点一下鼠标,可能要再等上百毫秒才轮到界面程序响应。
因此现代操作系统一般把 real-time 的时间片设在约 2 毫秒,interactive / normal 设在约 10 毫秒,background / idle 则放宽到 100 毫秒甚至更长。
自测 · Self-Check
为什么操作系统不能简单地"跳转"到用户程序代码去执行,然后就撒手不管?
set-interrupt 指令具体设置了哪两样东西?
real-time 优先级为什么要用最短的时间片?
数字电路:ALU 与加法器
从这里开始,我们从"CPU 有哪些部件"下潜到"这些部件到底是怎么用逻辑门搭出来的"。先从最核心的 ALU 讲起,再深入它内部最关键、也最能体现电路设计智慧的一块——加法器。
05.1 ALU 与 FPU 的分工
ALU(算术逻辑单元)至少要包含四类子电路:
- 加法器(Adder):两个整数相加。
- 取反器(Negator):对一个整数取负(通常借助补码技巧复用加法器电路)。
- 乘法器(Multiplicator):两个整数相乘。
- 按位运算器(Bitwise operators):AND / OR / XOR 等位运算。
FPU(浮点单元)实现的是遵循 IEEE 754 标准的浮点运算,之所以常常和 ALU 分开做成独立单元,是因为浮点运算涉及指数对齐、尾数归一化等额外步骤,电路复杂得多、也慢得多——把它单独拎出来,可以避免拖慢整数运算的关键路径。
05.2 组合逻辑电路与门延迟
ALU 和 FPU 的地基都是组合逻辑电路(combinatorial circuit):由逻辑门组成、信息只沿"输入 → 输出"单方向流动、不存在反馈环路的电路。基本逻辑门就是那六位老朋友:Not、And、Or、Nand、Nor、Xor。
当一个逻辑门的输入被保持在某个稳定值之后,输出并不会瞬间变化到位——电信号需要一点点时间才能稳定到正确的值,这段时间叫门延迟(gate delay)。评估一个组合逻辑电路好不好,主要看三个指标:
- 门延迟数:从输入稳定到输出稳定,最长需要经过多少级逻辑门?这直接决定了整个电路能跑多快。
- 最大扇入(fan-in):电路里单个逻辑门最多接了多少个输入?扇入越大,那个门自身的延迟就越大。
- 最大扇出(fan-out):电路里单个逻辑门的输出,最多被多少个下游门当作输入使用?扇出越大,驱动能力消耗越大,延迟也随之上升,并且存在物理上限。
05.3 加法器(慢):进位传播的瓶颈
怎么把两个 n 位整数加起来?最直观的办法——从最低位开始逐位相加,把进位(carry bit)一位一位地向高位传递,俗称"行波进位加法器(ripple-carry adder)"。问题在于:第 n 位的结果,理论上要等前面全部 n-1 位的进位都算完才能确定,这需要 Θ(n) 级门延迟。当 n = 64(也就是今天最常见的 64 位整数)时,这个延迟长到完全无法接受——如果每次加法都要等 64 级门延迟,CPU 的时钟频率会被砍掉一大截。
05.4 加法器(快):进位超前的分治法
目标:把 n 位加法的门延迟从 Θ(n) 压到 O(log n)。关键的思路转变是——与其老老实实按位传递进位,不如用分治法递归地"预先算好"每一段区间可能产生的进位。定义一个函数:
carry(k,m,0)= 只把第 k 位到第 m 位这一段相加(不考虑外部进位输入)时,产生的进位。carry(k,m,1)= 同样这一段相加,但假设还额外加了一个输入进位 1 时,产生的进位。
关键的合并公式是:carry(k,m,0) = carry(k,p,carry(p+1,m,0)),carry(k,m,1) = carry(k,p,carry(p+1,m,1))(p 是 k、m 之间的某个分割点)。也就是说,一段区间的进位,可以由它拆分成的两个子区间的进位合并得到——而这个合并操作本身只需要 O(1) 级门延迟。
请注意 carry(k,m,0) 和 carry(k,m,1) 是同时、提前算好的两个答案——"如果外部进位是 0 会怎样"和"如果外部进位是 1 会怎样",都不需要真的知道外部进位是多少就能算。真正的外部进位一旦确定,只要在这两个现成答案里选一个即可,不需要重新计算。这正是"用空间换时间"的分治法精髓。
具体计算顺序是按位数倍增展开的树状结构:先算好所有单比特的 carry(k,k,c),再两两合并成 carry(2k-1,2k,c),再合并成 carry(4k-3,4k,c)……每一轮区间长度翻倍,直到覆盖全部 n 位。既然每翻一倍只需要一轮 O(1) 延迟的合并,翻倍 log₂n 次就能覆盖 n 位,总延迟自然是 O(log n)。下面是 n = 8 时这棵"合并树"的样子:
实际电路里,这棵树还会有一趟反向的传递:自底向上先把 carry(k,m,0) 和 carry(k,m,1) 两种情况都预先算好,再让真正的外部进位从根节点开始沿树往下传递,每个节点只需要把结果转发给自己的两个子节点——而不是让某一根信号线直接扇出去驱动全部 n 位,从而把最大扇出也控制在很小的常数范围内。
05.5 乘法器:化乘为加
两个 n 位整数怎么相乘?最经典的思路就是竖式乘法(schoolbook multiplication)——把乘法拆解成 n 个"移位后的加数",问题就转化为:如何把这 n 个 b 位整数快速加总起来?答案是直接复用我们刚刚搭好的快速加法器,把这 n 次加法排成一棵平衡二叉树:
- 树一共有 O(log n) 层;
- 每一层要做一次加法,每次加法用快速加法器需要 O(log n) 级门延迟;
- 因此总门延迟是 O(log n) × O(log n) = O(log²n)。
现代 CPU 内部实际使用的乘法器电路(如 Wallace tree / Booth 编码等)还能做得更快,但那些属于更专精的优化技巧,理解到这一层"用加法器树实现乘法"的思路,就已经抓住了本质。
自测 · Self-Check
为什么行波进位加法器(慢速版)在 n=64 时会成为性能瓶颈?
快速加法器为什么要同时计算 carry(k,m,0) 和 carry(k,m,1) 这两个版本?
为什么乘法器的门延迟是 O(log²n) 而不是 O(log n)?
晶体管与逻辑门
继续往下潜,潜到硅片本身。逻辑门不是抽象的数学符号,它是用一种叫 MOSFET(金属氧化物半导体场效应晶体管)的微型开关搭出来的。这一模块讲清楚:晶体管怎么变成开关,开关怎么拼成门,门电路又是怎么在设计时"翻车"的。
06.1 MOSFET 三代:PMOS → NMOS → CMOS
MOSFET 大体分两种极性——NMOS(栅极为高电平 1 时导通,是个"输入 1 才通"的开关)和 PMOS(栅极为低电平 0 时导通,是个"输入 0 才通"的开关),二者行为互补。芯片设计史上,用它们搭电路经历了三代演进:
| 世代 | 技术 | 特点 | 代表芯片 |
|---|---|---|---|
| 第 1 代 | PMOS 电路 | 只用 PMOS 管搭建全部逻辑 | Intel 4004(1971) |
| 第 2 代 | NMOS 电路 | 只用 NMOS 管,比 PMOS 更快、更省电 | Intel 8080(1974) |
| 第 3 代 | CMOS(互补 MOS) | PMOS + NMOS 协同工作,见下文 | Intel 8086(1978)及以后几乎所有芯片 |
CMOS(Complementary MOS,互补金属氧化物半导体)是现代芯片的绝对主流方案,原因是它同时解决了信号质量和功耗两大难题:
- 极高的信号完整性:用一组 PMOS 子电路负责把输出拉高到 1(上拉网络),另一组 NMOS 子电路负责把输出拉低到 0(下拉网络)。两组网络分工明确、互补配合,输出电压能被稳稳地拉到轨道电压,因此可以支持更高速度、更强的抗噪声能力。
- 几乎零静态功耗:正常情况下,上拉和下拉网络里总有一组是完全断开的,电流路径被彻底切断,只有在输出翻转(切换)的一瞬间才会短暂耗电。这意味着更低的发热,也意味着可以把晶体管越堆越密而不至于把芯片烧穿。
06.2 逻辑门如何用晶体管搭出来
下面用一个通用的 CMOS 门结构来说明:任何一个 CMOS 逻辑门,骨架都是"电源 VDD — PMOS 上拉网络 — 输出节点 — NMOS 下拉网络 — 地 GND"这样一条链路,输入信号同时喂给两组网络。
基本门的搭法就是围绕这个骨架变化:
- Not(非门):最简单的情形——上拉网络就是一个 PMOS,下拉网络就是一个 NMOS。输入为 0 时 PMOS 导通、NMOS 关断,输出被拉到 1;输入为 1 时反过来,输出被拉到 0。
- Nand / Nor:把多个 PMOS / NMOS 按"串联对应并联"的对偶方式组合——例如二输入 Nand 的下拉网络是两个 NMOS 串联(必须两个输入都为 1 才导通,输出才被拉低),上拉网络则是两个 PMOS 并联(只要有一个输入为 0 就导通,输出就被拉高);Nor 正好反过来。
- And / Or:CMOS 天生只能直接做出"取反"的门(Nand / Nor),如果要得到不取反的 And / Or,做法很直接——在 Nand / Nor 后面再串一级 Not 门反相回来。
- Xor(异或门):真值表更复杂,需要更精巧的晶体管网络(不是简单的串并联能表达),因此实际电路里 Xor 的晶体管数量通常明显多于同为二输入门的 And / Or。
06.3 常见错误接法
PMOS 不能用来"下拉",NMOS 不能用来"上拉"。PMOS 传导 1(高电平)很干净利落,但试图用它去拉出一个干净的 0 时,输出会停在一个"发虚"的中间电压,而不是真正的地电位;NMOS 则恰好相反,传导 0 很干净,但拉不出一个真正干净的 1。
如果设计时把上拉网络错误地全部换成 NMOS,或者把下拉网络错误地全部换成 PMOS,电路表面上看起来逻辑关系没错,但实际测出来的电压是一个模糊、偏离标准电平的"半高不低"值——下游电路读到这种电压,很容易发生误判,是数字电路设计里一类经典的隐蔽错误,不能只靠"逻辑对不对"来检查,必须清楚每种晶体管各自"擅长"传导哪种电平。
06.4 传输门:一个开关的艺术
传输门(transmission gate)是一个"控制信号为 1 时才导通"的双向开关,由一对并联、极性相反的 PMOS 和 NMOS 组成——这样不管信号本身是想传 0 还是传 1,总有一个晶体管能干净地传导它,避免了上一节说的"发虚电压"问题。
使用传输门时通常还需要同时提供控制信号的反相信号(图中的 Control′)——因为 PMOS 和 NMOS 需要相反极性的栅极信号才能同时导通或同时关断。如果电路里手边没有现成的反相信号,就得额外接一个 Not 门去生成它,这会多付出一级门延迟的代价。尽管如此,传输门依然是电路设计里极其好用的基本单元——模块 07 要讲的 D 触发器,内部就大量使用了它。
自测 · Self-Check
为什么 CMOS 电路"几乎零静态功耗",而单纯的 PMOS 或 NMOS 电路做不到这一点?
为什么 CMOS 天生擅长做 Nand / Nor,而不是直接做 And / Or?
传输门为什么需要 Control 和 Control′ 两路信号,而不是一路就够?
时序电路与触发器
前面几个模块讲的都是组合逻辑——电路不记得任何"过去",输出只由当前输入决定。但 CPU 显然需要"记忆":寄存器要保存一个数直到你下次修改它。能记住一个比特的最小单元,就是本模块的主角——D 触发器(D Flip-Flop)。
07.1 D 触发器:1 比特记忆单元
D 触发器是最基本的存储单元,CPU 寄存器内部大量使用它。课程里介绍的这个变体是上升沿触发(rising-edge-triggered)的:只有当 Clock 信号从 0 跳变到 1 的那一瞬间,输入端 Data 当时的值才会被"拍照"锁存进去,成为新的 Value;在其它任何时刻——包括 Clock 保持在 1 的整段时间里——Data 怎么变化都不会影响已经存好的 Value。这个特性叫做边沿触发,是它区别于"电平触发"锁存器的关键。这版电路用了 18 个 MOSFET,数量不算少,但换来的是很高的稳定性。
橙色三角标记每次时钟上升沿:Value(青色)只在这些瞬间"拍照"更新为当时的 Data 值,其余时间保持不变——即便 Data(灰色)在两次上升沿之间反复变化,也不会影响已锁存的 Value。
07.2 复位与使能:让触发器听话
基础版 D 触发器只有"存"这一个功能,实际用在 CPU 里还需要两个额外的控制信号:
带 Reset(复位)
加入一个 Reset 输入:只要 Reset = 1,Value 就被强制清零,无视 Data 和 Clock 在做什么;Reset = 0 时,触发器行为恢复正常。
带 Enable(使能)
加入一个 Enable 输入:只有当 Enable = 1 时,Clock 的上升沿才会被"承认"、真正去捕获 Data;如果 Enable = 0,即便 Clock 发生了上升沿,也会被直接忽略,Value 纹丝不动。这让我们可以用同一个全局时钟信号,却精确控制"这个寄存器这一拍到底要不要更新"。
| Reset | Enable | Clock 上升沿 | 结果 |
|---|---|---|---|
| 1 | — | — | Value 强制为 0(优先级最高) |
| 0 | 1 | 发生 | Value 更新为当前 Data |
| 0 | 1 | 未发生 | Value 保持不变 |
| 0 | 0 | 发生或未发生 | Value 保持不变(忽略本次时钟) |
07.3 上电复位:保证起点已知
这里藏着一个容易被忽略但极其重要的问题:触发器是边沿触发的——它只在 Clock 跳变的瞬间才会响应。那么问题来了:芯片刚上电的那一刻,谁也不知道每个触发器内部锁存的初始值到底是 0 还是 1(物理上完全是随机的),而如果没有发生任何时钟跳变,就没有任何办法通过正常的 Data/Clock 输入去"纠正"它。
解决办法是给整个电路配一个独立的上电复位组件:芯片刚通电时,它会自动产生一个信号(例如先拉高、维持一段时间、再拉低),这个信号要足够长,长到能确保把芯片里所有的存储单元都成功复位一遍。把这个信号接到每个 D 触发器的 Reset 端,就能保证——无论上电前内部状态多么随机,上电复位一结束,所有触发器都从同一个已知状态(Value = 0)开始工作。
07.4 反馈移位寄存器:序列生成器
反馈移位寄存器(feedback shift register)把若干个 D 触发器首尾相连,每个触发器的输出接到下一个触发器的输入,而最后一个触发器的输出又被反馈回最前面(往往经过一些逻辑门处理,比如取反)。这样,每一位新产生的比特都由前面固定数量的比特决定,随着时钟一拍一拍地推进,就能连续生成一整串比特序列——这是构建伪随机序列发生器、循环冗余校验(CRC)等电路的基础组件。课程讲义里给出了两种搭法:一种直接调用现成的内置 D 触发器模块,另一种则用我们在 07.1~07.3 自己从晶体管一步步搭出来的 D 触发器——两者在逻辑行为上完全等价,区别只在于"造轮子"还是"用轮子"。
回头看这一路走过的台阶:MOSFET(06)组成逻辑门(06),逻辑门组成D 触发器(07.1),D 触发器加上控制信号成为寄存器(07.2–07.3),寄存器首尾相连成为移位寄存器(07.4)——这正是模块 03 里提到的 ALU、缓存、微码引擎等所有部件,最终都要落回到的同一套物理积木上。
自测 · Self-Check
"上升沿触发"和"电平触发(锁存器)"最本质的区别是什么?
为什么说"没有上电复位电路,就不可能保证触发器从已知状态开始"?
Enable 和 Reset 同时出现时,为什么 Reset 的优先级通常更高?
抽象机器模型:TM · RAM · PM
从这里开始,我们把视角切换一下——不再盯着某一颗具体的芯片,而是退后一步,问一个更根本的问题:"计算"到底是什么?计算机科学家发明了一系列"抽象机器模型",用极简的规则去逼近"计算"这件事的本质,方便分析算法复杂度、比较硬件设计的取舍。这些模型能按"抽象/具体"和"低层/高层"两个维度排成一张地图,理解了这张地图,才能看懂后面 ISA、性能评估这些具体课题各自站在地图的哪个格子里。
08.1 一张地图:模型的层级
计算机科学里谈"模型",其实分成四种,可以用两条轴线区分:抽象 vs 具体(要不要关心真实硬件的具体细节),低层 vs 高层(关心的是单步操作还是整段程序的行为)。
| 低层(关心单步操作) | 高层(关心整体行为) | |
|---|---|---|
| 抽象(不管真实硬件) | 机器模型 TM、RAM、PM,以及它们的并行变体——本模块主角 | 计算模型 例如 Java 语言、动态多线程模型 |
| 具体(对应真实硬件) | 指令集架构 ISA x86、ARM、MIPS——模块 10 的主角 | 计算机架构 冯·诺依曼架构、哈佛架构——模块 09 的主角 |
本模块要讲的 TM(图灵机)、RAM(随机访问机)、PM(指针机),都属于左上角这一格——它们故意"不管真实硬件长什么样",只关心"一步操作能做什么",这样才能公平地比较不同算法、不同硬件设计思路的效率上限,而不被某一代具体芯片的实现细节带偏。
08.2 图灵机模型:最简单也最笨拙的"计算"
一台图灵机(Turing Machine,TM)M(其中一种变体)拥有一条无限长的水平磁带(tape)和一个读写头(head)。磁带被分成一个个格子(cell),每格存 0、1 或 −(空白);输入是磁带开头的一段有限长二进制串,输出是运行结束时读写头所在格子下的二进制串。M 还有固定数量的状态(state),从指定的初始状态、读写头停在第一个输入格子上方开始,直到进入指定的终止状态才停止。
每一步,M 读取读写头下方的符号,完全根据这个符号和当前状态决定:往格子里写一个符号(0/1/−)、把读写头往指定方向移动一步(左/右/停留),并转移到指定的新状态。下面是一个具体例子——把磁带开头第一段全 0(或空白)翻成 1、其余保持不动的极简程序:
| 状态 | 读到的符号 | 动作 |
|---|---|---|
| 0(初始) | 0 / − | 写 1;停留原地;转到状态 1 |
| 0 | 1 | 写 0;向右移动;转到状态 0 |
| 1(终止) | — | — |
优点:极其简单却是"通用"的(忽略效率的话,任何有限信息的计算都能被某台 TM 模拟),而且完全局部(每一步读写头访问/移动的范围都是固定、微小的一格)——这两条性质让 TM 成为理论计算机科学证明"什么能被计算"的最基础工具。缺点也同样极端:极其低效(判断输入是否形如 x+x 这种简单问题,也要 Ω(n²) 时间),而且极不实用(历史上没有真正实用意义的物理 TM 实现)。
TM 的"完全局部"既是它最大的优点,也是它最大的缺点:正因为每一步只能碰一格磁带,硬件实现起来无比简单(这也是为什么它常被用来证明计算的理论极限);但也正因为如此,它没法像模块 08.3 的 RAM 模型那样"一步跳到任意内存地址",算法效率天差地别。
08.3 RAM 模型:更贴近真实计算机的抽象
一台随机访问机(Random-Access Machine,RAM)M(其中一种变体)以(机器)字(word)为单位处理数据:每个字固定 m 位(m 要"足够大"),被当作有符号整数或内存地址使用。M 拥有一块主存,格子 mem[0..2^m),每格 mem[k] 有地址 k、存一个字;输入在开始时存放于 mem[0..<n),输出是结束时主存的全部内容。M 还有固定数量的寄存器 reg[0..c],reg[0] 在开始时存 n,另有一个特殊寄存器 pc 维护当前指令编号。
M 按顺序执行固定的程序(一串指令),每条指令是以下三类之一:
- [条件]跳转:跳到指定指令[如果指定寄存器为[非]零](true=1,false=0)。
- 基本运算:对指定寄存器执行指定基本运算(初始化、加减乘除、取模、判等、小于、not/and/or),结果存入指定寄存器(乘法还需要第二个寄存器存高位)。
- 内存访问:执行一次"随机"访问——读:把 mem[reg[j]] 的值复制到 reg[k];写:把 reg[k] 的值复制到 mem[reg[j]],j、k 均可指定。
下面这段程序把输入的全部数字加总——对比 C 代码和等价的 RAM 程序,能看出"寄存器+跳转+内存读写"三件套,其实就是高级语言循环、数组访问的最底层翻译:
| C 程序 | RAM 程序 |
|---|---|
| int i = 0; int t = 0; while( i < n ) { t = t+mem[i]; i = i+1; } mem[i] = t; | reg[1] ← 0 reg[2] ← 0 sumloop: reg[3] ← mem[reg[1]] reg[2] ← reg[2]+reg[3] reg[1] ← reg[1]+1 reg[3] ← reg[1]<reg[0] goto sumloop if reg[3] ≠ 0 mem[reg[1]] ← reg[2] |
if/else 和 while 循环,都能直接翻译成"[条件]跳转 + 标签"的组合:
| 高级写法 | RAM 代码 |
|---|---|
| if( reg[k] ) { X } else { Y } | goto else1 if reg[k] = 0 X goto end1 else1: Y end1: |
| while( reg[k] ) { X } | goto end2 if reg[k] = 0 while2: X goto while2 if reg[k] ≠ 0 end2: |
连"函数调用"这种听起来很高级的概念,也能在 RAM 模型里落地:在内存里划一块调用栈(例如 mem[2^m−k] 存栈底往上数第 k 个字),用一个特殊寄存器 sp 记录栈顶之后那格的地址。push(reg[k]) 翻译成 mem[sp]←reg[k]; sp←sp−1;reg[k]←pop() 翻译成 sp←sp+1; reg[k]←mem[sp]。函数调用的完整套路是:调用前把要保护的寄存器和函数输入依次压栈,再把返回地址(pc+2)存入特殊寄存器 ra,跳到函数入口;函数体开始前按相反顺序把输入弹出到寄存器;返回时把函数输出压栈,再跳回 ra;调用结束后把输出和之前保护的寄存器依次弹出。
函数调用栈、局部变量、递归——这些我们在高级语言里习以为常的概念,拆到 RAM 模型这一层,其实只是"在内存里划一块区域,用一个寄存器当栈顶指针,配合 push/pop 两条翻译规则"而已,没有任何额外的魔法。
RAM 模型的优点:相当简单又几乎"通用"(任何 2^m 空间内的计算,都能被某个 m 位 RAM 完成);非常高效(例如在有序表上二分查找只需 O(log n) 时间);高度实用(是对"带内存的 CPU"相当贴切的抽象)。缺点是完全非局部——访问任意地址 k 的 mem[k],模型里都只算一步,这在真实硬件上其实并不成立(真实内存有缓存层级、越远越慢,这也是模块 09.4 分离缓存架构要处理的问题)。更进一步,可以证明:任何在 O(n) 个字的输入上、占用 O(n^d) 个字内存的确定性字级计算,都能被某个使用 O(log n) 位字的 RAM 模拟——计算的有限状态对应程序里带标签的代码段,步间保留的有限个字对应寄存器,每一步的状态转移对应代码段里的分支情形。
08.4 指针机模型:只认得"摸得到"的东西
指针机(Pointer Machine,PM)M 和 RAM 几乎一样,只有一处关键差异:M 只能通过指针访问内存。每个指针要么是 null,要么指向一个内存节点,节点有固定数量的字段,每个字段存一个字或一个指针;每个寄存器存一个字或一个指针;而指针只能被存储或判断是否相等——不能像地址一样做加减运算、也不能被当成数字比较大小。输入和输出都以指针结构(例如平衡二叉树)的形式存放,reg[0] 在开始时存指向输入的指针。
基本控制流和函数调用的翻译方式和 RAM 模型完全一样。直觉上,PM 模型能做的事,恰好对应用 Java 里仅使用 Object 和 long 类型(以及由它们构建出的类型)能写出的程序——例如 2-3 树、红黑树(Java 的 TreeMap 底层用的正是它)。
PM 模型的优点:通用(内存不受字长限制,理论上无边界);很好地刻画了问题的组合结构(例如可以证明对 n 个整数排序必须花 Ω(n·log n) 时间——这是比较排序的理论下界);能得到高效实用的算法(例如 O(n·log n) 的归并排序,在真实场景里往往比理论上"更快"的 O(n·loglog n) 排序表现更好——因为后者常数因子、实现复杂度更高,big-O 更小不代表真实运行更快)。缺点是不反映现代计算机的真实能力——"随机访问"这件事,在真实硬件上是被稳稳支持的(正如 RAM 模型假设的那样),PM 模型故意不让你用,是为了得到更保守、更普适的理论下界,但也因此显得比现实"悲观"。
08.5 程序即字符串:通往"存储程序"的关键一跃
为了支持随机化算法,还可以给 RAM/PM 模型加一条基本运算 rand:把从 [0, reg[k]) 中均匀随机抽取的整数存入 reg[j]。现实中并不存在真正的随机源——真实计算机使用"近似随机"的源:PRNG(伪随机数生成器)是一种确定性算法,产生的比特序列在统计检验下"看起来"随机;部分 CPU 还内置基于热噪声的硬件随机比特发生器。
接下来是计算机科学最基础的思想之一:任何计算都能被描述成一个"程序",这个程序能被编码成一个"字符串",而这个字符串又能被另一个程序(甚至是它自己)处理。这正是图灵"通用图灵机(Universal TM,UTM)"的核心构想——它能在任意给定输入上运行任意给定(编码后)的 TM,甚至可以运行编码后的自己。
在这个想法出现之前,程序和数据是彼时彼刻分离的:最早的计算机基本上只能运行单一固定的程序(1642 年的 Pascaline、1673 年 Leibniz 的计算器、1822 年 Babbage 的差分机);早期可编程计算机则只能运行外部提供的程序(1833 年 Babbage 的分析机、1936 年 Zuse 的 Z1、1944 年 Harvard Mark I、1965–约1980 年的 IBM 1130)。
换句话说,存储程序计算机能运行内部存储的程序;而通用存储程序计算机更进一步——它能运行"和内部数据存在同一种介质里"的程序。这个区别听起来微妙,却是决定"你能不能在自己电脑上装新软件"的分水岭。
一旦程序可以被当作数据一样编码、存储、传输、甚至被另一个程序生成或修改,"安装软件""升级系统""编译器编译自己"这些我们今天习以为常的事情才第一次在原理上成为可能——这正是模块 08.1 那张地图里,从"抽象机器模型"通向"计算机架构"的关键一跳,也是模块 09 讨论冯·诺依曼架构的必要前提。
自测 · Self-Check
TM、RAM、PM 三种模型,"局部性"假设分别是什么?为什么这个假设的强弱会直接影响算法复杂度分析?
为什么"通用存储程序计算机"比"存储程序计算机"更进一步?两者的区别具体是什么?
RAM 模型的"完全非局部"既是优点又是缺点,这句话怎么理解?
计算机架构:冯·诺依曼与哈佛
模块 08.5 讲到"存储程序计算机"是通往现代计算机的关键一跃——但这个想法要落地成具体硬件,CPU、内存、I/O 设备到底该怎么摆放?这就是"计算机架构"要回答的问题,它站在模块 08.1 那张地图"具体、高层"的格子里,直接决定了我们买到的每一台电脑的骨架长什么样。
09.1 冯·诺依曼架构:几乎所有电脑的共同祖先
冯·诺依曼架构(von Neumann architecture)是 1945 年 John von Neumann 基于 John Mauchly 和 John Presper Eckert 的想法描述的一种高层计算机架构:CPU(中央处理单元)拥有寄存器、PC(程序计数器)和 ALU(算术逻辑单元);CPU 能访问一块主存,这块主存同时存放程序和数据;CPU 能与输入/输出设备交互。今天每一台"现代计算机",都拥有某种与冯·诺依曼架构相关的骨架。
"程序和数据放在同一块内存里"听起来是个平淡的技术细节,但正是这一点,让模块 08.5 讲的"通用存储程序计算机"成为可能——通用性的关键就是程序能和数据用同一种介质存储、同一套地址访问机制处理。冯·诺依曼架构,正是把这件事画成了具体的硬件蓝图。
09.2 现代冯·诺依曼计算机的现实:虚拟地址
一台冯·诺依曼计算机(即采用冯·诺依曼架构的计算机)是一种通用存储程序计算机,RAM 模型能很好地为它建模。但今天许多现代计算机是64 位的——而拥有 2^64 个内存格子在物理上是不可能的!这样的计算机使用虚拟地址(virtual addressing)来"看起来"支持 64 位内存地址。
假设一台计算机有 16 GB 实际 RAM,那么它的物理内存只能存下 16·2³⁰·8/64 = 2³¹ 个字——距离 RAM 模型假设的 2⁶⁴ 个格子,差了整整 33 个二进制位(也就是几十亿倍)。现代操作系统会把不常用的内存部分转移到硬盘(比 RAM 慢得多):假设有 1 TB 硬盘空间,也只能多存 2⁴⁰·8/64 = 2³⁷ 个字,依然远远不够填满 2⁶⁴ 个格子。
换句话说,"64 位地址空间"更像是操作系统和硬件联手维持的一种假象——虚拟地址背后到底怎么映射到物理内存、怎么在内存和硬盘之间倒腾,属于"内存(缓存与虚拟内存)"这个主题的细节,这部分会在课程 Part 2 里展开,这里先记住这个数量级差距就够了。
09.3 哈佛架构:把程序和数据分家
哈佛架构(Harvard architecture)和冯·诺依曼架构相似,唯一的区别是:程序和数据分开存储、由 CPU 分开访问。这个名字最初是在微控制器的语境下被提出的,严格来说不应该套用在早期计算机上(那些早期"程序和数据分开"的机器,动机和现代微控制器并不相同)。典型例子是 PIC 微控制器:用闪存存指令,用 SRAM 存数据。
严格来说,哈佛架构不是通用的——如果你在哈佛架构上运行一个"通用"程序,你确实可以借此运行"以数据形式存储的程序",但这样一来,你就失去了程序、数据分开访问路径带来的全部好处!也就是说,一旦你为了通用性打破了"分开"的约定,哈佛架构相对冯·诺依曼架构的速度优势也就随之消失了。
09.4 分离缓存架构:鱼与熊掌的折中方案
哈佛架构的"分开存取"确实更快,但牺牲了通用性;冯·诺依曼架构通用又灵活,但程序、数据挤在同一条访问路径上。分离缓存架构(split-cache architecture)是两者一个聪明的折中:主存依然是冯·诺依曼式的统一内存(程序和数据不分家,保留通用性),但 CPU 内部配备两块独立的缓存——指令缓存和数据缓存,让离核心最近、最频繁被访问的这一层,享受到接近哈佛架构"分开访问、互不阻塞"的速度红利。
换句话说:底层(主存)依然是冯·诺依曼式的统一存储,保留了通用性和灵活性;顶层(CPU 内的缓存)却借用了哈佛架构"分开访问"的思路,让取指令和读写数据可以真正并行、互不排队。这是工程上典型的"在正确的层级上做取舍"——不需要在整台机器的架构层面二选一,只需要在缓存这一层悄悄借来对方的优点。
模块 02.3 提到,Pentium 1(1993)是第一颗采用分离式缓存架构的处理器。现在我们知道了:它在"计算机架构"这个分类层面,仍然是一台地地道道的冯·诺依曼计算机(主存里程序和数据从不分家);只是在缓存这一层,悄悄借用了哈佛架构"分开存取"的速度优势——这正是分离缓存架构存在的全部意义。
自测 · Self-Check
哈佛架构为什么"严格来说不是通用的"?如果强行在哈佛架构上运行通用程序会发生什么?
分离缓存架构为什么不直接被归类为"哈佛架构",而依然被视为冯·诺依曼架构的一种?
为什么现代 64 位计算机需要"虚拟地址"这个假象?它具体解决了什么矛盾?
指令集架构与 MIPS
计算机架构告诉我们 CPU、内存、I/O 怎么摆放;但"CPU 到底认得哪些指令、每条指令的比特长什么样",是另一层更具体的规范——指令集架构(ISA),它站在模块 08.1 那张地图"具体、低层"的格子里。这一模块用课程指定的教学 ISA——MIPS——把整条链路走一遍:从寄存器、指令格式,到编码成 0/1,再到真正驱动一颗 Logisim CPU 跑起来。
10.1 什么是 ISA
ISA(指令集架构,Instruction Set Architecture)是一个具体模型,精确规定了"一段由指令列表组成的程序会被怎样执行"。一份 ISA 规范至少要包括:
- 可用寄存器(包括大小和初始值)——但 ISA 规范不包含"这些寄存器该怎么用"的使用惯例,那属于编译器/操作系统层面的约定。
- 指令编码(包括定长/变长),通常以一个操作码(opcode)开头。
- 每条指令的语义,通常用二进制补码表示有符号整数。
10.2 CISC vs RISC
| 流派 | 指令语义 | 指令数量 | 典型实现 |
|---|---|---|---|
| CISC 复杂指令集计算机 | 每条指令可能语义复杂 | 指令很多,但大多冗余——约 80% 的程序只用到约 20% 的指令 | 几乎总是通过微码引擎实现 |
| RISC 精简指令集计算机 | 每条指令语义基础,最多 1 次内存访问——更容易流水线化 | 指令不多,但足够通用 | 常常不经过翻译成微码,直接执行 |
现实中的 CPU 往往是CISC 外壳 + RISC 内核的混合体。AMD 工程师 David Christie 谈到 AMD K5(一颗 x86 处理器)时曾这样描述:
这正好解释了模块 03.2 提到的"微码引擎"到底图什么——CISC 处理器把复杂指令翻译成一串简单、RISC 风格的微指令,芯片内部真正执行的运算核心,其实一直是一颗"RISC 内核";复杂性被挡在译码器这一层,没有渗透到执行电路里。
10.3 MIPS:RISC 的教学级活化石
MIPS(Microprocessor without Interlocked Pipelined Stages)被设计为 RISC 的先驱之一,曾用于任天堂 64 和 PlayStation 1/2,其中一些设计特征至今仍对现代 ISA 设计有参考价值。
MIPS 拥有 32 个通用 32 位寄存器(括号内为别名,蓝字为使用惯例——注意这些惯例不属于 ISA 本身,是软件约定):
| 编号 | 别名 | 惯例用途 |
|---|---|---|
| $0 | $zero | 硬接线为常数 0(写它没有效果) |
| $1 | $at | 保留给汇编器使用 |
| $2–$3 | $v0–$v1 | 函数调用返回值 |
| $4–$7 | $a0–$a3 | 函数调用参数 |
| $8–$15, $24–$25 | $t0–$t9 | 临时寄存器 |
| $16–$23 | $s0–$s7 | 被调用者保存的临时寄存器 |
| $26–$27 | $k0–$k1 | 保留给操作系统内核使用 |
| $28 | $gp | 全局指针(堆区,向上增长) |
| $29 | $sp | 栈指针(调用栈,向下增长) |
| $30 | $fp | 帧指针(其实没必要用,用了也可能有隐患) |
| $31 | $ra | 返回地址 |
MIPS 另有 16 个 32 位浮点寄存器(MIPS III 及以后升级为 64 位),以及三个不能被指令直接当操作数使用的内部寄存器:PC(当前指令地址)、HI/LO(上一次乘/除运算的结果)。
内存可以是字寻址的——第 k 个字的地址就是 k(RAM 模型正是这样,PC 就是指令编号本身);也可以是字节寻址的——第 k 个字节的地址就是 k,这在长地址变得廉价之后成为主流。如果每条指令占 4 字节,PC 往往就是"指令编号 × 4",每执行完一条非跳转指令后加 4 而不是加 1。
10.4 三种指令格式:R / I / J
MIPS 只有三种指令格式,全部固定 32 位:
- J 型:1 个编码后的跳转地址——最"瘦"的格式,专门给无条件跳转用。
- I 型:1 个立即数 + 1 个输入寄存器 + 1 个输出寄存器——立即数直接嵌在指令里,省去一次额外取数。
- R 型:2 个输入寄存器 + 1 个输出寄存器,外加 shamt(移位量)和 funct(算术操作码,配合 opcode 一起决定具体做什么运算)。
三种格式看着是三张不同的表,但只要抓住一条规律,它们就不再是三份要分别硬背的东西,而是同一个动作的三种分支:不管拿到哪种指令,CPU 永远、无条件先读最前面固定的 6 位(第 31~26 位)当作 opcode,然后才决定剩下 26 位该怎么切。opcode = 000000 时,CPU 才知道"这是 R 型,后面还要再切出 rs / rt / rd / shamt / funct 五块,具体做什么运算得看最后的 funct";opcode 是 001000(addi)之类的值时,CPU 直接知道"这是 I 型,后面切成 rs / rt / immediate 三块就够了,不用再多问";opcode 是 000010(j)或 000011(jal)时,CPU 知道"这是 J 型,剩下 26 位整个就是地址,不用切"。换句话说:opcode 的位置和宽度在三种格式里永远一样,变的只是"读完 opcode 之后,剩下的 26 位怎么分"——这也是为什么三种格式全部固定 32 位、opcode 全部占最前 6 位。
J 型指令只有两条——j(无条件跳转:PC := (PC[31:28]<<28) + address)和 jal(跳转并链接:R[31] := PC+4;PC := (PC[31:28]<<28) + address)。jal 本质上是把 RAM 模型里"保存返回地址、再跳转"两条指令合并成了一条——这正是模块 08.3 讲的函数调用惯例在真实 ISA 里的落地,MIPS 程序完全不需要手动操心 PC。
I 型指令覆盖了分支(beq/bne)、带立即数的算术(addi/addiu/slti/sltiu/andi/ori/xori)、取高位立即数(lui)和内存读写(lw/sw);R 型指令覆盖了移位(sll/srl/sra 及其寄存器变体)、跳转寄存器(jr/jalr)、乘除法及其结果读取(mult/multu/div/divu/mfhi/mflo)、以及寄存器到寄存器的算术逻辑运算(add/addu/sub/subu/and/or/xor/nor/slt/sltu)——这些运算种类,正好呼应模块 05.1 里 ALU 该有的"四类子电路"(加法器、取反器、乘法器、按位运算器)。
| 指令 | Opcode | 语义 |
|---|---|---|
| beq $rs,$rt,offset | 000100 | PC += 4 + (R[rs]=R[rt] ? offset : 0),offset = extsign(imm)·4 |
| bne $rs,$rt,offset | 000101 | PC += 4 + (R[rs]≠R[rt] ? offset : 0),offset = extsign(imm)·4 |
| addi $rt,$rs,imm | 001000 | R[rt] := R[rs] + extsign(imm) // 可能溢出 |
| addiu $rt,$rs,imm | 001001 | R[rt] := R[rs] + extsign(imm) |
| slti $rt,$rs,imm | 001010 | R[rt] := (R[rs] <有符号 extsign(imm) ? 1 : 0) |
| sltiu $rt,$rs,imm | 001011 | R[rt] := (R[rs] <无符号 extsign(imm) ? 1 : 0) |
| andi $rt,$rs,imm | 001100 | R[rt] := R[rs] &按位 extzero(imm) |
| ori $rt,$rs,imm | 001101 | R[rt] := R[rs] |按位 extzero(imm) |
| xori $rt,$rs,imm | 001110 | R[rt] := R[rs] ⊕按位 extzero(imm) |
| lui $rt,imm | 001111 | R[rt] := imm << 16 |
| lw $rt,imm($rs) | 100011 | R[rt] := mem[ (R[rs] + extsign(imm)) / 4 ] |
| sw $rt,imm($rs) | 101011 | mem[ (R[rs] + extsign(imm)) / 4 ] := R[rt] |
这里的 (+) 一律是模 2³² 加法;extsign / extzero 分别是符号扩展 / 零扩展到 32 位。
| 指令 | Funct | 语义 |
|---|---|---|
| sll $rd,$rt,sha | 000000 | R[rd] := R[rt] <<补零 sha |
| srl $rd,$rt,sha | 000010 | R[rd] := R[rt] >>补零 sha |
| sra $rd,$rt,sha | 000011 | R[rd] := R[rt] >>补符号位 sha |
| sllv / srlv / srav $rd,$rt,$rs | 000100 / 000110 / 000111 | 同上三条,移位量改由 R[rs]%32 决定 |
| jr $rs | 001000 | PC := R[rs] |
| jalr $rd,$rs | 001001 | R[rd] := PC+4 ; PC := R[rs] |
| mfhi $rd / mflo $rd | 010000 / 010010 | R[rd] := HI / R[rd] := LO |
| mult / multu $rs,$rt | 011000 / 011001 | (HI<<32)+LO := R[rs] × R[rt](有 / 无符号) |
| div / divu $rs,$rt | 011010 / 011011 | (LO,HI) := (R[rs] / R[rt],R[rs] % R[rt])(有 / 无符号) |
jr $rs(R 型)只做一件事——跳过去:PC := R[rs],不留任何痕迹。jal address(J 型)反过来:跳到一个编码在指令本身里的固定地址,同时把返回地址硬性存到 $31:R[31] := PC+4 ; PC := ...。jalr $rd,$rs(R 型)则是两者的结合体——跳转目标像 jr 一样来自寄存器(运行时才能确定,更灵活),但也像 jal 一样顺手保存返回地址;只是存到哪个寄存器可以由 rd 字段自由指定,不像 jal 那样锁死写 $31。三者的差别,本质上就是"跳转目标固定 vs 来自寄存器"和"要不要保存返回地址、存到哪里"这两个维度的排列组合。
| 指令 | Funct | 语义 |
|---|---|---|
| add $rd,$rs,$rt | 100000 | R[rd] := R[rs] + R[rt] // 可能溢出 |
| addu $rd,$rs,$rt | 100001 | R[rd] := R[rs] + R[rt] |
| sub $rd,$rs,$rt | 100010 | R[rd] := R[rs] − R[rt] // 可能溢出 |
| subu $rd,$rs,$rt | 100011 | R[rd] := R[rs] − R[rt] |
| and $rd,$rs,$rt | 100100 | R[rd] := R[rs] &按位 R[rt] |
| or $rd,$rs,$rt | 100101 | R[rd] := R[rs] |按位 R[rt] |
| xor $rd,$rs,$rt | 100110 | R[rd] := R[rs] ⊕按位 R[rt] |
| nor $rd,$rs,$rt | 100111 | R[rd] := ¬按位(R[rs] |按位 R[rt]) |
| slt $rd,$rs,$rt | 101010 | R[rd] := (R[rs] <有符号 R[rt] ? 1 : 0) |
| sltu $rd,$rs,$rt | 101011 | R[rd] := (R[rs] <无符号 R[rt] ? 1 : 0) |
这里的 (−) 是模 2³² 减法。
下面是几条真实指令的编码示例,能直观看到"格式"是怎么变成一串 0/1 的:
| 指令 | 编码(二进制,按字段分组) |
|---|---|
| j 0x00400078 | 000010 · 00 0001 0000 0000 0000 0001 1110 |
| addiu $16,$16,4 | 001001 · 10000 · 10000 · 0000 0000 0000 0100 |
| sltu $10,$8,$10 | 000000 · 01000 · 01010 · 01010 · 00000 · 101011 |
MIPS 还有一个历史遗留的怪癖——分支延迟槽(branch delay slot):条件分支指令(如 beq、bne)之后紧跟的那个指令槎位,总会被执行,无论分支条件最终成立与否。这是为了方便流水线化分支指令而设计的:CPU 可以在还不知道分支结果之前,就开始执行延迟槽里的指令。但这个技巧很粗糙,一旦有了合理的分支预测(回忆模块 03.2!),它的收益就被完全抵消了——现代 CPU 的 ISA 里已经没有分支延迟槽,本课程里我们也不会使用分支延迟槽。
10.5 从 C 到比特:一条完整的编译链路
把 C 语言的一个求因数分解的小程序,逐层翻译成 MIPS 汇编、再编码成十六进制机器码,能完整看到"高级语言 → 汇编 → 比特"这条链路的每一环:
| C 程序(节选) | MIPS 汇编(节选) | 十六进制编码(节选) |
|---|---|---|
| int n = mem[...]; int k = 2; while( k*k <= n ) { while(true){ if(n%k!=0) break; mem[p/4]=k; ... } k += 1; } | lui $4, 4097 lw $8, 0($4) ori $9, $0, 2 multu $9, $9 mfhi $10 sltu $10, $8, $10 divu $8, $9 mfhi $10 ... | 3c041001 8c880000 34090002 01290019 0000501... ... ... |
课程用 Logisim 把这样一颗能运行 MIPS 指令的 CPU 真正搭了出来——它内部由指令内存(存放上面这样的机器码)、寄存器堆(32 个通用寄存器的物理实现)、ALU(做算术逻辑运算)、分支逻辑(判断是否跳转)、数据内存(对应 lw/sw)和一个把 opcode/funct 解码成一堆控制信号的控制单元组成。
回头看,这颗 CPU 里的寄存器堆,正是模块 07 讲的 D 触发器阵列一排排拼起来的;ALU,正是模块 05 的加法器、乘法器电路;分支逻辑,正是模块 03.2 讲的分支预测思想在数字电路层面的具体判断逻辑;而控制单元用一整片逻辑门把 opcode/funct 解码成一串控制信号,正是模块 06 讲的逻辑门电路。指令集架构(ISA),说到底就是把这些底层积木组织起来、对外呈现的一份"使用说明书"——学完这一路,你已经从晶体管一直走到了一颗真正能跑程序的 CPU。
10.6 CPU 数据通路与控制单元
ISA 只规定"每条指令该做什么",真正让它跑起来,还需要一整套硬件把 opcode/funct 翻译成具体动作。课程用 Logisim 搭的这颗 CPU,大致由这几大块组成——每一块你其实都已经在前面的模块里单独学过:
- Instruction Memory(指令内存):存放编码好的机器码,PC 指向哪条就取哪条。
- Register File(寄存器堆):32 个通用寄存器的物理实现——模块 07 讲的 D 触发器阵列一排排拼起来,配合译码逻辑,按"读寄存器 ID"读出内容、按"写寄存器 ID"写入新值。
- ALU / MD:算术逻辑运算和乘除运算——模块 05 的加法器、乘法器电路。
- Data Memory(数据内存):对应 lw/sw 这类访存指令。
- Branching(分支判断逻辑):算出两个寄存器是否相等、目标地址该是多少,决定 PC 下一步该跳到哪。
- HI / LO:暂存乘除法的结果。
- Control Unit(控制单元):整颗 CPU 的"翻译官"——输入只有 Opcode 和 Funct 这 12 位,输出却是一整排控制信号,逐一决定上面每个部件这一拍该干什么。
控制单元本身,说穿了就是模块 06 讲的逻辑门电路——一片纯组合逻辑,没有存储、没有状态,输入变了输出立刻跟着变。它吐出来的控制信号大致有这些(名字来自课程电路里的实际标注):
| 信号名 | 决定的事情 |
|---|---|
| Jump / Jump Register | PC 下一步是否要跳转;如果跳,目标是编码好的地址还是某个寄存器里的值 |
| Branch / Branch Not Equal | 是否是条件分支指令,条件是"相等就跳"还是"不等就跳" |
| ALU Source | ALU 的第二个输入,来自寄存器(R 型)还是立即数(I 型) |
| Signed Immediate | 立即数扩展到 32 位时,用符号扩展还是零扩展 |
| ALU Control Bits | ALU 这一拍具体做哪种运算(加/减/与/或/异或/比较……) |
| MD Control Bits / Use MD | 要不要用乘除单元、用哪种(有/无符号乘、有/无符号除) |
| Memory Read / Memory Write | 这一拍要不要读数据内存、要不要写数据内存 |
| Register Write | 这一拍要不要把结果写回寄存器堆 |
| Use Register Destination | 写寄存器时,目标是 rd 字段(R 型)还是 rt 字段(I 型) |
| Move From Low/High | 写回寄存器的内容是不是要从 HI/LO 里取 |
给这样一颗 CPU 添加一条新指令,数据通路本身(ALU、寄存器堆、内存……)几乎不用大改——因为需要的基本运算能力早就在那儿了。真正要动脑筋的地方几乎总是控制单元:给定这条新指令的 opcode/funct 组合,该让控制单元吐出哪一组信号,才能让已有的这些部件按正确的顺序拼出想要的行为?如果某个信号组合是"前所未有"的(比如让 Register Write 和 Memory Write 在同一拍里都为 1),那还要回头检查一下,数据通路里对应的接线,是不是真的支持这种新组合同时生效。
10.7 多核机器与线程同步原语
现代 CPU 普遍拥有多个核心(core),大多是异步的——每个核心按自己的节奏运行,不能假设不同核心在同一段时间内完成相同数量的指令。多数操作系统支持多线程:操作系统给每个核心最多分配一个线程,并定期打断每个核心、给它换上另一个线程(这正是模块 04.3~04.4 讲过的中断驱动多任务机制,在多核场景下的自然延伸)。
正因为如此,多线程程序必须通过内存来协调——单纯的读/写操作是不够的!假设两个核心同时对内存里的同一个计数器做"读出来、加一、写回去",如果没有额外保护,两次自增很可能因为读写交错而丢失一次更新。这就需要硬件提供读-改-写(Read-Modify-Write)原子操作:
- TAS(Test-And-Set):读取一个值,同时无条件把它设为"已占用",整个过程不可被打断。
- FAA(Fetch-And-Add):读取一个值,同时把它加上某个数,整个过程不可被打断。
- CAS(Compare-And-Swap):只有当前值等于预期值时才更新为新值,并报告是否成功。
- LL/SC(Load-Linked / Store-Conditional):先"打标记式"读取,之后配套的写入只有在此期间没有其他核心动过这个地址时才会成功。
"读出来、加一、写回去"这三步如果不是原子的,两个核心可能都读到旧值、都各自加一、再都写回去——结果两次自增合并成了一次,计数器凭空少加了一次。TAS/FAA/CAS/LL-SC 这些原语的共同点,就是把"读+改+写"打包成硬件层面不可分割的一步,从根源上避免这种交错。
自测 · Self-Check
为什么说现代 x86 处理器是"CISC 外壳 + RISC 内核"?这对芯片设计有什么好处?
MIPS 的 jal 指令,为什么被说成"合并了 RAM 模型函数调用惯例里的两条指令"?
jr 和 jalr 都是"跳到寄存器里的地址",两者到底差在哪一步?
控制单元的输入只有 Opcode 和 Funct 这 12 位,为什么说"给 CPU 加一条新指令,主要工作在控制单元而不是数据通路"?
为什么两个核心同时对同一个内存位置做"读出来、加一、写回去",可能会丢失一次更新?TAS/FAA/CAS/LL-SC 是怎么解决这个问题的?
性能评估
芯片造出来之后,怎么知道它"好不好"?"性能"从来不是一个单一数字,而是一整组互相牵制的指标——速度、数据率、容量、体积、价格、功耗。这一模块把这些指标一一拆开,也顺便戳破几个"跑分刺客"常用的话术陷阱。
11.1 六个维度
- 速度(跑得多快)
- 数据率(数据传得多快)
- 容量(内部能装多少)
- 物理体积(外部有多大)
- 金钱成本(多贵)
- 功耗成本(多耗电)
回忆模块 01~02 讲的处理器演化史:每一代新工艺,往往同时改善好几个维度——但也常常是拿一个维度去换另一个维度(比如更高的速度往往伴随更高的功耗)。下面几节先集中拆解"速度"和"数据率/容量"这两类最常被拿来吹嘘、也最容易被误读的指标。
11.2 速度:不止是"GHz"
- 时钟速度(Clock speed):单位 Hz(1/秒)。但很多指令要花超过 1 个时钟周期才能完成——单看频率高低,完全无法直接比较两颗不同架构 CPU 的实际速度。
- 基准测试原始耗时:单位秒。基准程序(如 Livermore Loops)有用,但结果主观——换一套基准程序,排名可能完全反转。
- IPS(每秒指令数):同样因为基准选择(如 Dhrystone)而带有主观性。
- FLOPS(每秒浮点运算数):主要用于估计 FPU 的利用效率,但每个人对"一次浮点运算"的定义都不一样——比如有的评测口径武断地把一次除法算成 4 FLOPs,单精度和双精度又该怎么换算,都没有统一标准。
同一枚"跑分"数字,换一个基准测试程序、换一种 FLOP 计数口径,排名就可能反转。这也是为什么严谨的性能比较必须说清楚"用什么基准测出来的"——脱离基准口径谈"谁更快",本身就是一句没有意义的话。
11.3 加速比 Speedup
加速比(Speedup) = 新速度 / 旧速度 = 旧耗时 / 新耗时。
如果某项改进能把某种特殊场景的耗时减半,但这种场景只有 40% 的概率发生,那么平均加速比 = 1 / ( 0.6·1 + 0.4·1/2 ) = 1 / 0.8 = 1.25——也就是平均只快了 25%,远低于"减半"给人的直觉印象。这其实正是阿姆达尔定律(Amdahl's Law)背后的核心直觉:一项优化能带来的整体加速比,永远被"它能覆盖到的场景占比"死死限制住。
在拥有 p 个处理器的多核机器上,情况更加保守:不能指望当 p → ∞ 时加速比 → p。比较现实的目标是让总耗时落在 O(w/p + polylog(n,p)) 这个区间里,其中 n 是算法输入或数据结构的规模,w(工作量)是单个处理器(即串行)完成全部工作所需的时间。这个公式里的 "+polylog(n,p)" 项,代表协调多个处理器本身也要花时间——处理器越多,协调开销通常也跟着增长,这是加速比不能无限逼近 p 的根本原因。
11.4 CPI:每条指令要几个时钟周期
程序 P 的 CPI(cycles per instruction,每指令周期数) = P 运行时耗费的时钟周期数 / P 执行的指令数。当 P 是一个足够热门、值得为它专门优化 CPU 的程序时,CPI 是个很有用的指标。
机器 A 主频 2 GHz、对程序 P 的 CPI 为 2.5;机器 B 主频 1.2 GHz、CPI 为 1.4。哪台更快,快多少?
A 每条指令耗时 2.5/2 = 1.25 纳秒;B 每条指令耗时 1.4/1.2 ≈ 1.167 纳秒。
B 更快,快了 1.25 / (1.4/1.2) = 1.5/1.4 = 15/14 ≈ 1.07 倍——单看主频,A 的 2 GHz 几乎是 B 的近两倍,但把 CPI 也算进去之后,真正更快的反而是主频更低的 B。这正是"只看 GHz 判断快慢"最容易踩的坑。
11.5 数据率与容量
数据率(Data Rate):单位 bps(每秒比特数)或 B/s(每秒字节数),适用于任何数据传输场景——RAM 读写、硬盘读写、USB 传输、局域网/无线传输……
容量(Capacity):单位通常是字节,适用于任何存储部件——L1/L2/L3 缓存、RAM、硬盘、外部存储……小心 GB ≠ GiB,但日常场景里"GB"常常被拿来当 GiB 用,容易在精确计算时产生误差。
如果把历代游戏主机的 Dhrystone MIPS、RAM-CPU 总线速度、RAM 容量分别按年份画在对数坐标上,会发现它们几十年来都近似排成一条直线——这正是指数增长在真实产品上留下的痕迹。下一节要讲的摩尔定律,就是对这条曲线最广为人知的一种解释。
11.6 摩尔定律:一个"自我实现"的预言
摩尔在 1965 年做出的预测,某种程度上是自我实现的——整个半导体行业把它当作研发目标来使用。摩尔本人在 2005 年的一次采访中这样说:
回忆模块 01.2 的芯片里程碑表格——Intel 4004(1971,2,300 个晶体管)→ 8080(1974,4,500)→ 8086(1978,29,000)→ Pentium 1(1993,300 万)→ Pentium 4(2006,1.88 亿),13 年翻了 60 多倍。这正是摩尔定律在真实产品上的具体体现,也解释了为什么"研发路线图"能让这条曲线维持这么多年:不是自然规律逼着晶体管变小,而是整个行业每年都在朝着"下一次翻倍"这个目标集体冲刺。
摩尔定律显然不可能永远成立——晶体管不可能被做得比一个原子还小。在 2024 年的 SPIE 先进光刻大会上,一群光刻工程师"庆祝"了摩尔定律的死亡:芯片制造业已经无法在 20 nm 以下继续维持相同的单位面积成本。指数增长曾经真实存在了半个多世纪,但物理极限终究是物理极限。
自测 · Self-Check
为什么"时钟频率更高"不能直接等价于"这颗 CPU更快"?
为什么多核机器的加速比不能随处理器数 p 无限增长而趋近于 p?
摩尔定律为什么被称为"自我实现的预言"?这跟"物理定律"有什么本质区别?
总线、I/O 系统与存储
CPU 再强,如果没法和键盘、屏幕、硬盘"说话",就是一块孤立的硅。这一模块补上系统里剩下的两块拼图:总线(bus)——设备之间怎么通信;存储(storage)——数据到底被"记"在哪种物理介质上,以及这份"记忆"要花多久才能取出来。
12.1 为什么需要总线
CPU 需要一个 I/O 总线接口,才能同时接上键盘、鼠标、屏幕、硬盘、打印机、USB 设备……总线(bus)是计算机内部各独立部件之间的通信系统。主要有三类:内存总线连接 CPU 与 RAM;I/O 总线连接 CPU 与其他部件;还有一些总线直接把部件连到 RAM 上。总线是计算机可扩展性(比如插上一个新设备)的关键,但每条总线都有硬性上限:能连接的最大设备数量,以及最大数据率。
12.2 三组信号线:控制、地址、数据
一条总线由控制 + 地址 + 数据三组线组成。控制线可能包括:内存写/读、I/O 写/读、传输确认(Transfer ACK)、总线请求/授予(Bus request/grant)、中断请求/确认(Interrupt request/ACK)、时钟、复位——注意"中断请求/确认"正是模块 04 讲的中断机制在总线层面的具体信号线。
请特别注意"共享"这两个字,它是理解后面所有总线设计的钥匙。上图里四个部件接的是同一组导线,所以同一时刻只能有一个设备驱动数据线——如果一个设备输出 1、另一个同时输出 0,电路上就是电源直接对地短路(总线冲突 / bus contention),轻则数据全错,重则烧毁驱动器。
硬件上的解决办法是:挂在总线上的设备输出端一律用三态门(tri-state buffer)。除了输出 0 和 1,它还有第三种状态——高阻态(Z),相当于把自己从这根线上物理断开。任何时刻只有被允许发言的那个设备解除高阻,其余全部挂 Z。
总线本质上就是用三态门实现的"分布式 MUX"。功能上两者做的是同一件事——多个信号源选一个送出去;区别在于实现方式:MUX 把所有输入线都拉到一个集中的选择器上(线多、但逻辑简单、无需协商),总线让每个设备就地接到共享线上、靠三态门自己决定要不要发言(线少、但必须有一套仲裁机制决定谁发言)。设备一多,总线的布线代价远小于 MUX——这就是为什么系统级互连用总线,而 ALU 内部选运算结果用 MUX。
12.3 内存总线 vs I/O 总线:专用与通用的取舍
一台机器里不会只有一条总线,而是按"快而专"和"慢而通用"分成两类。这不是历史遗留的妥协,而是一个无法回避的物理与工程权衡。
内存总线(memory bus)只接 RAM 一种设备,所以它可以把每一分性能都榨出来,原因有四条,每条都值得单独理解:
- 专用:既然对面只可能是 DRAM,时序参数就可以按 DRAM 的极限写死——比如"发出地址后第 14 个周期,数据必然已经在线上"。不需要留任何兼容余量,更不需要问对方"你好了没"。省掉的每一次问答,都是省掉的延迟。
- 短:CPU 到内存插槽物理上只有几厘米。信号在 PCB 上大约每纳秒跑 15~20 厘米,距离短意味着传播延迟小、时钟偏移小,于是才敢跑到很高的频率还保持同步(原因见 12.5)。
- 宽:DDR 是 64 位并行,双通道就是 128 位,一个周期搬 8~16 字节。并行线能做这么宽,靠的也是"短"——线一长,并行线之间的偏移和串扰就压不住了。
- 负载固定:主板设计时就已知会挂几条内存条、每条什么电气特性,于是可以做精确的阻抗匹配、等长布线,开机时还会跑一遍时序训练(training)逐根校准延迟。
综合结果:DDR5 的访问延迟大约 60~80 ns,带宽几十 GB/s。
I/O 总线的代价全部来自"通用"这个要求。它要同时伺候速度差好几个数量级的设备——键盘每秒几 KB,NVMe SSD 每秒几 GB。要让任何厂商造的任何设备插上就能用,就必须有总线标准(PCIe、USB、SATA、I²C……)。标准规定的东西非常多:机械连接器尺寸、电气电平、信号协议、事务包格式、设备枚举与配置流程、错误处理与重传。
标准化的代价是协议开销:数据不能裸奔,要打包(包头、地址、CRC 校验)、要握手确认、链路启动时还要做 link training;再加上通常要经过桥接芯片(芯片组 / PCIe root complex)多转换一层。
| 维度 | 内存总线 | I/O 总线 |
|---|---|---|
| 接什么设备 | 只接 RAM,种类单一、特性已知 | 任意通用设备,速度差几个数量级 |
| 协议 | 时序写死,几乎无协商开销 | 打包 + 校验 + 握手 + 枚举配置 |
| 物理长度 | 几厘米,可以同步高频 | 可能几十厘米到几米(USB 线缆) |
| 典型延迟 | ≈ 60–80 ns | 几百 ns ~ µs 级 |
| 典型带宽 | 数十 GB/s(DDR5) | 数十 GB/s(PCIe 5.0 ×16 ≈ 63 GB/s) |
| 换来了什么 | 极致速度,但完全不可扩展 | 可扩展性:插上就能用 |
看最后两行:PCIe 5.0 ×16 的带宽已经追平甚至超过内存总线了,但延迟仍然差一个数量级。原因是——带宽可以堆,延迟堆不上去。带宽不够就加宽位宽、提高频率、多开几个通道,都是花钱能解决的;延迟由物理距离、协议往返次数、中间经过的层数决定,这些是每一次访问都要实打实付出的成本,加钱也绕不开。这就是 slide 里"in both latency and data rate"要把两者分开说的原因,也是考试里最常设的陷阱。
落到真实硬件上:一台典型桌面 PC 里,处理器通过桥接器/内存控制器连接缓存和 DRAM,再挂到一条 PCI/PCIe 总线上;音频、视频设备直接挂在这条总线上;LAN、SCSI(连接硬盘)以及通往扩展总线的扩展桥接器同样挂在这里。历史上这个分层就是北桥(接内存和显卡,快)和南桥(接硬盘、USB、网卡,慢)。现代 CPU 已经把内存控制器和 PCIe 控制器直接集成进芯片内部,但"近的快、远的慢;专用的快、通用的慢"这条分层逻辑一点没变。
12.4 总线主控(Bus Master)与仲裁
既然总线是共享介质、同一时刻只能有一个设备说话,那就必须先回答一个问题:谁有资格开口?
能够主动发起(initiate)一次读/写事务的设备,叫总线主控方(bus master);只能被动响应的叫从设备(slave / target)。
- CPU 永远是 master——它主动取指令、主动读写数据,从不坐等别人来访问它。
- RAM 永远是 slave——它只会响应,从不主动发起任何事务。内存条不会自己决定"我要把这段数据送给谁"。
- 真正有意思的是第三类:具备主控能力的 I/O 设备——DMA 控制器、网卡、显卡、NVMe 控制器。它们可以绕过 CPU 直接读写内存,这就是 DMA(Direct Memory Access)。
举个例子:网卡收到一个 1500 字节的以太网帧。如果网卡只是 slave,CPU 就必须执行几百条 lw/sw,一个字一个字把数据从网卡寄存器搬进内存——千兆网卡满速跑能把整颗 CPU 占死,什么正事都干不了。而如果网卡本身是 bus master,它自己发起总线事务把数据直接写进内存,写完只发一个中断说"好了,数据在这个地址",CPU 的总代价就只是一次中断处理。这就是 12.6 里"DMA"那一条在硬件层面的真正含义——它不是一个软件技巧,而是把 I/O 设备提升为总线主控方这件事的结果。(现代 PCIe 设备的配置空间里有一个 Bus Master Enable 位,驱动必须置位,设备才被允许做 DMA。)
一旦存在多个 master,就必然需要仲裁(arbitration)——因为同一时刻仍然只能有一个设备驱动总线,两个 master 同时发起事务的后果就是 12.2 说的总线冲突。仲裁方案主要有三类:
| 方案 | 怎么工作 | 优点 | 缺点 |
|---|---|---|---|
| 菊花链 Daisy Chain |
仲裁器发出一根 grant 信号,串行穿过所有 master。离仲裁器近的先看到:想用就截留,不想用就往下传。 | 线极少(不管多少设备都是那几根),逻辑简单 | 优先级由物理位置写死;排在末尾的设备在总线繁忙时可能永远轮不到(饿死 / starvation);grant 串行穿过所有设备,设备越多仲裁越慢 |
| 集中并行 Centralized Parallel |
每个 master 有独立的 request / grant 线接到中央仲裁器,仲裁器用任意策略挑选:固定优先级、轮转(round-robin)、公平队列、加权 QoS…… | 策略灵活,可保证公平、不会饿死;仲裁速度快 | 线数随设备数线性增长(n 个 master 要 2n 根线);仲裁器本身是单点 |
| 分布式自选 Distributed / Self-selection |
没有中央仲裁器。每个想用总线的 master 把自己的优先级编码放到共享仲裁线上(开漏输出 = 线与),所有设备同时观察并与自己的编码比较,发现有人更高就自动退出,剩下的那个自然获胜。CAN 总线就是这么做的。 | 无单点、可扩展、天生适合分布式系统 | 协议复杂;需要多根仲裁线来编码优先级 |
一次完整的仲裁时序是:request(我要用) → arbitration(仲裁器决定) → grant(批准) → 使用总线 → release(释放)。这里有个实用优化值得记:好的设计会做流水线化仲裁(pipelined arbitration)——在当前这次数据传输还在进行的同时,就并行地仲裁下一次归谁用。这样总线利用率能接近 100%,而不是"传完 → 停下来仲裁 → 再传"地走走停停。这个"重叠起来做"的思路,和模块 09 里流水线的核心思想是同一个。
12.5 同步总线 vs 异步总线
决定了"谁说话"之后,还剩最后一个问题:说话的双方怎么对齐节奏?两条路线——靠共同的时钟,或者靠互相打招呼。
同步总线(synchronous):所有设备共用一根时钟线,整个事务按固定的周期数推进。协议直接写死:"第 1 个周期 master 放地址,第 3 个周期 slave 的数据必须已经在数据线上,master 在第 3 拍的时钟沿采样。"没有任何询问、没有任何确认——时间到了,数据就该在那儿。协议因此极其简单,每个周期都能干活,所以快。
异步总线(asynchronous):没有共用时钟,靠握手协议(handshake)配合。经典的四相全互锁握手(four-phase fully interlocked handshake)每一步都必须等到对方的响应才能进行下一步,所以无论线有多长、对方有多慢,协议永远不会出错——慢设备就慢慢应答,快设备就快快应答,各取所需。
下面这个演示把两种方式的同一次"读内存"事务并排拆开,一拍一拍走一遍,重点看信息是靠什么在推进:
为什么同步总线"必须短"——时钟偏移(clock skew)
这是本节最重要、也是最常考的一个因果链,值得完整推一遍。
时钟信号从时钟源出发,到达总线近端和远端的时间是不一样的:走线越长、挂的负载越多,这个到达时间差(就是 skew)越大。现在做个定量估算:假设总线跑 1 GHz,那么一个周期只有 1 ns;如果时钟走到总线另一头晚了 0.4 ns,远端设备眼中的"上升沿"就比近端晚了将近半个周期。它采样的时刻于是可能落在数据还没稳定、或者数据已经变成下一拍的窗口里——采到的就是垃圾。
所以这条因果链是:频率越高 → 周期越短 → 能容忍的 skew 越小 → 总线必须越短。这是硬物理限制,不是设计水平问题,加钱也解决不了。同步总线的另一个缺点是所有设备被迫服从同一套时序,快设备被慢设备拖累(除非引入 wait state 让慢设备插入等待周期,但那又部分退化成了"问答")。
| 维度 | 同步(基于时钟) | 异步(基于握手) |
|---|---|---|
| 靠什么对齐 | 共用一根时钟线,事务按固定周期数推进 | Request / Acknowledge 互锁握手,逐步推进 |
| 速度 | 快:零协商开销,每周期都在传数据 | 慢:每次传输至少 4 次信号往返 |
| 复杂度 | 简单:本质上是"数拍子" | 复杂:双方各是一个状态机,还要额外的握手线 |
| 长度限制 | 必须短,受 clock skew 硬性约束 | 可以很长,不依赖时钟对齐 |
| 设备速度 | 被迫统一时序,快设备被拖累 | 天然兼容任意速度混挂,各按各的节奏 |
| 典型用在 | 内存总线、片内总线 | 老式 I/O 总线、长线缆、跨时钟域场景 |
今天的高速总线两条路线都不是纯的。PCIe 用的是嵌入式时钟(embedded clock)——不单独拉时钟线,而是用 8b/10b、128b/130b 编码保证数据流里有足够的电平跳变,接收端用 CDR(时钟数据恢复)电路从数据流本身把时钟提取出来。这样既躲开了独立时钟线的 skew 问题,又保留了同步传输的高吞吐。
更值得注意的是 PCI → PCIe 的演进本质:从并行共享总线变成了串行点对点链路。原因正是上面那条物理限制——在 GHz 以上,并行线之间的 skew 和串扰根本压不住,所以宁可用一对差分线跑极高速率,再用多个 lane 并起来堆带宽。今天我们说 PCIe/USB 是"总线",其实它们在拓扑上已经是交换式网络了,只是沿用了 bus 这个名字。
这三页 slide 最典型的考法是简答题:"why must a synchronous bus be short"、"what is a bus master and why is arbitration needed"、"compare the memory bus and the I/O bus"。答这类题时,把物理原因说出来——时钟偏移与周期长度的关系、共享介质上的驱动冲突、传播延迟与协议往返次数——得分远高于只背结论。判分点往往就在"为什么"那一句上。
12.6 CPU 怎么和设备对话
CPU 与 I/O 设备通信有两种方式:PMIO(端口映射 I/O)使用专门的 CPU 指令,某些 I/O 端口地址被分配给设备,设备只处理落在自己端口地址上的事务;MMIO(内存映射 I/O)则直接使用普通的内存访问指令,内存地址空间的一部分被分配给设备,设备处理落在这些内存地址上的访问。
使用 I/O 设备的控制结构也有几种层次:
- 程序化 I/O(软件轮询):程序不断反复检查设备是否准备好——简单但白白浪费 CPU 时间。
- 中断驱动 I/O:CPU 定期被时钟打断,顺带检查所有设备,一旦有设备就绪就触发中断。
- DMA(直接内存访问):CPU 把总线控制权临时交给 DMA 控制器,由它直接在设备和内存之间搬运数据,不需要 CPU 逐字节盯着。
- I/O 处理器/控制器:把 DMA 控制器的思路进一步推广,交给更高级的 I/O 控制逻辑全权处理。
"中断驱动 I/O"用的正是模块 04.3~04.4 讲过的同一套硬件机制——CPU 定期被计时器打断,顺便检查一下有没有设备就绪。这再次印证了"中断"是贯穿操作系统任务调度和 I/O 管理的同一根主线,而不是两套互不相关的技术。
12.7 存储系统的四个指标
任何存储系统都可以用四个指标衡量:容量(能存多少字节)、数据率(传输开始之后每秒能传多少 bps)、延迟(要多久才能开始传输)、成本。下面按机械程度从高到低,依次看磁盘、光盘、磁带、闪存四种存储介质。
12.8 磁盘存储:机械时代的记忆
磁盘用于长期、非易失性的大容量存储。2025 年典型消费级硬盘容量约 2 TB,自 1997 年起大致每 2 年翻两番(比摩尔定律的"每 2 年翻一番"还激进)——又是一条熟悉的指数曲线。典型特性:寻道时间约 10 ms,传输速率约 100 MB/s,价格低廉(约 50 美元/2025 年)。
磁盘有若干个盘片(Platter),每个盘片的(通常两个)表面上都磁性记录着比特,记录在磁道(Track)上,磁道又划分成扇区(Sector)(例如 4 KB 一个)。每次读写时:执行器(Actuator)移动磁臂(Arm)(每个表面配一根),带动磁头(Head)跨越磁道(这个过程叫寻道 Seek),选中目标表面,等待目标扇区旋转到磁头下方,然后开始读或写。
磁盘访问的延迟由队列 + 控制器 + 寻道 + 旋转 + 传输组成,其中"控制器+寻道+旋转"这三段合称延迟(latency)(不含传输本身)。控制器(非机械部分)耗时通常只有微秒级。
一块转速 7200 RPM、寻道时间 10 ms 的磁盘:7200 RPM = 120 转/秒 ⇒ 每转约 8 ms;平均旋转延迟 ≈ 8ms/2 = 4 ms;平均延迟 ≈ 4ms + 10ms = 14 ms。相比 CPU 时钟周期常见的纳秒级,14 毫秒是天文数字般的等待——这正是为什么磁盘访问必须尽量减少次数、尽量批量化,也是缓存和虚拟内存系统存在的重要动机之一。
12.9 光盘与磁带:为什么它们没有消失
光盘的存取时间比硬盘长得多,但作为存档介质的寿命也长得多。类型包括 ROM(只读)、R(一次写入)、RW(可读写)、RAM(随机存取)。容量:CD 系列约 700 MB;DVD 系列约 4.7 GB/层(最多 4 层);HD DVD 约 15 GB/层(最多 3 层);蓝光约 25 GB/层(最多 4 层)。机制上,光盘表面刻有一圈圈由坑(pit)嵌入底(land)组成的环形轨道,激光干涉用于检测两者之间的跳变:有跳变编码 1,无跳变编码 0——这种"靠状态跳变而不是靠状态本身编码信息"的思路,和模块 07 讲的边沿触发(靠信号跳变的瞬间采样,而不是靠电平本身)有着相似的设计精神。
磁带使用和磁盘相同的读写记录技术,物理介质是涂有磁性氧化物的柔性聚酯薄膜;每条磁带只有少量并行磁道;关键限制是磁带是顺序访问的(不是随机访问!),容量却能达到约 10 TB/卷——比大多数硬盘还大,正因为顺序访问不需要复杂的寻道机械,可以把存储密度做得极高。
磁带慢到没法当日常盘用(顺序访问,随便读一个文件可能要从头倒带),但密度高、寿命长、成本低,天生适合"写一次、多年不碰"的冷备份场景;光盘寻道时间也长,却因物理介质极其耐存放,被广泛用于档案保存。速度从来不是存储介质唯一的评价维度——12.4 节的四个指标里,容量、寿命(隐含在成本/可靠性里)同样重要,甚至在特定场景下比速度更重要。
12.10 闪存:没有机械部件的存储
CF/SD/SDHC 卡:读取速度约 100–200 MB/s;写入速度约 10 MB/s(便宜款)到 100 MB/s(高端款)。SSD(固态硬盘):容量 500 GB 到 2 TB,数据率更高、延迟更低,运行静音,价格约 100–300 美元——没有任何机械部件,是它们比磁盘快得多、也更耐用的根本原因。
闪存有两种常见的存储单元机制:
- 浮栅 MOSFET(floating-gate MOSFET):本质上和普通 MOSFET 一样,只是栅极被电学绝缘起来(因此叫"浮栅"),用来长期存储 MOSFET 的开/关状态。浮栅通过控制栅附近施加的高电压差来充电或放电——这个过程需要一点时间(写比读慢),而且会轻微损伤绝缘层(写入次数有限,现代浮栅 MOSFET 支持约 5 万次写入循环)。
- 电荷陷阱闪存单元(charge-trap flash cell):和 MOSFET 类似,但在通道和栅极之间多了一个电荷陷阱结构。例如一个 3 位电荷陷阱能存储 2³=8 种电荷等级,读取时通过测量阈值电压(通道开始导通所需的栅源电压)来判断电荷等级。相比浮栅 MOSFET:读取慢得多;信息密度高得多(因此便宜得多);错误率高得多(写入次数只有约 3000 次)。
闪存的存储单元本质上就是模块 06 讲过的 MOSFET,只是多加了一个"悬浮"或"陷阱"结构用来长期锁住电荷——这也解释了为什么闪存断电后数据不会丢失(非易失性),而模块 07 讲的 D 触发器断电就会清空(易失性):区别只在于"记忆"到底是靠持续通电的电路状态维持,还是靠物理结构里被囚禁的电荷维持。前者快但离电就忘,后者慢但能"记"很久——这也是为什么 CPU 内部的寄存器/缓存用触发器,而长期存储用闪存,两者从来不是互相替代的关系。
自测 · Self-Check
为什么"内存总线"和"I/O 总线"要分开设计,不能只用一条通用总线搞定所有连接?
为什么同步总线"必须足够短"?请把因果链完整说出来。
关键在于这条链是被频率反向约束的:频率越高 → 周期越短 → 能容忍的 skew 越小 → 总线必须越短。比如 1 GHz 时一个周期只有 1 ns,0.4 ns 的 skew 就已经接近半个周期。这是物理限制(信号传播速度有限),不是设计水平或成本问题。
什么是 bus master?为什么一旦有多个 master 就必须引入仲裁?顺便说明 DMA 为什么重要。
必须仲裁的原因是总线是共享介质:同一时刻只能有一个设备驱动数据线,否则一个输出 1、另一个输出 0 会造成总线冲突(电源对地短路),数据全错甚至烧毁驱动器。所以需要一套机制在多个想发言的 master 中选出唯一一个。
DMA 的重要性在于它把 I/O 设备提升为 master:网卡收到一个 1500 字节的帧,若只是 slave,CPU 得执行几百条 lw/sw 逐字搬运,千兆网卡能把整颗 CPU 占死;作为 master,网卡自己把数据直接写进内存,完成后只发一个中断,CPU 的代价降为一次中断处理。
菊花链仲裁的"饿死"问题是怎么产生的?集中并行仲裁是如何避免它的?
集中并行仲裁给每个 master 独立的 request/grant 线接到中央仲裁器,于是仲裁策略与物理位置解耦:可以用轮转(round-robin)、公平队列或加权 QoS,保证每个设备在有限时间内必然被服务。代价是线数随设备数线性增长(n 个 master 需要 2n 根线),而且仲裁器成为单点。
异步四相握手为什么"无论线多长、设备多慢都不会出错"?它的代价是什么?
代价有三:一次传输至少要经历 4 次信号在总线上的完整往返,线越长每次往返的传播时间越长,延迟显著高于同步方式;双方各需要一个状态机,控制逻辑复杂;还要额外占用握手线。
磁带的容量能做到比多数硬盘还大,为什么它却不能取代硬盘做日常存储?
为什么电荷陷阱闪存单元比浮栅 MOSFET 便宜得多,但可靠性(写入次数上限)却更差?
从 1833 年的差分机,到今天藏在手机里的十亿晶体管芯片,再到抽象出 TM/RAM/PM 模型去谈论"计算"本身——这份手册走的其实是一条完整的下潜再上浮路线:从计算机世代变迁(模块 01~02)下潜到 CPU 部件、晶体管与触发器(模块 03、05~07),中途插入抽象机器模型与计算机架构这条理论主线(模块 04、08~09),再一路上浮回到指令集架构、性能评估、总线与存储系统(模块 10~12),最终重新拼出"一台计算机是怎么把 0/1 变成你屏幕上的答案"这个完整故事。用更小的开关,搭出更快的门;用更快的门,搭出更聪明的电路;用更聪明的电路和更聪明的指令集,换来我们几乎感觉不到延迟的日常计算。回到侧边栏勾选你已经掌握的模块,或者用每节末尾的自测问答检验一下——这份手册会一直留在这里,供你随时回来复习。