4. I2C从机状态机设计:从机空闲态、寻址态、接收态、发送态的状态转换模型
状态机这东西,说白了就是让硬件知道「我现在该干嘛」。I2C从机没有状态机,就像一个人站在十字路口却不知道往哪走——总线上的信号变化它全看见了,但就是不知道该响应谁。
我个人习惯把I2C从机状态机分成四个核心状态:空闲态、寻址态、接收态和发送态。这四种状态构成了从机工作的全部生命周期。你想想看,从机在总线上无非就是四种情况:没人理它、被叫到名字、听主机说话、向主机说话。
4.1 状态定义与转换总览
先看一张整体状态转换图,后面我们再逐个拆解。
这张图我画了好几个版本才定下来。你看,从空闲态出发,只有START条件加上地址匹配才能进入寻址态。寻址态是个分岔路口——根据R/W位决定是去接收态还是发送态。数据传完了,STOP条件或者重复START条件会把状态机拉回空闲或重新寻址。
4.2 空闲态(IDLE)
空闲态是从机的「默认姿势」。SCL和SDA都是高电平,从机啥也不干,就盯着总线看。
嗯,这里要注意:空闲态不是真的「闲着」。从机硬件一直在监测START条件——SCL高电平时SDA从高到低的跳变。这个检测是纯硬件的,不占用CPU。
| 属性 | 说明 |
|---|---|
| 总线状态 | SCL高、SDA高 |
| 从机行为 | 监测START条件,不参与任何传输 |
| 退出条件 | 检测到START条件 + 地址匹配 |
| 异常恢复 | 总线超时或复位信号强制回到空闲态 |
4.3 寻址态(ADDR)
寻址态是从机最忙的阶段。主机发送了START条件后,紧跟着7位地址和1位R/W位。从机要在这9个SCL周期内完成地址比较,还要决定下一步方向。
为什么说它关键?因为地址匹配的时序窗口非常窄。我记得第一次调I2C从机时,地址比较逻辑写在了中断里,结果中断响应延迟稍微大了点,地址就匹配失败了。后来我把地址比较改成了硬件移位寄存器的实时比较,问题才解决。
寻址态的核心工作流程:
- 接收地址位:每个SCL时钟移入一位,7位地址全部接收后与本地地址寄存器比较
- 判断匹配:如果匹配,在第9个SCL周期拉低SDA发送ACK;不匹配则保持高电平发NACK
- 解析R/W位:第8位是R/W,0表示主机写(从机接收),1表示主机读(从机发送)
- 状态跳转:根据R/W位进入接收态或发送态
4.4 接收态(RX)
进入接收态后,从机开始接收主机发来的数据字节。每个字节8位数据,从机在每第9个SCL周期发送ACK/NACK。
接收态里有个容易被忽略的细节:数据接收的节奏控制。主机是按标准速率发送数据的,但从机内部可能正在处理上一个字节。如果从机没准备好接收下一个字节,怎么办?
标准I2C协议提供了时钟拉伸(Clock Stretching)机制——从机可以在第9个SCL之后拉低SCL,告诉主机「慢点,我还没消化完」。这个功能在RTOS环境下特别有用,因为任务切换和缓冲区处理都需要时间。
/* 接收态状态机伪代码 */
void i2c_slave_rx_state(void) {
uint8_t byte_count = 0;
while (state == I2C_STATE_RX) {
/* 等待SCL低电平,准备接收数据 */
wait_for_scl_low();
/* 接收8位数据 */
rx_byte = shift_in_8_bits();
/* 处理接收到的字节 */
if (rx_buffer_full()) {
/* 缓冲区满了,发NACK并准备停止 */
send_nack();
state = I2C_STATE_IDLE;
break;
}
/* 发送ACK */
send_ack();
/* 可选:时钟拉伸,给RTOS任务切换时间 */
if (need_processing_time()) {
stretch_clock(); /* 拉低SCL */
/* 此时可以触发任务通知 */
notify_rx_task(rx_byte);
release_clock(); /* 释放SCL */
}
byte_count++;
}
}
4.5 发送态(TX)
发送态是从机向主机发送数据的阶段。主机发完地址和R/W=1后,从机就要把数据放到SDA线上。
发送态和接收态最大的区别在于:数据方向反了。接收态是从机被动采样SDA,发送态是从机主动驱动SDA。这个切换在硬件上要特别注意——SDA线的驱动方向必须在正确的时间点切换,否则会造成总线冲突。
我记得第一次做从机发送时,SDA方向切换的时机没算准,结果在地址阶段结束后SDA还被从机驱动着,直接和主机的SCL信号打架。示波器上一看,波形乱七八糟的。
发送态的工作流程:
- 准备数据:从发送缓冲区取出一个字节,或者从寄存器读取
- 逐位发送:在每个SCL低电平期间设置SDA数据,高电平期间被主机采样
- 等待ACK:第9个SCL周期释放SDA,检测主机是否发送ACK
- 判断继续:主机ACK则继续发送下一个字节,NACK则停止发送回到空闲态
| 步骤 | SCL周期 | 从机动作 | 主机动作 |
|---|---|---|---|
| 1 | 1-8 | 逐位发送数据 | 采样SDA |
| 2 | 9 | 释放SDA,检测ACK | 拉低SDA表示ACK,释放表示NACK |
| 3 | (继续) | ACK则发下一字节,NACK则停止 | 决定是否继续读取 |
4.6 状态转换的边界条件
状态机设计得好不好,关键看边界条件处理得怎么样。我总结了几种常见的边界情况:
- 重复START:主机在数据阶段结束后不发STOP,而是再发一个START。此时从机要从接收态或发送态回到寻址态,重新匹配地址
- STOP条件提前:主机在任意时刻发送STOP,从机必须立即回到空闲态,不能卡在当前状态
- NACK后的行为:接收态中从机发NACK后,主机可能发STOP也可能发重复START,从机要做好两手准备
- 总线超时:SCL被拉低超过规定时间(通常25ms),从机应复位状态机释放总线
4.7 状态机实现的代码骨架
最后给一个实际项目中用过的状态机骨架。这个骨架配合RTOS使用,每个状态对应一个明确的处理函数:
typedef enum {
I2C_STATE_IDLE,
I2C_STATE_ADDR,
I2C_STATE_RX,
I2C_STATE_TX,
I2C_STATE_ERROR
} i2c_slave_state_t;
void i2c_slave_state_machine(i2c_slave_state_t current_state, uint8_t bus_event) {
switch (current_state) {
case I2C_STATE_IDLE:
if (bus_event == I2C_EVENT_START) {
/* 进入寻址态,开始地址匹配 */
set_state(I2C_STATE_ADDR);
enable_address_detection();
}
break;
case I2C_STATE_ADDR:
if (bus_event == I2C_EVENT_ADDR_MATCH) {
/* 地址匹配成功,根据R/W跳转 */
if (get_rw_bit() == 0) {
set_state(I2C_STATE_RX);
} else {
set_state(I2C_STATE_TX);
load_first_tx_byte();
}
} else if (bus_event == I2C_EVENT_STOP) {
/* 地址没匹配完就收到STOP */
set_state(I2C_STATE_IDLE);
}
break;
case I2C_STATE_RX:
if (bus_event == I2C_EVENT_BYTE_RECEIVED) {
handle_rx_byte();
} else if (bus_event == I2C_EVENT_STOP ||
bus_event == I2C_EVENT_REPEATED_START) {
set_state(I2C_STATE_IDLE);
}
break;
case I2C_STATE_TX:
if (bus_event == I2C_EVENT_BYTE_SENT) {
handle_tx_ack();
} else if (bus_event == I2C_EVENT_NACK) {
/* 主机不再读取,准备停止 */
set_state(I2C_STATE_IDLE);
} else if (bus_event == I2C_EVENT_STOP) {
set_state(I2C_STATE_IDLE);
}
break;
default:
set_state(I2C_STATE_IDLE);
break;
}
}
这个骨架看起来简单,但实际项目中我会在每个状态入口和出口加调试引脚翻转,用逻辑分析仪看状态切换是否和预期一致。调试状态机,说白了就是「看波形、对状态、找偏差」三个步骤循环。