1. 项目概述:为什么要给STM32H723ZGT6做AT25SF128A外部加载器
先把这个项目的本质说清楚。AT25SF128A是一颗128Mbit(16MB)的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 | 关闭写使能锁存 |
| 读状态寄存器1 | 0x05 | 用于轮询忙标志 |
| 读数据 | 0x03 | 标准SPI读取 |
| 快速读数据 | 0x0B | 带Dummy周期的读取 |
| 页编程 | 0x02 | 一次最多编程256字节 |
| 扇区擦除 | 0x20 | 擦除4KB扇区 |
| 块擦除 32KB | 0x52 | 擦除32KB块 |
| 块擦除 64KB | 0xD8 | 擦除64KB块 |
| 芯片擦除 | 0xC7 / 0x60 | 全片擦除 |
| 读JEDEC ID | 0x9F | 读取制造商和设备ID |
JEDEC ID用于上电初始化时验证Flash是否正确连接,AT25SF128A的ID是0x1F 0x89 0x01,其中0x1F是Adesto/Dialog的制造商代码。初始化时读一次ID,能很直观地判断接线和配置有没有问题。
状态寄存器1的bit0是“忙”标志位,擦除或编程期间该位为1。写状态寄存器命令是0x01,读状态寄存器2和3分别是0x35和0x15。四线模式下还需要关注状态寄存器2里的QE位,对AT25SF128A来说,要让四线模式生效通常需要把状态寄存器2的bit1(QE)置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_CLK:PF10
- OCTOSPI1_NCS:PG6
- OCTOSPI1_D0:PD4
- OCTOSPI1_D1:PD5
- OCTOSPI1_D2:PE2
- OCTOSPI1_D3:PD7
如果你只工作在标准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大小:这里填的是地址位数减1,AT25SF128A是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-Link,SWDIO和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函数负责把命令和地址组成一个完整的传输序列发给Flash,HAL_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失败时返回1,STM32CubeProgrammer会立刻报错,这样能在烧录早期就发现问题,而不用等到写入失败才排查。
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的标准读命令是0x03:
int 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 -mcpu=cortex-m7 -mthumb -mfloat-abi=hard -mfpu=fpv5-d16 \ -Os -ffunction-sections -fdata-sections \ -TSTM32H723_AT25SF128A.ld \ -Wl,--gc-sections -Wl,--entry=Init \ -o AT25SF128A.stldr \ Core/Src/main.c Core/Src/stm32h7xx_hal_msp.c \ Drivers/STM32H7xx_HAL_Driver/Src/stm32h7xx_hal_ospi.c \ ...(省略其他源文件)这里-Wl,--entry=Init明确指定入口符号是Init,-Wl,--gc-sections配合链接脚本中的KEEP指令,