STM32软件I2C模拟流程:图解说明时序逻辑
深入理解STM32软件I2C:从时序逻辑到实战代码的完整拆解
你有没有遇到过这种情况:项目中明明有两个I2C外设,但其中一个被EEPROM占了,另一个又连着OLED,这时候突然要加一个温湿度传感器—— 引脚不够用了怎么办?
或者更糟心的是,硬件I2C莫名其妙“死锁”,状态寄存器卡在BUSY不放,复位都无效?
别急。今天我们就来聊一个嵌入式开发里的“老手艺”—— 软件I2C (也叫GPIO模拟I2C)。它不像硬件I2C那样“高大上”,但它足够灵活、足够稳定,尤其适合那些资源紧张、调试复杂的小型化系统。
更重要的是: 搞懂软件I2C,你就真正看穿了I2C协议的本质 。
为什么还要用软件I2C?硬件不是更好吗?
确实,STM32几乎每款芯片都集成了至少一两个I2C控制器。那为啥还要手动去翻GPIO、写延时、一位位发数据?
答案是: 现实开发没那么理想 。
硬件I2C的三大痛点
-
资源有限
很多小封装MCU只有1~2个I2C接口,而现代物联网设备动辄连接四五种I2C器件(传感器、触控、RTC、显示屏……),根本不够分。 -
引脚受限
并非所有GPIO都能复用为I2C功能。有些引脚没有AF功能,或者PCB布局时已经占用,没法改。 -
稳定性问题
特别是在STM32F1/F4系列中,硬件I2C模块存在著名的“死锁”Bug:当总线异常(比如从机掉电)时,SR2寄存器的BUSY位可能永远置位,导致整个I2C外设瘫痪,只能靠复位解决。
而 软件I2C完全绕开这些坑 ——它不依赖任何专用外设,只靠两个普通GPIO和一段精准控制的代码,就能实现可靠的通信。
I2C协议的核心机制:你真的懂“起始条件”吗?
在动手写代码之前,我们必须先搞清楚一件事: I2C到底是怎么传数据的?
很多人背过口诀:“SCL高时SDA下降沿是起始,上升沿是停止”。但这背后其实有一套严格的物理层规则。
两根线,四种状态
- SCL :主控时钟线,由主机驱动
- SDA :双向数据线,所有设备共享
关键点在于:
✅ SDA只能在SCL为低电平时改变电平;一旦SCL拉高,SDA必须保持稳定,否则会被当作控制信号!
这就是所谓的“ 建立时间与保持时间 ”要求。
所以你看下面这个典型波形:
SCL: ──┐ ┌───┐ ┌───┐ ┌── ...
│ │ │ │ │ │
SDA: ──┼───┐ │ └───┐ │ └───┐ │ ┌── ...
│ ▼ ▼ ▼ ▼ ▼ ▼ │
└── Start Data0 Data7 ACK
你会发现:
- 起始条件:SCL高 → SDA从高变低
- 停止条件:SCL高 → SDA从低变高
- 数据变化全发生在SCL为低期间
- 每个字节后有一个ACK/NACK周期(第9个时钟)
这正是我们用软件模拟的基础逻辑。
软件I2C如何工作?一步步还原通信过程
既然不能靠硬件自动产生波形,那就只能“手搓”每一个电平跳变了。
整个流程就像一场精密的舞蹈,主角是你写的代码,舞台是SCL和SDA这两条线。
四大基本动作详解
1. 起始条件(Start Condition)
void i2c_start(void)
{
// 初始状态:SCL=1, SDA=1
HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_SET);
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_SET);
us_delay(5);
// SDA下降 → 起始信号
HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_RESET);
us_delay(5);
// 拉低SCL,准备发送数据
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET);
us_delay(5);
}
⚠️ 注意顺序不能错:
必须先保证SCL为高,再让SDA下跳
,否则可能误触发停止或其他异常。
2. 发送一个字节(MSB优先)
每个字节8位,逐位输出,在SCL上升沿被从机采样。
void i2c_send_byte(uint8_t data)
{
for (int i = 0; i < 8; i++)
{
// SCL拉低 → 允许SDA变化
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET);
us_delay(2);
// 设置SDA电平(最高位)
if (data & 0x80)
HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_SET);
else
HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_RESET);
data <<= 1; // 左移,准备下一位
us_delay(2);
// SCL拉高 → 从机在此上升沿采样
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_SET);
us_delay(5);
// SCL拉低 → 进入下一个bit周期
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET);
}
}
📌 关键细节:
- 必须确保SCL为低时才能改SDA;
- 上升沿前要有足够的建立时间(setup time);
- 下降沿后要有保持时间(hold time);
- 实际延时需根据目标速率调整(100kHz ≈ 5μs/bit)。
3. 接收一个字节
接收比发送复杂一点,因为你要读取外部设备的数据。
uint8_t i2c_read_byte(void)
{
uint8_t data = 0;
// 切换SDA为输入模式(释放总线)
i2c_sda_input();
for (int i = 0; i < 8; i++)
{
data <<= 1;
// SCL拉低 → 准备时钟上升沿
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET);
us_delay(2);
// SCL拉高 → 从机输出有效数据
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_SET);
us_delay(5);
// 在SCL高电平时读取SDA
if (HAL_GPIO_ReadPin(I2C_SDA_GPIO, I2C_SDA_PIN))
data |= 0x01;
// SCL再次拉低 → 完成一个bit
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET);
}
return data;
}
💡 提示:每次读取前必须将SDA设为 输入模式 ,否则会与从机冲突!
4. 应答处理(ACK/NACK)
每传输完一个字节,都需要应答确认。
- 主机接收数据时:发ACK表示继续接收,NACK表示结束
- 主机发送数据时:读ACK判断从机是否在线
void i2c_send_ack(uint8_t ack)
{
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET);
us_delay(2);
i2c_sda_output(); // 主机控制SDA
if (ack)
HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_SET); // NACK
else
HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_RESET); // ACK
us_delay(2);
// 上升沿通知从机
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_SET);
us_delay(5);
HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET);
}
最后一次读取通常发NACK,告诉从机“我要停了”。
实战案例:读取SHT30温湿度传感器
假设我们要通过软件I2C读取SHT30的数据,流程如下:
-
i2c_start() -
发送写地址:
0x88(即0x44 << 1 | 0) - 检查ACK
-
发送命令:
0x2C,0x06(启动周期测量) -
i2c_start()(重复起始) -
发送读地址:
0x89 - 读6字节数据(前2字节温度,中间2字节湿度,最后2字节CRC)
- 每次读完发ACK,最后一次发NACK
-
i2c_stop()
完整调用示例:
i2c_start();
i2c_send_byte(0x88); // 写地址
if (!i2c_read_ack()) goto err; // 可封装读ACK函数
i2c_send_byte(0x2C);
i2c_send_byte(0x06);
i2c_start(); // Repeated start
i2c_send_byte(0x89); // 读地址
if (!i2c_read_ack()) goto err;
temp_raw = i2c_read_byte(); i2c_send_ack(0); // ACK
temp_raw = (temp_raw << 8) | i2c_read_byte(); i2c_send_ack(0);
humid_raw = i2c_read_byte(); i2c_send_ack(0);
humid_raw = (humid_raw << 8) | i2c_read_byte(); i2c_send_ack(0);
crc_temp = i2c_read_byte(); i2c_send_ack(0);
crc_humid = i2c_read_byte(); i2c_send_ack(1); // NACK
i2c_stop();
可以看到, 重复起始(Repeated Start) 是软件I2C的一大优势——你可以连续发起读写操作而不释放总线,避免其他主设备抢占。
如何提升稳定性?五个关键设计要点
软件I2C虽然简单,但也容易出问题。以下是实际项目中的经验总结:
1. 使用真正的微秒级延时
千万别用
HAL_Delay(1)
!它是毫秒级的,远超I2C时序需求。
推荐使用:
static void us_delay(uint32_t us)
{
uint32_t start = DWT->CYCCNT;
uint32_t cycles = us * (SystemCoreClock / 1000000);
while ((DWT->CYCCNT - start) < cycles);
}
前提:开启DWT时钟(在
main.c
中添加
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
)
2. 配置为开漏输出 + 上拉电阻
gpio.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出
gpio.Pull = GPIO_PULLUP; // 外部或内部上拉
这样可以模拟I2C总线的“线与”特性:任意设备拉低都会使总线为低。
如果没有硬件开漏支持,可以用推挽输出配合外部上拉电阻,但要注意避免强推冲突。
3. 关键段禁止中断
如果在发送中途被打断太久(>几微秒),可能导致时序错误。
建议在关键操作中临时关闭全局中断:
__disable_irq();
i2c_start();
i2c_send_byte(addr);
__enable_irq();
适用于对实时性要求高的场景。
4. 合理选择上拉电阻
| 速度 | 推荐阻值 |
|---|---|
| 标准模式 (100kHz) | 4.7kΩ |
| 快速模式 (400kHz) | 2.2kΩ |
太大会导致上升沿缓慢,太小则功耗高且易过载。
5. 总线空闲检测(可选)
在执行
start
前,检查SDA/SCL是否都为高,防止上次通信未正常结束。
while (HAL_GPIO_ReadPin(I2C_SCL_GPIO, I2C_SCL_PIN) == 0); // 等待SCL释放
if (HAL_GPIO_ReadPin(I2C_SDA_GPIO, I2C_SDA_PIN) == 0) {
// SDA被拉低 → 总线忙 → 执行恢复流程
recover_bus();
}
和硬件I2C比,到底谁更强?
| 对比项 | 软件I2C | 硬件I2C |
|---|---|---|
| 引脚自由度 | ✅ 任意GPIO | ❌ 仅限特定复用引脚 |
| CPU占用 | ⚠️ 较高(轮询+延时) | ✅ 极低(DMA支持) |
| 稳定性 | ✅ 不受硬件Bug影响 | ⚠️ F1/F4有死锁风险 |
| 调试可视性 | ✅ 可用逻辑分析仪逐bit观察 | ✅ 自动模式波形干净 |
| 多速率兼容 | ✅ 动态调节延时即可 | ⚠️ 需重新配置寄存器 |
| 开发难度 | ⚠️ 需掌握底层时序 | ✅ HAL库一键初始化 |
结论很明确:
🎯 如果你追求极致灵活性和稳定性,选软件I2C;
🎯 如果你追求高性能和低功耗,选硬件I2C。
很多高手的做法是: 混合使用 ——高速设备走硬件I2C,低速/备用设备走软件I2C。
最佳实践建议:封装成独立模块
不要把I2C代码散落在各个.c文件里。推荐做法:
/Drivers/
soft_i2c.c
soft_i2c.h
提供统一API:
int soft_i2c_init(void);
int soft_i2c_write(uint8_t dev_addr, uint8_t reg, uint8_t *data, int len);
int soft_i2c_read(uint8_t dev_addr, uint8_t reg, uint8_t *data, int len);
这样不仅方便移植,还能快速替换底层实现(比如将来换成硬件I2C也不用改应用层)。
写在最后:掌握软件I2C,意味着你真正“看见”了协议
当你第一次用手动翻GPIO的方式,看着逻辑分析仪上一点点走出标准I2C波形时,那种成就感是无与伦比的。
它教会你的不只是“怎么通信”,而是:
- 协议是如何在物理层面落地的?
- 为什么要有建立时间和保持时间?
- 总线竞争是怎么发生的?
- 为什么需要上拉电阻?
这些问题的答案,都在那一行行看似简单的
HAL_GPIO_WritePin()
之中。
所以,哪怕你现在用的是高级RTOS+DMA+硬件I2C组合拳,我也建议你亲手实现一遍软件I2C。
因为它不仅是备胎方案,更是通往嵌入式底层世界的钥匙。
如果你在实现过程中遇到了SDA卡死、ACK失败、数据错乱等问题,欢迎在评论区留言讨论,我们可以一起分析波形、排查时序。毕竟,每一个嵌入式工程师,都是从“拉高低低”中成长起来的。
更多推荐
所有评论(0)