尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32模拟I2C从机实战:状态机设计、中断处理与踩坑总结

STM32模拟I2C从机实战:状态机设计、中断处理与踩坑总结 简介面向STM32/GD32嵌入式开发者提供一套C语言编写的模拟I2C从机demo代码解决MCU缺少硬件I2C从机控制器、或应用场景不适合占用中断资源时的从机通信问题。代码在GD32F130平台验证思路可迁移到其他STM32系列主机读取时序START ADDW REG START ADDR REG1 REG2 CRC与主机写时序START ADDW 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不匹配就释放SDANACK。数据传输方向由地址第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上升沿负责收数据采样SDASCL下降沿负责换阶段切换状态、准备ACK或下一位数据。地址阶段和数据接收阶段虽然都是8个bit的采样过程但收完后的处理不同——地址收完要判断是否匹配数据收完要存缓冲区。因此我用一个bit_count变量区分当前收了多少位在SCL下降沿检查这个计数。另外要设计好超时保护。如果主机在通信中途掉线比如只发了起始条件就挂死从机状态机会一直停在RX_ADDR状态这时后面再来的数据会被误当地址处理。可以在主循环里加一个超时检查超过比如10ms没收到新的SCL边沿就强制回IDLE。3. 手把手实现的Demo代码3.1 引脚初始化SDA开漏输出外部中断的配置我用的型号是STM32F103SCL接PA6SDA接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个典型问题问题现象可能原因解决方案主机找不到从机NACKSDA开漏模式下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默认是0SDA会被一直拉低总线忙任何主机都起不了通信。初始化把ODR置1相当于释放总线这是必须的一步。第二个问题我在调试时遇到了好几次。主机的起始条件要求SDA下降沿后保持至少0.6us快速模式到4.7us标准模式如果MCU在SDA中断里处理时间太长还没退出中断SCL就开始翻转后面的采样就会错位。解决办法一是优化中断函数把耗时操作移到主循环;二是把主机的I2C时钟降一档比如从400kHz降到100kHz。第四个问题也值得展开。写操作最后主机发完最后一字节数据后会发出停止条件SCL高电平时SDA上升沿。这时从机的状态可能还停在RX_DATAbit_count等于8但还没进入ACK_DATA分支。所以我特意在SDA中断的停止条件判断里加了bit_count 8的判断把最后一字节保存。这个坑如果不处理主机会发现总是少收到最后一字节数据。我在实际调试中还有一个体会如果项目里主机和从机都是自己写的建议先跑通主机写、从机收这一方向再跑反方向。两个方向都能稳定跑过1000次后再上业务逻辑。模拟从机的核心问题不是代码复杂度而是对时序细节的把握把中断优先级处理好、状态机梳理清楚后面移植到别的MCU也就是改改引脚的事情。这个demo后续还可以扩展时钟拉伸、多字节寄存器读写、错误重试等功能底子打好了加功能就是水到渠成的事。本文还有配套的精品资源点击获取
返回列表