做完这个项目两三个月了我一直在琢磨要不要把全过程写下来。原因很简单市面上能同时跑通Xilinx和紫光同创的纯Verilog卷积加速方案几乎找不到可参考的细节大部分人都被HLS或者厂商自带IP绑住了。这次我们用纯Verilog手写了一套脉动卷积阵列加速器用在车牌检测和识别场景上把端到端的推理延迟压到了几十微秒这个量级。整个过程踩了不少坑也积累了一些实打实的经验这篇文章就把架构设计、部署差异、调试实录一次讲清楚适合正在做FPGA视觉加速、想了解脉动阵列、或者有国产FPGA迁移需求的朋友当参考。1. 项目全貌与核心设计思路1.1 为什么坚持纯Verilog而不是HLS说实话最开始我们也认真评估过Vivado HLS、Vitis AI甚至想过直接调Xilinx的DPU IP。但深入比了几轮后还是决定全部手写RTL用纯Verilog实现。最主要的原因是低延迟场景下HLS的高度抽象反而成了包袱。HLS的PIPELINE指令虽然能帮你把循环流水化但它对硬件结构的映射是黑盒综合后实际生成了多少级流水、数据在哪个周期对齐、资源被调度到哪个Bank这些信息都不够透明。一旦要针对具体时序去做微调HLS的修改-综合-验证周期远不如直接改RTL来得痛快。脉动阵列这种有严格数据流动方向的结构手写RTL可以直接把每个数据在哪个时钟周期到哪个PE映射到代码里综合后的关键路径完全可预期。另一个原因是资源控制粒度。车牌识别网络不大但卷积层的通道数、特征图尺寸都要精确匹配FPGA上的DSP和BRAM。用HLS生成出来的逻辑通常偏大尤其在做小尺寸定点量化时HLS对移位、截断的处理往往不如手写灵活。代码规模方面整个项目RTL大概9000行加上验证脚本不到两万行。对于有清晰架构设计的团队来说这个量级完全可维护。当然这不是说HLS一无是处如果是大规模算法快速原型验证HLS效率确实高。但在“超低延迟”这个约束下纯Verilog能给到更细的控制粒度这是它不可替代的理由。1.2 为什么一定要上脉动阵列传统CPU/DSP做卷积的路径是先从内存把输入数据和权重搬到ALU旁边算完再搬回去。数据搬运的延迟和功耗占了很大比重。脉动阵列的思路完全反过来它在每个PE里都放上寄存器、乘法器和加法器输入数据一旦进入阵列就像水在管道里流动一样按照固定节奏从上游PE流向下游PE每个PE在数据经过时顺手完成一次乘累加。这种结构的好处有两层。第一层是数据复用相邻PE之间共享输入激活和权重外部存储器的访问次数被大幅削减这对小容量FPGA的BRAM带宽非常有意义。第二层是吞吐率阵列里所有PE在同一个时钟沿工作宏观上可以在一个周期之内完成几十甚至上百次乘加运算。我们用32个PE的阵列跑车牌识别网络150MHz时钟下等效算力约4.8 GOPs处理一张几十像素的小图计算延迟就是几十微秒量级。这里顺便回应一个问题为什么不用乘法器树乘法器树适合做单次向量的乘加比如全连接层的一次推理。但卷积是三维滑动窗口的重复计算脉动阵列可以把整个卷积循环的中间结果留在片内流动不像乘法器树那样每次运算都要把中间结果搬进搬出。在视觉任务上脉动阵列才是更贴合计算形态的硬件结构。1.3 车牌检测识别场景的硬件需求拆解做车牌识别第一反应可能是一股脑上大模型。但真实场景有个特点目标高度固定无非是蓝底白字、黄底黑字、绿底白字字符排列规范类别数量有限。这种先验决定了系统不需要对整帧高清图像做复杂检测用“颜色粗定位轻量CNN验证识别”的级联方案更合理。我们把整条链路拆成几个关键模块输入采集720p/1080p视频流通过MIPI或并行接口进FPGA先做灰度化和降采样得到适合硬件处理的小尺寸灰度图车牌粗定位利用车牌底色在YUV颜色空间内的分布特征通过像素级阈值判定和连通域聚集快速找出候选框候选区域精修对候选框做裁剪、缩放、归一化送到CNN验证网络排除误检字符识别对验证通过的车牌区域做定长切分逐字符分类最终输出超低延迟的可读结果。真正的卷积加速器只负责后面两个阶段它们处理的数据量小、网络结构浅计算时间非常短。粗定位虽然处理的是全图但用的只是比较器和行缓存逻辑简单延迟几乎可以忽略。这套任务拆解才是“超低延迟”的根本来源单纯堆算力反而是最笨的办法。2. 脉动卷积阵列的硬件架构逐层拆解2.1 单个PE的计算内核乘累加怎么在一个周期内完成先从最底层的PE说起。PE是脉动阵列的基本单元我习惯把它理解成一个“带寄存器的乘累加站”。每个PE有四个方向的输入输出输入激活、权重输入、部分和输入以及对应的三个输出再加上时钟和复位。module systolic_pe #( parameter DATA_WIDTH 8, parameter ACC_WIDTH 24 )( input wire clk, input wire rst_n, input wire [(DATA_WIDTH-1):0] act_in, // 经过本PE的输入激活 input wire [(DATA_WIDTH-1):0] w_in, // 经过本PE的权重 input wire [(ACC_WIDTH-1):0] psum_in, // 上游传进来的部分和 output reg [(DATA_WIDTH-1):0] act_out, // 传递给下游PE的激活 output reg [(DATA_WIDTH-1):0] w_out, // 传递给下游PE的权重 output reg [(ACC_WIDTH-1):0] psum_out // 累加后的部分和 ); wire signed [(ACC_WIDTH-1):0] act_ext; wire signed [(ACC_WIDTH-1):0] w_ext; wire signed [(ACC_WIDTH-1):0] mul_out; wire signed [(ACC_WIDTH-1):0] acc_out; assign act_ext {{(ACC_WIDTH-DATA_WIDTH){act_in[DATA_WIDTH-1]}}, act_in}; assign w_ext {{(ACC_WIDTH-DATA_WIDTH){w_in[DATA_WIDTH-1]}}, w_in}; assign mul_out act_ext * w_ext; assign acc_out psum_in mul_out; always (posedge clk or negedge rst_n) begin if (!rst_n) begin act_out {(DATA_WIDTH){1b0}}; w_out {(DATA_WIDTH){1b0}}; psum_out {(ACC_WIDTH){1b0}}; end else begin act_out act_in; w_out w_in; psum_out acc_out; end end endmodule这个PE的要点是乘法结果和上游部分和在同一个组合逻辑内完成相加然后在时钟沿统一打拍输出。这样做的好处是所有PE之间的路径都被寄存器隔开组合逻辑只存在于单个PE内部不会出现一个信号穿透几十个PE的长路径这是后续时序收敛的基础。乘法器的位宽扩展要注意符号问题。输入数据默认是8bit有符号定点数乘法结果扩展成24bit是为了给累加器留余量。车牌识别网络几乎全程使用ReLU激活所以部分和理论上都是非负数但中间层的偏置可能导致负值统一按有符号处理最稳妥否则会出现“算出来全是对的、输出全变成0”这种让人头疼的问题。2.2 一维脉动阵列的数据调度权重预载、输入巡游、部分和累积有了PE之后第二层问题就是怎么把它们组织起来。我们最终用的是32个PE组成的一维阵列而不是更吸引眼球的二维阵列。二维阵列适合大矩阵乘法但车牌识别网络的卷积层通道数不多用二维阵列会造成大量PE空洞利用率反而低。一维阵列加上时延控制在小型网络上能做到更紧凑的调度。一维脉动阵列跑3x3卷积的基本流程分三步。第一步把卷积核的9个权重预载到对应的PE里做好静态配置。第二步输入图像像素按照从左到右、从上到下的顺序一个接一个进入阵列。第三步每个PE把当前输入像素和预载权重相乘再加上从上游传来的部分和结果形成新的部分和继续往下游传。这样说比较抽象用一个8x8灰度图、1个3x3卷积核的例子来描述。当第一个输出像素需要计算时硬件同时需要(0,0)到(2,2)共9个像素。如果输入数据是逐行扫描的那么我们需要等第0、1、2行分别进入行缓冲后才能凑齐第一窗口。这也正是行缓冲在下一节存在的意义。数据准备好后9个像素在时间上错开进入阵列每一拍进入一个像素和对应位置的权重相乘。从第4拍开始部分和就能依次从阵列尾部输出。也就是说阵列尾部拿到第一个完整结果只需要几个周期这就是“数据流动”带来的低延迟特性。权重预载方面要考虑多输出通道的情况。车牌识别网络卷积层输出通道通常是16、32我们的做法是把多个输出通道的权重按组存入BRAM然后通过一个权重调度状态机在空闲间隙预载下一组权重实现通道间的无缝切换避免等待周期。2.3 行缓冲与滑动窗口图像卷积的数据喂给方式脉动阵列解决了“数据在阵列内部流动”的问题但还有一个前置问题怎么把二维图像转换成适配阵列的输入序列。直接去外部RAM随机读取9个像素是不可能的那会让访存变成性能瓶颈。标准解法就是行缓冲也叫Line Buffer。行缓冲的原理可以类比成三行抽屉。图像按行流式进入第一行先填满第一抽屉第二行填满第二抽屉第三行边进边和前面两行组合产生第一行输出的3x3窗口数据。每来一个新像素窗口向右滑动一格第三抽屉的新像素进来第一抽屉的对应像素被丢弃。这样每个输入像素周期都能产生一个卷积窗口不需要任何额外等待。在Verilog实现上行缓冲一般用BRAM做三个行缓冲各存放一行图像数据配合一组移位寄存器实现3x3窗口的组织。注意行缓冲的写地址和读地址必须圆滑递增到了行尾要生成行同步信号让窗口清空避免换行时把上一行末尾的像素串到下一行开头。// 行缓冲模块输入流式像素输出3x3窗口 module line_buffer #( parameter WIDTH 8, parameter H_SIZE 128 )( input wire clk, input wire rst_n, input wire [WIDTH-1:0] din, input wire valid, output wire [WIDTH-1:0] win_r0_c0, win_r0_c1, win_r0_c2, output wire [WIDTH-1:0] win_r1_c0, win_r1_c1, win_r1_c2, output wire [WIDTH-1:0] win_r2_c0, win_r2_c1, win_r2_c2 ); // 三行移位窗口寄存器 // 每拍valid有效时将新像素挤入窗口右端窗口整体左移 // 行切换时由行结束信号控制底层数据从BRAM行缓存装载 endmodule滑动窗口这件事本身和“滑动窗口滤波Verilog”是同一个套路无非滤波器的系数变成了卷积核的权重。理解了行缓冲之后再来做均值滤波、高斯滤波、Sobel边缘检测代码框架几乎不用改换一组系数和窗口大小就行。2.4 阵列尺寸与DSP资源的平衡阵列尺寸不是越大越好。我们最初在仿真里尝试过64个PE的方案发现性能提升不到20%但DSP消耗翻倍BRAM作为权重缓存也吃紧完全得不偿失。后来用“最小满足算力法”来确定阵列规模。具体做法是先把车牌识别网络每层的乘加次数统计出来除以系统帧率目标得到需要的有效MAC/s。再除以工作时钟频率得到并行度要求。比如网络单帧总乘加次数约1.2M MACs目标帧率25fps那么有效算力需要30M MAC/s。在150MHz时钟下每个PE每秒能做150M MACs理论上1个PE满负荷就够。但考虑行缓冲带来的空转、权重切换产生的气泡、多通道并行的需要实际上取32个PE比较稳妥既能展开多输出通道又能在小图内一条流水线吃完。资源占用方面以我们用的Xilinx Zynq-7020为例32个PE消耗约32个DSP48E1行缓冲和权重缓存消耗约86块36Kb的BRAMLUT消耗约三万。在紫光同创Logos系列中端型号上DSP数量需要裁剪到24个左右我们把部分卷积层改成分时复用方案在一个PE里通过状态机完成两次乘加牺牲少量时间换取资源可控。注意阵列规模一定要在写RTL之前确定不要等代码写完了再改。脉动阵列的调度逻辑和PE数量强耦合临时增减PE状态机和地址生成器都要跟着大改这个教训我们试过一次就不再犯了。3. 车牌检测识别整体流水线设计与实现3.1 从像素到结果整条流水线的划分整个系统从外部看是一条完整流水线视频像素流进来过几个模块最终从输出接口吐出车牌号码。但内部是分阶段并行处理的我在设计时把流水线切成六段。第一段是图像采集与预处理。MIPI或并行接口进来的Bayer/YUV数据先做灰度化再用双线性降采样把720p降到640x360以内。灰度化用公式0.299R0.587G0.114B为了避免浮点用整数移位近似。第二段是颜色粗检测在YUV空间里对蓝色、黄色像素做阈值比较生成二值图。第三段是连通域分析对二值图做快速游程编码把相邻的目标点连成候选块。第四段是候选框精修对候选块坐标做归一化送进第一个CNN网络验证网络排除非车牌的干扰目标。第五段是车牌识别网络对验证通过的车牌区域做字符切分和分类。第六段是结果输出把7位字符的类别编码通过并口或FMC接口发给MCU显示。这样拆的好处是前三级处理是在像素流中完成的不需要等整帧结束数据自然的流水下去。真正停下来等一整帧的只有粗检测模块里的连通域分析但它的计算只是像素比较和地址合并几百个周期就出结果。3.2 检测网络设计小分辨率单尺度回归检测网络我们做得比较轻核心目的是“精修候选框”而不是从头找车牌。既然颜色粗检测已经把区域缩小到几个候选块验证网络的输入就是一个小尺寸灰度图比如24x8网络只有两层卷积加一层全连接输出4个坐标回归值和1个置信度。训练这个网络比想象中简单我们用PyTorch把车牌图像按照不同光照、模糊程度做了数据增强正样本是真实车牌负样本是路面、护栏、车身等颜色接近的干扰块。训练到验证集精度97%左右就够用了因为后面的识别网络还会对坐标做进一步约束。训练完成后把权重导出成8bit有符号整数每组权重用$readmemh读入FPGA的BRAM初始化文件或者通过串口在启动时加载。这里有个容易被忽视的点训练时的输入分辨率要和硬件一致。很多人训练时用64x64的图硬件端却把候选框缩放到24x8精度下降不明显但坐标回归的分布完全不一样了。我们后来把训练时的预处理脚本和硬件缩放参数统一效果明显回升。3.3 识别网络设计与字符切分策略车牌字符识别部分我们经历了两次迭代。第一版用投影法做字符切分思路是统计每一列的像素密度找出字符之间的空白间隔来分割。代码写起来简单但实际场景里车牌有螺丝钉、锈迹、光线不均投影曲线的谷底经常被噪声填平切分错误率很高。后来放弃投影法改成固定等宽切分。理由很朴素中国车牌有标准物理尺寸字符间距固定只要定位到车牌左右边界按固定宽度切成7段即可。车牌定位由检测网络输出精度足够。每段10x20灰度图送入一个小CNN做40类分类省份汉字31类英文字母24类部分字母数字合并输出字符类别编号。识别网络的结构是输入10x20灰度第一层3x3x16卷积加2x2池化第二层3x3x32卷积加全局平均池化最后接40路全连接。整个网络每帧只需要对7个字符分别推理总乘加量很小。在实际的FPGA实现中7个字符的分类可以并行调度同时喂给7组PE让字符识别的尾部延迟压缩到“最慢那组分类结果”的时间而不是7倍时间。3.4 超低延迟的量化拆解“超低延迟”四个字说出来容易落到数据上要能让人信服。我们最终用ILA在板卡上实测了一组数据延迟定义是“候选框自动锁定后从最后一个输入像素进入识别网络真面目到7位字符结果出现在输出寄存器”的时间。处理阶段周期数150MHz下对应时间候选框数据搬入行缓冲5123.4 us验证网络三层推理6204.1 us车牌区域裁剪与归一化2561.7 us识别网络30层计算230015.3 us结果与置信度输出80.05 us合计369624.6 us这个24.6us是从候选框锁到结果输出的尾部延迟在实际应用里已经接近“所见即所得”。如果按整帧从进入到识别完成的端到端来算720p视频大约需要6.8ms这是因为得等整帧像素扫描完才能做全图粗检测但任务级的延迟和计算级延迟在这里是两回事。做硬件加速时一定要分清这两个指标否则很容易对自己的系统产生错误预期。4. 跨平台移植实战Xilinx与紫光同创FPGA部署4.1 资源与时钟约束的初始评估跨平台部署的第一件事不是写代码而是拿着器件手册评估资源和时序。Xilinx这边我们用的Zynq-7020资源足够重点考虑时钟结构。脉动阵列跑多少频率决定了PE之间的流水深度。我们一开始在Vivado里综合到250MHz时序差一点没过后来把关键路径的乘法器做了额外打拍稳定收敛在200MHz。但紫光同创的目标器件综合结果不太乐观关键路径能跑到160MHz左右再往上FF路径就会变红。我的建议是如果同时部署多个平台RTL设计阶段就按性能较低的平台来定约束。我们把全系统统一约束到150MHz这样两边的时序报告都非常健康而且还能留出余量给布局布线的波动。不要追求某个平台跑到极限跨平台项目的一致性比极限性能更重要。资源上也要按资源少的器件做预算。Xilinx器件上我们用了32个DSP在紫光同创的中端型号上DSP数量不够需要用LUT乘法器替代部分PE。我们写了一个可综合的乘法器选择宏ifdef USE_LUT_MUL // 使用移位加法实现8bit乘法 // 代价是组合逻辑变大但可以节省DSP资源 else // 直接例化厂商DSP原语或使用乘号 endif这样同一份RTL可以通过编译命令切换乘法实现方式不需要改算法层的调度代码。4.2 Xilinx工程搭建与关键原语使用Xilinx这边用Vivado 2020.2开发环境在Linux上启动会遇到glibc库依赖问题但这个在官方论坛有现成解决方案装对应版本的库文件就能跑起来。真正花时间的是时钟原语和配置Flash的适配。脉动阵列需要多路时钟像素时钟、阵列时钟、MCU接口时钟。我们统一用Xilinx的MMCM原语生成关键例化代码如下MMCME2_ADV #( .CLKIN1_PERIOD(20.0), // 输入50MHz .CLKFBOUT_MULT_F(8.0), // VCO 400MHz .CLKOUT0_DIVIDE_F(4.0), // 输出100MHz .CLKOUT1_DIVIDE(8) // 输出50MHz ) mmcm_inst ( .CLKOUT0(clk_array), .CLKOUT1(clk_pixel), . );配置Flash用的是华邦W25Q128在Vivado里不需要额外写驱动只要在Generate Bitstream后选择Memory Configuration把SPI总线设为x4模式时钟频率降到25MHz左右下载一次bit流后续就能开机自动加载。这里有个经验SPI时钟不是越高越好W25Q128在四线模式下虽然规格书标称能跑104MHz但FPGA配置逻辑的时序余量通常不稳定用50MHz以下最省心。4.3 紫光同创PDS平台移植过程与差异对比紫光同创的PDS开发环境界面和Vivado非常像熟悉Vivado的工程师几乎零成本上手。但实际踩坑主要在细节上。最大的差异是原语命名。Xilinx的MMCME2_ADV、BUFG、IBUFDS在紫光同创里分别对应不同的原语名称用的时候不能直接把Xilinx代码拿过来综合。我们的处理方式是写一个平台无关的时钟包装模块把所有厂商原语封装在内部上层逻辑看到的接口只有clk_sys、clk_pixel和rst_n。这样核心RTL完全不用改。另一个差异是SystemVerilog支持度。PDS较老版本对always_ff、logic关键字可能只支持一部分报错信息又不够直观折腾起来很费时间。我们的经验是跨平台项目统一用Verilog-2001子集规避掉所有SV语法糖宁可多写几行也不让综合器挑毛病。紫光同创的bit流固化和Xilinx流程也略有不同。PDS软件内置了配置存储器的生成向导支持SPI NOR Flash我们同样用的W25Q128配置模式和Xilinx基本一致只是下载工具换成了PDS自带的Programmer。整体来说从Xilinx迁到紫光同创大概花了三周其中一半时间在改原语封装和调整资源分配另一半时间在等布局布线结果和修时序。4.4 与STM32H743的FMC控制通路在整机系统里FPGA不是独立的计算盒子它需要把结果传给MCU做上层业务。我们选了STM32H743作为主控用FMC总线完成通信这也是一个被问得很多的方向。FMC可以把外部并行存储器接口映射到ARM内存地址空间MCU访问FPGA内部寄存器就像读写SRAM一样简单。FPGA侧只需要实现一个异步从设备接口以地址锁存、读写控制、数据总线为核心。握手时序可以参考标准SRAM接口但在FPGA内部要注意异步信号的同步问题读数据时做好采样窗口对齐。MCU端通过类似下面的代码完成一次读操作uint16_t result *(volatile uint16_t *)0x60000000;我们已经把FMC片选地址映射到Bank1的起始地址FPGA内部通过地址译码把不同偏移量映射到不同寄存器。比如偏移0x00是控制寄存器0x02是状态寄存器0x04到0x10是7个字符的识别结果。这种方案接口简单、吞吐率足够实测单次读写耗时在几十纳秒内对结果上报这种低频操作绰绰有余。5. 验证、调试与问题排查实录5.1 用Testbench和仿真把好第一道关脉动阵列这种强时序结构直接上板调试会很痛苦所以仿真工作一定要做扎实。我们用的是QuestaSim加手写testbench配合Python生成测试向量。仿真的第一个层次是单PE。给固定的输入激活和权重检查乘法累加结果是否正确符号位有没有丢失。这个层级最容易发现位宽问题。第二个层次是单层网络仿真用随机权重和随机输入跑一遍和Python参考模型做逐周期比对。第三个层次才是整机流水喂一幅真实的720p车牌图像连续跑完粗检测、验证、识别输出应该正好对上车牌号码。testbench里面有几个小技巧。第一个是用任务封装图像读取$readmemh把十六进制像素文件读入内存然后按行按列送入DUT。第二个是加断言检查部分和输出一旦与参考模型不一致立刻打印当前层号、行号、列号和期望值实际值。第三个是保存仿真波形文件的信号子集只导出关键信号避免波形文件动辄几个GB。initial begin // 读图像文件 $readmemh(input_image.hex, img_mem); // 送入DUT for (row 0; row H_SIZE; row row 1) for (col 0; col W_SIZE; col col 1) begin (posedge clk); din img_mem[row * W_SIZE col]; valid 1b1; end end5.2 上板调试中遇到的典型问题仿真过了不代表上板就稳这里记录几个我们实际踩过的坑。第一个是数据溢出问题。8bit输入乘8bit权重结果最大约6502524bit累加器理论不会溢出。但ReLU之后的特征图我们为了省字节又重新截断成8bit这时候直接把高位砍掉会导致严重的信息丢失。后来改成先饱和截断再存储也就是大于127的都记作127小于-128的都记作-128精度损失小了很多。第二个是行缓冲的换行毛刺。在行尾切换缓冲行时窗口寄存器里残留了上一行末尾的像素导致输出出现一条垂直的噪声线。排查了很久才发现是行结束信号没有同步到窗口平移逻辑后来在行结束信号上做了两级寄存同步问题消失。第三个是跨时钟域问题。像素时钟和阵列时钟不同源行缓冲的读写跨域时如果只有一个异步FIFO偶尔会出现像素错位。我们在行缓冲两端各加了一个小深度的异步FIFO再配合像素有效信号做握手基本解决了偶发错位。第四个是关于复位。原本用的是全局异步复位但PE数组里几百个寄存器复位释放时间略有不一致导致状态机偶发进入非法状态。统一改成同步复位模块后复位树归一化这个问题再没有复现。5.3 跨平台性能差异与优化手段同一份RTL在Xilinx和紫光同创上的综合结果有非常明显的差距这也符合预期毕竟工艺和架构不同。Xilinx跑150MHz非常轻松关键路径余量有3ns左右。紫光同创相同约束下余量只有0.3ns布局布线稍有波动就可能变负。针对第二平台的时序优化我们做了三件事。第一是给乘法器输出打一拍虽然增加了一级流水延迟但对吞吐率几乎无影响关键路径因此缩短了约1ns。第二是把部分和累加器从32bit改成24bit减少加法器进位链长度。第三是控制每根权重的扇出数量权重缓存输出的信号同时喂给16个PE扇出过大影响布局复制成多份后时序明显改善。经验跨平台项目千万不要等Xilinx跑通了才开始考虑第二个平台应该在RTL编码阶段就制定好“平台无关核心厂商相关包装”的分层策略。时钟、复位、I/O这些跨平台必改的逻辑全部收进wrapper核心算法层只使用标准Verilog这样迁移成本能降低一半以上。5.4 从项目复盘中沉淀的通用经验做一个项目如果只留下一堆代码价值少了一半。把过程中的判断和方法沉淀下来才算真正完成闭环。第一低延迟的关键是任务级流水不是单纯提高时钟。我们系统里的峰值算力并不高但通过把颜色粗检测、候选框精修、字符识别三级流水组织起来让每一步都在数据到达的瞬间处理完尾部延迟才能控制在几十微秒。第二纯Verilog并不排斥厂商原语。设计里时钟管理、I/O接口、BRAM初始化这些部分用厂商原语是最省事也最可靠的做法。关键是划清边界不让厂商相关代码渗透到算法核心。第三AI辅助设计工具值得用但要用在合适的位置。我们用Claude Code辅助生成过不少testbench骨架、寄存器配置模块和注释补全节省了挺多时间。但脉动阵列的调度、行缓冲的状态机、跨域同步这些核心逻辑AI生成的内容还是要逐行审查因为它很容易写出功能上正确但时序上完全收敛不了的代码。第四建议从行缓冲和单PE开始学习脉动阵列。脉动阵列本身不是玄学它就是把数据在寄存器之间流动这件事做到了极致。只要把单个PE的时序吃透理解数据流的方向再扩展到32个PE甚至二维阵列都是水到渠成的事。这套加速器目前还在持续迭代。我们计划下一步扩展成多路车牌并发检测同时接入DDR3控制器把更大分辨率图像直接纳入计算范围。届时再把这部分经验和踩坑记录整理出来分享。