STM32软件模拟IIC驱动设计:基于HAL库的模块化实现与移植指南

📅 2026/7/31 13:35:05
STM32软件模拟IIC驱动设计:基于HAL库的模块化实现与移植指南
1. 项目缘起为什么我们需要软件模拟IIC在STM32的开发中IICInter-Integrated Circuit总线是连接各类传感器、EEPROM、RTC等外设的常用通信协议。STM32的HAL库和CubeMX工具链为我们提供了硬件IIC的配置和使用方法这看起来非常方便。然而在实际项目中硬件IIC常常会成为一个“坑点”。我遇到过不止一次硬件IIC在特定时序下出现锁死、通信失败或者与某些特定型号的从设备兼容性不佳的问题。尤其是在多主机、长线缆或者从设备响应较慢的场景下硬件IIC的鲁棒性有时不尽如人意。这时软件模拟IICSoftware IIC或Bit-Banging IIC就成了一个非常可靠的备选方案。它的核心思想很简单不使用MCU内置的专用IIC外设而是用两个普通的GPIO引脚通过程序精确控制其输出高低电平和读取输入状态来模拟出IIC协议要求的起始、停止、数据发送、应答等所有时序。这样做的好处是时序完全可控调试直观并且可以轻松移植到任何具有GPIO的MCU上不受硬件外设数量和引脚映射的限制。对于像STM32F103C8T6这类资源有限的“小蓝板”或者当硬件IIC引脚被其他功能占用时软件模拟IIC的价值就凸显出来了。因此一个封装良好、易于移植的软件模拟IIC驱动是很多STM32开发者工具箱里的必备品。今天要分享的就是基于HAL库和CubeMX工程框架实现的一个高度模块化的软件模拟IIC驱动。它的最大特点是“改改引脚即可直接使用”旨在解决移植繁琐、代码耦合度高的问题。2. 驱动设计核心如何实现“改引脚即可用”要实现“改改引脚即可直接使用”的目标关键在于驱动代码与具体的硬件引脚实现解耦。我们不能把GPIO端口和引脚编号这些硬件相关的信息硬编码在时序控制函数里。否则每次换一个引脚就需要去修改底层驱动代码既容易出错也破坏了代码的模块化。2.1 硬件抽象层使用结构体封装引脚信息我的解决方案是定义一个硬件抽象层。具体来说是创建一个结构体用来描述一组IIC总线所需要的所有硬件信息。// software_i2c.h typedef struct { GPIO_TypeDef *scl_port; // SCL时钟线所在的GPIO端口如GPIOA uint16_t scl_pin; // SCL时钟线对应的引脚编号如 GPIO_PIN_10 GPIO_TypeDef *sda_port; // SDA数据线所在的GPIO端口如GPIOB uint16_t sda_pin; // SDA数据线对应的引脚编号如 GPIO_PIN_11 } SoftwareI2C_HandleTypeDef;这个SoftwareI2C_HandleTypeDef结构体就是我们的“引脚配置容器”。它包含了SCL时钟线和SDA数据线分别对应的端口和引脚。驱动中的所有函数都将基于这个结构体指针来操作具体的引脚。2.2 引脚操作宏提升可读性与执行效率直接调用HAL库的HAL_GPIO_WritePin和HAL_GPIO_ReadPin函数当然可以但代码会显得冗长且频繁的函数调用会引入额外的开销。为了代码更清晰、执行更高效我们通常会用宏定义来封装最底层的引脚操作。// software_i2c.h /* 设置引脚为输出模式开漏输出并先置高*/ #define SOFT_I2C_SDA_OUT(_h) do { \ GPIO_InitTypeDef GPIO_InitStruct {0}; \ GPIO_InitStruct.Pin (_h)-sda_pin; \ GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; \ GPIO_InitStruct.Pull GPIO_PULLUP; \ GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; \ HAL_GPIO_Init((_h)-sda_port, GPIO_InitStruct); \ HAL_GPIO_WritePin((_h)-sda_port, (_h)-sda_pin, GPIO_PIN_SET); \ } while(0) /* 设置引脚为输入模式上拉输入*/ #define SOFT_I2C_SDA_IN(_h) do { \ GPIO_InitTypeDef GPIO_InitStruct {0}; \ GPIO_InitStruct.Pin (_h)-sda_pin; \ GPIO_InitStruct.Mode GPIO_MODE_INPUT; \ GPIO_InitStruct.Pull GPIO_PULLUP; \ HAL_GPIO_Init((_h)-sda_port, GPIO_InitStruct); \ } while(0) /* 快速读写引脚电平的宏 */ #define SOFT_I2C_SCL_H(_h) HAL_GPIO_WritePin((_h)-scl_port, (_h)-scl_pin, GPIO_PIN_SET) #define SOFT_I2C_SCL_L(_h) HAL_GPIO_WritePin((_h)-scl_port, (_h)-scl_pin, GPIO_PIN_RESET) #define SOFT_I2C_SDA_H(_h) HAL_GPIO_WritePin((_h)-sda_port, (_h)-sda_pin, GPIO_PIN_SET) #define SOFT_I2C_SDA_L(_h) HAL_GPIO_WritePin((_h)-sda_port, (_h)-sda_pin, GPIO_PIN_RESET) #define SOFT_I2C_SDA_READ(_h) HAL_GPIO_ReadPin((_h)-sda_port, (_h)-sda_pin)这里有几个关键点模式切换SDA线在IIC协议中需要在输出主机写数据和输入主机读数据、读应答之间切换。因此我们定义了SOFT_I2C_SDA_OUT和SOFT_I2C_SDA_IN两个宏它们通过重新初始化GPIO模式来实现切换。注意设置为输出时使用**开漏输出Output Open-Drain**模式这是IIC总线标准所要求的便于实现“线与”功能。上拉电阻初始化时都使能了内部上拉GPIO_PULLUP。IIC总线依靠上拉电阻将总线拉至高电平。如果外部已经接了上拉电阻通常4.7kΩ内部上拉可以关闭或保留但内部上拉电阻值较大约40kΩ在高速或长距离通信时可能力度不够建议外部预留位置。do { ... } while(0)这是一个常见的宏定义技巧目的是将多条语句安全地封装成一个宏使其在语法上像一个独立的语句避免在使用时因分号等问题产生错误。通过这样的设计驱动层的核心时序函数如I2C_StartI2C_SendByte里操作的就不再是具体的GPIOA, GPIO_PIN_0而是抽象的(_h)-scl_port和(_h)-scl_pin。当需要更换引脚时我们只需要在应用层重新定义一个SoftwareI2C_HandleTypeDef结构体变量并传入新的端口和引脚参数即可驱动代码无需任何改动。3. 从零构建软件IIC的时序实现细节理解了核心设计思想我们来看看如何用代码“画出”IIC的时序图。IIC协议有几个基本单元起始条件、停止条件、发送一个字节含应答位、接收一个字节含应答位。3.1 基础时序单元的实现首先我们需要微秒级的延时函数。软件IIC的时序靠延时来保证标准模式100kHz和快速模式400kHz对时序有严格要求。我们可以利用STM32的SysTick定时器或者一个基本的循环来实现一个soft_i2c_delay函数。这里为了简单假设我们有一个可用的Delay_us函数。// 简单的微秒延时函数实际项目中需根据系统时钟校准 static void soft_i2c_delay(void) { for(uint32_t i 0; i 10; i); // 此循环次数需根据实际主频调整 }起始条件START当SCL为高电平时SDA出现一个下降沿。void SOFT_I2C_Start(SoftwareI2C_HandleTypeDef *hi2c) { SOFT_I2C_SDA_OUT(hi2c); // 确保SDA为输出模式 SOFT_I2C_SDA_H(hi2c); SOFT_I2C_SCL_H(hi2c); soft_i2c_delay(); SOFT_I2C_SDA_L(hi2c); // SDA下降沿 soft_i2c_delay(); SOFT_I2C_SCL_L(hi2c); // 钳住SCL准备发送数据 soft_i2c_delay(); }注意起始条件前确保总线和引脚处于空闲状态SCL和SDA均为高。SOFT_I2C_SCL_L(hi2c)之后SCL被拉低标志着总线进入“数据有效”阶段此时可以改变SDA的数据。停止条件STOP当SCL为高电平时SDA出现一个上升沿。void SOFT_I2C_Stop(SoftwareI2C_HandleTypeDef *hi2c) { SOFT_I2C_SDA_OUT(hi2c); SOFT_I2C_SDA_L(hi2c); soft_i2c_delay(); SOFT_I2C_SCL_H(hi2c); soft_i2c_delay(); SOFT_I2C_SDA_H(hi2c); // SDA上升沿 soft_i2c_delay(); }发送一个字节含应答检测数据在SCL低电平时准备在SCL高电平时必须保持稳定。从最高位MSB开始发送。uint8_t SOFT_I2C_WriteByte(SoftwareI2C_HandleTypeDef *hi2c, uint8_t data) { uint8_t i, ack; SOFT_I2C_SDA_OUT(hi2c); for(i 0; i 8; i) { // 先根据数据位设置SDA电平 if(data 0x80) { SOFT_I2C_SDA_H(hi2c); } else { SOFT_I2C_SDA_L(hi2c); } data 1; // 左移准备下一位 soft_i2c_delay(); // 拉高SCL从设备在此时采样SDA SOFT_I2C_SCL_H(hi2c); soft_i2c_delay(); // 拉低SCL为下一个数据位做准备 SOFT_I2C_SCL_L(hi2c); soft_i2c_delay(); } // 释放SDA线切换为输入模式读取从设备的应答位 SOFT_I2C_SDA_IN(hi2c); SOFT_I2C_SCL_H(hi2c); soft_i2c_delay(); ack SOFT_I2C_SDA_READ(hi2c); // 读取ACK0为应答1为非应答 SOFT_I2C_SCL_L(hi2c); SOFT_I2C_SDA_OUT(hi2c); // 读完ACK切回输出模式为后续操作做准备 return ack; // 通常返回0表示成功收到ACK }接收一个字节含应答发送主机在SCL高电平时读取SDA读取完成后主机需要发送一个应答位ACK或非应答位NACK。uint8_t SOFT_I2C_ReadByte(SoftwareI2C_HandleTypeDef *hi2c, uint8_t ack) { uint8_t i, data 0; SOFT_I2C_SDA_IN(hi2c); // SDA设置为输入准备读取 for(i 0; i 8; i) { data 1; // 先左移 SOFT_I2C_SCL_H(hi2c); soft_i2c_delay(); if(SOFT_I2C_SDA_READ(hi2c)) { data | 0x01; // 读取SDA电平存入最低位 } SOFT_I2C_SCL_L(hi2c); soft_i2c_delay(); } // 读取完毕切换SDA为输出发送应答位 SOFT_I2C_SDA_OUT(hi2c); if(ack) { SOFT_I2C_SDA_H(hi2c); // 发送NACK (非应答) } else { SOFT_I2C_SDA_L(hi2c); // 发送ACK (应答) } soft_i2c_delay(); SOFT_I2C_SCL_H(hi2c); soft_i2c_delay(); SOFT_I2C_SCL_L(hi2c); SOFT_I2C_SDA_H(hi2c); // 释放SDA可选为后续停止条件做准备 return data; }3.2 组合成完整的读写函数有了这些基础单元我们就可以组合出针对具体设备的读写函数。例如向一个IIC EEPROM设备地址0xA0的指定地址写入一个字节uint8_t EEPROM_WriteByte(SoftwareI2C_HandleTypeDef *hi2c, uint16_t addr, uint8_t data) { SOFT_I2C_Start(hi2c); // 发送设备地址写 等待ACK if(SOFT_I2C_WriteByte(hi2c, 0xA0)) { SOFT_I2C_Stop(hi2c); return 1; // 设备无应答失败 } // 发送内存地址高字节假设是16位地址的EEPROM if(SOFT_I2C_WriteByte(hi2c, (addr 8) 0xFF)) { SOFT_I2C_Stop(hi2c); return 2; } // 发送内存地址低字节 if(SOFT_I2C_WriteByte(hi2c, addr 0xFF)) { SOFT_I2C_Stop(hi2c); return 3; } // 发送要写入的数据 if(SOFT_I2C_WriteByte(hi2c, data)) { SOFT_I2C_Stop(hi2c); return 4; } SOFT_I2C_Stop(hi2c); // 注意EEPROM写入需要页写时间此处需延时几毫秒 // HAL_Delay(5); return 0; // 成功 }4. 在CubeMX工程中的集成与使用步骤现在我们将这个驱动集成到一个标准的STM32CubeMX生成的HAL库工程中。目标是实现“改改引脚即可用”。4.1 CubeMX中的GPIO配置首先在STM32CubeMX中为你计划用作软件IIC的SCL和SDA引脚进行基本配置。关键点在于这里不配置为I2C外设模式而是配置为普通的GPIO输出模式。打开CubeMX加载你的芯片型号。在Pinout Configuration标签页找到你打算使用的两个GPIO引脚例如PB10和PB11。分别点击这两个引脚将其模式设置为GPIO_Output。更严谨的初始配置可以设置为Mode:Output Open Drain(开漏输出)Pull-up/Pull-down:Pull-up(上拉)Maximum output speed:High(高速)User Label: 可以命名为SOFT_I2C_SCL和SOFT_I2C_SDA方便在代码中识别。生成代码。CubeMX会在main.c的MX_GPIO_Init函数中生成这两个引脚的初始化代码。我们的软件IIC驱动会在运行时动态切换SDA的输入/输出模式所以这里的初始配置是合理的起点。4.2 将驱动文件加入工程将编写好的software_i2c.h和software_i2c.c文件添加到你的MDK-Keil或STM32CubeIDE工程中。software_i2c.h: 包含结构体定义、宏定义和函数声明。software_i2c.c: 包含所有时序函数的实现。确保在software_i2c.c中包含了对应的HAL头文件#include “stm32f1xx_hal.h”根据你的芯片系列调整。4.3 应用层实例化与使用现在到了最体现“改引脚即可用”的环节。在您的应用代码中例如main.c您只需要做两件事定义并初始化一个总线实例// 在文件顶部全局定义或作为局部变量 SoftwareI2C_HandleTypeDef hi2c1; // 在main函数初始化部分指定具体的引脚 hi2c1.scl_port GPIOB; hi2c1.scl_pin GPIO_PIN_10; hi2c1.sda_port GPIOB; hi2c1.sda_pin GPIO_PIN_11; // 可选调用一个初始化函数该函数可以确保总线初始状态为空闲SCL和SDA高电平 SOFT_I2C_Init(hi2c1);SOFT_I2C_Init函数可以非常简单void SOFT_I2C_Init(SoftwareI2C_HandleTypeDef *hi2c) { // 将SCL和SDA都设置为开漏输出并置高 GPIO_InitTypeDef GPIO_InitStruct {0}; // 初始化SCL GPIO_InitStruct.Pin hi2c-scl_pin; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(hi2c-scl_port, GPIO_InitStruct); HAL_GPIO_WritePin(hi2c-scl_port, hi2c-scl_pin, GPIO_PIN_SET); // 初始化SDA GPIO_InitStruct.Pin hi2c-sda_pin; HAL_GPIO_Init(hi2c-sda_port, GPIO_InitStruct); HAL_GPIO_WritePin(hi2c-sda_port, hi2c-sda_pin, GPIO_PIN_SET); }使用该实例进行通信// 读取某I2C温度传感器假设地址0x48的数据 uint8_t temp_data[2]; SOFT_I2C_Start(hi2c1); SOFT_I2C_WriteByte(hi2c1, 0x48 1); // 写地址 SOFT_I2C_WriteByte(hi2c1, 0x00); // 寄存器地址 SOFT_I2C_Start(hi2c1); // 重复起始条件 SOFT_I2C_WriteByte(hi2c1, (0x48 1) | 0x01); // 读地址 temp_data[0] SOFT_I2C_ReadByte(hi2c1, 0); // 读第一个字节发送ACK temp_data[1] SOFT_I2C_ReadByte(hi2c1, 1); // 读第二个字节发送NACK SOFT_I2C_Stop(hi2c1);当您需要更换引脚时例如从PB10/PB11换到PA8/PA9您只需要修改hi2c1结构体的四个成员赋值即可所有底层的驱动函数都无需改动。hi2c1.scl_port GPIOA; hi2c1.scl_pin GPIO_PIN_8; hi2c1.sda_port GPIOA; hi2c1.sda_pin GPIO_PIN_9;这就是“改改引脚即可直接使用”的全部含义。5. 实战中的调优与避坑指南一个能工作的基础驱动只是第一步要让它在实际项目中稳定可靠还需要考虑很多细节。以下是我在多个项目中总结出的经验和常见问题。5.1 延时函数的精准性通信速率的基石软件IIC的时序完全由延时函数soft_i2c_delay控制。这个延时不准轻则通信不稳定重则完全无法通信。问题直接用for循环做延时严重依赖编译器优化和CPU主频。换一个编译优化等级如从-O0到-O2或者改变系统时钟延时时间就会大变样。解决方案使用SysTick定时器这是最推荐的方法。利用HAL库提供的HAL_Delay毫秒级的基础通过读取SysTick的计数器SysTick-VAL来实现微秒级延时。可以封装一个DWT_Delay_us函数如果芯片支持Data Watchpoint Trace单元或者用定时器。使用一个基本定时器配置一个基本定时器如TIM6/TIM7使其每1微秒产生一次更新中断或直接查询计数器实现精准延时。校准循环延时如果非要用循环必须在已知的系统时钟下通过逻辑分析仪或示波器测量实际产生的延时反复调整循环次数并锁定编译优化等级。强烈不推荐用于产品。一个简单的基于SysTick的微秒延时参考假设系统主频为72MHzSysTick设置为1ms中断一次void delay_us(uint32_t us) { uint32_t ticks; uint32_t told, tnow, tcnt 0; uint32_t reload SysTick-LOAD; // SysTick重装载值 ticks us * 72; // 72MHz下1us需要72个周期 told SysTick-VAL; // 刚进入时的计数器值 while(1) { tnow SysTick-VAL; if(tnow ! told) { if(tnow told) { tcnt told - tnow; // 注意SysTick是递减计数器 } else { tcnt reload - tnow told; } told tnow; if(tcnt ticks) break; } } }5.2 总线仲裁与错误恢复增加鲁棒性硬件IIC有内置的错误检测和仲裁逻辑软件模拟则需要我们自己考虑。总线忙检测在发送起始条件前可以先检查总线是否空闲SCL和SDA都为高。如果不是可以等待一段时间或尝试发送一个停止条件来复位总线上的设备。uint8_t SOFT_I2C_IsBusy(SoftwareI2C_HandleTypeDef *hi2c) { GPIO_InitTypeDef GPIO_InitStruct {0}; // 临时将SCL和SDA都设置为输入上拉 GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Pin hi2c-scl_pin; HAL_GPIO_Init(hi2c-scl_port, GPIO_InitStruct); GPIO_InitStruct.Pin hi2c-sda_pin; HAL_GPIO_Init(hi2c-sda_port, GPIO_InitStruct); uint8_t busy (HAL_GPIO_ReadPin(hi2c-scl_port, hi2c-scl_pin) GPIO_PIN_RESET) || (HAL_GPIO_ReadPin(hi2c-sda_port, hi2c-sda_pin) GPIO_PIN_RESET); // 恢复引脚为初始输出状态 SOFT_I2C_Init(hi2c); return busy; }超时机制在等待从设备应答读取ACK或进行读写操作时加入超时判断避免程序因IIC设备无响应而卡死。#define I2C_TIMEOUT 1000 // 超时计数根据实际情况调整 uint8_t SOFT_I2C_WaitAck(SoftwareI2C_HandleTypeDef *hi2c) { uint32_t timeout 0; SOFT_I2C_SDA_IN(hi2c); SOFT_I2C_SCL_H(hi2c); soft_i2c_delay(); while(SOFT_I2C_SDA_READ(hi2c)) { // 等待SDA被从设备拉低 timeout; if(timeout I2C_TIMEOUT) { SOFT_I2C_SCL_L(hi2c); return 1; // 超时返回非应答 } } SOFT_I2C_SCL_L(hi2c); return 0; // 收到应答 }5.3 中断与多任务环境下的考量如果软件IIC操作发生在中断服务函数中或者在一个RTOS的多任务环境中被多个线程调用就需要考虑重入和互斥问题。关中断最简单的保护方式是在整个IIC序列从Start到Stop操作期间关闭全局中断。但这会影响系统实时性。__disable_irq(); // ... 执行IIC读写操作 ... __enable_irq();使用信号量RTOS下将IIC总线视为一个共享资源使用互斥信号量Mutex来保护。在操作IIC前获取信号量操作完成后释放。osMutexAcquire(i2c_mutex_id, osWaitForever); EEPROM_WriteByte(hi2c1, addr, data); osMutexRelease(i2c_mutex_id);5.4 上拉电阻的选择硬件保障软件模拟解决了程序问题但硬件设计同样重要。IIC总线的SCL和SDA线必须接上拉电阻到VCC通常阻值在4.7kΩ到10kΩ之间具体取决于总线电容和通信速度。高速通信400kHz或总线较长使用较小的上拉电阻如2.2kΩ以提供更强的拉高能力保证上升沿速度。低功耗应用使用较大的上拉电阻如10kΩ可以减少静态电流。STM32内部上拉如前所述STM32的GPIO内部上拉电阻较大约40kΩ在标准速度下可能勉强能用但在快速模式或带多个设备时强烈建议使用外部上拉电阻。在原理图上预留两个4.7kΩ的电阻位置是明智之举。6. 性能对比与适用场景分析最后我们来客观地看看软件模拟IIC的优缺点以及它最适合的应用场景。软件模拟IIC的优势极高的引脚灵活性这是最大的优点。可以在任意GPIO上实现完美解决硬件IIC引脚被占用或数量不足的问题。极强的兼容性与可控性时序完全由代码控制可以轻松适配那些不严格遵循IIC标准时序的“非标”从设备。调试时你可以单步跟踪清楚地看到每一个起始、停止、数据位的变化。避免硬件BUG彻底绕开了某些STM32型号硬件IIC可能存在的缺陷或复杂配置。代码透明易于理解对于学习IIC协议原理非常有帮助。软件模拟IIC的劣势CPU占用率高通信过程需要CPU持续参与进行位级别的延时和IO操作。在高速或大数据量传输时会明显消耗CPU资源。时序精度依赖CPU通信速率和稳定性受系统中断、其他任务的影响。在复杂的多任务或中断频繁的系统中时序可能被打断导致通信失败。实现完整的协议栈较复杂像时钟拉伸Clock Stretching、多主机仲裁等高级功能需要更复杂的代码来实现。适用场景推荐低速、间歇性通信如读取温湿度传感器SHT30、BME280、RTC时钟芯片DS3231、EEPROM24C02等这些设备通常每秒只读写几次数据量小。引脚资源紧张或需要灵活布线当硬件IIC引脚被SPI、UART等其他重要外设占用时。调试和学习阶段用于验证从设备是否工作或者深入理解IIC协议。驱动非标准IIC设备有些设备的时序比较特殊用硬件IIC很难配置软件模拟则可以灵活调整。不推荐场景高速、持续的数据流传输如通过IIC接口读取大量数据的图像传感器。对实时性要求极高的系统软件模拟的延时可能成为不确定性因素。需要多主机仲裁的复杂网络实现起来难度较大可靠性不易保证。总结来说这个基于HAL库和CubeMX的软件模拟IIC驱动通过结构体抽象引脚实现了与硬件解耦达到了“改引脚即可用”的便捷性。它是我在STM32开发中应对硬件IIC不便时的首选备用方案。在实际使用中请务必关注延时精度和总线竞争问题并根据具体应用场景权衡其优缺点。希望这份详细的实现与解析能帮助你更自如地在项目中使用软件IIC。