linuxの話 (ELF ローダ)
長らく手つかずだった ELF ローダの製作に着手する。
その前に linux において ELF 実行形式ファイルを実行した場合に、どのような処理がカーネルによって行われるのかを調べる。概念的な疑問やカーネルにおける実装方法について不明な点はあまり無いが、ソースを読んで仕組みを確認するという行為そのものに意味がある。
なので、以下では execve システムコールの処理について見てゆく。
まずは、動作の流れの予想を考える。
execve システムコールは、引数に現在のプロセスで実行させる実行ファイルプログラムのパスを取り、システムコールを発行する。
ここでの処理内容としては、恐らくパス名から実行ファイルを探し、そして実行ファイルを読み込んで、実行ファイルのエントリポイントを取得し、ユーザーのレジスタ情報 (実行アドレス等) を書き換えて、ユーザープロセスに処理を返すものと思われる。
次に、実際の処理について見てゆく。
execve() システムコールによって、linux の sys_execve() サービスルーチンが呼び出される。システムコールが発行されると、eax レジスタにシステムコール番号 (この場合 0xb) が入り、ebx レジスタにファイル名のパス。ecx レジスタには 引数のアドレスの配列のポインタが入る。そして、edx レジスタには、ecx と同じように環境変数のそれが入る。
sys_execve() では、レジスタの ebx に格納されているファイル名の妥当性をチェックして、do_execve() を呼び出す。
以下では、do_execve() の処理について見てゆく。
まず unshare_files () 関数を呼び出し、オープンしているファイル情報を独立させる。通常、プロセス生成時にプロセス毎のオブジェクトが生成され、プロセスディスクリプタと関連づけられるのだが、全てのプロセスに対してこれを行った場合、fork 実行時のオーバーヘッドが大きくなる為、fork 実行時にプロセス情報の生成・初期化を初期化せずに、これらが更新されるまで親プロセスの各種オブジェクトを共有する(軽量プロセス)。一般的に fork を実行した後、直ちに exec を実行するケースが多い (らしい) ので、この手法によってプロセス生成時のパフォーマンス向上が見込める。ここではオープンしたファイル情報に関して、共有の解除を行っている。
次に、linux_binprm 型という、ちょっと見慣れない構造体のオブジェクトを取得している。これは、ユーザー空間にある引数をカーネル側で保持・加工する為のデータ構造らしい (include/linux/binfmts.h より)。
次に、open_exec() 関数を呼び出し、引数で指定したファイル名のファイルオブジェクトを取得する。ここでは、パス名検索に do_path_lookup_open() 関数を使っている。コメントには、open する意志をもってパス名検索を行うとあるが、実際には nameidata オブジェクトの intent メンバのいくうつかを予め設定して、結局 do_path_lookup を呼び出している。これについては、これ以上調べない事にする。
さて次に、init_new_context() 関数を実行している。これはアーキテクチャ依存関数であり、IA32 では、現在実行中のプロセスの LDT を引き継ぐ処理を行っている。
次に、引数と環境変数の数を取得して、linux_binprm オブジェクトを更新し、prepare_binprm() 関数を呼び出す。ここでは、指定したファイルのソフトウェア inode から、linux_binprm オブジェクトを設定する。そして、kernel_read() を呼び出し、ファイルの先頭から BINPRM_BUF_SIZE だけディスクファイルシステムから linux_binprm オブジェクトの buf メンバに読み込む。
include/linux/binfmts.h で BINRPM_BUF_SIZE は 128 と定義されている。この数字には意味があり、ファイルの先頭 128 バイトにファイルのマジック番号が存在する為、これを見て後にロードするファイルの種類を特定する。ここでは、それを行う為に、BINPRM_BUF_SIZE のデータをファイルから取得している。
次に、copy_strings() 関数や copy_strings_kernea() 関数を呼び出し、ファイル名、引数、そして環境変数を linux_binprm オブジェクトに設定する。copy_strings_kernel() で、カーネル空間からコピーしているのは、sys_execve() で getname によってユーザー空間から既にファイル名を取ってきた為。
そして、search_binary_handler() 関数を呼び出す。
ここでは、指定した実行形式ファイルに適合した実行形式でバイナリファイルを実行処理させる。具体的には、カーネル初期化時に登録された ELF ローダの load_binary メソッドを呼び出す。elf の linux_binfmt オブジェクトの load_binary メソッドが実行する実態は、load_elf_binary [fs/binfmt_elf.c] で定義されている。
linux_binprm に似た名前の見慣れない linux_binfmt だが、当然ながらその存在異義はまるで違う。このデータ構造はロードするバイナリファイルで利用される機能を定義したもの。全ての linux_binfmt オブジェクトは、リストに繋げられていて、format 変数がその先頭をさす。
又、このデータ構造は (恐らく) 各実行形式毎に存在しており、その実行形式で実行する 3 つの方法 (インターフェイス) を用意している。ここでは、通常の実行方法について見るため load_binary という実行メソッドについて注目する。
以下では、elf 実行形式における linux_binfmt オブジェクトの load_binary メソッドから実行される load_elf_binary() 関数の処理について見てゆく。
この関数内で使われている ELF ヘッダのデータ構造 struct elfhdr は 32 bit アーキテクチャと 64 bit アーキテクチャでデータ構造が異なる。32 bit の場合、[include/linux/elf.h] で定義されている elf32_hdr 構造体というデータ構造を持つ。
まず、実行形式ファイルの ELF ヘッダ部分に相当する領域を読み込む為のオブジェクトのメモリ領域の確保を行い、prepare_binprm() 関数で取得した大きさ BINPRM_BUF_SIZE の実行形式ファイルの先頭バッファを ELF ヘッダオブジェクトに読ませる。そして、ファイルのヘッダ部分のマジックナンバーや実行タイプや実行環境 (アーキテクチャ) 等を調べる。
そして、load_elf_interp() 関数を呼び出し、対象の実行形式ファイルのエントリポイントを取得し、start_thread() マクロで、ユーザープロセスのレジスタセットの eip を実行ファイルのエントリポイントに設定する。これによってシステムコールから帰ると、ユーザープロセスは、execve で指定したファイルの実行を開始する。
全体的な処理の流れはこれでよさそうだが、ファイルが動的リンクファイルの場合、load_elf_interp() 関数で、ファイルのエントリポイントが修正されている。ここにはローダが指定したリニアアドレスが書かれている為、ローダの要求通りのメモリマッピングを行えば、エントリエントリポイントのアドレスを修正する必要は無さそうだが、内部で mmap 処理を行い、戻り値のアドレスで修正している。
動的リンクの場合の ELF ローダの処理については把握しきってないので、これについての調査は、未来の自分に任せる事にする。
- Category(s)
- 学習経過
- The URL to Trackback this entry is:
- http://dev.ariel-networks.com/Members/ohyama/linux306e8a71-elf-30ed30fc/tbping
カーネル(その8)
前回は、GNU/Linux (以下、Linux) における ELF 実行処理について見てきた。今回は、共有ライブラリをロードしない ELF 実行形式ファイルを実行する環境を onix に構築する話をする。そして、実際に実行形式ファイルを Linux で作成して、onix で実行する。
onix で ELF を実行する処理は elf.c に記述されている。elf_load_file() ルーチンでは、実行するプログラムのパスを受け取り、パス名検索を行って、ディレクトリツリーからディスク inode を取得。そして、ファイルの実態を取得している。
そして、ファイルの ELF ヘッダ及びプログラムヘッダを解析し、実行ファイルによって共有された仮想領域に、各セグメントをマッピングさせる。
ここでの完全に独立した(共有ライブラリのロード及び、実行時ロードを行わない) ELF ファイルの実行処理は、linux における load_elf_binary() 処理とやっている事はほとんど同じである。ただし、各種ファイルチェック及びセキュリティチェック、そして共有ライブラリのロード処理に関する処理については、ここでは一切行っていない。
このように、ロード処理は極めて単純な仕組み及び処理になっている。
ここで話が逸れるが。プログラムのページマッピング処理を作っている最中に onix におけるページ管理の仕組みを大幅に変更した。現在の拡張ページテーブルにおける設計が限界を迎えた為である。
そもそも、何故この仕組みを使おうとしたかというと、半年前に遡る。
当時、スワップアウト時にページを圧縮して、ディスクアクセスを減らし、スワップ時のパフォーマンス劣化を防ぐ機能を持たせる事で、onix の存在意義を訴えようとしていた。ページフォルトによるスワップイン処理はハードウェアによってページ単位で処理が行われるので、スワップ時のパフォーマンスを上げる際には、圧縮するページのサイズが大きい程パフォーマンスが向上する(と思われる)。
それによって、IA-32 に存在する拡張ページテーブルの機能によって、仮想領域へのアクセスによるアドレス解決を 1 段階のページテーブルによって実現した。これによって、ページサイズは (2^22 = 4MB) になる。
だが、これを実現した場合、ユーザーに配れるページ数が極端に制限される。なぜならば、複数のプロセスによって同一の物理メモリを参照した場合には、メモリ保護を行う事が (恐らく) 不可能である為である。
例えば、1024 MB のメモリを搭載したマシンで onix を動かしたときを考える。搭載されたメモリの 3/8 はカーネルによって使われ、5/8 がユーザーが使える領域となるので、一つのプロセスが一つのページを利用した場合でも、最大で 160 個のプロセスしか起動できない事になる。
数 TB のメモリを積んだ 64 bitマシンで、頻繁にページテーブルがストレートマッピングされるような環境でも無い限り、この設計ではとても運用できない。
そこで、ユーザーランドのメモリ領域として予約してあるシステムの全体の 5/8 のメモリ領域のみ通常のページング方式によるマッピングを行い、それ以外のカーネル領域では拡張ページング方式によるアドレス解決を行う事にした。
カーネル領域において、従来通りの方法を採った理由は二つ。一つは、カーネル領域をアクセスするのはカーネルだけであるので、カーネルのメモリ管理機構によって拡張ページをスラブに分割してカーネルに供給する分には、ユーザー側で発生するような問題は発生せず、物理メモリの先頭からストレートマッピングしているだけの構造において、ページテーブルを参照するのは無駄である。という理由と、書き直すのが面倒という理由によって、拡張ページと通常ページが混在するシステムとなった。
次に、ELF 実行形式ファイルを Linux 側で作成して onix で実行する。
onix には、現在シェルが存在せず、カーネルモードによって実行するデバッグ用のシェルしかない。よって次のようにしてプログラムを実行する。
まず、プログラムカーネル起動時にプロセスを一つ生成して、デバッグシェルを起動。そして、"start timer" コマンドによってキューに存在する他のプロセス (プログラムファイルを実行するプロセス) に切替える。そして、プログラムを実行するプロセスは、プログラムの処理を終えたならば戻り値を受け取り、その値を表示する。
Linux による外部プログラムの実行時に処理が行われる load_elf_binary() では、システムコールのサービスルーチンから直接呼び出し、プロセスの制御を外部プログラムに移しているが。ここでは、ユーザープロセスから直接ロードを行って、エントリポイントを関数のポインタとしてユーザープログラムに渡し、関数呼び出しでプログラムの処理を実行する。
上述したように、ここで実行させるプログラムは、共有ライブラリをリンクせず、自前のコードのみを実行するプログラムである。とはいっても、出力に何も表れないのはさみしいので、write システムコールを呼び、標準出力に文字列を表示させる。コードは以下のようになる。
int main()
{
write(1,"FOO BAR baz\n",12);
return 0xaabbccdd;
}
当然だが、write のラッパールーチンも自前で書かなければならない。なので、次のコードを静的にリンクする。
.code32
.text
.global write, _start
_start:
jmp main
write:
movl $0x4, %eax # syscall number
movl 0x04(%esp), %ebx # first argument
movl 0x08(%esp), %ecx # second argument
movl 0x0c(%esp), %edx # third argument
int $0x80
ret
そして、-nostdlib オプションをつけてコンパイル・リンクして得た実行ファイルを onix で実行すると、次の用になる。

このようにカーネル側で、ユーザープロセスに関するメモリ管理機構が機能していれば、この手のプログラムを実行するのは難しい話では無い。しかし、動的リンクを行う場合にはちょっと話が難しくなる。Linux では、そのあたりをカーネル側ではサポートせずに、ライブラリに依存した形で実現している。これについては、次回以降で話をする。
- Category(s)
- 学習経過
- The URL to Trackback this entry is:
- http://dev.ariel-networks.com/Members/ohyama/30ab30fc30cd30eb-305d306e8/tbping