ARTICLE DETAIL

资讯详情

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

ESP32-P4 USB读卡器开发实战:TinyUSB MSC与SD卡文件系统集成

ESP32-P4 USB读卡器开发实战:TinyUSB MSC与SD卡文件系统集成 1. 项目缘起与整体设计思路1.1 为什么要在 ESP32-P4 上折腾 USB 读卡器拿到《DNESP32P4开发指南_V1.0》第四十九章这个标题的时候我第一反应是这个实验本质上就是把一块开发板变成“读卡器”让 PC 把它识别成一个 U 盘从而直接读写板子上的存储介质。听起来简单但真正动手做过的人都知道这里面牵扯到的东西一点都不少——USB OTG 外设配置、TinyUSB 协议栈、MSCMass Storage Class设备类、文件系统挂载、SD 卡或 SPI Flash 的底层驱动每一环出问题都会导致 PC 端“设备无法识别”或者“请插入磁盘”。ESP32-P4 是乐鑫新出的高性能 MCU带 USB 2.0 OTG High-Speed 接口这个接口支持 Device 和 Host 两种模式。做 USB 读卡器实验用的就是 Device 模式把 P4 模拟成一个 USB 大容量存储设备。为什么选这个方向因为在实际产品里很多数据采集设备、工业记录仪、车载记录终端都需要“把设备插到电脑上就能导出数据”这个功能而不是让用户去装驱动、装上位机、点一堆按钮。USB MSC 是免驱的Windows、Linux、macOS 全都原生支持用户体验最好。这个实验适合谁我觉得有三类人值得认真做一遍第一类是刚接触 ESP32-P4 和 ESP-IDF 的嵌入式新手想找一个综合性强、能串起 USB、文件系统、存储驱动多个知识点的项目第二类是做数据采集类产品的工程师需要快速验证“设备当 U 盘”这个方案的可行性第三类是对 TinyUSB 感兴趣、想搞清楚 USB 设备枚举流程的开发者。不管你是哪一类这个实验都能给你不少收获。1.2 方案选型TinyUSB 还是乐鑫原生 USB 栈ESP-IDF 里做 USB Device目前主流有两条路一条是用乐鑫自己封装的usb_device组件底层其实也是 TinyUSB另一条是直接用 TinyUSB 的 API。我在实际项目里两种都用过说说我的取舍逻辑。乐鑫的usb_device组件好处是跟 ESP-IDF 集成度高配置项通过 menuconfig 就能搞定MSC 类的例程也现成改改就能跑。缺点是封装层比较厚遇到枚举异常、端点配置错误这类底层问题调试起来隔了一层看日志不够直观。TinyUSB 原生 API 的好处是控制粒度细tusb_config.h里每个端点、每个类都能自己配出问题看 TinyUSB 的日志一目了然。缺点是要自己处理tusb_init()、tusb_task()的调用时机跟 FreeRTOS 任务的配合要自己设计。对于这个读卡器实验我的建议是先用乐鑫封装版跑通再切到 TinyUSB 原生版理解细节。跑通封装版能让你快速看到“PC 识别出 U 盘”这个结果建立信心切到原生版能让你真正搞懂 USB 描述符、端点、类请求这些东西。指南里这一章大概率是走封装版路线因为对新手更友好但我会在后面的章节里把 TinyUSB 原生的关键配置也补上方便你进阶。1.3 整体架构从 PC 到 SD 卡的数据通路先把整个数据流理清楚后面看代码才不会迷路。PC 端发起读文件请求数据是这样走的PC 文件管理器 → USB 主机控制器 → USB 总线 → ESP32-P4 USB OTG 外设Device 模式→ TinyUSB 协议栈 → MSC 类驱动 → 块设备接口bsp_sdcard_read/write→ SDMMC 主机控制器 → SD 卡。写文件就是反过来的方向。这里面 MSC 类驱动是个“翻译官”它把 USB 的 SCSI 命令比如 READ(10)、WRITE(10)、INQUIRY翻译成块设备的读写操作。块设备接口再往下就是具体的存储介质驱动这个实验里通常是 SD 卡也可能是 SPI Flash 模拟的 FAT 分区。理解这条通路的意义在于任何一环出问题现象都是“PC 不认盘”或“读写报错”但根因可能完全不同。比如 PC 显示“需要格式化”可能是 FAT 文件系统没挂载好PC 完全没反应可能是 USB 枚举就没过能识别但读写卡死可能是块设备读写函数阻塞了 USB 任务。后面排查章节我会按这条通路逐段讲怎么定位。2. 核心细节解析与实操要点2.1 USB OTG 外设配置别小看这几个 GPIOESP32-P4 的 USB OTG 接口用的是专用引脚不是随便哪个 GPIO 都能复用。做 Device 模式的时候最关键的是D / D- 差分对和VBUS 检测。P4 的 USB PHY 是内置的不需要外挂芯片这一点比很多老 MCU 省事。配置的时候有几个坑我踩过第一VBUS 检测引脚。Device 模式下P4 需要知道 USB 线有没有插上、主机有没有供电。有些开发板把 VBUS 检测接到了某个 GPIO有些直接内部处理。如果你用的板子 VBUS 检测是外部 GPIO必须在usb_device配置里指定正确的引脚号否则会出现“插上线但设备不枚举”的情况。我遇到过一块板子VBUS 检测脚配错了现象是 PC 端偶尔能识别、偶尔不能折腾了半天才发现是引脚定义跟原理图对不上。第二USB 时钟源。P4 的 USB OTG 需要 48MHz 时钟这个时钟通常来自内部 PLL 或者外部晶振。ESP-IDF 的 menuconfig 里有一项USB OTG Clock Source默认配置一般没问题但如果你改了主频或者用了特殊的时钟树要确认这一项跟你的硬件匹配。时钟不对USB 枚举会直接失败日志里会报 PHY 初始化错误。第三上拉电阻。Device 模式下D 线上需要一个 1.5kΩ 上拉到 3.3V用来告诉主机“我是个全速设备”。P4 内部可以配置这个上拉不需要外部电阻。但如果你用的开发板外部已经焊了上拉电阻就要注意别跟内部上拉冲突。这个细节在原理图 review 的时候一定要确认。2.2 TinyUSB 配置tusb_config.h里改什么如果你走 TinyUSB 原生路线tusb_config.h是核心配置文件。这个文件里每一项都影响 USB 设备的行为我挑几个跟 MSC 读卡器最相关的说。#define CFG_TUSB_MCU OPT_MCU_ESP32P4 #define CFG_TUSB_OS OPT_OS_FREERTOS #define CFG_TUD_MSC 1 #define CFG_TUD_MSC_EP_BUFSIZE 512 #define CFG_TUD_MSC_BUFSIZE 4096CFG_TUD_MSC_EP_BUFSIZE是 MSC 端点的缓冲区大小必须是 512 的倍数因为 USB 全速模式下最大包长是 64 字节高速模式下是 512 字节。P4 支持高速所以设 512 最合适。CFG_TUD_MSC_BUFSIZE是 MSC 类内部的读写缓冲区设大一点能提升吞吐量但会占 RAM。我实测 4096 是个比较平衡的值再大对速度提升不明显反而挤占其他任务的内存。还有一个容易忽略的CFG_TUD_MSC_EP_BUFSIZE和 SD 卡扇区大小的关系。SD 卡扇区是 512 字节USB MSC 的块大小通常也报 512 字节两者对齐读写效率最高。如果你把 MSC 块大小报成 4096PC 端会按 4096 来读写但 SD 卡底层还是 512中间就要做拼接反而慢。所以块大小保持 512 不要动。2.3 文件系统挂载FATFS 还是直接裸扇区这里有个设计选择PC 通过 USB 读卡器访问的是“文件”但 P4 底层看到的是“扇区”。中间这层文件系统怎么处理方案一P4 端挂载 FATFSPC 端也按 FAT 访问。这是最常见的做法。P4 先把 SD 卡挂载成 FATFSMSC 类驱动直接读写 SD 卡的扇区PC 端看到的就是一个标准的 FAT 卷。好处是 P4 自己也能读写这些文件比如采集数据写日志、PC 端导出数据两边都能操作。坏处是不能同时挂载——如果 P4 端 FATFS 挂载着PC 端又去写会出现文件系统不一致。所以通常的做法是P4 端平时挂载 FATFS 自己用当检测到 USB 插入、要当读卡器的时候先卸载 FATFS把裸扇区交给 MSC等 USB 拔出后再重新挂载。方案二P4 端不挂 FATFS只做裸扇区透传。MSC 类驱动直接把 SD 卡扇区读写请求转发给 SDMMC 驱动PC 端自己格式化、自己管理文件系统。好处是简单没有文件系统一致性问题。坏处是 P4 自己没法方便地读写文件只能按扇区操作。我的建议是方案一因为实际产品里 P4 通常需要自己记录数据同时又要支持 PC 导出。关键是要处理好“挂载/卸载”的时机。指南里这一章大概率用的是方案一因为更贴近实际应用。2.4 块设备回调MSC 类怎么跟 SD 卡对接TinyUSB 的 MSC 类需要你提供几个回调函数最核心的是这三个int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset, void* buffer, uint32_t bufsize); int32_t tud_msc_write10_cb(uint8_t lun, uint32_t lba, uint32_t offset, void* buffer, uint32_t bufsize); int32_t tud_msc_scsi_cb(uint8_t lun, uint8_t const scsi_cmd[16], void* buffer, uint16_t bufsize);read10和write10就是 PC 端读写扇区的入口。lba是逻辑块地址offset是块内偏移bufsize是要读写的字节数。你要做的就是把lba转换成 SD 卡的扇区号然后调用sdmmc_read_sectors或sdmmc_write_sectors。这里有个细节offset通常不为 0。PC 端可能一次读 4096 字节但 MSC 块大小是 512所以 TinyUSB 会拆成多次调用每次bufsize是 512offset是 0。但如果 PC 端读的字节数不是 512 的整数倍offset就可能非 0。所以你的回调里要处理offset不能假设它总是 0。我见过有人直接忽略offset结果 PC 端读小文件的时候数据错位排查了很久。scsi_cb是处理 SCSI 命令的比如INQUIRY查询设备信息、READ_CAPACITY查询容量、MODE_SENSE查询模式。这些命令里READ_CAPACITY最关键它决定了 PC 端看到的 U 盘容量。你要在这里返回 SD 卡的总扇区数和扇区大小。如果返回错了PC 端会显示错误的容量甚至无法格式化。3. 实操过程与核心环节实现3.1 环境准备ESP-IDF 版本与工具链先说环境。ESP32-P4 需要 ESP-IDF v5.3 或更高版本因为 P4 的芯片支持是后来才加的。我写这篇文章的时候用的是 v5.3.1实测稳定。如果你用的是 v5.2 或更早编译会报“unknown target esp32p4”。安装 ESP-IDF 的步骤这里不展开官方文档写得很清楚。我说几个容易出问题的地方第一Python 版本。ESP-IDF 的安装脚本对 Python 版本有要求建议用 3.8 到 3.11。我用 3.12 的时候遇到过idf.py报错降回 3.11 就好了。第二工具链路径。如果你电脑上装了多个版本的 ESP-IDF记得每次打开终端先source export.shLinux/macOS或运行export.batWindows确保用的是正确的版本。我习惯在项目目录下放一个env.sh里面写死 IDF 路径省得每次搞混。第三CLion 和 VSCode 的插件问题。热搜词里有人问“CLion 2023 的 Marketplace 里为什么找不到 ESP-IDF 插件”这个我专门查过。CLion 的 ESP-IDF 插件不是官方维护的是社区插件而且对 CLion 版本有要求。2023 版的 CLion 如果找不到可能是插件还没适配或者你的 CLion 是社区版社区版对插件支持有限。我的建议是用 VSCode ESP-IDF 官方插件这是乐鑫主推的组合配置简单调试也方便。VSCode 里装Espressif IDF插件然后按提示配置 IDF 路径就行。至于“能不能同时装多个 ESP-IDF 版本”答案是能。每个版本装在不同目录用的时候切换export.sh就行。我电脑上就同时装了 v5.1、v5.3、v5.4不同项目用不同版本。但要注意同一个终端会话里只能激活一个版本切换的时候要重新 source。3.2 工程创建与 menuconfig 配置用idf.py create-project usb_msc_sdcard创建工程然后idf.py set-target esp32p4。接下来是 menuconfig几个关键项Component config → USB OTG使能 USB OTG选择 Device 模式。Component config → FAT Filesystem使能 FATFS选择长文件名支持LFN。Component config → SDMMC使能 SDMMC配置 SD 卡引脚。SD 卡引脚配置要跟你的开发板原理图对上。P4 的 SDMMC 可以走 IO MUX 或者 GPIO Matrix走 IO MUX 性能更好但引脚是固定的。如果你的板子 SD 卡引脚跟 IO MUX 默认引脚不一致就要走 GPIO Matrix在 menuconfig 里选SDMMC GPIO Matrix并指定引脚号。配置完 menuconfig先编译一个空工程确认工具链没问题。idf.py build能过再开始写代码。3.3 代码实现从初始化到任务调度整个代码结构我分成四块USB 初始化、SD 卡初始化、MSC 回调、主任务。USB 初始化部分如果用乐鑫封装版大概是usb_device_config_t usb_config { .device_speed USB_DEVICE_SPEED_HIGH, .msc_config { .mount_point /usb, .block_size 512, .block_count sdcard_get_sector_count(), }, }; usb_device_init(usb_config);如果用 TinyUSB 原生版就是tusb_init()加上tusb_task()的循环调用。SD 卡初始化部分用sdmmc_host_init()和sdmmc_card_init()然后挂载 FATFSesp_vfs_fat_sdmmc_mount_config_t mount_config { .format_if_mount_failed false, .max_files 5, .allocation_unit_size 16 * 1024, }; sdmmc_card_t *card; esp_vfs_fat_sdmmc_mount(/sdcard, host, slot_config, mount_config, card);MSC 回调部分核心是read10和write10int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset, 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; }注意这里bufsize / 512是扇区数因为sdmmc_read_sectors按扇区读写。offset在这个简化版里没处理实际项目里要处理。主任务部分要处理 USB 插入/拔出事件切换 FATFS 挂载状态void usb_msc_task(void *arg) { while (1) { if (usb_is_connected()) { if (fatfs_is_mounted()) { esp_vfs_fat_sdcard_unmount(/sdcard, card); } usb_msc_start(); } else { usb_msc_stop(); if (!fatfs_is_mounted()) { esp_vfs_fat_sdmmc_mount(/sdcard, ...); } } vTaskDelay(pdMS_TO_TICKS(100)); } }这个任务逻辑是关键USB 连接时卸载 FATFS断开时重新挂载。这样保证 PC 端和 P4 端不会同时操作文件系统。3.4 参数计算容量上报与扇区对齐PC 端看到的 U 盘容量是由READ_CAPACITY命令的返回值决定的。这个返回值是“最后一个逻辑块地址”和“块大小”。比如 SD 卡有 0x007A0000 个扇区约 4GB那么最后一个 LBA 是 0x0079FFFF块大小是 512。计算的时候要注意返回的是最后一个 LBA不是总扇区数。我见过有人返回总扇区数结果 PC 端显示的容量比实际大 512 字节格式化的时候报错。正确的做法是total_sectors - 1。还有如果你的 SD 卡有分区表PC 端看到的是分区不是整个卡。这时候READ_CAPACITY要返回分区的起始 LBA 和大小而不是整卡的。这个细节在裸扇区透传模式下特别重要。指南里这一章如果用的是整卡透传那就返回整卡容量如果用的是分区透传就要解析 MBR 或 GPT 分区表。我建议新手先用整卡透传简单不容易错。4. 常见问题与排查技巧实录4.1 PC 端不识别设备从枚举日志查起这是最常见的问题现象是插上 USB 线PC 端一点反应都没有或者提示“未知 USB 设备”。排查思路是从 USB 枚举流程倒着查。第一步看 P4 端的日志。ESP-IDF 的 USB 驱动会打印枚举过程中的关键事件比如USB device connected、SET_ADDRESS、GET_DESCRIPTOR。如果日志停在USB device connected之后就没动静了说明设备描述符没发出去或者主机没响应。这时候要检查tusb_config.h里的描述符配置特别是 VID/PID、设备类、端点数量。第二步看 PC 端的设备管理器。Windows 下如果设备管理器里出现“未知 USB 设备设备描述符请求失败”说明枚举到了GET_DESCRIPTOR阶段就失败了。常见原因是描述符长度不对、端点配置冲突、或者 USB 时钟不稳。Linux 下可以用dmesg看内核日志信息更详细。第三步用 USB 分析仪抓包。如果没有硬件分析仪可以用软件工具比如 Windows 上的 USBPcap Wireshark。抓包能看到主机发了什么请求、设备回了什么定位非常准。我遇到过一次枚举失败抓包发现是设备返回的描述符里bMaxPacketSize0配成了 8但实际端点 0 的包长是 64主机直接拒绝了。改过来就好了。4.2 识别成 U 盘但提示格式化文件系统问题PC 端能识别出 U 盘但双击提示“需要格式化”这说明 USB 枚举过了MSC 类也正常问题出在文件系统层。可能的原因有几个一是 SD 卡本来就没有 FAT 文件系统PC 端读 MBR 发现没有有效分区二是 FATFS 挂载的时候参数不对比如扇区大小、簇大小跟实际不符三是 MSC 回调返回的容量信息跟实际不符PC 端按错误的容量去读分区表读出来是乱码。排查方法先在 P4 端用esp_vfs_fat_sdmmc_mount挂载看能不能成功。如果 P4 端都挂不上PC 端肯定也不行。如果 P4 端能挂上但 PC 端不行那就是 MSC 回调的问题。重点检查READ_CAPACITY的返回值和read10的lba转换。我踩过一个坑SD 卡是 4GB 的但我在READ_CAPACITY里返回的容量是按 8GB 算的PC 端按 8GB 去读分区表读到了卡外的地址返回全 0PC 端就认为分区表无效提示格式化。把容量改对就好了。4.3 读写速度慢瓶颈定位与优化USB 读卡器的速度理论上高速模式下能到 30-40MB/s但实际做出来往往只有几 MB/s。瓶颈可能在几个地方瓶颈位置典型速度优化方法SD 卡本身10-90MB/s换高速卡用 UHS-I 模式SDMMC 时钟20-40MHz提高时钟频率用 4 线模式MSC 缓冲区受 RAM 限制适当增大但别超过 16KBUSB 任务优先级受 FreeRTOS 调度影响提高 USB 任务优先级文件系统开销FATFS 有额外开销裸扇区透传可绕过我的实测经验SDMMC 用 4 线模式、40MHz 时钟MSC 缓冲区 8KBUSB 任务优先级设到 10比普通任务高读速度能到 15MB/s 左右。再往上就要看 SD 卡本身的速度了。如果你用的是低速卡换卡比优化代码更有效。还有一个容易忽略的点tusb_task()的调用频率。TinyUSB 是轮询式的tusb_task()要频繁调用才能及时响应 USB 事件。如果这个任务被其他高优先级任务阻塞了USB 传输就会卡顿。我习惯把tusb_task()放在一个独立的 FreeRTOS 任务里优先级设高循环里不加延时让它尽可能频繁地跑。4.4 常见问题速查表现象可能原因排查方法PC 完全不识别USB 枚举失败看 P4 日志、抓 USB 包提示未知设备描述符错误检查 VID/PID、端点配置提示需要格式化文件系统问题P4 端先挂载测试容量显示错误READ_CAPACITY 返回值错检查总扇区数计算读写卡死块设备回调阻塞检查 SDMMC 超时设置速度慢瓶颈在 SD 卡或调度逐段测速定位插拔后不恢复状态机没处理拔出事件检查 USB 事件回调4.5 独家避坑技巧说几个文档里不会写、但实际项目中很重要的点。第一USB 插入检测要用中断不要用轮询。我一开始用轮询检测 VBUS100ms 一次结果插拔快了会漏掉事件。后来改成 GPIO 中断响应及时多了。P4 的 VBUS 检测引脚支持中断配置成上升沿和下降沿都触发插入和拔出都能捕获。第二FATFS 卸载前要确保没有文件打开。如果 P4 端有任务正在写日志文件这时候 USB 插入、卸载 FATFS会导致文件系统损坏。我的做法是USB 插入事件触发后先发一个信号量给所有文件操作任务让它们停止操作并关闭文件然后再卸载。这个同步逻辑很重要少了它SD 卡用不了多久就会出坏块。第三SD 卡热插拔要处理。有些产品 SD 卡是可插拔的如果卡拔了但 MSC 还在跑PC 端会报 I/O 错误。我的做法是检测 SD 卡的 CDCard Detect引脚卡拔出时先停止 MSC再卸载 FATFS等卡插回再重新初始化。这个逻辑在数据采集设备里特别重要。第四注意 USB 和 SDMMC 的电源域。P4 的 USB PHY 和 SDMMC 控制器可能在不同的电源域如果用了低功耗模式要注意这两个外设的时钟和电源不能随便关。我遇到过进入 light sleep 后 USB 断连的问题后来发现是 USB PHY 的电源被关了。做低功耗产品的话这个细节要特别注意。第五编译时注意CONFIG_TINYUSB_*和CONFIG_USB_*的区别。ESP-IDF 里有两套 USB 配置项一套是乐鑫封装的CONFIG_USB_*一套是 TinyUSB 原生的CONFIG_TINYUSB_*。如果你混用了可能会出现配置不生效的情况。我的建议是用封装版就只改CONFIG_USB_*用原生版就只改CONFIG_TINYUSB_*别混着来。5. 进阶扩展与个人经验5.1 从单 LUN 到多 LUN同时暴露多个存储介质基础版读卡器只暴露一个 LUNLogical Unit Number也就是一个存储介质。但实际产品里可能既有 SD 卡又有 SPI Flash想同时暴露给 PC。TinyUSB 的 MSC 类支持多 LUNCFG_TUD_MSC设成 2然后在回调里根据lun参数区分操作哪个介质。多 LUN 的难点在于READ_CAPACITY和read10/write10都要按 LUN 区分。我的做法是定义一个 LUN 描述结构体数组每个元素包含介质类型、扇区数、读写函数指针。回调里根据lun索引找到对应的结构体调用对应的读写函数。这样扩展性最好加新介质只要往数组里加一项。5.2 性能优化从 5MB/s 到 20MB/s 的调优记录我拿一块 P4 开发板 32GB Class10 SD 卡做测试初始版本读速度只有 5MB/s。调优过程记录如下第一步SDMMC 从 1 线改 4 线。速度从 5MB/s 提到 12MB/s。4 线模式需要硬件支持检查原理图确认 D1-D3 引脚都接了。第二步SDMMC 时钟从 20MHz 提到 40MHz。速度从 12MB/s 提到 18MB/s。注意不是所有卡都支持 40MHz如果卡不支持初始化会失败要降回 20MHz。第三步MSC 缓冲区从 512 提到 8192。速度从 18MB/s 提到 20MB/s。再往上提升不明显而且占 RAM。第四步USB 任务优先级从 5 提到 10。速度波动变小平均速度略有提升。最终稳定在 20MB/s 左右对于数据采集设备来说够用了。如果你要更高速度可以考虑用 P4 的 USB High-Speed 模式480Mbps但 SD 卡本身的速度会成为瓶颈除非用 UHS-I 卡。5.3 个人经验这个实验最值得花时间的地方做了这么多遍 USB 读卡器实验我觉得最值得花时间的不是把代码跑通而是理解 USB 枚举流程和 MSC 协议。代码跑通可能只要半天但搞懂枚举流程、描述符结构、SCSI 命令交互需要好几天。但这几天花得值因为以后遇到任何 USB 设备开发的问题你都能从协议层去分析而不是瞎试。另外多看看 TinyUSB 的源码。TinyUSB 的代码写得挺清晰的msc_device.c里能看到 SCSI 命令是怎么解析的usbd.c里能看到枚举状态机是怎么跑的。看源码比看文档理解得深。最后别怕抓包。USB 分析工具看起来复杂但用几次就熟了。抓包能让你看到协议层的真实交互比看日志直观得多。我现在的习惯是USB 相关问题先抓包再分析最后改代码。这个流程能省很多时间。这个实验后续还可以这样扩展加上 USB CDC 串口让 P4 同时当读卡器和串口设备加上 USB HID模拟键盘鼠标或者加上 USB Audio做音频采集。P4 的 USB OTG 支持复合设备一个接口上跑多个类这些扩展都很有实用价值。
返回列表