FPGA脉动阵列加速车牌识别:纯Verilog实现与双平台低延迟部署 停车场道闸弹起来的那零点几秒很多人不会想到里面有一块FPGA在同时管着车牌检测和识别。这块FPGA用纯Verilog实现了一个脉动卷积阵列加速器目标是在Xilinx和紫光同创两个平台上都跑通把整个车牌识别链路的延迟压到毫秒级以下。这篇文章不是算法论文复现而是把我这一版完整过程整理出来从RTL设计到阵列映射、从图像链路到双平台部署重点放在“为什么这么做”和“实际会遇到什么”适合正在规划FPGA视觉加速方案或者想在小面积FPGA上跑小型CNN推理的朋友。先说一个反直觉的结论车牌检测与识别这类任务真正的延迟瓶颈往往不在CNN计算本身而在“等待一帧图像完整到达”和“反复访问DDR”。传统摄像头加ARM SoC的方案只在帧中断后才开始处理一帧720p60fps就是11.1ms再接一个几十毫秒的检测/识别进程总延迟轻松上到100ms以上。而FPGA脉动阵列配合行流式处理理论上可以从有效像素进入芯片开始边采边算把延迟压到亚毫秒到几毫秒区间。这也是我选择纯Verilog而不是上软核或者HLS的核心理由——每个周期的行为都可预测时序模型一目了然。1. 一个“反常规”的选型逻辑车牌识别为什么要走FPGA脉动阵列1.1 传统方案的延迟去哪了停车场道闸、高速门架、移动稽查这些场景最影响体验的就是“车牌已经进入画面但系统迟迟不给结果”。传统路径是Sensor到SoC内存再到CPU跑算法最后外设输出延迟主要由四个部分吃掉帧同步等待算法必须等整帧视频在DDR里落下720p60fps一帧就是11.1ms如果是30fps则要等33msDDR带宽抖动系统里同时有编码、显示、多路视频时内存访问延迟不稳定算法耗时也会跟着毛刺算法框架开销OpenCV加DNN或OCR引擎预处理、缩放、NMS、字符库匹配都是不小的开销操作系统调度和协议栈算法本身可能只有40ms经过任务切换、缓冲区拷贝、串口或网络发送最终表现会放大到一两百毫秒这套方案在市电充足、环境友好的系统里没有问题但在路侧机箱这种高温高振动的场合以及需要把设备尽量做小做便宜的硬件项目里短板就很明显。所以我从一开始就决定不依赖DDR缓存整帧也不跑Linux让摄像头像素流直接进FPGA逻辑用硬件流水线完成从检测到识别的一整条链路。1.2 为什么是脉动阵列而不是HLS或者现成NPU做FPGA视觉加速通常有三条路HLS高层综合、软核加专用IP、纯RTL阵列。我最终选了纯Verilog的脉动阵列核心原因是项目要同时部署到Xilinx和紫光同创两个平台HLS生成的RTL跟工具版本、DSP推断策略绑定很深换一套综合工具就可能重来而纯手写RTL对资源的控制粒度最细行为也完全确定。现有NPU IP又是另一回事。商用NPU IP授权贵小容量FPGA里往往塞不下而且车牌识别这种小网络用不上大算力硬上NPU反而浪费资源。脉动阵列的本质是一组规则排列的乘加单元只有两种数据流结构极端规整时序收敛容易资源占用可以提前估算。对于字符识别CNN这种几百万MAC的小网络一个8x8或16x16的阵列完全够用还不会把芯片面积撑爆。1.3 两个平台的芯片选型怎么定我手上的板卡一块是Xilinx Artix-7 XC7A35T另一块是紫光同创Logos-2系列。选型的评估维度不是单纯比逻辑规模而是看三个资源DSP切片数量脉动阵列的每个PE至少消耗一个硬核乘法器8x8阵列就是64个还要留一部分给其他滤波处理所以DSP数量是第一约束BRAM容量行缓冲、权重缓存、字符归一化缓存都占BRAM小芯片的BRAM很宝贵要提前按行宽和网络参数估算I/O Bank能不能直接接摄像头DVP接口对引脚约束简单MIPI则需要硬核或高精度逻辑选芯片时最好直接用带DVP/RGB输入能力的Bank实际综合下来8x8脉动阵列加整条图像链路LUT占用约8k到10kDSP占64到72个BRAM占40到60个块两边都有余量。如果网络再大一点就要考虑16x16阵列那时资源占用会明显上升小封装的Logos芯片就开始紧张了。2. 脉动阵列的核心数据流权重驻留、激活平移与部分和级联的细节2.1 最基础的一个PE长什么样脉动阵列的基本单元叫PEProcessing Element每个PE做的事情本质上只有一个乘加运算。这句话听起来简单但真正把它写对、约束好、在板级不跑偏还是有一些细节。下面是我在项目里用的PE模板位宽按INT8激活和INT8权重设计累加结果用32bitmodule pe #( parameter A_W 8, parameter W_W 8, parameter P_W 32 )( input wire clk, input wire rst_n, input wire acc_clr, // 部分和清零 input wire [A_W-1:0] act_in, // 激活输入来自左边PE input wire [W_W-1:0] wt, // 驻留权重 input wire [P_W-1:0] psum_in, // 部分和来自上一行PE output reg [A_W-1:0] act_out, // 激活右移输出 output reg [P_W-1:0] psum_out // 部分和下行输出 ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin act_out {A_W{1b0}}; psum_out {P_W{1b0}}; end else begin act_out act_in; if (acc_clr) psum_out $signed(act_in) * $signed(wt); else psum_out psum_in $signed(act_in) * $signed(wt); end end endmodule这里最容易被忽略的是两个$signed。如果不显式做有符号乘法综合器会把INT8的负数权重当成无符号数处理卷积结果全错。另一个细节是acc_clr必须与数据完全对齐PE里有寄存器打拍外部控制信号也得跟着打同样拍数否则会出现“清早了”或者“清晚了”的问题这在后面板级调试时是一个大坑。2.2 三种主流数据流选型我们都试过最后用WS脉动阵列有三大经典数据流权重驻留Weight Stationary, WS、激活驻留Input Stationary, IS、输出驻留Output Stationary, OS。理论论文里三个都讲得很美但落到车牌识别场景我只推荐WS。WS的操作方式是权重提前加载到PE的寄存器里之后不再移动激活数据从阵列左侧进入每个周期向右“平移”一格部分和从阵列上方进入每个周期向下累加。这样做的直接好处是一个卷积核的权重被反复使用几十上百次外部存储带宽压力极小。车牌识别网络的权重在识别阶段是固定的不存在训练侧那种权重频繁更新的场景所以WS是最贴合业务的。IS和OS也有它们的主场IS适合激活数据复用率高但权重频繁变化的结构OS适合输出特征图特别大、需要减少部分和搬移的结构。但对车牌这种“小图、小网络、权重静态”的场景WS的调度逻辑最简洁控制状态机最少在资源紧张的FPGA上更容易收敛。2.3 卷积窗口怎么映射到阵列上脉动阵列不是直接吃二维图像的它要吃“向量序列”。所以3x3卷积需要先把滑窗展开成9个数再按顺序送入阵列。这个展开动作在软件里叫im2col在硬件里我建议直接用行缓冲加移位寄存器实现不要去DDR里倒腾。// 三行缓冲 3x3滑窗展开示意 always (posedge clk) begin if (pix_valid) begin line_buf_0 pix_in; // 当前行 line_buf_1 line_buf_0; // 上一行 line_buf_2 line_buf_1; // 上上行 win_col0 line_buf_2; win_col1 line_buf_1; win_col2 line_buf_0; // 再经过两级寄存器得到3x3窗口的9个像素 end end窗口展平后9个像素作为激活输入从左侧流入阵列3x3卷积核的9个权重展开后驻留在对应PE里。因为是多通道输入不是把每个通道摊在不同PE列而是把同一窗口内不同输入通道的权重排在同一行的不同PE最后把部分和累加得到输出特征图的一个像素。这个调度细节决定了整体数据吞吐建议在写RTL之前先在纸上推演两遍。2.4 位宽、量化与资源估算整个识别链路里图像灰度化后是8bit卷积权重量化到INT8中间激活用ReLU截断到0到255部分和保持32bit累加最后输出前再截断回8bit。量化我用的方法是离线统计权重分布按min/max映射到[-128, 127]没有做复杂的per-channel量化因为车牌字符灰度差异大简单的全局量化在实测里损失不到1%的识别率却省了大量逻辑。资源估算方面8x8阵列仅PE本身就要64个DSP加上权重预加载通道、激活分发网络、部分和汇聚逻辑LUT和寄存器消耗大概是PE本身的1.5到2倍。选Artix-7 35T没问题但选紫光Logos时一定要看清具体型号的DSP硬核数量有些小封装的Logos只有几十个DSP想跑16x16阵列就得换芯片。经验是做这类项目先把资源估算表做出来再选芯片别拿到板卡再发现放不下。3. 从“裸阵列”到“车牌识别系统”周边模块比阵列本身更难3.1 整条链路模块划分阵列只是乘法发动机真正决定项目能不能落地的是周边配套。我的完整链路是摄像头DVP接口接收像素灰度化自动曝光和亮度校正中值滤波Sobel边缘检测行投影定位车牌候选区字符分割字符归一化最后送进脉动阵列跑字符分类CNN输出ASCII码结果。这个链路里脉动阵列只占了识别阶段的一块其他大量工作都是图像处理和状态机控制。因为所有模块都工作在像素流上最怕的是某个模块产生反压导致流水线断流。所以每个模块之间的握手我统一成valid/ready协议并且规定上游一旦拉高valid数据必须在一拍内稳定下游忙时上游必须暂停。这套约定比任何总线协议都好用纯Verilog也好实现。3.2 行流式预处理怎么不浪费资源灰度化直接用移位近似灰度值等于(R77 G150 B*29)右移8位三个系数用加法器实现不额外消耗DSP。中值滤波我这里用的是3x3窗口的排序网络9个像素需要三级比较器LUT开销大约几百个但效果比均值滤波好得多光照突变时不会把边缘糊掉。Sobel卷积用两个3x3系数固定可以单独做成两个小卷积器不需要挂到脉动阵列上因为预处理的算子固定、权值简单单独做更省控制逻辑。这里有一个经验预处理模块不要试图和CNN共用脉动阵列。表面上看省了乘法器但预处理时钟和数据格式跟CNN不一致调度起来极其痛苦最后往往为了省几十个DSP牺牲了一整条流水线的稳定性。预处理单独用几个LUT-based乘法器CNN独占阵列反而整体资源最省。3.3 检测与分割的逻辑实现车牌定位我没有用大的检测网络而是走传统视觉路线Sobel边缘图做水平投影找出车牌上下边界再做垂直投影利用车牌字符连通域密度高、背景相对干净的特点切出左右边界。这套逻辑在晴天、阴天、夜晚补光下都够用关键是投影计算要在行流式过程中实时累加不要等整帧存下来再统计。垂直投影表可以用一个Block RAM存下来宽度为图像行宽每个有效像素往对应列累加。当一行结束时把投影表交给定位状态机分析。字符分割本质是在投影表上找“峰谷”我实现成一个有限状态机从连续高值区间进入低值区间时记录字符起点从低值区间回到高值区间时记录字符终点。分割完成后每个字符块会被缩放到统一尺寸比如32x24之后进入CNN识别。3.4 识别网络在阵列上的调度字符识别网络我压到很小输入32x24灰度图第一层3x3卷积1通道到16通道第二层3x3卷积16通道到32通道然后接一个全局平均池化最后全连接层输出34类省份简称加数字字母。整个网络权重大约60KBINT8量化后不到60KBBRAM完全可以放下不需要外挂存储。调度策略是字符分割完成一个立即启动一次CNN推理不需要等整张车牌的所有字符全部分割完。这样第一个字符的识别结果输出时最后一个字符可能刚开始分割端到端延迟又进一步下降。脉动阵列在这个网络里要处理的总乘加量大约600万次按100MHz、8x8阵列每秒6.4G MAC的算力纯计算时间不到1ms。实际上因为数据搬移和控制开销我实测在0.3到0.8ms之间浮动但已经远好于CPU方案。4. Xilinx与紫光同创双平台部署一次“原语层”的迁移实站4.1 平台抽象层与宏封装双平台移植最容易踩的坑是把工具链特有的原语直接写死在RTL里。我的做法是设计一个platform.vh头文件用宏开关切换两边的原语和时钟资源ifdef XILINX // Xilinx DSP48E1 / MMCM / XPM_FIFO elsif PGL // 紫光同创 DSP / EH_PLL / IPM_FIFO endif所有跨平台模块比如时钟生成、FIFO、乘法器都以这个头文件为唯一入口。RTL主体只调用封装好的宏或者函数不允许直接例化厂商原语。这样两边综合时只需要改宏定义主体代码完全不动降低了移植回归的测试成本。要注意的是宏定义要放在综合设置里不要写在某个模块内部否则万一多个模块重复定义容易出怪问题。4.2 原语差异对照表把两边的差异列出来会更清晰资源类型Xilinx 方案紫光同创方案备注DSP乘法器DSP48E1/E2 切片或(* use_dspyes *)厂商DSP原语或乘法器推断紫光对宽度匹配要求更严格时钟管理MMCM/PLL原语EH_PLL/OSC原语两边都不能直接互换FIFOXPM_FIFOIPM_FIFO或厂商RAM原语深度参数定义不同BRAM初始化$readmemh支持良好部分综合版本对初始化文件格式敏感建议用通用RAM模板绕开逻辑分析仪Vivado ILAPDS自带逻辑分析仪触发条件语法有差异乘法器这块最值得留意。Xilinx的Vivado对Verilog乘法推断很激进写一个a*b它恨不得直接用DSP但紫光同创的PDS在某些版本里会优先推断成LUT导致时序直接崩盘。解决办法是写一个带宏封装的乘法器模块Xilinx平台用(* use_dspyes *)紫光平台显式例化DSP原语保证两边都真正用到硬核。4.3 三个容易翻车的点第一是复位风格。Xilinx官方建议尽量用同步复位减少全局复位树的压力紫光PDS对异步复位的处理没有Vivado那么成熟如果代码里到处是异步复位时序收敛时会出现莫名其妙的路径超标。我最终两个平台都统一用同步复位只在最顶层保留一个异步复位用于上电初始化。第二是约束文件格式。Xilinx用XDC紫光用FDC/PDS自家的约束格式引脚约束命名规则也不同。我写了一个Python脚本把一份CSV引脚表同时生成两边的约束文件避免手工翻译出错。第三是下载和调试流程Xilinx用Hardware Manager直接下载bit紫光要用PDS的Programmer工具转换文件时要注意版本匹配否则容易遇到固件不识别的问题。5. 超低延迟是怎么“量”出来的时延模型与看板实测5.1 先把“延迟”定义清楚很多项目标榜低延迟但不同人说的延迟根本不是一个东西。我这边的定义是从有效像素进入FPGA的输入引脚到结果寄存器更新完成这中间的时钟周期数。这个定义不包含机械动作时间也不包含网络传输时间是纯硬件加速链路的延迟。由于项目采用行流式处理不缓存完整图像理论上不需要等一帧结束才开始处理。只要摄像头输出前若干行边缘检测、投影统计就能开始车牌候选区域一旦被定位字符分割和CNN识别就立刻启动。所以延迟基本不受帧率限制而是和“要攒够多少行才能定位车牌”强相关。5.2 时延拆解与实测数据我整理过一份实测时延表基于720p60fps输入、100MHz阵列时钟的环境链路阶段需要等待的数据量实测时延DVP接收与灰度化1个像素约100ns中值滤波与Sobel3行像素约60us投影定位与候选区确认约30到40行约800us字符分割与归一化候选区内若干行约200usCNN字符识别单字符完成即可约300us到800us结果输出最后一个字符识别完约10us整条链路的端到端延迟大概在1.2ms到2ms之间。对比传统摄像头加Linux加OpenCV的100ms级别这个数字有数量级优势。需要提一句IP核版本、像素时钟、网络深度都会影响具体数字但行流式架构带来的延迟优势是结构性的不会因为硬件参数微调而消失。5.3 ILA与紫光逻辑分析仪的在线测量技巧测量延迟不能靠感觉。我在关键模块里打了几个触发标记pix_start表示有效像素开始plate_located表示定位完成seg_done表示字符分割完成cnn_done表示识别输出有效。用厂商自带的逻辑分析仪分别抓这些信号看它们之间的周期差。判断延迟是否达标我习惯在每条流水线的尾部加一个只读的时间戳寄存器记录从系统复位到某个模块done的周期数。这个寄存器在仿真里看是理想值板级看是真实值两者一对比就能快速定位异常。如果板级比仿真慢很多大概率是某个握手信号反压过多用逻辑分析仪抓valid/ready就能看出来。5.4 超低延迟能成立的条件要维持这种低延迟有三个前提条件。第一个是全链路无DDR访问权重、行缓冲、中间结果全部放在BRAM里DDR的页切换延迟和仲裁抖动不会进入关键路径。第二个是时钟策略统一阵列工作时钟和像素时钟用同源PLL避免跨时钟域频繁握手。第三个是CNN推理不等待全帧只要字符块准备好就立即开始这是把延迟从“帧级”降到“流水线级”的最关键一步。6. 从RTL到现场仿真能过但板级必现的几个坑6.1 部分和清零时序错位仿真一切正常上板后第一行输出乱码后面行又正常。排查后发现是acc_clr信号没有跟着激活数据一起打拍导致PE内部的流水寄存器把清零命令提前消费了。外层控制逻辑认为已经清了实际阵列里还在用旧的部分和。这类问题在纯RTL仿真里很难暴露因为RTL仿真默认寄存器时序理想但在板级异步输入的微小偏差会被放大。修复方式是把所有控制信号像数据一样做成“打拍链”acc_clr经过和激活数据完全相同的寄存器级数后再送给PE。我后来写了一个辅助宏把控制信号强制对齐到数据通路的valid链上一劳永逸。6.2 DVP摄像头像素时钟不稳定板级偶尔花屏用逻辑分析仪看是某些行像素错位。DVP的PCLK来自摄像头晶振和FPGA内部时钟不是完全同源如果输入延迟约束没做IDELAY没有调到最佳采样点HSYNC/VSYNC边沿抖动就会被当成有效像素处理。这里要做三件事一是对PCLK和同步信号做输入延迟约束让工具正确评估引脚到达时间二是在FPGA内部把VSYNC/HSYNC先经过两级同步器打拍去毛刺三是加一个帧有效状态机当VSYNC间隔超出正常范围时自动复位行计数器防止错位持续累积。6.3 乘法器被推断成LUT时序崩盘在Xilinx平台上一切正常换到紫光同创后综合报告多出几千个LUTFmax从120MHz掉到80MHz。一看综合报告DSP使用量为0原来PDS没有把8bit乘8bit的乘法推断成DSP而是全部用LUT实现了。这不是代码逻辑问题是不同综合器对乘法推断的策略不一样。解决方法是自己写一个mul_s8模块内部用宏区分平台Xilinx加(* use_dspyes *)属性紫光直接例化厂商DSP原语。替换后DSP使用量恢复正常Fmax回到120MHz以上。之后我把所有可能被推断成LUT的乘法运算都收口到这个模块里不再散落在各处。6.4 BRAM读写冲突导像素错位行缓冲用BRAM实现时如果读地址和写地址在同一拍指向同一地址部分FPGA的BRAM在写优先模式下会出现读出旧值或新值不稳定的现象。这在仿真里不容易测出来因为仿真器对BRAM行为做了理想化处理。我最终把行缓冲全部换成FIFO模式也就是XPM_FIFO或者IPM_FIFO的First-Word Fall-Through模式天然支持一读一写不同地址从架构上避开冲突。如果只能用双端口BRAM就强制让读地址和写地址至少相差2个周期同时在读写控制逻辑里加一个空满标志避免数据错位。这些坑排查到最后你会发现本质上都是三件事控制信号与数据时序对齐、跨平台原语匹配、存储资源读写策略。我现在的习惯是把这三条列成检查清单每换一个平台就对着清单过一遍。如果你也在做小体积低延迟的车牌识别或者其他视觉加速项目建议一开始就把跨平台原语封装和行流式延迟模型想清楚后面的返工会少很多。