如果_你的SDRAM控制器已经能完成初始化、能发出正确的行激活和读写命令,那么在仿真里看到正确的波形并不是什么难事。但一旦把控制器放到真实工程里,接到读写数据流上,问题就开始冒出来了:为什么连续读的时候数据会断流?为什么刷新一插入整个总线就“卡”一下?为什么明明突发长度是8,实际利用率一半都不到?这个系列我前面两篇聊了SDRAM的基础命令和单笔读写状态机,这篇作为第三篇,我打算把读写数据通路、多Bank调度和仲裁逻辑单独拉出来,讲一些控制器设计里真正影响性能和稳定性的细节。
整篇文章会围绕一个实际的控制器设计场景展开,这里假设我们做的是一个面向图像采集的缓存系统,需要把ADC数据流持续写入SDRAM,同时按帧读取出去。这类应用最怕两件事:写入时数据断流导致丢行,读取时延迟抖动导致花屏。所以读写路径上的每一个时序安排都必须仔细推敲,不能像仿真验证时那样只凑出“正确波形”。
1. 读写路径设计:控制器性能的分水岭
1.1 为什么前两篇的状态机在这里不够用了
前两篇我们设计的状态机,本质上是一个简单的命令发生器:收到读请求就跳READ,收到写请求就跳WRITE,写完或者读完就自动预充电,然后回到IDLE等下一条。这种设计用来验证协议没问题,但放在真实数据流场景下会很快暴露效率问题。最大的问题在于:它把“请求”和“数据”混在了一起。
SDRAM控制器不仅仅是发命令,还要同时管两头:一头是总线上的命令时序,另一头是数据线上的读写数据通路。命令时序大家基本都能按手册画对,但数据通路里有很多工程细节,比如DQS与数据边缘的对齐、读FIFO的存与取、写数据掩码的时机,这些在仿真里常常被简化掉,一到上板就原形毕露。
我之前见过一个项目,仿真里连续写入1024个数据全部正确,但放到真实系统里,写入到256个字节之后就开始丢数据。排查到最后,发现写FIFO的空满信号和写命令的延迟没对齐,数据总线还在给数据的时候控制器已经发完了写命令,被SDRAM直接忽略了。
提示:如果你只是做功能验证,简化数据通路的模型是没问题的;但做实际系统集成,读写数据通路必须和命令状态机一样用同等精力去设计。
1.2 数据总线上的“定向思维”必须纠正
SDRAM的DQ是双向总线,同一时刻只能读或者只能写,这本来没什么好说的,但真正容易忽略的是读与写切换时的总线周转周期。翻转读/写方向时,数据总线需要几个时钟周期的缓冲,否则两个方向的数据在总线上互相踩踏。
我见过有人在连续读后立刻发写命令,写命令本身完全合规,可写数据FIFO的输出在写命令发出后的第一个时钟就直接驱动DQ总线,这时候SDRAM还处于读数据的输出状态,两个驱动源直接把IO引脚打成高阻混战,上板之后轻则数据错乱,重则时序违例。
所以读写数据通路必须有一个“转向仲裁器”,至少要管住三件事:
- 读操作结束后,给总线留出
tRP、tRT(总线周转时间); - 写操作开始前,等到写命令对应的写延迟窗口到来才允许数据FIFO输出;
- 在读写切换期间,DQ方向控制信号必须比数据有效提前一拍。
能够理解这三点,再去设计读写路径就不会心浮气躁。
2. 读数据通路的工程化设计
2.1 DQS边缘对齐与数据采样窗口
读数据的时候,SDRAM不仅返回DQ数据,还会返回DQS信号。DQS是和数据对齐的,但它在FPGA引脚进来之后,并不会自动完美地和内部时钟对齐。你需要决定如何采样读数据。
最可靠的方案是使用FPGA厂商提供的DDR输入寄存器或专用硬件模块,让DQS直接控制IO寄存器。这样,数据由DQS的上升沿和下降沿完成双沿采样,再送到内部逻辑。使用这个方法时要注意一点:DQS并不是连续的,只有在读突发期间才会有跳变。如果控制器设计时没有处理DQS的“使能窗口”,FPGA的输入逻辑会在空闲状态下把DQS的静态电平误当成边沿,导致最终进入FIFO的数据是乱的。
我常用的做法是让控制器在发出READ命令后,根据读延迟参数,在预计DQS到来的前一个周期拉高DQS使能信号,用这个使能信号去选通输入采样逻辑。这个信号必须精确到时钟周期,不能有偏差。实际调试中,DQS使能信号早一拍晚一拍都可能导致读FIFO的“首数据”对不上。
读突发长度为8时,DQS窗口正好是4个时钟周期(双沿采样8个数据)。窗口结束时,要把DQS使能关掉。这个开关动作看似简单,可一旦和连续读命令叠加,就会出问题:前一个读突发的第8个数据和下一个读突发的第1个数据可能是背靠背的,DQS使能不能出现哪怕一个周期的毛刺。
2.2 读FIFO的容量估算与跨时钟域处理
SDRAM控制器通常跑在独立的时钟域,而系统侧可能是不同时钟。跨时钟域方案的选择很讲究。有人喜欢把读数据FIFO做成异步FIFO,直接放在控制器输出端。这个做法本身没问题,但FIFO深度不能拍脑袋决定。
我来算一个实际值:控制器时钟为100MHz,SDRAM时钟也是100MHz,突发长度为8,一次突发读返回8个16bit数据。系统侧要求平均吞吐不低于80MB/s,也就是每秒约4000万次16bit传输,等效每25个时钟周期就要完成一次突发读。SDRAM在读突发期间,从发出READ到最后一个数据返回,需要的时间包括:命令到数据输入的延迟通常3个周期,加上8个数据在双沿上采样需要4个周期,再加上FIFO写入到读取的异步延迟约2个周期。这样一次读操作从发命令到数据全部进入FIFO,大约9-10个时钟周期。
如果这10个周期里系统侧已经消耗掉了部分数据,那么FIFO里堆积的数据量大概在4到5个深度左右。要保证不溢出,FIFO深度至少要大于单次突发长度,再考虑异步FIFO的格雷码同步延迟,我一般取32。很多IP核默认深度是16,在高吞吐场景下就会偶尔溢出,证据就是数据随机丢一拍。
注意:异步FIFO的空满信号本身就有延迟,空满信号到达控制器侧时,FIFO实际状态可能已经变了。设计读数据通路时,一定要把空满信号的判定留出至少2个周期的余量。
2.3 突发接力的实现:连续读时不能等FIFO满了才停
连续读场景下,效率高的做法是让SDRAM背靠背地执行多个读突发。但背靠背读要求你提前把下一笔读命令发出去。这里有个关键:上一笔读数据的DQS窗口与下一笔读命令之间,必须满足tCCD(列到列延迟)要求。对于常见的SDRAM,tCCD是1个时钟周期,但这并不等于可以无条件地逐周期发READ命令,因为你还要考虑行地址是否变化,以及数据总线是否会因为连续读而必须插入额外的间隙。
在控制器里,我通常给读数据通路加一个"读命令预取"机制。也就是说,只要仲裁器已经确定下一笔读命令的行已经激活,且数据总线方向处于读状态,就可以在当前读突发的最后一个数据之前,提前一拍发出下一个READ命令。这个提前量需要根据读延迟精确计算:
假设CAS延迟为3,那么READ命令发出后第3个时钟DQS才有效,数据在第4个时钟上升沿被采样。如果当前读突发最后一个数据在时刻T返回,下一笔读命令最早可以在T-3时钟时刻发出,这样两个读突发之间基本没有气泡。
这个“预取”逻辑如果写成状态机,就是一类特殊的“流水线”状态。很多工程里直接用计数器来延迟,但计数器在出现等待时容易乱,建议把预取状态作为状态机的一个独立分支,同时记录当前bank是否激活等条件。
3. 写数据通路:从写延迟到数据掩码
3.1 写延迟与DQS信号关系
写数据和写命令之间有一个写延迟,通常等于CAS延迟-1。也就是说,你在某个周期发出WRITE命令,数据线上的DQS/DQ并不在这个周期有效,而是要到写延迟之后才能产生DQS的跳变沿和数据。
这个规律看着简单,但和读通路混在一起时容易出错。因为读延迟是固定的,写延迟也是固定的,但读与写之间的总线转向,需要把两者的延迟做代数叠加。比如一个控制器,读延迟是3,写延迟是2。在写完数据之后立刻转为读,读命令可以马上发,但要确保写数据总线已经在写延迟结束后释放,且总线周转时间满足要求。
设计写数据通路时,我建议使用一个移位寄存器组来生成写使能信号。具体做法是:发出WRITE命令后,把写使能放入一个深度为(写延迟+突发长度)的移位寄存器。移位到写延迟时刻,写使能变为有效,并打开DQS输出和三态门。这个移位寄存器可以避免组合逻辑产生毛刺。
3.2 写FIFO的填充时机
写FIFO的作用是缓存系统侧送来的写数据,在WRITE命令发出后,按SDRAM时序把数据送到DQ总线。写FIFO的读操作必须严格跟随写延迟。这里有一个容易犯的错误:在WRITE命令发出的那个周期就去读FIFO,然后想通过链路的延迟来凑时序。这样做的风险是,一旦链路优化调整了路径延迟,整个时序就对不上了。
正确做法是:用写命令作为基准,经过写延迟个时钟周期后,再从写FIFO里出数。这样FIFO读使能的时机是明确的。对于突发长度8的写操作,写FIFO的读使能需要保持4个时钟周期(讲述的是DDR双沿写入)。
写FIFO的深度估算方法与读FIFO类似,但要注意写侧的特殊性:如果系统侧送来的数据是不连续的小包,写FIFO会产生气泡。比如一次写16字节,但系统侧每4个时钟才送2个字节,那么你要么等足够数据凑满一次突发再发WRITE命令,要么就按非整突发写。非整突发会浪费SDRAM带宽,但能让数据流更平滑。
3.3 数据掩码(DQM)在字节写入中的应用
很多控制器设计教程并不会认真讲DQM,因为简单仿真里用不到。但在真实系统中,特别是图像处理、串口通信等场景,经常需要写半个字甚至一个字节。SDRAM数据线是16位,如果只需要写入低8位,而高8位要保持原值,那就必须使用DQM信号。
使用DQM时有一个细节:DQM的时序和写数据完全对齐,即在DQS边沿到来之前,DQM也必须稳定。DQM使能的切换通常比数据有效提前一点点,我一般用与写数据相同延迟的移位寄存器来生成DQM,确保数据在总线上的每一拍,DQM的电平都是对应的。
重要提示:DQM不是字节使能,它只是屏蔽数据写入。在发出WRITE命令时,DQM信号必须和写数据同步到达。如果DQM信号晚了一拍,SDRAM会把不需要屏蔽的数据也写进去,而且完全不会有报错。这类问题在仿真中几乎复现不出来,只能通过上板逐bit校验发现。
4. 多Bank并行调度:把“等”变成“干”
4.1 传统单Bank调度为什么效率低
SDRAM内部有多个Bank,每个Bank有独立的行缓冲区。理论上可以同时保持多个Bank的行处于激活状态,这样在一个Bank需要预充电时,可以切换到另一个Bank去读写,从而把预充电和行激活的时间隐藏起来。
但在最简单的控制器里,往往是一次只激活一个bank,用完就预充电,然后等待tRP结束。这样做其实丢掉了一半以上的性能。以一个图像缓存系统为例,采集一帧图像要写入几百行,每行对应SDRAM中的一个或几个page。如果每写一行就预充电一次,写入一行数据的有效时间可能只有40%,剩下的时间全在等预充电和行激活。
4.2 Bank状态跟踪表与Bank间切换策略
多Bank调度的前提是控制器要准确跟踪每个Bank当前处于什么状态:空闲、行激活、读/写突发、预充电中。这个状态跟踪表相当于一个小的寄存器组,每个bank维护三个状态位:
- 是否激活;
- 当前行地址;
- 是否处于预充电/激活等待计时中。
当系统侧发起一个读写请求时,控制器先根据请求的目标bank查表。如果目标Bank已经激活且行地址相同,这就是一个行命中,可以直接发列命令,效率最高。如果目标Bank处于激活但行地址不同,就必须发预充电,等待tRP后再激活新行,这是行冲突。如果目标Bank是空闲的,则直接发激活命令。
多Bank调度最好的策略是:在某个Bank执行预充电和下一个行激活期间,如果有其他Bank的行命中请求,优先处理。这样可以把tRP和tRCD的时间完全用其他工作填充。
4.3 行命中率优化:排列地址映射
多Bank调度出来后,地址映射策略也会直接影响性能。常见做法是把低bit作为列地址,接着一组bit作为bank地址,再高bit作为行地址。排一下:
- 16位数据总线,突发长度8,一次突发正好占用一个page内的连续字节时需要保证访问连续地址时bank保持不变,尽量让同一个图像行的数据分布于同一个bank的同一行内;
- 如果bank地址上移,连续地址会轮转访问多个bank,天然的银行交错;
- 如果bank地址下移,连续地址更倾向于访问同一个bank的同一个page,行命中率提高。
对于图像缓存这种顺序访问的应用,我比较推荐把列地址放在低位,bank地址放在中间,行地址放在高位。这样顺序写入一帧图像时,每一个page写满之后换到下一个bank,等到最后一个bank的page用完之后回到第一个bank的行会发生变化吗?其实不,因为行地址此时还没有递增。这样安排能让一定尺寸的数据块都落在同一行里,减少行切换。
映射关系可以用可配置参数实现,方便在仿真时比较不同映射的行命中率。我曾经在某项目里测过,仅仅调整地址映射,就把随机访问场景的行命中率从40%提升到75%,吞吐提升非常明显。
5. 刷新与预充电的仲裁逻辑
5.1 刷新请求是怎么“插队”的
SDRAM必须定期刷新,常见的是每64ms刷新8192行,也就是刷新间隔约7.8us。控制器需要周期性地发起刷新命令,并保证相邻刷新命令之间有足够的间隔。
刷新请求必须“插队”,但在插队时不能影响正在执行的数据传输。仲裁逻辑要处理三种请求:系统侧读请求、系统侧写请求、刷新请求。刷新请求的优先级应该最高,但也不能无脑抢占。因为如果当前一个读突发刚发出,立刻插入刷新命令会破坏数据总线状态。正确做法是:刷新请求发出后,等待当前所有bank回到预充电状态,或者至少等待当前命令完成,然后在总线空闲窗口插入刷新命令。
刷新请求本身不是“一瞬间”的,它还会导致所有bank的活动状态被重置。因此,如果当前某个bank正在被频繁访问,刷新一到,所有行激活状态全部失效。这会造成不小的性能损失。解决办法是:把刷新间隔稍微调快一点,但要保证不会超过SDRAM的刷新周期上限。
5.2 预充电策略:自动预充电还是手动预充电
SDRAM支持两种预充电方式,一种是在读写命令里带上自动预充电标志,另一种是单独发PRE命令。
自动预充电的好处是省事,读或写突发结束后,Bank自动进入预充电,控制器不必介入。但它有个坏处:你无法精确控制预充电时机,因为自动预充电在突发结束之后立即启动,如果此时你还想对同一个bank进行下一次读写,就必须等待tRP结束,这个等待时间无法被隐藏。
手动预充电则可以结合多Bank调度把等待时间“藏”到其他bank的访问后面。这就回到刚才说的Bank状态跟踪表:如果你预测到当前bank在下一次读写之前有足够的空档,就可以在最后一次访问该bank时使用自动预充电;如果接下来马上就要再次访问该bank,就不要自动预充电,保留行激活状态。
高级一点的做法是给每个Bank配置一个“预充电决策器”,根据该bank的最近访问频率、队列深度来动态决定是否自动预充电。这个决策器用一个简单的计数器就能实现:如果两个访问请求之间的间隔小于一定阈值,就禁止自动预充电。
5.3 最高优先级请求的掩盖
仲裁逻辑常见的一个坑是:刷新请求有最高优先级,但如果在最坏的时刻发出刷新,会导致后面连续多个请求都等待,实际平均等待时间反而变大。我在设计时,给刷新请求增加了一个“提前请求”信号。即刷新计数器在真正需要刷新之前大约5个时钟周期就发出预刷新请求,仲裁器看到这个信号后,如果当前请求队列不忙,它就提前开始做bank的预充电。到真正刷新窗口到来时,所有bank已经空闲,可以直接插入刷新命令,损失降到最低。
这个预刷新窗口的宽度需要根据SDRAM的tRP时间来确定,一般5个时钟已经足够。如果系统时钟频率更高,可以适当扩大,但不要超过刷新周期的1/8,否则刷新请求会太频繁。
6. 上板验证:我排查过的五个“玄学问题”
6.1 现象一:初始化成功但第一次读返回全F
仿真中从来没有见过全F的问题,上板却碰到了。第一反应通常是读时序采样点不对。排查方法:用ILA抓DQS、DQ和读FIFO写使能,对比读命令发出的时间。如果DQS使能信号比实际DQS早了一拍,采样器会在DQS还没有有效跳变时就去锁存DQ,DQ线上是高阻状态,读回来自然全F。
解决方式:把DQS使能信号延后一点,或者改一下采样时钟的相位。重点是要用ILA观察到DQS的边沿位置再调节,不能盲调。
6.2 现象二:连续写超过两页就丢数据
这个现象几乎可以断定是bank管理出了问题。连续写两页后,第三页地址落在另一个bank或同一bank另一行。如果控制器在行切换前没有正确预充电,那么激活新行时会失败,写入数据被丢弃。查Bank状态跟踪表,重点关注激活命令与预充电命令之间的间隙是否满足tRC。
我遇到过一例:状态跟踪表里bank状态在预充电完成之前就被错误标记为“空闲”,导致控制器在tRP未结束时就发了激活命令。把状态更新的时机从“发出预充电命令”改为“预充电完成”之后,问题消失。
6.3 现象三:仿真波形正常但上板低频都跑不稳
低频通常不会跑不稳,如果低频都不稳,说明不是时序收敛问题,而是引脚约束或IO标准的问题。常见错误是DQS和DQ没有规划在同一个IO Bank,导致两边电压域不一致。强制把DQS和DQ放在同一组差分引脚上,并检查引脚电压标准是否匹配SDRAM要求。
6.4 现象四:刷新后第一条命令就报错
刷新后第一条命令报错,多半是因为刷新期间的自动预充电并未关闭。有些控制器在刷新前将bank置为全空闲,但没等tRFC计数完成就启动了后续命令。查一下刷新命令之后是否给了足够长的刷新恢复时间tRFC,这个时间在大多数SDRAM中是60ns以上,比tRP长得多。很多初学者把tRP和tRFC混在一起,直接把tRP当成刷新恢复时间,导致报错。
6.5 现象五:综合时间暴增,其实只是多了一个if
有一次综合时间暴涨,重新布局布线后时钟频率还下降了。后来发现是在读数据通路的使能信号上做了一段复杂的组合逻辑,代码里写成了几十个if嵌套,被综合成了巨大的多级逻辑。问题根源不是逻辑量太大,而是我在写DQS使能时,用了一个异步信号和一个经过多级延迟的计数器组合。改成对计数器的值做简单比较,再把这个比较结果在时钟沿打一拍,综合时间一下就恢复正常了。
上板调试这件事,很多时候问题不在理论不清楚,而在于信号被层层组合后很难一眼看穿。我现在调试控制器,习惯把所有关键信号都以“内部标记”的形式引到ILA里,包括DQS使能、FIFO空满、Bank状态、刷新提前标记。宁可多用一些探针,也好过出问题后瞎猜。
最后一个实用建议:在控制器里加一个简单的计数器,统计刷新次数和读写请求次数。这个计数器在上板后通过串口回读,可以快速估算实际带宽是否达标。如果统计到的刷新次数远大于预期,检查是不是刷新计数器的预分频写错了;如果读写请求次数正常但吞吐偏低,把时间用在检查Bank调度逻辑上。
SDRAM控制器能写到这个阶段,你已经不是在“控制”一根内存条了,而是在和SDRAM的时序模型打交道。把读写数据通路、多Bank调度和刷新仲裁这三个问题想透,再回头看你仿真里的波形,会忍不住想改掉最初的不少版本。这套设计思路我一直沿用到后来的DDR控制器开发,SDRAM虽老,但里面蕴含的数据路径规划逻辑,到DDR时代依然管用。