
QSPI PSRAM这种东西点个灯、读个ID都很容易难的是把它塞进一个真正干活的系统里还不闹情绪。前两篇我们聊过APS6404L-3SQR这颗64Mb QSPI PSRAM的基础协议和单页读写状态机今天这篇第三期想换个视角专门讲让它稳定干活的三件事刷新机制怎么不拖后腿、连续读写怎么把带宽顶上去、真机出错怎么快速定位。如果你只是把PSRAM当成一个“能读能写的Flash”来用那你大概率会在两周后半夜收到同事的消息数据偶尔错一个bit花屏或者DMA一跑就卡死。这篇文章就是从这些真实场景里长出来的适合已经能跑通裸机读写、准备把PSRAM接入图像缓存、音频缓冲或通用数据存储的FPGA开发者。1. 系列第三篇的定位为什么能跑通一进系统就翻车1.1 前两篇做过哪些事系列前两篇一篇讲芯片本质和QSPI协议家族的差异另一篇落地了一个不带缓存的裸读写状态机。后者可以完成最基础的操作拉低CS、发出命令字节、送地址、读数据或写数据最后拉高CS结束一帧。这套东西在逻辑仿真里一定是对的接上真实芯片后大概率也能跑通。但问题就出在它“能跑通”三个字上。只要你的访问模型稍微复杂一点比如每次突发只读2字节、DMA连续搬运几千字节、或者显示控制器每帧都要从同一个地址范围读数据裸状态机的短板立刻暴露命令帧开销太大小访问的吞吐效率可以低到只有理论值的一两成。没有刷新的概念长时间连续拉低CS会挤压芯片内部刷新窗口。用户逻辑和PSRAM总线之间没有缓冲谁慢谁就把系统拖死。所以第三篇的定位不是“再教一遍怎么做”而是“把驱动从Demo级推进到系统级”。1.2 第三篇要处理的三类问题这一篇我打算把问题收敛成三类后面每一章对应一类第一是刷新与访问的仲裁。PSRAM本质是DRAM存储单元需要周期刷新。芯片内部有自动刷新逻辑但它需要一个前提——总线上有足够的时间让它把刷新做完。大多数驱动所谓“跑一阵子就出错”根源不是命令写错而是CS拉低时间太长刷新被饿死了。第二是命令流效率。QSPI的优势是少引脚代价是协议开销相对大。每一帧命令除了数据本身还要传命令字节、地址字节、模式位和等待周期。如果上层的访问粒度不合理再好的QSPI也跑不出漂亮带宽。第三是同步与调试。SCLK激励由FPGA给数据返回由芯片沿着同一组IO送回来结构上类似源同步但没有独立的DQS提示信号所有采样时刻全靠驱动设计和时序约束来保证。真机上的偶发错误大多藏在这里。这三件事解决完之后你手里的PSRAM才真正像一个能在系统里长期工作的存储设备而不是一只“实验室里很乖、装进机箱就发疯”的兔子。2. 刷新机制把“隐藏动作”当成设计对象2.1 为什么PSRAM一定需要刷新PSRAM的全称是Pseudo Static RAM伪静态随机存储器。它对外长得像SRAM时序简单、接口友好内部却是DRAM细胞每个bit靠电容上的电荷保存电荷会漏漏光了数据就没了。所以必须在电荷还没漏到阈值之前对整个存储阵列重新刷一遍。这个“重新刷一遍”就是刷新操作。对于APS6404L-3SQR这类芯片刷新不是像NOR Flash那样按扇区擦除也不消耗你的数据带宽去一条条发命令。芯片内部有一套自刷新状态机它自己按行扫描存储阵列周期性补充电荷。你作为FPGA端的设计者要做的不是帮它发刷新命令而是别挡住它的路。生活里可以找一个类比你请了个保洁阿姨定时来办公室收垃圾你不需要教她怎么收垃圾但如果你一直把办公室门锁着不让她进垃圾桶一定会满最后熏到的是你自己的工位。PSRAM的刷新就是这样它有能力自己干活但前提是你得把门打开一会儿。2.2 自动刷新和你发不发命令没有关系很多人第一次接触PSRAM的刷新下意识会去找“Refresh Command”。APS6404L确实有相应的控制命令但在常规跑数据时你并不需要周期性去发它。芯片内部的自动刷新电路会在访问间隙自己完成。问题恰恰在于“访问间隙”这四个字。你可以查一下手册里的刷新参数通常整个阵列需要在一个较短的窗口内完成一次全扫描。你平时的单笔读写结束之后CS已经拉高了刷新自然有时间做。但当你为了追求高带宽把CS长时间拉低进行连续成百上千字节的突发读写时内部刷新就会被持续打断。刷新请求可不管你外面命令发到一半它到了就是到了。有的芯片会选择在内部插入等待周期表现为你的数据阶段偶尔多花几个时钟有的芯片会在访问边界处临时断开存储阵列访问表现为主机端看起来还在传输但数据实际上停了一拍。如果你在驱动里没给这种停顿留余量FIFO的水位计算就会出错时序在高速下也会出现偏差。我见过不少初始状态的板子单发、多发都正常一上DMA连续传输就死机最后用逻辑分析仪抓CS信号发现CS低电平持续时间已经快到手册给的上限了。这类问题没有任何命令能救只能从仲裁层解决。2.3 仲裁策略给内部刷新留够窗口我的做法是把刷新看作一个“最高优先级但是频率很低”的总线请求让它和读写请求一起进入一个仲裁模块。仲裁规则很简单刷新请求到来时不允许正在进行的单帧传输被立刻打断但当前帧结束后必须插入一个刷新窗口。窗口期间CS保持高电平至少维持若干SCLK周期具体长度按下限加上余量来设计。窗口结束前不排队任何新的读写请求。这样设计的好处是刷新永远不会在数据中间插入不会破坏QSPI命令帧的完整性同时又能在密集访问中强制打开“门”。硬件实现上就是给总线控制器加一个小定时器和一个请求标志。比如每累计若干次总线传输后主动插入一次tBubble窗口或者设置一个刷新定时器每隔一定时间拉高一个刷新请求线。仲裁状态机把这个请求放在最高优先级等当前帧结束就跳进一个纯等待状态把CS释放计数足够周期后再回到空闲。这里有个容易忽略的细节刷新窗口的“空闲”应当是真正的总线空闲也就是CS处于高电平且IO处于高阻或确定状态。有的设计中刷新窗口里还偷偷把IO引脚拉到某个电平这会让芯片在外部环境噪声下误判总线行为效果反而更糟。正确的做法就是让所有信号都回到不活跃状态尽量不要做多余动作。补充一个小体会对于大部分应用不需要真的去数“这次刷新了哪一行”。芯片内部的行扫描管理远比外部干预靠谱。外部要做的是“不要连续霸占总线超过芯片容忍时间”而不是“替它管行地址”。所以仲裁策略里“强制空闲窗口”比“命令型刷新”更实用。3. QSPI命令流吞吐主要输在协议开销上3.1 一帧命令到底有多“贵”QSPI最大的优点是引脚少4根IO加1根CS加1根SCLK就能跑。但代价是命令字节和地址字节都要串行搬运。在x4模式下一个字节的传输只占2个SCLK周期看起来很快可如果访问的数据量本身很小协议开销的比例就压不住了。拿一次典型的Quad IO Read举例命令字节、3字节地址、模式字节、若干等待周期然后才开始读数据。假设命令帧固定开销大约6到7个SCLK周期这是x4模式下字节类字段的等效周期如果你只读1个字节数据阶段只有2个SCLK那么整体效率大约是2除以9连两成都不到。即使你一次读32字节开销占比也能到百分之十几。这意味着什么意味着你辛辛苦苦把SCLK频率提上去可能有一大半周期都浪费在“说废话”上。我整理了一个简单的对比表数据位宽按x4模式估算访问方式大致固定开销SCLK数据阶段SCLK整体效率约单字节读6~72低极不推荐8字节突发读6~716约七成32字节突发读6~764八成多所以一个关键结论在驱动之上用户逻辑的访问粒度必须朝“块”靠拢。能读32字节就不要拆成8次读4字节。对于显示控制器这类按行读数据的场景直接把行缓冲尺寸定义成PSRAM一次突发的最佳长度效果立竿见影。3.2 访问粒度、对齐和读改写QSPI PSRAM从外部看是好字节地址的线性空间但内部组织是按一定颗粒度管理的。如果你只是写一整块连续数据问题不大真正麻烦的是“写其中一小段”。比如你想修改一个数据结构里的3个字节而这个3字节恰好横跨芯片内部某个管理边界。有的芯片会要求写访问按最小单位成对出现有的则允许字节写但底层实际上是read-modify-write。不管哪种情况驱动一旦支持“任意地址任意长度写入”就得做好两件事掩码生成和读改写流程。掩码生成不用多说用字节使能位标记哪些字节真正要更新。读改写流程则是如果当前写入长度没有覆盖到某个内部颗粒度的完整范围那就先按整颗粒度读回来把不需要改的字节保留再把新的数据合并进去最后整块写回。这个过程在逻辑上很容易实现但会让驱动复杂很多。而且串口调试时一旦出现写入交错读改写操作和普通写操作彼此重叠很容易引发数据一致性错误。我给出的实践经验是在PSRAM驱动之上给用户一个“按对齐块访问”的接口。例如统一按32字节对齐分配缓冲区每次读写都从这个边界开始长度固定为32字节的倍数。这样命令生成、掩码逻辑、刷新仲裁全部可以简化长期稳定性也更好。除非你的应用实在做不到块对齐否则别把读改写写进主路径。3.3 连续突发与突发长度限制谈到吞吐就绕不开连续突发。QSPI PSRAM在读写时支持地址自动递增也就是说你只要在开头给一次地址后面数据阶段就会沿着地址递增方向连续搬运直到CS拉高为止。这比每访问一个字节都重新发命令高效得多。但“连续”不是没有边界。芯片内部地址是有行、列结构的虽然外部看起来是线性地址跨越特定边界时内部需要额外的行切换动作外部表现为偶发的等待周期或者需要重新发起命令。对这个边界驱动最好有明确认知。另一个限制来自刷新。我们在第二章说过CS低电平持续时间不能太长。于是这里出现一个张力突发越长效率越高但刷新压力越大。解决方式不是在某个固定长度上硬切而是做一个“传输预算”的机制。以我常用的设计为例允许上层请求一个很大的长度但底层控制器会把这次传输切分成若干段。每段达到预算长度后主动拉高CS插入一个刷新窗口再拉低CS继续搬运剩下的数据。这样上层看到的仍是完整的一次大数据读取底层实际上已经给刷新让了路。切分时要注意地址连续性。第二段的起始地址应当是第一段结束地址不能用第一段的起始地址再加一次偏移否则会重复读一段数据。连续突发中的“续传”最容易在这里出bug调试时优先检查这个位置。4. DMA与FIFO接口让PSRAM长得像Block RAM4.1 为什么非套一层不可裸状态机的最大问题在于它和用户逻辑直接握手。用户逻辑发一个读请求状态机开始一帧QSPI传输传输期间用户逻辑只能干等。如果用户逻辑是CPU软核等于CPU被存储访问拖死如果是显示控制器等于像素流断供。套一层DMA加FIFO的意义就是你把“慢速串行协议”藏在一个异步或者同步FIFO后面。用户逻辑面对的不是一套会咬人的SPI时序而是一个普通存储器接口写侧往里丢数据、读侧往外拿数据满了或空了由标志位通知。从这个角度看PSRAM驱动设计的核心其实不是QSPI状态机本身而是“怎么把QSPI的突发特性和用户接口的实时性要求进行解耦”。前两篇做的是QSPI这一篇做的才是PSRAM系统。4.2 读预取与写水线读侧设计一个常见思路是预取。PSRAM读一帧开销那么大绝对不能用户要4字节就发一帧4字节。理想做法是维护一个读缓存每次从PSRAM读一大块比如64字节存进FIFO。用户逻辑从FIFO按自己的需求取数只要缓存命中就不碰PSRAM总线。例如显示控制器需要按顺序扫描帧缓冲它每次读32字节。如果驱动每次实际读64字节下一次的32字节大概率已经在FIFO里了。只有当缓存数据被消费完才发起下一轮PSRAM读。这个机制可以显著降低命令开销。设计读FIFO时要注意两件事。第一预取粒度必须大于用户请求粒度否则你忙了半天每次还是在一帧小访问就失去了意义。第二FIFO的深度要够覆盖一轮填满和清空的过程推荐至少能装两到三次预取粒度避免读侧数据断流。写方向则相反要做“累积批量写”。用户逻辑经常是小块写比如一次写8字节甚至4字节。驱动不立刻发命令而是等写FIFO里的数据攒到接近一个配置好的突发长度比如32字节再一次性发一帧QSPI写命令。攒数据期间用户逻辑继续写HALF_FULL信号作为背压FIFO半满后通知前级不要再写太多否则FIFO溢出。水位阈值要结合PSRAM的突发长度来定。比如突发长度32字节FIFO深度256字节触发批量写的阈值设在32字节即可但半满标志仍然用于防溢出。写侧还要处理“最后的尾巴”。一个DMA传输结束时FIFO里可能还有不到32字节的残留数据。这时候不能傻等要靠“flush”信号触发最后一次写命令把残留数据写干净。这个信号在SPI控制器里往往被忽略写数据在DMA结束后丢了是最常见的隐性bug。4.3 一个可落地的状态机骨架我贴一个简化后的状态机框架重点不是完整代码而是看它怎么把“刷新窗口”“读预取”“写累积”放到一起仲裁。typedef enum logic [3:0] { IDLE, REFRESH_WAIT, WR_CMD, WR_ADDR, WR_WAIT, WR_DATA, WR_DONE, RD_CMD, RD_ADDR, RD_WAIT, RD_DATA, RD_DONE } state_t; always_ff (posedge clk) begin case (state) IDLE: begin if (refresh_req) begin state REFRESH_WAIT; end else if (wr_fifo_len WR_THRESHOLD) begin // 攒够了批量写数据发写命令 state WR_CMD; end else if (rd_fifo_level RD_LOW) begin // 读缓存快空了启动一次预取读 state RD_CMD; end end REFRESH_WAIT: begin // 保持CS高电平计数到REFRESH_CYCLES后回IDLE if (refresh_cnt REFRESH_CYCLES) begin refresh_req_ack 1; state IDLE; end end WR_CMD: begin cs_n 0; // 发送QSPI写命令字节 state WR_ADDR; end WR_ADDR: begin // 发送24位地址 state WR_WAIT; end WR_WAIT: begin // 按需插入等待周期进入数据阶段 state WR_DATA; end WR_DATA: begin // 按位/字节从写FIFO取数据直到本次突发长度结束 if (data_cnt burst_len - 1) begin cs_n 1; // 结束CS wr_fifo_pop_en 1; state WR_DONE; end end WR_DONE: begin // 清状态回IDLE这里可能拉一个wr_done脉冲给上层 state IDLE; end // 读方向类似RD_DATA阶段把数据写入读FIFO endcase end参考这段代码时记住一个原则仲裁判断必须在IDLE做不能在数据传输阶段中途插入刷新。否则可能导致命令帧被拆开CS时序错乱。每个状态里要加的FIFO读写使能、计数器清零逻辑按你自己的总线规范补齐就行。框架搭好后有效的优化顺序是先调突发长度和阈值再调SCLK频率最后才考虑命令对齐和引脚延时。不要一上来就追极限频率那样往往会把几个问题混在一起很难排查。5. 时序约束与跨时钟域QSPI没有你想得那么宽容5.1 用同源时钟而不是两个独立时钟APS6404L没有独立的DQS信号数据采样完全依赖SCLK沿你和芯片之间的同步关系。设计上最省心的方式是让PSRAM控制器的工作时钟和SCLK来自同一个锁相环且保持整数分频、相位可控。如果控制器工作在50MHzSCLK也由这个50MHz通过内部逻辑产生那么数据采样关系还算简单。如果控制器时钟是100MHzSCLK是50MHz那就需要仔细考虑“在哪个沿采样、经过几级寄存器”的问题。很多初学者随手把SCLK从某个普通IO飞出去控制器状态机用系统时钟跑结果就是SCLK比系统时钟晚了一两拍数据回来时采样窗口已经跑了。我更推荐这种结构PLL产生一个时钟作为QSPI控制器时钟同时从PLL的另一个输出直接驱动SCLK引脚两者相位关系固定。如果目标SCLK频率高可以在PLL里把SCLK输出设成与控制器时钟同频率或倍频再用IO逻辑做延迟微调。实际项目中不少“偶发错误”的根因就是SCLK路径和采样路径时钟不一致。你先在约束文件里保证这两个时钟同源再谈后面的一切。5.2 输入输出延时约束怎么算QSPI虽然有SCLK但它不是完整的源同步接口。主机FPGA要根据芯片手册里的建立保持时间和输出延时来约束时序。综合工具不会自动知道芯片引脚上的延迟你得手动给。输出方向FPGA控制芯片从FPGA时钟沿到芯片输入引脚之间存在FPGA内部路径delay、PCB走线delay、芯片输入建立时间要求。约束常用set_output_delay让综合工具知道外部设备需要的建立/保持时间。输入方向芯片返回数据数据从芯片输出经PCB走线到FPGA输入引脚。FPGA内部要通过IO逻辑采样。约束常用set_input_delay把芯片输出延时和走线延时一起告诉工具。给一段示意性约束参数必须按你实际芯片手册和PCB情况修改不能直接照搬# 示意周期值必须以实际系统为准 create_clock -name sys_clk -period 8.000 [get_ports clk_p] create_generated_clock -name sclk -source [get_pins pll_inst/clk_out0] \ -divide_by 1 [get_pins pll_inst/clk_out1] # FPGA输出给PSRAM外部建立时间约3ns保持时间约1ns set_output_delay -clock sclk -max 3.0 [get_ports {qspi_io[0]}] set_output_delay -clock sclk -min -1.0 [get_ports {qspi_io[0]}] set_output_delay -clock sclk -max 3.0 [get_ports {qspi_cs_n}] set_output_delay -clock sclk -min -1.0 [get_ports {qspi_cs_n}] # PSRAM输出给FPGA芯片输出延时约5ns采样需要建立保持 set_input_delay -clock sclk -max 5.0 [get_ports {qspi_io[0]}] set_input_delay -clock sclk -min 2.0 [get_ports {qspi_io[0]}]这里具体数值不要抄。时序约束的价值在于你在跑位后能看到每条路径的建立/保持余量是多少从而判断问题到底是代码逻辑还是物理层。注意一个细节QSPI四根双向IO在输入方向约束和输出方向约束要同时存在。工具会区分set_output_delay和set_input_delay分别作用于不同方向的路径。漏掉双向约束是综合工具默默放过、上板后偶发错数的典型原因。5.3 动态相位对齐扫一遍才算数即使静态约束全绿真实PCB上的过孔、连接器、上下拉电阻还是会引入估算不到的偏移。这时建议在驱动里加上一个可配置的采样延时参数。方法是在FPGA的IO接口例化一组延时单元或使用厂商提供的I/O延迟原语让数据采样的时钟相位可以在运行期调整。完全靠静态时序收敛到边缘风险太大我倾向于在硬件初始化阶段跑一次“相位扫描”。扫描流程很简单把SCLK的延时或数据采样时钟的延时从最小到最大分成几十个档位每个档位跑一段固定PRBS数据回读测试统计错bit数量。最终选一个错误率全零的区段再取区间中点作为工作点。有人问为什么不直接取中间档位因为PCB走线、芯片批次、温度都会让窗口偏移。你取的理论中点未必是实际中点。扫一遍把正常工作点写进寄存器不管是调试还是产测都很方便。实际操作中这项功能还可以帮你判断故障性质如果所有延时档位都报错多半是命令或CS电平有问题如果只有某个区间报错说明是采样相位问题。6. 真机问题排查三类典型故障与定位流程6.1 间歇单bit错误的隐蔽元凶最让人头痛的是“十次里有一次读出错误错误bit位置还不固定”。这时候先不要怀疑芯片坏了。先按这个顺序排查第一看电源纹波。PSRAM对供电质量不算特别敏感但高速切换四根IO时地弹和电源跌落会导致建立/保持时间余量变小。用示波器探头直接点在PSRAM电源引脚附近看开关瞬间的跌落幅度。第二检查IO方向切换。QSPI四根线是双向的写命令阶段FPGA输出数据读数据阶段芯片输出数据。方向切换时总线上可能出现瞬态争用。如果驱动没有预留足够的turnaround周期芯片输出使能和FPGA输出使能很容易打架表现为第一次读的bit偶发错误。解决方法是在读命令的地址/模式阶段结束后插入至少1个SCLK的空闲周期让FPGA侧把IO释放成高阻再让芯片接管总线。这个周期在仿真里看不出问题但真机几乎必加。第三检查CS信号质量。CS是异步信号如果CS在边沿附近出现毛刺芯片可能误判一帧开始或结束。CS路径上建议串一个电阻同时确保FPGA内部的CS产生逻辑用的是SCLK域打拍后的信号而不是某个异步标志直接输出。6.2 吞吐打不上去的协议瓶颈上板之后读写都正常但实测吞吐只有理论带宽的一半。这时先算一笔账理论带宽是SCLK频率乘4再乘2读写都以双沿计还是只按单沿先把计算口径统一。如果按单沿、x4模式50MHz SCLK的理论有效带宽约25MB/s。扣除命令帧开销和刷新窗口最终实用带宽能到15MB/s以上就算及格。要是连10MB/s都不到基本可以断定是被小的访问粒度拖垮了。排查方法是抓总线命令流看连续两次读命令之间的总线空闲有多久。如果每次突发只有8字节且CS经常拉高再拉低那么瓶颈在命令开销。把突发长度改成32字节或64字节再看事务间隔。还有一个常被忽略的点FIFO的水线阈值太低。比如写FIFO刚到4字节就触发一次QSPI写那么帧开销会被数据量摊得很薄效率自然低。把阈值调成32字节往往能立刻改善。调阈值不需要改协议层只改一个常数收益却很明显。6.3 花屏、错行和刷新冲突显示应用里的花屏和错行很多不是像素数据算错而是刷新窗口不够造成的。症状非常典型冷机开机正常跑几分钟后开始出杂点或者画面整体偶发错一列。温度越高出现越频繁。因为温度升高会加速DRAM单元电荷泄漏刷新需求更频繁。如果驱动长时间不放行刷新错误率就会随温度上升。解决方向不是拼命提高刷新频率而是压缩单次突发时间、增加总线空闲间隔。突发长度从64字节降到32字节加上强制刷新窗口症状通常立刻缓解。还有一类花屏是读预取一致性问题。显示控制器读帧缓冲时有随机跳变地址的能力如果预取逻辑没有正确处理“跳变后缓存失效”就会读到旧数据。排查方法很简单在跳变地址后读出的第一个像素上打一个标记或者读回测试时故意做随机跳读。6.4 一套可以照抄的测试固件最后给一套我每次换板子都会跑的测试流程省得你在真机上盲调测试项数据模式通过标准基础读ID固定命令读到正确ID可确认接线和命令通路单块回读固定累加数每个地址读回值与写入一致连续大块读写PRBS伪随机流无单bit错误CRC校验全零随机地址跳读地址扰动PRBS跳变后数据仍完全一致相位扫描短PRBS找到错误为零的延时区间高温连续运行长时间满负荷读写长时间无错误刷新窗口充足PRBS数据模式很重要它能暴露连续bit相关性问题。固定累加数测不出总线间的串扰和时序问题因为相邻bit变化模式太规整。PRBS加入随机跳变后四根IO之间的干扰才能被真正逼出来。每个测试项都要记录SCLK频率、突发长度、延时档位三类参数。调试时只改一个变量不要同时调三处否则出了问题根本不知道是谁干的。我个人搞这块芯片最大的体会是QSPI PSRAM的驱动难度从来不在某个具体命令而在“系统配合”。刷新仲裁、帧开销、时序约束这三件事是互相牵连的。你把突发长度加长吞吐上来了刷新就被压制你把刷新窗口加多稳定性上来了带宽又打了折扣。真正成熟的驱动必须把这三件事放在同一个仲裁模块里统筹而不是各调各的。后面再往深走还可以拓展多片PSRAM拼接带宽、在片选扩展基础上做成可寻址的大缓冲池或者把控制接口改造成AXI Slave以嵌入SoC。但这些都是建立在今天这套稳定框架之上的事情先把刷新、FIFO和时序收敛搞定再谈下一步不迟。