ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32H7xx Keil MDK外部QSPI Flash FLM下载算法实战

STM32H7xx Keil MDK外部QSPI Flash FLM下载算法实战

简介:面向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 只认这六个函数名和它们的签名,签名对不上链接会失败,返回值约定错了现象会很怪。

函数签名调用时机返回约定
Initint Init(unsigned long adr, unsigned long clk, unsigned long fnc)算法加载后、任何操作前0 成功,1 失败
UnInitint UnInit(unsigned long fnc)本次下载结束0 成功
EraseChipint EraseChip(void)勾选整片擦除时0 成功,1 失败
EraseSectorint EraseSector(unsigned long adr)按扇区擦除0 成功,1 失败
ProgramPageint ProgramPage(unsigned long adr, unsigned long sz, unsigned char *buf)写一页0 成功,非 0 为失败地址
Verifyunsigned 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 有两个存放位置,任选其一:

  1. 放进器件包目录/Keil/ARM/PACK/Keil/STM32H7xx_DFP/<version>/CMSIS/Flash,好处是和官方算法并列显示,坏处是器件包一升级就被覆盖,得重放。
  2. 放进 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 工程,不再直接拿下载器试。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进