纯Verilog实现脉动阵列:FPGA低延迟车牌识别加速器设计
2026/9/8 2:18:05 网站建设 项目流程

车牌识别在停车场、高速收费、园区门禁这些场景里,要求早就不是“能识别”就行,而是“够快、够稳、够便宜”。GPU方案性能强,但几十毫秒的推理延迟、几十瓦的功耗、复杂得能劝退一堆嵌入式工程师的驱动栈,放在前端设备上并不总是最优解。这个项目走了一条更硬核也更可控的路:用纯Verilog实现脉动卷积阵列(Systolic Array)加速器,在FPGA上完成车牌检测和识别,整条链路追求超低延迟,并且同一份RTL代码同时跑在Xilinx和紫光同创两家的FPGA上。

写这篇东西的初衷很简单。网上讲脉动阵列的文章不少,但绝大多数停留在概念图和矩阵乘公式上,真正告诉你“数据流怎么排、PE阵列怎么挂、换国产FPGA要改哪些文件”的几乎没有。这篇我会按一个真实工程的路子走一遍:为什么选脉动阵列、PE和行缓冲怎么设计、车牌识别网络怎么拆进硬件、Xilinx和紫光同创双平台部署时要隔离哪些差异、调试中踩过哪些坑。适合有基础Verilog积累、想自己从零搭CNN加速器的FPGA工程师,也适合准备做视频图像处理项目、正在纠结方案选型的朋友。

1. 项目定位:为什么一个“纯Verilog”能打

1.1 车牌识别的延迟瓶颈到底在哪

一套典型的前端车牌识别系统,链路是这样的:摄像头采集图像,图像经过接口送到处理器,处理器跑预处理、车牌定位、字符识别,最后把结果通过串口或网络发出去。如果用CPU方案,图像往往要先经过一段USB或者网线,在主机侧经过OpenCV、深度学习框架、系统调度这一层层开销,端到端延迟很容易到50ms以上。GPU方案虽然推理快,但功耗和散热在户外卡口、道闸设备里非常尴尬,而且冷启动、驱动版本、掉卡恢复这些问题在无人值守场景里都是隐患。

FPGA的价值在于把“采集-预处理-检测-识别-输出”全部放在同一颗芯片上完成。图像数据从传感器进来之后不需要出芯片,中间结果通过片上BRAM和行缓冲流动,不依赖DDR搬移,延迟自然就压下来了。我在这个项目里的目标是:从帧同步信号有效到识别结果输出,端到端延迟控制在几毫秒量级,为后续接道闸控制或收费系统留出足够多的响应余量。

1.2 为什么选脉动阵列而不是通用卷积器

做CNN加速器,第一反应往往是搭一组并行的乘累加(MAC)单元,每个单元独立算卷积的一部分。这种“广播式”MAC阵列结构简单,但有个隐藏问题:每个计算周期,输入数据要同时广播给大量PE,片上存储和寄存器堆的带宽压力会非常大。一旦网络层数变多、通道数变大,带宽立刻成为瓶颈,大量PE闲着等数据,加速比上不去。

脉动阵列的思路刚好反过来。每个PE只和相邻的PE通信,输入数据像流水一样“流”过整个阵列,权重和激活值在流动过程中被反复复用。它把对存储带宽的要求,转换成了对数据流组织的设计要求。对于车牌识别这种小网络,脉动阵列的优势体现得特别充分:网络层数少、单层特征图尺寸不大,阵列一旦填满,几乎每个时钟周期都能吐出一个有效结果,没有广播风暴,也没有中间结果反复存取DDR的浪费。

1.3 为什么坚持纯Verilog、同时兼容两条工具链

能用HLS的地方我坚决不用,能用IP核的地方我尽量自己写。这不是“代码洁癖”,是这个场景的现实约束。第一,超低延迟目标要求每条关键路径的时序都可控,纯RTL综合出来的逻辑,工程师能精确到拍数;HLS生成的逻辑有时候一个循环展开方向的差异,流水延迟就差出几十拍。第二,项目交付时经常面临“Xilinx的板子做量产、紫光同创的板子做国产化备选”的情况,纯Verilog的可移植性远好于Vivado HLS工程或者深度绑定Vitis的流程。紫光同创的PDS工具链对高版本Vivado/HLS生态支持有限,但对标准Verilog RTL是完全兼容的。

当然,完全不碰厂商IP也不现实。时钟、BRAM、DSP这些底层资源,两家的原语和IP配置不一样。我的做法是在RTL里加一层薄薄的平台适配层(shim),顶层逻辑只调用自己定义的接口,不直接例化厂商原语,实际用Xilinx还是紫光同创,只改这一层适配文件。这套结构之后在第四章展开讲,它是跨平台部署的核心。

2. 核心设计:脉动阵列的数据流与关键参数

2.1 PE单元与权重静止数据流

先看最小的计算单元。脉动阵列里的每个PE,核心是一个带权重寄存器的乘累加器。我用的数据流是权重静止(Weight Stationary):权重在计算开始前从外部逐列写入PE内部的寄存器,计算过程中权重不动,激活值沿着水平方向一个接一个流进PE阵列,部分和在垂直方向向下累加。等一行数据流完,阵列底部输出的就是完整的输出特征图结果。

module pe #(parameter W = 8, parameter ACCW = 32) ( input wire clk, input wire rst_n, input wire signed [W-1:0] act_in, // 来自左方PE的激活值 input wire signed [W-1:0] weight, // 权重加载端口 input wire signed [ACCW-1:0] psum_in, // 来自上方PE的部分和 input wire w_en, // 权重装载使能 output reg signed [W-1:0] act_out, // 向右方PE传递激活值 output reg signed [ACCW-1:0] psum_out // 向下方PE传递部分和 ); reg signed [W-1:0] w_reg; wire signed [ACCW-1:0] prod = $signed(act_in) * $signed(w_reg); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin act_out <= 'd0; psum_out <= 'd0; end else begin act_out <= act_in; if (w_en) w_reg <= weight; psum_out <= psum_in + prod; // 关键路径:加法链 end end endmodule

这段代码看起来简单,实际上藏着整个设计最关键的时序问题:psum_out <= psum_in + prod是一条沿着阵列垂直方向逐级向下传递的加法链。阵列规模小时无所谓,一旦做到16x16甚至32x32,这条链上的组合逻辑延迟会直接决定系统能不能跑到目标频率。我的做法是中间插入流水寄存器,每级PE输出的部分和额外打一拍,代价是流水延迟增加几拍,换来的却是频率可以稳定跑上200MHz甚至更高。对延迟敏感的场景,多这几拍完全可接受。

2.2 卷积到脉动阵列的映射

理论上的脉动阵列做的是矩阵乘法,但CNN卷积是三维运算,不能直接往里灌。最正统的映射思路是先做im2col,把输入特征图展开成矩阵,但im2col需要额外开辟buffer,非常费BRAM。我采用的是一种针对3x3卷积核做了裁剪的方案:行缓冲(line buffer)缓存输入图像的连续三行,按3x3滑动窗口取出当位置上的9个像素,再把这9个值按固定顺序送入PE阵列的对应权重列。

映射关系是这样的。PE阵列按“输出通道”分组,每个输出通道的3x3x输入通道个权重占用一条PE列组。以第一层卷积为例,输入是灰度图单通道,3x3卷积核只有9个权重,那一列9个PE就能装下整个kernel。计算时特征图的行数据从左向右流过这9个PE,每个PE负责kernel的一个位置,部分和在垂直方向累加,底部输出结果。多输出通道时,阵列宽度方向扩展,每增加一个输出通道就增加一列PE组。

这种映射方式不是最高效的通用矩阵乘法方案,但它省掉了im2col的地址生成和buffer管理,数据流非常干净,硬件开销小,非常适合车牌识别这种小网络、定长输入的场景。卷积层的padding、stride处理也都在行缓冲控制器里解决,不额外占用计算单元。

2.3 位宽、量化与饱和策略

车牌识别对精度要求没到自动驾驶那种苛刻程度,int8量化完全够用。输入图像本身是8bit灰度数据,卷积权重我直接用8bit有符号数,累加器做到32bit防止中间溢出。真正需要注意的不是位宽,而是每层输出怎么截断。CNN的激活值范围逐层差异很大,第一层卷积的输出范围可能是[-200, 300],到了后面几层可能就变成[-2000, 5000]了。如果两层之间都用同一个固定scale去截断,精度损失会迅速累积。

我的做法是逐层量化。离线阶段用一批车牌图像跑一遍浮点模型,统计每一层输出的动态范围,把每层的scale因子算好,固化成一个查找表。FPGA侧在每个卷积层输出后,根据当前层的scale做一次移位加饱和。scale尽量取2的幂,这样乘法就变成了移位操作,省掉的DSP资源可以留给更大的PE阵列。实践经验是:相比统一scale,逐层scale能稳定提升车牌字符识别的准确率大约1到2个百分点,代价只是每层多一个移位器,非常划算。

2.4 端到端流水线与延迟估算

整个加速器不是一次计算完整个网络,而是按层复用一个16x16的脉动阵列。调度器是一个有限状态机,按网络的拓扑顺序逐层触发,每层开始前先通过权重加载端口把当前层的卷积核写入PE寄存器,然后打开输入数据流水。

算一笔账。我采用的识别网络输入是32x96的车牌灰度图,结构是三层卷积加一层全连接:conv1(3x3x1x8)、池化、conv2(3x3x8x16)、池化、conv3(3x3x16x32)、全连接输出7个字符位各34类的分数。整个网络的乘累加总量大约2000万次,16x16阵列共256个MAC单元,理论上只要大约8万周期。跑200MHz时纯计算时间约0.4毫秒,加上层间切换、权重装载、行缓冲排空这些开销,整个识别前向过程可以控制在1毫秒以内。检测环节用Sobel边缘加形态学投影处理,和卷积部分是并行流水的,因此我实测整链路延迟稳定在3毫秒左右,完全达到“超低延迟”的立项目标。

3. 车牌检测与识别的工程实现

3.1 图像预处理链路

预处理模块直接挂在传感器输出后端,采用AXI-Stream流式结构。首先做RGB转灰度,用加权平均公式,三个系数固定成8bit,避免使用浮点。然后是ROI区域裁剪和缩放,这一步很关键,因为车牌在画面中的位置和大小随安装环境变化,前端设备通常会把检测区域限定在一个预先标定的框内,只对这个区域做后续处理,能省掉大量无效计算。

灰度转换和ROI裁剪都是逐像素流式操作,不需要缓存整帧图像。缩放模块稍微麻烦一点,我用了最朴素的最近邻,虽然画质一般,但胜在实现简单、时序稳定,且实际测试中车牌字符这种高对比度内容对最近邻缩放并不敏感。预处理输出直接接到脉动阵列的行缓冲入口,中间没有DDR参与,数据延迟只有几十个时钟周期,这是整个系统低延迟的第一个来源。

3.2 检测与识别网络的硬件调度

检测部分我没有上大目标检测网络。车牌场景有非常强的先验:车牌色彩信息明确、边缘梯度方向集中、字符排列规则。所以检测链路用Sobel算子算梯度幅值,再做水平投影和垂直投影,结合连通域分析定位车牌区域。这套传统CV流程在FPGA上实现量小、延迟低,实测在各种光照条件下对标准车牌的召回率足够稳定。真正用上脉动阵列的重头戏是识别部分:定位到车牌区域后,从原图中裁出字符区域图片,缩放归一化后送入轻量CNN,由16x16脉动阵列完成全部卷积计算。

硬件调度器用了一个主状态机加若干计数器实现。每个卷积层运行前,状态机先进入LOAD阶段,此时PE阵列的权重使能信号逐列拉高,将当前层的权重从BRAM写入PE寄存器。写入完成后进入RUN阶段,行缓冲开始向阵列灌数据,同时用计数器跟踪当前输出的行号和列号。最后一层计算完成后,状态机进入DRAIN阶段,等待流水线内的最后几个部分和从阵列底部排空。这个“LOAD-RUN-DRAIN”三段式结构写起来不复杂,但调试时对时序的要求很高,第五章我会专门讲这里踩过的坑。

3.3 识别输出与后处理

识别网络的最后一层是类似全连接的结构,输出7个字符位各自在34个字符类别上的分数向量。硬件后处理模块对这7个分数向量做argmax,得到每个字符位的类别编号和置信度,加上一个车牌整体的置信度阈值判断,最后打包成自定义的UART帧协议发送出去。整个过程没有一个CPU参与,纯RTL完成。

这里有个值得说的小细节:最后一层的7x34分数矩阵,我本来想直接做成一个二维寄存器阵列,但综合后发现这部分的FF和查找表消耗比想象中大。后来改成复用脉动阵列本身来做矩阵乘,把权重预先放到PE里,输入分数向量从侧边流入,结果从底部出来,阵列利用率几乎满载。这样既省了额外硬件,也让整个设计对“脉动阵列”这个核心的依赖更彻底。对于想让加速器跑多种模型的朋友,这种复用一个阵列把卷积和全连接都算完的思路,是降低资源占用非常有效的做法。

4. Xilinx与紫光同创双平台部署实测

4.1 两套工具链的基本差异

Xilinx侧用的是Vivado,从综合、实现到时序分析一体化,IP核生态成熟,Clocking Wizard、Block Memory Generator、AXI-Stream接口这些都很好用。紫光同创侧用的是官方PDS工具,界面和操作逻辑跟Vivado有相似之处,但IP配置方式、约束文件格式、部分原语名称都不一样。如果一个人只熟悉Vivado,第一次接触PDS,最直观的感受可能是“怎么什么都得手动配”以及“综合报告和布局布线的信息密度差一截”。

建筑材料本身也有差异。Xilinx 7系列的BRAM是36Kb块,可拆成两个18Kb使用;紫光同创的BRAM块更接近18Kb粒度,配置时要注意容量换算。DSP单元方面,Xilinx有带预加器的DSP48E1,紫光同创的DSP单元也支持乘加操作,但流水级数和端口时序不完全一样。这些差异在RTL中直接例化IP时会产生大量不一致,因此接口隔离不是“可选项”,而是双平台部署的前提。

4.2 可移植RTL的编写规范

跨平台设计不是把代码拷过去就能跑通的,需要从一开始就控制RTL的写法。我的工程目录里,顶层是sys_top.v,里面只做模块例化和数据流连接,不直接例化任何厂商原语。所有厂商相关的逻辑都收在platform_xilinx.vplatform_unisoc.v两个文件中,对外暴露完全一致的接口,比如clk_gen生成时钟、rst_sync做异步复位同步释放、ram_wrapper封装BRAM读写延迟、dsp_wrapper封装乘加单元。顶层通过一个宏定义选择例化哪个适配文件,其余逻辑代码完全共用。

module platform_top #(parameter CLK_FREQ = 200_000_000) ( input wire ext_clk, input wire ext_rst_n, output wire sys_clk, output wire sys_rst_n, ... ); `ifdef FPGA_XILINX platform_xilinx u_platform (...); `elsif FPGA_UNISOC platform_unisoc u_platform (...); `else $error("No platform selected"); `endif endmodule

这里要特别提醒:BRAM读延迟是跨平台最容易出问题的地方。Xilinx的BRAM输出可以选择加寄存器,读延迟可配置为1拍或2拍;紫光同创的BRAM IP配置界面类似,但默认延迟和Vivado不完全一致。如果RTL假定BRAM读延迟固定为某种拍数,换平台后所有数据都会错位。我的做法是把BRAM封装成固定2拍读延迟的接口,平台差异在wrapper内部打拍补齐,上层逻辑只面对一个统一接口。

4.3 资源与功耗实测对比

我分别在Xilinx Artix-7系列和紫光同创Logos系列上做了实测。两个平台都跑200MHz,整板功耗包括DDR和接口部分大约在1.5瓦左右,纯FPGA核心逻辑功耗不到1瓦。这个功耗水平在嵌入式前端设备里基本不需要主动散热,比大多数嵌入式GPU方案低了不只一个量级。

资源类型Xilinx Artix-7紫光同创 Logos说明
LUT约18k约21kLUT结构差异导致统计口径不同
FF约16k约17k基本持平
BRAM32块(18Kb粒度)28块(18Kb粒度)BRAM块容量换算后略有区别
DSP单元64个64个脉动阵列的乘累加全部映射到DSP
频率200MHz稳定200MHz稳定均留有5%以上的裕量
核心功耗约0.8W约0.9W实测值

两边的结果对比下来,同一份RTL在资源消耗和行为表现上基本一致。唯一需要留意的是紫光同创PDS的时序报告口径与Vivado有细微差异,WNS数值不能直接对比绝对值,建议以“能否满足你设定的目标时钟约束”为准,而不是纠结两个工具的report数字哪个更好看。

4.4 时序收敛和物理约束

脉动阵列的频率瓶颈基本就在PE阵列内部。乘法器输出到加法树再到累加器这条路径,是每一个微架构工程师都绕不开的关键路径。我的优化思路是三级处理:第一,乘累加放进DSP单元内部完成,DSP本身有内建的流水寄存器,乘法后直接累加基本不额外消耗LUT;第二,部分和在PE间传递时插入流水寄存器,让每条加法链都断成小段;第三,对阵列做物理区域约束,在Vivado里用Pblock把16x16个PE限定在同一片SLR区域内,避免布线绕远。紫光同创PDS里同样支持布局约束,只是命令和脚本形式不同,思路一样。

还有一个小经验:行缓冲控制器里的计数器逻辑,虽然不复杂,但综合后经常成为隐藏的时序瓶颈,因为很多计数器都是几十bit宽,而且使能信号要跨多个时钟周期。我习惯把计数器拆成高位和低位两个部分,低位使能频繁、位宽小,高位通过低位的进位触发,这样能明显改善建立时间裕量。

5. 踩坑记录与排查方法

5.1 权重装载时序:第一帧全零的坑

第一次上板测试,识别结果永远是“无车牌”,排查了很久,最后抓内部信号发现卷积输出的特征图全是0。问题出在权重装载信号和数据到达信号没有对齐:LOAD阶段还没有结束,RUN阶段的数据就已经开始流入阵列,PE寄存器里还是全0,算出来的结果自然是0。

解决办法是严格按LOAD-RUN-DRAIN三段状态机执行。LOAD阶段用计数器清零并开始装载权重,装载完最后一个权重后,额外等两个周期,确保所有PE的权重寄存器稳定写入,再拉高数据有效信号进入RUN阶段。每个时钟周期都去检查状态机状态和计数器的边界条件,尤其是最后一个PE的权重写入完成标志。这类问题在仿真里不一定能发现,因为仿真的激励信号往往是理想对齐的,而板级现场真正的数据流动会告诉你什么叫“差一拍都不行”。

5.2 部分和溢出的逐层截断

另一个精度相关的问题是累加器溢出。32bit累加器本身不容易溢出,但每层卷积结束之后,输出值必须缩放到下一层期望的8bit输入范围。如果直接丢低8位或者用固定的右移位数,中间几层卷积的激活值动态范围一变,精度立刻跳水。

后来我改成逐层饱和截断。每层卷积的输出先乘以当前层的scale因子(用移位实现),然后做饱和到8bit有符号范围。scale因子的确定必须用真实车牌样本统计,不能想当然。纯白车牌字符和深色车牌的激活值分布差异很大,用单一测试图去定scale,很容易在另一些光照条件下掉点。建议至少取几百张不同光照、不同倾斜角的车牌图像做统计,让动态范围的覆盖更稳。

5.3 跨平台结果不一致的两个原因

最常被怀疑但实际最该排查的,是BRAM读延迟差异。在Xilinx上跑正常的数据,移植到紫光同创后,特征图边缘偶尔出现错位的竖线。基本可以断定是BRAM读数据和消费数据的时序错拍。我统一把BRAM封装成固定延迟接口后,这个问题彻底消失。

另外一个隐蔽的坑是DSP单元流水级数不同。同一个乘法加累加操作,在Xilinx的DSP48E1里可能是2级流水,在紫光同创的DSP单元里可能是1级。如果RTL对DSP流水的延迟做了硬编码假设,换平台后关键路径和数据对齐都会出问题。所以我平时写代码就要求自己把DSP相关逻辑统一封装在dsp_wrapper里,延迟差异在wrapper内部补齐,上层逻辑永远只看到固定延迟的接口。

5.4 资源不足时的降档方案

工程后期经常收到需求:“能不能把阵列缩小一点,给其他功能腾资源?”脉动阵列的面积和PE数量基本成正比,所以我把PE阵列的规模做成了参数化配置。顶层通过parameter定义列数和行数,综合时只需改参数就能生成8x8、16x16等不同规格。实测8x8阵列在200MHz下,识别网络的纯计算时间大概是1.6毫秒,整链路延迟大约5毫秒,对于很多道闸场景依然够用,但资源和功耗几乎省了一半。

如果资源还是不够,还有第二个方案:把两个输出通道合并推理。原本阵列一列对应一个输出通道,改成两列共享一组权重,虽然会让部分和的累加逻辑复杂一点,但能进一步减少PE数量。代价是控制逻辑更繁琐、时序收敛需要多花时间。我个人的建议是,不要为了省资源把控制逻辑改得太复杂,优先考虑直接缩小阵列规模,把复杂度留给数据流,而不是留给状态机。

在调试这个系统的过程中,我最大的体会是:脉动阵列的RTL写起来并不难,难的是对数据流的精确掌控。每一个valid信号的拉高和拉低、每一拍数据在阵列里的位置,都必须像流水线里的机械臂一样精确咬合。我用随机生成的车牌图片做了上万次对比测试,仿真输出和板级输出逐像素完全一致,这种确定性是CPU和GPU方案永远给不了你的安心感。同样的加速核,换一个输入网络,再花一两天调调调度器,也能跑人脸检测或者工业缺陷识别。但不管怎么扩展,核心始终是那句话:先把数据流和时序搞清楚,FPGA才真正快得起来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询