news 2026/3/27 5:39:39

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/3/26 2:40:14

UE4SS完全指南:从零开始掌握Unreal Engine游戏脚本开发

UE4SS完全指南&#xff1a;从零开始掌握Unreal Engine游戏脚本开发 【免费下载链接】RE-UE4SS Injectable LUA scripting system, SDK generator, live property editor and other dumping utilities for UE4/5 games 项目地址: https://gitcode.com/gh_mirrors/re/RE-UE4SS …

作者头像 李华
网站建设 2026/3/25 16:11:25

GPT-SoVITS文本与语音对齐(Alignment)质量提升

GPT-SoVITS文本与语音对齐质量提升 在当前个性化语音交互需求激增的背景下&#xff0c;用户不再满足于“能说话”的AI助手&#xff0c;而是期待一个音色熟悉、语调自然、表达有情感的声音伙伴。然而&#xff0c;传统文本到语音&#xff08;TTS&#xff09;系统往往依赖数百小时…

作者头像 李华
网站建设 2026/3/25 11:54:04

Mem Reduct内存优化终极指南:让老旧电脑焕发第二春

Mem Reduct内存优化终极指南&#xff1a;让老旧电脑焕发第二春 【免费下载链接】memreduct Lightweight real-time memory management application to monitor and clean system memory on your computer. 项目地址: https://gitcode.com/gh_mirrors/me/memreduct 还在为…

作者头像 李华
网站建设 2026/3/25 14:58:00

5个超实用技巧:让你的Mac鼠标滚动体验飞起来

5个超实用技巧&#xff1a;让你的Mac鼠标滚动体验飞起来 【免费下载链接】Mos 一个用于在 macOS 上平滑你的鼠标滚动效果或单独设置滚动方向的小工具, 让你的滚轮爽如触控板 | A lightweight tool used to smooth scrolling and set scroll direction independently for your m…

作者头像 李华
网站建设 2026/3/25 5:08:36

2025汤逊湖创新论坛举办 为江夏“十五五”发展聚势赋能

2025年12月23日&#xff0c;在“十四五”规划圆满收官、“十五五”蓝图徐徐展开的关键时点&#xff0c;由武汉市江夏区人民政府指导&#xff0c;江夏区经济信息化和科技创新局&#xff08;数据局&#xff09;、江夏区科技创新和人才服务中心、江夏区工商业联合会联合主办的“20…

作者头像 李华
网站建设 2026/3/26 4:07:41

用C语言实现“拼接最大数”:核心思路与代码解析

用C语言实现“拼接最大数”&#xff1a;核心思路与代码解析 在算法题中&#xff0c;“将一组数字拼接成最大整数”是经典的字符串排序类问题&#xff0c;比如给定数组[3,30,34,5,9]&#xff0c;需拼接出9534330这个最大数。本文将基于提供的C语言代码&#xff0c;拆解解题核心…

作者头像 李华