尧图精选

RTL三态驱动与三段式状态机协同设计:I2C主模式实战解析

🕒 发布时间:2026/10/1 20:20:43 📁 来源:尧图网络
我刚写主模式模块那会儿最不理解的语法就是三态驱动。主模式、状态机听名字都是我说了算的东西可 RTL 里一看到assign sda_io sda_oe ? sda_out : 1bz;就总觉得写反了——既然是主为什么还要把总线让出去后来有一次在 ModelSim 里仿真我在一条 I2C 总线上挂了一个主模块和一个从设备模型结果从设备回 ACK 的那一刻SDA 电平直接变成 X 态波形上红彤彤一片。那次之后我才彻底想明白共享总线不是我们独享的主模式的能力不在于一直占着而在于按协议精准地控制保持和释放。这篇文章是系列第 05 讲重点解决从状态机设计到三态驱动这条链路。适合刚接触 RTL、准备写带 inout 接口的控制器 IPI2C、SPI、1-wire 等、或者在 FPGA 里做主模式模块的工程师。我会把三段式状态机的选型逻辑、三态驱动模板、仿真坑、综合落地一次讲透最后给一个完整可跑的 I2C 主模式示例。1. 主模式这个主字藏着一个关键的自我约束1.1 主模式在协议里到底主在什么地方先抛开代码说说协议层面的事。以 I2C 为例主设备负责产生 SCL 时钟、发起 START/STOP 条件、决定数据传输方向和速率从设备只能被动响应。从这个角度说主确实掌握控制权。但注意一个细节I2C 的 SDA 是一条双向共享线总线上可以挂多个器件。主设备在发送数据时是驱动器可一旦进入接收环节——比如读从设备的数据或者等从设备回 ACK——它就必须把 SDA 让出来。这时候总线由谁驱动由从设备。如果主设备还死死拉着 SDA两个驱动源一个拉高一个拉低轻则在仿真里出现 X 状态重则在板子上直接造成逻辑误判甚至器件应力问题。所以主模式的主指的是对总线时序的控制权而不是永久占用权。它真正的能力是能在正确的时间点决定自己当驱动器还是当旁观者。这个决策靠的就是 RTL 里的状态机和三态驱动联合实现。1.2 不是所有主模式都需要三态关键看总线拓扑这里有个容易误解的点很多初学者做完一个 SPI 主模式发现根本没用到 inout 和三态于是觉得三态驱动是个可有可无的东西。实际上需要三态的前提是多设备分时共享一条双向信号线。对比一下常见总线拓扑SPIMOSI、MISO 是分离的单向线主设备只管输出和输入靠 CS 片选区分从设备数据线不共享不需要三态。I2C / SMBus / 1-wire数据线是半双工 inout所有设备并联在同一根线上必须有三态控制。AHB / AXI虽然也是主从结构但读数据通道和写数据通道分开且通过独立的握手信号和地址仲裁区分设备数据线不需要 inout 三态。一句话总结看到 inout 端口第一反应应该是这里有一条共享总线第二反应才是这个模块是什么模式。如果想清楚拓扑再动手很多设计决策会顺理成章。1.3 三态的物理含义推挽、高阻与 open-drain三态中除了 0 和 1第三个状态是 Z高阻。高阻在逻辑上可以理解成物理断开——输出级的 PMOS 和 NMOS 都关断引脚既不拉高也不拉低外部电压由其他器件或上拉电阻决定。具体到 RTL 建模两种常见场景推挽三态输出可以是 0 或 1使能关闭时输出高阻。assign data_io oe ? data_o : 1bz;open-drain开漏器件只能拉低不能主动拉高高电平靠外部上拉电阻实现。I2C 就是这种。assign data_io od_en ? 1b0 : 1bz;很多协议强制 open-drain是为了避免多主冲突——两个主设备同时驱动总线时一个拉低一个拉高会造成短路电流而 open-drain 下只有拉低和不拉低两种行为即使同时驱动也只是两个管子同时拉低不会出问题。这也是为什么 I2C 从物理层就比推挽总线更抗冲突。写 RTL 之前先想清楚走哪种代码风格会差很多。2. 主模式状态机的三种写法选型背后的真实差异2.1 状态机不是 Verilog 的专属概念状态机这个模型做 Java 服务端的人处理订单流程在用单片机工程师写按键扫描在用还有人管循环调度那类写法叫环岛状态机。名字五花八门但核心模型都一样一堆状态、一组转移条件、一套输出动作。在 RTL 里状态机就是把协议时序翻译成一段可综合逻辑的最自然工具也是主模式模块的大脑。在讲三种写法之前先明确一个底层问题主模式状态机要干两件事——根据当前状态决定总线驱动的行为拉高、拉低、释放根据反馈信号ACK、忙信号决定下一步去哪。这两件事如果写在一个 always 块里表面上省事实际调试时容易把自己绕晕。下面就具体拆三种写法。2.2 一段式能跑但难维护一段式把状态寄存器的更新、次态计算、输出动作全部塞进同一个时序 always 块always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; scl 1b1; sda_en 1b0; end else begin case (state) IDLE: if (start) begin state START; sda_en 1b1; // 拉低 SDA end START: begin scl 1b0; state SHIFT; end // 后续略 endcase end end优点只有一个代码量少。缺点在稍微复杂的协议里会集中爆发输出赋值寄存一拍导致你看到的 bus 信号总是比状态转移慢一拍状态计算和输出逻辑纠缠在一起一旦要调整时序细节改一处崩三处。一段式适合十几行的教学 Demo主模式协议里不推荐。2.3 二段式组合逻辑算次态时序逻辑更新状态二段式把下一状态的计算次态逻辑和状态同步更新分开。次态逻辑是组合电路状态寄存器是时序电路// 第一段状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段次态计算组合 always (*) begin next_state state; case (state) IDLE: if (start) next_state START; START: next_state SHIFT; // 后续略 endcase end // 输出逻辑仍然可以单独写组合/时序 always (*) begin sda_en (state START) || (state SHIFT); end二段式比一段式清晰很多因为状态转移路径变得可追踪。它的不足在于输出逻辑和状态逻辑是分开写的但输出信号如果在状态转移后马上需要稳定纯组合输出容易在转移窗口产生毛刺。2.4 三段式状态机与输出解耦主模式协议的稳妥选择三段式把逻辑拆成三块状态寄存器、次态组合逻辑、输出逻辑。输出逻辑单独用一组时序 always并且输出寄存一拍// 第一段状态寄存器 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段次态逻辑 always (*) begin next_state state; case (state) IDLE: if (start) next_state START; START: next_state SHIFT; SHIFT: if (bit_cnt 3d7) next_state ACK; ACK: next_state STOP; STOP: next_state IDLE; endcase end // 第三段输出逻辑时序 always (posedge clk or negedge rst_n) begin if (!rst_n) sda_en 1b0; else begin case (next_state) START: sda_en 1b1; // 准备拉低 SDA ACK: sda_en 1b0; // 释放 SDA等从设备回 ACK STOP: sda_en 1b0; default: sda_en sda_en; endcase end end三个段落各管一摊调试时想查状态转移看第二段想查输出时序看第三段非常干净。在主模式场景里还有个额外好处输出打一拍后sda_en 的变化与状态跳变错开一个时钟域仿真时更容易观察使能时序综合后也更好控制建立保持时间。三种写法对比我用一个表格总结写法结构优点缺点适用场景一段式状态 输出全在一个 always代码少、直观输出时序不清晰、难改教学/极简 Demo二段式状态寄存器 组合次态转移路径清楚输出易出毛刺简单控制逻辑三段式状态/次态/输出三段分离时序规范、输出寄存、可维护性最好代码量稍大主模式总线协议、复杂控制器我自己的习惯是只要模块里出现 inout 端口状态机无脑选三段式。因为三态使能的时序要求往往很苛刻差一个节拍总线行为就错了三段式的输出寄存刚好让使能变化可以精确预测。3. 三态驱动的 RTL 写法从接口方向到综合结果3.1 inout 端口的建模本质inout 端口在 RTL 层面就是一个向量写路径和读路径同时存在。写路径由输出数据和使能共同决定读路径则永远把引脚电平采回来。最经典的模板module tri_driver #( parameter WIDTH 1 )( input wire [WIDTH-1:0] data_out, // 要发送的数据 input wire oe, // 输出使能1 驱动0 高阻 output wire [WIDTH-1:0] data_in, // 采样到的数据用于读 inout wire [WIDTH-1:0] data_io // 双向引脚 ); assign data_io oe ? data_out : {WIDTH{1bz}}; assign data_in data_io; endmodule这个模块谁都能写但真正关键的是oe 信号怎么产生。我见过不少新手把 oe 直接接高电平结果 inout 端口永远在驱动状态总线上的回读全是自己写出去的数据从设备拉低都拉不动。oe 必须来自状态机的输出逻辑由协议时序精确控制。3.2 open-drain 模式下的稳定模板I2C、SMBus 这类协议用 open-drain模板比推挽更简单——因为输出数据其实只用一个使能位控制是否拉低就行module od_driver ( input wire od_en, // 1拉低总线0释放总线 output wire sda_in, // 采样输入 inout wire sda ); assign sda od_en ? 1b0 : 1bz; assign sda_in sda; endmodule这段代码的行为与硬件 open-drain 完全一致od_en1时拉低od_en0时释放外部上拉电阻把 SDA 拉回高电平。注意这里不应该再出现 data_out 信号因为 open-drain 设备本来就不能主动拉高。如果看到某个人给 open-drain 总线接了一个oe ? data_out : 1bz并且 data_out 还会置 1那说明他对协议物理层理解有问题——拉高动作是上拉电阻做的不是芯片。顺带说一个实测经验从状态机到 od_en 之间建议寄存器输出直接给 od_en不要再经过组合逻辑做二次转发。因为 open-drain 总线上任何一拍的意外驱动都可能造成亚稳态或者总线竞争组合逻辑多一层就多一分毛刺风险。3.3 综合与 FPGA 落地的差异内部不要用三态这里必须强调一个 FPGA 的基本约束FPGA 内部没有真正的三态缓冲。Xilinx 和 Altera/Intel 在内部逻辑单元CLB / ALM里并没有可用的高阻驱动能力三态 assign 只有在连接到芯片顶层引脚时综合器才会把它映射成真正的 IO Buffer比如 Xilinx 的 IOBUF、Intel 的 ALTIOBUF。如果你在内部模块里写wire internal_bus; assign internal_bus en ? data_a : 1bz;综合工具大概率会报 warning最后实现出来的可能是多路选择器或一堆隔离逻辑并不是你想要的多设备共享线。正确的内部总线仲裁做法是用多路选择器assign internal_bus sel_a ? data_a : data_b;所以三态驱动必须收到顶层靠近芯片引脚。这也是很多公司 RTL 编码规范里强制的一条inout 只允许出现在顶层模块内部模块一律单方向。3.4 ModelSim 到底能不能看 RTL 电路图这个问题在热词里反复出现我直接给结论ModelSim/Questa 是仿真器不是综合器它本身不能生成综合意义上的 RTL 电路图。它主要干两件事跑 RTL 仿真、看波形。想看电路图得用带综合或综合后分析的工具。实际工作中我一般是这么分的QuartusTools - Netlist Viewers - RTL Viewer可以看综合前的 RTL 原理图Technology Map Viewer可以看映射到 FPGA 资源后的原理图。查模块间连接关系、查某个信号驱动到了哪个引脚这里最直观。Vivado综合后用Schematic窗口打开底层视图可以看到三态 IO 是否被正确实现成 IOBUF。ModelSim虽然没有 RTL viewer但它有个非常实用的功能——查看设计层次结构Sim 窗口里的 Instance 列表以及把一个状态的信号用枚举名显示而不是二进制的数字。这反而是在调试状态机时最有用的能力。以后看到有人问Modelsim 能不能看 RTL 电路图可以直接告诉他看波形找 ModelSim看电路连接找 Quartus/Vivado两者分工不同不用混着用。4. 完整实例一个 I2C 主模式收发器的状态机 三态协同设计4.1 场景与模块接口下面这个例子目标是用 I2C 主模式向某个寄存器地址写一个字节并读取 ACK。为了聚焦本讲主题我做了教学简化SCL 用计数器分频产生忽略多主仲裁和停时钟细节重点展示三段式状态机如何和 open-drain 三态驱动配合。模块接口信号名方向说明clk / rst_n输入系统时钟/复位start输入发起一次写传输的脉冲wdata[7:0]输入要发送的字节scl输出I2C 时钟sdainoutI2C 数据线open-drainbusy输出传输进行中标志nack输出从设备未应答标志4.2 状态定义与三段式代码骨架状态枚举IDLE、START、SHIFT、ACK、STOP。IDLE总线空闲SDA 释放SCL 保持高。STARTSCL 为高时 SDA 从高变低产生起始条件。SHIFT按位发送 8 个数据位SCL 低电平时更新数据高电平时保持。ACK第 9 个 SCL 高电平阶段释放 SDA采样从设备应答。STOPSCL 为高时 SDA 从低变高产生停止条件总线回到空闲。核心代码骨架默认 SCL_DIV 参数为 100表示一个 SCL 半周期对应 50 个系统时钟的简化模型module i2c_master #( parameter SCL_DIV 100 )( input wire clk, input wire rst_n, input wire start, input wire [7:0] wdata, output reg scl, inout wire sda, output reg busy, output reg nack ); // 状态定义 localparam IDLE 3d0; localparam START 3d1; localparam SHIFT 3d2; localparam ACK 3d3; localparam STOP 3d4; // open-drain 三态驱动 reg od_en; assign sda od_en ? 1b0 : 1bz; wire sda_in sda; // 例分频计数器省略SCL 由状态机在 SHIFT/ACK 阶段依据 clk_cnt 翻转 // 三段式状态机第一段状态寄存器 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 next_state state; case (state) IDLE: begin if (start) next_state START; end START: begin if (clk_cnt SCL_DIV/2 - 1) next_state SHIFT; end SHIFT: begin if (bit_cnt 3d7 scl_pos_edge) next_state ACK; end ACK: begin if (scl_pos_edge) next_state STOP; end STOP: begin if (clk_cnt SCL_DIV/2 - 1) next_state IDLE; end default: next_state IDLE; endcase end // 第三段输出逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin od_en 1b0; // 空闲时释放总线 busy 1b0; nack 1b0; scl 1b1; end else begin case (next_state) IDLE: begin od_en 1b0; busy 1b0; scl 1b1; end START: begin busy 1b1; od_en 1b1; // 拉低 SDA scl 1b1; // SCL 保持高构成起始条件 end SHIFT: begin scl (clk_cnt SCL_DIV/2) ? 1b0 : 1b1; od_en ~wdata[bit_cnt]; // 发送位0 拉低1 释放 end ACK: begin scl (clk_cnt SCL_DIV/2) ? 1b0 : 1b1; od_en 1b0; // 关键释放 SDA等从设备拉低回 ACK if (scl_pos_edge) nack sda_in; // 高电平时采样 end STOP: begin scl 1b1; // SCL 保持高 od_en 1b0; // 释放 SDA 让上拉拉高形成停止条件 end endcase end end endmodule4.3 状态机与三态驱动怎么衔接才不容易出问题上面代码里我故意把关键点放在ACK状态od_en必须在第 9 个时钟的高电平到来之前就释放这样从设备才有机会把 SDA 拉低。如果你像处理普通数据输出一样在进入 ACK 的同一拍还让od_en保持 1那一整个应答窗口总线都是被你自己拉低的不管从设备回不回应都看不见。衔接设计上我建议把三态驱动看成一个独立的小黑盒状态机只负责给它发od_en和输出数据绝不直接操纵 SDA。好处有两个一是仿真的时候你只看od_en的波形就能判断状态机行为对不对而不需要揣测综合器把代码翻译成什么二是这个od_driver小模块可以在多个主模式接口间复用只要改端口位宽和物理模式就行。另外注意 STOP 状态我这里写的是od_en 1b0理论上 open-drain 释放后 SDA 由外部上拉拉高这就形成了 SCL 高电平期间的上升沿。有些人习惯在 STOP 里先把 SDA 拉低再释放效果一样但要多控一拍反而容易踩时序边界。5. 仿真验证环节的坑三态冲突、上拉模型与波形检查5.1 三态冲突两个驱动源同时拉总线仿真里最经典的错误就是 Testbench 里自己也用一个 assign 强驱动 SDA同时 DUT 的三态输出也在驱动。两个源一个拉高一个拉低ModelSim 波形里就是一片红色 X。I2C 这类 open-drain 总线尤其容易踩因为大家惯性思维是给信号赋个值就等于驱动它。正确的 Testbench 做法是从设备模型一定也用三态输出不能直接assign sda 0或assign sda 1。至少应该是这样reg slave_driving; reg slave_out; assign sda slave_driving ? slave_out : 1bz;或者更接近 I2C 物理层用 open-drain 加外部上拉pullup(sda); // 上拉电阻 assign sda (slave_ack) ? 1b0 : 1bz; // 从设备回 ACK 时才拉低我排查总线莫名其妙变成 X这类问题时的固定步骤是先把 DUT 所有的od_en信号加进波形窗口用列表模式观察每一拍谁在驱动总线。只要总线上只有一个驱动源那 X 基本不会出现出现两个源同时有效的拍就是协议状态机出错的位置。5.2 从设备回读的关键时序在正确时刻释放总线ACK 采样是新手最容易翻车的地方。I2C 标准要求在 SCL 为高电平的中段采样 SDA如果配置成只在 SCL 上升沿采样同时把释放od_en的时机放在同一个时钟沿之前很容易因为仿真误差错过 ACK 窗口。我自己的习惯是ACK 状态里提前一个半周期释放 SDA然后在 SCL 高电平的中间位置开一个采样窗口比如用clk_cnt SCL_DIV - 1作为采样点。这样即使有小的毛刺也不会误判。还有一个小技巧仿真时把sda_in和od_en放在同一个波形组里看。如果od_en变化的时间点和 SDA 电平变化不对应十有八九是三态驱动本身的问题而不是协议状态机的问题。先分清是哪一层出错再往下查。5.3 状态寄存器的波形显示设置状态机一跑起来波形窗口里显示的全是0、1、2、3这种数字根本看不出当前到底在哪个阶段。ModelSim/Questa 里有个非常实用的功能Radix 设置。右键点击状态寄存器的信号名Radix - Symbolic或者Radix - Enum如果代码里用localparam定义了枚举状态名波形就会直接显示IDLE、START、SHIFT这样的名字一眼就能对照协议时序。用 One-Hot 编码的同学可能发现 Symbolic 显示的是十进制的位宽值那就先把这个状态寄存器加上enum类型关联再设置显示格式。三条指令就能搞定别看是小设置调试效率差不少。6. 后端落地的几个自查清单6.1 三态接口在综合、实现阶段的处理写完仿真通过不代表板级没问题。综合后我一般会做三件事在 Quartus查Tools - Netlist Viewers - Technology Map Viewer确认三态端口被正确映射为 IO buffer通常是 OBUFT 或者 ALTIOBUF而不是被综合成普通逻辑。在 Vivado综合后打开 Schematic找到对应引脚确认原语是IOBUF类型。查编译报告里的 warning如果有 tri-state 或者 inout 相关的 warning优先处理。模块内三态被转换这条 warning 很危险说明你的三态逻辑根本没落到引脚上。6.2 板级调试时的常见现象板子上遇到 I2C 协议异常抓逻辑分析仪比示波器好用得多因为总线电平变化慢逻辑分析仪能同时抓 SCL/SDA 和使能信号。常见的几个现象和原因总线空闲时实测是低电平说明上拉电阻没接或者芯片内部没有使能上拉。check 原理图I2C 两根线必须有外部上拉到电源轨。SDA 在部分高电平只有 0.8V 上下总线电容太大、上拉电阻过大导致上升沿爬不上去适当减小上拉电阻或者降低 SCL 频率。抓波形发现 START 条件不满足SCL 和 SDA 的下降沿没有满足SCL 为高时 SDA 变低的先后关系多半是状态机里 SCL 和 SDA 输出没有对齐或者 od_en 打了一拍导致 SDA 翻转比预期晚了。这些坑不踩过一遍不会长记性但只要把状态机的od_en波形和总线波形对照起来看定位都很快。6.3 代码风格与可维护性建议最后分享几个我这些年形成的固定习惯不一定最高级但省过很多返工一是三态接口全部收敛到顶层内部模块之间永远单向信号代码 review 的时候一眼就能扫出哪里在跨模块共享总线。二是命名统一输出使能叫xx_oe或者xx_en数据输出叫xx_o数据输入叫xx_i双向引脚叫xx_io。命名一乱写状态机第三段输出逻辑时很容易把使能和数据搞混。三是状态机每一状态都要注释对应协议时序比如ACK: // 第9个SCL释放SDA采样从设备应答。隔几个月再回来看代码注释能省下大量回忆成本。四是Testbench 里从设备模型一律 open-drain 上拉不要图省事直接 assign 强驱动。仿真里省事的地方最后都会变成板级调试时的不省心。我现在的标准动作是遇到带 inout 总线的接口先把三态模板单独抽成子模块再把状态机用三段式写清楚仿真波形里优先看od_en再判断总线行为板级调试直接逻辑分析仪抓 SCL/SDA。这套流程在 I2C、SMBus、1-wire 这些协议上基本无脑复用你可以直接把上面的骨架拿过去把状态表和时序参数换一换大概率一次跑通。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →