简介:一份基于FPGA XC6SLX16的双目OV5640摄像头图像采集与HDMI显示完整工程资料,面向FPGA开发者和嵌入式视觉学习者,集中展现Verilog HDL驱动图像传感器、像素同步读取、FIFO缓存、TMDS编码显示等关键设计。项目覆盖FPGA设计流程、硬件描述语言编程、图像传感器驱动、数字视频接口以及双目立体视觉等交叉知识点,适合作为数字系统设计和嵌入式视觉开发的实战样例。压缩包共687个文件,总大小10.07MB,主要包含.v、.vhd、.sv硬件描述源码,.ucf、.xdc引脚与时序约束,.tcl、.sh、.bat自动化仿真综合脚本,.do仿真命令以及PDF说明文档;目录按功能模块组织,便于对照代码快速定位图像采集、双目同步、HDMI输出等逻辑。其中OV5640寄存器配置、帧同步信号处理与跨时钟域FIFO缓存是工程重点,HDMI部分通过编码器完成TMDS转换,脚本支持一键仿真与综合,方便反复调试。已有481人学习,可从工程源码、脚本和文档中完整了解基于FPGA的实时图像采集显示系统搭建思路。读者在Vivado或ISE中打开工程后,能复现双目OV5640驱动和HDMI显示链路,理解并行数据处理与立体视觉基础,为后续扩展视差计算等深度感知算法提供可参考的框架。
1. 双摄像头 + FPGA + HDMI:XC6SLX16 能干的活比你想的多
用一块 XC6SLX16 同时驱动两路 OV5640,把采集到的 RGB565 画面合成到一块屏幕上,这个项目比"点亮一路摄像头"的难度上了一个台阶。难点不在 OV5640 本身,而在两路视频流的同步、DDR2 带宽的分配,以及最后 TMDS 编码输出 HDMI 信号。XC6SLX16 是 Spartan-6 家族里逻辑资源很紧的一颗芯片,LUT 不到一万,Block RAM 总共才 576Kb,想把两路 1280x720@30fps 的数据流顺畅地送到 HDMI 输出,每一步都得精打细算。
适合做这个项目的人有两类:一类是刚走完"FPGA 入门"、想碰真实图像链路的工程师,另一类是已经做过单路摄像头采集、想进一步掌握双路同步和 DDR 仲裁的进阶玩家。这套 Verilog HDL 工程把 OV5640 的 SCCB 初始化、DVP 采集、DDR2 读写仲裁、HDMI TMDS 编码串成了一条完整的数据通路,照着一路调下来,你对"FPGA 图像处理"的认知会比看十篇教程都深刻。
2. 平台资源底账与 DDR2 缓存方案:为什么片内 RAM 撑不住两路图像
2.1 带宽计算:先算清数据量再动手
在写第一行 Verilog 之前,先算这笔账。OV5640 在 720p@30fps 模式下输出 RGB565,像素时钟取 37.125MHz(正好是 720p60 像素时钟的一半),每个像素 16bit,单路数据率就是:
37.125 MHz × 16 bit = 594 Mbps ≈ 74.25 MB/s两路同时采集,总数据率约 148.5 MB/s。这还只是写入方向,HDMI 显示还要从缓存里把数据读出来,读方向同样要消耗带宽。虽然 720p 输出的有效像素率也是 37.125MHz,但 DDR2 的读和写是共享同一份带宽的,实际规划时要按 2 倍以上的余量去算。
XC6SLX16 的片内 Block RAM 总量是 576Kb(32 个 18Kb 块),分摊下来每路能用的不足 288Kb。一帧 720p RGB565 画面是 1280×720×2 = 1.8MB,片内 RAM 连一行的缓存都要省着用。所以结论很明确:片内 BRAM 只能做行缓冲(Line Buffer),整帧缓存必须外挂 DDR2 或者 DDR3。这个项目的板子上用的是一片 16bit 位宽的 DDR2,等效速率 800Mbps,理论带宽 1.6GB/s,砍掉刷新开销和仲裁损耗后,实际留给读写通道的有效带宽也有 1GB/s 左右,应付两路视频绰绰有余。
2.2 MIG 核生成与接口名:别被 app 信号绕晕
Spartan-6 上用 DDR2,正统做法是在 ISE 14.7 里通过 MIG(Memory Interface Generator)生成控制器。生成时注意两点:一是芯片选 XC6SLX16,封装要跟你的开发板完全一致,MIG 输出的 UCF 引脚约束是写死的,封装选错后面根本布线不过;二是存储类型选 DDR2,时钟频率按板子上的 DDR2 颗粒规格来,常见的是 400MHz(DDR2-800)。
MIG 生成后你会拿到一个带app_前缀的接口,这是用户逻辑与控制器之间的读写通道。核心信号就那么几个:
| 信号 | 方向 | 含义 |
|---|---|---|
| app_af_addr | 输出 | 读写地址(行/列/bank 编码后的扁平地址) |
| app_af_cmd | 输出 | 0 表示写,1 表示读 |
| app_af_wren | 输出 | 地址请求有效,拉高一个时钟周期 |
| app_wdf_data | 输出 | 写数据总线 |
| app_wdf_wren | 输出 | 写数据有效 |
| app_rd_data | 输入 | 读数据返回 |
| app_rd_data_valid | 输入 | 读数据有效 |
af是 Address Fetch,wdf是 Write Data FIFO。一个常见的误解是:发写请求时地址和数据要同时给。实际上 MIG 内部有缓冲,地址请求app_af_wren和数据写入app_wdf_wren可以相差几个周期,只要在写命令被真正接收前把数据送到app_wdf_data上就行。我一般会在用户逻辑里用一个 FIFO 把写数据暂存,等app_af_wren握手成功后再把数据顶出去。
2.3 写通道仲裁状态机的骨架
两路视频写 DDR,一路 HDMI 读 DDR,总线只有一个 master,必须在用户逻辑里做一个简单的仲裁。下面这段代码是写侧仲裁的核心状态机,两个摄像头通道轮询,读通道只在写空闲时才有机会占用总线:
// ddr_arbiter.v - 两写一读仲裁 localparam IDLE = 3'd0; localparam WR_CAM_A = 3'd1; localparam WR_CAM_B = 3'd2; localparam RD_HDMI = 3'd3; reg [2:0] state; reg [2:0] next_state; always @(posedge clk or posedge rst) begin if (rst) state <= IDLE; else state <= next_state; end // 仲裁策略:A/B 摄像头写请求优先,读请求让路 always @(*) begin case (state) IDLE: begin if (cam_a_wr_req) next_state = WR_CAM_A; else if (cam_b_wr_req) next_state = WR_CAM_B; else if (hdmi_rd_req) next_state = RD_HDMI; else next_state = IDLE; end WR_CAM_A: next_state = cam_a_wr_done ? IDLE : WR_CAM_A; WR_CAM_B: next_state = cam_b_wr_done ? IDLE : WR_CAM_B; RD_HDMI: next_state = hdmi_rd_line_done ? IDLE : RD_HDMI; default: next_state = IDLE; endcase end这个仲裁讲究的是"行粒度"而不是"像素粒度"。每个摄像头凑满一行(1280 个像素)后发起一次突发写,写一整行再释放总线,这样 DDR 的 burst 效率最高。如果每个像素都去抢总线,仲裁开销会把带宽吃掉一大半。读侧同理,HDMI 消隐期间可以暂停读,但不能让读 FIFO 空到造成画面撕裂,所以读请求在仲裁里优先级最低,但一旦拿到总线就必须连续读完一整行。
实际调这个仲裁时,最值得关注的是cam_a_wr_done和cam_a_wr_req这两个信号之间的时序关系。请求信号必须维持到仲裁器给出响应,不能在地址握手成功前就撤掉,否则会出现"请求被吞"的现象——摄像头那边以为数据已经写进 DDR 了,实际上仲裁器根本没理会。我一般会在每个摄像头通道里放一个请求锁存器,仲裁器拉高grant信号时才清掉。
3. 驱动 OV5640:SCCB 初始化与 720p RGB565 寄存器序列
3.1 上电时序:读 ID 之前先满足它的脾气
OV5640 是 OmniVision 的传感器,配置接口叫 SCCB,跟 I2C 兼容,芯片地址 7-bit 是 0x3C,对应 8-bit 读写地址 0x78/0x79。动手写配置之前,先把上电时序理清楚,这一步翻车率最高。
正确的上电顺序是:先给 AVDD(2.8V)和 DVDD(2.8V),再给 DOVDD(1.8V),时钟 XVCLK 要稳定输入,然后 RESETB 拉低保持至少 1ms 后释放,最后把 PWDN 拉低。如果 PWDN 没有拉低,芯片一直处于掉电模式,SCCB 怎么都叫不醒。这四条缺一条,读 ID 得到的就是 0xFF 或者 0x00。
验证 SCCB 通信是否建立,第一步永远是读 ID:
// sccb_read_id.v - 读 OV5640 厂商 ID // 从地址 0x300A 读回 0x56,从 0x300B 读回 0x40 // sccb_byte_cnt: 0=发送从机地址(0x78),1=发送寄存器地址高8位(0x30) // 2=发送寄存器地址低8位(0x0A),3=重复起始+从机地址(0x79) // 4=接收数据 reg [3:0] sccb_byte_cnt; reg [7:0] sccb_rx_data; always @(posedge clk) begin if (sccb_byte_cnt == 4'd3) begin // 第3字节之后是读方向,sccb_byte_cnt==4时采样SDA sccb_rx_data <= {sccb_rx_data[6:0], sda_in}; end end读 ID 的状态机比写寄存器要复杂一些,因为中间有一次重复起始信号。常见做法是:先发 0x78 + 寄存器地址 0x30 0x0A,然后发重复起始 + 0x79,之后连续收两个字节,第一个字节是 0x56,第二个是 0x40。注意 SCCB 对读操作不强制要求发 NACK,第二个字节结束后直接发停止位即可。
我见过很多人在这一步卡住,现象是 ID 读回全 0xFF。不要急着怀疑代码,先用示波器量 SCL 和 SDA 的上拉电阻,确认两个引脚都有上拉。SCCB 是开漏输出,没上拉电阻的话时序永远是错的。另外 SCL 频率不要超过 400kHz,我习惯在 100kHz 到 200kHz 之间,稳定第一。
3.2 SCCB 写寄存器时序实现
写单个寄存器是 SCCB 最常用的操作,OV5640 的寄存器地址是 16 位,所以一帧写操作包括:起始 + 从机地址 0x78 + ACK + 寄存器地址高字节 + ACK + 寄存器地址低字节 + ACK + 数据字节 + ACK + 停止。下面这个状态机是工程里常驻的模块:
// sccb_wr.v - SCCB 单寄存器写 parameter IDLE = 3'd0; parameter START = 3'd1; parameter SLAVE_ADDR = 3'd2; // 0x78 parameter REG_HIGH = 3'd3; // 寄存器地址[15:8] parameter REG_LOW = 3'd4; // 寄存器地址[7:0] parameter DATA_BYTE = 3'd5; // 写入数据 parameter STOP = 3'd6; reg [3:0] bit_cnt; // 0~7,每字节8位 reg [7:0] shift_reg; // 发送移位寄存器 always @(posedge clk) begin case (state) START: begin scl <= 1'b1; sda_out <= 1'b0; // SCL高时SDA拉低 = 起始 state <= SLAVE_ADDR; bit_cnt <= 3'd0; shift_reg <= 8'h78; // OV5640写地址 end SLAVE_ADDR: begin if (bit_cnt < 3'd7) begin sda_out <= shift_reg[7]; shift_reg <= {shift_reg[6:0], 1'b0}; bit_cnt <= bit_cnt + 1'b1; end else begin bit_cnt <= 3'd0; state <= REG_HIGH; shift_reg <= {reg_addr[15:8]}; // 这里在第9个时钟释放SDA,等待从机ACK end end // REG_HIGH / REG_LOW / DATA_BYTE 结构相同,不再重复 endcase end关键参数:scl由系统时钟分频产生,比如系统时钟 50MHz,分频得到 200kHz SCL。SDA 的变化要严格发生在 SCL 低电平期间,只有在起始/停止位时 SDA 才允许在 SCL 高电平跳变。这个状态机把数据发送和 ACK 采样混在一个节拍里处理,工程上能跑,但时序上要仔细推每个 bit 的切换时刻,否则会出现在临界点跳变的毛刺。
我后来重构过一版:用scl_en计数产生 SCL 的四个相位点(上升沿、高电平中段、下降沿、低电平中段),SDA 只在低电平中段改变,ACK 在高电平中段采样。这样时序会干净很多,尤其在 SCL 超过 300kHz 以后,那版"一拍干一件事"的状态机明显抗干扰能力更强。
3.3 720p@30fps RGB565 输出的一组关键寄存器
完整的初始化序列很长,一般有上百个寄存器写入,资源包里的ov5640_init.v用一个二维数组存储寄存器地址和数据,初始化状态机逐条写入。这里挑几个决定输出格式和分辨率的寄存器讲,你会更容易理解序列里每一行的意义:
| 寄存器地址 | 值 | 作用 |
|---|---|---|
| 0x3008 | 0x82 | 软复位,写入后芯片复位,需延时等待 |
| 0x3108 | 0x01 | 系统时钟分频,配合 PLL 得到 720p 所需像素时钟 |
| 0x3808 | 0x05 | 输出水平宽度高字节,0x0500 = 1280 |
| 0x3809 | 0x00 | 输出水平宽度低字节 |
| 0x380A | 0x02 | 输出垂直高度高字节,0x02D0 = 720 |
| 0x380B | 0xD0 | 输出垂直高度低字节 |
| 0x4300 | 0x62 | 输出格式:RGB565 |
| 0x501F | 0x01 | RGB 格式使能 |
0x4300这个寄存器决定了 DVP 引脚上的数据格式。0x62 对应 RGB565,数据线的高位是 R[4:0],中间是 G[5:0],低位是 B[4:0]。如果这里配错成 RGB888,后面采集模块的数据位宽和你存 DDR 的像素格式不一致,画面颜色会完全错乱甚至花屏。调试时如果画面颜色不对,第一反应应该是查这一条,而不是去怀疑 TMDS 编码器。
0x3808到0x380B这一组改的是输出分辨率。改小分辨率带宽压力小,改大则必须确认 DDR 带宽和像素时钟跟得上。我在调试阶段习惯先配成 320×240,画面小、带宽低,先把链路打通再改回 720p,这样排查问题的范围会小很多。
初始化序列写完后要等至少 100ms 让芯片内部 AEC/AGC 收敛,再开始采集,否则前几十帧亮度会跳变。
4. 双路采集同步与 DDR 地址规划:让两幅画面不错位
4.1 两路 OV5640 的 VSYNC 天然不同步
两个摄像头各自独立运行,上电复位时间、内部 PLL 锁定时刻都有细微差异,两路 VSYNC 的相位差可能达到几十毫秒。这个量级在实际观看中就是明显的画面错位——左眼画面比右眼画面晚了一帧,动起来时尤其明显。
解决思路不是去硬对齐 VSYNC 的上升沿,而是建立一个"帧号"概念。在 FPGA 内部维护一个frame_id计数器,每次有效 VSYNC 到来时递增。每路摄像头的数据写入 DDR 时,地址的高位带上自己的帧号余数,比如存储区划分为 8 个帧缓冲区,frame_id % 8决定写入哪块。读出侧(HDMI)永远只读同一个帧号区间的数据。这样即使两路 VSYNC 有相位差,只要在帧号上达成一致,显示出来的左右画面就是同一帧。
实现帧同步的常用做法是:把 A 路作为主设备,B 路作为从设备。当 A 路的 VSYNC 到来时,产生一个frame_sync_pulse;B 路的采集模块检测到这个脉冲后,如果自己的 VSYNC 还没来,就强制等待 B 路下一帧的 VSYNC 对齐。这个"等待"是在 FPGA 侧完成的,不需要改动 OV5640 的寄存器。
// frame_sync.v - 以CAM_A为基准,对齐CAM_B reg frame_sync_pulse; reg cam_b_frame_armed; always @(posedge clk) begin if (cam_a_vsync) begin frame_sync_pulse <= 1'b1; cam_b_frame_armed <= 1'b1; end else if (cam_b_vsync && cam_b_frame_armed) begin // B路收到对齐脉冲后,在最近一次VSYNC启动采集 cam_b_frame_armed <= 1'b0; frame_sync_pulse <= 1'b0; cam_b_frame_start <= 1'b1; end else begin frame_sync_pulse <= 1'b0; cam_b_frame_start <= 1'b0; end end这段代码的逻辑本质是:A 路的 VSYNC 触发frame_sync_pulse,B 路在下一个自己的 VSYNC 上升沿响应,完成一次帧号对齐。要注意cam_b_frame_armed不能在 B 路 VSYNC 还没来时被新脉冲覆盖,所以代码里用了先判定再清零的顺序,否则连续两个 A 路 VSYNC 会把 B 路的状态机冲乱。
4.2 DDR 地址映射:帧号、行号、像素三项编码
DDR 的地址规划直接决定读写效率。我的方案是给每路摄像头分配独立的帧存储区,A 路存前半段,B 路存后半段。以 1Gb 的 DDR2 颗粒计算,可用空间 128MB,每帧 720p RGB565 占 1.8MB,一个通道分配 32MB 可以存 17 帧,两通道共 64MB,剩余空间留给显示读缓冲和余量。
地址从高到低分成三段:frame_id % 16、line_cnt、burst_index。其中line_cnt占 10bit(0 到 719),burst_index占 7bit(每行 1280 像素,每 8 个像素一次突发,共 160 次突发)。把这个字段拼接到 MIG 的 32bit 地址总线上:
// ddr_addr_gen.v - 写地址生成 wire [31:0] ddr_wr_addr; assign ddr_wr_addr = { cam_sel, // bit[31]: 0=CAM_A, 1=CAM_B frame_id[3:0], // bit[30:27]: 帧号余数 line_cnt[9:0], // bit[26:17]: 行号 burst_index[6:0], // bit[16:10]: 行内突发序号 10'b0 // bit[9:0]: 对齐到64字节边界 };地址对齐到 64 字节边界这条非常重要。DDR2 的突发长度是 8,16bit 位宽一次突发正好 16 字节,连续 4 次突发就是一个完整的 64 字节对齐块。如果地址不对齐,MIG 内部会拆分成多个命令,DDR 效率直接腰斩。我当初就是因为地址低 6 位没清零,写带宽从 900MB/s 掉到 400MB/s,两路视频直接丢帧。
读侧地址生成同理,只是把frame_id固定为当前需要显示的帧号,line_cnt由 HDMI 的行同步计数器驱动,而不是采集模块驱动。这样写侧和读侧各走各的地址,互不干扰。
4.3 帧号校验:防止残帧上屏
即使做了同步对齐,还是会出现一种特殊情况:HDMI 正在读第 5 帧时,采集侧已经开始往第 5 帧的新位置覆写,导致屏幕上某一帧出现上半部分是旧数据、下半部分是新数据的撕裂。这是典型的"读写追尾"问题。
处理办法是给每个帧缓冲区加一个状态标记。采集侧写完一帧后把该帧标记为ready,读出侧只读ready的帧,读完一帧后把标记清掉。更进一步,可以在读出侧发现没有ready帧时,保持上一帧画面不刷新,而不是去读一个写了一半的帧。这个逻辑看起来简单,却是整个显示系统稳定性的最后一道保险。
我做成"双帧缓冲 + 三帧流水"的结构:采集侧永远写frame_id % 2的帧,读出侧读1 - frame_id % 2的帧,中间留一帧作为过渡。这样即使某帧写入超过了一帧时间,读出侧也不会撞上正在写的区域,最多是刷新率掉一档,但不会出撕裂。
5. HDMI 输出:TMDS 编码与 720p60 时序参数
5.1 DVI 协议就够用:HDMI 的纯视频部分
这个项目用的是 HDMI 接口输出,但实现的是 DVI 1.0 协议。DVI 和 HDMI 在 TMDS 通道上完全兼容,区别只在于 HDMI 额外携带音频和 CEC,纯视频场景下用 DVI 编码即可,HDMI 显示器也能正常识别。
TMDS 把 8bit 像素数据编码成 10bit 串行数据,三个通道分别对应 R、G、B,再加上一个像素时钟通道。编码规则的核心是 DC 平衡:每 10bit 码字里 1 的数量和 0 的数量最多差 2,确保信号在高速传输时没有直流偏置。DE(Data Enable)信号区分数据区和消隐区,消隐期间通道传输控制信号而不是像素数据。
5.2 720p60 的时序参数表
HDMI 输出必须有精确的时序参数,下面是 720p60(CEA-861 标准)的参数,直接写死在参数文件里:
| 参数 | 数值 |
|---|---|
| 像素时钟 | 74.25 MHz |
| 水平有效像素 | 1280 |
| 水平前沿 HFP | 110 |
| 水平同步 HSYNC | 40 |
| 水平后沿 HBP | 220 |
| 水平总长 | 1650 |
| 垂直有效行 | 720 |
| 垂直前沿 VFP | 5 |
| 垂直同步 VSYNC | 5 |
| 垂直后沿 VBP | 20 |
| 垂直总长 | 750 |
| 同步极性 | 正极性 |
每组参数都要精确到像素时钟周期的整数倍,不能随意改。HFP + HSYNC + HBP = 370,这是水平消隐的总时长。显示器靠这些消隐参数来对齐画面,任何一个值偏了,轻则画面偏移,重则显示器直接黑屏提示"无信号"。
5.3 TMDS 编码器的 Verilog 实现
下面这段是 TMDS 编码的核心逻辑,对每个通道独立工作:
// tmds_encoder.v - 单个通道的TMDS编码 module tmds_encoder( input wire clk, input wire [7:0] data_in, input wire [1:0] ctrl_in, // 消隐期控制信号 input wire de_in, output reg [9:0] tmds_out ); reg [3:0] balance; // DC平衡计数器 // 第一级:判断data_in中1的数量 wire [2:0] n_ones = data_in[0] + data_in[1] + data_in[2] + data_in[3] + data_in[4] + data_in[5] + data_in[6] + data_in[7]; // 第二级:生成9bit中间码 reg [8:0] q_m; always @(*) begin if (n_ones > 4 || (n_ones == 4 && data_in[0] == 0)) begin q_m[0] = data_in[0]; q_m[8:1] = ~data_in[7:1]; // XNOR end else begin q_m[0] = 1'b1; q_m[8:1] = data_in[7:1]; // XOR end end // 第三级:DC平衡编码 always @(posedge clk) begin if (de_in) begin if (balance == 0 || {1'b0, q_m[8]} + balance > 0) begin tmds_out <= {1'b1, q_m[8], ~q_m[7:0]}; balance <= balance - {1'b0, q_m[8]} + ~q_m[7:0]; end else begin tmds_out <= {1'b0, q_m[8], q_m[7:0]}; balance <= balance + {1'b0, q_m[8]} - q_m[7:0]; end end else begin // 消隐期编码控制信号 tmds_out <= {2'b00, ctrl_in[0], 2'b00, ctrl_in[1], 2'b00}; balance <= 4'd0; end end endmodule编码器三个通道各例化一份,G 通道的ctrl_in接 VSYNC 和 HSYNC,R 通道和 B 通道的ctrl_in接固定值。balance寄存器记录了链路当前的直流偏移,每个像素都在向零靠拢。这里最容易写错的地方是中间码的取反条件:当n_ones等于 4 时,要看第一个 bit 决定是 XOR 还是 XNOR,这个细节决定了编码结果是否符合 TMDS 规范,写错的话图像会偶发噪点,但在示波器上看起来波形又大致正常,很难排查。
串行化部分在 I/O 层面完成。Spartan-6 的OSERDES原语可以把 10bit 并行数据变成 10 倍速的串行流,三个通道各例化一组。像素时钟 74.25MHz,串行速率就是 742.5Mbps,这个速度在 Spartan-6 的OSERDES支持范围内,但布线时要注意三个通道的等长约束,否则时序收敛不了。
6. 避坑专章:OV5640 + DDR2 + HDMI 五处踩坑记录
6.1 现象:读 OV5640 ID 返回 0xFF
原因:上电时序不对。PWDN 没拉低,或者 RESETB 释放太快,芯片还处于复位状态。还有一个隐蔽原因:DOVDD 与 AVDD 的上电顺序颠倒,导致内部 LDO 没有启动。
解决:严格按照 AVDD → DVDD → DOVDD → XVCLK → RESETB 释放 → PWDN 拉低的顺序上电。RESETB 低电平保持时间拉长到 10ms 以上,给内部电源稳定留足余量。每次复位后延时 20ms 再尝试第一次 SCCB 通信。
6.2 现象:SCCB 写寄存器时好时坏,偶尔初始化半路卡住
原因:SCL 频率太高,或者 SDA 上没有上拉电阻。OV5640 对 SCCB 时序要求并不苛刻,但若 SCL 超过 400kHz,芯片内部的数字接口可能采样出错,导致 ACK 丢失。
解决:把 SCL 降到 200kHz,确认 SDA 和 SCL 都有 2.2kΩ 到 4.7kΩ 的上拉。另外初始化状态机要加超时保护:如果连续 16 个寄存器写入没有收到 ACK,主动拉高复位信号重新初始化,而不是死等。
6.3 现象:DDR 写入丢数,画面出现随机横纹
原因:MIG 的app_wdf_wren与app_af_wren握手关系处理错误。地址请求发出去后数据却慢了半个拍子,MIG 内部写 FIFO 超时,命令被丢弃。
解决:在用户逻辑里把写数据放入深度不小于 16 的 FIFO,地址请求发出前先确认 FIFO 里已经有完整的一行数据。触发条件改成"FIFO 数据量 > 突发长度"再发app_af_wren,这样 MIG 读取数据时永远有货。
6.4 现象:HDMI 屏幕无画面,显示器提示无信号
原因:像素时钟抖动过大,或者 DE 信号在消隐期没有拉低。MIG 生成的时钟和 HDMI 像素时钟来自不同的 MMCM,如果跨时钟域处理不干净,DE 的抖动会让显示器无法锁定。
解决:把所有与 HDMI 相关的信号统一到同一个 MMCM 输出的 74.25MHz 时钟域,DE 信号生成与像素时钟严格同源。用 ChipScope 抓 HSYNC、VSYNC、DE 三个信号,确认脉宽和位置与参数表完全一致。
6.5 现象:双路画面左右不同步,移动时一个快一个慢
原因:两路 OV5640 的帧号没有真正对齐。A 路 VSYNC 触发同步脉冲时,B 路恰好处于帧中间,B 路忽略了这个脉冲,导致整个 session 里 B 路的帧号与 A 路始终相差一帧。
解决:在同步状态机里增加"对齐确认"机制。frame_sync_pulse发出后,如果 3 个 VSYNC 周期内 B 路没有响应,强制复位 B 路的采集模块并重新等待对齐。确保每次上电后两路帧号差永远保持在 0 或 1 之间,而不是自由漂移。
7. 验证与调试:从 Read ID 到出画面的完整检查流
这个项目的验证层次很清晰,按顺序走能少走弯路。第一步用 ChipScope 抓 SCCB 模块的scl、sda、state三个信号,确认识别到 OV5640 的 ACK 应答;第二步抓采集模块的href和vsync,确认分辨率配置生效;第三步在 DDR 读侧出口挂一个测试图案发生器,先不管摄像头,直接让 HDMI 输出彩条,验证 DDR 读出和 TMDS 编码的链路是通的;最后才把摄像头数据切换到显示通路。
前两步是常规操作,第三步往往是被跳过的。我见过太多人一上来就把摄像头接上,结果画面是花的,根本说不清是采集问题还是显示问题。先用彩条把显示链路验证到 100% 可信,再怀疑摄像头,这是效率最高的排错顺序。
ChipScope 的触发条件设置有一个技巧:抓 VSYNC 时用"上升沿触发",抓 SCCB 时用"SDA 下降沿触发",这样每个触发点都是一个有意义的动作开始,而不是在空转时抓一堆无关波形。另外观察跨时钟域信号时,采样时钟要选那个信号自己的时钟域,否则会看到毛刺和跳变,误导你的判断。
最后那一步让我印象很深。那天下板调试,画面死活出不来,我以为是 TMDS 编码器写错了,翻来覆去改了一个小时。后来安静下来,用 ChipScope 去抓 DE 信号,发现 DE 在消隐期间有一个超出预期的毛刺——原来是采集模块的 HREF 信号在行尾没有及时拉低,多延后了一个像素时钟。这一个像素的偏差,造成了 HDMI 接收端整个行对齐失败。从那以后,我每次调显示项目都是固定顺序:先彩条验证显示链路,再抓控制信号的时序细节,最后才接数据源。这套流程帮我挡掉了至少十次返工。希望帮到你。
本文还有配套的精品资源,点击获取