为了在 Linux 上用 ROCm,但我的机器没有多余硬盘空间,就在空间尚充裕的安装了 Ventoy 的移动硬盘上做了个 Raw 虚拟磁盘,装了个 EndeavourOS 进去。我自己写了一套 initramfs hook,让内核启动时从 NTFS 分区上的虚拟磁盘挂载根文件系统。本来这事就算完了,结果看到群友提到 Ventoy 官方的 vtoyboot 工具要求不能使用 PARTUUID,我觉得奇怪,就顺着查了下去,阅读了 linux 内核代码解释了此问题的原因。
Linux To Go 的快速旅程
1. 安装系统(宿主机 → VM)
- 在 ventoy 主分区(NTFS)上创建 Raw 虚拟磁盘
fallocate -l 40G root.img - 添加 Raw 虚拟磁盘 和 EndeavourOS ISO 到虚拟机
- 启动虚拟机
- 格式化虚拟磁盘的块设备为 EXT4,不分区(即直接
mkfs.ext4 /dev/vdb,不在其上创建分区表) - 安装 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 替换为 keymap 和 consolefont。
添加一个 hook 覆盖默认挂载处理程序(mount_handler),在启动时实现以下过程:
- 挂载 ventoy 主分区到临时目录如
/ntfs_root - 从磁盘镜像创建 loop 设备
- 挂载 loop 设备到
/new_root bind --moveventoy 主分区到/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
#!/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
#!/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 虚拟机:
- 挂载 ventoy 主分区
losetup -f /path/to/root.img将虚拟磁盘文件挂载为 loop 块设备- 将 Linux root 分区挂载到
/mnt - 将
/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步开始,效果相同):
- 挂载 ventoy 主分区
losetup -f /path/to/root.img将虚拟磁盘文件挂载为 loop 块设备- 将 Linux root 分区挂载到
/mnt mount --bindventoy 主分区到/mnt/ntfsarch-chroot /mnt进入系统- 创建 post hook:
/etc/initcpio/post/copy-initramfs.sh
#!/bin/bash
# kernel
cp $1 /mnt/ntfs/archboot/
# initramfs
cp $2 /mnt/ntfs/archboot/- 重新生成 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 等)
- 安装 Linux 系统到 vdisk
创建固定大小的 vhd/vdi,然后把支持的 Linux 系统安装到 vhd/vdi 中即可。安装过程和普通安装没有任何差别。
- 在系统下执行 vtoyboot 脚本
安装完成并启动到 Linux 系统中之后,下载 vtoyboot 到 Linux 系统中,解压,然后以root权限执行里面的脚本 sudo bash vtoyboot.sh 脚本执行完之后,使用 poweroff 命令关机。
- 拷贝到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.sh 被 vtoyboot.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/dmdevice 或 blockdev --rereadpt /dev/dmdevice 会输出错误 Invalid argument (EINVAL)。这两个命令都是对 ioctl BLKRRPART 的简单包装。
GNU Parted 的 partprobe 则自己根据分区表计算偏移、使用 libdevmapper 创建 DM 设备。你可以查看 parted 包的依赖,其中包含 device-mapper,并对比 partprobe /dev/dmdevice 运行前后 dmsetup ls 和 ls /dev/dm* 的输出,注意到出现新的 DM 设备。
从源代码验证
GNU Parted partprobe
GNU partprobe 对 DM 设备使用 libdevmapper 创建 DM 设备,对其他块设备调用 BLKPG ioctl。
/*
* 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 tableint 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
/*
* 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
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
/**
* 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 是一种显式将设备退出分区支持的标志。
/**
* 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
/*
* 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,内核因此拒绝为其扫描分区表,自然也就不会生成 PARTUUID。kpartx 和 parted 都在尝试绕过 dm 的分区限制去兼容分区操作,使得这个限制并不显而易见——直到你像我一样追到源码里。
source tarball https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.0.12.tar.xz ↩︎
可在内核源码树中运行
grep -rne '|=\? GENHD_FL_NO_PART' .自行验证 ↩︎