外部記憶装置

外付け記憶装置

Mewz on libkrun - その2 libkrun の構造

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ

目次

対象

この記事は以下のソースコードに対応している

全体の構造

chroot_vm でシェルが起動する場合、各要素間の関係は下図のとおりになっている。

libkrun は共有ライブラリとして提供され、chroot_vm が libkrun の export された関数を呼び出すことで各種設定を行い、VM を起動している。 libkrunfw は Linux kernel の実体を提供する。これについても同様に共有ライブラリとして提供され、libkrun が呼び出す形で利用されている。

各要素の仕組み

libkrunfw

libkrunfw は専用の Linux kernel を共有ライブラリとして提供する。 また、kernel 実体を提供するだけでなく、メモリ上へのロードに必要なロードアドレスやエントリアドレスについても関数経由で設定することができる。

共有ライブラリに対応するコードは make 時に kernel.c として自動で生成される。 中身としては、Linux kernel のバイナリが展開された配列 KERNEL_BUNDLE と、末尾に libkrun に対して kernel を提供する関数 krunfw_get_kernl, krunfw_get_version が定義されている。

#include <stddef.h>
__attribute__ ((aligned (65536))) char KERNEL_BUNDLE[] = 
"\xfc\xf\x1\x15\x60\xe2\x13\x2\xb8\x10\x0\x0\x0\x8e\xd8\x8e"
"\xc0\x8e\xd0\xbf\x20\xd2\x13\x2\x89\xde\x8b\xd\xd0\x35\x1a\x2"
...
char * krunfw_get_kernel(size_t *load_addr, size_t *entry_addr, size_t *size)
{
    *load_addr = 16777216;
    *entry_addr = 16777344;
    *size = sizeof(KERNEL_BUNDLE) - 1;
    return &KERNEL_BUNDLE[0];
}

int krunfw_get_version()
{
    return ABI_VERSION;
}

KERNEL_BUNDLE は巨大な配列であり、122 万行、70MB超のファイルサイズとなっている。

$ wc -l kernel.c 
1220624 kernel.c
$ ls -l kernel.c 
-rw-r--r-- 1 naoki naoki 76255652 Dec 28 17:10 kernel.c

kernel.c 自体はビルド時のログからも分かるように、bin2cbundle.py により生成される。

Generating kernel.c from linux-6.6.63/vmlinux...
python3 bin2cbundle.py -t vmlinux linux-6.6.63/vmlinux kernel.c
cc -fPIC -DABI_VERSION=4 -shared -Wl,-soname,libkrunfw.so.4 -o libkrunfw.so.4.6.0 kernel.c  
strip libkrunfw.so.4.6.0

x86_64 環境においては ELF 形式な vmlinux から生成される。

libkrunfw/bin2cbundle.py at 563389b460691bfe235cd2b4cba56068e97d5546 · containers/libkrunfw · GitHub

def write_elf_cbundle(ifile, ofile) -> int:
    elffile = ELFFile(ifile)
    entry_addr = elffile['e_entry']

    load_segments = [ ]
    for segment in elffile.iter_segments():
        if segment['p_type'] == 'PT_LOAD':
            load_segments.append(segment)
        
    col = 0
    total_size = 0
    prev_paddr = None

    for segment in load_segments:
        if prev_paddr == None:
            load_addr = segment['p_vaddr'] & 0xfffffff
        else:
            padding = segment['p_paddr'] - prev_paddr - prev_filesz
            write_padding(ofile, padding, col)
            total_size = total_size + padding

        assert((segment['p_paddr'] - load_addr) == total_size)
        
        for byte in segment.data():
            ofile.write('\\x{:x}'.format(byte))
                
            if col == 15:
                ofile.write('"\n"')
                col = 0
            else:
                col = col + 1

        prev_paddr = segment['p_paddr']
        prev_filesz = segment['p_filesz']
        total_size = total_size + prev_filesz

    rounded_size = int((total_size + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
    padding = rounded_size - total_size    
    write_padding(ofile, padding, col)

    return load_addr, entry_addr

処理としてはシンプルである。 ELF ヘッダをパースして PT_LOAD セグメントに対応するバイナリを切り出し、一つの配列として扱うために必要に応じてパディングを行っている。 そしてその実体は KERNEL_BUNDLE 配列として出力する。

実体を処理した後、フッターとして krunfw_get_kernel, krunfw_get_version 関数を埋め込む。 このとき、vmlinux から得られたエントリアドレスやロードアドレスをそのまま埋め込む。

それらの処理から生成された kernel.clibkrunfw.so としてビルドすることでビルドの処理が完了する。

libkrunfw/bin2cbundle.py at 563389b460691bfe235cd2b4cba56068e97d5546 · containers/libkrunfw · GitHub

def write_footer_kernel(ofile, load_addr, entry_addr):
    footer = """
char * krunfw_get_kernel(size_t *load_addr, size_t *entry_addr, size_t *size)
{{
    *load_addr = {};
    *entry_addr = {};
    *size = sizeof(KERNEL_BUNDLE) - 1;
    return &KERNEL_BUNDLE[0];
}}

int krunfw_get_version()
{{
    return ABI_VERSION;
}}
"""
    ofile.write('";\n')
    ofile.write(footer.format(load_addr, entry_addr))

libkrun

libkrun 自体は一種の VMM であり、そのすべてを記述することは大変である。 そのため、今回は Mewz を動かすために必要な部分のみをまとめる。

Linux kernel の起動に必要な設定のおおよそは vmm::builder::build_microvm で行われる。 その中で、各種 virtio デバイスの設定や command-line parameters の生成及びマッピングを行う。

VirtIO MMIOバイス

まず、デバイスの管理について追いかける。 x86_64 環境においては必要に応じてシリアルデバイスのみ PortIO で行われ、それ以外は MMIO で扱う。aarch64 においてはすべて MMIO で扱うものと見られる。

libkrun/src/vmm/src/builder.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

    #[cfg(target_arch = "x86_64")]
    // Safe to unwrap 'serial_device' as it's always 'Some' on x86_64.
    // x86_64 uses the i8042 reset event as the Vmm exit event.
    let mut pio_device_manager = PortIODeviceManager::new(
        serial_device,
        exit_evt
            .try_clone()
            .map_err(Error::EventFd)
            .map_err(StartMicrovmError::Internal)?,
    )
    .map_err(Error::CreateLegacyDevice)
    .map_err(StartMicrovmError::Internal)?;

    // Instantiate the MMIO device manager.
    // 'mmio_base' address has to be an address which is protected by the kernel
    // and is architectural specific.
    #[allow(unused_mut)]
    let mut mmio_device_manager = MMIODeviceManager::new(
        &mut (arch::MMIO_MEM_START.clone()),
        (arch::IRQ_BASE, arch::IRQ_MAX),
    );

virtio のデバイス追加は attach_fs_devicesattach_console_devices といった関数経由で行われる。その中で attach_mmio_device が呼び出され、mmio_device_manager への登録や cmd params へのデバイスに関する情報が登録される。

libkrun/src/vmm/src/builder.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

/// Attaches an MmioTransport device to the device manager.
fn attach_mmio_device(
    vmm: &mut Vmm,
    id: String,
    device: MmioTransport,
) -> std::result::Result<(), device_manager::mmio::Error> {
    let type_id = device
        .device()
        .lock()
        .expect("Poisoned device lock")
        .device_type();
    let _cmdline = &mut vmm.kernel_cmdline;

    #[cfg(target_os = "linux")]
    let (_mmio_base, _irq) =
        vmm.mmio_device_manager
            .register_mmio_device(vmm.vm.fd(), device, type_id, id)?;
    #[cfg(target_os = "macos")]
    let (_mmio_base, _irq) = vmm
        .mmio_device_manager
        .register_mmio_device(device, type_id, id)?;

    #[cfg(target_arch = "x86_64")]
    vmm.mmio_device_manager
        .add_device_to_cmdline(_cmdline, _mmio_base, _irq)?;

    Ok(())
}

kernel command-line parameters

Virtio MMIOバイスのアドレスや IRQ 番号は固定されているわけではないため、x86_64 では cmd params 経由で受け渡す。

libkrun/src/vmm/src/device_manager/kvm/mmio.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

    /// Append a registered MMIO device to the kernel cmdline.
    #[cfg(target_arch = "x86_64")]
    pub fn add_device_to_cmdline(
        &mut self,
        cmdline: &mut kernel_cmdline::Cmdline,
        mmio_base: u64,
        irq: u32,
    ) -> Result<()> {
        // as per doc, [virtio_mmio.]device=<size>@<baseaddr>:<irq> needs to be appended
        // to kernel commandline for virtio mmio devices to get recognized
        // the size parameter has to be transformed to KiB, so dividing hexadecimal value in
        // bytes to 1024; further, the '{}' formatting rust construct will automatically
        // transform it to decimal
        cmdline
            .insert(
                "virtio_mmio.device",
                &format!("{}K@0x{:08x}:{}", MMIO_LEN / 1024, mmio_base, irq),
            )
            .map_err(Error::Cmdline)
    }

そのため、VirtIO MMIOバイスを利用するためには cmd params を適切にパースする必要がある。 cmd params 自体はゲストメモリ上の 0x20000 にマップされている。

libkrun/src/vmm/src/builder.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

#[cfg(all(target_arch = "x86_64", not(feature = "tee")))]
fn load_cmdline(vmm: &Vmm) -> std::result::Result<(), StartMicrovmError> {
    kernel::loader::load_cmdline(
        vmm.guest_memory(),
        GuestAddress(arch::x86_64::layout::CMDLINE_START),
        &vmm.kernel_cmdline
            .as_cstring()
            .map_err(StartMicrovmError::LoadCommandline)?,
    )
    .map_err(StartMicrovmError::LoadCommandline)
}

libkrun/src/arch/src/x86_64/layout.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

/// Kernel command line start address.
pub const CMDLINE_START: u64 = 0x20000;

Linux zeropage

RAMサイズや cmd params のアドレスおよびそのサイズ等の各種 kernel 初期化に必要なパラメータは "Linux zeropage" 経由で受け渡される。

libkrun/src/arch/src/x86_64/mod.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

/// Configures the system and should be called once per vm before starting vcpu threads.
///
/// # Arguments
///
/// * `guest_mem` - The memory to be used by the guest.
/// * `cmdline_addr` - Address in `guest_mem` where the kernel command line was loaded.
/// * `cmdline_size` - Size of the kernel command line in bytes including the null terminator.
/// * `initrd` - Information about where the ramdisk image was loaded in the `guest_mem`.
/// * `num_cpus` - Number of virtual CPUs the guest will have.
#[allow(unused_variables)]
pub fn configure_system(
    guest_mem: &GuestMemoryMmap,
    arch_memory_info: &ArchMemoryInfo,
    cmdline_addr: GuestAddress,
    cmdline_size: usize,
    initrd: &Option<InitrdConfig>,
    num_cpus: u8,
) -> super::Result<()> {
    const KERNEL_BOOT_FLAG_MAGIC: u16 = 0xaa55;
    const KERNEL_HDR_MAGIC: u32 = 0x5372_6448;
    const KERNEL_LOADER_OTHER: u8 = 0xff;
    const KERNEL_MIN_ALIGNMENT_BYTES: u32 = 0x0100_0000; // Must be non-zero.
    let first_addr_past_32bits = GuestAddress(FIRST_ADDR_PAST_32BITS);
    let end_32bit_gap_start = GuestAddress(MMIO_MEM_START);

    let himem_start = GuestAddress(layout::HIMEM_START);

    // Note that this puts the mptable at the last 1k of Linux's 640k base RAM
    #[cfg(not(feature = "tee"))]
    mptable::setup_mptable(guest_mem, num_cpus).map_err(Error::MpTableSetup)?;

    let mut params: BootParamsWrapper = BootParamsWrapper(boot_params::default());

    params.0.hdr.type_of_loader = KERNEL_LOADER_OTHER;
    params.0.hdr.boot_flag = KERNEL_BOOT_FLAG_MAGIC;
    params.0.hdr.header = KERNEL_HDR_MAGIC;
    params.0.hdr.cmd_line_ptr = cmdline_addr.raw_value() as u32;
    params.0.hdr.cmdline_size = cmdline_size as u32;

    params.0.hdr.kernel_alignment = KERNEL_MIN_ALIGNMENT_BYTES;
    if let Some(initrd_config) = initrd {
        params.0.hdr.ramdisk_image = initrd_config.address.raw_value() as u32;
        params.0.hdr.ramdisk_size = initrd_config.size as u32;
    }

    #[cfg(feature = "tee")]
    {
        params.0.hdr.syssize = num_cpus as u32;
    }

    add_e820_entry(&mut params.0, 0, EBDA_START, E820_RAM)?;

    let last_addr = GuestAddress(arch_memory_info.ram_last_addr);
    if last_addr < end_32bit_gap_start {
        add_e820_entry(
            &mut params.0,
            himem_start.raw_value(),
            // it's safe to use unchecked_offset_from because
            // mem_end > himem_start
            last_addr.unchecked_offset_from(himem_start) + 1,
            E820_RAM,
        )?;
    } else {
        add_e820_entry(
            &mut params.0,
            himem_start.raw_value(),
            // it's safe to use unchecked_offset_from because
            // end_32bit_gap_start > himem_start
            end_32bit_gap_start.unchecked_offset_from(himem_start),
            E820_RAM,
        )?;

        if last_addr > first_addr_past_32bits {
            add_e820_entry(
                &mut params.0,
                first_addr_past_32bits.raw_value(),
                // it's safe to use unchecked_offset_from because
                // mem_end > first_addr_past_32bits
                last_addr.unchecked_offset_from(first_addr_past_32bits) + 1,
                E820_RAM,
            )?;
        }
    }

    let zero_page_addr = GuestAddress(layout::ZERO_PAGE_START);
    guest_mem
        .write_obj(params, zero_page_addr)
        .map_err(|_| Error::ZeroPageSetup)?;

    Ok(())
}

Linux kernel 公式ドキュメントにまとめられている情報は古いため、 ソースコードを直に参照すると良い。

github.com

実際には、struct boot_params に含まれる struct setup_header にある cmd_line_ptr などを利用する。

linux/arch/x86/include/uapi/asm/bootparam.h at v6.6 · torvalds/linux · GitHub

struct setup_header {
...
    __u32   cmd_line_ptr;
...
    __u32   cmdline_size;
...
} __attribute__((packed));

zeropage はゲストメモリ上の 0x7000 にマップされる。

libkrun/src/arch/src/x86_64/layout.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

/// The 'zero page', a.k.a linux kernel bootparams.
pub const ZERO_PAGE_START: u64 = 0x7000;

Linux kernel ブートの実際

実際に chroot_vm 経由でブートした際、zeropage (E820 エントリ) から利用可能なメモリ領域を取得し、cmd params で渡された情報に従って VirtIO MMIOバイスの登録が行われていることが分かる。

$ sudo LD_LIBRARY_PATH=../lib ./chroot_vm rootfs_fedora /bin/bash
bash-5.2# dmesg
[    0.000000] Linux version 6.6.63 (root@libkrunfw) (gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #1 SMP PREEMPT_DYNAMIC Mon Dec  2 11:39:28 CET 2024
[    0.000000] Command line: reboot=k panic=-1 panic_print=0 nomodule console=hvc0 rootfstype=virtiofs rw quiet no-kvmapf init=/init.krun KRUN_INIT=/bin/bash KRUN_WORKDIR=/ KRUN_RLIMITS="6=4096:8192" "TEST=works" virtio_mmio.device=4K@0xd0000000:5 virtio_mmio.device=4K@0xd0001000:6 virtio_mmio.device=4K@0xd0002000:7 virtio_mmio.device=4K@0xd0003000:8 virtio_mmio.device=4K@0xd0004000:9 tsi_hijack  --
[    0.000000] [Firmware Bug]: TSC doesn't count with P0 frequency!
[    0.000000] BIOS-provided physical RAM map:
[    0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] usable
[    0.000000] BIOS-e820: [mem 0x0000000000100000-0x00000000cfffffff] usable
[    0.000000] BIOS-e820: [mem 0x0000000100000000-0x0000000130000000] usable
...
[    0.785511] software IO TLB: mapped [mem 0x00000000cc000000-0x00000000d0000000] (64MB)
[    0.785573] virtio-mmio: Registering device virtio-mmio.0 at 0xd0000000-0xd0000fff, IRQ 5.
[    0.785637] virtio-mmio: Registering device virtio-mmio.1 at 0xd0001000-0xd0001fff, IRQ 6.
[    0.785677] virtio-mmio: Registering device virtio-mmio.2 at 0xd0002000-0xd0002fff, IRQ 7.
[    0.785693] virtio-mmio: Registering device virtio-mmio.3 at 0xd0003000-0xd0003fff, IRQ 8.
[    0.785763] virtio-mmio: Registering device virtio-mmio.4 at 0xd0004000-0xd0004fff, IRQ 9.
...

Mewz の対応方針

これまでの調査から、以下の流れで実装すると動作することが期待される。

  1. libkrunfw を Mewz kernel に差し替え
  2. Linux zeropage への対応 (ここで最小限のブートが可能になる)
  3. kernel command-line params への対応
  4. Virtio MMIO への対応(virtio-net)

ここまで実装すれば、Mewz kernel を起動して通信を行うことが可能になる。 さらに、virtio-vsock と TSI への対応も行う。詳細については実装時にまとめることとする。