📝

第四阶段:模块化设计

学习层次化与参数化设计、generate、存储器以及同步、边沿检测、消抖和跨时钟域处理。

返回 FPGA 与 Verilog 学习目录

第四阶段:模块化设计讲义

对应课程目录第 11~13 章。建议学习时间:6~9 天。

本阶段学习目标

完成本阶段后,你应该能够:

  • 用模块边界把一个较大的设计拆成若干职责清晰的子模块
  • 正确使用名称连接和位置连接,并优先选择更易维护的名称连接
  • 使用 parameter 配置模块,使用 localparam 保存模块内部常量
  • 使用 generate forgenerate if 在展开阶段生成重复或可选硬件
  • 区分寄存器、ROM、单端口 RAM 和双端口 RAM 的接口与用途
  • 区分同步读和异步读,理解不同写法对 FPGA 存储资源推断的影响
  • 使用 $readmemh 初始化仿真存储器,并知道上板结果依赖器件与工具
  • 说明亚稳态产生的原因,以及双触发器同步器能解决什么、不能解决什么
  • 实现同步器、边沿检测、按键消抖和单周期脉冲输出
  • 为单比特电平、脉冲和多位数据选择合适的跨时钟域方法
  • 为参数化模块、RAM 和可靠输入电路编写自检式 Testbench

第 11 章:参数化与层次化设计

11.1 为什么要进行模块化设计

前面实现的门电路、寄存器、计数器和状态机都可以作为独立模块。实际 FPGA 工程通常不会把所有逻辑写在一个 module 中,而是按职责拆分:

1
2
3
4
5
6
top
├── clock_enable_gen
├── key_filter
├── control_fsm
├── data_counter
└── display_driver

这种结构中的 top 是顶层模块,其余模块是子模块。模块化设计的主要价值是:

  • 每个模块只负责一件清楚的事情
  • 模块接口可以单独检查和仿真
  • 已验证模块可以在其他工程中复用
  • 参数可以让同一份 RTL 适配不同位宽或规模
  • 综合报告和时序报告更容易按层次定位问题

模块不是软件中的“函数调用”。子模块例化后会成为真实硬件的一部分;同一个模块例化两次,通常就会生成两份并行工作的硬件。

11.2 模块例化

先定义一个 2 选 1 多路选择器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
module mux2 #(
    parameter WIDTH = 8
) (
    input  wire [WIDTH-1:0] data0,
    input  wire [WIDTH-1:0] data1,
    input  wire             select,
    output wire [WIDTH-1:0] data_out
);

assign data_out = select ? data1 : data0;

endmodule

在顶层中可以这样例化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
module mux_top (
    input  wire [7:0] a,
    input  wire [7:0] b,
    input  wire       select,
    output wire [7:0] y
);

mux2 #(
    .WIDTH(8)
) u_mux2 (
    .data0    (a),
    .data1    (b),
    .select   (select),
    .data_out (y)
);

endmodule

这段例化中:

  • mux2 是模块名
  • u_mux2 是实例名,每个实例的名字必须在当前层次内唯一
  • .WIDTH(8) 把参数 WIDTH 设为 8
  • .data0(a) 表示子模块端口 data0 连接到当前模块信号 a
  • 子模块的 data_out 驱动顶层连线 y

例化语句描述的是结构连接,必须写在 alwaysinitial 之外。

11.3 名称连接与位置连接

名称连接把端口名和信号名明确写出:

1
2
3
4
5
6
mux2 u_mux (
    .data0    (a),
    .data1    (b),
    .select   (sel),
    .data_out (y)
);

位置连接依赖模块端口声明的顺序:

1
mux2 u_mux (a, b, sel, y);

两种写法都合法,但本课程优先使用名称连接。位置连接一旦遇到端口顺序调整、同位宽信号互换或端口较多,就很容易接错,而且编译器未必能够发现。

参数也可以按位置覆盖:

1
mux2 #(16) u_mux (...);

更推荐按名称覆盖:

1
2
3
mux2 #(
    .WIDTH(16)
) u_mux (...);

11.4 端口方向与驱动关系

判断一根信号应该声明成 wire 还是 reg,要站在它所在模块的作用域内看驱动方式。

1
2
3
4
5
6
wire [7:0] count_value;

counter u_counter (
    .clk   (clk),
    .count (count_value)
);

虽然 count 在子模块内部可能由 always 块赋值并声明为 output reg,但在顶层中,count_value 是被子模块输出端口驱动的连线,因此声明为 wire

规则可以概括为:

当前模块中的驱动方式 常用声明
assign 左侧 wire
子模块输出连接到本层信号 wire
本模块 alwaysinitial 中赋值 reg
只作为输入被读取 取决于它在本层由谁驱动

不要根据“这个信号最终是否保存数据”孤立判断类型,要看当前作用域中的赋值位置和驱动者。

11.5 parameter:让模块可配置

parameter 是可以在例化时覆盖的常量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
module register_n #(
    parameter WIDTH = 8,
    parameter RESET_VALUE = 0
) (
    input  wire             clk,
    input  wire             reset_n,
    input  wire             enable,
    input  wire [WIDTH-1:0] data_in,
    output reg  [WIDTH-1:0] data_out
);

always @(posedge clk or negedge reset_n) begin
    if (!reset_n)
        data_out <= RESET_VALUE;
    else if (enable)
        data_out <= data_in;
end

endmodule

同一模块可以生成不同位宽的寄存器:

1
2
3
4
5
6
7
8
9
register_n #(
    .WIDTH(8),
    .RESET_VALUE(8'h00)
) u_reg8 (...);

register_n #(
    .WIDTH(16),
    .RESET_VALUE(16'h1234)
) u_reg16 (...);

参数在编译和展开设计层次时确定,不是在电路运行过程中改变。它更像“生成哪一种硬件”的配置,不是可在时钟中写入的寄存器。

参数化设计应满足:

  • 所有相关端口和内部信号的位宽都使用同一个参数
  • 常数要写明位宽,避免截断或符号扩展
  • 对参数的合法范围作出说明
  • 用至少两个不同参数值进行编译和仿真

11.6 localparam:模块内部常量

localparamparameter 都是常量,但 localparam 不能从模块外部覆盖,适合表达实现细节:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
module timer #(
    parameter CLOCK_HZ = 50_000_000,
    parameter PERIOD_MS = 1
) (
    input  wire clk,
    input  wire reset_n,
    output reg  tick
);

localparam integer CYCLES_PER_TICK = CLOCK_HZ / 1000 * PERIOD_MS;
localparam integer COUNT_WIDTH = $clog2(CYCLES_PER_TICK);

reg [COUNT_WIDTH-1:0] count;

// 计数逻辑省略

endmodule

这里:

  • CLOCK_HZPERIOD_MS 是使用者可能需要配置的接口参数
  • CYCLES_PER_TICKCOUNT_WIDTH 是由接口参数推导出的内部常量
  • $clog2(N) 返回容纳 N 个不同取值通常需要的位数

当参数可能等于 1 时,要防止出现零位宽:

1
2
localparam integer COUNT_WIDTH =
    (CYCLES_PER_TICK <= 1) ? 1 : $clog2(CYCLES_PER_TICK);

现代 FPGA 工具通常支持常量表达式中的 $clog2。如果使用较旧的 Verilog 工具,应查看工具支持情况,或使用常量函数计算位宽。

11.7 generate:按参数生成硬件

11.7.1 generate for

下面的例子生成 WIDTH 个异或门:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
module xor_bank #(
    parameter WIDTH = 8
) (
    input  wire [WIDTH-1:0] a,
    input  wire [WIDTH-1:0] b,
    output wire [WIDTH-1:0] y
);

genvar i;
generate
    for (i = 0; i < WIDTH; i = i + 1) begin : gen_xor
        assign y[i] = a[i] ^ b[i];
    end
endgenerate

endmodule

gen_xor 是生成块名字。展开后,层次中会出现类似 gen_xor[0]gen_xor[1] 的实例路径,便于综合和仿真工具定位。

对这个简单异或操作,直接写 assign y = a ^ b; 更简洁。generate for 更适合重复例化复杂子模块,例如多路相同通道、流水级或接口单元。

11.7.2 generate if

可以根据参数决定是否生成某段硬件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
generate
    if (REGISTER_OUTPUT) begin : gen_output_register
        always @(posedge clk) begin
            data_out <= data_comb;
        end
    end
    else begin : gen_output_wire
        always @(*) begin
            data_out = data_comb;
        end
    end
endgenerate

这是展开时选择,参数确定后只有一个分支进入最终设计。它不是运行时的 if 多路选择器。

11.8 forgenerate for 的区别

两者都可能生成重复硬件,但使用位置和目的不同:

写法 所在位置 常见用途
过程式 for always / initial 重复赋值、数组初始化、Testbench 枚举
generate for 模块结构层 重复例化模块、生成连续赋值或独立结构块

下面的过程式循环描述 8 个并行的组合赋值:

1
2
3
4
5
integer k;
always @(*) begin
    for (k = 0; k < 8; k = k + 1)
        y[k] = a[k] & b[k];
end

它不是软件中每次只计算一位的八次循环。边界固定的可综合循环通常会被展开成并行硬件。

11.9 实践:位宽和终值可配置的计数器

要求:

  • WIDTH 配置计数器位宽
  • MAX_VALUE 配置终值
  • enable = 1 时计数
  • 计到 MAX_VALUE 后回到 0
  • 回绕的同一时钟周期输出一个周期的 tick
  • 复位优先级高于计数使能
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
module counter_param #(
    parameter WIDTH = 4,
    parameter [WIDTH-1:0] MAX_VALUE = 4'd9
) (
    input  wire             clk,
    input  wire             reset_n,
    input  wire             enable,
    output reg  [WIDTH-1:0] count,
    output reg              tick
);

always @(posedge clk or negedge reset_n) begin
    if (!reset_n) begin
        count <= {WIDTH{1'b0}};
        tick  <= 1'b0;
    end
    else begin
        tick <= 1'b0;

        if (enable) begin
            if (count == MAX_VALUE) begin
                count <= {WIDTH{1'b0}};
                tick  <= 1'b1;
            end
            else begin
                count <= count + 1'b1;
            end
        end
    end
end

endmodule

这里每个非复位周期先执行 tick <= 1'b0,只有发生回绕时才覆盖成 1。因此 tick 不会在回绕后一直保持高电平。

参数必须满足:

1
2
WIDTH >= 1
0 <= MAX_VALUE <= 2^WIDTH - 1

实例化一个模 10 计数器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
counter_param #(
    .WIDTH(4),
    .MAX_VALUE(4'd9)
) u_counter_mod10 (
    .clk     (clk),
    .reset_n (reset_n),
    .enable  (enable),
    .count   (count),
    .tick    (tick)
);

11.10 参数化计数器自检式 Testbench

Testbench 同时例化模 10 和模 3 两个计数器,验证同一份 RTL 在不同参数下工作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
`timescale 1ns/1ps

module counter_param_tb;

reg clk;
reg reset_n;
reg enable;
wire [3:0] count10;
wire       tick10;
wire [1:0] count3;
wire       tick3;

integer errors;
integer model10;
integer model3;
integer cycle;

counter_param #(
    .WIDTH(4),
    .MAX_VALUE(4'd9)
) dut10 (
    .clk(clk), .reset_n(reset_n), .enable(enable),
    .count(count10), .tick(tick10)
);

counter_param #(
    .WIDTH(2),
    .MAX_VALUE(2'd2)
) dut3 (
    .clk(clk), .reset_n(reset_n), .enable(enable),
    .count(count3), .tick(tick3)
);

initial clk = 1'b0;
always #5 clk = ~clk;

task check_outputs;
    input expected_tick10;
    input expected_tick3;
    begin
        #1;
        if (count10 !== model10[3:0] || tick10 !== expected_tick10) begin
            $display("ERROR mod10: count=%0d tick=%b expected=%0d/%b",
                     count10, tick10, model10, expected_tick10);
            errors = errors + 1;
        end
        if (count3 !== model3[1:0] || tick3 !== expected_tick3) begin
            $display("ERROR mod3: count=%0d tick=%b expected=%0d/%b",
                     count3, tick3, model3, expected_tick3);
            errors = errors + 1;
        end
    end
endtask

initial begin
    errors  = 0;
    model10 = 0;
    model3  = 0;
    reset_n = 1'b0;
    enable  = 1'b0;

    @(negedge clk);
    reset_n = 1'b1;

    // 连续计数,覆盖模 3 和模 10 的多次回绕
    enable = 1'b1;
    for (cycle = 0; cycle < 22; cycle = cycle + 1) begin
        @(posedge clk);

        if (model10 == 9)
            model10 = 0;
        else
            model10 = model10 + 1;

        if (model3 == 2)
            model3 = 0;
        else
            model3 = model3 + 1;

        check_outputs(model10 == 0, model3 == 0);
    end

    // 禁止计数时必须保持,tick 必须为 0
    @(negedge clk);
    enable = 1'b0;
    repeat (3) begin
        @(posedge clk);
        check_outputs(1'b0, 1'b0);
    end

    if (errors == 0)
        $display("counter_param_tb PASS");
    else
        $display("counter_param_tb FAIL: %0d errors", errors);

    $finish;
end

endmodule

注意:这个 Testbench 中 expected_tick 使用“模型更新后是否为 0”判断。因为测试从 0 开始并且每次都使能,所以模型从终值回到 0 时正好对应 tick = 1

11.11 层次化设计的接口原则

一个容易复用的模块通常具有以下特点:

  • 名称能表达功能,而不是当前项目中的临时用途
  • 输入、输出和参数含义明确
  • 时钟和复位方式一致且有说明
  • 不从内部层次直接引用其他模块的信号
  • 不依赖未声明的隐式连线
  • 不把板级引脚名称写进通用功能模块
  • 一个输出只有一个明确驱动者

例如,通用计数器输出 counttick,顶层再决定 tick 是驱动 LED、状态机还是其他计数器。不要把 LED 引脚直接塞进通用计数器接口。

11.12 本章易错点

  1. 把模块例化写进 always 块。模块例化属于静态结构,必须写在过程块外。
  2. 使用位置连接后改了端口顺序,导致同位宽信号悄悄接反。
  3. 只修改输入端口位宽,没有同步修改输出和内部寄存器位宽。
  4. parameter 当作运行时变量。参数在展开硬件时就已确定。
  5. 允许外部覆盖内部状态编码等实现细节。此类常量应使用 localparam
  6. 认为 generate for 会让电路按时钟逐次运行。它生成的是多份并行硬件。
  7. 在顶层把子模块输出连接到本层 reg,又在本层 always 中驱动同一信号,造成多驱动。
  8. 参数改变后只编译默认配置,没有验证边界配置。

11.13 本章练习

  1. 实现参数化寄存器 register_n,参数包括 WIDTHRESET_VALUE,功能优先级为“复位 > 装载 > 保持”。
  2. 用两个 mux2 子模块和一个 mux2 顶层级联成 4 选 1 多路选择器,并画出模块层次。
  3. 使用 generate for 例化 4 个 8 位寄存器通道,每个通道有独立使能。
  4. counter_param 增加同步 loadload_data,规定优先级为“复位 > 装载 > 计数 > 保持”。
  5. 分别以 WIDTH=1, MAX_VALUE=1WIDTH=8, MAX_VALUE=199 编译并测试计数器。

11.14 本章检查点

进入下一章前,你应能回答:

  • 同一个模块例化三次会生成几份硬件?
  • 为什么顶层连接子模块输出的信号通常声明为 wire
  • parameterlocalparam 的区别是什么?
  • generate ifalways 中运行时的 if 有什么本质区别?
  • 参数化模块为什么必须用多个参数配置进行验证?

第 12 章:存储器设计

12.1 寄存器与存储器

寄存器适合保存少量、直接参与当前计算的数据。存储器适合按地址保存较多数据。

一个深度为 16、每个数据 8 位的存储器可以声明为:

1
reg [7:0] memory [0:15];

阅读顺序是:

  • memory 有 16 个元素,地址范围是 0~15
  • 每个元素都是一个 8 位 reg [7:0]
  • memory[3] 表示地址 3 中保存的 8 位数据
  • memory[3][0] 表示地址 3 数据的最低位

这是一维存储器数组,不要与一个 128 位向量混淆:

1
reg [127:0] flat_data;

两者都能保存 128 位,但访问方式和综合工具识别出的结构可能不同。

12.2 ROM 与 RAM

ROM 和 RAM 的课程级区别如下:

类型 运行时读取 运行时写入 常见用途
ROM 可以 通常不可以 查找表、波形表、程序或固定系数
RAM 可以 可以 缓冲、队列、帧数据、临时状态

FPGA 中的“ROM”通常不是不可修改的物理芯片,而是由初始化内容固定、运行时不提供写接口的片上存储资源。重新配置 FPGA 后,ROM 内容也可以改变。

12.3 异步读与同步读

12.3.1 异步读

1
assign read_data = memory[read_addr];

地址变化后,输出经过组合传播延时随之变化,不需要等待时钟边沿。这叫异步读或组合读。

时序关系:

1
read_addr 改变 -> 组合传播延时 -> read_data 改变

12.3.2 同步读

1
2
3
always @(posedge clk) begin
    read_data <= memory[read_addr];
end

地址在时钟边沿被采样,读数据在该边沿后的寄存器更新阶段输出:

1
第 N 个上升沿采样 read_addr -> 第 N 个上升沿后 read_data 更新

从接口使用者角度看,它比地址输入晚一个时钟边沿得到结果。同步读通常更容易推断为 FPGA 的块 RAM,并有利于较高时钟频率。

不要只看代码行数判断读延迟,要看 read_data 是由连续赋值还是时钟过程块更新。

12.4 同步写

RAM 常用同步写:

1
2
3
4
always @(posedge clk) begin
    if (write_enable)
        memory[write_addr] <= write_data;
end

只有在上升沿到来且 write_enable = 1 时,数据才写入指定地址。写地址、写数据和写使能都必须在该边沿附近满足建立时间与保持时间要求。

在时序逻辑中仍然使用非阻塞赋值 <=

12.5 单端口 RAM

“端口”在这里指存储器访问端口,不是 Verilog 模块端口数量。单端口 RAM 每个时钟周期通常通过一组地址、数据和控制信号完成一次访问。

下面是同步写、同步读的单端口 RAM:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
module single_port_ram #(
    parameter DATA_WIDTH = 8,
    parameter ADDR_WIDTH = 4
) (
    input  wire                  clk,
    input  wire                  write_enable,
    input  wire [ADDR_WIDTH-1:0] address,
    input  wire [DATA_WIDTH-1:0] write_data,
    output reg  [DATA_WIDTH-1:0] read_data
);

localparam DEPTH = (1 << ADDR_WIDTH);

reg [DATA_WIDTH-1:0] memory [0:DEPTH-1];

always @(posedge clk) begin
    if (write_enable)
        memory[address] <= write_data;

    read_data <= memory[address];
end

endmodule

这个模块的深度是 2^ADDR_WIDTH。当 ADDR_WIDTH=4 时,共有 16 个地址。

同一时钟边沿对同一地址同时读写时,read_data 究竟得到旧数据、新数据还是保持不变,称为读写冲突行为。上面的 RTL 仿真因为非阻塞赋值会读到旧数据,但具体 FPGA RAM 原语的模式和综合结果还要查看器件与工具文档。设计不能在未确认的情况下依赖冲突结果。

12.6 实践:带独立读写地址的同步单端口 RAM

为了方便验证,实践模块使用一套写接口和一套同步读接口。每周期最多写一个地址,同时读取一个地址:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module sync_ram #(
    parameter DATA_WIDTH = 8,
    parameter ADDR_WIDTH = 4
) (
    input  wire                  clk,
    input  wire                  write_enable,
    input  wire [ADDR_WIDTH-1:0] write_addr,
    input  wire [DATA_WIDTH-1:0] write_data,
    input  wire [ADDR_WIDTH-1:0] read_addr,
    output reg  [DATA_WIDTH-1:0] read_data
);

localparam DEPTH = (1 << ADDR_WIDTH);

reg [DATA_WIDTH-1:0] memory [0:DEPTH-1];

always @(posedge clk) begin
    if (write_enable)
        memory[write_addr] <= write_data;

    read_data <= memory[read_addr];
end

endmodule

这个接口常被称为简单双端口或伪双端口结构,因为读地址和写地址独立。不同厂商对“单端口”“简单双端口”“真双端口”的命名略有差异,应以目标器件文档为准。本讲义重点是理解每周期允许的读写组合,而不是只记名称。

12.7 同步 RAM 自检式 Testbench

Testbench 避开同址同时读写的未规定情形,先写入四个地址,再逐个同步读回:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
`timescale 1ns/1ps

module sync_ram_tb;

reg        clk;
reg        write_enable;
reg  [1:0] write_addr;
reg  [7:0] write_data;
reg  [1:0] read_addr;
wire [7:0] read_data;

reg [7:0] expected [0:3];
integer i;
integer errors;

sync_ram #(
    .DATA_WIDTH(8),
    .ADDR_WIDTH(2)
) dut (
    .clk          (clk),
    .write_enable (write_enable),
    .write_addr   (write_addr),
    .write_data   (write_data),
    .read_addr    (read_addr),
    .read_data    (read_data)
);

initial clk = 1'b0;
always #5 clk = ~clk;

task write_one;
    input [1:0] addr;
    input [7:0] data;
    begin
        @(negedge clk);
        write_enable = 1'b1;
        write_addr   = addr;
        write_data   = data;
        @(posedge clk);
        #1;
        write_enable = 1'b0;
    end
endtask

task read_and_check;
    input [1:0] addr;
    input [7:0] expected_data;
    begin
        @(negedge clk);
        read_addr = addr;
        @(posedge clk);
        #1;
        if (read_data !== expected_data) begin
            $display("ERROR addr=%0d data=%h expected=%h",
                     addr, read_data, expected_data);
            errors = errors + 1;
        end
    end
endtask

initial begin
    errors       = 0;
    write_enable = 1'b0;
    write_addr   = 2'b00;
    write_data   = 8'h00;
    read_addr    = 2'b00;

    expected[0] = 8'h12;
    expected[1] = 8'hA5;
    expected[2] = 8'h3C;
    expected[3] = 8'hF0;

    for (i = 0; i < 4; i = i + 1)
        write_one(i[1:0], expected[i]);

    for (i = 0; i < 4; i = i + 1)
        read_and_check(i[1:0], expected[i]);

    // 写使能为 0 时,写地址和写数据变化不应破坏内容
    @(negedge clk);
    write_enable = 1'b0;
    write_addr   = 2'd2;
    write_data   = 8'h00;
    @(posedge clk);
    #1;
    read_and_check(2'd2, 8'h3C);

    if (errors == 0)
        $display("sync_ram_tb PASS");
    else
        $display("sync_ram_tb FAIL: %0d errors", errors);

    $finish;
end

endmodule

这里在下降沿设置地址和控制信号,在上升沿触发读写,再用 #1 等待非阻塞赋值更新后检查输出。

RAM 没有复位端口是有意的。给大容量存储数组逐项复位可能阻碍块 RAM 推断,或产生大量额外逻辑。如果系统需要“内容有效”的概念,常用单独的有效位、写指针或初始化流程管理,而不是复位整块 RAM。

12.8 ROM 的描述方法

12.8.1 case 描述小型 ROM

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
module hex_to_7seg (
    input  wire [3:0] hex,
    output reg  [6:0] segments
);

always @(*) begin
    case (hex)
        4'h0: segments = 7'b1000000;
        4'h1: segments = 7'b1111001;
        4'h2: segments = 7'b0100100;
        4'h3: segments = 7'b0110000;
        4'h4: segments = 7'b0011001;
        4'h5: segments = 7'b0010010;
        4'h6: segments = 7'b0000010;
        4'h7: segments = 7'b1111000;
        4'h8: segments = 7'b0000000;
        4'h9: segments = 7'b0010000;
        4'hA: segments = 7'b0001000;
        4'hB: segments = 7'b0000011;
        4'hC: segments = 7'b1000110;
        4'hD: segments = 7'b0100001;
        4'hE: segments = 7'b0000110;
        4'hF: segments = 7'b0001110;
        default: segments = 7'b1111111;
    endcase
end

endmodule

这种写法适合很小的查找表,也可能综合成普通组合逻辑。

12.8.2 数组与初始化文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
module waveform_rom #(
    parameter DATA_WIDTH = 8,
    parameter ADDR_WIDTH = 8,
    parameter INIT_FILE = "wave.hex"
) (
    input  wire                  clk,
    input  wire [ADDR_WIDTH-1:0] address,
    output reg  [DATA_WIDTH-1:0] data
);

localparam DEPTH = (1 << ADDR_WIDTH);
reg [DATA_WIDTH-1:0] memory [0:DEPTH-1];

initial begin
    $readmemh(INIT_FILE, memory);
end

always @(posedge clk) begin
    data <= memory[address];
end

endmodule

$readmemh 按十六进制文本读取数据,$readmemb 按二进制文本读取。一个简单的 wave.hex 可以是:

1
2
3
4
5
6
7
8
00
18
30
48
60
78
90
A8

注意文件路径通常相对于仿真器或综合工具的工作目录,而不一定相对于 .v 文件。仿真能成功读取并不自动保证目标 FPGA 的综合流程也会把内容写入片上 ROM;必须查看器件和工具对初始化文件的支持方法。

12.9 双端口 RAM

双端口 RAM 允许两个端口访问同一存储数组。一个简化的同一时钟真双端口结构如下:

1
2
3
4
5
6
7
8
9
always @(posedge clk) begin
    if (we_a)
        memory[addr_a] <= wdata_a;
    rdata_a <= memory[addr_a];

    if (we_b)
        memory[addr_b] <= wdata_b;
    rdata_b <= memory[addr_b];
end

需要特别规定或规避:

  • A、B 端口同时写同一个地址
  • 一个端口写、另一个端口读同一个地址
  • 两个端口是否使用同一时钟
  • 目标块 RAM 支持哪些读写模式

如果两个端口使用不同时钟,存储器同时成为跨时钟域结构。异步 FIFO 会使用双时钟双端口 RAM、格雷码指针和同步器,这比简单地给两个 always 块接不同的时钟复杂得多,第 13 章只介绍选择原则,不在本阶段手写完整异步 FIFO。

12.10 FPGA 片上存储资源

常见实现资源包括:

  • 触发器:适合很小的存储和高速控制状态
  • LUT RAM / Distributed RAM:由查找表构成,适合较浅的小型 RAM
  • Block RAM:容量更大,通常支持同步读、双端口和可配置位宽
  • UltraRAM 或厂商特定大容量 RAM:只在部分器件中存在

RTL 只描述行为,综合工具根据写法、容量、约束和器件资源进行推断。要确认最终使用了什么资源,应查看综合报告,而不是仅凭数组声明猜测。

推断块 RAM 时通常应:

  • 使用厂商推荐的 RAM 模板
  • 保持读写时序与目标 RAM 原语能力一致
  • 避免给整个大数组添加不受支持的复位
  • 明确定义或避开同址读写冲突
  • 在综合报告中确认 RAM 数量、宽度和深度

12.11 存储器容量计算

存储器总位数为:

1
总位数 = 深度 × 每个地址的数据位宽

例如,ADDR_WIDTH=10DATA_WIDTH=16

1
2
深度 = 2^10 = 1024
总容量 = 1024 × 16 = 16384 bit = 2048 byte

地址宽度决定可表达的地址数量。如果实际只使用 1000 个地址,仍可能声明 1024 深度,并在接口或控制逻辑中保证不访问 1000~1023。

12.12 本章易错点

  1. reg [7:0] memory [0:15] 当成单个 128 位向量。
  2. 没有区分同步读与异步读,检查数据时错了一个时钟周期。
  3. 在大 RAM 上增加逐元素复位,导致无法推断块 RAM。
  4. 默认认为同址读写一定得到新数据,忽略目标器件的冲突模式。
  5. 只在 RTL 仿真中验证 $readmemh,没有确认综合工程能找到初始化文件。
  6. 根据“代码写了数组”就断言使用了 Block RAM,没有查看综合报告。
  7. 两个时钟直接访问同一个手写数组,却没有按厂商模板或异步 FIFO 方法处理跨时钟域。
  8. 写 RAM 的 Testbench 在上升沿同时改变输入并采样输出,产生仿真竞争。

12.13 本章练习

  1. 实现 16×8 的异步读、同步写 RAM,并说明读延迟。
  2. 将同一个 RAM 改成同步读,编写 Testbench 证明输出晚一个有效边沿更新。
  3. 实现 32×8 的 ROM,用 $readmemh 读取初始化文件并检查至少 5 个地址。
  4. sync_ram 增加 read_enable;当其为 0 时,规定 read_data 保持不变。
  5. 查阅所用 FPGA 厂商的 RAM 推断模板,对比它与本章代码的复位、读使能和冲突行为。

12.14 本章检查点

进入下一章前,你应能回答:

  • reg [15:0] memory [0:255] 的深度、位宽和总容量分别是多少?
  • 同步读 RAM 的地址在哪个时刻被采样?
  • 为什么不建议复位大容量 RAM 的每一个数据位?
  • 同址读写为什么不能只依据 RTL 直觉决定结果?
  • 如何确认数组最终综合成触发器、LUT RAM 还是 Block RAM?

第 13 章:跨时钟域与可靠设计

13.1 什么是异步输入和跨时钟域

对某个时钟域而言,下列信号通常是异步的:

  • 开发板按键和拨码开关
  • 外部中断或外设状态信号
  • 来自另一个无固定相位关系时钟域的信号
  • 复位的异步释放

即使两个时钟频率相同,只要它们的相位关系没有被设计和时序工具明确约束,也应按异步时钟域处理。

跨时钟域常写作 CDC,即 Clock Domain Crossing。

13.2 建立时间、保持时间与亚稳态

触发器要求输入数据在有效时钟边沿前后的一小段时间内保持稳定:

1
2
3
4
             有效时钟边沿
                  |
data  ------稳定--|--稳定------
          建立时间   保持时间

如果异步输入恰好在采样边沿附近变化,就可能违反建立时间或保持时间。触发器输出可能暂时既不是可靠的 0,也不是可靠的 1,经过一段不可预测的时间后才稳定,这就是亚稳态。

RTL 仿真通常不会真实模拟触发器的模拟亚稳态过程。因此:

  • 仿真通过不代表没有亚稳态风险
  • 亚稳态不是靠 Testbench 等待几个 #1 就能复现的普通逻辑错误
  • 必须在结构上使用合适的同步方案,并配合 CDC 检查和时序约束

任何同步器都不能把亚稳态概率变成绝对零。工程目标是给亚稳态足够的恢复时间,把故障概率降低到可接受水平。

13.3 双触发器同步器

单比特、变化不快的电平信号进入目标时钟域时,常使用两级触发器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
module sync_2ff (
    input  wire clk,
    input  wire reset_n,
    input  wire async_in,
    output wire sync_out
);

reg sync_ff1;
reg sync_ff2;

always @(posedge clk or negedge reset_n) begin
    if (!reset_n) begin
        sync_ff1 <= 1'b0;
        sync_ff2 <= 1'b0;
    end
    else begin
        sync_ff1 <= async_in;
        sync_ff2 <= sync_ff1;
    end
end

assign sync_out = sync_ff2;

endmodule

第一级最可能进入亚稳态,第二级在下一个目标时钟边沿再次采样,给第一级接近一个时钟周期的恢复时间。后续普通逻辑只使用 sync_ff2,不要同时使用 sync_ff1

双触发器同步器适合:

  • 单比特状态或电平
  • 输入保持时间足以跨过目标域至少若干时钟周期
  • 可以接受大约两个目标时钟边沿的同步延迟

它不直接适合:

  • 比目标时钟周期还窄的单周期脉冲
  • 各位可能同时变化的多位二进制总线
  • 要求每个事件都绝不丢失的高速事件流
  • 直接传输大量连续数据

部分综合工具支持类似 ASYNC_REG 的属性,帮助放置工具识别同步器并把两级触发器放得更近。属性语法与厂商有关,应参考所用工具文档。

13.4 为什么不能逐位同步多位总线

假设源时钟域中的计数值从 4'b0111 变为 4'b1000,四个位在硬件中不会绝对同时到达目标域。若每一位独立通过双触发器,目标域可能短暂看到:

1
0111 -> 0000 -> 1000

也可能看到其他混合值。每一位最终都稳定,不代表组合起来的字在任意时刻都有效。

多位数据常见方案包括:

  • 数据保持稳定,同时用单比特 valid 或握手信号通知目标域采样
  • 使用请求/应答握手,保证一次只传一个稳定数据字
  • 连续数据或高吞吐数据使用异步 FIFO
  • 计数指针等特定场景使用格雷码,使相邻值只变化一位

选择方法必须依据吞吐量、允许延迟、是否允许丢事件和两个时钟的关系。

13.5 边沿检测

同步后的电平可以与它的上一拍值比较,产生单周期脉冲:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
module edge_detect (
    input  wire clk,
    input  wire reset_n,
    input  wire signal_in,
    output wire rise_pulse,
    output wire fall_pulse
);

reg signal_d;

always @(posedge clk or negedge reset_n) begin
    if (!reset_n)
        signal_d <= 1'b0;
    else
        signal_d <= signal_in;
end

assign rise_pulse =  signal_in & ~signal_d;
assign fall_pulse = ~signal_in &  signal_d;

endmodule

时序关系:

1
2
3
4
signal_in   ____/‾‾‾‾‾‾‾____
signal_d    ________/‾‾‾‾‾‾‾__
rise_pulse  ____/‾\____________
fall_pulse  ______________/‾\__

rise_pulsefall_pulse 的宽度约为一个时钟周期。异步输入应先同步,再进行边沿检测;不能直接把异步信号送进组合边沿检测逻辑后当作可靠脉冲使用。

13.6 机械按键为什么需要消抖

机械触点按下和松开时会在短时间内多次接触和分离:

1
2
理想按键:  ‾‾‾‾‾‾\____________
实际按键:  ‾‾‾‾‾‾\_/\_/\_______

如果直接做边沿检测,一次按下可能产生多个脉冲。按键输入通常需要两个独立处理:

  1. 同步:降低异步采样带来的亚稳态传播风险
  2. 消抖:要求输入连续稳定足够长时间后才承认状态变化

双触发器同步器不能替代消抖;消抖计数器也不应直接用未同步的异步按键驱动大量逻辑。

13.7 消抖算法

设按键低电平表示按下,消抖后的状态为 key_state

  • 若同步后的输入等于当前确认状态,计数器清零
  • 若两者不同,说明可能发生状态变化,计数器递增
  • 只有连续不同达到 STABLE_CYCLES 个采样周期,才更新确认状态
  • 状态更新为按下时,同时输出一个时钟周期的 key_pressed

如果中途输入弹回原状态,计数清零,之前的候选变化作废。

真实工程中,假设时钟为 50 MHz、希望稳定 10 ms:

1
STABLE_CYCLES = 50,000,000 × 0.010 = 500,000

仿真时不必真的等待 50 万个周期,可以通过参数把 STABLE_CYCLES 改成 3 或 4,以快速覆盖逻辑分支。

13.8 实践:同步、消抖与按下脉冲

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
module key_filter #(
    parameter integer STABLE_CYCLES = 500_000
) (
    input  wire clk,
    input  wire reset_n,
    input  wire key_async,
    output reg  key_state,
    output reg  key_pressed
);

localparam integer COUNT_WIDTH =
    (STABLE_CYCLES <= 1) ? 1 : $clog2(STABLE_CYCLES);

reg key_meta;
reg key_sync;
reg [COUNT_WIDTH-1:0] stable_count;

// 第一级可能亚稳,后续逻辑只使用第二级 key_sync
always @(posedge clk or negedge reset_n) begin
    if (!reset_n) begin
        key_meta <= 1'b1;
        key_sync <= 1'b1;
    end
    else begin
        key_meta <= key_async;
        key_sync <= key_meta;
    end
end

always @(posedge clk or negedge reset_n) begin
    if (!reset_n) begin
        key_state    <= 1'b1;
        key_pressed  <= 1'b0;
        stable_count <= {COUNT_WIDTH{1'b0}};
    end
    else begin
        key_pressed <= 1'b0;

        if (key_sync == key_state) begin
            stable_count <= {COUNT_WIDTH{1'b0}};
        end
        else if (stable_count == STABLE_CYCLES - 1) begin
            key_state    <= key_sync;
            stable_count <= {COUNT_WIDTH{1'b0}};

            if (key_sync == 1'b0)
                key_pressed <= 1'b1;
        end
        else begin
            stable_count <= stable_count + 1'b1;
        end
    end
end

endmodule

该模块规定:

  • key_async = 1 表示释放,key_async = 0 表示按下
  • 复位后认为按键处于释放状态
  • key_state 是消抖后的稳定电平
  • key_pressed 只在确认按下时拉高一个 clk 周期
  • STABLE_CYCLES 必须大于等于 1

计数比较写成 STABLE_CYCLES - 1,意味着检测到第 STABLE_CYCLES 个连续不同的目标时钟边沿时更新状态。

13.9 按键滤波器自检式 Testbench

仿真把稳定周期缩短为 3。测试覆盖短毛刺、带抖动的按下、保持按下、带抖动的释放和第二次按下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
`timescale 1ns/1ps

module key_filter_tb;

reg clk;
reg reset_n;
reg key_async;
wire key_state;
wire key_pressed;

integer errors;
integer pulse_count;

key_filter #(
    .STABLE_CYCLES(3)
) dut (
    .clk         (clk),
    .reset_n     (reset_n),
    .key_async   (key_async),
    .key_state   (key_state),
    .key_pressed (key_pressed)
);

initial clk = 1'b0;
always #5 clk = ~clk;

always @(posedge clk) begin
    #1;
    if (key_pressed)
        pulse_count = pulse_count + 1;
end

task wait_clocks;
    input integer number;
    integer i;
    begin
        for (i = 0; i < number; i = i + 1)
            @(posedge clk);
        #1;
    end
endtask

task expect_state;
    input expected_state;
    begin
        if (key_state !== expected_state) begin
            $display("ERROR state=%b expected=%b at %0t",
                     key_state, expected_state, $time);
            errors = errors + 1;
        end
    end
endtask

initial begin
    errors      = 0;
    pulse_count = 0;
    reset_n     = 1'b0;
    key_async   = 1'b1;

    repeat (2) @(posedge clk);
    @(negedge clk);
    reset_n = 1'b1;
    wait_clocks(3);
    expect_state(1'b1);

    // 短毛刺:低电平不够 3 个同步后周期,不应确认按下
    @(negedge clk); key_async = 1'b0;
    wait_clocks(1);
    @(negedge clk); key_async = 1'b1;
    wait_clocks(5);
    expect_state(1'b1);

    // 按下前发生抖动,最后稳定为低电平
    @(negedge clk); key_async = 1'b0;
    wait_clocks(1);
    @(negedge clk); key_async = 1'b1;
    wait_clocks(1);
    @(negedge clk); key_async = 1'b0;
    wait_clocks(6);
    expect_state(1'b0);

    // 长时间保持按下,不能重复产生脉冲
    wait_clocks(5);
    expect_state(1'b0);

    // 释放时抖动,最后稳定为高电平
    @(negedge clk); key_async = 1'b1;
    wait_clocks(1);
    @(negedge clk); key_async = 1'b0;
    wait_clocks(1);
    @(negedge clk); key_async = 1'b1;
    wait_clocks(6);
    expect_state(1'b1);

    // 第二次稳定按下,应再产生一次脉冲
    @(negedge clk); key_async = 1'b0;
    wait_clocks(6);
    expect_state(1'b0);

    if (pulse_count !== 2) begin
        $display("ERROR pulse_count=%0d expected=2", pulse_count);
        errors = errors + 1;
    end

    if (errors == 0)
        $display("key_filter_tb PASS");
    else
        $display("key_filter_tb FAIL: %0d errors", errors);

    $finish;
end

endmodule

由于输入先经过两级同步器,key_async 变化后,key_sync 不会立即变化。Testbench 不应把消抖周期直接从异步输入变化时刻机械地数起,而应给同步与消抖全过程足够的目标时钟边沿。

13.10 脉冲跨时钟域

源时钟域中的一个周期脉冲可能比目标时钟周期短。如果直接接入双触发器同步器,目标时钟可能在整个脉冲期间都没有采样边沿,事件就会丢失。

常用方案之一是脉冲展宽:让源脉冲保持为电平,直到目标域能够采到。但展宽时间必须根据两个时钟的最坏关系确定,而且连续事件可能合并。

另一种常见方案是翻转同步:源域每发生一个事件就翻转一个位,目标域同步该位并检测变化:

1
2
3
源事件 1:toggle 0 -> 1
源事件 2:toggle 1 -> 0
目标域:同步后的 toggle 与上一拍不同,产生一个周期脉冲

核心结构示意:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 源时钟域
always @(posedge src_clk or negedge src_reset_n) begin
    if (!src_reset_n)
        src_toggle <= 1'b0;
    else if (src_pulse)
        src_toggle <= ~src_toggle;
end

// 目标时钟域:src_toggle 经过两级同步后
always @(posedge dst_clk or negedge dst_reset_n) begin
    if (!dst_reset_n)
        dst_toggle_d <= 1'b0;
    else
        dst_toggle_d <= dst_toggle_sync;
end

assign dst_pulse = dst_toggle_sync ^ dst_toggle_d;

这种方法仍有速率限制:如果源域在目标域来得及观察之前连续翻转两次,目标域看到的值可能回到原状态,两个事件都丢失。要求不丢事件时,应限制事件间隔,或使用带应答的握手、事件计数器或异步 FIFO。

13.11 复位跨时钟域

异步复位可以不等待时钟立即生效,但释放复位如果靠近时钟边沿,也可能违反触发器的恢复/移除时间,并导致同一时钟域内的寄存器在不同周期退出复位。

常用原则是:

1
2
异步拉低,立即进入复位
同步释放,按各自时钟域整齐退出复位

每个时钟域应有自己的复位同步释放链。不要把某个时钟域同步后的复位直接当作所有其他时钟域的同步复位。

下面是低有效复位同步释放的基本结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
reg [1:0] reset_sync;

always @(posedge clk or negedge async_reset_n) begin
    if (!async_reset_n)
        reset_sync <= 2'b00;
    else
        reset_sync <= {reset_sync[0], 1'b1};
end

assign reset_n = reset_sync[1];

实际项目还应遵循 FPGA 厂商关于复位网络、同步器属性和时序约束的建议。

13.12 CDC 方法选择表

需要传输的内容 常用方法 关键限制
单比特慢变化电平 两级触发器同步器 可接受同步延迟,电平保持足够久
异步机械按键 两级同步 + 消抖 消抖时间按时钟频率计算
单比特边沿事件 先同步电平,再在目标域边沿检测 源电平必须保持到能被采样
窄脉冲事件 展宽、翻转同步或握手 简单方案有事件间隔限制
偶发多位数据 数据保持 + 请求/应答握手 数据在握手期间必须稳定
连续多位数据流 异步 FIFO 正确处理格雷码指针和双时钟 RAM
计数或指针 特定设计中的格雷码 接收端仍需同步,不能任意替代握手

没有一种同步器适合所有 CDC。先把信号分类,再决定结构。

13.13 约束与工具检查

RTL 结构正确只是 CDC 设计的一部分。工程中还应:

  • 正确声明异步时钟组或跨域时序例外
  • 对同步器寄存器使用厂商推荐的属性
  • 运行静态 CDC 检查,查看未同步和重汇聚问题
  • 查看同步器是否被优化、复制或放置得过远
  • 检查异步 FIFO 等成熟 IP 的参数与复位要求

时序约束不是把违规路径“隐藏掉”。错误地把所有跨域路径设成 false path,会让工具停止检查,却不会让硬件自动可靠。

13.14 本章易错点

  1. 认为 RTL 仿真没有 x 就不存在亚稳态风险。
  2. 双触发器同步后又使用第一级输出驱动其他逻辑。
  3. 对多位二进制总线逐位同步,忽略各位到达时间不一致。
  4. 用双触发器直接采样窄脉冲,没有检查脉冲是否可能完全落在两个目标边沿之间。
  5. 先对异步按键做组合边沿检测,再把生成的窄脉冲送进同步器。
  6. 把同步器当成消抖器,一次按键仍产生多个有效边沿。
  7. 消抖计数值没有根据输入时钟频率换算。
  8. 认为翻转同步可以无限快传事件,忽略目标域的观察速度。
  9. 异步复位直接释放到大量寄存器,没有考虑同步释放。
  10. 用时序例外掩盖不正确的 CDC 结构。

13.15 本章练习

  1. 实现一个高电平有效的 sync_2ff,并说明为何 Testbench 不能证明亚稳态已经被“仿真解决”。
  2. 实现同步信号的上升沿和下降沿检测器,验证每次边沿只输出一个周期脉冲。
  3. key_filter 改成高电平表示按下,并更新复位值和按下脉冲条件。
  4. 时钟为 100 MHz、消抖时间要求 20 ms,计算 STABLE_CYCLES 和计数器最小位宽。
  5. 画出请求/应答握手传输一个 8 位数据字的时序步骤,标明数据必须保持稳定的区间。
  6. 比较双触发器、翻转同步、握手和异步 FIFO 的延迟、吞吐量与是否可能丢事件。

13.16 本章检查点

完成本章后,你应能回答:

  • 亚稳态由什么条件触发?为什么普通 RTL 仿真通常看不到?
  • 双触发器同步器中的第一级和第二级分别起什么作用?
  • 为什么不能把一个多位计数值的每一位独立同步后直接使用?
  • 按键同步与按键消抖分别解决什么问题?
  • 窄脉冲为什么可能被目标时钟域漏采?
  • 哪些情况应该从简单同步器升级为握手或异步 FIFO?

第四阶段综合练习

练习 A:四通道参数化事件计数器

设计一个顶层模块,使用 generate for 例化 4 个 counter_param

  • 每个通道有独立的 enable
  • 所有通道共享时钟和复位
  • 位宽参数为 8,终值参数为 99
  • 顶层输出四路计数值和四路 tick
  • Testbench 同时制造不同通道使能模式,检查每路互不干扰

重点检查模块数组连接、生成块命名和参数覆盖。

练习 B:同步 RAM 数据校验器

设计一个控制模块,向 16×8 同步 RAM 写入:

1
memory[address] = address + 8'h20

写完后逐地址读回并比较,最终输出:

  • done:校验结束,保持为 1
  • pass:所有地址都正确时为 1
  • error_count:错误数量

控制器可使用状态机。必须正确处理同步读的一拍延迟,不能在给出读地址的同一边沿立刻比较旧的 read_data

练习 C:可靠按键控制计数器

搭建以下层次:

1
2
3
key_counter_top
├── key_filter
└── counter_param

要求:

  • 外部按键低电平有效
  • 每次确认按下只让计数器增加一次
  • 按住不放不能连续计数
  • 抖动不能造成多次计数
  • 计数器达到 9 后回到 0
  • Testbench 使用较小消抖参数,覆盖两次带抖动按下和一次短毛刺

这个练习把层次化设计、参数、异步输入同步、消抖和时序计数连接在一起,是本阶段最重要的综合练习。


参考答案与实现提示

第 11 章练习要点

参数化寄存器的核心:

1
2
3
4
5
6
always @(posedge clk or negedge reset_n) begin
    if (!reset_n)
        data_out <= RESET_VALUE;
    else if (load)
        data_out <= data_in;
end

4 选 1 多路选择器可以先用两个实例选择 data0/data1data2/data3,再用第三个实例选择两路中间结果。若 select[0] 用于第一级、select[1] 用于第二级,应在 Testbench 中枚举 select=0~3 验证连接顺序。

带装载计数器应把优先级明确写在同一个时序过程块中:

1
2
3
4
5
6
if (!reset_n)
    ...
else if (load)
    ...
else if (enable)
    ...

装载终值时是否立即产生 tick 必须由接口规格决定。本练习建议规定“装载不产生 tick,只有使能计数引起的回绕产生 tick”。

第 12 章练习要点

异步读、同步写 RAM 的核心:

1
2
3
4
5
6
always @(posedge clk) begin
    if (write_enable)
        memory[write_addr] <= write_data;
end

assign read_data = memory[read_addr];

加入同步 read_enable 时:

1
2
3
4
5
6
always @(posedge clk) begin
    if (write_enable)
        memory[write_addr] <= write_data;
    if (read_enable)
        read_data <= memory[read_addr];
end

没有 else 表示 read_enable=0 时输出寄存器保持原值,这在时序逻辑中是合法的寄存器保持,不是组合逻辑锁存器。

RAM 校验状态机至少需要区分:写地址阶段、发出读地址阶段、等待同步读结果阶段、比较阶段和完成阶段。也可以使用流水方式每周期发出一个地址并比较上一个地址,但必须额外保存“当前 read_data 对应哪个地址”。

第 13 章练习要点

100 MHz 时钟下 20 ms 消抖:

1
STABLE_CYCLES = 100,000,000 × 0.020 = 2,000,000

因为:

1
2
2^20 = 1,048,576 < 2,000,000
2^21 = 2,097,152 >= 2,000,000

所以最小计数器位宽为 21 位。

请求/应答握手的基本顺序:

  1. 源域把数据放到数据总线上并保持稳定
  2. 源域翻转或拉高请求信号
  3. 目标域同步请求,采样已稳定的数据
  4. 目标域返回应答
  5. 源域同步应答后才允许撤销数据并开始下一次传输

具体协议必须规定请求和应答是电平、脉冲还是翻转编码,以及复位后双方的初始状态。


第四阶段完成标准

只有同时满足以下条件,才建议进入第五阶段:

知识标准

  • 能从顶层到子模块说明端口、内部信号和驱动关系
  • 能解释参数在设计展开时起作用,而不是运行时寄存器
  • 能区分 parameterlocalparam、过程式 forgenerate for
  • 能根据接口代码判断 RAM 是同步读还是异步读
  • 能计算存储器深度、数据宽度和总容量
  • 能说明块 RAM 推断为什么依赖写法和目标工具
  • 能说明亚稳态、两级同步、按键消抖和边沿检测各自解决的问题
  • 能按电平、脉冲、多位控制字和连续数据流分类选择 CDC 方法

编码标准

  • 独立实现并验证一个至少支持两组参数配置的参数化模块
  • 独立实现并验证一个同步读写 RAM
  • Testbench 能正确处理同步 RAM 的一拍读延迟
  • 独立实现两级同步器和单周期边沿检测器
  • 独立实现参数化按键消抖,并保证每次确认按下只产生一个脉冲
  • 模块例化优先使用名称连接,所有生成块具有清晰名称
  • 不使用未声明的隐式连线,不制造多驱动输出

验证标准

  • 参数化计数器 Testbench 覆盖回绕、保持和至少两组参数
  • RAM Testbench 覆盖多个地址、写禁止和同步读延迟
  • 按键 Testbench 覆盖短毛刺、按下抖动、长按和释放抖动
  • 所有自检 Testbench 使用 !==、错误计数和最终 PASS/FAIL
  • 输入在有效时钟边沿前驱动,输出在非阻塞赋值更新后检查
  • 能说明 RTL 仿真的边界:它可以验证逻辑功能,但不能模拟真实亚稳态概率,也不能替代综合资源报告、时序分析和 CDC 检查

完成本阶段后,你已经能够把单个 RTL 练习组织成具有层次、存储和可靠外部接口的小型系统。第五阶段将进一步学习系统化 Testbench、综合、时序约束和 FPGA 上板流程。

comments powered by Disqus