免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

ESP32-P4 USB读卡器实战:从MSC到FatFs,把开发板变成U盘

ESP32-P4 USB读卡器实战:从MSC到FatFs,把开发板变成U盘 作为一个经常在ESP32系芯片上折腾USB应用的工程师看到这个实验标题我其实挺有感触的。“USB读卡器Slave”这几个字看起来简单就是把开发板接上电脑让PC把板子当成一个大号U盘来识别。但真正动手做的时候里面牵扯到的协议栈、存储介质、文件系统、枚举时的时序配合随便哪一个环节出问题都能让人折腾一个下午。所以我决定把这个实验从原理到实操完整拆一遍把我自己踩过的坑、调过的参数、查过的寄存器都写出来。这篇内容主要面向正在看《DNESP32P4开发指南_V1.0》第四十九章的读者也适合任何想在ESP32-P4上快速实现USB MSC设备U盘功能的嵌入式开发者。1. 实验总体认识一块开发板如何“伪装”成U盘1.1 这个实验解决什么问题USB读卡器Slave实验的核心目标是把DNESP32P4开发板上的USB端口配置成设备模式让PC主机将其识别为一个Mass Storage Class大容量存储类设备表现出来的效果就是电脑里多了一个盘符你可以像操作普通U盘一样去读写文件。对于嵌入式开发者来说这个功能真正解决的是“文件交互”这个老大难问题。平时我们调试设备要把里面的日志、配置、固件备份拿出来要么用串口慢慢导要么用网络传输都不太方便。如果把板子变成一个U盘插入电脑就能直接拖拽文件对产品量产、数据采集、固件升级这些场景都有直接价值。从技术角度来说这个实验也是理解USB协议栈的绝佳入口。USB协议非常庞大MSC类是其中比较“纯粹”的一类——它没有HID那样复杂的报表描述符也没有CDC那样需要管理虚拟串口和中断端点MSC只需要处理批量传输Bulk Only TransferBOT和SCSI命令逻辑链路比较清晰特别适合作为学习USB从设备的第一个完整实验。1.2 USB Slave模式与读卡器的核心链路在开始看代码之前我建议先在脑子里建立这条完整的数据链路PC主机USB Host ↓ USB总线D/D-差分信号 DNESP32P4 USB OTG控制器Device模式 ↓ SCSI命令解析MSC类协议 FAT文件系统层FatFs ↓ 底层块设备读写 存储介质SD/TF卡或板载Flash这条链路非常关键。很多新手只盯着“USB枚举”部分以为设备能被识别就完事了。但读卡器实验的难点恰恰在下半段当PC发来一个SCSI READ命令请求读取某个逻辑块地址LBA的数据时你的板子必须去存储介质的对应位置把256字节或者512字节的数据翻出来原路返回给主机。这个过程中任何一层的接口不匹配、时序超时都会表现为PC端“写入失败”或者“设备无响应”。在这个实验里DNESP32P4的USB外设工作在Slave侧用的是芯片自带的USB 2.0 HS OTG控制器最高理论速率480Mbps。但实际我们能体验到的读写速度远远达不到这个值因为瓶颈在存储介质的读写速度和FatFs文件系统的处理效率上这一点后面实测部分我会给出数据。1.3 开发板与外设选型DNESP32P4开发板是正点原子基于乐鑫ESP32-P4芯片做的高性能开发板集成了双核RISC-V处理器主频可以到400MHz配上USB 2.0 HS OTG接口做这个实验非常合适。存储介质方面我强烈建议首选SD/TF卡而不是芯片内置的Flash。原因很简单SD卡本身就是按块设备设计的和MSC类天然匹配你可以随时把卡拔出来插到电脑上直接验证数据有没有写对读卡器读卡器用卡才符合直觉如果开发板上有TF卡槽那直接插卡用如果没有可以用SPI方式外接一个TF卡模块。DNESP32P4开发板我记得板载了TF卡槽而且引出的引脚比较友好省去了很多飞线的麻烦。2. 原理深挖USB枚举、MSC类与文件系统三者如何协作2.1 USB设备枚举流程要理解USB读卡器绕不开枚举Enumeration这个过程。所谓枚举就是USB主机和USB设备建立通信的第一步有点像两个人见面先互报家门。当DNESP32P4通过USB线插入PC时PC会检测到D线上的上拉电阻信号全速设备是1.5kΩ上拉到3.3V然后开始复位总线、发送一系列标准请求。设备收到这些请求后需要按顺序返回设备描述符Device Descriptor、配置描述符Configuration Descriptor、接口描述符Interface Descriptor、端点描述符Endpoint Descriptor等。对于MSC设备来说标准请求里面最有意思的是SET_CONFIGURATION一旦执行到这个请求主机就认定了设备的工作模式。随后主机会发送GET_MAX_LUN类专用请求询问设备支持多少个逻辑单元LUN。读卡器通常返回1代表一个盘符。枚举过程全部走端点0控制端点速度相对较慢但是MSC真正数据传输走的是批量端点Bulk Endpoint。TinyUSB协议栈在后台自动完成了枚举阶段的工作我们在应用层只需要注册好描述符剩下的交给协议栈处理。这也是为什么现代嵌入式开发不用像十年前那样手写USB状态机了。2.2 MSC类协议与SCSI命令枚举完成后MSC设备就进入了BOTBulk-Only Transport传输模式。这个模式说起来其实很规整每笔事务都由三个部分组成主机先发送一个CBWCommand Block Wrapper里面带有命令块然后主机和设备之间传输数据方向由CBW中的标志位决定最后设备返回一个CSWCommand Status Wrapper报告命令执行状态CBW里包着的命令就是SCSI命令。MSC设备经常要处理的SCSI命令包括INQUIRY主机询问设备的基本信息厂商、产品名、版本READ CAPACITY主机询问设备总共有多少扇区扇区多大TEST UNIT READY主机询问设备是否就绪READ(10)、WRITE(10)实际的数据读写命令REQUEST SENSE设备返回错误信息时使用我刚开始做这个实验时总觉得SCSI命令很神秘。后来打印一下调试日志才发现Windows在枚举U盘时发的命令就那么几条而且是固定顺序。你可以把它理解成一个“握手”过程电脑先问“你是谁”再问“你有多大”然后就开始源源不断地发READ和WRITE命令。这里有一个非常重要的细节MSC设备的底层是按扇区通常是512字节寻址的PC会把读卡器当成一块裸盘而不是一个文件系统。你看到的盘符和文件是PC上的操作系统基于FAT文件系统自己解析出来的。这也就意味着板子本身不一定非要跑文件系统——它只需要把扇区数据原样返回给PC即可。2.3 FatFs为什么要参与既然MSC只需要读写扇区那为什么很多读卡器实验还非要挂一个FatFs这也是我自己绕了一圈才想明白的问题。答案是为了底板和介质之间的“胶水”关系。如果你的存储介质是SD卡并且SD卡上已经格式化为FAT32那么在MSC转发扇区时理论上确实可以不跑FatFs。但问题是SD卡在SPI模式下初始化时需要发送ACMD命令来读取卡容量、确定块大小等参数这些信息需要一套代码来管理。在ESP-IDF生态里这一套代码就是FatFs底层的diskio接口——它得先通过底层驱动把SD卡“激活”才能做扇区级读写。更进一步说在TinyUSB的MSC回调中给上层提供的是“read block”和“write block”这样的接口。如果直接用SDMMC或SDSPI的驱动还需要自己封装一层。而FatFs本身就定义好了disk_read、disk_write、disk_status这些接口SD卡驱动只要把接口对应上然后MSC回调再调用disk_read、disk_write代码结构就非常清晰。所以FatFs在这里的角色更像是一个“统一抽象层”——它让上层MSC逻辑和底层SD卡驱动解耦。你把SD卡换成SPI Flash也只要换掉diskio的实现MSC的代码完全不用动。这个设计思想在嵌入式里非常实用。3. 环境与硬件准备3.1 硬件接线与引脚确认做这个实验之前先把硬件接线理清楚避免后面排查时一头雾水。DNESP32P4开发板的USB接口通常不止一个。一个用于调试/烧录一个用于USB OTG功能。做Slave实验时必须把USB线接到OTG那个接口上插错的典型症状就是“电脑完全没反应”。这个我在第一次做的时候还真犯过当时办公桌上USB线一大堆随手抓起一根就插结果折腾了十分钟才发现是接口错了。如果使用TF卡模块接线要特别注意。SD卡SPI模式的引脚定义一般包括CS片选SCK时钟MOSI主出从入MISO主入从出在DNESP32P4开发板上如果是板载TF卡槽通常已经接好了只需要在代码里确认对应的GPIO编号。如果是自己飞线尽量把引线剪短SPI时钟频率可以先降到1MHz验证通信再调高。USB线方面建议使用质量好的带屏蔽数据线。很多人忽略这一点但USB高速信号对线材质量很敏感劣质线材可能导致枚举失败或者数据传输CRC错误这个坑我踩过后面排查章节会详细说。3.2 ESP-IDF与TinyUSB组件配置软件环境方面我使用的是ESP-IDF v5.2以上的版本里面已经集成了TinyUSB组件的支持。ESP32-P4这个芯片较新建议使用IDF v5.2.1或者更新版本搭配V1.0指南使用避免遇到芯片支持不完整的问题。TinyUSB是Adafruit发起的一个开源USB协议栈专门为嵌入式设备设计代码紧凑、支持类多、文档清晰。ESP-IDF官方将它作为组件引入提供了一套esp_tinyusb封装API。我们不需要深入了解USB描述符内部的每一个字节但需要会配置最关键的结构体。一个重要概念TinyUSB是跑在实时操作系统上的它有自己的任务调度。如果你用的是FreeRTOSTinyUSB组件会创建后台任务来处理USB中断和数据收发。应用层只需要通过回调函数来感知事件和处理数据。在menuconfig里我习惯开启以下配置项Component config → TinyUSB → Enable TinyUSB stackUSB Vendor/Device settings 里设置VID/PID默认的可以先用TinyUSB Class drivers 里勾选 Mass Storage Class driver这里说一下VID/PIDVendor ID是厂商IDProduct ID是产品ID。没有公司的开发者可以先用Espressif的测试VID或者使用TinyUSB示例里的默认值。如果你以后要量产必须向USB-IF申请自己的VID或者购买第三方VID不然设备在系统里会显示为未知厂商。4. 核心代码实现把SD卡暴露成U盘4.1 存储介质初始化存储介质初始化是整个实验的基础。这里我以SD卡TF卡为例先把SD卡的驱动层搞定。在ESP-IDF中SD卡初始化有两种方式SDMMC4位模式和SDSPISPI模式。DNESP32P4开发板上如果TF卡槽接到了SDMMC控制器用SDMMC模式速度更快但如果是普通IO模拟的SPI那就走SDSPI。初始化代码可以简化为#include esp_vfs_fat.h #include sdmmc_cmd.h #include driver/sdmmc_host.h #include driver/sdspi_host.h sdmmc_host_t host SDMMC_HOST_DEFAULT(); sdmmc_slot_config_t slot_config SDMMC_SLOT_CONFIG_DEFAULT(); esp_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_err_t ret esp_vfs_fat_sdmmc_mount(/sdcard, host, slot_config, mount_config, card);这里有一个值得玩味的参数allocation_unit_size也就是文件系统的分配单元大小。这个值越大大文件读写的效率越高但小文件会浪费更多空间。对于U盘应用我建议设为16KB或者32KBPC端格式化U盘时通常也默认这个级别。如果挂载成功你会在日志里看到SD卡的信息制造商ID、卡容量、传输模式等。挂载失败时要重点检查SPI接线、卡是否格式化、卡是否损坏这几个点。我对一个有趣的细节印象很深如果你在电脑上把TF卡格式化成exFAT在传统FatFs版本的esp_vfs_fat_sdmmc挂载时可能会失败。因为老版本FatFs默认不带exFAT支持。解决方法是格式化成FAT32或者在menuconfig里启用exFAT支持。做U盘实验首选FAT32。4.2 MSC回调函数实现SD卡挂载好了之后接下来就要把底层的块设备操作接口暴露给TinyUSB的MSC类。在ESP-IDF的tinyusb组件里MSC注册接口大致如下#include tinyusb.h #include tusb_msc.h static int32_t msc_read(uint32_t lba, uint32_t offset, void *buffer, uint32_t bufsize) { size_t sect_count bufsize / SD_SECTOR_SIZE; // 调用FatFs底层disk_read接口 return disk_read(0, buffer, lba, sect_count); } static int32_t msc_write(uint32_t lba, uint32_t offset, const void *buffer, uint32_t bufsize) { size_t sect_count bufsize / SD_SECTOR_SIZE; // 调用FatFs底层disk_write接口 return disk_write(0, buffer, lba, sect_count); } static int32_t msc_scsi_command(uint8_t lun, const uint8_t *scsi_cmd, uint8_t *buffer, uint32_t bufsize) { // 这里处理特殊SCSI命令一般返回CBI_INVALID_CMD或者调用底层判断 return CBI_INVALID_CMD; } void msc_setup(void) { tinyusb_msc_config_t msc_cfg { .vendor_id Espressif, .product_id MSC Demo, .callback msc_event_cb, .read msc_read, .write msc_write, .scsi_command msc_scsi_command, }; tinyusb_msc_register(msc_cfg); }这里最需要注意的是lba逻辑块地址和offset这两个参数的区别。lba是PC请求的逻辑块号比如PC想读取第1000个扇区lba1000。offset是缓冲区内的小偏移通常用不到但必须返回正确否则PC端会读到错乱数据。bufsize这个参数也值得一提。USB高速模式下PC一次批量传输最多能带65536字节64KB但实际MSC通常按照SCSI READ(10)命令里指定的传输长度来。我们的底层磁盘读写接口必须能处理非512字节倍数的请求吗事实上POSIX风格的磁盘接口都会按照扇区对齐但MSC回调返回DMA时要注意性能最好在读取时使用缓冲区对齐访问。一个容易踩的坑是TinyUSB的MSC回调中read和write回调返回的是实际传输的字节数如果失败要返回负数。很多示例代码里写的是返回字节数这一点和普通disk_read函数的返回值含义不同。我在调这个的时候日志上总是报“SCSI错误”就是返回值搞错了。我习惯在回调里加打印但要注意如果打印太频繁会拖慢USB传输。建议用一个带开关的调试宏只在调试期打开。4.3 USB设备栈启动与主循环存储介质初始化好、MSC回调注册好之后就剩下最后一步启动TinyUSB设备栈。在ESP-IDF里启动代码非常简洁void app_main(void) { // 1. 初始化SD卡并挂载FAT sdcard_init(); // 2. 注册MSC回调 msc_setup(); // 3. 启动TinyUSB设备栈 tinyusb_device_config_t tusb_cfg { .device_descriptor NULL, // 使用默认描述符或者自定义 .string_descriptor NULL, .external_phy false, .configuration_descriptor NULL, }; tinyusb_driver_install(tusb_cfg); // 4. 主循环 while (1) { vTaskDelay(pdMS_TO_TICKS(10)); } }启动时有一个非常关键的细节configuration_descriptor是否需要自己定义。使用TinyUSB自带的MSC描述符时协议栈会基于默认配置生成描述符一般情况下够用。但如果你希望U盘显示一个自定义的厂商名、产品名或者序列号就需要自己构造字符串描述符并在上一步的string_descriptor里传入。另外一个重要点TinyUSB的设备栈需要响应USB中断而ESP-IDF的移植已经封装好了。主循环里不需要调用任何TinyUSB的task函数但如果你遇到“插入这个板子没有反应”的问题可以检查自己的固件里是否意外地暂停了USB外设时钟或者被某些低功耗模式干扰。我记得在某个版本IDF上如果不调用tinyusb_driver_install的tusb_cfg中配置external_phy高速模式下的外部PHY会被错误启用导致HS枚举不上。后来发现ESP32-P4内置了USB PHY这个参数必须保持为false。这类问题在不同芯片、不同IDF版本上行为确实不一样所以遇到枚举异常时第一步就要去核对官方示例的配置。5. 编译、烧录与实测5.1 menuconfig关键配置项在终端里运行idf.py menuconfig需要重点确认以下配置配置项推荐值说明TinyUSB stackEnabled打开TinyUSB协议栈Mass Storage ClassEnabled使能MSC类驱动USB Vendor ID0x303A乐鑫测试VID可自定义USB Product ID0x4002避免冲突USB Device SpeedHigh Speed 或 Full Speed根据芯片和线材Compiler optimizationsSize 或 Debug调试期用Debug发布用Size高速和全速的选择需要特别说明。ESP32-P4的USB控制器支持HS480Mbps但在很多情况下FS反而更稳定——即便枚举成FS实际速度对U盘应用也完全够用。如果线材质量一般强制HS可能导致链路协商失败反而不如FS稳定。我先用HS试了一圈实测在PC上写入速度只有FS的好一点点那干脆就老老实实用FS了。5.2 编译烧录步骤确认配置之后编译烧录步骤和普通ESP-IDF工程一样idf.py set-target esp32p4 idf.py build idf.py -p /dev/ttyUSB0 flash monitor烧录时注意如果调试串口和USB口是同一个口那么烧录过程中不要插着设备模式的USB线否则可能出现端口占用或供电冲突。我实测时在板子上烧完固件重新上电然后插上USB线到电脑Windows会提示“发现新硬件”。如果驱动正常设备管理器里会出现“大容量存储设备”同时“我的电脑”里多出一个盘符。整个枚举过程大概两三秒。固态硬盘和U盘不同U盘第一次插入时系统会读取分区表如果SD卡上之前没有分区Windows只会提示“需要格式化磁盘”。这时选择FAT32格式格式化完成后就能正常拖文件了。5.3 Windows/Linux实测表现我分别做了写入和读取测试写入一个10MB的压缩包开始速度能到5MB/s左右后面会掉到3MB/s上下从U盘回读这个文件到PC读取速度比较稳定在4-6MB/s之间创建100个1KB的小文件总耗时比较长文件系统开销很明显这个速度肯定是没法当高速U盘用的但作为嵌入式设备导出日志、存配置文件的方案已经完全够用了。核心需求是“不依赖串口/网络插上就能拷数据”。在Linux下直接lsblk能看到/dev/sd*设备sudo mount /dev/sda1 /mnt/usb挂载后一样正常读写。但Linux下有个很小的细节拔出U盘前要sync否则缓存中的数据可能因为设备没响应而丢失。这个习惯在Windows下也适用安全弹出还是很有必要的。6. 常见问题与排查速查表6.1 枚举失败/识别不到设备现象插入USB线后电脑完全没反应设备管理器里没有任何变化。排查步骤确认USB线插的是OTG接口而不是调试串口接口确认TinyUSB栈已经在main中被启动查看串口日志是否有TinyUSB初始化失败的错误检查接线D和D-有没有接反有没有虚焊换一根线材试试劣质线材最容易出这种问题如果日志里出现TUSB ERROR: Device error那多半是描述符配置有问题。此时建议先把自定义描述符全部注释掉用默认配置跑通基础流程再逐步加回自定义内容。6.2 格式化失败或容量不对现象电脑识别到了设备但提示“Windows无法完成格式化”。这种情况十有八九是MSC回调中的底层读写函数返回错误。格式化过程会对每个扇区做读写校验任何一次READ或WRITE失败都会导致格式化中止。排查时先在代码里用串口打印出每次SCSI命令的LBA和长度值看看有没有超出SD卡容量的请求。如果SD卡容量是32GB约62500个扇区而host请求读取LBA 80000000那肯定是底层填写的容量信息不对。检查READ CAPACITY回调是否返回了正确的扇区数。另外一个常见原因disk_read和disk_write底层访问的磁盘卷编号不对。ESP-IDF的FatFs最多支持多个卷如果MSC回调里写死了卷0而SD卡实际挂载在卷0那没问题但如果中途改过挂载逻辑可能导致卷号错位。6.3 读写速度慢现象能正常读写文件但速度只有几百KB/s。优先排查是否跑在了Full Speed12Mbps模式下。Full Speed的理论速度也就1MB/s出头如果线材有干扰峰值速度上不去很正常。但如果是HS模式下的几MB/s降至几百KB/s那问题往往出在底层README命令的块大小上传可能没有充分利用多扇区传输。SD卡驱动里建议使用多块读写命令如CMD18/CMD25而不是单块读写CMD17/CMD24。单块读写时每次都要发命令、等待响应开销太大。还有一点容易被忽视TinyUSB的MSC数据缓冲区是否做了对齐处理。DMA外设通常要求4字节甚至16字节对齐如果协议栈传入的缓冲区地址不对齐底层SD驱动会走慢速的缓存路径。解决方法是在MSC配置结构体里指定对齐的缓冲区或者用heap_caps_malloc分配内存。6.4 拔插崩溃/复位现象正常使用时没问题但直接拔USB线或者PC端安全弹出后板子重启甚至panic。这个问题本质上是热拔插处理没做周全。USB设备被拔掉后总线上的中断和DMA访问可能会访问已经不存在的硬件状态导致触发保护异常。我的经验是两个方面在TinyUSB的事件回调里监听TUSB_EVENT_DEVICE_DISCONNECTED在该事件中调用存储介质卸载把打开的FatFs文件全部关闭设立一个“设备在线”标志位MSC的read/write回调先检查标志位如果设备已经断开立即返回错误而不是继续访问SD卡Panic时的日志可以定位到是访问哪个地址发生了问题通常在sdmmc_read_sectors或者disk_read内部。这时把香炉先放下不要急着去改底层先看看是否已经正确优雅地处理了拔插事件。7. 实验之外这个能力的真实价值做完整个实验我最大的收获倒不是亲手做出了一个U盘而是搞清楚了USB存储类设备从“枚举”到“扇区读写”的完整数据流。这个知识可以无缝扩展到很多真正的产品场景一是固件升级和配置备份。很多嵌入式设备要求“升级固件最可靠的方式”——不需要上位机软件把设备插入电脑把固件文件拖进盘符拔出来设备自己解析文件完成升级。这套方案在工业HMI、医疗仪器、数字示波器里非常常见。二是数据导出。设备采集了大量传感器数据平时存在SD卡或Flash中需要定期回传给PC分析。有了USB MSC能力现场人员不需要安装任何驱动和数据采集软件插上电脑直接拷贝对非技术人员非常友好。三是桌面端交互。如果你做的是创客玩具、桌面键盘、智能硬件USB读卡器可以是其中一个附加功能。比如一个带无线配置功能的Wi-Fi模组插上电脑时显示为一个U盘用户编辑里面的配置文件来设置Wi-Fi密码配置完成后拔下设备自动切换到正常模式。这个交互模式比做App或者网页配置要省事得多。从学习层面讲这个实验把USB协议、存储介质、文件系统这三个嵌入式领域的核心模块串成了一根线。你能画清楚这张数据流图以后去做USB网卡CDC ECM、USB声卡UAC、USB HID设备底层思路都是相通的——无非是换一套类驱动、换一批端点描述符、换一组回调函数。我在实际做这个实验时还有一个体会就是“先让数据通路跑起来再去做优化”。不要一上来就想把速度做到满速、把代码结构设计得多么优雅。先拿原始SD卡、默认配置、CubeMX自动生成的代码跑通一个最小系统在日志里看到SCSI命令正常执行、文件能拷进去再一步步换高性能方案。嵌入式调试最忌讳的就是在黑盒子里瞎猜把数据通路每一级都打印出来故障点一定藏不住。
返回列表