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。

三段式就是把状态机拆成三部分:

  1. 第一段:时序逻辑,负责状态跳转
  2. 第二段:组合逻辑,负责下一状态的计算
  3. 第三段:时序逻辑或组合逻辑,负责输出

来个例子:

// 第一段:状态跳转
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。
  • 复位网络太复杂:异步复位信号到达时间不一致,导致寄存器复位不同步。解决办法:用同步复位或异步复位同步释放。

你想想看,时序问题其实就两个核心:路径延迟信号完整性。只要把这两个控制好,大部分问题都能解决。

我的经验:写代码的时候就要有时序意识。别等到综合完看时序报告才后悔。每写一段代码,心里默念一遍“这个路径能跑多快?”

知识体系总览

下面这张图把本章的核心知识点串起来了。你可以把它当作一个检查清单,看看自己有没有遗漏。

Verilog/VHDL基础回顾知识体系 硬件描述语言基础 组合逻辑 assign / always @(*) 阻塞赋值 = 时序逻辑 always @(posedge clk) 非阻塞赋值 <= 状态机设计 三段式结构 独热码 / 格雷码 FIFO & BRAM 深度/宽度/标志 读延迟1-2拍 跨时钟域同步 两级同步器 异步FIFO / 握手 常见时序问题 路径延迟 · 扇出 · 亚稳态 · 复位

这张图里,组合逻辑和时序逻辑是基础,状态机是控制核心,FIFO/BRAM是数据存储,跨时钟域是接口处理,时序问题是最终检验。你写代码的时候,脑子里要有这张图,就知道自己当前在做什么、下一步该做什么。

最后说一句:硬件设计和软件最大的区别是“并行”。你写的每一行Verilog,综合出来都是一个独立的硬件模块。别用软件的思维去写硬件,否则你会很痛苦。嗯,今天就到这里,下一章咱们聊流水线设计。

专注资料整理