2. 硬件描述语言基础(Verilog/VHDL):组合逻辑与时序逻辑、模块化设计、Testbench编写
好,咱们直接进入正题。
硬件描述语言,说白了就是用来“画”电路的代码。你写的每一行 Verilog 或 VHDL,最终都会变成实实在在的门电路和触发器。这一点跟软件完全不一样——软件是跑在 CPU 上的指令,而硬件描述语言是在“描述”硬件本身的结构和行为。
我个人习惯用 Verilog,因为它语法更简洁,在量化交易这种对时序要求极严的场景里,写起来更顺手。当然 VHDL 也有它的优势,比如类型检查更严格,军工和航天领域用得很多。但不管用哪种,核心思想是一样的:组合逻辑 和 时序逻辑。
核心要点: 组合逻辑无记忆,输出只取决于当前输入;时序逻辑有记忆,输出还取决于过去的状态(时钟沿触发)。
2.1 组合逻辑:瞬间响应的“硬连线”
组合逻辑电路,输出只由当前输入决定。没有时钟,没有存储。你想想看,一个简单的与门、或门、加法器,都是组合逻辑。
在 Verilog 里,组合逻辑通常用 assign 语句或者 always @(*) 块来实现。
// Verilog 组合逻辑示例:一个简单的加法器
module adder (
input [7:0] a,
input [7:0] b,
output [8:0] sum
);
assign sum = a + b; // 组合逻辑:输入一变,输出立刻变
endmodule
我在项目中遇到过一个问题:有人把组合逻辑的敏感列表写漏了。比如 always @(a) 只写了 a,但里面用到了 b。结果仿真时 b 变了,输出却不更新。嗯,这就是典型的“仿真与综合不一致”的坑。
我的建议: 写组合逻辑的 always 块,敏感列表一律用 always @(*) 或者 always_comb(SystemVerilog)。省心,不出错。
2.2 时序逻辑:时钟驱动的“记忆体”
时序逻辑就不一样了。它依赖时钟沿(上升沿或下降沿)来更新状态。寄存器、计数器、状态机,这些都是时序逻辑。
在 FPGA 里,时序逻辑的核心是 D 触发器。你写的 always @(posedge clk) 块,综合出来就是一排 D 触发器。
// Verilog 时序逻辑示例:一个带同步复位的 8 位计数器
module counter (
input clk,
input rst_n,
output reg [7:0] count
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
count <= 8'b0; // 复位
else
count <= count + 1; // 每个时钟上升沿加 1
end
endmodule
注意这里我用的是 <=(非阻塞赋值),而不是 =(阻塞赋值)。这是时序逻辑的黄金法则。我曾经见过一个新手,在时序逻辑里用了阻塞赋值,结果仿真波形完全乱掉——因为阻塞赋值会立即更新,导致同一个时钟沿内多个寄存器互相覆盖。
避坑指南: 时序逻辑用非阻塞赋值 <=,组合逻辑用阻塞赋值 =。混着用?我劝你别试,调试起来能让你怀疑人生。
2.3 模块化设计:像搭积木一样搭电路
一个复杂的交易信号处理系统,不可能全写在一个文件里。模块化设计就是把大功能拆成小模块,每个模块负责一件事。
比如一个简单的信号处理链路,可以拆成这样:
// 顶层模块:信号处理链路
module signal_chain (
input clk,
input rst_n,
input [15:0] data_in,
output [15:0] data_out
);
wire [15:0] filtered;
wire [15:0] normalized;
// 实例化滤波器模块
filter u_filter (
.clk (clk),
.rst_n (rst_n),
.data_in (data_in),
.data_out(filtered)
);
// 实例化归一化模块
normalizer u_norm (
.clk (clk),
.rst_n (rst_n),
.data_in (filtered),
.data_out(normalized)
);
// 实例化输出寄存器
reg [15:0] data_out_reg;
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
data_out_reg <= 16'b0;
else
data_out_reg <= normalized;
end
assign data_out = data_out_reg;
endmodule
你看,顶层模块就像一张接线图,把各个子模块连起来。每个子模块内部做什么,顶层不用关心。这就是“高内聚、低耦合”。
个人经验: 我习惯每个模块只做一件事,并且端口尽量少。如果一个模块的端口超过 20 个,我会考虑是不是拆得太粗了。量化交易里,一个模块通常对应一个数学运算,比如加法树、乘法器、累加器。
2.4 Testbench 编写:仿真才是硬道理
写完了代码,怎么知道对不对?上板子调试太慢,而且看不到内部信号。所以我们需要 Testbench——一个专门用来测试你的模块的“虚拟环境”。
Testbench 本身是不可综合的,它只在仿真器里跑。它的任务就是:产生激励(时钟、复位、数据),然后检查输出是否正确。
// 一个简单的 Testbench 示例:测试上面的计数器
`timescale 1ns / 1ps
module tb_counter;
reg clk;
reg rst_n;
wire [7:0] count;
// 实例化被测试模块
counter uut (
.clk (clk),
.rst_n (rst_n),
.count (count)
);
// 产生时钟:周期 10ns
initial begin
clk = 0;
forever #5 clk = ~clk;
end
// 产生复位和测试激励
initial begin
rst_n = 0;
#20;
rst_n = 1;
#200;
// 检查计数是否正常
if (count == 8'd20)
$display("Test passed! count = %d", count);
else
$display("Test failed! count = %d", count);
#100;
$finish;
end
// 波形导出(用于 GTKWave 等工具查看)
initial begin
$dumpfile("tb_counter.vcd");
$dumpvars(0, tb_counter);
end
endmodule
写 Testbench 有几个要点:
- 时钟和复位:一定要先产生,再给激励。我见过有人复位还没释放就开始发数据,结果仿真全乱。
- 自检查:不要光看波形,要写
$display或者断言来自动判断对错。 - 覆盖率:边界条件一定要测。比如计数器满的时候会不会溢出?复位后第一个时钟沿的行为对不对?
我曾经踩过的坑: 有一次写 Testbench,忘了加 #5 延时,结果时钟和激励在同一时刻变化,导致仿真出现竞争冒险。从那以后,我所有 Testbench 的时钟和激励都严格错开半个周期。
2.5 本章知识体系总览
下面这张图,是我自己总结的本章核心逻辑。你可以把它当作一张“地图”,随时回来对照。
这张图把本章的核心内容串起来了。组合逻辑和时序逻辑是两大基石,模块化设计是组织代码的方法论,而 Testbench 是验证手段。四者缺一不可。
最后说一句: 学硬件描述语言,别光看书。打开 Vivado 或者 Quartus,写一个计数器,写一个加法器,再写个 Testbench 跑一下。动手才是最快的捷径。