跳转到主要内容
Chang Wei's Blog昌维的博客

Article

从零开始用 SystemVerilog 手写一个 SDRAM 控制器

2026年6月15日星期一 12:00Chang Wei (昌维) <changwei1006@gmail.com>zh-Hans-CN
永久链接:

本文面向已经会写一点 SystemVerilog,但几乎完全不了解内存芯片工作原理的读者。我们会从“为什么内存不能像寄存器一样直接读写”开始,一步步讲到 SDRAM(Synchronous Dynamic Random Access Memory,同步动态随机存取存储器)的内部结构、命令编码、初始化流程、刷新机制、读写时序,最后通过我写的开源项目 SDRAM-Controller 逐段分析真实 RTL 代码。

SDRAM 控制器把简单的用户读写请求翻译成复杂的芯片命令和时序
SDRAM 控制器把简单的用户读写请求翻译成复杂的芯片命令和时序


一、为什么需要“内存控制器”?

如果你刚学 SystemVerilog,很容易把“存储”想象成这样:

logic [31:0] mem [0:1023];
 
always_ff @(posedge clock) begin
  if (write_enable) begin
    mem[address] <= write_data;
  end
 
  read_data <= mem[address];
end

这段代码描述的是一种非常理想化的片上 RAM:给一个地址,写使能有效就写进去;下一拍或者同一拍就能读出来。FPGA 内部的 Block RAM、简单的寄存器堆,确实可以用类似方式理解。

但是外部 SDRAM 不是这样工作的。你不能只给它一个地址和一个写使能,然后期待它自动把数据吐出来。外部 SDRAM 是一颗独立芯片,它有自己的时钟、地址脚、Bank 选择脚、数据脚、片选脚、行选通脚、列选通脚、写使能脚,还有一套严格的命令表和时序约束。你想读一个地址,实际要经历“打开某个 Bank 的某一行、等待行激活延迟、发出读命令、等待 CAS 延迟、采样数据、关闭或自动关闭该行”等步骤。你想写一个地址,也要经历类似流程。

所以,内存控制器(Memory Controller) 的作用就是做翻译:

  • 对上层逻辑,它暴露一个尽量简单的接口,比如 requestwrite_enableaddresswrite_dataread_dataresponse
  • 对 SDRAM 芯片,它负责产生 CS_NRAS_NCAS_NWE_NBAADDRDQMDQ 等硬件信号。
  • 在两者之间,它用有限状态机(FSM,Finite State Machine)管理所有等待周期、初始化命令、自动刷新、读写命令和数据总线方向。

可以把它想象成一个“内存翻译官”。CPU、FPGA 逻辑或你的应用模块只说:“我要读地址 A”或者“我要把数据 D 写到地址 A”。控制器则要替你对 SDRAM 芯片说一长串非常讲究时机的话:“先等上电稳定;先 Precharge All;再 Auto Refresh 八次;再写 Mode Register;现在打开 Bank 0 Row 3;等 tRCD;发 READ;再等 CAS Latency;现在采数据。”

flowchart LR user["用户逻辑<br/>request / write_enable / address / data"] --> controller["SDRAM Controller<br/>状态机 + 计数器 + 命令编码"] controller --> chip["SDRAM 芯片<br/>CS/RAS/CAS/WE/BA/ADDR/DQ"] chip --> controller controller --> user classDef box fill:#eef6ff,stroke:#3578c8,stroke-width:1px,color:#0f2742; class user,controller,chip box;

我写的这个 SDRAM-Controller 项目,就是一个用 SystemVerilog 写的 SDRAM 控制器。它的特点是:

  • 用参数支持不同工作频率、CAS Latency、Burst Length、Burst Type、Write Burst Mode。
  • 对外提供简单的 request/response 握手接口。
  • 对内实现 SDRAM 上电初始化、Mode Register 配置、自动刷新、读操作、写操作。
  • 在 Quartus/ModelSim 环境下做过仿真和 FPGA 顶层连接。

接下来我们不急着看代码,先把 SDRAM 的“世界观”建立起来。否则看到 RAS_NCAS_NtRCDtRPtCAC 这些名字时,你只会觉得它们像魔法常数。


二、DRAM 和 SRAM 到底差在哪里?

理解 SDRAM 之前,先要理解 DRAM(Dynamic Random Access Memory,动态随机存取存储器)和 SRAM(Static Random Access Memory,静态随机存取存储器)的差别。

SRAM 通常用 6 个晶体管构成一个 bit 的锁存结构。只要供电存在,数据就能稳定保持,所以叫 Static。它速度快、控制简单,FPGA 内部的缓存、寄存器堆、CPU 的 L1/L2 Cache 很多都使用 SRAM。但是 SRAM 面积大,单 bit 成本高,不适合作为大容量主存。

DRAM 则更省。一个 bit 主要由一个电容和一个晶体管构成。电容有电表示 1,没电表示 0。这个结构极其节省面积,所以可以做到很大容量。但电容会漏电,过一段时间电荷就会慢慢消失。因此 DRAM 必须不断“刷新”(Refresh),也就是定期把每一行读出来再写回去,补充电荷。

DRAM 1T1C 存储单元结构图:单个 bit 由一个晶体管和一个电容组成,电容储存电荷表示数据
DRAM 1T1C 存储单元结构图:单个 bit 由一个晶体管和一个电容组成,电容储存电荷表示数据

这就是 DRAM 里 “Dynamic” 的含义:数据不是静静躺在那里永久不动,而是必须周期性维护。对控制器来说,这意味着你不能只处理用户读写请求,还要在后台定时插入 Refresh 命令。

项目SRAMDRAM / SDRAM
单 bit 结构多晶体管锁存器主要是电容 + 晶体管
是否需要刷新不需要需要
速度相对慢
面积
容量较小很大
控制难度简单复杂
常见用途Cache、寄存器堆、FPGA Block RAM主存、显存、开发板外部内存

2.1 为什么读 DRAM 会“破坏”数据?

DRAM 的一个初学者容易忽略的事实是:读取本身会破坏电容里的电荷状态

一个 DRAM bit cell 很小,电容上的电压也很微弱。读取时,需要把这一行 cell 连接到位线(bitline)上,再由灵敏放大器(sense amplifier)判断电压偏高还是偏低。这个过程会扰动原来的电荷,所以读完之后必须把结果重新写回 cell。DRAM 内部会自动完成这个“恢复”动作,但前提是控制器必须遵守芯片规定的时序。

这解释了为什么 SDRAM 有“行激活(ACT)”这个动作。你不是直接读一个 bit,而是先把一整行打开,让这一行的数据进入 sense amplifier;之后再用列地址从这一行里选择你要的数据。

2.2 SDRAM 的 S 是什么?

早期 DRAM 的控制时序和外部时钟没有严格同步,控制器要靠各种异步信号宽度满足芯片要求。SDRAM 的 S 是 Synchronous(同步),意思是命令、地址和数据的关键动作都参考同一个时钟边沿。控制器在时钟上升沿给出命令,SDRAM 在时钟上升沿采样。

这对数字设计工程师很友好:我们可以用 SystemVerilog 的 always_ff @(posedge clock) 写状态机,用周期计数器表达 datasheet 里的 tRCDtRPtRCtMRD 等时序。

但是同步不等于简单。同步 SDRAM 仍然要求你严格等待指定周期。例如发完 ACT 后,不能下一拍立刻 READ,而要等 tRCD;发完 READ 后,数据不会立刻出现,而要等 CAS Latency;发完 REF 后,也不能立刻做其他命令,而要等 tRC


三、从“二维数组”到 Bank/Row/Column

很多软件工程师理解内存时,会把内存看成一个巨大的线性数组:

memory[0], memory[1], memory[2], memory[3], ...

但 SDRAM 芯片内部更像一个多层结构:先分 Bank,再分 Row,再分 Column。

SDRAM 内部由多个 Bank 组成,每个 Bank 里再按 Row 和 Column 组织存储单元
SDRAM 内部由多个 Bank 组成,每个 Bank 里再按 Row 和 Column 组织存储单元

在这个项目里,控制器参数默认是:

bank_count = 2,
row_count = 13,
column_count = 10,

这里的 bank_count = 2 不是“两个 Bank”,而是 Bank 地址宽度为 2 bit,所以可以选择 2^2 = 4 个 Bank。row_count = 13 表示行地址 13 bit,column_count = 10 表示列地址 10 bit。因此一个完整地址被拆成:

wire [ 1:0] bank = address[24:23];
wire [12:0] row_address = address[22:10];
wire [ 9:0] column_address = address[ 9: 0];

这段代码来自 rtl/sdram_controller.sv。它做了一件非常关键的事:把用户眼中的 25-bit 线性地址,拆成 SDRAM 芯片真正理解的 Bank、Row、Column。

可以这样理解:

address[24:23] -> 选择哪一个 Bank
address[22:10] -> 选择该 Bank 里的哪一行 Row
address[ 9: 0] -> 选择已打开 Row 里的哪一列 Column

3.1 为什么要分 Row 和 Column?

如果 SDRAM 有几千万甚至几亿个存储单元,不可能给每个存储单元都拉一根独立地址线。芯片使用复用地址的方式节省引脚:同一组 DRAM_ADDR 引脚,有时表示 Row Address,有时表示 Column Address。

所以读写一个地址要分两步:

  1. 用 ACT(Active)命令把某个 Bank 的某一行打开,此时 DRAM_ADDR 表示 Row Address。
  2. 用 READ 或 WRITE 命令选择这一行里的某一列,此时 DRAM_ADDR 的低位表示 Column Address。

这就是 SDRAM 时序复杂的根源之一:地址不是一次性送完,而是“先行后列”。行打开以后还要等待内部 sense amplifier 稳定,这就是 tRCD;列读命令发出以后还要等待数据从内部阵列到达数据引脚,这就是 CAS Latency。

3.2 Bank 的意义是什么?

Bank 可以理解为 SDRAM 内部相对独立的子阵列。多个 Bank 允许控制器做更高级的交错访问。例如当 Bank 0 正在等待某个时序时,理论上可以提前激活 Bank 1 的另一行,从而提高吞吐量。

我这个项目的控制器采用了教学上更容易理解的保守策略:一次处理一个请求,并且读写命令使用 Auto Precharge,让芯片在突发访问后自动关闭当前行。这样实现简单,不追求最高性能,但非常适合学习 SDRAM 控制器的基本流程。


四、SDRAM 命令不是“指令”,而是一组控制脚组合

学习 SDRAM 时,最容易被 ACTREADWRITEREFMRS 这些名字误导。它们看起来像 CPU 指令,但在硬件上,SDRAM 并没有一条“命令总线”。所谓命令,本质上是几个控制引脚在时钟边沿上的组合:

  • CS_N:Chip Select,片选,低有效。
  • RAS_N:Row Address Strobe,行地址选通,低有效。
  • CAS_N:Column Address Strobe,列地址选通,低有效。
  • WE_N:Write Enable,写使能,低有效。
  • BA:Bank Address,选择 Bank。
  • ADDR:地址脚,在不同命令里含义不同。

注意这些信号名字后面的 _N。这表示低有效。比如 RAS_N = 0 才表示 RAS 被选通。

这个项目把命令编码封装在 rtl/command.sv 里,写成 SystemVerilog task。这样主状态机里可以直接调用 ACT(...)READ(...)WRITE(...),可读性比每个状态都手写信号组合好很多。

下面是几个典型命令的真实代码。

// No operation (NOP)
task NOP;
begin
  DRAM_CS_N = 0;
  DRAM_RAS_N = 1;
  DRAM_CAS_N = 1;
  DRAM_WE_N = 1;
end
endtask

NOP(No Operation,无操作)不是“什么都不输出”,而是输出一组被 SDRAM 识别为“本拍没有新命令”的控制组合。等待时序时,控制器基本都在发 NOP。

// CBR Auto-Refresh (REF)
task REF;
begin
  DRAM_CS_N = 0;
  DRAM_RAS_N = 0;
  DRAM_CAS_N = 0;
  DRAM_WE_N = 1; 
end
endtask

REF(Auto Refresh,自动刷新)命令用于让 SDRAM 内部刷新一批行。控制器只要按周期发 REF,芯片内部会自己推进刷新地址。

// Bank activate (ACT)
task ACT(
  logic [ 1:0] bank,
  logic [12:0] row_address
);
begin
  DRAM_CS_N = 0;
  DRAM_RAS_N = 0;
  DRAM_CAS_N = 1;
  DRAM_WE_N = 1;
  DRAM_BA = bank;
  DRAM_ADDR = row_address;
end
endtask

ACT 命令打开某个 Bank 的某一行。这里 DRAM_BA 是 Bank,DRAM_ADDR 是 Row Address。发完 ACT 以后,控制器必须等待 tRCD,不能立刻 READ/WRITE。

// Read (READ)
task READ(
  logic [ 1:0] bank,
  logic [ 9:0] column_address,
  logic need_auto_precharge
);
begin
  DRAM_CS_N = 0;
  DRAM_RAS_N = 1;
  DRAM_CAS_N = 0;
  DRAM_WE_N = 1;
  DRAM_BA = bank;
  DRAM_ADDR[9:0] = column_address;
  DRAM_ADDR[10] = need_auto_precharge;
end
endtask

READ 命令选择某个 Bank 当前已打开行里的某一列。这里 DRAM_ADDR[10] 很重要:它是 Auto Precharge 位。设置为 1 表示读完这个 burst 后自动预充电,也就是自动关闭这一行。这样控制器不用额外发 PRE 命令,状态机更简单。

// Write (WRITE)
task WRITE(
  logic [ 1:0] bank,
  logic [ 9:0] column_address,
  logic need_auto_precharge
);
begin
  DRAM_CS_N = 0;
  DRAM_RAS_N = 1;
  DRAM_CAS_N = 0;
  DRAM_WE_N = 0;
  DRAM_BA = bank;
  DRAM_ADDR[9:0] = column_address;
  DRAM_ADDR[10] = need_auto_precharge;
end
endtask

WRITE 和 READ 只差 WE_N。这也是 SDRAM 命令表的特点:命令不是 opcode 字段,而是控制脚组合。

命令CS_NRAS_NCAS_NWE_NADDR / BA 的含义
NOP0111忽略
ACT0011BA 选 Bank,ADDR 选 Row
READ0101BA 选 Bank,ADDR 低位选 Column,A10 可控制 Auto Precharge
WRITE0100BA 选 Bank,ADDR 低位选 Column,A10 可控制 Auto Precharge
REF0001Auto Refresh
MRS0000写 Mode Register
PALL0010A10 = 1,Precharge All Banks

当你理解这张表后,command.sv 就不再神秘。它只是把 datasheet 里的命令表搬进了 SystemVerilog task。


五、时序参数:把 datasheet 的纳秒换成时钟周期

SDRAM datasheet 里会写很多以 t 开头的参数,例如:

  • tRCD:ACT 到 READ/WRITE 之间的最小延迟。
  • tRP:PRECHARGE 到下一次 ACT 之间的最小延迟。
  • tRC:一次 ACT/REF 到下一次 ACT/REF 的最小周期。
  • tMRD:Mode Register Set 后到下一条命令之间的最小延迟。
  • tCAC 或 CAS Latency:READ 命令到数据有效之间的延迟。

这些参数本来可能以 ns(纳秒)表示。但同步控制器用时钟驱动,所以最终要换成“等几个 clock cycle”。项目中的 rtl/parameter.sv 就承担这个职责:

localparam bit_width = 8;
 
// tCAC CAS_Latency
function [bit_width-1:0] tCAC(logic [bit_width-1:0] clock_frequency);
  unique case (clock_frequency)
    166 : tCAC = 3;
    133 : tCAC = 2;
    100 : tCAC = 2;
    default: tCAC = 2;
  endcase
endfunction

这里用工作频率选择 CAS Latency。比如 100 MHz 下返回 2,166 MHz 下返回 3。真实设计中这个表应该来自具体 SDRAM 型号的 datasheet,因为不同速度等级芯片的要求不一样。

// tRCD Active Command To Read/Write Command Delay Time
function [bit_width-1:0] tRCD(logic [bit_width-1:0] CAS_Latency);
  unique case (CAS_Latency)
    3 : tRCD = 3;
    2 : tRCD = 2;
    default: tRCD = 3;
  endcase
endfunction

这段表示如果 CAS Latency 是 2,那么 ACT 以后等 2 个周期再发 READ/WRITE;如果 CAS Latency 是 3,则等 3 个周期。它把 datasheet 的时序约束抽象成一个函数,让主状态机不用关心具体数值。

// tRC Command Period (REF to REF / ACT to ACT)
function [bit_width-1:0] tRC(logic [bit_width-1:0] CAS_Latency);
  unique case (CAS_Latency)
    3 : tRC = 10;
    2 : tRC = 7;
    default: tRC = 10;
  endcase
endfunction

tRCtRCD 大得多,因为它约束的是一轮完整行操作或刷新操作的周期。刷新时,控制器发出 REF 后必须等 tRC 才能回到 idle。

5.1 为什么这些函数看起来和 CAS Latency 绑定?

严格来说,tRCDtRPtRC 并不是由 CAS Latency 决定的,而是都由芯片速度等级、工作频率和 datasheet 约束共同决定。这个项目为了简化,把它们和 CAS Latency 做了对应表:CAS Latency 2 时用一组周期数,CAS Latency 3 时用另一组周期数。

这种写法适合学习和固定开发板实验。真正产品化的内存控制器通常会:

  • 以 ps/ns 为单位记录 datasheet 参数。
  • 根据实际时钟周期自动向上取整。
  • 支持不同芯片的 timing profile。
  • 区分 tRCDtRPtRCtWRtRAStRFC 等更多参数。

但作为入门项目,现在这种表驱动方式有一个优点:你能清楚看到状态机到底要等几个周期


六、Mode Register:告诉 SDRAM 你想怎么工作

SDRAM 上电后不会自动知道你想用什么 burst 长度、CAS Latency、burst 类型。控制器必须通过 MRS(Mode Register Set,模式寄存器设置)命令写入配置。

项目中的 MRS task 如下:

task MRS(
  logic write_burst_mode, // 0: Programmed Burst Length, 1: Single Location Access
  logic [1:0] operating_mode, // All Other States Reserved
  logic [2:0] latency_mode,
  logic burst_type, // 0: Sequential, 1: Interleaved
  logic [7:0] burst_length
);
begin
  DRAM_CS_N = 0;
  DRAM_RAS_N = 0;
  DRAM_CAS_N = 0;
  DRAM_WE_N = 0;
  DRAM_BA = 2'b00;
  DRAM_ADDR = {3'b000, write_burst_mode, operating_mode, latency_mode, burst_type, decode_burst_length(burst_length)};
end
endtask

这段最关键的是最后一行。MRS 命令不是通过数据总线写寄存器,而是把模式字段编码到地址线上。在 MRS 这一拍,DRAM_ADDR 不再表示普通地址,而表示 Mode Register 的各个配置位。

decode_burst_length 把人类可读的 burst length 转换为 Mode Register 编码:

function [2:0] decode_burst_length(logic [7:0] burst_length);
begin
  unique case (burst_length)
    1: decode_burst_length = 3'b000;
    2: decode_burst_length = 3'b001;
    4: decode_burst_length = 3'b010;
    8: decode_burst_length = 3'b011;
    0: decode_burst_length = 3'b111;
    default: begin
      decode_burst_length = 3'b100;
    end
  endcase
end
endfunction

SDRAM 的 burst 是什么?简单说,就是一次 READ 或 WRITE 命令后连续传输多个数据。Burst Length = 1 表示一次命令只读/写一个数据;Burst Length = 4 表示一次命令后连续读/写 4 个数据。这个项目默认:

write_burst_mode = 1,
burst_type = 1,
burst_length = 1,
CAS_Latency = tCAC(clock_frequency_mhz)

也就是说,它更偏向“单次地址访问”的简化控制器,不做复杂 burst 数据流管理。这对初学者很重要,因为一旦引入 burst,状态机还要管理连续数据节拍、DQM、列地址递增和响应时机。


七、整个控制器的外部接口

现在可以看主模块 sdram_controller 了。先看模块参数和端口:

module sdram_controller #(
  clock_frequency_mhz = 100,
  clock_stable_ns = 250_000, // Power-up: VCC and CLK stable T=200us Min
  initiate_refresh_count = 8,
 
  bank_count = 2,
  row_count = 13,
  column_count = 10,
 
  // Mode Register Set
  write_burst_mode = 1,
  burst_type = 1,
  burst_length = 1,
  CAS_Latency = tCAC(clock_frequency_mhz)
) (
  // bus
  input logic request,
  output logic response,
  input logic write_enable,
  input logic [24:0] address,
  output logic [31:0] read_data,
  input logic [31:0] write_data,
  output logic initiated,
 
  // DRAM 
  output logic [12:0] DRAM_ADDR,
  output logic [ 1:0] DRAM_BA,
  output logic DRAM_CAS_N,
  output logic DRAM_CKE,
  output logic DRAM_CLK,
  output logic DRAM_CS_N,
  inout wire [31:0] DRAM_DQ,
  output logic [ 3:0] DRAM_DQM,
  output logic DRAM_RAS_N,
  output logic DRAM_WE_N,
 
  input logic clock, reset
);

端口分两组看会清楚很多。

第一组是给用户逻辑用的“简化总线”:

  • request:用户发起一次读或写请求。
  • response:控制器完成本次请求。
  • write_enable:为 1 表示写,为 0 表示读。
  • address:线性地址。
  • write_data:写入数据。
  • read_data:读出数据。
  • initiated:初始化完成,用户可以开始发请求。

第二组是直接连到 SDRAM 芯片的真实管脚:

  • DRAM_ADDR:复用地址线。
  • DRAM_BA:Bank 地址。
  • DRAM_RAS_NDRAM_CAS_NDRAM_WE_NDRAM_CS_N:命令控制脚。
  • DRAM_DQ:双向数据总线。
  • DRAM_DQM:数据 mask。
  • DRAM_CLKDRAM_CKE:时钟和时钟使能。

这就是控制器的边界:左边是一套容易使用的 request/response 协议,右边是 SDRAM 芯片真实协议。

7.1 双向数据总线如何处理?

SDRAM 的 DQ 是 inout:写的时候 FPGA 驱动它,读的时候 SDRAM 驱动它。SystemVerilog 里要避免双方同时驱动,否则就是总线争用。

项目里这样处理:

logic write_enable_latch;
reg [31:0] DRAM_DQ_r;
assign DRAM_DQ = write_enable_latch ? DRAM_DQ_r : {32{1'bz}};
assign read_data = DRAM_DQ;

write_enable_latch 为 1,控制器把 DRAM_DQ_r 驱动到总线上;当它为 0,控制器输出高阻态 z,让 SDRAM 芯片在读操作中驱动总线。

这也是外部存储器控制里非常经典的一点:读写方向不仅是逻辑概念,也是电气概念。写时你要开输出使能,读时你必须释放总线。

7.2 为什么要锁存 write_enable?

用户的 write_enable 可能只在请求那一拍有效,但真正执行 WRITE 命令要等 ACT 之后的 tRCD。如果不锁存,到执行时 write_enable 可能已经变了。

项目用边沿检测和锁存解决这个问题:

logic request_posedge_edge;
edge_detect edge_detect_request (
  .clk ( clock ),
  .rst_n ( ~reset ),
  .data_in ( request ),
  .pos_edge ( request_posedge_edge )
);
 
logic write_enable_posedge_edge;
edge_detect edge_detect_write_enable (
  .clk ( clock ),
  .rst_n ( ~reset ),
  .data_in ( write_enable ),
  .pos_edge ( write_enable_posedge_edge )
);
 
always_ff @( posedge clock or posedge reset ) begin : latch_write_enable
  if (reset) begin
    write_enable_latch <= 0;
  end else begin
    if (request_posedge_edge) begin
      write_enable_latch <= write_enable_posedge_edge;
    end else begin
      write_enable_latch <= write_enable_latch;
    end
  end
end

这里的意图是:在 request 上升沿捕获这次操作是读还是写。控制器后续状态不再依赖外部 write_enable 的实时值,而是依赖内部锁存值。

从教学角度看,这体现了一个重要原则:跨越多个周期的事务,必须在事务开始时把关键信息锁存下来。地址、写数据、读写方向都属于事务信息。当前代码锁存了写方向,但地址和写数据直接使用输入值;如果上层逻辑不能保证它们在整个事务期间稳定,后续可以进一步改成内部锁存地址和写数据。


八、边沿检测模块:把电平请求变成单周期事件

request 是一个电平信号,但状态机通常希望看到“一次请求事件”。如果用户把 request 拉高好几个周期,控制器不能每个周期都当成新请求,所以需要检测上升沿。

项目里的 edge_detect.v 是传统 Verilog 写法:

module edge_detect (
  clk,
  rst_n,
  data_in,
  pos_edge,
  neg_edge 
);
input clk;
input rst_n;
input data_in;
output pos_edge;
output neg_edge;
 
reg data_in_d1;
reg data_in_d2; 
 
assign pos_edge = data_in_d1 & ~data_in_d2;
assign neg_edge = ~data_in_d1 & data_in_d2;
 
always@(posedge clk or negedge rst_n) 
begin
  if (!rst_n)
  begin
    data_in_d1 <=#1 1'b0;
    data_in_d2 <=#1 1'b0;
  end
  else 
  begin
    data_in_d1 <=#1 data_in;
    data_in_d2 <=#1 data_in_d1; 
  end
end
endmodule 

它用两个触发器保存当前输入和上一拍输入:

data_in      : 0 0 1 1 1 0 0
data_in_d1   : 0 0 0 1 1 1 0
data_in_d2   : 0 0 0 0 1 1 1
pos_edge     : 0 0 0 1 0 0 0

data_in_d1 = 1data_in_d2 = 0 时,说明刚刚出现上升沿,于是 pos_edge 只高一个周期。

这里有一个小细节:代码里还有一行 assign fail_pos_edge = data_in & ~data_in_d1;,但 fail_pos_edge 没有声明也没有使用。它看起来像调试残留。学习时可以忽略;如果要清理工程,可以删除或补成明确输出。


九、初始化流程:SDRAM 上电后不能马上读写

SDRAM 上电后必须按 datasheet 规定初始化。典型流程是:

  1. 上电后保持时钟和 CKE 稳定一段时间,常见要求至少 100 us 或 200 us。
  2. 发 PALL(Precharge All)关闭所有 Bank 中可能打开的行。
  3. 发若干次 Auto Refresh,常见是至少 2 次,有些设计会发 8 次。
  4. 发 MRS 设置 Mode Register。
  5. tMRD
  6. 进入正常工作状态。

项目里 clock_stable_ns = 250_000,也就是 250 us;initiate_refresh_count = 8,也就是初始化阶段发 8 次 REF。

主状态机枚举如下:

typedef enum logic [7:0] {
  state_wait_for_clock_stable,
  state_do_initiate_auto_refresh,
  state_wait_for_initiate_tRC,
  state_do_set_mode_register,
  state_wait_for_tMRS,
  state_wait_for_tRP,
  state_wait_for_tRCD,
  state_wait_for_tCAC,
  state_wait_for_tDPL,
  state_wait_for_tRC,
  state_idle
} state_t;

可以先把初始化部分画成流程图:

flowchart TD reset["Reset"] --> waitClock["等待时钟和电源稳定<br/>state_wait_for_clock_stable"] waitClock --> pall["PALL<br/>Precharge All Banks"] pall --> ref["REF<br/>初始化刷新"] ref --> waitTRC["等待 tRC"] waitTRC --> more{"刷新次数够了吗?"} more -- "否" --> ref more -- "是" --> mrs["MRS<br/>设置 Mode Register"] mrs --> waitMRD["等待 tMRD"] waitMRD --> idle["state_idle<br/>initiated = 1"]

对应代码从 reset 开始:

always_ff @( posedge clock or posedge reset ) begin : controller_state_machine
  if (reset) begin 
    DRAM_CKE <= 1;
    DRAM_CS_N <= 0;
    DRAM_ADDR <= 0;
    DRAM_BA <= 0;
 
    initiated <= 0;
    auto_refresh_response <= 0;
    cycle_count <= 0;
    state <= state_wait_for_clock_stable;
  end else begin
    unique case (state)
      ...
    endcase
  end
end

reset 后,控制器不立刻 idle,而是进入 state_wait_for_clock_stable

localparam wait_clock_stable_cycle = clock_stable_ns / clock_frequency_mhz;

这里的单位要稍微解释一下。clock_frequency_mhz = 100 时,一个周期是 10 ns。clock_stable_ns = 250_000 时,等待周期数是:

250000 ns / 10 ns = 25000 cycles

代码写成 clock_stable_ns / clock_frequency_mhz,在 100 MHz 下正好得到 2500? 这里要小心单位。因为 MHz 和 ns 的关系是:周期 ns = 1000 / frequency_mhz。更通用的换算应该是:

cycles = ceil(clock_stable_ns / (1000 / clock_frequency_mhz))
       = ceil(clock_stable_ns * clock_frequency_mhz / 1000)

也就是说,如果变量名严格按 clock_frequency_mhz 理解,250_000 / 100 = 2500 cycles 只等了 25 us,而不是 250 us。项目注释里也能看到作者当时在尝试推导:

// (clock_stable_ns * (1 / 1_000_000_000)) / (1 / clock_frequency_mhz);

这类单位问题在硬件设计里非常常见。写控制器时,一定要明确每个参数到底是 Hz、MHz、ns 还是 cycle。本文主要讲设计思路,所以先按状态机意图理解:这里要等待上电稳定时间。

等待结束后发 PALL:

state_wait_for_clock_stable : begin
  if (cycle_count == wait_clock_stable_cycle - 1) begin
    // precharge all L-Bank
    PALL();
    cycle_count <= 0;
    state <= state_do_initiate_auto_refresh;
  end else begin
    cycle_count <= cycle_count + 1;
  end
end

PALL 的作用是把所有 Bank 预充电,也就是关掉所有可能打开的行,让 SDRAM 回到干净状态。

然后进入初始化刷新:

state_do_initiate_auto_refresh : begin
  initiate_auto_refresh_count <= initiate_auto_refresh_count + 1;
  REF();
  state <= state_wait_for_initiate_tRC;
end

发完 REF,必须等 tRC

state_wait_for_initiate_tRC : begin
  if (cycle_count == tRC(CAS_Latency) - 1) begin
    cycle_count <= 0;
    // auto refresh {initiate_refresh_count} times
    if (initiate_auto_refresh_count <= initiate_refresh_count - 1) begin
      state <= state_do_initiate_auto_refresh;
    end else begin
      state <= state_do_set_mode_register;
    end
  end else begin
    NOP();
    cycle_count <= cycle_count + 1;
  end
end

这段代码体现了 SDRAM 控制器最基本的写法:发一个命令,然后进入等待状态,在等待状态里每拍发 NOP 并递增计数器,计数到 datasheet 要求后再跳到下一状态。

初始化刷新完成后,设置 Mode Register:

state_do_set_mode_register : begin
  MRS(write_burst_mode, 2'b0, CAS_Latency, burst_type, burst_length);
  state <= state_wait_for_tMRS;
end

再等 tMRD

state_wait_for_tMRS : begin
  if (cycle_count == tMRD() - 1) begin
    initiated <= 1;
    cycle_count <= 0;
    state <= state_idle;
  end else begin
    NOP();
    cycle_count <= cycle_count + 1;
  end
end

initiated <= 1 后,外部逻辑就可以认为 SDRAM 已经准备好。


十、自动刷新:DRAM 需要后台保鲜

初始化只解决上电问题。正常运行时,DRAM 仍然需要周期性刷新。如果控制器一直忙着处理用户读写而忘了刷新,过一段时间数据会丢。

项目用单独的 auto_refresh_counter 模块产生刷新请求:

module auto_refresh_counter #(
  clock_frequency_mhz = 100,
  cycle_ns = 32_000_000
  // cycle_ns = 64_000 // for simulation
) (
  output logic request,
  input logic response,
 
  input logic clock, reset
);

这个模块有一个很简单的握手:

  • request:刷新计数器告诉控制器“该刷新了”。
  • response:控制器告诉刷新计数器“我已经接受并发出刷新命令了”。

内部逻辑如下:

logic [31:0] count;
always_ff @( posedge clock or posedge reset ) begin : auto_refresh_counter_ff
  if (reset) begin
    count <= 0;
    request <= 0;
  end else if (response_posedge_edge) begin
    count <= 0;
    request <= 0;
  end else begin
    if (count == (cycle_ns / clock_frequency_mhz) - 1) begin
      count <= 0;
      request <= 1;
    end else begin
      count <= count + 1;
    end
  end
end

当计数到阈值,就拉高 request。控制器在 idle 状态看到 auto_refresh_request 后,发 REF 并拉高 auto_refresh_response

state_idle : begin
  response <= 0;
  cycle_count <= 0;
  if (auto_refresh_request) begin
    auto_refresh_response <= 1;
    REF();
    state <= state_wait_for_tRC;
  end else begin
    if (request_posedge_edge) begin
      ACT(bank, row_address);
      cycle_count <= 0;
      state <= state_wait_for_tRCD;
    end else begin
      state <= state_idle;
    end
  end
end

这里可以看到优先级:刷新请求优先于用户读写请求。这很合理,因为刷新是维持数据正确性的底线。如果刷新已经到期,控制器应该先刷新,再处理新的读写。

发完 REF 后等待 tRC

state_wait_for_tRC : begin
  auto_refresh_response <= 0;
  if (cycle_count == tRC(CAS_Latency) - 1) begin
    cycle_count <= 0;
    state <= state_idle;
  end else begin
    NOP();
    cycle_count <= cycle_count + 1;
  end
end

10.1 刷新周期应该怎么选?

很多 SDRAM datasheet 会规定例如“64 ms 内刷新全部 8192 行”。平均下来:

64 ms / 8192 = 7.8125 us

也就是说,控制器大约每 7.8 us 要发一次 Auto Refresh。也有芯片是 4096 行或不同温度条件下要求更频繁。项目里 cycle_ns = 32_000_000 看起来是 32 ms,注释中还有 64_000 用于仿真。这里同样提醒读者:真正移植到具体芯片时,一定要回到 datasheet,按芯片要求设置刷新周期。

学习控制器时,先掌握机制比死记数值更重要:刷新不是一种用户请求,而是控制器内部定时产生的维护事务


十一、一次读操作完整发生了什么?

现在我们终于来到最核心的读操作。假设用户逻辑想读取某个地址:

request = 1
write_enable = 0
address = A

控制器在 idle 状态检测到 request 上升沿后,先拆地址:

wire [ 1:0] bank = address[24:23];
wire [12:0] row_address = address[22:10];
wire [ 9:0] column_address = address[ 9: 0];

然后发 ACT:

if (request_posedge_edge) begin
  ACT(bank, row_address);
  cycle_count <= 0;
  state <= state_wait_for_tRCD;
end

这一步打开对应 Bank 的对应 Row。接下来必须等待 tRCD

state_wait_for_tRCD : begin
  if (cycle_count == tRCD(CAS_Latency) - 1) begin
    cycle_count <= 0;
    if (write_enable_latch) begin
      WRITE(bank, column_address, 1);
      DRAM_DQ_r <= write_data;
      state <= state_wait_for_tDPL;
    end else begin
      READ(bank, column_address, 1);
      DRAM_DQ_r <= {32{1'bz}};
      state <= state_wait_for_tCAC;
    end
  end else begin
    NOP();
    cycle_count <= cycle_count + 1;
  end
end

对于读操作,write_enable_latch 为 0,所以进入 READ(...) 分支。这里 need_auto_precharge = 1,表示读完自动关闭行。

READ 命令发出后,数据不会立即有效。控制器进入 state_wait_for_tCAC

state_wait_for_tCAC : begin
  if (cycle_count == CAS_Latency - 1) begin
    cycle_count <= 0;
    response <= 1;
    state <= state_idle;
  end else begin
    NOP();
    cycle_count <= cycle_count + 1;
  end
end

当 CAS Latency 到达,控制器拉高 response。由于:

assign read_data = DRAM_DQ;

外部逻辑可以在 response 有效时采样 read_data

读操作时序可以概括为:

读写操作的核心时序:ACT 后等待 tRCD,READ 后等待 CAS Latency,WRITE 后等待写恢复相关周期
读写操作的核心时序:ACT 后等待 tRCD,READ 后等待 CAS Latency,WRITE 后等待写恢复相关周期

sequenceDiagram participant U as 用户逻辑 participant C as SDRAM 控制器 participant M as SDRAM 芯片 U->>C: request=1, write_enable=0, address=A C->>M: ACT(bank, row) C-->>C: 等待 tRCD,期间输出 NOP C->>M: READ(bank, column, auto_precharge=1) C-->>C: 等待 CAS Latency M-->>C: DQ 输出有效数据 C->>U: response=1, read_data 有效

11.1 为什么 READ 前必须 ACT?

因为 READ 只提供列地址,而列必须建立在“某一行已经打开”的前提下。没有 ACT,芯片不知道你要读哪个 Row。你可以把 ACT 理解成“把一页书翻开”,READ 是“读这一页上的某一行字”。书没翻开之前,你不能直接读里面某个字。

11.2 为什么读完要 Auto Precharge?

SDRAM 打开一行后,可以连续读写同一行的不同列,这叫 row hit,性能更好。但如果下一个请求访问同一个 Bank 的另一行,就必须先关闭当前行,也就是 Precharge。高性能控制器会维护“当前每个 Bank 打开了哪一行”,根据 row hit/row miss 决定是否保持打开。

这个项目为了简单,READ/WRITE 都把 A10 设成 need_auto_precharge = 1。这样每次访问后芯片自动关闭行。优点是状态机简单,不用跟踪 open row;缺点是连续访问同一行也要重复 ACT,性能不高。

这正是教学项目和高性能控制器的取舍:先把正确性和完整流程讲清楚,再考虑优化。


十二、一次写操作完整发生了什么?

写操作和读操作前半段一样:

  1. 用户发 request
  2. 控制器拆地址。
  3. 发 ACT 打开 Bank/Row。
  4. tRCD

不同点从 state_wait_for_tRCD 的分支开始:

if (write_enable_latch) begin
  WRITE(bank, column_address, 1);
  DRAM_DQ_r <= write_data;
  state <= state_wait_for_tDPL;
end else begin
  READ(bank, column_address, 1);
  DRAM_DQ_r <= {32{1'bz}};
  state <= state_wait_for_tCAC;
end

写操作会:

  • 发 WRITE 命令。
  • write_data 放进 DRAM_DQ_r
  • 因为 write_enable_latch = 1assign DRAM_DQ = write_enable_latch ? DRAM_DQ_r : {32{1'bz}}; 会让 FPGA 驱动 DQ 总线。
  • 进入 state_wait_for_tDPL

state_wait_for_tDPL 代码如下:

state_wait_for_tDPL : begin
  if (cycle_count == tCAC(clock_frequency_mhz / 1_000_000) - 1) begin
    cycle_count <= 0;
    response <= 1;
    state <= state_idle;
  end else begin
    NOP();
    cycle_count <= cycle_count + 1;
  end
end

从意图上看,这里是在等待写操作完成所需的延迟。很多 SDRAM datasheet 会把写后到 Precharge 或下一命令的约束写成 tDPLtWR 或类似名字,具体取决于芯片和文档。项目里暂时借用了 tCAC(...) 来返回等待周期。

这里也有一个值得读代码时留意的地方:clock_frequency_mhz 参数默认已经是 100,而表达式 clock_frequency_mhz / 1_000_000 会变成 0。这显然不符合 tCAC 函数里 100/133/166 的 case。更合理的写法可能是直接使用 tCAC(clock_frequency_mhz),或者为写恢复单独定义 tDPL() / tWR() 函数。这个点不影响我们理解整体架构,但如果你要继续维护项目,这是一个应该修正和验证的地方。

写操作流程图如下:

flowchart TD idle["state_idle"] --> req{"request 上升沿?"} req -- "否" --> idle req -- "是" --> act["ACT(bank,row)<br/>打开目标行"] act --> waitRCD["等待 tRCD<br/>NOP..."] waitRCD --> rw{"write_enable_latch?"} rw -- "0: 读" --> read["READ(bank,column,A10=1)"] read --> waitCAC["等待 CAS Latency"] waitCAC --> readResp["response=1<br/>read_data 有效"] rw -- "1: 写" --> write["WRITE(bank,column,A10=1)<br/>驱动 DQ=write_data"] write --> waitDPL["等待写恢复周期"] waitDPL --> writeResp["response=1<br/>写事务完成"] readResp --> idle writeResp --> idle

十三、总状态机:把初始化、刷新、读写放在一起看

现在我们把所有流程合起来。主状态机的职责可以分为三类:

  1. 初始化状态:上电等待、PALL、初始化 REF、MRS。
  2. 维护状态:周期性 Auto Refresh。
  3. 用户事务状态:ACT、等待 tRCD、READ/WRITE、等待数据或写完成。

完整关系如下:

stateDiagram-v2 [*] --> wait_for_clock_stable wait_for_clock_stable --> do_initiate_auto_refresh: PALL do_initiate_auto_refresh --> wait_for_initiate_tRC: REF wait_for_initiate_tRC --> do_initiate_auto_refresh: refresh_count 未完成 wait_for_initiate_tRC --> do_set_mode_register: refresh_count 完成 do_set_mode_register --> wait_for_tMRS: MRS wait_for_tMRS --> idle: initiated=1 idle --> wait_for_tRC: auto_refresh_request / REF wait_for_tRC --> idle: tRC 到 idle --> wait_for_tRCD: request / ACT wait_for_tRCD --> wait_for_tCAC: READ wait_for_tRCD --> wait_for_tDPL: WRITE wait_for_tCAC --> idle: response=1 wait_for_tDPL --> idle: response=1

这个状态机有一个很鲜明的学习价值:它没有把 SDRAM 控制做成黑盒 IP,而是把每一步都展开成状态。你能看到每个命令后为什么要等、等多久、等待时输出什么、什么时候给用户响应。

如果用一句话概括这个控制器的行为,就是:

控制器在 reset 后先按 SDRAM datasheet 初始化;初始化完成后停在 idle;idle 中优先处理刷新请求,其次处理用户读写请求;每个用户请求都拆成 ACT + READ/WRITE + 等待 + response。


十四、开发板顶层:把控制器接到真实硬件

项目里除了 RTL,还有一个 simulation/sdram/sdram.v。这个文件由 Terasic System Builder 生成了大量开发板端口,然后手动接入 PLL、按键、拨码开关、七段数码管和 SDRAM 控制器。

核心部分如下:

wire reset = ~KEY[0];
wire clock = CLOCK_50;
 
wire clock_100, pll_locked;
wire reset_with_pll = reset || ~pll_locked;
pll pll_inst (
  .areset ( reset ),
  .inclk0 ( clock ),
  .c0 ( clock_100 ),
  .locked ( pll_locked )
);

开发板输入是 50 MHz,PLL 生成 100 MHz 给 SDRAM 控制器使用。reset_with_pll 把手动 reset 和 PLL lock 状态合并,避免 PLL 未锁定时控制器乱跑。

用户接口通过按键和拨码开关构造:

wire request = ~KEY[1] || ~KEY[2];
wire write_enable = ~KEY[2];
 
wire [ 7:0] address_low = SW[15:8];
wire [ 7:0] write_data_low = SW[7:0];
 
wire [31:0] read_data, write_data;
wire [24:0] address = { 8'b0000_0000, 8'b0000_0000, 8'b0000_0000, address_low };
assign write_data = { 8'b1111_1111, 8'b1111_1111, 8'b1111_1111, SW[7:0] };

这说明开发板实验方式大概是:

  • SW[15:8] 选择低 8 位地址。
  • SW[7:0] 选择低 8 位写数据。
  • 按键触发读或写请求。
  • 七段数码管显示地址、写数据和读数据。
  • LED 显示响应和初始化状态。

控制器实例化如下:

sdram_controller sdram_controller_inst(
  .request ( request ),
  .response ( response ),
  .write_enable ( write_enable ),
  .address ( address ),
  .read_data ( read_data ),
  .write_data ( write_data ),
 
  .initiated ( initiated ),
 
  .DRAM_ADDR ( DRAM_ADDR ) ,
  .DRAM_BA ( DRAM_BA ) ,
  .DRAM_CAS_N ( DRAM_CAS_N ) ,
  .DRAM_CKE ( DRAM_CKE ) ,
  .DRAM_CLK ( DRAM_CLK ) ,
  .DRAM_CS_N ( DRAM_CS_N ) ,
  .DRAM_DQ ( DRAM_DQ ) ,
  .DRAM_DQM ( DRAM_DQM ) ,
  .DRAM_RAS_N ( DRAM_RAS_N ) ,
  .DRAM_WE_N ( DRAM_WE_N ) ,
 
  .clock ( clock_100 ),
  .reset ( reset_with_pll )
);

顶层代码的价值在于,它展示了内存控制器如何从一个 RTL 模块变成真实开发板上的功能:PLL 提供时钟,按键产生请求,开关产生地址和数据,SDRAM 管脚直接连到芯片,显示模块把结果反馈给人。


十五、Testbench:如何在仿真里走一遍读写?

rtl/sdram_controller_tb.sv 是一个简单 testbench。它生成时钟、释放 reset,然后依次做读、写、再读:

localparam clock_frequency = 100_000_000;
localparam clock_period = 1_000_000_000 / clock_frequency;
logic clock = 0, reset = 1;
 
always #(clock_period / 2) clock = ~clock;

100 MHz 时钟周期是 10 ns。testbench 先 reset:

initial begin
  #(clock_period)
  reset = 0;
 
  write_enable = 0;
  request = 0;
  address = 0;
 
  #(clock_period)
 
  #(300_000)
  $display("initiated");

这里等待 300 us 左右,让控制器完成初始化。然后开始读:

#(clock_period)
$display("read start");
 
request = 1;
address = 1;
write_data = 0;
$monitor("read response: %b", response);
$monitor("read_data: %b", read_data);
#(clock_period)
request = 0;

然后写:

#(clock_period * 50)
 
request = 1;
address = 1;
write_enable = 1;
write_data = 1;
$monitor("response: %b", response);
#(clock_period)
request = 0;
write_enable = 0;

最后再读同一个地址:

#(clock_period * 50)
 
request = 1;
address = 1;
write_data = 0;
$monitor("read response: %b", response);
$monitor("read_data: %b", read_data);
#(clock_period)
request = 0;

这个 testbench 比较像“冒烟测试”:它检查状态机能不能跑起来,读写请求能不能触发 response。严格来说,如果要验证 SDRAM 控制器,还需要引入 SDRAM 行为模型(memory model),让模型根据命令真正保存和返回数据。否则只看 read_data = DRAM_DQ,没有外部模型驱动 DQ,就很难证明读写数据完全正确。

一个更完整的验证环境可以包含:

  • SDRAM behavioral model。
  • 对 request/response 的断言,比如 request 后必须在有限周期内 response。
  • 对命令时序的断言,比如 ACT 到 READ 至少间隔 tRCD
  • 对刷新周期的断言,比如刷新间隔不能超过 datasheet 要求。
  • 随机地址、随机读写、scoreboard 对比期望数据。

但对入门者来说,现在这个 testbench 已经能帮助你观察状态机跳转和命令输出。


十六、用一条读请求串起全部代码

为了把前面的知识真正连起来,我们用一条读请求从头追踪信号。

假设:

address = 25'b01_0000000000010_0000000011
write_enable = 0
request 出现上升沿

地址拆解后:

bank = 2'b01
row_address = 13'b0000000000010
column_address = 10'b0000000011

第一拍,idle 状态看到 request:

ACT(bank, row_address);
state <= state_wait_for_tRCD;

ACT 把信号设置成:

CS_N  = 0
RAS_N = 0
CAS_N = 1
WE_N  = 1
BA    = 01
ADDR  = row_address

接下来 state_wait_for_tRCD 连续输出 NOP,直到 cycle_count == tRCD(CAS_Latency) - 1。如果 CAS Latency = 2,tRCD 返回 2,也就是等两个周期。

然后发 READ:

READ(bank, column_address, 1);
state <= state_wait_for_tCAC;

READ 把信号设置成:

CS_N     = 0
RAS_N    = 1
CAS_N    = 0
WE_N     = 1
BA       = 01
ADDR[9:0]= column_address
ADDR[10] = 1    // Auto Precharge

再等 CAS Latency。等够以后:

response <= 1;
state <= state_idle;

外部逻辑看到 response=1,采样 read_data。至此,一次读事务完成。

这个过程看似绕,但每一步都有物理原因:

  • ACT 是把行读入 sense amplifier。
  • tRCD 是等待行数据稳定。
  • READ 是从已打开行里选择列。
  • CAS Latency 是等待列数据到达 DQ。
  • Auto Precharge 是让芯片读完后自动关闭行。
  • response 是控制器把复杂时序重新抽象成“这次请求完成了”。

十七、这个项目里值得学习的设计取舍

这个 SDRAM 控制器不是商业 DDR 控制器,也不是追求最大吞吐的内存子系统。它更像一个学习型控制器。正因为它简单,反而适合拿来读。

17.1 用 task 封装命令

command.sv 把命令编码集中管理,主状态机读起来像流程描述:

PALL();
REF();
MRS(...);
ACT(bank, row_address);
READ(bank, column_address, 1);
WRITE(bank, column_address, 1);
NOP();

这对入门者非常友好。你不需要在状态机每个分支里重新理解 RAS_N/CAS_N/WE_N 的组合。更重要的是,如果命令编码要改,只改一个文件。

17.2 用函数封装时序参数

parameter.svtRCDtRCtRPtMRD 这些时序放到函数里。主状态机只写:

if (cycle_count == tRC(CAS_Latency) - 1) begin
  ...
end

这让“等多久”的依据更集中。后续如果要支持另一颗 SDRAM 芯片,可以先从这个文件入手。

17.3 简化总线协议

request/response 是非常简单的握手协议。上层不用知道 SDRAM 内部状态,只要:

  1. initiated = 1
  2. 准备好地址和数据。
  3. 拉高 request 一个周期。
  4. response

这比直接让上层模块产生 SDRAM 命令安全得多,也更容易组合进其他 FPGA 项目。

17.4 Auto Precharge 降低状态机复杂度

每次 READ/WRITE 都设置 Auto Precharge,可以避免管理 open row。控制器不需要保存“Bank 0 当前打开 Row 几”“Bank 1 当前是否空闲”这样的信息。

代价是性能不高。每次访问都要 ACT,连续访问同一行也不能复用已打开行。但对于教学和低速实验,这个取舍完全合理。

17.5 刷新请求优先

idle 中先看 auto_refresh_request,再看用户 request。这体现了内存控制器的基本职责排序:先保证 DRAM 数据不会丢,再服务上层请求。


十八、如果继续改进,可以从哪里下手?

读完这个项目,你已经理解了一个 SDRAM 控制器的基本骨架。接下来如果想把它做得更严谨、更通用,可以按下面顺序改。

18.1 修正单位和时序换算

建议统一参数单位,例如:

parameter int CLOCK_FREQ_HZ = 100_000_000;
parameter int T_POWER_UP_NS = 200_000;
 
function automatic int ns_to_cycles(input int ns);
  return (ns * (CLOCK_FREQ_HZ / 1_000_000) + 999) / 1000;
endfunction

或者全部用 ps 避免小数。重点是:不要让变量名叫 MHz,却在表达式里按 Hz 使用

同时可以为写恢复单独定义:

function automatic int tWR_cycles();
  return ...;
endfunction

不要复用 tCAC 表达写延迟。

18.2 锁存地址和写数据

当前代码锁存了 write_enable,但没有锁存 addresswrite_data。如果上层协议规定它们必须保持稳定,这可以工作;但更健壮的控制器会在 request 被接受时锁存:

logic [24:0] address_latch;
logic [31:0] write_data_latch;
 
if (request_posedge_edge) begin
  address_latch <= address;
  write_data_latch <= write_data;
end

后续状态全部使用 latch。这样上层在发出请求后就可以准备下一笔事务。

18.3 增加 busy/ready 信号

现在上层需要自己知道什么时候可以发 request。更常见的接口会有:

output logic ready;
input logic valid;

或者类似 AXI/Wishbone/Avalon 的握手协议。ready=1 时才接受新请求,避免 request 在控制器忙时丢失。

18.4 支持 burst

项目已经有 burst_length 参数和 Mode Register 编码,但主状态机按单次访问处理。要真正支持 burst,需要:

  • 写操作连续驱动多个 DQ 数据。
  • 读操作连续采样多个 DQ 数据。
  • response 语义从“一个 word 完成”扩展为“一个 burst 完成”或每拍 valid。
  • 地址和数据接口变成流式接口。

这会显著增加复杂度,但也更接近高性能 SDRAM 控制器。

18.5 做 open-row 管理

当前每次访问都 Auto Precharge。更高性能的控制器会记录每个 Bank 当前打开的 Row:

如果目标 Bank 已打开目标 Row:直接 READ/WRITE
如果目标 Bank 已打开其他 Row:先 PRE,再 ACT,再 READ/WRITE
如果目标 Bank 没有打开 Row:ACT,再 READ/WRITE

这就是内存控制器里常说的 row hit / row miss / row conflict。实现它以后,连续访问同一行会快很多。

18.6 加入真正的 SDRAM behavioral model

没有 memory model,很难验证数据正确性。可以找对应芯片厂商提供的 Verilog model,或者写一个简化模型:

  • 解析 ACT/READ/WRITE/REF/MRS 命令。
  • 内部用数组保存数据。
  • 检查时序违规。
  • 在 READ 后按 CAS Latency 驱动 DQ。

这样 testbench 就能从“看波形”变成“自动判断 pass/fail”。


十九、给初学者的阅读路线

如果你现在刚接触 SDRAM,我建议不要一上来就啃完整 datasheet。可以按下面顺序学习:

  1. 先理解 DRAM bit cell 为什么需要刷新。
  2. 再理解 Bank/Row/Column,以及为什么读写要先 ACT 再 READ/WRITE。
  3. 背下最小命令表:NOP、PALL、REF、MRS、ACT、READ、WRITE。
  4. 理解几个核心时序:tRCD、CAS Latency、tRP、tRC、tMRD。
  5. 画出初始化流程。
  6. 画出单次读流程。
  7. 画出单次写流程。
  8. 最后再读 sdram_controller.sv

不要把 SDRAM 控制器想成一个“很大的模块”。它本质上是一系列小规则的组合:

  • 上电必须初始化。
  • DRAM 必须刷新。
  • 读写前必须打开行。
  • 命令之间必须等待。
  • 读数据有延迟。
  • 写数据要控制总线方向。
  • 控制器要把这些复杂规则包装成简单接口。

当这些规则一条条清晰后,状态机代码就会变得非常自然。


二十、从会写 SystemVerilog 到会写内存控制器,中间差了什么?

很多初学者写 SDRAM 控制器时,最大的困难并不是语法。always_ffcaseenumlogicassign 这些 SystemVerilog 元素本身都很熟悉。真正陌生的是思维方式:你不再只是描述一个纯数字逻辑模块,而是在和一颗具有模拟特性、内部阵列、时序限制和电气约束的芯片协作。

如果只写片上寄存器,你可以认为“时钟边沿到了,寄存器就更新”。如果写一个 UART,你需要考虑波特率和采样点,但协议仍然比较线性。SDRAM 控制器更像一个小型调度系统:它必须知道芯片现在是否初始化完成,是否快到刷新时间,某个 Bank 是否能接受命令,上一条命令到现在隔了几个周期,数据总线现在应该由 FPGA 驱动还是由 SDRAM 驱动。

这就是内存控制器的核心训练价值。它逼你把以下几种能力合在一起:

  • 读 datasheet 的能力:不是只看引脚表,而是能把 AC timing 表、command truth table、mode register bit field 翻译成 RTL。
  • 设计状态机的能力:不是随便写几个状态,而是让每个状态对应一个硬件动作或一个等待窗口。
  • 管理跨周期事务的能力:一次读写会跨越很多拍,地址、方向、数据和响应必须被一致地管理。
  • 处理双向总线的能力inout 不是普通 wire,读写方向错了会造成总线争用。
  • 做保守取舍的能力:初版控制器先保证正确,再考虑 burst、open-row、流水线和仲裁。

下面把这些能力拆成更具体的经验。

20.1 不要把状态名写成“我想做什么”,要写成“硬件现在处于什么约束”

例如 state_wait_for_tRCD 是一个好状态名。它不是泛泛地说“准备读写”,而是明确告诉读代码的人:上一拍已经发过 ACT,现在正在等待 ACT 到 READ/WRITE 的最小间隔。这个名字里包含了 datasheet 约束。

同理,state_wait_for_tCAC 告诉你 READ 已经发出,接下来等待数据有效;state_wait_for_tRC 告诉你 REF 已经发出,下一条可用命令要等刷新周期结束。这样的状态名让波形调试非常直接:当你在 ModelSim 里看到状态卡在 state_wait_for_tRCD,你马上知道应该检查 cycle_counttRCD(CAS_Latency),而不是猜测控制器是不是“忙”。

初学者常见的坏习惯是把状态写成 state_1state_2state_readstate_writestate_read 太粗了,因为读操作至少包含 ACT、等待 tRCD、READ、等待 CAS、响应几个阶段。把多个硬件约束压进一个状态名,会让后续调试变得困难。

20.2 每一个等待周期都应该能回答“为什么要等”

控制器里最容易被误解的代码是大量 NOP:

NOP();
cycle_count <= cycle_count + 1;

软件背景的人可能觉得这像低效空转。但在 SDRAM 里,等待不是浪费,而是在满足物理电路需要的建立时间和恢复时间。

发 ACT 后,芯片内部要把一整行 cell 连接到 sense amplifier。这不是零时间完成的,所以要等 tRCD。发 READ 后,列选择、内部数据路径、输出缓冲都需要时间,所以要等 CAS Latency。发 REF 后,芯片内部刷新一批行,也需要完整周期,所以要等 tRC。发 MRS 后,模式寄存器配置要稳定,所以要等 tMRD

因此写 SDRAM 控制器时,每个等待状态旁边都应该能在注释或状态名里找到对应 datasheet 参数。如果你写了一个“随便等 5 拍”的状态,却说不清来自哪个约束,那就是设计风险。

20.3 request/response 只是外壳,内部必须接受“请求会变成事务”

外部接口很简单:

request 上升沿 -> 控制器开始处理
response 拉高 -> 控制器处理完成

但内部不是一个组合逻辑响应,而是一笔事务。事务的意思是:它有开始、有中间状态、有结束,而且开始时的上下文必须贯穿到结束。

例如读地址 A 时,控制器在 ACT 阶段需要 A 的 bank 和 row,在 READ 阶段需要 A 的 bank 和 column,在 response 阶段需要告诉上层“这还是刚才地址 A 的结果”。如果上层在 request 后马上改变 address,而控制器没有锁存 address,后续状态就可能使用新地址的 column 搭配旧地址的 row,产生非常隐蔽的错误。

这个项目为了简化,把上层协议假设得比较强:请求期间地址和数据保持稳定。真实工程中,我更建议在 request 被接受时锁存:

address_latch <= address;
write_data_latch <= write_data;
write_enable_latch <= write_enable;

然后所有状态都使用 latch。这样外部接口更清楚:只要 request 被控制器接受,上层就可以释放或准备下一笔信息。

20.4 双向 DQ 总线的错误通常很危险

片上模块之间常用单向信号,所以初学者容易低估 inout 的危险。SDRAM 的 DQ 在写操作时由 FPGA 驱动,在读操作时由 SDRAM 驱动。如果控制器在读操作时没有释放总线,FPGA 和 SDRAM 可能同时驱动不同电平;仿真里可能出现 x,真实硬件上则可能造成大电流和不可预测行为。

项目里这行代码是非常关键的保护:

assign DRAM_DQ = write_enable_latch ? DRAM_DQ_r : {32{1'bz}};

它说明控制器只有在写事务中才驱动 DQ。读事务里输出高阻,让芯片驱动数据。初学者调 SDRAM 读不出数据时,除了看命令时序,也要看 DQ 方向是否正确、读窗口是否正确、是否过早或过晚采样。

20.5 datasheet 的表格要翻译成三类 RTL

读 SDRAM datasheet 时,可以把内容分成三类来翻译。

第一类是 Command Truth Table。这类表格告诉你 CS_N/RAS_N/CAS_N/WE_N 什么组合代表什么命令。它应该翻译成 command.sv 这样的 task 或函数。

第二类是 AC Timing Characteristics。这类表格告诉你 tRCDtRPtRCtMRDtWR 等最小时间。它应该翻译成 parameter.sv 这样的周期计算函数。

第三类是 Operation Sequence。这类图告诉你上电初始化、读、写、刷新应该按什么顺序发生。它应该翻译成 sdram_controller.sv 里的状态机。

如果你能把 datasheet 的每一类信息都放到 RTL 的对应位置,控制器结构就会很清楚。反过来,如果命令编码、时序常数和状态跳转混在一个巨大 always_ff 里,代码很快会变成难以维护的“波形玄学”。

20.6 先写保守控制器,再写高性能控制器

很多人一开始就想做“完整 SDRAM 控制器”:支持 burst、自动仲裁、open-row、读写重排、多个 master、流水线、AXI 接口。结果往往是还没验证基本读写,就已经被复杂状态淹没。

更稳的路线是四步走:

  1. 单请求、单 word、Auto Precharge:每次请求都 ACT + READ/WRITE + 自动关行,只验证基本正确性。
  2. 锁存事务和 ready/valid:让外部协议严谨起来,避免忙时丢请求。
  3. 加入 burst:处理连续 DQ 数据和 response/valid 节拍。
  4. 加入 open-row 和调度:在正确性基础上优化性能。

我这个项目处在第一步和第二步之间:已经有完整初始化、刷新、读写骨架,但还可以继续增强事务锁存、时序换算和验证环境。这个阶段非常适合教学,因为状态机不复杂,读者能把每一拍都看懂。

20.7 看波形时应该按“命令流”而不是按“代码行”调试

调 SDRAM 时,不要只盯着 SystemVerilog 代码。你应该在波形里把下面这些信号放在一起:

clock
reset
state
cycle_count
request / response
write_enable_latch
DRAM_CS_N / DRAM_RAS_N / DRAM_CAS_N / DRAM_WE_N
DRAM_BA
DRAM_ADDR
DRAM_DQ
auto_refresh_request / auto_refresh_response

然后把波形翻译成命令流:

PALL
NOP NOP ...
REF
NOP NOP ...
MRS
NOP NOP ...
ACT bank,row
NOP NOP ...
READ bank,column
NOP NOP ...
response

如果命令流和你手画的流程图一致,问题通常在时序参数、DQ 方向或外部模型;如果命令流本身不一致,问题就在状态跳转或请求握手。

这种调试方法比“看代码猜哪里错”可靠得多。SDRAM 控制器本质上是时序机器,波形就是它最真实的执行日志。

20.8 初学者最常见的八个坑

最后列一个很实用的 checklist。写或改 SDRAM 控制器时,可以逐项检查:

  1. 上电后是否真的等待了 datasheet 要求的稳定时间?单位换算有没有错?
  2. 初始化阶段是否发了 PALL、足够次数的 REF、MRS,并等待了对应周期?
  3. DRAM_ADDR 在 ACT 时是否表示 Row,在 READ/WRITE 时是否表示 Column?
  4. DRAM_ADDR[10] 的 Auto Precharge 位是否按预期设置?
  5. ACT 到 READ/WRITE 是否满足 tRCD
  6. READ 到采样数据是否满足 CAS Latency?
  7. WRITE 时 FPGA 是否驱动 DQ,READ 时 FPGA 是否释放 DQ?
  8. 刷新请求是否会被长期饿死?刷新后是否等待 tRC

只要这八项都过了,一个最小 SDRAM 控制器通常就能跑起来。后面的优化是锦上添花;这八项是正确性的地基。


二十一、总结

SDRAM 控制器之所以看起来复杂,不是因为 SystemVerilog 语法复杂,而是因为它连接了两个世界:上层数字逻辑希望内存像数组一样简单,底层 SDRAM 芯片却要求严格的命令和时序。

我写的 SDRAM-Controller 项目用相对直接的方式展示了这个翻译过程:

  • parameter.sv 把 datasheet 时序抽象成周期函数。
  • command.sv 把 SDRAM 命令表封装成 task。
  • edge_detect.v 把电平输入转换成单周期事件。
  • auto_refresh_counter.sv 周期性产生刷新请求。
  • sdram_controller.sv 用状态机串起初始化、刷新、读、写。
  • simulation/sdram/sdram.v 展示了控制器如何连接到 FPGA 开发板和真实 SDRAM 管脚。

从学习角度看,这个项目最重要的价值是:它没有把 SDRAM 控制器藏在厂商 IP 后面,而是把每一个步骤都写在 RTL 里。你可以沿着状态机逐拍追踪,看到每个命令为什么出现、每个等待周期为什么存在、每个 response 为什么要到那个时刻才拉高。

当你能手动画出“ACT → 等 tRCD → READ → 等 CAS Latency → response”这条链路时,你就已经跨过了 SDRAM 入门最关键的一道门槛。后面的 burst、open row、仲裁、多端口、AXI、DDR,只是在这个基本模型上继续扩展。