3. C/C++代码风格与可综合规范

做HLS设计这几年,我踩过最多的坑,反而不是算法多复杂,而是C/C++代码写得太“软件”了。你想想看,同样的代码,在CPU上跑得飞快,拿到FPGA上综合,要么报错,要么资源爆炸。说白了,HLS编译器不是万能的,它只认一个子集——可综合子集。

这一章,我就把那些容易出问题的地方,掰开揉碎了讲清楚。

3.1 可综合C/C++子集:哪些能用,哪些别碰

HLS工具(比如Vitis HLS)支持的C/C++语法,其实是个子集。我刚开始用的时候,习惯性地写了malloc和printf,结果综合直接报错。嗯,这里要注意:

  • 支持:基本数据类型、循环、数组、函数、指针(有限制)、结构体、模板。
  • 不支持:动态内存分配(malloc/free)、递归、虚函数、异常、标准库容器(vector/list)、文件IO(fopen/printf)。

核心原则:所有资源必须在编译时确定大小。FPGA没有操作系统帮你动态分配内存。

举个例子,我曾经接手一个项目,同事用std::vector存数据,觉得方便。结果综合时工具提示“不支持动态结构”。改回固定大小数组后,问题解决。所以,写HLS代码时,脑子里要时刻绷着一根弦:这段代码综合出来是什么电路?

3.2 数据类型选择:ap_int/ap_fixed 才是王道

C语言自带的int、float,在HLS里能用,但效率不高。为什么?因为FPGA的DSP单元和LUT是固定位宽的。你用int(32位),即使实际只需要10位,综合器也会生成32位电路,浪费资源。

我个人习惯,只要涉及计算,一律用ap_int或ap_fixed。它们来自Vitis HLS的任意精度库:

#include <ap_int.h>
#include <ap_fixed.h>

// 定义一个10位有符号整数
ap_int<10> a;

// 定义一个16位定点数,8位整数部分,8位小数部分
ap_fixed<16, 8> b;

这里有个经验:定点数ap_fixed的位宽选择,直接影响精度和资源。我在做图像处理时,像素值范围0~255,用ap_uint<8>就够了。但做FFT时,中间结果位宽会增长,需要仔细分析动态范围。

小技巧:如果不知道选多少位,可以先仿真,用printf打印最大值和最小值,再确定位宽。但记得仿真完要删掉printf。

3.3 循环的规范写法:让HLS编译器看懂你的意图

循环是HLS优化的核心。但写不好,综合出来的性能可能还不如软件。我见过最典型的错误,就是循环边界不是常量。

3.3.1 循环边界必须为常量

HLS编译器需要知道循环执行多少次,才能展开或流水线化。如果你写成:

for (int i = 0; i < n; i++) {  // n是变量,不可综合
    ...
}

工具会报错。正确做法是:

const int N = 1024;
for (int i = 0; i < N; i++) {  // 常量边界
    ...
}

如果n确实运行时才能确定,可以用#pragma HLS loop_tripcount给工具一个提示,但最好还是设计成固定大小。

3.3.2 循环流水线化(Pipelining)

这是提升吞吐量的关键。在循环前加一行pragma:

#pragma HLS PIPELINE II=1
for (int i = 0; i < N; i++) {
    // 每次迭代只依赖前一次的结果
    c[i] = a[i] + b[i];
}

II=1表示每个时钟周期启动一个新迭代。但要注意,如果循环体内部有数据依赖(比如c[i] = c[i-1] + 1),II可能无法达到1。我曾经在一个FIR滤波器设计中,因为依赖链太长,II只能做到4,后来通过重排计算顺序,才压到1。

3.3.3 循环展开(Unrolling)

展开可以并行执行多个迭代,但代价是资源翻倍。用法:

#pragma HLS UNROLL factor=4
for (int i = 0; i < 16; i++) {
    // 展开后变成4个并行计算
}

我个人建议:先尝试流水线化,如果吞吐量不够,再考虑部分展开。全展开(factor=0)除非循环很小,否则资源会爆炸。

避坑指南:我曾经在一个项目中,把128次循环全展开了,结果LUT用了90%,布线失败。后来改成factor=8流水线,资源降到40%,性能只降了10%。

3.4 数组的规范写法:从BRAM到接口

数组在HLS中会综合成BRAM或寄存器。写得好,数据吞吐流畅;写得不好,会成为瓶颈。

3.4.1 数组分区(Array Partition)

默认情况下,一个数组映射到一个BRAM,只有一个读写端口。如果多个循环同时访问不同元素,就会冲突。解决方案是分区:

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

// 现在arr被分成4个BRAM,可以同时访问4个不同元素

分区有三种模式:cyclic(循环分配)、block(块分配)、complete(完全展开成寄存器)。我一般先用cyclic,因为对连续访问友好。

3.4.2 数组作为接口

当数组是顶层函数的参数时,它默认综合成BRAM接口。但如果你希望用FIFO或AXI Stream,需要显式指定:

void top(int a[1024], int b[1024], int c[1024]) {
#pragma HLS INTERFACE mode=axis port=a
#pragma HLS INTERFACE mode=axis port=b
#pragma HLS INTERFACE mode=axis port=c
    ...
}

这里要注意:AXI Stream接口没有地址,数据是顺序传输的。如果你的算法需要随机访问,那就只能用BRAM接口。

3.5 知识体系总览

下面这张图,是我自己总结的HLS代码风格核心要点。你可以把它当作一个检查清单:

HLS可综合C/C++代码规范 可综合子集 支持:基本类型、循环、数组 不支持:malloc、递归、vector 数据类型选择 ap_int/ap_fixed 精确位宽 避免int/float浪费资源 循环规范 边界必须为常量 PIPELINE / UNROLL 数组处理 ARRAY_PARTITION 分区 接口类型选择(BRAM/AXI) 核心原则 所有资源编译时确定 · 数据依赖最小化 · 位宽精确匹配

3.6 避坑指南与个人经验

最后,分享几个我实际项目中遇到的教训:

  • 指针滥用:HLS支持指针,但不支持指针运算(比如p++)。如果你需要遍历数组,用索引arr[i]更安全。
  • 结构体对齐:结构体成员默认对齐到4字节边界。如果你用ap_int<3>,实际会占用8位。可以用#pragma HLS aggregate强制紧凑排列。
  • 函数内联:小函数建议加inline,减少层级。但大函数内联会导致代码膨胀,反而降低频率。

我的习惯:写HLS代码前,先画一个数据流图。哪些数据并行,哪些串行,一目了然。然后根据数据流图,决定循环是流水线还是展开。这样写出来的代码,综合结果通常不会太差。

嗯,这一章的内容就到这里。记住:HLS不是把C代码扔进去就能用的,它需要你用硬件思维去写软件。多练几次,你就能找到感觉。

可可精品资料库