公司动态

STM32软件模拟I2C从机:基于GPIO中断的状态机实现与踩坑实录

📅 2026/9/1 6:08:50
STM32软件模拟I2C从机:基于GPIO中断的状态机实现与踩坑实录
简介这是一份STM32软件模拟I2C从机的可运行源码包面向需要在资源受限环境中实现从机通信的嵌入式开发者。方案采用GPIO边沿中断替代传统查询方式实时捕获SCL/SDA上的START/STOP信号并通过状态机处理数据收发与ACK判断有效解决I2C从机被动通信的实时性难题。压缩包共11个文件约21KB包含C源码、头文件、STM32F429启动文件、链接脚本、Makefile及模拟器工程与README说明便于直接编译验证。已有133人学习。代码中提供完整GPIO初始化、中断服务程序、超时检测机制并附带使用硬件I2C主机进行的功能测试思路可帮助开发者快速迁移到实际项目同时为优化中断响应速度给出编译选项建议兼顾理论讲解与工程落地。 前几天有一块板子主控要求 STM32 以 I2C 从机角色接入总线被另一颗主控芯片读写。我第一反应是用硬件 I2C 外设结果两路硬件 I2C 的引脚全都已经被其他功能占了要不就是改板、要不就是自己想办法模拟。反正软件模拟 I2C 从机也不是什么新鲜事干脆写一套可运行源码把 100k、400k 两种速率都跑稳了。这篇文章就把设计思路、状态机、源码和踩坑记录一起整理出来给准备用软件模拟 I2C 从机的朋友一条能直接走通的路线。1. 为什么放着硬件I2C不用非要自己模拟从机1.1 我遇到的三个典型场景首先要说明一点只要能上硬件 I2C我绝不愿意自己模拟。I2C 协议本身的时序细节很多软件模拟是最后的选择但它往往是绕不过去的那道坎。我这次遇到的是引脚冲突。STM32F103 的 I2C1 只有 PB6/PB7 和 PB8/PB9I2C2 只有 PB10/PB11。板子上的 PB6/PB7 已经被 UART 功能抢走了PB10/PB11 也被其他外设占用如果我坚持用硬件 I2C就得重新设计板子或者飞线。而软件模拟只需要任意两个 GPIO想挂哪就挂哪这个问题瞬间消失。第二种常见场景是主从角色切换。有些项目要求同一颗芯片既能当主机扫描传感器又能在必要时被上位机当成从机来读写配置。硬件 I2C 外设要么只做主机、要么只做从机切换配置非常麻烦而软件模拟的状态机天然就支持动态切换只需要把状态清零重来就行。第三种场景是做测试工装。在产测环境里经常需要让一颗 MCU 模拟成某颗传感器或 EEPROM配合上位机做读写测试。软件模拟从机的好处是地址可以任意改寄存器映射可以任意编完全不依赖芯片的硬件从机地址掩码逻辑。1.2 软/硬模拟从机的差距和选型建议很多朋友关心软件模拟从机到底能不能稳定跑我直接给一组对比数据维度硬件 I2C 从机软件模拟 I2C 从机引脚选择固定映射任意普通 GPIO传输速率最高可到 1M取决于型号100k 很稳400k 可用再高要谨慎CPU 占用极低硬件外设自动处理每个位都要进中断占用明显动态改地址取决于外设掩码能力完全自由调试难度配置外设寄存器黑盒较多代码逻辑完全透明但需要自己保证时序我的建议是如果只是做低速数据交换100k 标准模式优先这套软件模拟方案在这种速率下非常从容。400k 快速模式也能跑但中断里逻辑不能再加重不然很容易在连续字节传输时漏边沿。2. 从机视角看I2C协议只需要盯住四条总线事件2.1 起始、停止条件SDA边沿 SCL高电平的组合软件模拟从机第一个要认清的事就是什么事件真正代表一帧 I2C 传输开始很多人一上来就闷头写数据移位结果起始条件都没认清楚。I2C 总线空闲时SCL 和 SDA 都处于高电平。起始条件START是SCL 保持高电平期间SDA 从高跳变到低。停止条件STOP是SCL 保持高电平期间SDA 从低跳变到高。注意关键限定条件是“SCL 高电平期间”。如果 SCL 已经是低电平了SDA 再变化那只是在正常传输数据位就算触发边沿也不应该被当成 START 或 STOP。所以在代码里我专门留了一个 SDA 外部中断只要 SDA 边沿一来首先检查 SCL 是不是高电平。是高位继续判断是下降沿还是上升沿分别对应 START 和 STOP是低位直接忽略。这个判断是软件模拟从机的第一道防线。2.2 地址、数据位和ACK应答地址字节的采样跟数据字节本质上是同一个动作。START 之后主机陆续给出 8 个 SCL 时钟从机要在每一个 SCL 上升沿去采样 SDA拼出一个字节。这个字节高 7 位是从机地址最低位是读写方向0 表示主机接下来要写数据1 表示主机要从从机读数据。这里有个很容易忽略的细节点地址匹配之后第 9 个时钟是 ACK 位。ACK 位期间主机释放 SDA从机要把 SDA 拉低告诉主机“是我我已经收到了”。软件模拟从机必须在这个 SCL 低电平阶段就把 SDA 提前拉住等到第 9 个时钟高电平到来时主机才能采样到一个稳定的低电平。如果 ACK 动作做得太晚主机就会收到一个高电平判断成 NACK整个传输直接中断。主机写数据、从机接收的字节同样是每 8 个数据位后进入第 9 个时钟从机需要拉低 SDA 表示 ACK。主机读数据、从机发送的字节则是主机在第 9 个时钟给出 ACK/NACK从机在这段时间要释放 SDA不要跟主机抢线。2.3 从机时序参数的表单速查软件模拟从机可能用到的几个关键时序参数以标准模式 100k 为例参数含义最小值tSU;STASTART 前 SCL 高电平保持时间4.7ustHD;STASTART 后 SCL 低电平时间4.0ustSU;DAT数据建立时间250nstHD;DAT数据保持时间0nstSU;STOSTOP 前数据建立时间4.0us软件模拟从机一般是“被动”侧不负责产生时钟所以这些参数主要是让我们理解主机侧的节奏。真正需要把握的是SCL 上升沿之后SDA 数据必须已经稳定SCL 下降沿之后我们才有机会调整 SDA 为下一 bit 做准备。这个“边沿驱动”的思维贯穿后面所有代码。3. 实现路线与GPIO中断框架的搭建3.1 为什么选外部中断而不是轮询有人会想我直接在 while(1) 主循环里循环读 SCL 和 SDA 电平再判断边沿不是更省事我早期也这么干过结果一上项目就翻车。主循环轮询最大的问题是时机不可控。I2C 传输是连续 byte 流主机每个 SCL 上升沿都要从机采样如果主循环正在跑别的任务哪怕是延迟几个微秒都可能漏掉某一个上升沿。一旦漏一位后面整个字节就错位了还特别难排查。所以我最终选用 GPIO 外部中断来驱动。SCL 和 SDA 各接一路外部中断边沿一到硬件自动跳进中断处理函数跟主循环没关系。这种做法的代价是中断频率比较高100k 速率下每 bit 大约 10us中断 CPU 占用还是能接受的。3.2 引脚配置与开漏模式的使用技巧引脚选择上SCL 用 PB10SDA 用 PB11。为什么选这两个因为它们在 STM32F103 上属于同一个外部中断线组 EXTI15_10可以合并在同一个中断服务函数里统一处理省去 NVIC 配置的麻烦。两个引脚都要配置成开漏输出模式而不是常规的输入模式。开漏模式的好处非常多外部接 4.7k 上拉电阻后GPIO 既能输出低电平也能由外部电阻把电平拉高读取 IDR 寄存器又能实时拿到总线上的实际电平。这就相当于一个“可读可写”的总线引脚非常适合 I2C 模拟。另外要注意STM32 的开漏输出模式下向 ODR 写 1 对应释放总线引脚高阻向 ODR 写 0 对应拉低总线。我封装了两个宏#define SDA_LOW() GPIO_WriteBit(GPIOB, GPIO_Pin_11, Bit_RESET) #define SDA_RELEASE() GPIO_WriteBit(GPIOB, GPIO_Pin_11, Bit_SET) #define SDA_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11)还有一个容易被忽略的点SCL 引脚也要配置成开漏输出。虽然从机不主动产生时钟但后面如果要实现“时钟拉伸”或者暂时钳住总线SCL 必须也能被拉低。就算这次用不到也建议提前配好避免后期改动时被动。3.3 外部中断的初始化代码基于标准外设库的初始化代码如下void I2C_Slave_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10 | GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); GPIO_WriteBit(GPIOB, GPIO_Pin_10 | GPIO_Pin_11, Bit_SET); GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource10); GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource11); EXTI_InitStructure.EXTI_Line EXTI_Line10; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Rising_Falling; EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); EXTI_InitStructure.EXTI_Line EXTI_Line11; EXTI_Init(EXTI_InitStructure); NVIC_InitStructure.NVIC_IRQChannel EXTI15_10_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }这里我把两个引脚都配置成开漏输出然后通过 GPIO_WriteBit 置高电平配合外部 4.7k 电阻完成空闲状态。GPIO_EXTILineConfig两个函数分别把 PB10/PB11 映射到 EXTI10/EXTI11之后就能在一个中断服务函数里统一判断。4. 可运行源码以SCL边沿为驱动的状态机4.1 状态定义和全局变量软件模拟从机说穿了就是一个有限状态机。我定义的全局变量和状态如下#define I2C_SLAVE_ADDR 0xA0 // 7位地址0x50写入方向时是0xA0 typedef enum { I2CS_IDLE 0, I2CS_ADDR_BITS, // 接收地址字节 I2CS_ACK_ADDR, // 地址应答阶段 I2CS_RX_BITS, // 接收数据字节 I2CS_ACK_RX, // 接收数据应答阶段 I2CS_TX_BITS // 从机发送数据阶段 } I2CS_State; volatile uint8_t s_i2c_state I2CS_IDLE; volatile uint8_t s_i2c_bit_cnt 0; volatile uint8_t s_i2c_byte 0; volatile uint8_t s_i2c_read_op 0; volatile uint8_t s_i2c_rx_buf[16]; volatile uint8_t s_i2c_rx_len 0; volatile uint8_t s_i2c_tx_buf[16]; volatile uint8_t s_i2c_tx_len 0; volatile uint8_t s_i2c_tx_idx 0;状态机不需要搞得太复杂抓住两个核心节点地址阶段和数据处理阶段。I2CS_ADDR_BITS用于接收地址字节匹配成功后根据读写方向切到发送或者接收状态。ACK 阶段用独立状态表示方便在 SCL 下降沿时做时序动作。4.2 START/STOP检测与地址匹配SDA 边沿中断是状态机的“总开关”它负责识别 START 和 STOPstatic void I2CS_OnSDAEdge(void) { if (SCL_READ()) { if (SDA_READ() 0) { // SCL高电平期间SDA下降沿 START s_i2c_state I2CS_ADDR_BITS; s_i2c_bit_cnt 0; s_i2c_byte 0; s_i2c_rx_len 0; s_i2c_tx_idx 0; } else { // SCL高电平期间SDA上升沿 STOP s_i2c_state I2CS_IDLE; } } // 如果SCL为低说明只是普通数据传输中的SDA翻转忽略 }在 START 事件里我把所有接收状态清零准备接收新的地址字节。在 STOP 事件里直接把状态机切回 IDLE一帧传输结束。业务层可以在s_i2c_rx_len里有数据时到主循环去消费这样外部中断只做“搬砖”不做业务逻辑。地址匹配逻辑放在 SCL 上升沿采样数据之后static void I2CS_CheckAddr(void) { if ((s_i2c_byte 0xFE) I2C_SLAVE_ADDR) { s_i2c_read_op s_i2c_byte 0x01; s_i2c_state I2CS_ACK_ADDR; s_i2c_bit_cnt 0; } else { s_i2c_state I2CS_IDLE; } }注意s_i2c_byte 0xFE取出高 7 位地址最低位 0x01是读写方向。这里我用0xA0作为地址字节值7 位地址实际是0x50。如果你的从机地址不同替换这个宏即可。4.3 数据收发与ACK的临界时序核心逻辑在 SCL 中断处理函数里。每次 SCL 上升沿采样 SDA每次 SCL 下降沿根据状态更新 SDA 输出。下面这段是能跑通 100k 和 400k 的关键代码框架static void I2CS_OnSCLEdge(void) { if (SCL_READ()) { // ---------- 上升沿采样数据位 ---------- uint8_t sda SDA_READ(); switch (s_i2c_state) { case I2CS_ADDR_BITS: case I2CS_RX_BITS: s_i2c_byte (s_i2c_byte 1) | sda; s_i2c_bit_cnt; if (s_i2c_bit_cnt 8) { s_i2c_bit_cnt 0; if (s_i2c_state I2CS_ADDR_BITS) { I2CS_CheckAddr(); } else { s_i2c_rx_buf[s_i2c_rx_len] s_i2c_byte; s_i2c_byte 0; s_i2c_state I2CS_ACK_RX; } } break; case I2CS_TX_BITS: // 主机在此时采样我们发送的数据位 // 这里不需要额外操作数据已经在SCL低电平时准备好 if (s_i2c_bit_cnt 8) { // 刚被主机采样完第8位进入第9个时钟的ACK位 s_i2c_bit_cnt 0; // 释放SDA让主机控制ACK/NACK SDA_RELEASE(); } break; default: break; } } else { // ---------- 下降沿准备/更新SDA ---------- switch (s_i2c_state) { case I2CS_ACK_ADDR: if (s_i2c_read_op 0) { // 主机写从机拉低SDA应答地址 SDA_LOW(); s_i2c_state I2CS_RX_BITS; s_i2c_bit_cnt 0; s_i2c_byte 0; } else { // 主机读地址ACK后要立即切到发送模式 SDA_RELEASE(); s_i2c_state I2CS_TX_BITS; s_i2c_bit_cnt 0; s_i2c_tx_idx 0; // 立即输出第一个数据位 uint8_t bit (s_i2c_tx_buf[0] 7) 0x01; if (bit) SDA_RELEASE(); else SDA_LOW(); s_i2c_bit_cnt 1; } break; case I2CS_ACK_RX: // 数据字节接收完成第9个时钟拉低SDA应答 SDA_LOW(); s_i2c_state I2CS_RX_BITS; s_i2c_bit_cnt 0; s_i2c_byte 0; break; case I2CS_TX_BITS: if (s_i2c_bit_cnt 8) { // 继续发送下一个数据位 uint8_t bit (s_i2c_tx_buf[s_i2c_tx_idx] (7 - s_i2c_bit_cnt)) 0x01; if (bit) SDA_RELEASE(); else SDA_LOW(); s_i2c_bit_cnt; } else { // 第9个时钟已过如果主机继续读准备下一字节 s_i2c_bit_cnt 0; s_i2c_tx_idx; if (s_i2c_tx_idx s_i2c_tx_len) { uint8_t bit (s_i2c_tx_buf[s_i2c_tx_idx] 7) 0x01; if (bit) SDA_RELEASE(); else SDA_LOW(); s_i2c_bit_cnt 1; } else { SDA_RELEASE(); s_i2c_state I2CS_IDLE; } } break; default: break; } } }这里我特别说明一下主机读模式下第一个数据位的时序。地址字节的 ACK 阶段结束于第 9 个时钟的下降沿这个下降沿一出现从机必须立刻把第一个发送数据的最高位放到 SDA 上。所以我在I2CS_ACK_ADDR的下降沿分支里直接做了SDA_RELEASE()/SDA_LOW()而不是等到下一次上升沿再处理。如果这一步漏了主机会读到不稳定的电平非常难查。4.4 主循环里的数据消费中断服务函数里不做业务处理只把收进来的数据放到s_i2c_rx_buf。主循环这样写int main(void) { SystemInit(); I2C_Slave_GPIO_Init(); // 准备从机返回给主机的数据 s_i2c_tx_buf[0] 0x12; s_i2c_tx_buf[1] 0x34; s_i2c_tx_len 2; while (1) { if (s_i2c_rx_len 0) { // 把接收到的数据缓存下来然后清空长度 uint8_t len s_i2c_rx_len; for (uint8_t i 0; i len; i) { s_i2c_rx_buf[i] s_i2c_rx_buf[i]; // 这里根据业务处理 } s_i2c_rx_len 0; // 可以是更新tx_buf供主机下次读取 } } }主循环的介入不会破坏中断时序因为中断里只负责协议层搬运。要注意的是s_i2c_rx_len是在中断里自增的主循环读取后清零这一步不能用普通变量跨越临界区建议加关中断保护。实际项目里我用一个标志位通知主循环“有数据到了”主循环再拷贝数据避免竞争。5. 实测发现的两个隐蔽坑与调试经验5.1 从机发送模式下第一个数据位丢失这个问题非常典型。接上逻辑分析仪发现主机读到的第一个字节最高位经常是 0 或者不定值但后面 7 位都正常。原因是我一开始在地址匹配后把s_i2c_tx_idx清 0但并没有立刻输出第一位而是等下一个 SCL 下降沿才做。结果主机的第一个 SCL 上升沿已经来了数据还没来得及放到 SDA 上。解决方式就是代码里写的在I2CS_ACK_ADDR的 SCL 下降沿分支里确认是读操作后立即准备第一位的输出同时把s_i2c_bit_cnt置成 1表示第一位已经“发出去”了。这样后续每来一个下降沿只需输出第 2 位、第 3 位……直到第 8 位正好对得上主机侧的采样节奏。5.2 SDA中断在SCL低电平时抖动误判START/STOP第二个坑是误触发 START/STOP。SDA 是一根共享数据线在 SCL 低电平时SDA 可以自由变化来为下一位做好准备。如果这个变化触发 SDA 外部中断而中断函数里没有判断 SCL 是高还是低就会把正常的电平切换误判成 START 或 STOP导致状态机重置。我的处理方式是在I2CS_OnSDAEdge一进门就判断 SCL 电平SCL 为高才执行 START/STOP 检测。另外还加了一个小技巧进入 SDA 中断后先读一次EXTI_GetITStatus清除 pending 位再根据 SCL 电平和 SDA 电平做判断避免中断连抖导致状态异常。如果你发现 START/STOP 还是会误触发可能是 SDA 信号边沿过缓。这时候可以在 SDA 上并联不超过 10pF 的小电容滤波或者把外部上拉电阻从 4.7k 降低到 2.2k增强边沿速度。5.3 用逻辑分析仪观察总线的方法软件模拟 I2C 从机最忌讳“盲写盲调”。我强烈建议手头备一个逻辑分析仪没有的话用带逻辑分析功能的示波器也行。抓总线时只需要接两根线CH1 接 SCLCH2 接 SDA采样率至少 1MHz 以上100k 速率跑起来非常从容。分析抓包数据时重点看几段波形起始位置是否有 SDA 先低、SCL 后低的正常顺序地址字节 8 个时钟是否完整第 9 个时钟从机有没有把 SDA 拉低主机读模式下从机每个 bit 是否在 S本文还有配套的精品资源点击获取