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。但事情没这么简单——挂载策略决定了系统的健壮性。
我推荐的做法是:
- 先挂载为只读(ro):内核启动阶段,rootfs以只读方式挂载。这样可以防止意外写入导致文件系统损坏。
- init进程执行后,再remount为读写(rw):init进程完成基本初始化后,再重新挂载为读写模式。
- 如果挂载失败,立即回滚:如果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系统的启动流程画了一张图,方便你理解:
这个流程看起来简单,但每个环节都有坑。比如第2步,如果env分区损坏,bootloader读不到槽位信息怎么办?我一般会在uboot里加一个默认值:如果读不到,就默认从slot_a启动。同时,在rootfs启动后,检查env分区是否正常,如果不正常就修复它。
我的习惯:在rootfs的init脚本中,我会加一段代码,把当前槽位信息写入一个sysfs节点或tmpfs文件。这样用户空间的升级服务可以随时查询当前在哪个槽位,避免误操作。
好了,双分区AB系统的核心实现就这些。分区表设计要留足余量,内核cmdline传递要精准,rootfs挂载策略要稳健。这三者配合好了,系统的可靠性就能上一个台阶。
最后提醒一句:AB系统不是万能的。它只能解决升级过程中的问题,解决不了rootfs本身的逻辑错误。比如你升级了一个有bug的应用程序,AB系统照样会启动那个有bug的版本。所以,升级包的完整性校验和功能测试,同样不能少。