ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vivado HLS设计流程详解:从C代码到RTL的FIR滤波器实现与优化

Vivado HLS设计流程详解:从C代码到RTL的FIR滤波器实现与优化

第一次在项目里认真用 Vivado HLS,我心里其实挺没底的:C 代码写得再流畅,真能变成可综合的 RTL?当时手头要做一个 11 阶 FIR 滤波模块,放在原来用 Verilog 的流程里,状态机、时序收敛、接口对接,没个两三天出不来。结果我花了一下午写 C、跑仿真、加指令、导 IP,晚上就把模块接进整个系统开始联调。当然,后续优化和踩坑花的时间也不算少,但这些经验恰恰是这份设计流程最值钱的部分。

这篇文章想把 Vivado HLS 的设计流程从头到尾捋一遍:建工程、写 C、功能仿真、综合、导出 RTL、集成验证,再用一个 FIR 滤波器实例演示全过程。无论你是刚接触 FPGA 的学生,还是被复杂 RTL 拖进度的在职工程师,只要有 C 语言基础,就能顺着这份流程跑通自己第一个 HLS 模块。文末我还会把几处最容易踩的坑单独拉出来说,这些内容常规文档里基本不会写。

1. 先说清楚:HLS 到底在帮你省什么时间

HLS(High-Level Synthesis)的核心思路,是把算法层面的设计意图用 C/C++ 表达,再由工具把它翻译成可综合的 RTL。用一句话概括它的价值:它在算法和硬件之间搭了一座可以快速迭代的桥。传统 RTL 流程里,改一个滤波系数、调一帧数据位宽,可能牵动整条状态机和握手逻辑;在 HLS 里,你改的是 C 代码,重新综合一遍,几分钟后就能看到新的资源占用和时序报告。

我习惯把这件事类比成写汇编和写 C 的区别。你在 Verilog 里做的很多工作,本质上是在手工安排数据在哪个时钟沿流动、哪块逻辑在哪一拍计算。这件事精确、可控,但非常费神。HLS 把这些细节交给工具去调度,你只需要描述“每一步算什么、数据从哪里来、结果到哪里去”。代价是,你对最终硬件微观结构的控制力变弱了,所以它并不适合所有场景。

对比维度传统 RTL 流程HLS 流程
算法改动成本高,动一发牵全身低,改 C 重新综合
时序控制粒度逐周期精确控制交给调度器,粗颗粒表达
调试手段波形逐拍排查先用 C 仿真验证算法,再做联合调试
适合场景接口时序、小规模精确控制逻辑算法密集型模块:滤波、变换、搜索、加速
学习门槛需要熟悉硬件描述和时序概念会写 C 基本能上手

那什么时候不该用 HLS?我自己的判断标准是:如果这个模块的本质是精确的握手协议、逐拍的接口时序,或者逻辑非常简单,直接用 RTL 反而更快。HLS 的强项在算法密度高、数据流清晰、参数需要反复调优的地方,典型如数字信号处理、图像预处理、神经网络推理加速。选对场景,它才真的省时间;选错场景,你会被工具的各种自动推断折腾到怀疑人生。

2. 整条流程拆开看:建工程到导出 IP 的关键环节

2.1 建工程,选器件

打开 Vivado HLS(新版本里叫 Vitis HLS,操作逻辑基本一致),第一步是创建 Project。工程名和路径按项目规范来就行,真正关键的一步是选择器件型号——这一步决定了后面综合时的底层资源模型。选错器件会导致资源评估和时序报告完全失真,所以千万别随手选一个“差不多”的型号。时钟周期我也建议在创建工程时就填真实值,而不是随便填一个 10ns 了事,后面对照时序报告的时候你会感谢这个决定。

创建完工程后,工作台左侧的工程浏览器里能看到 Source、Testbench、Solution 三块区域。第一次用的人容易忽略一个概念:Solution(解决方案)。同一个 C 工程可以创建多个 Solution,每个 Solution 可以设置不同的时钟、优化指令、目标器件,用来做对比实验。我自己的习惯是,优化指令尽量通过 Directive 窗口附加到代码上,并给每个 Solution 起清晰的名字,比如soln_pipeline_ii1、soln_partial_unroll2。不然跑了几轮之后,你自己都会分不清哪个方案对应哪份报告。

2.2 准备源文件:顶层函数和测试平台必须分开

HLS 综合的对象是顶层函数(Top Function),工程里可以有多个 .c/.cpp 文件,但最终只有被指定为顶层的那一个会被综合成硬件。其余文件里的函数如果被顶层函数调用,会被内联或者综合成子模块;而测试平台(Testbench)里的代码不会被综合,只用于 C 仿真和 C/RTL 协同仿真。这个“综合什么、不综合什么”的边界,是新手最容易搞混的地方——把测试代码和功能代码写在同一个文件里,综合时很容易把 printf、文件读写这类不可综合语句带进硬件。

我在工程里通常按三个文件组织:top.cpp(可综合的顶层函数及其内部实现)、top.h(接口和常量定义)、testbench.cpp(main 函数以及输入激励、结果比对)。功能代码里只出现数据类型、运算、数组、循环这类可综合结构,任何涉及打印、文件操作、动态内存的代码一律放 testbench。坚持这一条规则,能让综合报错率直线下降。

2.3 C 仿真:先证明算法是对的

写完成代码和测试平台后,第一步不是综合,而是 C 仿真。这一步和你平时在本地 C 编译环境里跑一个普通程序没有任何区别,几秒到几十秒就出结果。它的作用只有一个:证明你写的算法在这份测试激励下输出正确。这个阶段千万别急着调硬件问题,那是后话。C 仿真过不了,后面所有步骤都白搭。

建议 testbench 里不要只看一眼输出“大概对”,最好做一个自动比对:跑一份黄金参考模型的结果,比对误差是否在容忍范围内,然后return 0表示通过、return 1表示失败。这样在做 C/RTL 协同仿真时,工具能直接通过函数的返回值判断仿真是否通过,省去人工翻波形的痛苦。

2.4 C 综合:第一次看到你的 C 代码变成 RTL

C 仿真通过后,就可以在 Solution 上右键选择 C Synthesis,工具开始把 C 代码翻译成 RTL,并生成综合报告。报告里最值得看的四个数字:Latency(完成一次任务需要的时钟周期数)、Interval(两次启动之间的周期数)、资源占用(LUT、FF、BRAM、DSP、URAM)、以及关键路径的时序估算。

拿到报告后,先不要纠结某个数字“为什么这么大”,而是先建立直觉:Latency 和 Interval 大,说明数据路径还在串行执行,值得做流水优化;资源里 DSP 用得少但 LUT 爆了,说明乘法被工具用 LUT 逻辑实现了,要考虑补约束让乘法落到 DSP 上;BRAM 数量突然增加,多半是某个数组被综合成了存储块,后续可能需要做数组拆分。

2.5 协同仿真:让 RTL 也跑一遍你的测试平台

综合之后,建议做一次 C/RTL 协同仿真(Cosimulation)。这一步由工具自动生成 RTL 级测试平台,把同一份 C testbench 的激励喂给综合出的 RTL,再比对输出。协同仿真最大的价值在于暴露“接口适配”和“指令语义”的问题。比如你给接口加的握手信号在 RTL 里没有按预期出现、流水线边界数据不一致、数组读写顺序被工具重排导致结果差异,这些问题 C 仿真完全看不出来,只有 RTL 仿真才能暴露。协同仿真默认会弹出波形窗口,如果你不想一个个信号翻,直接看控制台打印的 PASS/FAIL 就行。

2.6 导出 RTL / IP,并集成回 Vivado

协同仿真过了,模块就可以交付了。选择 Export RTL,工具会生成压缩 RTL 文件,或者把模块打包成一个 IP。HLS 支持把模块打包成带 AXI 接口的 IP,放进 Vivado 的 IP Catalog,然后在 Block Design 里像拖普通 IP 一样例化。这个步骤是把 HLS 模块接回系统最关键的一环——你在 HLS 里定义的接口类型,决定了它在 Block Design 里长什么样。比如hls::stream顶层形参加上INTERFACE axis指令,导出的 IP 就是 AXI4-Stream 接口;如果只是数组形参,默认一般会变成 BRAM 接口。提前想清楚系统里其他模块怎么和它通信,能省掉大量来回调整的时间。

3. 实例走一遍:11 阶 FIR 滤波器从 C 到 RTL

3.1 算法与接口设计

FIR(有限脉冲响应)滤波器是数字信号处理里最简单的算法之一,特别适合拿来做 HLS 的第一块敲门砖。它的数学形式是 y[n] = sum(coeff[k] * x[n-k]),k 从 0 到 TAPS-1。在硬件上,它天然适合流水线:每个新采样进来,和一组系数相乘累加,输出一个结果。我用 11 阶(11 个抽头)、输入输出各 256 个采样点作为演示规模。为什么选这个规模?因为它小到综合速度快,大到能明显看出循环结构和资源之间的权衡,拿来演示再合适不过。

接口设计上,第一版我故意用三个数组形参:系数coeffs、输入input、输出output,不做任何优化指令。这样综合出来的硬件就是一个“给启动信号,读一批数据,算完写回一组数据”的简洁结构,方便你看清楚 HLS 最原始的行为。后面再改成流式接口,对比两种接口风格对吞吐率的影响。

3.2 C 代码与测试平台

先贴第一版代码:

// fir.h #ifndef FIR_H #define FIR_H #define TAPS 11 #define SAMPLES 256 void fir_hls(const int coeffs[TAPS], const int input[SAMPLES], int output[SAMPLES]); #endif
// fir.cpp #include "fir.h" void fir_hls(const int coeffs[TAPS], const int input[SAMPLES], int output[SAMPLES]) { for (int i = 0; i < SAMPLES; i++) { int acc = 0; for (int j = 0; j < TAPS; j++) { if (i >= j) { acc += input[i - j] * coeffs[j]; } } output[i] = acc; } }

测试平台里用一个朴素的双重循环做黄金参考:

// testbench.cpp #include "fir.h" #include <stdio.h> int main() { int coeffs[TAPS] = {1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1}; int input[SAMPLES]; int output[SAMPLES]; for (int i = 0; i < SAMPLES; i++) { input[i] = i * 3 - 100; } fir_hls(coeffs, input, output); int errors = 0; for (int i = 0; i < SAMPLES; i++) { int expected = 0; for (int j = 0; j < TAPS && i >= j; j++) { expected += input[i - j] * coeffs[j]; } if (output[i] != expected) { errors++; } } if (errors) { printf("FAIL, errors = %d\n", errors); return 1; } printf("PASS\n"); return 0; }

注意测试平台里的黄金参考实现和顶层函数的实现逻辑一样,这在演示工程里没问题,但在真实项目里我强烈建议用脚本工具算一份独立的期望值存成文件,测试平台直接读文件比对,避免“两个实现错到一起去”的尴尬局面。

3.3 仿真、综合,第一份报告的读法

在工程里把fir_hls设为顶层函数,跑 C 仿真,控制台打印 PASS。接下来创建 Solution,时钟设成 10ns,跑 C 综合。我当时的综合报告大致是这样:

指标数值(约)说明
Latency (max)16000+ 周期嵌套循环串行执行的代价
Interval (max)16000+ 周期没加流水,算完一批才启动下一批
LUT几百个逻辑规模不大
FF几百个同上
DSP0乘法没有落到 DSP,全用 LUT 实现
BRAM2~3 块input/output 数组被综合成存储块

这份报告最扎眼的是 Latency 一万六千多个周期。原因不难理解:外层循环 256 次、内层循环 11 次,每算一个输出点,工具默认把乘法、加法、判断、循环计数器这些操作串在一起执行。对 10ns 时钟来说,一个任务要跑 160 多微秒,吞吐率大约只有每秒几千个采样。这个性能对很多实时系统来说完全不够,但也符合预期——它就是我们常说的“先让它跑对,再让它跑快”的起点。

3.4 换成流式接口后的变化

数组接口虽然直观,但和很多实时系统期望的“每个时钟进来一个采样、出去一个结果”并不匹配。这时应该把顶层函数改成hls::stream流式接口:

#include "fir.h" #include "hls_stream.h" void fir_stream(hls::stream<int> &in, hls::stream<int> &out, const int coeffs[TAPS]) { static int shift_reg[TAPS]; for (int i = 0; i < SAMPLES; i++) { #pragma HLS PIPELINE II=1 int x = in.read(); for (int j = TAPS - 1; j > 0; j--) { #pragma HLS UNROLL shift_reg[j] = shift_reg[j - 1]; } shift_reg[0] = x; int acc = 0; for (int j = 0; j < TAPS; j++) { #pragma HLS UNROLL acc += shift_reg[j] * coeffs[j]; } out.write(acc); } }

这里shift_reg是一个移位寄存器,模拟 FIR 的时延链。两个内层循环都加UNROLL,外层循环加PIPELINE II=1,综合后最理想的情况是每个周期完成一次乘加流水,Latency 大幅下降到几十个周期级别(不含数据搬运),Interval 从一万六千多降到 1 个周期。当然,代价是 LUT/FF/DSP 明显上涨——这就是 HLS 优化永恒的主旋律:用资源换吞吐。

4. 三条常用优化指令:PIPELINE、UNROLL、ARRAY_PARTITION 的用法和理由

4.1 PIPELINE:让重叠运算成为常态

PIPELINE 可能是 HLS 里最重要的一条指令。它做的事情是把一个循环拆成若干流水级,让循环的下一次迭代不等上一次完全结束就启动。类比工厂流水线:一个人从头做一辆车要一百分钟,流水线分十个工位,每个工位只做十分钟的活,之后每十分钟就有一辆车下线——单车的完成时间没怎么变,但吞吐率提升了十倍。

在 HLS 里,#pragma HLS PIPELINE II=1表示“我希望每个时钟周期启动一次新迭代”,II 就是 Initiation Interval,指两次迭代启动之间间隔的周期数。如果达不到 II=1,工具会在报告里明确写出当前实现的最小 II 以及瓶颈在哪,比如“因移位寄存器依赖导致 II=2”。这时候你要去解开那个依赖——很可能是某个数组被实现成 BRAM,读写端口不够用了。

4.2 UNROLL:用多份资源换并行度

默认情况下循环是串行迭代的,每次迭代共享同一份硬件资源。UNROLL 指令则把循环体复制多份,让 j=0 到 j=10 的 11 次迭代在硬件上同时发生。对 FIR 来说,UNROLL 之后 11 个移位寄存器值和 11 个系数能够同时进入乘累加器,结果在一个加法树里算完。代价很直观:乘法器从 1 个变成 11 个(可能对应 11 个 DSP),LUT 和 FF 同步上涨。

UNROLL 还有个完全展开的参数可以配合使用,但展开因子太大时布线压力会显著增大,我一般建议先试完全展开,如果时序收敛不了,再退到部分展开(传展开因子),同时配合 II 目标逐档调。

4.3 ARRAY_PARTITION:破解存储瓶颈

这条指令专门对付数组。数组在综合时默认会变成 RAM(BRAM 或 LUTRAM),而一块 RAM 一个读端口、一个写端口,每个周期只能读一个地址。FIR 的乘累加一旦 UNROLL,需要同时读shift_reg[0]到shift_reg[10]共 11 个值,单端口 RAM 根本喂不饱并行计算的胃口。

解决方式是对数组做 ARRAY_PARTITION:把一个 BRAM 拆成多个小 RAM。在 FIR 例子里,#pragma HLS ARRAY_PARTITION variable=shift_reg complete把 11 个元素完全拆成 11 个独立寄存器,工具就可以在一个周期内并行读取所有抽头。如果数组很大,完全拆分不划算,就退而求其次用block或cyclic因子拆分,让工具在端口数和存储面积之间找一个平衡点。

4.4 指令的组合使用与预期收益

单条指令通常解决不了问题。FIR 流式版本能跑到 II=1,靠的是 PIPELINE + UNROLL + ARRAY_PARTITION 三者配合:PIPELINE 定吞吐目标,UNROLL 提供并行计算资源,ARRAY_PARTITION 解除存储端口瓶颈。现实中我优化一个模块,基本都是这个套路——先确定接口和数据流,再选循环级优化,最后解资源瓶颈,而不是一条条指令稀里糊涂地试。每加一组指令就重新综合一次,看报告里的 Latency、Interval、资源三个维度怎么变,很快就能建立直觉。

5. 几个容易踩的坑和我的排查建议

5.1 接口设计不明确,综合出来的 RTL 和预期不符

HLS 的接口自动推断规则挺智能,但“智能”有时就是坑。数组形参默认会被映射成 BRAM 接口,附带ap_mem_port相关的一组读写信号;想用流式接口的时候,如果代码里还是用数组下标访问,没有严格按照流的 read/write 方式处理,工具可能依然推断成存储接口,而不是 AXI4-Stream 或握手接口。我建议每次综合完都去 Interface 报告里扫一眼端口列表,确认每个握手信号(ap_start、ap_done、ap_ready 以及 AXI 相关信号)和外部模块的预期匹配。发现不对,优先改 C 代码而不是在外部补逻辑去适配。

5.2 浮点计算的精度与资源陷阱

用 C 写算法时,顺手用float是最自然的事。但 HLS 综合浮点运算时,会为每个加法和乘法生成庞大的浮点运算单元,延迟高、资源消耗成倍增加。有一次我在某个图像滤波模块里把累加器写成了 float,综合报告里 DSP 直接飙到几十个,Latency 也高得离谱。改成定点之后,资源和延迟立刻降了一个量级。

但定点不是白给的,一定要做精度验证:分别用浮点和定点跑同一批数据,固定好 Q 格式之后,量化误差必须在项目预算内。我通常会在 C 仿真阶段把定点版本和浮点版本的输出差值统计出来,和代码一起评审,别等硬件都跑起来才后悔位宽选小了。

5.3 static 变量和移位寄存器初始化

HLS 中static变量会被综合成寄存器,跨多次调用保持状态。FIR 的移位寄存器非常适合用它。但有一类问题很容易踩:C 模型里 static 变量在每次仿真开始时是 0,硬件寄存器上电后的复位值却取决于你在 RTL 里怎么连复位信号。如果系统里反馈路径依赖初始状态,建议明确给复位信号,或者在 C 代码里做显式初始化逻辑,而不是靠“默认是零”这种隐性假设。

5.4 综合报告和布局布线后的数字对不上

HLS 综合报告里的 LUT/FF/DSP 是理想简化模型,最终布局布线时实际占用通常更高,原因是逻辑复制、时序收敛时的缓冲插入、工具自动添加的调试逻辑。Latency 在布局布线后也可能因为时序收敛不了而被迫放宽时钟,导致实际频率低于预期。我自己的经验是:HLS 报告适合做版本间对比和趋势判断,不适合当作最终交付数字。后端时序不过的时候,先看是哪个路径出了问题,再回到 HLS 调整接口或流水深度。

5.5 工具版本的差异

HLS 工具每隔一个大版本,自动推断规则和指令语法都可能微调。比如早期版本对hls::stream深度的处理、读写阻塞行为、还有接口指令的写法,在新版本里都可能有变化。如果一个工程升级了工具版本,我强烈建议把所有 Solution 重新综合一遍,重点看接口端口和综合警告。之前一个项目就因为换了工具版本后 axis 接口指令的某个选项写法变了,导出的 IP 在 Block Design 里连接不稳定,排查了很久才定位到是版本兼容问题。

5.6 一个让我少加班的工作习惯

最后分享一个个人习惯:每当要开始优化一个 HLS 模块,我会先建三个 Solution——第一个放完全没优化的原始代码,第二个放解除了主要瓶颈的代码,第三个放最终要交付的配置。每个 Solution 只改一组变量,比如 II 目标、UNROLL 因子、数组拆分方式,命名里带上关键参数。这样每次综合完,翻一眼报告就能看到这组改动把资源和延迟往哪个方向推了多远。对比多了,你对“哪条指令该加、加了大概什么效果”的判断力会很快建立起来。这个东西,看十篇文档不如自己跑三组对比来得快。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进