OSの上で動くプログラム
以降,第I〜II部では,Linux OS上で動作する機械語プログラムを作成する(第I部では i386 用Linux,第II部では ARM 用Linuxを使う)。 つまり,過去の授業においてC言語等のプログラムをコンパイルして得ていた実行可能ファイルを,コンパイラを使わずに作る,という意味だ。
第0部とはCPUがちがうので,使うCPUの機械語命令に合わせてプログラムを書く必要がある。さらに,OSの作法にも従わないとOSの上では実行できない。
- 機械語プログラムは実行可能ファイル形式のファイルに格納しなければ実行できない。
- 計算結果の出力等,プログラム外部とのやり取りはすべてOSのシステムコールを介して行う必要がある。
この章ではこれらの決まりについて説明する。
実行可能ファイル
過去の授業のおさらいとして,C言語で簡単なプログラムを記述し,コンパイルして実行してみよう。
演習1.1-1
(1) 下記のプログラムを打鍵入力してファイルに保存しなさい。ファイル名は hello.c
とする。
#include <stdio.h>
int main()
{
printf("hello, world\n");
return 0;
}
(2) 以下の手順でコンパイルし,実行しなさい。
$ gcc hello.c -- hello.cをコンパイルしてa.outを生成
$ ./a.out -- a.outを実行
hello, world
gcc
で作られた a.out
という名前のファイルが実行可能ファイルだ。
a.out
という名前は「指定がない場合に使われる名前」であり,ファイル名は好きに付けてよい。 gcc
コマンドの -o 出力ファイル名
という引数で名前を指定できるし,作成後にファイル名を変更しても問題ない。
UNIX系OSでは file
コマンドでファイルの種類を調べることができる。
$ file a.out
a.out: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), ...
ELF (Executable and Linkable Format) はLinuxで用いられている実行可能ファイル形式だ。上の実行例では x86-64 CPU用の機械語プログラムが格納されたELFファイルであることが示されている。
なお,emacs
やgcc
等のコマンドも実行可能ファイルであり,a.out
と立場は変わらない。
以下のように file
コマンドを適用すればわかる。
$ which emacs -- emacsコマンドのありかを調べる
/usr/bin/emacs
$ file -L /usr/bin/emacs
/usr/bin/emacs: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), ...
(/usr/bin/emacs
はシンボリックリンクなので,-L
オプションを付けてリンク先の内容を調べている。)
第0部のミクロンは「利用者がメモリの全内容を自由に変更できる」機械として設計されていたが,OSの管理下にあるコンピュータではそのような自由は許されない。一般のプログラムはOSに割り当てられたメモリ領域の中でしか実行できない。 プログラムをメモリ領域内に配置(ロード)する処理はOSしかできないように設計されており,その処理をしてもらうためには実行可能ファイル形式のファイルを用意しなければならない。
hexファイルからの実行可能ファイルの作成
i386用機械語プログラムを入力して実行可能ファイルを作り,実行してみよう。
演習1.1-2
(1) 以下の2つの16進数列を打鍵入力し,それぞれ h.hex
, elf.hex
というファイル名で保存しなさい。
(空白の数や改行位置は自由に変えてよい。a
〜f
の代わりにA
〜F
を使ってもよい。)
h.hex
b8 04 00 00 00
bb 01 00 00 00
b9 82 80 04 08
ba 0a 00 00 00
cd 80
b8 01 00 00 00
bb 00 00 00 00
cd 80
49 27 6d 20 66 69 6e 65 2e 0a
elf.hex
7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
02 00 03 00 01 00 00 00 60 80 04 08 34 00 00 00
00 00 00 00 00 00 00 00 34 00 20 00 01 00 28 00
00 00 00 00 01 00 00 00 00 00 00 00 00 80 04 08
00 80 04 08 00 10 00 00 00 10 00 00 05 00 00 00
00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00
(2) 以下の手順で実行可能ファイル a.out を生成し,実行しなさい。
$ cat elf.hex h.hex | xxd -r -p > a.out -- 連結してバイナリファイルに変換
$ chmod +x a.out -- 実行許可を付与 (すでに付いていれば不要)
$ ./a.out -- a.outを実行
I'm fine.
(前述の通り,実行可能ファイルの名前は a.out
でなくても構わない。また,すでに存在している実行可能ファイルに上書きした場合は chmod +x
は不要。)
cat
コマンドは,単に複数のファイルを連結した結果を出力する (concatenate)。
(elf.hex
を後で再利用するので h.hex
と分けて記述しただけ。)
cat
コマンドによる連結結果に xxd -r -p
を適用して,バイナリファイルに変換している。(後述の「テキストファイルとバイナリファイル」を参照。)
注意:hexファイルの書き誤りに注意
xxdコマンドを使ってhexファイルをバイナリファイルに変換する場合,書き誤りがあっても気づきにくいので注意。 xxdコマンドは,空白や改行をすべて無視し,2桁ずつ16進文字列を読み出して,1バイトに変換する。そのため,例えば以下のように一部のバイトを1桁で記述すると,それ以降,バイトの区切りがずれてしまう。
↓以下のように記述すると,bb 10 00 00 0b 98 28 00 40
と書いたのと同じ。
bb 1 00 00 00
b9 82 80 04 08
また,16進文字でない文字も空白と同様に無視される。従って,例えば 1f
と書くべきところを 1h
と書くと,1
のみ書いたのと同じになる(そしてそれ以降,バイトの区切りがずれる)。
上記のような誤りがあっても警告等は一切出力されないので,意図通りのバイト列が出力されているか,下記のxxd -g1
コマンドや後述の逆アセンブル方法を使って随時確認することを勧める。
テキストファイルとバイナリファイル
実行可能ファイルはバイナリファイルである。例えば,演習1.1-2で作成した a.outをEmacsで開いてみよう。
バイナリファイルは人間が読み書きすることを意図していないファイルなので,テキストエディタでは開けない(あるいは開いてもぐちゃぐちゃな表示になる)。
Emacsではなくて,xxdコマンドを使って中身を見てみよう。xxdコマンドは,-g1
オプションを付けて実行すると,ファイルの中身を16進数で表示する。
$ xxd -g1 a.out -- バイナリファイルa.outの中身を確認
00000000: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 .ELF............
00000010: 02 00 03 00 01 00 00 00 60 80 04 08 34 00 00 00 ........`...4...
00000020: 00 00 00 00 00 00 00 00 34 00 20 00 01 00 28 00 ........4. ...(.
00000030: 00 00 00 00 01 00 00 00 00 00 00 00 00 80 04 08 ................
00000040: 00 80 04 08 00 10 00 00 00 10 00 00 05 00 00 00 ................
00000050: 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000060: b8 04 00 00 00 bb 01 00 00 00 b9 82 80 04 08 ba ................
00000070: 0a 00 00 00 cd 80 b8 01 00 00 00 bb 00 00 00 00 ................
00000080: cd 80 49 27 6d 20 66 69 6e 65 2e 0a ..I'm fine..
左端の8桁の16進数は,ファイルの先頭から何バイト目かを表している。その次の2桁×16個の16進数がファイルの中身16バイト分だ。その右側は,その16バイトを文字コードと解釈したときの文字を示している(対応する文字がない場合は .
と表示)。
先頭の96バイト(6行分)がelf.hex
に記述されていたデータ,
その後の44バイト(3行分)がh.hex
に記述されていた機械語プログラムだ。
elf.hex
やh.hex
等のhexファイルとa.out
はどう違うのか?
hexファイルは,「b」「8」「空白」「0」「4」「空白」…のような文字の並びを格納したファイルだ。hexファイルの中身をxxdコマンドで見てみればよくわかる。
$ xxd -g1 h.hex
00000000: 62 38 20 30 34 20 30 30 20 30 30 20 30 30 0a 62 b8 04 00 00 00.b
00000010: 62 20 30 31 20 30 30 20 30 30 20 30 30 0a 62 39 b 01 00 00 00.b9
00000020: 20 38 32 20 38 30 20 30 34 20 30 38 0a 62 61 20 82 80 04 08.ba
00000030: 30 61 20 30 30 20 30 30 20 30 30 0a 63 64 20 38 0a 00 00 00.cd 8
00000040: 30 0a 62 38 20 30 31 20 30 30 20 30 30 20 30 30 0.b8 01 00 00 00
00000050: 0a 62 62 20 30 30 20 30 30 20 30 30 20 30 30 0a .bb 00 00 00 00.
00000060: 63 64 20 38 30 0a 34 39 20 32 37 20 36 64 20 32 cd 80.49 27 6d 2
00000070: 30 20 36 36 20 36 39 20 36 65 20 36 35 20 32 65 0 66 69 6e 65 2e
00000080: 20 30 61 0a 0a.
h.hex
の中身は,「b
の文字コード(62)」「8
の文字コード(38)」「空白の文字コード(20)」…のような,表示可能・打鍵可能な文字の文字コードの列だ。このような文字だけからなるファイルをテキストファイルと言う。
C言語やJavaのソースファイルもアセンブリ言語プログラムファイルもすべてテキストファイルだ。
$ xxd -g1 hello.c
00000000: 23 69 6e 63 6c 75 64 65 20 3c 73 74 64 69 6f 2e #include <stdio.
00000010: 68 3e 0a 0a 69 6e 74 20 6d 61 69 6e 28 29 0a 7b h>..int main().{
00000020: 0a 20 20 70 72 69 6e 74 66 28 22 68 65 6c 6c 6f . printf("hello
00000030: 2c 20 77 6f 72 6c 64 5c 6e 22 29 3b 0a 20 20 72 , world\n");. r
00000040: 65 74 75 72 6e 20 30 3b 0a 7d 0a eturn 0;.}.
クイズ
- テキストファイル,バイナリファイルそれぞれの利点(どのような場合に使うべきか)を考えなさい。
- 以下の各ファイル形式を,テキストファイルとバイナリファイルに分類しなさい。
JPEG, EPS, ZIP, HTML, docx, PDF, TeX, DVI (TeXの出力ファイル)
逆アセンブル
演習1.1-3
下記のコマンドを実行して,a.out
中の機械語プログラムに対するアセンブリ言語表記を確認しなさい。
$ ndisasm -b32 -e96 a.out -- 96バイト目から逆アセンブル
00000000 B804000000 mov eax,0x4
00000005 BB01000000 mov ebx,0x1
0000000A B982800408 mov ecx,0x8048082
0000000F BA0A000000 mov edx,0xa
00000014 CD80 int 0x80
00000016 B801000000 mov eax,0x1
0000001B BB00000000 mov ebx,0x0
00000020 CD80 int 0x80
00000022 49 dec ecx
00000023 27 daa
00000024 6D insd
00000025 206669 and [esi+0x69],ah
00000028 6E outsb
00000029 65 gs
0000002A 2E cs
0000002B 0A db 0x0a
ndisasmはi386(及びx86-64)用の逆アセンブラ (disassembler) である。 機械語2進数列をアセンブリ表記に翻訳することを逆アセンブルと言い,それを行うソフトウェアを逆アセンブラと言う。
ndisasmのオプション引数-b32
は32ビットモードの意味,-e96
は先頭96バイトを読み飛ばすことを表す。
ndisasmの出力は,左から,先頭から何バイト目かを表す16進数(オフセットとも言う),機械語命令を表す16進数列,その命令をアセンブリ表記したもの,である。
機械語命令列がh.hex
に記述した16進数列に一致していることがわかる。なお,先頭8命令が機械語プログラムであり,第00000022バイト以降の 49 27 6d ...
は(I'm fine.
の各文字の)文字コードの列である。ndisasmの上記の出力では,この文字コード列が機械語命令列と誤認識されている。
参考:elf.hexの中身
elf.hex に記述された96バイトには,このファイルがi386用実行可能ELFファイルであるという情報や,このファイルの中身を 0x08048000 番地に配置し,0x08048060 番地から実行開始すること,といった情報が書かれている。
readelf
コマンドを使えば,どのような情報が書かれているか調べることができる。
$ readelf -a a.out -- ELFファイルa.outの中身を確認
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x8048060
(中略)
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x01000 0x01000 R E 0x1000
(後略)
(参考1:なお,elf.hex の中身を先頭に付けて作る実行可能ファイルは,実行するのに最低限必要な情報しか含んでいないので,コンパイラ等が出力する通常のELFファイルとは異なる。例えば objdump
コマンドを使って逆アセンブルしたり情報を取り出したりすることはほとんどできない。)
(参考2:elf.hex の中には主記憶に配置するバイト列(つまり機械語プログラム)の長さを記述すべき箇所があるが,elf.hex ではこれを 4096 (= 0x1000) と記述している。 実際のファイルの長さがそれより短くてもエラーにはならないようである。)
最小のプログラム
計算結果の出力
機械語プログラムを説明する前に,数値の出力方法について,C言語を使って説明する。
第0部のミクロン・シミュレータでは,数値等を出力する特別な方法はなく,計算結果をメモリ中に書き込んでおけばそれを実行後に観測することができた。 一方,OSの上で動くプログラムの場合,「実行後にメモリを観測する」こと等は行えない。割り当てられていたメモリは実行終了後ただちに回収されるし,セキュリティのため一般利用者はメモリの内容を観測できないようになっているからだ。 何かの方法でプログラムの中から出力を行わなければ,実行の結果は観測できない。
C言語プログラムにおいて計算結果を出力したい場合,普通はprintf
などのライブラリ関数を使う。しかし,もしそれが使えないとしたらどうなるだろう? 計算結果を出力することはできないのだろうか? 実はそんなことはなく,ライブラリ関数を使わなくても(少し制約はあるが)数値を出力することはできる。
以下は 123 + 45 の計算結果を出力するC言語プログラムの例だ。
/* ライブラリ関数を使わないので#includeは不要 */
int main()
{
return 123 + 45;
}
コンパイルして実行してみよう。
$ gcc 12345.c -- a.outを生成
$ ./a.out -- 実行
一見,何も起こらない。でも実は,計算結果はちゃんと出力されている。echo $?
を実行すればわかる。
$ ./a.out -- 実行
$ echo $? -- 直前のコマンドの終了コードを表示
168
C言語プログラムのmain
関数の戻り値は,OSに渡す終了コードを表している。
UNIX系OSでは,各アプリケーションは終了時に終了コードと呼ばれる整数を出力することができる。0は正常終了を表し,0以外の数はエラー等による終了を表す。(0以外の終了コードが何を意味するかはアプリケーションごとに異なる。)
$?
はシェルの定義済み変数で,直前に実行したコマンドの終了コードを保存している。
終了コードは本来は正常終了したかどうかをOSに知らせるためのものだが,ここでは整数の出力手段として使おうというわけだ。これを使えば,printf
などの仕組みが存在しなくても整数を出力できる。
ただし,0以上255以下の整数しか出力できないので注意。その範囲外の数を出力しようとしても,$?
には下位8ビットだけが代入される。(Linuxの仕様。多くのUNIX系OSでも同様。CPUとは無関係。)
後の章ではprintf
に相当する関数を自作することになるが,それまでは他に出力手段がないので,終了コードを使って数値を出力することにする。
最小の機械語プログラム
演習1.1-4
以下の16進数列を打鍵入力しなさい。
演習1.1-2のh.hex
と同様に,elf.hex
と連結してバイナリファイルに変換し,(chmod +x
して)実行しなさい。echo $?
を実行して終了コードを確認しなさい。
また,ndisasmを使ってこのプログラムのアセンブリ表記を確認しなさい。
123.hex
b8 01 00 00 00
bb 7b 00 00 00
cd 80
演習1.1-4のプログラムは,OS上で動くプログラムとして最小のものであり,終了コード 123 (= 0x7b) を出力して終了するだけのプログラムである。
アセンブリ表記は以下のようになる。
mov eax, 1 // exitのシステムコール番号
mov ebx, 0x7b // 終了コード
int 0x80 // システムコール
mov
はミクロンのmov
と同じで,レジスタEAXやEBXに即値を代入する。
int 0x80
はi386用Linuxにおけるシステムコール命令である。
実行すると,EAXに格納されたシステムコール番号に応じた処理が実行される。
システムコール番号 1 は exitシステムコールを表す。
exitシステムコールを呼び出すとそのプログラムは終了する(メモリ上から消される)。
exitシステムコール
上記の 123.hex
はexitシステムコールを実行するだけのプログラムだ。
exitシステムコールは,EAXに1,EBXに終了コードを格納した状態で int 0x80
を実行することで呼び出せる。exitシステムコールを実行するとそのプログラムは終了する。
(int
はinterruptの略で「ソフトウェア割り込み」を意味するが,i386用Linuxではこれを管理者機能呼び出し (supervisor call) の手段として使っている。この部分の詳細はこの授業の範囲を超えるので,ここでは「int 0x80
でOSの機能を呼び出す」という説明だけにしておく。)
プログラムの終端では必ずexitシステムコールを実行しなければならない。
第0部のミクロンにおいて,hlt
命令に到達しなければプログラムがいつまでも終了しないのと同じで,exitシステムコールが実行されない間はプログラムがその先も続いているものとして扱われる。
(ミクロンと違って 00
は hlt
命令ではないので,00
に到達したからといって停止しない。)
実際には,OSの保護機能が働いて,「割り当てられていないメモリ領域から命令を読み出そうとした」等のエラーによってプログラムは停止するが,「エラーを起こして止める」のではなく正常な手順でプログラムを終了させる方が当然スマートだ。
(なお,i386にもhlt
命令が存在するが,通常のプログラムには実行できない命令となっている(実行すると保護違反エラーになる)。実際,hlt
命令はCPUを止める命令であり,実行すればOS等も止まってしまう。)
(ちなみに,演習1.1-2のプログラムは write システムコールと exit システムコールを実行するだけのプログラムである。write システムコールは,端末やファイルへの出力のためのシステムコールである。演習1.1-2のプログラムは,write システムコールで I'm fine.
という文字列を出力した後,exit システムコールで終了する。本科目で使用するシステムコールは write と exit の2つだけだ。)