赣州市网站建设_网站建设公司_原型设计_seo优化
2025/12/28 10:23:18 网站建设 项目流程

从零实现STM32软件模拟I2C:深入底层时序的设计与实战

你有没有遇到过这样的场景?

明明硬件I2C配置得“天衣无缝”,却总在读取某个EEPROM时卡住;示波器一抓,发现ACK没回来——但换个芯片又好了。翻遍HAL库文档、重配时钟、调滤波……折腾半天,最后才发现是那颗国产传感器对SCL低电平时间太敏感,标准外设模块的内部状态机根本压不到足够长。

这时候,软件模拟I2C(Bit-Banging)就成了你的“救命稻草”。

本文不讲API调用,也不依赖CubeMX自动生成代码。我们要做的是:从最基础的GPIO操作开始,亲手捏出每一个上升沿和下降沿,完整实现一套可在STM32上运行的低速I2C通信协议栈。目标不仅是“能用”,更是让你彻底搞懂那些藏在数据手册里的时序参数到底意味着什么。


为什么需要“手搓”I2C?

I2C看起来简单:两根线、主从架构、地址寻址。可一旦进入实际工程,你会发现:

  • 某些老旧或非标设备要求低于100kHz的速率,而硬件I2C最小只能跑到几十kHz;
  • 多个I2C外设冲突,MCU自带接口不够用;
  • HAL库的HAL_I2C_Master_Transmit()莫名其妙超时,查不出原因;
  • 调试阶段想亲眼看看起始信号是不是真的符合规范。

这些问题背后,其实都指向一个事实:硬件I2C虽然高效,但不够透明、不够灵活

而软件模拟的方式,就像自己当司机,每一步踩油门还是刹车都由你说了算。尤其是在低速应用中(比如10~100kHz),MCU完全有余力通过GPIO精准控制每个电平跳变的时间点。

更重要的是——这是理解I2C本质的最佳路径


I2C协议的核心逻辑:不只是“发字节”

很多人以为I2C就是“发地址→写数据”或者“发地址→读数据”。但真正决定通信成败的,其实是那些微秒级的电平变化顺序。

总线结构决定了行为方式

I2C使用两条开漏输出线:
-SDA:串行数据
-SCL:串行时钟

所有设备只能将线路拉低或释放(高阻态),不能主动推高。因此必须外接上拉电阻(通常4.7kΩ~10kΩ)来确保空闲时为高电平。

🔍 小知识:如果你拔掉上拉电阻试试?总线永远卡在低电平,任何通信都无法启动。

这种设计允许多设备共享总线而不短路,但也带来了关键约束:任意设备都不能强行驱动高电平。我们写代码时要时刻记住这一点。


关键时序参数:别让“太快”毁了通信

你以为只要高低切换就行?错。I2C规范(NXP UM10204)对每个动作都有严格的时间窗口要求。

以标准模式(100kbps)为例,几个最关键的参数如下:

参数含义最小值
ThighSCL高电平持续时间4.0 μs
TlowSCL低电平持续时间4.7 μs
Tsu:sta起始信号建立时间(SDA下降前SCL需稳定为高)4.7 μs
Thd:sta起始信号保持时间(SDA变低后到SCL变低之间)4.0 μs

这些数值看着不大,但在72MHz主频下,相当于几百个指令周期。如果延时不精准,哪怕快了几百纳秒,某些从机可能直接无视你的起始信号。

所以,延时函数的质量直接决定通信稳定性


主控流程拆解:一步步走完一次通信

一次典型的I2C读操作,流程如下:

  1. 发送起始条件(Start)
  2. 发送从机地址 + 写标志(ADDR+W)
  3. 接收ACK
  4. 发送寄存器地址
  5. 再次发送起始(Repeated Start)
  6. 发送从机地址 + 读标志(ADDR+R)
  7. 接收ACK
  8. 逐字节读取数据
  9. 发送NACK(最后一字节)
  10. 发送停止条件(Stop)

其中,起始、停止、ACK/NACK是最容易出错的部分,也是我们必须手动精确控制的关键节点。


STM32上的实现细节:从GPIO到时序

我们选用最常见的STM32F103C8T6(Blue Pill板载芯片),使用PB6作为SCL,PB7作为SDA。

为什么不直接用HAL库的GPIO_WritePin?因为那个函数太慢!它包含参数检查、位带操作等额外开销。为了保证时序精度,我们需要更轻量的操作方式。

GPIO配置:手动操控CRH寄存器

// 引脚定义 #define I2C_SCL_GPIO GPIOB #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_GPIO GPIOB #define I2C_SDA_PIN GPIO_PIN_7 // 快速设置SDA方向:输出 or 输入 #define SDA_OUT() do { \ GPIOB->CRH &= ~0x0000000F; \ GPIOB->CRH |= 0x00000003; /* MODE7[1:0]=10 (最大2MHz输出), CNF7[1:0]=00 -> 推挽 */ \ } while(0) #define SDA_IN() do { \ GPIOB->CRH &= ~0x0000000F; \ GPIOB->CRH |= 0x00000008; /* 输入模式,CNF7[1:0]=10 -> 上拉输入 */ \ } while(0) // 电平操作宏(直接访问ODR) #define SET_SDA() (GPIOB->BSRR = GPIO_PIN_7) #define CLR_SDA() (GPIOB->BRR = GPIO_PIN_7) #define SET_SCL() (GPIOB->BSRR = GPIO_PIN_6) #define CLR_SCL() (GPIOB->BRR = GPIO_PIN_6) // 读取SDA状态 #define READ_SDA() ((GPIOB->IDR & GPIO_PIN_7) ? 1 : 0)

✅ 提示:这里用BSRRBRR寄存器实现单周期置位/复位,比HAL_GPIO_WritePin快得多。


延时函数:别被编译优化坑了

我们希望每次i2c_delay()大约延迟5μs,对应100kHz左右的速率。

static void i2c_delay(void) { uint32_t count = 800; while (count--) { __asm volatile ("nop"); } }

这个值怎么来的?假设系统时钟72MHz,每条NOP约13.9ns,800×13.9ns ≈ 11.1μs。等等,这不是超了吗?

别急——这是粗略估算。实际应结合示波器测量调整。你可以先写个空循环点亮LED,用逻辑分析仪测真实延时,再反推修正count值。

⚠️ 注意:开启-O2优化后,编译器可能会优化掉无意义的循环。加上volatile关键字防止误删。

更好的做法是使用DWT Cycle Counter(Cortex-M3及以上支持):

// 初始化DWT(仅需一次) CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; DWT->CYCCNT = 0; // 精确延时n微秒 static void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }

这样就能做到纳秒级可控,再也不怕编译器“自作聪明”。


实现核心信号:起始、停止、ACK

起始信号(Start Condition)

规则:SCL为高时,SDA由高→低

void i2c_start(void) { SDA_OUT(); // 确保SDA可输出 SET_SDA(); SET_SCL(); i2c_delay(); // 保证SCL高电平维持 > T_high CLR_SDA(); // SDA下降 → 起始信号 i2c_delay(); // 维持 > T_su:sta CLR_SCL(); // 开始传输数据 i2c_delay(); }

⚠️ 常见错误:忘记先拉高SCL就直接拉低SDA,会导致从机误判为数据位而非起始信号。


发送一个字节并等待ACK

uint8_t i2c_send_byte(uint8_t data) { uint8_t i; for (i = 0; i < 8; i++) { CLR_SCL(); // 先拉低时钟 if (data & 0x80) SET_SDA(); else CLR_SDA(); i2c_delay(); SET_SCL(); // 上升沿,从机采样 i2c_delay(); CLR_SCL(); // 下降沿,准备下一位 i2c_delay(); data <<= 1; } // 释放SDA,接收ACK SDA_IN(); SET_SDA(); // 释放总线(外部上拉拉高) i2c_delay(); SET_SCL(); i2c_delay(); uint8_t ack = !READ_SDA(); // 0表示ACK,1表示NACK CLR_SCL(); SDA_OUT(); // 恢复输出模式 return ack; // 返回是否收到ACK }

📌 关键点:
- 数据在SCL低电平时设置;
- SCL上升沿被从机采样;
- 发送完8位后,主设备释放SDA,由从机拉低表示确认(ACK)。


接收一个字节(可选ACK/NACK)

uint8_t i2c_read_byte(uint8_t ack) { uint8_t i, data = 0; SDA_IN(); for (i = 0; i < 8; i++) { data <<= 1; CLR_SCL(); i2c_delay(); SET_SCL(); i2c_delay(); if (READ_SDA()) { data |= 0x01; } } CLR_SCL(); // 发送ACK/NACK SDA_OUT(); if (ack) { CLR_SDA(); // ACK: 主机拉低SDA } else { SET_SDA(); // NACK: 释放,让SDA为高 } i2c_delay(); SET_SCL(); // SCL上升,从机采样ACK i2c_delay(); CLR_SCL(); return data; }

📌 特别注意:最后一次读取通常发NACK,通知从机“我不再要数据了”。


停止信号(Stop Condition)

规则:SCL为高时,SDA由低→高

void i2c_stop(void) { CLR_SCL(); SDA_OUT(); CLR_SDA(); i2c_delay(); SET_SCL(); // SCL先拉高 i2c_delay(); SET_SDA(); // SDA上升 → 停止信号 i2c_delay(); }

✅ 完整通信结束标志。之后总线进入空闲状态。


实战案例:读取AT24C02 EEPROM

假设我们要从地址0x05读一个字节:

uint8_t read_eeprom_byte(uint8_t dev_addr, uint8_t reg_addr) { uint8_t data; i2c_start(); if (!i2c_send_byte(dev_addr << 1)) { // 发送写地址 goto error; } if (!i2c_send_byte(reg_addr)) { // 发送寄存器地址 goto error; } i2c_start(); // 重复起始 if (!i2c_send_byte((dev_addr << 1) | 1)) { // 发送读地址 goto error; } data = i2c_read_byte(0); // 读取数据,发NACK i2c_stop(); return data; error: i2c_stop(); // 出错也要释放总线 return 0xFF; }

💡 使用技巧:
- 加入最多3次重试机制;
- 添加超时检测(可用SysTick计数);
- 启动时检测SDA/SCL是否能正常拉低,判断是否有设备占用总线。


常见问题与避坑指南

❌ 问题1:始终收不到ACK

可能原因
- 从机未供电或损坏;
- 地址错误(注意7位地址左移一位);
- 上拉电阻缺失或过大;
- SCL低电平时间不足(国产芯片尤其敏感)。

🔧 解法:
尝试延长i2c_delay()中的低电平部分,例如在CLR_SCL()后多加一次i2c_delay()


❌ 问题2:通信偶尔失败

可能原因
- 中断抢占导致时序断裂;
- RTOS任务切换打断关键段;
- 编译优化改变了循环次数。

🔧 解法:
在关键段临时关闭中断:

__disable_irq(); i2c_start(); i2c_send_byte(...); __enable_irq();

但注意时间不宜过长,避免影响系统实时性。


✅ 设计建议总结

项目建议
适用场景低速设备(≤100kHz)、调试、引脚受限、兼容性修复
不适用场景高速通信、低功耗模式、中断服务中
上拉电阻4.7kΩ常用,高速可降至2.2kΩ,注意功耗
引脚选择避免使用JTAG/SWD专用引脚(如PA13/14)
错误处理加入ACK超时、重试机制、总线恢复逻辑

结语:掌握底层,才能驾驭复杂

也许你会说:“现在都有DMA+硬件I2C了,还用手写bit-bang干嘛?”

答案是:当你面对一颗不守规矩的传感器、一段无法解释的通信故障、一个没有I2C外设可用的引脚布局时,正是这些“原始技能”救你于水火之中

而且你会发现,一旦亲手实现了完整的I2C时序,再回头看硬件模块的工作原理,一切都变得清晰起来。

下次遇到I2C问题,别急着换库、换板子、换芯片。试着拿起示波器,一步一步看电平变化——说不定,只是一个i2c_delay()写短了而已。

如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询