Skip to content

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

为了在 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' . 自行验证 ↩︎