news 2026/4/28 1:05:07

STM32软件模拟I2C时序完整示例

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32软件模拟I2C时序完整示例

从零实现STM32软件模拟I2C:不只是“能用”,更要懂原理

在嵌入式开发的日常中,你是否遇到过这样的窘境?

项目快收尾了,突然发现要用的I2C接口已经被另一个传感器占用了;或者选型时图便宜用了个LQFP48封装的STM32F103,结果两个硬件I2C都不在你想用的引脚上。更糟的是,某些EEPROM或温湿度模块偏偏只认标准I2C时序,连重映射都救不了。

这时候,软件模拟I2C就成了那根“救命稻草”。

它不像硬件外设那样自带DMA和中断控制,但它足够灵活——只要有两个GPIO,就能把I2C“捏”出来。更重要的是,当你真正动手写一遍起始信号、逐位发送数据、等待ACK的时候,那些原本藏在HAL库背后的协议细节,才会真正属于你。

本文不讲套话,也不堆砌术语。我们将一起从最基础的GPIO操作开始,一步步构建一个稳定、可移植、符合规范的软件模拟I2C驱动,并深入剖析每一个关键设计背后的“为什么”。


I2C到底是什么?别再只会背“两根线”了

很多人说起I2C,第一反应是:“哦,SDA和SCL嘛。”
但如果你真这么理解,调试时遇到NACK、总线锁死、波形畸变,基本只能靠猜。

协议的本质:同步 + 半双工 + 主从仲裁

I2C不是简单的串口翻版。它的核心设计哲学是:用最少的引脚实现多设备通信。为此,它引入了几项关键机制:

  • 开漏输出 + 上拉电阻:所有设备的SDA/SCL都是“能拉低,不能主动推高”。空闲时靠电阻上拉到高电平,任一设备想发低电平就直接接地。这就是所谓的“线与”逻辑。
  • 主控节奏(SCL由主机驱动):整个通信节奏由主机通过SCL控制,从机只能在指定时刻采样SDA。
  • 边沿触发数据切换:数据在SCL为低时改变,在SCL上升沿被采样——这避免了建立/保持时间冲突。
  • 应答机制(ACK/NACK):每传完一个字节,接收方必须在第9个时钟周期拉低SDA表示收到。否则就是NACK,常用于地址不存在或读取结束。

这些看似琐碎的规定,其实都在解决同一个问题:如何让多个设备安全共享同一对信号线而不打架?


为什么需要软件模拟?硬件I2C不好吗?

STM32几乎每款芯片都带至少一个硬件I2C控制器,那我们为何还要手动“比特 banging”?

硬件I2C的三大痛点

  1. 引脚固定,不够灵活
    比如STM32F103C8T6只有PB6/PB7支持I2C1,如果你这两个脚已经接了LED或按键,那就只能换方案。

  2. 兼容性差,尤其老版本IP核
    STM32早期的I2C外设有个臭名昭著的问题:总线异常后无法恢复。一旦SCL被意外拉低,整个I2C模块可能锁死,必须复位才能恢复。

  3. 调试困难,黑盒感强
    当你调用HAL_I2C_Master_Transmit()失败时,你知道是起始条件没产生?还是没收到ACK?还是从机忙?很难定位。

而软件模拟I2C,每一行代码对应一个电平变化,配合逻辑分析仪,你可以清楚看到:

“啊!原来是我在SCL还高的时候就改了SDA,违反了tHD:DAT!”

这种透明性,对于学习和排错来说,价值千金。


如何正确模拟I2C时序?别再瞎写延时了!

很多网上的“模拟I2C”代码长得像这样:

void delay_us(int n) { while(n--) for(int i=0;i<100;i++); }

然后每个操作后面跟几个delay_us(5);。问题是:这个“100”哪来的?主频变了怎么办?不同芯片执行速度一样吗?

要写出可靠的模拟I2C,我们必须回到源头——看时序参数表

关键时序参数(标准模式,100kbps)

参数含义最小值
tLOWSCL低电平时间4.7μs
tHIGHSCL高电平时间4.0μs
tSU:STA起始信号建立时间(SDA下降前SCL须高)4.7μs
tHD:STA起始信号保持时间(SDA下降后SCL仍高)4.0μs
tSU:DAT数据建立时间(SCL上升前沿前数据稳定)250ns
tHD:DAT数据保持时间(SCL上升沿后数据维持)0ns(建议≥100ns)

来源:I2C官方规范 Rev.6 (2014)

这意味着什么?

  • 你的延时函数精度至少要达到微秒级
  • 在发送每一位时,必须先设置SDA,等够tSU:DAT再拉高SCL;
  • SCL拉低后,可以立即准备下一位数据;
  • 起始/停止条件对时序要求更严格,不能随便跳变。

实战代码详解:从GPIO配置到完整通信

下面这段代码适用于STM32F1系列,但思想可迁移到任何Cortex-M平台。

GPIO怎么配?开漏才是正道

#define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_GPIO_PORT GPIOB void i2c_gpio_init(void) { RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = I2C_SCL_PIN | I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // ← 开漏输出! GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStructure); // 初始状态:释放总线(相当于上拉) GPIO_SetBits(I2C_GPIO_PORT, I2C_SCL_PIN | I2C_SDA_PIN); }

注意这里使用的是GPIO_Mode_Out_OD—— 输出开漏模式。这意味着:

  • 1→ 引脚变为高阻态,外部上拉决定电平;
  • 0→ 引脚接地,强制拉低。

这才是真正的I2C电气特性模拟。如果误设为推挽输出,当另一个设备也在拉低时,就会形成电源到地的短路路径,轻则干扰,重则烧毁IO。


宏定义的艺术:高效控制电平切换

为了提升性能并减少函数调用开销,我们用宏来操作SCL和SDA:

// SCL控制 #define SCL_H() { I2C_GPIO_PORT->BSRR = I2C_SCL_PIN; } // 置1 → 高阻(上拉) #define SCL_L() { I2C_GPIO_PORT->BRR = I2C_SCL_PIN; } // 置0 → 拉低 // SDA控制 #define SDA_H() { I2C_GPIO_PORT->BSRR = I2C_SDA_PIN; } #define SDA_L() { I2C_GPIO_PORT->BRR = I2C_SDA_PIN; } // 读SDA状态 #define READ_SDA ((I2C_GPIO_PORT->IDR & I2C_SDA_PIN) ? 1 : 0)

这里利用了STM32的BSRR/BRR寄存器
-BSRR写1置位,写0无效;
-BRR写1清零,写0无效;
两者均为原子操作,无需读-改-写,速度快且线程安全。


延时函数:别再死循环凑数了

static void i2c_delay(void) { uint32_t delay = (SystemCoreClock / 1000000) * 4; // ~4μs @ 72MHz while (delay--) { __NOP(); // 加入空指令,防止被编译器优化掉 } }

这个延时约等于4μs,在72MHz系统时钟下适用。你可以根据实际频率调整乘数。例如:

  • 8MHz系统?改为(SystemCoreClock / 1000000) * 5得到5μs;
  • 或者更精确地计算每微秒多少个循环。

⚠️ 提示:若系统开启了编译优化(-O2),while(--delay);可能被完全删除!务必加入__NOP()或声明volatile变量。


起始条件:最容易出错的地方

void i2c_start(void) { SDA_H(); SCL_H(); // 确保总线空闲 i2c_delay(); SDA_L(); // SDA下降,SCL仍高 → Start! i2c_delay(); SCL_L(); // 拉低SCL,进入数据传输阶段 i2c_delay(); }

关键点在于顺序:

  1. 先保证SCL和SDA都是高(总线空闲);
  2. 然后SDA由高→低(这是起始标志);
  3. 最后再拉低SCL,准备发第一个bit。

如果颠倒顺序,比如先拉低SCL再动SDA,那就不是Start,而是普通数据变化,从机会完全无视。


发送一个字节 + 接收ACK

uint8_t i2c_send_byte(uint8_t byte) { uint8_t i; for (i = 0; i < 8; i++) { if (byte & 0x80) { SDA_H(); // 发送高位 } else { SDA_L(); } i2c_delay(); // 保证建立时间 ≥250ns SCL_H(); // 上升沿采样 i2c_delay(); SCL_L(); // 下降沿切换数据 byte <<= 1; // 左移下一位 } // 接收ACK:释放SDA,读第9个脉冲 SDA_H(); i2c_delay(); SCL_H(); i2c_delay(); uint8_t ack = READ_SDA; // 0 = ACK, 1 = NACK SCL_L(); return ack; }

重点说明:

  • 数据是高位先行
  • 在SCL为低时设置SDA,确保上升沿时数据已稳定;
  • 第9个时钟周期,主机释放SDA(设为输入/高阻),让从机有机会拉低表示ACK;
  • 若返回0,说明收到ACK;非零则为NACK。

接收字节:谁来发ACK?

uint8_t i2c_read_byte(uint8_t ack) { uint8_t i, byte = 0; SDA_H(); // 释放数据线,允许从机输出 for (i = 0; i < 8; i++) { i2c_delay(); SCL_H(); // 上升沿采样 i2c_delay(); byte <<= 1; if (READ_SDA) byte |= 1; SCL_L(); // 下降沿后可更新数据 } // 主机决定是否继续接收 if (ack) { SDA_L(); // 发ACK:拉低SDA } else { SDA_H(); // 发NACK:释放SDA } i2c_delay(); SCL_H(); // 第9个时钟 i2c_delay(); SCL_L(); SDA_H(); // 释放总线 return byte; }

接收时,主机必须在每个字节后明确告知是否继续:

  • 如果还会读更多字节,发ACK
  • 如果是最后一个字节,发NACK,通知从机停止发送。

这是很多初学者忽略的关键点。


实际应用:如何用这套代码读写AT24C02?

以最常见的EEPROM AT24C02为例,演示一次写操作流程:

void at24c02_write_byte(uint8_t addr, uint8_t data) { i2c_start(); i2c_send_byte(0xA0); // 设备地址+写 (0b10100000) i2c_send_byte(addr); // 内部地址 i2c_send_byte(data); // 写入数据 i2c_stop(); // EEPROM内部写入需要时间,必须延时 Delay_ms(5); }

读操作稍复杂,需两次传输:

uint8_t at24c02_read_byte(uint8_t addr) { uint8_t data; i2c_start(); i2c_send_byte(0xA0); // 写模式 i2c_send_byte(addr); // 发送地址 i2c_start(); // 重复起始 i2c_send_byte(0xA1); // 读模式 (0b10100001) data = i2c_read_byte(0); // 读一字节,发NACK i2c_stop(); return data; }

注意中间那个i2c_start()—— 这叫重复起始(Repeated Start),用来切换读写方向而不释放总线,防止其他主机抢占。


常见坑点与避坑指南

❌ 坑1:总线卡死,SDA一直为低

原因可能是:

  • 某次通信未正常结束(缺少i2c_stop());
  • 从机故障,持续拉低SDA;
  • GPIO模式错误导致强推高与强拉低冲突。

✅ 解法:

  • 检查每次通信是否都有匹配的start/stop;
  • 添加总线恢复机制:连续发送9个时钟脉冲(SCL翻转9次),迫使从机释放总线;
  • 必要时硬件复位从设备。

❌ 坑2:总是收到NACK

可能原因:

  • 从机地址错误(注意7位地址左移!常见错误把0x50当作地址,实际应为0xA0写 / 0xA1读);
  • 从机未供电或未连接;
  • 上拉电阻太大或太小;
  • 时序太快,从机来不及响应。

✅ 解法:

  • 用逻辑分析仪确认发送的地址是否正确;
  • 测量VCC和GND是否正常;
  • 更换4.7kΩ上拉电阻;
  • 适当增加延时。

❌ 坑3:中断打断导致时序错乱

在RTOS或多任务环境中,如果在发送中途发生中断,可能导致SCL长时间为低,触发从机超时保护。

✅ 解法:

  • 在关键段落禁用中断(慎用);
  • 或将I2C操作封装为互斥资源(如FreeRTOS中的mutex);
  • 使用定时器+状态机方式替代延时循环,提高实时性。

性能与适用场景权衡

软件模拟I2C当然有代价:

项目软件模拟硬件I2C
CPU占用高(全程轮询)低(DMA+中断)
最高速率~100kbps(可靠)支持Fast Mode+(1Mbps)
中断容忍度
引脚灵活性极高固定
调试难度低(可见性强)高(依赖工具)

所以建议:

  • 适合场景:低速传感器读取(如温湿度、光照)、EEPROM、RTC、OLED屏;
  • 不适合场景:音频流、高速ADC采样、视频传输等持续大数据量通信。

结语:掌握底层,才能超越框架

今天我们不仅实现了一套可用的软件模拟I2C代码,更重要的是搞明白了:

  • 为什么必须先SCL高再SDA降才能算Start?
  • 为什么ACK要在第9个时钟发出?
  • 为什么要用开漏输出?
  • 如何根据时序参数设计延时?

这些知识不会让你立刻写出更快的代码,但在某天凌晨三点面对一块“死总线”时,你会感谢现在认真读过的每一行解释。

下次当你再看到HAL_I2C_Master_Transmit(),不妨停下来想想:
它背后,是不是也经历了同样的起始、发送、等待ACK、停止的过程?

技术没有高低,只有理解深浅。愿你在嵌入式的世界里,永远不只是“调用API的人”,而是“知道API为何存在”的那个人。

如果你正在做STM32项目,欢迎把这套代码拿去用。也欢迎在评论区分享你在I2C调试中踩过的坑,我们一起填平它们。

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

Keil4安装详细流程:入门级讲解

从零搭建Keil4开发环境&#xff1a;一次成功的安装与调试实战指南 你是不是也曾在搜索“keil4安装教程”时&#xff0c;被一堆残缺不全、步骤跳跃的博客搞得焦头烂额&#xff1f;点了半天注册机生成LIC&#xff0c;结果一启动软件就闪退&#xff1b;明明插了ST-Link&#xff0…

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

38、时变系统框架:综合与分析

时变系统框架:综合与分析 1. 多维系统的平衡截断模型降阶 在多维系统中,对平衡稳定的 NMD 系统实现进行截断,会得到一个低维的平衡稳定实现。这可以通过考虑系统的 Lyapunov 不等式轻松看出。下面给出多维系统的平衡截断模型降阶误差界定理。 - 定理 :假设 $(A_r; B_r…

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

GPT-SoVITS与传统TTS对比:优势究竟在哪里?

GPT-SoVITS与传统TTS对比&#xff1a;优势究竟在哪里&#xff1f; 在AI语音技术飞速发展的今天&#xff0c;我们已经不再满足于“能说话”的机器声音。无论是短视频中的虚拟主播、有声书里的定制旁白&#xff0c;还是智能客服中带有情感的回应&#xff0c;用户对语音自然度和个…

作者头像 李华
网站建设 2026/4/25 20:35:14

基于微信小程序的私房菜定制上门服务系统(源码+论文+部署+安装)

感兴趣的可以先收藏起来&#xff0c;还有在毕设选题&#xff0c;项目以及论文编写等相关问题都可以给我留言咨询&#xff0c;我会一一回复&#xff0c;希望可以帮到大家。一、程序背景随着人们生活水平提升&#xff0c;对餐饮的个性化需求日益增长&#xff0c;私房菜定制上门服…

作者头像 李华
网站建设 2026/4/17 22:05:11

GPT-SoVITS + GPU加速:实现毫秒级语音合成响应

GPT-SoVITS GPU加速&#xff1a;实现毫秒级语音合成响应 在智能客服频繁掉线、虚拟主播声音生硬的今天&#xff0c;用户对“像人一样说话”的AI语音系统提出了更高要求——不仅要音色自然&#xff0c;还得反应迅速。传统的语音合成方案往往陷入两难&#xff1a;要么依赖数小时…

作者头像 李华