C言語プログラムを実行可能バイナリに変換するプロセスの復習・私的備忘録

実行可能なバイナリファイルができるまでの流れ

C言語ソースファイルから実行可能なバイナリファイルができるまでの流れは?

C言語のソースファイルから実行可能なバイナリファイルができるまでの流れは、以下のようなステップを経ます。

  1. プリプロセス:

    • ソースコード内のプリプロセッサディレクティブ(例えば、#include#define)が処理される。
    • 外部ヘッダファイルがソースコードに挿入され、マクロが展開される。
    • 結果として、元のソースコードが拡張された中間ファイルが生成される。
  2. コンパイル:

    • プリプロセッサによって生成された中間ファイルに対して文法チェックと型チェックが行われ、プログラムの構文が正しいか、データ型の整合性が確認される。
    • プログラムの構造が理解された後、アセンブリ言語に変換される。
    • アセンブリ言語は、CPUに近い低レベルの言語であり、可読性が高く、機械語に変換しやすい形になっている。
  3. アセンブル:

    • アセンブリ言語のファイルが機械語に変換され、オブジェクトファイルが生成される。
    • 各命令がCPUが実行可能な形式に変換され、データセクションやコードセクションが整理される。
  4. リンク:

    • アセンブルで生成されたオブジェクトファイルがリンク処理を経て、実行可能バイナリファイルが生成される。
    • コンパイル実行時に指定されたライブラリ、プロジェクト内の他のオブジェクトファイル、および標準ライブラリがリンクされ、外部の関数や変数が統合される。
    • これにより、プログラムが正しく機能するために必要なすべてのリソースが一つの実行可能ファイルにまとめられる。

プリプロセス

  • ソースコード内のプリプロセッサディレクティブ(例えば、#include#define)が処理される。
  • 外部ヘッダファイルがソースコードに挿入され、マクロが展開される。
  • 結果として、元のソースコードが拡張された中間ファイルが生成される。

Cソースコードのプリプロセス結果を出力するには、gcc コマンドに -E オプションを使う。 -E はプリプロセスのみを実行し、コンパイルやリンクは行わない。

1gcc -E sample.c -o sample.i

このコマンドでは、sample.c のプリプロセス結果が sample.i というファイルに出力される。

試しに以下のソース(sample.c)に適用してみた。

1#include<stdio.h>
2int main(void){
3  char c;
4  c = getc(stdin);
5  putc(c, stdout);
6}

出力結果は500行を超えたので、ここでは一部を載せる。

 1$ gcc -E sample.c
 2# 1 "sample.c"
 3# 1 "<built-in>" 1
 4# 1 "<built-in>" 3
 5# 424 "<built-in>" 3
 6# 1 "<command line>" 1
 7# 1 "<built-in>" 2
 8# 1 "sample.c" 2
 9(省略)
10extern FILE *__stdinp __attribute__((__swift_attr__("nonisolated(unsafe)")));
11(省略)
12int getc(FILE *);
13(省略)
14int putc(int, FILE *);
15(省略)
16# 2 "sample.c" 2
17int main(void){
18  char c;
19  c = getc(__stdinp);
20  putc(c, __stdoutp);
21}

元々あった#include<stdio.h>が展開されたようだ。
# から始まる行が多数出力されたが、何のこと変わらなかたった。コメント行かなとも思ったが、ChatGPTに聞いたらそうではなかった。

# から始まる行はコメント行ではなく、プリプロセッサ指示を示しています。これらはコンパイラやプリプロセッサに対する指示を提供し、ソースコードの処理を制御する役割を果たします。

具体的には、次のような内容が含まれます:

  • ファイル名や行番号の情報:例えば、# 1 "sample.c" は、sample.c の1行目から処理を開始することを示します。
  • プリプロセッサの命令#include#define などの指示が含まれることがあります。

これらはデバッグ情報やエラー出力に役立つため、プリプロセッサ展開の結果に現れますが、実際のプログラムの実行には影響しません。

先ほどのプリプロセス結果そのものをChatGPTに問うてみた。 コンパイラのビルトイン機能に関する記述がある。ということは、環境ごとにコンパイラは異なるので、プリプロセスの結果も変わってくるということだろうか。これ以上は深入りしない。

このプリプロセスの結果は、C言語プログラムのプリプロセッサ処理とビルトイン機能の動作を示しています。以下に各行の意味を説明します。

  1. # 1 "sample.c"
    これは、sample.c の1行目を示しています。コンパイラがこのファイルの処理を開始したことを示しています。

  2. # 1 "<built-in>" 1
    これはビルトイン機能の1行目を示しています。ここでは、C言語の標準ライブラリやコンパイラのビルトイン機能に関する処理が行われていることを示しています。

  3. # 1 "<built-in>" 3
    これは、ビルトインの3行目を指しています。

  4. # 424 "<built-in>" 3
    これは、実際のソースコードの424行目でビルトイン機能の3行目があることを示します。これは通常、コンパイラ内部で定義された関数やマクロに関連する情報です。

  5. # 1 "<command line>" 1
    これはコマンドラインからの入力を示しており、特に特別な指示があった場合に記録されます。

  6. # 1 "<built-in>" 2
    ビルトインの2行目を示しています。

  7. # 1 "sample.c" 2
    sample.c の2行目を示しています。この時点では、実際のコードに戻ります。

  8. extern FILE *__stdinp __attribute__((__swift_attr__("nonisolated(unsafe)")));
    これは、標準入力 (stdin) を指す外部変数 __stdinp の宣言です。__attribute__ はコンパイラに対する特別な情報を提供します。

  9. int getc(FILE *);
    標準ライブラリの getc 関数の宣言です。これは、ファイルから1文字を読み取るための関数です。

  10. int putc(int, FILE *);
    標準ライブラリの putc 関数の宣言です。これは、ファイルに1文字を書き込むための関数です。

  11. # 2 "sample.c" 2
    sample.c の2行目から処理が再開されることを示しています。

  12. int main(void){
    main 関数の開始です。

  13. char c;
    文字型の変数 c を宣言しています。

  14. c = getc(__stdinp);
    標準入力から1文字を読み取り、変数 c に格納します。ここでは __stdinp を使っています。

  15. putc(c, __stdoutp);
    変数 c の内容を標準出力に書き込みます。ここでも __stdoutp を使用しています。

コンパイル

  • プリプロセッサによって生成された中間ファイルに対して文法チェックと型チェックが行われ、プログラムの構文が正しいか、データ型の整合性が確認される。
  • プログラムの構造が理解された後、アセンブリ言語に変換される。
  • アセンブリ言語は、CPUに近い低レベルの言語であり、可読性が高く、機械語に変換しやすい形になっています。

gccコンパイラではアセンブリコードを出力するには、-S オプションを使用する。

1gcc -S sample.i

このコマンドを実行すると、sample.i のソースコードがアセンブリコードに変換され、sample.s というファイルが生成される(sample.c をコンパイルしても同じ結果ファイルが生成される)。 このファイルには、人間が読めるアセンブリ言語で記述されたコードが含まれる。 アセンブリコードを出力することで、コンパイラがどのようにソースコードを処理しているかを詳しく理解するのに役立つ。

実際にコマンドを実行した結果、生成されたファイルが以下の sample.s

 1    .section    __TEXT,__text,regular,pure_instructions
 2    .build_version macos, 14, 0 sdk_version 15, 0
 3    .globl  _main                           ; -- Begin function main
 4    .p2align    2
 5_main:                                  ; @main
 6    .cfi_startproc
 7; %bb.0:
 8    sub sp, sp, #32
 9    stp x29, x30, [sp, #16]             ; 16-byte Folded Spill
10    add x29, sp, #16
11    .cfi_def_cfa w29, 16
12    .cfi_offset w30, -8
13    .cfi_offset w29, -16
14    adrp    x8, ___stdinp@GOTPAGE
15    ldr x8, [x8, ___stdinp@GOTPAGEOFF]
16    ldr x0, [x8]
17    bl  _getc
18    sturb   w0, [x29, #-1]
19    ldursb  w0, [x29, #-1]
20    adrp    x8, ___stdoutp@GOTPAGE
21    ldr x8, [x8, ___stdoutp@GOTPAGEOFF]
22    ldr x1, [x8]
23    bl  _putc
24    mov w0, #0                          ; =0x0
25    ldp x29, x30, [sp, #16]             ; 16-byte Folded Reload
26    add sp, sp, #32
27    ret
28    .cfi_endproc
29                                        ; -- End function
30.subsections_via_symbols

セクションヘッダ

1.section    __TEXT,__text,regular,pure_instructions
  • .section: 新しいセクションを定義する命令。
  • __TEXT は、実行可能コードを含むセクションであることを示す。
  • __text は実際のコードが格納されるセクション名。
  • regular は通常のデータであることを示し、pure_instructions は最適化された命令が含まれることを意味する。
1.build_version macos, 14, 0 sdk_version 15, 0
  • この行は、ビルドされるmacOSのバージョンとSDKのバージョン情報を示している。

関数の開始

1.globl  _main                           ; -- Begin function main
  • .globl: 指定されたラベル(ここでは _main)をグローバルに宣言し、他のファイルから参照可能にする。
1.p2align    2
  • .p2align: アラインメントを設定します。2 は、アドレスが4バイト境界に揃うことを意味する。
1_main:                                  ; @main
  • _main:main 関数の開始を示すラベル。

スタックの準備

1.cfi_startproc
  • .cfi_startproc: CFI(Call Frame Information)の開始を示し、デバッグ情報を提供する。
1sub sp, sp, #32
  • sub sp, sp, #32: スタックポインタ (sp) を32バイト減少させ、スタックに空間を確保する。 この空間はローカル変数や保存するレジスタのために使用される。
1stp x29, x30, [sp, #16]             ; 16-byte Folded Spill
  • stp: 2つのレジスタをスタックに同時に保存する命令。
  • x29 はフレームポインタ、x30 はリンクレジスタで、[sp, #16] はスタックの16バイトオフセット位置に格納される。
1add x29, sp, #16
  • add: レジスタの値を加算します。ここでは、sp に16を加えて x29(フレームポインタ)を更新する。
1.cfi_def_cfa w29, 16
  • CFIの情報で、現在のフレームアドレスを w29 と16バイトのオフセットとして定義する。
1.cfi_offset w30, -8
2.cfi_offset w29, -16
  • これらの命令は、CFI情報を更新し、保存された w30w29 のオフセットを示す。 これにより、スタックフレームの情報が正しくデバッグできるようになる。

標準入力からの読み取り

1adrp    x8, ___stdinp@GOTPAGE
  • adrp: ページアドレスをロードする命令で、___stdinp のアドレスを取得する。 @GOTPAGE はグローバルオフセットテーブルのページアドレスを示す。
1ldr x8, [x8, ___stdinp@GOTPAGEOFF]
  • ldr: 指定したメモリ位置からレジスタにデータをロードする。 ここでは、___stdinp の実際のポインタ値を x8 に読み込む。
1ldr x0, [x8]
  • 標準入力からのデータを読み取る準備をするため、x8 に格納されたポインタからデータを x0 にロードする。
1bl  _getc
  • bl: ブランチリンク命令で、_getc 関数を呼び出す。_getc は標準入力から1文字を読み取る関数。
1sturb   w0, [x29, #-1]
  • sturb: 符号なしバイトをスタックに保存する。 ここでは、w0 に格納された読み取った文字を、x29 に基づくスタックのオフセット位置に保存する。
1ldursb  w0, [x29, #-1]
  • ldursb: 符号付きバイトをスタックから読み込み、w0 に格納する。 スタックから前に保存した文字を復元する。

標準出力への書き込み

1adrp    x8, ___stdoutp@GOTPAGE
  • stdout のアドレスを取得する。 adrp 命令でページアドレスを取得する。
1ldr x8, [x8, ___stdoutp@GOTPAGEOFF]
  • stdout の実際のポインタ値を x8 に読み込む。
1ldr x1, [x8]
  • stdout への書き込みの準備をする。 x8 に格納されたポインタからデータを x1 にロードする。
1bl  _putc
  • bl: _putc 関数を呼び出す。 この関数は、x1 に格納されたポインタを使って1文字を書き込む。

関数の終了

1mov w0, #0                          ; =0x0
  • mov: w0 レジスタに0を設定する。 これは main 関数の戻り値を表す。
1ldp x29, x30, [sp, #16]             ; 16-byte Folded Reload
  • ldp: スタックから2つのレジスタを同時に復元する。 ここでは、フレームポインタ(x29)とリンクレジスタ(x30)を復元する。
1add sp, sp, #32
  • スタックポインタを元の位置に戻す。 これで確保したスタック領域を解放する。
1ret
  • ret: 関数から戻る。 呼び出し元のアドレスに戻る。
1.cfi_endproc
  • .cfi_endproc: CFI情報の終了を示し、デバッグ情報の整合性を保つ。

最後の命令

1.subsections_via_symbols
  • この命令は、シンボルを介してサブセクションを管理するための情報を示す。 通常は、リンクされたセクションに関連するもの。

まとめ

このアセンブリコードは、Cプログラムの main 関数の詳細な実装を示している。 標準入力から1文字を読み取り、それを標準出力に書き込む処理が低レベルの命令として表現されている。 各命令は、CPUがどのように操作を実行するかを具体的に示しており、ハードウェアの動作に密接に関連している。

getcputc の実態は libc の中にあることはわかっている。先ほどのアセンブラの実行結果では、どのように libc 内のgetcやputcの実態を参照しているのかが読み取れなかった。ChatGPTさん説明できる?

アセンブリコード内で getcputc の実態を参照している部分は、主に以下の命令に関連している。

getcputc の呼び出し

  1. bl _getc:
  • ここで、_getc 関数を呼び出している。 アセンブリでは、bl 命令を使って、関数のアドレスにジャンプし、同時に戻りアドレスをリンクレジスタに保存する。
  1. bl _putc:
  • 同様に、_putc 関数を呼び出している。

グローバルオフセットテーブル(GOT)

これらの関数の実態は、実行時に動的にリンクされるため、直接的なアドレスは記述されていない。 以下の命令でその参照が行われている:

1adrp x8, ___stdinp@GOTPAGE
2ldr x8, [x8, ___stdinp@GOTPAGEOFF]
  • adrpldr:
  • adrp 命令で、___stdinp___stdoutp のページアドレスを取得し、その後 ldr 命令で実際のポインタをロードする。 このプロセスは、動的リンクのために必要である。

動的リンク

  • 動的ライブラリ:
    実行時に、プログラムは必要なライブラリ(ここでは libc)を読み込む。 _getc_putc の実態は、リンク時にそれぞれのアドレスが解決され、GOT(グローバルオフセットテーブル)を介して参照される。

  • GOTの役割:
    GOTは、動的にリンクされる関数のアドレスを管理するためのテーブルであり、実行時にアドレスが設定される。 これにより、実行中のプログラムが必要なライブラリの関数を適切に呼び出せるようになる。

まとめ

アセンブリコードでは、getcputc の実態を直接参照していないのは、これらの関数が動的にリンクされるためである。 代わりに、GOTを通じてこれらの関数のアドレスを解決し、呼び出しを行う。 このようにすることで、プログラムは異なるライブラリバージョンに柔軟に対応できるようになっている。

アセンブル

  • アセンブリ言語のファイルが機械語に変換され、オブジェクトファイルが生成される。
  • 各命令がCPUが実行可能な形式に変換され、データセクションやコードセクションが整理される。

アセンブル後のオブジェクトファイルを生成するには、gcc コマンドに -c オプションを使用する。 このオプションを指定すると、ソースファイルがコンパイルされ、アセンブリコードが機械語に変換されてオブジェクトファイル sample.o が生成される。

1gcc -c sample.s

なお、sample.s の代わりに sample.c を指定しても、同じ結果ファイルが生成される。

このオブジェクトファイルは機械語だが、まだ実行可能ではない。 以下で説明するリンク処理を行わずにオブジェクトファイルを実行しようとしてもエラーになる。

1$ chmod 755 sample.o
2
3$ ls -l sample.o
4-rwxr-xr-x  1 dam  staff  720  9 21 11:00 sample.o*
5
6$ ./sample.o
7-bash: ./sample.o: cannot execute binary file

リンク

  • アセンブルで生成されたオブジェクトファイルがリンク処理を経て、実行可能バイナリファイルが生成される。
  • コンパイル実行時に指定されたライブラリ、プロジェクト内の他のオブジェクトファイル、および標準ライブラリがリンクされ、外部の関数や変数が統合される。
  • これにより、プログラムが正しく機能するために必要なすべてのリソースが一つの実行可能ファイルにまとめられる。

LinuxやmacOS環境において、コンパイル時のリンカの役割は?

1. リンカの役割

リンカは、コンパイルされたオブジェクトファイルを結合し、最終的な実行可能ファイルを生成する役割を持っている。 具体的には以下の処理を行う。

  • シンボルの解決: 各オブジェクトファイル内の関数や変数などのシンボルを解決し、必要なアドレスを決定する。
  • アドレスの割り当て: プログラム内の各セクション(コード、データなど)にアドレスを割り当て、メモリに正しく配置する。
  • ライブラリのリンク: 必要な外部ライブラリをリンクし、プログラムが依存する関数や変数を解決する。

2. どのようなライブラリをリンクするのか

Linux
  • libc(glibc): 標準Cライブラリで、printfscanfgetcputcなどの基本的な入出力機能が含まれている。
  • 動的ライブラリ: コンパイラやリンカが自動的に標準の動的ライブラリ(例: libc.so)をリンクする。 -lmオプションで数学ライブラリlibmをリンクすることもある。
macOS
  • libSystem.dylib: macOSのCランタイムライブラリで、C標準ライブラリの機能を提供する。 この中には、getcputcを含む標準入出力関数が含まれている。
  • その他のライブラリ: プログラムが使用する他のライブラリ(例えば、libc++など)もリンクされる場合がある。

3. Cランタイムとスタートアップルーチン

Cプログラムの実行には、特定のスタートアップルーチンが必要である。 この部分は通常、以下のような処理を行う。

  • 初期化: グローバル変数や静的変数の初期化を行う。
  • メイン関数の呼び出し: main関数を呼び出し、プログラムの実行を開始する。
  • 終了処理: プログラムが終了した際には、必要なクリーンアップ処理(リソースの解放など)を行う。
LinuxとmacOSの違い
  • Linux: 通常、crt1.oなどのスタートアップオブジェクトファイルが使用され、Cライブラリと連携して動作する。
  • macOS: libSystemに含まれるスタートアップコードが呼び出され、C言語の実行環境を整える。

結論

リンカは、オブジェクトファイルを結合して実行可能ファイルを作成する重要な役割を果たしており、C言語の標準ライブラリやランタイム環境を自動的にリンクすることで、プログラムの実行に必要な機能を提供する。

ここからはmacで試したこと。
実行可能バイナリファイルを生成して、otool -L で動的リンクされているライブラリを確認してみる。

1$ gcc sample.c -o sample
2
3$ otool -L sample
4sample:
5	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1351.0.0)
6
7$ ls /usr/lib/libSystem.B.dylib
8ls: /usr/lib/libSystem.B.dylib: No such file or directory

/usr/lib/libSystem.B.dylib をリンクすることになっているが、そのファイルが存在していない。 存在しないファイルをリンクするというのが理解し難いが、このライブラリは mac の特定バージョン以降はキャッシュに含まれるようになっていることが判明した。

macOS Big Sur 11.0.1 Release Notes

New in macOS Big Sur 11.0.1, the system ships with a built-in dynamic linker cache of all system-provided libraries. As part of this change, copies of dynamic libraries are no longer present on the filesystem. Code that attempts to check for dynamic library presence by looking for a file at a path or enumerating a directory will fail. Instead, check for library presence by attempting to dlopen() the path, which will correctly check for the library in the cache. (62986286)

(翻訳) macOS Big Sur 11.0.1 では、システムが提供するライブラリのすべてを含む動的リンカーキャッシュが新たに組み込まれた。 この変更により、動的ライブラリのコピーはファイルシステム上に存在しなくなる。 そのため、ファイルのパスを確認したりディレクトリを列挙したりして動的ライブラリの存在をチェックしようとするコードは失敗する。 ライブラリの存在を確認するには、dlopen() を使用してパスを試みることで、キャッシュ内のライブラリを正しく確認できる。

dlopen 関数の役割は?

dlopen() は、指定されたパスの mach-o ファイルを調べる。 ファイルが現在のプロセスと互換性があり、現在のプロセスにまだロードされていない場合は、ロードされリンクされる。 リンクされた後、初期化関数が含まれている場合は、dlopen() が戻る前に呼び出される。 dlopen() は、動的なライブラリとバンドルをロードできる。 dlsym() および dlclose() で使用できるハンドルを返す。 同じパスで dlopen() を 2 回目に呼び出すと、同じハンドルが返されるが、ハンドルの内部参照カウントが増加する。 ※Mach-0 ファイル:macOS および iOS で使用される実行ファイルフォーマット。

以下リンク先を参考にして、dlopen 関数によるリンクが可能か確認してみた。
macOSは動的ライブラリがファイルシステムから見えないらしい

 1$ cat dlopen_test.c
 2#include <mach-o/dyld.h>
 3#include <dlfcn.h>
 4#include <stdio.h>
 5
 6int library_exists(const char* path) {
 7    void* handle = dlopen(path, RTLD_LAZY);
 8    if (handle == NULL) return 0;
 9    dlclose(handle);
10    return 1;
11}
12
13int main(void) {
14    int result;
15    char* path = "/usr/lib/libSystem.B.dylib";
16    result = library_exists(path);
17    printf("library_exists(%s) == %d\n", path, result);
18}
19
20$ gcc dlopen_test.c -o dlopen_test
21
22$ ./dlopen_test
23library_exists(/usr/lib/libSystem.B.dylib) == 1

dlopen/usr/lib/libSystem.B.dylib を正常にロード&リンクできることを確認できた。 実態が存在することは確認できたので、プログラム実行時に何かしらの方法でリンクしているのだろう。

macOS のプログラムローダー(動的リンカーである dyld)は、実行可能バイナリファイルを実行する際に、依存しているライブラリを動的リンクキャッシュからリンクする。 この動的リンクによって、プログラムの実行が効率的に行われる。

  1. 実行可能バイナリの読み込み:
    プログラムが実行されると、dyld がバイナリをメモリに読み込む。 この際、バイナリに埋め込まれた依存関係(例えば、libSystem.B.dylib など)が確認される。

  2. 動的リンクキャッシュの参照:
    dyld は動的リンクキャッシュを参照して、必要なライブラリ(libSystem.B.dylib を含む)がキャッシュに存在するかを確認する。 キャッシュに存在すれば、そのライブラリをメモリにロードする。

  3. シンボルの解決:
    必要なライブラリがロードされた後、dyld はシンボル(関数や変数など)を解決する。 これにより、プログラム内で getcputc といった関数が適切に動作するようになる。

関連ページ