最近在整理以前做的FPGA图像处理项目,翻到一个很有意思的题目:在FPGA里实现对图像的实时CNN卷积计算。不少朋友一看到“FPGA跑CNN”就觉得是赶时髦,觉得GPU和NPU才是深度学习该去的地方。但真正做过视频采集、机器视觉、工业检测的人应该懂,很多场景卡的不是“算力够不够”,而是“延迟能不能压到几毫秒以内”,以及“能不能直接挂在摄像头后面不用等系统启动”。刚好这两个点都是FPGA擅长的。
这儿先把项目核心摊开说清楚:这是一套基于FPGA的硬件化CNN卷积加速方案,输入是一路实时视频流,FPGA内部直接完成卷积层的乘累加计算,输出是处理后的特征图或检测结果,整个过程不需要经过CPU参与,延迟基本能做到“像素进、结果出”的流式处理。适合正在做FPGA图像处理、深度学习部署落地的开发者参考,也适合准备校招面试时想聊点硬核项目的同学收藏。
1. 项目思路拆解:为什么CNN卷积计算要搬到FPGA上做
1.1 实时图像处理对延迟的要求有多苛刻
先算一笔账。假设采集的是1920x1080@60fps的视频流,一帧时间大约是16.6毫秒。如果走CPU方案,摄像头先把整帧送到内存,CPU再开始读数据、跑一遍卷积、写回结果,光是数据搬运和等待就容易吃掉一半帧时间。真正到算法计算的时候,留给CNN的时间可能只剩几个毫秒。而FPGA方案不一样,图像是逐行扫描进来的,卷积窗口只要攒够3行像素就能开始算,不用等整帧到齐。
拿一个最基础的3x3卷积来看,假设输入是三通道RGB图像,输出32个特征图,那么每个输出像素需要的乘累加次数是3通道x3x3x32,也就是864次。一帧1920x1080画面,总共要算约17.9亿次乘累加。60fps下每秒就是107亿次乘累加。这个量级在GPU上当然轻松,但在一个几百DSP资源的FPGA上,只要把数据通路和流水线设计合理,同样能做到实时——因为硬件计算的本质是“空间换时间”,每个乘法器都同时在干活,而不是像CPU那样排队执行指令。
1.2 对比GPU、NPU和CPU,FPGA的优势和劣势都要看清
很多人问我,现在NPU那么便宜,为什么还要用FPGA?我的看法是,FPGA在特定场景里确实不可替代,但也要承认它不适合所有任务。
FPGA最核心的优势是“低延迟且延迟可控”。GPU吞吐高,但数据要经过PCIe来回搬运,驱动栈和运行时开销大,端到端延迟通常在几毫秒到几十毫秒。FPGA可以直接把MIPI或者LVDS摄像头接口、图像预处理、卷积加速、结果输出全部做进一个芯片里,延迟能压到微秒级。而且FPGA的时序是确定的,不会像CPU那样出现调度抖动。
劣势也很明显:开发周期长、算法迭代不灵活。CNN结构稍微改一下,RTL代码就得跟着调整,神经网络的层数一多,资源开销会指数上涨。所以我的经验是:FPGA适合“网络结构相对固定、需要极致性能和确定性延迟”的落地场景,比如工业视觉检测、医疗内窥镜处理、高速文档扫描仪这类应用。算法还在频繁变动的阶段,老实先用在GPU上跑通了再考虑硬件化。
1.3 这个项目到底选用了什么方案路线
整个项目参考的是“分块流水线”思路,不把整个CNN网络都塞进FPGA,而是优先实现计算量最大的卷积层硬件加速,其他层根据资源和实时性需求灵活处理。具体来说有三个关键设计决策:
- 图像输入采用AXI4-Stream视频流接口,直接对接常见的VDMA或Sensor采集IP;
- 卷积计算单元做成可配置的3x3窗口乘累加阵列,通过寄存器配置通道数和特征图数;
- 第一版先跑单层卷积,验证性能和稳定性后,再扩展到多层流水线。
这个路线的好处是,每走一步都有明确的可验证输出。先做通一路视频进来、卷积完出去的回环通路,再逐步加网络深度,避免一上来就憋个大招结果调不通。
2. 核心细节解析:存储架构与数据位宽的取舍
2.1 行缓存原理:为什么不能整帧存入FPGA片内
做FPGA图像处理绕不开一个概念:行缓存。很多新手上来就是OpenCV思维,要处理整帧图像就先开一个DDR空间,把整帧存进去再读出来做卷积。这在FPGA里是极其浪费的,而且会增加延迟。
以3x3卷积为例,要计算某个像素点(x, y)的输出,需要原始图像中(x, y)周围3x3共9个像素。处理第一行像素时,后面两行还没进来,所以必须先把前两行数据暂存起来。等第三行数据到达时,把缓存中的三行数据一起送入卷积窗口,就能边接收新数据边输出计算结果。
这9个像素在硬件里怎么表示?常见做法是“行缓存+移位寄存器阵列”。行缓存放两行或三行完整图像数据,用FIFO或BRAM实现;移位寄存器阵列负责并行输出3x3窗口内的9个像素。以1920x1080、RGB888三通道图像为例,单行数据量是1920x3字节约5.76KB,三行也就是17.28KB。FPGA片内BRAM动辄几MB,这点开销完全能接受。相比整帧占用1920x1080x3约6.2MB,行缓存直接把片上存储开销压缩了三百倍。
行缓存的深度要根据图像宽度配置,而且当图像尺寸改变时,比如从1080p改成720p,行缓存深度也要跟着改。我的习惯是先在HLS里做成参数化,或者RTL里用常量定义,预留好最大分辨率,后续参数改起来也方便。
2.2 带宽估算:DDR访问和片上缓存怎么分配才不会卡脖子
实时视频处理最怕的不是算力不足,而是带宽打满。需要算三笔账:摄像头输入带宽、DDR读写带宽、卷积读出带宽。
以1080p@60、RGB888输入为例,输入带宽约为1920x1080x3字节x60帧,计算下来约373MB/s。DDR3-1600单片16bit的理论带宽是12.8GB/s,扣除读写切换和刷新开销后实际可用大约7到8GB/s,输入部分完全没问题。卷积读出带宽方面,如果输出特征图也是1080p尺寸,32通道32bit输出,读一遍就是1920x1080x4字节x32,约265MB/帧,60帧每秒相当于15.9GB/s。这个数字超过了单片DDR3的实际可用带宽,所以在多通道设计时必须用片上缓存把中间特征图缓冲一部分。
我实际采用的策略是分层缓冲:行缓存处理最底层的原始图像数据,特征图累加结果先放在BRAM里,攒够一块再批量写DDR。同时尽量把卷积停留在“片内处理完直接传给下一层”,避免每一层都去DDR走一趟。如果特征图通道数太大,就按输出通道分片计算,每片算完一个通道直接写到VDMA的下一级缓冲,这样DDR访问量能降到原来的四分之一左右。
2.3 定点量化:float转int8时精度掉得有多快
CNN模型的权重和激活值在训练时基本都是FP32浮点,直接在FPGA里做浮点乘累加不是不行,但DSP资源消耗大、功耗高,而且很多老型号FPGA根本没有硬浮点单元。实际工程里最常用的是定点量化,最常见的是INT8或INT16。
量化过程需要为每层确定scale和zero_point。简单理解:把浮点数值范围映射到整数范围。公式大概是 float_val = (int_val - zero_point) * scale。scale越小,表示精度越高,但可表示的数值范围越小;scale越大,范围大但精度粗。选scale的时候要看这一层权重和激活的实际分布范围,一般统计最大值和最小值,然后线性映射。
我的经验是,图像分类这种任务INT8量化几乎不掉点,但像目标检测这种对边界框回归敏感的模型,直接用INT8可能出现精度明显退化。稳妥做法是权重用INT8,中间累加器用INT32避免溢出,卷积输出再截断回INT8。累加器用32位这个细节很重要,因为3x3窗口9个数乘加,9个INT8相乘的结果范围大约是-32768到32767之间,如果多通道累加,32位累加器能扛住1024个通道的量级,给后面扩展留了充足余量。
3. 卷积计算单元的硬件化实现要点
3.1 3x3卷积窗口怎么在硬件里“长”出来
前面说了行缓存负责攒数据,这一节具体说卷积窗口的生成。核心思想是把串行输入的像素流转换成并行可用的3x3数据矩阵。
硬件结构是一个三级移位寄存器链配合行缓存。每来一个像素时钟,三行数据各自向右移动一个像素。最右侧三列就是当前卷积窗口:第一行的三个像素来自行缓存1,第二行的三个像素来自行缓存2,第三行的三个像素来自当前输入行。移位寄存器的宽度要匹配像素位宽,RGB888的话就是24bit。
这里有个容易被忽略的细节:padding。卷积神经网络通常要保证输入输出尺寸一致,因此必须在图像边界补零。在行缓存架构里,padding是在行缓存写入和移位寄存器输出两个层面做的。水平方向补零,通过在每个有效行开始前向移位寄存器先压入N个0来实现;垂直方向补零,通过行缓存初始化时先填充N行0来实现。我第一版直接忽略了padding,导致输出特征图尺寸比理论小了一圈,后来在图像边界处发现卷积值全是错的,调了半天才反应过来是补零逻辑没做。
3.2 乘累加阵列与DSP48资源规划
卷积窗口有了,接下来就是乘累加。一个3x3窗口需要9个乘法器,如果支持多通道输入,乘累加阵列就要按通道数扩展。比如三通道RGB输入,第一层卷积就是3通道x9个权重,总共27个乘法器。
FPGA里的DSP48资源是硬核乘法器,以Xilinx 7系列为例,一片中等器件通常有几百个DSP48E1。每个DSP48E1支持25x18乘法累加。设计乘累加阵列时,为了让DSP利用率最大化,我喜欢把乘法结果先寄存一拍,再做加法树合并,避免组合逻辑级联过长导致时序收敛困难。
流水线划分上,一个典型的3级流水线结构是:第一级做乘法,第二级做三输入加法,第三级做跨通道累加。以200MHz工作频率为例,完成一个3x3卷积窗口的完整乘累加需要3个时钟周期。如果一个时钟处理一个像素,那么吞吐率就是200M像素/秒,处理1080p@60图像绰绰有余。实际带宽瓶颈反而在行缓存读数据和DDR写结果上,计算单元倒成了最不缺资源的部分。
3.3 多通道并行策略:面积和吞吐率怎么平衡
很多人在多通道卷积上容易走极端:要么一个通道复用一个计算单元,要么所有通道全部并行。前者资源利用率高但速度慢,后者速度快但DSP消耗大。我的建议是折中,按“输入通道并行、输出通道复用”的方式设计。
举个例子,输入图像三通道,第一层卷积输出4个特征图。设计思路是:输入侧3通道各自对应一组3x3乘法器并行计算,输出侧4个特征图共享这一组乘加结果,每个特征图用不同的权重寄存器循环计算。这样乘法器数量从3x9=27个起步,而不是4x3x9=108个,节省了近四分之三的资源。代价是4个特征图要分时复用计算单元,每个输出像素需要4个时钟周期才能全部算完。在1080p@60场景下,4个时钟周期对应约50M像素每秒吞吐,依然能满足要求。
权重参数存储也要规划。每层权重量化后存入BRAM,计算时按行、按通道实时读取。如果权重太大存不下,可以把大卷积核拆成两个小卷积核,比如5x5拆成两个3x3级联,资源开销反而更小。这是ResNet里经常用的技巧,FPGA实现时同样适用。
3.4 用HLS还是手写Verilog:我的实际选择
这个问题没有标准答案,取决于项目周期和最终目标。我用Vivado HLS做过卷积核IP,也手写过RTL。个人体会是:HLS适合快速验证算法功能和数据流正确性,写起来很像C语言,迭代快;但如果追求极致时序、想精细控制每个DSP的位置和流水线级数,还是手写RTL更顺手。
HLS有个坑值得提醒:pragma指令对最终电路结构影响巨大,比如pipeline、array_partition、dataflow这些指令用错了,生成的电路可能面积翻倍或者性能不升反降。调试HLS生成的RTL代码也比较痛苦,信号命名跟C代码变量对不上,仿真定位问题很费劲。
这个项目我最终采用“HLS做外围接口封装,主干卷积计算单元手写Verilog”的混合方案。接口部分用HLS自动生成AXI接口不容易出错,核心计算单元手写保证可控。实际开发周期上,手写RTL部分花了大概两周,接口封装和调试用了三天,整体可控。
4. 工程化落地:从纯FPGA到SoC协同处理
4.1 单芯片还是SoC:ZYNQ和纯FPGA怎么选
做实时图像CNN卷积,很少是FPGA孤零零跑一个算法,总要跟外部设备交互:摄像头采集、上位机通信、参数配置。这时候就面临平台选择问题。
如果算法链路固定,不需要太复杂的控制逻辑,用纯FPGA加一个简单的MicroBlaze软核就够。软核只负责寄存器配置,不参与像素数据流,就能避免“CPU算不过来”的问题。如果项目里还有Linux、网络协议栈、复杂的调度逻辑,那就直接上ZYNQ这种带硬核ARM的SoC FPGA。PS端跑Linux处理业务,PL端专心做卷积加速,两边用AXI总线通信,这是目前最主流的方案。
我做过一个相机端实时检测项目,用的是ZYNQ平台,Linux侧负责读摄像头、跑检测框后处理,PL侧负责卷积特征提取。ARM和FPGA之间通过AXI-Lite配置寄存器,大量图像数据则用AXI-Stream从采集端直接流进卷积IP,不走ARM。这样ARM完全从像素级计算中解脱出来,只分担低频率的控制和决策任务。
4.2 用STM32H743配合FPGA做FMC通信的参考实践
除了ZYNQ,还有一种很稳的“低成本MCU+FPGA”组合:STM32H743加FPGA,两者通过FMC总线通信。这类方案在入门级项目、教学演示还有小型工业设备里非常常见。FMC全称Flexible Memory Controller,STM32H743的FMC外设支持SRAM接口时序,最高频率约100MHz,数据总线可以配成16bit或32bit。
FPGA端把自己模拟成一个异步SRAM设备,挂在FMC总线上。ARM侧往某个地址写数据,FPGA端解析地址译码和数据总线,完成寄存器或FIFO的读写。时序上关键是匹配FPGA的响应时间和STM32FMC的读写周期。STM32H743的FMC写时序包括地址建立时间、数据建立时间、保持时间,FPGA端要在这段时间内锁存数据并给出响应,否则就会出现数据丢失。
我当时踩过一次坑是16bit数据总线时字节序搞反了。ARM端按照16bit写一个32位寄存器,FPGA收到的高低16位和预期相反,导致卷积窗口权重全错。排查了好久才发现是FMC的字节lane映射到了地址线的低bit,需要在FPGA端做字节交换。这个细节在文档里写得不算明显,建议第一次调FMC的朋友先做连续读写自检,确认字节序和地址映射都对,再开始传真正的图像数据。
4.3 视频接口与板级验证:HDMI/VGA回环怎么搭
验证卷积计算正确性最直观的办法,就是处理结果直接显示到屏幕上。比较省事的方案是FPGA开发板通过HDMI或VGA输出,接一个显示器看图像效果。
输入侧可以用摄像头开发板,也可以用PC发测试图片。摄像头输入走MIPI或者DVP,FPGA里经过行缓存和卷积后,结果再送HDMI TX输出。调试时不用一上来就跑1080p@60,先跑一个低分辨率如640x480@30,方便用ILA抓内部信号。等链路通畅后再调高分辨率,排查时序问题。
ILA是Vivado自带的逻辑分析仪IP,这个工具是调试神器。把卷积窗口的三个行缓存输出信号、乘累加结果、写DDR的地址信号都挂上去,在某个像素坐标处打一个断点,比较FPGA输出和Python或者MATLAB仿真出的参考值。一旦发现某一行开始数据不对,马上能定位是行缓存对齐错了还是权重加载错了,比盲猜高效得多。
5. 常见问题与调试技巧实录
5.1 资源不够用怎么办:先减位宽再减通道
实际部署时经常会遇到LUT、DSP、BRAM爆满的情况。我的优化顺序是:先检查DSP利用率,再看BRAM,最后看LUT。
DSP不够用,优先看能不能把乘法器复用。比如4个input channel分时共享8个DSP,而不是一个通道配一组。BRAM不够用,检查行缓存深度是不是按最大分辨率配置的,如果实际只跑到720p,把行缓存深度调小能省出大量BRAM。LUT太高通常说明组合逻辑写复杂了,考虑把大位宽比较器和多路选择器拆成多级流水。
另一个有效的办法是“时间换面积”。降低工作频率,从200MHz降到150MHz,时序放松后综合器会用更少的资源完成布线。很多初版跑不通的设计,降低频率后资源占用反而大幅下降,因为综合器不用疯狂复制寄存器来满足时序了。
5.2 时序收敛不了:时序违例排查的常见方向
时序不收敛是FPGA开发最磨人的问题之一。遇到setup time violation,先查数据通路是否太长,在关键路径中间插入流水寄存器。遇到hold time violation,多半是时钟偏斜大或者异步路径没约束好,检查时钟约束是否覆盖了所有时钟域。
一个很典型的场景:卷积计算单元工作时钟200MHz,DDR控制器接口时钟300MHz,两个时钟域通过AXI FIFO交互。如果FIFO深度不够或者读写指针同步没做好,就会出现偶发性的数据错位。我遇到过一次计算单元输入偶发多一个像素,查了两天,最后发现是FIFO的almost_empty信号电平抖动导致上游提前灌数据。解决办法是换成标准AXI4-Stream协议,让握手信号控制数据流动,而不是自己用脉冲信号控制。
5.3 卷积结果和软件不一致:三个最容易迷糊的地方
硬件结果和软件参考不一致时,优先检查三件事:权重加载顺序、边界处理方式、定点格式匹配。
权重加载顺序很坑。PyTorch或者TensorFlow模型导出的权重矩阵是行优先,FPGA里如果是列优先读取,卷积结果就会完全错乱。我的做法是在导出权重时先写一段脚本把它转换成FPGA需要的排布格式,避免在硬件里做转置。
边界处理里padding补零和补边界的区别前面提过,很多人漏做。定点格式匹配则是量化scale没对齐,比如软件里用的scale是0.0078,硬件里由于寄存器位宽限制四舍五入成0.008,多层累积下来误差就大了。这时候需要在硬件里保留足够的定点小数位,或者每层输出重新做一次缩放校准。
5.4 实测下来的性能账
最后晒一下当时的实测数据供参考。平台是Xilinx Artix-7系列,100T级别,核心卷积单元跑在180MHz。输入640x480@30灰度图,第一层3x3卷积,输出8个特征图,INT16定点计算。实测整条链路延迟约为1.2毫秒,其中输入行缓存等待约0.6毫秒,卷积计算和输出耗时约0.6毫秒,处理速度远大于30fps需求。
如果上到1080p@60,DDR带宽吃紧,实测延迟会到3到5毫秒,但依然在实时范围内。换更高端的Kintex或者Virtex系列后,DSP资源翻倍,可以跑更大规模的网络,这个方案不需要大改架构,主要调整行缓存深度和并行通道数就行。
6. 一个值得尝试的扩展方向:把预处理和输出后处理也搬进FPGA
最后分享一个我实际做过的扩展。前面主要讲卷积计算本身,但一个完整的图像处理链路里,卷积之前还有降噪、白平衡、缩放,卷积之后还有激活函数、池化、目标框解析。这些如果全在FPGA里做,系统才算真正闭环。
激活函数ReLU最简单,比较大小就行,不耗DSP。池化层用行缓存再拉一层窗口,跟卷积窗口的思路一样。让我意外的是“卡尔曼滤波FPGA实现”这个方向,跟目标跟踪任务结合起来时效果很好:卷积检测确定目标位置,卡尔曼滤波在FPGA里做运动状态预测,两者都放在PL端,完全绕开了CPU,延迟从原来MCU方案的近20毫秒降到2毫秒以内。做过这个扩展之后,我对“FPGA能不能做完整深度学习实时部署”这个问题的答案,已经变得非常肯定。
如果你也在做类似项目,我的建议是先从单层、单通道的小验证开始,等行缓存、乘累加阵列和DDR读写这三个核心模块都熟练了,再往多层、多通道扩展。千万别一上来就想着复现ResNet50,那会让调试复杂度直接爆炸。FPGA开发本身就是个“先把地基打稳,再往上加楼层”的事情,地基打得越扎实,后面扩展起来会越顺手。