3. Verilog/VHDL基础:模块化设计、数据类型、运算符、always/process块、赋值语句、仿真基础
各位同学,欢迎来到第三讲。今天我们要聊的,是高频交易信号生成硬件实现的“地基”——Verilog和VHDL的基础知识。别小看这些基础,我见过太多项目后期出问题,都是因为一开始数据类型没选对,或者always块写得不够严谨。说白了,硬件设计和软件不一样,你写的每一行代码,最后都会变成实实在在的电路。所以,咱们得从一开始就养成好习惯。
3.1 模块化设计:把大问题拆成小积木
模块化设计,说白了就是“分而治之”。一个复杂的交易信号生成器,你不可能在一个文件里写完所有逻辑。那样做,不仅你自己看着头疼,后期维护更是噩梦。我个人习惯,是把功能拆成独立的模块,每个模块只干一件事。
举个例子,一个简单的信号生成器,可以拆成:
- 数据接收模块:负责从外部接口拿到行情数据。
- 计算模块:负责做具体的数学运算,比如移动平均。
- 输出模块:负责把计算结果打包发送出去。
这样做的好处很明显:
- 可复用:计算模块写好了,下次换个项目也能直接用。
- 易调试:哪个模块出了问题,单独测试它就行。
- 团队协作:几个人可以同时开发不同的模块,互不干扰。
核心思想:模块的接口要清晰,内部实现要隐藏。就像你用一个黑盒子,只关心它输入什么、输出什么,不关心里面怎么折腾。
3.2 数据类型:选对了,事半功倍
数据类型这事儿,我在项目中吃过不少亏。刚开始用Verilog时,我总觉得wire和reg差不多,随便用用得了。结果有一次,一个组合逻辑的输出我用了reg类型,仿真死活不对。后来才发现,reg类型在组合逻辑里用,虽然语法上没错,但容易让人误解它的行为。
咱们来看看最常用的几种:
| 类型 | Verilog | VHDL | 说明 |
|---|---|---|---|
| 线网 | wire | signal (in architecture) | 用于组合逻辑,相当于物理连线 |
| 寄存器 | reg | signal (in process) | 用于时序逻辑,可以存储值 |
| 整数 | integer | integer | 用于仿真,不可综合 |
| 向量 | [7:0] wire/reg | std_logic_vector(7 downto 0) | 表示多位宽数据 |
我的建议:在写可综合的代码时,尽量用wire和reg。integer只在testbench里用,别把它弄到真正的设计里,否则综合工具会给你一堆警告。
3.3 运算符:加减乘除,没那么简单
运算符看起来简单,但在硬件里,每个运算符都对应着一堆门电路。你写一个“+”,综合工具就会给你生成一个加法器。你写一个“*”,那可能就是一堆乘法器,面积和功耗都上去了。
在高频交易里,我们追求的是极致的速度。所以,能用移位运算代替乘除法,就尽量用。比如,乘以2可以用左移一位(<< 1),除以2可以用右移一位(>> 1)。
举个例子:
// 不推荐:直接用乘法
wire [15:0] result = data * 4;
// 推荐:用移位
wire [15:0] result = data << 2;
你想想看,移位运算在硬件里就是几根线重新连一下,几乎不消耗逻辑资源。而乘法器,那可是要占用不少LUT的。
注意:移位运算只适用于乘以或除以2的幂次。如果是乘以3、乘以5这种,那还是得用乘法器。不过,你可以考虑用查找表(LUT)来优化,这个我们后面会讲到。
3.4 always/process块:时序逻辑的心脏
always块(Verilog)和process块(VHDL),是时序逻辑的核心。我刚开始学的时候,总觉得这东西跟C语言的循环差不多。其实完全不是一回事。always块不是“循环执行”,而是“被触发执行”。
最常见的写法是:
// Verilog: 时钟上升沿触发
always @(posedge clk) begin
if (rst) begin
q <= 0;
end else begin
q <= d;
end
end
-- VHDL: 时钟上升沿触发
process(clk, rst)
begin
if rst = '1' then
q <= '0';
elsif rising_edge(clk) then
q <= d;
end if;
end process;
这里有个关键点:敏感信号列表。在Verilog里,@(posedge clk)表示只在时钟上升沿触发。如果你写的是@(a or b),那就是组合逻辑,a或b任何变化都会触发。
避坑指南:我曾经犯过一个错误,在always块里同时用了posedge和negedge,结果综合出来的电路用了双边沿触发器。这种触发器在普通FPGA里很少见,导致布局布线失败。所以,尽量只用单边沿。
3.5 赋值语句:阻塞 vs 非阻塞
这是新手最容易搞混的地方。Verilog里有两种赋值方式:
- 阻塞赋值(=):顺序执行,前面的赋值会立即影响后面的。
- 非阻塞赋值(<=):并行执行,所有赋值在块结束时同时生效。
我给大家一个简单粗暴的规则:
- 组合逻辑:用阻塞赋值(=)。
- 时序逻辑:用非阻塞赋值(<=)。
为什么?你想想看,时序逻辑里,我们希望所有寄存器在同一时钟沿同时更新。如果用阻塞赋值,就可能出现“先赋值、后使用”的情况,导致仿真结果和实际电路不一致。
举个例子:
// 错误示范:时序逻辑里用阻塞赋值
always @(posedge clk) begin
a = b; // 先执行
c = a; // 此时a已经更新,c得到的是新值
end
// 实际电路:c得到的是旧值,因为两个寄存器是同时更新的
// 正确做法:用非阻塞赋值
always @(posedge clk) begin
a <= b;
c <= a; // 这里a还是旧值,c得到的是旧值
end
我的经验:在写testbench时,我偶尔会用阻塞赋值来生成时钟或激励。但在真正的设计代码里,我严格遵循“时序用非阻塞,组合用阻塞”的原则。这样能避免99%的仿真与综合不一致问题。
3.6 仿真基础:先跑仿真,再上板子
仿真,说白了就是在电脑上模拟你的设计。我见过不少同学,代码写完了直接上板子,结果灯不亮、信号不对,然后一脸懵。其实,花10分钟写个testbench,就能发现大部分问题。
一个简单的testbench长这样:
module tb_signal_generator;
reg clk;
reg rst;
wire [7:0] signal_out;
// 实例化被测试模块
signal_generator uut (
.clk(clk),
.rst(rst),
.signal_out(signal_out)
);
// 生成时钟
initial begin
clk = 0;
forever #5 clk = ~clk; // 10ns周期
end
// 生成激励
initial begin
rst = 1;
#20 rst = 0;
#100 $finish;
end
// 监视输出
initial begin
$monitor("time=%0t, signal_out=%d", $time, signal_out);
end
endmodule
你看,testbench里没有wire和reg的限制,你可以用initial、forever这些不可综合的语句。仿真通过后,再去做综合和布局布线,这样效率高得多。
注意:仿真通过不代表上板子一定没问题。因为仿真器是理想化的,它不考虑门延迟、布线延迟。所以,仿真只是第一步,后面还要做时序分析。
3.7 本章知识体系
为了让大家更直观地理解本章内容,我画了一张图,把各个知识点串起来:
这张图把本章的六个核心知识点都串起来了。你可以看到,模块化设计是顶层思想,数据类型和运算符是基础元素,always/process块和赋值语句是核心语法,而仿真则是验证手段。它们环环相扣,缺一不可。