拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Verilog入门实战:从环境搭建到仿真波形调试全攻略

Verilog代码入门这件事大概率不是你语法没看明白而是被“硬件描述语言”这五个字卡住了。搜这个词的人要么是还在上学准备做数字IC/FPGA相关项目要么是工作里突然被要求搞一下逻辑设计。我当年入门时也是这样看得懂module和assign但一到自己写就不知道从哪一行放哪一行。这篇教程写给彻底零基础的小白目标是让你在看完文章后的几小时内自己动手跑通三件事装好轻量开发环境、写出第一个可仿真的Verilog模块、看懂仿真波形并定位问题。你不需要一开始就装Quartus或Vivado也不需要买板子配一台普通电脑就行。文章里的代码都是我实际跑过的命令直接给出来你跟着敲一遍就会发现Verilog没想象中那么玄。1. 开写前的准备环境与工具链选择1.1 为什么我推荐先把Quartus放一边很多小白学Verilog的第一反应是去装Quartus或者Vivado。这个思路本身没错但这些工具本质上是给完整FPGA工程用的包含综合、布局布线、引脚分配、时序分析一大堆功能安装包动辄几个GB打开界面还要等半天。对只想学语法、写几个模块跑跑仿真的新手来说这就像为了学做西红柿炒蛋先买了个商用厨房——不是不行但成本太高很容易在环境配置阶段就放弃了。我的建议是学习阶段先用轻量级仿真工具比如Icarus Verilog配合GTKWave看波形。Icarus Verilog体积小、启动快、支持Verilog-2001和大部分SystemVerilog语法平时练习完全够用。等你能熟练写出计数器、状态机、UART这些模块对时序图也有了感觉再上手Quartus或Vivado也不迟。到那时候你会发现真正难的不是工具怎么用而是代码能不能综合、时序能不能收敛——那是另一个层次的问题了。1.2 装一个轻量级Verilog开发环境如果你用的是Ubuntu或者WSL安装非常直接sudo apt install iverilog gtkwave就这两条命令环境就齐了。Windows纯命令行用户也可以去找Icarus Verilog的Windows安装包装完把bin目录加入PATH用法完全一样。macOS用户用brew install icarus-verilog 和 brew install gtkwave 也能搞定。写代码我推荐VS Code装两个插件就够了Verilog-HDL/SystemVerilog提供语法高亮和错误提示TerosHDL支持代码补全、格式化、模块实例化模板。我在WSL里写Verilog时还折腾过终端字体试了一圈下来最接近macOS那种观感的是Sarasa Term SC和JetBrains Mono前者中英文混排比例舒服后者代码连字看着清爽在VS Code的settings.json里设置一下即可。这里多说一句Verilog代码不能像Python脚本那样在文本文件里直接运行必须先经过工具编译再交给仿真器执行所以编辑器只是辅助真正的“运行”在命令行里完成。1.3 第一个能跑起来的程序hello_world环境装好后先别急着写复杂逻辑用一个最简单的例子验证工具链通不通。新建一个hello.v文件module hello_world; initial begin $display(Hello, Verilog!); $finish; end endmodule然后在终端执行iverilog -o hello hello.v vvp hello如果输出Hello, Verilog!说明工具链完全没有问题。这段代码里出现了两个关键点initial块在仿真开始时执行一次$display和C语言的printf类似用来打印信息。但请记住initial和$display都是仿真专用的不可以综合真实硬件里没有这种东西。后面写可综合模块时CLOCK和复位才是主角。2. Verilog代码的正确打开方式结构与语法地基2.1 忘掉软件里的“变量”wire和reg到底啥区别学Verilog最容易翻车的地方就是用软件思维去理解硬件。比如我见过有人问“wire和reg是不是就像int和float”不是差距很远。wire的意思是“导线”本身不存数据。它就像一根真实的铜线电平由驱动源决定比如一个门的输出或者assign语句。reg的意思是“寄存器式变量”它可以保存值但这里有个容易误解的点verilog里用reg声明不代表综合出来一定是寄存器。组合逻辑里也经常用reg只不过最后会被综合成连线加逻辑门。举个例子wire a, b, c; assign c a b;这里c是wire用assign赋值实现与门。下面的写法也等价reg d; always (*) begin d a b; endd是reg但综合出来仍然只是一个与门没有任何寄存器。所以记住两个硬规则assign左边的信号必须是wire类型always块里被赋值的信号必须是reg类型。项目里常见的报错“cannot be driven”基本都出自这里。2.2 阻塞赋值和非阻塞赋值差一个符号结果天差地别Verilog里有两个赋值符号和。初学者很容易忽略它们的区别但这是数字IC笔试面试几乎必考的点。是阻塞赋值可以理解为“先算完马上写进去”后面的语句必须等它完成。是非阻塞赋值可以理解为“这一刻所有信号都同时更新”像真实寄存器在时钟沿的行为。工程上有一条约定俗成的规则时序逻辑里用组合逻辑里用。如果混用仿真和综合的结果很可能不一致。看一个经典的反例always (posedge clk) begin a b; c a; end这段代码综合后a和c会同时变成b的值因为阻塞赋值在同一个always块里是顺序执行的c拿到的是a更新后的新值。但如果改成always (posedge clk) begin a b; c a; end在时钟沿到来那一刻a拿旧bc拿旧a两个操作是并发的硬件行为也是这个。所以写时序逻辑时我强烈建议一律用不要偷懒用否则仿真波形经常给出让你一头雾水的结果。2.3 先分清组合逻辑和时序逻辑再动笔很多小白拿到需求直接开始写always写到一半才发现不知道该用posedge还是negedge该用还是。这块其实有固定的判断流程。先问自己这个功能需不需要“记住”以前的状态如果不需要输出只由当前输入决定那就是组合逻辑例如加法器、比较器、多路选择器用assign或者always (*)写。如果需要记住状态比如计数器要记住当前计到几状态机要记住现在在哪个状态那就是时序逻辑必须有时钟用always (posedge clk ...)写。组合逻辑里还有一个坑if或者case分支如果没写全工具可能会推断出锁存器。比如always (*) begin if (en) d in; enden为0时d保持原值这在组合逻辑里是不允许的综合工具就会生成一个latch。分支要写完整或者用default分支兜底。这个习惯越早养成越好否则等到上板调试时时序行为会很诡异。3. 三个必练的小项目从计数器到UART3.1 计数器Verilog版的“Hello World”计数器是数字逻辑里最基础的东西没有之一。我练过的所有开发板例程几乎都是从点亮LED开始而点亮LED背后就是计数器分频。先写一个参数化的计数器module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt d0; else cnt cnt 1b1; end endmodule这里有几个细节值得展开。rst_n是低有效异步复位所以敏感列表里写negedge rst_n复位条件判断写if (!rst_n)。为什么习惯用低有效复位很多芯片和FPGA的复位引脚就是低有效这算行业惯例。cnt 1b1注意加的是1位宽的1防止位宽不匹配。d0在SystemVerilog里是自动位宽的0在旧工具里可能不支持Icarus Verilog实测没问题如果遇到老编译器就写成{WIDTH{1b0}}。把代码保存为counter.v然后编译仿真就能看到波形。没有复位的时序逻辑仿真时初值是X所有信号都会变成红色这是每个新手都会遇到的第一个“惊吓”后面专门讲。3.2 分频器计数器最常见的工程落地计数器最直接的应用就是分频。比如系统时钟是50MHz你想得到一个1Hz的秒脉冲来驱动LED闪烁那就需要把50MHz分成1Hz也就是计数25_000_000个周期翻转一次。偶数分频比较简单reg [24:0] cnt; reg clk_out; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 0; else if (cnt 25_000_000 - 1) begin cnt 0; clk_out ~clk_out; end else cnt cnt 1b1; end这种写法输出的clk_out占空比是50%。如果是奇数分频比如3分频、5分频就不能只靠一个上升沿计数器了需要同时用上升沿和下降沿各生成一组脉冲再组合起来。原因很简单奇数分频想得到50%占空比必须把半个周期在下降沿“补”出来。面试里手撕分频器这种题出现频率很高建议自己把5分频的波形画一画再写代码会顺很多。3.3 串口发送UART TX写出第一个带状态机的模块计数器之后我推荐练一个UART发送模块。它麻雀虽小五脏俱全有时钟分频、移位输出、状态判断能让你第一次感受到“写模块”和“写函数”的差别。UART异步串口的协议很简单空闲时TX为高要发送数据时先拉低一个bit时间作为起始位然后依次发出8个数据位通常LSB first最后拉高一个bit时间作为停止位。一个bit持续多久由波特率决定比如115200波特率下每个bit大约是8.68微秒。如果时钟是50MHz一个bit就是434个时钟周期。精简可仿真的发送模块如下module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD 115200 )( input wire clk, input wire rst_n, input wire tx_start, input wire [7:0] tx_data, output reg tx, output reg tx_busy ); localparam BAUD_CNT_MAX CLK_FREQ / BAUD - 1; localparam IDLE 2d0, START 2d1, DATA 2d2, STOP 2d3; reg [1:0] state; reg [15:0] baud_cnt; reg [2:0] bit_cnt; reg [7:0] data_buf; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; tx 1b1; tx_busy 1b0; baud_cnt 16d0; bit_cnt 3d0; end else begin case (state) IDLE: begin tx 1b1; if (tx_start) begin state START; data_buf tx_data; tx_busy 1b1; end end START: begin tx 1b0; if (baud_cnt BAUD_CNT_MAX) begin baud_cnt 16d0; state DATA; bit_cnt 3d0; end else baud_cnt baud_cnt 1b1; end DATA: begin tx data_buf[bit_cnt]; if (baud_cnt BAUD_CNT_MAX) begin baud_cnt 16d0; if (bit_cnt 3d7) begin state STOP; end else bit_cnt bit_cnt 1b1; end else baud_cnt baud_cnt 1b1; end STOP: begin tx 1b1; if (baud_cnt BAUD_CNT_MAX) begin baud_cnt 16d0; state IDLE; tx_busy 1b0; end else baud_cnt baud_cnt 1b1; end default: state IDLE; endcase end end endmodule这段代码是标准的摩尔状态机写法每个状态里都带着波特率计数器。写UART这类模块时我习惯先把状态转移图在纸上画出来标清楚每个状态要做什么、什么时候跳走再动手写代码写起来会顺手很多也不容易漏分支。3.4 三段式状态机越早学会越省心状态机是数字设计的基本功Verilog里常见一段式、二段式、三段式写法。一段式把所有逻辑塞在一个always里代码短但输出容易出现毛刺维护也费劲。二段式拆成时序和组合两个always已经能用。三段式是我更推荐的工程写法也是很多公司笔试里的加分点。三段式的核心是把状态机拆成三个部分第一段时序逻辑做状态迁移第二段组合逻辑算次态第三段负责输出。模板大概长这样// 第一段时序逻辑状态迁移 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段组合逻辑计算次态 always (*) begin next_state state; case (state) IDLE: if (条件) next_state WORK; WORK: if (条件) next_state DONE; DONE: next_state IDLE; default: next_state IDLE; endcase end // 第三段输出逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) out 1b0; else if (state WORK) out 1b1; end第二段里先写next_state state意思是默认保持原状态再按条件修改这样能有效避免组合逻辑综合出latch。第三段输出用时序逻辑打一拍输出信号会干净很多代价是输出晚一个时钟周期工程上通常可以接受。对于小白我建议一开始就直接练三段式别走弯路。4. 仿真才是小白的命根子Icarus Verilog GTKWave4.1 testbench让一个模块“活”起来Verilog模块本身是“死”的需要testbench测试平台来给它提供时钟、复位、激励信号然后观察输出是否符合预期。testbench本身也是Verilog代码但它不需要端口整个文件就是一台虚拟的实验台。写testbench有个基本套路例化被测模块生成时钟拉复位给输入然后dump波形。下面是前面计数器的测试平台timescale 1ns/1ps module tb_counter; reg clk; reg rst_n; wire [7:0] cnt; counter #(.WIDTH(8)) u_dut ( .clk (clk), .rst_n (rst_n), .cnt (cnt) ); initial begin clk 0; forever #10 clk ~clk; end initial begin rst_n 0; #100; rst_n 1; #1000; $finish; end initial begin $dumpfile(counter.vcd); $dumpvars(0, tb_counter); end endmoduleforever #10 clk ~clk 会生成一个周期20ns的时钟也就是50MHz。复位先拉低100ns再拉高这叫异步复位释放。$dumpfile和$dumpvars是给GTKWave输出波形文件用的。编译仿真命令iverilog -o tb_counter tb_counter.v counter.v vvp tb_counter gtkwave counter.vcd波形窗口会显示cnt从0一路往上加看到那个阶梯一样的波形你对“时序逻辑”的理解就算是初步建立起来了。4.2 常用仿真技巧打印、文件读写和路径打印很多人在仿真时只会看波形其实命令行输出和文件读写也很重要。$display用来打印$monitor可以监控信号变化只要变量一变就自动打印initial begin $monitor(time%t cnt%0d, $time, cnt); end文件读写也常用。比如你想把仿真结果存成文件integer fd; initial begin fd $fopen(result.txt, w); end always (posedge clk) begin if (rst_n) $fwrite(fd, %0d\n, cnt); end如果想知道仿真时当前工作路径Icarus Verilog可以用$system调用外部命令initial $system(pwd);这个只能用于仿真不能综合。还有一种场景是给ROM/RAM做初始化用$readmemh或者$readmemb读文件reg [7:0] mem [0:255]; initial $readmemh(data.hex, mem);仿真、调试、数据回放都离不开这几个系统函数建议专门跑一遍。4.3 常见仿真报错别慌先分清代码问题和工具问题新手遇到仿真报错时第一反应往往是“我的代码是不是写错了”其实也不一定。有一种经典报错是17.1 error: failure to obtain a verilog simulation license.这是ModelSim或Questa这类商业仿真工具的许可证错误跟你的代码其实没关系。排查方向主要是环境变量LM_LICENSE_FILE或者MGLS_LICENSE_FILE有没有设置正确license文件是否过期、是否和主机绑定、端口是否可达。如果只是学习用途我个人建议别在这里耗太久直接用开源的Icarus Verilog就完了省掉一整类问题。如果真是代码问题最常见的编译错误其实是语法级的漏了分号、begin和end不匹配、模块端口声明顺序不一致等等。比如always块里写了end但没写begin工具就会报syntax error near end。每次看到这类错误我都是先看报错行号和它前面几行绝大多数情况下问题就出在靠近报错点的那个分号或括号上。5. 进阶方向从练习代码走向小系统5.1 同步FIFO数据缓存的核心学会状态机和计数器后下一个经典题目是FIFO。FIFO在工程中到处可见跨时钟域数据缓冲、串口收发缓存、图像行缓存几乎每个系统都有它的身影。同步FIFO的要点是读写指针、满空判断。最简单的实现是用一个计数器统计当前存储的数据个数数据量为0空等于深度就是满。写成代码如下module fifo_sync #( parameter DATA_WIDTH 8, parameter DEPTH 16 )( input wire clk, input wire rst_n, input wire w_en, input wire [DATA_WIDTH-1:0] w_data, input wire r_en, output reg [DATA_WIDTH-1:0] r_data, output wire full, output wire empty ); localparam ADDR_WIDTH $clog2(DEPTH); reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; reg [ADDR_WIDTH-1:0] w_ptr, r_ptr; reg [ADDR_WIDTH:0] count; always (posedge clk or negedge rst_n) begin if (!rst_n) begin w_ptr 0; r_ptr 0; count 0; r_data 0; end else begin case ({w_en ~full, r_en ~empty}) 2b10: begin mem[w_ptr] w_data; w_ptr w_ptr 1b1; count count 1b1; end 2b01: begin r_data mem[r_ptr]; r_ptr r_ptr 1b1; count count - 1b1; end 2b11: begin mem[w_ptr] w_data; w_ptr w_ptr 1b1; r_data mem[r_ptr]; r_ptr r_ptr 1b1; end default: ; endcase end end assign full (count DEPTH); assign empty (count 0); endmodule这里$clog2是求2的对数向上取整Icarus Verilog支持用来根据深度自动算地址宽度。二维数组mem就是真正的存储空间综合时会被映射成寄存器阵列或RAM。这个同步FIFO代码可以跑仿真验证也是很多公司笔试手撕题的原型。5.2 I2C与EEPROM读写状态机的经典应用I2C总线也是状态机的经典应用场景工程里常用来读写EEPROM、传感器寄存器等。I2C只有两根线SCL时钟和SDA数据协议细节包括起始条件、停止条件、器件地址、寄存器地址、数据字节、ACK应答。初写I2C控制器时最容易犯的错是想一口气把完整的读写时序写完。我自己的经验是先把单字节写分解成如下状态生成起始信号、发送器件地址含读写位、等待ACK、发送寄存器地址、等待ACK、发送数据、等待ACK、生成停止信号。每个状态对应一个case分支bit移位则用子计数器处理。这里也顺带提到Verilog里的task。发送一字节这样的重复逻辑可以封装成tasktask send_byte; input [7:0] data; integer i; begin for (i 7; i 0; i i - 1) begin scl 0; sda data[i]; #1000; scl 1; #1000; end end endtask不过要注意带#延时的task通常只用于仿真或者行为级建模如果要做可综合的控制器延时必须用时钟周期计数来代替这也是“代码能仿真”和“代码能上板”的重要区别。5.3 多字节收发让通信模块真正可用写完了单字节的UART发送下一步就是做多字节收发。单字节收发只能算协议层的“最小功能单元”实际工程里一帧数据可能是几十个字节帧头、长度、命令、数据、校验、帧尾。多字节系统的关键是通过FIFO做缓冲。接收端把每个字节写入FIFO处理器端按帧解析发送端先把待发送的数据按帧拼好再逐字节交给UART发送模块。这时候你之前写的FIFO就派上用场了。如果想做得更规整可以给自己定一套简单协议比如第一字节是固定帧头0xAA第二字节是有效数据长度后面是数据体最后加一个累加和校验。用Verilog实现时本质上就是一个大一点的状态机等待帧头、接收长度、循环收数据、校验。手写一遍这个流程你对“模块化设计”和“协议解析”的理解会深很多后面做SPI、CAN、以太网套路都是类似的。6. 常见问题与避坑手册6.1 波形全是X/Z第一反应查复位仿真波形一片红X态是新手最容易遇到的。绝大多数情况是时序逻辑没有复位或复位没拉够时间。仿真器里寄存器初值默认是X没有复位逻辑的话信号就一直保持X。还有一种是信号高阻Z通常来自inout端口没赋值或者信号悬空。这个问题很好排查检查模块有没有复位检查复位在testbench里是否拉低过检查被观测信号有没有被驱动源驱动。另外仿真时如果复位信号只在0时刻拉低了一个瞬时后面马上拉高可能还不够。比如上面testbench里复位拉低100ns才拉高这就是给复位足够的稳定时间。6.2 仿真正常、上板不对先怀疑时序逻辑“仿真波形明明对的怎么上板就乱套”这一类问题里最常见的原因是阻塞赋值和程序性逻辑混用导致的综合前后不一致。其次组合逻辑里出现了latch也会导致上板后的行为莫名其妙。再一个原因是异步信号直接打进时钟域没有做同步处理。在做FPGA实验时按键输入如果没有消抖或者两级寄存器同步经常表现为“按一下跳好几次”或者“偶发不响应”。仿真和上板不一致并不可怕可怕的是没有排查方向。我的习惯是先用仿真把数据通路验证到不出错再上板上板出了诡异现象先看时序报告和综合警告尤其是Latch inferred这类warning基本能定位大部分问题。6.3 高频编译报错速查表下面列几个我见过频率很高的编译错误和排查方向报错或现象常见原因解决办法syntax error near always上一模块漏了分号或endmodule检查上一条语句和文件末尾object xxx is not declared信号名拼错或没声明核对端口、wire、reg声明cannot be driven by this instancewire和reg类型用反assign左侧用wirealways块内用regvariable xxx is locked in eda brwoser多驱动或端口连接错误检查是否多个驱动源同时驱动同一信号got initial but expected something else可综合代码里混入了initial/延时initial、#延时只放在testbenchfailure to obtain a simulation license商业工具许可问题检查license环境变量或换开源工具这些报错有个共同点工具提示的错误行号往往不是真正的根因。比如“object not declared”报在always块开头的信号上但你往前翻很可能是某个端口声明漏了reg类型。排查时要往前看别只看报错那行。6.4 给“手搓题”和面试党的一点提醒如果你准备的是数字IC或FPGA方向的秋招手写Verilog是躲不开的环节。最常见的题目就那几类计数器、分频器、序列检测、状态机、FIFO、UART收发。建议每个都自己动手写一遍仿真不要只在纸上画。笔试面试时代码风格也很重要参数化设计、三段式状态机、低有效复位、模块划分清晰这些都能给面试官留下好印象。还有一个容易被忽略的点面试官问“这个module能不能综合”时很多人答不上来。initial、$display、#延时、forever这些都是仿真语法不能综合要在心里有数。对初学者来说写代码前先分清“我是写给仿真看还是写给综合工具看”能避免掉一大堆问题。最后再分享一个我自己的习惯任何一个新模块哪怕只有三行我也会顺手把它包在一个module里然后写一个testbench拉一下波形。波形上直观看到的信号跳变比任何文档都更能帮你建立时序感。你真正开始觉得Verilog“没那么难”就是从你第一次看自己写出来的计数器波形开始的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门