1. 从一次掉电事故说起:为什么u-boot的flash子系统值得单独梳理
三年前我在做一个工业网关项目,设备在现场跑了小半年,突然有批次机器批量返修,症状出奇地一致:能上电、能跑系统,但所有配置参数全部回到出厂值,像是被人按了恢复键。售后同事一开始怀疑是纽扣电池没电,换了电池问题依旧;又怀疑是应用层写文件没落盘,查了代码发现配置写入后明明调了同步接口。最后把板子拿回实验室,用示波器抓电源时序,才发现问题出在u-boot阶段——设备在异常掉电重启时,u-boot读取环境变量分区读到了半截数据,校验失败后自动加载了默认环境,然后这个默认环境又把内核启动参数里的配置分区路径指错了,应用层一看路径不对,直接重建了一套默认配置。
这个坑让我意识到一件事:很多人做嵌入式开发,注意力全放在内核和文件系统上,对u-boot的flash子系统往往停留在“能启动就行”的层面。可一旦涉及持久化——环境变量保存、启动参数固化、固件升级回滚、出厂参数存储——u-boot的flash子系统就是整条链路的起点。它要是不可靠,上层做得再漂亮都是空中楼阁。
这篇梳理就是把我这些年踩过的坑、验证过的方案、以及实际项目里反复用到的配置方法整理出来。核心围绕u-boot的flash子系统展开,涉及MTD分层的设计逻辑、SPI-NOR器件的操作要点、环境变量持久化的实现路径,以及掉电保护这类工程上必须考虑的问题。适合正在做嵌入式Linux产品、需要处理参数持久化、或者被flash读写问题折磨过的同行参考。不管你是刚接触u-boot的新手,还是已经调过几块板子的老手,下面这些内容应该都能找到对你有用的部分。
2. u-boot flash子系统的整体设计与分层思路
2.1 为什么u-boot要单独做一套flash抽象层
先回答一个很多人没想明白的问题:内核已经有MTD子系统了,为什么u-boot还要自己搞一套flash驱动框架?直接用内核那套不行吗?
答案在于运行环境的根本差异。内核跑起来的时候,内存管理、中断、调度、DMA都已经就绪,MTD可以依赖这些基础设施做复杂的缓冲和异步操作。但u-boot运行在极简环境下,没有虚拟内存、没有完整的中断框架,甚至栈空间都小得可怜。它需要一套能在几百KB内存里跑起来、不依赖任何操作系统服务的flash访问方案。
所以u-boot的flash子系统本质上是一个“裸机可用”的抽象层。它的设计目标很明确:用最小的资源开销,提供统一的flash读写擦接口,让上层命令(比如saveenv、sf、nand)不用关心底层是SPI-NOR还是NAND还是NOR。这个抽象层就是u-boot自己的MTD实现,代码主要在drivers/mtd/目录下。
这里有个容易混淆的点:u-boot的MTD和内核的MTD虽然名字一样,但它们是两套独立实现,只是概念模型相似。u-boot的MTD更轻量,去掉了大量内核才需要的特性,比如复杂的坏块管理策略、ECC自动纠错的高级封装等。理解这一点很重要,因为你在u-boot下操作flash时,很多在内核里自动完成的事情需要手动处理。
2.2 u-boot flash子系统的四层结构
从代码组织上看,u-boot的flash子系统大致分为四层,从上到下依次是:
- 命令层:也就是我们在u-boot命令行里敲的saveenv、sf probe、sf read、nand erase这些命令,对应cmd/目录下的实现。
- 抽象层:提供统一的mtd_info结构体和读写擦接口,屏蔽底层器件差异,对应drivers/mtd/mtdcore.c等文件。
- 器件驱动层:针对具体flash类型的驱动,比如SPI-NOR的spi-nor.c、NAND的nand_base.c、SPI-NAND的spi-nand.c。
- 控制器驱动层:也就是具体SoC的flash控制器驱动,比如STM32的qspi、i.MX的fspi、全志的spi控制器等。
这个分层的好处是,当你换一颗flash芯片时,通常只需要改器件驱动层的参数(比如页大小、擦除块大小、时序),控制器驱动和上层命令基本不用动。但实际项目中,问题往往出在层与层之间的衔接上——比如控制器驱动的时序配置和器件手册对不上,导致读出来的数据偶发位翻转;或者抽象层的分区表和实际flash布局不一致,导致写到错误地址。
2.3 MTD设备模型与分区表的绑定关系
u-boot里的MTD设备模型和内核类似,核心是mtd_info结构体,它描述了一个MTD设备的全部属性:类型(NOR/NAND/SPI-NOR等)、大小、擦除块大小、页大小、读写擦函数指针等。但光有设备还不够,实际使用中我们操作的是“分区”,不是整块flash。
分区表在u-boot里有几种来源:
- 设备树中的分区定义:这是现在最推荐的方式,在dts里用partition节点描述每个分区的名字、偏移、大小。
- 环境变量mtdparts:老一些的方案,通过mtdparts这个环境变量定义分区,格式类似
mtdparts=spi0.0:1m(boot),4m(kernel),8m(rootfs)。 - 驱动内硬编码:最不推荐,改起来麻烦,但有些老代码还在用。
分区表和MTD设备的绑定发生在mtdcore初始化阶段。u-boot会遍历所有注册的MTD设备,根据分区定义把它们切成多个逻辑MTD设备。这里有个关键细节:分区必须按偏移从小到大排列,且不能重叠,否则u-boot在解析时会报错或者行为异常。我见过一个案例,分区表里两个分区偏移写反了,结果saveenv把环境变量写到了内核分区里,直接把内核覆盖了,设备变砖。
提示:分区表的偏移和大小一定要和实际flash的擦除块大小对齐。SPI-NOR的擦除块通常是4KB或64KB,如果分区起始地址不是擦除块的整数倍,擦除操作会失败或者误擦相邻数据。
3. SPI-NOR器件的操作要点与持久化实现细节
3.1 SPI-NOR的物理特性决定了操作方式
SPI-NOR是嵌入式设备里最常见的持久化存储介质之一,容量从512KB到32MB都有。它的物理特性直接决定了u-boot里的操作方式,理解这些特性比死记命令重要得多。
SPI-NOR的存储单元是“页”,典型页大小256字节。读操作可以按字节随机访问,但写操作有个硬性约束:只能把1写成0,不能把0写成1。这意味着如果你要修改一个已经写过的位置,必须先擦除整个擦除块(通常是4KB或64KB),擦除后所有位变回1,然后再写入新数据。
这个特性带来两个直接后果:
第一,写之前必须擦除。很多人第一次用sf write命令时会发现写进去的数据不对,原因就是目标地址没有先擦除。u-boot的sf命令不会自动帮你擦,需要先执行sf erase。
第二,擦除粒度远大于写入粒度。写256字节只需要几毫秒,但擦除一个64KB的块可能需要几百毫秒甚至更久。这个时间差在掉电场景下非常关键——如果擦除过程中掉电,整个块的数据可能都处于不确定状态。
3.2 u-boot下SPI-NOR的探测与识别流程
在u-boot里操作SPI-NOR之前,第一步是让u-boot识别到这颗芯片。流程大致是这样的:
- 控制器驱动初始化,配置SPI总线的时钟、模式(CPOL/CPHA)。
- 发送RDID(0x9F)命令读取JEDEC ID,通常是3个字节,比如Winbond的0xEF4018。
- 用读到的ID去匹配驱动里的spi_nor_ids表,找到对应的参数:页大小、擦除块大小、总容量、地址宽度等。
- 注册MTD设备,生成对应的mtd_info。
这里最容易出问题的是第2步和第3步。如果RDID读出来是0xFFFFFF或者0x000000,说明SPI通信本身有问题,可能是时钟太快、模式不对、或者CS片选时序不满足。如果ID读出来是对的但匹配不到驱动表项,u-boot会报“unrecognized JEDEC id”然后拒绝注册设备。
我遇到过一颗国产SPI-NOR,ID是0x0B4018,驱动表里没有,但它的参数和Winbond W25Q128完全一致。解决办法是在spi_nor_ids表里手动加一项,把ID和参数填进去。这个操作不难,但要知道去哪个文件改——通常是drivers/mtd/spi/spi-nor-ids.c。
3.3 环境变量持久化的完整链路
环境变量持久化是u-boot flash子系统最核心的应用场景,也是“持久化”这个词在u-boot语境下的主要含义。它的完整链路是这样的:
保存流程(saveenv命令):
- 检查环境变量是否有效(crc32校验)。
- 找到环境变量所在的分区(通常是名为“env”或“u-boot-env”的分区)。
- 擦除环境变量分区的一个副本(u-boot通常维护两个副本,交替写入,防止擦除过程中掉电导致数据全丢)。
- 把环境变量数据写入刚擦除的副本。
- 更新副本标志,标记哪个副本是最新的。
加载流程(启动时自动执行):
- 读取两个副本的头部信息。
- 分别做crc32校验。
- 选择校验通过且版本较新的那个副本。
- 如果两个副本都校验失败,加载编译时内置的默认环境。
这个双副本机制是u-boot持久化可靠性的关键。单副本方案在擦除过程中掉电,环境变量就彻底丢了;双副本方案下,即使一个副本损坏,另一个还能用。但双副本也带来一个代价:环境变量分区的大小必须是环境变量数据的两倍以上。比如你的环境变量有4KB,分区至少要给8KB,实际项目中通常给64KB或128KB,留足余量。
注意:环境变量分区的擦除块大小要和flash的物理擦除块对齐。如果分区大小不是擦除块的整数倍,u-boot在擦除时可能会报错或者擦到分区外的数据。
3.4 环境变量持久化的配置实操
在实际项目里配置环境变量持久化,需要改几个地方:
设备树里的分区定义:
&spi0 { flash@0 { compatible = "jedec,spi-nor"; reg = <0>; spi-max-frequency = <50000000>; partitions { compatible = "fixed-partitions"; #address-cells = <1>; #size-cells = <1>; partition@0 { label = "u-boot"; reg = <0x0 0x80000>; }; partition@80000 { label = "env"; reg = <0x80000 0x10000>; }; partition@90000 { label = "kernel"; reg = <0x90000 0x400000>; }; }; }; };u-boot配置文件里的相关选项:
CONFIG_ENV_IS_IN_SPI_FLASH=y CONFIG_ENV_OFFSET=0x80000 CONFIG_ENV_SIZE=0x10000 CONFIG_ENV_SECT_SIZE=0x1000 CONFIG_ENV_OFFSET_REDUND=0x90000这里几个参数的含义需要说清楚:CONFIG_ENV_OFFSET是主副本的偏移,CONFIG_ENV_OFFSET_REDUND是备份副本的偏移,CONFIG_ENV_SIZE是环境变量数据的大小,CONFIG_ENV_SECT_SIZE是擦除块大小。注意CONFIG_ENV_OFFSET_REDUND和CONFIG_ENV_OFFSET之间至少要隔一个CONFIG_ENV_SIZE,否则两个副本会重叠。
我个人的经验是,CONFIG_ENV_SIZE不要设得太小。有些项目为了省空间设成0x2000(8KB),结果环境变量稍微多一点就写不下,saveenv直接报错。建议至少给0x4000(16KB),实际用下来16KB能存几百个环境变量,对绝大多数项目都够了。
4. 掉电保护与异常恢复的工程实践
4.1 掉电为什么会导致flash数据损坏
掉电对flash的威胁来自两个层面:物理层面和逻辑层面。
物理层面,SPI-NOR的擦除和写入操作都需要一定时间。擦除一个64KB的块可能需要300ms到1s,写入一页256字节需要0.5ms到3ms。如果在这段时间内电源掉下去,操作会中断在半途。擦除中断的后果尤其严重——擦除是把整个块的位从0变回1,中断后块内部分位是1、部分是0,数据完全不可预测。
逻辑层面,即使物理操作完成了,如果u-boot在更新元数据(比如副本标志、crc校验值)之前掉电,下次启动时u-boot会认为这个副本无效,转而使用另一个副本。如果两个副本都处于“更新中”状态,那就只能回退到默认环境。
4.2 双副本机制的实际工作细节
u-boot的双副本机制不是简单地写两份,而是有一套状态标记逻辑。每个副本的头部有一个标志字段,表示这个副本的状态:有效、无效、或者正在更新。
保存时的流程是:
- 读取两个副本的标志,确定哪个是当前有效副本。
- 把新数据写入另一个副本(先擦除,再写入)。
- 写入完成后,更新这个副本的标志为有效。
- 把旧副本的标志改为无效。
这个流程的关键在于第3步和第4步的顺序。如果先改旧副本标志再写新副本,中间掉电会导致两个副本都无效;如果先写新副本再改旧副本标志,中间掉电最多是新副本写了一半,旧副本仍然有效。u-boot采用的是后者,这是正确的设计。
但这里有个细节很多人不知道:u-boot在写入新副本之前,会先把新副本的标志设为“正在更新”。这样即使写入过程中掉电,下次启动时u-boot看到这个标志就知道这个副本不可信,直接跳过。这个“正在更新”标志是掉电保护的最后一道防线。
4.3 实际项目中的掉电测试方法
光看代码逻辑不够,掉电保护必须实测。我常用的测试方法是:
方法一:随机掉电测试。用可编程电源或者继电器,在设备运行过程中随机切断电源,重复几百次,每次上电后检查环境变量是否完整、设备是否能正常启动。这个方法最接近真实场景,但耗时较长。
方法二:定点掉电测试。在saveenv执行过程中,用调试器或者GPIO触发在特定时间点掉电,比如擦除开始后10ms、写入开始后1ms等。这个方法能精确测试每个关键时间点的掉电行为,但需要硬件配合。
方法三:软件模拟。在u-boot代码里插入延时或者人为制造写入失败,观察恢复逻辑是否正确。这个方法最快,但只能验证逻辑,不能验证物理层面的问题。
我一般先用方法三快速验证逻辑,再用方法一跑一轮长时间测试。实测下来,双副本机制在随机掉电测试中表现很稳,几百次掉电没有出现过环境变量丢失的情况。但前提是分区大小和擦除块对齐,且CONFIG_ENV_SECT_SIZE配置正确。
4.4 环境变量损坏后的恢复策略
即使有双副本保护,极端情况下环境变量还是可能损坏,比如两个副本所在的擦除块都出现了物理坏块。这时候需要一套恢复策略。
u-boot默认的行为是:如果两个副本都校验失败,加载编译时内置的默认环境。这个默认环境是只读的,设备能启动,但所有自定义配置都丢了。对于现场设备来说,这意味着需要重新配置,如果设备部署在偏远地区,维护成本很高。
更稳妥的做法是在应用层做一层备份。比如在文件系统里保存一份配置的副本,设备启动后应用层检查u-boot环境变量是否有效,如果无效就从文件系统里的备份恢复。这个方案需要应用层和u-boot配合,但能大幅降低现场维护成本。
还有一种做法是在flash上单独划一个“出厂参数”分区,这个分区只在生产时写一次,之后只读。设备启动时如果发现环境变量损坏,可以从出厂参数分区读取关键参数(比如MAC地址、设备序列号)重新生成环境变量。这个方案在网通类产品里很常见。
5. 常见问题排查与实操避坑指南
5.1 flash识别失败的排查思路
flash识别失败是u-boot阶段最常见的问题之一,表现是启动日志里看不到MTD设备注册信息,或者sf probe命令报错。排查思路可以按下面的顺序来:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| RDID读出0xFFFFFF | SPI通信失败 | 检查时钟频率、CPOL/CPHA模式、CS片选 |
| RDID读出0x000000 | MISO线被拉低 | 检查硬件焊接、上拉电阻 |
| RDID正确但报unrecognized | 驱动表里没有这颗芯片 | 查JEDEC ID,手动添加表项 |
| 识别成功但读写报错 | 地址宽度或擦除块大小配置错误 | 对照芯片手册检查参数 |
| 分区注册失败 | 分区表偏移或大小不对齐 | 检查dts分区定义 |
这个表里的每一行我都实际遇到过。最常见的是第一行和第二行,基本都是硬件或者时序问题。第三行在国产芯片上很常见,解决办法就是加表项。第四行和第五行属于配置问题,仔细对照手册一般都能解决。
5.2 saveenv失败的几种典型情况
saveenv失败比flash识别失败更让人头疼,因为这时候设备已经能跑了,问题出在持久化环节。我整理了几种典型情况:
情况一:报“Cannot erase environment”。这通常是环境变量分区的擦除块大小配置不对。比如flash的擦除块是64KB,但CONFIG_ENV_SECT_SIZE配成了4KB,u-boot会按4KB去擦,结果擦除命令被flash拒绝。解决办法是把CONFIG_ENV_SECT_SIZE改成和实际擦除块一致。
情况二:报“Environment CRC error”。这说明环境变量数据写进去了,但校验不通过。可能的原因是写入过程中掉电、或者flash有坏块。如果是偶发,检查电源稳定性;如果是必现,检查CONFIG_ENV_SIZE是否超过了分区实际可用大小。
情况三:saveenv成功但重启后环境变量没变。这种情况最隐蔽,通常是分区表配置和实际flash布局不一致。比如dts里env分区偏移是0x80000,但u-boot编译时CONFIG_ENV_OFFSET配的是0x90000,结果saveenv写到了0x90000,而启动时从0x80000读,自然读不到新数据。解决办法是确保dts分区定义和u-boot配置里的偏移完全一致。
5.3 SPI-NOR写保护的坑
SPI-NOR有一个写保护机制,通过状态寄存器里的BP位(Block Protect)控制。如果BP位被设置,对应区域的擦除和写入操作会被硬件拒绝,但读操作正常。这个机制本意是保护关键数据不被误写,但在实际项目中经常造成困扰。
我遇到过一颗flash,出厂时BP位默认设置了,导致u-boot能识别、能读,但saveenv一直失败。查了半天代码没发现问题,最后用sf protect off命令解除保护才解决。所以如果你的flash能读不能写,先检查写保护状态。
u-boot里可以用sf protect lock/unlock命令操作写保护,但要注意:写保护状态是存在flash状态寄存器里的,掉电不丢失。也就是说你这次解除了保护,下次上电如果没有人重新设置,保护仍然是解除状态。但有些flash的BP位在每次上电时会根据某个引脚的电平重新加载,这个要具体看芯片手册。
5.4 环境变量分区大小的经验值
关于环境变量分区给多大,我的经验值是:
- 环境变量数据本身:至少16KB(CONFIG_ENV_SIZE=0x4000)
- 单副本占用:至少一个擦除块,通常64KB
- 双副本总占用:至少128KB
如果flash容量紧张,可以压缩到单副本32KB,但我不推荐。省下来的几十KB空间,换来的是现场掉电后环境变量丢失的风险,不划算。
另外,环境变量分区的位置也有讲究。我一般把它放在u-boot分区之后、内核分区之前。这样即使环境变量分区出了问题,也不会影响u-boot本身的启动。有些方案把环境变量放在u-boot分区内部,虽然省空间,但一旦环境变量写坏,可能连带u-boot都起不来,风险太大。
5.5 调试flash问题的实用命令
u-boot命令行里有几个命令对调试flash问题很有用:
# 探测SPI flash,确认识别正常 sf probe # 读取flash指定地址的数据到内存 sf read 0x80000000 0x80000 0x100 # 查看内存里的数据 md 0x80000000 0x40 # 擦除flash指定区域 sf erase 0x80000 0x10000 # 写入数据到flash sf write 0x80000000 0x80000 0x100 # 查看MTD设备列表 mtd list # 查看环境变量 printenv # 保存环境变量 saveenv这几个命令组合起来,基本能定位大部分flash读写问题。比如怀疑环境变量分区有问题,可以先用sf read把分区内容读到内存,用md查看,确认数据是否符合预期。如果读出来全是0xFF,说明分区是空的或者擦除过;如果读出来是乱码,说明数据损坏。
6. 从u-boot到内核:持久化链路的完整视角
6.1 u-boot环境变量如何传递给内核
u-boot的环境变量不是孤立存在的,它会通过bootargs传递给内核。bootargs本身就是一个环境变量,里面包含了内核启动参数,比如控制台设备、根文件系统位置、MTD分区信息等。
传递链路是这样的:u-boot启动内核时,把bootargs的值放到内核能读到的位置(通常是设备树里的chosen节点或者ATAG),内核解析这个参数,根据里面的mtdparts或者设备树分区信息来注册MTD设备。
这里有个容易出问题的地方:u-boot的分区表和内核的分区表必须一致。如果u-boot里env分区是0x80000-0x90000,但内核设备树里写的是0x80000-0xA0000,那内核看到的env分区就比u-boot看到的大,应用层如果按内核的分区去操作,可能会越界写到内核分区里。
我的做法是,分区表只在设备树里定义一份,u-boot和内核都从设备树读取。这样能保证两边看到的分区完全一致。老一些的方案用mtdparts环境变量传递,虽然也能用,但维护起来容易出错。
6.2 应用层如何安全地读写持久化数据
应用层读写flash上的持久化数据,有几个原则:
原则一:不要直接操作MTD设备节点。/dev/mtd0这类设备节点允许任意读写擦,权限太大,一不小心就把分区写坏了。应该用专门的工具或者库,比如libmtd、mtd-utils,或者自己封装一层带校验的接口。
原则二:写入前先备份。如果要更新一个分区的数据,先把旧数据读出来存到内存或者临时文件,写入新数据后校验,校验通过再删除备份。这样即使写入过程中出问题,还能回滚。
原则三:关键数据加校验和版本号。光写数据不够,还要写crc校验和版本号。读取时先校验crc,再比较版本号,确保读到的是完整且最新的数据。
原则四:考虑磨损均衡。SPI-NOR的擦写次数有限,通常10万次左右。如果频繁写同一个位置,那个擦除块会先坏掉。对于频繁更新的数据(比如运行日志、计数器),要么用文件系统(jffs2、ubifs自带磨损均衡),要么自己在应用层做简单的轮转写入。
6.3 光猫类产品的jffs2挂载与MTD分区关系
光猫类产品里经常看到jffs2文件系统挂载在某个MTD分区上,这个分区通常是“rootfs”或者“data”分区。jffs2的特点是自带磨损均衡和掉电保护,适合频繁读写的场景。
挂载jffs2的前提是MTD分区已经正确注册。如果u-boot阶段分区表配错了,内核阶段就找不到对应的MTD设备,jffs2自然挂不上。常见的报错是“No MTD device found”或者“jffs2: Error reading superblock”。
排查这类问题,第一步是在内核启动日志里找MTD设备注册信息,确认分区数量和偏移是否正确。第二步是检查jffs2的挂载参数,比如mount -t jffs2 /dev/mtdblock5 /data,这里的mtdblock编号要和实际分区对应。第三步是如果jffs2分区是第一次使用,需要先擦除再挂载,否则jffs2会认为分区里没有有效的文件系统。
提示:jffs2挂载的分区大小不要太小,至少给1MB以上。jffs2的元数据开销比较大,分区太小会导致可用空间很少,甚至挂载失败。
7. 个人实操体会与后续扩展方向
这些年调u-boot flash子系统,最大的体会是:持久化的可靠性不是靠某一个机制保证的,而是靠整条链路的每一环都不出问题。从硬件时序、驱动配置、分区表定义、环境变量读写、到应用层的数据管理,任何一环有短板,掉电时都可能出问题。
我现在的习惯是,新板子第一次调通flash后,先跑一轮掉电测试,确认环境变量在反复掉电后仍然完整。然后再跑一轮长时间读写测试,确认flash没有坏块。这两轮测试过了,才敢把板子发到现场。
后续如果要做更深入的扩展,有两个方向值得研究:一是SPI-NAND的持久化方案,SPI-NAND容量更大但坏块管理更复杂,u-boot里的支持不如SPI-NOR成熟;二是安全启动场景下的持久化,环境变量和密钥存储需要加密和签名,这块u-boot有相关框架但配置起来比较繁琐。这两个方向我后面会单独整理,有兴趣的可以关注。