这一篇在干嘛?

综合工具基本是”照着你的代码结构”翻译成逻辑的——除了状态机、RAM 这类规则结构,它不会替你重新架构。所以 RTL 怎么写,直接决定综合出什么电路。本章讲三件事:怎么写决策树(if/else vs case)、哪些编码陷阱会造成仿真与硬件不一致(parallel_case/full_case、阻塞非阻塞混用、for 循环误用、组合环、意外锁存器)、以及怎么组织设计(分区与参数化)。

12.1 决策树

所谓决策树(decision tree),就是逻辑用来决定”接下来做什么”的条件序列,通常体现为 if/else 或 case 结构。看一个最简单的寄存器写选择:

module regwrite(
    output reg rout,
    input clk,
    input [3:0] in,
    input [3:0] ctrl);
 
always @(posedge clk)
    if (ctrl[0])    rout <= in[0];
    else if (ctrl[1])  rout <= in[1];
    else if (ctrl[2])  rout <= in[2];
    else if (ctrl[3])  rout <= in[3];
endmodule

这种 if/else 结构可以概念化为一串选择器(mux,多路选择器):

串行 mux 结构的简单优先级 图 12.1 串行 mux 结构实现的简单优先级

同一个功能可以有不同实现方式,取决于速度/面积权衡和优先级要求。怎么写、怎么约束,决定综合出哪种架构。

12.1.1 优先结构 vs 并行结构

if/else 天生带优先级:写在前面(靠上)的条件优先级更高。

优先级的位置 图 12.2 优先级的位置:越靠近寄存器的 mux 优先级越高

在图 12.2 中,只要 ctrl[0] 有效,无论其他位是什么,in[0] 都会被寄存。只有 ctrl[0] 无效时才轮到后面的位。排在某条件之前的所有条件都比它优先——这对应优先级 mux(priority mux)的实现:

优先级 mux 图 12.3 优先级 mux:靠寄存器最近的输入优先

经验法则:决策树有优先级编码需求时,用 if/else。

case 结构则常用于(但不总是)条件互斥的场合——任何时刻只有一个条件为真。典型例子是根据多位地址/数值做译码。同样的功能用 case(1) 写:

case(1)
    ctrl[0]: rout <= in[0];
    ctrl[1]: rout <= in[1];
    ctrl[2]: rout <= in[2];
    ctrl[3]: rout <= in[3];
endcase

很多新手以为 case 语句会自动生成”无优先级”的并行结构。这对 VHDL 成立,对 Verilog 不成立——看默认综合结果:

优先级编码逻辑 图 12.4 Verilog case 的默认实现:优先级被编码进各 mux 的使能端

图 12.4 显示 Verilog 的 case 默认仍带优先级编码(各使能端做了互斥化处理)。这正是陷阱所在:很多设计师于是用综合指令 parallel_case 强行去掉优先级:

// 危险的 case 写法
case(1) // synthesis parallel_case

加了这条指令,综合器认为各条件互斥、可以省掉优先级编码,得到真正并行的结构(图 12.5)——更快、更省逻辑。

无优先级编码 图 12.5 加 parallel_case 后的无优先级实现

但问题来了:parallel_case仅综合指令,仿真器会忽略它。它写成一个特殊注释,行为对仿真不可见。一旦你的条件实际上并不互斥,仿真和硬件就会不一致。

正确做法:根本不用这个指令。

  • 优先级逻辑 → 用 if/else;
  • 天生并行的逻辑 → 用普通的 case 写法让综合器自己识别(如 case(sel) 用完整的多位选择值,而不是 case(1) 这种位测试写法);
  • 如果综合器报告”case 结构不并行”,改 RTL 让它真的并行;如果真是优先级,改回 if/else。
  • 如果非要加指令,加到约束文件里而不是代码注释里,至少换工具时不会被”藏”在代码里。

12.1.2 完备条件(Full Conditions)与 default

前面例子里,如果所有条件都不成立,综合工具默认让寄存器保持原值(回环反馈),于是需要一个使能信号。你可以显式加 default 分支来消除这个隐式行为:

// 危险的 case 写法
module regwrite(
    output reg rout,
    input clk,
    input [3:0] in,
    input [3:0] ctrl);
 
always @(posedge clk)
    case(1) // synthesis parallel_case
    ctrl[0]: rout <= in[0];
    ctrl[1]: rout <= in[1];
    ctrl[2]: rout <= in[2];
    ctrl[3]: rout <= in[3];
    default: rout <= 0;
    endcase
endmodule

default 条件的编码实现 图 12.6 显式 default 条件成为 mux 的一个输入

default 显式化为 mux 的一个输入后,触发器不再需要使能,但总逻辑量未必减少。

另一个更优雅的技巧:在 case 之前先赋默认值

module regwrite(
    output reg rout,
    input clk,
    input [3:0] in,
    input [3:0] ctrl);
 
    always @(posedge clk) begin
    rout <= 0;
    case(1)
    ctrl[0]: rout <= in[0];
    ctrl[1]: rout <= in[1];
    ctrl[2]: rout <= in[2];
    ctrl[3]: rout <= in[3];
    endcase
end
 
endmodule

先赋默认值再 case 覆盖,既免掉 default 分支,又保证寄存器在每个条件下都有确定赋值——这同时避免了意外锁存器(后述)。

类似 parallel_case,还有个 full_case 指令,告诉综合器”所有情况已覆盖、不需要隐式保持逻辑”:

// 危险的 case 写法
case(1) // synthesis full_case

无 default 条件的实现 图 12.7 full_case 后所有保持原值的逻辑都被删除

综合器会删掉全部”保持当前值”的逻辑,只留下 mux 本身。但和 parallel_case 一样,它只对综合可见、对仿真不可见:如果某个条件下你没给输出赋值,仿真器会让输出保持原值(锁存行为),而综合器认为那是”无关项(don’t care)“——两者直接分叉。

一条让人后背发凉的事实

有些综合工具提供”把所有 case 自动当作 full_case + parallel_case 处理”的全局选项。永远不要开这个选项。 它制造的是藏在暗处的错误综合结果,仿真查不出来,基本板测也未必暴露。

12.1.3 多控制分支:一个寄存器只在一处控制

常见坏习惯:同一个寄存器的赋值分散在多个独立的 if 里:

// 坏的编码风格
module separated(
    output reg oDat,
    input iClk,
    input iDat1, iDat2, iCtrl1, iCtrl2);
always @(posedge iClk) begin
    if (iCtrl2) oDat <= iDat2;
    if (iCtrl1) oDat <= iDat1;
end
endmodule

由于无法判断 iCtrl1 和 iCtrl2 是否互斥,代码语义有歧义,综合器只能按出现顺序猜优先级:写在后面的 iCtrl1 赢(图 12.8)。注意这与 if/else 结构正好相反——if/else 是写在前面的赢。交换两行代码,优先级就反了,非常容易埋雷。

隐式优先级的实现 图 12.8 隐式优先级实现:靠后的条件反而赢

好习惯:一个寄存器的所有赋值放在同一个控制结构里。

12.2 编码陷阱

12.2.1 阻塞 vs 非阻塞赋值

先明确概念:

  • 阻塞赋值(blocking,=:像软件一样,后面的语句必须等前面的执行完;执行时所有变量已更新。
  • 非阻塞赋值(nonblocking,<=:与顺序无关;事件触发时所有更新同时发生。

看一个正常例子:

module blockingnonblocking(
    output reg out,
    input clk,
    input in1, in2, in3);
    reg logicfun;
 
    always @(posedge clk) begin
    logicfun <= in1 & in2;
    out <= logicfun | in3;
end
 
endmodule

非阻塞赋值的正确实现 图 12.9 非阻塞赋值:logicfun 和 out 都是触发器

logicfun 和 out 都是触发器,in1/in2 的变化要经过两个时钟周期才传到 out。

如果把 <= 改成 =(看似只动了一个字符):

// 坏的编码风格
logicfun = in1 & in2;
out = logicfun | in3;

语义变成:out 必须等 logicfun 更新完才更新,且都发生在同一个时钟事件内——logicfun 的寄存器消失了,整个设计的时序被改变:

阻塞赋值导致的错误实现 图 12.10 阻塞赋值:logicfun 变成了纯组合逻辑,少了一级寄存

调换两行顺序(先算 out 再算 logicfun)确实能凑回两级寄存,但对复杂逻辑来说这种写法既不直观也不可行。还有人想拆成两个独立的 always 块:

// 坏的编码风格
always @(posedge clk)
    logicfun = in1 & in2;
always @(posedge clk)
    out = logicfun | in3;

看似并行,仿真时并不是(同一时间步内执行顺序取决于仿真器调度),这种风格必须避免。

阻塞赋值最常见的”诱惑”场景:大量默认赋值。比如按 ctrl 选择把某个输入位寄存到对应输出位,其余位清零。用非阻塞写,每个分支都要手工清零所有未涉及的位,又长又烦:

// 不佳的编码风格
module blockingnonblocking(
    output reg [3:0] out,
    input clk,
    input [3:0] ctrl, in);
 
    always @(posedge clk)
    if (ctrl[0]) begin
    out[0] <= in[0];
    out[3:1] <= 0;
    end
    else if (ctrl[1]) begin
    out[1] <= in[1];
    out[3:2] <= 0;
    out[0] <= 0;
    end
    else if (ctrl[2]) begin
    out[2] <= in[2];
    out[3] <= 0;
    out[1:0] <= 0;
    end
    else if (ctrl[3]) begin
    out[3] <= in[3];
    out[2:0] <= 0;
    end
    else
    out <= 0;
endmodule

有人于是用阻塞赋值简化(先统一清零,再覆盖):

// 坏的编码风格
always @(posedge clk) begin
    out = 0;
    if(ctrl[0]) out[0] = in[0];
    else if(ctrl[1]) out[1] = in[1];
    else if(ctrl[2]) out[2] = in[2];
    else if(ctrl[3]) out[3] = in[3];
end

综合结果一样,但仿真中可能出现竞态(race condition)。其实同样的技巧用非阻塞写照样简洁:

module blockingnonblocking(
    output reg [3:0] out,
    input clk,
    input [3:0] ctrl, in);
 
always @(posedge clk) begin
    out <= 0;
    if (ctrl[0]) out[0] <= in[0];
    else if (ctrl[1]) out[1] <= in[1];
    else if (ctrl[2]) out[2] <= in[2];
    else if (ctrl[3]) out[3] <= in[3];
end
 
endmodule

out <= 0 再按条件覆盖,竞态消失。这就是”默认值前置”技巧在时序逻辑中的用法。

三条铁律

  1. 组合逻辑用阻塞赋值建模;
  2. 时序逻辑用非阻塞赋值建模;
  3. 绝不在同一个 always 块里混用两种赋值。 违反它们会导致仿真与综合不一致、可读性差、仿真变慢,以及极难调试的硬件错误。

12.2.2 For 循环:不是给你做迭代的

有软件背景的设计师最容易掉进这个坑。C 语言里 for 循环是算法迭代,但可综合 HDL 里 for 循环只是”复制相似语句的简写”,迭代之间没有任何隐式寄存。

软件式的幂运算:

PowerX = 1;
for(i=0;i<N;i++) PowerX = PowerX * X;

照搬到 HDL:

// 坏的编码风格
module forloop(
    output reg [7:0] PowerX,
    input    [7:0] X, N);
    integer    i;
 
always @* begin
    PowerX = 1;
    for (i=0;i<N;i=i+1)
        PowerX = PowerX * X;
end
 
endmodule

行为仿真没问题,但综合起来:XST 不允许 N 不定;Synplify 会按 N 的最大值把循环完全展开成一大坨组合逻辑,跑得极慢。正确做法是改成带控制的迭代结构——每拍做一次乘法:

module forloop(
    output reg [7:0] PowerX,
    output reg Done,
    input Clk, Start,
    input [7:0] X, N);
    integer i;
always @(posedge Clk)
    if (Start) begin
    PowerX <= 1;
    i <= 0;
    Done <= 0;
end
else if(i < N) begin
    PowerX <= PowerX * X;
    i <= i + 1;
end
else
    Done <= 1;
endmodule

这个版本比”软件式”实现面积小一个数量级、速度快一个数量级。代价是花 N 个时钟周期——这正是硬件思维和软件思维的分界线。

for 循环的正确用法:压缩重复但并行的赋值。比如让输出的每一位等于 X 对应位异或 Y 的偶数位:

// 展开形态:32 行相似代码
Out[0] <= Y[0] ^ X[0];
Out[1] <= Y[2] ^ X[1];
Out[2] <= Y[4] ^ X[2];
...
Out[31] <= Y[62] ^ X[31];
 
// 用 for 循环压缩为一行
always @(posedge Clk)
    for (i=0; i<32; i=i+1) Out[i] = Y[i*2] ^ X[i];

注意:循环内没有反馈(本次迭代不依赖上次迭代的结果),for 循环只是打字员。这是判断用法的试金石。

12.2.3 组合逻辑环

组合环(combinatorial loop):组合逻辑的输出不经任何寄存器直接反馈回自己。

组合环与时序环的对比 图 12.11 组合环 vs 时序环:前者几乎没有合法用途

典型制造方式是敏感列表不完整

// 坏的编码风格
module combfeedback(
    output out,
    input a);
    reg b;
 
// 坏的编码风格:b 反馈回 b
assign out = b;
 
// 坏的编码风格:敏感列表不完整
always @(a)
    b = out ^ a;
endmodule

行为仿真中:a 变化时 b = out ^ a,看起来没有反馈振荡。但综合器发现敏感列表不全,会自动补全敏感列表(把 out 也加进去),反馈环就此闭合——实现成一个输出反馈回自身的 XOR 门(图 12.12)。只要 a 为 1,这个电路就持续振荡

敏感列表补全后反馈环闭合 图 12.12 敏感列表被补全后,组合环闭合,a=1 时电路振荡

预防手段很简单:组合 always 块把表达式用到的所有输入都写进敏感列表(或者干脆用 always @*)。这样仿真行为和综合假设一致,问题在仿真阶段就会暴露。

12.2.4 意外锁存器

组合逻辑里漏掉某条件的赋值,会综合出锁存器(latch,电平敏感的存储单元,FPGA 中强烈不推荐使用):

// 锁存器推断
module latch (
    input iClk, iDat,
    output reg oDat);
always @*
    if(iClk) oDat <= iDat;
endmodule

iClk 有效时直通、无效时保持——“保持”就只能用锁存器实现。这是经典的”if 没有 else”错误。

更隐蔽的写法:

// 坏的编码风格
assign O = C ? I: O;

本意是三态 mux,但输出反馈回自身,综合工具往往直接推断出一个锁存器——在一个本不该有时序单元的路径上硬插进一个时间端点,基本可以断定是 HDL 写错了。

连函数调用都可能藏锁存器:

// 坏的编码风格
module latch (
    input iClk, iDat,
    output reg oDat);
    always @*
    oDat <= MyLatch(iDat, iClk);
    function MyLatch;
    input D, G;
    if(G) MyLatch = D;
    endfunction
endmodule

看似把锁存器行为封装进了函数,但函数永远按纯组合逻辑求值,输入直接穿透到输出,锁存器根本不会生成(或者生成出错误的电路)。函数里”if 没覆盖所有分支”同样隐含保持行为,但实现不出来。

12.3 设计组织

12.3.1 分区

分区指按模块、层次和功能边界来组织设计,必须在项目早期规划——越到后期改动代价越大。好的分区让设计师一次只面对一块功能,独立设计、仿真、调试。

数据通路 vs 控制

大多数架构可分成两部分:

  • 数据通路(data path):数据从输入流到输出的”管道”,执行必要的运算;
  • 控制结构(control):不搬运数据,只配置数据通路执行哪种操作。

数据通路与控制的分区 图 12.13 数据通路与控制分区到不同模块

把数据通路和控制分区到不同模块,理由有二:

  1. 团队分工清晰,接口明确;
  2. 下游优化需要:数据通路往往是关键路径(吞吐率取决于流水线时序),可能需要布局规划(floorplan);控制逻辑(如 SPI 或 I²C 总线配置寄存器,几十 kHz 量级)时序要求宽松得多,可以交给自动布局布线自由摆放。

时钟与复位结构

每个模块只用一种时钟、一种复位类型。 多时钟域/多复位结构的设计,必须让层次边界把它们隔开。这样混合时钟/复位引发的各种问题就很难发生。

多重实例化

同一逻辑操作出现多次时,把它抽成独立模块、在层次中多次实例化(图 12.14)。好处:按模块分工、跨设计复用;比从大模块里复制粘贴再重设计接口靠谱得多。

模块化设计 图 12.14 模块化设计:重复逻辑抽成子模块多次实例化

各实例有细微差异(位宽、迭代次数等)怎么办?用参数化。

12.3.2 参数化

define(全局定义)

`define 用于全模块恒定的值,或编译期开关:

`define CHIPID 8'hC9 // 全局芯片 ID
`define onems 90000 // 11ns 时钟下约 1ms
`define ulimit16 65535 // 无符号 16 位数的上限

另一个典型应用是 ASIC 原型验证到 FPGA:一套代码、两种目标,用 `ifdef 切换:

`define FPGA
//`define ASIC

顶层模块里:

`ifdef ASIC
input TESTMODE;
output TESTOUT;
`endif
 
`ifdef FPGA
output DEBUGOUT;
`endif

ASIC 的测试引脚在 FPGA 里无意义,FPGA 的调试输出不进 ASIC——用全局定义保持单一代码库。建议把所有全局 define 集中到一个文件,所有模块统一 include,避免定义互相矛盾。

parameter(局部参数)

parameter 属于具体模块,可逐实例修改。典型例子是可变位宽寄存器:

module paramreg #(parameter WIDTH = 8) (
    output reg [WIDTH-1:0] rout,
    input clk,
    input [WIDTH-1:0] rin,
    input rst);
always @(posedge clk)
    if (!rst) rout <= 0;
    else rout <= rin;
endmodule

同一个代码基,实例化出 2 位和 22 位寄存器:

// 正确但过时的参数传递方式
paramreg #(2) r1(.clk(clk), .rin(rin), .rst(rst), .rout(rout));
paramreg #(22) r2(.clk(clk), .rin(rin), .rst(rst), .rout(rout));

没有参数化,你就得维护一堆几乎相同的代码副本,改起来又烦又容易错。

还有一种 defparam 命令可以从层次中任意位置修改任意参数。危险在于:参数本应像局部变量一样只作用于本实例,从子模块反向修改上层参数(图 12.15)会造成综合与仿真的不一致——综合工具通常自顶向下求值参数。如果非用 defparam,必须放在对应实例化处。

defparam 的滥用 图 12.15 defparam 滥用:子模块反向改写顶层参数

Verilog-2001 的改进

  1. 按名传参#(.WIDTH(22)) 解锁位置限制、提高可读性、减少人为错误,强烈推荐
paramreg #(.WIDTH(22)) r2(.clk(clk), .rin(rin), .rst(rst), .rout(rout));
  1. localparam(本地参数):由其他参数推导、 confined 在本实例内。典型用途是输出位宽 = 输入位宽之和:
// 混合风格的 localparam 示例
module multiparam #(parameter WIDTH1 = 8, parameter WIDTH2 = 8)
(oDat, iDat1, iDat2);
localparam WIDTHOUT = WIDTH1 + WIDTH2;
output [WIDTHOUT-1:0] oDat;
input [WIDTH1-1:0] iDat1;
input [WIDTH2-1:0] iDat2;
 
assign oDat = iDat1 * iDat2;
endmodule

外部只需传两个输入位宽,输出位宽自动推导——杜绝”输出位宽与输入之和不匹配”这类人为错误。(注意:localparam 目前不能写在模块头部的端口列表里,若要在 I/O 中使用需用 Verilog-1995 风格的端口声明。)

常见坑

parallel_case/full_case 是仅综合指令,仿真器视而不见。一旦条件实际不互斥(parallel_case)或实际未覆盖所有分支(full_case),仿真器按”保持原值”跑,硬件按”无关项”综合——仿真通过、上板出错,而且是那种复现都困难的问题。还有工具的”全局自动 full/parallel case”选项,碰都不要碰。正确姿势:优先级用 if/else,并行结构用标准 case 写法让综合器自己识别,所有 case 保证完备(default 或前置默认赋值)。

通关标准:

学完本篇你应该能做到:

  1. 判断一个决策树该用 if/else(有优先级)还是 case(互斥并行),并画出对应的 mux 结构图;
  2. 说清 parallel_case 和 full_case 为什么危险,并用 default/前置默认赋值从代码层面保证完备性;
  3. 遵守阻塞/非阻塞三条铁律,用”先统一默认值再覆盖”的非阻塞技巧简化多位输出赋值;
  4. 分辨 for 循环的合法用法(展开重复并行语句)与非法用法(软件式迭代算法),后者能改写成时钟驱动的迭代结构;
  5. 识别组合环(敏感列表不全)和意外锁存器(if 缺 else、O = C ? I : O、函数内条件赋值)的代码特征;
  6. 合理运用 define(全局)、parameter(实例级)、localparam(推导值)和按名传参。