news 2026/4/16 15:20:50

STM32软件I2C模拟流程:图解说明时序逻辑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32软件I2C模拟流程:图解说明时序逻辑

深入理解STM32软件I2C:从时序逻辑到实战代码的完整拆解

你有没有遇到过这种情况:项目中明明有两个I2C外设,但其中一个被EEPROM占了,另一个又连着OLED,这时候突然要加一个温湿度传感器——引脚不够用了怎么办?

或者更糟心的是,硬件I2C莫名其妙“死锁”,状态寄存器卡在BUSY不放,复位都无效?

别急。今天我们就来聊一个嵌入式开发里的“老手艺”——软件I2C(也叫GPIO模拟I2C)。它不像硬件I2C那样“高大上”,但它足够灵活、足够稳定,尤其适合那些资源紧张、调试复杂的小型化系统。

更重要的是:搞懂软件I2C,你就真正看穿了I2C协议的本质


为什么还要用软件I2C?硬件不是更好吗?

确实,STM32几乎每款芯片都集成了至少一两个I2C控制器。那为啥还要手动去翻GPIO、写延时、一位位发数据?

答案是:现实开发没那么理想

硬件I2C的三大痛点

  1. 资源有限
    很多小封装MCU只有1~2个I2C接口,而现代物联网设备动辄连接四五种I2C器件(传感器、触控、RTC、显示屏……),根本不够分。

  2. 引脚受限
    并非所有GPIO都能复用为I2C功能。有些引脚没有AF功能,或者PCB布局时已经占用,没法改。

  3. 稳定性问题
    特别是在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的数据,流程如下:

  1. i2c_start()
  2. 发送写地址:0x88(即0x44 << 1 | 0
  3. 检查ACK
  4. 发送命令:0x2C,0x06(启动周期测量)
  5. i2c_start()(重复起始)
  6. 发送读地址:0x89
  7. 读6字节数据(前2字节温度,中间2字节湿度,最后2字节CRC)
  8. 每次读完发ACK,最后一次发NACK
  9. 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失败、数据错乱等问题,欢迎在评论区留言讨论,我们可以一起分析波形、排查时序。毕竟,每一个嵌入式工程师,都是从“拉高低低”中成长起来的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/16 13:05:22

EhSyringe:让E站彻底告别语言障碍的中文翻译神器

EhSyringe&#xff1a;让E站彻底告别语言障碍的中文翻译神器 【免费下载链接】EhSyringe E 站注射器&#xff0c;将中文翻译注入到 E 站体内 项目地址: https://gitcode.com/gh_mirrors/eh/EhSyringe EhSyringe是一款专为E-Hentai网站设计的开源翻译工具&#xff0c;能够…

作者头像 李华
网站建设 2026/4/16 13:08:37

GPT-SoVITS语调曲线编辑可能性探讨

GPT-SoVITS语调曲线编辑可能性探讨 在语音合成技术飞速发展的今天&#xff0c;我们早已不再满足于“能说话”的机器。用户开始追求更细腻的情感表达、更具个性化的语气风格&#xff0c;甚至希望像编辑图像一样&#xff0c;对语音的语调曲线进行可视化调整——这正是当前TTS系统…

作者头像 李华
网站建设 2026/4/16 12:44:10

国家自然科学基金数据查询工具:科研工作者的智能助手

国家自然科学基金数据查询工具&#xff1a;科研工作者的智能助手 【免费下载链接】nsfc 国家自然科学基金查询 项目地址: https://gitcode.com/gh_mirrors/nsf/nsfc 国家自然科学基金数据查询工具是一款专为科研工作者设计的智能查询系统&#xff0c;能够帮助用户快速获…

作者头像 李华
网站建设 2026/4/15 14:55:40

17、使用Go解析和创建XML与JSON及构建Web服务

使用Go解析和创建XML与JSON及构建Web服务 1. 使用Go解析和创建XML 1.1 解析XML 在拥有解码器后,可使用 Token 方法获取XML流中的下一个标记(token)。标记是表示XML元素的接口。我们要持续从解码器中获取标记,直到没有更多标记为止。可以使用一个无限 for 循环来实现这…

作者头像 李华
网站建设 2026/4/16 11:15:36

YimMenu游戏增强深度体验:从功能扩展到进阶玩法

YimMenu游戏增强深度体验&#xff1a;从功能扩展到进阶玩法 【免费下载链接】YimMenu YimMenu, a GTA V menu protecting against a wide ranges of the public crashes and improving the overall experience. 项目地址: https://gitcode.com/GitHub_Trending/yi/YimMenu …

作者头像 李华
网站建设 2026/4/13 14:12:08

解决Keil5MDK安装常见错误:针对ARM Cortex-M开发的避坑指南

Keil5MDK安装踩坑实录&#xff1a;给ARM Cortex-M开发者的血泪避雷手册你有没有经历过这样的场景&#xff1f;刚拿到一块崭新的STM32开发板&#xff0c;满心欢喜地打开Keil官网下载最新版MDK&#xff0c;结果安装到一半弹出“Error writing to file”&#xff1b;好不容易装上了…

作者头像 李华