第三讲:Verilog基础与流水线思维
各位同学,今天咱们聊点硬核的。组合逻辑、时序逻辑、阻塞赋值、非阻塞赋值,还有流水线——这些概念是FPGA设计的基石。我做了十几年FPGA,说句实话,很多人栽跟头就栽在这些基础概念上。
你想想看,写Verilog和写软件最大的区别在哪?软件是顺序执行的,而硬件是并发的。这个思维转变,是入门的第一道坎。
组合逻辑与时序逻辑
先说说组合逻辑。说白了,就是输入一变,输出立马跟着变。没有时钟,没有存储,纯粹的门电路搭出来的东西。比如一个与门,输入A和B都是1,输出就是1,没有任何延迟的概念——当然,实际物理上是有门延迟的,但咱们做RTL设计时先不考虑这个。
时序逻辑就不一样了。它靠时钟驱动,在时钟沿到来的时候才更新状态。触发器(Flip-Flop)就是最典型的时序逻辑元件。我刚开始学的时候,总搞不清什么时候用组合逻辑,什么时候用时序逻辑。后来我总结了一个经验:凡是需要记住状态的,必须用时序逻辑。
核心区别一句话总结:
- 组合逻辑:输入决定输出,没有记忆
- 时序逻辑:时钟沿触发,有记忆能力
来看个简单的例子。一个加法器,用组合逻辑实现:
// 组合逻辑加法器
module adder_comb (
input [7:0] a,
input [7:0] b,
output [7:0] sum
);
assign sum = a + b;
endmodule
这个代码里,a和b一变,sum立刻跟着变。没有时钟,没有寄存器。
那如果我想在每个时钟周期才更新一次结果呢?就得用时序逻辑:
// 时序逻辑加法器
module adder_seq (
input clk,
input [7:0] a,
input [7:0] b,
output reg [7:0] sum
);
always @(posedge clk) begin
sum <= a + b;
end
endmodule
看到了吗?多了个clk信号,而且用了always @(posedge clk)。这就是时序逻辑的标志。
阻塞赋值与非阻塞赋值
好,接下来这个坑,我敢说每个FPGA工程师都踩过——阻塞赋值(=)和非阻塞赋值(<=)的区别。
先记住一条铁律:组合逻辑用阻塞赋值,时序逻辑用非阻塞赋值。别问为什么,先记住,后面慢慢理解。
阻塞赋值,顾名思义,它是"阻塞"的。在always块里,阻塞赋值是顺序执行的。上一句没执行完,下一句就等着。而非阻塞赋值是并行的,所有赋值操作在时钟沿同时发生。
我举个例子,你一看就明白了:
// 错误示范:时序逻辑用了阻塞赋值
always @(posedge clk) begin
a = b;
c = a;
end
这段代码,如果用阻塞赋值,会发生什么?在时钟沿到来时,先把b赋给a,然后把a(此时已经是b的值)赋给c。结果就是a和c都变成了b的值。但这是你想要的行为吗?通常不是。你想要的可能是a和c同时更新,各自取原来的值。
用非阻塞赋值就对了:
// 正确写法:时序逻辑用非阻塞赋值
always @(posedge clk) begin
a <= b;
c <= a;
end
这样,在时钟沿到来时,a拿到b的值,c拿到a原来的值。两者是同时发生的。这就是硬件并发的本质。
我曾经踩过的坑:
有一次做高速接口设计,时序怎么都跑不过。查了两天,最后发现是一个always块里混用了阻塞和非阻塞赋值。仿真没问题,但综合出来的电路完全不是我想的那样。从那以后,我每个always块都严格检查赋值方式。
流水线设计思想
好了,重头戏来了。流水线(Pipeline)是FPGA加速的核心思想之一。说白了,就是把一个大的组合逻辑拆成几个小段,每段之间用寄存器隔开。
为什么要这么做?因为组合逻辑越大,路径延迟就越长,能跑的最高频率就越低。你想想看,一个复杂的计算,比如一个32位乘法器,组合逻辑延迟可能有10ns。那你的时钟周期就不能小于10ns,也就是频率不能超过100MHz。
但如果我把这个乘法器拆成5级流水线,每级只做一小部分工作,每级的延迟可能只有2ns。那时钟周期就可以做到2ns,频率直接飙到500MHz!
当然,代价是延迟增加了。原来1个时钟周期出结果,现在要5个时钟周期。但吞吐量上去了——每时钟周期都能出一个结果。
流水线的核心思想:
- 用延迟换吞吐量
- 用寄存器打断关键路径
- 让每一级逻辑尽量均衡
来看一个具体的例子。假设我们要做一个4级流水线的加法器链:
// 4级流水线加法器链
module pipeline_adder (
input clk,
input rst_n,
input [7:0] a,
input [7:0] b,
input [7:0] c,
input [7:0] d,
output reg [7:0] result
);
reg [7:0] stage1_a, stage1_b;
reg [7:0] stage2_sum1, stage2_c;
reg [7:0] stage3_sum2, stage3_d;
reg [7:0] stage4_sum3;
// 第一级:输入寄存器
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
stage1_a <= 8'd0;
stage1_b <= 8'd0;
end else begin
stage1_a <= a;
stage1_b <= b;
end
end
// 第二级:计算 a+b,同时寄存 c
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
stage2_sum1 <= 8'd0;
stage2_c <= 8'd0;
end else begin
stage2_sum1 <= stage1_a + stage1_b;
stage2_c <= c;
end
end
// 第三级:计算 (a+b)+c,同时寄存 d
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
stage3_sum2 <= 8'd0;
stage3_d <= 8'd0;
end else begin
stage3_sum2 <= stage2_sum1 + stage2_c;
stage3_d <= d;
end
end
// 第四级:计算最终结果
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
stage4_sum3 <= 8'd0;
end else begin
stage4_sum3 <= stage3_sum2 + stage3_d;
end
end
assign result = stage4_sum3;
endmodule
这个例子,每一级只做一个加法,路径很短。4个时钟周期后,第一个结果出来,之后每个时钟周期都能出一个结果。
性能提升原理
流水线为什么能提升性能?咱们用数据说话。
假设一个组合逻辑的延迟是T,不流水线的话,最大时钟周期就是T,吞吐量是1/T。
如果分成N级流水线,每级延迟大约是T/N(加上寄存器延迟T_reg),时钟周期可以做到T/N + T_reg。吞吐量就是N/(T + N*T_reg)。
当N足够大时,吞吐量趋近于1/T_reg。也就是说,流水线的极限性能受限于寄存器本身的延迟。
| 流水线级数 | 时钟周期 | 延迟 | 吞吐量 |
|---|---|---|---|
| 1级(无流水线) | 10ns | 10ns | 100M结果/秒 |
| 2级 | 5.5ns | 11ns | 182M结果/秒 |
| 4级 | 3.25ns | 13ns | 308M结果/秒 |
| 8级 | 2.125ns | 17ns | 470M结果/秒 |
看到没?流水线级数越多,吞吐量越高,但延迟也在增加。这就是我说的"用延迟换吞吐量"。
个人经验:
我一般不会把流水线做到超过10级。级数太多,控制逻辑变复杂,而且寄存器延迟占比越来越大,收益递减。另外,流水线深度太深,芯片面积也受不了。做设计要权衡,不是越深越好。
流水线的实际应用
在大模型推理加速中,流水线无处不在。比如矩阵乘法、卷积运算、注意力机制,这些计算密集型操作,几乎都是用流水线实现的。
我记得有一次做Transformer的加速器,一个矩阵乘法模块,输入是512x512的矩阵。如果不用流水线,一个乘法器要算262144次乘法,延迟大得吓人。后来我用了8级流水线的乘法器阵列,每个时钟周期能出64个结果,吞吐量直接提升了8倍。
当然,流水线设计也有坑。比如流水线冒险(Hazard)——数据相关、控制相关,处理不好就会出bug。这个咱们后面会专门讲。
嗯,今天就先聊到这儿。流水线思维是FPGA加速的灵魂,你把它吃透了,后面学什么都快。
这张图很直观地展示了流水线的核心思想。无流水线时,一个大的组合逻辑块,时钟周期被延迟拖累。加了流水线后,逻辑被切碎,每级之间用寄存器隔开,时钟周期大幅缩短,吞吐量成倍提升。
记住,流水线不是银弹。它适合计算密集、数据流稳定的场景。如果控制逻辑复杂、分支多,流水线反而会引入冒险,得不偿失。这个咱们后面会详细讲。
好,今天就到这儿。下一讲咱们聊聊状态机和控制通路,这是把流水线串起来的关键。