3. I2C从机驱动开发:从机状态机设计、中断处理、寄存器读写实现、多主机仲裁处理
各位,今天我们来啃一块硬骨头——I2C从机驱动。说实话,从机比主机难写。主机掌握主动权,什么时候发、什么时候收,都是自己说了算。从机呢?你得时刻准备着,随时响应主机的召唤。我刚开始做I2C从机时,就被状态机折腾得够呛。今天我把这些经验掰开揉碎了讲给你听。
3.1 从机状态机设计——核心中的核心
I2C从机的灵魂是什么?状态机。没有状态机,你的从机就是一盘散沙。为什么这么说?你想想看,主机随时可能发起Start、发送地址、读写数据、发送Stop,从机必须在每个时刻都知道自己该干什么。
一个标准的I2C从机状态机,至少包含以下几个状态:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| IDLE | 空闲状态,等待Start | 总线空闲或收到Stop |
| ADDR_RCVD | 已收到地址,等待R/W位 | 收到匹配的从机地址 |
| DATA_RX | 接收数据中(主机写从机) | R/W=0,且收到数据字节 |
| DATA_TX | 发送数据中(主机读从机) | R/W=1,且主机继续提供SCL |
| STOP_RCVD | 收到Stop条件 | 总线出现Stop信号 |
我个人习惯用枚举类型来定义这些状态,清晰明了:
typedef enum {
I2C_SLAVE_STATE_IDLE,
I2C_SLAVE_STATE_ADDR_RCVD,
I2C_SLAVE_STATE_DATA_RX,
I2C_SLAVE_STATE_DATA_TX,
I2C_SLAVE_STATE_STOP_RCVD
} i2c_slave_state_t;
状态切换的逻辑,说白了就是跟着SCL和SDA的边沿走。每次SCL上升沿采样数据,下降沿准备下一个bit。这里有个坑——状态切换必须在SCL低电平期间完成,否则时序就乱了。
关键设计原则:状态机每个状态的入口和出口都要有明确的动作。比如进入ADDR_RCVD时,要清除地址匹配标志;进入DATA_TX时,要准备好第一个要发送的字节。
3.2 中断处理——别让CPU空转
轮询方式写从机?我劝你别这么干。I2C的时序要求很严格,轮询很容易丢数据。中断才是正道。
I2C从机的中断事件通常包括:
- 地址匹配中断:主机发送的地址与从机地址一致
- 数据接收中断:收到一个完整字节
- 数据发送中断:上一个字节已发送完毕,可以准备下一个
- Stop条件中断:总线释放
- 仲裁丢失中断:多主机冲突
我在项目中遇到过一个问题:中断服务函数里做了太多事情,导致下一个SCL时钟到来时还没处理完。结果呢?数据错位,整个通信乱套。后来我学乖了——中断里只做最轻量的事情,比如把数据塞进FIFO,设置标志位,真正的处理放到主循环或任务里。
我的经验:中断服务函数里不要调用printf、不要做复杂计算、不要操作慢速外设。就做三件事:读状态寄存器、读写数据寄存器、清中断标志。其他事情,出去再说。
来看一个典型的中断处理框架:
void I2C1_IRQHandler(void) {
uint32_t status = I2C->SR1; // 读状态寄存器
if (status & I2C_SR1_ADDR) { // 地址匹配
i2c_slave_state = I2C_SLAVE_STATE_ADDR_RCVD;
// 清除ADDR标志(读SR1再读SR2)
(void)I2C->SR2;
}
if (status & I2C_SR1_RXNE) { // 收到数据
rx_buffer[rx_index++] = I2C->DR;
i2c_slave_state = I2C_SLAVE_STATE_DATA_RX;
}
if (status & I2C_SR1_TXE) { // 可以发送
I2C->DR = tx_buffer[tx_index++];
i2c_slave_state = I2C_SLAVE_STATE_DATA_TX;
}
if (status & I2C_SR1_STOPF) { // Stop条件
i2c_slave_state = I2C_SLAVE_STATE_STOP_RCVD;
// 清除STOPF标志
I2C->CR1 |= I2C_CR1_ACK;
}
}
3.3 寄存器读写实现——从机的“内存”怎么暴露
I2C从机通常要暴露一组寄存器给主机访问。这组寄存器就是主机和从机之间的“共享内存”。设计得好,通信效率高;设计得烂,调试起来想砸板子。
寄存器读写的典型流程是这样的:
- 主机先写一个字节,指定要访问的寄存器地址(这叫“指针设置”)
- 主机再发起读操作,从机就把该寄存器的值发回去
- 或者主机连续写多个字节,从机自动递增寄存器地址
我曾经见过一个设计:寄存器地址和寄存器值混在一起,没有明确的协议分层。结果主机读回来的数据总是对不上。后来我总结了一套寄存器访问的“黄金法则”:
寄存器访问设计要点:
- 寄存器地址从0开始连续编址,方便地址递增
- 每个寄存器固定宽度(通常是8位或16位)
- 读操作不改变寄存器值(除非是“读清除”类型)
- 写操作要检查地址合法性,非法地址直接NACK
代码实现上,我习惯用一个数组来模拟寄存器空间:
#define REG_MAP_SIZE 256
static uint8_t reg_map[REG_MAP_SIZE];
static uint8_t reg_addr_ptr; // 当前指针位置
// 主机写从机时,第一个字节是寄存器地址
void i2c_slave_handle_write(uint8_t data) {
if (i2c_slave_state == I2C_SLAVE_STATE_ADDR_RCVD) {
// 第一个数据字节 = 寄存器地址
reg_addr_ptr = data;
} else {
// 后续数据字节 = 写入寄存器
if (reg_addr_ptr < REG_MAP_SIZE) {
reg_map[reg_addr_ptr++] = data;
} else {
// 地址越界,发送NACK
i2c_slave_send_nack();
}
}
}
// 主机读从机时,从机返回当前指针指向的寄存器值
uint8_t i2c_slave_handle_read(void) {
uint8_t data = 0;
if (reg_addr_ptr < REG_MAP_SIZE) {
data = reg_map[reg_addr_ptr++];
}
return data;
}
注意:寄存器地址指针的递增要谨慎。如果主机只读一个字节,指针要不要递增?这取决于你的协议设计。我建议:读操作也递增,这样主机可以连续读取多个寄存器。但一定要在数据手册里写清楚。
3.4 多主机仲裁处理——别让两个主机打起来
多主机仲裁是I2C协议里最精妙的设计之一。说白了,就是多个主机同时抢总线时,谁先发现SDA被拉低,谁就主动退出。这个机制完全靠硬件实现,但作为从机驱动开发者,你也要理解其中的门道。
为什么?因为从机也可能参与仲裁——当从机作为发送方时,它实际上就是总线上的“临时主机”。如果另一个主机也在发送数据,冲突就发生了。
我画了一张图,帮你理清多主机仲裁的流程:
从机驱动里怎么处理仲裁?关键在于仲裁丢失中断。当硬件检测到SDA电平与自己发送的不一致时,会触发这个中断。你的处理逻辑应该是:
void i2c_slave_handle_arbitration_lost(void) {
// 1. 清除仲裁丢失标志
I2C->SR1 &= ~I2C_SR1_ARLO;
// 2. 复位状态机到IDLE
i2c_slave_state = I2C_SLAVE_STATE_IDLE;
// 3. 释放总线(硬件自动完成,但软件要确保不再驱动SDA)
I2C->CR1 &= ~I2C_CR1_START;
// 4. 重新使能从机地址识别
I2C->CR1 |= I2C_CR1_ACK;
// 5. 清空FIFO,丢弃未完成的数据
rx_index = 0;
tx_index = 0;
}
避坑指南:我曾经在仲裁丢失后忘记重新使能ACK,结果从机再也不响应地址了。调试了整整一个下午才发现。记住:仲裁丢失后,从机要像刚上电一样重新初始化接收逻辑。
3.5 实战经验总结——从机开发的“三板斧”
说了这么多,我最后给你总结三条从机开发的核心原则:
- 状态机要健壮:每个状态都要考虑异常情况,比如收到非法地址、数据长度超限、意外Stop等。不要假设主机总是按规矩来。
- 中断要轻量:中断里只做最必要的事,把数据搬运和协议解析放到主循环。我见过太多人把中断写成“重量级函数”,结果系统响应越来越慢。
- 寄存器访问要安全:地址越界检查、写保护、读清除标志位,这些机制一个都不能少。你的从机可能被各种奇奇怪怪的主机访问,保护自己很重要。
嗯,从机驱动开发就是这样。看起来简单,但每个细节都可能让你栽跟头。把这些基础打牢了,后面学SPI和UART的从机模式就会轻松很多。