FPGA脉动阵列实现车牌识别:纯Verilog超低延迟加速方案 1. 为什么车牌识别需要一套“纯Verilog”的加速方案1.1 超低延迟在车牌场景里到底意味着什么做车牌检测识别这个方向大部分团队第一反应是上树莓派、Jetson或者干脆跑在后台服务器的GPU上。这套方案确实能跑但客户一旦把“功耗”“单板”“毫秒级响应”这几个词摆上桌面情况就完全不一样了。我在实际项目里接到的需求是视频流在FPGA上完成“检测车牌位置 识别字符”从像素进入FPGA到串口吐出车牌字符串整个端到端延迟不能超过一帧。简单换算一下如果是50fps的采集一张图片的周期是20ms意味着推理必须在一帧时间内完成不能有任何“攒满一帧再处理”的等闲操作。这就把很多现成方案卡死了。树莓派上跑个轻量级YOLO单帧推理本身就要30到50ms这还是没算预处理和字符识别的开销。后台GPU方案延迟更低但功耗轻松上百瓦而且需要网络传图传输本身又会引入不确定的抖动。相比之下FPGA的优势恰恰体现在这里像素流自带时钟进一行算一行数据不落地、不等批天然就是一条连续流水线。车牌识别又是计算模式非常规整的任务卷积占了绝大部分计算量非常适合用硬件阵列去干。1.2 选纯Verilog而不是HLS到底图什么既然决定上FPGA下一步就是选开发方式。当时有人建议用HLS理由是“写起来快像C语言”。我也承认在做算法快速验证时HLS确实方便。但这个项目里我最终坚持全部用纯Verilog写没有引入任何HLS模块原因有三。第一时序的可控性。HLS生成的RTL有一个特点它为了兼容多种流水策略会插入很多多余的握手信号和状态机综合出来面积大一圈是常事。在XC7A35T这种资源偏少的器件上做8x8脉动阵列每一点LUT和FF都要精打细算手写Verilog对寄存器什么时候打一拍、数据什么时候有效完全是透明的。第二跨平台移植。这个项目要求同时在Xilinx和紫光同创两套工具链上部署。HLS在Xilinx的Vivado HLS里用得很顺但资深的工程师都知道HLS生成的RTL对厂商IP和原语的依赖很重迁移到另一个厂商的EDA工具上几乎等于重写。纯Verilog只要不碰厂家专用原语可以做到“一套代码两处综合”。第三调试效率。车牌识别的链路很长从灰度化、缩放、卷积、全连接到字符输出每一级都有可能出现数据错位。手写RTL时我可以随时在任意两级之间加调试探针直接把中间值抓出来跟仿真相比较。HLS生成的黑盒代码在这一步就很痛苦你很难把一个内部信号引出来看。1.3 架构选型在乘法器树和脉动阵列之间做选择卷积加速的硬件结构无非几种单MAC串行、乘法器树并行、以及脉动阵列。单MAC串行占资源最少但太慢不用考虑。乘法器树是目前最常用的方案把同一时钟周期内需要的所有乘法结果并行算出来再加法树求和。但乘法器树有个问题输入数据得同时准备好。一旦特征图尺寸大、输入通道多就需要宽位宽的输入缓冲布线压力非常大。脉动阵列的思路完全不同。它把计算单元排列成二维网格数据在网格里“流动”每个PE只和相邻PE通信。权重预先加载到PE内部寄存器输入激活从阵列上方流入部分和从左侧累加后向右传出。这个结构天然适合卷积层这种“同一个权重被多个输入反复复用”的场景。我最终选定了8x8的PE阵列好理解、布线容易、资源压力适中14nm工艺下做到200MHz也不困难。打个不太严谨但好懂的比方乘法器树像是一堆人同时开工但每个人都需要自己的完整材料包材料一到齐才能动工。脉动阵列则像一条流水线每个工人手上只留一个专用工具权重零件从头顶传下来加工完传给右边的人继续累加整个车间一直处于满负荷状态。2. 脉动阵列的RTL实现从PE单元到卷积数据流2.1 最小PE单元乘法、累加、两个移位寄存器脉动阵列的基本单元是PE每个PE的核心工作只有两件事把输入激活和权重乘起来再加上左边传来的部分和。剩下的事都是数据搬运。我的PE用参数化位宽编写顶层例化64个代码结构如下module pe_8bit #( parameter ACT_W 8, // 激活值位宽 parameter WGT_W 8, // 权重位宽 parameter ACC_W 24 // 累加器位宽 )( input wire clk, input wire rst_n, input wire pe_en, input wire signed [ACT_W-1:0] act_in, // 从上方的PE传入的激活 input wire signed [WGT_W-1:0] wgt_in, // 从左侧的PE传入的权重 input wire signed [ACC_W-1:0] psum_in, // 从左侧PE传入的部分和 output reg signed [ACT_W-1:0] act_out, // 向下一个PE传出激活 output reg signed [WGT_W-1:0] wgt_out, // 向右侧PE传出权重 output reg signed [ACC_W-1:0] psum_out // 向右侧PE传出部分和 ); wire signed [ACT_WWGT_W-1:0] mul_result; wire signed [ACC_W-1:0] add_result; assign mul_result act_in * wgt_in; assign add_result psum_in mul_result; always (posedge clk or negedge rst_n) begin if (!rst_n) begin act_out d0; wgt_out d0; psum_out d0; end else if (pe_en) begin act_out act_in; wgt_out wgt_in; psum_out add_result; end end endmodule这段代码看起来简单实际里面藏着三个容易被新手忽略的决策。第一个是累加器位宽选24位而不是16位。8位定点数相乘得到16位结果但卷积会把输入通道维度上的多个乘法结果累加在一起。以我用的轻量检测网络为例某一层有32个输入通道32个16位乘积相加最大可能溢出16位。24位累加器能把MAX/MIN边界留足冗余实测下来整网跑完没有溢出。第二个是三个输出全部打了一拍寄存器。脉动阵列的“脉动”就是靠这些寄存器一级一级传递数据实现的本质是给每个PE加上了一个流水站让数据能以固定的节拍流过所有PE而不需要担心组合逻辑路径太长导致时序违例。第三个是pe_en信号。这个使能信号不是可有可无的。在实际卷积过程中特征图的边界行和边界列可能不需要计算比如3x3卷积的第一行没法做中心对齐通过pe_en可以随时冻结阵列避免在无效数据上白耗功耗。2.2 权重预载与数据流动三个方向各司其职PE阵列定下来后接下来就是数据流调度。阵列里数据分成三个方向权重从左往右灌入激活从上方往下方流动部分和从左侧向右累加输出。三者不是同时乱跑而是严格按节拍推进。权重是预先加载的。在开始计算一个卷积层之前先把该层全部权重按PE的行列位置排好从左边界逐列写入。8x8阵列意味着每次能同时并行灌入8个权重只要8个周期就能把一层权重全部装进PE内部的wgt_in寄存器。整层卷积计算过程中权重不再变动全程待在寄存器里这一特点让脉动阵列省去了大量重复读权重的带宽。激活数据按行从上往下输入。特征图的一行像素分成若干组每组8个值作为一个“节拍”进来对应8行PE各自收到一个输入。比如一个64x64的特征图按行展开后有64行每行依次下压一次就完成了一次完整的特征图遍历。这个过程中每个激活值都被8个PE共用数据复用率做到满。部分和从左侧进入第一个PE向右逐级累加。第一列PE收到的是0第二列PE收到的是第一列的计算结果第8列PE最终输出的是8组乘法结果的累加和。这样安排还有个好处部分和的宽度可以做得比激活宽很多中间累加过程完全不会因为位宽裁剪损失精度只有阵列的最右边才做一次量化截断。2.3 卷积到脉动阵列的映射以及padding带来的坑直接把二维卷积塞进一维脉动阵列中间需要一次“数据重排”也就是传说中的im2col。3x3卷积核在特征图上滑动每次窗口覆盖9个像素对应9次乘累加。如果认真观察这9次乘累加本质上就是9个不同的矩阵乘每个矩阵乘对应卷积核的一个权重位置。我的映射方法是对每个卷积核位置单独做一次脉动阵列遍历。以3x3卷积的第0权重为例它对应的就是特征图上所有位置的中心输入。那么第0轮我只让输入行加载窗口左上角的像素PE里加载的权重是w00阵列输出的部分和是w00x00。第1轮输入行加载的是窗口正上方的像素PE权重换成w01累加的是w01x01。9轮走完部分和正好就是完整卷积结果。这样做的好处是不需要在FPGA内部实现复杂的im2col缓冲代价是计算整个卷积层需要“扫9遍”特征图。好在8x8阵列每个节拍能算8个输出通道总吞吐仍然很高。padding是第一个坑。特征图边界补零看起来很简单硬件上却意味着阵列要在边界位置强制输入0不仅浪费计算周期还需要额外的控制信号去识别“当前像素是否处于边界”。我的做法是把padding逻辑放到前级输入行缓冲里在特征图真正到达阵列之前就把零填好PE完全感知不到padding的存在。这个设计让阵列控制逻辑简化了不少但也导致了第二个问题队列里的数据如果是“假零”也会消耗真正的计算节拍。针对这一点我在深度流水线里做了一个优化边界行直接跳过脉动阵列。3x3卷积的输出特征图相比输入特征图上下各少一行、左右各少一列。与其把这些无效计算发到阵列里跑不如在输入控制模块直接“掐掉”边界行。实测这一项优化让单层卷积的延迟降低了12%左右代价只是控制逻辑里多一个行计数器的判断。3. 为超低延迟服务的量化与流水线设计3.1 INT8定点化车牌图像根本不需要浮点FPGA上做CNN第一个绕不开的问题是用浮点还是定点。FPGA的DSP硬核本质上就是做定点乘法你给它塞浮点等于把计算量翻了好几倍DSP数量瞬间就不够了。所以只要是资源受限的FPGA部署INT8定点量化基本是唯一靠谱的选择。车牌图像有它的特殊性车牌区域对比度很高蓝底白字、黄底黑字、白底黑字字符和背景在亮度分量上差距非常明显。这让INT8量化在这个场景里有天然优势不像检测自然场景里的行人光照变化大、颜色复杂量化精度稍微差一点目标就丢了。我用一组实际数据做验证同一个轻量检测网络FP32推理准确率98.2%INT8量化后是97.6%只掉了0.6个百分点完全在可接受范围内。量化的具体做法是对每一层的激活和权重分别统计min/max用一个线性映射把浮点值缩放到[-128, 127]区间。缩放系数不需要在FPGA上额外做乘法而是提前在离线阶段把它融合进相邻层的权重里。这样FPGA上的卷积计算从头到尾只有整数乘加没有任何浮点操作。3.2 BN层和ReLU层如何“免费”融进卷积车牌识别网络里几乎每层卷积后面都跟着BatchNorm和ReLU。如果老老实实做三个独立模块会多出两层完整的数据搬运。实际上BN层在推理阶段可以完全数学化简不需要硬件实现。BN的推理公式是y gamma * (x - mean) / sqrt(var eps) beta。在卷积已经算出x的前提下这一长串式子可以转成y a * x b其中a和b是每通道一个的常数。更进一步既然a和b是离线知道的我们可以把它们乘到卷积核的权重和偏置上新的权重 原权重 * a新的偏置 原偏置 * a b。经过这个融合卷积核从8bit变成需要一点额外位宽但卷积结构没有任何变化。融合之后的ReLU更简单脉动阵列最右侧输出累加结果后只要判断一下符号位负数直接清零正数原样输出。这不需要额外模块在累加输出寄存器的使能逻辑上顺手就做了。整个链路上BN和ReLU没有产生任何额外的时钟周期开销全部“免费”嵌进了卷积计算过程。3.3 全链路流水编排一行都不等超低延迟的关键不是省多少MAC而是数据能不能一直往前走任何一点“攒批”的操作都会毁掉整个延迟指标。我在设计里把整条链路分成五级流水像素接收、预处理、检测卷积、候选框筛选、字符识别。前一级的处理结果一旦产出有效信号立刻通过FIFO送给下一级下一级在同一拍就开始消费。整条链路没有任何“等一帧齐了再开始”的同步屏障。最典型的是预处理级。摄像头输入是逐行扫描的预处理也设计成逐行处理。灰度化对每个像素独立计算结束后立即判断这个像素属于哪个缩放目标位置然后写进行缓冲FIFO。卷积阵列不等“一行凑满”就开始计算只要行缓冲里有3行数据3x3卷积的窗口就能滑动起来了。这种设计下系统流水线建立起来之后端到端延迟基本等于“输入到输出经过的物理路径延迟”而不是“处理一帧数据的计算时间”。实测中帧率能跑到60fps以上但单帧延迟只有15ms左右靠的就是这个持续流动的结构。4. Xilinx与紫光同创两套平台部署实录4.1 两个平台的差异比想象中更本质这个项目验证时用了两块板子一块是Xilinx Artix-7 XC7A35T另一块是紫光同创PGL22G。一开始我以为无非是换个综合器代码能过就行。真正动手迁移才发现事情远没那么简单。首先是对原语的依赖。Xilinx的平台上有MMCM/PLL、RAMB36、DSP48E1等一系列成熟原语Vivado用起来很顺手。但紫光同创有自己的Pango Design Suite原语名称和用法跟Xilinx不同DSP结构也有差别。如果一开始写RTL时直接例化了Xilinx原语迁移起来就要全部重写。我最初的代码因为用了Xilinx的Block RAM生成器做行缓冲迁移时差点被卡住。后来痛定思痛做了一个决定把RAM、FIFO、PLL这类底层单元全部封装成独立模块对外只暴露接口内部才根据编译宏选择对应的厂商原语。这样综合时通过define切换XILINX或PANGOLIN代码主体完全不动。其次是DSP映射的差异。Xilinx的DSP48E1集成了预加器、乘法器、累加器、逻辑单元综合器对乘法加累加这种结构的推断能力很强。紫光同创的DSP也具有乘累加功能但综合器在某些写法下不会自动推断成DSP而是退化成LUT实现资源和时序都会崩。遗产代码经验是写RTL时明确写出乘法器输出寄存器再写累加器不要用一个always块把整个乘加打一拍。这种“寄存器隔开乘和加”的写法在两家综合器下都能被稳定识别为DSP结构。4.2 移植的完整步骤和约束适配整个迁移过程我按下面几步走清理RTL搜索所有带厂商标识的原语例化全部替换成自定义封装模块。Xilinx的BRAM换成自定义的ram_wrapperPango里换用对应的RAM原语实现。约束文件重写Xilinx用的是XDC约束Pango用SDC约束语法虽然很接近但时钟定义、引脚绑定的命令名称有差别。我在工程里直接维护两个版本的约束文件逻辑部分不变只改时钟和引脚。功能仿真用同一个testbench在Vivado和Pango里分别跑仿真比较波形确保行为一致。这里要提醒一下Pango的仿真工具是基于Mentor ModelSim改的对SystemVerilog某些新语法支持不如Vivado所以testbench尽量用老式Verilog语法写。时序收敛Xilinx那边跑到250MHz轻轻松松但PGL22G上相同代码时序要紧张不少。最后我把核心时钟统一降到200MHz两边都能稳定收敛两个平台的行为完全一致。4.3 资源和实际性能对比下面是两块板子上脉动阵列加速器的最终资源占用情况这里只列核心加速逻辑不含图像采集和UART输出资源类型XC7A35TPGL22GLUT9.8K / 20.8K10.2K / 21.6KFF11.3K / 41.6K12.1K / 38.4KDSP64 / 9064 / 86BRAM12 / 5013 / 40时序收敛频率250MHz200MHz实测功耗核心约1.8W约2.1W紫光平台频率略低的直接原因是综合工具对长数据通路的优化能力不如Vivado强大但200MHz下64个PE依然有12.8 GMAC/s的理论算力跑轻量车牌识别网络绰绰有余。整体上PGL22G在资源映射和时序收敛上需要花更多精力去调但完全可行。5. 车牌检测识别链路在FPGA上的端到端打通5.1 图像预处理从RGV到固定尺寸输入FPGA做的事情跟软件完全不同。软件可以直接读整张图片但FPGA只能随着像素时钟一个一个处理像素。我的预处理模块用了一个行同步和场同步信号驱动的状态机逐像素完成三件事。第一步是RGB24转灰度。车牌本身的颜色信息对检测非常重要但为了降低计算量我先把输入转为灰度再用另外一路颜色判定逻辑并行判断蓝色和黄色只有检测到这两种颜色才触发后续的候选框扫描。这样灰度图交给卷积网络做字符识别颜色逻辑用于粗定位互不干扰。第二步是缩放。摄像头采集的是720p或1080p的画面而检测网络输入是固定的160x160像素。FPGA里做任意尺寸缩放最常用的手段是“最近邻插值”虽然会损失一点画质但车牌字符本身笔画清晰最近邻在测试集上的识别准确率只比双线性插值低0.3%。我选择了最近邻插值的硬件实现方案因为它只需要一张坐标映射表不需要计算浮点系数。第三步是归一化。把0到255的像素值映射到INT8定点范围[-128, 127]这里直接用一张256深度的LUT查找表完成每个像素一个节拍就能输出定点值延迟为零。5.2 检测子网络轻量回归加颜色粗定位FPGA上做大模型的检测资源完全不够。我的方案是“颜色粗定位 轻量卷积精修”。颜色粗定位是专门为车牌场景定制的技巧。RGB空间里蓝色车牌的B通道远大于R通道黄色车牌的R和G通道远大于B通道。用一个简单的比较器逐像素标记候选像素然后用行投影和列投影找到密集区域就能快速框出车牌的大致位置。这个步骤完全不占DSP只用了几个比较器和累加器扫描全图只需要几十微秒。找到候选框之后以这个框为中心裁剪出一个宽高比4:1左右的图像块缩放到160x40送入轻量检测网络。这个网络结构很浅两层3x3卷积加一层全连接参数量不到20万。它做的事情不是重新找车牌而是对粗定位结果做四个偏移量的精细回归修正框的位置。这个两级设计的好处在于粗定位卡掉了绝大多数背景区域卷积只需要处理候选框周边的一小块计算量从全图变成了一个很小的窗口。这直接决定了整个检测阶段的延迟可以压在10ms以内。5.3 字符识别分割加单字符分类器字符识别阶段没有用端到端的CRNN原因是FPGA资源有限CRNN里的LSTM单元在纯Verilog里实现起来相当繁琐而且时序不好收敛。我采用了传统方案先做字符分割再用单个字符分类器逐个识别。字符分割在硬件上由“连通域扫描”模块完成。车牌区域图像二值化后字符和背景分离模块按列扫描统计每个字符的起止列位置配合固定宽度约束剔除掉边框和铆钉干扰。分割出的每个字符图像缩放到16x16送给一个三层单字符CNN分类器。单字符CNN的计算量极小一次前向推理只涉及约10万次乘累加8x8脉动阵列跑一个字符只需要几十微秒。车牌上有7到8个字符序列化识别总耗时约300微秒不会成为整条链路的瓶颈。分类器输出的是索引我在一个只读查找表里存了“索引到ASCII字符串”的映射识别完成后直接通过UART把ASCII码发送出去。6. 实测延迟、资源对比与避坑要点6.1 端到端延迟的实际测量结果测量端到端延迟不是简单的按下秒表。我在摄像头输入侧插入了一个“帧起始标志”作为时间参考然后在UART输出侧检测到车牌字符串帧头时锁存一个时间戳。两边时间戳做差得到的就是真实端到端延迟。最终在720p输入、160x160检测网络、18类字符集条件下两平台的实测数据如下测量项XC7A35T 200MHzPGL22G 200MHz图像预处理延迟1.2ms1.4ms检测网络推理6.8ms7.5ms候选框精修0.6ms0.7ms字符分割识别1.8ms2.0ms端到端总延迟10.4ms11.6ms平均处理帧率82fps74fps这个结果在“超低延迟”这个目标上算是达标了。最难能可贵的是延迟曲线非常平滑几乎看不到毛刺解释在于纯Verilog实现没有操作系统调度、没有缓存失效、没有任务切换每一拍的执行时间都是确定的。6.2 踩过的坑跨平台移植和INT8溢出第一个坑是padding引起的计算浪费。初版设计在特征图边缘补零时脉动阵列会把零值也当作正常数据流走一遍导致边缘多出的计算率达到三成。后来在输入控制模块里直接跳过边界行让阵列只算有效窗口单层卷积延迟立刻下降。硬件设计里“省能源”和“省时间”往往是同一件事少算一个无效像素既省功耗又省延迟。第二个坑是INT8溢出的“隐形错误”。最初累加器只设了16位仿真时小图没问题换到真实车牌图像后识别错误率明显变高。追查后发现是某些高亮车牌区域的像素值叠加后超过了16位范围。把累加器扩到24位之后问题彻底消失。这个教训是位宽选择不能只按“大概”来必须离线把每一层激活的max绝对值统计完再加上安全余量。第三个坑是跨平台的DSP推断问题。在Vivado上综合好好的乘法器加累加器拿到Pango里综合却大量跑成LUT。查来查去根因是Pango的综合器对“乘法结果信号在组合逻辑里被使用”的写法推断不友好。我把乘法器输出显式加了一级寄存器之后两个平台都能准确映射到DSP硬核。第四个坑是逻辑分析仪对时序的干扰。我习惯在调试时挂chipscope或者logic analyzer观察脉动阵列内部信号。第一次全套在线调试时发现加上探针之后最高频率从250MHz掉到了180MHz。后面学乖了调试完成后正式上板版本必须把所有不影响功能验证的探针全部摘掉重新综合再跑一次时序收敛。6.3 这个方案后续还能怎么演进目前这套加速器只能跑车牌识别但脉动阵列本身的通用性远不止于此。最直接的扩展是把它复用去跑其他轻量级视觉任务比如人脸检测、车道线检测。只要换一套离线训练好的网络权重重新生成权重ROMRTL主体完全不用动。另一个方向是调大PE阵列规模。从8x8扩到16x16理论上算力翻四倍DSP资源压力也翻四倍。如果换成资源更充裕的器件或者做双阵列并行再配合更高分辨率输入跑个YOLOv5s级别的检测也不是不能想。就我个人体会脉动阵列这个架构在FPGA上最迷人的地方在于它几乎不依赖厂商IP整个核心逻辑就是几十个相同的PE叠在一起像搭积木一样。这种“简单结构的重复”恰恰是FPGA最擅长的事。如果你手头有车牌识别或者其他小模型低延迟推理的需求我想说的是别急着上GPU先拿脉动阵列试一试很可能会打开一扇新的大门。