用STM32F103和SPI Flash实现一个U盘

目录

我实现了一个极简的FTL,在此基础上,使用STM32F103 和SPI Flash实现了一个USB MSC U盘。本文不包含对USB协议的讲解, 所实现的FTL也并不完整,无任何高明之处,仅仅指出使用 Flash实现一个块设备能被操作系统识别并读写至少应该 完成哪些工作。

1. 写在开头

手上有一块STM32F103开发板,上面带有一片容量为16MB的SPI NOR Flash。该板上的STM32F103ZETX MCU支持作为USB设备,ST 官方也提供了USB MSC设备类对应的demo,使用其提供的接口 能快速实现一个USB MSC U盘。我很久便想尝试自己从零实现一个 FTL,具备了这些条件之后于是开始动手尝试。

虽然应用该FTL使用的存储介质是SPI NOR Flash,但该FTL同样 考虑到Nand FLash的一些共性,因此该FTL稍加修改也是可以在 Nand Flash上测试的。

作为一个“极简”的FTL,我实现的FTL仅实现了逻辑块到物理地址 的映射,不包含其他完善的FTL应该具备的功能,如磨损均衡和坏块处理, 以及ECC、掉电安全等与可靠性相关的设计。

2. 系统架构

作为U盘的主控MCU软件在层次上主要可分为三个部分,顶层 负责处理主机通过USB接口对U盘的识别和读写请求,底层通过 SPI外设接口实现对SPI NOR FLash的直接读写,中间的FTL 作为沟通以上两者的桥梁,将主机对U盘逻辑块的读写转换 成对SPI Flash上物理地址的读写。

3. 我的极简FTL设计

3.1 为什么需要FTL

主机对U盘的读写是以块的方式进行的,典型的块大小为512字节, 对主机来说,U盘上的块可以重复写以修改某个块的内容;但对于 作为存储介质的Flash来说,读写数据需要符合一定的规则,一般 具有以下共同点:

  • Flash通常分成若干块,每个块包含若干页或扇区,不同Flash 有不同数量的块/页/扇区,其大小也有差异;
  • 可以随机读Flash上的任意块/页/扇区,但不能覆盖写,写页/扇区 时必须确保先对其所属的块进行擦除操作。

因此需要FTL对Flash的读写进行封装,使在主机看来数据块可以 随机地进行读写。U盘对主机所允许读写的单元称逻辑扇区或逻辑 块,与读写Flash的物理地址区分开。

3.2 本次使用的SPI Flash

使用的SPI NOR Flash型号为W25Q128BV,具有256个块,每个块 具有256个页,每个页大小为256字节,每个块64KB,总容量为 16MB。

尽管对于该款Flash来说,在某些情况下通过覆盖写的方式更新 写过的数据是可行的(只能将数据从1变为0),对同一个块上的 页也可以不按从头到尾的方式写,我们实现FTL时还是假设 所用的Flash在写时只能按页顺序写:在往某个块写数据时, 只能按写页0,1,2…的方式写,并且需要更新已写页时必须 先执行块擦除,不允许重复写同一个页。

3.3 逻辑块到物理块的映射

此处的逻辑块即主机读写U盘时操作的数据单元,也可称逻辑扇区, 大小为512字节。

为了实现时方便,将SPI Flash上相邻的两个页,总大小为512字节, 称为物理块(注意区分和SPI Flash大小为64KB块的区别),或称 物理扇区,这样逻辑扇区和物理扇区大小是相同的,Flash上每一个 64KB的数据块具有128个扇区。

每次主机写一个逻辑扇区时,FTL会增加一条记录,记录该写该 逻辑扇区时实际将数据写到Flash上的哪个物理扇区,这样的记录 即为l2p(logical to physical)表项。随着写操作的进行,会 逐渐产生多条l2p表项,它们组成l2p表。主机读一个逻辑扇区 时,FTL执行查找l2p表的操作,这样可以找到该逻辑扇区的数据 存放与Flash上的物理扇区的位置,读取该物理扇区的数据交付 于主机。

l2p表是由FTL维护的,并且最终会保存在Flash中,这样在U盘 断电后再重新上电,主机可以继续读取已经写入的逻辑扇区。 实际所用SPI Flash大小为16MB,部分空间用于存放l2p表, 则剩余用于存放数据的总空间不超过16MB,假设每个l2p 表项占用4个字节,那么记录所有从逻辑扇区到物理扇区l2p 表项占用不超过16 * 1024 ^ 2 / 512 * 4 = 131072字节, 也就是在未重复的情况下用SPI Flash上大致两个块可以 保存所有l2p表项。出于冗余考虑将保存l2p表的数据块增加 至4个,这样FTL用于维护l2p表占用Flash的空间大致为262KB, 约占整片Flash容量的0.8%,我认为是可接受的。

3.4 索引块和数据块

FTL将Flash上的块按功能划分为两类:用于保存l2p表项 的索引块和用于保存数据的数据块。已知总共有4个索引块, 那么总共有256 - 4 = 252个数据块。

每个索引块,第一个物理扇区是保留的,其余的扇区均用于 保存l2p表项;

每个数据块,开头和结尾总共两个扇区是保留的,其于的扇区 存放数据。

3.5 l2p表项在索引块中的存储

每次写一个逻辑扇区时,FTL就会新增加一条l2p表项,l2p表项 先存放在位于MCU RAM中大小为一个扇区的缓冲中,缓冲区满后 再写入索引块的一个扇区,也可使用flush操作立刻将缓冲区中 的l2p表项写入Flash。

对于主机来说,逻辑扇区是可以使用覆盖写的方式更新数据的, 因此,l2p表中不同记录的逻辑扇区号可能发生重复,但其中 最多只有一条记录是有效的。

随着写操作的进行,索引块中数据存放的形式可见下所示:

sector  content
0       reserve
1       l2p item, l2p item, ... l2p item
2       l2p item, l2p item, ... free
3       free
.       free
.       free
.       free
127     free

索引块的第一个扇区保留作特殊用途,稍后说明;

其他的扇区,对于同一扇区中的l2p表项,位于高地址的总比 位于低地址的要更新;同一索引块不同扇区中的l2p表项, 位于扇区号大的总比位于扇区号小的要更新。

由于Flash每次写之前都必须确保页所在的块已经过擦除, 每次重新写一个索引块之前,在执行擦除操作之后,往 扇区0开头的8个字节写入如下内容:

00 00 00 00 XX XX XX XX

开头4个字节的0表示该索引块已被使用,同理,这4个字节 为0xff时表示该索引块空闲。接下来的4个字节表示该索引 块的版本号,用于区分索引块的新旧,更新的索引块中的 l2p表项总比旧的索引块中的更新。

查找l2p表项按以下的方式进行:

  • 不同索引块按版本号,从最新的索引块进行查找;
  • 同一索引块按扇区编号从最后到开头进行查找;
  • 同一扇区按从高地址到低地址查找。

由于索引块的数量是有限的,持续写逻辑扇区的操作最终会 使索引块写满,这是FTL执行一次索引块“紧凑”操作,遍历 所有的l2p表项,逻辑扇区相同也就是重复的l2p表项仅保留 一份,释放的空间可用于继续记录新l2p表项。

索引块的紧凑操作方法如下:

  • 取一个空闲的索引块作为搬移l2p表项的目标;
  • 对所有已用的索引块,每次取其中版本号最新的块,从后到 前扫描块中所有的l2p表项,如果表项不在搬移的目标 块中,则将其搬移到目标块;
  • 索引块中所有的l2p表项操作完成后,该块可执行擦除 作为空闲块,在前面搬移的目标块写满之后该块可被选为 搬移的新目标块;
  • 执行以上的操作直到最初使用的索引块中的l2p表项全部 遍历结束。

由于紧凑操作要求至少要有一个空闲的索引块,所以实际用于 保存l2p表的索引块至多有3个,这也是分配索引块时冗余的 考虑。

3.6 数据在数据块中的存储

与索引块相似,数据块中也有一些扇区保留作特殊用途。每个 数据块中,第一个和最后一个扇区都是保留的,其他扇区可 用于保存从主机写入的数据。

数据块保留扇区以这种方式使用:块中的第一个扇区开始的 4个字节为0时,表示该块已被使用;最后一个扇区开始的4 个字节为0时,表示该块已满。分别读取这两个保留扇区开始的 4个字节,即可区分该块是空闲/已用但未满/已写满的。

每次写一个逻辑扇区时,都需要写一个物理扇区,并新添加一条 l2p表项。随着写操作的进行,数据块最终会写满,类似索引块 写满时需要进行的紧凑操作,数据块此时需要进行垃圾回收(GC), 剔除无效的扇区以释放空间。GC操作方法如下:

  • 取一个空闲的数据块作为搬移扇区数据的目标;
  • 对所有已用(包括已写满)的数据块中的物理扇区,查找l2p 表中与之对应的最新的逻辑扇区地址,如果该物理扇区是有效 的,将其搬移到目标块,并新增对应的l2p表项;
  • 已用数据块中所有的扇区搬移完成后,该块可执行擦除作为 空闲块,在前面搬移的目标块写满之后该块可被选为搬移的 新目标块;
  • 执行以上的操作直到最初所有已用的数据块遍历完或者在 释放足够的空间后停止。

GC操作需要频繁地读写索引和数据块,因此开销巨大。在扫描 已用块时,如果该块已满并且所有扇区都是有效的,可以直接 跳过搬移该块,扫描下一个已用数据块。GC操作也不必进行到 所有已用块都扫描完,在释放出足够存放下一笔待写数据所需 的空间后即可以停止。

GC操作需要至少有一个空闲块可作为搬移的目标块,因此,分 配数据块时也要考虑冗余。

4. 实现与测试

本文不对实现该U盘的代码进行讲解,可从以下链接获取完整 工程自行参考:

https://www.jiawei.site/downloads/stm32/stm32_spif_udisk.zip

我也仅对该实现代码进行了简单的测试,以下测试均在Linux下进行:

1. U盘识别

通过USB接口将U盘连接到PC,并执行lsusb,可看到如下 所示的USB设备:

Bus 001 Device 006: ID 0483:572a STMicroelectronics STM32F401 microcontroller [ARM Cortex M4] [CDC/ACM serial port]

执行lsblk,可看到一个容量为15.4M的块设备:

sdd           8:48   1  15.4M  0 disk 
2. 块设备直接读写

可以使用DD命令直接读写块设备:

将一个8KB的文件写入:

~/Documents/test >>> ls -lh | grep bin
-rwxr-xr-x 1 jiawei jiawei 8.0K Feb  3 19:31 app.bin
~/Documents/test >>> sudo dd if=app.bin of=/dev/sdd bs=512 seek=0 status=progress conv=fsync
[sudo] password for jiawei: 
16+0 records in
16+0 records out
8192 bytes (8.2 kB, 8.0 KiB) copied, 1.00188 s, 8.2 kB/s

读回:

~/Documents/test >>> sudo dd if=/dev/sdd of=readback.bin bs=512 count=16 seek=0 conv=fsync
16+0 records in
16+0 records out
8192 bytes (8.2 kB, 8.0 KiB) copied, 4.46718 s, 1.8 kB/s

对比写入和读回的数据(无输出说明内容一致):

cmp app.bin readback.bin
3. 格式化和文件系统检查

我们的U盘容量仅为16MB,可以将其格式化为FAT16:

~ >>> sudo mkfs.vfat -F 16 /dev/sdd
[sudo] password for jiawei: 
mkfs.fat 4.2 (2021-01-31)
~ >>> sudo fsck.vfat -v /dev/sdd
fsck.fat 4.2 (2021-01-31)
Checking we can access the last sector of the filesystem
Boot sector contents:
System ID "mkfs.fat"
Media byte 0xf8 (hard disk)
       512 bytes per logical sector
      2048 bytes per cluster
         4 reserved sectors
First FAT starts at byte 2048 (sector 4)
         2 FATs, 16 bit entries
     16384 bytes per FAT (= 32 sectors)
Root directory starts at byte 34816 (sector 68)
       512 root directory entries
Data area starts at byte 51200 (sector 100)
      7880 data clusters (16138240 bytes)
31 sectors/track, 1 heads
         0 hidden sectors
     31620 sectors total
Checking for unused clusters.
/dev/sdd: 0 files, 0/7880 clusters
4. 拷贝和读写文件

执行挂载:

sudo mount -t vfat /dev/sdd /mnt -o uid=$(id -u),gid=$(id -g),umask=022

新建并读写文件:

~ >>> echo "hello" > /mnt/hello.txt
~ >>> cat /mnt/hello.txt
hello

拷贝文件:

~/.../Core/Src >>> ls -lh
total 76K
-rw-r--r-- 1 jiawei jiawei 1.8K Feb  1 15:54 dma.c
-rw-r--r-- 1 jiawei jiawei 2.6K Feb  1 16:20 gpio.c
-rw-r--r-- 1 jiawei jiawei 5.3K Feb  1 02:50 main.c
-rw-r--r-- 1 jiawei jiawei 4.8K Feb  1 17:50 spi.c
-rw-r--r-- 1 jiawei jiawei 5.7K Feb  1 17:50 spi_flash.c
-rw-r--r-- 1 jiawei jiawei 2.3K Feb  1 00:08 stm32f1xx_hal_msp.c
-rw-r--r-- 1 jiawei jiawei 6.5K Feb  1 15:45 stm32f1xx_it.c
-rw-r--r-- 1 jiawei jiawei 5.0K Feb  1 00:08 syscalls.c
-rw-r--r-- 1 jiawei jiawei 3.0K Feb  1 00:08 sysmem.c
-rw-r--r-- 1 jiawei jiawei  15K Feb  1 00:08 system_stm32f1xx.c
-rw-r--r-- 1 jiawei jiawei 3.2K Feb  1 00:57 usart.c
~/.../Core/Src >>> cp *.c /mnt
~/.../Core/Src >>> sudo sync
~/.../Core/Src >>> ls /mnt -lh
total 68K
-rwxr-xr-x 1 jiawei jiawei 1.8K Feb  3 19:49 dma.c
-rwxr-xr-x 1 jiawei jiawei 2.6K Feb  3 19:49 gpio.c
-rwxr-xr-x 1 jiawei jiawei    6 Feb  3 19:48 hello.txt
-rwxr-xr-x 1 jiawei jiawei 5.3K Feb  3 19:49 main.c
-rwxr-xr-x 1 jiawei jiawei 4.8K Feb  3 19:49 spi.c
-rwxr-xr-x 1 jiawei jiawei 5.7K Feb  3 19:49 spi_flash.c
-rwxr-xr-x 1 jiawei jiawei 2.3K Feb  3 19:49 stm32f1xx_hal_msp.c
-rwxr-xr-x 1 jiawei jiawei 6.5K Feb  3 19:49 stm32f1xx_it.c
-rwxr-xr-x 1 jiawei jiawei 5.0K Feb  3 19:49 syscalls.c
-rwxr-xr-x 1 jiawei jiawei 3.0K Feb  3 19:49 sysmem.c
-rwxr-xr-x 1 jiawei jiawei  15K Feb  3 19:49 system_stm32f1xx.c
-rwxr-xr-x 1 jiawei jiawei 3.2K Feb  3 19:49 usart.c

可以尝试解除挂载并断开USB连接,重新连接并挂载U盘,读取 拷贝的文件检查拷贝结果。