简介:这是一份面向STM32/GD32平台的模拟I2C从机通信Demo,使用纯C语言实现,适用于无硬件I2C外设、引脚受限或不想占用中断资源的单片机项目,也可用于I2C传感器、外部EEPROM等从设备逻辑的快速仿真。代码在50K通信速率下验证不丢包,主机读取时序支持START+ADD+W+REG后再启动START+ADD+R并返回REG1、REG2及CRC校验,写入时序则支持START+ADD+W+REG1、REG2与CRC;通过自动识别ACK是重新发起START还是直接写数据,可稳定区分读、写操作,逻辑清晰,便于二次开发。主机读写时序均带CRC校验,便于通信可靠性验证,可作为无硬件I2C外设时的替代方案。资源包共2个文件,含1个C源文件与1个头文件,压缩后仅2KB,头文件负责对外接口声明,C文件完成模拟时序与状态判断,无中断依赖,可整体复制到工程中直接使用。已有1144人学习下载,适合需要快速搭建或学习I2C从机通信逻辑的嵌入式开发者。 做嵌入式这些年,I2C没少打交道。很多人一开始接触模拟I2C,都是从模拟主机开始写:把GPIO配上开漏,手动翻转SCL,一位一位把SDA推出去,逻辑简单直接。可一旦换成“模拟I2C从机”,同样的思路就卡壳了。从机不能控制总线节奏,它只能在主机发起的时钟边沿里见缝插针地响应每一位,漏一个上升沿,整包数据就废了。这篇就把我在STM32F407上用C语言写模拟I2C从机demo的完整思路、代码和踩坑记录梳理一遍,给正准备啃这块硬骨头的人一个能直接跑的参考。
1. 为什么放着硬件I2C不用,偏要GPIO模拟从机
STM32大多数型号自带硬件I2C外设,主模式用起来问题不大,但从模式往往让人头疼。硬件从机虽然能自动处理起始、停止、地址匹配、ACK,但它的寄存器配置、中断状态机、错误恢复逻辑都比较绕。尤其遇到下面几种情况,GPIO模拟反而是更实际的选择:
- 芯片I2C从机通道不够用。手里只有一颗F407,I2C1已经被别的传感器占用了,I2C2和I2C3又跟别的外设共用引脚,这时候再挂一个I2C从机设备,硬件通道就排不开了。
- 地址不够用。硬件I2C从机通常只有一个固定的从机地址,想在一块板子上模拟多个I2C设备,或者做个I2C地址转换器,不搞点别的方案就应付不来。
- 调试不透明。硬件从机内部状态机封装太死,经常出现“看起来在主循环里转了,但主机那边就是没收到ACK”的情况,排查时只能一遍遍翻参考手册,不如自己写状态机来得直观。
模拟从机的本质,是把GPIO变成“耳朵”和“嘴巴”:一边监听SCL和SDA上的边沿,一边在正确的时刻驱动SDA。这个工作可以拆成三个关键词:GPIO外部中断、逐bit状态机、开漏输出。
注意:模拟I2C从机对响应速度的要求远高于模拟主机。主机可以等,从机不能等。主机在SCL上升沿采样SDA,从机就必须在这个沿之前把SDA准备好,或者在SCL下降沿之后立刻准备下一位。因此,从机逻辑必须跑在中断里,主循环里做不了这件事。
2. I2C时序的“硬骨头”:从机必须看懂起始、数据位和ACK
I2C是两线制,SCL时钟线、SDA数据线。所有从机都挂在同一条总线上,从机要做的第一件事就是读懂总线上的“暗号”。I2C总线上除了数据,还有几个关键事件,任何一个从机都必须能识别:
- 起始条件(START):SCL为高电平时,SDA由高变低。表示主机要开始一次传输,从机需要把状态机复位,准备接收地址。
- 停止条件(STOP):SCL为高电平时,SDA由低变高。表示传输结束,从机复位到空闲。
- 数据位:每个SCL脉冲传输1位。SCL为高电平时SDA的电平就是当前位的值;SDA只能在SCL为低电平时改变。
- ACK位:每8个数据位之后有第9个SCL脉冲。在这个脉冲的低电平阶段,接收方如果拉低SDA,表示应答(ACK);释放SDA表示不应答(NACK)。
- 地址字节:起始之后第一个字节的高7位是从机地址,最低位是读写方向(0表示写,1表示读)。
对模拟从机来说,最难的不是理解这些事件,而是在正确的沿上做正确的事。我把从机在每个时钟周期要做的动作拆成了三块:
- SCL上升沿:主机正在采样SDA。从机如果是接收状态,就采样SDA,把数据位收进移位寄存器;如果是发送状态,则什么都不做,保持SDA稳定。
- SCL下降沿:一位传输完毕,SCL进入低电平。从机可以在这个阶段改变SDA,准备下一个位的数据,或者为ACK位做准备。
- SDA在SCL高电平时的跳变:这个不是数据,而是起始或停止条件。需要单独在SDA中断里判断。
搞清楚这个时序模型,代码结构就清晰了。用两个外部中断分别监听SCL和SDA,用状态机记录当前处于哪个阶段,再用一个bit计数器决定当前是数据位还是ACK位。
3. 代码架构:EXTI中断 + 状态机,逐bit收发不丢数据
模拟I2C从机的程序框架,我建议分三层:
- 硬件层:初始化GPIO,配成开漏输出,开启SCL和SDA两个引脚的外部中断。
- 中断层:响应SCL上升沿/下降沿和SDA跳变,在中断里完成逐bit的采样、发送、ACK操作。
- 应用层:根据完整字节决定“收到的是什么指令”,更新应用变量。
3.1 为什么用开漏输出
模拟I2C时,SCL和SDA的GPIO模式必须选择开漏输出,而不是推挽输出。I2C总线是线与关系:任何设备拉低总线,总线就是低;所有设备释放总线,总线才被上拉电阻拉高。开漏输出可以做到“既能输出0,又能释放总线”,并且可以通过读IDR寄存器实时获取线上的电平状态,收发一体。
如果配成推挽输出,两个设备一个输出高一个输出低,会直接短路。配成输入则没法主动拉低总线去发ACK。开漏输出是I2C模拟的默认选择。
3.2 状态机设计
状态机的核心状态就这几种:
typedef enum { I2C_IDLE = 0, // 空闲 I2C_ADDR, // 接收地址字节 I2C_REG, // 接收寄存器地址 I2C_WRITE_DATA, // 接收写入数据 I2C_READ_DATA // 发送读数据 } i2c_state_t;状态机由SCL的上升沿和下降沿共同驱动。我设计了一个bit_index变量,用来记录“当前正在处理字节中的第几位”。一个字节有9位(8位数据 + 1位ACK),所以bit_index的范围是0~9。它的变化规则是:
- SCL上升沿:如果当前是数据位(
bit_index < 8),就把SDA电平采进rx_byte,然后bit_index++;如果bit_index本来等于8,说明刚完成第9位的前半段,上升沿结束时递增到9。 - SCL下降沿:
bit_index等于8时,说明数据位结束,进入ACK位低电平,从机要在这个低电平期间设置SDA电平;bit_index等于9时,说明ACK位结束,复位bit_index,准备处理下一个字节。
3.3 中断优先级
SCL中断必须比SDA中断优先级更高,或者至少不能低于它。因为SCL边沿是整个传输的“节拍器”,如果SCL中断被其他中断阻塞,就会漏掉数据位。SDA中断只负责检测起始和停止,优先级可以低一点。实际测试中,我把两个中断都放在EXTI9_5_IRQHandler里,通过判断中断线区分,NVIC抢占优先级设为1,没有开SDA的更高嵌套。
4. demo代码:STM32F407 GPIO模拟I2C从机最小实现
下面给出一个可以直接编译运行的demo。功能很简单:模拟一个7位地址为0x30的从机,支持主机写1个字节到寄存器,也支持主机读回这个寄存器。用PB6做SCL,PB7做SDA。
4.1 初始化
#include "stm32f4xx.h" #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_PORT GPIOB #define SCL_READ() ((GPIOB->IDR & I2C_SCL_PIN) != 0) #define SDA_READ() ((GPIOB->IDR & I2C_SDA_PIN) != 0) #define SDA_HIGH() (GPIOB->BSRRL = I2C_SDA_PIN) #define SDA_LOW() (GPIOB->BSRRH = I2C_SDA_PIN) #define I2C_SLAVE_ADDR 0x30 typedef enum { I2C_IDLE = 0, I2C_ADDR, I2C_REG, I2C_WRITE_DATA, I2C_READ_DATA } i2c_state_t; volatile i2c_state_t i2c_state = I2C_IDLE; volatile uint8_t bit_index = 0; volatile uint8_t rx_byte = 0; volatile uint8_t tx_byte = 0; volatile uint8_t reg_addr_tmp = 0; volatile uint8_t data_reg = 0xA5; // 演示用的数据寄存器GPIO和中断初始化:
void I2C_Slave_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct; EXTI_InitTypeDef EXTI_InitStruct; NVIC_InitTypeDef NVIC_InitStruct; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_SYSCFG, ENABLE); GPIO_InitStruct.GPIO_Pin = I2C_SCL_PIN | I2C_SDA_PIN; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStruct.GPIO_OType = GPIO_OType_OD; GPIO_InitStruct.GPIO_PuPd = GPIO_PuPd_UP; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStruct); GPIO_SetBits(GPIOB, I2C_SCL_PIN | I2C_SDA_PIN); SYSCFG_EXTILineConfig(EXTI_PortSourceGPIOB, EXTI_PinSource6); SYSCFG_EXTILineConfig(EXTI_PortSourceGPIOB, EXTI_PinSource7); EXTI_InitStruct.EXTI_Line = EXTI_Line6 | EXTI_Line7; EXTI_InitStruct.EXTI_Mode = EXTI_Mode_Interrupt; EXTI_InitStruct.EXTI_Trigger = EXTI_Trigger_Rising_Falling; EXTI_InitStruct.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStruct); NVIC_InitStruct.NVIC_IRQChannel = EXTI9_5_IRQn; NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0; NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStruct); }4.2 中断服务函数
EXTI9_5_IRQHandler同时接收SCL和SDA的中断事件。SDA中断只判断起始和停止,SCL中断处理逐bit逻辑。
void EXTI9_5_IRQHandler(void) { // SCL中断 if (EXTI_GetITStatus(EXTI_Line6) != RESET) { EXTI_ClearITPendingBit(EXTI_Line6); if (SCL_READ()) { // SCL上升沿:主机采样 if (i2c_state != I2C_IDLE && bit_index < 8) { rx_byte = (rx_byte << 1) | (uint8_t)(SDA_READ() ? 1 : 0); } bit_index++; if (bit_index == 9) { i2c_byte_done(); // 一字节传输完成,处理应用层逻辑 } } else { // SCL下降沿:低电平阶段准备SDA if (bit_index == 8) { i2c_prepare_ack(); // 进入ACK位低电平 } else if (bit_index == 9) { // ACK位结束,复位并准备下一个字节第一位 SDA_HIGH(); bit_index = 0; if (i2c_state == I2C_READ_DATA) { if (tx_byte & 0x80) SDA_HIGH(); else SDA_LOW(); } } else if (i2c_state == I2C_READ_DATA && bit_index < 8) { // 发送模式下,逐个准备数据位 if (tx_byte & (0x80 >> bit_index)) SDA_HIGH(); else SDA_LOW(); } } } // SDA中断:检测起始/停止 if (EXTI_GetITStatus(EXTI_Line7) != RESET) { EXTI_ClearITPendingBit(EXTI_Line7); if (SCL_READ()) { if (SDA_READ() == 0) { // SCL高时SDA下降 -> 起始 i2c_state = I2C_ADDR; bit_index = 0; rx_byte = 0; SDA_HIGH(); } else { // SCL高时SDA上升 -> 停止 i2c_state = I2C_IDLE; bit_index = 0; SDA_HIGH(); } } } }4.3 应用层处理函数
void i2c_prepare_ack(void) { switch (i2c_state) { case I2C_ADDR: if ((rx_byte >> 1) == I2C_SLAVE_ADDR) { SDA_LOW(); // 地址匹配,应答 } else { SDA_HIGH(); // 地址不匹配,不应答 } break; case I2C_REG: case I2C_WRITE_DATA: SDA_LOW(); // 正常接收,应答 break; case I2C_READ_DATA: SDA_HIGH(); // 从机发送模式,第9位释放SDA,由主机发NACK break; default: SDA_HIGH(); break; } } void i2c_byte_done(void) { switch (i2c_state) { case I2C_ADDR: if ((rx_byte >> 1) == I2C_SLAVE_ADDR) { if (rx_byte & 0x01) { // 读请求 i2c_state = I2C_READ_DATA; tx_byte = data_reg; // 把寄存器值放入发送缓冲 } else { // 写请求 i2c_state = I2C_REG; } } else { i2c_state = I2C_IDLE; } break; case I2C_REG: reg_addr_tmp = rx_byte; i2c_state = I2C_WRITE_DATA; break; case I2C_WRITE_DATA: data_reg = rx_byte; i2c_state = I2C_IDLE; break; case I2C_READ_DATA: // 单字节读完成,如果要多字节连续读,在这里继续赋值tx_byte i2c_state = I2C_IDLE; break; default: i2c_state = I2C_IDLE; break; } }这段代码只实现了最基础的“写一个寄存器、读一个寄存器”,但已经把模拟从机所有关键节点覆盖了。拿到手上先跑通,再往里面加自己的协议逻辑。
5. 实测踩坑:毛刺、优先级、总线锁死,一个都不能少
demo跑通之后,并不代表万事大吉。我在调试中踩了几个非常典型的坑,每一个都值得单独说一下。
5.1 SDA和SCL同时变化,中断顺序会坑人
起始条件本身是“SCL高、SDA下降”。但有些主机在拉高SCL之后马上拉低SDA,两个引脚的变化时间差极小。如果SCL下降沿和SDA下降沿挤在同一个瞬间,SDA中断进来时SCL可能已经被拉低,这时候SCL_READ()返回0,代码就检测不到起始条件。后来我把SDA中断里的判断改得更健壮:不只看当前SCL电平,而是加了一个“总线状态”标志。SCL为高时SDA跳变,无论当前读到的SCL是不是还高,只要不是在数据传输中,就先当成起始处理。这里的关键是:不要用单一时刻的SCL电平做严格判断,结合之前状态和标志位判断更可靠。
5.2 初始化时SDA引脚瞬间拉低导致总线“假死”
代码里有个细节:GPIO初始化后要立刻把SDA和SCL置1,释放总线。别小看这一步。如果开漏输出后ODR寄存器值是0,SDA会被直接拉低,主机一上来就以为收到了起始条件,后续时序完全错乱。我的习惯是初始化GPIO后立刻GPIO_SetBits(GPIOB, I2C_SCL_PIN | I2C_SDA_PIN),确保总线空闲。
5.3 中断里不能做耗时操作
模拟从机的每一个SCL上升沿到下一个上升沿之间的时间窗口,在100kHz总线下大约是10微秒。STM32F407跑168MHz,一个普通c语言函数哪怕几十条指令,也就几十个周期。但如果在中断里使用HAL库的HAL_GPIO_ReadPin、HAL_GPIO_WritePin这类带大量断言和延迟的函数,几百个周期就没了。所以demo里我直接操作寄存器,省掉所有封装。使用HAL库的话,请改写成直接操作BSRR和IDR。
5.4 总线死锁处理
实际挂在总线上时,如果从机在某个中间状态突然复位,可能会把自己的SDA或者SCL拉低,导致整条总线卡死。模拟从机没有硬件I2C的SMBus超时机制,所以我在初始化时如果检测到SCL持续低电平超过一个阈值(比如100ms),就会主动释放SCL,甚至用软件方式把SCL“顶开”一段时间,给主机一个恢复机会。简单方案:外部启动后,把SCL引脚配成推挽输出,先输出9个脉冲,强制复位总线上可能卡住的状态。
5.5 毛刺过滤
在强干扰环境中,SDA上的一根毛刺如果恰好出现在SCL高电平时,会被当成起始或停止条件。我加了一个简单的办法:检测到SDA跳变时,再延时几微秒后读一次SDA,如果和跳变方向一致才承认这是一个有效的起始/停止。这个延时在中断里也不能用系统延时函数,所以我用了一个for (i=0; i<50; i++);的空循环。低速I2C下足够用。
6. 验证与扩展:从能跑到稳定跑的检查清单
写完代码,怎么确定从机真的符合I2C协议?分享一个我常用的验证清单。
6.1 用逻辑分析仪看真实波形
逻辑分析仪是模拟I2C从机的必备工具。把SCL和SDA接到分析仪上,抓一次完整的写操作。重点看三处:
- 起始后第一个字节是否是
0x60(地址0x30 + 写位)。 - 第9个SCL高电平时SDA是不是由从机拉低(ACK)。
- 第二个字节(寄存器地址)和第9位ACK正常结束后,第三字节数据写入后,SDA是否被正确ACK。
任何一处SDA电平与预期不符,问题都出在对应的中断分支里。比如ACK不出来,多半是i2c_prepare_ack()没在bit_index == 8的下降沿被调用。
6.2 用另一个STM32模拟主机做压力测试
我习惯用一个STM32当主机,循环读写从机寄存器,每次传输间隔不断缩短,连续跑几百万包。如果中间出现一次NACK、一次数据错误,就把速率降回去一点。对于模拟从机,100kHz标准模式是最稳妥的,400kHz快速模式也可以,但中断里如果还有其他高频任务,就容易丢位。
6.3 功能扩展方向
这个demo想扩展成多寄存器从机也很容易:把data_reg换成数组data_reg[N],在I2C_WRITE_DATA状态用reg_addr_tmp作为数组下标。想把I2C_READ_DATA变成连续读,只要在i2c_byte_done的I2C_READ_DATA分支里更新tx_byte,并且不要退出READ_DATA状态,直到主机发出停止条件即可。
6.4 我个人的调试心得
最后说点实在的。模拟I2C从机最忌讳一上来就写整套协议,先拿一个最简单的字节收发跑通,再用逻辑分析仪一帧一帧核对,最后才叠加寄存器、多字节这些复杂逻辑。我每次改动中断里的一个分支,都会重新抓一遍波形,因为这类边沿敏感的程序,一个看似不起眼的优先级调整,都可能把整个时序带偏。先把“能收一个字节、能发一个字节”跑稳,后面的事情都是顺着这个骨架往上长。
本文还有配套的精品资源,点击获取