ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ESP32-S3 + TinyUSB 实现免驱 USB 摄像头:UVC 协议与 MJPEG 流实战

ESP32-S3 + TinyUSB 实现免驱 USB 摄像头:UVC 协议与 MJPEG 流实战 1. 为什么我要折腾一个免驱摄像头手头有几块 ESP32-S3 的开发板之前一直拿来做些传感器采集和简单控制的小项目。有次朋友问我能不能用单片机做一个摄像头插到电脑上就能直接用不需要装任何驱动。这个需求听起来简单但实际做起来涉及的东西还挺多的——USB 协议栈、UVC 协议、图像传感器驱动、TinyUSB 框架每一个环节都有坑。我先说结论ESP32-S3 加上 OV2640 摄像头模组配合 TinyUSB 协议栈确实可以做到在 Windows 上即插即用系统会自动识别为标准 USB 摄像头设备打开相机应用就能看到画面。整个过程不需要在电脑端安装任何驱动或软件。这个项目的核心价值在于它把嵌入式开发和 USB 设备开发的门槛拉低了很多。你不需要去啃 USB 协议规范那几百页的文档也不需要买昂贵的 USB 分析仪一块几十块钱的开发板加一个摄像头模组就能跑起来。适合有基本 Arduino 或 ESP-IDF 开发经验、想了解 USB 设备开发、或者单纯想做一个自定义摄像头的朋友。我前后大概花了三周时间从环境搭建到最终稳定运行中间踩了不少坑。这篇文章会把整个流程拆开来讲包括方案选型的思考、关键参数的配置、代码的逐段解析以及我遇到的那些让人抓狂的问题和解决方法。2. 方案选型与整体设计思路2.1 为什么选 ESP32-S3 而不是其他芯片市面上能做 USB 摄像头的方案不少比如 STM32 系列配合 USB 外设、树莓派 Pico、还有各种专用的 USB 摄像头芯片。我最终选 ESP32-S3 主要基于几个考虑。第一是原生 USB OTG 支持。ESP32-S3 内置了 USB 1.1 全速控制器注意这里是全速Full Speed12 Mbps不是高速High Speed480 Mbps。这个带宽限制直接决定了你能做到什么分辨率和帧率。全速 USB 的等时传输端点最大包长是 1023 字节每帧最多传输 19 个包在高速模式下这个数字不同算下来每毫秒最多传输约 19KB 数据。对于 MJPEG 格式来说640x480 分辨率下每帧大概 20-50KB所以帧率大概能到 10-15fps。这个性能做监控或者视频通话够用了但别指望做高清直播。第二是计算能力和内存。ESP32-S3 是双核 240MHz 的 Xtensa LX7带向量指令扩展对于图像数据的搬运和简单处理绰绰有余。512KB 的 SRAM 加上外扩 PSRAM一般开发板会带 8MB足够做帧缓冲。OV2640 输出 JPEG 数据时一帧 640x480 大概 30KB 左右双缓冲也就 60KB完全没压力。第三是生态和成本。ESP-IDF 对 USB 的支持已经比较成熟了TinyUSB 作为中间件集成得很自然。开发板价格便宜OV2640 模组更是白菜价整体成本控制在百元以内。对比一下其他方案STM32F4 系列虽然 USB 性能更好有些型号支持高速但开发环境配置麻烦USB 协议栈要么用 ST 的库比较臃肿要么自己移植学习曲线陡峭。树莓派 Pico 的 USB 是 1.1 全速但 RP2040 没有摄像头接口得用 SPI 或者并口去读摄像头数据带宽更紧张。所以 ESP32-S3 在这个场景下是一个比较均衡的选择。2.2 UVC 协议到底是怎么回事UVC 全称 USB Video Class是 USB 实现者论坛定义的一套标准协议。它的核心思想是摄像头设备不需要厂商提供专用驱动操作系统内置的通用驱动就能识别和操作它。Windows 从 Vista 开始就内置了 UVC 驱动Linux 的 uvcvideo 模块更是早就有了。UVC 设备在 USB 描述符里会声明自己是一个视频接口集合Video Interface Collection包含视频控制接口VC Interface和视频流接口VS Interface。控制接口负责处理亮度、对比度、曝光这些参数的调节流接口负责实际的数据传输。数据传输用的是等时传输Isochronous Transfer。这种传输方式的特点是保证带宽和时序但不保证数据完整性——丢包了不会重传。对于视频流来说这是合理的偶尔丢一帧用户根本看不出来但如果因为重传导致延迟累积体验反而更差。UVC 协议里定义了几种数据格式最常用的是未压缩的 YUV和MJPEG。YUV 格式数据量大640x480 一帧就要 460KB全速 USB 根本传不动。MJPEG 是压缩格式每帧数据量小很多而且 OV2640 硬件支持 JPEG 编码直接输出压缩好的数据ESP32-S3 只需要搬运就行不需要做额外的编码运算。所以 MJPEG 是唯一可行的选择。2.3 TinyUSB 在中间扮演什么角色TinyUSB 是一个开源的跨平台 USB 协议栈支持设备模式和主机模式。它的设计很干净把 USB 协议的各种细节封装成了回调函数和描述符配置开发者只需要关注自己的业务逻辑。在这个项目里TinyUSB 负责几件事枚举过程中向主机报告自己是一个 UVC 摄像头设备处理主机发来的各种标准请求和类特定请求比如设置亮度、查询支持的分辨率管理等时传输端点的数据发送。ESP-IDF 已经把 TinyUSB 集成进来了通过esp_tinyusb组件可以直接使用。不过要注意版本兼容性不同版本的 ESP-IDF 集成的 TinyUSB 版本不一样API 可能有细微差别。我用的 ESP-IDF 是 v5.1对应的 TinyUSB 版本是 0.14 左右。2.4 整体数据流设计整个系统的数据流是这样的OV2640 通过 SCCB 总线兼容 I2C接收配置命令通过 8 位并口或者 DVP 接口输出 JPEG 数据。ESP32-S3 的 LCD_CAM 外设负责接收这些数据通过 DMA 搬运到 PSRAM 里的帧缓冲。然后 TinyUSB 的 UVC 类驱动从帧缓冲里取数据通过 USB 等时端点发送给主机。这里有个关键设计点双缓冲还是三缓冲。我一开始用的是单缓冲结果发现帧率很不稳定因为 USB 发送和摄像头采集会争抢同一个缓冲区。改成双缓冲后一个缓冲区在采集时另一个在发送帧率稳定了很多。三缓冲理论上更好但会占用更多内存而且在这个带宽瓶颈下提升有限所以最终用了双缓冲。3. 硬件准备与开发环境搭建3.1 硬件清单和接线要点我用的硬件清单如下ESP32-S3-DevKitC-1 开发板带 8MB PSRAM 的版本OV2640 摄像头模组带 24pin FPC 排线USB 数据线注意要能传数据的有些线只能充电杜邦线若干接线方面OV2640 的 DVP 接口需要连接到 ESP32-S3 的 LCD_CAM 外设引脚。具体对应关系如下OV2640 引脚ESP32-S3 引脚功能说明SIODGPIO4SCCB 数据SIOCGPIO5SCCB 时钟VSYNCGPIO6垂直同步HREFGPIO7水平参考PCLKGPIO13像素时钟XCLKGPIO15主时钟输出D0-D7GPIO11,9,8,10,12,18,17,16数据总线PWDN接地电源下电低有效RESET接 3.3V复位高有效注意不同开发板的引脚定义可能不同一定要查你手上板子的原理图。我一开始照着网上的教程接线结果那篇教程用的是另一款开发板引脚完全对不上浪费了半天时间排查。XCLK 是 ESP32-S3 输出给摄像头的时钟信号OV2640 最高支持 24MHz但实际用 20MHz 比较稳定。这个时钟是通过 LEDC 或者 LCD_CAM 外设的分频器产生的后面代码里会详细说。3.2 ESP-IDF 环境搭建的坑ESP-IDF 的安装本身不复杂官方文档写得很清楚。但有几个地方容易出问题。首先是Python 版本。ESP-IDF v5.1 要求 Python 3.7 以上但不要用最新的 3.12有些依赖包还没适配。我用的是 3.11比较稳。安装的时候建议用虚拟环境避免污染系统 Python。其次是工具链下载。国内网络环境下载 GitHub 上的工具链可能会很慢甚至失败。我的做法是手动下载离线安装包然后设置环境变量指向本地路径。具体来说在~/.espressif目录下会有tools和python_env两个文件夹如果安装脚本卡在下载环节可以手动把工具链压缩包放到dist目录下再重新运行安装脚本。安装完成后运行idf.py --version确认版本。然后创建一个新项目idf.py create-project uvc_camera cd uvc_camera3.3 项目配置的关键选项在idf.py menuconfig里有几个配置项必须改Component config → ESP System Settings → Memory protection关掉不然 PSRAM 的 DMA 访问会出问题。Component config → ESP PSRAM → Support for external RAM打开并且选择正确的 PSRAM 类型一般是 Octal SPI PSRAM。Component config → TinyUSB Stack → TinyUSB task stack size改成 4096 以上默认的 2048 不够用会栈溢出。Component config → TinyUSB Stack → Enable UVC打开这是 UVC 类驱动的开关。Component config → Camera Configuration如果用的是 esp32-camera 组件这里要选对摄像头型号和引脚定义。还有一个容易忽略的地方USB 描述符的 VID 和 PID。默认的 VID 是 0x303AEspressif 的PID 是 0x4001。如果你想自定义可以在 menuconfig 里改。不过对于免驱摄像头来说用默认的就行Windows 会根据 UVC 类描述符来匹配驱动跟 VID/PID 关系不大。4. 核心代码逐段解析4.1 摄像头初始化的关键参数摄像头初始化用的是 esp32-camera 组件这个组件封装了 OV2640 的寄存器配置。核心配置结构体如下camera_config_t camera_config { .pin_pwdn -1, .pin_reset -1, .pin_xclk 15, .pin_sccb_sda 4, .pin_sccb_scl 5, .pin_d7 16, .pin_d6 17, .pin_d5 18, .pin_d4 12, .pin_d3 10, .pin_d2 8, .pin_d1 9, .pin_d0 11, .pin_vsync 6, .pin_href 7, .pin_pclk 13, .xclk_freq_hz 20000000, .ledc_timer LEDC_TIMER_0, .ledc_channel LEDC_CHANNEL_0, .pixel_format PIXFORMAT_JPEG, .frame_size FRAMESIZE_VGA, .jpeg_quality 12, .fb_count 2, .fb_location CAMERA_FB_IN_PSRAM, .grab_mode CAMERA_GRAB_WHEN_EMPTY, };这里有几个参数值得展开说。xclk_freq_hz设成 20MHz。OV2640 的规格书说最高 24MHz但实际测试下来 20MHz 更稳定。如果你发现画面有横纹或者颜色异常可以试着降到 16MHz 甚至 10MHz。这个时钟是由 LEDC 外设产生的精度够用。jpeg_quality设成 12。这个值的范围是 0-63数字越小质量越高、数据量越大。12 是一个比较平衡的值640x480 下每帧大概 25-35KB。如果你需要更高帧率可以调到 20 甚至 30但画质会明显下降。我试过调到 8画质确实好一些但帧率从 12fps 掉到了 9fps得不偿失。fb_count设成 2就是双缓冲。前面说过单缓冲会导致帧率不稳定。如果你内存够可以设成 3但在这个带宽瓶颈下提升不明显。grab_mode设成CAMERA_GRAB_WHEN_EMPTY意思是当帧缓冲为空时才抓取新帧。另一个选项是CAMERA_GRAB_LATEST会覆盖旧帧。对于 UVC 来说WHEN_EMPTY更合适因为 USB 传输需要稳定的数据源频繁覆盖会导致传输中断。4.2 TinyUSB UVC 描述符的配置UVC 描述符是整个项目里最复杂的部分。TinyUSB 提供了一套宏来简化描述符的编写但理解每个字段的含义还是必要的。描述符分为几个层次设备描述符、配置描述符、接口关联描述符IAD、视频控制接口描述符、视频流接口描述符以及各种类特定描述符。设备描述符里要声明bDeviceClass为TUSB_CLASS_MISCbDeviceSubClass为 2Common ClassbDeviceProtocol为 1Interface Association Descriptor。这样主机就知道这个设备使用了 IAD 来组合多个接口。配置描述符里要包含两个接口一个是视频控制接口一个是视频流接口。视频控制接口的bInterfaceClass是TUSB_CLASS_VIDEObInterfaceSubClass是 1Video ControlbInterfaceProtocol是 0。视频流接口的bInterfaceSubClass是 2Video Streaming。视频控制接口下面要挂几个类特定描述符头部描述符、输入终端描述符、输出终端描述符、摄像头终端描述符、处理单元描述符、编码单元描述符。这些描述符定义了摄像头的功能拓扑结构。视频流接口下面要挂输入头部描述符和格式描述符。格式描述符里要声明支持的格式MJPEG、分辨率、帧率。TinyUSB 的 UVC 驱动已经把这些描述符的模板写好了在usb_descriptors.c里。你需要改的主要是分辨率、帧率和格式相关的字段。我建议先直接用默认的配置跑通再根据自己的需求调整。4.3 等时传输端点的配置等时传输端点的配置有几个关键参数#define EPNUM_VS_IN 0x81 #define EPNUM_VS_OUT 0x020x81表示端点 1方向为 IN设备到主机。0x02表示端点 2方向为 OUT主机到设备。UVC 的视频流数据主要通过 IN 端点传输OUT 端点一般不用但描述符里还是要声明。等时端点的wMaxPacketSize在全速模式下最大是 1023 字节。TinyUSB 会根据这个值来计算每帧需要传输多少个包。在usbd_edpt_open的时候TinyUSB 会检查端点是否支持等时传输。这里有个坑ESP32-S3 的 USB 外设对等时传输的支持有限。具体来说它不支持等时传输的同步端点Synchronization Endpoint只支持异步端点。这意味着你不能保证数据包的精确时序只能尽力而为。对于 MJPEG 流来说问题不大因为每帧数据是独立的丢几个包只会导致画面局部花屏不会导致整个流崩溃。4.4 数据发送的核心逻辑数据发送的核心是一个循环从摄像头帧缓冲取数据通过 USB 端点发送出去。void uvc_task(void *arg) { camera_fb_t *fb NULL; while (1) { fb esp_camera_fb_get(); if (!fb) { vTaskDelay(1); continue; } // 发送帧头 uvc_frame_header_t header { .bmHeaderInfo 0x01, // 帧开始 .bFrameIndex 1, .dwFrameInterval 333333, // 30fps }; tud_video_n_frame_xfer(0, header, fb-buf, fb-len); esp_camera_fb_return(fb); vTaskDelay(1); } }tud_video_n_frame_xfer是 TinyUSB 提供的 UVC 帧传输函数。它会自动把数据分成多个 USB 包通过等时端点发送。注意这个函数是异步的调用后会立即返回实际传输在后台进行。这里有个关键点帧间隔dwFrameInterval的单位是 100 纳秒。333333 乘以 100 纳秒等于 33.3 毫秒对应 30fps。但实际能达到的帧率受限于 USB 带宽和摄像头采集速度这个值只是一个期望值。还有一个容易忽略的地方帧缓冲的释放时机。esp_camera_fb_return必须在数据发送完成后才能调用否则会出现数据竞争。但tud_video_n_frame_xfer是异步的怎么知道发送完成了TinyUSB 提供了一个回调函数tud_video_tx_done_cb在这个回调里释放帧缓冲才是安全的。我一开始没注意这个问题直接在调用tud_video_n_frame_xfer后就释放了帧缓冲结果画面经常出现撕裂和花屏。后来改成在回调里释放问题就解决了。5. 调试过程中踩过的坑5.1 Windows 识别不到设备怎么办这是最常见的问题。插上 USB 后Windows 设备管理器里要么什么都没有要么显示一个带黄色感叹号的未知设备。首先检查USB 线。我遇到过好几次换了三根线才找到一根能传数据的。有些线只有电源线没有数据线插上后设备能供电但无法枚举。其次检查描述符配置。用 USB 分析工具比如 Wireshark 的 USBPcap 插件抓包看看枚举过程卡在哪一步。如果主机发了GET_DESCRIPTOR请求但设备没有响应说明描述符配置有问题。如果设备响应了但主机不认可能是描述符的某个字段不符合 UVC 规范。还有一个可能的原因是电源问题。ESP32-S3 加上 OV2640 的功耗大概在 200mA 左右有些 USB 口供电不足会导致枚举失败。可以试着换一个 USB 口或者用带外部供电的 USB Hub。5.2 画面卡顿、帧率不稳定的排查画面卡顿的原因可能有很多。我遇到过的几种情况PSRAM 带宽不足。ESP32-S3 的 PSRAM 是通过 Octal SPI 接口连接的带宽有限。如果摄像头采集和 USB 发送同时访问 PSRAM会出现争抢。解决办法是把帧缓冲放在内部 SRAM 里但内部 SRAM 只有 512KB放不下两帧 640x480 的 JPEG 数据。折中方案是降低分辨率到 320x240这样一帧只有 10KB 左右双缓冲也就 20KB可以放在内部 SRAM。USB 中断优先级太低。TinyUSB 的任务优先级默认是 5如果其他任务优先级更高USB 传输会被打断。可以在 menuconfig 里把 TinyUSB 的任务优先级调到 10 以上。摄像头时钟不稳定。XCLK 的精度会影响摄像头输出的稳定性。如果发现画面有周期性抖动可以试着降低 XCLK 频率或者换一个 LEDC 定时器通道。5.3 画面颜色异常的解决方法OV2640 输出的 JPEG 数据默认是 YUV422 格式但有些主机期望的是 YUV420 或者 RGB。如果颜色明显偏绿或者偏紫说明色彩空间转换有问题。UVC 描述符里有一个bColorPrimaries字段用来声明色彩空间。默认值是 1BT.709可以改成 2BT.601试试。另外bTransferCharacteristics和bMatrixCoefficients也会影响颜色表现。还有一个可能是JPEG 解码器的问题。Windows 的 UVC 驱动会用系统自带的 JPEG 解码器来解码 MJPEG 数据。如果解码器不支持某种 JPEG 编码方式颜色就会异常。解决办法是在 OV2640 的寄存器配置里关掉一些高级编码选项比如 4:2:2 采样、霍夫曼表优化等。5.4 常见问题速查表问题现象可能原因排查方法解决方案设备管理器无反应USB 线只有电源线换线测试使用带数据线的 USB 线未知设备黄色感叹号描述符配置错误Wireshark 抓包检查 UVC 描述符字段枚举成功但无画面端点配置错误查看 TinyUSB 日志检查等时端点地址和包长画面卡顿PSRAM 带宽不足降低分辨率测试改用内部 SRAM 或降低分辨率颜色偏绿/偏紫色彩空间不匹配修改描述符字段调整 bColorPrimaries帧率低于预期USB 带宽瓶颈计算理论带宽降低分辨率或提高 JPEG 压缩率画面撕裂帧缓冲释放时机错误检查回调函数在 tx_done 回调里释放设备频繁掉线电源供电不足测量电流使用外部供电 Hub6. 性能优化与进阶玩法6.1 帧率和分辨率的平衡全速 USB 的带宽是 12Mbps实际可用带宽大概 8-9Mbps。MJPEG 数据加上 USB 协议开销实际有效载荷大概 6-7Mbps。换算成字节是每秒 750-875KB。640x480 分辨率下JPEG 质量 12 时每帧约 30KB理论帧率是 25-29fps。但实际受限于摄像头采集速度和 PSRAM 带宽能跑到 12-15fps 就不错了。320x240 分辨率下每帧约 10KB理论帧率可以到 75fps但摄像头本身最高只支持 30fpsOV2640 在 VGA 以下分辨率可以到 60fps但需要配置寄存器。实际能跑到 25-30fps。如果你需要更高的帧率可以考虑降低 JPEG 质量到 20-25这样每帧数据量会减少 30% 左右。但画质会明显下降文字边缘会出现明显的块状伪影。6.2 用 PSRAM 做帧缓冲的注意事项ESP32-S3 的 PSRAM 有两种模式Quad SPI 和 Octal SPI。Octal SPI 的带宽是 Quad SPI 的两倍但需要更多的引脚。大多数开发板用的是 Octal SPI PSRAM带宽大概 80MB/s。虽然带宽看起来够用但 PSRAM 的访问延迟比内部 SRAM 高很多。DMA 访问 PSRAM 时如果和 CPU 访问发生冲突会导致 DMA 传输暂停。解决办法是尽量让 DMA 访问和 CPU 访问错开比如在 USB 发送数据时CPU 去做其他事情不要去读 PSRAM。另外PSRAM 的缓存配置也很重要。在 menuconfig 里可以设置 PSRAM 的缓存大小默认是 32KB可以调到 64KB。缓存越大DMA 访问的命中率越高性能越好。6.3 多分辨率切换的实现UVC 协议支持在运行时切换分辨率主机可以通过SET_CUR请求来设置新的分辨率。实现这个功能需要在 UVC 类驱动里处理SET_CUR请求然后重新配置摄像头。具体来说在tud_video_set_interface_cb回调里根据主机设置的bFrameIndex来调用esp_camera_set_framesize。注意切换分辨率会导致摄像头重新初始化画面会中断几百毫秒这是正常的。我实现了一个简单的版本支持 640x480 和 320x240 两种分辨率。主机在相机应用里切换分辨率时设备会自动重新配置。不过 Windows 的相机应用对分辨率切换的支持不太好有时候需要重新打开应用才能生效。6.4 加个麦克风做成音视频一体设备UVC 只处理视频如果要加音频需要同时实现 UACUSB Audio Class。TinyUSB 支持 UAC但配置起来比 UVC 复杂一些。音频数据通过等时端点传输和视频端点分开。ESP32-S3 可以用 I2S 接口接一个数字麦克风比如 INMP441。音频采样率一般用 16kHz 或 48kHz16 位单声道。音视频同步是个麻烦事。UVC 和 UAC 是独立的接口主机不会自动同步它们的时间戳。简单的做法是在视频帧头里嵌入音频时间戳但需要主机端的配合。对于一般的视频通话场景只要音频和视频的延迟都在可接受范围内用户是感觉不到不同步的。7. 我个人的一些实操心得这个项目从开始到稳定运行前后大概花了三周。中间有几次差点放弃主要是因为 USB 枚举的问题太难排查了。没有专业的 USB 分析仪只能靠 Wireshark 抓包和 TinyUSB 的日志来猜。后来我发现一个技巧先用 TinyUSB 的示例代码跑通枚举。TinyUSB 仓库里有一个video_capture示例是一个完整的 UVC 摄像头实现。我先把那个示例烧进去确认 Windows 能识别然后再一步步改成自己的代码。这样可以把问题范围缩小避免同时调试多个变量。另一个心得是日志很重要。TinyUSB 有详细的日志输出可以在 menuconfig 里把日志级别调到 Debug。虽然日志会很多但关键时刻能救命。我遇到过一个枚举失败的问题看了日志才发现是描述符的长度字段算错了导致主机读取描述符时越界。还有一点不要迷信网上的教程。我参考的几篇教程里有的引脚定义是错的有的代码是基于旧版本的 TinyUSBAPI 已经变了。最可靠的做法是看官方文档和示例代码然后根据自己的硬件调整。最后说一个细节USB 接口的 ESD 保护。ESP32-S3 的 USB 引脚比较脆弱如果经常插拔建议加一个 ESD 保护二极管。我有一块板子就是因为静电打坏了 USB 引脚只能换芯片。这个方案后续还可以扩展比如加一个 SD 卡做本地录像或者通过 WiFi 做 RTSP 推流。不过那就是另一个项目了涉及到网络协议栈和视频编码复杂度会高很多。如果只是想做一个免驱摄像头现在这个方案已经够用了。
返回列表