☰
Verilog实现BMP图像旋转90度:从字节流到存储器重排的完整实践
2026/11/5 2:22:31 网站建设 项目流程

简介:这份资源面向数字电路与FPGA学习者,聚焦用Verilog实现图像旋转这一典型课程设计题目,帮助读者理解如何把BMP位图数据读入硬件模块、按像素重排后顺时针旋转90度输出。压缩包内共1个doc文档,约267KB,为完整的大作业设计说明书,涵盖作业要求、设计思路、Verilog与C++源代码、仿真结果及参考资料,并详细讲解BMP文件头、位图信息头与像素阵列结构,以及数据读取、缓存重排、写回三段式模块划分方法。已有1104人学习下载,适合正在做图像处理类Verilog大作业或想练习testbench文件读写的读者参考,可据此理清从C++预处理txt数据到ModelSim仿真的完整链路与模块划分思路。

1. 从一张 256×256 的 Lena 图说起:这个 Verilog 图像旋转项目到底在做什么

一张 256×256 的 24 位 BMP 图片,像素阵列正好 196608 字节,加上 54 字节文件头,总共 196662 字节。这个数字看着不起眼,但它是一道很典型的大作业题面:用 Verilog 把这张图顺时针旋转 90 度,而且不是调库,是从字节流开始自己搬。我第一次看到这个题目时的直觉是「图像处理不是软件干的活吗」,翻完代码才明白,它真正考的是三件事——BMP 格式的字节级理解、Verilog 里用存储器重排像素的地址映射、以及 testbench 读写文件的仿真闭环。这套东西适合正在学数字电路设计、要交综合设计作业、或者想搞明白「硬件描述语言怎么碰真实数据」的开发者。它不追求吞吐和流水线,追求的是把一条从文件到文件的数据通路完整跑通,跑通之后你再去理解行缓存、双端口 RAM、流水线旋转,就有了具体的锚点。

2. 工程结构与数据通路:三个模块如何把 BMP 拆开再拼回去

这个项目的骨架其实很朴素,整条链路是「BMP 二进制 → 文本化像素 → Verilog 存储器重排 → 文本化结果 → BMP 二进制」。它没有综合成真实电路去驱动显示器,而是在仿真环境里用$readmemb、$fwrite这类系统任务完成文件 I/O。理解这一点很关键,否则你会一直纠结「Verilog 怎么能读文件」——能,但那是仿真期的行为,不是可综合逻辑。

2.1 目录与文件职责划分

按大作业说明书的描述,工程里实际流转的文件大致是这么几类,我按职责列一下,方便你对照自己手上的包:

文件/模块角色关键点
file1.bmp原始输入图必须 256×256、24bit、无压缩
file1.dat剥离出的 54 字节文件头最后拼回 BMP 时要用
file.txt像素的二进制文本每字节 8 个 0/1,空格分隔
zprotate.v核心旋转模块存储器重排 + 文件读写
ram24bit2.txt旋转后的 24bit 像素(十六进制)中间结果
final.txt/result.txt旋转后的字节十进制供 C++ 拼回 BMP
tupian.bmp最终输出图验证旋转是否正确

这张表是我建议你在自己工程里先对齐的东西。很多同学跑不通,不是 Verilog 写错,而是中间文件名对不上——C++ 写的是file.txt,Verilog 读的也是file.txt,但 C++ 输出目录和仿真工作目录不是同一个,于是$readmemb读到空文件,仿真不报错,输出全 0,最后拼出来的图一片黑。这种坑后面第 5 章会专门讲。

2.2 为什么要在 C++ 和 Verilog 之间插一层文本

原始设计里,C++ 负责把 BMP 的二进制像素逐字节转成 8 位二进制字符串写进 txt,Verilog 再读这个 txt。有人会问,为什么不直接让 Verilog 读二进制?原因是$readmemb读的是「二进制文本」,$readmemh读的是「十六进制文本」,它们都不直接吃原始二进制字节流。用 C++ 做这层转换,本质是把「字节」翻译成「仿真器认识的文本格式」,这是那个年代没有 SystemVerilog DPI 时的常规做法。我一般会把这个转换脚本单独放一个目录,输入输出路径都用相对路径,避免换机器就崩。

2.3 旋转的地址映射:一行公式说清 90 度怎么来的

核心算法就一行地址变换。原图按行优先存储,像素(row, column)的线性下标是column*256 + row(注意这里 row/column 的定义和直觉略有出入,代码里post_position=column*256+row)。旋转后写到current_position = 256*(255-row)+column。这两个式子合起来,就是把「第 column 列、第 row 行」的像素搬到新图的对应位置,实现顺时针 90 度。你不需要背公式,但要能自己推一遍:拿四个角点代进去,看 (0,0) 去哪、(0,255) 去哪,验证方向对不对。方向搞反是这类作业最高频的翻车点,转出来是镜像或者逆时针,图能看但不对。

3. 核心代码逐段拆:C++ 预处理、Verilog 重排、C++ 回拼

这一章把三段关键代码摊开讲。每段我都标了它解决什么问题、哪些参数能改、改错会怎样。你照着抄能跑,但更重要的是知道每一行在数据通路上站什么位置。

3.1 C++ 第一步:剥头 + 字节转二进制文本

#include <iostream> #include <fstream> using namespace std; int main() { short int header[27]; // 54 字节文件头,按 short 读 27 个 bool pixel[3]; // 一个像素 3 字节 int n = 256 * 256; // 像素总数 ifstream picture("file1.bmp", ios::binary); ofstream outfile("file.txt", ios::binary); picture.read((char *)header, sizeof(header)); // 跳过 54 字节头 for (int i = 0; i < n; i++) { picture.read((char *)pixel, sizeof(pixel)); // 读 3 字节 BGR for (int j = 0; j < 3; j++) { int val = int(pixel[j]); int s[8]; for (int k = 0; k < 8; k++) { // 十进制转 8 位二进制 s[k] = val % 2; val = val / 2; } for (int k = 0; k < 8; k++) outfile << s[7 - k]; // 高位先写 outfile << ' '; } } picture.close(); outfile.close(); return 0; }

这段代码的逻辑是:BMP 前 54 字节是文件头和信息头,24 位真彩色没有调色板,所以直接跳过 54 字节就是像素阵列。每个像素 3 字节,BMP 里存储顺序是 B、G、R,代码原样读出、原样转二进制,不做通道交换——这一点很重要,旋转不改变颜色通道顺序,交换了反而会让红蓝对调。s[7-k]保证高位在前,和$readmemb的位序约定一致。参数上唯一要动的是n,如果你换成 128×128 的图,这里改成128*128,同时 Verilog 里的size24也要跟着改,两边必须同步,否则读越界。

提示:原始代码用的是#include<iostream.h>这种老式写法,现代编译器会报错,改成上面这种标准头文件加using namespace std;即可,逻辑一行没变。

3.2 Verilog 核心:双存储器重排 + 时钟节拍

module zprotate; parameter size24 = 65535; // 256*256-1 parameter size8 = (size24+1)*3-1; // 字节数-1 reg clk; reg [0:7] ram8bits [0:size8]; // 字节级存储 reg [0:23] ram24bit1 [0:size24]; // 原始像素 reg [0:23] ram24bit2 [0:size24]; // 旋转后像素 integer counter, file, row, column, post_position, current_position, x; initial clk = 0; always #5 clk = ~clk; // 10 时间单位一个周期 initial $readmemb("file.txt", ram8bits); // 读入二进制文本 initial begin for (counter = 0; counter <= size24; counter = counter + 1) ram24bit1[counter] = {ram8bits[3*counter], ram8bits[3*counter+1], ram8bits[3*counter+2]}; // 3 字节拼 1 像素 end initial begin for (column = 0; column < 256; column = column + 1) for (row = 0; row < 256; row = row + 1) begin post_position = column * 256 + row; current_position = 256 * (255 - row) + column; @(posedge clk) // 每个时钟搬一个像素 ram24bit2[current_position] = ram24bit1[post_position]; end end initial begin wait (column == 256); // 等重排完成 file = $fopen("ram24bit2.txt"); for (x = 0; x <= size24; x = x + 1) $fwrite(file, "%h ", ram24bit2[x]); // 十六进制落盘 $fclose(file); end endmodule

这段是整个项目的黑匣子核心。三个initial块分别干三件事:读文件、拼像素、按时钟重排。@(posedge clk)放在内层循环里,意味着每搬一个像素等一个上升沿,256×256 就是 65536 个周期,仿真时间不算长但也不短,跑之前把仿真时长设够。ram24bit1和ram24bit2是两个独立存储器,读一个写一个,避免原地覆盖导致数据错乱——这是重排类算法的通用套路,我一般叫它「双缓冲」。参数size24和size8是硬编码的,换图必须改,而且size8依赖size24,改一个另一个自动跟着变,这点设计得还算省心。

注意:wait (column == 256)依赖前面循环把column加到 256 才触发,这种写法在仿真器里能跑,但可读性差、时序脆弱。真要改,我建议用一个done标志位在重排结束后置 1,写文件块wait(done),逻辑更清楚。

3.3 C++ 第二步:把旋转结果拼回 BMP

#include <iostream> #include <fstream> using namespace std; int main() { char ch; int a[256 * 256], s[8]; ifstream header("file1.dat", ios::in | ios::binary); // 之前存下的 54 字节头 ifstream pixels("result.txt", ios::in | ios::binary); // 旋转后十进制字节 ofstream out("tupian.bmp", ios::out | ios::binary); while (header.get(ch)) out.put(ch); // 先写回头 for (int i = 0; i < 256 * 256; i++) pixels >> a[i]; // 读十进制 for (int j = 0; j < 256 * 256; j++) { for (int k = 0; k < 8; k++) { // 十进制转二进制字节 s[k] = a[j] % 2; a[j] = a[j] / 2; } for (int k = 0; k < 8; k++) out << s[7 - k]; } header.close(); pixels.close(); out.close(); return 0; }

回拼的关键是「头 + 像素」顺序不能乱,且像素必须按字节写回,不能带空格。这里result.txt里是十进制数、空格分隔,所以用>>读;如果上游给的是十六进制,就得换std::hex。文件头file1.dat是第一步单独存下来的,如果你第一步没存,就得重新从原图读 54 字节,别想着手写一个头——宽度、高度、文件大小、像素数据偏移这些字段错一个,图片就打不开。我见过有人图省事直接复制别的图的头,结果尺寸对不上,打开是花屏。

4. 从零跑通:仿真环境、文件路径与验证闭环

代码看懂是一回事,跑起来是另一回事。这一章按实际操作顺序走一遍,重点放在「怎么确认每一步的输出是对的」,而不是「点哪个按钮」。

4.1 环境准备与编译顺序

你需要一个 Verilog 仿真器(ModelSim、Icarus Verilog 都行)和一个 C++ 编译器。顺序是固定的:先跑 C++ 预处理生成file.txt,再跑 Verilog 仿真生成ram24bit2.txt和final.txt,最后跑 C++ 回拼生成tupian.bmp。三步之间靠文件传递,任何一步的输出路径错了,下一步就吃空文件。我一般会在工程根目录建input/、work/、output/三个文件夹,所有路径写相对路径,仿真器的工作目录设成work/,这样换机器只要目录结构一致就能复现。

用 Icarus Verilog 的话,编译和运行大概是这样:

iverilog -o rotate zprotate.v vvp rotate

iverilog只做编译,vvp才是真正跑仿真。跑完检查当前目录有没有生成ram24bit2.txt,文件大小应该是 65536 个十六进制数加空格,大概几百 KB。如果文件是 0 字节,八成是$readmemb没读到file.txt,先确认文件在不在、路径对不对。

4.2 每一步的验证点

不要等最后看图才发现错,每一步都设一个检查点。第一步跑完,用文本编辑器打开file.txt,看开头是不是一串 0/1 加空格,长度对不对;第二步跑完,看ram24bit2.txt前几个值是不是合理的十六进制颜色;第三步跑完,用系统看图工具打开tupian.bmp,正常应该是一张旋转了 90 度的 Lena。如果图能打开但内容是乱的,问题多半在地址映射;如果图打不开,问题在文件头或字节数。

4.3 方向验证:四个角点法

旋转方向对不对,不用等整张图,取四个角点手算就行。原图左上角像素下标 0,按公式current_position = 256*(255-0)+0 = 65280,也就是新图的左下角;原图右上角(column=255,row=0)下标 255,映射到256*255+255 = 65535,新图右下角。顺时针 90 度时,原图左上应该跑到新图右上,如果你算出来跑到左下,说明公式里的255-row方向反了,改成row试试,或者把column和row对调。这个手算过程五分钟,比跑一遍仿真快得多。

5. 避坑与排查:这类文件 I/O 型 Verilog 作业最容易翻的车

这一章是我从这类项目里总结出的高频问题,每条都按「现象 → 原因 → 解决」写,你对照自己的现象查,基本能定位。

5.1 仿真不报错但输出全 0

现象:仿真正常结束,ram24bit2.txt里全是 0 或者空。原因:$readmemb读的file.txt不在仿真工作目录,或者文件格式不是纯 0/1 文本(比如带了 BOM、用了逗号分隔)。解决:把file.txt放到仿真器实际的工作目录,用十六进制编辑器确认文件头没有多余字节;$readmemb对格式很挑,空格分隔、每字节 8 位、不要换行混排。

5.2 拼出来的 BMP 打不开

现象:tupian.bmp双击提示损坏或无法预览。原因:文件头 54 字节没写对,或者像素字节数不是 196608。解决:确认file1.dat确实是原图前 54 字节,且回拼时先写头再写像素;用ls -l看输出文件大小,正确应该是 196662 字节,差一个字节都打不开。

5.3 图能看但颜色红蓝对调

现象:旋转成功,但人脸偏蓝、背景偏红。原因:BMP 像素存储是 BGR 顺序,某一步做了通道交换。解决:检查 C++ 预处理和回拼两段,确认都没有交换pixel[0]和pixel[2];旋转本身不改变通道顺序,别在这里画蛇添足。

5.4 旋转结果是镜像或逆时针

现象:图方向不对。原因:地址映射公式的 row/column 定义和直觉相反,或者255-row写成了row。解决:用 4.3 的角点法手算验证,确认post_position和current_position两个式子的行列含义一致,改完重新跑。

5.5 换图后仿真卡死或越界

现象:换成 128×128 的图后仿真跑很久或报数组越界。原因:size24、size8、C++ 里的n三处没同步改。解决:把尺寸做成一个参数源头,C++ 里用宏或常量,Verilog 里改size24让size8自动派生,三处对齐再跑。

6. 二次开发:把它从「能转」改成「转得快、转得活」

这个项目跑通之后,最有价值的改造不是换张图,而是把它往真实硬件设计的思路上推。我一般会从三个方向动手,每个方向都能让你对「存储器重排」这件事理解更深一层。

第一个方向是去掉文件 I/O,改成真正的流式接口。现在的设计靠$readmemb和$fwrite,本质是仿真脚本,不可综合。改造思路是给模块加din、din_valid、dout、dout_valid端口,内部用行缓存(line buffer)暂存若干行,按像素流进、按旋转后的顺序流出。旋转 90 度需要缓存整幅图或者至少一整列,256×256×24bit 就是 1.5Mbit,片上 RAM 放不下,实际做法是外挂 SRAM 或者分块处理。这一步做完,你才真正理解为什么图像旋转在硬件里是个「存储换带宽」的问题。

第二个方向是把双缓冲改成双端口 RAM 或乒乓结构。现在ram24bit1读、ram24bit2写,两个独立数组,综合时会变成两块大 RAM。如果改成一块双端口 RAM,读地址和写地址分开给,面积能省一半,但要注意读写冲突——同一周期读写同一地址会出问题,得靠地址调度避开。这个改造很适合拿来练时序分析。

第三个方向是参数化。把 256、24、90 度这些魔数抽成parameter WIDTH=256, HEIGHT=256, DEPTH=24,地址映射写成通用公式,换尺寸只改参数不改逻辑。下面这个片段是我常用的参数化写法,你可以直接替换原来的硬编码:

parameter WIDTH = 256; parameter HEIGHT = 256; // 顺时针 90 度:新图宽=HEIGHT,高=WIDTH wire [31:0] src_addr = col * WIDTH + row; wire [31:0] dst_addr = (HEIGHT - 1 - row) * HEIGHT + col;

改完记得用 4.3 的角点法重新验证一遍方向,参数化最容易在边界上翻车。验证方法我习惯是「小图先行」:先拿 4×4 的手工构造图跑,每个像素值就是它的坐标,转完一眼能看出对不对,再上 256×256 的真图。

说个我自己的教训。早些年做类似的重排项目,我图省事直接在原数组上原地旋转,觉得省一块 RAM 很聪明,结果跑出来一半像素被覆盖、图成了抽象画。从那以后我每次做存储器重排,都强制先画一张「读地址 → 写地址」的对照表,确认没有读写重叠再动手写代码,这个习惯帮我省了无数次 debug。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询