4. HLS 接口综合:ap_ctrl_hs、ap_memory、axis 接口
好,咱们今天聊点实在的。你写好了卷积加速器的 HLS 代码,仿真也过了,综合也过了。然后呢?怎么把它放到 Zynq 的 PS 里跑起来?
说白了,就是你的 IP 核得跟 ARM 核“对上话”。这个对话的桥梁,就是 HLS 的接口综合。我刚开始接触这块时,觉得不就是几个 pragma 嘛,随便写写就行。结果呢?PS 端一调用,要么数据传不过去,要么时序乱掉,折腾了好几天。
嗯,这里要注意。接口选错了,后面全白干。今天我就把 ap_ctrl_hs、ap_memory 和 axis 这三个最常用的接口给你讲透。
4.1 接口综合的本质:PS 怎么“看见”你的 IP
你想想看,PS 是 ARM 核,跑的是软件。你的 IP 是硬件逻辑,跑在 PL 里。它们之间怎么通信?
其实就三件事:
- 控制:PS 告诉 IP “开始干活”,IP 告诉 PS “干完了”
- 数据搬运:PS 把输入数据塞给 IP,IP 把结果吐回来
- 状态查询:PS 时不时看看 IP 忙不忙、有没有出错
HLS 的接口综合,就是把你的 C 函数参数和函数行为,翻译成 PS 能读写的硬件寄存器。我习惯把每个接口想象成一个“信箱”,PS 往里面丢信,IP 从里面取信。
核心原则:接口类型决定了数据通路和控制逻辑的实现方式。选错了,性能可能差 10 倍。
4.2 ap_ctrl_hs:让 PS 能“按开关”
ap_ctrl_hs 是 HLS 默认生成的接口。它负责函数级别的控制。说白了,就是让 PS 能启动你的 IP,并且知道它什么时候结束。
我举个例子。你写了一个卷积函数:
void conv2d(
float in[256],
float out[128],
float weights[64]
);
HLS 综合后,会自动生成几个控制寄存器:
| 寄存器偏移 | 名称 | 功能 |
|---|---|---|
| 0x00 | AP_CTRL | 启动(bit0)、完成(bit1)、空闲(bit2) |
| 0x04 | AP_START | 写1启动,写0忽略 |
| 0x08 | AP_DONE | 只读,为1表示完成 |
| 0x10+ | 参数寄存器 | 存放函数参数的地址或值 |
PS 端的调用流程是这样的:
- 把输入数据的地址写到参数寄存器
- 把输出缓冲区的地址写到参数寄存器
- 往 AP_CTRL 的 bit0 写 1,启动 IP
- 轮询 AP_DONE,直到为 1
- 从输出缓冲区读结果
我的经验:ap_ctrl_hs 适合“粗粒度”控制。如果你的卷积核很大,一次处理很多数据,用这个接口很稳。但如果你要频繁启动 IP(比如每来一个像素就启动一次),那轮询的开销会让你崩溃。
4.3 ap_memory:最直接的“共享邮箱”
ap_memory 接口,说白了就是把你的数组参数映射到一块共享内存上。PS 和 IP 都能读写这块内存。
我在项目中遇到过一个问题:用 ap_memory 接口时,PS 写数据到内存,IP 读数据。看起来很简单对吧?但坑在于——缓存一致性。
Zynq 的 PS 有 L1/L2 cache,PS 写数据时可能先写到 cache 里,还没刷回 DDR。IP 直接从 DDR 读,读到的全是旧数据。
怎么解决?
- PS 端用
Xil_DCacheFlushRange()刷 cache - 或者用
Xil_DCacheInvalidateRange()让 cache 失效 - 再或者,直接用
AXI_HP端口 +AXI_ACP端口,走一致性通路
避坑指南:我曾经在 ap_memory 接口上栽过跟头。PS 写数据后忘了刷 cache,IP 读到的全是 0。查了两天才发现是 cache 的问题。从那以后,我养成了“写后必刷,读前必失效”的习惯。
ap_memory 的优点是简单,适合大数据块传输。缺点是延迟高,因为每次访问都要走 AXI 总线到 DDR。
4.4 axis 接口:流式传输的“高速公路”
axis 接口,全称 AXI4-Stream。它跟 ap_memory 完全不同。ap_memory 是“共享邮箱”,axis 是“传送带”。
数据在 axis 接口上是一个一个“拍”(beat)地流过去。没有地址,没有握手?不,有握手。TVALID 和 TREADY 信号就是握手信号。
为什么卷积加速器喜欢用 axis?
- 数据是流式的,不需要等全部数据到位再处理
- 可以做到“乒乓操作”,输入和输出同时进行
- 延迟低,适合实时处理
在 HLS 里声明 axis 接口很简单:
void conv2d_axis(
hls::stream<ap_axis<32,1,1,1> >& in,
hls::stream<ap_axis<32,1,1,1> >& out
);
这里 ap_axis<32,1,1,1> 表示:
- 数据宽度 32 位
- 有 TUSER 信号(1 bit)
- 有 TLAST 信号(1 bit)
- 有 TID 信号(1 bit)
PS 端怎么用 axis 接口?通常需要配合 DMA。PS 把数据写到 DMA,DMA 再通过 axis 流给 IP。IP 处理完,再通过 axis 流回 DMA,DMA 再通知 PS 取数据。
性能对比:同样是传输 1024 个 32 位数据,ap_memory 需要 1024 次 AXI 读 + 1024 次 AXI 写,每次都有地址开销。axis 只需要 1024 个 beat,没有地址开销,带宽利用率高得多。
4.5 三种接口的选型指南
我整理了一个表格,方便你对照选择:
| 场景 | 推荐接口 | 原因 |
|---|---|---|
| 一次性处理大块数据(如整张图片) | ap_memory | 简单,PS 直接读写内存 |
| 流式处理(如视频逐行扫描) | axis | 低延迟,高吞吐,适合流水线 |
| 需要频繁启动/停止 IP | ap_ctrl_hs + axis | 控制灵活,数据流式 |
| 数据量小且随机访问 | ap_memory | 地址随机访问方便 |
| 高性能卷积加速 | axis + 多个通道 | 可以做到每个时钟周期出一个结果 |
4.6 实战:让 PS 调用你的卷积 IP
好,理论说完了。咱们看看实际代码怎么写。
假设你有一个卷积 IP,用 axis 接口:
// HLS 代码
void conv2d_top(
hls::stream<ap_axis<32,1,1,1> >& input_stream,
hls::stream<ap_axis<32,1,1,1> >& output_stream
) {
#pragma HLS INTERFACE axis port=input_stream
#pragma HLS INTERFACE axis port=output_stream
#pragma HLS INTERFACE s_axilite port=return bundle=CTRL_BUS
// ... 卷积实现 ...
}
PS 端的调用代码(C 语言):
#include "xconv2d_top_hw.h"
#include "xconv2d_top.h"
XConv2d_top ip;
XConv2d_top_Config *cfg;
// 初始化
cfg = XConv2d_top_LookupConfig(XPAR_XCONV2D_TOP_0_DEVICE_ID);
XConv2d_top_CfgInitialize(&ip, cfg);
// 启动 DMA 传输
// 假设你已经配置好了 AXI DMA
XDma dma;
// ... DMA 初始化代码 ...
// 把输入数据从 DDR 搬到 IP
XDma_StartTransfer(&dma, input_buffer, DMA_TO_DEVICE, size);
// 启动 IP
XConv2d_top_Start(&ip);
// 等待 IP 完成
while(!XConv2d_top_IsDone(&ip));
// 把结果从 IP 搬回 DDR
XDma_StartTransfer(&dma, output_buffer, DMA_FROM_DEVICE, size);
我的习惯:我会在 PS 端用中断代替轮询。IP 完成时触发中断,PS 在中断服务函数里处理结果。这样 CPU 不用空转,可以做其他事情。
4.7 接口综合的常见陷阱
最后,分享几个我踩过的坑:
- ap_memory 的地址对齐:PS 传地址时,必须 4 字节对齐。不对齐的话,IP 读数据会出错。
- axis 的 TLAST 信号:别忘了在最后一个数据拍上置 TLAST。否则 DMA 不知道数据结束了,会一直等。
- ap_ctrl_hs 的启动脉冲:启动信号需要至少一个时钟周期的宽度。PS 写寄存器时,最好写两次(先写 1,再写 0),确保 IP 能捕捉到。
- 接口带宽匹配:PS 的 AXI 总线频率可能跟 IP 的时钟频率不同。跨时钟域处理要做好,否则数据会丢。
嗯,接口综合这块内容不少,但核心就一句话:选对接口,事半功倍。ap_ctrl_hs 管控制,ap_memory 管数据,axis 管流式。三者配合好了,你的卷积加速器就能在 Zynq 上跑得飞起。
可可精品资料库