1. 先解决一个灵魂问题:Verilog到底是"写代码"还是"画电路"?
很多刚接触Verilog的朋友,一上来就把它当成C语言或者Python来学,结果越学越迷糊。为什么?因为Verilog虽然长着一副"代码"的样子,本质上却是一门硬件描述语言,你写下的每一行,最终都会变成真实的电路逻辑门,而不是一条一条按顺序执行的指令。
我打个比方你就明白了:C语言像是给一个员工写"操作手册",告诉它第一步做什么、第二步做什么;而Verilog像是给一家工厂画"流水线图纸",你画的是多个工位同时开工的结构,每个工位都在并行运转。
这个区别直接决定了学习方式完全不同。
- C/Python是顺序执行:写完第一行,再执行第二行,一个时刻只做一件事。
- Verilog是并行执行:所有always块、assign语句理论上同时生效,描述的是电路里每个模块各自干各自的活。
所以学习Verilog的第一步,不是急着背语法,而是先把自己脑子里的"软件思维"切换成"硬件思维"。比如你写a = b + c,在软件里是一次计算;在硬件里,意味着你画了一组加法器,b和c的导线接进去,输出端就永远是a的结果,只要b和c变了,a就跟着变,不需要谁去"触发"它。
同样,初学时最容易卡住的一个点:always块里的变量赋值要用<=(非阻塞赋值),不能用=。原因也藏在硬件里——<=的意思是"在时钟边沿到来时,所有信号一起更新",模拟的是寄存器(触发器)的行为:时钟到达的那一瞬间,旧值捕获、新值锁定。如果混用=,在仿真时会发现电路行为诡异,波形里出现一堆毛刺。
这篇教程我按自己带新人的经验来组织:先把环境跑通,再建立信号概念,然后用计数器走上正轨,最后把仿真和常见坑一次讲清楚。适合完全没接触过数字电路、没写过硬件代码的读者,也适合学过一点但总感觉"没通"的朋友。
2. 环境准备:用免费工具把第一行代码跑起来
2.1 工具怎么选:别一上来就装全家桶
很多教程会让你直接装Quartus或者Vivado,这两个是工业级IDE,功能强是真的强,但它们的目标用户是"做工程的人",而不是"刚学着写代码的新手"。安装包几个GB起步,编译一次几十秒,光是工程配置的IDE界面就能劝退不少人。
所以我建议新手先用Icarus Verilog(简称iverilog),配合GTKWave看波形。这套组合免费、开源、跨平台,安装包才几十MB,装完直接用命令行就能跑仿真,非常适合把注意力集中在"语言本身"而不是"工具操作"上。
各平台的安装方式:
| 操作系统 | 安装命令 / 方法 |
|---|---|
| Ubuntu/Debian | sudo apt install iverilog gtkwave |
| Windows | 去Icarus Verilog官网下载最新版本安装包,一路Next即可 |
| macOS | brew install icarus-verilog && brew install --cask gtkwave |
提示:Windows用户装完记得把安装目录下的bin文件夹加入系统环境变量PATH,否则命令行里输入
iverilog会提示找不到命令。装好之后打开终端,输入iverilog -V能显示版本信息,就说明环境OK了。
2.2 先别建工程,先跑一个最小例子
很多新手卡在"不知道该建什么类型文件"。Verilog源文件就是纯文本,扩展名用.v,你用记事本都能写。入门阶段别建工程,直接建两个文件,一个设计文件,一个测试文件(testbench),够够的。
新建led.v,输入以下代码:
module led( input wire clk, output reg led_out ); always @(posedge clk) begin led_out <= ~led_out; end endmodule这就是一个最简单的分频闪烁器:每个时钟上升沿,led_out翻转一次。从电路来看,它就是一个T触发器。
再看testbench,新建led_tb.v:
module led_tb; reg clk; wire led_out; // 生成50MHz时钟:周期20ns,每10ns翻转一次 initial begin clk = 0; forever #10 clk = ~clk; end // 给10微秒的仿真时间后结束 initial begin #10000 $finish; end // 实例化被测模块 led u_led( .clk(clk), .led_out(led_out) ); // 打印输出波形文件 initial begin $dumpfile("led.vcd"); $dumpvars(0, led_tb); end endmodule在终端运行:
iverilog -o led.vvp led.v led_tb.v vvp led.vvp然后输入:
gtkwave led.vcd你会看到一个波形窗口,里面clk每隔10ns翻转一次,led_out在每条clk上升沿之后也跟着翻转,频率正好是clk的一半。到这里你的"第一行Verilog"就跑通了。看到波形的那一瞬间,你就理解了什么叫"硬件在跑",而不是"代码在跑"。
2.3 关于仿真报错的几个常见情况
有朋友在Quartus里遇到过这样的报错:
error: failure to obtain a verilog simulation license这个错误很典型,不是代码写错了,是Quartus自带的仿真器ModelSim需要license授权,而新手装的是Web版(免费版),有些版本不自带完整的仿真授权。解决办法有两个:一是用Quartus内置的免费仿真器(如果你用的版本支持),二是绕开IDE,用上面提的iverilog命令行工具做纯Verilog仿真。我的建议是直接用第二种,轻量、省心、不碰license问题。
3. 核心语法速通:从"信号"而不是"变量"开始
3.1 wire和reg到底是什么
刚学Verilog,遇到的第一对"冤家"肯定是wire和reg。网上的说法常常是"wire对应组合逻辑,reg对应时序逻辑",这么说其实不准确,还容易让新手记错。
我更推荐这么理解:
- wire:就是一根导线。它自己不会保存值,信号从哪里来,它的值就是什么,典型来源是assign连续赋值或者模块输出端口。
- reg:一个能"记住"值的存储元件。它在某个时刻被写入,之后即使输入端变化,它的值也保持到下一次写入为止。
在always块里被赋值的信号,必须是reg类型;在assign语句里被驱动的信号,必须是wire类型。这是新手最容易报语法错误的地方。
但要注意:reg不一定综合成寄存器。如果你在always @(*)(组合逻辑的always块)里用reg,它综合出来还是导线+逻辑门,不会变成触发器。所以"reg=寄存器"这个印象要纠正过来,准确说应该是"reg是被过程赋值语句驱动的信号,综合结果由实际代码决定"。
// 组合逻辑:reg综合成wire reg result; always @(*) begin if (a > b) result = 1'b1; else result = 1'b0; end // 时序逻辑:reg综合成触发器 reg count; always @(posedge clk) begin count <= count + 1'b1; end你看,同样是reg,第一段没有时钟,综合出来是一堆比较器和多路选择器;第二段有时钟,综合出来是D触发器。
3.2 三种赋值方式:assign、=和<=
把赋值逻辑搞懂,Verilog就学懂了一小半。
assign只能驱动wire,表示持续连接。它没有"时间点"的概念:右边的信号一变,左边的值立刻更新。这是描述组合逻辑的核心工具。
assign sum = a + b;=(阻塞赋值)只用在always块里,表示"立即生效":先算右边,立即赋给左边,然后才执行下一条语句。它描述的是组合逻辑的行为,或者过程代码里暂存中间值的场景。
<=(非阻塞赋值)也只用在always块里,表示"在时序边沿统一更新":先算右边的值,但不立即更新,而是等always块结束、仿真时间前进一个delta周期后才更新。这就保证了同一时刻触发的多个触发器不会出现"你改了我才改"的连锁反应,真实硬件行为就是这样。
核心记忆点:写时序逻辑用
<=,写组合逻辑用=(或assign)。如果always块里时钟触发,内部变量用<=基本不会出错。这是FPGA开发里无数个"波形诡异"问题的最终答案。
3.3 组合逻辑与时序逻辑:怎么判断该用哪种
很多小白拿到一个需求不知道从哪下手。我分享一个最简单的判断方法:
- 这个输出的值,是瞬时的组合结果,还是需要"记住"之前的状态?
- 瞬时的,例如"两个数相加""判断是否大于某个值"→ 组合逻辑,用assign或
always @(*)。 - 需要记住的,例如"计数器当前值""状态机当前状态"→ 时序逻辑,用
always @(posedge clk)。
- 瞬时的,例如"两个数相加""判断是否大于某个值"→ 组合逻辑,用assign或
再进一步,大部分数字电路都可以拆成"时序逻辑存状态 + 组合逻辑算下一拍"两部分。你写的代码,心里始终要清楚:哪些是触发器(reg),哪些是连线(wire),这样代码就不会乱。
4. 计数器实战:Verilog世界的Hello World
4.1 从需求到代码的完整推导
如果说每种语言都有一个入门实例,那Verilog的入门实例非计数器莫属。计数器虽然简单,但五脏俱全:有时钟、有复位、有寄存器、有比较器、有进位输出。热搜词里"verilog计数器"排名靠前,说明这是大家普遍的第一个实战项目。
下面我用一个"每计数到5翻转一次LED"的小例子,带你完整走一遍从需求到代码的思考过程。
需求拆解:
- 一个8位计数器,每个时钟周期加1
- 当计数到5时,计数归零,同时LED翻转
- 上电后先复位,让系统从确定状态启动
对应代码:
module counter_led( input wire clk, input wire rst_n, output reg led ); reg [7:0] cnt; // 计数器核心 always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt <= 8'd0; else if (cnt == 8'd5) cnt <= 8'd0; else cnt <= cnt + 1'b1; end // LED翻转控制 always @(posedge clk or negedge rst_n) begin if (!rst_n) led <= 1'b0; else if (cnt == 8'd5) led <= ~led; else led <= led; end endmodule注意两个细节:
第一,复位用的是rst_n,低电平有效。名字里的"_n"是业内惯例,表示active low。条件里写if (!rst_n)而不是if (rst_n),别搞反。
第二,cnt == 8'd5,8'd5表示8位宽的十进制数5。如果你写5,工具可能会推断成32位整数,在比较时出现位宽不匹配警告。养成写位宽的习惯,是专业选手和业余选手的分水岭。
4.2 测试文件怎么写才算合格
看完设计代码,再看testbench。很多新手写testbench只会给一个时钟然后干瞪眼,等到信号变了也不知道对不对。合格的testbench至少要干三件事:产生时钟、给复位、设计激励。
`timescale 1ns / 1ps module counter_led_tb; reg clk; reg rst_n; wire led; // 时钟:10ns周期,5ns高,5ns低 initial clk = 0; always #5 clk = ~clk; // 复位:一开始低电平100ns,然后拉高 initial begin rst_n = 0; #100; rst_n = 1; end // 仿真总时长 initial #1000 $finish; // 实例化 counter_led uut( .clk(clk), .rst_n(rst_n), .led(led) ); // 波形 initial begin $dumpfile("counter_led.vcd"); $dumpvars(0, counter_led_tb); end endmodule这段测试的时序设计是:前100ns保持复位状态,让所有信号归零;100ns后释放复位,计数器开始工作。你观察led引脚,应该看到它每6个时钟周期翻转一次(计数0~5共6拍)。
这里有个实用技巧:把#1000改大一点,比如#100000,用GTKWave打开VCD文件后,放大波形区间,找到led翻转的位置,再往前看cnt的变化,整个人就对"时序"这两个字有感觉了。
5. 仿真才是重头戏:testbench应该怎么敲
5.1 为什么说"写完代码必须仿真"
硬件设计和软件最大的不同:你没法在真实芯片上"printf"调试。写软件时打几行日志就能定位问题,写Verilog时如果直接烧进FPGA,出了问题你连看都看不到内部信号。而testbench就是你的"示波器 + 调试器 + 自动化测试工具",它让你在电脑上完整观察电路内部每个信号的行为。
我刚带新人的时候,反复强调一句话:"设计代码可以简单,testbench不允许简单。" 因为代码写得再漂亮,仿真没过关就是零。
testbench的核心作用有三个:
- 给被测模块(DUT)产生精确的输入激励,模拟真实场景
- 自动检测输出是否和预期一致(高手用
$display或者断言来做) - 把所有信号波形记录下来,供GTKWave等工具查看
5.2 initial块和always块在testbench里的用法
testbench里最常用的两个结构:initial用于一次性时序描述,always用于周期性描述(比如时钟)。
// 一次性复位逻辑 initial begin rst_n = 0; #100; rst_n = 1; end // 周期时钟 always #5 clk = ~clk;注意,initial块不是可综合语法,它只存在于仿真中。这意味着你不能把testbench拿去综合,它是独立的仿真文件。这也是为什么工程里要把设计和测试分开两个文件放。
更高级一点,你可以在testbench里用$monitor自动打印信号变化:
initial begin $monitor("time=%0t cnt=%0d led=%b", $time, uut.cnt, led); end这样仿真时控制台会实时打印计数器数值和LED状态,省得一直盯着波形看。新手强烈建议用这个功能,能把仿真过程"可视化"变成文字,立刻就知道代码行为对不对。
5.3 用GTKWave看波形时重点看什么
波形打开了,别瞎看。我按新手容易漏掉的重点列一下:
- 复位信号:确认rst_n从低变高的时间点和预期一致。
- 时钟边沿:确认每个posedge clk处,计数器是否加1。
- 比较器的行为:cnt到达5时,下一拍是否清零。
- 输出时序:led翻转是否发生在cnt==5之后的那个时钟边沿,而不是同一时刻。
特别注意第三点:非阻塞赋值生效的时机。在波形里,cnt等于5的那个时钟上升沿,cnt并不会立刻就变成0,而是在下一个时钟上升沿才更新。如果你在仿真时发现"为什么cnt=5持续了一个周期",这不是bug,这正是寄存器行为的正确表现。很多人刚学时在这里纠结半天,以为代码写错了。
6. 进阶语法的入门视角:状态机、task与parameter
6.1 parameter:让代码可配置,而不是写死
写完计数器,接触的第二组重要语法就是parameter。它的作用相当于C语言的#define,给你定义可复用的常量。
module counter #( parameter WIDTH = 8, parameter MAX_CNT = 5 )( 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 <= {WIDTH{1'b0}}; else if (cnt == MAX_CNT) cnt <= {WIDTH{1'b0}}; else cnt <= cnt + {{WIDTH-1{1'b0}}, 1'b1}; end endmodule这样实例化时可以按需定制:
counter #(.WIDTH(4), .MAX_CNT(9)) u_counter( .clk(clk), .rst_n(rst_n), .cnt(cnt) );写参数化模块的意义在于:你的模块从"一次性用品"变成了"可复用组件"。公司里写代码,最忌讳把常数直接写在代码里,项目换需求就得满文件搜索改数。用parameter,改一行实例化即可。
6.2 task:把重复激励打包
写复杂testbench时,你可能会发现喂数据的操作反复出现。举个例子,模拟一个SPI设备,每次发8比特数据,你总不能复制粘贴8段初始代码吧?这时task就派上用场了。
task send_data; input [7:0] data; integer i; begin for (i = 0; i < 8; i = i + 1) begin sclk = 0; sda = data[7-i]; #10; sclk = 1; #10; end end endtask调用:
initial begin send_data(8'hA5); send_data(8'h5A); end注意task只能在testbench(仿真环境)里使用,综合工具不认它。如果你要写可综合的代码,把共用逻辑封装成module更合适。但写测试脚本时,task确实能大幅减少重复代码。
6.3 三段式状态机:进阶路上的必经关卡
"verilog 三段式状态机"也是热搜高频词。为什么要学状态机?因为凡是带流程控制的模块——比如UART收发、I2C读写、按键消抖——本质上都是一个状态机。
三段式指的是:
- 第一段:时序逻辑,负责状态寄存器的更新
- 第二段:组合逻辑,负责计算下一状态
- 第三段:时序(或组合)逻辑,负责根据当前状态输出
以UART发送的简化流程为例:
localparam IDLE = 2'b00; localparam START = 2'b01; localparam DATA = 2'b10; localparam STOP = 2'b11; reg [1:0] state, next_state; // 第一段:状态更新 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else state <= next_state; end // 第二段:下一状态计算 always @(*) begin case (state) IDLE: next_state = start_flag ? START : IDLE; START: next_state = DATA; DATA: next_state = (bit_cnt == 3'd7) ? STOP : DATA; STOP: next_state = IDLE; default: next_state = IDLE; endcase end // 第三段:输出 always @(posedge clk or negedge rst_n) begin if (!rst_n) uart_tx <= 1'b1; else begin case (state) IDLE: uart_tx <= 1'b1; START: uart_tx <= 1'b0; DATA: uart_tx <= tx_data[bit_cnt]; STOP: uart_tx <= 1'b1; endcase end end三段式最大的优点是:状态更新、状态跳转、输出逻辑三者分离,代码结构清晰,定位问题快。新手学这一段可能会有点吃力,但这是从"写得出"到"写得专业"的关键一步,咬咬牙啃下来很值。
关于task调用的补充:很多UART或I2C模块需要使用task在testbench中模拟主机行为,两人一碰头,代码立刻清爽不少。试着把你之前写的计数器testbench改成用task驱动多个不同的复位/使能场景,你会有"原来testbench还能这么写"的感觉。
7. 新手特别容易踩的坑(都是血压换来的)
7.1 位宽不匹配:一眼看上去对,波形却全乱
这是新手最常见、也最隐蔽的错误。比如写count + 1,count是4位,1默认是32位整数,综合工具会把加法的结果扩展成32位,再截断到4位,仿真时有时能过,有时会出现意想不到的毛刺。规则很简单:数字面量永远带上位宽,比如4'd1或1'b1。
还有一个类似的坑:比较操作时位宽不一致。cnt == 5和cnt == 4'd5仿真结果可能不同,因为Verilog生成环境($$time)会提示整数位宽差异。养成所有常量都携带位宽的习惯,能省掉一大半"为什么结果不对"的排查时间。
7.2 在if-else里漏掉else:锁存器被偷偷生成
写组合逻辑时,if语句漏掉else分支,会导致综合工具推断出一个锁存器(latch)。它不像触发器那样有明确的时钟控制,而是靠电平保持数据,容易产生毛刺,是硬件设计里要尽量避免的东西。
// 这样会生成latch always @(*) begin if (en) q = d; end // 这样才是纯组合逻辑 always @(*) begin if (en) q = d; else q = 1'b0; end怎么快速发现?在综合报告里搜"latch"或者"inferred latch",一搜一个准。如果你想设计一个寄存器,请用always @(posedge clk),而不是靠if控制信号通断。
7.3 竞争冒险:用<=就对了
两个always块同时驱动同一个信号,或者在一个时钟上升沿读到的值还是旧值——这类问题排查起来异常痛苦,因为波形看起来"明明有值,就是不对"。
比如这样写:
// 错误示范:同一个变量在组合逻辑中先读后写 always @(posedge clk) begin a = b + c; // 阻塞赋值 d = a + e; // 读到的a是刚更新的 end在真实电路里,d的输入是寄存器a的旧值还是新值,取决于综合方式,结果不确定。如果改成<=,a和d都会在时钟边沿同时更新,d读到的一定是旧a,行为确定。
所以再次强调:时序逻辑用<=,组合逻辑用=。如果一段代码你在always @(posedge clk)里用了=并且变量又在块内被后续使用,停下来检查一下是不是该分解成两段逻辑。
7.4 手写代码时容易忽略begin/end
Verilog里if内的多条语句必须用begin/end括起来,否则只有第一条属于if。这个错误不是编译期报错,而是行为完全不符合预期,特别容易在调试时让人怀疑人生。
// 错误:只有第一句受if控制 if (rst_n) cnt <= 0; led <= 0; // 这一句不受if控制 // 正确 if (rst_n) begin cnt <= 0; led <= 0; end同样,case的每个分支里多条语句也要用begin/end包起来。这类排版问题,建议从一开始就养成缩进和括号的强迫症。很多代码风格指南把这个列为第一优先级,不是没道理的。
7.5 别用#延时写可综合代码
#10这样的延时语句只存在于testbench里。如果你把它写进设计代码,iverilog仿真时能跑,看起来也"对",但拿到Quartus或者Vivado里综合会直接报错,或者工具自动忽略这个延时,行为和仿真完全不同。
设计代码里唯一的时间参考是时钟和复位,任何时间控制都交给外面的世界。这也是"硬件思维"的一个关键体现:你无法用软件那套"先等10毫秒再执行"的思维来设计硬件电路。想等待一段时间?那就设计一个计数器,数到对应的时钟周期数,这才是硬件的延迟方式。
我在带新人时发现,凡是写#延时上瘾的,基本都是还在用软件的思维写硬件。如果你也有这个习惯,早点戒掉,转成计数器或状态机,整个人的Verilog水平会上一个台阶。