写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.v:
iverilog -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{1'b0}}; else if (en) cnt <= cnt + 1'b1; 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{1'b0}}; else if (cnt == MAX_COUNT) cnt <= {WIDTH{1'b0}}; else if (en) cnt <= cnt + 1'b1; 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 | 组合逻辑连线、模块端口连接 | 是 |
| reg | always 或 initial 中赋值的信号 | 是 |
| integer | 循环变量、通用整数运算 | 综合时不推荐作为大规模硬件 |
| parameter / localparam | 常量、参数配置 | 是 |
| genvar | generate 循环中的循环变量 | 是(用于生成语句) |
另外提一个容易漏掉的类型:signed。如果你的信号需要参与有符号运算,建议在声明时写清楚:
wire signed [7:0] data_a;不然两个“看起来是有符号数”的[7:0]信号做比较时,会被 Verilog 当作无符号数处理,负数比较的结果就会完全不对。
3.2 操作符速查表
Verilog 操作符的总体使用频率很高,我把日常写 RTL 时真正用得上的列在一张表里,剩下的零散操作符等你实际用到时再查也不迟:
| 类别 | 操作符 | 说明与示例 |
|---|---|---|
| 算术 | + - * / % | a + b,乘法注意位宽翻倍 |
| 位运算 | `& | ^ ~` |
| 逻辑 | `&& | |
| 归约 | `& | ^` |
| 移位 | << >> <<< >>> | 算术左移/右移用<<<>>> |
| 比较 | == != > < >= <= | 注意===可以比较不定态 x 和高阻态 z |
| 拼接 | {} | {a, b}把多个信号拼成一个向量 |
| 复制 | {{n{bit}}} | {8{1'b0}}生成 8 位全零向量 |
| 三目 | ?: | 条件选择,可综合 |
拼接和复制这两个操作符在写位宽扩展、时钟分频、移位寄存器时特别好用。比如生成一个 8 位全零向量{8{1'b0}},或者把两个 4 位信号拼成 8 位{a, b},语法上远比手动声明多个信号清爽得多。
3.3 位宽与符号:最容易翻车的地方
位宽问题是很多编译警告的根源,也是代码跑仿真时结果不对劲的高频原因。
一个经典的例子:
wire [7:0] a = 8'd200; wire [7:0] b = 8'd100; wire [7:0] c; assign c = a + b; // c 等于 8'd44,而不是 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 == 2'b00) ? data_a : (sel == 2'b01) ? data_b : (sel == 2'b10) ? data_c : data_d;这种写法比一大堆if/else清晰多了,综合出来的多路选择器结构也一目了然。
4.2 时序逻辑模板:时钟与复位
我写时序逻辑时基本固定使用下面这套模板,它可以覆盖九成以上的寄存器逻辑场景:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin // 复位赋值 reg_a <= {WIDTH{1'b0}}; reg_b <= 1'b0; 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 = 1'b0; // 默认值,防止锁存 if (en) data_out = data_in; end这段代码里data_out = 1'b0就是关键一行。没有它,当en为 0 时,data_out没有赋值,综合工具就可能会推断出锁存器。有了默认值,无论哪个分支,信号都被明确赋值,组合逻辑就是纯组合逻辑。
4.4 编译指令:define、include、ifdef 与 celldefine
Verilog 里以反引号开头的编译指令也属于语法的一部分,很多人容易把它们跟普通关键字搞混。我这里挑几个常用的说一下。
`define用来定义宏:
`define WIDTH 8使用宏时同样要带反引号:`WIDTH。宏在做全局参数、仿真开关切换时比较方便,但我不建议在可综合代码里大规模使用宏来替代parameter。因为宏是全局生效的,一旦多个模块里用了同一个宏名,预编译阶段的替换容易相互干扰。
`include用来包含文件。多个模块要复用头文件时可以这样写:
`include "defines.v"`ifdef常用于仿真环境的条件编译:
`ifdef SIMULATION // 仿真专用的逻辑 `else // 综合专用的逻辑 `endif还有一个经常听说但 RTL 里很少用到的指令:`celldefine和`endcelldefine。这两个指令主要用来标记标准单元库或者延迟模型中的单元定义,一般出现在厂商提供的库文件里。普通 FPGA 或 ASIC 前端设计基本不需要手动写它,我看到不少人在这里犯迷糊,所以提一句:如果你在写业务逻辑,碰到`celldefine通常意味着你正在看库文件,而不是自己的 RTL 代码。
5. 高频功能块语法模板:计数器、状态机、task/function
5.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{1'b0}}; else if (count_en) cnt <= cnt + {{WIDTH-1{1'b0}}, 1'b1}; end endmodule注意递增部分我用了{{WIDTH-1{1'b0}}, 1'b1},它实际就是生成一个位宽为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{1'b0}}; 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 = 1'b0; case (state) RUN: data_valid = 1'b1; endcase end这里面的关键点有两个。第一,第二段用了always @(*)和阻塞赋值=,因为它是纯组合逻辑;第三段同样用always @(*),并且给输出设置了默认值,防止产生锁存器。第二,第二段里我给next_state先赋值state,再进入case,这样就算某个状态没有完全覆盖所有分支,也不会让next_state悬空。
状态机的状态编码建议用localparam定义:
localparam IDLE = 3'd0; localparam RUN = 3'd1;像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 <= 8'h00; else if (shift_en) shift_reg <= {shift_reg[6:0], 1'b0}; // 左移补 0 end第二,三态门控制在双向端口(inout)上非常典型。I2C 的 SDA 以及 QSPI 的 IO 线都是双向的,所以输出逻辑通常写成:
assign sda = sda_out_en ? sda_out : 1'bz;这里1'bz表示高阻态,释放总线,让外部设备能够拉低 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类型,或改用 always |
| near "xyz": syntax error | 往往有拼写错误、漏掉分号、begin/end 不配对 | 检查报错行往前一片的 begin/end |
endmodulemissing | 模块没闭合 | 在文件末尾补上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. 一份可以照抄的自查清单
最后这部分,我把它当成是整篇文章的浓缩版。写完一个模块之后,不要急着仿真,先对着这份清单检查一遍,能帮你省下大量排错时间。
| 检查项 | 具体内容 |
|---|---|
| 模块名称 | 模块名和文件名是否一致,方便查找 |
| 端口方向 | 每个端口是否都有明确的方向声明 |
| 信号类型 | 端口信号用 wire,always 内赋值的信号用 reg |
| 位宽 | 所有向量信号是否声明了位宽,运算是否可能截断 |
| 时钟与复位 | 时序逻辑敏感列表是否包含所有必要的时钟沿和异步复位 |
| 阻塞非阻塞 | 时序逻辑是否用<=,组合逻辑是否用= |
| begin/end 配对 | 多语句块是否都有 begin/end 包裹 |
| 组合逻辑完备性 | 是否所有分支都有赋值,默认值是否设置 |
| 状态机 | 是否覆盖 default 分支,next_state 是否先赋当前值 |
| 参数化 | 常量定义使用 localparam,外部可配置使用 parameter |
| 例化方式 | 是否按名字例化,端口是否一一对应 |
| 双向端口 | inout 信号释放时是否赋予高阻态1'bz |
| 编译检查 | iverilog是否零错误输出 |
我个人在提交代码前,一定会从头到尾把这些项目过一遍。说实话,这张表看起来简单,但每一条都是从实际项目踩坑里提炼出来的。比如位宽截断,早期我以为编译器总会自动扩展,结果在仿真里算出一个诡异的溢出的数字,查了半天才发现是两个窄位宽信号直接相加造成的。又比如双向端口的高阻态,逻辑上就少写了一行,整个 I2C 读操作就是读不到数据。这些经验用一句话讲就是:语法检查工具能帮你排除拼写和结构错误,但逻辑和位宽问题,要靠自己的代码纪律来守住。
最后再分享一个小习惯:我每完成一个模块,会单独建一个check目录,把模块文件和最小 testbench 放进去,用iverilog编译一遍。这看起来多了一步,但实际上比最后整个工程一起报错、面对几十条 error 节省太多时间。语法这东西,写得多了自然就熟了,但有了工具和清单,你不用把所有细节都背下来,也能写出稳定、规范、可维护的 Verilog 代码。