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 Header
、Program Header Table
、Segument(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を参考にされたい
Attribute | Meaning |
---|---|
e_entry | このELFプロセスを開始する際のエントリポイントとなる仮想アドレス |
e_phoff | Program Header Table が存在する場所のファイルオフセット値 |
e_shoff | Section Header Table が存在する場所のファイルオフセット値 |
e_phentsize | Program Header Table にある1エントリのサイズ |
e_phnum | Program Header Table 中のエントリの個数 |
e_shentsize | Section Header Table にある1エントリのサイズ |
e_shnum | Section Header Table 中のエントリの個数 |
上記で抜粋した内容から、Program Header
やSection Header
の各エントリの情報を取り出すことが可能であると分かるであろう。
ここで、Program Header
の内容を一部抜粋する。
Attribute | Meaning |
---|---|
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.bin
はProgram Header
の数が5個で、内4つのp_type
の値がPT_LOAD
、最後の一つだけPT_NOTE
になっているという大変簡単な構造になっている。
ここで、PT_LOAD
、PT_NOTE
についてのみ、Man page of ELFからその詳細内容を部分的に抜粋する。一部情報を削っているため、必要に応じて参考資料を確認されたい。
p_type | Meaning |
---|---|
PT_LOAD | この要素はp_filesz とp_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-loader
のElf
という構造体を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関数を見てみると以下のような処理になっていることがわかる。処理内容がすこし長いためコードの転載は控える。
- ELFファイルの先頭から、ELFヘッダー分のデータを抜き出す
loader_result
という変数名のKernelLoaderResult
構造体のインスタンスを作成し、kernel_load
メンバにELFヘッダのe_entry
の値を格納しておく。この値はシステムが最初に制御を渡すアドレス、つまりプロセスを開始する仮想アドレスに該当する。- ELFファイルを先頭からプログラムヘッダテーブルが存在するアドレスまで(
e_phoff
分)シークし、プログラムヘッダテーブル数分(e_phnum
分)ループしながら、ELFファイルに含まれているプログラムヘッダを全て抜き出す。 - 上記のプログラムヘッダをループしつつ以下の内容を行う
- ELFファイルの先頭から今確認しているプログラムヘッダに対応するセグメントまで(
p_offset
分)シーク - Guestのメモリに対して、
mem_offset
から算出したmemory regionのアドレス位置を先頭に、kernel_image
(p_offset
分シーク済みなので、プログラムヘッダに対応するセグメントのデータの先頭)から、セグメントのサイズ分(p_filesz
分)だけを書き込む kernel_end
(GuestMemory上での読み込んだセグメントの末尾のアドレス)の値を更新し、loader_result.kernel_end
(2回目以降のループでは前回の値が記録されている)と比較して大きい方の値をloader_result.kernel_end
に格納しておく
- ELFファイルの先頭から今確認しているプログラムヘッダに対応するセグメントまで(
- 全てのプログラムヘッダをループ後、返り値として最終的な
loader_result
を返却する。
これはまさに上記でみたELFフォーマットを解釈し読み込むコードになっていることがわかる。
また当該関数呼び出しの結果返却されるKernelLoaderResult
の値には、最終的なGuestMemory上でのカーネルの開始位置、終了位置の情報が含まれており、特にこの開始位置の情報はSetup registers of vCPUで利用する値になるため重要である。