16、USB Vendor Class与自定义协议
说实话,USB 标准里那几大类——HID、Mass Storage、CDC、Audio——覆盖了绝大多数消费电子场景。但你要是做工业设备、仪器仪表、或者跟 FPGA 通信,这些标准类就不够用了。为什么?因为标准类定义死了数据的格式和含义,你没法塞自己的私有协议进去。
这时候就该 Vendor Specific Class 登场了。说白了,就是 USB 规范给你留了个后门:你声明自己是厂商自定义设备,然后想怎么传数据就怎么传。我最早接触这个,是在一个用 Android 平板控制 FPGA 数据采集的项目里。标准 HID 传不了大数据,CDC 又太啰嗦,最后选了 Vendor Class + 批量端点,效果出奇的好。
16.1 Vendor Specific Class 原理
USB 设备描述符里有个 bDeviceClass 字段。标准设备填 0x00(在接口描述符里定义类),或者填 0x02(CDC)、0x03(HID)这些。Vendor Class 填什么?填 0xFF。
0xFF 就是厂商自定义类的标志。告诉主机:别拿标准驱动来套我,我自己写驱动。Android 这边,你需要在 usb_device_filter.xml 里匹配 VID 和 PID,然后通过 UsbDeviceConnection 直接操作。
核心要点:Vendor Class 设备不需要遵循任何标准协议格式。控制传输、批量传输、中断传输的数据内容完全由你定义。代价是——主机端没有现成驱动,你得自己写。
我个人习惯把 Vendor Class 设备描述成「一张白纸」。你想画什么画都行,但画笔得自己带。Android 的 UsbManager 就是你的画笔。
16.2 自定义请求:Setup Packet 发送与解析
控制传输是 USB 的「命令通道」。标准请求(比如获取描述符、设置地址)由 USB 核心处理。但 Vendor Class 允许你发 自定义请求——也就是自己定义 bmRequestType、bRequest、wValue、wIndex、wLength 这五个字段的含义。
16.2.1 Setup Packet 结构
一个控制传输的 Setup 包固定 8 字节:
| 偏移 | 字段 | 说明 |
|---|---|---|
| 0 | bmRequestType | 方向(主机→设备/设备→主机)+ 类型(标准/类/厂商)+ 接收者(设备/接口/端点) |
| 1 | bRequest | 请求号。标准请求用 0~11,厂商自定义随便用 |
| 2-3 | wValue | 16 位参数,随你定义 |
| 4-5 | wIndex | 16 位参数,通常用于指定接口或端点 |
| 6-7 | wLength | 数据阶段长度(如果有的话) |
举个例子。我想让 FPGA 设备复位某个寄存器,可以这样定义:
bmRequestType= 0x40(主机→设备,厂商类型,设备接收)bRequest= 0x01(我自定义的「复位寄存器」命令)wValue= 寄存器地址wIndex= 0(保留)wLength= 0(无数据阶段)
16.2.2 Android 端发送自定义请求
Android 的 UsbDeviceConnection 提供了 controlTransfer() 方法。签名如下:
int controlTransfer(int requestType, int request, int value,
int index, byte[] buffer, int length, int timeout);
对应到上面的例子:
UsbDeviceConnection conn = ...;
int regAddr = 0x1234;
int result = conn.controlTransfer(
0x40, // bmRequestType: 主机→设备, 厂商, 设备
0x01, // bRequest: 复位寄存器
regAddr, // wValue: 寄存器地址
0, // wIndex: 保留
null, // 无数据
0, // 数据长度0
100 // 超时100ms
);
if (result < 0) {
// 传输失败,检查USB线或设备状态
}
避坑指南:我曾经在 controlTransfer 的 timeout 参数上吃过亏。设太短(比如 10ms),设备还没准备好就超时了。设太长(比如 5000ms),UI 线程卡死。建议 100~200ms,配合异步线程使用。
16.2.3 设备端解析(以 FPGA 为例)
FPGA 端的 USB 控制器(比如 Cypress FX3、FTDI FT600)收到 Setup 包后,会解析 bmRequestType 和 bRequest。如果是厂商请求,就进入你的自定义状态机。伪代码思路:
// FPGA Verilog 伪代码
always @(posedge clk) begin
if (setup_packet_valid) begin
if (bmRequestType == 8'h40 && bRequest == 8'h01) begin
// 复位寄存器
reg_file[wValue] <= 32'h0;
ack_handshake <= 1;
end
end
end
嗯,这里要注意:FPGA 端必须正确回复握手包(ACK),否则主机侧会一直重试直到超时。
16.3 批量端点自定义协议设计
控制传输适合小数据量的命令交互。真要传几百 KB 甚至几 MB 的数据,得靠 批量端点。批量传输的特点是:可靠(有 CRC 和重传),但延迟不确定(USB 总线空闲时才传)。
设计自定义协议时,我建议遵循一个简单的 帧格式:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 字节 | 帧头标识 | 固定值 0xAA55,用于同步 |
| 2 | 2 字节 | 数据长度 | 有效载荷长度(不含头) |
| 4 | 1 字节 | 命令字 | 0x01=读, 0x02=写, 0x03=配置等 |
| 5 | 1 字节 | 校验方式 | 0x00=无校验, 0x01=CRC8 |
| 6 | N 字节 | 有效载荷 | 实际数据 |
| 6+N | 1 或 2 字节 | 校验值 | 根据校验方式决定 |
为什么加帧头标识?因为批量传输是流式的,没有消息边界。万一丢了一个包,后续数据就全错位了。0xAA55 可以帮助接收方重新同步。
重要:批量端点的最大包大小取决于 USB 速度。全速是 64 字节,高速是 512 字节,超高速是 1024 字节。设计帧时,确保一帧不超过最大包大小,否则需要拆包。
16.3.1 Android 端批量读写
Android 的批量读写接口很简单:
UsbEndpoint bulkOut = ...; // 批量输出端点
UsbEndpoint bulkIn = ...; // 批量输入端点
// 发送数据
byte[] txData = buildFrame(0x01, payload); // 组装帧
int sent = conn.bulkTransfer(bulkOut, txData, txData.length, 100);
// 接收数据
byte[] rxBuf = new byte[512];
int received = conn.bulkTransfer(bulkIn, rxBuf, rxBuf.length, 100);
这里有个坑:bulkTransfer() 返回的是实际传输的字节数。如果返回 -1,说明传输失败。我曾经遇到一个情况:设备端批量端点没准备好,主机一直返回 -1,查了半天发现是 FPGA 固件里端点使能位没置起来。
16.4 实战:与 FPGA/自定义硬件通信
好了,理论说完了。咱们来一个完整的实战案例。假设你有一个 FPGA 开发板,通过 USB 连接到 Android 平板。FPGA 内部实现了 Vendor Class 设备,包含一个控制端点(0x00)和一对批量端点(0x01 输出,0x82 输入)。
16.4.1 整体流程
下面这张图展示了通信的完整流程:
16.4.2 关键代码片段
Android 端核心代码(Kotlin 风格):
class UsbFpgaCommunicator(private val device: UsbDevice, private val conn: UsbDeviceConnection) {
private val bulkOut: UsbEndpoint
private val bulkIn: UsbEndpoint
init {
val iface = device.getInterface(0)
bulkOut = iface.getEndpoint(0) // 批量输出
bulkIn = iface.getEndpoint(1) // 批量输入
conn.claimInterface(iface, true)
}
// 发送自定义控制请求:复位 FPGA 内部计数器
fun resetCounter(): Boolean {
val result = conn.controlTransfer(0x40, 0x02, 0x0000, 0, null, 0, 100)
return result >= 0
}
// 发送批量数据帧
fun sendDataFrame(cmd: Byte, payload: ByteArray): Boolean {
val frame = buildFrame(cmd, payload)
val sent = conn.bulkTransfer(bulkOut, frame, frame.size, 200)
return sent == frame.size
}
// 接收批量数据帧
fun receiveDataFrame(): ByteArray? {
val buf = ByteArray(512)
val received = conn.bulkTransfer(bulkIn, buf, buf.size, 200)
if (received > 0) {
return buf.copyOf(received)
}
return null
}
private fun buildFrame(cmd: Byte, payload: ByteArray): ByteArray {
val len = payload.size
val frame = ByteArray(6 + len + 1) // 头6字节 + 数据 + CRC8
frame[0] = 0xAA.toByte()
frame[1] = 0x55.toByte()
frame[2] = (len and 0xFF).toByte()
frame[3] = (len shr 8).toByte()
frame[4] = cmd
frame[5] = 0x01 // CRC8 校验
System.arraycopy(payload, 0, frame, 6, len)
frame[6 + len] = calculateCRC8(payload)
return frame
}
}
个人经验:FPGA 端的批量端点处理,一定要用双缓冲(double buffering)。否则主机发得快了,FPGA 还没处理完上一包,就会丢数据。我踩过这个坑,后来在 FX3 固件里开了 AUTO DMA 通道,问题解决。
16.4.3 调试技巧
调试自定义协议,最痛苦的是「不知道数据传没传对」。我的三板斧:
- 逻辑分析仪抓 USB 总线——比如 Saleae 配合 USB 解码插件,直接看 Setup 包和批量数据内容。
- Android 端打印原始字节——把收发的 byte[] 转成 Hex 字符串 log 出来,对照协议文档逐字节检查。
- FPGA 端回环测试——让 FPGA 把收到的数据原样返回,Android 端对比发送和接收是否一致。
我记得有一次,FPGA 返回的数据总是少两个字节。查了半天,发现是帧头解析逻辑里把长度字段的大小端搞反了。这种问题,用逻辑分析仪一看就明白。
16.5 小结
Vendor Class 和自定义协议,是 Android USB 开发里最灵活、也最需要细心的地方。你想想看,没有标准约束,一切靠自己定义——帧格式、命令字、校验方式、超时策略。好处是你可以针对硬件特性做极致优化,坏处是调试起来确实费头发。
但话说回来,一旦你把这套框架搭好,后续跟 FPGA、DSP、MCU 通信就都是套路了。控制传输发命令,批量传输传数据,帧头同步加校验——这套组合拳,我用了好几年,稳得很。
核心总结:
- Vendor Class = bDeviceClass 0xFF,自己定义一切
- 控制传输用于命令,批量传输用于数据
- 自定义帧格式必须包含同步头、长度、命令、校验
- 调试三板斧:逻辑分析仪、Hex 日志、回环测试
公众号:蓝海资料掘金营,微信deep3321