Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ
- その1 libkrun を試す
- その2 libkrun の構造 ← この記事
- その3 Mewz 追加実装(Linux zeropage, kernel cmd params)
- その4 Mewz 追加実装(Virtio MMIO)
- その5 Mewz on libkrun してみた
- その6 Mewz 追加実装(virtio-console)
目次
対象
この記事は以下のソースコードに対応している
- libkrun: GitHub - containers/libkrun at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9
- libkrunfw: GitHub - containers/libkrunfw at 563389b460691bfe235cd2b4cba56068e97d5546
全体の構造
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.c
を libkrunfw.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 で扱うものと見られる。
#[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_devices
や attach_console_devices
といった関数経由で行われる。その中で attach_mmio_device
が呼び出され、mmio_device_manager
への登録や cmd params へのデバイスに関する情報が登録される。
/// 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 経由で受け渡す。
/// 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
にマップされている。
#[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) }
/// Kernel command line start address. pub const CMDLINE_START: u64 = 0x20000;
Linux zeropage
RAMサイズや cmd params のアドレスおよびそのサイズ等の各種 kernel 初期化に必要なパラメータは "Linux zeropage" 経由で受け渡される。
/// 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 公式ドキュメントにまとめられている情報は古いため、 ソースコードを直に参照すると良い。
実際には、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
にマップされる。
/// 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 の対応方針
これまでの調査から、以下の流れで実装すると動作することが期待される。
- libkrunfw を Mewz kernel に差し替え
- Linux zeropage への対応 (ここで最小限のブートが可能になる)
- kernel command-line params への対応
- Virtio MMIO への対応(virtio-net)
ここまで実装すれば、Mewz kernel を起動して通信を行うことが可能になる。 さらに、virtio-vsock と TSI への対応も行う。詳細については実装時にまとめることとする。