【准研一速通】从零到Testbench:我的Verilog实战入门笔记

📅 发布时间:2026/7/15 3:07:15 👁️ 浏览次数:
【准研一速通】从零到Testbench:我的Verilog实战入门笔记
1. 写在前面一个准研一的“求生”之路大家好我是一个即将踏入研究生大门的准研一学生专业方向是硬件设计。和很多同学一样我的本科背景是计算机对C语言、数据结构这些软件层面的东西还算熟悉但一提到电路、信号、FPGA这些硬件词汇脑子里就只剩下“学过但忘了”的尴尬。接到导师任务要求快速上手Verilog并完成一个小设计时我的内心是崩溃的。面对全新的领域那种从零开始的茫然感相信很多人都懂。我花了大概两周时间集中精力“狂肝”目标非常明确不是成为Verilog理论大师而是快速打通从语法到仿真验证的完整路径实现“学完就能用”。这篇文章就是我这段“求生”经历的实战笔记。我不会罗列所有枯燥的语法细节那是手册该做的事而是聚焦于如何将一个个抽象的语法点串联成一个能跑起来、能看见波形的具体设计。我的学习路径很简单理解基础概念 - 动手写代码 - 用Testbench验证 - 分析结果。整个过程就像搭积木每一块都必须亲手拼上去才知道它到底稳不稳。我使用的工具主要是Vivado虽然启动慢点但生态好和Modelsim仿真速度快。学习资源方面我强烈推荐两个网站HDLBits和Verilog OJ中科大。前者像是一个闯关游戏题目由浅入深强迫你用最简洁的代码实现功能后者则提供了更贴近中文学习习惯的引导和题目。我的方法就是看完一个语法点立刻去这两个网站上找对应的题目练习把代码写出来、仿真通过这个知识点才算真正“入库”。下面我就把我这条“速通”路线分享出来希望能帮到和我一样起点、同样焦虑的你。我们直接从最核心的“模块”开始。2. 核心基石理解Verilog的“乐高积木”——模块如果把用Verilog设计数字电路比作搭乐高那么模块Module就是一块块最基本的积木。这是你学习Verilog时第一个必须刻在脑子里的概念。它不仅仅是语法更是一种设计思想。2.1 模块是什么你的第一个“黑盒子”你可以把一个模块想象成一个封装好的“黑盒子”。这个盒子有若干输入端口、若干输出端口盒子内部实现了某种特定的功能比如加法、比较、数据选择。作为设计者你的主要工作就是两件事1. 定义这个黑盒子模块声明2. 把很多个这样的黑盒子连接起来组成一个更大的系统模块实例化。这其实就是自顶向下Top-Down的设计思想。比如你要设计一个简单的计算器顶层模块就是“计算器”。你分析发现它需要“加法器”、“减法器”、“显示解码器”等子模块。然后你再分别去设计每一个子模块。在Verilog的世界里这些成熟的、封装好的模块常常被称为IP核。你调用一个加法器IP本质上就是在实例化一个别人已经设计好的“加法器模块”。让我用代码来具象化这个“黑盒子”。假设我们要定义一个最基础的2输入与门模块module and_gate ( input wire a, // 输入端口a1位线网类型 input wire b, // 输入端口b1位线网类型 output wire y // 输出端口y1位线网类型 ); // 盒子内部的功能实现与逻辑 assign y a b; endmodule看这就是一个完整的模块声明。module和endmodule是关键词框定了模块的范围。and_gate是你给这个盒子起的名字。括号里的是端口列表声明了这个盒子对外连接的“插头”有哪些。input和output定义了数据流的方向。wire是数据类型你可以先简单理解为它代表电路中的一根物理连线。assign y a b;这行代码就是盒子内部的核心电路描述了一个与门。2.2 让积木动起来模块的两种实例化方法定义好了“与门”这块积木我们怎么用它呢我们需要在一个更大的模块比如叫top_module里把它“放置”进去并连接好线。这个过程就叫实例化。你可以给同一个模块定义多次实例就像乐高套装里有很多块相同形状的积木。实例化有两种连接方式我刚开始也容易搞混但理解后很简单第一种按位置连接不推荐但需了解就像函数调用参数的顺序必须和模块声明时端口的顺序严格一致。module top_module ( input wire in1, input wire in2, output wire out ); // 实例化一个与门命名为u1 // 端口连接顺序a, b, y 必须对应 and_gate 声明时的 (a, b, y) and_gate u1 (in1, in2, out); // 将in1连到ain2连到bout连到y endmodule这种方式很简洁但有个致命缺点一旦原模块的端口顺序发生变化所有按位置实例化的地方都得跟着改极易出错。在稍微复杂点的工程里这简直是灾难。第二种按名称连接强烈推荐这种方式显式地指明了连接关系清晰且安全。module top_module ( input wire in1, input wire in2, output wire out ); // 实例化一个与门命名为u1 // 格式.模块内部的端口名 (连接到此端口的线名) and_gate u1 ( .a (in1), // 将顶层模块的 in1 信号连接到实例 u1 的端口 a .b (in2), // 将顶层模块的 in2 信号连接到实例 u1 的端口 b .y (out) // 将实例 u1 的端口 y连接到顶层模块的 out 信号 ); endmodule按名称连接就像给每根线贴上了标签一目了然。即使and_gate模块的内部端口顺序变成了(b, a, y)我这边的实例化代码也完全不需要修改因为我是通过端口名a、b来连接的。这是工程实践中的标准做法务必养成习惯。3. 从“变量”与“运算符”开始搭建逻辑理解了模块这个框架我们就要往里面填充内容了。这就涉及到Verilog中描述数据变量和操作运算符的基本要素。这部分和软件编程有相似之处但背后的硬件思维截然不同。3.1 变量不只是存储更是连线Verilog的变量主要分两大类线网型net和寄存器型variable。初学者最容易在这里踩坑。线网型wire这是最常用的类型。你可以把它想象成电路板上的一根物理导线。它的值是由“驱动”它的东西决定的。比如一个与门的输出驱动了一根wire那么这根wire的值就永远等于那个与门的输出。它自己不能存储值。通常模块的输入输出端口、以及内部简单的连接都用wire。用assign语句赋值的对象也必须是wire型。寄存器型reg这个名字有点误导它不一定对应硬件上的寄存器触发器。reg型变量具有保持值的能力直到被下一次赋值改变。它常用于always块、initial块中以及需要存储状态的地方。例如在描述一个状态机时代表当前状态的变量就应该声明为reg型。一个关键区别wire需要用assign连续赋值而reg在always或initial块中用过程赋值或。我刚开始总是用错后来总结了一个笨办法凡是看到assign左边一定是wire凡是在always (*)或always (posedge clk)里被赋值的左边就声明为reg。当位宽大于1时就构成了向量。声明方式如reg [3:0] counter;表示一个4位寄存器counter[3]是最高位MSBcounter[0]是最低位LSB。这在表示总线、计数器时非常方便。3.2 运算符硬件思维的体现Verilog的运算符大多和C语言类似如,-,,|,等学起来很快。但有几个硬件描述特有的运算符需要特别关注它们直接体现了“并行”和“位操作”的硬件思想。位拼接运算符{}这是我最喜欢的运算符之一它能把多个信号的指定位拼接成一个新的向量。比如在做加法时我们经常需要把“和”与“进位输出”一起处理wire [3:0] a, b; wire [3:0] sum; wire cout; // 将cout和sum拼接成一个5位向量ab的结果会自动适配 assign {cout, sum} a b;这行代码非常简洁地实现了一个4位加法器同时输出了进位。它比先算加法再单独处理进位要直观得多。缩位运算符它对一个向量的所有位进行递推运算最终产生一个1位的结果。例如判断一个8位向量data是否全为1wire [7:0] data; wire all_ones; assign all_ones data; // 等价于 data[0] data[1] ... data[7]这里的就是缩位与。同理|data是缩位或判断是否至少有一位为1^data是缩位异或可以用于奇偶校验。这个运算符在简化代码上非常强大。等式运算符与在仿真中尤其要注意。是逻辑相等如果比较的双方有任何一位是未知值x或高阻态z结果就是x不确定。而是全等比较它会逐位精确比较包括x和z。因此在Testbench中检查输出时如果你知道可能存在x但又想精确匹配就需要用。不过要注意是不可综合的仅用于仿真。4. 让电路“活”起来行为描述语句掌握了数据和操作我们就要用语句来描述电路的行为了。这是Verilog描述能力的核心也是从“写代码”到“设计电路”的关键一跃。4.1 过程块always与initial硬件电路是并行工作的而always块就是描述这种“持续行为”的核心。一个模块里可以有多个always块它们是同时并发执行的。always块后面总跟着一个敏感列表用()括起来里面是触发这个块执行的信号。这有两种主要形式电平敏感always (a or b)或always (a, b)。意思是只要a或b中任何一个的值发生变化块内的语句就重新执行一遍。这通常用于描述组合逻辑电路。边沿敏感always (posedge clk)或always (negedge rst_n)。意思是只在时钟clk的上升沿或复位rst_n的下降沿才执行块内的语句。这用于描述时序逻辑电路这是设计带时钟同步的电路如计数器、状态机的标准写法。initial块则完全不同它里面的语句只执行一次通常用于Testbench中给信号赋初值或者生成一次性的激励波形。它不可综合也就是说不能把它写到最终要变成实际芯片的代码里。4.2 过程赋值阻塞与非阻塞这是在always块里最让人困惑也最容易出错的地方我花了很长时间做了很多实验才搞明白。阻塞赋值顾名思义它会“阻塞”后续语句的执行。在同一个always块中它像C语言一样顺序执行。赋值语句完成后左边的值立即更新然后才执行下一条语句。always (posedge clk) begin a b; // 语句1执行后a立刻变成b的值 c a; // 语句2此时a已经是新的值等于b所以c被赋值为b end这描述的不是一个寄存器传递而是一个在时钟沿触发的、类似于“c b”的组合逻辑。在描述组合逻辑的always (*)块中我们通常使用阻塞赋值。非阻塞赋值这是硬件描述的精髓。在同一个always块中所有非阻塞赋值语句是并行执行的。计算所有等式右边的值然后在过程块结束的瞬间同时更新所有等式左边的值。always (posedge clk) begin a b; // 语句1记录下b当前的值 c a; // 语句2记录下a当前的值注意是时钟沿到来时a的旧值 end这个块描述了一个经典的寄存器级联移位寄存器行为在时钟上升沿a被赋值为b而c被赋值为a的旧值。a和c的更新是同时发生的。在描述时序逻辑带有时钟的always块时99%的情况都应该使用非阻塞赋值。这是避免仿真与综合后电路行为不一致的黄金法则。我自己的记忆口诀是“组合逻辑用阻塞时序逻辑用非阻塞同一个变量不能在同一个always块里混合使用两种赋值。”4.3 条件与循环if-else, case, for这些语句和C语言很像但思维要转换到硬件。if-else和case它们用于描述多路选择器MUX。综合工具会把它们翻译成对应的硬件选择电路。一个常见的坑是如果if或case没有覆盖所有可能的情况综合工具会推断出锁存器Latch。锁存器在ASIC设计中通常是要避免的因为它对毛刺敏感会增加静态时序分析的复杂性。所以写组合逻辑的always块时一定要确保所有输入条件下输出都有明确的赋值。通常的做法是在if-else链的最后加一个else在case语句的最后加一个default给输出一个默认值。for循环这里的for循环是用于生成重复的硬件结构而不是软件中的迭代执行时间。例如用一个for循环来实例化8个相同的子模块或者描述一个多位的移位操作。循环的次数必须在编译时就能确定常量综合工具会把它“展开”成并行的硬件。5. 实战闭环用Testbench验证你的设计学了一堆语法代码写完了怎么知道它对不对这就是Testbench的用武之地。Testbench本质上也是一个Verilog模块但它不是用来综合成电路的而是专门用来给你设计的模块称为DUT被测设备施加激励、并观察其响应的“测试平台”。没有Testbench的Verilog学习是不完整的。5.1 Testbench的基本结构搭建你的测试舞台一个最简单的Testbench框架长这样timescale 1ns/1ps // 时间单位/精度 module tb_and_gate(); // Testbench模块通常没有端口 // 1. 声明信号 reg a_tb, b_tb; // 激励信号用reg因为需要在initial/always中赋值 wire y_tb; // 观察信号连接DUT的输出用wire // 2. 产生激励 initial begin a_tb 0; b_tb 0; #10; // 等待10个时间单位 a_tb 1; #10; b_tb 1; #10; a_tb 0; #10; $finish; // 结束仿真 end // 3. 实例化被测模块DUT and_gate u_dut ( .a (a_tb), .b (b_tb), .y (y_tb) ); // 4. 可选监控输出 initial begin $monitor(Time%t: a%b, b%b, y%b, $time, a_tb, b_tb, y_tb); end endmodule这个结构非常清晰声明信号 - 产生输入激励 - 把激励喂给DUT - 观察输出。$monitor是一个系统函数它会在其列表中的任何信号发生变化时自动打印一次信息到控制台是调试的好帮手。5.2 关键技巧如何产生时钟和复位信号时钟和复位是数字电路的灵魂在Testbench中生成它们有固定套路。产生时钟最常用的是在initial块里用forever循环或者在always块里。// 方法1使用initial和forever产生50MHz时钟周期20ns reg clk; initial begin clk 0; // 必须赋初值 forever #10 clk ~clk; // 每10ns翻转一次 end // 方法2使用always效果同上 reg clk; initial clk 0; always #10 clk ~clk;切记一定要给时钟信号赋初值通常是0因为它的缺省值是z高阻态~z还是z会导致时钟线永远为高阻。产生复位信号分同步复位和异步复位。// 异步复位复位信号有效立即复位无需等待时钟 reg rst_n; initial begin rst_n 0; // 复位有效 #100; // 保持100ns rst_n 1; // 撤销复位 // ... 后续测试 end // 同步复位在时钟边沿检查复位信号 initial begin rst_n 0; (negedge clk); // 等待一个时钟下降沿确保时钟稳定 rst_n 1; // 在下降沿撤销复位实际生效在下一个上升沿 (posedge clk); // 再等待一个上升沿确保稳定 end在实际项目中采用异步复位、同步释放的策略很常见但作为入门先理解这两种基本形式。5.3 实战案例测试一个时序检测器让我们用一个稍微复杂的例子来串联所有知识。假设我们设计了一个“序列检测器”功能是在连续的时钟上升沿检测输入数据data是否出现了“1101”序列如果检测到输出detect拉高一个周期。设计代码 (seq_detector.v)module seq_detector ( input wire clk, input wire rst_n, input wire data, output reg detect ); // 使用状态机这里简化为移位寄存器方式 reg [3:0] shift_reg; // 4位移位寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg 4b0; detect 1b0; end else begin // 左移新数据进入最低位 shift_reg {shift_reg[2:0], data}; // 判断是否匹配“1101” if (shift_reg 4b1101) begin detect 1b1; end else begin detect 1b0; end end end endmoduleTestbench代码 (tb_seq_detector.v)timescale 1ns/1ps module tb_seq_detector(); reg clk, rst_n, data; wire detect; // 生成时钟 initial clk 0; always #5 clk ~clk; // 100MHz时钟 // 生成复位和测试序列 initial begin // 初始化 rst_n 0; data 0; #20 rst_n 1; // 20ns后释放复位 // 测试序列发送 1, 1, 0, 1 (posedge clk); data 1; // 第一个1 (posedge clk); data 1; // 第二个1 (posedge clk); data 0; // 0 (posedge clk); data 1; // 第三个1此时应检测到 (posedge clk); data 0; // 后续数据 (posedge clk); data 0; #50 $finish; end // 实例化被测模块 seq_detector u_dut ( .clk(clk), .rst_n(rst_n), .data(data), .detect(detect) ); // 监控并打印结果 initial begin $monitor(Time%tns, data%b, shift_reg%b, detect%b, $time, data, u_dut.shift_reg, detect); end // 简单自动检查进阶 always (posedge clk) begin if (detect 1b1) begin $display( SUCCESS: Sequence detected at time %t!, $time); end end endmodule在这个Testbench里我们不仅生成了时钟和复位还精确地在每个时钟上升沿改变了输入数据data模拟了“1101”序列的输入。通过$monitor我们可以实时看到内部移位寄存器shift_reg的变化和输出detect。当shift_reg变为1101时detect果然变成了1。你还可以在仿真器中查看波形直观地看到信号随时间的变化这种“所见即所得”的反馈是学习硬件描述语言最大的成就感来源。跑通这个例子你就完成了从设计、编码到仿真验证的完整闭环。这比单纯看语法书要扎实得多。剩下的就是不断重复这个过程想一个功能 - 用Verilog描述 - 写Testbench验证 - 看波形/分析结果。在这个过程中你会遇到各种错误和意想不到的仿真结果每一次排查和解决都是对硬件思维的一次深化。这条路没有捷径但每一步都算数。希望这篇笔记能帮你少走些弯路更快地上手这门有趣的硬件描述语言。