3. C/C++语法基础回顾(面向HLS):数据类型、循环与数组、函数与指针、结构体与枚举

各位同学,欢迎来到《HLS硬件加速入门到实战》的第一章。

说实话,很多做FPGA的老手,一开始接触HLS时都会有点懵。为什么?因为C/C++写习惯了,总觉得代码就是顺序执行的。但在HLS的世界里,你得时刻想着——这段代码综合出来是什么电路?

所以,在正式进入HLS设计之前,我强烈建议大家先花点时间,把C/C++里那些跟硬件描述密切相关的语法点重新捋一遍。说白了,就是给后续的硬件加速打地基。

这一章,我们就聚焦在数据类型、循环与数组、函数与指针、结构体与枚举这四个核心模块上。

核心观点:HLS不是简单的C语言编译,而是将C代码翻译成硬件电路。因此,数据类型的选择、循环的写法、指针的使用,都会直接影响最终电路的面积、速度和功耗。

HLS C/C++ 语法基础知识体系 HLS 硬件加速 数据类型 int, float, ap_int等 循环与数组 流水线、展开、分区 函数与指针 内联、数据流、接口 结构体与枚举 打包、状态机 每个知识点都直接影响硬件实现的效率

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优化的基本功。后面的章节,我们会基于这些基础,一步步深入到真正的硬件加速设计中去。

可可精品资料库