「ゼロからのOS自作入門」3日目にてKernelが起動しない原因をちゃんと理解する

2023/08/07に公開

はじめに

ゼロからのOS自作入門を購入したものの積んでしまっていたがとある機会に再開したところ, Ubuntu22.04でないとAnsible周りの実装が動かなくなっていたので, 再度おさらいを実施.
その最中, 3日目のkernelロード周りが動作しないことが分かった.

この問題に関してはIssueにて起票はされており, 解決策も既に分かっている.
https://github.com/uchan-nos/os-from-zero/issues/134

解決策1: 先に4日目のELFローダを実装する.
解決策2: ldd-7を使用する.

一方で, Issueのコメントを眺めていてもこの問題の原因について私はイマイチ理解が追いついていなかった.
ELFに対する実践的な理解度が不足しているためだと思われる.
そのため, 自身の理解度に落とし込むために色々を解析を進めてみた.

対象読者

  • ldd-14が出力するELFの内容に興味がある方
  • 「ゼロからのOS自作入門」を進めていて, 3日目のkernel起動Issueを解決した際に本Issueの理解をせずに進めた方

非対象読者

  • readelfを見ただけで本Issueの原因が良くわかる方
  • 特にこの辺りの技術領域に興味がない方

前提条件

  • X86 WindowsOS, WSL2 Ubuntu22.04を使用.
  • QEMUでのOS起動を実施.
  • 2023/08/07時点の情報. この時点のlddはldd-14である.
  • 環境としては「day03a時点」と「day03a直後にday04dのELFローダ実装後」の2パターンに対して解析.

kernel.elfの理解

何を解析するにせよ, kernel.elfが理解できていないと始まらないので, 色々と解析を行う.

readelfによる解析

readelfコマンドを使うことでELF formatは解析を行うことができる.
とりあえず全ての解析情報を眺めるため, allオプションで実行.

readelf -a kernel.elf

以下の情報が得られる. 注目ポイントのみ抜粋.

ELF Header:
...
  Entry point address:               0x101120
...

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000100040 0x0000000000100040
                 0x00000000000000e0 0x00000000000000e0  R      0x8
  LOAD           0x0000000000000000 0x0000000000100000 0x0000000000100000
                 0x0000000000000120 0x0000000000000120  R      0x1000
  LOAD           0x0000000000000120 0x0000000000101120 0x0000000000101120
                 0x0000000000000013 0x0000000000000013  R E    0x1000
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x0
...
Symbol table '.symtab' contains 3 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.cpp
     2: 0000000000101120    19 FUNC    GLOBAL DEFAULT    1 KernelMain

このreadelfの結果を見て, 初心者レベルで理解できるのは「0x101120がエントリポイントらしい?」というくらいではないだろうか.
実際QEMU上でEntryAddressをPrint文でチェックすると0x101120が取得できる.

objdumpによる逆アセンブル

これからメモリダンプしてKernelMainがどこにあるのか探したかったので, そもそもの命令内容を調べてみた.

objdump -d kernel.elf

KernelMainについては以下の情報である.

0000000000101120 <KernelMain>:
  101120:       55                      push   %rbp
  101121:       48 89 e5                mov    %rsp,%rbp
  101124:       66 2e 0f 1f 84 00 00    cs nopw 0x0(%rax,%rax,1)
  10112b:       00 00 00 
  10112e:       66 90                   xchg   %ax,%ax
  101130:       f4                      hlt    
  101131:       eb fd                   jmp    101130 <KernelMain+0x10>

QEMUのメモリダンプにて, Hexかつ64bitで調べるので,
x86がリトルエンディアンであることを踏まえて, メモリダンプする際には0x1f0f2e66e5894855とダンプされる個所を探すことになる.

QEMUでのメモリダンプ

それぞれのケースでメモリダンプ結果を調べてみる.

day03a時点

0x100120にそれらしきデータが格納されていることがわかる...
つまり0x101120をエントリポイントとしたコードを記述しても, KernelMainは実行されないということになる.

(qemu) xp /10xg 0x100120
0000000000100120: 0x1f0f2e66e5894855 0x9066000000000084
0000000000100130: 0x2525011101fdebf4 0x1710177225030513
0000000000100140: 0x177306121b11251b 0x121b11002e020000
0000000000100150: 0x3a2503197a184006 0x000000193f0b3b0b
0000000000100160: 0x080100050000002b 0x0021000100000000

day03a直後にday04dのELFローダ実装後

このケースではEntrypoint通り, 0x101120に配置されていることがわかる.

(qemu) xp /10xg 0x101120
0000000000101120: 0x1f0f2e66e5894855 0x9066000000000084
0000000000101130: 0x0000000000fdebf4 0x0000000000000000
0000000000101140: 0x0000000000000000 0x0000000000000000
0000000000101150: 0x0000000000000000 0x0000000000000000
0000000000101160: 0x0000000000000000 0x0000000000000000

2つの挙動について

「day03a時点」というのは, 以下の順で処理が行われる.

  1. kernel.elfを0x100000に確保したメモリ上にロード. 0x100000は事前に決めたアドレス.
  2. ELFヘッダからEntrypointの取得.
  3. EntryPointを実行.

「day03a直後にday04dのELFローダ実装後」というのは4日目の実装をちゃんと見ればわかるが以下の処理が行われる.

  1. kernel.elfを一時領域にロード.
  2. kernel.elfのプログラムヘッダに存在するLOADセクションを解析し, 確保すべきアドレス領域とサイズを取得.
  3. 解析情報を元にメモリを確保.
  4. kernel.elfのデータをLOADセクション情報を元にコピー.
  5. 一時領域を開放.

つまり以下の違いがある.

day03a時点 day03a直後にday04dのELFローダ実装後
kernel.elfがそのままメモリに残った状態 kernel.elfのプログラムヘッダ通りにメモリ配置しなおした状態

kernel.elfそのものをダンプしてみる

「kernel.elfがそのままメモリに残った状態」というのがすなわちどういうことか理解するため,
objdumpでkernel.elfをダンプしてみる.

od -t x1 -A x kernel.elf

すると0x120のオフセットにてKernelMainが存在することが把握できる.

000120 55 48 89 e5 66 2e 0f 1f 84 00 00 00 00 00 66 90

つまり, 「kernel.elfの中身とELFで示すEntryPointは不一致である」というのが動作しない原因だったということが分かる.

readelfのおさらい

ここでIssue起票はreadelfだけで原因を理解できていたことから, 再度readelfの結果を見てみる.

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000100040 0x0000000000100040
                 0x00000000000000e0 0x00000000000000e0  R      0x8
  LOAD           0x0000000000000000 0x0000000000100000 0x0000000000100000
                 0x0000000000000120 0x0000000000000120  R      0x1000
  LOAD           0x0000000000000120 0x0000000000101120 0x0000000000101120
                 0x0000000000000013 0x0000000000000013  R E    0x1000

実はプログラムヘッダの読み方が分かっていれば, 以下の情報はわかるようになっている.

  • Kernelmainの実態が0x120に存在している. 2つ目のLOADセクションのOffsetがそういった意味.
  • EntryPoint自体はVirtAddrにあるように0x101120であるため, kernel.elfでは0x100120にあっても, 実行時は0x101120に配置されていることになる.
  • これらはローダが配置するため, kernel.elfを直接メモリにロードした場合とは状況が異なる.

Issue起票者のコメントも上記のことを述べているのだろう.

lld14では、ファイル上の余分なパディングが消えて一つ目のLOAD領域の直後にエントリーポイントのLOAD領域が保存されています。なので、(0x00100000 + 24)に記載されているアドレスに処理を移しても、ファイル上のオフセットが一致していないため誤作動し、何かしらの例外を引き当てているように思えます。

最後に

この辺りはリンカローダを理解していれば分かる内容ではあるが,
また一つ賢くなった…かもしれない?

Discussion