1. 项目概述从零打造一个基于STM32的U盘最近花了两个多星期终于把STM32F103C8T6这颗芯片和一片大页NAND FlashK9F1G08U0A给“撮合”到了一起成功实现了一个128MB的U盘。整个过程下来感觉像是打通了嵌入式开发的任督二脉从USB协议栈的懵懂到NAND Flash管理的头大再到最终在电脑上看到“可移动磁盘”的狂喜踩的坑和收获的经验一样多。这个项目非常适合想深入理解USB Mass Storage协议、学习NAND Flash底层驱动以及掌握STM32 USB外设的嵌入式开发者。无论你是刚接触USB的学生还是想为产品增加存储功能的工程师跟着这个思路走一遍绝对能让你对“数据是如何从电脑走到芯片内部的Flash里”这件事有透彻的理解。简单来说这个项目的核心就是让电脑把我们的STM32Flash组合识别为一个标准的U盘可以进行文件的复制、粘贴、删除。这背后需要STM32的USB从机控制器模拟一个Mass Storage设备并通过Bulk-Only Transport协议与电脑通信同时还需要一套可靠的底层驱动来管理NAND Flash的读写、擦除和坏块。下面我就把这两个多星期里梳理清楚的硬件选型、协议栈解析、驱动编写和调试心得毫无保留地分享出来。2. 核心硬件与协议栈选型解析2.1 为什么选择STM32F103与K9F1G08U0A选择STM32F103系列作为主控首要原因就是它内置了全速USB 2.0从设备控制器。这意味着我们不需要外挂复杂的USB PHY芯片大大简化了硬件设计和BOM成本。其USB控制器自带专用数据缓冲区Packet Buffer能通过DMA与内核共享内存减轻了CPU在数据搬运上的负担。对于实现U盘这类需要稳定、中等速率数据传输的设备STM32F103的72MHz主频和全速USB12Mbps的带宽是足够胜任的。至于存储介质选择三星的K9F1G08U0A这片NAND Flash主要是看中其容量128MB和性价比。它是典型的“大页”NAND一页有(2K 64)字节2KB数据区64字节备用区一个块包含64页也就是128KB。相比“小页”NAND如K9F120851216字节/页大页NAND的连续读写效率更高但坏块管理和磨损均衡的算法也相对更复杂一些。对于自制U盘项目这片Flash的容量和速度是一个很好的平衡点。注意NAND Flash的“页”是读写的最小单位“块”是擦除的最小单位。写入前必须先擦除整个块将所有位设为1这是所有NAND Flash的物理特性也是驱动设计中必须严格遵守的铁律。2.2 USB Mass Storage与Bulk-Only Transport协议精要电脑之所以能识别我们的设备为U盘是因为我们实现了USB Mass Storage Class规范。在这个规范下我们选择了最常用、也最纯粹的Bulk-Only Transport传输协议。设备如何宣告自己这由USB描述符决定。在我们的代码usb_desc.c中需要精心配置几个关键描述符设备描述符告诉主机这是一个Mass Storage设备bDeviceClass 0x00 在接口中定义使用USB 2.0全速。配置描述符与接口描述符这是核心。接口描述符中bInterfaceClass必须设为0x08表示Mass StoragebInterfaceSubClass设为0x06表示支持SCSI透明命令集bInterfaceProtocol设为0x50这明确告知主机我们使用仅批量传输协议即BOT。这是最简单的一种不需要中断端点。端点描述符除了默认的控制端点0我们还需要额外配置两个批量端点。一个Bulk-IN端点设备到主机例如端点1用于发送数据一个Bulk-OUT端点主机到设备例如端点2用于接收数据和命令。它们的最大包长通常设置为64字节全速USB上限。通信的基本单元CBW、CSW与数据阶段BOT协议的所有通信都围绕三种结构进行理解它们的关系就理解了整个数据流。命令块包装主机发送一个31字节固定格式的包给设备。这个包里最重要的信息是dCBWSignature固定为0x43425355即“USBC”的ASCII码、dCBWDataTransferLength本次命令要求传输的数据长度以及内嵌的SCSI命令块如读、写、查询等。数据阶段根据CBW中的命令可能会有大量的数据需要传输。如果是READ(10)命令设备需要通过Bulk-IN端点向主机发送数据如果是WRITE(10)命令主机则通过Bulk-OUT端点向设备发送数据。这个阶段可能包含多个USB数据包。命令状态包装数据阶段结束后设备必须向主机发送一个13字节的CSW包。其中dCSWSignature固定为0x53425355“USBS”dCSWDataResidue表示未完成的数据量应为0最关键的是bCSWStatus它告诉主机命令执行结果0成功1失败2阶段错误。这个过程就像一个严谨的对话主机说“我要你读第X块数据共Y字节”CBW设备说“好的数据来了”数据阶段IN传输最后设备汇报“任务完成一切顺利”CSW状态为0。任何一步出错主机都会尝试复位或提示格式化失败。3. 软件架构与关键流程实现3.1 系统初始化与USB枚举流程系统上电后第一步是完成基础配置。首先是系统时钟树初始化确保USB外设的时钟源是精确的48MHz这是USB全速通信的硬性要求。接着初始化GPIO将USB的DPD引脚通过一个1.5kΩ电阻上拉到3.3V这是全速USB从设备的识别信号。USB本身的初始化在USB_Init函数中完成主要是复位USB内核、配置控制寄存器、使能相关中断。这里的中断配置是关键STM32的USB中断映射到三个NVIC通道USB低优先级中断处理所有通用事件如总线复位、挂起、端点传输完成。我们的主要逻辑都在这里。USB高优先级中断专为同步和双缓冲批量传输设计以保证实时性。在简单的BOT U盘项目中通常不用。USB唤醒中断用于从挂起模式唤醒。初始化完成后设备连接电脑枚举过程随即开始。电脑的USB主控制器会发送一系列标准请求如获取描述符、设置地址、设置配置我们的固件在控制端点0的中断服务程序中响应这些请求。当主机成功获取并接受了我们的Mass Storage相关描述符后枚举完成设备管理器里会出现一个“大容量存储设备”。3.2 BOT状态机数据流转的核心引擎枚举成功后真正的数据读写就交给了BOT状态机来调度。这个状态机是整个固件的“大脑”它定义了设备在接收到CBW包后的一系列状态变迁。通常我们会定义几个状态常量如BOT_STATE_IDLE空闲、BOT_STATE_DATA_OUT接收主机数据、BOT_STATE_DATA_IN向主机发送数据、BOT_STATE_CSW_SEND发送状态。其工作流程紧密耦合在两个端点的中断服务函数中端点2 OUT中断当主机通过OUT端点发来数据时触发。中断服务程序首先将USB缓冲区中的数据拷贝到MCU的RAM中。然后检查BOT状态如果是BOT_STATE_IDLE则判断收到的是否为合法的CBW包检查签名和长度。如果是则解析CBW提取出SCSI命令如READ10或WRITE10和逻辑块地址并跳转到对应的命令处理函数同时将状态改为BOT_STATE_DATA_IN或BOT_STATE_DATA_OUT。如果是BOT_STATE_DATA_OUT说明正处于WRITE10命令的数据接收阶段则将数据存入写缓存区。端点1 IN中断当设备通过IN端点向主机发送完一个数据包后触发。中断服务程序检查BOT状态如果是BOT_STATE_DATA_IN说明正处于READ10命令的数据发送阶段则准备下一个64字节的数据包并发送。如果所有数据都已发送或接收完毕则将状态改为BOT_STATE_CSW_SEND并主动调用函数发送CSW状态包给主机。这个状态机确保了命令、数据、状态三个阶段严格有序地执行避免了数据混乱。在usb_bot.c文件中Mass_Storage_Out和Mass_Storage_In这两个函数就是状态机的具体实现。3.3 SCSI命令处理与文件系统的桥梁主机通过CBW发送过来的命令是遵循SCSI Primary Command-2规范的命令描述块。对于U盘最核心的两个命令是READ(10)读取逻辑块。主机指定起始逻辑块地址和要读取的块数。WRITE(10)写入逻辑块。主机指定起始逻辑块地址和要写入的块数及数据。在usb_scsi.c中SCSI_Read10_Cmd和SCSI_Write10_Cmd函数负责处理这两个命令。它们的主要任务是将SCSI的“逻辑块地址”映射到NAND Flash的物理地址并调用底层的Flash读写函数。这里有一个关键点SCSI命令以“扇区”为单位操作通常一个扇区是512字节。而USB全速端点的最大包长是64字节NAND Flash的读写单位是页2KB。这就需要在软件中做多次缓冲和转换。以READ(10)为例其内部流程如下从CBW中解析出起始逻辑块地址LBA和块数BlockNbr。将LBA乘以512换算成字节地址。再根据Flash的页大小计算出起始页号和页内偏移。进入一个循环每次处理一个扇区512字节或一页2KB即4个扇区的数据。从Flash中读取一页数据到RAM缓冲区。将这页数据按64字节分包通过端点1 IN中断分批发送给主机。直到满足BlockNbr要求的扇区数全部发送完毕。最后将BOT状态置为发送CSW。WRITE(10)的处理则是反向的主机通过端点2 OUT发来数据我们将其按64字节接收在RAM中拼凑成512字节的扇区再拼凑成2KB的整页最后调用Flash写函数写入。这里必须注意写操作必须等够512字节一个扇区或2KB一页的整数倍数据后才能一次性写入Flash因为Flash编程的最小单位是页。4. NAND Flash驱动与坏块管理实战4.1 Flash底层读写与擦除管理直接操作K9F1G08U0A这类原始NAND Flash和操作SD卡或SPI Flash有本质区别其复杂性主要来自于写前需擦除和存在坏块这两个特性。基本操作函数 驱动层需要实现几个最基础的函数它们通过GPIO模拟或FSMC总线发送特定命令序列来操作FlashFlash_Reset()复位芯片。Flash_Read_ID()读取制造商和设备ID用于检测和初始化。Flash_Read_Page()读取一页数据到缓冲区。需要先发送读命令和地址然后从I/O口连续读取数据。Flash_Write_Page()写入一页数据。需要先发送串行数据加载命令和地址然后连续写入数据最后发送编程命令启动写入。Flash_Erase_Block()擦除一个块。发送擦除命令和块地址然后确认。擦除管理的核心挑战 假设我们要写入一个扇区512字节但其所在的页可能已有其他数据。由于不能直接改写标准流程是将目标块内所有有效数据包括要修改的页和其他页读取到RAM。擦除整个目标块。将RAM中修改后的数据新数据旧数据写回目标块。 这个过程效率极低尤其对于128KB的大块需要巨大的RAM做缓存在STM32上不现实。高效的“交换块”算法 这里我借鉴并移植了“圈圈”大神的高效算法。其核心思想是预留若干个物理块作为“交换区”。当需要写入某个块内的一个页时不直接擦除原块而是将原块的全部数据使用Flash内部的页复制命令快速拷贝到一个空闲的交换块中。在交换块中使用随机数据写入命令修改目标页的数据。最后擦除原块再将交换块中的数据整体复制回原块。为了均衡磨损多个交换块轮流使用。这个算法巧妙利用了NAND Flash的内部命令避免了大量数据在MCU RAM和Flash间的搬运极大提升了写效率尤其是在连续写入时优势明显。4.2 坏块管理与地址重映射NAND Flash出厂时就有坏块使用过程中也会产生新的坏块。坏块管理是保证数据可靠性的生命线。坏块表 我们需要在Flash中找一个固定的、安全的区域通常是最后几个块来存储一张坏块映射表。这张表是一个二维数组记录了原始坏块地址和替换它的好块地址的映射关系。为了安全这张表会在三个不同的块中保存三份副本采用“写前擦除、顺序写入、校验和验证”的机制确保即使某次写入时断电也至少有一份完整可用的备份。地址重映射流程 每当固件需要访问一个逻辑块地址时都必须先经过“重映射”函数检查坏块数量。如果为0直接使用原地址。判断本次访问的地址是否与上次访问在同一块。如果是直接使用上次计算好的映射地址提高效率。如果不是则在坏块映射表中进行二分查找。因为映射表是按地址排序的二分法能在最多6次比较内对于50个坏块确定该逻辑地址是否对应一个坏块。如果是坏块则返回映射表中的备用块地址如果不是则返回原地址。坏块发现与处理 在擦除或写入操作后必须读取状态寄存器确认操作成功。如果失败则标记该块为坏块。处理流程是从预留的备用块池中找到一个未使用的好块。更新坏块映射表建立“原坏块地址 - 新备用块地址”的映射。将新的映射表重新写入三个备份块中。更新内存中的坏块数量。这套机制虽然增加了每次访问的查表开销但它是商用级U盘和SSD的基石。没有它一旦遇到坏块整个文件系统就可能崩溃。5. 调试心得与常见问题排查5.1 开发环境搭建与调试技巧硬件准备 除了STM32最小系统和NAND Flash一个可靠的USB连接至关重要。建议使用带电源和数据指示灯的USB线便于观察枚举状态。在DP引脚上串联一个22Ω的电阻有助于抑制信号反射提高通信稳定性。务必确保USB的VBUS5V和GND连接稳定电压跌落会导致枚举失败。软件工具链IDEKeil MDK或STM32CubeIDE均可。我使用Keil因为它对STM32的调试支持非常成熟。调试器ST-Link V2是最佳选择。除了下载程序其实时变量查看和串口打印功能在调试USB这种实时系统时无可替代。PC端工具USBlyzer或Wireshark配合USBPcap是抓取USB数据包的利器。当电脑提示“无法识别的设备”或“格式化失败”时抓包分析能让你一眼看出是描述符错误、命令超时还是数据校验失败。核心调试方法分步验证不要试图一次性跑通所有代码。先写一个简单的LED闪烁程序确保MCU能跑起来。然后单独测试NAND Flash的ID读取、页读写、块擦除功能用串口打印出数据进行校验。利用断点和实时变量在USB中断入口、CBW解析函数、SCSI命令处理函数入口设置断点。观察BOT_STATE、CBW.CB[0]SCSI命令码、LBA等关键变量的值看状态机流转是否符合预期。串口日志输出在代码关键路径添加printf语句输出如“枚举开始”、“收到READ10命令LBA%lu”、“开始写入Flash页”等信息。这是追踪程序流最直观的方式。5.2 典型问题与解决方案实录在调试的两个多星期里我遇到了几乎所有新手可能遇到的问题。下面这个表格总结了我的“踩坑”记录和解决方法问题现象可能原因排查思路与解决方案电脑完全无法识别设备提示“未知USB设备”1. 硬件连接问题VBUS/DP/DM2. USB时钟不是精确的48MHz3. 描述符错误或格式不对4. 端点0中断未正确响应1. 检查USB线、上拉电阻、电源。用万用表量VBUS电压。2. 检查系统时钟配置确保PLL输出72MHzUSB预分频得到48MHz。3.使用USBlyzer抓包查看设备枚举阶段主机发出的Get_Descriptor请求对比设备返回的描述符数据是否与代码定义一致。特别注意描述符的总长度和字段值。4. 在USB_ISTR中断服务程序中设置断点看是否能进入。检查中断优先级和使能位。设备能识别为“大容量存储设备”但提示“需要格式化”点击格式化又失败1. SCSI命令响应错误2.READ_CAPACITY或READ_10命令返回的数据格式错误3. Flash底层读写函数有bug返回的数据全为0xFF或错误4. BOT状态机逻辑错误CSW状态返回非01. 抓包分析主机发出的SCSI命令在CBW中检查SCSI_Read10_Cmd等函数是否被正确调用。2.重点检查SCSI_ReadCapacity_Cmd函数。它返回的扇区总数和扇区大小必须是正确的。扇区总数 Flash总容量 / 512要用大端格式发送。3. 单独编写测试函数绕过USB直接通过串口命令读取Flash特定地址的数据验证读写正确性。4. 确保在每个命令处理结束后正确发送CSW包且bCSWStatus字段为0成功。拷贝小文件正常拷贝大文件或速度很慢时出错1. USB端点缓冲区溢出2. Flash写入速度跟不上USB接收速度导致数据丢失3. 坏块管理逻辑有缺陷在连续写入时地址映射出错4. 中断服务程序执行时间过长导致丢失USB数据包1. 检查端点缓冲区大小设置确保大于等于最大包长64。2.在WRITE10命令处理中增加流控。不要等主机发完所有数据再开始写Flash而应采用“乒乓缓冲区”准备两个RAM缓冲区一个接收USB数据另一个同时写入Flash交替使用。3. 在连续写入的测试中加入详细的串口日志打印出每一步操作的逻辑地址和映射后的物理地址检查映射关系是否一致。4. 优化中断服务程序只做最必要的数据搬运和状态设置将复杂的处理如Flash擦写放到主循环中。检查中断嵌套优先级。设备使用一段时间后突然出现文件系统错误或数据丢失1. 坏块管理失效数据写入了坏块2. 擦写均衡未做导致某个块过早磨损3. 电源不稳定在Flash编程/擦除过程中断电导致数据半写或元数据损坏1.加强坏块检测。不仅在初始化时扫描在每次擦除操作后也要检查状态发现新坏块立即加入映射表并保存。2. 实现简单的磨损均衡。记录每个块的擦除次数在分配新块时选择擦除次数最少的块。对于自制U盘可以将交换块池扩大并轮流使用这本身也是一种均衡。3. 在硬件上增加大电容稳压在软件上确保任何对关键元数据如坏块表的写入都是“原子操作”先写入备份块验证成功后再标记原块无效。5.3 从SD卡例程到NAND Flash的移植关键ST官方和很多开发板提供的Mass Storage例程都是针对SD卡的。SD卡有标准的SPI/SDIO接口和成熟的FATFS中间件读写以扇区为单位无需关心坏块。直接套用到NAND Flash上必然失败。移植的核心修改点存储介质接口层将例程中SD卡的disk_read和disk_write函数替换为NAND Flash的读写函数。这里的函数接口通常是Read_Memory(lun, addr, buffer, length)和Write_Memory(lun, addr, buffer, length)。地址转换SD卡地址是线性扇区地址。NAND Flash需要在此函数内部进行从逻辑扇区地址到物理页 块地址的转换并集成坏块重映射逻辑。写入管理这是最大不同点。SD卡可以直接覆盖写一个扇区。NAND Flash的写函数必须包含前文所述的“交换块”擦写管理算法确保在写入前目标区域已被擦除。初始化在USB_Init之后需要增加Flash初始化函数包括ID检测、坏块表加载或创建等。我遇到的最大坑最初直接使用简单的页写函数没有擦除管理。结果电脑每次格式化都“成功”但一写入数据就失败。抓包发现主机发送WRITE命令后设备返回的CSW状态是失败。根本原因是Flash在没有擦除的情况下写入内部编程操作失败状态寄存器报错。直到移植了完整的擦写管理算法后问题才得以解决。6. 代码结构梳理与优化建议6.1 官方库文件与用户文件分工理解代码结构能让开发和调试事半功倍。整个工程文件可以清晰分为三层第一层USB硬件抽象层位于USB_DRIVER目录下包含usb_regs.c/husb_init.cusb_int.cusb_mem.c等。这些文件直接操作STM32的USB寄存器处理底层中断、缓冲区管理。除非非常了解USB硬件否则不要修改这些文件。它们提供了稳定的硬件接口。第二层Mass Storage类协议栈这是项目的核心通常放在USB_APP目录下。usb_bot.c/hBOT状态机的实现。包含CBW_DecodeMass_Storage_InMass_Storage_Out等核心函数。这是数据流控制的枢纽。usb_scsi.c/hSCSI命令解析与处理。包含SCSI_Read10_CmdSCSI_Write10_CmdSCSI_Inquiry_Cmd等函数。这里实现了U盘对主机各种查询和读写命令的响应。usb_pwr.c/h电源管理挂起/唤醒。usb_desc.c/h所有USB描述符的定义。修改设备名称、厂商ID、产品ID、端点大小等都在这里。usb_prop.c/h设备属性回调函数如MASS_ResetUSB复位处理MASS_Init等。usb_endp.c/h非控制端点端点1、2的初始化函数。通常只需配置端点类型和缓冲区地址。第三层设备应用层这是我们需要重点编写和修改的部分。memory.c/h存储介质驱动接口。这是连接协议栈和硬件的桥梁。必须实现Read_Memory和Write_Memory函数供usb_scsi.c调用。内部再调用具体的NAND Flash驱动函数。nand_flash.c/hNAND Flash底层驱动。实现页读、页写、块擦除、坏块管理、地址映射等所有硬件操作。main.c系统初始化调用USB_Init 然后进入主循环。主循环可以处理一些后台任务如Flash的垃圾回收如果有实现。6.2 性能与可靠性优化思路当基本功能实现后可以考虑以下优化来提升U盘的实用性和可靠性增加写缓存目前是攒够一个扇区或一页才写Flash。可以开辟一个更大的RAM缓冲区如16KB在写入时先填充缓冲区缓冲区满或主机发送SYNC_CACHE命令时再一次性写入Flash。这能显著提升小文件连续写入的速度并减少Flash擦写次数。实现垃圾回收由于NAND Flash的“异地更新”特性新数据写在交换块旧块被标记无效会产生大量包含无效数据的“脏块”。可以在主机空闲时如USB挂起后在后台运行垃圾回收程序将有效数据合并擦除脏块释放空间。加入写保护开关通过一个GPIO引脚连接物理开关在Write_Memory函数中检测该引脚电平。如果写保护开启则对WRITE和ERASE命令返回错误。这是一个非常实用的功能。完善错误处理在SCSI_Read10_Cmd和SCSI_Write10_Cmd中对底层Flash函数的返回值进行严格检查。一旦读写失败应尝试重试若重试失败则返回CSW状态为0x01命令失败并触发坏块管理流程而不是让程序死锁或返回错误数据。功耗优化在USB_Suspend中断中将MCU和Flash进入低功耗模式。当检测到USB总线恢复活动时再唤醒。这对于电池供电的设备很有意义。经过这次从零到一的实现过程我深刻体会到嵌入式开发不仅仅是调通代码更是对硬件特性、通信协议和系统资源管理的综合运用。STM32的USB外设虽然复杂但结构清晰NAND Flash管理虽然繁琐但算法固定。最难的部分其实是调试是当现象不符合预期时如何利用有限的信息LED、串口、调试器去定位层层封装之下的问题所在。这个过程痛苦但收获巨大。最后给想复现这个项目的朋友一个建议一定要循序渐进先确保Flash的单元测试通过再调试USB枚举最后整合BOT协议。每走通一步就离成功的“叮咚”U盘插入提示音声更近一步。
基于STM32与NAND Flash实现USB Mass Storage U盘的完整开发指南
发布时间:2026/6/5 12:54:15
1. 项目概述从零打造一个基于STM32的U盘最近花了两个多星期终于把STM32F103C8T6这颗芯片和一片大页NAND FlashK9F1G08U0A给“撮合”到了一起成功实现了一个128MB的U盘。整个过程下来感觉像是打通了嵌入式开发的任督二脉从USB协议栈的懵懂到NAND Flash管理的头大再到最终在电脑上看到“可移动磁盘”的狂喜踩的坑和收获的经验一样多。这个项目非常适合想深入理解USB Mass Storage协议、学习NAND Flash底层驱动以及掌握STM32 USB外设的嵌入式开发者。无论你是刚接触USB的学生还是想为产品增加存储功能的工程师跟着这个思路走一遍绝对能让你对“数据是如何从电脑走到芯片内部的Flash里”这件事有透彻的理解。简单来说这个项目的核心就是让电脑把我们的STM32Flash组合识别为一个标准的U盘可以进行文件的复制、粘贴、删除。这背后需要STM32的USB从机控制器模拟一个Mass Storage设备并通过Bulk-Only Transport协议与电脑通信同时还需要一套可靠的底层驱动来管理NAND Flash的读写、擦除和坏块。下面我就把这两个多星期里梳理清楚的硬件选型、协议栈解析、驱动编写和调试心得毫无保留地分享出来。2. 核心硬件与协议栈选型解析2.1 为什么选择STM32F103与K9F1G08U0A选择STM32F103系列作为主控首要原因就是它内置了全速USB 2.0从设备控制器。这意味着我们不需要外挂复杂的USB PHY芯片大大简化了硬件设计和BOM成本。其USB控制器自带专用数据缓冲区Packet Buffer能通过DMA与内核共享内存减轻了CPU在数据搬运上的负担。对于实现U盘这类需要稳定、中等速率数据传输的设备STM32F103的72MHz主频和全速USB12Mbps的带宽是足够胜任的。至于存储介质选择三星的K9F1G08U0A这片NAND Flash主要是看中其容量128MB和性价比。它是典型的“大页”NAND一页有(2K 64)字节2KB数据区64字节备用区一个块包含64页也就是128KB。相比“小页”NAND如K9F120851216字节/页大页NAND的连续读写效率更高但坏块管理和磨损均衡的算法也相对更复杂一些。对于自制U盘项目这片Flash的容量和速度是一个很好的平衡点。注意NAND Flash的“页”是读写的最小单位“块”是擦除的最小单位。写入前必须先擦除整个块将所有位设为1这是所有NAND Flash的物理特性也是驱动设计中必须严格遵守的铁律。2.2 USB Mass Storage与Bulk-Only Transport协议精要电脑之所以能识别我们的设备为U盘是因为我们实现了USB Mass Storage Class规范。在这个规范下我们选择了最常用、也最纯粹的Bulk-Only Transport传输协议。设备如何宣告自己这由USB描述符决定。在我们的代码usb_desc.c中需要精心配置几个关键描述符设备描述符告诉主机这是一个Mass Storage设备bDeviceClass 0x00 在接口中定义使用USB 2.0全速。配置描述符与接口描述符这是核心。接口描述符中bInterfaceClass必须设为0x08表示Mass StoragebInterfaceSubClass设为0x06表示支持SCSI透明命令集bInterfaceProtocol设为0x50这明确告知主机我们使用仅批量传输协议即BOT。这是最简单的一种不需要中断端点。端点描述符除了默认的控制端点0我们还需要额外配置两个批量端点。一个Bulk-IN端点设备到主机例如端点1用于发送数据一个Bulk-OUT端点主机到设备例如端点2用于接收数据和命令。它们的最大包长通常设置为64字节全速USB上限。通信的基本单元CBW、CSW与数据阶段BOT协议的所有通信都围绕三种结构进行理解它们的关系就理解了整个数据流。命令块包装主机发送一个31字节固定格式的包给设备。这个包里最重要的信息是dCBWSignature固定为0x43425355即“USBC”的ASCII码、dCBWDataTransferLength本次命令要求传输的数据长度以及内嵌的SCSI命令块如读、写、查询等。数据阶段根据CBW中的命令可能会有大量的数据需要传输。如果是READ(10)命令设备需要通过Bulk-IN端点向主机发送数据如果是WRITE(10)命令主机则通过Bulk-OUT端点向设备发送数据。这个阶段可能包含多个USB数据包。命令状态包装数据阶段结束后设备必须向主机发送一个13字节的CSW包。其中dCSWSignature固定为0x53425355“USBS”dCSWDataResidue表示未完成的数据量应为0最关键的是bCSWStatus它告诉主机命令执行结果0成功1失败2阶段错误。这个过程就像一个严谨的对话主机说“我要你读第X块数据共Y字节”CBW设备说“好的数据来了”数据阶段IN传输最后设备汇报“任务完成一切顺利”CSW状态为0。任何一步出错主机都会尝试复位或提示格式化失败。3. 软件架构与关键流程实现3.1 系统初始化与USB枚举流程系统上电后第一步是完成基础配置。首先是系统时钟树初始化确保USB外设的时钟源是精确的48MHz这是USB全速通信的硬性要求。接着初始化GPIO将USB的DPD引脚通过一个1.5kΩ电阻上拉到3.3V这是全速USB从设备的识别信号。USB本身的初始化在USB_Init函数中完成主要是复位USB内核、配置控制寄存器、使能相关中断。这里的中断配置是关键STM32的USB中断映射到三个NVIC通道USB低优先级中断处理所有通用事件如总线复位、挂起、端点传输完成。我们的主要逻辑都在这里。USB高优先级中断专为同步和双缓冲批量传输设计以保证实时性。在简单的BOT U盘项目中通常不用。USB唤醒中断用于从挂起模式唤醒。初始化完成后设备连接电脑枚举过程随即开始。电脑的USB主控制器会发送一系列标准请求如获取描述符、设置地址、设置配置我们的固件在控制端点0的中断服务程序中响应这些请求。当主机成功获取并接受了我们的Mass Storage相关描述符后枚举完成设备管理器里会出现一个“大容量存储设备”。3.2 BOT状态机数据流转的核心引擎枚举成功后真正的数据读写就交给了BOT状态机来调度。这个状态机是整个固件的“大脑”它定义了设备在接收到CBW包后的一系列状态变迁。通常我们会定义几个状态常量如BOT_STATE_IDLE空闲、BOT_STATE_DATA_OUT接收主机数据、BOT_STATE_DATA_IN向主机发送数据、BOT_STATE_CSW_SEND发送状态。其工作流程紧密耦合在两个端点的中断服务函数中端点2 OUT中断当主机通过OUT端点发来数据时触发。中断服务程序首先将USB缓冲区中的数据拷贝到MCU的RAM中。然后检查BOT状态如果是BOT_STATE_IDLE则判断收到的是否为合法的CBW包检查签名和长度。如果是则解析CBW提取出SCSI命令如READ10或WRITE10和逻辑块地址并跳转到对应的命令处理函数同时将状态改为BOT_STATE_DATA_IN或BOT_STATE_DATA_OUT。如果是BOT_STATE_DATA_OUT说明正处于WRITE10命令的数据接收阶段则将数据存入写缓存区。端点1 IN中断当设备通过IN端点向主机发送完一个数据包后触发。中断服务程序检查BOT状态如果是BOT_STATE_DATA_IN说明正处于READ10命令的数据发送阶段则准备下一个64字节的数据包并发送。如果所有数据都已发送或接收完毕则将状态改为BOT_STATE_CSW_SEND并主动调用函数发送CSW状态包给主机。这个状态机确保了命令、数据、状态三个阶段严格有序地执行避免了数据混乱。在usb_bot.c文件中Mass_Storage_Out和Mass_Storage_In这两个函数就是状态机的具体实现。3.3 SCSI命令处理与文件系统的桥梁主机通过CBW发送过来的命令是遵循SCSI Primary Command-2规范的命令描述块。对于U盘最核心的两个命令是READ(10)读取逻辑块。主机指定起始逻辑块地址和要读取的块数。WRITE(10)写入逻辑块。主机指定起始逻辑块地址和要写入的块数及数据。在usb_scsi.c中SCSI_Read10_Cmd和SCSI_Write10_Cmd函数负责处理这两个命令。它们的主要任务是将SCSI的“逻辑块地址”映射到NAND Flash的物理地址并调用底层的Flash读写函数。这里有一个关键点SCSI命令以“扇区”为单位操作通常一个扇区是512字节。而USB全速端点的最大包长是64字节NAND Flash的读写单位是页2KB。这就需要在软件中做多次缓冲和转换。以READ(10)为例其内部流程如下从CBW中解析出起始逻辑块地址LBA和块数BlockNbr。将LBA乘以512换算成字节地址。再根据Flash的页大小计算出起始页号和页内偏移。进入一个循环每次处理一个扇区512字节或一页2KB即4个扇区的数据。从Flash中读取一页数据到RAM缓冲区。将这页数据按64字节分包通过端点1 IN中断分批发送给主机。直到满足BlockNbr要求的扇区数全部发送完毕。最后将BOT状态置为发送CSW。WRITE(10)的处理则是反向的主机通过端点2 OUT发来数据我们将其按64字节接收在RAM中拼凑成512字节的扇区再拼凑成2KB的整页最后调用Flash写函数写入。这里必须注意写操作必须等够512字节一个扇区或2KB一页的整数倍数据后才能一次性写入Flash因为Flash编程的最小单位是页。4. NAND Flash驱动与坏块管理实战4.1 Flash底层读写与擦除管理直接操作K9F1G08U0A这类原始NAND Flash和操作SD卡或SPI Flash有本质区别其复杂性主要来自于写前需擦除和存在坏块这两个特性。基本操作函数 驱动层需要实现几个最基础的函数它们通过GPIO模拟或FSMC总线发送特定命令序列来操作FlashFlash_Reset()复位芯片。Flash_Read_ID()读取制造商和设备ID用于检测和初始化。Flash_Read_Page()读取一页数据到缓冲区。需要先发送读命令和地址然后从I/O口连续读取数据。Flash_Write_Page()写入一页数据。需要先发送串行数据加载命令和地址然后连续写入数据最后发送编程命令启动写入。Flash_Erase_Block()擦除一个块。发送擦除命令和块地址然后确认。擦除管理的核心挑战 假设我们要写入一个扇区512字节但其所在的页可能已有其他数据。由于不能直接改写标准流程是将目标块内所有有效数据包括要修改的页和其他页读取到RAM。擦除整个目标块。将RAM中修改后的数据新数据旧数据写回目标块。 这个过程效率极低尤其对于128KB的大块需要巨大的RAM做缓存在STM32上不现实。高效的“交换块”算法 这里我借鉴并移植了“圈圈”大神的高效算法。其核心思想是预留若干个物理块作为“交换区”。当需要写入某个块内的一个页时不直接擦除原块而是将原块的全部数据使用Flash内部的页复制命令快速拷贝到一个空闲的交换块中。在交换块中使用随机数据写入命令修改目标页的数据。最后擦除原块再将交换块中的数据整体复制回原块。为了均衡磨损多个交换块轮流使用。这个算法巧妙利用了NAND Flash的内部命令避免了大量数据在MCU RAM和Flash间的搬运极大提升了写效率尤其是在连续写入时优势明显。4.2 坏块管理与地址重映射NAND Flash出厂时就有坏块使用过程中也会产生新的坏块。坏块管理是保证数据可靠性的生命线。坏块表 我们需要在Flash中找一个固定的、安全的区域通常是最后几个块来存储一张坏块映射表。这张表是一个二维数组记录了原始坏块地址和替换它的好块地址的映射关系。为了安全这张表会在三个不同的块中保存三份副本采用“写前擦除、顺序写入、校验和验证”的机制确保即使某次写入时断电也至少有一份完整可用的备份。地址重映射流程 每当固件需要访问一个逻辑块地址时都必须先经过“重映射”函数检查坏块数量。如果为0直接使用原地址。判断本次访问的地址是否与上次访问在同一块。如果是直接使用上次计算好的映射地址提高效率。如果不是则在坏块映射表中进行二分查找。因为映射表是按地址排序的二分法能在最多6次比较内对于50个坏块确定该逻辑地址是否对应一个坏块。如果是坏块则返回映射表中的备用块地址如果不是则返回原地址。坏块发现与处理 在擦除或写入操作后必须读取状态寄存器确认操作成功。如果失败则标记该块为坏块。处理流程是从预留的备用块池中找到一个未使用的好块。更新坏块映射表建立“原坏块地址 - 新备用块地址”的映射。将新的映射表重新写入三个备份块中。更新内存中的坏块数量。这套机制虽然增加了每次访问的查表开销但它是商用级U盘和SSD的基石。没有它一旦遇到坏块整个文件系统就可能崩溃。5. 调试心得与常见问题排查5.1 开发环境搭建与调试技巧硬件准备 除了STM32最小系统和NAND Flash一个可靠的USB连接至关重要。建议使用带电源和数据指示灯的USB线便于观察枚举状态。在DP引脚上串联一个22Ω的电阻有助于抑制信号反射提高通信稳定性。务必确保USB的VBUS5V和GND连接稳定电压跌落会导致枚举失败。软件工具链IDEKeil MDK或STM32CubeIDE均可。我使用Keil因为它对STM32的调试支持非常成熟。调试器ST-Link V2是最佳选择。除了下载程序其实时变量查看和串口打印功能在调试USB这种实时系统时无可替代。PC端工具USBlyzer或Wireshark配合USBPcap是抓取USB数据包的利器。当电脑提示“无法识别的设备”或“格式化失败”时抓包分析能让你一眼看出是描述符错误、命令超时还是数据校验失败。核心调试方法分步验证不要试图一次性跑通所有代码。先写一个简单的LED闪烁程序确保MCU能跑起来。然后单独测试NAND Flash的ID读取、页读写、块擦除功能用串口打印出数据进行校验。利用断点和实时变量在USB中断入口、CBW解析函数、SCSI命令处理函数入口设置断点。观察BOT_STATE、CBW.CB[0]SCSI命令码、LBA等关键变量的值看状态机流转是否符合预期。串口日志输出在代码关键路径添加printf语句输出如“枚举开始”、“收到READ10命令LBA%lu”、“开始写入Flash页”等信息。这是追踪程序流最直观的方式。5.2 典型问题与解决方案实录在调试的两个多星期里我遇到了几乎所有新手可能遇到的问题。下面这个表格总结了我的“踩坑”记录和解决方法问题现象可能原因排查思路与解决方案电脑完全无法识别设备提示“未知USB设备”1. 硬件连接问题VBUS/DP/DM2. USB时钟不是精确的48MHz3. 描述符错误或格式不对4. 端点0中断未正确响应1. 检查USB线、上拉电阻、电源。用万用表量VBUS电压。2. 检查系统时钟配置确保PLL输出72MHzUSB预分频得到48MHz。3.使用USBlyzer抓包查看设备枚举阶段主机发出的Get_Descriptor请求对比设备返回的描述符数据是否与代码定义一致。特别注意描述符的总长度和字段值。4. 在USB_ISTR中断服务程序中设置断点看是否能进入。检查中断优先级和使能位。设备能识别为“大容量存储设备”但提示“需要格式化”点击格式化又失败1. SCSI命令响应错误2.READ_CAPACITY或READ_10命令返回的数据格式错误3. Flash底层读写函数有bug返回的数据全为0xFF或错误4. BOT状态机逻辑错误CSW状态返回非01. 抓包分析主机发出的SCSI命令在CBW中检查SCSI_Read10_Cmd等函数是否被正确调用。2.重点检查SCSI_ReadCapacity_Cmd函数。它返回的扇区总数和扇区大小必须是正确的。扇区总数 Flash总容量 / 512要用大端格式发送。3. 单独编写测试函数绕过USB直接通过串口命令读取Flash特定地址的数据验证读写正确性。4. 确保在每个命令处理结束后正确发送CSW包且bCSWStatus字段为0成功。拷贝小文件正常拷贝大文件或速度很慢时出错1. USB端点缓冲区溢出2. Flash写入速度跟不上USB接收速度导致数据丢失3. 坏块管理逻辑有缺陷在连续写入时地址映射出错4. 中断服务程序执行时间过长导致丢失USB数据包1. 检查端点缓冲区大小设置确保大于等于最大包长64。2.在WRITE10命令处理中增加流控。不要等主机发完所有数据再开始写Flash而应采用“乒乓缓冲区”准备两个RAM缓冲区一个接收USB数据另一个同时写入Flash交替使用。3. 在连续写入的测试中加入详细的串口日志打印出每一步操作的逻辑地址和映射后的物理地址检查映射关系是否一致。4. 优化中断服务程序只做最必要的数据搬运和状态设置将复杂的处理如Flash擦写放到主循环中。检查中断嵌套优先级。设备使用一段时间后突然出现文件系统错误或数据丢失1. 坏块管理失效数据写入了坏块2. 擦写均衡未做导致某个块过早磨损3. 电源不稳定在Flash编程/擦除过程中断电导致数据半写或元数据损坏1.加强坏块检测。不仅在初始化时扫描在每次擦除操作后也要检查状态发现新坏块立即加入映射表并保存。2. 实现简单的磨损均衡。记录每个块的擦除次数在分配新块时选择擦除次数最少的块。对于自制U盘可以将交换块池扩大并轮流使用这本身也是一种均衡。3. 在硬件上增加大电容稳压在软件上确保任何对关键元数据如坏块表的写入都是“原子操作”先写入备份块验证成功后再标记原块无效。5.3 从SD卡例程到NAND Flash的移植关键ST官方和很多开发板提供的Mass Storage例程都是针对SD卡的。SD卡有标准的SPI/SDIO接口和成熟的FATFS中间件读写以扇区为单位无需关心坏块。直接套用到NAND Flash上必然失败。移植的核心修改点存储介质接口层将例程中SD卡的disk_read和disk_write函数替换为NAND Flash的读写函数。这里的函数接口通常是Read_Memory(lun, addr, buffer, length)和Write_Memory(lun, addr, buffer, length)。地址转换SD卡地址是线性扇区地址。NAND Flash需要在此函数内部进行从逻辑扇区地址到物理页 块地址的转换并集成坏块重映射逻辑。写入管理这是最大不同点。SD卡可以直接覆盖写一个扇区。NAND Flash的写函数必须包含前文所述的“交换块”擦写管理算法确保在写入前目标区域已被擦除。初始化在USB_Init之后需要增加Flash初始化函数包括ID检测、坏块表加载或创建等。我遇到的最大坑最初直接使用简单的页写函数没有擦除管理。结果电脑每次格式化都“成功”但一写入数据就失败。抓包发现主机发送WRITE命令后设备返回的CSW状态是失败。根本原因是Flash在没有擦除的情况下写入内部编程操作失败状态寄存器报错。直到移植了完整的擦写管理算法后问题才得以解决。6. 代码结构梳理与优化建议6.1 官方库文件与用户文件分工理解代码结构能让开发和调试事半功倍。整个工程文件可以清晰分为三层第一层USB硬件抽象层位于USB_DRIVER目录下包含usb_regs.c/husb_init.cusb_int.cusb_mem.c等。这些文件直接操作STM32的USB寄存器处理底层中断、缓冲区管理。除非非常了解USB硬件否则不要修改这些文件。它们提供了稳定的硬件接口。第二层Mass Storage类协议栈这是项目的核心通常放在USB_APP目录下。usb_bot.c/hBOT状态机的实现。包含CBW_DecodeMass_Storage_InMass_Storage_Out等核心函数。这是数据流控制的枢纽。usb_scsi.c/hSCSI命令解析与处理。包含SCSI_Read10_CmdSCSI_Write10_CmdSCSI_Inquiry_Cmd等函数。这里实现了U盘对主机各种查询和读写命令的响应。usb_pwr.c/h电源管理挂起/唤醒。usb_desc.c/h所有USB描述符的定义。修改设备名称、厂商ID、产品ID、端点大小等都在这里。usb_prop.c/h设备属性回调函数如MASS_ResetUSB复位处理MASS_Init等。usb_endp.c/h非控制端点端点1、2的初始化函数。通常只需配置端点类型和缓冲区地址。第三层设备应用层这是我们需要重点编写和修改的部分。memory.c/h存储介质驱动接口。这是连接协议栈和硬件的桥梁。必须实现Read_Memory和Write_Memory函数供usb_scsi.c调用。内部再调用具体的NAND Flash驱动函数。nand_flash.c/hNAND Flash底层驱动。实现页读、页写、块擦除、坏块管理、地址映射等所有硬件操作。main.c系统初始化调用USB_Init 然后进入主循环。主循环可以处理一些后台任务如Flash的垃圾回收如果有实现。6.2 性能与可靠性优化思路当基本功能实现后可以考虑以下优化来提升U盘的实用性和可靠性增加写缓存目前是攒够一个扇区或一页才写Flash。可以开辟一个更大的RAM缓冲区如16KB在写入时先填充缓冲区缓冲区满或主机发送SYNC_CACHE命令时再一次性写入Flash。这能显著提升小文件连续写入的速度并减少Flash擦写次数。实现垃圾回收由于NAND Flash的“异地更新”特性新数据写在交换块旧块被标记无效会产生大量包含无效数据的“脏块”。可以在主机空闲时如USB挂起后在后台运行垃圾回收程序将有效数据合并擦除脏块释放空间。加入写保护开关通过一个GPIO引脚连接物理开关在Write_Memory函数中检测该引脚电平。如果写保护开启则对WRITE和ERASE命令返回错误。这是一个非常实用的功能。完善错误处理在SCSI_Read10_Cmd和SCSI_Write10_Cmd中对底层Flash函数的返回值进行严格检查。一旦读写失败应尝试重试若重试失败则返回CSW状态为0x01命令失败并触发坏块管理流程而不是让程序死锁或返回错误数据。功耗优化在USB_Suspend中断中将MCU和Flash进入低功耗模式。当检测到USB总线恢复活动时再唤醒。这对于电池供电的设备很有意义。经过这次从零到一的实现过程我深刻体会到嵌入式开发不仅仅是调通代码更是对硬件特性、通信协议和系统资源管理的综合运用。STM32的USB外设虽然复杂但结构清晰NAND Flash管理虽然繁琐但算法固定。最难的部分其实是调试是当现象不符合预期时如何利用有限的信息LED、串口、调试器去定位层层封装之下的问题所在。这个过程痛苦但收获巨大。最后给想复现这个项目的朋友一个建议一定要循序渐进先确保Flash的单元测试通过再调试USB枚举最后整合BOT协议。每走通一步就离成功的“叮咚”U盘插入提示音声更近一步。