第二十五章 伪双端口RAM IP核实验

对应《正点原子 ZYNQ FPGA 开发指南》第二十五章(约 P580~P600),本章无外部硬件参与,实验结果通过 ILA 在线调试观察。

本章在讲什么?(先看这个)

上一章的单端口 RAM 有个天然限制:读和写共用一个端口,同一时刻只能干一件事。而很多实际场景(最典型的就是下一章的 FIFO)要求一边写入、一边读出,同时进行——这就要用双端口 RAM。

双端口 RAM 分两种:伪双端口(端口 A 只能写、端口 B 只能读)和真双端口(两个端口都能读能写)。本章以更常用的伪双端口为例,重点解决一个新问题:读写冲突——当读写同时打向同一个地址时,读出来的数据是不可靠的。本章用"错开读写地址"的方法来规避它。

实验内容与上一章同构:BMG 配置成伪双端口 RAM(深度 64、位宽 8bit),写模块循环写入,写完一半后用 rd_flag 启动读模块,仿真 + ILA 验证读写不冲突。

核心概念拆解

25.1 RAM IP 核简介

三种静态 RAM 的框图对比一目了然:

  • 单端口 RAM:一组信号(DINA/ADDRA/WEA/DOUTA...),读写共用;
  • 伪双端口 RAM:端口 A 只有写入侧(DINA/CLKA/WEA/ADDRA),端口 B 只有读出侧(DOUTB/CLKB/ADDRB),两组信号完全独立,可以用两个不同的时钟
  • 真双端口 RAM:相当于把单端口 RAM 的全部信号复制一份成 A/B 两组,两个端口都能读写。

图25.1.1 单端口RAM框图

图25.1.2 简单(伪)双端口RAM框图

图25.1.3 真双端口RAM框图

伪双端口 RAM 的框图里还有几个 ECC 相关信号(INJECTSBITERR、SBITERR 等),它们只在启用纠错码(ECC,单 bit 纠错/双 bit 检错)时才有意义,一般不用管。

两个必须理解的冲突概念:

  1. 读写冲突:同一时刻读写同一地址。你刚写进去的数据其实还没落进存储体,读出来的可能是默认值或旧值——读优先模式下读到旧数据,写优先模式下读到未知数据 "XX"。

图25.1.4 RAM读写冲突示意图

  1. 写写冲突:两个端口的写使能同时有效且地址相同,该地址最终写入的是未知数据 "XX"。任何双端口 RAM 都不支持写写冲突,必须从设计上杜绝(伪双端口因为 A 只写、B 只读,天然不会发生)。

图25.1.5 RAM写写冲突示意图

规避读写冲突有两条路:

  • 错开读写地址(本章采用):让读地址和写地址永不指向同一地址;
  • 写穿通到读(write-through):把最新写入的数据寄存一拍同步到读侧,检测到读写同地址时直接把这份"旁路数据"给读端口。

图25.1.6 RAM读写冲突处理

25.2 实验任务

将 BMG IP 核配置成同步(Common Clock)伪双端口 RAM 并读写,仿真观察波形,下载后用 ILA 验证读写无冲突。

25.3 程序设计

整体模块设计

因为读写信号完全独立,本章把读写拆成两个子模块:ram_wr(写模块,额外输出读启动信号 rd_flag)和 ram_rd(读模块),顶层 ip_2port_ram 负责连线。RAM 仍是纯内部存储,顶层只有时钟和复位两个输入。

图25.3.1 伪双端口RAM读写实验系统框图

RAM IP 核配置

与上一章流程完全一致,只有两处不同:

  • Memory Type 选 Simple Dual Port RAM
  • 勾选 Common Clock(同步时钟)——本次读写都用 50MHz 系统时钟。不勾的话 A/B 端口可以有各自独立的时钟,这正是伪双端口能做跨时钟域数据缓冲的原因(下一章 FIFO 的读写时钟就不同)。

ECC Options 保持 No ECC;Port A(写深度 64、写位宽 8)、Port B(读深度 64、读位宽 8)配置方法同上一章,记住规则:只有端口 A 的写位宽/写深度可任意配,端口 B 的读深度会自动确定。其余选项卡保持默认,OK → Generate。

图25.3.4 Basic选项卡配置界面

RAM 写模块设计

写模块的设计要点:复位后写端口使能和写使能一直为高,写地址在 0~63 循环;当写地址第一次计数到 31 时拉高 rd_flag 并保持,用这个"晚半程启动"把读地址和写地址错开。

图25.3.10 RAM写模块波形图

module ram_wr(
    input            clk         , //时钟信号
    input            rst_n       , //复位信号,低电平有效

    //RAM 写端口操作
    output            ram_wr_we  ,  //ram 写使能
    output reg        ram_wr_en  ,  //端口使能
    output reg        rd_flag    ,  //读启动信号
    output reg  [5:0] ram_wr_addr,  //ram 写地址
    output      [7:0] ram_wr_data   //ram 写数据
);

//------------------------------------------------
//ram_wr_we 为高电平表示写数据
assign ram_wr_we = ram_wr_en ;

//写数据与写地址相同,因位宽不等,所以高位补0
assign ram_wr_data = {2'b0,ram_wr_addr} ;

//控制RAM 使能信号
always @(posedge clk or negedge rst_n) begin
    if(!rst_n)
        ram_wr_en <= 1'b0;
    else
        ram_wr_en <= 1'b1;
end

//写地址信号 范围:0~63
always @(posedge clk or negedge rst_n) begin
    if(!rst_n)
        ram_wr_addr <= 6'd0;
    else if(ram_wr_addr < 6'd63 && ram_wr_we)
        ram_wr_addr <= ram_wr_addr + 6'd1;
    else
        ram_wr_addr <= 6'd0;
end

//当写入32 个数据(0~31)后,拉高读启动信号
always @(posedge clk or negedge rst_n) begin
    if(!rst_n)
        rd_flag <= 1'b0;
    else if(ram_wr_addr == 6'd31)
        rd_flag <= 1'b1;
    else
        rd_flag <= rd_flag;
end

endmodule

写数据直接复用写地址({2'b0, ram_wr_addr} 高位补零),这样读出来的数据就能和地址一一对账,方便验证。

RAM 读模块设计

读模块更简单:rd_flag 拉高即读端口使能,读地址 0~63 循环。注意端口 B 只能读、没有写使能信号,使能后一直处于读状态。

module ram_rd(
    input            clk        ,   //时钟信号
    input            rst_n      ,   //复位信号,低电平有效

    //RAM 读端口操作
    input              rd_flag    ,    //读启动标志
    input       [7:0]  ram_rd_data,    //ram 读数据
    output             ram_rd_en ,     //端口使能
    output reg  [5:0]  ram_rd_addr     //ram 读地址
);
//------------------------------------------------
//控制RAM 使能信号
assign ram_rd_en = rd_flag;

//读地址信号 范围:0~63
always @(posedge clk or negedge rst_n) begin
    if(!rst_n)
        ram_rd_addr <= 6'd0;
    else if(ram_rd_addr < 6'd63 && ram_rd_en)
        ram_rd_addr <= ram_rd_addr + 6'd1;
    else
        ram_rd_addr <= 6'd0;
end

endmodule

顶层模块设计

顶层把写模块、RAM IP、读模块依次拉通,ILA 例化在顶层观察全部关键信号:

module ip_2port_ram(
    input       sys_clk    ,  //系统时钟
    input       sys_rst_n     //系统复位,低电平有效
);

//wire define
wire             ram_wr_en  ;  //RAM 端口A 使能
wire             ram_wr_we  ;  //RAM 端口A 写使能
wire             ram_rd_en  ;  //RAM 端口B 使能
wire             rd_flag    ;  //读启动标志
wire    [5:0]    ram_wr_addr;  //RAM 写地址
wire    [7:0]    ram_wr_data;  //RAM 写数据
wire    [5:0]    ram_rd_addr;  //RAM 读地址
wire    [7:0]    ram_rd_data;  //RAM 读数据

//------------------------------------------------
//RAM 写模块
ram_wr u_ram_wr(
    .clk             (sys_clk    ),
    .rst_n           (sys_rst_n  ),
    .rd_flag         (rd_flag    ),
    .ram_wr_en       (ram_wr_en  ),
    .ram_wr_we       (ram_wr_we  ),
    .ram_wr_addr     (ram_wr_addr),
    .ram_wr_data     (ram_wr_data)
);

//简单双端口RAM
blk_mem_gen_0 u_blk_mem_gen_0 (
  .clka   (sys_clk    ), // input wire clka
  .ena    (ram_wr_en  ), // input wire ena
  .wea    (ram_wr_we  ), // input wire [0 : 0] wea
  .addra  (ram_wr_addr), // input wire [5 : 0] addra
  .dina   (ram_wr_data), // input wire [7 : 0] dina
  .clkb   (sys_clk    ), // input wire clkb
  .enb    (ram_rd_en  ), // input wire enb
  .addrb  (ram_rd_addr), // input wire [5 : 0] addrb
  .doutb  (ram_rd_data)  // output wire [7 : 0] doutb
);

//RAM 读模块
ram_rd u_ram_rd(
    .clk           (sys_clk    ),
    .rst_n         (sys_rst_n  ),
    .rd_flag       (rd_flag    ),
    .ram_rd_en     (ram_rd_en  ),
    .ram_rd_addr   (ram_rd_addr),
    .ram_rd_data   (ram_rd_data)
);

//ILA 在线调试
ila_0 u_ila_0 (
    .clk    (sys_clk    ),
    .probe0 (ram_wr_en  ),
    .probe1 (ram_wr_we  ),
    .probe2 (ram_rd_en  ),
    .probe3 (rd_flag    ),
    .probe4 (ram_wr_addr),
    .probe5 (ram_wr_data),
    .probe6 (ram_rd_addr),
    .probe7 (ram_rd_data)
);

endmodule

顶层波形图能看清整个时序编排:端口 A 先启动、一直写;写到一半 rd_flag 拉高启动端口 B;此后读写同时进行,但地址恒定相差 32,互不干涉。

图25.3.14 顶层模块信号波形图

25.4 仿真验证与下载验证

TB 仍只需时钟和复位激励。仿真波形有三个关键观察点:

  1. rd_flag 拉高后 ram_rd_en 才拉高,读模块随后开始工作;
  2. 一个时序逻辑细节:代码里是"写地址==31 时给 rd_flag 赋 1",但波形上 rd_flag 在写地址为 32 时才拉高——因为时序逻辑的赋值发生在下一个时钟上升沿,不是当拍生效。看波形时要有这个"延迟一拍"的意识;
  3. 读启动后写端口和读端口同时对 RAM 操作,读写地址恒定相差 32,没有发生读写冲突

图25.3.16 双端口RAM仿真波形图2

图25.3.17 双端口RAM仿真波形图3

下载验证:引脚仍只需 sys_clk(U18)、sys_rst_n(N16),LVCMOS33,外加 create_clock 时序约束。ILA 波形与仿真一致:写端口和读端口同时工作、无读写冲突,实验验证成功。

初学者容易踩的坑

  • 读写冲突不会报错,只会给你错数据。综合、实现、仿真(除非碰巧覆盖)都可能发现不了,上板后表现为"偶发读到旧值/未知值"。做双端口设计时先在纸上画出读写地址随时间的变化,确认永不相交。
  • 错开一个地址就够,不必错开 32 个。只要读写时钟同频,地址错开 1 个就不会撞上。本章错开 32 是为了在 ILA 上看得更清楚。
  • "地址==31 时 rd_flag 拉高"在波形上显示为地址 32 时拉高。这是时序逻辑一拍延迟的正常现象,不是代码 bug。分析波形永远要把寄存器输出比条件判断晚一拍算进去。
  • 伪双端口的端口 A 没有读功能,所以配置界面里端口 A 不存在 Operating Mode(写优先/读优先)这些选项——它们是"写的时候读数据线输出什么"的规则,只在单端口/真双端口上有意义。
  • 端口 B 使能后一直在读,没有"读使能"可关。如果需要"只读特定几个地址",要靠控制 ENB 的高低来实现,而不是去找一个不存在的读使能信号。
  • Common Clock 勾不勾要想清楚。勾了表示 A/B 共用一个时钟(本章场景);不勾则读写可以各自给时钟,做跨时钟域缓冲时才需要——但也随之引入跨时钟域约束问题,初学阶段先用同步时钟。

小结 & 下一步

本章完成了存储器三部曲的第二部:理解了伪双端口 RAM"写读分家、可双时钟"的结构,掌握了读写冲突/写写冲突的概念及"错开地址"这一最简单的规避手段,rd_flag 这种"写侧通知读侧开工"的握手思路也是 FPGA 设计里的常见手法。真双端口 RAM 的用法作为拓展训练(两个读写模块各管一个端口、分区读写规避两种冲突),有兴趣可以自行挑战。

下一步进入第二十六章:FIFO IP 核实验。FIFO 的内部存储用的正是伪双端口 RAM,而读写时钟可以不同——届时还会引入跨时钟域、空满标志同步等更进阶的概念。