ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA状态机实战:从逻辑设计到UART接收完整实现

FPGA状态机实战:从逻辑设计到UART接收完整实现 做FPGA开发学到后面你会发现一个很残酷的事实语法只是敲门砖真正决定你能不能把一个功能做出来、做稳的是逻辑设计能力。而逻辑设计能力最集中的体现就是状态机。我之前带过不少从0基础开始学FPGA的朋友很多人卡住的地方不是Verilog语法而是拿到一个需求之后不知道怎么下手。比如按键消抖、串口接收一帧数据、驱动一个传感器芯片这些东西“好像每个字都听懂了”一写代码就懵。这篇 part.6就是来解决这个问题的我会从逻辑设计的思维讲起把状态机这个核心工具彻底讲透再用一个完整的UART接收程序串起所有知识点。适合已经会写基础组合逻辑和时序逻辑、但对“怎么组织一个完整功能”还没找到感觉的读者。1. 逻辑设计思路从“写代码”到“画电路”很多从单片机转过来的朋友第一个不适应的地方就是FPGA里没有“顺序执行”这回事。C语言里你写a 1; b a;结果是 a 和 b 都是 1。但在 Verilog 的 always 块里如果你写a 1; b a;在同一个时钟沿b 拿到的还是 a 的旧值。这个区别就是软件思维和硬件思维最核心的分水岭。1.1 FPGA开发的核心矛盾软件思维 vs 硬件思维我把这个矛盾拆开来说。软件是“指令流”CPU 一条一条取指令、执行指令天然是顺序的。而 FPGA 里面没有“CPU”这个概念你写出来的 is 代码最终会被综合成一堆查找表、触发器、布线资源它们全部并行地工作在时钟节拍下。打个比方软件像一个人做流水线工作做完步骤1再做步骤2FPGA 像一条真正的流水线每个工位同时开工各自处理自己那一环。这意味着你必须在脑子里把“时间”的概念画出来——信号在哪个时钟周期变化、在哪个周期被采样、在哪个周期生效——而不是像写软件一样关心“第几行代码先执行”。硬件思维的另一个特点是一切都是由时钟沿驱动的。组合逻辑的 assign 虽然没有时钟但最终采样的动作一定发生在时钟沿。所以你在设计任何一个功能之前要回答的第一个问题永远是这个功能的输入信号什么时候变化输出信号应该什么时候稳定中间需要多少拍1.2 逻辑设计的基本套路数据通路 控制通路我见过很多新手写代码恨不得一个 always 块干完所有事结果代码写到 200 行改一个需求从头看到尾。这种写法不是不能工作但一旦功能变复杂基本就是灾难。工程上通用的思路是拆成两大部分——数据通路和控制通路。数据通路负责“干活”比如数据的移位、拼接、运算、存储它关心的是数据怎么流动控制通路负责“指挥”它根据当前处于哪个阶段决定数据通路执行什么操作、什么时候输出。还有一个很形象的对应数据通路像生产线控制通路像车间主任。车间主任不搬箱子但他知道什么时候该让哪条线开动、什么时候该让哪条线停下。对应到代码实现里控制通路八成以上就是一个状态机再加上若干计数器。所以我带新手学FPGA通常第三课就会引入状态机。如果你能把“数据通路控制通路”这个框架焊死在脑子里你后面写任何复杂模块——I2C、SPI、USB、PCIE——都能快速理出头绪。实操心得我建议你接到任何需求之后第一步不是写代码而是画框图。把模块分成哪些子模块每个子模块的输入输出是什么数据流怎么走控制信号有哪些这些都画清楚再动键盘。画框图的时间占整个项目时间的30%一点都不夸张。2. 状态机逻辑设计里的“总导演”状态机在FPGA里的地位相当于操作系统里的调度器或者说电影拍摄现场的总导演。它自己不具体干“搬砖”的活但所有“下一步干什么”的决策都由它来做。2.1 状态机到底在解决什么问题先想一个问题让一个LED先闪3下、然后灭2秒、再亮着等按键按键按下后重新开始。如果你硬用 if 嵌套来写串行逻辑会绕到你想砸键盘。状态机的思路则是先把整个流程拆成几个明确的阶段——闪烁阶段、等待阶段、熄灭阶段、按键检测阶段——然后每个阶段用一个“状态”表示每个状态里只做这个阶段该做的事做完就跳转到下一个状态。这就是状态机的核心价值把一个复杂的行为过程拆成有限个清晰的阶段状态每个阶段有明确的进入条件、行为、退出条件。这样设计出来的逻辑结构清楚、容易调试、也容易修改。你要新增一个“蜂鸣器响一声”的步骤只需要加一个状态和两条跳转线根本不用动其他代码。FPGA里最常用的是 Moore 型状态机和 Mealy 型状态机。Moore 型的状态输出只和当前状态有关Mealy 型的输出还和输入有关。新手我建议先死磕 Moore 型因为它的输出只依赖状态寄存器的值天然稳定不容易出毛刺。Mealy 型虽然有时候能省一个周期但组合逻辑容易引入毛刺等你有经验了再玩也不迟。2.2 三段式 vs 两段式我为什么推荐新手直接上三段式状态机的写法网上教程通常会分成一段式、二段式、三段式。一段式就是把状态跳转和输出写在一个 always 块里二段式把状态跳转和输出分开三段式则是把状态跳转、次态组合逻辑、输出时序逻辑彻底拆成三个 always 块。我个人的建议非常明确新手直接学三段式不要在一段式上浪费时间。原因有三个。第一三段式的输出是经过寄存器打拍的能有效避免组合逻辑输出产生的毛刺。第二状态跳转、次态计算、输出这三个逻辑各自独立代码可读性和可维护性远超一段式。第三工程实践中绝大多数正规代码都是三段式风格你从新手期就适应这套写法后面看别人的代码、进公司干活都会顺畅很多。三段式的结构长这样第一个 always 块只做一件事——时钟沿到来时把次态赋值给现态第二个 always 块是纯组合逻辑根据当前状态和输入信号算出次态第三个 always 块是时序逻辑根据当前状态输出控制信号。注意三段式的输出会晚一个时钟周期因为输出在第三个 always 块里被寄存器打了一拍。在做串口接收、SPI读写这类时序敏感的接口时这个“晚一拍”必须提前算清楚否则协议时序对不上。3. 一个真正能跑的状态机UART接收的完整拆解理论讲再多不如写一个完整例子。我选 UART 接收作为教学案例因为 UART 协议足够简单但又包含了状态机、时钟分频计数、采样点判断、数据拼接这些最常用的元素。做完这个模块你就已经掌握 FPGA 逻辑设计 50% 的常用套路了。3.1 为什么选UART接收当教学案例UART 大概是 FPGA 教程里出现频率仅次于流水灯的东西但很多教程只是丢一段代码没讲清楚为什么要在位的中间采样、为什么要有空闲状态、为什么数据要一位一位地收。我见过不少学生把这个模块背下来了换个波特率、换个时钟频率就抓瞎。所以这一节我不只给代码还会把每一处设计决策的“为什么”交代清楚。UART 接收的本质是把一个串行引脚上的电平变化按照约定的波特率在正确的时刻把每一位的电平采样下来拼成字节。这个过程中“正确的时刻”四个字是关键它背后是波特率时钟和采样点的问题。3.2 状态定义与跳转条件设计一个最简单的 UART 接收状态机四个状态就够了IDLE、START、DATA、STOP。IDLE 是空闲状态平时一直待在这里等待起始沿START 是收到起始位后的确认阶段DATA 是逐个接收数据位STOP 是接收停止位收完就产生一个完成脉冲。跳转条件这样设计IDLE检测到 RX 引脚出现下降沿也就是从高电平变成低电平说明可能有起始位来了跳转到 STARTSTART等计数到起始位的中间点再确认一次 RX 仍然是低电平说明这个下降沿确实是有效的起始位而不是毛刺跳转到 DATA同时清零位计数DATA每隔一个波特率周期采样一次采满 8 位后跳转到 STOPSTOP等计数到停止位中间点此时 RX 应该是高电平说明这一帧接收完毕产生接收完成信号回到 IDLE。为什么要在位的“中间点”采样因为串口通信双方没有共同时钟接收端只能靠波特率近似地判断每一位的到达时间。如果在每个位的起始沿就立刻采样电平刚刚跳变信号可能还没稳定采样结果容易出错。而一个位的中间位置是信号最稳定的地方所以最可靠的做法是检测到起始位下降沿之后先等半个位周期落在起始位的正中间做确认之后每隔一个完整位周期恰好就会落在每个数据位的正中间。下面算一下波特率分频值。假设系统时钟 50MHz我们要接收 9600 波特率的串口数据。一个位的时间是 1/9600 秒约 104.17 微秒。在 50MHz 时钟下一个时钟周期是 0.02 微秒所以一个位需要 104.17 / 0.02 5208 个时钟周期。也就是分频计数器最大值要设到 5208每数到 5208 个时钟我们就知道过一个位的时间了。同理如果你把时钟换成 100MHz波特率换成 115200那就是 100_000_000 / 115200 ≈ 868 个时钟周期。明白了这个原理你就不再需要背任何固定的计数器值而是随时可以根据自己的时钟频率和波特率现场算出来这才是真正的“会了”。3.3 完整代码实现与逐段讲解下面给出一个完整可用的 UART 接收模块我用的是三段式状态机加上一个波特率计数器和一个位计数器。注释写在关键位置方便你对照理解。module uart_rx #( parameter CLK_FREQ 50_000_000, // 系统时钟频率单位Hz parameter BAUD_RATE 9600 // 串口波特率 )( input wire clk, input wire rst_n, input wire rx, // 串行输入 output reg [7:0] data_out, // 接收到的8位数据 output reg data_valid // 接收完成脉冲 ); // 波特率分频计数目标值一个位周期占用的系统时钟数 localparam BAUD_CNT_MAX CLK_FREQ / BAUD_RATE; // 状态编码 localparam S_IDLE 3d0; localparam S_START 3d1; localparam S_DATA 3d2; localparam S_STOP 3d3; // 状态寄存器现态current_state、次态next_state reg [2:0] current_state; reg [2:0] next_state; // 波特率计数器记录当前处于位周期内的哪个位置 reg [15:0] baud_cnt; // 位计数器记录当前收到第几个数据位 reg [3:0] bit_cnt; // 接收移位寄存器数据一位一位移进来 reg [7:0] rx_shift; // 第一步状态寄存器时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) current_state S_IDLE; else current_state next_state; end // 第二步次态组合逻辑 计数器控制 always (*) begin // 默认保持当前状态 next_state current_state; case (current_state) S_IDLE: begin // 检测到rx下降沿认为可能有起始位 if (rx 1b0) next_state S_START; end S_START: begin // 计数到起始位中间点 if ((baud_cnt BAUD_CNT_MAX / 2 - 1) (rx 1b0)) next_state S_DATA; else if ((baud_cnt BAUD_CNT_MAX / 2 - 1) (rx 1b1)) next_state S_IDLE; // 误判不是真正的起始位 end S_DATA: begin // 每过一个位周期采一次采满8个位完成 if ((baud_cnt BAUD_CNT_MAX - 1) (bit_cnt 4d7)) next_state S_STOP; end S_STOP: begin // 等停止位中间点产生完成脉冲后回空闲 if (baud_cnt BAUD_CNT_MAX / 2 - 1) next_state S_IDLE; end default: next_state S_IDLE; endcase end // 第二步辅助波特率计数器和位计数器 always (posedge clk or negedge rst_n) begin if (!rst_n) begin baud_cnt 16d0; bit_cnt 4d0; end else begin case (current_state) S_IDLE: begin baud_cnt 16d0; bit_cnt 4d0; end S_START: begin if (baud_cnt BAUD_CNT_MAX / 2 - 1) baud_cnt 16d0; else baud_cnt baud_cnt 1b1; end S_DATA: begin if (baud_cnt BAUD_CNT_MAX - 1) begin baud_cnt 16d0; if (bit_cnt 4d7) bit_cnt 4d0; else bit_cnt bit_cnt 1b1; end else begin baud_cnt baud_cnt 1b1; end end S_STOP: begin if (baud_cnt BAUD_CNT_MAX / 2 - 1) baud_cnt 16d0; else baud_cnt baud_cnt 1b1; end default: begin baud_cnt 16d0; bit_cnt 4d0; end endcase end end // 第三步输出时序逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin data_out 8d0; data_valid 1b0; end else begin // 默认拉低产生一个周期的脉冲 data_valid 1b0; case (current_state) S_DATA: begin // 在每个数据位的中间把rx采样进移位寄存器 if (baud_cnt BAUD_CNT_MAX / 2 - 1) begin rx_shift {rx_shift[6:0], rx}; end end S_STOP: begin // 停止位中间点数据已经齐了输出结果 if (baud_cnt BAUD_CNT_MAX / 2 - 1) begin data_out rx_shift; data_valid 1b1; end end default: ; endcase end end endmodule代码里有个小细节值得注意在 DATA 状态下我让移位寄存器在baud_cnt BAUD_CNT_MAX / 2 - 1时采样这个时刻正好是每个数据位的中间点而不是在数据位结束的时候。这个“提前半拍”的设计和协议本身的时序有关——UART 接收方本来就应该在位中间采样这样即使两边时钟有微小偏差也不容易采样到位的边缘。然后说一下验证方式。写 testbench 时你可以用任务task来模拟发送一个字节这样波形看起来最直观。核心逻辑是先把 RX 拉高模拟空闲然后拉低一个位的时间表示起始位接着按 8 个数据位逐位输出先发最低位最后拉高一个位的时间表示停止位。每一次拉高拉低之间要延时CLK_FREQ / BAUD_RATE个时钟周期。如果你仿真之后发现data_valid总是晚一个周期、或者数据错位不用慌大概率是采样点算错了。回头检查baud_cnt的计数起点和采样条件是不是和状态匹配。我见过太多人因为计数器清零的时机不对导致每个位都偏了半个周期最后拼出来的数据整个错位——这种问题用 ModelSim 或 Vivado 自带的仿真器拉出来波形看一眼就能锁定。4. 从状态机到“会思考的逻辑系统”单个状态机写好了只是第一步真实项目里往往是多个状态机、多个模块协同配合。这一节我讲两个最常见的协作模式状态机嵌套以及状态机和 FIFO/计时器的配合。4.1 状态机嵌套大状态机和小状态机怎么配合举一个实战场景你想通过 SPI 接口读取一个温度传感器芯片。这个操作不是一次数据读就完事而是要先写配置寄存器再等芯片转换完成再读回温湿度数据最后做一次 CRC 校验。如果把这些步骤全部塞进一个状态机状态数量会爆增到十几二十个代码瞬间变得难以维护。工程上更合理的做法是把流程控制交给一个“主状态机”它只管宏观步骤——写配置、等待转换、读数据、检查结果。而每一步底层那些时钟时序、位拼接之类的细节封装到对应的“子模块”里比如spi_write_byte、spi_read_bytes。主状态机通过“使能信号”启动子模块子模块干完了拉高“完成信号”主状态机收到完成信号再跳转下一步。这种“主从模式”的核心思想是状态机之间只通过握手信号通信各自内部的状态细节互不干扰。这样做的好处太多了——每个状态机规模小、容易验证模块可以复用出了问题定位也快。我强烈建议你从学状态机第一天起就养成这个习惯每个模块的状态机只关心自己这一层的事情不要越级。4.2 状态机与FIFO/计时的配合处理“时间”和“缓冲”状态机擅长管理“顺序”但有两件事它天生不适合直接处理大数据量的缓冲和跨时钟域的信号传递。这两件事的通用解决方案分别是 FIFO 和异步 FIFO。举个例子高速 ADC 采样数据源源不断进来你要先存一段时间再打包上传。如果你在状态机里用一个数组去搬数据既不现实也没必要。标准做法是把数据流接到一个异步 FIFO状态机只负责两件事FIFO 快满的时候发一个“暂停采集”的请求或者 FIFO 有数据的时候启动一次读出和上传。这样一来状态机不用关心数据是一万个字节还是十万个字节它的逻辑始终简洁清晰。至于“时间”状态机自己是不感知时间流逝的。要让状态机“等 100 毫秒”“如果 2 秒内没收到应答就报错”你需要另外配一个计数器或超时定时器。我有个小习惯凡是状态机里需要等待的地方我都会把等待条件做成一个输入信号比如timeout_flag由专门的定时器模块产生。这样状态机的跳转逻辑全是电平/脉冲判断不会出现一个大 always 块里既管状态跳转又做计数的混乱局面。5. 新手最高频的状态机翻车现场说句实话状态机本身不难难的是各种看似莫名其妙的问题。我在带人过程中几乎每个人都会踩下面这几个坑。5.1 状态机跑飞、锁存器、毛刺实战排查先讲状态机跑飞。跑飞的意思是状态跑到了一个根本没有定义的编码上通常表现为current_state出现了状态编码之外的数值。原因一般有三个第一case 语句没有写 default综合工具默认帮你锁存第二复位逻辑没写好上电后状态寄存器处于不确定值第三输入信号没有做同步处理外部毛刺直接把状态寄存器打到了非法状态。解决办法也很直接case 一定要写 default 分支复位必须可靠异步复位一般要拉低足够长时间跨时钟域或外部来的信号先打两拍同步一下再进状态机。相信我这三个习惯做足了跑飞的概率会降到极低。然后是锁存器问题。如果你在 always 块里用if却没有写else或者在case里漏了分支组合逻辑的输出在某些输入组合下会要求“保持原值”这在组合逻辑里是无法实现的综合器就会自动给你生成一个锁存器latch。锁存器不是不能用但绝大多数场景下它不是你想要的东西而且极易引发时序问题。检查方法很简单综合后看报告里有没有Latch字样或者看原理图里有没有意外生成的锁存器符号。修的时候把缺失的 else 和 default 补上就行。5.2 常见Bug实录与排查技巧速查表下面把我见过的、学生最容易反复踩的坑整理成一个速查表你以后遇到问题可以直接对照排查。现象可能原因排查方法仿真正常上板完全不动时钟没起振、复位没释放、引脚约束不对先查时钟约束和复位信号再用 ILA 抓内部信号状态机跑飞乱跳case 少 default、输入毛刺、异步信号未同步检查代码补 default输入信号打两拍再使用输出总是晚了一拍三段式的输出打拍导致确认是不是有意设计如果协议要求不能晚就要调整数据位错位、拼出的字节不对采样点没落在位中间检查波特率计数器的清零时机和采样条件综合报告出现 latchif/case 分支不完整补全分支或给信号赋默认值时序报告出现时序违例组合逻辑路径过长状态机链路过深插入流水寄存器或改用独热码优化状态编码排查手段方面除了看仿真波形我强烈推荐你用 FPGA 厂商自带的逻辑分析仪核Vivado 里叫 ILAQuartus 里叫 SignalTap。方法很简单把current_state这个信号设为 debug 信号重新综合实现上板后通过逻辑分析仪实时观察状态寄存器的跳转轨迹。这条路径比你自己在那里盲猜要快一百倍。最后再说一个我自己一直在用的细节技巧状态编码的选择。状态少的用独热码one-hot每个状态对应一个寄存器位虽然浪费寄存器资源但组合逻辑简单、时序好收敛。状态很多、超过十几个的时候用二进制编码或格雷码更省资源。不要一上来就纠结这个问题但你要知道有这回事——等你哪天做个大状态机时序不过的时候你会想起我这句话的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进