跳转至

Wishbone SRAM 控制器

在 SRAM 的介绍中,我们讲到了如何实现读或者写 SRAM;在 Wishbone 总线协议的介绍中,我们讲到了总线协议的目的和实现。那么在这一章,我们就要把两者结合起来,来实现一个 Wishbone SRAM 控制器。这也是这个实验要做的主要目标。

简介

本文讲的是如何实现一个 Wishbone 协议的 SRAM 控制器。本文讲的是一种参考的实现,不一定最优的实现。

Wishbone Slave

在前面的内容中提到,Wishbone 分为 Master 和 Slave 两端,在这里,我们要实现 SRAM 的控制器,是处理请求的那一侧,因此我们要实现的是 Wishbone Slave。让我们回顾一下 Wishbone 总线协议的信号,这次是 Slave 端,除了时钟信号以外,都是输入变输出,输出变输入:

  1. CLK_I: 时钟输入,即自研总线中的 clock_i
  2. STB_I:高表示 master 要发送请求,即自研总线中的 valid_o
  3. ACK_O:高表示 slave 完成请求,即自研总线中的 ready_i
  4. ADR_I:master 想要读写的地址,即自研总线中的 addr_o
  5. WE_I:master 想要读还是写,即自研总线中的 we_o
  6. DAT_I:master 想要写入的数据,即自研总线中的 data_o
  7. SEL_I:master 读写的字节使能,即自研总线中的 be_o
  8. DAT_O:master 从 slave 读取的数据,即自研总线中的 data_i
  9. CYC_I:总线的使能信号,无对应的自研总线信号

再复习一下 Wishbone 的要点:

  1. STB_I=1, CYC_I=1 的时候,表示 master 正在发起请求
  2. STB_I=1, CYC_I=1, ACK_O=1 的时候,表示 slave 完成了当前的请求

为了实现 Wishbone Slave,我们通常采用一个状态机的方式,让我们来考虑一下需要哪些状态:

  1. 通常第一个状态是 IDLE,表示闲置
  2. STB_I=1, CYC_I=1 的时候,master 发起了请求,这时候要根据 master 的请求类型进行处理,在这里,我们需要分别处理读和写,所以需要状态 READ 和 WRITE
  3. 根据之前的分析,读需要两个周期,写需要三个周期,那就添加状态 READ_2,WRITE_2 和 WRITE_3
  4. 当读和写完成的时候,转移到 DONE 状态,设置 ACK_O=1,然后回到 IDLE 状态

根据上面的原则,可以画出来状态机:

flowchart LR
  IDLE -- STB_I && CYC_I && !WE_I --> READ;
  READ --> READ_2;
  READ_2 --> DONE;

  IDLE -- STB_I && CYC_I && WE_I --> WRITE;
  WRITE --> WRITE_2;
  WRITE_2 --> WRITE_3;
  WRITE_3 --> DONE;

  DONE --> IDLE;

在 SystemVerilog 中,我们可以用下面的语法来定义各个状态:

typedef enum logic [2:0] {
    STATE_IDLE = 0,
    STATE_READ = 1,
    STATE_READ_2 = 2,
    STATE_WRITE = 3,
    STATE_WRITE_2 = 4,
    STATE_WRITE_3 = 5,
    STATE_DONE = 6
} state_t;

接着,我们可以试着把上面的状态转移表写出来:

state_t state;

always_ff @ (posedge clock) begin
    if (reset) begin
        state <= STATE_IDLE;
    end else begin
        case (state)
            STATE_IDLE: begin
                if (STB_I && CYC_I) begin
                    if (WE_I) begin
                        state <= STATE_WRITE;
                    end else begin
                        state <= STATE_READ;
                    end
                end
            end
            STATE_READ: begin
                state <= STATE_READ_2;
            end
            // ...
        endcase
    end
end
Wishbone 总线中的 CLK_I 信号去哪儿了?

在实验中,Wishbone 总线的时钟和其余逻辑采用同一个时钟,因此这里就用 clock 代替了 CLK_I。在比较复杂的设计中,可能会出现一些逻辑用 A 时钟,总线相关逻辑用 B 时钟的情况。

SRAM 控制器

在状态机的基础上,我们要实现 SRAM 控制器,也就是之前讲述的,两周期读,三周期写的实现方式。

首先让我们来看读操作,对于一次读操作,需要经历如下的四个周期:

clockCYC_ISTB_IADR_I0x04SEL_I0b1111WE_IACK_ODAT_OASTATEIDLEREADREAD_2DONEIDLEram_addr0x01ram_dataAram_ce_nram_oe_nram_we_nram_be_n0b0000abcd
  1. 第一个周期(a):master 设置 CYC_I=1, STB_I=1, WE_I=0,此时状态是 IDLE,下一个状态是 READ
  2. 第二个周期(b):按照要求输出 addr, oe_n=0, ce_n=0, we_n=1, 根据 SEL_I=0b1111 可知四个字节都要读取,所以输出 be_n=0b0000,此时状态是 READ,下一个状态是 READ_2
  3. 第三个周期(c):这时候 SRAM 返回了数据,把数据保存到寄存器中,此时状态是 READ_2,下一个状态是 DONE
  4. 第四个周期(d):输出 ce_n=1, oe_n=1 让 SRAM 恢复空闲状态,设置 ACK_O=1,此时请求完成,状态是 DONE,下一个状态是 IDLE

照葫芦画瓢,对于一次写操作,需要经历如下五个周期:

clockCYC_ISTB_IADR_I0x04SEL_I0b1111WE_IDAT_IAACK_OSTATEIDLEWRITEWRITE_2WRITE_3DONEIDLEram_addr0x01ram_dataAram_ce_nram_oe_nram_we_nram_be_n0b0000abcde
  1. 第一个周期(a):master 设置 CYC_I=1, STB_I=1, WE_I=1,此时状态是 IDLE,下一个状态是 WRITE
  2. 第二个周期(b):按照要求输出 addr, data, oe_n=1, ce_n=0, we_n=1,根据 SEL_I=0b1111 可知四个字节都要写入,所以输出 be_n=0b0000,此时状态是 WRITE,下一个状态是 WRITE_2
  3. 第三个周期(c):按照要求输出 we_n=0,此时状态是 WRITE_2,下一个状态是 WRITE_3
  4. 第四个周期(d):按照要求输出 we_n=1,此时状态是 WRITE_3,下一个状态是 DONE
  5. 第四个周期(e):输出 ce_n=1 让 SRAM 恢复空闲状态,设置 ACK_O=1,此时请求完成,状态是 DONE,下一个状态是 IDLE

Wishbone 地址和 SRAM 地址的关系

需要注意,Wishbone 的地址的单位是字节,而 SRAM 的地址的单位是 4 字节,所以地址有一个四倍的关系。

状态机实现技巧

上面介绍了 SRAM 控制器的波形,那么如何从波形推导出具体的实现?下面我以写操作为例子,在上图 b 周期的时候,状态从 IDLE 变成 WRITE,并且 ram_ce_n 从 1 变为 0。前面已经提到过,我们会在时序逻辑,也就是 always_ff @ (posedge clock) 中更新 state

always_ff @ (posedge clock) begin
    // ...
    if (STB_I && CYC_I) begin
        if (WE_I) begin
            state <= STATE_WRITE;
        end
    end
end

那么,如何修改 ram_ce_n 呢?第一种思路是,我设定一个寄存器 ram_ce_n_reg,然后把寄存器的输出直接连接到 ram_ce_n 上。那么此时,为了实现上面的波形,需要保证在进入 WRITE 状态的同时,也修改 ram_ce_n_reg,这样就可以保证 ram_ce_nstate 同时更新:

reg ram_ce_n_reg;

always_ff @ (posedge clock) begin
    if (reset) begin
        state <= STATE_IDLE;
    end else begin
        // ...
        if (STB_I && CYC_I && state == STATE_IDLE) begin
            if (WE_I) begin
                ram_ce_n_reg <= 1'b0;
                state <= STATE_WRITE;
            end
        end
    end
end

always_comb begin
  ram_ce_n = ram_ce_n_reg;
end

这种方式的好处是:从寄存器到输出的延迟很小,适合用于访问外设的场景;缺点是实现的时候,需要根据上一个周期的状态进行判断和更新,如果状态比较复杂,例如同时有多个状态可以转移到 WRITE 状态,那么在每个转移的地方,都需要相应地设置 ram_ce_n_reg

另一种方式,是用组合逻辑计算出当前的 ram_ce_n

always_comb begin
    // default
    ram_ce_n = 1'b1;

    if (state == STATE_WRITE) begin
        ram_ce_n = 1'b0;
    end
end

这样的好处是减少了寄存器的使用,并且代码上比较简单;缺点是把组合逻辑的延迟引入了输出的路径上,可能会使得 SRAM 接口上的时序变得更长。

实际情况下,需要结合以上两种方法,来实现想要实现的波形。至于什么时候采用哪一种,需要同学们在实践的过程中多多体会。

SRAM 控制信号初始化

实现 SRAM 控制器的时候,可能会出现一个问题,就是在 FPGA 刚烧入 Bitstream 的时候,状态机还没有初始化,此时的 SRAM 控制信号(ce_nwe_noe_n)等可能处于 0,那么 SRAM 就会认为此时的 FPGA 在进行写操作,导致 SRAM 内的数据被覆盖。

解决方法是,在 initialreset 中对 SRAM 控制信号进行设置:

initial begin
    ram_ce_n_reg = 1'b1;
    ram_oe_n_reg = 1'b1;
    ram_we_n_reg = 1'b1;
end

assign ram_ce_n = ram_ce_n_reg;
assign ram_oe_n = ram_oe_n_reg;
assign ram_we_n = ram_we_n_reg;

always @ (posedge clock) begin
    if (reset) begin
        ram_ce_n_reg <= 1'b1;
        ram_oe_n_reg <= 1'b1;
        ram_we_n_reg <= 1'b1;
    end
end

扩展

实际上,上面的状态转移设计是比较保守的,可以尝试把 IDLE 和 READ/WRITE 合并,然后去掉 DONE,这样在 Wishbone 上也实现了两周期读和三周期写。如果同学有兴趣的话,可以考虑实现这个优化。


最后更新: 2024年9月8日
作者:Jiajie Chen