资讯动态

FPGA图像处理ISP仿真框架设计:从Verilog验证到工程实践

发布时间:2026/10/3 7:47:12 来源:尧图企业网站定制
做FPGA图像处理的同学迟早会跟ISP打打交道。先说清楚这里说的ISP不是网络服务商是Image Signal Processor图像信号处理器。CIS传感器出来的RAW数据得经过黑电平校正、坏点矫正、去马赛克、白平衡、色彩校正、Gamma这些环节才能变成人眼看起来正常的RGB或者YUV图像。这一整条链路拿到FPGA上用Verilog一版一版写完、仿真、上板比在Linux上拿OpenCV调库要细碎得多。最大的痛点是你手里可能根本没有一块带Sensor的开发板或者Sensor驱动还没调通算法模块却要提前交付。这种情况下一套好用的Verilog仿真框架就是你的开发主力。这篇文章我想把我自己一直在用的ISP仿真框架完整拆出来讲包括TB怎么写、图像数据怎么读进DUT、处理结果怎么存回BMP、哪些模块适合用什么方式验证、以及跑仿真时我踩过的一堆坑。代码我会贴关键部分注释尽量给足方便你直接搬到自己的工程里改。适合的人群是已经在写FPGA图像基础模块、想系统验证ISP pipeline逻辑的工程师也适合刚接触数字IC验证、想拿图像处理当练手项目的同学。如果你只是想知道某个ISP算法在matlab里怎么调那这篇文章帮不到你但如果你想搞清楚Verilog版本的ISP模块到底怎么在仿真环境里跑起来、怎么证明它输出是对的那看完应该能少走很多弯路。1. ISP仿真框架整体设计为什么不能只靠上板调试1.1 ISP pipeline在FPGA里的特殊性先聊聊ISP本身。Sensor输出的RAW格式本质上是一个大数组每个像素点可能只有10bit、12bit或者14bit的灰阶而且是拜耳阵列Bayer pattern也就是R、G、B像素按2x2周期排列。后面的黑电平校正、坏点矫正、去马赛克、白平衡、CCM、Gamma每一步都在从这个大数组里取邻近像素做运算。在CPU或者DSP上你随手就能写img[x][y]取数循环套循环就完事了。但在FPGA里数据不是随机访问的而是一个像素接着一个像素地流式进来。要做3x3窗口滤波你必须先把两行数据缓存下来要判断坏点你得拿当前像素跟周围8个像素比较。所以FPGA上的ISP开发本质上是在做一整套“时空对齐”的活儿。时序、行缓存、窗口生成、流水延迟补偿这些概念比算法本身的算术逻辑更磨人。也正是因为这样ISP的Verilog开发比普通总线接口模块要复杂。普通模块的仿真你只要测几个握手信号、几组边界数据基本就能放心。但ISP模块输入是一整幅图像输出也是一整幅图像你必须用图像本身来验证灰度有没有算错、边缘有没有糊、坏点有没有被滤掉。而这些东西靠上板调试非常痛苦。板子上可能有Sensor驱动问题、时钟问题、I2C配置问题环境稍微一变你根本分不清是算法模块的错还是其他环节的错。仿真框架的价值就在这里它把DUT隔离出来喂进去的是你指定的测试图吐出来的是可以打开的BMP算法对不对肉眼一看就明白或者跟参考模型一比对就知道。1.2 仿真框架要解决的三个核心问题我给自己定过一套ISP仿真框架的目标很简单就三件事。第一能把测试图像数据按像素流喂进DUT这个叫数据注入。第二能把DUT输出的像素流按坐标重新组装成图像存成BMP文件这个叫数据采集。第三能有一套快捷的结果判定手段比如跟软件参考模型的输出做逐像素比对或者直接把输出图打开看一眼这个叫结果验证。这三件事搞定不管pipeline里加多少个模块你都能单独拉出来验证。我见过不少朋友一开始不搭框架直接写一个简单的testbench用两个for循环把像素寄存器的值一股脑塞给DUT然后看波形。这种做法不是不行但问题很大。一是你看到的波形只是无数个valid和data信号很难直观看出图像对不对二是当你同时调试坏点矫正、去马赛克、白平衡好几个模块的时候没有统一的TB结构每一次改动都要重新翻代码效率极低三是不好用软件参考模型做回归测试。所以后来我宁可多花一天时间把TB框架搭完整也不愿意之后每一次改动都手工看半天波形。1.3 推荐的整体框架结构我的仿真框架目录大概是这样的sim/ tb/ isp_tb_top.sv // 顶层testbench负责例化DUT和调度 image_io.sv // BMP读写、RAW读写、像素流转换 task_send_image.sv // 按AXI-Stream时序发送图像数据 task_check_result.sv // 比对输出像素与期望值 rtl/ isp_top.v // 待测模块按实际项目调整 dpc.v rgb2gray.v scripts/ run_iverilog.sh // Icarus Verilog编译运行脚本 run_xsim.tcl // Vivado XSim运行脚本 data/ input.bmp // 输入测试图 golden.raw // 参考模型生成的期望输出TB顶层不直接处理像素数据它只负责例化DUT、调用send_image任务把一帧图发出去再用check_result任务接收DUT输出。image_io.sv里封了BMP文件头的解析和导出方便在仿真里直接喂彩色图、导出处理结果。这种分层的好处是换一个模块只需要换DUT那一层加一个步骤只需要在TB里多调一个任务要回归测试一键把所有测试图跑完然后批处理比对。比你每次都新写一个TB、改端口、换文件路径要舒服太多了。// isp_tb_top.sv 简化示意 module isp_tb_top; reg clk, rst_n; reg [7:0] img_r [0:640*480-1]; // ... image_io #(.WIDTH(640), .HEIGHT(480)) u_io(); // ... initial begin $dumpfile(dump.vcd); $dumpvars(0, isp_tb_top); u_io.read_bmp(../../data/input.bmp, img_r, img_g, img_b); rst_n 0; #100 rst_n 1; u_io.send_image(img_r, img_g, img_b); #1000; u_io.write_bmp(../../data/out.bmp, out_r, out_g, out_b); $finish; end endmodule这个结构你可以当作模板来用。后面每个小节我会把关键部分的实现细节展开讲为什么这么写、哪些地方容易踩坑。2. 测试图像的数据注入BMP读取与像素流生成2.1 为什么选BMP作为输入输出载体做ISP仿真第一步就是喂图。我强烈建议用BMP格式不要为了省事直接手写一串像素值喂进去。原因是BMP格式足够简单24位BMP每个像素正好是RGB三个字节没有JPEG那种压缩、熵编码乱七八糟的东西非常适合做RTL仿真。而最终输出如果也是BMP你可以直接用系统自带的图片查看器打开或者用Python的OpenCV一行代码读进来做统计分析一点都不费劲。另一个更重要的原因是真正常用的测试图比如经典的色卡图、格子图、渐变图用BMP保存最接近RAW传感器的数据结构。尤其是配合RAW转BMP、BMP转RAW的工具链可以模拟Sensor输出前的数据形态。BMP文件结构不复杂主要分三块。第一块是14字节的文件头BITMAPFILEHEADER里面存了“BM”标志和整个文件大小、像素数据偏移。第二块是40字节的信息头BITMAPINFOHEADER里面存了图像宽度、高度、位深、压缩方式。第三块才是像素数据。有一个能让新手卡半天的点BMP的每一行数据是4字节对齐的也就是说每行像素的字节数如果不是4的倍数文件里会补一些填充字节。比如一张24位、宽度为321像素的图一行像素字节数是321乘以3等于963字节963除以4余3所以实际存储会补1个字节的0变成964字节一行。如果你用$fread直接盲读或者用Python直接转RAW没处理这个对齐后面数据全都会错位图像看起来就是斜的、碎的。我在TB里处理BMP的时候一般直接写一个read_bmp任务把文件头信息解析出来拿到宽度、高度、数据偏移然后跳过填充字节把每个像素的R、G、B读进三个数组。task read_bmp( input string filename, output logic [7:0] img_r[0:MAX_PIXELS-1], output logic [7:0] img_g[0:MAX_PIXELS-1], output logic [7:0] img_b[0:MAX_PIXELS-1] ); int fd, width, height, data_offset, row_size, pad; longint file_size; logic [7:0] byte_buf; begin fd $fopen(filename, rb); // 读取文件头信息这里省略了具体的地址解析但思路是 // $fseek(fd, 10, 0); 读4字节得到 data_offset // $fseek(fd, 18, 0); 读4字节得到 width // $fseek(fd, 22, 0); 读4字节得到 height // 然后逐字节读取像素 row_size width * 3; pad (4 - (row_size % 4)) % 4; // 按行循环读 row_size 字节像素再跳过 pad 字节 for (int y 0; y height; y) begin for (int x 0; x width; x) begin // 注意BMP像素顺序是B, G, R $fread(byte_buf, fd); img_b[y*widthx] byte_buf; $fread(byte_buf, fd); img_g[y*widthx] byte_buf; $fread(byte_buf, fd); img_r[y*widthx] byte_buf; end for (int p 0; p pad; p) begin $fread(byte_buf, fd); // 丢弃填充字节 end end $fclose(fd); end endtask注意这里我强调三点。一是BMP里像素顺序是BGR不是RGB很多人第一次写的时候踩这个坑导致整个图像红蓝通道对调偏色超级明显。二是如果图像高度是正的BMP行顺序是从下往上存的但一般我们为了方便会选择存储高度为负的BMP或者从文件尾反向读如果不想折腾直接在Python转RAW的时候把镜像处理好让TB的坐标和肉眼看到的坐标一致。三是$fseek在大多数仿真器里都支持但Icarus Verilog对$fseek的支持一般如果发现读出来的数据不对可以先检查是不是文件指针没定位对。2.2 RAW数据流注入模拟Sensor时序如果只是测试RGB彩色图的算法比如色彩校正、灰度转换、边缘增强用上面那种直接读BMP的方式够了。但ISP是处理Sensor的RAW数据的也就是拜耳阵列的RAW图所以很多时候你需要先准备一张RAW文件再按像素流送进DUT最后用匹配的拜耳转RGB算法导出可视化结果。RAW文件的格式更简单就是一个像素接一个像素的灰度值。假设你用的Sensor是10bit RAW每个像素占16bit空间或者紧凑成10bit打包那TB里只需要按指定的位宽和数据宽度把文件读进来转成一个二维数组再按行扫描发送。task send_raw_image( input string filename, input int width, height, input int bit_width ); int fd; logic [15:0] pixel_data [0:MAX_PIXELS-1]; begin fd $fopen(filename, rb); for (int i 0; i width*height; i) begin $fread(pixel_data[i], fd); end $fclose(fd); // 按行发送 for (int y 0; y height; y) begin for (int x 0; x width; x) begin (posedge clk); s_axis_tvalid 1b1; s_axis_tdata pixel_data[y*widthx][bit_width-1:0]; s_axis_tuser (y 0 x 0) ? 1b1 : 1b0; // frame start s_axis_tlast (x width-1) ? 1b1 : 1b0; // line end do (posedge clk); while (s_axis_tready ! 1b1); end end s_axis_tvalid 1b0; end endtask这里我把帧起始信号和行结束信号都放在像素流里跟AXI-Stream总线的tuser和tlast对齐。这样在TB里模拟真实Sensor输出时下游模块可以方便地复位行列计数也方便在仿真波形里定位到第几行、第几列出错。你不用额外拉一根frame_sync信号因为一个完整的图像数据流本来就包含了这种起始边界信息。2.3 用AXI-Stream握手信号控制发送节奏ISP模块内部不管你是BMP还是RAW到了RTL端口层面最终都是像素流。我习惯统一用AXI-Stream协议的简化版valid、ready、data再加一个user表示帧起始、last表示行末。仿真TB里必须仔细写握手不能直接把数据哗啦啦全发出去。很多新人在TB里写一个repeat(N) (posedge clk) data_out ...压根不看DUT的ready信号结果仿真时序完全不是实际电路行为。正确的发送方式应该是发送方拉高valid如果接收方同时拉高ready这个时钟周期才真正完成一次像素传输。我上面那段代码里的do (posedge clk); while (tready ! 1b1);就是在等握手成功。这个写法的好处是你后续可以很方便地在TB里模拟背压比如每传8个像素就拉低ready一个周期看DUT内部的行缓存、FIFO有没有溢出数据有没有错位。对于调试ISP这种流水线密集的模块背压测试非常有必要因为FPGA里真实FIFO不是无限深的一旦某个模块处理不过来就会反压到前面的Sensor接口。另外还有一个跟时序相关的细节如果DUT内部有流水延迟你输出端的valid信号可能有几个周期的滞后但你从TB的send_image任务看数据已经全部发完了。这时候你不能直接用“发送完成标志”来判断DUT输出结束而要在接收端统计像素数量数到一个完整的width*height或者等到DUT输出的tlast出现且计数正确才算一帧结束。这也是为什么TB框架里接收任务一定要独立于发送任务不能想当然地“发完就结束”。3. 核心模块仿真设计与参考模型比对3.1 灰度转换模块的仿真从RGB到YUV的定点化验证有了图像注入的框架接下来就可以开始验证真正的ISP模块了。第一个最经典的例子是RGB转灰度。别小看这个模块它包含了ISP开发里所有必须考虑的要素定点化、流水线延迟、有效信号对齐。公式很简单Y 0.299R 0.587G 0.114B。但在FPGA里你不能直接写浮点乘法必须把系数缩放成整数最后移位回来。我用的是8bit定点系数分别是77、150、29加起来正好256对应右移8位。也就是Y (77R 150G 29B 128) 8加128是做四舍五入。module rgb2gray #( parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] i_r, input wire [DATA_WIDTH-1:0] i_g, input wire [DATA_WIDTH-1:0] i_b, input wire i_valid, output reg [DATA_WIDTH-1:0] o_y, output reg o_valid ); // 两级流水 reg [15:0] sum_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sum_r 16d0; o_y 8d0; o_valid 1b0; end else begin sum_r 77 * i_r 150 * i_g 29 * i_b 128; o_y sum_r[15:8]; o_valid i_valid; end end endmodule这个模块在仿真里怎么验你可以在TB里例化它然后用前面说的send_image把一张彩色BMP发进去DUT输出的Y分量写回一张灰度BMP。打开图片看如果是一张层次分明的灰度图就是对的。但只看图不够严谨最好再用软件算一遍期望值。我用Python写参考模型import cv2 import numpy as np img cv2.imread(input.bmp) # 注意OpenCV默认也是BGR y (img[:,:,2] * 77 img[:,:,1] * 150 img[:,:,0] * 29 128) 8 cv2.imwrite(golden_y.bmp, y)然后TB里把DUT输出的o_y一个像素一个像素地跟golden_y.raw比对误差为0才算通过。这个比对任务我建议做成一个通用task后面所有模块都能用。这里有一个我在实际中踩过的坑上面代码里sum_r用了[15:0]但实际上77乘以255加150乘以255加29乘以255加128最大是19635 38250 7395 128 65408刚好小于65536。也就是说16bit是勉强够用的。如果你把系数调大一点或者输入位宽不是8bit而是10bit、12bit就很容易溢出仿真出来整幅图出现大量白色噪点。所以定系数的时候务必先手算一下最大中间值再决定中间寄存器位宽。这是好多新手容易忽略的点一个隐藏的位宽溢出可以让你的整条pipeline在仿真里“看起来很奇怪”但你又找不到逻辑错在哪。3.2 滑动窗口滤波模块的仿真思路ISP里大量使用滤波操作坏点矫正就是典型。坏点矫正的原理其实不复杂3x3窗口内如果中心像素跟周围8个像素的差值特别大就认为它是坏点用周围像素的中值或者均值替换。这个算法在CPU上很好写但在Verilog里难的不是算术而是怎么在流式处理时同时拿到左上、上、右上、左、中、右、左下、下、右下这9个像素。这就涉及行缓存Line Buffer和窗口寄存器的设计。我在仿真框架里验证滑动窗口模块的惯用方法是准备一张带噪声点的测试图噪声点数量随机撒模拟坏点然后跑仿真。输出图如果坏点被抹掉而正常边缘没有被明显模糊就说明窗口逻辑和滤波逻辑都对。// 3x3窗口生成示意 module window_3x3 #( parameter WIDTH 640, parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] i_pixel, input wire i_valid, output reg [DATA_WIDTH-1:0] w00, w01, w02, // 上一行 output reg [DATA_WIDTH-1:0] w10, w11, w12, // 当前行 output reg [DATA_WIDTH-1:0] w20, w21, w22, // 下一行 output reg o_valid ); reg [DATA_WIDTH-1:0] line_buf [0:1][0:WIDTH-1]; // 两行缓存 reg [DATA_WIDTH-1:0] shift_p2, shift_p1, shift_p0; reg [2:0] col_cnt; reg [1:0] row_sel; always (posedge clk or negedge rst_n) begin if (!rst_n) begin // 复位所有窗口寄存器... end else begin w00 w01; w01 w02; w02 line_buf[row_sel][col_cnt2]; w10 w11; w11 w12; w12 shift_p0; w20 w21; w21 w22; w22 i_pixel; // 更新移位寄存器和行缓存... end end endmodule上面这个窗口模块的端口和内部逻辑描述重点记住一点它输出的w00到w22必须在同一拍上是同一行、同一列周围的空间邻域这个对齐是在仿真里最容易验证也最容易出错的地方。怎么验证一个非常实用的技巧用一张构造的测试图让每个像素的数值等于它的坐标值比如第一个像素是0第二个是1第三个是2这样窗口内每个位置的值就带有明显的坐标信息。跑完仿真你在波形里看w00到w22这9个值如果它们恰好是递增/递减的坐标序列说明窗口对齐正确。用这种方法比盯着自然图判断快得多。这一点也是我强烈建议你放在ISP仿真框架里的公共能力支持生成构造测试图。不管是纯色块、渐变、坐标图还是随机噪点图都应该能一键生成并灌进TB。没有这种能力你验证窗口模块的效率会大打折扣。3.3 用参考模型做模块级回归比对前面讲灰度转换的时候提到了参考模型。实际上对于整个ISP项目我更建议把“参考模型”作为项目的一部分来做而不是临时的Python脚本。流程是这样先用OpenCV或者MATLAB写好算法模型输入一张测试图输出一份golden raw文件然后在TB里跑RTL仿真把RTL输出跟golden raw做逐像素比对统计最大值误差、平均误差、错误像素数。为什么要这么做因为ISP模块调试到后期肉眼已经看不出问题了你需要量化评估改动的影响。比如你把中值滤波的窗口从3x3改成5x5RTL实现做了修改仿真输出的图像肉眼看差别不大但逐像素比对的结果能告诉你改动的副作用范围在哪里。又比如你改了白平衡的增益系数期望是像素值整体放大但有些像素溢出饱和在BMP上看只有一小片区域发白逐像素比对才能精确定位是哪些坐标、哪些通道出了问题。参考模型的形式可以很灵活。最省事的是用Python/OpenCV跑一批图生成golden存成二进制raw然后TB里用$readmemh或者$fread读进来比对。如果项目本身有复杂的算法比如去马赛克、3A统计你可以用C写一个软件模型执行速度更快的版本。但无论如何参考模型的输出格式一定要固定像素顺序从上到下、从左到右每个像素位宽跟RTL端口一致。否则模型是对的比对逻辑却对不齐白折腾一场。task check_pixel_sequence( input string golden_file, input int total_pixels, input int tolerance ); int fd, mismatch_cnt; logic [15:0] golden_val, dut_val; begin fd $fopen(golden_file, rb); mismatch_cnt 0; for (int i 0; i total_pixels; i) begin $fread(golden_val, fd); // 从DUT的输出FIFO里取一个像素这里省略FIFO读取细节 dut_val get_dut_pixel(); if (abs(dut_val - golden_val) tolerance) begin mismatch_cnt; $display(MISMATCH at pixel %0d: DUT%0d GOLDEN%0d, i, dut_val, golden_val); if (mismatch_cnt 20) begin $display(Too many mismatches, aborting...); $finish; end end end $fclose(fd); if (mismatch_cnt 0) $display(CHECK PASSED: all %0d pixels matched within tolerance, total_pixels); else $display(CHECK FAILED: %0d mismatches, mismatch_cnt); end endtask注意这里有个tolerance参数。为什么不能要求严格相等因为你在FPGA里做的是定点算术而参考模型如果用浮点算最后一步四舍五入的规则不同差1个灰度级是正常的。如果参考模型本身也是定点、系数一致那可以设tolerance为0回归时更严格。我的习惯是模块算法还在调整期tolerance给大一点算法冻结以后tolerance改成非常严格确保任何改动都不会引入意外差异。3.4 把仿真框架扩展到完整ISP pipeline前面说的都是单个模块的验证。但项目最终是要把整个ISP pipeline串起来。我在TB里习惯的做法是DUT例化成isp_top内部包含DPC、BLC、Demosaic、AWB、CCM、Gamma等模块。TB的发送任务一次把RAW图像灌进去接收任务把最后的RGB或者YUV图像收回来。这样的好处是你随时可以看到端到端的图像效果而且一旦哪个模块出问题可以通过内部节点抓波形快速定位。不过整个pipeline串起来之前一定要先保证每个子模块的单测是通过的。这一点我有过很惨痛的教训有段时间赶进度先把所有模块搭了个大概直接跑整帧demo结果输出图像花得离谱。因为每个模块都有一点小bug叠加在一起最终效果根本无法判断是哪个环节的问题。后来我强制自己在串起来之前逐个模块跑单测、对golden全部通过才允许集成。虽然前期看着慢但集成阶段非常顺利。仿真框架如果从一开始就支持单模块独立验证这个流程就顺理成章了。我还会在TB里加一个“内部节点导出”的功能就是在isp_top内部拉出一些关键信号比如某个中间模块的o_data在TB顶层声明一个wire型变量跨层次引用isp_top.dpc.o_data。调试的时候就可以把中间数据写出来看这级模块输出到底对不对。很多仿真器都支持跨层次引用Icarus Verilog也没问题只是层次路径要写对。4. 跑ISP仿真的常见坑与提速技巧4.1 图片花屏、斜纹、色彩错乱的排查仿真跑起来最常见的现象就是输出的图花屏。我归纳过几类典型原因你可以对照排查。一是行同步错位。很多时候TB发的数据是对的但DUT内部的行缓存写入/读出的时机不对导致窗口内的像素串行了。症状是图像里出现斜切状错位或者每一行的起始位置逐渐偏移。排查手法是在TB里用一张每个像素值等于坐标的测试图看窗口输出是否连续递增。若输出序列在中途被截断或者重复说明行缓存地址控制有问题。二是BMP读取没处理行对齐填充。如果是从BMP转RAW时没考虑每行4字节对齐图像会出现非常规律的横向偏移而且偏移量跟图像宽度相关。这种情况我会先用Python把测试图保存为无填充的RAW再在TB里用RAW格式读入绕开BMP解析问题。等确定DUT逻辑没问题再回头检查BMP读取代码。三是有效信号不对齐。DUT内部的模块分为多级流水每一级都有一个valid伴随数据。如果有某一级把valid延后了一个时钟但数据没有延后最后写BMP时可能把两帧之间的垃圾数据也写了进去。解决方案是定义明确的“数据有效帧区间”接收任务严格按有效像素计数不依赖DUT最后的完成信号。四是位宽和通道顺序问题。比如Sensor是10bit输出但模块输入口是8bit数据被截断或者Bayer的RGGB顺序和你的去马赛克模块约定的顺序不一致直接导致全图偏色。这种问题看波形不明显直接把中间模块输出转成BMP最快。所以在框架里我一般会做一个“中间数据转灰度BMP”的task专门用来dump任一级模块的输出。没这个工具你只能猜。4.2 Icarus Verilog、Vivado XSim和VCS的选择仿真工具上我自己最常用的是Icarus Verilog配合GTKWave因为免费、轻量、脚本化方便非常适合快速跑回归。Icarus Verilog对SystemVerilog的支持不算完整但做TB和简单的RTL仿真完全够用。如果你用Vivado做开发也可以直接用XSim它跟Vivado的IP集成、原语库兼容性更好但仿真速度相对慢一点。有一个比较麻烦的问题是FPGA工程里经常例化厂商原语比如BUFG、IDDR、PLL这些在外面调试时没法直接仿真。我的做法是把待测DUT包一层工程里的原语在顶层TB例化时不经过原语直接给DUT喂理想的时钟和复位。如果DUT内部有厂商FIFO或者RAM IP并且是用Vivado生成的Icarus Verilog通常读不了。这时候我会把FIFO替换成一个behavioral模型或者用ifdef SIM在仿真时跳过IP例化改成寄存器数组模拟。这套做法从刚做ISP仿真就一直在用很省事。跑XSim的时候可以把仿真脚本写成一个tcl文件set_property top isp_tb_top [get_filesets sim_1] set_property target_simulator XSim [current_project] launch_simulation run -all close_sim如果用的是Icarus Verilog编译运行一条命令就行iverilog -g2012 -o sim.vvp tb/isp_tb_top.sv tb/image_io.sv rtl/*.v vvp sim.vvp跑完以后生成VCD波形用GTKWave打开gtkwave dump.vcd4.3 仿真速度慢的优化思路FPGA仿真速度本来就远慢于实际硬件。一幅1920x1080的RAW图在纯行为级仿真里可能要跑几分钟甚至更久。这个速度对于调试是不可接受的。我的经验是仿真阶段不要直接用1080p大图而是把测试图缩小到320x240或者640x480。ISP模块的流水线逻辑跟分辨率没有太大关系小图能暴露的逻辑错误大图照样能暴露。等小图全通过再拿1~2帧大图做最终回归。另外一个很有效的提速技巧是在做单模块仿真时用$dumpvars时只保存目标模块附近的信号不要把整个TB几十万个信号全部dump下来。波形文件一旦太大仿真器的IO开销会拖慢好几倍。我通常是先全量dump跑一幅小图确认结构没问题后就把$dumpvars范围缩小到DUT内部某几个寄存器然后放心跑大批测试图。还有一个比较进阶的手段如果参考模型已经验证过一版RTL后续只是小改动可以用$value$plusargs控制TB只跑某一张图、某一帧、或者直接跳过某些模块的初始化等待。回归用例多的时候这种参数化控制在CI自动化里特别有用。4.4 帧与帧之间状态残留的坑ISP模块很多都是逐帧处理帧与帧之间有间隔。仿真里我最常犯的错是跑完第一帧第二帧开始前没有把行缓存、计数寄存器、FIFO清干净导致第二帧开头几行出现残影或者错位。现象很像真实硬件里Sensor切换分辨率或者曝光参数后的首帧异常。仿真时要养成习惯一帧结束信号比如收到最后一行的tlast之后DUT内部所有状态必须在下帧tuser到来之前复位到初始值。如果模块设计本身就不支持帧间自复位TB也要尽量模拟真实Sensor的帧间隔在每两帧之间预留足够的无效时钟周期。调试这个问题有个土办法第一帧用纯红色图第二帧用纯蓝色图中间不停顿或者只停一两个周期然后看输出第二帧的开头有没有混入第一帧的颜色。如果混入了说明有状态残留。这个方法我屡试不爽比看波形直观得多。4.5 我的仿真脚本和回归策略最后说回回归。框架搭好以后一定要固化成一个回归脚本不要每次手动敲命令。我的习惯是写一个shell脚本自动调用工具链、跑完所有用例、比对golden、输出一个汇总报告。如果某条用例挂了脚本把对应的mismatch日志单独打个包方便打开分析。这个脚本虽然简单但特别能提升效率。以前我手动跑回归改了某个模块以后经常忘了跑其他用例结果提交的时候把队友的模块搞坏了还不知道。有了自动化回归每次改动一跑三十秒内就能知道哪几个测试图挂了立刻定位问题。脚本的大概框架是#!/bin/bash CASES(lena grid_gradient noise_colorbar) for c in ${CASES[]}; do iverilog -g2012 -o sim.vvp -s isp_tb_top tb/isp_tb_top.sv tb/image_io.sv rtl/*.v vvp sim.vvp case$c if diff -q out.raw golden_$c.raw; then echo PASS: $c else echo FAIL: $c fi done实际工程里会用$value$plusargs来传测试图路径和期望文件路径TB里读进来跑就行。库里面的测试图要有意识地覆盖有普通自然图、有高中低亮度图、有边缘密集图、有纯色块图、有带噪点图。这样既可以看到实际效果又能在边界情况暴露问题。ISP这种图像处理逻辑测试图的覆盖度直接决定你把bug修得干净不干净光靠一张lena图是远远不够的。我个人在这套框架上受益最多的就是它把“算法正确性验证”从玄学变成了工程。以前没有参考模型、没有自动化比对的时候调一个模块全靠肉眼看图改一个参数要重新仿真、重新截图、重新对比效率低不说还不容易判断是好是坏。后来狠下心把框架搭完整每一个模块都有单测、每一帧输出都有golden、每一次修改都有回归记录整个项目推进速度反而快了很多。如果你现在正好在写ISP相关模块建议花上两天时间把这套仿真框架搭起来。刚开始会觉得投资不小但等你同时调试几个模块、反复改动算法参数的时候就会回来感谢这套框架了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑