用 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 区放在后面偏移的位置。上电先跑 bootloader,bootloader 检查是否有升级请求、固件是否完整,如果没有升级指令就直接跳转到 APP;如果有升级包,则通过 USB 下载到内部 flash,下载完再跳转。整体规划大概是:
- bootloader 区:0x08000000,大小 32KB;
- APP 区: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 改为 0x08008000,Size 根据 APP 实际 flash 大小调整,比如 0x00018000(96KB)。如果用了 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 开启了 SysTick,APP 时间基准错乱 | 跳转前关闭 SysTick,App 重新初始化 |
| 跳转后按键无响应,看门狗复位 | 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; - 系统复位后再次进入 bootloader:LED 快速闪三下,打印
[BL] flag found, jump now; - APP 启动成功: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 处理代码,但那时代码里枚举状态还是 0,USB 库拿着未初始化的端点表一顿操作,最后停留在 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 内核芯片也有类似问题。它们在架构上模仿 STM32,NVIC 机制差不多,也是跳转后中断残留导致 APP 异常。例如某款以 N32 开头的国产 MCU,bootloader 跳转 APP 后 APP 无法触发中断,排查下来同样是向量表偏移和中断使能位没处理干净。解决方法一模一样:跳转前系统复位,利用 RAM 标志位做二次跳转。这说明“系统复位后跳转”这个思路是跨芯片可复用的,值得收进你自己的工程模板里。
5.4 最后再分享两个小习惯
第一个习惯:永远不要在跳转代码里调用HAL_Delay或者任何依赖中断的延时函数。跳转前中断已经被关闭,SysTick 已经停了,调用这些函数会无限卡死。我见过有人把HAL_Delay(10)放在跳转前当“稳定等待”,结果 CPU 就在里面转圈,永远走不到跳转那一步。
第二个习惯:跳转函数的入口和出口要做“日志化”。哪怕只是用一个 GPIO 翻转作为标记,也要写。因为跳转类问题往往只出现在量产现场,现场可没有调试器,你只能靠这些简单的硬件表现推断问题卡在哪一段。我在 PCBA 上留了一个测试点,专门用来测跳转的电平时序,整条链路 30ms 内完成,线上只需要看示波器就能确认跳转是否成功。
这些经验都是反复踩坑踩出来的。B_ENDP_ALIGN 这个名字看起来很技术,本质就是环境不干净。把“未处理的中断”这个根因解决掉,就不会再遇到这种卡死了。