1. 为什么要在 STM32F103 上折腾 CherryUSB
STM32F103 这颗芯片在嵌入式圈子里算是“国民级”的存在,价格便宜、资料多、最小系统板满地都是。但它的 USB 外设只有全速(Full Speed)模式,缓冲区也不大,很多人第一次做 USB 通信时都会被各种描述符、端点配置、中断处理搞得头大。传统做法是直接用 ST 官方的 USB 库,代码能跑,但移植起来比较重,换一颗芯片或者换一个 USB 类就得大改。
CherryUSB 是一个轻量级的 USB 协议栈,特点是分层清晰、代码量小、支持 Device 和 Host 两种模式,而且对 MSC、CDC、HID 这些常用类都有现成支持。把它移植到 STM32F103 上,核心目标就是让这颗小板子能作为一个 U 盘设备被电脑识别,也就是实现 MSC(Mass Storage Class)功能。这个场景很实用:比如你的设备采集了一堆数据,想直接通过 USB 口把内部 Flash 或者 SD 卡里的文件拖到电脑上,不需要额外装驱动,插上就能用。
这篇文章面向的是有一定 STM32 基础、用过 CubeMX 或者标准库、想尝试第三方 USB 协议栈的开发者。我会从源码下载开始,一步步讲到 MSC 功能跑通,中间涉及到的描述符配置、端点分配、中断处理、读写回调都会拆开说清楚。你不需要事先了解 CherryUSB 的内部架构,跟着做就能复现。
2. 移植前的整体思路与方案选型
2.1 为什么选 CherryUSB 而不是官方库
ST 官方的 USB Device 库(比如旧版的STM32_USB-FS-Device_Lib)和 CubeMX 生成的中间件都能用,但它们的耦合度比较高。举个例子,官方库把 USB 标准请求、类请求、端点操作都揉在几个文件里,你想加一个自定义的类或者改一下端点缓冲区分配,得翻好几层代码。CherryUSB 的设计思路不一样,它把 USB 协议栈分成三层:最底层是usb_dc(设备控制器驱动),中间是usb_core(协议核心),最上面是class(各类驱动)。你移植的时候只需要实现usb_dc里的几个函数,剩下的协议解析、枚举过程、类请求处理都由协议栈自己完成。
另一个原因是 CherryUSB 的 MSC 类驱动已经写好了,它实现了一个标准的 BOT(Bulk-Only Transport)协议,你只需要提供底层的读写函数,也就是“怎么从存储介质读一个扇区”和“怎么写一个扇区”。对于 STM32F103 来说,存储介质可以是内部 Flash、SPI Flash 或者 SD 卡。我这次用的是内部 Flash 模拟一个小的 U 盘,容量不大,但足够验证流程。
2.2 硬件和软件环境确认
硬件方面,我用的是常见的 STM32F103C8T6 最小系统板,也就是俗称的“蓝板”。它引出了 USB 的 D+ 和 D- 引脚,分别是 PA12 和 PA11。注意,STM32F103 的 USB 外设需要外部提供一个 1.5k 的上拉电阻在 D+ 线上,有些板子已经自带了,有些没有。如果你用的是最小系统板,最好先确认一下 D+ 有没有通过电阻上拉到 3.3V,否则电脑可能识别不到设备。我手头这块板子自带了一个可控制的 1.5k 上拉,通过一个三极管控制,但为了简化,我直接把它短接到 3.3V 了。
软件方面,我用的开发环境是 Keil MDK 5,编译器是 ARMCC V6。CherryUSB 本身是纯 C 写的,不依赖特定编译器,但你需要确保工程里开启了 C99 模式。STM32 的底层驱动我用的是标准库,不是 HAL 库。原因是我觉得标准库的寄存器操作更直接,中断处理也更可控。如果你习惯用 HAL 库,移植思路是一样的,只是底层初始化代码换成 HAL 的写法。
2.3 整体移植路线图
整个移植过程可以分成四步:第一步是下载 CherryUSB 源码,把必要的文件加入工程;第二步是编写usb_dc驱动,也就是 STM32F103 USB 外设的初始化、端点配置、中断处理;第三步是配置 MSC 类,实现读写回调;第四步是调试枚举过程,解决电脑识别不到或者识别成未知设备的问题。每一步都有一些容易踩的坑,我会在对应章节里详细说明。
3. 源码下载与工程文件组织
3.1 获取 CherryUSB 源码
CherryUSB 的源码托管在 GitHub 上,你可以直接克隆仓库或者下载 zip 包。我建议用 git clone,方便后续更新。仓库地址是https://github.com/sakumisu/CherryUSB。下载下来之后,目录结构大概是这样的:core目录放的是 USB 协议核心,class目录是各类驱动,port目录是不同芯片的底层驱动模板,demo目录是一些示例工程。
你需要关注的文件有:core/usbd_core.c、core/usbd_core.h、class/msc/usbd_msc.c、class/msc/usbd_msc.h、port/dwc2/usb_dc_dwc2.c(但 STM32F103 用的是全速设备控制器,不是 DWC2,所以这个不能用)。STM32F103 的 USB 外设是 ST 自己设计的,不是 Synopsys 的 DWC2,所以你需要自己写一个usb_dc驱动。CherryUSB 在port目录下有一个template文件夹,里面有一个usb_dc_template.c,你可以基于这个模板来改。
3.2 把源码加入 Keil 工程
在 Keil 里新建一个分组,比如叫CherryUSB,然后把下面这些文件添加进去:usbd_core.c、usbd_msc.c,以及你自己写的usb_dc_stm32f103.c。头文件路径也要加进去,包括core、class/msc、port/template这几个目录。注意,CherryUSB 用了一些宏来控制功能裁剪,你需要在usb_config.h里定义好。这个文件可以放在你的工程目录下,内容大概是这样:
#ifndef USB_CONFIG_H #define USB_CONFIG_H #define CONFIG_USBDEV #define CONFIG_USBDEV_MSC #define CONFIG_USBDEV_MSC_MAX_LUN 1 #define CONFIG_USBDEV_MSC_MAX_BUFSIZE 512 #define CONFIG_USBDEV_MSC_MANUFACTURER_STRING "MyCompany" #define CONFIG_USBDEV_MSC_PRODUCT_STRING "STM32F103 MSC Demo" #define CONFIG_USBDEV_MSC_VERSION_STRING "1.0" #endif这里的CONFIG_USBDEV_MSC_MAX_BUFSIZE要和你实际使用的端点缓冲区大小匹配。STM32F103 的 USB 缓冲区总共只有 512 字节,所以你要合理分配。我后面会详细讲怎么分配。
3.3 注意事项:文件依赖与编译顺序
CherryUSB 的核心文件之间有一些依赖关系,比如usbd_core.c会调用usb_dc_*系列函数,而这些函数需要你在usb_dc_stm32f103.c里实现。如果你编译时报“未定义符号”,大概率是某个文件没加进去或者头文件路径不对。另外,usbd_msc.c里会引用一个usbd_msc_get_cap函数,这个函数需要你在应用层实现,用来告诉协议栈你的存储介质有多大、扇区大小是多少。这个函数不实现的话,编译能过,但枚举会失败。
4. STM32F103 USB 外设底层驱动实现
4.1 USB 外设初始化与时钟配置
STM32F103 的 USB 外设挂在 APB1 总线上,时钟使能位是RCC_APB1ENR的USBEN位。在初始化之前,你需要先配置好系统时钟,确保 USB 时钟是 48MHz。STM32F103 的 USB 外设要求时钟精度比较高,所以外部晶振最好用 8MHz,然后通过 PLL 倍频到 72MHz,再分频给 USB。具体来说,USB 时钟来自 PLL 的 1.5 分频,所以 PLL 输出必须是 72MHz 或者 48MHz。如果你用的是 8MHz 晶振,PLL 倍频到 72MHz,那么 USB 时钟就是 48MHz,正好。
初始化代码大概是这样:
void usb_dc_low_level_init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11 | GPIO_Pin_12; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); USB_Init(); }这里的USB_Init()是标准库里的函数,它会配置 USB 外设的基本寄存器,比如中断使能、设备地址等。但 CherryUSB 需要自己管理端点,所以你不能直接用标准库的中断处理函数,而是要自己写USB_LP_CAN1_RX0_IRQHandler。
4.2 端点缓冲区分配策略
STM32F103 的 USB 缓冲区是一块 512 字节的 SRAM,通过USB_BTABLE来管理。每个端点都需要分配发送和接收缓冲区,而且地址必须是 2 字节对齐的。CherryUSB 的 MSC 类默认使用端点 1 作为控制端点,端点 2 作为批量输出,端点 3 作为批量输入。但 STM32F103 的端点 0 是控制端点,端点 1 到 7 可以配置成其他类型。我实际用的是端点 1 作为批量输出,端点 2 作为批量输入,端点 0 作为控制。
缓冲区分配我用了下面的方案:端点 0 的发送和接收各 64 字节,端点 1 的接收 64 字节,端点 2 的发送 64 字节。总共用了 256 字节,剩下的 256 字节留着备用。分配代码如下:
#define USB_BUFFER_ADDR_EP0_TX 0x00 #define USB_BUFFER_ADDR_EP0_RX 0x40 #define USB_BUFFER_ADDR_EP1_RX 0x80 #define USB_BUFFER_ADDR_EP2_TX 0xC0然后在USB_Init()之后,通过SetUSBDevAddr和SetEPType等函数配置端点。注意,CherryUSB 的usb_dc_ep_start_xfer函数会调用你实现的底层发送函数,你需要把数据从用户缓冲区拷贝到 USB 缓冲区,然后设置USB_EP_CTR_TX位触发发送。
4.3 中断处理与事件回调
STM32F103 的 USB 中断有两个:一个是低优先级中断USB_LP_CAN1_RX0_IRQHandler,一个是高优先级中断USB_HP_CAN1_TX_IRQHandler。CherryUSB 只需要低优先级中断就够了,因为它的批量传输不要求高优先级。中断处理函数里,你需要读取USB_ISTR寄存器,判断是哪种事件,然后调用 CherryUSB 的回调函数。
常见的事件有:正确传输(CTR)、复位(RESET)、挂起(SUSP)、唤醒(WKUP)。复位事件里,你需要重新配置端点,并调用usbd_core_reset通知协议栈。正确传输事件里,你需要判断是哪个端点,然后调用usbd_core_ep_handler。下面是一个简化的中断处理框架:
void USB_LP_CAN1_RX0_IRQHandler(void) { uint16_t istr = USB->ISTR; if (istr & USB_ISTR_RESET) { USB->ISTR = ~USB_ISTR_RESET; usb_dc_reset(); usbd_core_reset(); } if (istr & USB_ISTR_CTR) { uint8_t ep = istr & USB_ISTR_EP_ID; if (istr & USB_ISTR_DIR) { // 接收事件 usbd_core_ep_handler(ep, USB_EVENT_RX); } else { // 发送事件 usbd_core_ep_handler(ep, USB_EVENT_TX); } USB->ISTR = ~USB_ISTR_CTR; } }这里要注意,USB_ISTR的清除方式是写 0,不是写 1。标准库里的USB_Istr函数已经帮你处理了,但如果你自己写,一定要小心。
5. MSC 类配置与读写回调实现
5.1 MSC 描述符配置要点
CherryUSB 的 MSC 类驱动会自动生成大部分描述符,但你需要提供一些字符串描述符,比如厂商名、产品名、序列号。这些在usb_config.h里通过宏定义。另外,MSC 类需要一个接口描述符,里面包含两个批量端点。CherryUSB 会根据你定义的端点号自动填充。你需要注意的是,端点的最大包长要设置成 64 字节,因为 STM32F103 的全速 USB 批量端点最大就是 64 字节。
还有一个细节是bInterfaceSubClass和bInterfaceProtocol,对于 MSC 设备,这两个值通常是0x06和0x50,表示 SCSI 透明命令集和 BOT 协议。CherryUSB 已经帮你填好了,你不需要改。
5.2 实现usbd_msc_get_cap回调
这个回调函数用来告诉协议栈你的存储介质容量和扇区大小。函数原型是:
void usbd_msc_get_cap(uint8_t lun, uint32_t *block_num, uint16_t *block_size);我用的内部 Flash 模拟了一个 64KB 的 U 盘,扇区大小是 512 字节,所以block_num是 128,block_size是 512。实现如下:
void usbd_msc_get_cap(uint8_t lun, uint32_t *block_num, uint16_t *block_size) { *block_num = 128; *block_size = 512; }注意,block_num和block_size的乘积不能超过你实际能读写的存储空间。如果你用外部 SPI Flash,需要根据 Flash 的实际容量来设置。
5.3 读写扇区函数与 Flash 操作
MSC 类驱动会调用usbd_msc_read_sector和usbd_msc_write_sector两个函数,你需要实现它们。对于内部 Flash,读操作很简单,直接 memcpy 就行。写操作比较麻烦,因为 STM32F103 的内部 Flash 写之前必须先擦除,而且擦除是按页进行的,一页是 1KB 或者 2KB。如果你要写一个 512 字节的扇区,可能需要先擦除一整页,这会影响其他扇区的数据。
我的做法是:把整个 64KB 的 Flash 空间划分成 128 个扇区,每个扇区 512 字节。写之前,先判断目标地址所在的页是否已经被擦除过。如果没擦除,就先把那一页的数据读到内存里,擦除页,再把新数据合并写回去。这个过程比较慢,但能保证数据不丢。实际代码里,我用了一个简单的缓存机制,但为了简化,这里只给出核心逻辑:
int usbd_msc_write_sector(uint8_t lun, uint32_t sector, uint8_t *buffer) { uint32_t addr = FLASH_BASE + sector * 512; FLASH_Unlock(); FLASH_ErasePage(addr & ~(PAGE_SIZE - 1)); for (int i = 0; i < 512; i += 2) { FLASH_ProgramHalfWord(addr + i, *(uint16_t *)(buffer + i)); } FLASH_Lock(); return 0; }这里省略了数据合并的步骤,实际使用中如果你要保留其他扇区的数据,必须先读出来再写回去。
5.4 注意事项:Flash 写寿命与性能
内部 Flash 的擦写次数有限,大概 1 万次左右。如果你频繁写 U 盘,Flash 很快就会坏。所以这个方案只适合做演示或者低频写入的场景。如果你需要频繁读写,建议用外部 SPI Flash 或者 SD 卡。另外,Flash 写入速度比较慢,一个 512 字节的扇区写下来可能要几毫秒,电脑端会感觉复制文件很慢。这是正常的,因为全速 USB 的批量传输带宽本来就不高,加上 Flash 擦除时间,整体速度能到几十 KB/s 就不错了。
6. 枚举调试与常见问题排查
6.1 电脑识别不到设备的排查思路
这是移植过程中最常见的问题。电脑插上 USB 线之后,设备管理器里没有任何反应,或者显示“未知 USB 设备”。排查步骤是这样的:先确认硬件上 D+ 有没有上拉。用万用表量一下 PA12 对 3.3V 的电阻,如果是 1.5k 左右,说明上拉正常。如果没有上拉,电脑根本不会检测到设备插入。然后确认 USB 时钟是不是 48MHz,可以用示波器量 PA8 引脚(MCO 输出),或者直接看代码里的时钟配置。
如果硬件没问题,那就是软件问题。先看中断有没有进去。在USB_LP_CAN1_RX0_IRQHandler里加一个 GPIO 翻转,用示波器看有没有波形。如果没有,说明中断没使能,检查NVIC_EnableIRQ和USB->CNTR寄存器的中断使能位。如果有中断,但枚举失败,那就用 USB 抓包工具看一下电脑发了什么请求,设备回了什么。常见的错误是设备描述符里的bMaxPacketSize0不是 64,或者端点配置不对。
6.2 枚举成功但无法格式化或复制文件
有时候电脑能识别到 U 盘,但提示“需要格式化”或者复制文件时出错。这通常是读写回调返回了错误,或者扇区数据不对。先检查usbd_msc_get_cap返回的容量和扇区大小是否正确。如果容量是 0,电脑会认为设备没有存储介质。然后检查usbd_msc_read_sector有没有正确填充缓冲区。你可以在函数里加一个断点,看看电脑读的是哪个扇区,数据是不是全 0。
还有一个坑是 MSC 的 BOT 协议要求设备在处理完一个命令后发送 CSW(Command Status Wrapper),如果 CSW 里的状态不对,电脑会认为命令失败。CherryUSB 会自动处理 CSW,但你需要确保读写函数返回 0 表示成功,返回非 0 表示失败。
6.3 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 电脑无反应 | D+ 无上拉 | 检查 1.5k 上拉电阻 |
| 未知 USB 设备 | 时钟不对 | 确认 USB 时钟 48MHz |
| 枚举失败 | 描述符错误 | 用抓包工具对比标准描述符 |
| 需要格式化 | 容量为 0 | 检查usbd_msc_get_cap |
| 复制文件出错 | 写回调失败 | 检查 Flash 擦除和编程 |
| 设备频繁掉线 | 中断处理超时 | 优化中断服务函数 |
6.4 实操心得:抓包工具的选择
调试 USB 最有效的工具是硬件抓包器,比如 Beagle USB 480 或者类似的协议分析仪。如果没有硬件抓包器,可以用软件工具,比如 Wireshark 配合 USBPcap。但软件抓包只能看到电脑端的请求,看不到设备端的响应,所以定位问题会慢一些。我个人的经验是,先确保硬件没问题,然后用抓包工具看枚举过程,重点看GET_DESCRIPTOR请求的响应数据。如果设备描述符的前 8 个字节不对,后面的枚举肯定失败。
7. 性能优化与后续扩展方向
7.1 提高读写速度的几个手段
STM32F103 的全速 USB 理论带宽是 12Mbps,实际批量传输能到 1MB/s 左右。但受限于 Flash 擦写速度,实际文件复制速度可能只有几十 KB/s。如果你想提高速度,可以考虑用外部 SPI Flash,因为 SPI Flash 的页编程时间比内部 Flash 短很多。另外,你可以把 MSC 的缓冲区设大一点,比如 512 字节,减少 USB 传输次数。但 STM32F103 的 USB 缓冲区总共只有 512 字节,所以你要在端点之间做权衡。
还有一个优化点是使用 DMA。STM32F103 的 USB 外设不支持 DMA,所以只能靠中断搬运数据。但你可以优化中断处理函数,减少不必要的判断。比如,在批量传输时,直接根据端点号调用对应的处理函数,而不是遍历所有端点。
7.2 从 MSC 扩展到复合设备
CherryUSB 支持复合设备,也就是一个 USB 接口同时实现 MSC 和 CDC。比如,你可以让 STM32F103 既作为一个 U 盘,又作为一个串口。这样电脑上会同时出现一个磁盘和一个 COM 口。实现方法是配置两个接口,一个给 MSC,一个给 CDC。CherryUSB 的usbd_core支持多接口,你只需要在描述符里把两个接口都列出来,然后在usbd_core_interface_handler里分别处理。
不过要注意,STM32F103 的端点数量有限,MSC 用了两个批量端点,CDC 又需要两个批量端点和一个中断端点,加起来可能不够。你可以把 CDC 的中断端点去掉,只用批量端点,但这样就不符合 CDC 规范了。所以实际做复合设备时,通常要选端点更多的芯片,比如 STM32F103 的增强型或者 STM32F4 系列。
7.3 后续可以尝试的方向
如果你已经跑通了 MSC,下一步可以试试把文件系统加进来。比如在 STM32F103 上移植 FatFS,然后把 Flash 模拟成一个磁盘,这样电脑上就能看到一个真正的文件系统,而不是一个未格式化的磁盘。FatFS 的移植比较简单,你只需要实现disk_read和disk_write两个函数,底层调用 MSC 的读写回调就行。
另一个方向是 USB Host。CherryUSB 也支持 Host 模式,你可以让 STM32F103 作为主机去读写 U 盘。但 STM32F103 的 USB 外设只支持全速,而且 Host 模式需要提供 5V 电源和过流保护,硬件上要复杂一些。如果你有兴趣,可以找一块带 USB Host 的板子试试。
我个人在实际操作中的体会是,CherryUSB 的移植难度主要在于底层驱动的实现,尤其是端点缓冲区的分配和中断处理。一旦底层跑通,上层的 MSC 类几乎不需要改代码。另外,调试 USB 一定要有耐心,因为枚举过程涉及很多细节,一个描述符字段不对就会导致失败。多抓包、多对比标准描述符,问题总能解决。