概述:双路 USB 视频不是“打开两个端点”这么简单。设备要先用两组 UVC 接口和 IAD 把一个 USB 设备声明成两个视频功能,再分别准备两条图像处理链,把它们接到两个 USB 输出实例。环形缓冲解决生产和传输之间的速率波动,PTS 为两路帧提供可比较的时间信息,真正的帧配对仍由上位机完成。

本文讨论一种常见的嵌入式方案:SoC 直接连接 USB 控制器,内部从两个图像源产生视频,设备端运行 RTOS 或轻量固件,主机端使用标准 UVC 驱动。文章不绑定某个芯片型号,重点提炼可以迁移到其他图像 SoC、MCU+视频协处理器或 USB 摄像头桥接设备的设计方法。

1. 先建立四层模型

“一个设备、两个摄像头、同步输出”实际上包含四个问题。把它们分开,后面的链路才容易理解。

问题负责层解决的事情
主机为什么看到两个摄像头USB 描述符层把接口组合成两个 UVC 功能
芯片怎样产生两路视频视频业务层为两路源分别建立处理和输出链
两路帧怎样带着时间出 USBUSB 视频硬件层组织帧、添加 Header、写入端点
上位机怎样把两路帧配成一对应用层使用时间戳、队列和超时窗口配对

这四层不能互相替代:

  • IAD 能让主机注册两个摄像头,但不能产生第二路像素。
  • 两条视频链能各自出图,但没有共同时间信息时,主机无法可靠配对。
  • 时间戳能让两路帧可比较,但不会自动把两帧合成一个应用对象。
  • 三帧缓冲能保护单路帧的完整性,但不是跨摄像头同步锁。
flowchart LR
    DESC["USB 描述符\nIAD + VC + VS"] --> ENUM["主机\n两个摄像头条目"]
    SOURCE["两个图像源\n处理与输出链"] --> FRAME["两路完整帧"]
    FRAME --> PACK["UVC 封装\nHeader + PTS/SCR"]
    PACK --> HOST["主机接收队列"]
    HOST --> PAIR["按时间戳配对"]

全文按这个顺序展开:先讲“主机看到什么”,再讲“设备送出什么”,最后讲“主机如何对齐”。

2. 一个 USB 设备为什么能显示两个摄像头

2.1 设备、功能和接口不是一回事

USB 设备对应一份设备描述符和配置描述符。配置描述符下面可以包含多个接口,主机驱动再按照接口之间的关联关系,把它们组合成具体功能。

一路 UVC 视频通常至少包含两个接口:

  • VC(Video Control)接口:负责控制请求和格式协商;
  • VS(Video Streaming)接口:负责视频流配置和数据传输。

IAD(Interface Association Descriptor,接口关联描述符)告诉主机:连续的若干接口属于同一个功能。一台设备提供两路视频时,可以抽象为:

IAD 0 → VC 0 + VS 1 → Bulk IN 0x81 → 视频功能 0
IAD 1 → VC 2 + VS 3 → Bulk IN 0x82 → 视频功能 1

IAD 只负责“分组”,不负责搬运像素。真正的视频字节仍通过 VS 接口对应的端点进入 USB 链路。

2.2 描述符结构与主机视图

flowchart TB
    classDef device fill:#ede9fe,stroke:#7c3aed,color:#3b0764
    classDef function fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef endpoint fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef host fill:#dbeafe,stroke:#2563eb,color:#172554

    D["一个 USB Device\n设备级名称"]:::device
    F0["IAD 0\nVC + VS"]:::function
    F1["IAD 1\nVC + VS"]:::function
    E0["Bulk IN\n0x81"]:::endpoint
    E1["Bulk IN\n0x82"]:::endpoint
    W0["主机摄像头 0"]:::host
    W1["主机摄像头 1"]:::host

    D --> F0 --> E0 --> W0
    D --> F1 --> E1 --> W1

图 1:一个设备描述符下面挂两组 UVC 功能,主机最终显示两个摄像头条目。

主机完成枚举后,只能确认设备声明了两个功能和两个数据入口。它还不知道芯片内部哪条图像源会接到哪个端点。因此,“两个摄像头能被看到”只证明描述符正确,不证明视频链已经出图。

2.3 端点解决传输分路,不解决时间同步

两个端点共用同一个 USB 控制器和物理链路,但主机调度两个端点时,不会自动保证它们的帧边界一致。

可以先记住这组对应关系:

IAD / VC / VS / endpoint  → 解决“哪一路是什么”
PTS / SOF / 应用队列     → 解决“哪两帧时间接近”

所以,枚举完成后,下一步不是继续修改描述符,而是检查设备内部是否真正建立了两条独立的视频数据链。

3. 设备内部怎样形成两条视频链

3.1 两路视频从源头就应分开

典型的数据路径如下:

图像源 A
  → 采集/处理节点
  → 输出接口 A
  → UVC 封装实例 A
  → 端点 0x81

图像源 B
  → 采集/处理节点
  → 输出接口 B
  → UVC 封装实例 B
  → 端点 0x82

中间可以包含裁剪、缩放、格式转换、叠加、缓存和 DMA 等节点。不同芯片的模块名称会变化,但职责顺序基本一致:

源流 → 图像处理 → 输出接口 → UVC 封装 → USB 端点。

flowchart TB
    classDef source fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
    classDef process fill:#ede9fe,stroke:#7c3aed,color:#3b0764
    classDef output fill:#ffedd5,stroke:#ea580c,color:#431407
    classDef usb fill:#dbeafe,stroke:#2563eb,color:#172554
    classDef host fill:#dcfce7,stroke:#16a34a,color:#14532d

    A["图像源 A"]:::source --> AP["处理链 A\n裁剪/缩放/格式转换"]:::process --> AO["输出接口 A"]:::output --> AW["UVC 封装 A\nHeader / PTS / SCR"]:::usb --> AE["Bulk IN 0x81"]:::usb --> AH["主机摄像头 0"]:::host
    B["图像源 B"]:::source --> BP["处理链 B\n裁剪/缩放/格式转换"]:::process --> BO["输出接口 B"]:::output --> BW["UVC 封装 B\nHeader / PTS / SCR"]:::usb --> BE["Bulk IN 0x82"]:::usb --> BH["主机摄像头 1"]:::host

图 2:两路在设备内部独立产生、独立封装、独立进入端点;它们只在 USB 控制器和总线上共享资源。

3.2 命令面、数据面和事件面

一个可靠的双路实现,至少要区分三条方向不同的链路:

交互面方向典型内容是否承载像素
命令面上层 → 视频驱动 → 硬件尺寸、格式、源选择、启停否
数据面图像处理 → DMA/缓存 → UVC 封装 → 端点原始或处理后的帧数据是
事件面中断 → 驱动 → 事件/回调帧完成、空闲、溢出、错误否

事件面只能反馈硬件状态,不能代替数据面。例如收到 FIFO 溢出事件,只能说明某一路受到传输压力,不代表固件已经自动恢复,也不代表另一条路必然停止。

flowchart LR
    APP["业务配置"] -->|命令| DRIVER["视频驱动"] -->|寄存器配置| HW["图像与 USB 硬件"]
    HW -->|像素数据| FIFO["端点 FIFO"] --> PC["主机"]
    HW -.->|中断事件| DRIVER -.->|状态/回调| APP

图 3:命令面配置数据面,事件面反馈状态,三者不能混为一谈。

3.3 一路真正出图需要两个条件

每一路都必须同时满足:

  1. 设备侧准备完成:源流、处理节点、DMA 缓冲和输出接口已经配置;
  2. 主机侧协商完成:该路完成 UVC Probe/Commit,设备按协商结果初始化封装和端点。

只满足一个条件时,常见结果是:设备能枚举但无图,或者硬件已经有数据但主机无法按期望组帧。

4. 从主机协商到第一帧

4.1 两个控制面在输出硬件处汇合

业务配置决定“输出什么、经过哪些处理、送到哪一路”;UVC 控制面决定“主机需要什么宽高、帧率和格式”。两者最终在对应的输出实例处汇合。

sequenceDiagram
    autonumber
    participant B as 业务配置
    participant V as 视频处理链
    participant O as 输出接口
    participant U as UVC 控制面
    participant H as UVC 封装硬件
    participant PC as 主机

    B->>V: 配置两路源流、尺寸、格式和缓存
    V->>O: 建立两路输出接口
    PC->>U: VS Probe GET/SET
    PC->>U: VS Commit SET
    U->>H: 初始化对应封装实例和端点
    U->>O: 启动目标输出实例
    B->>V: 启动对应图像源
    H-->>PC: 持续发送 UVC 帧

这里的关键边界是:**UVC Commit 通常只负责主机侧协商和该路 UVC 输出的初始化,不应被默认理解为自动创建完整的图像处理链。**业务侧必须提前准备源流和输出接口,或者明确实现“Commit 触发业务启动”的自动化路径。

4.2 推荐的初始化顺序

设备侧可以采用下面的通用顺序:

1. 确定两路源流、分辨率、帧率和像素格式
2. 为两路分别计算 DMA 和环形缓冲地址
3. 初始化图像处理节点和输出接口
4. 等待主机完成对应路的 UVC Probe/Commit
5. 初始化 UVC Header、端点和传输参数
6. 两路都 ready 后,再按产品策略启动图像源
7. 检查首帧、帧率、时间戳和错误计数

如果两路不要求同时开始,可以分别启动;如果应用需要较小的启动偏差,建议先完成两路配置,再使用一个“启动栅栏”同时放行两路源流。

flowchart TB
    A["两路参数就绪"] --> B0["准备源流 A\n缓存/处理/输出接口"]
    A --> B1["准备源流 B\n缓存/处理/输出接口"]
    B0 --> C0["初始化 UVC A\n端点 0x81"]
    B1 --> C1["初始化 UVC B\n端点 0x82"]
    C0 --> D{"两路都 ready?"}
    C1 --> D
    D -->|否| E["继续等待"]
    D -->|是| F["同时或按策略启动两路"]
    F --> G["各自从环形缓冲取帧"]
    G --> H["添加时间信息并发送"]
    H --> I["主机按时间戳配对"]

这个启动栅栏只能减少两路的启动偏差,不能把 USB 传输变成硬件级同步。

4.3 每一路都要有明确的映射关系

双路系统最容易出现的错误不是描述符错误,而是内部映射错了一项。建议维护一张类似下表的配置表:

对象路 0路 1
图像源源 A源 B
输出实例输出 A输出 B
UVC 封装实例实例 0实例 1
Bulk IN 端点0x810x82
DMA 地址组地址组 A地址组 B
空闲/错误事件事件组 A事件组 B

内部通道号、输出实例号、封装实例号和端点号通常不是同一套编号。它们不能按数值直接推断,但在初始化配置中必须一一对应。错映射可能导致一路无图、两路串图,或者停止一路时误伤另一条路。

5. 三帧环形缓冲解决什么问题

5.1 它位于哪里

三帧环形缓冲位于设备内部的 DDR 或片内共享内存中,连接帧生产者和帧消费者:

图像处理模块写入 address0/1/2
→ 硬件按环形模式轮转
→ 输出模块读取可用帧
→ UVC 封装后写入端点 FIFO

帧离开设备后只剩 USB payload,不再携带“第 0 槽、第 1 槽、第 2 槽”的概念。

两路必须使用各自的地址组。若两路错误地使用同一组 DMA 地址,生产者会互相覆盖,典型结果是花屏、撕裂或两路画面内容异常。

5.2 三个槽位怎样保护完整帧

三帧缓冲的目标不是让两路“锁步”,而是隔离生产者和消费者的短时速率差:

  • 生产者按图像源节拍写入下一槽;
  • 消费者读取已经写完的槽;
  • 读写即将冲突时,优先跳过一帧或重复上一帧,避免覆盖正在读取的槽;
  • 三个槽位为短时抖动提供有限容差。

常见的“三帧”实现使用两个比特保存槽位索引,但只使用三个有效值。槽位数量是硬件资源约束,不代表系统拥有无限缓存。

它不是以下机制:

  • 不是两个摄像头的同步锁;
  • 不是 USB 总线调度器;
  • 不是主机端配对队列;
  • 不是任意格式重配的安全窗口。

5.3 三帧缓冲和时间戳是串联关系

可以把两者放在同一条数据链上理解:

环形缓冲:保证取到一帧相对完整的数据
        ↓
UVC 封装:为这帧添加 Header、PTS/SCR 等信息
        ↓
主机应用:根据时间信息比较和配对两路帧

没有环形缓冲,输出可能出现撕裂、重复或不稳定的帧间隔,时间戳的业务价值会下降;没有时间戳,两路即使都稳定出帧,主机也缺少共同的比较尺度。

6. PTS、SOF 和同步边界

6.1 PTS 解决的是“可比较”,不是“自动配对”

PTS(Presentation Time Stamp)通常写在 UVC Payload Header 中,用来描述帧在时间轴上的位置。设备为两路帧使用同一个时基来源后,主机才能比较它们的时间差。

USB 高速模式下,主机以微帧节拍驱动总线;设备中的 UVC 硬件可以读取或跟随该时基,将相关时间信息写入视频头部。具体 PTS 单位、位宽和回绕规则应以设备的 UVC 实现和主机解析方式为准,不能只根据“存在 PTS 字段”就假设所有平台的换算方式相同。

flowchart LR
    USB["USB 主机时基\nSOF/微帧"] --> CLK["设备 USB 控制器"]
    CLK --> A["UVC 封装实例 A\n读取共同时间基准"]
    CLK --> B["UVC 封装实例 B\n读取共同时间基准"]
    A --> PA["端点 A\n帧 + PTS"]
    B --> PB["端点 B\n帧 + PTS"]
    PA --> APP["主机应用\n时间戳比较/配对"]
    PB --> APP

图 4:共同时间基准让两路帧“可比较”,但最终配对仍由主机应用完成。

6.2 必须区分四种“同步”

说法是否能仅靠双路 UVC 保证准确含义
两路同时被枚举可以主机注册两个 UVC 功能
两路使用共同 USB 时基通常可以两个封装实例参考同一总线时基
两路在同一曝光时刻采样不能需要传感器触发、共享时钟或硬件同步
两路帧能在主机配成一对需要应用逻辑使用时间戳、帧队列和超时窗口

因此,本文所说的“同步”主要是可比较、可配对的时间同步,不是曝光级同步。

6.3 一个最小的主机配对算法

主机端至少维护两条队列:

queue_a:{frame_a, timestamp_a}
queue_b:{frame_b, timestamp_b}

当两条队列都有数据时:

  1. 比较队首帧的时间戳差值;
  2. 差值在允许窗口内,组成一对并同时消费;
  3. 差值过大,丢弃较早的帧,或等待另一条队列;
  4. 等待超过超时窗口仍未匹配,记录缺帧并继续运行;
  5. 正确处理时间戳回绕,不直接比较未经处理的裸计数值。

如果一路是 30 fps、另一路是 9 fps,应以 9 fps 的帧作为配对节拍。高帧率路为低帧率路提供最近邻候选,但多出来的帧不应被误判为新的同步事件。

7. 格式、帧长和带宽约束

7.1 不要把所有“长度”当成同一个量

双路视频中至少有四类容易混淆的参数:

参数含义不能替代什么
逻辑宽高UVC 协商和应用看到的尺寸不能替代 DMA 行长
单帧有效字节数一帧像素数据的实际大小不能替代 USB 事务大小
事务/传输大小一次 USB 搬运的数据量不能修复错误的帧几何
有效数据计数输出模块认为当前帧有多少有效数据不能简单等同于分辨率乘法

不同格式的单帧字节数不同。Y16、Y8、YUV422、NV12 等格式还可能使用不同的行布局和有效数据计数。特别是切换分辨率或像素格式时,不能只修改 UVC 描述符而保留旧的 DMA 和输出参数。

如果帧几何、有效数据计数和封装参数不一致,主机可能表现为:

  • 设备能枚举,但图像只有局部区域;
  • 每帧在固定错误位置提前结束;
  • 出现 ERR、EOF 或短帧;
  • 两路画面偶发串行或撕裂。

7.2 双路共享总线,必须做总带宽预算

两个端点逻辑独立,但仍争用同一条 USB 链路。可以先用下面的公式估算:

总需求 ≈ Σ(单帧有效字节数 × 帧率)
       + UVC Header
       + USB 协议开销
       + 设备和主机缓冲余量

不要只计算像素数量。应按实际格式计算每路单帧大小,并通过抓包和板测确认可持续帧率。

8. 启停、重配和溢出必须按路处理

8.1 独立启停

一路停止时,不应默认复位整个 USB 控制器。推荐的边界是:

停止目标路的帧发送
→ 等待目标路进入 idle
→ 释放目标路的输出状态
→ 停止或反初始化目标路的图像链
→ 保留另一条路继续运行

只修改软件状态而不等待硬件 idle,可能留下正在写 FIFO 或读取 DMA 槽的活动;反过来,清空公共 USB 状态又可能误伤另一条路。

8.2 分辨率和格式重配

如果改变了 DMA 帧长、行长、缓冲地址、像素格式或输出几何,通常需要:

停止目标路
→ 等待帧边界或 idle
→ 重新计算 DMA 和环形缓冲布局
→ 重建目标路的处理和输出参数
→ 重新初始化 UVC 封装和端点
→ 重新协商并启动

某些芯片支持在不停流的情况下动态修改裁剪或变倍参数,但这是硬件明确支持的特例,不能推广为所有格式和分辨率都可以在线修改。

8.3 溢出处理

溢出应按封装实例或端点分别统计。排查顺序建议是:

  • 只有一路溢出:检查该路格式、帧率、帧长和主机消费速度;
  • 两路同时溢出:优先检查总线预算、USB 时钟和端点调度;
  • 出现丢帧:检查时间戳队列是否能处理缺口;
  • 事件只被记录没有恢复闭环:不要把日志和回调描述成“自动重启”。

硬件可以选择在带宽不足时丢弃完整帧,以避免 FIFO 卡死。丢帧会造成时间戳序列出现缺口,但不等于两路时间基失效;主机应用仍应通过超时和丢弃策略继续运行。

9. 推荐验证闭环

“系统管理器里出现两个摄像头”不能作为完整验收标准。建议按以下顺序验证:

  1. 枚举:确认一个 USB 设备下出现两组 VC/VS、两个摄像头条目和两个数据端点。
  2. 单路 A:确认完整帧、宽高、格式、帧率和无持续溢出。
  3. 单路 B:使用同样指标确认第二路源流和端点映射正确。
  4. 双路同时运行:记录两路帧率、时间戳差值、短帧、错误和溢出计数。
  5. 隔离停止:停止一路,确认另一条路仍能连续出帧。
  6. 单路重配:只改变一路格式或分辨率,确认另一条路不受影响。
  7. 长时间运行:反复插拔、协商、停止和启动,确认描述符、端点、缓存和错误状态没有串路。

最终要验证的是下面这条闭环:

描述符拆分功能
→ 两条内部视频链分别产生完整帧
→ 两个输出实例分别封装并发送
→ 环形缓冲吸收单路抖动
→ 时间戳建立共同参照
→ 主机应用按窗口配对并处理缺帧

10. 三件最容易混淆的事

10.1 设备名称和摄像头名称

设备管理器中的产品名是设备级名称;摄像头列表中的名称是功能级名称。一个物理设备同时出现在设备树和两个摄像头条目下是正常现象。

10.2 通道号和端点号

内部视频通道、输出实例、封装实例、DMA 地址组和 USB 端点属于不同编号空间。不能因为某一路编号更小,就推断它一定对应某个端点。应以初始化映射表和描述符为准。

10.3 “同步”和“同时采样”

本文的同步是:两路帧带有可比较的时间信息,主机可以按窗口配对。它不自动保证两个传感器在同一个曝光时刻采样。曝光级同步需要额外的传感器触发、共享时钟或硬件同步设计。

11. 一句话总结

一台嵌入式设备同时输出两路 USB 视频,需要把四件事接起来:IAD 把一个设备声明成两个 UVC 功能;两条内部视频链分别产生和发送两路帧;环形缓冲保证单路帧的完整性和稳定性;时间戳与主机应用完成跨路比较和配对。 IAD 解决“主机看见谁”,视频链解决“设备送出什么”,环形缓冲解决“帧是否完整”,时间戳和应用逻辑解决“哪两帧应该放在一起”。