STM32软件模拟I2C从机:GPIO中断实现与调试实战
发布时间:2026/9/3 19:47:45 锦皓数字建站

简介面向嵌入式开发者的STM32 I2C从机固件实现资源基于标准外设库采用中断驱动方式处理数据收发适合需要实现或调试I2C从机通信的开发者。内容涵盖I2C协议基础、STM32硬件接口配置、从机地址设置、中断服务例程、错误处理与调试优化等关键环节并详细说明了如何通过库函数配置时钟、GPIO复用及中断优先级工程默认基于STM32F10x系列可直接导入Keil编译运行。压缩包共386个文件包含C/H源码、Keil工程文件、编译生成的hex/axf固件以及map、lst、crf等编译辅助文件整体约10.09MB便于查看和移植。已有1771人学习下载该工程展示了从初始化到中断收发的完整流程可帮助读者快速搭建I2C从机通信框架并通过观察源码与固件输出深化对协议时序的理解。对于正在排查总线冲突、时序异常或移植I2C从机驱动的场景工程中的配置与中断处理代码也能提供直接参考。 手里有个STM32的小项目需要在板子和上位机之间做双向通信外设接口不够用只剩两个空闲GPIO正好把I2C从机用软件模拟的方式做出来。这个固件I2C从机听起来不起眼实际调试过程中坑不少写出来给同样在这条路上折腾的人一点参考。所谓固件I2C就是用GPIO加上定时器或外部中断完全用软件把I2C总线时序模拟出来不依赖芯片自带的硬件I2C外设。这次做的是从机方向也就是STM32被动响应主机的读写请求而不是主动去读传感器。适用场景很明确硬件I2C外设不够用、引脚被其他功能占用、或者你需要在两个MCU之间搭一条轻量通信链路时这个方案就能顶上。1. 为什么选固件I2C从机项目背景与整体思路1.1 什么时候不必用硬件I2C外设很多人的第一反应是STM32自带I2C外设为什么还要用软件模拟这个疑问很正常我刚开始也这么想直到被硬件I2C的状态机折腾到怀疑人生。STM32的硬件I2C外设功能是完整的但有几类场景会让你特别难受。一是引脚复用冲突比如某个I2C外设的SCL/SDA被其他功能占用了你又不想改板子二是同时需要两路I2C一路挂在传感器上一路连接主机另一路可能就得靠模拟三是硬件I2C在DMA配合、错误标志处理上逻辑比较复杂特别是从机模式下收到NACK、总线错误时标志位容易卡住处理不好直接死等。软件模拟的优势在于完全可控。每个时钟沿、每个数据位都是自己推的协议走到哪一步心里清清楚楚。调试的时候可以直接看GPIO波形和代码断点不依赖硬件外设内部那一堆状态寄存器。缺点是CPU占用高从机模式下中断响应要求高但对大多数低速控制类通信来说100kHz标准模式完全够用。1.2 从机侧模拟为什么比主机侧难如果你写过软件模拟I2C主机可能会觉得从机也没啥照着时序发就行。实际上两者难度完全不在一个量级。主机是被动发起方时钟是自己给的SCL翻转节奏完全由软件控制每一步操作都在自己的时间轴上出问题可以随时停下来调试。但从机不一样SCL是外部主机给的从机只能被动检测边沿事件在极短的时间内完成采样、状态判断、数据输出或读取任何一个环节慢半拍整个通信就错位了。我这次就是把SCL配成外部中断触发方式设为上升沿和下降沿同时触发在中断服务函数里根据当前状态机的阶段去采样SDA或切换SDA方向。这种方式可以做到最简单的同步但前提是中断响应必须足够快中断服务函数里不能有耗时操作比如HAL_Delay或者打印日志一个都不能放。这个从机模拟的设计目标很朴素支持7位地址匹配支持寄存器式读写速率跑100kHz标准模式通信稳定性至少要撑住连续数千次的读写不丢数据。下面的内容会围绕这一套实现展开。2. 核心原理拆解从机侧的I2C时序与状态机2.1 从机必须应答的四个关键节点I2C协议很多人都会背但站在从机的角度去看整个通信过程就是几个必须抓住的节点理解这些节点是写代码的基础。第一个是起始条件和停止条件。SCL高电平期间SDA从高到低是起始条件SDA从低到高是停止条件。从机必须检测到这两个条件才能知道通信什么时候开始、什么时候结束。因为是模拟实现这两个条件的检测通常在SCL中断里做不了要么用SDA的外部中断去捕捉要么在SCL边沿中断里顺带判断SDA的电平组合。第二个是地址匹配。起始条件之后主机发出一个字节高7位是从机地址最低位是读写方向位。从机收到这个字节后如果地址匹配就需要在第9个时钟周期拉低SDA作为ACK应答不匹配则保持高电平。这次我分配的从机地址是0x32也就是7位地址0x19左移一位的结果具体地址值前后逻辑一定要搞清楚。第三个是数据位的8个时钟周期。每个字节数据的传输是MSB在前从机在SCL高电平期间读取SDA电平把8个bit拼成一个字节然后在第9个时钟周期输出ACK。第四个是读写方向切换。主机发送的地址字节最低位如果是0表示主机接下来要写从机从机接收数据如果是1表示主机要读从机从机需要根据之前主机写入的寄存器地址来输出数据。这个方向切换直接影响SDA引脚的方向配置处理不好就会出现总线冲突。2.2 寄存器式通信设计把从机变成一个可读写的寄存器文件为了让这个从机真正实用我给它设计了寄存器式访问模型。也就是说主机通过I2C读写从机器内部定义好的一组寄存器每个寄存器有固定的地址、长度和读写属性。这样一来上层逻辑不用关心底层字节流怎么传输只需要定义好寄存器表让从机像一个小存储文件一样被操作。举个例子我在这块板子上定义了这么几个寄存器寄存器地址名称读写属性说明0x00DEV_ID只读设备标识固定0xA50x01CTRL读写控制寄存器控制板载LED0x02STATUS只读状态寄存器上报工作状态0x10DATA_L读写数据低字节0x11DATA_H读写数据高字节在这种模型下从机需要维护一个状态变量记录当前处于哪个寄存器地址以及访问序号。主机写操作时第一个字节是寄存器地址从机记录地址值之后的字节依次写入该地址和后续地址主机读操作时第一个字节同样是寄存器地址从机记录地址主机发出重复起始条件后切换为读方向此后从机每次被读一个字节数据都从当前寄存器地址取出然后地址自增。这套模型的好处在于通用性很强。上位机通过USB转I2C适配器或者另一个MCU只要按照寄存器表操作就能和板子对接改功能时只需要调整寄存器定义不需要改动底层传输逻辑。3. 实操实现GPIO中断方案与关键代码3.1 硬件连接与GPIO初始化先看硬件部分。软件模拟I2C对GPIO的要求比较明确我选的是芯片上两个普通IO口分别作为SCL和SDA。这里有一个绝对不能偷懒的点两个引脚都必须配置为开漏输出模式同时外部加上拉电阻阻值选4.7kΩ到10kΩ之间。开漏输出的意义在于总线上的低电平是靠管子拉低的高电平是靠上拉电阻提供的这样多个设备才能安全共享总线不会出现推挽输出时一个拉高一个拉低的电源短路。我还额外加了一个示波器探针接口用来看SCL和SDA的实际波形。调I2C模拟从机示波器是刚需没有示波器纯粹靠猜排查问题的效率极低。逻辑分析仪也可以只要能看时序越便宜越好用。GPIO初始化代码大致如下用HAL库写的底层结构很清晰void I2C_Slave_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); // SCL - PB6, SDA - PB7, 开漏输出, 上拉 GPIO_InitStruct.Pin GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_VERY_HIGH; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); // SCL 外部中断, 上升沿下降沿, 优先级要高 GPIO_InitStruct.Pin GPIO_PIN_6; GPIO_InitStruct.Mode GPIO_MODE_IT_FALLING_RISING; GPIO_InitStruct.Pull GPIO_NOPULL; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); HAL_NVIC_SetPriority(EXTI9_5_IRQn, 0, 0); HAL_NVIC_EnableIRQ(EXTI9_5_IRQn); // 初始状态: SDA引脚设为输入, 等待主机发起起始条件 I2C_Slave_Set_SDA_IN(); }SCL的中断优先级我设成了0nested vector interrupt controller里的最高优先级。因为从机响应主机时钟沿这件事是硬实时任何被打断都可能造成时序错误。如果你的系统里其他中断也很重要至少也要保证SCL中断的优先级高于定时器之类的周期性中断。3.2 中断状态机的实现要点核心逻辑都在SCL中断服务函数里一个状态机贯穿始终。SCL的上升沿和下降沿分别承担不同的任务这个要区分清楚。从标准I2C时序来看一个字节有8个时钟周期加一个额外的ACK周期。我定义了一个状态变量i2c_slave_state根据这个变量和当前是上升沿还是下降沿来决定要做的动作。我的实现思路是这样的在SCL下降沿时从机准备好下一拍的工作。如果是接收数据阶段就要把SDA方向切到输入准备采样如果是发送数据阶段就要把SDA方向切到输出把下一bit的数据放到总线上。在SCL上升沿时从机采样SDA电平。如果当前处于接收阶段就读取SDA的IDR寄存器值把bit拼进当前字节如果处于发送阶段什么都不用做等待下降沿切换下一bit。在第9个时钟周期也就是8个数据bit集齐后要根据当前阶段处理ACK/NACK以及判断要不要调整SDA方向。这里最关键的细节是读取SDA的电平不要用HAL_GPIO_ReadPin这个函数有参数检查耗时偏长。直接用寄存器的IDR寄存器读取比如说(GPIOB-IDR GPIO_PIN_7) ! 0一个语句就把电平读出来了。中断服务函数每省一条指令都是在给时序留余量。另外SDA方向切换也存在隐患。如果上一阶段是输出低电平作为ACKSCL下降沿到来后要立刻把SDA切成输入如果这个切换发生在SCL仍然为高的时候SDA的输出阶段会跟主机的输出阶段直接冲突导致总线上出现不确定的电平。所以我的做法是在SCL下降沿后立即切换方向保证切换动作发生在SCL低电平期间。中断服务函数的框架大致如下void EXTI9_5_IRQHandler(void) { if (EXTI-PR1 EXTI_PR1_PR6) { EXTI-PR1 EXTI_PR1_PR6; // 清中断标志 I2C_Slave_ISR(); } } void I2C_Slave_ISR(void) { uint8_t scl_level (GPIOB-IDR GPIO_PIN_6) ? 1 : 0; uint8_t sda_level (GPIOB-IDR GPIO_PIN_7) ? 1 : 0; // 下降沿 if (scl_level 0) { switch (i2c_slave_state) { case I2C_STATE_ADDR: // 地址字节已收满, 模拟ACK if (sda_byte_matched) I2C_Slave_Set_SDA_OUT_LOW(); else I2C_Slave_Set_SDA_IN(); break; case I2C_STATE_RX_DATA: // 数据位到来, 切输入准备采样 I2C_Slave_Set_SDA_IN(); break; case I2C_STATE_TX_DATA: // 发送下一bit if (current_byte 0x80) I2C_Slave_Set_SDA_IN(); // 释放SDA, 表示1 else I2C_Slave_Set_SDA_OUT_LOW(); // 拉低, 表示0 current_byte 1; break; default: break; } } // 上升沿 else { // 采样SDA, 具体逻辑按状态机推进 ... } }上面只是框架真正的代码还要处理起始条件检测、停止条件检测、重复起始条件等边界情况。切记一个原则中断函数越短越好所有的协议处理逻辑都要压缩到几十条指令内完成。3.3 时钟拉伸与速率控制软件模拟从机一个很大的隐患是慢。主机发出SCL时钟后从机要在半个时钟周期内完成采样和状态更新100kHz标准模式意味着单周期是10微秒高电平时间5微秒。如果你芯片主频跑在72MHz5微秒足够执行几百条指令理论上没问题但如果主频只有8MHz或者系统里还有大量其他中断时间就非常紧张了。我用的办法是开启从机时钟拉伸功能。当从机没有准备好接收数据时主动把SCL拉低让主机等着直到从机准备好再释放SCL。这个功能很多硬件I2C外设也支持但在软件模拟里实现起来同样是靠GPIO输出模式切换把SCL临时变成开漏输出低电平等就绪后切回输入让外部上拉拉高。要注意的是主机侧必须支持时钟拉伸。绝大多数硬件I2C主机是支持等待的但如果是另一个软件模拟的主机得确认它不会因为SCL长时间为低而报超时错误。这个我在调试时吃过亏主机端设的100ms超时结果从机中断里处理任务花了50ms主机就报错了后来把从机处理逻辑拆分才解决。速率这块实测100kHz标准模式稳定400kHz高速模式能通但余量很小一旦系统里中断变多偶发错误就开始出现。做项目的话除非主机端必须用400kHz否则我建议锁死100kHz。把速率降下来总线的干扰容错和从机的响应余量都会大幅改善。4. 常见问题与排查技巧实录4.1 总线挂死、地址不应答、数据错位排查这次调试下来踩过不少坑有些问题很有代表性单个列出来给各位参考。第一个高发问题是总线直接挂死。现象是SDA一直为低主机发送起始条件后无法完成任何字节传输。原因是某一次通信中从机在需要发送高电平时没有释放SDA或者主机发送完停止条件后从机还保持SDA为低导致总线一直被占用。排查思路是先看代码里有没有一个可靠的停止条件处理逻辑它必须把状态机复位到初始状态并且把SDA方向切回输入。还有一个隐藏点是有没有对I2C总线做超时复位如果连续200ms检测到SDA一直为低就强制把SDA和SCL都释放掉避免让整条总线死掉。第二个典型问题是地址匹配不上。主机扫描地址时发现没有任何设备应答。这个我一开始很困惑后来用示波器抓波形才发现问题出在起始条件检测上起始条件是SDA从高到低时SCL为高但如果代码只在SCL中断里判断SDA状态起始条件出现时SCL是稳定的高电平并不会触发SCL中断。也就是说必须额外在SDA上配置一个下降沿中断用于检测起始条件或者把起始条件检测逻辑放在SDA中断里。把SDA的下降沿中断打开之后情况才正常。第三个问题很有迷惑性通信能通但收到的数据偶尔错位比如第一个字节错后面的全对。查下来是ACK阶段SDA方向切换的时机问题。第9个时钟周期结束时从机释放SDA的速度慢了半拍主机以为从机还在占用SDA导致下一个字节的第一位采样到了错误的电平。解决方法是把ACK阶段的状态转移放在第9个时钟的下降沿触发而不是上升沿这样微调一下就能解决。还有一类问题是数据传输过程中SDA方向切换导致数据抖动在示波器上能看到SDA在SCL高电平期间还存在跳变。I2C协议规定SCL高电平期间SDA必须保持稳定出现跳变说明切换时机选在了SCL高电平期间。可以把所有SDA方向切换都安排在SCL下降沿之后保证操作都在SCL低电平窗口内完成。4.2 几个实操心得与参数建议调试到最后的稳定版本几个关键配置敲定如下供参考。SCL使用下降沿和上升沿双边沿中断SDA只使用下降沿中断。SCL中断优先级设为最高SDA中断优先级其次SDA中断专门负责起始条件和停止条件检测。主频跑在72MHzI2C速率控制在100kHz上拉电阻选用4.7kΩ设备地址0x32寄存器表总共16个寄存器。代码层面还有一个经验不要用动态内存不要用函数指针不要用任何阻塞式依赖。整个中断服务函数建议完全基于全局状态变量 简单的switch-case状态机实现这样既方便定位问题也能把执行时间压到最短。另外强烈建议在调试阶段加一个计数器统计收到的字节数和ACK/NACK次数放在后台循环里周期上传到串口。这样抓问题的时候不需要每次都用示波器盯很久直接看计数器的变化就能锁定是在哪个环节出的问题。我最后就靠着这个统计定位到了ACK阶段的时序冲突。最后再分享一个小技巧调试软件I2C从机时主机端建议准备一个USB转I2C适配器配合简单的主机脚本可以方便地进行连续读写压力测试。我实际用过之后发现单次读写很难暴露出问题连续几千次的循环读写才是检验从机稳定性的试金石。主机脚本设置成每20ms读写一次寄存器跑一整晚如果早上起来统计的失败次数还是零这个从机基本就能交付了。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。