プログラミング
main()なしで(有効な)Cプログラムを書く方法
Writing a (valid) C program without main() (labs.iximiuz.com)
要約
この記事は、C言語のコンパイルパイプライン(プリプロセッサ、コンパイラ、アセンブラ、リンカ)を解説し、最終的にmain()関数を持たない実行可能バイナリを作成する方法を示します。Cランタイムライブラリに依存せず、直接システムコールを使用することで、プログラムのエントリポイントをカスタマイズする方法を実演しています。
全文翻訳
このチュートリアルはコミュニティの著者が作成しました。コミュニティコンテンツは、iximiuz Labsチームによって最善を尽くしてレビューされます。プログラミング、Linuxに関するチュートリアル最終更新日: 2026年7月25日main()なしで(有効な)Cプログラムを書くby Başar SubaşıCコンパイルパイプラインを段階的に見ていきます: プリプロセッサ、コンパイラ、アセンブラ、リンカ。通常のhello worldから始め、マクロと生成されたアセンブリを調べ、main()関数を持たない実行可能バイナリを生成して終わります。
なぜCなのか?
ほとんどのプログラミング言語はmain()関数から実行を開始します。Goはfunc main()、Javaはpublic static void main()、Cはint main()を持っています。では、なぜこのチュートリアルでCを選んだのでしょうか?
Cは、アセンブリを書かずにオペレーティングシステムに最も近いものです。Linuxカーネル自体はCで書かれており、すべてのプログラムが依存するシステムコールインターフェースは、Cの規約を念頭に置いて設計されています。Cプログラムがどのようにコンパイル、リンク、実行されるかを学ぶことで、Linux APIが実際にどのように機能するかを学ぶことになります。
典型的なCプログラムはmain()関数から始まります。しかし、main()はどこから来るのでしょうか?コンパイラ?リンカ?オペレーティングシステム?
このチュートリアルでは、Cコンパイルパイプライン全体を段階的に見ていきます。通常のhello worldから始め、プリプロセッサがマクロを展開する様子、コンパイラがCをアセンブリに変換する様子、そしてアセンブラとリンカがアセンブリを実行可能バイナリに変換する様子を観察します。その後、main()を排除しても動作する実行可能ファイルを作成します。
このプレイグラウンドは、gcc、as、ldなどがすでにインストールされている標準的なUbuntu 24.04マシンです。
ステップ1: Hello World
以下の内容でhello.cという名前のファイルを作成します:
#include <stdio.h>
int main(void) {
printf("Hello, world!\n");
return 0;
}
コピーして実行します:
gcc hello.c -o hello
./hello
おなじみの挨拶が表示されるはずです。ここで自問してみてください: その単一のgccコマンドを入力したとき、いくつの異なるツールが実行されましたか?
舞台裏では、gccは複数のツールを順番に実行するドライバです:
プリプロセッサ (cpp) - #include、#define、条件付きコンパイルを処理します。
コンパイラ (cc1) - Cソースをアセンブリに変換します。
アセンブラ (as) - アセンブリをオブジェクトファイルに変換します。
リンカ (collect2 / ld) - オブジェクトファイルとライブラリをリンクして最終的な実行可能ファイルを生成します。
チュートリアルの残りの部分では、これらの各ステージを訪れます。
Cコンパイルパイプラインの4つのステージ。
ステップ2: プリプロセッサ
プリプロセッサはテキストプロセッサです。Cの型や制御フローを理解せず、ディレクティブとマクロを展開するだけです。-Eフラグは、gccにプリプロセッサの後で停止するように指示します。
hello.cにマクロを追加して、次のようにします:
#include <stdio.h>
#define GREETING "Hello, preprocessed world!\n"
int main(void) {
printf("%s", GREETING);
return 0;
}
コピーして実行します:
gcc -E hello.c -o hello.i
hello.iを開き、GREETINGがあった行を探してみてください。それはどうなりましたか?そして、なぜファイルが元のソースよりもはるかに大きくなっているのでしょうか?
ステップ3: Cからアセンブリへ
コンパイラはCをアセンブリに変換します。-Sフラグは、gccにそのステージの後で停止するように指示します。
gcc -S hello.c -o hello.s
hello.sを開き、mainというラベルの関数を見てください。正確な命令はアーキテクチャに依存しますが、x86_64では、関数プロローグ、printfへの呼び出し、およびリターンが見られるはずです。
最適化がどのように出力を変更するかを見るために、loop.cという名前のファイルにこの関数を作成します:
int sum(int n) {
int s = 0;
for (int i = 0; i < n; i++) {
s += i;
}
return s;
}
3つのバージョンでアセンブリを生成します:
gcc -O0 -S loop.c -o loop-O0.s
gcc -O2 -S loop.c -o loop-O2.s
gcc -Os -S loop.c -o loop-Os.s
ファイルを比較してください。-O0では、コンパイラは文字通りです。ループ、カウンタ、加算を記述されたとおりに保持します。-O2では、オプティマイザはループを展開したり、ベクトル化したり、あるいは閉形式の公式 n * (n - 1) / 2 で置き換えたりするかもしれません。-Osでは、オプティマイザは高速性を保ちながらコードを小さくしようとします。
最適化は魔法ではありません。それは、コンパイラがプログラムの中間表現に一連の変換を適用することです。コンパイラが持つ情報が多いほど、より積極的に最適化できます。
ステップ4: アセンブリからオブジェクトへ、オブジェクトからバイナリへ
アセンブラは.sファイルをオブジェクトファイルに変換し、リンカはオブジェクトファイルを実行可能ファイルに変換します。これらのステップを手動で実行できます。
hello-worldアセンブリをアセンブルします:
gcc -c hello.c -o hello.o
-cフラグは、gccにアセンブル後に停止するように指示します。オブジェクトファイルを調べます:
nm hello.o
objdump -d hello.o
main、printfのシンボル、およびおそらく.rodataセクションへの参照が見られるはずです。printfは未解決のシンボルです。なぜなら、オブジェクトファイルにはprintfの実装が含まれていないからです。リンカはCライブラリからそれを解決します。
オブジェクトファイルをバイナリにリンクします:
gcc hello.o -o hello
ここで動的依存関係を見てみましょう:
ldd ./hello
libc.so.6と動的ローダーが見えるはずです。Cライブラリだけがリンカによって静かに追加されるものではありません。また、通常はcrt1.o、crti.o、crtn.oのようなオブジェクトファイルからスタートアップコードも追加されます。スタートアップコードは環境を設定し、コンストラクタを呼び出し、そして最後にmain()を呼び出します。
crtファイルはCランタイムスタートアップファイルです。crt1.oには_startエントリポイントが含まれています。これはカーネルがプログラム開始時に実際にジャンプするものです。_startは最終的にmain()を呼び出します。
そこで、質問です。_startが実際の開始点であり、_startはリンカが満たすことができる単なるシンボルである場合、独自の_startを提供し、スタートアップファイルを完全にスキップするとどうなるでしょうか?
ステップ5: main()のないCプログラム
5a: 明らかな試み
カスタム関数がprintfを呼び出すmy_entry.cという名前のファイルを作成します:
#include <stdio.h>
void my_entry(void) {
printf("Hello from my_entry!\n");
}
スタートアップファイルなしでアセンブリにコンパイルします:
gcc -nostartfiles -S my_entry.c -o my_entry.s
my_entry.sを見てください。コンパイラは、my_entry:ラベルとprintf(またはgccが単純なフォーマット文字列の代わりによく使用するputs)への呼び出しを持つ、標準的な関数を生成しました。
次に、独自のアセンブリファイルに_startを追加します。これはカーネルがプログラム開始時にジャンプするシンボルです。まず.textディレクティブを追加して、新しいコードが実行可能セクションに入るようにします:
.text
.globl _start
_start:
call my_entry
movq $60, %rax
xorq %rdi, %rdi
syscall
最後の3つの命令は、x86_64のexitシステムコールを直接呼び出します。システムコール番号60はexit、%rdiの値は終了コードです。%rdiをゼロに設定すると、プログラムは正常に終了します。
Cランタイムなしでオブジェクトファイルをリンクします:
gcc -nostartfiles my_entry.s -o no-main
./no-main
echo $?
コンパイルされました。リンクされました。起動さえしました。しかし、何も表示されません。
オペレーティングシステムアーキテクチャ: ユーザースペースアプリケーション、libc、システムコールインターフェース、カーネル、およびハードウェア。
なぜ? printfはglibcの一部であり、glibcは使用できる前に初期化される必要があります。初期化は通常、-nostartfilesで削除されたスタートアップファイルで行われます。そのセットアップがないと、stdoutは準備ができていないため、printfは静かに何も行いません。
5b: 純粋なバージョン
main()もスタートアップコードもないプログラムが必要な場合、glibcに依存することはできません。カーネルと直接通信する必要があります。
libcに触れない作業だけを行うようにmy_entry.cを書き直します。例えば、メッセージへのポインタを返すだけです:
const char *my_message(void) {
return "Hello from my_entry!\n";
}
再度コンパイルします:
gcc -nostartfiles -S my_entry.c -o my_entry.s
次に、my_messageを呼び出し、結果を出力するために生のwriteシステムコールを使用し、その後停止するために生のexitシステムコールを使用する_startを追加します:
.text
.globl _start
_start:
call my_message
movq %rax, %rsi # buffer
movq $21, %rdx # length
movq $1, %rdi # stdout
movq $1, %rax # sys_write
syscall
movq $60, %rax # sys_exit
xorq %rdi, %rdi # exit 0
syscall
リンクして実行します:
gcc -nostartfiles my_entry.s -o no-main
./no-main