【FPGA】SDRAM接口设计与状态机优化实践

📅 发布时间:2026/7/9 14:00:59 👁️ 浏览次数:
【FPGA】SDRAM接口设计与状态机优化实践
1. 从零开始为什么FPGA项目绕不开SDRAM如果你玩过FPGA尤其是做过图像处理、音频缓存或者需要大块数据交换的项目那你肯定遇到过一个问题FPGA片内的Block RAMBRAM不够用了。BRAM速度快、用起来方便但容量就那么点存一张稍微大点的图片都捉襟见肘。这时候你就得把目光投向板子上的那颗“大家伙”——SDRAM芯片。SDRAM同步动态随机存取存储器可以说是很多FPGA开发板的“标配”外设。它容量大、成本低动辄几十兆甚至上百兆字节是充当外部数据缓存的理想选择。但是和FPGA内部直接可用的BRAM不同SDRAM是个“事儿精”。它有一整套严格的“礼仪规范”你必须按照它的规矩来它才肯为你工作。这套规矩就是SDRAM的接口时序协议。很多新手朋友一看到SDRAM的数据手册就头大里面又是Bank又是行激活Row Active又是预充电Precharge还有各种以“t”开头的时序参数tRCD, tRP, tRRC...。感觉比跟人打交道还复杂。没错直接操作SDRAM的物理引脚是非常繁琐且容易出错的。所以我们的核心任务就是在FPGA内部设计一个“外交官”——一个SDRAM接口控制器。这个控制器的核心大脑就是一个精心设计的状态机。它负责把上层应用简单的“读请求”或“写请求”翻译成SDRAM能听懂的一连串标准命令并严格遵守所有的时间间隔要求。我刚开始接触SDRAM时也走了不少弯路。要么是读出来的数据全是乱码要么是写着写着SDRAM就“死机”不响应了。后来才发现问题十有八九都出在状态机的设计上状态跳转的条件没设对或者某个关键状态的等待周期数没给够。这篇文章我就结合自己踩过的坑和优化经验跟你详细聊聊怎么设计一个既稳定又高效的SDRAM接口状态机。我们不求一步登天写出最完美的代码但求理解原理写出一个能跑起来、性能还不错的控制器为后续更复杂的应用打下坚实基础。2. 读懂SDRAM的“语言”关键时序与命令解析在动手写代码之前我们必须当好“学生”先把SDRAM的数据手册读懂。手册是芯片制造商定的“法律”我们的状态机就是根据这部“法律”来办事的。别怕枯燥我们挑最核心的来看。2.1 SDRAM的“住址”系统Bank、Row和Column你可以把SDRAM想象成一个有很多栋楼Bank的小区每栋楼有很多层Row每层有很多个房间Column。存取数据时需要先指定去哪栋楼Bank地址再激活哪一层Row地址最后告诉我要这层的哪个房间Column地址。我们常用的一个23位地址brc_address[22:0]通常就是这样分配的[22:21]是2位Bank地址[20:8]是13位Row地址[7:0]是9位Column地址。这种寻址方式决定了它的操作是分步骤的不能像BRAM那样给个地址立刻拿数据。2.2 你必须记住的几个关键“时间规矩”这是状态机设计时定时常数的直接依据。假设我们的SDRAM时钟是100MHz周期10ns手册里通常会给出最小值我们设计时要留有余量。参数符号含义典型最小值我们设计采用的周期数100MHz实际等待时间tRCD行激活到读/写命令的延迟18ns3个周期30nstRP预充电命令的等待时间18ns3个周期30nstRRC自动刷新周期60ns7个周期70nsCL (CAS Latency)读命令到数据输出的延迟2或3个周期2个周期20nstDPL写数据到预充电的延迟2个周期2个周期20nstMRD加载模式寄存器后的等待时间2个周期3个周期30ns2.3 SDRAM的“指令集”命令的真值表状态机在每个时钟周期都要向SDRAM发送一个4位的命令由{CS_n, RAS_n, CAS_n, WE_n}这4个信号组成。这个命令表你必须烂熟于心因为状态机就是通过在不同状态输出不同命令来驱动SDRAM的。命令CS_nRAS_nCAS_nWE_n地址线A10等关键信号作用模式寄存器设置 (MRS)0000地址线用于配置突发长度、CAS延迟等行激活 (ACTIVE)0011输出Bank地址和行地址读 (READ)0101A100输出Bank和列地址写 (WRITE)0100A100输出Bank和列地址预充电 (PRECHARGE)0010A101对所有Bank预充电A100对当前Bank自动刷新 (AUTO REFRESH)0001地址线无关Don‘t Care空操作 (NOP)0111保持当前状态常用作等待2.4 模式寄存器MRS告诉SDRAM你的工作偏好上电初始化后第一件要紧事就是配置模式寄存器。这就像给SDRAM“上户口”告诉它我们以后打算怎么跟它打交道。配置是通过一次特殊的“MRS”命令完成的此时地址线上的数据不再是地址而是配置码。主要配置三项突发长度 (Burst Length)我们一次读/写连续几个数据常见设为8即一次命令连续传输8个数据。CAS延迟 (CAS Latency)就是上面提到的CL读数据需要等几个时钟周期根据时钟频率选2或3。突发类型 (Burst Type)顺序突发还是交错突发通常选顺序Sequential。在我的代码里这几个参数用参数Parameter定义在了模块开头修改起来非常方便parameter MODE_BURST 3b011, // 突发长度设为8 BURST_TYPE 1b0, // 顺序突发 CAS_LATENCY 3b010, // CAS延迟2 OP_CODE 1b0; // 突发读/突发写模式 parameter BURST_LENGTH 8; // 突发长度83. 状态机设计化繁为简的控制器核心理解了SDRAM的规矩我们就可以开始设计控制它的“大脑”——状态机了。手册里给的状态图往往很复杂包含了所有可能的状态路径。但对我们实现一个基础控制器来说可以抓住主线进行简化。我们的目标是完成初始化、响应刷新、处理读写请求。3.1 状态定义我们到底需要哪些状态我采用了8个独立的状态用独热码One-Hot编码虽然多用了一些触发器但状态译码简单在FPGA里运行效率高。localparam POWAIT 8b0000_0001, // 上电等待200us PRECH 8b0000_0010, // 预充电 MDREST 8b0000_0100, // 模式寄存器设置 IDLE 8b0000_1000, // 空闲等待命令 AUREFR 8b0001_0000, // 自动刷新 ROWACT 8b0010_0000, // 行激活 READ 8b0100_0000, // 读操作 WRITE 8b1000_0000; // 写操作POWAITSDRAM上电后需要一段稳定时间通常是200us。这个状态就是傻等。PRECH发送预充电命令并等待tRP时间。MDREST发送模式寄存器设置命令并等待tMRD时间。IDLE最重要的状态。在这里判断刷新时间到了吗有读写请求吗AUREFR发送自动刷新命令并等待tRRC时间。SDRAM需要每64ms刷新8192行算下来每7.8us就要发起一次刷新。ROWACT发送行激活命令并等待tRCD时间。READ/WRITE发送读/写命令并完成整个突发传输。3.2 状态跳转逻辑清晰的流程图思维状态机的第二部分也是容易出错的部分就是定义状态之间何时跳转。我画了一个简化的思维导图来帮助理解POWAIT (等待200us) | v PRECH (预充电所有Bank) |----------------- (若初始化未完成) | | v (初始化完成) v IDLE ------------------- AUREFR (执行8次刷新) | | | (刷新时间到/有刷新请求) | (8次刷新完成) |-----------------------------| | | (有读写请求) v ROWACT (激活目标Bank和Row) / \ / \ v v WRITE READ | | v v PRECH PRECH | | v v IDLE IDLE用代码来表示关键跳转条件核心思想就是“等待计数器结束 特定标志位”// 举例从IDLE状态跳出的条件 assign idle2aurefr (state_c IDLE) (refresh_flag || end_cnt_ref); // 需要刷新 assign idle2rowact (state_c IDLE) (rd_flag || wr_flag); // 有读写请求 // 举例从AUREFR状态跳出的条件 assign aurefr2idle (state_c AUREFR) (init_flag) (end_cnt); // 初始化完成后刷新一次就回IDLE assign aurefr2mdrest (state_c AUREFR) (~init_flag) (end_cnt); // 初始化过程中完成8次刷新后去配置模式寄存器这里有个关键点初始化过程中的刷新。上电后在配置模式寄存器之前需要先执行8次具体次数依芯片而定自动刷新以确保存储阵列稳定。所以状态流是POWAIT - PRECH - AUREFR (执行8次) - MDREST - IDLE。3.3 状态机中的“计时器”参数化设计每个需要等待的状态除了IDLE都需要一个计数器。这个计数器的最大值X就是我们在2.2节中确定的那些周期数。在状态机中我用一个组合逻辑块根据当前状态来分配X的值always (*) begin if (state_c POWAIT) begin X POWAIT_TIME; // 20000个周期 100MHz 200us end else if (state_c PRECH) begin X TRP; // 3 end else if (state_c MDREST) begin X TMRD; // 3 end else if ((state_c AUREFR) (~init_flag)) begin X 8 * TRRC; // 初始化时8次刷新等待 end else if ((state_c AUREFR) (init_flag)) begin X TRRC; // 初始化后单次刷新等待 end else if (state_c ROWACT) begin X TRCD; // 3 end else if (state_c WRITE) begin X BURST_LENGTH TDPL; // 突发长度 写后到预充电延迟 end else if (state_c READ) begin X BURST_LENGTH CAS_TIME; // 突发长度 CAS延迟 end else begin X 0; end end这样做的好处非常明显所有时序参数都在模块开头的parameter区域定义。如果换用不同速度的SDRAM芯片或者改变时钟频率只需要修改那几个参数而不需要到状态机深处去改数字大大提高了代码的可维护性和可移植性。4. 关键逻辑与代码实现细节状态机的骨架搭好了还需要填充血肉也就是各个状态下的具体输出逻辑。这部分直接关系到SDRAM能否正确响应。4.1 命令Command的生成命令的输出需要严格遵循时序。我的经验是在状态跳转的时刻即条件满足的同一个时钟沿输出对应的命令并在下一个状态维持NOP命令除非需要发送新的命令。always (posedge clk or negedge rst_n) begin if(!rst_n) begin command NOOP_CMD; end else if(aurefr2mdrest) begin // 即将跳转到MDREST状态时 command MRS_CMD; end else if(idle2rowact) begin // 即将跳转到ROWACT状态时 command BA_CMD; // Bank Active命令 end else if(rowact2read) begin command READ_CMD; end else if(rowact2write) begin command WRITE_CMD; end else if(powait2prech || read2prech || write2prech) begin command PAB_CMD; // 预充电所有Bank end else if(prech2aurefr || idle2aurefr) begin command AF_CMD; // 自动刷新 end else begin command NOOP_CMD; // 其他时候都发NOP end end注意命令信号需要直接赋值给输出给SDRAM的{sdram_cs_n, sdram_ras_n, sdram_cas_n, sdram_we_n}。4.2 地址Address与数据掩码DQM的生成地址线在不同命令下意义不同必须正确复用。MRS命令时地址线输出的是配置码{Bank, OP_CODE, CAS Latency, Burst Type, Burst Length}。行激活命令时输出{Bank地址 行地址}。读/写命令时输出{Bank地址 A10(通常为0) 列地址}。A10在读写命令中控制是否在突发传输后自动预充电我们通常设为0不自动预充电由状态机显式控制预充电更稳妥。预充电命令时A10位需要拉高1‘b1 10表示对所有Bank进行预充电。数据掩码DQM在写数据时使用可以屏蔽某些字节。在我们的简单实现中通常在整个突发写期间将其置为低不屏蔽。但需要注意在写突发结束到发起预充电命令之间有tDPL的时间要求这段时间数据线需要保持高阻或稳定有的设计会通过拉高DQM来屏蔽数据。4.3 数据总线DQ的三态控制与读数据有效信号这是接口的“数据通道”处理不好会导致总线冲突或数据采样错误。写操作在进入WRITE状态时将三态输出使能dq_out_en拉高将用户数据din送到sdram_dq_out。在突发写结束时根据计数器判断提前拉低使能。读操作读操作不需要我们驱动数据线。关键点在于读数据有效信号dout_vld的生成。由于CAS延迟CL2的存在SDRAM会在发出读命令后的第2个时钟周期才在数据线上输出第一个数据。因此我们需要对“读状态”进行延迟打拍always (posedge clk or negedge rst_n) begin if(!rst_n) begin dout_vld_r0 0; dout_vld_r1 0; dout_vld_r2 0; end else begin dout_vld_r0 (state_c READ); // 第一拍 dout_vld_r1 dout_vld_r0; // 第二拍 dout_vld_r2 dout_vld_r1; // 第三拍此时数据有效 end end assign dout_vld dout_vld_r2; // 将打拍后的信号作为有效信号输出同时从sdram_dq_in端口输入的数据直接赋值给dout即可。dout_vld信号告诉上游模块比如FIFO何时可以取走dout上的数据。4.4 刷新机制的实现不可或缺的“保养”SDRAM是动态存储器数据电荷会泄漏必须定期刷新。我们用一个独立的计数器cnt_ref来计时每计满7.8us780个周期100MHz就拉起refresh_flag标志。assign add_cnt_ref init_flag; // 初始化完成后才开始刷新计时 assign end_cnt_ref add_cnt_ref (cnt_ref AUFRESH_TIME - 1); always (posedge clk or negedge rst_n) begin if(!rst_n) begin refresh_flag 0; end else if((~refresh_flag) (end_cnt_ref)) begin refresh_flag 1b1; // 时间到请求刷新 end else if((refresh_flag) (idle2aurefr)) begin refresh_flag 1b0; // 刷新请求已被状态机响应清除标志 end end状态机在IDLE状态检测到refresh_flag为高就会优先跳转到AUREFR状态执行刷新操作。刷新完成后回到IDLE。这里有一个重要的优化点如果状态机正在执行长时间的连续读写比如全页突发而刷新时间到了怎么办一种简单的策略是让当前读写操作完成回到IDLE后再执行刷新。虽然可能略微超时但对于大多数应用偶尔的微小延迟是可接受的。更复杂的控制器会支持“打断”当前操作插入刷新但设计难度会大大增加。5. 性能优化与稳定性提升实战一个能跑起来的状态机只是第一步一个优秀的状态机还需要考虑性能和稳定性。下面分享几个我实践中总结的优化技巧。5.1 流水线化操作隐藏延迟提升带宽基础状态机是顺序执行的激活tRCD- 读/写 - 预充电tRP- 下一次激活... 你会发现每次访问不同行都有大量的时间花在等待上tRCDtRP。如果我们把不同Bank的操作流水线化就能隐藏这些延迟。 SDRAM通常有4个Bank它们可以独立进行行激活。优化思路是当Bank0正在读取数据时可以提前预充电Bank1并为Bank2发送行激活命令。这样当Bank0的数据读完后可以立刻开始读Bank1或Bank2而无需等待预充电和激活时间。实现这样的控制器状态机需要同时跟踪多个Bank的状态复杂度呈指数上升通常需要引入仲裁逻辑和更复杂的命令队列。对于新手我建议先实现单Bank流水即连续访问同一行不同列时可以省略预充电和激活步骤再逐步挑战多Bank并行。5.2 突发长度与效率的权衡突发长度Burst Length设得越大一次命令传输的数据越多命令开销的比例就越小理论带宽利用率越高。但缺点也很明显如果每次只需要读写少量数据长的突发会读写多余的数据反而降低效率并且会延迟对其他访问请求的响应。在我的示例中设为8是一个比较折中的值。你可以根据你的应用场景调整这个参数。例如用于视频帧缓冲数据访问通常是连续的大块可以尝试使用全页突发512。而用于零散的数据包存储可能突发长度设为4或1更合适。5.3 仿真验证Modelsim是你的好朋友写SDRAM控制器不仿真等于“盲人摸象”。你必须搭建一个完整的测试平台Testbench。使用SDRAM仿真模型Micron等厂商官网会提供对应芯片的Verilog仿真模型。在TB中例化这个模型和你的控制器。编写初始化、读写测试序列模拟上电、初始化然后发起一系列随机的读写请求。请求的地址最好能覆盖不同的Bank和行以测试边界情况。观察波形重点查看上电200us等待是否满足。初始化序列预充电-8次刷新-模式寄存器设置是否正确。刷新周期是否规律约7.8us一次。读/写命令与数据对齐关系是否正确特别是读数据的CAS延迟。命令之间的时间间隔tRCD, tRP等是否满足手册要求。数据总线在三态切换时有无冲突。自动化检查在TB中编写检查器Checker自动对比写入的数据和读出的数据是否一致并报告错误。这比人眼盯波形要可靠得多。5.4 实际调试技巧示波器与SignalTap II仿真通过后下载到FPGA板子问题可能才真正开始。逻辑分析仪如SignalTap II这是调试FPGA内部信号的利器。把关键状态信号state_c、命令信号、地址信号、数据信号、各种标志位refresh_flag,busy等抓出来看。对比波形和仿真波形看实际运行中状态跳转、时序是否符合预期。示波器用于测量FPGA引脚输出到SDRAM芯片的实际物理时序特别是时钟与数据、时钟与命令之间的建立保持时间Setup/Hold Time。如果布线质量差或时序约束没做好可能导致数据采样错误。确保你的FPGA工程对SDRAM时钟和数据端口做了正确的输入/输出延迟约束。我印象最深的一次调试是仿真一切正常但板上跑起来数据偶尔出错。用SignalTap抓波形发现dout_vld信号有时会多出一个周期的高脉冲。最后排查发现是在状态机从READ跳回PRECH的判断条件里计数器end_cnt的计算在某个边界条件下出现了毛刺。通过将组合逻辑的判断条件用寄存器打一拍再使用问题就解决了。这个小坑告诉我状态机的跳转条件要尽量纯净避免使用复杂的组合逻辑直接作为跳变条件。