4、第一个Vitis程序:创建Vitis项目、编写简单的向量加法内核、编写主机端代码、编译与运行、性能分析
好,咱们正式开始动手了。
前面几章聊了不少概念,什么HLS、什么Zynq架构、什么软硬件协同。说实话,光听不练容易晕。我自己的经验是——跑通第一个程序,比看十遍文档都管用。
这一章,我们就来写一个最经典的入门例子:向量加法。别小看它,麻雀虽小五脏俱全。从创建项目到跑出结果,再到看性能数据,整个Vitis开发流程你都能走一遍。
4.1 创建Vitis项目
打开Vitis IDE,你会看到一个熟悉的Eclipse风格界面。嗯,别被它吓到,其实操作逻辑很清晰。
第一步:创建平台工程
- 点击
File → New → Platform Project - 给平台起个名字,比如
zcu102_platform - 选择你的硬件描述文件(XSA)。这个文件一般由Vivado生成,包含了PS和PL的配置信息
- 我习惯勾上
Generate boot components,这样后面省事
第二步:创建应用工程
- 点击
File → New → Application Project - 选择刚才建好的平台
- 给应用起名,比如
vadd_example - 系统语言选
C++ - 模板先选
Empty Application,咱们自己写代码
4.2 编写向量加法内核
内核(Kernel)就是跑在FPGA上的加速逻辑。我们用HLS C++来写。
在项目中新建一个源文件,叫 vadd.cpp:
#include <ap_int.h>
#include <hls_stream.h>
#define DATA_SIZE 4096
extern "C" {
void vadd(
const int *a, // 输入向量A
const int *b, // 输入向量B
int *c, // 输出向量C
int size // 向量长度
) {
#pragma HLS INTERFACE m_axi port=a offset=slave bundle=gmem0
#pragma HLS INTERFACE m_axi port=b offset=slave bundle=gmem1
#pragma HLS INTERFACE m_axi port=c offset=slave bundle=gmem2
#pragma HLS INTERFACE s_axilite port=size
#pragma HLS INTERFACE s_axilite port=return
for (int i = 0; i < size; i++) {
#pragma HLS PIPELINE II=1
c[i] = a[i] + b[i];
}
}
}
这段代码看起来简单,但有几个关键点我得提醒你:
extern "C":这是必须的,告诉编译器用C的命名规则,不然主机端找不到函数#pragma HLS INTERFACE:定义了内核和外部怎么通信。m_axi是走DDR内存,s_axilite是走控制总线#pragma HLS PIPELINE II=1:这个最狠。它告诉编译器,每个时钟周期处理一个数据。II=1意味着流水线全开
4.3 编写主机端代码
主机端代码跑在ARM处理器上,负责调用内核、搬运数据。新建 main.cpp:
#include <stdio.h>
#include <stdlib.h>
#include <xrt/xrt_bo.h>
#include <xrt/xrt_device.h>
#include <xrt/xrt_kernel.h>
#define DATA_SIZE 4096
int main() {
// 1. 打开设备
xrt::device device = xrt::device(0);
xrt::uuid xclbin_uuid = device.load_xclbin("vadd.xclbin");
// 2. 创建内核对象
xrt::kernel kernel = xrt::kernel(device, xclbin_uuid, "vadd");
// 3. 分配缓冲区
size_t size_in_bytes = DATA_SIZE * sizeof(int);
xrt::bo bo_a = xrt::bo(device, size_in_bytes, kernel.group_id(0));
xrt::bo bo_b = xrt::bo(device, size_in_bytes, kernel.group_id(1));
xrt::bo bo_c = xrt::bo(device, size_in_bytes, kernel.group_id(2));
// 4. 准备数据
int *a = bo_a.map<int*>();
int *b = bo_b.map<int*>();
int *c = bo_c.map<int*>();
for (int i = 0; i < DATA_SIZE; i++) {
a[i] = i;
b[i] = i * 2;
}
// 5. 数据从主机传到FPGA
bo_a.sync(XCL_BO_SYNC_BO_TO_DEVICE);
bo_b.sync(XCL_BO_SYNC_BO_TO_DEVICE);
// 6. 运行内核
auto run = kernel(a, b, c, DATA_SIZE);
run.wait();
// 7. 数据从FPGA传回主机
bo_c.sync(XCL_BO_SYNC_BO_FROM_DEVICE);
// 8. 验证结果
int errors = 0;
for (int i = 0; i < DATA_SIZE; i++) {
if (c[i] != a[i] + b[i]) errors++;
}
printf("Test %s with %d errors\n", errors ? "FAILED" : "PASSED", errors);
return 0;
}
这段代码的流程很清晰:
- 打开设备,加载比特流(xclbin)
- 创建内核句柄
- 分配三块缓冲区:输入A、输入B、输出C
- 准备测试数据
- 把数据从DDR搬到FPGA的全局内存
- 启动内核,等待完成
- 把结果从FPGA搬回DDR
- 验证结果
sync()。结果数据根本没传过去,内核算了个寂寞。记住:数据不会自己飞过去,必须显式同步。
4.4 编译与运行
编译分两步:先编译内核,再编译主机程序。
编译内核:
v++ -t hw --platform xilinx_zcu102_base_202410_1 \
-c -k vadd -o vadd.xo vadd.cpp
v++ -t hw --platform xilinx_zcu102_base_202410_1 \
-l -o vadd.xclbin vadd.xo
编译主机程序:
aarch64-linux-gnu-g++ -std=c++17 \
-I$XILINX_XRT/include \
-L$XILINX_XRT/lib \
-lxrt_coreutil -o vadd_host main.cpp
运行:
把 vadd_host 和 vadd.xclbin 拷贝到板子上,然后:
./vadd_host vadd.xclbin
如果一切顺利,你会看到:
Test PASSED with 0 errors
-t hw 没加。不加这个选项,默认走软件仿真,根本不会生成真正的比特流。嗯,这个坑我替你们踩过了。
4.5 性能分析
跑通只是第一步。我们得看看它到底快不快。
Vitis提供了性能分析工具。在运行程序后,会生成一个 xrt.run_summary 文件。用Vitis Analyzer打开:
vitis_analyzer xrt.run_summary
你会看到几个关键指标:
| 指标 | 说明 | 我们的例子 |
|---|---|---|
| Kernel Execution Time | 内核实际运行时间 | 约 2.5 μs(4096个元素) |
| Data Transfer Time | 数据传输时间 | 约 15 μs |
| Total Wall Time | 总耗时 | 约 20 μs |
| Throughput | 吞吐量 | 约 1.6 GB/s |
看到没?数据传输时间比计算时间还长。这是FPGA加速的典型特征——计算极快,但数据搬运是瓶颈。所以实际项目中,我们往往要设计流水线,让计算和传输重叠起来。
另外,Vitis Analyzer还能看到内核内部的流水线情况。比如我们的向量加法,如果II=1,那每个时钟周期都在干活。如果II>1,说明有资源冲突,需要优化。
嗯,第一个程序就到这里。你可能会觉得:就这么点东西?对,就这么点。但相信我,能跑通这个例子,你已经掌握了Vitis开发的80%的流程。剩下的,无非是换更复杂的内核、调更多的参数。
这张图把整个数据流画清楚了。你写代码时,脑子里要有这张图——数据从哪里来,到哪里去,谁在什么时候干活。搞明白这个,Vitis开发就不再是黑盒了。