☰
单XSPI双片选驱动两片PSRAM:嵌入式内存扩展的硬件设计与工程实践
2026/10/23 4:11:33 网站建设 项目流程

1. 先算一笔账:为什么非要用一个XSPI实例挂两片PSRAM

1.1 单实例双片选,省下的是真金白银

先说说我遇到的场景:MCU是Cortex-M85内核,要做图形界面加语音识别,内部RAM怎么都不够用,只能把主意打到外部PSRAM上。评估了一圈发现,512Mb的串行PSRAM单颗容量不够装两个屏的帧缓冲加音频环形缓冲,再往上走1Gb的料号和供货又不行,交期直接劝退。折腾一圈,最后发现最划算的路径是:让同一个XSPI实例同时驱动两颗PSRAM,利用XSPI控制器自带的多个片选信号,把两颗芯片挂在同一条总线上。

为什么要纠结"一个XSPI instance"这件事?原因很现实:很多MCU只给了一个XSPI外设实例,或者给了两个但另一个已经被NOR Flash占用了。XSPI外设不像普通串口那么简单,它包含一大套寄存器组、时序控制逻辑、DMA接口和中断向量,每多开一个实例,不只是多一根CS脚的问题,而是整套外设资源都要加倍。尤其在你只剩一个XSPI可用的情况下,想让系统同时拥有"外挂程序存储"和"大容量运行内存",就只能在一个实例下面想办法。

单实例双CS的好处是实实在在的。第一,地址空间连续,两颗PSRAM映射到同一段地址空间的不同区间,CPU访问起来像访问一块大内存,不用在东一块西一块的零散地址里跳来跳去。第二,引脚省得多,两套独立的XSPI接口需要两套CLK、两套DQ、两套CS,而共享总线方案只是多一根CS1,对引脚紧张的小封装MCU来说是救命级的优势。第三,软件侧只有一个驱动实例、一套中断入口、一个DMA通道配置,省掉的代码量和调试时间非常可观。

1.2 什么时候必须放弃这个方案

当然,这个方案不是万能药。我在动手之前把放弃条件也想清楚了:如果应用场景里两颗PSRAM需要同时满速访问,比如一边在做摄像头图像采集,另一边同时在跑AI推理,那么单XSPI控制器本身就成了瓶颈,因为同一时刻控制器只能服务一个片选;这时候哪怕引脚再紧张,也得咬咬牙上双实例。

还有一种情况是第二颗PSRAM需要的CS引脚和MCU上的关键外设冲突。XSPI的CS1脚经常和其它高速外设复用,比如Ethernet、SDHI之类的,一旦冲突,单实例双CS的方案就得推倒重来。我的原则是:先查数据手册里的引脚复用表,确认CS1在当前封装下没有被更重要的功能占用,再开始画板。否则等你把PCB铺完了才发现CS1被网口占了,那种绝望我经历过。

另外,时序上有个容易被忽略的点:同一颗XSPI控制器在不同CS之间切换时序参数,是需要额外时钟周期来重新同步的。如果你的访问模式是频繁在两个CS之间跳来跳去,每一跳都是开销。

2. 硬件连线:同一条XSPI总线上接两颗PSRAM的接线细节

2.1 共享信号和独享信号的完整清单

PSRAM本身是一个没有地址线、靠命令访问的串行器件,所以挂在XSPI总线上时,绝大多数信号是可以共享的。我以常见的8线OPI接口PSRAM为例,把接线画成一张表:

信号方向连接方式
XSPI_CLKMCU到PSRAM同时接到两颗PSRAM的CK管脚
XSPI_DQ[7:0]双向同时接到两颗PSRAM的DQ[7:0]
XSPI_DQS双向同时接到两颗PSRAM的DQS(如果器件支持)
XSPI_CS0MCU到PSRAM只接PSRAM_A的CE#/CS#
XSPI_CS1MCU到PSRAM只接PSRAM_B的CE#/CS#
XSPI_RESETMCU到PSRAM两颗都接,或者各用独立GPIO

这里最重要的原则就是:CLK、DQ、DQS这些总线信号全部并联共享,CE#/CS#片选信号必须一一分开,绝对不推荐把两个CE#短接在一起当一颗用,那样两颗芯片的输出驱动会同时作用在DQS和DQ线上,读数据时直接打架,轻则波形畸变,重则烧毁IO。

如果MCU的XSPI控制器有独立的复位输出,建议接到两颗PSRAM的RESET#上,并且在GPIO配置里把它设成独立控制。这样调试的时候可以只复位其中一颗,不用两颗一起重新初始化。如果MCU没有专用复位脚,用普通GPIO拉两根线过去也行,代价只是多占两个IO,但好处是你能单独操作任意一颗PSRAM,这个自由度在后面联调阶段非常值钱。

2.2 布局走线与电源去耦的实操建议

XSPI跑到100MHz以上DDR模式时,信号完整性就开始讲究起来了。CLK信号到达两颗PSRAM的走线长度尽量等长,DQ和DQS等长,这个等长不是指严格到毫米级,但PCB Layout时至少要让第二颗PSRAM的信号路径比第一颗多出来的长度控制在几百密耳以内,多出来的stub不要太长。

实际布局上我推荐用"菊花链"而不是"星型":主控先走到PSRAM_A,再从PSRAM_A旁边绕到PSRAM_B,CLK线尽量走内层,两端尽量靠近器件管脚处加匹配电阻。如果两颗PSRAM离得太远,中间的stub反射在DDR模式下会非常麻烦。我第一次打样的时候PCB空间太挤,PSRAM_A和PSRAM_B分列在PCB两侧,XSPI_CLK走了很长一条绕线,结果DDR读数据在高温下偶尔就错一位,后来把布局改成贴着放,问题才消失。

电源去耦同样不能省。PSRAM在DDR模式读写切换的瞬间电流变化很陡,VCC上如果去耦不足,示波器能看到几十毫伏级别的毛刺,轻则偶尔读错,重则器件挂死。我的做法是每颗PSRAM的VCC管脚旁边放一个0.1uF和一个1uF的MLCC,尽量靠近管脚,VCC的过孔就近打,不要共用很长的电源分支。另外两片PSRAM的VCC最好都从同一个稳压源出来,中间用磁珠隔离也行,但不要让其中一颗的电源走线穿过另一颗的下方。

3. 初始化流程:每一颗PSRAM都要单独"配一次对"

3.1 为什么不能同时初始化两片PSRAM

很多第一次接触这个方案的人会问:能不能发一条命令让两颗PSRAM一起进入OPI DDR模式?答案是绝对不能。PSRAM不像I2C设备有从机地址,它完全依靠CS引脚来区分总线上的命令归属。总线上同一时刻只有被CS选中的那一颗芯片会解析并响应命令,另一颗虽然也看得到时钟和数据线上的信号,但只要它的CE#是高电平,就会把总线上的命令当空气。

更危险的做法是想当然地把两颗PSRAM的CE#短接在一起然后发命令,这样两颗芯片会同时接收命令、同时驱动数据线,读操作时两个输出驱动器同时往DQ上推数据,总线冲突直接导致信号崩溃。所以初始化时只能一颗一颗来:先选中CS0,把完整的配置流程走完;再把CS指针拨到CS1,把同一套流程重复一遍。两颗之间不会有任何"同步"问题,因为它们的内部状态机完全独立,自己要对自己负责。

3.2 以APS256XX为例的完整初始化序列

我用的PSRAM是APMemory的APS256XX系列,典型的8线OPI接口器件。上电后芯片默认工作在SPI SDR模式,所以初始化过程大致分三个阶段:先用SPI模式做复位和ID检查,然后发送切换命令让芯片进入OPI DDR模式,最后在OPI DDR模式下继续配置延迟和驱动强度等参数。

完整的流程可以用下面这段伪代码来表达:

/* 伪代码,接口名称以实际SDK为准 */ static void psram_reset(xspi_cs_t cs) { uint8_t reset_en = 0x66; uint8_t reset = 0x99; XSPI_DirectTransfer(cs, &reset_en, 1, NULL, 0); XSPI_DirectTransfer(cs, &reset, 1, NULL, 0); delay_us(150); } static void psram_check_id(xspi_cs_t cs) { uint8_t cmd = 0x9F; uint8_t id[4] = {0}; XSPI_DirectTransfer(cs, &cmd, 1, id, sizeof(id)); /* 正常回读应该是厂家ID+密度ID,全0xFF或全0x00都说明物理链路有问题 */ } static void psram_enter_opi_ddr(xspi_cs_t cs) { /* 写配置寄存器0,使能OPI模式+DDR模式,并设定驱动强度等位域 */ uint8_t cmd[] = {0x71, 0x00, 0x00, 0x26, 0x40}; XSPI_DirectTransfer(cs, cmd, sizeof(cmd), NULL, 0); delay_us(200); /* 注意:从此之后,这颗PSRAM已经工作在OPI DDR模式, 主控侧XSPI外设的协议配置也必须同步切换,否则后续命令全部失效 */ } static void psram_init(xspi_cs_t cs) { psram_reset(cs); psram_check_id(cs); psram_enter_opi_ddr(cs); /* 切到OPI DDR之后,继续按手册配置CR1/CR2/CR3等寄存器, 包括Read Latency、Refresh Interval、Burst Length */ uint8_t cfg1[] = {0x71, 0x00, 0x01, 0x02, 0x00}; uint8_t cfg2[] = {0x71, 0x00, 0x02, 0x00, 0x00}; uint8_t cfg3[] = {0x71, 0x00, 0x03, 0x00, 0x02}; XSPI_DirectTransfer(cs, cfg1, sizeof(cfg1), NULL, 0); XSPI_DirectTransfer(cs, cfg2, sizeof(cfg2), NULL, 0); XSPI_DirectTransfer(cs, cfg3, sizeof(cfg3), NULL, 0); delay_us(100); } /* 两片PSRAM分别初始化 */ void board_psram_init_all(void) { psram_init(XSPI_CS0); psram_init(XSPI_CS1); /* 初始化完成后,把XSPI控制器切到Memory-Mapped模式, 之后就可以直接通过指针访问两个地址区间 */ XSPI_MemoryMappedEnable(XSPI_CS0); XSPI_MemoryMappedEnable(XSPI_CS1); }

这段流程里有几个细节值得展开说。第一,Reset命令发完之后要等足够久,PSRAM内部的复位状态机需要时间完成,等太短会导致后续命令被丢弃,尤其在低温环境下复位时间会更长。第二,0x71这条写配置寄存器命令发出后,芯片会立即切换工作模式,主控侧必须紧接着把XSPI外设的协议模式从SPI SDR换成OPI DDR,这个同步如果晚一步,后面所有命令都串不进去。所以在工程上我习惯把"切换前"和"切换后"的配置分开,用两个不同的XSPI设备描述对象来管理。

还有一点:ID检查那一行不要图省事跳过。两颗PSRAM都读一遍ID,能在早期把焊接问题、虚焊、短路问题暴露出来,省得后面系统跑起来出现莫名其妙的偶发错误,再回头怀疑是软件问题。

3.3 工程配置里最容易漏掉的CS1时序参数

很多图形化配置工具里,新建一个XSPI实例时只会默认生成CS0的Device配置,CS1那一栏默认是禁用或者沿用CS0参数的。如果你没有单独给CS1填时序参数,代码里访问CS1地址段的时候,控制器会拿CS0那套时序去操作第二颗PSRAM,而两颗PSRAM虽然型号相同,工艺批次不同也可能导致Dummy Cycle和Read Latency有细微差异,结果就是CS0稳如老狗,CS1隔三差五读错。

以FSP这种图形化配置环境为例,在XSPI实例的属性页里,每个Chip Select都有独立的一组Device Timing参数,包括协议模式、时钟频率分频系数、Dummy Cycle数、Read/Write Latency、命令序列。CS1那一组参数你要单独配,不能以为"和CS0一样"就完事。建议的做法是:先把CS0调通,然后把CS1的DummyCycles和Latency先设成比CS0大一档,再逐步往下压,直到能稳定通过压力测试再固定下来。

4. 地址映射与访问策略:把两片PSRAM当一块大内存用之前

4.1 地址空间划分和CS自动切换原理

XSPI控制器在Memory-Mapped模式下,会把外部存储空间映射到CPU地址空间的特定区间。CS0和CS1各自对应的地址段在芯片手册的内存映射表里是写死的,一般CS0从XSPI映射基地址开始,CS1紧跟着在后面,但不保证连续。有些MCU会在两个CS区域中间留一段保留地址,这个坑我踩过,当时写DMA缓冲区分配代码时假设CS1紧挨着CS0,结果缓冲区横跨了两段映射区,访问到中间空洞的地址立刻触发总线错误。

正确的做法是初始化完两片PSRAM后,先打印或者断点查看两个区域的基地址和大小,确认中间有没有空洞,再开始做地址规划。你把CS0区域的起始地址记为PSRAM_A_BASE,CS1区域记为PSRAM_B_BASE,两片PSRAM在软件里就是两个完全独立的内存池,即使地址空间看起来相邻,也不要在逻辑上把它们合并成一片。

4.2 Cache、DMA对齐与跨片选传输的注意事项

CPU访问XSPI映射区域时,如果MCU开了D-Cache,就得小心缓存一致性问题。CPU写过PSRAM的数据,如果不做Cache Clean,DMA搬运时可能读到的是Cache里的旧数据;反过来,DMA写入PSRAM后,CPU如果不能确认Cache Line已经被Invalidate,读到的也可能是旧的。嵌入式里这类问题表现非常隐蔽,程序逻辑看不出任何毛病,但结果就是错的。

我习惯把PSRAM映射区域配置成Write-Through或直接关闭Cache,只在性能敏感的热路径上手动做Cache操作。如果你用的是带XSPI的Cortex-M系列MCU,还可以用MPU把PSRAM区域设成Non-Cacheable或者Write-Back+临时Clean。总之,先保证一致性,再考虑性能。

DMA访问还涉及对齐问题。XSPI控制器通常对32位访问效率最高,如果你的DMA搬运长度不是总线宽度的整数倍,控制器会拆成多次非对齐事务,性能掉得厉害。更麻烦的是跨CS的DMA传输,也就是DMA传输长度跨越了CS0和CS1的边界,这个在部分MCU上是不受支持的,控制器内部无法在一个DMA序列里完成片选切换。我在项目里遇到过一次:DMA读2MB数据,从CS0区域读到CS1区域,源地址跨越了CS边界,结果末尾几十字节全乱了。后来的做法是无论如何不让单个DMA请求跨CS边界,如果数据确实分布在两片PSRAM上,就拆成两个DMA请求。

5. 实测数据与带宽真相:容量翻倍不等于性能翻倍

5.1 测试环境与方法

我先把话说在前面:单实例双CS的PSRAM方案,容量翻倍是真的,带宽翻倍想都不要想。因为整个XSPI控制器只有一个数据通路,CS0和CS1完全是分时复用,同一时刻只能有一个片选在总线上传输数据。

我的测试环境是XSPI时钟跑到133MHz,OPI接口、8线DDR模式,理论峰值带宽是133MHz乘以2(DDR双沿)再乘以8位,等于2128Mbit/s,也就是266MB/s。需要说明的是,这已经是这类PSRAM接口的上限附近了,实际能跑多少取决于命令开销和Dummy Cycle。

测试方法是写一段简单的裸机测试代码:用DMA在内存映射区做连续块读、连续块写,统计吞吐率。测试数据块大小选1MB,避开Cache的影响,关闭中断,用定时器计时。为了对比两片PSRAM和单片PSRAM的差异,我分别测了"只访问CS0"和"交替访问CS0/CS1"两组数据。

5.2 单双片数据对比与结论

测试项单颗PSRAM(CS0)双PSRAM交替访问(CS0+CS1)
连续块读(1MB)约182MB/s约176MB/s
连续块写(1MB)约121MB/s约118MB/s
64B随机读约28MB/s约27MB/s
双缓冲DMA(读CS0写CS1)不适用约110MB/s(注意这是总吞吐)

数据很说明问题:单颗PSRAM连续读能到180MB/s以上,加上第二颗之后总带宽并没有变成双倍,反而因为CS切换时的时序重新同步开销,稍微掉了一点。写操作本身比读操作慢,因为写命令的地址和命令头占用的总线周期更多。

还有个容易混淆的概念是"双缓冲翻倍":有些人以为把两个缓冲放在两颗不同的PSRAM上,一边读一边写就能让吞吐翻倍。但从上面的测试结果看,读CS0写CS1这种"双缓冲"实际总吞吐只有110MB/s左右,比单颗PSRAM的连续读带宽还低。原因很简单:DMA在CS0发起读事务,事务结束后CS1才能发起写事务,中间还有片选切换开销,整个流水线实际上没有并行度。

5.3 基于带宽特性的应用建议

明白了带宽真相之后,应用策略就要跟着调整。如果你的系统里有两块显示缓冲区,我的建议是:两块缓冲区放在同一颗PSRAM上,不要分开放两颗。因为显示驱动访问缓冲区时是连续读写,如果两块缓冲分布在CS0和CS1两边,每次切换屏幕缓冲区都会多一次片选切换开销,长远看没有任何性能收益。

反过来,如果两块缓冲区用途完全不同,比如一块是图像采集的帧缓冲,一块是音频环形缓冲,它们几乎不会在同一个时间片里被同时访问,那就可以放心地分开放置,这样能避免两个数据流在同一个PSRAM里互相争抢带宽。

还有一点关于随机访问:PSRAM的随机读带宽远低于连续读,64字节随机读大概只有28MB/s。如果你的应用依赖大量分散的小块访问,比如数据库索引、图形加速器的命令列表,那就要非常谨慎。这种情况下,加大DMA搬运粒度、把分散的小块数据先在内部RAM里聚合成大块,再一次性写入PSRAM,性能会有数量级的提升。

6. 联调踩坑实录:六个值得记录的现场问题

6.1 第二片PSRAM读回来全是0xFF

这是最常见的现象,也是最容易排查的。第二片PSRAM在CS1地址段反复读取,返回的数据永远是全0xFF。拿示波器看CS1波形,发现CS1根本没有任何低电平动作。问题基本出在两个地方:一是CS1地址段没有在XSPI外设配置里使能,很多SDK默认只开CS0;二是CS1引脚被GPIO复用成了其它功能,需要在引脚配置里把XSPI_CS1功能打开。这两个问题在图形化配置工具里都很容易漏,因为新建工程时工具只默认生成CS0的设备配置。

处理方式也很简单:先在引脚配置里确认XSPI_CS1已经映射到正确的物理引脚,然后在XSPI外设配置里把CS1的Device参数完整填上,包括协议模式、时序参数和地址段。填完之后不要急着跑应用,先在CS1地址段的起始地址写一个已知值,再读回来校验,确认基本访问通路没问题再往下走。

6.2 同型号不同批次,高频下只有一片稳定

两片PSRAM型号完全一样,但一个是老批次料,一个是新批次料。133MHz下CS0那颗跑几天都没事,CS1那颗跑几个小时的连续读就开始随机出错,出错间隔没有规律,降低到100MHz又一切正常。

这种问题十有八九是两颗芯片内部的时序校准有微小差异,导致它们在不同的频率点上有不同的时序裕量。解决办法一是把CS1的Dummy Cycle或Read Latency单独上调一档。当时的配置是Dummy Cycle从6改成7,CS1就稳定了。如果调Dummy还不行,二是单独为CS1降一点时钟频率,比如CS0跑133MHz,CS1跑100MHz,反正两颗的时序参数是独立的,互不影响。

这个坑提醒我:即使同一型号,批次差异也不能默认忽略。在量产阶段,我建议把两颗PSRAM的时序参数都留出一档裕量,以最差批次为准,别为了追求那几MB带宽把自己逼到悬崖边上。

6.3 低温上电初始化失败

环境测试时发现,-20℃低温下系统上电,有概率出现PSRAM初始化失败,热机就没事。示波器抓Reset引脚的波形,发现Reset命令发完之后,芯片还没完全恢复稳定,紧接着的命令就已经发出去了。

查数据手册发现,低温下PSRAM内部的上电复位时间会比常温长很多。我原来在Reset之后只等了150us,低温下不够。把Reset后延时加大到500us,问题消失。另外,如果PSRAM的RESET#脚是MCU GPIO控制的,上电时要保证RESET#先保持一段低电平,再拉高释放,给芯片足够的启动时间。

这里还有个经验:如果项目要做环境测试,初始化和时序验证一定要在低温条件下做一遍,很多常温下完全没问题的参数在低温下会现出原形。

6.4 写后立刻读却是旧数据

现象是对PSRAM做"写循环之后立刻读回"的测试,数据对不上,但如果写完之后隔几百微秒再读,数据就对了。第一反应是PSRAM写延迟问题,后来发现根因在MCU的Cache和XSPI写缓冲上。CPU写数据进XSPI映射地址时,数据先落在写缓冲或者D-Cache里,还没有真正推到外部总线上;立刻发起读操作时,读请求走了另一条路径,于是读到了外部存储器的旧值。

解决办法有两种:对于D-Cache,在对PSRAM区域执行写完之后的读操作前,执行Cache Clean操作,把Cache Line强制写回;或者把该区域的Cache策略改成Write-Through,牺牲一点写性能换一致性。对于XSPI控制器内部写缓冲,通常在驱动库里会有对应的等待函数,写完之后调用一次等待总线空闲的API,再发起读操作。

6.5 VCC纹波导致的随机bit翻转

这是最难排查的一类问题,现象非常随机:系统长时间跑下来,偶尔会有某个bit反转,但不是固定位置,也不是固定时间,用软件单步调试永远复现不了。最终用示波器抓到VCC上的纹波,在DDR模式读写切换的瞬间有大约80mV的毛刺,超过了PSRAM电源纹波规格。

处理方式是增加去耦电容,同时对PSRAM的IO驱动强度做降档设置。驱动强度太强会在切换瞬间产生更大的电流毛刺,把驱动强度从最强档降一档之后,纹波明显变小,问题不再复现。

这类随机性问题最忌讳上来就改代码,先把电源、时钟、信号完整性排查清楚,尤其是高速DDR模式下,电源和信号问题导致的软错误远比代码逻辑问题多得多。

6.6 DMA跨CS搬运数据错乱

前面提过一次,这里详细记录。当时写了个DMA任务,要把一块2MB的数据从CS0区域搬到外部接口,源地址不小心跨越了两个CS的边界。DMA启动后,前1.5MB数据正常,到了CS边界附近,传输的数据开始错位,最后一部分数据直接丢失。

原因是XSPI控制器在单个DMA传输过程中不支持跨CS切换,DMA内部地址连续递增,但跨越到CS1的地址段后,控制器用的还是CS0的时序参数,加上两个CS的映射区中间可能还有空洞,DMA访问到洞上就直接悬空。

解决方式是检查所有DMA请求的源地址和目的地址,确保单个DMA传输范围

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询