
做车牌检测识别这类项目我一开始并没有直接奔着高端SoC方案去。客户给的条件很直白摄像头画面进来在板卡上完成车牌定位、字符识别整个链路延迟要压到毫秒以内而且FPGA不能挑品牌Xilinx和紫光同创都要能跑。捋了一圈之后我决定自己写一套纯Verilog的脉动卷积阵列加速器把车牌检测识别里最吃算力的卷积部分全部落到脉动阵列上。这篇就好好拆一拆这个项目的整体思路、脉动阵列核心实现以及双平台部署时那些容易翻车的细节。这里先给个结论车牌识别这个场景用脉动阵列跑卷积属于杀鸡用牛刀但恰恰是这种“杀鸡用牛刀”的做法才能换来极致的低延迟和确定性。整个识别链路在100MHz左右的时钟下能做到单张图像毫秒级以内的处理具体预算后面我会展开算。1. 方案选型为什么是脉动阵列为什么坚持纯Verilog1.1 车牌识别的算力画像与延迟瓶颈很多人以为车牌识别很重要上大模型实际上不是。车牌区域在图像里尺寸有限字符结构规范一个轻量的CNN分类网络或者一个简化版的检测网络参数量在几十KB级别计算量通常在几十万到几百万MAC乘加运算之间。以我实际用的单字符识别网络为例输入是24x48的灰度图三个卷积层加两个池化层再接一个全连接层。第一层卷积1通道到8通道3x3核输出尺寸24x48第二层8通道到16通道输出12x24第三层16通道到32通道输出6x12。全连接从2304维降到128维最后映射到31个字符类别。我手算过这个网络的乘加总量第一层约8.3万MAC第二层约8.3万MAC第三层约2.1万MAC全连接约29.9万MAC合计约48.6万MAC。单个车牌7个字符全部识别完大概是340万MAC。这个量级放在GPU上毫无感觉但在FPGA上如果用通用的“乘法器阵列DDR搬运”方案延迟大头反而会耗在数据搬运和内存访问上。这也正是FPGA做车牌识别的核心矛盾计算量不大但对延迟极度敏感。车辆驶过道闸的瞬间识别结果必须立刻出来拖个几十毫秒可能就影响体验了。传统“摄像头采集→CPU跑算法→返回结果”的方案延迟至少几十毫秒还有一些不可控的调度抖动。脉动阵列的厉害之处在于数据进来之后所有乘加运算在PE之间直接流动不需要反复读写缓存几乎把片内带宽打满也没有调度抖动。这正好打在车牌识别场景的延迟痛点上。1.2 脉动阵列与传统MAC流水线的真实差异有些朋友问FPGA上用一排乘加器连起来的流水线跟脉动阵列到底有什么区别本质上它们都在做乘加但数据交互方式差异很大。传统MAC流水线每个乘加器都要从寄存器堆或者RAM里取数和取权重并行度越高对多端口RAM的依赖越重。到了8路、16路并行的时候BRAM端口不够用就得做多bank交叉访问逻辑复杂度和布线压力都上来了。时序收敛困难功耗也难看。脉动阵列的思路是让数据在PE之间像流水一样传递。每个PE只跟相邻的PE通信取数和取权重都是本地操作不会出现多个PE同时抢一个RAM端口的情况。用工业界的一个类比来说普通流水线是每个工位都去仓库领料而脉动阵列是传送带把料送到每个工位手上。后者没有总线冲突节奏稳定非常适合卷积这种规则运算。实际综合下来同样16个乘加单元脉动阵列的布线拥塞程度明显低于分散式乘加阵列在低端FPGA上也能轻松跑到150MHz以上。1.3 纯Verilog vs HLS我为什么坚持手写RTL项目启动前我内部做过一轮评估用Vivado HLS写CNN加速器开发速度快很多但最终放弃了原因有几个。第一双平台可移植性。项目要求同时支持Xilinx和紫光同创HLS生成的RTL高度依赖工具链的调度结果在Vivado上综合好好的代码挪到紫光同创的PDSPango Design Suite上很可能就变了个样子综合出来的时序完全不可控。手写RTL则不同代码层面的可移植性完全掌握在自己手里。第二延迟确定性。HLS的流水线调度由编译器决定遇到分支时可能产生气泡最坏延迟很难精确估算。手写RTL的每一拍延迟都是确定的这对车牌识别这种强实时场景非常重要。第三资源可控。车牌识别网络的卷积核很小3x3和1x1为主完全可以用固定结构的PE阵列来适配。手写RTL可以精确控制每一级寄存器的位置做到资源利用率最大化。当然代价是开发周期变长。整个脉动阵列加控制逻辑大概2000行Verilog前前后后调了一个多月。但换来的是代码在两家FPGA上都能稳定跑我觉得值。2. 系统架构从摄像头到字符输出的完整数据流2.1 顶层模块划分与信号流整个系统的数据流很清晰摄像头采集图像进入预处理模块完成灰度化、二值化、边缘检测接着是车牌定位和字符切分切分出来的字符图像交给脉动阵列加速的CNN分类器识别最后识别结果通过UART输出到上位机或者闸机控制器。顶层模块大致如下cmos_capture负责摄像头接口时序输出像素数据和行场同步信号。pre_process灰度化、Sobel边缘检测、二值化全部用流水线实现。plate_locate基于投影法和形态学操作定位车牌区域。char_segment在车牌区域内做字符切分输出单字符的像素流。systolic_cnn脉动卷积阵列加速的CNN推理引擎识别单字符。uart_tx识别结果输出。模块之间全部用流控接口连接数据准备好就拉高valid下游拉高ready表示可以接收。整个链路是全流水的模块之间不需要等待整帧图像处理完再启动下一级。2.2 图像预处理链的流水线设计预处理部分我没有用复杂的算法尽量在保证鲁棒性的前提下减少资源消耗。灰度化用简单的加权平均Y R0.3 G0.6 B*0.1这里只用到乘法和移位操作不引入浮点。Sobel边缘检测是典型的3x3卷积操作在FPGA上用行缓冲器实现。我用了两排行缓冲缓存当前行和上一行的像素配合当前行实时数据形成一个3x3窗口。每一拍窗口滑动一个像素输出水平和垂直方向的梯度幅值。二值化用的是固定阈值实际部署时先在上位机统计一版图像的直方图选取合适的阈值写进寄存器。如果想做自适应阈值Otsu也完全可以在FPGA上实现但会增加不少逻辑我这版先用固定阈值兜底。2.3 车牌定位与字符切分车牌定位我用的是投影法。车牌区域在二值化边缘图像中通常表现为一个水平方向连续、垂直方向有一定高度的矩形亮区。先做水平投影统计每行边缘像素数量超过阈值的行标记为候选区域再做垂直投影统计候选区域内每列边缘像素数量根据车牌宽高比筛选最终的车牌边界。字符切分也类似在车牌区域内做垂直投影字符之间会有明显的谷值按谷值切分就能得到单个字符图像。实际车牌中字符间距不均或者有边框干扰时我会加一个基于字符宽度的后处理把过宽的候选区域强制二分成两个字符。这些操作用Verilog实现并不复杂核心是两组计数器加阈值比较器延迟只有几十个时钟周期。3. 脉动卷积阵列的Verilog核心实现3.1 PE单元计算与流水脉动阵列的基本单元是PE。每个PE负责一次乘加运算输入特征值乘以权重累加到来自上一个PE的部分和上。我的PE设计如下。module pe #( parameter DATA_W 8, parameter ACC_W 32 )( input wire clk, input wire rst_n, input wire [DATA_W-1:0] act_in, // 输入激活值 input wire [DATA_W-1:0] w, // 权重预加载 input wire [ACC_W-1:0] psum_in, // 来自上侧的部分和 output reg [DATA_W-1:0] act_out, // 流向右侧的激活值 output reg [ACC_W-1:0] psum_out // 输出到下一级的部分和 ); wire [ACC_W-1:0] mul_result; wire [DATA_W-1:0] act_signed; wire [DATA_W-1:0] w_signed; assign act_signed act_in; assign w_signed w; always (posedge clk or negedge rst_n) begin if (!rst_n) begin act_out {DATA_W{1b0}}; psum_out {ACC_W{1b0}}; end else begin act_out act_in; // 激活值逐级传递 mul_result act_signed * w_signed; // 有符号乘法 psum_out psum_in mul_result; // 累加部分和 end end endmodule这个PE的时序很好理解每个时钟周期激活值从左边进来乘上权重加进来的部分和然后往下游传。权重信号w在推理过程中保持不变也就是所谓的“权值静止”工作方式。乘法和累加都用了寄存器打拍一方面提升时钟频率另一方面也符合脉动阵列“每一拍数据移动一个PE”的节奏。乘法器是组合逻辑放在always块外面或者里面综合效果差别不大但我在最终版本里还是单独用了一个wire来连接方便后续换成DSP单元。3.2 阵列互联权值静止、数据流动整个脉动阵列的互联方式可以用一句话概括激活值从左边流入经过每一列PE时乘以对应权重部分和从上边流入经过每一行PE时累加最后在底部收集完整结果。我采用的阵列规模是8x8共64个PE。对于一个3x3卷积层需要把卷积窗口展开成9个乘加位置。我做了个映射PE阵列的每一列对应一个卷积核权重位置共9列3x3后7列空闲每一行对应一个输出通道。这样一个时钟周期可以同时计算8个输出通道的9个权重位置相当于一次处理了72次乘加运算。这里有个细节很多初学者容易忽略脉动阵列的数据需要对齐。比如输入特征图是24x48卷积窗口在空间滑动窗口坐标每变化一次就要往阵列里推入一组新的9个像素值3x3窗口。如果阵列规模不够一次放完9个位置就得拆成多个cycle。我用的8x8阵列9列差一列最后是把输入通道方向拆分来补齐的逻辑上稍复杂一点但效果完全一致。以第一层卷积为例1通道到8通道3x3核。我把8个输出通道对应8行PE9个卷积核位置中的前8个对应前8列第9个位置额外用一个cycle处理。每个cycle里一行PE从左边接收同一输出通道对应的9个权重激活值从左边流入部分和在行内传递。这个过程重复8次就完成了1通道到8通道的完整映射。3.3 行缓冲与3x3窗口的生成脉动阵列吃的是3x3窗口数据而摄像头输入是逐像素流因此需要先把像素流转换成窗口流。我用行缓冲器解决这个问题。module window_gen #( parameter DATA_W 8, parameter IMG_W 640 )( input wire clk, input wire rst_n, input wire valid_in, input wire [DATA_W-1:0] pixel_in, output reg [DATA_W-1:0] window [0:2][0:2] );这个模块内部维护两行像素缓存每行宽度等于图像宽度。每当有效像素进入就写入当前行的尾部同时读出行缓存中上一行的对应位置。配合寄存器打拍最终形成3x3窗口的9个像素值。窗口生成是整个流水线里最容易出错的地方坑点在于行列对齐。像素进入窗口的时机与图像有效行的起止必须严格同步否则窗口会错位卷积结果全是乱的。我后来在仿真里专门做了边界检查确认窗口内每一行的像素坐标都符合预期。3.4 定点数设计与位宽推导车牌识别网络里我统一采用8bit有符号定点数表示输入特征和权重。累加器用32bit这个位宽不是拍脑袋定的而是经过最坏情况推导的。对于一层卷积一个输出像素点的累加次数等于输入通道数乘卷积核大小。以我的网络第三层为例输入通道数163x3核一共有16*9144次累加。8bit有符号数最大绝对值是128两个相乘最大是16384144次累加最大值约236万小于32bit有符号能表示的范围约21亿所以32bit累加器完全够用。即使未来网络换成大一点的输入通道数比如64通道、3x3核累加次数为576最坏情况下结果约943万32bit依然安全。我最终在整体输出前做了一步截取把32bit累加结果缩放回8bit。缩放因子是离线训练时统计出来的写进寄存器推理时直接右移加饱和处理。这里有个经验饱和截断一定要加否则遇到异常输入时特征值溢出会污染后续所有层的结果。4. Xilinx与紫光同创双平台部署实践4.1 Xilinx Vivado工程配置要点Xilinx端我用的是Vivado 2020.1目标芯片为Artix-7系列。工程搭建比较标准但有几个点值得单独说。时钟管理上输入摄像头时钟和系统时钟我用MMCM做了同步。脉动阵列本身是纯逻辑跑系统时钟但输入像素流来自摄像头时钟域中间加了异步FIFO做跨时钟域处理。这里我最深刻的教训是异步FIFO的深度一定要够至少是图像一行像素数的两倍。因为预处理模块一旦因为窗口对齐暂停一拍FIFO就会积压整行数据。BRAM的使用上行缓冲器和图像缓存都用Xilinx的Block Memory Generator IP。IP核的参数设置里要选择“Read First”模式避免读端口和写端口同时操作同一地址时的冲突。其实这个坑在PDS里也存在后面会讲。4.2 紫光同创PDS迁移与IP替换紫光同创这边用的是PDSPango Design Suite目标芯片我选了PGL22G性能够用逻辑单元约22K块RAM足够放下行缓冲和图像缓存。迁移过程比想象中顺利但也踩了不少坑。最大一个坑是BRAM原语名称不一样。Xilinx的Block Memory Generator生成的RAM在PDS里没法直接用需要手动例化紫光同创的RAM IP核。好在PDS自带Memory Compiler能生成类似功能的RAM但接口时序跟Xilinx那边有细微差别尤其是读延迟一拍和两拍的配置必须对齐。我花了两天时间逐一核对读写时序最后直接用了一个自定义的RAM包装模块把两家原语的差异封装在内部上层代码完全不用改。时钟原语也不同。Xilinx用MMCMPDS用的是PLL配置方式类似但命名和锁定信号极性有差异。我在顶层做了一个clock_gen模块里面根据平台选择不同的原语分频倍频。4.3 时钟、BRAM与约束差异对照我把主要的平台差异整理成一个对照表方便后来人参考。对比项Xilinx紫光同创注意点综合工具VivadoPDS两家综合策略差异大需单独跑综合时钟原语MMCM/PLLPLL命名不同locked信号极性可能相反BRAM IPBlock Memory GeneratorMemory Compiler读延迟配置要仔细核对约束文件XDCPDC引脚约束写法类似但命令略有差异DSP单元DSP48E1乘法器原语乘法器推断规则不同可能需要手工映射XDC和PDC的约束写法差异是个隐性坑。Vivado里写set_property PACKAGE_PINPDS里语法不太一样最稳妥的做法是在PDS工程里用图形化引脚分配然后把生成的PDC文件导出来后续改版就直接改这个文件。4.4 跨平台仿真验证策略我实际上没有单独的仿真平台而是搭了一套基于Verilog的testbenchcamera模型加上图像输入文件两个平台都能跑。testbench里最关键的是把RTL代码和工具链生成的网表分开看。仿真策略是先在ModelSim里跑纯RTL仿真验证功能正确性然后分别在Vivado和PDS里跑综合后仿真确认时序约束正确、没有毛刺。双平台仿真最大的价值在于提前发现RAM读延迟和时钟域处理的差异避免板级联调时再去查。5. 超低延迟的板级实现与优化实录5.1 延迟预算的拆解与实测项目一开始定的目标就是“超低延迟”。我把整个链路拆开算过一笔账。核心流程摄像头输出一帧640x480图像经过预处理模块大约只有2-3行像素的流水延迟车牌定位用的是投影法需要统计整帧的投影数据这部分延迟最大约等于一帧图像的时间在100MHz下大约是3.07ms定位完成后字符切分和识别是流水操作单字符识别约0.1ms7个字符约0.7ms。所以总体延迟大约在4ms左右如果摄像头输出的是30fps可以做到帧间无感。实际调试下来这个数字跟实测基本吻合。如果接下来想做更低延迟唯一的方向就是车牌定位不依赖整帧统计改成基于边缘密度的局部检测或者用脉动阵列直接跑一个轻量目标检测网络这样可以把延迟再压到1ms以内。这也是我后续计划尝试的方向。5.2 流水线切分与乒乓缓存预处理和CNN之间的数据衔接我用了乒乓缓存。两块SRAM交替存储一块在写当前帧数据另一块在读上一帧数据。这样CNN推理和图像预处理完全并行不会互相等待。乒乓缓存的地址生成有个细节两块RAM的地址总线不能复用必须用独立的地址计数器否则切换瞬间容易产生毛刺。我在实际调试中遇到过RAM数据错乱的问题最后发现就是地址总线复用导致的分开之后问题立刻消失。5.3 时序收敛实用经验时序问题是FPGA项目的老大难脉动阵列尤其明显。我踩过的坑和解决办法整理一下。第一个是PE之间的长路径。脉动阵列中激活值和部分和都要穿越多个PE如果每个PE的寄存打拍不够路径延迟会累加导致时钟频率上不去。解决办法是把乘法结果和累加结果分别打拍牺牲一个cycle的吞吐换来时序收敛。第二个是行缓冲器的RAM时序。行缓冲器如果直接用distributed RAM规模大了之后路径会很差。我换成了Block RAM之后时序立刻改善。代价是多一个cycle的读延迟需要在控制逻辑里补偿。第三个是跨时钟域。摄像头时钟和系统时钟不是同源的我最初用两级同步器处理跨时钟域信号结果还是偶尔丢数据。最后改成异步FIFO才彻底解决而且FIFO的读写指针各用各的格雷码这是最稳妥的做法。第四个是综合工具的综合策略差异。同样的RTLVivado自动推断出的DSP乘法器可能比PDS多导致PDS的资源不够。我的对策是在RTL里显式例化乘法器原语做到两个平台资源消耗一致。5.4 仿真与板级联调的常用技巧板级调试最痛苦的是看不到内部信号。我把脉动阵列里关键的中间结果都引到了ILAIntegrated Logic Analyzer上在Vivado里用VIO实时修改阈值参数大大加快调试速度。紫光同创那边对应的调试工具是内嵌逻辑分析仪用法类似只是名字不一样。仿真技巧上我用了一个临时方案把摄像头采集到的原始RGB数据直接转成文本文件用MATLAB/Python离线做一遍车牌识别算法把卷积层的中间结果导出再与FPGA仿真结果逐层比对。任何一层不一致都能快速定位到是PE运算错误、权重加载错误还是数据对齐错误。6. 常见问题与避坑速查6.1 部署中的典型问题现象可能原因排查方向识别结果错乱但仿真正确权重没正确加载检查权重ROM初始化文件和位序板级运行过程中偶发错误跨时钟域数据丢失检查异步FIFO深度和读写指针同步时序不收敛时钟提不上去PE路径过深给乘法/累加结果增加打拍两次运行结果不一致未初始化的RAM检查复位电路和RAM初值PDS平台资源不够乘法器推断差异手动例化乘法器原语掉电后识别失效权重加载顺序错检查上电装载时序6.2 车牌识别场景的几个心得这个项目做下来我最大的体会是车牌识别这类边缘AI场景核心不是算法多先进而是数据流的确定性。FPGA的优势在于可以把每一拍数据流转都精确控制脉动阵列更把这个特点发挥到了极致。如果要从零开始做类似项目我的建议是先花时间把数据流图画清楚模块接口定义好再开始写PE。千万不要一上来就写脉动阵列的代码。阵列本身逻辑简单复杂的是数据怎么送进去、结果怎么取出来这些必须在架构阶段想明白。最后再分享一个小技巧脉动阵列的权重加载和推理可以用同一套PE硬件只是控制信号不同。权重加载时把输入看作权重流关闭累加功能推理时权重静止数据流动。复用一个PE阵列能省下不少逻辑资源。这个项目后续的扩展方向也很多。比如把车牌定位也改用脉动阵列加速的目标检测网络彻底去掉传统CV的整帧投影延迟或者把单字符识别网络换成LPRNet这类直接序列识别的网络脉动阵列只需要相应调整数据流映射方式。无论如何脉动阵列这个地基是稳的往上扩展只是设计工作量的问题。