上个月在实验室调一块STM32L452和FPGA之间的I2C通信板子被一个OVR错误折磨了整整两天。现象很诡异单独读写某个寄存器一切正常但在数据稍微频繁一点的应用场景下I2C总线动不动就卡死查到最后才发现根子出在I2C时钟延展上。这篇文章就围绕这条STM32L452主控、FPGA做从机的I2C链路把手动关闭时钟延展的方法和OVR错误的完整排查思路都摊开讲。先说清楚项目背景大家好判断这篇文章适不适合自己。我的系统里FPGA负责多通道ADC数据的实时采集和预处理同时维护了一组配置寄存器STM32L452负责系统控制、电源管理和上位机通信两者之间通过I2C交换控制命令和运行状态。I2C在这里属于典型的“低速但关键”的控制通道可靠性要求远比速率要求高。如果你正在做类似MCUFPGA混合架构恰好又遇上从机响应慢、总线假死、OVR报错之类的问题这篇文章应该能帮你少走不少弯路。1. 项目复盘STM32L452和FPGA的I2C链路到底败在哪1.1 系统架构控制通道为什么选了I2C在这个系统里I2C的角色不是搬运大数据而是传递“指令”和“状态”。FPGA端实现了一组寄存器映射比如采集通道使能、采样率分频系数、滤波参数、运行状态字。STM32L452需要定期修改这些参数并读回状态。选I2C而不是SPI或UART主要有几个考虑一是I2C天然支持一主多从后续如果板子上还要挂传感器总线可以直接复用二是I2C只有两根线对PCB布局友好这在空间紧张的板卡设计里很关键三是FPGA内部做I2C从机模块非常成熟IP核或者自研状态机都行实现成本低。但I2C也带来了一个隐性成本它有一套复杂的握手机制一旦从机行为不合规排查起来特别痛苦。具体到我这块板子FPGA从机模块一开始来源于某个开源工程改的表面上功能正常实际上藏着一个非常深的隐患在数据还没准备好的时候它会主动拉低SCL总线。这个行为在低速场景下几乎不会被注意到但在通信频率稍微提高后就开始频繁触发问题。1.2 故障现象总线时好时坏OVR标志随机出现第一轮联调时我先用简单的单字节读写做了基础验证一切正常。接着加入连续配置多个寄存器的代码结果就翻车了。故障现象有三个特征通信在前几十次内正常之后随机出现超时一旦超时后续I2C事务很难恢复表现为总线“假死”读取错误寄存器时能看到OVROverrun/Underrun标志位被置位。这个现象非常迷惑人因为“随机出现”这条线索在大多数工程师眼里都指向电气问题。我一度怀疑是上拉电阻阻值不对或者板子上有毛刺干扰于是把注意力全放在了硬件侧换了一个又一个上拉电阻方案问题依旧。最后借助逻辑分析仪抓了一段持续运行的波形才发现真正的异常点SCL线上会出现一个异常长度的低电平脉冲持续比别人多出几十微秒。这就说明从机在某个时刻主动把SCL拉低了也就是I2C协议里标准的时钟延展行为。可是问题来了我给FPGA从机模块的代码根本没有写延展逻辑啊。1.3 为什么FPGA会“偷偷”做时钟延展这是整个排查过程里最值得记录的一点。很多工程师和我一样以为只有软件实现的慢速从机才需要时钟延展FPGA这种硬件逻辑数据几乎都是“秒回”的怎么会做延展实际上去查FPGA内部I2C从机的实现就会发现不少从开源社区来的代码在判断“当前字节是否处理完成”时会做一个条件如果下一个字节的缓冲还没就绪就在SCL低电平期间把SCL继续拉低直到内部数据锁存完毕。这个逻辑本意是防止数据丢失在纯FPGA环境下也确实能工作但放在STM32L452做主机、且没有启用NOSTRETCH模式的场景下主机端的I2C外设会认为这是正常的从机延展于是傻傻地等。一旦等待时间超过应用层看门狗或超时阈值上层就判定通信失败。所以FPGA虽然不是故意的但它确实在特定条件下制造了时钟延展。这个问题不抓到波形光靠读代码真的很难定位因为触发条件往往取决于内部寄存器值的变化时序。2. 时钟延展机制深挖I2C里最容易被忽略的握手2.1 时钟延展的本质一个慢速设备的生存机制在深入代码之前先把I2C时钟延展这件事彻底讲清楚。I2C总线的SCL时钟虽然由主机产生但这个时钟并非完全由主机单向控制。由于I2C采用开漏结构任何设备包括从机都可以把SCL拉低。从机在需要额外时间处理数据时就会在SCL处于低电平期间把SCL继续保持为低主机在SCL低电平结束后需要检测SCL是否为高如果检测到仍然为低就不会继续下一个时钟周期而是等待总线被释放。这个从机主动保持SCL低电平的动作就是时钟延展。生活里有个很贴切的类比有人按固定节奏敲门主机产生SCL时钟门里的人如果还没准备好会用手抵住门、让敲门的人下一圈敲不动从机拉低SCL等门里准备好了才放开敲门节奏继续。这本来是I2C协议为了兼容慢速从机而设计的善意机制但代价是如果从机延展时间过长主机的软件超时逻辑就容易炸。2.2 STM32L452的NOSTRETCH关闭延展的支持开关在STM32L452这代L4系列上I2C外设相比早期的F1/F0有了很大改动寄存器更丰富对时钟延展的控制也更灵活。其中最关键的就是I2C_CR1寄存器的NOSTRETCH位。NOSTRETCH位默认是0也就是关闭NOSTRETCH功能此时STM32L452 I2C外设完全支持从机延展如果总线上有从机拉低SCL外设会耐心等待。把NOSTRETCH位置1后I2C外设不再理会从机的SCL拉低请求会按照已经配置好的Timing参数继续产生时钟。在STM32的HAL库里这个配置体现在初始化结构体的NoStretchMode字段hi2c1.Init.NoStretchMode I2C_NOSTRETCH_DISABLE; // 默认支持时钟延展 hi2c1.Init.NoStretchMode I2C_NOSTRETCH_ENABLE; // 关闭忽略时钟延展我这次就是把它从DISABLE改成了ENABLE。这一改SCL上的异常低电平不再影响主机节奏通信瞬间稳定了很多。2.3 关闭延展的真正前提从机必须能扛住主机节奏这里必须强调一个容易被误解的地方。关闭时钟延展不是“万能药”它只是把问题从主机的“等待”转移到了从机的“必须赶上”。如果从机处理速度确实跟不上主机关闭延展后从机来不及处理就会溢出也就是我们接下来要解决的OVR错误。所以在决定关闭时钟延展之前一定要确认两点从机在单个字节接收后能否在下一个SCL周期内完成数据处理和寄存器更新从机在收到读请求后能否确保在SCL由低变高前把要发送的数据准备好。对于FPGA来说这两点通常在逻辑设计层面可以做到因为FPGA是并行硬件处理几个寄存器的延时是纳秒级的只要设计时不留“等一拍再算”的隐藏时序完全可以在一个I2C时钟周期内完成。这也是在这个项目里关闭延展能成功的原因。如果从机是一个软件模拟I2C的低速MCU我强烈建议不要轻易关闭延展反而应该保留默认配置然后去优化从机的中断响应时间这才是更稳妥的方案。2.4 切换NOSTRETCH模式后通信时序有哪些变化开启NOSTRETCH后主机的SCL会严格按照Timing寄存器配置的速率连续输出不再等待从机的延展。表面上看只是少了一种握手实际会影响三个细节主机发送数据时如果从机没来得及在SCL低电平期间锁存数据下一次时钟一来就会覆盖导致数据错乱主机读到NACK时不再有延展作为缓冲错误标志会更快暴露出来对错误处理代码的要求更高如果总线上还有其他支持延展的从机它们的延展行为会被主机完全无视可能导致这些设备工作异常。所以如果你打算关掉延展还得确认整条I2C总线上不会挂别的依赖延展的从机。如果总线上既有FPGA又有一块响应很慢的传感器那么关闭延展就可能会让传感器出问题到时候还得设计分时复用总线这个坑越大。3. 实操记录从FPGA从机到STM32L452的完整修改过程3.1 先改FPGA从机确保不再依赖延展也不产生延展要让关闭延展后的通信稳定第一步不是改STM32而是先保证FPGA从机模块足够“快”。我重新写了FPGA端的I2C从机核心逻辑核心思路是在SCL低电平期间完成寄存器的更新和发送数据的预处理不把任何数据处理放到SCL高电平周期。下面这段Verilog代码是接收路径的核心状态机片段重点是位计数和数据锁存// I2C从机接收状态机简化 reg [3:0] bit_cnt; reg [7:0] rx_shift; always (posedge scl_sync) begin if (!bus_active) begin bit_cnt 0; end else if (bit_cnt 8) begin rx_shift {rx_shift[6:0], sda_sync}; bit_cnt bit_cnt 1; end else begin // 第9个时钟是ACK/NACK窗口这里不做数据操作 bit_cnt 0; end end // 关键在SCL低电平期间把字节写入寄存器组 always (negedge scl_sync) begin if (byte_done !is_read_cmd) begin reg_file[addr] rx_shift; // 立即更新配置 end end这里要注意scl_sync和sda_sync是经过两级寄存器同步、并做了毛刺过滤的信号不能直接用FPGA引脚的原始信号。I2C总线上难免有上升沿噪声同步器能滤掉大部分亚稳态导致的误触发。另外读数据路径也要提前准备在主机发出读指令后从机需要在地址读写位之后的第一个SCL上升沿之前把寄存器值放到SDA上。用FPGA做这个非常合适只要在地址译码完成的下降沿预先锁存数据即可。3.2 调整STM32L452的I2C初始化代码然后回到STM32L452这边。这里我直接用HAL库配置的关键点有两个一是Timing参数二是NoStretchMode字段。CubeMX里的配置步骤如下在Pinout Configuration里找到I2C1使能I2C配置为I2C模式标准模式或快速模式在Parameter Settings里找到No Stretch Mode设置为EnableTiming Calculation里根据实际I2C时钟源设置总线速率。我最终用的配置是400kHz快速模式I2CCLK来自HSI16经过分频CubeMX会自动算出Timing寄存器的值。手工初始化代码大致如下I2C_HandleTypeDef hi2c1; void MX_I2C1_Init(void) { hi2c1.Instance I2C1; hi2c1.Init.ClockSpeed 400000; hi2c1.Init.DutyCycle I2C_DUTYCYCLE_2; hi2c1.Init.OwnAddress1 0; hi2c1.Init.AddressingMode I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode I2C_DUALADDRESS_DISABLE; hi2c1.Init.OwnAddress2 0; hi2c1.Init.OwnAddress2Masks I2C_OA2_NOMASK; hi2c1.Init.GeneralCallMode I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode I2C_NOSTRETCH_ENABLE; // 核心修改点 if (HAL_I2C_Init(hi2c1) ! HAL_OK) { Error_Handler(); } }这里有个很容易踩的坑很多老项目从F1系列移植过来的代码里I2C初始化用的是I2C1_InitSpeedUp等旧接口这些接口在L4的HAL库中已经废弃不会保留NoStretchMode配置导致你改了配置也没生效。如果遇到这种情况建议直接重建初始化流程或者用CubeMX重新生成。3.3 速率选择400kHz真的合适吗在关闭时钟延展后有一个参数值得重新评估I2C总线速率。我最初出于“快”的考虑选了400kHz并在这样的配置下遇到OVR。后来我把400kHz和100kHz分别做了压力测试发现100kHz下即使不关闭延展OVR出现的概率也低很多延迟也在可接受范围。原因是I2C通信的瓶颈往往不在SCL频率本身而在于从机准备数据和主机寄存器访问之间的时序余量。速率越高每个位的时间窗口越小从机必须在这个窗口内完成所有操作。如果你对FPGA内部实现没有绝对把握先在100kHz下跑通再逐步提高到400kHz比一上来就拉满更稳。CubeMX的Timing Calculation页面里可以看到不同速率对应的Timing寄存器值。我最终的项目配置还是保持在400kHz但只在FPGA代码优化到位之后才切回去不建议大家盲目追求高速。3.4 用逻辑分析仪验证波形上怎么看延展和OVR改完代码剩下的问题就是怎么验证修改有效。强烈建议准备一台逻辑分析仪不需要太高端采样率2MHz以上的普及型号就够了。I2C只有两根线接线简单采样率完全不是瓶颈。抓包时要注意设置触发条件我一般是在SDA上升沿触发然后记录一段连续传输。观察波形时重点看SCL低电平是否出现异常加宽如果出现宽度远超正常位周期的低脉冲就是时钟延展正常传输时每个字节的第9个时钟位SDA是高还是低ACK和NACK是否和预期一致OVR错误发生前后SCL和SDA的状态尤其是是否有总线被拉死的情况。我自己的实测结果很直观未关闭延展时每隔一段时间就会在某个寄存器写入的字节后看到SCL被拉低几十微秒关闭延展后同样场景下SCL低电平宽度完全恢复为正常值OVR不再出现。这一步做完问题基本就锁定了。不过OVR错误本身还有不少细节值得继续研究这也是下一章的重点。4. OVR错误全解现场排查、复位处理与防复发4.1 OVR错误到底是什么名字很吓人本质却很简单OVR全称是Overrun/Underrun error在STM32L452的I2C里具体对应两类场景主机模式下的RXOVR接收数据时内部接收寄存器还没被CPU及时读走新的数据又到了导致溢出从机模式下的TXUDR从机要发送数据但发送寄存器里还没准备好主机就开始采SDA导致发送不足。在这个项目里STM32L452是主机但注意OVR标志不只是主机侧的标志它也会在从机侧出现。由于FPGA是硬件从机相关错误更重要的是体现为主机侧超时和总线假死。不过从错误寄存器读到的OVR位确实直接帮我们确认了“时序异常导致数据来不及处理”这一根因。这里要特别提醒读错误标志时不要把OVR和BERR、ARLO混淆。BERR是总线错误ARLO是仲裁丢失它们的处理策略都不同。现场排查时务必要把ISR寄存器的全部错误位都读出来逐位分析才能准确判断故障类型。4.2 从现象到根因一次完整的OVR排查实录我总结的排查路径大家可以照着走一遍第一步确认错误标志。在I2C中断回调或者轮询代码里读取I2C_ISR寄存器记录OVR、BERR、ARLO等位的状态第二步复位总线。把I2C外设使能位关闭等几个微秒再重新使能必要时把SDA和SCL对应的GPIO重新初始化第三步抓现场波形。把逻辑分析仪挂在SDA和SCL上大量传输直到故障复现观察异常出现前后的波形第四步对比正常和异常波形定位是延展导致、还是从机NACK、还是总线被外部设备干扰第五步根据根因选择方案是关闭时钟延展、优化从机时序还是降低总线速率。我这次就是走到第三步时发现SCL异常拉低顺着这条线索摸到了时钟延展。如果当时没有波形分析我大概率还在硬件干扰、上拉电阻这些方向上打转很难想到软件层面的从机行为。4.3 OVR发生后如何快速恢复I2C外设软件复位代码即使配置到位系统中也难免出现瞬时错误比如上电瞬间总线电平不定、或者某个寄存器写入被意外中断。这个时候一个稳健的错误恢复函数就非常关键。下面是我项目里实际使用的错误回调处理流程void HAL_I2C_ErrorCallback(I2C_HandleTypeDef *hi2c) { if (hi2c-Instance I2C1) { __HAL_I2C_DISABLE(hi2c); // 关闭I2C外设 // 复位I2C外设到默认状态 __HAL_I2C_INIT(hi2c); // 重新使能I2C外设 __HAL_I2C_ENABLE(hi2c); // 通知上层应用重新发起通信 I2C1_CommRetry(); } }这里有两个细节值得注意。一是关闭外设和重新使能之间最好留一点时间让总线电平回落二是如果有其他挂在同一条总线上的设备在恢复后要让它们重新确认自己的状态防止出现“主机恢复从机还在蒙圈”的尴尬局面。如果错误非常频繁不要只靠回调恢复还要在上层增加失败重发机制。我在应用层维护了一个简单的帧序号每次发送带上递增序号从机侧校验到重复序号就直接丢弃。这样即使偶发错误发生也不会导致控制参数错乱。4.4 防复发清单从软件到硬件一次过把所有经验汇总成一个清单下次遇到类似问题可以逐条对照类别检查项说明软件NoStretchMode是否已开启STM32L452的I2C_CR1.NOSTRETCH1软件从机是否会在SCL高电平期间锁存数据必须在低电平期间完成数据更新软件上层是否有超时重发机制避免一次失败导致整条链路瘫痪软件错误回调是否复位了外设能快速恢复不留下“假死”硬件上拉电阻是否合适4.7kΩ到10kΩ常用高速场景可能需要更小硬件总线上是否有其他延展从机有的话谨慎关闭主机NOSTRETCH硬件布线是否过长长线电容大会让上升沿变缓加剧时序问题上拉电阻这里多说一句很多工程师喜欢用1kΩ提高速率但过小的上拉电阻会导致驱动能力不足反而让SCL/SDA的低电平无法稳定拉到0.4V以下触发地址仲裁错误。没有示波器看眼图的情况下4.7kΩ是比较保险的起点。压力测试也很重要。我修完这个问题后让板子连续跑了12小时的高频寄存器写入并且一遍一遍地检查OVR标志。只有长时间压力测试通过了才敢说这个方案是真的稳定。建议每个遇到类似问题的项目都把这个测试步骤补上。说了这么多最后分享一点个人体会。I2C这类型的慢速总线看起来结构简单但一旦出现时序相关的疑难杂症往往比高速接口更难排查。这次OVR的根因说穿了就是FPGA作为从机在特定时刻制造了时钟延展而STM32L452作为主机又忠实地执行了延展等待。两者单独工作都没问题放在一起就成了系统性故障。我现在做MCUFPGA混合架构的项目第一件事就是确认两边对I2C时钟延展的预期是否一致。如果FPGA是自研从机尽量保证它不延展如果FPGA从机模块是从开源工程里继承来的必须用逻辑分析仪实测确认它在最极端的数据场景下也不会拉低SCL。然后再决定STM32端是否需要把NoStretchMode打开以及总线速率定在多少。这个经验比任何一段代码都有价值。