HDLBits刷题笔记和一些拓展知识(十一)

发布于:2025-07-09 ⋅ 阅读:(15) ⋅ 点赞:(0)

HDLBits刷题笔记

以下是在做HDLBits时的一些刷题笔记,截取一些重要内容并加上自己的理解,方便以后翻阅查找

Verification: Reading Simulations

从这一章开始就是仿真的内容了。FPGA的仿真和设计是同等重要的,尤其在大型的FPGA项目中仿真的作用额外突出。如果说FPGA的设计是创造电路的过程,那么FPGA仿真就是验证这个被创造的电路是否正确可行。

为什么需要仿真呢?其实我们在设计完RTL代码后完全可以直接烧录到硬件中观察结果,为什么我们还需要辛辛苦苦学仿真?我总结一下那就是仿真有两个特别大的好处。第一个优点就是debug很灵活,有过项目经验的应该感受到,如果使用硬件进行debug往往需要借助ila(内部逻辑分析仪)。ila其实就是一个用来抓取数据的工具,但如果你想要抓取不同模块下的信号,往往需要多个ila,而一个FPGA硬件中ila的资源是有限的。如果你想抓取很多个信号的数据,就需要多次设置不同的ila,每更改一次ila的位置,就需要重新综合、布局布线、生成比特流,再加上烧录程序,这一过程非常耗时。 而仿真则是由软件实现,每次仿真环境下的所有寄存器变量都可以观察到,并且你可以随时修改仿真或者设计文件,保存修改后再次运行新的仿真就好,明显比ila灵活地多。

仿真的第二个优点就是具有经济效应。在功能仿真阶段发现一个bug,可能只需要修改几行代码。如果等到上板调试时才发现,定位问题的难度和花费的时间将成倍增加,因为在物理硬件上,你无法像仿真一样看到所有内部信号的状态。更不要说在大规模IC设计中,想要流片一次成本很大,根本不可能让你通过硬件debug,仿真就是唯一的出路。

当然仿真也有一些缺点。相比于直接在硬件上运行的bit流,通过软件运行的仿真是比较慢的,尤其是复杂的仿真可能就会跑几个小时甚至几天。(区分之前我说ila硬件debug的耗时和bit流在硬件上运行时的高效,二者不矛盾。)当然,仿真的速率也完全会受到电脑性能的影响。其次,仿真也会遇到之前常说的“仿真综合不匹配”的问题。也就是说可能仿真能通过的,实际在硬件上运行可能出问题。这就对设计和验证人员的代码能力提出了要求,特别是一些大型项目中这样的问题就会变得突出。当然,也不必太过担心,因为在一些中小项目中,只要代码符合规范,基本是不会出现这样的问题的。

接下来就是一个FPGA项目的通用设计流程,如图所示:

对于简单的低速项目我们可以先忽略时序仿真部分,此时项目主要流程就是设计代码->功能仿真(通不过仿真就修改设计)->通过后综合、布局布线、生成比特流。

一般来说,我们把用来测试的设计模块称为DUT(Design Under Test,待测设计);而把除了DUT之外所有用来搭建一个仿真环境的HDL代码叫做Testbench(测试环境,简写为tb)。

Bugs mux2

这一部分题目告诉我们对于简单的设计问题,例如一些变量名写错、数据位宽写错、赋值类型搞错等粗心错误最好在写代码时就避免。也就是说在练习我们直接通过观察设计代码找到问题的能力。

这一题有两个问题,第一个是out的数据位宽错误;第二个是sel信号和向量进行了按位与操作会导致向量被截短。答案如下:

这一题其实也是有一点问题,那就是assign out = (~sel & a) | (sel & b);表达的本意应该是sel为1时选b,否则选a,理应写成assign out = sel ? b : a;但答案的波形正好相反。这一点注意一下就好。

Bugs nand3

这一题的问题也是两个:首先需要实现的是与非门,out信号在输出前需要取反;其次是对于隐式例化需要严格按照原模块的接口顺序对接口赋值,最终答案如下:

Bugs mux4

本题的问题主要有这么几个:首先,mux0和mux1的位宽都是1位,我们应该改成一个字节位宽;其次,wire类型的mux0和例化中实例化名称都使用了mux0,这是两个相同的标识符,会造成冲突;第三个是逻辑错误,按照原来代码我们假设mux2在sel=1时选择a,那么在例化中无论sel[1]被赋值1还是0,c都无法被选中。修改后答案如下:

Bugs addsubz

本题的问题有两个:第一个是if的out使用了按位取反~,我们应该使用逻辑非!(例如8’hff是一个正数,但错误写法会认为这是零);其次就是写case没有default,写if没有else,所以很容易生成锁存器,修改方法有两个。第一个是补充上default和else分支:

或者赋初值:

Bugs case

这一题有三个问题,第一个是valid的上电复位不能写在端口命名里,需要单列一行语句;然后是always块里面有多条语句,缺少了begin-end包裹;其次是组合always块依然是会生成锁存器(见之前锁存器的笔记),所以必须把每个分支补充完整;最后是case的有一个分支写成了6位宽,有一个写成了十进制,我们需要统一成8’hxx类型:

当然使用赋初值的方式也可以,答案刚好是示例:

Sim/circuit1

这部分的题目需要我们根据波形构建电路,乍一看是设计的题目,怎么会出现在验证中呢?必须说明的是读懂波形并根据波形写代码(设计或者验证)的能力是FPGA工程师的核心技能之一。对于设计,很多时候我们需要一个模块并不是直接开始就用Verilog代码写的,往往是根据模块要实现的功能先画出简单波形,然后再根据波形利用组合逻辑、时序逻辑或者状态机实现。也就是说在设计中,画波形是一个非常常见的预备工作,而波形就是写代码、设计状态机的唯一根本依据。 我们写的任何代码,都不能与预先画好的波形文件冲突。

其次再说验证模块。我们先思考一个问题:如果说testbench是在验证设计文件,那我们怎么知道我们的tb是正确的呢?感觉有些套娃,即我们 如何验证 验证文件(即仿真代码) 的合理性。答案还是波形,波形是唯一的真理!我们也是根据波形来设计我们的验证代码,然后就可以进行仿真,只要仿真出的激励波形与预先画好的波形所完成功能一致,那就说明了验证模块的正确性。

再强调一遍,读懂波形并根据波形写代码的能力对于FPGA工程师(无论是设计还是验证)十分重要。波形是唯一的真理

好了,说明了波形的重要性,我们回归题目。通过观察波形我们知道q仅在ab同时为1时置为1,答案如下:

Sim/circuit2

这一题四个变量,直接观察不出什么规律,我的回答是画卡诺图顺带化简:

但是显然这个卡诺图虽然规律性很强,但是却无法化简,同过观察卡诺图我们可以发现q会在ab相同且cd相同以及ab不同且cd不同时输出1,于是得到答案:

Sim/circuit3

老样子先画卡诺图并化简:

随后直接写出答案:

Sim/circuit4

先画出卡诺图并化简:

随后是答案:

Sim/circuit5

这一题乍一看像是个时序电路,但是题目要求我们只能使用组合逻辑。组合电路主要就是门电路和选择器,这一题显然不是门电路实现,所以需要我们向选择器出发。观察得知,能作为选择端口功能的只有c,因为q的输出变化和c的变化节奏相同,于是我们得到如下答案:

Sim/circuit6

这一题老样子,使用选择器解决:

Sim/circuit7

到时序电路了,这是一个典型的同步复位信号,a就是reset,答案如下:

Sim/circuit8

这一题的q比较简单,显然就是一个clk下降沿触发用来寄存a数据的寄存器;但是p稍微复杂一点,描述波形就是p在clock=1时会跟随a的值(组合逻辑的功能),在clock=0时会寄存当前的值(时序逻辑功能),而想要实现这种既有组合逻辑又有时序逻辑的功能一般就是引入锁存器,于是答案如下:

Sim/circuit9

观察波形可知,这是一个同步复位到4的0-6计数器,于是答案如下:

Sim/circuit10

这一题的题干说明了本题只有一个触发器并且这个触发器就是state,所以state一定是时序逻辑的。我们再确定一下q的类型,q在一开始b刚刚改变(第40个时间单位)的时候就同时改变,所以q只能是组合逻辑,否则q一定输出会慢一拍。那么我们在看一下,q收到几个变量的影响呢?从第80到第110个时间单位可以看出q一定不可能只受到a和b的影响,不然这个期间ab不变,q也一定不变,所以q受到a、b和state三个变量的影响。我们先画出q输出的卡诺图:

显然化简不了,观察得知q在state为1且ab相同或者state为0且ab不同时为1,这样q就解决了。那么state呢?state不可能只受到ab两个变量影响,例如50-60时ab为10,下一个时钟周期state为0;而在110-120时ab也为10,下一个时钟周期,state为1。所以state受到a、b和q三个变量的影响。同样画一下时序逻辑下的卡诺图:

于是得到答案:

Tb/clock

这一题以及之后的内容是有关写仿真代码的,这里记一些笔记:
验证代码的编写有简单与复杂之分,最简单的testbench可以仅仅由一个文件构成;而对于大型工程,tb为一个高度模块化、可复用的复杂验证环境,由非常多不同的文件组成,其文件管理非常像大型C语言项目。我们还是从简单的结构出发,先介绍一下单一tb文件的通用组成:

  • timescale定义:指定仿真时间单位和精度。

  • 顶层模块(Test-Top):Testbench本身是一个没有输入输出端口的顶层模块。

  • 信号声明:reg:用于驱动DUT(待测设计)的输入信号;wire:用于连接和观察DUT的输出信号。

  • DUT例化:将你要测试的模块(DUT)在Testbench中例化,并将其端口连接到Testbench内部声明的reg和wire上。

  • 时钟和复位生成器 (Clock and Reset Generator):通常使用always块来生成周期性的时钟信号;使用initial块来生成初始的复位信号。这是所有同步电路测试的基础。

  • 激励生成器 (Stimulus Generator):这是Testbench的核心。通常在initial块中编写,按照时间顺序,通过延时(#)来控制reg变量的变化,从而为DUT提供一系列的测试输入(测试向量)。

  • 响应监视器和结果显示 (Response Monitor and Display):使用Verilog的系统任务(System Tasks)来观察和记录结果。常用的有:

    • $monitor:持续监视信号,当信号变化时打印其值。

    • $display:在代码执行到某处时,打印一次信息。

    • $finish:在测试序列结束后,调用此任务来结束仿真。

    • $dumpfile / $dumpvars:用于生成波形文件,以便在波形查看器中进行可视化分析。

例如如下的简单设计文件:

/*时序与门电路*/
module sequential_and (
    input               clk,    // 时钟输入
    input               rst_n,  // 异步复位,低电平有效
    input               a,      
    input               b,      
    output reg          out_q   // 寄存器输出 (a & b)
);

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            out_q <= 1'b0;
        end else begin
            out_q <= a & b;
        end
    end

endmodule

tb文件如下:

/*仿真文件*/
`timescale 1ns / 1ps  //仿真时间精度
module tb_sequential_and; //注意没有输入输出端口

    // -- 1. 信号声明 --
    // 驱动DUT的信号声明为 reg
    reg tb_clk;
    reg tb_rst_n;
    reg tb_a;
    reg tb_b;

    // 从DUT接收的信号声明为 wire
    wire tb_out_q;

    // -- 2. 例化待测设计 (DUT) --
    sequential_and u_dut (
        .clk    (tb_clk),
        .rst_n  (tb_rst_n),
        .a      (tb_a),
        .b      (tb_b),
        .out_q  (tb_out_q)
    );

    // -- 3. 时钟生成器 --
    // 定义时钟周期为10ns (频率100MHz)
    localparam CLK_PERIOD = 10;
    initial begin
        tb_clk = 0;
        // forever 语句会永久执行,每隔半个周期翻转一次时钟电平
        forever #(CLK_PERIOD / 2) tb_clk = ~tb_clk;
    end

    // -- 4. 激励生成与测试流程 --
    initial begin
        // 初始化所有输入
        tb_rst_n = 1'b1; // 先释放复位
        tb_a     = 1'b0;
        tb_b     = 1'b0;
        
        // -- 步骤 1: 复位测试 --
        $display("【Test】Starting simulation. Applying reset...");
        tb_rst_n = 1'b0; // 激活复位
        #20;             // 保持20ns的复位
        tb_rst_n = 1'b1; // 释放复位
        #5;              // 等待一下,确保在时钟沿之前稳定

        // -- 步骤 2: 测试所有输入组合 --
        $display("【Test】Testing input combination: a=0, b=0");
        tb_a = 1'b0;
        tb_b = 1'b0;
        #(CLK_PERIOD); // 等待一个时钟周期

        $display("【Test】Testing input combination: a=0, b=1");
        tb_a = 1'b0;
        tb_b = 1'b1;
        #(CLK_PERIOD); // 等待一个时钟周期

        $display("【Test】Testing input combination: a=1, b=0");
        tb_a = 1'b1;
        tb_b = 1'b0;
        #(CLK_PERIOD); // 等待一个时钟周期

        $display("【Test】Testing input combination: a=1, b=1");
        tb_a = 1'b1;
        tb_b = 1'b1;
        #(CLK_PERIOD); // 等待一个时钟周期

        // 输入在时钟边沿附近变化,观察输出是否保持稳定
        $display("【Test】Testing input changes near clock edge");
        tb_a = 1'b0;
        tb_b = 1'b0;
        #(CLK_PERIOD);

        // -- 步骤 5: 结束仿真 --
        $display("【Test】Test finished.");
        #20;             // 再额外运行一段时间以观察最终状态
        $finish;         // 结束仿真
    end
    
    // -- 5. 结果监视器 --
    // 在仿真开始时,打印一次表头
    initial begin
        $display("--------------------------------------------------");
        $display(" Time(ns) | rst_n |  a  |  b  | out_q (DUT Output)");
        $display("--------------------------------------------------");
    end
    
    // 每当任何被监视的信号发生变化时,打印当前时间和所有信号的值
    always @(*) begin
        $monitor(" %8t |   %b   |  %b  |  %b  |        %b", $time, tb_rst_n, tb_a, tb_b, tb_out_q);
    end

endmodule

这种结构的优点是简单直接,易于上手。缺点是当测试变得复杂时,所有逻辑都混在一个文件里,会变得难以维护、扩展和复用。对于初学者以及一些简单的项目,这样的仿真文件足以对付,但是如果想要更进一步学习现代仿真技巧,可以单独去学UVM (Universal Verification Methodology),这又是一个比较专业的领域了,这里就不在介绍。

好了回归这个题目。本题希望我们生成一个时钟生成器,写法还是比较公式的,主要是使用initial+forever 或者直接使用always两种写法。如下:

localparam P_CLK_PERIOD = 20; //时钟周期个时间单位
reg     clk_0,clk_1;

//使用initial块
initial begin
    clk_0 = 0; // 初始化时钟为 0
    forever begin
        #(P_CLK_PERIOD / 2) clk_0 = ~clk_0; // 每隔半个周期,将时钟翻转
    end
end

//使用仿真always块
always begin //不可综合
    clk_1 = 0; 
    #(P_CLK_PERIOD/2);
    clk_1 = 1;
    #(P_CLK_PERIOD/2);
end

首先说一说forever,他是不可综合的,只能用于仿真,一般就是写在initial里面用来不断循环执行其中的语句;其次是always(注意这个always没有后头的@和敏感变量表),可以理解为仿真中的always@(*),就是不断循环执行其中的代码,同样不可综合。

补充一下仿真的时间精度,一般就是使用如下语法并放在仿真文件开头:

`timescale 1ns / 1ps  //1ns代表仿真时间单位,1ps代表仿真显示的精度

如果你在仿真中不写timescale,一般默认仿真的时间单位为1ns。

给出这一题的答案,需要注意的是时间精度是1ps:

Tb/tb1

这一题希望你写一个激励文件,一般就是写在initial里。initial里的语句可以包含延时,语法为“# 延时时间”,所以本题答案如下:

当然本题也可以使用非阻塞赋值,答案如下:

关于initial块中=和<=的区别我在之前的笔记中有记过。可以简单理解为仿真中的阻塞赋值=会在赋值时立刻生效,而非阻塞赋值<=不会立刻生效,会在下一个时钟之前生效。二者的根本区别就是语句执行到时,在这一刻,阻塞赋值立即有效,非阻塞赋值无效。 由于本题没有时钟参与,所以二者等效。

Tb/and

本题直接对着波形写就好,答案如下:

本题同样没有时钟参与,所以也可以使用非阻塞赋值。

Tb/tb2

本题有时钟参与,所以最好在使用延时模块后用非阻塞赋值,进而确保时钟对齐,答案如下:

本题使用阻塞赋值也可以通过,这是因为我们激励的改变都在时钟下降沿,刚好避开了上升沿。

Tb/tff

这一题也由于有时钟参与,建议在使用延时模块后用非阻塞赋值确保时钟对齐,答案如下:

如果本题第14和15行改为阻塞赋值就会报错,这是因为阻塞赋值和时钟上升沿触发发生了冲突,没有做到时钟对齐,所以只能使用非阻塞赋值。

完结撒花

到这里所有HDLBits的题目就都写完了,包括了Verilog的主要核心知识点,从最开始的变量类型到状态机再到简单的仿真。真的学到了非常多的东西,好耶!!!我看官网后面还有4道综合题目,由于时间关系我就先去学习别的东西了,以后要是想做就再写一个刷题笔记的附录放进去。

终点亦是起点,FPGA长路漫漫。


网站公告

今日签到

点亮在社区的每一天
去签到