4、第一个Hello World程序:编写简单矢量加法Kernel、主机端代码实现、编译与运行流程
说实话,学FPGA嵌入式开发,最让人头疼的就是第一个程序跑不起来。我当年刚接触Vitis时,光是搭建环境就折腾了两天。所以这一章,咱们直接上手,写一个最简单的矢量加法Kernel,把整个流程走通。你跟着我做一遍,后面就顺了。
4.1 矢量加法Kernel:从数学到硬件
矢量加法,说白了就是两个数组对应元素相加。比如A[0]+B[0]=C[0],A[1]+B[1]=C[1],以此类推。在CPU上写个for循环就搞定,但在FPGA上,我们要把它写成Kernel——一个可以在硬件上并行执行的函数。
我个人习惯把Kernel代码放在src/kernel目录下。新建一个文件,叫vadd.cpp,内容如下:
// vadd.cpp - 矢量加法Kernel
#include <hls_stream.h>
#include <ap_int.h>
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 bundle=control
#pragma HLS INTERFACE s_axilite port=return bundle=control
// 循环展开,实现并行计算
for (int i = 0; i < size; i++) {
#pragma HLS PIPELINE II=1
c[i] = a[i] + b[i];
}
}
}
这里有几个关键点,我得跟你说明白:
- extern "C":防止C++名字修饰,保证主机端能正确调用。
- #pragma HLS INTERFACE:定义Kernel与外部通信的接口。m_axi是AXI Master接口,用于读写DDR;s_axilite是AXI Lite接口,用于传递标量参数。
- #pragma HLS PIPELINE II=1:告诉编译器,这个循环要流水线化,每个时钟周期处理一个数据。II=1表示启动间隔为1,也就是每个周期都能开始一个新迭代。
我的小经验:刚开始写Kernel时,别急着优化。先把功能跑通,再考虑PIPELINE、UNROLL这些优化指令。我曾经一上来就搞复杂优化,结果调试了三天才发现是接口定义错了。
4.2 主机端代码:如何调用Kernel
Kernel写好了,得有人叫它干活。这个“人”就是主机端代码,运行在ARM处理器上。新建src/host/main.cpp:
// main.cpp - 主机端代码
#include <stdio.h>
#include <stdlib.h>
#include <xrt/xrt_kernel.h>
int main(int argc, char **argv) {
// 1. 加载XCLBIN文件
xrt::device device = xrt::device(0);
xrt::uuid xclbin_uuid = device.load_xclbin("vadd.xclbin");
// 2. 创建Kernel句柄
xrt::kernel kernel = xrt::kernel(device, xclbin_uuid, "vadd");
// 3. 准备数据
const int size = 1024;
std::vector<int> a(size), b(size), c(size);
for (int i = 0; i < size; i++) {
a[i] = i;
b[i] = i * 2;
}
// 4. 分配设备内存并传输数据
xrt::bo bo_a = xrt::bo(device, size * sizeof(int), kernel.group_id(0));
xrt::bo bo_b = xrt::bo(device, size * sizeof(int), kernel.group_id(1));
xrt::bo bo_c = xrt::bo(device, size * sizeof(int), kernel.group_id(2));
bo_a.write(a.data());
bo_b.write(b.data());
bo_a.sync(XCL_BO_SYNC_BO_TO_DEVICE);
bo_b.sync(XCL_BO_SYNC_BO_TO_DEVICE);
// 5. 设置Kernel参数并启动
xrt::run run = kernel(bo_a, bo_b, bo_c, size);
run.wait();
// 6. 读取结果
bo_c.sync(XCL_BO_SYNC_BO_FROM_DEVICE);
bo_c.read(c.data());
// 7. 验证结果
int errors = 0;
for (int i = 0; i < size; i++) {
if (c[i] != a[i] + b[i]) {
errors++;
}
}
printf("Test %s with %d errors\n", errors ? "FAILED" : "PASSED", errors);
return errors;
}
这段代码的流程很清晰:
- 加载XCLBIN:XCLBIN是编译好的硬件二进制文件,包含了Kernel的配置信息。
- 创建Kernel句柄:相当于拿到一个遥控器,后面用它来启动Kernel。
- 准备数据:在主机内存中生成测试数据。
- 分配设备内存:FPGA不能直接访问主机内存,得通过
xrt::bo(Buffer Object)在DDR中开辟空间。 - 启动Kernel:调用
kernel()函数,传入参数,然后等待完成。 - 读取结果:把计算结果从设备内存拷回主机。
- 验证:检查计算结果是否正确。
注意:我曾经犯过一个低级错误——忘记调用
sync()同步数据。结果Kernel读到的全是垃圾值,排查了整整一个下午。记住:sync(XCL_BO_SYNC_BO_TO_DEVICE)是把数据从主机传到设备,sync(XCL_BO_SYNC_BO_FROM_DEVICE)是反过来。
4.3 编译与运行:从源码到硬件
代码写完了,怎么让它跑起来?Vitis的编译流程分两步:
- 硬件编译:把Kernel代码综合成硬件电路,生成XCLBIN文件。这一步很慢,小项目也要十几分钟。
- 软件编译:编译主机端代码,生成可执行文件。这一步很快,几秒钟搞定。
我建议用Makefile来管理编译流程。新建Makefile:
# Makefile
PLATFORM = xilinx_u250_gen3x16_xdma_4_1_202210_1
TARGET = hw
KERNEL_SRC = src/kernel/vadd.cpp
HOST_SRC = src/host/main.cpp
XCLBIN = vadd.xclbin
HOST_EXE = vadd_host
# 硬件编译
$(XCLBIN): $(KERNEL_SRC)
v++ -t $(TARGET) --platform $(PLATFORM) -k vadd -o $@ $<
# 软件编译
$(HOST_EXE): $(HOST_SRC)
g++ -o $@ $< -lxrt_core -lpthread
# 运行
run: $(XCLBIN) $(HOST_EXE)
./$(HOST_EXE)
clean:
rm -rf $(XCLBIN) $(HOST_EXE) _x
然后执行:
make run
如果一切顺利,你会看到:
Test PASSED with 0 errors
核心要点:整个流程就是“写Kernel → 写主机代码 → 编译 → 运行”。你想想看,其实和普通软件开发很像,只不过多了一步硬件编译。这一步虽然慢,但换来的是硬件级的并行加速。
4.4 知识体系:一张图看懂
为了让你更直观地理解整个流程,我画了一张图:
这张图展示了两个层面:左侧是开发流程,从上到下依次是编写代码、编译、运行;右侧是运行时数据流,数据从主机内存传到设备内存,Kernel通过AXI接口读取并计算,最后结果再传回主机。
避坑指南:我曾经在编译时忘记指定平台(PLATFORM),结果v++报了一堆莫名其妙的错误。所以Makefile里一定要写对平台名称。不确定的话,可以用
platforminfo -l命令查看已安装的平台。
嗯,到这里,第一个Hello World程序就完成了。你可能会觉得步骤有点多,但相信我,跑通一次之后,后面的开发就只是在这个框架上做加法而已。矢量加法虽然简单,但它包含了Kernel开发的所有核心要素:接口定义、数据搬运、并行计算、结果验证。把这个吃透了,后面的矩阵乘法、图像处理、神经网络加速器,都是同样的套路。
可可精品资料库