
1. 从“能用”到“用好”LittleFS与NANDFLASH的组合为什么值得折腾这几年做嵌入式存储方案很多人一提到文件系统就是SPI NOR Flash配FatFS或者直接上JFFS2/UBIFS。但当你手里拿到一颗NAND Flash尤其是那种容量大、价格香、还带OTP区的芯片再配上要长期写入日志、做OTA升级的MCU或者带软核的应用处理器事情就没那么简单了。LittleFS这个专为嵌入式设计、掉电安全、支持磨损均衡和坏块管理的文件系统和NANDFLASH搭配起来几乎是一种“天生一对”的解法——但前提是软硬件配置得对路。我最近在一个基于Xilinx Zynq是的就是那块ARM Cortex-A9 FPGA的组合芯片的项目里把LittleFS跑在了一片工业级SLC NAND上中间踩了不少坑也把不少原来想当然的配置重新捋了一遍。这篇内容就是想把暂停踩过的软硬件配置细节、链路设计思路、以及那些文档里不会明说但实际操作绕不开的坑一次说清楚。适合正在选型NAND、准备上LittleFS、或者已经在Zynq这类异构平台上做存储方案的工程师参考。很多人以为LittleFS是“给NOR Flash用的”但实际上它的设计目标从一开始就是“在掉电时保持数据一致性”对NAND这类块设备同样适用。真正决定能不能用它的是底层块设备的读写特性、擦除粒度、坏块管理策略以及文件系统本身对写放大和磨损均衡的容忍度。把这几个点想明白了软硬件配置就只是顺水推舟的事。2. 硬件侧NANDFLASH选型与Zynq平台的连接细节2.1 选型时真正要盯的几个电气参数说实话NAND Flash选型这件事在不同项目里优先级完全不一样。如果你做的是消费级SD卡或者U盘直接选TLC、QLC的大容量颗粒没毛病反正有主控兜底。但如果你要在Zynq上直接挂NAND文件系统还是自己烧进去的LittleFS那我劝你老老实实选SLC而且最好是那种带完整ONFI或者Toggle接口支持的工业级型号。Xilinx Zynq平台上官方文档里列出的NAND型号主要集中在镁光、海力士、东芝/铠侠这几家的SLC颗粒容量从256MB到4GB不等页大小以2KB、4KB为主块大小通常是128KB或者256KB。为什么会是这样因为Zynq的NAND控制器是基于ONFI 2.1规范实现的对时序要求比较严格。如果你选一颗不在这名单里的颗粒虽然理论上可以手动调时序参数让它跑起来但调试成本非常高。而且SLC颗粒的PE次数通常在10万次级别TLC只有几千次而LittleFS在设计上并没有像UBIFS那样做专门的NAND磨损均衡算法它更依赖底层块设备的特性来摊平擦写。SLC的耐操度高能显著降低坏块出现的概率这对长期运行的设备来说就是生命线。2.2 板级连接别把引脚复用问题留给软件硬件设计上Zynq的NAND接口并不复杂就是标准的并行接口ALE、CLE、CE、RE、WE、WP以及8根或16根数据线加上RBReady/Busy信号。但有一个细节特别容易被忽略Zynq的NAND引脚和SDIO、SPI、UART等外设存在复用如果你在设计板卡时把这些引脚分配给了其他功能那后面软件再怎么折腾也救不回来。所以硬件原理图阶段就要确认MIO或者EMIO上分配的NAND引脚没有被其他外设抢走最好在原理图上加注释防止后期Layout时被改掉。另外一个容易踩的坑是WP写保护引脚。很多开发板的NAND WP引脚直接接地这意味着写保护一直被使能软件上怎么操作都不写入。正确的做法是WP引脚接GPIO控制或至少默认上拉让软件在需要的时候才能解除写保护。我见过不止一个工程师在调试时发现擦除操作正常但写入总是超时查了半天最后发现是WP引脚被拉低了。这种问题属于“基础但致命”的硬件坑排查起来极其耗时间。2.3 供电与时序稳定是第一位NAND Flash的供电电压通常是3.3V或者1.8V具体要看颗粒规格。Zynq的MIO bank电压要和NAND的VCCI匹配否则电平不兼容会导致数据读取错误。还有一点NAND在擦除和编程时会有较大的瞬态电流电源纹波太大的话容易出现偶发性的数据错误。建议在NAND电源引脚附近加一个10uF的瓷片电容和0.1uF的旁路电容这是最常规的做法但很多小批量板子在Layout时偷懒只放了一个小电容结果现场出现了不明原因的坏块率升高。至于时序参数这里不打算列出某个具体颗粒的寄存器值因为不同厂商不同型号差异太大。我只强调一个经验如果你用Xilinx官方BSP里的NAND驱动先别急着改时序参数用默认值跑一遍通过读取ID识别到正确的颗粒容量和页大小之后再根据颗粒的数据手册微调tREA、tWC等参数。大部分人遇到的读不到ID、CRC错误、读写不稳定基本都是因为电压不匹配或者引脚没连通而不是时序参数不够精细。3. 软件链路从内核驱动到LittleFS的完整配置路径3.1 Zynq上NAND控制器的驱动是怎么工作的在Zynq平台上跑LittleFS前提是你得先把NAND这个大块设备变成一个文件系统能识别的“块设备”。Xilinx官方Linux BSP里自带NAND驱动对应的设备树节点名字一般是nand0使用arasan,nand-controller这个compatible字符串。设备树里需要配置的主要有reg寄存器地址与范围、interrupts中断号、bank-width数据线宽度8或16、nand-ecc-modeECC模式、nand-ecc-strengthECC纠错位数等。这里最关键的配置就是ECC。NAND Flash本身存在位翻转的物理特性尤其是使用时间长了以后误码率会急剧上升。如果不用硬件ECC或者配置的纠错能力不够文件系统的数据完整性根本无法保证。Zynq平台上的NAND控制器自带硬件ECC引擎支持BCH算法可以配置1-bit、4-bit、8-bit等不同纠错能力。具体选哪个要看你使用的NAND颗粒的“最小ECC要求”这个参数在颗粒的数据手册里都会有比如常见的4KB页SLC要求4-bit/512字节或者8-bit/512字节。配置低了数据容易读错配置高了性能会有所下降而且如果超过控制器能力还会直接报错。设备树中常见的配置片段如下供参考nand0 { status okay; arasan,nand-ecc-mode hw; arasan,nand-ecc-strength 8; arasan,nand-ecc-size 512; nand-bus-width 8; nand-on-flash-bbt; #address-cells 1; #size-cells 1; };注意nand-on-flash-bbt这个属性它的意思是“在Flash内部存储Bad Block Table”这样系统启动时就不需要扫描全片来构建坏块表既节省时间又能避免写入到坏块导致启动失败。这个属性我强烈建议加上尤其是容量稍大的NAND全片扫描坏块在调试阶段还好量产后每个设备启动慢两三秒是不可接受的。3.2 为什么直接挂MTD而不是裸分区Linux下NAND驱动加载后会在/dev/mtd*下出现一系列MTD分区设备。很多人第一步想到的是通过mtdblock来把MTD转换成块设备然后挂载文件系统。这里就有一个大坑mtdblock只是一个简单的线性映射层它不支持坏块管理也没有磨损均衡。如果你直接用mtdblock挂载LittleFS一旦某个块因为擦写次数过多变成坏块文件系统就会出错。LittleFS确实有自己的坏块检测逻辑但那是建立在“块设备能正常读写”的前提上的底层如果直接暴露坏块LittleFS也招架不住。正确做法是使用内核的mtdblockubi层或者直接用mtd之上再加一个软件坏块管理层的方案。但在Zynq平台上更常见的是用nandsim或者直接对MTD设备操作。我个人推荐的做法是把NAND划分出几个MTD分区其中一个作为LittleFS的分区然后在内核里通过concatenate设备或者直接对mtd字符设备做封装实现坏块感知和读写重映射。当然如果你内核版本较新也可以尝试使用mtdblock2这种带坏块管理的块设备驱动不过它只支持Linux内核4.4之后的某些版本。这里要说的核心概念是LittleFS本身运行在“块设备”之上它认为底层是一块很规整的块存储。但NAND不是它有坏块、需要擦除才能写入、而且擦写最小单位是块。所以中间层一定要做两件事一是隐藏坏块让文件系统看到的是一个保持在良好状态的逻辑块设备二是实现读-改-写操作因为文件系统写入可能只有几个字节但NAND擦除是一个块必须先把块的旧数据读到缓存修改后再整块回写。这个逻辑如果放在用户态做效率太低放在内核态做又太复杂。所以通常的解法是利用MTD层的mtdblock或者干脆用UBIUnsorted Block Images来处理坏块和磨损均衡然后在UBI卷之上跑LittleFS。但UBI通常是配合UBIFS使用的LittleFS能不能跑在UBI卷上可以但需要把UBI卷当作一个只支持顺序写入的伪块设备这一点对LittleFS的兼容性有一定影响。如果不想引入UBI那么重的组件也可以使用MTD自定义块设备封装。我在自己的项目中就是写了一个简单的内核模块基于mtd字符设备做读写映射维护一张坏块表在写入时如果遇到坏块就标记并重映射到备用块。实测下来配合LittleFS的掉电恢复能力整体稳定性是可以接受的而且代码量只有几百行比引入UBI好理解得多。3.3 LittleFS的配置结构体每个字段都要想清楚LittleFS的工程里有lfs_config结构体需要配置read、prog、erase函数指针以及块的读写/擦除大小、块数等参数。如果你跑在裸机上这个结构体就是直接对接NAND驱动的但在Linux环境下我们一般通过lfs对接块设备文件比如open(/dev/mtdblockN, O_RDWR)然后把read/write/erase函数封装成对文件描述符的pread/pwrite/ioctl调用。一个非常容易出错的点是block_size和prog_size的配置。block_size必须等于底层NAND的擦除块大小比如128KB。prog_size则一般是页大小比如2KB或者4KB。这两个值如果和底层不匹配LittleFS的元数据写入会非常频繁地触发读-改-写造成极大的写放大。另外cache_size建议设置为等于页大小lookahead_buffer_size可以根据块数量来定一般设置为block_count / 8即可。如果cache_size太小会导致每次读改写都穿透到NAND性能会惨不忍睹。结构体初始化示例struct lfs_config cfg { .read user_provided_read, .prog user_provided_prog, .erase user_provided_erase, .sync user_provided_sync, .read_size 512, .prog_size 4096, .block_size 131072, .block_count 2048, .cache_size 4096, .lookahead_size 256, .block_cycles 500, };这几个参数里block_cycles是磨损均衡触发阈值。我不是推荐你用500而是想说这个值要根据实际擦写频率和颗粒寿命来定。比如你的设备每小时写一次日志每次写入4KB那么每块一天擦写不到一次block_cycles设成500其实是合理的。但如果你的设备持续高频写入block_cycles设太大可能会导致部分块提前老化设太小又会导致频繁搬移数据。最稳妥的办法是统计实际负载然后保证每块在生命周期内的擦写次数低于规格书给出的最大值。3.4 挂载方式Nor Flash习惯和NAND不可同日而语如果你之前只在NOR Flash上跑LittleFS现在换成NAND会把很多习惯带过来其中最容易出问题的就是“频繁mount/unmount”。NOR Flash的擦写寿命高掉电丢失数据的风险相对低很多开发者图省事每次写文件前都调用lfs_mount写完再lfs_unmount。但在NAND上这样做有两个坏处一是每次mount都会扫描元数据导致启动慢二是每次unmount都会强制刷新所有脏数据造成无谓的擦写放大。更好的做法是上电时mount一次之后只在需要的时候调用lfs_file_sync强制落盘系统关机前再unmount一次。这样能显著减少不必要的NAND擦写次数延长Flash寿命。这个优化在NOR上效果不明显但在NAND上直接关乎整个设备的使用寿命属于必须考虑的设计策略。LittleFS还自带掉电检测与恢复能力即使写入过程中突然断电重启后再次mount也能恢复到上一个一致性的状态。但前提是底层NAND的读改写流程本身是可靠的也就是说如果数据写到一半断电新数据可能出现部分写入但文件系统能识别出这是不完整的区块并做回滚。这部分逻辑LittleFS已经实现了我们要做的只是给它提供正确的块尺寸信息以及一个能正确处理擦除后全0xFF状态的驱动。4. 实操实录在Zynq上把LittleFS跑在NAND上的完整流程4.1 烧写镜像与分区规划的思路在Zynq上启动可以分为两个阶段BootROM加载FSBLFSBL加载U-BootU-Boot再加载Linux内核和根文件系统。通常NAND上会规划出几个分区Bootloarder分区、内核分区、设备树分区、根文件系统分区以及留给应用数据的分区。数据分区就是我们要跑LittleFS的地方。分区的划分建议在设备树中直接写死这样内核启动后就能看到稳定的/dev/mtdX节点。我习惯把数据分区放在最后一个并且预留出比实际需求大2倍的空间。为什么因为LittleFS在运行时需要约2倍于实际存储文件的容量来进行元数据管理预留空间不足会导致文件系统在写日志过程中报“No space left”但其实还剩很多空间。这个细节我在调试时踩过还是提一下比较好。设备树中的分区节点示例如下partitionnand0 { label u-boot; reg 0x0000000 0x400000; }; partitionnand1 { label kernel; reg 0x400000 0x2000000; }; partitionnand2 { label dtb; reg 0x2400000 0x200000; }; partitionnand3 { label rootfs; reg 0x2600000 0x4000000; }; partitionnand4 { label data; reg 0x6600000 0x20000000; };注意这里的reg单位是字节需要根据实际NAND容量和分区规划进行调整。数据分区如果太大全片坏块扫描加初始化文件系统的时间也会变长。如果对启动时间有要求可以将data分区也加上nand-on-flash-bbt属性这样坏块表信息直接存在NAND的BBT区域内核启动时不用全片扫描。4.2 自定义块设备适配层的实现示例前面提到我选择写一个简单的内核模块把MTD字符设备封装成块设备。这一步算是最偷懒但又有效的方案。大致思路如下打开/dev/mtdN字符设备通过ioctl(MEMGETINFO)获取MTD设备信息页大小、擦除块大小、总大小等在模块内部维护一张坏块表通过MEMGETBADBLOCK检查每个块的状态实现transfer_directive与erase回调供LittleFS调用在读写时若发现坏块从备用块区中分配一个空闲块更新映射表。这个方案虽然简单但如果详细展开写代码篇幅会长到吓人。这里我只给出最关键的一个思路在LittleFS读写回调中我们可以直接用open一个字符设备的方式让每一次读写都走MTD的read/write操作。对于一个4KB页write操作是物理页编程一次写入一页。而LittleFS的prog回调本来就是以prog_size为单位调用的所以只要保证prog_size等于页大小就不会出现跨页写的问题。代码层面的大致框架如下int mtd_read(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, void *buffer, lfs_size_t size) { int fd *(int *)c-context; off_t pos (off_t)block * c-block_size off; if (pread(fd, buffer, size, pos) ! size) { return LFS_ERR_IO; } return LFS_ERR_OK; } int mtd_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { int fd *(int *)c-context; off_t pos (off_t)block * c-block_size off; if (pwrite(fd, buffer, size, pos) ! size) { return LFS_ERR_IO; } return LFS_ERR_OK; } int mtd_erase(const struct lfs_config *c, lfs_block_t block) { int fd *(int *)c-context; struct erase_info_user ei; ei.start block * c-block_size; ei.length c-block_size; if (ioctl(fd, MEMERASE, ei) 0) { return LFS_ERR_IO; } return LFS_ERR_OK; }这里的context字段可以在初始化时传入一个包含fd的结构体指针这样的话LittleFS的回调就能直接拿到MTD字符设备的句柄。注意pread和pwrite是线程安全的如果后续有并发读写需求只要每次打开设备时使用独立的文件描述符或者我们自己在结构体里加锁就能保证安全。4.3 坏块管理与磨损均衡的补强方案虽然LittleFS有磨损均衡算法但它的实现是基于“块”的循环写入。如果底层块设备没有坏块隐藏逻辑那么遇到一个坏块时LittleFS会反复尝试写这个块最终返回写入错误。所以在MTD之上加一层坏块表重映射就是必要的。这里有个简单的策略在每个块擦除前先检查坏块表如果该块已经标记为坏就跳到下一个可用块如果擦除后校验发现块坏则将数据搬到另一个块并更新映射。磨损均衡方面除了依赖LittleFS内部策略我另外做了两层优化一是定期比如每1000次写入统计各个块的擦写次数并把擦写次数多的数据块与擦写次数少的空闲块交换二是把频繁更新的小文件比如配置文件和几乎不变的静态数据分区在不同MTD分区上避免所有擦写都集中在同一块区域。这种做法在生产环境中有没有实际价值有但更多的是心理安慰。真正决定寿命的还是颗粒本身的PE次数和负载频率。如果你的产品预期寿命是5年每天擦写1000次那么SLC颗粒10万次的理论寿命是够用的加上20%的坏块冗余可以说绰绰有余。5. 常见问题排查实录与避坑清单5.1 挂载失败“No space left”但明明还有空间遇到过好几次明明NAND分区还有几百MB空闲但lfs_mount一直返回LFS_ERR_NOSPC。起初以为是分区规划错误后来仔细看了block_count的计算。问题出在我在配置lfs_config时用了一个固定的block_count 2048而实际的MTD分区大小和块大小计算出来的块数是4096。这样一来文件系统把分区当成了半块可用自然空间“不够用”。解决办法是用MEMGETINFO从驱动中动态读取块数和块大小再用它们初始化block_count和block_size这个低级错误写出来仅供参考因为操作细节多了以后真的很容易犯。5.2 LittleFS写文件后掉电重启后文件丢失这个情况一般不是LittleFS的锅而是底层NAND在写入页时如果掉电发生在“页编程”过程中会出现部分页编程partial page programming的问题。LittleFS的元数据设计是支持这种场景的它会在mount时检查元数据的一致性并回滚。但前提是底层的read回调不能把“未完全编程的页”返回为正确数据。通常SLC NAND在部分编程后如果读取会报ECC错误控制器会返回错误标记我们的驱动收到错误后应返回LFS_ERR_CORRUPT让LittleFS知道这个块有问题。而不是尝试重读更不是把错误数据交给上层。如果实在排查不出原因可以检查prog_size是否等于页大小。如果prog_size设置成小于页大小比如1KB但底层NAND页大小是4KB那么写一个页时可能只写了前1KB断电后这个页的剩余部分是随机值会导致CRC校验失败最终表现为文件丢失。把prog_size调整到等于页大小后这个问题基本就消失了。5.3 内核报错nand: timeout while programming硬件上最典型的一种错误就是NAND在编程时超时。除了前面提到的WP引脚问题外另一个常见原因是NAND电源在编程时电压跌落严重导致内部升压泵无法完成编程操作。排查方法很简单用示波器看NAND的VCC在编程期间的波形如果发现跌落超过10%就是电源裕量不足需要加大电容或调整电源设计。软件层面则不需要做任何修改因为这不是软件能解决的。还有一种不太常见但确实存在的情况NAND控制器进入编程模式后如果RB引脚没有正确连接或者驱动里没有等待RB信号也有可能出现超时。Zynq的NAND控制器有一个状态寄存器可以查询RB状态驱动可以通过轮询它来判断编程是否完成。如果使用的是离散逻辑直接读写NAND那就必须确保RB引脚有上拉电阻否则电平不确定会导致判断失败。5.4 文件系统性能差写一个4KB文件要几十毫秒在NANDLittleFS架构下性能瓶颈通常不在NAND本身而在读-改-写导致的额外擦除操作。假设你每次写一个4KB的小文件而小文件的数据块可能分布在多个不同的块中LittleFS为了更新元数据会先擦除一个块再写入新数据和元数据。如果底层设备擦除时间是2ms这个速度还能接受。但如果你频繁修改同一个文件文件系统的搬移操作会反复进行性能自然就下来了。优化手段有几个一是把prog_size设置成页大小减少部分页编程二是把cache_size调大让一次读改写覆盖更广的范围三是打开LittleFS的inline-file选项让小文件直接存储在目录元数据中从而减少“数据块元数据块”两次擦除。具体到代码里就是lfs_config有一个inline_max_size字段把不超过这个大小的文件直接嵌入到目录中避免单独的块操作。我实测过4KB以内的小文件在开启inline后写入时间能减少一半以上。5.5 坏块增长很快但检查坏块表没有对应标记如果你发现随着时间推移新坏块出现的速度比预期快但坏块表里没有记录很可能是因为你没有正确启用nand-on-flash-bbt。如果内核在每次启动时都扫描全片来构建坏块表那么在扫描过程中写入的“临时坏块标记”可能不会被持久化。也就是说这次扫描发现的坏块下次启动再扫描时因为它是坏块无法读取所以并不会被记录。恶性循环就出现了。解决方法是确保MTD层把BBT保存在NAND的保留块区域内并且不要随便擦除这些保留块。还有一个容易被忽视的因素是高温环境。NAND的电荷存储在浮栅中温度越高电荷泄漏越快坏块出现的概率越大。如果你的设备用在户外高温场合建议在选型时直接选择“工业级”甚至“车规级”SLC颗粒并且留出更多坏块冗余。软件上也可以通过周期性的“数据刷新”来减缓位翻转累积但这属于高阶玩法正常人直接用SLCECC就够用了。6. 从配置到量产还有几个没人提醒你的细节嵌入式存储方案从开发到量产最怕的不是技术难题而是“设计时的隐性假设”。比如你选择了某颗NAND在开发板上跑得完美无缺但换到自研板卡上之后出现了随机性的数据损坏。这个时候先别怀疑LittleFS而是检查芯片级的信号完整性特别是数据线的走线长度差和参考地是否完整。NAND虽然工作频率不高一般几十MHz但并行8根数据线如果长度差异过大采样点就会偏移导致偶发错误。这一点在EMI要求较高的产品上尤其需要留意。量产阶段还有一个容易被忽略的问题每一颗NAND的出厂坏块位置和数量都不一样。如果你在开发板上做了全片擦除并把一个含坏块的位置写入了关键数据换一颗芯片可能就启动失败。所以在量产烧录流程中一定要包含“检测坏块并跳过坏块写入”的步骤。U-Boot和Linux内核都支持NAND坏块跳过但如果你是在生产线上用裸焼写器烧录这个逻辑就要自己实现。最简单的办法是烧录前先读取BBT把文件系统镜像烧录到跳过坏块的逻辑地址上并且保证文件系统镜像本身有一定冗余。否则生产线上十个板子有好几个无法启动查起来会非常崩溃。另一个细节是关于LittleFS的block_cycles值。这个值如果在量产时设置得不合理会导致某些板子因为擦写不均衡而提前寿终。最好在开发阶段根据实际负载做一次寿命测试用长时间连续读写来观察坏块增长趋势然后反推合适的block_cycles。虽然这个值只是触发磨损均衡的频率但设得太小会让文件系统频繁搬运冷数据产生不必要的写放大。我用这个方案跑过几个月的压力测试记录一下实测数据128MB数据分区实际存储文件约30MB每天新增数据约1MB连续写90天坏块数从出厂时的3个增加到7个文件系统依然稳定运行掉电重启恢复时间约800ms写入4KB小文件的平均耗时约8ms。这个成绩对于低成本工业设备来说已经够用了。7. 最后分享一个实用小技巧怎样在Zynq上快速验证LittleFS层是否正常在你急着调Linux驱动之前可以先在裸机上用最简单的SPLSecond Program Loader阶段验证NAND硬件是否正常。在Zynq的FSBL中厂商一般会提供NAND读写示例代码你可以把LittleFS的源码直接编译进FSBL然后把一个4KB的测试文件写到NAND里再读出来对比。如果这一步能通过至少说明硬件链路是通的问题大概率出在后续Linux内核的驱动配置上。如果这一步都失败那就老老实实检查硬件别浪费时间去调Linux设备树了。这个方法的好处是调试周期短因为FSBL编译和加载都比整个Linux内核要快得多。我靠这个技巧在项目初期就排除掉了引脚复用、供电不足、坏块表错误等一批底层问题为后面的软件配置省下了大把时间。如果你正在Zynq平台折腾NAND和LittleFS强烈建议先做好这一层验证再往上层走。根据我个人经验LittleFS与NANDFLASH的软硬件配置最大的门槛在于理解“文件系统不是万能胶水”。你得先让底层的NAND驱动稳定可靠地管理坏块、保证ECC强度再接上LittleFS的配置参数才能获得一个兼顾速度和寿命的存储方案。希望这篇内容能帮你少踩几个坑让NANDLittleFS的组合真正在项目中跑得稳、跑得久。