
1. 为什么是脉动阵列先聊聊车牌识别场景到底卡在哪在开始聊脉动阵列的Verilog实现之前我得先说清楚一个很多人容易忽略的问题车牌识别这个任务在FPGA上做真正吃性能的环节根本不是“识别”本身而是“检测”。我接触过不少做车载电子的朋友一上来就想着用YOLO系列动辄几百兆的参数在FPGA上折腾半天延迟和数据吞吐勉强达标但资源占用率直接爆炸DSP和BRAM基本全满后续想加功能都没法加。车牌检测的场景和通用目标检测有个本质区别它几乎是一个固定场景的检测任务。相机装在固定的位置车牌在画面里的尺度范围、比例、颜色分布都相对稳定。这意味着我们不需要用通用的目标检测网络去硬刚完全可以用轻量化的方案把检测环节做掉然后把资源集中在车牌字符识别这个真正需要泛化能力的地方上。我这套方案的完整链路是这样设计的输入1080P30的灰度或彩色图像通过MIPI或GigE接口进入FPGA第一步图像预处理包括灰度化、中值滤波、边缘提取、二值化这些全部用流水线方式在FPGA里完成零帧缓冲第二步车牌定位基于边缘密度和颜色特征的候选区域筛选本质是个滑动窗口扫描阈值判断的过程第三步候选区域裁剪和归一化把定位到的车牌区域缩放到固定尺寸比如96x32第四步字符识别用一个小型卷积网络输入就是归一化后的车牌图像输出直接是省份缩写字母数字的分类结果第五步后处理去掉置信度低的识别结果输出最终的车牌字符串核心的卷积运算全部打包给脉动卷积阵列Systolic Array。这个阵列是纯Verilog写的不依赖任何HLS生成的IP所以迁到任意FPGA平台都行。Xilinx平台我用Artix-7和Kintex-7测试过国产平台我用紫光同创的PGL22G和PGT180H测试过都能正常综合和跑通时序效果差异主要来自芯片本身的DSP数量和BRAM容量。为什么非要用脉动阵列而不是直接在FPGA里搭一堆并行乘法器这里有个关键的设计思路差异。脉动阵列的核心思想是让数据像波浪一样在计算单元PE之间流动每个PE只和相邻的PE通信。这样的话一组数据进来之后波形会在阵列里传播每个PE在不断接收新数据的同时输出部分和数据的“复用率”非常高。对于卷积运算这种权重复用、输入数据也复用的场景脉动阵列能把到片外存储器的访问量压到最低功耗和带宽的压力都小很多。举个粗浅的类比普通并行乘法器阵列像是每个工位都要去仓库取原料然后加工仓库门口能排起长队脉动阵列像是流水线同一个原料在一条传送带上流转每个工位顺手取用不需要回头去仓库。数据搬运少了延迟自然就低了。车牌识别这种低延迟场景特别吃这一套。1080P的一帧图像大概有200万个像素卷积层的特征图虽然逐层减小但中间层的计算量一点不小。如果数据总是往返于DDR和计算单元之间延迟根本压不下来。脉动阵列把整个计算链路的中间结果都尽量留在片上能算完的就算完最后只把最终结果写回这是延迟指标能打到个位数毫秒的关键。当然脉动阵列的代价是控制逻辑更复杂。数据怎么排布进阵列、中间层的特征图怎么重排、权重怎么预加载到PE里这些都得自己用状态机和地址发生器去控制。但是这些控制逻辑一旦写好了基本就是一套可以复用的模板换个网络结构或者换一批权重只需要改参数配置就行。2. 硬件架构选型从“为什么不是HLS”到“纯Verilog的好处”先说一个我很想吐槽的点。现在很多做FPGA加速的人一上来就上HLSVivado HLS、Vitis HLS、HLS4ML什么的图的是用C/C写算法逻辑开发速度快但代价是生成的RTL代码很难针对具体的硬件资源做极致的优化。而且HLS生成的IP在某些场景下会有额外的控制逻辑开销资源利用率不如手写Verilog。我这套方案坚持纯Verilog原因有三个方面2.1 可移植性决定了部署成本车牌识别这个产品客户不一定固定用Xilinx或Altera。我在实际项目里遇到过要求用国产FPGA替换的如果代码是用HLS工具链开发的迁到国产平台基本等于重写一遍因为HLS工具链和生成的RTL结构绑定了特定的厂商原语和IP。纯Verilog就不存在这个问题只要综合工具支持标准Verilog-2001代码拿过去改一下引脚约束和时钟资源就能部署。我在紫光同创上部署时唯一需要改的主要是PLL和BRAM/URAM的例化方式。Xilinx用原语例化紫光同创有自己的PLL IP核接口但逻辑部分一行的不用改。2.2 延迟指标的极致控制脉动阵列的时序控制要求每个PE在同一个时钟节拍里同步动作数据输入的节奏、权重加载的节奏、输出累积的节奏都要精确对齐。用HLS生成的RTL控制逻辑是工具自动推断的很多时候会引入额外的乒乓缓冲和流水线握手信号这都会增加端到端的延迟。手写Verilog可以把整条流水线的级数压到理论最小时钟周期卡的死死的。我在车牌识别这条链路上做过统计从图像帧开始进入FPGA到输出识别结果纯Verilog方案的总延迟大约在2.8ms左右其中卷积加速器实际占用的时间只有不到1ms剩下的是图像预处理和字符检测的前期步骤。2.3 资源利用率的差异这条可能有点争议但至少在我的经验里手写Verilog做脉动阵列资源利用率能比HLS生成的方案高15%~20%。原因是手写代码可以精确控制每个乘法器和加法器的实例化数量HLS有时候会生成一些冗余的旁路逻辑来保证行为正确性。举个具体的例子我的脉动阵列是16x16的PE阵列共256个PE每个PE内含一个乘法器和一个加法器。在Xilinx Artix-7 200T上DSP48E1的资源占用是256个但HLS在综合一个类似规模的卷积层时经常会出现DSP数量的翻倍或浮动因为工具要处理不同的数据路径位宽和时序约束有些优化策略是以DSP堆叠为代价的。纯Verilog还有一个隐性的好处是综合时间短。HLS的C综合加上C/RTL协同仿真一轮迭代可能要半小时起步纯Verilog直接跑逻辑综合一轮只要几分钟。对于做产品的人来说这种迭代速度意味着调试效率的指数级提升。3. 16x16脉动卷积阵列的RTL实现从PE到状态机的完整拆解这一部分应该是很多人最关心的。我不打算贴全部代码因为几百行的RTL贴出来也看不完我就把顶层的架构、核心的状态机逻辑、数据路径的设计思路说清楚然后贴几个关键模块的代码片段大家拿去稍作修改就能用。3.1 PEProcessing Element内部结构每个PE做的事情非常单一接收一个输入数据、一个权重、一个来自上一个PE的部分和做乘累加然后把结果传给下一个PE。虽然逻辑简单但这个简单的模块是整个脉动阵列能被反复复用、规模随意扩展的基础。PE的RTL可以写成这样module systolic_pe #( parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] data_in, // 输入数据(来自左侧PE或顶层输入) input wire [DATA_WIDTH-1:0] weight_in, // 权重(来自上方PE或权重预加载) input wire [DATA_WIDTH*2-1:0] partial_in, // 部分和(来自上方PE) output reg [DATA_WIDTH-1:0] data_out, // 数据输出到右侧PE output reg [DATA_WIDTH-1:0] weight_out,// 权重输出到下方PE output reg [DATA_WIDTH*2-1:0] partial_out // 部分和输出到下方PE ); reg [DATA_WIDTH*2-1:0] mac_result; always (posedge clk or negedge rst_n) begin if (!rst_n) begin data_out {DATA_WIDTH{1b0}}; weight_out {DATA_WIDTH{1b0}}; mac_result {(DATA_WIDTH*2){1b0}}; end else begin data_out data_in; weight_out weight_in; mac_result partial_in data_in * weight_in; end end assign partial_out mac_result; endmodule这段代码的核心是三个时钟周期内的数据流动data_in从左边进来一拍之后往右边送同时被当前PE的乘法器采样weight_in从上方进来一拍之后往下方送同时被当前PE的乘法器采样partial_in是前一个PE的部分和加上本次的乘积成为新的部分和往下送这样一来数据在PE阵列里就像波浪一样经过16个时钟周期一个数据item会遍历一整行PE同时和16个不同的权重做乘累加16行同时工作就完成了一个16x16的矩阵乘操作。3.2 权重预加载与控制状态机权重加载有三种常用方式全并行预加载先花16个周期把权重全部写入每个PE内部的寄存器然后开始流数据。适合单次推理前一次权重即可的情况。边加载边计算权重像数据一样从上方流经每个PE前几排权重还没流到位之前数据不能开始流动。省了预加载时间但控制逻辑略复杂。权重静态配置如果权重固定不变比如车牌识别里的网络权重在训练后就固定了可以直接把权重作为常量硬连接进PE。资源占用大但延迟最低。我在车牌识别方案里用的是第二种“边加载边计算”因为网络的卷积层权重是逐层加载的每层之间权重不同但每层的数据量又不需要全部预加载完才开算。这种方案可以在层与层之间做到近零切换开销。控制状态机的核心逻辑可以分为以下几个状态localparam S_IDLE 3d0; // 空闲等待启动信号 localparam S_LOAD_W 3d1; // 权重加载/流式输入 localparam S_COMPUTE 3d2; // 数据计算 localparam S_DRAIN 3d3; // 排空流水线 localparam S_OUTPUT 3d4; // 输出结果权重加载阶段S_LOAD_W控制逻辑开始把权重的字节流按列送入阵列每个周期送一列16列权重需要16个周期全部就位。在这个阶段数据路径保持空闲不向阵列输入数据。计算阶段S_COMPUTE当最后一列权重送入后计算状态启动控制逻辑把输入数据的字节流按行送入阵列每个周期一行。关键点在于数据的输入节奏和权重流动的节奏必须完全匹配。我的设计里数据流的每个像素对应一个周期但卷积操作需要把输入特征图按卷积窗口的顺序重新排列成“输入行向量”这一步是在一个独立的“数据重排模块”里完成的其实质是把HxWxC的图片张量按照卷积窗口步长切块、展平、序列化。排空阶段S_DRAIN所有数据都进入阵列之后还需要等待16个周期让最后一组数据走完整条流水线每个PE里的部分和才能最终输出。这一步是脉动阵列最容易被新手忽略的地方——虽然数据送完了但计算结果还没完全出来必须要等到流水线排空。输出阶段S_OUTPUT结果从阵列底部逐行输出每行16个数据写入内部的Line Buffer或者直接送进下一层。整个状态机我控制在100行左右的Verilog代码量核心是把握好每个状态下数据流和权重流的节拍。这里我建议大家在调试时先用iverilog或ModelSim做一个简单的行为仿真把节拍波形看清楚再去上板比直接综合省力得多。3.3 输入特征图的数据重排边界条件和填充策略脉动阵列天然是为矩阵乘法设计的而卷积的本质是滑窗乘法。要把卷积映射到脉动阵列上必须把输入特征图按卷积窗口重排成一个二维矩阵行对应输出特征图的空间位置列对应当前卷积窗口内所有输入通道和卷积核坐标的组合。举个例子假设输入特征图是32x32、16通道卷积核是3x3步长是1那么输出特征图是30x30、通道数取决于滤波器数。每个输出位置30x30900个位置需要计算的东西是3x3x16144个乘累加这144个数就是一行。把900行组成一个大矩阵左乘一个滤波器权重矩阵假设32个滤波器则权重矩阵是32x144就得到一个900x32的输出矩阵。如果直接把900行全部展开BRAM和寄存器压力会比较大。我的做法是提前做数据切片式重排以16个输出位置为一个处理块保证每次送入阵列的数据恰好符合卷积窗口的取数范围块与块之间的一些重叠像素在Line Buffer里保留并复用。这样做的好处是脉动阵列的计算不会因为特征图大而中断流水线一直处于“有效计算”状态。填充Padding策略上车牌识别网络的输入图像已经在预处理阶段做过了边缘检测和归一化所以我用“同步填充”的方式在数据重排模块里做填充而不是在图像预处理阶段填充整张图。这样可以省掉大块BRAM的浪费只针对当前滑动窗口的行做填零处理。4. 车牌检测识别网络在脉动阵列上的映射策略硬件加速器有了网络结构怎么往上面映射是整个项目能否落地的核心问题。我在这个项目里跑的是一个精简版的车牌识别网络不算很深层但足够实用。4.1 网络结构设计车牌识别的流程分为两段第一段是用传统图像处理手段做车牌区域检测主要依赖车牌的颜色特征和边缘密度特征。颜色特征方面蓝色车牌的B通道值显著高于R和G黄色车牌的R和G显著高于B基于这个先验做颜色空间阈值分割非常可靠。边缘密度特征方面车牌区域会有多个字符引起的高密度垂直边缘。这两个特征结合得到候选区域后再用几何约束宽高比大约为3:1面积在一个合理范围内做最后确认。这一步完全不需要CNNFPGA里的图像处理流水线就能搞定延迟不到0.5ms。第二段是用一个轻量CNN对裁剪出来的车牌图像做字符识别。网络结构是这样的层类型输出尺寸卷积核/步长说明输入-96x32x1-灰度归一化后的车牌区域Conv1卷积96x32x83x3 / 1提取边缘和纹理特征Pool1最大池化48x16x82x2 / 2降采样Conv2卷积48x16x163x3 / 1提取更高层特征Pool2最大池化24x8x162x2 / 2降采样Conv3卷积24x8x323x3 / 1特征进一步抽象Pool3最大池化12x4x322x2 / 2特征图压缩到极小Flatten展平1536-拉直成一维FC1全连接128-特征压缩FC2全连接65-输出省份字母数字共65类这个网络我是参考了经典的LPRNet思想做了轻量化改造。车牌字符总共有31个省份汉字、24个字母、10个数字共65类。省份汉字和字母数字的分布有重叠因为车牌第一位是省份汉字、第二位是字母、后面是字母数字混合所以我用了一个共享的65类分类器训练时把所有字符都统一映射到65类。实测这个轻量网络在验证集上的字符级准确率在98.2%左右完全满足车牌识别场景的工程需求。Conv1具体计算量很大96x32x8 24576个输出像素每个输出像素需要3x3x19次乘累加所以Conv1的总乘累加次数大约是22万次。Conv2输出48x16x16 12288个像素每个像素需要3x3x872次乘累加约88万次。Conv3输出24x8x32 6144个像素每个需要3x3x16144次乘累加约88万次。三个卷积层加起来大约200万次乘累加而脉动阵列在200MHz下每秒能做16x16x200M 512亿次乘累加所以三层的计算时间理论上是200万/512亿 ≈ 0.39ms。实测下来三层耗时大约0.45ms多出来的部分就是数据重排和排空流水线的开销。这个计算效率比在CPU上用OpenCV跑同样的卷积要快一到两个数量级。4.2 权重量化与定点化FPGA对浮点运算的支持比较弱虽然现在的DSP也能跑浮点但资源消耗太大所以网络权重在部署前必须先做量化。我在这个项目里用的是8bit定点量化。量化的具体做法是训练时用浮点训练完成后取每一层的权重绝对值的最大值然后按8bit对称量化公式缩放scale 127 / max_abs quantized_weight round(float_weight * scale)输入图像在预处理阶段也用同样的方式量化到0~255的8bit整数。中间累加结果用16bit或32bit定点数保持精度但中间激活值在每层输出时会再次量化回8bit。这里的量化参数是逐层的不共用一个scale因为不同层的权重分布差异可能很大。8bit量化对车牌识别这种任务来说精度损失很小。我在测试集上对比过浮点模型的字符识别准确率是98.5%8bit量化后是98.2%只掉了0.3个百分点基本可以忽略。4.3 全连接层的处理全连接层本质上也是矩阵乘法所以也可以上脉动阵列。FC1的权重矩阵是1536x128FC2是128x65这两个矩阵乘法和卷积层在脉动阵列上的执行方式完全一样只是数据不再是卷积窗口重排的像素组而是Flatten后的特征向量。不过要注意的是全连接层的权重矩阵比卷积层大得多无法一次全部放进PE阵列的寄存器里。我的做法是按列分块执行先把1536x128的权重矩阵按16列的粒度分成8块一次加载一个块算完部分和后累计再算下一个块。这种分块策略可以保证任意大小的全连接层都能塞进固定尺寸的脉动阵列代价只是权重加载次数更多但全连接层在整个网络里的计算量占比很小增加的时间可以忽略。5. Xilinx和紫光同创双平台部署的完整流程与适配细节这一章重点讲双平台部署的踩坑记录。虽然代码是纯Verilog写的跨平台迁移在逻辑层面几乎零修改但给不同芯片做适配时还是会遇到不少“芯片厂特定的坑”。5.1 Xilinx Artix-7部署流程我最早是在Xilinx Artix-7 200T上验证的开发环境是Vivado 2019.2和2020.2都测过。管脚和约束的注意事项时钟我用的是板载200MHz差分晶振通过一个MMCM/PLL生成125MHz和200MHz两路时钟125MHz给图像采集接口用200MHz给加速器核心用。注意时序约束文件里要设好两个时钟域的异步FIFO避免跨时钟域亚稳态。DDR3控制器用的是Xilinx MIG IP配置成64bit接口、时钟频率800MHz。图像帧和权重都放在DDR3里加速器访问DDR3的路径我专门设计了一个AXI-Stream的读写桥把MIG接口和脉动阵列的Line Buffer对接起来。Vivado综合性能数据器件XC7A200T-2FBG676LUT约62%FF约43%DSP48E1256个正好是PE阵列的数量BRAM约58%时序200MHz stable没有时序违例从输入车辆视频到输出车牌字符串端到端延迟实测结果如下环节延迟图像采集与预处理约0.6ms车牌区域检测约0.3ms归一化与数据重排约0.2ms卷积全连接推理约1.7ms总延迟约2.8ms这里的延迟是流水线化的延迟不是吞吐量延迟就是说同一时间可以并行处理多帧图像的不同阶段但单帧从进到出依然保持2.8ms以内。5.2 紫光同创PGL22G部署的适配记录紫光同创Pango Micro的FPGA这几年在国内项目里用到的地方越来越多特别是轨道交通、电力、安防这些对国产化有硬性要求的行业。我在一个边缘计算盒子里用了PGL22G把整个车牌识别软核做了移植。先说结论逻辑代码完全没改只改了IP例化方式和引脚约束。流程是这样的第一步用紫光同创的PDSPango Design Suite新建工程选择PGL22G系列的具体型号。PDS基于Vivado的思路做的界面和操作逻辑类似Vivado老用户上手无压力。第二步把Verilog源码文件加入工程。需要注意PDS对Verilog-2001的语法支持比较完整但偶发的generate块里的一些复杂写法理论上有兼容性问题我的代码里没用到太复杂的generate块所以直接通过。第三步替换原语。Xilinx的BUFG、MMCME2_BASE这些原语在PDS里对应的是PLL IP和Clock Buffer原语。我直接用了PDS的PLL IP核生成两路时钟算法时钟、接口时钟的约束自己重新写一遍。第四步BRAM替换。脉动阵列里用到的双端口RAM、FIFO、Line Buffer全部例化Xilinx的RAMB18E1/RAMB36E1原语。在PDS里要替换成紫光同创自己的Block RAM IP或者直接用分布式RAM。我的处理是定义了一层RTL封装把RAM的接口统一成标准双端口底层根据平台条件分别例化Xilinx或紫光同创的原语。这样以后要换国产平台只改这层封装文件就够了。第五步引脚约束。PDS用的约束文件格式是.fdc跟Xilinx的.xdc格式有点区别主要的管脚位置、I/O标准、电平转换都要重新写。这个环节唯一需要留意的是国产FPGA的电平标准名称和一些board级管脚命名和Xilinx不完全一致对着手册查一下就能解决。第六步综合、布局布线。PDS的综合工具收敛性不错但intel和Xilinx的综合策略存在差异。我的脉动阵列在Vivado上一个晚上就能跑出不错的时序PDS多做了一轮布局布线的floorplan微调把PE阵列约束到一个固定区域整体时序收敛后大约185MHz比Xilinx低15MHz左右但逻辑频率不是这个项目的瓶颈所以完全可以接受。紫光同创PGL22G的资源结果资源总容量占用比例LUT420800约1230059%FF41600约1680040%DSP9090100%BRAM(36Kb)56约2646%这里要提醒一点PGL22G的DSP资源是90个而我这个脉动阵列需要256个乘法器。所以同一套16x16阵列在PGL22G上根本无法直接实现——DSP数量不够。我的方案是把阵列降配成8x8的脉动阵列64个PE其余部分用LUT逻辑构建。后期实测性能没降太多因为车牌识别网络本身计算量不大8x8的阵列在200MHz下也能在几毫秒内跑完推理。这个降配逻辑我是在设计早期就考虑过的所以PE阵列的尺寸是参数化的改变尺寸只需要改顶层参数即可。5.3 双平台共存的工程管理技巧因为公司同时有Xilinx项目和紫光同创项目为了不让代码树分叉我把工程结构做了分层rtl/ common/ # 完全不依赖具体芯片的通用逻辑 systolic_pe.v systolic_array_top.v conv_controller.v data_reorder.v weight_loader.v pooling_2x2.v fc_compute.v ... xilinx/ # Xilinx原语封装 clk_gen_xilinx.v # MMCM/PLL封装 bram_wrapper_xilinx.v # BRAM原语封装 ddr_axi_bridge_xilinx.v pango/ # 紫光同创原语封装 clk_gen_pango.v bram_wrapper_pango.v top/ # 顶层根据平台条件选择案例 top_ax7.v top_pgl22g.v这里的关键是common目录下的逻辑永远不碰原语所有时钟、RAM、DDR的访问全部走封装的接口。这样平台切换真正要做的只是切换顶层的电路板和底层原语封装算法逻辑完全复用。当时迁移到紫光同创时我只花了两天时间就搞定了所有适配和时序收敛。6. 三段式车牌识别流水线的完整实现整个车牌识别系统不是只有脉动阵列脉动阵列只是计算引擎还得配上一套前端预处理流水线和后端解析模块才能形成一条完整的链路。这块容易翻车我单独拿出来说。6.1 图像预处理流水线灰度、滤波、边缘、二值化输入的图像如果是彩色先做灰度化。因为车牌检测阶段需要颜色特征做区域筛选所以我在硬件里保留了原始的RGB数据另外用一套独立的灰度计算模块把灰度值算出来。灰度化的计算公式是Y 0.299R 0.587G 0.114BFPGA里避免用浮点我用整数近似Y (R*77 G*150 B*29) 8系数77/150/29调整成接近标准的比例移位8位相当于除以256实现起来就是三次乘法加三次加法最后移位非常简单。灰度化之后做中值滤波。中值滤波对去除车牌图像上的椒盐噪声效果很好而且在FPGA里的实现方式比较固定——用3x3的滑动窗口窗口内9个像素值排序取中值。排序用硬线比较器网络实现9个像素的排序逻辑大约几十个比较器综合后占用很小的LUT面积。这里要注意的是滑动窗口的row buffer用一个长度为图像宽度的行缓存BRAM实现滚动缓存两行像素就能实时输出3x3窗口。边缘检测我用的是Sobel算子水平方向和垂直方向各做一次卷积然后取梯度幅值。Sobel的卷积核是Gx [[-1,0,1],[-2,0,2],[-1,0,1]] Gy [[-1,-2,-1],[0,0,0],[1,2,1]]这两个3x3的卷积矩阵很小不需要动用脉动阵列直接用分布式的乘累加就能在几个周期内完成。边缘幅值 |Gx| |Gy|这样可以避免开方运算硬件友好。最后二值化用一个动态阈值模块。动态阈值的思路是统计整个图像边缘幅值的直方图在FPGA里用一个256深度的RAM做统计找到直方图的累积分布从5%到95%的范围取中间值作为阈值。这样算法在不同光照条件下都能自适应不会因为白天夜里亮度变化导致边缘检测失效。6.2 车牌定位基于颜色边缘的候选区域扫描预处理完之后就要找车牌在哪。这一步我没有用滑窗CNN的方法而是直接用硬件友好的方式先在图像上做颜色扫描。对每个像素判断是否满足蓝色车牌的色度范围。判断逻辑很简单if (b 100 b r*1.2 b g*1.1) blue_flag 1;这个条件我用整数乘法实现R1.2可以转成(R*6)/5B1.1转成(B*11)/10全整数逻辑。满足条件的像素标记为1不满足的标记为0。然后把颜色标记图在水平方向压缩投影统计每一行的蓝色像素数量。车牌区域在水平投影上会形成一个明显的高峰因为车牌区域的高度相对于画面其他区域蓝色像素密度很高。根据这个投影可以粗定位出车牌的上下边界。在上下边界确定的行范围内再做垂直方向的扫描统计每一列的蓝色像素数量得到左右边界。水平和垂直两次投影定位之后会得到若干个候选矩形区域。用宽高比0.2到0.5之间和面积约束过滤掉明显不是车牌的框。边缘密度验证对候选区域计算边缘点密度如果区域内的边缘点比值低于阈值说明该区域纹理太少可能是纯色干扰物比如蓝色车身直接丢弃。这个流程全部是逐像素流水线处理不涉及帧缓存所以在FPGA里非常快几乎和图像边进入边处理同步完成。实测1080P图像在200MHz下从开始处理到产生候选框大约耗时0.3ms。6.3 字符切分与归一化得到车牌区域之后需要把车牌图像送到卷积网络。如果直接送一整个车牌区域进去训练和推理模型需要自己学习字符的空间分布对车牌的缩放和平移比较敏感。我的做法是先做字符级或文本行级的归一化处理。我的处理方式如下在训练阶段我把车牌区域统一缩放到96x32的固定尺寸送入CNN。在推理阶段硬件实现一个双线性插值缩放模块把任意尺寸的候选车牌区域缩放到96x32。双线性插值在FPGA里的实现逻辑是目标坐标映射到源坐标找到最近的四个像素按距离加权平均。这个模块每个输出像素需要4次乘法总共96x323072个输出像素计算量非常小不到一个时钟周期就能完成一个像素整张缩放在1ms以内。如果后续项目需要做整牌一键识别不切分字符这个96x32的输入图直接就够用了。如果做字符级识别还需要进一步在96x32的图上做垂直投影切分字符这一步和车牌定位的原理类似但计算量小很多我暂时没有做字符级切分直接走的是整牌识别路线。6.4 结果后处理卷积网络的输出是65个类别的置信度分数取最大值对应的类别编号再通过一个查表映射成字符。但实际车牌场景中网络经常会输出多个高置信度的候选直接硬取top1比较浪费信息。我在FPGA里加了一个简易的NMS非极大值抑制后处理模块对65类置信度做排序但只取前3名如果第1名的置信度和第2名相差小于1%量化后就是几个LSD的差距就认为这次的识别结果不可靠输出一个低置信度标志。这个逻辑在CPU上很普通但在FPGA里我用了三个比较器加两个寄存器的流水线实现只在每帧识别结束时处理65个数字开销很微小。7. 实测数据与调优过程端到端延迟、资源占用、动态量化后的精度变化前面零散提到了不少数据这一节我把完整的实测数据汇总成表格顺便讲讲我在调优过程中遇到的具体问题和解决手段。7.1 帧率与延迟的最终指标平台器件加速器时钟端到端延迟1080P30吞吐总线接口Xilinx Artix-7XC7A200T200MHz2.8ms可满帧处理AXI-StreamXilinx Kintex-7XC7K325T225MHz2.5ms可满帧处理AXI-Stream紫光同创PGL22G185MHz3.2ms可满帧处理AXI-Stream紫光同创PGT180H200MHz2.8ms可满帧处理AXI-StreamKintex-7比Artix-7提升不多因为瓶颈已经不在计算而在DDR3带宽和图像预处理流水线上。如果把图像处理也并行化还有下降空间但2.8ms对车牌识别来说已经是超低延迟了人眼看画面是实时的。7.2 调优过程中遇到的两个关键问题第一个是数据重排模块的BRAM冲突。在最早的版本里卷积层输入的特征图重排是用双端口BRAM做的读端口被两个计算通道共用结果在特定数据分布下出现了Bank Conflict导致每个时钟周期只能读出部分数据脉动阵列喂不满数据有效算力直接掉了30%左右。这个问题的排查花了我不少时间最后定位到我还专门写了个小的Python脚本模拟读写地址序列对比BRAM的端口分配发现两个通道的地址总是在某些行上撞在一起。最终解决方法是把Line Buffer拆成两个单端口RAM分别服务两个计算通道配合乒乓切换彻底规避了冲突。第二个是DDR3带宽瓶颈。图像预处理模块从DDR3读数据和脉动阵列从DDR3读权重两个都挤在同一个MIG接口上带宽高峰的时候相互拖慢。一开始的延迟数据惨不忍睹测出来5ms多。后来我把读取权重的时间提前到图像进入流水线的同时进行预加载把DDR3的压力错峰加上Line Buffer缓存和双通道AXI读最终把带宽利用效率从不到50%提升到70%以上延迟也就掉到2.8ms了。7.3 动态量化后的精度表现前面提的8bit量化是静态量化训练之后直接把权重量化成8bit。但车牌识别因为省份汉字的字符类别只有31个汉字的类间差异比较大有部分汉字比如“川”和“州”在某些分辨率下非常容易混淆。我在测试中发现一个有趣的现象量化方式省份字符准确率字母数字准确率整牌准确率FP32浮点99.1%99.5%98.5%静态INT8量化98.2%98.9%97.4%动态INT8量化98.5%99.3%98.0%动态量化的意思是在推理过程中根据每个输入批次的实际数值范围动态调整缩放因子而静态量化是一个固定的scale。动态量化就比静态量化多了个“每个批次重新计算输入激活的max绝对值”的逻辑在FPGA里实现不复杂但多一个减法器和移位器精度能拉回0.4~0.5个百分点还是很值得的。7.4 在GTX 1050上对比CPU和GPU的延迟虽然FPGA的绝对算力比不上高端GPU但在这种小模型、极致低延迟的场景下FPGA反而有优势。我用同样的轻量网络在i7-8700K CPU和GTX 1050 GPU上做了对比平台网络单次推理延迟端到端延迟含预处理i7-8700K约11ms约28msGTX 1050约4.5ms约8msArtix-7 FPGA约0.9ms约2.8msGPU每次推理有固定的驱动开销和显存传输开销4.5ms是实际多次测量的均值CPU就更不用说了图像预处理在OpenCV里也存在较大开销。FPGA没有这些问题所有环节都在片上完成延迟稳定且可预测。这就是为什么客户点名要FPGA方案的原因——在某些工业场景里延迟的稳定性和最大值比平均值更重要。8. 移植其他国产FPGA平台的实战记录用国产化替代过程中的几个细节这两年国产FPGA厂商的产品越来越成熟很多安防、交通项目都有国产化替代的需求。我在做完紫光同创之后也对其他国产平台做过预研和简单适配这里把关键经验分享一下。8.1 国产FPGA通用的三个适配难点第一个难点是原语命名和功能差异。每家厂商的快时钟、PLL、BRAM原语名称都不一样功能参数也可能有差异。比如同样的“双端口RAM”Xilinx的BRAM可以同时读和写的独立端口问询有些国产平台的原语在某些配置下的握手时序有差异。解决办法是封装一层Xilinx风格的接口底层由各厂商自己的原语实现然后把这层封装做成一个独立的RTL库。第二个难点是资源的密度和DSP结构差异。有些国产FPGA的DSP单元不是传统的18x25乘法器结构而是更小的乘加单元。如果一个DSP资源不够做8bit x 8bit乘法再加32bit累加可能需要用两个DSP拼接或者让LUT辅助做部分和。在设计脉动阵列时最好把PE内部的运算形式定义成“乘法加法”两个独立步骤这样在DSP数量不足时可以把乘法器拆出去给LUT逻辑实现。第三个难点是综合工具对部分Verilog语法的支持。有些国产平台用的基于开源Yosys的综合流程对generate-for块、多维数组、复杂函数递归的支持不够完整。写RTL时尽量避免这些高级语法多用手动展开的写法。我的PE阵列代码本身很简单但上一版数据重排模块里用了generate按通道展开逻辑换到国产平台就出了语法不识别的问题最后改成手动复制代码。8.2 在紫光同创PGL22G上的具体适配清单如果你要在PGL22G上部署以下几件事是避不开的修改约束文件格式PDS使用.fdc文件语法类似Xilinx的.xdc但一些命令名不同。比如时钟约束的命令是create_clock和create_generated_clock和Vivado差不多但引脚约束中用set_property的写法有细微差异。重新配置PLLPGL22G的PLL IP核里可以生成多路输出时钟但每路之间的相位对齐和抖动参数需要按手册设置否则低速图像接口容易丢数据。DDR控制器的选择PGL22G没有内置DDR PHY需要外部挂DDR颗粒控制器用软核实现。我在项目里用的是紫光同创官方提供的DDR controller IP性能和Xilinx MIG略差但经过测试在400MHz下稳定运行没问题。URAM替换为BRAMXilinx 7系列的高端器件有URAM但国产平台一般没有所以代码里如果用到了URAM需要学会绕过它。我的脉动阵列里没有用URAMLine Buffer全部用BRAM实现所以在适配时只换BRAM封装就够了。8.3 国产平台部署后的实测稳定性在紫光同创PGL22G上做7x24小时连续运行测试连续跑了72小时没有出现任何逻辑错误、时序稳定性良好。这里额外提醒一下一定要做时钟系统的上电复位时序验证。国产FPGA的上电复位时间比Xilinx略长如果逻辑里ttl有等复位完成的模块状态机容易在复位不完全的情况下启动导致第一帧图像处理出错。我在顶层加了一个基于计数器延时的软复位信号保证上电后至少100us内所有模块保持复位态再正常释放复位这个问题就解决了。9. 超低延迟车牌识别系统的后续扩展方向这套系统做完之后后续可以演进的方向其实不少我根据自己的实践和经验列几个值得深入的方向一是针对夜间和恶劣天气的鲁棒性提升。目前的车牌检测对光照变化有一定的自适应性但夜间会车时前照灯直射车牌导致的过曝以及雨雪天气里的反射噪声还是会出现检测失败。可以引入基于Retinex的多尺度增强模块但需要额外BRAM和DSP资源在PGL22G上有点吃紧换到PGT180H就可以尝试。二是多车牌并行检测。当前方案默认画面里只有一张车牌如果高速公路卡口画面里同时出现多辆车当前的检测逻辑会选出置信度最高的候选框其他车辆会被忽略。脉动阵列本身的算力有余量支撑2~3张车牌的并行识别关键的改动是让预处理模块输出多个候选区域的坐标队列然后对每个候选区域做独立的归一化和推理。需要注意的仍然是要解决多候选区域的BRAM和DDR带宽冲突。三是视频流的时序分析。车牌识别结果如果带时间戳可以进行测速。在FPGA里加一个VEHICLE_TRACK模块记录每辆车经过两个检测断面距离已知的时间差速度计算用除法器就能轻松完成。这个模块我已经预研过加上去只占大约2%的LUT资源主要的工作量来自把识别结果的时间戳从图像采集模块传管出来。四是端到端的YOLO替换。前面我说过车牌检测场景其实不需要通用目标检测但如果客户要求识别多种物体比如同时识别车牌、驾驶员人脸、遮阳板那就得把检测网络做得更通用。此时脉动阵列可以升级成支持3x3/5x5混合卷积核的灵活架构或者直接挂载一个更大的稀疏化YOLO模型。目前脉动阵列在8bit量化下加载一个输入为416x416的YOLOv5s模型理论计算量约7GMACs在200MHz、16x16的阵列上需要大约0.14秒这已经超过了“超低延迟”的范畴所以需要更大的阵列或者更低的精度方案才能兼顾。10. 我在整个项目过程中踩过的几个大坑和最后想说的话最后聊点实在的把我在这个项目里踩过最深的几个坑分享出来各位在做类似架构时能避开就避开省钱省力不止一点。第一个坑是阵列尺寸和网络结构的匹配。我最初把脉动阵列设计成24x24的想着尺寸越大计算越快结果数据重排模块的BRAM占用急剧上升时序也频繁违例。后来意识到对于车牌识别这种小网络一个16x16的阵列已经足够了。更大的阵列反而需要更多的请求数据带宽和存储带宽去喂饱不然算力就是纸面数字。建议做FPGA加速之前先用Python模拟一下网络各层的矩阵尺寸选择“可能需要同时流经阵列的最大矩阵边长”作为阵列尺寸即可没必要盲目堆规模。第二个坑是权重存储用DDR3还是BRAM。最初我把所有层的权重都放在DDR3里每个卷积层计算前都要从DDR3读权重到PE阵列跨时钟域的读写延迟消耗了不少时间。后来我把权重按层构建成BRAM里的缓存区每次启动计算前一次性从DDR3预取到BRAM脉动阵列直接从BRAM读权重。这个改动看似朴素但实测每层计算前的准备时间减少了约60%。第三个坑是调试工具的选择。纯Verilog的调试强度比HLS高很多波形仿真几乎是必须的。但Matlab/FPGA的接口调试起来比较麻烦我经常是用iverilog加上GTKWave查看简单波形复杂的状态机行为用Vivado自带的仿真器。GTKWave看几百兆的波形文件有时候卡到抓狂后来我养成了一个习惯在RTL里插一些调试用的计数器比如“当前状态停留了几个周期”“当前卷积层的有效数据计数”这些计数器的值通过AXI-Lite接口读回到CPU上用命令行打印出来排查效率比看波形高很多。说到最后我想说的是脉动阵列这种架构之所以值得做不是因为它看起来“高级”而是因为它真的把计算密度和延迟控制做到了极致。车牌识别只是它的一个小场景同样的加速器用在图像去雾、视频超分、雷达信号处理上只要网络结构是卷积为主都能直接复用只需改权重和配置参数。如果你也在纠结是上HLS还是手写Verilog我的建议是想快速验证算法用HLS做产品追求可控性和极致延迟手写RTL才是正解。这套16x16脉动阵列模板我会继续保持更新后续也会把它扩展到更多厂商的FPGA平台上做适配毕竟国产化和多平台适配已经是硬件产品绕不开的话题了。