📑

C言語で学ぶリンカーとシンボル解決のプロセス

に公開

シンボル(Symbol)とは?

まずは「シンボル」について簡単に確認しておきます。
プログラムにおける シンボル(Symbol)とは、関数や変数などの名前(識別子)と、それに対応するメモリ上のアドレスや値を結びつける情報のことです。

  • 関数名(printf, puts など)
  • グローバル変数(static int invocation など)
  • モジュール単位で定義される変数や定数

自分たちが日頃書いている変数名や関数呼び出しは、最終的にはこの「シンボル」が解決された後に、機械語レベルで動作します。

シンボル解決(Symbol Resolution)とは?

プログラムをコンパイルし、最終的にリンクして実行ファイルを生成する過程で、コンパイラやリンカーがシンボル同士の対応関係を確定する処理をSymbol Resolution(シンボル解決) と呼びます。たとえば、ある関数を呼び出すときにそのシンボル(関数名)がどのオブジェクトファイル内のどのアドレスを指すのかをリンク時に最終決定する、というような動きがこれに当たります。

このように、シンボル はプログラムの構造をメモリ配置と結びつける重要な要素であり、シンボル解決 はそれらを最終的に対応付けて、プログラムを実行可能な形へまとめる仕組みです。

C言語から、実際にプログラムが実行されるまでの流れは以下のようになります。(以降C言語を例にします)

  1. ソースコード(.c)
  2. コンパイル&アセンブル(.oファイル生成)
  3. リンカ(シンボル解決&再配置)
    • 複数のオブジェクトファイルを結合して実行ファイルを生成
  4. 実行ファイル(ELF/EXE)
  5. ロード&実行(OS)

1. シンボルの種類

Symbolは主に以下の2種類に分類されます。

1.1 強いシンボル(Strong Symbol)

初期化済みのグローバル変数や関数の定義が、Strong Symbolとして扱われます。

// strong_symbol.c

#include <stdio.h>

int global_count = 10; // 強いシンボル

void print_count(void) { // 強いシンボル
    printf("global_count = %d\n", global_count);
}

int main(void) {
    print_count();
    return 0;
}

int global_count = 10;は「値が初期化されているグローバル変数」なので強いシンボルになります。
void print_count(void) { ... }は、関数定義なので強いシンボルになります。
これらはリンカがリンク時に、重複する強いシンボル(同名の関数や初期化済みグローバル変数)が複数定義されているとエラーを出します。

1.2 弱いシンボル(Weak Symbol)

参照はされているが、定義がまだ見つかっていないシンボルはWeak Symbolとして扱われます。

// weak_symbol.c

#include <stdio.h>

int global_flag;  // 弱いシンボル(未初期化)

int main(void) {
    if (global_flag == 0) {
        printf("global_flag is 0\n");
    } else {
        printf("global_flag is not 0\n");
    }
    return 0;
}

int global_flag;は「未初期化のグローバル変数」なのでWeak Symbolとみなされます。
このWeak Symbolは、別のファイルで同名の変数がStrong Symbolとして定義されると、そちらが優先されてリンクされます。

2.シンボルの競合(Symbol Conflict)

2.1 強いシンボル同士の衝突

先ほどの例にも出てきたように、同じ名前のSymbolが複数のファイルで定義されていた場合、リンカーはエラーを発生させます。

// file1.c
int a = 10;

// file2.c
int a = 20;

これをコンパイルすると、aが重複定義されているため、エラーになります。

2.2 強いシンボルと弱いシンボルの衝突

上記でも説明しましたが、シンボルには強い弱いの概念があります。
これらが衝突した場合のケースが以下になります。

// weak.c
#include <stdio.h>

__attribute__((weak)) void greet() {
    printf("Weak greet function\n");
}

// strong.c
#include <stdio.h>

void greet() {
    printf("Strong greet function\n");
}

この場合、リンカーは強いシンボル(greet in strong.c)を優先し、弱いシンボルは無視されます。

3. シンボルの解決が行われる手順

では実際にどのようにシンボル解決がされるのか、例を用いて説明します。
普段開発しているプログラムは複数のファイルに分割して作成されることが多いため、リンカーはシンボルを解決し、適切なアドレスを割り当てる必要があります。

数ファイルで開発していると、それぞれの .o ファイルには以下のようなシンボル情報が入っています。

  • 定義済みシンボル(Defined Symbol)
    関数や変数が「ここで定義されている」という情報
  • 未定義シンボル(Undefined Symbol)
    このファイルでは定義されていないが、どこかで使われているシンボル

繰り返しの説明になりますが、リンカーはこれらを集めて「どの未定義シンボルが、どこで定義されているか」を最終的に対応付けます。

3.1 具体例

// main.c
#include <stdio.h>

// greet の定義は別ファイルにある
extern void greet();

int main() {
    greet();
    return 0;
}
// greet.c
#include <stdio.h>

void greet() {
    printf("Hello, world!\n");
}

これらをコンパイルすると、それぞれのオブジェクトファイルに以下のようなシンボルが含まれます。

  • main.o
    • 未定義シンボル: greet
    • 定義済みシンボル: main
  • greet.o
    • 定義済みシンボル: greet

3.1.1 シンボル解決の流れ

  1. リンカはmain.oの未定義シンボルgreetを探す
  2. greet.ogreetが定義されていることを発見し、対応付ける
  3. 参照元のコードでgreetを呼び出す機械語命令に対して、greet のアドレスを埋め込む (→ これが再配置Relocationといいます)
  4. 実行ファイルが生成される

コンパイル・リンク時のコマンド例は以下になります。

gcc -c main.c   # main.o の生成
gcc -c greet.c  # greet.o の生成
gcc main.o greet.o -o my_program  # リンク: シンボル解決・再配置を経て実行ファイルが生成

3.2 シンボルテーブルの確認

nmコマンド(参考)などを使うと、各ファイル内のシンボル状況を確認できます。

nm main.o

たとえば、上記を実行すると(Mac上で実行しています)

U greet
                 U _greet
0000000000000000 T _main
0000000000000000 t ltmp0
0000000000000030 s ltmp1

のように UUndefined の略で、このシンボルはこのオブジェクトファイル内では定義されておらず、リンク時に別のオブジェクトファイルから解決される必要があることを示します。 TTextセクション に定義されているグローバルシンボルであることを意味し、ここでは main 関数が定義されています。

次に、greet.oも同様に実行してみます。

nm greet.o
0000000000000000 T _greet
                 U _printf
000000000000001c s l_.str
0000000000000000 t ltmp0
000000000000001c s ltmp1
0000000000000030 s ltmp2

上記のようになり、Tは、グローバルシンボルであり、関数greetの定義が含まれています。
_printfは未定義のシンボルであり、このオブジェクトファイル内ではprintfの定義は見つからず、リンク時に標準ライブラリなどから提供される必要があるシンボルであることを示しています。
000000000000001c s l_.strについて、小文字のsは、このシンボルがローカルシンボルであり、読み取り専用データ領域(文字列リテラルなど)の一部であることを示しています。

3.3 シンボル解決と再配置

シンボル解決(Symbol Resolution)

リンカは、各オブジェクトファイル(.o)のシンボルテーブルを読み込み、以下の処理を行います。

  • 未定義シンボルの対応付け
    各オブジェクトファイル内の「未定義シンボル(Undefined Symbol)」は、他のファイルで定義された「定義済みシンボル(Defined Symbol)」と対応付けられます。
    たとえば、main.oに存在するextern void greet();の参照は、greet.oにあるvoid greet()の定義にリンクされます。

  • 衝突時の優先順位の決定
    同じシンボル名が複数のファイルに存在する場合、強いシンボルと弱いシンボルのルールに基づいて、どちらを採用するかが決定されます。

このプロセスにより、各シンボルの「実体」がどこにあるのかが明確になり、プログラム全体で正しい関数呼び出しや変数参照が可能になります。

再配置(Relocation)

シンボル解決によって確定した各シンボルの最終アドレスを、各オブジェクトファイル内の命令やデータ参照に反映させる作業を再配置(Relocation)と呼びます。

  • 再配置エントリの利用
    各オブジェクトファイルには、シンボルへの参照箇所とそれが配置されるべき相対位置を示す「再配置エントリ」が含まれています。リンカはこれらのエントリを元に、シンボル解決で得た最終アドレスを、該当する機械語命令やデータ領域に埋め込みます。

  • 最終的なアドレス設定
    これにより、実行時に各命令が正しいアドレスにジャンプし、グローバル変数も正しく参照されるようになります。結果として、すべての参照が正しく置き換えられた実行可能なプログラムが完成します。

この2つの処理を経て、最終的な実行ファイルが完成します。

実行ファイルの生成とリンクコマンド

複数の.oファイルを結合し、実行可能なプログラムを生成する最終ステップは、リンク(Linking)です。

gcc main.o greet.o -o combining_program

この gcc コマンドは内部的にリンカを呼び出し、以下の処理を実行します。

  • main.o と greet.o に含まれる 未定義シンボルの解決(Symbol Resolution)
  • 各シンボルのアドレスを反映する再配置(Relocation)
  • 実行可能形式(例:Mach-OやELF)への変換

最終的にcombining_programという名前の実行ファイルが生成され、OS上で実行できる形式になります。実行結果は以下です。

4. シンボルのスコープ

シンボルのスコープについては以下になります。

4.1 ローカルシンボル(Local Symbol)

特定のファイル内だけで使われるSymbol。

4.2 グローバルシンボル(Global Symbol)

他のファイルからも参照できるSymbol。

int global_var = 100;  // 他のファイルからも使える

グローバルシンボルはexternを使って他のファイルから参照可能。

まとめ

  • シンボルとは、関数や変数などの名前と、それに紐づくアドレスなどの情報。
  • シンボルには「強いシンボル」「弱いシンボル」「未定義シンボル」などがあり、リンカはそれらを解析・統合してプログラム全体を構成する。
  • リンカは、未定義シンボルに対応する定義済みシンボルを探し、最終的なメモリアドレスを決定する。
  • 決定されたアドレスを機械語の命令やデータに反映する処理が再配置(Relocation)という。
  • シンボルの競合が発生した場合は、強いシンボルが優先され、強いシンボル同士の衝突はエラーとなる。
  • nmコマンドを使えば、オブジェクトファイル内のシンボル情報を視覚的に確認できる。

参考

https://sourceware.org/binutils/docs/binutils/nm.html

https://www.brainkart.com/article/Assembly-and-Linking_11818/

https://binarydodo.wordpress.com/2016/07/01/symbol-resolution-during-link-editing/

https://www.youtube.com/watch?v=6XVUIeAaROU

Discussion