简介:面向STM32H7XX系列MCU开发者的外部Flash下载算法应用笔记,聚焦KEIL-MDK环境下为SPI/QSPI外置Flash定制FLM下载算法的完整思路。内容从MDK下载算法基础知识切入,覆盖FLM工程建立、SPI Flash算法制作与使用、FLM_DEBUG调试工程搭建,并展开说明关闭中断、查询方式操作、重实现HAL_InitTick/HAL_GetTick/HAL_Delay、QSPI/OCTOSPI接口函数以及FlashDev.c设备结构体配置等关键环节,也给出利用GPIO观察下载进度和排查问题的调试方法。资源包为1个docx文档,大小约421KB,篇幅集中、便于按章节查阅。已有2499人学习,适合需要将程序烧录到外部Flash并完成可靠调试的嵌入式工程师、STM32学习者参考,可帮助梳理从工程模板修改到算法验证的落地路径,并补充时序调整、错误处理与加载地址配置等实践要点。
1. 外部 Flash 烧不进去,问题不在板子
板子上挂一颗 QSPI Flash,应用整个搬进去跑 XIP,Keil MDK 里点下载,进度条卡在 0% 不动,弹一句 Cannot Load Flash Programming Algorithm;换 J-Link 接上去,单步能读能写,一到烧录就哑火。硬件没坏,坏在认知:MDK 只认器件包(DFP)里自带的那几个片上 flash 算法,/Keil/ARM/PACK/Keil/STM32H7xx_DFP/x.x.x/CMSIS/Flash下没有你手上这颗外部 SPI Flash 的条目,它就不认识这块地址空间。
这份 STM32H7XX + KEIL MDK 外部 FLASH FLM 下载算法工程笔记,干的就是补上这块空白:写一份与地址无关的算法工程,实现初始化、擦除、编程、读取、校验,让 MDK 在下载阶段把算法搬进芯片内部 RAM 执行,替你操作外部 Flash。适合已经能把 H7 跑起来、准备把程序放进外部 Flash 做 ISP 或 XIP 的开发者,也适合想搞明白 MDK 下载黑匣子里到底发生了什么的熟手。
2. FLM 的运行链路:算法怎么被塞进内部 RAM 替你烧外部 Flash
2.1 从点击下载到算法在 RAM 里跑起来
很多人第一次做 FLM,把它当成一个普通固件工程来编,编完发现生成的 .FLM 只有几十 KB,既没有中断向量表也没有 main,一脸懵。这玩意儿本来就不是拿来独立运行的,它是一段被调试器按需搬运、按需调用的位置无关代码。
完整链路是这样:MDK 在下载前,先把 .FLM(本质是一个特殊链接的 ELF)加载到芯片内部 RAM 的某个地址,这个地址就是你工程里配置的算法加载地址,工程侧对应 Flash Download 页里的「RAM for Algorithm」。搬完之后,调试器通过调试协议在目标 RAM 里找到算法的入口符号,依次调用Init、EraseSector、ProgramPage、Verify、UnInit,参数用寄存器传。算法里做的是 QSPI/OCTOSPI 命令收发,真正干活的是外部 Flash 自己。
这里有个关键约束决定了后面所有的写法:算法运行期间,芯片里没有你的应用,中断向量表指向的位置是无效的。所以算法内部一个中断都不能开,全部查询方式轮询;SysTick 中断当然也不能开,但 HAL 库的HAL_Delay、HAL_GetTick又强依赖 SysTick,这就得自己接管。这条约束是后面 3.3 节所有改写的原因,先记住。
还有一点常被忽略:算法被要求「与地址无关」,因为 MDK 可能会把它加载到不同地址、不同工程复用。所以模板工程里那些 Position Independent 选项、绝对地址跳转,在 FLM 工程里是要反过来处理的,这在第 6 章改调试工程时会再撞上一次。
2.2 FlashDevice 结构体:MDK 认识外部 Flash 的唯一凭据
FlashDev.c里就一个结构体常量,但它决定了 MDK 眼里你的 Flash 长什么样——多大、从哪开始、按多少字节编程、擦除粒度多大、擦完是什么值。写错了,后面代码再对也白搭。
/* FlashDev.c:告诉 MDK 这块外部 Flash 的全部物理属性 */ struct FlashDevice const FlashDevice = { FLASH_DRV_VERS, /* 驱动版本,固定宏,不要改 */ "H7B0_Ext_QSPI_Flash", /* 设备名,会显示在 Flash Download 列表里 */ EXTSPI, /* 设备类型:外部 SPI Flash */ 0x90000000, /* 器件起始地址,QSPI 内存映射基地址 */ 0x00400000, /* 器件总容量 4MB */ 0x100, /* 编程页大小 256 字节,必须与硬件页一致 */ 0, /* 保留,必须为 0 */ 0xFF, /* 擦除后的内容,NOR Flash 通常是 0xFF */ 20000, /* ProgramPage 超时,单位 ms,调试期可放大 */ 50000, /* EraseSector 超时,单位 ms,调试期可放大 */ /* 扇区表:必须覆盖整个 4MB,最后以 SECTOR_END 收尾 */ { 0x010000, 0x000000 }, /* 64KB 扇区,起始偏移 0 */ { 0x010000, 0x010000 }, /* ... 中间省略 60 条,每 64KB 一条 ... */ { 0x010000, 0x3F0000 }, /* 最后一条:偏移 0x3F0000 */ { SECTOR_END } };逐个成员说清楚容易踩的地方。DevAdr是绝对地址,QSPI 内存映射一般从 0x90000000 起,写错一位,MDK 就会去擦别的地址,后果你懂的。szDev是总容量,sectors[]里的条目累加必须能覆盖到szDev,覆盖不到的区域 MDK 会认为不可编程,下载大固件时到某个偏移就报错。
sectors[]数组上限是SECTOR_NUM(512 条)。如果你的 Flash 最小擦除单位是 4KB、总容量 4MB,那就是 1024 个扇区,装不下。常见做法是把扇区表写粗一点,用 64KB 条目描述硬件上按 4KB 擦的区域,MDK 请求擦 64KB 时,你在EraseSector里循环擦 16 次 4KB 就行——擦除粒度由你的代码决定,不是由表硬性绑定。这个技巧能省掉一大堆表项,也能避免表写漏。
szPage是编程页大小,必须等于 Flash 硬件页。MDK 的ProgramPage每次最多给你szPage字节且地址按页对齐,你的驱动不需要处理跨页。但如果szPage写成 512 而硬件页是 256,写进去的数据就会在页内回卷,现象是「校验失败,但每隔 256 字节有一半数据是对的」,非常经典。
valEmpty填0xFF还是0x00取决于器件,NOR Flash 基本都是 0xFF,EEPROM 类可能是 0x00。填错的表现是 MDK 认为空片不空,会先擦一遍才写,速度慢一倍但功能正常,属于不容易发现的性能坑。
2.3 FlashPrg.c 的六个入口函数与调用时序
FlashPrg.c是算法的主体,MDK 只认这六个函数名和它们的签名,签名对不上链接会失败,返回值约定错了现象会很怪。
| 函数 | 签名 | 调用时机 | 返回约定 |
|---|---|---|---|
Init | int Init(unsigned long adr, unsigned long clk, unsigned long fnc) | 算法加载后、任何操作前 | 0 成功,1 失败 |
UnInit | int UnInit(unsigned long fnc) | 本次下载结束 | 0 成功 |
EraseChip | int EraseChip(void) | 勾选整片擦除时 | 0 成功,1 失败 |
EraseSector | int EraseSector(unsigned long adr) | 按扇区擦除 | 0 成功,1 失败 |
ProgramPage | int ProgramPage(unsigned long adr, unsigned long sz, unsigned char *buf) | 写一页 | 0 成功,非 0 为失败地址 |
Verify | unsigned long Verify(unsigned long adr, unsigned long sz, unsigned char *buf) | 校验 | 0 成功,非 0 为首个不一致地址 |
Init的三个入参里,adr是本次操作的起始地址,clk是 MDK 传下来的时钟频率(很多工程直接忽略,用自己的固定分频),fnc是功能码(编程、擦除、校验的组合掩码)。真正干活的是在这里把 QSPI 初始化好、把 Flash 从掉电/深睡里唤醒、解除块保护。
Verify的返回值约定最容易翻车:返回 0 表示全部一致,返回非 0 表示第一个不一致的地址。我见过有人写成「一致返回 1,不一致返回 0」,结果就是每次下载都报校验失败,但程序其实是好的,白白排查半天。函数体里比对时用一个volatile指针读回,别让编译器优化掉。
这几个函数在算法里是地址无关的,内部不能调用应用层的绝对地址函数。所以驱动层要写成「自己带一个 QSPI 句柄、自己带时钟配置」的自包含形式,这一点在 4.4 节展开。
3. 工程搭建:从模板改出一个能编译出 .FLM 的工程
3.1 起步模板与器件配置
不建议从零建工程。Keil 安装路径下有个通用模板:/Keil/ARM/PACK/ARM/CMSIS/<version>/Device/_Template_Flash,里面就是FlashDev.c、FlashPrg.c、FlashOS.h三件套骨架。但通用模板的器件配置是空的,时钟、QSPI 都要自己从零填,容易漏。
更省事的办法是找一份已经跑通的同系列 H7 FLM 工程当模板改,把FlashDev.c的结构体按自己的器件重写,FlashPrg.c的函数骨架保留,只换驱动实现。改的时候按这个顺序来,出错好定位:
第一步,在 Keil 的 Target 页把器件选成对应的 STM32H7 型号,但不要勾选「Use Memory Layout from Target Dialog」,FLM 工程需要一个手工 scatter 或者干脆把 IROM1 指到 RAM 地址。IROM1 的 Start 填算法的加载地址(比如 AXI SRAM 的 0x24000000),Size 填你留给算法的 RAM 大小,常见 32KB 或 64KB。这个值会直接映射到工程侧 Flash Download 里的 RAM for Algorithm,两边必须一致。
第二步,Output 页勾上「Create Executable」,输出名改成有辨识度的,比如H7B0_Ext_QSPI_Flash。生成的 .FLM 就是最终产物,不需要 .hex。
第三步,C/C++ 页的优化等级建议先用-O1,调试通过之后再往上调。-O2以上时,如果驱动里某个查询循环写得不够严谨,容易被优化成死循环或者被整个删掉,现象是「同一份代码 -O0 能跑,-O2 卡死」,这类玄学问题在 FLM 里特别多。
第四步,把System_stm32h7xx.c这类文件先加进来让工程能编过,等到第 6 章改调试工程时再按需删除。
3.2 IOC 最小化配置:只留时钟和 QSPI/OCTOSPI
用 CubeMX 配 IOC 的时候要克制。原则是最小化:只配下载算法必须的组件——系统时钟、QSPI 或 OCTOSPI 外设、必要的 GPIO。别把 UART、USB、RTOS、DMA 全勾上,多一个外设就多一份初始化时间,也多一份在 RAM 里跑飞的风险。
时钟这块,推荐先用内部晶振(HSI)把算法跑通。原因很实际:外部晶振的起振时间不确定,HSE 起振失败会让Init直接卡死在 HAL 的超时等待里,而外部 Flash 的工作时钟本来就可以在 QSPI 分频里单独调低,不需要系统主频跑满。等算法功能验证完了,再换外部晶振、提高 QSPI 时钟,出问题也知道是哪一个变量改的。工程里可以准备两份 IOC,一份内部晶振一份外部晶振,切换时直接换配置文件,别在一个文件里来回改。
QSPI 配置上有几个参数建议一开始就固定下来:时钟分频先用 4 分频甚至 8 分频,别一上来就跑最高速;指令模式单线、地址模式按器件支持选;Dummy Cycles 严格按器件手册来,多一个少一个都会读出错数据。这些参数后面在内存映射模式读回校验时会再核对一次。
另外建议多配几个空闲 GPIO 出来做状态指示。这是个土办法但极其有效:在Init开头拉高一个脚,在EraseSector里翻转一下,在ProgramPage里再翻转一下,用示波器或者逻辑分析仪看波形,一眼就能判断算法有没有真正跑起来、卡在哪个阶段。调试 FLM 时你没有任何 printf 可用(串口初始化在算法里是负担),GPIO 翻转几乎是唯一可观测的手段,血泪经验。
配完 IOC 生成代码,把生成的main.c之外的文件(时钟、QSPI、GPIO 初始化部分)加到 FLM 工程里。main.c不要整个加进来,里面通常有while(1)和中断初始化,会污染算法。
3.3 三个 sysTick 依赖函数的替换实现
HAL 库把HAL_InitTick、HAL_GetTick、HAL_Delay定义成了__weak,重新实现一遍,链接器就会用你的版本。这是整个 FLM 开发里最关键的一步,因为 HAL 里所有HAL_QSPI_*的超时判断、HAL_Delay的等待,全都挂在这三个函数上,而它们默认依赖 SysTick 中断——算法里中断是禁区。
我一般用 SysTick 寄存器轮询的方式实现,不碰中断也不依赖 DWT:
/* 覆盖 HAL 弱定义:SysTick 只用作计时器,永远不开中断 */ HAL_StatusTypeDef HAL_InitTick(uint32_t TickPriority) { /* 这里什么都不做,SysTick 在 HAL_Delay 里临时启用 */ return HAL_OK; } uint32_t HAL_GetTick(void) { /* 用一个软件累加的毫秒计数,由 HAL_Delay 维护 */ return s_ms_tick; } void HAL_Delay(uint32_t Delay) { while (Delay--) { /* 单次计数 1ms:重装载值 = 内核时钟 / 1000 - 1 */ SysTick->LOAD = (SystemCoreClock / 1000U) - 1U; SysTick->VAL = 0U; /* CLKSOURCE=1 表示用内核时钟,ENABLE=1 启动,TICKINT 保持 0 */ SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_ENABLE_Msk; /* 死等 COUNTFLAG 置位,计满 1ms 自动清零并停止 */ while ((SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk) == 0U) { __NOP(); } s_ms_tick++; } }三个函数的职责要分清。HAL_InitTick里不启动定时器,是因为算法在真正需要延时之前可能还有别的初始化,提前开 SysTick 也无害但没必要;HAL_GetTick返回软件累加的s_ms_tick,它只在HAL_Delay里增长,所以「时间」只在延时期间推进,这对 FLM 场景够用,因为所有超时判断都在 QSPI 收发前后,中间必然有延时调用;HAL_Delay是核心,每次循环重装 SysTick,死等COUNTFLAG,一个字节一个字节地把毫秒数数出来。
参数上要注意SystemCoreClock这个变量。它由SystemInit根据实际时钟树算出来,如果你在Init里改了时钟配置而没有更新这个变量,算出的重装载值就是错的,延时会整体偏快或偏慢,表现为「QSPI 擦除偶尔超时」。稳妥做法是在Init里时钟配完之后手动调一次SystemCoreClockUpdate()。
如果不想动 SysTick,另一个方案是用 DWT 的 CYCCNT 周期计数器。好处是精度高,坏处是 CYCCNT 是 32 位,主频越高回绕越快,需要处理回绕;而且某些调试器会独占 DWT,得先置位CoreDebug->DEMCR的 TRCENA。两个方案都行,选一个自己看得懂的,别混用——我见过HAL_GetTick用 DWT、HAL_Delay用 SysTick 的写法,两边时基不一致,超时判断永远不触发。
3.4 产物落地:.FLM 放哪、工程侧怎么挂上去
编出来的 .FLM 有两个存放位置,任选其一:
- 放进器件包目录
/Keil/ARM/PACK/Keil/STM32H7xx_DFP/<version>/CMSIS/Flash,好处是和官方算法并列显示,坏处是器件包一升级就被覆盖,得重放。 - 放进 Keil 安装目录下的
ARM/Flash,好处是独立于器件包、升级不受影响,推荐这个。
放好之后打开应用工程,Options for Target → Debug → Settings → Flash Download 页,点 Add,列表里就能看到你FlashDev.c里写的设备名。添加进来后,下方的 RAM for Algorithm 起始地址和 Size 必须填对。Size 给太小是最常见的报错来源,提示「算法文件无法加载」,因为算法本身的代码段加数据段放不下。给 32KB 起步,保守点给 64KB,反正 RAM 够。
还有一个容易漏的配置:Programming Algorithm 列表里,你的外部 Flash 算法和片上 Flash 算法可以共存。下载时 MDK 按地址匹配,落到 0x90000000 区间的走你的算法,落到 0x08000000 的走片上算法。如果你的程序是「引导区在片上、应用在外部」的结构,两个都要加上,并且注意各自的起始地址不要重叠。
4. QSPI/OCTOSPI 驱动层:五个必须自己写的接口函数
4.1 解除写保护与自动轮询
不少 SPI Flash 出厂时状态寄存器里的块保护位是开着的,尤其是用了很久的样片,或者被别的平台写过保护。你在算法里直接擦,命令发下去返回成功,读回来数据没变,现象就是「擦除返回 0 但内容还是旧的」。
所以Init里第一件正事是解保护,这个逻辑要单独封装成两个函数:
/* 清除状态寄存器里的块保护位,让整片地址可写 */ int hal_qspi_flash_set_wps_status(void) { uint8_t sr = 0; /* 读状态寄存器 1 */ if (qspi_read_status(&sr) != 0) return -1; /* 清掉 BP0~BP2 和 SRWD 位,注意有的器件还需要清状态寄存器 2 */ sr &= ~(SR_BP_MASK | SR_SRWD); if (qspi_write_status(sr) != 0) return -1; /* 写状态寄存器本身也需要时间,立刻回读会读到旧值 */ return hal_qspi_flash_auto_polling_wps_status(); } /* 轮询状态寄存器 WIP 位,直到写保护位真正生效 */ int hal_qspi_flash_auto_polling_wps_status(void) { uint32_t timeout = WPS_POLL_TIMEOUT; /* 建议 1000 次以上 */ uint8_t sr = 0; do { if (qspi_read_status(&sr) != 0) return -1; if ((sr & SR_WIP) == 0) return 0; /* WIP 清零表示内部操作完成 */ } while (--timeout); return -1; }注意两个细节。一是写状态寄存器命令本身是异步的,命令发完器件内部还在写,必须轮询 WIP 位(有的器件叫 BUSY),等它清零才能发下一条命令,否则下一条擦除命令会被直接丢弃,且不报错。二是有些器件的块保护位分布在状态寄存器 1 和 2 两个字节里,只清一个是不够的,具体看手册;判断标准是:解保护后用0x03读回整片,随便挑几个地址试擦,擦完变 0xFF 才算真解开了。
轮询超时值别省。NOR Flash 的写状态寄存器时间通常在毫秒级,给 1000 次读状态足够,但读状态本身走的是 QSPI 命令,如果时钟配得很低,一次读可能要几十微秒,1000 次就是几十毫秒。这个循环里不要调HAL_Delay,用纯计数轮询,快得多。
4.2 任意长度写:页边界拆分与地址推进
ProgramPage每次最多给你一页(szPage),但驱动层最好实现一个支持任意长度的写函数,因为Init阶段可能要写一些配置数据,长度不一定是页的整数倍。核心是按页拆分:
/* 任意长度写:内部按页拆分,跨页自动推进地址 */ int hal_qspi_flash_write(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t off, page_left, chunk; /* FLM 传进来的是绝对地址,QSPI 命令要的是相对偏移 */ if (addr < EXT_FLASH_BASE) return -1; off = addr - EXT_FLASH_BASE; if (off + len > EXT_FLASH_SIZE) return -1; /* 越界直接拒 */ while (len > 0U) { /* 本页还能写多少字节 */ page_left = QSPI_PAGE_SIZE - (off % QSPI_PAGE_SIZE); chunk = (len < page_left) ? len : page_left; if (qspi_page_program(off, buf, chunk) != 0) return -1; /* 等这次页编程真正完成,否则下一段会覆盖失败 */ if (qspi_wait_busy() != 0) return -1; off += chunk; buf += chunk; len -= chunk; } return 0; }参数说明:addr用绝对地址,和ProgramPage的入参保持一致,内部减掉EXT_FLASH_BASE转成 24 位偏移;len可以任意长,函数保证不跨页写。逻辑上有三层保护:起始地址必须落在器件范围内、长度不能越界、每次写之前算清本页剩余空间。
这里有个容易翻车的点:页编程命令(0x02)在页边界不会自动跳转,而是回卷到本页开头继续写。也就是说如果你一次发 300 字节到页内偏移 200 的位置,前 56 字节落在本页尾部,剩下 244 字节会覆盖本页开头——数据全乱,但命令本身返回成功。所以按页拆分不是优化,是必须。写完每页之后还要等 WIP 清零,别连着发,器件内部写周期没结束时会丢命令。
页大小这个常量要和FlashDev.c里的szPage用同一个宏,别在两处各写一个数字。改器件的时候只改一处,另一处忘改就是数据错乱,而且这种错乱是间歇性的,很难查。
4.3 扇区擦除与 memory-mapped 校验的切换
擦除相对简单,难点在「擦多大」。你的sectors[]表里写了 64KB,MDK 就会以 64KB 对齐的地址请求擦除,而器件最小擦除单位可能是 4KB,所以函数里要循环:
/* 擦除一个扇区块:按器件最小擦除粒度循环,直到覆盖请求的块大小 */ int hal_qspi_flash_erase_sector_block(uint32_t addr, uint32_t block_size) { uint32_t off, i; if (addr < EXT_FLASH_BASE) return -1; off = addr - EXT_FLASH_BASE; if ((off % QSPI_SECTOR_SIZE) != 0U) return -1; /* 必须按最小粒度对齐 */ for (i = 0; i < block_size; i += QSPI_SECTOR_SIZE) { if (qspi_sector_erase(off + i) != 0) return -1; if (qspi_wait_busy() != 0) return -1; /* 等本次擦除完成 */ } return 0; }block_size由调用方(EraseSector)根据sectors[]表推算,通常固定传 64KB 或 4KB。对齐检查一定要做,地址没对齐就发擦除命令,器件的实际行为是忽略低位、擦掉一个完整扇区,把相邻数据一起干掉,而且返回成功。
整片擦除EraseChip的处理要谨慎。SPI Flash 通常有 chip erase 命令(0xC7或0x60),但 4MB 器件整片擦除可能要几十秒,远超toErase给的 50 秒预算,也不方便中途报进度。我的做法是EraseChip里按扇区循环擦,本质和EraseSector一样,只是范围覆盖全片。缺点是慢,优点是可控、可中断、可加 GPIO 翻转看进度。
校验这边用内存映射读回最方便。QSPI 切到内存映射模式后,0x90000000 起始的地址空间就是 Flash 内容的镜像,直接按字节比对:
/* 读取并校验:切到内存映射模式,直接按地址比对 */ unsigned long Verify(unsigned long adr, unsigned long sz, unsigned char *buf) { unsigned long i; if (hal_qspi_flash_memory_mapped() != 0) return adr; /* 切模式失败 */ for (i = 0; i < sz; i++) { /* volatile 防止编译器把读操作优化掉 */ if (*(volatile unsigned char *)(adr + i) != buf[i]) { return adr + i; /* 返回第一个不一致的地址 */ } } return 0; /* 0 表示校验通过 */ }hal_qspi_flash_memory_mapped()里做的事是配置一条快速读命令(比如0xEB四线读),指定 dummy cycles 和地址/数据线宽,然后调HAL_QSPI_MemoryMapped。dummy cycles 必须和器件手册一致,配少了读出来整体错位,现象是「每 16 字节里有几个字节是下一个字节的值」,非常有辨识度。
5. 避坑与排查:五个真实翻车现场的定位思路
5.1 加载与运行期:算法根本没跑起来
现场一:点下载立刻弹 Cannot Load Flash Programming Algorithm!
现象是下载还没开始就报错,日志里连擦除都没发生。原因基本是两个:一是 RAM for Algorithm 的 Size 给小了,算法代码段放不下;二是起始地址和你的应用占用的 RAM 重叠,加载时把应用的数据段踩了,或者反过来。解决方式是回到 FLM 工程看 map 文件里 Code + RO Data + RW Data 的总大小,Size 至少给它的 1.5 倍;起始地址挑一块应用不用的 RAM 区,H7 上 AXI SRAM 前 64KB 一般没人动,用它比较稳。改完重新编译 .FLM 并重新添加,Keil 会缓存算法信息,不重新添加可能还是旧值。
现场二:算法加载成功,一进 Init 就 HardFault 或者直接超时
现象是下载进度条动了一下就报错,用调试器 attach 上去看 PC 停在一个异常处理里。原因通常是算法里开了中断——CubeMX 生成的代码默认会HAL_Init()里配 NVIC、开 SysTick 中断,而算法运行期间中断向量表无效,一进中断就跑飞。解决方式是把生成代码里的 NVIC 配置、HAL_Init里开中断的部分全部去掉,只保留时钟和外设初始化。另外检查一下.sct或者启动文件里有没有使能全局中断的语句,__enable_irq()这类调用在 FLM 里一律删掉。
现场三:擦了也写了,校验也过了,复位后程序不跑
这个最迷惑,因为 MDK 全绿。原因多半在FlashDev.c:DevAdr和实际的内存映射地址不一致,或者sectors[]表没有覆盖到固件真正落的那段地址。表现是固件的前半段被正确烧写,后半段 MDK 认为「不在我的管辖范围」直接跳过,校验也只校验了写过的那部分。解决办法是打开下载日志,比对实际写入的地址范围和sectors[]累加出来的范围,两者必须完全一致。另一个可能是valEmpty填错,导致 MDK 判断空片逻辑出错,这个改起来只需一个字节。
5.2 擦写与校验:跑起来了但结果是错的
现场四:Verify 报校验失败,但读出来数据明明是对的
现象是每次下载都红字提示校验错误,给了个地址,用调试器看那个地址的内容和源文件一模一样。原因基本确定是 D-Cache:H7 带 D-Cache,内存映射区 0x90000000 如果没有配置成 non-cacheable,CPU 读的是缓存里的旧值,而算法刚写完的数据还在总线上没落到缓存。解决方式有两种,在Init里调SCB_DisableDCache()直接关掉(简单粗暴,FLM 场景够用),或者配一段 MPU region 把 0x90000000 起始的地址空间设成 Normal Non-cacheable。关缓存最省事,验证阶段先用这个,性能敏感了再上 MPU。
现场五:小固件能烧,大固件烧到一半失败
现象是 100KB 以内一切正常,超过某个大小就报超时或者校验错。三个原因按概率排:一是toProg/toErase给得太小,大扇区擦除实际耗时超过预算,MDK 提前放弃;二是驱动里qspi_wait_busy的超时循环次数不够,器件还在写你就发下一条命令;三是 QSPI 时钟配得太高,长距离走线或者劣质排线下误码率上来了。解决顺序是先把两个超时值放大到 20000/50000,不行再把 QSPI 分频从 2 改成 4 试,最后才怀疑硬件。这三步顺序很重要,反着来会浪费大量时间在换板子上。
6. 二次开发:把 FLM 改成可调试工程的完整改造清单
正常的 FLM 工程没法像普通工程那样下断点单步,因为它的入口不是main,是调试器按符号调用的那几个函数。想验证FlashPrg.c里某一行到底有没有执行、adr参数传进来是什么值,就得把它改造成「能在 RAM 里跑起来、能被 J-Link attach」的可调试工程。
改造清单有五条,缺一条都调不起来。第一,去掉 C/C++ 页里的 Position Independent Code 选项;第二,去掉 Asm 页里的 Read Only / Read-Write Position Independent 选项;第三,使能 Linker 页的 Use Memory Layout from Target Dialog;第四,在 Manage Run-Time Environment 里使能 Startup 组件;第五,删掉工程里手动加入的System_stm32h7xx.c,改用 Startup 自动带进来的那份。
改造完还得加一个main.c当入口,把算法函数挨个调一遍:
/* 调试专用 main:顺序调用 FLM 各函数,配合断点逐段验证 */ int main(void) { uint8_t pattern[256]; uint32_t i, ret; SystemCoreClockUpdate(); for (i = 0; i < sizeof(pattern); i++) pattern[i] = (uint8_t)i; ret = Init(EXT_FLASH_BASE, 0, 0); /* 1. 初始化 + 解保护 */ if (ret != 0) { while (1); } /* 断点停这里看返回值 */ ret = EraseSector(EXT_FLASH_BASE); /* 2. 擦一个扇区 */ if (ret != 0) { while (1); } ret = ProgramPage(EXT_FLASH_BASE, 256, pattern); /* 3. 写一页 */ if (ret != 0) { while (1); } ret = Verify(EXT_FLASH_BASE, 256, pattern); /* 4. 读回校验 */ if (ret != 0) { while (1); } UnInit(0); while (1); /* 到这里说明全链路通了 */ }这段代码的价值在于把「MDK 调用算法」这个过程还原成了你能单步的普通函数调用。每个if (ret != 0)后面挂一个while(1),跑起来停在哪一个就说明那一步失败,比在下载日志里猜快十倍。断点可以下在hal_qspi_flash_write内部,直接看拆页算出来的chunk对不对;也可以下在SetWPS之后,用调试器的 Memory 窗口看 0x90000000 一块是不是全 0xFF。
这里有个我踩过的坑要提醒:改造前的 FLM 工程 IROM1 指向 RAM 地址,改造后由于要使能 Use Memory Layout from Target Dialog,链接脚本会重新生成,如果器件选型里 RAM 区的划分和你想的不一样,改完可能链接失败显示空间不足。这时候别急着改 scatter,先回 Target 页确认 IRAM1 的起始和大小,FLM 场景下我一般把 IROM1 和 IRAM1 都指向同一块 RAM,让代码和数据挤在一起,简单直接。
我现在的习惯是:任何一块新外部 Flash 上手,先不动应用工程,单独建一个 FLM_DEBUG 工程,用上面这个 main 把 Init → Erase → Program → Verify 跑通一遍,把每一步的耗时用 GPIO 翻转记下来,对着 datasheet 核对是否合理(4KB 擦除一般在几十毫秒量级,256 字节页编程在几百微秒量级),数字对上了再去挂到应用工程里下载。这个流程看着多花半小时,但省掉的是「下载报错 → 怀疑硬件 → 换板子 → 换半天发现是扇区表写漏了」这种一整天的消耗。从那以后我每次给新型号适配 FLM,都强制先走一遍这个 DEBUG 工程,不再直接拿下载器试。希望帮到你。
本文还有配套的精品资源,点击获取