两年前我第一次拿 STM32H743 跑带 LVGL 的完整 GUI 工程时,编译能过,上电却直接进 HardFault,折腾了一整天最后发现是 RAM 不够。H743 官方标称有接近 1MB 的 SRAM,听起来怎么都够用,但真要被 JPEG 硬解、LTDC 显存、LVGL draw buffer、网络协议栈一起瓜分的时候,512KB 的 AXI SRAM 根本撑不住。后来老实按方案在 FMC 上外扩了一片华邦 W9825G6KH-6,32MB SDRAM,CubeMX 配置、HAL 库初始化、内存测试一路走通,问题才彻底解决。这篇文章就是我从"内存告急"到"SDRAM 稳定跑起来"的完整记录,适合准备给 H743 外扩 SDRAM 做 GUI、图像采集或者大缓冲应用的人参考。
1. 先别急着接外设,说清楚H743那900多KB到底去哪了
1.1 内部SRAM的真实布局:TCM、AXI SRAM、SRAM1-4
STM32H743 的 RAM 账面数字很好看,Flash 2MB,RAM 加起来接近 1MB,听起来根本不用外扩。但 H7 的 RAM 和 F1/F4 那种"一块连续 SRAM"完全不是一个概念,它被物理分成了好几块,而且每块的访问路径不一样。
- DTCM/ITCM:各 128KB,挂在 CPU 内核私有总线上,访问延迟最低,但 DMA、DMA2D、LTDC、JPEG 全都访问不到。TCM 只能给 CPU 跑代码、放栈、放临界变量。
- AXI SRAM:512KB,地址 0x24000000,通过 AXI 总线矩阵挂在 CPU 和外设之间,这是最常用的"主存",绝大多数 malloc 和全局大数组都放这里。
- SRAM1/SRAM2/SRAM3/SRAM4:分别为 128KB、128KB、32KB、64KB,地址分散在 0x30000000 和 0x38000000 附近,带宽路径和 AXI SRAM 不一样,可以用来做 DMA 缓冲,但工程上很少有人把主堆栈放那里。
关键问题在于:CPU 能用 1MB,不代表你的程序能把 1MB 全部用完。TCM 对 DMA 不可见,SRAM1-4 又是零散分布的,实际能作为一个连续大堆使用的只有 512KB AXI SRAM。你写一个static uint8_t big_buffer[600 * 1024],链接器根本没地方放。
1.2 哪些"吃内存大户"会瞬间榨干它
外扩 SDRAM 之前,先想清楚你的内存到底被谁吃掉了。我当时的工程有四个大户:
第一个是 JPEG 硬件编解码器。H743 内置硬件 JPEG 模块非常实用,解一张 1920x1080 的 JFIF 图,输出 RGB565 格式需要 1920 * 1080 * 2 = 约 4MB 缓冲,输出 ARGB8888 直接翻倍到 8MB。512KB AXI SRAM 连一张全高清的 RGB565 输出都装不下。
第二个是 LVGL。LVGL 的 draw buffer 越大,渲染性能和帧率越稳。一个 800x480 的 RGB565 屏幕,单缓冲 768KB,如果做双缓冲就是 1.5MB。这还没算 LVGL 内部为控件、字体、图片解码预留的动态内存。
第三个是 LTDC 显存。如果直接用 LTDC 驱动 RGB 屏,帧缓冲一般放在外部 SDRAM 里。一个 1024x600 的 RGB888 屏,一帧就是 1.84MB,双缓冲直接奔 3.7MB 去。
第四个是 DMA2D 的图像处理中转区。用 DMA2D 做图片缩放、颜色格式转换、Alpha 混合时,需要源缓冲、目标缓冲同时驻留内存,操作一张大图很容易一次性吃掉好几 MB。
当你把这些部件同时放进一个工程,内部 RAM 的处境就是:不是"省一省够用",而是"结构性不够用"。所以外扩 SDRAM 不是可选项,是这类运行内存大户应用的刚需。
2. W9825G6KH-6这颗SDRAM的选型与硬件连接
2.1 容量、位宽、引脚特性拆解
W9825G6KH-6 是华邦生产的 256Mbit SDRAM,换算过来是 32MB,数据宽度 16bit。内部由 4 个 bank 组成,每个 bank 有 4096 行、1024 列,行地址 12 位(A0-A11),列地址 10 位(A0-A9),两个 bank 选择引脚 BA0/BA1。这个结构决定了 FMC 配置时 RowBitsNumber 要填 12,ColumnBitsNumber 要填 10,InternalBankNumber 填 4。
后缀 -6 表示时钟周期为 6ns,对应最高约 166MHz。在 H743 的 FMC 上,SDRAM 时钟通常用 HCLK/2,也就是 100MHz-120MHz 左右,完全在它的工作范围内,余量充足。这个型号是非常成熟的通用料,QFP 类的贴片手工焊都没问题,某宝和正规代理都能买到,价格便宜,假货相对少,是 32MB 这个容量档位里性价比很高的选择。
2.2 为什么选SDRAM而不是并口SRAM或者PSRAM
从 F103 时代很多人就外扩 SRAM,比如 IS62WV51216,1MB 并口 SRAM。这种芯片接口简单、时序简单、随机访问快,但容量到 4MB 以上时价格、引脚数量、布线复杂度全部失控。32MB 并口 SRAM 需要 22 根地址线加 16 根数据线加控制线,接近 45 根线,布 4 层板非常痛苦。
SDRAM 的优势在于地址线和数据线是时分复用的,行地址、列地址共用同一组引脚,32MB 容量只占 13 根地址线加 16 根数据线,总共三十多根线就能搞定。代价是访问需要周期性刷新,有行激活、预充电这些状态机操作,随机访问延迟比 SRAM 高。但 H743 的 FMC 控制器把 SDRAM 的状态机全管起来了,CPU 这边看起来就是一块连续内存,所以几乎不需要你关心 SDRAM 内部的刷新时序细节。
对比同价的 PSRAM,比如 APMemory 系的 SPI PSRAM,它走 QuadSPI 接口,虽然引脚少,但带宽和随机访问能力都远不如 FMC 上的并行 SDRAM,做显存容易碰到带宽瓶颈。FMC 外扩 SDRAM 是"容量、带宽、成本、布线难度"这几个维度里最均衡的一条路。
2.3 原理图连接与PCB布线的几个关键点
连接上并不复杂。H743 的 FMC 外设提供了专门的 SDRAM 控制器接口,SDNE0 做片选(对应 SDRAM Bank1 地址 0xC0000000),SDCKE0 做时钟使能,SDCLK 输出时钟,SDNRAS、SDNCAS、SDNWE 分别是行地址选通、列地址选通、写使能。片选、RAS、CAS、WE 这些信号一一对应接到 W9825G6KH 的相应引脚就行。
硬件上容易栽的坑有三个。第一个是去耦电容一定不能省,SDRAM 每个 VDD 和 VDDQ 引脚附近都放 0.1uF 陶瓷电容,电源入口再放 10uF 钽电容或电解电容。SDCLK 是高频信号,电源不干净会导致偶发读写错误,而且这种错误很难定位。第二个是数据线、地址线、控制线尽量做等长处理,4 层板的话把所有 SDRAM 信号走同一层,避免换层过多引入过孔延迟差异。第三个是如果板上还有其他大电流负载,比如电机驱动、加热丝,SDRAM 供电最好单独走一小块电源岛,或者至少用磁珠和数字部分隔开,否则负载切换时电压跌落会让 SDRAM 直接丢数据。
3. CubeMX配置:时序不是"看着填",是算出来的
3.1 时钟树与FMC时钟摆到多少合适
CubeMX 配置的第一步是确定 FMC 的时钟。H743 的 FMC 挂在 AHB3 总线上,FMC 的 SDRAM 时钟 SDCLK 可配置为 HCLK 本身、HCLK/2、HCLK/3 这几个档位。我用的工程是 CPU 跑 480MHz,HCLK 为 240MHz,所以 SDCLK 选 HCLK/2 就是 120MHz。
可能会有朋友想,W9825G6KH-6 能跑 166MHz,为什么不用 HCLK 直接 240MHz 给 SDRAM?第一是 FMC 的 SDCLK 最高档是 HCLK,240MHz 已经超过 SDRAM 上限;第二是 FMC 本身内部有时序余量的限制,SDRAM 数据和命令引脚在这么高的频率下要保持建立保持时间会很紧,120MHz 才是 H743 工程里最常见、最稳的档位,留给布线延迟的余量足够大。
3.2 行列地址位、CAS、突发长度的填法
CubeMX 的 FMC 页签下,SDRAM 配置项很多。以 W9825G6KH-6 为例,我填的参数如下:
- Row address bits:12
- Column address bits:10
- Number of internal banks:4
- Data bus width:16 bit
- CAS latency:3
- SDClock period:HCLK/2
- Read burst:Enable
- Read pipe delay:1
- Write protection:Disable
这里 CAS latency 和模式寄存器里的 CL 必须是同一个值,否则初始化后读写必挂。120MHz 下周期约 8.33ns,CL=3 表示从发出读命令到数据有效需要 3 个时钟周期,也就是约 25ns。W9825G6KH 在 166MHz 下 CL 可以到 3,在 120MHz 下 CL=3 是保证可靠性的稳妥选择。如果追求极限性能可以试 CL=2,但我个人不建议在 GUI 工程里为了这一两个时钟周期去赌时序裕量。
Read burst 一般建议 Enable。这个选项让 FMC 在 CPU 做连续多字访问时,自动把 SDRAM 的突发读能力用起来,对 memcpy、DMA2D 这种大块传输性能提升非常明显。
3.3 关键时序从手册到寄存器的换算过程
CubeMX 的时序参数需要自己从 W9825G6KH-6 数据手册里查,再根据 SDCLK 时钟周期换算成时钟周期数填进去。我当时的计算过程如下,照着这个思路走就不会错。
SDRAM 时钟频率 120MHz,一个周期 8.33ns。手册几个关键参数的标准值:tRCD(行激活到列命令延迟)最小 20ns,tRP(预充电周期)最小 20ns,tRAS(行激活最短时间)最小 42ns,tRC(行周期)最小 66ns,写恢复时间 tWR 约 2 个时钟周期。
把 ns 值除以 8.33ns 再向上取整,得到:
- tRCD:20ns => 需要 2.4 个周期 => 填 3
- tRP:20ns => 填 3
- tRAS:42ns => 填 6(42 / 8.33 = 5.04,向上取整必须到 6)
- tRC:66ns => 填 8(66 / 8.33 = 7.92)
CubeMX 里这些字段填的是时钟周期数,代码生成后直接写进 FMC 的 SDTR 寄存器。我这里用的是一个典型值,你实际画板时务必打开 W9825G6KH-6 的最新数据手册核对,尤其是温度范围和电压范围超出常规条件时,时序会更加收紧。
还要注意 SDRAM 的自动刷新周期设置。SDRAM 要求每行在 64ms 内至少被刷新一次,W9825G6KH 共有 8192 行,所以每次刷新间隔为 64ms / 8192 = 7.8125us。FMC 的 SDRAM 刷新计数寄存器 SDRTR 的计算公式是:
刷新周期计数 =(刷新间隔时间 / SDRAM 时钟周期) - 20
减 20 是给自动刷新命令本身的执行时间 tRFC 留出余量。代入 7.8125us / 8.33ns ≈ 938,再减 20 得到 918。这就是 CubeMX 里 Refresh 字段要填的值。如果填大了,刷新频率过低,SDRAM 会因电荷泄漏丢数据;填小了,刷新频繁占用总线,带宽白白损失。
3.4 引脚复用冲突排查
CubeMX 自动分配 SDRAM 引脚基本不会错,但 H743 封装引脚密集,最容易出问题的是 SDRAM 引脚和调试口、以太网、USB 的复用冲突。我在一次工程里把 SDRAM 数据线 DQ 分配到了 PA15、PB3、PB4 这些引脚上,结果和 SWD 调试口冲突,上电后调试器直接连不上,只能按住复位瞬间连接,非常被动。
配置完引脚后,一定要在 CubeMX 的 Pinout 视图里看引脚颜色。橙色和红色代表有冲突,需要手动指定到其他可复用引脚。使用 SWD 调试时,尽量保持 PA13、PA14、PA15、PB3、PB4 不被 SDRAM 占用,否则调试体验会非常痛苦。如果引脚实在不够用,我的建议是换更大封装,而不是强行在复用里做文章,后面 Debug 的时间成本远超芯片差价。
4. 初始化代码跑通:从HAL库到内存被系统认领
4.1 CubeMX生成的初始化代码与SDRAM序列化流程
CubeMX 生成的代码默认只做了 FMC 寄存器级别的初始化,真正的 SDRAM 上电序列还需要手动调用。HAL 库的HAL_SDRAM_Init()函数内部会调用HAL_SDRAM_MspInit()配置引脚和时钟,然后需要手动按顺序向 SDRAM 发送初始化命令。CubeMX 生成的main()里会有一句SDRAM_InitSequence()或者类似的函数调用,看起来像是这样:
void SDRAM_InitSequence(void) { FMC_SDRAM_CommandTypeDef cmd; // 1. 发送 NOP 命令,等待至少 200us,让 SDRAM 完成上电稳定 cmd.CommandMode = FMC_SDRAM_CMD_NOP; cmd.CommandTarget = FMC_SDRAM_CMD_TARGET_BANK1; cmd.AutoRefreshNumber = 1; cmd.ModeRegisterDefinition = 0; HAL_SDRAM_SendCommand(&hsdram1, &cmd, 0x1000); HAL_Delay(10); // 2. 发送预充电命令,关闭所有行 cmd.CommandMode = FMC_SDRAM_CMD_PRECHARGE_ALL; HAL_SDRAM_SendCommand(&hsdram1, &cmd, 0x1000); // 3. 发送自动刷新命令,ST 例程一般刷 8 次,确保初始刷新完成 cmd.CommandMode = FMC_SDRAM_CMD_AUTOREFRESH_MODE; cmd.AutoRefreshNumber = 8; HAL_SDRAM_SendCommand(&hsdram1, &cmd, 0x1000); // 4. 发送加载模式寄存器命令,设置 CAS=3,突发长度=1 cmd.CommandMode = FMC_SDRAM_CMD_LOAD_MODE; cmd.ModeRegisterDefinition = 0x23; // CL=3,BL=1 HAL_SDRAM_SendCommand(&hsdram1, &cmd, 0x1000); // 5. 设置刷新定时器 HAL_SDRAM_ProgramRefreshRate(&hsdram1, 918); }上电稳定这一步最容易翻车。SDRAM 上电后需要至少 200us 的延时才能开始接收初始化命令,有些人把 NOP 命令发得太后,导致后续预充电命令被 SDRAM 忽略,最终表现为"数据写进去,读出来全错位"。代码里加一个HAL_Delay(10)是保守做法,实际 200us 以上的延时即可。如果你用 RTOS,这个阶段要在调度器启动前完成。
4.2 模式寄存器设置:CL=3和突发长度为什么必须一致
模式寄存器设置是整个初始化里最需要严谨的一步。W9825G6KH 模式寄存器的各位定义包括突发长度、突发类型、CAS 延迟、运行模式等,初始化时通过地址线 A0-A12 送入。
我这里填的 0x23,拆开看是二进制 0b100011,其中 A3 为 0 表示突发类型为顺序突发,BA1=0、BA0=0 是模式寄存器选择,CAS 位 A6-A4 为 0b011 表示 CL=3,A2-A0 为 0b011 表示突发长度为 8?这里有个容易混淆的地方,实际很多 ST 例程在 FMC SDRAM 下填 0x23 时,对应的突发长度是 1 或者 8,取决于 FMC 如何解析 ModeRegisterDefinition。
工程上的关键原则是:模式寄存器里的 CL 必须和 CubeMX 里配置的 CAS Latency 一致,两者不一致时 SDRAM 的实际行为是未知的,轻则随机数据错误,重则完全不可读。突发长度方面,FMC 控制器内部会自己管理突发访问,Mode Register 里设成 1 并不会让连续内存访问变慢,因为 FMC 会拆成多次单字访问,实际性能由 FMC 的 Read Burst 配置决定。我没在这个字段上纠结,使用芯片厂商提供的初始化参考值,前提是确保 CL=3 这一项与前面配置一致。
4.3 链接脚本增加SDRAM区域和变量放置
初始化完成后,0xC0000000 起就是一块连续 32MB 的内存。但链接器默认不知道它的存在,需要用链接脚本显式声明。GCC 工具链的 .ld 文件里,在 MEMORY 段追加一段 SDRAM:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 2048K DTCMRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K RAM (rwx) : ORIGIN = 0x24000000, LENGTH = 512K SDRAM (rwx) : ORIGIN = 0xC0000000, LENGTH = 32M }然后在 SECTIONS 段里增加一个自定义输出段:
.sdram (NOLOAD) : { . = ALIGN(16); *(.sdram) *(.sdram.*) } > SDRAMC 代码里就可以用编译器属性把大数组放到 SDRAM:
__attribute__((section(".sdram"))) uint8_t jpeg_output_buffer[1920 * 1080 * 2]; __attribute__((section(".sdram"))) lv_color_t lv_draw_buffer[800 * 480];如果你用的是 IAR,操作方式类似,在 .icf 文件里定义一个place in SDRAM_region { section .sdram };这样的大段规则,然后照样用__attribute__((section(".sdram")))指定。VSCode 里配 CMake 或 Makefile 工程的朋友,记得把链接脚本路径写对,CubeMX 生成的 .ld 文件如果手动改过,重新生成工程前要先备份。
4.4 MPU与D-Cache:CPU写得快、外设读不到的老大难
H743 的 D-Cache 是性能利器,也是 SDRAM 应用里最大的隐形坑。默认情况下,如果开了 D-Cache,CPU 写数据到 SDRAM 时可能只写进 Cache Line,并没有真正落到 SDRAM 物理内存。如果 LTDC 控制器直接从 SDRAM 读显存,DMA2D 从 SDRAM 搬运数据,JPEG 硬件编码器往 SDRAM 写数据,这些外设不走 CPU 的 Cache 路径,立刻就会遇到"CPU 认为写好了、外设看到的却是旧数据"的问题。
解决方案有两种。第一种是把 SDRAM 区域用 MPU 配置为普通不可缓存区域,也就是 Normal Non-cacheable。这个方案最简单,CPU 每次写 SDRAM 都是直写,性能会损失一些,但对于 120MHz 的 SDRAM 来说,CPU 直写本来也快不到哪里去,省心更重要。
第二种是保留 Cache,但手动做缓存一致性维护:CPU 写 SDRAM 时,如果后续要被外设读,就调用SCB_CleanDCache();外设往 SDRAM 写完,CPU 要去读时,就调用SCB_InvalidateDCache()。以 JPEG 解码为例,正确流程是:JPEG 外设解码完成后,先执行一次SCB_InvalidateDCache()把对应地址的 Cache Line 全部作废,再让 CPU 去读输出缓冲,否则 CPU 可能直接从 Cache 里拿到旧数据。
我个人的工程习惯是:LTDC 显存区配成 Write-Through(写直达)或 Non-cacheable,因为显示刷新对显存写入的实时性要求高,Cache 带来的写延迟反而会干扰帧缓冲更新;JPEG 解码的中间数据缓冲和 LVGL 的 draw buffer 保留 Cache,由代码显式控制 Clean/Invalidate,在性能和正确性之间取平衡。MPU 配置要在 main 函数早期,Cache 使能之前或之后立即完成,保证外设访问 SDRAM 之前区域属性已经生效。
5. 读写测试:别等GUI跑起来才发现问题
5.1 基础读写:全地址32位写读验证
SDRAM 初始化完不能直接跑业务,得先证明 32MB 真的能读能写。最简单的方法是暴力遍历:从起始地址开始,以 32 位宽度写入固定 pattern,再读回对比。C 代码大概是这样:
#define SDRAM_START 0xC0000000u #define SDRAM_SIZE (32u * 1024u * 1024u) int SDRAM_Test_DataBus(void) { volatile uint32_t *p; uint32_t patterns[] = {0xAAAAAAAA, 0x55555555, 0xDEADBEEF, 0x12345678}; uint32_t errors = 0; for (int i = 0; i < sizeof(patterns)/sizeof(patterns[0]); i++) { for (uint32_t addr = 0; addr < SDRAM_SIZE; addr += 4) { p = (volatile uint32_t *)(SDRAM_START + addr); *p = patterns[i]; } for (uint32_t addr = 0; addr < SDRAM_SIZE; addr += 4) { p = (volatile uint32_t *)(SDRAM_START + addr); if (*p != patterns[i]) { errors++; } } } return errors; }注意这里不能用普通 C 编译器的 memcpy 或者连续赋值优化,必须用 volatile 指针直接访问,否则编译器可能把 RAM 里的副本优化掉,测了个寂寞。第一次跑全量测试会花一点时间,32MB 遍历双循环 4 个 pattern,在 120MHz SDRAM 上大概几秒钟,属于正常。
5.2 地址线完整性测试:排除错位和硬件虚焊
基础读写测试能通过不代表地址线没问题。地址高位错位、某个数据引脚虚焊、片选或时钟焊盘连锡,这些硬件问题表现出的症状往往是"某一段地址读出重复数据"或者"特定 pattern 读回全 0 全 1"。
我常用的地址线测试方法是递增位移法:往addr地址写addr值,再读回比对。由于 SDRAM 内部行地址和列地址是复用的,如果 A10、A7 这类特殊引脚虚焊,错误地址会以 2 的幂次体现,方便快速定位。
int SDRAM_Test_AddressLines(void) { volatile uint32_t *p; uint32_t errors = 0; for (uint32_t addr = 0; addr < SDRAM_SIZE; addr += 4) { p = (volatile uint32_t *)(SDRAM_START + addr); *p = addr; } for (uint32_t addr = 0; addr < SDRAM_SIZE; addr += 4) { p = (volatile uint32_t *)(SDRAM_START + addr); if (*p != addr) { errors++; } } return errors; }这个测试最好在高温或低温环境做一遍,SDRAM 的时序和虚焊在临界温度下最容易暴露。我踩过一次坑:常温 25 度下全量测试全过,设备运行到 60 度时偶发花屏,最后查出是 SDCLK 线上一个过孔阻抗不连续导致高速信号反射,高温下时序裕量进一步恶化。这种问题软件上只能优化时序配置去兼容,根治还得靠改板子。
5.3 带宽实测结果参考
内存测试之外,实际带宽决定了 SDRAM 能不能胜任显存这一角色。我自己在 H743 @ 480MHz、FMC SDRAM @ 120MHz 16bit 配置下,调用了精简版的带宽测试工具,用循环连续读写加 DWT 时钟周期计数测出的结果大致如下:
| 操作 | 实测带宽 | 备注 |
|---|---|---|
| memcpy 32KB 读 | 约 170-190 MB/s | 连续地址突发读 |
| memcpy 32KB 写 | 约 140-160 MB/s | 写操作包含内部预充电和写入恢复 |
| 随机 4 字节读写 | 约 20-40 MB/s | 每次访问都要行激活和预充电 |
| AXI SRAM 连续读 | 约 400 MB/s | 作为对照 |
这个数据说明一个问题:SDRAM 适合大块连续传输,不适合频繁随机小访问。做 GUI 的时候,LVGL 刷新用的矩形填充、DMA2D 的块拷贝都能达到接近峰值带宽,运行体验很好;但如果你把 malloc 的对象频繁分配释放分散到 SDRAM 里,可能触发大量随机访问,性能下降明显。所以我的分配策略是:把大缓冲(显存、图像帧、draw buffer、协议栈大包缓存)放 SDRAM,把高频小对象留在 AXI SRAM。
5.4 把SDRAM跑进实际业务:JPEG解码和LVGL的收益
带宽测试通过后,我直接用外扩 SDRAM 跑通了 JPEG 硬解。解码一张 1920x1080 的 JPEG,输出 RGB565 到 SDRAM buffer,从 JPEG 外设中断触发到SCB_InvalidateDCache()完成,整体耗时在几十毫秒量级,对于 GUI 相册应用完全够用。对比之前用内部 SRAM 只能解小图的窘境,32MB 直接让我可以把多张全高清图同时驻留在内存里做缩略图轮播。
LVGL 方面,draw buffer 从 64KB 提升到 800x480 的全分辨率缓冲后,滑块、列表滚动的拖影明显减少。配合 DMA2D 做图层混合,动画帧率能从二十几帧跳到四五十帧。这里注意 LVGL 的lv_init()和 buffer 分配要在 SDRAM 初始化完成之后,最好在main()中明确调用一次 SDRAM 自检函数,失败就点亮错误 LED 并停留在 while(1) 里,别带着隐患继续跑业务。
6. 我踩过的坑,按从常见到冷门排个序
6.1 能写不能读、读出来全0xFF的排查链路
这恐怕是外扩 SDRAM 最常见的故障现象。写入不报错,但读回来的数据全部是 0xFF 或者 0x00。我的排查路径如下,照着做能省很多时间。
先确认 FMC 有没有真的把片选、RAS、CAS、WE 这些控制信号送到 SDRAM。用示波器量 SDNE0 和 SDCKE0 引脚的电压,初始化完成后这些引脚应该保持有效电平,如果没有任何活动,问题多半在 FMC 时钟没有使能或者引脚复用配置错。
再检查初始化序列顺序。SDRAM 对命令顺序非常敏感,NOP 命令发出后必须等 200us 以上,预充电必须在自动刷新之前,自动刷新次数不能太少。很多"能写不能读"的案例是干脆没调用SDRAM_InitSequence(),只执行了HAL_SDRAM_Init()。HAL 库的 Init 函数只配寄存器,不上电序列。
最后检查 CubeMX 配置的列地址位数和行地址位数。如果 SDRAM 的行地址实际是 12 位,你配置成了 11 位,FMC 访问地址时行地址会错位,表现出来就是读写都能成功但数据错乱、或者越界区域读回全 F。用my_mem_read在 0xC0000000 附近和 0xC8000000 附近各读几个值,对比跳变规律就能判断是不是行地址位数不匹配。
6.2 偶发死机与刷新计数的关系
SDRAM 需要在 64ms 内完成 8192 次刷新,平均 7.8125us 一次。如果 SDRTR 的刷新计数设置错误,比如填了 4095 这种极大值,SDRAM 刷新间隔会远超规范,表现为系统运行几分钟到几十分钟后偶发 HardFault 或数据随机错误,非常难定位。
这种偶发故障的排查思路是看故障概率是不是随时间累积。如果设备运行时间越长、死机概率越高,大概率就是刷新配置有问题。HAL_SDRAM_ProgramRefreshRate(&hsdram1, 918)最后一个参数直接影响刷新频率,建议在量产固件里做成宏定义,方便按不同主频的板卡调整。另外,如果 FMC 时钟被重新配置,刷新定时器要跟着重新设置,否则用着旧刷新计数在新时钟下周期会偏移。
6.3 硬件焊接与去耦不足的坑
SDRAM 的 TSOP54 封装引脚间距很窄,手工焊接容易连锡。连锡的典型症状很诡异:数据线 DQ3 和 DQ4 短路时,读回数据在字节内出现镜像错误,比如写入 0x08 读回 0x04。排查这类硬件问题,软件测试只能做嫌疑定位,最终确认还得靠万用表蜂鸣档或显微镜目检。
去耦不足的问题更难察觉。SDRAM 在页操作和大块刷写时电流变化剧烈,如果 VDD 上的 0.1uF 电容离引脚太远,电源纹波会直接导致读写出错。我在一次使用长飞线连接外部 SDRAM 面包板调试时,测出偶发随机数据错误,飞线一捏紧就消失,松开又复现,最终确认是接触不良加电源噪声。所以能用 PCB 解决的事,绝对不要用飞线。
6.4 CubeMX重新生成工程对链接脚本的影响
CubeMX 有个机制:重新生成代码时会覆盖部分用户文件,但不会覆盖位于指定目录下的链接脚本。然而如果你直接改了根目录的.ld文件,下次生成工程很可能被还原成默认状态。我在一次升级 CubeMX 版本后重新生成工程,忘了 SDRAM 区域被还原,程序链接时所有 SDRAM 段全报错,花了大半天排查才发现是链接脚本被静默覆盖。
解决办法是把自定义链接脚本放到 CubeMX 不会覆盖的路径,比如Core/Src/linker/下,或者用 CubeMX 的Linker Settings指定自定义脚本路径。Git 提交前留意.ld文件变更记录,如果发现不需要的改动,立刻回退。这个坑很冷门,但一旦出现就是白天的 Debug 时间白白消耗。
SDRAM 外扩这件事,配置本身就那么几个步骤,硬件上也无非几十根线,真正难的是把每一步背后的时序逻辑吃透。每次新画一块板子,我都会先跑一遍第五节的测试程序再开始写 GUI,确认了内存可靠,上层应用再复杂也只是工程量问题。W9825G6KH 这颗芯片价格不高、驱动成熟、资料丰富,配合 STM32H743 的 FMC 控制器,算是在大内存嵌入式的世界里少踩很多坑的组合。希望这篇记录能帮你的 H743 项目少走我走过的弯路。