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文件。
接收代码解读
完整代码不再粘贴,主要是对别人代码的一种学习,最主要的是学习思想,而不是简单的看代码,也是一种加深印象的做法。
模块声明与参数定义
`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 里改了这个参数,整个模块的位宽会自动调整。
端口定义(输入输出)
( // 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(输出):从机(也就是本模块)说:“我准备好接收了”。只有当TVALID 和TREADY同时为 1 时,数据才算传输成功。 -
S_AXIS_TDATA:真正的数据总线。 -
S_AXIS_TLAST:表示这是当前数据包的“最后一个”数据。 -
S_AXIS_TSTRB:字节选通信号(用于指示哪些字节有效),但在本代码逻辑中常被忽略。
辅助函数与常量定义
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,后面定义指针时会用到这个宽度。
状态机定义
parameter [1:0] IDLE = 1'b0, // Initial/idle state WRITE_FIFO = 1'b1; // FIFO written state wire axis_tready; reg mst_exec_state;定义了两个状态:
- IDLE (0):空闲,发呆,等待开始信号。
- WRITE_FIFO (1):正在干活,正在把数据写进 FIFO。
mst_exec_state:这是一个寄存器,用来存当前处于哪个状态。
状态机逻辑(FSM)
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 信号生成(握手)
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。
写指针与写完成逻辑(最关键的逻辑)
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 存储实现
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(字节掩码),可以单独写某个字节。
发送代码解读
模块声明与参数
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 个时钟周期。这是为了防止系统复位未稳时就开始通信。
端口定义
( 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:主机输入。从机告诉主机“我准备好了”。这是唯一的输入控制信号(除了时钟复位)。
辅助函数与常量
localparam NUMBER_OF_OUTPUT_WORDS = 8;-
NUMBER_OF_OUTPUT_WORDS(8):这个演示代码只会发 8 个数,发完就停。 -
WAIT_COUNT_BITS 和bit_num:计算计数器和指针需要的位宽。
状态机定义 (FSM)
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):正式发送数据的状态。
状态机逻辑
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)
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):
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 在同一个时钟周期对齐输出,这里故意把控制信号打了一拍。
读指针逻辑
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_en:assign 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,数据再也发不出来了。
数据生成(最简单的部分)
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
`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拉高了,表示包结束。 - 握手逻辑正确:数据只有在
tvalid 和tready同时为高的时候才发生变化(从 1 变 2,2 变 3…)。
上述仿真有点瑕疵,理论应该是这样的,存入01,02,03…08.