第四章 硬件描述语言基础(Verilog):模块化设计、组合逻辑与时序逻辑、状态机编写规范
各位同学,欢迎来到第四章。从这一章开始,我们正式进入硬件描述语言的世界。说实话,Verilog 这门语言,你把它当成软件来写,那肯定要踩坑。我见过太多人把 FPGA 当 CPU 用,结果综合出来的电路完全不是那么回事。
这一章我们聚焦三个核心:模块化设计、组合逻辑与时序逻辑、状态机编写规范。这三个东西,说白了就是 FPGA 设计的骨架、血肉和大脑。我们一个一个来拆解。
4.1 模块化设计:把大问题拆成小积木
模块化设计,说白了就是「分而治之」。一个大的 AI 加速器,你不可能在一个文件里写完所有逻辑。我个人的习惯是:一个模块只干一件事,接口要少,功能要纯。
举个例子,我们要设计一个卷积计算单元。你想想看,如果我把输入缓存、乘法累加、激活函数、输出缓冲全部写在一个 always 块里,那代码维护起来简直是噩梦。正确的做法是拆成四个模块:
- input_buffer:负责数据重排和缓存
- mac_array:负责乘加运算
- activation_unit:负责 ReLU / Sigmoid 等激活
- output_buffer:负责结果暂存和输出
每个模块独立仿真、独立验证。我在项目中遇到过一个问题:某个模块的时序不满足,结果因为耦合太紧,改一个地方牵连了七八个模块。从那以后,我强制要求团队:模块间用 valid-ready 握手信号通信,内部逻辑不许跨模块引用。
模块化设计三原则:
- 高内聚:一个模块只做一件事,并且做好
- 低耦合:模块间通过标准接口通信,不直接操作内部寄存器
- 层次化:顶层只做连线,不写逻辑
来看一个简单的模块定义示例:
// 一个简单的累加器模块
module accumulator #(
parameter DATA_WIDTH = 16
)(
input wire clk,
input wire rst_n,
input wire valid_in,
input wire [DATA_WIDTH-1:0] data_in,
output reg [DATA_WIDTH-1:0] sum_out,
output reg valid_out
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
sum_out <= 'd0;
valid_out <= 1'b0;
end else if (valid_in) begin
sum_out <= sum_out + data_in;
valid_out <= 1'b1;
end else begin
valid_out <= 1'b0;
end
end
endmodule
注意看,这个模块的接口非常清晰:时钟、复位、输入数据、输出结果。外部调用者只需要关心 valid 信号,不需要知道内部是怎么累加的。这就是模块化的精髓。
4.2 组合逻辑与时序逻辑:硬件工程师的左右手
很多初学者搞不清楚组合逻辑和时序逻辑的区别。我打个比方:组合逻辑就像水管,水流过去就立刻出来;时序逻辑就像水桶,要等时钟来了才舀一勺。
在 Verilog 中,组合逻辑用 assign 或者 always @(*) 描述,时序逻辑用 always @(posedge clk) 描述。这里有个关键点:阻塞赋值 (=) 和非阻塞赋值 (<=) 绝对不能混用。
我曾经在调试一个 AI 加速器的数据路径时,发现计算结果总是差一拍。查了两天才发现,有人在组合逻辑里用了非阻塞赋值,导致仿真和综合结果不一致。嗯,这个坑我替你们踩过了。
重要规则:
- 组合逻辑 always 块内:使用 阻塞赋值 (=)
- 时序逻辑 always 块内:使用 非阻塞赋值 (<=)
- 同一个 always 块内:不要混用两种赋值方式
来看一个对比示例:
// 组合逻辑:多路选择器
always @(*) begin
case (sel)
2'b00: mux_out = a;
2'b01: mux_out = b;
2'b10: mux_out = c;
default: mux_out = 'd0;
endcase
end
// 时序逻辑:带使能的寄存器
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
data_q <= 'd0;
end else if (en) begin
data_q <= data_d;
end
end
你想想看,如果我把组合逻辑里的 = 换成 <=,仿真时可能看不出问题,但综合出来的电路会多出很多不必要的锁存器。这就是为什么我一直强调:写代码的时候就要想清楚,你描述的是硬件,不是软件。
4.3 状态机编写规范:让控制逻辑井井有条
状态机是 FPGA 设计的核心控制单元。在大模型加速器中,状态机负责调度数据流、控制计算单元、管理存储访问。我个人强烈推荐三段式状态机,它把状态跳转、状态判断、输出逻辑分得清清楚楚。
三段式状态机的结构:
| 段落 | 描述 | always 块类型 |
|---|---|---|
| 第一段 | 状态寄存器更新(当前状态 → 下一状态) | 时序逻辑 (posedge clk) |
| 第二段 | 下一状态组合逻辑判断(根据当前状态和输入) | 组合逻辑 @(*) |
| 第三段 | 输出逻辑(根据当前状态产生控制信号) | 组合逻辑 @(*) 或时序逻辑 |
来看一个实际的状态机示例,这是一个简单的数据包处理状态机:
// 三段式状态机示例
module packet_fsm (
input wire clk,
input wire rst_n,
input wire sop, // start of packet
input wire eop, // end of packet
input wire data_val,
output reg fifo_wr,
output reg pkt_done
);
// 状态编码(用独热码,综合效率高)
localparam IDLE = 3'b001;
localparam HEADER = 3'b010;
localparam PAYLOAD = 3'b100;
reg [2: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: begin
if (sop && data_val)
next_state = HEADER;
else
next_state = IDLE;
end
HEADER: begin
// 头处理完成后进入负载
next_state = PAYLOAD;
end
PAYLOAD: begin
if (eop)
next_state = IDLE;
else
next_state = PAYLOAD;
end
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑(组合逻辑)
always @(*) begin
fifo_wr = 1'b0;
pkt_done = 1'b0;
case (state)
HEADER: begin
fifo_wr = data_val; // 写头信息到FIFO
end
PAYLOAD: begin
fifo_wr = data_val; // 写负载数据
if (eop)
pkt_done = 1'b1; // 包结束标志
end
default: begin
fifo_wr = 1'b0;
pkt_done = 1'b0;
end
endcase
end
endmodule
状态机编写避坑指南:
- 状态编码:状态少用二进制,状态多用独热码(综合后更省资源)
- 默认状态:case 语句一定要加 default,防止综合出锁存器
- 输出赋值:第三段输出逻辑中,先给所有输出赋默认值,再根据状态改写
- 避免冒险:组合逻辑输出要小心毛刺,必要时加一级寄存器打拍
我曾经接手过一个项目,里面的状态机把三段混在一起写,状态跳转和输出逻辑全在一个 always 块里。结果呢?代码可读性极差,改一个状态要小心翼翼,生怕影响输出。后来我花了两天时间重构,拆成三段式,问题迎刃而解。
最后说一句:写状态机的时候,脑子里要有一张状态转移图。先画图,再写代码,这是最稳妥的做法。你想想看,如果连状态图都没想清楚,写出来的代码能靠谱吗?
好了,这一章的内容就到这里。模块化设计让你搭好框架,组合逻辑和时序逻辑让你填好血肉,状态机让你赋予设计灵魂。这三样东西练扎实了,后面的大模型算子加速设计,你就能游刃有余。
公众号:蓝海资料掘金营,微信deep3321