一个叫“colibri”的库,把我从USB开发的地狱里捞了出来。
事情是这样的,前阵子我做了一个基于ESP32-S3的客制化键盘,硬件调试全部通过,结果卡在固件上。我用的官方USB协议栈,功能倒是全,但配置起来极其繁琐,为了一个HID设备,我翻了几百页文档,写了接近两千行初始化代码,最后烧进去还时不时枚举失败。后来一个玩RP2040的朋友跟我说,你换colibri试试吧,这名字听着挺小众,我一开始也没当回事,实在被折磨得不行了才试了一下,结果就是这次尝试,让我整个项目的开发效率至少翻了一倍。
这库全称叫Colibri USB Device Stack,主打轻量化和易用性,专为嵌入式场景设计。如果你也正在被USB协议栈的复杂度折磨,或者你正在做小批量产品、DIY外设、创客项目,想用尽量少的代码实现稳定的USB通信,那这篇文章就是写给你看的。我会从架构设计的思路、核心API的解析、到具体移植步骤和踩坑记录,完整拆一遍。
1. 内容整体设计与思路拆解
1.1 为什么我最终选了colibri而不是更主流的方案
先交代一下背景。做嵌入式USB开发,目前主流的方案无非是那么几种:芯片原厂出的SDK自带的USB库,比如ST的USB Device Library、乐鑫的ESP-IDF USB Stack;再就是TinyUSB这种社区驱动、跨平台的开源方案,还有一个相对小众但常被用在商业产品里的,就是这个Colibri。
原厂库最大的问题是“绑死”。ESP-IDF的USB栈跟它的系统深度耦合,你用的RTOS、事件循环、内存管理全都得顺着它的习惯来。ST的库更离谱,我印象里它那套回调机制从F1到H7几乎每个系列都不一样,代码拷过去基本是重写。这种库适合你做原厂方案的快速原型,但你要是想跨平台复用或者搞清楚它内部的工作机制,那难度直线上升。
TinyUSB确实是好库,生态成熟,社区活跃,MIT协议,有大量开源键盘、鼠标项目在用。这也是我最初的首选。但用着用着我就发现一个问题,TinyUSB为了覆盖尽可能多的芯片平台和USB类协议,它的代码抽象层次特别厚。在资源充足的场景下这完全没问题,但在一些资源比较紧张的单片机上,它的ROM占用和内存占用会让你很难受。我实测过在一颗192KB RAM的MCU上,仅仅启用TinyUSB的HID和CDC功能,堆区就被吃掉了将近8KB。
Colibri的定位正好卡在这两者中间。它的代码量精简得厉害,核心部分就几个源文件,整个库移植一遍大概也就三千行左右。但它不是那种“玩具级”的精简,它支持完整的控制传输、批量传输、中断传输和同步传输,HID、CDC、MSC、Audio等常见类驱动都有。更关键的是,它在API设计上刻意做了很多简化,让我这种喜欢直接控制寄存器、不想被OS抽象层束缚的人用起来非常顺手。
1.2 它的核心设计思路:小,但完整
Colibri这个库的设计哲学,我总结下来就两句话:核心只做USB协议必须做的事,其余全部交给用户;类驱动和平台层彻底分离,换芯片不换逻辑。
我们知道一个完整的USB设备,逻辑上可以拆成三层。最底层是收发器加物理接口,就是PHY和D+/D-两根线的时序控制。中间层是协议层,负责解析Setup包、维护设备状态、处理标准请求,比如Get_Descriptor、Set_Address这些。最上层是类驱动层,让设备表现出“键盘”“串口”或者“U盘”的行为。
很多大而全的USB协议栈会把这三层揉在一起,对外提供一个统一的API。你觉得用起来挺方便,但内部一旦出问题,根本无从下手排查。Colibri不是这样,它的核心就是中间那层协议引擎,负责完成所有标准请求的自动应答,把复杂的控制传输细节消化掉。然后它对外暴露几个简单的回调函数,你注册一个设备描述符,剩下的事情库帮你处理。
这么做的好处是显而易见的。第一,你不需要理解USB控制传输那套复杂的SETUP/IN/OUT状态机,也能写出能用的设备。第二,因为代码分层清晰,你可以只保留自己需要的部分。比如我这个键盘项目只需要HID,我完全可以把CDC、MSC这些模块的源文件从编译列表里拿掉,减少ROM占用。
第三点是我后来才发现的,那就是调试太方便了。之前调USB,问题出在PHY层还是协议层,界线很模糊,你得靠逻辑分析仪一点一点抓数据。用了Colibri之后,协议层是经过验证的,我只需要关心自己的描述符和端点配置对不对,排查范围至少缩小了一半。
2. 核心细节解析与实操要点
2.1 描述符体系:一次搞懂USB设备的“身份证”
做USB开发,无论如何都绕不开描述符。你可以把描述符理解为USB设备的“身份证”加“说明书”,主机通过读取这一串结构化数据,才知道你这个设备是什么、能做什么、怎么通信。
Colibri对描述符的处理是我见过最清爽的之一。它没有搞那种复杂的初始化API,而是直接让用户准备一段符合USB规范的内存结构,然后通过一个回调函数告诉库“我的描述符在这里”。
以我的键盘项目为例,设备描述符和配置描述符我是这样定义的:
static const uint8_t device_descriptor[] = { 0x12, // bLength 0x01, // bDescriptorType (Device) 0x00, 0x02, // bcdUSB 2.00 0x00, // bDeviceClass 0x00, // bDeviceSubClass 0x00, // bDeviceProtocol 0x40, // bMaxPacketSize0 (64 bytes) 0x01, 0x12, // idVendor 0x03, 0x00, // idProduct 0x00, 0x01, // bcdDevice 0x01, // iManufacturer 0x02, // iProduct 0x03, // iSerialNumber 0x01 // bNumConfigurations };这段定义里,我要重点提醒两个地方。第一个是bMaxPacketSize0,这个值不是随便填的,它表示端点0的最大包长,低速设备是8字节,全速设备可以是8、16、32、64。你填了64,主机就会按64字节的最大包来跟你做控制传输。第二个是idVendor,也就是常说的VID,这个值是向USB-IF组织购买的,正规出货的产品必须有,DIY玩家的习惯是先用一个占位符,比如常见的0x1234或者0xFFFF,这是没问题的,但如果你打算量产,这里一定要认真规划。
配置描述符要稍微复杂一些,因为它是由多个描述符拼接而成的。一个完整的配置描述符集合,至少包含一个配置描述符、一个接口描述符、一个端点描述符,如果你做了HID设备,中间还要嵌入HID描述符。我这里是键盘,所以还加了一个Report Descriptor:
static const uint8_t config_descriptor[] = { // Config Descriptor 0x09, 0x02, 0x3B, 0x00, 0x01, 0x01, 0x00, 0x80, 0x64, // Interface Descriptor 0x09, 0x04, 0x00, 0x00, 0x01, 0x03, 0x01, 0x01, 0x00, // HID Descriptor 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0x63, 0x00, // Endpoint Descriptor 0x07, 0x05, 0x81, 0x03, 0x08, 0x00, 0x0A };我第一次干这事的时候,每个字节都是对着USB规范手册一个一个查的,效率极低。后来我的经验是,别自己去拼描述符,用USB Descriptor Tool这个软件来生成。你把接口类型、端点方向、包大小这些参数填进去,它自动帮你算出每个字节的值,省时省力还不容易出错。哪怕你是个热衷手写底层的硬核玩家,也建议先生成再手调,人脑去算位宽和偏移量,纯属浪费时间。
2.2 断点机制:理解端点与FIFO的关系
描述符定义了设备长什么样,端点则定义了数据从哪里进出。每个USB设备最多可以有16个端点,方向区分IN和OUT,实际可用的取决于芯片外设。这里有个很多新手会混淆的点,端点不是内存地址,而是“通道”的概念。比如端点1 IN,表示设备向主机发送数据的1号通道。
Colibri对端点的管理方式是透明的。你初始化的时候把端点配置好,之后发送数据只需要调用一个发送函数。我在键盘项目里的实际配置是这样:
static colibri_endpoint_t eps[] = { { .addr = 0x81, // 端点1 IN .type = COLIBRI_EPT_INT, // 中断传输 .max_packet_size = 8 } };这里的.max_packet_size字段,决定了单次中断传输最多能带多少字节。HID键盘的Report Descriptor如果定义了标准六键无冲,加上修饰键和保留位,一共是8个字节,所以这里填8就够用了。如果你的键盘支持全键无冲,报告长度可能到16或者更大,那么这里就要相应调整。
关于端点的使用,我踩过一个比较坑的细节,就是端点描述符里的bInterval字段。HID键盘这类中断传输设备,这个字段表示主机轮询设备的间隔时间,单位是毫秒。我最初按样例填了10,结果打字的时候能明显感觉到延迟,后来改成1,延迟问题立刻消失。当然间隔设得太短会增加USB总线负载,但对全速设备来说,1ms轮询一个8字节的包,总线开销小到可以忽略。
2.3 回调函数架构:库怎么把事件交还给你
Colibri的事件处理,靠的是一组注册回调函数。库本身不关心你的业务逻辑,它只负责在合适的时候调用你提前注册好的函数。这种设计有点像C语言版的观察者模式,简单直接,没有多余的抽象层。
核心的回调有四个我需要关注:设备复位、控制请求接收、端点数据传输完成、以及总线挂起/恢复。我的键盘代码里,最重要的回调是接收主机控制请求的handler:
static colibri_result_t on_control_request(colibri_control_request_t *req, uint8_t **data, uint32_t *len) { if (req->bmRequestType == 0x81 && req->bRequest == 0x06 && req->wValue == 0x2200) { // 主机在请求HID Report Descriptor *data = (uint8_t *)hid_report_descriptor; *len = sizeof(hid_report_descriptor); return COLIBRI_SUCCESS; } return COLIBRI_CONTROL_UNHANDLED; }对这个回调的理解,是学会Colibri的关键。当主机发来标准请求时,如果Colibri核心层能自己处理的,比如获取设备描述符、设置地址等,它会直接处理完,你的回调根本不会被调用。只有当请求是类相关的、厂商相关的,或者核心层搞不定的,才轮到你的回调上场。
但要注意,如果回调返回COLIBRI_CONTROL_UNHANDLED,库会直接返回STALL给主机。所以如果某个请求你明确不想支持,也可以主动STALL掉,这反而是一种节省资源的做法。比如有些主机会尝试读取字符串描述符,如果你的设备没有字符串描述符,直接在回调里STALL掉,主机就会跳过这个步骤,系统里的设备管理器会显示一串问号,但功能正常。
3. 实操过程与核心环节实现
3.1 移植前准备:确认你的平台和工具链
在动手写代码之前,有一件很重要的事必须先做:确认你的芯片平台Colibri支持不支持,以及你的RAM和ROM够不够。
我用的ESP32-S3,RAM有512KB,对于Colibri来说毫无压力。但如果你想在8位AVR上跑这个库,比如老款的Arduino Uno,那我劝你放弃,Colibri虽然精简,但控制传输的状态机需要记录多个阶段性变量,32位平台的效率会高得多。一般来说,我建议至少是Cortex-M0级别的MCU,主频几十兆以上,RAM不少于8KB,才能跑得比较舒服。
工具链这块,因为我的项目用了乐鑫的ESP-IDF环境,而Colibri是CMake组织的,所以集成很简单,在组件目录下放一个CMakeLists.txt指向源文件就行。如果你用的是STM32CubeIDE或者Keil,把几个.c文件直接加到工程里,然后把对应的头文件路径包含进去,一样能编过。这个库对外部的依赖极少,基本就是标准C库加上寄存器操作的头文件,所以跨编译器很省心。
3.2 初始化流程与注册回调
一切准备就绪,代码层面需要做的事情其实很少。Colibri的使用逻辑是:初始化底层、注册回调、配置端点,然后进入主循环。
我的键盘初始化代码是这样写的:
#include "colibri.h" #include "colibri_platform.h" static colibri_config_t config; void keyboard_usb_init(void) { // 1. 传递平台相关的底层配置 memset(&config, 0, sizeof(config)); config.device_descriptor = device_descriptor; config.config_descriptor = config_descriptor; config.string_descriptors = string_descriptors; config.control_callback = on_control_request; config.endpoints = eps; config.num_endpoints = 1; // 2. 初始化USB硬件外设 colibri_platform_init(); // 3. 把配置注册进协议栈核心 colibri_init(&config); // 4. 连接上拉电阻,正式出现在总线上 colibri_connect(); }注意这里的第4步,colibri_connect()函数干的事是使能D+引脚的1.5k电阻上拉。这是个容易被忽略的细节,USB设备枚举的前提条件是主机能检测到设备连接,而这个检测机制就依赖于D+或D-上的上拉电阻。全速设备上拉在D+,低速设备上拉在D-。有些特殊需求场景,比如你想让固件先准备一会儿再把设备暴露给主机,就可以先不调用connect,等初始化完毕再调用。
初始化完成之后,主循环里需要定期调用colibri_poll(),这个函数驱动内部状态机运行。如果用的RTOS,通常会把它放到一个独立任务的死循环里,或者用定时器中断周期性调用。
void keyboard_task(void *arg) { while (1) { colibri_poll(); vTaskDelay(pdMS_TO_TICKS(1)); } }3.3 发送键盘按键数据的完整实现
键盘应用最核心的功能,就是把按下的键发给主机。用Colibri实现这一步出奇地简单,你只需填充一个缓冲区,然后调用端点发送函数:
uint8_t key_report[8] = {0}; void keyboard_send_report(uint8_t modifier, uint8_t keycode) { key_report[0] = modifier; // 修饰键 Ctrl/Shift/Alt key_report[2] = keycode; // 按键码 colibri_ep_int_in(0x81, key_report, sizeof(key_report)); }这里有个坑我必须提一下。colibri_ep_int_in是非阻塞调用,也就是说这个函数只是把数据拷贝到端点FIFO里就返回了,真正的发送是由硬件完成的。如果你的按键事件来得太快,上一次数据还没发完,下一次又来了,会发生数据覆盖或者丢包。解决方法是利用发送完成回调:
static volatile bool report_sent = true; static void on_ep_in_complete(uint8_t ep_addr) { report_sent = true; } void keyboard_send_report_safe(uint8_t modifier, uint8_t keycode) { if (!report_sent) return; // 上一次还没发完,丢弃本次 report_sent = false; key_report[0] = modifier; key_report[2] = keycode; colibri_ep_int_in(0x81, key_report, sizeof(key_report)); }这个安全发送函数的意义在于,它保证同一时间只有一个报告在传输流程中,不会出现数据错乱。对于键盘这种对实时性要求不极端、但对数据准确性要求极高的设备,这种丢弃策略反而比排队更合理,因为键盘是按状态上报的,你在按键被按下的时候发一次,如果在上一次报告还在飞的时候又发了更新,主机可能先收到旧的再收到新的,中间这个缝隙极短,不会影响体验。但如果你丢的是“按键释放”的事件,那就麻烦了,主机可能以为按键一直被按着。
3.4 字符串描述符与语言ID的正确打开方式
字符串描述符是很多初学者会忽略的细节。USB规范要求,字符串描述符的“索引0”是语言ID列表,设备至少要支持一种语言。我见过一些人偷懒,干脆不实现字符串描述符,因为大多数情况下系统不读也能工作,但部分操作系统在枚举阶段要求必须能正常读取语言ID,否则直接把设备标记为错误。
Colibri对字符串描述符的处理很灵活。它不限制你如何存储这些字符串,你只需要提供一个字符串描述符数组的指针数组,让协议栈核心能通过索引拿到对应的数据即可。比如:
static const uint8_t lang_id[] = { 0x04, 0x03, 0x09, 0x04 }; // 英语-美国 static const uint8_t str_manufacturer[] = "MyLab"; static const uint8_t str_product[] = "Custom Keyboard"; static const uint8_t *string_descriptors[] = { lang_id, str_manufacturer, str_product };值得注意的是,字符串描述符的格式不是普通的C字符串,第一个字节是长度,第二个字节是描述符类型0x03,后面才是UTF-16LE编码的字符数据。Colibri的API底层做了封装,所以这里可以直接用普通字符串字面量赋值,但如果你以后移植到其他协议栈,千万别忘了补这个格式转换。
4. 常见问题与排查技巧实录
4.1 设备无法枚举、系统提示未知设备
这是USB开发里出现频率最高的问题,没有之一。我遇到过好几种触发场景,把它们归纳成一个排查表,你按顺序查基本能定位问题:
| 现象 | 可能原因 | 定位方式 |
|---|---|---|
| 完全没有反应,没有任何枚举事件 | D+/D-接反或上拉电阻未使能 | 示波器看D+是否有上拉电平 |
| 系统提示“未知USB设备” | 描述符结构错误,地址设置失败 | 逻辑分析仪抓控制传输的STALL包 |
| 设备能被识别但无法加载驱动 | VID/PID冲突或描述符中类信息不一致 | 查看系统设备管理器的详细描述 |
| 时好时坏,重启后才正常 | 电源去耦不良或VBUS检测脚接法错误 | 测量供电波形,检查去耦电容 |
我在用Colibri时遇到最多的是第二种情况。排查方法也很直接:逻辑分析仪接在D+和D-上,抓取枚举阶段的完整波形。如果发现主机发送Get_Descriptor之后,设备返回了STALL,那基本可以断定是描述符填充有问题。这时候对照USB规范一字节一字节查你的描述符数组,重点看bLength是否和实际长度一致、配置描述符总长度字段wTotalLength是否正确。
很多人在配置描述符的总长度字段上翻车。你这根描述符数组里所有的描述符长度加起来是0x3B,但wTotalLength里填的却是0x29,主机要读描述符时只会收到前半截,接口/端点信息全部丢失,枚举必然失败。
4.2 HID设备能枚举但数据发不出去
枚举成功只是第一步,能用起来才是真正的考验。我调试键盘固件时,遇到一个诡异现象:Windows能正确识别出键盘设备,但按任何键都没反应。
后来排查发现,问题不在发送函数,而在Report Descriptor。我最初用的报告描述符是标准的8字节键盘报告,但它的Usage Page没设置对,写成了Generic Desktop而不是Keypad,导致主机虽然接收到了数据,却不知道如何解释这些字节。
static const uint8_t hid_report_descriptor[] = { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x06, // Usage (Keyboard) 0xA1, 0x01, // Collection (Application) // ... 具体按键定义省略 0xC0 // End Collection };这里第一行的0x05, 0x01,表示Usage Page为通用桌面设备,第二行的0x09, 0x06表示Usage为键盘。如果这两行弄错,主机是认不出你设备的。Report Descriptor是HID设备最重要的数据之一,建议你直接用HID Descriptor Tool生成,不要手写。
4.3 低功耗场景下的挂起与远程唤醒
用电池供电的无线键盘需要做低功耗,USB有线设备也有类似的功耗管理需求。USB规范规定,总线持续3ms以上无活动,设备必须进入挂起状态,电流消耗降到2.5mA以下。Colibri提供了对应的回调,方便你在挂起时进入睡眠模式:
static void on_suspend(void) { // 关外设时钟、关LED、准备进入睡眠 colibri_platform_enter_sleep(); } static void on_resume(void) { // 唤醒时钟、重新初始化外设 colibri_platform_exit_sleep(); }这里要提醒一下,如果你的设备是USB总线供电的,挂起时主机可能连5V都断掉了,所以单纯睡眠没有意义。这种情况一般需要设计外部电路来维持供电或使用备用电池。但如果你的设备是自供电的,挂起回调就非常有用,我在一个基于LDO的板子上实测,挂起前电流大约12mA,进入睡眠后能压到0.3mA,对电池供电产品来说这个提升非常可观。
4.4 编译层面的坑:ROM和RAM占用如何优化
Colibri虽然轻量,但不代表你随手一编就能得到最优的二进制。如果你在做资源非常紧张的项目,有几个优化技巧值得尝试。
第一个是裁剪协议栈功能。Colibri支持通过宏定义启用或禁用某些特性,比如你不支持字符串描述符,就在编译选项里加上COLIBRI_NO_STRINGS。不需要同步传输,就关掉COLIBRI_ENABLE_SYNC_EPT。每个被关闭的特性都会直接减少对应的代码段,有时候能省下将近1KB的Flash空间。
第二个是合理利用编译器优化。嵌入式编译器通常有几个优化等级:-O0、-O1、-O2、-Os和-Ofast。我建议用-Os,即优化体积。USB协议栈这种代码,大部分情况下不是性能瓶颈,用-Os可以把代码压缩到最小。但要注意,如果你在代码里用了断言或者调试日志,-Os可能会把这些优化掉一部分,调试完记得保留必要的输出。
第三个,也是很多人容易忽略的,就是端点缓冲区大小。USB外设的端点FIFO是RAM的一部分,你定义的端点FIFO越大,剩余可用的通用RAM就越小。有些库会默认给每个端点分配512字节甚至1KB的FIFO,但你的实际负载可能只有8字节,这是极大的浪费。Colibri允许你在平台层精确定义每个端点FIFO的大小,我习惯全速中断端点配16字节就够,批量端点配64字节,能省下大量RAM。
4.5 调试工具推荐:从逻辑分析仪到USB分析仪
调试USB开发,工具选对了能事半功倍。我自己的调试装备是一个24MHz采样率的8通道逻辑分析仪,配合开源的PulseView软件,USB全速12Mbps的包能比较清晰地抓出来。对于键盘这样的中断传输设备,这个配置已经足够了。
如果你的预算宽裕一些,可以考虑USB协议分析仪,比如Beagle USB 480或者国产的一些兼容版本。协议分析仪的好处是它直接解析USB协议的裸数据包,会告诉你每个包是SETUP、IN还是OUT,甚至帮你标出PID错误、CRC错误,调试体验比逻辑分析仪好一个档次。
软件层面,我强烈推荐装一个USBPcap加Wireshark的搭档组合,Windows上可以抓USB总线数据。虽然对于全速HID设备来说,抓到的是批量传输的最终结果,不是最底层的数据包,但有时候排查驱动层的问题更实用。我曾经靠它在Windows下发现一个奇怪现象:键盘明明发送了按键报告,系统也收到了,但窗口不输入字符。最后定位到是按键码的映射表弄错了,把A键的Usage ID写成了KB_A,但实际对应的是0x04,导致驱动层面匹配不上。
5. 项目扩展与进阶应用场景
5.1 往复合设备方向升级
做完了纯键盘,自然会想加多媒体按键和鼠标功能。这时候有人会在网上搜“如何实现USB复合设备”,看到配置描述符里要填多个接口描述符,头立刻大了一圈。Colibri对这类需求没有任何额外难度,你只需要在配置描述符里多写几组接口和端点描述符,然后把报告描述符合并成一个,最后在回调里区分不同接口的请求即可。
我的键盘后来就加了音量控制和鼠标移动两个功能。实现方式是在配置描述符里定义了三个接口:标准键盘接口、消费者控制接口(Consumer Control)、以及鼠标接口。每个接口的端点都用中断IN,报告长度分别是8字节、2字节和3字节。
实际使用中我的体会是,同一个端点地址不要被多个接口共用,虽然规范允许共享端点,但不同接口的报告长度可能不同,FIFO配置容易冲突,反而给自己找麻烦。每个功能单独分配一个端点地址,虽然看起来浪费,但逻辑清晰,排查问题容易。
5.2 从全速到高速的迁移
Colibri另一个比较省心的点,是它把全速和高速的差异隐藏得比较好。你切换到支持高速USB的芯片平台时,大部分代码可以直接复用。唯一要注意的是描述符里的bcdUSB字段,以及端点描述符里的wMaxPacketSize,高速设备的批量端点最大包长是512字节,中断端点则要看报告长度。
高传输速率场景下,比如做一个USB高速数据采集器,中断传输的数据带宽可能不够用,你会需要批量传输端点。Colibri对批量传输的支持也很完善,发送大块数据时可以配合DMA,把数据描述符的地址直接指向内存缓冲区,减少CPU拷贝的开销。我第一次在高速模式下调通批量传输时,开发板连续往外发100KB数据,电脑端接收完全无压力,那一刻真的会感慨这个库的设计之简洁。
5.3 批量产时的稳定性与烧录工艺建议
键盘做过原型,最终还是要考虑量产。USB产品量产时的固件烧录与测试,有几个专门针对USB设备的注意事项。
首先是固件烧录顺序。我建议先在产线上烧录Bootloader,再做整机测试,测试通过后再烧录正式固件。这样如果某个板子测试失败,还能重新烧录,不用直接报废。Bootloader本身不需要支持USB功能,一个普通的串口下载Bootloader就够用了。
其次是产线测试程序的编写。对于HID键盘,测试工具应该能读取到设备的厂商字符串、产品字符串和序列号,然后发送一个闪烁命令让设备上的LED亮起,由工人确认设备正常。注意在测试程序里要做设备拔插检测,测试完一个设备要等它从USB总线消失后再测下一个,否则可能因为枚举信息缓存导致测错板子。
我最想强调的其实是晶振。USB全速设备的通信速率依赖于精确的时钟,主机和设备的时钟误差不能超过0.25%。我用过一批质量不错的晶振,常温下精度20ppm,看起来完全不超标,但低温环境测试时发现部分板子因为晶振起振慢导致枚举失败。后来换成有源晶振并对固件做自适应校准,稳定性才上来。所以如果你的产品可能在户外使用,晶振绝对不能省。
最后的个人经验
用了Colibri这么久,我最想分享的一点是:不要被USB协议那厚厚一叠规范吓退。USB协议的底层确实复杂,但像Colibri这样的库,已经把最繁琐的部分封装好了,你要做的只是理解几个核心概念,然后大胆地去试。
我刚开始用的时候也踩了不少坑,尤其是描述符那块,前前后后查了两天资料才弄明白。但现在回想,那些看似头疼的坑,恰恰是理解USB最宝贵的素材。如果你正在做USB相关的项目,被某个库折磨得怀疑人生,不妨换个思路、换个库试试。也许一个意外的开源项目,就能把你从各种诡异的底层问题里解放出来。