
先泼一盆冷水搜“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 里改一下系数数组、重新综合导出 IPVivado 工程里替换一下 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_intW存在的意义它允许你定义任意位宽的数据类型。浮点数要格外小心。float和double在 Zynq 上不是不能综合但综合结果是把浮点运算拆成一系列 DSP 和 LUT 组成的软核资源消耗和延迟都是定点运算的好几倍。我优化过一个神经网络加速模块把float全部换成ap_fixed16, 6之后DSP 用量直接降了 60%时序也从勉强收敛变成绰绰有余。代价是精度会有损失这就看你的算法对误差的容忍度了。在代码开头加这两行是 HLS 工程最常见的操作#include ap_fixed.h #include ap_int.h typedef ap_fixed16, 6 fixed_16_6; // 16 bit 总位宽6 bit 整数位 typedef ap_int8 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-1Zynq-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 portA offsetslave bundlegmem0 #pragma HLS INTERFACE m_axi portB offsetslave bundlegmem1 #pragma HLS INTERFACE m_axi portC offsetslave bundlegmem2 #pragma HLS INTERFACE s_axilite portreturn 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 II1加在最内层循环前面让循环体的每个迭代重叠执行。IIInitation Interval表示启动间隔II1 意味着每个时钟周期都能启动一个新的迭代。对应到硬件就是综合器在循环体内插入了寄存器级流水段乘法进行时加法器已经在处理上一个数据这是我用 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 II1就会因为取数带宽不足而失败综合器会告诉你 “unable to pipeline due to resource constraint”。解决办法是让数据“分家”#pragma HLS ARRAY_PARTITION variableB cyclic factor8 dim2把 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::streamint stream_in; hls::streamint 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 做图像滤波代码里全是floatC 仿真秒过综合时等了将近两小时出来的报告把我吓一跳DSP 用了 180 个占 Zynq-7020 全部资源的 80%时序还收敛不了。根因很简单FPGA 的 DSP 硬核是定点的浮点乘法必须用多个 DSP 拼成一个硬核不支持的方式一套单精度浮点乘法单元就要消耗好几个 LUT 和 DSP。解决方法是把浮点数统统换成定点数。在滤波这种允许误差的场景ap_fixed16, 6或ap_fixed24, 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 min1 max1024至少告诉综合器边界的范围让它能预估延迟并合理调度资源。不过 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 综合时用的 100MHzPS 端跑 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 kernelhost 端用 C 或 Python 的 XRT API 直接调用这比手动操作 AXI 总线省心不少。不过 Vitis 平台流程对工程结构的要求更严格需要先提供一个跑通的 platform比如 Xilinx 官方开发板平台再把 kernel 代码编译成.xclbin。我的建议是如果只是验证算法坚持用 Vitis HLS Vivado 流程就够了如果要做软件/硬件协同的完整应用原型再上手 Vitis 平台流程。我在实际项目里最常有的体会是Vitis HLS 的入门曲线不是代码语法而是对硬件资源时序的敏感度。用软件那套“编译通过就完事”的习惯写 HLS综合报告永远会给你上课。反而是愿意花时间看每一条综合警告、每一个资源报告的人很快就能摸清门道。最后分享一个最实用的小习惯每次修改代码后先看 Synthesis Summary 里的 Latency 和 Interval再决定要不要继续优化资源占用和时序问题等性能达标了再处理这样迭代效率最高。