あなたが知らないプログラムの真の始まり――main()関数の前にあるスタートアップとは:main()関数の前には何があるのか(8)(3/3 ページ)
C言語の「Hello World!」プログラムで使われる、「printf()」「main()」関数の中身を、デバッガによる解析と逆アセンブル、ソースコード読解などのさまざまな側面から探る連載。今回は、main()の中身や、その前に呼び出されるスタートアップという初期化処理について学ぶ。
スタートアップのソースコードを読む
C言語では、実際にはmain()が呼ばれる前に様々な設定処理が行われている。これは「スタートアップ」と呼ばれる部分だ。
ではmain()の前にあるスタートアップでは、いったい何が行われているのだろうか?スタートアップのソースコードを見てみよう。
スタートアップの役割
実際にはスタートアップは標準Cライブラリによって提供されているため、一般プログラマは意識する必要は無い。
冒頭で「プログラムの実行はmain()から開始される」というのは嘘である、とは説明した。しかしユーザ・プログラマにとっては、これは嘘ではなく正しいと言えるだろう。要は目線の問題であり、一般プログラマとして見ているか、システム開発者として見ているかの違いだ。
スタートアップでは、何が必要だろうか。プログラムが動作するための各種レジスタの設定、ライブラリの初期化などが思いつく。
しかしいくつかの設定は、カーネルによってすでに行われているものもある。
例えばプロセスを持たずにタスクレスで動作する組込みソフトウェアでは、スタートアップでスタックポインタの設定を行わないと、そもそも関数呼び出しを行うことができない。またデータ領域やBSS領域の初期化を行わないと、静的変数を正常に読み書きできないことも考えられる。
しかしLinuxのようなプロセスモデルの汎用OSでは、これらの設定はカーネルによって行われている可能性が高いだろう。たとえば実行時に渡されるargv[]の文字列の本体がスタック上に置かれているような構成ならば、スタックポインタはカーネル側で初期化すべきだ。
スタートアップでどのような設定が行われているのかを知りたければ、スタートアップのソースコードを読むことがもっとも近道だ。そしてスタートアップは標準Cライブラリの役割であり、GNU/Linuxディストリビューションの場合はglibcによって提供される。
つまりglibcのソースコード中から探せばいいわけだ。
glibcのソースコードを読む
glibcが持っているシステムコール・ラッパーの実装を読む話のときに、標準Cライブラリが持っている機能の中で最も重要なものはprintf()やstrcpy()などではなく、システムコール・ラッパーだという話をした。
理由はシステムコール・ラッパーが、C言語とアセンブラの橋渡しをする部分だからだ。つまりC言語のユーザ・プログラマがC言語の知識だけでは実装できない未知の部分をあらかじめ用意しているということだ。
これと同じことが、スタートアップについても言えることだろう。スタートアップは各種レジスタ設定が必要となるため、基本としてアセンブラで記述する。そしてその中から各種のサービス関数やmain()などのC言語の関数を、関数呼び出しによりコールする。
つまりC言語のプログラマがC言語の範囲だけでプログラミングできるようにするために、スタートアップは標準Cライブラリ側での提供が絶対に必要な部分ということになる。
そしてスタートアップの処理が標準Cライブラリとして提供されるということは、そのソースコードはglibcにあるはずだ。
ということでglibcの中を探してみよう。まずは検索のキーワードだが、__libc_start_main()という関数からmain()が呼ばれていることがわかっている。これをキーワードにして調べてみよう。
[user@localhost glibc-2.21]$ grep -r __libc_start_main . | wc -l 137 [user@localhost glibc-2.21]$
137件ならば、目視で判別できる分量だ。lessに入力して__libc_start_main()に関わる重要そうな部分を探そう。目視で判別するときのコツは、lessで検索しなおして反転表示させることと、重要な部分は左側にあるので左側を注意してみる、ということだ。
すると、以下の2箇所が気にかかる。sysdeps以下の様々なアーキテクチャ向けのファイルもヒットしているようだが、x86以外は無視していいだろう。
... ./sysdeps/i386/start.S: later in __libc_start_main. */ ./sysdeps/i386/start.S: call __libc_start_main@PLT ./sysdeps/i386/start.S: call __libc_start_main ... ./csu/libc-start.c:# define LIBC_START_MAIN __libc_start_main ...
まずsysdeps/i386/start.Sなのだが、これはcall命令で__libc_start_main()を呼び出しているようなアセンブラのファイルであり、名前もstart.Sであるから、これがエントリ・ポイントの_startの定義である可能性が高い。スタートアップはアセンブラで書く必要があるから、アーキテクチャ依存になる。よってi386のようなアーキテクチャ依存のディレクトリに置かれていることも納得できる。
そしてcsu/libc-start.cで「LIBC_START_MAIN」に#defineしているのが気にかかる。「LIBC_START_MAIN」という名前で何らかの定義が行われていないか、調べる必要があるだろう。ヘッダファイルではなくC言語ファイルで定義されているので、調べるのはこのファイルに閉じた範囲でよさそうだ。
_startを読む
まずは順番として、_startから調べてみよう。
sysdeps/i386/start.Sの当該箇所を見ると、以下のようになっている。
55: .text 56: .globl _start 57: .type _start,@function 58:_start: 59: /* Clear the frame pointer. The ABI suggests this be done, to mark 60: the outermost frame obviously. */ 61: xorl %ebp, %ebp 62: 63: /* Extract the arguments as encoded on the stack and set up 64: the arguments for `main': argc, argv. envp will be determined 65: later in __libc_start_main. */ 66: popl %esi /* Pop the argument count. */ 67: movl %esp, %ecx /* argv starts just at the current stack top.*/ ... 84:#ifdef SHARED ... 103:#else 104: /* Push address of our own entry points to .fini and .init. */ 105: pushl $__libc_csu_fini 106: pushl $__libc_csu_init 107: 108: pushl %ecx /* Push second argument: argv. */ 109: pushl %esi /* Push first argument: argc. */ 110: 111: pushl $main 112: 113: /* Call the user's main function, and exit with its value. 114: But let the libc call main. */ 115: call __libc_start_main 116:#endif 117: 118: hlt /* Crash if somehow `exit' does return. */ ...
まさに_startの定義のようだ。ここがエントリ・ポイントであり、プログラムの実行開始時の一番初めに実行される部分だ。アーキテクチャ依存の処理になるのでファイルはsysdeps/i386というx86依存のディレクトリに置かれており、アセンブラで書かれている。
気になるのは__libc_start_main()の呼び出し前にmain()の第1引数であるargcとargvをスタックに積んでいるが、第3引数であるenvpはスタックに積んでいないということだ。その先の__libc_start_main()で積まれるのだろうか。
また_startの先頭ではスタックポインタの初期化を行わずに、いきなりpop命令によってスタックを使っている。ということは、スタックポインタはOSカーネルによって設定された状態でプロセスが起動するようだ。
pop命令の直後にはESPをECXにコピーしており、コメントを読むとどうやらargv[]の配列はスタック上に置かれた状態でプログラムが起動するらしい。よってスタックポインタであるESPの指す先がargv[]の先頭となっており、それをECXに設定しているわけだ。
argv[]がスタック上にあることを確認してみよう。
[user@localhost hello]$ gdb -q hello Reading symbols from /home/user/hello/hello...done. (gdb) break main Breakpoint 1 at 0x80482c5: file hello.c, line 5. (gdb) run Starting program: /home/user/hello/hello Breakpoint 1, main (argc=1, argv=0xbffff744) at hello.c:5 5 printf("Hello World! %d %s\n", argc, argv[0]); (gdb) print argv[0] $1 = 0xbffff87a "/home/user/hello/hello" (gdb) print argv $2 = (char **) 0xbffff744 (gdb) print/x $esp $3 = 0xbffff680 (gdb)
ESPの直後にargv[]があり、さらにその直後にargv[0]の文字列がある。よってargv[]の配列はスタック上にあり、さらにargv[0]の実体である文字列はその直後に置かれている、ということのようだ。
__libc_start_main()を読む
次にLIBC_START_MAINについて調べてみよう。
これはsysdeps/i386のようなアーキテクチャ依存のディレクトリではなく、csu/libc-start.cという共通処理の部分に置かれている。つまり、アーキテクチャ非依存の共通処理のようだ。
そして当該の箇所は、以下のようになっていた。どうやら__libc_start_main()の関数定義になっているらしい。
96:# define LIBC_START_MAIN __libc_start_main ... 122:/* Note: the fini parameter is ignored here for shared library. It 123: is registered with __cxa_atexit. This had the disadvantage that 124: finalizers were called in more than one place. */ 125:STATIC int 126:LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL), 127: int argc, char **argv, 128:#ifdef LIBC_START_MAIN_AUXVEC_ARG 129: ElfW(auxv_t) *auxvec, 130:#endif 131: __typeof (main) init, 132: void (*fini) (void), 133: void (*rtld_fini) (void), void *stack_end) 134:{ 135: /* Result of the 'main' function. */ 136: int result; ... 141: char **ev = &argv[argc + 1]; 142: 143: __environ = ev; ... 244: if (init) 245: (*init) (argc, argv, __environ MAIN_AUXVEC_PARAM); ... 271:#ifdef HAVE_CLEANUP_JMP_BUF ... 288: /* Run the program. */ 289: result = main (argc, argv, __environ MAIN_AUXVEC_PARAM); ... 318:#else 319: /* Nothing fancy, just call the function. */ 320: result = main (argc, argv, __environ MAIN_AUXVEC_PARAM); 321:#endif 322: 323: exit (result); 324:}
実際には#ifdef SHAREDや#ifndef SHAREDでいろいろ括られている部分があるが、SHAREDは共有ライブラリの場合に定義されるので、glibcが静的ライブラリ版と共有ライブラリ版でビルドされたときの動作に違いがあるようだ。
見るとmain()の呼び出しがHAVE_CLEANUP_JMP_BUFという定義で分けられて2箇所にあるのだが、どちらも同じように呼び出しているようだ。main()には引数としてargcとargvを渡していることも確認できる。そしてmain()の第3引数に環境変数を渡すための処理も行われており、実は環境変数の配列は&argv[argc + 1]の位置から取得しているため、argv[]の直後に置かれているということがわかる。
またmain()からの戻り値をそのまま引数として渡すことでexit()を呼んでいることもわかる。つまりmain()の終端は、終了コードをexit()に引数として渡して呼び出してもいいし、終了コードをreturnで戻してもいいわけだ。
さらに、このファイルが置かれている「csu」というディレクトリ名にも注目したい。
「csu」はおそらく「C Start Up」の略で、スタートアップを指すときによく用いられる略語だ。他にもスタートアップには「crt」(C RunTime)などの略語が使われることもある(「crt」はその言葉の意味からして、まさにランタイムライブラリを指すこともあるが、「C RunTime startup」としてスタートアップの略語としても用いられるようだ)。
例えばFreeBSDの標準Cライブラリは以下のようなファイルを持っており、これがスタートアップ部分のようだ。glibcの実装と比較してみても面白いだろう。
[user@localhost ~]$ find FreeBSD-9.3/usr/src/lib -name "*crt*" | grep i386 FreeBSD-9.3/usr/src/lib/csu/i386-elf/crt1_c.c FreeBSD-9.3/usr/src/lib/csu/i386-elf/crti.S FreeBSD-9.3/usr/src/lib/csu/i386-elf/crt1_s.S FreeBSD-9.3/usr/src/lib/csu/i386-elf/crtn.S [user@localhost ~]$
書籍紹介
ハロー“Hello, World” OSと標準ライブラリのシゴトとしくみ
坂井弘亮著
秀和システム 3,200円
C言語の入門書では、"Hello, World"と出力するプログラムを最初に作るのが定番です。"Hello, World"は、たった7行の単純なプログラムですが、printf()の先では何が行われているのか、main()の前にはいったい何があるのか、考えてみると謎だらけです。本書は、基礎中の基礎である"Hello, World"プログラムを元に、OSと標準ライブラリの仕組みをあらゆる角度からとことん解析します。資料に頼らず、自分の手で調べる方法がわかります。
Copyright © ITmedia, Inc. All Rights Reserved.
関連記事
- プログラミング言語Cについて知ろう
プログラミング言語の基本となる「C」。正しい文法や作法を身に付けよう。Cには確かに学ぶだけの価値がある(編集部) - シェルコード解析に必携の「5つ道具」
コンピュータウイルスの解析などに欠かせないリバースエンジニアリング技術ですが、何だか難しそうだな、という印象を抱いている人も多いのではないでしょうか。この連載では、「シェルコード」を例に、実践形式でその基礎を紹介していきます。(編集部) - 【 od 】コマンド――ファイルを8進数や16進数でダンプする
本連載は、Linuxのコマンドについて、基本書式からオプション、具体的な実行例までを紹介していきます。今回は、「od」コマンドです。