ELF binary format and vmlinux structure

本稿執筆時、ToyVMMでVMを起動する際に利用するカーネルはELF形式のvmlinux.binを前提としている。
そのため、VMMの内部ではELF形式を解釈し、適切にカーネルをVM用に用意したメモリ領域にロードする必要がある。 この処理は rust-vmm/linux-loader crateで実装されており、ToyVMMではこのcrateを利用するため実装としては隠蔽されてしまうが、このcrateの中でどのように処理されているかを知ることは重要だと判断したため、本章を設けELFバイナリのロードに関する解説を記載することとした。

ELF Binary Format

ELFのファイルフォーマットは以下のようになっている

上記の通り、ELFファイルフォーマットは基本的にELF HeaderProgram Header TableSegument(Sections)Section Header Tableからなる。
ELFファイルはシステムローダが利用する場合はProgram Header Tableに記述されたSegmentの集合として取り扱われ、コンパイラ・アセンブラ・リンカはSection Header Tableに記述されたSectionの集合として扱われる。

ELF HeaderはこのELFファイルの全体的な情報を保持している。
Program Header Tableの各エントリであるProgram Headerは、それぞれが対応するSegumentについてのHeader情報を保持している。つまり、Program Headerの数だけ、Segmentが存在していることになる。
また、このSegumentはさらに複数のSecitonという単位に分割でき、このSection単位でヘッダ情報を保持しているのがSection Header Tableである。

ELF Headerは常にファイルオフセットの先頭から始まっており、ELFデータを読み込むために必要となる情報を保持している。 以下にELF Headerの内容を一部抜粋する。全体の構成を知りたい場合はMan page of ELFを参考にされたい

AttributeMeaning
e_entryこのELFプロセスを開始する際のエントリポイントとなる仮想アドレス
e_phoffProgram Header Tableが存在する場所のファイルオフセット値
e_shoffSection Header Tableが存在する場所のファイルオフセット値
e_phentsizeProgram Header Tableにある1エントリのサイズ
e_phnumProgram Header Table中のエントリの個数
e_shentsizeSection Header Tableにある1エントリのサイズ
e_shnumSection Header Table中のエントリの個数

上記で抜粋した内容から、Program HeaderSection Headerの各エントリの情報を取り出すことが可能であると分かるであろう。

ここで、Program Headerの内容を一部抜粋する。

AttributeMeaning
p_typeこのProgram Headerが指すSegmentの種類を表現しており、解釈の方法についてのヒントを与える
p_offsetこのProgram Headerが指すSegmentのファイルオフセット値
p_paddr物理アドレスが意味を持つシステムでは、この値はProgram Headerが指すSegmentの物理アドレスを指す
p_fileszこのProgram Headerが指すSegmentのファイルイメージのバイト数
p_memszこのProgram Headerが指すSegmentのメモリイメージのバイト数
p_flagsこのProgram Headerが指すSegmentの情報を示すフラグで、実行可能、書き込み可能、読み取り可能を表現している

上述の通り、Program Headerの中身を解釈することで、当該セグメントの位置やサイズ、どの様に解釈すべきかの情報を手に入れることができる。 今回の内容はこのProgram Headerの構造まで把握できていれば十分であるため、Section Headerやそのほかの詳細については省略する。
興味がある方は、Man page of ELF等を参考に確認されたい。

さて、後述するが今回取り扱うvmlinux.binProgram Headerの数が5個で、内4つのp_typeの値がPT_LOAD、最後の一つだけPT_NOTEになっているという大変簡単な構造になっている。 ここで、PT_LOADPT_NOTEについてのみ、Man page of ELFからその詳細内容を部分的に抜粋する。一部情報を削っているため、必要に応じて参考資料を確認されたい。

p_typeMeaning
PT_LOADこの要素はp_fileszp_memszで記述される読み込み可能なSegmentである。
PT_NOTEこの要素はロケーションとサイズのための補助情報が書き込まれている

PT_LOADでは、ファイルのバイト列はメモリセグメントの先頭に対応づけされているため、p_offsetを利用して得られる、セグメントのメモリアドレスからサイズ分(基本的にはp_memszを利用する)をCOPYすることでセグメントの内容を読み込むことができる。

以上で必要最低限なELFの知識を身につけることができたので、次は実際にvmlinux.binをダンプしてみて中身を確認してみる。

vmlinxの解析

それではここでvmlinuxの内容を少し解析してみよう。
この解析内容の一部は今後重要な要素になってくるため是非把握してもらいたい。
readelfコマンドはELFフォーマットのファイルを理解しやすい形でダンプしてくれる非常に心強いツールである。 ここではvmlinuxのELF Header(-h)、Program Header(-l)をそれぞれ表示してみる

$ readelf -h -l vmlinux.bin
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x1000000
  Start of program headers:          64 (bytes into file)
  Start of section headers:          21439000 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         5
  Size of section headers:           64 (bytes)
  Number of section headers:         36
  Section header string table index: 35

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000200000 0xffffffff81000000 0x0000000001000000
                 0x0000000000b72000 0x0000000000b72000  R E    0x200000
  LOAD           0x0000000000e00000 0xffffffff81c00000 0x0000000001c00000
                 0x00000000000b0000 0x00000000000b0000  RW     0x200000
  LOAD           0x0000000001000000 0x0000000000000000 0x0000000001cb0000
                 0x000000000001f658 0x000000000001f658  RW     0x200000
  LOAD           0x00000000010d0000 0xffffffff81cd0000 0x0000000001cd0000
                 0x0000000000133000 0x0000000000413000  RWE    0x200000
  NOTE           0x0000000000a031d4 0xffffffff818031d4 0x00000000018031d4
                 0x0000000000000024 0x0000000000000024         0x4

 Section to Segment mapping:
  Segment Sections...
   00     .text .notes __ex_table .rodata .pci_fixup __ksymtab __ksymtab_gpl __kcrctab __kcrctab_gpl __ksymtab_strings __param __modver
   01     .data __bug_table .vvar
   02     .data..percpu
   03     .init.text .altinstr_aux .init.data .x86_cpu_dev.init .parainstructions .altinstructions .altinstr_replacement .iommu_table .apicdrivers .exit.text .smp_locks .data_nosave .bss .brk
   04     .notes

ELF Headerをみてみると、Entry point address (e_entry)の値としてProgram Headerの最初のセグメントの物理アドレスの値である(0x0100_0000)が格納されていることが分かる。この値はrust-vmm/linux-loaderの実装としてkernelをロードした際の返り値として返却される値であり、かつvCPUのeip(命令アドレスレジスタ)に設定する値でもあるため重要である。
また、ELF HeaderのNumber of program headers(e_phnum)の値である5と同じ数のProgram Headerが確認でき、Program Headerを出力をみると先頭4つはTypeがLOAD、最後はNOTEとなっていることが確認できる。
また、1つ目、および4つ目のLOADセグメントはFlagを確認するとE(xecutable)がマークされており、この辺りに実行可能コードが存在していることも分かる。 特に1つめのエントリは実際にカーネルの実行バイナリのエントリポイントに該当する内容が配置されていることが期待される。
今回はこれ以上の深追いは控えておくが、興味がある人はELFのSpecificationをもとにさらに解析をしてみるのも面白いかもしれない。

ToyVMMでの実装

ToyVMMでは、src/builder.rsの中のload_kernel関数の中でvmlinuxの読み込みを実施している。
この関数には、カーネルファイルへのパス情報などが含まれているboot_configとVM向けに確保したメモリ(guest_memory)を渡している。 load_kernelが実施していることは単純で、boot_configからカーネルファイルへのパスを取得し、linux-loaderElfという構造体をLoaderという名前で取り扱い、この構造体に実装されているELF形式のLinuxのローディング処理を適切な引数を伴って実行しているだけである。

use linux_loader::elf::Elf as Loader;

let entry_addr = Loader::load::<File, memory::GuestMemoryMmap>(
    guest_memory,
    None,
    &mut kernel_file,
    Some(GuestAddress(arch::x86_64::get_kernel_start())),
).map_err(StartVmError::KernelLoader)?;

さて、ここからlinux-loaderの実装について深掘りしてみよう。 linux-loaderでは、KernelLoader traitが定義されており、その定義は以下のようになっている

/// Trait that specifies kernel image loading support.
pub trait KernelLoader {
    /// How to load a specific kernel image format into the guest memory.
    ///
    /// # Arguments
    ///
    /// * `guest_mem`: [`GuestMemory`] to load the kernel in.
    /// * `kernel_offset`: Usage varies between implementations.
    /// * `kernel_image`: Kernel image to be loaded.
    /// * `highmem_start_address`: Address where high memory starts.
    ///
    /// [`GuestMemory`]: https://docs.rs/vm-memory/latest/vm_memory/guest_memory/trait.GuestMemory    .html
    fn load<F, M: GuestMemory>(
        guest_mem: &M,
        kernel_offset: Option<GuestAddress>,
        kernel_image: &mut F,
        highmem_start_address: Option<GuestAddress>,
    ) -> Result<KernelLoaderResult>
    where
        F: Read + Seek;
}

コメントから推測できるように、このtraitが実装しているべきload関数は、特定のカーネルイメージフォーマットをGuestMemoryに読み込むような実装になっていることを要求している。 linux-loaderではx86_64向けの実装として、ELF形式の他にbzImage形式のカーネルの読み込みについても実装が存在しているようであるが、ひとまず今回はELF向けの実装を利用する。

さて、先のToyVMM側のコードで利用していたElf構造体(Loaderと名前を変えてimportした構造体)はこのKernelLoader traitを実装しており、そのload関数がELFファイルをロードする実装になっていることが期待できる。
そのため、このload関数を見てみると以下のような処理になっていることがわかる。処理内容がすこし長いためコードの転載は控える。

  1. ELFファイルの先頭から、ELFヘッダー分のデータを抜き出す
  2. loader_resultという変数名のKernelLoaderResult構造体のインスタンスを作成し、kernel_loadメンバにELFヘッダのe_entryの値を格納しておく。この値はシステムが最初に制御を渡すアドレス、つまりプロセスを開始する仮想アドレスに該当する。
  3. ELFファイルを先頭からプログラムヘッダテーブルが存在するアドレスまで(e_phoff分)シークし、プログラムヘッダテーブル数分(e_phnum分)ループしながら、ELFファイルに含まれているプログラムヘッダを全て抜き出す。
  4. 上記のプログラムヘッダをループしつつ以下の内容を行う
    • ELFファイルの先頭から今確認しているプログラムヘッダに対応するセグメントまで(p_offset分)シーク
    • Guestのメモリに対して、mem_offsetから算出したmemory regionのアドレス位置を先頭に、kernel_imagep_offset分シーク済みなので、プログラムヘッダに対応するセグメントのデータの先頭)から、セグメントのサイズ分(p_filesz分)だけを書き込む
    • kernel_end(GuestMemory上での読み込んだセグメントの末尾のアドレス)の値を更新し、loader_result.kernel_end(2回目以降のループでは前回の値が記録されている)と比較して大きい方の値をloader_result.kernel_endに格納しておく
  5. 全てのプログラムヘッダをループ後、返り値として最終的なloader_resultを返却する。

これはまさに上記でみたELFフォーマットを解釈し読み込むコードになっていることがわかる。
また当該関数呼び出しの結果返却されるKernelLoaderResultの値には、最終的なGuestMemory上でのカーネルの開始位置、終了位置の情報が含まれており、特にこの開始位置の情報はSetup registers of vCPUで利用する値になるため重要である。

References