计算机组成原理 105:中央处理器(CPU)
101 给了你一个取指-译码-执行的循环,却跳过了两件事:那些寄存器之间的搬运,控制信号到底是谁发的;以及这个循环一条接一条,怎么才能跑快。这一章把 CPU 这个盒子打开——从数据通路、控制器,一路到流水线和多核。
101 在结尾给了我们一个循环:
取指(Fetch)→ 译码(Decode)→ 执行(Execute)→ 取下一条 → ...
这句话很顺,但它其实把 CPU 里最精彩、也最容易糊的部分全压扁了。
比如 101 里写过:
PC → MAR
主存 → MDR
MDR → IR
ACC + X → ACC
看起来只是几行箭头,但真到硬件里,每一行都会变成一堆非常具体的问题:
PC → MAR到底是谁把 PC 放上总线?谁允许 MAR 收下?- ALU 做加法要两个输入,可 CPU 内部如果只有一条总线,同一拍只能送一个数,另一个数放哪?
- ALU 是组合逻辑,输入一变它就一直在算。那结果寄存器是不是也一直在“吃”?
- 书上说的指令周期、机器周期、节拍,到底是在干嘛?为什么不就一个时钟一路往下走?
这一章就是来把这些“箭头背后的东西”拆开。风格还是 101 那套:先看我们撞上了什么问题,再看 CPU 为什么长成现在这样。 顺序是这样:
- 里面有什么(5.1)——先看清 CPU 由哪些部件、哪些寄存器组成。
- 谁在指挥(5.2)——把"控制器发信号"这句话拆到一个个时钟节拍上。
- 出意外怎么办(5.3)——循环假设一切顺利,可除零、缺页、键盘敲击随时会来。
- 怎么跑快(5.4)——流水线,这一章的重头戏。
- 一个核不够用(5.5)——多核与多处理器。
5.1 打开盒子:CPU 里到底有什么
101 给 CPU 下过一个定义:控制器 + 运算器。这句话没错,但它漏了一个东西。
运算器要算 A + B,A 和 B 得先送到 ALU 的两个输入端,算完的结果还得送出去。这些数据靠什么在 CPU 内部跑来跑去? 靠内部总线和一堆导线。可"控制器 + 运算器"这个切法里,根本没有总线的位置。
所以工程上更常用、也更诚实的切法是另一种:
- 数据通路(Datapath):指令执行时,数据真正流经的那条路径,以及路径上的所有部件——ALU、寄存器组、多路选择器、内总线都算。它是指令的执行部件:负责"真的把活干了"。
- 控制器(Control Unit):对指令译码,生成控制信号,指挥数据通路的每一个动作。它是指令的控制部件:自己不搬数据、不做运算,只负责"指挥谁在什么时候动"。
记住这条分界线:数据从哪来、经过谁、到哪去,是数据通路的事;什么时候让它动、往哪走,是控制器的事。
可以把 CPU 想成一个很小的舞台:寄存器、ALU、总线都是演员和道具;控制器是导演。演员不会自己决定“现在该我上场了”,导演也不会亲自去搬箱子。导演只发信号:谁把数据送出来,谁收进去,ALU 现在做加法还是减法。
后面整整两节(5.1 讲数据通路里有什么,5.2 讲控制器怎么发号施令)都在这条线两边展开。
数据通路里的两类零件
数据通路里的部件,按"记不记得住事"可以分成截然不同的两类。这个区分后面讲流水线时还会用到,先记牢。
第一类:组合逻辑元件(操作元件)。 它的输出只取决于当前的输入——你给它什么,它立刻算出结果,不看时钟、也不记得上一拍发生过什么。输入变了,输出马上跟着变。常见的有:
- ALU:算术逻辑运算的核心,加减与或都靠它。
- 译码器(Decoder):把
n位输入翻译成 2n 种输出中的一种——n位有 2n 种组合,所以有 2n 条输出线,但每次只有一条被使能。操作码译码、地址译码都用它。 - 多路选择器(MUX):好几个输入,输出等于其中某一个,靠一根 Select 控制信号决定"放哪个进来"。
- 三态门:一个由 EN 信号控制的开关。
EN = 1时门打开,输出 = 输入;EN = 0时输出端呈高阻态,相当于这根线被断开了。总线上挂着一堆部件却不打架,全靠三态门轮流上线。
第二类:时序逻辑元件(状态元件)。 它的输出不只看当前输入,还看以前发生过什么——因为它有状态、能存东西。而且它必须踩着时钟节拍工作:不到下一个时钟沿,它就老老实实保持原值不变。
各种寄存器和存储器都属于这一类:通用寄存器组、程序计数器(PC)、状态寄存器、移位/暂存/锁存寄存器……它们是数据通路里"能把值停住"的地方。
一句话对照:组合逻辑元件负责"算",时序逻辑元件负责"记"。 ALU 算完一个加法,结果必须落进某个寄存器(时序元件)停住,否则下一拍输入一变,这个结果就没了。101 里 ACC + X → ACC 那一步,左边的"算"是 ALU 干的,右边的"落进 ACC"是寄存器干的——现在你知道这是两类不同的零件在配合。
控制器在这套零件里干嘛
数据通路是一堆"算"和"记"的零件,它们自己不会动。谁来决定第 3 拍打开哪个三态门、让 ALU 做加法还是减法、把结果写进哪个寄存器? 控制器。
把 101 那个循环落到部件上,控制器干的就是这四件事,循环往复:
- 取指令:指挥 PC 送出当前指令地址,从存储器取出指令,送进指令寄存器(IR)。
- 分析指令:对 IR 里的指令译码,搞清楚它要做什么操作(加法?跳转?)、用什么寻址方式、要哪些操作数。
- 执行指令:按正确的时序发出一串控制信号,打开/关闭特定的数据通路、选择 ALU 的运算、决定写不写寄存器或内存。
- 转向下条:更新 PC,准备取下一条,开始新一轮。
控制器具体怎么把这四件事拆成一个个时钟节拍的信号,是 5.2 的主题。这一节我们先把数据通路里的零件——尤其是那一堆寄存器——认全。光看抽象的"数据通路 + 控制器"还是悬空的,我们需要一个真实的、小到能整个装进脑子的 CPU 来把它坐实。
用 8086 把它坐实
这个 CPU 就是 8086。
它是 Intel 第一款真正的 16 位处理器,也是现代 x86 家族的起点。选它不是因为它先进,恰恰相反——它够老、够小、够简单,寄存器数得过来,结构画得下,而且考试里能遇到的绝大多数问题,靠它都能讲明白。
它的结构
照着 5.1 开头那条分界线看这张图,它正好分成两半:
- 数据通路这边:一组寄存器、ALU、
T暂存器、Flags,还有把它们串起来的指令队列和内总线(Internal Bus)。 - 控制器这边:指令译码器、时序控制单元、标志控制逻辑、微指令控制逻辑。
MAR、MDR 站在内总线和**外总线(External Bus)**的交界处,是 CPU 伸向外部存储器的两只手——这跟 101 里讲主存接口时说的完全一致。
下面我们顺着"数据通路里有哪些寄存器"这条线,把 8086 的寄存器一类一类过一遍。这是本节信息量最大的地方,也是考试最爱抠的地方。
寄存器:CPU 里离 ALU 最近的存储
8086 的寄存器按功能分五大类,外加几个特殊的。先上一张全景图,再逐类拆。
通用寄存器——啥都能存,地址或数据都行。四个,名字带着各自的"出身":
AX(Accumulator,累加器):算术逻辑运算的默认主角,101 里的 ACC 就是它。BX(Base,基址寄存器):常用来存内存地址。CX(Counter,计数寄存器):循环计数、移位次数的专用计数器。DX(Data,数据寄存器):输入/输出、以及大整数运算的搭档。
段寄存器——存的是"某一段内存的起始地址"。为什么需要它?因为一个程序在内存里不是一坨,而是分段摆放的,每段干不同的事:
CS(Code Segment)指向代码段(.text)的开头——指令住这儿。DS(Data Segment)指向数据段(.data)的开头——全局数据住这儿。SS(Stack Segment)指向栈段的开头——函数调用的现场住这儿。ES(Extra Segment)指向附加段,留点灵活性给程序员。
逻辑上,一个程序跑起来需要四个段:代码段(放编译后的指令)、数据段(放全局数据)、栈段(函数嵌套调用的现场)、额外段(机动用)。每个段寄存器认领一个段,指向它的起始地址。这套"分段"的思路,101 讲存储程序时埋了个头——指令和数据都在内存里,但总得有个办法把"哪段是代码、哪段是数据"框出来,段寄存器就是干这个的。
指针寄存器——光知道一个段从哪开始还不够。栈段里函数一层套一层,代码段里指令一条接一条,段内的"当前位置"得有人盯着。指针寄存器就盯这个:
SP(Stack Pointer,栈指针):指向当前栈的顶。BP(Base Pointer,栈基址):指向当前栈帧的底。IP(Instruction Pointer,指令指针):当前指令在代码段内的偏移——它就是 8086 里的"程序计数器 PC"。
栈这块值得停一下,因为它直接对应你写过的每一次函数调用。函数执行时要保存的东西(返回地址、局部变量)天然是后进先出的,所以用栈。8086 的栈从高地址向低地址生长,嵌套调用的函数栈帧一层层往下堆。比如在 main() 里调用 f(g(h(1))):
高地址 ┌─────────────┐
│ main 的栈帧 │
├─────────────┤
│ f 的栈帧 │
├─────────────┤
│ g 的栈帧 │
├─────────────┤ ← BP 指向最后那个栈帧的底
│ h 的栈帧 │
低地址 └─────────────┘ ← SP 指向最后那个栈帧的顶
BP 和 SP 一上一下,正好把最后一个(最内层)栈帧框住:从哪开始、到哪结束。函数返回时,弹掉这一帧,BP/SP 再退回上一层。
IP 则盯着代码段。指令在 .text 段里从低地址往高地址排,所以取完一条指令后,控制器会把 IP = IP + 指令长度,让它指向下一条——这正是 101 里"PC 自增"那一步的真身。
变址寄存器——专门为"遍历"而生,方便你对数组、字符串里的第 i 个元素下手。8086 有两个:SI(源变址)和 DI(目的变址)。看两段汇编就懂了。
数组遍历(对前 5 个元素逐个处理,注意循环次数存在 CX 里,每执行一次 LOOP 它自动减一):
MOV DS, addr ; 数组起始地址放进 DS
MOV CX, 5 ; CX = 循环计数器,数组有 5 个元素
MOV SI, 0 ; SI = 索引,从 0 开始
NEXT_ELEMENT:
MOV AX, [SI] ; 取当前元素到 AX
; ……对 AX 里的数据做处理……
ADD SI, 2 ; 移到下一个元素(每个 2 字节)
LOOP NEXT_ELEMENT ; CX 自减,不为 0 就继续
字符串复制(把源串前 length 个字节搬到目的串,SI 指源、DI 指目的):
MOV SI, OFFSET source ; SI 指向源串
MOV DI, OFFSET dest ; DI 指向目的串
CLD ; 清方向标志,确保从低地址往高地址搬
MOV CX, length ; CX = 串长
REP MOVSB ; 重复 CX 次:把 DS:SI 的字节搬到 ES:DI
注意 REP MOVSB 一条指令就干完了整段循环——它隐含地用 SI、DI 当源/目的指针,用 CX 当次数。这就是变址寄存器的价值:把"遍历"做成了寄存器自动推进的事。
标志寄存器(FLAGS)——它不存数据,存的是运算完之后的状态。这些状态紧接着会被控制器拿去用,决定下一步要不要跳转。8086 的 FLAGS 是 16 位,其中 9 位有意义,7 位空着。
这 9 位分两拨。第一拨是条件标志(conditional flags),运算指令、比较指令(CMP)一执行,硬件就自动把它们设好,不用你手动管:
- OF(Overflow,溢出):有符号数运算结果超出了能表示的范围,置 1。
- CF(Carry,进位):无符号数运算溢出了寄存器位数,置 1。加法时是进位,减法时是借位。
- SF(Sign,符号):结果是负数(符号位为 1),置 1。
- ZF(Zero,零):结果为 0,置 1。
- AF(Auxiliary Carry,辅助进位):低 4 位向高位的进位,主要给 BCD 运算用。
- PF(Parity,奇偶):结果里 1 的个数是偶数置 1,奇数置 0。
这些标志的用处,是喂给条件跳转指令:跳转指令查一眼相关标志位,决定要不要把 PC 改到目标地址,程序的分支就是这么拐弯的。
下面两个点最容易被考、也最容易被搞混,单独拎出来。
CF 和 OF 到底差在哪?
| 标志 | 含义 | 服务于 | 何时置 1 | |:--|:--|:--|:--| | CF | 进位标志 | 无符号数运算 | 加法进位、减法借位 | | OF | 溢出标志 | 有符号数运算 | 结果超出有符号数范围(如
+127 + 1变成-128) |关键认知:上面这套"无符号看 CF、有符号看 OF"只是逻辑上的解读。硬件加法器根本不区分你这串比特是有符号还是无符号——底层全是补码 + 加法器那一套电路。CF 和 OF 是电路从结果的某几位组合出来的,然后我们才赋予它"进位/溢出"的含义。
OF 这一位具体是怎么算出来的?
对两个
n位补码整数 A、B 相加得 C,盯它们的符号位(最高位,第 n−1 位):| 情况 | 结果 | |:--|:--| | 异号相加(一正一负) | 永远不会溢出 | | 同号相加,结果符号不变 | 无溢出 | | 正 + 正,结果却变成负 | 溢出 | | 负 + 负,结果却变成正 | 溢出 |
所以判据一句话:两个操作数同号,结果却跟它俩不同号,就溢出了。 写成布尔表达式(An−1,Bn−1,Cn−1 是三者的符号位):
OF=An−1Bn−1Cn−1+An−1Bn−1Cn−1减法 A−B 怎么办?转成加法 A+(−B),再套上面这套符号位判据即可。
第二拨是控制标志(control flags),它们不会被普通运算指令自动改,得靠专门的指令去设/清。了解即可:
- IF(Interrupt,中断允许):
IF = 1时 CPU 才响应可屏蔽中断,IF = 0时把它们全挡在门外。这一位在 5.3 讲中断时会再出现。 - TF(Trap,单步):
TF = 1时每执行一条指令就触发一次单步中断,调试器逐条调试就靠它。 - DF(Direction,方向):管字符串操作往高地址走还是往低地址走(上面那段
CLD清的就是它)。
特殊寄存器——还有几个不属于上面五类,但绕不开。
先说 T 暂存器。它的存在理由特别能体现 101 那个思路:不是教材想多加一个名词,而是硬件限制逼出来的。
ALU 一次运算要两个操作数。可如果 CPU 内部是单总线结构,同一时刻公共总线上只能有一个部件往外送数据。否则两个寄存器一起“喊话”,总线上电平就打架了。
那执行 R0 + R1 怎么办?不能让 R0 和 R1 同时上总线,只能分拍:
| 时钟 | 微操作 | 为什么这样做 |
|:--|:--|:--|
| T1 | R0out, Tin | 先把第一个数放上总线,存进暂存器 T |
| T2 | R1out, Add, Zin | 第二个数上总线,ALU 同时看到 T 和总线,结果进 Z |
| T3 | Zout, R0in | 结果再从 Z 写回 R0 |
这里有两个容易卡住的点。
第一,为什么没有 Tout?因为 T 到 ALU 通常不是走公共总线,而是专线直连。T 像 ALU 旁边的一个小托盘,第一个数放进去以后,它一直把这个数摆在 ALU 的一个输入端。第二个数才需要通过公共总线送到 ALU 的另一个输入端。所以 T2 这一拍只需要 R1out,不需要 Tout。
第二,ALU 是组合逻辑,确实会一直算。只要 T 和总线的输入一变,ALU 输出就跟着变,前半拍甚至可能因为电路延迟出现毛刺。那 Z 为什么不会把垃圾结果吃进去?因为 Z 是寄存器,属于时序逻辑。它不是“随时吃”,而是等到这一拍末尾的时钟沿,并且 Zin 有效时,才像按快门一样把稳定结果锁住。
所以这套配合可以一句话记住:ALU 负责一直算,暂存器负责卡点记。 单总线下,T/Y 负责先存一个输入,Z 负责把 ALU 的输出稳定地接住。
再说 IR(指令寄存器):CPU 从内存或指令队列取出一条指令,先扔进 IR 停着,供译码和执行用。控制单元译的就是 IR 里这条。
最后一个常考的小问题:哪些寄存器对汇编程序员可见? 写过 8086 汇编的人知道,可见 = 你能用指令直接点名操作。可见的有:通用寄存器、段寄存器、标志寄存器、指令指针 IP。反过来记更省事——不可见的只有三个:MAR、MDR、IR。它们是 CPU 取指访存的内部草稿纸,你的程序碰不到。
引脚:CPU 怎么跟外面通电
寄存器是 CPU 的"内务",可它终究要跟外面打交道——读内存、连外设、接收中断、对时。这些都通过引脚(Pin) 走:它是 CPU 和主板之间的电气接口,负责数据传输、地址定位、中断控制、时序控制。
这一节以了解为主,考试一般不直接考引脚,但它能帮你把"CPU 怎么和外部世界交互"想具体。
8086 是 16 位机,数据总线 16 位;它能寻址 1 MB 空间,所以地址总线 20 位。但芯片一共就 40 个引脚,不够分——于是 8086 玩了个"复用":
| 引脚 | 方向 | 干嘛 |
|:--|:--|:--|
| AD0–AD15 | 双向 | 地址/数据复用:发地址时走 A0–A15,传数据时走 D0–D15 |
| A16/S3–A19/S6 | 输出 | 高位地址线和状态线复用 |
同一组 AD0–AD15,这一拍当地址用、下一拍当数据用,靠时序区分。为了让外部电路别认错,8086 用一根 ALE(地址锁存使能)信号配合外部锁存器,在"现在是地址"的那一刻把地址锁存下来。
为了让外设能打断 CPU、以及能复位 CPU,有这几根:
| 引脚 | 方向 | 干嘛 |
|:--|:--|:--|
| NMI | 输入 | 不可屏蔽中断,紧急、必须立刻响应 |
| INTR | 输入 | 可屏蔽中断请求 |
| RESET | 输入 | 复位,把 CPU 状态初始化 |
| INTA# | 输出 | 中断响应,告诉外设"我开始处理你的中断了" |
还有一组控制信号,是 CPU 对外设发号施令用的:
| 引脚 | 方向 | 干嘛 |
|:--|:--|:--|
| RD# / WR# | 输出 | 读 / 写信号(低电平有效),对内存或 I/O 读写 |
| ALE | 输出 | 地址锁存使能,标志"现在 AD 上是地址" |
| DT/R | 输出 | 数据方向:读 = 0,写 = 1 |
| READY | 输入 | 外设就绪信号,没就绪 CPU 就等 |
| HOLD / HLDA | 输入/输出 | 外设请求总线控制权 / CPU 应答交权 |
最后两组里藏着两个之后会反复用到的机制。READY 是给慢速外设留的台阶:设备处理不过来时把 READY 拉低,CPU 就暂停、干等它准备好,避免数据出错。HOLD/HLDA 是一对总线"借/还"信号,它让外设能绕过 CPU 直接访问内存——这就是 101 末尾提过的 DMA,到 6.x 讲 I/O 方式时会展开。
到这里,CPU 这个盒子里"有什么"算是看清了:一条数据通路(ALU + 一堆寄存器 + 内总线)、一个还没拆开的控制器、几只伸向外部的引脚。下一节,我们把控制器拆开。
5.2 谁在发号施令:控制器
现在来还第一笔账。
101 那条 ADD 指令,从 PC → MAR 一路走到 ACC + X → ACC,每一步都是"某个寄存器的内容被送到另一个寄存器"或"ALU 被触发"。当时我们说,这些动作"由控制器按正确顺序发出控制信号"完成。可控制器凭什么知道顺序?信号长什么样?它自己又是怎么造出来的? 这一节全答。
控制器是整个系统的指挥中心,它的活儿归纳起来是三样:
- 指令解码:CPU 取出一条指令后,控制器把它译开,搞清楚要做什么操作、碰哪些操作数。
- 生成控制信号:基于译码结果,发出一串信号,驱动 ALU、寄存器、存储器按预期动作。
- 时序控制:让这些信号在正确的先后顺序、正确的节拍上发出,谁先谁后绝不能乱。
换个特别糙的说法:控制器就是 CPU 的"大脑"。输入是一条机器指令;输出是几乎每个时钟"嘀嗒"都要发出的一批微命令——"让寄存器 A 把数据送出去""让 ALU 做加法"这种最小颗粒的命令。
但这里千万别把“大脑”想得太玄。控制器做的事更像一个排练表:
第 1 拍:PCout, MARin
第 2 拍:MemR, MDRin
第 3 拍:MDRout, IRin
...
每一拍哪些信号亮,哪些信号不亮,CPU 就按这个节奏走。后面所谓硬布线、微程序,本质都在回答同一个问题:这张排练表到底是焊在电路里,还是写在一块小 ROM 里?
按"控制信号是怎么产生的",控制器分硬布线和微程序两种造法(本节最后讲)。先看它由哪些部件组成。
组成:三个配合的部件
从功能倒推,控制器可以拆成三个部件:
- 指令控制器:负责取指、译码,并算出下一条指令的地址。
- 时序控制器:产生各种时序信号,掌控指令执行的节奏,让各部件步调一致。
- 控制信号发生器:根据译码结果,在每个时钟周期产出具体的控制信号。
逐个看。
指令控制器 干的事,正是 101 取指阶段那几步的硬件落地:由 CS:IP 给出当前指令地址(CS 标出代码段开头,IP 标出段内偏移)→ 从内存读出指令、放进 IR → 改 IP 指向下一条。
时序控制器 是给整台机器打拍子的。它解决的不是“做什么”,而是“什么时候做”。
先别急着背指令周期、机器周期、时钟周期。直接从最朴素的问题开始:
执行 MOV R1, [addr](把内存 addr 处的数据搬进 R1)时,CPU 内部至少要做三件事:
addr → MAR ; ① 把地址送进 MAR
M[MAR] → MDR ; ② 存储器按 MAR 取数,放进 MDR
MDR → R1 ; ③ 把 MDR 的数据经内总线送进 R1
这三步绝不能乱:地址还没进 MAR,主存不知道该读哪;数据还没进 MDR,R1 也没东西可收。
所以 CPU 需要一个“节拍器”,把连续的时间切成一格一格:
T1:addr → MAR
T2:M[MAR] → MDR
T3:MDR → R1
这里的 T1/T2/T3 就是节拍,也就是最小的小步。你可以先把它理解成“每个小步开始干一组不会冲突的微操作”。
书上会把时间分成三层,从大到小是:
指令周期⊃CPU 周期(机器周期)⊃时钟周期/节拍这三个词可以这样理解:
| 层级 | 直觉理解 | 例子 |
|:--|:--|:--|
| 指令周期 | 完成一整条指令 | 取出并执行完一条 ADD |
| 机器周期 / CPU 周期 | 一条指令里的一个大阶段 | 取指周期、取数周期、执行周期 |
| 时钟周期 / 节拍 | 大阶段里的一个小动作 | PC → MAR、MDR → IR |
为什么中间还要有“机器周期”这一层?因为很多教材和早期 CPU 会按访存动作来切大阶段。访问一次主存很慢,工程师干脆以“完成一次主存访问需要的时间”为标杆,把取指、取数、写回这种大动作切成一个个机器周期。纯内部的 ALU 运算很快,但为了跟这套节奏配合,也常常被放进某个机器周期里。
这就产生了两种控制策略:
- 定长机器周期:每个大阶段都给一样多的小节拍。简单,但浪费。简单操作做完了也得等。
- 不定长机器周期:不同大阶段给不同数量的小节拍。复杂,但更省时间。比如纯寄存器加法不必硬等一个完整访存周期。
更细一点,教材还会区分节拍电位和节拍脉冲。这个也不用玄学化:
- 节拍电位:持续一小段时间,负责“开路”。比如这一拍让
R1out打开,数据有时间在总线上稳定下来。 - 节拍脉冲:通常出现在末尾的尖峰,负责“写入”。比如数据稳定后,给
Zin一个写入沿,让寄存器把值锁住。
这正好对应 5.1 里 ALU 和 Z 的关系:ALU 可以在一拍中一直算,但寄存器只在卡点那一下记。
时序控制器内部通常由晶振/脉冲源、节拍发生器和机器周期发生器配合完成。晶振给出稳定时钟;节拍发生器数出 T1/T2/T3...;机器周期发生器决定现在处在取指、取数、执行还是中断响应这种大阶段。
控制信号发生器 接过译码结果,在每个时钟周期产出控制信号。信号分两去向:发给 CPU 内部部件的(走内总线),和发给系统其他设备的(走系统控制总线,协调 CPU 与外设)。
控制信号长什么样
控制信号是发生器吐出的电信号,指挥着 CPU 内的每个动作——让 ALU 做加法还是减法、让某个寄存器读还是写。种类很多,但现阶段会考的可以归成三类:
- 内存读写:对内存或 I/O 读写。如
MemR、MemW分别是读、写内存。 - 寄存器选择:选哪个寄存器读写。如
Rin、Rout分别是写入、送出某寄存器。 - ALU 操作码:选 ALU 干哪种运算。如
ALUop = Add让 ALU 做加法。
你已经能感觉到,这些信号大多是成对的:一个 Xout(把 X 的值送上总线)配一个 Yin(让 Y 从总线收下)。一次"把 X 搬到 Y"的传送,本质就是同一拍里 Xout 和 Yin 一起有效。
单总线里有一条铁律:
同一拍里只能有一个
out有效,但可以有多个in有效。只能一个
out,是因为公共总线同一时刻只能被一个部件驱动;可以多个in,是因为同一份数据可以同时被好几个寄存器收下。
所以 PCout, MARin, Yin 这种“一个人说,几个人听”可以;PCout, MDRout 这种“两个人同时往总线上说”不行。
先学会"写下来":寄存器传送语言(RTL)
在把信号铺到具体指令上之前,得先有个记法,把"哪个寄存器的值搬到哪个寄存器"清清楚楚写下来。这套记法叫寄存器传送语言(Register Transfer Language, RTL)。它有好几种写法,考试用的是教学里最常见的伪代码版。
常见符号就这么几个:
| 符号 | 含义 |
|:--|:--|
| R1, R2, … | 通用寄存器 |
| PC | 程序计数器 |
| IR | 指令寄存器 |
| MAR | 内存地址寄存器 |
| MDR | 内存数据寄存器 |
| M[x] | 内存中地址为 x 的单元 |
| ← | 传送:右边送到左边 |
| , | 并行:同一时钟周期内同时发生 |
| if | 条件执行 |
四条语法规则,配合例子记:
赋值用 ←,别跟编程语言的 = 混了。右边是源,左边是目标:
R1 ← R2 + R3 // 把 R2 + R3 的结果写入 R1
(有的教材把源寄存器的读取用括号括起来写成 R2 ← (R1),跟 R2 ← R1 一个意思,看题目约定。)
逗号 = 同一拍并行:
PC ← PC + 4, MAR ← PC // 一个时钟周期内:PC 自增;同时把(自增前的)PC 送进 MAR
多行 = 顺序,每行占一个时钟周期:
MAR ← PC // 第 1 拍
MDR ← M[MAR] // 第 2 拍
IR ← MDR // 第 3 拍
访存固定套路:地址走 MAR,数据走 MDR。
// 读内存:
MAR ← PC
MDR ← M(MAR)
// 写内存:
MAR ← R1, MDR ← R2
M(MAR) ← MDR
把信号落到一条真实指令:ADD R0, (R1)
工具齐了,现在做 101 没做完的事——给那条 ADD 指令配上每一拍的控制信号。我们换一条稍微复杂点的:ADD R0, (R1),意思是"把 R1 指向的内存单元里的数,加到 R0 上"。它会完整跑过取指、译码、执行、访存、写回。
取指与译码阶段
这一阶段做的事跟指令具体是什么无关——任何指令都得先被取出来、译开。
PC 先给出当前指令地址,取出指令后顺手把 PC 加到下一条,最后译码。落到节拍上:
| 时钟 | 功能(RTL) | 控制信号 | 在干嘛 |
|:--|:--|:--|:--|
| C1 | MAR ← PC | PCout, MARin | 把指令地址从 PC 送进 MAR |
| C2 | MDR ← M(MAR) | MemR, MDRin | 存储器按 MAR 取出指令,进 MDR |
| C3 | IR ← MDR | MDRout, IRin | 指令送进 IR,准备译码 |
| C4 | MUXop ← PCIncr | PCIncr | 多路选择器选出常数 1,送到 ALU 一端 |
| C5 | T2 ← PC + 1 | PCout, T2in, Add | ALU 算出下一条指令地址 |
| C6 | PC ← T2 | T2out, PCin | 把新地址写回 PC |
| C7 | 指令译码 | (无) | 由指令译码器件完成 |
对着图把 C1–C3 走一遍,你会发现它跟 101 里 PC → MAR、主存 → MDR、MDR → IR 一模一样——只是现在每一步右边都钉上了具体信号(PCout + MARin 这种成对的"送出/收下")。C4–C6 是把"PC 自增"也用 ALU 实打实算了一遍(借 T2 暂存器中转)。
执行、访存与写回阶段
译码知道了这是 ADD R0, (R1),接下来才是这条指令特有的动作。注意 C7 在这里复用——译码完就接着执行。
| 时钟 | 功能(RTL) | 控制信号 | 在干嘛 |
|:--|:--|:--|:--|
| C7 | MAR ← R1 | R1out, MARin | R1 里是操作数地址,送进 MAR |
| C8 | MDR ← M(MAR) | MemR, MDRin | 取出那个操作数,进 MDR |
| C9 | T1 ← R0 | R0out, T1in | 把 R0(加数)暂存到 T1 |
| C10 | T2 ← MDR + T1 | MDRout, MUXop, Add, T2in | ALU 把两个操作数相加,结果进 T2 |
| C11 | R0 ← T2 | T2out, R0in | 把和写回 R0 |
看 C9:为什么 R0 要先绕去 T1?正是 5.1 里那个单总线的毛病——ALU 要两个输入,但总线一次只能送一个,所以先把 R0 存进暂存器 T1,下一拍再让 MDR 上总线,两者在 ALU 会合。一个"硬件受限 → 多一个暂存器 → 多一个节拍"的设计后果,在这里看得清清楚楚。
到这儿,101 欠的第一笔账还清了:所谓"控制器发控制信号",落到实处就是这样一张表——每个时钟周期,发哪几个成对的 out/in 信号,外加一个 ALU 操作码。 控制器的全部工作,就是为每条指令生成并按拍发出这张表。
这张表是怎么造出来的:两种控制器
最后一个问题:上面那张"C1 发这些、C2 发那些"的信号表,控制器是怎么实现的?两种思路,正好是硬件派和软件派。
硬布线控制器:把表焊死在电路里
硬布线控制器(Hardwired Controller) 的做法简单粗暴:用逻辑门、触发器、译码器这些硬件,直接把控制逻辑用导线连出来。它本质是一个有限状态机(FSM),控制逻辑靠电路的实际布线固定下来。
它的逻辑可以概括成三段:
- 输入端:指令信息(经译码器转换)、时序信息(时钟脉冲产生的节拍)、来自执行部件的状态反馈(比如运算的标志位)。
- 核心:一大片预先设计好、连线固定的组合逻辑门网络(与、或、非门)。
- 输出端:当前输入的某种组合,经过这片固定网络,直接生成一串微操作控制信号。
说白了它就是个复杂的"查表器",只不过这张"表"是用硬件电路焊死的——特定的输入组合一出现,对应的输出信号立刻产生。图里那个 Cn=∑(Im⋅Mi⋅Tk⋅Bj) 表达的就是这个意思:第 n 个控制信号,是"哪条指令、哪个机器周期、哪个节拍、哪些状态"这些条件的逻辑组合(与/或)的结果。
这里很容易误会:T1/T2/T3 不是指令译码器“翻译”出来的。更准确地说,CPU 里有三类角色:
- 时序发生器像鼓手,只负责盲目打拍子:
T1, T2, T3... - 指令译码器像导演,只负责看
IR:现在这条是ADD还是LOAD - 硬布线控制网络像舞者,听到“现在是
ADD,又正好是T2”,就让对应控制线亮起来
所以某个控制信号常常长得像这样:
R1out=ADD⋅MEX⋅T2意思是:只有当前指令是 ADD、当前处在执行机器周期、当前又走到第 2 个节拍时,R1out 才有效。
指令很多时,状态机会分出很多路吗?
这里有个很自然的问题:如果 CPU 有 20 条机器指令,取指结束之后,状态机是不是要从某个状态分出 20 条路?
不会。状态机不会按“每条指令一条路”来修路,而是按指令类别来合流。
比如经典的多周期数据通路里,取指结束后常见的分流大概是这样:
| 指令类别 | 代表指令 | 后面大概要走什么路 |
|:--|:--|:--|
| R 型算术逻辑类 | add, sub, and, or | 直接进执行周期,用 ALU 算寄存器之间的结果 |
| 立即数运算类 | addi | 直接进执行周期,用寄存器和立即数算结果 |
| Load/Store 访存类 | lw, sw | 先算有效地址,再读/写内存 |
| Branch 分支类 | beq, bne | 比较寄存器,必要时改 PC |
| Jump 跳转类 | j, jal | 直接改 PC 或保存返回地址 |
也就是说,20 条指令最后可能只对应 4、5 条主干道。因为从“时间安排”的角度看,很多指令在某些阶段做的是同一类事情,可以共用同一段状态。
比如 lw 和 sw 是不同指令,一个读内存,一个写内存,但它们前面都有一个共同动作:先用 ALU 算有效地址。所以状态机不会给它俩各修一条路,而是让它们先共用“地址计算”这段路。等真正读/写内存时,再由译码信号决定是 MemR 还是 MemW。
那如果 add 和 sub 走的是同一段执行状态,CPU 怎么区分到底该加还是该减?答案还是那句话:状态机只负责粗粒度卡时间,控制矩阵负责细粒度抠动作。
同样走到执行周期第 1 拍,输出控制线可以长这样:
ALUadd=ADD⋅MEX⋅T1 ALUsub=SUB⋅MEX⋅T1add 和 sub 可以共享同一个时间状态,但因为 IR 译码出来的指令线不同,最后被点亮的 ALU 控制线也不同。
所以硬布线控制器的分工是:
- 状态机(路):只按指令大类安排时间,所以路很少。
- 与或门矩阵(开关):用“指令线 × 时间线 × 状态反馈”精确生成具体控制信号。
状态转换表:把圆圈图翻译成电路剧本
如果教材继续往下画,通常会把“状态圆圈跳转图”翻译成一张状态转换与输出表。这张表就是给电路看的剧本。
它一般有四类信息:
| 表格列 | 含义 |
|:--|:--|
| 现态 | 当前状态寄存器里存的是哪个状态,比如 S3 = 0011 |
| 输入条件 | 指令译码信号、状态反馈、中断请求等 |
| 次态 | 下一个时钟沿到来后要跳到哪个状态 |
| 输出 | 当前状态下要拉高哪些 M、T 或控制线 |
关键看分叉状态。假设取指结束停在 S3,那表里可能写成这样:
| 现态 | 输入条件 | 次态 | 输出 |
|:--|:--|:--|:--|
| S3 | lw / sw / beq | S4 | M_IF = 1, T4 = 1 |
| S3 | add / addi | S6 | M_IF = 1, T4 = 1 |
这就解释了“机器周期是不是在 IR 译码后分配”的问题:是的,至少在这种多周期硬布线模型里,取指完成后,IR 译码信号会参与决定状态机下一步去哪。 复杂访存类指令继续走地址计算状态,简单算术类指令直接跳过那段,进入执行状态。
还有一个常见细节:如果表格里“输出”只取决于现态,不取决于外部输入,那它就是 Moore 型状态机。比如只要现态是 S0,输出就固定是 M_IF = 1, T1 = 1;只要现态是 S3,输出就固定是 M_IF = 1, T4 = 1。输入信号只影响“下一步去哪”,不直接影响“当前输出什么”。
时序发生器真正怎么做成电路
把状态转换表落到硬件上,时序发生器通常就是三个东西首尾相接:
┌────────────┐
现态 ──► │ 次态逻辑 │ ──► 次态
└────────────┘ │
▲ ▼ 时钟沿
│ ┌────────────┐
└──────────│ 状态寄存器 │
└────────────┘
│
▼
┌────────────┐
│ 输出函数 │ ──► M、T、控制信号
└────────────┘
三块分别干这三件事:
- 状态寄存器:记住“现在走到哪一步”。它由触发器组成,比如里面存着
0011,就代表当前是S3。每来一个时钟沿,它就把门口的“次态”吞进去,更新成新的现态。 - 次态逻辑 / FSM:计算“下一步去哪”。它是纯组合逻辑,输入包括现态、IR 译码信号、状态反馈、中断请求等,输出是下一状态编码。
- 输出函数:根据当前现态产生
M_IF、T1、T2这类时序信号,或者进一步参与生成各条控制线。
所以 CPU 的“时间”不是一个抽象概念,它真的可以做成这样一圈硬件闭环:
晶振脉冲
→ 状态寄存器更新
→ 次态逻辑算下一步
→ 输出函数点亮时序线
→ 控制信号驱动数据通路
指令执行完时,状态机还会通过清零/重置逻辑回到取指初态,比如从最后一个执行状态跳回 S0。这根“回到 S0”的线,本质上就是在告诉控制器:这一条指令结束,下一条指令重新开始取指。
它的脾气也就定了:
- 硬件实现:逻辑就在导线和焊点上。
- 功能固定:想改控制逻辑?基本得重新布线、换芯片。
- 快:信号是组合逻辑直接生成的,没有任何"查指令、跑软件"的额外开销,延迟只取决于门电路的传播时间。
- 设计复杂:逻辑一复杂,这片门网络就难设计、难验证。
微程序控制器:把表当成"程序"存起来
微程序控制器(Microprogrammed Controller) 是另一条路。它不把逻辑焊进电路,而是把控制逻辑当成程序,存进一块专门的存储器——控制存储器(Control Memory, CM)。
核心思想是"用程序控制程序的执行":CPU 的每条机器指令,都被拆成一串更基本的微操作;这串微操作写成一段微程序,存在控制存储器里。要执行某条指令,就去把它对应的那段微程序"跑"一遍。
为什么要这么绕?因为硬布线有一个很现实的问题:指令一多,逻辑门会爆炸。
如果 CPU 只有几十条规整指令,硬布线很好:快、直接、干净。但如果 CPU 有几百上千条形态各异的复杂指令,有的 2 步,有的 10 步,有的还要根据状态拐弯,那么把所有控制逻辑都焊成门电路,设计和验证都会变得极其痛苦。
微程序的思路就是:惹不起复杂电路,那就把复杂性挪到一块小 ROM 里。ADD 对应一段微代码,LOAD 对应另一段微代码。想加一条复杂指令,不一定要重画整张门电路图,往控制存储器里加一段微程序就行。
这也顺便解释了后来两条路线的分歧:
- CISC(复杂指令集)倾向于用微程序消化复杂指令,例如传统 x86 里大量复杂指令会被拆成内部微操作。
- RISC(精简指令集)反过来砍掉复杂指令,让指令规整、步数接近,这样硬布线控制器又能重新发挥“快”的优势。
对着图认部件:
- 指令寄存器
IR:存着取来的机器指令,里头有操作码(OP)。 - 起始和转移地址形成部件:这是大脑。它根据 OP 算出该指令对应微程序的起始地址;又根据当前微指令的"下地址"和外部标志,算出下一条微指令地址。
- 微程序计数器
μPC:存着上面算出来的微指令地址——它就是控制存储器里的"PC"。 - 控制存储器 CM:按
μPC给的地址,读出对应的微指令。 - 微指令寄存器
μIR:存着当前正在执行的那条微指令。一条微指令分两段:微命令字段(直接控制 CPU 各部件——这就是控制器的最终输出)和下地址字段(指明下一条微指令在哪,送回地址形成部件)。
工作流程就是绕着这张图转圈:译码(OP 译出微程序起始地址)→ 取微指令(μPC 寻址 CM)→ 执行(μIR 的微命令字段发出控制信号)→ 循环(用下地址字段算出下一条微指令地址,回到 μPC),直到这条机器指令的微操作全跑完。
微指令里的微命令字段怎么编码
微指令里那个"微命令字段",要表示"这一拍激活哪些微命令"。怎么把它编码,是个在灵活性、速度、微指令长度之间权衡的事,有三种经典方案。
直接编码(水平编码):字段里每一位直接对应一个微命令,1 激活、0 不激活,不需要译码器,输出直接驱动控制线。
举个例子,假设有这些微命令,字段排成这样:
| 位 | D7 | D6 | D5 | D4 | D3 | D2 | D1 | D0 | |:--|:--|:--|:--|:--|:--|:--|:--|:--| | 含义 | RA_load | RB_load | ALU_ADD | ALU_SUB | BUS_A | BUS_B | … | … |
如果这个字段是 1 1 1 0 1 0 0 0,就表示同一拍里同时:给 RA 装载、给 RB 装载、ALU 做加法、选总线 A。特点:并行性高(一拍能激活多个不冲突的微命令)、最快(不经译码);但字长长(要控制的微命令越多,位数线性膨胀,控制存储器吃不消)、设计复杂(每一位都要精确照顾)。
字段直接编码(垂直编码):把微命令字段切成若干互斥的组。每组内部是一批互斥的微命令(同一拍只能选一个),组与组之间是相容的(可同时激活)。每组编成一个值,经译码器才变成具体的微命令。
还是上面那些微命令,这次分组:
| 字段 | F1(寄存器操作,2 位) | F2(ALU 操作,2 位) | F3(总线选择,1 位) | |:--|:--|:--|:--| | 00 | 无操作 | 无操作 | 无操作 | | 01 | RA_load | ALU_ADD | BUS_A | | 10 | RB_load | ALU_SUB | — |
字段 01 | 01 | 1 表示:F1 译出 RA_load、F2 译出 ALU_ADD、F3 选 BUS_A。这里 RA_load 和 RB_load 互斥(在同一字段),ALU_ADD 和 ALU_SUB 互斥,但 RA_load 和 ALU_ADD 不同字段可以并行。特点:字长短(字段内编码共享位)、设计简单;代价是并行受限(同字段内不能并行)、稍慢(多了译码器这一层延迟)。
混合编码:前两者的结合。一部分字段用直接编码(管那些频繁同时发生、对速度敏感、互不冲突的关键微命令,保住并行度和速度);另一部分用字段编码(管互斥的、并行要求不高的,压缩长度)。在并行度和控制存储器容量之间取个平衡。
微程序 vs 硬布线
跟硬布线对着看,微程序的脾气正好相反:
| | 硬布线 | 微程序 | |:--|:--|:--| | 控制逻辑放哪 | 焊在门电路里 | 存在控制存储器里 | | 改逻辑 | 要重新布线/换芯片 | 改存储的微指令即可 | | 加新指令 | 难 | 相对容易 | | 速度 | 快(组合逻辑直出) | 慢(要多访问 CM、还要算下地址) | | 设计 | 复杂逻辑难设计验证 | 复杂逻辑反而更好组织 |
一句话:硬布线拿速度换灵活,微程序拿灵活换速度。
指令、微指令、微命令:三层
最后把三个词彻底分清,它们是一条自顶向下的控制层次:
- 指令:程序员的代码经编译、汇编后,CPU 能直接识别执行的最小功能单位(如
ADD R0, (R1))。 - 微指令:控制存储器里的一个"字"。一条机器指令被拆成一串步骤,每步对应一条(或多条)微指令。
- 微命令:最基本、不可再分、由硬件电路直接完成的操作(如
PCout、MARin)。
关系是:一条指令 = 一段微程序 ⊃ 多条微指令 ⊃ 多个微命令。拿前面那条 ADD R0, (R1) 当例子,它就是一段微程序:
| 时钟 | 功能 | 对应微指令 | 包含的微命令 |
|:--|:--|:--|:--|
| C1 | MAR ← PC | 微指令 A | PCout, MARin |
| C2 | MDR ← M(MAR) | 微指令 B | MemR, MDRin |
| C3 | MUXop ← PCIncr | 微指令 C | PCIncr |
| C4 | T2 ← PC + 1 | 微指令 D | PCout, Add, T2in |
| C5 | PC ← T2 | 微指令 E | T2out, PCin |
| C6 | 指令译码 | 微指令 F | (硬件译码逻辑) |
| C7 | MAR ← R1 | 微指令 G | R1out, MARin |
| C8 | MDR ← M(MAR) | 微指令 H | MemR, MDRin |
| C9 | T1 ← R0 | 微指令 I | R0out, T1in |
| C10 | T2 ← MDR + T1 | 微指令 J | MDRout, Add, T2in |
整段是微程序,每一行是一条微指令,每行右边那几个就是这条微指令包含的微命令。控制器逐拍执行这段微程序,101 那条指令就被它一步步"演奏"出来了。
5.3 当意外发生:异常与中断
到目前为止,我们的循环有一个隐含假设:一切顺利。取指、译码、执行,一条接一条,岁月静好。
可现实不是这样。指令执行到一半,可能除数突然是 0;要访问的内存页根本不在物理内存里;程序想往只读区域写东西。与此同时,外面的世界也不安分——键盘被敲了、网卡收到包了、定时器到点了,这些事随时会发生,CPU 总不能装作没看见。
所以 CPU 需要一种能力:在执行流的任意一点,暂停手头的活,跳去处理突发情况,处理完再回来(如果还能回来的话)。 这就是异常与中断机制。两个词经常被混着用,但它们的来源不同——这正是考试爱抠的点。
异常:来自指令内部的意外
异常(Exception) 指程序执行过程中冒出的非正常情况。一旦发生,CPU 暂停当前执行,转去运行操作系统准备好的异常处理程序。它的特征是来自 CPU 内部、由当前正在执行的指令引起——所以也叫"内中断"。
执行指令时,常见的异常有五类。每类配一段最小的 C 代码,你一看就知道是什么场景:
① 除法错误——除以零或非法除法。OS 通常捕获后中断程序、报错或终止。
int a = 10;
int b = 0;
int c = a / b; // 除以零,触发除法错误
② 浮点异常——浮点运算出错:溢出、下溢、除零、非法操作(如对负数开平方)。OS 可能中断程序、置异常标志,或产生 NaN。
float y = sqrt(-1.0); // 非法操作,结果为 NaN
③ 缺页异常——要访问的内存页还没调进物理内存。OS 把所需页从磁盘调入、更新页表,然后恢复程序继续执行(这一类通常是可恢复的)。
char* ptr = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE, fd, 0);
char c = ptr[0]; // 访问 ptr[0] 时触发缺页,内核调页后程序继续
④ 保护错误——非法操作:写只读内存、访问内核空间、执行特权指令。OS 中断程序,挡住非法访问(你熟悉的"段错误 Segmentation Fault"就在这类)。
int* ptr = (int*)0xFFFF0000; // 指向只读或内核地址
*ptr = 42; // 写操作触发保护错误
⑤ 硬件错误——内存故障、总线错误、电源故障等硬件层面的问题。可能记录错误、尝试修复,或者直接宕机。
char* bad_addr = (char*)0xDEADBEEF;
char c = *bad_addr; // 假设该地址引发硬件错误
自陷:程序主动"投案"
上面五类大多是"被动撞上"的。但有时程序是主动想让操作系统介入的——这种主动转入异常处理的机制叫自陷(trap)。它跟外部中断不同:由当前执行的指令或条件触发,是程序自己发起的。
自陷最典型的三个用途:
- 异常处理:除零、非法访问、无效指令等错误发生时,进入异常处理流程。
- 系统调用:用户程序想请求操作系统服务(开文件、分配内存),用一条专门的指令(如 x86 的
syscall)触发自陷,借此从用户态进内核态,执行系统调用处理程序。 - 调试断点:调试时设的断点,程序跑到断点就触发自陷,停下来交给调试器。
自陷的处理流程(简化版)是这样一条链:
① CPU 检测到自陷条件(异常 / 系统调用 / 断点)
② 暂停当前程序,保存现场(PC、寄存器等)
③ 切换到内核态,转入操作系统的自陷处理程序
④ OS 按具体情况处理异常或完成服务
⑤ 可恢复 → 恢复现场继续执行;否则 → 终止程序
那条用来显式触发自陷的指令,就叫陷阱指令——它是用户程序跟操作系统打交道的重要入口。
中断:来自外部世界的打断
中断(Interrupt) 是 CPU 正常执行时,由外部设备或软件指令触发的事件,迫使 CPU 暂停当前执行、转去处理。它让 CPU 能及时响应键盘、定时器、网络这些外部事件——是现代操作系统和硬件协同的核心机制之一。
怎么给中断分类
中断是个大概念,有两种切法。
按来源切:
- 外部中断:外部设备/事件触发,如输入设备、时钟、外部信号。
- 内部中断(也就是上面说的异常):程序或 CPU 内部状态触发,如错误、异常。
按能不能屏蔽切:
- 可屏蔽中断(Maskable):可以通过设置中断屏蔽位(还记得 5.1 标志寄存器里的
IF吗?)暂时禁止。一般外设中断都属于此类——键盘、鼠标、网卡。 - 不可屏蔽中断(NMI):屏蔽不了的紧急中断,专门留给系统级灾难——电源故障、内存校验错误。
中断和异常到底怎么区分?(这是选择题的常客)
在很多分类法里,异常被当成中断的一种(内中断)。但有些教材把两者明确分开:
- 异常:CPU 执行指令时内部冒出的意外(同步——同一个输入跑一遍,异常必然在同一条指令上出现)。
- 中断:来自 CPU 外部、与当前指令无关的事件(异步——什么时候来,跟你执行到哪条指令没关系)。
记住这条"内部/同步 vs 外部/异步"的分界,碰到题目灵活分辨即可。
中断真正被响应后的处理流程(保存现场 → 找到中断服务程序 → 执行 → 恢复现场返回),和上面自陷那条链是同一套思路,在组成原理的中断专题里会细讲,这里先建立"它存在、它打断循环、它处理完要能回来"这个认知。
5.4 让循环跑快:指令流水线
第二笔账,到了。
101 的循环跑得有多笨,看一眼就知道:取指部件取指时,执行部件在干等;执行部件忙时,取指部件又闲着。一条指令必须完完整整跑完,下一条才敢开始。这是这一章的重头戏要解决的问题。
先把一条指令拆成五段
要并行,先得有能并行的"零件"。我们把一条指令的执行,从逻辑上切成五个阶段:
- 取指(IF, Instruction Fetch):按 PC 从指令存储器取出指令,并更新 PC。
- 译码(ID, Instruction Decode):解析指令、读出源寄存器、生成后续阶段要用的控制信号。
- 执行(EX, Execute):用 ALU 做计算或算分支,产出中间结果。
- 访存(MEM, Memory):读写数据存储器;不访存的指令这一段空着。
- 写回(WB, Write Back):把结果写回寄存器;不写回的指令这一段空着。
用 ADD R1, R2, R3 串一遍:IF 取出它 → ID 读出 R2、R3 → EX 算 R2 + R3 → MEM 这条用不上(空)→ WB 把和写进 R1。再看 BEQ R1, R2, label:EX 阶段比较 R1 和 R2 决定跳不跳。
MEM 和 WB 一定会出现吗?
在标准五段流水线里,所有指令都会按顺序走过这五段(结构上都在),但 MEM 和 WB 对某些指令是空操作:算术/逻辑/分支指令不访存,MEM 空;存储、分支、跳转不写寄存器,WB 空。而 IF、ID、EX 是每条指令都得实打实做的。
单周期 vs 多周期:为什么要拆
拆之前,先看不拆会怎样。
单周期处理器:每条指令在一个时钟周期里从头干到尾。听着干脆,但有个致命问题——这个时钟周期必须容得下最慢的那条指令。于是一条简单的加法,也得陪着最慢的访存指令一起,把周期拉得老长。简单,但低效。
多周期处理器:把指令拆成上面那五段,每段花一个时钟周期,不同段交给不同部件。先看不拆时的样子——指令一条接一条,整条整条地排队:
下一条必须等上一条整个结束才能开始,部件大把时间在闲置。多周期流水线就是来治这个的:既然五段用的是不同部件,那 I1 走到 EX 时,IF 部件已经空出来了,干嘛不让 I2 进 IF?让不同指令的不同阶段重叠起来跑——这就是流水线。
物理结构:五段住在哪些部件里
五个阶段分别由 CPU 里不同的部件处理,下一阶段吃上一阶段的输出。因为是不同部件,它们可以同时工作——这样部件利用率上去了,CPU 执行指令的吞吐也就上去了。
逻辑结构:靠流水段寄存器锁住中间结果
物理图里器件太多。把它抽象一下,就是流水线的逻辑结构:
关键在每两段之间那个流水段寄存器:它把本段处理完的所有数据锁存住,保证这一拍的结果能稳稳地在下一拍交给下一段。所有寄存器和存储器都用统一时钟 CLK 同步——每来一个时钟,各段算完的数据齐刷刷锁进段尾的流水段寄存器,成为下一段的输入;同时本段也收下前一段递过来的数据。还记得 5.1 说的吗?流水段寄存器正是"时序逻辑元件",负责把值"停住"。
执行时序图:把重叠画出来
流水线的本质,是把并行的粒度从"整条指令"降到了"指令的某个阶段"。这种细粒度的重叠,能大幅压缩总时间:
画流水线常用两种图,最好都会看。
常规画法:横轴时钟周期,纵轴不同指令。最直观——一眼能看出每条指令在每一拍处于哪个阶段,以及指令之间怎么错开。
时空图:更抽象一点,横轴时间,纵轴是阶段/资源。它强调的是"每一拍各个流水段分别被哪条指令占着",看重叠和资源占用更清楚。
理想很美好:流水线冒险
上面这些图都是理想情况。可一旦指令之间有了纠葛,理想就破了。
流水线要正确工作,得满足两个前提:
- 指令重叠跑时不抢同一个硬件资源(同一拍里各段别用同一条数据通路)。
- 流水线跑出来的结果,必须和老老实实串行执行的结果一模一样。
违背这两条前提的调度,就叫冒险(Hazard)。冒险分三类:
- 结构冒险:硬件资源不够,撞车了。
- 数据冒险:一条指令要用前面指令还没算出来的结果。
- 控制冒险:分支/跳转改了 PC,导致"下一条到底取哪条"一时说不清。
"冒险"和"冲突"是一回事吗?
几乎可以互换,但有微妙差别:冒险(Hazard) 是更宽的概念——它指"有出错的可能",但不一定真出错;冲突(Conflict) 则指"已经出错了"。冒险是隐患,冲突是事故。
结构冒险:硬件不够用
结构冒险来自 CPU 硬件资源有限。两条指令在同一拍要用同一个硬件,就撞上了。
图里画出了各指令每一拍要用的硬件:指令 0 和指令 1 在第 4 拍分别想写和读寄存器,指令 0 和指令 3 在第 3 拍分别想写和读存储器。如果硬件不支持"同时读写",就发生了结构冒险。处理办法两个,都很直白:
- 资源重复:既然是资源不够,那就加硬件。比如把指令存储器和数据存储器分开(这样取指和访存就不抢同一个存储器了),寄存器堆支持同拍读写。
- 流水线停顿:如果指令 A 和 B 撞了,就让 B 等一等,推迟到不撞为止。
数据冒险:要用的数还没算好
数据冒险来自指令之间的依赖。一条指令要用另一条的结果,可如果它们挤进流水线太近,后面那条可能在数据还没准备好时就去用了。按"读/写顺序被打乱的方式",数据冒险分三种:
- 写后读(RAW, Read After Write):后一条要读的,正是前一条要写的。本该等前一条写完再读,结果它抢先读了——读到旧值。
- 读后写(WAR, Write After Read):后一条要写的,正是前一条要读的。本该等前一条读完再写,结果它抢先写了——把前一条要读的值冲掉了。
- 写后写(WAW, Write After Write):两条都写同一个地方。本该后写的更晚,结果顺序反了,最终留下的是错的那个值。
中文名容易绕。直接记英文:前一条叫 A、后一条叫 B,"A 写、B 读、出了冒险"就是 Read After Write(RAW),其余照此类推。核心始终是那条前提——流水线的结果必须和串行一致;只要某个数据的读写顺序跟串行不一样,就出了数据冒险。
处理数据冒险,三招:流水线停顿、数据前推、编译器重排序。前两招细看。
第一招:流水线停顿。 检测到冒险,就暂停后面的指令,往流水线里塞气泡(bubble,空操作),让它干等,直到依赖的数据就绪。具体到画法:如果 A、B 冲突(A 在前),就把 B 的译码(ID)推迟到 A 的写回(WB)之后——A 都写回了,B 再去读,自然读到新值。停顿是个"万金油"方案,结构冒险也能用它兜底。
第二招:数据前推(旁路转发,Data Forwarding)。 停顿太亏了——数据其实在 A 的 EX 段末尾就算出来了,何必非等它走完 WB 写回寄存器、B 再从寄存器读?直接从流水段寄存器里把这个中间结果抄给 B 的 ALU 输入端不就行了。看个 RAW 的例子:
I1: ADD R1, R2, R3 ; R1 = R2 + R3
I2: SUB R4, R1, R5 ; R4 = R1 - R5 ← 要用 I1 刚算出的 R1
I1 在 EX 段末尾就得到了新的 R1,存进了 EX/MEM 流水段寄存器。那就直接从这个寄存器把值送回 ALU 输入端,I2 执行时用的就是新 R1,根本不用等 I1 走完 WB:
能建的旁路通路主要三条:EX→EX(前一条 EX 产出的 ALU 结果直接转给下一条用,如 add → add)、M→EX(前一条在 MEM 段才出结果、当前指令 EX 要用)、WB→EX(依赖的是更早之前那条指令的写回结果,只能从 WB 段取)。
但数据前推不是万能的,Load-Use 冒险就治不了。 它是 RAW 的一个特例,专出现在 load 指令后面紧跟一条用它结果的指令:
I1: load r1, 0(r2) ; 从内存加载到 r1
I2: add r3, r1, r4 ; 立刻就要用 r1
问题在于:load 要到 MEM 段才能从内存把数据捞出来,可 I2 在 EX 段就要用它——而此刻 I1 的 MEM 还没跑完,数据压根还不存在,你拿什么前推?
办法也朴素:先停一拍(插一个气泡),等 load 走完 MEM,再用 M→WB 的转发线把值送过去。停这一拍是省不掉的,前推只是把"停三拍"压到了"停一拍":
一个完整的冒险处理实例
把上面这些缝起来。考试里画"解决了冒险的流水线",有一招简单粗暴但稳妥的通法。假设某条赋值语句被汇编成四条指令:
I1 LOAD R1, [a]
I2 LOAD R2, [b]
I3 ADD R1, R2
I4 STORE R1, [x]
先把依赖捋清楚:
I3和I1之间是 WAW(都写R1)I3和I2之间是 RAW(I3 要读 I2 写的R2)I4和I3之间是 WAR(I4 读 I3 写的R1……实为先后读写顺序约束)
通法就一句话:A、B 冲突且 A 在前,就把 B 的 ID 放到 A 的 WB 之后——用停顿把有冲突的两条在时间上彻底错开。按这个规则排下来:
考试画流水线,用这种"停顿错开"的画法最不容易错。
控制冒险:下一条到底取哪个
控制冒险来自分支和跳转。麻烦在于:CPU 得等分支指令执行完,才知道下一条该取哪里——可流水线早就抢跑、把后面的指令取进来了。看例子:
100: ADD R1, R2, R3 ; R1 = R2 + R3
104: BEQ R1, #0, 200 ; 若 R1 == 0,跳到 200
108: SUB R4, R5, R6 ; ← 流水线会抢先把它取进来
112: MUL R7, R8, R9
...
200: OR R10, R11, R12 ; 但如果真跳了,该执行的是这条
BEQ 还没在 EX 段算出"到底跳不跳",流水线已经把地址 108 的 SUB 取进来了。万一 BEQ 成立、该跳到 200,那 108 这条就白取了——这就是控制冒险。
处理办法三种:
- 流水线停顿:分支指令之后先别取,停下来插气泡,等分支结果出来再说。简单但浪费。
- 分支预测(Branch Prediction):猜——猜它跳还是不跳,提前按猜的结果取指。猜对了,零开销;猜错了,把错取的指令清空,重新取。现代 CPU 几乎都靠它,且预测准确率很高。
- 延迟分支(Delayed Branch):编译器把分支后面那些不依赖分支结果的指令挪到分支指令之后先执行,填上等待的空档,减少浪费。
流水线快了多少:两个指标
流水线到底带来多大提升?两个公式说清楚。设时钟周期为 Tc、流水线段数为 k、任务(指令)数为 n。
吞吐率(Throughput)——单位时间完成的任务数。理想无阻塞下,一条 k 段流水线完成 n 个任务需要 k+n−1 个时钟周期(第一条指令灌满流水线要 k 拍,之后每拍出一个结果,再出 n−1 个)。于是:
TP=(k+n−1)Tcn加速比(Speedup)——流水线相对串行快了几倍。串行做 n 个任务要 n⋅k⋅Tc,流水线只要 (k+n−1)⋅Tc:
S=T流水线T串行=k+n−1n⋅k当任务数 n 很大时,分母里的 k−1 可以忽略,于是:
S≈k这是流水线最漂亮的一句话:当指令足够多,最大加速比趋近于流水线的段数。 五段流水线,理想下能快近 5 倍——这就是我们费这么大劲拆五段、加流水段寄存器、还要对付三种冒险的全部回报。
还想更快:高级流水线
把单条流水线榨干之后,还想提升,就得在指令级并行(ILP) 上做文章。两条大思路:多发射(一拍同时发射多条指令)和超流水(把级切得更细)。对应三种技术:
- 超标量(Superscalar):配多套执行单元(多个 ALU、FPU),一个时钟周期里并行发射多条指令。硬件动态分析指令间有没有依赖,没依赖就分到不同执行单元同时跑。
- 超流水线(Superpipeline):把每个阶段再切成更小的阶段(5 段细分成 10 段甚至更多),每段更短,于是可以跑更高的时钟频率。
- 超长指令字(VLIW, Very Long Instruction Word):把多条能并行的子操作,由编译器在编译期就打包进一条"超长指令"的多个槽位里:
| ALU_op | MUL_op | LOAD_op | BRANCH_op |
处理器一拍把这些互不相干的操作一起执行。它的妙处是把"找并行"这件难事甩给了编译器——编译器提前分析好数据相关、控制相关、资源冲突,硬件就不用再搞乱序执行、寄存器重命名、动态相关性检测那一套,大大简化、还省功耗。
三者并排着看,思路差异一目了然:
| 技术 | 怎么提升 ILP | 并行发生在哪 | 控制复杂度 | |:--|:--|:--|:--| | 超标量 | 并行多发射 | 同一拍多条指令 | 极高 | | 超流水线 | 拆细流水级 | 不同拍高度重叠 | 中 | | 超长指令字 | 编译期打包并行 | 一条指令内并行 | 极低 |
一句话记住本质区别:超标量——硬件很聪明;超流水线——时钟切得很细;VLIW——编译器很聪明。
5.5 一个核不够用:多处理器
流水线、超标量,这些都是在一条指令流内部榨并行。可这条路总有头。再往上要更多算力,就得换层思路:多条指令流、多条数据流一起上。
弗林分类法:按"几条流"切
弗林分类法(Flynn's Taxonomy) 是 1966 年 Michael Flynn 提出的,按计算机里指令流和数据流的数量,切成四类:
| | 单一指令流 | 多指令流 | |:--|:--|:--| | 单一数据流 | SISD | MISD | | 多数据流 | SIMD | MIMD |
先说清两个"流":指令流是程序里一连串有序的指令,决定"计算机要做什么";数据流是执行时处理的数据序列,是"计算机要处理什么"。
- SISD(单指令单数据):一个处理单元(PU)收一条指令流,每条指令处理一份数据。就是最经典的单核——101 那台机器、前面整章讲的流水线 CPU,都是 SISD。
- SIMD(单指令多数据):多个处理单元在同一时刻执行相同的指令,但各自处理不同的数据,实现数据级并行。举个例子,
N × N矩阵加法:SISD 要一个 PU 连续算N × N次;SIMD 若有N个 PU,只需N次。 - MISD(多指令单数据):多个 PU 对同一份数据执行不同指令。现实里极罕见、难实现,主要见于容错系统——几个不同的 PU 算同一份数据,比对结果以确保没出错。
- MIMD(多指令多数据):多个 PU 各自执行不同指令、处理不同数据。现代多核处理器就是 MIMD 的典型——每个核跑自己的线程。
SIMD 和 SIMT 别搞混。 SIMD 就是"堆硬件":多加几个 PU 同时处理多份数据,但有个硬约束——同一时刻所有 PU 必须执行同一条指令。而 SIMT(Single Instruction Multiple Thread)是 GPU 的路子:单条指令同时在多个线程上跑,但允许一个 warp 里的线程在一定程度上偏离同一执行路径——不同线程同一时刻不必执行同一条指令。这点灵活性,正是 GPU 能跑复杂分支的原因。
多核:把多个核塞进一块芯片
MIMD 落到今天的桌面/手机芯片上,就是多核。这里要分清三个词。
- 物理核心:CPU 芯片上真实存在的、独立的硬件处理单元。每个物理核都有自己的运算电路和缓存,能独立执行指令。它是实打实的硬件。
- 逻辑核心:通过超线程等技术,在一个物理核上虚拟出的多个处理单元。它是操作系统看到的处理单元,并非真实硬件。
超线程(Hyper-Threading) 的核心想法是:让一个物理核心模拟成多个逻辑核心,从而同时跑多个线程。每个逻辑核有自己的一套寄存器,但它们共享同一个物理核的执行单元、缓存等资源。
注意一个常被误解的点:超线程不等于性能翻倍。因为两个逻辑核共享同一套物理执行资源,提升通常只在 20% 到 30% 之间——它榨的是物理核在某个线程卡住(比如等内存)时闲下来的那部分资源。一句话打比方:物理核心是真盖出来的"房子",逻辑核心是在房子里隔出来的"房间"。 房间多了点,但地基还是那块地基。
共享内存多处理机:多个处理器,一块内存
多个处理器凑一起干活,最自然的协作方式是共享同一块物理内存——这就是共享内存多处理机(Shared Memory Multiprocessor)。处理器之间靠读写共享内存来通信和交换数据。
它有两个关键特点:
- 共享内存空间:所有处理器访问同一个物理内存空间,数据共享简单高效;处理器之间通过读写共享内存里的数据来通信和同步。
- 处理器互连:处理器通过互连网络(总线、交叉开关等)连到共享内存。这张互连网络的性能,直接决定整个系统的上限——处理器再多,如果都堵在通往内存的那条路上,也快不起来。这也呼应了 101 的老结论:内存(以及通往内存的路)始终是绕不开的瓶颈。
小结
这一章我们把 CPU 这个盒子打开了,还清了 101 欠下的两笔账。把每一节"遇到的问题 → 给出的设计"折叠成一张表,能 30 秒复述:
| 遇到的问题 | 给出的设计 |
|:--|:--|
| "运算器 + 控制器"漏了内总线 | 改用数据通路(执行)+ 控制器(控制)切分 |
| 零件怎么分类 | 组合逻辑元件(算,无状态)/ 时序逻辑元件(记,有时钟) |
| 抽象结构太悬空 | 用 8086 坐实:五类寄存器 + 引脚 |
| ALU 要两个输入但单总线只能送一个 | 加一个 T 暂存器,多一个节拍 |
| "控制器发信号"到底是什么 | 每拍发一批成对的 out/in 信号(RTL + C1–C11 表) |
| 这张信号表怎么实现 | 硬布线(焊死,快但死)/ 微程序(存起来,活但慢) |
| 执行中途出意外 | 异常(内部/同步)/ 自陷(主动投案) |
| 外部世界要被理会 | 中断(外部/异步,可屏蔽 / NMI) |
| 循环一条接一条太慢 | 拆 五阶段,让不同指令的阶段重叠(流水线) |
| 重叠带来纠葛 | 三类冒险:结构(加资源/停顿)、数据(前推/停顿)、控制(预测/停顿) |
| 前推救不了 load 紧跟使用 | Load-Use 必停一拍,再 M→WB 转发 |
| 单条指令流榨干了 | 多发射 / 超流水:超标量、超流水线、VLIW |
| 一个核不够 | 弗林分类(SISD/SIMD/MISD/MIMD)、多核 / 超线程、共享内存多处理机 |
回头看,CPU 不是凭空设计出来的一块神秘芯片。它就是把 101 那个"取指 → 译码 → 执行"的朴素循环,一层层落到硬件上、再一点点想办法跑快的产物:要执行就得有数据通路,要协调就得有控制器,嫌慢就上流水线,流水线打架就处理冒险,单核到顶了就上多核。每一步,都是对上一步暴露出的某个具体麻烦的回应——这正是这门课最值得带走的思维方式。
