Archipelago

Lec 7用接口实现时序电路模块

讲义完成更新于 2026-09-29

Lec 7 用接口实现时序电路模块

TL;DR

  • 接口与实现解耦:通过模块(Module)、方法(Method)与规则(Rule)将硬件状态与组合逻辑封装为类型安全的黑盒,消除底层信号误用。
  • 守卫与就绪使能协议:方法守卫(Guard)在硬件层直接合成为 Ready/Enable 握手信号,从电路结构上杜绝无效状态下的非法访问与破坏。
  • 弹性流水线设计:引入 FIFO 解耦跨阶段变长延迟,利用局部握手实现延迟不敏感(Latency-Insensitive)的高并发硬件流水线。

硬件抽象与模块化设计挑战

复杂硬件被看作是一些模块的集合,它们的输入/输出线以某种方式连接在一起。这种观点在技术上是正确的,但对设计过程没有太大帮助。“只看连线”的方式太底层、太机械化,不利于从高层抽象出复杂系统的结构,也不利于根据性能和应用需求多次实例化模块,也不利于将 IP 封装成可直接组合构建大型系统的“黑盒”模块。现代硬件开发大量依赖 IP 核(IP blocks,知识产权核)——例如 CPU 核心、加密模块、DMA 控制器等通用或商业模块。此外,这种做法也缺乏对模块化细化(modular refinement)的支持,使得开发者难以在不了解整个设计的前提下对某个模块进行独立改进。

我们需要的是一种抽象:把“一个模块能做什么”(接口)和“它怎么做到的”(实现)彻底分开。本讲就建立这种抽象——用**接口(interface)+ 方法(method)**来描述时序模块。

本讲导览

  • 引例: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 模块由四部分构成:

  1. 子模块(submodules):寄存器(Reg#(T))或其它模块——通过连接输入/输出来组合。
  2. 方法(methods):输入参数 + 当前状态 → 输出(纯组合、不改状态)。
  3. 规则(rules):当前状态 + 外部输入 → 下一状态 + 驱动子模块的输入。规则每个时钟周期都触发一次。
  4. 输入(inputs):来自外层(父)模块的外部值。

Minispec 模块结构:包含子模块寄存器、组合方法、时序规则与外部输入的层次化划分

对照 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
endmodule

Bluespec 等价写法:BSV 把“对外能做什么”单独抽成 interface,并把状态变更包装成 Action 方法,寄存器用 mkReg 实例化:

interface 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)

本课遵守两条纪律,从结构上杜绝组合环路:

  1. 模块只与自己的子模块交互(不越级);
  2. 方法不读 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;       // 写:排入下一状态,下个时钟沿才生效

关键语义(务必牢记):

  • <= 是寄存器写入(下一周期生效),每个寄存器每周期至多写一次,且在周期末统一更新;在同一周期内对同一寄存器先写后读,读到的仍是旧值。
  • = 是组合赋值/命名一根连线,立即生效,描述的是组合逻辑而非状态。

寄存器交换的妙处:因为 <= 都到周期末才生效,可以不用临时变量直接交换两个寄存器:

x <= 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;
endmethod

if (!busy) 是 start 的守卫,语义是:

  • 只有当 !busy 成立时,该方法才就绪(ready),可被调用;
  • 守卫在硬件上对应方法的 ready 信号;调用方须在 ready 有效时才置 enable;
  • 调用一个未就绪的方法是非法的——在规则化(rule)设计里,会让用到它的 rule 无法触发(被阻塞),而不是产生错误数据。

守卫把“FIFO 满时不能 push、空时不能 pop”这类约定,从“文档里的建议”变成了接口强制的语义,从根本上消除了引例里 FIFO 的误用问题。

测试:testbench 与系统函数

硬件设计中我们常独立测试每个模块,方法是写一个 testbench——它本身也是一个模块,把“被测模块”(DUT,Design Under Test)当作子模块,用一串测试输入检查其输出是否正确。

在硬件描述语言中,Testbench 的运行机制与可综合硬件逻辑存在本质区别:

  1. 仿真激励生成与时钟推进:Testbench 内部维护仿真专用的时钟计数与状态序列,按周期产生测试向量(Stimulus Vectors),驱动 DUT 的输入端口。由于 Testbench 仅在软件仿真环境中执行,它不受目标 FPGA 或 ASIC 物理资源的面积和延迟约束。
  2. 不可综合系统函数:以 $ 开头的函数属于仿真专用的系统任务(System Tasks)。例如 $display 用于格式化打印当前时钟沿各信号的稳态值,$finish 用于向仿真内核发送终止执行信号。这些函数在逻辑综合(Logic Synthesis)阶段会被综合器直接忽略或剔除,绝不会映射为门级网表。
  3. 断言与自动化验证闭环:健全的 Testbench 会将 DUT 的输出与黄金参考模型(Golden Reference)进行逐拍比对。当输出出现偏差时,通过断言报错并记录反例波形,从而在不需要人工逐拍观察波形图的情况下实现自动化回归测试。
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

通过将测试环境与核心逻辑物理隔离,设计者可以对每个时序模块建立契约化测试,确保重构内部状态机时外部接口契约始终保持不变。

时序逻辑的威力:“时间比空间更灵活”

组合电路只能实现固定位宽 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
endinterface
  • enq 的守卫是“未满”,deq/first 的守卫是“非空”。
  • 因为守卫内建在接口里,“满了还 enq”“空了还 deq”在结构上就不会发生——调用方在守卫不就绪时直接被阻塞,等条件满足再进行。

这把引例里那一堆“满了会怎样”的模糊文档,压缩成了两个清晰的守卫条件。

用 FIFO 构建流水线:弹性 vs 非弹性

关于组合电路流水线的基础(延迟/吞吐量、流水线图、k 阶流水线、阶段划分配方、性能铁律等)见 Lec 15。本节专注于用模块/FIFO 把多个时序模块串成流水线这一更高层的话题。

把流水线各阶段“分隔”开有两种主流做法:

非弹性流水线结构:以同步触发器分隔各组合处理阶段

非弹性流水线(inelastic pipeline)

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

弹性流水线结构:以双端口 FIFO 与握手信号实现阶段间延迟解耦

弹性流水线(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 需要 12 个周期(取决于输入),g 总是 1 个周期;输入 x0x3 中 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 反馈连接拓扑结构

设 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 长期成为瓶颈)。

我的理解

::: insight

守卫规则与 Ready/Enable 协议的硬件本质

在传统 Verilog 等底层硬件描述语言中,模块间握手(如 Valid/Ready)需要设计者手动编写多路复用器和控制逻辑。一旦调用方在接收方未就绪时拉高有效信号,就会导致静默数据丢失;反之若接收方就绪条件形成未打破的组合环,则会导致电路震荡。

Minispec 与 Bluespec 的守卫方法与规则调度器从根本上重塑了这一范式:

  1. 方法守卫是纯组合逻辑的 Ready 信号:一个方法的守卫条件(如 !busy 或 notFull)直接由被调用模块的当前寄存器状态组合计算得出,并向外暴露为只读硬件输出线。
  2. 规则激活是全原子事务的 Enable 触发:规则调度器只有在该规则内所有涉及的方法守卫全部为真时,才会为相关方法断言 enable 信号。若任一守卫失败,该规则整拍静默挂起,内部任何状态都不会发生部分更新。
  3. 消除无效协议状态空间:这种机制将软件中的事务性原子语义(ACID)下沉为硬件门级连线,使“跨多级 FIFO 阶段的握手阻塞”成为编译器自动推导的调度网表,从形式化层面上杜绝了因协议死锁或状态漂移导致的深层硬件 Bug。 :::