OS自作入門でUSBマウスを実機で動かすまでの試行錯誤

前回に引き続き、『ゼロからのOS自作入門』(通称:ミカンOS本)を読み進めています。「6.4 ポーリングでマウス入力」ではUSB接続のマウスに対応します。

USBホストコントローラを制御するためのドライバの実装は書籍では省略されていますが、ソースコードが公開されているので、それを写経しながら勉強しました。

エミュレータ(QEMU)上では順調に動いたのですが実機で動かすのに手こずった、、という日記です。けど動きました!

処理の概要

USBマウスとOSの橋渡しとなるハードウェアとしてUSBコントローラという制御チップがPCに搭載されている。この制御チップを制御するソフトウェアがドライバ。

ドライバ部分のソースコードと、ドライバを利用するKernel部分のソースコードを自作する。

USBホストコントローラーの規格として、USB1.1用の OHCI , UHCI 、USB2.0用の EHCI 、USB3.x用の xHCI がある。xHCIに準拠したホストコントローラのことを xCI と呼ぶ。

書籍のMikanOSでは xHCI 規格のみサポートしているとのこと。

めちゃくちゃ大雑把に言うと、以下のような処理を実装する。

  • PCに搭載されているxHC(=USBコントローラ)を見つけ出す
  • xHCのMMIOアドレスを読む
  • ドライバの処理を呼び出し、xHCを初期化・起動する
  • 画面上のカーソルを動かす

ハードウェアの構成

ハードウェアとして以下のような構成になってる。

上図のPCIバスにつながるPCIデバイス(USBコントローラやGPUなどなど...)までが、PCの端末内に収まってるイメージ。

PCIデバイスは PCIコンフィギュレーション という情報を持っており、その中の Class Code を読み取ることでPCIデバイスがどういうものなのか判定できる。

引用元 ハイパーバイザの作り方~ちゃんと理解する仮想化技術~ 第15回 PCIパススルーその1「PCIパススルーとIOMMU」

xHCに対応したUSBコントローラなら 0x0C0330 である。

IOアドレス空間でPCIコンフィギュレーションを読み書きする

PCIコンフィギュレーション を読むと言ってもどうすればいいのか。

メインメモリのメモリアドレス空間とは別に、周辺機器用のIOアドレス空間というのがある。空間といってもアドレスと物理的なレジスタの割り当て表のようなイメージ。この割り当て表はホストブリッジが把握している。

CPUにはIOアドレス空間の読み書き専用のIO命令がある。C++では直接IO命令を使えないので、アセンブリ言語で書く(https://github.com/kariyayo/mikan_os/blob/222ae4b7e0b5bd91040cfcb6186371228d5f1003/kernel/asmfunc.asm)。これをC++から呼び出す。

PCIコンフィギュレーション を読み書きするために、ホストブリッジが持つ CONFIG_ADDRESSレジスタ と CONFIG_DATAレジスタ を利用する。これらのレジスタは、IOアドレス空間の特定のアドレスに対応づけられている。

これらを以下のように扱うことで PCIコンフィギュレーション を読み書きできる。

  • CONFIG_ADDRESSレジスタ に読み書きしたい位置を設定してから
  • CONFIG_DATAレジスタ を読み書きする

例えば、 PCIコンフィギュレーション のClassCodeを取得する ReadClassCode() 関数を見てみる。 PCIコンフィギュレーション は4バイトずつ読むので 0x08が読み取り開始位置。この読み取り開始位置を WriteAddress() 関数 → アセンブリ言語で書かれたIoOut32() で IOアドレス空間上の 0x0cf8 (= CONFIG_ADDRESSレジスタ )に書き込む。それから ReadData() 関数 → アセンブリ言語で書かれたIoIn32() でIOアドレス空間上の 0x0cfc (= CONFIG_DATAレジスタ )を読み取る、という流れ。

    /** CONFIG_ADDRESSレジスタのIOアドレス空間上のアドレス */
    const uint16_t kConfigAddress = 0x0cf8;
    /** CONFIG_DATAレジスタのIOアドレス空間上のアドレス */
    const uint16_t kConfigData = 0x0cfc;

    void WriteAddress(uint32_t address) {
        IoOut32(kConfigAddress, address);
    }

    uint32_t ReadData() {
        return IoIn32(kConfigData);
    }

    ClassCode ReadClassCode(uint8_t bus, uint8_t device, uint8_t function) {
        WriteAddress(MakeAddress(bus, device, function, 0x08)); // 4バイト単位
        auto reg = ReadData();
        ClassCode cc;
        cc.base      = (reg >> 24) & 0xffu;
        cc.sub       = (reg >> 16) & 0xffu;  // ClassCodeのsub部分は16~23なので、16ビット右シフトして上位8ビットを捨てることで取得
        cc.interface = (reg >> 8)  & 0xffu;
        return cc;
    }

実機で動かしたい!

ここまでで、「PCに搭載されているxHC(USBホストコントローラ)を見つけ出す」部分が実装できた。他の部分はごにょごにょと実装して、QEMUで動かすと、問題なくマウスカーソルが動いた!

実機PCでも動かしたい・・・!

実機がxHCI非サポートだった

私がMikanOSを実機で動かすときに使っているのは、だいぶ前から家に放置されてた ThinkPad X220。動くだろうか。。マウスカーソルが動かない。。

プリントデバッグしたところ、PCIデバイスを発見できているが、その中からxHC(USBコントローラ)が見つからないようだ。ググってみてもThinkPad X220はUSB3.xには対応してなさそう。

うーん、どうしようかな。実機で動かすのは諦めたくないけど、新しいPC用意するのもなぁ。ということで、PCI ExpressカードでUSB3.0に対応してみよう!

以下のような構成になる。

事象:Address Device Command が Code 17 で失敗する

PCI Expressカードを導入して実機環境で起動すると、、まだマウスカーソルが動かない!

色々プリントデバッグしたところ、USB マウスの初期化処理中に以下のようなエラーが出てOSが停止していた。

AddressDeviceCommand failed. Code: 17

xHCI の仕様書で確認すると、Code 17 は Parameter Error 。QEMU では問題なく動くコードが、なぜ実機のコントローラだと弾かれるのだろう。

原因判明:CSZ(Context Size)

引き続きプリントデバッグ。ホストブリッジが持つレジスタはIOアドレス空間を使っていた。ホストブリッジが持つレジスタを操作することで、PCIデバイス(=xHC(USBコントローラ))が持つ PCIコンフィギュレーション の情報を読み取った。xHC(増設したPCIeカード)のレジスタはMMIOという機能によってメインメモリのメモリアドレス空間でアクセスできるように初期化している。メモリアドレス空間のどこにあるかは PCIコンフィギュレーション のBase Address Register 0に記録されている、ということがxHCIの仕様で決められている。

  • ホストブリッジ
    • CONFIG_ADDRESSレジスタ
    • CONFIG_DATAレジスタ
  • xHC(USBコントローラ)
    • PCIコンフィギュレーション
    • 色々なレジスタ

PCIコンフィギュレーションからBase Address Register 0 を読み取って、そのアドレスを用いてメインメモリを読み取る、結果的にMMIOによってxHCのレジスタを読み取れる。

xHCのレジスタを色々出力させていたところ、USBコントローラの機能を示すレジスタ HCCPARAMS1 の値が以下のようになってた。

HCCPARAMS1: 01405cf, CSZ: 1

これが原因っぽい。xHCI 仕様書の「HCCPARAMS1」の項目を見ると、Bit 2 に CSZ (Context Size) というフラグがある。

  • CSZ = 0: 1つのコンテキストのサイズは 32バイト
  • CSZ = 1: 1つのコンテキストのサイズは 64バイト

MikanOS のデフォルト実装は、QEMU や一部のコントローラに合わせて 32バイト(CSZ=0)前提の固定サイズの構造体 で実装されている。

しかし、私の PCIe 拡張カードは CSZ=1(64バイト)を要求していた。

OS 側が「32バイト目」にエンドポイント設定を書き込んでも、ハードウェア側は「64バイト目」を読み取ります。そのズレによってハードウェアは Parameter Error を返していたのではないか。

解決:CSZに対応

固定サイズの C++ 構造体を使うのをやめ、コンテキストを単なるバイト配列(uint8_t buf[...])として確保するように修正。

// context.hpp
// HCCPARAMS1 を読んで CSZ が 1 かどうかを保持しておく
extern bool is_csz1;

struct DeviceContext {
    uint8_t buf[2048]; // 64バイトになっても入り切るサイズを確保

    EndpointContext* GetEndpointContext(int i) {
        // ハードウェアの要求に合わせて 64バイト / 32バイト 幅でポインタを計算
        return reinterpret_cast<EndpointContext*>(
            buf + (is_csz1 ? 64 : 32) + i * (is_csz1 ? 64 : 32)
        );
    }
};

これで QEMU(CSZ=0)でも実機(CSZ=1)でも、ハードウェアが期待する正しい位置にデータを書き込めるようになったはず。

実行したところ、ついに実機の PCIe 拡張カード越しにマウスのカーソルが動いた!嬉しい!!

※余談:構造体のサイズを変更した際、make clean がサブディレクトリの .o を消さない仕様だったため、一部のファイルだけ古いサイズのままリンクされてしまい、謎のメモリ破壊(ポインタの消失)に数時間悩まされるという罠もありました。。

おしまい

ハードウェアや規格の仕様書は偉大。原因特定まで時間がめっちゃ掛かったー、もっと上手にやりたいところですなぁ。。

OS自作入門のカーネル起動時に起きたABI不一致と解決策

『ゼロからのOS自作入門』を引き続き。「3.5 カーネルからピクセルを描く」のところで、UEFI ブートローダから自作カーネル(kernel.elf)へジャンプした瞬間に画面が真っ白のまま固まってしまいました。

GDB でレジスタと逆アセンブルを追いかけた結果、「EDK II のツールチェーンを CLANGPDB にしたことで、ブートローダとカーネル間で呼び出し規約(ABI)の不一致が起きていたことが判明しました、という日記です。

発生した現象

ブートローダ側で画面全体を白色(値 255)で初期化した後、カーネルへフレームバッファのアドレスとサイズを渡してグラデーションを描画するコードを実行。

ブートローダ側(一部抜粋)

EFI_GRAPHICS_OUTPUT_PROTOCOL* gop;

// ...

Print(L"Frame Buffer: 0x%0lx - 0x%0lx, Size: %lu bytes\n",
      gop->Mode->FrameBufferBase,
      gop->Mode->FrameBufferBase + gop->Mode->FrameBufferSize,
      gop->Mode->FrameBufferSize);
// 白く塗りつぶす
UINT8* frame_buffer = (UINT8*)gop->Mode->FrameBufferBase;
for (UINTN i = 0; i < gop->Mode->FrameBufferSize; ++i) {
    frame_buffer[i] = 255;
}

// ...

// エントリポイント(カーネル側 main.cpp の KernelMain関数)を取得して呼び出す
UINT64 entry_addr = *(UINT64*)(kernel_base_addr + 24);
typedef void EntryPointType(UINT64, UINT64);
EntryPointType* entry_point = (EntryPointType*)entry_addr;

entry_point(gop->Mode->FrameBufferBase, gop->Mode->FrameBufferSize);

カーネル側(main.cpp)

#include <cstdint>

extern "C" void KernelMain(uint64_t frame_buffer_base,
                           uint64_t frame_buffer_size) {
    uint8_t* frame_buffer = reinterpret_cast<uint8_t*>(frame_buffer_base);
    // グラデーション
    for (uint64_t i = 0; i < frame_buffer_size; ++i) {
        frame_buffer[i] = i % 256;
    }
    while (1) __asm__("hlt");
}

前回と異なり、エントリーポイントである KernalMain 関数に2つの引数がある。

仮引数 実引数 実際の値(Printで確認した値)
frame_buffer_base gop->Mode->FrameBufferBase 0x80000000
frame_buffer_size gop->Mode->FrameBufferSize 0x1d5000

QEMU 上で起動したところ、ブートローダが塗った白色の画面のまま変化せず、カーネル側のグラデーションが 1 ピクセルも描画されない。。。

GDB で原因調査する

QEMU に -s -S オプションを付けて GDB を接続し、CPU の状態を調べた。

(gdb) target remote :1234
(gdb) info registers rip
rip            0xfff0              0xfff0

rip が 0xfff0 を指していた。これは x86-64 のリセットベクタ(CPU 起動直後の初期位置)。何が起きているのだろう。

AIさんと相談して他のレジスタも確認してみたら?ということで、カーネルの先頭(0x101000)にブレークポイントを張り、関数に突入した瞬間の引数レジスタを確認。

(gdb) b *0x101000
(gdb) c
Breakpoint 1, 0x0000000000101000 in ?? ()

(gdb) info registers
...
rcx            0x80000000          2147483648
rdx            0x1d5000            1921024
rsi            0x7248f98           119836568
rdi            0x80201a            8396826
...

渡したかった値は

  • rcx のフレームバッファのアドレス: 0x80000000
  • rdx のフレームバッファのサイズ: 0x1d5000

しかし、前回の記事でも確認したELFファイルのヘッダーを見てみるとABIが UNIX - System V となっている。

$ readelf -h kernel.elf
ELF Header:
  ...
  OS/ABI:                            UNIX - System V
  ...

ABI UNIX - System V では、第1引数と第2引数は rdi と rsi にセットされることになっている。

正しい値は rcx と rdx に設定されているのに!

原因:Microsoft x64 ABI と System V AMD64 ABI

同じ x86-64 アーキテクチャでも、ABIによって関数の引数を渡すレジスタの規約(呼出規約: Calling Convention)が異なる。

引数の順序 Microsoft x64 ABI System V AMD64 ABI
第1引数 RCX RDI
第2引数 RDX RSI
第3引数 R8 RDX
第4引数 R9 RCX

書籍にも書いてあったが、UEFIアプリケーションはEFI形式ではなく、Windowsの実行可能ファイルであるPE形式。そのため、ABIは Microsoft x64 ABI になる。カーネル側のC++ソースコードをコンパイルした結果はEFI形式で、ABIは System V AMD 64 ABI となる。

なので、

  1. UEFIアプリケーション(Microsoft X64 ABI)、つまりブートローダーは、第1引数を RCX、第2引数を RDX に入れて entry_point() を呼んだ
  2. ELF カーネル(System V AMD64 ABI)は、第1引数を RDI、第2引数を RSI から読み取ろうとした
    • 呼び出す側と呼ばれた側で利用するレジスタがずれてしまった
  3. その結果、カーネルは私の想定とは異なる RDI = 0x80201a を画面の描画先アドレスとして書き込みを開始した

なぜ書籍のコードは動いていたのか?

書籍のコードではなぜ動くのか。

差分を調べたところ、理由はUEFIアプリケーションのビルドで使う EDK II のビルド設定(TOOL_CHAIN_TAG)の違い(だと思う)。書籍では edk2/Conf/target.txt の TOOL_CHAIN_TAG の値を CLANG38 にしているのだけど、最新の EDK Ⅱ ではその値を指定できず CLANGPDB という値を指定した。この指定の違いが原因なのではないか。

という予想だけど、ちゃんとした理由ははっきりしないまま。。

GDB による操作での事前検証

修正前に「本当にレジスタの不一致だけが原因か?」を確かめるため、GDB 上で手動でレジスタをコピーして動かしてみた。

(gdb) b *0x101000
(gdb) c
(gdb) set $rdi = $rcx
(gdb) set $rsi = $rdx
(gdb) c

このコマンドを実行したらクラッシュすることなく、QEMU の画面が真っ白からグラデーションへと塗り替わった!

解決策:__attribute__((sysv_abi)) の明示

解決策はシンプル。ブートローダ側の関数ポインタ宣言に「この関数を呼ぶときだけは SysV ABI のレジスタを使ってね」とコンパイラに指示する属性を付与する。

// 修正前:
// typedef void EntryPointType(UINT64, UINT64);

// 修正後:
typedef void __attribute__((sysv_abi)) EntryPointType(UINT64, UINT64);

これで、コンパイラは entry_point() を呼ぶ直前に mov %rcx, %rdi や mov %rdx, %rsi を挟む機械語を出力してくれるようになる。

おしまい

GDBは前回も調査でお世話になったり、今回はその場でレジスタの値を書き換えて検証もしました。とても便利。

レジスタレベルを調査することでABIの呼出規約に対する理解が少しだけ深まりました!

OS自作入門でOSが起動しなくてデバッグした:ELFのロードアドレスとリンカの-z separate-codeオプション

『ゼロからのOS自作入門』という本を読んでます。読みながら写経して動かして、という感じ。QEMU 上で動かしてみると想定通り動かなかったのですが、おそらく書籍に書かれたリンカと異なるバージョンのリンカを使っていたためです。

書籍の「3.3 初めてのカーネル」という章で以下のC++で書いた最小のカーネルを実装します。

extern "C" void KernelMain() {
    while (1) __asm__("hlt");
}

__asm__("hlt") というはアセンブリ言語の命令を直接埋め込む書き方です。ループで hlt 命令を実行していて、 hlt 命令はCPUを停止させる命令。

そして、これを呼び出すようなUEFIアプリケーション(ブートローダー)を実装します。

しかし、いざ QEMU 上で動かしてみると想定通りに停止しませんでした。UEFIアプリケーションでELFファイルをメモリに展開するやり方に原因があったこと、リンカの -z separate-code オプションで動くようになることを確認できました、という日記です。

デバッグしてみる

カーネルとなるC++のソースコードは、以下のオプションでリンクする。 --image-base 0x100000 で出力されたバイナリのベースアドレスを 0x100000 番地としている。

$ ld.lld --entry KernelMain -z norelro --image-base 0x100000 --static -o kernel.elf main.o

動かない理由を調べる。まず、readelfコマンドでELFファイルのヘッダを確認する。

$ readelf -h kernel.elf
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:               0x101120
  Start of program headers:          64 (bytes into file)
  Start of section headers:          1032 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         4
  Size of section headers:           64 (bytes)
  Number of section headers:         14
  Section header string table index: 12

Entry point address が重要で、リンク時に --entry KernelMain と指定したC++の関数を呼び出すためのアドレス。 Entry point address 値は、ELF形式の仕様でオフセット24バイトの位置から、8バイト整数として書かれると決まっている。なので、現時点の自作UEFIアプリケーション(ブートローダー)の実装では以下のようになっている。

  1. kernel.elf をファイルシステムから開く
  2. メモリの 0x100000 番地にファイル全体をそのまま Read でコピーする
  3. ELFヘッダの仕様に基づき、オフセット24バイト目に書かれている8バイト整数を読み取ってエントリポイントアドレスを取得する
  4. そのアドレスへ関数ポインタ経由でジャンプする
// UEFI側の読み出しコード(抜粋)
EFI_PHYSICAL_ADDRESS kernel_base_addr = 0x100000;
kernel_file->Read(kernel_file, &kernel_file_size, (VOID*)kernel_base_addr);

UINT64 entry_addr = *(UINT64*)(kernel_base_addr + 24); // オフセット24バイト目
typedef void EntryPointType(void);
EntryPointType* entry_point = (EntryPointType*)entry_addr;
entry_point();

これで上記のソースコードから、C++コードの KernelMain() 関数を呼ぶことになる。

デバッグする。

ビルドして QEMU を -s -S オプション付きで起動。 -s はGDBをポート1234で待ち受け、 -S はCPUを起動直後(最初の1命令目を実行する前)で一時停止させる。そして、GDB でエントリポイント 0x101120 にブレークポイントを張り、実行時の命令を確認してみる。

$ gdb
(gdb) target remote localhost:1234
   0x000000000000fff0 in ?? ()
   (gdb) b *0x101120
Breakpoint 1, 0x0000000000101120 in ?? ()
(gdb) x /3i $rip
=> 0x101120:    add    %al,(%rax)
   0x101122:    add    %al,(%rax)
   0x101124:    add    %al,(%rax)

hlt があるはずの場所に、謎の add %al, (%rax) が並んでいる。なんだこれ?どうやら 0x00 0x00 x86-64 においてデコードされた結果であり、未初期化の空っぽなメモリ領域を実行してしまっているっぽい。

つまり、 想定外(未初期化)の 0x101120 番地を実行してしまっている。

カーネルコードはどこへ消えたのか?

0x101120 にコードがないなら、どこにあるのだろう?適当にメモリを探索してみると、エントリポイントよりも手前にある 0x100120 付近に本物のコードが見つかった。

(gdb) x /10i 0x100120
   0x100120:    push   %rbp
   0x100121:    mov    %rsp,%rbp
   0x100124:    data16 data16 cs nopw 0x0(%rax,%rax,1)
   0x100130:    hlt
   0x100131:    jmp    0x100130

hlt と jmp(ループ)がちゃんとある!

  • コードが実際に置かれた場所: 0x100120
  • CPU がジャンプした場所: 0x101120

なぜずれたのか?

もう一度 readelf コマンドの出力に戻る。

$ readelf -h kernel.elf
ELF Header:
  Entry point address:               0x101120
  Start of program headers:          64 (bytes into file)

ELF ヘッダの Entry point address は確かに 0x101120 となっている。

うーん、そもそもELF形式のこと知らないなぁと調べてみる。家に積んであった『リンカ・ローダの実践開発テクニック』を読んだり。

ELFヘッダ・セクションヘッダ・プログラムヘッダの3つがあって、プログラムヘッダは OSがプログラムをメモリに読み込んで実行する際にどこに何を配置するか を示しているらしい。

プログラムヘッダは readelf -l で確認できる。

$ readelf -l kernel.elf

Elf file type is EXEC (Executable file)
Entry point 0x101120
There are 4 program headers, starting at offset 64

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

 Section to Segment mapping:
  Segment Sections...
   00
   01
   02     .text
   03

Program Headers: という行の下から「セグメント」が並んでいる。 Segment Sections... という行の下から「セクションが」並んでいる。

3つ目のセクション 02 .text という行、 02 は3つ目(0から始まるインデックス番号)のセグメントに含まれてますよ、 .text はプログラムの機械語コードが格納されるテキスト領域のセクションですよ、という意味。

この3つ目のセグメント LOAD に注目する Offset: 0x0000000000000120 で VirtAddr: 0x0000000000101120 となってる。

VirtAddr: 0x0000000000101120 出た!CPU がジャンプした場所は 0x101120 と一致してる。

そして、コードが実際に置かれた場所は 0x100120 だった。

ブートローダーの実装を思い出すと、 0x100000 番地にELFファイルを読み込んでいるだけだった。その結果、コードが 0x100000 番地から上記セグメントの Offset 0x120 の場所に命令が置かれてしまった。本当は VirtAddr: 0x0000000000101120 の位置に置かないといけなかったのに。

0x100000 ┌────────────────────────────────────────┐
         │ ELFヘッダ (オフセット24に 0x101120 が記録) │
0x100120 ├────────────────────────────────────────┤
         │ ★ KernelMain 本体 (hlt / jmp)           │
0x100800 ├────────────────────────────────────────┤ ── ファイル終端 (書き込み終了)
         │                                        │
         │ 未初期化領域 (0x00 0x00 ... )            │
         │                                        │
0x101120 ├────────────────────────────────────────┤ ── CPUがジャンプした場所
         │ add %al, (%rax) (0x00 0x00 の誤解釈)     │
         └────────────────────────────────────────┘

解決策

ブートローダーでELFファイルを読み込んでメモリに配置する際に、プログラムヘッダーの内容を確認して配置するのが正しい解決方法、だと思う。別の方法として、リンカの -z separate-code オプションを使うことで解決できた。

『ゼロからのOS自作入門』でうまくいっていて私の環境でうまくいかなったのは、おそらくリンカのバージョン違い。調べてみると -z separate-code というオプションがあった。

プログラムヘッダーにあるセグメントの VirtAddr はメモリ上のページサイズの倍数で決められる。1ページが複数のセグメントに跨がないことになり、読み込み専用のセグメントと書き込み可能なセグメントが同じページを共有しないようにしているらしい。 VirtAddr はそうなのだが、 ELFファイルにおけるセグメントの配置はメモリ上のサイズ・配置とは別 で、ファイルサイズ節約のためページの倍数とか関係ないサイズになっている模様。

しかし -z separate-code オプションをつけると、ファイル上においてもページの倍数単位にセグメントが置かれるようになる(セグメント間はパディングされるのでファイルサイズは大きくなる)。

今回のことに当てはめると、 .text セクションが Offset 0x120 だったけれども、 -z separate-code オプションをつけることでファイル上でも4KB(0x1000)のページ境界に配置される。結果、ファイル上のオフセット 0x1000 と、プログラム実行時の相対アドレス 0x101000 - ベースアドレス 0x100000 = 0x1000 がピッタリ一致するようになる。

-z separate-code ありで、ビルド後のELFヘッダを確認。

$ readelf -h kernel.elf
  Entry point address:               0x101000
  Start of section headers:          8920 (bytes into file)
  • ファイルサイズがパディングによって約 9KB に増加
  • エントリポイントが綺麗な 4KB 境界である 0x101000 に変化

readelf -l でプログラムヘッダを確認。

Type   Offset              VirtAddr
LOAD   0x0000000000000000  0x0000000000100000  (ヘッダ等)
LOAD   0x0000000000001000  0x0000000000101000  (.text)

Offset: 0x0000000000001000 となっている。 0x100000 番地にELFファイルをそのまま展開することでも、 Entry address point と実際に命令が置かれる場所が 0x101000 で一致する。

おしまい

『ゼロからのOS自作入門』まだ始まったばかりだけどおもしろい!『リンカ・ローダ実践開発テクニック』も読みたくなってきた。