4. Verilog基础(下):时序逻辑与状态机设计

好,咱们接着聊。上一节我们把组合逻辑的底子打好了,这一节要进入FPGA设计的核心——时序逻辑。说白了,就是带时钟的电路。没有时钟,FPGA就是一盘散沙;有了时钟,所有操作才有了节奏感。

我个人习惯把时序逻辑比作「跳舞」。组合逻辑是自由发挥,时序逻辑是跟着节拍走。这个节拍,就是时钟的上升沿或下降沿。

4.1 always @(posedge clk) —— 时钟的节拍

在Verilog里,我们最常用的时序逻辑写法就是:

always @(posedge clk) begin
    // 在时钟上升沿执行的操作
end

这个结构,我刚开始学的时候觉得很简单,不就是「当时钟上升沿来的时候干活」嘛。但实际项目中,坑可不少。

敏感列表:posedge clk 表示只在时钟上升沿触发。如果你写 always @(posedge clk or negedge rst_n),那就是时钟上升沿或复位下降沿都会触发。嗯,这里要注意,敏感列表里不要乱加信号,否则综合工具会报错。

核心要点:时序逻辑的 always 块,敏感列表里只能有时钟和复位信号。别把其他信号加进去,那是组合逻辑的写法。

举个例子,一个最简单的D触发器:

reg q;
always @(posedge clk) begin
    q <= d;
end

这个代码综合出来就是一个D触发器。时钟上升沿时,q 拿到 d 的值。简单吧?但这里有个关键点——赋值符号。

4.2 阻塞赋值 vs 非阻塞赋值 —— 一字之差,天壤之别

这是Verilog初学者最容易踩的坑,没有之一。我在项目中遇到过好几次因为赋值符号用错导致的仿真和实际行为不一致。

阻塞赋值(=):顺序执行,前面的赋值会阻塞后面的。

非阻塞赋值(<=):并行执行,所有赋值在同一时刻完成。

看个对比例子:

// 阻塞赋值 —— 组合逻辑
always @(*) begin
    a = b;
    c = a;  // c 拿到的是 a 的新值
end

// 非阻塞赋值 —— 时序逻辑
always @(posedge clk) begin
    a <= b;
    c <= a;  // c 拿到的是 a 的旧值
end

为什么会这样?因为非阻塞赋值在时钟上升沿时,先「采样」所有右边的值,然后「更新」左边的值。所以 c <= a 拿到的其实是 a 在时钟上升沿之前的值。

黄金法则:

  • 时序逻辑(always @(posedge clk))用 <= (非阻塞)
  • 组合逻辑(always @(*))用 = (阻塞)

我曾经见过一个同事,在时序逻辑里用了阻塞赋值,结果仿真通过,上板子就乱跳。查了两天才发现是赋值符号的问题。从那以后,我写代码第一眼就看赋值符号。

小技巧:如果你不确定该用哪个,记住一句话——「时钟沿触发用非阻塞,组合逻辑用阻塞」。写多了就形成肌肉记忆了。

4.3 状态机设计 —— 数字系统的「大脑」

状态机,说白了就是让电路记住自己「现在在干什么」,然后根据输入决定「下一步干什么」。FPGA里几乎所有控制逻辑,最终都会落到状态机上。

我个人习惯把状态机分成三段:

  • 第一段:状态转移(时序逻辑)
  • 第二段:次态逻辑(组合逻辑)
  • 第三段:输出逻辑(组合逻辑或时序逻辑)

这种写法叫「三段式状态机」,可读性好,也容易维护。你想想看,如果所有逻辑都写在一个 always 块里,那代码看起来得多乱。

4.3.1 状态编码

常用的编码方式有三种:

编码方式 特点 适用场景
二进制编码 状态数少,节省寄存器 状态数较多时
格雷码 相邻状态只变1位,抗毛刺 跨时钟域传输
独热码 每个状态一个寄存器,译码简单 状态数少(<16),追求速度

我在项目中常用独热码,因为FPGA寄存器多,独热码的译码逻辑简单,时序容易收敛。但如果你状态数超过20个,还是用二进制编码吧,否则寄存器浪费太多。

4.3.2 三段式状态机示例

来个经典例子——一个简单的交通灯控制器:

// 状态定义
localparam IDLE    = 3'b001;
localparam GREEN   = 3'b010;
localparam YELLOW  = 3'b100;

reg [2:0] state, next_state;

// 第一段:状态转移
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        state <= IDLE;
    else
        state <= next_state;
end

// 第二段:次态逻辑
always @(*) begin
    case (state)
        IDLE:    next_state = GREEN;
        GREEN:   next_state = (timer_done) ? YELLOW : GREEN;
        YELLOW:  next_state = (timer_done) ? IDLE : YELLOW;
        default: next_state = IDLE;
    endcase
end

// 第三段:输出逻辑
always @(*) begin
    case (state)
        GREEN:  light = 2'b01;  // 绿灯
        YELLOW: light = 2'b10;  // 黄灯
        default: light = 2'b00; // 红灯
    endcase
end

你看,三段各司其职。第一段只管状态跳转,第二段算下一个状态,第三段根据当前状态输出。这样写,调试的时候一眼就能看出问题在哪。

避坑指南:

  • 第二段(次态逻辑)一定要用 阻塞赋值,因为它是组合逻辑
  • case 语句一定要写 default,否则综合会生成锁存器
  • 状态定义用 localparam,别用 `define,作用域可控

4.3.3 状态机设计流程

我画了一张图,帮你理清状态机设计的整体思路:

状态机设计流程 1. 确定状态 列出所有工作状态 2. 定义转移条件 什么情况下跳转 3. 编写三段式代码 状态转移+次态+输出 4. 仿真验证 检查状态跳转是否正确 5. 综合实现 检查时序和资源 6. 上板调试 用逻辑分析仪抓信号 发现问题?回到步骤1或3修改

这张图是我多年做状态机设计的经验总结。你注意看那个反馈箭头——状态机设计很少一次成功,仿真发现跳转不对,或者上板子发现时序问题,都要回头修改。这是正常的,别怕。

4.3.4 状态机常见问题

  • 状态跑飞:由于干扰或未定义状态,状态机跳到了非法状态。解决办法是 case 里写 default,把非法状态拉回初始态。
  • 输出毛刺:组合逻辑输出容易有毛刺。我一般把输出打一拍,用时序逻辑输出,虽然多一个时钟延迟,但信号干净多了。
  • 状态太多:如果状态超过30个,建议拆分成多个小状态机,或者用微控制器代替。别硬撑,否则时序很难收敛。

我的习惯:写状态机前,先在纸上画状态转移图。画清楚了再写代码,效率比直接写代码高得多。你想想看,画图时发现逻辑漏洞,总比仿真时发现要省时间吧?

4.4 小结

这一节我们聊了时序逻辑的核心——always @(posedge clk) 的用法,阻塞与非阻塞赋值的区别,以及状态机的三段式设计方法。这些东西,说白了就是FPGA设计的「内功」。内功练好了,后面学什么接口协议、高速设计,都会轻松很多。

嗯,最后提醒一句:写代码时多想想综合器会把它变成什么电路。你写的是硬件描述语言,不是软件。这个思维转变过来了,你就算真正入门了。

可可精品资料库