拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

STM32G0 Flash操作避坑指南:HardFault根因与HAL/LL库实践

STM32G0 Flash操作避坑指南:HardFault根因与HAL/LL库实践

如果你玩STM32G0的时候,第一次调Flash读写就碰到了HardFault,不要怀疑是芯片有问题,多半是你还在用F1/F4那套思路写G0的Flash代码。我接手过一个项目,同事在G0B1上照搬F103的参数存储例程,一调用HAL_FLASHEx_Erase,板子立刻死给你看,HardFault_Handler里断点都打不进去。排查了整整一个下午,最后定位到的原因说出来很简单:他把要存的数据页放在了0x08000000,也就是中断向量表和主程序所在的第0页。擦除这个页等于把脚下的地板抽掉,程序不HardFault才怪。

这篇就把我在STM32G0上用HAL库和LL库操作Flash踩过的坑、排查HardFault的完整链路,以及最后沉淀下来的工程化方案一次讲清楚。内容不绕弯子,直接按"架构差异 → HAL实践 → LL实践 → HardFault排查 → 工程收尾"的顺序来。

1. 为什么Flash操作在G0上特别容易翻车:先想清楚三件事

1.1 G0的Flash架构和F1/F4的差异,直接决定你"怎么死"

STM32G0是Cortex-M0+内核,主频最高64MHz,价格便宜,这两年很多产品从F0/F1往G0迁移。但不少人迁移的时候只改了芯片型号和时钟树,Flash操作代码还留着F1的风格,结果就是各种灵异死机。

先说最核心的差异:G0的Flash不是按扇区(Sector)组织的,而是按页(Page)组织的。F1是4KB一个扇区,F4是16KB/64KB/128KB不等的大扇区,而G0常见的页大小是2KB(比如G0B1这个型号),小容量型号甚至有1KB的页。这个差异带来的实际影响是:你擦除的最小单位变小了,但页数量变多了,操作时对地址对齐的要求也更严格。F1时代你只要记住"1KB对齐"就行,G0上你得先查清楚你手里这颗料是2KB页还是1KB页,这个信息在参考手册的Flash organization那一节写得很清楚,每个型号都不一样。

第二个差异是G0没有Flash等待周期(Latency)配置。F1的FLASH_ACR寄存器里有个LATENCY位,F4更麻烦,要按工作电压和主频去设置等待周期,跑80MHz以上还要开预取缓冲。G0把这一层彻底干掉了,Flash控制器内部处理了访问时序,你不需要也不允许去设等待周期。刚上手的人可能觉得"少配一个寄存器省事",但在调试的时候少了一个可变量,反而不容易联想到Flash访问时序问题。

第三个差异是双Bank结构。G0的大容量型号,比如G0B1/G0C1,内部是两个Bank,每个Bank独立,硬件上允许你在Bank0跑代码的同时擦写Bank1。单Bank型号如G030/G031就没有这个能力。这个差异直接决定了你写OTA或者参数存储时能不能"一边跑一边擦",也决定了一旦你擦错区域,MCU是卡死(无RWW单Bank)还是立刻HardFault(双Bank冲突)。很多人在G030上调好的Flash代码,原封不动挪到G0B1上,功能是正常的,但抗干扰能力差很多,原因就在这里:代码跑在Bank0,你去擦写Bank0的某个页,如果那个页恰好包含正在执行的指令——不是硬件层挡住,而是CPU取指取到空白区,直接进HardFault。

第四个差异容易被忽略:G0的Flash方案在写入的时候,软件必须把"编程宽度"告诉Flash控制器。老G0系列可以用字节写、半字写、字写、双字写,用FLASH_CR寄存器的PGSZ位来设置。F1只有半字写一种方式,F4只有字和双字。所以G0写一个字节是合法的,但性能最差;推荐用双字(64位)写入,因为不管是性能还是寿命表现都更好。这个PGSZ概念在HAL库被封装成FLASH_TYPEPROGRAM_xxx宏,在LL库则是LL_FLASH_TYPEPROGRAM_xxx枚举,如果你直接操作寄存器,就要自己维护PGSZ位。HAL和LL库的代码风格差异也体现在这里,后面详细说。

1.2 HAL库与LL库的取舍:性能、可控性与开发效率的平衡

很多人在"项目该用HAL库还是LL库"这个问题上反复纠结。我的态度很明确:涉及Flash这种需要精确控制时序和状态的底层操作,我优先用LL库;涉及到业务逻辑、外设初始化、芯片无关的可移植模块,用HAL库。Flash操作是整个系统中离硬件最近、出错后果最严重的部分之一,封装得越厚,出问题的时候越难排查——这句话是我在定位了一次HAL_FLASH_Program在G0上的诡异问题后总结出来的。

做个直观对比:

对比项HAL库LL库
代码量较大,Flash模块几百行小,核心就几十行寄存器操作
封装层次多,函数调用链深,逻辑晦涩少,一个函数对应一个寄存器操作
错误检测内置超时、错误标志检查,返回HAL_OK/HAL_ERROR多数情况下不做处理,错误标志需要用户自己检查
可重入性内部有全局互锁变量,不可重入无全局状态,操作是否安全由用户保证
执行速度较慢,有函数调用和判断开销快,直接读写寄存器
适合场景业务代码、初始化、低频配置Bootloader、中断/实时敏感场景、底层调试

HAL库的Flash模块在F1/F4时代还算好用,因为那几款芯片Flash控制器相对简单。到了G0,Flash控制器加入了PGSZ、双Bank、多页擦除、写保护粒度更细等新特性,HAL库为了兼容这一大堆特性,函数内部的分支判断非常多。你用HAL_FLASHEx_Erase擦一页,函数内部会先判断是不是大容量、检查Bank属性、判断写入宽度,再去做实际的寄存器操作。这一层层的判断,在98%的情况下都没问题,但一旦你对某个宏定义理解错了,就会触发那种"明明返回值是HAL_OK但数据就是不对"的玄学问题。

LL库的思路则完全相反:LL_FLASH_Program(LL_FLASH_TYPEPROGRAM_DOUBLEWORD, addr, data)这个调用,里面几乎没有逻辑判断,对应的就是硬件手册上"设置PGSZ位、设置PG位、写数据"这几步。正因为薄,你心里必须清楚每一步在干什么,反而不会出现那种"照着例程抄都能死机"的情况。对我来说,差那几微秒性能不是关键,关键是代码路径短、可控,出问题一眼能看出是哪个环节。

不过LL库也有它的坑:它不帮你检查状态。如果上次操作留下了错误标志,你这次写数据,FLASH控制器会因为残留的PGERR/PGSERR标志拒绝执行,而LL库不会主动提示你。所以用LL库写Flash,必须在每次操作之前清标志、之后查状态,这个步骤不能省。别笑,我第一次从HAL换到LL库的时候,就因为这个吃了两天苦头,后面细讲。

1.3 HardFault在Flash场景中的常见触发方式

Flash操作引发HardFault,归根结底是四种原因,先列出来,后面排查的时候对号入座。

第一种,也是最常见的一种:执行代码所在的Flash页被擦除或改写。芯片在擦除/编程某个页的时候,如果CPU刚好要从那个地址取指,取回来的是0xFF或者被改写的数据,PC寄存器就飞到未知区域,接下来不是预取错误就是未定义指令,直接HardFault。这种情况在G0单Bank型号上尤其致命,因为所有代码都在同一块Flash里。

第二种:访问了超出Flash实际容量的地址。G0B1的Flash是512KB,地址范围0x08000000到0x0807FFFF,如果你算错了偏移量,把写指针指到0x08080000之后,这就是个不存在的地址,总线上直接产生Bus Error,Cortex-M0+把它转成HardFault。这类越界访问往往不是故意为之,而是宏定义算错、页号乘页大小的时候溢出。

第三种:对已经非0xFF的地址做编程。Flash的特性是只能从1写成0,如果你想对0x0000FFFF这个位置的"F"位再写1,硬件会置一个编程错误标志,但错误标志本身不直接导致HardFault。真正的HardFault风险在于,如果你没检查错误标志,直接继续往后续地址写,或者下次复位后程序逻辑认为"数据写成功了"去读,读回来的是半新不旧的错数据,后续代码拿到非法参数,间接搞出HardFault。

第四种:中断向量表或复位向量所在页被破坏。只要向量表所在的Flash页被擦除,或者选项字节被意外修改,MCU在响应任何异常中断时都取不到正确的入口地址,设备基本就废了。这种HardFault最坑的地方在于它不是立刻发生的,可能在你擦完页、复位之后才出现,板子表现为"上电就死",非常难定位。

记住这四种触发方式,再往下看HAL和LL库的具体操作,你就知道为什么每一步都要做得那么小心翼翼了。

2. HAL库Flash编程:一次"照着例程写也死机"的完整复盘

2.1 HAL库Flash擦写的标准流程

先把HAL库的标准流程写出来,这是我在G0B1上验证过的可用代码,不是网上那种抄来抄去的半成品。核心流程是:解锁 → 擦除页 → 按宽度编程 → 锁回。

#include "stm32g0xx_hal_flash.h" // 擦除并写入一个页(以2KB页、双字编程为例) void Flash_WritePage(uint32_t page_addr, const uint8_t *data, uint32_t len) { FLASH_EraseInitTypeDef erase_cfg; uint32_t page_error = 0; uint64_t buf; HAL_FLASH_Unlock(); // 1. 页擦除 erase_cfg.TypeErase = FLASH_TYPEERASE_PAGES; erase_cfg.Page = (page_addr - FLASH_BASE) / FLASH_PAGE_SIZE; // LL库要的是页号 erase_cfg.NbPages = 1; if (HAL_FLASHEx_Erase(&erase_cfg, &page_error) != HAL_OK) { // 不要只盯着错误码,page_error会告诉你具体哪一页失败 uint32_t err = HAL_FLASH_GetError(); HAL_FLASH_Lock(); return; } // 2. 数据按双字(8字节)对齐编程,不足部分补0xFF for (uint32_t i = 0; i < len; i += 8) { buf = 0xFFFFFFFFFFFFFFFFULL; for (int j = 0; j < 8 && (i + j) < len; j++) { ((uint8_t *)&buf)[j] = data[i + j]; } if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, page_addr + i, buf) != HAL_OK) { break; } } HAL_FLASH_Lock(); }

这个流程本身没什么新鲜的,真正的坑埋藏在你看不到的细节里。

先说擦除参数:erase_cfg.Page是页号,不是地址。很多从F4迁移过来的人会把"要擦除的Sector编号"和"起始地址"混在一起,F4的库函数要用地址,G0的HAL库用的是页号。你用HAL_FLASHEx_Erase(FLASH_TYPEERASE_PAGES, 0x08000000, 1)这种方式传地址,编译器不会报错,但硬件拿0x08000000当页号去算实际地址,直接越界,擦除的不是你想要的页,甚至可能擦到不存在的区域,返回一个看起来很莫名的错误。

再说锁的问题。HAL_FLASH_Unlock和HAL_FLASH_Lock必须成对出现,但更要命的是很多HAL库版本里,Lock之后如果复位前又发生了一次写操作,Flash重新上锁的行为会把你搞晕。G0的Flash控制器有个特性:一旦FLASH_CR的LOCK位被置1,任何对上电后尚未解锁的Flash控制寄存器的写操作都会导致总线错误。听起来很绕,翻译成人话就是:你忘了调用HAL_FLASH_Unlock就直接调HAL_FLASH_Program,HAL库内部会尝试写FLASH_CR,但这个寄存器还是锁定状态,这一写就是一次非法访问,可能直接触发HardFault。所以如果你在"没解锁"状态下调用了编程函数,第一件事是去查CFSR里的MMFAR是不是指向FLASH_CR的地址,是的话,恭喜你已经找到根因了。

2.2 两个高频坑:擦除参数含义与编程对齐

第一个坑我们刚才说了,擦除函数要的是页号不是地址,再补充一个连招:页号是从0开始计的。也就是说0x08000000是页0,0x08000800(2KB页大小的情况)是页1,否则你只能对着地址算。如果你像我一样在G0B1上做OTA,代码里到处都是"Bank0起始地址 + 偏移"这种写法,建议封装一个#define ADDR_TO_PAGE(addr) ((addr - FLASH_BASE) / FLASH_PAGE_SIZE)宏,用一个地方算,别到处手动除。少算一次,少踩一次坑。

第二个坑是编程的对齐问题。HAL_FLASH_Program的第二个参数是地址,这个地址必须与编程宽度对齐:用FLASH_TYPEPROGRAM_BYTE时任意地址对齐,HALFWORD时2字节对齐,WORD时4字节对齐,DOUBLEWORD时8字节对齐。如果对齐不对,G0的Flash控制器不会自动帮你修改,它会按对齐后的地址执行,导致数据写到了你不想写的偏移位置。这个坑特别隐蔽,因为HAL_FLASH_Program的返回值有时是HAL_OK,你觉得成功了,读回来一对比才发现数据错位。我的建议是,凡是需要写Flash的地方,统一按8字节对齐的缓冲区处理,别在应用层搞出"我想从这个地址的第五个字节开始写"这种操作,G0不适合这种玩法。

2.3 HAL_FLASH_Program内部行为与并发/中断注意事项

HAL_FLASH_Program这个函数看起来只是个"写数据"的入口,但你在G0上如果把它当成完全独立、随便调用的黑盒,迟早要出事。它内部有几步关键操作:等待BSY标志清零、设置PGSZ位、置PG位、把数据写到目标地址、等待BSY再次清零、清PG位。这个流程在单线程下没有毛病,问题出在两步等待上。等待BSY期间,Flash控制器不允许任何其他Flash操作,中断和异常虽然可以响应,但如果中断服务程序里面又去调HAL_FLASH_Program,那这个函数内部锁标志会乱套,因为HAL库用一个模块级的全局变量来防止重入,中断里调用会直接跳过锁判断。

这里必须说清楚一个Cortex-M0+的特性:M0+的NVIC不支持硬件中断嵌套,所以"中断里面打断Flash操作"这件事在单中断场景下不会发生。但如果你上了RTOS,任务A正在擦Flash的过程中调度器切换到任务B,任务B里的代码恰好又要写Flash,这时候两个调用之间没有任何互斥保护,HAL库的全局锁直接就废了。我遇到过的实际案例是:FreeRTOS下两个任务共享了一个Flash写函数,偶发HardFault,用调试器单步怎么都复现不出来,后来在Flash写函数入口加个互斥信号量,问题就消失了。所以别迷信HAL库的封装,线程安全要自己保证。

另外一个HAL库的隐藏行为:HAL_FLASH_Program会修改FLASH_CR寄存器的PGSZ位,这个位在上电后默认值是双字编程。如果你之前用LL库或者直接寄存器操作改了PGSZ,然后又调HAL_FLASH_Program,它会重新设置成它认为合适的宽度。如果你的代码里混用了HAL和LL的Flash操作,就会出现"LL写完了HAL又来改PGSZ"的情况,程序看起来正常,性能却莫名其妙地差,而且很难查。我现在的习惯是:一个工程里只用一种库操作Flash,要么全部HAL,要么全部LL,绝不混用。

3. LL库Flash操作:寄存器级的直给和隐晦

3.1 LL库Flash擦写最小骨架

LL库的Flash操作核心就四个函数:解锁、擦除、编程、等待操作完成。写起来非常清爽,但有几个细节你必须补上,否则就是裸奔。

#include "stm32g0xx_ll_flash.h" // LL库擦写一页的完整流程(含错误标志清/查) void Flash_WritePage_LL(uint32_t page_addr, const uint8_t *data, uint32_t len) { uint64_t buf; LL_FLASH_Unlock(); // 清掉残留错误标志,这一步不能省 LL_FLASH_ClearFlags(); // 等待Flash空闲 if (LL_FLASH_WaitForLastOperation(LL_FLASH_TIMEOUT) != SUCCESS) { LL_FLASH_Lock(); return; } // 页擦除:第二个参数是页地址(不是页号) if (LL_FLASH_Erase(FLASH_TYPEERASE_PAGES, page_addr) != SUCCESS) { LL_FLASH_Lock(); return; } if (LL_FLASH_WaitForLastOperation(LL_FLASH_TIMEOUT) != SUCCESS) { LL_FLASH_Lock(); return; } // 双字编程 for (uint32_t i = 0; i < len; i += 8) { buf = 0xFFFFFFFFFFFFFFFFULL; for (int j = 0; j < 8 && (i + j) < len; j++) { ((uint8_t *)&buf)[j] = data[i + j]; } LL_FLASH_Program(LL_FLASH_TYPEPROGRAM_DOUBLEWORD, page_addr + i, buf); if (LL_FLASH_WaitForLastOperation(LL_FLASH_TIMEOUT) != SUCCESS) { // 失败后一定要看彻底清除错误标志,否则下次擦写会被历史标志卡住 LL_FLASH_ClearFlags(); break; } } LL_FLASH_Lock(); }

上面代码里,我最想强调的就是LL_FLASH_ClearFlags这个调用。F1/F4的HAL库在你每次调用HAL_FLASH_Program之前会自动清错误标志,所以你从HAL切换到LL库的时候,很容易忘记"清标志"这件事。如果上一次编程因为某种原因失败了,PGSERR标志置1,下次你调用LL_FLASH_Program,硬件检测到错误标志直接拒绝执行,但LL库里没有一行代码告诉你这个事。你只会看到数据写不进去,以为地址算错了,在这上面绕一天都很正常。正确姿势是每次操作之前清标志,每次操作之后查标志,形成肌肉记忆。

3.2 PGSZ编程宽度背后的机制与选择

G0的Flash控制器通过FLASH_CR寄存器里的PGSZ位来决定编程宽度,LL库的LL_FLASH_PROGRAM_TYPEPROGRAM_DOUBLEWORD、WORD、HALFWORD、BYTE这几个枚举值,本质就是帮你把PGSZ设成对应的编码。默认上电后PGSZ是双字,这也是为什么最推荐用双字的原因之一:少一步设置位的操作,硬件也最喜欢这个宽度。

为什么不推荐用字节编程?因为G0的Flash写一个字节和写一个双字,控制器付出的"编程周期"几乎一样。写8个字节要分成8次字节编程循环,总耗时大概是双字编程的8倍,而Flash的编程循环次数是有寿命上限的(官方标称典型值1万次擦写),从寿命和性能两个角度看,字节编程都是亏的。

还有一个更微妙的问题:LL_FLASH_Program在G0上允许你传任意宽度,但如果你用HAL库擦除、LL库编程(或者反过来),PGSZ可能被对方的代码改掉。这种混用场景下,你LL库传BYTE,HAL库之前设置的PGSZ已经被覆盖了,编程结果能对但行为诡异,甚至在某些边界条件下触发出错标志。还是那句话:一个工程只用一个库操作Flash。

3.3 为什么LL库在Bootloader场景更合适

如果你写的是Bootloader,我更倾向于全部用LL库。原因很简单:Bootloader对代码体积、执行时间、可控度都有硬要求,HAL库的Flash模块会把一部分你根本用不到的逻辑也编进去,比如NVIC配置、双Bank切换的可选分支,这些在Bootloader里纯属浪费。LL库的操作路径短,看得明白,你在调试器里单步跟踪的时候,每一步做了什么寄存器操作清清楚楚,遇到写不进数据的情况,直接在FLASH_CR、FLASH_SR这两个寄存器上打断点,一眼就能看出问题出在BSY没等完还是PGSZ不对。

另外一个场景是Flash模拟EEPROM。这种场景里你可能会频繁地在一个页内做局部更新、标记脏数据、循环利用页,这需要你对Flash控制器的状态了如指掌。HAL库的封装在这种"非标准用法"下反而碍手碍脚,它的Page擦除、Program函数都假设你是标准的"擦整页、写整块",而LL库允许你用寄存器自由组合操作,想怎么写怎么写。我做过一个项目,用2KB的页做了64个32字节的slot,每次参数变更只写一个slot,写满才擦除整页,寿命直接拉长了几十倍。这种活,也只有LL库能让你这么随心所欲地控制。

4. HardFault全链路排查:从复现到根因的实操记录

4.1 一个典型的HardFault复现过程

我拿半年前一个实际项目举例。现象是:设备上电后能正常运行,调用一次"保存用户参数"函数,程序立刻死机;复位后如果不去保存参数,设备能一直跑;但只要一执行Flash写操作,必死。

刚开始我也怀疑是Flash访问越界,于是第一步是复现。在HardFault_Handler入口放一个断点,用调试器跑,复活现场,然后读寄存器。这一步很关键,因为HardFault一旦发生,你要么当场抓住它,要么得想别的招。实际步骤是:先在Main里设断,确认执行到哪一行触发;然后单步进去调函数,看是擦除掉的还是编程掉的;最后用串口或者调试器把现场寄存器和栈内存全部dump出来。

复现的结果很有意思:只有在擦除Page0的时候才死,换成擦除Page1或者Page2,程序活得好好的。而且擦除Page0死掉的方式很统一:复位上电后直接进入HardFault。这个现象其实已经指出了方向——Page0是向量表和启动代码所在位置,把它擦成0xFF后,MCU连初始SP和Reset_Handler都找不到了。

4.2 Cortex-M0+现场取证:CFSR、HFSR与栈回溯

HardFault发生的那一刻,不要急着按复位键,先把下面这段代码放进HardFault_Handler里,把现场抓出来:

void HardFault_Handler(void) { volatile uint32_t r0, r1, r2, r3, r12, lr, pc, xpsr; volatile uint32_t cfsr = SCB->CFSR; volatile uint32_t hfsr = SCB->HFSR; volatile uint32_t mmfar = SCB->MMFAR; volatile uint32_t bfar = SCB->BFAR; uint32_t *sp; // M0+的异常返回L R值可以用来判断用的是MSP还是PSP if ((__get_LR() & 0x4) == 0) { sp = (uint32_t *)__get_MSP(); } else { sp = (uint32_t *)__get_PSP(); } r0 = sp[0]; r1 = sp[1]; r2 = sp[2]; r3 = sp[3]; r12 = sp[4]; lr = sp[5]; pc = sp[6]; xpsr = sp[7]; // 在这里打断点,或者用串口把上面这些变量发出去 for (;;) {} }

Cortex-M0+的栈帧布局是固定的8个字:R0、R1、R2、R3、R12、LR、PC、xPSR。G0这颗芯片没有FPU,不需要关心FPU状态保存,比M4简单不少。你把这些变量抓出来,PC就是发生HardFault时CPU正在执行的指令地址,LR是函数返回地址,xPSR里还能看到条件标志位。

CFSR寄存器是另一个关键线索。它实际上分三段:MMFSR(低8位)、BFSR(中8位)、UFSR(高16位),每一段标志位都对应一种具体错误类型。我的调试记录里,这次的CFSR值是0x00008200,拆开看是UFSR的INVSTATE位(0x00020000?实际值0x00008200是BFSR的PRECISERR加上BFARVALID),说明发生了精确的Bus Error,而且BFAR寄存器里记录了出错地址。这时候看一眼BFAR,值指向0x08000800,正好是Page0的中间地址,真相已经浮出水面。

CFSR位段常见标志含义排查方向
MMFSR[0]IACCVIOL取指访问违例PC跳到非法区域,检查栈帧里的PC值
BFSR[8]IBUSERR指令总线错误取指地址不对,检查Flash地址是否越界
BFSR[9]PRECISERR精确数据总线错误写Flash时地址无效,检查BFAR
BFSR[10]IMPRECISERR非精确数据总线错误写操作与读操作乱序,较难定位
UFSR[16]UNDEFINSTR未定义指令PC指向非代码区,通常是Flash被擦成0xFF
UFSR[17]INVSTATE无效执行状态PC跳到Thumb/ARM状态错乱的区域

4.3 根因定位:执行代码与擦写页冲突

回到那个案例。CFSR的PRECISERR + BFAR指向0x08000800,说明CPU试图从0x08000800取指,但那个地址已经被擦成0xFF。为什么会去那里取指?因为复位后,CPU从0x08000000处读取初始SP,从0x08000004处读取Reset_Handler地址。如果这两个位置存的都是0xFFFFFFFF,CPU连第一行代码都跑不起来。那一刻复位向量就是0xFFFFFFFF,M0+把0xFFFFFFF0当成新的PC,然后就等着进HardFault了。

根因找到了:保存用户参数的函数执行了整页擦除,而它擦掉的是Page0,也就是代码段和向量表所在的页。这是所有Flash相关HardFault里最朴素也最常见的一种。

解决思路有几条。第一条最简单,把用户参数存储区放到代码段之外的页,比如Flash末尾的空余页,让擦除操作碰不到向量表和代码。第二条是把Flash操作函数放到RAM里执行,这样即使擦除的页覆盖了Flash里的所有代码,CPU正在执行的指令已经在RAM中,不会去Flash取指。这里给出两种编译器下的做法:

GCC下,给函数加一个section属性,然后在链接脚本里固定放RAM:

__attribute__((section(".ramfunc"))) void Flash_WritePage_RAM(uint32_t addr, uint64_t *data, uint32_t len) { // Flash擦写代码,必须保证自身和调用链都在RAM里 }

Keil/AC5下用__RAM_FUNC:

__RAM_FUNC void Flash_WritePage_RAM(uint32_t addr, uint64_t *data, uint32_t len) { // 同样,整个调用链都要保证在RAM里 }

但要注意,不是把所有Flash操作代码都塞进RAM就万事大吉。如果Keil目标里没有正确配置RAM的散列文件,__RAM_FUNC声明的函数可能还是会被放到Flash里,你以为是RAM执行,实际上还在Flash里跑。校验方法很简单:在函数里取函数指针,看它的值落在0x20000000之后的RAM区域还是0x08000000的Flash区域。另外,如果你想擦除的页包含中断向量表本身,就算你在RAM里执行擦除函数,擦完后任何中断都拉不起来。所以向量表所在页(通常就是Page0)必须特殊处理,方法要么是"永远不擦它",要么是"先跳转到RAM中的临时向量表再擦"。

4.4 两个隐蔽的HardFault源:写保护与向量表损坏

排查HardFault时,除了前面讲到的越界和擦错页,还有两个隐蔽原因很容易反复踩坑。

第一个是Flash写保护,也就是WRP(Write Protection)。G0默认情况下Flash是全部可写的,但如果你在选项字节里配置了写保护,或者之前调试时误开了保护,然后程序再去写那些受保护的页,Flash控制器会拒绝操作,同时置上写保护错误标志。这个错误不会立刻HardFault,但如果你在写保护的页上读回数据做校验,读到的还是旧数据,程序逻辑就会跑偏,后续代码可能拿着错误数据去做数组索引,最终触发HardFault。排查方法:用STM32CubeProgrammer读一下选项字节,看RDP等级和WRP区域配置,确认不是"上锁"状态。

第二个是调试器报错。你在Keil里点了Download,结果弹出Error: Flash Download failed - Target DLL has been cancelled,很多人第一反应是代码编译问题,其实这大概率是目标芯片当前处于读保护Level 1(RDP=1),调试器无法通过SWD访问Flash。这个坑尤其容易出现在你用了HAL_FLASH_OB_EnableRDP之类的函数做应用层读保护之后。解决办法是用STM32CubeProgrammer连着ST-Link,先做Full Chip Erase或者把RDP降回Level 0。注意:降RDP的过程会触发一次Flash全片擦除,别把没备份的代码搭进去了。

5. Flash工程的工程化收尾:校验、掉电与升级的实战经验

5.1 读回校验与"假成功"问题

HAL库和LL库的编程函数,从语义上只保证"把数据写给Flash控制器",不保证数据真的写成了你想要的。这对Flash这种"写0能行、写1不行"的介质来说,存在一个"假成功"风险:你对一个非0xFF的地址执行了编程,硬件其实已经拒绝了这次写入,但因为某种原因状态标志没被正确捕获,函数返回成功,读回来却是老数据。

我推荐的校验方法非常朴素:写完之后读回,逐字节比较。当前面代码里用双字编程时,校验也要用双字读:

uint64_t read_back = *(volatile uint64_t *)(target_addr); if (read_back != expected) { // 数据对不上,必须处理 }

读回这个动作本身要用volatile,否则编译器可能把这次读优化掉,直接用了刚才写出去的缓存值,那就等于没校验。不要信"HAL库已经返回成功了所以不需要校验"这种话,我在G0上遇到过HAL_FLASH_Program返回HAL_OK但实际数据不对的情况,跟编译器优化等级、Keil的优化选项都有关系,校验是唯一可靠的兜底。

5.2 掉电安全性:双区备份与脏标志

工业设备上最怕的不是程序跑着跑着死机,而是写Flash写到一半突然断电。擦除一页需要几十毫秒,这期间如果掉电,页里的数据既不是完整的旧值,也不是完整的新值,而是半擦半写的混合状态。下一次上电如果直接把这个页当有效数据用,行为不可预测,运气差一点就是所谓"参数区损坏导致设备变砖"。

我的习惯是做双区备份加脏标志。具体方案是:把参数区划分为两个逻辑区A和B,每次先写B区,写完后在B区写入一个固定魔数(比如0xA5A5A5A5)作为"有效"标记;下次启动时先检查A区,如果A区有效就用A,否则看B区。写入过程变成:旧数据在A区,新数据写入B区,B区写好后标记有效,再回头擦掉A区。这样任何时间点掉电,至少有一个区的数据是完整的。成本是Flash空间翻倍,但对关键参数来说,这点成本完全值得。

脏标志是另一个常用手段:在参数区头部留一个专门的状态字,写入前先把它从0xFF写成0x00表示"正在更新",更新完成后再把状态字写成0x55表示"已完成"。上电时如果发现状态字是0x00,说明上次没写完,可以做回滚或恢复默认。这个思路简单,但在产品里特别管用。

5.3 双Bank OTA和Flash模拟EEPROM的扩展思路

如果你的产品要做OTA升级,G0B1这类双Bank大容量型号其实给了很好的硬件底座。思路是:当前从Bank0启动,把新固件写入Bank1,全部写完后用选项字节的SWAP功能切换启动Bank,复位后从Bank1启动,然后把Bank0擦空作为下一次升级的写入区。整个过程最危险的一步在"擦Bank0"时:如果你正在用Flash里的代码操作擦除,而擦除目标是当前正在执行的Bank0,那不就和前面Page0翻车的案例一样了吗?所以正确答案是:擦除当前运行Bank必须在RAM里执行擦除代码,或者干脆在Bank1固件里擦Bank0。这一步对可靠性影响极大,很多OTA变砖都是从这里来的。

如果用单Bank型号做OTA,就得用"双App + 引导跳转"的方案,在Bootloader里判断哪个App有效,App更新时写非当前运行区,写完后把有效性标志翻转。这种方案的复杂度比双Bank高不少,但逻辑是一样的:保证任何时刻都至少有一套可以启动的固件。

Flash模拟EEPROM这个需求,在G0上也是可行的。原理就是利用"页擦后可以多次编程只写1转0"的特性,把一页拆成多个slot顺序写,写满后擦掉重来。G0页最小只有1KB,比传统EEPROM大得多,但如果你的业务数据就几十个字节,一页分成几十个slot,寿命能做到几十万次级别,足够大多数消费类产品了。关键点是每次启动时要做一次扫描,找到最后一个有效的slot位置,并且定期做磨损均衡。LL库在这种场景下依然是首选,因为你可能需要在页内做非对齐、非满长度写入,HAL库的封装反而会限制你。

另外,如果调试的时候碰上Keil的Flash Download失败,先别急着怪代码。大概率是目标板复位引脚被调试器占用、读保护开启、或者SWD引脚被你自己的代码复用了。用STM32CubeProgrammer在复位保持连接的模式下擦一次芯片,大半问题都能烟消云散。我见过一个同事在这上面耗了一天,最后发现是代码把PA13/PA14直接当GPIO输出了,调试器当然连不上,这种低级失误有时候比HardFault还难查。

回到开头那个案例,同事最后接受的建议很简单:把参数存储区从Page0挪到Flash末尾的专用页,同时在HardFault_Handler里加了现场dump逻辑,再也没出现过"一擦Flash就死机"的灵异问题。Flash操作这东西,该花的时间一分都不能省,尤其是G0这种架构和旧系列差异很大的芯片,把底层机制搞明白再写业务代码,比出了HardFault再回来排查要省几天时间。

返回列表