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拿旧b,c拿旧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. 三个必练的小项目:从计数器到UART
3.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 + 1'b1; end endmodule这里有几个细节值得展开。rst_n是低有效异步复位,所以敏感列表里写negedge rst_n,复位条件判断写if (!rst_n)。为什么习惯用低有效复位?很多芯片和FPGA的复位引脚就是低有效,这算行业惯例。cnt + 1'b1注意加的是1位宽的1,防止位宽不匹配。'd0在SystemVerilog里是自动位宽的0,在旧工具里可能不支持,Icarus Verilog实测没问题,如果遇到老编译器就写成{WIDTH{1'b0}}。
把代码保存为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 + 1'b1; 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 = 2'd0, START = 2'd1, DATA = 2'd2, STOP = 2'd3; 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 <= 1'b1; tx_busy <= 1'b0; baud_cnt <= 16'd0; bit_cnt <= 3'd0; end else begin case (state) IDLE: begin tx <= 1'b1; if (tx_start) begin state <= START; data_buf <= tx_data; tx_busy <= 1'b1; end end START: begin tx <= 1'b0; if (baud_cnt == BAUD_CNT_MAX) begin baud_cnt <= 16'd0; state <= DATA; bit_cnt <= 3'd0; end else baud_cnt <= baud_cnt + 1'b1; end DATA: begin tx <= data_buf[bit_cnt]; if (baud_cnt == BAUD_CNT_MAX) begin baud_cnt <= 16'd0; if (bit_cnt == 3'd7) begin state <= STOP; end else bit_cnt <= bit_cnt + 1'b1; end else baud_cnt <= baud_cnt + 1'b1; end STOP: begin tx <= 1'b1; if (baud_cnt == BAUD_CNT_MAX) begin baud_cnt <= 16'd0; state <= IDLE; tx_busy <= 1'b0; end else baud_cnt <= baud_cnt + 1'b1; 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 <= 1'b0; else if (state == WORK) out <= 1'b1; end第二段里先写next_state = state,意思是默认保持原状态,再按条件修改,这样能有效避免组合逻辑综合出latch。第三段输出用时序逻辑打一拍,输出信号会干净很多,代价是输出晚一个时钟周期,工程上通常可以接受。对于小白,我建议一开始就直接练三段式,别走弯路。
4. 仿真才是小白的命根子:Icarus Verilog + GTKWave
4.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}) 2'b10: begin mem[w_ptr] <= w_data; w_ptr <= w_ptr + 1'b1; count <= count + 1'b1; end 2'b01: begin r_data <= mem[r_ptr]; r_ptr <= r_ptr + 1'b1; count <= count - 1'b1; end 2'b11: begin mem[w_ptr] <= w_data; w_ptr <= w_ptr + 1'b1; r_data <= mem[r_ptr]; r_ptr <= r_ptr + 1'b1; 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。发送一字节这样的重复逻辑可以封装成task:
task 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 instance | wire和reg类型用反 | assign左侧用wire,always块内用reg |
| variable "xxx" is locked in eda brwoser | 多驱动或端口连接错误 | 检查是否多个驱动源同时驱动同一信号 |
| got 'initial' but expected something else | 可综合代码里混入了initial/延时 | initial、#延时只放在testbench |
| failure 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“没那么难”,就是从你第一次看自己写出来的计数器波形开始的。