ARTICLE DETAIL

资讯详情

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

ESP32-S3 USB复合设备实战:TinyUSB实现U盘+虚拟串口双功能

ESP32-S3 USB复合设备实战:TinyUSB实现U盘+虚拟串口双功能 1. 为什么要在ESP32-S3上折腾USB复合设备第一次接触ESP32-S3的USB功能时我的需求其实很朴素板子通过USB线插到电脑上既能像U盘一样拖拽文件进去又能同时开一个串口终端看日志。听起来像是两个独立的功能但问题在于ESP32-S3只有一个原生USB接口GPIO19/20如果按传统思路要么把它枚举成Mass Storage设备要么枚举成CDC串口设备二选一。这个限制在实际项目中非常致命。比如你做的是一个数据采集器现场部署时希望运维人员插上USB就能导出CSV日志文件同时开发阶段又需要串口来调试。如果每次切换功能都要重新烧录固件那效率低到没法接受。TinyUSB这个开源协议栈的出现改变了局面它支持复合设备Composite Device也就是一个物理USB接口同时向主机声明多个接口Interface每个接口对应一种功能。主机操作系统会分别加载对应的驱动最终在设备管理器里看到两个独立的设备节点。ESP32-S3的USB-OTG外设配合TinyUSB协议栈实现U盘虚拟串口双功能在技术上是完全可行的。但网上的资料要么只讲MSCMass Storage Class要么只讲CDCCommunication Device Class把两者合在一起的完整配置流程少之又少。我在实际调试过程中踩了不少坑比如端点资源分配冲突、描述符配置错误导致枚举失败、文件系统挂载时机不对等等。这篇文章就把整个流程从头到尾梳理一遍包括描述符怎么写、端点怎么分配、文件系统怎么挂载、以及那些文档里不会告诉你的注意事项。注意本文基于ESP-IDF v5.x环境和TinyUSB组件编写不同版本API可能有差异建议先确认你的开发环境版本。适合阅读这篇文章的读者包括有一定ESP32开发基础、了解基本USB概念的嵌入式工程师正在做USB设备开发、需要多接口复合方案的产品开发者以及对TinyUSB协议栈感兴趣、想深入理解USB枚举过程的技术爱好者。即使你之前没接触过USB协议栈跟着步骤走也能跑通。2. 整体方案设计与核心技术选型2.1 为什么选TinyUSB而不是ESP-IDF自带的USB栈ESP-IDF其实提供了两套USB方案一套是esp_tinyusb组件对TinyUSB的封装另一套是较老的usb_device栈。我选择TinyUSB的原因很直接——它对复合设备的支持更成熟描述符配置更灵活社区活跃度高遇到问题容易找到参考。TinyUSB的架构分为设备栈和主机栈两部分我们这里只用到设备栈。它的核心思想是类驱动Class Driver分离MSC类驱动负责处理SCSI命令和存储介质访问CDC类驱动负责串口数据收发两者互不干扰通过USB协议栈的核心层统一管理端点资源和描述符。相比之下ESP-IDF自带的USB栈在复合设备场景下配置起来更繁琐而且文档相对分散。TinyUSB的tusb_config.h文件把所有配置集中在一处改起来一目了然。2.2 复合设备的描述符结构设计USB复合设备的描述符结构比单一功能设备复杂得多。简单来说描述符是一棵层级树设备描述符Device Descriptor整个设备的全局信息包括VID、PID、设备类代码等。复合设备这里通常把bDeviceClass设为0xEFMiscellaneousbDeviceSubClass设为0x02Common ClassbDeviceProtocol设为0x01Interface Association Descriptor表示这是一个使用IAD的多接口设备。配置描述符Configuration Descriptor描述一个配置下的所有接口包含总长度、接口数量、供电方式等。接口关联描述符IAD这是复合设备的关键。它告诉主机“接下来的两个接口属于同一个功能”。比如CDC功能需要两个接口控制接口数据接口IAD把它们绑在一起主机才知道这是一个完整的CDC设备。接口描述符Interface Descriptor每个接口独立描述自己的类、子类、协议和端点数量。端点描述符Endpoint Descriptor描述每个端点的地址、类型控制/批量/中断/同步、方向IN/OUT、最大包大小和轮询间隔。对于U盘虚拟串口的组合我们需要功能接口数量端点需求类代码MSCU盘1个接口1个IN端点 1个OUT端点批量0x08CDC串口2个接口控制数据1个中断IN端点 1个批量IN端点 1个批量OUT端点0x02总共需要3个接口、5个端点。ESP32-S3的USB-OTG外设支持6个端点EP0除外所以资源是够用的但分配时需要仔细规划避免地址冲突。2.3 端点资源分配策略端点地址在USB协议中是7位编码最高位表示方向1为IN0为OUT。ESP32-S3的端点编号从1到6每个编号可以配置为IN或OUT但不能同时用作两个方向。我的分配方案是这样的EP1 INMSC的批量IN端点用于向主机发送存储数据EP2 OUTMSC的批量OUT端点用于接收主机写入的数据EP3 INCDC的中断IN端点用于发送串口状态通知如线路状态变化EP4 INCDC的批量IN端点用于向主机发送串口数据EP5 OUTCDC的批量OUT端点用于接收主机发来的串口数据EP6留空备用。这个分配方案的好处是MSC和CDC的端点完全分开不会互相干扰。批量端点最大包大小设为64字节全速USB中断端点设为8或16字节即可。提示端点最大包大小不能随便设。全速USB的批量端点最大包大小固定为8/16/32/64字节高速USB才是512字节。ESP32-S3的USB-OTG支持全速和高速两种模式但大多数开发板默认跑全速所以按64字节配置就行。3. 开发环境搭建与关键配置项3.1 ESP-IDF环境准备和TinyUSB组件引入先确认你的ESP-IDF版本。打开终端执行idf.py --version如果版本低于v5.0建议升级。TinyUSB组件在ESP-IDF v5.x中已经作为官方组件提供可以通过组件管理器直接引入。在你的项目目录下创建idf_component.ymldependencies: espressif/esp_tinyusb: ^1.0.0然后执行idf.py reconfigure组件会自动下载到managed_components目录。如果你用的是较老版本的ESP-IDF也可以手动把TinyUSB源码克隆到components目录下但那样需要自己处理编译配置比较麻烦。我实测下来用组件管理器的方式最省心版本管理也清晰。唯一需要注意的是esp_tinyusb组件对ESP-IDF的最低版本有要求v5.0以下可能会编译报错。3.2 menuconfig中必须修改的选项进入idf.py menuconfig有几个关键配置必须改Component config → TinyUSB Stack →TinyUSB task stack size默认4096字节建议改成8192因为复合设备处理描述符和类请求时栈消耗更大。TinyUSB task priority保持默认5即可除非你的应用对USB响应实时性要求极高。Enable TinyUSB CDC勾选。Enable TinyUSB MSC勾选。Component config → USB-OTG →USB OTG Mode选择Device模式。USB OTG PHY选择Internal PHY使用芯片内置的USB PHY。Component config → FAT Filesystem support →FATFS相关选项保持默认即可但如果你要用长文件名需要把Long filename support打开。这些配置改完之后保存退出。我踩过的一个坑是忘记开MSC或CDC的编译开关结果代码里调用tusb_msc相关函数时链接报错排查了半天才发现是menuconfig里没勾选。3.3 分区表和文件系统规划U盘功能需要一个存储介质来承载文件系统。ESP32-S3通常用外部SPI Flash或SD卡作为存储介质。我这里以SPI Flash上的FAT分区为例。在partitions.csv中定义一个FAT分区# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x200000, storage, data, fat, 0x210000,0x100000,这里storage分区大小设为1MB类型为data子类型为fat。这个分区会被格式化为FAT文件系统主机通过U盘功能访问的就是这个分区。注意分区大小要根据实际需求调整。如果你要存大量日志文件1MB可能不够。但也不能太大因为SPI Flash总容量有限app分区也要留足空间。文件系统挂载的时机很关键。必须在TinyUSB初始化之前完成FATFS挂载否则主机枚举MSC设备时读取容量信息会失败。我的做法是在app_main开头就调用esp_vfs_fat_spiflash_mount挂载成功后再初始化TinyUSB。4. 核心代码实现与关键环节拆解4.1 描述符配置复合设备的重中之重描述符配置是复合设备最容易出错的地方。TinyUSB提供了TUD_CONFIG_DESCRIPTOR、TUD_MSC_DESCRIPTOR、TUD_CDC_DESCRIPTOR等宏来简化描述符定义。但复合设备需要手动组合这些描述符并正确设置接口编号和端点地址。先看配置描述符的总长度和接口数量#define ITF_NUM_MSC 0 #define ITF_NUM_CDC 1 #define ITF_NUM_CDC_DATA 2 #define ITF_NUM_TOTAL 3 #define EPNUM_MSC_OUT 0x02 #define EPNUM_MSC_IN 0x81 #define EPNUM_CDC_NOTIF 0x83 #define EPNUM_CDC_OUT 0x04 #define EPNUM_CDC_IN 0x84注意端点地址的写法0x81表示EP1 IN0x02表示EP2 OUT。最高位是方向位低4位是端点编号。配置描述符数组这样写uint8_t const desc_configuration[] { // 配置描述符接口数3配置编号1字符串索引0属性0x80电流250mA TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x80, 250), // MSC接口描述符 两个批量端点 TUD_MSC_DESCRIPTOR(ITF_NUM_MSC, 0, EPNUM_MSC_OUT, EPNUM_MSC_IN, 64), // CDC接口关联描述符 控制接口 数据接口 TUD_CDC_DESCRIPTOR(ITF_NUM_CDC, 0, EPNUM_CDC_NOTIF, 8, EPNUM_CDC_OUT, EPNUM_CDC_IN, 64), };TUD_CDC_DESCRIPTOR宏会自动生成IAD描述符把控制接口和数据接口关联起来。这个宏的参数依次是控制接口编号、字符串索引、通知端点地址、通知端点大小、数据OUT端点地址、数据IN端点地址、数据端点大小。CONFIG_TOTAL_LEN需要根据实际描述符长度计算。可以用TUD_CONFIG_DESC_LEN TUD_MSC_DESC_LEN TUD_CDC_DESC_LEN来算但更稳妥的做法是让编译器自动计算#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN TUD_MSC_DESC_LEN TUD_CDC_DESC_LEN)我一开始手动算长度结果漏算了IAD描述符的8个字节导致主机枚举时报告配置描述符长度错误。后来改用宏自动计算就没再出过问题。4.2 MSC类驱动实现让主机认出U盘MSC类驱动需要实现几个回调函数TinyUSB通过它们来读写存储介质// 读取存储介质 int32_t msc_read_cb(uint32_t lba, void* buffer, uint32_t bufsize) { // lba是逻辑块地址每个块512字节 esp_partition_read(storage_partition, lba * 512, buffer, bufsize); return bufsize; } // 写入存储介质 int32_t msc_write_cb(uint32_t lba, uint8_t* buffer, uint32_t bufsize) { esp_partition_write(storage_partition, lba * 512, buffer, bufsize); return bufsize; } // 同步刷新缓存 void msc_flush_cb(void) { // SPI Flash不需要额外同步操作 }这里用esp_partition_read/write直接操作分区比通过文件系统层更底层效率更高。但要注意主机写入数据后FAT文件系统的元数据可能还在缓存中需要调用f_sync或重新挂载才能看到最新文件。初始化MSC类驱动的代码tinyusb_config_t tusb_cfg { .device_descriptor NULL, // 使用默认设备描述符 .string_descriptor NULL, .external_phy false, .configuration_descriptor desc_configuration, }; tinyusb_config_msc_t msc_cfg { .callback_msc_read msc_read_cb, .callback_msc_write msc_write_cb, .callback_msc_flush msc_flush_cb, }; tinyusb_driver_install(tusb_cfg); tinyusb_msc_init(msc_cfg);4.3 CDC类驱动实现虚拟串口的收发逻辑CDC类驱动相对简单TinyUSB已经封装好了大部分逻辑。你只需要在初始化时配置好回调tinyusb_config_cdc_t cdc_cfg { .cdc_port TINYUSB_CDC_ACM_0, .callback_rx cdc_rx_callback, .callback_rx_wanted_char NULL, .callback_line_state_changed NULL, .callback_line_coding_changed NULL, }; tinyusb_cdc_init(cdc_cfg);cdc_rx_callback在主机发来数据时被调用void cdc_rx_callback(int itf, cdcacm_event_t *event) { uint8_t buf[64]; size_t rx_size 0; esp_tusb_cdcacm_read(itf, buf, sizeof(buf), rx_size); // 处理收到的数据比如回显 esp_tusb_cdcacm_write_queue(itf, buf, rx_size); esp_tusb_cdcacm_write_flush(itf, 0); }发送数据到主机void cdc_send_data(const char* data, size_t len) { esp_tusb_cdcacm_write_queue(TINYUSB_CDC_ACM_0, (const uint8_t*)data, len); esp_tusb_cdcacm_write_flush(TINYUSB_CDC_ACM_0, 0); }提示esp_tusb_cdcacm_write_flush的第二个参数是超时时间单位是FreeRTOS tick设为0表示不等待。如果主机端接收缓冲区满了数据可能会被丢弃。在对可靠性要求高的场景建议设一个非零超时值。4.4 文件系统挂载与U盘数据一致性文件系统挂载必须在TinyUSB初始化之前完成esp_vfs_fat_mount_config_t mount_config { .format_if_mount_failed true, .max_files 5, .allocation_unit_size 4096, }; esp_vfs_fat_spiflash_mount_rw_wl(/usb, storage, mount_config, s_wl_handle);这里用了esp_vfs_fat_spiflash_mount_rw_wl带磨损均衡Wear Leveling功能。SPI Flash的擦写次数有限磨损均衡能延长寿命。数据一致性是个大问题。主机通过U盘写入文件后ESP32端的FATFS缓存可能还没更新。如果此时ESP32程序去读同一个文件可能读到旧数据。解决办法有两种主机端弹出U盘后ESP32端调用f_sync刷新缓存。在MSC的msc_write_cb中每次写入后都调用f_sync。但这样性能很差不推荐。我的做法是在主机端安全弹出后通过CDC串口发送一个命令给ESP32触发文件系统重新挂载。这样既保证了数据一致性又不影响写入性能。5. 实操过程中的常见问题与排查技巧5.1 设备枚举失败从描述符到端点的逐项排查枚举失败是最常见的问题表现为主机完全认不到设备或者设备管理器里出现黄色感叹号。排查思路如下第一步确认硬件连接。ESP32-S3的USB引脚是GPIO19D-和GPIO20D有些开发板引出了这两个引脚但没接USB座需要自己飞线。另外USB线必须是数据线有些充电线只有电源线没有数据线。第二步检查描述符长度。用USB抓包工具如Wireshark配合USBPcap抓取枚举过程看主机请求配置描述符时返回的长度是否正确。如果返回的长度和wTotalLength字段不一致主机会拒绝枚举。第三步检查端点地址冲突。确保每个端点的地址唯一且方向位正确。比如EP1 IN和EP1 OUT不能同时存在因为它们是同一个物理端点。第四步检查接口编号。接口编号必须从0开始连续递增不能跳号。IAD描述符中的bFirstInterface和bInterfaceCount要正确指向被关联的接口。我遇到过一次枚举失败抓包发现主机在获取配置描述符后直接复位了设备。后来发现是IAD描述符的bInterfaceCount写成了1应该是2控制接口数据接口。改成2之后问题解决。5.2 主机识别U盘但无法格式化或写入这种情况通常是MSC回调函数返回值不对。msc_read_cb和msc_write_cb必须返回实际传输的字节数如果返回0或负数主机会认为操作失败。另一个常见原因是存储介质容量报告错误。TinyUSB通过tud_msc_capacity_cb回调获取容量信息void tud_msc_capacity_cb(uint8_t lun, uint32_t* block_count, uint16_t* block_size) { *block_count STORAGE_SIZE / 512; *block_size 512; }如果block_count算错了主机会显示错误的容量格式化时可能报错。确保STORAGE_SIZE和分区实际大小一致。注意FAT文件系统的簇大小和分区大小要匹配。1MB以下的分区用512字节簇1MB到32MB用4KB簇。如果簇大小设置不当格式化会失败。5.3 虚拟串口能识别但收不到数据CDC串口能识别但收不到数据通常是端点配置或回调注册的问题。检查以下几点通知端点EP3 IN的中断间隔是否设置合理。全速USB的中断端点间隔范围是1到255毫秒一般设16或32即可。callback_rx是否正确注册。如果注册为NULL收到数据时不会有任何反应。主机端的串口参数波特率、数据位、停止位是否和CDC配置匹配。CDC ACM的波特率是虚拟的实际上不影响USB传输速度但有些主机端软件会检查这些参数。我遇到过一种情况串口能打开但发送数据后ESP32端收不到。后来发现是esp_tusb_cdcacm_read的调用时机不对——必须在回调函数中读取不能在主循环中轮询。TinyUSB的CDC数据到达是事件驱动的主循环轮询会错过数据。5.4 双功能同时工作时的性能瓶颈MSC和CDC同时工作时USB带宽是共享的。全速USB的理论带宽是12Mbps实际有效带宽大概8Mbps左右。如果U盘正在大量写入数据串口的响应可能会变慢。优化建议降低MSC的写入频率比如主机端攒够一定数据再写入。CDC串口不要用于高速数据传输只用于调试日志和命令交互。如果确实需要高速串口考虑把CDC的批量端点最大包大小设为64字节并减少中断端点的轮询频率。实测下来在U盘写入速度约500KB/s的情况下CDC串口仍然能保持流畅的日志输出延迟在可接受范围内。5.5 常见问题速查表现象可能原因排查方法主机完全认不到设备USB线无数据线芯 / 描述符长度错误换线测试 / USB抓包设备管理器黄色感叹号描述符配置错误 / 端点冲突检查IAD和端点地址U盘能识别但无法格式化MSC回调返回值错误 / 容量报告错误检查回调函数和容量计算串口能打开但无数据回调未注册 / 读取时机错误检查回调注册和读取位置双功能同时工作时卡顿USB带宽不足 / 任务优先级冲突调整任务优先级和传输策略6. 几个容易忽略的细节和实操心得6.1 字符串描述符的坑字符串描述符看起来简单但编码格式有讲究。USB规范要求字符串描述符使用UTF-16LE编码每个字符占2字节。TinyUSB提供了TUD_STRING_DESCRIPTOR宏来处理编码转换但如果你手动构造字符串描述符很容易忘记加BOM或搞错字节序。我的建议是直接用TinyUSB的宏const char* string_desc_arr[] { (const char[]){0x09, 0x04}, // 语言ID英语 My Company, // 制造商 ESP32-S3 Composite, // 产品名 12345678, // 序列号 MSC Interface, // MSC接口字符串 CDC Interface, // CDC接口字符串 };注意第一个字符串是语言ID必须是0x0409英语的UTF-16LE编码。这个不能省否则主机会拒绝获取其他字符串。6.2 任务优先级和栈大小的调整TinyUSB在ESP32上运行在一个独立的任务中默认优先级是5栈大小是4096字节。复合设备场景下描述符处理和类请求的嵌套调用更深4096字节可能不够。我遇到过栈溢出导致的随机崩溃把栈大小改成8192后就稳定了。另外如果你的应用中有其他高优先级任务比如WiFi、蓝牙要注意不要让它们长时间占用CPU否则TinyUSB任务得不到调度USB响应会超时。建议把TinyUSB任务的优先级设为中等偏上既不会被饿死也不会抢占关键任务。6.3 热插拔和重新枚举的处理USB设备支持热插拔但ESP32-S3在USB断开后需要重新初始化TinyUSB栈才能再次枚举。TinyUSB提供了tusb_deinit和tusb_init接口但频繁调用可能导致内存碎片。我的做法是监听USB连接状态变化在断开时挂起MSC和CDC任务在连接时恢复。TinyUSB的tud_mount_cb和tud_umount_cb回调可以用来处理这两个事件void tud_mount_cb(void) { ESP_LOGI(TAG, USB mounted); // 恢复任务 } void tud_umount_cb(void) { ESP_LOGI(TAG, USB unmounted); // 挂起任务刷新文件系统缓存 }提示在tud_umount_cb中一定要调用f_sync刷新文件系统否则主机端可能丢失最后写入的数据。6.4 量产时的VID/PID选择开发阶段可以用TinyUSB默认的VID/PID0x303A/0x4002但量产时必须申请自己的VID/PID。VID需要向USB-IF申请费用不低。如果只是小批量内部使用可以先用默认的但要注意不要和已注册的设备冲突。PID可以自己定义但建议遵循一定的命名规则方便版本管理。比如0x4002是MSCCDC复合设备0x4003是纯CDC设备以此类推。6.5 调试工具的选择和使用USB调试离不开抓包工具。Windows上可以用Wireshark配合USBPcapLinux上直接用usbmon内核模块加Wireshark。抓包能看到完整的枚举过程和类请求交互是排查枚举问题的利器。另外TinyUSB提供了日志输出功能在tusb_config.h中把CFG_TUSB_DEBUG设为2或3可以看到详细的协议栈日志。但日志输出会占用串口带宽调试完成后记得关掉。我个人的习惯是先用日志定位大致范围再用抓包确认具体问题。两者结合大部分USB问题都能在半小时内定位到根因。6.6 关于CDC串口驱动的一个小细节Windows 10及以上版本自带CDC ACM驱动插上就能用。但Windows 7需要手动安装驱动而且对CDC的支持不完整可能会出现无法识别的情况。如果目标用户中有Windows 7用户建议在产品说明中注明或者提供一个INF驱动文件。Linux和macOS对CDC ACM的支持很好即插即用不需要额外驱动。macOS上设备节点是/dev/tty.usbmodem*Linux上是/dev/ttyACM*。这个双功能USB方案我前后调试了大概两周大部分时间花在描述符配置和枚举问题排查上。一旦跑通之后稳定性还是很好的连续运行72小时没有出现掉线或数据丢失。后续如果要做更多功能比如HID键盘或MIDI设备也可以按照同样的思路往复合设备里加接口只要端点资源够用就行。
返回列表