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 (输出) :从机(也就是本模块)说:"我准备好接收了"。只有当TVALID和TREADY同时为 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;
定义了两个状态:
- IDLE (0) :空闲,发呆,等待开始信号。
- 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_BITS和bit_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_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,数据再也发不出来了。
数据生成(最简单的部分)
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拉高了,表示包结束。 - 握手逻辑正确 :数据只有在
tvalid和tready同时为高 的时候才发生变化(从 1 变 2,2 变 3...)。
上述仿真有点瑕疵,理论应该是这样的,存入01,02,03...08.
