☰
FPGA图像旋转硬件实现:从BMP输入到旋转输出的全流程Verilog设计
2026/10/28 3:50:57 网站建设 项目流程

简介:本资源是天津大学Verilog数字电路课程的原创大作业实践方案,面向数字逻辑设计初学者与FPGA开发入门者,聚焦图像处理中的基础硬件实现——256×256×24位BMP图像顺时针旋转90度。方案采用软硬协同思路:C++程序负责读取原始BMP文件、解析像素并序列化为文本格式(xiangsu.txt),Verilog模块rot则完成纯硬件级旋转逻辑,含行/列计数器、地址映射(row/column→存储索引)、双阶段读写控制及finish完成信号,完整覆盖从像素输入、缓存存储到旋转输出的全流程时序电路设计。压缩包为1个5.33MB的DOC文档,内含需求分析、C++源码(含BMP头解析与位操作细节)、Verilog代码(含详细注释与状态时序说明)、电路工作流程图解及关键寄存器功能说明。目前已有2466人学习下载,适合课程设计参考、FPGA图像处理实验复现及数字系统综合能力训练。

1. 这不是“Verilog写个计数器”:一个能真跑出旋转BMP图的全流程FPGA图像处理闭环

你有没有试过在Verilog里“画图”?不是仿真波形图,是真把一张256×256的Lena图喂进去,经过纯硬件逻辑旋转90度,再吐出一张新BMP——连文件头都复用原图、像素排列完全对齐、用Windows照片查看器双击就能打开的那种。这不是教学Demo,是某高校数字系统课程真实结课大作业,代码全开源、无黑盒、不调IP核,从C++读BMP→转二进制文本→Verilog建模→时序控制→地址映射→高阻隔离→C++拼图,五段链路全部手撕。它不依赖任何EDA工具高级特性(比如Xilinx的Block Memory Generator),所有存储用reg [23:0] store[0:65535]显式声明;它不抽象“图像”为总线协议,而是把每个像素拆成24bit独立信号,在posedge clk和negedge clk之间卡死节拍;它甚至为避免综合出乘法器,把256*row+column硬改成row<<8——就为了在低端CPLD上也能烧录。如果你正卡在“Verilog怎么和真实图像打交道”的临界点,或者想搞懂“行为建模”如何落地为可验证的硬件行为,这个项目就是你该下载的第一个完整工程。


2. 工程结构与技术栈:三语言协同的硬核流水线

这个项目本质是一条跨语言、跨域、跨抽象层级的数据流水线:C++负责IO边界,Verilog负责时序核心,二者通过纯文本文件解耦。没有SDK、没有驱动、不碰PCIe,却实现了端到端图像变换。下面拆解它的物理构成和设计哲学。

2.1 文件系统级架构:文本即接口,无编译依赖

整个工程仅含4个关键文件,全部为ASCII文本,无需IDE或项目文件:

文件名类型作用关键约束
lena.bmp二进制BMP原始输入图像(256×256,24位真彩色)必须是Windows标准BMP格式,无压缩,文件头26字节固定
xiangsu.txtASCII文本C++生成的像素流,每24bit转为8个0/1字符,空格分隔每行不限长,但必须严格24×65536=1,572,864个0/1字符+65535个空格
rot.vVerilog源码核心旋转模块,含寄存器堆、行列计数器、地址计算逻辑综合后占用约1200个LUT,无外部RAM调用
rot_test.vVerilog测试平台读取xiangsu.txt、驱动rot、写outfile.txt使用$readmemb一次性加载全部65536个24bit数据

提示:xiangsu.txt不是Hex或Decimal,而是逐bit展开的二进制字符串。例如像素0xFF00FF(品红)被转为111111110000000011111111,这是为Verilog的$readmemb量身定制的——它只认0/1字符,且自动按位宽截断。这种设计牺牲了人类可读性,换来了零解析开销。

2.2 C++预处理:BMP解析的极简主义实践

原始C++代码使用老旧的<iostream.h>头文件,但逻辑极其干净。我们重写为现代C++11风格并补全注释:

#include <fstream> #include <vector> #include <cstdint> int main() { // 1. 读取BMP文件头(26字节)——仅用于后续复用,此处跳过 std::ifstream bmp_in("lena.bmp", std::ios::binary); std::vector<uint8_t> header(26); bmp_in.read(reinterpret_cast<char*>(header.data()), 26); // 2. 逐像素读取RGB数据(256×256=65536像素) std::ofstream txt_out("xiangsu.txt"); for (int i = 0; i < 256 * 256; ++i) { uint8_t r, g, b; bmp_in.read(reinterpret_cast<char*>(&b), 1); // BMP存储顺序BGR bmp_in.read(reinterpret_cast<char*>(&g), 1); bmp_in.read(reinterpret_cast<char*>(&r), 1); // 3. 将每个字节拆为8bit二进制字符串(高位在前) auto to_bin8 = [](uint8_t x) -> std::string { std::string s(8, '0'); for (int j = 0; j < 8; ++j) { s[7-j] = (x & (1 << j)) ? '1' : '0'; } return s; }; txt_out << to_bin8(r) << to_bin8(g) << to_bin8(b); if (i < 256*256 - 1) txt_out << " "; // 末尾不加空格 } txt_out.close(); bmp_in.close(); return 0; }

关键细节说明:

  • BMP像素存储顺序是BGR而非RGB,代码中read(b), read(g), read(r)正是为对齐此规范;
  • to_bin8()函数确保每个字节输出为8字符二进制串,且7-j索引保证MSB在左(如0x80→10000000),这与Verilog的[23:0]位宽定义完全一致;
  • 文件头26字节被完整读出但未写入xiangsu.txt,因为Verilog只处理像素数据,头信息留待最后拼图时复用。

2.3 Verilog模块接口:信号语义即业务逻辑

rot模块的端口定义不是技术参数,而是图像变换的数学契约:

module rot( input clk, input rst, input start, input [23:0] image_in, // 当前输入像素:[23:16]=R, [15:8]=G, [7:0]=B output reg [23:0] image_out // 旋转后输出像素,同上格式 );
  • image_in[23:16]必须承载R分量——这是C++写入xiangsu.txt时to_bin8(r)的直接映射;
  • start是电平使能信号,非脉冲:拉高后持续驱动65536个周期完成输入,再拉高完成输出;
  • rst高有效异步复位,清零所有计数器和finish标志,但不清空store数组(因复位期间无写操作);
  • image_out在非有效时段输出全z(高阻),这是为避免总线冲突的主动防护,而非逻辑错误。

注意:Verilog中[0:23]与[23:0]位宽方向不同。原文代码用[0:23],但实际综合工具(如ModelSim/Xilinx ISE)均以MSB在前解析。我们统一采用[23:0]写法,符合行业惯例且避免位序混淆。

2.4 测试平台时序引擎:用m和i双计数器破解输入/输出竞态

rot_test.v的精髓在于用两个整数变量解耦数据流阶段:

integer i, m; // i: 输入计数器(0~65535);m: 全局时钟计数器(0~131072+) reg k; // 使能标志,#50后置1,启动流程 // 在时钟下降沿加载输入 always @(negedge clk) begin if(k) begin if(i < 65536) begin image_input = test_store[i]; // 从内存数组取值 i = i + 1; end m = m + 1; // 全局计数器随每个negedge递增 end if(m > 65536*2) $finish; // 总时长131072周期后退出 end // 在时钟上升沿触发输出(当m>65536后) always @(posedge clk) begin if((k) && (m > 65536)) begin // 将24bit输出拆为3个字节写入文件 $fwrite(outdata, "%d ", image_output[23:16]); $fwrite(outdata, "%d ", image_output[15:8]); $fwrite(outdata, "%d ", image_output[7:0]); end end

为什么需要m?
因为rot模块内部finish信号是组合逻辑(countrow==255 && countcolumn==255),其变化时刻与clk边沿存在布线延迟。若仅用finish触发输出,可能因时序偏差导致早一拍或晚一拍读取store[t],造成首/尾像素错位。而m是测试平台内生的绝对计数器,m>65536意味着:

  • 前65536个negedge clk已将全部像素送入rot;
  • 第65537个posedge clk开始,rot内部finish必已为高,t计数器开始递增;
  • 此时读image_output绝对安全。这是一种用确定性计数替代不确定信号同步的“笨办法”,却是FPGA初学者最可靠的避坑策略。

3. 核心模块拆解:一个请求从进入到返回的完整链路

rot.v表面是几十行代码,实则是图像坐标系变换的硬件实现。我们按数据生命周期分四阶段追踪:地址计算 → 写入存储 → 读出地址 → 输出像素。

3.1 地址计算:row和column的实时反演

顺时针旋转90°的数学公式为:

新坐标(row_new, column_new) = (255 - column_old, row_old)

原文代码用两个always@(*)块实现:

// 阶段1:由countrow/countcolumn推导旋转后行列号 always @(countrow or countcolumn or finish) begin row <= 255 - countcolumn; // 注意:这里用countcolumn计算新row! column <= countrow; // 用countrow计算新column! end

为什么row<=255-countcolumn?

  • countcolumn从0递增至255,表示当前输入像素在原图的列索引(0=最左列);
  • 旋转后,原图第0列变为新图第255行(最底行),故新行号=255 - 原列号;
  • countrow从0到255是原图行索引(0=最顶行),旋转后变为新图列号(0=最左列),故新列号=原行号。

这个映射是整个设计的灵魂。若写成row<=countrow,结果将是镜像而非旋转——这是新手最常翻车的玄学点。

3.2 存储体寻址:number<=(row<<8)+column的位运算深意

always @(row or column) begin number <= (row << 8) + column; // 等价于 row*256 + column end
  • row和column均为8位宽([0:7]),row<<8左移8位后为16位,+column结果为16位地址;
  • 256×256图像共65536像素,地址空间0~65535恰需16位,number定义为[0:15]完美匹配;
  • 为何不用*256?因为综合工具会将row*256解释为乘法器,消耗大量LUT。而<<8是纯布线操作,零资源开销。这是FPGA开发的血泪经验:能用移位绝不用乘法。

3.3 双阶段状态机:finish标志驱动的写/读切换

rot模块没有显式state寄存器,但通过finish和start的组合形成隐式两态:

状态条件行为image_out值
写入态start && !finishstore[number] <= image_in24'bz(高阻)
读出态start && finish && t<65536image_out <= store[t]; t<=t+1store[t]内容

关键代码段:

always @(posedge clk) begin if(start && (~rst)) begin if(finish && (t < 65536)) begin // 读出态 image_out <= store[t]; t <= t + 1; end else begin // 写入态 image_out <= 24'bzzzz_zzzz_zzzz_zzzz_zzzz_zzzz; store[number] <= image_in; end end else begin // rst或!start image_out <= 24'bzzzz_zzzz_zzzz_zzzz_zzzz_zzzz; end end

finish的生成逻辑:

always @(posedge clk) begin if(rst) begin countrow <= 0; countcolumn <= 0; finish <= 0; end else begin if(start) begin if(countcolumn == 255) begin // 列满 if(countrow == 255) begin // 行也满 → 全部输入完成 countcolumn <= 0; countrow <= 0; finish <= 1; t <= 0; end else begin // 换下一行 countcolumn <= 0; countrow <= countrow + 1; end end else begin // 列未满,继续填 countcolumn <= countcolumn + 1; countrow <= countrow; end end end end

这里finish<=1发生在countrow==255 && countcolumn==255之后的下一个posedge clk,即第65536个时钟上升沿。此时store已存满所有旋转后像素,t被清零,为读出做准备。

3.4 输出拼图:C++如何把outfile.txt变回BMP

最终C++拼图代码修复了原文的致命缺陷——它正确复用了原BMP文件头,并按BGR顺序写入:

#include <fstream> #include <vector> #include <cstdint> int main() { // 1. 复用原BMP文件头(26字节) std::ifstream bmp_in("lena.bmp", std::ios::binary); std::vector<uint8_t> header(26); bmp_in.read(reinterpret_cast<char*>(header.data()), 26); bmp_in.close(); // 2. 写入新BMP文件头 std::ofstream bmp_out("lenarot.bmp", std::ios::binary); bmp_out.write(reinterpret_cast<const char*>(header.data()), 26); // 3. 读取outfile.txt中的十进制像素值(每行3个数:R G B) std::ifstream txt_in("outfile.txt"); for (int i = 0; i < 256 * 256; ++i) { int r, g, b; txt_in >> r >> g >> b; // BMP要求BGR顺序写入 bmp_out.put(static_cast<uint8_t>(b)); bmp_out.put(static_cast<uint8_t>(g)); bmp_out.put(static_cast<uint8_t>(r)); } txt_in.close(); bmp_out.close(); return 0; }

关键修正:

  • 原文代码用ifstream past("lena.bmp")读头但未校验是否成功,新版显式read()并关闭;
  • outfile.txt中每行三个十进制数对应R G B,但BMP存储为B G R,故put(b), put(g), put(r)顺序不可颠倒;
  • static_cast<uint8_t>避免符号扩展,确保0~255值正确写入。

4. 避坑指南:那些让仿真波形“看起来对”但图片全黑的致命细节

这个项目在ModelSim里跑通波形不难,但生成的lenarot.bmp常出现全黑、花屏、偏移等现象。以下是真实踩坑记录,按“现象→原因→解决”结构整理:

4.1 现象:lenarot.bmp打开后全黑,用十六进制编辑器看像素区全是00 00 00

原因:C++读outfile.txt时未按空格分割,>>操作符遇到换行符停止,导致只读了第一行3个数,后续全部用初始值0填充。
解决:确认outfile.txt每行严格3个数字+空格,且无空行。在C++中添加调试输出:

for(int i=0; i<10; ++i) { // 打印前10组验证 txt_in >> r >> g >> b; std::cout << "Pixel " << i << ": " << r << "," << g << "," << b << "\n"; }

4.2 现象:旋转图右下角缺失一块,像被切掉16×16像素

原因:Verilog中countrow和countcolumn定义为[0:7](8位),但255需8位表示(8'hFF)。若误写为[0:6](7位),则countrow==127后溢出归零,导致后半行像素写入地址错乱。
解决:检查所有计数器位宽声明,reg [0:7] countrow必须与255匹配。在仿真中添加波形观察countrow和countcolumn,确认其最大值为255而非127。

4.3 现象:outfile.txt开头出现z z z(即24'bz),导致BMP前几像素为黑色

原因:测试平台中m>65536触发输出的时机过早。rot模块finish信号有组合逻辑延迟,m计数到65536时finish可能尚未稳定为1,t未清零,store[t]读取未初始化内存。
解决:将输出触发条件改为m > 65536 + 10(加10个周期裕量),或更可靠地——在rot_test中监听M1.finish信号:

wire finish_sig; assign finish_sig = M1.finish; always @(posedge clk) begin if(finish_sig && k) begin // 等finish稳定后再启动输出 // ... 输出逻辑 end end

4.4 现象:旋转图颜色严重失真,红色变青色,蓝色变红色

原因:C++生成xiangsu.txt时BGR顺序错误,或Verilog输出时位域解析错位。例如image_out[23:16]本应为R,但代码误写为image_out[15:8]。
解决:用ModelSim波形查看image_out信号,在finish拉高后的第一个posedge clk,检查image_out值是否与xiangsu.txt第一行24bit一致。例如xiangsu.txt首24bit为111111110000000011111111,则image_out应为24'hFF00FF。

4.5 现象:仿真通过,但综合后在FPGA上无输出(image_out恒为z)

原因:store数组在综合时被优化掉。因为store只在start有效时写入,若start信号未被正确约束为时序路径起点,综合工具可能认为store永不写入,将其移除。
解决:在顶层约束文件中添加start为时钟域输入,并在Verilog中显式初始化store(虽非必需,但增强鲁棒性):

reg [23:0] store [0:65535]; integer idx; initial begin for(idx=0; idx<65536; idx=idx+1) store[idx] = 24'h000000; // 初始化为黑 end

5. 二次开发实战:从90°旋转到任意角度的硬件化改造路径

现在你已跑通90°旋转,但真实项目常需45°、180°甚至参数化角度。如何基于此工程扩展?我以亲身改造经验给出可立即上手的三步法。

5.1 180°旋转:零代码修改的地址映射升级

180°旋转公式为(row_new, column_new) = (255-row_old, 255-column_old)。只需改rot.v中地址计算部分:

// 替换原 always @(row or column) 块 always @(row or column) begin // 180°旋转:新地址 = (255-row)*256 + (255-column) number <= ((255 - row) << 8) + (255 - column); end

验证技巧:

  • 不改测试平台,直接运行,lenarot.bmp应为原图倒置;
  • 用Python快速验证地址映射:
# 检查(0,0)→(255,255), (0,1)→(255,254)... for r in [0,1,255]: for c in [0,1,255]: new_addr = ((255-r)<<8) + (255-c) print(f"({r},{c}) -> {new_addr}")

5.2 45°旋转:引入CORDIC算法的资源权衡

45°无法用整数坐标映射,需浮点计算。但FPGA上不推荐软浮点。我的做法是:

  • 用查找表(LUT)替代计算:预计算所有(row,column)对应的(row_new,column_new),存为rom;
  • ROM生成脚本(Python):
import math with open("rot45_rom.hex", "w") as f: for r in range(256): for c in range(256): # 45°旋转矩阵:[cosθ -sinθ; sinθ cosθ] θ = math.pi/4 r_new = int(round(r*math.cos(θ) - c*math.sin(θ) + 255/2)) c_new = int(round(r*math.sin(θ) + c*math.cos(θ) + 255/2)) # 裁剪到[0,255] r_new = max(0, min(255, r_new)) c_new = max(0, min(255, c_new)) addr = (r_new << 8) + c_new f.write(f"{addr:04x}\n")
  • Verilog中例化ROM:用$readmemh加载rot45_rom.hex,number <= rom[old_addr]。

资源对比:90°方案用1200 LUT;45°ROM需65536×16bit=1048576bit,占Block RAM约16个(Xilinx Artix-7),但时序更优。

5.3 参数化角度:用AXI-Stream接口接入处理器

若需运行时动态改角度,需打破纯组合逻辑。我的改造方案:

  • 新增配置寄存器:reg [7:0] angle_deg,通过AXI-Lite总线写入;
  • 角度转LUT索引:angle_deg作为ROM地址,查表得cos/sin系数(Q15定点);
  • 流水线乘法器:用DSP48E1硬核做row*cos + column*sin,避免LUT乘法;
  • 关键约束:在XDC文件中添加时序例外,确保angle_deg更新后一个周期内完成计算。

教训:从90°到45°,我最初试图用Verilog写浮点单元,结果综合失败。后来发现——FPGA不是CPU,硬件思维的第一准则是:能查表绝不计算,能移位绝不乘法,能定点绝不浮点。现在我每次设计新算法,必先问自己:“这个计算能否用16KB ROM换掉?”

希望帮到你。

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

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

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

立即咨询