十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Verilog状态机三段式写法:核心原理与I2C EEPROM实战调试

Verilog状态机三段式写法:核心原理与I2C EEPROM实战调试 状态机这东西做FPGA和数字IC的绕不开。我见过不少新人C语言写得挺溜一到Verilog状态机就懵不是时序跑飞就是代码写成一坨浆糊。其实说白了状态机就是数字电路里用来做“流程控制”的那根筋芯片里所有稍微复杂点的逻辑——通信协议解析、命令调度、数据通路切换——背后都是一个或几个状态机在撑着。这篇就把Verilog状态机这块讲透从核心思路到三段式写法再带一个能直接用的I2C读EEPROM案例最后把调试时那些坑一个个踩给你看。1. 状态机的核心思路数字世界的流程图1.1 什么是状态机它到底解决什么问题打个比方状态机就像地铁闸机的逻辑平时锁着IDLE态你刷卡成功触发条件闸门打开输出动作你通过后闸门自动关上并回到锁定状态。如果卡里钱不够或者没刷上闸机就继续保持锁着并且亮红灯输出错误标志。电路里的状态机干的是完全一样的事只不过它的“状态”是寄存器里存的二进制编码“条件”是输入信号的电平或跳变“输出”就是驱动外部逻辑的拉高拉低。它解决的核心问题就是让电路能够记住“现在在干什么”从而决定“下一步该干什么”。纯组合逻辑做不到这一点因为它没有记忆计数器虽然也有记忆但它只会在数字上递增递减做不了复杂分支判断。状态机把这些都包圆了。在Verilog里状态机通常由三部分组成状态寄存器存当前所处的状态用always块在时钟沿更新次态判断逻辑根据当前状态和输入条件计算下一个状态输出逻辑根据当前状态或当前状态输入产生对外输出1.2 Moore型和Mealy型新手该先学哪个这是个老生常谈的问题但确实重要。Moore型状态机的输出只取决于当前状态Mealy型的输出还取决于输入信号。举个串口接收的例子如果是在“收到数据位”这个状态里把输出的接收完成标志拉高那是Moore型如果是在“正在收最后一位且校验通过”时才拉高标志那就是Mealy型因为它依赖了输入的校验结果。我的建议是新手优先掌握Moore型。原因很直接Moore型的输出和输入不直接相关不会出现组合逻辑毛刺时序关系也好分析在同步设计里不容易跑飞。Mealy型虽然有时候能省一两个状态但输出路径上多了组合逻辑时序约束和跨时钟域处理都会变复杂属于“省了状态费了脑细胞”的买卖。等Moore型玩熟了再根据实际项目需要去学Mealy型不迟。2. 三段式写法详解公认最稳的编码风格2.1 为什么业界推三段式一段式二段式差在哪Verilog状态机的写法乱七八糟网上什么风格都有但信号处理、通信、IC设计这些正经领域主流推的是三段式。我不是说一段式二段式不能干活但用久了对比过你就知道三段式在可维护性和时序清晰度上优势是实打实的。一段式状态寄存器、次态判断、输出逻辑全揉在一个always块里。代码短但输出是寄存器输出还需要额外寄存器打一拍而且状态一多跟改屎山一样看都不想看。二段式一段写时序逻辑状态寄存器更新一段写组合逻辑次态计算输出。比一段式清晰但输出仍是组合逻辑容易出毛刺尤其输出信号要跨模块使用的时候坑挺多的。三段式时序逻辑更新状态寄存器、组合逻辑算次态、时序逻辑或组合逻辑出输出三段各管各的。结构和状态转移图一一对应可读性强也方便做时序约束和综合优化。2.2 实操示例一个简单状态机的三段式模板直接上一个完整可综合的模板看三段的划分就明白了。这里用一个简单的序列检测器做演示检测输入data上连续出现101时拉高detected标志。module seq_detector ( input wire clk, input wire rst_n, input wire data, output reg detected ); // 状态编码用localparam定义比define好作用域只在模块内 localparam S_IDLE 2d0; localparam S_GET_1 2d1; // 已接收第一个1 localparam S_GET_10 2d2; // 已接收10 localparam S_GET_101 2d3; // 已接收101输出成功 reg [1:0] cur_state; reg [1:0] next_state; // 第一段时序逻辑同步复位状态寄存器更新 always (posedge clk or negedge rst_n) begin if (!rst_n) cur_state S_IDLE; else cur_state next_state; end // 第二段组合逻辑根据当前状态和输入计算下一状态 always (*) begin next_state cur_state; // 默认保持当前状态防止产生锁存器 case (cur_state) S_IDLE : next_state data ? S_GET_1 : S_IDLE; S_GET_1 : next_state data ? S_GET_1 : S_GET_10; S_GET_10 : next_state data ? S_GET_101 : S_IDLE; S_GET_101: next_state data ? S_GET_1 : S_GET_10; default : next_state S_IDLE; endcase end // 第三段时序逻辑输出Moore型输出打一拍 always (posedge clk or negedge rst_n) begin if (!rst_n) detected 1b0; else if (cur_state S_GET_101) detected 1b1; else detected 1b0; end endmodule几个细节值得说道说道。第一段里我用的是同步复位后面会专门讲复位策略第二段组合逻辑里开头先写next_state cur_state作为默认赋值这是老手通用习惯能有效防止电平敏感列表不全导致生成锁存器第三段输出用时序逻辑打了一拍输出的detected在时序上落后于进入S_GET_101状态一个时钟但好处是干净无毛刺。2.3 状态编码二进制、独热码还是格雷码这也是个会被人问炸的题。二进制编码最省寄存器N个状态用log2N个寄存器但状态跳转时多位同时变化组合逻辑大容易出毛刺独热码一个状态一个寄存器位省组合逻辑状态译码简单适合状态多、追求时序性能的设计但寄存器多格雷码相邻状态间只有一位变化适合连续跳转的场景比如计数但状态多时不直观分析麻烦。我的选型口诀是状态少于8个用二进制8到20个用独热码有连续编号跳转的需求优先考虑格雷码超过20个状态先想想设计是不是该拆模块了。FPGA里寄存器资源相对多独热码用得非常普遍因为状态译码就是判断某一位是否为1组合逻辑级数少时序容易收敛。ASIC那边管面积管得紧用二进制的更多。自己写代码时没有特别要求就先二进制简单直观如果是大工程比如DDR控制器那种动辄几十个状态的别想默认独热码。3. 实操落地用状态机实现I2C对EEPROM的读操作这一节来点真家伙。I2C协议是芯片世界的基础协议非常适合拿来练状态机。我们做一个常见场景FPGA作为I2C主机通过I2C接口向EEPROM比如AT24C32的某个地址写数据再把数据读回来。3.1 初始化配置与寄存器设计先定义我们要用到的寄存器。I2C通信的关键参数是时钟频率标准模式100Kbit/s快速模式400Kbit/s。很多EEPROM支持快速模式但为了稳妥这里按400K设计。系统时钟我按50MHz算I2C的SCL时钟就用系统时钟分频得到。400K对应的分频系数是50M/400K 125注意一个完整I2C周期里SCL要有高有低所以分频计数值用125/262.5取整做62。//----------参数与寄存器定义---------- localparam CLK_FREQ 50_000_000; // 系统时钟50MHz localparam SCL_FREQ 400_000; // I2C时钟400KHz localparam HALF_SCL CLK_FREQ / SCL_FREQ / 2; // 半个SCL周期计数 reg scl_cnt_en; // SCL分频计数使能 reg [5:0] scl_cnt; // 分频计数器0~62 reg scl_tick; // 每半个SCL周期拉高一个时钟,作为状态机的时间基准 reg scl_half; // 0表示当前是SCL低半周期1表示高半周期 reg scl_out; // 输出的SCL电平 reg sda_out; // 输出的SDA电平 reg sda_in; // 输入的SDA电平 reg [7:0] tx_byte; // 待发送的字节 reg [7:0] rx_byte; // 收到的字节 reg [3:0] bit_cnt; // 位计数器 reg [3:0] step; // 状态机内部步骤 reg ack_flag; // 应答标志1表示收到从机应答I2C时序里SDA数据在SCL高电平期间必须保持稳定在SCL低电平期间才允许变化。所以整个状态机的动作颗粒度就是半个SCL周期用scl_tick来控制。3.2 状态划分与跳转逻辑把整个写读流程拆开可以分成这些状态起始、发送器件地址写标志、等待应答、发送寄存器高位地址、等待应答、发送寄存器低位地址、等待应答、发送数据、等待应答、停止——这是一次完整的写字节流程。读操作还需要在写完寄存器地址后重新发一个起始信号然后发器件地址读标志再一个字节一个字节地读。为了演示状态机又不会让代码长度爆炸我用一个精简版示例只实现“向EEPROM的某个地址写入一个字节8‘hAB然后再Start 器件读地址 读回一个字节”。这里可以只用几个大状态I2C_IDLE空闲I2C_START拉SDA从高到低产生起始条件I2C_ADDR_W发送器件地址7位写位0I2C_ACK_W检查写地址的应答I2C_DATA发送要写入的字节I2C_ACK_D检查写数据的应答I2C_RESTART再一次起始条件I2C_ADDR_R发送读地址7位读位1I2C_ACK_R读地址应答I2C_RD_BYTE读取8位数据I2C_NACK_STOP主机回非应答然后产生停止条件状态机的核心跳转代码框架如下注意所有跳转都发生在scl_tick的沿到来时保证和SCL同步。always (posedge clk or negedge rst_n) begin if (!rst_n) begin cur_state I2C_IDLE; step 4d0; ack_flag 1b1; end else if (scl_tick) begin case (cur_state) I2C_IDLE: begin if (start_trigger) cur_state I2C_START; end I2C_START: begin // 起始条件SCL高期间SDA由高到低 // 这里step控制SDA拉低的时机 cur_state I2C_ADDR_W; step 4d0; tx_byte {I2C_DEV_ADDR, 1b0}; // 7位地址写位 end I2C_ADDR_W: begin if (step 8) begin // 逐位发送 sda_out tx_byte[7]; tx_byte {tx_byte[6:0], 1b0}; step step 1b1; end else begin // 第9个时钟释放SDA等待从机应答 cur_state I2C_ACK_W; step 4d0; end end I2C_ACK_W: begin ack_flag sda_in; cur_state I2C_DATA; end I2C_DATA: begin if (step 8) begin sda_out tx_byte[7]; tx_byte {tx_byte[6:0], 1b0}; step step 1b1; end else begin cur_state I2C_ACK_D; step 4d0; end end I2C_ACK_D: begin ack_flag sda_in; cur_state I2C_RESTART; end I2C_RESTART: begin cur_state I2C_ADDR_R; step 4d0; tx_byte {I2C_DEV_ADDR, 1b1}; // 读位 end I2C_ADDR_R: begin if (step 8) begin sda_out tx_byte[7]; tx_byte {tx_byte[6:0], 1b0}; step step 1b1; end else begin cur_state I2C_ACK_R; step 4d0; end end I2C_ACK_R: begin ack_flag sda_in; cur_state I2C_RD_BYTE; step 4d0; end I2C_RD_BYTE: begin if (step 8) begin rx_byte {rx_byte[6:0], sda_in}; step step 1b1; end else begin cur_state I2C_NACK_STOP; step 4d0; end end I2C_NACK_STOP: begin // 主机发送非应答然后产生停止条件 cur_state I2C_IDLE; wr_done 1b1; rd_data rx_byte; end default: cur_state I2C_IDLE; endcase end end3.3 SCL和SDA时序是如何产生的状态转移只是骨架真正让I2C协议成立的是SCL和SDA这两个引脚的电平时序。SCL用分频计数器生成SDA则根据状态机的阶段来置高置低。关键点是SDA必须在SCL低电平时改变在SCL高电平时保持稳定。所以代码里要严格区分当前是SCL的高半周期还是低半周期只在低半周期才允许改动SDA。我在实际工程里习惯用scl_half这个标志来做这个区分凡是改SDA的动作都加一个条件判断确保不会在SCL高电平期间贸然拉SDA。起始条件的实现细节如下SCL还处于高电平状态时把SDA从1拉到0。停止条件正好反过来SCL高电平时把SDA从0拉到1。完成这两个特殊时序需要跨过完整的SCL高半周期所以状态机里必须预留出足够的步骤时间我之前见过很多新手在这个地方踩坑SDA变化时机不对拿着示波器查半天才发现是起始条件没做对位。3.4 行为仿真的Testbench怎么写代码写完必须先仿真再上板。Verilog的仿真用testbench驱动给状态机喂激励看着波形验证功能。I2C这种带外部应答的协议仿真时要模拟EEPROM的行为这个不能偷懒不然整个流程根本跑不通。一个基础testbench的写法timescale 1ns / 1ps module tb_i2c_eeprom; reg clk_50m; reg rst_n; reg start_trigger; wire scl; wire sda; // I2C从机模拟相关 reg sda_drive; reg sda_en; wire sda_wire sda_en ? sda_drive : 1bz; // 模拟SDA线的线与特性 // 实例化被测模块 i2c_eeprom_ctrl u_dut ( .clk (clk_50m), .rst_n (rst_n), .start_trigger(start_trigger), .scl (scl), .sda (sda_wire), .wr_done (wr_done), .rd_data (rd_data) ); // 产生50MHz时钟 initial clk_50m 0; always #10 clk_50m ~clk_50m; // 从机应答模拟在第9个时钟沿拉低SDA作为ACK always (posedge scl) begin if (in_ack_cycle) begin sda_en 1b1; sda_drive 1b0; // ACK 0 end else begin sda_en 1b0; // 释放总线 end end initial begin rst_n 0; start_trigger 0; #200 rst_n 1; (posedge clk_50m); start_trigger 1; (posedge clk_50m); start_trigger 0; // 跑完整次仿真自动结束 wait (wr_done); #5000 $finish; end endmodule仿真跑完要看几个关键的波形时间点起始条件产生时SDA相对SCL的时序对不对地址发送时每一位的顺序是不是MSB先出第9个时钟位置从机是否拉低了SDA停止条件SDA的上升沿是不是在SCL高电平时产生的。这几个点对了基本就说明状态机的流程没走偏。4. 状态机调试那些事从仿真到上板实测4.1 仿真能过上板就挂的最常见原因这类问题我碰到太多次了总结下来就几条复位不同步、跨时钟域没处理、输出时序约束不够。复位问题最常见。老式的写法喜欢用异步复位always (posedge clk or negedge rst_n)这在FPGA里其实可以用但要注意复位释放的时刻必须避开时钟有效沿否则寄存器输出会出现亚稳态。很多工程里复位信号是从按键、接口芯片、上位机来的本身就是异步信号不做异步复位同步释放直接挂上去上板就时灵时不灵。我自己现在写新代码除非有特殊需求默认用同步复位省心。跨时钟域问题也很典型比如状态机跑在100MHz但输入信号是从一个1MHz的外设来的。你不做两级同步直接塞进状态机就会出现采样到中间电平的恶心情况状态机偶尔乱跳。解决手段就那几个慢到快用两级D触发器同步快到慢用脉冲同步器或握手。别嫌麻烦这些基本功省不了。4.2 状态机跑飞了怎么排查状态机跑飞说白了就是状态寄存器跳到了一个非法状态或者卡死在某个状态出不来。排查思路我按顺序列出来先查复位复位释放正常吗复位时间够不够有没有在时钟沿附近释放再看输入状态跳转依赖的输入信号是不是有毛刺或亚稳态用示波器或者ILA抓一下真实输入波形别信仿真。检查输出有没有全态定义case的default分支写了吗没有default的case在综合时会生成锁存器状态机跳进奇怪的组合逻辑就是这里出的问题。看状态编码是否安全非法状态有没有恢复到合法状态的路径如果是独热码多bit同时翻转导致的非法状态转移尤其要小心可以考虑加一个看门狗计数器连续多个时钟没有预期跳转就强制回IDLE。我自己最喜欢用的工具是ILA集成逻辑分析仪把当前状态寄存器和关键输入信号挂上去触发采样看状态机的真实运行轨迹这个比盲猜高效太多了。4.3 常见问题速查表现象可能原因对策状态机上板后偶尔乱跳输入信号未同步、亚稳态输入先过两级触发器慢信号做握手总线波形有毛刺输出是组合逻辑产生输出改为寄存器输出或加约束状态机死循环卡死复位不彻底 / 非法状态无恢复路径检查复位释放时序补default分支和看门狗SCL/SDA时序不对SDA变化时机没卡在SCL低电平改SDA只能在scl_half为低半周期时进行仿真能过、上板无效跨时钟域、管脚约束错误、信号被综合优化核对XDC引脚和时序约束检查综合报告读到的数据全是FF地址不对 / 器件没在总线上 / 应答条件不匹配用示波器抓SDA第9个时钟确认有无ACK低电平4.4 一个独家建议状态机里也写个超时保护这块是我个人的工程习惯分享给大家。状态机在某些异常场景下真的会卡在某个状态比如I2C总线上从机没回ACK、握手信号永远等不到。如果没有超时机制整条链路就冻住了后续所有逻辑全瘫痪。所以稍微复杂一点的状态机我都建议加一个超时计数器进入某个状态时清零每个时钟加一累加到预设阈值就强制跳回IDLE或错误状态同时置一个error标志。这个习惯说实话救过我很多次。尤其在给的接口信号不太靠谱的第三方模块时超时保护能避免整个系统被一个哑掉的模块拖死。代价只是几个寄存器和一次比较非常值。5. 从状态机到工程思维写在最后的几句体会状态机本身不复杂但把它写稳、用好是FPGA工程师的基本功。回想我最早写状态机的时候一个三段式模板背了很久然后在UART、SPI、I2C、DDR这些常见接口上反复用它才慢慢明白哪段该用组合逻辑、哪段该用时序逻辑、输出要打几拍、复位怎么做——这些东西不是背出来的是踩坑踩出来的。对刚开始学的朋友我的建议是先把今天这个三段式模板吃透然后找几个典型的外设接口协议UART收发、SPI读写、I2C访问EEPROM用状态机一个个实现仿真跑通再到板子上调。状态机这个工具练手了后面看什么协议都不怕无非是把人家datasheet里的时序图翻译成状态转移图再把状态转移图翻译成Verilog的事。状态机之上还有跨时钟域、低功耗、断言验证、总线协议这些大山等着爬但状态机是地基地基打不牢楼高了早晚要塌。这篇就到这里希望对正在啃状态机的你有点用。我自己也是从照抄别人的三段式模板起步的你现在踩的坑我基本都踩过有问题评论区见。
返回列表