1. 在动手之前先建立“硬件思维”1.1 Verilog 到底在描述什么很多刚接触 Verilog 的朋友第一反应是拿它当 C 语言来学写出来的代码一眼看上去就是“披着 Verilog 外衣的软件程序”。这个坑我见过太多次了。Verilog 不是用来“执行”的它是用来“描述”的描述一组逻辑电路长什么样、信号怎么连、时序怎么走。你可以把 Verilog 理解成“电路蓝图语言”而不是“指令序列语言”。软件语言写给 CPU 看让它一条条取指令、执行Verilog 写给综合工具看让它帮你把文本翻译成实际的寄存器、查找表、多路选择器和连线。所以当你写出a b c的时候脑子里应该出现的是一个加法器而不是一个变量赋值操作。我判断一个初学者有没有入门就看他能不能回答一个问题always块和assign语句在硬件上分别对应什么。如果你能说出assign对应组合逻辑连线、always (posedge clk)对应一组带触发器的时序逻辑那你已经建立起了最基本的硬件思维。后面所有语法学习都应该围绕“这段代码会综合出什么电路”来进行而不是“这段代码执行结果是什么”。1.2 模块、端口、例化一切从结构开始Verilog 的基本单元是模块module。一个模块就像一块电路板有输入引脚、输出引脚内部有各种元器件和连线。模块之间通过端口连接这种连接动作叫例化instantiate。一个最朴素的模块长这样module my_module( input wire clk, input wire rst_n, input wire [7:0] data_in, output reg [7:0] data_out ); always (posedge clk or negedge rst_n) begin if (!rst_n) data_out 8d0; else data_out data_in; end endmodule注意端口声明里input wire和output reg的写法。很多教程会告诉你input默认是wireoutput可以是reg也可以是wire但真正的工程习惯是在端口上把类型写清楚。原因很简单代码评审的时候别人一眼就能看出这个信号是组合输出还是寄存器输出不用再去翻 always 块。例化模块的时候推荐用名字连接而不是位置连接my_module u_my_module( .clk (clk), .rst_n (rst_n), .data_in (data_in), .data_out(data_out) );位置连接写起来省事但一旦模块端口调整编译报错能让人崩溃。名字连接多敲几个字符换来的是可维护性这笔账怎么算都值。1.3 两种基本建模方式assign 与 alwaysVerilog 的数据流建模和行为建模是刚入门时最需要分清的两个概念。assign语句用于描述组合逻辑它右边的表达式一旦变化左边立即更新。举个实际例子assign sum a b; assign eq (a b);这种写法的硬件含义就是一条线上接了一个加法器和比较器输入一变输出马上跟着变没有时钟参与。always块则更灵活既可以描述组合逻辑也可以描述时序逻辑。区别在于敏感列表和赋值方式// 组合逻辑电平敏感 always (*) begin y a b; end // 时序逻辑边沿敏感 always (posedge clk) begin q d; end这里有个工程经验组合逻辑的 always 块里用阻塞赋值时序逻辑的 always 块里用非阻塞赋值。这条规则不是随便定的它关系到仿真行为和综合结果的一致性。后面我会专门讲阻塞赋值和非阻塞赋值的深层原因。2. 语法基础写代码前必须拿下的几个关键点2.1 数据类型reg 和 wire 到底怎么选初学者问得最多的问题之一就是“什么时候用 reg什么时候用 wire”。我给出的判断标准很简单在 always 块里被赋值的信号必须声明成 reg在 assign 语句里被赋值的信号声明成 wire。这个规则背下来就能应付日常开发。但这只是表面。更本质的理解是reg不代表一定能综合出寄存器它只是表示“这个信号在过程赋值中被保存”。比如下面这个组合逻辑always (*) begin if (sel) y a; else y b; end这里的y声明成 reg但综合出来只是一个多路选择器没有任何寄存器。很多人刚知道这一点时都很惊讶觉得 reg 应该对应触发器。实际上real是“过程赋值变量”wire是“连线”它们在仿真模型里是两种不同的抽象但综合到硬件后是否变成寄存器取决于你怎么用。还有一点容易忽略端口方向inout只能声明为 wire不能是 reg因为双向端口本质上是一根线由三态缓冲器控制方向。2.2 运算符与优先级别让表达式骗了你Verilog 的运算符和 C 语言很像但有几个细节非常容易被坑到。我在代码评审里见过无数因为运算符优先级导致功能错误的案例。先看最经典的a b c这个表达式C 程序员会认为它等价于(a b) c但 Verilog 的优先级里大于所以实际上等价于a (b c)。有没有很意外2.3 运算符优先级避坑清单我现在的习惯是凡是混合使用了算术、比较、逻辑、位运算的表达式一律用括号把优先级显式写出来。不是为了编译器而是为了人。写代码的人三个月后回来看如果没有括号他自己也得理半天。Verilog 运算符优先级从高到低大致如下类别运算符优先级一元运算! ~ -最高乘除* / %高加减 -中高移位 中关系 中低相等 ! !低位运算 ^ |更低逻辑 ||很低条件?:最低这不是让你背表而是提醒你不要想当然。比如4b1001 4b1100 4b1000按照优先级先算然后才是按位与结果和你预期的完全不一样。2.3 parameter 的写法从常量到可配置设计parameter 是 Verilog 里实现参数化设计的关键语法。语法本身不复杂module fifo #( parameter DATA_WIDTH 8, parameter DEPTH 16 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] din, output wire [DATA_WIDTH-1:0] dout );关键在于用 parameter 把位宽、深度、阈值这类数值集中管理。我在开发中会把所有可调的常量都提成 parameter绝不直接写魔法数字。比如判断计数器是否满就写if (cnt CNT_MAX)而不是if (cnt 255)。这样当需求从 255 变成 1023 时只需要改一行声明。还有 localparam它和 parameter 的区别在于 localparam 不能跨模块传递只能在当前模块内部使用。状态机里的状态编码我习惯用 localparam 而不是 parameter因为状态的定义只在模块内部有意义外部不需要关心。参数传递有两种方式一种是模块例化时通过#(...)修改另一种是在模块声明时使用defparam现在不推荐常用的是前一种。例化时改参数的写法fifo #( .DATA_WIDTH(16), .DEPTH (32) ) u_fifo ( .clk (clk), .rst_n(rst_n), .din (din), .dout (dout) );这种写法在 IP 复用、SOC 集成时几乎无处不在凡是写可复用模块parameter 化是基本功。3. 组合逻辑与时序逻辑的经典实现3.1 用 always 块写组合逻辑的注意事项用 always 块写组合逻辑最常见的错误是“产生锁存器”latch。什么是锁存器就是当条件不满足时输出保持原值。比如always (*) begin if (sel) y a; end这段代码在sel 0时没有给y赋值综合工具会推断出一个锁存器来保持 y 的值。锁存器在同步设计中是被尽量避免的因为它对时序分析不友好、容易产生毛刺、功耗也高。解决办法是给每个分支都赋值或者给一个默认值。工程上我习惯在 always 块开头先给所有输出赋默认值always (*) begin y 1b0; // 默认值 nxt_state cur_state; if (sel) y a; end另一种常见问题是在组合逻辑里使用非阻塞赋值。这在仿真中会导致组合逻辑时序混乱综合时也会产生警告。组合逻辑严格用时序逻辑严格用这条铁律一定要守住。3.2 always 块、assign、例化三选一很多模块功能用三种写法都能实现选哪种更合适我的经验是场景推荐写法简单的输入输出变换assign多条件组合逻辑、译码always case需要寄存器的逻辑always (posedge clk)调用已有模块例化比如一个简单的二选一assign 两行搞定assign out sel ? in0 : in1;如果选择条件很多或者要加优先级就用 always 加 case/if。case 和 if 的区别在于case 是全等匹配并行判断if 是有优先级的综合出来可能是级联多路选择器。性能和面积有差异设计时需要留意。3.3 计数器与分频电路时序设计的入门钥匙计数器是 FPGA 开发里最常用的模块没有之一。定时器、分频器、状态跳转、地址生成全都是计数器。最简单的二分频电路就是用寄存器翻转always (posedge clk or negedge rst_n) begin if (!rst_n) clk_div2 1b0; else clk_div2 ~clk_div2; end这里实现的是占空比 50% 的二分频也就是主时钟每来一个上升沿输出翻转一次。想要四分频就在二分频基础上再级联一级或者用计数器模 4 实现。实际工程中我更推荐用计数器而不是级联触发器来分频因为计数器方式更容易控制占空比和同步复位reg [1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 2d0; clk_div4 1b0; end else if (cnt 2d1) begin cnt 2d0; clk_div4 ~clk_div4; end else begin cnt cnt 1b1; end end这个代码在 cnt 等于 1 时翻转一次周期经过 4 个时钟所以是四分频。这里的关键是“翻转的时间点”决定了占空比而“计数的周期”决定了分频系数。3.4 阻塞赋值与非阻塞赋值的真正区别这个知识点值得单独立一节。阻塞赋值是“先算后赋”代码顺序和执行顺序一致。非阻塞赋值是“同时计算、最后更新”右边的表达式在一个时间步内统一求值之后在时间步末统一赋值。举个最典型的例子两个寄存器交换数据。// 错误示范用阻塞赋值 always (posedge clk) begin a b; b a; end // 正确示范用非阻塞赋值 always (posedge clk) begin a b; b a; end阻塞赋值版本综合出来是什么电路是a b, b a第二个赋值把 a 的新值又赋给 b结果两个寄存器都变成 b 的原值完全不是交换。而非阻塞赋值版本两个表达式同时采样然后在时钟边沿同时写入才真正实现了交换。这条规则的经验教训是在同一个 always 块里如果描述多个寄存器的更新并且这些更新之间存在数据依赖必须用非阻塞赋值。这个习惯能避免很多仿真和综合不一致的诡异问题。4. 状态机设计从三段式到工程习惯4.1 三段式状态机的结构状态机是数字逻辑设计的核心思想几乎所有的控制逻辑都能归纳成状态机。我个人强烈推荐三段式写法状态跳转、次态逻辑、输出逻辑分离。// 第一段状态寄存 always (posedge clk or negedge rst_n) begin if (!rst_n) cur_state IDLE; else cur_state nxt_state; end // 第二段次态逻辑 always (*) begin nxt_state cur_state; case (cur_state) IDLE: if (start) nxt_state RUN; RUN: if (done) nxt_state IDLE; default: nxt_state IDLE; endcase end // 第三段输出逻辑 always (*) begin case (cur_state) RUN: out 1b1; default: out 1b0; endcase end第一段只做一件事在时钟沿把次态打一拍变成现态。第二段是纯组合逻辑根据当前状态和输入信号决定下一个状态。第三段根据当前状态产生输出。这种写法的好处是状态跳转、条件判断、输出产生的逻辑完全分开仿真调试时定位问题一目了然。而且第三段的输出逻辑可以是组合输出也可以改成寄存器输出把 always (*) 改成 always (posedge clk)适应不同的时序要求。4.2 状态机常见误区和优化第一个误区是“状态枚举定义成魔数”。正确做法是用 localparamlocalparam IDLE 3d0, RUN 3d1, DONE 3d2;第二个误区是“case 条件不写全”。如果有未覆盖状态综合工具可能会推断出锁存器。所以 case 的 default 分支一定要写哪怕只是nxt_state IDLE或者nxt_state cur_state。第三个误区是“在同一状态机里混用阻塞和非阻塞赋值”。只要是在同一个 always 块里必须统一风格。我见过有人为了图省事把次态逻辑和状态寄存写在同一个 always 块里还混用了两种赋值仿真结果时好时坏最后查了半天才发现是赋值方式的问题。状态机的格雷码编码和二进制编码怎么选如果状态切换频繁且希望降低毛刺用格雷码如果追求资源最小化用二进制如果追求最快的判断和最简单的译码用独热码。我在实际项目里的经验是小状态机少于 8 个状态用二进制就够大状态机或高速场景用独热码格雷码在跨时钟域时更有价值。5. 常用接口与算法模块的 Verilog 实现思路5.1 I2C 读写 EEPROM 的时序设计要点I2C 是嵌入式里最常用的低速总线协议用 Verilog 实现 I2C 主机控制器几乎是每个数字工程师都做过的练习。协议本身不复杂起始条件、停止条件、设备地址、寄存器地址、数据、应答位。关键的三段式实现状态机定义IDLE、START、SEND_ADDR、WAIT_ACK、SEND_REG、SEND_DATA、STOP每个状态产生 SCL 和 SDA 的对应电平严格按照 I2C 时序建立时间、保持时间控制状态跳转我常用的 I2C 驱动核心代码localparam IDLE 4d0; localparam START 4d1; localparam SEND_ADDR 4d2; localparam WAIT_ACK 4d3; localparam SEND_DATA 4d4; localparam STOP 4d5;在 SCL 低电平时改变 SDA在 SCL 高电平时采样 SDA这是 I2C 的根本时序规则。如果用 FPGA 实现这里的核心技巧是 4 倍或 8 倍过采样一个 SCL 周期分成多个 FPGA 时钟周期在这些周期里分别完成 SDA 翻转、延时、采样等操作。模拟时序时我用“时钟分频计数器 状态寄存器”的方式控制 SCL 的电平翻转时机。对 EEPROM 写入时还要注意页写入限制。大部分 EEPROM比如 24LC02 系列单次写操作最多写入 8 字节超过会回卷覆盖同一页的数据。这个是功能正确性和需求理解上的差异仿真环境通常不会自动检查这种边界条件。5.2 DDR3 读写控制的大致框架DDR3 控制器是 FPGA 项目里让人又爱又恨的模块。完整实现一个符合时序要求的 DDR3 控制器涉及初始化、刷新、激活、读、写、预充电等多个命令复杂度很高。大部分方案是直接用厂商 IP比如 Xilinx 的 MIG 或 Intel 的 EMIF。但即使有 IP也还是需要写逻辑接口模块。DDR3 用户接口通常是非阻塞的写数据时app_wdf_wren拉高一个周期数据就进入写 FIFO控制器自己负责调度读数据时app_rd_data_valid拉高表示数据有效。我见过初学 FPGA 的朋友第一次接 DDR3上来就想着自己写一个完整控制器折腾了一个多月时序照样跑不过。我的建议是先用厂商 IP 把接口跑通理解用户接口的时序把读写调度和缓冲管理做好这比重复造控制器轮子有价值得多。如果你想学习底层原理可以拿 MIG 生成的示例设计做参考分析它的状态机和命令仲裁逻辑那才是真正有价值的部分。5.3 数字滤波滑动窗口滤波与 α-β 滤波器滤波算法不是只有软件才能做FPGA 做数字滤波是强项。滑窗滤波和 α-β 滤波都是工程里很常见的实现。滑动窗口滤波的思路很直观在固定窗口内对输入信号累加再求平均。Verilog 里用移位寄存器或环形缓冲区实现窗口每来一个新数据就丢掉最老的那个累加器加上新数据减去旧数据最后右移得到平均值。这样做不需要每个点都重新累加资源开销很小always (posedge clk) begin if (valid) begin acc acc din - data_buf[WINDOW-1]; data_buf {data_buf[WINDOW-2:0], din}; end end窗口大小要用 parameter 定义这样后期可以配置滑动窗口大小不用改代码。α-β 滤波器是一个轻量级的预测跟踪滤波器常用于位置和速度估计公式是// 预测 pred_pos pred_pos vel * Ts; // 更新 pos_filtered pred_pos alpha * (measurement - pred_pos); vel vel beta * ((measurement - pred_pos) / Ts);这里alpha和beta是系数通常是小数的定点表示。Verilog 里处理小数要特别注意FPGA 不适合直接做浮点运算工程上一般用定点数用x n乘以 2 的幂次或用一个整数Q格式来表示小数。实际项目中我用 Q10 格式也就是把 1.0 表示为 1024alpha 取 0.2 就对应 205这样整个滤波模块都在整数运算的范畴内。5.4 其他热度较高的实用模块异或同或、LRU、RR 调度这几个功能不复杂但在一些具体场景里经常会出现。Verilog 的异或、同或运算符分别是^和~^。很多初学者会写assign y ~(a ^ b);来实现同或其实~^就是同或运算符没必要绕一圈。常用场景包括数据校验、CRC 计算、奇偶校验。乘法器里的部分积产生也大量用到异或运算。LRU最近最少使用缓存替换算法的 Verilog 实现核心是一个“使用位”矩阵。当缓存被访问时把对应行设为当前最“新”的状态替换时找出所有位上都不“新”的那一行。实现方式不是背电路而是设计一个“年龄计数器”或“bit-matrix”逻辑用组合逻辑判断最老的数据。RR 调度Round-Robin则更像轮询仲裁。核心是一个指针寄存器和请求向量每次仲裁获胜后指针移到下一个优先级。Verilog 实现时要注意仲裁器输出是组合逻辑还是寄存器逻辑这会影响时延时钟频率高的话需要把仲裁结果打一拍再输出。6. 开发环境与仿真调试工程落地最容易被忽视的事6.1 VSCode 与 gvim 的 Verilog 语法高亮和纠错配置我见过太多人把时间浪费在“代码写完了但编译不过”上其实很多低级语法错误在编辑器阶段就能查出来。VSCode 配置 Verilog 环境主要干两件事语法高亮和语法纠错。语法高亮用官方插件verilog或verilog-formatter就行。语法纠错我推荐两个插件Verilog-HDL/SystemVerilog扩展配合iverilog编译器做实时 lint或者用svls它是一个用 Rust 写的 SystemVerilog 语言服务器支持 VSCode 的 LSP 协议诊断信息实时显示在编辑区。配置方法很简单sudo apt install iverilog然后在 VSCode 的 settings.json 里添加{ verilog.linting.iverilog: true, verilog.linting.iverilog.enable: true, verilog.linting.iverilog.args: -g2012, verilog.linting.iverilog.includePath: [ ${workspaceFolder} ] }这样保存代码的瞬间语法错误和不规范的模块例化都会以红色波浪线标出来。第一次看到这个效果的时候你会觉得之前裸写代码的自己太亏了。gvim 是老牌编辑器很多资深工程师还在用。在 gvim 里启用 Verilog 高亮只需在.vimrc中加入syntax on filetype plugin indent on au BufRead,BufNewFile *.v,*.sv set filetypeverilog还要装vim-verilog-systemverilog插件它提供了关键字高亮、自动补全和一些常用的代码折叠功能。6.2 用 $readmemh 和文件读操作做仿真仿真时经常需要把外部数据文件读入存储器或者把仿真结果导出。Verilog 提供了$readmemh和$readmemb系统任务很实用reg [7:0] mem [0:255]; initial begin $readmemh(data.hex, mem); end这里data.hex是十六进制数据文件每行一个数支持注释。$readmemb对应二进制文件。注意一个关键问题$readmemh在仿真器找不到文件时不会报错而是一堆红字警告后继续运行所有地址的内容保持 X 状态。这种“静默失败”很容易让新手误以为代码有问题。如果想要更可控的文件读操作可以用$fopen、$fscanf和$fread组合。比如读取一个文本文件里的单个数integer fd, status; reg [31:0] value; initial begin fd $fopen(input.txt, r); status $fscanf(fd, %h, value); $fclose(fd); end写入文件用$fwrite或$fdisplay这个在跑大批量仿真后导出波形数据点时很有用。6.3 综合与语法检查DC 工具带来的启发Design CompilerDC是后端综合工具虽然 FPGA 开发用的 Vivado 和 Quartus 也叫“综合”但 DC 面向 ASIC规则更严格。很多 ASIC 工程师会把 DC 的 lint 报告当作团队的代码规范底线。DC 综合会重点检查几个方面组合逻辑环路、锁存器推断、跨时钟域约束、位宽不匹配、未连接的输入端口。这些检查项其实也能反向指导 RTL 设计习惯。例如DC lint 里有一条很常见如果代码中存在组合逻辑环路combinational loop也就是a b a这种DC 会报 error因为这种电路在物理上会产生振荡或不定态。仿真器不一定能发现这个问题因为仿真器只是按顺序求值可能在某个循环次数后稳定但真实电路会有严重的时序问题。我的建议是写代码时严格保持“单驱动”原则一个信号只能在一个 always 块或 assign 里被赋值。这能规避大部分让人头痛的综合问题。7. 常见问题排查与实用技巧记录7.1 Verilog 文件读取不存在的几种原因“Verilog 读文件时不存在”是热搜词说明这个问题真真切切困扰着很多人。我总结下来主要有四种原因第一文件路径不对。仿真器的工作目录通常不是你编辑器所在目录可能是仿真的 build 目录或工程目录。解决方法是用绝对路径或者在仿真脚本里把工作目录cd到数据文件所在目录。第二$readmemh的文件路径是相对仿真运行时的工作目录而不是 source 文件的目录。很多人以为 “和 .v 文件在同一目录就行”其实不一定。项目里我用一个公共的tb_defs.vh头文件定义所有数据文件路径配置文件改了所有 testbench 跟着变。第三文件格式不对。$readmemh要求纯文本文件每行一个 16 进制数有地址形式的地址标记。如果文件中混入了 Windows 换行符CRLF仿真器有些会崩溃有些会跳过。解决办法是统一用 Unix 换行符。第四权限问题。Linux 服务器上仿真文件存在但没读权限报错信息往往误导人。用ls -l看一下完事。7.2 运算符优先级踩坑与括号习惯我在 2.2 节里已经提过优先级的问题这里再补充一个实际踩坑的场景。曾经有个同事写仲裁器的代码表达式是这样的assign grant req[0] req[1] 1b0 ? 2b01 : 2b10;他的本意是“req[0] 有效并且 req[1] 无效时grant 为 01”但?:优先级最低真正执行的是(grant (req[0] (req[1] 0))) ? ...结果完全不是他想的逻辑。从那次之后我在代码评审里的策略变成只要表达式里出现超过两个运算符就强制括号。这不是能力问题是工程习惯问题减少人为歧义。7.3 状态机、计数器、滤波模块的通用调试技巧调试数字逻辑最忌讳的就是凭空猜测。我的建议是先加内部信号观察点把状态机的当前状态、计数值、关键握手信号引到仿真波形里。Vivado 的仿真器里可以观察内部寄存器也可以用mark_debug把信号标记出来在 ILA 里看实时状态。另一个高价值技巧是写 testbench 时先做“冒烟测试”再写完整激励。比如写一个分频器先不管输入信号只跑一个时钟看看分频输出是否翻转然后模拟几个周期的输入变化。这个习惯在做复杂模块时特别节省时间因为可以把基本功能问题和复杂逻辑问题隔离。7.4 原语使用与缓冲器信号处理热搜词里有个很细的点“verilog 中原语将输入信号过一个缓冲器”。Verilog 原语指的是buf、not、and、or等门级原语。比如buf (out, in);这在 RTL 设计里用得很少但在网表后仿、门级仿真里比较常见。FPGA 开发中常见的“过缓冲器”实际上是用查找表实现的或者用IBUF、OBUF原语处理引脚信号这在跨时钟域和信号完整性处理时很常见。Xilinx 系列 FPGA 里会直接例化IBUF和OBUF原语。比如IBUF #(.IOSTANDARD(LVCMOS33)) ibuf_inst (.I(pin_in), .O(int_in));这类代码的重点不是语法而是理解信号在进入 FPGA 内部之前需要经过输入缓冲器完成电平适配和阻抗匹配这就是“过一个缓冲器”的真正意义。写在最后的一点经验写了这么多最后还是忍不住分享一下我入行这些年最深的感受。Verilog 这门语言门槛真的不高语法量比 C 语言少太多了两三个星期就能把所有语法过一遍。但真正让工程师和工程师拉开差距的从来不是语法背得有多熟而是面对一段代码能不能在脑子里映出对应的电路结构和时序关系。我见过很多简历上写着“熟悉 Verilog”的人一遇到状态机就写成一锅粥一遇到跨时钟域就乱打拍子一遇到 FIFO 深度就拍脑袋。相反那些真正能上手就接项目的人往往不是语法最熟的而是最清楚“这段代码会综合成什么样”的人。如果你刚学 Verilog先把模块、assign、always、阻塞与非阻塞、状态机、分频、计数这几个基本功练扎实然后一定要动手写仿真。Vivado、ModelSim、VCS任何一个工具都行把自己的代码跑起来用波形亲眼看看信号到底怎么跳。最后再分享一个小技巧遇到不确定的语法行为不要只看书直接写一个最小的测试模块放到仿真器里跑几分钟就能得到答案。这个习惯比看十篇教程都管用。希望这篇文章能帮你在 Verilog 这条路上少走一点弯路。