1. 从一根Type-C线说起:为什么要在ESP32-P4上折腾USB读卡器
手里攥着一块ESP32-P4开发板,板子上插着一张TF卡,电脑端只连了一根Type-C线——没有读卡器,没有额外的USB转TTL模块,甚至没有串口终端。这时候如果能让电脑直接把这块板子识别成一个U盘,把TF卡里的文件拖出来,那开发调试的效率会提升一大截。这就是USB读卡器(Slave)实验要解决的核心问题:让ESP32-P4作为USB设备端,把本地的存储介质(TF卡、SPI Flash等)通过USB Mass Storage协议暴露给主机,主机端无需安装任何驱动就能像操作普通U盘一样读写。
这个实验涉及几个关键技术点:ESP32-P4的USB OTG外设配置、TinyUSB协议栈的MSC(Mass Storage Class)设备类实现、SDMMC/SPI接口的存储介质驱动、以及ESP-IDF中USB设备栈的初始化流程。适合已经跑通过GPIO和串口基础实验、想进一步理解USB设备开发的中级开发者。如果你之前只用过ESP32系列的串口下载和WiFi功能,对USB Device这一块还比较陌生,那这个实验正好是一个很好的切入点——它不像USB HID那样需要处理复杂的人机交互协议,也不像USB Audio那样涉及实时数据流,MSC类的协议相对规整,调试起来有明确的抓手。
我在实际跑这个实验的时候,前后大概花了两个下午才把主机端稳定识别的问题解决掉。中间踩的坑主要集中在描述符配置和SD卡初始化时序上,这些后面会详细展开。先给一个整体判断:这个实验的代码量不大,但涉及的知识面比较宽,从USB协议基础到存储驱动都要碰一点,属于典型的“麻雀虽小五脏俱全”型实验。
2. USB Mass Storage协议到底在做什么
2.1 主机眼里的“U盘”是怎么被枚举出来的
当ESP32-P4通过USB线插入电脑时,主机端首先发起的是枚举过程。这个过程本质上是一问一答:主机问“你是什么设备”,设备通过描述符回答“我是Mass Storage设备,支持SCSI透明命令集,最大传输块512字节”。整个枚举流程大致如下:
- 主机检测到USB总线上的设备接入,发送GET_DESCRIPTOR请求获取设备描述符的前8个字节;
- 根据返回的bMaxPacketSize0字段,主机确定控制传输的最大包长,然后再次请求完整的18字节设备描述符;
- 主机读取配置描述符集合,其中包括配置描述符、接口描述符、端点描述符,对于MSC设备还可能有类特定描述符;
- 主机根据描述符中的VID/PID加载对应的驱动(Windows下是USBSTOR,Linux下是usb-storage),然后发送SCSI命令进行后续操作。
这里有一个容易被忽略的细节:ESP32-P4的USB OTG外设支持Device和Host两种模式,在Device模式下,内部PHY的配置直接影响枚举的成功率。如果PHY的时钟配置不对,主机可能连设备描述符都读不到,表现为“未知USB设备”或者干脆没反应。
2.2 MSC类的BOT传输协议
MSC类最常用的传输协议是BOT(Bulk-Only Transport),它只使用两个批量端点:一个Bulk IN端点用于设备向主机发送数据,一个Bulk OUT端点用于主机向设备发送数据。所有的SCSI命令、数据、状态都通过这两个端点传输,控制端点只用于标准的USB请求。
BOT协议的核心是CBW(Command Block Wrapper)和CSW(Command Status Wrapper)这一对结构体。主机发送CBW,里面包含SCSI命令码和参数;设备执行后返回CSW,里面包含命令执行状态和剩余数据量。整个过程像是一个严格的握手协议:
- 主机发CBW(31字节固定格式);
- 设备根据CBW中的方向位决定是接收数据还是发送数据;
- 设备发送CSW(13字节)报告执行结果。
TinyUSB协议栈已经把CBW/CSW的解析和封装都做好了,开发者只需要实现SCSI命令的回调函数即可。但理解这个流程对于调试非常重要——当主机端提示“设备未就绪”或者“I/O错误”时,往往就是某个SCSI命令的响应不符合预期。
2.3 SCSI命令集中最关键的几条
MSC设备需要响应一系列SCSI命令,其中必须实现的有:
| 命令码 | 命令名称 | 作用 |
|---|---|---|
| 0x00 | TEST UNIT READY | 检查设备是否就绪 |
| 0x12 | INQUIRY | 返回设备厂商、型号等信息 |
| 0x1A | MODE SENSE(6) | 返回设备参数 |
| 0x1B | START STOP UNIT | 启动/停止介质 |
| 0x23 | READ FORMAT CAPACITIES | 返回格式化容量 |
| 0x25 | READ CAPACITY(10) | 返回介质容量和块大小 |
| 0x28 | READ(10) | 读取数据块 |
| 0x2A | WRITE(10) | 写入数据块 |
| 0x03 | REQUEST SENSE | 返回错误详情 |
其中READ CAPACITY和READ(10)/WRITE(10)是数据读写的核心。READ CAPACITY返回的最后一个LBA和块大小决定了主机端看到的“U盘”容量。如果这个值返回错误,主机端可能显示容量为0或者直接拒绝挂载。
3. 在ESP-IDF里把TinyUSB的MSC设备跑起来
3.1 工程配置里必须打开的开关
在ESP-IDF的menuconfig中,有几个配置项直接决定USB MSC能否正常工作:
- Component config → USB OTG → Enable USB OTG:打开USB OTG外设支持;
- Component config → TinyUSB → Enable TinyUSB:启用TinyUSB协议栈;
- Component config → TinyUSB → MSC class:勾选Mass Storage Class支持;
- Component config → FatFs → Enable FAT filesystem support:因为TF卡上通常使用FAT文件系统。
还有一个容易被遗漏的配置:USB OTG的PHY选择。ESP32-P4支持内部PHY和外部PHY两种模式,如果使用板载的Type-C接口,通常选择内部PHY即可。但如果你的板子使用了外部USB PHY芯片,就需要在配置中切换到对应的选项,否则枚举会失败。
另外,FreeRTOS的tick rate建议保持在1000Hz,TinyUSB的任务调度对时基有一定要求,tick rate过低可能导致USB传输超时。
3.2 描述符配置:VID/PID和字符串的坑
TinyUSB的MSC设备描述符在tusb_config.h和usb_descriptors.c中定义。VID和PID可以自定义,但建议不要使用已经被广泛占用的值,否则Windows可能自动加载不匹配的驱动。我在测试时用了一个比较冷门的PID,结果Windows直接识别为“未知设备”,后来换成常见的测试PID(如0x1234)就正常了。
字符串描述符需要特别注意编码格式。TinyUSB要求字符串描述符使用UTF-16LE编码,如果直接填ASCII字符串,主机端可能显示乱码或者枚举失败。在usb_descriptors.c中,字符串描述符的生成通常通过宏TUSB_DESC_STRING来完成,确保每个字符都正确转换为双字节。
// 字符串描述符示例(UTF-16LE) const uint16_t desc_str[] = { 0x0409, // 语言ID 'E', 'S', 'P', '3', '2', '-', 'P', '4', };3.3 SD卡初始化和MSC回调的衔接
MSC设备的读写最终要落到实际的存储介质上。ESP32-P4通过SDMMC接口连接TF卡,初始化流程大致是:
- 配置SDMMC的时钟、引脚和总线宽度;
- 调用
esp_vfs_fat_sdmmc_mount挂载FAT文件系统; - 获取SD卡的信息(扇区大小、扇区数量);
- 在MSC的读写回调中,直接调用
sdmmc_read_sectors和sdmmc_write_sectors进行扇区级读写。
这里有一个关键点:MSC协议是扇区级的,不经过文件系统。也就是说,主机端发送READ(10)命令时,设备需要直接读取指定LBA的原始扇区数据,而不是通过文件路径去读文件。所以MSC回调中不能使用fopen/fread,必须使用底层的扇区读写接口。
// MSC读回调示例 static int32_t msc_read_cb(uint32_t lba, void* buffer, uint32_t bufsize) { esp_err_t ret = sdmmc_read_sectors(card, buffer, lba, bufsize / 512); if (ret != ESP_OK) { return -1; } return bufsize; }如果TF卡上已经挂载了FAT文件系统,同时又要通过MSC暴露给主机,需要注意文件系统的一致性问题。主机端写入数据后,ESP32-P4这边的FATFS缓存可能还是旧的,直接读取会得到错误的内容。解决办法是在MSC写入回调中调用esp_vfs_fat_sdmmc_unmount再重新挂载,或者使用f_sync强制刷新缓存。我在实际测试中采用了更简单的方案:MSC实验期间不挂载FATFS,只做裸扇区读写,主机端格式化后再挂载。
4. 主机端识别不稳定?先查这五个地方
4.1 枚举失败的典型现象和排查顺序
主机端识别不稳定通常表现为以下几种现象:
- 插入后完全无反应,设备管理器中没有新设备出现;
- 出现“未知USB设备(设备描述符请求失败)”;
- 识别为U盘但容量显示为0或者无法访问;
- 能识别但读写过程中频繁断开。
排查顺序建议从硬件到软件逐层推进:
- 检查USB线缆:有些Type-C线只供电不传数据,换一根确认支持数据传输的线;
- 检查PHY配置:确认menuconfig中的PHY选项与硬件匹配;
- 检查描述符:用USB分析仪或者Wireshark的USB抓包功能查看枚举过程中的描述符返回是否正确;
- 检查SD卡初始化:如果SD卡初始化失败,MSC回调会返回错误,主机端表现为“设备未就绪”;
- 检查电源:ESP32-P4在USB Device模式下,VBUS检测引脚的电平状态会影响USB控制器的启动。
4.2 描述符请求失败的三种常见原因
描述符请求失败是枚举阶段最常见的问题,根据我的调试经验,主要有三类原因:
第一类是端点缓冲区配置不当。TinyUSB默认的端点缓冲区大小可能不够,特别是当配置描述符集合超过64字节时,控制传输需要分多次完成。如果CFG_TUD_ENDPOINT0_SIZE设置得太小,主机读取配置描述符时会超时。
第二类是字符串描述符索引错误。设备描述符中的iManufacturer、iProduct、iSerialNumber字段是字符串描述符的索引,如果这些索引指向了不存在的字符串描述符,主机在请求字符串时会得到STALL响应,导致枚举中断。
第三类是PID/VID冲突。某些VID/PID组合在Windows下有内置驱动,如果设备返回的描述符与驱动预期的不匹配,驱动加载会失败。建议使用冷门的VID/PID组合,或者在Linux下先验证枚举是否正常。
4.3 读写过程中的超时和断连
如果枚举成功但读写时频繁断连,重点检查以下几个方面:
- SD卡的读写速度:某些低速TF卡在连续读写时响应时间过长,导致USB传输超时。可以在MSC回调中加入超时重试机制;
- 任务优先级:TinyUSB的任务(
tusb_task)需要定期调用,如果被其他高优先级任务阻塞,USB传输会超时。建议将TinyUSB任务放在一个独立的FreeRTOS任务中,优先级设置为中等偏上; - DMA缓冲区对齐:ESP32-P4的SDMMC接口使用DMA传输时,缓冲区地址需要4字节对齐。如果MSC回调中传入的buffer地址未对齐,可能导致DMA传输失败。
// 确保DMA缓冲区对齐 WORD_ALIGNED_ATTR static uint8_t msc_buffer[512];5. 从能用到好用:几个提升稳定性的实操技巧
5.1 扇区缓存与写入合并
主机端在写入小文件时,可能会产生大量零散的扇区写操作。如果每次WRITE(10)都直接写SD卡,不仅速度慢,还会加速卡的老化。一个实用的优化是在MSC回调中实现简单的写入合并:维护一个扇区缓存,当连续的LBA写入时先缓存,积累到一定数量或者收到SYNCHRONIZE CACHE命令时再统一写入。
TinyUSB的MSC类支持CFG_TUD_MSC_EP_BUFSIZE配置,适当增大这个值可以减少USB传输次数。但注意不要超过SD卡的块大小限制,通常512字节的倍数比较安全。
5.2 热插拔检测与状态恢复
USB设备的热插拔是常态,但ESP32-P4在USB断开后需要正确重置MSC状态,否则重新插入时主机可能无法识别。TinyUSB提供了tud_mount_cb和tud_umount_cb回调,可以在这两个回调中处理SD卡的重新初始化和状态清理。
void tud_umount_cb(void) { // USB断开时,停止SD卡访问,清理缓存 msc_ready = false; } void tud_mount_cb(void) { // USB重新连接时,重新初始化SD卡 msc_ready = true; }如果不在umount回调中停止SD卡访问,可能会出现USB已经断开但SD卡还在读写的情况,导致数据不一致。
5.3 主机端格式化后的重新挂载
主机端对“U盘”进行格式化后,分区表和文件系统结构会发生变化。如果ESP32-P4这边还保持着旧的FATFS挂载信息,后续的读写会出错。解决办法是在MSC的WRITE(10)回调中检测是否写入了MBR或DBR扇区(通常是LBA 0和LBA 1),如果检测到,就触发一次重新挂载。
这个逻辑在实际项目中非常有用,因为用户很可能在电脑上直接格式化U盘,然后期望设备端能立即使用新的文件系统。
6. 调试工具和验证方法
6.1 用Wireshark抓USB包看枚举流程
Wireshark配合USBPcap可以在Windows下抓取USB通信数据。对于MSC设备,重点看以下几个阶段:
- 枚举阶段:GET_DESCRIPTOR请求和响应,确认描述符内容是否正确;
- SCSI命令阶段:CBW和CSW的对应关系,确认每条命令是否得到正确响应;
- 数据传输阶段:READ(10)和WRITE(10)的数据方向和数据量。
如果CSW中的状态字节是0x01(命令失败),可以进一步查看REQUEST SENSE命令返回的Sense Key和ASC/ASCQ,这些代码能精确定位失败原因。
6.2 Linux下的dmesg和lsusb
在Linux环境下调试更方便,dmesg会输出USB设备的枚举日志,包括设备描述符、配置描述符和驱动加载信息。lsusb -v可以查看设备的详细描述符。如果设备识别为/dev/sdX,可以用dd命令直接测试扇区读写:
# 读取第一个扇区 sudo dd if=/dev/sdX of=/tmp/sector0.bin bs=512 count=1 # 写入测试数据 sudo dd if=/dev/zero of=/dev/sdX bs=512 count=16.3 用f_mount验证文件系统一致性
在ESP32-P4端,可以通过f_mount和f_open验证主机端写入的数据是否能被正确读取。如果主机端写入了文件但设备端读不到,说明FATFS缓存没有刷新。这时候可以调用f_sync或者重新挂载来强制刷新。
7. 这个实验还能往哪些方向扩展
USB读卡器实验本身是一个基础实验,但它的框架可以扩展到很多实际应用场景。比如把SPI Flash的某个分区通过MSC暴露给主机,实现“配置分区”的拖拽更新;或者结合USB HID,让设备同时作为键盘和读卡器工作;再或者把MSC和CDC组合成复合设备,一根USB线同时提供串口和U盘功能。
TinyUSB支持复合设备配置,只需要在tusb_config.h中同时启用MSC和CDC的宏定义,然后在描述符中增加对应的接口即可。复合设备的枚举过程比单功能设备复杂一些,主要是配置描述符集合会变长,需要确保端点缓冲区足够大。
我在实际项目中用复合设备做过一个数据采集器:设备通过CDC上报传感器数据,同时通过MSC把历史数据以CSV文件的形式暴露给主机。这样用户既可以在串口终端看实时数据,也可以直接拖拽CSV文件做离线分析,体验比单纯用串口好很多。
注意:复合设备中不同接口的端点地址不能冲突,MSC通常使用Bulk端点,CDC使用Interrupt端点,分配时需要仔细规划。
最后分享一个我在调试USB MSC时总结的小经验:每次修改描述符后,一定要在主机端彻底删除旧设备记录再重新插入。Windows会缓存USB设备的描述符信息,如果只是拔插而不清理缓存,可能仍然使用旧的描述符数据,导致调试结果不可信。在设备管理器中勾选“显示隐藏的设备”,然后删除对应的USB设备记录,再重新插入,才能确保枚举过程是全新的。