返回

告别内核恐慌: 使用`switch_root`正确切换嵌入式根系统

Linux

深入剖析 switch_root:解决 pivot_root 后 "Attempted to kill init!" 内核恐慌

搞嵌入式系统启动时碰壁是常有的事,尤其是涉及到存储介质可靠性和系统更新策略的时候。这次咱们就来聊聊一个比较棘手的问题:在 Flash + SD 卡的嵌入式设备上,尝试从初始的 Flash 系统切换到 SD 卡上的根文件系统时,遭遇 "Kernel panic - not syncing: Attempted to kill init !" 的错误。

啥情况?切换根文件系统咋就崩了?

想象一下这个场景:你有个嵌入式板子,带了个小而稳的 Flash(比如 16MB)和一个大但不那么靠谱的 SD 卡(比如 8GB)。SD 卡不靠谱主要是硬件连接问题,掉电后可能唤醒不正常,还改不了硬件。

为了保证系统更新过程的稳定性和可靠性,通常会用 A/B 双系统方案(更新不活跃的那个分区,然后重启切换),外加一个恢复(Recovery)系统作为最后保障。

但 Flash 空间有限,放不下完整的 initramfs 一套功能齐全的恢复系统。所以,常见的做法是把启动引导和恢复功能结合在一起,放在 Flash 里。Flash 上跑一个极简系统,里面有个 /init 脚本,负责根据启动参数决定到底加载哪个系统(A、B 还是 R)。

问题就出在这个 /init 脚本尝试切换到 SD 卡分区(比如 A 或 B)的时候。脚本大致是这样操作的:

#!/bin/ash
# ... 省略部分代码 ...

boot_prod() {
  # 挂载 /proc 和 /sys (后面可能移动或卸载)
  mount -t proc none /proc
  mount -t sysfs none /sys

  # 挂载目标分区到 /mnt,检查是否有 /sbin/init
  mount $1 /mnt && [ -x /mnt/sbin/init ] || return 1

  echo "switching to $1"
  cd /mnt
  # 移动 /sys 和 /dev 到新根目录
  mount --move /sys sys
  umount /proc # 新系统会重新挂载
  mount --move /dev dev

  # pivot_root 切换根目录
  pivot_root . mnt
  umount mnt # 卸载旧根挂载点

  # 执行新系统的 init
  # 问题就出在这!!!
  exec chroot . sbin/init <dev/console >dev/console 2>&1
}

# ... case $tryboot ... 调用 boot_prod ...

# ... 失败后启动 shell ...

这段脚本用了 pivot_root,然后跟着 umount 旧根,最后用 exec chroot . sbin/init 来启动新系统的 init 进程。理想很丰满,现实却是在执行 exec chroot ... 这行 之前 (日志里看不到 + exec chroot ... 这行),系统就报 "Kernel panic - not syncing: Attempted to kill init !" 了。

即使注释掉 umount mnt 这句也没用,而且能确认新文件系统的 /sbin/init(通常是指向 bin/busybox 的软链接)确实存在且可执行。

那么,到底是哪里没搞对?

刨根问底:为啥 pivot_root + chroot + exec 会翻车?

要理解这个问题,得先搞明白几个关键点:

  1. PID 1 的特殊性 : 在 Linux 系统里,PID 为 1 的进程,也就是 init 进程,是所有用户空间进程的祖先。它由内核在启动最后阶段直接执行,并且肩负着系统初始化、进程管理(孤儿进程收养)等重任。内核对 PID 1 有特殊的保护机制,不能随便杀掉它。
  2. pivot_root 的作用 : pivot_root(new_root, put_old) 系统调用是用来改变当前进程及其所在 mount 命名空间的根文件系统的。它把当前的根挂载点移动到 put_old 目录下,然后把 new_root 目录作为新的根挂载点。重要的是,pivot_root 仅仅改变了文件系统的挂载结构 ,它不改变 调用它的进程(以及其他进程)当前的根目录 (/) 指向,也不会 自动杀死旧的 PID 1。它只是为后续真正的切换做准备。
  3. chroot 的作用 : chroot(new_root_path) 命令(或系统调用)改变的是当前进程及其进程眼中的根目录 (/) 位置。把它限制在一个新的子目录里,让它感觉那个子目录就是系统的根。
  4. exec 的作用 : exec 系统调用会用一个新的程序映像替换当前进程的映像。进程的 PID 不变,但执行的代码、数据段、堆栈等全都被新的程序替换了。

现在把它们串起来看问题出在哪儿:

在我们的场景里,最初执行 /init 脚本的那个进程,很可能就是内核启动的 PID 1!当这个脚本执行到 pivot_root . mnt 时,它只是改变了文件系统的挂载视图,内核的根挂载点切换了,但脚本进程(PID 1)本身的工作目录、它眼中的根目录 (/) 可能还在旧的 Flash 文件系统上(虽然 cd /mnt 尝试改变工作目录,但根 \ 的概念可能还没完全切换)。

最关键的一步是 exec chroot . sbin/init。这行命令想干两件事:

  • chroot .:让当前进程(PID 1)感觉当前的目录(也就是 pivot_root 之后的新根 /)就是它的根目录。
  • exec sbin/init:用新根目录下的 /sbin/init 程序替换 掉当前的脚本进程(PID 1)。

内核一看:老铁,你(PID 1)这是要自我了断啊?或者说,你在试图用一种非常规(而且可能不安全,比如还有其他进程或内核自身可能依赖于旧的 PID 1 状态)的方式来替换掉我钦定的 init 进程?不行!于是,内核触发保护机制,认为这是个严重错误,直接 Kernel Panic,并报错 "Attempted to kill init!"。

内核期望的是一个平滑、干净、由特定机制(稍后会讲)完成的 PID 1 切换,而不是当前 PID 1 自己"捣鼓"一番然后试图用 exec 来个"移魂大法"。

正确姿势:switch_root 登场

要解决这个问题,我们需要一个专门设计用来完成最后一步 ——从临时根文件系统(比如 initramfs,或者我们这个场景下的 Flash 上的初始系统)切换到最终的目标根文件系统,并且正确地启动目标系统的 init 进程(让它成为新的 PID 1)——的工具。

这个工具就是 switch_root (通常由 BusyBox 提供)。

别被名字(以及常见的 initramfs 语境)迷惑了,switch_root 不仅仅是为了 initramfs 存在的。它的核心目的是安全地、彻底地 替换当前根文件系统,并执行新根上的 init

switch_root 工作原理(简要说):

  1. 清理旧根 : 这是 switch_root 最关键也是与手动 pivot_root + chroot 区别最大的一步。它会删除 掉当前根文件系统(也就是调用 switch_root 时所在的那个文件系统)下的所有 文件和目录。注意!是删除 !这包括正在运行的 switch_root 命令本身!
    • 目的 :强制释放所有进程对旧根文件系统的引用(打开的文件、工作目录等)。确保切换到新根时,旧根已经没有任何瓜葛,可以被干净地卸载掉。
    • 疑问 : 我的旧根是只读的 SquashFS 怎么办?会被删坏吗? 不用担心。对于只读文件系统,这个"删除"操作更多是概念上的,主要是为了检查并确保没有进程依赖于它。它并不会真的去写入或破坏 Flash 上的 SquashFS。如果是 ramfs/tmpfs,那是真的会被清空。
  2. 切换根挂载点 : switch_root 内部会调用 pivot_root (或者类似的机制) 将你指定的新根目录挂载到 / 上。
  3. 执行新 init : 最后,它会执行你指定的位于新根文件系统 上的 init 程序(例如 /sbin/init)。这个新的 init 程序会继承 PID 1 ,成为系统的新守护神。

由于 switch_root 会先清理旧根,再切换并 execinit,整个过程对于内核来说是"合法"且设计如此的。它确保了旧环境的彻底终结和新环境的干净启动,避免了之前那种 PID 1 "自杀式"操作导致的内核恐慌。

实战演练:改造 /init 脚本

知道了原理,改造脚本就简单了。主要思路就是用 exec switch_root 来代替原来复杂的 cd, mount --move, pivot_root, umount, exec chroot 组合拳的最后几步。

推荐方案:使用 switch_root

修改 boot_prod 函数如下:

#!/bin/ash
# ... 前面不变 ...
export PATH=/bin:/sbin:/usr/bin:/usr/sbin

boot_prod() {
  # 检查 busybox 是否包含 switch_root 命令
  if ! type switch_root >/dev/null 2>&1; then
    echo "Error: switch_root command not found!"
    return 1
  fi

  local new_root_dev=$1
  local new_root_mnt="/mnt"
  local new_init_path="/sbin/init" # 相对于新根的 init 路径

  echo "Preparing to switch root to ${new_root_dev}"

  # 1. 确保必要的设备节点存在 (这一步很重要!)
  #    udev 可能还没在新系统跑起来,至少保证 console 设备存在
  [ -c /dev/console ] || mknod /dev/console c 5 1
  #    其他你可能依赖的设备节点...

  # 2. 挂载 proc 和 sys (有些 init 脚本期望它们已挂载,但 switch_root 前通常需要它们在旧根上卸载)
  mount -t proc none /proc
  mount -t sysfs none /sys
  #   devtmpfs 可能更常用,挂载到 /dev
  #   或者,如果你用了静态/dev,需要确保下面的移动包含了所需节点
  mount -t devtmpfs devtmpfs /dev

  # 3. 挂载目标根文件系统
  echo "Mounting ${new_root_dev} to ${new_root_mnt}"
  mount "${new_root_dev}" "${new_root_mnt}"
  if [ $? -ne 0 ] || [ ! -x "${new_root_mnt}${new_init_path}" ]; then
    echo "Failed to mount ${new_root_dev} or ${new_init_path} not found/executable."
    umount /proc /sys /dev # 清理
    return 1
  fi

  # 4. 移动 /dev 到新根目录
  #    有些新系统的 init 需要 /dev 在切换过去时已经准备好
  #    注意: 移动后,当前脚本对 /dev 的访问路径就变了!
  #    确保 switch_root 命令本身还能被找到 (通常 /bin 或 /sbin 在 PATH 里)
  echo "Moving /dev to ${new_root_mnt}/dev"
  mount --move /dev "${new_root_mnt}/dev"
  if [ $? -ne 0 ]; then
    echo "Failed to move /dev."
    umount "${new_root_mnt}"
    umount /proc /sys
    return 1
  fi

  # 5. 卸载 proc 和 sys (switch_root 要求旧根目录尽量干净)
  echo "Unmounting /proc and /sys from old root"
  umount /proc
  umount /sys
  #   这里不需要卸载旧的 /dev, 因为它已经被移走了

  # 6. 执行切换!
  #    `switch_root` 会:
  #    a. 删除当前根文件系统 (Flash SquashFS 上的文件,概念性删除)
  #    b. 把 ${new_root_mnt} 变成新的 /
  #    c. 执行 ${new_root_mnt}${new_init_path} 作为新的 PID 1
  echo "Executing switch_root to ${new_root_mnt}"
  # 加上 exec 确保是 switch_root 进程替换当前脚本进程
  exec switch_root "${new_root_mnt}" "${new_init_path}"

  # 如果 exec switch_root 成功执行,这里的代码永远不会运行
  echo " !!! switch_root failed !!! "
  # 发生错误,尝试恢复挂载并返回失败
  mount --move "${new_root_mnt}/dev" /dev # 尝试移回来
  umount "${new_root_mnt}"
  # 可能需要重新挂载 proc/sys 才能继续运行 shell
  mount -t proc none /proc
  mount -t sysfs none /sys
  return 1
}

# ... case $tryboot 逻辑保持不变 ...
case $tryboot in
A)
  boot_prod /dev/mmcblk0p6 A # 假设 A B 是传递给 config_set 的,如果不需要可以去掉
  ;;
B)
  boot_prod /dev/mmcblk0p7 B
  ;;
R)
  # 恢复模式逻辑,可能直接 exec /sbin/init 或其他恢复脚本
  [ -x /sbin/init ] && exec /sbin/init # 假设恢复系统的 init 就在 Flash SquashFS 上
  ;;
*)
  ;;
esac

# 如果所有 boot_prod 都失败了...
echo "Could not boot any system cleanly; running a recovery shell"
# 重新挂载 proc/sys 以便 shell 能工作
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev # 确保基本的 /dev
exec setsid cttyhack sh

代码解释和关键点:

  • 检查 switch_root : 先确认 switch_root 命令是否存在。
  • 挂载必要文件系统 : 挂载 /proc, /sys, /dev。使用 devtmpfs 是个好主意,可以动态创建设备节点。如果你用静态 /dev,那得确保后续移动 /dev 前,里面有你需要的节点(特别是 /dev/console)。
  • 挂载新根 : 将 SD 卡分区挂载到 /mnt (或其他临时目录)。
  • 移动 /dev : mount --move /dev /mnt/dev 很关键。很多新系统的 init 进程期望在启动时就能访问 /dev 目录下的设备。注意,移动后,如果脚本后续还需要访问设备,路径就变了(但 switch_root 执行前通常不需要再访问)。
  • 卸载 /proc, /sys : switch_root 希望旧的根尽可能空。将这两个伪文件系统从旧根卸载。
  • 执行 exec switch_root /mnt /sbin/init : 这是核心。exec 确保 switch_root 进程替换掉当前的脚本进程 (PID 1)。/mnt 是新根的挂载点,/sbin/init 是新根文件系统里 init 程序的路径(相对于新根)。
  • 错误处理 : 如果 mountswitch_root (虽然 exec 成功后脚本就结束了,但 switch_root 命令本身可能失败) 失败,尝试恢复挂载点并返回错误码,最终让脚本能退回到恢复 shell。
  • 恢复模式 (R) : 这里的逻辑假设恢复系统就是 Flash 上的这个极简系统本身,所以直接 exec /sbin/init。根据你的实际情况调整。

进阶技巧与注意事项

  • /dev 的处理 : 除了 mount --move,另一种方式是让新系统的 init 脚本自己负责挂载 devtmpfs 或运行 mdev -s (BusyBox 的设备管理工具) 来填充 /dev。这种方式更"干净",新系统对自己负责。如果这样做,上面的脚本就不需要 mount --move /dev,只需要确保新系统 init 能正确处理 /dev 就行。但先移动过去通常更简单直接。
  • 挂载点传播 : 如果你系统里有复杂的挂载点(比如共享子树),pivot_rootmount --move 可能会遇到意想不到的问题。对于大多数嵌入式场景,这通常不是问题。
  • U-Boot 启动参数 : 确认 U-Boot 传递给内核的 bootargs 里,init= 参数(如果有的话)是否指向了 Flash 上的这个 /init 脚本 (比如 init=/init)。root= 参数应该指向包含 /init 脚本的那个 Flash 文件系统(比如 root=/dev/mtdblockX 或对应的 ubi: 设备)。内核需要先找到并执行这个初始脚本。脚本内部再根据 $tryboot 参数决定挂载哪个 SD 卡分区。
  • 调试 :
    • set -x 对于调试 shell 脚本非常有帮助,它会打印出每条执行的命令。
    • 串口控制台是嵌入式调试的生命线。确保内核和你的脚本都能输出信息到串口。
    • 在关键步骤前后加 echo 语句,能帮你定位问题发生在哪一步。比如,在 exec switch_root 前加一句 echo "+++ About to execute switch_root +++"。如果这句打印出来了,但系统还是崩了,那问题就锁定在 switch_root 及其后续过程中。如果这句没打印,那问题还在前面。
  • BusyBox 功能确认 : 确保你的 BusyBox编译时开启了 switch_root 和其他你需要的功能(mount, umount, pivot_root 内部可能也需要)。

通过使用 switch_root 这个专门为此设计的工具,可以优雅地解决 pivot_root 手动切换后 init 进程无法正确交接棒导致内核恐慌的问题,让你的嵌入式系统启动流程更健壮。