做嵌入式最烦的事是什么不是改 bug而是要升级已经装在生产现场的固件。如果你手里有一批 HC32F460 的设备想通过串口做 IAP 升级那这篇文章你可以直接抄作业。串口 IAP 本身并不复杂麻烦就麻烦在中断向量表重定向这一步搞不定App 写进去了也跑不起来而且连问题在哪都很难查。我最早用华大 HC32F460 做 Bootloader 的时候就是照着网上 STM32 那套思路来下位机收数据、擦 Flash、写 Flash、然后改一下向量表偏移、跳转结果 App 死活进不了中断。串口打印的日志是“跳转成功”但实际上整个系统就像死了一样连 SysTick 都不工作。后来定位到是向量表重定向没做对而且不是简单地写一个 VTOR 寄存器就结束的里面还牵扯到 Flash 等待周期、外设复位、SCB-VTOR 的地址对齐这些问题。这篇文章把这套流程完整拆一遍内容包括分区设计、串口协议、跳转前要做哪些收尾工作、向量表到底怎么重定向最稳以及如何使用 RTT 来调试 IAP 过程。目标读者是正在做或准备做 Bootloader 的嵌入式工程师尤其是第一次碰华大系列 MCU 的朋友看完应该能少走不少弯路。1. 项目概述HC32F460 串口 IAP 到底在解决什么问题1.1 先从串口 IAP 说起IAPIn-Application Programming就是在应用中进行编程Bootloader 就是一个跑在用户 App 之前的引导程序专门负责接收新固件、写入 Flash然后跳转执行新的 App。对于 HC32F460 这种 Cortex-M4F 内核的国产 MCU串口 IAP 是最常见也最实用的升级方式因为量产设备不一定接入了网络也没有 SWD 调试口可用但串口几乎一定有。HC32F460 的资源对 IAP 来说很充裕2MB 的 Flash部分型号 1MB 或者 512KBSRAM 也够用。我在项目中用的型号是 1MB FlashBootloader 占了 32KBApp 分区从 0x00008000 开始剩余空间足够跑业务逻辑。串口用的是 USART1波特率 115200配合 DMA 接收来降低 CPU 占用升级一个几十 KB 的固件只要几秒钟。串口 IAP 的完整流程拆开来大概是这么几条线Bootloader 负责引导App 负责业务两者共用同一个串口升级时 Bootloader 从串口接收固件数据包写入到 App 分区写完做校验CRC、长度、版本号校验通过后跳转到新的 App跳转的关键动作就是切换栈指针、设置向量表、跳转 Reset_Handler。1.2 中断向量表重定向为什么是 IAP 的“命门”中断向量表重定向是整个 IAP 方案里最容易翻车的环节。正常 MCU 上电后CPU 从 0x00000000 地址取栈顶指针从 0x00000004 地址取复位入口然后开始跑 Bootloader。Bootloader 里会有自己的中断向量表串口中断、定时器中断这些都用 Bootloader 的向量表来跳转。当 Bootloader 跳转到 App 时如果中断向量表还停留在 Bootloader 的地址范围App 里即使写了中断服务函数也不会有任何中断能进去。还有一个容易被忽略的点即使你把中断向量表设置到了 App 的起始地址如果 App 里面的中断服务函数本身没有正确编译和链接或者中断向量表地址没有对齐 Cortex-M4 要求的 128 字节边界结果都是一样——中断不响应。很多工程师跳转后觉得“死机了”其实 CPU 还在跑只是所有中断都失效了看起来像死机。HC32F460 的中断向量表重定向相比 STM32 稍微特殊一点后面我会专门讲。但核心思路不变让 CPU 能正确地从 App 的向量表里取中断入口地址。1.3 这篇文章适合谁来读如果你正在做以下事情那这篇文章应该能帮到你产品要支持远程或者现场串口升级手上用的是华大 HC32F460 系列 MCU已经在用 HC32F460但 Bootloader 的 App 跳转有问题中断一直进不去想了解 RTT 在 Bootloader 调试中怎么用不想把 printf 和数据接收混在同一个串口里第一次接触 IAP 和中断向量表重定向需要一份尽量少踩坑的实操笔记。这篇文章不讲太多空洞的理论重点放在能落地的方案和代码上。中间会穿插一些我实际踩过的坑这些坑在数据手册里基本找不到。2. 核心细节解析中断向量表重定向的原理和 HC32F460 的差异2.1 Cortex-M4 内核怎么使用中断向量表Cortex-M4 内核的中断向量表是一块连续的地址区域每 4 字节一个入口。向量表的最开始是初始栈指针 MSP第二个是复位中断 Reset_Handler后面跟着各种异常和外部中断的入口地址。CPU 响应中断时会从向量表里读出对应的入口地址然后跳转执行。向量表的位置由系统控制块中的 VTOR 寄存器决定地址是 0xE000ED08这是 ARM 内核标准的寄存器。VTOR 寄存器里有个 TBLBASE 位用来选择向量表是在 Flash 还是 RAM还有 21 位的偏移字段表示向量表相对 0x00000000 或者 SRAM 基地址的偏移量。关键要求是向量表地址必须按 128 字节对齐因为偏移字段的最低 7 位是固定的 0。这里我要强调一下很多人写 SCB-VTOR 的时候不注意对齐用了一个没有按 128 字节对齐的 App 起始地址结果向量表偏移设置进去之后实际生效的地址被截断了自然就出问题。所以 App 分区起始地址最好选在 0x00008000、0x00010000 这种对齐得很好的位置。2.2 HC32F460 重定向方式与 STM32 的差异STM32 的 Bootloader 资料非常多很多人直接把 STM32 的模式套到 HC32F460 上。STM32 的做法一般是调用NVIC_SetVectorTable或者直接写SCB-VTOR APP_ADDR然后设置 MSP 跳转大多数情况下就 OK 了。但 HC32F460 在实际工程中有几个需要注意的点这里单独说。第一HC32F460 的内核虽然是 Cortex-M4FVTOR 寄存器也存在但我在项目中发现如果你在 App 里直接改 VTOR且 App 代码中对 Flash 等待周期Flash Wait Cycle配置不合适就有可能导致取向量表时读到错误数据。这个问题在老批次芯片上比较容易复现稳妥的做法是在 Bootloader 初始化里就把 Flash 等待周期配置合理跳转前不要改掉它。第二HC32F460 在官方库的启动文件和系统初始化里有的工程模板会默认把中断向量表重定向到 SRAM。这个机制和 STM32 的 VTOR 是两回事它把向量表复制到 SRAM 起始区域然后通过系统控制寄存器把中断向量映射到 SRAM。如果你用了这种模式Bootloader 跳转 App 的时候就要注意 App 的启动代码是否也做了同样的初始化两者不一致的话中断向量表的来源就会错乱。我的建议是在一个工程里只使用一种重定向方式要么用 SCB-VTOR 直接偏移到 App 起始地址要么用 SRAM 映射不要混着来。我在项目里选的是 SCB-VTOR 方案代码简洁也符合大部分嵌入式工程师的习惯。3. 完整实操过程从 Flash 分区到跳转 App3.1 Flash 分区规划和链接脚本修改不管用哪家 MCU做 IAP 的第一步都是规划 Flash 布局这一步千万不能省。我的分区方案是区域起始地址大小说明Bootloader0x0000000032KB启动引导和升级逻辑App 区0x00008000最大 480KB业务代码固定起始地址参数/备份区0x0007F0004KB存版本号、升级标志、升级计数Bootloader 分 32KB 其实是很充裕的串口升级逻辑加上 Flash 驱动一般不超过 16KB。App 只要不超过 480KB 就能正常工作。参数区我单独划了 4KB用于保存升级标志、当前 App 版本号、上次升级结果等这个设计在后来的生产调试里帮了大忙Bootloader 可以根据参数区的标志决定是跳到 App 还是进入升级模式。分区确定后改链接脚本的时候要小心。Keil MDK 中分散加载文件scatter 文件把 IROM1 的起始地址改到 0x00008000LR_IROM1 0x00008000 0x00078000 { ER_IROM1 0x00008000 0x00078000 { *(.text) *(.text*) *(.rodata) *(.rodata*) *(.data) *(.data*) } RW_IRAM1 0x20000000 0x00020000 { *(.bss) *(.bss*) } }如果你用 IAR就是改 icf 文件里的define symbol __ICFEDIT_region_IROM1_start__ 0x00008000;并把长度改成 App 分区的大小。GCC 的话改链接脚本 ld 文件里的 FLASH 区域 ORIGIN 为 0x00008000LENGTH 为 480K。有一点要特别留意App 工程里链接脚本只改起始地址是不够的还要保证中断向量表会被放到这个起始地址。ARM 的启动文件 startup_hc32f460.s 里面定义了 vector_table它会被放到代码段的最开始。确认向量表在生成的 bin 文件开头这是 IAP 能不能跳转成功的前提。3.2 串口升级协议设计和状态机串口升级协议不用做得很复杂但要可靠。我的设计是“帧头 帧类型 载荷 CRC16”所有多字节都用小端序。帧类型分为握手、开始升级、数据包、结束包、应答包五种。其中数据包的载荷就是 App bin 文件的内容每包 512 字节。协议细节如下字段字节数说明帧头20xAA 0x55帧类型10x01 握手0x02 开始0x03 数据0x04 结束0x80 ACK0x81 NACK载荷长度2载荷有效字节数载荷N最大 512CRC162从帧类型到载荷结束的 CRC16Bootloader 的上层逻辑是一个简单的状态机typedef enum { ST_IDLE, ST_HANDSHAKE, ST_START, ST_RECEIVING, ST_VERIFY, ST_JUMP } upgrade_state_t;每次收到完整的一帧就根据当前状态做转移。数据包是连续到达的Bootloader 把载荷写入 Flash写完后返回 ACK上位机收到 ACK 才发下一包。如果返回 NACK 或者超时未收到 ACK上位机重发当前包。这个机制虽然牺牲了一点速度但可靠性在高干扰环境中很值得。超时处理也要设计好。我用的方案是串口空闲中断加大循环超时如果 2 秒内没有收到任何数据状态机自动回到 IDLE并继续尝试跳转已有 App。防止升级过程中断线后设备变成砖头。3.3 Bootloader 端关键代码实现Bootloader 的核心逻辑集中在三个地方Flash 驱动、串口接收、跳转函数。先说跳转函数这是重定向中断向量表的关键我直接贴完整代码#include hc32_common.h #include ddl_flash.h #define APP_FLASH_BASE 0x00008000UL typedef void (*app_entry_t)(void); static void deinit_peripherals(void) { /* 复位用过的外设串口、时钟、DMA 等 */ SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; /* 关闭 USART1 接收和发送相关中断 */ USART_DisableIrq(FC1_USART, USART_RX_IRQ); USART_DisableIrq(FC1_USART, USART_TX_IRQ); /* 关闭 DMA 中断 */ DMA_DeInit(); } static void jump_to_app(void) { uint32_t app_sp; app_entry_t app_entry; uint32_t *p_vtor (uint32_t *)APP_FLASH_BASE; /* 检查 App 向量表第一个字是否为有效栈指针 */ if (p_vtor[0] 0xFFFFFFFF || (p_vtor[0] 0x03) ! 0) { return; } __disable_irq(); deinit_peripherals(); /* 设置中断向量表偏移 */ SCB-VTOR APP_FLASH_BASE; __DSB(); __ISB(); app_sp p_vtor[0]; app_entry (app_entry_t)p_vtor[1]; /* 切换主栈指针 */ __set_MSP(app_sp); __enable_irq(); app_entry(); while (1) ; }这个函数里的几个细节值得说说。首先跳转之前要把用到的外设都关闭并复位否则 App 初始化的时候很可能遇到未清除的中断标志一开中断就立刻进异常。我项目中就遇到过这种问题Bootloader 里开了 USART1 接收中断跳转前没有关闭App 初始化串口时使能 NVIC 中断后残留的中断标志导致 HardFault。其次SCB-VTOR APP_FLASH_BASE这条语句的后面一定要加__DSB() 和 __ISB()。DSB确保在设置 VTOR 前的存储操作都完成ISB清空流水线避免处理器还在使用旧的向量表信息。有些工程不加这两条短期也能跑是因为运气好但不是每次都稳。第三关于__enable_irq()写在哪里的问题。我的写法是在跳转前就把中断打开让 App 的 Reset_Handler 一上来就可以响应中断。如果你不确定 App 的启动代码里会不会主动开中断也可以不在这里打开让 App 自己管理。我建议保持代码简单在跳转前打开全局中断即可App 的启动代码里通常会再次设置中断优先级分组等不会冲突。Flash 写入的代码我封装了一个函数注意 HC32F460 的 Flash 编程前必须要调FLASH_WaitCmd等待命令完成连续擦写时还要注意切换扇区static bool flash_write_data(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i; if ((addr APP_FLASH_BASE) || (addr len APP_FLASH_END)) { return false; } /* 写入前确保目标扇区已擦除 */ for (i 0; i len; i 4) { FLASH_Program(addr i, *(uint32_t *)(buf i)); FLASH_WaitCmd(); } /* 回读校验 */ for (i 0; i len; i) { if (*(volatile uint8_t *)(addr i) ! buf[i]) { return false; } } return true; }这里我简化了扇区擦除的逻辑实际做的时候要先按扇区擦除再写擦除后建议马上回读一遍全 FF确认擦除成功再写数据。3.4 App 端需要做的改动App 端的改动比 Bootloader 少但容易漏。第一件事就是修改链接脚本的 Flash 起始地址和 3.1 节里说的一样App 工程和 Bootloader 工程的起始地址不同。第二件事是确认系统初始化时中断向量表会重定向。如果你用的是华大官方库启动文件里通常有一个VECT_TAB_OFFSET的宏App 工程里把它改成对应 Flash 偏移#define VECT_TAB_OFFSET 0x00008000ULSystemInit 函数里会执行SCB-VTOR FLASH_BASE | VECT_TAB_OFFSET;如果你是在 main 里自己做那就在外设初始化之前加上这段SCB-VTOR 0x00008000UL;注意地址要按 128 字节对齐0x00008000本身就是对齐的没有对齐问题。如果你的 App 区不是从 0x00008000 开始比如放在 0x00010000那也是一样对齐的。还有一个 App 独有的坑App 的 bin 文件必须是从 App 起始地址开始的可执行镜像不能包含 Bootloader 前的偏移。用 Keil 生成 bin 时选择fromelf --bin --outputapp.bin app.axf这个 bin 就是从 0x00008000 开始的。如果你发现生成的 bin 文件开头多了 0x8000 字节的填充说明生成方式有问题需要重新设置。4. 用 RTT 调试 IAP 的实战经验4.1 为什么我推荐 RTT 而不是 printf调试 Bootloader 的时候最简单的调试方式是串口打印但这里有个矛盾IAP 本身就占用了串口你再用同一个串口打印调试信息升级协议和打印输出会混在一起非常痛苦。用两个串口当然可以但很多时候板子只留了一个调试串口。我后来换了方案用 J-Link 的 RTTReal-Time Transfer调试信息和 IAP 串口完全隔离。RTT 是 SEGGER 提供的调试通道走的是 SWD 接口不需要额外占用芯片的串口对 HC32F460 这种自带 SWD 接口的 MCU 来说非常合适刷新率高、实时性好。而且 RTT 的速度远高于串口打印打印几百条日志不会有明显卡顿这在中断频繁的 IAP 调试中很有用。你在写 Bootloader 的时候可以在关键节点打各种级别的日志擦除进度、写帧计数、CRC 校验结果、跳转前寄存器状态都能实时看到。4.2 RTT 的接入步骤和典型用法接入 RTT 需要几个文件SEGGER_RTT.c、SEGGER_RTT.h、SEGGER_RTT_printf.c和SEGGER_RTT_Conf.h这些在 SEGGER 官网可以下载到。把文件加入工程后在需要打印的地方调用头文件中的 API 即可。初始化是在 main 开头调用SEGGER_RTT_Init()然后就可以用SEGGER_RTT_printf(0, Bootloader start, app addr 0x%08X\r\n, APP_FLASH_BASE); SEGGER_RTT_printf(0, Receive frame: seq %d, len %d, crc 0x%04X\r\n, seq, len, crc); SEGGER_RTT_printf(0, Erase sector %d done\r\n, sector_idx); SEGGER_RTT_printf(0, CRC verify %s\r\n, ok ? OK : FAIL);然后用 J-Link 调试器连上板子在 PC 上打开 J-Link RTT Viewer选择设备型号 HC32F460连接后就能在窗口里看到实时输出。RTT 调试最爽的场景是调试跳转过程。跳转前后的代码执行有很强的时间性先用 RTT 打印当前状态再打印 App 启动的初始化信息。如果 App 启动后能打印出“App init done”那说明向量表重定向和跳转都成功了。如果只有 Bootloader 的打印没有 App 的打印那问题大概率出在跳转或启动代码可以针对性地排查。4.3 RTT 调试中易踩的坑RTT 也不是完全没有坑我列几个实际用下来需要注意的地方。第一RTT 依赖 J-Link 和调试口保持连接。IAP 过程中如果 Bootloader 初始化代码把 SWD 调试引脚复用成普通 IORTT 会直接断链。HC32F460 的引脚复用配置里SWDIO/SWCLK 默认是调试功能但如果你不小心重映射了连接就会断开RTT 再也没有输出。遇到这种情况先用复位恢复连接再检查引脚配置。第二RTT 默认缓冲区大小是 32KB 上行、1KB 下行在SEGGER_RTT_Conf.h里可以改如果打印的频率太高缓冲区满了之后继续调用SEGGER_RTT_printf会丢数据或者阻塞。我在 Bootloader 里没有大量打印只在关键节点打印所以没遇到这个瓶颈。如果你要打印高速数据适当调大BUFFER_SIZE_UP就好。第三RTT 的 Control Block 有时候会因为编译优化被裁剪导致连接后看不到输出。遇到这种情况在 GCC 下可以用__attribute__((used))修饰_SEGGER_RTT这个结构体变量或者把优化等级降低一点一般能解决问题。5. 常见问题与排查技巧实录5.1 典型问题速查表问题现象根本原因解决办法跳转前打印一切正常App 完全无输出中断向量表未重定向或重定向地址不对确认 App 里设置了SCB-VTOR 0x00008000检查链接脚本App 能启动但所有中断一开就 HardFault跳转前外设中断没有关闭中断标志残留跳转前检查串口、DMA、定时器全部 DeInit清除中断挂起位串口升级过程中数据大部分正确偶发丢包波特率过快加上没有流控或者长度边界处理有问题降低波特率到 115200 或 57600添加 ACK 重传机制写入 Flash 后校验失败Flash 写入等待时间不足或者扇区没有正确擦除调用FLASH_WaitCmd等待写完成擦除后回读确认全 FF升级 App 后串口打印全是乱码Bootloader 和 App 外设时钟配置不一致没有重新初始化引脚App 启动后重新配置引脚复用和串口参数看门狗导致升级过程中系统复位升级耗时超过看门狗喂狗周期在升级主循环中按帧喂狗或者升级前临时关闭看门狗RTT Viewer 显示“Cannot find RTT Control Block”RTT 缓冲区被优化掉或者 SWD 连接异常确认SEGGER_RTT_Init()已被调用给_SEGGER_RTT加 used 属性跳转进 App 后偶尔运行不稳定可能过一会才挂Flash 等待周期配置不对Flash 访问时间不够在时钟初始化中设置正确的 Flash 等待周期5.2 几个隐藏比较深的坑第一个坑是跳转地址的合法性检查不够严格。我在 jump_to_app 里加了p_vtor[0] 0xFFFFFFFF的判断但是这只是检查向量表有没有擦除过。如果 Flash 里写的是一份损坏的 App栈指针可能指向一个非法地址跳过去也会挂。稳妥的做法是给 App 头部加上版本号和 CRC 字段Bootloader 跳转前先验证整个 App 固件的 CRCCRC 有效才跳无效就进入等待升级状态这样能避免设备变成砖头。第二个坑和 Flash 擦写期间的全局中断有关。HC32F460 执行 Flash 擦写时如果此时来了中断即使中断服务函数没有调用 Flash 相关代码也可能导致擦写命令执行异常。我在工程里用了一对临界区保护__disable_irq(); /* Flash program */ __enable_irq();但注意擦写操作本身耗时不可忽略临界区不能包太长。实际做法是每包 512 字节的数据写入时临闭中断写完再开这样中断延迟最多几百微秒既安全又不会影响系统实时性。第三个坑是 App 工程和 Bootloader 工程中断优先级分组不一致。Cortex-M4 的优先级分组通过 AIRCR 寄存器配置如果 Bootloader 里设置成 3 位抢占优先级跳转后 App 里改成 0 位抢占优先级那么中断使能后之前挂起的中断行为会完全不一样这也可能导致跳转后出现无法解释的卡死。我在 Bootloader 跳转前不会去动 AIRCRApp 初始化时按自己的需求设置一次并保证整个系统全局只有一次配置。第四个坑是关于 App 里缓存了 Bootloader 的中断状态。如果你在 App 启动后直接NVIC_EnableIRQ(USART1_IRQn)而某个外设中断使能位在 Bootloader 阶段已经置位App 阶段又没清 NVIC 的挂起位可能一开中断就进入一个没有对应的中断服务函数的异常。所以我写跳转函数时特意把用过的外设中断全部 disable 掉也在deinit_peripherals()里清空 NVIC 挂起位。写在最后的经验碎片再说一个和调试工具相关的经验。RTT 配合 HC32F460 的 J-Scope 之类功能还能看全局变量实时变化但在 Bootloader 调试中RTT 的打印信息已经足够覆盖大部分需求。关键是在写日志的时候要有节奏感不要所有地方都堆日志。我以前在擦写循环里每条打印都带数据缓冲内容结果日志量大到 RTT 缓冲经常溢出反而不容易找出问题。后来只在状态转换点打印帧级别只在出错时才打明细整个升级过程一目了然。串口 IAP 项目做完之后我最有体会的一点是Bootloader 不是一次性的代码它需要在产品生命周期里稳定运行所以越是基础的逻辑越要写得保守。比如升级失败时保持原有 App 可运行、升级标志和版本号分开存储、跳转前反复检查向量表有效位这些细节都决定了整机现场维护的成本。这套代码我后来复用到好几个项目里HC32F460 上稳定运行了很长一段时间没有因为 IAP 这个环节出过生产事故。希望这篇文章能帮你把这条路走得更顺一点。