STM32F103外扩8MB内存:用ESP-PSRAM64H实现SPI PSRAM扩容方案
做MCU开发最头疼的事情之一就是明明逻辑不算复杂RAM却先见底了。STM32F103的内存空间本来就紧巴巴RCT6的48KB还算能撑C8T6的20KB简直让人不敢开大数组。一旦牵扯到音频缓冲、GUI显存、传感器历史数据缓存内存焦虑基本是必然的。我这次做的项目需要在F103上同时缓存两路传感器数据流外加一段音频回放缓冲板子空间和引脚已经定死没法换大容量芯片最后还是决定在SPI上做文章用一颗ESP-PSRAM64H给F103外挂8MB RAM。折腾了一周踩了不少坑把过程记录一下给同样内存吃紧的朋友一个参考。先说结论ESP-PSRAM64H确实是给STM32F103这类入门级MCU扩容RAM的高性价比方案3.3V单电源、SOP-8封装、SPI接口接线极其简单。但它毕竟不是SRAM时序、初始化、地址映射都需要自己处理不能像FSMC那样把外设挂到内存地址空间里直接访问。这篇文章不讲虚的直接上手从硬件连接到驱动代码再到性能和常见坑一步步拆开来写。1. 为什么要绕开并口SRAM转头选SPI PSRAM1.1 并口SRAM的坑不是速度不行是引脚和兼容性劝退在决定用PSRAM之前我第一反应还是先评估传统的并口SRAM方案。比如常用的IS62WV51216512K*16bit容量1MB走FSMC接口随机访问延迟在几十纳秒级别性能确实非常能打。但问题也很致命引脚占用太多。数据线16根地址线19根加上读写使能、片选、输出使能轻松吃掉30个以上GPIO。对于F103ZET6这类144引脚芯片还算能接受但PCB布线时一堆等长线会让人想摔鼠标而对于市面上最常见的STM32F103C8T648引脚它压根没有FSMC外设想用并口SRAM直接没戏。我手头的板子就是C8T6的最小系统所以并口SRAM方案第一步就出局了。并口SRAM还有另一个隐蔽的坑——时序配置。FSMC的读时序、写时序、地址建立时间、数据保持时间每一组参数都要对着SRAM的数据手册去算。芯片不一样时序表还不完全一样。一旦遇到访问冲突或者数据线竞争的问题逻辑分析仪都不一定能立刻定位是配置问题还是接线问题。我当时评估了一下如果强行用并口SRAM光调时序和改板子就得耽误好几天果断放弃。1.2 ESP-PSRAM64H一颗被低估的8MB“内存豆”后来把目标转向串行方案最先想到的是SPI接口的SRAM比如Microchip的23LC系列但容量普遍在128KB左右单价还不便宜很难满足项目对“大内存”的诉求。直到我注意到ESP-PSRAM64H这颗芯片容量直接干到64Mbit换算下来是8MB价格比同等容量的并口SRAM便宜一个量级而且国内电子市场非常容易买到——毕竟很多ESP32模块、乐鑫开发板都在用这颗料货源稳定。从规格上看ESP-PSRAM64H的工作电压是3.3V直接兼容STM32F103的IO电平不需要额外的电平转换电路这对低压系统非常友好。它的接口虽然是四线SPI/QSPI但同时兼容标准SPI模式也就是说F103的硬件SPI就能驱动不用非得换F4/H7系列。还有一点很关键它内部自带刷新逻辑本身是DRAM的存储介质但不需要你用软件去周期性刷新完全透明访问。虽然没有Flash那种擦写寿命限制又比普通SPI Flash多了一个“任意地址写字节”的能力用起来更接近RAM。1.3 方案横向对比容量、成本、引脚、速度这里把我评估过的三种方案放在一张表里方便你有个直观感受方案典型芯片容量引脚占用接口复杂度随机读写性能典型单价并口SRAMIS62WV512161MB28~32个GPIOFSMC时序配置繁杂很高ns级中等SPI SRAM23LC51264KB~512KB4~6个GPIOSPI简单中等偏贵SPI PSRAMESP-PSRAM64H8MB5个GPIO含CS/VCC/GNDSPI简单需初始化中等偏上便宜可以看到ESP-PSRAM64H最大的优势是用极少的引脚换来了极大的容量USB转串口模块大小的板子上就能扩展出8MB内存。当然代价是速度——SPI单线模式不可能做到FSMC那种分分钟上百MB/s的吞吐。但在音频数据缓存、Log记录、GUI离屏缓冲这类对实时性不极端敏感的场景里SPI PSRAM完全够用。2. 硬件接线与设计要点2.1 引脚分配一个SPI外设加三个GPIO搞定ESP-PSRAM64H是SOP-8封装引脚少得感人标准SPI模式下只需要六根有效信号线VCC、GND、SCK、MOSI、MISO、CS。它还有一个WP引脚和一个HOLD引脚正常使用直接拉高不需要MCU控制。我手上的板子是STM32F103C8T6硬件SPI接口有两个SPI1在PA5/PA6/PA7SPI2在PB13/PB14/PB15。考虑到我板子上SPI1被一个外部Flash占用了所以选择SPI2来驱动PSRAM。具体接线如下ESP-PSRAM64H引脚功能接到STM32F103VCC电源3.3VGND地GNDSCK时钟PB13MOSI (SI)主出从入PB15MISO (SO)主入从出PB14CS片选PB12WP写保护3.3VHOLD保持3.3V硬件连接就这么多不需要额外的地址线、数据线也不需要电平转换。CS片选我特意选了PB12而不是直接用硬件NSS目的是手动控制片选时序避免SPI外设在多设备场景下出现NSS误触发的问题。这个方法在后面驱动部分会详细说。2.2 供电与去耦PSRAM对电源纹波比想象中敏感很多新手往往忽略了PSRAM的供电设计但实践下来这一点很容易出问题。ESP-PSRAM64H内部是DRAM阵列虽然刷新逻辑在芯片自身但它内部又是负压泵又是灵敏放大器瞬间电流变化比较大。如果VCC脚上的去耦电容没放或者放得离芯片太远高频读写时电压跌落会导致数据位翻转表现就是“明明写对了读出来偶尔多一个bit错误”。我的做法是在PSRAM的VCC引脚附近放一个0.1uF陶瓷电容再在板子5V转3.3V的输出端并联一个10uF的钽电容做低频滤波。如果是用面包板接线这点尤其要注意面包板本身的寄生电感和接触电阻很大PSRAM读写频率一旦超过几MHz波形畸变就会很严重建议用杜邦线时尽量短。2.3 用逻辑分析仪快速验证接线和波形接线完成后不要急着写驱动先做一次硬件通信验证。拿一个逻辑分析仪最低采样率能到16MHz就够把SCK、MOSI、CS三根线接上然后把F103的SPI2初始化成低速模式比如1MHz发几个字节出去。重点看两个东西一是CS片选信号拉低后SCK时钟是否正常输出了。如果看不到SCK大概率是SPI外设没有完成初始化或者GPIO复用配置错了最常见的是引脚被配置成了普通输出而不是复用推挽。二是在SCK每个时钟上升沿MOSI上的数据位是否和代码里拟发送的字节一致。这一步能提前暴露引脚的物理接反、虚焊等低级问题避免后面陷入“数据怎么都不对”的排查泥潭。3. 驱动实现让F103优雅地访问这8MB3.1 SPI初始化CubeMX和寄存器双版本为了照顾用不同开发习惯的读者我把SPI初始化分成了两种方式。如果你用的是STM32CubeMX直接在图形界面上把SPI2启用为全双工主机模式参数按下面设置ModeFull-Duplex MasterBaud Rate Prescaler8分频APB1时钟36MHz即4.5MHz先用保守频率调试Clock Polarity (CPOL)LowClock Phase (CPHA)1Edge也就是SPI Mode 0Data Size8bitFirst BitMSB FirstNSSDisable硬件片选用软件控制这里要特别强调SPI Mode 0还是Mode 3取决于PSRAM的数据手册ESP-PSRAM64H官方手册明确支持Mode 0和Mode 3。我代码里用的是Mode 0也就是空闲时钟为低电平、第一个边沿采样数据。如果你的代码一开始通信不正常先把CPOL和CPHA都反过来试试很多时候不是芯片坏了而是模式匹配问题。标准库或者寄存器版本也不复杂核心代码就是把GPIO配置成复用推挽输出然后打开SPI外设时钟设置SPI_CR1寄存器。下面是一段精简的初始化代码void PSRAM_SPI_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; SPI_InitTypeDef SPI_InitStruct {0}; // 使能SPI2和GPIOB时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_SPI2, ENABLE); // 配置PB13 SCK、PB15 MOSI为复用推挽 GPIO_InitStruct.GPIO_Pin GPIO_Pin_13 | GPIO_Pin_15; GPIO_InitStruct.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStruct.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStruct); // 配置PB14 MISO为浮空输入 GPIO_InitStruct.GPIO_Pin GPIO_Pin_14; GPIO_InitStruct.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOB, GPIO_InitStruct); // 配置PB12 CS为推挽输出初始为高电平 GPIO_InitStruct.GPIO_Pin GPIO_Pin_12; GPIO_InitStruct.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStruct.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStruct); GPIO_SetBits(GPIOB, GPIO_Pin_12); SPI_InitStruct.SPI_Direction SPI_Direction_2Lines_FullDuplex; SPI_InitStruct.SPI_Mode SPI_Mode_Master; SPI_InitStruct.SPI_DataSize SPI_DataSize_8b; SPI_InitStruct.SPI_CPOL SPI_CPOL_Low; SPI_InitStruct.SPI_CPHA SPI_CPHA_1Edge; SPI_InitStruct.SPI_NSS SPI_NSS_Soft; SPI_InitStruct.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_8; SPI_InitStruct.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI2, SPI_InitStruct); SPI_Cmd(SPI2, ENABLE); }3.2 核心读写函数24位地址加上命令字节ESP-PSRAM64H的命令集和常见的SPI Flash有点相似但不需要擦除。在标准SPI模式下读操作的命令是0x03后面跟24位地址地址的最高位一般置0。写操作的命令是0x02也是后面跟24位地址然后直接写入数据。因为它是RAM所以任何地址都可以随时写入不像Flash那样必须先擦除整块扇区。下面这个函数是实现单字节写的核心逻辑void PSRAM_WriteByte(uint32_t addr, uint8_t data) { // 软件拉低片选 GPIO_ResetBits(GPIOB, GPIO_Pin_12); // 发送写命令 0x02 SPI_SendByte(0x02); // 发送24位地址高字节在前 SPI_SendByte((addr 16) 0xFF); SPI_SendByte((addr 8) 0xFF); SPI_SendByte(addr 0xFF); // 发送数据 SPI_SendByte(data); // 拉高片选 GPIO_SetBits(GPIOB, GPIO_Pin_12); }对应的单字节读函数就是发送0x03命令加24位地址后紧接着读取一个字节。在标准SPI模式下主机发送地址字节时从机MISO上其实已经开始返回前导数据了但那些数据是无效的需要把地址发送完后再读。一个很容易犯的错误是在发完地址后主机以为从机会自动开始输出结果读到的第一个字节是0xFF或者乱码。这其实是时序理解问题SPI是全双工的每次接收数据之前必须发送一个时钟所以读数据时需要主机持续发0x00来产生时钟。连续多字节读写就更简单了命令和地址发送完后直接在一个片选周期内连续读/写N个数据字节即可。不过要注意PSRAM没有Flash那样的页大小限制不需要像Flash那样跨页截断地址可以连续递增到8MB边界这一点非常方便。也就是说一次DMA突发传输几KB数据只要别超出8MB总容量完全没有跨页烦恼。3.3 把8MB变成可用的缓冲池窗口与分配器思路直接拿着地址去读写是底层的读写函数但要用好这8MB还得把数据组织抽象出来。因为F103没有MMU也没法把外部PSRAM映射进内部地址空间所以不能简单地用指针直接访问。一个实用的思路是把PSRAM当作一个大的字节数组用索引地址来访问然后在这个基础上实现一个极简的内存分配器。比如我建立一个PSRAM堆区基准地址从0x000000开始内部用一段链表记录每个分配块的大小和空闲状态。分配内存时从链表中遍历找一块大小合适的空闲块释放时把块标记为空闲并做相邻块合并。这个逻辑不复杂适合普通MCU。但如果你不想写分配器也可以针对固定场景做“定长队列缓冲”——比如音频播放缓冲区就固定在PSRAM的某个地址段用两个指针做环形读写既简单又高效。还有一种更偷懒但也很常用的做法是定义结构体映射。把外部PSRAM当成一块“内存映射文件”在代码里定义几个宏来表示不同用途的起始地址#define AUDIO_BUF_ADDR 0x000000 // 音频缓冲 #define SENSOR_LOG_ADDR 0x100000 // 传感器日志 #define GUI_FRAME_ADDR 0x300000 // GUI离屏缓冲这种方式的优点是代码里直观适合数据结构固定的场景。缺点是地址需要自己管理一旦某个缓冲区超过预定范围就会踩到其他数据。我的建议是项目初期用固定地址跑通之后如果数据区经常调整再升级成简单的分配器。3.4 DMA接力的高速传输设计如果只是靠CPU一个字节一个字节地读写SPI PSRAM在低速模式下的性能还算能接受但一旦把SPI时钟拉高CPU逐字节发送会严重拖慢吞吐率。尤其是某些应用需要连续搬运几KB的数据这时候再让CPU在SPI_SR标志位之间死等纯属浪费算力。正确的做法是引入DMA让SPI外设自动完成数据搬运。F103的DMA1通道是挂在APB1总线上的SPI2的接收和发送分别对应DMA1的通道4RX和通道5TX。我这边的设计是需要从PSRAM读出一大段数据时先把读命令和24位地址通过CPU发送给PSRAM然后启动一次DMA接收配置好接收缓冲地址和传输长度同时让DMA在发送方向持续发送一个固定的0x00字节用于产生SCK时钟。等DMA传输完成中断触发后数据就已经安静地躺在RAM缓冲区里了全程CPU参与度很低。DMA配置有几个关键点一个是DMA的外设地址要设成SPI2-DR外设寄存器地址不能写错另一个是DMA的外设地址增量模式必须关闭而内存地址增量模式必须开启否则DMA会一直搬运同一个地址的数据。再一个就是缓冲区地址一定要落在F103的SRAM里如果放在外部Flash或者外设寄存器区域DMA访问不到传输会卡死或者数据全错。4. 性能实测与对比4.1 裸读写速度18MHz时钟下能跑多少驱动跑通之后第一件事就是测速度。我直接用SPI2APB1时钟36MHz最低预分频是2所以SPI2外设时钟可以到18MHz这也是F103上SPI2的物理上限。测试方法是连续读写1KB、4KB、16KB数据块用定时器计时重复多次取平均。测试结果如下数据块大小读速度KB/s写速度KB/s1KB约950约9804KB约1050约106016KB约1100约1180可以看到读写速度在18MHz时钟下稳定在1MB/s左右和理论值差不多。你可能会觉得这速度好慢但实际上它的理论极限是18MHz时钟发送一个地址需要4个字节接收一个字节又是一个时钟周期命令开销和地址开销占了很大比例。如果是连续大块数据平均下来1MB/s到1.4MB/s之间用作音频缓存、传感器历史数据存储绰绰有余。4.2 和并口SRAM的实测差距如果拿并口SRAM的500ns随机访问周期来对标SPI PSRAM确实差了一个数量级不止。并口SRAM的随机读写速度能轻松跑到几十MB/s用FSMC映映射访问时几乎就是2个时钟完成一次读取。但这个差距只对频繁随机小规模访问有意义如果你每次都是指针访问一个变量或者一个字SPI PSRAM的延迟会让你觉得卡顿。反过来在顺序读写的场景下SPI PSRAM虽然不如并口SRAM但配合DMA和批量传输差距并没有想象中大。比如音频流应用每次读512字节SPI PSRAM大约需要几百微秒而并口SRAM可能几十微秒就完成了。但如果用双缓冲预先读取下一段几百微秒的延迟完全可以在后台隐藏掉。所以结论很明确顺序访问多、随机访问少选SPI PSRAM没问题随机访问频繁还是老老实实并口SRAM。4.3 什么时候该用PSRAM什么时候还得并口用了一轮下来我把适用场景总结成两类。适合SPI PSRAM的场景包括需要超大数据缓存但板子走线空间紧张、对成本敏感、使用C8T6这类没有FSMC接口的芯片、应用层可以通过DMA批量读写解决的问题。不适合的场景包括频繁的小块随机读写、对确定性延迟要求极高、需要把PSRAM直接映射成C语言指针来当普通内存用的场合。我这里实际项目就属于典型的第一类音频回放缓冲加上传感器日志全部都是顺序大块读写SPI PSRAM用得很舒服。如果你的项目里到处都是随机指针访问建议还是多花点成本上并口方案。5. 踩坑实录这些问题我替你们试过了5.1 数据乱飞先查这几个地方最常见的问题就是“写进去读出来数据不一致”。排查顺序建议从简单到复杂。第一个要查的是SPI Mode是否匹配这是统计数据错误的最常见原因。写命令时如果用Mode 0读到全是0xFF可以换成Mode 3试试很多时候问题瞬间解决。第二个要查的是CS片选的极性ESP-PSRAM64H是低电平有效很多人误配成高电平有效了结果PSRAM全程没有选中通信自然失败。第三个要查的是MISO和MOSI是否接反特别是使用杜邦线的时候经常因为颜色标记不一致而接反。5.2 DMA传输“半途而废”的排查用DMA搬运PSRAM数据时最头疼的就是传输到一半不动了或者DMA完成中断一直不触发。排插之后发现最常见的原因是DMA的传输长度配置错了F103的DMA传输计数器是16位的最大只能65535你想一次性传100KB是不现实的必须先切片切成小段。另一个隐蔽问题是在DMA传输期间如果软件去改动了外设地址寄存器或者内存地址寄存器的值会直接导致诡异的传输错误。最后一个坑是DMA缓冲区的地址对齐问题如果内存缓冲区的起始地址的字节对齐方式和DMA配置不匹配同样会出问题。5.3 掉电丢数据是特性不是故障很多第一次接触PSRAM的朋友会问我“这个芯片既然是RAM那掉电之后数据会丢失吗”答案很明显会。这一点和所有SRAM/DRAM一样PSRAM是易失性存储器掉电即丢数据。它不是用来替代Flash做持久存储的而是用来缓存程序运行时的临时数据。如果你期望的是“重启之后还能恢复”那是MRAM或者FRAM该干的事价格完全是另一个档次。另外要特别注意ESP-PSRAM64H是DRAM类型的存储介质虽然内部自动刷新不需要软件干预但它对工作电压和温度仍然比较敏感。在工业级温度范围下它虽然能工作但我实测在高温环境中出现位翻转的概率会略高一些介意的话可以在上层加一层简单的CRC校验对瞬时数据错误做重读处理。5.4 已知命令集的兼容问题ESP-PSRAM64H的命令集和另一款常见的QSPI PSRAM IPS6404LSQ基本兼容我在代码里也做过交替验证。但如果你手头买到的芯片丝印不是ESP-PSRAM64H最好再查一下具体型号的手册。部分国产兼容芯片虽然引脚定义一致但初始化时序和命令集存在细微差异尤其是进入Quad模式后的读时序。我的建议是前期调试一律停留在标准SPI模式不要进Quad模式等系统稳定后再考虑用四线模式提升性能。最后再分享一点我个人的体会。这颗芯片的驱动程序其实不难难点在于你对自己的数据访问模式有清晰认知。做嵌入式这么多年我越来越觉得不是所有问题都要用性能最强的方案解决在合适的场景用合适的芯片才是一个工程师真正的功力。弹尽粮绝的RAM预算里抠出8MB空间这感觉真香。