Zephyr USB设备栈概览:整体架构、核心数据结构、配置流程
好,咱们正式开始啃这块硬骨头。Zephyr的USB设备栈,说白了就是一套帮你把嵌入式设备伪装成U盘、键盘、鼠标或者复合设备的框架。我最早接触它的时候,第一反应是——这玩意儿分层怎么这么多?但用熟了你会发现,每一层都有它存在的道理。
一、整体架构:三层分工,各司其职
Zephyr的USB设备栈分三层,从上往下看:
- 应用层:你写的业务代码。比如我要做一个自定义HID设备,就在这里定义报告描述符。
- 设备核心层:负责管理USB状态、处理标准请求、调度回调函数。说白了就是USB协议栈的“大脑”。
- 控制器驱动层:直接跟硬件寄存器打交道。不同的MCU(比如STM32、NXP、Nordic)有不同的实现。
我个人习惯把这三层想象成“老板-经理-员工”的关系。应用层是老板,只管提需求;核心层是经理,负责协调;驱动层是员工,干苦力活。嗯,这个比喻虽然糙,但道理不糙。
关键点:Zephyr的USB设备栈是事件驱动的。所有数据传输、状态变化都通过回调函数通知上层。你不需要轮询,写起来很舒服。
二、核心数据结构:你得认识这几个“熟人”
在Zephyr里写USB设备,有几个结构体你绕不开。我挑最重要的三个讲:
1. usb_device_descriptor —— 设备描述符
这是USB设备的“身份证”。里面包含了VID、PID、设备版本号等信息。Zephyr把它定义在usb/class/usb_device.h里。你想想看,主机枚举设备时,第一件事就是读这个描述符。
static const struct usb_device_descriptor dev_desc = {
.bLength = sizeof(struct usb_device_descriptor),
.bDescriptorType = USB_DEVICE_DESC,
.bcdUSB = sys_cpu_to_le16(USB_2_0),
.bDeviceClass = 0,
.bDeviceSubClass = 0,
.bDeviceProtocol = 0,
.bMaxPacketSize0 = 64,
.idVendor = sys_cpu_to_le16(0x2FE3),
.idProduct = sys_cpu_to_le16(0x0001),
.bcdDevice = sys_cpu_to_le16(0x0100),
.iManufacturer = 1,
.iProduct = 2,
.iSerialNumber = 3,
.bNumConfigurations = 1,
};
小提示:VID需要去USB-IF申请,但开发阶段可以用0x2FE3(Zephyr的测试VID)。千万别在产品里用这个,否则会被USB-IF找麻烦。我曾经见过有人忘了改VID,产品送测时被退回……
2. usb_cfg_data —— 配置数据
这个结构体是Zephyr USB栈的核心。它把设备描述符、配置描述符、接口描述符、端点描述符全部打包在一起。每个USB设备至少有一个usb_cfg_data实例。
USBD_DEFINE_CFG_DATA(usb_cfg) = {
.usb_device_description = &dev_desc,
.interface_descriptor = &intf_desc,
.cfg_descriptor = &cfg_desc,
.num_endpoints = 2,
.endpoint = ep_data,
};
这里要注意,USBD_DEFINE_CFG_DATA是一个宏,它会帮你自动处理对齐和链接。我刚开始用的时候,总想手动初始化,结果踩了坑——宏里面做了很多编译器相关的优化,手动搞容易出问题。
3. usb_ep_cfg_data —— 端点配置
每个端点都需要一个这样的结构体。它描述了端点的方向(IN/OUT)、类型(控制、批量、中断、同步)、最大包大小等。
static const struct usb_ep_cfg_data ep_data[] = {
{
.ep_cb = bulk_in_cb,
.ep_addr = USB_EP_DIR_IN | 1,
.ep_type = USB_EP_TYPE_BULK,
.ep_mps = 64,
},
{
.ep_cb = bulk_out_cb,
.ep_addr = USB_EP_DIR_OUT | 1,
.ep_type = USB_EP_TYPE_BULK,
.ep_mps = 64,
},
};
避坑指南:端点地址的编码方式,不同MCU可能不一样。Zephyr用USB_EP_DIR_IN和USB_EP_DIR_OUT来区分方向,但有些底层驱动会忽略这个宏。我曾经在NXP的芯片上调试了两天,才发现是端点地址的bit7没有正确设置。
三、配置流程:三步走,稳得很
配置一个USB设备,其实就三步。我习惯叫它“注册-初始化-使能”。
- 注册设备:调用
usb_enable(),传入你的usb_cfg_data。这一步会把你的设备描述符注册到USB核心层。 - 初始化端点:核心层会遍历你的端点配置,调用底层驱动初始化硬件寄存器。这一步是自动的,你不需要手动干预。
- 使能设备:调用
usb_dc_attach(),让USB控制器开始工作。主机检测到设备插入后,会开始枚举流程。
代码看起来是这样的:
void main(void)
{
int ret;
ret = usb_enable(&usb_cfg);
if (ret != 0) {
printk("USB enable failed: %d\n", ret);
return;
}
printk("USB device initialized\n");
/* 剩下的就是等待主机枚举,然后处理数据传输 */
}
你可能会问:为什么usb_enable()和usb_dc_attach()是分开的?嗯,这里有个设计考量。有些场景下,你可能想先配置好USB协议栈,但暂时不让控制器上线(比如系统还在初始化其他外设)。分开调用给了你更大的灵活性。
实际项目经验:我在做一个数据采集器时,需要USB和SD卡同时初始化。如果先让USB上线,主机立刻开始枚举,而此时SD卡还没准备好,会导致设备描述符里的字符串索引无法读取。解决办法就是先调usb_enable(),等SD卡初始化完再调usb_dc_attach()。
四、回调机制:事件驱动,别搞阻塞
Zephyr的USB栈大量使用回调。比如端点收到数据时,会调用你注册的ep_cb函数。这个回调是在中断上下文执行的,所以千万别在里面做耗时操作,比如打印日志、申请内存。我曾经犯过这个错——在回调里调了printk(),结果系统直接死机。
正确的做法是:在回调里用信号量或消息队列通知应用层线程,让线程去处理数据。Zephyr的k_sem_give()和k_msgq_put()都是中断安全的,放心用。
static void bulk_out_cb(uint8_t ep, enum usb_dc_ep_cb_status_code status)
{
if (status == USB_DC_EP_SETUP) {
/* 主机发来了SETUP包,需要处理标准请求 */
usb_handle_standard_request();
} else if (status == USB_DC_EP_DATA_OUT) {
/* 数据到达,通知应用线程 */
k_sem_give(&data_ready_sem);
}
}
小技巧:如果你的应用需要处理大量数据,可以考虑用DMA。Zephyr的USB驱动层对DMA的支持因芯片而异,但大部分主流MCU都支持。用DMA的话,回调里只需要检查传输完成标志,CPU负载会低很多。
五、复合设备的特殊之处
复合设备,说白了就是一个USB设备里包含多个功能。比如一个设备既是键盘又是鼠标,或者既是U盘又是串口。Zephyr通过usb_cfg_data里的interface_descriptor数组来支持多个接口。
配置复合设备时,要注意接口编号不能冲突。每个接口的bInterfaceNumber必须唯一,而且端点地址也不能重复。我见过有人把两个接口的端点都配成EP 1 IN,结果主机枚举时直接报错。
另外,复合设备的配置描述符需要手动拼接。Zephyr没有提供自动生成复合描述符的工具,你得自己算好每个接口的偏移量。嗯,这块确实有点麻烦,但好在有模板可以参考。
好了,这一章的内容就到这里。下一章我们会深入讲解如何编写一个具体的USB设备类,比如HID或者CDC ACM。到时候我会带上完整的代码示例,咱们手把手调通一个能用的设备。