4. USB设备栈移植:TinyUSB协议栈介绍、源码结构分析、在STM32上的移植步骤

好,咱们进入第四章。前面几章我们把USB协议的基础概念、描述符、枚举流程都捋了一遍。从这章开始,要动真格的了——把协议栈移植到你的板子上。

我选TinyUSB作为教学对象,原因很简单:它轻量、活跃、对STM32支持好。说白了,它就是为嵌入式MCU量身定做的USB协议栈。你想想看,一个只有几百KB的协议栈,能跑Device、Host、OTG,还能支持HID、CDC、MSC、Audio……这性价比,没谁了。

4.1 TinyUSB协议栈概览

TinyUSB是开源项目,托管在GitHub上。它最大的特点就是模块化设计。我个人习惯把它分成三层:

  • 底层(Porting Layer):跟MCU硬件打交道,比如配置USB寄存器、处理中断。
  • 中间层(Device/Host Stack):处理USB协议的核心逻辑,比如枚举、控制传输。
  • 应用层(Class Drivers):实现具体的设备类,比如HID键盘、CDC串口。

这种分层的好处很明显——你移植的时候,大部分精力只需要花在底层。中间层和应用层基本不用动。

核心优势:

  • 纯C语言编写,无RTOS依赖(但可以配合FreeRTOS使用)
  • 支持多种MCU:STM32、NXP、Microchip、Raspberry Pi Pico等
  • 代码规范,注释齐全,调试方便
  • 活跃的社区支持,遇到问题基本能找到答案

我在项目中遇到过最头疼的事,就是某个商业协议栈出了bug,厂商技术支持回复慢得像蜗牛。从那以后,我坚定地转向开源方案。TinyUSB的issue列表里,很多问题当天就能得到回复。

4.2 源码结构分析

咱们先看看TinyUSB的源码长什么样。下载下来后,目录结构大致如下:

tinyusb/
├── hw/
│   ├── bsp/            # 板级支持包(各开发板)
│   └── mcu/            # MCU相关代码(寄存器定义、时钟配置)
├── src/
│   ├── common/         # 通用头文件、宏定义
│   ├── device/         # USB Device协议栈核心
│   ├── host/           # USB Host协议栈核心
│   ├── class/          # 设备类驱动(cdc, hid, msc, audio...)
│   ├── osal/           # 操作系统抽象层(用于RTOS)
│   ├── port/           # 移植接口(需要你实现的部分)
│   └── tusb.c          # 主入口文件
├── examples/           # 示例工程
└── docs/               # 文档

嗯,这里要注意。你真正需要关心的,其实就两个地方:

目录/文件 作用 移植时是否需要修改
src/port/ MCU底层接口(中断、DCD、EDD) 是(核心移植工作)
hw/mcu/ MCU外设驱动(时钟、GPIO、USB IP) 是(根据你的MCU型号)
src/device/ Device协议栈核心 否(一般不动)
src/class/ 设备类驱动 否(直接调用)
examples/ 参考示例 参考学习

说白了,移植TinyUSB就是把你MCU的USB硬件操作,封装成TinyUSB规定的接口函数。这些接口定义在 src/port/ 目录下。

我的建议: 第一次移植,别从零开始。先找到TinyUSB官方仓库里跟你MCU最接近的示例,比如STM32F4系列,然后在此基础上修改。这样能省掉很多底层调试的麻烦。

4.3 移植核心接口

TinyUSB的移植接口主要集中在 tusb_port.h 和 dcd_xxx.c 文件中。你需要实现以下几个关键函数:

4.3.1 初始化与去初始化

// 初始化USB设备控制器
void dcd_init(uint8_t rhport);

// 去初始化USB设备控制器
void dcd_deinit(uint8_t rhport);

这两个函数负责配置USB外设的时钟、GPIO、中断、端点寄存器等。说白了,就是把你的MCU USB硬件调到能工作的状态。

4.3.2 端点操作

// 打开端点
bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * desc_ep);

// 关闭端点
void dcd_edpt_close(uint8_t rhport, uint8_t ep_addr);

// 提交传输请求
bool dcd_edpt_xfer(uint8_t rhport, uint8_t ep_addr, uint8_t * buffer, uint16_t total_bytes);

// 设置STALL
void dcd_edpt_stall(uint8_t rhport, uint8_t ep_addr);

// 清除STALL
void dcd_edpt_clear_stall(uint8_t rhport, uint8_t ep_addr);

这些是移植中最核心的部分。我记得第一次移植时,在 dcd_edpt_xfer 函数里卡了两天。原因很简单——我忘了处理端点描述符中的最大包长。你想想看,如果主机发来64字节的数据,你只准备了32字节的缓冲区,那肯定要出问题。

4.3.3 中断处理

// USB中断服务函数(由你的MCU中断向量表调用)
void dcd_int_handler(uint8_t rhport);

这个函数是USB协议栈的“心脏”。所有USB事件——复位、挂起、恢复、端点传输完成——都会触发中断,然后由这个函数分发处理。

避坑指南: 我曾经在STM32F103上移植时,发现USB中断进不去。查了半天,原来是中断优先级配置问题。USB中断的优先级不能太高,否则会跟系统滴答定时器冲突。建议设置为中等优先级。

4.4 在STM32上的移植步骤

好,理论说完了,咱们动手。以下是我在STM32F407上移植TinyUSB的完整步骤。其他型号的STM32大同小异。

4.4.1 第一步:准备工程

  1. 从GitHub下载TinyUSB源码(建议用稳定版本,比如0.15.0)。
  2. 创建一个新的STM32工程(可以用STM32CubeMX生成,也可以用现成的模板)。
  3. 把TinyUSB的 src/ 目录复制到你的工程中。
  4. 配置好USB外设的时钟(STM32F407的USB需要48MHz时钟,通常由PLL提供)。

4.4.2 第二步:实现移植接口

在 src/port/ 目录下,创建一个 dcd_stm32_fsdev.c 文件(STM32F407的USB是FS设备控制器)。然后实现前面提到的那些接口函数。

这里我贴一段关键代码,展示如何打开端点:

bool dcd_edpt_open(uint8_t rhport, tusb_desc_endpoint_t const * desc_ep)
{
    (void) rhport;

    // 获取端点地址和方向
    uint8_t const ep_addr = desc_ep->bEndpointAddress;
    uint8_t const ep_dir = tu_edpt_dir(ep_addr);
    uint8_t const ep_num = tu_edpt_number(ep_addr);
    uint16_t const ep_mps = desc_ep->wMaxPacketSize;

    // 配置USB寄存器
    USB_OTG_DeviceTypeDef * dev = USB_OTG_FS.dev;
    USB_OTG_INEndpointTypeDef * in_ep = USB_OTG_FS.IN_ep[ep_num];
    USB_OTG_OUTEndpointTypeDef * out_ep = USB_OTG_FS.OUT_ep[ep_num];

    if (ep_dir == TUSB_DIR_IN)
    {
        // 配置IN端点
        in_ep->DIEPCTL |= (ep_mps & 0x7FF) << 0;  // 设置MPS
        in_ep->DIEPCTL |= USB_OTG_DIEPCTL_SD0PID_SEVNFRM;
        in_ep->DIEPCTL |= USB_OTG_DIEPCTL_USBAEP;
    }
    else
    {
        // 配置OUT端点
        out_ep->DOEPCTL |= (ep_mps & 0x7FF) << 0;
        out_ep->DOEPCTL |= USB_OTG_DOEPCTL_SD0PID_SEVNFRM;
        out_ep->DOEPCTL |= USB_OTG_DOEPCTL_USBAEP;
    }

    return true;
}

嗯,这段代码看起来简单,但实际调试时可能会遇到各种问题。比如寄存器位域的定义,不同STM32系列可能不一样。我建议你对照着STM32的参考手册,一个一个寄存器确认。

4.4.3 第三步:配置描述符

在 src/class/ 目录下,找到你需要的设备类。比如我们要做一个HID键盘,就参考 examples/device/hid_composite/ 里的描述符定义。

描述符是USB通信的“身份证”。主机通过读取描述符来识别你的设备。我习惯把描述符放在一个单独的文件里,比如 usb_descriptors.c。

4.4.4 第四步:编写应用代码

在 main.c 中,初始化TinyUSB并启动任务:

#include "tusb.h"

int main(void)
{
    // 初始化系统时钟、GPIO等
    system_init();

    // 初始化TinyUSB
    tusb_init();

    while (1)
    {
        // TinyUSB任务处理(必须在主循环中调用)
        tud_task();
    }
}

就这么简单?嗯,基本框架确实就这几行。但实际项目中,你还需要处理USB事件回调,比如设备连接、断开、配置完成等。

4.4.5 第五步:调试与验证

编译下载后,用USB线连接电脑。如果一切顺利,电脑会识别出新设备。如果不行,别慌。我总结了一套调试流程:

  1. 检查时钟:用示波器或逻辑分析仪看USB D+引脚,是否有1.5kΩ上拉电阻产生的电平变化。
  2. 检查中断:在 dcd_int_handler 函数里加断点,看是否能触发。
  3. 检查描述符:用USB分析工具(比如Bus Hound)抓取枚举过程,看主机是否收到正确的描述符。
  4. 检查端点:确认端点配置是否正确,特别是最大包长和传输类型。

我的经验: 调试USB最有效的工具是逻辑分析仪。花几百块钱买一个,能省下你几天的排查时间。我曾经靠它抓到了一个D+上拉电阻时序不对的问题,那个问题在代码层面根本看不出来。

4.5 移植后的整体架构

为了让你更直观地理解移植后的软件架构,我画了一张图:

TinyUSB 移植架构图(STM32) 应用层(main.c / 用户代码) 设备类驱动(HID / CDC / MSC) TinyUSB 核心协议栈(device / host) 移植层(dcd_stm32_fsdev.c) STM32 USB 外设(寄存器 / 中断) USB 总线(物理层) 你只需要修改 移植层和MCU硬件 这两部分 其他层基本不动

从这张图可以看得很清楚:你只需要关注橙色和红色两层。紫色和绿色层是TinyUSB已经写好的,你直接调用就行。

4.6 常见问题与避坑

移植过程中,我遇到过不少坑。这里列几个典型的:

问题1:USB枚举失败,主机报告“未知设备”

原因:通常是描述符错误或端点配置不对。我曾经犯过一个低级错误——把HID报告描述符的长度写错了,导致主机无法解析。用USB分析工具抓包,一眼就能看出来。

问题2:数据传输不稳定,偶尔丢包

原因:缓冲区对齐问题。STM32的USB控制器要求缓冲区地址按4字节对齐。如果你传了一个未对齐的地址,数据就会出错。解决办法:用 __attribute__((aligned(4))) 声明缓冲区。

问题3:中断响应慢,影响实时性

原因:中断优先级设置不当。USB中断需要及时响应,但也不能抢占系统关键任务。我一般把USB中断优先级设置为中等,比系统滴答定时器低,比普通外设高。