做车牌检测识别这类项目,我一开始并没有直接奔着高端SoC方案去。客户给的条件很直白:摄像头画面进来,在板卡上完成车牌定位、字符识别,整个链路延迟要压到毫秒以内,而且FPGA不能挑品牌,Xilinx和紫光同创都要能跑。捋了一圈之后,我决定自己写一套纯Verilog的脉动卷积阵列加速器,把车牌检测识别里最吃算力的卷积部分全部落到脉动阵列上。这篇就好好拆一拆这个项目的整体思路、脉动阵列核心实现,以及双平台部署时那些容易翻车的细节。
这里先给个结论:车牌识别这个场景,用脉动阵列跑卷积,属于杀鸡用牛刀,但恰恰是这种“杀鸡用牛刀”的做法,才能换来极致的低延迟和确定性。整个识别链路在100MHz左右的时钟下,能做到单张图像毫秒级以内的处理,具体预算后面我会展开算。
1. 方案选型:为什么是脉动阵列,为什么坚持纯Verilog
1.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上综合好好的代码,挪到紫光同创的PDS(Pango 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{1'b0}}; psum_out <= {ACC_W{1'b0}}; 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行PE,9个卷积核位置中的前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,这个位宽不是拍脑袋定的,而是经过最坏情况推导的。
对于一层卷积,一个输出像素点的累加次数等于输入通道数乘卷积核大小。以我的网络第三层为例,输入通道数16,3x3核,一共有16*9=144次累加。8bit有符号数最大绝对值是128,两个相乘最大是16384,144次累加最大值约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替换
紫光同创这边用的是PDS(Pango Design Suite),目标芯片我选了PGL22G,性能够用,逻辑单元约22K,块RAM足够放下行缓冲和图像缓存。
迁移过程比想象中顺利,但也踩了不少坑。最大一个坑是BRAM原语名称不一样。Xilinx的Block Memory Generator生成的RAM在PDS里没法直接用,需要手动例化紫光同创的RAM IP核。好在PDS自带Memory Compiler,能生成类似功能的RAM,但接口时序跟Xilinx那边有细微差别,尤其是读延迟一拍和两拍的配置,必须对齐。我花了两天时间逐一核对读写时序,最后直接用了一个自定义的RAM包装模块,把两家原语的差异封装在内部,上层代码完全不用改。
时钟原语也不同。Xilinx用MMCM,PDS用的是PLL,配置方式类似,但命名和锁定信号极性有差异。我在顶层做了一个clock_gen模块,里面根据平台选择不同的原语分频倍频。
4.3 时钟、BRAM与约束差异对照
我把主要的平台差异整理成一个对照表,方便后来人参考。
| 对比项 | Xilinx | 紫光同创 | 注意点 |
|---|---|---|---|
| 综合工具 | Vivado | PDS | 两家综合策略差异大,需单独跑综合 |
| 时钟原语 | MMCM/PLL | PLL | 命名不同,locked信号极性可能相反 |
| BRAM IP | Block Memory Generator | Memory Compiler | 读延迟配置要仔细核对 |
| 约束文件 | XDC | PDC | 引脚约束写法类似但命令略有差异 |
| DSP单元 | DSP48E1 | 乘法器原语 | 乘法器推断规则不同,可能需要手工映射 |
XDC和PDC的约束写法差异是个隐性坑。Vivado里写set_property PACKAGE_PIN,PDS里语法不太一样,最稳妥的做法是在PDS工程里用图形化引脚分配,然后把生成的PDC文件导出来,后续改版就直接改这个文件。
4.4 跨平台仿真验证策略
我实际上没有单独的仿真平台,而是搭了一套基于Verilog的testbench,camera模型加上图像输入文件,两个平台都能跑。testbench里最关键的是把RTL代码和工具链生成的网表分开看。
仿真策略是:先在ModelSim里跑纯RTL仿真,验证功能正确性;然后分别在Vivado和PDS里跑综合后仿真,确认时序约束正确、没有毛刺。双平台仿真最大的价值在于提前发现RAM读延迟和时钟域处理的差异,避免板级联调时再去查。
5. 超低延迟的板级实现与优化实录
5.1 延迟预算的拆解与实测
项目一开始定的目标就是“超低延迟”。我把整个链路拆开算过一笔账。
核心流程:摄像头输出一帧640x480图像,经过预处理模块大约只有2-3行像素的流水延迟;车牌定位用的是投影法,需要统计整帧的投影数据,这部分延迟最大,约等于一帧图像的时间,在100MHz下大约是3.07ms;定位完成后,字符切分和识别是流水操作,单字符识别约0.1ms,7个字符约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的读写指针各用各的格雷码,这是最稳妥的做法。
第四个是综合工具的综合策略差异。同样的RTL,Vivado自动推断出的DSP乘法器可能比PDS多,导致PDS的资源不够。我的对策是在RTL里显式例化乘法器原语,做到两个平台资源消耗一致。
5.4 仿真与板级联调的常用技巧
板级调试最痛苦的是看不到内部信号。我把脉动阵列里关键的中间结果都引到了ILA(Integrated 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这类直接序列识别的网络,脉动阵列只需要相应调整数据流映射方式。无论如何,脉动阵列这个地基是稳的,往上扩展只是设计工作量的问题。