一句话总结: 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%原因
-
没接上拉电阻: 用万用表量 SDA/SCL,空闲必须是 3.3V,不是0V。
-
地址搞错: 0x3C和0x78双重宇宙。HAL库扫地址函数:
HAL_I2C_IsDeviceReady,写个for循环0-127扫一遍,串口打印能ACK的地址。 -
GPIO模式错: 软件模拟必须是
GPIO_Mode_Out_OD开漏输出,不能是推挽。硬件I2C是复用开漏。 -
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); }
-
速度太快: 杜邦线+面包板寄生电容大,400k跑不通,先降到100k或50k。
-
从机没上电 / 电源不共地: 最低级的错误,查一下。
10. 总结与面试常考点
-
I2C为什么要开漏+上拉? -> 实现线与,支持多主,支持时钟拉伸。
-
I2C一帧多少位? -> 9位,8位数据+1位ACK。
-
100k的I2C,一秒传多少字节? -> 100k/9 ≈ 11kB/s,理论值,考虑地址和开销更低。
-
如何处理I2C死锁? -> 9时钟法 + 产生STOP。
-
解释一下重复起始和起始的区别? -> Sr中间总线不释放,防止被多主机抢占。
参考与推荐学习路线
-
江科大《STM32入门教程》I2C章节 - 软件I2C讲得最清楚 江科大STM32学习笔记(8)------ I2C通信协议_stm32 i2c通信协议-CSDN博客
-
野火《STM32模块例程介绍》I2C协议详解 - 原理解析最透彻
-
正点原子AT24C02/ MPU6050例程 - 实战代码最全 7
-
FastBit STM32 I2C Clock Stretching 专题 - 深度理解时钟拉伸 4