ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA实现双目OV5640视频采集与HDMI显示的技术实践

FPGA实现双目OV5640视频采集与HDMI显示的技术实践 简介一套基于FPGA EP4CE10的双目OV5640摄像头采集与HDMI显示完整工程面向数字逻辑与嵌入式视觉开发者解决双目图像同步采集、数据缓存及HDMI实时输出的硬件实现问题。工程核心代码采用Verilog HDL编写包含OV5640寄存器配置驱动、双目数据拼接、TMDS编码及视频时序生成等模块覆盖从摄像头初始化到HDMI显示输出的完整链路。压缩包共266个文件约13.18MB以Verilog源码.v、.tdf为主辅以Quartus工程文件.qpf、.qsf、IP核配置.qip及综合仿真报告.rpt、.sdo并保留可下载的.sof配置文件便于直接验证。目前已有214人学习下载特别适合正在学习FPGA图像处理或需要参考双目视觉驱动的开发者。资料包含工程备份与详细引脚分配文件可帮助读者快速定位摄像头时序配置、数据拼接与HDMI输出接口的设计要点减少重复调试。该工程展示了如何利用Cyclone IV系列FPGA资源实现多路摄像头数据流控制是理解嵌入式视觉系统硬件架构的实用参考。1. 为什么用EP4CE10做双目OV5640而不是直接用ARM很多人一听到双目摄像头第一反应是树莓派或者Jetson但如果你做过实时双目采集就会发现ARM软核在处理两路同步像素流时DMA中断和缓存一致性会让你怀疑人生。OV5640输出的是并行DVP信号每像素8位720P30时单路像素时钟约65MHz两路就是130MHz的数据流这还不算配置时序。EP4CE10虽然只有约10K逻辑单元但FPGA的优势在于其并行性可以用两个独立的状态机分别采样左右摄像头再用一个硬件乒乓缓冲把两路数据合并输出整个过程不需要CPU介入。而且Cyclone IV的IO支持3.3V和2.5V电平直接兼容OV5640的接口电压省掉了电平转换芯片。这个项目的本质不是“驱动一个摄像头”而是用FPGA原生的并行能力解决双路同步和时序匹配问题对于想入门FPGA视频处理、或者做低成本深度视觉原型的人来说是一个比KMZ101那种串口发AT指令更硬的实践。2. OV5640的DVP接口与SCCB配置2.1 上电时序比寄存器配置更先决定成败OV5640不是上电就能出图的CMOS。它在硬件上需要严格的复位和供电时序PWDN引脚拉低后至少要等1ms再拉高RESET随后等待20ms以上的系统稳定时间然后才能开始SCCB配置。很多人在仿真里把寄存器写对了上板却黑屏十有八九是上电时序没满足。另外一个容易被忽略的点是OV5640的XVCLK输入时钟我习惯直接给24MHz如果给27MHz也能跑但内部PLL分频后的像素时钟会变导致后面HDMI时序和分辨率对不上。建议在原理图上把XVCLK固定接在有源晶振上别用FPGA内部PLL转出来的时钟因为那样会和FPGA配置过程耦合复位时会丢时钟。我见过有人在淘宝买的OV5640模块上自带晶振那种模块可以把FPGA侧XVCLK引脚悬空但前提是模块上的晶振频率必须是24MHz。本项目使用的是正点原子或黑金的OV5640模块它们默认是外部输入时钟所以驱动代码里必须有一个顶层模块负责产生XVCLK并处理复位信号。2.2 SCCB时序与I2C的细微差别OV5640用SCCB接口做寄存器读写它和I2C非常接近但不完全一样。SCCB的标准模式是起始条件SIO_C高电平期间SIO_D拉低、8位器件地址OV5640的写地址是0x42读地址是0x43、寄存器地址、数据。区别在于SCCB的停止条件是SIO_C在高电平期间SIO_D由低到高而且一次传输中如果没有stop可以连续写多个寄存器。在Verilog实现里我一般直接按I2C的主模式写因为OV5640能兼容I2C时序只是要注意SCCB不支持读操作中的重复起始读单个寄存器要先写寄存器地址然后stop再发一次读地址。下面是一个简化的SCCB写字节模块状态机分四步起始、发地址、发数据、停止。代码里用了计数器做位循环时钟分频为200kHz这是OV5640规格书推荐的SCCB最大频率。module sccb_write_byte #( parameter CLK_FREQ 50_000_000, parameter SCL_FREQ 200_000 )( input wire clk, input wire rst_n, input wire start, input wire [7:0] dev_addr, // 例如 8h42 input wire [7:0] reg_addr, input wire [7:0] reg_data, output reg scl, output reg sda, output reg done ); localparam DIV_CNT CLK_FREQ / SCL_FREQ / 2; reg [15:0] cnt; reg [7:0] byte_cnt; reg [7:0] bit_cnt; reg [2:0] state; // 状态: IDLE, START, ADDR, REG, DATA, STOP, ACK // 这里省略完整实现只展示核心的起始和停止条件 always (posedge clk or negedge rst_n) begin if (!rst_n) begin scl 1b1; sda 1b1; state 0; end else begin case (state) 0: begin // IDLE: 等待start脉冲 if (start) begin state 1; cnt 0; end end 1: begin // START: sda从高拉低scl保持高 if (cnt DIV_CNT - 1) begin cnt cnt 1; end else begin sda 0; if (cnt DIV_CNT*2 - 1) begin cnt 0; state 2; scl 0; byte_cnt 0; bit_cnt 0; end else begin cnt cnt 1; end end end // ...... 后续状态机处理移位输出和ACK采样 endcase end end endmodule这段代码只是一个框架重点在于时序控制启动条件必须在SCL高电平时改变SDA否则OV5640不认。ACLK采样点要在SCL低电平期间准备数据高电平期间读取。实际工程中建议直接把状态机补全然后在Modelsim仿真里对比I2C波形。2.3 让双目都输出RGB565的寄存器配置OV5640默认输出的是JPEG格式在FPGA里用JPEG做显示非常麻烦需要解码器。HDMI显示需要RGB所以必须把摄像头配成RGB565输出。注意OV5640输出RGB565时每条像素是16位但是DVP接口是8位并行所以每两个PCLK周期才能凑出一个像素。这意味着采样时钟要用PCLK的上升沿并且每两个字节拼成一个像素。下表是我验证过的一组寄存器能让两个摄像头都工作在1280x72030fps、RGB565输出格式寄存器地址写入值作用0x30080x82软复位复位后要延时10ms以上0x31030x93配置PLL分频得到72MHz像素时钟0x30170x7F开启MIPI并关闭DVP这个寄存器要按模块版本改0x30180xFC关闭MIPI启用DVP并行输出0x30340x18PLL倍频系数0x30350x21PLL分频系数0x30360x62PLL VCO调节0x30370x01像素时钟分频0x38100x00水平起始0x38110x10水平结束0x38120x00垂直起始0x38130x10垂直结束0x38140x31X尺寸缩放0x38150x31Y尺寸缩放0x43000x02输出格式设为RGB5650x501F0x00关闭ISP缩放注意0x3017和0x3018这两个寄存器比较敏感。有些OV5640模组是堆叠MIPI的和DVP共存的必须把MIPI相关位关掉否则DVP输出数据会乱。我踩过坑写0x3018为0xFC后画面有规律条纹后来发现是0x3017的bit[7]没有置1。建议先单目调通再复制到另一路。3. 双目同步采样两路CMOS数据流与乒乓缓冲3.1 双目同步为什么不能靠软件对齐如果两个摄像头各自独立输出PCLK那么它们的VSYNC和HSYNC不会完全同频即使配了同一套寄存器晶振漂移也会导致左右帧相差几百个时钟周期。用软件在CPU里做双buffer同步需要等待两个VSYNC都到达然后同时读两路FIFO这在高分辨率下会产生至少一帧的延迟。FPGA的做法是每个摄像头都配一个FIFO把HSYNC和VSYNC信号打入FPGA的IO由同源逻辑去对齐。常用做法是选一个摄像头作为主时钟源把它的PCLK和VSYNC接到FPGA的全局时钟引脚上另一个摄像头的PCLK作为从时钟但这里的“同步”并不是让两个PCLK严格对齐而是把两路像素数据都写入同一块双端口RAM的对应地址。左右摄像头的数据流结构完全相同关键是写入地址的计算基于各自的像素计数器但读取时使用同一个读指针。这样在显示端看起来就是一幅左右拼接的图像。3.2 用异步FIFO解决PCLK跨时钟域OV5640输出的PCLK和数据是同步的但FPGA内部HDMI发送逻辑工作在不同的时钟域像素时钟是HDMI驱动产生的分辨率不同。最简单的方案是用异步FIFO。每个摄像头通道配一个FIFO写入端用各自PCLK读出来用HDMI像素时钟。FIFO深度至少需要一行数据以1280像素宽为例RGB565是16位一行约2KB我一般配2048x16的异步FIFO加上写使能信号。关键点在于写使能信号的生成。OV5640的数据线是DVP标准HREF为高时数据有效。所以写使能就是HREF。但要注意HREF信号在每行有效期间会有间隙这时不能写FIFO。为了保险我还会用VSYNC的上升沿复位行计数器和FIFO写指针这样每一帧的第一个像素都从FIFO的固定地址开始写避免积累误差。下面对FIFO的写时序做截断wire ov5640_pclk; wire [7:0] ov5640_data; wire ov5640_href; wire ov5640_vsync; // 异步FIFO实例化使用Quartus的fifo IP // 写端口: 时钟pclk, 数据拼接后的16bit // 因为DVP是8位先收到的高字节低字节后到需要打一拍 reg [7:0] data_shift; reg data_valid_shift; always (posedge ov5640_pclk or negedge rst_n) begin if (!rst_n) begin data_shift 8d0; data_valid_shift 1b0; end else begin data_shift ov5640_data; data_valid_shift ov5640_href; end end // fifo_wr_data 是16bit需要两个字节攒齐 always (posedge ov5640_pclk or negedge rst_n) begin if (!rst_n) begin wr_data 16d0; wr_en 1b0; byte_cnt 1b0; end else begin if (ov5640_href) begin byte_cnt !byte_cnt; if (byte_cnt 1b1) begin wr_data {data_shift, ov5640_data}; // 高字节先到 wr_en 1b1; end else begin wr_en 1b0; end end else begin wr_en 1b0; byte_cnt 1b0; end end end这段代码把两个8位数据拼成16位RGB565。注意byte_cnt初始为0第一次HREF高时得到的是低字节这里取决于OV5640在RGB565模式下是先传高字节还是低字节我建议在仿真时先验证。我的项目中实测是先高字节后低字节所以将第一个收到的data_shift作为高位。如果画面颜色相反红蓝互换就把拼接顺序交换。3.3 乒乓缓冲配合HDMI读端HDMI读端需要连续的行像素而摄像头的行输出是间断的。如果只用一个FIFO当HDMI读到FIFO空时就会出现行撕裂。解决办法是用两个FIFO组成行级乒乓第N行写入FIFO_A同时HDMI从FIFO_B读取第N-1行下一行切换角色。这样缓冲深度只需要两行。但还要处理帧同步在VSYNC信号到来时切换帧缓冲否则显示两个半帧。另一个方案是直接使用片上RAM做双端口数组然后分配一个地址范围作为帧缓存。例如720P的RGB565一帧需要2560x720x2 3.6MBEP4CE10内嵌RAM最多只有大概276Kb实际上EP4CE10的Block RAM是414Kb约51KB根本放不下一帧。所以行乒乓是唯一可行方案或者外接SDRAM。这个项目如果要做完整场景建议加SDRAM或者只做低分辨率比如640x480并排显示一帧大小是1280x480x21.2MB依然不够。所以明智的做法是“流式传输”不存储整帧只缓存若干行直接让HDMI和摄像头同步滚动。这样要求摄像头的像素时钟和HDMI像素时钟必须接近比如摄像头输出720p60HDMI也输出720p60两者用PLL锁定到同一个参考时钟。我实际配置成摄像头输出1280x72060HDMI也输出1280x72060两个PCLK都源自同一个24MHz晶振经过PLL倍频后虽然相位不完全同步但频率偏差极小配合行FIFO就足够稳定。4. HDMI发送TMDS编码与视频时序4.1 用DVI/HDMI 1.4不是非得用IP核Altera Cyclone IV没有内建HDMI PHY但它的普通IO可以输出TMDS信号只是需要外部电阻网络或者直接用差分IO。HDMI光靠FPGA难在TMDS编码编码本身是纯数字逻辑难度不大关键是100MHz的高速信号约束。EP4CE10的IO速度等级是C8实测在720p60下像素时钟74.25MHz数据率是3路x 8bit x 10bit 2.4Gbps这已经逼近极限。所以最好把分辨率限制在720p。TMDS编码分为控制期和数据期。像素数据采用DE信号区分DE为高时传输像素数据要经过8b/10b编码DE为低时传输控制信号。HDMI允许额外的辅助数据但DVI只传输像素和控制信号。对于简单的显示器可以按DVI模式处理。4.2 TMDS 8b/10b编码算法拆解TMDS的8b/10b和传统8b/10b如PCIe不一样它是专为视频设计的。它把8位像素分成低4位和高4位分别经过“最小化转换”编码再根据直流平衡选择是否取反。具体算法如下对低4位D0-D3做异或或同或编码选择转换数更少的方式。判断标准如果D0为1使用XNOR否则XOR。对高4位D4-D7同理但参考上一位的q_m输出。合并两个4b编码和4位附加位得到9位。根据当前直流偏移DC balance决定第10位是否反转整个9位。在Verilog实现时用纯组合逻辑写编码函数然后打拍同步。下面给出一个简化的RGB通道编码器其中data是8位像素。module tmds_encode ( input wire clk, input wire [7:0] data, input wire [1:0] ctrl, // 控制信号: 00,01,10,11 input wire de, // 数据有效 output reg [9:0] tmds ); reg [4:0] dc_bias; reg [8:0] q_m; wire [3:0] d_low data[3:0]; wire [3:0] d_high data[7:4]; wire q_m_low_xor; // 低4位编码: 选择XOR或XNOR assign q_m_low_xor (d_low[0] 1b0) ? 1b1 : 1b0; // 简单示例 // 实际需要计算转换次数这里略去完整逻辑 always (posedge clk) begin if (de) begin // 像素编码流程 // q_m[0] d_low[0] // q_m[1] q_m[0] ^ d_low[1] 或 ~(^) // 根据dc_bias决定是否翻转 // tmds ... end else begin // 控制编码: 按Ctrl映射到特定10bit值 case (ctrl) 2b00: tmds 10b1101010100; 2b01: tmds 10b0010101011; 2b10: tmds 10b0101010100; 2b11: tmds 10b1010101011; endcase end end endmodule上面代码只是演示端口实际编码逻辑较长。有一个现成的开源实现从FPGA4Fun和HDMI2USB项目里都能找到tmds_encoder.v百度一搜就有。我建议不要自己逐位推编码直接用验证过的模块然后理解它的几个关键输出q_out[9]是反转位q_out[8:0]是编码结果。4.3 生成720p的视频时序HDMI显示必须满足VESA/CVT时序。1280x72060的时序参数如下参数像素数周期数水平有效1280-水平前肩16-水平同步64-水平后肩80-整行1440-垂直有效720-垂直前肩3-垂直同步5-垂直后肩20-整帧748-像素时钟为74.25MHz。在Verilog里定义两个计数器h_count从0到1439v_count从0到747。hsync在h_count从16到79时拉低vsync在v_count从723到727时拉低de在h_count 1280且v_count 720时拉高。这部分就是标准的视频信号发生器不需要IP核。在Altera平台上用PLL IP生成了三个时钟pixel_clk74.25M、clk_x1像素时钟、clk_x5371.25M用于串行化。由于EP4CE10没有专用的serdes硬核通常做法是用DDR输出或移位寄存器实现10:1串行化。一个土办法是使用Altera的ALTDDIO_OUT元件把10位并行数据拆成五个相位每个相位输出两位然后利用DDIO在时钟上下沿同时输出。这样做虽然没有真正的TMDS差分但配合外部电阻网络可以勉强工作。4.4 在约束文件中设置差分输出如果板子上的HDMI座是TMDS单端转差分那么需要把三个通道的PCLK、DATA2P/N等引脚指定到FPGA的差分对引脚。在Quartus的QSF文件里要给差分输出加上OUTPUT_IO_GROUP约束并在IO电平设为TMDS实际Cyclone IV没有原生的TMDS支持所以一般复用LVDS电平。一个可用的约束示例set_instance_assignment -name IO_STANDARD LVDS -to hdmi_clk_p set_instance_assignment -name IO_STANDARD LVDS -to hdmi_clk_n set_instance_assignment -name OUTPUT_TERMINATION SERIES 50 OHM -to hdmi_clk_p当然很多低成本开发板用电阻网络做单端转差动这种直接设为3.3V LVTTL输出再通过板子的90欧差分走线到HDMI座。如果你用的是那种板子IO电平选3.3V即可但要做set_output_delay约束。5. 上板验证与排错先解决SCCB无ACK再处理画面错位5.1 用SignalTap抓三个关键信号上板后不要直接看显示器先用SignalTap II抓SCCB的SDA、SCL确认读写ack。我用的是Quartus自带的逻辑分析仪触发条件设为VSYNC上升沿抓取左右两个摄像头各一帧的HREF、PCLK、DATA。如果看到HREF周期出现毛刺说明OV5640配置未完成检查PLL是否锁定。最容易犯的错误是寄存器0x3008写软复位后立即写其他寄存器OV5640复位期间不响应需要在写0x3008后至少延时10ms。如果SCCB一直无ACK用示波器量SDA上电后是否为高阻OV5640的SDA需要外部上拉4.7K。很多模块已经带了上拉但如果自己画板漏掉上拉电阻就会出现SDA一直低电平。另一个常见问题SCCB的器件地址是7位还是8位我前面写0x42实际上OV5640的7位地址是0x21左移一位才是0x42。在代码中用8位地址0x42是正确的但如果你直接写0x21且发地址时少移一位就会无ACK。5.2 图像偏色和拼接错位的诊断如果画面颜色偏紫或红蓝交换先确认RGB565的字节序。我这边最开始按高字节在前画面偏蓝把wr_data的两个字节交换后正常。如果出现左右图像各显示半幅比如左半屏是左摄像头右半屏是右摄像头但是中间有一条竖线花屏往往是行FIFO读写指针不同步。这时检查HDMI读使能是否正好从FIFO非空开始或者说FIFO的空标志信号有没有打拍。异步FIFO的空标志从写时钟同步到读时钟需要两个周期如果直接拿空标志作为读使能可能出现无效数据。正确做法是用“almost_empty”作为帧FIFO的读开始条件然后连续读整行。5.3 用“色卡法”验证双目同步验证双目是否真正同步我建议拍摄一个移动的亮点把LED灯在摄像头前快速摆动显示器上左右两帧中灯的位置差如果恒定说明同步没问题如果差值忽大忽小说明两个摄像头的帧启动时刻不一致。解决方法是把两个OV5640的RESET连在一起由同一个FIFO复位信号触发。切记不要在SCCB配置时分别复位因为两个摄像头的配置完成时刻可能相差几个ms这样即便寄存器相同帧起始也会有偏移。我的做法是先配置好两个摄像头然后同时拉高/拉低复位信号让它们同时启动输出。另一个实用技巧是在Verilog里把两个摄像头的VSYNC信号做异或如果两个VSYNC上升沿间隔超过一定阈值就触发一个错误标志位。我将其引到板载LED如果闪烁说明同步丢失此时检查PCLK是否出现毛刺或者改小PLL的倍频系数。最后EP4CE10的资源占用我完整工程用了约8600个LE、8个M9K块、3个PLL布线后Fmax可达92MHz。如果你用SDRAM做缓存资源会接近满载。这个设计留给你的扩展点是把两个摄像头输出换成MIPI输入或者将并排输出改成上下格式但OV5640的DVP并行接口模式在这个板上是性价比最高的方案。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进