4. AXI4-Lite从机设计实战(Verilog):状态机设计、地址译码逻辑、寄存器读写控制、仿真验证

好,咱们今天来点真格的。

前面几章把AXI4-Lite的协议讲了个七七八八,但说实话,光看波形图是学不会的。你得亲手写一个从机,跑一遍仿真,踩几个坑,才能真正理解这玩意儿是怎么工作的。

这一章,我就带你手撸一个AXI4-Lite从机。咱们的目标很明确:写一个支持读写操作的从机模块,挂4个32位寄存器。我会把状态机、地址译码、读写控制这些核心逻辑掰开揉碎了讲。

本章核心知识点

  • 写地址通道与写数据通道的握手时序
  • 读地址通道与读数据通道的握手时序
  • 地址译码逻辑:如何从地址总线中提取寄存器偏移
  • 状态机设计:写状态机与读状态机的独立实现
  • 仿真验证:用Verilog testbench验证读写功能
AXI4-Lite 从机设计核心逻辑 AXI4-Lite 从机 写地址通道 (AW) 写数据通道 (W) 写响应通道 (B) 读地址通道 (AR) 读数据通道 (R) 核心控制逻辑 写状态机 (FSM_W) 读状态机 (FSM_R) 寄存器组 (REGx4) 地址译码

4.1 模块接口定义

先搭个架子。我习惯把接口分成5组:全局信号、写地址通道、写数据通道、写响应通道、读地址通道、读数据通道。这样看着清爽,也方便后续维护。

module axi4_lite_slave #(
    parameter ADDR_WIDTH = 32,
    parameter DATA_WIDTH = 32
)(
    // 全局信号
    input  wire                     ACLK,
    input  wire                     ARESETn,
    
    // 写地址通道
    input  wire [ADDR_WIDTH-1:0]    AWADDR,
    input  wire                     AWVALID,
    output reg                      AWREADY,
    
    // 写数据通道
    input  wire [DATA_WIDTH-1:0]    WDATA,
    input  wire [DATA_WIDTH/8-1:0]  WSTRB,
    input  wire                     WVALID,
    output reg                      WREADY,
    
    // 写响应通道
    output reg  [1:0]               BRESP,
    output reg                      BVALID,
    input  wire                     BREADY,
    
    // 读地址通道
    input  wire [ADDR_WIDTH-1:0]    ARADDR,
    input  wire                     ARVALID,
    output reg                      ARREADY,
    
    // 读数据通道
    output reg  [DATA_WIDTH-1:0]    RDATA,
    output reg  [1:0]               RRESP,
    output reg                      RVALID,
    input  wire                     RREADY
);

小提示:WSTRB信号很多人一开始会忽略。它表示写数据中哪些字节是有效的。比如WSTRB=4'b0011,表示只写低16位。我在一个项目中没处理好这个信号,结果寄存器的高16位被意外清零了...嗯,血的教训。

4.2 写状态机设计

写操作需要三个通道协同:AW、W、B。说白了就是:先收地址,再收数据,最后回响应。

我设计了一个3状态的状态机:

localparam IDLE_W = 2'b00;
localparam ADDR_W = 2'b01;
localparam DATA_W = 2'b10;
localparam RESP_W = 2'b11;

reg [1:0] wstate, wnext;

// 状态转移
always @(posedge ACLK or negedge ARESETn) begin
    if (!ARESETn)
        wstate <= IDLE_W;
    else
        wstate <= wnext;
end

// 次态逻辑
always @(*) begin
    wnext = wstate;
    case (wstate)
        IDLE_W: if (AWVALID)          wnext = ADDR_W;
        ADDR_W: if (WVALID)           wnext = DATA_W;
        DATA_W:                        wnext = RESP_W;
        RESP_W: if (BREADY)           wnext = IDLE_W;
        default: wnext = IDLE_W;
    endcase
end

你可能会问:为什么ADDR_W状态只等WVALID?因为AWREADY在IDLE_W状态就拉高了,地址在第一个周期就锁存了。数据可能晚到,所以得等。

注意:AXI协议允许AW和W同时有效,也允许W先于AW到达。我这个状态机假设了AW先到,W后到。如果你要处理乱序情况,状态机要更复杂一些。不过对于AXI4-Lite,master通常不会这么干,所以简化处理没问题。

4.3 地址译码逻辑

地址译码,说白了就是看地址落在哪个寄存器上。我习惯用偏移地址来判断:

// 地址译码
wire [ADDR_WIDTH-1:0] addr_offset;
assign addr_offset = AWADDR & 32'h0000_00FF;  // 取低8位

reg [1:0] write_sel;
always @(*) begin
    case (addr_offset[3:2])  // 按字对齐
        2'b00:   write_sel = 2'b00;  // 寄存器0
        2'b01:   write_sel = 2'b01;  // 寄存器1
        2'b10:   write_sel = 2'b10;  // 寄存器2
        2'b11:   write_sel = 2'b11;  // 寄存器3
        default: write_sel = 2'b00;
    endcase
end

这里有个细节:地址是字节地址,但寄存器是32位的,所以用addr_offset[3:2]来选。偏移0x00、0x04、0x08、0x0C分别对应4个寄存器。

4.4 寄存器读写控制

写寄存器的逻辑,我放在写状态机的DATA_W状态里:

reg [31:0] reg0, reg1, reg2, reg3;

always @(posedge ACLK or negedge ARESETn) begin
    if (!ARESETn) begin
        reg0 <= 32'h0;
        reg1 <= 32'h0;
        reg2 <= 32'h0;
        reg3 <= 32'h0;
    end else if (wstate == DATA_W) begin
        case (write_sel)
            2'b00: reg0 <= WDATA;
            2'b01: reg1 <= WDATA;
            2'b10: reg2 <= WDATA;
            2'b11: reg3 <= WDATA;
        endcase
    end
end

读操作就简单多了。读地址通道和读数据通道是独立的:

// 读地址锁存
reg [31:0] read_addr;
always @(posedge ACLK or negedge ARESETn) begin
    if (!ARESETn)
        read_addr <= 32'h0;
    else if (ARVALID && ARREADY)
        read_addr <= ARADDR;
end

// 读数据输出
always @(*) begin
    case (read_addr[3:2])
        2'b00:   RDATA = reg0;
        2'b01:   RDATA = reg1;
        2'b10:   RDATA = reg2;
        2'b11:   RDATA = reg3;
        default: RDATA = 32'h0;
    endcase
end

关键点:读数据是组合逻辑输出的,RVALID信号控制什么时候把数据发给master。我习惯在读地址锁存后的下一个周期拉高RVALID,这样数据已经稳定了。

4.5 仿真验证

写testbench,我个人的习惯是:先测写,再测读,最后测读写混合。别一上来就搞复杂场景,先把基本功能跑通。

// 写操作测试
initial begin
    // 写寄存器0
    AWADDR  = 32'h0000_0000;
    AWVALID = 1;
    WDATA   = 32'hA5A5_A5A5;
    WVALID  = 1;
    @(posedge ACLK);
    AWVALID = 0;
    WVALID  = 0;
    @(posedge ACLK);
    
    // 写寄存器1
    AWADDR  = 32'h0000_0004;
    AWVALID = 1;
    WDATA   = 32'h5A5A_5A5A;
    WVALID  = 1;
    @(posedge ACLK);
    AWVALID = 0;
    WVALID  = 0;
    @(posedge ACLK);
end

// 读操作测试
initial begin
    #100;
    ARADDR  = 32'h0000_0000;
    ARVALID = 1;
    @(posedge ACLK);
    ARVALID = 0;
    @(posedge ACLK);
    // 此时RDATA应该等于0xA5A5_A5A5
end

仿真跑起来后,重点看几个信号:

  • AWREADY和WREADY:是否在正确的时间拉高
  • BVALID和BRESP:写响应是否返回OKAY
  • RVALID和RDATA:读数据是否与写入的一致

避坑指南:我曾经犯过一个错误——写状态机里忘了等BREADY。结果BVALID拉高后,master还没准备好收响应,我就跳回IDLE状态了。这会导致写操作丢失。记住:一定要等BREADY拉高才能结束写事务。

4.6 完整代码结构

最后,我把整个模块的代码结构整理一下。你写的时候按这个框架来,不容易漏东西:

  1. 端口声明:按AXI通道分组
  2. 内部寄存器定义:4个32位寄存器
  3. 写状态机:IDLE_W → ADDR_W → DATA_W → RESP_W
  4. 地址译码:从地址中提取寄存器选择信号
  5. 写数据锁存:在DATA_W状态写入寄存器
  6. 写响应生成:BRESP固定为OKAY,BVALID在RESP_W状态拉高
  7. 读地址锁存:在ARVALID&ARREADY时锁存地址
  8. 读数据输出:组合逻辑根据地址输出寄存器值
  9. 读状态机:IDLE_R → ADDR_R → DATA_R

嗯,这一章的内容就到这儿。代码量不大,但逻辑环环相扣。你写的时候,建议先画个状态转移图,再动手写代码。仿真的时候,把每个通道的握手信号都盯仔细了。

可可精品资料库