ARTICLE DETAIL

资讯详情

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

STM32G0 Flash踩坑记:HAL/LL库擦写、掉电保存与HardFault排查实战

STM32G0 Flash踩坑记:HAL/LL库擦写、掉电保存与HardFault排查实战 最近在STM32G071上做一套参数存储功能本来觉得Flash读写是件稀松平常的事结果被HardFault按在地上摩擦了一整晚。问题根子在于对G0系列Flash架构理解不够加上HAL库和LL库两种接口交替使用时序和参数细节对不上最后查了一整晚寄存器才定位到原因。这篇文章把这段经历完整复盘一遍涵盖G0 Flash的基本特性、HAL库和LL库操作Flash的正确姿势、掉电保存的实用设计以及HardFault的定位方法和避坑清单给正在折腾STM32G0的同行一个可以少走弯路的参考。1. 动手之前先搞懂G0的Flash脾气1.1 G0和F1/F4最大的不同页擦除、单bank、RWW很多从F103或F407转过来的朋友第一反应是拿老经验套G0。这其实是最要命的。F1那种小容量Flash是扇区结构F4是大扇区加小扇区混合而STM32G0全系列都是页Page擦除没有扇区的概念。以我用的STM32G071为例每页是2KB而G030/G031这类小容量型号是1KB一页。这意味着你想只擦4KB就去动第0页和第1页不能像F1那样有个“最小扇区”去对齐。另外一个关键点是RWWRead-While-Write。G0系列支持在Flash擦写期间CPU从Flash继续取指执行这一点和F1完全不同。F1写Flash的时候如果你不把代码搬运到RAM里跑或者不开预取缓冲做特殊处理很容易“自杀”——擦写过程把正在执行的代码冻住了。G0硬件上做了隔离允许一边擦写一边执行代码只要被擦写的页不是CPU当前正在取指和取数的页就行。还有一点要记住读写Flash不需要解锁擦除和写操作必须解锁。G0的解锁时序是往FLASH-KEYR依次写两个固定的键值KEY1和KEY2HAL库和LL库都封装好了但如果你手动操作寄存器时写错了顺序或键值FLASH控制器的LOCK位不会清除后续擦写命令直接无效。我见过一个案例就是在调试时手抖多写了一次键值导致控制器进入了错误状态后面的所有操作全部返回PGSERR。1.2 等待周期和电压条件最容易忽略的两个前提Flash操作有两个前置条件很多人看都不看就开干结果踩坑。第一个是等待周期Latency。G0的Flash读取需要根据AHB总线和供电电压配置等待周期配置不对的话从Flash取指都会出错表现出来就是莫名奇妙的HardFault或随机复位。以G071在2.7V到3.6V供电为例主频不超过32MHz时等待周期可以设为0超过32MHz到64MHz则需要设1个等待周期。CubeMX生成的代码一般会自动配好但如果你手动改过时钟树或者自己重写了SystemClock_Config一定要回头检查FLASH-ACR寄存器的LATENCY字段。这个字段不在FLASH_CR里很多人找半天找不到。第二个是供电电压。G0系列写Flash或擦除Flash时对VDD电压有硬性要求通常需要2.7V以上。如果你的板子用锂电池供电电压掉到2.5V左右Flash擦写操作就可能失败甚至触发硬件错误。G0为了解决低压工作场景在FLASH_CR里提供了一个STROBE位当VDD低于2.7V时要置1让Flash内部电荷泵升压完成编程。这个位是G0特有的F1/F4完全没有所以老工程师最容易在这里栽跟头。如果你发现电压明明只有2.5V但操作Flash完全没反应或者报错先去看看STROBE置位没有。1.3 HAL库与LL库怎么选不是越底层越好HAL库和LL库这问题网上吵了不知道多少年。我自己实际体验下来两者不是对立关系而是互补关系。HAL库的特点是有状态机、有超时机制、有错误码操作Flash时你不需要自己反复查询BSY状态位HAL_FLASHEx_Erase调用完自己会等操作完成然后返回HAL_OK或者错误码。缺点也很明显代码体积大、函数调用层级深、执行流程里塞了一堆防御性判断。但这对于大多数应用场景根本没有影响因为Flash擦写本身就要几毫秒到几十毫秒HAL库多消耗的几百个周期微不足道。LL库则是一层薄薄的寄存器封装直接刷寄存器、直接操作BSY位速度快、代码量小但没有任何超时机制。如果Flash硬件卡死或进入异常状态LL库的while循环会永远等下去表现出来的症状就是程序卡死、看门狗超时复位。LL库也不帮你管理错误标志编程错误、对齐错误、写保护错误都得你自己去读FLASH-SR判断。我的建议是应用固件用HALBootloader用LL。Bootloader对代码体积敏感而且Bootloader里通常只需要很基础的Flash擦写逻辑LL库刚好合适。应用固件追求的是稳定和可维护性HAL的错误码和超时机制能帮你省很多排查时间。另外提一句HAL和LL在同一个工程里可以混用但同一个外设不要一边用HAL一边用LL状态会被搞乱。2. HAL库Flash操作标准姿势与细节坑2.1 解锁、擦除、写入、加锁四件套HAL库操作Flash的标准流程是固定的四步解锁、擦除、写入、加锁。顺序不能乱加锁可以避免程序跑飞后误操作Flash。先看解锁和擦除FLASH_EraseInitTypeDef erase {0}; uint32_t error 0; HAL_FLASH_Unlock(); erase.TypeErase FLASH_TYPEERASE_PAGES; erase.Page (target_addr - FLASH_BASE) / FLASH_PAGE_SIZE; erase.NbPages 1; if (HAL_FLASHEx_Erase(erase, error) ! HAL_OK) { printf(Erase failed, error code: 0x%08lX\r\n, HAL_FLASH_GetError()); } HAL_FLASH_Lock();这里有一个非常重要的细节FLASH_EraseInitTypeDef.Page在G0的HAL库中填的是页号不是地址。你如果直接传一个地址进去HAL库内部会把它当作页号来用实际擦除的页会跑到天上去。我在第一次写的时候就犯了这错误把一个0x0800F800的地址塞进去结果它按页号算等于是把地址0x08000000加上0x0800F800 * 2K之后的位置给擦了直接擦到Flash边界之外随后读回来全是0xFF程序行为完全混乱。正确做法是先用(addr - FLASH_BASE) / FLASH_PAGE_SIZE把地址换算成页号。G071的FLASH_PAGE_SIZE在stm32g071xx.h里定义是2048G030系列则是1024不同型号记得看头文件。踩完擦除的坑再看写入。HAL库写入函数原型是HAL_StatusTypeDef HAL_FLASH_Program(uint32_t TypeProgram, uint32_t Address, uint64_t Data);TypeProgram在G0上支持FLASH_TYPEPROGRAM_WORD32位和FLASH_TYPEPROGRAM_DOUBLEWORD64位。用WORD时地址必须4字节对齐用DOUBLEWORD时必须8字节对齐不对齐会触发PGAERR编程对齐错误。另外要注意HAL_FLASH_Program内部会自动等待操作完成所以你在调用前不需要手动查BSY但调用后最好读一次HAL_FLASH_GetError确认没有错误标志残留。2.2 页擦除的两种写法HAL_FLASHEx_Erase与底层FLASH_PageEraseHAL库擦除页有两条路线。一条是上面写的HAL_FLASHEx_Erase这是官方推荐的公共接口它会处理等待、超时、错误码归档这些脏活。另一条是直接调内部的FLASH_PageErase函数这个函数不做超时和错误检查纯粹往FLASH-CR寄存器填擦除页地址然后触发命令。有些老工程师为了“性能”喜欢直接调用FLASH_PageErase绕过HAL的封装这我不太推荐。Flash操作本身就是慢速操作省那点函数调用开销毫无意义反而会丢掉HAL库的防御机制。但如果你确实要在中断上下文或者实时性要求极高的场景下做Flash操作那么用LL库都比直接调HAL内部的FLASH_PageErase要稳妥至少LL库有清晰的BSY位轮询逻辑。另外擦除操作有一个容易忽略的陷阱擦除期间不能断电。这不是废话真有产品因为擦除途中掉电导致参数区数据全丢因为一页数据擦了一半没擦干净也没写成功。软件层面能做的只有双页备份、写之前检查电源、以及擦除前把关键数据先挪到安全区域。硬件上如果你的产品会有掉电场景加一个电源监控芯片掉电瞬间拉低复位脚比什么软件方案都靠谱。2.3 读Flash的注意事项对齐与缓存读Flash不需要解锁可以直接用指针访问。比如uint32_t data *(volatile uint32_t *)0x0800F800;这里两个细节值得提。第一建议加volatile修饰防止编译器把重复读取优化掉尤其是你在做写入后回读校验的时候。第二读取要按芯片支持的对齐方式去读。G0支持非对齐读取吗硬件上ARM Cortex-M0本身不支持非对齐访问Cortex-M0遇到非对齐访问会触发HardFault。所以你读Flash时如果用的地址不是4字节对齐直接HardFault。这在解析结构体参数块时特别坑比如你定义了一个#pragma pack(1)的结构体存参数里面有个偏移量是奇数地址的字段用指针直接读就炸了。正确做法是memcpy到内存再解析交给编译器处理对齐问题。还有一个要注意的是Flash预取缓冲和缓存。G0的Flash一般带预取缓冲读起来比较快但如果你在做IAP在线升级写入新的程序后需要复位或清I-Cache才能确保读到的是新数据。G0不像F4那样有显式的I-Cache和D-Cache所以这个坑稍微少一些但跳转到APP之前还是建议做一次系统复位让CPU从头跑否则指令流水线里残留的旧指令可能让你看到诡异现象。3. LL库Flash操作精简但更考验功底3.1 LL库代码骨架与API详解LL库操作Flash的代码比HAL库简洁很多核心就是这么几行LL_FLASH_Unlock(); while (LL_FLASH_IsActiveFlag_BSY()); LL_FLASH_Erase(LL_FLASH_TYPE_PAGE, FLASH_BASE page_index * FLASH_PAGE_SIZE); while (LL_FLASH_IsActiveFlag_BSY()); LL_FLASH_Program(LL_FLASH_TYPEPROGRAM_WORD, target_addr, data); while (LL_FLASH_IsActiveFlag_BSY()); LL_FLASH_Lock();逻辑非常直白解锁等空闲擦除等完成写入等完成加锁。这中间没有任何超时保护如果FLASH-SR的BSY位一直为1程序就死循环了。所以用LL库有两条铁律第一条不能在中断里长时间等待Flash操作。中断函数里如果调用了LL_FLASH_Erase然后死等BSY清0而擦除一页可能需要几十毫秒整个系统的实时性全废了。更糟的情况是如果擦除过程中来了更高优先级的中断嵌套中断里又访问Flash可能引发总线冲突直接HardFault。第二条每步操作后要检查错误标志。LL库不会像HAL库那样返回错误码但FLASH-SR寄存器里的PGSERR、PGAERR、WRPERR这些标志还在你得自己去看。典型的检查代码if (LL_FLASH_IsActiveFlag_Error()) { uint32_t sr LL_FLASH_ReadStatusReg(); // 根据sr中的位做对应处理 LL_FLASH_ClearFlags(); }3.2 从F1/F4过来的同学注意G0的LL_FLASH_Erase传的是地址这是LL库最容易踩的坑也恰恰是我之前被HardFault按在地上摩擦的根源之一。**STM32G0的LL库擦除接口第二个参数要求传页地址而不是页码。**F1和F4的LL库擦除函数往往传的是bank和页号你把老代码从F1移植到G0如果直接套用传进去的是页号比如0x03LL库就会傻傻地跑到Flash地址0x08000003去执行擦除。Flash地址不按页对齐控制器会怎么处理结果就是不可预期的。正确的写法是LL_FLASH_Erase(LL_FLASH_TYPE_PAGE, FLASH_BASE page_index * FLASH_PAGE_SIZE);注意这行代码先算地址再传入。如果你的page_index是从0开始的那么第0页的地址就是FLASH_BASE。千万别把page_index直接传进去。我在代码里加了一行防御性断言建议你也加上uint32_t page_addr FLASH_BASE page_index * FLASH_PAGE_SIZE; if (page_addr FLASH_BASE page_addr FLASH_BASE FLASH_SIZE) { LL_FLASH_Erase(LL_FLASH_TYPE_PAGE, page_addr); } else { // 地址越界直接上报错误 }3.3 为什么LL库更适合写BootloaderBootloader和App相比对Flash操作的需求很特殊代码要尽量小逻辑要尽量简单而且Bootloader本身通常放在Flash开头擦写的是后面的App区域。此时RWW特性可以正常发挥作用CPU执行Bootloader代码的同时擦写后面的App区域两者不冲突。HAL库在这个场景下就显得笨重了。一个完整的HAL Flash驱动加上依赖的时钟、GPIO等模块体积轻松几KB而LL库可能几百字节就搞定。Bootloader本身要占一个自己的区域留给App的空间本来就紧张能省则省。另外Bootloader的Flash操作往往是整个系统最早执行的代码路径之一这个时候系统时钟、中断控制器可能都还处于默认状态HAL库初始化那一大套反而容易引入不确定因素。LL库则是纯粹的寄存器操作行为可以精确预测。我自己现在的Bootloader都是纯LL库写的App工程用HAL库两者互不干扰编译出来的Bootloader体积比HAL版本小了将近一半。4. 实战基于HAL库的参数存储驱动完整可复制4.1 需求与分区规划我这次的需求是存储一组设备校准参数大概几百字节包含魔数、序列号、一组校准系数和CRC校验值。要求掉电不丢失、支持反复擦写、万一写入失败还能恢复出厂默认值。Flash分区规划是第一步也是最容易忽略的一步。我用的G071 Flash总共64KBApp固件大约占用前24KB所以我从0x08010000开始规划参数区。一个参数块128字节预留了4个块的位置但实际只用了两个块做轮换备份剩下两个块留作扩展。分区规划的原则很简单参数区必须独立于代码区且越靠后越好。因为Flash是从低地址开始的代码量如果增长需要往高地址扩展如果参数区紧挨着代码区代码一变大就把参数区挤掉了。把参数区放在Flash末尾最安全。4.2 实现代码初始化、写入、读取直接上代码。这是一个精简但完整的参数存储驱动用双页轮换的方式保存数据#define PARAM_MAGIC 0xA5A5A5A5 #define PARAM_DATA_SIZE 120 typedef struct { uint32_t magic; uint32_t sequence; uint8_t data[PARAM_DATA_SIZE]; uint32_t crc; } ParamBlock; #define PARAM_BLOCK_TOTAL_SIZE (sizeof(ParamBlock)) #define PARAM_PAGE_SIZE FLASH_PAGE_SIZE #define PARAM_PAGE_A (FLASH_BASE FLASH_SIZE - 2 * PARAM_PAGE_SIZE) #define PARAM_PAGE_B (FLASH_BASE FLASH_SIZE - 1 * PARAM_PAGE_SIZE) static uint8_t param_cache[PARAM_BLOCK_TOTAL_SIZE]; static uint32_t param_crc32(const uint8_t *buf, uint32_t len) { // 这里可换用标准CRC32实现为节省空间用软件CRC uint32_t crc 0xFFFFFFFF; for (uint32_t i 0; i len; i) { crc ^ buf[i]; for (int bit 0; bit 8; bit) { if (crc 1) { crc (crc 1) ^ 0xEDB88320; } else { crc 1; } } } return ~crc; } static int param_is_valid(uint32_t page_addr) { ParamBlock *blk (ParamBlock *)page_addr; uint32_t calc param_crc32(blk-data, PARAM_DATA_SIZE); return (blk-magic PARAM_MAGIC) (calc blk-crc); }这里有一个关键设计CRC只对data区做校验不对magic和sequence做。因为magic和sequence在写入过程中如果被改写CRC算出来就永远对不上就没法区分是数据坏了还是标记没写完。只校验数据区可以容忍“块写完了一半、标记没来得及更新”的掉电场景。写入函数如下static int param_write_impl(uint32_t page_addr, uint32_t sequence) { ParamBlock *blk (ParamBlock *)param_cache; blk-magic PARAM_MAGIC; blk-sequence sequence; blk-crc param_crc32(param_cache 8, PARAM_DATA_SIZE); // 整页擦除后重新写入这里用HAL库 FLASH_EraseInitTypeDef erase {0}; uint32_t error 0; uint32_t page_num (page_addr - FLASH_BASE) / PARAM_PAGE_SIZE; HAL_FLASH_Unlock(); erase.TypeErase FLASH_TYPEERASE_PAGES; erase.Page page_num; erase.NbPages 1; if (HAL_FLASHEx_Erase(erase, error) ! HAL_OK) { HAL_FLASH_Lock(); return -1; } // 按32位写入数据G0的WORD编程必须4字节对齐 uint32_t *ptr (uint32_t *)param_cache; for (uint32_t i 0; i PARAM_BLOCK_TOTAL_SIZE; i 4) { if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, page_addr i, *(ptr i / 4)) ! HAL_OK) { HAL_FLASH_Lock(); return -2; } } HAL_FLASH_Lock(); return 0; } int param_save(const uint8_t *data, uint32_t len) { if (len PARAM_DATA_SIZE) { return -3; } uint32_t active_page 0; uint32_t current_seq 0; if (param_is_valid(PARAM_PAGE_A) param_is_valid(PARAM_PAGE_B)) { ParamBlock *a (ParamBlock *)PARAM_PAGE_A; ParamBlock *b (ParamBlock *)PARAM_PAGE_B; active_page (a-sequence b-sequence) ? PARAM_PAGE_A : PARAM_PAGE_B; current_seq ((ParamBlock *)active_page)-sequence; } else if (param_is_valid(PARAM_PAGE_A)) { active_page PARAM_PAGE_A; current_seq ((ParamBlock *)active_page)-sequence; } else if (param_is_valid(PARAM_PAGE_B)) { active_page PARAM_PAGE_B; current_seq ((ParamBlock *)active_page)-sequence; } else { active_page 0; current_seq 0; } // 把新数据填到cache然后写到非活动页 memset(param_cache, 0xFF, sizeof(param_cache)); memcpy(param_cache 8, data, len); uint32_t target_page (active_page PARAM_PAGE_A) ? PARAM_PAGE_B : PARAM_PAGE_A; return param_write_impl(target_page, current_seq 1); }这里的逻辑是先检查两个页哪个有效挑出sequence较大的作为当前有效页然后把新数据写到另一个页sequence加1。如果某一页校验失败就直接恢复到另一页。这个方案能覆盖掉电导致半页写坏的情况。读取函数就简单了int param_load(uint8_t *data, uint32_t len) { ParamBlock *valid NULL; if (param_is_valid(PARAM_PAGE_A) param_is_valid(PARAM_PAGE_B)) { ParamBlock *a (ParamBlock *)PARAM_PAGE_A; ParamBlock *b (ParamBlock *)PARAM_PAGE_B; valid (a-sequence b-sequence) ? a : b; } else if (param_is_valid(PARAM_PAGE_A)) { valid (ParamBlock *)PARAM_PAGE_A; } else if (param_is_valid(PARAM_PAGE_B)) { valid (ParamBlock *)PARAM_PAGE_B; } if (valid NULL) { return -1; } memcpy(data, valid-data, len); return 0; }4.3 如何做掉电保护A/B区轮换上面代码的核心思想就是A/B区轮换。这个方案规避了一个致命场景如果只有一个参数区擦除过程中掉电那整页数据就没了连旧数据也找不回来。A/B区轮换保证任何时刻至少有一个区是完整可用的。具体来说写入流程是先写B区非活动页B区写完后它自带完整的magic和CRC已经是合法数据了下次写入时写A区再下次写B区如此往复。即使某次写入只写了一半就掉电该页的magic或CRC会不匹配下次启动时只有另一个区被识别为有效数据依然在。有一个值得注意的边界情况如果两个区都校验失败说明Flash的坏页问题真的很严重了我这时候会直接采取“恢复默认参数”的策略同时把错误标志上报给主控提示设备可能需要重新校准。掉电保护能做到的最好程度就是保证“要么新数据完整要么旧数据完整”不存在中间状态。如果你有更高要求比如必须保证新数据绝对不丢那就要借助外部EERPOM或者增加超级电容在掉电瞬间维持供电完成写入这属于硬件方案的范畴了。5. HardFault深度排查别慌先看寄存器5.1 HardFault的常见现场与原因HardFault本身不分你我但触发时机能说明很多问题。我在排查过程中总结了几个典型的“现场”第一种程序跑到HAL_FLASHEx_Erase或者LL_FLASH_Erase调用时直接HardFault。这种一般是并行总线访问冲突访问了非法地址或者Flash控制器还没解锁就去操作控制寄存器。未解锁就写FLASH-CR在总线上会触发错误响应Cortex-M0直接进HardFault。第二种Flash操作成功返回了但紧接着某个中断一进来就HardFault。这种大概率是中断向量表所在页被擦了或者中断服务程序所在页被擦了。例如你把参数区放在0x08000000附近而中断向量表刚好也在那里擦完Flash再去点灯灯不亮反而进了HardFault。第三种擦写老半天都没事但程序一运行到某个固定路径就HardFault。这种可能跟Flash里存的常量数据有关。比如你把一个const数组定义在Flash的某个页而那个页恰好被擦除了程序访问这个数组时取到的是0xFF可能形成非法指针或者空指针最终HardFault。这种最隐蔽因为HardFault不是马上发生的而是“绕了一大圈才炸”。5.2 Keil/IAR中的定位方法断点寄存器回溯栈排查HardFault最有用的工具不是printf而是硬件本身。在HardFault_Handler里打断点然后看这几个东西第一是SCB-HFSRHardFault Status Register。这个寄存器的bit30 FORCED几乎必定是1表示HardFault是由其他可配置异常升级来的。如果FORCED为0那说明是直接硬件错误比如总线错误直通HardFault。第二是SCB-CFSRConfigurable Fault Status Register。这是真正告诉你“哪里错了”的寄存器。Cortex-M0的可配置故障包括总线错误、用法错误、内存管理错误。把这个寄存器按位拆开IBUSERRbit8取指总线错误去非法地址执行代码。PRECISERRbit9精确数据总线错误访问某个地址时报错。IMPRECISERRbit10非精确数据总线错误通常是写缓冲导致的延迟报错。UNALIGNEDbit24非对齐访问Cortex-M0不支持非对齐访问置1说明你干了违禁的事。INVSTATEbit25无效状态尝试执行半字对齐或错误的Thumb指令状态。UNDEFINSTRbit24? 不对UNDEFINSTR是bit24等等。不同内核具体位序略有差异但总体上这几个最常用。第三是SCB-BFAR和SCB-MMFAR。如果CFSR里的BFARVALID或MMARVALID为1BFAR或MMFAR里面就是引发错误的地址。看到这个地址是0x08000000和Flash区域相关的地址大概率就是Flash擦写配置出问题看到地址是0x20000000附近的悬空指针地址那就是程序逻辑bug。拿到这些信息后另一个王牌是栈回溯。在HardFault_Handler里寄存器窗口里看当前SP然后在内存窗口打开SP地址顺着栈往上翻找一段看起来像返回地址的数值通常在0x0800xxxx或者0x0801xxxx范围内。这些数值就是触发HardFault之前函数的返回地址。如果Keil的Call Stack窗口能正常显示调用栈直接用它最方便如果显示不出来说明栈已经被破坏了这时候就手动翻内存。5.3 G0特有的几个闪存寄存器检查项排查G0的HardFault有四个Flash相关的寄存器必须查第一个是FLASH-ACR的LATENCY字段。前面说过如果主频超过32MHz而等待周期没配上Flash读取就可能出错取指失败进HardFault。这一类HardFault的CFSR通常是IBUSERR。第二个是FLASH-CR的STROBE位。供电电压低于2.7V时写/擦操作必须把STROBE置1。如果这个位没配置Flash编程电压不足命令执行不完整要么触发PGSERR要么Flash控制器行为异常进一步引发总线错误。第三个是FLASH-SR的错误标志位。PGSERR、PGAERR、WRPERR这三兄弟分别代表编程顺序错误、编程对齐错误、写保护错误。我用HAL库时习惯在每次Flash操作后打印一次HAL_FLASH_GetError()有错误立刻就能看到。第四个是选项字节里的RDP和WRP。如果RDP读保护等级意外被改了Flash就会进入“受保护”状态正常读写会变成非法访问直接就HardFault。如果你之前调试时动过选项字节一定要检查当前设置。6. 常见问题速查表与避坑清单6.1 问题速查表症状可能原因排查方法调用擦除/写入函数时立刻HardFaultFlash未解锁地址未对齐访问了保护区域检查解锁流程确认地址对齐和Flash区域权限擦写后读回全是0xFF擦的不是目标页页号与地址换算错误核对擦除API传参是页号还是页地址擦写完成后偶发HardFault中断向量表所在页被擦除中断服务程序访问了被擦页检查擦除页范围与代码段是否重叠低压供电时Flash操作失败VDD低于2.7VSTROBE未置位检查FLASH-CR的STROBE位Flash操作卡死操作期间中断嵌套冲突LL库死等BSY关闭中断或用HAL库超时机制程序运行到固定路径HardFaultconst数组所在页被擦指针指向Flash被擦区域检查CFSR和BFAR定位错误地址跳转到APP后立刻HardFault中断向量表未重定向App地址不对检查SCB-VTOR设置和Linker脚本6.2 我的避坑清单整理了一份G0 Flash操作的避坑清单每一行都是我确确实实踩过或看人踩过的。第一先算页号和地址再调API。HAL库擦除传页号LL库擦除传页地址这个不要搞混。建议在工程里加一个统一的转换宏例如#define PAGE_ADDR_TO_INDEX(addr) (((addr) - FLASH_BASE) / FLASH_PAGE_SIZE)所有地方统一走宏转换。第二Flash操作前关闭中断操作完成后再打开。虽然G0支持RWW但中断服务程序里如果恰好访问了Flash控制寄存器或者被擦写的页仍然可能出问题。实操中我在擦写前后用了__disable_irq()和__enable_irq()实测可显著降低偶发HardFault概率。如果用了RTOS还需要考虑临界区保护这属于更高阶的话题。第三不要在产品代码里用HAL_FLASH_Program写单个字节。G0虽然支持FLASH_TYPEPROGRAM_BYTE部分型号但每次都走后端编程流程效率不高而且容易不小心把相邻数据写坏。攒够一页数据一次性写入更稳妥。第四CRC校验写进Flash是必须的。不要只靠magic判断参数有效掉电场景下magic可能刚好是0xFFFFFFFF或者0x00000000只有CRC能真正校验数据完整性。第五操作Flash前把看门狗喂了。擦除一页G0大概是几十毫秒如果看门狗超时设置得比较激进比如500ms而你在擦除之后又做了日志打印、数据拷贝等一堆操作可能不知不觉就超时复位了表现成“写入后系统不断重启”。第六用调试器在线仿真时遇到的HardFault拔掉调试器单独跑可能完全不一样。调试器会影响握手时序、电压纹波也会隐藏一些电学问题。遇到“仿真没问题脱机必死”的情况优先怀疑Flash电压和等待周期配置。第七保存一份HardFault现场信息到Flash或串口。我在HardFault_Handler里会把HFSR、CFSR、BFAR、SP、LR全部打包发送到调试串口这在产品量产阶段排查问题是救命稻草。结尾写Flash这件事说难不算难说简单也真不简单。我踩过最大的坑就是把HAL库的页号和LL库的页地址搞混然后在HardFault里挣扎了一整晚最后发现CFSR里BFAR指向的地址和我预期的目标差了十万八千里。从那以后我给自己定了一条规矩只要涉及Flash第一步永远是确认当前用的库接口到底要页号还是要页地址第二步永远是在操作前后打印错误码和关键寄存器。这两个习惯帮我避开了后续至少三四个类似的坑。另外一个实际体会是G0的RWW特性确实省心但也别因为支持RWW就放松对中断和代码布局的敬畏。把Flash擦写操作集中封装放在一个专用的模块里中断里绝不调用代码里明确标注“此函数执行期间禁止访问Flash”能省掉很多半夜被叫起来排查问题的经历。如果你也正准备在STM32G0上做参数存储或者OTA升级希望这篇复盘能帮你少走一点弯路。
返回列表