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

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


2004-02-29 (Sun)

[Writing] Hello, world! 物語・最終回

アドレスの重要性

昨日の mon.c はコードの分かりやすさを優先させた結果、「アドレス」という概念が欠けている。このため、分岐命令の実行や、文字列の開始アドレスをレジスターにセットすることが出来ないなど、致命的欠点が残っており、タイトルにある Hello, world! の機械語版を実行することは出来ない。

アドレスは機械語はもとより、高級言語を理解する上で、極めて重要な役割を占める。幸い今日は休日でもあるし、アドレスの世界を探訪してみよう。

アセンブリ言語版 Hello, world!

まず、Hello, world! をアセンブリ言語で書き下ろしてみよう。そのプログラム(hello.asm)は次のようになる。

    1  bits    32
    2
    3          mov     eax, 4          ; EAX = 4 (write system call#)
    4          mov     ebx, 1          ; EBX = 1 (fd: stdout)
    5          mov     ecx, msg        ; ECX = buf (buffer address)
    6          mov     edx, end - msg  ; EDX = len (buffer length)
    7          int     0x80            ; Execute system call
    8          ret                     ; Return to caller
    9  msg:
   10          db      'Hello, world!' ; Message string
   11          db      10              ; New line
   12  end:
   13          db      0               ; Null terminator

連載を通じて何度も説明してきた通り、printf ライブラリー関数の正体はファイルディスクリプター1番(標準出力)への write システムコールである。Linux における write システムコール番号は4番であり、その SYNOPSIS は次のようになっている(man 2 write)。

      ssize_t write(int fd, const void *buf, size_t count);

引数は3つ必要であり、先頭がファイルディスクリプター番号、2番目が出力文字列のバッファー開始アドレス、3番目が文字列長である。

C言語であれば、2,3番目の引数の設定は write(1, msg, strlen(msg)); という具合に簡単に記述できるが、アセンブリ言語ではそうはいかない。hello.asm では、9行目で msg シンボルを定義し、10行目以降に文字列データを db 命令(Define Byte)で割り当てている。12行目の end シンボルは、文字列長を計算するために定義しており、6行目で end - msg をアセンブラーが評価することで、mov 命令のソースオペランドは 14 に展開される。この結果、文字列を変更しても、ソースオペランドを修正する必要がなくなるのである。アセンブリ言語では、頻用されるテクニックのひとつなので、覚えておかれると良いだろう。

以上まとめると、

  • EAX レジスターに、システムコール番号4番
  • EBX レジスターに、ファイルディスクリプター番号1番
  • ECX レジスターに、出力文字列の先頭アドレス(msg シンボルで代用)
  • EDX レジスターに、文字列長14

をセットし、ソフトウェア割り込み 0x80 を実行すれば、見事 Hello, world! が標準出力に出現するはずである。アセンブルして、機械語を確かめてみよう。

$ nasm hello.asm
$ wc -c hello    
     38 hello
$ hexdump -C hello
00000000  b8 04 00 00 00 bb 01 00  00 00 b9 17 00 00 00 ba  |................|
00000010  0e 00 00 00 cd 80 c3 48  65 6c 6c 6f 2c 20 77 6f  |.......Hello, wo|
00000020  72 6c 64 21 0a 00                                 |rld!..|
00000026

計38バイトのプログラムが出力された。しかし、残念ながらこのダンプリストをしこしこと打ち込み実行しても、Hello, world! は現れない。なぜか?

その答えを知るためには、逆アセンブラで実際に出力されたコードの内容を確認する必要がある。

$ objdump -b binary -m i386 -D hello

hello:     file format binary

Disassembly of section .data:

00000000 <.data>:
   0:   b8 04 00 00 00          mov    $0x4,%eax
   5:   bb 01 00 00 00          mov    $0x1,%ebx
   a:   b9 17 00 00 00          mov    $0x17,%ecx   <--- ここに注目!
   f:   ba 0e 00 00 00          mov    $0xe,%edx
  14:   cd 80                   int    $0x80
  16:   c3                      ret    
  17:   48                      dec    %eax
  18:   65                      gs
  19:   6c                      insb   (%dx),%es:(%edi)
  1a:   6c                      insb   (%dx),%es:(%edi)
  1b:   6f                      outsl  %ds:(%esi),(%dx)
  1c:   2c 20                   sub    $0x20,%al
  1e:   77 6f                   ja     0x8f
  20:   72 6c                   jb     0x8e
  22:   64 21 0a                and    %ecx,%fs:(%edx)
        ...

0x17 番地以降の奇妙な逆アセンブルリストは、objdump が "Hello, world!?n" 文字列を、誤って実行コードとして解釈したものである。ここで、0x0A 番地の内容に注目してほしい。mov $0x17,%ecx とあるが(binutils はインテル型式のため、NASM とはオペランドの位置が逆転していることに注意)、これは文字列の開始アドレスが 0x17 番地であることを意味している。しかし、Linux においては、プロセスの実行コードやデータは 0x08048000 番地以降に置かれる決まりになっているのである。この事実は、次の簡単なプログラム(address.c)で確認することができる。

    1  #include <stdio.h>
    2
    3  char msg[] = "Hello, world!?n";
    4
    5  int main() {
    6    printf("main() locates @ %08X?n", (int) main);
    7    printf(" msg[] locates @ %08X?n", (int) msg);
    8    printf("%s?n", msg);
    9    return 0;
   10   }

main 関数のエントリーアドレスと、msg 配列の開始アドレスを出力している。実際に、実行してみると・・

$ gcc -Wall address.c 
$ ./a.out 
main() locates @ 08048344
 msg[] locates @ 08049438
Hello, world!
$

確かに main 実行コードおよび msg データは 0x08048000 以降に配置されており、0x17 番地には何も存在しないことが分かる。hello を入力・実行しても、何も表示されなかった訳だ。ちなみに、0x08048000 という magic number の由来が気になっておられる方は、次のコマンドを実行してみてほしい。

$ ld --verbose | head -n 20
GNU ld version 2.14 20030612
  Supported emulations:
   elf_i386
   i386linux
using internal linker script:
==================================================
/* Script for -z combreloc: combine and sort reloc sections */
OUTPUT_FORMAT("elf32-i386", "elf32-i386",
             "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)
SEARCH_DIR("/usr/local/gnu/i686-pc-linux-gnu/lib"); SEARCH_DIR("/usr/local/gnu/lib"); SEARCH_DIR("/usr/local/lib"); SEARCH_DIR("/lib"); SEARCH_DIR("/usr/lib");
/* Do we need any of these for elf?
   __DYNAMIC = 0;    */
SECTIONS
{
  /* Read-only sections, merged into text segment: */
  . = 0x08048000 + SIZEOF_HEADERS;     <--- 注目!
  .interp         : { *(.interp) }
  .hash           : { *(.hash) }

ld --verbose は、デフォルトの ELF リンカースクリプトを表示するためのコマンドであり、この中に問題の magic number が見える。リンカースクリプトは、リンカーローダー ld を制御するためのスクリプト言語であり、その詳細については GCC プログラミング工房で解説しているので、興味のある方は参照してほしい。

アドレス対応版 mon2.c

それでは、アドレス管理機能を付加した mon2.c を作成してみよう。

    1  //
    2  // m o n 2 . 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  byte* start_add;                // Program start address
   13
   14  void hexedit(byte* buf, int size) {
   15    int idx = 0, res, val;
   16    char *p, msg[ 256 ];
   17
   18    // Read data
   19
   20    while (1) {
   21      printf("%08X: %02X = ", (int) &buf[ idx ], buf[ idx ]);
   22      p = fgets(msg, size, stdin);
   23      if (p == NULL)
   24        break;
   25      else if (strcmp(p, "-?n") == 0) {
   26        idx = (idx > 0) ? idx - 1 : 0;
   27        continue;
   28       }
   29      else if (strcmp(p, "?n")) {
   30        res = sscanf(msg, "%x", &val);
   31        if (res == 1)
   32          buf[ idx ] = val;
   33        else {
   34          printf("Illegal data, please try again!?n");
   35          continue;
   36         }
   37       }
   38      idx = (idx < (size - 1)) ? idx + 1 : size - 1;
   39     }
   40    printf("?n?n");
   41   }
   42
   43  void hexdump(byte* buf, int len) {
   44    int idx = 0, i;
   45
   46    // Dump data
   47
   48    while (1) {
   49      printf("%08X: ", (int) &buf[ idx ]);
   50      for (i = 0; i < 16; i++)
   51        printf("%02X ", buf[ idx + i ]);
   52      printf("?n");
   53      idx += 16;
   54      if (idx >= len) break;
   55     }
   56    printf("?n");
   57   }
   58
   59  int main() {
   60    int res;
   61    char msg[ 256 ], *p;
   62    int (*fp)(void);              // Function pointer
   63
   64    // Align start address
   65
   66    start_add = (byte*) (((int) mem + 15) & 0xFFFFFFF0);
   67
   68    // Our monitor starts ...
   69
   70    while (1) {
   71      // Create raw image
   72
   73      hexedit(start_add, 64);
   74      hexdump(start_add, 64);
   75
   76      // Execute the binary
   77
   78      fp = (int (*)(void)) start_add;
   79      printf("Are you sure? (y/n) ");
   80      p = fgets(msg, sizeof(msg), stdin);
   81      if (strcmp(p, "y?n") == 0 || strcmp(p, "Y") == 0) {
   82        res = (*fp)();
   83        printf("Result is %08X?n?n", res);
   84       }
   85     }
   86
   87    return 0;
   88   }

と言っても、大きく変わった訳ではない。12,66行の追加、および21,49,78行の変更ぐらいである。その内容については割愛するので、各自チェックしてほしい(66行は16バイト境界に割り当てるための常套句)。それでは、実行してみよう。

$ gcc -Wall -o mon2 mon2.c
$ ./mon2
08049900: 00 = 

mon.c ではアドレスがゼロ番地からスタートしていたが、mon2.c では 0x8049900 に変わっている(使用環境によって値は異なる可能性がある)。以上で、モニタープログラム側の準備は完了した。

org 命令

残りは、hello.asm プログラムの開始アドレスを 0x08049900 番地に変更するのみである。このためには、org 命令を使う。開始アドレスを変更した hello_org.asm は次のようになる。

    1  bits    32
    2          org     0x8049900       ; Program entry address
    3
    4          mov     eax, 4          ; EAX = 4 (write system call#)
    5          mov     ebx, 1          ; EBX = 1 (fd: stdout)
    6          mov     ecx, msg        ; ECX = buf (buffer address)
    7          mov     edx, end - msg  ; EDX = len (buffer length)
    8          int     0x80            ; Execute system call
    9          ret                     ; Return to caller
   10  msg:
   11          db      'Hello, world!' ; Message string
   12          db      10              ; New line
   13  end:
   14          db      0               ; Null terminator

修正点は、2行目の org 命令のみである。それでは、その効果をアセンブル&逆アセンブルで確認してみよう。

$ nasm hello_org.asm
$ objdump -b binary -m i386 -D hello_org

hello_org:     file format binary

Disassembly of section .data:

00000000 <.data>:
  0:   b8 04 00 00 00          mov    $0x4,%eax
  5:   bb 01 00 00 00          mov    $0x1,%ebx
  a:   b9 17 99 04 08          mov    $0x8049917,%ecx   <--- 注目!
  f:   ba 0e 00 00 00          mov    $0xe,%edx
 14:   cd 80                   int    $0x80
 16:   c3                      ret    
 17:   48                      dec    %eax
 18:   65                      gs
 19:   6c                      insb   (%dx),%es:(%edi)
 1a:   6c                      insb   (%dx),%es:(%edi)
 1b:   6f                      outsl  %ds:(%esi),(%dx)
 1c:   2c 20                   sub    $0x20,%al
 1e:   77 6f                   ja     0x8f
 20:   72 6c                   jb     0x8e
 22:   64 21 0a                and    %ecx,%fs:(%edx)
       ...

ECX レジスターへの設定値が 0x8049917 に変化している!「俺って、天才じゃん」と、思わずニヤけてしまいそうだが、何かがおかしい。その不自然さは、逆アセンブルリスト中のアドレス表示にある。何と、ゼロ番地から始まっているではないか・・涙;

「org 命令の嘘つき〜〜!」と大空に向かって叫びたい気持ちは、よく分かるが、ここは冷静に考えてみてほしい。バイナリー型式で出力された hello_org には、ELF 型式とは異なり、実行コードや文字列以外の余計なデータは一切添付されていない。ということは、プログラムの開始アドレスに関する情報も、hello_org には記載されていないことになる。

そう、途方に暮れた objdump はプログラムの開始アドレスが分からないために、とりあえず無難な「ゼロ番地」をその開始アドレスとしたのである。となれば、必ずや「開始アドレスをマニュアル指定」するための、オプション命令が objdump に用意されているに違いない。

実際、その通りで、--adjust-vma が目指すオプションである。さすがは、世界に冠たる GNU 開発ツール。痒いところに手が届くとは、まさにこのことだ。早速、有り難いオプションを活用してみよう。

$ objdump -b binary -m i386 --adjust-vma=0x8049900 -D hello_org

hello_org:     file format binary

Disassembly of section .data:

08049900 <.data>:         <--- ナイス!!
 8049900:      b8 04 00 00 00          mov    $0x4,%eax
 8049905:      bb 01 00 00 00          mov    $0x1,%ebx
 804990a:      b9 17 99 04 08          mov    $0x8049917,%ecx
 804990f:      ba 0e 00 00 00          mov    $0xe,%edx
 8049914:      cd 80                   int    $0x80
 8049916:      c3                      ret    
 8049917:      48                      dec    %eax   <--- Hello, wordl! 格納開始アドレス
 8049918:      65                      gs
 8049919:      6c                      insb   (%dx),%es:(%edi)
 804991a:      6c                      insb   (%dx),%es:(%edi)
 804991b:      6f                      outsl  %ds:(%esi),(%dx)
 804991c:      2c 20                   sub    $0x20,%al
 804991e:      77 6f                   ja     0x804998f
 8049920:      72 6c                   jb     0x804998e
 8049922:      64 21 0a                and    %ecx,%fs:(%edx)
       ...

見事である。このように、binutils はプロフェッショナルのための「道具」として、ずば抜けた資質を兼ね備えている。その隠された能力を引き出すことができるかどうかは、ひとえにユーザーの腕にかかっていると言えるだろう。

機械語版 Hello, world!

それでは、手塩にかけて育て上げた我らが hello_org 嬢の美しい姿を、この目で見届けよう。

$ hexdump -C hello_org
00000000  b8 04 00 00 00 bb 01 00  00 00 b9 17 99 04 08 ba  |................|
00000010  0e 00 00 00 cd 80 c3 48  65 6c 6c 6f 2c 20 77 6f  |.......Hello, wo|
00000020  72 6c 64 21 0a 00                                 |rld!..|
00000026
$ ./mon2
08049900: 00 = b8
08049901: 00 = 4
08049902: 00 = 0
08049903: 00 = 0
08049904: 00 = 0
08049905: 00 = bb
08049906: 00 = 1
08049907: 00 = 0
08049908: 00 = 0
08049909: 00 = 0
0804990A: 00 = b9
0804990B: 00 = 17
0804990C: 00 = 99
0804990D: 00 = 4
0804990E: 00 = 8
0804990F: 00 = ba
08049910: 00 = e
08049911: 00 = 0
08049912: 00 = 0
08049913: 00 = 0
08049914: 00 = cd
08049915: 00 = 80
08049916: 00 = c3
08049917: 00 = 48
08049918: 00 = 65
08049919: 00 = 6c
0804991A: 00 = 6c
0804991B: 00 = 6f
0804991C: 00 = 2c
0804991D: 00 = 20
0804991E: 00 = 77
0804991F: 00 = 6f
08049920: 00 = 72
08049921: 00 = 6c
08049922: 00 = 64
08049923: 00 = 21
08049924: 00 = 0a
08049925: 00 = 0
08049926: 00 = 

08049900: B8 04 00 00 00 BB 01 00 00 00 B9 17 99 04 08 BA 
08049910: 0E 00 00 00 CD 80 C3 48 65 6C 6C 6F 2C 20 77 6F 
08049920: 72 6C 64 21 0A 00 00 00 00 00 00 00 00 00 00 00 
08049930: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 

Are you sure? (y/n) y
Hello, world!            <--- !!!
Result is 0000000E

08049900: B8 = 

38バイトにもなると少々大変だが、おじさん達は紅顔の美少年時代を思い出しながら、お兄さん達は古き良き時代を空想しながら、ひたすら打ち込んでほしい。「ホンマに嫁にやっても良いのだな?」との問いに答えれば、画面上に Hello, world! が出現するはずだ。

旅立ち

4回にわたる Web 上の連載を通じて、私達は Hello, world! プログラムの本質を38バイトにまで絞り込むことができた。実行可能ファイル型式 ELF が介在しない、モニターの世界はシンプルそのものであり、コードの生の姿を観察するには最適である。

しかし、この先に進むには、障壁が多い。高機能な Linux カーネルが災いし、CPU の本質を探る道は、ほとんど閉ざされてしまっているからだ。Linux カーネル、x86-CPU に代わる、何らかの導き手が私達には必要だろう。

ということで、この数年間、日々導き手を求めてネット上を放浪してきた。そして今年、ようやく目指すものに邂逅できたような気がする。最終的な下調べを行った後に、皆さんにご紹介できればと思う。彼(彼女?)は、これまでの連載において Missing-link になっていた、アセンブリ言語と機械語の溝を埋め尽くしてくれることだろう。