C言語プログラムを実行可能バイナリに変換するプロセスの復習・私的備忘録
目次
実行可能なバイナリファイルができるまでの流れ
C言語ソースファイルから実行可能なバイナリファイルができるまでの流れは?
C言語のソースファイルから実行可能なバイナリファイルができるまでの流れは、以下のようなステップを経ます。
プリプロセス:
- ソースコード内のプリプロセッサディレクティブ(例えば、
#include
や#define
)が処理される。- 外部ヘッダファイルがソースコードに挿入され、マクロが展開される。
- 結果として、元のソースコードが拡張された中間ファイルが生成される。
コンパイル:
- プリプロセッサによって生成された中間ファイルに対して文法チェックと型チェックが行われ、プログラムの構文が正しいか、データ型の整合性が確認される。
- プログラムの構造が理解された後、アセンブリ言語に変換される。
- アセンブリ言語は、CPUに近い低レベルの言語であり、可読性が高く、機械語に変換しやすい形になっている。
アセンブル:
- アセンブリ言語のファイルが機械語に変換され、オブジェクトファイルが生成される。
- 各命令がCPUが実行可能な形式に変換され、データセクションやコードセクションが整理される。
リンク:
- アセンブルで生成されたオブジェクトファイルがリンク処理を経て、実行可能バイナリファイルが生成される。
- コンパイル実行時に指定されたライブラリ、プロジェクト内の他のオブジェクトファイル、および標準ライブラリがリンクされ、外部の関数や変数が統合される。
- これにより、プログラムが正しく機能するために必要なすべてのリソースが一つの実行可能ファイルにまとめられる。
プリプロセス
- ソースコード内のプリプロセッサディレクティブ(例えば、
#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 "sample.c"
これは、sample.c
の1行目を示しています。コンパイラがこのファイルの処理を開始したことを示しています。
# 1 "<built-in>" 1
これはビルトイン機能の1行目を示しています。ここでは、C言語の標準ライブラリやコンパイラのビルトイン機能に関する処理が行われていることを示しています。
# 1 "<built-in>" 3
これは、ビルトインの3行目を指しています。
# 424 "<built-in>" 3
これは、実際のソースコードの424行目でビルトイン機能の3行目があることを示します。これは通常、コンパイラ内部で定義された関数やマクロに関連する情報です。
# 1 "<command line>" 1
これはコマンドラインからの入力を示しており、特に特別な指示があった場合に記録されます。
# 1 "<built-in>" 2
ビルトインの2行目を示しています。
# 1 "sample.c" 2
sample.c
の2行目を示しています。この時点では、実際のコードに戻ります。
extern FILE *__stdinp __attribute__((__swift_attr__("nonisolated(unsafe)")));
これは、標準入力 (stdin
) を指す外部変数__stdinp
の宣言です。__attribute__
はコンパイラに対する特別な情報を提供します。
int getc(FILE *);
標準ライブラリのgetc
関数の宣言です。これは、ファイルから1文字を読み取るための関数です。
int putc(int, FILE *);
標準ライブラリのputc
関数の宣言です。これは、ファイルに1文字を書き込むための関数です。
# 2 "sample.c" 2
sample.c
の2行目から処理が再開されることを示しています。
int main(void){
main
関数の開始です。
char c;
文字型の変数c
を宣言しています。
c = getc(__stdinp);
標準入力から1文字を読み取り、変数c
に格納します。ここでは__stdinp
を使っています。
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情報を更新し、保存された
w30
とw29
のオフセットを示す。 これにより、スタックフレームの情報が正しくデバッグできるようになる。標準入力からの読み取り
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がどのように操作を実行するかを具体的に示しており、ハードウェアの動作に密接に関連している。
getc
や putc
の実態は libc
の中にあることはわかっている。先ほどのアセンブラの実行結果では、どのように libc
内のgetcやputcの実態を参照しているのかが読み取れなかった。ChatGPTさん説明できる?
アセンブリコード内で
getc
やputc
の実態を参照している部分は、主に以下の命令に関連している。
getc
とputc
の呼び出し
bl _getc
:
- ここで、
_getc
関数を呼び出している。 アセンブリでは、bl
命令を使って、関数のアドレスにジャンプし、同時に戻りアドレスをリンクレジスタに保存する。
bl _putc
:
- 同様に、
_putc
関数を呼び出している。グローバルオフセットテーブル(GOT)
これらの関数の実態は、実行時に動的にリンクされるため、直接的なアドレスは記述されていない。 以下の命令でその参照が行われている:
1adrp x8, ___stdinp@GOTPAGE 2ldr x8, [x8, ___stdinp@GOTPAGEOFF]
adrp
とldr
:adrp
命令で、___stdinp
や___stdoutp
のページアドレスを取得し、その後ldr
命令で実際のポインタをロードする。 このプロセスは、動的リンクのために必要である。動的リンク
動的ライブラリ:
実行時に、プログラムは必要なライブラリ(ここではlibc
)を読み込む。_getc
や_putc
の実態は、リンク時にそれぞれのアドレスが解決され、GOT(グローバルオフセットテーブル)を介して参照される。GOTの役割:
GOTは、動的にリンクされる関数のアドレスを管理するためのテーブルであり、実行時にアドレスが設定される。 これにより、実行中のプログラムが必要なライブラリの関数を適切に呼び出せるようになる。まとめ
アセンブリコードでは、
getc
やputc
の実態を直接参照していないのは、これらの関数が動的にリンクされるためである。 代わりに、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ライブラリで、
printf
やscanf
、getc
、putc
などの基本的な入出力機能が含まれている。- 動的ライブラリ: コンパイラやリンカが自動的に標準の動的ライブラリ(例:
libc.so
)をリンクする。-lm
オプションで数学ライブラリlibm
をリンクすることもある。macOS
- libSystem.dylib: macOSのCランタイムライブラリで、C標準ライブラリの機能を提供する。 この中には、
getc
やputc
を含む標準入出力関数が含まれている。- その他のライブラリ: プログラムが使用する他のライブラリ(例えば、
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
)は、実行可能バイナリファイルを実行する際に、依存しているライブラリを動的リンクキャッシュからリンクする。 この動的リンクによって、プログラムの実行が効率的に行われる。
実行可能バイナリの読み込み:
プログラムが実行されると、dyld
がバイナリをメモリに読み込む。 この際、バイナリに埋め込まれた依存関係(例えば、libSystem.B.dylib
など)が確認される。動的リンクキャッシュの参照:
dyld
は動的リンクキャッシュを参照して、必要なライブラリ(libSystem.B.dylib
を含む)がキャッシュに存在するかを確認する。 キャッシュに存在すれば、そのライブラリをメモリにロードする。シンボルの解決:
必要なライブラリがロードされた後、dyld
はシンボル(関数や変数など)を解決する。 これにより、プログラム内でgetc
やputc
といった関数が適切に動作するようになる。