灵活通信的底层掌控:在STM32上手写软件I2C主从实现
你有没有遇到过这样的窘境?项目已经进入PCB布线阶段,突然发现唯一的硬件I2C引脚被调试接口占用了;或者换了一款新MCU,原来的驱动代码完全跑不起来。这时候,如果你会“手搓”一套软件I2C,问题往往迎刃而解。
今天我们就来深入聊聊这个嵌入式工程师必备的“保底技能”——用GPIO模拟I2C总线协议,并以STM32为平台,从零开始构建一个完整可用的软件I2C通信系统。
为什么需要软件I2C?
I2C(Inter-Integrated Circuit)是一种经典的双线串行通信协议,只需要SCL(时钟线)和SDA(数据线)就能挂载多个设备。它广泛用于连接传感器、EEPROM、RTC等外设。大多数现代MCU都内置了硬件I2C控制器,按理说应该很省心。
但现实没那么简单。
硬件模块的局限性
- 引脚固定:STM32的I2C1通常只能用PB6/PB7或PA9/PA10,一旦这些引脚被占用(比如做了SWD调试),你就没法用了。
- 资源紧张:小封装MCU可能只有一个I2C外设,而你的板子上有5个I2C器件怎么办?
- 移植困难:不同系列MCU的寄存器配置差异大,代码难以复用。
- 调试黑盒:硬件I2C内部逻辑复杂,波形看不见摸不着,出问题很难定位。
这时候,软件I2C就成了破局的关键。
它不依赖专用外设,而是通过控制任意两个GPIO口,手动“画”出I2C的时序波形。虽然牺牲了一些性能,但它带来的灵活性与可移植性,在很多场景下远超其代价。
软件I2C的核心思想:把协议“演”出来
要模拟I2C,首先要理解它的本质:一系列严格定义的电平跳变序列。
I2C采用开漏输出 + 上拉电阻的方式,支持多主竞争和应答机制。所有通信由主机发起,基本单元包括:
- 起始条件(Start):SCL高时,SDA从高变低
- 停止条件(Stop):SCL高时,SDA从低变高
- 数据位传输:每个bit在SCL上升沿被采样
- ACK/NACK:接收方在第9个周期拉低SDA表示确认
软件I2C的任务,就是用精确延时配合GPIO操作,把这些动作一步步“表演”出来。
💡 小贴士:你可以把它想象成一场舞台剧——没有自动控制系统,全靠演员(CPU)严格按照剧本(协议)走位和对白(电平变化)。
STM32实战:从GPIO初始化到完整通信
我们以STM32F1系列为例,使用HAL库进行开发。假设选用PB6作为SCL,PB7作为SDA。
第一步:配置GPIO为开漏输出
I2C总线要求能够“释放”线路,让外部上拉电阻将其拉高,因此必须使用开漏输出模式。
#define I2C_SDA_PIN GPIO_PIN_7 #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_PORT GPIOB void Software_I2C_Init(void) { __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = I2C_SDA_PIN | I2C_SCL_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull = GPIO_PULLUP; // 启用内部上拉(建议外接4.7kΩ) GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(I2C_PORT, &GPIO_InitStruct); // 初始状态:释放总线(均为高电平) HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_SET); }📌重点说明:
-GPIO_MODE_OUTPUT_OD是关键,确保引脚可以被拉低或浮空。
- 外部上拉电阻推荐使用4.7kΩ~10kΩ,若仅依赖内部弱上拉(约40kΩ),可能导致上升沿过缓,影响高速通信。
第二步:编写基础时序函数
微秒级延时函数
为了适配标准模式(100kHz)或快速模式(400kHz),我们需要精准的微秒延时。
void Software_I2C_Delay_us(uint32_t us) { uint32_t start = SysTick->VAL; uint32_t ticks = us * (SystemCoreClock / 1000000UL); while ((start - SysTick->VAL) % 0x00FFFFFF < ticks); }⚠️ 注意:此方法受SysTick重装载值影响,在实际项目中建议改用DWT或定时器实现更高精度。
起始信号生成
void Software_I2C_Start(void) { // 确保总线空闲 HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_SET); Software_I2C_Delay_us(5); HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_RESET); // SDA下降 Software_I2C_Delay_us(5); HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); // 拉低SCL准备发数据 }📌 关键点:SDA下降必须发生在SCL为高期间,否则会被识别为数据位而非起始信号。
停止信号生成
void Software_I2C_Stop(void) { HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_RESET); Software_I2C_Delay_us(5); HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_SET); // 先升SCL Software_I2C_Delay_us(5); HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_SET); // 再升SDA → 停止条件 Software_I2C_Delay_us(5); }📌 波形顺序不能错:SCL先高,SDA后高才是合法停止。
发送一字节并读取ACK
uint8_t Software_I2C_WriteByte(uint8_t data) { for (int i = 0; i < 8; i++) { HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); Software_I2C_Delay_us(2); if (data & 0x80) HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_SET); else HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_RESET); data <<= 1; Software_I2C_Delay_us(2); HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_SET); // 上升沿采样 Software_I2C_Delay_us(5); } // 读ACK HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_SET); // 释放SDA HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); Software_I2C_Delay_us(2); HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_SET); Software_I2C_Delay_us(5); uint8_t ack = HAL_GPIO_ReadPin(I2C_PORT, I2C_SDA_PIN); // 低电平为ACK HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); return ack; // 返回0表示收到ACK }📌 技巧:发送完8位后,主机要主动释放SDA,然后驱动SCL高电平去读取从机的回应。
接收一字节并发送ACK/NACK
uint8_t Software_I2C_ReadByte(uint8_t ack) { uint8_t data = 0; HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_SET); // 输入前释放SDA for (int i = 0; i < 8; i++) { data <<= 1; HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); Software_I2C_Delay_us(2); HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_SET); Software_I2C_Delay_us(5); if (HAL_GPIO_ReadPin(I2C_PORT, I2C_SDA_PIN)) data |= 0x01; } // 发送ACK/NACK HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); if (ack) HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_RESET); // 拉低表示ACK else HAL_GPIO_WritePin(I2C_PORT, I2C_SDA_PIN, GPIO_PIN_SET); // 释放表示NACK Software_I2C_Delay_us(2); HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_SET); Software_I2C_Delay_us(5); HAL_GPIO_WritePin(I2C_PORT, I2C_SCL_PIN, GPIO_PIN_RESET); return data; }📌 最后一个字节通常发NACK,通知从机结束传输。
第三步:封装高级通信接口
有了基本操作,就可以组合成完整的读写函数。
HAL_StatusTypeDef Software_I2C_Master_Transmit(uint8_t dev_addr, uint8_t *pData, uint16_t Size) { Software_I2C_Start(); if (Software_I2C_WriteByte((dev_addr << 1) | 0)) { // 写地址 Software_I2C_Stop(); return HAL_ERROR; } for (int i = 0; i < Size; i++) { if (Software_I2C_WriteByte(pData[i])) { Software_I2C_Stop(); return HAL_ERROR; } } Software_I2C_Stop(); return HAL_OK; } HAL_StatusTypeDef Software_I2C_Master_Receive(uint8_t dev_addr, uint8_t *pData, uint16_t Size) { Software_I2C_Start(); if (Software_I2C_WriteByte((dev_addr << 1) | 1)) { // 读地址 Software_I2C_Stop(); return HAL_ERROR; } for (int i = 0; i < Size - 1; i++) { pData[i] = Software_I2C_ReadByte(1); // 收到前N-1字节都发ACK } pData[Size - 1] = Software_I2C_ReadByte(0); // 最后一字节发NACK Software_I2C_Stop(); return HAL_OK; }✅ 这些API可以直接用来操作常见器件,例如:
- AT24C02 EEPROM:先写地址,再读数据
- BMP280/BME280:配置控制寄存器后读取测量值
- PCF8574 IO扩展芯片:写入高低电平或读取按键状态
高阶挑战:能做从机吗?
理论上是可以的,但难度陡增。
软件I2C通常只做主机,因为从机需要被动响应中断级事件,比如检测起始信号、实时应答、处理地址匹配等。而纯轮询方式很难满足严格的建立/保持时间要求。
不过在某些测试或仿真场景下,也可以尝试简单模拟:
// 轮询检测起始条件(简化版) while (1) { if (HAL_GPIO_ReadPin(I2C_PORT, I2C_SCL_PIN) && !HAL_GPIO_ReadPin(I2C_PORT, I2C_SDA_PIN)) { // 检测到起始条件!切换至从机模式... break; } }但这只是起点。真正实现稳定从机会涉及:
- 使用外部中断捕获SDA边沿
- 定时器中断同步时钟
- 关键段关闭全局中断防止打断
🔧 实际产品中,强烈建议使用硬件I2C模块处理从机功能。软件模拟更适合教学演示或临时调试。
工程实践中的那些“坑”
我在多个项目中使用软件I2C,总结出以下经验:
❌ 常见问题1:总是收到NACK
可能原因:
- 设备地址错误(注意7位地址左移一位)
- 上拉电阻太弱或缺失
- SDA/SCL接反
- 目标设备未供电或损坏
🔧 解法:用逻辑分析仪抓波形,看是否成功发出地址帧。
❌ 常见问题2:通信偶尔失败
根源往往是时序抖动:
- 中断打断了关键延时
- 编译器优化导致指令执行时间变化
- 系统负载过高
🔧 解法:
- 在__disable_irq()和__enable_irq()之间执行关键时序
- 使用更稳定的延时源(如DWT CYCCNT)
- 加入超时重试机制
✅ 最佳实践清单
| 项目 | 推荐做法 |
|---|---|
| 引脚选择 | 避免使用BOOT相关引脚,优先选非复用引脚 |
| 上拉电阻 | 外接4.7kΩ,不依赖内部弱上拉 |
| 延时精度 | 使用DWT或定时器替代SysTick |
| 代码结构 | 封装为独立模块(sw_i2c.c/h) |
| 移植性 | 所有引脚通过宏定义,便于更换平台 |
| 调试手段 | 必备逻辑分析仪或示波器 |
写在最后:掌握协议的本质,才能自由驾驭硬件
软件I2C看似是“退而求其次”的方案,实则是深入理解通信协议的一扇门。当你亲手写出每一个电平跳变,你会真正明白什么叫“建立时间”、“采样边沿”、“总线仲裁”。
更重要的是,这种能力赋予你极大的设计弹性。无论是快速原型验证、跨平台迁移,还是应对奇葩的PCB布局限制,你都能从容应对。
随着物联网终端越来越小型化,对外设接口的动态调配需求只会增加。未来结合RTOS任务调度与高精度计时,软件I2C甚至可以在轻量级系统中承担更多角色。
所以,别再只盯着CubeMX生成的硬件驱动了。试着关掉IDE,打开原理图,拿起笔,自己写一遍软件I2C吧。你会发现,原来底层世界如此清晰可控。
如果你正在做一个需要灵活通信的项目,不妨试试这条路。有任何疑问或踩过的坑,欢迎在评论区分享交流。