29、项目实战2:网络摄像头(OV5640+以太网UDP传输+PC上位机显示)

这个项目,说实话,是我个人觉得整个课程里最有意思的一个。

为什么?因为它把图像采集、高速传输、PC端显示全串起来了。你做完这个,基本就能理解一个完整的视频传输链路是怎么跑的。

说白了,就是把OV5640摄像头拍到的画面,通过FPGA打包成UDP包,扔到以太网上,最后在PC的上位机软件里显示出来。

29.1 项目整体架构

先看大框架。整个系统分四块:

  • 图像采集端:OV5640摄像头,输出DVP并行数据。
  • FPGA处理核心:负责数据缓存、格式转换、UDP组包。
  • 以太网传输:PHY芯片(RTL8211或YT8512),走UDP协议。
  • PC上位机:用Python或C#写的接收程序,解析UDP包并显示图像。

我习惯把FPGA内部再分成几个模块:

  • cmos_capture:抓取OV5640的像素数据。
  • frame_buffer:用DDR3或内部BRAM做帧缓存。
  • udp_packet:把图像数据切成MTU大小的UDP包。
  • eth_tx:MAC层发送,控制PHY芯片。

29.2 OV5640配置与数据采集

OV5640上电后,默认输出是RGB565格式,分辨率640x480。但我们需要调整一下。

我个人建议输出格式用JPEG压缩流,而不是原始RGB。为什么?因为640x480的RGB数据,一帧就有640*480*2 = 614400字节。如果每秒30帧,那就是18MB/s的数据量。UDP虽然快,但丢包风险也大。JPEG压缩后,一帧可能只有几十KB,传输压力小很多。

小提示:OV5640的寄存器配置可以通过SCCB(类似I2C)接口写入。我一般用逻辑分析仪抓一下初始化时序,确保每个寄存器都写进去了。

配置关键寄存器:

// 设置输出格式为JPEG
SCCB_Write(0x3006, 0x00);  // 关闭ISP
SCCB_Write(0x3018, 0x04);  // 设置DVP输出
SCCB_Write(0x3034, 0x1A);  // 设置PLL
SCCB_Write(0x4300, 0x30);  // 输出格式:JPEG
SCCB_Write(0x501F, 0x01);  // 使能JPEG输出

嗯,这里要注意:OV5640的JPEG输出不是连续的,它会在每帧开始和结束时插入一些特殊标记。你需要在FPGA里检测这些标记,才能正确切分帧数据。

29.3 数据缓存与UDP组包

JPEG数据是变长的,一帧可能大也可能小。所以我们需要一个缓存区。

我项目中用过两种方案:

  • 方案A:用DDR3做帧缓存。适合高分辨率,但控制复杂。
  • 方案B:用内部BRAM做行缓存。适合低分辨率,简单可靠。

对于640x480的JPEG流,我推荐方案B。因为JPEG压缩后,一帧数据通常不超过100KB。Pango系列FPGA的BRAM足够用。

UDP组包时,要注意MTU限制。标准以太网MTU是1500字节,减去IP头和UDP头(20+8=28字节),实际有效载荷最多1472字节。

所以你要把一帧JPEG数据切成多个1472字节的包。每个包加一个自定义头部,包含帧序号和包序号。

// UDP包自定义头部结构
typedef struct {
    uint16_t frame_id;   // 帧序号
    uint16_t packet_id;  // 包序号
    uint16_t data_len;   // 本包数据长度
    uint16_t total_packets; // 总包数
} udp_header_t;

PC上位机收到包后,根据帧序号和包序号重组数据。如果发现丢包,可以请求重传,或者直接丢弃这一帧。

注意:UDP不保证可靠传输。如果网络环境差,丢包是常态。我建议在上位机里做简单的丢包检测,比如连续丢3个包就显示“网络不稳定”提示。

29.4 以太网MAC层实现

紫光同创的FPGA里,我一般用GMII接口连接PHY芯片。时钟125MHz,数据位宽8bit。

MAC层需要实现:

  • 前导码生成(7字节0x55 + 1字节0xD5)
  • MAC地址填充
  • IP/UDP校验和计算
  • CRC32校验

校验和计算是个坑。IP头校验和可以用查表法快速计算,但UDP校验和是可选的。我建议把UDP校验和设为0,这样PHY芯片不会校验,能省不少逻辑资源。

发送流程大致如下:

// 伪代码:UDP包发送状态机
state_idle:
    if (有数据要发送) goto state_preamble;
state_preamble:
    发送8字节前导码;
    goto state_mac;
state_mac:
    发送14字节MAC头(目标MAC+源MAC+类型);
    goto state_ip;
state_ip:
    发送20字节IP头;
    goto state_udp;
state_udp:
    发送8字节UDP头;
    goto state_data;
state_data:
    发送1472字节图像数据;
    goto state_crc;
state_crc:
    发送4字节CRC32;
    goto state_idle;

我曾经在调试时发现,CRC32计算错了,导致PC端网卡直接丢弃了所有包。后来用Wireshark抓包才发现,原来是我把CRC的初始值设错了。嗯,细节决定成败。

29.5 PC上位机显示

上位机我用Python写的,用socket接收UDP数据,用OpenCV显示图像。

核心代码就几行:

import socket
import cv2
import numpy as np

# 创建UDP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', 8080))

frame_buffer = {}
current_frame_id = 0

while True:
    data, addr = sock.recvfrom(2048)
    header = data[:8]  # 解析自定义头部
    frame_id = int.from_bytes(header[0:2], 'big')
    packet_id = int.from_bytes(header[2:4], 'big')
    total_packets = int.from_bytes(header[6:8], 'big')
    
    # 缓存数据包
    if frame_id not in frame_buffer:
        frame_buffer[frame_id] = [None] * total_packets
    frame_buffer[frame_id][packet_id] = data[8:]
    
    # 检查是否收齐一帧
    if all(p is not None for p in frame_buffer[frame_id]):
        jpeg_data = b''.join(frame_buffer[frame_id])
        img = cv2.imdecode(np.frombuffer(jpeg_data, np.uint8), cv2.IMREAD_COLOR)
        cv2.imshow('Camera', img)
        cv2.waitKey(1)
        del frame_buffer[frame_id]

你想想看,这个上位机其实很简单。但实际跑起来,你会发现帧率可能只有10-15fps。为什么?因为Python的socket接收速度有限,而且OpenCV的imdecode也需要时间。

如果想提高帧率,可以用C++写上位机,或者用硬件解码。不过对于学习来说,Python版本已经够用了。

29.6 调试与避坑指南

这个项目调试起来,说实话,有点麻烦。因为涉及硬件和软件两边,问题定位比较困难。

我总结几个常见坑:

  • OV5640初始化失败:SCCB时序不对,或者上电顺序不对。建议用示波器抓SCCB波形,确认每个ACK都有回应。
  • UDP包发不出去:检查PHY芯片的复位时序,以及MAC地址是否配置正确。可以用Wireshark在PC端抓包,看有没有数据到达。
  • 图像花屏:JPEG数据被截断或顺序错乱。检查FPGA里的帧缓存逻辑,确保每个包的数据完整。
  • 帧率太低:可能是UDP包间隔太大,或者上位机处理太慢。可以尝试增大UDP发送缓冲区,或者减少每帧的JPEG质量。
核心要点:这个项目的关键在于数据流的控制。从摄像头到FPGA,再到以太网,最后到PC,每一步都要保证数据不丢失、不乱序。建议先用小分辨率(320x240)调试,成功后再升级到640x480。

我记得第一次跑通这个项目时,看到PC屏幕上显示出摄像头画面,那种成就感真的难以形容。虽然只有15fps,但那是自己一行一行代码写出来的。

好了,这一章就到这里。下一章我们会做一个更复杂的项目:基于PCIe的高速数据采集卡。到时候见。