3. Verilog/VHDL基础回顾:组合逻辑与时序逻辑、状态机设计、FIFO与BRAM使用、跨时钟域同步、常见时序问题
好,咱们直接进入正题。这一章是给那些写过Verilog但可能很久没碰硬件的同学准备的。说白了,就是帮你把脑子从软件思维切回硬件思维。我见过太多人把FPGA当CPU写,结果综合出来一堆莫名其妙的Latch。嗯,咱们今天就把这些坑填上。
3.1 组合逻辑 vs 时序逻辑:本质区别
组合逻辑,就是输入一变,输出立马跟着变。没有时钟,没有记忆。比如一个简单的与门:
assign y = a & b; // 组合逻辑,a或b一变,y立刻变
时序逻辑就不一样了。它靠时钟驱动,只在时钟沿采样输入,然后更新输出。说白了,它有“记忆”。
always @(posedge clk) begin
q <= d; // 时序逻辑,只在时钟上升沿更新
end
我个人的习惯是:写代码前先问自己一句——“这个信号需要记住过去的状态吗?” 如果需要,那就是时序逻辑;如果不需要,组合逻辑搞定。我在项目中遇到过有人把计数器写成组合逻辑,结果仿真对了,上板子直接炸了。为什么?因为组合逻辑没有记忆,计数器根本累加不起来。
核心区别一句话:组合逻辑是“纯函数”,时序逻辑是“状态机”。
3.2 阻塞赋值 vs 非阻塞赋值
这是新手最容易翻车的地方。我刚开始学的时候也搞混过,后来被一个老工程师骂了一顿才记住。
- 阻塞赋值 (=):顺序执行,前面的赋值会阻塞后面的。用在组合逻辑里。
- 非阻塞赋值 (<=):并行执行,所有赋值在同一时刻生效。用在时序逻辑里。
看个例子你就明白了:
// 错误示范:时序逻辑里用阻塞赋值
always @(posedge clk) begin
a = b;
c = a; // 这里c拿到的是a的新值,不是旧值!
end
// 正确做法:时序逻辑用非阻塞赋值
always @(posedge clk) begin
a <= b;
c <= a; // 这里c拿到的是a的旧值,符合硬件行为
end
我曾经在一个项目里看到有人把非阻塞赋值用在组合逻辑里,结果仿真波形看起来对,但综合出来的电路多了一堆没用的寄存器。那叫一个惨。记住:组合逻辑用阻塞,时序逻辑用非阻塞。这是铁律。
3.3 状态机设计:三段式才是王道
状态机是数字电路的核心。我个人强烈推荐三段式状态机。为什么?因为可读性强,时序好,不容易出bug。
三段式就是把状态机拆成三部分:
- 第一段:时序逻辑,负责状态跳转
- 第二段:组合逻辑,负责下一状态的计算
- 第三段:时序逻辑或组合逻辑,负责输出
来个例子:
// 第一段:状态跳转
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
// 第二段:下一状态计算
always @(*) begin
case (state)
IDLE: if (start) next_state = RUN;
else next_state = IDLE;
RUN: if (done) next_state = IDLE;
else next_state = RUN;
default: next_state = IDLE;
endcase
end
// 第三段:输出
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
out <= 1'b0;
else if (state == RUN)
out <= 1'b1;
else
out <= 1'b0;
end
你想想看,这样写出来的状态机,每个部分职责清晰。调试的时候一眼就能看出问题在哪。我在项目中遇到过有人用一段式状态机,一个always块里又管跳转又管输出,结果代码写了300行,谁也看不懂。后来我花了两天帮他重构,才把bug找出来。
小技巧:状态编码用独热码(one-hot)还是格雷码?如果状态数少于8个,独热码综合出来的电路更快。状态多了,用格雷码省寄存器。
3.4 FIFO与BRAM使用
FIFO是跨时钟域数据缓冲的利器。BRAM是FPGA内部的块存储器。这两个东西经常一起用。
FIFO的核心参数:
| 参数 | 说明 |
|---|---|
| 深度 | 能存多少个数据 |
| 宽度 | 每个数据多少位 |
| 读写时钟 | 同频还是异步 |
| 空/满标志 | 防止溢出或读空 |
我个人习惯:能用IP核就别手写FIFO。Xilinx和Altera都提供了现成的FIFO IP,经过充分验证,比自己写的靠谱得多。但如果你非要手写,记住几个要点:
- 读写指针要用格雷码同步到对端时钟域
- 空满判断要留余量(almost empty / almost full)
- 深度最好是2的幂次,方便地址计算
BRAM的使用就更直接了。你把它想象成一个二维数组,有地址、有数据、有读写使能。唯一要注意的是:BRAM有读延迟,一般是1-2个时钟周期。别指望地址给上去,数据立马出来。
// BRAM读操作示例
always @(posedge clk) begin
if (rd_en)
rd_data <= bram[rd_addr]; // 读数据在下一个时钟沿才有效
end
我曾经在一个高频交易项目里,因为没注意BRAM的读延迟,导致流水线断了一拍,整个系统的延迟多了10纳秒。10纳秒啊,在交易系统里可能就是几百万的损失。从那以后,我每次用BRAM都会在仿真里仔细检查读时序。
3.5 跨时钟域同步
跨时钟域是FPGA设计里最容易出问题的地方。说白了,就是一个信号从时钟域A跑到时钟域B,如果处理不好,就会产生亚稳态。
亚稳态:就是信号在时钟沿附近变化,导致寄存器采样到了一个不确定的值。这个值可能既不是0也不是1,而是介于两者之间。更可怕的是,亚稳态会传播,导致整个系统崩溃。
解决方案其实很简单:两级同步器。
// 两级同步器,用于单比特信号跨时钟域
always @(posedge clk_b) begin
sync1 <= sig_a; // 第一级,可能亚稳态
sync2 <= sync1; // 第二级,亚稳态概率大大降低
end
assign sig_b = sync2; // 输出同步后的信号
为什么两级就够了?因为亚稳态的概率虽然存在,但两级同步后,概率已经低到可以忽略不计。当然,如果你做的是航天级设备,可能需要三级甚至四级。
注意:两级同步器只适用于单比特信号。如果是多比特信号(比如数据总线),需要用异步FIFO或者握手协议。千万别把多比特信号直接打两拍,那样数据会错位。
我曾经在一个项目里看到有人把32位数据总线直接打两拍同步,结果数据对不上,查了三天才发现是这个问题。嗯,从那以后我每次做跨时钟域设计,都会画一张时序图,确保每个信号都处理对了。
3.6 常见时序问题
时序问题说白了就是:你的电路跑不到目标频率。常见的有这么几种:
- 组合逻辑路径太长:一个时钟周期内,信号要经过太多级逻辑,导致延迟超标。解决办法:插入流水线寄存器。
- 扇出太大:一个信号驱动太多负载,导致上升沿变缓。解决办法:复制寄存器,分散负载。
- 跨时钟域没处理好:导致亚稳态,时序分析工具会报错。解决办法:用同步器或异步FIFO。
- 复位网络太复杂:异步复位信号到达时间不一致,导致寄存器复位不同步。解决办法:用同步复位或异步复位同步释放。
你想想看,时序问题其实就两个核心:路径延迟和信号完整性。只要把这两个控制好,大部分问题都能解决。
我的经验:写代码的时候就要有时序意识。别等到综合完看时序报告才后悔。每写一段代码,心里默念一遍“这个路径能跑多快?”
知识体系总览
下面这张图把本章的核心知识点串起来了。你可以把它当作一个检查清单,看看自己有没有遗漏。
这张图里,组合逻辑和时序逻辑是基础,状态机是控制核心,FIFO/BRAM是数据存储,跨时钟域是接口处理,时序问题是最终检验。你写代码的时候,脑子里要有这张图,就知道自己当前在做什么、下一步该做什么。
最后说一句:硬件设计和软件最大的区别是“并行”。你写的每一行Verilog,综合出来都是一个独立的硬件模块。别用软件的思维去写硬件,否则你会很痛苦。嗯,今天就到这里,下一章咱们聊流水线设计。