- 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() 呼び出しになる
- 文字列は
.rodata の 0x2004 にバイト列として保存され、C文字列は長さ情報ではなく NULL終端文字で終わりを判断する
- 実際の出力経路は、libcのバッファリングとロック、
write または writev システムコール、Linuxカーネル、pseudo-terminal、ターミナルエミュレーターでのレンダリングまで続き、実行環境によって変わり得る
CのHello Worldから出発
#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 として解釈される
0x00 は NULL終端文字であり、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 関数ポインタを呼び出す
stdout は fd = 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件のコメント
Hacker News のコメント
退屈しのぎに macOS で Rust を使って似たようなものを試してみたが、
#![no_std]、#![no_main]、直接WRITE/EXITシステムコールを使う “Hello, world!” でも、Ghidra で見ると出力はどうやっても約 16KB程度だったさらに小さくするコードゴルフは可能だろうが、すでに誰かが試して文書化している可能性が高そうだ
rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:mainでコンパイルしたkernel32のExitProcess、GetStdHandle、WriteFileを直接呼び出しており、hello world なのでパニックハンドラは適当に置いた。実行ファイル内にはまだかなりパディングがあるので、サイズを増やさずにもっと入れられるし、さらに“犯罪的”な方法で小さくすることもできるだろうが、大きな意味はなさそうだちなみに関連する PDB デバッグデータベースは 208,896 バイトだった
mainを完全に捨てて_startを使う必要があり、セクションアラインメントをしないようリンカフラグも渡す必要があるhttps://darkcoding.net/software/a-very-small-rust-binary-ind... を見ると、この方法で簡単に 500 バイト前後までいける
スタックのある言語なら、実行ファイルは結局、読み取り専用/読み書きページとして最低でも 2 ページ程度は載る可能性が高い
すべての最適化を適用すると hello world は最終的に約 8KB 程度だったと記憶している: https://github.com/johnthagen/min-sized-rust
Musl が飛ばしている別の沼がある。Linux でシステム関数を呼ぶ方法は、
syscallを直接使うことだけではないより「行儀のよい」方法は vDSO を呼び出すことだ。カーネルがアドレス空間に自動的にマップしてくれる魔法のような小さなライブラリで、カーネルはシステムコールを実行するのに最適なコードを提供できる
一部のシステムコールはユーザー空間で実行され、
syscallそのものが不要になることもあり、かつては vDSO がint 0x80やsysenterといったカーネル呼び出しメカニズムのどれかを選ぶこともあったhttps://man7.org/linux/man-pages/man7/vdso.7.html
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 つやってほしかった。
printfがputsに変わる原因になった最適化とインライン化を無効にするか、そもそもputsを直接使う hello world として書くほうがよいさらにコンパイル段階をプリプロセス、コンパイル、アセンブル、リンクの 4 段階に分けるか、
ccに--save-tempsを付けて生成されるファイルを説明するとよい。パイプラインを直接見れば、魔法のように見える部分はずっと少なくなる大学のシステムプログラミングの授業で好きだった課題を思い出した。「C++の hello world の断片を渡して、可能な限り最小のコンパイル済みバイナリを提出せよ」という課題だった
readelfやobjdumpのようなツールでプログラムをのぞき込み、レイヤーやコンパイラ最適化を少しずつ剥がしながら、それでも「hello world」を出力する最小のバイナリまで削っていった記憶が残っている当然検索してみると、学生たちよりはるかにうまくやっている人がいた: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....
単に hello world を出力する最小のバイナリを作って、意味的に同等だと主張できるのではないか。文字列データまで含めても、x86 命令は十数個で足りそうだ
個人的には、自分のコンピュータの容量を節約するのが好きなので面白いが、最近は 10MiB、20MiB、50MiB、100MiB を超えるプログラムがたくさん書かれている。商業環境で商業目的に作られたものもあるが、純粋な楽しみで書いているというプログラムも多い。小さなプログラムを使う楽しみはないのだろうか
「日付が変わったから寝よう」という感じの締めくくりは、むしろこの記事にとって完璧な結末だった
残念ながら多くの「hello world」深掘りと同じように、この記事も
writeシステムコールで止まり、残りをざっくり流しているシステムコールの前までは本質的に、
printfがputsを呼び、putsがwriteを呼んで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
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」と言わせればよい