STM32H723ZGT6与AT25SF128A:外部加载器开发与SPI Nor Flash烧录实战
1. 项目概述为什么要给STM32H723ZGT6做AT25SF128A外部加载器先把这个项目的本质说清楚。AT25SF128A是一颗128Mbit16MB的SPI NOR Flash来自Dialog原Adesto的AT25SF系列支持标准SPI、双线和四线模式在物联网网关、音视频缓冲、日志存储这类需要大容量外部存储的场景里非常常见。而STM32H723ZGT6是ST的Cortex-M7高性能MCU主频最高550MHz带有两个专用于外部存储器接口的OCTOSPI外设。平时我们开发用的内部Flash只有1MB对于很多产品来说根本不够用把程序或数据放到外部SPI NOR Flash是再正常不过的方案。那么问题来了芯片内部Flash可以通过ST-Link直接烧录但外部SPI Flash没有任何现成的烧录通路。你总不能在生产线上一片一片地先用烧录器把Flash写好再贴片那样效率太低也容易出问题。最优雅的办法是让STM32CubeProgrammer通过SWD接口连接MCU借助一个“外部加载器”间接操作SPI Flash。所谓外部加载器本质是一段被下载到MCU内部RAM里运行的小程序它实现了读、写、擦除等标准接口STM32CubeProgrammer调用这些接口完成对外部Flash的编程和校验。这个项目标题“How make external loader AT25SF128A for STM32H723zgt6”问的就是这件事如何为指定的MCU和指定的Flash芯片编写并集成这样一个外部加载器。本文的目标是给你一条可以直接落地的技术路线包括硬件连接、OCTOSPI配置、加载器代码实现、编译集成、上板验证和常见坑的排查。适合正在用STM32H723做产品开发、需要把代码或数据烧到外部NOR Flash的嵌入式工程师也适合想搞懂STM32CubeProgrammer外部加载器机制的开发者。读完你不仅能给AT25SF128A做出加载器换个其他型号的SPI NOR Flash也能按同样的思路快速做出来。2. 硬件连接与AT25SF128A基础特性2.1 AT25SF128A的关键参数与命令集在写代码之前先把这颗Flash的底细摸清楚。AT25SF128A容量为128Mbit也就是16MB按字节寻址需要24根地址线。它的页大小是256字节扇区大小是4KB块大小有32KB和64KB两种。这些参数会直接影响外部加载器里Write和Erase的实现方式。命令集方面常用指令如下功能命令码说明写使能0x06每次写/擦除之前必须发送写禁用0x04关闭写使能锁存读状态寄存器10x05用于轮询忙标志读数据0x03标准SPI读取快速读数据0x0B带Dummy周期的读取页编程0x02一次最多编程256字节扇区擦除0x20擦除4KB扇区块擦除 32KB0x52擦除32KB块块擦除 64KB0xD8擦除64KB块芯片擦除0xC7 / 0x60全片擦除读JEDEC ID0x9F读取制造商和设备IDJEDEC ID用于上电初始化时验证Flash是否正确连接AT25SF128A的ID是0x1F 0x89 0x01其中0x1F是Adesto/Dialog的制造商代码。初始化时读一次ID能很直观地判断接线和配置有没有问题。状态寄存器1的bit0是“忙”标志位擦除或编程期间该位为1。写状态寄存器命令是0x01读状态寄存器2和3分别是0x35和0x15。四线模式下还需要关注状态寄存器2里的QE位对AT25SF128A来说要让四线模式生效通常需要把状态寄存器2的bit1QE置1。不过外部加载器场景里我建议先用标准SPI模式跑通整个流程等稳定后再考虑四线提速这样排查问题会容易很多。2.2 STM32H723的OCTOSPI外设与引脚分配STM32H723ZGT6带有两个OCTOSPI控制器分别是OCTOSPI1和OCTOSPI2。OCTOSPI这个名字听起来很吓人但你完全可以把它当做一个更高级的SPI控制器来用。它支持1线、2线、4线、8线模式支持SDR和DDR既可以在间接模式下由CPU发命令操作也可以在内存映射模式下让MCU直接把外部Flash当内存读。外部加载器场景下我觉得间接模式足够用了逻辑清晰调试方便内存映射模式反而会让问题变得复杂。引脚分配建议直接交给STM32CubeMX处理。我个人的习惯是先在CubeMX里把OCTOSPI1使能让软件自动分配一组可用的引脚再根据自己板子原理图人工调整。一般来说OCTOSPI1的引脚会分布在PF、PD、PE这几个端口上。以STM32H723ZGT6的LQFP144封装为例一组常见复用是OCTOSPI1_CLKPF10OCTOSPI1_NCSPG6OCTOSPI1_D0PD4OCTOSPI1_D1PD5OCTOSPI1_D2PE2OCTOSPI1_D3PD7如果你只工作在标准SPI模式1-1-1那只需要CLK、NCS、D0相当于MOSI和D1相当于MISO四根线。如果要跑四线模式D2和D3也要接上。AT25SF128A的WP引脚和HOLD引脚就是四线模式下的D2和D3所以硬件设计时即便只用标准SPI也建议把这两个引脚引出到MCU后续升级四线模式不用改板。硬件连接还有一些细节需要注意Flash的VCC要加0.1uF去耦电容最好再加一个4.7uF的钽电容WP和HOLD引脚如果不用于四线模式需要上拉到VCC否则可能引起误操作。SCK线上串联22欧姆电阻可以有效抑制振铃尤其SCK频率超过20MHz之后信号完整性会直接影响通信稳定性。2.3 为什么不用普通SPI而是OCTOSPI这是我经常被问到的问题。STM32H723当然有多个普通SPI外设也能驱动这颗Flash为什么非要折腾OCTOSPI原因有三个方面。第一外部加载器要被STM32CubeProgrammer调用ST官方外部加载器的标准范例基本都基于OCTOSPI或FMC接口普通SPI没有对应的标准方案兼容性差。第二OCTOSPI的间接模式专门为NOR Flash操作优化过支持“命令地址数据”的组合发送还能自动处理状态寄存器轮询比用普通SPI裸操作省心得多。第三OCTOSPI最高支持133MHz的时钟频率四线模式下带宽远高于普通SPI后续如果想做XIP就地执行从外部Flash启动普通SPI根本跑不起来。3. 用STM32CubeMX搭建OCTOSPI基础工程3.1 创建项目与使能OCTOSPI打开STM32CubeMX新建工程在MCU选择器里输入STM32H723ZGT6。进入Pinout Configuration视图后左侧Categories列表里找到Multimedia展开后能看到OCTOSPI1和OCTOSPI2两个外设。选中OCTOSPI1在Mode下拉框里选“Single bank SPI”。这里有个容易混淆的点STM32CubeMX里的“SPI”指的是OCTOSPI的数据线宽度配置Single bank SPI就是标准1位SPI模式Quad SPI是四线模式。对于第一版加载器选Single bank SPI先跑通。配置参数有几个地方需要认真设置时钟分频这个要结合系统时钟计算。OCTOSPI外设时钟来自PLL1Q比如配置到200MHz如果分频系数设为2那么SPI时钟就是100MHz对AT25SF128A来说已经超规格了这颗Flash的标准SPI模式最高支持到133MHz四线模式最高104MHz所以分频后时钟不要超过Flash规格就好。采样点偏移这是OCTOSPI调试中最常见的坑。信号在PCB上走线有延迟采样点偏了就会导致数据错位尤其在高速时钟下。Level 0表示在串行时钟的上升沿采样Level 1表示在下降沿采样具体用哪个要看Flash的数据手册和实际信号质量调试时如果读回数据全是0xFF优先调这个参数。片选高电平时间Flash在命令之间的片选高电平时间有最低要求AT25SF128A要求至少20ns配置成2到4个时钟周期比较稳妥。Flash大小这里填的是地址位数减1AT25SF128A是16MB地址宽度24位所以填23。引脚分配这里CubeMX会自动根据所选模式分配引脚。如果自动分配的引脚和你板子上的实际连接不一致手动拖拽调整即可。调整完之后检查一下电气属性确认引脚模式是Alternate Function速度等级建议选Very High因为在高速SPI时钟下GPIO输出速度不够会直接把波形搞坏。3.2 时钟树配置外部加载器的代码最终运行在MCU内部RAM中时钟系统必须正确初始化。在CubeMX的Clock Configuration页面里我建议把系统主频配到480MHz或550MHz取决于是否启用超频能力然后让PLL1Q输出一个合适的OCTOSPI时钟源。这里分享一个实际经验OCTOSPI的时钟并不是越高越好。外部加载器的主要任务是下载和校验固件SPI时钟跑到30到50MHz已经完全够用再高的频率对信号完整性和代码稳定性都是考验。我个人建议把OCTOSPI时钟配到40MHz左右分频系数留一点余量调试阶段先把功能跑通再说性能。调试器选择这里要注意外部加载器工程本身没有Application Program它只是被STM32CubeProgrammer通过SWD下载到RAM里运行的代码。所以调试器的连接方式仍然是ST-Link或J-LinkSWDIO和SWCLK两根线加上GND和3.3V四根线就能完成所有工作。3.3 代码生成与工程结构CubeMX配置完成后点击Generate Code生成基础工程。工具链选择上我推荐使用STM32CubeIDE或者命令行下的arm-none-eabi-gcc。外部加载器的编译产物不需要遵循标准Application的链接布局而是要链接到RAM区域所以工程生成后还需要手工修改链接脚本。生成后的工程结构大致如下STM32H723_AT25SF128A_Loader/ ├── Core/ │ ├── Inc/ │ └── Src/ │ ├── main.c │ └── ... ├── Drivers/ │ └── STM32H7xx_HAL_Driver/ ├── STM32H723ZGTX_FLASH.ld └── *.iocCubeMX默认的链接脚本会链接到内部Flash和RAM而外部加载器必须完全运行在RAM中所以这个默认链接脚本不能用。原因在于外部加载器本身的目的是把外部Flash烧录好如果加载器代码放在内部Flash里等于先要往内部Flash里烧一遍加载器这在生产流程里完全行不通。外部加载器必须作为纯RAM程序由STM32CubeProgrammer每次临时下载进去执行。4. 外部加载器核心代码实现4.1 加载器接口协议与导出符号外部加载器能不能被STM32CubeProgrammer识别关键在于导出的符号是否符合规范。STM32CubeProgrammer通过SWD连接MCU把加载器二进制下载到RAM中然后通过特定地址调用这些函数。标准的外部加载器接口函数如下int Init(void); int DeInit(void); int Write(uint32_t Address, uint32_t Size, uint8_t* buffer); int Read(uint32_t Address, uint32_t Size, uint8_t* buffer); int Erase(uint32_t SectorAddr, uint32_t SectorSize); int EraseChip(void); int GetInfo(void);返回值的约定很直接返回0表示操作成功返回非0表示失败。STM32CubeProgrammer会在每个操作后检查返回值如果非0就报错终止所以在每个分支里都要确保返回值正确。还有一点需要留意这些函数名是固定的不要加static修饰也不要改名否则符号表里找不到对应的符号加载器就没办法被调用。编译时还需要避免链接器把未引用的函数优化掉如果用了-ffunction-sections和--gc-sections需要在链接脚本里保留这些符号或者用__attribute__((used))修饰函数。GetInfo函数返回一个32位值其中包含了Flash的容量、页大小和擦除大小信息STM32CubeProgrammer拿到这些信息后才知道该怎样分配缓冲区、怎样发送擦除命令。这里我按惯例封装成如下格式uint32_t GetInfo(void) { uint32_t size_kb 16 * 1024; /* 16384 KB */ uint32_t page_size 256; /* 256 字节 */ uint32_t erase_size 4096; /* 4KB 扇区 */ return (size_kb 16) | (page_size 8) | erase_size; }高16位放容量单位KB中间8位放页大小低8位放擦除大小。这个编码方式在ST的加载器范例里基本是通用做法。4.2 链接脚本让加载器在RAM中运行外部加载器能运行在RAM里的前提是链接脚本正确。给STM32H723ZGT6写的链接脚本我习惯把代码放在ITCMRAM数据放在DTCMRAM这样可以利用H7的哈佛结构取指和数据访问互不干扰。MEMORY { ITCMRAM (xrw) : ORIGIN 0x00000000, LENGTH 128K DTCMRAM (xrw) : ORIGIN 0x20000000, LENGTH 128K RAM (xrw) : ORIGIN 0x24000000, LENGTH 512K } ENTRY(Init) SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) KEEP(*(Init)) KEEP(*(Write)) KEEP(*(Read)) KEEP(*(Erase)) KEEP(*(EraseChip)) KEEP(*(GetInfo)) } ITCMRAM .data : { *(.data*) } DTCMRAM .bss : { *(.bss*) } DTCMRAM }这几个KEEP指令非常重要。如果不加arm-none-eabi-gcc在链接时会把没有被显式引用的函数当作垃圾回收掉结果是最终生成的加载器里根本没有Init、Write这些函数STM32CubeProgrammer自然找不到它们。我最初做这个项目时就吃过这个亏折腾了好几个小时。4.3 SPI Flash驱动的底层实现接下来是重头戏AT25SF128A的底层驱动。这里我们通过OCTOSPI的间接模式来操作。间接模式的意思是CPU发起传输数据先经过OCTOSPI的发送FIFO和接收FIFO而不是像内存映射模式那样直接按地址读取。先来看初始化部分。CubeMX已经生成了MX_OCTOSPI1_Init函数我们在此基础上增加验证Flash读ID的逻辑static int AT25SF128A_ReadID(uint8_t *manufacturer, uint8_t *memory_type, uint8_t *capacity) { uint8_t cmd[4] {0x9F, 0x00, 0x00, 0x00}; uint8_t rx[4] {0}; if (HAL_OSPI_Command(hospi1, sCommand, HAL_OSPI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return -1; if (HAL_OSPI_Receive(hospi1, rx, HAL_OSPI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return -1; *manufacturer rx[1]; *memory_type rx[2]; *capacity rx[3]; return 0; }HAL_OSPI_Command函数负责把命令和地址组成一个完整的传输序列发给FlashHAL_OSPI_Receive负责从接收FIFO中读回数据。两者配合完成一次完整的SPI事务。如果读回的ID是0x1F 0x89 0x01说明硬件连接和OCTOSPI配置都正确。写使能和状态轮询是另外两个基础操作static void AT25SF128A_WriteEnable(void) { sCommand.Instruction 0x06; sCommand.AddressSize HAL_OSPI_ADDRESS_8_BITS; sCommand.DataSize HAL_OSPI_DATA_1_BYTE; HAL_OSPI_Command(hospi1, sCommand, HAL_OSPI_TIMEOUT_DEFAULT_VALUE); } static int AT25SF128A_WaitBusy(void) { uint8_t status 0x01; uint32_t timeout 1000000; sCommand.Instruction 0x05; sCommand.DataSize HAL_OSPI_DATA_1_BYTE; while (status 0x01) { if (--timeout 0) return -1; HAL_OSPI_Command(hospi1, sCommand, HAL_OSPI_TIMEOUT_DEFAULT_VALUE); HAL_OSPI_Receive(hospi1, status, HAL_OSPI_TIMEOUT_DEFAULT_VALUE); } return 0; }这里要特别说明一下命令配置的复用问题。sCommand是一个OSPI_RegularCmdTypeDef结构体每次发送命令前需要重新设置指令码、地址长度和数据长度。很多初学者在这个结构体的重复配置上出问题比如上一次设置了地址长度是24位下一次发不带地址的命令忘了改回8位就会导致命令序列错乱。我的建议是把WriteEnable、WaitBusy、ReadData、PageProgram这些操作封装成独立函数每个函数内部完整地配置结构体不要图省事复用上次的配置。4.4 外部加载器API的实现基础驱动搞定后把这些底层操作组装成外部加载器接口。Init函数负责初始化OCTOSPI外设并验证Flash连接int Init(void) { uint8_t manufacturer, type, capacity; MX_GPIO_Init(); MX_OCTOSPI1_Init(); if (AT25SF128A_ReadID(manufacturer, type, capacity) ! 0) return 1; if (manufacturer ! 0x1F) return 1; return 0; }读ID失败时返回1STM32CubeProgrammer会立刻报错这样能在烧录早期就发现问题而不用等到写入失败才排查。Write函数是核心中的核心。AT25SF128A的页编程一次最多写256字节写操作不能跨页边界。一个大于256字节的写入请求必须被拆分成多次页编程int Write(uint32_t Address, uint32_t Size, uint8_t* buffer) { uint32_t offset 0; uint32_t current_size; uint32_t page_remaining; while (offset Size) { /* 计算当前页剩余可写字节数 */ page_remaining AT25SF128A_PAGE_SIZE - (Address % AT25SF128A_PAGE_SIZE); current_size (Size - offset) page_remaining ? (Size - offset) : page_remaining; AT25SF128A_WriteEnable(); if (AT25SF128A_PageProgram(Address offset, buffer offset, current_size) ! 0) return 1; if (AT25SF128A_WaitBusy() ! 0) return 1; offset current_size; } return 0; }这个分页逻辑是Write函数最容易写错的地方。如果你不处理页边界让一次页编程跨了两个页Flash的状态寄存器会报编程错误数据写进去也是错的。我见过好几个项目就是栽在这个细节上。PageProgram的内部实现static int AT25SF128A_PageProgram(uint32_t address, uint8_t* data, uint32_t size) { sCommand.Instruction 0x02; sCommand.Address address; sCommand.AddressSize HAL_OSPI_ADDRESS_24_BITS; sCommand.DummyCycles 0; sCommand.DataSize size; if (HAL_OSPI_Command(hospi1, sCommand, HAL_OSPI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return -1; if (HAL_OSPI_Transmit(hospi1, data, HAL_OSPI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return -1; return 0; }Erase函数需要根据传入的扇区地址和大小选择不同的擦除命令。实际调用时STM32CubeProgrammer会传入一个扇区地址和扇区大小扇区大小由GetInfo返回的擦除大小决定也就是4096字节。所以我们主用扇区擦除命令0x20即可int Erase(uint32_t SectorAddr, uint32_t SectorSize) { (void)SectorSize; AT25SF128A_WriteEnable(); sCommand.Instruction 0x20; /* 扇区擦除 4KB */ sCommand.Address SectorAddr; sCommand.AddressSize HAL_OSPI_ADDRESS_24_BITS; sCommand.DataSize HAL_OSPI_DATA_1_BYTE; if (HAL_OSPI_Command(hospi1, sCommand, HAL_OSPI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return 1; if (AT25SF128A_WaitBusy() ! 0) return 1; return 0; }如果后续想把加载器做得更通用可以根据SectorSize自动判断使用0x20、0x52还是0xD8这样同一份代码可以适配不同擦除粒度的Flash。但对AT25SF128A来说固定用4KB扇区擦除已经够了。Read函数相对简单AT25SF128A的标准读命令是0x03int Read(uint32_t Address, uint32_t Size, uint8_t* buffer) { sCommand.Instruction 0x03; sCommand.Address Address; sCommand.AddressSize HAL_OSPI_ADDRESS_24_BITS; sCommand.DummyCycles 0; sCommand.DataSize Size; if (HAL_OSPI_Command(hospi1, sCommand, HAL_OSPI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return 1; if (HAL_OSPI_Receive(hospi1, buffer, HAL_OSPI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return 1; return 0; }读函数在外部加载器中的主要用途是烧录完成后的校验。STM32CubeProgrammer写入完成后会自动执行校验如果Read实现不正确校验会报错即使数据实际写入是对的也会被判定为失败。EraseChip函数在量产场景下非常有用一键擦除整片Flash比逐个扇区擦除快得多int EraseChip(void) { AT25SF128A_WriteEnable(); sCommand.Instruction 0xC7; sCommand.AddressSize HAL_OSPI_ADDRESS_8_BITS; sCommand.DataSize HAL_OSPI_DATA_1_BYTE; if (HAL_OSPI_Command(hospi1, sCommand, HAL_OSPI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return 1; if (AT25SF128A_WaitBusy() ! 0) return 1; return 0; }5. 编译、集成与上板验证5.1 编译生成.stldr文件外部加载器的编译产物有两种常见格式.elf和.stldr。.stldr本质上就是从.elf转换来的某些版本的STM32CubeProgrammer要求加载器文件必须带.stldr扩展名。STM32CubeIDE直接输出.elf把这个.elf文件重命名成.stldr通常就能直接用。如果用手动编译的方式核心命令大致是arm-none-eabi-gcc -mcpucortex-m7 -mthumb -mfloat-abihard -mfpufpv5-d16 \ -Os -ffunction-sections -fdata-sections \ -TSTM32H723_AT25SF128A.ld \ -Wl,--gc-sections -Wl,--entryInit \ -o AT25SF128A.stldr \ Core/Src/main.c Core/Src/stm32h7xx_hal_msp.c \ Drivers/STM32H7xx_HAL_Driver/Src/stm32h7xx_hal_ospi.c \ ...省略其他源文件这里-Wl,--entryInit明确指定入口符号是Init-Wl,--gc-sections配合链接脚本中的KEEP指令
