软件I2C在STM32上的实现:从协议到代码的深度实践
你有没有遇到过这样的场景?项目已经进入PCB布线阶段,突然发现硬件I2C引脚被串口占用了;或者多个传感器都需要接入I2C总线,但MCU只提供一路I2C外设。更糟的是,某次调试中总线“挂死”,设备不再响应——重启都无效。
这时候,软件I2C就成了你的“救命稻草”。
它不像硬件I2C那样依赖特定外设模块,而是用两根普通GPIO模拟完整的I2C通信时序。虽然牺牲了一些效率,但它带来的灵活性和容错能力,在真实工程中往往比性能更重要。
今天我们就来拆解:如何在STM32上写出一个稳定、可复用、抗干扰的软件I2C驱动,并深入理解其背后的底层逻辑。
为什么需要软件I2C?
先别急着写代码,我们得搞清楚——什么时候该用软件I2C?
硬件I2C真的够用吗?
STM32确实集成了强大的硬件I2C控制器,支持DMA、中断、自动ACK等高级功能。但在实际开发中,它的短板也很明显:
- 引脚固定:只能使用指定的复用功能(AF)引脚,无法根据PCB布局灵活调整;
- 数量有限:小封装型号如STM32F030K6T6只有1个I2C接口,面对多传感器系统捉襟见肘;
- 死锁风险高:一旦从设备异常拉低SDA或SCL,硬件模块可能陷入等待状态,甚至需要复位才能恢复;
- 时钟拉伸兼容性差:部分旧版HAL库对Clock Stretching处理不完善,导致通信失败。
而这些问题,恰恰是软件I2C的主场优势。
通过CPU直接控制IO电平变化,你可以:
- 在任意引脚上构建I2C通道;
- 主动释放总线、强制恢复通信;
- 精确控制每一个bit的时间窗口;
- 添加超时重试机制提升鲁棒性。
尤其是在原型验证、教育项目或资源紧张的设计中,软件I2C几乎是必选项。
I2C协议的本质:两条线如何对话?
在动手实现之前,我们必须回归本质:I2C到底是什么?
简单说,它是基于开漏结构 + 上拉电阻的双向串行总线,由主设备通过SCL(时钟)和SDA(数据)协调通信节奏。
关键点在于:
- 所有设备共享同一组信号线;
- 通信由主设备发起;
- 每个字节后必须有ACK/NACK确认;
- 起始与停止条件靠SDA在SCL高电平时跳变来定义。
这意味着:只要我们能精准控制这两条线的状态切换顺序,就能“假装”自己是一个I2C主机。
这正是软件I2C的核心思想——用时间换自由度。
STM32上的实现要点
GPIO配置:为什么必须是开漏输出?
这是很多人忽略的关键细节。
如果你把SCL/SDA设为推挽输出,当两个设备同时驱动总线时会发生短路风险。比如主设备想发高电平,但从设备正在应答(拉低SDA),推挽结构会形成电源到地的直通路径,轻则干扰信号,重则烧毁IO。
正确的做法是设置为开漏输出(Open Drain),并外接上拉电阻(通常4.7kΩ)。这样:
- 输出低电平时,MOSFET导通,将线路拉到GND;
- 输出高电平时,MOSFET关闭,依靠上拉电阻自然升至VDD;
- 多个设备可以安全地“线与”操作,任一设备拉低即代表逻辑0。
GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); // SCL 配置 gpio.Pin = I2C_SCL_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 gpio.Pull = GPIO_PULLUP; // 启用内部上拉(建议外部更稳) gpio.Speed = GPIO_SPEED_FREQ_HIGH; // 高速模式减少上升延迟 HAL_GPIO_Init(I2C_SCL_PORT, &gpio); // SDA 同样配置 gpio.Pin = I2C_SDA_PIN; HAL_GPIO_Init(I2C_SDA_PORT, &gpio);⚠️ 注意:即使启用了内部上拉,也推荐使用外部精密电阻。片内上拉阻值较大(约40kΩ),在高速或长线传输时可能导致上升沿过缓。
初始化完成后,记得将SCL和SDA都置为高电平,模拟总线空闲状态。
微秒级延时:决定成败的精度
I2C标准模式要求SCL周期至少10μs(对应100kHz),其中高电平≥4.0μs,低电平≥4.7μs。这些时间必须严格满足,否则从设备可能无法正确采样。
但问题来了:HAL_Delay(1)最小单位是毫秒,根本不够用!
所以,我们需要更高精度的延时方案。
方案一:NOP循环(适合快速原型)
最简单的办法就是插入空指令:
void i2c_delay_us(uint32_t us) { uint32_t n = us * (SystemCoreClock / 1000000UL / 5); // 每微秒约5个NOP while (n--) __NOP(); }这个系数需要实测校准。例如在72MHz主频下,编译优化等级-O1时,大约每微秒执行5~6个__NOP()。
优点是简单、跨平台;缺点是对编译器敏感,移植时需重新测试。
方案二:DWT计数器(推荐用于正式项目)
Cortex-M3/M4/M7内核自带DWT(Data Watchpoint and Trace)单元,提供精确的CPU周期计数。
启用后可实现纳秒级延时控制:
void i2c_init_dwt(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; DWT->CYCCNT = 0; } void i2c_delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t cycles = us * (SystemCoreClock / 1000000UL); while ((DWT->CYCCNT - start) < cycles); }✅ 建议首次调用前使能DWT,并确保链接脚本未占用此功能。
这种方式不受中断影响较小,适合要求稳定的工业应用。
核心函数实现:一步步构建通信流程
现在进入实战环节。我们将实现四个基本操作:起始、停止、写一字节、读一字节。
起始条件(Start Condition)
规则:SCL为高时,SDA从高变低。
void software_i2c_start(void) { // 确保初始为空闲状态 HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); i2c_delay_us(5); HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET); // SDA下降 i2c_delay_us(5); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); // 拉低SCL准备发送 i2c_delay_us(5); }注意最后一步要拉低SCL,为后续数据传输做准备。
停止条件(Stop Condition)
相反过程:SCL为高时,SDA从低变高。
void software_i2c_stop(void) { HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET); i2c_delay_us(5); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); i2c_delay_us(5); HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET); // SDA上升 i2c_delay_us(5); }发送一个字节 + 接收ACK
每个字节以MSB先行方式逐位发送,之后释放SDA让从设备拉低表示ACK。
uint8_t software_i2c_write_byte(uint8_t data) { for (int i = 0; i < 8; i++) { HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); i2c_delay_us(2); if (data & 0x80) HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET); else HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET); i2c_delay_us(2); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); // 上升沿采样 i2c_delay_us(5); data <<= 1; } // 释放SDA,读取ACK HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET); i2c_delay_us(1); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); i2c_delay_us(2); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); i2c_delay_us(5); uint8_t ack = HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN); // 低=ACK HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); return (ack == GPIO_PIN_RESET) ? 0 : 1; // 返回0表示收到ACK }返回值设计成“0表示成功”是为了方便判断:if (!software_i2c_write_byte(addr))即表示通信正常。
读取一个字节 + 发送ACK/NACK
读操作前必须将SDA切换为输入模式,否则会干扰从设备输出。
uint8_t software_i2c_read_byte(uint8_t send_ack) { uint8_t data = 0; // 切换SDA为输入 GPIO_InitTypeDef gpio = {0}; gpio.Pin = I2C_SDA_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(I2C_SDA_PORT, &gpio); i2c_delay_us(1); for (int i = 0; i < 8; i++) { HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); i2c_delay_us(2); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); i2c_delay_us(2); data = (data << 1) | HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN); i2c_delay_us(3); } // 切回输出模式 gpio.Mode = GPIO_MODE_OUTPUT_OD; HAL_GPIO_Init(I2C_SDA_PORT, &gpio); // 发送ACK/NACK HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); i2c_delay_us(2); if (send_ack) HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET); // ACK else HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET); // NACK i2c_delay_us(2); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); i2c_delay_us(5); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); return data; }最后一个字节通常发NACK,通知从设备结束传输。
实际应用案例:读取SHT30温湿度传感器
我们以常见的SHT30为例,展示完整通信流程。
该传感器地址为0x44,测量命令为0x2C06(高重复性),返回6字节数据(温度+湿度+CRC)。
int read_sht30(float *temp, float *humi) { uint8_t buf[6]; software_i2c_start(); if (software_i2c_write_byte(0x44 << 1)) { // 写模式 software_i2c_stop(); return -1; // 无响应 } if (software_i2c_write_byte(0x2C) || software_i2c_write_byte(0x06)) { software_i2c_stop(); return -1; } software_i2c_start(); // 重复启动 if (software_i2c_write_byte((0x44 << 1) | 1)) { // 读模式 software_i2c_stop(); return -1; } for (int i = 0; i < 5; i++) { buf[i] = software_i2c_read_byte(1); // 前5字节发ACK } buf[5] = software_i2c_read_byte(0); // 最后字节NACK software_i2c_stop(); // 解析数据(略去CRC校验) uint16_t raw_temp = (buf[0] << 8) | buf[1]; uint16_t raw_humi = (buf[3] << 8) | buf[4]; *temp = -45 + 175 * (float)raw_temp / 65535.0f; *humi = 100 * (float)raw_humi / 65535.0f; return 0; }整个过程完全可控,可在任意异常点加入重试逻辑。
高级技巧:总线恢复与稳定性优化
如何解决“总线挂死”?
当某个从设备故障并持续拉低SDA时,总线将无法发出起始信号。
硬件I2C往往束手无策,但软件I2C可以主动“踢一脚”:
void i2c_bus_recovery(void) { HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); for (int i = 0; i < 9; i++) { if (HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN)) break; // SDA已释放 HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET); i2c_delay_us(5); HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); i2c_delay_us(5); } // 最后再发一次Stop清理状态 software_i2c_stop(); }连续发送最多9个时钟脉冲,迫使从设备完成当前字节传输并释放总线。
提升稳定性的五大建议
- 使用外部上拉电阻(4.7kΩ),避免依赖片内弱上拉;
- 电源去耦:每个I2C设备旁加0.1μF陶瓷电容;
- 降低速率:软件I2C建议运行在100~200kHz,避免极限压榨CPU;
- 禁用中断:在起始/停止等关键段临时关中断,防止延时不准;
- 逻辑分析仪验证:用Saleae或DSView抓波形,确认tHIGH/tLOW符合规范。
软件I2C vs 硬件I2C:怎么选?
| 维度 | 软件I2C | 硬件I2C |
|---|---|---|
| 引脚灵活性 | ✅ 任意GPIO | ❌ 固定AF引脚 |
| 多总线支持 | ✅ 可模拟多路 | ⚠️ 取决于外设数量 |
| CPU占用 | 高(轮询) | 低(DMA/中断) |
| 容错能力 | 强(可恢复) | 弱(易死锁) |
| 开发难度 | 中(需控时序) | 低(库函数封装) |
| 移植性 | 高(仅改引脚) | 低(依赖HAL) |
结论很明确:
- 快速原型、教学演示、引脚受限 → 选软件I2C
- 高频采集、低功耗需求、实时性强 → 优先用硬件I2C
但别忘了:最好的架构是软硬结合。你可以保留一路硬件I2C用于高速设备(如音频codec),另一路用软件I2C连接低速传感器,实现资源最优分配。
结语:掌握底层,才能驾驭复杂
软件I2C看似“退而求其次”的选择,实则是嵌入式工程师必备的基本功。
它教会我们一件事:协议不是魔法,而是可以用代码重现的时序游戏。
当你亲手写出第一个__NOP()延时、第一次看到SDA在示波器上准确跳变时,那种掌控感远胜于调用一句HAL_I2C_Master_Transmit()。
更重要的是,这种能力让你在面对奇怪bug时不再盲目重启,而是能冷静分析:“是不是ACK没回来?”、“SDA有没有被谁拉死了?”、“要不要发几个clock试试?”
这才是真正的嵌入式思维。
如果你也在做传感器整合、小型化设计,不妨试试把其中一个I2C设备迁移到软件模拟总线上。你会发现,系统的灵活性和健壮性,瞬间提升了一个档次。
对了,文中的完整驱动代码我已经整理成模块化文件,欢迎在评论区留言获取。你在项目中用过软件I2C吗?遇到了哪些坑?一起交流下吧!