前阵子把手头几块 FPGA 开发板翻出来,想正经跑一遍“摄像头采集 -> 边缘检测 -> VGA显示”的完整图像处理链路。网上关于边缘检测的教程不少,但多数停在仿真层面,真正能在板子上实时跑起来、支持 OV7725 和 OV7670 两种摄像头的完整代码并不多。我前后调了两周,把 I2C 配置、SDRAM 缓存、Sobel 算子、VGA 时序全部打通,过程中也踩了不少坑。这篇就把整个项目的思路、关键代码、硬件调试经验完整记录下来,给准备做 FPGA 图像处理实战的朋友一个可直接抄作业的参考。
项目本身做的事情很简单:摄像头采集 RGB565 图像数据,通过 FPGA 写入 SDRAM 做乒乓缓存,再从 SDRAM 读出,先转成灰度图,再用 Sobel 算子提取边缘,最后用 VGA 输出二值化边缘图像。整套设计用 Quartus 完成编译和下载,代码是纯 Verilog,不依赖任何厂商私有 IP,在 Cyclone IV 这类主流芯片上都能直接跑。适合刚学完 Verilog 语法、想上手真实项目的同学,也适合做智能车或工业视觉预处理的老手做底板参考。
1. 项目概述:一个能跑的实时边缘检测框架
1.1 整体数据流与模块划分
整个系统的数据流是串行处理的,严格按下面的顺序流动:
OV7725/OV7670 摄像头输出 RGB565 并行数据,每像素 16bit,同时输出 PCLK、VSYNC、HREF/HSYNC 三个同步信号。RGB565 数据先进入一个异步 FIFO,跨时钟域后写入 SDRAM 控制器。SDRAM 里存一帧完整的图像,通过乒乓操作实现“边写边读”。下一拍,SDRAM 读出的数据经过一个 RGB888 转 YCbCr 的灰度化模块,得到 8bit 灰度值。灰度值进入 Sobel 核心模块,经过三行缓冲和卷积运算,输出二值化边缘信号。最后,边缘信号和 VGA 时序生成模块的坐标信号同步,映射成 RGB555 输出到显示器。
这个架构在 FPGA 项目里属于非常经典的“方案级”设计——摄像头、存储、处理、显示四大块各司其职,每一块都可以单独调试,不会互相干扰。
1.2 为什么选择 OV7725 和 OV7670 这两颗摄像头
OV7670 是入门玩家最熟悉的一颗 30 万像素传感器,YUV/RGB 输出格式齐全,内部寄存器通过 SCCB 接口配置,最关键的是它便宜、资料多、开发板上几乎人手一颗。OV7725 则可以看作 OV7670 的加强版,帧率更高,支持 60fps 的 VGA 输出,而且带自动增益和自动白平衡,弱光环境下的表现比 OV7670 好不少。
我在设计时把两个摄像头模块抽象成了同一套接口——都是 SCCB 配置、都是 RGB565 输出、都提供 8bit 像素端口和三个同步信号。这样做的直接好处是:底层逻辑完全复用,只需要在初始化脚本里切换寄存器文件。如果你手头只有其中一个型号,代码也不需要做任何裁剪,直接屏蔽另一套初始化序列即可。
2. 摄像头配置:OV7725 与 OV7670 的寄存器调教
2.1 SCCB 时序与关键寄存器配置
两个摄像头都使用 SCCB(Serial Camera Control Bus)协议,其实就是 I2C 的变种。FPGA 作为主机,通过两根线 SIO_C 和 SIO_D 写入摄像头内部的寄存器。OV7670 的从机地址写地址是 0x42,读地址是 0x43;OV7725 也是 0x42 写、0x43 读。地址为 8bit,内部寄存器地址也是 8bit,每个寄存器值 8bit。
我一开始直接用 I2C 控制器的代码去驱动 SCCB,也能工作,但需要注意 SCCB 对 ACK 的处理比标准 I2C 宽松,而且是纯单向写的场景较多。真正影响稳定性的是三根线的时序——SIO_C 时钟频率不要超过 400kHz,我实测用 100kHz 最稳,FPGA 侧用一个计数器分频产生,不需要 PLL 单独给 SCCB 一个时钟。
下面列几个 OV7725 初始化时绕不开的寄存器:
// OV7725关键初始化寄存器(部分) 8'h12: 8'h80 // COM7: 复位,RGB输出,VGA有效 8'h40: 8'h21 // COM15: RGB565输出格式 8'h17: 8'h22 // HSTART: 水平起始位置 8'h18: 8'h84 // HSTOP: 水平结束位置 8'h19: 8'h07 // VSTRT: 垂直起始位置 8'h1A: 8'hF1 // VSTOP: 垂直结束位置 8'h0D: 8'h41 // BLLF: 自动黑电平补偿OV7670 初始化时有一个必须注意的寄存器序列:寄存器 0x11(CLKRC)决定内部时钟分频。如果这个值设错,像素时钟 PCLK 会乱,最终图像会发生“拉丝”或整帧错位。常用配置是 CLKRC = 0x01,即输入时钟 24MHz,内部不倍频,PCLK 约等于 24MHz,正好符合 VGA 640x480@60Hz 的像素需求。
配置完成后,建议读回寄存器值做一次完整性校验。这个操作虽然会多写几行代码,但能排除 SCCB 时序慢导致寄存器漏写的问题。漏写的表现是整个画面颜色不对,且调节白平衡寄存器完全没反应。
2.2 PCLK 采样与数据对齐
摄像头数据采用并行输出,PCLK 上升沿数据有效,但有一个很隐蔽的问题:PCLK 频率和相位与 FPGA 内部时钟完全不同,属于典型的跨时钟域采样。处理方式是在 PCLK 所在的时钟域里直接用 PCLK 做寄存器采样,采完后再用双寄存器同步到系统时钟域。
// PCLK域采样 always @(posedge cam_pclk) begin cam_data_r <= cam_data_in; cam_href_r <= cam_href_in; cam_vsync_r <= cam_vsync_in; end // 同步到系统时钟域 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cam_data_sync <= 16'b0; end else begin cam_data_sync <= cam_data_r; end end这个双寄存器同步只消除亚稳态风险,并不能保证数据在系统时钟域里仍然是完整的“一行”。所以后面写入 SDRAM 时还要依赖 HREF 信号来划定有效行区域。HREF 高电平期间内的像素才要写入,低电平(行消隐)期间的数据全部丢弃。这个规则一定要严格遵守,否则 SDRAM 里存出的画面会是斜的。
我实测过 OV7670 的 HREF 时序,行有效像素数并不严格等于 640,有时候是 644 或者 652。解决的思路是不要计数器硬数到 640 就认为一行结束,而是直接把 HREF 高电平作为写有效信号,SDRAM 只按顺序写有效数据,显示端再做行同步对齐。这样即使像素数有一点差异,画面依然能稳定显示。
3. SDRAM 缓存:整个系统的核心肌肉
3.1 为什么必须要 SDRAM 而不是流式直通
有朋友可能会问,摄像头进来的数据不就一帧吗,直接在 FPGA 里做灰度+卷积然后输出到 VGA 不就行了?理论上可行,但实际做不到。原因在于摄像头输出的帧率是 30fps 或 60fps,而 VGA 显示器的扫描节奏由显示器的行场同步信号决定,两者并不是天然同步的。如果没有缓存,就会出现画面撕裂、上半帧下半帧错位的问题。
SDRAM 在这里的作用是做一个完整的帧缓存,让摄像头的写入节奏和 VGA 的读出节奏解耦。写入端由摄像头帧同步控制,读出端由 VGA 时序控制,两边互不等待,只要 SDRAM 带宽够,就不会丢帧。我测试过不加缓存直接流水线处理的方案,画面断层非常严重,基本不可用。
3.2 带宽计算与读写仲裁
以 640x480@60fps 输出、RGB565 数据为例,每帧数据量是 640×480×2 字节 = 614400 字节,约 600KB。摄像头每秒产生 30 帧时,写入带宽需求为 600KB×30 = 18MB/s。VGA 显示端从 SDRAM 读出的带宽同样按 60fps 计算,需要 600KB×60 = 36MB/s。两者相加大约 54MB/s。
SDRAM 本身的工作频率为 133MHz,数据位宽 16bit,理论带宽是 133MHz×2 字节 = 266MB/s,看起来绰绰有余。但 SDRAM 有刷新周期、激活延迟、预充电延迟等额外开销,实际可用带宽往往只有理论值的 60% 到 70%,即 160MB/s 左右。54MB/s 的需求只占实际带宽的三成多,所以一个单 Bank SDRAM 就足够,不需要上 DDR 或双 Bank 乒乓。
读写仲裁采用最简单的时间片轮转。控制器维护一个状态机,在写请求和读请求同时到达时进行优先级切换。我用的策略是写优先级略高于读,因为摄像头写入如果被長時間阻塞,FIFO 就会溢出丢行;而 VGA 读取出错最多是画面一瞬间有噪点,肉眼几乎察觉不到。
// 简化版仲裁状态机 localparam SDRAM_IDLE = 3'd0; localparam SDRAM_WRITE = 3'd1; localparam SDRAM_READ = 3'd2; localparam SDRAM_REFRESH = 3'd3; always @(posedge clk_sdram or negedge rst_n) begin if (!rst_n) begin state <= SDRAM_IDLE; end else begin case (state) SDRAM_IDLE: begin if (w_fifo_empty == 1'b0) state <= SDRAM_WRITE; else if (r_fifo_empty == 1'b0) state <= SDRAM_READ; else state <= SDRAM_REFRESH; end SDRAM_WRITE: begin if (write_done) state <= SDRAM_IDLE; end // ...省略读和刷新分支 endcase end endSDRAM 的刷新周期也不能写死成常数。标准 SDRAM 要求每 64ms 刷新全部行,通常设置为每 15.625us 发一次刷新命令。在控制器里用一个计数器计数,时间一到就插入刷新状态。若计数器和仲裁逻辑没配合好,刷新命令会抢占读写窗口,导致数据丢失。我的做法是设计一个“刷新请求锁存器”,只要时间到就拉高一次,仲裁状态机在 IDLE 状态下保证执行一次刷新。
4. Sobel 边缘检测的 FPGA 实现
4.1 3×3 窗口是怎么来的:行缓存与移位寄存器
边缘检测最核心的部分是 Sobel 算子,本质是一个 3×3 的卷积核运算。要得到输出像素的周围 3×3 邻域,在硬件上必须同时准备好当前行、上一行、上两行的数据。
很多新手在这里容易犯一个错误,以为用二维数组reg [7:0] window [0:2][0:2]就能存下窗口,然后在每个时钟沿去滑动它。实际上在 FPGA 里,二维数组需要大量 Block RAM 或分布式 RAM,而且地址计算复杂,实用性很差。正确的做法是用三个移位寄存器做行缓存。
Quartus 自带的 IP 核altshift_taps可以直接生成多行延迟。设置 TapsPerDepth(抽头数)为 3,每级抽头间隔为一个行长度,这样就能在三个输出端口上同时得到三行数据。行的长度并不是 640,而是 1024,因为 RAM 深度必须取 2 的整数次幂,而且行数据里包含消隐区。实际使用中只截取前 640 个有效像素。
我实际测试中遇到过一个伪装问题:altshift_taps 的输出数据和输入数据之间有三个时钟周期的固定延迟,如果忽略这个延迟,后续 VGA 时序对齐就会错三拍,画面边缘检测结果会整体向右下方偏移。解决方式是显式添加一个对齐寄存器链,把输入 valid 信号也延迟相同周期数。
4.2 Sobel 运算与阈值处理
3×3 窗口数据在 FPGA 中通常习惯记为 z1 到 z9,具体位置关系如下:
z1 z2 z3 z4 z5 z6 z7 z8 z9Sobel 算子的水平梯度 Gx 和垂直梯度 Gy 分别定义为:
Gx = (z7 + 2×z8 + z9) - (z1 + 2×z2 + z3)
Gy = (z3 + 2×z6 + z9) - (z1 + 2×z4 + z7)
在 Verilog 里,这个卷积过程可以分解为两级流水线。第一级算出 Gx 和 Gy 的绝对值;第二级将两者求和,并与阈值比较。这种两级流水设计能让数据吞吐率保持为每个时钟周期一个像素,不会被运算延迟拖垮。
整个模块的 Verilog 核心代码大致如下:
// 第一级流水:计算 Gx、Gy 的绝对值 assign gx = (z7 + {z8,1'b0} + z9) - (z1 + {z2,1'b0} + z3); assign gy = (z3 + {z6,1'b0} + z9) - (z1 + {z4,1'b0} + z7); // 第二级:绝对值求和并与阈值比较 assign g_abs = (gx[9:0] > 0) ? gx[9:0] : (~gx[9:0] + 1); assign gy_abs = (gy[9:0] > 0) ? gy[9:0] : (~gy[9:0] + 1); assign grad_sum = g_abs + gy_abs; always @(posedge clk) begin if (grad_sum > THRESHOLD) edge_out <= 8'hFF; // 边界像素输出白色 else edge_out <= 8'h00; // 非边界像素输出黑色 end阈值的选择是个经验活。我调试时把阈值做成了拨码开关控制的可变值,范围 32 到 128 共 5 档。最终发现 64 到 80 之间效果最好,既能保留物体的主要轮廓,又不会把桌面的纹理噪点全部识别成边缘。阈值高了边缘会断断续续,低了整幅图像都是雪花状的白点。
这里还涉及一个 Sobel 和 Prewitt 的对比问题。梯度算子都基于差值计算,Sobel 在中心像素的权重是 2,Prewitt 中心权重是 1。Sobel 对图像高频噪声更敏感,但对边缘响应的锐度更好。实测下来两者在 FPGA 上的资源占用几乎一样,只是一组系数不同。如果场景是固定光照的工业检测,Sobel 更合适;如果是光线复杂的户外,Prewitt 的噪声抑制能力反而更友好。
5. VGA 显示与整体联调
5.1 VGA 时序设计与 RGB 映射
VGA 显示使用标准的 800×525 时序(640×480@60Hz)。VSYNC 低电平脉冲持续 2 行,HSYNC 低电平脉冲持续 96 像素。行消隐前肩 16 像素、后肩 48 像素;场消隐前肩 10 行、后肩 33 行。用两个计数器分别计数行像素和场行数,当两个计数器都在有效显示区间时,数据才能输出到 RGB。
VGA 输出在显示边缘时将 RGB 全部拉高,背景则全部拉低。由于 Sobel 输出已经是二值图像,所以不需要做任何灰度映射,直接把 1bit 边缘信号复制到 RGB 的每个通道高位即可。这样一个白底黑图的效果非常干净,也能让边缘看的十分清晰。
需要注意:RGB565 转 VGA 输出时要处理位宽映射。摄像头输出叫 RGB565,VGA 接口的 RGB 是模拟信号,FPGA 引脚直接输出数字电平,然后通过板上电阻网络转模拟电压。每个通道的位数由电阻网络决定,常见是 RGB332 或 RGB565。我板子上是 RGB565 直出,边缘信号 1bit 复制到每个通道的高位即可。
实际显示效果中,如果发现图像有偏色的情况,说明有摄像头颜色格式配置错误。OV7670 里有三种 RGB 输出模式,RGB444、RGB555、RGB565,必须和显示端一致。曾经因为寄存器 12h 中 RGB 位未设置好,输出的图像红蓝通道颠倒,非常容易误判成 SDRAM 数据错误。
5.2 SignalTap 调试:看板子内部波形最关键
在板级调试阶段,Quartus 自带的 SignalTap II 逻辑分析仪是最强大的工具。它比仿真更接近真实硬件,能实时抓取 FPGA 内部信号波形,判断到底是哪个环节出了问题。
我最常用的调试方法是在 Sobel 模块的输入输出处添加测试点:
// 添加 SignalTap 观测信号 wire debug_clk_en; wire [7:0] debug_gray_in; wire [7:0] debug_sobel_out; wire debug_hsync; wire debug_vsync;SignalTap 采集到波形后,先看 hsync 和 valid 信号是否对齐,再看边缘输出在行有效期间是否有规律地翻转。如果整个屏幕上雪花一片,多半是 SDRAM 读出的数据没有经过正确行同步对齐。如果图像有边缘但偏色,检查摄像头初始化配置。如果边缘特别细碎,调高阈值即可。
6. 常见问题与排查实录
把实际调试过程中踩过的坑整理成一个速查表,很多问题在仿真里根本看不出来,只能上板排查。
| 现象 | 根因 | 解决方法 |
|---|---|---|
| 画面紫色或偏色严重 | RGB565 格式未配置正确,寄存器 COM15 写错 | 重写 OV7670 寄存器 0x12=0x80,0x40=0xD0 |
| 图像左右有撕裂/错位 | HREF 计数方式不对,有效像素写入错误 | 以 HREF 高电平为写有效信号,不做固定 640 计数 |
| 边缘整体偏移 | altshift_taps 行缓存延迟未对齐 | 增加延迟补偿寄存器链,使 valid 同步 |
| 边缘太密像噪声 | Sobel 阈值太低 | 阈值调整到 64~80,或改用自适应阈值 |
| 画面时有时无 | 帧同步信号没做好跨时钟域处理 | VSYNC 用双寄存器同步后再触发 FIFO 复位 |
| SDRAM 写入丢行 | FIFO 溢出,写请求长时间阻塞 | 提高写仲裁优先级,加大 FIFO 深度 |
| 行闪条/横纹 | SDRAM 刷新周期与实际带宽不匹配 | 检查刷新计数器周期,确认在消隐期间完成刷新 |
SDRAM 丢行是非常常见又难查的问题。丢行不是整帧丢失,而是某几行数据没有写入,表现是画面出现横的白色条纹。排查方法是统计写入端收到的行数,和 VGA 读出的行数做对比。如果写端行数比读端多几行,那就是 FIFO 和 SDRAM 仲裁配合有问题。这里我提供了两个解决方案:一是扩展 SDRAM 写 FIFO 深度到 256 以上,吸收瞬时数据突发;二是在 VSYNC 信号到来时强制清空 FIFO,防止旧帧数据残留到新帧开头。
另外,SCCB 配置偶尔出现掉配置的问题,表现为开机正常,但运行几分钟后图像自动变暗或者颜色变怪。原因是初始化的复位时序和摄像头内部上电时序冲突,摄像头还没完全上电稳定,FPGA 就开始了寄存器写入。解决办法是在初始化前增加一个至少 10ms 的延时等待,这个延时用计数器实现,不要直接用 sleep 函数,在 FPGA 里没有传统意义上的 sleep,一切靠状态机计时。
7. 一点实操体会与代码复用扩展
这个项目跑通之后,我最大的体会是:FPGA 图像处理项目的难点不在算法,而在同步和存储管理。Sobel 算子的计算公式一写就通,真正花时间的是把摄像头输出、SDRAM 读写、VGA 时序三条线的时序对齐。整个调试过程里 SignalTap 抓波形花了一半以上的时间,但这也是收获最大的部分——看波形比看代码更能理解时序问题到底出在哪。
代码本身我做成了模块化结构,摄像头初始化脚本、SDRAM 控制器、Sobel 单元、VGA 时序四个模块完全解耦。后续想扩展其他算法,比如高斯滤波、中值滤波,或者把 Sobel 换成 Canny,只需要替换中间的处理模块,前后端的接口都不用改动。想加帧率统计或边缘密度统计,也只需要在 Sobel 输出端加一个计数器。
如果你手头正打算做类似的边缘检测项目,我的建议是先别急着写 Sobel,先把摄像头图像能稳定完整地显示在 VGA 上,再动算法部分。显示链路通了,整个项目就成功了七成,剩下的都是工程时间问题。