4、I2C主机模式驱动开发:主机模式初始化、主机发送/接收流程、超时处理机制、多主机通信仲裁

好,我们进入第四章。I2C 主机模式,说白了就是由你掌控时钟线 SCL,主动发起通信。很多初学者觉得 I2C 简单,不就是两根线嘛。但实际做产品时,主机模式的稳定性直接决定了整个系统的可靠性。我在一个工业传感器项目中,就曾因为主机初始化漏配了一个滤波参数,导致批量产品在强干扰环境下频繁丢数据。嗯,从那以后我对主机模式的每个细节都不敢马虎。

4.1 主机模式初始化:别漏了这几步

初始化主机模式,不只是把 GPIO 掰成开漏模式那么简单。你需要配置的,是 I2C 外设的核心时序参数。我个人习惯把初始化拆成四个步骤,缺一不可:

  1. 时钟使能与 GPIO 复用:先打开 I2C 外设时钟,再把对应引脚设为开漏输出、复用功能。
  2. 软件复位:很多芯片的 I2C 外设上电后处于未知状态。我建议你在配置寄存器前,先往 I2C_CR1 的 SWRST 位写 1,保持至少 4 个时钟周期再清 0。这一步能清掉所有挂起的错误标志。
  3. 配置通信速率:标准模式 100kHz 或快速模式 400kHz。你需要根据 APB 总线时钟计算 CCR 寄存器里的分频系数。举个例子,如果 APB 时钟是 36MHz,要得到 400kHz,CCR 的值就是 36MHz / (2 * 400kHz) = 45。
  4. 使能外设:最后把 I2C_CR1 的 PE 位置 1。注意,使能之前不要操作任何数据寄存器,否则可能触发意外中断。

核心要点:初始化顺序不能乱。先复位再配速率,最后使能。我曾经见过有人把使能放在复位之前,结果外设一直报总线错误,查了两天才找到原因。

下面是一个典型的初始化代码片段,以 STM32 标准库风格为例:

void I2C_Master_Init(void)
{
    // 1. 使能时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);

    // 2. GPIO 配置:开漏输出,复用功能
    GPIO_InitTypeDef gpio;
    gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;  // SCL, SDA
    gpio.GPIO_Mode = GPIO_Mode_AF_OD;
    gpio.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &gpio);

    // 3. 软件复位
    I2C_SoftwareResetCmd(I2C1, ENABLE);
    delay_us(10);
    I2C_SoftwareResetCmd(I2C1, DISABLE);

    // 4. 配置速率:400kHz
    I2C_InitTypeDef i2c;
    i2c.I2C_ClockSpeed = 400000;
    i2c.I2C_Mode = I2C_Mode_I2C;
    i2c.I2C_DutyCycle = I2C_DutyCycle_2;
    i2c.I2C_Ack = I2C_Ack_Enable;
    i2c.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
    I2C_Init(I2C1, &i2c);

    // 5. 使能外设
    I2C_Cmd(I2C1, ENABLE);
}

小技巧:如果你用的是快速模式(400kHz),记得把 CCR 寄存器里的 DUTY 位置 1,让高低电平保持 1:2 的比例。标准模式不需要这个设置。

4.2 主机发送与接收流程:状态机思维

I2C 主机发送和接收,本质上是一个有限状态机。你写代码时,脑子里要时刻清楚当前处于哪个状态。我习惯把流程画成一张图,对着图写代码,不容易漏掉某个事件。

先看发送流程。主机发送一字节数据,标准步骤是:

  1. 产生起始条件(START)。
  2. 发送从机地址 + 写位(0)。
  3. 等待 ADDR 标志置位,然后清除它。
  4. 发送要写的数据字节到 DR 寄存器。
  5. 等待 TXE 标志置位,表示数据已移出。
  6. 如果需要继续发送,重复步骤 4-5。
  7. 发送完成后,产生停止条件(STOP)。

接收流程类似,只是把写位换成读位(1),并且主机需要在接收完每个字节后主动发送 ACK 或 NACK:

  1. 产生起始条件。
  2. 发送从机地址 + 读位(1)。
  3. 等待 ADDR 标志置位,清除它。
  4. 从 DR 寄存器读取接收到的数据。
  5. 如果还要继续接收,在读取后自动发送 ACK。
  6. 如果这是最后一个字节,在读取前设置 NACK 位,然后产生停止条件。

注意:清除 ADDR 标志的时机非常关键。在 STM32 上,读 SR1 再读 SR2 才能清掉 ADDR。如果你只读了 SR1,ADDR 不会清掉,后续的发送或接收流程会卡死。我踩过这个坑,排查时用逻辑分析仪看到 SCL 一直拉低,就是 ADDR 没清导致的。

下面是一个主机发送多字节的代码示例:

uint8_t I2C_Master_Transmit(uint8_t slaveAddr, uint8_t *data, uint16_t len)
{
    // 产生起始条件
    I2C_GenerateSTART(I2C1, ENABLE);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));

    // 发送从机地址 + 写位
    I2C_Send7bitAddress(I2C1, slaveAddr, I2C_Direction_Transmitter);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));

    // 逐字节发送
    for(uint16_t i = 0; i < len; i++)
    {
        I2C_SendData(I2C1, data[i]);
        while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));
    }

    // 产生停止条件
    I2C_GenerateSTOP(I2C1, ENABLE);
    return 0;
}

4.3 超时处理机制:别让系统死等

你想想看,如果从机突然掉电或者 SCL 被拉死,你的主机就会卡在 while 循环里,整个系统都瘫痪了。这就是为什么超时处理是 I2C 驱动里必不可少的一环。

我个人习惯在每个等待标志的循环里都加一个超时计数器。超时值怎么定?一般取 10ms 就够了。I2C 通信一帧数据最多几十微秒,10ms 已经足够宽裕。如果超过 10ms 还没等到标志,就认为总线异常,执行错误恢复流程。

错误恢复流程包括:

  • 先产生停止条件,释放总线。
  • 如果停止条件也发不出去(SCL 被拉死),就手动切换 GPIO 模式,用软件模拟的方式产生 9 个时钟脉冲,让从机释放 SDA。
  • 最后重新初始化 I2C 外设。

避坑指南:我曾经在一个电池供电的项目里,把超时时间设成了 100ms。结果从机偶尔响应慢,主机就频繁触发超时复位,导致功耗飙升。后来我把超时时间调到 50ms,同时增加了重试次数,问题就解决了。超时值不是越大越好,要根据实际场景调。

带超时的发送函数骨架:

uint8_t I2C_Master_Transmit_Timeout(uint8_t slaveAddr, uint8_t *data, uint16_t len, uint32_t timeout)
{
    uint32_t tick = 0;

    // 起始条件
    I2C_GenerateSTART(I2C1, ENABLE);
    tick = GetTick();
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT))
    {
        if((GetTick() - tick) > timeout)
        {
            I2C_ErrorRecovery();
            return 1;  // 超时
        }
    }

    // ... 后续步骤类似,每个 while 都加超时判断
    // ... 省略中间代码

    I2C_GenerateSTOP(I2C1, ENABLE);
    return 0;
}

4.4 多主机通信仲裁:谁先说话谁赢

多主机通信,就是多个主机挂在同一条 I2C 总线上。这时候谁控制时钟?答案是:谁先拉低 SDA 谁就赢了。I2C 的仲裁机制是基于线与逻辑的——多个主机可以同时发送数据,但一旦某个主机试图拉高 SDA 而检测到 SDA 被其他主机拉低,它就输了,自动退出竞争。

仲裁发生在两个地方:

  • 地址阶段:多个主机同时发送起始条件,谁的地址更小(二进制值更低),谁就赢得仲裁。
  • 数据阶段:赢得仲裁的主机继续发送数据,输掉的主机转为从机模式,等待下一次机会。

硬件会自动处理仲裁过程。你作为驱动开发者,只需要做两件事:

  1. 使能多主机模式(有些芯片需要配置 CR2 的 MULT 位)。
  2. 在发送完起始条件后,检查是否发生了仲裁丢失(AF 标志)。如果 AF 置位,说明你输了,需要重新发起通信。

经验之谈:多主机场景下,我建议每个主机在发送前先检测总线是否空闲。怎么检测?看 SDA 和 SCL 是否同时为高电平超过 4.7μs(标准模式下的总线空闲时间)。有些芯片有 BUSY 标志位,可以直接读。如果没有,就用 GPIO 轮询。

仲裁丢失的处理代码:

void I2C_Event_Handler(void)
{
    if(I2C_GetITStatus(I2C1, I2C_IT_AF))
    {
        // 仲裁丢失,清除标志
        I2C_ClearITPendingBit(I2C1, I2C_IT_AF);

        // 产生停止条件,释放总线
        I2C_GenerateSTOP(I2C1, ENABLE);

        // 等待一段时间后重试
        delay_ms(1);
        I2C_Master_Transmit_Timeout(slaveAddr, data, len, 10);
    }
}

下面这张图总结了主机模式的核心流程和仲裁机制:

I2C 主机模式驱动核心流程 主机模式初始化 检测总线空闲 + 仲裁 主机发送流程 主机接收流程 START → 地址+写 → 数据 → STOP START → 地址+读 → 数据+NACK → STOP 超时处理机制 错误恢复 + 重试 仲裁丢失时自动退出发送 超时后执行恢复流程

最后总结一下主机模式开发的关键点:初始化要按顺序来,发送接收要按状态机走,超时处理不能省,多主机场景下要处理好仲裁丢失。把这些都做到位了,你的 I2C 驱动才算真正可靠。