U-Boot flash子系统与持久化:MTD、SPI-NOR架构及环境变量保存实战

发布时间:2026/11/6 3:02:09
U-Boot flash子系统与持久化:MTD、SPI-NOR架构及环境变量保存实战

1. 从一次启动失败说起:为什么flash子系统值得单独梳理

前阵子帮朋友看一块工控板,现象很典型:串口能打印U-Boot的启动日志,DRAM初始化也过了,但每次跑到加载内核那一步就卡死,报错信息里反复出现SF: Failed to read和Bad Magic Number。板子上的固件是烧在SPI-NOR里的,之前一直跑得好好的,只是最近换了一批同型号但不同批次的flash芯片。这种问题最折磨人,因为它不是代码逻辑错了,而是U-Boot的flash子系统在跟新芯片"对不上暗号"。

这件事让我意识到,很多人用U-Boot都是拿来就用,编译、烧录、启动,一旦涉及flash读写、环境变量保存、固件升级这些"持久化"操作,就开始抓瞎。U-Boot的flash子系统其实是一套分层清晰、但细节极多的框架,它上承命令行工具(sf、mtd、nand等),下接具体控制器驱动(SPI控制器、NAND控制器),中间还夹着MTD抽象层和SPI-NOR/NAND的通用操作层。任何一层配置不对,持久化就会出问题。

这篇梳理就是想把U-Boot里跟flash持久化相关的这套东西讲透。核心关键词是u-boot、flash子系统、持久化、MTD、SPI-NOR。我会从整体架构讲到具体驱动,从设备树配置讲到命令行实操,重点解释"为什么这么设计"以及"实际用的时候哪里最容易翻车"。适合正在做嵌入式Linux开发、需要自己适配flash、或者被环境变量保存和固件升级折磨过的朋友。不管你是刚接触U-Boot的新手,还是已经能改驱动但没系统梳理过的老手,应该都能从里面找到点有用的东西。

2. U-Boot flash子系统的分层架构与数据流向

2.1 四层结构:从命令行到物理芯片

U-Boot的flash子系统不是一坨代码,而是分层的。理解这个分层,是排查所有flash问题的前提。从上到下大致是这么四层:

  • 命令层:sf、mtd、nand、ubi这些命令,是用户直接敲的。它们负责解析参数,然后调用下层接口。
  • 抽象层(MTD):drivers/mtd/mtdcore.c这一套,提供统一的mtd_info结构体和mtd_read/mtd_write/mtd_erase接口。不管底层是NOR还是NAND,上层看到的都是MTD设备。
  • 通用操作层:针对SPI-NOR有spi-nor.c,针对NAND有nand_base.c。这一层处理芯片的通用命令,比如读ID、擦除、页编程。
  • 控制器驱动层:具体SoC的SPI控制器驱动(如spi-quad.c)或NAND控制器驱动。这一层直接操作寄存器,跟硬件打交道。

数据流向是这样的:你在命令行敲sf read 0x80000000 0x100000 0x10000,命令层解析出"从flash偏移0x100000读0x10000字节到内存0x80000000",然后调用MTD的mtd_read。MTD根据设备类型找到对应的_read函数,SPI-NOR的话最终走到spi_nor_read,它再通过SPI控制器驱动发命令给芯片。芯片返回数据,原路返回。

这个分层的好处是,换一个SPI控制器,只要写新的控制器驱动,上层MTD和命令层完全不用动。坏处是,一旦出问题,你得知道是哪一层挂了。比如读出来全是0xFF,可能是控制器驱动没配对,也可能是SPI模式设错了,还可能是芯片根本没响应。

2.2 MTD抽象层到底抽象了什么

MTD(Memory Technology Device)这层是理解flash子系统的关键。它把"闪存"这种特殊的存储介质抽象成"可以按块擦除、按页读写"的设备。为什么不能像内存一样随便写?因为flash的物理特性决定了:写之前必须先擦除,而且擦除的最小单位(扇区/块)远大于写入的最小单位(页)。

MTD用mtd_info这个结构体描述一个flash设备,里面有几个字段你必须认识:

字段含义典型值(SPI-NOR)
type设备类型MTD_NORFLASH
size总容量16MB
erasesize擦除块大小64KB(4KB扇区×16)
writesize写入页大小1字节(NOR可以按字节写)
flags能力标志MTD_CAP_NORFLASH

注意writesize对NOR来说是1,因为NOR支持按字节编程,但对NAND来说通常是2048或4096。这个差异直接影响了上层怎么组织写入。另外erasesize特别重要,环境变量保存、固件升级时如果没按擦除块对齐,就会出问题。

MTD还维护分区表。U-Boot里分区可以通过设备树的partition子节点定义,也可以在板级文件里硬编码。分区的作用是把一整块flash切成几段,比如u-boot、u-boot-env、kernel、rootfs。每个分区在MTD里是一个独立的mtd_info,有自己的偏移和大小。这样你操作环境变量分区时,就不会误伤内核分区。

2.3 SPI-NOR与NAND在U-Boot里的处理差异

虽然MTD把两者统一了,但SPI-NOR和NAND在U-Boot里的处理路径差别很大,持久化操作时踩的坑也不一样。

SPI-NOR的特点是:接口简单(SPI总线)、支持随机读、按字节写、可靠性高、容量小。U-Boot里SPI-NOR的驱动在drivers/mtd/spi/下面,核心是spi-nor.c。它通过spi_flash结构体跟SPI控制器通信。SPI-NOR的读写命令是标准化的,比如0x03读、0x02页编程、0x20扇区擦除。但不同厂商的芯片在状态寄存器、擦除命令上可能有细微差别,这就是为什么换芯片批次会出问题。

NAND的特点是:容量大、成本低、有坏块、需要ECC。U-Boot里NAND驱动在drivers/mtd/nand/下面,核心是nand_base.c。NAND的读写必须经过ECC校验,坏块管理也是必须的。持久化时如果没处理坏块,写到坏块上数据就丢了。

对持久化来说,SPI-NOR通常用来存U-Boot本身、环境变量、设备树;NAND通常用来存内核和根文件系统。环境变量这种小数据、高可靠性要求的,放SPI-NOR更合适。这也是为什么热词里"光猫jffs2挂载哪个mtd分区"这种问题,本质是在问NAND上的文件系统分区怎么对应。

3. 环境变量持久化:从内存到flash的完整链路

3.1 环境变量存在哪:CONFIG_ENV_IS_IN_SPI_FLASH的配置逻辑

U-Boot的环境变量默认存在内存里,掉电就丢。要持久化,必须配置存储后端。跟flash相关的配置项有好几个:

  • CONFIG_ENV_IS_IN_SPI_FLASH:环境变量存在SPI-NOR里
  • CONFIG_ENV_IS_IN_NAND:存在NAND里
  • CONFIG_ENV_IS_IN_MMC:存在eMMC/SD卡里
  • CONFIG_ENV_IS_NOWHERE:不持久化(默认)

以SPI-NOR为例,配置里还要指定几个关键参数:

CONFIG_ENV_IS_IN_SPI_FLASH=y CONFIG_ENV_OFFSET=0x100000 CONFIG_ENV_SIZE=0x10000 CONFIG_ENV_SECT_SIZE=0x10000

CONFIG_ENV_OFFSET是环境变量在flash里的偏移,CONFIG_ENV_SIZE是环境变量区大小,CONFIG_ENV_SECT_SIZE是擦除扇区大小。这里有个坑:CONFIG_ENV_SECT_SIZE必须跟flash实际的擦除扇区大小一致。如果flash的扇区是64KB,你配成4KB,保存环境变量时擦除就会出错。

为什么要有CONFIG_ENV_SECT_SIZE?因为环境变量保存时,U-Boot需要先擦除再写入。擦除的最小单位是扇区,如果环境变量区跨了多个扇区,就得逐个擦除。这个参数告诉U-Boot每次擦多大。

3.2 saveenv背后发生了什么

你在U-Boot命令行敲saveenv,看起来就一条命令,背后其实走了一长串流程:

  1. 命令层调用env_save()。
  2. env_save()根据配置找到对应的存储驱动,比如env_save_spi_flash()。
  3. 驱动先检查环境变量区是否有效,如果无效就先擦除。
  4. 把内存里的环境变量数据(env_ptr指向的缓冲区)按扇区对齐,逐扇区擦除再写入。
  5. 写入完成后,可能还要回读校验。

这里最容易出问题的是第4步。环境变量数据在内存里是紧凑的,但写入flash时必须按扇区对齐。如果CONFIG_ENV_SIZE不是CONFIG_ENV_SECT_SIZE的整数倍,U-Boot会报错或者写入不完整。我见过有人把CONFIG_ENV_SIZE设成0x8000,CONFIG_ENV_SECT_SIZE设成0x10000,结果保存时只写了一半,重启后环境变量丢失。

还有一个隐蔽的坑:环境变量的CRC校验。U-Boot在环境变量数据末尾会存一个CRC32值。如果写入过程中断电,CRC对不上,U-Boot会认为环境变量损坏,回退到默认值。这其实是保护机制,但如果你在调试时频繁断电,可能会误以为环境变量没保存成功。

3.3 冗余环境变量:CONFIG_ENV_OFFSET_REDUND的取舍

U-Boot支持冗余环境变量,配置CONFIG_ENV_OFFSET_REDUND后,会存两份环境变量,一份在CONFIG_ENV_OFFSET,一份在CONFIG_ENV_OFFSET_REDUND。每次保存时交替写入,并记录一个标志位表示哪份是最新的。

这个机制的好处是防断电。如果保存第一份时断电,第二份还是完整的,U-Boot启动时能自动恢复。坏处是占用双倍空间,而且配置更复杂。

实际用的时候,如果你的产品对可靠性要求高(比如工业设备),建议开冗余。如果只是开发板调试,单份就够了。开冗余时要注意两个偏移不能重叠,而且都要按扇区对齐。我见过有人把两个偏移设得太近,结果第二份覆盖了第一份,反而更糟。

4. SPI-NOR驱动适配:换芯片批次就翻车的根因

4.1 spi-nor.c如何识别芯片:JEDEC ID与SFDP

SPI-NOR驱动识别芯片靠的是JEDEC ID。每颗SPI-NOR芯片上电后,你发0x9F命令,它会返回3个字节的ID:厂商ID、设备ID高字节、设备ID低字节。驱动拿这个ID去spi_nor_ids表里查,找到对应的struct flash_info,里面记录了芯片的容量、扇区大小、页大小、读写命令等参数。

但JEDEC ID有个问题:同厂商同容量的芯片,ID可能一样,但内部参数(比如擦除命令、状态寄存器位定义)不同。这时候就要靠SFDP(Serial Flash Discoverable Parameters)。SFDP是JEDEC标准,芯片里有一块参数表,驱动读出来就知道芯片支持哪些命令、扇区怎么分布。

U-Boot的spi-nor.c里,如果spi_nor_ids表里没找到匹配的ID,会尝试读SFDP。如果SFDP也读不到,就会报"unrecognized JEDEC id bytes"。这就是换芯片批次翻车的常见原因:新批次芯片的ID跟旧的不一样,或者SFDP表格式变了,驱动没适配。

4.2 手动添加flash_info的完整步骤

遇到不认识的芯片,最直接的办法是手动添加flash_info。步骤如下:

  1. 用示波器或逻辑分析仪抓SPI总线,确认0x9F命令返回的ID。或者用U-Boot的sf probe命令,看打印的ID。
  2. 在drivers/mtd/spi/spi-nor-ids.c(不同版本路径可能不同)里找到spi_nor_ids数组。
  3. 添加一条记录,格式类似:
{ .name = "mx25l25635f", .id = SNOR_ID(0xc2, 0x20, 0x19), .size = SZ_32M, .sector_size = SZ_64K, .page_size = 256, .flags = SPI_NOR_HAS_LOCK | SPI_NOR_4BIT_BP, },
  1. 如果芯片支持4字节地址(容量大于16MB),还要加SPI_NOR_4B_OPCODES标志。
  2. 重新编译U-Boot,烧录测试。

这里的关键是sector_size和page_size必须跟芯片手册一致。sector_size错了,擦除会失败;page_size错了,写入会出错。另外flags里的SPI_NOR_HAS_LOCK表示支持写保护,SPI_NOR_4BIT_BP表示状态寄存器用4位表示块保护,这些都要看手册。

4.3 设备树里SPI-NOR节点的正确写法

现代U-Boot用设备树描述硬件。SPI-NOR节点挂在SPI控制器下面,典型写法:

&spi0 { status = "okay"; flash@0 { compatible = "jedec,spi-nor"; reg = <0>; spi-max-frequency = <50000000>; spi-tx-bus-width = <1>; spi-rx-bus-width = <4>; #address-cells = <1>; #size-cells = <1>; partitions { compatible = "fixed-partitions"; #address-cells = <1>; #size-cells = <1>; partition@0 { label = "u-boot"; reg = <0x0 0x100000>; }; partition@100000 { label = "u-boot-env"; reg = <0x100000 0x10000>; }; }; }; };

几个容易错的地方:spi-rx-bus-width设成4表示用Quad模式读,但有些芯片Quad读需要先发使能命令,如果驱动没处理,读出来就是乱码。spi-max-frequency不能超过芯片和控制器支持的最高频率,设高了会读写不稳定。分区节点的reg是<偏移 大小>,偏移和大小都要跟实际布局一致,否则环境变量保存会写到错误位置。

5. 命令行实操:sf与mtd命令的持久化用法

5.1 sf probe与sf read/write/erase的完整流程

sf命令是操作SPI-NOR最常用的。基本流程:

sf probe 0:0 50000000 0 sf read 0x80000000 0x100000 0x10000 sf erase 0x100000 0x10000 sf write 0x80000000 0x100000 0x10000

sf probe的参数是总线:片选 频率 模式。频率不写就用设备树里的。sf read是内存地址 flash偏移 长度。sf erase是flash偏移 长度,长度必须是擦除扇区的整数倍。sf write是内存地址 flash偏移 长度。

这里有个大坑:sf write之前必须先sf erase。因为flash只能把1写成0,不能把0写成1。如果目标区域不是全0xFF,直接写会得到错误的数据。我见过有人直接sf write覆盖旧数据,结果读出来是旧数据和新数据的按位与,完全不对。

另一个坑是长度对齐。sf erase的长度如果不是扇区大小的整数倍,U-Boot会报错或者只擦部分。sf write的长度如果跨了扇区边界,写入过程中可能触发隐式擦除(取决于驱动实现),导致数据丢失。稳妥的做法是:擦除和写入都按扇区对齐,长度取扇区整数倍。

5.2 mtd命令与分区操作

mtd命令更偏向分区操作:

mtd list mtd read <分区名> <内存地址> <偏移> <长度> mtd write <分区名> <内存地址> <偏移> <长度> mtd erase <分区名>

mtd list会列出所有MTD分区,包括SPI-NOR和NAND的。用分区名操作比用裸偏移安全,因为分区名对应的是逻辑分区,不会误伤其他区域。

mtd write内部会自动处理擦除吗?不一定。有些版本的U-Boot里,mtd write会先擦除再写,有些不会。稳妥起见,还是先mtd erase再mtd write。另外mtd erase如果不带偏移和长度,会擦除整个分区,用的时候要小心。

5.3 环境变量相关命令:env print/set/save/export

环境变量操作命令:

env print env set bootargs 'console=ttyS0,115200 root=/dev/mtdblock3' env save env export -t 0x80000000 env import -t 0x80000000 0x1000

env save就是saveenv。env export把环境变量导出到内存,env import从内存导入。这两个命令在批量烧录时有用:可以先在一台设备上配好环境变量,导出成二进制,然后批量导入到其他设备。

注意env set只是改内存里的值,必须env save才持久化。如果改了没保存,重启就丢。另外环境变量有大小限制,CONFIG_ENV_SIZE决定了能存多少。如果变量太多超了,env save会报错。

6. 持久化踩坑实录:几个真实案例的排查链路

6.1 案例一:saveenv报"Failed to erase"的排查

现象:敲saveenv,报Failed to erase,环境变量保存失败。

排查链路:

  1. 先确认CONFIG_ENV_SECT_SIZE跟flash实际扇区大小是否一致。查芯片手册,扇区是64KB,配置里是0x10000,一致。
  2. 用sf probe确认芯片识别正常,ID对得上。
  3. 用sf read读环境变量区,看是否能读出数据。能读,说明读路径没问题。
  4. 手动sf erase环境变量区,报同样的错。说明擦除命令有问题。
  5. 查spi_nor_ids表,发现芯片的擦除命令是0x20(4KB扇区擦除),但驱动里配的是0xD8(64KB块擦除)。新批次芯片不支持0xD8。
  6. 修改flash_info里的擦除命令,或者让驱动用0x20逐扇区擦除。重新编译,问题解决。

这个案例的教训是:擦除命令不是所有芯片都一样的。老芯片支持块擦除,新芯片可能只支持扇区擦除。驱动适配时一定要看手册。

6.2 案例二:环境变量保存后重启丢失

现象:saveenv显示成功,但重启后环境变量恢复默认值。

排查链路:

  1. 确认CONFIG_ENV_OFFSET跟分区表里环境变量分区的偏移一致。查设备树,分区偏移是0x100000,配置里也是0x100000,一致。
  2. 用sf read读环境变量区,看是否有数据。读出来全是0xFF,说明根本没写进去。
  3. 检查saveenv的返回值,发现虽然打印成功,但实际写入长度为0。
  4. 查代码,发现CONFIG_ENV_SIZE设成了0x20000,但CONFIG_ENV_SECT_SIZE是0x10000,环境变量区跨了两个扇区。驱动在擦除第二个扇区时失败,但错误没上报。
  5. 把CONFIG_ENV_SIZE改成0x10000,重新编译,问题解决。

这个案例的教训是:环境变量区大小必须是扇区大小的整数倍,而且最好就是一个扇区,避免跨扇区操作。

6.3 案例三:NAND上JFFS2挂载失败与MTD分区对应

热词里"光猫jffs2挂载哪个mtd分区"这个问题,本质是NAND分区和文件系统分区的对应关系。现象:内核启动时挂载JFFS2失败,报No filesystem could mount root。

排查链路:

  1. 看内核启动日志,确认NAND驱动是否识别了所有分区。mtd list能看到分区,但名字可能跟预期不符。
  2. 确认JFFS2分区在设备树里的reg是否正确。JFFS2需要知道分区的起始偏移和大小。
  3. 检查内核命令行root=参数。如果是root=/dev/mtdblock3,要确认mtdblock3对应的是哪个分区。mtdblock的编号是按分区注册顺序来的,顺序变了编号就变了。
  4. 用mtd read读JFFS2分区头部,看是否有JFFS2的魔数(0x1985)。如果没有,说明分区里没烧文件系统。
  5. 重新烧录JFFS2镜像到正确分区,问题解决。

这个案例的教训是:MTD分区编号不是固定的,依赖注册顺序。用分区名比用编号可靠。另外JFFS2挂载需要分区里有有效的文件系统镜像,空分区挂不上。

7. 把flash子系统用稳的几个经验

7.1 配置检查清单

每次适配新板子或新flash,按这个清单过一遍:

  • CONFIG_ENV_IS_IN_SPI_FLASH或CONFIG_ENV_IS_IN_NAND是否使能
  • CONFIG_ENV_OFFSET是否跟分区表一致
  • CONFIG_ENV_SIZE是否是CONFIG_ENV_SECT_SIZE的整数倍
  • CONFIG_ENV_SECT_SIZE是否跟芯片手册一致
  • 设备树里SPI-NOR节点的spi-max-frequency是否在安全范围内
  • spi-rx-bus-width是否跟硬件连线一致(单线还是四线)
  • 分区表的偏移和大小是否跟实际布局一致

7.2 调试技巧

  • 用sf probe确认芯片识别,看打印的ID和容量
  • 用sf read读关键区域,确认数据可读
  • 用md命令看内存里的数据,对比flash读出的数据
  • 保存环境变量后,立即sf read回读,确认写入成功
  • 如果读写不稳定,降低SPI频率试试
  • 用逻辑分析仪抓SPI波形,看命令和数据是否符合预期

7.3 关于持久化的个人体会

我在实际项目里踩过最多的坑,不是代码写错,而是"想当然"。比如以为同型号芯片参数一定一样,以为环境变量区大小随便设,以为擦除命令所有芯片都支持。这些"想当然"在实验室可能不出问题,一到量产就爆发。

现在我的习惯是:每换一款flash,先花半小时看手册,把JEDEC ID、扇区大小、页大小、擦除命令、状态寄存器位定义都记下来,然后跟驱动里的flash_info逐项对比。这个习惯帮我省了很多返工时间。

另外,环境变量这种关键数据,我建议开冗余。多占一个扇区的空间,换来的是断电不丢配置,很值。固件升级时,也尽量用分区名而不是裸偏移,减少误操作。

flash子系统的代码在U-Boot里不算最复杂的,但细节最多。把这些细节理清楚,持久化就稳了。