FTL地址映射:逻辑地址到物理地址的映射表管理

各位做固件的朋友,今天我们来聊聊FTL的核心——地址映射。说白了,就是让主机发来的逻辑地址,找到NAND Flash上真正的物理位置。这个映射表要是管不好,整个SSD的性能就全毁了。

我刚开始做FTL时,觉得映射不就是个查表嘛,有啥难的?后来踩了坑才明白,这里面门道深着呢。映射粒度、表的大小、缓存策略,每一个选择都直接影响性能。

映射粒度:4KB还是8KB?

映射粒度,就是每个映射条目管理的数据块大小。主流方案有两种:4KB和8KB。怎么选?我直接说结论。

映射粒度 映射表大小(1TB SSD) 随机写性能 写放大
4KB 256MB
8KB 128MB

4KB粒度,映射表大,但随机写性能好。8KB粒度,表小一半,但写放大可能增加。我个人习惯,消费级SSD用4KB,企业级用8KB。为什么?消费级用户对随机性能敏感,企业级更看重容量和成本。

核心原则:映射粒度越小,灵活性越高,但表越大。粒度越大,表越小,但碎片化风险增加。

我在项目中遇到过一个问题:某款SSD用4KB粒度,DRAM只有256MB,1TB容量时映射表就占了256MB,其他功能没空间了。后来改成8KB,表降到128MB,问题解决。但随机写性能掉了约15%。这就是取舍。

映射表缓存策略

映射表全放DRAM里?成本太高。全放NAND里?性能太差。所以需要缓存策略。我常用的方案是两级缓存:SRAM做L1,DRAM做L2。

我的经验:L1缓存用SRAM,放最热门的映射条目。L2用DRAM,放次热门的。冷数据直接查NAND上的映射表。

具体怎么设计?看这个流程图:

主机LBA请求 查询L1缓存(SRAM) 命中? 直接返回物理地址 查询L2缓存(DRAM) 命中? 更新L1缓存 查NAND映射表 更新L1+L2缓存

这个流程你仔细看。L1命中直接返回,延迟最低。L2命中更新L1,下次就快了。L2都没命中,只能去NAND查,这是最慢的路径。

注意:缓存替换策略很关键。我常用LRU,但要注意热点数据保护。曾经有个项目,频繁的冷数据把热点数据挤出了L1缓存,随机读性能暴跌。后来加了访问计数,热点数据锁定在L1里,问题解决。

映射表管理实战

嗯,这里要讲点实际的。映射表怎么组织?我推荐哈希表+链表的结构。

// 映射条目结构
struct map_entry {
    uint64_t lba;           // 逻辑块地址
    uint64_t pba;           // 物理块地址
    uint32_t ref_count;     // 引用计数
    uint32_t flags;         // 标志位
    struct list_head list;  // 链表节点
};

// 哈希桶
struct hash_bucket {
    struct list_head head;
    spinlock_t lock;
};

// 映射表
struct ftl_map {
    struct hash_bucket *buckets;
    uint32_t bucket_count;
    struct kmem_cache *entry_cache;
};

这段代码看着简单,但有几个坑。第一,哈希函数要均匀分布。我见过用LBA低几位做哈希的,结果连续LBA全挤到一个桶里,性能惨不忍睹。第二,链表操作要加锁,但锁粒度要细。我习惯每个桶一把锁,并发性能好。

避坑指南:我曾经用全局锁保护整个映射表,结果多核CPU下性能只有单核的1.2倍。改成桶级锁后,性能线性提升到4倍。锁的粒度,直接影响并发性能。

映射表持久化

掉电后映射表不能丢。怎么搞?定期刷到NAND里。我常用的策略是增量更新。

  • 全量备份:每30分钟或每100万次写操作,全量刷一次
  • 增量日志:每次映射变更,记录到日志区
  • 恢复流程:上电先加载全量备份,再回放增量日志

这个方案我用了很多年,稳定可靠。但要注意日志区大小,别写满了。我一般预留映射表大小的10%做日志区,够用。

关键点:增量日志要保证原子性。每条日志写完要加CRC校验,防止部分写导致数据损坏。我吃过这个亏,掉电后日志区有条坏记录,恢复时直接崩溃。后来每条日志前加魔数,后加CRC,中间加长度,三重保护。

好了,映射表这块就讲这么多。记住核心:粒度选对,缓存分层,持久化可靠。做到这三点,你的FTL就稳了。

专注资料整理