🏠

sw/host.cpp & host.h 子模块文档

概述

host.cpphost.h 构成了 Versal ACAP 平台上 AIE+PL 异构系统的主机控制应用程序。这是整个系统的"指挥中枢",负责通过 XRT(Xilinx Runtime)API 与硬件交互。


host.h 详解

设计意图

host.h 是一个轻量级的头文件,解决两个关键问题:

  1. OpenCL 兼容性:定义宏确保与 OpenCL C++ 绑定兼容
  2. DMA 对齐要求:提供 4KB 对齐的内存分配器

核心组件

1. OpenCL 兼容性宏

#define CL_HPP_CL_1_2_DEFAULT_BUILD
#define CL_HPP_TARGET_OPENCL_VERSION 120
#define CL_HPP_MINIMUM_OPENCL_VERSION 120
#define CL_HPP_ENABLE_PROGRAM_CONSTRUCTION_FROM_ARRAY_COMPATIBILITY 1

为什么需要这些宏?

XRT 的历史演进中曾支持 OpenCL API。虽然本教程使用原生 XRT C++ API(xrt::device, xrt::kernel 等),但这些宏确保了代码在混合环境中的兼容性。可以将其视为"保险策略"——如果未来需要迁移到 OpenCL 风格的 API,这些宏提供了向后兼容的基础。

版本选择 rationale

  • OpenCL 1.2 是嵌入式系统广泛支持的稳定版本
  • 更高的版本(如 2.0)引入了更多复杂性(SVM、共享虚拟内存),对于确定性要求的信号处理场景并非必需

2. aligned_allocator —— DMA 友好的内存分配

template <typename T>
struct aligned_allocator {
    using value_type = T;
    
    T* allocate(std::size_t num) {
        void* ptr = nullptr;
        if (posix_memalign(&ptr, 4096, num*sizeof(T)))
            throw std::bad_alloc();
        return reinterpret_cast<T*>(ptr);
    }
    
    void deallocate(T* p, std::size_t num) {
        free(p);
    }
};

为什么是 4KB 对齐?

想象 DMA 引擎是一辆高速货运列车,它喜欢在有明确标记的站台(页边界)装卸货物:

  • 4KB = 一页:ARM Linux 的默认页大小
  • 不对齐的代价:如果缓冲区跨越页边界,DMA 可能需要拆分成多次传输,增加开销
  • 双重保险:XRT 的 xrt::bo 内部也会处理对齐,但主机侧显式对齐确保了与底层 DMA 控制器的最佳配合

内存所有权模型

操作 责任方 说明
分配 aligned_allocator::allocate 使用 posix_memalign 从堆分配
所有权 调用者(通常是 std::vector 遵循 RAII,析构时自动释放
设备访问 XRT Buffer Object 通过 xrt::bo 映射到设备地址空间

注意:在本教程的实际代码中,aligned_allocator 被定义但未在 host.cpp 中使用。host.cpp 直接使用 xrt::bo 进行缓冲区管理。这个分配器是为其他可能使用 std::vector<T, aligned_allocator<T>> 的场景准备的。


host.cpp 详解

整体架构

flowchart TB subgraph Initialization["阶段1: 初始化"] A[解析命令行参数] --> B[打开xrt::device] B --> C[加载xclbin] end subgraph MemorySetup["阶段2: 内存设置"] D[分配输入缓冲区] --> E[填充输入数据] E --> F[sync TO_DEVICE] D --> G[分配输出缓冲区] G --> H[填充哨兵值] end subgraph Execution["阶段3: 执行编排"] I[创建s2mm kernel] --> J[启动s2mm run] K[创建mm2s kernel] --> L[启动mm2s run] M[创建graph句柄] --> N[run graph] N --> O[end graph] J --> P[wait s2mm] L --> Q[wait mm2s] end subgraph Verification["阶段4: 验证"] R[sync FROM_DEVICE] --> S[比对golden数据] S --> T[输出测试结果] end Initialization --> MemorySetup MemorySetup --> Execution Execution --> Verification

逐段代码分析

1. 头文件包含

#include <fstream>
#include <cstring>
#include "experimental/xrt_kernel.h"
#include "experimental/xrt_graph.h"
#include "data.h"

关键洞察experimental/ 前缀表明这些 API 处于稳定化过程中。在 2024.2 版本的 Vitis 中,这些 API 已经相当成熟,但保留 experimental 命名空间是为了保持与未来版本的兼容性。

2. 样本数量定义

#define SAMPLES 256

这个宏贯穿整个系统:

  • 输入:128 个复数样本(cint16 = 2 × int16)
  • 插值后:256 个复数样本(2× 上采样)
  • 输出:256 个整数(象限分类结果)

3. 设备初始化

char* xclbinFile = argv[1];
auto device = xrt::device(0);
if(device == nullptr)
    throw std::runtime_error("No valid device handle found...");
auto xclbin_uuid = device.load_xclbin(xclbinFile);

错误处理策略

  • 使用异常而非返回码,符合现代 C++ 风格
  • 但缺少对 argc 的检查——如果用户忘记提供参数会导致未定义行为

改进建议

if (argc < 2) {
    throw std::invalid_argument("Usage: program <xclbin_file>");
}

4. 输入缓冲区设置

int sizeIn = SAMPLES/2;  // 128
auto in_bohdl = xrt::bo(device, sizeIn * sizeof(int16_t) * 2, 0, 0);
auto in_bomapped = in_bohdl.map<uint32_t*>();
memcpy(in_bomapped, cint16Input, sizeIn * sizeof(int16_t) * 2);
printf("Input memory virtual addr 0x%px\n", in_bomapped);
in_bohdl.sync(XCL_BO_SYNC_BO_TO_DEVICE);

内存计算详解

  • sizeIn = 128:复数样本数量
  • sizeof(int16_t) * 2:每个复数占 4 字节(实部 + 虚部各 2 字节)
  • 总大小:128 × 4 = 512 字节

为什么用 uint32_t* 映射?

这是一个类型擦除技巧:

  • cint16Inputint16_t 数组(平面存储:R,I,R,I...)
  • 映射为 uint32_t* 允许以 32 位字为单位访问,与 DMA 的 AXI4 总线宽度匹配
  • 实际使用时通过指针算术按需要解释

同步的必要性

in_bohdl.sync(XCL_BO_SYNC_BO_TO_DEVICE);

这行代码强制执行缓存一致性操作——确保 CPU 写入的数据真正刷新到 DDR,对 DMA 可见。没有这行,DMA 可能读到陈旧的缓存数据。

5. 输出缓冲区设置

int sizeOut = SAMPLES;  // 256
auto out_bohdl = xrt::bo(device, sizeOut * sizeof(int), 0, 0);
auto out_bomapped = out_bohdl.map<uint32_t*>();
memset(out_bomapped, 0xABCDEF00, sizeOut * sizeof(int));
printf("Output memory virtual addr 0x%px\n", out_bomapped);

哨兵值 0xABCDEF00

这是一个调试技巧:

  • 如果输出中出现了 0xABCDEF00,说明对应位置没有被 AIE/S2MM 写入
  • 这种明显的"魔法数字"比全 0 更容易识别问题
  • 在硬件调试中特别有用,可以通过 ILA 捕获总线事务观察

6. Kernel 与 Graph 句柄创建

// PL Kernels
auto mm2s_khdl = xrt::kernel(device, xclbin_uuid, "mm2s");
auto s2mm_khdl = xrt::kernel(device, xclbin_uuid, "s2mm");

// AIE Graph
auto cghdl = xrt::graph(device, xclbin_uuid, "clipgraph");

名称匹配的严格性

  • "mm2s" 必须与 pl_kernels/mm2s.cpp 中的函数名完全一致
  • "clipgraph" 必须与 aie/graph.h 中定义的类实例名一致(clipped clipgraph;

这些名称在链接阶段由 system.cfg 中的连接定义引用,任何不匹配都会导致运行时错误。

7. 执行时序编排(核心!)

// 先启动接收端
auto s2mm_rhdl = s2mm_khdl(out_bohdl, nullptr, sizeOut);
printf("run s2mm\n");

// 再启动发送端
auto mm2s_rhdl = mm2s_khdl(in_bohdl, nullptr, sizeIn);
printf("run mm2s\n");

// 最后启动 AIE Graph
cghdl.run(1);
printf("graph run\n");
cghdl.end();
printf("graph end\n");

// 等待完成
mm2s_rhdl.wait();
s2mm_rhdl.wait();

为什么是这个顺序?

这是生产者-消费者模式的安全实现:

  1. s2mm 先启动:确保接收端准备好,避免数据到达时无人接收(数据丢失)
  2. mm2s 后启动:开始发送数据
  3. graph 最后启动:AIE 会在数据到达时自动开始处理

反例——错误的顺序

// ❌ 危险:先启动 graph
graph.run(1);      // AIE 开始等待输入
mm2s_khdl(...);    // 但数据还没来!
// 结果:可能的死锁或超时

关于 nullptr 参数

查看 kernel 签名:

void mm2s(ap_int<32>* mem, hls::stream<ap_axis<32, 0, 0, 0>>& s, int size);

第二个参数是 hls::stream,对应 AXI4-Stream 接口。在 host 代码中,这个参数通过 system.cfg 中的连接定义隐式绑定到 AIE Graph 的 PLIO,因此 host 侧传入 nullptr 作为占位符。

8. 结果回传与验证

out_bohdl.sync(XCL_BO_SYNC_BO_FROM_DEVICE);

int errorCount = 0;
for (int i = 0; i < sizeOut; i++) {
    if ((signed)out_bomapped[i] != golden[i]) {
        printf("Error found @ %d, %d != %d\n", i, out_bomapped[i], golden[i]);
        errorCount++;
    }
}

if (errorCount)
    printf("Test failed with %d errors\n", errorCount);
else
    printf("TEST PASSED\n");

类型转换的重要性

out_bomappeduint32_t*,而 goldenint 数组。比较前必须转换为有符号类型,否则负数会被错误解释为大正数。


设计决策与权衡

1. 阻塞式同步 vs 异步

当前选择:使用 xrt::run::wait() 阻塞直到完成

权衡

方面 阻塞式 异步式
代码复杂度 简单线性 需要状态机/回调
CPU 利用率 主线程空闲 可并行处理
调试难度 容易定位问题 时序复杂难追踪
适用场景 教学/验证 生产高性能应用

何时应该改为异步

  • 需要重叠数据传输与计算时
  • 主机有其他独立任务可执行时
  • 追求最大吞吐量时

2. 单缓冲 vs 双缓冲

当前选择:单缓冲,一次处理一个批次

优化方向

// 伪代码:双缓冲实现
for (int iter = 0; iter < totalIterations; iter++) {
    // 使用 ping/pong 缓冲区交替
    auto& currentBuf = (iter % 2 == 0) ? buf0 : buf1;
    
    // 上一轮结果的回传与下一轮的发送重叠
    if (iter > 0) prevBuf.sync(FROM_DEVICE);
    if (iter < totalIterations - 1) nextBuf.sync(TO_DEVICE);
    
    graph.run(1);
}

3. 异常 vs 错误码

当前选择:C++ 异常

优点

  • 错误处理与业务逻辑分离
  • 栈展开自动调用析构函数(RAII 安全)

缺点

  • 嵌入式系统中异常可能有性能开销
  • 需要编译器支持(-fexceptions

常见陷阱与调试技巧

🚨 陷阱 1:忘记 sync

// ❌ 错误
memcpy(buffer, data, size);
kernel.run();  // DMA 读到旧数据!

// ✅ 正确
memcpy(buffer, data, size);
buffer.sync(XCL_BO_SYNC_BO_TO_DEVICE);
kernel.run();

🚨 陷阱 2:缓冲区大小单位混淆

// ❌ 错误:传递元素数而非字节数
xrt::bo(device, sizeIn, 0, 0);  // 只分配了 128 字节!

// ✅ 正确
xrt::bo(device, sizeIn * sizeof(int16_t) * 2, 0, 0);

🚨 陷阱 3:Graph 启动过早

// ❌ 危险
graph.run(1);
mm2s.run();  // AIE 饿死

// ✅ 安全
mm2s.run();
graph.run(1);  // AIE 立即有数据可用

🔧 调试技巧

  1. 打印虚拟地址:确认缓冲区确实分配成功

    printf("Buffer @ %p\n", mapped_ptr);
    
  2. 检查哨兵值:如果看到 0xABCDEF00,说明对应位置未被写入

  3. 分阶段验证

    • 先用 x86sim 验证 AIE 算法逻辑
    • 再用 aiesimulator 验证周期精确行为
    • 最后用 hw_emu 验证系统集成
    • 最终上板测试
  4. 使用 XRT 日志:设置环境变量获取详细日志

    export XRT_VERBOSITY=7
    

依赖关系

直接依赖

文件 作用
data.h 提供 cint16Input[]golden[] 测试数据
experimental/xrt_kernel.h XRT PL Kernel API
experimental/xrt_graph.h XRT AIE Graph API

间接依赖(通过 xclbin)

组件 契约
mm2s kernel 必须有 void mm2s(int32*, stream, int) 签名
s2mm kernel 必须有 void s2mm(int32*, stream, int) 签名
clipgraph 必须是 ADF Graph,含 interpolator→clip→classifier 链

扩展阅读

On this page