news 2026/4/15 14:26:31

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/11 22:42:42

GPT-SoVITS英文单词发音纠正方法

GPT-SoVITS英文单词发音纠正方法 在语言学习的数字化浪潮中&#xff0c;一个长期存在的难题始终困扰着学习者&#xff1a;如何获得即时、精准且个性化的发音反馈&#xff1f;传统的英语教学依赖教师一对一点评&#xff0c;效率低、覆盖有限&#xff1b;而早期语音识别系统又往往…

作者头像 李华
网站建设 2026/4/13 1:51:11

18、Go Web服务与单元测试全解析

Go Web服务与单元测试全解析 1. Go Web服务 1.1 删除文章的Web服务 在Go中,通过Web服务删除文章的操作相对简单,主要是获取文章并调用删除方法。以下是实现该功能的代码: func handleDelete(w http.ResponseWriter, r *http.Request) (err error) {id, err := strconv.…

作者头像 李华
网站建设 2026/4/13 7:03:56

科研数据智能分析平台:重新定义国家自然科学基金数据洞察力

科研数据智能分析平台&#xff1a;重新定义国家自然科学基金数据洞察力 【免费下载链接】nsfc 国家自然科学基金查询 项目地址: https://gitcode.com/gh_mirrors/nsf/nsfc 在科研项目申报和学术趋势分析中&#xff0c;你是否曾为获取准确、全面的国家自然科学基金数据而…

作者头像 李华
网站建设 2026/4/10 17:25:55

23、并发 Web 应用与 Go 语言部署实践

并发 Web 应用与 Go 语言部署实践 在开发 Web 应用时,性能优化和高效部署是至关重要的两个方面。下面我们将探讨如何创建并发的照片马赛克 Web 应用,以及如何将 Go 语言编写的 Web 应用部署到不同的环境中。 并发照片马赛克 Web 应用 并发编程在提高性能方面有着广泛的应用…

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

Ncorr安装实战:数字图像相关软件的高效配置与性能优化

Ncorr安装实战&#xff1a;数字图像相关软件的高效配置与性能优化 【免费下载链接】ncorr_2D_matlab 2D Digital Image Correlation Matlab Software 项目地址: https://gitcode.com/gh_mirrors/nc/ncorr_2D_matlab Ncorr 2D DIC软件作为材料力学和结构工程领域的重要测…

作者头像 李华