文章来源:FPGA入门到精通
Verilog 写了几个月才搞明白的 5 个坑。
写 Verilog 这事,一开始觉得跟写 C 差不多,直到仿真跑得漂漂亮亮,上板波形全是乱的,才发现这门语言的坑不是你看教程能避开的,得自己踩过才长记性。
第一个,同一个 always 块里千万别混用 = 和 <=。
时序逻辑用非阻塞赋值 <=,组合逻辑用阻塞赋值 =,这条规则很多人知道,但写代码写顺手了就容易混。
比如你写一个带状态机的模块,时序部分用了 <=,但里面有个组合逻辑的中间变量顺手也写了 <=。仿真的时候可能没事,因为仿真器按顺序执行的,但综合工具对非阻塞赋值的处理是在时间步末尾才更新值,综合出来的电路跟仿真结果可能完全不一样。
排查这种 bug 特别痛苦,因为仿真波形看着是对的,上板就不对。你拿着仿真结果跟综合结果对比,对不上,根本不知道从哪找起。
解决办法就是养成肌肉记忆,看到 always @(posedge clk) 就只写 <=,看到 always @(*) 就只写 =,想都不想。
正确写法是这样的:
always @(posedge clk) begin
data_r <= data_w; // 时序逻辑,非阻塞
valid_r <= valid_w;
end
always @(*) begin
result_w = data_r + 1; // 组合逻辑,阻塞
ready_w = ~full_r;
end
错误示范,绝对不要这么写:
always @(posedge clk) begin
data_r <= data_w;
result_w = data_r + 1; // 错!时序块里混了阻塞赋值
end
第二个,别让两个 always 块写同一个信号。
这个坑比第一个更隐蔽。你在一个 always 块里赋值了一个信号,另一个 always 块里又对它赋了一次值,综合工具会报 multiple driver 警告。如果你忽略了这个警告跑下去,综合出来的电路行为是不确定的,取决于工具的实现。
最常见的情况是:你在时序逻辑里初始化了一个寄存器,又在组合逻辑里根据某个条件对它赋值,觉得这样写逻辑上没问题。但综合器看到的是两个驱动源在竞争同一个信号,它不会帮你做仲裁,只会给你一个不确定的结果。波形上表现为信号值随机跳变,而且不是每个时钟周期都跳,是偶尔跳一下,排查起来跟中邪了一样。
养成一个习惯:每个信号只在唯一一个 always 块里被赋值,绝不重复。如果你确实需要在不同条件下驱动同一个信号,把它们合并到一个 always 块里用 if-else:
// 错误写法:两个 always 块驱动同一个信号
always @(posedge clk) begin
if (mode_r == 2'b00)
data_out_r <= data_a_r;
end
always @(posedge clk) begin
if (mode_r == 2'b01)
data_out_r <= data_b_r; // 多 driver 警告
end
// 正确写法:合并到一个 always 块
always @(posedge clk) begin
case (mode_r)
2'b00: data_out_r <= data_a_r;
2'b01: data_out_r <= data_b_r;
default: data_out_r <= 8'h00;
endcase
end
第三个,信号命名加后缀,排错的时候非常方便。
这个习惯是我反复踩坑之后才养成的。一开始觉得信号名怎么取无所谓,能看懂就行。但项目一大,模块一多,一个信号到底是 reg 还是 wire,是在这个模块里声明的还是从端口进来的,光看名字根本分不清。
后来在开源项目里看到一种命名约定:时序寄存器加 _r 后缀,比如 data_r、cnt_r;组合逻辑中间变量加 _w;下一状态信号加 _next,比如 state_next。配合参数化命名,模块内部一百多个信号,扫一眼名字就知道类型,不用回顶部翻声明。
说真的这个习惯不花任何成本,就是命名的时候多打两三个字母,但排错效率至少翻一倍。看个对比就知道了:
// 没有后缀,分不清类型
wire [7:0] result;
reg [3:0] count;
reg [7:0] result_next;
// 加后缀,一眼看出类型
wire [7:0] data_w; // wire,组合逻辑
reg [7:0] data_r; // reg,时序寄存器
reg [7:0] data_next_w; // 下一状态,组合逻辑
reg [3:0] cnt_r; // 计数器寄存器
wire [3:0] cnt_next_w; // 计数器下一状态
第四个,parameter、localparam、`define 三者别乱用。
初学 Verilog 的时候最容易犯的错就是:不管什么常量都用 define。define 是全局宏,一旦定义了,整个项目都能看到,而且不会被模块实例化时覆盖。你在一个模块里定义了一个 define DATA_WIDTH 8,另一个模块里如果也定义了同名宏,直接冲突,报错信息还特别不友好。
正确的做法是:模块内部固定不变的参数用 localparam,比如状态机的状态编码、内部常量。需要在上层实例化时修改的参数用 parameter,比如 FIFO 深度、位宽。只有跨文件的全局常量才考虑 define,而且命名要加模块前缀防止冲突。三种用法一目了然:
// `define:全局宏,作用域整个文件,慎用
`define FIFO_MODULE_DEPTH 16
module fifo #(
parameter WIDTH = 8, // 可被上层覆盖
parameter DEPTH = `FIFO_MODULE_DEPTH
)(
input clk,
...
);
// localparam:模块内部锁定,外部改不了
localparam ADDR_W = $clog2(DEPTH);
localparam IDLE = 3'b000;
localparam WRITE = 3'b001;
localparam READ = 3'b010;
endmodule
第五个,generate + for 可以砍掉一半重复代码。
这个技巧知道的人不多,但用上了就回不去了。比如你要写一个 8 级流水线,每级做同样的操作,只是级联的信号不同。不用 generate 的话,你要手写 8 段几乎一模一样的 always 块,改一个地方要改 8 处。
用 generate + for,代码量直接砍到原来的八分之一。写法是外层用 generate,里面套 for 循环和 always 块,循环变量用 genvar 声明。
FIFO 的读写指针、多通道并行处理、参数化的级联结构,全都可以这么干。维护的时候只改一处,所有级联同步更新,再也不会出现改了第三级忘了改第五级的尴尬。
// 不用 generate:手写 8 级流水线,重复代码一大堆
always @(posedge clk) begin
stage0_r <= data_in;
stage1_r <= stage0_r + 1;
stage2_r <= stage1_r + 1;
stage3_r <= stage2_r + 1;
// ... 还有 4 级,手写到吐
end
// 用 generate:4 行搞定任意级数
genvar i;
generate
for (i = 0; i < NUM_STAGES; i = i + 1) begin : gen_stage
always @(posedge clk) begin
if (i == 0)
stage_r[i] <= data_in;
else
stage_r[i] <= stage_r[i-1] + 1;
end
end
endgenerate
到这里聊的都是防坑,接下来的一个是让你写得更聪明。Verilog 不难,难的是不知道自己写的代码综合出来是什么东西。这 5 个习惯养成了,能避开大部分新手踩的坑,剩下那部分就得靠实战积累了。