4. 主机发送模式:主机作为发送器的完整时序与代码实现

好,咱们进入实战环节。前面几章把I2C协议的基础概念、时序参数都捋了一遍,现在该动手写代码了。这一章我重点讲主机作为发送器——说白了,就是主机主动往总线上丢数据,从机负责收。

我记得刚入行那会儿,第一次调I2C主机发送,以为照着数据手册把SCL、SDA拉来拉去就行。结果呢?从机就是不ACK。折腾了两天才发现,是起始条件之后的地址相位多了一个时钟周期。嗯,这种坑踩过一次就记住了。

4.1 主机发送模式的完整时序拆解

先看一张完整的发送时序图,我习惯把每个阶段标清楚,这样写代码时心里有数。

主机发送模式时序阶段(7位地址)

  • 阶段1:总线空闲 — SCL、SDA均为高电平
  • 阶段2:起始条件(S) — SDA从高变低,SCL保持高
  • 阶段3:发送从机地址(7位) — MSB先发,SCL每个高电平采样一位
  • 阶段4:发送R/W位 — 主机发送模式为0(写)
  • 阶段5:等待从机ACK — 主机释放SDA,从机在第9个SCL拉低SDA
  • 阶段6:发送数据字节(8位) — MSB先发,每字节后跟ACK
  • 阶段7:重复起始条件或停止条件 — 继续发或结束传输
  • 阶段8:停止条件(P) — SDA从低变高,SCL保持高
主机发送模式完整时序流程 总线空闲 起始条件 S 发送7位地址 R/W=0 等待ACK 发送数据字节 等待ACK 重复起始? 停止条件 P 总线空闲 注意:每发送一个字节(地址或数据),主机必须释放SDA并等待从机ACK。若NACK,应发送停止条件终止传输。

4.2 代码实现:基于GPIO模拟的完整主机发送

我个人偏好用GPIO模拟来实现I2C主机发送,原因很简单——不依赖具体MCU的硬件I2C外设,移植性极强。你想想看,换个芯片平台,只要改GPIO宏定义就行,核心逻辑完全不用动。

实战建议: 我建议初学者先用GPIO模拟把协议跑通,再用硬件I2C。这样即使硬件I2C出问题,你也能用逻辑分析仪对照时序自己排查。

/* ============================================================
 * 文件名:i2c_master_tx.c
 * 描述:I2C主机发送模式完整实现(GPIO模拟)
 * 平台:通用嵌入式MCU
 * 作者:资深工程师的经验之谈
 * ============================================================ */

#include "i2c_master_tx.h"

/* 宏定义:GPIO操作(根据实际MCU修改) */
#define I2C_SCL_HIGH()      GPIO_SetBits(GPIOB, GPIO_Pin_6)
#define I2C_SCL_LOW()       GPIO_ResetBits(GPIOB, GPIO_Pin_6)
#define I2C_SDA_HIGH()      GPIO_SetBits(GPIOB, GPIO_Pin_7)
#define I2C_SDA_LOW()       GPIO_ResetBits(GPIOB, GPIO_Pin_7)
#define I2C_SDA_READ()      GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7)

/* 延时函数:根据实际SCL频率调整 */
static void i2c_delay_us(uint32_t us)
{
    /* 简单循环延时,实际项目中建议用定时器 */
    for (uint32_t i = 0; i < us * 10; i++) {
        __NOP();
    }
}

/* 起始条件:SDA先拉低,SCL保持高 */
static void i2c_start_condition(void)
{
    I2C_SDA_HIGH();
    I2C_SCL_HIGH();
    i2c_delay_us(5);    /* tSU:STA 建立时间 */
    I2C_SDA_LOW();      /* SDA从高变低 */
    i2c_delay_us(4);    /* tHD:STA 保持时间 */
    I2C_SCL_LOW();      /* 拉低SCL,准备传输数据 */
}

/* 停止条件:SCL先拉高,SDA从低变高 */
static void i2c_stop_condition(void)
{
    I2C_SDA_LOW();      /* 确保SDA为低 */
    I2C_SCL_HIGH();
    i2c_delay_us(4);    /* tSU:STO 建立时间 */
    I2C_SDA_HIGH();     /* SDA从低变高 */
    i2c_delay_us(5);    /* tBUF 总线空闲时间 */
}

/* 发送一个字节(MSB first),返回从机ACK状态 */
static uint8_t i2c_send_byte(uint8_t data)
{
    uint8_t ack;
    uint8_t i;

    /* 发送8位数据,从MSB开始 */
    for (i = 0; i < 8; i++) {
        if (data & 0x80) {
            I2C_SDA_HIGH();
        } else {
            I2C_SDA_LOW();
        }
        data <<= 1;            /* 左移,准备下一位 */
        i2c_delay_us(2);        /* 数据建立时间 */
        I2C_SCL_HIGH();         /* 拉高SCL,从机采样 */
        i2c_delay_us(4);        /* SCL高电平脉宽 */
        I2C_SCL_LOW();          /* 拉低SCL,准备下一位 */
        i2c_delay_us(2);
    }

    /* 第9个时钟:释放SDA,等待从机ACK */
    I2C_SDA_HIGH();             /* 主机释放SDA */
    i2c_delay_us(2);
    I2C_SCL_HIGH();             /* 第9个SCL高电平 */
    i2c_delay_us(2);
    ack = I2C_SDA_READ();       /* 读取SDA:低=ACK,高=NACK */
    I2C_SCL_LOW();
    i2c_delay_us(2);

    return ack;                 /* 返回0=ACK,1=NACK */
}

/* ============================================================
 * 主机发送数据函数
 * 参数:
 *   slave_addr  - 7位从机地址(左对齐,bit7~bit1)
 *   p_data      - 待发送数据缓冲区
 *   len         - 数据长度(字节数)
 * 返回值:
 *   0 - 成功
 *   1 - 从机NACK(地址或数据)
 * ============================================================ */
uint8_t i2c_master_transmit(uint8_t slave_addr, uint8_t *p_data, uint16_t len)
{
    uint8_t ret = 0;
    uint16_t i;

    /* 步骤1:发送起始条件 */
    i2c_start_condition();

    /* 步骤2:发送从机地址 + R/W=0(写) */
    ret = i2c_send_byte((slave_addr & 0xFE) | 0x00);
    if (ret) {
        /* 地址NACK:从机不存在或忙 */
        i2c_stop_condition();
        return 1;
    }

    /* 步骤3:逐字节发送数据 */
    for (i = 0; i < len; i++) {
        ret = i2c_send_byte(p_data[i]);
        if (ret) {
            /* 数据NACK:从机无法接收更多数据 */
            i2c_stop_condition();
            return 2;
        }
    }

    /* 步骤4:发送停止条件 */
    i2c_stop_condition();

    return 0;   /* 传输成功 */
}

4.3 关键时序参数与代码对应关系

写代码时最怕什么?时序参数和实际波形对不上。我整理了一张对照表,你写代码时直接对着看就行。

时序参数 标准模式(100kHz) 快速模式(400kHz) 代码中对应位置
tHD:STA(起始保持时间) ≥4.0μs ≥0.6μs i2c_start_condition()中SDA拉低后延时
tSU:STA(起始建立时间) ≥4.7μs ≥0.6μs i2c_start_condition()中SDA拉低前延时
tLOW(SCL低电平时间) ≥4.7μs ≥1.3μs i2c_send_byte()中SCL拉低后延时
tHIGH(SCL高电平时间) ≥4.0μs ≥0.6μs i2c_send_byte()中SCL拉高后延时
tSU:DAT(数据建立时间) ≥250ns ≥100ns 设置SDA后、拉高SCL前的延时
tHD:DAT(数据保持时间) ≥0ns ≥0ns SCL下降沿后保持SDA不变
tSU:STO(停止建立时间) ≥4.0μs ≥0.6μs i2c_stop_condition()中SCL拉高后延时
tBUF(总线空闲时间) ≥4.7μs ≥1.3μs 停止条件后、下次起始前的延时

我曾经踩过的坑: 有一次把tHD:STA设得太短,从机是工业级温度范围的,低温下时序漂移,起始条件保持时间不够,从机直接不响应。后来我把所有时序参数都加了20%的余量,再也没出过问题。记住:芯片数据手册给的是最小值,不是推荐值。

4.4 主机发送模式的避坑指南

写代码容易,调通难。我总结几个高频问题,你写代码时多留个心眼。

  1. 地址相位NACK — 最常见的问题。检查地址是否左对齐(7位地址放在bit7~bit1,bit0为R/W)。我见过有人把0x50直接当地址发,结果从机地址变成了0x28,当然NACK。
  2. SCL频率不准 — 用GPIO模拟时,延时函数受编译器优化等级影响很大。我建议用定时器或汇编NOP指令做精确延时,别用空循环。
  3. SDA方向切换 — 发送完地址或数据后,主机必须把SDA设为输入(释放总线),才能读到从机的ACK。很多新手忘了切换GPIO方向,读到的永远是高电平。
  4. 停止条件后总线忙 — 有些从机在收到停止条件后需要时间处理内部事务。如果紧接着发起始条件,从机可能还没准备好。加一个tBUF延时,至少5μs。
  5. 多字节发送中断 — 如果数据长度超过从机缓冲区,从机会在第N+1个字节返回NACK。代码里要处理这种情况,不能死等。

调试小技巧: 我习惯在代码里加一个调试模式,每次发送完起始条件、地址、每个字节后都打印当前状态。配合逻辑分析仪看波形,哪里不对一目了然。等调通了再把调试信息关掉。

4.5 实战:驱动一个I2C EEPROM写入数据

光说不练假把式。咱们拿一个24C02 EEPROM来演示主机发送的实际调用。24C02的从机地址是0xA0(7位地址0x50左对齐),写入时需要先发设备地址,再发存储地址,最后发数据。

/* 示例:向24C02 EEPROM写入1字节数据 */
#define EEPROM_ADDR     0xA0    /* 7位地址0x50左对齐 + R/W=0 */
#define EEPROM_PAGE_ADDR 0x00   /* 页地址 */
#define EEPROM_BYTE_ADDR 0x10   /* 字节地址 */

uint8_t write_buf[3];
uint8_t ret;

/* 构造写入数据:设备地址 + 存储地址 + 数据 */
write_buf[0] = EEPROM_PAGE_ADDR;    /* 高8位地址(24C02只用低2位) */
write_buf[1] = EEPROM_BYTE_ADDR;    /* 低8位地址 */
write_buf[2] = 0x5A;                /* 要写入的数据 */

/* 调用主机发送函数 */
ret = i2c_master_transmit(EEPROM_ADDR, write_buf, 3);

if (ret == 0) {
    /* 写入成功,EEPROM需要5ms内部写入时间 */
    delay_ms(5);
} else if (ret == 1) {
    printf("错误:EEPROM地址NACK,请检查硬件连接\n");
} else if (ret == 2) {
    printf("错误:EEPROM数据NACK,可能写入超页\n");
}

你看,实际用起来就这么几行。但要注意,24C02在写入后有一个内部编程时间(典型5ms),期间不会响应任何I2C命令。我建议你在每次写入后加一个轮询ACK的机制——发设备地址,如果返回NACK就继续等,直到ACK为止。这样比固定延时更高效。

性能优化思路: 如果一次要写很多字节,可以用24C02的页写入模式(一次最多16字节),减少起始/停止条件的开销。但要注意不能跨页,否则数据会回卷覆盖。

好了,主机发送模式的核心内容就这些。代码我给你了,时序对照表也列了,避坑指南也写了。剩下的就是你自己动手调一遍。记住:看波形比看代码更能发现问题。逻辑分析仪是你的好朋友,别嫌麻烦。