资讯动态

FPGA脉动阵列实现超低延迟车牌识别加速器

发布时间:2026/9/8 7:13:47 来源:尧图企业网站定制
1. 项目概述与系统架构拆解这两年一直在折腾 FPGA 图像处理边边角角的工程做过不少但真正让我觉得“有点意思”的是这个用纯 Verilog 写的脉动卷积阵列加速器。它跑的不是板卡自带的软核、也不是 ARM 端 Linux 里的推理框架而是从权重加载、卷积滑窗、特征图搬运到车牌字符识别全部在 RTL 电路里完成。目标只有一个超低延迟。部署平台同时覆盖 Xilinx 和紫光同创两条线前者用 Vivado后者用 PDS代码层面共用一套 RTL 源文件只在时钟约束和 IP 例化部分做了平台隔离。先说清楚这个项目到底做了什么。输入是一路 YUV422 格式的车牌图像分辨率 1280x720经过行缓冲、灰度转换、卷积阵列、激活函数、池化、特征分类这几个阶段最后输出车牌号码文本。整条流水线里帧率不是瓶颈真正追求的是单帧端到端延迟——从像素进入 FPGA 到最后输出识别结果按帧推进而不是按块缓存的模式来算大概在 2 到 3 毫秒左右。如果用传统的 CPU 方案哪怕用工业相机加高性能工控机也很难做到这种确定性极低的延迟更不可能把延迟抖动控制在微秒级。适合参考这篇内容的人应该是有一定 Verilog 基础、想上手 CNN 加速器但不知道从哪里切入的 FPGA 工程师或者正在做车牌识别、边缘计算、工业视觉检测相关项目的同学。我默认你已经会写简单的状态机、会看时序报告但不需要你有多深的神经网络理论功底。脉动阵列的数学原理我会在后面的内容里用“传送带”的类比讲清楚。架构上我把它拆成五个子模块图像输入与格式转换模块、行缓存与滑窗生成模块、脉动卷积阵列、后处理与识别模块、控制与权重加载模块。这五个模块的协调关系是这样的图像数据按行输入行缓存模块负责把串行像素流变成 3x3 的卷积窗口窗口数据送入脉动阵列做乘累加结果经过 ReLU 和最大池化后进入分类器分类器也是一层小规模的全连接运算最后通过查表输出字符。整条流水线没有设计复用重算的逻辑每个时钟周期都有像素在流动这点非常关键。你会发现很多网上教程里的卷积加速器其实只是“调用乘法器循环计算”真正到了图像流上性能根本拉不上去。脉动阵列的优势在于数据复用——每个权重可以在多个像素上重复使用而且不需要反复从寄存器堆里搬运。2. 脉动卷积阵列设计思路与实现细节2.1 脉动阵列为什么会快用传送带类比理解数据流先别急着看代码。脉动阵列Systolic Array这个名字听起来很唬人但本质就是一条像素数据“传送带”。想象一下流水线车间里面每个工人只负责做一个动作零件放在传送带上自动流到下一个工人手里工人不需要自己走过去拿零件也不需要停下来等。这就是脉动阵列的核心数据在一个方向上流动权重在另一个方向上流动每个处理单元PE只做一次乘加操作然后立刻把结果传给下一个 PE。我的卷积阵列规模为 3x3 的 16 个 PE对应 3x3 卷积核的 9 个权重加 7 个辅助计算单元。为什么选 3x3 而不是 5x5 或 7x7第一个原因是车牌字符识别的特征本来就是边缘和笔画为主3x3 卷积核足够提取第二个原因是 FPGA 的 DSP 资源有限3x3 阵列只需要 9 个乘法器如果扩展到 7x7资源消耗会增长到 49 个乘法器在中小型 FPGA 上就很吃力了。第三个原因也是工程上最直接的原因——3x3 是最常用的卷积核尺寸代码覆盖率和可复用性最高。想换核尺寸时只需要修改参数宏和例化个数即可。2.2 PE 内部结构与乘累加单元每一个 PE 内部做的事情非常简单取一个输入像素和一个权重做乘法再加上之前累加的结果然后输出。这对应到 Verilog 代码里就是一句always (posedge clk) begin if (rst) acc 0; else if (en) acc acc in_data * weight; end注意这里的陷阱。脉动阵列里的乘累加和普通的多周期累加不一样由于数据是流水线式流动的PE 的输入数据在时刻 A 进入结果却在时刻 AN 才有效所以必须精确控制使能信号和时间对齐。我在调试时踩过一个大坑权重切换和像素到达的时钟周期没有对齐导致第一行卷积结果全部错误表现为板卡输出的图像出现整行偏移。后来用了一个简单的计数器来对齐reg [3:0] shift_cnt; always (posedge clk) begin if (load_en) shift_cnt 0; else if (shift_cnt 4d15) shift_cnt shift_cnt 1b1; end // 只有在 shift_cnt 4d15 时结果才有效这种对齐思想在 CNN 加速器里无处不在。脉动阵列并不是“越复杂越快”反而是“越规则越容易高速”。规则的数据流意味着你可以放心地把时序预算投入到关键路径的优化上而不是浪费在乱七八糟的握手信号上。2.3 数据复用与带宽优化设计脉动阵列之前先算一笔带宽账。1280x720 的图像如果每个像素在 YUV422 格式下是 2 字节那么一整帧数据就是约 1.8MB。假设我们的加速器工作在 150MHz一帧 1280x720 需要约 921600 个时钟周期来输入大约6.1毫秒。这个时间其实不算快因为如果按像素处理每个时钟只能进一个像素。但是卷积神经网络有一个天然优势——数据复用。3x3 卷积核滑窗时相邻窗口之间共享 2 列像素。也就是说每穿过一个窗口只有 1/3 的数据是新进入的。脉动阵列抓住这一点让权重驻留在 PE 中像素流经各个 PE 时自动完成乘算根本不需要为每一个卷积窗口单独去取一遍数据。这块的带宽节省是非常可观的实测下来传统移位寄存器方案需要约 3 倍的像素读取量而脉动阵列只需要 1 倍。权重加载方面也做了优化。权重并不是每个周期都在变化而是完成一整张特征图之后才更新一次。所以我把权重存储设计成了双端口 RAM端口 A 用于运行时的权重读取端口 B 用于下一层卷积的权重预加载。这样在上一层运算还没结束时下一层的权重就已经准备好了层间的切换延迟被压缩到几个时钟周期在实际工程中非常实用。2.4 脉动阵列的 Verilog 实现框架一个简洁的脉动阵列顶层框架如下module systolic_array #( parameter DATA_WIDTH 16, parameter KERNEL_SIZE 3 )( input clk, input rst_n, input en, input signed [DATA_WIDTH-1:0] in_data, input signed [DATA_WIDTH-1:0] weight_00, input signed [DATA_WIDTH-1:0] weight_01, input signed [DATA_WIDTH-1:0] weight_02, // ... 其余权重端口 output signed [DATA_WIDTH*2-1:0] out_data ); wire signed [DATA_WIDTH-1:0] pe_chain [0:KERNEL_SIZE*KERNEL_SIZE-1]; wire signed [DATA_WIDTH*2-1:0] acc_chain [0:KERNEL_SIZE*KERNEL_SIZE-1]; genvar i; generate for (i 0; i KERNEL_SIZE*KERNEL_SIZE; i i 1) begin : PE_LOOP pe_unit #( .DATA_WIDTH(DATA_WIDTH) ) u_pe ( .clk(clk), .rst_n(rst_n), .en(en), .in_data(in_data), .weight(weight_array[i]), .acc_out(acc_chain[i]) ); end endgenerate endmodule因为用了 generate 循环换卷积核尺寸非常方便只是把参数改一改PE 实例数量就会自动扩展。这也是我从 Chisel 和 HLS 的生成式思路里借鉴过来的但最后还是回到了原生 Verilog——原因很简单可读性和调试便捷性。原生 Verilog 配合仿真工具信号一眼就能看全而 HLS 生成出来的 RTL 往往在时序收敛时让你无从下手。3. 车牌检测与识别卷积之外的关键模块脉动阵列只解决了卷积计算的问题距离“识别车牌”还差着十万八千里。实际部署时我把识别拆成三个环节车牌区域定位、字符分割、字符识别。其中字符识别用了一层精简的 CNN 网络而车牌区域定位和字符分割则用了更“FPGA 友好”的传统图像处理方法。3.1 车牌定位梯度统计与边缘响应车牌定位这一步没有用目标检测网络因为板上资源有限。常用的思路是利用车牌的矩形边框和字符高对比度特征做边缘检测。这里我用的是 Sobel 算子但它不是独立模块而是卷进脉动阵列里一起算的。实现方式是把 Sobel 的两个 3x3 卷积核水平方向和垂直方向分别预加载进阵列然后对灰度图像做一次卷积得到梯度幅值图。梯队幅值图出来后用一个行投影计数器统计每行中梯度大于阈值的像素数量。车牌区域内字符和边框会造成密集的高梯度点所以行投影会形成一个很明显的波峰。把波峰对应的行范围切出来再在列方向做一次同样的投影就得到一个粗粒度的车牌候选矩形。这个算法最关键的就是阈值选得好不好我调了大约一百多帧测试图像最终把阈值定在 180像素值范围 0-255在夜间和逆光场景下都有不错的表现。3.2 字符分割与归一化拿到车牌矩形之后先做二值化。我在这个项目里用的是一种自适应局部阈值法因为固定的全局阈值在车速快、亮度变化剧烈的户外场景下完全不够用。局部窗口大小选的是 15x15每个像素的阈值是窗口内像素均值减去一个常数 CC 取 8。这个常数不能太大也不能太小太大会把字符笔画打断太小会把背景噪声保留下来。二值化之后字符分割采用连通域标记法。这个方法在软件里很常见但在 FPGA 里跑需要在行扫描时维护一个实时的标签等价表稍微麻烦一点。为了省事我简化了过程只做垂直投影分割把每一列的黑色像素数量统计出来连续的“有内容”列段就是一个字符候选。这样做对标准车牌很有效因为字符间距比较均匀但遇到字符粘连或者铆钉干扰时会出错。如果后续想做得更健壮可以再接一个宽度判断来剔除误分割。分割出来的字符尺寸不定所以要做一次缩放归一化统一变成 24x48。这个缩放不是简单的最近邻插值因为最近邻在缩放倍数较大会产生马赛克效应影响识别率。我采用的是双线性插值用一组定点小数来表示源坐标。Verilog 实现的关键是把浮点除法转成移位和乘法比如缩放倍数 1.7 就近似表示成 $1.7 \approx \frac{87}{51}$然后改成乘法和移位操作避免在 RTL 里引入除法器。3.3 识别网络与输出编码字符识别网络是一个两层的简化 CNN第一层用 4 个 3x3 卷积核生成 4 张特征图每张特征图经过 2x2 最大池化第二层是全连接层输出节点数等于字符类别数。车牌字符集合包含数字 0-9、大写字母 A-Z但排除容易混淆的字母如 O 和 I以及汉字省份简称总共约 50 类左右实际输出我用的是 One-Hot 编码直接通过比较电路选出最大值不需要排序网络。全连接层的计算量不算大因为输入特征图经过池化之后只有 12x24x4 1152 个数据点权重数大约 1152x50 57600 个。但即使在 150MHz 下遍历一遍全连接也要约 38 万个时钟周期约 2.5 毫秒这个时间我有点不满意。后来我做了裁剪只保留每张特征图响应最强的 1/4 区域参与全连接相当于注意力机制。裁剪后计算量减少到原来的四分之一左右识别率下降了不到 0.3%但延迟减少了 1.8 毫秒非常划算。4. Xilinx 与紫光同创双平台部署实录4.1 平台选型与资源对比项目前期主要是在 Xilinx 的 Artix-7 系列上验证型号是 XC7A75T。一方面我手上这块板卡资源比较充裕另一个原因是 Vivado 生态成熟调试起来方便。但实际量产考虑成本换成了紫光同创的 Logos-2 系列具体型号是 PGA2K50。两颗芯片做了一下资源对比见下表资源/平台Xilinx Artix-7 XC7A75T紫光同创 Logos-2 PGA2K50逻辑单元75K50KDSP Slice180160Block RAM4.8Mb4.5Mb最高时钟实测180MHz150MHz开发工具VivadoPDS这里要特别说一句FPGA 开发最怕换平台但换个角度也是好事。因为换了平台你会被逼着把所有依赖厂商 IP 的逻辑全部重写一遍反而提升了代码的可移植性。我第一版代码里用了 Xilinx 的 FIFO IP 和 BRAM IP迁移到紫光同创时发现虽然两家都支持 AXI4 接口但配置界面和原语名称完全不同。后来我花了一个晚上把所有通用 FIFO 改为自己写的基于 BRAM 的同步 FIFO只用厂商的 Simple Dual Port RAM 原语这样两边就都能跑了。4.2 时钟约束与跨时钟域处理双平台部署中最让人头疼的是时钟约束。紫光同创的 PDS 里创建时钟的命令和 Vivado 不太一样而且如果你在代码里写了alway (posedge clk)PDS 的时序分析工具会自动推断寄存器时钟但如果你用了 MMCM/PLL必须显式地创建 generated clock否则时序报告就是一份废纸。我最后的时钟方案是这样的外部输入 50MHz 晶振经过 PLL 倍频到 150MHz 作为主时钟用于整个脉动阵列、行缓存和卷积逻辑另有一个低速时钟域25MHz用于配置寄存器读写和权重加载通过异步 FIFO 和主时钟域做隔离。这是我在经历了“跨时钟域数据打拍不彻底导致偶发乱码”之后总结出的血泪经验——千万别贪图方便让权重直接跨时钟域读取数据撕裂几乎必然发生。PDS 里的约束写法可以参考这段create_clock -name sys_clk -period 20.0 [get_ports clk] create_generated_clock -name core_clk -source [get_pins pll/CLKOUT0] -divide_by 1 -multiply_by 3 [get_pins pll/CLKOUT0]Vivado 里的写法则简单很多一般直接让 PLL IP 自动生成约束。但有一个细节值得提醒综合后一定要去检查report_clock_interaction看看有没有不必要的 CDC 路径被工具串成异步路径有时候 FSM 里的异步复位释放也会被误报。4.3 资源优化技巧从 110% 到 78%第一版代码在紫光同创上综合时LUT 使用率直接爆到 110%根本布线完成不了。后来我做了三件事资源降到了 78%时钟也从 100MHz 提到 150MHz第一用 DSP 单元替代 LUT 乘法器。Verilog 里写a * b综合器默认可能会推断成 LUT 乘法特别浪费资源。我改成直接例化厂商的 DSP 原语比如紫光同创的MULT18X18Xilinx 的DSP48E1。虽然代码看起来“厂商绑定”了但我用一层generate if做条件编译两边各走各的原语通用性并没有损失。第二权重定点化。浮点运算是资源黑洞我所有权重都做了定点化处理统一使用 16-bit 定点数其中 1 bit 符号位、3 bit 整数位、12 bit 小数位。这个精度对车牌识别足够了实测识别率和浮点版本相比下降了不到 1%但资源使用率降低了差不多一半。第三共享行缓存。原本 RGB 三个通道各做一份行缓存后来发现车牌识别只需要灰度图就直接在输入端把 RGB 转成灰度一行像素只存一个灰度值。行缓存 BRAM 的占用从原来的三块变成一块省下来的 BRAM 都给了特征图缓存。资源配置前后对比如下资源项优化前优化后LUT32,000110%22,50078%FF18,00016,300DSP9656BRAM28 块19 块最高时钟100MHz150MHz5. 延迟优化全流程从设计到上板实测5.1 延迟链路的逐级拆解超低延迟是这个项目最大的卖点也是所有优化动作的最终目的。我把端到端延迟拆成四段像素输入1280x720 图像按 150MHz 输入每像素一周期共 921,600 周期约 6.14ms。行缓存与滑窗这个是非阻塞的滑窗生成的延迟只有 2 行高度约几微秒。脉动阵列卷积一帧图像经过 3x3 卷积每个像素都要和 9 个权重做乘累加。由于阵列是流水线化的理论卷积时间约等于图像输入时间加上流水线深度约 6.15ms。字符识别分类网络部分约 1ms 左右。看着总延迟要到 13ms 左右这个数字虽然相比软件方案已经很快了但还可以继续优化。优化思路是帧内流水不要等整帧图像输入完毕才开始卷积而是像素进入行缓存达到第 3 行时卷积就立即开始。这样卷积和输入是重叠进行的端到端延迟主要由输入时间 网络计算时间 尾部输出时间组成。实测下来从最后一个像素进入 FPGA 到识别结果显示大约是 2.7ms。5.2 实测数据与 SignalTap/ILA 抓波分析在 Xilinx 平台上我用 ILA 抓了 en 信号、卷积输出 out_valid 和识别结果有效标志。分析时发现卷积结果有 9 个周期的固定延迟这是 3x3 卷积核的脉动深度导致的正常现象。但后来发现在图像边界处会多出异常输出原因是行缓存没有做边界填充导致卷积窗口跨越图像边界时读取到未初始化的数据。解决的办法很简单在行缓存读取逻辑里加一个边界判断当窗口中心位于图像第一行或最后一行时对应输入补零。这个操作在硬件上就是几个比较器和多路选择器代价可以忽略但对于识别率的提升非常明显。上板实测数据表格如下测试平台端到端延迟识别率500张测试图平均功耗Xilinx Artix-72.7ms96.8%2.1W紫光同创 Logos-23.1ms96.5%1.7W两个平台延迟差距约 0.4ms主要原因是紫光同创的 PLL 最高只能跑到 150MHz而 Xilinx 平台可以跑到 180MHz单纯时钟频率差异导致的。识别率基本持平说明浮点转定点没有带来明显的精度损失。5.3 延迟抖动与确定性做工业项目的都知道平均值好看没用关键是“最大延迟”可控。软件方案容易受系统调度影响延迟抖动能达到几十毫秒而 FPGA 的流水线一旦填满每个像素的处理时间是严格固定的输出延迟只和当前帧的像素数量有关。我在车里实际部署测试时用示波器观察了 1000 帧的识别结果发布间隔抖动控制在 ±0.05ms 以内。这个特性对于车牌识别闸机、高速路收费口这种场景非常重要因为系统需要根据识别结果精确控制闸机抬杆时机。6. 常见问题与排查技巧实录6.1 时序收敛问题小心你的加法树脉动阵列里最容易被忽略的关键路径是累加器。acc acc in_data * weight这条语句如果数据位宽是 16 位乘法结果就是 32 位再加上寄存器本身的加法路径其实已经很长了。时序工具在 150MHz 下能收敛但到了 180MHz 就经常报负数余量。我实测后做了两处改动第一乘法结果先寄存一拍再做加法把“乘加”拆成“乘-寄存-加”第二加法器改成流水线加法把 32 位加法拆成高 16 位和低 16 位并行计算再合起来。代价是输出延迟增加 2 个周期但时序余量从负的 0.2ns 变成正的 0.35ns完全值了。6.2 上板调试时遇到的黑屏问题有一次代码下载到板子上采集到的图像全黑但仿真波形完全正常。排查了很久最后发现问题出在复位逻辑上外部按键复位太短PLL 还没锁定就被释放了导致整个模块在未稳定的时钟下跑了几个周期状态机直接跑飞。解决办法是检查 PLL 的 locked 信号只有 locked 为高才释放内部复位。reg [3:0] rst_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) rst_cnt 0; else if (pll_locked rst_cnt 4d15) rst_cnt rst_cnt 1b1; end assign rst_out (rst_cnt 4d15) ? 1b1 : 1b0;这个经验看似简单但在我参与过的多个项目里反复出现值得单独提一下。6.3 紫光同创 PDS 工具的使用差异紫光同创的 PDS 界面和 Vivado 有明显区别最直观的是它没有“运行综合后自动打开时序报告”的默认行为很多新手会错过时序告警。强烈建议每次综合后手动跑一下report_timing_summary然后重点看WNS(Worst Negative Slack) 和TNS。PDS 的 debug 工具叫 DSView类似 Vivado 的 ILA抓取信号需要先在代码里例化dbg_mark调试 IP逻辑分析仪核心会占用额外的 BRAM 资源。所以建议在调试版本里才加这些探针量产版本必须去掉。另外PDS 对 SystemVerilog 的支持没有 Vivado 成熟如果你从别处 Copy 了 SV 代码有可能会出现一些莫名其妙的语法错误。我后来统一改用 Verilog-2001 编写省去很多麻烦。如果你非要用always_comb这种语法记得检查编译选项里有没有开启对应标准。6.4 字符识别的脏数据问题识别率上不去的时候先别怀疑网络结构大概率是数据预处理出的问题。我遇到过一次字符分割正常但识别率突然大幅下降的情况后来发现是二值化阈值计算模块里的均值累加器溢出了。窗口 15x15 共 225 个像素每个像素 8 bit累加和最大为 225x255 57375需要 16 bit 的寄存器但我最开始只留了 12 bit数据溢出后阈值忽大忽小二值化结果一团糟。这个问题在仿真时没暴露因为仿真测试向量里没有这么极端的亮暗变化上板后马上就翻车。7. 心得与扩展思路项目做到现在这个程度我最大的体会是FPGA 做 AI 加速重点不在“网络有多深”而在“数据流有多顺”。脉动阵列之所以能成为经典架构就是因为它把数据的规律性和计算的并行性结合到了极致。你在网上下载那些别人写好的 CNN Verilog 代码如果看不懂数据流的时序关系基本很难改到自己能用而一旦自己动手写过一个脉动阵列再看其他并行加速架构一眼就能看出门道。有几个可以继续扩展的方向我简单说一下。第一如果目标场景是高速路上的车牌识别车流量大、车速快可以考虑加入多帧跟踪与卡尔曼滤波结合车牌位置预测来做识别辅助这个在 FPGA 上也不是特别难加卡尔曼滤波本质就是一些矩阵乘加复用脉动阵列完全可以搞定。第二如果想进一步提高识别率可以把网络层数加深但要注意 BRAM 消耗会急剧上涨。第三如果想做视频流的动态识别可以在输出端加一个去重逻辑同一车牌连续多帧识别结果一致时再上报避免重复触发。最后再分享一个小技巧脉动阵列的代码写完之后先用带随机权重的仿真向量做自检把第一层卷积的手算结果和 RTL 输出做对比。这一步能省下后面大量的调试时间比盲目上板试错高效得多。我甚至写了一个简单的 Python 脚本随机生成 CNN 权重并导出成 COE 文件直接灌进 FPGA 的 ROM 里方便快速验证。这个脚本没什么技术含量但能让你绕开“权重格式不对”这类低级问题专心搞架构优化。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价