uchan note

プログラミングや電子工作の話題を書きます

はりぼてOSでELFなアプリを起動する

これは 自作 OS Advent Calendar 2016 の 18 日目の記事です。

概要

『30 日でできる!OS 自作入門』 の「はりぼて OS」が対応している実行可能形式は HRB 形式です。 HRB という名の通り「はりぼて OS」独自の形式で、.text、.data、.bss セクションに相当するデータを含むことができます。

構造がシンプルなので、入門者にとっては実行可能形式を学ぶのに都合がいい形式であると思います。 しかし、独自形式ゆえ周辺のツールが整いづらく、実際に扱いやすい形式というわけでもありません。

本記事では、Linux 環境でスタンダードとなっている ELF 形式を「はりぼて OS」でも動かそうじゃないか、ということでやり方をご紹介します。 ELF 形式のアプリをロードして実行する方法(OS 側)、ELF 形式でアプリをコンパイルする方法(アプリ側)の 2 つの側面から解説します。

f:id:uchan_nos:20161218173129p:plain

画像はいらすとやから頂いたエルフです。

※そもそも ELF (Executable and Linking Format) は何かという話はこの記事には書いてありません。 ELF について知りたい方は Executable and Linkable Format - Wikipedia などを参照してください。

ELF 形式のアプリをロードして実行する方法

ELF 形式のアプリをロードする際のメモリ配置を決めるのが、ここでの主なトピックです。 実行コードやグローバル変数のデータ領域をどこに配置するか、スタック領域やヒープ領域をどこに置くか、といったことを考えます。 仕様が決まれば、それにしたがってロード部分のプログラムを書くだけです。

ELF のメモリ配置の仕様を考える

メモリ配置はシステムによって様々な考え方がありますが、ここでは「はりぼて OS」で HRB 形式のアプリをロードする際に採用されているメモリ配置を踏襲しようと思います。 まずは、そのメモリ配置を見てみます。

セグメント 1(読み取り専用・実行可能)には実行する機械語(.text)を配置します。 本当は機械語だけを配置すれば良いのですが、はりぼて OS では読み込んだ HRB ファイル全体をセグメント 1 としています。 このようにすると、ヘッダやデータ領域の分だけメモリが無駄にはなりますが、ローダプログラムはシンプルになります。

|---------------|
| HRB ヘッダ    |
| .text         |
| .data         |
|---------------|

セグメント 2(読み書き可能・実行不可)にはプログラムで必要になるデータ領域を配置します。 データ領域には、初期値付きグローバル変数の領域である .data や、初期値無しグローバル変数の領域である .bss の他、スタック用の領域や api_malloc が確保するメモリの源となる malloc 領域も必要です。 HRB ヘッダに各領域の大きさが書いてあるので、ローダプログラムはそれを見て適切に配置を行います。

|---------------|
| スタック領域  |
| .data         |
| .bss          |
| malloc 領域   |
|---------------|

こんな感じのメモリ配置を ELF 形式で実現するための仕様を考え、次のようにしました。

  • セクションは 5 つ:.stack, .text, .data, .bss, .malloc
  • ファイル上では .stack, .bss, .malloc セクションはサイズを持たない。
  • ELF のセグメントはいくつでも良い。実行(X)フラグが立っているかどうかで配置するメモリ上のセグメント(1 か 2)を決める。
  • EIP の初期値は ELF ヘッダ中のエントリアドレス(e_entry)で表現する。
  • ESP の初期値は .stack セクションのサイズで表現する。

.stack と .malloc を ELF のセクションとするかは悩みましたが、最終的にはそうすることにしました。 EIP の初期値は ELF ヘッダに専用の項目 e_entry があるので悩むことはありませんが、ESP の初期値や malloc 領域のサイズを書き込むための項目はなく、そのための工夫です。 このようにした理由を簡単に説明します。

ページングを有効にしつつセグメンテーションを用いないアーキテクチャ(現代ではこれが一般的)では、 次のようにスタックを最高位アドレス付近の固定アドレスに配置すると決めれば、ESP の初期値は固定値となり ELF ファイルに埋め込む必要がありません。

|---------------| 0x00000000
| .data         |
| .bss          |
| malloc 領域   |
| スタック領域  |
|---------------| 0xffffffff

もっと言えば、一般にスタック領域の最大サイズはアプリのビルド時には見積もれず、かつスタック領域は普通は低位アドレスに向かって成長するため、ESP の初期値を高位アドレスとするのは自然なのです。

しかし「はりぼて OS」ではスタックの成長で .bss や .data 領域が破壊されないようにするため、あえてスタック領域を一番前に置く構造になっています。 ELF ファイルのロードでこれを実現しようと思うと、なんとかして ESP の初期値を ELF ファイルに埋め込む必要があり、.stack セクションを作ることにしたのです。

.malloc セクションがあるのも似たような理由です。 ページングを用いたフラットアドレスなアーキテクチャでは、アプリには広いアドレス空間が与えられますが、だからといって物理メモリが無駄に消費されることはありません。 アプリを起動する前に malloc のための物理メモリを確保しておく必要がなく、アプリがメモリを要求してきたときに初めて物理メモリを確保すれば間に合います。

そのようなアーキテクチャでは、malloc 用の領域は .bss の直後から始まるサイズ上限のない空間、としておいて問題ありません。 すなわち、malloc 用の領域のサイズを ELF ファイルに埋め込む必要がありません。

一方、はりぼて OS ではページングを使いませんから、アプリ実行時に malloc 用の物理メモリ領域を確保しておく必要があります。 そのため ELF ファイルにその大きさを記録しておく必要があります。 malloc 領域のサイズを設定する項目は ELF ヘッダには用意されていませんので、ESP の初期値と同様、セクションとして表現することにしました。

ローダプログラムを ELF 形式に対応させる

ここからは、上で決めた仕様にしたがった ELF ファイルを読み込むプログラムを作ります。 ELF ファイルを読み取り、ELF 構造を解釈しながら適切に配置すればよいので、やることは難しくありません。 (アプリはリンク済みなのでリロケーションも必要ありませんし。)

ということで、改造した部分のソースコードを示しますので、興味のある方はお読みください。 harib27f からのすべての変更部分 は、はりぼて OS の最終版からのすべての変更点が載っています。 ELF 化したいくつかのアプリのソースコードも含みます。

この中で最も本質的な部分は haribote/console.c に対する変更です。 console.c はコンソールでファイル名を入力してから、そのファイルを検索し、メモリ上に読み込み、機械語へジャンプするまでの処理を担当します。 .hrb に対して行うそのような処理を真似て、.elf ファイルの読み込み部分を作りました。

API を ELF アプリから呼び出せるようにする

console.c にはローダプログラムだけでなく、アプリの API 呼び出しに対応するプログラムも書かれています。 ほとんどの API はそのままで ELF アプリからも呼び出せますが、malloc 関連の 3 つの API api_initmalloc, api_malloc, api_free に関しては改造が必要でした。

api_initmalloc は内部で HRB ヘッダから計算した値を EAX, EBX, ECX に設定しています。 EBX は struct MEMMAM のアドレス、EAX はその memman が管理する対象領域の先頭アドレス、ECX は管理対象領域のサイズとして使われます。 もちろん ELF アプリでは EAX, EBX, ECX の値は期待する値にならないので、console.c の中で改めて必要な値を計算する必要があります。

api_mallocapi_free も memman のアドレスを再計算する必要があり、console.c を改造しています。

ELF 形式でアプリをコンパイルする方法(ツール準備)

さて、ここまでで ELF ファイルの仕様が決まり、OS 側の準備が整ったので、いよいよその仕様に沿う ELF 形式の実行ファイルを作っていきましょう。 例として lines アプリを ELF 化することを目指します。 lines アプリは画面に色が付いた線を引くための簡単なアプリなのですが、api_malloc を使うなど意外と高度なこともしており、例としてちょうどいいのです。

ELF ファイルの生成には ELF 形式に対応した GCC や LD が必要です。 お手持ちのコマンドが ELF に対応しているかは ld --help コマンドで確認できます。 (LD が ELF に対応していれば、恐らく GCC も ELF に対応したものがシステムに入っているはずです。)

$ ld --help
...
ld: supported targets: elf32-i386 elf32-iamcu coff-i386 elf32-little elf32-big plugin srec symbolsrec verilog tekhex binary ihex
...

このリストの中に elf32-i386 があれば、はりぼて OS 用の ELF アプリをビルドすることができるはずです。 Linux なら ELF がスタンダードなので、システムに付属のツール群は ELF に対応していると思います。 Windows 上でそれらを用意するには『自作エミュレータで学ぶx86アーキテクチャ』に付属の開発ツールセット tolset_p86 を使うのが簡単です。 もっと新しい GCC を使いたい場合は Windows で GCC 6.2.0 をビルドするメモ に従って自分で GCC(と LD)をビルドします。

ELF 形式でアプリをコンパイルする方法(実践)

目指すセクション構成はこんな感じです。

セクション名 ファイル内オフセット ファイル内でのサイズ メモリ上でのサイズ
.text ELF ヘッダ直後 .text の総和 .text の総和
.stack .text の直後 0 変数 STACK で指定
.data .text の直後 .data の総和 .data の総和
.bss .data の直後 0 .bss の総和
.malloc .data の直後 0 変数 MALLOC で指定

これを実現するリンカスクリプトは次のようになります。

ENTRY(_HariMain)
INCLUDE ld_variables.lds

SECTIONS
{
  . = SIZEOF_HEADERS;
  .text : { *(.text) }
  "file offset of data" = .;

  . = 0;
  .stack : AT("file offset of data") {
    . = STACK;
  }
  .data : {
    *(.rodata*) *(.data)
  }
  .bss  : { *(.bss) }
  .malloc : {
    . += MALLOC;
  }
}

コード領域(.text)とデータ領域(.text 以外)をそれぞれ別のメモリセグメントに配置すると、アプリから見た両者の先頭アドレスはともに 0 となります。 したがって、リンカスクリプトでは途中で . = 0; として、ロケーションカウンタを 0 にリセットする必要があります。 そうしないと、メモリ上で .text の後ろに続けて .stack が配置される想定の ELF ファイルが生成されてしまいます。

ただ、それだけでは LD が気を効かせてなのか、.data をページ境界に整列させようとします(ld コマンドに --nmagic オプションを指定しているにもかかわらず)。 結果として .text と .data の間に大量の(最大 4KB の)0 が埋め込まれ、ELF ファイルが肥大化します。 まあ 4KB くらいなら一般的には小さいので良いですが、はりぼて OS の世界では大きすぎます(これを放っておくと川合さんに怒られてしまうかもしれません)。 AT("file offset of data") を書いておくと .stack セクションとそれに続く .data セクションがファイル内で詰めて配置されるようになり、生成される ELF ファイルが必要最低限の大きさになります。やったー!

このリンカスクリプトを用いて ELF バイナリを生成する Makefileapp_make_elf.txt です。 その中で最も重要なのは次の部分です。

LD = $(ELFGCCPATH)ld.exe -L$(APILIBPATH) --nmagic
...
$(APP).elf : $(APP).o $(APILIBPATH)libapi.a Makefile $(LDSCRIPT) ld_variables.lds ../app_make_elf.txt
    $(LD) -T$(LDSCRIPT) -o $(APP).elf $(APP).o -lapi

ld コマンドに --nmagic を渡すことで、ページングを有効化した際に必要となるページ境界のアライメントを無効にします。 はりぼて OS はページングを使いませんから、このオプションを使うことでコンパクトな ELF ファイルを手に入れることができます。

-T オプションで、先ほど説明したリンカスクリプトへのパスを指定します。

-l オプションで、追加でリンクするライブラリを指定します。 LD の仕様で、-l オプションを $(APP).o より後ろに書くのが重要です。 また、ここでリンクするライブラリも ELF 形式でビルドされている必要があります(後述)。

さて、リンカスクリプトの中で登場する STACKMALLOC という変数はどこから来るか説明しましょう。 それは各アプリのディレクトリに置いてある ld_variables.lds というファイルに書いてあります。 例えば lines ではこんな感じになっています(elines/ld_variables.lds)。

STACK  = 1*1024;
MALLOC = 48*1024;

この ld_variables.lds がリンカスクリプトから INCLUDE ld_variables.lds としてインクルードされることで、 アプリ固有であるスタック領域と malloc 領域の大きさを設定できるようになっています。

apilib を ELF でビルドする

忘れがちなのはアプリが依存するライブラリを ELF でビルドすることです。 lines は apilib に依存するので、それを ELF 化する必要があります。

apilib の Makefile は、元の Makefile に ELF 用の記述を付け足したものです。 重要なのは次の行です。

libapi.a : apilib.lib Makefile
  $(OBJCOPY) -I coff-i386 apilib.lib -O elf32-i386 libapi.a

LD の -l オプションで指定するライブラリのファイル名は libxxx.a という形式になっている必要があるため、apilib.a ではなく libapi.a としました。

2 行目を見ると分かる通り、objdopy コマンドを使って COFF 形式である apilib.lib を ELF 形式に変換しています。 すべての .c ファイルを ELF 形式でビルドし直してもいいのですが、objcopy を使えば一発なので楽です。

参考文献

  • リンカ・ローダ実践開発テクニック
    • タイトルに "ELF" という言葉は入っていませんが、中身は完全に ELF の話です。ローダの作り方について非常に参考になりました。
  • ELF Format
    • Hazymonn さんの ELF フォーマットについての記事です。ELF のヘッダの構造とか、各値の意味などが詳しく説明されています。
  • Executable and Linking Format (ELF)
    • 32 ビットの ELF フォーマットに関する公式の仕様書です、多分。全部は読んでませんが、細かいことも書いてあります。
    • 64 ビットの ELF に関する PDF もどこかにあるのかな…?
  • GNU LD のドキュメント
    • この中でも特に "3.1 Basic Linker Script Concepts" や "3.6.8.2 Output Section LMA" が役に立ちました。
  • tools/obj2bim - hrb-wiki