第二章 硬件描述语言基础(Verilog/VHDL):模块化设计、组合逻辑与时序逻辑、仿真与测试
好,咱们进入正题。这一章是FPGA设计的“内功心法”。你想想看,不管多复杂的交易信号处理系统,拆到最底层,无非就是一堆逻辑门和触发器在干活。而硬件描述语言,就是我们指挥这些门和触发器干活的语言。
我个人习惯用Verilog,因为它在量化交易领域更流行,代码也更简洁。当然VHDL也有它的铁粉,语法严谨,适合大型工程。但核心思想是一样的——描述硬件,而不是写软件。
2.1 模块化设计:把大问题拆成小积木
做FPGA设计,最忌讳的就是“一把梭”。把所有逻辑写在一个模块里,后期调试能让你怀疑人生。我早期做高频交易信号处理时,就吃过这个亏。一个模块几千行,改一个地方,仿真全崩。
模块化设计,说白了就是“分而治之”。把整个系统拆成功能独立的小模块,每个模块只干一件事,干好一件事。
核心原则:
- 高内聚:一个模块内部的功能要紧密相关
- 低耦合:模块之间的接口要简单清晰
举个例子,一个简单的交易信号接收模块,可以拆成这样:
// 顶层模块:信号接收与解析
module signal_receiver_top (
input wire clk,
input wire rst_n,
input wire rx_data,
output reg [31:0] signal_price,
output reg signal_valid
);
// 实例化子模块
wire [7:0] byte_data;
wire byte_valid;
// 串口接收模块
uart_rx u_uart_rx (
.clk (clk),
.rst_n (rst_n),
.rx_data (rx_data),
.byte_data (byte_data),
.byte_valid (byte_valid)
);
// 协议解析模块
protocol_parser u_protocol_parser (
.clk (clk),
.rst_n (rst_n),
.byte_data (byte_data),
.byte_valid (byte_valid),
.signal_price (signal_price),
.signal_valid (signal_valid)
);
endmodule
你看,顶层模块只负责“搭积木”,具体怎么收串口数据、怎么解析协议,都交给子模块去干。这样,哪个模块出了问题,直接定位到那个模块去修就行。
我的习惯:每个模块的端口尽量控制在20个以内。超过20个,我就会考虑是不是该拆分了。接口信号命名要有规律,比如所有输入信号加个 _i 后缀,输出加个 _o,双向加 _io。这样看代码一目了然。
2.2 组合逻辑与时序逻辑:一个管“算”,一个管“存”
这是FPGA设计的核心概念,也是很多新手容易搞混的地方。我简单给你捋一捋。
组合逻辑:输出只取决于当前的输入。说白了,就是“即时响应”。输入一变,输出立马跟着变。没有记忆功能。
时序逻辑:输出不仅取决于当前输入,还取决于之前的状态。它是有“记忆”的,靠时钟沿来触发更新。
为什么会这样?因为FPGA底层的基本单元就是查找表(LUT)和触发器(FF)。LUT实现组合逻辑,FF实现时序逻辑。
来看个对比:
| 特性 | 组合逻辑 | 时序逻辑 |
|---|---|---|
| 输出依赖 | 仅当前输入 | 当前输入 + 历史状态 |
| 触发方式 | 输入变化立即触发 | 时钟沿触发 |
| 典型实现 | assign、always@(*) | always@(posedge clk) |
| 典型应用 | 加法器、译码器、多路选择器 | 计数器、状态机、寄存器 |
写代码时,有个黄金法则:组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=)。这个规矩千万别破,破了仿真结果和实际电路就对不上了。我曾经在项目里见过一个同事,时序逻辑里用了阻塞赋值,仿真跑得挺好,上板子就乱跳。查了两天才找到原因,教训深刻。
// 组合逻辑示例:加法器
always @(*) begin
sum = a + b; // 阻塞赋值
end
// 时序逻辑示例:寄存器
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
q <= 0; // 非阻塞赋值
else
q <= d;
end
2.3 仿真与测试:不仿真,不上板
这是我在团队里定的一条死规矩。任何模块,没有经过仿真验证,绝对不允许综合和上板。为什么?因为FPGA调试太痛苦了。你想想看,信号在芯片内部跑,你看不见摸不着,只能靠逻辑分析仪抓几个关键信号。要是问题出在内部状态机里,抓都抓不到。
仿真,就是让你在电脑上把代码跑一遍,看看波形对不对。常用的仿真工具有ModelSim、Vivado Simulator、VCS等。我个人偏爱Vivado自带的仿真器,够用,还不用额外装软件。
写测试文件(Testbench)有几个要点:
- 时钟和复位:先产生时钟和复位信号,这是所有时序逻辑的基础
- 输入激励:模拟真实场景,给模块输入各种数据
- 自动比对:别靠肉眼盯着波形看,写代码自动检查输出对不对
- 边界条件:测试最大值、最小值、溢出、复位等极端情况
// 简单的Testbench示例
module tb_signal_receiver_top;
reg clk;
reg rst_n;
reg rx_data;
wire [31:0] signal_price;
wire signal_valid;
// 实例化待测模块
signal_receiver_top u_dut (
.clk (clk),
.rst_n (rst_n),
.rx_data (rx_data),
.signal_price (signal_price),
.signal_valid (signal_valid)
);
// 产生时钟
always #5 clk = ~clk; // 100MHz时钟
// 测试过程
initial begin
clk = 0;
rst_n = 0;
rx_data = 1;
#20 rst_n = 1; // 释放复位
// 模拟发送一个字节 0xA5
send_byte(8'hA5);
#100;
// 检查结果
if (signal_valid && signal_price == 32'hA5)
$display("Test PASS: signal_price = %h", signal_price);
else
$display("Test FAIL: signal_price = %h, expected = 32'hA5", signal_price);
#100 $finish;
end
// 串口发送任务
task send_byte(input [7:0] data);
integer i;
begin
rx_data = 0; // 起始位
#87;
for (i = 0; i < 8; i = i + 1) begin
rx_data = data[i];
#87;
end
rx_data = 1; // 停止位
#87;
end
endtask
endmodule
注意:仿真通过不代表上板一定没问题。时序约束、时钟抖动、电源噪声这些,仿真里是模拟不出来的。但反过来,仿真都过不了,上板肯定不行。所以,仿真通过是“必要条件”,不是“充分条件”。
知识体系总览
为了让你对本章内容有个整体认识,我画了张图。你可以看到,模块化设计是骨架,组合逻辑和时序逻辑是血肉,仿真测试是检验手段。三者缺一不可。
嗯,这一章的内容就这些。模块化设计让你代码结构清晰,组合逻辑和时序逻辑让你明白硬件是怎么工作的,仿真测试帮你把bug扼杀在摇篮里。这三板斧练好了,后面的内容学起来就轻松多了。
一句话总结:写Verilog/VHDL,心里要时刻想着“这代码综合出来是什么电路”。想明白了,代码自然就写对了。
公众号:蓝海资料掘金营,微信deep3321