Skip to content

Linux To Go:自定义钩子方案和 vtoyboot 研究

Published on

为了在 Linux 上用 ROCm,但我的机器没有多余硬盘空间,就在空间尚充裕的安装了 Ventoy 的移动硬盘上做了个 Raw 虚拟磁盘,装了个 EndeavourOS 进去。我自己写了一套 initramfs hook,让内核启动时从 NTFS 分区上的虚拟磁盘挂载根文件系统。本来这事就算完了,结果看到群友提到 Ventoy 官方的 vtoyboot 工具要求不能使用 PARTUUID,我觉得奇怪,就顺着查了下去,阅读了 linux 内核代码解释了此问题的原因。

Linux To Go 的快速旅程

1. 安装系统(宿主机 → VM)

  1. 在 ventoy 主分区(NTFS)上创建 Raw 虚拟磁盘 fallocate -l 40G root.img
  2. 添加 Raw 虚拟磁盘 和 EndeavourOS ISO 到虚拟机
  3. 启动虚拟机
  4. 格式化虚拟磁盘的块设备为 EXT4,不分区(即直接 mkfs.ext4 /dev/vdb,不在其上创建分区表)
  5. 安装 EndeavourOS 到 EXT4,不添加 bootloader(Ventoy 的 GRUB2 会直接加载内核和 initramfs,因此不需要在虚拟磁盘内安装 bootloader)

如果你打算安装 Arch Linux, archinstall 提供了 --skip-boot 选项来跳过 bootloader 安装[1],同样适用于这个场景。

2. 添加 initramfs hook

安装器运行完毕后不要重启arch-chroot 进入新系统的环境,安装 mkinitcpio,修改相关配置,让 initramfs 能从虚拟磁盘中挂载 Linux root。

确保使用基于 busybox 的 initramfs。mkinitcpio 版本 40 起,mkinitcpio.conf 默认 HOOKS 设置会生成基于 systemd 的 initramfs,需去除 systemd hook,将 sd-vconsole 替换为 keymapconsolefont

添加一个 hook 覆盖默认挂载处理程序mount_handler),在启动时实现以下过程:

  1. 挂载 ventoy 主分区到临时目录如 /ntfs_root
  2. 从磁盘镜像创建 loop 设备
  3. 挂载 loop 设备到 /new_root
  4. bind --move ventoy 主分区到 /new_root 内,如 /new_root/mnt/ntfs,否则在 switch_root 后将失去对 ventoy 主分区的访问

我们将指向 Linux root 块设备的 root 参数重新利用,改为指向 ventoy 主分区;使用 rootloop 参数作为虚拟磁盘在 ventoy 主分区内的路径,使用 loopflags 参数作为 Linux root 的文件系统的挂载参数。

一份不含失败时进入 emergency shell 的 hook 示例:

/etc/initcpio/hooks/loop-root

shell
#!/usr/bin/ash

run_hook() {
	mount_handler="loop_root_mount_handler"
}

loop_root_mount_handler() {
    local ntfs_mnt="/ntfs_root"
    local loopdev=""

    modprobe -q ntfs3
    modprobe -q loop

    mkdir -p "$ntfs_mnt"

    msg ":: mounting NTFS3 partition $root"
    mount -t ntfs3 -o "${rwopt:-ro}${rootflags:+,$rootflags}" "$root" "$ntfs_mnt"

    local img_path="$ntfs_mnt/$rootloop"
    msg ":: attaching loop device to $rootloop"
    loopdev=$(losetup --find --show "$img_path")

    msg ":: mounting $loopdev as root ($loopflags)"
    mount -t ext4 -o "${rwopt:-ro}${loopflags:+,$loopflags}" "$loopdev" "$1"

    mkdir -p "$1/mnt/ntfs"
    mount --move "$ntfs_mnt" "$1/mnt/ntfs"

    msg ":: root mounted successfully"
}

对应的 install:/etc/initcpio/install/loop-root

shell
#!/usr/bin/env bash

build() {
    add_module ntfs3
    add_module loop
    add_module ext4
    add_binary losetup
    add_runscript
}

help() {
    cat <<HELPEOF
loop-root hook: mount root from a loop-mounted image file on an NTFS3
partition.  Requires rootloop=<absolute-path> in the kernel cmdline, e.g.

    root=UUID=xxx rootloop=/root.img [loopflags=xxx]

The hook mounts the NTFS3 partition, attaches a loop device to the
specified image file, and mounts the loop device as the real root.
HELPEOF
}

创建完 hook 后,运行 mkinitcpio -P 重新生成 initramfs。

3. 复制 kernel 和 initramfs 到 ventoy 主分区(回到宿主机)

对于 Windows 宿主机,方法包括但不限于:

  • 使用客户机集成或 scp 从虚拟机拷贝文件回 Windows 宿主机
  • 使用第三方工具(DiskGenius、NanaZip)打开虚拟磁盘
  • 给 Linux 虚拟机直连 ventoy 的主分区/磁盘/USB设备

对于 Linux 宿主机或直连 ventoy 的 Linux 虚拟机:

  1. 挂载 ventoy 主分区
  2. losetup -f /path/to/root.img 将虚拟磁盘文件挂载为 loop 块设备
  3. 将 Linux root 分区挂载到 /mnt
  4. /mnt/boot 下的 kernel(如 vmlinuz-linux)和 initramfs(如 initramfs-linux.img)拷贝到 ventoy 主分区上喜欢的位置,我选择了 /archboot/

4. 映像后处理钩子自动化拷贝

第三步的手动拷贝只需在每次内核或 initramfs 更新后重复。这可以通过 映像后处理钩子 自动化。

原理:loop-root hook 在启动时把 ventoy 主分区 bind-mount 到了 /mnt/ntfs,所以系统运行时 /mnt/ntfs/archboot/ 就是 Ventoy 存放 kernel 的位置。mkinitcpio 生成新 initramfs 后会执行 /etc/initcpio/post/ 下的所有脚本,传递新 kernel 和 initramfs 的路径作为 $1$2。我们只需要一个把它们 cp 过去的脚本,以后在系统内更新内核就会自动同步到 Ventoy 分区。

以下是一次性的 post hook 部署步骤(在 Linux 宿主机或直连 ventoy 的 Linux 虚拟机上完成;或暂时跳过这一部分,成功启动到 Linux To Go 后从下面的第6步开始,效果相同):

  1. 挂载 ventoy 主分区
  2. losetup -f /path/to/root.img 将虚拟磁盘文件挂载为 loop 块设备
  3. 将 Linux root 分区挂载到 /mnt
  4. mount --bind ventoy 主分区到 /mnt/ntfs
  5. arch-chroot /mnt 进入系统
  6. 创建 post hook:

/etc/initcpio/post/copy-initramfs.sh

shell
#!/bin/bash

# kernel
cp $1 /mnt/ntfs/archboot/
# initramfs
cp $2 /mnt/ntfs/archboot/
  1. 重新生成 initramfs,post hook 自动将 kernel 和 initramfs 拷贝到 ventoy 主分区

此后在 Linux To Go 内部更新内核时,mkinitcpio 会自动调用此脚本完成拷贝,无需任何手动干预。

5. 为 ventoy 添加 Linux To Go 启动条目

Ventoy是基于grub2的,因此你可以定义你自己的 grub2 菜单然后加载它。[2]

在 ventoy 主分区创建 /ventoy/ventoy_grub.cfg

menuentry "Linux To Go (NTFS3 loop image)" {
    search --set=root --fs-uuid A236238336235815 
    linux /archboot/vmlinuz-linux root=UUID=A236238336235815 rw rootflags=force,uid=0,gid=0 rootloop=/archboot/root.img
    initrd /archboot/initramfs-linux.img
}

6. 启动到虚拟磁盘

完成上述步骤后,启动到 Ventoy 后,在 Ventoy 的界面上按 F6 加载 ventoy_grub.cfg 中定义的条目。

好奇心害死猫

次日,我在 #archlinux-cn Telegram 群内看到群友提到,他根据一篇博客文章使用 ventoy 的 Linux vDisk 文件启动插件 (vtoyboot)也制作了一个 Linux To Go。在文中,要求 kernel cmdline 中指定 root 块设备的参数不得使用 PARTUUID,吸引了我的目光,让我初步了解了 Device Mapper。

vtoyboot 使用方式

以下摘自 https://www.ventoy.net/cn/plugin_vtoyboot.html ,为压缩篇幅略有修改

plugin_vtoyboot:Ventoy 使用此插件来支持在物理机上直接启动安装了 Linux系统的 vdisk 文件 (vhd/vdi/raw 等)

  1. 安装 Linux 系统到 vdisk

创建固定大小的 vhd/vdi,然后把支持的 Linux 系统安装到 vhd/vdi 中即可。安装过程和普通安装没有任何差别。

  1. 在系统下执行 vtoyboot 脚本

安装完成并启动到 Linux 系统中之后,下载 vtoyboot 到 Linux 系统中,解压,然后以root权限执行里面的脚本 sudo bash vtoyboot.sh 脚本执行完之后,使用 poweroff 命令关机。

  1. 拷贝到U盘,改后缀名为 .vtoy 拷贝到 U盘中, 然后重启电脑用 Ventoy 启动

实验调查

根据博客文章留下的 GitHub issue,我们瞥到了 Ventoy 的技术细节一角:

blkid 可以看到 /dev/mapper/ventoy/dev/mapper/ventoy1/dev/mapper/ventoy2 和硬盘、U盘,没有见到not found对应的PARTUUID。

博客主的分区方案是 Single root partition + ESP, 我用同样的方案创建了一个 vtoyboot 启动的 Linux To Go 进行实验:

直接启动时的 blkid

# blkid /dev/sda
/dev/sda: PTUUID="5d7254dd-0251-420d-8700-305f002e6d2a" PTTYPE="gpt"
# blkid
/dev/sda1: UUID="E63A-22FD" BLOCK_SIZE="512" TYPE="vfat" PARTUUID="a6094f8e-4bb6-4a56-8ccd-dfc780a59320"
/dev/sda2: UUID="eadee6dc-cca2-478e-9d8e-3941d46552c7" BLOCK_SIZE="4096" TYPE="ext4" PARTUUID="2561f439-85c4-416c-aae7-e8766be2d954"

vtoyboot 启动时的 blkid

/dev/sda1: LABEL="Ventoy" BLOCK_SIZE="512" UUID="A236238336235815" TYPE="ntfs" PARTUUID="6b701877-1811-4672-ba83-f385ffe870c3"
/dev/sda2: SEC_TYPE="msdos" LABEL_FATBOOT="VTOYEFI" LABEL="VTOYEFI" UUID="EA6C-95B2" BLOCK_SIZE="512" TYPE="vfat" PARTLABEL="VTOYEFI" PARTUUID="d826368c-4f28-4811-8cd4-4cecfc2d8275"

/dev/mapper/ventoy: PTUUID="5d7254dd-0251-420d-8700-305f002e6d2a" PTTYPE="gpt"
/dev/mapper/ventoy1: UUID="E63A-22FD" BLOCK_SIZE="512" TYPE="vfat"
/dev/mapper/ventoy2: UUID="eadee6dc-cca2-478e-9d8e-3941d46552c7" BLOCK_SIZE="4096" TYPE="ext4"
/dev/mapper/sda1: LABEL="Ventoy" BLOCK_SIZE="512" UUID="A236238336235815" TYPE="ntfs"

对比后即可发现,vtoyboot 启动时,虚拟磁盘块设备在 /dev/mapper/ventoy, esp 分区和 root 分区分别在 /dev/mapper/ventoy1/dev/mapper/ventoy2,但奇怪的是,他们都没有 PARTUUID

DM设备上的“分区”并非真正的分区设备

vtoyboot 文件中,distros/mkinitcpio/ventoy-hook.shvtoyboot.sh复制到 /usr/lib/initcpio/hooks/ventoy。这个脚本在 initramfs 中创建了上面看到的 mapper 中的块设备:

  • Ventoy 主分区: /dev/mapper/sda1

  • 虚拟磁盘: /dev/mapper/ventoy

  • 虚拟磁盘中的分区: /dev/mapper/ventoy1, /dev/mapper/ventoy2

首先修改 ventoy hook:保留 dmsetup create 时使用的 table_file,并为不同分区的 table_file 文件名添加 $ID 防止相互覆盖。重新生成 initramfs 后,在内核参数中添加 break,进入 emergency shell。手动挂载 linux root,拷贝所有 table_file,得到以下文件:

==> ventoy_raw_table <==
0 3905847424 linear /dev/sda1 0

==> ventoy_table <==
0 6291456 linear /dev/sda1 768817224

==> ventoy_part_table1 <==
0 2097152 linear /dev/mapper/ventoy 2048

==> ventoy_part_table2 <==
0 4190208 linear /dev/mapper/ventoy 2099200

对照 ventoy hook 中的 dmsetup 调用:

dmsetup create ventoy /ventoy_table
dmsetup create ventoy$ID /ventoy_part_table
dmsetup create $RAWDISKSHORT /ventoy_raw_table

我们发现,虚拟磁盘在 /dev/mapper/ 下的“分区”,实际上都是映射到另一个设备的线性范围的 Device Mapper 逻辑块设备。但是这种“分区”和我们熟悉的 /dev/sda1, /dev/nvme0n1p1 分区块设备有什么区别,导致 blkid 中不显示它们的 PARTUUID 呢?

Linux 建立了“块设备”的抽象,允许不同总线、不同后端硬件、通过网络,等等,用同样的方式——按固定大小的块进行数据读写(Linux Storage Stack Diagram)。“分区设备”是一种特殊的块设备,它是由内核扫描块设备的分区表创建的扩展设备。可以通过 sysfs 查看块设备类型,disk 和 partition 会显示不同的 DEVTYPE

grep DEVTYPE /sys/class/block/*/uevent

当用户空间程序编辑设备上的分区表后,对设备调用 BLKRRPART 或 BLKPG_* ioctl 提醒内核更新分区设备。

那么你能否对任何块设备进行分区呢?答案是否定的,使用 busybox partprobe /dev/dmdeviceblockdev --rereadpt /dev/dmdevice 会输出错误 Invalid argument (EINVAL)。这两个命令都是对 ioctl BLKRRPART 的简单包装。

GNU Parted 的 partprobe 则自己根据分区表计算偏移、使用 libdevmapper 创建 DM 设备。你可以查看 parted 包的依赖,其中包含 device-mapper,并对比 partprobe /dev/dmdevice 运行前后 dmsetup lsls /dev/dm* 的输出,注意到出现新的 DM 设备。

从源代码验证

GNU Parted partprobe

GNU partprobe 对 DM 设备使用 libdevmapper 创建 DM 设备,对其他块设备调用 BLKPG ioctl。

libparted/arch/linux.c

c
/*
 * Sync the partition table in two step process:
 * 1. Remove all of the partitions from the kernel's tables, but do not attempt
 *    removal of any partition for which the corresponding ioctl call fails.
 * 2. Add all the partitions that we hold in disk, throwing a warning
 *    if we cannot because step 1 failed to remove it and it is not being
 *    added back with the same start and length.
 *
 * To achieve this two step process we must calculate the minimum number of
 * maximum possible partitions between what linux supports and what the label
 * type supports. EX:
 *
 * number=MIN(max_parts_supported_in_linux,max_parts_supported_in_msdos_tables)
 */
static int
_disk_sync_part_table (PedDisk* disk)
{
        PED_ASSERT(disk != NULL);
        PED_ASSERT(disk->dev != NULL);
        int lpn, lpn2;
        unsigned int part_range = _device_get_partition_range(disk->dev);
        int (*add_partition)(PedDisk* disk, const PedPartition *part);
        int (*resize_partition)(PedDisk* disk, const PedPartition *part);
        int (*remove_partition)(PedDisk* disk, int partno);
        bool (*get_partition_start_and_length)(PedPartition const *part,
                                               unsigned long long *start,
                                               unsigned long long *length);


#ifdef ENABLE_DEVICE_MAPPER
        if (disk->dev->type == PED_DEVICE_DM) {
                add_partition = _dm_add_partition;
                remove_partition = _dm_remove_partition;
                resize_partition = _dm_resize_partition;
                get_partition_start_and_length = _dm_get_partition_start_and_length;
        } else
#endif
        {
                add_partition = _blkpg_add_partition;
                remove_partition = _blkpg_remove_partition;
#ifdef BLKPG_RESIZE_PARTITION
                resize_partition = _blkpg_resize_partition;
#else
                resize_partition = NULL;
#endif
                get_partition_start_and_length = _kernel_get_partition_start_and_length;
        }
        // ...
}

Busybox partprobe

$ busybox partprobe --help
BusyBox v1.36.1-4 multi-call binary.

Usage: partprobe DEVICE...

Ask kernel to rescan partition table

partprobe.c

c
int partprobe_main(int argc, char **argv) MAIN_EXTERNALLY_VISIBLE;
int partprobe_main(int argc UNUSED_PARAM, char **argv)
{
	getopt32(argv, "");
	argv += optind;

	/* "partprobe" with no arguments just does nothing */

	while (*argv) {
		int fd = xopen(*argv, O_RDONLY);
		/*
		 * Newer versions of parted scan partition tables themselves and
		 * use BLKPG ioctl (BLKPG_DEL_PARTITION / BLKPG_ADD_PARTITION)
		 * since this way kernel does not need to know
		 * partition table formats.
		 * We use good old BLKRRPART:
		 */
		ioctl_or_perror_and_die(fd, BLKRRPART, NULL, "%s", *argv);
		close(fd);
		argv++;
	}

	return EXIT_SUCCESS;
}

IOCTL ioctl.c

让我们查看 linux 7.0.12 源代码[3]

BLKRRPART 在内核中由 blkdev_common_ioctl 处理,它调用 disk_scan_partitions

block/ioctl.c:L714

c
/*
 * Common commands that are handled the same way on native and compat
 * user space. Note the separate arg/argp parameters that are needed
 * to deal with the compat_ptr() conversion.
 */
static int blkdev_common_ioctl(struct block_device *bdev, blk_mode_t mode,
			       unsigned int cmd, unsigned long arg,
			       void __user *argp)
{
	unsigned int max_sectors;

	switch (cmd) {
	// ...
		case BLKRRPART:
		if (!capable(CAP_SYS_ADMIN))
			return -EACCES;
		if (bdev_is_partition(bdev))
			return -EINVAL;
		return disk_scan_partitions(bdev->bd_disk,
				mode | BLK_OPEN_STRICT_SCAN);
	//...

gendisk handling genhd.c

disk_scan_partitions 首先检查 disk_has_partscan

block/genhd.c:L363

c
int disk_scan_partitions(struct gendisk *disk, blk_mode_t mode)
{
	struct file *file;
	int ret = 0;

	if (!disk_has_partscan(disk))
		return -EINVAL;

	// ...

Block device blkdev.h

include/linux/blkdev.h:L254

c
/**
 * disk_has_partscan - return %true if partition scanning is enabled on a disk
 * @disk: disk to check
 *
 * Returns %true if partitions scanning is enabled for @disk, or %false if
 * partition scanning is disabled either permanently or temporarily.
 */
static inline bool disk_has_partscan(struct gendisk *disk)
{
	return !(disk->flags & (GENHD_FL_NO_PART | GENHD_FL_HIDDEN)) &&
		!test_bit(GD_SUPPRESS_PART_SCAN, &disk->state);
}

Block device blkdev.h

GENHD_FL_NO_PART 是一种显式将设备退出分区支持的标志。

include/linux/blkdev.h:L84

c
/**
 * DOC: genhd capability flags
 *
 * ``GENHD_FL_REMOVABLE``: indicates that the block device gives access to
 * removable media.  When set, the device remains present even when media is not
 * inserted.  Shall not be set for devices which are removed entirely when the
 * media is removed.
 *
 * ``GENHD_FL_HIDDEN``: the block device is hidden; it doesn't produce events,
 * doesn't appear in sysfs, and can't be opened from userspace or using
 * blkdev_get*. Used for the underlying components of multipath devices.
 *
 * ``GENHD_FL_NO_PART``: partition support is disabled.  The kernel will not
 * scan for partitions from add_disk, and users can't add partitions manually.
 *
 */
enum {
	GENHD_FL_REMOVABLE			= 1 << 0,
	GENHD_FL_HIDDEN				= 1 << 1,
	GENHD_FL_NO_PART			= 1 << 2,
};

Device-Mapper dm.c

dm 驱动在分配设备时直接设置了 GENHD_FL_NO_PART

drivers/md/dm.c:L2283

c
/*
 * Allocate and initialise a blank device with a given minor.
 */
static struct mapped_device *alloc_dev(int minor)
{
	int r, numa_node_id = dm_get_numa_node();
	struct dax_device *dax_dev;
	struct mapped_device *md;
	void *old_md;
	// ...

	md->disk->major = _major;
	md->disk->first_minor = minor;
	md->disk->minors = 1;
	md->disk->flags |= GENHD_FL_NO_PART; // <---- 
	md->disk->fops = &dm_blk_dops;
	md->disk->private_data = md;
	sprintf(md->disk->disk_name, "dm-%d", minor);

	// ...

不止 dm,zram、loop、MTD 等驱动也都因为其架构设计、功能理念等原因设置了 GENHD_FL_NO_PART[4]

Ventoy 工作原理

ventoy 代码树 IMG/cpio/ventoy/hook/ 下每一种 distro 都可以看到调用 ventoy_udev_disk_common_hook创建 /dev/mapper/ventoy(映射到安装介质文件)。

Ventoy 官网简略说明了它的原理:

Ventoy必须做很多hook的工作,帮助内核找到、挂载安装源。其实就是告诉内核,当前是Ventoy从一个虚拟的设备引导起来的,你的安装源并不在常规的介质中,而是在XX硬盘的XXX.iso文件, 你把它挂载起来当做安装源就可以啦。

结论

简而言之:DM 设备在内核中设置了 GENHD_FL_NO_PART,内核因此拒绝为其扫描分区表,自然也就不会生成 PARTUUIDkpartxparted 都在尝试绕过 dm 的分区限制去兼容分区操作,使得这个限制并不显而易见——直到你像我一样追到源码里。


  1. https://github.com/archlinux/archinstall/pull/3677 ↩︎

  2. https://www.ventoy.net/cn/plugin_grubmenu.html ↩︎

  3. source tarball https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.0.12.tar.xz ↩︎

  4. 可在内核源码树中运行 grep -rne '|=\? GENHD_FL_NO_PART' . 自行验证 ↩︎