3. Verilog基础回顾:模块化设计、组合逻辑与时序逻辑、阻塞与非阻塞赋值、状态机设计
说实话,很多朋友一上来就追着问我:「能不能直接讲算法加速?Verilog 我早就会了。」
但我得说句实在话——我见过太多项目翻车,最后查出来都是基础没打牢。比如阻塞赋值用错了地方,状态机跑飞了,或者模块接口乱成一团。这些坑,我当年都踩过。
所以这一章,咱们把 Verilog 里最核心的几个点再过一遍。不是为了讲语法,而是为了让你在写加速器的时候,脑子里有清晰的硬件思维。
本章核心:不是学语法,而是建立「硬件思维」。写 Verilog 跟写 C 完全是两码事,你写的每一行代码,最终都会变成实实在在的门电路。
3.1 模块化设计:把大问题拆成小零件
模块化设计,说白了就是「分而治之」。一个复杂的硬件加速器,你不可能在一个文件里写完所有逻辑。我个人的习惯是:每个功能单元独立成一个模块,然后像搭积木一样把它们拼起来。
模块的基本结构:
module adder #(
parameter WIDTH = 8
)(
input wire [WIDTH-1:0] a,
input wire [WIDTH-1:0] b,
output wire [WIDTH-1:0] sum
);
assign sum = a + b;
endmodule
你看,一个加法器模块就这么简单。但要注意几点:
- 端口方向要明确:input、output、inout 别搞混。我见过有人把输出写成 input,仿真能过,综合直接报错。
- 参数化设计:用
parameter定义位宽,这样模块可以复用。比如上面的WIDTH,例化时可以改成 16 或 32。 - 层次化例化:顶层模块只管连线,不写逻辑。
我的经验:在写加速器时,我会把每个计算单元(比如卷积核、池化层)都封装成独立模块。这样调试的时候,哪个模块出问题,直接定位,不用翻几千行代码。
3.2 组合逻辑与时序逻辑:一个管「现在」,一个管「以后」
这个区分太重要了。你想想看:
- 组合逻辑:输出只取决于当前输入。没有记忆,没有时钟。比如加法器、多路选择器。
- 时序逻辑:输出不仅取决于当前输入,还取决于之前的状态。需要时钟沿触发,有寄存器。
组合逻辑示例:
always @(*) begin
if (sel)
out = a;
else
out = b;
end
时序逻辑示例:
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 0;
else
q <= d;
end
这里有个关键点:组合逻辑的敏感列表用 @(*),时序逻辑用 @(posedge clk)。我刚开始学的时候,经常把敏感列表写错,结果仿真波形怎么看都不对。
避坑指南:组合逻辑里不要产生锁存器(latch)。如果你在组合逻辑的 always 块里没有把所有分支写全,综合工具会给你生成一个 latch。我曾经因为这个原因,导致一个加速器的控制逻辑在特定条件下卡死,查了两天才找到问题。
3.3 阻塞与非阻塞赋值:一字之差,天壤之别
这是 Verilog 里最容易踩的坑,没有之一。
| 赋值方式 | 符号 | 适用场景 | 执行特点 |
|---|---|---|---|
| 阻塞赋值 | = |
组合逻辑 | 立即执行,顺序执行 |
| 非阻塞赋值 | <= |
时序逻辑 | 并行执行,在 always 块结束时统一更新 |
为什么会有这种区别?
说白了,硬件是并行的。你写 a <= b; c <= a; 在时序逻辑里,c 拿到的是 a 的旧值,而不是 b 的值。这模拟了寄存器更新的真实行为——所有寄存器在时钟沿同时采样。
错误示例:
// 错误:在时序逻辑里用阻塞赋值
always @(posedge clk) begin
a = b;
c = a; // c 拿到的是 a 的新值,不是硬件行为
end
正确示例:
// 正确:时序逻辑用非阻塞赋值
always @(posedge clk) begin
a <= b;
c <= a; // c 拿到的是 a 的旧值,符合硬件行为
end
黄金法则:组合逻辑用 =,时序逻辑用 <=。别问为什么,记住就行。我见过太多人混用,结果仿真和综合结果不一致,最后流片回来才发现问题。
3.4 状态机设计:控制逻辑的灵魂
在硬件加速器里,状态机负责调度整个计算流程。比如卷积加速器,需要控制数据加载、计算、写回等步骤。
我推荐三段式状态机:
- 第一段:时序逻辑,状态跳转
- 第二段:组合逻辑,次态判断
- 第三段:时序逻辑,输出赋值
三段式状态机模板:
// 状态定义
localparam IDLE = 2'b00;
localparam LOAD = 2'b01;
localparam CALC = 2'b10;
localparam WRITE = 2'b11;
reg [1:0] state, next_state;
// 第一段:状态跳转
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
// 第二段:次态判断(组合逻辑)
always @(*) begin
case (state)
IDLE: next_state = start ? LOAD : IDLE;
LOAD: next_state = load_done ? CALC : LOAD;
CALC: next_state = calc_done ? WRITE : CALC;
WRITE: next_state = write_done ? IDLE : WRITE;
default: next_state = IDLE;
endcase
end
// 第三段:输出赋值(时序逻辑)
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
data_en <= 0;
calc_en <= 0;
end else begin
case (state)
LOAD: data_en <= 1;
CALC: calc_en <= 1;
WRITE: data_en <= 0;
default: begin
data_en <= 0;
calc_en <= 0;
end
endcase
end
end
为什么用三段式?
- 第一段管状态跳转,简单明了。
- 第二段管次态逻辑,纯组合,不产生寄存器。
- 第三段管输出,时序逻辑,输出无毛刺。
我的习惯:状态编码尽量用独热码(one-hot)或者格雷码。独热码译码简单,适合 FPGA;格雷码相邻状态只变一位,适合跨时钟域。我曾经在一个高速加速器里用二进制编码,结果状态跳转时出现毛刺,导致控制逻辑误触发,后来改成独热码就解决了。
避坑指南:状态机一定要有 default 分支。如果状态跑飞了(比如因为单粒子翻转),没有 default 分支,状态机就卡死了。我曾经在一个卫星通信项目里吃过这个亏,后来所有状态机都加了 default 回到 IDLE。
3.5 小结:基础不牢,地动山摇
这一章的内容,说白了就是 Verilog 的「内功心法」。模块化设计让你代码清晰,组合时序逻辑让你理解硬件本质,阻塞非阻塞赋值让你避免仿真综合不一致,状态机让你控制逻辑稳健。