做车牌检测识别,最怕的不是算法不够花哨,而是延迟压不住。停车场闸机前那几秒,车停稳、摄像头抓拍、识别、抬杆,整个过程但凡多犹豫一下,后面就是一串喇叭声。这个项目里我用纯Verilog搭了一套脉动卷积阵列加速器,没有调现成的IP核,也没用HLS,硬是在Xilinx和紫光同创两家的FPGA上把车牌检测和识别的完整流程跑通了,端到端延迟做到几十毫秒级别。整套东西的思路、RTL实现细节、跨平台移植的坑和性能优化过程,我在这篇文章里完整摊开,给准备在FPGA上做AI加速的同行当个参考。
1. 车牌识别场景的算力账单:为什么非得用专用加速器
1.1 从抓拍到抬杆:延迟预算怎么分配
停车场入口的完整动作链大概是这样的:车辆驶入、地感线圈触发、摄像头抓拍、图像传给处理单元、检测车牌位置、识别车牌字符、查库比对、抬杆放行。传统方案里,图像处理一般放在工控机或者服务器上,摄像头到主机之间哪怕走的是千兆网,一帧720p图像也有几毫秒的传输和拷贝开销。要是再赶上主机负载高、进程调度抖动,识别延迟从100ms飙到几百毫秒很正常。
FPGA的优势在于数据不需要上CPU、不需要经过DDR反复搬运,摄像头进来的数据直接在片上流转。但FPGA也有自己的问题:它本质上是一个"搭积木"的硬件平台,用错了架构,资源利用率低、时序收敛困难,最后可能还不如一颗跑着OpenCV的ARM芯片。
所以这个项目的第一个设计决策很明确:把整个识别流程全部放在FPGA内部,不借助额外处理器。我的延迟预算是这样拆的:图像采集和预处理5ms以内,检测网络30ms以内,车牌区域裁剪和识别网络10ms以内,结果输出1ms以内,总共控制在50ms左右。这个预算对标的是"司机基本感觉不到等待"的体验,同时给系统的后续扩展留出余地。
1.2 通用处理器为什么跑不动:两个硬瓶颈
很多人一开始会问:车牌识别而已,用ARM加OpenCV不是也能做吗?能,但要看计算量。车牌检测如果用一个轻量级卷积网络,输入320x240灰度图,总共大概1亿多次乘加运算。听起来不多,但在一颗300MHz的ARM处理器上,即使编译器做了很好的优化,每个乘加平均也要几个甚至十几个周期,再加上内存访问、循环控制的开销,跑完整个网络得上百毫秒。
这里面的第一个瓶颈是"冯诺依曼墙":数据和指令都要从内存取,卷积运算的访存密度又特别高,缓存命中率稍有波动性能就掉一截。第二个瓶颈是"并行度不足":ARM核心再多也就四个八个,而卷积运算本身是高度并行的——不同输出通道之间、不同空间位置之间互不依赖,这种并行度需要大量计算单元同时发力才能榨干。
FPGA恰好在这两点上都是强项:数据在片上流动,不走通用内存总线;逻辑阵列可以同时铺开几十上百个乘加单元。但FPGA也不是随手写写就能发挥威力的,真正把性能做上去,需要一个规整的、数据复用率高的计算架构。脉动卷积阵列就是为这种场景准备的。
1.3 脉动卷积阵列的直觉理解
脉动阵列这个概念听起来高深,其实拿流水线来类比就很好懂。想象一条流水线,每个工位只干一件事——把从上游传来的半成品加上自己手里的一份材料,再传给下游。工位之间不需要来回取料,每个人都专注在自己的岗位上,原料从头流到尾,成品就从另一端源源不断出来。
对应到卷积计算:每个处理单元就是一个"工位",它内部寄存着一个权值,输入数据从阵列的一端流入,经过每个PE时做一次乘加运算,部分积累加结果在阵列中流动。整个过程数据局部性极好,每个输入像素会被多个滤波器反复使用,但这种反复使用全部发生在片内,不需要反复从内存读。同时脉动阵列结构非常规整,全部由重复的PE单元组成,非常适合用Verilog这种RTL语言描述,逻辑清晰、便于规模扩展。
2. 整体架构:检测识别两级流水,共用一块加速阵列
2.1 一个阵列跑两个网络:复用而不是堆硬件
车牌识别全流程实际包含两个神经网络:前面是目标检测网络,负责从整幅图像里找到车牌的位置框;后面是识别网络,负责把裁剪出来的车牌图像映射成字符序列。如果给每个网络各配一套加速器,资源翻倍不说,逻辑复杂度也上来了。
我的做法是设计一个统一的脉动阵列计算核心,通过一个指令调度器在运行时切换网络参数。检测网络跑完,得到车牌框坐标后,同一块阵列再加载识别网络的权重,继续跑识别推理。这么做的好处是FPGA资源利用率高,在Xilinx和紫光同创的中等规模器件上都能放下,同时调度逻辑也不复杂——本质就是一个状态机,当前网络执行完毕后,切换权重加载指针和输入数据源。
整个系统数据流是这样的:摄像头并行输入先经过灰度化和缩放预处理,写入双帧缓冲;检测阶段从帧缓冲A读图像,输出车牌框坐标;根据坐标从帧缓冲B中截取车牌区域,作为识别网络的输入;识别结果经过解码后通过UART或以太网输出。帧缓冲设计成双缓冲是为了实现乒乓操作——摄像头正在写入bank A的时候,加速器可以读取bank B的数据,互不干扰。
2.2 数据通路设计:绕开DDR的片上方案
设计初期我认真考虑过要不要加一颗DDR3做帧缓存,毕竟很多FPGA图像处理方案都离不开DDR。但仔细一算发现,车牌识别的输入分辨率根本不需要做到1080p,640x480的灰度图足够用了,一帧才307KB。而常见FPGA的片上BRAM资源有几百KB到几MB,完全能够容纳两到三帧灰度图。
于是整个数据通路完全绕开DDR,图像数据从摄像头进来后直接写入片上BRAM。这个决策对延迟的贡献极大:省去了DDR读写带宽竞争,也省去了DDR控制器的复杂逻辑,更重要的是端到端延迟变得极其可控——每个模块之间的握手信号是确定的,不会出现DDR仲裁导致的随机延迟。
行缓冲(Line Buffer)也是数据通路里的关键模块。卷积运算需要用到一个3x3的滑动窗口,而图像数据是按行连续输入的。行缓冲的作用就是缓存两行完整数据,配合当前行数据,拼出三行数据后再取3x3窗口。这个模块用移位寄存器或者小容量RAM实现,逻辑不复杂,但很容易写错,后面我会专门讲踩坑经验。
2.3 量化方案:INT8够不够用
神经网络权重默认是FP32浮点格式,但FPGA做浮点运算资源开销巨大。车牌识别这个场景对量化非常友好,因为车牌字符的边缘、纹理特征是强结构信息,不像自然图像那样对噪声和高频细节那么敏感。
我采用的是常见的INT8对称量化方案:先在校准集上统计每一层激活值的绝对值最大值,算出缩放系数scale,再把浮点权重和激活值映射到[-127, 127]区间。卷积计算过程中,乘加累加器保持32位精度,避免中间结果溢出,每层输出经过ReLU和量化后再回到8位。这样做的精度损失在车牌数据集上实测不到0.5%,完全在可用范围内。
这里有一个容易被忽略的细节:累加器尽管是32位,卷积层的累加次数是有限的。如果中间结果超过INT32范围,就需要提前截断或者重新安排量化策略。我用了一个宏定义来配置累加器位宽,初期设成40位多留裕量,综合发现问题后再裁剪,确保不会出现溢出导致精度崩掉的情况。
3. 纯Verilog实现脉动阵列:核心模块逐个拆
3.1 PE处理单元怎么写:一段可以被综合器吃透的RTL
脉动阵列的基本单元就是PE,麻雀虽小五脏俱全。单个PE内部需要做三件事:锁存自己的权值、接收输入数据并传下去、将输入与权值相乘并累加部分和。这里我贴一段核心代码,这是整个项目里所有PE的骨架:
module pe #( parameter DW = 8, // 数据位宽 parameter AW = 32 // 累加器位宽 )( input logic clk, input logic rst_n, input logic w_load, // 权值加载使能 input logic [DW-1:0] w_in, // 从上游输入的权值 input logic [DW-1:0] act_in, // 从上游输入的激活值 output logic [DW-1:0] act_out, input logic [AW-1:0] psum_in, // 上游部分和 output logic [AW-1:0] psum_out ); logic [DW-1:0] w_reg; logic [AW-1:0] psum_reg; always_ff @(posedge clk or negedge rst_n) begin if (!rst_n) begin w_reg <= '0; psum_reg <= '0; end else begin if (w_load) w_reg <= w_in; act_out <= act_in; // 激活值打拍向下游传递 psum_reg <= psum_in + $signed(w_reg) * $signed(act_in); end end assign psum_out = psum_reg; endmodule这段代码有几个关键点。第一,权值寄存器只有在w_load信号有效时才更新,计算阶段权值保持不动,这就是典型的"权值固定"数据流。第二,乘法结果直接加到部分和上,累加器宽度要比乘数宽度宽得多,防止溢出。第三,act_out在时钟沿后输出等于当前拍的act_in,相当于数据在阵列中每经过一个PE就延迟一个时钟周期,实现自然的数据流水传递。
我建议所有乘法都写成$signed()显式有符号乘法,避免综合器把INT8当作无符号数处理,否则负权重会算出完全错误的结果。
3.2 数据流控制:权重加载、输入流动、结果排空
搭建好单个PE后,难点转移到整个阵列的数据流控制。一个16x16的脉动阵列,在实际跑卷积时不能简单地把图像怼进去就完事,它需要三个明确的阶段:LOAD_WEIGHT、STREAM_IN和DRAIN。
LOAD_WEIGHT阶段把当前卷积层所有滤波器的权值卸载到PE阵列内部寄存器。这一阶段常被新手忽视,但权值加载是耗时大户。假设一个卷积层有64个输出通道、32个输入通道、3x3卷积核,总权重数就是64x32x9=18432个。如果每个周期加载一个PE,加载完这些权重需要18432个时钟周期。在150MHz时钟下就是123微秒,听着不长,但网络有几十层,累积起来就非常可观了。
STREAM_IN阶段才真正开始做卷积计算。输入特征图从行缓冲生成滑动窗口后,窗口数据按照脉动阵列的节奏送入第一行PE,部分和在阵列中向下累加。这个阶段必须严格保证数据到达时间和PE计算节拍一致,否则序列完全错位,输出结果就是错的。
DRAIN阶段则是把阵列里还没流出的部分和全部排空。我最初的设计没有显式处理排空,导致最后一行卷积结果永远算不完,后来在状态机里加了计数器,在输入结束后多等PE阵列深度个时钟周期,问题才解决。
3.3 配套模块:行缓冲、ReLU、池化和量化
脉动阵列只是"发动机",周边配套模块决定它能不能正常运转。行缓冲模块负责从图像帧缓存里读数据,拼出3x3窗口;ReLU激活在数字电路里其实就是判断符号位,负数清零,一个二选一多路选择器就搞定;最大池化做一个两两比较的树形结构;量化模块做饱和截断或者四舍五入。
这些模块单独拎出来都很简单,难的是它们的时序要和脉动阵列完全对齐。我是用计数器统一管理整个流水线的节拍:图像行计数器、窗口内偏移计数器、输入通道计数器、输出通道计数器,每个计数器的溢出条件都预先在仿真里验证过。如果你也打算自己写,强烈建议先把这些计数器的时序图画出来再写代码,不要直接上手Verilog,否则大概率会在仿真阶段被各种边界条件折磨。
4. Xilinx与紫光同创双平台落地:纯Verilog的隐藏优势
4.1 两个平台的差异:从EDA工具到底层原语
这个项目的目标平台从一开始就是两个:Xilinx的7系列/Zynq系列,以及紫光同创的FPGA。很多FPGA工程师会习惯性地大量使用厂商IP核,比如Xilinx的FIFO IP、Block Memory Generator、MMCM时钟IP。这种做法在单一平台开发时效率很高,但要做跨平台移植就非常痛苦——Vivado生成的IP核在紫光同创的Pango Design Suite(PDS)里完全无法使用,只能重新生成对应平台的IP,而两边的接口参数、复位策略、时钟约束都不完全兼容。
项目强调"纯Verilog"的价值就在这里:所有逻辑模块全部用行为级RTL编写,不依赖任何厂商特定的IP原语。FIFO自己写,RAM自己写,时钟模块封装成独立的wrapper,内部才根据平台例化不同的时钟原语。这样做的代价是前期的开发量更大,但换来的是代码在两个平台之间可以无缝综合,省去了移植时大量的返工。
实际操作中两边的差异主要体现在这几个方面:Xilinx用Vivado和XDC约束,紫光同创用PDS和PDC约束;Xilinx的DSP原语是DSP48E1/E2,紫光同创有自己的DSP单元结构;块RAM的容量和排列也不同,但这些都是物理资源层面的差异,逻辑代码本身不受影响。我只需要把"平台相关"的部分收敛到几个顶层模块里,其余80%的RTL代码两边完全一致。
4.2 移植实操与资源、时序评估
具体移植时,我的做法是建两个工程,共享同一套RTL源文件,只有平台适配层不同。平台适配层包含三部分:时钟生成、复位同步、调试接口。时钟生成在Xilinx下例化MMCM,在紫光同创下例化它自己的PLL,封装成统一的clk_gen接口;复位逻辑统一用同步复位加异步断言的方式,避免两家工具的复位树综合差异;调试接口在Xilinx上走ILA,在紫光同创上用PDS的逻辑分析仪。
资源占用方面,拿我最终使用的8x16=128个PE阵列举例,每个PE对应一个乘加单元,映射到两个平台都需要约128个DSP单元。Xilinx Zynq XC7Z020有220个DSP,资源占用58%;紫光同创同等规模逻辑的器件也基本够用。BRAM主要用于帧缓冲和权重存储,我分配了约1.5Mbit的BRAM资源。整体LUT占用在40%左右,时序在150MHz约束下两个平台都能收敛。
关于工作频率,我一开始在Xilinx上跑到200MHz很轻松,但同样的代码放到紫光同创器件上,200MHz就时序违例了。后来分析发现主要是因为两家的DSP和BRAM布局布线特性不同,最终把目标时钟统一调整到150MHz,两个平台都能稳定收敛。这种事只有双平台同时做才会碰到,如果只盯着一家器件,可能根本不会注意到综合器对代码结构的偏好差异。
5. 超低延迟优化实录:把延迟一毫秒一毫秒抠出来
5.1 端到端延迟分解:瓶颈往往不在乘法器
我花了很长时间优化延迟,最后发现一个反直觉的结论:脉动阵列本身的乘加计算只占端到端延迟的一小半,真正吃时间的反而是穿插在计算周围的各种"等待"。我把自己实测的一帧数据做了个延迟分解,大概是这样:
- 图像采集和灰度/缩放预处理:5ms
- 检测网络推理:约18ms
- 车牌区域裁剪和预处理:3ms
- 识别网络推理:约6ms
- 结果编码和输出:1ms内
总耗时约32ms。其中的检测网络推理18ms包含了每层的权值加载、计算、排空、以及层间切换的流水线气泡。如果只算纯计算时间,128个PE在150MHz下的峰值算力是19.2GMAC/s,而我这个检测网络大约1.5亿次MAC,理论计算只要8ms左右。多出来的10ms基本都消耗在权值加载和流水线等待上。
这个差距说明了一个道理:对于中小规模的加速器来说,性能瓶颈不是算力不足,而是数据供给速率和调度效率。如果你也在调类似系统,建议先做延迟分解,别一上来就堆PE数量。
5.2 我踩过的三个性能坑
第一个坑是权值加载时间被严重低估。最初的实现里,每个卷积层之间串行执行:当前层计算完、把结果写回缓存、然后才开始加载下一层的权重。每一层平均增加120微秒左右的加载时间。解决办法是给权重存储做双缓冲:当前层正在计算时,下一层权重已经在另一个缓冲区里加载完毕,层切换时直接切换数据源,几乎零开销。这个优化让检测网络的推理时间从27ms降到了18ms。
第二个坑是批次归一化(BN)层在推理时的融合。最初网络里每一层卷积后面都跟了单独的BN计算模块,用BRAM存均值和方差参数,算完还要再做一次乘法缩放。后来我把BN参数融合到卷积层本身的权重和偏置里,在量化阶段就把融合做完,FPGA上完全不需要单独的BN模块,省掉的不仅是计算时间,还有BN参数占用的存储和搬运开销。
第三个坑是边界处理。车牌图像经过多次卷积后特征图尺寸会缩小,而卷积本身需要padding才能保证边缘像素参与计算。我之前图省事,直接在行缓冲里把边界值固定为0,结果输入图像边缘的车牌边框特征计算错误,检测率掉了一截。最后还是在输入特征图进阵列之前,由行缓冲控制器根据坐标判断是否处于边界,边界处补零、否则透传数据,问题才算解决。
6. 调试经验与常见问题速查
6.1 仿真验证三板斧:从单元到系统逐层推进
脉动阵列这类高度规则的结构,调试起来有一个好处:一旦PE本身验证正确,整个阵列的行为就基本可控。我的验证策略分三步走。第一步是单元验证,单独仿真PE模块,喂几组已知的权值和输入值,断言计算结果的正确性,这一步能同时验证有符号乘法和累加逻辑。第二步是小规模阵列验证,用4x4的阵列搭配已知权重,验证数据流方向和流水节拍是否正确。第三步才是整机验证,把阵列、行缓冲、量化模块、网络调度器全部连起来,用仿真图像跑完整网络,与软件模型逐层对比输出。
所有验证模块都用SystemVerilog断言和testbench完成,纯Verilog项目的好处是仿真不依赖任何厂商工具,ModelSim、Vivado Simulator、开源的iverilog都能跑,非常灵活。
6.2 上板调试:把"看不见"的状态拉出来
上板调试和仿真完全是两回事,很多在仿真里永远不会出现的问题,在上板后都会暴露出来。我遇到过的几个典型问题整理成了一张速查表,方便大家排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 输出全是0 | 权值加载失败或使能信号没拉起来 | 检查权值加载状态机,抓LOAD_WEIGHT阶段的完成信号 |
| 图像有重影或错位 | 行缓冲写地址和读地址错拍 | 打印行缓冲读写指针,和理想时间轴对比 |
| 检测框整体偏移 | 卷积padding逻辑有误,边缘特征计算错误 | 核对边界判断条件,仿真中强制检查边缘像素路径 |
| 偶发时序错误 | 跨时钟域信号没做同步 | 检查所有跨时钟域握手信号,补两级寄存器同步 |
| 精度和软件模型偏差大 | 累加器溢出或量化scale不准 | 逐层对比中间特征图,定位最大偏差层 |
上板调试时逻辑分析仪是必备工具。我的经验是把"层完成标志"、"帧结束标志"、"量化输出有效"这几个关键信号全部拉出来,用状态机配合计数器定位问题出现的具体帧和具体层,这样可以把随机性问题缩小到具体环节,效率远高于瞎猜。
6.3 一个关于跨平台的独门建议
最后分享一个我觉得特别重要的经验:跨平台项目千万别等一个平台全部调通了再移植到另一个平台。我的做法是代码开发阶段就保持两个平台的综合脚本同步更新,每次大改动后,两个平台各跑一次综合和时序分析。这样做的好处是能尽早发现"只在某个平台能综合"的代码风格问题。比如某些for循环在Vivado里能正确展开,在PDS里可能因为综合策略不同导致逻辑膨胀,这类问题越早发现,返工成本越低。
写在最后的体会
这套系统做完之后,我最大的感受反而是轻舟已过万重山。脉动阵列、纯Verilog、双平台部署,每一项单独拿出来都不算新鲜,但把它们组合在一起解决一个具体的实时场景,中间还是有不少朴素的工程智慧。如果回到项目初期,我会更早地做延迟分解,更早地做权重双缓冲,更早地正视两个平台之间的工具链差异。每个环节单独看都是小问题,攒在一起就可能让整个项目延期。这套方案后续还可以扩展的方向很多,比如把阵列规模加大、加入多核并行、扩展到其他实时检测任务。但就车牌检测识别这个场景来说,一块中等规模的FPGA、一套纯RTL实现的脉动阵列,已经能交出足够让人满意的"超低延迟"答卷了。