简介:面向STM32/GD32嵌入式开发者,提供一套C语言编写的模拟I2C从机demo代码,解决MCU缺少硬件I2C从机控制器、或应用场景不适合占用中断资源时的从机通信问题。代码在GD32F130平台验证,思路可迁移到其他STM32系列,主机读取时序(START ADD+W REG START ADD+R REG1 REG2 CRC)与主机写时序(START ADD+W REG1 REG2 CRC)均已覆盖,并通过自动识别ACK区分start信号与直接写数据,令总线解析更简洁。资源为RAR压缩包,仅2KB,包含1个C源文件与1个头文件,头文件承载接口与参数宏,方便按工程调整引脚和通信参数。作者在50K速率下测试不丢包,且全程无需中断资源,对定时器和GPIO占用极低,适合资源较紧张的嵌入式场景。现有1144人学习,可直接参考移植,适合正在调试I2C通信或想摆脱硬件从机模块限制的开发者。 做模拟I2C从机这件事,我最初是在一个用STM32F407做数据采集的项目里被逼出来的——主控板是现成的,要对接一个只能当I2C主机的传感器模块,可板上的硬件I2C外设已经被占用了一半。网上搜了一圈,模拟主机的资料确实多,但模拟从机的demo少得可怜。从机是被动角色,主机什么时候拉SCL、什么时候切换SDA,从机完全说了不算,所有的判断都得靠中断里的边沿检测,时机差一点数据就错了。这篇文章把我实际调通的STM32模拟I2C从机demo代码拆开讲,说说状态机怎么设计、SCL和SDA两个外部中断怎么配合、开漏输出怎么处理,以及我在这中间踩过的几个坑。
1. 什么情况下必须用模拟I2C从机
1.1 硬件I2C从机的限制
很多人觉得用MCU内置的硬件I2C外设从机模式不就行了?如果引脚和资源都富余,硬件方案确实是首选,省心、时序准确、不占CPU。但实际项目里你总会碰到几个尴尬情况:
- 引脚不够用了,只剩两个普通GPIO,而硬件I2C引脚恰好被别的功能占着(我就是这个情况)。
- 硬件I2C从机在低功耗模式下唤醒不及时,总线上有起始条件来了,从机还没准备好。
- 项目里已经有别的模块占用了I2C外设,同一个MCU里挂了两个从机,硬件资源不够分。
- 换芯片换到一半,新芯片的硬件I2C与外设引脚的映射对不上,但又不能改板子。
另外还有一个体验上的问题:不同厂家芯片的硬件I2C实现细节差异很大,尤其从机模式下的时序容错处理不太一样,调试的时候想用一个统一方案覆盖所有芯片,模拟反而更可控。
1.2 模拟方案适合什么场景
模拟I2C从机说白了就是用GPIO外部中断跟踪SCL和SDA的每一个边沿,把I2C协议翻译成一串状态转移。它适合以下场景:
- 对时序要求不算极端的总线,100kHz标准模式下完全够用,400kHz快速模式经过优化也能跑。
- 通信频率不高的传感器数据交互,比如读取温湿度、电池电量、配置参数等。
- 需要快速移植到不同MCU平台,只要改引脚配置和中断服务函数就行。
- 学习I2C协议的好途径——把状态机写一遍,比看十遍时序图都管用。
如果项目里跑的是1MHz以上的高速I2C,或者主机连续读写超大块数据不允许任何时间偏差,那还是老实选硬件I2C外设,模拟方案在这种场景下性价比不高。
2. 模拟I2C从机的状态机设计思路
2.1 从机视角重新理解I2C时序
写从机模拟代码前,先把协议站在从机角度捋一遍,这和写主机驱动时的视角完全不一样。主机知道时钟什么时候来,从机只能在中断里被动地"接住"每一个边沿。
I2C通信的关键事件如下:
- 起始条件:SCL为高时,SDA产生一个下降沿,这是通信开始的"敲门声"。
- 地址阶段:随后8个SCL脉冲内,主机依次发送7位地址+1位读写标志,从机在每个SCL上升沿采样一次SDA。
- 应答阶段:第9个SCL脉冲,地址匹配的从机要把SDA拉低(ACK),不匹配就释放SDA(NACK)。
- 数据传输:方向由地址第0位决定。写操作时,从机在每个字节后给ACK;读操作时,从机在SCL低电平期间改变SDA数据,主机在SCL高电平期间采样,第9个脉冲主机回ACK表示还要继续读,回NACK表示停止。
- 停止条件:SCL为高时,SDA产生一个上升沿。
特别注意一点:模拟从机里,起始条件和停止条件的检测非常容易漏,因为这时候SCL没有变化,只有SDA变了,如果只监听SCL中断,起始条件根本不会触发。这也是很多新手写模拟从机时数据一直对不上的主要原因。
2.2 中断选型与引脚配置方案
我的方案是两条线都接外部中断,并且都是双边沿触发:
- SCL接EXTI:上升沿采样数据,下降沿判定阶段切换、准备ACK/下一bit。
- SDA也接EXTI:用于起始/停止条件检测,中断处理里先判断SCL的电平,若SCL为高且SDA有边沿,说明是起始或停止事件;若SCL为低,说明只是普通的数据变化,忽略。
引脚配置上有个关键点:SDA既要能读(检测边沿、采样数据),又要能写(发送ACK、发送数据)。如果把它配成输入中断模式,就没办法拉低SDA;配成开漏输出模式,又担心外部中断不触发。其实在STM32上,开漏输出模式下把ODR写1时引脚等于高阻释放,IDR仍然可以读到引脚电平,EXTI中断检测的是IDR的电平变化,两者完全不冲突。
所以SDA的正确配置是:开漏输出模式,初始把ODR置1(释放),然后手动使能EXTI中断。这样既能在需要时拉低SDA,又能在总线上出现起始/停止条件时触发中断。初始化代码我在第3节给出。
2.3 状态机定义与转移条件
我把从机的整个工作过程拆成8个状态:
typedef enum { I2C_STATE_IDLE = 0, /* 空闲,等待起始条件 */ I2C_STATE_RX_ADDR, /* 接收地址+读写位 */ I2C_STATE_ACK_ADDR, /* 地址应答等待完成 */ I2C_STATE_RX_DATA, /* 接收数据(写操作) */ I2C_STATE_ACK_DATA, /* 数据应答等待完成 */ I2C_STATE_TX_DATA, /* 发送数据(读操作) */ I2C_STATE_STOP /* 停止条件 */ } I2C_SlaveState;状态转移的核心逻辑是:SCL上升沿负责"收"数据(采样SDA),SCL下降沿负责"换"阶段(切换状态、准备ACK或下一位数据)。地址阶段和数据接收阶段虽然都是8个bit的采样过程,但收完后的处理不同——地址收完要判断是否匹配,数据收完要存缓冲区。因此我用一个bit_count变量区分当前收了多少位,在SCL下降沿检查这个计数。
另外要设计好超时保护。如果主机在通信中途掉线,比如只发了起始条件就挂死,从机状态机会一直停在RX_ADDR状态,这时后面再来的数据会被误当地址处理。可以在主循环里加一个超时检查,超过比如10ms没收到新的SCL边沿就强制回IDLE。
3. 手把手实现的Demo代码
3.1 引脚初始化:SDA开漏输出+外部中断的配置
我用的型号是STM32F103,SCL接PA6,SDA接PA7,从机地址设为0x32。工程基于HAL库,但SDA的外部中断部分用寄存器手动打开,因为HAL库没有直接提供"开漏输出+EXTI"的组合模式。
void I2C_Slave_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); /* SCL:浮空输入 + 双边沿外部中断 */ GPIO_InitStruct.Pin = GPIO_PIN_6; GPIO_InitStruct.Mode = GPIO_MODE_IT_RISING_FALLING; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* SDA:开漏输出,初始释放 */ GPIO_InitStruct.Pin = GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET); /* 手动配置SDA的EXTI:上升沿+下降沿都触发 */ GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource7); EXTI->IMR |= EXTI_IMR_MR7; EXTI->RTSR |= EXTI_RTSR_TR7; EXTI->FTSR |= EXTI_FTSR_TR7; HAL_NVIC_SetPriority(EXTI9_5_IRQn, 1, 0); HAL_NVIC_EnableIRQ(EXTI9_5_IRQn); }这里有个细节值得说明:GPIO_PULLUP对开漏输出模式是不生效的,所以SDA的上下拉要由外部上拉电阻决定。PCB上I2C总线必须要有上拉电阻,一般4.7k到10k比较常见,如果没有,通信会随机失败。SCL配成上拉输入是为了防止总线空闲时引脚浮空误触发中断。
3.2 起始/停止条件的检测
SDA的中断函数是整套代码里最需要抓住时机的地方。进入中断后第一件事就是读SCL的电平,判断这次SDA变化是起始/停止,还是普通数据位变化:
void EXTI9_5_IRQHandler(void) { /* SCL中断处理(略,见3.3节) */ /* SDA中断:检测起始/停止条件 */ if (EXTI->PR & EXTI_PR_PR7) { EXTI->PR = EXTI_PR_PR7; if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_6) == GPIO_PIN_SET) { /* SCL为高,说明SDA变更是起始或停止信号 */ if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_7) == GPIO_PIN_RESET) { /* 下降沿:起始条件 */ i2c_state = I2C_STATE_RX_ADDR; bit_count = 0; shift_reg = 0; i2c_rx_done = 0; } else { /* 上升沿:停止条件 */ if (i2c_state == I2C_STATE_RX_DATA && bit_count > 0) { i2c_rx_done = 1; /* 标记一帧接收完成 */ } i2c_state = I2C_STATE_IDLE; } } /* 如果SCL为低,这只是数据位翻转,直接忽略 */ } }实际调试时,我遇到过一种情况:上位机(主机)在发送数据过程中,SDA的电平变化点与SCL下降沿几乎重叠。这时候SDA中断里读到的SCL电平可能已经是下降了,或者恰好还在高电平,导致误判。我的解决办法是在初始化时把SCL的采样放在中断入口处,并且用一小段延时或重复采样做去抖。更稳的办法是稍微滞后一点再采样,但中断里不适合做长时间延时,所以重复读两次取多数值是比较实用的做法。
3.3 地址匹配与ACK/NACK处理
SCL中断承担了状态机的核心推进工作。我把上升沿和下降沿分开处理。上升沿采样SDA数据:
/* SCL上升沿:从数据线上采样1个bit */ if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_6) == GPIO_PIN_SET) { if (i2c_state == I2C_STATE_RX_ADDR || i2c_state == I2C_STATE_RX_DATA) { shift_reg <<= 1; if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_7) == GPIO_PIN_SET) { shift_reg |= 0x01; } bit_count++; } }SCL下降沿要处理的事情更多。这里的关键是区分"刚好收满8位"和"第9个时钟结束"两个时刻。假设执行到以下SCL下降沿代码时,如果地址刚好收到8位,表示要进入ACK阶段了;如果状态是ACK_ADDR,表示ACK阶段结束了:
else { /* SCL下降沿 */ if (i2c_state == I2C_STATE_RX_ADDR && bit_count == 8) { /* 8位地址(含读写位)收满 */ i2c_addr = shift_reg >> 1; /* 7位从机地址 */ i2c_is_read = shift_reg & 0x01; /* 最低位是读写标志 */ if (i2c_addr == I2C_SLAVE_ADDR) { SDA_LOW(); /* 地址匹配,拉低SDA应答 */ i2c_state = I2C_STATE_ACK_ADDR; } else { SDA_RELEASE(); /* 地址不匹配,不响应 */ i2c_state = I2C_STATE_IDLE; } bit_count = 0; shift_reg = 0; } else if (i2c_state == I2C_STATE_ACK_ADDR) { /* 第9个时钟结束,ACK阶段完成 */ SDA_RELEASE(); if (i2c_is_read) { /* 主机要读数据 */ i2c_state = I2C_STATE_TX_DATA; i2c_tx_index = 0; bit_count = 0; /* 预置第一个数据位 */ if (i2c_tx_len > 0 && (i2c_tx_buf[0] & 0x80) != 0) { SDA_RELEASE(); } else { SDA_LOW(); } bit_count = 1; } else { /* 主机要写数据 */ i2c_state = I2C_STATE_RX_DATA; bit_count = 0; shift_reg = 0; } } else if (i2c_state == I2C_STATE_RX_DATA && bit_count == 8) { /* 一个完整数据字节收满 */ if (i2c_rx_len < 15) { i2c_rx_buf[i2c_rx_len++] = shift_reg; } SDA_LOW(); /* 数据应答 */ i2c_state = I2C_STATE_ACK_DATA; bit_count = 0; shift_reg = 0; } else if (i2c_state == I2C_STATE_ACK_DATA) { /* 数据ACK阶段结束 */ SDA_RELEASE(); i2c_state = I2C_STATE_RX_DATA; bit_count = 0; shift_reg = 0; } else if (i2c_state == I2C_STATE_TX_DATA) { /* 发送数据的处理,见3.4节 */ } }这里需要注意:bit_count == 8判断必须在ACK_ADDR和ACK_DATA之前,因为它们的触发条件不同。如果把状态判断放在前面,会出现bit_count还停在8就又被ACK状态的分支消耗掉的情况,状态会错乱。调试时可以用一个状态打印函数把每个中断里的状态和bit_count打出来。
3.4 数据收发状态机的完整实现
读操作(从机发送)的处理比写操作绕一些。原因是从机要主动改变SDA电平,但又必须在SCL低电平期间改变,高电平期间保持稳定。我采用的策略是:在第9个ACK时钟下降沿后的低电平窗口里预置第一个bit,然后在后面每个SCL下降沿准备下一个bit。
else if (i2c_state == I2C_STATE_TX_DATA) { if (bit_count < 8) { /* 还没发完8个bit,准备下一位 */ bit_count++; if (bit_count < 8) { uint8_t current_byte = i2c_tx_buf[i2c_tx_index]; if ((current_byte & (0x80 >> bit_count)) != 0) { SDA_RELEASE(); } else { SDA_LOW(); } } } else { /* 8个bit已经全部放到总线上,第9个时钟读主机ACK */ SDA_RELEASE(); if (SDA_READ() == GPIO_PIN_RESET) { /* 主机ACK,表示还想要下一个字节 */ i2c_tx_index++; if (i2c_tx_index < i2c_tx_len) { bit_count = 0; if ((i2c_tx_buf[i2c_tx_index] & 0x80) != 0) { SDA_RELEASE(); } else { SDA_LOW(); } bit_count = 1; } else { /* 数据已经全部发完 */ i2c_state = I2C_STATE_IDLE; } } else { /* 主机NACK,通信结束 */ i2c_state = I2C_STATE_IDLE; } } }写操作相对简单,接收方向的数据处理就是不断采样、存缓冲区、给ACK。demo里我维护了两个全局数组:
volatile uint8_t i2c_rx_buf[16]; /* 写操作收到的数据 */ volatile uint8_t i2c_rx_len = 0; volatile uint8_t i2c_tx_buf[16] = {0x01, 0x02, 0x03, 0x04, 0x05}; volatile uint8_t i2c_tx_len = 5; volatile uint8_t i2c_rx_done = 0; /* 收到完整一帧后置1,主循环处理 */做读操作demo时,从机主动发数据这个动作特别容易翻车。我建议先用一个固定数组测试通信,确认主机能完整收到数据后再改成动态更新。另外发送数据时,如果主机的ACK信号从机没读到(比如时序太紧),后续状态就卡死了。可以在TX_DATA状态加一个保护:如果bit_count超过9还没等到正常状态转移,强制回IDLE。
4. 调试经验:常见坑与排查方法
4.1 逻辑分析仪验证时序的要点
模拟I2C从机写好后,必须用逻辑分析仪看实际波形,不要只在代码里打日志。我用的8通道逻辑分析仪,采样率调到24MHz以上,抓SDA和SCL两根线,导出后对照I2C协议逐bit核对。重点看三处:
- 起始条件是否被正确识别:波形里SDA在SCL高电平期间形成下降沿,后面的地址数据能正确对齐。
- ACK/NACK是否在正确的时钟周期出现:ACK应该在第9个时钟的低电平期间被拉低,高电平期间保持。
- 读操作方向的数据位是否建立正确:SDA的变化应该发生在SCL低电平期间,每个SCL高电平期间SDA必须稳定。
逻辑分析仪上如果发现ACK时序对了但位置偏了一个相位,多半是状态机的下降沿/上升沿判断反了。我曾犯过的错误是把"拉低SDA应答"放在了第8个时钟的上升沿,结果主机看到的是数据位不是ACK。
4.2 实测中遇到的4个典型问题
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 主机找不到从机(NACK) | SDA开漏模式下ODR没初始化成1,总线被拉死 | 初始化后写GPIO_PIN_SET释放SDA |
| 起始条件偶尔丢失 | 主机的SDA变化时间与SCL高电平窗口重叠 | 在SDA中断里去抖,重复读SCL确认;降低I2C主机的传输速度 |
| 读数据时第一个字节是0xFF | 发送模式下第一位预置的时机太晚,主机已采样 | 在ACK_ADDR的下降沿就预置第一个bit,而不是等TX_DATA的下降沿 |
| 数据块接收完后缺最后一个字节 | 停止条件到来前从机没有保存最后一字节 | 在SDA中断的停止条件分支里判断bit_count,若等于8也要存入缓冲区并置rx_done |
第一个问题很隐蔽。SDA配置成开漏输出后,如果ODR默认是0,SDA会被一直拉低,总线忙,任何主机都起不了通信。初始化把ODR置1相当于释放总线,这是必须的一步。
第二个问题我在调试时遇到了好几次。主机的起始条件要求SDA下降沿后保持至少0.6us(快速模式)到4.7us(标准模式),如果MCU在SDA中断里处理时间太长,还没退出中断SCL就开始翻转,后面的采样就会错位。解决办法一是优化中断函数,把耗时操作移到主循环;二是把主机的I2C时钟降一档,比如从400kHz降到100kHz。
第四个问题也值得展开。写操作最后,主机发完最后一字节数据后,会发出停止条件(SCL高电平时SDA上升沿)。这时从机的状态可能还停在RX_DATA,bit_count等于8但还没进入ACK_DATA分支。所以我特意在SDA中断的停止条件判断里加了bit_count == 8的判断,把最后一字节保存。这个坑如果不处理,主机会发现总是少收到最后一字节数据。
我在实际调试中还有一个体会:如果项目里主机和从机都是自己写的,建议先跑通"主机写、从机收"这一方向,再跑反方向。两个方向都能稳定跑过1000次后再上业务逻辑。模拟从机的核心问题不是代码复杂度,而是对时序细节的把握,把中断优先级处理好、状态机梳理清楚,后面移植到别的MCU也就是改改引脚的事情。这个demo后续还可以扩展时钟拉伸、多字节寄存器读写、错误重试等功能,底子打好了,加功能就是水到渠成的事。
本文还有配套的精品资源,点击获取