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主模式控制器就稳了。