Hello World のあいさつ
(thecoder08.github.io)-
現代の Hello World プログラムの背後に隠れた抽象化の世界を探る
- この記事は C で書かれた Hello World プログラムについての内容。C は、インタプリタ/コンパイラ/JIT において、プログラムが実際に動作する前に言語が何をするのかをあまり意識しなくてよい高級言語の中では、最も低レベル寄りに位置する。
- もともとはコーディング経験のある人なら誰でも理解できるように書こうとしたが、少なくとも C やアセンブリの知識があると役に立ちそうだ。
-
Hello World プログラムの開始
- 誰もが Hello World プログラムに親しんでいるはず。Python では、おそらく最初に書いたプログラムは
print('Hello World!')のようなものだっただろう。 - この記事では、C プログラミング言語で書かれた Hello World を見ていく。C ではインタプリタを呼び出してプログラムを実行することはできない。まずコンパイラを実行して、コンピュータのプロセッサが直接実行できる機械語コードへ変換する必要がある。
- 誰もが Hello World プログラムに親しんでいるはず。Python では、おそらく最初に書いたプログラムは
-
プログラムの分析
- コンパイルされたプログラムファイルを分析すると、ELF 実行ファイルであり、x86-64 命令セットアーキテクチャ向けであることが分かる。
- ELF 実行ファイルは、Linux における Windows の .exe ファイルに相当する。
- x86-64 は、1981 年に IBM PC が導入されて以来、PC で使われてきた CPU アーキテクチャだ。
- このファイルには、CPU だけが理解できる唯一の言語である機械語コードが含まれている。
-
アセンブリコードの分析
- プログラムの開始アドレスであるエントリポイントを見つけて、アセンブリコードを分析してみる。
- アセンブリ言語は、機械語コードを人間が読める形で表現したものだ。
- コンパイラ(正確にはリンカ)によって自動的に追加された初期化コードが見え、
__libc_start_main関数を呼び出していることが分かる。 - しかしこのコードはプログラム内で定義されておらず、別のどこかにある。
-
C 標準ライブラリ
__libc_start_main関数は、システムの標準 C ライブラリである libc.so.6 に定義されている。- C 標準ライブラリは、コンピュータ上のほぼすべてのプログラムが使うルーチンや関数の集合だ。
- C ライブラリが初期化処理を行い、私たちが書いた main() 関数を呼び出す。main() から return すると、こちらが与えた終了コードでプログラムを終了させる。
-
main() 関数の分析
- main() 関数ではスタックフレームを設定し、Hello World 文字列のアドレスを関数呼び出しの引数として設定したあと、puts() 関数を呼び出す。
- puts() が使われているのは、もともとの printf() 呼び出しをコンパイラが最適化して置き換えたためだ。printf() は複雑だが、puts() は単に書式なし文字列を出力するだけだからである。
-
Hello World 文字列
- 文字列は "Hello World!" の後に NULL 終端子が続く形になっている。
- C では文字列に関連する長さ情報がないため、NULL 終端子で文字列の終わりを示す。NULL 終端子がなければ、許可されていないメモリを読み続け、プログラムは Segmentation Fault で落ちてしまう。
- コンパイラ最適化により、printf() で使った改行(\n)は取り除かれている。puts() は文字列を出力したあとに改行を追加するためだ。
-
puts() 関数
- puts() 関数は、さらに標準ライブラリ内のコードを呼び出すことになる。
- glibc のコードを見ると、_IO_puts -> _IO_new_file_xsputn の順に呼ばれていることが分かるが、コードが複雑で説明しにくい。
- musl libc の場合はもう少し単純で、puts -> fputs -> fwrite -> __fwritex -> __stdio_write -> syscall の順に呼ばれる。
-
システムコール
- C ライブラリがどれだけ大きくても、ハードウェアと直接通信することはできない。それができるのはカーネルだけだ。
- したがって puts() の呼び出しは、最終的には OS に何かをしてほしいと要求するところで終わる。ここでは出力ストリームに文字列を書き込むことだ。
- musl libc は writev というシステムコールを使う。これは複数のバッファを一度に書き込めるようにするものだ。
- システムコールは、レジスタにパラメータを設定し、syscall 命令を実行することで行われる。すると制御がカーネルへ移り、カーネルがパラメータを読んでシステムコールを実行する。
-
カーネル
- Linux カーネルは、システムコールによって要求された動作を実行しなければならない。write システムコールは、ファイルシステム上の開かれたファイルやストリームに書き込むようカーネルに指示する。
- write は、書き込むファイルディスクリプタ、書き込むバッファ、書き込むバイト数という 3 つのパラメータを受け取る。
- 実際にどこへ書き込まれるかは状況によって異なる。ターミナルエミュレータなら仮想端末(pty)として見え、リモートログインなら sshd に渡され、物理端末ならシリアル USB アダプタへ送られる。フレームバッファコンソールなら、カーネルがテキストをレンダリングしてディスプレイに出力する。
-
結論
- 現代のソフトウェアシステムは、ハードウェアの上で非常に複雑かつ精巧に動作しているため、コンピュータが行う小さな 1 つの処理を完全に理解しようとすることにはあまり意味がない。
- すべてを説明するには、多くの部分を省略せざるを得なかった。
- Hello World メッセージを送ることは、いまこのコンピュータ上で動作している無数のシステムコールやプログラムのうちの 1 つにすぎない。
GN⁺の意見
- コンピューティングシステムの各層が抽象化によって下位層の複雑さを隠し、開発者が便利にアプリケーションを開発できるようにしていることを示す記事ですね。
- 一方で、アプリケーションの 1 行が実行されるために、その下でどれほど多くのことが起きているのかを実感させ、デバッグがなぜ難しいのかも分からせてくれます。
- すべてのプログラマは、自分が主に使う言語の下にあるシステムまではよく知っておくべきだと思います。全部を知る必要はありませんが、抽象化された部分が実際にはどう動いているのかを知ることは重要です。
- 高級言語を使うとしても、メモリ構造、スタックとヒープ、システムコールといったシステムプログラミングの概念を学んでおけば、デバッグや性能最適化に大いに役立つでしょう。
- アプリケーション開発者がコンパイラや C ライブラリを直接いじる機会はほとんどないでしょうが、自分の書いたプログラムが最終的にどのようにシステムを利用しているのかを理解することは、良いプログラマになるうえで不可欠だと思います。
まだコメントはありません。