本章目录 36 节

← 返回课程首页

第 19 章 Verilog/SystemVerilog 的硬件表达

1. 本章要解决的问题

前 18 章一直用电路图、逻辑方程、状态表和寄存器传输描述硬件。真正交给数字设计工具时,还需要一种精确、可检查、可转换为门级结构的文本表达。Verilog 和 SystemVerilog 就承担这个任务。

最容易犯的错误,是把 HDL 当作普通程序设计语言:看到两行代码,便认为第一行先执行、第二行后执行;看到 for 循环,便认为硬件要花多个周期逐次运行;看到变量,便认为它只是软件内存中的一个数。

硬件描述语言(hardware description language, HDL)中的代码首先描述结构、连接和随时间发生的硬件行为。工具可能用它做仿真,也可能把其中可综合的部分转换为门、触发器、MUX 和存储器。本章回答十个问题:

  1. HDL 代码与实际硬件是什么关系?
  2. 模块、端口和实例分别表示什么?
  3. 一位信号、位向量和常量怎样书写?
  4. 连续赋值与组合过程分别适合描述什么?
  5. 为什么组合过程漏掉赋值会推断锁存器?
  6. 怎样描述触发器、寄存器、使能和复位?
  7. 阻塞赋值 = 与非阻塞赋值 <= 为什么不能随意互换?
  8. 参数、局部参数和数组怎样表达可复用硬件?
  9. 怎样把存储器和 FSM 写成清楚的 RTL?
  10. 怎样在综合前发现位宽、多驱动、组合环和接口语义错误?

本章使用 SystemVerilog 的基础可综合子集,文件扩展名写作 .sv。经典 Verilog 的核心思想相同,但 SystemVerilog 的 logicalways_combalways_ff 能更清楚地表达设计意图,也便于工具检查。

2. 与第 17、18 章的联系

第 17 章已经建立寄存器传输级(RTL)的硬件思考方式。例如:

\[ R^{+}=ld_R?D:R. \]

它表示有效时钟边沿到来时,ld_R=1 便把 D 写入寄存器 R,否则保持。对应的 SystemVerilog 可以写成:

always_ff @(posedge clk) begin
    if (ld_r)
        r <= d;
end

第 18 章的 D × W bit 存储器则可以写成一个具有 D 个元素、每个元素宽 W bit 的数组。地址选择数组元素,写过程规定哪个边沿更新它。

本章不会让代码替代电路理解。每读到一段 RTL,都要继续追问:

3. 前置知识快速检查

  1. 2 选 1 MUX 的输出怎样由选择信号决定?
  2. 组合逻辑的输出能否依赖未声明的历史状态?
  3. 两个 D 触发器在同一时钟边沿更新时,右侧表达式读取旧值还是新值?
  4. 同步复位与异步复位分别在什么事件下改变状态?
  5. 16 × 8 bit 存储器的深度、字宽和地址位数分别是多少?

答案是:选择一路输入;不能;读取边沿前的旧值;同步复位在有效时钟边沿生效,异步复位可由复位有效事件直接生效;深度 16、字宽 8 bit、地址 4 bit。若第 1、2 题不熟,回看第 8、10 章;若第 3、4 题不熟,回看第 12 章;若第 5 题不熟,回看第 18 章。

4. 学习目标

完成本章后,你应能够:

5. 第一原则:RTL 描述的是并行硬件

观察图 19-1 时,先看左侧三条语句,再看右侧三块硬件。三条连续赋值没有共享一位“处理器”轮流执行;它们各自形成逻辑,并在输入变化后并行传播。

三条 RTL 语句对应三块并行硬件

图 19-1 三条连续赋值同时存在于芯片上,代码的上下顺序不表示跨周期执行顺序

例如:

assign n = ~a;
assign p = b & c;
assign y = sel ? n : p;

这段代码对应:

输入改变后,各条组合路径按自身传播延迟响应。代码写在前面,不会自动获得更早的时钟周期。

立即检查

如果把前两条 assign 的书写顺序交换,硬件功能会改变吗?

不会。它们是并行的连续驱动关系,交换文本顺序不改变连接。

6. 模块是带端口的硬件边界

模块(module)把一块硬件的外部接口和内部实现包在一起。下面是 1 bit 的 2 选 1 MUX:

module mux2_1bit (
    input  logic a,
    input  logic b,
    input  logic sel,
    output logic y
);
    assign y = sel ? b : a;
endmodule

读这段代码时按以下顺序:

  1. 模块名是 mux2_1bit
  2. absel 是进入模块的输入端口;
  3. y 是离开模块的输出端口;
  4. 条件运算符规定 sel=1 选择 bsel=0 选择 a
  5. 整个模块没有时钟和状态元件,因此是组合逻辑。

图 19-2 把模块定义、模块实例与实体硬件分开。定义像一张电路蓝图;每写一个实例,设计层次中就出现一份对应硬件。

模块定义、实例和连线

图 19-2 模块定义规定接口和内部关系,两个实例对应两份同时工作的硬件

在上层模块中实例化两次:

logic y0, y1;

mux2_1bit u_mux0 (
    .a   (d0),
    .b   (d1),
    .sel (s0),
    .y   (y0)
);

mux2_1bit u_mux1 (
    .a   (d2),
    .b   (d3),
    .sel (s1),
    .y   (y1)
);

.a(d0) 表示实例端口 a 连接上层信号 d0。命名端口连接比只按位置排列更容易检查,也不怕端口声明顺序改变。

例题 1:把模块实例还原为硬件

某上层模块实例化三个 mux2_1bit,前两个分别选择 (a,b)(c,d),第三个在前两个输出之间选择。这个结构包含多少个 MUX?

每个实例对应一个 MUX,因此共有三个 MUX。若第三个选择信号作为最高层选择位,前两个共享另一个选择位,这三个 MUX 可以组成 4 选 1 MUX。代码复用的是模块定义,实例化后并不会让三次选择共用同一个实体。

变式:如果在 for 生成结构中实例化 8 次,展开后就是 8 份硬件。生成循环在 elaboration(设计展开)阶段决定结构数量,不表示运行时花 8 个周期。

7. logic 表示四状态逻辑对象,不自动表示寄存器

SystemVerilog 常用 logic 声明信号:

logic        valid;
logic [7:0]  data;
logic [15:0] sum;

logic 可以在仿真中取四种状态:

logic [7:0] data 是一个 8 bit 打包数组(packed array),位编号从 7 到 0:

声明为 logic 并不决定它综合为导线还是寄存器。决定因素是驱动方式:

wirelogic 的基础选择

经典 Verilog 用 wire 表示网络连接,用 reg 表示过程赋值对象。reg 这个名字容易让初学者误以为一定产生寄存器。SystemVerilog 通常用 logic 统一表达单驱动设计信号,并让 always_combalways_ff 表明过程意图。

基础 RTL 可以采用这条规则:模块内部和端口优先使用 logic;一个信号只由一个明确来源驱动。真正需要多驱动网络或三态总线时,再使用相应网络类型并单独定义解析规则。

8. 位宽是硬件数量,不能当作排版细节

下面两个声明对应不同硬件:

logic [7:0] a;   // 8 bit
logic [8:0] b;   // 9 bit

加法尤其要关注结果位宽。两个 8 bit 无符号数相加,完整结果可能需要 9 bit:

logic [7:0] a, b;
logic [8:0] sum_full;

assign sum_full = {1'b0, a} + {1'b0, b};

{1'b0, a} 是拼接,把一个 0 放在 a 的高位,明确把操作数扩展到 9 bit。若只写到 8 bit 输出,最高进位会被截断。这可能正是模 \(2^8\) 运算的规格,也可能是错误;必须由接口契约决定。

常量的尺寸

常用写法为 位数'进制数值

8'b1010_0110
8'hA6
8'd166
1'b0
4'bx

下划线只用于分组阅读,不改变数值。'0 表示按左侧目标宽度填满 0,适合复位:

q <= '0;

有符号运算还受 signed 属性影响。基础设计中,除非规格确实要求补码有符号运算,优先明确使用无符号向量;需要有符号数时写出 logic signed [W-1:0],并检查两侧宽度和符号扩展。

立即检查

logic [3:0] y; assign y = 4'b1111 + 4'b0001; 保存的结果是什么?

4 bit 输出只保留低 4 bit,得到 0000。若还需要进位,应把结果和操作数显式扩展为 5 bit。

9. 连续赋值:直接描述组合连接

assign 适合表达一个清楚的组合方程:

assign y_and = a & b;
assign y_xor = a ^ b;
assign y_mux = sel ? d1 : d0;
assign equal = (x == y);

右侧任一输入变化,仿真器都会重新计算左侧。综合时,这些关系转换为门、比较器、MUX 或连线。

运算符要分清:

类别 常用运算符 含义
按位 ~ & \| ^ 对向量每一位分别运算
逻辑 ! && \|\| 把操作数当作真假条件
关系 == != < <= > >= 比较并得到一位条件
移位 << >> 左移或逻辑右移
算术 + - * 算术运算,结果宽度需检查
选择 cond ? a : b 二选一,通常对应 MUX
拼接 {a,b} 把多个位段拼成更宽向量

这里的 <= 有两种上下文:在表达式中是“小于等于”比较;在过程赋值左侧之后是非阻塞赋值符号。读代码时要结合位置判断。

10. always_comb:描述有分支的组合逻辑

复杂组合逻辑用 always_comb 更清楚:

always_comb begin
    if (sel)
        y = b;
    else
        y = a;
end

always_comb 告诉工具:

在组合过程中通常使用阻塞赋值 =。它使同一过程内后续语句立即看到前面刚算出的临时结果,适合按计算依赖顺序写组合算法。

例题 2:4 bit 小型 ALU

规格如下:op=00 加法,01 减法,10 按位 AND,11 按位 XOR。结果只保留 4 bit。

module alu4 (
    input  logic [3:0] a,
    input  logic [3:0] b,
    input  logic [1:0] op,
    output logic [3:0] y,
    output logic       zero
);
    always_comb begin
        case (op)
            2'b00:  y = a + b;
            2'b01:  y = a - b;
            2'b10:  y = a & b;
            default:y = a ^ b;
        endcase
    end

    assign zero = (y == 4'b0000);
endmodule

硬件上,四种运算结果进入选择网络,op 决定输出。zero 是对最终 y 的组合比较。default 覆盖 op=11,所以四种二进制操作码都有定义。

若还要输出完整加法进位,当前 4 bit 的 y 不够,应增加 carry,并在加法分支使用 5 bit 临时结果。不要指望赋值目标在结果已经截断后恢复丢失的进位。

变式:op 增至 3 bit,而 case 仍只覆盖四种编码,就必须规定其他编码的输出,例如令 y='0 并产生 illegal_op

11. 组合逻辑必须对输出完整赋值

考虑:

always_comb begin
    if (en)
        y = d;
end

en=0 时,代码没有规定 y 的当前值。为了让 y 保留上次结果,硬件必须加入状态保持,这会推断锁存器。问题不在语法,而在规格缺了一条组合路径。

图 19-3 对比完整 MUX 与意外锁存器。先看 en=0:左边明确选择常量 0;右边只能反馈旧 y

完整组合赋值与锁存器误推断

图 19-3 组合过程必须为每条可达路径规定输出;“什么都不写”意味着需要保存旧值

若规格是 en=0 输出 0,应写成:

always_comb begin
    if (en)
        y = d;
    else
        y = '0;
end

也可以先给默认值,再覆盖特殊条件:

always_comb begin
    y = '0;
    if (en)
        y = d;
end

例题 3:找出锁存器来源

always_comb begin
    case (sel)
        2'b00: y = a;
        2'b01: y = b;
        2'b10: y = c;
    endcase
end

sel=11 时没有给 y 赋值,因此 y 必须保持旧值,形成锁存器。修正方法是增加:

        default: y = d;

或者在 case 前写 y=d; 作为默认值。选择哪种默认行为由规格决定,不能为了消除警告随意填一个值。

变式:若设计确实需要电平敏感锁存器,应明确使用 always_latch 表达意图。基础同步设计通常优先使用边沿触发寄存器。

12. 组合过程中的阻塞赋值

下面的组合过程先计算中间量,再计算输出:

always_comb begin
    temp = a & b;
    y    = temp | c;
end

阻塞赋值 = 让第二行立即读到本次刚算出的 temp。对应逻辑方程是:

\[ y=(a\cdot b)+c. \]

在同一组合过程中反过来写:

always_comb begin
    y    = temp | c;
    temp = a & b;
end

虽然有些工具会根据组合敏感关系重复求值并最终稳定,但这种写法破坏了清楚的数据依赖顺序,也容易产生仿真与理解问题。组合过程按“先算依赖,再算使用者”的顺序书写。

13. always_ff:描述边沿触发状态更新

一个带同步复位和使能的 8 bit 寄存器:

module reg8_en (
    input  logic       clk,
    input  logic       rst,
    input  logic       en,
    input  logic [7:0] d,
    output logic [7:0] q
);
    always_ff @(posedge clk) begin
        if (rst)
            q <= '0;
        else if (en)
            q <= d;
    end
endmodule

它表达三个优先级:

  1. 只在 clk 上升沿检查同步控制;
  2. rst=1 时清零,优先于 en
  3. rst=0en=1 时装载 d
  4. 两者均不成立时没有赋值,边沿触发寄存器自然保持。

组合过程漏赋值会推断锁存器;时序过程中某个边沿分支不赋值则表示寄存器保持。两者的区别来自过程触发方式和所表达的状态元件。

14. 非阻塞赋值表达“同一边沿一起更新”

时序过程通常使用非阻塞赋值 <=

always_ff @(posedge clk) begin
    q0 <= d;
    q1 <= q0;
    q2 <= q1;
end

在一个上升沿:

观察图 19-4:边沿到来时先拍下所有右侧旧值,再更新所有 Q。文本上下顺序不会把数据在一个边沿穿过三级寄存器。

非阻塞赋值的边沿快照

图 19-4 非阻塞赋值先读取边沿前状态,再并行提交新状态,符合真实寄存器同时采样

例题 4:两个寄存器交换数据

边沿前 a=8'h3Cb=8'hA5

always_ff @(posedge clk) begin
    a <= b;
    b <= a;
end

边沿后 a=8'hA5b=8'h3C。两条右侧都读取旧值,所以完成交换。

若在同一时序过程中错误地使用阻塞赋值:

a = b;
b = a;

第二行会读到第一行刚写入的 a,仿真结果变为两者都等于旧 b,无法表达两个真实触发器的并行交换。

基础编码规则:组合过程用 =;边沿触发状态更新用 <=。这条规则既表达硬件时间语义,也减少竞争与仿真顺序依赖。

15. 同步复位与异步复位的代码差别

同步高有效复位:

always_ff @(posedge clk) begin
    if (rst)
        q <= '0;
    else
        q <= d;
end

只有上升沿到来时才检查 rst

异步高有效复位:

always_ff @(posedge clk or posedge rst) begin
    if (rst)
        q <= '0;
    else
        q <= d;
end

rst 从 0 变 1 本身就是过程事件,因此无需等待时钟即可清零;解除复位后的第一次正常采样仍由时钟边沿完成。

复位极性必须与端口命名一致。低有效异步复位通常写作 rst_n

always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        q <= '0;
    else
        q <= d;
end

第 15 章讨论过“异步置位、同步释放”的系统策略。RTL 模板只能表达局部触发器行为;复位树、释放同步器和工艺库支持还要在系统与实现阶段统一规划。

16. 时序过程中的优先级会形成 MUX 链

always_ff @(posedge clk) begin
    if (rst)
        q <= '0;
    else if (load)
        q <= d;
    else if (inc)
        q <= q + 1'b1;
end

这不是三件互不相关的事。它形成明确优先级:复位最高,装载其次,加一最低。寄存器 D 端前需要组合选择逻辑。

若规格要求 loadinc 同时为 1 时装载,那么上述顺序正确。若要求装载后再加一,应直接写出 q <= d + 1'b1 的分支。代码顺序体现的是硬件选择优先级,必须来自规格。

立即检查

rst=0load=1inc=1,上面代码选择什么?

选择 d,因为 else if 只执行第一个成立分支。

17. 参数化模 N 计数器

例题 5:用参数决定计数范围与状态位宽

参数化让同一模块定义适配不同规模。下面的计数器从 0 数到 N-1 后回到 0:

module modn_counter #(
    parameter int unsigned N = 10,
    localparam int unsigned W = (N <= 1) ? 1 : $clog2(N)
) (
    input  logic         clk,
    input  logic         rst,
    input  logic         en,
    output logic [W-1:0] count,
    output logic         terminal
);
    assign terminal = en && (count == N-1);

    always_ff @(posedge clk) begin
        if (rst)
            count <= '0;
        else if (en) begin
            if (count == N-1)
                count <= '0;
            else
                count <= count + 1'b1;
        end
    end
endmodule

parameter 是实例化时可改的设计常量。localparam 是模块内部派生常量,不允许实例从外部随意覆盖。$clog2(N) 返回覆盖 N 个编码所需的向上取整对数;N<=1 的分支保证位宽至少为 1。

N=10,则 W=4,计数器物理上是 4 个状态位加比较与加一逻辑。位模式 10~15 没有正常计数含义。若需要从任意非法状态自动恢复,可增加 count >= N-1 的策略;基础版本假定复位后从合法状态运行。

terminal 被定义为“本周期允许计数且当前即将回绕”,它是组合状态信号。若接口要求寄存一个完整周期的完成脉冲,则应再用触发器保存。

变式:N=16W=4,所有 4 bit 编码均合法;比较器仍可保留,综合工具可能根据常量关系优化逻辑。

18. 参数化不是运行时改变电路宽度

实例化:

modn_counter #(.N(60)) u_sec (...);
modn_counter #(.N(1000)) u_ms (...);

设计展开时,工具分别计算两个实例的位宽并生成两套硬件。芯片运行后,N 不是普通输入端口,不能在每个周期改变计数范围。

这一区分很重要:

如果计数终点需要软件运行时配置,就应把终点做成寄存器输入,而不是参数。

19. 打包数组与非打包数组

下面是第 18 章存储组织在 SystemVerilog 中的常见写法:

logic [7:0] mem [0:15];

图 19-5 从中括号位置读结构:变量名左边的 [7:0] 是每个元素内部的 8 bit 打包向量;变量名右边的 [0:15] 表示 16 个独立元素。

打包位向量与非打包存储数组

图 19-5 logic [7:0] mem [0:15] 表示 16 个可索引数据字,每个字由 8 个连续位组成

访问方式:

mem[3]       // 第 3 个 8 bit 数据字
mem[3][0]    // 第 3 个数据字的最低位
mem[3][7:4]  // 第 3 个数据字的高半字节

交换两个维度的位置会改变含义。声明数组时先用自然语言读一遍:“这是 16 个元素,每个元素 8 bit”。

20. 存储器的 RTL 表达

例题 6:异步读、同步写的小型存储器

module simple_mem #(
    parameter int unsigned D  = 16,
    parameter int unsigned W  = 8,
    localparam int unsigned AW = (D <= 1) ? 1 : $clog2(D)
) (
    input  logic          clk,
    input  logic          we,
    input  logic [AW-1:0] raddr,
    input  logic [AW-1:0] waddr,
    input  logic [W-1:0]  wdata,
    output logic [W-1:0]  rdata
);
    logic [W-1:0] mem [0:D-1];

    assign rdata = mem[raddr];

    always_ff @(posedge clk) begin
        if (we)
            mem[waddr] <= wdata;
    end
endmodule

这段代码表达:

若接口要求写周期立即从读口看到新数据,可以显式加入旁路:

assign rdata = (we && (waddr == raddr)) ? wdata : mem[raddr];

这会增加地址比较器和读数据 MUX。它不是一条“免费”的软件判断。

观察图 19-6,把每段代码对应到端口与硬件:数组对应存储主体,always_ff 对应写端口的边沿更新,assign 对应读选择网络,旁路条件对应比较器和 MUX。

存储器 RTL 到硬件端口的映射

图 19-6 异步读形成地址到数据的组合路径,同步写在时钟边沿更新选中数据字,旁路需要额外比较与选择硬件

变式:同步读。把读数据也放入 always_ff

always_ff @(posedge clk) begin
    if (re)
        rdata <= mem[raddr];
    if (we)
        mem[waddr] <= wdata;
end

此时 rdata 是寄存输出,地址在边沿被接受。读写同址行为仍需结合 RTL 语义、综合推断结果和目标存储宏规格检查。

21. 两过程 FSM:状态寄存器与下一状态逻辑分开

下面是一个最小的 IDLE → RUN → DONE → IDLE 控制器:

module controller (
    input  logic clk,
    input  logic rst,
    input  logic start,
    input  logic terminal,
    output logic count_en,
    output logic done
);
    typedef enum logic [1:0] {
        IDLE,
        RUN,
        DONE
    } state_t;

    state_t state, next_state;

    always_ff @(posedge clk) begin
        if (rst)
            state <= IDLE;
        else
            state <= next_state;
    end

    always_comb begin
        next_state = state;
        count_en   = 1'b0;
        done       = 1'b0;

        case (state)
            IDLE: begin
                if (start)
                    next_state = RUN;
            end

            RUN: begin
                count_en = 1'b1;
                if (terminal)
                    next_state = DONE;
            end

            DONE: begin
                done       = 1'b1;
                next_state = IDLE;
            end

            default: begin
                next_state = IDLE;
            end
        endcase
    end
endmodule

这段代码直接对应第 16、17 章的结构:

枚举(enumeration)用状态名替代裸二进制常量,提高可读性。具体状态编码可由工具选择或按声明处理;需要固定编码时应明确给每个枚举项赋值。

22. for 循环通常描述复制的硬件

一个 8 bit 按位异或:

always_comb begin
    for (int i = 0; i < 8; i++) begin
        y[i] = a[i] ^ b[i];
    end
end

循环边界在设计展开或综合时可确定,因此对应 8 个并行异或门,不是一个异或门运行 8 个周期。写成 assign y=a^b; 更简洁,硬件含义相同。

若确实要用一个运算器分 8 个周期处理,就需要计数器、状态机、寄存器和每周期一次的状态更新。循环语法本身不创造时间。

生成循环(generate for)常用于复制模块实例或条件结构;过程 for 常用于规则化组合赋值或固定次数运算。二者都要求综合工具能在构建硬件时确定规模。

23. 一个信号只保留一个清楚的驱动来源

错误示例:

always_comb begin
    y = a & b;
end

assign y = c | d;

y 同时由过程和连续赋值驱动。对普通片上逻辑,这不是“后一句覆盖前一句”,而是两个硬件源争夺同一信号。工具通常报多驱动错误。

正确做法是先明确选择规则,再由一个表达式驱动:

always_comb begin
    if (sel)
        y = a & b;
    else
        y = c | d;
end

多个时序过程写同一个寄存器同样有问题。复位、装载、计数和保持应在同一个 always_ff 中按规格排出优先级。

24. 组合环:没有状态边界的反馈

assign a = ~b;
assign b = ~a;

这形成组合反馈环。理想零延迟逻辑无法给出稳定的单一求值顺序,实际电路可能振荡、停在不可预测状态或受到物理延迟支配。

第 11 章的锁存器也有反馈,但它依靠精心设计的交叉耦合结构保存状态,并具有明确控制与库模型。普通 RTL 中意外形成的组合环通常是连接错误。同步反馈应经过寄存器:

always_ff @(posedge clk)
    q <= ~q;

这描述每个时钟边沿翻转一次的触发器状态,不是组合环。

25. 可综合代码与仅用于仿真的代码

综合(synthesis)要把代码转换为有限硬件,因此并非所有仿真语句都能直接实现。

常见可综合基础结构包括:

常见测试平台语句包括:

某些 FPGA 工具支持用 initial 初始化寄存器或存储器,某些 ASIC 流程则不会把它当作通用上电复位。是否可综合以及映射成什么硬件取决于目标技术和工具。基础可移植 RTL 应用明确的接口、复位和初始化方案表达所需行为。

第 20 章会把设计模块与测试平台分开,并解释事件驱动仿真、激励、自检查和断言。

26. x 不是普通的“任意值”

仿真中的 x 表示未知。它可能来自:

看到 x 时应沿信号来源查找原因。不要在普通功能逻辑中用宽松匹配把未知位静默当成 0 或 1。特别是 casex 会把 xz 当作通配符,可能掩盖真实设计错误,基础 RTL 中避免使用。

若规格存在真正的“不关心项”,应在组合化简、明确的 casez 模式或综合约束中谨慎表达,并同时检查仿真行为。设计输出在正常合法输入下应有确定值。

27. 常见误区

误区 1:代码从上到下跨周期运行

模块实例、连续赋值和不同过程并行存在。只有时钟边沿和明确状态决定跨周期推进。

误区 2:logic 就是寄存器

logic 是数据类型。组合驱动得到组合逻辑,边沿过程赋值得到触发器,不完整组合赋值可能得到锁存器。

误区 3:所有 if 都是软件分支

组合 if 通常形成 MUX;时序 if 通常形成寄存器 D 输入前的选择和使能逻辑。所有候选路径都实际存在于硬件中。

误区 4:不写 else 等于输出 0

组合过程不写 else 表示没有规定该路径,常导致锁存器;时序过程中不写对应分支表示寄存器保持。

误区 5:非阻塞赋值只是另一种写法

<= 表达边沿取样与并行提交。它使同一时序过程中的右侧读取旧状态,正好匹配真实触发器。

误区 6:位宽不够时工具会自动保留所有信息

目标位宽不足会截断高位。符号、常量尺寸和中间表达式宽度还会影响扩展方式,关键运算要显式设计位宽。

误区 7:for 循环会自动节省硬件

固定次数循环通常展开为并行硬件。资源共享必须加入寄存器、计数和控制状态,明确用周期换面积。

误区 8:能仿真就是可综合、可实现

仿真只说明模型按语言事件规则运行。综合还要求代码能映射为目标硬件;实现还受时序、面积、功耗、库和物理结构约束。

28. 一套实用的 RTL 阅读方法

面对一个陌生模块,按下列顺序阅读:

  1. 端口契约:输入、输出、位宽、时钟、复位和握手信号是什么?
  2. 状态清单:哪些对象在 always_ff 中更新?它们保存什么历史?
  3. 组合路径:输出和各寄存器 D 输入由哪些表达式、MUX、运算器产生?
  4. 边沿语义:每个寄存器在什么边沿、什么条件下更新?
  5. 优先级:复位、装载、使能、计数同时成立时谁优先?
  6. 完整性:组合输出是否覆盖每条路径?case 是否处理非法编码?
  7. 位宽:加法、移位、拼接、切片和常量是否保留所需信息?
  8. 驱动:每个信号是否只有一个清楚来源?
  9. 接口特例:同址读写、禁用输出、上电未知值如何规定?
  10. 硬件草图:能否画出寄存器、MUX、比较器、运算器和存储器?

若无法画出硬件草图,通常说明代码的时间语义还没有真正读懂。

29. 从规格到 RTL 的推荐顺序

写代码前先完成硬件设计:

  1. 用一句话写清模块任务;
  2. 列端口表和每个端口的周期语义;
  3. 画数据通路与状态元件;
  4. 写寄存器传输或状态表;
  5. 明确复位、使能、优先级和非法输入;
  6. 再选择 assignalways_combalways_ff 和数组;
  7. 手工逐拍检查至少一个正常流程和一个边界流程;
  8. 第 20 章再用测试平台自动验证。

这能避免“先写出能编译的代码,再猜它代表什么硬件”。RTL 是设计结果的精确表达,不是用语法碰运气。

30. 工程中的实际意义

RTL 是逻辑设计、验证、综合和物理实现之间的共同接口:

一行 RTL 可能带来大量硬件。例如 y=a*b 在宽位向量上可能形成乘法器;大数组的复位循环可能阻止存储宏推断;多层条件选择可能形成很深的 MUX 链。代码短不等于面积小或速度快。

因此,基础 RTL 质量包含三层一致:

31. 本章知识链

硬件规格
   ↓
模块与端口:定义边界
   ↓
logic / 位向量 / 数组:定义数据形状
   ↓
assign / always_comb:组合关系
   ↓                  ↘ 完整赋值,避免锁存器
always_ff:边沿状态更新
   ↓                  ↘ 非阻塞赋值,读取旧状态
参数、枚举与层次实例:组织可复用结构
   ↓
存储器与 FSM RTL
   ↓
画出寄存器、MUX、逻辑和阵列
   ↓
第 20 章:用仿真和测试平台验证

32. 本章小结

  1. Verilog/SystemVerilog 描述并行硬件及其时间行为,不是普通顺序程序。
  2. 模块定义硬件边界,实例对应一份实际硬件结构。
  3. logic 是四状态数据类型,是否产生寄存器取决于驱动方式。
  4. 位向量宽度决定硬件位数;截断、扩展和有符号属性必须与规格一致。
  5. assignalways_comb 描述组合逻辑,组合输出必须在所有路径有定义。
  6. 不完整组合赋值会要求保存旧值,从而推断锁存器。
  7. always_ff 描述边沿触发状态;时序过程使用非阻塞赋值。
  8. 非阻塞赋值使同一边沿的右侧读取旧状态,再一起提交新状态。
  9. 同步/异步复位的事件列表不同,分支顺序定义复位、装载和使能优先级。
  10. 参数在设计展开时配置结构,不是运行时输入。
  11. logic [W-1:0] mem [0:D-1] 表示 DW bit 数据字。
  12. 数组、比较、旁路和循环都会映射为具体硬件资源。
  13. 每个普通设计信号应有一个清楚的驱动来源,组合反馈应被主动识别。
  14. 能仿真、能综合和能满足实现约束是三个不同检查层次。

33. 练习

基础题

  1. HDL 中两条独立的 assign 为什么不是按文本顺序逐条执行?
  2. 模块定义与模块实例有什么区别?实例化四次通常意味着多少份硬件?
  3. logic [11:0] data 有多少位?最高位和最低位编号是什么?
  4. 8'h3F8'd638'b0011_1111 是否表示相同位模式?
  5. assign y = sel ? b : a; 通常对应什么组合模块?
  6. always_comb 中为什么通常使用阻塞赋值 =
  7. always_ff @(posedge clk) 表示在什么事件更新状态?
  8. 组合过程中漏写 else 与时序过程中没有赋值分别通常表示什么?
  9. 非阻塞赋值为什么适合多个寄存器在同一边沿更新?

分析与改错题

  1. logic [7:0] y; assign y = 8'hFF + 8'h01;y 是多少?怎样保留进位?

  2. 找出并修正下面代码的组合逻辑问题:

    always_comb begin
        if (a)
            y = b;
    end
  3. 一个 4 选 1 MUX 的 case(sel) 只覆盖 000110,会发生什么?写出一种完整修正。

  4. 边沿前 q0=0q1=1d=1,执行 q0 <= d; q1 <= q0; 后两个寄存器分别是什么?

  5. 解释下面代码为什么不是交换:a = b; b = a;。给出正确时序写法。

  6. 某寄存器过程依次判断 rstloadinc。三者同时为 1 时执行什么?怎样改成 inc 最高优先级?

  7. logic [15:0] mem [0:31]; 的深度、字宽和总容量分别是多少?mem[7][3] 表示什么?

  8. $clog2(100) 是多少?logic [W-1:0] addr 能编码多少种位模式?其中多少个可作为 0~99 地址?

  9. 找出下面代码的问题并改为单驱动:

    assign y = a & b;
    always_comb y = c | d;
  10. for (int i=0; i<16; i++) y[i]=a[i]&b[i]; 通常综合为一个与门运行 16 次,还是 16 个并行与门?若要复用一个与门,需要增加哪些状态结构?

综合题

  1. 写一个 8 bit、带同步高有效复位和使能的寄存器模块,并说明 rsten 同时为 1 时的结果。
  2. always_comb 写一个 8 bit 饱和加法器:和超过 255 时输出 8'hFF,否则输出正常和。
  3. 写一个参数化 W bit 2 选 1 MUX,默认 W=8,并实例化一个 32 bit 版本。
  4. 写一个 16 深度、8 bit 字宽、异步读、同步写的存储器;规定写周期同址读取时通过旁路看到新 wdata
  5. 把第 16 章的三状态 IDLE/RUN/DONE 控制器写成两过程 FSM。要求同步复位,RUNterminal=1 转到 DONEDONE 输出一个周期 done=1 后回到 IDLE
  6. 阅读例题 5 的模 N 计数器。对 N=6,列出 W、合法状态、未使用编码,并逐拍写出复位解除后连续使能 8 个边沿的 count
  7. 为交通信号项目制定 RTL 纸面方案:列出控制器、持续时间寄存器文件、计数器和输出译码模块的端口;说明哪些用 always_comb、哪些用 always_ff;规定复位、配置写入、状态转换与持续时间为 0 时的行为;画出模块连接,并列出至少十项第 20 章要自动检查的场景。

34. 练习答案

展开第 19 章练习答案

题 1

每条连续赋值描述一个持续存在的驱动关系,对应各自的组合逻辑或连线。输入变化会使相关表达式重新求值,各块硬件同时存在;文本先后顺序不产生时钟周期。

题 2

模块定义规定接口和内部实现,是可复用的硬件蓝图。模块实例是在上层层次中放入一份具体硬件并连接端口。实例化四次通常得到四份并行硬件。

题 3

[11:0] 含从 11 到 0 共 12 位。最高位为 data[11],最低位为 data[0]

题 4

三者都表示 8 bit 位模式 0011_1111,数值为十进制 63。进制标记改变书写方式,不改变最终位模式。

题 5

它对应 2 选 1 MUX。sel=0 选择 asel=1 选择 b

题 6

组合过程常按数据依赖顺序计算临时量和输出。阻塞赋值使后续语句立即读到本次过程刚算出的值,适合表达单次组合求值。

题 7

它表示在 clk 的 0→1 上升沿采样右侧和控制条件,并更新所描述的边沿触发状态。

题 8

组合过程漏掉一条路径时,输出若要保留旧值就需要锁存器。时序过程本来只在边沿更新;某个边沿条件下不赋值表示触发器保持原值。

题 9

非阻塞赋值在边沿先读取所有右侧旧状态,再提交所有左侧新状态,匹配多个真实触发器同一边沿并行采样。

题 10

8 bit 输出只保存低位,所以 y=8'h00。保留进位可写:

logic [8:0] y_full;
assign y_full = {1'b0, 8'hFF} + {1'b0, 8'h01};

结果为 9'h100

题 11

a=0y 未赋值,会要求保留旧值并推断锁存器。若规格要求此时输出 0:

always_comb begin
    if (a)
        y = b;
    else
        y = '0;
end

也可先写 y='0;,再在 a=1 时覆盖。

题 12

sel=11 时没有定义输出,会推断锁存器或触发工具检查。完整写法之一:

always_comb begin
    case (sel)
        2'b00:  y = d0;
        2'b01:  y = d1;
        2'b10:  y = d2;
        default:y = d3;
    endcase
end

题 13

两条右侧读取边沿前状态,所以边沿后 q0=1q1=0

题 14

阻塞赋值第一行先把 a 改为旧 b,第二行再读取已经改变的 a,因此两者都变成旧 b。正确时序写法为:

always_ff @(posedge clk) begin
    a <= b;
    b <= a;
end

题 15

if (rst) ... else if (load) ... else if (inc) 中三者同时为 1 时执行复位。若要求 inc 最高优先级,可写:

always_ff @(posedge clk) begin
    if (inc)
        q <= q + 1'b1;
    else if (rst)
        q <= '0;
    else if (load)
        q <= d;
end

这种优先级是否合理必须由规格确认;工程中复位常被置于更高优先级。

题 16

深度 32,字宽 16 bit,总容量为

\[ 32\times16=512\ \text{bit}=64\ \text{byte}. \]

mem[7][3] 是第 7 个数据字的第 3 位。

题 17

因为 \(2^6=64<100\le128=2^7\),所以 $clog2(100)=7。7 bit 地址共有 128 种位模式,其中 100 个对应地址 0~99,28 个未使用。

题 18

y 同时由连续赋值和组合过程驱动,属于多驱动。若 sel 选择两种函数,应合并为单一驱动:

always_comb begin
    if (sel)
        y = a & b;
    else
        y = c | d;
end

题 19

静态 16 次循环通常展开成 16 个并行与门。若要复用一个与门,需要保存输入或当前位、保存部分结果、用计数器选择位,并用 FSM 控制 16 个周期的读取、运算和写回。

题 20

module reg8 (
    input  logic       clk,
    input  logic       rst,
    input  logic       en,
    input  logic [7:0] d,
    output logic [7:0] q
);
    always_ff @(posedge clk) begin
        if (rst)
            q <= '0;
        else if (en)
            q <= d;
    end
endmodule

rsten 同时为 1 时清零,因为复位分支优先。

题 21

logic [8:0] sum;

always_comb begin
    sum = {1'b0, a} + {1'b0, b};
    if (sum[8])
        y = 8'hFF;
    else
        y = sum[7:0];
end

两个 8 bit 无符号数的完整和最多为 510,9 bit 足够。最高位为 1 表示结果超过 255。

题 22

module mux2 #(
    parameter int unsigned W = 8
) (
    input  logic [W-1:0] a,
    input  logic [W-1:0] b,
    input  logic         sel,
    output logic [W-1:0] y
);
    assign y = sel ? b : a;
endmodule

logic [31:0] a32, b32, y32;
logic        sel32;

mux2 #(.W(32)) u_mux32 (
    .a(a32), .b(b32), .sel(sel32), .y(y32)
);

展开后是一组 32 bit 宽的选择逻辑。

题 23

module mem16x8 (
    input  logic       clk,
    input  logic       we,
    input  logic [3:0] raddr,
    input  logic [3:0] waddr,
    input  logic [7:0] wdata,
    output logic [7:0] rdata
);
    logic [7:0] mem [0:15];

    assign rdata = (we && (waddr == raddr))
                 ? wdata
                 : mem[raddr];

    always_ff @(posedge clk) begin
        if (we)
            mem[waddr] <= wdata;
    end
endmodule

比较器检测同址,MUX 在旁路写数据和阵列读数据之间选择。该规格规定写请求有效期间同址读口显示新 wdata

题 24

typedef enum logic [1:0] {IDLE, RUN, DONE} state_t;
state_t state, next_state;

always_ff @(posedge clk) begin
    if (rst)
        state <= IDLE;
    else
        state <= next_state;
end

always_comb begin
    next_state = state;
    count_en   = 1'b0;
    done       = 1'b0;

    case (state)
        IDLE: if (start) next_state = RUN;
        RUN: begin
            count_en = 1'b1;
            if (terminal) next_state = DONE;
        end
        DONE: begin
            done       = 1'b1;
            next_state = IDLE;
        end
        default: next_state = IDLE;
    endcase
end

state 是唯一状态寄存器;其余为当前状态和输入形成的组合逻辑。

题 25

N=6W=$clog2(6)=3。合法状态为 0~5,即 000101110111 未使用。假设复位边沿把计数器置 0,随后解除复位并保持 en=1,连续 8 个上升沿后的序列为:

边沿前:0
边沿 1:1
边沿 2:2
边沿 3:3
边沿 4:4
边沿 5:5
边沿 6:0
边沿 7:1
边沿 8:2

题 26

一种清楚的模块划分如下:

模块 主要端口 RTL 结构
traffic_ctrl clk,rst,start,terminal,state always_ff 保存状态,always_comb 计算下一状态
duration_regs clk,rst,cfg_we,cfg_addr,cfg_wdata,state,duration always_ff 写配置;组合读或同步读需明确
phase_counter clk,rst,clear,en,limit,count,terminal always_ff 保存计数,always_comb/assign 比较终点
light_decode state,main_r/y/g,side_r/y/g 完整 always_comb 输出译码

同步复位把控制状态置 IDLE、计数器清零,并把四项持续时间寄存器装入规定默认值。配置只在 cfg_we=1 的上升沿写入。进入新交通相位时计数清零,随后每个有效工作拍加一;达到当前 duration 后转到下一相位。

持续时间为 0 必须有明确策略。一种安全规格是把 0 解释为 1 个周期,比较时使用 effective_duration=(duration==0)?1:duration;另一种规格是拒绝写入 0 并报告配置错误。二者任选其一并在接口中固定。

连接关系为:配置寄存器根据控制状态输出当前 duration;计数器比较后产生 terminal 返回控制器;控制器输出 state、计数器清零/使能;灯光译码只读取 state

第 20 章至少检查:复位默认灯态;每个相位持续拍数;四种状态顺序;终点边界;计数清零;暂停或使能关闭;运行时配置写入;读取配置;持续时间 0;最大持续时间;复位与配置写同拍优先级;非法状态恢复;输出互锁,确保冲突方向不同时为绿灯。

35. 自测清单

若第 1、4、7 项不稳定,请遮住代码说明,分别重画图 19-1、19-3、19-4;若第 8~10 项不稳定,请重做题 10、16、17;若第 11 项不稳定,请把例题 6 和两过程 FSM 中每个 assignalways_combalways_ff 逐一标为组合路径或状态更新。

36. 下一章衔接

RTL 写清了“希望硬件怎样工作”,还需要系统地证明它在正常、边界和错误输入下确实这样工作。第 20 章将建立事件驱动仿真的时间模型,编写测试平台产生时钟、复位和输入激励,使用任务、检查器与基础断言自动判断结果,并区分设计错误、测试错误和规格缺口。