ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA驱动异步SRAM IS61LV25616AL:Verilog控制器设计与时序避坑

FPGA驱动异步SRAM IS61LV25616AL:Verilog控制器设计与时序避坑

简介:面向FPGA初学者与嵌入式系统开发者,这份资源提供了一套经XSC3S400开发板实际验证的IS61LV25616AL读写接口Verilog程序。IS61LV25616AL是16位宽、256K容量的低电压CMOS SRAM,常用来扩展FPGA有限的内置存储;程序中地址总线、数据总线、读写控制与片选信号的时序设计均清晰可读,完整展示了从端口声明、读写状态跳转到数据读回的实现通路。压缩包共15个文件、约57KB,包含.v源代码、.ucf引脚约束文件、ISE工程配置(.xise/.gise)以及综合报告和HTML说明页面,便于对照项目结构快速重建并移植。目前已有1038人学习。借助源码与工程文件,读者可深入理解FPGA与外部SRAM的交互时序,掌握读写状态机设计方法,并能迁移到其他存储芯片或开发平台,是FPGA存储接口入门、课程设计与竞赛准备的实用参考。

1. 为什么说 IS61LV25616AL 没有看起来那么省心

第一次把 IS61LV25616AL 挂到 FPGA 上时,我一度以为这不过是个“地址拉上去、数据自动出来”的简单外设。真去读数据手册才发现,这颗 256K×16 的异步 SRAM 虽然不需要时钟,但每一次读写都得卡住 CE#、OE#、WE# 的先后顺序,连字节使能 UB#/LB# 也不能随便放飞。网上一搜“FPGA verilog 读写 SRAM”,资料很多,但能直接拿来用、状态机写得清楚、仿真能跑通的不多。这个项目的价值,就是把这种“看似简单实则到处是时序陷阱”的芯片,封装成一个参数可调、握手清晰、便于二次开发的 Verilog 控制器。无论你是做图像缓存、软核 CPU 的局部内存,还是纯粹想在 FPGA 上体验并行总线,它都很合适;它也是一份很好的“异步器件被同步状态机驱动”的活教材。

2. 硬件接口与工程组织:先把引脚和时序握在手里

2.1 IS61LV25616AL 的关键信号与电气参数

IS61LV25616AL 是一颗 3.3V 供电的异步 CMOS SRAM,容量是 256K × 16bit,也就是 512KB。没有内部时钟,所有动作都由外部控制信号的电平组合触发。FPGA 这边必须自己产生一套符合时序要求的地址、读写控制信号,并把双向数据总线管好。

这颗芯片的引脚分成四组:18 根地址线、16 根数据线、5 根控制线和电源地。控制线里最容易搞混的是 CE#、OE#、WE# 和 UB#/LB#。CE# 是片选,整颗芯片是否工作;OE# 只管读操作时数据能不能输出到 DQ;WE# 只管写操作;UB#/LB# 决定 D[15:8] 和 D[7:0] 哪一半参与当前访问。

信号方向作用
A[17:0]输入地址总线,18 位,对应 256K 个 16bit 单元
D[15:0]双向数据总线,读时由 SRAM 驱动,写时由 FPGA 驱动
CE#输入片选,低有效,拉高后芯片处于休眠
OE#输入输出使能,低有效,影响读时序
WE#输入写使能,低有效,影响写时序
UB#输入高字节选择,低有效,控制 D[15:8]
LB#输入低字节选择,低有效,控制 D[7:0]

数据手册上最重要的参数是 tAA(地址到数据有效时间)、tDOE(OE# 下降沿到数据有效时间)和 tPWE(WE# 低电平脉冲宽度)。常见的访问时间档位有 10ns、12ns、15ns,具体取决于后缀。FPGA 的时钟周期必须留够余量。如果系统时钟 50MHz,一个周期 20ns,不加等待也够;如果是 100MHz 系统时钟,一个周期才 10ns,就需要状态机里至少插入 2~4 个时钟周期来等待 SRAM 响应。

2.2 工程目录与 RTL 文件划分

拿到这个工程后,先看目录结构。一个合格的异步 SRAM 读写工程,通常不会把所有代码堆在一个顶层文件里。我一般习惯这样组织:

sram_demo/ ├── rtl/ │ ├── sram_controller.v // 核心状态机,只负责一次读写 │ ├── top.v // 顶层,绑定物理引脚 │ └── uart_debug.v // 可选,串口命令解析/数据回显 ├── sim/ │ ├── sram_tb.v // 测试平台,验证读写流程 │ └── is61lv25616al_model.v // SRAM行为模拟模型 ├── constraints/ │ └── pins.xdc // 引脚分配和时序约束 └── scripts/ └── run_sim.tcl // Vivado/Quartus仿真脚本

sram_controller.v 是核心模块,它对外暴露的接口很像一个内存控制器:start、rw、addr、wdata、rdata、busy。顶层模块不直接碰 SRAM 信号,只调用这个控制器。这样的好处是,如果以后把 IS61LV25616AL 换成别的异步 SRAM,只需要改控制器内部实现,顶层的逻辑不用动。

控制器内部采用同步状态机设计。所有 SRAM 控制信号都在系统时钟的上升沿改变,而不是用组合逻辑直接产生。异步 SRAM 本身没有时钟,但我们用同步设计去驱动它,就能避免毛刺、抖动和综合后时序难收敛的问题。每次访问由 start 发起,busy 拉高,等到状态机走完一个完整读写周期后,busy 拉低,表示一次访问结束。

工程里如果只做读写测试,可以加一个简单的串口模块,在 PC 上发命令来写某个地址、读某个地址;如果要看波形,也可以直接用 ILA 抓 SRAM 引脚。下面第三章先把最核心的控制器讲清楚。

3. 核心模块拆解:一个带状态机的 SRAM 访问控制器

3.1 读写状态机:IDLE → ACCESS → DONE

这个控制器只有三个状态:IDLE、ACCESS、DONE。IDLE 等待外部发起请求;ACCESS 中保持地址、使能信号,并等待足够的时钟周期;DONE 负责把总线释放、恢复片选,并通知上层一次访问结束。

先看完整的 RTL 代码,这是整个工程里最值得抄走的一段:

// sram_controller.v // 参数 CLK_CYCLES 控制一次访问经历多少个系统时钟周期 module sram_controller #( parameter CLK_CYCLES = 4 )( input wire clk, input wire rst_n, input wire start, // 发起一次读写 input wire rw, // 1: 写, 0: 读 input wire [17:0] addr, // 读写地址 input wire [15:0] wdata, // 写数据 output reg [15:0] rdata, // 读数据 output reg busy, // 忙信号 // 接物理 SRAM 的信号 output reg [17:0] sram_addr, inout wire [15:0] sram_data, output reg sram_ce_n, output reg sram_oe_n, output reg sram_we_n, output reg sram_ub_n, output reg sram_lb_n ); localparam IDLE = 2'd0; localparam ACCESS = 2'd1; localparam DONE = 2'd2; reg [1:0] state; reg [4:0] cnt; reg [15:0] dout; reg driving; // 1: FPGA驱动sram_data; 0: 释放总线 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; cnt <= 0; busy <= 1'b0; driving <= 1'b0; sram_ce_n <= 1'b1; sram_oe_n <= 1'b1; sram_we_n <= 1'b1; sram_ub_n <= 1'b1; sram_lb_n <= 1'b1; sram_addr <= 18'd0; end else begin case (state) IDLE: begin if (start && !busy) begin busy <= 1'b1; sram_ce_n <= 1'b0; sram_ub_n <= 1'b0; sram_lb_n <= 1'b0; sram_addr <= addr; if (rw) begin // 写访问:拉低 WE,把数据放到总线上 sram_we_n <= 1'b0; sram_oe_n <= 1'b1; dout <= wdata; driving <= 1'b1; end else begin // 读访问:拉低 OE,数据由 SRAM 驱动 sram_we_n <= 1'b1; sram_oe_n <= 1'b0; driving <= 1'b0; end cnt <= 0; state <= ACCESS; end end ACCESS: begin cnt <= cnt + 1'b1; if (cnt == CLK_CYCLES - 1) begin // 最后一个等待周期 if (!rw && !driving) begin // 读,且数据总线由SRAM驱动 rdata <= sram_data; end // 结束访问:抬高 WE/OE,下一步释放数据总线 sram_we_n <= 1'b1; sram_oe_n <= 1'b1; state <= DONE; end end DONE: begin sram_ce_n <= 1'b1; sram_ub_n <= 1'b1; sram_lb_n <= 1'b1; driving <= 1'b0; busy <= 1'b0; state <= IDLE; end endcase end end // 双向数据总线的三态控制 assign sram_data = driving ? dout : 16'hzzzz; endmodule

这段代码里有几个关键点需要展开讲。

第一,三态总线。sram_data 声明为 inout wire,不能直接赋值。我单独用driving寄存器控制是让 FPGA 驱动数据,还是把总线释放给 SRAM。写操作时driving=1,sram_data 被赋上 dout;读操作时driving=0,sram_data 变成高阻zzzz,此时 SRAM 的 OE# 为低,数据自然从 SRAM 芯片输出到总线上。仿真里最常见的翻车就是把inout口当普通 output 用,导致多驱动源冲突。

第二,CLK_CYCLES 参数。这个参数是一次访问从发起信号生效到采到数据(或写完)要跨多少个系统时钟周期。默认取 4,在 50MHz 时钟下就是 80ns,对于 10ns 访问时间的 SRAM 来说非常保守;在 100MHz 下就是 40ns,也够用。你完全可以根据自己的时钟频率和芯片速度等级把它改小到 2 或 3。需要注意,最小不要设为 1,否则状态机中的判断条件会和计数器更新产生奇怪的竞争,数据可能早一个周期被采样。

第三,字节使能。代码里在每次访问时把 UB# 和 LB# 都拉低,也就是默认 16 位全读写。这是最简单的用法。如果你只想写一个字节,就需要多引入一个字节选择信号,这会在后面第六章改造时展开。

3.2 顶层集成与三态总线的处理

顶层模块的作用是把控制器和物理引脚绑起来。这部分代码量少,但容易踩坑,尤其是 inout 总线在顶层如何连线。

// top.v 片段 module top ( input wire clk, input wire rst_n, // 另一侧,来自用户逻辑 input wire start, input wire rw, input wire [17:0] addr, input wire [15:0] wdata, output wire [15:0] rdata, output wire busy, // 物理 SRAM 引脚 output wire [17:0] sram_addr_pin, inout wire [15:0] sram_data_pin, output wire sram_ce_n_pin, output wire sram_oe_n_pin, output wire sram_we_n_pin, output wire sram_ub_n_pin, output wire sram_lb_n_pin ); sram_controller #( .CLK_CYCLES(4) ) u_sram ( .clk (clk), .rst_n (rst_n), .start (start), .rw (rw), .addr (addr), .wdata (wdata), .rdata (rdata), .busy (busy), .sram_addr (sram_addr_pin), .sram_data (sram_data_pin), .sram_ce_n (sram_ce_n_pin), .sram_oe_n (sram_oe_n_pin), .sram_we_n (sram_we_n_pin), .sram_ub_n (sram_ub_n_pin), .sram_lb_n (sram_lb_n_pin) ); endmodule

在约束文件里,这些线对应 FPGA 的物理封装脚。数据总线 sram_data_pin 必须是支持三态的引脚,绝大多数 FPGA bank 都满足。关键约束是,不能让综合工具给 SRAM 控制信号做跨时钟域处理,因为它们本身就是和系统时钟同源的。需要在 XDC 里对输入时钟做create_clock,其余信号通过set_input_delay/set_output_delay约束到芯片的 tAA、tDOE 等参数上。初学者为了跑通,可以直接只做时钟约束和引脚分配,WR、OE、CE 这些信号由于状态机同步产生,物理时序一般也能稳定,但想要跨到高频 100MHz 以上,必须把延迟补进约束里。

3.3 仿真模型:testbench 里模拟 SRAM 行为

控制器写完后,不要急着上板。先写一个 SRAM 行为模型,在 testbench 里把读写验证一遍。IS61LV25616AL 没有时钟,行为模型的核心就是用一个内存数组存数据,再按照 OE# 和 CE# 的电平把数据放到总线上。

下面是一个足够用的行为模型片段:

// is61lv25616al_model.v 仿真专用 module is61lv25616al_model ( input wire [17:0] A, inout wire [15:0] DQ, input wire CE_n, input wire OE_n, input wire WE_n, input wire UB_n, input wire LB_n ); reg [15:0] mem [0:(1<<18)-1]; // 读操作:只有片选、输出使能同时有效时才驱动总线 assign DQ = (!CE_n && !OE_n && WE_n) ? mem[A] : 16'hzzzz; // 写操作:在 WE_n 上升沿锁存数据 reg [17:0] addr_latch; reg [15:0] data_latch; reg we_prev; always @(*) begin if (!CE_n && !WE_n) begin addr_latch = A; data_latch = DQ; end end always @(posedge WE_n) begin if (!CE_n) begin // 写入时同时考虑字节使能 if (!UB_n) mem[addr_latch][15:8] = data_latch[15:8]; if (!LB_n) mem[addr_latch][7:0] = data_latch[7:0]; end end endmodule

这个模型里,读操作是组合逻辑行为,OE# 一拉低,数据立即出现在总线上,没有模拟实际 SRAM 几纳秒的访问延迟。仿真时如果你关心 tAA 延迟,可以在 model 里加#10ns的延迟赋值。不过对于验证状态机流程,组合行为已经足够看到读写时序是否正确。

上面的写操作模型用posedge WE_n锁存数据,这是模拟异步 SRAM 内部锁存行为的关键点。实际芯片是在 WE# 上升沿那一刻把数据总线上的值抓进去,因此我们测试写入时,必须在 WE# 拉高之前让数据保持稳定,否则仿真中会出现 X 态或写入错误值。

4. 避坑指南:从仿真到上板最容易翻车的五件事

4.1 启动与复位类的坑

现象 1:上板后读取一直返回 0xFFFF 或 0x0000,仿真却一切正常。

这是最常见的“仿真过了,一上板就翻车”场景。仿真时 testbench 把地址线、数据线全部拉得干干净净,但真实 FPGA 上,SRAM 的引脚可能没接好、约束没做对,或者 OE# 和 WE# 的电平在初始化阶段不确定。原因主要有两个:一是复位后 sram_ce_n 没有立刻拉高,SRAM 在上电瞬间可能进入非法状态;二是 XDC 里数据总线引脚漏分配,综合工具默认把未用引脚当成普通 IO,导致物理上读不到数据。

解决方法是,先把复位逻辑拉长,至少保持几十微秒,确保 FPGA 配置完成后 SRAM 电源稳定、控制线确实输出确定电平。再检查 XDC 里是否给数据总线、地址总线分配了正确的 bank 电压和引脚。最后用 ILA 抓 sram_ce_n、sram_oe_n 和 sram_data_pin,看读周期里数据到底有没有出现在总线上。

现象 2:复位刚释放就开始连续读写,状态机忙信号处理不对。

如果 controller 之外的上层逻辑在 rst_n 拉高后的第一个周期就发起 start,此时 busy 可能还没有完全释放,导致 start 被漏掉。原因是 IDLE 状态下只判断start && !busy,但 busy 是由 DONE 状态拉低后一拍才变为 0。如果上层在 busy 为 1 时继续发访问请求,请求会被忽略。

解决办法是上层握手协议必须跟上。一般做法是每次发起访问前检测 busy 是否为低,检测到低后再打一拍 start。或者更简单的做法,把 start 当成“请求边沿”,只在 busy 拉低后的第二个周期才检测。我习惯在顶层加一个简单的 FIFO,把访问请求缓存起来,控制器空闲时从 FIFO 里取任务,这样就避免了 start/busy 的时序竞态。

4.2 读写时序类的坑

现象 3:写操作后马上读同一个地址,读回来的是旧值或不定值。

这是异步 SRAM 最经典的时序坑。SRAM 的写周期要求在 WE# 下降沿之前地址有效、CE# 有效;在 WE# 上升沿附近,数据必须满足建立时间和保持时间。很多初学者写的状态机在 WE# 拉高的同时就把 sram_data 总线释放掉,相当于数据和写结束边沿同时变化,SRAM 内部大概率锁存到高阻态或前一拍的数据。

解决方法是让数据总线多保持一拍。我上面代码里特意把 driving 信号放到 DONE 状态再拉低,而不是在 ACCESS 最后一个周期就释放。这样 WE# 拉高后的下一个时钟沿,数据仍然稳定,满足常见 SRAM 至少 0ns 的保持时间要求。如果你要加 margin,可以在 DONE 状态里再多等一个周期再释放。

现象 4:多个逻辑模块共享数据总线,读操作时出现冲突,甚至能感觉到芯片发烫。

IS61LV25616AL 的数据总线是双向的。当控制器处于读状态时,FPGA 一侧必须把总线释放掉(输出高阻);如果用户代码里不小心在同一个 inout 线上用了两个assign,或者顶层例化了两个 controller 都接在同一组引脚,读写时就会产生两个驱动源,轻则波形异常,重则短路发烫。

解决办法是数据总线在 FPGA 内部必须只经过一个三态驱动器。所有模块若要访问 SRAM,应当通过一个仲裁器或一个 controller 实例统一管理。我一般不会在多个 RTL 模块里直接引用 sram_data,而是把所有读写请求汇聚到一个 memory access 层,再由这一层去操作 controller。

现象 5:只修改了一个字节,另一个字节被意外覆盖。

因为 IS61LV25616AL 有 UB#/LB# 两个字节使能引脚,如果一直拉低,16 位访问和 8 位访问就无法区分。写入 8bit 数据时若 LB# 为低,低字节会被写,同时如果 UB# 也是低,高字节也会被写入——哪怕高字节数据总线是高阻,SRAM 也会把高阻值锁存进去,变成不确定数据。

解决办法是控制器增加一个byte_en[1:0]输入,写操作时根据它控制 UB#/LB#。例如只写低字节时,UB_n=1, LB_n=0;只写高字节时,UB_n=0, LB_n=1。读操作也可以按字节读,但读的时候未选中的字节数据不会进总线,rdata 对应位会变成 0 或保持之前的值,上层逻辑需要自行掩码。

5. 运行与验证:在 FPGA 上把数据写进去读出来

5.1 用你的综合工具跑仿真

拿到这个工程,第一步不是上板,而是跑仿真。在 sim 目录下打开 testbench,里面有完整的读写激励:先写地址 0x00000,再读回来比对;然后写几个随机地址,验证不同地址的读写是否正确。

如果你用的是开源仿真工具,可以直接用 iverilog 跑:

iverilog -g2012 -o sram_tb sim/sram_tb.v rtl/sram_controller.v sim/is61lv25616al_model.v vvp sram_tb

如果用的是 Vivado 或 Quartus 自带的仿真器,直接在工程里把 sram_controller.v 和 is61lv25616al_model.v 加入仿真文件集,然后运行 behavioral simulation。仿真脚本一般长这样(Vivado XSim 命令):

launch_simulation add_wave /sram_tb/* run -all

仿真波形里重点看几个信号:cnt递增时,sram_we_n和sram_oe_n是否按访问类型变化;写操作时sram_data是否在 WE# 上升沿前后保持稳定;读操作时 rdata 采到的值和写进去的值是否一致。

testbench 里还有一个check_result任务,它会自动比较写入和读出的数据,并在终端打印 PASS/FAIL 信息。仿真通过后,再进行上板实测。

5.2 通过 ILA 或逻辑分析仪进行板级验证

上板验证有两种常见做法。第一种是直接用 FPGA 内部逻辑分析仪(ILA),在综合工具里把 sram_ce_n、sram_we_n、sram_oe_n、sram_addr、sram_data 这些信号加入 debug 核,设置触发条件为start == 1或busy == 1,跑一段时间后拉取波形。ILA 的好处是不需要额外外部仪器,缺点是它会占用 FPGA 内部 BRAM 和布线资源,而且触发深度有限。

第二种做法是在工程里加一个 UART 串口调试模块,PC 通过串口发送“写地址+数据”或“读地址”命令,FPGA 解析后调用 sram_controller,再把结果通过串口发回。这个方式适合持续读写测试,尤其适合验证 256K 地址范围内的随机读写稳定性。

我建议第一次上板先做固定地址的回环测试:写 0xAAAA、0x5555、0x1234,再读回来,把结果通过 UART 输出。如果这三个地址都通过,再遍历整片内存写一遍递增数据再读一遍校验。这个流程能快速暴露地址线虚焊、数据线接反、字节使能错误等物理层问题。

5.3 约束文件中的关键设置

引脚约束和时序约束是上板前不能省的一步。在 constraints 文件里,至少要做三件事。

第一,分配物理引脚。地址线 A[17:0]、数据线 D[15:0]、控制线都要映射到 FPGA 封装的某个 bank 脚位,并且确保 bank 电压是 3.3V,与 SRAM 电源一致。引脚分配错了,仿真再漂亮也没用。

第二,创建时钟约束。如果你的系统时钟是外部 50MHz 晶振,就在 XDC 里写:

create_clock -name sys_clk -period 20.000 [get_ports clk]

如果系统时钟进来后经过 PLL 倍频到 100MHz,需要把 PLL 输出引脚作为主时钟源,让综合工具正确计算所有同步逻辑的时序路径。

第三,对 SRAM 接口做一张输入输出延迟约束。常见做法是设置set_output_delay和set_input_delay,把 SRAM 的 tAA、tDOE 换算成 FPGA 时钟的延迟窗口。对于第一次跑通,可以先只做时钟约束,因为控制器状态机极低的时钟频率(比如 50MHz 下访问周期 80ns)给足了时序余量;但如果你想跑接近 SRAM 极限的访问速度,delay 约束必须补上,否则综合工具会把 I/O 路径优化得过于乐观,上板后才发现采样边沿不对。

验证完成的标准是:整片内存能连续写 0x0000 到 0xFFFF 再回读校验通过,过程中没有出现一例数据翻转。如果出现偶发性错误,优先把 CLK_CYCLES 参数调大一个周期,再把示波器或 ILA 波形拉出来对比 SRAM 数据手册上的时序参数,看是哪一步没满足。

6. 二次开发:从“能读写”到“好用”

这个控制器最值得改的是它的参数化和握手方式。第一个简单改造是加字节写使能。给 sram_controller 增加一个 input[1:0] byte_en,在 IDLE 状态时把 UB#/LB# 的赋值从固定拉低改为受 byte_en 控制。写低字节时byte_en[0]=1,LB# 拉低;写高字节时byte_en[1]=1,UB# 拉低。这样上一层把 16bit 数据总线和 8bit 数据单元访问统一起来,不需要为 8bit 访问单开一个控制器。

第二个改造是加简单 FIFO 缓冲。如果你要连续读写一段数据,比如图像缓存,每写一个数据都要等待busy信号,效率是极低的。正确做法是在 controller 外面套一个 FIFO,写路径上,写数据先入 FIFO,controller 空闲时自动弹出并发送到 SRAM;读路径上,controller 读回来的数据写入另一个 FIFO,上层逻辑只从 FIFO 取。这样上层逻辑和 SRAM 访问并行,不互相等。常见代码是把 controller 的start和busy变成一个简单的状态仲裁器,只要有数据就发起访问。

第三个改造是读突发模式。IS61LV25616AL 是异步 SRAM,每访问一次都要重新等待地址到数据时间,但如果把地址线在外设时钟下周期性递增,就可以把读访问效率逼近“连续地址、流水线采样”。做法是把 CLK_CYCLES 参数减小到 2,在状态机里增加一个burst_len计数器,每次访问完成后如果还有下一笔地址,就不回 IDLE,而是直接进入下一个 ACCESS,只保持 CE# 为低。这个设计能显著提高图像行缓存场景下的吞吐量。

我亲身经历的一次教训是,第一版控制器把读数据采样放在 DONE 状态,而不是 ACCESS 最后一个周期,导致上板后总是读到上一拍地址的数据。后来我在仿真里把 sram_data 波形逐拍拉出来,才意识到采样时刻晚了一个周期。从那以后,我每设计一个外部器件控制器,都会在 testbench 里加一条“地址变化后第 N 拍数据变化”的断言,把采样窗口卡得死死的。希望这篇文章能帮你少走一次我走过的弯路,让 IS61LV25616AL 在 FPGA 上真正听话起来。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进