☰
Vitis HLS入门指南:从C/C++到FPGA硬件加速的完整实践
2026/10/3 3:11:21 网站建设 项目流程

先泼一盆冷水:搜“HLS”的时候,做流媒体的朋友看到的是一套视频分片播放协议,做 FPGA 的朋友看到的却是High-Level Synthesis(高层次综合)。Vitis HLS 是 AMD/Xilinx 那个能把 C/C++ 综合成 RTL 的工具,不是视频处理那个 HLS。这个混淆我见过太多次,不少人拿着视频点播清单跑过来问怎么加速,结果两回事。这篇东西就是写给想认真学 Vitis HLS 的人:从它适合干什么、怎么把软件思维换成硬件思维,到搭一个矩阵乘法工程实际跑通,再到性能优化和最常见翻车点,一条线捋下来。无论你是刚接触 FPGA 的软件工程师,还是被 Verilog 写算法折磨的硬件工程师,这篇文章都能让你少走几周弯路。

我尽量用“干过活”的口吻写,不堆术语,但该说的原理一句不少。文中所有步骤都基于 Vitis HLS 2023.1,旧版本界面稍微不同,但核心概念一样。

1. 搞清楚 Vitis HLS 的适用边界:它不是给所有算法准备的

1.1 为什么算法加速不能只靠“把 C 编译成 RTL”

很多人听到 Vitis HLS 的第一反应是:那我把 OpenCV 的代码丢进去,是不是就能在 FPGA 上跑出几百倍加速?答案会让你失望,但这也是学习这个工具最关键的认知起点——Vitis HLS 不是编译器魔法,它做的是“翻译”,而翻译质量完全取决于你递给它的源代码长什么样。

传统 RTL 开发的问题在于表达方式和算法差距太大。写一个五级流水线的 FIR 滤波器,Verilog 代码写 300 行,其中一半是时序控制、握手信号、复位逻辑,真正的乘加运算只占一小部分。C 语言里表达同样的滤波器就是一组循环和几个数组下标,硬件工程师读起来轻松,软件工程师也能看懂。Vitis HLS 的意义就是把后者的表达成本压缩到前者的十分之一甚至更低,让设计者把精力放在算法结构和数据流上,而不是信号时序上。

但这里有一个必须接受的事实:综合器会老老实实把你写的每个循环、每个数组访问映射到硬件资源上。你写一个嵌套三层、边界不固定的循环,它也能综合,但结果往往是一个巨大无比的状态机加上一堆你不知道什么时候才置位的使能信号。工具的“智能”远没有到理解算法意图的程度,它只是按照一套固定的调度策略去生成硬件。

1.2 什么算法适合 HLS,什么算法应该滚去写 RTL

我用一个表格总结自己的判断标准,这是这几年踩坑踩出来的经验:

类型适合 HLS?原因
图像处理(卷积、缩放、色彩空间转换)非常适合数据流规律、循环结构清晰、定点化容易
数字信号处理(FIR、FFT、滤波)非常适合有成熟的 HLS 库(hls::FIR 等),乘加密集
矩阵运算、机器学习推理算子非常适合循环嵌套规整,并行度挖掘容易
复杂控制逻辑(FSM 套 FSM、协议栈)不太适合状态跳转复杂,HLS 生成的 FSM 难调试
高速接口协议(PCIe、DDR 控制)不适合时序要求苛刻,需要精确到周期的控制,手写 RTL 更直接
随机访问密集、动态数据结构不适合链表、动态内存、递归都无法高效综合

说白了,HLS 擅长的是“计算密集、结构规则、数据流清晰”的算法,不擅长“控制密集、状态复杂、时序刁钻”的逻辑。如果你发现自己的代码里全是if-else嵌套和状态标志位,写 RTL 可能还更快一点。

1.3 一个现实中的混合开发策略

实际工程里,HLS 和手写 RTL 不是二选一,而是分工合作。我的做法是:接口层、物理层逻辑(比如 DDR 控制器、SerDes、以太网 MAC)用手写 RTL,因为那是时序敏感、错误容忍度极低的区域;算法核心层用 HLS 加速,因为可以反复迭代算法参数而不需要重新写一遍 RTL。最后通过 AXI 接口把它们拼在一起。

这种混合策略的好处是:算法改动时只需要重新综合 HLS 那个模块,接口逻辑和系统架构完全不动。我做图像拼接项目时,滤波核从均值滤波换成高斯滤波,Vitis HLS 里改一下系数数组、重新综合导出 IP,Vivado 工程里替换一下 IP 版本就完事,整个过程半天搞定。如果全部手写 RTL,光验证滤波核功能就得一星期。

2. 可综合的 C/C++:把“软件思维”翻译成“硬件结构”

2.1 数据类型:ap_int 与 ap_fixed 才是主角

软件里 int 写到硬件里不一定是 32 位——不,它一定是 32 位,Vitis HLS 会老老实实给你拉 32 根线。问题是很多时候你根本不需要 32 位。图像像素 8 bit 就够,卷积累加 32 bit 可能溢出,16 bit 搭配饱和截断反而更省资源。这就是ap_int<W>存在的意义,它允许你定义任意位宽的数据类型。

浮点数要格外小心。float和double在 Zynq 上不是不能综合,但综合结果是把浮点运算拆成一系列 DSP 和 LUT 组成的软核,资源消耗和延迟都是定点运算的好几倍。我优化过一个神经网络加速模块,把float全部换成ap_fixed<16, 6>之后,DSP 用量直接降了 60%,时序也从勉强收敛变成绰绰有余。代价是精度会有损失,这就看你的算法对误差的容忍度了。

在代码开头加这两行,是 HLS 工程最常见的操作:

#include "ap_fixed.h" #include "ap_int.h" typedef ap_fixed<16, 6> fixed_16_6; // 16 bit 总位宽,6 bit 整数位 typedef ap_int<8> int8; // 8 bit 有符号整数

2.2 循环和内存:边界必须可静态确定,动态分配是禁区

C 语言里写for (i = 0; i < n; i++)天经地义,但到了 HLS 这里,循环边界必须是编译期能确定的常量。因为综合器要根据循环边界来规划硬件流水线的深度,边界不确定就意味着硬件无法确定要展开几份逻辑。如果 n 是运行时从寄存器读出来的,综合器要么放弃优化,要么给你一串错误。

动态内存分配(malloc、new、std::vector的动态 resize)在可综合代码里一律禁止。FPGA 上没有堆,也没有垃圾回收。数组会被映射成片上 BRAM 或 LUTRAM,这些存储器的数量是固定的,你没法在运行的时候“申请一块内存”。我见过有人把 OpenCV 的cv::Mat直接丢进 HLS,综合报错后一脸茫然——那里面有大量动态内存操作,根本不可能综合。

如果确实需要在运行时决定处理多大的数据,有两条路:一是把数组开成最大尺寸,用另一个参数告诉 IP 实际使用多少;二是用流式接口按帧处理,一次处理一个元素,不依赖随机访问。

2.3 指针、数组和接口:顶层函数的签名就是硬件接口的定义

在 HLS 里,顶层函数的参数列表决定了 IP 的硬件接口。一个普通数组参数通常会综合成 BRAM 接口(ap_memory),带地址线、数据线、写使能等;一个指针参数如果标记为m_axi就会生成 AXI 主设备接口,可以主动去读写 DDR。这个设计初看起来很方便,但实际上要求你在写顶层函数之前就清楚 IP 在系统里怎么连接。

我刚开始学的时候犯过一个错:把顶层函数写成传值调用,比如void matmul(int a, int b, int c),以为会自动生成内部寄存器。实际上这样综合出来是一个纯组合逻辑块,数据来了就算,算完就丢,根本不产生握手信号。后来才明白顶层参数要尽量用数组、指针或者引用,而且要用 pragma 显式声明接口协议。

3. 从零跑通一个矩阵乘法加速核:工程搭建、仿真与协同仿真

3.1 环境准备:先装好正确的工具链

学习 Vitis HLS 不需要把 Vitis 全家桶都装上,安装 Vivado 时勾选 Vitis HLS 组件就够了。如果你用 AMD/Xilinx 官网下载的 Vivado ML Edition 2023.1,安装界面里能看到独立的 “Vitis HLS 2023.1” 选项。装完打开就是那个熟悉的蓝色界面,新建工程时选择目标器件,我习惯选xc7z020clg484-1(Zynq-7020),这是各种开发板上最常见的型号,资源情况我心里有数。

开源替代方案是GCC + Verilator那套流程,但调试体验和官方工具的集成度没法比,新手不推荐。License 方面,Vivado/Vitis HLS 的 WebPACK 版本对 Zynq-7020 和 Artix-7 系列完全够用,不需要额外付费。

3.2 写一个能综合的矩阵乘法 top 函数

讲解理论时我不喜欢用 CPU 上跑得很开心、综合时却各种报错的玩具代码。这里给的是一个 8×8 矩阵乘法,接口用m_axi连接到 DDR,方便后面接入 Zynq 的 PS 端进行实测。

#include "matmul.h" #define MAT_N 8 void matmul(int A[MAT_N][MAT_N], int B[MAT_N][MAT_N], int C[MAT_N][MAT_N]) { #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=return for (int i = 0; i < MAT_N; i++) { for (int j = 0; j < MAT_N; j++) { int acc = 0; for (int k = 0; k < MAT_N; k++) { acc += A[i][k] * B[k][j]; } C[i][j] = acc; } } }

第 8 行的acc是关键。软件工程师写矩阵乘法可能会把累加结果直接写回C[i][j],但在硬件里,每次读改写C[i][j]都意味着一趟 BRAM 的读和写,比用局部变量累加、最后写一次慢得多。这个习惯在 HLS 里直接影响性能和资源,我后面还会细说。

3.3 写一个像样的 testbench 并跑起来

C 仿真阶段的 testbench 和普通 C 程序没区别,关键是验证逻辑。我习惯在 testbench 里做三件事:初始化输入数据、调用待测函数、逐个元素比对结果。比对的期望值不能自己拍脑袋,我一般用三层循环的朴素实现算一遍,再引导至函数结果比对。

#include "matmul.h" #include <iostream> #include <cstdlib> #define MAT_N 8 int main() { int A[MAT_N][MAT_N]; int B[MAT_N][MAT_N]; int C_ref[MAT_N][MAT_N]; int C_dut[MAT_N][MAT_N]; for (int i = 0; i < MAT_N; i++) for (int j = 0; j < MAT_N; j++) { A[i][j] = rand() % 16; B[i][j] = rand() % 16; C_ref[i][j] = 0; C_dut[i][j] = 0; } // 朴素参考实现 for (int i = 0; i < MAT_N; i++) for (int j = 0; j < MAT_N; j++) for (int k = 0; k < MAT_N; k++) C_ref[i][j] += A[i][k] * B[k][j]; matmul(A, B, C_dut); int errors = 0; for (int i = 0; i < MAT_N; i++) for (int j = 0; j < MAT_N; j++) if (C_ref[i][j] != C_dut[i][j]) { errors++; std::cout << "Mismatch at [" << i << "][" << j << "]: " << C_ref[i][j] << " vs " << C_dut[i][j] << std::endl; } if (errors == 0) { std::cout << "TEST PASSED" << std::endl; return 0; } else { std::cout << "TEST FAILED with " << errors << " errors" << std::endl; return 1; } }

在 Vitis HLS 界面里,依次点击 Run C Simulation、Run Synthesis、Run C/RTL Co-simulation,前两项都能顺利通过的话,恭喜你,第一个 HLS IP 的数据通路已经能够正确工作了。Co-simulation 会把你刚才综合出来的 RTL 和 testbench 联合跑一遍,这一步特别重要,因为 HLS 综合器偶尔会“自作主张”改变数据通路的调度,导致行为与 C 仿真不一致。

3.4 看综合报告:不要只盯着 “PASS”

C/RTL 协同仿真通过后,打开 Synthesis Summary 看看几个关键指标:预估频率、LUT/FF/DSP/BRAM 使用量、Latency(完成一次计算需要的周期数)和 Interval(两次计算开始之间的周期数)。

矩阵乘法这个例子,在没有任何优化 pragma 的情况下,8×8 的 Latency 我实测大概是几百到一千个周期。Latency 不等于吞吐量,Interval 才是衡量吞吐的关键。理解这两个指标对后面优化至关重要——如果你要把 IP 用在视频流里,每一帧数据都需要 interval 足够短,否则新的数据到了,上一次还没算完,就只能是丢帧。

4. 性能优化三板斧:流水线、数组分割、数据流

4.1 PIPELINE:让硬件真正“并行”起来

把 8×8 矩阵乘法的综合报告翻出来看,你会发现资源占用很少,但 Latency 动辄几百上千周期,根因是最内层循环没有被流水化。默认情况下,循环每迭代一次,硬件要完成一次乘法、一次加法、写回累加变量,然后才能开始下一次迭代,这三步是顺序执行的。

#pragma HLS PIPELINE II=1加在最内层循环前面,让循环体的每个迭代重叠执行。II(Initation Interval)表示启动间隔,II=1 意味着每个时钟周期都能启动一个新的迭代。对应到硬件,就是综合器在循环体内插入了寄存器级流水段,乘法进行时加法器已经在处理上一个数据,这是我用 HLS 最常做的一个优化,几乎每个计算密集的循环都会加。

对于矩阵乘法这种三层嵌套,光在最内层加 PIPELINE 还不够,B 数组的访问方式会变成瓶颈(下一节细说)。更激进的做法是把内层循环完全展开:

#pragma HLS UNROLL for (int k = 0; k < MAT_N; k++) { acc += A[i][k] * B[k][j]; }

UNROLL会让综合器一次性生成 8 个乘法器和 8 个加法器,代价是资源翻了 8 倍,收益是 Latency 可能只占原来的十分之一。选 PIPELINE 还是 UNROLL,要看目标 FPGA 的资源余量,没有绝对正确的答案。

4.2 ARRAY_PARTITION:破解 BRAM 端口冲突

矩阵乘法最隐蔽的优化瓶颈在数组访问。片上 BRAM 有一个特性:一个周期只能读一次、写一次。循环里读B[k][j]时,如果 B 被映射成单个 BRAM,那么每周期只能取出一个数,PIPELINE II=1就会因为取数带宽不足而失败,综合器会告诉你 “unable to pipeline due to resource constraint”。

解决办法是让数据“分家”:#pragma HLS ARRAY_PARTITION variable=B cyclic factor=8 dim=2,把 B 的第二维按 8 个 bank 拆分。这样循环展开后,每个 bank 独立读写,相当于同时有 8 个端口。代价是多用几块 BRAM,但对 8×8 这种小矩阵,其实是用 LUTRAM 实现的,资源开销很小。

还有一个和 ARRAY_PARTITION 类似但更省资源的ARRAY_RESHAPE,它把多个 bank 拼成一个更宽的字,一次读取就能拿到 8 个数据。具体选哪个,我建议先用ARRAY_PARTITION,因为它直观、容易理解,等资源紧张了再试 RESHAPE。

4.3 DATAFLOW:让多个处理阶段真正并行执行

如果你的 HLS 工程里有多个循环或函数依次处理数据,比如读入 → 预处理 → 矩阵乘 → 后处理 → 写出,默认情况下它们是顺序执行的:前一个循环全部跑完了,后一个才开始。#pragma HLS DATAFLOW可以让这些阶段在数据流上重叠执行,前一个阶段刚产生第一批数据,后一个阶段立刻开始处理。

DATAFLOW 对代码风格有要求,使用它时数组会产生乒乓缓冲(ping-pong buffer),本质是用面积换吞吐。如果数据量太大,乒乓缓冲的存储资源会让你吃不消。更推荐的写法是使用hls::stream,它是 FIFO 接口,没有乒乓双倍存储的问题,还能顺便解决接口位宽不匹配的麻烦。

hls::stream<int> stream_in; hls::stream<int> stream_out; #pragma HLS DATAFLOW read_input(stream_in); compute(stream_in, stream_out); write_output(stream_out);

4.4 循环变换:把最深的循环搬到外面

矩阵乘法标准写法是i-j-k循环顺序,C 语言里这么写效率没问题,但硬件里访问B[k][j]时,B 的列方向元素不是连续存储的,触发大量 BRAM 切换。我优化时通常会做两层变换:

一是交换循环顺序,变成i-k-j,这样B[k][j]在第二维上连续,读 BRAM 时可以按突发方式取一整行;二是把A[i][k]做成局部缓存,在整个计算过程中只从 AXI 读一次,后续全部从片上 BRAM 取数。

这个变换对性能的影响巨大,甚至比加流水线还明显。但代价是代码可读性变差,我一般会在代码里写清楚原始算法和硬件优化版本的对应关系,防止日后自己都看不懂。

5. 那些仿真通过却上板翻车的坑:实测排错记录

5.1 浮点资源爆炸:仿真 1 秒,综合 2 小时

我第一次用 HLS 做图像滤波,代码里全是float,C 仿真秒过,综合时等了将近两小时,出来的报告把我吓一跳:DSP 用了 180 个,占 Zynq-7020 全部资源的 80%,时序还收敛不了。

根因很简单:FPGA 的 DSP 硬核是定点的,浮点乘法必须用多个 DSP 拼成一个硬核不支持的方式,一套单精度浮点乘法单元就要消耗好几个 LUT 和 DSP。解决方法是把浮点数统统换成定点数。在滤波这种允许误差的场景,ap_fixed<16, 6>或ap_fixed<24, 12>的精度完全够用。如果你的算法确实需要浮点动态范围,先检查一下是不是可以用块浮点(block floating point)的方式,一组数据共用指数,只对尾数做定点运算。

5.2 接口协议不匹配:C 仿真过了,接进 SoC 却读不到数据

单独在 Vitis HLS 里做 co-simulation 很容易给人一种“一切正常”的错觉,直到你把 IP 封装好后接进 Zynq 的 PS 端,发现 AXI 总线读回的数据全是 0,或者 PL 端根本拉不起来 AP_START 信号。

我遇到过一次:把顶层数组参数设成默认的ap_memory接口,CPU 访问时通过 AXI BRAM Controller 也可以读写,但没法用普通 AXI 性能计数器或者 DMA 的方式高效搬运。换成m_axi接口后,手写主机代码去控制突发传输时,又发现 burst length 必须对齐到 16 字节,否则数据错位。

所以定义接口之前,先想清楚这个 IP 在系统里扮演什么角色:是挂在 AXI 总线上的从设备,还是要主动读 DDR 的主设备?从设备用s_axilite或ap_none就够,主设备必须m_axi,而且要自己负责地址管理和突发长度对齐。

5.3 循环边界动态变量:综合没报错,但性能一塌糊涂

听我一句劝:哪怕综合器没报错,循环边界也尽量用编译期常量。我之前把循环次数设计成从寄存器读入的参数,综合确实通过了,但产生的硬件是边运行边判断循环次数,导致流水线必须每次都清零重来,Interval 比固定边界的最差情况还差 5 倍。

如果确实需要动态边界,用#pragma HLS TRIPCOUNT min=1 max=1024至少告诉综合器边界的范围,让它能预估延迟并合理调度资源。不过 TRIPCOUNT 只影响性能分析和报告,不会改变最终硬件结构,用它不要指望优化效果。

5.4 有符号与无符号混用:RTL 里的“隐形炸弹”

C 语言里int和unsigned int混用,编译器会做隐式转换,结果通常符合直觉,但同样的表达式综合到 RTL 里,位宽扩展和符号扩展的逻辑会让结果完全不一样。我遇到过两次:一次是int32_t和uint8_t混乘,高位被填成扩展位,导致结果差了一个 2 的 8 次方的倍数;另一次是右移一个有符号数,硬件综合器按逻辑右移实现,符号位没有保留,负数直接变成大正数。

避免办法就是在顶层函数里把所有类型都显式转换,乘法里的操作数统一转成同一种类型,移位操作前先想清楚是逻辑移位还是算术移位。

5.5 一个完整的排错链路:从“C 仿真通过”到“上板结果不对”

分享一个真实排错过程,方便你以后自己定位问题。某次图像处理工程,C 仿真结果完全正确,co-simulation 偶尔对偶尔错,上板后输出图像跟花屏差不多。我当时从三个方向排查:

第一步,回看 co-simulation 的波形。Vitis HLS 的 co-sim 会生成 VCD 波形,我在 Vivado 里打开,找到 AXI 总线的握手信号,发现 PS 端发起读请求后,PL 端的ARREADY信号拉高时机比我预想的晚了几个周期,说明总线仲裁有问题。

第二步,检查 IP 内部的 FIFO 深度。读请求在一个周期内进来了 64 个,但我写的 FIFO 只有 16 个深度,丢了一半数据。把 FIFO 改深后,co-simulation 稳定通过。

第三步,上板验证时又发现新问题:输出图像整体偏移了 32 字节。定位到是m_axi突发传输的地址对齐问题,我传入的 base address 是 0x10000010,不是 16 字节对齐。改成 0x10000020 之后,图像完全正常。

这一轮排错下来,我对 HLS 的认识从“C 语言的硬件版编译器”变成了“一个会生成底层时序的工具”,前者是工具给用户的美好幻觉,后者才是写代码时必须具备的心态。

6. 把 HLS 生成的 IP 接进 Vivado 和 Vitis:走向真实上板验证

6.1 从 Export RTL 到 IP 集成

当你的 HLS 设计在 C/RTL 协同仿真中稳定通过、综合报告符合预期后,选择 Export RTL,工具会把 RTL 封装成 Vivado 可识别的 IP。导出选项里记得勾选 “IP-XACT” 和 “Vivado IP Catalog”,这样在 Vivado 的 IP Catalog 中直接能找到它。

在 Vivado 里创建一个 Block Design,把 Zynq PS 核、你的 HLS IP、AXI Interconnect 拖进来,手动连线完成后点击 Validate Design,系统会检查连接是否完整。这一步经常出现的问题是 AXI 时钟频率不匹配,HLS IP 综合时用的 100MHz,PS 端跑 150MHz,中间没有时钟转换,数据就会出错。

6.2 上板前的时序收敛检查

HLS 综合报告里显示的预计频率只是一个估算值,真正的时序约束在 Vivado 综合布局布线后才会给出准确答案。如果时序不收敛,不要急着改 HLS 代码,先从三个方面排查:时钟约束是否过高(比如一个 200MHz 的 IP 放在 100MHz 的系统中,自然是过约束);组合逻辑链路是否太长(展开过度的 UNROLL 可能在单个周期内串了很多乘法器);接口寄存器是否缺失(m_axi接口信号没有打拍可能导致跨时钟域亚稳态)。

6.3 走向 Vitis 统一流程的扩展方向

如果你不想手动搭建整套 Block Design,也接受用 C++ 写 host 代码调用加速器,那就可以从 Vitis HLS 的独立模式迁到 Vitis 统一平台流程。Vitis 会把你的 HLS 内核封装成 OpenCL kernel,host 端用 C++ 或 Python 的 XRT API 直接调用,这比手动操作 AXI 总线省心不少。

不过 Vitis 平台流程对工程结构的要求更严格,需要先提供一个跑通的 platform(比如 Xilinx 官方开发板平台),再把 kernel 代码编译成.xclbin。我的建议是:如果只是验证算法,坚持用 Vitis HLS + Vivado 流程就够了;如果要做软件/硬件协同的完整应用原型,再上手 Vitis 平台流程。

我在实际项目里最常有的体会是:Vitis HLS 的入门曲线不是代码语法,而是对硬件资源时序的敏感度。用软件那套“编译通过就完事”的习惯写 HLS,综合报告永远会给你上课。反而是愿意花时间看每一条综合警告、每一个资源报告的人,很快就能摸清门道。最后分享一个最实用的小习惯:每次修改代码后,先看 Synthesis Summary 里的 Latency 和 Interval,再决定要不要继续优化;资源占用和时序问题,等性能达标了再处理,这样迭代效率最高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询