
这次聊Verilog语法基础。上一篇写完 FPGA 环境搭建之后后台催语法篇的人一直没断过干脆把这段时间整理的内容一次性放出来。先说结论Verilog 语法看着很多但真正天天用的核心其实就那几个——模块结构、数据类型、assign、always、阻塞与非阻塞赋值、参数化设计、状态机和几个经典接口写法。把这条线串起来数码管驱动、按键消抖、UART、SPI、I2C、FIFO这些入门工程基本都能动手写了。这篇适合两类人一类是已经把Vivado或Quartus装好、但还没正经写过几行Verilog的初学者另一类是写过一些代码、但总是被仿真结果和板子现象不一致折磨的人。内容不会把语法手册搬过来重点放在“为什么这么写”和“实际工程里怎么用”上顺便把我踩过的坑和一些改进过的代码习惯一起放出来有基础的朋友可以直接滑到状态机和FIFO部分看。1. 先别急着写代码把思维从软件切到硬件1.1 Verilog不是编程语言是描述电路的语言很多刚从C语言转过来的新手最容易犯的错就是把Verilog当C写。比如有人在always块里写for循环累加以为和软件一样一条条执行还有人喜欢在几个always块里操作同一个变量结果综合时报出多驱动错误。其实Verilog的本质是“描述硬件结构”你写的每段代码最终都会映射成查找表、触发器、多路选择器、加法器这些实际电路。所以学Verilog的第一步不是背语法而是建立“电路感”。看到一个赋值语句脑子里要出现一根导线看到一个always (posedge clk)脑子里要出现一排D触发器看到组合逻辑和时序逻辑混在一起要下意识觉得危险。有了这个意识后面写状态机、写FIFO都会顺手很多。我用一个生活化的例子C语言像是写菜谱一步一步执行Verilog更像是画电路图纸每个元件一上电就同时开始工作元件之间靠导线传递信号。菜谱可以在任意一步停下来等一等电路图不会等你所有模块同时跑信号就当拍传递。1.2 并行、时钟和“一拍”到底是什么Verilog代码的默认执行方式是并行不是顺序。两个always块之间没有先后关系它们各自独立地在时钟沿到来时被触发。这个特性和软件有本质区别也是很多新手写的仿真波形“怎么想都不对”的根源。时钟沿是时序逻辑的“心跳”。always (posedge clk)里的代码只在时钟上升沿那一瞬间执行而且执行完后结果要等到下一个时钟沿才对外可见。这里有个非常重要的概念非阻塞赋值。在时钟沿来临时所有右边的值先被统一采样然后才更新到左边。也就是说一个时钟沿内a和b交换数据如果用非阻塞赋值两个变量拿到的都是对方上一拍的值。打个比方一屋子人同时收到指令“把手里的小球传给右边的人”所有人先一起递出去再一起接住。如果按阻塞赋值的逻辑左边的人先传、右边的人后接整个顺序就乱了。这就是为什么时序逻辑要用非阻塞赋值组合逻辑才用阻塞赋值的原因。2. 最常用的Verilog语法骨架你能用到的就这些2.1 模块、端口与数据类型一个完整的Verilog模块长这样module counter #( parameter WIDTH 8, parameter MAX 250 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else if (en) begin if (cnt MAX) cnt d0; else cnt cnt 1b1; end end endmodule注意几个点端口方向是input、output、inout。数据类型最常用wire和reg。wire是导线型用assign连续赋值驱动reg是寄存器型不代表一定综合成触发器在always块里被赋值的信号必须是reg类型但如果没有时钟驱动它综合出来可能只是连线。初学阶段可以简单记assign右边用wirealways里被赋值的用reg。#(...)放在模块名后面叫参数化模块parameter允许外部传入参数这个一定要会因为FIFO深度、计数器位宽、状态编码这些全靠它做可配置设计。localparam只能在模块内部用不能被外部修改适合定义状态IDLE、SEND、WAIT这类宏。2.2 assign与always两种描述电路的方式assign描述组合逻辑比如一个加法器、一个多路选择器assign sum a b; assign sel_out sel ? b : a;它随时根据输入变化而变化没有时钟概念综合出来就是纯组合逻辑电路。always则可以描述组合逻辑和时序逻辑两种。如果always块不带时钟沿只带*或信号列表它就是组合逻辑如果带上posedge clk就是时序逻辑。一个常见错误是敏感列表写不全。比如组合逻辑always (a or b)如果赋值表达式里用了c但敏感列表没写c仿真时c变化不会触发块执行结果就是功能错误。现在主流写法是直接写always (*)让工具自动推断敏感信号省心不容易错。组合逻辑还有个隐患条件不完整时工具会推断出锁存器。比如always (*) begin if (en) q a; end当en为0时q保持原值综合工具就会插入一个锁存器这在多数设计里不是你想要的。所以组合逻辑一定要写全所有分支或者给q赋默认值。2.3 parameter与localparam让代码可配置参数化是Verilog工程化的分水岭。举一个简单例子你想设计一个分频器分频系数在不同项目里可能不同。如果把系数写死在代码里每次移植都要改逻辑容易出错。用parameter就清爽多了module clk_div #( parameter DIV_N 50000000 )( input wire clk_in, output reg clk_out );实例化时再传参clk_div #( .DIV_N(26d25000000) ) u_clk_div ( .clk_in (clk), .clk_out(clk_1hz) );localparam的典型用途是状态机编码localparam IDLE 2b00, SEND 2b01, DONE 2b10;数组parameter也经常被问到比如用Verilog定义系数表parameter [7:0] coeff [0:3] {8d1, 8d3, 8d3, 8d1};这个语法在SystemVerilog里更规范但Verilog-2001也支持{...}初始化仿真通过没问题综合工具基本也认。3. 用经典案例把语法串起来计数器、状态机、FIFO与UART3.1 计数器与数码管动态显示计数器是最简单的时序逻辑但别小看它几乎所有复杂设计都离不开计数。数码管动态显示就是用计数器做扫描的典型场景。先把计数器写稳always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 20d0; else if (cnt 20d999_999) cnt 20d0; else cnt cnt 1b1; end这个计数器在50MHz时钟下每20毫秒溢出一次可以拿来做扫描时钟、按键消抖定时或者产生一个使能脉冲。动态显示的原理很简单多个数码管共用段选线通过位选信号轮流点亮利用人眼视觉暂留让它们看起来同时亮。实际工程里扫描逻辑一般这样写一个毫秒级计数器产生扫描节拍再根据节拍切换当前点亮的位同时把对应位的段码送到段选总线。这里最容易出的问题是切换瞬间出现“重影”原因是位选和段选信号不同步。解决方法先消隐段选全灭再切换位选最后输出段码。这个经验我是在实际调板子时总结的不加消隐直接扫描数码管会有一段很明显的拖影加上后干净很多。3.2 三段式状态机实际工程的标准写法状态机是数字逻辑的核心内容UART收发、SPI时序、I2C读写、以太网帧解析全离不开它。我强烈建议直接学三段式写法一步到位省得以后回头改。三段式指第一段状态转移时序逻辑当前状态更新为次态第二段次态判断组合逻辑根据输入和当前状态算次态第三段输出逻辑可以时序也可以组合。模板长这样// 第一段状态寄存器 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 SEND; SEND: if (done) next_state DONE; DONE: next_state IDLE; default: next_state IDLE; endcase end // 第三段输出逻辑 always (*) begin tx_data 8d0; case (state) SEND: tx_data data_buf; DONE: tx_valid 1b1; endcase end三段式的好处第一是代码清晰每个always块职责单一第二是时序收敛容易输出用寄存器打一拍能有效避免组合逻辑毛刺第三是可维护性强改一个状态不用翻全篇代码。很多老工程师一上来就说“状态机写三段式”不是没有道理。这里要注意一个细节第三段如果用组合逻辑输出需要给所有输出赋默认值防止锁存器如果用时序逻辑输出输出会比状态变化晚一拍时序上要心里有数。我第一次写SPI主机时就是用组合逻辑输出结果在高速时钟下波形有点毛刺后来改成寄存器输出通过打一拍把毛刺滤掉波形干净多了。3.3 同步FIFO的Verilog实现思路FIFO是跨时钟域和数据缓冲的常用组件。同步FIFO虽然相对简单但麻雀虽小五脏俱全能帮你把读写指针、空满标志、计数器这几个概念一次理清。基本结构是这样reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; reg [ADDR_WIDTH-1:0] wr_ptr, rd_ptr; reg [ADDR_WIDTH:0] count; wire wr_en, rd_en; wire full (count DEPTH); wire empty (count 0); always (posedge clk) begin if (wr_en !full) begin mem[wr_ptr] din; wr_ptr wr_ptr 1b1; end end always (posedge clk) begin if (rd_en !empty) begin dout mem[rd_ptr]; rd_ptr rd_ptr 1b1; end end always (posedge clk) begin if (wr_en !full) count count 1b1; else if (rd_en !empty) count count - 1b1; end读写指针位宽要比深度多一位这是为了用最高位区分“满”和“空”的循环状态经典判断方法是(wr_ptr[ADDR_WIDTH] ! rd_ptr[ADDR_WIDTH]) (wr_ptr[ADDR_WIDTH-1:0] rd_ptr[ADDR_WIDTH-1:0])表示满两个指针完全相等表示空。用计数器法也可以缺点是面积略大但判断逻辑简单直观适合入门用。我在实际工程里更常用计数器法做调试因为满空信号一眼能看明白。等后面做异步FIFO、跨时钟域处理时再用指针最高位判满空避免多余的计数器影响时序。3.4 UART多字节收发中的语法细节UART是每个FPGA学习者的必经之路。单字节收发不难难在“多字节收发”怎么组织。很多人卡在“接收完一个字节后怎么知道是第几个字节”“发完一个字节后怎么自动发下一个”。本质上是状态机加FIFO缓冲接收端把每个字节存入FIFO发送端从FIFO取数并自动发送形成数据流。一个常见的发送模块逻辑流程空闲状态检查FIFO空标志不空则读出数据、拉低Tx线一个波特率周期作为起始位、然后按位发送8位数据最后发送停止位再检查FIFO是否还有数据。这里用到的语法点包括移位寄存器always (posedge clk) begin if (tx_start) tx_shift {data_in, 1b0}; // 低位先发 else if (tx_clk_en) tx_shift {1b1, tx_shift[7:1]}; end多字节收发还有一个容易踩的坑波特率时钟生成。如果用计数器做分频一定要保证采样点落在数据位中间不能正好在边沿。最稳妥的做法是产生一个波特率x16的采样时钟在每个数据位的中心点采样。我早期图省事直接用波特率时钟的上升沿采样结果在高温环境下偶尔出现误码后来改成过采样才稳定。4. task、function与数组模块化写法的关键语法4.1 task与function的区别与适用场景当代码逻辑重复出现时task和function就能派上用场。先说function它不能包含时序控制不能有posedge和#延迟适合做纯组合逻辑的运算比如计算位宽、做数据拼接、实现格雷码转换。task则灵活得多可以包含延迟、时序控制适合描述一个完整的时序操作序列比如I2C的起始、停止条件SPI的一次字节交换等。一个典型的task示例模拟I2C起始条件task i2c_start; begin sda 1b1; scl 1b1; #DELAY; sda 1b0; #DELAY; scl 1b0; end endtasktask里可以传参数也可以直接操作模块级信号。不过要注意task不适合综合成独立模块一般只用于仿真或简单的行为描述。如果RTL代码里想复用逻辑更推荐把功能做成module实例化或者用generate循环展开。4.2 数组、存储器与parameterVerilog里用数组模拟RAM非常常见语法也不复杂reg [7:0] mem [0:255]; // 256字节RAM读取用mem[addr]写入也要在always块里always (posedge clk) begin if (wr_en) mem[wr_addr] wr_data; dout mem[rd_addr]; end注意这种写法综合后可能是分布式RAM或块RAM取决于深度和位宽。初始化可以用initial和$readmemhinitial begin $readmemh(init.hex, mem); end$readmemh只用于仿真不适用于可综合设计。可综合的ROM初始化一般用(* ram_init_file *)属性或者直接用case语句生成。这里有个小技巧如果要在模块里定义可配置的数组位宽和深度可以用parameter组合但要注意有些综合工具对“parameter控制的二维数组”支持不够好遇到这种情况换个工具版本或者直接用宏定义往往能绕过。5. 仿真与综合环境里最容易踩的坑5.1 仿真工具选择与常见报错仿真工具这关新手常问Icarus Verilog、Vivado XSim、ModelSim/Questa到底选哪个。我的建议是本地轻量练习用Icarus Verilog加GTKWave跑得快、无版权限制适合验证语法和基础逻辑工程级项目直接用Vivado的XSim省得来回倒腾工程需要做覆盖率、断言验证再上Questa。具体对比如下工具适合场景优点缺点Icarus Verilog快速验证小模块简洁轻量支持Windows/Linux对SystemVerilog支持一般波形工具简陋Vivado XSimVivado工程内仿真与综合无缝衔接支持好启动慢大型仿真性能一般ModelSim/Questa工业级验证、复杂测试调试功能强性能好许可证安装略麻烦初学者容易在许可上卡壳我遇到过最常见的报错是“Failure to obtain a Verilog simulation license”。如果用的是ModelSim-Intel FPGA Starter Edition十有八九是环境变量或服务没启动。解决办法确认lmutil服务已经启动MGLS_LICENSE_FILE环境变量正确指向许可证文件如果是破解版或试用版最好直接改用Icarus或XSim省掉这些烦恼。另一个高频报错是模块实例化和定义不匹配比如端口名写错、位宽不匹配综合时会报“Cannot find port”或者出一大堆警告。养成好习惯每个模块定义完先做一次语法检查再写实例化代码能省很多时间。5.2 建模错误、位宽与组合逻辑毛刺仿真没过还好最怕的是仿真通过、上板出错。这里分享几个我真实遇到的案例。第一是位宽截断。比如cnt定义为4位却要和8位常数比较比较结果永远不成立计数器跑飞。这类错误仿真时波形一拉就能发现但如果你只看终点结果就不容易察觉。建议在写比较逻辑前先确认参与运算的所有信号位宽必要时用{}拼接补位。第二是组合逻辑毛刺。多个输入同时变化时组合逻辑输出会出现短暂的中间态。在低速率下没问题高速时钟下就可能导致功能异常。解决思路是输出端寄存器打一拍或者用时钟同步逻辑避免直接采样组合输出。我做过一个信号发生器DDS输出直接连到比较器结果在频率切换瞬间出现毛刺后来加了一级输出寄存问题消失。第三是if-else不完整导致锁存器。前面已经提过这里再说一个调试技巧仿真后打开综合报告Vivado里Report HDL Checks工具会直接列出推断出的锁存器位置逐个检查是不是你想要的。这个方法比人肉翻代码高效得多。6. 新手学习路线的几个建议6.1 按这个顺序学少走弯路考虑到很多朋友是自学我把个人认为比较顺的路程列一下第一步熟悉模块结构、wire/reg、assign、always能写计数器。第二步学会仿真用Icarus或XSim跑通计数器、加法器、选择器养成先仿真后上板的习惯。第三步写数码管动态显示和按键消抖把时钟分频、扫描、状态判断这些综合运用起来。第四步学习状态机写一个UART收发模块再用FIFO做多字节缓冲。第五步尝试简单接口协议比如SPI、I2C配合EEPROM或ADC/DAC芯片做完整的数据通路。第六步根据自己的方向选图像处理、接口高速收发或数字信号处理深入。很多人一上来就啃语法书背了一堆fork/join、wait、event这些几乎用不到的东西反而把最核心的always、状态机、参数化设计绕开了。语法书翻一遍就行真正的提升是靠写工程、调板子、看报错堆出来的。6.2 面试常问的几个点如果是为了找工作下面几个问题出现的频率极高阻塞赋值和非阻塞赋值的区别为什么时序逻辑用非阻塞赋值状态机有几种写法区别是什么为什么推荐三段式同步复位和异步复位的优缺点如何消除亚稳态同步FIFO和异步FIFO的区别满空信号如何产生时序约束里的建立时间、保持时间是什么Violation了怎么处理如何消除组合逻辑毛刺这些问题的背后考察的其实不是死记硬背而是有没有真正理解“硬件描述语言”里的“硬件”二字。所以我的学习建议一直是每写完一段代码就在工具里看综合后的原理图再上板验证一遍循环几次很多概念自然就通了。我个人的体会是Verilog入门最大的门槛不是语法而是思维转换。刚开始写代码时总忍不住用软件的思路去想问题出现了很多“仿真对、上板错”的情况。后来慢慢养成了一个习惯每个模块写完后先问自己三个问题——这段代码综合成什么电路哪个信号是时钟哪个信号是数据哪个逻辑会产生锁存器或者亚稳态想清楚这三个问题代码质量会有一个质的飞跃。最后再分享一个小技巧如果你正在用VSCode写Verilog一定要装Verilog-HDL/SystemVerilog插件配合TerosHDL的格式化功能和代码模板写代码效率能提升不少。写完之后先用Icarus快速仿真一把再往工程里放这样调试循环会快很多。语法这个坎过去了后面做复杂项目才是真正有意思的开始。