第二讲:HLS 数据类型与接口综合

各位同学好,今天我们来聊聊HLS里最基础也最容易踩坑的部分——数据类型和接口综合。说实话,我刚开始用Vitis HLS那会儿,觉得数据类型不就是C语言那套吗?结果综合出来的硬件要么面积爆炸,要么时序跑不过。后来才明白,HLS里的数据类型,跟软件C完全是两码事。

一、任意精度整数:别再用int了

先问大家一个问题:你写C代码时,是不是习惯用int、short、char?但在硬件里,这些类型其实很浪费。比如你只需要一个10位的计数器,用int就是32位,白白多出22个触发器。我做过一个图像处理项目,里面全是8位像素值,结果有人全用int,综合出来面积大了三倍。

HLS提供了ap_int和ap_uint,前者带符号,后者无符号。用法很简单:

#include <ap_int.h>

ap_uint<8> pixel;      // 8位无符号,范围0-255
ap_int<10> offset;     // 10位有符号,范围-512到511
ap_uint<1> flag;       // 1位,其实就是个wire

为什么要用这个? 说白了,就是省资源。你想想看,一个8位的加法器和一个32位的加法器,面积能一样吗?而且位宽越小,时序越好跑。我个人的习惯是:能用ap_uint<N>绝不用int,除非你真的需要32位。

小技巧: 如果你不确定位宽够不够,可以稍微多留1-2位做保护。比如像素值最大255,你用ap_uint<9>,这样加法时不会溢出。但别留太多,否则面积又上去了。

二、定点数:浮点数的替代方案

图像处理里经常要算一些系数,比如高斯滤波的权重。用float当然方便,但综合出来的硬件又大又慢。这时候定点数就派上用场了。

HLS的定点数类型是ap_fixed和ap_ufixed。它的格式是ap_fixed<W, I>,W是总位宽,I是整数部分位数。小数部分就是W-I位。

#include <ap_fixed.h>

// 总16位,整数8位,小数8位
ap_fixed<16, 8> coeff = 1.5;

// 无符号版本,总12位,整数4位,小数8位
ap_ufixed<12, 4> weight = 0.625;

我记得有一次做HDR图像融合,需要计算多个曝光图像的加权平均。用float综合出来延迟很大,换成ap_fixed<16, 8>后,不仅面积小了,吞吐量也上去了。当然,定点数有精度损失,你得自己评估误差是否在可接受范围内。

注意: 定点数的量化模式可以设置,比如AP_RND(四舍五入)或AP_TRN(截断)。默认是截断,如果你需要更高精度,记得显式指定。我曾经因为默认截断导致图像出现条纹,排查了半天才发现是量化问题。

三、流接口(axis):数据搬运的利器

图像处理里,数据通常是一帧一帧流式传输的。HLS提供了hls::stream和AXIS接口来支持这种模式。说白了,就是数据像流水一样,一拍一拍地往前走。

定义AXIS接口很简单:

#include <hls_stream.h>
#include <ap_axi_sdata.h>

// 定义AXIS数据类型,比如32位数据+1位用户+1位最后
typedef ap_axis<32, 1, 1, 1> axis_pixel;

void image_filter(hls::stream<axis_pixel> &input,
                  hls::stream<axis_pixel> &output) {
#pragma HLS INTERFACE axis port=input
#pragma HLS INTERFACE axis port=output

    axis_pixel p;
    for (int i = 0; i < 1024; i++) {
        p = input.read();
        // 处理像素...
        p.data = p.data * 2;  // 简单处理
        output.write(p);
    }
}

为什么要用stream? 因为stream有反压机制。如果下游处理不过来,stream会自动阻塞上游。这比用FIFO手动管理方便多了。我做过一个项目,用stream做流水线,代码写起来跟串行一样简单,但综合出来是并行流水线。

核心要点: AXIS接口的TLAST信号很重要。在图像处理中,通常用TLAST标记一行的结束或一帧的结束。别忘了在代码里正确设置它,否则下游模块可能不知道数据什么时候结束。

四、内存接口(m_axi):大块数据的搬运

有时候数据量太大,不能全部放在片上BRAM里。比如一张1080p的图像,光像素数据就2MB多,片上根本放不下。这时候就需要用m_axi接口,直接读写DDR。

m_axi接口的定义方式:

void ddr_access(ap_uint<64> *input, ap_uint<64> *output, int size) {
#pragma HLS INTERFACE m_axi port=input offset=slave depth=1024
#pragma HLS INTERFACE m_axi port=output offset=slave depth=1024
#pragma HLS INTERFACE s_axilite port=size

    for (int i = 0; i < size; i++) {
        output[i] = input[i] * 2;
    }
}

这里要注意几个点:

  • depth参数: 告诉HLS这个接口需要多大的FIFO深度。设小了可能丢数据,设大了浪费资源。
  • offset=slave: 表示地址由外部通过AXI-Lite配置。
  • 突发传输: m_axi支持突发模式,一次可以连续读写多个数据。但需要你的循环是连续访问的,不能跳来跳去。

我曾经遇到过一个坑:m_axi接口的带宽利用率很低。后来发现是因为循环里有条件分支,导致访问地址不连续,无法触发突发传输。解决办法是把数据重新排列,保证连续访问。

优化建议: 使用m_axi时,尽量让数据访问是连续的。如果必须随机访问,考虑用#pragma HLS ARRAY_PARTITION把数据分到多个BRAM里,或者用#pragma HLS DATA_PACK打包数据。

五、综合对比:什么时候用什么接口?

我整理了一个表格,方便大家对比:

接口类型 适用场景 带宽 资源消耗 典型应用
axis 流式数据,逐像素处理 高(一拍一个数据) 低(只需FIFO) 图像滤波、缩放
m_axi 大块数据,随机访问 高(支持突发) 中(需要地址生成) 帧缓存、查找表
bram 小数据,片上存储 极高(零延迟) 低(BRAM资源) 行缓存、系数表

说白了,选接口就是看你的数据流模式。如果是流水线式的,用axis;如果是随机访问大块内存,用m_axi;如果是小数据频繁访问,用BRAM。

六、知识体系总览

下面这张图总结了本章的核心内容,大家可以对照着理解:

HLS数据类型与接口综合知识体系 数据类型 任意精度整数 定点数 ap_int/ap_uint ap_fixed/ap_ufixed 接口类型 流接口(axis) 内存接口(m_axi) hls::stream + ap_axis 指针 + pragma 综合优化 资源优化 时序优化 位宽裁剪、数据打包 流水线、突发传输 核心原则:根据数据流模式选择类型和接口,在资源、带宽、延迟之间做权衡 实践建议 1. 能用窄位宽就别用宽位宽,省资源也省时序 2. 流式处理优先用axis,大块数据用m_axi 3. 定点数代替浮点数,但要评估精度损失 4. 使用m_axi时保证连续访问,触发突发传输 5. 别忘了设置TLAST信号,否则下游会卡住

嗯,这张图基本把本章的内容串起来了。数据类型决定你用什么位宽和精度,接口类型决定数据怎么进出,综合优化则是让你在资源、带宽、延迟之间找到平衡点。

最后说一句:HLS开发,说白了就是C代码的硬件化。但你不能用软件的思维去写硬件代码。数据类型和接口的选择,直接决定了最终硬件的质量。多动手,多综合,慢慢就有感觉了。

可可精品资料库