基於 ramfs 的 OTA

背景

默認的 OTA 方案是基於 recovery 系統完成的。某個產品考慮產品形態和 flash 容量之後,計划去掉 recovery 系統(不考慮掉電安全),這就需要 OTA 方案能支援在只有單個系統的情況下完成升級動作。

默認的 recovery 系統方式

先介紹下默認使用的基於 recovery 系統的升級方式。

主系統由內核和根文件系統組成,分別保存在 flash 上的 kenrelrootfs 分區。另外設置一個 recovery 分區,用於保存 recovery 系統。

此處的 recovery 系統,是一個帶 initramfs 的內核,OTA 所需的應用和庫都包含在 initramfs 中,因此啟動到 recovery 系統之後,可不再依賴 flash 上的其他分區。

當需要進行系統升級時,先設置標誌並重啟,bootloader 檢測到標誌後會啟動進入 recovery系統。在 recovery 系統中,kernelrootfs 分區都是處於未使用狀態,直接將新的數據寫入分區中即可。

更新完主系統之後,設置標誌,重啟到新的主系統即可。

沒有 recovery 帶來的問題

系統默認是將 flash 上的 rootfs 分區掛載為根文件系統,即系統運行時隨時都可能會讀寫 rootfs 分區的數據。

OTA 不重啟到 recovery 系統中,直接在正常系統中,即在 rootfs 分區仍被掛載為根文件系統的情況下,直接從塊設備介面將數據寫入 rootfs 分區,會有概率導致系統崩潰。

畢竟 OTA 應用和庫本身都是放在 rootfs 中的,系統其他活躍進程也隨時有可能對文件系統發出請求。

基於 initramfs 的解決方式

問題很明確,不能再掛載著rootfs的時候更新 rootfs,那先考慮下,在掛載 rootfs 之前進行OTA

原本的內核是直接在內核初始化之後掛載 flash 上的 rootfs 分區作為根文件系統。現在 recovery 系統沒了,但我們可以借鑒 recovery 系統的形式,為這個內核加上 initramfs,在其中包含 OTA 所需的程式。

存在initramfs的情況下,啟動時內核會先掛載 initramfs 並執行 rdinit 指定的程式,到了 initramfsinit 腳本中,就可以判斷是正常啟動還是 OTA 了,若為正常啟動則直接掛載 rootfs 分區,並進行根文件系統切換,後續的流程就跟原方案的主系統啟動流程一致了。

若判斷到正在進行 OTA,則轉而執行 OTA 流程,將新的數據寫入 kernelrootfs 分區,此時的環境跟原方案的 recovery 系統是一樣的。

這種方案的優點是跟之前的流程較為類似,可復用一些成果。缺點是內核帶上 initramfs 之後,不可避免地體積會變大,啟動時間會變長。

關於標誌傳遞

如何告知 initramfs 中的啟動腳本,當前需要進行 OTA 呢?

方式一:通過自定義分區傳遞標誌,在 flash 上的劃定某個分區,例如劃定一個 misc 分區,約定好標誌,OTA 時更新其中的標誌即可

方式二:通過 ubootenv 分區傳遞標誌,uboot 原生提供了可以在 linux 用戶空間讀寫 env 分區的應用,編譯後使用 fw_printenvfw_setenv 應用即可。詳見 uboot 文檔。

方式三:通過cmdline傳遞標誌,initramfs可直接讀取方式一和二設置的標誌,也可以請 bootloader 約定好,由bootloader檢測到方式一和二設置的標誌後,修改傳遞給 kernelcmdline

方式四:通過晶片提供的暫存器傳遞標誌。例如某些晶片的 RTC 模組中,會預留一些暫存器,供用戶自定義使用,不掉電重啟數據是不會丟的。

基於臨時 ramfs 的解決方式

initramfs 是在掛載 rootfs 之前進行 OTA,那有沒有辦法在掛載 rootfs 之後進行 OTA 呢?也是有的,先把 rootfs 分區卸載掉就可以了。

當然,直接 umount 是不行的,rootfs 分區現在還是尊貴的根文件系統,要想卸載,就得先切換到另一個根文件系統去。那另外的根文件系統從何而來呢?沒有現成的,但可以造!

我們看看 openwrt 如何做的。切換根文件之前,先調用 kill_remaining 函數 kill 掉無關進程,這樣可以讓構造的 ramfs 只需包含 OTA 所需的應用和庫。

kill_remaining() { # [ <signal> [ <loop> ] ]
	local loop_limit=10

	local sig="${1:-TERM}"
	local loop="${2:-0}"
	local run=true
	local stat
	local proc_ppid=$(cut -d' ' -f4  /proc/$$/stat)

	echo -n "Sending $sig to remaining processes ... "

	while $run; do
		run=false
		for stat in /proc/[0-9]*/stat; do
			[ -f "$stat" ] || continue

			local pid name state ppid rest
			read pid name state ppid rest < $stat
			name="${name#(}"; name="${name%)}"

			# Skip PID1, our parent, ourself and our children
			[ $pid -ne 1 -a $pid -ne $proc_ppid -a $pid -ne $$ -a $ppid -ne $$ ] || continue

			local cmdline
			read cmdline < /proc/$pid/cmdline

			# Skip kernel threads
			[ -n "$cmdline" ] || continue

			echo -n "$name "
			kill -$sig $pid 2>/dev/null

			[ $loop -eq 1 ] && run=true
		done

		let loop_limit--
		[ $loop_limit -eq 0 ] && {
			echo
			echo "Failed to kill all processes."
			exit 1
		}
	done
	echo
}

然後拷貝所需文件到 ram 中,構造出所需的 ramfs

switch_to_ramfs() {
         # 將一些基礎文件拷貝到ram中,構造ramfs
	for binary in \
		/bin/busybox /bin/ash /bin/sh /bin/mount /bin/umount	\
		pivot_root mount_root reboot sync kill sleep		\
		md5sum hexdump cat zcat bzcat dd tar			\
		ls basename find cp mv rm mkdir rmdir mknod touch chmod \
		'[' printf wc grep awk sed cut				\
		mtd partx losetup mkfs.ext4 nandwrite flash_erase	\
		ubiupdatevol ubiattach ubiblock ubiformat		\
		ubidetach ubirsvol ubirmvol ubimkvol			\
		snapshot snapshot_tool					\
                # 除了上面列出來的,還可以將自定義的一些文件賦值到 $RAMFS_COPY_BIN 中,這樣就無需改動官方的這份文件
		$RAMFS_COPY_BIN
	do
		local file="$(which "$binary" 2>/dev/null)"
		[ -n "$file" ] && install_bin "$file"
	done
	install_file /etc/resolv.conf /lib/*.sh /lib/functions/*.sh /lib/upgrade/*.sh /lib/upgrade/do_stage2 /usr/share/libubox/jshn.sh $RAMFS_COPY_DATA

	[ -L "/lib64" ] && ln -s /lib $RAM_ROOT/lib64

接著進行關鍵的根文件系統切換

	supivot $RAM_ROOT /mnt || {
		echo "Failed to switch over to ramfs. Please reboot."
		exit 1
	}

切換後收個尾

        #原本的根文件系統,變成掛載在 /mnt 下,現在可以卸載掉
	/bin/mount -o remount,ro /mnt
	/bin/umount -l /mnt

	grep /overlay /proc/mounts > /dev/null && {
		/bin/mount -o noatime,remount,ro /overlay
		/bin/umount -l /overlay
	}
}

最後在 ramfs 中調用真正的 OTA 命令

# Exec new shell from ramfs
exec /bin/busybox ash -c "$COMMAND"

這種做法的好處是,避免了 intiramfs 帶來的體積和啟動速度問題,且 OTA 過程只有一次重啟。

更具體請參考 openwrt 官方的升級腳本(舊版本搜索run_ramfs,新版本搜索 switch_to_ramfs)。

畢竟是 shell 腳本,很容易便可以移植到其他的環境中使用的。

Tags: