拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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那样有个“最小扇区”去对齐。

另外一个关键点是RWW(Read-While-Write)。G0系列支持在Flash擦写期间,CPU从Flash继续取指执行,这一点和F1完全不同。F1写Flash的时候,如果你不把代码搬运到RAM里跑,或者不开预取缓冲做特殊处理,很容易“自杀”——擦写过程把正在执行的代码冻住了。G0硬件上做了隔离,允许一边擦写一边执行代码,只要被擦写的页不是CPU当前正在取指和取数的页就行。

还有一点要记住:读写Flash不需要解锁,擦除和写操作必须解锁。G0的解锁时序是往FLASH->KEYR依次写两个固定的键值KEY1和KEY2,HAL库和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判断。

我的建议是:应用固件用HAL,Bootloader用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里定义是2048,G030系列则是1024,不同型号记得看头文件。

踩完擦除的坑再看写入。HAL库写入函数原型是:

HAL_StatusTypeDef HAL_FLASH_Program(uint32_t TypeProgram, uint32_t Address, uint64_t Data);

TypeProgram在G0上支持FLASH_TYPEPROGRAM_WORD(32位)和FLASH_TYPEPROGRAM_DOUBLEWORD(64位)。用WORD时地址必须4字节对齐,用DOUBLEWORD时必须8字节对齐,不对齐会触发PGAERR编程对齐错误。另外要注意,HAL_FLASH_Program内部会自动等待操作完成,所以你在调用前不需要手动查BSY,但调用后最好读一次HAL_FLASH_GetError确认没有错误标志残留。

2.2 页擦除的两种写法(HAL_FLASHEx_Erase与底层FLASH_PageErase)

HAL库擦除页有两条路线。一条是上面写的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,如果直接套用,传进去的是页号比如0x03,LL库就会傻傻地跑到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库更适合写Bootloader

Bootloader和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总共64KB,App固件大约占用前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->HFSR(HardFault Status Register)。这个寄存器的bit30 FORCED几乎必定是1,表示HardFault是由其他可配置异常升级来的。如果FORCED为0,那说明是直接硬件错误,比如总线错误直通HardFault。

第二是SCB->CFSR(Configurable Fault Status Register)。这是真正告诉你“哪里错了”的寄存器。Cortex-M0+的可配置故障包括总线错误、用法错误、内存管理错误。把这个寄存器按位拆开:

  • IBUSERR(bit8):取指总线错误,去非法地址执行代码。
  • PRECISERR(bit9):精确数据总线错误,访问某个地址时报错。
  • IMPRECISERR(bit10):非精确数据总线错误,通常是写缓冲导致的延迟报错。
  • UNALIGNED(bit24):非对齐访问,Cortex-M0+不支持非对齐访问,置1说明你干了违禁的事。
  • INVSTATE(bit25):无效状态,尝试执行半字对齐或错误的Thumb指令状态。
  • UNDEFINSTR(bit24? 不对,UNDEFINSTR是bit24)等等。不同内核具体位序略有差异,但总体上这几个最常用。

第三是SCB->BFAR和SCB->MMFAR。如果CFSR里的BFARVALID或MMARVALID为1,BFAR或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.7V,STROBE未置位检查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升级,希望这篇复盘能帮你少走一点弯路。

返回列表