NEUROMANTIC

自分でC/C++/UE4/Graphics/ゲームなどをやったことをメモするブログ

C/C++のビルド過程のメモ(The C++ Build Process Explained)

序文

C++のビルド過程が忘れつつありましたのでそれに関する良い文書を探し、復習を兼ねてメモしてみました。

参照リンク

github.com


本文

1. Overview

  • C++のビルド過程は既存のC言語のビルド過程に基づき築かれている。
  • C++のビルド過程中にリンギングはコンパイラーより違うかもしれないし、CC++のスタンダードでは正確なリンギングを記していない。しかしほぼ全てのC++コンパイラは挙動がほぼ同じである。(説明文ではリンギングを説明するためにGNUを使う)

https://raw.githubusercontent.com/green7ea/cpp-compilation/master/tree.svg?sanitize=true

  • C/C++のSourceファイルがたくさんある。
  • 各ソースファイルに独自の単位でコンパイラを通す
    1. 現在のファイルにフリープロセッサーを通す。マクロが置換する。
    2. 従属するヘッダーや親ファイルの内容を入れ込める。
    3. 最終的にソースファイル.c .cppがコンパイルされ、オブジェクト.oに変換する。
  • .oオブジェクトファイルを纏めて実行可能なファイルまたはライブラリーへ変換する。

2.1 Trivial C Program

ここからはCプロフラムのソースコードを参照にして説明する(そうだ)。そしてコンパイラはccをつかう。

ccというプログラムはOSプラットフォームによって違われるが、Linuxではg++gccを指す。

add.h

#ifndef ADD_H_INCLUDED
#define ADD_H_INCLUDED
int add(int a, int b);
int sub(int a, int b);
#endif // ADD_H_INCLUDED

add.c

#include "add.h"
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

simple.c

#include <stdio.h>
#include "add.h"
int main(int argv, char **argc) {
  printf("%i\n", sub(add(5, 6), 6));
  return 0;
}

2.2 Why Headers?

  • mainにてadd関数を呼ぶとすると、そのadd関数から返すリターンタイプとそのadd関数がどのタイプの引数を受け止めるかを確かめないといけない。なのでadd関数を呼ぶとしたら以下のような手順を踏む。

A. main関数にて

  1. リターン値に対するメモリ空間をスタックに生成する。(add関数はstdcallだと見なす。)
  2. スタックにabに対する引数のための空間をスタックに生成する。
  3. そしてadd関数が終わったあとでmain関数から中断したところに戻るため、リターンアドレスをスタックに入れる。
  4. add関数があるアドレスへ移動する。(jmpとかcallとか)

B. add関数にて

  1. レジスタなどの関数が実行される前のプロセッサーの状態をどこかにセーブする。
  2. abを足してレジスタかまたはメモリ空間に値を入れる。今のadd関数では値を即リターン値として扱うため、リターン値の対するメモリ空間に値を入れる。
  3. add関数が実行される前のCPUの状態に戻す。
  4. リターンアドレスを読んで、main関数に戻る。

C. main関数に戻ってから

  1. スタックから引数のab値をポップする。(無くす)
  2. スタックにあるリターン値を別のところに使う。

  3. CまたはC++言語で関数の呼び方やスタックの作り方は各コンパイラによって違うかもしれない。この呼び方や作り方をABIとする。(Application Binary Interface)

  4. もし同じコンパイラだとしてもOSや使っているCPUによって異なるABIを使うかもしれない。複雑になるが、効率になる。

  5. 上記に「リターン値やパラメータのためのメモリ空間を作る」とあるが、そうするにはそのパラメータとリターン値のタイプに関するサイズが必然必要となる。そのため、ヘッダーファイルをインクルードする。

  6. もしパラメータかリターンタイプがポインターか参照だったら、そのタイプに関するサイズを求めなくても良い。これを利用してコンパイル速度をあげようとしたのがPIMPLというイディオムである。

2.3 Preprocessor

  • 各文の最初に#があるのは、その文章はCフリープロセッサーを通すことを示す。

フリープロセッサー以下の作業を行う。

  1. #includeインクルード
  2. Cマクロ拡張 (#define RAGTODEG(x) ((x) * 57.29878))
  3. Cフリープロセッサー分岐作成 (#if #ifdef #endifなど)
  4. ファイルの状態に従属するラインコントロール (__FILE__ __LINE__

  5. 注意すべきところは#include <>#include ""に対し、C/C++スタンダードは細かい挙動を提供していない。<>""によっての挙動は全てコンパイラが賄う。

3. Header Trees

  • ヘッダーファイルをインクルードするとき、インクルードするヘッダーファイルが他のヘッダーファイルをインクルードするとしたら、急にビルド時間が遅くなりメモリか性能を相当消費することになりやすい。 今筆者のソースコードをコンパイルするとき、-Hオプションを入れて幾つかのファイルが実際のコンパイルに関与するかを調べた結果、作成したファイルは3個なのに22個もコンパイルされることがわかる。

4. Object File

  • 3.からの手順か終わってからコンパイラが各ソースファイルをコンパイルしてオブジェクトファイル.oにすることが出来る。
  • オブジェクトファイルは他のオブジェクトファイルとのリンギングがまだしてないままのアセンブリコードを持つ。

simple.o

simple.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 83 ec 10             sub    $0x10,%rsp
   8:   89 7d fc                mov    %edi,-0x4(%rbp)
   b:   48 89 75 f0             mov    %rsi,-0x10(%rbp)
   f:   be 06 00 00 00          mov    $0x6,%esi
  14:   bf 05 00 00 00          mov    $0x5,%edi
  19:   e8 00 00 00 00          callq  1e <main+0x1e>
            1a: R_X86_64_PLT32  add-0x4
  1e:   be 06 00 00 00          mov    $0x6,%esi
  23:   89 c7                   mov    %eax,%edi
  25:   e8 00 00 00 00          callq  2a <main+0x2a>
            26: R_X86_64_PLT32  sub-0x4
  2a:   89 c6                   mov    %eax,%esi
  2c:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 33 <main+0x33>
            2f: R_X86_64_PC32   .rodata-0x4
  33:   b8 00 00 00 00          mov    $0x0,%eax
  38:   e8 00 00 00 00          callq  3d <main+0x3d>
            39: R_X86_64_PLT32  printf-0x4
  3d:   b8 00 00 00 00          mov    $0x0,%eax
  42:   c9                      leaveq
  43:   c3                      retq

ここで注目するところは、2つがある。

  1. アセンブリコードが<main>関数でグループ化している。
  2. そして0x19番地のcallq 1e <main+0x1e>が今リンギングされていない他のオブジェクトファイルの関数の<add>関数を指している。(0x25 = <sub>も同様)

5. Symbol Tables

  • 各オブジェクトファイルの最上部ではこのオブジェクトで定義された関数のリストと、そして他のオブジェクトファイルで参照する(こっちでは定義されてない)関数のリストのリストがある。これをSymbol Tableという。

次のようにシンボルテーブルを取り出すことが出来る。

nm add.o > add.sym
nm simple.o > simple.sym

add.o

Position Type Name
0 Text add
14 Text sub

simple.o

Position Type Name
Undefined add
Undefined GLOBAL_OFFSET_TABLE
0 Text main
Undefined printf
Undefined sub

6. Linkerがやること

  • 実行できるファイルにするには生成したオブジェクトファイルを一緒に組んでリンギングをしなければならない。しかしここにそれを実現する2つの方法にまた分かれる。

  • シンボルテーブルによる関数の遷移先を直接リンクしてテーブルなしに直に関数へ飛ぶようにする。(スタティック)

  • 関数への遷移先を記したテーブルを用意し、関数の呼び出しのときにテーブルをルックアップして遷移するようにする。(ダイナミック)

スタティックにする方法は効率的だが、ライブラリには向いてない。反面、動的リンクは静的リンクよりはやや遅いが、柔軟でライブラリの配布に向いている。

7. C++でビルドする時との違い ~ 8. A Basis for Objects

  • 昔のC++のコンパイラはあくまでもC++のコードをCのコードに変換する、即ち前処理コンパイラだったそうだ。
  • しかし今のC++ではTemplateLambdaとそしてManglingというものがあって、Cコンパイラとは細部的としては違う。

この文書で筆者はTemplateは説明せずにManglingだけを説明している。例として以下のコードがあるとする。

extern "C" int add_c(int a, int b) { return a + b; }

int add(int a, int b) { return a + b; }
int add(const int *a, const int &b) { return *a + b; }
float add(float a, float b) { return a + b; }
namespace manu
{
    int add(int a, int b) { return a + b; }
}

そしてコンパイルをして出るオブジェクトファイルのシンボルテーブルを調査すれば以下のように結果が出る。

Position Type Name Signature
0 Text add_c int add_c(int, int)
14 Text _Z3addii int add(int, int)
28 Text Z3addPKiRS int add(const int*, const int&)
44 Text _Z3addff float add(float, float)
5e Text _ZN4manu3addEii int manu::add(int, int)
  • C言語ではシンボルテーブルの関数の名前がプログラマが指定してネームとして扱われるが、C++ではNamespaceかLambdaかTemplateかそしてClassのメンバー関数かによって同じ関数名であってもスコープが違ったりする。なのでC++ではManglingを使ってコンパイラから指定されたネームを実質的な関数名としてする。
  • extern "C"はC++のManglingを消すようにする。こうして既存のCと交換性を持つ。
  • Manglingの規則はまたコンパイラごとに違う。しかし今はほぼ全てのコンパイラがItanium C++ ABIで指定して基準化された規則を使う。

Mangling(Itanium C++)の規則は下のリンクを参照する。 github.com

MSVCは独自のフォーマットを使うらしい。ひでぇ

  • C++のオーバーローディングを実装するために静的ディスパッチを実行する。そしてオーバーローディングした関数はコンパイルを通してからは全部異なる関数名を持つ。