3 ポイント 投稿者 GN⁺ 2024-04-09 | 1件のコメント | WhatsAppで共有
  • Cで書いた Hello World の1行であっても、コンパイル済みの実行ファイル、C標準ライブラリ、システムコール、カーネル、ターミナルを順に経て、ようやく画面に表示される
  • gcc hello.c -o helloで作られる成果物は ELF 64-bit x86-64実行ファイルであり、ELFヘッダーのエントリポイント 0x1060_start コードが最初に実行される
  • ユーザーが書いた main() は直接開始されず、_start__libc_start_main を経由する。printf("Hello World!\n") は最適化により、より単純な puts() 呼び出しになる
  • 文字列は .rodata0x2004 にバイト列として保存され、C文字列は長さ情報ではなく NULL終端文字で終わりを判断する
  • 実際の出力経路は、libcのバッファリングとロック、write または writev システムコール、Linuxカーネル、pseudo-terminal、ターミナルエミュレーターでのレンダリングまで続き、実行環境によって変わり得る

CのHello Worldから出発

  • サンプルプログラムは、Cで書いた次のコードである
#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}
  • Pythonの print('Hello World!') と同じ結果を出すが、Cプログラムはインタプリタでそのまま実行するのではなく、まず コンパイルする必要がある
gcc hello.c -o hello
./hello
  • 実行結果は次のとおり
Hello World!
  • Cまたはアセンブリに関する基礎知識があれば、流れを追いやすいレベルである

実行ファイルの正体

  • file hello の結果で重要なのは ELF executable, x86-64 である
    • ELF実行ファイルは、Linuxで実行可能なプログラム形式である
    • x86-64 は、64ビットx86プロセッサ向けの機械語プログラムを意味する
  • readelf -h hello でELFヘッダーを見ると、Entry point address: 0x1060 が表示される
    • このアドレスは、プログラムがロードされたあとCPUが実行を開始する位置である

_start とCライブラリへの入口

  • objdump -D hello で逆アセンブルすると、0x1060 の位置に _start がある
  • _start はユーザーが直接書いたコードではなく、コンパイラ、より正確にはリンカが自動で追加したコードである
  • このコードは初期化を行ったあと、次の呼び出しを実行する
call *0x2f53(%rip)        # 3fd8 <__libc_start_main@GLIBC_2.34>
  • この関数はプログラム内に直接定義されているのではなく、標準Cライブラリ側にある
  • readelf -d hello の動的セクションには、libc.so.6 への依存関係が表示される
Shared library: [libc.so.6]
  • libc.so.6 はシステムの標準Cライブラリであり、Linuxの .so ファイルはWindowsの .dll のように複数のプログラムで共有できるコードを含む
  • Cライブラリはコマンドライン引数や環境変数の処理などの初期化を担当し、main() を呼び出したあと、戻り値でプログラムを終了する

main() で実際に起きること

  • 逆アセンブル結果では、main()0x1149 にある
  • main() の流れは次のとおり
    • スタックフレームを設定する
    • 関数呼び出しの引数を準備する
    • Hello Worldの出力関数を呼び出す
    • スタックフレームを片付ける
    • 終了コード 0 で戻る
  • 重要なのは、文字列アドレスを引数として準備し、puts@plt を呼び出す部分である
lea    0xeac(%rip),%rax
call   1050 <puts@plt>
  • ソースコードには printf() があったが、コンパイラが最適化して puts() に変える
    • printf() はフォーマット付き出力機能を備えた複雑な関数である
    • この例では変数の埋め込みのようなフォーマット機能を使っていないため、より単純な puts() に置き換えられる
    • puts() は文字列の後ろに改行を自分で付けるため、元の文字列の \n も削除される

文字列が保存される仕組み

  • 文字列は .rodata セクションの 0x2004 アドレスにある
  • その位置のバイトは次のとおり
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
  • このバイト列は "Hello World!" と最後の 0x00 として解釈される
  • 0x00NULL終端文字であり、C文字列の終わりを表す
  • C文字列は長さ情報を一緒に持たないため、文字列を受け取る関数はNULL終端文字に出会うまで1バイトずつ処理する
  • 文字列の間にNULL終端文字がないと、C関数が複数の文字列を続けて処理したり、許可されていないメモリを読んで Segmentation Fault で終了したりする可能性がある

Glibcの puts() 経路

  • puts@plt は最終的に標準ライブラリ側へつながる
  • Glibcでは puts()_IO_puts に接続される
  • _IO_puts は次の処理を行う
    • 文字列の長さを求める
    • stdout 出力ストリームに対する ロックを取得する
    • 条件を確認し、_IO_sputn を呼び出す
    • 改行文字を出力する
    • ロックを解除し、出力した文字数を返す
  • Glibcの内部実装は大きく複雑なため、より小さなCライブラリである musl libc の流れへ進む

musl libcで出力が下りていく過程

  • muslの puts()stdout のロックを取得し、fputs()putc_unlocked('\n', stdout) を呼び出したあと、ロックを解除する
  • fputs() は文字列の長さを求め、fwrite() を呼び出す
  • fwrite() は再びロックを取得し、__fwritex() を呼び出す
  • __fwritex() はバッファの状態を確認し、必要であれば出力ストリームの write 関数ポインタを呼び出す
  • stdoutfd = 1 として定義されており、write 関数は最初に __stdout_write に指定される
  • __stdout_write()TIOCGWINSZ ioctlを実行したあと、__stdio_write() を呼び出す
  • __stdio_write()SYS_writevシステムコールを実行する

システムコールとカーネル

  • Cライブラリだけではハードウェアと直接通信できず、ハードウェアアクセスはOSカーネルが担当する
  • 出力要求は最終的に、OSにテキストを出力ストリームへ書き込むよう依頼するシステムコールで終わる
  • 一般的な出力は write システムコールで行われ、muslは複数のバッファを配列として書き込める writev を使う
  • muslのx86-64システムコール実装は、引数の数ごとに __syscall0 から __syscall6 まで分かれている
  • 各関数は引数をCPUレジスタに設定し、syscall 命令を実行する
    • 制御はカーネルへ移る
    • カーネルはレジスタのパラメータを読み、要求されたシステムコールを実行する

カーネルの後、画面に見えるまで

  • Linuxカーネルは write システムコールを受け取り、開いているファイルやストリームにデータを書き込む
  • write システムコールは、ファイルディスクリプタ、書き込むバッファ、書き込むバイト数を引数として受け取る
  • 例の環境では、GNOME端末エミュレーターで hello プログラムが実行され、stdout/dev/pts/0 pseudo-terminal に接続される
  • カーネルはHello Worldメッセージをバッファに保存し、ターミナルエミュレーターがそれを読んで画面に表示する
  • ターミナルエミュレーターはテキストをフレームとしてレンダリングし、Xサーバーまたはコンポジタが他のアプリ画面と合成したあと、カーネルを通じてディスプレイに表示する
  • 実行環境によって、その後の経路は変わり得る
    • リモートログインでは、カーネルがテキストを sshd に送り、sshd が暗号化されたパケットとして再びカーネルに渡し、インターネットへ送る
    • 物理端末とserial-to-USBアダプタを使う場合、カーネルがテキストをUSBパケットとして送る
    • framebuffer consoleでは、カーネルがテキストをフレームとしてレンダリングし、ディスプレイに出力する

小さな出力ひとつに絡む複雑さ

  • Hello Worldメッセージの送信は、ひとつのプログラムで発生したひとつのシステムコールにすぎない
  • 現代のソフトウェアとハードウェアは、小さな動作ひとつでさえ完全に追跡するのが難しいほど、複雑で細かな階層で構成されている
  • この説明では多くの詳細や例外、カーネル内部の動作を省略し、主要な流れだけを追っている

1件のコメント

 
GN⁺ 2024-04-09
Hacker News のコメント
  • 退屈しのぎに macOS で Rust を使って似たようなものを試してみたが、#![no_std]#![no_main]、直接 WRITE/EXIT システムコールを使う “Hello, world!” でも、Ghidra で見ると出力はどうやっても約 16KB程度だった
    さらに小さくするコードゴルフは可能だろうが、すでに誰かが試して文書化している可能性が高そうだ

    • Windows では似たようなプログラムが3072バイトで、rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:main でコンパイルした
      kernel32ExitProcessGetStdHandleWriteFile を直接呼び出しており、hello world なのでパニックハンドラは適当に置いた。実行ファイル内にはまだかなりパディングがあるので、サイズを増やさずにもっと入れられるし、さらに“犯罪的”な方法で小さくすることもできるだろうが、大きな意味はなさそうだ
      ちなみに関連する PDB デバッグデータベースは 208,896 バイトだった
    • 最小にするなら main を完全に捨てて _start を使う必要があり、セクションアラインメントをしないようリンカフラグも渡す必要がある
      https://darkcoding.net/software/a-very-small-rust-binary-ind... を見ると、この方法で簡単に 500 バイト前後までいける
    • コードゴルフは面白いが、最近のページサイズがどれくらいかも見るべきだ
      スタックのある言語なら、実行ファイルは結局、読み取り専用/読み書きページとして最低でも 2 ページ程度は載る可能性が高い
    • min-sized-rust プロジェクトには、Rust バイナリサイズを減らす最適化が多数まとまっている
      すべての最適化を適用すると hello world は最終的に約 8KB 程度だったと記憶している: https://github.com/johnthagen/min-sized-rust
    • XNU は 1 ページより小さい Mach-O をロードしないため、そのプラットフォームでは残念ながら小さなバイナリ遊びの余地はあまりない
  • Musl が飛ばしている別の沼がある。Linux でシステム関数を呼ぶ方法は、syscall を直接使うことだけではない
    より「行儀のよい」方法は vDSO を呼び出すことだ。カーネルがアドレス空間に自動的にマップしてくれる魔法のような小さなライブラリで、カーネルはシステムコールを実行するのに最適なコードを提供できる
    一部のシステムコールはユーザー空間で実行され、syscall そのものが不要になることもあり、かつては vDSO が int 0x80sysenter といったカーネル呼び出しメカニズムのどれかを選ぶこともあった
    https://man7.org/linux/man-pages/man7/vdso.7.html

    • 32ビット x86 の場合にのみ、vDSO には汎用の高速システムコールシムが含まれている
      x86-64 では標準のシステムコール方式は SYSCALL 命令であり、vDSO には時間関連関数と SGX 関連関数がいくつか入っているだけだ
  • 言語別 “Hello World” プログラムのオーバーヘッドを比較した記事も読む価値がある: https://drewdevault.com/2020/01/04/Slow.html
    続編: https://drewdevault.com/2020/01/08/Re-Slow.html
    Linux で最小のプログラムを作った伝説的な記事もある。プログラムは単にステータスコード 42 で終了する: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
    同じサイトで最小の “Hello World” プログラムも見つけられる

  • この記事は、実質的にプログラムの本当のエントリポイントともいえる動的リンカの役割をほとんど飛ばしている
    この観点が気になるなら https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2... を見るとよい

  • DOS 愛好家向けに言うと、DOS でアセンブリ/機械語で書いた “hello, world” は23バイトまで小さくできた: https://github.com/susam/hello
    この 23 バイトのうち 15 バイトはドル記号で終わる文字列そのものが占めているため、実際の機械語コードは x86 命令 4 個からなる 8 バイトだけだ

  • 記事はよかったが、あと 2 つやってほしかった。printfputs に変わる原因になった最適化とインライン化を無効にするか、そもそも puts を直接使う hello world として書くほうがよい
    さらにコンパイル段階をプリプロセス、コンパイル、アセンブル、リンクの 4 段階に分けるか、cc--save-temps を付けて生成されるファイルを説明するとよい。パイプラインを直接見れば、魔法のように見える部分はずっと少なくなる

  • 大学のシステムプログラミングの授業で好きだった課題を思い出した。「C++の hello world の断片を渡して、可能な限り最小のコンパイル済みバイナリを提出せよ」という課題だった
    readelfobjdump のようなツールでプログラムをのぞき込み、レイヤーやコンパイラ最適化を少しずつ剥がしながら、それでも「hello world」を出力する最小のバイナリまで削っていった記憶が残っている
    当然検索してみると、学生たちよりはるかにうまくやっている人がいた: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....

    • その断片が C++ だったことに意味があるのかは疑問に思う
      単に hello world を出力する最小のバイナリを作って、意味的に同等だと主張できるのではないか。文字列データまで含めても、x86 命令は十数個で足りそうだ
    • 好きな課題なら、なぜ「hello world」以外のプログラムでも可能な限り最小のバイナリを作る人がもっと多く見られないのか気になる
      個人的には、自分のコンピュータの容量を節約するのが好きなので面白いが、最近は 10MiB、20MiB、50MiB、100MiB を超えるプログラムがたくさん書かれている。商業環境で商業目的に作られたものもあるが、純粋な楽しみで書いているというプログラムも多い。小さなプログラムを使う楽しみはないのだろうか
  • 「日付が変わったから寝よう」という感じの締めくくりは、むしろこの記事にとって完璧な結末だった

  • 残念ながら多くの「hello world」深掘りと同じように、この記事も write システムコールで止まり、残りをざっくり流している
    システムコールの前までは本質的に、printfputs を呼び、putswrite を呼んで char const* を渡し、少し帳簿付けをする関数呼び出しの連鎖なので、個人的には最も興味深い部分ではない
    本当に興味深く複雑になるのはシステムコールの後だ。カーネルがプロセスの stdout を端末エミュレータ側の入力につなぎ、端末はフォントレンダリングライブラリと GPU ドライバでフレームバッファを用意する。文字バイトに合うフォントのアウトラインをディスクから読み込み、ビューポートに合わせ、リサイズ・カーニング・フォントメトリクスを適用したうえで、GPU がラスタライズとアンチエイリアシングを行う
    その後、ウィンドウマネージャが端末ウィンドウのフレームとデスクトップを合成し、透明度や曇りガラス効果があればシェーダで処理する。結果のフレームバッファはモニター解像度と色深度に合わせて HDMI や DisplayPort の信号としてパケット化され、ケーブルとディスプレイ入力回路を経てピクセルアドレッシング信号に変わる。LCD、OLED、プラズマ、CRT によってリフレッシュ方式は異なり、たとえば 3840×2400 WRGB OLED では約 3,686 万個のサブピクセルを扱う必要がある
    このすべての処理が、60Hz 基準の 1 フレーム時間である 16.67ms の中で起こる

    • 説明はよいが、結局は人間の視覚システムで止まっていることになり、そこもまた本当に興味深く複雑な部分だ :)
      https://en.wikipedia.org/wiki/Visual_system
    • こういう深さの探究が好きなら、Gynvael Coldwind による、Windows で Python の hello world がどのように実行されるかを掘り下げた記事も気に入ると思う
      CPython 内部、Windows conhost、フォントのラスタライズ、GPU レンダリングなどを扱っている: https://gynvael.coldwind.pl/?id=754
    • この大部分はプログラム自体とは無関係だ。たとえば出力を /dev/null にパイプしていたなら、こうしたことは起きない
    • _start 以前に起こることも抜けている。たとえば Linux でプロセスが生まれる仕組み、とりわけかなり奇妙な execve、プログラムがメモリにロードされる過程、binfmt_* と強力な binfmt_misc、再配置、例外処理フレーム、セクション、ELF ローダ全般、必要な malloc を含むオペレーティングシステムのリソース割り当てなどがある
  • 「Python と違って、このプログラムを実行するためにインタプリタを呼び出すことはできない」というのは、必ずしも正確ではない
    tcc -run hello.c を使えば可能だ。厳密にはインタプリタではなく、メモリ内コンパイラに近いが
    さらに変人ポイントが欲しければ、プログラムに「Hello world」ではなく「Hellorld」と言わせればよい