简介:I2C是嵌入式系统中常用的串行通信协议,主从通信时从机需准确响应总线事件。在STM32开发中,通过STM32CubeMX可快速完成外设初始化,但实现可靠的I2C从机中断接收并不像主机发送那么简单。从机必须处理地址匹配、每字节到达及时读取,以及一帧结束的判断。理解HAL库从机中断链路是解决不定长数据接收的关键:从机依赖STOP条件判断帧结束,借助大缓冲和中断回调实现变长接收,并用双缓冲避免数据覆盖。基于STM32F1实际工程,详解CubeMX配置步骤、中断服务路径及常见踩坑点,帮助开发者快速掌握I2C从机中断接收技巧,提升通信可靠性。 用STM32CubeMX配置STM32F1的I2C主机,半天就能跑通,点几下鼠标,HAL_I2C_Master_Transmit一发,逻辑分析仪上波形清清楚楚。可一旦调转角色,让F1做I2C从机,还要配合主机不定长的数据帧,用中断方式把数据收下来,很多人就开始卡壳了:从机地址到底填0x32还是0x64?HAL_I2C_Slave_Receive_IT调一次之后要等多久才回调?主机发的字节数不定,接收缓冲设成多大都不对劲。这篇文章就基于我实际调过的F103工程,把CubeMX里从机中断接收的每一步配置、HAL底层中断链路,以及不定长数据帧的处理方法完整过一遍,也把翻过车的几个地方单独拎出来。
1. 从机中断接收到底难在哪:F1硬件I2C的脾气
1.1 主机和从机的思维差异
做主机的时候,你的程序是主动方:调用发送函数,时序就由你的芯片产生,出问题大不了超时重发。做从机的时候,程序完全是被动的,主机什么时候发、发多少、发完是否带stop条件,全都不可控。你要做的不是"发数据",而是"随时准备好被叫醒"。
整段逻辑的核心就变成两件事:地址匹配后能不能及时响应,以及数据进来后能不能在下一个字节到来之前把DR寄存器读走。
F1的I2C外设内部有一个数据寄存器DR,硬件每接收到一个字节,就会把RXNE(接收缓冲区非空)标志置位。如果软件没有及时读取DR,主机继续发下一个字节时,硬件就会通过时钟拉伸(SCL拉低)来让主机等待,直到你读走DR。这个等待机制本身是可靠的,但代价是总线速率被拖慢。如果中断响应太慢,或者中断被更高优先级的东西打断了,SCL上就会出现很长的低电平间隔,有些主机对这种异常时序容忍度很低,会直接报超时错误。
1.2 F1的I2C外设为什么容易让人发怵
STM32F1的硬件I2C在社区里口碑一直两极分化,不少人踩过坑后宁可GPIO模拟。其实F1的I2C模块并没有硬伤,真正的问题出在两点。
第一,F1的I2C事件中断是"多源合一"的。地址匹配、数据接收、发送完成、停止条件这些事件全部走同一个中断入口(如I2C1_EV_IRQHandler),你必须靠读SR1寄存器判断当前是什么事件。HAL库把这些逻辑封装成了I2C_Slave_ISR,但如果你没有理解这个事件分发机制,出了问题看代码也看不出所以然。
第二,F1的I2C时钟是挂在APB1总线上的,APB1最高36MHz(F103),而这个分频会直接影响SCL的实际频率。CubeMX虽然会自动算CCR值,但很多人后期改过时钟树后没有重新生成代码,导致SCL频率漂移,通信时好时坏。
所以我的建议是:别绕开硬件I2C,去把HAL库的从机中断路径吃透。摸清之后,后面用DMA、用多线程处理都会顺畅很多。
2. CubeMX配置逐项拆解:外设、时钟、地址与NVIC
2.1 外设模式与时钟配置
在CubeMX中打开I2C1(或I2C2),Mode选择I2C即可。这里不需要在界面上区分主从,I2C总线本来就是多主多从的,主从角色由运行时调用哪个API决定。
Parameters Settings里几个关键项:
- I2C Speed Mode:选
Standard Mode(100kHz)还是Fast Mode(400kHz),取决于你的主机侧支持多快。从机这边不需要跑得比主机快,但至少要能跟上主机的SCL频率。我的习惯是先按主机侧的额定速率设置。 - Clock Speed (Hz):这个参数配合APB1的实际频率,决定了CCR寄存器的值。F1标准模式下SCL频率约等于
PCLK1 / (2 * CCR),快速模式下还要考虑占空比参数。你不需要手工算,但要知道:CCR的最小值是4(标准模式)或1(快速模式),如果PCLK1过高导致算出来的CCR低于下限,CubeMX会提示错误,这时候只能降低APB1时钟或者换更低的SCL目标频率。 - Clock No Stretch Mode:保持
Disabled。时钟拉伸是从机的救命稻草,主机读写时序不合适时,靠它争取时间。只有你的主机侧明确不支持时钟拉伸,才考虑打开。
还需要确认一下APB1外设时钟。F103默认配置下APB1是36MHz,I2C工作在400kHz快速模式没问题。如果你把APB1拉到了F1允许的36MHz以上(有些超频玩法),I2C时序就会乱。
2.2 从机地址的配置细节
Addressing Mode选7-bit,Own Address 1填你的从机地址。这里默认是7位地址,比如你填0x32。
要特别注意7位地址和总线首字节的关系。I2C总线上的第一个字节是7位地址 << 1 | R/W,所以:
- 从机地址
0x32,对应的写地址是0x64,读地址是0x65。 - 主机侧用
HAL_I2C_Master_Transmit(&hi2c1, DEV_ADDR, data, len, timeout)时,DEV_ADDR参数传的就是0x32,HAL库内部会帮你左移。 - 从机侧OAR1寄存器里存的也是
0x32,硬件自动和总线上的0x64/0x65做匹配,不需要你手动左移。
很多人在这里犯迷糊:把从机地址配成0x64,结果主机无论怎么发,从机都进不了中断。原因就是硬件比较的是7位地址部分,你填了带读写位的值,自然对不上。Dual Address Mode保持Disabled即可,除非你需要一从机双地址。General Call也保持Disabled,广播呼叫会让总线上所有从机同时响应,业务上一般用不到。
2.3 GPIO和中断优先级
引脚方面,CubeMX会自动把I2C1的SCL/SDA分配到PB6/PB7(或PA8/PA9,看引脚复用)。I2C引脚必须配置为开漏输出+外部上拉,CubeMX默认生成的就是这种配置,不需要手动改。
中断优先级方面,我强烈建议把I2C事件中断(I2C1 global interrupt)的优先级设为中等偏上。比如系统里只有SysTick和I2C两个中断源,优先级设为priority 1(数值越小优先级越高)是可以的。如果设得太低,比如priority 15,一旦系统里其他中断频率较高,I2C的RXNE处理就会被持续延后。F1硬件虽然会通过时钟拉伸等软件来读DR,但中断迟到超过一定时间,多主机环境下的仲裁逻辑会出问题。
另外,在CubeMX的NVIC配置页中,I2C总共有I2C1 global interrupt这一个入口,事件和错误都走它。开启后还要确认对应的I2C1 error interrupt是否一并使能,HAL库内部对错误标志也要及时清除,否则错误标志累积可能导致后续中断异常。
3. 中断接收的运行链路:一次HAL调用背后的完整动作
3.1 HAL_I2C_Slave_Receive_IT到底做了什么
在main初始化完成后,调用一次:
HAL_I2C_Slave_Receive_IT(&hi2c1, rx_buffer, I2C_RX_BUF_SIZE);这个函数不是"注册一个回调就结束了",它做了这些事:
- 把
hi2c1->State置为HAL_I2C_STATE_BUSY_RX,标记从机当前处于接收忙状态。 - 把
pRxBuffPtr指向你给的缓冲区,XferRemSize和XferSize设置为你传入的字节数。 - 清掉I2C的ADDR、RXNE、STOPF等标志。
- 使能I2C事件中断和错误中断(通过CR2寄存器的ITEVTEN、ITBUFEN、ITERREN)。
重点在最后一步:从机地址匹配(ADDR)中断、数据接收中断(RXNE)、停止条件中断(STOPF)此时全部打开。也就是说,调用完这个函数,从机才真正“上线”,之后主机发起通信时,硬件会自动触发中断。
3.2 数据到达时中断服务程序里的动作
以I2C1为例,CubeMX生成的stm32f1xx_it.c里会有一个I2C1_EV_IRQHandler,它会调用HAL库提供的HAL_I2C_EV_IRQHandler(&hi2c1)。这个函数内部进入I2C_Slave_ISR,按顺序检查各种标志:
- ADDR标志:主机发出地址且地址匹配后置位。从机要在这个时刻确认自己是接收方还是发送方。如果是接收方向,硬件进入接收模式,HAL会清除ADDR并继续等待数据。
- RXNE标志:每接收到一个字节置位。HAL把
DR寄存器的值读出来,写入pRxBuffPtr指向的内存,同时递增缓冲指针、递减剩余计数。 - STOPF标志:主机发送停止条件后置位。这是从机判断“一帧数据结束”的关键。
整个过程完全在中断上下文完成。如果数据是一连串字节连续到达,中断会反复触发,每次处理一个字节。这也是为什么中断响应速度如此重要:一个字节没读走,下一个字节到了之后,硬件时钟拉伸就会一直拉着SCL。
3.3 回调函数应该写什么
HAL库在接收完成(达到指定长度或检测到停止条件)后,会调用弱函数HAL_I2C_SlaveRxCpltCallback。我们只需要重写这个函数:
void HAL_I2C_SlaveRxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c->Instance == I2C1) { uint16_t rx_len = I2C_RX_BUF_SIZE - __HAL_I2C_GET_REMAINING_BYTES(hi2c); // 处理接收到的数据 ProcessRxFrame(rx_buffer, rx_len); // 重新开启下一次接收 HAL_I2C_Slave_Receive_IT(&hi2c1, rx_buffer, I2C_RX_BUF_SIZE); } }这里有几个容易被忽略的点。第一,__HAL_I2C_GET_REMAINING_BYTES拿到的是还没接收完成的字节数,用初始大小减去它就是实际收到的字节数。第二,回调执行完后,从机就处于“收工”状态了,ADDR/RXNE/STOPF中断都被HAL关闭了,所以必须在这里重新调用一次接收函数,否则下一帧数据来的时候从机不会应答。第三,回调是在中断上下文里运行的,不要做耗时操作,比如浮点运算、打印日志,或者干脆别在回调里处理业务数据,把数据复制出来、置一个标志位让主循环处理,然后立刻重启接收。
4. 不定长数据帧接收:从STOP位判断一帧结束
4.1 固定长度接收的局限
很多人第一次写从机中断接收,直接设I2C_RX_BUF_SIZE = 8,因为“我预期主机发8个字节”。如果主机恰好每次发8字节,确实没问题。可一旦数据帧变长变短,比如传感器上报数据长度随着命令不同而不同,固定长度接收就出问题了:
- 缓冲区设小了,数据会被截断。
- 缓冲区设大了,比如设成64,主机只发8字节,接收中断将永远不会“满”,回调也就不触发,程序就卡在那儿等后续字节。
主机发完数据一定会发停止条件,所以从机最可靠的一帧结束标志就是STOP位。
4.2 大缓冲+STOPF变长接收的实现
思路很简单:把初始接收长度设为一个足够大的值(只要大于最大可能的一帧长度),然后依赖STOPF中断来提前结束本次接收。
F1的HAL库在I2C_Slave_ISR中检测到STOPF且当前处于接收状态时,会直接把剩余计数置0,调用HAL_I2C_SlaveRxCpltCallback。所以回调里的长度计算依然成立:
uint8_t rx_buffer[128]; volatile uint16_t rx_len = 0; void StartI2C_SlaveReceive(void) { HAL_I2C_Slave_Receive_IT(&hi2c1, rx_buffer, sizeof(rx_buffer)); } void HAL_I2C_SlaveRxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c->Instance == I2C1) { rx_len = sizeof(rx_buffer) - __HAL_I2C_GET_REMAINING_BYTES(hi2c); // 本次收到 rx_len 个字节,数据存放在 rx_buffer // 建议在此处仅做数据搬运,立即重启接收 memcpy(rx_frame, rx_buffer, rx_len); StartI2C_SlaveReceive(); } }注意:HAL_I2C_SlaveRxCpltCallback可能因为两种原因触发,一个是“收满指定长度”,另一个是“收到STOP条件”。在变长接收场景下,几乎都是后一种。如果主机一帧真的能超过缓冲大小,那就属于设计问题了,得加大缓冲或者改用DMA接收。
4.3 双缓冲方案:别让数据处理拖垮下一帧
单缓冲方案有个隐患:如果上一帧数据还没被主循环拿走,下一帧数据已经到了,中断会直接往同一个缓冲区里写,把旧数据覆盖掉。由于中断回调里只能做轻量化操作,真正处理业务数据一般都在主循环里,这个时间差完全可能撞车。
稳妥的解法是双缓冲交替使用。缓冲区分成A和B两块,中断接收时用A,主循环处理时用B;下一帧来的时候切换成B,主循环可以安心处理A里的旧数据,处理完再等接收完成标志,切回A。
#define RX_BUF_SIZE 128 uint8_t rx_buffers[2][RX_BUF_SIZE]; volatile uint8_t rx_active_buf = 0; volatile uint16_t rx_ready_len[2] = {0, 0}; volatile uint8_t rx_ready_flag[2] = {0, 0}; void HAL_I2C_SlaveRxCpltCallback(I2C_HandleTypeDef *hi2c) { if (hi2c->Instance == I2C1) { uint8_t idx = rx_active_buf; rx_ready_len[idx] = RX_BUF_SIZE - __HAL_I2C_GET_REMAINING_BYTES(hi2c); rx_ready_flag[idx] = 1; // 切换到另一块缓冲 rx_active_buf = idx ^ 1; HAL_I2C_Slave_Receive_IT(&hi2c1, rx_buffers[rx_active_buf], RX_BUF_SIZE); } }主循环里是这样消费数据的:
while (1) { for (int i = 0; i < 2; i++) { if (rx_ready_flag[i]) { rx_ready_flag[i] = 0; ProcessFrame(rx_buffers[i], rx_ready_len[i]); } } // 其他业务 }双缓冲的本质是让“接收数据的缓冲区”和“处理数据的缓冲区”解耦,牺牲一倍内存,换取“主机随时发、从机随时收、主循环不慌不忙”的体验。在F103上,140字节的额外RAM开销几乎可以忽略。
4.4 主机侧测试要点
最后用同一个CubeMX工程验证一下从机接收,但把角色反过来:用另一块STM32F103作为主机,发送非固定长度数据。主机侧代码类似:
uint8_t test_data[] = {0x01, 0x02, 0x03, 0x04, 0x05}; HAL_I2C_Master_Transmit(&hi2c1, 0x32, test_data, 5, 100); delay_ms(10); uint8_t test_data2[] = {0xA1, 0xB2}; HAL_I2C_Master_Transmit(&hi2c1, 0x32, test_data2, 2, 100);如果从机的回调里打印出rx_len,第一次是5,第二次是2,说明变长接收已经跑通了。
5. 排查记录:从机接收翻车的几个现场与修复方案
5.1 上拉电阻缺失,总线确认不了地址
F1的IO默认内部有弱上拉,但I2C总线需要外部上拉(典型值4.7kΩ对应400kHz,2.2kΩ对应100kHz以下)。如果板子上没有上拉电阻,SCL/SDA就拉不高,地址匹配永远失败,逻辑分析仪上只能看到主机反复发起传输然后NACK收尾。
注意:并不是每块板子都预留了I2C上拉电阻,用杜邦线飞线测试时最容易忽略这个问题。
5.2 从机地址多了一个读写位
前面已经说过,这算是最常见的配置错误。现象是:主机侧不发报错,但SCL波形显示主机的地址字节总是收到NACK;从机侧的中断完全没进去。
排查技巧:用逻辑分析仪抓第一帧。总线上首字节如果显示0x65,而你的从机地址寄存器里存的是0x32,那么地址匹配应该成功,因为0x65就是0x32左移一位后加读写位。如果总线上显示首字节是0xC8,那一定是在从机地址配置里直接填了“带地址位”的值。
5.3 APB1时钟和I2C速度设置不匹配
CubeMX生成的代码里,HAL_I2C_Init会调用I2C_CalcClockConfig,基于当前的PCLK1和CCR设定计算出实际SCL频率。但如果你后期在CubeMX里改了时钟树,生成代码后却没有重新初始化I2C外设(或者直接拿旧代码编译烧录),实际SCL频率和配置值会出现偏差。极端情况下,SCL频率超过I2C规格上限,从机可能误判起始条件。
我的经验是:每次改完时钟树都重新生成工程,然后看一眼hi2c1.Init.ClockSpeed和实际波形是否一致。手边有逻辑分析仪就抓一下SCL频率,这是最直观的验证。
5.4 中断优先级设置不当丢字节
如果系统里有串口接收、定时器中断等多个中断源,I2C中断优先级设得太低,会出现一个现象:主机明显放慢了发送速度还是偶尔丢数据,但从机里看RXNE标志却正常置位,问题出在中断嵌套上。
比如UART中断优先级高于I2C,主机连续发送时,UART中断加上长串口打印,把I2C中断赶进了“等待队列”。F1的时钟拉伸虽然能拖住主机,但拖不住总线的仲裁超时(某些主机对SCL低电平时间有限制)。我的建议是:I2C事件中断优先级设到priority 2或更低数值,并且禁用所有调试工具在中断里的打印输出。
5.5 主机速率过高,从机处理不过来
主机以400kHz发送一串很长的数据,从机每个字节都要靠中断搬运。如果主循环里恰好有一个很耗时的临界区禁用了中断(可能是某个外设库函数内部的__disable_irq),那么在这段时间里I2C中断无法执行,RXNE一直为1,SCL被时钟拉伸卡住,主机端就会因为等待过久触发超时。
解决办法有三个方向:一是把接收逻辑完全交给DMA,中断只处理完成回调;二是压低主机速率,I2C业界标准允许主机降频,这不丢人;三是优化临界区代码,尽量缩小关中断的窗口。
从实际项目看,最省心的是走DMA路由,尤其是一帧数据动辄几十个字节的场合。中断方式适合帧比较短、系统负载不高的场景,但从代码层面理解中断链路,无论换哪条路都是基础。
最后再分享一个小技巧:调试这种从机中断接收,手里一定得有一根逻辑分析仪。别去猜主机的波形,直接抓到SCL/SDA,看看主机是否发了STOP、地址是否匹配、从机有没有ACK。硬件上的问题,波形一抓就全都清楚了。我手头这台F103的工程,用这个方法排查地址和上拉问题,前后一共花了不到十分钟。
本文还有配套的精品资源,点击获取