4. C/C++代码规范与HLS约束:可综合的C/C++子集、数据类型选择(ap_int/ap_fixed)、循环与数组的约束、函数内联与流水线

好,咱们进入第四章。这一章,说白了就是教你写HLS能认的C/C++代码。

很多刚接触HLS的朋友,上来就把纯软件C代码往Vivado HLS里一丢,然后抱怨综合不了。嗯,我当年也干过这事儿。后来被时序报错折磨了几次,才老老实实把规范捡起来。

这一章,我带你捋一遍HLS的“规矩”。哪些C/C++语法能用,哪些不能用。数据类型怎么选。循环和数组怎么处理。还有函数内联和流水线,这两个优化手段怎么用。

HLS代码规范与约束核心知识体系 可综合C/C++子集 数据类型选择 ap_int / ap_uint ap_fixed / ap_ufixed float / double 慎用 循环与数组约束 循环边界必须为常量 数组分区 / 映射 循环展开 / 流水线 函数内联与流水线 INLINE 减少层级 PIPELINE 提高吞吐 DATAFLOW 任务并行 核心目标:面积、时序、吞吐量的平衡 用对类型 + 管好循环 + 优化函数 = 好设计

4.1 可综合的C/C++子集:哪些能用,哪些是坑

HLS不是万能的。它只能综合C/C++的一个子集。我刚开始用的时候,以为跟写软件一样,结果被一堆“unsupported construct”报错砸晕了。

能用的(可综合):

  • 基本数据类型:char, short, int, long long, float, double(但float/double综合出来资源很大)
  • 指针:有限制地使用,不能有动态内存分配
  • 数组:静态数组,大小固定
  • 结构体:可以,但注意对齐
  • 循环:for, while(边界必须可静态确定)
  • 条件语句:if-else, switch-case
  • 函数调用:支持,但建议控制层级
  • 位运算:&, |, ^, ~, <<, >>

不能用的(不可综合):

  • 动态内存分配:malloc, free, new, delete
  • 系统调用:printf, scanf, fopen, time等
  • 递归函数:综合工具无法处理无限递归
  • 虚函数:C++多态不支持
  • 异常处理:try-catch
  • 标准模板库(STL):大部分容器不能用
  • 函数指针:间接调用无法综合
⚠️ 避坑指南: 我曾经在一个项目中,用C++的std::vector来存储中间结果,觉得动态扩容很方便。结果综合时报错说“dynamic memory allocation not supported”。最后只能改成固定大小的数组,折腾了一下午。所以,写HLS代码前,先把STL的习惯戒掉。

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

为什么HLS要引入ap_int和ap_fixed?因为C/C++原生的int是32位,float是32位,double是64位。你想想看,如果我只想算一个10位的加法,用int就是浪费了22位寄存器。

ap_int / ap_uint:任意精度整数

ap_int 表示N位有符号整数,ap_uint 表示N位无符号整数。N可以从1到1024,甚至更大。

// 示例:用ap_uint精确控制位宽
#include <ap_int.h>

ap_uint<8> a = 0xFF;      // 8位无符号,范围0~255
ap_int<12> b = -2048;     // 12位有符号,范围-2048~2047
ap_uint<1> flag = 1;      // 1位,就是一根线

ap_fixed / ap_ufixed:任意精度定点数

做数字信号处理时,浮点运算太费资源。定点数就是你的好朋友。ap_fixed<W,I,Q,O> 定义了一个W位总宽、I位整数部分的定点数。

// 示例:定点数定义
#include <ap_fixed.h>

// 总宽16位,整数部分8位,小数部分8位,有符号
ap_fixed<16,8> x = 3.14159;

// 总宽24位,整数部分12位,小数部分12位,无符号
ap_ufixed<24,12> y = 255.5;
💡 我的经验: 我个人习惯,在算法设计阶段先用float验证功能,然后根据数据范围确定整数位宽,根据精度要求确定小数位宽,最后转成ap_fixed。这样既保证了功能正确,又控制了资源。
数据类型 位宽 资源消耗 适用场景
int 32 中等 通用控制逻辑
ap_uint<8> 8 低 像素值、计数器
ap_fixed<16,8> 16 低 滤波器系数
float 32 高(需FPU) 精度要求极高时
double 64 非常高 尽量不用

4.3 循环与数组的约束:别让综合器猜

循环和数组是HLS优化的主战场。但也是坑最多的地方。

循环约束:边界必须为常量

HLS需要知道循环执行多少次,才能规划硬件资源。如果循环边界是变量,综合器会报错或生成很差的硬件。

// ❌ 错误:循环边界是变量
int n = 100;
for (int i = 0; i < n; i++) { ... }

// ✅ 正确:循环边界是常量
for (int i = 0; i < 100; i++) { ... }

// ✅ 也可以使用宏定义
#define N 100
for (int i = 0; i < N; i++) { ... }

循环优化:UNROLL 和 PIPELINE

默认情况下,循环是串行执行的。要提升性能,就得用这两个指令。

// 循环展开:复制多份硬件,并行执行
#pragma HLS UNROLL factor=4
for (int i = 0; i < 16; i++) {
    c[i] = a[i] + b[i];
}

// 循环流水线:让循环的不同迭代重叠执行
#pragma HLS PIPELINE II=1
for (int i = 0; i < 100; i++) {
    sum += data[i];
}
🔑 关键点: UNROLL用面积换速度,PIPELINE用寄存器换吞吐。我一般先试PIPELINE,如果II(Initiation Interval)达不到1,再考虑UNROLL。

数组约束:分区与映射

数组默认综合成BRAM。但BRAM只有两个读写端口。如果多个地方同时访问数组,就会成为瓶颈。

// 数组分区:将一个BRAM拆成多个小BRAM,增加端口
#pragma HLS ARRAY_PARTITION variable=mem type=complete dim=1
int mem[16];

// 数组映射:将多个小数组合并成一个,节省BRAM
#pragma HLS ARRAY_MAP variable=arr1,arr2 instance=combined

我记得有一次做图像处理,一个数组被三个模块同时读。默认的BRAM只有两个端口,导致时序跑不过。后来用ARRAY_PARTITION拆成三个独立的BRAM,问题就解决了。

4.4 函数内联与流水线:优化你的代码结构

函数在HLS中会被综合成硬件模块。函数调用会产生层级和延迟。怎么优化?两个手段:INLINE 和 PIPELINE。

函数内联(INLINE)

内联就是把函数体直接插入到调用处,消除函数调用的开销。但代价是代码体积变大。

// 默认:函数综合成一个独立模块
int add(int a, int b) {
    return a + b;
}

// 内联:函数体直接展开
#pragma HLS INLINE
int add_inline(int a, int b) {
    return a + b;
}

我个人习惯,对于只调用一次的小函数,直接内联。对于被多次调用的函数,保留模块化结构,然后用PIPELINE优化。

函数流水线(PIPELINE)

函数级别的PIPELINE,可以让函数的不同调用重叠执行。跟循环的PIPELINE类似。

void process_data(int in, int &out) {
    #pragma HLS PIPELINE II=1
    int temp = in * 2;
    out = temp + 1;
}

DATAFLOW:任务级并行

当多个函数之间有数据流关系时,可以用DATAFLOW让它们像流水线一样并行执行。

void top(int in[100], int out[100]) {
    #pragma HLS DATAFLOW
    int buf1[100], buf2[100];
    
    func_a(in, buf1);   // 读入数据
    func_b(buf1, buf2); // 处理数据
    func_c(buf2, out);  // 输出数据
}

DATAFLOW会自动在函数间插入FIFO,让func_a处理完一部分数据后,func_b就可以开始工作,不用等全部处理完。这个技巧我在做视频流处理时经常用,效果很好。

⚠️ 注意: DATAFLOW要求函数之间不能有反馈依赖。如果func_b的结果会影响func_a的输入,那就不能用DATAFLOW。我曾经在一个反馈滤波器中用了DATAFLOW,结果综合出来的结果完全不对,查了半天才发现是依赖问题。

好了,这一章的内容就这些。记住:数据类型选对,循环管好,函数优化到位,你的HLS设计就成功了一大半。

可可精品资料库