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

资讯详情

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

STM32L452与FPGA的I2C通信:时钟延展与OVR错误解决方案

STM32L452与FPGA的I2C通信:时钟延展与OVR错误解决方案 1. 项目背景为什么用 I2C 把STM32L452和FPGA接在一起1.1 这套架构解决什么问题最近在调一块板子MCU 用的 STM32L452旁边配了一片中规模 FPGA负责高速数据采集、格式转换和乒乓缓存。FPGA 这边干的活实时性要求很高数据一帧处理完就要主动推给 MCUSTM32L452 这边跑业务逻辑、解析命令、控制外设同时把 FPGA 送来的结果上报给上位机。两块芯片之间的通信如果做不好整个系统的数据链路就断在中间所以这段时间我把大部分精力都花在了这条 I2C 总线上。实测下来这个方案本身是合理的。FPGA 内部用状态机实现一个简单的 I2C 主机采集到数据就按固定帧格式往 STM32 推STM32L452 做 I2C 从机收到帧之后解析、响应。I2C 只有两根线SCL、SDA占用的 PCB 面积和 IO 资源都很少对中低速命令交互来说完全够用还能顺便在总线上挂别的传感器。可越是这种看似简单的方案越容易在细节上翻车——这次翻就翻在“时钟延展”上。1.2 为什么是 I2C而不是 SPI 或 UART可能有人会问FPGA 和 MCU 离得这么近直接用 SPI 不是更快吗SPI 确实快但它是主从严格同步的模型FPGA 做主机时要同时处理 MOSI 和 MISO状态机比 I2C 复杂。更重要的是FPGA 这边上报事件是异步的SPI 要么等 MCU 轮询要么额外加一根中断脚架构上就绕。UART 更简单但双方独立时钟波特率一旦有点误差长时间跑下来就会积累误码而且 UART 没有应答机制数据丢了都不知道。I2C 的中间状态更丰富有地址、有 ACK/NACK、有 STOP天然适合“命令-应答”型任务。我选的 STM32L452 做从机、FPGA 做主机也是看中 I2C 这套状态机制能把“谁发给谁、发没发成”说得清清楚楚。定了方向之后剩下的事情就是让两边在协议细节上对齐——问题恰恰出在我以为“不用对齐”的那部分。1.3 看之前你需要知道的事这篇文章适合正在做 STM32L4 系列L431/L452/L476 等与 FPGA、DSP 或其他 MCU 通过 I2C 联调的朋友。尤其是你遇到“地址对了、第一字节正常后面要么卡死要么乱码”“关闭时钟延展之后又开始冒 OVR 错误”这一类问题时下面这些内容可以直接拿来参考。我默认你已经会用 STM32CubeMX 生成工程也大概了解 I2C 的起始条件、停止条件、地址帧和数据帧长什么样。不了解也没关系遇到名词我会顺手解释但重点还是放在“怎么定位问题、怎么改代码”上。2. 翻车现场现象、排查过程以及如何定位到时钟延展2.1 典型现象第一个字节正常然后整条总线卡死第一次联调时我在 FPGA 侧写了一个很常规的 I2C 主机状态机每隔 100ms 向 STM32L452 发一帧 3 字节命令比如0x5A 0x01 0x00。逻辑分析仪抓出来的波形开头一段是好的FPGA 发出了 START地址是 0x320x64 左移一位后带 W 位STM32 也能看到 ACK。但紧接着问题就来了。SCL 在某一个位置被拉低之后迟迟不释放。按 400kHz 的 I2C 速率算一个 bit 周期只有 2.5µs正常 SCL 低电平也就 1.25µs 左右可我看到的低电平持续了几十微秒甚至更久。FPGA 那边的状态机还在傻等等着 SCL 出现下一个完整的上升沿结果永远等不到整个通信就死在这里。更气人的是代码单步调试的时候现象又不一样——只要我停住内核SCL 反而能释放我一继续跑SCL 又被拉低。这时候已经很明确了SCL 是被 STM32 主动拉住的。ST 的 I2C 外设内部有状态寄存器我读了一下 I2C1-ISR发现 RXNE 置 1 且一直没被清掉。RXNE 表示接收数据寄存器里有数据没被读走而 STM32L452 在从机模式下只要固件没有及时读走 RXDR就会通过拉低 SCL 来“请求主机放慢节奏”——这就是时钟延展Clock Stretching。2.2 第一轮排查硬件、地址、速率都查了一遍遇到这种灵异现象我第一反应是硬件问题。先拿万用表量了 SCL/SDA 有没有虚焊短路又确认了上拉电阻4.7kΩ 上拉到 3.3V正常。再看地址STM32 里配置的 OwnAddress1 是 0x32FPGA 发的也是 0x32正常。再降速试把 I2C 从 400kHz 降到 100kHz现象依旧。到这里电平、地址、速率这些最基础的因素全都排除了。剩下最可疑的就是协议交互层面的行为不一致。我翻了一遍 STM32L4 的参考手册 RM0394在 I2C 章节看到一句话从机在收到地址后、以及每个数据字节后如果内部状态还没准备好就会把 SCL 拉低强制主机等待。这话我早就知道但从来没认真想过它会成为问题——因为之前用的主控芯片都老老实实实现了对时钟延展的等待而这次 FPGA 侧的主机压根没做这一层。2.3 根因定位NOSTRETCH 位决定一切STM32L452 的 I2C_CR1 寄存器里有一个 NOSTRETCH 位bit 14。复位默认值是 0也就是外设一使能时钟延展就是开着的。这个设计本来是个保护机制保证固件处理不过来时数据也不会丢。但前提是总线上的主机必须配合——主机看到 SCL 被拉低就应该停下来等。我的 FPGA 主机状态机从头到尾就没回读过 SCL它只是按固定节拍把 SCL 拉高、拉低遇到从机延展时照样往下跑。这边 STM32 在等 FPGA 释放那边 FPGA 在等 SCL 出现上升沿两边互相等总线自然就卡死了。根因清楚之后修复方向有两个要么改 FPGA 状态机加上对 SCL 的回读和等待要么让 STM32 把时钟延展关掉。考虑到这只是命令交互数据量不大而且后续 FPGA 代码还要继续加功能我不太想动它的状态机所以选择了后者——这也是我这篇文章想重点讲的做法。3. 时钟延展的原理I2C 的“等一等”机制为什么成了坑3.1 协议层面是怎么工作的I2C 和 SPI 最大的区别之一就是 SCL 是开漏结构。所谓“开漏”可以理解成芯片只能把 SCL 拉低不能主动推高总线上的高电平是靠外部上拉电阻实现的。想发高电平就释放引脚让上拉电阻把电平抬上去。这个结构带来一个很巧妙的能力总线上的任何一个设备都可以通过“把 SCL 拉低不放”来阻止时钟继续走。比如从机收到一个字节还没来得及把数据从接收寄存器里读走它就可以拉着 SCL 不放主机这边就算想发下一个字节也发不出来。等到从机处理完了释放 SCL时钟才继续走。这就是“额外插入的等待周期”I2C 协议里叫时钟延展Clock Stretching本质是让慢的一方可以叫停快的一方。对于 STM32L452 这种成熟的 MCU I2C 外设来说延展是硬件自动完成的。从机模式下地址匹配后、每个数据字节接收后、发送模式下 TXDR 没填好之前硬件都会视情况拉低 SCL不需要软件干预。我开头遇到的“SCL 长低电平”就是硬件在等我清 RXNE。3.2 STM32L452 的默认行为和触发点在 STM32L452 上我用的是从机接收模式。默认 NOSTRETCH0也就是延展允许。具体的触发点有三个地方地址匹配后ISR 的 ADDR 标志置 1SCL 被拉住直到软件写 ICR 把 ADDR 清掉每个数据字节接收后ISR 的 RXNE 标志置 1SCL 被拉住直到软件读一遍 RXDR从机发送模式下如果 TXIS 置位但软件没有及时写入 TXDRSCL 也会被拉住。这三个触发点本质都是在保护“固件处理速度跟不上总线速度”的情况。但就像我前面说的这套保护机制要成立依赖主机“愿意等”。遇到一个不回读 SCL 的 FPGA 主机保护就变成了死锁。3.3 FPGA 侧典型的 I2C 主机实现为什么容易栽跟头FPGA 里实现 I2C 主机网上能找到的代码风格大致分两类。一类是纯分频状态机按固定节奏翻转 SCL从不回读 SCL 引脚。这种代码量小时序确定性好我用的就是这种但严格来说它只能算“半个 I2C 主机”因为它没有实现协议里要求的时钟同步动作。另一类会回读 SCL在释放 SCL 后增加一个等待状态确认 SCL 真的变高了再进入下一拍这种就是正确实现。我后来给 FPGA 侧补的等待逻辑核心就是下面这个状态// 释放 SCL 后回读 SCL必须等到真正拉高才继续 WAIT_SCL: begin if (scl_i) begin state NEXT_BIT; // SCL 确实释放了继续 end else begin state WAIT_SCL; // 从机还在延展继续等 end end注意真实工程里这个等待状态必须配一个超时计数器否则从机出 bug 一直拉着 SCL整条总线就永久卡死了。我不太想改 FPGA 状态机还有一个原因是这个等待在所有字节、所有 bit 上都要加改起来容易引入新问题相比之下STM32 侧一个 NOSTRETCH 位就能解决改动面小得多。4. 手把手关闭时钟延展CubeMX、HAL 和寄存器三种改法4.1 CubeMX 和 HAL 的配置方法STM32CubeMX 里I2C1 的参数配置页面有一个 NoStretch Mode 选项默认是 Disabled也就是延展开启。需要手动改成 Enabled对应到代码里就是 HAL 初始化结构体的 NoStretchMode 字段。如果你用的是寄存器版本下面也有直接改寄存器的办法。用 HAL 的话生成出来的初始化函数看起来是这样static void MX_I2C1_Init(void) { hi2c1.Instance I2C1; hi2c1.Init.Timing 0x10707DBC; // CubeMX 自动算好的时序别手改 hi2c1.Init.OwnAddress1 0x32; 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(); } }这里有一个细节要注意如果你是老版本 CubeMX 生成的工程I2C 初始化结构体里可能用的是 ClockSpeed 和 DutyCycle 而不是 Timing。没关系不管哪个版本NoStretchMode 这个字段都在设成 I2C_NOSTRETCH_ENABLE 就行。改完直接跑再用逻辑分析仪抓波形。如果你看到 SCL 低电平时间始终小于一个 bit 周期不再出现那种“低电平拉几百微秒”的现象说明延展已经关掉了。4.2 不改 CubeMX直接改寄存器的做法有些场景不方便重新生成代码比如这个函数已经在别的项目里稳定跑了好几年只想打个补丁。那就直接操作寄存器但切记顺序不能乱void I2C1_DisableClockStretch(void) { // 1. 先关闭 I2C 外设 I2C1-CR1 ~I2C_CR1_PE; // 2. 置位 NOSTRETCH关闭时钟延展 I2C1-CR1 | I2C_CR1_NOSTRETCH; // 3. 重新使能 I2C 外设 I2C1-CR1 | I2C_CR1_PE; }为什么必须先关 PE 再改因为参考手册里明确写了NOSTRETCH 位只能在 I2C 外设关闭PE0时修改PE1 时写这一位是无效的。HAL 的 I2C_Init 函数内部也是按这个顺序操作的所以走 HAL 反而更省心。另外提醒一句这个函数要在任何 HAL_I2C_Slave_Receive_IT 或 DMA 接收启动之前调用。如果是在运行中修改最好选在总线上没有传输动作的时候做否则关闭外设的瞬间FPGA 刚好发来一个 START这次传输会被直接丢弃。4.3 配置完之后的验证方法实践上验证时钟延展有没有真正关掉比想象中简单。把逻辑分析仪挂在 SCL 和 SDA 上让 FPGA 连续发几百帧数据看两件事SCL 低电平的最长时间是否始终小于一个 bit 周期。400kHz 时大约是 1.25µs如果出现明显更长的低电平说明还有别的设备或别的代码在延展总线通信过程中不再出现卡死FPGA 侧状态机能正常走完每一帧。我改完之后第一轮测试卡死问题确实消失了但又冒出来一个新的报错OVR。这个是下一节的重点。5. OVR 错误解决方案关闭延展只是第一步5.1 OVR 是怎么触发的关闭时钟延展之后STM32L452 就不再对主机“喊停”了。主机想多快就多快只要它发的速度超过固件处理速度数据就会来不及接收。具体到从机接收模式就是 FPGA 已经发来下一个字节而固件还没把 RXDR 里上一个字节读走此时 I2C 硬件会丢掉这个新字节并在 ISR 的 OVR 位bit 16置 1表示发生了过载Overrun。我从设备角度感受最深的是时序余量。400kHz 快速模式下一个字节带 ACK 一共 9 个 SCL 周期总时间约 22.5µs。这个时间对 80MHz 主频的 STM32L452 来说只要中断不被打断很久用中断方式处理 RXNE 是绰绰有余的。但就怕两种情况一是主程序里有很长一段关中断的临界区比如写 Flash、刷 LCD或者高优先级定时器中断里跑了不少代码二是 FPGA 侧为了提高吞吐把 I2C 速率拉到 1MHz FM此时一个字节只有 9µs留给软件的时间窗口就非常紧张了。反过来从机发送模式下还有一个 Underrun 问题STM32 当从机给 FPGA 回数据如果 TXDR 没在 SCL 要求的时间点之前填好也会触发 OVR。这个坑更容易被忽略因为不少人的测试流程里只测“接收”不测“回发”。5.2 HAL 工程里 OVR 的处理和自动恢复用 HAL 库时OVR 是走 HAL_I2C_ErrorCallback 这个回调来通知的。HAL 在错误中断里会做两件事先把 OVR 标志清掉再把 I2C 状态机置回 READY然后调用回调。所以我这边要做的就是在回调里判断错误类型然后重启一次接收等下一次地址匹配uint8_t i2c1_rx_buf[64]; volatile uint8_t i2c1_rx_ready 0; void HAL_I2C_ErrorCallback(I2C_HandleTypeDef *hi2c) { if (hi2c-Instance I2C1) { if (hi2c-ErrorCode HAL_I2C_ERROR_OVR) { // HAL 进回调前已经把 OVR 标志清掉了这里复位错误码 hi2c-ErrorCode HAL_I2C_ERROR_NONE; // 重启接收等下一次 START HAL_I2C_Slave_Receive_IT(hi2c, i2c1_rx_buf, sizeof(i2c1_rx_buf)); } } }需要说明的是OVR 一旦发生那个被丢掉的字节是找不回来的。上面这种“重启接收”的办法只能保证后续的帧不受影响对于已经发生的那一帧必须靠协议层的重试机制来处理。如果 FPGA 发的命令是“写配置”“查询状态”这类可重发的操作那没问题如果是“事件上报”这种一次性数据丢了就是丢了。所以我倾向于把这个回调当“保险丝”而不是唯一的依靠。纯寄存器版本的处理更直接在错误中断里判断然后清标志if (I2C1-ISR I2C_ISR_OVR) { I2C1-ICR I2C_ICR_OVRCF; // 写 OVRCF 清 OVR }5.3 治本方案中断优先级、DMA、降速三选一重启接收只是亡羊补牢。想从根上减少 OVR无非三个方向让软件更快、让总线更慢、或者把“搬数据”这件事从 CPU 手里拿出去。最快的改法是拉高中断优先级。把 I2C1 的事件中断EV和错误中断ER都放到 NVIC 的最高优先级保证 RXNE 一到就能被响应。注意 HAL 库的事件中断和错误中断是分开的两个 IRQHandler都要使能不能只开一个。更省心的做法是用 DMA。STM32L452 的 I2C 从机接收支持 DMADMA 会自动把 RXDR 里的数据搬到内存不需要 CPU 逐字节响应OVR 的概率会低很多。HAL 里的用法是HAL_I2C_Slave_Receive_DMA(hi2c1, i2c1_rx_buf, sizeof(i2c1_rx_buf));如果 FPGA 发的是固定长度帧DMA 用普通模式每帧结束后在传输完成回调里重新启动下一次接收就行。如果帧长不固定建议用循环模式配合 STOPF 标志来确定帧边界。关于 DMA 的细节能写一整篇这里先给个方向。如果上面两个都不想改还有一个很土但很有效的办法把 I2C 速率降到 100kHz。一个字节从 22.5µs 变成 90µs留给固件的余量大了四倍基本可以消除 OVR。代价是整条总线的吞吐下降对于命令交互这种场景完全够用。5.4 角色反转STM32L452 做主机时同样适用这套思路不是只能用在“FPGA 当主机、STM32 当从机”。反过来如果 STM32L452 做主机、FPGA 做从机而 FPGA 侧写的是那种不支持时钟延展的简化从机STM32 作为主机接收数据时同样会因为 RXNE 没及时清掉而把 SCL 拉低造成 FPGA 侧误判。这时给 STM32 置上 NOSTRETCH、再配合 DMA 或高优先级中断是一样的效果。也就是说NOSTRETCH 的本质是总线上的时钟完全交给主机控制从机不插嘴。这要求从机的软件响应速度必须够快否则就用 DMA 或降速来换时间。想清楚这一层不管你在总线上扮演哪个角色都能知道怎么搭配置了。6. 常见问题与排查技巧汇总6.1 联调问题速查表我把这次联调踩过的坑整理成了一张表方便你直接照着查现象可能原因排查/解决办法SCL 被拉低超过一个 bit 周期从机时钟延展主机没回读 SCL关闭从机 NOSTRETCH或 FPGA 状态机加 SCL 回读等待关闭延展后 OVR 频繁出现RXNE/TXIS 处理不及时拉高中断优先级 / 用 DMA / 降速 / FPGA 字节间插入延时第一字节正常后面全乱地址后主机在延展期间抢跑抓波形看 SCL 低电平时间查 FPGA 是否回读 SCL地址对了但收不到 ACK从机地址不匹配或总线忙核对 OwnAddress1、抓地址字节波形偶发 OVR复位后又能跑一段时间某段临界区关中断时间过长查 NVIC 抢占优先级、查写 Flash/LCD 等长临界区SCL/SDA 边沿很缓波形像梯形上拉电阻太大或总线电容大4.7kΩ 换 2.2kΩ确认两边 IO 都是开漏停掉调试器就正常跑起来就死调试器停住时恰好释放了 RXNE 等待状态本质还是延展和主机配合问题按第一行处理6.2 几个特别容易忽略的细节第一上拉电阻不要再依赖 MCU 内部的了。STM32 内部上拉大约是几十千欧驱动 100kHz 勉强可以但 400kHz 下边沿明显变缓容易导致误采样。FPGA 那边也不例外板子上最好放 2.2kΩ 到 4.7kΩ 的外部上拉。之前也见过有人问“有外部上拉是不是就不用配内部上拉了”——对外部上拉有了之后内部上拉就不建议再开否则会降低边沿速度还会增加一点无用功耗。第二FPGA 的 IO 必须配成开漏。FPGA 的普通 IO 默认是推挽输出直接驱动 SCL/SDA 会和从机的开漏输出冲突。正确写法是用三态门模拟开漏比如assign scl scl_oe ? 1b0 : 1bz;对外表现成“只能拉低、不能推高”。这一点如果错了不是通信不稳的问题是可能烧 IO 的问题一定要最先检查。第三调试时不要只盯着数据对不对。用逻辑分析仪设置一个触发条件只要 SCL 低电平时间超过 3 个 bit 周期就触发。这样一旦总线出现异常延展你能立刻抓到现场比事后看轨迹高效得多。6.3 一点个人体会最后说点这次折腾下来的心得。做嵌入式联调我最深的感受是协议栈越成熟、外设越智能越容易让人忽略“总线另一端的设备到底怎么想”。I2C 的时钟延展在绝大多数 MCU 组合里都不是问题因为大家都按规范实现了但 FPGA 这种自由度极高的硬件写状态机的人不一定会去翻 I2C 规范里关于时钟同步的章节。所以我现在做方案评审时会多问一句总线上有没有可能不支持时钟延展的设备如果有主机的实现就必须支持回读 SCL或者从机侧主动关闭延展。这次的项目里STM32L452 关闭延展后用 400kHz 中断方式配合 FPGA 每帧之间预留 20µs 的间隔已经稳定跑了两周一次 OVR 都没再出现。如果你也遇到类似的“翻车”希望这篇能帮你省下我踩坑花掉的那几天时间。
返回列表