STM32H7外部QSPI Flash FLM下载算法开发与排错
简介:这份应用笔记面向使用STM32H7XX系列MCU、需要在KEIL-MDK环境下把程序烧录到外部SPI Flash的嵌入式开发者,聚焦自定义FLM下载算法的开发与落地,解决外部Flash无法直接套用MDK内建算法的问题。内容从MDK下载算法基础知识讲起,依次覆盖FLM工程建立、SPI Flash下载算法制作与使用,以及FLM_DEBUG调试工程建立,并强调开发中关闭中断、改用查询方式、重新实现HAL_InitTick、HAL_GetTick和HAL_Delay等要点;同时涉及QSPI/OCTOSPI接口函数、FlashDev.c结构体配置、时序参数调整、写保护处理与GPIO辅助观察。压缩包仅含1个docx文档,约421KB,篇幅精炼,目录清晰,便于按模块查阅。已有2499人学习,适合需要为外部Flash定制烧录算法、排查下载与校验问题的工程师参考,可从中获得工程搭建思路、接口实现要点和调试排错方法。
1. 外部 Flash 跑程序,卡住的往往不是驱动
在 STM32H7 上把程序搬进外部 QSPI Flash 之后,最常见的翻车点不是驱动没写对,而是按下 keil mdk flash 下载按钮时弹出一句 Cannot Load Flash Programming Algorithm,或者干脆报地址越界。MDK 对片内 Flash 自带下载算法,外部 SPI Flash 没有,得自己做一个 .FLM:这段与地址无关的代码会被调试器搬进芯片内部 RAM 跑起来,代替 MDK 完成初始化、擦扇区、写页、回读校验。这份 STM32H7XX 外部 FLASH FLM 下载算法笔记,从模板改造、IOC 最小化配置、FlashDev.c 与 FlashPrg.c 的落地写法,一路讲到算法装不上、下载一半挂死、校验不过的排查路径。它适合已经能把外部 Flash 读起来、但还没打通下载链路的 H7 开发者;先跑通片内 Flash 调试的人看到这份笔记,踩的坑会少一半。
2. 从模板起一个 FLM 工程:最小化 IOC 与不依赖 SysTick 的延时
2.1 FLM 是加载进内部 RAM 里一段一段跑的,所以什么都不能开
很多人第一次做下载算法,习惯性地把平时工程里的 BSP 一股脑塞进去,结果第一次下载就卡在初始化。原因在于 FLM 的运行环境和普通固件完全不同:MDK 在下载阶段把 .FLM 文件当作一段“地址无关代码”加载到芯片内部 RAM 的指定区域(加载地址在调试配置里设),然后按需调用里面的 Init、EraseSector、ProgramPage、Verify。此时芯片刚复位完成,向量表没建立,中断控制器没初始化,也没有任何线程或调度器在跑。只要有一个中断触发,CPU 就会跳到未定义的地址,表现为下载器报通讯失败或者直接挂起。
所以第一条铁律是:FLM 里不要开任何中断,全部改用查询方式操作外设。轮询标志位、轮询忙状态、轮询写完成,这套做法在功能上完全不输中断,代价只是下载时 CPU 空转,而下载本来就是一件不需要考虑功耗和实时性的事。
第二条更隐蔽。HAL 库大量函数内部靠“等超时”来兜底,比如 QSPI 发送、Flash 写等待,写法都是先记一个起始时刻,然后循环判断HAL_GetTick() - start < timeout。而HAL_GetTick()返回的毫秒计数来自uwTick,uwTick是在 SysTick 中断服务函数里自增的。中断一关,uwTick永远停在零,所有带超时判断的 HAL 函数就会一直等到那个永远到不了的超时值,实际表现是下载卡死在初始化阶段,过好几分钟才被调试器判为失败。更麻烦的是HAL_Delay()——很多 Flash 手册要求上电后延时若干毫秒再发命令,HAL_Delay 一卡,整条链路就断了。
结论很直接:在 FLM 工程里必须重新实现HAL_InitTick、HAL_GetTick、HAL_Delay这三个函数。它们在 HAL 里都带__weak属性,你在自己工程里定义一份同名同签名的实现,链接器就会用你的版本覆盖库里的版本,不需要改任何库文件。
2.2 模板选取与工程文件组织
Keil 安装路径下有一份官方模板,大致位置是ARM/PACK/ARM/CMSIS/<version>/Device/_Template_Flash,里面就是下载算法的三件套:FlashOS.h、FlashDev.c、FlashPrg.c。用这份模板起步可以,但更省事的是找一份已经能跑的 H7 QSPI FLM 工程当底子改——时钟树、QSPI 引脚、命令序列都是现成的,你只需要把地址和容量改成自己板子的参数。
工程里该加哪些文件,我的习惯是这样:
FLM_H7_QSPI/ ├── FlashDev.c # 器件描述表,告诉 MDK 这块 Flash 长什么样 ├── FlashPrg.c # 六个回调函数的实现,算法核心 ├── FlashOS.h # MDK 固定头文件,不要改 ├── qspi_flash.c / .h # 自己的 QSPI 底层:初始化、擦、写、读 ├── bsp_clock.c / .h # 时钟配置,只保留算法必需的几个时钟 └── stm32h7xx_hal_conf.h # 裁剪过的 HAL 配置,关掉用不上的模块启动文件交给 Run-Time Environment 自动带入,不要手动往工程里加system_stm32h7xx.c。这一点后面第 6 章会展开——调试版工程必须靠 Startup 自动带进来的那份,手动加会和启动文件里的定义冲突,链接时直接报重复符号。
还有一个容易忽略的开关:模板工程默认可能勾了位置无关代码(Position Independent)相关的选项。这项在普通应用工程里是好事,但在 FLM 里,地址无关是由 MDK 的加载机制保证的,工程里再叠一层反而会让函数指针的相对偏移算错,表现为调用 ProgramPage 时跳到乱七八糟的地方。新建工程后第一件事,把 C/C++ 和 Asm 两处的 Position Independent 选项都取消掉。
2.3 IOC 配置的三个原则:最小化、可观测、先内部时钟
用 CubeMX 生成配置时,最容易犯的错是“顺手全配了”。FLM 的 IOC 只有三个原则。
第一,最小化。只配时钟、QSPI 或 OCTOSPI 控制器、以及它们用到的 GPIO 复用。串口、定时器、DMA、文件系统,一个都不要。多一个外设就多一份初始化,就多一个在下载阶段挂死的可能。
第二,可观测。留两三个空闲 GPIO 当“探针”,在 Init、擦除循环、写等待循环里翻转电平。后面用逻辑分析仪或者示波器一夹,就能看出算法到底走到哪一步、卡在等什么,比纯靠调试器打断点快得多。这块算是血泪经验,第一次做 FLM 时没有探针,一个写等待超时的问题查了整整两天。
第三,时钟源先内部后外部。外部晶振在算法里要等 HSE 起振,如果板子上晶振焊接不良、负载电容不匹配,或者起振时间比算法里的等待时间长,初始化会直接卡死;而且这种卡死和下一条命令发不出去的表现几乎一样,很难区分。我的做法是先用内部 HSI 把整条链路(初始化、擦、写、校验)跑通,确认逻辑没问题,再换成外部晶振重测一遍时序裕量。两份 IOC 各存一份,调试时随时切换。
IOC 里真正需要关注的配置项不多:
| 配置项 | 建议取值 | 说明 |
|---|---|---|
| 系统时钟 | 先用 HSI,再切 HSE | 内部时钟起振确定,便于隔离问题 |
| QSPI 时钟分频 | 先慢后快 | 首次调试用最低分频,跑通后再提 |
| QSPI 模式 | 间接写 + 内存映射读 | 写擦用间接模式,校验用映射模式 |
| Flash 容量 | 与实物一致 | 写错会导致扇区表越界 |
| 调试 GPIO | 2~3 个 | 在关键循环里翻转,用于定位卡点 |
生成代码后,把qspi.c、gpio.c、rcc.c里真正用到的部分整理进自己的qspi_flash.c,不要整个main.c搬过来——CubeMX 生成的main()里有 HAL_Init 和 SysTick 配置,那套东西和 FLM 的运行环境是冲突的。
2.4 重写三个 HAL 弱函数,用 DWT 做毫秒基准
不依赖 SysTick 中断的毫秒计时,最省事的做法是借 Cortex-M7 内核自带的 DWT 周期计数器。它就是个自由运行的 32 位计数器,每个 CPU 周期加一,不需要任何中断参与。
/* 覆盖 HAL 的弱函数:不再启动 SysTick 中断,改用 DWT 周期计数器 */ HAL_StatusTypeDef HAL_InitTick(uint32_t TickPriority) { (void)TickPriority; CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* 打开 DWT 访问权限 */ DWT->CYCCNT = 0; /* 计数器清零 */ DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; /* 启动计数 */ return HAL_OK; } uint32_t HAL_GetTick(void) { /* SystemCoreClock 是当前 CPU 主频,480MHz 时 1ms 对应 480000 个周期 */ return DWT->CYCCNT / (SystemCoreClock / 1000U); } void HAL_Delay(uint32_t Delay) { uint32_t start = HAL_GetTick(); /* 死等:减法写法天然兼容计数器回绕 */ while ((HAL_GetTick() - start) < Delay) { __NOP(); } }这段代码的关键点有三个。第一,HAL_InitTick里没有调用HAL_SYSTICK_Config,所以 SysTick 中断从头到尾都不会开,符合 2.1 节的要求。第二,HAL_GetTick的除数写成SystemCoreClock / 1000U而不是硬编码,换主频时不用改代码。第三,DWT 的 CYCCNT 是 32 位,480MHz 下大约 8.9 秒回绕一次,HAL_GetTick()的返回值因此也会周期性归零;用(now - start) < Delay这种无符号减法写法,回绕前后算出来的差值都是对的,但如果哪里写成if (now > start + Delay)就会在回绕点翻车。
还有一个细节:SystemCoreClock这个全局变量要靠 SystemInit 更新,而 FLM 工程里没有完整的启动流程。稳妥的做法是在Init()里显式调一次时钟配置函数,然后手动把SystemCoreClock赋成实际频率,别指望它自己是对的。如果这里没对上,HAL_Delay(10)可能实际只延时了 3 毫秒,Flash 上电时序不够,写进去的数据就是错的。
3. FlashDev.c 与地址空间:把 4MB 外部 Flash 描述成 MDK 认得的器件
3.1 存储映射起始地址先定死,后面所有东西都围着它转
外部 QSPI Flash 通过内存映射方式挂到总线上,常见基地址是 0x90000000。这个地址不是随便挑的,它由芯片的总线矩阵和 QSPI/OCTOSPI 控制器决定,具体查参考手册里的存储器映射表。定下来之后,三处必须严格一致:
第一处是FlashDev.c里的DevAdr,MDK 靠它判断下载目标落在哪个地址区间。 第二处是应用工程的分散加载文件(sct)或者链接脚本,代码段、只读数据段都要放到这个区间。 第三处是算法内部所有地址换算——EraseSector和ProgramPage拿到的adr是绝对地址,而 QSPI 的擦写命令要的是相对 Flash 芯片内部的偏移,所以每个函数开头都得减一次基地址。
这三处只要有一处不对,症状就千奇百怪:地址算多了一次减法,写进去的数据整体偏移;链接脚本没改,代码还在片内 Flash 里,下载算法看起来“成功”了但程序跑的是旧代码。
3.2 FlashDevice 结构体逐字段填
FlashDev.c里只有一个全局常量,但每个字段都直接影响下载行为,值得一个一个抠。
/* FlashDev.c —— 告诉 MDK 这块外部 Flash 长什么样 */ #include "FlashOS.h" struct FlashDevice const FlashDevice = { FLASH_DRV_VERS, /* 驱动版本宏,固定值,不要改成数字 */ "H7_QSPI_4MB", /* 出现在 MDK 算法下拉框里的名字,自己起 */ EXTSPI, /* 器件类型:外部 SPI Flash */ 0x90000000, /* 存储映射起始地址,与链接脚本一致 */ 0x00400000, /* 总容量 4MB */ 0x100, /* 编程页大小 256 字节 */ 0, /* 保留字段,必须为 0 */ 0xFF, /* 擦除后的内容,NOR Flash 都是 0xFF */ 20000, /* 页编程超时,单位毫秒,宁大勿小 */ 50000, /* 扇区擦除超时,单位毫秒,宁大勿小 */ /* 扇区表:成对给出「扇区大小, 起始偏移」,按地址升序排列,SECTOR_END 收尾 */ 0x001000, 0x000000, /* 4KB 小扇区,从偏移 0 开始铺满整片 */ SECTOR_END };DevType这个字段要特别注意。FlashOS.h里定义了ONCHIP、EXT8BIT、EXT16BIT、EXT32BIT、EXTSPI几个宏,外部 SPI Flash 用EXTSPI。用错类型在部分 MDK 版本上不会立刻报错,但会让 MDK 在计算校验和、处理地址对齐时走错分支。
两个超时字段是最容易抠得太紧的地方。有人按数据手册的典型擦除时间填个 500ms,结果批量下载时偶尔报超时——Flash 的擦除时间随温度、寿命、扇区位置波动很大,数据手册给的是典型值,最坏值可能是典型值的四五倍。这两个字段填大了没有任何副作用,只在你真的卡死时多等一会儿;填小了就是随机失败的来源。我的习惯是直接给它 20 秒和 50 秒,调试时反而更方便定位——真卡住了,等 20 秒报错,比等 500 毫秒报错更容易判断是哪一步出问题。
3.3 扇区表的排列方式决定了擦除行为
扇区表是FlashDevice里最需要动脑子的部分。它是一组(扇区大小, 起始偏移)对,MDK 读进去之后会自己推导出每个扇区的边界,然后在你调用EraseSector时传入对应的绝对地址。
规则是:条目按地址升序排列,相邻两个条目的起始偏移之差,就是前一种扇区重复的次数,最后必须以SECTOR_END结束。整个表必须完整覆盖0到szDev的范围,不能有空洞,也不能重叠。有空洞的后果是某段地址 MDK 认为不可擦写,下载时报“地址不在算法范围内”;重叠的后果更糟——你只想擦一个 4KB 扇区,实际把后面一大片也擦了。
外部 QSPI Flash 常见两种组织:整片 4KB 小扇区,或者“底部若干 4KB + 中间 32KB + 上部 64KB”的混合结构。整片 4KB 写起来最简单:
0x001000, 0x000000, /* 4KB × 1024,铺满 4MB */ SECTOR_END混合结构则要分段列出,每一段给出该段的扇区大小和该段的起始偏移:
0x001000, 0x000000, /* 前 64KB 用 4KB 扇区 */ 0x010000, 0x010000, /* 中间用 64KB 块,擦除速度快得多 */ SECTOR_END选哪种取决于你的下载习惯。整片 4KB 的好处是擦除粒度细,改一行代码只擦一小块,下载快;坏处是全片擦除时命令数量多,一次完整烧录可能要多花几秒。混合结构适合“程序区 + 数据区”的布局,数据区用小扇区频繁改,程序区用大块一次擦完。这里没有标准答案,但有一点是确定的:扇区表必须和芯片实际结构完全一致,不能为了“擦得快”把 4KB 扇区谎报成 64KB——MDK 会按 64KB 的边界去发擦除命令,落在错误边界的擦除会擦掉不该擦的内容。
3.4 编程页大小与 MDK 的分包逻辑
szPage填的是这颗 Flash 一次页编程能接受的最大字节数,NOR Flash 通常是 256 字节。这个值决定了 MDK 在下载时如何切分数据——它会按szPage对齐后一段一段调用ProgramPage。
注意是“最大”,不是“必须”。ProgramPage收到的sz参数可能小于szPage,尤其是文件末尾那一小段。所以函数里不能假设sz恒等于 256,必须按实际长度处理,并且要支持跨页写入——一段数据从第 200 字节开始、长度 100 字节,就跨了两个物理页,靠一个写命令是写不完的,要自动拆成两次。
填错的典型症状是“下载完成但数据错乱”:szPage报大了,MDK 一次给你 512 字节,而 Flash 内部缓冲区只有 256 字节,超出的部分被丢弃或者覆盖回页首;报小了,MDK 会切成很多小段,功能上没问题但下载慢得离谱。
4. FlashPrg.c 六个函数:初始化、擦除、编程、校验的落地写法
4.1 Init / UnInit:上电时序与引脚状态
Init是 MDK 调用的第一个函数,签名固定为int Init(unsigned long adr, unsigned long clk, unsigned long fnc)。三个参数里adr是本次下载的目标起始地址,clk是 MDK 传进来的 CPU 时钟频率(单位 Hz),fnc是功能码。返回值 0 表示成功,非 0 表示失败,MDK 会据此决定是否继续。
clk这个参数很有用——你不需要自己去猜主频,直接用它在算法内部换算超时周期。但要注意它给的是 CPU 时钟,QSPI 控制器的分频还得按自己的配置算。
/* FlashPrg.c —— MDK 在下载阶段回调的入口函数 */ #include "FlashOS.h" #include "qspi_flash.h" int Init(unsigned long adr, unsigned long clk, unsigned long fnc) { (void)adr; (void)fnc; SystemCoreClock = clk; /* 让 HAL_Delay 的换算基准正确 */ if (qspi_flash_clock_enable() != 0) { return 1; /* 时钟没起来,直接失败 */ } if (qspi_flash_gpio_init() != 0) { return 2; /* 引脚复用配置失败 */ } if (qspi_flash_ctrl_init() != 0) { return 3; /* QSPI 控制器初始化失败 */ } if (qspi_flash_read_id() != 0) { return 4; /* 读不到 ID,多半是硬件或时序问题 */ } qspi_flash_exit_power_down(); /* 有些片子复位后处于深睡,要发唤醒命令 */ HAL_Delay(2); /* 手册要求的上电稳定时间,短一点没关系 */ qspi_flash_disable_write_protect(); /* 解除全部地址的写保护,见 4.3 */ return 0; } int UnInit(unsigned long fnc) { (void)fnc; qspi_flash_deinit(); /* 引脚恢复为模拟态,关掉 QSPI 时钟 */ return 0; }返回值带上不同的错误码是个好习惯。MDK 只会告诉你“算法执行失败”,但如果你在Init里把每一步的返回值区分开,再用调试器看一眼 CPU 停在哪一行,就能立刻知道是时钟、引脚、控制器还是芯片本身的问题。同理,第 2.3 节说的那几个调试 GPIO,在这里也该翻一下,示波器上一看就知道执行到哪一步了。
UnInit相对轻松,但也别省。有些下载器在一次下载结束后会反复调用 Init/UnInit 对,如果 UnInit 没把引脚恢复干净,下次 Init 里重新配复用就可能失败。
4.2 EraseSector / EraseChip:擦除是耗时最长的一步
EraseSector收到的是绝对地址,先换算成芯片内部偏移,再发擦除命令。扇区大小要和FlashDev.c里的扇区表一致——MDK 按表里的粒度调用你,你按同一个粒度去擦,才不会有错位。
int EraseSector(unsigned long adr) { uint32_t offset = adr - QSPI_FLASH_BASE; /* 0x90000000 起的偏移 */ qspi_write_enable(); /* 擦除前必须发写使能 */ if (qspi_cmd_sector_erase(offset) != 0) { return 1; /* 命令发送失败 */ } /* 不返回则继续轮询忙状态,超时值取 FlashDev.c 里配的 50s 量级 */ if (qspi_wait_wip(ERASE_TIMEOUT_MS) != 0) { return 2; /* 等待擦除完成超时 */ } return 0; } int EraseChip(void) { qspi_write_enable(); if (qspi_cmd_chip_erase() != 0) { return 1; } /* 整片擦除时间最长,超时要给足,50000ms 起步 */ if (qspi_wait_wip(CHIP_ERASE_TIMEOUT_MS) != 0) { return 2; } return 0; }这里有个容易忽略的点:擦除命令发出之后,芯片进入忙状态,此时只能发读状态寄存器命令,发别的命令一律被忽略或者返回错误。所以qspi_wait_wip的循环体里只做一件事——发0x05读状态寄存器,检查 bit0。如果有人为了“省时间”在等待循环里顺手做点别的事,比如提前准备下一段数据发过去,大概率会被丢弃,最后表现为随机数据错误。
另一个坑是写使能。SPI Flash 的写使能位(WEL)在每次擦除、编程命令执行完之后会自动清零,所以每发一条擦除或编程命令之前都要重新发一次0x06。忘记发的表现是命令看起来发出去了,忙状态也等了,但读回来数据没变——这类现象很像时序问题,其实是漏了一个字节的命令。
4.3 ProgramPage 与任意长度写:跨页和写保护是两个独立问题
ProgramPage是调用最频繁的函数,签名int ProgramPage(unsigned long adr, unsigned long sz, unsigned char *buf)。它的职责是把buf里的sz字节写到adr,并且保证字节数可以是任意值。
底层我一般分两层写:一个处理任意长度、自动跨页的hal_qspi_flash_write,一个薄薄的ProgramPage只做地址换算和错误码映射。这样底层函数在调试工程里可以单独调用测试,不用每次都跑完整下载流程。
/* 支持任意长度、自动跨页的写函数 */ int hal_qspi_flash_write(uint32_t addr, const uint8_t *buf, uint32_t len) { while (len > 0) { /* 计算当前页还剩多少字节可写,避免越页 */ uint32_t page_left = QSPI_PAGE_SIZE - (addr % QSPI_PAGE_SIZE); uint32_t n = (len < page_left) ? len : page_left; qspi_write_enable(); /* 每次写命令前都要重新使能 */ if (qspi_cmd_page_program(addr, buf, n) != 0) { return -1; } if (qspi_wait_wip(PROGRAM_TIMEOUT_MS) != 0) { return -2; /* 页编程超时 */ } addr += n; buf += n; len -= n; } return 0; } int ProgramPage(unsigned long adr, unsigned long sz, unsigned char *buf) { uint32_t offset = adr - QSPI_FLASH_BASE; /* sz 不一定等于 szPage,文件末尾那一段通常更短 */ if (hal_qspi_flash_write(offset, buf, sz) != 0) { return 1; } return 0; }跨页这个问题值得单独说。NOR Flash 的页编程有个物理约束:一条页编程命令写入的数据不能跨越页边界,一旦跨了,地址会回卷到页首,把前面的数据覆盖掉。表现出来就是“下载完成、校验也过、但程序跑起来某几个函数是乱的”——因为校验逻辑如果也按同样的错误方式读,不一定能发现。page_left那行代码就是防这个的。
写保护是另一个独立问题。很多 QSPI Flash 出厂或者复位后,状态寄存器里的块保护位是置位的,某些地址段处于写保护状态。此时擦除命令会被静默忽略,忙状态也正常返回,读回来还是老数据。所以Init里要显式做一次“解除全部写保护”:先读状态寄存器,判断保护位,再发写状态寄存器命令清掉,最后轮询确认。这套动作不能省,尤其是拿到一块二手板子或者别人调过的板子时,状态寄存器里可能是任何值。
/* 解除写保护并等待生效,Init 里调用一次即可 */ int qspi_flash_disable_write_protect(void) { qspi_write_enable(); /* 写状态寄存器同样需要 WEL */ if (qspi_cmd_write_status(0x00) != 0) { /* 清掉 BP/TOP 等保护位 */ return -1; } return qspi_wait_wip(PROGRAM_TIMEOUT_MS); }有些型号的保护位在状态寄存器 2 或者 3 里,需要0x31、0x11这类扩展命令配合,具体查自己芯片的手册。写完之后一定要回读确认,别假设命令一定生效。
4.4 Verify:读回比较,注意缓存
Verify的职责是把刚写进去的数据读回来,和源缓冲区逐字节比较。签名int Verify(unsigned long adr, unsigned long sz, unsigned char *buf),返回 0 表示一致,返回非 0 表示不一致——很多实现返回的是出错位置,方便定位。
实现上有两条路。第一条是走间接读模式,一个字节一个字节地发读命令,慢但绝对可靠。第二条是切到内存映射模式,把 Flash 当成一块普通内存直接memcmp,速度快一个数量级,但有个前提:QSPI 控制器必须真的进入了映射模式,而且地址窗口配置正确。
int Verify(unsigned long adr, unsigned long sz, unsigned char *buf) { uint32_t offset = adr - QSPI_FLASH_BASE; if (qspi_flash_memory_mapped() != 0) { /* 切到内存映射模式 */ return 1; } /* 映射模式下 Flash 内容可以像普通内存一样访问 */ const uint8_t *flash_ptr = (const uint8_t *)(QSPI_FLASH_BASE + offset); for (uint32_t i = 0; i < sz; i++) { if (flash_ptr[i] != buf[i]) { return (int)(offset + i + 1); /* 返回 1 基的出错偏移 */ } } return 0; }如果芯片开了 D-Cache,这里就是最经典的陷阱:映射窗口的地址可能被缓存过,flash_ptr[i]读到的其实是缓存里的旧值,校验永远通过,但实际 Flash 里是错的。正确做法是在进入映射模式之后、比较之前,把对应地址范围的数据缓存无效化(对 Cortex-M7 是SCB_InvalidateDCache_by_Addr),或者干脆把这块地址配成不可缓存区域。第一条路虽然慢,但完全没有这个顾虑,调试阶段我倾向先用间接读把逻辑跑通,确认时序和命令都没问题,再换成映射读提速。
5. 避坑与排查:五个真实翻车现场
5.1 报错提示算法无法加载,或者下拉框里根本找不到
现象:调试配置的 Flash Download 页里点 Add,找不到自己编出来的算法;或者添加了,点下载时报 Cannot Load Flash Programming Algorithm。
原因:三种可能。一是 .FLM 文件没放到 MDK 会扫描的目录里,放在工程输出目录是不行的;二是文件名和FlashDev.c里DevName字段不一致,MDK 靠文件名匹配;三是 RAM for Algorithm 的 Size 设得太小,算法被加载到 RAM 时空间不够,调试器直接报加载失败。
解决:把生成出来的 .FLM 复制到 MDK 安装目录下的ARM/Flash,这是全局目录,所有工程都能看到,比塞进某个器件包的CMSIS/Flash目录更好管理,重装或者升级器件包时不会丢。然后在调试配置里把 RAM for Algorithm 的起始地址和 Size 按芯片内部 RAM 的实际情况调大,一般 8KB~16KB 起步,算法里如果带了较大缓冲还要再留余量。这个值给大了不会浪费什么,给小了必挂。
5.2 下载进度条卡在中途,几十秒后报超时
现象:点下载后进度条走到某个百分比就不动了,等很久报 timeout,重试有时候又能过。
原因:绝大多数是擦除或编程的等待逻辑没写对。常见的是qspi_wait_wip里的超时判断用了HAL_GetTick(),但HAL_InitTick没重写、SysTick 没跑,HAL_GetTick()恒返回 0,(now - start) < timeout永远成立,循环出不来。另一种是FlashDev.c里的toProg/toErase填得太小,芯片还在忙,MDK 已经判超时并放弃了。
解决:先确认三个 HAL 弱函数确实被覆盖了——在HAL_GetTick里下一个断点,看能不能命中;或者用调试 GPIO 在等待循环里翻转,示波器上看有没有波形。确认没问题后,把FlashDev.c里两个超时字段统一放大到 20000 和 50000 毫秒,先用大值把功能跑通,等整套流程稳定了再考虑要不要收紧。这类玄学问题九成是超时值或者时钟基准的问题,别一上来就怀疑硬件。
5.3 下载显示成功,程序跑起来却直接进 HardFault
现象:MDK 提示 Programming Done、Verify OK,复位后程序不跑,调试器一挂上去发现停在 HardFault。
原因:地址映射没对齐。要么链接脚本里的代码段地址和FlashDev.c的DevAdr不一致,下载算法把数据写到了 A 区间,程序却从 B 区间取指;要么算法里减基地址的运算多减或者少减了一次,写进去的内容整体偏移了几百字节,恰好落在一个“看起来有数据”的位置,校验时因为比较的也是同一块错位区域,反而通过了。
解决:下载完成后,用调试器的内存窗口直接看 0x90000000 起始的几个字,对照编译出来的 .map 文件里第一个函数的地址,看是不是同一份内容。再检查链接脚本,确认ROM区域的起始地址、大小和算法描述完全一致。这一步不要靠猜,靠对地址。另外一个容易忽略的点是时钟——算法里配置的 QSPI 时钟分频和应用工程里配的可能不一样,如果算法里跑得太快,读出来的内容在边界上可能不稳,表现出来也是随机跑飞。
5.4 第一次下载正常,第二次必失败,断电才能恢复
现象:上电后第一次下载成功,不断电再下一次就报错,必须拔电重来。
原因:芯片还停留在上一次操作后的状态里。可能是 QSPI 控制器没有复位,还处在内存映射模式,此时发间接模式命令会被忽略;也可能是写保护状态寄存器在某些操作后被重新置位;还有一种情况是算法跑完之后没有正确UnInit,引脚还挂在 QSPI 复用上,下次Init重新配置时冲突。
解决:在Init开头先做一次控制器软复位——关掉 QSPI 使能位、清掉配置寄存器、等几个周期,再重新初始化。同时在UnInit里把 QSPI 控制器彻底关掉、引脚切回模拟态。如果还不行,在Init里加一次“退出映射模式”的显式命令,有些控制器切回间接模式需要额外一步。这类问题的排查方法是:第二次下载失败时不要断电,直接用调试器看 QSPI 控制器的几个关键寄存器值,和刚上电时的值对比,差异就是线索。
5.5 校验能通过,但把 Flash 拆下来用编程器读,内容对不上
现象:MDK 的 Verify 一路绿灯,但用外部编程器读出来的内容和源文件不一致,或者程序运行结果和预期差一点。
原因:Verify 走了内存映射模式,读的是 D-Cache 里的旧数据。写操作通过间接模式直接进了 Flash,但映射窗口那块地址在缓存里还是写之前的内容,比较的时候自然全对。这个坑在开了 Cache 的 H7 上尤其常见,因为默认配置下这块区域是可缓存的。
解决:两条路。稳妥的一条是把 Verify 改成走间接读,一个字节一个字节地发读命令,慢但结果可信;性能优先的一条是在比较之前对目标地址范围做一次缓存无效化,并且把 QSPI 映射区域配置成不可缓存或者写穿透。我一般两个都做——间接读用来做最终确认,映射读加缓存无效化用来做快速预检,两边的结果必须一致才认为真的写对了。从那以后我每次改完 Verify 都强制用编程器抽读一段比对,不再只信 MDK 的那句 Verify OK。
6. FLM_DEBUG:把算法从黑匣子里拽出来单独调试
正常的 FLM 工程是没法像普通工程那样下断点单步的——它不被直接烧进 Flash,而是被 MDK 在下载阶段动态加载到 RAM 里执行,你按 F5 启动调试,跑的其实是应用工程,算法根本没被加载。所以在算法开发的早期,必须专门做一个可调试的版本。
改造点就四个,缺一不可。第一,去掉 C/C++ 选项里的 Position Independent 相关勾选;第二,Asm 选项里同样去掉;第三,Linker 页里勾上 Use Memory Layout from Target Dialog,让链接地址从 Target 对话框里取,而不是用分散加载文件;第四,在 Manage Run-Time Environment 里把 Startup 勾上,由它自动带入启动文件,同时把工程里手动添加的system_stm32h7xx.c删掉——两个启动文件同时存在会报重复定义,而且 RTE 带进来的那份才是和调试环境匹配的。
改完之后,工程就变成一个普通的、能烧到内部 Flash 或者内部 RAM 里跑的工程了。这时候可以直接在FlashPrg.c的六个函数里下断点,用一个测试用的main()依次调用 Init → EraseSector → ProgramPage → Verify,每步之后用调试器的内存窗口看 Flash 映射区域的内容。这个流程跑通了,再切回真正的 FLM 工程编译,剩下的就只是加载和打包的问题。
验证顺序我一般固定成五步,每步都有明确的观察点:
| 步骤 | 验证内容 | 观察点 |
|---|---|---|
| 1 | Init 能否读到正确的 Flash ID | 返回值 + ID 寄存器 |
| 2 | 单扇区擦除后读回是否全 0xFF | 内存窗口看映射地址 |
| 3 | 写 256 字节后再读回是否一致 | 逐字节比对 |
| 4 | 跨页写 512 字节是否越页回卷 | 检查第 256 字节位置 |
| 5 | 整片擦除耗时是否在超时范围内 | 计时器测实际耗时 |
这套顺序的价值在于把问题隔离在最小范围内。第 1 步不过,说明引脚、时钟、命令序列有问题,跟擦写逻辑无关;第 3 步不过但第 2 步过,说明是页编程命令或者写使能的问题;第 4 步不过,基本可以断定是跨页计算写错了。按这个顺序走,每个失败都能直接定位到一小段代码,不至于对着“下载失败”四个字猜。
最后提醒一个容易忘的细节:调试版工程里为了让printf或者日志能输出,有人会顺手接上串口和重定向。这套东西在真正的 FLM 工程里必须全部拿掉,因为下载阶段没有串口初始化的上下文,HAL 的串口发送函数会因为时钟没配而卡在等待标志位上。我现在的习惯是,每次从调试版切回 FLM 版之前,先把工程完整编译一遍并检查输出目录里的 .FLM 大小——如果比上次明显大了一圈,多半是哪个不该加的模块又被链进来了。希望帮到你。
本文还有配套的精品资源,点击获取