ESP32-P4 USB MSC读卡器开发:TinyUSB与SD卡实战
发布时间:2026/9/20 19:31:58 锦皓数字建站

1. 项目缘起与整体设计思路1.1 为什么要在ESP32-P4上折腾USB读卡器第一次拿到ESP32-P4这块芯片的时候我盯着它的USB OTG接口看了很久。之前用ESP32-S3做USB相关项目要么是CDC虚拟串口要么是HID设备总觉得没把USB的潜力完全榨干。ESP32-P4不一样它原生带了一个高速USB OTG PHY支持USB 2.0 High-Speed480Mbps这意味着它有能力跑一些对带宽有要求的场景——比如把开发板变成一个大容量存储设备让电脑直接读写板子上的SD卡。这个想法听起来简单但实际做起来涉及的东西不少。你要让ESP32-P4在USB总线上扮演一个“设备”的角色而不是“主机”。也就是说当ESP32-P4通过USB线插到电脑上时电脑会认为它是一个U盘而不是一个串口设备。这背后需要实现USB Mass Storage ClassMSC协议让ESP32-P4能够响应电脑发来的SCSI命令把SD卡上的数据块读出来或者写进去。我选择做这个实验的原因很直接在很多嵌入式项目里设备需要把采集到的数据存到SD卡上然后用户想把SD卡拔下来插到电脑上拷数据。但如果设备本身就能通过USB线直接当读卡器用那用户就不需要拔卡了插上USB线就能访问存储内容。这个功能在数据采集器、行车记录仪、工业记录仪这类产品上非常实用。1.2 方案选型TinyUSB还是乐鑫原生USB栈ESP-IDF里面其实有两套USB协议栈可选。一套是乐鑫自己维护的USB Host/Device栈另一套是TinyUSB。TinyUSB是一个开源跨平台的USB协议栈乐鑫把它移植到了ESP32系列芯片上并且做了深度适配。我最终选了TinyUSB原因有几个。第一TinyUSB的MSC设备类实现非常成熟代码结构清晰你要改什么东西很容易找到地方。第二TinyUSB的社区活跃遇到问题查资料方便。第三乐鑫在ESP-IDF里对TinyUSB的封装做得不错用起来不复杂。当然选TinyUSB也有代价。它的抽象层比较多如果你要深入定制一些非标准行为需要理解它的内部机制。但对于MSC读卡器这个场景来说标准协议已经足够不需要魔改。1.3 整体架构拆解整个系统的数据流是这样的电脑通过USB线发送SCSI读命令ESP32-P4的USB设备控制器收到数据后TinyUSB的MSC类驱动解析命令然后调用我们注册的存储回调函数回调函数再去操作SD卡驱动把数据读出来返回给TinyUSB最后通过USB端点发回给电脑。这里面有几个关键角色USB Device ControllerESP32-P4内部的硬件模块负责USB物理层和数据链路层。TinyUSB MSC Device Class处理USB Mass Storage协议包括BOTBulk-Only Transport和SCSI命令解析。存储回调层我们自己实现的桥接代码把TinyUSB的读写请求转发给SD卡驱动。SDMMC驱动ESP-IDF提供的SD卡驱动通过SDMMC接口或者SPI接口访问SD卡。这个架构的好处是分层清晰每一层只关心自己的事情。TinyUSB不需要知道底层是SD卡还是Flash它只需要调用我们提供的读写函数就行。同样SD卡驱动也不需要知道数据最终会通过USB传出去它只管响应读写请求。2. 核心细节解析与实操要点2.1 硬件连接与引脚分配ESP32-P4的USB OTG接口用的是内部PHY也就是说你不需要外接USB PHY芯片直接把USB座子的D和D-接到芯片对应的引脚上就行。具体来说ESP32-P4的USB OTG引脚是固定的USB_DGPIO20USB_D-GPIO19这两个引脚是专用的不能通过GPIO矩阵映射到其他引脚。所以画板子的时候要注意USB座子必须接到这两个脚上。SD卡这边就灵活一些。ESP32-P4支持SDMMC主机控制器可以用4位数据线模式也可以用1位模式。我建议用4位模式速度更快。典型的引脚分配是这样的功能GPIO说明SD_CLKGPIO43时钟线SD_CMDGPIO44命令线SD_D0GPIO39数据线0SD_D1GPIO40数据线1SD_D2GPIO41数据线2SD_D3GPIO42数据线3这些引脚也不是随便选的ESP32-P4的SDMMC控制器有固定的IO MUX引脚组。如果你用其他引脚需要通过GPIO矩阵交换可能会影响时序。所以尽量用默认的IO MUX引脚。注意SD卡的供电是3.3V不要接5V。另外SD卡座子的CDCard Detect引脚可以接一个GPIO用来检测卡是否插入。不过在这个实验里我们可以不接CD引脚简化设计。2.2 TinyUSB MSC设备类的配置参数在menuconfig里配置TinyUSB的时候有几个参数需要特别注意TinyUSB Task Stack Size默认是4096字节但对于MSC设备来说这个值偏小。因为SCSI命令处理过程中会用到一些局部变量和缓冲区建议调到8192字节。TinyUSB Task Priority默认是5这个优先级在大多数场景下够用。但如果你的系统里有其他高优先级任务频繁抢占CPU可以适当提高。MSC Device Buffer Size这个参数决定了每次USB传输的数据块大小。默认是512字节也就是一个扇区的大小。如果你希望提高吞吐量可以调到4096或者8192但要注意内存占用。还有一个容易忽略的参数是USB Device VID/PID。VID是厂商IDPID是产品ID。如果你只是自己做实验可以用默认值。但如果要做一个正式产品需要申请自己的VID。不过对于这个实验来说用默认的就行。2.3 SD卡驱动的初始化流程SD卡初始化的步骤比较多但ESP-IDF已经把大部分工作封装好了。你只需要调用几个函数配置SDMMC主机控制器的引脚和时钟。初始化SDMMC主机。配置SD卡槽的参数比如总线宽度、频率等。调用esp_vfs_fat_sdmmc_mount挂载文件系统。这里有一个关键点SD卡的时钟频率。ESP32-P4的SDMMC控制器支持很高的时钟频率但SD卡本身有速度等级限制。如果你用的是普通Class 10卡建议把频率设在20MHz或者40MHz。如果设得太高可能会出现读写错误。另外总线宽度也要注意。4位模式比1位模式快很多但需要确保你的硬件连接支持4位。如果只接了D0那就只能用1位模式。2.4 MSC回调函数的实现要点TinyUSB的MSC设备类需要你提供几个回调函数最核心的是这两个tud_msc_read10_cb处理读请求。tud_msc_write10_cb处理写请求。这两个函数的参数包括LUN逻辑单元号、偏移量、缓冲区指针和缓冲区大小。对于SD卡来说LUN通常是0偏移量是以扇区为单位的。实现的时候要注意几点第一偏移量转换。TinyUSB传过来的偏移量是扇区号你需要乘以512得到字节偏移。但如果你用的是FAT文件系统直接操作扇区可能会破坏文件系统结构。所以更安全的做法是通过文件系统API来读写而不是直接操作扇区。第二缓冲区对齐。SDMMC驱动对缓冲区的对齐有要求通常需要4字节对齐。TinyUSB传过来的缓冲区不一定满足这个要求所以你可能需要用一个中间缓冲区来做拷贝。第三错误处理。如果SD卡读写失败回调函数需要返回falseTinyUSB会向主机报告错误。但要注意频繁的错误可能会导致主机重新枚举设备。3. 实操过程与核心环节实现3.1 工程创建与基础配置我用的开发环境是ESP-IDF v5.3配合VS Code的ESP-IDF插件。如果你还没装好环境建议先按照官方文档把工具链装好。这里不展开讲安装过程重点说工程配置。创建一个新工程选择tusb_msc相关的示例作为起点。乐鑫在ESP-IDF里提供了tusb_msc的示例代码路径在examples/peripherals/usb/device/tusb_msc。这个示例已经实现了基本的MSC设备功能但默认是用SPI Flash模拟的存储介质我们需要把它改成SD卡。打开工程后先运行idf.py menuconfig进入Component config - TinyUSB Stack把MSC相关的选项打开。然后进入Component config - FatFs确保FAT文件系统支持被启用。3.2 SD卡初始化代码实现在main函数里首先要初始化SD卡。我写了一个sd_init函数代码如下#include driver/sdmmc_host.h #include sdmmc_cmd.h #include esp_vfs_fat.h static sdmmc_card_t *s_card NULL; esp_err_t sd_init(void) { esp_err_t ret; // 配置SDMMC主机 sdmmc_host_t host SDMMC_HOST_DEFAULT(); host.max_freq_khz 40000; // 40MHz // 配置引脚 sdmmc_slot_config_t slot_config SDMMC_SLOT_CONFIG_DEFAULT(); slot_config.width 4; // 4位模式 slot_config.clk GPIO_NUM_43; slot_config.cmd GPIO_NUM_44; slot_config.d0 GPIO_NUM_39; slot_config.d1 GPIO_NUM_40; slot_config.d2 GPIO_NUM_41; slot_config.d3 GPIO_NUM_42; slot_config.flags | SDMMC_SLOT_FLAG_INTERNAL_PULLUP; // 挂载文件系统 esp_vfs_fat_sdmmc_mount_config_t mount_config { .format_if_mount_failed false, .max_files 5, .allocation_unit_size 16 * 1024 }; ret esp_vfs_fat_sdmmc_mount(/sdcard, host, slot_config, mount_config, s_card); if (ret ! ESP_OK) { ESP_LOGE(TAG, Failed to mount SD card: %s, esp_err_to_name(ret)); return ret; } ESP_LOGI(TAG, SD card mounted successfully); return ESP_OK; }这段代码里host.max_freq_khz设成了40000也就是40MHz。这个频率对于大多数SD卡来说是安全的。如果你用的是高速卡可以尝试调到更高但要注意稳定性。slot_config.flags里加了SDMMC_SLOT_FLAG_INTERNAL_PULLUP启用内部上拉电阻。如果你的板子上已经有外部上拉这个标志可以不加。3.3 MSC回调函数的完整实现接下来是MSC回调函数的实现。我写了一个简单的扇区读写函数直接操作SD卡的扇区#include tusb.h // 读取扇区 int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset, void *buffer, uint32_t bufsize) { if (lun ! 0) return -1; esp_err_t ret sdmmc_read_sectors(s_card, buffer, lba, bufsize / 512); if (ret ! ESP_OK) { ESP_LOGE(TAG, Read failed: %s, esp_err_to_name(ret)); return -1; } return bufsize; } // 写入扇区 int32_t tud_msc_write10_cb(uint8_t lun, uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t bufsize) { if (lun ! 0) return -1; esp_err_t ret sdmmc_write_sectors(s_card, buffer, lba, bufsize / 512); if (ret ! ESP_OK) { ESP_LOGE(TAG, Write failed: %s, esp_err_to_name(ret)); return -1; } return bufsize; } // 获取容量 void tud_msc_capacity_cb(uint8_t lun, uint32_t *block_count, uint16_t *block_size) { *block_count s_card-csd.capacity; *block_size 512; }这里我直接用了sdmmc_read_sectors和sdmmc_write_sectors这两个函数是ESP-IDF提供的底层扇区读写接口。用它们的好处是效率高不需要经过文件系统层。但缺点是会绕过FAT文件系统的缓存如果电脑和ESP32-P4同时访问SD卡可能会出现数据不一致。注意如果你希望电脑和ESP32-P4能同时访问SD卡建议用文件系统API来读写而不是直接操作扇区。但这样会复杂很多因为你需要把SCSI的扇区读写转换成文件读写这涉及到FAT文件系统的内部结构。3.4 USB设备初始化与任务启动MSC回调函数写好后需要初始化TinyUSB并启动任务。代码如下#include tinyusb.h #include class/msc/msc_device.h void usb_init(void) { const tinyusb_config_t tusb_cfg { .device_descriptor NULL, .string_descriptor NULL, .external_phy false, .configuration_descriptor NULL, }; ESP_ERROR_CHECK(tinyusb_driver_install(tusb_cfg)); // 初始化MSC设备 tinyusb_config_msc_t msc_cfg { .callback NULL, }; ESP_ERROR_CHECK(tinyusb_msc_init(msc_cfg)); }tinyusb_driver_install会安装USB驱动并启动TinyUSB的任务。tinyusb_msc_init会注册MSC设备类并设置回调函数。这里有一个细节tinyusb_config_t里的device_descriptor和configuration_descriptor如果设为NULLTinyUSB会用默认的描述符。默认描述符里的VID/PID是乐鑫的测试ID如果你要做产品需要自己定义描述符。3.5 完整流程串联与测试把上面的代码串起来整个流程是这样的系统启动初始化NVS Flash。调用sd_init初始化SD卡并挂载文件系统。调用usb_init初始化TinyUSB和MSC设备。TinyUSB任务开始运行等待主机连接。电脑通过USB线连接ESP32-P4识别为一个U盘。电脑发送SCSI命令TinyUSB调用回调函数读写SD卡。测试的时候我用的是Windows 10和macOS两套系统。Windows下识别很快插上就能看到盘符。macOS下稍微慢一点但也能正常识别。实测读写速度在40MHz时钟下连续读取速度大约在8MB/s左右写入速度大约在5MB/s左右。这个速度对于大多数应用来说够用了。如果你需要更高的速度可以尝试提高SD卡时钟频率但要注意信号完整性。4. 常见问题与排查技巧实录4.1 电脑识别不到设备这是最常见的问题。可能的原因有几个第一USB线的问题。有些USB线只有充电功能没有数据线。换一根线试试。第二驱动问题。Windows下通常不需要额外驱动但有些精简版系统可能缺少MSC驱动。可以在设备管理器里看看有没有未知设备。第三描述符配置错误。如果VID/PID或者设备类描述符配置不对电脑可能无法正确识别。可以用USB分析仪抓包看看或者用lsusb命令在Linux下查看设备描述符。第四供电不足。ESP32-P4加上SD卡的工作电流可能超过USB端口的供电能力。如果电脑USB端口供电不足设备可能会反复重启。可以试试用带外部供电的USB Hub。4.2 SD卡挂载失败SD卡挂载失败的原因也很多。我整理了一个排查表现象可能原因解决方法返回ESP_ERR_TIMEOUT时钟频率太高降低max_freq_khz到20MHz返回ESP_ERR_NOT_FOUND引脚配置错误检查GPIO编号是否与硬件一致返回ESP_ERR_INVALID_RESPONSESD卡不兼容换一张卡试试挂载成功但读写失败文件系统损坏用电脑格式化SD卡为FAT32还有一个容易被忽略的问题SD卡的上拉电阻。SDMMC协议要求CMD和DATA线上有上拉电阻。ESP32-P4内部有可配置的上拉电阻但如果你的板子上没有外部上拉需要确保SDMMC_SLOT_FLAG_INTERNAL_PULLUP被设置。4.3 读写速度慢如果读写速度明显低于预期可以从以下几个方面排查首先确认总线宽度。如果你用的是1位模式速度会比4位模式慢很多。检查slot_config.width是否设成了4。其次确认时钟频率。40MHz是一个比较保守的值可以尝试提高到50MHz甚至更高。但要注意频率越高信号完整性越难保证。如果PCB走线不好可能会出现误码。第三检查USB连接速度。ESP32-P4支持USB 2.0 High-Speed但如果你用的USB线或者USB Hub只支持Full-Speed速度会受限。可以在电脑的设备管理器里查看USB设备的速度。第四优化缓冲区大小。TinyUSB的MSC缓冲区大小会影响吞吐量。如果缓冲区太小USB传输会频繁中断。可以尝试增大CFG_TUD_MSC_BUFSIZE。4.4 电脑提示“需要格式化磁盘”这个问题通常是因为SD卡上的文件系统不被电脑识别。可能的原因有SD卡没有格式化为FAT32或者exFAT。文件系统损坏。分区表有问题。解决方法把SD卡插到电脑上用系统自带的格式化工具重新格式化为FAT32。如果格式化失败可以用diskpart命令清除分区表后重新创建。注意格式化会清除SD卡上的所有数据操作前请备份。4.5 写入数据后电脑看不到更新这个问题通常是因为缓存不一致。TinyUSB和SD卡驱动都有自己的缓存如果电脑写入了数据但缓存没有刷新到SD卡电脑再读取时可能读到旧数据。解决方法在tud_msc_write10_cb里写入完成后调用sdmmc_write_sectors的同步版本或者手动触发缓存刷新。另外可以在电脑端弹出设备后再重新插入强制刷新缓存。4.6 多平台兼容性问题不同操作系统对USB MSC设备的处理方式略有不同。Windows对SCSI命令的响应比较宽容macOS则严格一些。Linux下通常没问题但需要注意权限配置。如果遇到macOS下识别慢的问题可以尝试在描述符里添加bInterfaceProtocol字段明确指定BOT协议。另外macOS对设备的电源管理比较敏感如果设备频繁休眠可能会导致连接断开。5. 进阶优化与扩展思路5.1 提高读写性能的几种手段如果你对性能有更高要求可以尝试以下几个方向第一启用DMA。ESP32-P4的SDMMC控制器支持DMA传输可以大幅降低CPU占用。在sdmmc_host_t里设置flags为SDMMC_HOST_FLAG_DMA即可。第二使用双缓冲。在TinyUSB的回调函数里可以用双缓冲技术让USB传输和SD卡读写并行进行。这样可以把吞吐量提高30%以上。第三调整USB端点大小。TinyUSB默认的Bulk端点大小是64字节可以调到512字节。这样每次USB传输的数据量更大减少了协议开销。第四优化文件系统参数。allocation_unit_size设大一些可以减少FAT表的访问次数。但也不能太大否则会浪费空间。5.2 支持多LUNTinyUSB的MSC设备类支持多个LUN。也就是说你可以让ESP32-P4同时暴露多个存储设备比如一个LUN对应SD卡另一个LUN对应SPI Flash。这样电脑上会看到两个盘符。实现多LUN需要在回调函数里根据lun参数区分不同的存储介质。tud_msc_capacity_cb里也要为每个LUN返回不同的容量。5.3 添加写保护功能在某些场景下你可能希望设备只读不允许电脑写入。这可以通过在tud_msc_write10_cb里直接返回-1来实现。但更好的做法是在SCSI命令层面返回写保护错误这样电脑会显示“磁盘被写保护”而不是直接报错。5.4 与文件系统共存如果你希望ESP32-P4本身也能访问SD卡上的文件同时电脑也能通过USB访问那就需要处理并发访问的问题。一种方案是加一个互斥锁确保同一时间只有一个访问者。另一种方案是让ESP32-P4在USB连接时暂停文件操作断开后再恢复。我在实际项目中用的是互斥锁方案。在tud_msc_read10_cb和tud_msc_write10_cb里先获取互斥锁操作完成后再释放。ESP32-P4本地的文件操作也要获取同一个锁。这样可以避免数据竞争。5.5 低功耗优化如果设备是电池供电的低功耗就很重要。ESP32-P4的USB模块在空闲时可以进入低功耗模式。但要注意进入低功耗模式后USB连接可能会断开。所以需要在低功耗和连接稳定性之间做权衡。一个可行的方案是当USB没有连接时让ESP32-P4进入Light Sleep模式。当USB插入时通过GPIO中断唤醒。但这样需要额外的硬件支持比如USB VBUS检测电路。6. 实操心得与避坑指南6.1 我踩过的几个坑第一个坑是SD卡时钟频率设得太高。一开始我设了80MHz结果SD卡初始化经常失败。后来降到40MHz就稳定了。所以不要贪心先从低频开始稳定后再逐步提高。第二个坑是USB描述符配置错误。我一开始自己写描述符结果电脑识别成了其他设备。后来直接用TinyUSB的默认描述符问题就解决了。所以除非必要不要自己写描述符。第三个坑是缓冲区对齐问题。SDMMC驱动要求缓冲区4字节对齐但TinyUSB传过来的缓冲区不一定满足。我一开始没注意导致读写偶尔出错。后来加了一个对齐的中间缓冲区问题就消失了。第四个坑是文件系统缓存不一致。电脑写入数据后ESP32-P4本地读到的还是旧数据。后来在写入回调里加了缓存刷新问题解决。6.2 几个实用的调试技巧第一个技巧是用USB分析仪抓包。虽然USB分析仪比较贵但它是排查USB问题的利器。如果没有分析仪可以用Wireshark配合usbmon在Linux下抓包。第二个技巧是打印SCSI命令。在tud_msc_scsi_cb里加日志可以看到电脑发了哪些SCSI命令。这对于理解主机的行为很有帮助。第三个技巧是用lsusb -v查看描述符。在Linux下这个命令可以显示设备的完整描述符包括端点、接口、类等信息。对比一下就知道描述符有没有问题。第四个技巧是分步测试。先测试SD卡单独工作是否正常再测试USB单独工作是否正常最后再合在一起。这样可以快速定位问题出在哪一层。6.3 关于ESP-IDF版本的选择我用的ESP-IDF v5.3这个版本对ESP32-P4的支持比较完善。如果你用的是更早的版本可能会遇到一些兼容性问题。建议用v5.2以上的版本。另外TinyUSB的版本也很重要。ESP-IDF v5.3里集成的TinyUSB版本比较新MSC设备类的实现比较稳定。如果你自己手动更新TinyUSB要注意API可能有变化。6.4 硬件设计上的注意事项如果你要自己画板子有几个地方要注意第一USB差分线的阻抗控制。USB 2.0 High-Speed要求差分阻抗90欧姆。如果阻抗不匹配信号完整性会变差可能导致连接不稳定。第二SD卡的走线长度。SDMMC的时钟线尽量短数据线尽量等长。如果走线太长可能需要加匹配电阻。第三电源去耦。ESP32-P4和SD卡都需要良好的电源去耦。建议在每个电源引脚旁边放一个0.1uF的电容。第四ESD保护。USB接口容易受到静电冲击建议加ESD保护二极管。6.5 关于量产的一些建议如果你要把这个方案用在量产产品上有几个事情需要提前考虑第一VID/PID申请。USB-IF的VID需要付费申请费用不低。如果只是小批量产品可以考虑用开源社区的VID但要注意合规性。第二驱动签名。Windows 10以后对驱动签名有要求但MSC设备用的是系统自带驱动不需要额外签名。第三一致性测试。USB-IF有一致性测试通过后才能使用USB标志。如果产品要出口这个测试是必须的。第四固件升级。如果产品已经卖出去后续要升级固件可以通过USB MSC接口放一个固件文件设备读取后自行升级。这个功能很实用但要注意安全防止恶意固件。7. 写在最后的一些个人体会这个实验我从开始做到基本稳定大概花了一周时间。其中大部分时间不是在写代码而是在调试各种奇怪的问题。有时候电脑识别不到设备有时候SD卡挂载失败有时候读写速度上不去。每一个问题都需要耐心排查。我觉得做USB相关开发最重要的素质是耐心和细致。USB协议本身很复杂涉及的东西很多。但一旦你理解了它的工作原理很多问题都能迎刃而解。另外不要害怕看协议文档。USB MSC协议的文档虽然厚但关键部分就那么几章。SCSI命令集的文档也是常用的命令就那几个。把常用的搞清楚剩下的用到再查。最后分享一个小技巧如果你在调试过程中遇到问题可以先在Linux下测试。Linux的USB子系统日志比较详细dmesg命令可以看到很多有用的信息。相比之下Windows的日志就没那么直观。这个方案后续还可以扩展很多功能比如加一个按键按下后把SD卡挂载到ESP32-P4本地再按一下切换到USB模式。或者加一个OLED屏幕显示当前USB连接状态和读写速度。这些扩展都不难有兴趣的可以试试。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。