4. Vivado HLS入门:从C到RTL的思维转变

说实话,我第一次接触HLS(高层次综合)时,心里是有点抵触的。做了这么多年RTL设计,总觉得手写Verilog才踏实。但后来一个项目让我彻底改观——客户要求两周内完成一个视频缩放算法的硬件加速,用传统RTL方法光写状态机就得一周。嗯,那次我硬着头皮用了HLS,结果三天就搞定了。

HLS不是什么魔法。它就是个翻译工具,把你的C/C++代码翻译成硬件描述语言。但关键在于——你得学会用C语言写出“硬件思维”的代码。

4.1 高层次综合到底在做什么?

简单说,HLS把C函数转换成RTL模块。输入是C代码,输出是Verilog/VHDL,中间经过三个步骤:

  1. 调度(Scheduling):决定每个操作在哪个时钟周期执行
  2. 绑定(Binding):把操作映射到具体的硬件资源(LUT、DSP、BRAM)
  3. 控制逻辑提取:生成状态机来控制数据流

我习惯把HLS比作一个编译器——只不过它编译的目标不是CPU指令,而是硬件电路。

核心概念:HLS不是把C代码“跑”在硬件上,而是根据C代码的行为“生成”一个等效的硬件电路。同样的C代码,不同的优化指令,生成的电路天差地别。

HLS高层次综合流程 C/C++ 源代码 含优化指令 ① 调度 确定时钟周期 安排操作顺序 ② 绑定 映射到硬件资源 LUT/DSP/BRAM RTL 输出 Verilog/VHDL ③ 控制逻辑提取 生成状态机 💡 关键点 C代码行为 → 硬件电路

4.2 C/C++代码到RTL的转换机制

你写一个C函数,HLS会怎么处理?我举个例子:

// 原始C代码
int add_array(int a[4], int b[4]) {
    int sum = 0;
    for (int i = 0; i < 4; i++) {
        sum += a[i] + b[i];
    }
    return sum;
}

这段代码在HLS眼里,会生成什么样的电路?

  • 数组a和b → 两个BRAM或FIFO接口
  • 循环 → 状态机控制的流水线
  • 加法操作 → 一个或多个DSP48模块
  • 累加器sum → 寄存器+反馈路径

但这里有个坑——默认情况下,HLS会串行执行这个循环。也就是说,它生成一个状态机,每个时钟周期做一次加法,4个周期完成。你想想看,如果循环是1024次呢?那延迟就大了。

我曾经踩过的坑:第一次用HLS做FFT加速,没加任何优化指令,结果综合出来的延迟比软件还慢。后来才发现,HLS默认是“最保守”的实现——它优先保证功能正确,性能完全不管。你得主动告诉它“我要并行”、“我要流水线”。

4.3 优化指令(Pragma)基础

优化指令就是你和HLS沟通的桥梁。你告诉它“这里要展开”、“那里要流水线”。我个人最常用的几个指令:

指令 作用 使用场景
#pragma HLS PIPELINE 循环流水线化,提高吞吐率 循环体内部无数据依赖时
#pragma HLS UNROLL 循环展开,并行执行多次迭代 需要最大化并行度时
#pragma HLS ARRAY_PARTITION 数组分割,增加读写端口 消除BRAM端口瓶颈
#pragma HLS INTERFACE 指定端口协议(AXI/BRAM/握手) 模块与外部通信时
#pragma HLS DATAFLOW 任务级流水线,函数间并行 多个函数/循环串联处理时

4.3.1 PIPELINE:让循环跑起来

看这个例子:

void sum_array(int a[100], int &out) {
    int sum = 0;
    for (int i = 0; i < 100; i++) {
        #pragma HLS PIPELINE II=1
        sum += a[i];
    }
    out = sum;
}

II=1表示“Initiation Interval=1”,意思是每个时钟周期都能启动一次新的循环迭代。没有这个指令,HLS会等一次迭代完全结束才启动下一次——100个周期完成。加了PIPELINE II=1,理论上只需要100+流水线深度个周期。

我的经验:II=1是理想值,但能不能达到取决于循环体内的数据依赖。如果当前迭代需要上一轮的结果(比如sum += ...),那II可能被迫增大。这时候可以考虑UNROLL或者重新设计算法。

4.3.2 UNROLL:暴力并行

UNROLL把循环体复制多份,同时执行。比如:

void add_vectors(int a[4], int b[4], int c[4]) {
    for (int i = 0; i < 4; i++) {
        #pragma HLS UNROLL
        c[i] = a[i] + b[i];
    }
}

完全展开后,4个加法器同时工作,一个周期出结果。代价是面积增大4倍。你想想看,如果循环是1024次,全展开的话资源消耗会爆炸。

所以UNROLL有个变体——部分展开:

#pragma HLS UNROLL factor=4

这样每次并行处理4个元素,循环次数减少到原来的1/4。在面积和性能之间取个折中。

4.3.3 ARRAY_PARTITION:解决端口瓶颈

这是个很容易被忽略的指令。默认情况下,数组在硬件里映射成BRAM——一个BRAM只有两个读写端口。如果你的循环要同时读多个数组元素,BRAM端口就成了瓶颈。

int buf[1024];
#pragma HLS ARRAY_PARTITION variable=buf cyclic factor=4 dim=1

这样把一个大数组拆成4个小BRAM,每个有独立端口,可以同时读4个数据。

关键对比:

  • PIPELINE:时间上重叠,提高吞吐率
  • UNROLL:空间上复制,降低延迟
  • ARRAY_PARTITION:消除数据访问瓶颈,为前两者铺路

三者经常配合使用。我习惯先加PIPELINE,如果II达不到1,再检查是不是数组端口不够,加ARRAY_PARTITION。最后如果延迟还不满足,才考虑UNROLL。

4.4 接口综合:你的模块怎么跟外界说话?

HLS生成的模块需要和Zynq的PS(处理器系统)或者其他IP核通信。接口指令决定了通信协议。

接口类型 指令写法 适用场景
AXI4-Stream #pragma HLS INTERFACE axis port=in_data 高速数据流,视频/网络
AXI4-Lite #pragma HLS INTERFACE s_axilite port=return 控制寄存器,PS配置
BRAM #pragma HLS INTERFACE bram port=mem 与PS共享内存
握手信号 #pragma HLS INTERFACE ap_hs port=in_data 简单模块间通信

我记得第一次做AXI-Stream接口时,忘了加axis指令,结果综合出来一堆乱七八糟的握手信号,调试了两天才发现是接口没指定对。

4.5 综合报告怎么看?

综合完成后,Vivado HLS会生成一份报告。我建议你重点关注这几个指标:

  • Latency(延迟):从输入到输出需要多少个时钟周期
  • II(Initiation Interval):两次启动之间的最小间隔
  • BRAM_18K / DSP48E / FF / LUT:资源使用量
  • Worst-case slack:时序裕量,负值说明频率达不到

我的习惯:每次修改代码后,先看资源有没有暴涨,再看延迟有没有改善。如果资源翻倍但延迟只降了10%,那这个优化可能不值得。另外,报告里的“Loop”部分会告诉你每个循环的II是多少——如果某个循环II大于1,点进去看是什么依赖导致的。

4.6 一个完整的例子:从C到RTL

最后,我带你走一遍完整的流程。假设我们要实现一个简单的FIR滤波器:

#include <hls_stream.h>

#define N 32

void fir_filter(
    hls::stream<int> &in,
    hls::stream<int> &out,
    int coeff[N]
) {
    #pragma HLS INTERFACE axis port=in
    #pragma HLS INTERFACE axis port=out
    #pragma HLS INTERFACE s_axilite port=coeff
    #pragma HLS INTERFACE s_axilite port=return

    static int shift_reg[N];
    #pragma HLS ARRAY_PARTITION variable=shift_reg cyclic factor=4

    int acc = 0;
    for (int i = N-1; i > 0; i--) {
        #pragma HLS PIPELINE II=1
        shift_reg[i] = shift_reg[i-1];
    }
    shift_reg[0] = in.read();

    for (int i = 0; i < N; i++) {
        #pragma HLS UNROLL factor=4
        acc += shift_reg[i] * coeff[i];
    }
    out.write(acc);
}

这段代码里:

  • 输入输出用AXI-Stream,适合高速数据流
  • 系数用AXI-Lite,PS可以动态更新
  • 移位寄存器用ARRAY_PARTITION,保证多端口访问
  • 数据搬移用PIPELINE II=1,每个周期移一个
  • 乘累加用UNROLL factor=4,4个乘法器并行

综合后,这个模块大概能跑到200MHz以上,处理32阶FIR滤波器,延迟只有几个时钟周期。如果用纯RTL写,至少得写几百行Verilog,而且调试起来更麻烦。

注意:HLS生成的RTL代码可读性很差——全是自动生成的信号名和状态机。所以不要指望用它来学习RTL设计。它的价值在于快速迭代算法原型,让你把精力花在算法优化上,而不是信号级联上。

嗯,这一章的内容就到这里。HLS入门的关键就四个字:思维转换。你得学会用C语言描述硬件行为,然后用指令告诉工具“我要什么样的硬件”。刚开始可能会不习惯,但相信我,用上几次你就会发现——这玩意儿真香。