概述:主机能够枚举一颗从未见过的芯片,并非因为主机预先识别了该芯片,而是因为设备按 USB 规范用描述符描述自身。描述符说明设备的身份与接口数量,Probe/Commit 协商确定输出格式。

1. 基础:协议分层、双方角色与通信方式

要理解这套过程,先分清两层规范,它们叠加在一起,职责不同:

层规范负责内容
总线层USB(Universal Serial Bus,通用串行总线)电气连接、寻址、数据包的收发、四种传输类型
设备类层UVC(USB Video Class,USB 视频类)摄像头的描述符格式、控制请求、数据格式

UVC 建立在 USB 之上。USB 只负责把字节送到某个端点,不关心这些字节承载的是视频还是其他内容;UVC 才规定摄像头的描述符格式与可用的控制请求。

双方的角色分工是理解后续流程的前提:

  • 主机(PC 上的操作系统与驱动):发起所有请求、为设备分配地址、决定加载哪个驱动、决定取数据的时机。
  • 设备(MCU 加摄像头固件):只负责应答。设备不能主动向主机发送数据,视频数据同样由主机发起读取:主机查询端点是否有数据,设备才返回数据。

设备侧要能完成应答,以下三项都是必需的:

前提作用
USB 设备控制器(含 PHY 物理层)电气层收发,D+ 上拉,端点 FIFO
设备协议栈解析标准请求,管理地址、端点与状态机
一份自描述的描述符表设备身份的唯一信息源

USB 定义了四种传输类型,本文涉及前三种,第四种是视频流的可替代方案:

传输类型用在哪特点
控制传输(Control),固定使用 EP0枚举、协商、读状态每条请求都保证送达并回执
批量传输(Bulk)视频数据不丢包,但带宽与时延不保证
中断传输(Interrupt)UVC 的事件通知周期轮询,延迟有上限
等时传输(Isochronous)视频数据的另一种选择保带宽与时延,出错不重传

端点(endpoint)是设备内部的数据出入口,每个端点有地址和方向。主机的每一次通信都发往设备上的某个端点,不针对设备的某个功能。枚举与协商都经由 0 号端点(EP0),视频数据使用独立的端点。

设备侧只要按同样格式应答描述符,更换芯片后主机同样能够识别。以下从设备插入开始,依次描述这八个步骤。

2. 枚举流程:关键信息集中在第 5 步

设备插入后,主机会按固定顺序执行八个步骤,整体分为两段:前段识别设备并分配地址,后段读回全部能力并登记进系统。

flowchart TB
    subgraph A["前半段:先识别设备,再分配地址"]
        direction LR
        R["主机复位
+ 速度协商"]:::host --> S2["GET_DESCRIPTOR
DEVICE 8 字节"]:::req S2 --> S3["GET_DESCRIPTOR
DEVICE 18 字节"]:::req S3 --> S4["SET_ADDRESS
分配地址"]:::host end subgraph B["后半段:读回全部能力,登记为设备"] direction LR S5["GET_DESCRIPTOR
CONFIGURATION"]:::key --> S6["GET_DESCRIPTOR
STRING"]:::req S6 --> S7["SET_CONFIGURATION
端点使能"]:::host S7 --> S8["按接口类代码
加载驱动并注册设备"]:::host end S4 ==> S5 classDef host fill:#cce5ff,stroke:#0d6efd,color:#084298; classDef req fill:#d4edda,stroke:#28a745,color:#155724; classDef key fill:#fff3cd,stroke:#ffc107,color:#856404;

前两步为什么要分两次读取设备描述符?因为主机尚不知道该设备的端点能够接收多大的数据包,首次只读取 8 字节以取得 bMaxPacketSize0,再读取完整的 18 字节。

这八个步骤中,主机与设备各自的动作:

步主机侧设备侧
1检测到接入,复位设备,协商是全速还是高速接受复位
2读取 8 字节设备描述符返回设备描述符的前 8 字节
3读取完整 18 字节返回完整设备描述符,含 VID / PID / 配置数量
4分配一个新地址记录地址,此后仅以该地址应答
5读取配置描述符集合返回全部能力,即第 3 节所列结构
6读取字符串描述符返回厂商 / 产品 / 序列号等文本
7选定配置按配置内容使能各端点
8按接口类代码加载驱动,将设备注册进系统此后等待主机协商输出格式

第 5 步是全部信息的来源。 一次 GET_DESCRIPTOR(CONFIGURATION) 取回整个配置描述符集合(configuration descriptor set),按固定顺序排列:

Configuration
├── IAD(Interface Association Descriptor,接口关联描述符)
├── VC 接口 + 一串 CS_INTERFACE 描述符 + 中断端点
└── VS 接口 + 若干 format 描述符 + 若干 frame 描述符 + 数据端点

主机侧看到的设备能力,全部来自这一次读取的字节。这批字节的结构是固定的,下文以 UVC 摄像头为例说明。

3. UVC 的描述符:命名的三个层次与 IAD 关联

UVC 设备的标准结构是一对接口:一个 VideoControl(VC,视频控制接口,负责控制)加一个 VideoStreaming(VS,视频流接口,负责数据)。接口类代码(bInterfaceClass)固定为 0x0E,子类 0x01 为 VC、0x02 为 VS。主机读到 0x0E 即判定这是摄像头,加载系统自带的 UVC 驱动。

一对 VC 与 VS 构成一个功能(function),由 IAD 将两个接口关联并声明为一个整体,否则系统会将它们视为两个互不相关的设备。当一台设备要提供多路视频时,就在一个 configuration 下放置多组 IAD + VC + VS。

由此形成命名的三个层次。左侧是设备声明的结构,右侧是主机据此建立的结构:

flowchart LR
    subgraph DESC["设备声明的结构(描述符中的三层)"]
        direction TB
        DEV["设备级
iProduct = 2
Img+Data Camera"]:::dev F1["功能 1
IAD iFunction = 6
Img Camera"]:::fn F2["功能 2
IAD iFunction = 7
Data Camera"]:::fn I1["接口 0 + 1
VC 与 VS
iInterface = 6"]:::itf I2["接口 2 + 3
VC 与 VS
iInterface = 7"]:::itf DEV --> F1 DEV --> F2 F1 --> I1 F2 --> I2 end subgraph VIEW["主机侧视图"] direction TB DM["设备树
Img+Data Camera"]:::host CAM["摄像头列表
Img Camera
Data Camera"]:::host end DEV -.->|显示设备名| DM I1 -.->|各注册为一个摄像头| CAM I2 -.-> CAM classDef dev fill:#e2d9f3,stroke:#6f42c1,color:#3b2a6b; classDef fn fill:#fff3cd,stroke:#ffc107,color:#856404; classDef itf fill:#d4edda,stroke:#28a745,color:#155724; classDef host fill:#cce5ff,stroke:#0d6efd,color:#084298;

固件中的字符串表就是这三层的原始形式,节点中的下标数字对应这张表的行号:

static const char *ustring[] =
{
    "Language",           /* 0  语言 ID            */
    "<Vendor> Co.,Ltd",   /* 1  iManufacturer      */
    "Img+Data Camera",    /* 2  iProduct     ← 设备级 */
    serialId,             /* 3  iSerialNumber      */
    "Configuration",      /* 4  iConfiguration     */
    "Interface",          /* 5                     */
    "Img Camera",         /* 6  功能 1 的 iFunction / iInterface */
    "Data Camera"         /* 7  功能 2 的 iFunction / iInterface */
};

设备描述符中 iProduct = 2,第一组 IAD/VC/VS 的 iFunction 与 iInterface 为 6,第二组为 7。这几个字段本身都是字符串描述符的索引号。

将这份字节按层级展开,就是主机在第 5 步读取到的结构:

Configuration 1
├─ IAD #1                                    iFunction = 6 → "Img Camera"
│  ├─ Interface 0(VC)   class 0x0E / subclass 0x01
│  │  ├─ CS_INTERFACE : Header
│  │  ├─ CS_INTERFACE : Input Terminal   (相机终端)
│  │  ├─ CS_INTERFACE : Processing Unit
│  │  ├─ CS_INTERFACE : Output Terminal
│  │  └─ Endpoint 0x87   中断传输 IN
│  └─ Interface 1(VS)   class 0x0E / subclass 0x02   iInterface = 6
│     ├─ VS Input Header
│     ├─ Format 1..6     未压缩(NV12 / YUY2 / Y16 / Y8 / RGB888 / RGB565)
│     │  └─ 每种格式包含若干 Frame 描述符
│     ├─ Format 7        MJPEG
│     │  └─ 若干 Frame 描述符
│     └─ Endpoint 0x81   批量传输 IN
└─ IAD #2                                    iFunction = 7 → "Data Camera"
   ├─ Interface 2(VC)   class 0x0E / subclass 0x01
   │  ├─ CS_INTERFACE : Header / Terminal / Unit …
   │  └─ Endpoint 0x88   中断传输 IN
   └─ Interface 3(VS)   class 0x0E / subclass 0x02   iInterface = 7
      ├─ VS Input Header
      ├─ Format 1        未压缩
      │  └─ 6 组 Frame:每组 = 数据帧 + 高度多 2 行的信息帧
      └─ Endpoint 0x82   批量传输 IN

CS_INTERFACE 是类特定接口描述符(class-specific interface descriptor),VC 内部的终端与处理单元都由它定义。

接口编号在固件中写作 USB_DYNAMIC,由协议栈在枚举时分配,因此上文的 0/1/2/3 是典型结果而非硬编码值。

主机据此建立的设备树,同一台设备会同时出现在两个位置:

设备管理器
├─ 通用串行总线控制器
│  └─ USB 复合设备
│     └─ <Vendor> Co.,Ltd  Img+Data Camera     ← iProduct(设备级,只有一个)
└─ 摄像头(Imaging Devices)
   ├─ Img Camera                                ← 功能 1 的 iFunction
   └─ Data Camera                               ← 功能 2 的 iFunction

两个位置描述的是同一份数据,区别只在于主机把它放在设备树的哪一层。

两个功能承载的数据形态不同,这是分成两路的原因:

功能 1(Img Camera)功能 2(Data Camera)
内容图像帧结构化数据帧
格式数6 种未压缩 + 1 种压缩(MJPEG)1 种未压缩
帧组织每种格式若干组独立的图像帧参数成对:数据帧 + 高度多 2 行的信息帧
尺寸图像分辨率6 档:640×512、384×288、256×192、160×120、320×240、512×384

第二路的 6 档尺寸各自成对,信息帧始终比同档的数据帧多 2 行,对应「传感器矩阵 + 元信息(帧头 / 量程 / 时间戳)」这种数据的常见组织方式。

4. 枚举完成识别,能力由 Probe/Commit 协商确定

描述符已向主机提供格式清单,但主机尚未选定使用其中哪一项,帧率也未确定。这一步要用 UVC 的类特定控制请求(class-specific request)再协商一轮,全程同样经由 EP0。这种请求由 UVC 定义,只是借用了 USB 的控制传输:

sequenceDiagram
    autonumber
    participant WIN as 主机 UVC 驱动
    participant EP0 as USB EP0
    participant CLS as 设备侧 UVC Class 处理
    participant HW as 设备侧硬件输出通路
    WIN->>EP0: GET_CUR(VS_PROBE) 读取候选格式与帧参数
    EP0->>CLS: 返回该格式支持的分辨率 / 帧率区间
    WIN->>EP0: SET_CUR(VS_PROBE) 探测
    EP0->>CLS: 校验 payload 能否承载
    WIN->>EP0: SET_CUR(VS_COMMIT) 确定
    EP0->>CLS: commit 回调
    CLS->>HW: 以 bFormatIndex / bFrameIndex / dwFrameInterval 初始化输出通路
    HW-->>WIN: 数据端点开始传输数据

VS_PROBE 用于探测,VS_COMMIT 用于确定。关键在于最后三步:Commit 之前硬件通路尚未启动。回调取得 bFormatIndex、bFrameIndex、dwFrameInterval 三个下标,组成一份帧参数,才去初始化封装硬件与输出接口。因此相机已插入但未打开预览时,设备不会向数据端点发送像素。

协商完成后即与 EP0 无关:像素经由独立的 Bulk IN 或 Isochronous IN 端点传输,由主机按期读取。此后主机侧负责接收,设备侧按协商结果输出。

5. 三个最容易混淆的地方

以下三处名称相近,实际调试时最容易误判:

  1. 端点号 ≠ 内部通道号。0x81、0x82 是 USB 端点地址,设备固件内部可能另有「通道 1/2/3」编号,两套编号之间没有数值对应关系,仅表示固件在配置中把某路内部通道映射到了某个端点。

  2. 设备名不是摄像头名称。设备树读取 iProduct,摄像头列表读取各 function 的 iFunction,两者本就是不同的字段,因此同一台设备会出现在两个位置。

  3. IAD 不可省略。缺少 IAD 时,VC 与 VS 会被视为两个独立接口,Windows 下常表现为「识别到 USB Video 设备但无法打开」,因为 UVC 驱动需要依靠配对关系才能确定控制接口与数据接口属于同一路视频。

一句话:USB 的即插即用建立在设备按规范描述自身的基础上。主机发起请求、设备应答,这一交互贯穿三个阶段:枚举时交换描述符确定身份,协商时用 Probe/Commit 确定格式,开始传输后设备向端点输出数据。