ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32F103移植CherryUSB实现MSC设备:从时钟配置到枚举排查

STM32F103移植CherryUSB实现MSC设备:从时钟配置到枚举排查 1. 为什么要在STM32F103上折腾CherryUSBSTM32F103这颗芯片在嵌入式圈子里算是国民级的存在了价格便宜、资料丰富、外设够用拿来做USB设备开发再合适不过。但问题在于ST官方那套USB库用起来实在让人头疼——代码结构复杂、移植性差、不同系列芯片之间接口还不统一。我之前用标准库的USB Device库做过一个MSC设备光是理清那些回调函数的调用关系就花了两天时间后来想换到另一颗芯片上发现几乎要重写一遍。CherryUSB的出现算是解决了这个痛点。它是一个开源的小型USB协议栈特点是代码量小、结构清晰、跨平台支持好而且同时支持Device和Host模式。最关键的是它的移植层设计得很干净你只需要提供几个底层接口函数剩下的协议处理它全帮你搞定。对于STM32F103这种资源有限的芯片来说CherryUSB的ROM和RAM占用都在可接受范围内实测Device模式下编译出来大概占用12KB左右的FlashRAM占用不到4KB。这篇文章要聊的是在STM32F103上把CherryUSB跑起来并且实现MSC功能——也就是让STM32F103模拟成一个U盘设备插到电脑上能识别出一个磁盘。这个场景在实际项目中很常见比如做数据采集设备的时候需要把采集到的数据以文件形式导出用MSC就比自己写上位机软件方便得多。整个流程从源码下载开始到最终电脑上能看到盘符并能正常读写文件为止中间会涉及底层接口适配、描述符配置、MSC类驱动对接等环节。适合阅读这篇文章的是那些已经有一定STM32开发基础、用过标准库或HAL库、对USB协议有基本了解但没深入搞过USB设备开发的工程师。如果你之前完全没接触过USB协议建议先花半小时了解一下USB的基本概念——端点、描述符、枚举过程这些不然看代码的时候会比较懵。2. CherryUSB的源码获取与目录结构拆解2.1 从仓库拉取代码的正确姿势CherryUSB的源码托管在GitHub上直接clone下来就行。但这里有个细节需要注意——它的仓库里包含了多个子模块如果你只是简单下载zip包可能会缺少一些依赖文件。我建议用git clone的方式并且加上--recursive参数git clone --recursive https://github.com/sakumisu/CherryUSB.git如果网络条件不太理想clone速度慢的话也可以先clone主仓库然后手动下载子模块。不过实测下来CherryUSB的仓库体积并不大主仓库加上子模块也就几十兆的样子。下载完成后你会看到目录结构大概是这样的CherryUSB/ ├── core/ # USB协议栈核心代码 ├── class/ # 各类设备类驱动MSC、CDC、HID等 ├── port/ # 不同平台的移植层 │ ├── dwc2/ # Synopsys DWC2控制器 │ ├── ehci/ # EHCI控制器 │ ├── musb/ # Mentor MUSB控制器 │ ├── fsdev/ # 全速设备控制器STM32F103用的就是这个 │ └── ... ├── demo/ # 各平台的示例工程 ├── docs/ # 文档 └── ...对于STM32F103来说关键的是port/fsdev/目录。STM32F103的USB外设是一个全速设备控制器符合USB 2.0全速规范CherryUSB里对应的移植层就在这个目录下。这个目录里包含了针对不同芯片的底层驱动实现比如usb_dc_fsdev.c就是FSDEV控制器的设备模式驱动。2.2 核心文件的作用与依赖关系在动手移植之前有必要先理清楚几个关键文件的作用不然改代码的时候容易一头雾水。core/usbd_core.c是整个设备模式的核心负责处理USB标准请求、设备状态管理、端点管理这些通用逻辑。这个文件你基本不需要改动除非你要做一些非常规的操作。class/msc/usbd_msc.c是MSC类的实现它向上提供了一组API供应用层调用向下通过USB端点收发数据。这个文件里实现了SCSI命令的解析和响应包括INQUIRY、READ CAPACITY、READ(10)、WRITE(10)这些常用命令。port/fsdev/usb_dc_fsdev.c是底层驱动它实现了CherryUSB定义的设备控制器接口包括端点初始化、数据收发、中断处理等。这个文件是需要你根据具体芯片做适配的地方。port/fsdev/usb_glue_stm32.c或者类似名字的文件是STM32的粘合层负责把STM32的HAL库或者标准库的USB相关函数对接上来。不同版本的CherryUSB这个文件的名字可能略有不同但作用是一样的。还有一个重要的配置文件是usb_config.h它定义了USB设备的各种参数比如端点数量、缓冲区大小、支持的类等。这个文件通常放在你的工程目录下而不是CherryUSB的源码目录里这样方便不同项目使用不同的配置。2.3 版本选择的一个小建议CherryUSB的更新比较活跃不同版本之间的API可能会有细微变化。我在实际使用中发现如果你用的是比较新的版本文档和示例可能还没完全跟上。所以建议选择一个release版本而不是直接拿master分支的代码。截至我写这篇文章的时候v1.3.0版本在STM32F103上的表现比较稳定MSC功能的实测也没有问题。另外要注意的是CherryUSB的MSC类驱动在v1.2.0之后的版本里做了一些重构API和之前不太一样。如果你参考的是网上的老教程可能会发现代码对不上。遇到这种情况直接看源码里的头文件注释是最靠谱的。3. STM32F103的USB外设初始化与时钟配置3.1 时钟树里容易踩的坑STM32F103的USB外设对时钟的要求比较严格——它需要48MHz的时钟而且这个时钟必须是由PLL直接提供的不能经过分频。这一点和很多其他外设不一样也是很多人第一次搞USB时容易翻车的地方。STM32F103的时钟树里USB时钟的来源是PLL的输出。具体来说PLL的输出经过一个1.5分频或者2分频取决于PLL配置之后得到USB时钟。要得到48MHzPLL的输出必须是72MHz除以1.5或者96MHz除以2。对于STM32F103来说最常见的配置是系统时钟72MHzPLL输出72MHz然后USB预分频器设置为1.5分频得到48MHz。在标准库里的配置代码大概是这样RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); // 8MHz晶振 × 9 72MHz RCC_USBCLKConfig(RCC_USBCLKSource_PLLCLK_1Div5); // 72MHz ÷ 1.5 48MHz RCC_APB1PeriphClockCmd(RCC_APB1Periph_USB, ENABLE);如果你用的是HAL库配置方式类似但要注意CubeMX里USB时钟的配置项。CubeMX有时候会自动帮你算好但如果你手动改了时钟树一定要回头检查USB时钟是不是还是48MHz。我遇到过好几次因为改了系统时钟导致USB枚举失败的情况排查半天最后发现是USB时钟变成了36MHz。注意STM32F103的USB外设时钟必须精确为48MHz偏差超过0.25%就可能导致枚举失败或者通信不稳定。如果你用的是外部晶振确保晶振的精度在±20ppm以内。3.2 中断优先级与NVIC配置USB中断的优先级配置也有讲究。STM32F103的USB外设使用两个中断向量USB_LP_CAN1_RX0_IRQn低优先级和USB_HP_CAN1_TX_IRQn高优先级。在CherryUSB的移植层里通常只使用低优先级中断就够了因为CherryUSB的中断处理逻辑已经做了足够的优化不需要靠硬件中断优先级来保证实时性。在NVIC里的配置NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel USB_LP_CAN1_RX0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure);中断服务函数里只需要调用CherryUSB提供的处理函数void USB_LP_CAN1_RX0_IRQHandler(void) { USBD_IRQHandler(0); }这里的参数0是总线编号CherryUSB支持多总线STM32F103只有一个USB外设所以传0就行。3.3 端点缓冲区的分配策略STM32F103的USB外设有一个专用的512字节SRAM区域用于端点缓冲区这个区域和主SRAM是分开的通过USB外设的寄存器来访问。CherryUSB的FSDEV驱动会自动管理这个区域的分配但你需要确保在配置里设置的端点数量和缓冲区大小不超过512字节的总量。对于MSC设备来说通常需要用到3个端点EP0用于控制传输EP1用于Bulk IN设备到主机EP2用于Bulk OUT主机到设备。每个Bulk端点的缓冲区大小可以设置为64字节全速USB的最大包长。这样算下来EP0需要64字节EP1需要64字节EP2需要64字节总共192字节远小于512字节的限制。在usb_config.h里的配置大概是这样#define CONFIG_USBDEV_EP_NUM 4 #define CONFIG_USBDEV_MAX_BUS 1 #define CONFIG_USBDEV_EP0_MAX_SIZE 64端点的具体分配在MSC类的初始化代码里完成你不需要手动去配置端点寄存器CherryUSB会帮你处理好。4. CherryUSB移植层的适配与代码对接4.1 移植层需要实现哪些接口CherryUSB的移植层接口定义在port/fsdev/usb_dc_fsdev.h里你需要实现的核心函数包括函数名作用实现难度usb_dc_init初始化USB控制器中等usb_dc_deinit反初始化简单usb_dc_ep_open打开端点中等usb_dc_ep_close关闭端点简单usb_dc_ep_set_stall设置端点STALL简单usb_dc_ep_clear_stall清除端点STALL简单usb_dc_ep_write向端点写数据中等usb_dc_ep_read从端点读数据中等usb_dc_ep_read_wait等待端点数据中等usb_dc_ep_set_callback设置端点回调简单这些函数的实现大部分可以直接参考CherryUSB自带的usb_dc_fsdev.c你需要做的是把里面调用的STM32底层函数替换成你使用的库标准库或HAL库对应的函数。4.2 标准库与HAL库的适配差异如果你用的是标准库usb_dc_fsdev.c里的代码基本可以直接用因为CherryUSB的FSDEV驱动最初就是基于标准库写的。但如果你用的是HAL库就需要做一些替换工作。主要的差异在于标准库里的SetEPType、SetEPRxAddr、SetEPTxAddr这些函数在HAL库里对应的是HAL_PCD_EP_Open、HAL_PCD_EP_Transmit等。不过HAL库的PCDPeripheral Controller Driver抽象层比较厚直接对接CherryUSB反而更麻烦。我的建议是如果你用的是HAL库不要试图通过HAL的PCD层来对接CherryUSB而是直接操作USB外设的寄存器。CherryUSB的FSDEV驱动本身就是直接操作寄存器的你只需要把寄存器操作的宏定义改成STM32F103对应的地址就行。这样做的好处是代码更直接、效率更高而且不受HAL库版本变化的影响。具体来说你需要确保以下几个宏定义正确#define USB_BASE_ADDR 0x40005C00 #define USB_CNTR_REG (USB_BASE_ADDR 0x40) #define USB_ISTR_REG (USB_BASE_ADDR 0x44) #define USB_FNR_REG (USB_BASE_ADDR 0x48) #define USB_DADDR_REG (USB_BASE_ADDR 0x4C) #define USB_BTABLE_REG (USB_BASE_ADDR 0x50)这些地址在STM32F103的参考手册里都能查到不同型号的STM32F103比如C8T6、RCT6USB外设的基地址是一样的都是0x40005C00。4.3 中断处理里的一个常见问题在移植过程中我遇到过一个比较隐蔽的问题USB中断处理函数里如果执行时间过长会导致主机端的枚举过程超时。CherryUSB的中断处理函数本身已经做了优化但如果你在端点回调函数里做了耗时操作比如读写Flash就会拖慢中断响应。解决方法是把耗时操作放到主循环里中断回调里只做标志位的设置。CherryUSB的MSC类驱动已经考虑到了这一点它的读写回调是在主循环里轮询处理的不会在中断里直接操作存储介质。但如果你自己添加了额外的端点回调一定要注意这个问题。另外STM32F103的USB中断标志位清除时机也很关键。有些标志位需要在处理完数据之后才能清除如果提前清了可能会丢失中断。CherryUSB的驱动里已经处理好了这些细节你不需要额外操心但如果你自己改了中断处理代码就要特别小心。5. MSC功能的实现与存储介质对接5.1 MSC类驱动的调用逻辑CherryUSB的MSC类驱动提供了一组回调函数你需要实现这些回调来对接实际的存储介质。核心的回调包括struct usbd_msc_cb { int (*msc_read)(uint8_t lun, uint8_t *buf, uint32_t sector, uint32_t count); int (*msc_write)(uint8_t lun, uint8_t *buf, uint32_t sector, uint32_t count); int (*msc_init)(uint8_t lun); int (*msc_deinit)(uint8_t lun); int (*msc_get_capacity)(uint8_t lun, uint32_t *block_num, uint32_t *block_size); };这些回调的注册方式是在MSC类初始化的时候传入struct usbd_msc_cb msc_cb { .msc_read msc_read_cb, .msc_write msc_write_cb, .msc_init msc_init_cb, .msc_deinit msc_deinit_cb, .msc_get_capacity msc_get_capacity_cb, }; usbd_msc_init(0, msc_cb);msc_get_capacity回调需要返回存储介质的块数量和块大小。对于STM32F103来说常见的存储介质有SPI Flash比如W25Q64、SD卡通过SPI模式、内部Flash等。块大小通常设置为512字节这是USB MSC协议的标准块大小。5.2 用SPI Flash做存储介质的实操细节以W25Q64为例它的容量是8MB按512字节一个块算总共是16384个块。msc_get_capacity回调的实现int msc_get_capacity_cb(uint8_t lun, uint32_t *block_num, uint32_t *block_size) { *block_num 16384; // 8MB / 512 *block_size 512; return 0; }msc_read回调需要从Flash的指定扇区读取数据int msc_read_cb(uint8_t lun, uint8_t *buf, uint32_t sector, uint32_t count) { uint32_t addr sector * 512; W25Q64_ReadData(addr, buf, count * 512); return 0; }msc_write回调类似但要注意Flash的写入需要先擦除。W25Q64的最小擦除单位是4KB一个扇区而USB MSC的写入单位是512字节。这意味着你不能每次收到512字节就擦除一次那样效率太低而且会缩短Flash寿命。我的做法是在RAM里开一个4KB的缓存当写入的地址跨越4KB边界时先把缓存里的数据擦除并写入Flash然后再处理新的数据。这个逻辑稍微有点复杂但实测下来写入速度可以接受而且Flash的寿命也不会受到太大影响。提示如果你用的是SD卡而不是SPI Flash就不需要考虑擦除的问题SD卡内部有控制器帮你处理。但SD卡的SPI模式读写速度比Flash慢实测在STM32F103上大概只有200KB/s左右。5.3 文件系统的配合与格式化问题MSC设备被电脑识别之后电脑会把它当成一个物理磁盘。如果这个磁盘没有文件系统电脑会提示你格式化。对于SPI Flash来说你可以在固件里预先写入一个FAT文件系统或者让用户第一次使用时在电脑上格式化。我通常的做法是在固件里不预置文件系统让用户自己格式化。这样做的好处是固件简单而且用户可以选择自己需要的文件系统格式FAT32、exFAT等。但缺点是用户第一次使用时需要多一步操作。如果你想让设备一插上就能用可以在固件里实现一个简单的FAT32格式化功能在msc_init回调里检查Flash的前几个扇区如果没有有效的文件系统就自动格式化。不过这个功能实现起来比较繁琐需要你了解FAT32的文件系统结构。对于大多数应用场景来说让用户手动格式化一次是可以接受的。6. 枚举失败与读写异常的排查实录6.1 电脑识别不到设备的排查链路枚举失败是最常见的问题表现是插上USB线之后电脑没有任何反应或者提示未知USB设备。排查这个问题我通常按照以下顺序进行第一步检查硬件连接。USB的D和D-线有没有接反STM32F103的USB外设对D和D-的极性是固定的接反了肯定枚举不了。另外D线上需要一个1.5kΩ的上拉电阻到3.3V有些开发板已经集成了这个电阻有些没有。如果没有这个上拉电阻主机不会检测到设备插入。第二步检查时钟配置。用示波器或者逻辑分析仪测量USB时钟引脚PA8如果配置了MCO输出的话确认是不是48MHz。如果没有示波器可以写一段代码让USB时钟输出到MCO引脚然后用频率计测量。第三步检查中断配置。确认USB中断已经使能并且中断服务函数里调用了USBD_IRQHandler。可以在中断服务函数里翻转一个GPIO用示波器看有没有中断产生。第四步检查描述符。如果设备能被识别但提示未知USB设备通常是描述符有问题。可以用USB抓包工具比如Wireshark配合USBPcap抓取枚举过程中的数据看看主机请求了什么描述符设备返回了什么。6.2 读写文件时出现卡顿或错误的处理如果设备能正常识别但读写文件时出现卡顿、速度慢或者报错通常是以下几个原因一是端点缓冲区大小设置不当。STM32F103的USB外设的端点缓冲区是共享的512字节如果多个端点的缓冲区加起来超过了512字节就会导致数据错乱。检查usb_config.h里的端点配置确保总大小不超过512字节。二是MSC回调函数的执行时间过长。前面提到过MSC的读写回调是在主循环里执行的如果单次读写操作耗时太长会导致主机端的超时。对于SPI Flash来说擦除一个4KB扇区需要几十毫秒这个时间是可以接受的但如果你在回调里做了其他耗时操作比如打印调试信息就可能导致超时。三是文件系统的缓存策略。电脑在读写U盘时会使用自己的缓存策略有时候你看到文件已经写入了但实际数据还在电脑的缓存里没有真正写到设备上。这种情况下需要在电脑上执行安全弹出操作确保所有数据都写入了设备。6.3 一个容易被忽略的STALL问题在调试MSC设备时我遇到过一个比较奇怪的问题设备能识别也能看到盘符但打开盘符时提示设备未就绪。用抓包工具分析后发现主机发送了一个READ CAPACITY命令设备返回了STALL。排查后发现原因是msc_get_capacity回调返回了错误的值。我在回调里返回的块数量是0导致主机认为设备容量为0进而认为设备未就绪。修正回调的返回值之后问题就解决了。这个问题的教训是MSC的各个回调函数一定要返回正确的值特别是msc_get_capacity。如果这个回调返回错误主机就不会继续发送读写命令设备看起来就像未就绪一样。7. 实测性能与优化空间7.1 读写速度的实测数据在STM32F10372MHz主频上用W25Q64作为存储介质实测的读写速度如下操作类型速度备注顺序读取约350KB/sSPI时钟18MHz顺序写入约120KB/s包含擦除时间随机读取约300KB/s受SPI协议开销影响随机写入约80KB/s擦除操作占主要时间这个速度对于大多数数据导出场景来说是够用的。如果你需要更快的速度可以考虑以下优化方向一是提高SPI时钟频率。W25Q64支持最高80MHz的SPI时钟但STM32F103的SPI外设最高只能到36MHzAPB2时钟72MHz的二分频。实际使用中18MHz是比较稳定的配置再高可能会出现数据错误。二是使用DMA传输。STM32F103的SPI支持DMA用DMA来搬运数据可以减少CPU占用提高吞吐量。不过CherryUSB的MSC回调是同步的你需要把DMA传输改成异步方式这会增加一些复杂度。三是使用SD卡代替SPI Flash。SD卡的读写速度比SPI Flash快而且不需要擦除操作。但SD卡的SPI模式速度也受限于SPI时钟如果你用SDIO模式STM32F103不支持SDIO只有STM32F103VC以上的型号才有速度会更快。7.2 内存占用的优化CherryUSB在STM32F103上的内存占用主要包括USB协议栈核心代码约8KB FlashMSC类驱动约4KB Flash端点缓冲区512字节USB外设专用SRAM数据缓冲区根据配置通常1-2KB RAM对于STM32F103C8T664KB Flash20KB RAM来说这个占用是完全可接受的。但如果你用的是STM32F103C6T632KB Flash10KB RAM就需要仔细规划了。主要的优化空间在于减少缓冲区的大小和数量以及裁剪不需要的USB类驱动。在usb_config.h里你可以通过关闭不需要的功能来减少代码体积#define CONFIG_USBDEV_MSC 1 #define CONFIG_USBDEV_CDC 0 #define CONFIG_USBDEV_HID 0 #define CONFIG_USBDEV_AUDIO 0只保留MSC功能可以节省不少Flash空间。7.3 稳定性方面的几个经验在实际项目中USB设备的稳定性比速度更重要。我总结了几个提高稳定性的经验第一在USB中断处理函数里尽量不要调用printf之类的调试输出函数。这些函数执行时间长会阻塞中断导致USB通信超时。如果一定要调试可以用GPIO翻转或者把调试信息缓存到RAM里在主循环里再输出。第二MSC的读写回调里要做好错误处理。如果SPI Flash读取失败要返回错误码CherryUSB会把对应的端点设置为STALL主机端会收到错误提示。如果不返回错误码而是返回0主机可能会认为数据正确导致文件损坏。第三在设备拔出时做好清理工作。CherryUSB提供了拔出检测的回调你可以在回调里保存未写入的数据避免数据丢失。不过对于MSC设备来说只要用户正常安全弹出数据就不会丢失。8. 从MSC延伸到其他USB类的思路MSC只是CherryUSB支持的众多USB类之一。如果你已经跑通了MSC想尝试其他功能CherryUSB的架构让这件事变得很简单。比如CDC虚拟串口类的移植和MSC非常类似只需要换一个类驱动然后实现CDC的回调函数就行。CDC的回调比MSC更简单主要是数据收发struct usbd_cdc_cb { void (*cdc_rx)(uint8_t *buf, uint32_t len); void (*cdc_tx_done)(void); };你只需要在cdc_rx回调里处理接收到的数据在需要发送数据时调用usbd_cdc_send就行。实测在STM32F103上CDC的收发速度可以到500KB/s以上比MSC的读写速度快不少。如果你想同时支持MSC和CDC也就是做一个复合设备CherryUSB也支持。需要在描述符里配置多个接口然后在初始化时分别注册两个类的回调。复合设备的枚举过程稍微复杂一些但CherryUSB已经帮你处理好了大部分细节。另一个有意思的方向是HID类。用HID类可以做自定义的USB设备不需要安装驱动就能被电脑识别。比如你可以做一个USB游戏手柄、USB键盘、或者自定义的数据采集设备。HID的报告描述符需要你自己定义这部分稍微有点门槛但CherryUSB的示例里有现成的模板可以参考。我在实际项目中的体会是CherryUSB最大的价值在于它的可移植性。你在一颗芯片上跑通的代码换到另一颗芯片上只需要改移植层的几个函数上层逻辑完全不用动。这对于需要跨平台的产品开发来说能省下大量的时间。而且它的代码结构清晰出了问题容易定位不像某些商业协议栈那样是个黑盒子。
返回列表