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

资讯详情

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

FPGA主模式I2C控制器RTL设计:三段式状态机与三态驱动实践

FPGA主模式I2C控制器RTL设计:三段式状态机与三态驱动实践

1. 主模式I2C的RTL设计到底在解决什么问题

做FPGA或者ASIC的兄弟应该都有体会,I2C这个协议看起来简单——两根线,一根时钟一根数据,开漏输出,挂载多个从设备。但真正落到RTL代码上,尤其是要做一个稳定可靠的主模式控制器,坑远比想象中多。我最早接触I2C是在一个传感器采集项目里,当时觉得不就是个慢速串行总线嘛,随便写个状态机就搞定了。结果第一版代码上板之后,波形抓出来一看,起始条件建立时间不够、数据保持时间踩在边界上、从设备偶尔NACK、多字节连续读写的时候状态机跳转混乱——问题一个接一个。

这篇文章要聊的,就是主模式I2C控制器的RTL设计,核心围绕三个东西展开:状态机架构怎么选、三态驱动怎么处理、时序参数怎么算。适合有一定Verilog基础、正在做I2C主控制器设计或者调试的工程师,也适合想从“能跑”进阶到“跑得稳”的朋友。我不会只贴代码,而是把每个设计决策背后的逻辑讲清楚——为什么用三段式状态机而不是一段式,为什么SCL和SDA的输出要分开处理,时钟分频系数到底怎么算才能覆盖标准模式、快速模式和高速模式。

先给一个全局认知:I2C主模式控制器的本质是一个协议转换器——把上层给的并行数据(设备地址、寄存器地址、写数据)转换成符合I2C时序规范的串行比特流,同时把从设备返回的串行数据还原成并行数据。这个转换过程由状态机驱动,而状态机的每一个状态都对应着总线上一个明确的物理动作。理解了这个本质,后面的设计思路就顺了。

2. 状态机架构选型:一段式、两段式还是三段式

2.1 三种状态机写法的本质区别

Verilog写状态机,教科书上通常讲三种写法:一段式、两段式、三段式。很多人初学的时候觉得功能都一样,随便选一个能跑就行。但实际上,这三种写法在时序收敛、代码可维护性、综合结果上的差异非常大,尤其是在I2C这种对时序敏感的场景下。

一段式状态机把所有逻辑(状态转移、输出赋值、计数器控制)塞在一个always块里。代码看起来紧凑,但组合逻辑路径长,容易产生毛刺,而且后期加功能的时候牵一发动全身。我早期写过一版一段式的I2C控制器,仿真没问题,上板之后SCL上出现了窄脉冲毛刺,从设备直接不响应了。后来用逻辑分析仪抓波形才发现,是组合逻辑的竞争冒险导致的。

两段式状态机把状态转移和输出逻辑分开,时序逻辑负责状态切换,组合逻辑负责输出译码。这种写法比一段式好一些,但输出仍然是组合逻辑,毛刺风险依然存在。对于I2C的SCL和SDA这种直接驱动外部引脚的信号来说,组合逻辑输出是大忌。

三段式状态机是我最终采用的方案,也是我推荐给所有做I2C控制器的朋友的标准写法。它的核心思想是:第一段时序逻辑描述状态转移,第二段组合逻辑描述状态转移条件,第三段时序逻辑描述输出。这样所有输出都是寄存器输出,没有组合逻辑毛刺,时序收敛也好做。

2.2 为什么I2C主模式必须用三段式

有人可能会问:I2C速度又不高,标准模式才100kHz,快速模式400kHz,就算有毛刺,从设备也应该能滤掉吧?这个想法很危险。I2C协议对**起始条件(START)和停止条件(STOP)**的定义是建立在SDA和SCL的相对时序上的:SCL为高时SDA从高变低是START,SCL为高时SDA从低变高是STOP。如果你的SDA输出有毛刺,在SCL高电平期间抖动了一下,从设备就可能误判为START或STOP条件,导致总线状态混乱。

三段式状态机的输出是寄存器打拍出来的,每个输出信号的跳变都对齐到时钟边沿,不会出现组合逻辑的竞争冒险。而且第三段输出逻辑可以很方便地根据当前状态和输入条件,精确控制每个信号的赋值,代码结构也清晰。

2.3 状态编码的选择:二进制、格雷码还是独热码

状态编码方式也会影响控制器的稳定性和资源占用。二进制编码用的寄存器最少,但状态跳转时多位同时变化,译码逻辑复杂,容易产生毛刺。格雷码相邻状态只有一位变化,毛刺少,但状态多了之后编码不直观。独热码每个状态用一个寄存器位表示,译码简单,速度快,但寄存器消耗大。

对于I2C主控制器来说,状态数量通常在10到20个之间,不算多。我一般推荐用独热码,原因是:第一,译码逻辑简单,每个状态就是一个寄存器的位,组合逻辑路径短;第二,状态跳转时只有两个位变化(当前状态位清零,下一状态位置一),毛刺风险低;第三,FPGA里寄存器资源相对充裕,多消耗几个寄存器换来时序余量的提升是值得的。如果做ASIC对面积敏感,可以考虑格雷码,但一定要做好时序约束。

3. 主模式I2C的状态划分与转移逻辑

3.1 完整的状态定义

一个完整的主模式I2C控制器,状态划分需要覆盖以下场景:总线空闲、发送起始条件、发送设备地址和读写位、等待应答、发送寄存器地址、发送写数据、重复起始条件、读取数据、发送应答或非应答、发送停止条件。我通常把它拆成以下状态:

  • IDLE:总线空闲,SCL和SDA都为高电平。
  • START:产生起始条件,SCL保持高,SDA从高拉低。
  • SEND_ADDR:逐位发送7位设备地址加1位读写控制位。
  • ACK_ADDR:释放SDA,接收从设备的应答位。
  • SEND_REG:发送寄存器地址(如果是读写从设备内部寄存器)。
  • ACK_REG:接收寄存器地址的应答位。
  • SEND_DATA:发送写数据字节。
  • ACK_DATA:接收写数据的应答位。
  • RESTART:产生重复起始条件。
  • READ_DATA:接收从设备返回的读数据字节。
  • SEND_ACK:主机发送应答(继续读)或非应答(结束读)。
  • STOP:产生停止条件,SCL高时SDA从低拉高。

这些状态之间的转移条件由位计数器和字节计数器共同决定。位计数器从0到7循环,每计满8位产生一个字节完成信号;字节计数器根据上层配置的读写长度决定发送或接收多少个字节。

3.2 状态转移条件的细节处理

状态转移条件的设计有几个容易踩坑的地方。第一个是起始条件的建立时间。I2C规范要求START条件之前,SDA和SCL都必须保持高电平至少4.7微秒(标准模式)。很多初学者直接从IDLE跳到START,SDA拉低的时候SCL可能刚稳定下来,建立时间不够。我的做法是在IDLE状态加一个延时计数器,确保总线空闲时间满足规范要求再发起始条件。

第二个是应答位的采样时机。从设备在SCL的第9个时钟周期拉低SDA表示应答。主机需要在SCL高电平期间采样SDA。如果采样时机不对,可能采到从设备释放SDA的过渡状态。我的做法是在SCL高电平的中间时刻采样,具体来说,SCL上升沿之后等待四分之一时钟周期再采样,这样最稳定。

第三个是重复起始条件与停止条件的区分。读操作通常需要先写寄存器地址,然后发重复起始条件,再发读地址。重复起始条件的时序和普通起始条件一样,但前面不需要停止条件。状态机里需要用一个标志位来区分当前是首次起始还是重复起始,避免错误地插入停止条件。

3.3 位计数器和字节计数器的协同

位计数器负责在一个字节内逐位发送或接收,字节计数器负责统计已经传输了多少个字节。这两个计数器的协同逻辑是状态机正确运行的关键。

位计数器在每个SCL下降沿加一,计到7之后归零,同时产生一个字节完成脉冲。字节计数器在字节完成脉冲到来时加一,根据上层配置的总字节数判断是否继续。如果是写操作,字节计数器计到目标值后进入STOP状态;如果是读操作,字节计数器计到目标值减一时,在最后一个字节的应答位发送NACK,然后进入STOP状态。

这里有一个细节:读操作的最后一个字节,主机必须发送NACK而不是ACK,告诉从设备停止发送。很多初学者忘了这一点,导致从设备继续输出数据,总线挂死。我在代码里用一个last_byte信号来标记当前是否是最后一个字节,在SEND_ACK状态根据这个信号决定发送ACK还是NACK。

4. 三态驱动的RTL实现与开漏总线处理

4.1 I2C总线的物理特性与三态驱动原理

I2C总线是开漏输出结构,SCL和SDA线上各有一个上拉电阻。任何设备都可以把线拉低,但不能主动拉高——拉高是靠上拉电阻完成的。这意味着每个设备的SCL和SDA引脚都需要三态驱动:输出0时驱动低电平,输出1时进入高阻态,让上拉电阻把线拉高。

在Verilog里,三态驱动通常这样写:

assign scl = scl_out_en ? 1'b0 : 1'bz; assign sda = sda_out_en ? 1'b0 : 1'bz;

其中scl_out_en和sda_out_en是输出使能信号,为1时驱动低电平,为0时释放总线。注意这里永远不驱动高电平,只驱动低电平或高阻。这是I2C和SPI、UART最大的区别——SPI是推挽输出,可以主动驱动高和低;I2C必须遵守开漏规则,否则多个设备同时驱动时会发生电源对地的短路。

4.2 输出使能信号的生成逻辑

输出使能信号的生成是三态驱动的核心。对于SCL来说,主机在大部分时间里都是驱动低电平的(因为主机控制时钟),只有在需要释放时钟线让从设备拉低时(比如时钟同步或仲裁丢失)才进入高阻。但在标准的主模式单主机场景下,SCL通常由主机独占控制,从设备不会拉低SCL(除了时钟拉伸的情况)。

对于SDA来说,输出使能的控制更复杂。发送数据位时,根据当前数据位是0还是1决定是否驱动低电平;接收数据位时,必须释放SDA让从设备驱动;发送应答位时,根据是ACK还是NACK决定是否驱动低电平。

我在代码里把SDA的输出使能逻辑放在三段式状态机的第三段,根据当前状态和要发送的数据位来赋值:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sda_out_en <= 1'b0; end else begin case (state) SEND_ADDR, SEND_REG, SEND_DATA: begin sda_out_en <= ~tx_data[bit_cnt]; end ACK_ADDR, ACK_REG, ACK_DATA, READ_DATA: begin sda_out_en <= 1'b0; end SEND_ACK: begin sda_out_en <= ~ack_bit; end default: begin sda_out_en <= 1'b0; end endcase end end

这段代码的逻辑是:发送状态时,如果当前数据位是0,则驱动低电平(sda_out_en = 1);如果数据位是1,则释放总线(sda_out_en = 0)。接收状态时始终释放总线。发送应答时,ACK驱动低电平,NACK释放总线。

4.3 输入采样与同步处理

SDA和SCL作为输入信号,从外部引脚进入FPGA后需要做同步处理,避免亚稳态。通常用两级触发器打拍:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sda_sync1 <= 1'b1; sda_sync2 <= 1'b1; scl_sync1 <= 1'b1; scl_sync2 <= 1'b1; end else begin sda_sync1 <= sda_in; sda_sync2 <= sda_sync1; scl_sync1 <= scl_in; scl_sync2 <= scl_sync1; end end

同步之后的信号再用于状态机的采样逻辑。注意采样SDA的时机——在SCL高电平期间采样,而且要在SCL稳定之后。我通常用scl_sync2的上升沿作为采样触发条件,在上升沿之后延迟几个时钟周期再采样SDA,确保信号稳定。

5. 时钟分频与I2C时序参数计算

5.1 时钟分频系数的计算方法

I2C的SCL频率由主机时钟分频得到。假设系统时钟为50MHz,目标SCL频率为100kHz(标准模式),分频系数为:

分频系数 = 系统时钟频率 / (2 × SCL频率) = 50,000,000 / (2 × 100,000) = 250

为什么是2倍?因为SCL的一个完整周期包括高电平和低电平各一半,分频计数器需要计到250时翻转一次SCL电平,这样SCL的周期就是500个系统时钟周期,对应100kHz。

对于快速模式400kHz,分频系数为:

分频系数 = 50,000,000 / (2 × 400,000) = 62.5

取整为62或63。取62时SCL频率约为403kHz,取63时约为397kHz,都在400kHz的容差范围内。我一般取63,稍微慢一点更稳妥。

5.2 时序参数的规范要求

I2C规范对时序有明确要求,标准模式下:START条件的建立时间最小4.7微秒,START条件的保持时间最小4.0微秒,STOP条件的建立时间最小4.0微秒,数据建立时间最小250纳秒,数据保持时间最小0纳秒(但实际建议留足余量)。

这些参数在RTL里通过延时计数器来保证。比如START条件的建立时间,在IDLE状态检测到启动信号后,先等待一个4.7微秒的计数器,然后再拉低SDA。4.7微秒在50MHz时钟下对应235个时钟周期,计数器计到235即可。

数据保持时间的处理稍微复杂一些。I2C规范要求数据在SCL下降沿之后保持至少0纳秒,但实际上为了稳定,我通常让数据在SCL下降沿之后继续保持半个时钟周期再变化。这样从设备有足够的时间锁存数据。

5.3 时钟拉伸的处理

时钟拉伸是I2C的一个特性,从设备可以通过拉低SCL来暂停主机的时钟,给自己争取处理时间。主模式控制器必须支持这个特性,否则在某些从设备(比如EEPROM在写周期内)上会出错。

处理时钟拉伸的逻辑是:主机释放SCL之后,检测SCL是否真的变高了。如果SCL被从设备拉低,主机进入等待状态,直到SCL变高才继续。在RTL里,这通过检测scl_sync2的上升沿来实现:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scl_stretch <= 1'b0; end else begin if (scl_out_en == 1'b0 && scl_sync2 == 1'b0) scl_stretch <= 1'b1; else if (scl_sync2 == 1'b1) scl_stretch <= 1'b0; end end

当scl_stretch为1时,状态机暂停在当前位置,不进行状态转移,直到SCL释放。

6. 读写操作的完整流程与代码实现

6.1 写操作的流程拆解

写操作是最基本的I2C事务,流程如下:发送START条件,发送设备地址加写位(0),等待ACK,发送寄存器地址,等待ACK,发送数据字节,等待ACK,发送STOP条件。

每一步在状态机里都有对应的状态。以写一个字节到EEPROM的寄存器为例,状态转移序列是:IDLE → START → SEND_ADDR → ACK_ADDR → SEND_REG → ACK_REG → SEND_DATA → ACK_DATA → STOP → IDLE。

在SEND_ADDR状态,状态机把设备地址和写位组成一个8位数据,逐位发送。位计数器从7递减到0,每次发送最高位。发送完8位后进入ACK_ADDR状态,释放SDA,在SCL的第9个周期采样SDA,如果为0表示从设备应答,继续;如果为1表示NACK,进入错误处理或直接STOP。

6.2 读操作的流程拆解

读操作比写操作多一个重复起始条件。流程是:发送START,发送设备地址加写位,等待ACK,发送寄存器地址,等待ACK,发送重复START,发送设备地址加读位(1),等待ACK,读取数据字节,发送NACK,发送STOP。

注意读操作的最后一个字节,主机必须发送NACK。在SEND_ACK状态,根据last_byte信号决定发送ACK还是NACK。如果是最后一个字节,ack_bit置1(NACK),释放SDA;否则ack_bit置0(ACK),驱动SDA低。

6.3 关键代码片段与注释

下面是状态机第三段输出逻辑的核心代码,展示了SCL和SDA的输出使能如何根据状态生成:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scl_out_en <= 1'b0; sda_out_en <= 1'b0; end else begin case (state) IDLE: begin scl_out_en <= 1'b0; sda_out_en <= 1'b0; end START: begin scl_out_en <= 1'b0; sda_out_en <= 1'b1; end SEND_ADDR, SEND_REG, SEND_DATA: begin scl_out_en <= ~scl_phase; sda_out_en <= ~tx_data[bit_cnt]; end ACK_ADDR, ACK_REG, ACK_DATA, READ_DATA: begin scl_out_en <= ~scl_phase; sda_out_en <= 1'b0; end SEND_ACK: begin scl_out_en <= ~scl_phase; sda_out_en <= ~ack_bit; end STOP: begin scl_out_en <= 1'b0; sda_out_en <= 1'b0; end default: begin scl_out_en <= 1'b0; sda_out_en <= 1'b0; end endcase end end

其中scl_phase是SCL的相位信号,由分频计数器生成,0表示SCL低电平阶段,1表示高电平阶段。scl_out_en <= ~scl_phase表示在SCL低电平阶段驱动低电平,高电平阶段释放总线。

7. 仿真验证与上板调试的实操经验

7.1 仿真环境的搭建

I2C控制器的仿真需要一个从设备模型来响应主机的读写请求。我通常用Verilog写一个简单的EEPROM模型,支持随机读写和ACK响应。仿真平台包括:时钟和复位生成、主机控制器实例化、从设备模型实例化、总线上的上拉电阻模拟(用pullup语句)。

仿真用例要覆盖:单字节写、单字节读、多字节连续写、多字节连续读、NACK响应处理、时钟拉伸场景。每个用例都要检查总线上的时序是否符合规范,数据是否正确。

7.2 逻辑分析仪抓波形的技巧

上板调试的时候,逻辑分析仪是必不可少的工具。我用的是Saleae Logic或者类似的工具,采样率至少设到10MHz以上,才能看清100kHz SCL的细节。

抓波形的时候重点看几个地方:START条件的建立时间是否足够,SDA在SCL高电平期间是否稳定,ACK位的采样点是否正确,STOP条件的时序是否规范。如果发现从设备不响应,先检查设备地址是否正确——7位地址左移一位再加读写位,很多人在这里搞错。

7.3 常见问题速查表

问题现象可能原因排查方法
从设备完全不响应设备地址错误用逻辑分析仪抓SDA波形,核对地址位
第一个字节ACK正常,后续NACK寄存器地址错误检查寄存器地址是否在从设备支持范围内
SCL无输出输出使能逻辑错误检查scl_out_en是否在正确状态置位
SDA一直被拉低三态驱动冲突检查是否有多个设备同时驱动SDA
读数据全为1从设备未驱动SDA检查主机是否在READ_DATA状态释放了SDA
总线挂死缺少STOP条件检查状态机是否有超时退出机制

8. 踩坑记录与设计优化建议

8.1 我踩过的三个典型坑

第一个坑是起始条件建立时间不足。早期版本直接从IDLE跳到START,SDA拉低的时候SCL刚稳定,建立时间只有几个时钟周期。从设备偶尔能识别,偶尔不能。后来在IDLE状态加了延时计数器,确保总线空闲时间超过4.7微秒再发起始条件,问题解决。

第二个坑是读操作最后一个字节忘了发NACK。从设备收到ACK后继续输出下一个字节,但主机已经进入STOP状态,总线冲突。后来加了last_byte信号,在SEND_ACK状态根据这个信号决定ACK还是NACK。

第三个坑是时钟拉伸处理不完整。主机释放SCL后没有检测SCL是否真的变高,直接进入下一个状态。在EEPROM写周期内,从设备拉低SCL,主机却继续跑状态机,导致时序错乱。后来加了scl_stretch检测逻辑,SCL被拉低时状态机暂停。

8.2 设计优化方向

如果要把这个控制器做得更完善,可以考虑几个方向:第一,加超时机制,总线挂死时自动复位状态机;第二,支持多主机仲裁,检测SDA与自身输出不一致时退出仲裁;第三,加FIFO缓冲,支持连续多字节读写而不需要上层频繁干预;第四,参数化设计,SCL频率、地址位宽、数据位宽都可配置。

8.3 给初学者的建议

如果你刚开始做I2C主控制器,我的建议是:先用三段式状态机把基本读写跑通,再逐步加功能。不要一上来就追求大而全,先把单字节写和单字节读调稳定。仿真的时候一定要写从设备模型,不要只靠上板调试。逻辑分析仪抓波形的时候,先看SCL再看SDA,SCL是节奏,SDA是内容,节奏对了内容才好排查。

我在实际项目里用这套架构做过EEPROM控制器、传感器采集控制器、OLED驱动控制器,基本没出过大问题。关键就是把状态机写规范,把三态驱动处理干净,把时序参数算准确。这三件事做好了,I2C主模式控制器就稳了。

返回列表