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;
}

这段代码的流程很清晰:

  1. 加载XCLBIN:XCLBIN是编译好的硬件二进制文件,包含了Kernel的配置信息。
  2. 创建Kernel句柄:相当于拿到一个遥控器,后面用它来启动Kernel。
  3. 准备数据:在主机内存中生成测试数据。
  4. 分配设备内存:FPGA不能直接访问主机内存,得通过xrt::bo(Buffer Object)在DDR中开辟空间。
  5. 启动Kernel:调用kernel()函数,传入参数,然后等待完成。
  6. 读取结果:把计算结果从设备内存拷回主机。
  7. 验证:检查计算结果是否正确。
注意:我曾经犯过一个低级错误——忘记调用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开发流程 1. 编写Kernel代码 vadd.cpp 2. 编写主机代码 main.cpp 3. 硬件编译 v++ → vadd.xclbin 4. 软件编译 g++ → vadd_host 5. 运行验证 运行时数据流 主机内存 a[], b[], c[] 设备内存 (DDR) bo_a, bo_b, bo_c Kernel (vadd) 硬件加速器 sync TO AXI Master 结果写回 sync FROM 主机控制 (xrt::run)

这张图展示了两个层面:左侧是开发流程,从上到下依次是编写代码、编译、运行;右侧是运行时数据流,数据从主机内存传到设备内存,Kernel通过AXI接口读取并计算,最后结果再传回主机。

避坑指南:我曾经在编译时忘记指定平台(PLATFORM),结果v++报了一堆莫名其妙的错误。所以Makefile里一定要写对平台名称。不确定的话,可以用platforminfo -l命令查看已安装的平台。

嗯,到这里,第一个Hello World程序就完成了。你可能会觉得步骤有点多,但相信我,跑通一次之后,后面的开发就只是在这个框架上做加法而已。矢量加法虽然简单,但它包含了Kernel开发的所有核心要素:接口定义、数据搬运、并行计算、结果验证。把这个吃透了,后面的矩阵乘法、图像处理、神经网络加速器,都是同样的套路。

可可精品资料库