ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32 Bootloader USB升级跳转失败?B_ENDP_ALIGN与中断残留排查指南

STM32 Bootloader USB升级跳转失败?B_ENDP_ALIGN与中断残留排查指南 用 USB 做 STM32 升级的坑我估计不少人都踩过。bootloader 里集成了 USB 设备走 DFU 或者 USB 虚拟串口把固件下载到 flash下载完成后执行跳转结果 APP 起不来调试器一挂PC 指针停在B_ENDP_ALIGN这个错误码附近后面很可能还跟着一个while(1)死循环。如果你也碰到这个现象基本就和标题里三个关键词有关STM32、bootloader、B_ENDP_ALIGN核心原因是有未处理的中断导致跳转后进了不该进的中断服务函数。这篇文章把整套链路讲透报错到底怎么来的为什么“重启系统”能解决以及更关键的——正确的“受控重启”应该怎么写而不是简单按一下复位键了事。1. 先从现象说起B_ENDP_ALIGN 到底卡在哪里1.1 使用 USB 升级的典型工程结构很多产品会做两个固件区bootloader 区放在 flash 起始地址APP 区放在后面偏移的位置。上电先跑 bootloaderbootloader 检查是否有升级请求、固件是否完整如果没有升级指令就直接跳转到 APP如果有升级包则通过 USB 下载到内部 flash下载完再跳转。整体规划大概是bootloader 区0x08000000大小 32KBAPP 区0x08008000大小 96KB固定标志位或者固件版本信息放在 flash 末尾或者独立 sector。这种结构下bootloader 里集成 USB 设备非常常见。USB DFU 是官方标准方案USB CDC 虚拟串口是很多团队的替代方案操作简单上位机直接用串口工具发 bin 文件就行。问题往往就出在“下载完成后跳转”这一步。USB 外设和普通串口不一样它有独立的 OTG 控制器、端点缓冲区、DMA 中断而且中断优先级和状态机都比较复杂跳转前如果没把这些状态收拾干净APP 启动的瞬间什么怪事都可能发生。1.2 卡死的典型表现形式第一种下载完固件调用跳转函数屏幕上没有任何输出板上 LED 不闪上位机断开连接程序就像被冻住了一样。复位一下APP 又能正常跑。第二种接了调试器在线调试跳转过程会发现 PC 停在 USB 中断服务函数里具体位置是一个错误状态码分支也就是B_ENDP_ALIGN。有些库代码写得很直接if ((endpoint 0x03) ! 0) { return B_ENDP_ALIGN; }或者是在端点状态不对时进入了一个错误分支。总之CPU 一直在 USB 相关代码里打转出不去了。第三种APP 本身没有使用 USB但 bootloader 用了 USB。跳转后 APP 跑起来中断一触发就 HardFault或者中断不进功能完全异常。其实这些底层逻辑是同一个跳转时中断残留。1.3 B_ENDP_ALIGN 是什么B_ENDP_ALIGN是 STM32 USB 设备库中一个状态码字面意思是“端点缓冲区或端点地址没有按硬件要求对齐”。STM32 的 USB 端点在双缓冲、DMA 操作时对缓冲区地址有 4 字节对齐的硬性要求。硬件是死的地址不对就报错。库代码里很多地方会对端点号、传输方向、缓冲区地址做检查一旦发现对齐条件不满足就返回这个错误码。但要注意一点B_ENDP_ALIGN 只是“压死骆驼的最后一根稻草”。很多情况下你的端点地址和缓冲区本身没有问题问题是 USB 外设根本没被初始化好就有人闯进了 USB 的中断处理流程随便取到一个异常状态最后落在这个错误分支里出不来。1.4 先确认卡死点而不是盯着错误码看遇到这种问题第一件事不是上网搜 B_ENDP_ALIGN 是什么意思而是先确认程序到底卡在哪一行。在调试器里挂住之后打开 Call Stack 窗口看当前栈顶函数是谁。如果是 USB 中断处理函数那基本可以断定是中断残留导致的。再看看当前读取的端点寄存器值如果USB_OTG_FS-DTXFSTS之类的内容乱七八糟说明外设状态根本不在正常逻辑里。另外打开工程的 map 文件确认USB_OTG_FS_IRQHandler对应的代码地址到底在 bootloader 区还是 APP 区。很多时候你会看到中断入口跳到了 bootloader 的 USB 处理函数但 APP 的库代码完全没参与。这说明中断发生时向量表还停留在 bootloader 的位置APP 的向量表重映射没有生效。这个细节非常关键几乎可以一锤定音。2. 解剖根因中断残留和端点状态错乱2.1 未处理的中断是怎么残留的很多人对“关闭全局中断”有误解以为跳转前写上__disable_irq()就万事大吉。但实际上__disable_irq()只关门不打扫房间。NVIC 里的中断使能寄存器、挂起寄存器是在跳转前一直保持的。比如你在 bootloader 阶段使能了 USB OTG 中断USB 在传输过程中来了一次中断但因为当时中断优先级被某种方式屏蔽或者刚好被 CPU 暂时挂起这个 pending 位就留在 NVIC 里了。跳转到 APP 后APP 的启动代码开始执行某个时刻执行到__enable_irq()或者HAL_Init()里重新打开全局中断NVIC 发现 USB 挂起位还亮着就立刻把 USB 中断请求抛给 CPU。而此时 APP 的向量表可能已经重映射到 0x08008000但 APP 的 USB 驱动还没初始化端点是空的外设状态是乱的。中断服务函数拿着这些垃圾值去跑端点处理流程自然就会撞上 B_ENDP_ALIGN 这类错误分支。2.2 USB 外设状态残留同样致命中断残留只是其中一个问题USB 外设本身的寄存器状态也是个雷。bootloader 里完成了一次 USB 枚举和文件传输此时 USB_OTG_FS 控制器的设备地址、端点使能、FIFO 状态都已经配置好了。跳转到 APP 后如果 APP 也要用 USB重新初始化时会执行一堆写寄存器操作。但很多初始化代码是“置位”而不是“先复位再置位”比如端点使能寄存器之前已经使能了现在再使能一次可能不会做任何事导致端点状态停留在 bootloader 的配置里APP 的 EP 描述符根本写不进去。更麻烦的是有些 USB 中断线程还挂在半路上。比如 USB 的 DMA 还在搬运数据或者某个传输完成中断还没来得及处理跳转一执行整个程序状态就乱了。网上不少帖子建议“跳转前把 USB 外设时钟关掉”这个方向是对的但只关时钟不一定能清干净挂起中断最好的办法是彻底复位一次。2.3 跳转代码里最常见的三个坑我见过太多跳转代码只写了三行void jump_to_app(void) { uint32_t app_addr *(volatile uint32_t *)0x08008004; void (*app_main)(void) (void (*)(void))app_addr; __set_MSP(*(volatile uint32_t *)0x08008000); app_main(); }这个写法在“裸跑、无中断、无外设”的 demo 里可能没问题但放到带 USB 的 bootloader 里三个坑直接踩爆第一个坑没关闭全局中断。跳转瞬间如果来了中断CPU 会去找中断向量表但此时栈指针和 PC 都可能已经切换中断一进来直接跑飞。第二个坑没处理 PendSV、SysTick 这类内核中断。SysTick 中断在 bootloader 里开着跳转到 APP 后又没有重新配置SysTick 就会在“bootloader 的时间和 APP 的时间”之间反复横跳。APP 如果依赖 HAL 的HAL_IncTick时间基准就被污染了。第三个坑APP 工程的向量表偏移没配置。bootloader 里设置了SCB-VTOR 0x08008000但 APP 工程在编译时还是从 0x08000000 开始中断向量表和代码都对齐到了错误的位置。虽然有些芯片上SCB-VTOR可以在运行时报改但 APP 里的链接脚本、分散加载文件必须同步修改否则数据段位置不对。2.4 为什么按一下复位键就好了实践中最简单的临时解决办法是“重启系统”很多人的第一反应也是这个。按一下复位键SP 重新从 0x08000000 读栈顶PC 跳到 Reset_Handler所有外设寄存器恢复到复位默认值NVIC 所有挂起和使能位清零SysTick 停止USB 控制器关断。整块芯片回到一个完全干净的状态再启动 APP自然什么问题都没有了。但问题在于手动复位之后是重新跑 bootloader不是直接跑 APP。如果你的 bootloader 每次上电都要检查升级指令、等待几秒用户或者产线就会觉得升级效率很低。所以“重启系统”这件事不能靠人工按键必须在代码里自动化地完成并且要让 bootloader 识别出“接下来直接跳 APP”而不是又进入等待升级的流程。3. 正解跳转前做一次“受控的系统重启”3.1 思路转变别试图逐个清理直接整体复位有的工程师很较真想在跳转前把所有外设停掉、把所有中断清干净、把所有 DMA 通道关掉然后直接跳转到 APP觉得这样更“优雅”。我试过这种做法花费大量时间来回调试最后还是发现总有几个边角状态照顾不到。USB 外设的寄存器太多每个版本库的初始化状态还不一样靠手工清理完全不现实。正确思路是跳转前做一次受控的系统复位。用代码让芯片“自己重启”而不是直接跳到 APP 地址。我们先把 bootloader 的意图写到一个断电不丢失、复位不改变的 RAM 地址再调用 NVIC_SystemReset 让整个系统彻底复位。复位后 bootloader 重新从入口开始执行但一上来先检查这个 RAM 标志发现“刚才有人要求直接跳 APP”就不再等待升级而是直接执行跳转。这时候的外设和中断已经全部复位干净跳转自然就顺了。3.2 跳转前要做的完整动作清单受控重启不是简单调一行NVIC_SystemReset()它需要按顺序做几件事关闭全局中断防止重启过程中又被中断拉走停掉 SysTick 和 Tim 相关的中断源如果有 USB、以太网这类复杂外设主动复位对应外设的时钟把“需要直接跳转 APP”的标志写入 RAM 特定地址等待写操作完成调用NVIC_SystemReset()。这里面第 2 步容易被忽略。SysTick 的异常一旦挂着复位后虽然内核会清掉但如果你复用的是 SysTick 的校准值会出现时间基准不对的情况。第 3 步也不是必须的因为系统复位本身就会把外设寄存器全部复位但主动调用外设复位可以确保外设时钟域内的状态也被强制清掉有些芯片手册会要求这么做。3.3 代码模板带启动标志的 bootloader 跳转函数下面是我在实际项目里用的模板直接抄过去改一下地址就能用。#define BOOT_FLAG_ADDR 0x20000000 #define BOOT_FLAG_VALUE 0xA5A5A5A5 #define APP_FLASH_BASE 0x08008000 /* 复位后 bootloader 主函数先调用这个判断 */ uint8_t boot_check_need_jump(void) { if (*(volatile uint32_t *)BOOT_FLAG_ADDR BOOT_FLAG_VALUE) { *(volatile uint32_t *)BOOT_FLAG_ADDR 0; return 1; } return 0; } void boot_enter_app(void) { uint32_t app_stack *(volatile uint32_t *)APP_FLASH_BASE; uint32_t app_reset *(volatile uint32_t *)(APP_FLASH_BASE 4); /* 1. 关闭全局中断再停掉 SysTick */ __disable_irq(); SysTick-CTRL 0; SysTick-VAL 0; /* 2. 如果是 USB 升级主动复位 USB OTG 外设 */ __HAL_RCC_USB_OTG_FS_FORCE_RESET(); __HAL_RCC_USB_OTG_FS_RELEASE_RESET(); /* 3. 设置启动标志并在内存屏障后复位 */ *(volatile uint32_t *)BOOT_FLAG_ADDR BOOT_FLAG_VALUE; __DSB(); NVIC_SystemReset(); /* 正常执行不到这里 */ while (1); } void boot_jump_to_app_now(void) { uint32_t app_stack *(volatile uint32_t *)APP_FLASH_BASE; uint32_t app_reset *(volatile uint32_t *)(APP_FLASH_BASE 4); /* 此时系统已经复位过外设和中断都是干净状态直接跳转 */ __disable_irq(); if (app_stack 0xFFFFFFFF || app_reset 0xFFFFFFFF) { /* flash 里没有有效 APP */ return; } /* 设置 APP 向量表偏移 */ SCB-VTOR APP_FLASH_BASE; __set_MSP(app_stack); ((void (*)(void))app_reset)(); while (1); }bootloader 的 main 函数开头这样做int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); if (boot_check_need_jump()) { boot_jump_to_app_now(); } /* 正常流程等待升级指令或超时后跳转 */ MX_USB_DEVICE_Init(); while (1) { /* 升级逻辑 */ } }第一次跳转时boot_enter_app()被调用写标志后系统复位。复位后 main 执行到boot_check_need_jump()发现标志是0xA5A5A5A5立刻清除标志并调用boot_jump_to_app_now()。此时所有寄存器都已经恢复到复位默认值APP 的 USB 中断即便立刻来临走的也是 APP 自己的库代码不会再踩到 bootloader 残留状态。3.4 APP 端需要做的配合修改APP 工程必须知道自己跑在 0x08008000而不是 0x08000000。Keil 里的操作是打开 Options for Target把 IROM1 的 Start 改为 0x08008000Size 根据 APP 实际 flash 大小调整比如 0x0001800096KB。如果用了 STM32CubeMX还需要在 System Core NVIC 里设置向量表偏移SCB-VTOR 0x08008000;有些教程建议在 SystemInit() 里改因为那是最早执行的代码在 main 开头改也行但要注意在中断使能之前改完。最好的做法是 CubeMX 直接生成带 VTOR 配置的代码它会自动放到SystemInit()后面。这样跳转之后第一个中断发生前向量表已经指向 APP 区。另外 APP 里尽量不要用“擦除整个 flash 或整个 sector”的操作。如果 APP 要支持固件自升级也必须有边界检查知道 bootloader 占用的空间不能动。3.5 Keil/IAR 下工程配置注意点工程配置里有几个容易忽略的点我单独列一下分散加载文件scatter file里的起始地址必须和 bootloader 跳转地址完全一致。Keil 默认的 sct 文件是LR_IROM1 0x08000000要改成0x08008000否则链接出来的数据段位置错乱。编译 APP 时VTOR的值和IROM1起始地址必须统一。有些工程师只在运行时改 VTOR不修改 IROM1导致编译出的常量数组位置和运行时地址不匹配。如果开了看门狗 IWDG跳转前和跳转后都要喂狗否则系统复位后 IWDG 还在跑APP 还没初始化完就先被复位了。这个坑在长时间升级时会尤其明显。使用 RTOS 时跳转前需要确保没有挂起的 PendSV 异常。简单的做法是__disable_irq()之后在跳转前手动把NVIC-ICER对应位清掉或者就像前面说的直接系统复位RTOS 状态根本不会被带入 APP。4. 没 USB 也会踩跳转相关的排查清单4.1 几个常见跳转失败现象对照表USB 只是触发“未处理中断”的常见外设实际上任何外设中断残留都可能导致跳转失败。我整理了一个对照表方便排查现象直接原因核心对策跳转后 HardFault中断向量表映射错误中断跑到非法地址检查 SCB-VTOR 和 IROM1 配置跳转后卡死在某个外设中断 ISR外设中断挂起位未清除ISR 读取异常状态跳转前关中断清挂起或系统复位跳转后 USB 设备无法识别USB 端点状态残留Vbus 枚举状态混乱复位 USB 外设复位后清状态跳转后 SysTick 频繁触发bootloader 开启了 SysTickAPP 时间基准错乱跳转前关闭 SysTickApp 重新初始化跳转后按键无响应看门狗复位IWDG 在 bootloader 开启APP 没及时喂狗跳转前/APP 启动后尽快刷新 IWDG跳转后进制式栈溢出合法还在使用 bootloader 的栈栈空间冲突跳转前让出栈使用 APP 栈顶4.2 手动清理中断的替代方案如果不愿意用系统复位也可以手动清理中断但代码要写得很仔细。基本思路是遍历所有 NVIC 中断源把使能位清掉把挂起位清掉void clean_nvic_interrupts(void) { uint32_t i; /* 关闭所有 NVIC 中断使能 */ for (i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; } /* 清除所有挂起中断 */ for (i 0; i 8; i) { NVIC-ICPR[i] 0xFFFFFFFF; } /* 恢复默认中断向量表 */ SCB-VTOR 0x00000000; /* 关闭 SysTick */ SysTick-CTRL 0; SysTick-VAL 0; }这段代码在 MCU 只有 8 个 NVIC 中断组时基本够用如果是大容量型号需要根据实际支持的中断数量调整循环上限。手动清理最大的缺点是代码和具体芯片强耦合换一款 MCU 就要重新查中断号范围而且你永远不知道某个外设是不是还有一些“隐藏”状态位没被清掉。所以我自己用下来的感觉是手动清理适合快速验证生产项目还是用受控复位更靠谱。4.3 调试技巧用 LED 和串口标记跳转节点跳转类问题最怕的就是“静默卡死”。代码停在某个地方但屏幕上啥都没有你只能盲猜。我在所有跳转路径上都放 LED 状态指示和串口日志bootloader 上电LED 亮一下串口打印[BL] boot start收到完整固件LED 闪烁两下串口打印[BL] firmware ready准备跳转LED 常亮串口打印[BL] jump to app 0x08008000系统复位后再次进入 bootloaderLED 快速闪三下打印[BL] flag found, jump nowAPP 启动成功LED 熄灭打印[APP] app started。这样哪怕出问题你只要看一下 LED 停在哪个阶段就能立刻缩小范围。如果是卡在 USB 中断里LED 很可能停在“准备跳转”常亮的状态因为跳转函数已经执行了但之后 CPU 就再也回不到正常流程了。4.4 还有一类“玄学”编译器优化导致跳转崩溃还有一种情况我踩过很深编译优化等级开高之后跳转函数里明明写了正确的逻辑但实际执行时 SP 或者 PC 被编译器重排导致跳转崩溃。这种问题在-O2或-O3下比较常见。检查方法很简单先把优化调到-O0试试如果问题消失那就要在跳转函数上单独标注不优化__attribute__((optimize(O0))) void boot_jump_to_app_now(void) { ... }一些老项目里跳转函数用汇编写反而更稳定。比如可以嵌入一段汇编__asm volatile( LDR SP, [%0]\n LDR PC, [%0, #4]\n :: r(APP_FLASH_BASE) : memory );但要注意用汇编跳转之前C 运行时环境里的静态变量、栈帧可能会被遗留在原地。好在系统复位后再跳C 环境已经由 Reset_Handler 重新建立不需要担心这个问题。5. 实际案例和多年经验总结5.1 一个 USB CDC 升级跳转失败的完整案例之前做一个量产设备bootloader 通过 USB CDC 接收固件APP 是业务逻辑跑 FreeRTOS。量产烧录时用 USB 下载固件后跳转十台设备里有两三台会出现设备管理器里 USB 枚举失败拔插数据线后 APP 才能跑起来。一开始怀疑是 USB 线质量问题换了几种线现象依旧。后来接调试器看发现问题就出在跳转后第一个 USB 中断上。APP 启动时 USB 还没初始化但 NVIC 里挂起位还在APP 一旦开中断USB 中断立刻进来执行的是 APP 的 USB 处理代码但那时代码里枚举状态还是 0USB 库拿着未初始化的端点表一顿操作最后停留在 B_ENDP_ALIGN 的错误分支里。改用“写 RAM 标志 NVIC_SystemReset”的方案后问题彻底消失。量产几十台再也没有遇到枚举失败。这件事让我明白一个道理与其把 bootloader 的退出逻辑写得花里胡哨不如拆成两步——先申请重置再带着“干净的芯片”进入 APP。5.2 为什么我后来坚持“bootloader 里只做最少的初始化”传统上很多人喜欢在 bootloader 里启用一堆外设USB、串口、LED、按键、flash 模拟 EEPROM、在线调试接口。这些功能每个看起来都很有用但每一个外设都意味着一个中断源每一个中断源都可能成为跳转时的隐患。我现在设计 bootloader 的原则是一切从简。只启用升级必要的外设比如 USB 或者串口能不用中断就尽量轮询定时器能不用就不用真要用也只在升级过程中打开升级完成、进入跳转前把所有外设时钟全关掉。这样做的好处是跳转逻辑非常简单宁可让 bootloader 代码土一点也不要让 APP 启动时去处理一堆历史遗留状态。5.3 这个 B_ENDP_ALIGN 的坑也可以延伸到其他 MCU不只是 STM32我用过的一些国产 M4/M3 内核芯片也有类似问题。它们在架构上模仿 STM32NVIC 机制差不多也是跳转后中断残留导致 APP 异常。例如某款以 N32 开头的国产 MCUbootloader 跳转 APP 后 APP 无法触发中断排查下来同样是向量表偏移和中断使能位没处理干净。解决方法一模一样跳转前系统复位利用 RAM 标志位做二次跳转。这说明“系统复位后跳转”这个思路是跨芯片可复用的值得收进你自己的工程模板里。5.4 最后再分享两个小习惯第一个习惯永远不要在跳转代码里调用HAL_Delay或者任何依赖中断的延时函数。跳转前中断已经被关闭SysTick 已经停了调用这些函数会无限卡死。我见过有人把HAL_Delay(10)放在跳转前当“稳定等待”结果 CPU 就在里面转圈永远走不到跳转那一步。第二个习惯跳转函数的入口和出口要做“日志化”。哪怕只是用一个 GPIO 翻转作为标记也要写。因为跳转类问题往往只出现在量产现场现场可没有调试器你只能靠这些简单的硬件表现推断问题卡在哪一段。我在 PCBA 上留了一个测试点专门用来测跳转的电平时序整条链路 30ms 内完成线上只需要看示波器就能确认跳转是否成功。这些经验都是反复踩坑踩出来的。B_ENDP_ALIGN 这个名字看起来很技术本质就是环境不干净。把“未处理的中断”这个根因解决掉就不会再遇到这种卡死了。
返回列表