ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

STM32H723自制AT25SF128A External Loader完整实战

STM32H723自制AT25SF128A External Loader完整实战 STM32H723ZGT6 自制 AT25SF128A External Loader 完整实战1. 项目背景与方案选型1.1 为什么要自己做 External Loader先交代一下项目背景。最近在一个基于 STM32H723ZGT6 的项目里需要把 GUI 资源、音频素材、字库这些大块数据放到外部 SPI Flash 里选型定的是 Adesto现 Dialog 旗下的 AT25SF128A128Mbit 也就是 16MB 容量。这颗芯片在工控和消费电子里用得非常多性价比高、擦写寿命也够用关键是 STM32H7 系列本身 SPI 外设足够强接一颗 SPI Flash 跑资源存储方案很成熟。但这里有一个绕不过去的坎STM32CubeProgrammer 默认并不认识 AT25SF128A。默认支持列表里主要是 ST 自家型号和几个常见的 Numonyx/Micron 系列直接连 ST-Link 烧外部 Flash 会提示找不到对应器件。这就涉及我们今天要解决的问题——写一个属于 AT25SF128A 的 External Loader。External Loader 本质上是一个遵循 ST 规定接口的静态库或者独立程序由 STM32CubeProgrammer 在烧录时动态加载到 RAM 里执行。它负责把编程器发过来的数据翻译成目标 Flash 能识别的 SPI 命令序列。你可以把它理解为“驱动”——就像操作系统需要驱动才能识别新硬件一样STM32CubeProgrammer 需要 External Loader 才能操作不认识的 Flash。我实测下来的结论是自己动手写一个 External Loader 并不复杂核心工作就是把 ST 官方模板里几个固定函数填充完整再针对目标芯片数据手册把 SPI 时序和命令码对齐。整趟走下来大概一个下午就能跑通但里面的坑确实不少这篇文章我把完整流程和踩过的坑都记录下来。1.2 方案选型基于官方模板还是从零开始动手之前先想清楚底层方案。目前做 External Loader 有两条路第一个思路是用 STM32CubeProgrammer 安装目录下的 Template.eww 工程模板改这是 ST 官方给开发者预留的扩展接口位于STM32CubeProgrammer/bin/ExternalLoader目录下。这个模板已经把所有固定接口、内存映射、链接脚本都配好了我们只需要在Dev_Interface.c里填充几个函数即可。第二个思路是从零用 Makefile 或者任意 IDE 新建一个静态库工程自己定义导出函数符号。这条路适合对链接过程和加载机制非常熟悉的开发者可以灵活控制段布局但没必要——官方模板已经经过大量验证直接用能少踩很多链接层面的坑。我最终选择的是官方模板路线然后针对 AT25SF128A 的特点重写了读写擦除函数。这样做的好处是函数签名、内存映射、导出符号都不用操心CubeProgrammer 加载时对符号查找是硬性要求任何一处不匹配都会导致 loader 加载失败。2. External Loader 运行机制与接口剖析2.1 CubeProgrammer 如何加载和调用 External Loader要写好 External Loader首先得搞清楚 STM32CubeProgrammer 到底是怎么使用它的。这部分内容官方文档《UM2237》写得很清楚但实际操作时很多人还是容易踩坑我在这里用大白话捋一遍。整个流程是这样的CubeProgrammer 检测到用户勾选了 External Flash 烧录选项并指定了.stldr文件路径。它会把.stldr文件加载进来解析里面的段信息把代码段和数据段准备到目标 MCU 的 RAM 地址。通过调试接口ST-Link/J-Link把 loader 程序下载到 RAM 指定地址同时把 PC 指针跳转到 loader 的入口点。CubeProgrammer 通过约定好的函数指针调用 loader 暴露出来的Init、Write、Read、Erase等函数。每个函数执行完毕后loader 会返回一个状态值CubeProgrammer 根据这个状态值决定继续还是报错。这里最关键的一点是External Loader 是运行在目标板上的不是运行在 PC 上的。它跑在 STM32H723 的 RAM 里用的是目标芯片的 SPI 外设去操作 Flash。所以 loader 里所有外设初始化代码都必须围绕目标芯片来写。另外还要注意.stldr文件的格式。它本质上是一个 ELF 文件只是扩展名不同。CubeProgrammer 在加载时会通过段表找到.text、.data、.bss等段然后分别搬运到对应的 LMA加载地址。所以链接脚本里的 RAM 地址必须和 STM32H723 实际 RAM 地址范围匹配否则一运行就 HardFault。2.2 八个关键接口函数逐一拆解官方 External Loader 模板定义了一组固定接口我整理成表格如下函数名功能关键返回值Init初始化 SPI 外设和 Flash 芯片1 成功0 失败DeInit释放 SPI 外设资源1 成功0 失败Write写入数据到指定地址1 成功0 失败Read从指定地址读取数据1 成功0 失败Erase擦除指定扇区或整片1 成功0 失败Check校验 Flash ID 或空片状态1 成功0 失败GetCRC计算指定区域的 CRC32返回 CRC 值GetSize返回 Flash 总容量返回字节数每个函数都有固定的函数签名例如int32_t Init(void)、int32_t Write(uint32_t Address, uint32_t Size, uint8_t* buffer)。注意Write函数的参数是uint32_t Size而不是uint32_t* Size有些资料里写的是指针形式但模板里其实是传值形式。这个细节决定了你写Write函数时怎么判断入参。接口函数里最核心的是Init、Erase和Write。Init做两件事一是配置 STM32H723 的 SPI 外设时钟和引脚二是向 Flash 发送读 ID 命令确认通信正常。Erase要根据地址判断扇区边界AT25SF128A 的 4KB 扇区擦除命令是20h64KB 块擦除命令是D8h整片擦除是C7h。Write则要处理页边界问题AT25SF128A 页大小是 256 字节跨页写入时必须在页边界处切割。2.3 内存映射与链接脚本的适配问题官方模板工程默认的 RAM 地址映射可能不是针对 H723 的直接编译会出问题。我用的 STM32H723ZGT6 有 564KB SRAM其中 AXI SRAM 是 128KB位于0x24000000还有 SRAM1/2/3 各 128KB位于0x30000000和0x30020000、0x30040000。External Loader 运行期间占用的是哪一段 RAM完全看链接脚本怎么配。我实际使用的是0x24000000起始的 AXI SRAM大小分配 32KB 给 loader 本身。注意不要分配太多因为 CubeProgrammer 还要在 RAM 里放数据缓冲区和栈。链接脚本里除了.text和.data还要给栈预留至少 1KB否则函数调用层级稍微深一点就栈溢出。这里有个非常容易踩的坑由于 H723 的 AXI SRAM 支持字节访问和写分配但 CubeProgrammer 下载 loader 时如果遇到 cache 一致性问题可能导致代码运行异常。稳妥的做法是在Init函数里把 SCB_DisableDCache() 调用加上或者链接时把代码段放到 SRAM1 而不是 AXI SRAM。我在实测中发现在 AXI SRAM 里跑 loader 偶尔出现随机失败禁用 DCache 之后问题消失。3. AT25SF128A 芯片要点与 SPI 配置策略3.1 AT25SF128A 关键特性AT25SF128A 是 Adesto 推出的一颗 128Mbit SPI NOR Flash支持标准 SPI、Dual SPI 和 Quad SPI 三种模式。我在这个项目里使用的是标准 SPI 模式原因后面说明。这颗芯片的关键参数如下参数数值说明容量128Mbit / 16MB地址需要 24 位页大小256 字节Page Program 一次最多 256B扇区大小4KBSector Erase 命令 0x20块大小64KBBlock Erase 命令 0xD8最大时钟104MHz标准 SPI 模式下擦写次数100K 次每扇区数据保持20 年典型值AT25SF128A 的指令集不复杂但有几个容易搞混的点我提醒一下。它支持 JEDEC ID 读取命令9Fh返回 3 字节Manufacturer ID1FhDevice ID 分两部分。AT25SF128A 的 Device ID 是8901h也就是完整 JEDEC ID 是1F 89 01。当初我为了确认这个 ID 花了点时间因为 Adesto 的 datasheet 里 Device ID 写法跟 Micron 不同Micron 是 2 字节Adesto 这颗也是 2 字节但顺序是89 01别读反了。状态寄存器方面AT25SF128A 的05h命令返回状态寄存器的 bit0 是 BUSY 位bit1 是 WELWrite Enable Latch位。写入和擦除之前必须发06h写使能命令否则芯片直接忽略写操作。这是 SPI NOR Flash 的通用安全机制但新手容易漏。3.2 STM32H723 的 SPI 外设配置细节STM32H723ZGT6 有多达 6 个 SPI 外设其中 SPI1/2/3 是通用 SPISPI4/5/6 在部分封装上可能是 FDCAN 复用的。我选的是 SPI1因为它可以跑到较高频率且引脚分配灵活。引脚分配我用了以下方案信号引脚复用功能SPI1_SCKPA5AF5SPI1_MISOPA6AF5SPI1_MOSIPA7AF5SPI1_NSSPA4软件管理GPIO 输出NSS 这里我强烈建议用软件管理也就是把 SPI 配成 Master 模式但禁用硬件 NSS用普通 GPIO 控制 Flash 的 CS 引脚。原因有两个第一AT25SF128A 的 CS 引脚是低电平有效硬件 NSS 在某些时序下会自动拉高导致命令被截断第二软件控制 CS 可以在每次传输前主动拉低、传输结束后拉高确保 Flash 识别命令边界。SPI 外设的具体配置参数如下hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_MASTER; hspi1.Init.Direction SPI_DIRECTION_2LINES; hspi1.Init.DataSize SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity SPI_POLARITY_LOW; hspi1.Init.CLKPhase SPI_PHASE_1EDGE; hspi1.Init.NSS SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_8; hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; hspi1.Init.TIMode SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial 7;时钟极性 CPOL0、相位 CPHA0 对应 SPI Mode 0这是绝大多数 SPI NOR Flash 的默认模式AT25SF128A 也不例外。分频系数选 8是因为 H723 的 SPI1 挂载在 APB2 上APB2 时钟典型配置为 100MHz 或 120MHz除以 8 之后 SPI 时钟在 12.5MHz~15MHz 之间远低于这颗 Flash 的 104MHz 上限。我实测在这个速率下传输非常稳定不追求极限速度稳定优先。3.3 为什么我不用 Quad SPI 模式如果你看过 AT25SF128A 的数据手册会发现它支持 Quad SPI理论上读写速度可以翻倍甚至更多。但我在这个项目里没有采用 Quad SPI 模式主要原因是 External Loader 的定位问题。STM32CubeProgrammer 烧录外部 Flash 的场景里瓶颈通常不是 Flash 传输本身而是调试接口ST-Link 的 SWD 速率约 4MHz~10MHz和 USB 传输。即便用了 Quad SPI整体烧录时间也快不了多少反而要额外配置 IO 复用、四线命令序列、以及处理 Dummy Cycle 等时序细节代码复杂度显著上升。另外一个现实考量是项目量产阶段可能通过 UART 或者自定义 bootloader 更新外部 Flash 资源那时候的固件代码完全可以绕过 External Loader 自己初始化 Quad SPI灵活性更高。External Loader 只是开发阶段的烧录工具够用就好不必为了炫技把复杂度堆上去。如果你确实需要极致烧录速度建议后面再迭代一版 Quad SPI 的 loader现在先用标准 SPI 把流程跑通。4. External Loader 核心代码实现4.1 工程搭建与模板修改我使用的是 STM32CubeProgrammer 安装目录下的Template.eww但为了省事也可以从 STM32CubeG4 或者 STM32CubeH7 的固件包里面找现成的 External Loader 示例。这里我直接说我在 H723 上的做法。第一步复制模板目录到本地工作区重命名为AT25SF128A_Loader。第二步打开工程修改链接脚本。以 IAR 工程为例.icf文件里定义了 RAM 起始地址和大小我把0x24000000作为起始地址长度改为0x800032KB。关键片段如下define symbol __ICFEDIT_intvec_start__ 0x24000000; define symbol __ICFEDIT_region_RAM_start__ 0x24000000; define symbol __ICFEDIT_region_RAM_end__ 0x24008000;在模板的Dev_Interface.c里需要填充以下几个函数。我先把框架写出来int32_t Init(void) { // 1. 配置 SPI1 引脚和时钟 // 2. 读取 JEDEC ID 验证通信 // 3. 确保 Flash 退出未知状态 } int32_t Write(uint32_t Address, uint32_t Size, uint8_t* buffer) { // 按页写入处理页边界 } int32_t Read(uint32_t Address, uint32_t Size, uint8_t* buffer) { // 发送 0x03 读命令连续读取 } int32_t Erase(uint32_t Address, uint32_t Size) { // 根据 Size 选择扇区擦除或整片擦除 }这里有个细节Read函数的入参Size在官方模板里是uint32_t Size不是指针。而Write函数的Size也是传值。这意味着 CubeProgrammer 期望 loader 一次性处理完整个传输请求不会分段多次调用。所以你的Write函数内部必须自己处理页边界拆分不能指望 CubeProgrammer 帮你拆。4.2 SPI 底层收发函数封装为了让代码更干净我封装了三个基础的 SPI 操作函数SPI_WriteReadByte、SPI_WriteCommand、SPI_Flash_WaitBusy。代码逻辑如下static uint8_t SPI_WriteReadByte(uint8_t data) { uint8_t rxdata 0; HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); // CS 拉低 if (HAL_SPI_TransmitReceive(hspi1, data, rxdata, 1, 100) ! HAL_OK) { // 错误处理 } HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); // CS 拉高 return rxdata; }注意我这里每次传输一个字节都拉低再拉高 CS对于单字节命令这是完全正确的。但对于连续读取和页编程这种需要维持 CS 低电平的多字节传输就不能用这个函数了。所以在Read和Write函数里我会单独写 CS 控制的逻辑命令阶段保持 CS 拉低直到整个传输过程结束。SPI_Flash_WaitBusy实现如下static void SPI_Flash_WaitBusy(void) { uint8_t status 0x00; uint8_t cmd 0x05; // Read Status Register HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, 100); do { HAL_SPI_Receive(hspi1, status, 1, 100); } while (status 0x01); // BUSY 位为 1 表示忙碌 HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); }这个函数在每次写入和擦除之后调用确保上一次操作完成后再发起新的操作。AT25SF128A 的页编程典型时间是 0.4ms 左右扇区擦除典型时间是 40ms块擦除典型时间是 150ms整片擦除最久需要约 50 秒。在Erase函数里如果做整片擦除超时时间要设得足够大。4.3 Init 与 ID 校验的完整代码Init函数是整个 loader 的敲门砖。CubeProgrammer 加载 loader 后第一个调用的就是它如果返回 0后面的操作全部不会执行。我的实现如下int32_t Init(void) { uint8_t jedec_id[3] {0}; // 1. 初始化 SPI 引脚和时钟 MX_GPIO_Init(); MX_SPI1_Init(); // 关闭 D-Cache避免 AXI SRAM 一致性导致的问题 SCB_DisableDCache(); // 2. 读取 JEDEC ID uint8_t cmd 0x9F; HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, 100); HAL_SPI_Receive(hspi1, jedec_id, 3, 100); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); // 3. 校验 ID: AT25SF128A 1F 89 01 if ((jedec_id[0] ! 0x1F) || (jedec_id[1] ! 0x89) || (jedec_id[2] ! 0x01)) { return 0; // ID 不匹配初始化失败 } return 1; }这段代码在 ST 的模板中原本是直接返回 1 的我们把 ID 校验加进去。好处是 loader 加载后能立刻确认 SPI 通信是否正常如果接线错误或者芯片型号不对CubeProgrammer 会直接报 init 失败方便排查问题。4.4 页编程与跨页边界处理Write函数是 External Loader 里最容易出 bug 的地方核心问题就是页边界。AT25SF128A 的页是 256 字节页编程命令02h一次最多写 256 字节地址低 8 位为 0 时是一个页的起始位置。如果写入地址跨过了页边界Flash 会回卷到该页开头覆盖已有数据这绝对不是我们想要的行为。所以Write函数内部必须对每次写入的长度做限制。我把处理逻辑写成了这样int32_t Write(uint32_t Address, uint32_t Size, uint8_t* buffer) { uint32_t page_size 256; uint32_t page_offset Address 0xFF; // 当前地址在页内的偏移 uint32_t chunk_size; while (Size 0) { // 计算本次最多能写多少字节而不跨页 chunk_size page_size - page_offset; if (chunk_size Size) { chunk_size Size; } // 发送写使能命令 SPI_Flash_WriteEnable(); // 发送页编程命令和地址 HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); uint8_t cmd 0x02; HAL_SPI_Transmit(hspi1, cmd, 1, 100); uint8_t addr_buf[3] { (uint8_t)((Address 16) 0xFF), (uint8_t)((Address 8) 0xFF), (uint8_t)(Address 0xFF) }; HAL_SPI_Transmit(hspi1, addr_buf, 3, 100); HAL_SPI_Transmit(hspi1, buffer, chunk_size, 100); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); // 等待编程完成 SPI_Flash_WaitBusy(); // 更新地址、缓冲区和剩余长度 Address chunk_size; buffer chunk_size; Size - chunk_size; page_offset 0; // 后续都在页起始位置 } return 1; }这里的思路很直白先算出当前地址在页内的偏移量然后用“页大小减偏移量”得到本次可以连续写入的最大字节数写完一个块后再更新地址和剩余长度。页偏移只在第一次计算时用之后每次都是从页头开始写所以page_offset在循环末尾置 0。还有一个容易被忽略的细节Write函数传入的buffer指针可能会因为字节对齐问题导致 HAL 库的字写入异常。STM32H7 的 SPI 外设在用 DMA 或者 FIFO 时对地址对齐有要求保险起见我在 Enter 函数把 buffer 复制到一个内部对齐缓冲区再操作。不过实测下来 CubeProgrammer 传给 loader 的 buffer 通常是对齐的这个隐患留意一下即可。4.5 擦除策略与地址计算Erase函数的策略比较灵活。CubeProgrammer 会传一个地址和大小进来loader 需要根据这两个参数决定怎么擦。我的做法是如果Size大于等于整个 Flash 容量且Address为 0就执行整片擦除。否则按 4KB 扇区循环擦除每次擦一个扇区地址递增 4096。整片擦除命令是C7h扇区擦除命令是20h。代码实现int32_t Erase(uint32_t Address, uint32_t Size) { uint32_t flash_size 16 * 1024 * 1024; // 16MB // 整片擦除 if ((Address 0) (Size flash_size)) { SPI_Flash_WriteEnable(); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); uint8_t cmd 0xC7; HAL_SPI_Transmit(hspi1, cmd, 1, 100); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); SPI_Flash_WaitBusy(); return 1; } // 扇区擦除按 4KB 对齐 uint32_t end_addr Address Size; uint32_t sector_addr Address ~0xFFF; // 对齐到扇区起始 while (sector_addr end_addr) { SPI_Flash_WriteEnable(); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); uint8_t cmd 0x20; HAL_SPI_Transmit(hspi1, cmd, 1, 100); uint8_t addr_buf[3] { (uint8_t)((sector_addr 16) 0xFF), (uint8_t)((sector_addr 8) 0xFF), (uint8_t)(sector_addr 0xFF) }; HAL_SPI_Transmit(hspi1, addr_buf, 3, 100); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); SPI_Flash_WaitBusy(); sector_addr 4096; } return 1; }注意扇区擦除命令的地址是 24 位但 AT25SF128A 的容量是 16MB刚好 24 位能完整覆盖。如果你用的是更大的 Flash比如 32MB就需要扩展地址为 32 位并且使用 4 字节地址模式那是另一个话题了。4.6 Read 与 Check 函数Read函数相对简单发送03h读命令和 24 位地址后连续读取即可int32_t Read(uint32_t Address, uint32_t Size, uint8_t* buffer) { HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); uint8_t cmd 0x03; HAL_SPI_Transmit(hspi1, cmd, 1, 100); uint8_t addr_buf[3] { (uint8_t)((Address 16) 0xFF), (uint8_t)((Address 8) 0xFF), (uint8_t)(Address 0xFF) }; HAL_SPI_Transmit(hspi1, addr_buf, 3, 100); HAL_SPI_Receive(hspi1, buffer, Size, 1000); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); return 1; }这里有个细节HAL 的HAL_SPI_Receive在接收模式下是纯接收不发送时钟外的数据。对于 SPI 从设备来说SCK 时钟由主机产生所以主机调用HAL_SPI_Receive时硬件会自动产生时钟并接收数据。实测下来只要缓冲区长度不超过 Flash 的地址空间就没问题。Check函数用于查询特定地址是否为空0xFF模板里默认返回 1 表示通过。我在实现里加入了读 ID 的校验逻辑本质上和Init前半段一样不再重复粘贴。5. 编译、配置与烧录验证5.1 IAR 工程编译与输出的 .stldr 文件我用的是 IAR Embedded Workbench for ARM 8.50 以上版本STM32CubeProgrammer 的官方模板支持 IAR 和 Keil 两种工具链。工程编译完成后默认输出的是.out或者.axf文件需要手动改名或者让 IAR 直接输出.stldr文件。在 IAR 工程里设置输出文件名的方法是在 Project - Options - Linker - Output 里的 Output file 填写AT25SF128A.stldr。注意这里的扩展名必须是.stldrCubeProgrammer 识别 loader 文件靠的就是扩展名。链接脚本中还需要注意导出符号。CubeProgrammer 加载 .stldr 文件时是通过符号表查找Init、Write、Erase等函数符号的。如果编译时开启了函数内联或者链接时优化把部分函数去掉了就会导致符号找不到。我在工程里用__ramfunc关键字把关键接口函数放到了 RAM 段并且在链接器选项里把--no_inline加上确保每个函数都有独立符号。5.2 在 STM32CubeProgrammer 中配置外部 Flash 烧录编译好 loader 之后打开 STM32CubeProgrammer操作步骤如下连接 ST-Link 到 STM32H723 板子确认能正常读到芯片 ID。在左侧菜单选择 External Flash 烧录界面。点击“Load”按钮选择编译生成的AT25SF128A.stldr文件。设置起始地址。因为我这里把外部 Flash 映射在地址0x90000000上这是 STM32H7 系列通过 FMC 或 QSPI 映射外部存储器的常见基地址。如果你的应用不是用 memory-mapped 方式而是通过 SPI 命令直接读写那这个地址可以自定义只要 CubeProgrammer 的填写地址和实际烧录地址一致即可。选择要烧录的二进制文件比如 GUI 资源镜像点击“Download”。如果一切顺利CubeProgrammer 会先下载 loader 到 RAM然后执行Init再根据你选择的擦除方式执行Erase最后逐块执行Write。烧录完成后建议点击“Verify”或者“Read”回读一遍数据确认写入内容正确。5.3 实测烧录结果与速率分析我的测试环境是 STM32H723ZGT6 开发板SPI1 时钟配置为 12.5MHzST-Link/V2 通过 SWD 连接CubeProgrammer 版本 2.14.0。实测烧录一个 4MB 的资源镜像耗时约 30 秒其中大部分时间花在擦除和写入上。如果换成 Quad SPI 模式理论可以缩短到 10 秒以内但前面说过开发阶段这个速率完全够用。还要确认一点烧录完成后 STM32H723 主程序通过 SPI 读外部 Flash 时读出的数据是否和写入一致。我专门写了一个读回校验的小程序把0x90000000地址空间的前 64KB 数据读出来和源文件做了比对完全一致。这说明 External Loader 的写入和擦除没有逻辑错误。6. 常见问题与排查技巧实录6.1 Loader 加载失败或者 Loader 初始化失败这是最常遇到的问题。现象是 CubeProgrammer 报Error: Loader failed to initialize或者Cannot access target。排查思路按顺序来第一确认.stldr文件的符号表是否正确。用文本编辑器打开 .stldr 文件虽然它是 ELF 格式但可以用readelf或者 IAR 自带的ielftool查看检查Init、Write、Read、Erase符号是否存在。第二确认链接脚本里的 RAM 地址是否在目标芯片的 RAM 范围内。STM32H723 的 RAM 分布前面说过了如果链接脚本还在用默认的 STM32F4 地址一运行就会 HardFault。第三确认 SPI 引脚配置是否正确。有个坑是 Cubemx 生成的MX_SPI1_Init函数里的引脚复用和你在板子上实际连接不一致比如 SCK 和 MOSI 接反导致 Flash 完全无响应。我建议先用一个最简单的裸机程序测试 SPI 通信确定能读到 JEDEC ID 再回来调 loader。6.2 烧录时报错Data size does not match这个报错通常是你设置的烧录地址和文件大小超过了 Flash 容量范围或者地址没有按扇区对齐。AT25SF128A 容量 16MB如果你把地址填到0x90010000并且文件超过 16MB自然就报错了。还有一个常见原因是擦除地址未对齐我的Erase函数里做了Address ~0xFFF的操作就是为了避免传入非 4KB 对齐的地址导致扇区擦除错乱。6.3 Flash 写入后读取全 0xFF 或者全 0x00出现这种问题绝大多数是Write Enable没发或者时序不对。SPI NOR Flash 在没有06h写使能命令之前页编程和擦除命令都会被忽略。我在Write函数的循环里每一页都调用了一次SPI_Flash_WriteEnable()保证每条页编程命令前 WEL 位都是置位的。另外还要检查 CS 引脚的时序。SPI Flash 要求命令、地址、数据阶段 CS 必须持续拉低任何中间拉高都会导致命令被终止。我之前在调试时把HAL_SPI_Transmit封装成了每次传完自动拉高 CS 的函数结果在写命令地址时 CS 就提前拉高了Flash 根本不认。所以多字节操作的 CS 控制必须手动管理。6.4 烧录中途卡死或者超时如果写入大文件时中途卡死优先怀疑WaitBusy超时设置不够。AT25SF128A 的整片擦除时间长达 50 秒如果你在Erase整片时用了 5 秒的超时必然失败。另外 STM32H723 的 SPI 在连续大数据传输时如果使用了 FIFO 中断或者 DMA需要确保中断优先级配置正确。我在这个项目里直接用的阻塞式 HAL 调用简单可靠缺点是会占用 CPU但对 External Loader 这种临时工具来说完全没问题。6.5 关于 cache 一致性的补充提醒前面提到过在 AXI SRAM 里跑 loader 要关闭 DCache。这里再补充一句如果你把 loader 放到0x30000000的 SRAM1 区域同样要留意 cache 问题因为 SRAM1 也是可被 cache 的。唯一彻底避开 cache 影响的办法是把代码段放到0x30000000且禁用对应的 cache 区域或者在启动时直接 SCB_DisableDCache()。我的实测经验是直接调SCB_DisableDCache()最省心对 loader 这种短小程序来说性能损失可以忽略。6.6 问题速查表现象可能原因解决方案Loader 加载失败链接脚本 RAM 地址错误检查 .icf 文件确认地址在 H723 RAM 范围内Init 失败JEDEC ID 不匹配检查 SPI 接线确认 ID 为 1F 89 01写入后全 0xFF未发写使能命令在编程前调用 SendWriteEnable跨页数据覆盖未处理页边界按 256 字节页拆分写入擦除超时整片擦除耗时过长增加 WaitBusy 超时到 60 秒烧录速度极慢SPI 分频过大降低预分频系数提高 SPI 时钟偶尔写入失败Cache 一致性问题初始化时禁用 DCache最后再说一个我踩过的比较隐蔽的坑。在 IAR 工程里默认会把未用到的段丢弃如果Check函数没有被Init或者Write引用链接器可能把它优化掉导致 CubeProgrammer 调用Check时找不到入口。解决方法是把Check函数加上__root关键字或者显式在链接脚本里保留这个符号。7. 后续扩展思路与一些个人经验Loader 毕竟只是开发工具链的一环真正让外部 Flash 在项目里跑起来还需要主固件里的 SPI Flash 驱动配合。我个人建议在写完 External Loader 之后立刻顺手写一套和它对应的主固件驱动命令集保持一致这样从烧录到运行无缝衔接不会出现“loader 能烧但固件读不了”的尴尬。按我个人经验把这套流程跑通之后后续换任何 SPI NOR Flash 都只是改命令号和 ID 校验的问题。整个 External Loader 的结构我已经总结成一套模板现在新项目里要用新 Flash我只需要动Init里的 ID 表格和Erase/Write里的命令码半个小时就能产出一个新的 .stldr。还有一个小技巧如果你手头没有逻辑分析仪调试时序问题会很痛苦但其实可以用 STM32H723 的另一个 SPI 外设做自回环测试或者用一个 GPIO 配合延时模拟波形把关键时序点拉出来确认。我在最初调试时就是靠这种土办法确认 CS 拉低时序正确的。如果你也想在 STM32H7 系列上扩展外部 Flash 支持按这篇文章的思路做一套自己的 Loader 绝对值得。照着模板走一遍流程再针对 AT25SF128A 改几个函数半天时间能跑通后面再遇到别的 Flash 就有基础了。整个过程里最核心的经验就是先确认 ID 通信再调试擦写最后再优化速度一步一步来不会出大问题。
RELATED READING

延伸阅读

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