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_INUSB_EP_DIR_OUT来区分方向,但有些底层驱动会忽略这个宏。我曾经在NXP的芯片上调试了两天,才发现是端点地址的bit7没有正确设置。

三、配置流程:三步走,稳得很

配置一个USB设备,其实就三步。我习惯叫它“注册-初始化-使能”。

  1. 注册设备:调用usb_enable(),传入你的usb_cfg_data。这一步会把你的设备描述符注册到USB核心层。
  2. 初始化端点:核心层会遍历你的端点配置,调用底层驱动初始化硬件寄存器。这一步是自动的,你不需要手动干预。
  3. 使能设备:调用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。到时候我会带上完整的代码示例,咱们手把手调通一个能用的设备。