“(3)”这个后缀说明它是系列里的第三篇。前两篇我们完成了APS6404L-3SQR这颗QSPI PSRAM的基本初始化、寄存器读写和裸读写验证,当时以为只要ID能读、数据能写就算打通了。真正开始跑连续数据流之后才发现,距离“能用”还有一大截,这一篇记录的就是从“能读能写”到“敢拿来当内存用”之间踩过的坑、推倒重来的部分和最终稳定运行的控制器结构。内容集中在三个核心问题上:刷新调度、时序余量、控制器模块化重构。适合那些已经把PSRAM点亮的开发者参考,尤其是准备把这类伪静态随机存储器用于图像缓存、音频缓冲等数据流场景的朋友。
1. 初版控制器能跑测试向量,却扛不住真实数据流
1.1 三个典型症状与排查方向
我们当时的应用场景很普通,图像采集模块把一帧数据写入PSRAM,然后图形输出模块从同一个地址空间把数据读出来显示。结果并不是完全不显示,而是花屏、局部错位、长时间运行后整块数据消失。
整理下来主要是三个现象:
- 固定数据测试(比如全0x55、0xAA交替)可以过,但随机数测试偶发字节错误;
- 运行一段时间后,某个固定地址段的数据全部变成0xFF或0x00;
- 掉电重新上电后,某些地址写入不生效,像是芯片自己“忘掉”了之前的状态。
这三个症状对应完全不同的根因:随机字节错误多半是时序余量不够,固定区域数据消失基本可以锁定刷新缺失,写入不生效则要回到初始化流程查寄存器配置。这也是排查顺序的依据——先解决初始化配置问题,再解决刷新问题,最后才是时序调优。因为前两个问题如果存在,时序调优做得再细也是白搭。
1.2 QPI模式下的地址位序:驱动里最容易埋雷的地方
先讲一个我们在初版驱动里真实踩过的坑。SPI接口的存储芯片通常按MSB优先传输地址,QSPI PSRAM在QPI模式下地址字段也是同样的规则。但QPI模式下一次并行传4个bit,地址线3字节共23位有效,部分驱动写惯了普通SPI的逐bit移位,到了QPI下仍然按单线思路处理,结果地址在bit级别上发生了错位。
这种错位有个很迷惑人的特点:单个字节的读写测试看起来是正常的,因为错位后的地址仍然是一个合法地址,数据能写进去也能读出来。只有当你把地址当作连续区域访问,比如按顺序往0x000000到0x00FFFF写满递增数据,再按同样顺序读回来时才会发现数据出现在错误的位置。
验证方法很简单:用两个已知地址,一个写0x55,另一个写0xAA,然后反向读取。如果交叉读到了对方地址的数据,基本可以断定是地址位序或位宽处理出错。排查时对照芯片手册里Read/Write命令的地址时序图,把每一位的对应关系列一张表,再跟RTL里移位寄存器的顺序逐一比对。
1.3 管脚维持时间:看似能过,实际上余量已经耗尽
地址错位解决之后,随机字节错误仍然偶发。这次问题在采样窗口上。QSPI模式下4根双向数据线SIO[3:0]在SCLK的边沿附近翻转,FPGA内部还要经过IO单元、布线资源才能到达触发器的D端。如果PCB走线不等长、IO逻辑资源布局分散,再加上温度变化,采样时刻就会向窗口边缘移动。
数据手册里那些参数,什么输出有效时间、输出维持时间,看起来只是理论值,但在QSPI PSARM这种高速翻转的接口上,它们直接决定了控制器时钟能否稳定采样。我们当时的做法是先用片外的等长约束约束了SIO到SCLK的走线组,然后在FPGA里给SIO信号加了输入延迟约束。建议直接把QSPI的采样时钟和FPGA系统时钟设计成相位可调的关系,而不是赌布线延迟足够小。实测下来,仅仅把采样点从默认的上升沿移到下降沿,字节错误率就明显下降。这属于STA时序收敛的范畴,可以在综合工具里用report_timing检查路径余量。
2. 刷新机制:不主动刷,PSRAM就是一块会丢数据的SRAM
2.1 刷新不是“可选优化”,而是必需功能
QSPI PSRAM本质上是DRAM颗粒套了个SPI接口的外壳,靠电容存储电荷,电荷会漏,所以必须周期性刷新。这一点和真正的SRAM完全不同。很多人拿到芯片手册先看读写命令时序,却忽略了刷新章节,初版驱动跑挂大多跟这个有关。
刷新窗口和行数的关系决定了刷新频率。以常见的64Mbit级PSRAM为例,手册通常给出整个存储阵列必须在8ms左右内完成一轮刷新,内部行数有些是512行、有些更多,具体数字以你手里那颗芯片的手册为准,计算公式是固定的:平均刷新间隔 = 刷新窗口 / 内部行数。如果窗口是8ms、行数是512行,那平均 15.625μs 就要完成一行刷新;如果行数是8192行,平均间隔就缩短到 1μs 不到。算出这个数字之后,再换算成你的SPI时钟下的周期数,就能得到一个用于驱动状态机的刷新周期计数器。
当时我们的SPI引擎工作在50MHz,按我们这颗芯片的实际行数折算下来,大约每几百个时钟周期就需要插入一次刷新。这个数量级完全不能靠“偶尔发一条命令”蒙混过关,必须做成自动调度。
2.2 显式刷新命令与自动刷新:我选择前者
APS6404L-3SQR这一类PSRAM通常支持两种刷新方式:一种是通过配置寄存器开启自动刷新,芯片在空闲时自己处理;另一种是主机在总线空闲时主动发送刷新命令。
我们最终采用的是显式刷新。原因很简单,自动刷新的具体触发时机由芯片内部决定,而我们的控制器状态机需要精确知道每次刷新事务何时开始、何时结束,才能在刷新期间避免新的读写命令。如果让芯片内部自动刷新,控制器只靠等待时间兜底,一旦刷新期间来了新的读请求,行为就变得不可预测。
刷新命令本身很短,在QPI模式下通常就是一条命令、CS拉低再拉高的过程。真正要设计的是调度策略:什么时候允许插队、一次刷几行、用户请求等待多久可以打断刷新。我们的策略是刷新请求优先,但每次只执行一笔刷新事务;如果刷新请求到达时总线正忙,则置一个刷新挂起标志,等当前读写事务结束后的第一个总线空闲时刻执行。这样既保证了刷新间隔,又不会无限期延迟用户请求。
这部分的RTL可以做到很简单,关键是刷新请求和用户请求不要互相把对方饿死。给一个示意逻辑:
// 刷新定时器:按SPI时钟计数,到达刷新间隔时拉高refresh_pending module refresh_timer #( parameter REFRESH_INTERVAL = 780 // 由刷新窗口和行数折算得到 )( input wire clk, input wire rst_n, output reg refresh_pending ); reg [15:0] cnt; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt <= 16'd0; refresh_pending <= 1'b0; end else if (cnt == REFRESH_INTERVAL - 16'd1) begin cnt <= 16'd0; refresh_pending <= 1'b1; end else begin cnt <= cnt + 16'd1; refresh_pending <= 1'b0; end end endmodule这个refresh_pending信号引入主控状态机后,仲裁逻辑就成了一个简单的优先级判断:空闲状态下先看刷新挂起位,再去响应新的读写请求。刷新命令执行完毕的同时,重新武装计数器。
2.3 PASR局部刷新:省电的代价是丢数据
APS6404L-3SQR这类芯片还支持局部阵列自刷新(Partial Array Self Refresh),通过配置寄存器圈定哪些bank需要刷新,未圈定的区域在低功耗模式下直接不刷。这在低功耗待机场景下很有用,但代价是未刷新区域的数据无法保证留存。
我们一开始在调试长时间空闲后的数据丢失时,误以为是刷新命令没发出去,排查半天才发现是寄存器里误配了局部刷新位,把一部分bank排除在刷新范围之外。这里提醒一句:如果你的应用不需要低功耗待机模式,就确认寄存器配置里没有引入局部刷新使能位,否则整个存储区域的数据可靠性都是没有保障的。
3. 控制器重构:从“一次读写一个大状态机”到“可配置事务引擎”
3.1 为什么初版的状态机无法继续扩展
初版控制器为了快速验证,读写、写寄存器、读ID都用独立的大状态机实现。代码倒是直白,但问题很快暴露:每新增一种操作(比如刷新、进入QPI、配置MR寄存器),都要新写一段状态机,而且不同操作之间的公共部分(CS拉低、逐字节发送、等待延迟)被复制粘贴了好几遍。一旦需要调整整条SPI时钟策略,就要找遍每个状态机逐一修改。
其实SPI类接口的事务模式高度统一:一条命令、几字节地址、可选dummy周期、一段数据。与其为每个操作单独写状态机,不如把一次事务描述成一串执行阶段,用一个通用的解释器状态机去执行。这种做法在很多成熟的SPI控制器IP里都能看到,只是早期觉得“不过是一个简单接口,用不上”,结果被可维护性狠狠教育了。
3.2 事务描述符:把SPI时序交给配置,而不是硬编码
我们把一次QSPI访问拆成若干阶段,每个阶段包含类型、传输字节数、预期的数据方向、等待周期数。
| 阶段类型 | 说明 | 配置参数 |
|---|---|---|
| PH_CMD | 发送1字节命令码 | cmd值 |
| PH_ADDR | 发送地址字段,通常3字节 | 地址寄存器 |
| PH_DUMMY | 等待N个周期,不驱动数据线 | 等待周期数 |
| PH_DATA_OUT | 写出数据,QPI模式一次4bit | 数据来源FIFO |
| PH_DATA_IN | 读入数据,按列宽拼装成字节 | 数据写入FIFO |
| PH_DONE | 事务结束,CS拉高 | 无 |
主控制器做的事情很机械:按描述符取出下一个阶段,执行完毕再取下一条。不同的操作(读、写、刷新、读ID)只是描述符内的阶段组合不同。这样处理的好处是,调整时序时只需要改阶段的等待周期数,而不必动状态机的跳转逻辑。
状态机核心可以压缩成这样一个结构:
case (phase_sel) PH_CMD: begin sck_posedge_count <= 0; if (bit_count == 7) next_phase = PH_ADDR; end PH_ADDR: begin // 3字节地址,按MSB优先逐对发送,QPI下每次发送4bit end PH_DATA_IN: begin // 每个字节需要8个SCLK边沿,边沿到来时采样SIO end endcase这种重构带来的附带收益是,后续如果要从QSPI SDR升级到DDR模式,只需要在阶段执行逻辑中增加对同一个时钟周期内双沿采样的处理,命令/地址/数据的组合描述完全不用变。
3.3 Read/Write Latency:等待周期必须显式编排
QSPI PSRAM和普通NOR Flash有一个关键差别:它存在读写延迟(Latency),也就是发出命令和地址之后,数据并不会立刻出现在总线上。这个延迟由芯片的配置寄存器决定,通常读延迟和写延迟还不一样。控制器如果在发送完命令地址后立即开始采样数据,读到的就是上一笔事务的残留数据。
这也是初版驱动随机字节错误的另一个来源。当我们把Latency周期数作为参数配置进事务描述符之后,问题立刻消失。建议在RTL中把所有等待周期做成寄存器配置项,初始化流程里从芯片中读取当前生效的延迟配置,再转成等待周期数。千万不要在代码里写死某个数值,因为QPI与SPI模式、单倍率与双倍率模式下延迟参数往往是不同的。
3.4 数据侧的FIFO问题:系统时钟和SPI时钟注定不是同一个世界
重构期间我们还补上了FIFO。之前没有FIFO的原因是系统总线和SPI引擎都跑在同一个FPGA系统时钟下,以为不需要跨时钟域。实际上真正的瓶颈在burst传输的流量整形上:系统侧可能一次请求16字节,而SPI侧一次只能推进几个bit;反过来,读请求发出后,数据若干周期后才陆续到达,这一进一出的节奏差异如果没有缓冲承接,主控逻辑要么被频繁反压,要么丢数据。
写数据侧放置写入FIFO,读数据侧放置读取FIFO,SPI引擎负责按可用空间从FIFO取数或者向FIFO写数。FIFO深度按最坏情况估算,简单版本:一次系统侧写请求的字节数乘以系统时钟与SPI有效时钟的比值,再乘上(1+Latency占比),取整到就近的2的幂。我们当时的应用,深度64字节足够覆盖一次16字节突发加上Latency等待的空档,同时可以把系统侧的反压请求数量控制在可接受范围。
4. 实测数据、常用调试手段与后续扩展思路
4.1 不同工作模式下的性能对比
重构完成后,我对同一颗APS6404L-3SQR在几种模式下做了简单吞吐测试,这里列出的是相对参考值,不同FPGA平台和时钟频率下会略有差别:
| 模式 | SCLK频率 | 理论带宽 | 实测读带宽 | 实测写带宽 |
|---|---|---|---|---|
| SPI 1-1-1 | 25MHz | 3.1 MB/s | 2.4 MB/s | 2.5 MB/s |
| QPI SDR | 50MHz | 25 MB/s | 18 MB/s | 19 MB/s |
| QPI DDR | 50MHz | 50 MB/s | 33 MB/s | 30 MB/s |
实测带宽普遍比理论值低20%~30%,这是正常的。原因包括命令地址开销、Latency等待、刷新事务插入,以及FIFO空/满时的气泡周期。如果你的应用对带宽有硬指标,应把这些开销提前算进去,而不是直接按理论值规划。
4.2 调试手段:抓时序、跑误码、查刷新
调试QSPI PSRAM控制器,我常用的三件套是逻辑分析仪抓取、误码率测试脚本、刷新窗口与错误时间的关联分析。
逻辑分析仪主要用来抓CS、SCLK、SIO0~SIO3,核对SPI事务的命令字节和地址字段是否符合预期。特别是在QPI模式下,四根数据线的值拼在一起才是完整的半字节,单看一根线毫无意义,所以抓取时务必把SIO0~SIO3作为一个总线整体查看。
误码率测试不是只跑一遍,而是长时间循环写读固定伪随机序列,比如几十亿字节跑完为止。一旦出现错误,记录下错误地址和错误时间戳,再去对照刷新定时器的触发点。如果错误密集出现在刷新命令之后,大概率是刷新事务与读写事务并行导致的总线冲突;如果错误出现在写入后立即读取的阶段,大概率是Latency配置或采样点问题。
4.3 后续可以做的优化
控制器稳定之后,还有几个方向值得继续投入。一个是在QPI DDR模式下做完整的时序收敛,可以在同等芯片成本下把带宽翻倍,适合图像类应用;另一个是双片PSRAM并联,扩展位宽或容量,但需要确认芯片支持多片通过独立CS挂到同一组SIO总线上,并且总线驱动强度要重新评估;再有一个是把PASR局部刷新和自刷新流程做成可配置电源管理状态,让这块内存有机会进入低功耗待机模式。
5. 写在最后:这件事和时序、刷新、抽象有关,但不止于此
整个调试过程中,最有价值的习惯是把所有“偶发”当作“必然”来对待。随机字节错误看起来像运气问题,追到根子上要么是时序余量不足,要么是状态机漏掉了某个Latency周期,要么是刷新和其他事务发生了不该发生的重叠。这类问题有一个共同特点:它们只在长时间运行或高速数据流下才会暴露,恰好是肉眼检查代码最难发现的一类缺陷。
另外想分享一个细节:刷新定时器的分频系数应该和SPI时钟来自同一个时钟源,不要用独立的慢速时钟去产生刷新请求,否则两个时钟域之间的异步事件跑进状态机会引入全新的时序问题。这块我在初版里踩过,后来统一到同一时钟树上才彻底消停。如果有在设计QSPI PSRAM控制器的朋友,建议直接从模块化事务引擎这个结构起步,刷新、延迟、FIFO这些能力一开始就留好接口,会省掉后面大量的返工时间。