あなたが知らないプログラムの真の始まり――main()関数の前にあるスタートアップとは:main()関数の前には何があるのか(8)(2/3 ページ)
C言語の「Hello World!」プログラムで使われる、「printf()」「main()」関数の中身を、デバッガによる解析と逆アセンブル、ソースコード読解などのさまざまな側面から探る連載。今回は、main()の中身や、その前に呼び出されるスタートアップという初期化処理について学ぶ。
エントリ・ポイントを見てみる
ここで、ちょっと別の視点から実行ファイルを見てみよう。
プログラムの実行開始位置の情報がもしもどこかにあるのだとしたら、それは実行ファイル中にあるべきだ。そこで目先を変えて、実行ファイルのメタ情報を調べてみよう。
Linuxの実行ファイルはELFフォーマットという形式になっている。そしてELFフォーマットの解析は、連載第1回で説明したreadelfというコマンドで行うことができる。
試しに以下を実行してみよう。
[user@localhost hello]$ readelf -a hello | less
すると、図5.3のような出力が得られた。
上から11行目の「Entry point address」という値が0x80481c0になっている。これは「エントリ・ポイント」と呼ばれるものだ。プログラムの実行はエントリ・ポイントというアドレスから開始される。つまりこのプログラムは、0x80481c0というアドレスから実行開始されるということだ。
そして図5.2をもう一度、見てほしい。_start()の先頭のアドレスも0x80481c0になっている。
よって_start()がエントリ・ポイントとして登録されているということがわかる。Linuxカーネルがこのプログラムを実行するときには、実行ファイルのエントリ・ポイントを見て、そこから実行を開始するということだ。プログラムの実行はmain()関数から始まるのではなく、スタートアップから始まるということになる。対して「C言語のプログラムは」と言ったときには、「main()から始まる」というのが正しいということになるだろうか。
つまりGDBの解析では、readelfの結果からエントリ・ポイントを調べて以下のようにしてブレークポイントをエントリ・ポイントに張ってしまえば、実は一撃でプログラムの先頭で停止させることができるし、readelfの結果から_startがエントリ・ポイントになっていることを知ることもできる。しかしこれはエントリ・ポイントというものを知らなければできないことなので、ここでは敢えてGDBのみを使う方法で_startの先頭を探ってみている。
(gdb) break *0x80481c0
まとめてみると、以下のような順番で処理が呼び出されているということがわかった。
_start→__libc_start_main→main()
main()が呼ばれるまでの処理を追う
デバッガでの解析に戻ろう。GDBを使って、main()が呼ばれるまでと呼ばれた後の、一連の処理を追ってみよう。
まずブレークポイントをきれいにしよう。delete breakpointsで全削除して、エントリ・ポイントである_start()に新たにブレークポイントを設定する。
(gdb) delete breakpoints Delete all breakpoints? (y or n) (gdb) break _start Breakpoint 4 at 0x80481c0 (gdb)
runで実行を開始しよう。
(gdb) run The program being debugged has been started already. Start it from the beginning? (y or n) Starting program: /home/user/hello/hello Breakpoint 4, 0x080481c0 in _start () (gdb)
_start()でブレークした。この状態でstepiにより、ステップ実行で処理を進める。
進めていくと__libc_start_main()の呼び出しがある。図5.2のアセンブラ出力の、下から2行目の位置だ。stepiで__libc_start_main()の中に入っていこう。すると図5.4のようになった。
さらに処理を進めていくのだが、ここで図5.1をもう一度見直しておきたい。main()から__libc_start_main()に戻ってきたときには0x8048478というアドレスに戻っていたので、main()の呼び出しはその直前にあるはずだ。ということはmain()の呼び出しを見るためには、0x8048478というアドレスの付近まで処理を進めればいいと見当がつくだろう。
途中にcall命令による関数呼び出しがいくつかあるので、nextiで適当に実行を進めていく。すると、0x8048475という位置に図5.5のような関数呼び出しがある。
関数呼び出しの先は、「*0x8(%ebp)」のようにして指定されている。これはEBPレジスタの値+8の位置に格納されている値、という意味だ。
値を見てみよう。
(gdb) print/x *(int *)($ebp + 8) $1 = 0x80482bc (gdb)
これが呼び出し先の関数のアドレスであり、おそらくmain()のアドレスになっているはずだ。
図2.2を見直してみよう。main()の先頭は0x80482bcというアドレスになっており、一致していることがわかる。
nextiでmain()を呼んでみよう。
画面が崩れてしまっているが、中央付近に「Hello World! ...」の文字列が表示されており、メッセージが出力されたことがわかる。
たしかにmain()が呼ばれているようだ。
main()の呼び出しの前後を見る
さてここで、少しだけアセンブラを読んでみよう。アセンブラの解析と聞くと身構えてしまう読者のかたもいるかもしれないが、ここではそれほど難しい解析は行わない。そしてアセンブラを見るとき、命令の一字一句を理解する必要もない。
まずは図5.5を漠然と眺めてみよう。気がつくところはないだろうか。
図5.5で反転表示されているのはmain()関数の呼び出しのためのcall命令だが、その前にはmov命令によりESPレジスタの指す先に値を格納している。これは関数呼び出しの前なので、おそらく引数の準備だろう。ということはmain()を呼び出す前に、argcやargvなどの引数を用意しているのだろうと想像がつく。
さらにcall命令によるmain()の呼び出しの後には、以下のような行がある。
call 0x8048e60 <exit>
どうやらmain()の呼び出しの直後には、exit()が呼ばれているようだ。
さらに反転表示された行の次の行ではmov命令でEAXレジスタの値をスタックに格納している。どうやらmain()の戻り値を引数にして、exit()を呼んでいるように思える。
つまり、例えば以下のようにして、main()の戻り値を引数にしてexit()が呼ばれているようなのだ。
exit(main(argc, argv, envp));
main()からreturnで返った場合には、戻り値がそのままexit()に渡されているわけだ。
ここで__libc_start_main()から戻ってきた場合にhlt命令が呼ばれていたことを思い出してほしい。実際にはそこまで戻る前に必ずexit()が呼ばれ、その先でプログラムは終了する。よってHALTすることは無いわけだ。
Copyright © ITmedia, Inc. All Rights Reserved.
関連記事
- プログラミング言語Cについて知ろう
プログラミング言語の基本となる「C」。正しい文法や作法を身に付けよう。Cには確かに学ぶだけの価値がある(編集部) - シェルコード解析に必携の「5つ道具」
コンピュータウイルスの解析などに欠かせないリバースエンジニアリング技術ですが、何だか難しそうだな、という印象を抱いている人も多いのではないでしょうか。この連載では、「シェルコード」を例に、実践形式でその基礎を紹介していきます。(編集部) - 【 od 】コマンド――ファイルを8進数や16進数でダンプする
本連載は、Linuxのコマンドについて、基本書式からオプション、具体的な実行例までを紹介していきます。今回は、「od」コマンドです。