4. C/C++代码规范与HLS约束:可综合的C/C++子集、数据类型选择(ap_int/ap_fixed)、循环与数组的约束、函数内联与流水线
好,咱们进入第四章。这一章,说白了就是教你写HLS能认的C/C++代码。
很多刚接触HLS的朋友,上来就把纯软件C代码往Vivado HLS里一丢,然后抱怨综合不了。嗯,我当年也干过这事儿。后来被时序报错折磨了几次,才老老实实把规范捡起来。
这一章,我带你捋一遍HLS的“规矩”。哪些C/C++语法能用,哪些不能用。数据类型怎么选。循环和数组怎么处理。还有函数内联和流水线,这两个优化手段怎么用。
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):大部分容器不能用
- 函数指针:间接调用无法综合
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
// 示例:用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;
| 数据类型 | 位宽 | 资源消耗 | 适用场景 |
|---|---|---|---|
| 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];
}
数组约束:分区与映射
数组默认综合成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就可以开始工作,不用等全部处理完。这个技巧我在做视频流处理时经常用,效果很好。
好了,这一章的内容就这些。记住:数据类型选对,循环管好,函数优化到位,你的HLS设计就成功了一大半。
可可精品资料库