Categories Books | Hard | Hardware | Linux | MCU | Misc | Publish | Radio | Repository | Thoughts | Time | UNIX | Writing | プロフィール
昨日の mon.c はコードの分かりやすさを優先させた結果、「アドレス」という概念が欠けている。このため、分岐命令の実行や、文字列の開始アドレスをレジスターにセットすることが出来ないなど、致命的欠点が残っており、タイトルにある 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 に展開される。この結果、文字列を変更しても、ソースオペランドを修正する必要がなくなるのである。アセンブリ言語では、頻用されるテクニックのひとつなので、覚えておかれると良いだろう。
以上まとめると、
をセットし、ソフトウェア割り込み 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 を作成してみよう。
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 に変わっている(使用環境によって値は異なる可能性がある)。以上で、モニタープログラム側の準備は完了した。
残りは、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_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 になっていた、アセンブリ言語と機械語の溝を埋め尽くしてくれることだろう。