Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.2 块设备抽象——文件系统的第一层谎言

你手里有一个东西,它长得很像磁盘

你走回车间,打开示波器。你面前是一颗Winbond W25Q32JV——32Mbit(4MB)的串行NOR Flash。8个引脚:VCC、GND、CS、CLK、DI、DO、WP、HOLD。

你拿起逻辑分析仪的探头,夹在DI和DO上,准备看看这个“存储设备“到底是怎么说话的。

你给主控发一个读取命令——读取地址0x000100开始的256个字节。SPI总线上出现这样的波形:

读操作 SPI 总线波形
=======================

CS ___/''''''''''''''''''''''''''''''''''''''''\___
CLK _/ \_/ \_/ \_/ \_/ \_/ \_/ \_/ \_/ \_/ \_/ \_
DI  __/ 0x03 \__/ 0x00 \__/ 0x01 \__/ 0x00 \______________
    ↑ 读命令  ↑ 地址 23..16  ↑ 地址 15..8  ↑ 地址 7..0

DO  ______________/       数据0       \__/       数据1       \__
                  \___________________/  \___________________/
                 ↑ 从地址0x000100读出的第一个字节

25MHz时钟。每8个时钟周期一个字节。256个字节,大约82微秒读完。快得让你看不见。

你再发一个擦除命令——擦除地址0x000000所在的4KB扇区:

扇区擦除 SPI 总线波形
=======================

CS ___/''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''\___
CLK _/ \_/ \_/ \_/ \_/ \_/ \_/ \_/ \_/ ... (200ms的等待) ... / \_/ \_/ \_/ \_/ \_
DI  __/ 0x06 \___________________/ 0x20 \__/ 0x00 \__/ 0x00 \__/ 0x00 \________________
    ↑ 写使能                         ↑ 扇区擦除命令  ↑ 24位地址

等待 200 毫秒 —— 在这段时间里,Flash内部高压泵在运行,
浮栅里的电子正在通过氧化层返回衬底。CS可以拉高,但状态寄存器
的BUSY位一直为1,直到擦除完成。

DO  _____________________/ BUSY=0 \___________________________________________________
                         ↑ 擦除完成,状态寄存器的WIP位清零

注意:读256字节=82微秒。擦除4096字节=200毫秒=200,000微秒。

擦除比读慢了 200,000 ÷ 82 ≈ 2,439 倍。

读操作和写/擦除操作之间的性能鸿沟,就是这个数量级。你所学的“块设备“——read_block和write_block这两个对称的函数签名——掩盖了这条鸿沟的整个存在。


块设备抽象:一纸谎言

在操作系统教科书里,“块设备”(Block Device)是一个优美的概念:

教科书里的块设备抽象
=======================

struct block_device {
    int (*read_block)(int block_no, void *buf);
    int (*write_block)(int block_no, const void *buf);
};

// 使用起来简直像魔法:
char buf[4096];
read_block(5, buf);       // 读取第5块 → 4KB数据
buf[100] = 'A';           // 修改其中一个字节
write_block(5, buf);      // 写回第5块
// 完成!

这层抽象的核心承诺是:读和写是对称的。你在逻辑块号上操作,不用关心里面的物理结构。你可以“原地“修改数据。

对于一个真正的磁盘(HDD),这层抽象基本成立:

  • read_block(5, buf) → 磁头移动到第5扇区所在的柱面 → 读取一个扇区(512B或4KB)
  • write_block(5, buf) → 磁头移动到同一位置 → 磁场翻转重写 → 旧数据被新数据覆盖

读和写的代价大致相同(都是寻道+旋转延迟+读写时间)。粒度也相同(都是扇区)。

但对于NOR Flash:

Flash 上"块设备"的真实情况
=======================

read_block(5, buf):
  → 发送 0x03 0x00 0x50 0x00(地址=5×4096=0x5000)
  → 拉CLK,收数据
  → 82 微秒
  → ✓ 真的读回来了

write_block(5, buf):     ← 等等,你能"直接写"吗?
  → 你不能。
  → 第5块可能不是擦除态(不是全0xFF)
  → 你必须:
      1. 擦除第5块 — 200 毫秒
      2. 逐字节编程 — 每个字节 ~14 微秒,4KB = 58 毫秒(工程保守估算)
      总耗时: ~258 毫秒
  → 而且:如果有其他"块"逻辑上映射到了同一个物理块的不同偏移
           你擦除第5物理块会毁掉那些数据!

write_block 在 Flash 上不是一个原子操作。它可能包含 [擦除] + [编程] 两步,而且擦除的物理范围远大于“你想要的“。

这就是“谎言“的本质。块设备抽象让你以为你能“原地写入一个块“,但Flash的物理现实是:你只能写入一个已经擦除过的块,而擦除的粒度比你想象的更大。


抽象的代价:谁在为谎言买单

谁在为这个谎言买单?答案取决于你住在这层抽象的上层还是下层。

如果你住在上层——你是一个文件系统开发者,调用read_block/write_block——那你被这个谎言保护得很好。你不需要知道扇区擦除是200毫秒,不需要知道编程和擦除的粒度不对称,不需要知道每个块有100,000次P/E寿命。你只需要读写逻辑块号。

但你的文件系统可能会在不知不觉中自杀。

比如,你在逻辑块5上存了一个频繁修改的小文件。每次修改你都调用write_block(5, buf)。上层的块设备驱动在背后帮你做擦除+编程,每次258毫秒。它不会告诉你“这个块快死了“——它只是忠实地执行你的命令。直到有一天,write_block(5, buf)返回了错误——第5块的某个单元已经无法编程了——而你的整个文件系统就此崩溃。

这就是抽象的代价:它让你不需要知道物理细节,但也让你无法做出基于物理细节的正确决策。

如果你住在下层——你是一个NOR Flash驱动开发者——那你的生活就是一个无尽的补丁游戏。你需要:

NOR Flash 驱动面对的物理现实
=======================

1. 扇区不对齐问题
   write_block(5, buf) 请求的是逻辑块5
   但逻辑块5可能跨越了两个物理扇区
   你擦除扇区A → 毁了逻辑块4的前半部分
   你擦除扇区B → 毁了逻辑块6的后半部分

2. 写入暂停问题
   在擦除200毫秒期间,如果高优先级中断来了?
   你必须在中断服务程序里处理
   或者实现擦除暂停/恢复

3. 地址映射问题
   文件系统认为的逻辑块号
   和物理扇区号可能不是一一对应的
   (如果有坏块,需要remap)

4. 错误处理问题
   擦除失败怎么办?重试几次?
   编程失败怎么办?记录坏块?迁移数据?
   ECC纠正失败怎么办?

把这些物理限制都“抽象“掉,本身就是一项巨大的工程。


三道 API 的江湖

在嵌入式世界里,Flash块设备的API设计基本分为三派。每一派都是对“谎言“的不同程度的坦白。

第一派:同步 API —— 最直白的谎言

// 同步块设备接口(适合有OS的场景)
sint32 flash_read_block(uint32 block_id, uint8 *buf);
sint32 flash_write_block(uint32 block_id, const uint8 *buf);
sint32 flash_erase_block(uint32 block_id);

调用线程阻塞等待Flash操作完成。read阻塞约82微秒(可以接受),write阻塞约258毫秒(会卡死调度器),erase直接暴露(诚实)。

这是最“传统“的设计,也是FATFS这类文件系统的默认假设。它的问题在于:258毫秒的阻塞对于RTOS是不可接受的。 在FreeRTOS中,一个任务阻塞258毫秒意味着:

  • 如果它是最高优先级任务(比如Flash写入任务),整个系统在这258毫秒内什么也做不了
  • 如果它被抢占,那它持有的互斥锁可能锁住其他任务258毫秒
  • 如果你有CAN消息需要在10毫秒内处理——对不起,你的CAN任务被迫陪Flash擦除

第二派:异步 API —— 第一次坦白

// 异步块设备接口(适合硬实时系统)
typedef enum {
    DISK_ASYNC_OP_READ,
    DISK_ASYNC_OP_WRITE,
    DISK_ASYNC_OP_ERASE
} disk_async_op_t;

typedef void (*disk_async_callback_t)(sint32 result);

sint32 disk_async_submit(
    disk_async_op_t op,
    uint32 block_addr,
    uint8 *buf,
    disk_async_callback_t callback
);

bool disk_async_is_idle(void);
sint32 disk_async_wait(void);

这是生产级部署通常选择的道路。提交一个Flash操作请求,立即返回,继续运行其他任务。当Flash操作完成时,注册的回调函数被调用,或者任务在下次轮询时检查完成状态。

重点是这个接口的设计哲学:

同步 vs 异步:时间逃逸图
=======================

同步模式(阻塞等待):
  Task A: [submit] . . . . . . . . . . . . . . . . [done][process]
                  ↑                                   ↑
                  └── 258ms Flash擦除期间,Task A啥也干不了 ──┘

异步模式(事件驱动):
  Task A: [submit][run other stuff...][poll: done? no][more stuff...][poll: done!][process]
  Flash:  . . . . . . . . . . . . 258ms擦除 . . . . . . . . . . . . . . . . . [done]
          ↑ Flash在后台忙,Task A继续运行
          此时Task A可以处理CAN消息、更新显示、响应按键

异步API坦白了这个事实:Flash操作不是“调用-返回“模式的。它是“请求-通知“模式的。你发起一个请求,然后等待一个事件。

坦白是有代价的:异步编程模型远比同步模型复杂。你不能再写:

write_block(5, buf);
process_data(buf);

而是要写:

disk_async_submit(DISK_ASYNC_OP_WRITE, 5, buf, on_write_done);
// 返回后不能碰buf,因为Flash可能还在读它
// 在on_write_done回调到来之前,你不能调用process_data

状态机、回调、轮询——异步编程引入了一整套新的复杂度。但对于一个需要在CAN总线上每10毫秒响应一次的ECU来说,这是唯一的选择。

第三派:DMA+中断 API —— 彻底坦白

// 最底层的Flash访问接口(MCAL层)
void Fls_Read(
    Fls_AddressType SourceAddress,
    uint8 *TargetBufferPtr,
    Fls_LengthType Length
);

void Fls_Write(
    Fls_AddressType TargetAddress,
    const uint8 *SourceBufferPtr,
    Fls_LengthType Length
);

void Fls_Erase(
    Fls_AddressType TargetAddress,
    Fls_LengthType Length
);

// 完成通知通过回调
void Fls_JobEndNotification(void);
// 错误通知通过回调
void Fls_JobErrorNotification(void);

这是AUTOSAR FLS(Flash Driver)的标准接口。它甚至不隐藏“地址“的概念——你直接传物理地址。没有块号,没有抽象层。

在这三层API之间,每一层都是一次翻译:

Flash 访问的翻译链
=======================

Fls_Erase(0x5000, 4096)         ← MCAL层:物理地址,物理长度
        ↓
disk_async_erase(block_5)       ← 块设备层:逻辑块号
        ↓
fs_erase_block(5)         ← 文件系统层:文件系统块号

正是这层翻译链,让上层的文件系统可以认为自己在操作“逻辑块“,而不需要关心物理地址是0x5000还是0x6000。


为什么嵌入式系统必须走异步这条路

有人会问:PC上的文件系统不是同步的吗?Linux的read()write()系统调用不是阻塞的吗?PC的NVMe SSD擦除不也要时间吗?

答案是:PC的NVMe SSD有自己的FTL(Flash Translation Layer)控制器——一个运行在SSD内部的ARM Cortex-R或类似核心上的固件。 这个控制器替你承受了异步的痛。

PC SSD 的异步处理是透明的
=======================

你的程序:  write(fd, buf, 4096);
          ↓ 系统调用,你的线程进入 D 状态(不可中断睡眠)
NVMe驱动: 提交写入命令到 submission queue
SSD控制器: 收到命令 → 分配空闲块 → 写入数据 → 更新映射表
           → 后台GC → 完成后 ring doorbell
          ↓ 你的线程被唤醒
你的程序:  write() 返回

你以为write()是"同步"的,
但SSD控制器在你的视线之外,
用异步状态机处理了所有的复杂逻辑。

嵌入式系统没有这个奢侈。你的MCU上可能连SSD控制器都没有——你直接接在NOR Flash的SPI总线上,程序化的擦除和编程完全由你控制。你既是应用程序,又是FTL控制器。

因此异步I/O是“不得不“的选择。你的ECU不能停258毫秒什么都不干——如果这258毫秒里CAN总线上来了一条紧急制动命令,你必须在微秒级别响应。


块设备的“分块艺术“:如何选择Block Size

最后一个问题:块多大?

disk_async.h里,你看到了这样的定义:

#define FS_BLOCK_SIZE     (4096)   // 4KB = 一个 NOR Flash 扇区
#define FS_BLOCK_COUNT    (128)    // 128 块 × 4KB = 512KB

为什么是4KB?

对于NOR Flash,答案是:因为NOR Flash的擦除扇区就是4KB。 这不是你选的,是芯片制造商选的。

但为什么NOR Flash的扇区是4KB?因为这是SRAM缓存行对齐、DMA传输效率和文件系统元数据大小之间的一个工程妥协:

  • 太小(比如512B):扇区数量暴增 → metadata管理成本上升 → 磨损记录表变大
  • 太大(比如64KB):一次擦除摧毁太多数据 → GC效率低下 → 写放大飙升

4KB是一个被验证的甜蜜点。


下集预告

块设备抽象是文件系统设计的第一层谎言——它让你以为自己在操作整齐的逻辑块,而实际上你在操作一个性格暴躁的NOR Flash芯片。但故事还没完。在这层谎言之上,你还要搭建文件系统本身。而最简单、最天真的设计——FAT文件系统——在Flash上会以一种惨烈的方式失败。

下一节,我们解剖FAT的结构,看看为什么这个统治了U盘和SD卡四十年的文件系统,在裸NOR Flash上活不过一个星期。

悬念留给:FAT表是FAT文件系统的核心——也是它的命门。每一次文件修改,FAT表就要被改写一次。在磁盘上这是一次寻道,在Flash上这是一次擦除。而你的ECU每天要修改多少次文件呢?