/ «2004-02-22 (Sun) ^ 2004-02-29 (Sun)» ?
   西田 亙の本:GNU 開発ツール -- hello.c から a.out が誕生するまで --

Categories Books | Hard | Hardware | Linux | MCU | Misc | Publish | Radio | Repository | Thoughts | Time | UNIX | Writing | プロフィール


2004-02-28 (Sat)

[Writing] Hello, world! 物語・その3

モニターコマンドよ、もう一度

最終回となる今回は、昔懐かしい16進エディタを Linux 上で作成することから始めよう。

今を遡ること、20数年前。世の中には「マイコン雑誌」片手に、ダンプリスト入力に没頭するマイコン小僧が溢れていた。雑誌発売日のパソコンショップでは、誰が一番早く掲載されたゲームを起動するか、すさまじいまでのデッドヒートが繰り広げられたものである。チェックサムの最終チェックに余念がないもの。真っ先に GO コマンドを入力したは良いものの、プログラムが暴走してしまい、茫然自失とする少年。カセットに記録しておいたプログラムがロードエラーで読み出せず、半狂乱になっている少年。今となっては、なにもかもが懐かしい・・。

時は過ぎ、マイコンはパソコンとなり、少年はおじさんとなった。現代のパソコンは確かに進化した。その能力はかっての8ビットマイコンを遙かに圧倒しているが、「大切な何かが欠けている」ような気がする。それは私が思うに、モニターコマンドと BASIC-ROM だ。かってのマイコン少年は、BASIC でプログラムの基本を学び、いつの日かモニターコマンドを通じて、機械語の世界へと飛び込んだものである。

ところが、現代のパソコンには BASIC-ROM はもちろん、モニターコマンドすら搭載されていない。ここに、現代のパソコン少年やパソコンおじさんの悲劇がある。

しかし、嘆いてばかりもおられない。幸い私達には、「おつむ」が付いているので、創意工夫によりこの難局を乗り切ることができる・・はずだ。やってみよう。

16進エディタを作ろう!

まずは、モニターの心臓部とも言える Edit コマンドに挑戦してみよう。実行コードを入力できなければ、全ては始まらない。本当は、ncurses を使い、カーソル移動できる本格的な16進エディタが欲しいところだが、今は単純なユーザーインターフェースで我慢しよう。イメージとしては、次のような感じ。

$ ./hexedit
0000: 00 = 1
0001: 00 = 2
0002: 00 = 3
0003: 00 = 4
0004: 00 = 5
0005: 00 = 7
0006: 00 = -      <--- マイナスで1番地バックする
0005: 07 = 6
0006: 00 =        <--- コントロールDで終了

0000: 01 02 03 04 05 06 00 00 00 00 00 00 00 00 00 00 
0010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
0020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 

格納アドレスはゼロ番地からとし(実際にはプログラムは高位アドレスにおかれるが、分岐命令は考えないこととする)、現在のアドレス・現在の値を表示した後に、ユーザーから入力された値で、メモリーの内容を更新することとする(もちろん16進入力!)。ただし、打ち間違いのために、マイナスを入力した時は、1番地バックできる機能を搭載させよう。

以上、とってもシンプルなエディットコマンドだ。最初は、欲張らないことが肝心。それでは、以上の仕様をイメージしながらコーディングしてみよう。皆さんも、各自挑戦してみてほしい。私が作成した hexedit.c は次のようになった(ソースはこちら)。

    1  #include <stdio.h>              // printf(), fgets(), sscanf()
    2  #include <string.h>             // strcmp()
    3
    4  typedef unsigned char byte;     // "byte" definition
    5
    6  void hexedit(byte* buf, int size) {
    7    int idx, res, val;
    8    char *p, msg[ 256 ];
    9
   10    // Clear buffer
   11
   12    for (idx = 0; idx < size; idx++)
   13      buf[ idx ] = 0;
   14    idx = 0;
   15
   16    // Read data
   17
   18    while (1) {
   19      printf("%04X: %02X = ", idx, buf[ idx ]);
   20      p = fgets(msg, size, stdin);
   21      if (p == NULL)
   22        break;
   23      else if (strcmp(p, "-?n") == 0) {
   24        idx = (idx > 0) ? idx - 1 : 0;
   25        continue;
   26       }
   27      else if (strcmp(p, "?n")) {
   28        res = sscanf(msg, "%x", &val);
   29        if (res == 1)
   30          buf[ idx ] = val;
   31        else {
   32          printf("Illegal data, please try again!?n");
   33          continue;
   34         }
   35       }
   36      idx = (idx < (size - 1)) ? idx + 1 : size - 1;
   37     }
   38    printf("?n?n");
   39   }
   40
   41  void hexdump(byte* buf, int len) {
   42    int idx = 0, i;
   43
   44    // Dump data
   45
   46    while (1) {
   47      printf("%04X: ", idx);
   48      for (i = 0; i < 16; i++)
   49        printf("%02X ", buf[ idx + i ]);
   50      printf("?n");
   51      idx += 16;
   52      if (idx >= len) break;
   53     }
   54    printf("?n");
   55   }
   56
   57  int main() {
   58    byte mem[ 256 ];              // Memory buffer
   59
   60    hexedit(mem, 256);
   61    hexdump(mem, 64);
   62    return 0;
   63   }

ご覧のように、私はふたつの関数を用意した。hexedit にバッファーの先頭アドレスとそのサイズを指定して呼び出すと、バッファー内容の編集を行うことができる。同様に、hexdump にふたつの引数を渡すと、指定された長さのバッファー内容を出力する。プログラム内容は、至って簡単なものだが、C初学者のテーマとして、モニターコマンドは最適なような気がする。Cテキストにありがちな、ハノイの塔や、数当てプログラムなどよりも、遙かに実践的で面白いと思うのは、私だけだろうか?

素晴らしきかなキャスト演算子

それでは、記念すべき世界初(?)の Linux 版・超簡易モニタープログラムを完成させよう。最終的な mon.c は、次のようになった(ソースはこちら)。

    1  //
    2  // m o n . c
    3  //
    4  // --- A tiny monitor program on Linux ---
    5  //
    6
    7  #include <stdio.h>              // printf(), fgets(), sscanf()
    8  #include <string.h>             // strcmp()
    9
   10  typedef unsigned char byte;     // "byte" definition
   11  byte mem[ 256 ];                // Memory buffer
   12
   13  void hexedit(byte* buf, int size) {
   14    int idx, res, val;
   15    char *p, msg[ 256 ];
   16
   17    // Clear buffer
   18
   19    for (idx = 0; idx < size; idx++)
   20      buf[ idx ] = 0;
   21    idx = 0;
   22
   23    // Read data
   24
   25    while (1) {
   26      printf("%04X: %02X = ", idx, buf[ idx ]);
   27      p = fgets(msg, size, stdin);
   28      if (p == NULL)
   29        break;
   30      else if (strcmp(p, "-?n") == 0) {
   31        idx = (idx > 0) ? idx - 1 : 0;
   32        continue;
   33       }
   34      else if (strcmp(p, "?n")) {
   35        res = sscanf(msg, "%x", &val);
   36        if (res == 1)
   37          buf[ idx ] = val;
   38        else {
   39          printf("Illegal data, please try again!?n");
   40          continue;
   41         }
   42       }
   43      idx = (idx < (size - 1)) ? idx + 1 : size - 1;
   44     }
   45    printf("?n?n");
   46   }
   47
   48  void hexdump(byte* buf, int len) {
   49    int idx = 0, i;
   50
   51    // Dump data
   52
   53    while (1) {
   54      printf("%04X: ", idx);
   55      for (i = 0; i < 16; i++)
   56        printf("%02X ", buf[ idx + i ]);
   57      printf("?n");
   58      idx += 16;
   59      if (idx >= len) break;
   60     }
   61    printf("?n");
   62   }
   63
   64  int main() {
   65    int res;
   66    char msg[ 256 ], *p;
   67    int (*fp)(void);              // Function pointer
   68
   69    // Create raw image
   70
   71    hexedit(mem, 256);
   72    hexdump(mem, 64);
   73
   74    // Execute the binary
   75
   76    fp = (int (*)(void)) mem;
   77    printf("Are you sure? (y/n) ");
   78    p = fgets(msg, sizeof(msg), stdin);
   79    if (strcmp(p, "y?n") == 0 || strcmp(p, "Y") == 0) {
   80      res = (*fp)();
   81      printf("Result is %08X?n", res);
   82     }
   83
   84    return 0;
   85   }

見てお分かりの通り、hexedit.c と、大して変わらない。変わったのは、main 関数が加わったぐらいだが、こちらも22行というコンパクトさ。「こんなもので、ホンマに入力したプログラムが実行できるんかいな?」という気もするが、これが出来るんです。

mon.c の心臓部は、80行にある。67行で関数ポインター変数である fp を定義し、76行で同変数にメモリーバッファーの先頭アドレスを設定している。この結果、Cコンパイラーは、メモリーバッファー上に int fp(void) 関数が位置していると解釈し、健気にも fp 関数を呼び出すコードを出力する。

このように、キャスト演算子は馬鹿正直なCコンパイラーを「騙す」ための仕組みだと言っても、過言ではない。一方で、素直に「騙されてくれる」Cコンパイラーが、私は大好きである。

関数の正体

それでは、出来上がったモニタープログラムの試験運転を兼ねて、C言語における関数の正体を探ってみよう。アセンブリ言語で記述した、世界一簡単な関数プログラム(ret.asm)は次のようになる。

    1  bits    32
    2
    3  function:
    4          ret

3行目は、関数エントリーアドレスを function というシンボルで定義するためのものであり、実際には必要ない。大切なのは、4行目の ret 命令である。ret 命令の正体については、GCC プログラミング工房の octopus 編で詳細に解説しているので、そちらを参照してほしい。手短に説明すると、ret は呼び出し元がスタック上に push した戻り番地を pop し、そのアドレスにジャンプするための命令である。ともかく、アセンブルしてみよう。

$ nasm ret.asm
$ wc -c ret
      1 ret
$ hexdump -C ret
00000000  c3                                                |.|
00000001

NASM はオプションなしでアセンブルを行うと、バイナリー型式でアセンブル結果を出力する(この時、bits 32 を忘れると、16 ビットモードを前提にしたプレフィックスが前置されてしまう)。アセンブル後に生成された ret のサイズは、驚くなかれ1バイトである。思わず、「何かの間違いではないんかい?」という気もするが、NASM は正しい。ret アセンブリ命令は、機械語に翻訳すると C3 になるのである。ret はあくまでも、人間様のための表記に過ぎず、x86 にとっては C3 が自然言語なのだ。

そうは言っても、狐につままれたような感じを覚えておられる方も多いことだろうから、モニタープログラムで確認してみよう。

$ ./mon
0000: 00 = c3
0001: 00 =       <--- コントロールDで入力終了

0000: C3 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
0010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
0020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 

Are you sure? (y/n) y
Result is 080498E0

mon プログラムを起動し、C3 を入力する。その上で、「ホンマにええんか?」という最後通告に対して、潔く「y」と答えてほしい。私の場合は、Result is 080498E0 と表示されたが、実行環境によってこの値は異なるはずだ。この謎の数字には、一体どういう意味が隠されているのだろうか?

種明かしをしてしまうと、080498E0 に深い意味はない。x86-GCC において、関数の戻り値は、EAX レジスターに設定される決まりになっている。にもかかわらず、私達が入力した機械語関数は ret 命令ひとつだけで構成されており、EAX レジスターの設定は行っていなかった。このため、mon.c 中の80行を実行する直前の EAX レジスター値が「返された」ように見えたのである。

それでは、C言語でいうところの return 0x12345678; を機械語でプログラムしてみよう(eax_ret.asm)。

    1  bits    32
    2
    3  function:
    4          mov     eax, 0x12345678         ; EAX = 0x12345678
    5          ret

4行目で、EAX レジスターへの転送命令を追加した。この命令により、EAX レジスターに 0x12345678 がセットされた上で、呼び出し元への復帰が行われるはずだ。アセンブルして、その機械語を確認してみよう。

$ nasm eax_ret.asm 
$ wc -c eax_ret
      6 eax_ret
$ hexdump -C eax_ret
00000000  b8 78 56 34 12 c3                         |.xV4..|
00000006

C3 の他に、新たに5バイトが出現した。先頭の B8 が、EAX レジスタへの即値転送を意味する mov 命令であり、続く4バイトは Little-endian で格納した 0x12345678 である。それでは、この6バイトをモニタープログラムに食べさせてみよう。一体、どんな結果が我々を待ち受けているのであろうか?

$ ./mon
0000: 00 = b8
0001: 00 = 78
0002: 00 = 56
0003: 00 = 34
0004: 00 = 12
0005: 00 = c3
0006: 00 = 

0000: B8 78 56 34 12 C3 00 00 00 00 00 00 00 00 00 00 
0010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
0020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 

Are you sure? (y/n) y
Result is 12345678

なんてこったい、本当に 0x12345678 が手元に返ってきてしまったよ!機械語に馴染みの薄い方は、目の前で一体何が起きているのか、今ひとつ実感が湧かないかもしれない。しかし、今や私達は実行可能ファイルという抽象的なものを一切介さず、1バイト単位のバイナリーデータで x86-CPU を直接制御しているのである。この「人馬一体」となった得も言われぬ感覚は、スクリプト言語はもちろん、コンパイラーを通して体感することは決してできないものだ。

システムコール呼び出し

C言語関数の正体が掴めたところで、システムコール呼び出しに挑戦してみよう。そこで、前回紹介した exit システムコールをモニター上から実行してみる。このために、exit システムコールと ret 命令を組み合わせた、exit_ret.asm を用意してほしい。

    1  bits    32
    2
    3          mov     eax, 1
    4          mov     ebx, 123
    5          int     0x80
    6          ret                ; No one reaches here!

次にアセンブル。

$ nasm exit_ret.asm 
$ wc -c exit_ret
     13 exit_ret
$ hexdump -C exit_ret
00000000  b8 01 00 00 00 bb 7b 00  00 00 cd 80 c3 |......{......|
0000000d

少々長くなってきたので、注意深く入力しよう。1バイトでも間違えれば、即プログラムは暴走する(と言っても、堅牢な Linux 上であれば、Segmentation fault が発生するだけなのでご心配なく)。

$ ./mon ; echo $?
0000: 00 = b8
0001: 00 = 1
0002: 00 = 0
0003: 00 = 0
0004: 00 = 0
0005: 00 = bb
0006: 00 = 7b
0007: 00 = 0
0008: 00 = 0
0009: 00 = 0
000A: 00 = cd
000B: 00 = 80
000C: 00 = c3
000D: 00 = 

0000: B8 01 00 00 00 BB 7B 00 00 00 CD 80 C3 00 00 00 
0010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
0020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
0030: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 

Are you sure? (y/n) y
123

mon プロセスは、私達が発行した eixt システムコールの結果終了し、シェルには 123 が返されている。exit_ret.asm の6行目には、ret 命令が残されていたが、実は必要ないことが分かる。「誰も到達できない」とは、こういう意味だったのだ。

おわりに

比較的単純なCプログラムを使うことで、昔懐かしのモニタープログラムもどきを Linux 上で実行することが可能になった。mon.c に拡張を施し、アセンブルコマンドや逆アセンブルコマンドを実装することは、それほど難しいことではないだろう。

Cコンパイラーを使っている限り、実行可能ファイル(ELF)の壁を超えることは出来ない。Hello, world! 物語で明らかになったように、UNIX 上のプログラムの本質は極めて単純であり、わずか10数バイトでその豊かな世界を楽しむことができる。にもかかわらず、普段は鬱蒼と茂った葉が私達の行く手を遮っているのである。

機械語を知らずとも、アプリケーションを作成することは出来るだろう。同様に、私達は母国語である日本語だけで暮らすことは出来る。しかし、英語で情報を入手できれば、回りの世界は一気に広がることだろう。機械語もまた、より楽しく、より深いプログラミングを満喫するためには、不可欠の伴侶であると、私は信じている。

なぜ、mon.c は保護違反にならないのか?

x86 の保護機構の知識がある方々は、どうしてこのような危険極まりないコードが Linux 上で実行できてしまうのか、不思議に思っておられることだろう。この問いに対する明解な答えは、近永さんが Brainstorm 上の「メモリーパーミッション」で示されているので、是非参照してほしい。