Lec 7 用接口实现时序电路模块
复杂硬件被看作是一些模块的集合,它们的输入/输出线以某种方式连接在一起。这种观点在技术上是正确的,但对设计过程没有太大帮助。“只看连线”的方式太底层、太机械化,不利于从高层抽象出复杂系统的结构,也不利于根据性能和应用需求多次实例化模块,也不利于将 IP 封装成可直接组合构建大型系统的“黑盒”模块。现代硬件开发大量依赖 IP 核(IP blocks,知识产权核)——例如 CPU 核心、加密模块、DMA 控制器等通用或商业模块。此外,这种做法也缺乏对模块化细化(modular refinement)的支持,使得开发者难以在不了解整个设计的前提下对某个模块进行独立改进。
我们需要的是一种抽象:把“一个模块能做什么”(接口)和“它怎么做到的”(实现)彻底分开。本讲就建立这种抽象——用接口(interface)+ 方法(method)来描述时序模块。
Outline
- 引例:FIFO 复用之难
- 模块与接口
- 方法:value / Action / ActionValue 及其硬件信号
- 寄存器模块
Reg#(T)与状态更新语义 - 守卫(guard)与 ready/enable 握手
Maybe类型:表达“可能无效”的结果- FIFO 模块
- 用 FIFO 构建流水线:弹性 vs 非弹性
引例:FIFO 复用之难
通过一个小故事,FIFO 的例子来讲解 IP 核复用的困难点。
一个小故事
作者曾经经营一家芯片设计公司。有一天,他震惊地发现:同一块芯片上竟然有 26 个 FIFO(先进先出队列)模块,而这 26 个 FIFO 居然来自 23 种不同的设计。他感到非常困惑,于是去询问工程师原因。工程师们回答说:“这个 FIFO 不符合我的需求。”
作者继续追问:“但它不是 FIFO 吗?”工程师们回答:“是啊,它是 FIFO,但不适合我。”
这个回答引起了作者的兴趣,他在飞往圣地亚哥的飞机上从网上下载了一个免费的 FIFO 模块来研究。结果第一件让他震惊的事情是:该模块的用户文档有 23 页之多。而这份文档并不是在讲 FIFO 是怎么实现的,而是讲怎么使用这个 FIFO!
这让作者意识到:即使像 FIFO 这么简单的模块,其使用方式也可以非常复杂,使用者需要理解很多细节、信号命名约定(例如
n表示反相信号,低电平表示有效)、时钟和复位信号等。文档中还写道:“如果在 FIFO 已满时尝试 push 操作,会产生 error。” 可到底怎么出错?是忽略?是丢数据?是溢出?文档并不清楚。有些说明看起来合理,比如“FIFO 为空时不能 pop”,但它说的都是一种“建议”而非明确规范。文档还告诉你,“如果你这么做,内部的 V 指针将在下一个时钟沿上增加”。作者质疑:我只是用户,我不应该知道也不想知道 FIFO 的内部是怎么实现的。这完全违反了“黑盒”设计原则:模块使用者应该只关心接口,不该被迫了解内部细节。
本质问题:功能与实现细节耦合了(mix functionality and implementation)。一个好的接口应当:
- 只暴露“做什么”,隐藏“怎么做”;
- 把语义说清楚(满了 push 会怎样?空了 pop 会怎样?)而不是留作“建议”;
- 让误用在结构上不可能发生(而不是靠用户自觉)。
下面我们用 Minispec 的 module / interface / method 机制来达到这个目标。
模块与接口
语法约定:本课实验主语言是 Minispec(.ms),少量用 Bluespec/BSV(.bsv)。下文代码以 Minispec 为主,在关键处用引用块给出 Bluespec 等价写法。两者概念完全一致,只是写法不同。
Minispec 模块的四个组成部分
有限状态机(FSM)的问题是不能干净地组合——把一个 FSM 接进另一个 FSM,很容易意外形成组合环路(穿过逻辑门却不穿过任何寄存器),导致整个系统行为未定义。模块(module) 通过给 FSM 加上最小的结构约束,使其可组合。一个 Minispec 模块由四部分构成:
- 子模块(submodules):寄存器(
Reg#(T))或其它模块——通过连接输入/输出来组合。 - 方法(methods):输入参数 + 当前状态 → 输出(纯组合、不改状态)。
- 规则(rules):当前状态 + 外部输入 → 下一状态 + 驱动子模块的输入。规则每个时钟周期都触发一次。
- 输入(inputs):来自外层(父)模块的外部值。

对照 Lec 6 的时序电路框图:这里的 “methods + rules” 两个盒子正好取代了原来的那块“组合逻辑”,而寄存器仍是状态。
以一个 mod-4 的 2 位计数器为例:
module TwoBitCounter;
Reg#(Bit#(2)) count(0); // 子模块:寄存器,初值 0
method Bit#(2) getCount = count; // 方法:输出当前值
input Bool inc default = False; // 输入:本周期是否 +1(来自父模块)
rule increment; // 规则:每周期计算下一状态
if (inc) count <= count + 1;
endrule
endmoduleBluespec 等价写法:BSV 把“对外能做什么”单独抽成
interface,并把状态变更包装成 Action 方法,寄存器用mkReg实例化:bluespecinterface Counter; method Action inc; // 有副作用、无返回 method Bit#(2) read; // 无副作用、有返回 endinterface module mkCounter(Counter); Reg#(Bit#(2)) count <- mkReg(0); method Action inc; count <= count + 1; endmethod method Bit#(2) read = count; endmodule
严格分层组合(strict hierarchical composition)
本课遵守两条纪律,从结构上杜绝组合环路:
- 模块只与自己的子模块交互(不越级);
- 方法不读
input,只有rule读input。
这带来简洁的语义:尽管电路看起来高度并行,整个系统其实是“由外向内”地行为——规则从最外层模块逐级触发进子模块。
组合多个模块的例子——用两个 TwoBitCounter 拼一个 4 位计数器:
module FourBitCounter;
TwoBitCounter lower;
TwoBitCounter upper;
method Bit#(4) getCount = {upper.getCount, lower.getCount};
input Bool inc default = False;
rule increment;
lower.inc = inc;
upper.inc = inc && (lower.getCount == 3); // 低位回绕时高位才进位
endrule
endmodule注意:父模块的 rule 负责驱动子模块的 input(lower.inc = ...),并能读子模块的方法输出(lower.getCount)。这就是模块间通信的全部方式。
方法 vs 规则 vs 输入
初学最易混淆的就是这三者的分工:
| 角色 | 读什么 | 产生什么 | 时序 | 类比 |
|---|---|---|---|---|
| method | 参数 + 当前状态 | 输出(组合) | 立即(组合) | 只读窗口 |
| rule | 当前状态 + input | 下一状态 + 子模块 input | 每周期触发、<= 下周期生效 | 周期性的“动作” |
| input | —(由父模块驱动) | —(被 rule 读取) | —— | 外部接线柱 |
Bluespec 的方法分三类(Minispec 用 method/rule/input 表达同样的事):
方法类型 副作用 返回值 硬件信号 value method 无 有 输入参数 + 输出数据线 Action method 有 无 输入参数 + enable ActionValue method 有 有 输入参数 + enable + 输出数据 + ready Bluespec 的 Action 是原子的:一次 Action 内对各状态的更新要么全发生、要么全不发生,且都基于“本周期开始时”的旧值计算。
寄存器模块 Reg#(T) 与状态更新语义
寄存器是最基本的状态子模块,存放类型为 T 的值:
Reg#(Bit#(2)) count(0); // 实例化,初值 0(注意:是 Reg#(Bit#(2)) 不是 Reg#(2))
... = count; // 读:读出“本周期开始时”的值
count <= count + 1; // 写:排入下一状态,下个时钟沿才生效关键语义(务必牢记):
<=是寄存器写入(下一周期生效),每个寄存器每周期至多写一次,且在周期末统一更新;在同一周期内对同一寄存器先写后读,读到的仍是旧值。=是组合赋值/命名一根连线,立即生效,描述的是组合逻辑而非状态。
寄存器交换的妙处:因为
<=都到周期末才生效,可以不用临时变量直接交换两个寄存器:minispecx <= y; y <= x; // 正确地交换 x、y(都用旧值)这正是 Lec 8 GCD 例子里 swap 步骤的原理。
踩坑:别把
<=当软件里的“立即赋值”——count <= count+1; let z = count;中z拿到的是加 1 之前的旧值。
守卫(guard)与 ready/enable 握手
我们希望模块能自我保护:条件不满足时拒绝操作,而不是产生未定义行为。Bluespec 用守卫(guard)表达:
method Action start(Bit#(32) a, Bit#(32) b) if (!busy);
x <= a; y <= b; busy <= True;
endmethodif (!busy) 是 start 的守卫,语义是:
- 只有当
!busy成立时,该方法才就绪(ready),可被调用; - 守卫在硬件上对应方法的 ready 信号;调用方须在 ready 有效时才置 enable;
- 调用一个未就绪的方法是非法的——在规则化(rule)设计里,会让用到它的 rule 无法触发(被阻塞),而不是产生错误数据。
守卫把“FIFO 满时不能 push、空时不能 pop”这类约定,从“文档里的建议”变成了接口强制的语义,从根本上消除了引例里 FIFO 的误用问题。
测试:testbench 与系统函数
硬件设计中我们常独立测试每个模块,方法是写一个 testbench——它本身也是一个模块,把“被测模块”当作子模块,用一串测试输入检查其输出是否正确:
module FourBitCounterTest;
FourBitCounter counter;
Reg#(Bit#(6)) cycle(0);
rule test;
counter.inc = (cycle[0] == 1); // 只在奇数周期 +1
$display("[cycle %d] getCount=%d", cycle, counter.getCount);
cycle <= cycle + 1;
if (cycle >= 32) $finish; // 跑 32 周期后结束
endrule
endmodule以 $ 开头的是系统函数($display、$finish 等),它们在综合时被忽略,只用于仿真时输出结果、控制流程。
时序逻辑的威力:“时间比空间更灵活”
组合电路只能实现固定位宽 n 的加法器;而有了状态,我们可以用一个全加器 + 一个触发器,把进位输出 cout 反馈回进位输入 cin,每周期加一位,从而对任意长的数相加——位数越长,只是多等几个周期而已。
这就是时序逻辑相对组合逻辑的根本优势:可以把一次大计算摊到多个周期上完成(用时间换空间),输入/输出/步数都可以是变长的。代价是更多周期、更复杂的控制(何时算完?用 Maybe/isDone 表达,见下)。
Maybe 类型:表达“可能无效”的结果
很多模块需要表达“现在还没有有效结果”。Minispec 用参数化的 Maybe#(T) 类型:
- 它要么是
Invalid(无值),要么是Valid(v)(携带一个值 v); isValid(x)判断是否有效,fromMaybe(default, x)在无效时取默认值。
Maybe#(Word) r = Invalid;
r = Valid(42);
if (isValid(r)) ... fromMaybe(0, r) ...为什么需要它? 看一个反例——朴素的 GCD 模块把输入输出散开成一堆独立端口(start、a、b、result、isDone),非常容易误用:可能设置了 a/b 却忘了 start,或者忘了先检查 isDone 就读 result,而且即使 start 为假,每个周期也得把所有输入都驱动一遍。
改进办法:把相关的输入/输出聚合——用一个方法一次性传入全部参数(或一个都不传),用 Maybe 表达“结果有效或无效”。这样接口只剩“启动”和“取(可能无效的)结果”两件事,难以误用。GCD 的完整实现见 Lec 8(用 rule 描述其内部 FSM)。
FIFO 模块
FIFO 是连接两个模块、解耦“生产者/消费者”的核心构件。一个带守卫的 FIFO 接口通常是:
interface Fifo#(type T);
method Action enq(T x); // 入队,守卫:notFull
method Action deq; // 出队(丢弃队首),守卫:notEmpty
method T first; // 看队首,守卫:notEmpty
endinterfaceenq的守卫是“未满”,deq/first的守卫是“非空”。- 因为守卫内建在接口里,“满了还 enq”“空了还 deq”在结构上就不会发生——调用方在守卫不就绪时直接被阻塞,等条件满足再进行。
这把引例里那一堆“满了会怎样”的模糊文档,压缩成了两个清晰的守卫条件。
用 FIFO 构建流水线:弹性 vs 非弹性
关于组合电路流水线的基础(延迟/吞吐量、流水线图、k 阶流水线、阶段划分配方、性能铁律等)见 Lec 15。本节专注于用模块/FIFO 把多个时序模块串成流水线这一更高层的话题。
把流水线各阶段“分隔”开有两种主流做法:

非弹性流水线(inelastic pipeline)
- 用寄存器分隔流水线阶段;
- 所有阶段在同一时钟下同步前进,每个周期整条流水线一起“挪一格”;
- 优点是简单、面积小;缺点是启停困难——一旦某阶段需要暂停(如等待数据),整条流水线都要协调地停下来(需要全局的 stall/bubble 控制)。

弹性流水线(elastic pipeline)
- 用 FIFO 分隔流水线阶段;
- 一个阶段只要其输入 FIFO 非空、且输出 FIFO 不满,就可以独立处理一拍数据,不必与其它阶段步调一致;
- 优点是灵活、对不均匀的数据流(变长延迟)鲁棒,阶段之间天然解耦(latency-insensitive,延迟不敏感);缺点是 FIFO 带来额外面积与延迟。
这种“看输入是否有、输出是否能放”的判断,正是上一节 FIFO 守卫(notEmpty/notFull)的用武之地。
时序模块的流水线
现在假设每个盒子是一个时序模块(而非纯组合电路),盒子里的数字表示它处理一个输入所需的时钟周期数:

阶段 S1:7 周期;S2:8 周期;S3:5 周期。
平均吞吐量由最慢的阶段决定:流水线整体处理输入的速率不会超过最慢阶段(这里是 S2 的 8 周期/输入)。运行一段时间后,快阶段的输出 FIFO 会被后续慢阶段“堵住”而被迫等待。
重叠 FIFO 与分析约定
当我们画一个 FIFO 时,通常并不关心里面到底有几个元素;因此相邻阶段之间若出现“背靠背”的两个 FIFO,分析时可以合并成一个来画。为简化吞吐量分析,我们常假设:所有输入 FIFO 永远非空、所有输出 FIFO 永远不满(即只考虑稳态、不被边界拖慢的理想情形)。
流水线气泡(bubble)
当某个阶段“这一拍没有有效数据可送”时,下游就会出现气泡——流水线图里的空格。
- 例:设
f需要 1~2 个周期(取决于输入),g总是 1 个周期;输入 x0~x3 中 x0、x2 需 1 周期、x1、x3 需 2 周期。

图中的空格就是流水线气泡:当 f 对某个输入多花了一拍,下游 g 这一拍就没活干(气泡)。气泡降低了实际吞吐量。
前馈流水线 vs 带反馈的流水线
我们目前讨论的都是前馈流水线(feed-forward)——没有任何反馈。
- 为什么组合电路不能有反馈?因为会形成无法稳定的组合环路(见 Lec 6)。
- 而时序模块允许反馈:只要在反馈路径上插入 FIFO(或寄存器),就能打破组合环、合法地构成带环流水线,并能借此改变流水线的功能。
分析的困难在于:模块的接口并不表明它内部是否流水线化、是否按顺序处理。 外部接口只描述数据格式与握手,看不到内部实现细节,这给带环网络的流水线行为/性能分析带来困难——必须仔细推敲预期功能。
守卫规则与正确性:一个会“卡死”的例子
考虑 output = h(f(x_i), g(x_{i+1}))——输出依赖两个不同时间点的数据,这违背了同步直觉。用 FIFO + 守卫规则来描述时要特别小心。

设 FIFO B 一开始为空,用一条规则(rule)描述这个电路,会发现它永远无法启动:
规则(直观写法)
B.enq(f(A.first)):B 接收来自 A 队首、经f处理后的数据;C.enq(h(g(A.first), B.first)):C 接收h(g(A 队首), B 队首);A.deq; B.deq;:A、B 出队。
为什么卡死? 这条规则要同时满足所有守卫才能触发,其中 C.enq(...) 用到了 B.first,需要 B 非空;但 B 的数据要靠本规则里的 B.enq 才能产生——初始 B 为空,B.first 不就绪 ⇒ 整条规则的守卫不成立 ⇒ 规则永不触发 ⇒ B 永远填不进数据。先有鸡还是先有蛋。

解决办法:把它拆成多条独立的规则(让 B 的填充和 C 的消费各自独立触发,而不是捆在一条规则里互相依赖)。用 FIFO + 守卫的好处是正确性容易保证(不会读到无效数据、不会满了还写),代价是吞吐量的均衡仍需调优(各阶段速率匹配、避免某个 FIFO 长期成为瓶颈)。