3、硬件描述语言基础(Verilog):模块化设计、组合逻辑与时序逻辑、状态机、仿真与测试平台
各位同学,欢迎来到第三章。这一章我们聊聊Verilog,这是咱们做嵌入式推理加速器的“砖瓦”。说白了,你脑子里再好的架构,再牛的算法,最后都得靠它变成电路。
我个人习惯把Verilog看作一种“描述硬件行为的语言”,而不是编程语言。你写C代码,编译器给你翻译成指令;你写Verilog,综合器给你映射成门电路。这个思维转变很重要。我见过不少新手,上来就用C语言的思维写Verilog,结果综合出来的电路一塌糊涂。
3.1 模块化设计:搭积木的艺术
模块化,说白了就是“分而治之”。一个复杂的加速器,比如卷积计算单元,你不可能在一个模块里写完所有逻辑。那样做,不仅你自己看着头疼,综合工具也会崩溃。
我建议你把每个功能单元都封装成一个独立的模块。比如,一个乘法器、一个加法树、一个累加器,各干各的。
核心原则:高内聚,低耦合。每个模块只做一件事,并且把它做好。
举个例子,一个简单的D触发器模块:
module d_flip_flop (
input wire clk,
input wire rst_n,
input wire d,
output reg q
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 1'b0;
else
q <= d;
end
endmodule
你看,这个模块的接口很清晰:时钟、复位、数据输入、数据输出。内部逻辑也很简单。这就是模块化的好处——你可以单独测试它,然后放心地把它用到更大的系统里。
我的经验:在写模块之前,先画个接口图。把输入输出信号、位宽、功能都列清楚。这能避免后期大量返工。我曾经在一个项目里,因为接口定义不清,导致顶层连线连了三天,最后发现信号位宽对不上,那叫一个崩溃。
3.2 组合逻辑与时序逻辑:硬件的“阴阳”两面
这是Verilog里最基础,也最容易搞混的概念。你想想看,组合逻辑就是“输入一变,输出立马变”,没有记忆功能。时序逻辑则相反,它依赖于时钟,输出不仅取决于当前输入,还取决于之前的状态。
组合逻辑:用 assign 或者 always @(*) 来描述。比如一个加法器:
assign sum = a + b;
或者:
always @(*) begin
case (sel)
2'b00: out = a;
2'b01: out = b;
2'b10: out = c;
default: out = 1'b0;
endcase
end
时序逻辑:用 always @(posedge clk) 来描述。比如一个计数器:
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
cnt <= 8'b0;
else
cnt <= cnt + 1'b1;
end
避坑指南:我曾经犯过一个低级错误——在组合逻辑的always块里用了非阻塞赋值 <=。综合出来的电路仿真怎么都不对。记住:组合逻辑用阻塞赋值 =,时序逻辑用非阻塞赋值 <=。这是铁律,别问为什么,先记住。
为什么会有这个区别?因为非阻塞赋值是“并行”的,它会在always块结束时统一更新。而阻塞赋值是“顺序”的,立即生效。在时序逻辑里,我们需要模拟寄存器的行为——所有寄存器在时钟沿同时采样。所以必须用非阻塞赋值。
3.3 状态机:控制逻辑的灵魂
在加速器里,状态机无处不在。比如,一个卷积计算单元,它需要经历“加载数据 -> 计算 -> 写回结果”这几个状态。状态机就是用来管理这些状态的。
我习惯把状态机分成三段式来写。这样结构清晰,综合效果也好。
三段式状态机模板:
// 第一段:状态寄存器
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
current_state <= IDLE;
else
current_state <= next_state;
end
// 第二段:次态逻辑(组合逻辑)
always @(*) begin
case (current_state)
IDLE: begin
if (start)
next_state = LOAD;
else
next_state = IDLE;
end
LOAD: begin
if (load_done)
next_state = COMPUTE;
else
next_state = LOAD;
end
COMPUTE: begin
if (compute_done)
next_state = WRITE_BACK;
else
next_state = COMPUTE;
end
WRITE_BACK: begin
if (write_done)
next_state = IDLE;
else
next_state = WRITE_BACK;
end
default: next_state = IDLE;
endcase
end
// 第三段:输出逻辑(组合逻辑或时序逻辑)
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
data_out <= 32'b0;
done <= 1'b0;
end else begin
case (current_state)
IDLE: begin
done <= 1'b0;
end
COMPUTE: begin
data_out <= result;
done <= 1'b1;
end
default: begin
data_out <= 32'b0;
done <= 1'b0;
end
endcase
end
end
为什么用三段式?第一段是时序逻辑,负责状态跳转。第二段是组合逻辑,负责计算下一个状态。第三段是输出逻辑。这样做的最大好处是:避免产生“组合逻辑反馈环路”,而且代码可读性极强。你想想看,如果所有逻辑都写在一个always块里,后期维护起来有多痛苦。
嗯,这里要注意:第二段是组合逻辑,所以用阻塞赋值 =。第三段我习惯用时序逻辑,这样输出会稳定一个时钟周期,不容易出现毛刺。
3.4 仿真与测试平台:验证你的设计
写完了代码,不仿真验证一下,你敢直接拿去流片吗?反正我不敢。仿真测试平台(Testbench)就是用来干这个的。
一个基本的Testbench结构:
module tb_conv_unit;
// 定义信号
reg clk;
reg rst_n;
reg [31:0] data_in;
wire [31:0] data_out;
wire done;
// 实例化待测模块
conv_unit u_conv_unit (
.clk(clk),
.rst_n(rst_n),
.data_in(data_in),
.data_out(data_out),
.done(done)
);
// 生成时钟
always #5 clk = ~clk; // 10ns周期,100MHz
// 测试激励
initial begin
// 初始化
clk = 0;
rst_n = 0;
data_in = 32'b0;
// 复位
#20 rst_n = 1;
// 加载数据
#10 data_in = 32'hA5A5A5A5;
// 等待计算完成
@(posedge done);
// 检查结果
if (data_out == 32'h5A5A5A5A)
$display("Test Passed!");
else
$display("Test Failed!");
// 结束仿真
#100 $finish;
end
// 监控信号
initial begin
$monitor("Time=%0t, clk=%b, rst_n=%b, data_in=%h, data_out=%h, done=%b",
$time, clk, rst_n, data_in, data_out, done);
end
endmodule
我的习惯:写Testbench时,我会先写一个“冒烟测试”——就是最简单的功能验证,确保模块能跑起来。然后再写“边界测试”——比如输入最大值、最小值、零值等。最后写“随机测试”——用 $random 生成随机激励,看看模块会不会在极端情况下崩溃。我曾经用随机测试发现过一个隐藏很深的溢出bug,要是没发现,芯片回来肯定是个废片。
仿真工具方面,我个人推荐用Vivado Simulator或者ModelSim/Questa。开源的Icarus Verilog(iverilog)也够用,配合GTKWave看波形,学习阶段完全没问题。
注意:仿真通过不代表综合后也能通过。因为仿真器是“理想化”的,它不考虑门延迟、布线延迟。所以,仿真通过后,一定要做“后仿真”(门级仿真),带上SDF标准延迟文件。这一步不能省,我吃过亏。
好了,这一章的内容就到这里。模块化设计让你能驾驭复杂系统,组合逻辑和时序逻辑是硬件的基石,状态机是控制逻辑的核心,仿真测试平台是你最后的防线。把这几点吃透了,你就能写出靠谱的Verilog代码。
下一章,我们会把这些知识串起来,开始设计一个真正的卷积加速器单元。到时候,你会看到这些基础概念是如何在实际项目中发挥作用的。