3. Vivado HLS 与高层次综合:C/C++ 转 RTL、流水线与数据流优化、接口综合与 pragma 指令

好,咱们进入第三章。这一章聊的是 Vivado HLS,说白了就是怎么用 C/C++ 写出硬件来。

很多做软件的朋友一听「硬件设计」就头大。其实 HLS 这玩意儿,就是给你一把「高级语言」的钥匙,让你用 C 的思维去描述硬件行为。我个人习惯叫它「C 转 RTL 编译器」——你写 C 函数,它给你生成 Verilog 模块。

3.1 C/C++ 转 RTL:HLS 到底在干什么?

先看一个最简单的例子。你写个加法函数:

int add(int a, int b) {
    return a + b;
}

HLS 会把它综合成一个组合逻辑加法器。但如果你写个循环:

for (int i = 0; i < N; i++) {
    sum += data[i];
}

嗯,这里就有意思了。默认情况下,HLS 会把这个循环展开成一个「串行累加器」——每个时钟周期加一个数。但如果你告诉它「我要并行」,它就能生成一个加法树。

核心概念:HLS 不是把 C 代码「翻译」成硬件,而是把 C 代码的「行为」映射到硬件结构上。同样的 C 代码,不同的优化指令,出来的 RTL 天差地别。

我在项目中遇到过一件事:一个图像滤波算法,用 C 写出来 50 行,直接综合出来的延迟是 2000 个周期。加了流水线之后,降到 300 个周期。同样的代码,性能差了 6 倍多。

3.2 流水线优化:让硬件「并行干活」

流水线是 HLS 优化里最常用的手段。说白了就是让硬件像工厂流水线一样,每个阶段同时处理不同的数据。

看这个循环:

for (i = 0; i < N; i++) {
    a[i] = b[i] * c[i];
    d[i] = a[i] + e[i];
}

默认综合出来是这样的:

  • 第 1 个周期:读 b[0]、c[0],做乘法
  • 第 2 个周期:写 a[0],读 e[0],做加法
  • 第 3 个周期:写 d[0]
  • …… 每个循环体 3 个周期,N 次循环就是 3N 个周期

加上 #pragma HLS pipeline 之后:

for (i = 0; i < N; i++) {
    #pragma HLS pipeline II=1
    a[i] = b[i] * c[i];
    d[i] = a[i] + e[i];
}

这时候硬件会变成:

  • 第 1 个周期:读 b[0]、c[0],做乘法(同时准备下一轮读)
  • 第 2 个周期:写 a[0],读 e[0],做加法;同时读 b[1]、c[1],做乘法
  • 第 3 个周期:写 d[0];同时写 a[1],读 e[1],做加法;同时读 b[2]、c[2]……

你看,从第 2 个周期开始,每个周期都能输出一个结果。总周期数从 3N 降到了 N+2 左右。

II=1 是什么意思? II 是 Initiation Interval,即「启动间隔」。II=1 表示每个时钟周期都能启动一次新的循环迭代。这是流水线的最高境界。

我曾经在一个 FIR 滤波器项目里,II 死活压不到 1。后来发现是数组的读写冲突——同一个 BRAM 端口,读和写不能同时进行。改成双端口 RAM 就解决了。这种坑,你不做一遍真的想不到。

3.3 数据流优化:函数级别的流水线

流水线是针对循环的。那如果我有多个函数串在一起呢?

void top(A_t in, B_t out) {
    C_t tmp1, tmp2;
    func1(in, tmp1);
    func2(tmp1, tmp2);
    func3(tmp2, out);
}

默认情况下,func1 跑完所有数据,才交给 func2。func2 跑完,才交给 func3。这叫「顺序执行」。

加上 #pragma HLS dataflow 之后:

void top(A_t in, B_t out) {
    #pragma HLS dataflow
    C_t tmp1, tmp2;
    func1(in, tmp1);
    func2(tmp1, tmp2);
    func3(tmp2, out);
}

这时候,func1 处理完第一个数据,就把结果推给 func2,自己接着处理第二个数据。func2 拿到第一个数据就开始干活,不用等 func1 全部做完。

这就像三个工人:第一个拧螺丝,第二个上垫片,第三个打包装。流水线一开,三个人同时干活,产量翻三倍。

注意:dataflow 要求函数之间用 FIFO 或 ping-pong buffer 连接。如果中间变量是标量(比如 int),HLS 会报错。我刚开始用的时候,经常被这个坑到——明明代码逻辑没问题,综合就是报错。后来养成习惯:dataflow 里的中间变量一律用 hls::stream 或者数组。

3.4 接口综合:你的硬件怎么跟外界说话?

C 函数编译成 RTL 模块之后,函数的参数会变成硬件端口。但端口长什么样,得你来定。

看这个函数:

void example(int a, int b, int *c) {
    *c = a + b;
}

默认情况下,HLS 会把 a 和 b 综合成 ap_none 类型的输入端口,c 综合成 ap_vld 类型的输出端口——带 valid 信号。

但实际项目中,我们经常需要跟 AXI 总线打交道。这时候就要用 #pragma HLS interface 来指定接口类型。

接口类型 适用场景 信号数量
ap_none 纯组合逻辑输入 1(数据线)
ap_vld 带握手信号的端口 2(数据 + valid)
ap_fifo FIFO 接口 4(数据 + valid + ready + almost_full)
axis AXI4-Stream 5+(数据 + valid + ready + last + keep)
m_axi AXI4 主接口(读写 DDR) 复杂(地址 + 数据 + 控制)
s_axilite AXI4-Lite 从接口(寄存器配置) 复杂(地址 + 数据 + 控制)

举个例子,如果你想让硬件通过 AXI4-Stream 接收数据:

void process(hls::stream<int> &in, hls::stream<int> &out) {
    #pragma HLS interface axis port=in
    #pragma HLS interface axis port=out
    int val = in.read();
    val = val * 2;
    out.write(val);
}

这样综合出来的模块,输入输出都是 AXI4-Stream 接口,可以直接跟 DMA 或者其它 IP 核对接。

我的经验:做 Zynq 开发时,90% 的接口都用 axis 或 m_axi。axis 适合流式数据(视频、音频、网络包),m_axi 适合随机访问 DDR。别用 ap_vld 去连 AXI 总线——那是给自己找麻烦。

3.5 pragma 指令:HLS 优化的「开关」

HLS 的优化全靠 pragma。我把最常用的几个列出来:

pragma 作用 典型用法
HLS pipeline 循环流水线 #pragma HLS pipeline II=1
HLS unroll 循环展开(并行化) #pragma HLS unroll factor=4
HLS dataflow 函数级流水线 #pragma HLS dataflow
HLS interface 指定接口协议 #pragma HLS interface axis port=in
HLS array_partition 数组拆分(增加读写端口) #pragma HLS array_partition variable=data dim=1 factor=2
HLS resource 指定硬件资源(如 DSP48) #pragma HLS resource variable=sum core=DSP48
HLS latency 约束最大/最小延迟 #pragma HLS latency min=2 max=5
HLS inline 内联函数(减少层级) #pragma HLS inline off

这些 pragma 可以组合使用。比如:

void filter(int data_in[1024], int data_out[1024]) {
    #pragma HLS interface axis port=data_in
    #pragma HLS interface axis port=data_out
    #pragma HLS array_partition variable=data_in cyclic factor=4

    for (int i = 0; i < 1024; i++) {
        #pragma HLS pipeline II=1
        data_out[i] = data_in[i] * 2 + 1;
    }
}

这个例子同时用了 interface、array_partition 和 pipeline。综合出来的硬件:输入输出是 AXI4-Stream,数组被拆成 4 个 BRAM 块,循环每个周期出一个结果。

调试技巧:HLS 的 Synthesis Report 里会告诉你每个循环的 II 是多少。如果 II > 1,点进去看「Dependency」那一栏——它会告诉你为什么流水线被打断了。我一般先看这个,再决定要不要加 array_partition 或者改代码结构。

3.6 本章知识体系

下面这张图把 HLS 的核心流程串起来了:

Vivado HLS 高层次综合知识体系 C/C++ 源代码 HLS 综合引擎 RTL (Verilog/VHDL) 三大核心优化方向 流水线优化 循环流水线 (pipeline) 函数级流水线 (dataflow) II=1 目标 接口综合 AXI4-Stream (axis) AXI4 主接口 (m_axi) AXI4-Lite (s_axilite) 存储优化 array_partition BRAM 拆分 FIFO 深度控制 pragma 指令:pipeline / unroll / dataflow / interface / array_partition / resource 优化后的 RTL 实现 关键:同样的 C 代码,不同的 pragma 组合,生成的 RTL 面积和性能可以差 10 倍以上

从这张图能看出来,HLS 的核心就是「C 代码 → 综合引擎 → RTL」,中间通过三大优化方向(流水线、接口、存储)来调控最终硬件的质量。而 pragma 指令,就是你控制这些优化的「旋钮」。

避坑指南:我曾经在一个项目里,为了把 II 从 2 压到 1,折腾了整整两天。最后发现是数组的读写端口冲突——两个循环同时访问同一个数组的不同元素,但 BRAM 只有一个读端口。加了个 array_partition 就解决了。所以,遇到 II 压不下去,先看 Report 里的「Dependency」和「Resource」。

嗯,这一章的内容就到这里。HLS 这东西,光看文档是学不会的。我建议你找个简单的算法(比如 FIR 滤波或者矩阵乘法),从 C 代码开始,一步步加 pragma,看 Report 的变化。折腾几次,你就摸到门道了。