3. HLS基础语法:C/C++可综合子集、数据类型与循环优化基础

各位同学,欢迎来到第三章。今天我们要聊的,是HLS设计中最基础、也最容易被忽视的部分——语法和数据类型。

说实话,我见过不少工程师,C/C++写得飞起,一转到HLS就翻车。为什么?因为HLS的C/C++不是我们平时写软件用的那个C/C++。它有一个「可综合子集」的概念。说白了,就是只有一部分语法能被综合成硬件,另一部分只能用来写测试平台。

3.1 可综合子集:哪些能用,哪些不能用

我刚开始用HLS时,犯过一个低级错误。我在代码里写了malloc,想着动态分配内存多灵活啊。结果综合器直接报错,告诉我「不支持动态内存分配」。嗯,从那以后我就记住了:硬件里没有堆,没有动态分配这一说。

可综合的C/C++子集,其实很有限。我列个清单给你看:

语法特性 可综合 备注
基本数据类型(int, char, float) ✅ float综合成定点数,效率低
数组 ✅ 综合成BRAM或寄存器
指针 ⚠️ 有限制 只能用于顶层接口,不能动态分配
结构体 ✅ 综合成总线接口时要注意对齐
循环(for, while) ✅ 需要确定边界,否则无法综合
函数调用 ✅ 综合成硬件模块
递归 ❌ 硬件不支持递归调用
malloc/free ❌ 没有动态内存
虚函数 ❌ C++多态不支持
异常处理 ❌ try/catch不能用

你想想看,硬件本质上是一堆固定的逻辑门和触发器。它没有操作系统,没有堆栈管理。所以那些依赖运行时环境的东西,统统不能用。

⚠️ 注意: 循环的边界必须是常量。如果你写了一个 for (i = 0; i < n; i++),其中 n 是变量,综合器会报错。我踩过这个坑,当时调试了一整天才发现是循环边界的问题。

3.2 数据类型:ap_int 和 ap_fixed 的精髓

接下来聊聊数据类型。这是HLS里最有意思的部分之一。

标准C/C++的int是32位,char是8位。但在硬件里,位宽是可以任意定义的。你想想看,如果我只想传一个10位的数据,用int就浪费了22个比特。在高速SerDes设计中,每一比特的带宽都弥足珍贵。

Vivado HLS提供了ap_int和ap_fixed这两个神器。它们允许你精确控制数据的位宽。

3.3.1 ap_int:任意精度的整数

ap_int<W> 表示一个W位的有符号整数。ap_uint<W> 是无符号版本。

// 定义一个12位的有符号整数
ap_int<12> data_in;

// 定义一个8位的无符号整数
ap_uint<8> counter;

// 赋值和运算和普通int一样
data_in = 1024;  // 12位能表示的最大值是2047
counter = counter + 1;

我个人习惯在SerDes设计里,所有内部数据都用ap_uint。为什么?因为SerDes处理的是串行比特流,位宽精确控制能省下大量LUT资源。

💡 小技巧: 使用 ap_int 时,综合工具会自动推断出最合适的硬件位宽。你不需要手动做位宽截断,工具会帮你处理好。但要注意,赋值时如果源数据位宽大于目标位宽,高位会被截断——这和C语言的行为一致。

3.3.2 ap_fixed:定点数,浮点的替代方案

在SerDes设计中,我们经常需要处理均衡器系数、增益控制等小数运算。用float?可以,但综合出来的面积大得吓人。用ap_fixed才是正道。

// 定义一个16位定点数,其中8位是整数部分,8位是小数部分
ap_fixed<16, 8> coeff;

// 也可以指定舍入模式和溢出模式
ap_fixed<16, 8, AP_RND, AP_SAT> gain;

// 赋值
coeff = 1.5;  // 实际存储为 1.5 * 2^8 = 384
gain = 2.25;  // 存储为 2.25 * 2^8 = 576

这里AP_RND表示四舍五入,AP_SAT表示饱和处理。我在项目中遇到过一个问题:均衡器系数溢出导致信号失真。后来加了AP_SAT,问题就解决了。

模式 说明 适用场景
AP_RND 四舍五入到最近值 通用场景
AP_RND_ZERO 向零舍入 截断操作
AP_SAT 饱和到最大值/最小值 防止溢出
AP_SAT_ZERO 溢出时归零 特定算法
AP_WRAP 直接截断高位(默认) 模运算
🔑 核心要点: 在SerDes设计中,ap_fixed 的位宽选择直接影响芯片面积和功耗。每减少1位,LUT消耗大约减少5-10%。但位宽太少会导致量化噪声增大,影响误码率。这是一个需要权衡的设计点。

3.3 循环优化基础:性能的起点

循环是HLS设计中最重要的结构。为什么?因为大部分数据处理都在循环里完成。而循环的优化,直接决定了你的设计能跑多快、占多大面积。

我画了一张图,帮你理解循环优化的核心逻辑:

循环优化核心逻辑 原始循环 顺序执行,N次迭代 PIPELINE 流水线,II=1 UNROLL 展开,并行执行 MERGE 合并循环,减少开销 性能提升 吞吐量↑ 延迟↓ 三种优化方式可以组合使用,但要注意资源消耗 PIPELINE + UNROLL 是SerDes设计中最常用的组合

3.3.1 PIPELINE:流水线化

这是最常用的优化手段。它的核心思想是:让循环的每一次迭代重叠执行。

// 原始代码:顺序执行,每次迭代需要3个时钟周期
for (int i = 0; i < N; i++) {
    a[i] = b[i] + c[i];  // 1个周期
    d[i] = a[i] * e[i];  // 1个周期
    f[i] = d[i] >> 2;    // 1个周期
}

// 加PIPELINE后:每次迭代只需要1个时钟周期
#pragma HLS PIPELINE II=1
for (int i = 0; i < N; i++) {
    a[i] = b[i] + c[i];
    d[i] = a[i] * e[i];
    f[i] = d[i] >> 2;
}

II(Initiation Interval)表示启动间隔。II=1意味着每个时钟周期都能启动一次新的迭代。这是最理想的情况。

⚠️ 注意: 不是所有循环都能做到II=1。如果循环体内有数据依赖(比如当前迭代需要上一次迭代的结果),II就会变大。我曾经在一个FIR滤波器设计里,因为数据依赖导致II=3,性能直接掉了三分之二。

3.3.2 UNROLL:循环展开

展开的意思,就是把循环体复制多份,并行执行。

// 原始代码:串行执行4次
for (int i = 0; i < 4; i++) {
    out[i] = in[i] * coeff[i];
}

// 完全展开:4个乘法并行执行
#pragma HLS UNROLL
for (int i = 0; i < 4; i++) {
    out[i] = in[i] * coeff[i];
}

展开后,综合工具会生成4个乘法器同时工作。代价是面积增加了4倍。

我个人习惯在SerDes的均衡器设计里,对抽头系数较少的模块使用部分展开。比如一个16抽头的均衡器,我展开成4组,每组4个抽头。这样性能和面积之间取得平衡。

3.3.3 MERGE:循环合并

当你写了多个相邻的循环,且它们操作的是同一组数据时,可以考虑合并。

// 两个独立的循环
for (int i = 0; i < N; i++) {
    tmp[i] = data[i] * 2;
}
for (int i = 0; i < N; i++) {
    out[i] = tmp[i] + offset;
}

// 合并成一个循环
#pragma HLS LOOP_MERGE
for (int i = 0; i < N; i++) {
    tmp[i] = data[i] * 2;
    out[i] = tmp[i] + offset;
}

合并的好处是减少了循环控制的开销,也减少了中间存储(tmp数组)的BRAM消耗。

🔑 核心要点: 循环优化的三个方向——PIPELINE提升吞吐量,UNROLL提升并行度,MERGE减少开销。在SerDes设计中,我通常先做PIPELINE,如果吞吐量还不够,再考虑UNROLL。MERGE一般是在代码重构时顺手做的。

3.4 实战建议:从零开始写可综合的HLS代码

最后,我分享几个实战中总结的经验:

  1. 先写功能,再优化。不要一开始就加各种pragma。先确保功能正确,然后用Vivado HLS的优化报告看瓶颈在哪。
  2. 数据类型用到位宽精确。在SerDes里,能用ap_uint<8>就不要用int。每省一个比特,都是实打实的资源节省。
  3. 循环边界必须是常量。如果边界是变量,综合器会报错。实在需要变量边界,可以用#pragma HLS LOOP_TRIPCOUNT来指定一个估计值,但这只是用于分析,不影响综合结果。
  4. 小心数组的BRAM消耗。大数组会综合成BRAM,而BRAM是有限的。如果数组太大,可以考虑用#pragma HLS ARRAY_PARTITION来分割成多个小BRAM或寄存器。

嗯,这一章的内容就到这里。记住,HLS的语法和数据类型是基础,基础不牢,后面优化做得再好也白搭。下一章我们会深入讲接口综合,那是SerDes设计的另一个关键点。

可可精品资料库