行业资讯
📅 2026/9/9 18:03:48
STM32F1 I2C从机中断接收不定长数据:CubeMX配置与HAL库解析
简介I2C是嵌入式系统中常用的串行通信协议主从通信时从机需准确响应总线事件。在STM32开发中通过STM32CubeMX可快速完成外设初始化但实现可靠的I2C从机中断接收并不像主机发送那么简单。从机必须处理地址匹配、每字节到达及时读取以及一帧结束的判断。理解HAL库从机中断链路是解决不定长数据接收的关键从机依赖STOP条件判断帧结束借助大缓冲和中断回调实现变长接收并用双缓冲避免数据覆盖。基于STM32F1实际工程详解CubeMX配置步骤、中断服务路径及常见踩坑点帮助开发者快速掌握I2C从机中断接收技巧提升通信可靠性。 用STM32CubeMX配置STM32F1的I2C主机半天就能跑通点几下鼠标HAL_I2C_Master_Transmit一发逻辑分析仪上波形清清楚楚。可一旦调转角色让F1做I2C从机还要配合主机不定长的数据帧用中断方式把数据收下来很多人就开始卡壳了从机地址到底填0x32还是0x64HAL_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最高36MHzF103而这个分频会直接影响SCL的实际频率。CubeMX虽然会自动算CCR值但很多人后期改过时钟树后没有重新生成代码导致SCL频率漂移通信时好时坏。所以我的建议是别绕开硬件I2C去把HAL库的从机中断路径吃透。摸清之后后面用DMA、用多线程处理都会顺畅很多。2. CubeMX配置逐项拆解外设、时钟、地址与NVIC2.1 外设模式与时钟配置在CubeMX中打开I2C1或I2C2Mode选择I2C即可。这里不需要在界面上区分主从I2C总线本来就是多主多从的主从角色由运行时调用哪个API决定。Parameters Settings里几个关键项I2C Speed Mode选Standard Mode100kHz还是Fast Mode400kHz取决于你的主机侧支持多快。从机这边不需要跑得比主机快但至少要能跟上主机的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是36MHzI2C工作在400kHz快速模式没问题。如果你把APB1拉到了F1允许的36MHz以上有些超频玩法I2C时序就会乱。2.2 从机地址的配置细节Addressing Mode选7-bitOwn 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参数传的就是0x32HAL库内部会帮你左移。从机侧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Ω对应400kHz2.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一直为1SCL被时钟拉伸卡住主机端就会因为等待过久触发超时。解决办法有三个方向一是把接收逻辑完全交给DMA中断只处理完成回调二是压低主机速率I2C业界标准允许主机降频这不丢人三是优化临界区代码尽量缩小关中断的窗口。从实际项目看最省心的是走DMA路由尤其是一帧数据动辄几十个字节的场合。中断方式适合帧比较短、系统负载不高的场景但从代码层面理解中断链路无论换哪条路都是基础。最后再分享一个小技巧调试这种从机中断接收手里一定得有一根逻辑分析仪。别去猜主机的波形直接抓到SCL/SDA看看主机是否发了STOP、地址是否匹配、从机有没有ACK。硬件上的问题波形一抓就全都清楚了。我手头这台F103的工程用这个方法排查地址和上拉问题前后一共花了不到十分钟。本文还有配套的精品资源点击获取