FPGA教程系列-Vivado AXI4-Stream自定义IP核

FPGA教程系列-Vivado AXI4-Stream自定义IP核

打包AXI4-Stream MASTER(创建一个自定义IP核)

下一步

1. Package your current project (封装当前工程): 将你当前正在 Vivado 中打开的整个 RTL(Verilog/VHDL)工程打包成一个 IP 核。

2. Package a block design from the current project (封装当前工程中的 Block Design): 将当前工程中已经画好的 Block Design(.bd 文件,即由多个 IP 连接而成的子系统)封装成一个新的、单一的 IP 核。

3. Package a specified directory (封装指定目录): 选择硬盘上的一个特定文件夹,Vivado 会扫描该文件夹内的源文件(代码、约束等),并将其封装为 IP。

4. Create a new AXI4 peripheral (创建一个新的 AXI4 外设): 这不是封装现有的代码,而是生成代码模板。它会启动一个向导,帮你生成一个带有 AXI4 总线接口(AXI4-Lite, AXI4-Full, 或 AXI4-Stream)的 Verilog/VHDL 框架代码。

选第四个。设置名称:

选择Stream接口,接口类型选择主机Master,数据位宽32位

直接将IP添加到仓库里,IP打包完成:

默认参数,生成ip即可。source中可以看到该ip的逻辑文件。

同样,生成一个slave文件。

接收代码解读

完整代码不再粘贴,主要是对别人代码的一种学习,最主要的是学习思想,而不是简单的看代码,也是一种加深印象的做法。

模块声明与参数定义

verilog 复制代码
`timescale 1 ns / 1 ps

module myip_AXIslave_slave_stream_v1_0_S00_AXIS #
(
    // Users to add parameters here
    // User parameters ends
    // Do not modify the parameters beyond this line

    // AXI4Stream sink: Data Width
    parameter integer C_S_AXIS_TDATA_WIDTH	= 32
)
  • ** `timescale **:定义仿真时间单位(1ns)和精度(1ps)。
  • module ... :定义了模块名称。
  • parameter :定义了一个参数 C_S_AXIS_TDATA_WIDTH,默认是 32 位。这意味着这个 IP 默认每次传输 32bit(4字节)的数据。如果你在 Vivado 里改了这个参数,整个模块的位宽会自动调整。

端口定义(输入输出)

verilog 复制代码
(
    // AXI4Stream sink: Clock
    input wire  S_AXIS_ACLK,
    // AXI4Stream sink: Reset
    input wire  S_AXIS_ARESETN,
    // Ready to accept data in
    output wire  S_AXIS_TREADY,
    // Data in
    input wire [C_S_AXIS_TDATA_WIDTH-1 : 0] S_AXIS_TDATA,
    // Byte qualifier
    input wire [(C_S_AXIS_TDATA_WIDTH/8)-1 : 0] S_AXIS_TSTRB,
    // Indicates boundary of last packet
    input wire  S_AXIS_TLAST,
    // Data is in valid
    input wire  S_AXIS_TVALID
);

定义了标准的 AXI4-Stream 协议 信号:

  • S_AXIS_ACLK:时钟信号,所有逻辑都在这个时钟上升沿动作。
  • S_AXIS_ARESETN :复位信号,低电平有效 (名字里最后的 N 代表 Negative)。
  • S_AXIS_TVALID (输入) :主机(Master)说:"我有数据要发"。
  • S_AXIS_TREADY (输出) :从机(也就是本模块)说:"我准备好接收了"。只有当 TVALIDTREADY 同时为 1 时,数据才算传输成功。
  • S_AXIS_TDATA:真正的数据总线。
  • S_AXIS_TLAST:表示这是当前数据包的"最后一个"数据。
  • S_AXIS_TSTRB:字节选通信号(用于指示哪些字节有效),但在本代码逻辑中常被忽略。

辅助函数与常量定义

verilog 复制代码
function integer clogb2 (input integer bit_depth);
  begin
    for(clogb2=0; bit_depth>0; clogb2=clogb2+1)
      bit_depth = bit_depth >> 1;
  end
endfunction

localparam NUMBER_OF_INPUT_WORDS  = 8;
localparam bit_num  = clogb2(NUMBER_OF_INPUT_WORDS-1);
  • clogb2 函数 :这是一个计算"以2为底的对数(向上取整)"的函数。存 NUMBER_OF_INPUT_WORDS = 8 个数据。为了给这 8 个位置编号(0~7),需要几位二进制?clogb2(7) 算出来是 3位 。所以 bit_num = 3,后面定义指针时会用到这个宽度。

状态机定义

verilog 复制代码
parameter [1:0] IDLE = 1'b0,        // Initial/idle state 
                WRITE_FIFO  = 1'b1; // FIFO written state

wire  	axis_tready;
reg mst_exec_state;  

定义了两个状态:

  1. IDLE (0) :空闲,发呆,等待开始信号。
  2. WRITE_FIFO (1) :正在干活,正在把数据写进 FIFO。

mst_exec_state:这是一个寄存器,用来存当前处于哪个状态。

状态机逻辑(FSM)

verilog 复制代码
always @(posedge S_AXIS_ACLK) 
begin  
  if (!S_AXIS_ARESETN) 
    mst_exec_state <= IDLE;
  else
    case (mst_exec_state)
      IDLE: 
          // 只要看到主机发来了 TVALID,就进入写状态
          if (S_AXIS_TVALID)
            mst_exec_state <= WRITE_FIFO;
          else
            mst_exec_state <= IDLE;

      WRITE_FIFO: 
        // 如果 writes_done (写完了) 信号变高,就回到空闲
        if (writes_done)
            mst_exec_state <= IDLE;
        else
            mst_exec_state <= WRITE_FIFO;
    endcase
end
  • 复位时,进入 IDLE
  • IDLE :如果你给了 TVALID,下一拍我就跳到 WRITE_FIFO 准备收数据。
  • WRITE_FIFO :我就一直收,直到 writes_done 变高(由后面逻辑决定,比如存满了或者收到了 TLAST),然后跳回 IDLE

TREADY 信号生成(握手)

verilog 复制代码
assign S_AXIS_TREADY = axis_tready;

assign axis_tready = ((mst_exec_state == WRITE_FIFO) && (write_pointer <= NUMBER_OF_INPUT_WORDS-1));

告诉主机什么时候准备好。

  • 条件是:必须处于 WRITE_FIFO 状态 FIFO 还没满(指针小于等于7)。
  • 注意 :因为状态机从 IDLE 跳到 WRITE_FIFO 需要一个时钟周期,所以主机拉高 TVALID 后,这个 IP 至少要晚一个周期才会拉高 TREADY

写指针与写完成逻辑(最关键的逻辑)

verilog 复制代码
always@(posedge S_AXIS_ACLK)
begin
  if(!S_AXIS_ARESETN)
    begin
      write_pointer <= 0;
      writes_done <= 1'b0;
    end  
  else
    if (write_pointer <= NUMBER_OF_INPUT_WORDS-1)
      begin
        if (fifo_wren) // 如果真的写入了数据
          begin
            write_pointer <= write_pointer + 1; // 指针+1
            writes_done <= 1'b0;
          end
          
          // 如果刚写的是第8个数据 (指针==7) 或者 收到了 TLAST
          if ((write_pointer == NUMBER_OF_INPUT_WORDS-1)|| S_AXIS_TLAST)
            begin
              writes_done <= 1'b1; // 标记:写完了
            end
      end  
end

// 生成写使能:必须 VALID 和 READY 同时为 1
assign fifo_wren = S_AXIS_TVALID && axis_tready;
  • fifo_wren:这是标准的 AXI 握手逻辑,只有双方都同意,才算写入一次。
  • write_pointer:每写一次,指针加 1。
  • writes_done:当存满 8 个数,或者收到 TLAST 信号时,这个信号拉高,通知状态机回到 IDLE。

隐藏的坑 :仔细看代码,write_pointer除了复位信号外,没有任何清零的逻辑!

  • 这意味着:这个 IP 接收完这一包数据后,write_pointer 就停在末尾了。下一包数据来的时候,因为指针没归零,TREADY 永远拉不起来。它是一次性的! 如果你想重复使用,必须修改这里,让指针在 writes_done 后清零。

FIFO 存储实现

verilog 复制代码
generate 
  for(byte_index=0; byte_index<= (C_S_AXIS_TDATA_WIDTH/8-1); byte_index=byte_index+1)
  begin:FIFO_GEN
    // 定义存储器数组
    reg  [(C_S_AXIS_TDATA_WIDTH/4)-1:0] stream_data_fifo [0 : NUMBER_OF_INPUT_WORDS-1];

    always @( posedge S_AXIS_ACLK )
    begin
      // 下面这行注释里本来有 S_AXIS_TSTRB,但被屏蔽了
      if (fifo_wren)// && S_AXIS_TSTRB[byte_index])
        begin
          // 写入数据
          stream_data_fifo[write_pointer] <= S_AXIS_TDATA[(byte_index*8+7) -: 8];
        end  
    end  
  end		
endgenerate
  • 这里用了一个 generate 循环。对于 32位宽的数据,它循环 4 次(0, 1, 2, 3)。
  • 目的是把 32位的存储器拆成 4 个 8位的存储器。
  • 为什么这么麻烦? 主要是为了支持 S_AXIS_TSTRB(字节掩码),可以单独写某个字节。

发送代码解读

模块声明与参数

verilog 复制代码
module myip_AXImaster_master_stream_v1_0_M00_AXIS #
(
    parameter integer C_M_AXIS_TDATA_WIDTH	= 32,
    parameter integer C_M_START_COUNT	= 32
)
  • C_M_AXIS_TDATA_WIDTH (32) :数据位宽,默认为 32位(4字节)。
  • C_M_START_COUNT (32) :启动延迟计数。这个 Master 不会复位后立刻发数据,而是会先等 32 个时钟周期。这是为了防止系统复位未稳时就开始通信。

端口定义

verilog 复制代码
(
    input wire  M_AXIS_ACLK,
    input wire  M_AXIS_ARESETN,
    output wire  M_AXIS_TVALID,
    output wire [C_M_AXIS_TDATA_WIDTH-1 : 0] M_AXIS_TDATA,
    output wire [(C_M_AXIS_TDATA_WIDTH/8)-1 : 0] M_AXIS_TSTRB,
    output wire  M_AXIS_TLAST,
    input wire  M_AXIS_TREADY
);
  • M_AXIS_TVALID:主机输出。表示"我现在有有效数据要发给你"。
  • M_AXIS_TDATA:主机输出。数据线。
  • M_AXIS_TLAST:主机输出。表示"这是最后一个数据了"。
  • M_AXIS_TREADY主机输入。从机告诉主机"我准备好了"。这是唯一的输入控制信号(除了时钟复位)。

辅助函数与常量

verilog 复制代码
localparam NUMBER_OF_OUTPUT_WORDS = 8;
  • NUMBER_OF_OUTPUT_WORDS (8) :这个演示代码只会发 8 个数,发完就停。
  • WAIT_COUNT_BITSbit_num:计算计数器和指针需要的位宽。

状态机定义 (FSM)

verilog 复制代码
parameter [1:0] IDLE = 2'b00,        
                INIT_COUNTER  = 2'b01, 
                SEND_STREAM   = 2'b10; 
reg [1:0] mst_exec_state;
  • IDLE (00) :复位后的初始状态。
  • INIT_COUNTER (01) :倒计时/等待状态。
  • SEND_STREAM (10) :正式发送数据的状态。

状态机逻辑

verilog 复制代码
always @(posedge M_AXIS_ACLK)                                             
begin                                                                     
  if (!M_AXIS_ARESETN) 
    ...
  else
    case (mst_exec_state)
      IDLE: 
        mst_exec_state  <= INIT_COUNTER; // 复位松开立刻进入计数状态
                                                                      
      INIT_COUNTER:                                                       
        if ( count == C_M_START_COUNT - 1 ) // 等待计数器数满
          mst_exec_state  <= SEND_STREAM;   // 进入发送状态
        else
          begin
            count <= count + 1;
            mst_exec_state  <= INIT_COUNTER;
          end
                                                                      
      SEND_STREAM:                                                        
        if (tx_done) // 如果数据发完了
          mst_exec_state <= IDLE; // 回到 IDLE(注意:回到 IDLE 后会马上再次进入 INIT_COUNTER)
        else
          mst_exec_state <= SEND_STREAM;                                
    endcase                                                               
end
  • 流程:复位 -> IDLE -> INIT_COUNTER (等32个周期) -> SEND_STREAM (发8个数) -> IDLE -> INIT_COUNTER ...
  • 循环发送 :注意,这里的逻辑会让它无限循环发送。每次发完 8 个数,等一会,再发 8 个数。这与 Slave 代码的"一次性"不同。

控制信号生成 (TVALID, TLAST)

verilog 复制代码
assign axis_tvalid = ((mst_exec_state == SEND_STREAM) && (read_pointer < NUMBER_OF_OUTPUT_WORDS));
assign axis_tlast = (read_pointer == NUMBER_OF_OUTPUT_WORDS-1);
  • axis_tvalid :只有在 SEND_STREAM 状态且还没发完 8 个数时,才为高。
  • axis_tlast:当读指针指到第 7 个数(最后一个)时,拉高。

关键延迟逻辑(Alignment):

verilog 复制代码
always @(posedge M_AXIS_ACLK)
begin
    ...
    axis_tvalid_delay <= axis_tvalid;
    axis_tlast_delay <= axis_tlast;
end
assign M_AXIS_TVALID = axis_tvalid_delay;
assign M_AXIS_TLAST = axis_tlast_delay;

为什么要有 delay?

  • 看后面的数据生成逻辑,数据 stream_data_out 是在时钟沿更新的(时序逻辑)。
  • 如果不打一拍,控制信号(组合逻辑生成的 axis_tvalid)会比数据早一拍到达,导致时序对不齐。
  • 为了让 TVALID、TLAST 和 TDATA 在同一个时钟周期对齐输出,这里故意把控制信号打了一拍。

读指针逻辑

verilog 复制代码
always@(posedge M_AXIS_ACLK)
begin
  ...
    if (read_pointer <= NUMBER_OF_OUTPUT_WORDS-1)
      begin
        if (tx_en) // 握手成功(TREADY && TVALID)
          begin
            read_pointer <= read_pointer + 1;
            tx_done <= 1'b0;
          end
      end
    else if (read_pointer == NUMBER_OF_OUTPUT_WORDS)
      begin
        tx_done <= 1'b1; // 发完了
      end
end
  • tx_enassign tx_en = M_AXIS_TREADY && axis_tvalid; 只有当从机说 Ready 且主机 Valid 时,指针才加 1。
  • tx_done :当指针数到 8 时,拉高 tx_done,通知状态机切状态。

隐患 :这里同样没有显式的指针清零逻辑(除了复位)。但是,因为 mst_exec_state​ 会跳回 IDLE,如果需要在下一轮循环中让 read_pointer​ 归零,代码里其实缺了一句逻辑!

  • Bug Alert : 仔细看代码,read_pointer 只有在 !M_AXIS_ARESETN 时才清零。这意味着,虽然状态机在循环跑(IDLE->INIT->SEND->IDLE),但 read_pointer 一直停在 8。
  • 结论 :这个 Master 实际上也是一次性 的。它发完第一包 8 个数后,虽然状态机会不断尝试进入 SEND 状态,但因为 read_pointer 已经是 8 了,axis_tvalid 永远是 0,数据再也发不出来了。

数据生成(最简单的部分)

verilog 复制代码
always @( posedge M_AXIS_ACLK )
begin
  if(!M_AXIS_ARESETN)
    stream_data_out <= 1;
  else if (tx_en)
    stream_data_out <= read_pointer + 32'b1;
end
  • 初始值:1。

  • 后续值read_pointer + 1​。所以发送的数据序列是:1, 1, 2, 3, 4, 5, 6, 7​。(注意第一拍发出去的是初始值 1,发送同时 tx_en​ 有效,下一拍更新为 read_pointer(1) + 1 = 2... 稍微有点怪,通常期望发 1~8)。

    • 实际上,由于 stream_data_out 是时序逻辑更新,而 M_AXIS_TDATA 直接连它。
    • 第一拍握手时,发出去的是旧值(1)。
    • 握手后,stream_data_out 更新。
    • 所以发出的数据大概率是 1 (初始), 1 (ptr=0+1), 2, 3... 直到最后。

仿真

Testbench

verilog 复制代码
`timescale 1ns / 1ps

module tb_axis_system;

    // =========================================================================
    // 1. 信号定义
    // =========================================================================
    reg  aclk;
    reg  aresetn;

    // AXI4-Stream 接口连接线 (连接 Master 输出 -> Slave 输入)
    wire [31:0] axis_tdata;
    wire [3:0]  axis_tstrb;
    wire        axis_tlast;
    wire        axis_tvalid;
    wire        axis_tready;

    // 定义时钟周期 (100MHz = 10ns)
    parameter CLK_PERIOD = 10;

    // =========================================================================
    // 2. 模块实例化
    // =========================================================================

    // 实例化 Master (发送者)
    myip_AXImaster_0 #(
        .C_M_AXIS_TDATA_WIDTH(32),
        .C_M_START_COUNT(10)      // 修改参数:缩短启动等待时间,方便仿真查看
    ) u_master (
        .m00_axis_aclk    (aclk),
        .m00_axis_aresetn (aresetn),
        .m00_axis_tvalid  (axis_tvalid),
        .m00_axis_tdata   (axis_tdata),
        .m00_axis_tstrb   (axis_tstrb),
        .m00_axis_tlast   (axis_tlast),
        .m00_axis_tready  (axis_tready)
    );
    // 实例化 Slave (接收者)
    myip_AXIslave_0 #(
        .C_S_AXIS_TDATA_WIDTH(32)
    ) u_slave (
        .s00_axis_aclk    (aclk),
        .s00_axis_aresetn (aresetn),
        .s00_axis_tvalid  (axis_tvalid),
        .s00_axis_tdata   (axis_tdata),
        .s00_axis_tstrb   (axis_tstrb),
        .s00_axis_tlast   (axis_tlast),
        .s00_axis_tready  (axis_tready)
    );

    // =========================================================================
    // 3. 时钟生成
    // =========================================================================
    initial begin
        aclk = 0;
        forever #(CLK_PERIOD/2) aclk = ~aclk;
    end

    // =========================================================================
    // 4. 测试流程控制
    // =========================================================================
    initial begin
        // 1. 初始化
        aresetn = 0;
        $display("Simulation Start: Reset Active");
        
        // 2. 保持复位 100ns
        #100;
        aresetn = 1;
        $display("Reset Released. Master should start counting down.");

        // 3. 等待足够长的时间让传输发生
        // Master 设置了 START_COUNT=10,所以复位后约 10 个周期开始传输
        // 总共传输 8 个数据,大概需要 20-30 个周期
        #500;
        
        // 4. 结束仿真
        $display("Simulation Finished");
        $stop;
    end

    // =========================================================================
    // 5. 监控与打印 (Monitor)
    // =========================================================================
    // 在时钟上升沿检测握手是否成功
    always @(posedge aclk) begin
        if (aresetn) begin
            // 当 VALID 和 READY 同时为高时,表示一次成功的数据传输
            if (axis_tvalid && axis_tready) begin
                $display("[%0t ns] Transfer Occurred! Data: 0x%h | Last: %b", 
                         $time, axis_tdata, axis_tlast);
            end
            
            // 检测 Slave 内部是否写满了 (通过观察 Slave 是否拉低 Ready)
            // 注意:因为是 Testbench,我们也可以通过 hierarchy path 偷窥 Slave 内部信号
            // 比如: u_slave.write_pointer
        end
    end

endmodule

仿真结果的思考:

从波形上看,这是一个标准的、成功的 AXI-Stream 数据传输过程:

  • 数据量正确:发送了从 1 到 8 共 8 个数据。
  • 结束信号正确 :在发送数据 8 的时候,axis_tlast 拉高了,表示包结束。
  • 握手逻辑正确 :数据只有在 tvalidtready 同时为高 的时候才发生变化(从 1 变 2,2 变 3...)。

上述仿真有点瑕疵,理论应该是这样的,存入01,02,03...08.

相关推荐
智行众维2 天前
【用户心得】SCANeR™Studio学习笔记(六):人因工程Pack——一站式搞定驾驶模拟的多模态数据同步
笔记·学习·自动驾驶·汽车·仿真·scaner·人因工程
世冠科技3 天前
建模仿真技术成为汽车产教融合新引擎,世冠科技董事长李京燕发表主题报告
仿真·国产软件
Wishell20153 天前
FPGA教程系列-Vivado Aurora 8B/10B 例程解读
仿真
Wishell20154 天前
FPGA教程系列-Vivado Aurora 8B/10B IP核设置
仿真
Wishell20156 天前
FPGA教程系列-Vivado Aurora 8B/10B IP核接口解析
仿真
Wishell20156 天前
FPGA教程系列-Vivado Aurora 8B/10B 协议解析
仿真
Altair澳汰尔9 天前
成功案例丨仿真+AI技术为快消包装行业赋能提速:基于 AI 的轻量化设计节省数十亿美元
人工智能·ai·仿真·cae·消费品·hyperworks·轻量化设计
十五年专注C++开发12 天前
fmilib: 一个FMI 标准的 C 语言实现库
c语言·仿真·fmi·fmu
Wishell201512 天前
FPGA教程系列-Vivado实现低延时除法器与generate用法
仿真