1. 主模式I2C的RTL设计到底难在哪
很多人第一次写I2C主模式控制器,代码能跑通仿真,波形看着也像那么回事,但一上板就出问题:要么从机不响应,要么读回来的数据错位,要么SCL频率跟预期对不上。问题往往不在协议理解上,而在于RTL的组织方式——状态机怎么划分、三态驱动怎么处理、时序参数怎么落到计数器里。
这篇内容围绕主模式I2C控制器的RTL设计展开,从状态机的架构选型讲到三态双向驱动的实现细节,再到实际调试中容易踩的坑。适合已经了解I2C协议基本时序、准备动手写Verilog代码的工程师,也适合写过一版但总觉得不够稳、想重新梳理设计思路的人。
I2C主模式的核心工作其实就四件事:产生正确的START和STOP条件、把并行数据串行移出、在正确的时刻采样SDA、以及处理从机的ACK/NACK响应。听起来简单,但把这四件事放进一个干净的状态机里,同时保证SCL频率精确可控、SDA三态切换不出现毛刺,就需要在架构层面做不少取舍。
我见过不少初学者把I2C主模式写成一个大一统的always块,里面塞满了计数器比较和条件判断,最后自己都理不清哪个周期该干什么。这种写法在低速下勉强能用,一旦要支持400kHz甚至1MHz的快速模式,时序就会各种飘。所以这篇文章的重点不是教你I2C协议是什么,而是帮你建立一套可复用的RTL设计框架。
2. 状态机架构选型:一段式、两段式还是三段式
2.1 三种写法在I2C主模式下的实际差异
关于状态机的一段式、两段式、三段式写法,网上讨论很多,但放到I2C主模式这个具体场景里,差异会变得非常具体。
一段式状态机把所有逻辑塞进一个时序always块:状态转移、输出信号、计数器更新全在一起。写起来快,代码短,但调试时你会发现波形里很难区分"当前状态是什么"和"输出为什么是这个值"。I2C主模式里SCL和SDA的输出逻辑跟状态强相关,一段式写法的可读性会急剧下降。
两段式状态机把状态转移和输出逻辑分开:一个时序块管状态寄存器的更新,一个组合块管输出。这是I2C主模式比较常用的写法,因为SCL和SDA的输出条件可以写得很清晰。但组合逻辑输出容易产生毛刺,尤其是SDA在三态切换的时候,毛刺可能导致从机误判START或STOP条件。
三段式状态机在两段式基础上多了一个输出寄存级:状态转移时序块、组合次态逻辑块、输出寄存块。输出经过一级寄存器打拍,毛刺被滤掉,时序更干净。代价是输出会延迟一个时钟周期,需要在状态设计时把这个延迟考虑进去。
我的建议是:I2C主模式用三段式。原因很直接——SDA的三态切换对毛刺极其敏感,组合逻辑输出的风险不值得冒。多一个周期的延迟完全可以在状态机设计时补偿回来。
2.2 状态划分的粒度怎么把握
状态机该分几个状态?分得太细,代码冗长;分得太粗,每个状态里塞太多逻辑,又回到一段式的老路。
一个实用的划分方式是按照I2C的基本操作单元来分:
| 状态 | 功能 | 说明 |
|---|---|---|
| IDLE | 空闲 | 等待启动信号,SCL和SDA都释放为高 |
| START | 产生起始条件 | SCL高时SDA拉低 |
| SEND_BYTE | 发送一个字节 | 8位数据逐位移出 |
| WAIT_ACK | 等待应答 | 释放SDA,采样从机ACK |
| RECV_BYTE | 接收一个字节 | 逐位采样SDA |
| SEND_ACK | 发送应答 | 主机拉低或释放SDA |
| STOP | 产生停止条件 | SCL高时SDA拉高 |
这个划分粒度刚好:每个状态做的事足够单一,状态之间的转移条件也很明确。如果再把SEND_BYTE拆成"准备数据"和"移位输出"两个状态,就有点过度设计了,因为移位本身可以用计数器在同一个状态里完成。
2.3 状态转移条件的写法技巧
状态转移条件写得好不好,直接决定了代码可维护性。一个常见的坏习惯是把转移条件写成一大串与或非表达式,比如:
// 不推荐的写法 if ((state == SEND_BYTE) && (bit_cnt == 3'd7) && (scl_phase == 1'b0) && (ack_en == 1'b1)) next_state = WAIT_ACK;这种写法的问题是,条件之间没有层次,改一个地方容易影响另一个地方。更好的方式是用中间信号把条件拆开:
wire byte_done = (bit_cnt == 3'd7) && (scl_phase == 1'b0); wire need_ack = ack_en; // 转移逻辑清晰 case (state) SEND_BYTE: if (byte_done) next_state = need_ack ? WAIT_ACK : STOP; ... endcase中间信号的好处是,仿真波形里能直接看到byte_done什么时候拉高,调试时不用去数计数器。
3. SCL时钟生成与相位控制
3.1 分频计数器的参数计算
I2C的SCL频率由分频计数器决定。假设系统时钟是50MHz,目标SCL频率是100kHz,那么分频系数是:
分频系数 = 系统时钟频率 / (2 × SCL频率) = 50,000,000 / (2 × 100,000) = 250为什么是2倍?因为SCL的一个完整周期包含高电平和低电平各一段时间,计数器需要分别控制这两段。所以计数器从0数到249,前半段SCL为高,后半段SCL为低。
如果要支持标准模式(100kHz)、快速模式(400kHz)和快速模式+(1MHz),可以用一个参数化的分频系数:
parameter CLK_FREQ = 50_000_000; parameter SCL_FREQ = 100_000; localparam DIV_CNT = CLK_FREQ / (2 * SCL_FREQ);这样换频率只需要改参数,不用动逻辑。
3.2 SCL相位与SDA采样的关系
I2C协议规定,SDA在SCL低电平期间变化,在SCL高电平期间保持稳定。所以采样SDA的时机应该在SCL高电平的中间位置,而不是边沿。
实际实现时,我通常把SCL的一个周期分成四个相位:
- 相位0:SCL低,SDA准备数据
- 相位1:SCL拉高,SDA保持
- 相位2:SCL高,采样SDA(如果是接收)
- 相位3:SCL拉低,准备下一个bit
这样每个bit的传输有明确的节奏,采样点落在SCL高电平的中间,避开边沿附近的抖动。
// 相位计数器 always @(posedge clk or negedge rst_n) begin if (!rst_n) phase_cnt <= 2'd0; else if (phase_cnt == 2'd3) phase_cnt <= 2'd0; else phase_cnt <= phase_cnt + 1'b1; end // SCL输出 assign scl_out = (phase_cnt == 2'd1) || (phase_cnt == 2'd2);这个四相位设计的好处是,采样点固定在相位2,发送数据在相位0,SCL的占空比也自然接近50%。
3.3 时钟拉伸的处理
从机有时候会拉低SCL来暂停传输,这叫时钟拉伸。主模式设计时必须考虑这种情况,否则从机还没准备好,主机就把数据发完了。
处理方式是在SCL应该拉高的时候,检测SCL实际电平。如果SCL被从机拉低,主机就停在当前相位等待:
wire scl_stretched = scl_oe && !scl_in; // 主机想拉高但实际为低 // 相位计数器在拉伸时暂停 always @(posedge clk or negedge rst_n) begin if (!rst_n) phase_cnt <= 2'd0; else if (scl_stretched) phase_cnt <= phase_cnt; // 保持 else if (phase_cnt == 2'd3) phase_cnt <= 2'd0; else phase_cnt <= phase_cnt + 1'b1; end这个细节很多参考设计里没有,但实际接一些传感器或EEPROM时,时钟拉伸是真实存在的。
4. 三态双向驱动的实现细节
4.1 为什么I2C的SDA必须用三态
I2C的SDA线是双向的,主机和从机都可能驱动它。协议规定SDA线上必须有上拉电阻,任何设备想输出低电平时把线拉低,想输出高电平时释放线(变成高阻态),由上拉电阻把线拉高。
这意味着RTL里不能用简单的assign sda = sda_out,而必须用三态控制:
assign sda = sda_oe ? sda_out : 1'bz;其中sda_oe是输出使能,sda_out是要输出的值。当sda_oe为0时,SDA引脚呈高阻态,从机可以驱动它。
4.2 输出使能与输出值的分离控制
初学者容易犯的一个错误是把输出使能和输出值绑在一起,比如"要输出1就释放,要输出0就拉低"。这在大多数情况下没问题,但在ACK阶段会出问题。
ACK阶段主机需要释放SDA,让从机来驱动。如果此时主机的输出使能没有正确关闭,就会和从机抢总线。所以输出使能和输出值必须独立控制:
// 发送数据时:oe=1,out=当前bit // 接收数据时:oe=0,释放SDA // 发送ACK时:oe=1,out=0(拉低表示ACK) // 发送NACK时:oe=0,释放SDA(由上拉电阻拉高)这个分离控制在状态机里体现为:每个状态明确指定sda_oe和sda_out的值,而不是根据某个条件临时决定。
4.3 三态切换的时序陷阱
三态切换最大的坑是切换时刻。如果SDA从输出模式切到高阻模式的时刻正好在SCL高电平期间,而切换前SDA是低电平,切换后上拉电阻需要时间把线拉高,这个上升沿可能被从机误判为START或STOP条件。
避免方法:只在SCL低电平期间切换SDA的输出使能。因为I2C协议规定START和STOP条件都发生在SCL高电平期间,只要SCL低的时候切换,就不会产生误判。
// 只在SCL低电平时更新sda_oe always @(posedge clk or negedge rst_n) begin if (!rst_n) sda_oe <= 1'b0; else if (phase_cnt == 2'd0) // SCL低相位 sda_oe <= next_sda_oe; end这个约束在状态机设计时就要考虑进去,不能等到代码写完再补。
4.4 输入同步与去抖
SDA和SCL作为输入信号,从引脚进来是异步的,直接用在状态机里会有亚稳态风险。标准做法是打两拍同步:
reg sda_sync1, sda_sync2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sda_sync1 <= 1'b1; sda_sync2 <= 1'b1; end else begin sda_sync1 <= sda_in; sda_sync2 <= sda_sync1; end end同步后的信号再用于采样和判断。注意同步会引入两个时钟周期的延迟,在采样SDA时要考虑这个延迟,确保采样点仍然落在SCL高电平的中间位置。
5. 完整状态机的Verilog实现
5.1 状态编码与参数定义
// 状态编码用独热码还是二进制码? // I2C主模式状态数不多(7个左右),二进制码足够,综合面积更小 localparam IDLE = 3'd0; localparam START = 3'd1; localparam SEND_BYTE = 3'd2; localparam WAIT_ACK = 3'd3; localparam RECV_BYTE = 3'd4; localparam SEND_ACK = 3'd5; localparam STOP = 3'd6;独热码在状态多的时候有优势,但I2C主模式状态少,二进制码更省触发器,译码逻辑也不复杂。
5.2 三段式状态机的骨架
// 第一段:状态寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) cur_state <= IDLE; else cur_state <= next_state; end // 第二段:次态组合逻辑 always @(*) begin next_state = cur_state; case (cur_state) IDLE: if (start_en) next_state = START; START: if (phase_done) next_state = SEND_BYTE; SEND_BYTE: if (byte_done) next_state = WAIT_ACK; WAIT_ACK: if (ack_done) next_state = byte_cnt_done ? STOP : SEND_BYTE; RECV_BYTE: if (byte_done) next_state = SEND_ACK; SEND_ACK: if (ack_done) next_state = byte_cnt_done ? STOP : RECV_BYTE; STOP: if (phase_done) next_state = IDLE; default: next_state = IDLE; endcase end // 第三段:输出寄存 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scl_oe <= 1'b0; sda_oe <= 1'b0; sda_out <= 1'b1; end else begin // 根据next_state或cur_state设置输出 // 注意:用cur_state还是next_state取决于是否需要提前一个周期准备 end end5.3 位计数器与字节计数器的配合
I2C传输的基本单位是字节,每个字节8位,后面跟一个ACK位。所以需要两个计数器:
- 位计数器:0到7,记录当前字节移到了第几位
- 字节计数器:记录已经传输了多少个字节
位计数器在每个SCL周期递增,数到7时归零并产生byte_done信号。字节计数器在byte_done时递增,用于判断传输是否结束。
always @(posedge clk or negedge rst_n) begin if (!rst_n) bit_cnt <= 3'd0; else if (cur_state == SEND_BYTE || cur_state == RECV_BYTE) begin if (phase_cnt == 2'd3) begin if (bit_cnt == 3'd7) bit_cnt <= 3'd0; else bit_cnt <= bit_cnt + 1'b1; end end else bit_cnt <= 3'd0; end注意位计数器只在发送或接收状态递增,其他状态保持或清零,避免误计数。
5.4 START和STOP条件的精确生成
START条件是SCL高时SDA从高变低,STOP条件是SCL高时SDA从低变高。这两个条件的生成需要精确控制SDA的变化时刻。
// START状态:先确保SCL和SDA都为高,然后拉低SDA START: begin // 相位0:SCL低,SDA高(准备) // 相位1:SCL高,SDA高 // 相位2:SCL高,SDA拉低(START条件) // 相位3:SCL低,SDA保持低 end实际实现时,用相位计数器控制SDA的变化时刻,确保START条件在SCL高电平期间产生。
6. 仿真验证与上板调试的实战经验
6.1 仿真时最容易漏掉的场景
写完RTL跑仿真,很多人只测了正常读写流程,结果上板遇到各种边界情况。以下场景建议在仿真阶段就覆盖:
- 从机NACK:从机不响应时,主机是否正确产生STOP并回到IDLE
- 时钟拉伸:从机拉低SCL时,主机是否正确等待
- 连续读写:多次传输之间START和STOP是否正确
- 总线仲裁丢失:多主机场景下仲裁失败的处理(如果支持多主机)
6.2 逻辑分析仪抓波形的关注点
上板调试时,逻辑分析仪是必备工具。抓I2C波形时重点看几个地方:
| 观察点 | 正常表现 | 异常表现与可能原因 |
|---|---|---|
| START条件 | SCL高时SDA下降沿干净 | SDA下降沿有毛刺,可能是三态切换时机不对 |
| SCL频率 | 与设计值一致 | 频率偏高或偏低,检查分频系数 |
| ACK位 | 第9个SCL周期SDA被从机拉低 | SDA保持高,从机未响应,检查从机地址 |
| STOP条件 | SCL高时SDA上升沿干净 | 上升沿缓慢,上拉电阻过大 |
6.3 上板常见问题排查
问题一:从机完全不响应。先确认从机地址是否正确,I2C地址有7位和10位两种格式,7位地址在传输时要左移一位并在最低位加读写位。然后检查上拉电阻是否焊接,SDA和SCL在上电后是否都为高电平。
问题二:能收到ACK但数据错误。重点检查采样点位置。如果采样点太靠近SCL边沿,可能采到不稳定的值。把采样点移到SCL高电平的中间位置,通常能解决。
问题三:高速模式下不稳定。400kHz以上时,信号上升沿时间变得关键。检查上拉电阻值,标准模式常用4.7kΩ,快速模式可能需要降到2.2kΩ甚至1kΩ。同时确认PCB走线不要太长,寄生电容会拖慢上升沿。
6.4 参数化设计的实际价值
把分频系数、地址位宽、数据位宽做成参数,后期换平台或换从机时只需要改参数,不用重写逻辑。我自己的习惯是至少参数化这几个:
parameter CLK_FREQ = 50_000_000; parameter SCL_FREQ = 100_000; parameter ADDR_WIDTH = 7; parameter DATA_WIDTH = 8;这样同一个模块可以复用到不同项目里,省去很多重复劳动。
7. 从主模式设计延伸出的几个思考
写完主模式控制器之后,有几个方向可以继续深入。一是多主机仲裁,当总线上有多个主机同时发起传输时,需要检测仲裁丢失并正确处理。二是从模式设计,从模式的状态机结构和主模式差异很大,尤其是时钟同步和地址匹配部分。三是FIFO缓冲,在实际系统中,I2C控制器通常需要配合FIFO来缓冲数据,避免CPU频繁介入。
另外,如果你用的是FPGA,三态双向IO需要用到IOBUF原语,不同厂商的写法略有差异。Xilinx的IOBUF和Altera的alt_iobuf在端口命名上不同,但功能一致。综合时注意三态信号会被映射到IOBUF上,不要被综合工具优化掉。
我在实际项目里踩过最深的坑是三态使能的默认值。复位后如果sda_oe默认为1且sda_out默认为0,SDA会被一直拉低,总线直接死锁。所以复位时一定要确保sda_oe为0,让SDA处于释放状态。这个细节在仿真里如果没接上拉电阻模型,可能看不出来,但上板就是致命问题。