C/C++のビルド過程のメモ(The C++ Build Process Explained)
序文
C++のビルド過程が忘れつつありましたのでそれに関する良い文書を探し、復習を兼ねてメモしてみました。
参照リンク
本文
1. Overview
C++
のビルド過程は既存のC言語のビルド過程に基づき築かれている。C++
のビルド過程中にリンギングはコンパイラーより違うかもしれないし、C
とC++
のスタンダードでは正確なリンギングを記していない。しかしほぼ全てのC++コンパイラは挙動がほぼ同じである。(説明文ではリンギングを説明するためにGNUを使う)
- C/C++のSourceファイルがたくさんある。
- 各ソースファイルに独自の単位でコンパイラを通す
- 現在のファイルにフリープロセッサーを通す。マクロが置換する。
- 従属するヘッダーや親ファイルの内容を入れ込める。
- 最終的にソースファイル
.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
関数にて
- リターン値に対するメモリ空間をスタックに生成する。(
add
関数はstdcall
だと見なす。) - スタックに
a
とb
に対する引数のための空間をスタックに生成する。 - そして
add
関数が終わったあとでmain
関数から中断したところに戻るため、リターンアドレスをスタックに入れる。 add
関数があるアドレスへ移動する。(jmp
とかcall
とか)
B. add
関数にて
- レジスタなどの関数が実行される前のプロセッサーの状態をどこかにセーブする。
a
とb
を足してレジスタかまたはメモリ空間に値を入れる。今のadd
関数では値を即リターン値として扱うため、リターン値の対するメモリ空間に値を入れる。add
関数が実行される前のCPUの状態に戻す。- リターンアドレスを読んで、
main
関数に戻る。
C. main
関数に戻ってから
- スタックから引数の
a
とb
値をポップする。(無くす) スタックにあるリターン値を別のところに使う。
C
またはC++
言語で関数の呼び方やスタックの作り方は各コンパイラによって違うかもしれない。この呼び方や作り方をABI
とする。(Application Binary Interface)もし同じコンパイラだとしてもOSや使っているCPUによって異なるABIを使うかもしれない。複雑になるが、効率になる。
上記に「リターン値やパラメータのためのメモリ空間を作る」とあるが、そうするにはそのパラメータとリターン値のタイプに関するサイズが必然必要となる。そのため、ヘッダーファイルをインクルードする。
- もしパラメータかリターンタイプがポインターか参照だったら、そのタイプに関するサイズを求めなくても良い。これを利用してコンパイル速度をあげようとしたのが
PIMPL
というイディオムである。
2.3 Preprocessor
- 各文の最初に
#
があるのは、その文章はCフリープロセッサーを通すことを示す。
フリープロセッサー以下の作業を行う。
#include
インクルード- Cマクロ拡張 (
#define RAGTODEG(x) ((x) * 57.29878)
) - Cフリープロセッサー分岐作成 (
#if
#ifdef
#endif
など) ファイルの状態に従属するラインコントロール (
__FILE__
__LINE__
)注意すべきところは
#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つがある。
- アセンブリコードが
<main>
関数でグループ化している。 - そして
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++では
Template
とLambda
とそして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++のオーバーローディングを実装するために静的ディスパッチを実行する。そしてオーバーローディングした関数はコンパイルを通してからは全部異なる関数名を持つ。