基於 ramfs 的 OTA

all發表於2020-09-12

背景

預設的 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 指令碼,很容易便可以移植到其他的環境中使用的。

相關文章