ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

GD32F103 IAP升级实战:BootLoader设计、Flash分区与跳转卡死排查

GD32F103 IAP升级实战:BootLoader设计、Flash分区与跳转卡死排查 简介GD32F103在线升级源代码工程以单片机IAP功能为核心面向使用GD32F103C8T6或兼容STM32F103的嵌入式开发人员。工程完整实现了引导程序与用户程序分区将64K片上Flash划分成30K引导区和34K用户区同时给出扇区映射、地址跳转等关键处理可直接移植到类似内核项目中适合需要独立搭建引导加载机制的初级与中级工程师参考。压缩包为ZIP格式共164个文件压缩包大小约624KB包含75个头文件、61个C源文件、8个汇编启动文件以及工程配置文件、分散加载文件、批处理脚本和已编译的hex、bin固件文件类型覆盖从工程编译到固件烧录的完整环节。代码已按GD32F103C8T6的64K ROM地址范围做了空间规划引导程序占用0x08000000至0x080077FF用户程序地址从0x08007800开始延续到0x0800FFFF扇区大小为1024字节。已有5169人学习适合对照学习GD32的Flash管理、IAP升级流程以及Bootloader与App之间的跳转实现。1. IAP升级到底解决什么问题1.1 一个没有IAP的产品有多痛苦先说个真实的场景。三年前我给一家做工业控制面板的客户做了一批GD32F103RCT6设备数量不大两百来台发往全国各地的项目现场。头一个月还好第二个月客户反馈有个功能逻辑不对需要改固件。那会儿没有IAP程序是通过SWD口烧进去的CFG引脚锁死外壳还打了密封胶只能让现场人员拆机、拆胶、接线、烧录、复原一台设备折腾二十分钟两百台就是四千分钟加上差旅费用这个成本直接让项目从盈利变成亏损。那次之后我就把IAP作为这类产品的标准配置不管量大量小只要产品还有可能远程维护我都会预留BootLoader和升级通道。GD32F103这类Cortex-M3内核的国产MCUFlash容量和价格都适合做IAP代码逻辑不复杂稳定性也高是性价比很高的方案。1.2 IAP的本质把程序拆成两段跑IAPIn-Application Programming翻译过来是在应用编程通俗点说就是芯片在正常运行的状态下自己擦除自己的一部分Flash再把新程序写进去然后跳转到新程序里运行。这个过程不需要外部烧录器不需要断电只需要一条可靠的通信链路串口、CAN、以太网、USB甚至无线都可以。GD32F103和STM32F103的架构很相似内部Flash都是从0x08000000开始编址Cortex-M3内核上电后从0x08000004处读取复位向量跳转到复位函数执行。IAP的设计思路就是把Flash按地址分成两个区域从0x08000000开始的低地址区放BootLoader负责接收和写入固件从0x08008000或者更后面开始的高地址区放App负责实际的业务逻辑。两个程序互不干扰App又可以通过某种触发方式串口指令、按键、标志位等跳回BootLoader完成升级闭环。1.3 GD32F103做IAP的可行性选型GD32F103系列主频最高108MHzFlash容量从64KB到512KB不等RAM从20KB到96KB。做IAP时我一般推荐选128KB Flash以上的型号BootLoader分配16KB~32KBApp剩下至少96KB空间比较宽松可以实现功能升级和远程维护。我手头这块板子是GD32F103CBT6128KB Flash20KB RAM主频跑的108MHz完全满足IAP升级需求。下面所有代码和分析都基于这个配置展开配的是GD32固件库标准外设库不是HAL库——为什么不用HAL库后面专门讲。2. BootLoader与App的功能拆分与Flash规划2.1 Flash分区规划与大小计算在做IAP之前第一件事是画一张Flash地址分区表。这就像一个房子的户型图哪块地方放什么东西必须先定清楚不然BootLoader写崩了或者App越界了整机就变砖了。以GD32F103CBT6为例128KB Flash起始地址0x08000000末尾0x0801FFFF。我的分区方案如下区域起始地址大小存放内容BootLoader区0x0800000016KB升级引导程序、Flash驱动、通信协议App区0x08004000112KB业务逻辑程序标志位区0x0801F8002KB升级标志、版本号、校验信息BootLoader分配16KB足够了我见过有人给BootLoader留64KB白白浪费了宝贵的Flash空间App区被压缩得很紧增加新功能还得先删旧功能非常被动。标志位区放在Flash末尾是为了避免和App的代码区冲突同时它不需要很大2KB能存几十个参数了。App区起始地址选择0x08004000是0x400的整数倍也就是16KB的倍数这样既满足Flash扇区对齐GD32F103的扇区大小为1KB也方便代码定位和地址计算。如果换其他型号扇区大小可能不一样比如GD32F303大容量版本扇区是2KB或者4KB分区之前先查数据手册别套模板。2.2 跳转函数与中断向量表重映射跳转是整个IAP过程中最容易出问题的一环核心代码其实就二十行左右但每一行都值得仔细推敲typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; uint32_t app_pc *(volatile uint32_t *)(app_addr 4); pFunction jump_func; if ((app_sp 0xFFF00000) ! 0x20000000) { printf(App栈顶地址非法: 0x%08X\r\n, app_sp); return; } printf(跳转到App, SP0x%08X, PC0x%08X\r\n, app_sp, app_pc); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; for (int i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } jump_func (pFunction)(app_pc); __set_MSP(app_sp); jump_func(); }这个函数的每一步都有讲究。第一读取App区的首两个字第一个字是栈顶地址Initial SP第二个字是复位向量Reset Handler这是Cortex-M3内核的固定格式。第二判断栈顶地址是否落在RAM区间这是防止跳转到空白Flash或者被擦除的区块Flash全0xFF时读出来的栈顶是0xFFFFFFFF不满足0x20000000开头的高位匹配直接return避免程序跑飞。这两个校验非常关键。跳转前把SysTick和外设中断全部关掉这一步容易漏。很多人跳转后App一跑就进HardFault排查半天发现是BootLoader时代开着的某个定时器中断跳到App后App没有配置对应的外设一进中断就执行了空指针或者非法地址直接死掉。我在跳转函数里用循环把所有NVIC中断全部清除简单粗暴但有效。2.3 为什么跳转后一用hal_delay就卡死热词里有个现象很典型“iap跳转后卡死hal_delay”。这个问题我帮人排查过不少次大部分情况并不是跳转本身的问题而是时钟配置问题。GD32F103和STM32F103虽然引脚兼容但内部时钟树并不是完全一样的GD32的HSE时钟启动、PLL倍频配置、AHB/APB分频设置都有差异。如果你的App代码是从STM32工程直接拷过来的用HAL库的HAL_Delay依赖于HAL_RCC_ClockConfig中配置的SysTick频率。跳到App后如果系统时钟变了而SysTick的计数器频率没跟着改HAL_Delay里的循环判断就永远等不到预期的tick表现为卡死。在GD32上用标准外设库写App时第一件事是重新配置系统时钟调用gd32_clock_init之类的函数把时钟树拉起来再配置SysTick。跳转前BootLoader已经把时钟配置成108MHz了App里如果直接用system_clock_config或者systick_config就不会出现这种问题。另外一个更隐蔽的原因是中断分组GD32默认的中断分组和STM32不完全一致App启动时要把NVIC优先级分组重新设置一遍否则中断行为不对也会造成一种“卡死”的假象。3. 核心源代码实现从底层驱动到升级协议3.1 Flash擦写与中断处理代码IAP的核心操作就是Flash擦除和写入GD32的Flash控制器和STM32类似但寄存器和命令序列有细微差别不能直接套用。我封装了一套基于GD32固件库的Flash驱动// GD32F10x Flash擦除与编程函数 #include gd32f10x.h #include gd32f10x_fmc.h uint32_t fmc_erase_page(uint32_t page_addr) { if ((page_addr 0x08000000) || (page_addr 0x08020000)) { return FMC_FAIL; } fmc_unlock(); fmc_flag_clear(FMC_FLAG_BANK0_END | FMC_FLAG_BANK0_WPERR | FMC_FLAG_BANK0_PGERR); fmc_page_erase(page_addr); while (fmc_flag_get(FMC_FLAG_BANK0_BUSY) SET) { // 等待擦除完成 } if (fmc_flag_get(FMC_FLAG_BANK0_WPERR) ! RESET) { return FMC_PROGRAM_ERROR; } fmc_lock(); return FMC_SUCCESS; } uint32_t fmc_write_word(uint32_t addr, uint32_t data) { if ((addr 0x08000000) || (addr 0x08020000)) { return FMC_FAIL; } fmc_unlock(); fmc_flag_clear(FMC_FLAG_BANK0_END | FMC_FLAG_BANK0_WPERR | FMC_FLAG_BANK0_PGERR); fmc_word_program(addr, data); while (fmc_flag_get(FMC_FLAG_BANK0_BUSY) SET) { // 等待编程完成 } if (fmc_flag_get(FMC_FLAG_BANK0_WPERR) ! RESET) { return FMC_PROGRAM_ERROR; } fmc_lock(); return FMC_SUCCESS; }两点必须注意。第一GD32的Flash操作在擦写期间CPU要等待Flash控制器完成这个等待时间如果被中断打断且中断里有Flash访问操作就可能造成总线冲突。所以涉及Flash擦写的这段代码我一般会关掉所有中断或者在中断里坚决不访问Flash。第二每次操作前解锁、操作后加锁是为了防止意外写坏Flash里的其他数据特别是BootLoader自身所在的地址空间。3.2 串口升级协议设计通信协议我用的是简单的帧格式最核心的原则是“够用就好”。一开始我也想用Ymodem协议省事、别人验证过但Ymodem的ACK时序和错误重传策略在某些USB转串口的芯片上不太稳定后来我干脆写了一个更简洁的私有协议整包校验实测下来更可控。#define PKG_HEADER 0xA5 #define PKG_MAX_LEN 1024 typedef struct { uint8_t header; uint8_t cmd; uint16_t len; uint8_t data[PKG_MAX_LEN]; uint8_t crc; } upgrade_pkg_t; uint8_t calc_crc8(const uint8_t *buf, uint16_t len) { uint8_t crc 0; while (len--) { crc ^ *buf; for (int i 0; i 8; i) { if (crc 0x80) crc (crc 1) ^ 0x07; else crc 1; } } return crc; }协议帧由5部分组成帧头0xA5、命令字擦除、写数据、跳转、查询版本、数据长度、数据体、CRC8校验。CRC8多项式用0x07计算速度快资源占用小适合嵌入式环境。命令字我定义了四个命令值功能CMD_ERASE0x01擦除App区CMD_WRITE0x02写入一段固件数据CMD_JUMP0x03固件接收完毕跳转执行CMD_VERIFY0x04校验已写入的固件通信流程简单说就是上位机先发擦除命令擦完再分包发送固件数据每包最大1KB每收一包BootLoader回一个ACK或者NAK上位机收到ACK才发下一包。全部发完上位机发校验命令BootLoader再读Flash做CRC比对一致后发跳转命令App启动。3.3 完整升级流程串联整套升级流程串起来大概是这样的void bootloader_main(void) { system_init(); uart1_init(115200); printf(GD32F103 IAP BootLoader v1.2\r\n); if (check_upgrade_flag() UPGRADE_REQUEST) { printf(检测到升级请求进入升级模式\r\n); upgrade_process(); } else { printf(无升级请求直接跳转App\r\n); delay_1s(); jump_to_app(APP_START_ADDR); } } void upgrade_process(void) { uint8_t cmd; uint32_t app_addr APP_START_ADDR; cmd uart_recv_byte(); if (cmd ! CMD_ERASE) { return; } uart_send_byte(ACK); for (int i 0; i APP_PAGE_NUM; i) { fmc_erase_page(APP_START_ADDR i * 1024); } uart_send_byte(ACK); while (1) { cmd uart_recv_byte(); if (cmd CMD_WRITE) { recv_and_write_data(); } else if (cmd CMD_VERIFY) { uart_send_byte(verify_app() ? ACK : NAK); } else if (cmd CMD_JUMP) { uart_send_byte(ACK); delay_10ms(); jump_to_app(APP_START_ADDR); } } }升级入口的触发方式我用了最稳的一种Flash里放一个固定的标志位区。上位机发一个“进入升级模式”的指令BootLoader启动时读取这个标志如果有效就留在BootLoader里等数据否则直接跳App。另一种常见的做法是上电时检测某个GPIO的电平按键按下就进升级松开就跳App适合现场操作但会占用一个引脚。我倾向于两种方式都支持一个逻辑与的判断操作灵活度更高。4. 编译链接设置与工程配置4.1 App程序的链接脚本修改App程序编译时和普通裸机程序最大的区别在于起始地址不是0x08000000了而是0x08004000中断向量表的位置也要跟着偏移。在Keil MDK中修改方法是找到Options for Target对话框在Target标签页的IROM1起始地址填0x08004000长度填0x0001C000112KB。如果用GCC则是在链接脚本里改MEMORY { FLASH (rx) : ORIGIN 0x08004000, LENGTH 112K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K }App代码的第一件事就是重定位中断向量表// App主函数初始化 int main(void) { SCB-VTOR APP_START_ADDR; nvic_irq_enable_preempt_priority_group(NVIC_PRIGROUP_PRE4_SUB0); system_clock_config(); uart1_init(115200); // 业务逻辑... }SCB-VTOR是Cortex-M3固件里一个非常关键的寄存器它告诉CPU中断向量表在哪里。如果不设置CPU仍然从0x08000000找中断向量也就是去找BootLoader的向量表这样App里任何中断发生都会跳到BootLoader的中断处理函数那里根本没有对应处理轻则中断白进重则程序跑飞。这几乎是IAP跳转后各种诡异问题的头号原因。4.2 生成bin文件与校验Keil工程默认生成的是hex文件用hex做IAP升级也不是不行但hex格式里带了地址信息BootLoader还要自己解析地址段增加复杂度。我基本都是生成裸二进制bin文件直接在固件里按顺序写入Flash简单高效。在Keil MDK中可以这样配置Options for Target - User标签页在After Build/Rebuild栏的User Command里加一行fromelf.exe --bin -o ./Output/App.bin ./Output/App.axf编译完成后自动生成App.bin。如果你用的是命令行构建也可以用下面的方式arm-none-eabi-objcopy -O binary App.elf App.bin生成bin文件之后上位机软件读取这个二进制文件按帧格式分包下发。每包数据的起始地址由BootLoader自己计算App区起始地址加上偏移量不用上位机传地址省掉了一个可能出错的地方。4.3 上位机与调试经验刚开始我用的串口调试助手手动发数据一包一包发速度慢不说中途一个字节错了就得全部重来。后来我写了一个简单的Python升级脚本用PySerial发送固件支持断点续传和自动重试整个流程稳定很多。import serial import struct import time PKG_HEADER 0xA5 PKG_LEN 1024 ser serial.Serial(COM3, 115200, timeout2) def crc8(data): crc 0 for b in data: crc ^ b for _ in range(8): if crc 0x80: crc (crc 1) ^ 0x07 else: crc 1 return crc 0xFF def send_pkg(cmd, datab): pkg struct.pack(BBH, PKG_HEADER, cmd, len(data)) data pkg bytes([crc8(pkg)]) ser.write(pkg) return ser.read(1) b\x06 def upgrade(bin_path): with open(bin_path, rb) as f: firmware f.read() send_pkg(0x01) # 擦除命令 for i in range(0, len(firmware), PKG_LEN): chunk firmware[i:iPKG_LEN] for retry in range(5): if send_pkg(0x02, chunk): break else: print(f包 {i//PKG_LEN} 发送失败) return send_pkg(0x04) # 校验 send_pkg(0x03) # 跳转 print(升级完成) upgrade(App.bin)Python这块用到了struct模块把帧头、命令、长度打包成二进制再用bytes([crc8(pkg)])追加校验字节。上位机协议需要和BootLoader严格一致任何一个字节对齐不对整个通信就全断了。开发时最好先用串口抓包工具看原始十六进制数据确认没问题再写脚本。5. 常见问题排查与避坑总结5.1 跳转卡死问题全排查把热词里“iap跳转后卡死hal_delay”这个现象展开说它包含了嵌入式开发的经验教训。如果你跳转后程序卡死按下面这个顺序排查大多数情况下十分钟内能定位问题。第一确认跳转地址正确。打印跳转前的SP和PC值SP必须落在0x20000000到0x2000FFFF之间PC必须是App区内的地址0x08004000之后如果PC在BootLoader区间说明App的bin文件没有从偏移后的地址编译链接脚本没生效。第二确认中断向量表重映射。App的main函数第一行执行SCB-VTOR APP_START_ADDR别放到后面初始化外设时才执行。中断向量表设置晚了万一中间有个中断触发比如串口接收中断就会抢先跑到错误的地方。第三确认时钟配置。GD32F103的hal_delay卡死最常见就是这个原因。GD32的时钟树和STM32有区别App里一定要重新配置system_clock_config和systick_config不能依赖BootLoader配置好的时钟状态。特别是HSE外部晶振启动失败时GD32会切回内部RC振荡器HAL_Delay的时基全乱表现为卡死或者休眠。第四确认外设中断全部清理。跳转前我把NVIC的所有中断都清了还不够的话App启动早期不要打开外设中断等初始化完成再统一打开。5.2 其他经验与调试技巧除了跳转问题Flash操作中的失败也很常见。有一次擦除App区时发现擦完读出来数据不是全0xFF排查了半天发现是擦除前没有解锁FMC。GD32的Flash控制器在上电后默认是锁定状态不执行fmc_unlock()任何擦写操作都会返回错误。这个错误可能在执行时并不报出来但数据写不进去App跳转后跑不起来。还有一个细节写入Flash时建议用32位字写入不要用字节写入。GD32的Flash编程操作最小单位是字4字节如果用字节方式写CPU要反复执行加载和写入指令效率低而且如果中间被中断打断可能会有未知的副作用。我的fmc_write_word函数一次写4字节连续数据在内存里直接用指针强转避免频繁的偏移计算。调试这类BootLoader程序我强烈建议在关键节点打印调试信息。跳转前打印SP和PC擦除完成打印一个小写字母‘e’每包写完打印一个‘.’校验通过打印‘OK’。这些看着不起眼的信息能在现场帮你省掉大量猜测的时间。我甚至遇到过打印内容里能看到问题的情况App启动后乱码说明波特率不对或者时钟不对没有任何输出说明连复位向量都没执行到反复重启说明看门狗没关——打印是调试的老朋友。5.3 内存地址对齐与产线烧录BootLoader烧进芯片的方式和普通程序一样用SWD或者ISP都可以。我的习惯是产线先用SWD烧录BootLoader生成的hex文件之后App一律走串口IAP升级。这样产线烧录时间短后期维护也方便。App和BootLoader的版本信息管理也很重要。我在Flash末尾保留了一个版本结构体包含App版本号、编译时间、固件CRC值。BootLoader启动时读取并打印这些信息升级工具可以自动比对版本避免重复刷写相同固件。这个模块看似冗余但在批量维护场景下非常实用——客户反馈问题你远程连上设备第一件事就是看版本号确认是不是最新固件。最后说一下地址对齐问题。App区起始地址必须对齐到Flash扇区边界GD32F103的低密度、中密度、高密度型号扇区大小不同有的是1KB有的是2KB、4KB。你从网上找的例程可能是针对其他型号的复制过来前先把数据手册翻到Flash接口那一章确认扇区大小再做分区。这个错误很隐蔽不是每次触发偶尔出现一次Flash读写出错排查起来非常头痛。本文还有配套的精品资源点击获取
返回列表