4、接口设计规范:AXI-Stream、AXI-Lite、自定义接口的标准化设计,接口文档化

接口设计,说白了就是模块之间的「握手协议」。

我见过太多项目,前期接口定义得模棱两可,到了联调阶段才发现信号对不上、时序不匹配,最后不得不返工重做。嗯,这种痛苦,经历过一次就再也不想经历了。

这一章,我们就来聊聊接口设计的标准化。我会重点讲三种最常用的接口:AXI-Stream、AXI-Lite,以及自定义接口。最后再聊聊接口文档化——这个很多人忽略,但实际非常关键的一环。

4.1 AXI-Stream:数据流的标准通道

AXI-Stream 是 Xilinx 生态里最常用的接口之一。它专门用来传输连续的数据流,没有地址概念,只有数据、有效信号和就绪信号。

我个人习惯,只要模块之间是「流水线式」的数据传递,优先考虑 AXI-Stream。为什么?因为它简单、高效,而且工具链支持好。

核心信号:

  • tvalid:发送方告诉接收方,「数据准备好了」
  • tready:接收方告诉发送方,「我可以接收」
  • tdata:数据总线,宽度通常是 8、16、32、64 或 128 位
  • tlast:标记数据包的最后一个数据
  • tkeep:指示 tdata 中哪些字节是有效的(可选)
  • tuser:用户自定义信号,常用于传递边带信息(可选)

这里有个关键点:tvalid 和 tready 的握手规则。数据只有在 tvalid 和 tready 同时为高时,才算真正传输成功。如果 tvalid 为高但 tready 为低,数据必须保持稳定,直到握手完成。

我的经验:在写 AXI-Stream 的发送端时,我习惯把 tvalid 的生成逻辑单独写一个 always 块。这样调试时,一眼就能看出 valid 信号是怎么来的,不会和 data 逻辑混在一起。

来看一个简单的发送端代码示例:

// AXI-Stream 发送端示例
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        axis_tvalid <= 1'b0;
        axis_tdata  <= 'd0;
        axis_tlast  <= 1'b0;
    end else begin
        if (axis_tvalid && axis_tready) begin
            // 数据成功发送,准备下一个数据
            axis_tvalid <= (fifo_empty) ? 1'b0 : 1'b1;
            axis_tdata  <= fifo_rdata;
            axis_tlast  <= (fifo_rd_cnt == pkt_len - 1) ? 1'b1 : 1'b0;
        end else if (!axis_tvalid && !fifo_empty) begin
            // 没有 valid,但 FIFO 有数据,发起传输
            axis_tvalid <= 1'b1;
            axis_tdata  <= fifo_rdata;
            axis_tlast  <= (fifo_rd_cnt == pkt_len - 1) ? 1'b1 : 1'b0;
        end
    end
end

你可能会问:为什么不用更简单的组合逻辑直接赋值?

嗯,这里有个坑。我曾经在一个高速项目中,用组合逻辑直接生成 tvalid,结果因为路径延迟太大,导致时序违例。后来改成寄存器输出,问题就解决了。所以,我建议所有 AXI-Stream 的控制信号都用寄存器输出,这样时序更可控。

4.2 AXI-Lite:寄存器配置的标准通道

AXI-Lite 是 AXI 协议的轻量版。它主要用于 CPU 对 FPGA 内部寄存器的读写操作,比如配置参数、读取状态等。

说白了,AXI-Lite 就是用来「写寄存器」和「读寄存器」的。它不像 AXI-Stream 那样追求高吞吐,而是强调简单、可靠。

核心信号:

  • awaddr / awvalid / awready:写地址通道
  • wdata / wvalid / wready:写数据通道
  • bresp / bvalid / bready:写响应通道
  • araddr / arvalid / arready:读地址通道
  • rdata / rvalid / rready:读数据通道

AXI-Lite 的握手规则和 AXI-Stream 类似,也是 valid-ready 机制。但有一点不同:写操作需要三个通道协同工作(地址、数据、响应),读操作需要两个通道(地址、数据)。

避坑指南:我曾经在一个项目中,把 AXI-Lite 的写响应通道(bvalid)延迟了一个周期才拉高。结果 CPU 那边一直等不到响应,超时后直接报错。后来查了协议才发现,bvalid 必须在 wvalid 和 wready 握手完成后立即拉高,不能拖。

来看一个简单的寄存器写操作实现:

// AXI-Lite 写寄存器示例
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        reg0 <= 32'h0;
        reg1 <= 32'h0;
        bvalid <= 1'b0;
    end else begin
        // 默认拉低 bvalid
        bvalid <= 1'b0;
        
        // 检测写请求
        if (awvalid && wvalid && !bvalid) begin
            case (awaddr[3:0])
                4'h0: reg0 <= wdata;
                4'h4: reg1 <= wdata;
                default: ; // 未使用的地址,忽略
            endcase
            bvalid <= 1'b1; // 立即响应
        end
    end
end

这里有个小技巧:把寄存器地址映射做成一个表格,写在代码注释里。这样别人看代码时,不用去翻文档就知道哪个地址对应哪个寄存器。

4.3 自定义接口:灵活但要有规矩

有些场景下,AXI-Stream 和 AXI-Lite 并不适用。比如两个模块之间需要传递「带地址的数据包」,或者需要「多通道并行传输」。这时候,我们就需要自定义接口。

自定义接口虽然灵活,但容易出问题。我见过最夸张的一个项目,同一个模块的接口信号命名,三个人写了三种风格:有人用 data_in,有人用 din,还有人用 i_data。联调时简直是一场灾难。

自定义接口的标准化原则:

  1. 命名规范统一:所有信号名遵循「方向_功能_类型」的格式。比如 i_data_valido_data_readyio_busy
  2. 握手信号必备:任何自定义接口,至少包含 valid 和 ready 两个握手信号。没有握手的接口,就是定时炸弹。
  3. 时序约束明确:在接口文档中写明每个信号的时序要求,比如「数据在 valid 拉高后的下一个时钟沿有效」。
  4. 预留扩展位:数据总线宽度留 1-2 位的余量,方便后续功能扩展。

举个例子,一个简单的「数据包接口」可以这样定义:

// 自定义数据包接口
// 信号命名:方向_功能
input  wire        i_pkt_valid;   // 输入数据包有效
output wire        o_pkt_ready;   // 输出就绪
input  wire [31:0] i_pkt_data;    // 输入数据
input  wire [7:0]  i_pkt_addr;    // 输入地址
input  wire        i_pkt_last;    // 输入数据包结束标志

你看,这样命名,一眼就能看出每个信号的作用。而且 valid-ready 握手机制保证了数据传输的可靠性。

4.4 接口文档化:让设计可维护

接口文档化,说白了就是把接口的定义写清楚、写规范。这件事看起来简单,但很多人不愿意做。结果就是:代码写完了,接口怎么定义的?没人记得了。

我个人习惯,在写 RTL 代码之前,先写接口文档。文档里至少包含以下内容:

接口文档模板:

信号名 方向 位宽 描述 时序要求
i_clk 输入 1 系统时钟,所有信号在上升沿采样 50MHz,占空比50%
i_rst_n 输入 1 异步复位,低电平有效 至少保持3个时钟周期
i_data_valid 输入 1 输入数据有效标志 与 i_data 同时有效
i_data 输入 32 输入数据总线 在 i_data_valid 为高时有效
o_data_ready 输出 1 输出就绪标志 在 i_data_valid 为高后的下一个时钟沿拉高

我的习惯:接口文档用 Excel 或 Markdown 表格写,然后直接粘贴到代码文件的头部注释里。这样代码和文档永远在一起,不会出现「文档更新了但代码没改」的情况。

另外,接口文档里还要注明版本号修改记录。比如:

// 接口版本:v2.1
// 修改记录:
// v2.1 - 2024-03-15 - 增加 i_pkt_last 信号,支持数据包结束标志
// v2.0 - 2024-01-10 - 数据总线从16位扩展到32位
// v1.0 - 2023-11-01 - 初始版本

为什么要这么做?

我曾经接手过一个项目,接口文档和代码完全对不上。文档里写的是 16 位数据总线,代码里实际用的是 32 位。我花了整整两天才把问题排查清楚。从那以后,我坚持「文档即代码,代码即文档」的原则。

4.5 总结

接口设计规范,说白了就是三件事:

  • 选对协议:数据流用 AXI-Stream,寄存器配置用 AXI-Lite,特殊场景用自定义接口。
  • 统一命名:信号名要有规律,让人一看就懂。
  • 文档化:接口定义写清楚,版本管理做起来。

做到这三点,你的设计就能经得起时间的考验。哪怕半年后回头再看,也能快速上手。

下一章,我们会聊聊模块划分的粒度问题——到底一个模块该写多大?写小了会不会太碎?写大了会不会难维护?嗯,到时候再细聊。