3. C/C++语法基础回顾(面向HLS):数据类型、循环与数组、函数与指针、结构体与枚举
各位同学,欢迎来到《HLS硬件加速入门到实战》的第一章。
说实话,很多做FPGA的老手,一开始接触HLS时都会有点懵。为什么?因为C/C++写习惯了,总觉得代码就是顺序执行的。但在HLS的世界里,你得时刻想着——这段代码综合出来是什么电路?
所以,在正式进入HLS设计之前,我强烈建议大家先花点时间,把C/C++里那些跟硬件描述密切相关的语法点重新捋一遍。说白了,就是给后续的硬件加速打地基。
这一章,我们就聚焦在数据类型、循环与数组、函数与指针、结构体与枚举这四个核心模块上。
核心观点:HLS不是简单的C语言编译,而是将C代码翻译成硬件电路。因此,数据类型的选择、循环的写法、指针的使用,都会直接影响最终电路的面积、速度和功耗。
3.1 数据类型:从int到ap_int的跨越
在标准C/C++里,我们习惯了int、float、double这些数据类型。但在HLS中,这些类型会直接映射到硬件寄存器或连线。
举个例子,你用int定义一个变量,综合出来就是一个32位的寄存器。但如果你只需要一个3位的计数器呢?用int就浪费了29个寄存器!
我的经验:在项目中,我见过有人用int定义了一个状态变量,结果综合出来面积比预期大了好几倍。后来改成ap_uint<4>,面积直接降了70%。所以,能用窄位宽就别用宽位宽,这是HLS优化的第一课。
HLS中常用的数据类型包括:
- 标准C类型:char、short、int、long、float、double。这些类型在综合时会映射为固定位宽的硬件单元。
- 任意精度类型:ap_int<N>、ap_uint<N>(有符号/无符号,N为位宽)。这是HLS的杀手锏,你可以精确控制位宽。
- 定点数类型:ap_fixed<W,I>、ap_ufixed<W,I>。用于需要小数运算但不想用浮点数的场景。
为什么会推荐用ap_int?你想想看,在FPGA里,每节省一个寄存器,就意味着你可以把更多的资源留给真正的计算逻辑。我曾经在一个图像处理项目中,把所有int改成ap_uint<8>,资源占用直接减半。
注意:float类型在HLS中综合出来的面积非常大,而且延迟很高。除非你非用不可,否则尽量用定点数ap_fixed替代。我曾经有个同事,在视频处理里用了float做归一化,结果时序怎么都跑不满,后来换成ap_fixed<16,8>,问题迎刃而解。
3.2 循环与数组:HLS优化的主战场
循环和数组,是HLS优化的核心。为什么?因为大部分计算密集型任务,都离不开循环。而数组,在硬件里就是BRAM或寄存器。
循环的优化手段:
- 循环流水线(PIPELINE):让循环的迭代重叠执行。比如一个循环要执行10次,每次3个时钟周期,不流水就是30个周期,流水后可能只需要12个周期。
- 循环展开(UNROLL):把循环体复制多份并行执行。面积换速度的典型做法。
- 循环合并(MERGE):把多个循环合并成一个,减少控制逻辑的开销。
我个人的习惯是:先写一个功能正确的循环,然后用PIPELINE指令看看能不能提速。如果还不够,再考虑UNROLL。不要一上来就UNROLL,否则面积爆炸了别怪我没提醒你。
数组的优化:
数组在HLS中默认映射到BRAM。但BRAM只有两个读写端口,如果你的循环需要同时访问多个数组元素,就会成为瓶颈。
解决办法有两个:
- 数组分区(ARRAY_PARTITION):把一个大数组拆成多个小数组,或者拆成寄存器,增加访问端口。
- 数组映射(ARRAY_MAP):把多个小数组合并成一个大数组,减少BRAM的使用数量。
// 示例:循环流水线
void example(int A[100], int B[100], int C[100]) {
#pragma HLS PIPELINE II=1
for (int i = 0; i < 100; i++) {
C[i] = A[i] + B[i];
}
}
这段代码加了PIPELINE指令,II=1表示希望每个时钟周期都能输出一个结果。能不能做到?取决于你的数组访问有没有冲突。
避坑指南:我曾经在一个项目中,循环里同时访问了数组的A[i]、A[i-1]、A[i+1]三个元素。结果因为BRAM只有两个端口,流水线死活打不起来。后来把数组做了PARTITION,分成三个独立的BRAM,问题才解决。
3.3 函数与指针:理解硬件视角的调用
在C语言里,函数调用就是压栈出栈。但在HLS里,函数调用会被综合成硬件模块的实例化。
函数的关键属性:
- 内联(INLINE):把函数体直接嵌入调用处,消除函数调用的开销。适合小函数。
- 数据流(DATAFLOW):让函数之间像流水线一样并行执行。一个函数处理完一部分数据,下一个函数就可以开始处理,不用等全部完成。
- 接口综合:函数的参数会变成硬件接口。比如指针参数,会变成AXI总线或FIFO接口。
指针在HLS中的特殊性:
指针在HLS里是个双刃剑。用好了,可以灵活地传递数据;用不好,综合出来的接口会让你头疼。
举个例子:
void add(int *a, int *b, int *c) {
*c = *a + *b;
}
这段代码综合出来,a、b、c都会变成独立的硬件接口。如果你只是想传一个标量值,用传值方式(pass by value)会更简单。
我的建议:在HLS中,尽量用引用(&)代替指针,除非你需要传递数组或实现多级间接访问。引用在HLS中综合出来的接口更清晰,也更容易控制。
另外,函数递归在HLS中要慎用。因为递归会生成不确定数量的硬件实例,综合工具很难处理。我一般用迭代代替递归,或者用固定深度的循环展开。
3.4 结构体与枚举:数据打包与状态机
结构体和枚举,在HLS中也有特殊的用法。
结构体:
结构体可以把多个数据成员打包在一起。在HLS中,结构体默认会被拆开,每个成员独立存储。但你可以用数据打包(DATA_PACK)指令,把结构体打包成一个宽位宽的变量,减少接口数量。
struct pixel {
ap_uint<8> r;
ap_uint<8> g;
ap_uint<8> b;
};
// 使用DATA_PACK打包
struct pixel_packed {
#pragma HLS DATA_PACK
ap_uint<8> r;
ap_uint<8> g;
ap_uint<8> b;
};
打包后的结构体,在硬件里就是一个24位的寄存器,访问效率更高。
枚举:
枚举在HLS中常用于状态机(FSM)的定义。比如:
enum state_t {
IDLE,
READ,
PROCESS,
WRITE,
DONE
};
state_t current_state, next_state;
枚举值在综合时会变成常量,状态机逻辑也会被综合成高效的硬件电路。
注意:枚举的底层类型默认是int,也就是32位。如果你的状态数很少(比如只有4个状态),可以用ap_uint<2>来定义枚举的底层类型,节省寄存器。
嗯,说到这里,我想起一个项目。当时用枚举定义了一个8状态的状态机,结果综合出来占了不少寄存器。后来改成ap_uint<3>,面积直接降了一半。所以,能用窄位宽就别用int,这个原则在枚举上也适用。
小结
这一章我们回顾了HLS中最重要的四个语法基础:
- 数据类型:从int到ap_int,精确控制位宽
- 循环与数组:流水线、展开、分区,优化性能的关键
- 函数与指针:理解硬件视角的调用,慎用递归
- 结构体与枚举:数据打包、状态机设计
这些知识点,说白了就是HLS优化的基本功。后面的章节,我们会基于这些基础,一步步深入到真正的硬件加速设计中去。