资讯动态

Verilog图片翻转:地址重排原理与FPGA缓存实现

发布时间:2026/10/15 4:38:54 来源:尧图企业网站定制
简介一份用 Verilog 实现图片翻转的课程设计大作业说明书面向电子类专业本科生及 Verilog 入门者目标是把 256×256、24 位 BMP 图像顺时针旋转 90 度并提供可模仿的完整设计方案。文档先讲 BMP 位图四部分结构包括 54 字节文件头、图像信息头、颜色表与像素阵列再给出 C 预处理、txt 中间文件、数据读取、像素重排和写回的具体流程代码部分按 Clock、Rotate 等模块划分配合 ModelSim 仿真验证便于读者理解每一步实现和排错。整个资源包为 1 个 doc 文档大小约 267KB内文包含作业简述、设计思路、Verilog 源代码、C 代码、参考资料以及小组分工说明章节完整可直接作为课程设计参考或在此基础上改进。已有 1101 人学习适合正在做图像旋转课设、或想学习用硬件描述语言读写 BMP 位图的读者对 FPGA 图像处理入门也有参考价值。1. verilog 图片翻转先忘掉像素计算记住“地址重排”四个字verilog 图片翻转听起来像是对像素做某种运算实际干过的人都知道翻转一张图一个加法都不用更不用碰任何像素值。真正的做法是先把图片完整写进一块缓存然后改变读出缓存的地址顺序——水平翻转就是把行内地址倒着给垂直翻转就是把行扫描顺序倒过来仅此而已。这篇文章用最简单的双端口 RAM 模型从地址映射推演到可综合的 Verilog 模块再把 BRAM 和 DDR 两条落地路线、五个高频翻车点一次讲清。适合正在做 FPGA 图像入门、被花屏和错位折磨或者想在面试里把这个功能讲明白的从业者。先说明一点这里说的“翻转”指镜像翻转不是 90° 旋转后者要转置缓存工程成本完全不同后文会解释。2. 图像在缓存里怎么排布翻转地址映射的四种模式2.1 一张图在缓存里就是一行接一行的线性数组图像一旦进了缓存它没有“行”和“列”的概念只有一串地址。一张W列H行的 RGB888 图片按行优先存放第y行第x列的像素地址是addr y * W x以 640×480 为例第 2 行第 100 列的地址就是2 * 640 100 1380最后一行最后一列的地址是479 * 640 639 306559。只要支持这个公式不管你用的是 BRAM、DDR 还是某种自定义存储翻转的思路就统一了。这个公式是整个地址重排的地基。后面所有 Verilog 代码里的乘法和加法本质上都在算这一行。有一个容易忽略的点这里的单位是“像素”不是“字节”。如果缓存位宽正好等于像素位宽比如 24bit地址就按像素走如果按字节寻址RGB888 会引入×3甚至×4的对齐计算这个放到 2.4 单独说。2.2 水平翻转、垂直翻转、180° 翻转的地址公式读侧仍然按照正常的扫描顺序输出x从 0 递增到W-1y从 0 递增到H-1。翻转只改“这个扫描位置该去哪个地址取数据”。模式输出端扫描顺序缓存读地址直通从左到右从上到下y * W x水平翻转左右镜像从左到右从上到下y * W (W - 1 - x)垂直翻转上下镜像从左到右从上到下(H - 1 - y) * W x180° 翻转从左到右从上到下(H - 1 - y) * W (W - 1 - x)拿一张 3×3 原图举例像素按地址 0~8 依次是A B C D E F G H I水平翻转时读地址序列是2, 1, 0, 5, 4, 3, 8, 7, 6输出就是C B A F E D I H G垂直翻转时读地址序列是6, 7, 8, 3, 4, 5, 0, 1, 2输出为G H I D E F A B C关键点在于输出端的扫描顺序完全没有变变的只是每次读缓存时给出的地址。这个思想贯穿整个模块设计也是“翻转不需要碰数据”这句话的由来。2.3 为什么翻转不需要碰像素数据新手容易走入一个误区把整帧像素搬进另一个缓存搬运过程中把行列倒序写入再读出来。这种方案也能实现翻转但代价是额外一次全帧写、一次全帧读带宽和延迟都翻倍。常见的省事做法是保持原图不动只翻转读地址。因为翻转本质是“视图变换”不是“数据变换”。像素数据一次都不需要改你改的是“从哪儿取像素”。DDR 带宽很贵省一次全帧搬运等于把整个方案的成本砍掉一半。理解了这一点你也就理解了为什么在这个项目里地址生成模块才是主角像素通路反而是配角。后面所有代码核心都围绕“地址”两个字展开。2.4 像素地址与字节地址24bit RGB 是个容易踩的算术题如果你的缓存位宽正好是 24bit地址可以按像素编址公式直接用。但工程里很少有 24bit 位宽的双端口 RAM更多是 32bit 或 64bit 总线。常见的做法是把一个 RGB888 像素打包成 32bit{ R[7:0], G[7:0], B[7:0], 8h00 }这样一个像素固定占一个 32bit 单元地址仍然可以按像素递增字节地址等于pixel_addr * 4。读取后丢弃最高或最低的空字节即可。这样做最省心也是我推荐的做法。真正容易出错的是接 DDR。DDR 控制器暴露的是字节地址如果你直接拿addr y * W x去算然后把像素按 24bit 往总线上塞会出现一个像素横跨两个存储单元、burst 长度错位、读写地址对不齐等问题。正确姿势是先统一“一个存储单元 一个像素”的抽象在 DDR 控制器的用户接口层做打包/解包上层地址生成模块完全不关心字节地址。3. 手写可综合的翻转地址生成模块3.1 写地址侧一行一行把像素灌进缓存下面这个模块负责在输入像素有效时产生顺序写地址。参数化宽高默认 640×480地址位宽 19bit因为 640×480 307200 2^19。module wr_addr_gen #( parameter WIDTH 640, parameter HEIGHT 480, parameter ADDR_W 19 )( input wire clk, input wire rst_n, input wire wr_en, // 输入像素有效每周期1个像素 input wire frm_start, // 帧起始信号可用vsync output reg [ADDR_W-1:0] wr_addr, // 写缓存地址 output reg wr_frm_done // 一帧写完拉高1拍 ); reg [15:0] x_cnt; reg [15:0] y_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin x_cnt 0; y_cnt 0; wr_addr 0; wr_frm_done 0; end else if (frm_start) begin x_cnt 0; y_cnt 0; wr_frm_done 0; end else if (wr_en) begin wr_addr y_cnt * WIDTH x_cnt; if (x_cnt WIDTH - 1) begin x_cnt 0; if (y_cnt HEIGHT - 1) begin y_cnt 0; wr_frm_done 1b1; end else begin y_cnt y_cnt 1b1; end end else begin x_cnt x_cnt 1b1; end end else begin wr_frm_done 1b0; end end endmodule逻辑说明wr_en与输入像素同步只有它为高时计数器才推进。frm_start优先级高于wr_en用于帧头复位所以必须保证frm_start不能和有效像素同拍标准视频时序里 vsync 和 de 不会重叠问题不大。wr_frm_done在写入最后一像素的那一拍拉高持续一个周期供上层状态机使用。参数说明WIDTH和HEIGHT是综合期常量y_cnt * WIDTH会被综合工具化简成常数乘法不占用 DSP也不用担心乘法器资源。x_cnt、y_cnt用 16bit 属于“宽备窄用”综合后实际触发器位数会按参数裁剪。地址位宽ADDR_W需要手动保证不小于log2(WIDTH * HEIGHT)这个参数由外部传入更安全。3.2 读地址侧扫描顺序不变物理地址按翻转模式映射读侧模块是核心它内部的扫描计数器与写侧一模一样正常从左到右、从上到下递增但输出的地址会根据翻转模式做组合映射。module rd_addr_gen #( parameter WIDTH 640, parameter HEIGHT 480, parameter ADDR_W 19 )( input wire clk, input wire rst_n, input wire rd_en, // 下游请求1个像素 input wire [1:0] flip_mode, // 2b00直通 2b01水平 2b10垂直 2b11水平垂直 output reg [ADDR_W-1:0] rd_addr, // 读缓存地址 output reg rd_frm_done ); reg [15:0] x_cnt; reg [15:0] y_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin x_cnt 0; y_cnt 0; rd_frm_done 0; end else if (rd_en) begin if (x_cnt WIDTH - 1) begin x_cnt 0; if (y_cnt HEIGHT - 1) begin y_cnt 0; rd_frm_done 1b1; end else begin y_cnt y_cnt 1b1; end end else begin x_cnt x_cnt 1b1; end end else begin rd_frm_done 1b0; end end wire [ADDR_W-1:0] normal_addr y_cnt * WIDTH x_cnt; always (*) begin case (flip_mode) 2b00: rd_addr normal_addr; 2b01: rd_addr y_cnt * WIDTH (WIDTH - 1 - x_cnt); 2b10: rd_addr (HEIGHT - 1 - y_cnt) * WIDTH x_cnt; 2b11: rd_addr (HEIGHT - 1 - y_cnt) * WIDTH (WIDTH - 1 - x_cnt); default: rd_addr normal_addr; endcase end endmodule逻辑说明rd_en是下游显示端或处理端的请求信号每请求一次计数器加一扫描位置推进一格。地址映射不改变扫描顺序只改变“这个位置去读哪个地址”。rd_frm_done在读完整帧后拉高可以用于通知写侧切换缓冲。参数说明flip_mode是一个 2bit 寄存器位可以由拨码、寄存器或 AXI 总线写入。之所以把地址映射做成组合逻辑而不是在计数器上做条件判断是因为这样四条路径的时序完全一致不容易出现某一种模式下多一拍少一拍的问题。3.3 顶层拼接双端口 RAM 与帧同步的最小框架有了写地址和读地址还需要一块双端口 RAM 把它们连起来。下面这个行为级 RAM 模型可以直接用于仿真和大多数 FPGA 综合module dpram #( parameter DATA_W 24, parameter ADDR_W 19 )( input wire clk, input wire we, input wire [ADDR_W-1:0] waddr, input wire [DATA_W-1:0] wdata, input wire [ADDR_W-1:0] raddr, output reg [DATA_W-1:0] rdata ); reg [DATA_W-1:0] mem [0:(1 ADDR_W) - 1]; always (posedge clk) begin if (we) mem[waddr] wdata; rdata mem[raddr]; end endmodule注意这是一个同步读 RAM读地址给出后数据在下一拍才出现在rdata上。这个延迟会在第 5 章引发第一个大坑这里先记住所有与像素同步的 hsync/vsync/de 信号都必须跟着 RAM 的读延迟一起打拍。帧同步逻辑最简单的做法是用一个状态机IDLE 等待写帧写完进 READ读完回 IDLE。写一帧、读一帧适合“图片处理”这种非实时流水场景。reg [1:0] state; localparam IDLE 0, FILL 1, READ 2; always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else begin case (state) IDLE: if (wr_frm_done) state READ; READ: if (rd_frm_done) state IDLE; default: state IDLE; endcase end end assign rd_en (state READ) ? out_req : 1b0;这个状态机隐含一个约束读帧期间写侧必须停。如果你的输入是连续视频流就得换成第 4 章说的乒乓缓冲否则写到一半的帧会被读出去。3.4 参数化与扩展分辨率变了要动哪里把WIDTH、HEIGHT改成别的值理论上代码不用动但要注意三件事一是地址位宽ADDR_W要大于log2(WIDTH * HEIGHT)否则地址截断后会出现相邻行重叠。比如改成 1920×1080像素数 2073600需要 21bit。二是x_cnt、y_cnt用 16bit 够不够1080p 以内都没问题4K 垂直方向 2160 也小于 65535够用。三是如果分辨率是运行时动态切换的y_cnt * WIDTH就不能再用常数乘法了需要把WIDTH做成寄存器用真正的硬件乘法器。再强调一次如果想做的是 90° 旋转这个模块是不够的。旋转要求输出第 0 行时读原图的第 0 列也就是按列访问行存储的帧访问跨度为整个帧的宽度。对 BRAM 还好对 DDR 来说相当于每一笔都跨行跳地址burst 效率直接崩掉。工程上 90° 旋转一般用转置缓存或分块转置和翻转是两套方案。4. 缓存方案怎么选BRAM、DDR、乒乓缓冲的带宽账4.1 小图直接上 BRAM双端口 RAM 一例如果图片分辨率不大比如 256×256 或者 640×480 灰度图直接例化一块双端口 BRAM 就是最省事的方案。BRAM 的好处是读写延迟固定、时序可控、不需要仲裁。容量上按“像素数 × 存储位宽”估算比如 256×256 的 RGB888 按 32bit 打包存储需要256 * 256 * 32bit 2Mbit存储容量。对于单颗 18Kbit 或 36Kbit 的 BRAM需要多块拼接X ilinx 或 A 家器件各有各的生成方式但接口都可以统一成“写地址 写数据 写使能读地址 读数据”这个模型。用 BRAM 时前面的 dpram 模型可以直接替换成厂商原语也可以保持行为级描述让综合工具自动推断。我一般会让工具自己推断 BRAM代码里只留行为模型这样仿真和上板用的是同一套代码少一层转换。BRAM 方案限制不在地址逻辑而在容量。一张 1080p 的 RGB888 按 32bit 打包需要1920 * 1080 * 32bit 66Mbit再大的芯片也放不了几张这样的帧。所以大图必须走 DDR。4.2 大图交给 DDR地址公式不变但要记住一个行距DDR 方案里翻转地址公式仍然成立但要处理两个额外问题DDR 控制器通常按 burst 长度访问每一行的有效像素数往往不是 burst 长度的整数倍所以每行尾部要补 padding 字节DDR 地址是字节地址而你的像素是 32bit 打包。两个问题叠加出现了一个关键参数STRIDE行跨度。常见做法是先把行宽对齐到 burst 长度localparam PIX_PER_BURST 16; localparam STRIDE ((WIDTH PIX_PER_BURST - 1) / PIX_PER_BURST) * PIX_PER_BURST;然后所有地址计算都用STRIDE替代WIDTH// 水平翻转 rd_addr y_cnt * STRIDE (STRIDE - 1 - x_cnt); // 垂直翻转 rd_addr (HEIGHT - 1 - y_cnt) * STRIDE x_cnt;注意这里x_cnt仍然按实际像素宽度 0~WIDTH-1 计数地址却落在 padding 后的存储布局上。这样每一行真正用到的像素都在同一行 burst 内不会跨行。DDR 方案的第二个坑是读写仲裁。写侧要把像素搬进 DDR读侧要按翻转地址取数两边同时访问同一颗 DDR 颗粒需要仲裁器或经过 DDR 控制器的多端口接口。这个不是标题能展开的内容但你要知道翻转的地址逻辑没有变复杂变复杂的只是 DDR 控制器的访问模式。4.3 实时视频流反翻转双帧乒乓和单帧缓存的差别前面状态机“写一帧、读一帧”的做法有个前提输入不是连续流。如果摄像头一直在出图写侧不可能停下来等读侧。这时必须在缓存里放两帧写侧写 A 帧的时候读侧读 B 帧A 写完后读侧切到 A写侧切到 B。乒乓切换的经典做法是等“写完成”和“读完成”同时满足再换。代码骨架reg wr_sel, rd_sel; always (posedge clk or negedge rst_n) begin if (!rst_n) begin wr_sel 0; rd_sel 1; end else if (wr_frm_done rd_frm_done) begin wr_sel ~wr_sel; rd_sel ~rd_sel; end end这个交换条件要求读写双方都完成一帧才交换虽然可靠但写侧会被读侧拖慢。如果读写帧率不同步写侧会周期性被读侧阻塞输入流必须支持背压。更顺滑的做法是三帧环形缓冲写帧号写完就推进读帧号始终追赶写帧号中间永远隔一帧。代价是多一帧存储换来的是一套无需握手的流水逻辑。我的建议很简单如果只是图片处理单帧加状态机如果是视频流至少双帧乒乓如果读写帧率可能抖动直接上三缓冲别在握手逻辑上抠资源。4.4 带宽估算表先算账再选型翻转本身不增加读带宽但“整帧写入再整帧读出”这个架构天然需要双倍显存带宽。按 24bit 原始像素计算几个常见分辨率的带宽需求如下分辨率像素时钟单通道写带宽单通道读带宽读写总带宽640×4806025.2 MHz0.6 Gbps0.6 Gbps1.2 Gbps1920×108060148.5 MHz3.6 Gbps3.6 Gbps7.1 Gbps3840×216060594 MHz14.3 Gbps14.3 Gbps28.5 Gbps如果按 32bit 打包存储实际写入/读出带宽还要再放大 4/3 倍。DDR3 单颗 x16 的理论带宽在 12.8Gbps 左右实际可用打六折到 4K60 翻转单颗 DDR 颗粒就扛不住了要上两颗或多通道控制器。这也是为什么说翻转方案的瓶颈永远是缓存容量和带宽而不是那几个乘法和加法。选缓存方案之前先把这个表拉出来把带宽账算清再决定是 BRAM、单颗 DDR 还是多通道 DDR。5. verilog 图片翻转高频踩坑现象、原因、解决5.1 整帧画面乱序RAM 读延迟没有补偿到同步信号上现象翻转逻辑看起来全对仿真波形里地址也对但上板后图像像是被打乱的行块有时还带斜条纹。原因绝大多数 FPGA RAM 是同步读地址发出后要等 1~2 拍数据才出来而你的 hsync/vsync/de 信号如果没有同步打拍就会比像素数据提前到达显示端按新一行开始拼接时数据还是上一行的尾部。解决把输出端的像素数据和所有同步信号一起打同样的拍数。reg [23:0] pixel_d; reg de_d; always (posedge clk) begin pixel_d pixel_out; de_d de_in; end // 若RAM读延迟是2拍再重复打一拍这是一个典型的“波形对、图像错”的坑不亲眼看到花屏很难意识到。5.2 几何对了颜色却错位24bit 像素和 32bit 存储总线的包装纠纷现象翻转方向完全正确但画面颜色明显不对红色跑到蓝色通道里或整体偏绿。原因写入侧按{R, G, B, 8h00}打包读出侧却按{8h00, R, G, B}解包或者某个字节序反了。地址逻辑根本没参与这件事。解决统一打包规则并且写侧读侧用同一个宏定义define PACK_RGB24(r, g, b) {r, g, b, 8h00} define UNPACK_RGB24(pix) pix[23:8]只要读写两侧都用同一组宏这类问题一次就能绝根。最怕的是有人在一个模块里手写位拼接另一个模块里翻了个字节序颜色错位就变成“仅在某些翻转模式出现”的玄学问题。5.3 切换模式瞬间花一帧计数器和模式字不是同拍生效现象运行中切换翻转模式切换后的第一帧总是乱七八糟的第二帧开始才正常。原因rd_addr_gen的x_cnt/y_cnt在切换瞬间正处于某一行的中间位置新的地址映射从这一位置开始生效读出来的数据自然是不连续画面。解决检测到模式变化后先把读使能屏蔽计数器清零等到下一帧帧头再重新开始读。reg [1:0] mode_d; wire mode_chg (flip_mode ! mode_d); always (posedge clk) begin mode_d flip_mode; if (mode_chg) begin x_cnt 0; y_cnt 0; rd_en_g 1b0; // 屏蔽读请求 end else begin rd_en_g rd_en; end end模式切换最好放在消隐期配合vsync一起做花帧就能完全消失。如果你的系统允许直接让显示端黑一帧再切换是最省事的后悔药。5.4 垂直翻转后图像斜切DDR 行距 stride 没参与地址计算现象水平翻转一切正常垂直翻转后每一行都整体向左或向右偏移一行行起点连成一条斜线。原因DDR 里每一行存储长度是STRIDE而不是WIDTH垂直翻转时下行地址少了(STRIDE - WIDTH)的补偿。解决把 4.2 节的做法落实到位垂直地址用STRIDE计算不能用WIDTH。我见过不少项目在这个坑上卡了两三天原因就是代码里WIDTH太显眼没人注意到 DDR 那侧的行距悄悄变大了。5.5 读到半帧混合画面单缓冲读写重叠现象第一帧输出顶部正常底部是上一帧的旧数据多跑几帧后整个画面像新旧两帧拼在一起。原因写侧还没写完一帧读侧就开始读了或者上一帧还没读完写侧已经覆盖了同一块缓存。解决严格用状态机把读写时段分开先写后读读写完成再交换。如果实时流无法停顿这块必须换成乒乓或三缓冲。reg [1:0] state; localparam IDLE 0, FILL 1, READ 2; always (posedge clk) begin case (state) IDLE: if (wr_frm_done) state READ; READ: if (rd_frm_done) state IDLE; default: state IDLE; endcase end注意这个状态机只允许“写完一帧再读一帧”它隐含要求写侧在 READ 期间暂停。做不到暂停的实时场景老老实实上双缓冲。6. 验证翻转结果的硬核技巧用坐标编码图自检6.1 让像素值自带坐标验证翻转最有效的方式不是看波形而是让每个像素的值“自带坐标信息”。做法很简单写入侧生成测试图时让 R 通道等于列坐标低 8 位G 通道等于行坐标低 8 位B 通道固定为 0pixel_in {x_cnt[7:0], y_cnt[7:0], 8h00};这样整幅图就是一个水平方向的红色渐变叠加一个垂直方向的绿色渐变。翻转后红色渐变的方向反映水平翻转绿色渐变的方向反映垂直翻转肉眼看颜色分布就能立刻判断四个模式对不对。这个方法比数波形高效一个数量级。6.2 自动比对任务仿真时还可以写一个 task 自动比对。基本原理是输出端在扫描位置(x, y)读到的像素应该等于原图在映射位置(x_src, y_src)的像素。假设水平翻转task check_hflip; input [9:0] x; input [9:0] y; begin if (o_pixel[23:16] ! (WIDTH - 1 - x) [7:0]) $display(FAIL hflip at x%0d y%0d, x, y); end endtask这里的WIDTH - 1 - x是期望的原图像素列号也就是R通道应有的值。跑完全帧扫描所有坐标点都满足期望翻转逻辑才算真正过关。用坐标编码图的好处是一旦某个模式地址映射写错失败点的坐标能直接告诉你在哪一行、哪一列出了问题定位效率非常高。6.3 上板观察与留一手上板调试时我习惯先输出一帧直通图确认整个通路没问题再逐个开翻转模式。如果直通正常、翻转异常问题基本锁定在地址生成或同步打拍如果直通就不对先别碰翻转代码去查数据通路。这个顺序能省掉一半的调试时间。在线逻辑分析仪抓rd_addr和rd_en也是个验证办法但不如坐标编码图直观。我最早的版本只盯着波形看地址从逻辑上看全对上板却怎么都斜折腾两天才发现是 DDR 行距填充没算进去。后来凡是帧缓存地址相关的设计我都会先画一行存储布局草图再写代码坐标编码测试图也一直留着——它让“翻转到底对了没”从一个问题变成一眼就能确认的事。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑