4、双分区AB系统实现:分区表设计、内核cmdline传递、rootfs挂载策略

双分区AB系统,说白了就是给系统准备两套完整的“备胎”。一套在跑,另一套闲着。一旦正在跑的那套出问题,立马切到备胎上。这个机制在嵌入式领域已经非常成熟了,Android、OpenWrt、各种IoT设备都在用。

我个人习惯把AB系统比作“双保险”。你想想看,设备在野外升级,突然断电了怎么办?升级文件写一半坏了怎么办?没有AB分区,设备就变砖了。有了AB分区,大不了回滚到另一套系统,用户甚至感觉不到异常。

核心思想:任何时候,系统中至少有一个可启动的、完整的rootfs。升级操作只修改非活跃分区,不碰当前运行的系统。

4.1 分区表设计:怎么给系统“分地盘”

分区表设计是AB系统的地基。我见过不少项目,分区表没规划好,后面改起来非常痛苦。这里我给出一个典型的双分区布局,你可以根据实际存储介质(eMMC、NAND Flash、SD卡)做调整。

以eMMC为例,分区表大致长这样:

分区名称 起始地址 大小 说明
bootloader 0x000000 2MB uboot或barebox,不参与AB切换
env 0x200000 512KB 环境变量分区,存储当前活跃槽位
boot_a 0x280000 16MB 槽位A的内核和设备树
rootfs_a 0x1280000 256MB 槽位A的根文件系统
boot_b 0x11280000 16MB 槽位B的内核和设备树
rootfs_b 0x12280000 256MB 槽位B的根文件系统
data 0x22280000 剩余空间 用户数据分区,两个槽位共享

我的经验:boot分区和rootfs分区要分开。为什么?因为内核和设备树更新频率远低于rootfs。分开后,升级rootfs时不用重新烧写内核,节省时间也降低风险。

这里有个细节:env分区用来存“当前启动哪个槽位”的信息。我建议用冗余存储,比如存两份,防止env分区损坏导致系统不知道从哪启动。我曾经遇到过eMMC坏块正好落在env分区,结果设备反复重启,查了两天才定位到问题。

4.2 内核cmdline传递:告诉内核“你该用谁”

Bootloader(比如uboot)在启动内核时,会通过cmdline传递关键参数。AB系统里,最重要的参数就是root=,它告诉内核rootfs在哪个分区。

举个例子,uboot中根据env分区的boot_slot变量,动态拼接cmdline:

// uboot环境变量示例
boot_slot=a
bootargs=console=ttyS0,115200 root=/dev/mmcblk0p${rootfs_part} rootfstype=ext4 rw

// 如果boot_slot=a,则rootfs_part=4(rootfs_a分区号)
// 如果boot_slot=b,则rootfs_part=6(rootfs_b分区号)

嗯,这里要注意:分区号是固定的吗?不一定。不同存储设备、不同分区表,分区号会变。我建议在uboot中写死分区号映射关系,而不是动态扫描。动态扫描虽然灵活,但一旦分区表乱了,系统可能启动到错误的分区。

除了root=,我还会传递一个自定义参数,比如slot=a。这样rootfs启动后,可以通过读取/proc/cmdline知道当前在哪个槽位运行。这个信息在升级时非常有用——你总不能升级正在运行的系统吧?

避坑指南:我曾经在某个项目里,uboot传的cmdline太长,超过了内核的接收限制(通常是2048字节)。结果内核启动时直接截断,root参数没传进去,系统挂载了默认分区。从那以后,我每次都会检查cmdline总长度,尽量精简。

4.3 rootfs挂载策略:怎么让系统“站稳脚跟”

内核启动后,会按照cmdline的指示挂载rootfs。但事情没这么简单——挂载策略决定了系统的健壮性。

我推荐的做法是:

  1. 先挂载为只读(ro):内核启动阶段,rootfs以只读方式挂载。这样可以防止意外写入导致文件系统损坏。
  2. init进程执行后,再remount为读写(rw):init进程完成基本初始化后,再重新挂载为读写模式。
  3. 如果挂载失败,立即回滚:如果rootfs分区损坏,挂载失败,uboot应该检测到并自动切换到另一个槽位。

具体到代码层面,内核cmdline可以这样写:

root=/dev/mmcblk0p4 rootfstype=ext4 ro rootwait

rootwait参数很重要。它告诉内核:如果root设备还没准备好,就等一等。有些存储设备初始化慢,不加这个参数,内核可能因为找不到rootfs而panic。

init进程中的remount操作,通常在/etc/fstab或init脚本中完成:

# /etc/fstab 示例
/dev/mmcblk0p4 / ext4 defaults,noatime 0 1

或者直接在init脚本里执行:

mount -o remount,rw /

关键点:挂载策略的核心是“先只读,后读写”。只读阶段做完整性检查,读写阶段才允许修改。这个顺序能避免很多文件系统问题。

4.4 整体流程:从加电到系统运行

我把整个AB系统的启动流程画了一张图,方便你理解:

双分区AB系统启动流程 1. Bootloader启动 2. 读取env分区,获取当前槽位(slot_a/slot_b) 3. 拼接cmdline,设置root=/dev/mmcblk0pX 4. 加载内核和设备树,启动内核 5. 内核以只读方式挂载rootfs,启动init进程 6. init完成初始化,remount为读写

这个流程看起来简单,但每个环节都有坑。比如第2步,如果env分区损坏,bootloader读不到槽位信息怎么办?我一般会在uboot里加一个默认值:如果读不到,就默认从slot_a启动。同时,在rootfs启动后,检查env分区是否正常,如果不正常就修复它。

我的习惯:在rootfs的init脚本中,我会加一段代码,把当前槽位信息写入一个sysfs节点或tmpfs文件。这样用户空间的升级服务可以随时查询当前在哪个槽位,避免误操作。

好了,双分区AB系统的核心实现就这些。分区表设计要留足余量,内核cmdline传递要精准,rootfs挂载策略要稳健。这三者配合好了,系统的可靠性就能上一个台阶。

最后提醒一句:AB系统不是万能的。它只能解决升级过程中的问题,解决不了rootfs本身的逻辑错误。比如你升级了一个有bug的应用程序,AB系统照样会启动那个有bug的版本。所以,升级包的完整性校验和功能测试,同样不能少。