STM32 I2C协议详解:从原理到实战

一句话总结: I2C 是用 两根线 搞定一堆外设的同步半双工协议。搞懂 起始/停止/应答 3个信号,你就能用STM32驱动市场上 90% 的传感器。

本文融合了野火、正点原子、江科大等精品教程的核心思想,并结合 HAL 库实战经验重写


1. 为什么 STM32 项目离不开 I2C?

1.1 I2C 是什么

I2C (Inter-Integrated Circuit) 是飞利浦在1982年发明的,两根线:

  • SCL (Serial Clock): 时钟线,完全由主机掌控

  • SDA (Serial Data): 数据线,双向

特点:

  • 同步、半双工、串行

  • 多主多从,一条总线理论可挂 112个 7位地址设备

  • 只需要2根线 + 2个上拉电阻,极度省 IO

1.2 I2C vs SPI vs USART

特性 I2C SPI USART
线数 2根 (SCL+SDA) 4根 (SCK+MOSI+MISO+CS) 2根 (TX+RX)
速度 低速 100k / 400k / 3.4M 高速 10M+ 中低速
双工 半双工 全双工 全双工
组网 多主多从,地址寻址 一主多从,片选寻址 点对点
典型外设 OLED, MPU6050, AT24C02, BMP280, RTC Flash, 屏幕, 无线模块 蓝牙, GPS, 串口屏

结论: 传输传感器、小数据、用线少选 I2C;传大文件、要速度选 SPI;板间远距离选 USART。

2. 硬件层:为什么 SDA/SCL 必须接上拉电阻?

这是 90% 新手踩的第一坑。

STM32 的 I2C 引脚是 开漏输出 (Open-Drain),结构类似这样:

  • 输出 0:N-MOS导通,强行把总线拉低到 GND

  • 输出 1:N-MOS截止,引脚处于高阻态,靠外部上拉电阻拉到 VCC

好处:支持 线与逻辑 --- 只要有一个设备拉低,总线就是低。只有所有设备都释放,总线才为高。这正是多机仲裁和时钟拉伸的基础。

阻值如何选? 经验值:标准模式(100kHz)用 4.7kΩ ,快速模式(400kHz)用 2.2kΩ 。 理论公式:Rmax = Tr / (0.8473 * Cb)Cb 是总线电容,Tr是上升时间。阻值太小功耗大,太大上升沿慢导致时序错误。

3. 协议层:6大核心时序信号

软件模拟I2C的核心就是用GPIO翻出下面6种波形。

3.1 空闲状态

SCL 和 SDA 都为高电平。

3.2 起始信号 S 和停止信号 P

这是 I2C 的帧头帧尾,定义非常严格:在 SCL 为高电平时,SDA的跳变

  • 起始 S: SCL 高时,SDA 从高变低 (下降沿)

  • 停止 P: SCL 高时,SDA 从低变高 (上升沿)

记忆技巧(野火教程版): 把SCL看成水面,SDA看成船。船头下沉是Start,船尾翘起是Stop。

3.3 数据有效性 & 8位数据帧

  • SCL 低电平时,允许 SDA 变化,准备数据

  • SCL 高电平时,SDA 必须稳定,接收方在此刻采样

  • 每次传 8位,MSB 高位在前

3.4 应答信号 ACK / NACK

每传完1个字节(8 bit),第9个时钟是应答位。

  • 发送方: 在第9个时钟前释放 SDA。

  • 接收方:

    • 拉低 SDA = ACK (0),告诉对方我收到了,继续发

    • 释放 SDA = NACK (1),告诉对方我不想收了 / 地址没找到

3.5 时钟拉伸 Clock Stretching

这是I2C唯一的"从机反制主机"的手段。从机如果处理不过来(比如MPU6050做DMP解算),可以在 ACK 前把 SCL 强行拉低,主机发现 SCL 没变高,就会乖乖等待,直到从机释放 SCL。

STM32 硬件I2C自动处理拉伸,软件模拟I2C你需要在拉高SCL后,加一句检查SCL是否真的变高的代码,否则可能丢数据。

4. 数据帧格式:真正读写是怎么玩的?

I2C的第一个字节永远是 7位从机地址 + 1位读写位 R/W

  • 0 = 写 (Master -> Slave)

  • 1 = 读 (Master <- Slave)

HAL库大坑: 手册上写的0x3C是7位地址,HAL库函数要求传 8位地址 ,所以代码里要写成 0x3C << 1 = 0x78。这就是为什么你抄 0x3C 通信永远失败。

4.1 4种核心读写时序

1) 指定地址写: 最常用,写 OLED、写寄存器

复制代码
S | DevAddr+W | ACK | RegAddr | ACK | Data | ACK | P

2) 当前地址读: 不指定寄存器,读当前位置指针

复制代码
S | DevAddr+R | ACK | Data | NACK | P

3) 指定地址读: 复合格式,最重要!读 MPU6050必用。先用一次写操作把寄存器指针指过去,再用重复起始 Sr 读。

复制代码
S | DevAddr+W | ACK | RegAddr | ACK | Sr | DevAddr+R | ACK | Data | NACK | P
// Sr = Repeated Start,中间不发 P,SCL一直被主机占着,防止总线被别的Master抢走

4) 连续读写: 在指定地址后连续发送多个Data,适用于读多个字节(例如MPU6050一次读14字节的加速度+温度+陀螺仪)

5. STM32的两种实现:硬件I2C vs 软件模拟I2C

对比项 软件模拟 (Bit-Banging) 硬件I2C (Peripheral)
引脚 任意GPIO 固定复用引脚 PB6/PB7 (I2C1), PB10/PB11 (I2C2)
实现 CPU手动翻转GPIO,delay微秒 配置CR1/CR2/SR/DR寄存器,外设自动产生时序
优点 代码简单,无Bug,移植强,不受死锁影响 省CPU,可配合DMA/中断,支持多主机、SMBus
缺点 占用CPU,不能用DMA,速率不精确 STM32F1/F4的硬件I2C有著名Bug,容易卡死在BUSY标志,需要复杂的错误处理
推荐 入门、新手、稳定性要求高的项目 强烈推荐! 追求极致效率,且有能力处理EV5/EV6等事件的中高级开发者

老司机建议: 90%的国产开源项目(正点原子、江科大)用软件I2C驱动OLED/MPU6050,不是他们不会硬件I2C,而是软件I2C真的更稳。

5.1 STM32 硬件I2C关键寄存器(以F103为例)

  • I2C_CR1 PE位: 外设使能开关,配置前必须关闭

  • I2C_CR1 START/STOP位: 产生起始/停止

  • I2C_CR1 ACK位: 接收后是否给ACK

  • I2C_SR1 SB位 (EV5): 起始位已发送

  • I2C_SR1 ADDR位 (EV6): 地址已发送/匹配

  • I2C_SR1 TXE/RXNE: 发送/接收寄存器空/非空

6. 实战一:CubeMX + HAL 硬件I2C驱动0.96寸OLED

这是最快跑通 I2C 的方法。

1. CubeMX配置

  • Connectivity -> I2C1 -> Mode: I2C

  • PB6 -> SCL, PB7 -> SDA,自动配置为 复用开漏输出

  • Parameter Settings:

    • Clock Speed: 100000 (先用100k跑通,再提速到400k)

    • Duty Cycle: 50%

    • Addressing Mode: 7Bit

2. 核心代码解析

OLED本质是写从机,只需要一个写入函数。OLED有命令和数据之分,通过第二个参数区分(0x00写命令,0x40写数据)。

复制代码
#define OLED_ADDR  0x78  // 0x3C << 1,8位地址!
​
extern I2C_HandleTypeDef hi2c1;
​
// 写命令/数据,移植自官方SSD1306
void OLED_Write(uint8_t reg, uint8_t data)
{
    // HAL_I2C_Mem_Write 完美匹配 “指定地址写” 时序
    // hi2c, DevAddress, MemAddress(Reg), MemAddSize, pData, Size, Timeout
    HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR, reg, I2C_MEMADD_SIZE_8BIT, &data, 1, 100);
}
​
void OLED_WR_CMD(uint8_t cmd){ OLED_Write(0x00, cmd); }
void OLED_WR_DATA(uint8_t dat){ OLED_Write(0x40, dat); }

常用HAL库函数全家桶:

复制代码
HAL_I2C_Master_Transmit(&hi2c1, DevAddr, pData, Size, Timeout); // 纯写,不带寄存器地址
HAL_I2C_Master_Receive(&hi2c1, DevAddr, pData, Size, Timeout);  // 纯读
HAL_I2C_Mem_Write(&hi2c1, DevAddr, MemAddr, MemAddSize, pData, Size, Timeout); // 指定地址写 (强推)
HAL_I2C_Mem_Read(&hi2c1, DevAddr, MemAddr, MemAddSize, pData, Size, Timeout);  // 指定地址读 (强推)
HAL_I2C_IsDeviceReady(&hi2c1, DevAddr, Trials, Timeout); // 扫总线神器,查设备在不在

7. 实战二:HAL读取MPU6050 - 指定地址读的完美例子

MPU6050地址:AD0接地为0x68,接VCC为0x69。HAL用 0xD0 / 0xD2

复制代码
#define MPU6050_ADDR  0xD0
​
uint8_t MPU6050_ReadReg(uint8_t reg)
{
    uint8_t data;
    HAL_I2C_Mem_Read(&hi2c1, MPU6050_ADDR, reg, I2C_MEMADD_SIZE_8BIT, &data, 1, 100);
    return data;
}
​
// 一次性读14字节,效率更高
void MPU6050_Read_All(int16_t* ax, int16_t* ay, int16_t* az, int16_t* gx, int16_t* gy, int16_t* gz)
{
    uint8_t buf[14];
    HAL_I2C_Mem_Read(&hi2c1, MPU6050_ADDR, 0x3B, I2C_MEMADD_SIZE_8BIT, buf, 14, 100);
    *ax = (buf[0]<<8)|buf[1];
    *ay = (buf[2]<<8)|buf[3];
    *az = (buf[4]<<8)|buf[5];
    // ... buf[6][7]是温度 ...
    *gx = (buf[8]<<8)|buf[9];
    *gy = (buf[10]<<8)|buf[11];
    *gz = (buf[12]<<8)|buf[13];
}

8. 实战三:软件模拟I2C终极模板(江科大风格,亲测可移植)

MyI2C.h - 任意GPIO都行

复制代码
#ifndef __MYI2C_H
#define __MYI2C_H
#define SDA_PIN  GPIO_Pin_9
#define SCL_PIN  GPIO_Pin_8
#define SDA_PORT GPIOB
#define SCL_PORT GPIOB
​
void MyI2C_Init(void);
void MyI2C_Start(void);
void MyI2C_Stop(void);
uint8_t MyI2C_SendByte(uint8_t byte);
uint8_t MyI2C_ReceiveByte(void);
uint8_t MyI2C_ReceiveAck(void);
void MyI2C_SendAck(uint8_t ack);
#endif

MyI2C.c - 核心是空出时序单元拼接

复制代码
void MyI2C_W_SDA(uint8_t bit){
    GPIO_WriteBit(SDA_PORT, SDA_PIN, (BitAction)bit);
    Delay_us(2);
}
void MyI2C_W_SCL(uint8_t bit){
    GPIO_WriteBit(SCL_PORT, SCL_PIN, (BitAction)bit);
    Delay_us(2);
}
uint8_t MyI2C_R_SDA(void){
    uint8_t bit = GPIO_ReadInputDataBit(SDA_PORT, SDA_PIN);
    Delay_us(2);
    return bit;
}
​
void MyI2C_Start(void){
    MyI2C_W_SDA(1); MyI2C_W_SCL(1); // 保证兼容Sr,SCL低时先释放SDA
    MyI2C_W_SDA(0);
    MyI2C_W_SCL(0);
}
void MyI2C_Stop(void){
    MyI2C_W_SDA(0);
    MyI2C_W_SCL(1);
    MyI2C_W_SDA(1);
}
uint8_t MyI2C_SendByte(uint8_t byte){
    for(uint8_t i=0;i<8;i++){
        MyI2C_W_SDA((byte & (0x80>>i)) ? 1:0);
        MyI2C_W_SCL(1);
        MyI2C_W_SCL(0);
    }
    return 0;
}
uint8_t MyI2C_ReceiveAck(void){
    uint8_t ack;
    MyI2C_W_SDA(1); // 主机释放SDA
    MyI2C_W_SCL(1);
    ack = MyI2C_R_SDA();
    MyI2C_W_SCL(0);
    return ack; // 0=ACK, 1=NACK
}
// 指定地址写
void I2C_WriteReg(uint8_t devAddr, uint8_t regAddr, uint8_t data){
    MyI2C_Start();
    MyI2C_SendByte(devAddr);      MyI2C_ReceiveAck();
    MyI2C_SendByte(regAddr);      MyI2C_ReceiveAck();
    MyI2C_SendByte(data);         MyI2C_ReceiveAck();
    MyI2C_Stop();
}
// 指定地址读 - 复合时序
uint8_t I2C_ReadReg(uint8_t devAddr, uint8_t regAddr){
    uint8_t data;
    MyI2C_Start();
    MyI2C_SendByte(devAddr);      MyI2C_ReceiveAck();
    MyI2C_SendByte(regAddr);      MyI2C_ReceiveAck();
    
    MyI2C_Start(); // 重复起始
    MyI2C_SendByte(devAddr|0x01); MyI2C_ReceiveAck();
    data = MyI2C_ReceiveByte();
    MyI2C_SendAck(1); // NACK结束
    MyI2C_Stop();
    return data;
}

9. 调试排坑指南:I2C不通的99%原因

  1. 没接上拉电阻: 用万用表量 SDA/SCL,空闲必须是 3.3V,不是0V。

  2. 地址搞错: 0x3C和0x78双重宇宙。HAL库扫地址函数:HAL_I2C_IsDeviceReady,写个for循环0-127扫一遍,串口打印能ACK的地址。

  3. GPIO模式错: 软件模拟必须是 GPIO_Mode_Out_OD 开漏输出,不能是推挽。硬件I2C是复用开漏。

  4. F1硬件I2C卡死: I2C_FLAG_BUSY一直置位。解决:总线恢复函数,手动产生9个时钟脉冲把从机刷出来。

复制代码
void I2C_Bus_Recover(void){
    GPIO_InitTypeDef g;
    // 将SCL配置为GPIO输出,SDA输入
    // ...
    for(int i=0;i<9;i++){
        GPIO_ResetBits(GPIOB, SCL_PIN); Delay_us(5);
        GPIO_SetBits(GPIOB, SCL_PIN); Delay_us(5);
    }
    // 再产生一个STOP
    GPIO_ResetBits(GPIOB, SDA_PIN);
    Delay_us(5);
    GPIO_SetBits(GPIOB, SCL_PIN); Delay_us(5);
    GPIO_SetBits(GPIOB, SDA_PIN); Delay_us(5);
}
  1. 速度太快: 杜邦线+面包板寄生电容大,400k跑不通,先降到100k或50k。

  2. 从机没上电 / 电源不共地: 最低级的错误,查一下。

10. 总结与面试常考点

  • I2C为什么要开漏+上拉? -> 实现线与,支持多主,支持时钟拉伸。

  • I2C一帧多少位? -> 9位,8位数据+1位ACK。

  • 100k的I2C,一秒传多少字节? -> 100k/9 ≈ 11kB/s,理论值,考虑地址和开销更低。

  • 如何处理I2C死锁? -> 9时钟法 + 产生STOP。

  • 解释一下重复起始和起始的区别? -> Sr中间总线不释放,防止被多主机抢占。


参考与推荐学习路线

相关推荐
lhbweilai1 小时前
STM32 I2C(hal库)
stm32·单片机·嵌入式硬件
单片机杂货铺5 小时前
基于STM32的智能市政清洁小车系统设计
stm32·单片机·嵌入式硬件·物联网·硬件工程
单片机杂货铺5 小时前
基于STM32的智能书房系统的设计
stm32·单片机·嵌入式硬件·51单片机·硬件工程
10WTW016 小时前
NTP协议
网络协议·计算机网络·ntp·ntp协议
weixin_727535628 小时前
HTTP 八股文:从三次握手到浏览器渲染的硬核拆解
网络·网络协议·http
复园电子9 小时前
USB Over IP技术详解:基于USB服务器实现USB设备远程访问、重定向与集中管理
服务器·网络协议·tcp/ip
TlSfoward9 小时前
TLSFoward 能帮你看到什么 TLSFOWARD抓包工具
服务器·爬虫·网络协议·https
lhbweilai9 小时前
STM32 定时器(hal库)
stm32·单片机·嵌入式硬件
别催小唐敲代码10 小时前
STM32 MQTT协议详解:从报文地狱到阿里云实战
stm32·嵌入式硬件·阿里云