3、I2C从机模式驱动开发:从机模式初始化、从机地址配置、从机接收/发送中断处理、从机状态机设计
好,咱们进入I2C从机模式。主机模式大家可能更熟悉——发起通信、控制时钟、决定什么时候收什么时候发。但从机模式呢?说白了,从机是被动的。它得时刻准备着,等主机来叫它。这个“等”的过程,就是状态机的核心。
我记得刚做第一个I2C从机项目时,以为只要配好地址、开个中断就完事了。结果呢?从机经常“死机”,主机发了几次数据它都没反应。后来才发现,状态机设计出了问题——从机在某个状态卡住了,没有超时保护。嗯,这里要注意,从机模式比主机模式更容易出“软死”的问题。
3.1 从机模式初始化
初始化从机,比主机多一个步骤:你要告诉硬件,我是从机,不是主机。很多MCU的I2C外设默认是主机模式,得显式切换。
我个人习惯的初始化流程是这样的:
- 使能时钟——I2C外设时钟和GPIO时钟都得开。
- 配置GPIO——SDA和SCL设为开漏输出,带上拉(内部或外部)。
- 复位I2C外设——确保寄存器处于已知状态。
- 设置从机地址——可以是7位或10位,后面细说。
- 使能中断——从机全靠中断驱动,轮询不现实。
- 使能I2C外设——开始监听总线。
关键点:初始化顺序不能乱。尤其是GPIO配置必须在I2C使能之前完成,否则总线状态可能不对,导致第一个ACK就失败。
来看一段实际的初始化代码(以STM32为例,但思路通用):
void i2c_slave_init(uint8_t own_address)
{
// 1. 使能时钟
RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN;
// 2. 配置GPIO:PB6=SCL, PB7=SDA,开漏输出
GPIOB->MODER &= ~(GPIO_MODER_MODER6 | GPIO_MODER_MODER7);
GPIOB->MODER |= (GPIO_MODER_MODER6_1 | GPIO_MODER_MODER7_1); // 复用功能
GPIOB->OTYPER |= (GPIO_OTYPER_OT6 | GPIO_OTYPER_OT7); // 开漏
GPIOB->PUPDR |= (GPIO_PUPDR_PUPD6_0 | GPIO_PUPDR_PUPD7_0); // 上拉
// 3. 复位I2C
I2C1->CR1 |= I2C_CR1_SWRST;
I2C1->CR1 &= ~I2C_CR1_SWRST;
// 4. 配置从机地址(7位模式)
I2C1->OAR1 = (own_address << 1) | I2C_OAR1_OA1EN;
// 5. 使能中断:接收、发送、错误、停止
I2C1->CR2 |= I2C_CR2_ITBUFEN | I2C_CR2_ITEVTEN | I2C_CR2_ITERREN;
// 6. 使能I2C外设
I2C1->CR1 |= I2C_CR1_PE;
}
小技巧:复位那一步很多人会省略。但我建议别省——尤其是在调试阶段,外设可能处于未知状态,复位一下更安心。
3.2 从机地址配置
地址配置看似简单,其实有几个坑。7位地址和10位地址的配置方式不同,而且有些MCU支持双地址(响应两个不同的从机地址)。
| 特性 | 7位地址 | 10位地址 |
|---|---|---|
| 地址范围 | 0x00 ~ 0x7F | 0x000 ~ 0x3FF |
| 保留地址 | 0x00(广播)、0x01~0x07 | 无特殊保留 |
| 配置寄存器 | OAR1的低7位 | OAR1 + OAR2配合 |
| 中断处理差异 | 无 | 需要额外处理头部字节 |
我在项目中遇到过一个问题:给从机配了地址0x55,结果它老是响应0x2A。查了半天,发现是地址左移了一位没处理。很多MCU的地址寄存器要求左移1位存放,因为最低位是读写标志位。你想想看,如果直接把0x55写进去,实际地址就变成了0x2A。
避坑指南:我曾经因为地址配置错误,导致两个从机响应同一个地址,总线冲突了整整两天才找到原因。配置地址后,一定要用逻辑分析仪抓一下,确认从机确实在正确的地址上ACK。
另外,如果你需要支持多个从机地址(比如一个用于常规通信,一个用于配置),可以开启双地址模式。但要注意,双地址会增加中断处理的复杂度,状态机里得区分是哪个地址被匹配了。
3.3 从机接收/发送中断处理
从机的中断处理,是驱动开发的核心。主机发来数据,从机得知道什么时候收、什么时候发。这些事件都通过中断通知CPU。
常见的从机中断事件包括:
- 地址匹配(ADDR)——主机发出了我的地址,我要准备响应了。
- 接收缓冲区非空(RXNE)——主机给我发了一个字节,快来读。
- 发送缓冲区空(TXE)——主机要读数据,快把下一个字节填进去。
- 停止条件(STOP)——主机结束了本次通信。
- 错误(ERR)——总线错误、仲裁丢失、溢出等。
我个人习惯把中断服务函数写成这样:
void I2C1_EV_IRQHandler(void)
{
uint32_t status = I2C1->SR1;
// 地址匹配事件
if (status & I2C_SR1_ADDR) {
// 读取SR1和SR2清除ADDR标志
(void)I2C1->SR1;
(void)I2C1->SR2;
// 判断主机是读还是写
if (I2C1->SR2 & I2C_SR2_TRA) {
// 主机读模式:从机要发送数据
slave_state = SLAVE_STATE_TX;
tx_idx = 0;
} else {
// 主机写模式:从机要接收数据
slave_state = SLAVE_STATE_RX;
rx_idx = 0;
}
return;
}
// 接收数据事件
if (status & I2C_SR1_RXNE) {
rx_buffer[rx_idx++] = I2C1->DR;
return;
}
// 发送数据事件
if (status & I2C_SR1_TXE) {
I2C1->DR = tx_buffer[tx_idx++];
return;
}
// 停止条件事件
if (status & I2C_SR1_STOPF) {
// 清除STOPF标志
I2C1->CR1 |= I2C_CR1_PE;
slave_state = SLAVE_STATE_IDLE;
// 通知应用层:一帧数据接收完成
frame_complete_flag = 1;
return;
}
}
注意:ADDR标志的清除方式很特殊——必须读SR1再读SR2。顺序不能反,也不能只读一个。很多新手在这里卡住,从机死活不响应后续中断。
3.4 从机状态机设计
状态机是从机驱动的灵魂。没有状态机,中断处理就是一盘散沙。你想想看,主机可能随时发起读或写,从机得知道自己当前在干什么、下一步该干什么。
我常用的从机状态机包含以下几个状态:
| 状态 | 含义 | 进入条件 | 退出条件 |
|---|---|---|---|
| IDLE | 空闲,等待地址匹配 | 上电/停止条件 | 地址匹配中断 |
| RX | 接收模式,等待主机写数据 | 地址匹配 + 主机写 | 停止条件/重启条件 |
| TX | 发送模式,等待主机读数据 | 地址匹配 + 主机读 | 停止条件/主机NACK |
| ERROR | 错误状态,等待恢复 | 总线错误/溢出 | 超时/复位 |
下面这张图展示了从机状态机的完整流转:
状态机的实现,我建议用switch-case,别用if-else嵌套。代码可读性差不说,还容易漏状态。
typedef enum {
SLAVE_STATE_IDLE,
SLAVE_STATE_RX,
SLAVE_STATE_TX,
SLAVE_STATE_ERROR
} slave_state_t;
void slave_state_machine(void)
{
switch (slave_state) {
case SLAVE_STATE_IDLE:
// 啥也不干,等中断
break;
case SLAVE_STATE_RX:
// 数据已经在中断里收进了rx_buffer
// 这里可以检查是否收完一帧
if (frame_complete_flag) {
process_received_frame();
frame_complete_flag = 0;
slave_state = SLAVE_STATE_IDLE;
}
break;
case SLAVE_STATE_TX:
// 数据已经在中断里发出去了
// 检查是否发完
if (tx_idx >= tx_len) {
slave_state = SLAVE_STATE_IDLE;
}
break;
case SLAVE_STATE_ERROR:
// 错误恢复
i2c_slave_reset();
slave_state = SLAVE_STATE_IDLE;
break;
default:
slave_state = SLAVE_STATE_IDLE;
break;
}
}
设计建议:状态机里一定要加超时。我曾经遇到过主机发了地址后突然掉电,从机一直卡在RX状态,再也响应不了新通信。加个超时定时器,比如10ms没动静就强制回IDLE,问题就解决了。
3.5 从机驱动的几个实战要点
最后,分享几个我在项目中积累的经验:
- 时钟延展(Clock Stretching):有些从机处理速度慢,需要拉低SCL让主机等。如果你的MCU支持硬件时钟延展,记得使能;如果不支持,得确保中断响应够快,别让主机等超时了。
- NACK处理:主机读数据时,如果读到最后一个字节后发了NACK,从机得释放SDA线。有些MCU会自动处理,有些需要手动配置。
- 多主机环境:如果总线上有多个主机,从机得支持仲裁。说白了,就是检测到总线被其他设备占用时,主动释放总线。
- 调试手段:我习惯在状态机里加一个调试引脚,每进入一个状态就翻转一次。用示波器一看,就知道状态机跑没跑对。
避坑指南:我曾经在一个项目里,从机状态机没加超时,结果主机异常复位后,从机还卡在TX状态。主机重新发地址时,从机还在往外吐数据,总线直接乱掉。从那以后,我每个状态都加了超时保护——哪怕IDLE状态也加一个看门狗喂狗操作。