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每天要修改多少次文件呢?