写Verilog这些年我最深的体会是语法不难难的是在真正写代码时总能因为一些“记不牢”的细节反复翻手册。比如reg和wire到底什么时候用、和为什么不能乱换、模块例化时端口总是漏连信号甚至有时候花半小时写出一个模块结果编译一下报出十几个错误看着满屏的 error 不知道从哪下手。后来我总结了一套自己的做法把日常开发里高频用到的 Verilog 常用语法整理成清单配合 Icarus Verilog 这类开源工具做语法检查在写代码的时候就顺手把问题消掉而不是等问题堆到最后一起爆发。这篇内容就是这份清单的完整版包含语法模块、功能模板、编译检查流程还有一份可以直接抄的自查表。适合刚学 Verilog 语言入门教程的初学者也适合准备笔试、面试或者临时要写一段可综合代码的从业者照着它写代码能少走很多弯路。1. 这份语法清单的定位写给谁、解决什么问题1.1 从“背语法”到“查语法”的思路转变我见过不少初学者学 Verilog第一个动作就是下载一本几百页的语法手册然后从module开始一路往下背。结果背到generate和task的时候已经晕了真正打开编辑器写代码还是不知道怎么开头。这里我想先说一个反直觉的观点Verilog 语法不适合背适合“查”。所谓“查”不是让你每次都去翻几百页的手册而是把项目里反复用到的语法元素按模块、数据类型、操作符、过程块、子程序这几个方向整理成一张表。写代码前扫一眼框架写完之后用工具跑一遍语法检查哪里不对编译器的报错信息会直接告诉你。我自己把这份清单定位成三层快速上手层知道一个 Verilog 文件的基本骨架是什么端口怎么写信号怎么声明。编码实战层积累一些高频场景的代码模板比如计数器、状态机、task、function直接套用。排错检查层编译报错之后能快速看懂 error 和 warning 到底在说什么并通过一条固定的检查流程定位问题。这篇文章就是围绕这三层展开的。你不需要从头到尾读完完全可以把它当成一份放在手边的速查文档用到哪一节就翻哪一节。1.2 工具环境的准备Icarus Verilog 与 GTKWave既然涉及“语法检查”就必须有一个趁手的检查工具。我推荐使用 Icarus Verilog也就是常说的iverilog。它是个开源的 Verilog 仿真与编译工具轻量、免费编译速度快而且对于语法错误的提示比较直观特别适合用来做代码风格的快速校验。在 Ubuntu 或 Debian 系统上安装就是一条命令sudo apt install iverilog gtkwaveWindows 上可以从 Icarus Verilog 官网下载安装包安装后把安装目录下的bin路径加入环境变量一样能在命令行里用。Mac 用户可以用brew install icarus-verilog。安装完之后最简单的语法检查方式是这样的。假设你有一个文件叫counter.viverilog -o counter.vvp counter.v如果文件里有语法错误终端会直接输出error:开头的提示并且告诉你出错的文件名和行号。如果没有输出说明文件通过了编译阶段。后面我还会专门讲怎么用iverilog配合vvp做仿真以及用 GTKWave 看波形这里先不做展开。2. 模块骨架与端口声明所有工程的第一步2.1 ANSI 风格的端口声明Verilog 的模块定义老式写法是把端口名全部列在括号里再在模块内部挨个声明方向。VHDL 和 Verilog 等工具发展这么多年目前的代码风格基本都推荐采用 ANSI 风格也就是在模块括号里直接写明方向和类型。一句话总结外部端口用 input、output、inout内部信号用 wire、reg、integer 等类型。一个标准的计数模块骨架长这样module counter #( parameter WIDTH 8 )( 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) cnt cnt 1b1; end endmodule注意三个细节#(...)用来定义参数后面例化时可以重新赋值这是实现参数递增、位宽可配置的基础。output reg直接声明输出端口是寄存器类型省去在模块内部再写一行reg cnt;的重复操作。复位信号rst_n带了下划线后缀是一种约定俗成的低电平复位命名代码读起来更清晰。2.2 parameter 与 localparam参数化的两种用法parameter是可以在模块外部修改的localparam只能在模块内部使用外部不可修改。这个区别经常有人忽略但我认为它是衡量一个模块设计是否专业的重要分界线。举个例子你想要设计一个支持不同位宽的计数器module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire en, output reg [WIDTH-1:0] cnt ); localparam MAX_COUNT (1 WIDTH) - 1; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else if (cnt MAX_COUNT) cnt {WIDTH{1b0}}; else if (en) cnt cnt 1b1; end endmodule这里MAX_COUNT是内部常量用它来表示计数上限避免在多个地方写同样的魔数。如果这段代码被例化很多次localparam不会暴露到外部也防止调用者不小心修改内部状态是一种更好的封装习惯。2.3 模块例化按名字连接端口的唯一推荐方式模块写完之后总要在顶层或者其他模块里例化。我见过很老的代码例化时只按端口顺序传信号像这样counter u_counter(clk, rst_n, en, cnt);这种方式一旦端口顺序写错或者有人改动了模块的端口列表顶层代码就会悄悄连错而且很难查。我的建议是永远使用按名字例化counter #( .WIDTH (16) ) u_counter ( .clk (clk), .rst_n (rst_n), .en (en), .cnt (cnt) );例化时的两个括号要区分清楚#(...)用于参数配置后面那对括号用于端口连接。按名字例化确实会多写几行但可读性和可维护性完全不一样尤其是在大型工程里这几个字符的差距会直接影响排查问题的速度。3. 数据类型与操作符reg/wire 的边界和运算符的坑3.1 reg、wire、integer、parameter 怎么选很多初学者纠结reg和wire的区别其实可以这样理解wire是线负责把两个逻辑点连起来reg是变量只能在过程块always、initial里被赋值。判断一个信号该用哪种类型的标准很简单看它出现在什么语句里。出现在assign右边、模块例化的输入端口、或者连续赋值语句左边的用wire出现在always块里被赋值的用reg。这里要特别纠正一个常见误解reg类型的信号综合后并不一定是寄存器。比如reg [3:0] tmp; always (*) begin tmp in_a in_b; endalways (*)是组合逻辑块里面的tmp虽然是reg类型但综合出来只是一组组合逻辑加法器不会生成真正的触发器。再看一下三四类常见声明的应用场景类型典型用途可综合性wire组合逻辑连线、模块端口连接是regalways 或 initial 中赋值的信号是integer循环变量、通用整数运算综合时不推荐作为大规模硬件parameter / localparam常量、参数配置是genvargenerate 循环中的循环变量是用于生成语句另外提一个容易漏掉的类型signed。如果你的信号需要参与有符号运算建议在声明时写清楚wire signed [7:0] data_a;不然两个“看起来是有符号数”的[7:0]信号做比较时会被 Verilog 当作无符号数处理负数比较的结果就会完全不对。3.2 操作符速查表Verilog 操作符的总体使用频率很高我把日常写 RTL 时真正用得上的列在一张表里剩下的零散操作符等你实际用到时再查也不迟类别操作符说明与示例算术 - * / %a b乘法注意位宽翻倍位运算^ ~逻辑归约^移位 算术左移/右移用比较 ! 注意可以比较不定态 x 和高阻态 z拼接{}{a, b}把多个信号拼成一个向量复制{{n{bit}}}{8{1b0}}生成 8 位全零向量三目?:条件选择可综合拼接和复制这两个操作符在写位宽扩展、时钟分频、移位寄存器时特别好用。比如生成一个 8 位全零向量{8{1b0}}或者把两个 4 位信号拼成 8 位{a, b}语法上远比手动声明多个信号清爽得多。3.3 位宽与符号最容易翻车的地方位宽问题是很多编译警告的根源也是代码跑仿真时结果不对劲的高频原因。一个经典的例子wire [7:0] a 8d200; wire [7:0] b 8d100; wire [7:0] c; assign c a b; // c 等于 8d44而不是 300原因很简单两个 8 位信号相加结果在赋值给 8 位信号时自然截断。如果你需要得到 300就必须把相加结果的位宽定成 9 位wire [8:0] c; assign c a b;乘法的位宽更要留心两个 N 位无符号数相乘结果需要 2N 位才能全部容纳。比如两个 8 位乘法wire [7:0] a, b; wire [15:0] product; assign product a * b;product声明为 16 位这样才不会丢数据。我个人的习惯是在做算术运算前先心里算一遍最终结果的位宽再写信号的声明这样编译警告会少一大半。3.4 阻塞赋值与非阻塞赋值写错了后患无穷这块是 Verilog 语法里最值得反复强调的规则尤其是在笔试和面试里几乎是必问题。我把结论放在最前面时序逻辑块里用非阻塞赋值。组合逻辑块里用阻塞赋值。同一个 always 块里不要混用和。为什么时序逻辑里要用因为非阻塞赋值的语义是“先计算右边表达式的值再在时间步的末尾统一赋给左边”。这就模拟了真实触发器的行为所有寄存器都在同一个时钟沿采样旧值然后同时更新。举个例子一个移位寄存器always (posedge clk) begin shift_reg[0] data_in; shift_reg[1] shift_reg[0]; shift_reg[2] shift_reg[1]; end如果这里误写成阻塞赋值那第二条语句里shift_reg[1]拿到的就是shift_reg[0]刚更新过的新值仿真结果会变成一次性把所有位填成data_in和真实硬件行为完全不符。这就是我在实际项目中强调最多的一条纪律。4. 过程块与逻辑描述assign、always、initial 的职责划分4.1 三种语句块各自适合干什么Verilog 描述逻辑的方式主要就是三大类assign连续赋值、always过程块、initial初始化块。assign只能描述组合逻辑而且语句之间是并行执行的没有先后顺序永远处于“持续赋值”的状态。always可以描述组合逻辑也可以描述时序逻辑靠敏感列表来触发执行。initial只在仿真开始时执行一次常用于 testbench 里的信号初始化不可综合所以永远不要指望把 initial 写进可综合的 RTL 代码。日常开发里我的选择逻辑是简单的组合逻辑比如信号连接、译码、拼接用assign复杂一些的时序逻辑比如寄存器、计数器、状态机用always配合posedge时钟需要仿真激励时在 testbench 里用initial生成复位波形和输入数据。一个常见的组合逻辑小例子assign data_out (sel 2b00) ? data_a : (sel 2b01) ? data_b : (sel 2b10) ? data_c : data_d;这种写法比一大堆if/else清晰多了综合出来的多路选择器结构也一目了然。4.2 时序逻辑模板时钟与复位我写时序逻辑时基本固定使用下面这套模板它可以覆盖九成以上的寄存器逻辑场景always (posedge clk or negedge rst_n) begin if (!rst_n) begin // 复位赋值 reg_a {WIDTH{1b0}}; reg_b 1b0; end else begin // 正常逻辑 reg_a next_value_a; reg_b next_value_b; end end这里的敏感列表posedge clk or negedge rst_n表示异步复位也就是说复位信号一变低寄存器立刻被复位不需要等时钟沿。如果你想要同步复位敏感列表里只需要写posedge clk然后把if (!rst_n)放在时钟沿触发的块内部。还有一个细节容易被忽略异步复位的复位信号如果会产生毛刺会导致寄存器误复位。所以在实际工程里复位信号通常需要经过异步复位、同步释放的处理这里不展开但你必须知道复位不是一个简单的高电平或低电平信号。4.3 组合逻辑与锁存器的规避组合逻辑用always (*)写时最容易踩的坑是产生锁存器。什么叫锁存器简单说就是组合逻辑块里某些分支下信号没有被赋值综合工具为了“保持上一次的值”就会自动推断出一个锁存结构。锁存器在时序分析里很难处理所以一般要尽量避免。规避锁存的规则和组合逻辑的完备性有关要么给所有可能的分支都赋值要么在 always 块开头给信号一个默认值。always (*) begin data_out 1b0; // 默认值防止锁存 if (en) data_out data_in; end这段代码里data_out 1b0就是关键一行。没有它当en为 0 时data_out没有赋值综合工具就可能会推断出锁存器。有了默认值无论哪个分支信号都被明确赋值组合逻辑就是纯组合逻辑。4.4 编译指令define、include、ifdef 与 celldefineVerilog 里以反引号开头的编译指令也属于语法的一部分很多人容易把它们跟普通关键字搞混。我这里挑几个常用的说一下。define用来定义宏define WIDTH 8使用宏时同样要带反引号WIDTH。宏在做全局参数、仿真开关切换时比较方便但我不建议在可综合代码里大规模使用宏来替代parameter。因为宏是全局生效的一旦多个模块里用了同一个宏名预编译阶段的替换容易相互干扰。include用来包含文件。多个模块要复用头文件时可以这样写include defines.vifdef常用于仿真环境的条件编译ifdef SIMULATION // 仿真专用的逻辑 else // 综合专用的逻辑 endif还有一个经常听说但 RTL 里很少用到的指令celldefine和endcelldefine。这两个指令主要用来标记标准单元库或者延迟模型中的单元定义一般出现在厂商提供的库文件里。普通 FPGA 或 ASIC 前端设计基本不需要手动写它我看到不少人在这里犯迷糊所以提一句如果你在写业务逻辑碰到celldefine通常意味着你正在看库文件而不是自己的 RTL 代码。5. 高频功能块语法模板计数器、状态机、task/function5.1 计数器与参数递增计数器是 Verilog 里出现频率最高的模块之一像分频、定时、滑动窗口滤波的风向采集、地址递增本质都离不开计数逻辑。一个最简单的可参数化递增计数器前面已经给过代码。这里再加上一个“使能门控”的版本注意它的结构module counter #( parameter WIDTH 8 )( input wire clk, input wire rst_n, input wire count_en, output reg [WIDTH-1:0] cnt ); always (posedge clk or negedge rst_n) begin if (!rst_n) cnt {WIDTH{1b0}}; else if (count_en) cnt cnt {{WIDTH-1{1b0}}, 1b1}; end endmodule注意递增部分我用了{{WIDTH-1{1b0}}, 1b1}它实际就是生成一个位宽为WIDTH的数值 1。如果你直接写cnt 1编译器也能推断出 1 的位宽但如果把cnt做成参数化位宽使用复制操作符可以更明确地表达意图阅读代码的人不会误解。滑动窗口滤波在 Verilog 里的典型实现本质上就是一组移位寄存器把采样数据逐拍往后移形成一个窗口reg [DATA_WIDTH-1:0] window [0:WINDOW_SIZE-1]; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (int i 0; i WINDOW_SIZE; i i 1) window[i] {DATA_WIDTH{1b0}}; end else begin window[0] data_in; for (int i 1; i WINDOW_SIZE; i i 1) window[i] window[i-1]; end end这里的for循环是可综合的但它的本质是在仿真编译阶段把逻辑展开相当于把多个寄存器的赋值语句全部列出来。这也就是为什么for (int i 0; ...)里那个int变量可以被综合工具接受的原因它是用来生成电路结构而不是真正在硬件里跑循环。5.2 状态机的三段式写法状态机可以说是数字逻辑设计里最核心的时序模板。三段式状态机是我个人强烈推荐的一种风格它把状态迁移、次态逻辑、输出逻辑分成三个 always 块逻辑清晰时序也更可控。// 第一段状态寄存器 reg [STATE_WIDTH-1:0] state; 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 RUN; RUN: if (done) next_state IDLE; default: next_state IDLE; endcase end // 第三段输出逻辑 always (*) begin data_valid 1b0; case (state) RUN: data_valid 1b1; endcase end这里面的关键点有两个。第一第二段用了always (*)和阻塞赋值因为它是纯组合逻辑第三段同样用always (*)并且给输出设置了默认值防止产生锁存器。第二第二段里我给next_state先赋值state再进入case这样就算某个状态没有完全覆盖所有分支也不会让next_state悬空。状态机的状态编码建议用localparam定义localparam IDLE 3d0; localparam RUN 3d1;像parameter和localparam配合状态机的场景我几乎在每个项目里都要写上一遍。5.3 task 与 function子程序的两种形态task和function是 Verilog 里用来封装子程序的语法但在使用上差异很大。一句话概括function 适合做纯组合逻辑计算task 更像一段完整的过程可以包含时序控制。function至少有一个输入可以有返回值。比如定义一个求位宽的辅助函数function integer clog2; input integer value; integer tmp; begin clog2 0; tmp value - 1; while (tmp 0) begin clog2 clog2 1; tmp tmp 1; end end endfunction这个函数可以在参数计算里直接使用localparam ADDR_WIDTH clog2(DEPTH);task则可以包含(posedge clk)、#10等仿真事件控制非常适合在 testbench 里写激励序列。比如读写 EEPROM 的 I2C 激励一个写字节的 task 可以写成task i2c_write_byte; input [7:0] data; begin // 产生起始条件 // 移位发送 8 位数据 // 等待 ACK end endtask当然这里的task在仿真里用很自然如果在可综合 RTL 里使用task必须确保所有语句都是可综合的比如不要在里面写#10延迟也不要写文件输出。实际做 I2C 读写 EEPROM、QSPI 读写 Flash 这类协议逻辑时我更推荐用状态机来实现协议时序用task只是在 testbench 里模拟主机行为这样仿真和综合的代码职责更清楚。5.4 协议类代码的语法框架I2C 与 QSPI 的共性问题很多人在网上一搜“I2C 读写 EEPROM verilog”或“QSPI 读写 Flash verilog”能搜到一堆完整代码但缺少一个语法规整的视角。我在这类协议代码里总结出三个最共通的语法点。第一移位寄存器是绝对的骨架。无论是 I2C 的 SDA 还是 QSPI 的数据线本质都是一位一位地把数据移出去再一位一位地把数据采样进来所以assign加always的配合是少不了的reg [7:0] shift_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) shift_reg 8h00; else if (shift_en) shift_reg {shift_reg[6:0], 1b0}; // 左移补 0 end第二三态门控制在双向端口inout上非常典型。I2C 的 SDA 以及 QSPI 的 IO 线都是双向的所以输出逻辑通常写成assign sda sda_out_en ? sda_out : 1bz;这里1bz表示高阻态释放总线让外部设备能够拉低 SDA。很多初学者第一次写inout端口时总是漏掉这个高阻态赋值导致仿真时总线始终被驱动读不到正确数据。第三位计数和字节计数是状态跳转的核心。在语法层面就是cnt信号加一个case判断的问题但这个计数粒度必须提前想清楚是每移一位跳一态还是每个字节跳一态。我见过不少协议代码状态机本身没错错在计数器的清零和使能时机没有设计好导致波形里多出半个时钟周期的毛刺。6. 语法检查的完整闭环用开源工具快速定位错误6.1 为什么推荐 Icarus Verilog 做第一道闸我一直认为iverilog是 Verilog 语法检查性价比最高的工具。它比很多商业 IDE 的语法提示更快比在线 Verilog 网站更严谨而且完全本地运行代码不会上传到第三方服务器。它的定位是仿真和编译工具但用来做语法检查绰绰有余。完整的语法检查和功能仿真流程我习惯分成三步编译iverilog -o test.vvp test.v tb.v仿真vvp test.vvp看波形gtkwave test.vcd如果只是检查语法跑完第一步就够了。如果还想验证逻辑功能再执行第二三步。6.2 编译报错的典型场景与定位方法iverilog的报错信息格式一般是文件名:行号: error: 具体原因。不要一看到 error 就慌先看行号再看信息九成情况都能快速解决。我整理了几类高频报错基本都是初学者和从业者都容易犯的报错场景常见原因修复思路找不到模块xxx被例化模块的文件没有包含进编译列表在命令行末尾加上模块文件或检查模块名是否拼错undeclared variable信号没有声明类型补上wire或reg同时检查位宽procedural assignment to a non-reg在 always 里给 wire 赋值把左侧信号改成reg类型continuous assignment to a reg在 assign 里给 reg 赋值换成wire类型或改用 alwaysnear xyz: syntax error往往有拼写错误、漏掉分号、begin/end 不配对检查报错行往前一片的 begin/endendmodulemissing模块没闭合在文件末尾补上endmodule其中begin/end不配对是我自己遇到最多的问题。Verilog 允许单条语句不写begin/end但多条语句必须写一旦漏写编译器会把逻辑归属搞错报错位置也和真正问题相差十万八千里。我的习惯是任何语句块哪怕只有一条语句也统一写begin/end多了几行但一眼就能看出结构排错会轻松很多。6.3 仿真与波形验证不只是“编译过了就想当然”语法检查通过只代表书写正确不代表功能正确。我之前遇到过一个案例代码编译零错误但状态机跑起来就是不对。最后通过vvp生成 VCD 波形再用 GTKWave 打开才发现是复位信号释放时机和输出使能差了一个周期。这类问题如果只看代码智力成本极高但看一眼波形立刻清楚。仿真流程里testbench 是最重要的一环。一个最小化的仿真文件长这样timescale 1ns / 1ps module tb_counter; reg clk; reg rst_n; reg en; wire [7:0] cnt; counter #(.WIDTH(8)) u_counter ( .clk (clk), .rst_n (rst_n), .en (en), .cnt (cnt) ); initial begin clk 0; rst_n 0; en 0; #100; rst_n 1; en 1; #1000; $finish; end always #5 clk ~clk; initial begin $dumpfile(tb_counter.vcd); $dumpvars(0, tb_counter); end endmodule注意这里必须写timescale否则某些版本的仿真器对延迟时间的解释可能不符合预期。$dumpfile和$dumpvars用来导出波形数据GTKWave 打开后才能看到各个信号的随时间变化曲线。6.4 编译顺序与 include 路径多文件工程的坑当工程里有多个 Verilog 文件时iverilog的编译顺序理论上不强制因为它会解析模块之间的依赖关系但文件缺了就会报找不到模块。我的做法是把所有需要编译的文件一次列全iverilog -o sim.vvp tb_counter.v counter.v defines.v如果用到include指令且文件不在当前目录记得加-I参数指定搜索路径iverilog -I headers -o sim.vvp tb_counter.v counter.v这里有个冷门细节Verilog 里的include不像 C 语言那样会自动防止重复包含所以如果多个文件都 include 了同一个头文件而且头文件里又有宏定义预编译时会告警。一般我会在头文件中加条件编译保护虽然 Verilog 里不强制但在复杂工程里能少很多莫名其妙的冲突。7. 一份可以照抄的自查清单最后这部分我把它当成是整篇文章的浓缩版。写完一个模块之后不要急着仿真先对着这份清单检查一遍能帮你省下大量排错时间。检查项具体内容模块名称模块名和文件名是否一致方便查找端口方向每个端口是否都有明确的方向声明信号类型端口信号用 wirealways 内赋值的信号用 reg位宽所有向量信号是否声明了位宽运算是否可能截断时钟与复位时序逻辑敏感列表是否包含所有必要的时钟沿和异步复位阻塞非阻塞时序逻辑是否用组合逻辑是否用begin/end 配对多语句块是否都有 begin/end 包裹组合逻辑完备性是否所有分支都有赋值默认值是否设置状态机是否覆盖 default 分支next_state 是否先赋当前值参数化常量定义使用 localparam外部可配置使用 parameter例化方式是否按名字例化端口是否一一对应双向端口inout 信号释放时是否赋予高阻态1bz编译检查iverilog是否零错误输出我个人在提交代码前一定会从头到尾把这些项目过一遍。说实话这张表看起来简单但每一条都是从实际项目踩坑里提炼出来的。比如位宽截断早期我以为编译器总会自动扩展结果在仿真里算出一个诡异的溢出的数字查了半天才发现是两个窄位宽信号直接相加造成的。又比如双向端口的高阻态逻辑上就少写了一行整个 I2C 读操作就是读不到数据。这些经验用一句话讲就是语法检查工具能帮你排除拼写和结构错误但逻辑和位宽问题要靠自己的代码纪律来守住。最后再分享一个小习惯我每完成一个模块会单独建一个check目录把模块文件和最小 testbench 放进去用iverilog编译一遍。这看起来多了一步但实际上比最后整个工程一起报错、面对几十条 error 节省太多时间。语法这东西写得多了自然就熟了但有了工具和清单你不用把所有细节都背下来也能写出稳定、规范、可维护的 Verilog 代码。