异步SRAM行为模型:Verilog时序建模与仿真验证实战
1. 项目概述:从一份SRAM模型代码说起
最近在调试一个老项目的FPGA板卡,主控和一块ISSI的IS61LV25616异步SRAM通信总是不稳定。硬件工程师拍胸脯说电路没问题,那问题大概率出在我的FPGA逻辑时序上。为了在仿真阶段就把问题揪出来,我需要一个能精确模拟这块SRAM时序行为的Verilog模型。网上一通翻找,终于在一个老外的开源项目里找到了一个可用的模型文件,就是开头贴出来的那段代码。这可不是一个简单的“存储器数组”,它包含了关键的时序参数和specify块,能配合仿真器进行时序检查,对于验证FPGA读写逻辑的严谨性至关重要。很多刚接触硬件描述语言的朋友,容易把Testbench和RTL模型混为一谈,或者认为模型只要功能对就行。实际上,一个高质量的器件行为模型,尤其是带时序的,是连接理想RTL世界和真实物理器件的桥梁。它能让你的仿真结果无限接近实际板级调试,提前发现潜在的时序违规问题,节省大量示波器抓波形的时间。这篇文章,我就以这个IS61LV25616的模型为例,拆解一下如何理解、使用乃至自己动手编写一个实用的异步SRAM行为模型,这不仅是FPGA/CPLD开发者的基本功,对做MCU嵌入式软件、芯片验证甚至板级硬件设计的工程师都有参考价值。
2. 核心需求解析:为什么我们需要带时序的模型?
在数字系统设计中,我们通常用Verilog或VHDL来描述硬件行为。对于SRAM、Flash这类存储器,如果只在仿真中用一个简单的寄存器数组来模拟,会发生什么?比如,你写一个reg [15:0] mem [0:262143];,然后直接assign data_out = mem[addr];。这确实能实现存储功能,但它忽略了一个致命问题:时间。
真实的物理芯片有延迟。从地址线稳定到数据有效,需要时间(tAA,地址访问时间);从片选有效到输出有效,需要时间(tCE,片选到输出有效时间);写信号撤除后,数据还需要保持一段时间(tDH,数据保持时间)。如果你的FPGA逻辑在地址变化后5ns就去锁存数据,而SRAM的实际tAA是10ns,那么在板子上必然读到错误数据。仿真时用理想模型却一切正常,这就埋下了巨大的隐患。
因此,一个合格的SRAM行为模型必须至少包含两方面:
- 功能正确性:正确响应读、写、字节使能等操作。
- 时序真实性:模拟关键时序参数,如传播延迟、三态输出切换时间,并能配合仿真工具进行建立/保持时间检查。
开头给出的IS61LV25616模型正是这样做的。它通过#延迟语句模拟输出延迟,通过specify块定义时序检查规则。这样,当你的Testbench驱动信号不满足SRAM数据手册要求时,仿真器就会报出警告或错误,让你在流片或制板前就意识到时序问题。这比“板子跑不起来,用逻辑分析仪抓半天”要高效得多。
3. 模型代码深度拆解
3.1 模块接口与参数定义
`timescale 1ns/1ns module IS61LV25616 (A, IO, CE_, OE_, WE_, LB_, UB_); parameter dqbits = 16; parameter memdepth = 262143; // 256K = 2^18 = 262144个地址,但地址从0到262143 parameter addbits = 19; // 需要19根地址线寻址256K (2^18),但这里用19,因为262143 < 2^19 input CE_, OE_, WE_, LB_, UB_; input [(addbits - 1) : 0] A; inout [(dqbits - 1) : 0] IO;timescale:定义了仿真时间单位和精度,这里是1纳秒/1纳秒。所有#后面的延迟数值都以此为单位。- 接口信号:这是典型的异步SRAM接口。
A:地址总线,19位宽,可寻址2^19 = 512K个位置,但本芯片只用了低256K。IO:16位双向数据总线。注意是inout类型,因为同一组引脚既要读也要写。CE_:片选(Chip Enable),低有效。这是总开关,为高时芯片不工作,数据线高阻。OE_:输出使能(Output Enable),低有效。控制读操作时数据是否输出。WE_:写使能(Write Enable),低有效。控制写操作。LB_,UB_:低字节、高字节使能,低有效。允许单独写入或读取数据的高8位或低8位。
- 参数化:
dqbits,memdepth,addbits使得模型稍作修改就能适配不同容量的SRAM,增强了复用性。
注意:模型中的
memdepth设为262143(即2^18 - 1),但addbits是19。这看起来有点不一致,但实际仿真中,地址线A[18]会被忽略,因为存储阵列只有256K深度。这是一种简化处理,确保地址输入不会越界。在实际应用中,你的控制器地址线不应超过18位有效位。
3.2 存储阵列与读写控制逻辑
reg [(dqbits/2 - 1) : 0] bank0 [0 : memdepth]; // 低字节存储体 reg [(dqbits/2 - 1) : 0] bank1 [0 : memdepth]; // 高字节存储体 wire r_en = WE_ & (~CE_) & (~OE_); // 读使能条件:WE=1, CE=OE=0 wire w_en = (~WE_) & (~CE_) & ((~LB_) | (~UB_)); // 写使能条件:WE=CE=0, 且至少一个字节使能有效- 存储阵列:用两个独立的8位宽、256K深的
reg数组bank0和bank1来模拟16位SRAM。这种分开建模的方式完美支持了字节使能操作。你可以单独写bank0(低字节)而不影响bank1。 - 读写使能信号:
r_en:读操作的核心条件是WE_为高(非写状态)、CE_和OE_都为低(芯片选中且允许输出)。注意,异步SRAM的读操作不依赖时钟。w_en:写操作的核心条件是WE_和CE_都为低(芯片选中且写使能),并且LB_或UB_至少有一个为低(至少有一个字节被使能)。这里OE_在写操作时是“无关项”(don‘t care),模型中用((~LB_) | (~UB_))巧妙地忽略了它。
3.3 输出延迟与三态控制
这是模型模拟时序的关键部分。
assign #(r_en ? Taa : Thzce) IO = r_en ? dout : 16'bz; assign dout [(dqbits/2 - 1) : 0] = LB_ ? 8'bz : bank0[A]; assign dout [(dqbits - 1) : (dqbits/2)] = UB_ ? 8'bz : bank1[A];- 数据输出 (
dout):根据LB_和UB_的状态,决定从哪个存储体读取数据。如果字节使能无效(为高),则对应字节输出高阻态z。这模拟了SRAM内部输出驱动器的行为。 - 双向端口驱动 (
IO):这是一个带有条件延迟的连续赋值语句。IO = r_en ? dout : 16‘bz;:当读使能r_en有效时,将dout的值驱动到IO总线上;否则,将IO总线置为高阻态(16‘bz),允许外部控制器驱动它进行写操作。#(r_en ? Taa : Thzce):这是惯性延迟。它模拟了信号变化的物理延迟。- 当从非读状态进入读状态(
r_en从0变1)时,延迟时间为Taa(地址访问时间)。这意味着地址稳定后,需要经过Taa时间,数据才会出现在IO上。 - 当从读状态退出到高阻状态(
r_en从1变0)时,延迟时间为Thzce(片选无效到输出高阻的时间)。这意味着CE_变高后,数据总线并不会立刻变高阻,而是会保持一段时间的数据,然后才变为高阻,防止总线冲突。
- 当从非读状态进入读状态(
Taa和Thzce这些参数值通过条件编译ifdef选择,对应芯片的不同速度等级(如10ns或12ns)。这种建模方式非常贴近数据手册的描述。
3.4 写操作建模
always @(A or w_en) begin #Tsa // 地址建立时间 if (w_en) #Thzwe begin bank0[A] = LB_ ? bank0[A] : IO [(dqbits/2 - 1) : 0]; bank1[A] = UB_ ? bank1[A] : IO [(dqbits - 1) : (dqbits/2)]; end end写操作的建模用了一个always块,敏感列表是地址A和写使能w_en。这模拟了异步SRAM的写时序:当地址或写条件变化时,可能触发写操作。
#Tsa:模拟地址建立时间(Address Setup Time)。在写使能WE_有效之前,地址必须已经稳定至少Tsa时间。模型通过先延迟Tsa,再检查w_en来隐含这一要求。如果实际电路中地址在WE_有效前稳定时间不足,这个模型可能无法正确捕获,但后续的specify块会检查。if (w_en):检查写条件是否真正满足。#Thzwe:模拟写使能无效后数据保持时间(Write Enable to High-Z Time?这里更像写脉冲宽度后的保持)。延迟Thzwe后,才将数据总线IO上的值锁存到存储阵列中。Thzwe可以理解为从WE_变高到数据被真正写入内部锁存的时间。- 条件写入:根据
LB_和UB_的状态,决定更新哪个存储体。如果字节使能为高,则保持原值不变,实现了字节写操作。
实操心得:这个写模型采用
always @(A or w_en)是一个巧妙但需要注意的写法。它意味着任何A或w_en的变化都会进入这个过程块。在仿真中,如果地址在写周期内变化,可能会触发多次执行,导致非预期的行为。这要求Testbench必须严格遵守数据手册的时序,确保写周期内地址稳定。一个更严谨的模型可能会用always @(posedge w_en)之类的边沿触发,但异步SRAM的写通常由WE_的下降沿和上升沿定义,这里用电平敏感也是一种常见简化。
3.5 时序检查规范 (Specify Block)
这是模型中最具价值的部分之一,它利用了Verilog的时序检查系统任务,让仿真器(如ModelSim、VCS)自动检查输入信号是否满足SRAM的时序要求。
specify specparam tSA = 0, tAW = 8, tSCE = 8, tSD = 6, tPWE2 = 10, tPWE1 = 8, tPBW = 8; $setup (A, negedge CE_, tSA); $setup (A, posedge CE_, tAW); $setup (IO, posedge CE_, tSD); // ... 更多$setup和$width检查 endspecifyspecparam:定义时序检查中使用的参数,这些值直接从IS61LV25616的数据手册中得来。tSA: 地址相对于CE_下降沿的建立时间。tAW: 地址相对于CE_上升沿的建立时间(实为地址保持时间)。tSCE:CE_低电平的最小脉冲宽度。tSD: 数据相对于CE_上升沿的建立时间(写周期结束前数据必须稳定的时间)。tPWE2/tPWE1:WE_低电平的最小脉冲宽度(写脉冲宽度),根据OE_模式选择。tPBW: 字节使能LB_/UB_低电平的最小脉冲宽度。
- 系统任务:
$setup(data, clock, limit): 检查data信号在clock参考事件之前必须稳定至少limit时间。例如$setup (A, negedge CE_, tSA)检查地址A在CE_下降沿之前tSA时间必须稳定。$width(edge, limit): 检查脉冲的宽度。例如$width (negedge CE_, tSCE)检查CE_低电平的脉冲宽度必须至少为tSCE。
当你的Testbench产生的信号违反这些规则时,仿真器会在控制台输出详细的警告信息,例如“Setup violation detected...”。这是调试时序问题的黄金线索。
4. 如何为你的SRAM编写行为模型
拿到一个SRAM的数据手册,如何从头开始编写一个类似的模型?这里分享我的步骤和技巧。
4.1 第一步:精读数据手册,提取关键参数
不要只看首页摘要,必须找到“AC CHARACTERISTICS”或“TIMING WAVEFORMS”章节。以异步SRAM为例,你需要关注以下核心参数:
| 时序参数符号 | 含义 | 说明 |
|---|---|---|
| tRC | 读周期时间 | 连续两次读操作的最小间隔 |
| tAA | 地址访问时间 | 从地址有效到数据输出有效的时间 |
| tACE | 片选访问时间 | 从CE_有效到数据输出有效的时间 |
| tOE | 输出使能时间 | 从OE_有效到数据输出有效的时间 |
| tOH | 输出保持时间 | 地址/片选无效后,数据保持有效的时间 |
| tWC | 写周期时间 | 连续两次写操作的最小间隔 |
| tWP | 写脉冲宽度 | WE_低电平的最小持续时间 |
| tSA | 地址建立时间 | 写使能WE_有效前,地址必须稳定的时间 |
| tSD | 数据建立时间 | 写使能WE_无效前,数据必须稳定的时间 |
| tHD | 数据保持时间 | 写使能WE_无效后,数据必须保持的时间 |
| tHZWE,tHZCE | 输出高阻时间 | WE_/CE_无效后,数据总线变为高阻的时间 |
把这些参数整理成一个表格,并注明最小值、典型值、最大值。建模时通常使用最大值(最坏情况)进行延迟和检查,以确保设计在最坏条件下也能工作。
4.2 第二步:定义模块接口与内部存储
根据数据手册的引脚描述,定义模块的输入输出。内部用reg数组实现存储。对于有字节使能的,建议用多个数组分开建模,逻辑更清晰。
module my_sram_model ( input [ADDR_WIDTH-1:0] A, inout [DATA_WIDTH-1:0] DQ, input CE_n, input OE_n, input WE_n, input [BYTE_WIDTH-1:0] BEn_n // 如果有字节使能 ); parameter DATA_WIDTH = 16; parameter ADDR_WIDTH = 20; parameter DEPTH = 2**ADDR_WIDTH; reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; // 或者分字节存储 // reg [DATA_WIDTH/2-1:0] bank_low [0:DEPTH-1]; // reg [DATA_WIDTH/2-1:0] bank_high [0:DEPTH-1];4.3 第三步:实现读操作与输出延迟
读操作的核心是控制双向端口DQ。需要根据CE_n、OE_n、WE_n的状态判断是否处于读模式,并施加对应的延迟。
wire read_enable = (CE_n == 1‘b0) && (OE_n == 1’b0) && (WE_n == 1‘b1); wire [DATA_WIDTH-1:0] internal_data_out = mem[A]; // 或根据字节使能组合 // 关键:使用条件延迟驱动输出 assign #(read_enable ? tAA : tHZCE) DQ = read_enable ? internal_data_out : {DATA_WIDTH{1‘bz}};这里tAA和tHZCE就是第一步提取的参数。注意,当从读模式退出时,延迟tHZCE后输出才变高阻,这模拟了真实器件的总线释放时间。
4.4 第四步:实现写操作与数据锁存
写操作通常用always块对地址和写使能信号电平敏感。重点是要模拟建立时间和保持时间的要求。
always @(A or WE_n or CE_n or BEn_n) begin // 1. 模拟地址/控制信号建立时间要求:先等待tSA #tSA; // 2. 检查当前是否满足写条件 if ((CE_n == 1‘b0) && (WE_n == 1’b0) && (|BEn_n == 1‘b0)) begin // 至少一个字节使能有效 // 3. 等待写脉冲宽度和数据的建立时间(这部分通常隐含在tSD中,模型里可能简化) // 4. 在WE_n的上升沿(或之后)锁存数据?异步SRAM通常在WE_n上升沿锁存。 // 一种常见方法是等待一个写周期,然后采样数据。 wait(WE_n == 1’b1); // 等待写使能变高(写结束) #tSD; // 确保数据在WE_n变高前已稳定tSD时间(这里用延迟模拟) // 5. 根据字节使能,更新对应的存储单元 if (BEn_n[0] == 1‘b0) mem[A][7:0] = DQ[7:0]; if (BEn_n[1] == 1’b0) mem[A][15:8] = DQ[15:8]; // 6. 模拟数据保持时间tHD?通常不需要在模型中主动保持,由Testbench保证。 end end这个写法比原始模型更复杂,但更贴近实际写时序。原始模型的写法#Tsa if (w_en) #Thzwe begin ...是一种高度简化的合并。
4.5 第五步:添加时序检查Specify块
这是将模型从“功能模拟”升级为“时序验证”的关键。根据数据手册的时序图,将$setup、$hold、$width等检查任务添加进去。
specify // 定义时序参数 specparam tSA = 0, tHA = 0; // 举例 specparam tSD = 6, tHD = 0; specparam tPWE = 10; // 写脉冲宽度 specparam tSCE = 8; // 片选脉冲宽度 // 时序检查 // 地址相对于WE_n下降沿的建立/保持时间 $setup(A, negedge WE_n, tSA); $hold(negedge WE_n, A, tHA); // 数据相对于WE_n上升沿的建立/保持时间 $setup(DQ, posedge WE_n, tSD); $hold(posedge WE_n, DQ, tHD); // 脉冲宽度检查 $width(negedge WE_n, tPWE); $width(negedge CE_n, tSCE); endspecify避坑技巧:
specify块中的检查是静态时序检查,它不依赖于模块内部的行为逻辑。即使你的always块写错了,只要Testbench的信号违反规则,检查依然会触发。因此,务必确保specify块中的参数和信号边沿关系与数据手册完全一致。建议一边看数据手册的波形图,一边编写$setup和$hold语句。
4.6 第六步:编写全面的Testbench进行验证
模型写好后,必须用Testbench验证其功能和时序。一个好的Testbench应该:
- 验证正常功能:进行连续的读、写、字节读写操作,检查数据是否正确存储和读取。
- 验证时序违规:故意制造违反建立时间、保持时间、脉冲宽度的场景,确认仿真器能正确报告
Timing Violation。 - 验证延迟行为:检查输出延迟
tAA、高阻延迟tHZ等是否被正确模拟。可以在Testbench中打印时间戳来验证。 - 覆盖边界情况:测试地址边界、数据全0全1、快速连续的读写切换等。
initial begin // 初始化 CE_n = 1‘b1; OE_n = 1’b1; WE_n = 1‘b1; DQ = 16’hz; // 测试1:正常写操作(满足时序) #100; A = 0; DQ = 16‘h1234; CE_n = 1’b0; #tSA; // 满足地址建立时间 WE_n = 1‘b0; #tPWE; // 满足写脉冲宽度 WE_n = 1’b1; #tSD; // 满足数据保持时间(相对于WE_n上升沿) DQ = 16‘hz; CE_n = 1’b1; // 测试2:制造建立时间违规 #200; A = 1; CE_n = 1‘b0; #(tSA - 1); // 比要求的建立时间少1ns! WE_n = 1’b0; // 期待仿真器报告Setup Violation on signal A wrt negedge WE_n ... end5. 常见问题与排查技巧实录
在实际使用这类模型或编写自己的模型时,我踩过不少坑。这里总结几个典型问题和解决方法。
5.1 仿真中出现“X”或“Z”传播问题
问题描述:在仿真中,SRAM模型内部或输出数据总线DQ上出现了未知态X或高阻态Z,并传播到了整个系统,导致仿真结果不可信。
原因分析:
- 未初始化存储阵列:Verilog中的
reg数组默认值是X。如果上电后未进行写操作就直接读,会读出X。 - 双向端口冲突:当SRAM输出高阻
Z,而外部控制器也输出高阻Z时,总线电平不确定。如果外部控制器在不应驱动的时候驱动了总线(比如SRAM正在输出时,控制器也输出),就会产生总线冲突,可能表现为X。 - 时序竞争:如果模型内部
assign语句的延迟设置不当,可能导致短暂时间内多个源驱动同一信号,产生X。
解决方案:
- 初始化存储器:在
initial块中使用循环或系统任务$readmemh/$readmemb初始化SRAM内容。initial begin for (integer i=0; i<DEPTH; i=i+1) mem[i] = 0; // 或者从文件加载 // $readmemh("sram_init.hex", mem); end - 严格遵循读写周期:在Testbench中,确保在读周期,控制器将
DQ置为高阻;在写周期,SRAM模型将DQ置为高阻。仔细检查CE_n、OE_n、WE_n的状态机。 - 检查延迟模型:确保
#(r_en ? Taa : Thzce)这类条件延迟的逻辑正确。可以用$display打印r_en和IO的值,观察状态切换点。
5.2 时序违规警告看不懂或找不到
问题描述:仿真器报告了一堆$setup或$holdviolation,但警告信息冗长,不知道具体是哪个信号、相对于哪个时钟边沿出了问题。
排查技巧:
- 提高警告信息粒度:在仿真命令行中,通常可以设置时序检查的详细程度。例如在ModelSim中,
vsim +notimingchecks会关闭所有检查,而vsim默认开启。更细的可以通过tcheck命令控制。 - 理解警告格式:典型的警告如:
# ** Warning: (vsim-3037) ... $setup( posedge A:1000 ps, posedge CLK:1500 ps, 2000 ps );。这表示信号A在CLK上升沿(发生在1500ps)之前,需要在1000ps就稳定,但实际稳定时间不满足2000ps的要求。你需要计算实际稳定时间 = 1500ps - 1000ps = 500ps < 2000ps,违规。 - 在波形图中定位:将相关信号(
A,CE_n,WE_n,DQ)添加到波形窗口,找到警告信息中提到的具体时间点(如上面的1000ps和1500ps),观察信号跳变是否真的不符合要求。这能最直观地发现问题。 - 检查
specify块定义:确认$setup(data_event, reference_event, limit)中的data_event和reference_event是否与数据手册的波形图对应。常见的错误是把边沿搞反了。
5.3 模型行为与数据手册波形对不上
问题描述:自己编写的模型,在仿真中的时序波形和数据手册给出的示意图不一致,比如输出使能OE_n无效后,数据总线变成高阻的时间点不对。
根因与调整:
- 延迟参数理解错误:数据手册的
tHZOE(输出使能无效到高阻)和tHZCE(片选无效到高阻)可能不同。你的模型是否区分了这两种情况?原始模型只用了Thzce,可能做了简化。更精确的模型需要根据CE_n和OE_n哪个先变无效来决定使用哪个参数。 - 惯性延迟与传输延迟:Verilog的
#延迟是惯性延迟。如果输入脉冲宽度小于延迟时间,该脉冲会被“过滤”掉。对于tAA这类延迟,用惯性延迟assign #Taa dout = ...是合适的。但对于某些保持时间,可能需要用到传输延迟(在always块内用非阻塞赋值加延迟建模更复杂)。需要根据参数的具体含义选择建模方式。 - 复杂行为的简化:一些SRAM有“写通过”特性,即在写周期内,如果
OE_n有效,当前写入的数据会同时出现在输出上。还有些SRAM的深度和宽度可以通过引脚配置。这些复杂行为在基础模型中可能被省略了。编写模型前,要明确你的验证目标,决定需要模拟到多细的粒度。
5.4 仿真速度过慢
问题描述:当SRAM容量很大(比如1MB以上),或者Testbench进行长时间、大批量数据访问时,仿真速度变得极慢。
优化策略:
- 使用
integer索引代替reg数组:对于超大容量存储,Verilog仿真器处理大数组效率较低。可以考虑使用$readmemh初始化,并只在必要时访问。但行为模型通常避免这样,因为需要模拟任意地址访问。 - 简化时序检查:
specify块中的时序检查会显著降低仿真速度,尤其是在信号变化频繁时。在功能验证初期,可以注释掉specify块,或者使用编译指令+notimingchecks来禁用时序检查,先保证逻辑功能正确。 - 优化Testbench:避免在Testbench中使用过于密集的循环访问每一个地址。可以采用随机地址访问、边界测试和功能场景测试来代替全地址遍历。
- 考虑使用PLI/VPI:对于极大规模存储器的仿真,可以考虑用C语言通过PLI/VPI接口实现存储模型,性能会好很多,但复杂度也高。
6. 从模型到实际应用的思考
这个IS61LV25616模型,以及我们讨论的建模方法,其价值远不止于仿真本身。它代表了一种严谨的硬件设计思维。
对于FPGA工程师,当你用HDL描述一个外部器件接口时(如SPI Flash控制器、DDR3接口),本质上也是在为那个器件建立一个“简化模型”。这个模型存在于你的状态机、计数器、和数据路径中。理解器件的行为模型,能让你写出更健壮的控制器。例如,你知道SRAM的tAA是10ns,那么你的FPGA在发出地址后,必须等待至少10ns才能去采样数据总线,这个等待就需要用计数器或状态机来实现。
对于嵌入式软件工程师,理解底层存储器的访问时序同样重要。虽然C代码*ptr = value;看起来简单,但编译器生成的汇编指令,以及CPU总线周期,必须满足SRAM的时序要求。在配置MCU的外部存储器控制器(FSMC、FMC等)时,那些Address setup time、Data setup time的寄存器配置,其含义就来源于此。一个精准的仿真模型,可以帮助硬件工程师和软件工程师在硅前就协同验证驱动程序的正确性。
最后,关于开篇那句话“好的testbench比RTL代码更重要”。我深以为然。而一个好的testbench,离不开精准的器件模型。它就像一面镜子,能照出你设计中的所有瑕疵。花时间研究、编写、调试这些模型,看似是“辅助工作”,实则是提升设计质量、缩短调试周期的捷径。下次当你拿到一个芯片的数据手册,不妨尝试着为它写一个Verilog模型,这个过程会让你对时序的理解深入骨髓。
