C プログラムを作成し、gcc を使用してコンパイルすると、実行可能ファイルが取得されます。とてもシンプルです。そうですか?
コンパイル プロセス中に何が起こるのか、C プログラムがどのように実行可能ファイルに変換されるのか疑問に思ったことはありませんか?
ソース コードが最終的に実行可能ファイルになるまでには、主に 4 つの段階があります。
C プログラムが実行可能ファイルになるまでの 4 つの段階は次のとおりです。
この記事シリーズのパート I では、C プログラムのソース コードが実行可能ファイルにコンパイルされるときに gcc コンパイラが実行する手順について説明します。
先に進む前に、単純な hello world の例を使用して、gcc を使用して「C」コードをコンパイルして実行する方法を簡単に見てみましょう。
$ vi print.c #include <stdio.h> #define STRING "Hello World" int main(void) { /* Using a macro to print 'Hello World'*/ printf(STRING); return 0; }
それでは、このソース コードに対して gcc コンパイラを実行して、実行可能ファイルを作成しましょう。
$ gcc -Wall print.c -o print
上記のコマンドで:
- gcc – GNU C コンパイラを呼び出します
- -Wall – すべての警告を有効にする gcc フラグ。 -W は警告を表し、「すべて」を -W に渡します。
- print.c – 入力 C プログラム
- -o print – C コンパイラに C 実行可能ファイルを print として作成するように指示します。 -o を指定しない場合、デフォルトで C コンパイラは a.out という名前の実行可能ファイルを作成します
最後に、C プログラムを実行して hello world を表示する print を実行します。
$ ./print Hello World
注意 :いくつかの C プログラムを含む大きなプロジェクトに取り組んでいるときは、前に説明したように、make ユーティリティを使用して C プログラムのコンパイルを管理してください。
gcc を使用してソース コードをバイナリに変換する方法についての基本的な考え方がわかったので、C プログラムが実行可能ファイルになるまでに実行する必要がある 4 つの段階を確認します。
1.前処理
これは、ソース コードが通過する最初の段階です。この段階では、次のタスクが実行されます:
<オール>前処理をよりよく理解するために、フラグ -E を使用して上記の「print.c」プログラムをコンパイルすると、前処理された出力が stdout に出力されます。
$ gcc -Wall -E print.c
さらに良いことに、以下に示すようにフラグ「-save-temps」を使用できます。 「-save-temps」フラグは、gcc コンパイラが使用する一時的な中間ファイルを現在のディレクトリに保存するようコンパイラに指示します。
$ gcc -Wall -save-temps print.c -o print
したがって、プログラム print.c を -save-temps フラグでコンパイルすると、現在のディレクトリに次の中間ファイルが取得されます (印刷実行可能ファイルと共に)
$ ls print.i print.s print.o
前処理された出力は、.i という拡張子を持つ一時ファイルに保存されます (つまり、この例では「print.i」)
次に、print.i ファイルを開いて内容を表示します。
$ vi print.i ...... ...... ...... ...... # 846 "/usr/include/stdio.h" 3 4 extern FILE *popen (__const char *__command, __const char *__modes) ; extern int pclose (FILE *__stream); extern char *ctermid (char *__s) __attribute__ ((__nothrow__)); # 886 "/usr/include/stdio.h" 3 4 extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__)); extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__)) ; extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__)); # 916 "/usr/include/stdio.h" 3 4 # 2 "print.c" 2 int main(void) { printf("Hello World"); return 0; }
上記の出力では、ソース ファイルが非常に多くの情報で満たされていることがわかりますが、ソース ファイルの最後には、私たちが記述したコード行がまだ残っています。最初にこれらのコード行を分析しましょう。
<オール>print.i ファイルを検索すると、関数 printf が次のように宣言されていることがわかりました。
extern int printf (__const char *__restrict __format, ...);
キーワード「extern」は、関数 printf() がここで定義されていないことを示しています。このファイルの外部にあります。 gcc が printf() の定義に到達する方法については後で説明します。
gdb を使用して C プログラムをデバッグできます。これで、前処理段階で何が起こるかについて十分に理解できました。次のステージに進みましょう。
2.コンパイル中
コンパイラがプリプロセッサ段階で完了した後。次のステップは、print.i を入力として受け取り、それをコンパイルして、コンパイルされた中間出力を生成することです。この段階の出力ファイルは「print.s」です。 print.s に存在する出力は、アセンブリ レベルの命令です。
print.s ファイルをエディターで開き、内容を表示します。
$ vi print.s .file "print.c" .section .rodata .LC0: .string "Hello World" .text .globl main .type main, @function main: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 movq %rsp, %rbp .cfi_offset 6, -16 .cfi_def_cfa_register 6 movl $.LC0, %eax movq %rax, %rdi movl $0, %eax call printf movl $0, %eax leave ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3" .section .note.GNU-stack,"",@progbits
私はアセンブリ レベルのプログラミングにはあまり興味がありませんが、ざっと見てみると、このアセンブリ レベルの出力は、アセンブラが理解してマシン レベルの言語に変換できる何らかの形式の命令であることがわかります。
3.組み立て
この段階で、print.s ファイルが入力として取得され、中間ファイル print.o が生成されます。このファイルは、オブジェクト ファイルとも呼ばれます。
このファイルは、アセンブリ命令を含む「.s」ファイルを理解し、マシン レベルの命令を含む「.o」オブジェクト ファイルに変換するアセンブラによって生成されます。この段階では、既存のコードのみが機械語に変換され、printf() などの関数呼び出しは解決されません。
この段階の出力はマシン レベルのファイル (print.o) であるためです。そのため、その内容を表示することはできません。それでも print.o を開いて表示しようとすると、まったく読めないものが表示されます。
$ vi print.o ^?ELF^B^A^A^@^@^@^@^@^@^@^@^@^A^@>^@^A^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@0^ ^@UH<89>å¸^@^@^@^@H<89>ǸHello World^@^@GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3^@^ T^@^@^@^@^@^@^@^AzR^@^Ax^P^A^[^L^G^H<90>^A^@^@^\^@^@]^@^@^@^@A^N^PC<86>^B^M^F ^@^@^@^@^@^@^@^@.symtab^@.strtab^@.shstrtab^@.rela.text^@.data^@.bss^@.rodata ^@.comment^@.note.GNU-stack^@.rela.eh_frame^@^@^@^@^@^@^@^@^@^@^@^ ... ... …
print.o ファイルを見て説明できる唯一のことは、文字列 ELF についてです。
ELF は、実行可能でリンク可能な形式の略です。
これは、gcc によって生成されるマシン レベルのオブジェクト ファイルと実行可能ファイルの比較的新しい形式です。これ以前は、a.out と呼ばれる形式が使用されていました。 ELF は a.out よりも洗練された形式であると言われています (ELF 形式については、今後の別の記事でさらに掘り下げる可能性があります)。
注:出力ファイルの名前を指定せずにコードをコンパイルすると、生成される出力ファイルの名前は「a.out」になりますが、形式は ELF に変更されています。デフォルトの実行可能ファイル名が同じままであることだけです。
4.リンク
これは、関数呼び出しとその定義のすべてのリンクが行われる最終段階です。前述のように、この段階まで gcc は printf() などの関数の定義について 知りません。コンパイラは、これらすべての関数が実装されている場所を正確に把握するまで、関数呼び出しにプレースホルダーを使用するだけです。この段階で、printf() の定義が解決され、関数 printf() の実際のアドレスがプラグインされます。
リンカーはこの段階で動作を開始し、このタスクを実行します。
リンカーは追加の作業も行います。プログラムの開始時と終了時に必要な追加のコードをプログラムに結合します。たとえば、コマンドライン引数を渡したり、環境変数をすべてのプログラムに渡したりするなど、実行環境をセットアップするための標準的なコードがあります。同様に、プログラムの戻り値をシステムに返すために必要な標準コード。
コンパイラの上記のタスクは、小さな実験で確認できます。これで、リンカーが .o ファイル (print.o) を実行可能ファイル (print) に変換することは既にわかっています。
したがって、print.o ファイルと print ファイルの両方のファイル サイズを比較すると、違いがわかります。
$ size print.o text data bss dec hex filename 97 0 0 97 61 print.o $ size print text data bss dec hex filename 1181 520 16 1717 6b5 print
size コマンドを使用すると、出力ファイルのサイズがオブジェクト ファイルから実行可能ファイルにどのように増加するかについて、大まかなアイデアを得ることができます。これはすべて、リンカーが私たちのプログラムと結合する追加の標準コードによるものです。
これで、C プログラムが実行可能ファイルになる前に何が起こるかがわかりました。前処理、コンパイル、アセンブリ、およびリンク段階についてはご存知でしょう。リンク段階にはさらに多くのことがあります。これについては、このシリーズの次の記事で説明します。