Verilog学习路径全解析:从基础语法到FPGA工程实战
1. 写在最前这门语言到底在学什么第一次接触Verilog的人往往上来就被module、reg、wire、always这些关键字砸晕。我当年入门的时候也一样翻了一堆教材每一本都在讲语法但没人告诉我Verilog本质上不是在“写代码”而是在“画电路”。你写的每一条语句最后都会变成芯片上一堆真实的门电路和触发器。这种思维方式转换才是新手最大的门槛。这篇文章从零开始把Verilog的完整学习路径捋一遍——从最小模块、组合逻辑、时序逻辑到状态机、总线协议、仿真验证再到编辑器配置和常见坑。你会知道每种写法背后的电路长什么样为什么要这么写以及实际工程中怎么用它去实现计数器、分频器、I2C读写EEPROM、滑动窗口滤波这类具体功能。适合正在入门数字电路和FPGA开发的人也适合那些学了一段时间但总觉得“语法会了动手就废”的朋友当成查漏补缺的笔记。全文没有任何需要联网的依赖纯手写Verilog配合iverilog和GTKWave就能跑完所有例子。下面直接进入正题。2. 最小系统从最简模块看懂代码与电路的对应关系2.1 module与端口声明先搭好芯片的“外壳”Verilog的最小单位是模块module一个模块对应一个硬件电路块。比如最简单的与门逻辑module and_gate ( input wire a, input wire b, output wire y ); assign y a b; endmodule这段代码综合出来就是两个输入引脚、一个输出引脚中间一个与门。input、output定义了芯片对外接口的方向wire表示连线型数据类型约等于物理世界的一根导线。初学者最容易犯的第一个错误是把wire和reg搞混——这里先记住一条准则使用assign连续赋值时左边必须是wire使用always过程赋值时左边必须是reg。模块内部如果有多条assign语句它们是并行执行的跟书写顺序无关。这一点和C语言完全不同。学Verilog时脑子里千万别想着“从上往下执行”要想“每条语句都是同时接通的一根导线”。我从带新人的经验看一旦转过这个弯后面的学习速度会快很多。2.2 仿真testbench光写模块不够还得有个“测试台”写好的Verilog代码无法直接运行它描述的是硬件不是软件。要验证功能得写一个testbench——相当于给芯片接上一个信号发生器然后把输出结果拉出来观察。一个最小测试环境大概长这样timescale 1ns / 1ps module tb_and_gate; reg a; reg b; wire y; and_gate u_and_gate ( .a(a), .b(b), .y(y) ); initial begin a 0; b 0; #10 a 0; b 1; #10 a 1; b 0; #10 a 1; b 1; #10 $finish; end endmodulereg类型在testbench里用来给输入信号赋值initial块执行一次性的信号激励。#10表示延时10个时间单位。跑完这个测试台再用$monitor或波形工具观察y就能确认与门功能是否正确。很多人写testbench时喜欢把所有信号往initial里丢但实际项目里时钟信号这种周期性信号更常用的写法是always #5 clk ~clk;。这也是testbench和设计代码思维差异的起点——设计代码里always是用来触发寄存器更新的testbench里always则是纯粹为了生成激励。2.3 编辑器与仿真工具链VSCode和GVim下的Verilog配置写Verilog建议直接从第一天就配好编辑器否则后面代码复杂了缩进和语法高亮会把人折磨疯。我在VSCode里用的是Verilog-HDL/SystemVerilog插件配合verible-verilog-format做格式化打开文件就能看到模块、端口、信号自动高亮写always块的时候括号匹配也很准。如果习惯用GVim也可以通过安装vim-verilog或者自带的verilog.vim语法文件实现SystemVerilog的高亮显示。装好之后在.vimrc里加上au BufNewFile,BufRead *.sv,*.svh set filetypesystemverilog就能识别.sv文件。实际测下来GVim的代码折叠功能对大模块特别友好——把一整块always或者几百行的状态机折叠起来只看端口列表调试效率会高不少。仿真工具这边免费方案首选Icarus Verilogiverilog配合GTKWave看波形。命令很简单iverilog -o tb_and_gate.vvp tb_and_gate.v and_gate.v vvp tb_and_gate.vvp写完代码就编译、跑simulation、开波形这个循环是整个学习过程里最重要的基本功。另外多说一句别一上来就学SystemVerilog的面向对象验证方法学那属于验证工程师的范畴做设计的人先把Verilog的RTL风格练扎实后面学SystemVerilog只是锦上添花。3. 运算符与参数几个高频写法一次讲透3.1 优先级、位宽与常见运算符Verilog的开发效率很大程度上取决于对运算符的熟练度。下面是我整理的一张基础运算符优先级表从高到低排列——这张表我建议打印出来贴在显示器边上优先级运算符说明最高~按位取反*/%乘、除、取模-加、减逻辑移位按位与^~^按位异或、同或|按位或逻辑与||逻辑或? :三目运算符最低{}{{}}位拼接、复制初学阶段容易出的问题有三个。第一个是和混用。是按位与输入是两段多位宽数据输出逐位相与是逻辑与输出只有0或1。比如4b1010 4b1100结果是4b1000而4b1010 4b1100结果是1b1因为两个数都不为0逻辑为真。第二个是异或和同或记反。a ^ b是异或相同为0、不同为1a ~^ b也可以写成a ^~ b是同或相同为1、不同为0。实际工程里奇偶校验电路和CRC运算会大量用到异或我每次写CRC之前都会先在纸上把多项式展开再对照代码检查一遍光靠脑子想很容易绕进去。第三个是移位运算符的位宽问题。wire [7:0] a; wire [7:0] b; assign b a 1;这样写没问题但如果写成assign b a 1b1;在部分老工具里可能会被当成1的位宽参与运算导致结果的位宽不对。稳妥的写法是保证参与运算的信号位宽明确必要时在表达式前加上{ }拼接。3.2 parameter与localparam的工程化用法parameter和localparam的本质区别是parameter可以在模块例化时从外部覆盖localparam不行。日常编码中对外可配置的常量用parameter模块内部的固定常量用localparam。工程上最常见的parameter用法是定义计数器位宽和数据宽度module counter #( parameter WIDTH 8, parameter MAX_COUNT 200 ) ( input wire clk, input wire rst_n, output reg [WIDTH-1:0] count ); localparam MAX MAX_COUNT - 1; always (posedge clk or negedge rst_n) begin if (!rst_n) count 0; else if (count MAX) count 0; else count count 1b1; end endmodule顶层例化时用#(.WIDTH(16), .MAX_COUNT(50000))覆盖默认参数这样同一份计数器代码能复用到不同需求的场景。要注意的是在Verilog-2001之后都推荐用#(参数列表)写在模块名后面而不是老式的defparam。老式写法在大型工程里修改不直观而且多个模块同时defparam时很容易互相干扰。另一个高频问题是位宽不一致。比如MAX_COUNT默认是200但在顶层覆写成50000可WIDTH没变还是8位计数器就会溢出。这种参数之间的依赖关系纯靠人脑确实很难检查更好的习惯是让上位宽自己算localparam COUNTER_WIDTH clog2(MAX_COUNT);其中clog2是用函数实现的对数向上取整。这个技巧能让你的模块适配性一下子提升一个档次。3.3 例化与连接模块之间到底怎么“接线”模块例化相当于在FPGA里“插入”了一个子电路然后把子电路的端口和外部信号连接起来。最常见的两种连接方式// 按端口名连接推荐 counter #( .WIDTH(16), .MAX_COUNT(50000) ) u_counter ( .clk (clk), .rst_n(rst_n), .count(led_count) ); // 按端口顺序连接不推荐 counter u_counter_2 (clk, rst_n, led_count_2);按端口名连接的好处是端口顺序随便调都不影响代码可读性也高按端口顺序连接一旦端口列表改一下所有例化全部错位排查起来极其痛苦。我在代码评审里看到按顺序例化一般都会建议改掉——这不是风格问题这是维护性的问题。4. 时序逻辑实战计数器、分频时钟与滤波器的Verilog实现4.1 同步复位与异步复位别忘了rst_n主流设计里复位信号一般低电平有效叫rst_n。异步复位写法如下always (posedge clk or negedge rst_n) begin if (!rst_n) count 0; else count count 1b1; end这里negedge rst_n出现在敏感列表里意味着只要复位一拉低不管时钟在哪count立刻清零。这就是“异步复位”。而如果敏感列表里只有posedge clk复位信号在always块里被当成普通条件判断就必须等时钟上升沿到来才能复位这叫“同步复位”。实际工程中我几乎都用异步复位、同步释放的方式因为它能保证复位信号释放时和时钟沿对齐避免亚稳态。这个点展开讲很深初学者只需要先记住always (posedge clk or negedge rst_n)是通用模板先别乱改。4.2 四分频电路从计数器到分频器的通用套路计数器是Verilog里最典型的时序逻辑四分频也是最常见的入门题目。我们先把“计数”和“分频”这两个概念分清计数器是数数分频是把输入时钟频率变低。四分频意味着输出时钟每4个输入时钟周期翻转一次最后占空比50%的输出频率正好是输入的1/4。module clk_div4 ( input wire clk, input wire rst_n, output reg clk_out ); reg [1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 2d0; clk_out 1b0; end else if (cnt 2d1) begin cnt 2d0; clk_out ~clk_out; end else begin cnt cnt 1b1; clk_out clk_out; end end endmodule关键点在cnt 2d1时翻转clk_out。从0数到1需要2个时钟翻转一次输出半个周期下次再从0数到1再翻转一次凑满一个完整输出周期。所以4分频只需要一个2位计数器最大计数值到1就够。这个“计数值到N/2-1”的思路适用于任意偶数分频。奇数分频则复杂一些通常用两个计数器分别在时钟上升沿和下降沿计数再组合输出这里不展开。分频电路在FPGA项目里的使用要谨慎能用时钟使能就不要分频。FPGA内部的全局时钟网络是专门为时钟信号设计的如果随便将分频时钟接到普通逻辑上时序约束很容易出问题跑上高频后器件会变得不稳定。我遇到过的很多“FPGA跑飞了”的问题最后查下来都是自己随手分出来的时钟惹的祸。4.3 滑动窗口滤波与阿尔法贝塔滤波器信号处理的硬件实现思路热词里出现“滑动窗口滤波verilog”和“阿尔法贝塔滤波器verilog”说明很多人在做传感器数据处理的FPGA实现。先解释一下滑动窗口滤波它维护一个固定长度的窗口每个时钟周期进来一个新数据同时丢掉最老的数据对所有窗口内数据求平均。硬件实现上最简单的方案是FIFO加累加器。module sliding_avg #( parameter WIDTH 8, parameter DEPTH 8 ) ( input wire clk, input wire rst_n, input wire valid_in, input wire [WIDTH-1:0] data_in, output reg [WIDTH-1:0] avg_out ); reg [WIDTH-1:0] buffer [0:DEPTH-1]; reg [WIDTH3:0] sum; reg [3:0] ptr; reg [3:0] count; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sum 0; ptr 0; count 0; for (i 0; i DEPTH; i i 1) buffer[i] 0; end else if (valid_in) begin if (count DEPTH) sum sum - buffer[ptr] data_in; else begin sum sum data_in; count count 1b1; end buffer[ptr] data_in; ptr ptr 1b1; end end always (*) begin if (count 0) avg_out 0; else avg_out sum / DEPTH; end endmodule代码里最核心的思路是避免每个周期都重新累加所有窗口数据而是只做一次减法和一次加法这样无论窗口多深组合逻辑都不会因为加法器级联而变得很大。对FPGA的查找表资源来说省下的加法器数量是很可观的。阿尔法贝塔滤波器则是一阶稳态跟踪滤波器适合对匀速运动的目标做平滑估计原理上跟卡尔曼滤波有关系但实现简单得多。它的Verilog实现一般分成两步第一步做预测第二步用测量值和预测值的差来做修正。硬件里大量使用定点数所以alpha和beta通常被量化成2的幂次方比如1/16、1/64让乘除法变成移位操作节省大量数字信号处理逻辑资源。4.4 常见时序错误复盘我踩过的三个坑第一个坑是“always块里用阻塞赋值”。在时序逻辑的always里用了仿真时看似没问题但综合出来的电路很可能产生你没预料到的额外触发器或竞争。规范做法是时序逻辑一律用非阻塞赋值组合逻辑里才用阻塞赋值。这条规则我前面已经强调过这里再说一次因为它真的是新手最高频错误。第二个坑是“多驱动”。同一个信号被两个always块赋值或者被assign和always同时赋值综合工具直接报错或者产生难以预知的X态。一个信号只能有一个驱动源这条约束在写代码时要刻在脑子里。第三个坑是“混合使用if和case的优先级误解”。if...else if天然有优先级综合出来是多选器链case是编码选择综合出来近似查表。某些场景下你希望有优先级比如中断控制器可以用if但纯译码场景用casex或casez会更清晰。用错之后逻辑功能一般不会错但路径延迟和面积会变差。5. 状态机实战从I2C读写EEPROM看FSM的完整落地5.1 状态机的三段式写法框架状态机是Verilog的“半壁江山”几乎所有复杂控制逻辑UART收发、I2C通信、DDR3读写控制、LRU替换算法都是围绕状态机展开的。我推荐三段式写法第一段做状态跳转第二段做次态组合逻辑第三段做输出寄存器化。框架如下// 第一段状态寄存器 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 (start) next_state READ; READ: next_state DONE; default: next_state IDLE; endcase end // 第三段输出逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) data_valid 1b0; else if (state READ) data_valid 1b1; else data_valid 1b0; end关键好处有两个一是状态跳转和输出生成分开代码容易维护二是输出全部打了一拍寄存器避免了组合逻辑毛刺对后端时序收敛更有利。5.2 以I2C读写EEPROM为例的状态机设计过程I2C是一个典型的串行通信协议由时钟线SCL和数据线SDA组成。写EEPROM的操作流程大概是起始信号、发送器件地址加写位、等待应答、发送寄存器地址、等待应答、发送数据、等待应答、停止信号。用状态机表示我会这么拆状态动作跳转条件IDLE等待触发收到start信号跳STARTSTART拉低SDA产生起始条件完成后跳ADDRADDR移位发送器件地址写位发送完8位跳ACK1ACK1采样SDA上的应答位应答为低跳REG_ADDRREG_ADDR发送寄存器地址发送完8位跳ACK2ACK2采样应答应答为低跳DATADATA发送数据字节发送完8位跳ACK3ACK3采样应答应答为低跳STOPSTOP产生停止条件完成后回IDLE对应的大致Verilog框架localparam IDLE 4d0, START 4d1, ADDR 4d2, ACK1 4d3, REG_ADDR 4d4, ACK2 4d5, DATA 4d6, ACK3 4d7, STOP 4d8; reg [3:0] state; reg [3:0] next_state; reg [2:0] bit_cnt; reg [7:0] shift_reg; 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 (i2c_start) next_state START; START: next_state ADDR; ADDR: if (bit_cnt 3d7) next_state ACK1; ACK1: next_state REG_ADDR; REG_ADDR: if (bit_cnt 3d7) next_state ACK2; ACK2: next_state DATA; DATA: if (bit_cnt 3d7) next_state ACK3; ACK3: next_state STOP; STOP: next_state IDLE; default: next_state IDLE; endcase end这里bit_cnt用来记录当前发送到第几位。实际工程里我还需要一个更细的clk_cnt来产生SCL的4分频时钟因为I2C的时序要求SCL高电平期间SDA不能变化。状态机加时钟分频两者配合才能把I2C时序调整到完全满足器件手册要求。硬件上EEPROM的写周期一般需要5到10毫秒状态机在写完数据后不能立刻进行下一笔写操作必须进入一个等待状态延时到写周期结束。所以状态机里经常要加一个WAIT状态用计数器延时。这个细节刚入门的人很容易漏结果就是读回来的数据永远是0xFF排查半天发现是写操作还没完成就被覆盖了。5.3 其他高频状态机场景UART收发与DDR3读写控制UART的接收状态机是另一个经典练习题。它的状态一般包括IDLE、START、DATA、STOP每个数据位用波特率时钟采样多次取中间的值避免信号边缘抖动带来的误采样。具体实现时典型做法是先用一个波特率分频计数器产生采样脉冲每16个计数采一次SDA/RX引脚。DDR3读写控制的复杂度更高一层。它不仅要处理初始化校准还要管理bank、行、列地址以及预充电、刷新等操作。真正的DDR3控制器IP会内置一层非常复杂的状态机用户侧通常读写FIFO就够了。但如果你要自己写一套简化的DDR3读写控制逻辑至少要规划出IDLE、ACTIVE、READ、WRITE、PRECHARGE、REFRESH这6个状态并在每个状态里处理精确的时序参数。难度确实大但状态机的设计思路跟I2C是一脉相承的——先画状态转移图再映射成代码。状态图画清楚代码只是翻译问题。6. 工程向技巧内存管理、调度算法与流程化设计6.1 用Verilog实现LRU和RR调度的思路热词里出现lru的verilog和rr调度verilog说明有人在接触缓存替换和任务调度方向。LRU最近最少使用在硬件里最常见的是用“位矩阵”方法实现维护一个N x N的寄存器矩阵每次访问某个way时把这一行对应的列全部置1再把其他行访问本way的列清0最后按行向量里1的个数判断最久未使用的项。举个例子4路组相联缓存里每次命中way 2就执行for (i 0; i 4; i i 1) begin if (i 2) lru_matrix[i] 4b1110; else lru_matrix[i][2] 1b0; end然后判断哪一路最久未用就看哪行的|lru_matrix[i]按位或结果最小。这种方法不需要排序也不需要复杂算法纯寄存器和组合逻辑就能实现非常适合FPGA。RR轮询调度则简单不少硬件上就是一个计数器按顺序把权限交给下一个请求者。工程实现时要考虑的是“跳过无请求者”和“公平性之间的权衡”。比如4个发起方可以设计成每次仲裁后指针1如果当前指向的发起方没有请求就继续往后找直到找到有请求的那一位。这个逻辑用case加组合逻辑实现代码量不大。6.2 文件读取与仿真辅助$readmemh与文件不存在问题热词里出现“verilog读文件时不存在”对应的就是$readmemh、$readmemb系统函数。这个函数用来把外部十六进制或二进制文件加载到存储器数组里常用于初始化ROM、加载测试向量。典型写法reg [7:0] mem [0:1023]; initial begin $readmemh(data.hex, mem); end文件不存在时仿真器通常会报错但不会直接停止仿真而是把对应内存保持为X态导致后面逻辑行为异常。排查这类问题我通常是三步走第一步确认仿真器当前工作目录。很多看似文件不存在的问题其实是路径不对。用$display(Current dir: %s,FILE);打印出来看。第二步确认文件格式。$readmemh要求每行是一个十六进制数可以带注释但不能有0x前缀。第三步确认文件内容长度是否超出数组大小。读入数据超过数组范围时仿真器一般会打印warning但容易被忽略。所以初始化内存后我会顺手打印几个关键地址的值确认数据真的进去了。6.3 可综合风格与不可综合风格别把仿真写法带进综合写Verilog做FPGA或ASIC设计时必须区分哪些代码能综合成电路哪些只能用于仿真。initial、#10、$display、$readmemh这些语句都只是仿真用的综合工具会忽略或者报错。真正可综合的RTL风格核心就是assign、always阻塞/非阻塞赋值、module例化、generate和各种运算符。热词里还出现“写了verilog dc”应该是指Design Compiler这类逻辑综合工具。用DC综合时对RTL代码的写法要求更严格比如不能有未初始化寄存器、不能有组合逻辑环路、不能有多个时钟域交织。我的经验是写RTL一开始就按可综合风格来写养成“仿真通过不等于能上板”的意识否则后面后端工程师很容易拿代码回来让你返工。7. 常见问题与排查技巧实录7.1 高频错误速查表现象可能原因解决方向仿真结果全是X复位没拉低、未初始化寄存器检查testbench复位时序确认rst_n先拉低再拉高波形对但上板不对时钟没加约束、跨时钟域问题添加时序约束查看时序报告输出出现毛刺组合逻辑未打拍、异步信号直接使用关键输出加一级寄存器状态机跑飞状态未初始化、少数状态缺失加default分支复位状态明确$readmemh文件读取失败路径错误、格式错误打印当前路径检查文件格式综合面积过大乘法器/除法器资源消耗优化为移位加法和查找表降低位宽7.2 排查思路从“功能错”到“时序错”的进阶顺序我刚学Verilog时遇到仿真结果不对第一反应就是盯着波形瞎猜。后来形成了固定的排查套路效率提高了非常多。第一步先确认复位释放时间。用$display打印关键信号的值确认所有模块都处于正确初始状态。第二步缩小问题范围。把一个大模块拆成若干小模块单独给每个小模块写testbench逐个验证。组合逻辑有问题多半是运算符或位宽问题时序逻辑有问题多半是阻塞赋值和非阻塞赋值混用。第三步检查跨时钟域信号。如果模块里有两个时钟域的数据交互务必确认握手机制或异步FIFO的实现是否完整。这是最容易踩的深坑也是很多人从仿真走向真实硬件时遇到的第一道坎。第四步如果功能都正常但时序不满足换工具分析——看综合报告里的关键路径找出组合逻辑深度过大的地方做流水线切分。这是后端优化的话题但做RTL的人需要尽早建立这个意识。7.3 给新手的一句经验Verilog表面上是语言问题本质上是电路设计问题。语法学得再熟如果不理解综合出来的电路长什么样写出来的代码迟早要坑到自己。反过来只要你能把每个常见写法对应的硬件结构想清楚——assign是连线、always是触发器和组合逻辑的混合体、状态机是一堆寄存器加译码逻辑——整个Verilog就豁然开朗了。我入行这些年带过的项目但凡在“代码和电路对应关系”上想通了的人后面学SystemVerilog、学总线协议、学时序收敛都顺理成章。最后再分享一个小习惯我每写完一个模块都会花五分钟画一张手写草图把输入输出端口、内部寄存器、状态跳转画出来再对照代码检查一遍。这张草图在后端调试和文档编写时都帮了大忙。硬件设计这件事慢就是快把每一步的电路模型想清楚比为了赶进度多写几百行代码重要得多。