写业务代码写到第四五年,我撞上一个挺尴尬的瓶颈:代码写得越多,对底下那层东西反而越模糊。一个i++编译完长什么样,函数调用时栈里到底发生了什么,为什么换个循环顺序性能差三倍——这些问题的答案我都是从博客和视频里零散拼来的,彼此对不上。真正让我把这一块串起来的,是啃了一周 Y86-64。Y86-64 是一套为了教学而设计的精简指令集,只有十几种指令、八个寄存器,但它把“处理器如何在每个时钟周期里搬运数据、判断条件、更新状态”这件事完整地摊在你面前。学它不会让你立刻写出更快的代码,但会让你从此看汇编、看编译器优化报告、看性能分析器的火焰图时,脑子里有一张真实的电路图在对位。这篇笔记适合两类人:一类是正在啃这章教材、被 HCL 和数据冒险绕晕的同学;另一类是有几年工程经验、想补上底层认知但不想一上来就啃真实处理器手册的从业者。下面我按自己当时的学习顺序,把指令集、HCL、SEQ 实现、流水线改造和踩过的坑完整复盘一遍。
1. 为什么值得专门花时间啃 Y86-64
1.1 从“会写代码”到“知道代码怎么跑”的认知断层
大部分人学处理器实现都会经历一个误区:一上来就去看真实产品的架构手册。那些手册里光是特性列表就有几十页,各种可选项、扩展指令、缓存一致性协议铺开来,根本看不出哪部分是核心骨架。Y86-64 的价值恰恰在于它的“故意残缺”——去掉浮点、去掉复杂寻址模式、去掉条件传送以外的所有分支优化,只保留指令执行的主干链路。这就像学开车先练手动挡的离合器配合,把多余的东西全部剥离,留下的每个部件你都能看得见、摸得着。
我当时的做法是先不碰流水线,把顺序实现(教材里叫 SEQ)在纸上手工跑一遍。随便挑一段包含call、ret、条件跳转和pushq的代码,拿张纸画六个阶段的框,一条指令一条指令地填 valA、valB、valE、valM、dstE、dstM 这些中间值。跑到第三条指令的时候你就会开始理解为什么pushq要在执行阶段把%rsp减 8,而不是在写回阶段减。这个手工过程很枯燥,但它带来的理解和看别人画的框图完全是两回事。
1.2 Y86-64 的取舍:一个刻意被做残的指令集
我整理了一下 Y86-64 的完整指令集合,一共就这些:
| 指令 | 编码(icode) | 长度 | 用途 |
|---|---|---|---|
| halt | 0 | 1 字节 | 停止执行 |
| nop | 1 | 1 字节 | 空操作 |
| rrmovq | 2 | 2 字节 | 寄存器到寄存器 |
| irmovq | 3 | 10 字节 | 立即数到寄存器 |
| rmmovq | 4 | 10 字节 | 寄存器到内存 |
| mrmovq | 5 | 10 字节 | 内存到寄存器 |
| OPq(add/sub/and/xor) | 6 | 2 字节 | 算术逻辑运算 |
| jXX(jmp/jle/jl/je/jne/jge/jg) | 7 | 9 字节 | 条件跳转 |
| call | 8 | 9 字节 | 调用 |
| ret | 9 | 1 字节 | 返回 |
| pushq | A | 2 字节 | 压栈 |
| popq | B | 2 字节 | 弹栈 |
这份表里有两个细节值得盯着看。第一,irmovq、rmmovq、mrmovq、jXX、call都是 10 字节或 9 字节,因为它们后面跟着一个 8 字节的常数字。指令长度不固定这件事,在流水线阶段会变成一个实实在在的麻烦——取指阶段必须先解码第一个字节,才知道要往后读几个字节,valP的计算必须依赖icode。很多人第一次搭流水线时,取指阶段的valP算错,根子就在这里。
第二,注意没有的东西:没有浮点运算,没有乘除法,没有变址寻址,没有leave,甚至没有not。这是刻意为之。每砍掉一样东西,处理器实现里对应的那部分复杂逻辑就消失了,你能更专注地看清楚主干。缺的那部分不代表不重要,而是说明这章的目标不是教你造一颗能用的芯片,而是教你建立“指令到数据通路的映射”这个思维模型。
1.3 学习路径的递进设计
我的建议路径是这样的:先搞懂指令集编码和程序员可见状态,把.ys汇编文件手工翻译成机器码,这一步是为了后面调试时能对照十六进制。接着读 HCL 的语法,写几个小组合逻辑找找感觉。再然后是最重要的两步:先在纸上完成 SEQ 的六阶段走查,再进入流水线。
从 SEQ 到流水线的跨越不是“加几级流水线寄存器”这么简单。SEQ 里所有环节都在同一个时钟周期内串行完成,你不需要考虑“第 N 条指令的结果和第 N+1 条指令的操作数冲突”。一旦拆成五级,相邻指令的重叠执行会带来三类新问题:数据冒险、控制冒险、以及异常和存储器访问顺序的微妙边界。我当时的正确做法是:不要急着看教材给的最终答案,先自己把流水线寄存器逐个插进去,然后跑一段简单代码,看看哪些地方出现了错误的中间值。错一次,你就永远记得转发是干什么用的了。
还有一点经验:这章的内容不适合一次读完。它的逻辑是层层递进的,SEQ 没吃透就去看流水线控制逻辑,会觉得那些F_stall、D_bubble的表达式像是从天上掉下来的。我建议每个阶段之间至少隔一天,中间做点别的,让脑子里的模型沉淀一下。
2. 先摸清 Y86-64 的骨架:指令集与程序员可见状态
2.1 可见状态:八个寄存器、三个条件码、一个 PC
Y86-64 的程序员可见状态非常干净。寄存器文件里八个通用寄存器,编码从 0 到 7,依次是%rax、%rcx、%rdx、%rbx、%rsp、%rbp、%rsi、%rdi。特殊值0xF表示“无寄存器”,这个设计看着不起眼,实际非常关键——后面流水线里判断是否需要转发、是否需要写回,一半的逻辑都在检查dstE和dstM是不是0xF(RNONE)。
条件码有三个:ZF零标志、SF符号标志、OF溢出标志。它们由算术逻辑运算指令(OPq)更新,被条件跳转(jXX)和条件传送(rrmovq带条件)读取。这里有个设计上的取舍值得注意:OPq一定会更新条件码,不能选择不更新;而其他指令一律不影响条件码。真实架构里通常有专门的标志位控制,Y86-64 为了简化直接砍掉了。这个简化带来的后果是:你写代码时不能像真实汇编那样把addq夹在cmp和jl之间,否则条件码会被覆盖。教材里的练习经常在这里埋坑。
程序计数器PC指向下一条要执行的指令。内存是一个大的字节数组,按小端序存放。执行状态Stat有四种:AOK正常、HLT遇到 halt、ADR地址错误、INS非法指令。这四个状态在 SEQ 里是组合逻辑算出来的,到了流水线里会变成一路往后传的信号,最后在写回阶段被统一裁决。
2.2 指令编码:1 到 10 字节的变长结构
Y86-64 的指令编码规则比较整齐,但也有例外。第一个字节永远是icode:ifun的形式,高四位是指令代码,低四位是功能码。第二个字节通常是寄存器指示符,格式是rA:rB,高四位一个寄存器,低四位另一个。有些指令没有寄存器操作数,这个字节就干脆省略。
举几个具体的例子,这样你看机器码的时候不会懵:
halt编码是00,一个字节结束nop是10rrmovq %rsi, %rdx编码是20 62,2是 rrmovq,0是无条件,6是%rsi,2是%rdxirmovq $0x1234, %rax编码是30 F 0后面接 8 字节小端的常数字,总共 10 字节mrmovq 8(%rbp), %rax编码是50 50加 8 字节偏移量,rA是%rbp,rB是%raxaddq %rcx, %rbx编码是60 13,功能码0表示加法(减法 1、与 2、异或 3)
注意irmovq里的rA位置填了F,因为它只需要一个目标寄存器。这个细节在写 HCL 的译码逻辑时必须对得上,srcA和srcB的取值规则是:只有rrmovq、rmmovq、OPq需要读rA,只有rrmovq、mrmovq、rmmovq、OPq、pushq需要读rB,call、jXX、irmovq则一个都不需要。
2.3 被故意去掉的东西意味着什么
我一直觉得,搞清楚一套教学指令集“缺了什么”,比搞清楚它“有什么”更有信息量。Y86-64 没有乘除法,意味着 ALU 只需要做加减与异或这四件事,控制信号简单到可以用一个四选一的多路选择器搞定。没有浮点,意味着寄存器文件宽度固定 64 位,不需要单独的浮点寄存器堆。没有变址寻址,意味着地址计算永远是“基址寄存器值加常数字”这一种形式,ALU 的输入端接哪根线是硬编码的。
这些删减换来的代价是,你从中获得的经验不能直接迁移到真实架构的性能调优上。真实处理器有乱序执行、有多发射、有预取器、有复杂的缓存层级,Y86-64 一样都没有。但我想强调一点:这些东西全部是在“五级流水线”这个基础上的增量优化。理解了流水线为什么会被数据冒险卡住,你才能理解为什么乱序执行是有价值的;理解了分支预测失败的代价,你才能理解为什么现代处理器愿意花那么多晶体管做预测器。跳过基础直接学高级特性,学到的只是一堆名词。
3. HCL:用几行布尔表达式描述一块电路
3.1 为什么不是写 Verilog
HCL(硬件控制语言)是这章绕不开的工具。它的定位很明确:不追求可综合性,只追求“把组合逻辑讲清楚”。你不需要关心门级延迟、不需要关心时钟树、不需要写测试平台,只需要描述“输入是什么,输出是什么,中间的逻辑关系是什么”。这种抽象层级对学习者极其友好。
HCL 只有三种基本构件。第一是布尔表达式,支持&&、||、!,但注意它不支持短路求值——因为它是描述电路的,两根线同时通电,不存在“先算左边再算右边”。第二是字级表达式,用[s : A, B, C]这种多路选择器语法,按选择信号s的值挑选一个输入。第三是集合成员判断,用icode in {IRRMOVQ, IOPQ}这种写法,编译时会被展开成一系列相等比较的或运算。
它和 Verilog 最大的差别在于:HCL 里没有“过程”的概念,没有顺序执行,没有赋值语句。你写的每一行都是并行存在的组合逻辑。刚上手时最不适应的就是这一点——脑子里总想着“先干什么再干什么”,但电路里没有先后,只有电平同时到达。我踩过的一个坑是试图在 HCL 里“临时保存一个值供后面用”,结果发现根本写不出来,因为那需要一个寄存器,而寄存器属于时序逻辑,得单独声明。
3.2 组合逻辑的写法要点与常见陷阱
写 HCL 有几个实用的习惯,我总结下来是三条。第一条,多路选择器一定要写全默认分支。HCL 要求所有可能的选择信号取值都有对应输出,否则会报错。这个约束看着麻烦,实际上是在帮你避免锁存器——真实电路里如果某个输入组合没有指定输出,综合工具会推断出一个锁存器,那是个经典 bug 来源。教材里几乎每个多路选择器的最后一行都是1 : 某个默认值,那个1就是“其余所有情况”。
第二条,善用icode in {...}的集合写法而不是一长串||。前者可读性好得多,而且编译器会帮你做优化。比如译码阶段的srcA选择逻辑,用集合写出来一眼就能看出哪些指令读rA:
word srcA = [ icode in {IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ} : rA; icode in {IPOPQ, IRET} : RRSP; 1 : RNONE; ];第三条,也是最容易出错的一条:HCL 里的表达式顺序不影响求值,但多路选择器的优先级顺序影响结果。上面这段代码里,如果某条指令同时满足前两行的条件,比如某条指令既在第一个集合里,又恰好icode等于IRET(当然这里不可能),那么排在前面的那行会胜出。所以写多路选择器时,把最特殊、最具体的条件写在前面,把最宽泛的默认分支放在最后。这个习惯在后面写转发逻辑时至关重要,因为转发来源有优先级,写错顺序会导致数据被错误地转发。
4. SEQ:一个时钟周期跑完一条指令
4.1 六个阶段的拆解与数据流
SEQ 的设计哲学是“一条指令占满一个时钟周期”,六个阶段串行完成。这个设计在性能上很糟糕,但在理解上极其清晰。我把它整理成了一张表,每次手工走查时对着看:
| 阶段 | 主要工作 | 关键输出 |
|---|---|---|
| 取指 | 按 PC 读指令字节,解析 icode/ifun | valP(下条指令地址)、valC(常数字) |
| 译码 | 从寄存器文件读出操作数 | valA、valB、dstE、dstM、srcA、srcB |
| 执行 | ALU 计算或地址计算,条件判断 | valE、Cnd |
| 访存 | 读或写内存 | valM |
| 写回 | 更新寄存器文件 | 无(副作用) |
| 更新 PC | 决定下一条指令地址 | 新的 PC |
这里有几个容易看漏的地方。执行阶段的 ALU 输入不是固定的:OPq用valA和valB,irmovq用valC,rmmovq和mrmovq用valC加valB,call和pushq用-8,ret和popq用8。ALU 的第二个输入也类似,OPq用valB,其他情况用0。这一套线怎么接,就是 HCL 里aluA和aluB两个多路选择器要解决的问题。
另一个容易漏的是条件判断Cnd。它只在jXX和条件传送时被用到,逻辑是这样的:jmp无条件为真;jle要求ZF || (SF ^ OF);jl要求SF ^ OF;je要求ZF;jne要求!ZF;jge要求!(SF ^ OF);jg要求!(SF ^ OF) && !ZF。注意这里的SF ^ OF就是“小于”的判断条件,因为溢出会翻转符号位,用异或刚好抵消。这个式子我当初背了半天,后来想通了——它就是补码比较的本质。
4.2 时序:为什么写回和 PC 更新必须在同一个边沿
SEQ 是单周期实现,所有状态更新都发生在时钟上升沿的同一个瞬间。这意味着执行阶段算出来的valE、访存阶段读出的valM、以及新的 PC 值,全部在同一个边沿被写入。寄存器文件是边沿触发器,所以在边沿之前,读端口读到的是旧值;边沿之后,写入的新值才生效。这个规则保证了“同一条指令内先读后写”不会自己覆盖自己。
我当初画这个时序图的时候纠结了很久一个细节:OPq指令在读寄存器文件和写寄存器文件之间,会不会出现自己读自己刚写的值的竞争?答案是不会,因为读发生在组合逻辑通路上,写发生在时钟边沿。只要时钟周期足够长,让组合逻辑稳定下来,读到的就是稳定的旧值。这也是为什么单周期处理器的时钟周期必须大于最慢那条指令的组合逻辑延迟——mrmovq要经过取指、译码、地址计算、访存、写回五段路径,它的延迟决定了整个芯片能跑多快。
4.3 SEQ 的性能天花板:算一下就明白为什么要改
假设组合逻辑延迟是 300 皮秒,寄存器开销(建立时间加传播延迟)是 20 皮秒,那么 SEQ 的时钟周期就是 320 皮秒,吞吐量大约是 3.125 GIPS。这个数字看着还行,但问题在于:大部分指令根本走不满 300 皮秒的完整路径,nop可能只需要几十皮秒就跑完了,却也要占满一整个周期。资源利用率极低。
更严重的是,这个设计里没有任何重叠执行。第 N 条指令在执行阶段的 ALU 在忙,而访存单元完全空闲;第 N+1 条指令明明已经可以开始取指了,却只能干等。这就是流水线要解决的核心矛盾。把这个 320 皮秒拆成五段,每段平均 60 皮秒,加上 20 皮秒的流水线寄存器开销,时钟周期变成 80 皮秒,理想情况下吞吐量提升到 12.5 GIPS,加速比接近 4 倍。这个数算完,你就明白为什么所有现代处理器都要做流水线了。
不过理想很丰满,实际加速比远达不到 4 倍。原因有三:阶段延迟不均匀,最慢的一段决定周期;流水线寄存器本身有开销,级数越深这个开销占比越大;数据冒险和控制冒险会引入停顿,吃掉一部分性能。这三条我在后面会逐个展开。
5. 流水线:把一条指令拆成五份同时干
5.1 流水线寄存器怎么插
从 SEQ 改造成流水线,第一个动作是插入四组流水线寄存器:F 到 D 之间、D 到 E 之间、E 到 M 之间、M 到 W 之间。每组寄存器要把“这条指令后续还需要的东西”全部保存下来。我当初的困惑是:到底哪些信号要保存?
判断标准很简单:看它在后面的阶段还有没有被用到。比如icode和ifun要一路带到写回阶段,因为异常处理和dstE的判断都依赖它们。valC在执行阶段就要用,所以只需要保存到 E 阶段。valP在取指阶段算出来,但call和jXX在执行阶段要用它,所以要保存到 E 阶段。dstE和dstM要带到写回阶段,因为那时候才真正写寄存器。
这个设计里有个常见的误区:以为流水线寄存器只是“加延时”。实际上不是,它是在切断组合逻辑路径的同时承载指令的上下文。每切断一刀,你就必须在切口的两侧各声明一套寄存器,把跨越切口的所有信号都搬过去。漏掉任何一个信号,都会在后面的阶段读到一个错误或未定义的值,这种 bug 极其难查——因为电路本身的时序是对的,只是数据丢了。
5.2 数据冒险与转发的优先级规则
数据冒险的本质是:后面指令要用的操作数,还在前面指令的流水线寄存器里躺着,没写回寄存器文件。最直接的解法是“暂停等它写完”,但那样性能损失太大。转发(也叫旁路)的思路是:既然值已经算出来了,我直接从流水线的中间把值接过来用,不走寄存器文件。
转发要解决的核心问题是“从哪接”。在 E 阶段需要读操作数valA和valB时,可能的值来自四个地方:D 阶段寄存器文件读出的原始值、E 阶段 ALU 刚算出的e_valE、M 阶段的M_valE(ALU 结果)、M 阶段的m_valM(内存读出的值)、还有 W 阶段的W_valE和W_valM。这些来源存在优先级,因为越新的值越正确。我的实现里按这个顺序排:
| 优先级 | 来源 | 说明 |
|---|---|---|
| 1 | e_valE(E 阶段 ALU 输出) | 最新,紧邻的上一条指令 |
| 2 | m_valM(M 阶段内存读出) | 上上条指令的 load 结果 |
| 3 | M_valE(M 阶段 ALU 输出) | 上上条指令的运算结果 |
| 4 | W_valM/W_valE | 更早指令,实际可省略 |
| 5 | 寄存器文件原始读值 | 兜底 |
这里有个非常隐蔽的坑:e_valE这条转发路径,在条件传送(rrmovq带条件)不成立的时候,必须被屏蔽掉。原因是条件不成立时,这条指令不应该写寄存器,那它的结果当然也不能被转发给后面的指令。处理办法是:在 E 阶段判断条件不成立时,把e_dstE强制改成RNONE。这样后面指令检查d_srcA == e_dstE时自然不匹配,转发路径自动断开。我当初没想到这一层,结果一段带条件传送的测试代码跑出了完全错误的结果,查了两个小时才定位到。
另一个细节是 W 阶段的转发其实可以省掉。因为 W 阶段和 D 阶段在同一个周期里,寄存器文件的写端口在时钟上升沿才生效,组合逻辑读端口读到的是旧值。所以在同周期内,D 阶段读不到 W 阶段刚写的值,必须靠转发补上。但从时序上看,从 W 阶段转发过来的路径太长,容易成为关键路径。实际实现里通常把寄存器文件设计成“内部转发”——写端口的数据同时在内部回送到读端口,等价于 W 到 D 的转发。
5.3 load/use 冒险与暂停机制
转发解决不了所有问题,最典型的是 load/use 场景:紧挨着的两条指令,前一条从内存读值(mrmovq或popq),后一条立刻用它。问题在于内存读出的值要到 M 阶段末尾才可用,而后面那条指令在 E 阶段就要用这个值,时间上根本来不及。
这个场景下转发是无效的,只能暂停。具体做法是:把 D 阶段按住不动(不更新 D 阶段的流水线寄存器),同时在 E 阶段插入一个气泡(把E_icode设成nop)。这样做的效果是,后面那条指令在原地等一个周期,等 load 的值从内存出来,通过 M 到 E 的转发路径拿到手,然后继续。
用代码表达这个条件是这样的:
bool E_icode in {IMRMOVQ, IPOPQ} && E_dstM in {d_srcA, d_srcB}意思是:如果 E 阶段正在执行一条读内存的指令,并且它的目标寄存器正好是 D 阶段要读的源寄存器,那就必须暂停。这个表达式我建议你手写一遍,别直接抄。因为写的过程中你会被迫想清楚“为什么是 E 阶段,不是 M 阶段”,想清楚了就再也不会忘。
5.4 控制冒险与分支预测的两条路线
控制冒险比数据冒险更棘手,因为它涉及到“我取错了指令怎么办”。jXX指令的目标地址要到执行阶段才能算出来,但取指阶段必须每个周期都有活干。所以处理器必须猜——猜跳不跳,然后按猜的结果继续取指。
这里有个设计选择值得拿来对比。一种策略是“总是预测跳转”(教材里主线采用的做法),另一种是“总是预测不跳转”。前者对循环结构友好,因为循环体末尾的跳转绝大多数情况下是跳回去的;后者对if-else里的少数分支友好。教材主线用的是预测跳转,代价是需要在预测失败时取消已经进入流水线的两条指令(F 和 D 阶段的两条),往 F 和 D 插入气泡,让它们变成nop。
预测失败的处理逻辑长这样:
bool E_icode == IJXX && !e_Cnd // 跳转条件不成立,预测失败这个条件成立时,D 阶段和 E 阶段都要插入气泡。注意 M 阶段不用动,因为预测失败时 M 阶段里的指令是更早的指令,跟这次误预测无关。我当初的错误是把 W 阶段也一起清了,结果前面已经执行完的指令全白干了,调试时看着一堆莫名其妙的错误状态,非常崩溃。
ret指令是控制冒险的特殊情况。它的返回地址存在栈顶,要在访存阶段才能读出来,取指阶段完全不知道下一条该去哪。输教材里最初的处理方式是暂停三个周期,等ret走到 M 阶段,valM出来了再继续取指。后来优化成:在 D 阶段遇到ret就插入气泡,同时在 F 阶段暂停,等ret到 M 阶段拿到地址后放行。我的实现里用了后者,条件是IRET in {D_icode, E_icode, M_icode},这个联合判断看着别扭,但它是必须的——因为不同位置的ret需要不同的处理时机。
5.5 流水线控制逻辑的优先级顺序
整个流水线的控制逻辑最后要合成四个信号:F_stall、D_stall、D_bubble、E_bubble。这四个信号之间有优先级,写错顺序会出现“该停的没停”或“该走的被卡住”的问题。
我的经验优先级是:先把所有会导致暂停的条件列出来,再把会导致气泡的条件列出来,最后处理两个信号冲突时的裁决。特别注意load/use的暂停和ret的处理可能同时成立,这时候要保证不会重复插入气泡,也不能漏掉。教材里的最终表达式把ret的判断写成了!(load/use 条件) && IRET in {...}的形式,那个取反就是为了避免和暂停逻辑打架。
还有一个很容易忽略的问题:气泡(bubble)的两个信号D_bubble和E_bubble到底要不要一起置位?答案是看情况。预测失败时,D 和 E 都要插入气泡,因为要取消两条指令;而load/use引起的暂停只需要在 E 阶段插气泡,D 阶段是按住不动而不是清空。把这两种情况混淆,是流水线逻辑最常见的 bug 来源。
6. 常见问题与排查实录
6.1 问题速查表
我把自己和周围人踩过的坑整理成了一张表,遇到问题时从上往下对一遍,效率比盲目看波形高得多:
| 症状 | 最可能原因 | 排查方向 |
|---|---|---|
| 程序跑飞,PC 跳到奇怪的地址 | valP计算错误或jXX条件判断写反 | 检查取指阶段valP的默认值,检查Cnd的异或逻辑 |
| 条件传送后结果不对 | e_dstE没在条件不成立时置为RNONE | 检查 E 阶段dstE的选择逻辑 |
栈操作后%rsp差 8 | pushq/popq的 ALU 输入接了valB而不是常数字 | 检查aluA的多路选择器 |
| 紧邻的 load 之后读到旧值 | 没有识别 load/use 冒险 | 检查 E 阶段的 load 判断和 D 阶段暂停条件 |
| 分支后执行了不该执行的指令 | 预测失败时没插气泡 | 检查E_icode == IJXX && !e_Cnd条件 |
ret之后跳到错误地址 | 取指阶段没有及时暂停 | 检查IRET在 D/E/M 三阶段的联合判断 |
| 内存读写结果与预期相反 | 同周期读写顺序不对 | 确认先读后写,读端口在前 |
| 异常状态没上报 | Stat传播时被清零 | 检查 W 阶段的异常优先级裁决 |
关于最后一条,需要多说两句。Stat信号在流水线里是逐级往后传的,如果后面的阶段出现了异常,它应该覆盖前面阶段的异常状态,因为后面的指令更晚执行,它的错误更重要。所以最终以 W 阶段的Stat为准。但如果前面某条指令已经产生了HLT,后面不该再执行任何指令,这时候所有后续指令都要被取消。这个边界在很多实现里处理得不干净,表现就是 halt 之后程序还在跑。
6.2 几条独家避坑心得
第一条心得:先跑通最简单的,再跑复杂的。我一开始就写了包含调用、递归、栈操作的测试程序,结果一出错根本分不清是哪一层的问题。后来改成用irmovq加addq这种两三条指令的片段做冒烟测试,通过后再逐步加跳转、加栈、加递归。这个顺序看着笨,但它把调试空间压缩到了单点。
第二条心得:一定要写一组能自检的测试程序。我习惯在代码末尾用halt停下,然后检查特定寄存器的值是不是期望值。如果有模拟器,可以直接在命令行里看寄存器快照。没有的话,就手工推演,虽然慢,但推演的过程本身就加深了理解。
第三条心得:HCL 里的每个多路选择器都写一个“不可能走到”的默认分支,然后在调试时故意把默认分支设成一个显眼的错误值(比如全 1)。如果程序跑出了全 1,你就知道有某个信号的取值超出了你的枚举范围,这比默默返回 0 要好查得多。
第四条心得:OPq指令和条件码的关系要时刻记着。我写测试代码时曾经在subq和jle之间插了一条andq,结果条件码被覆盖,跳转永远不成立。这个错误在高级语言里根本不可能犯,但在手写汇编时太容易了。
7. 把理解变成可验证的东西
看懂和能跑起来,中间隔着一条很宽的沟。我的建议是至少做一件事:写一个自己的 Y86-64 模拟器。不需要写得多快,用 Python 写个几百行的顺序模拟器就够,关键是这个过程会把所有模糊地带逼出来。
我自己写的时候,最大的收获是在实现pushq和popq的时候。纸面上看,pushq就是把%rsp减 8 然后把值写进去,但实际写代码时你会遇到一个顺序问题:是先改%rsp还是先写内存?如果先改%rsp,那写内存时用的就是新地址;如果先写内存,用的是旧地址。正确的做法是先把旧值写入%rsp - 8,再更新%rsp。这个顺序在纸上画图和写代码的感觉完全不一样,写一遍代码就牢牢记住了。
还有一个值得较真的地方:条件码的更新时机。OPq指令执行完立刻更新ZF、SF、OF,但如果你在流水线里做,条件码的更新要等到写回阶段。这意味着后面的指令如果依赖条件码做判断,就要处理流水线中的数据冒险。Y86-64 的教学版本里,条件码的处理被简化了——它假设条件码在每个周期末更新,并且读取的是最新的值,这个简化掩盖了真实架构里的部分复杂性。但你在写模拟器时,如果严格按流水线时序实现,就会遇到“jXX读到的条件码是上上条指令的”这种问题。这时候你会发现,教材里那个简化为什么是必要的——因为真实处理器的条件码转发也有一套专门的通路。
写模拟器的另一个好处是,你可以随便做实验。比如把转发路径关掉,看看程序还能不能跑对(答案是不能,但能跑对一部分,那些正好没有数据冒险的部分);比如把分支预测策略从“预测跳转”改成“预测不跳转”,跑同一个程序看周期数变化。这些实验在真实硬件上做不了,在模拟器里就是改两行代码的事。做完这些实验,你对“为什么这样设计”的理解会从被动接受变成主动认同。
最后分享一个我一直在用的小方法:每学完一个处理器特性,就回去重读一遍那段代码对应的汇编。比如学完转发机制后,回头看一个简单的a = b + c; d = a + e;编译结果,你会立刻看出第二条指令的valA是从哪里转发来的,那种“看穿了”的感觉会持续很久。这个习惯让我在后来的工作中受益很大——读性能分析报告时,我能从一行行汇编里读出流水线的行为,而不再是看一堆抽象的数字。
如果后面还要继续深挖,下一个值得花时间的点是缓存与流水线的交互。Y86-64 里内存访问是一步到位的,没有缓存层次,但真实处理器里一次访存可能命中 L1、可能命中 L2、也可能要等几百个周期。把这个不确定性引入流水线,你就进入了乱序执行的领域,那是另一段完全不同的旅程了。