告别内核恐慌: 使用`switch_root`正确切换嵌入式根系统
2025-04-20 06:51:56
深入剖析 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
会翻车?
要理解这个问题,得先搞明白几个关键点:
- PID 1 的特殊性 : 在 Linux 系统里,PID 为 1 的进程,也就是
init
进程,是所有用户空间进程的祖先。它由内核在启动最后阶段直接执行,并且肩负着系统初始化、进程管理(孤儿进程收养)等重任。内核对 PID 1 有特殊的保护机制,不能随便杀掉它。 pivot_root
的作用 :pivot_root(new_root, put_old)
系统调用是用来改变当前进程及其所在mount
命名空间的根文件系统的。它把当前的根挂载点移动到put_old
目录下,然后把new_root
目录作为新的根挂载点。重要的是,pivot_root
仅仅改变了文件系统的挂载结构 ,它不改变 调用它的进程(以及其他进程)当前的根目录 (/
) 指向,也不会 自动杀死旧的 PID 1。它只是为后续真正的切换做准备。chroot
的作用 :chroot(new_root_path)
命令(或系统调用)改变的是当前进程及其子进程眼中的根目录 (/
) 位置。把它限制在一个新的子目录里,让它感觉那个子目录就是系统的根。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
工作原理(简要说):
- 清理旧根 : 这是
switch_root
最关键也是与手动pivot_root
+chroot
区别最大的一步。它会删除 掉当前根文件系统(也就是调用switch_root
时所在的那个文件系统)下的所有 文件和目录。注意!是删除 !这包括正在运行的switch_root
命令本身!- 目的 :强制释放所有进程对旧根文件系统的引用(打开的文件、工作目录等)。确保切换到新根时,旧根已经没有任何瓜葛,可以被干净地卸载掉。
- 疑问 : 我的旧根是只读的 SquashFS 怎么办?会被删坏吗? 不用担心。对于只读文件系统,这个"删除"操作更多是概念上的,主要是为了检查并确保没有进程依赖于它。它并不会真的去写入或破坏 Flash 上的 SquashFS。如果是 ramfs/tmpfs,那是真的会被清空。
- 切换根挂载点 :
switch_root
内部会调用pivot_root
(或者类似的机制) 将你指定的新根目录挂载到/
上。 - 执行新
init
: 最后,它会执行你指定的位于新根文件系统 上的init
程序(例如/sbin/init
)。这个新的init
程序会继承 PID 1 ,成为系统的新守护神。
由于 switch_root
会先清理旧根,再切换并 exec
新 init
,整个过程对于内核来说是"合法"且设计如此的。它确保了旧环境的彻底终结和新环境的干净启动,避免了之前那种 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
程序的路径(相对于新根)。 - 错误处理 : 如果
mount
或switch_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_root
和mount --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
进程无法正确交接棒导致内核恐慌的问题,让你的嵌入式系统启动流程更健壮。