C/C++のビルドの仕組みとライブラリ
研究でVisual C++を書くうちに、そういえばリンカとかライブラリの仕組みを理解していなかったな、と思ったので色々調べてみました。
書きやすいからという理由で説明口調の文章にしていますが、多分に間違いを含む可能性があります!(オイ)
気付いた部分があればコメントしていただけるとありがたいです。
※ 2018/12/01 : #pragma once
周りを追記しました
※ 2018/12/23 : ライブラリを使うためのビルド設定の話を追記しました
スポンサーリンク
もくじ
1. ビルドとは
まずビルドとはなんでしょうか?
コンピュータの分野において、ビルドは材料となるファイルから何らかの成果物を作成する処理を意味します。 C/C++のようなプログラム言語では、ソースコードを材料として、ライブラリや実行ファイルという成果物を作成することをビルドと呼びます。
※ ライブラリって何?という話については記事の後半で解説します。
2. C/C++のビルドの仕組み
では、C/C++のビルド処理は具体的に何をしているのでしょうか? 次の図に沿って説明していきます。
C/C++のビルド処理はプリプロセッサ・コンパイラ・アセンブラ・リンカという4つのパートから成ります。
2.1. プリプロセッサ
プリプロセッサとは、名前の通りコンパイルのための下準備をするパートのことです。
ビルドを開始すると、まずプリプロセッサがソースファイル(*.c
/*.cpp
など)1つ1つに対して次のような処理を行います。
#include
/#define
などのプリプロセッサディレクティブの解決- コメントの削除
ここで特に重要なのは#include
の解決です。
ソースファイルの中で#include
が指定されていた場合、プリプロセッサは指定されたヘッダファイルを探してきて、ソースファイルのその位置に中身をまるっと貼り付けます。
つまり*.c
/*.cpp
のようなソースファイルの#include
の部分には*.h
/*.hpp
の中身が丸コピされるということです。
この処理は再帰的に実行されるので、インクルードしたヘッダファイルに#include
の指定があればそのファイルの中身もコピーされます。
ただし、再帰の中で同じヘッダファイルが何度か出てくることも想定されます。
たとえば#include <vector>
があちこちのヘッダファイルに書かれている、なんてことはよくあると思います。
このとき、同じヘッダファイルを何度もインクルードしてしまうのは効率が悪いです。
こうしたケースへの対策として、ヘッダファイルを書くときは中の実装を#ifndef XXXX_H
で囲むか、先頭に#pragma once
というキーワードを追加することで、1度しかインクルードしないようコンパイラに指示するのがC/C++の作法となっています。
ヘッダファイルの実装例
// mylib.h #ifndef MYLIB_H #define MYLIB_H // ここにコードを書く #endif
もしくは
// mylib.h #pragma once // ここにコードを書く
2.2. コンパイラ
プリプロセッサを通過したソースファイルはコンパイラに渡され、コンパイルが実行されます。
コンパイルは「人間が読み書きしやすいように設計された言語(高水準言語)のコードをコンピュータが理解しやすい形式に翻訳する処理」を意味します。 今回のケースではC/C++で書かれたファイルをアセンブラ言語のファイルに変換する処理のことです。 アセンブラ言語とは機械語を扱いやすいように書き直した低水準言語です。
コンパイラは、プリプロセッサから受け取ったソースファイル1つ1つをアセンブラ言語のファイルに変換します。
さて、先ほど「コンパイラはソースファイルをそれぞれアセンブラ言語のファイルに変換する」と説明しました。 ここでソースファイルを跨いで参照されている関数はどうやって処理されるのかという疑問が生じます。
例えば次のようなソースコードをビルドするとしましょう。
サンプルコード
// mylib.h int add(int, int);
// mylib.c int add(int a, int b){ return a + b; }
// main.c #include <stdio.h> #include "mylib.h" int main (void){ printf("%d", add(2,3)); return 0; }
これをビルドするとき、main.c
をコンパイルする段階ではmylib.c
にあるadd
関数の実装は見えていません。
main.c
とmylib.c
は別々にコンパイルされるためです。
それでもちゃんとmain.c
のコンパイルに成功します。
実は、この問題を解決するための仕組みがヘッダファイルとプロトタイプ宣言です。
上の例では、mylib.hにint add(int, int);
のようにadd関数のプロトタイプ宣言が書かれています。
mylib.hはプリプロセッサの段階でmain.cに丸コピされるので、main.cのコンパイル時には「int型の引数を2つ取ってint型の値を返すadd
という関数がある」という情報が得られることになります。
コンパイラはその手がかりだけを基にコンパイルを完了させ、addの実装の中身は保留します。
こうして、他のファイルで定義されている関数の実装を保留した状態(外部参照が未解決であるといいます)のアセンブラ言語のファイルがソースファイルの数だけ作成されます。
2.3. アセンブラ
コンパイラで生成されたアセンブラ言語のファイルを機械語のファイルに変換するのがアセンブラです。 ここでCPUのアーキテクチャに合わせた機械語が生成されます。
生成される機械語のファイルはオブジェクトファイルまたは、機械語が2進数の命令の羅列であることからバイナリファイルと呼んだりします。 オブジェクトファイル内の機械語に翻訳されたプログラムはオブジェクトコードと呼ばれます。
2.4. リンカ
最後に、作成された複数のオブジェクトファイルを1つにまとめる作業をリンクといいます。 これを実行するのがリンカです。
リンカは、main
関数のあるオブジェクトファイルから芋づる式に、保留された関数の実装(未解決の外部参照)を探索していきます。
そして、必要な情報を全て集めて1つの実行ファイルないしライブラリファイルを作成します。
ここまでの処理が滞りなく終われば、晴れてビルド完了となります。
3. ライブラリの種類と仕組み
ここまで、C/C++のビルドの仕組みを説明してきました。
ファイルを1つ1つコンパイル・アセンブルしてからリンクするこの仕組みは一見ムダが多いように思えますが、ライブラリの概念を導入するときに威力を発揮します。
ライブラリとは、ビルドの管理を楽にしたり他人の書いたプログラムを再利用したりするために、プログラムをいくつかの単位に分割する仕組みのことです。
例えばstdio.h
やstdlib.h
などの標準ライブラリもこの仕組みを利用しています。
ここからは、このライブラリの仕組みについて説明していきたいと思います。
3.1. ソースコードを配布
最も単純なライブラリの形式は、ソースコードをそのまま配布するというものです。 プログラムを分割していないのでライブラリと呼べるか微妙なところですが、他人が書いたプログラムを再利用しているのでとりあえずライブラリの一種ということにしておきます。
利用方法
自分が書いたソースコードと全く同じようにビルドします。
利点
- 利用環境に合わせてビルドできる
- ソースコードを利用者側で改変できる
欠点
- 利用者側でビルドする手間がかかる
3.2. 静的リンク(static linking)
ライブラリのソースコードを事前にコンパイルしオブジェクトコードの状態で配布する方式を静的リンクライブラリ(static link library)といいます。
通常は複数のオブジェクトファイルをまとめた静的リンクライブラリファイル(Windowsでは*.lib
、macOS/Linuxではlib*.a
)の形で配布されます。
利用方法
静的ライブラリを自分のプログラムに組み込むためには、ビルドのときにヘッダファイルと静的リンクライブラリファイルが必要になります。
自分のソースコードにこのヘッダファイルを#include
させ、リンクのタイミングで静的ライブラリを混ぜ込むようコンパイルオプションを設定してビルドします。
するとライブラリのコードも内包した実行ファイルが作成されます。
一例として、gccでlibmylib.a
を混ぜながらmain.c
をビルドするときのコマンドを紹介しておきます。
gcc main.c -I <mylib.hがあるディレクトリへのパス> -L <libmylib.aがあるディレクトリへのパス> -l mylib
指定すべき要素は①ライブラリのヘッダファイルの場所、②ライブラリファイルの場所、③ライブラリの名前、の3つです。
Visual Studioの場合は、プロジェクトのプロパティ内の①C/C++>追加のインクルードディレクトリ、②リンカ>追加のライブラリディレクトリ、③リンカ>入力>追加の依存ファイル、の設定項目がそれぞれ対応しています。
利点
- 全てのコードが実行ファイルにまとまるので取り回しが楽
- 最初にまとめてメモリにロードされるので実行が高速
- コンパイル済みの状態で配布するので、ライブラリ利用者の手間が減る
欠点
- 同じライブラリを利用するプログラムが複数あっても、それぞれの実行ファイルにライブラリのコードが丸コピされる
- つまりファイル容量が大きくなりがち
- プログラム起動時のメモリへのロード時間が長くなる
- フローによっては使われないコードもメモリにロードされるため、メモリ使用量が増える
3.3. 動的リンク(dynamic linking)
静的ライブラリを使ったパターンでは、全てのコードを内包した実行ファイルが作成されるのでした。 これに対し、リンクが不完全な実行ファイルを作成しておいて実行時にリンクを行う方式を動的リンクといいます。 動的リンクで使用されるライブラリは動的リンクライブラリ(dynamic link library)もしくは共有ライブラリと呼ばれます。
コンパイラのオプションを適当に指定してビルドすると動的リンクライブラリファイルが作成されます(Windowsでは*.dll
、macOSではlib*.dylib
、Linuxではlib*.so
)。
設定すべきオプションは開発環境によってまちまちなので、ここでは紹介を省きます。
利用方法
動的リンクライブラリを自分のプログラムから利用するときはヘッダファイルと動的リンクライブラリファイルが必要になります。
自分のソースコードにこのヘッダファイルを#include
させ、先ほど静的リンクのところに書いたのと同じ設定をしてビルドします。
すると、一部のコードを内包しない代わりに動的リンクの情報を持った実行ファイルが作成されます。
この実行ファイルが起動され、動的リンクになっている箇所を実行することになった段階で、動的リンクライブラリファイルの探索とメモリへのロードが行われ、ライブラリ側のコードが実行されます。
ただし、実行時に動的リンクライブラリを発見できるようにライブラリファイルへのパスを指定しておく必要があります(Windowsの場合は実行ファイルのあるディレクトリも探索するので、ここにdllをコピーするだけでもOKです。ただ、コピーしてしまうと以下で説明するメモリ使用量のメリットが消えますが…)。 動的ライブラリを探索するときの細かい挙動はちょっと検証しきれていないので、これくらいの表現に留めておきます、ごめんなさい。 また調べて別の記事で書くかもしれません。
なお一般的なライブラリでは、動的リンクライブラリを単体で配布せず、動的リンクの情報を持った静的ライブラリファイル(*.lib
or *.a
)と動的ライブラリファイル(*.dll
or *.dylib
or *.so
)の組み合わせを配布していることが多いです。
こうしておけば、ビルドの際には一律に静的ライブラリだけ読み込めばいいことになるため、ビルド時の見通しが良くなります。
もちろん、この静的ライブラリは処理の実装を持っていないので、実行時に動的リンクが行われ、動的ライブラリファイルが参照されます。
利点
- よく使うライブラリを動的ライブラリにしておくことでファイル容量・実行時のメモリ使用量を削減できる
- ライブラリの内部実装だけを変えるとき、実行ファイルをビルドし直す必要がない
- 必要になった時点でメモリにロードされるため、フローによっては呼び出されないライブラリがあるときにメモリの節約になる
- コンパイル済みの状態で配布するので、ライブラリ利用者の手間が減る
欠点
- 実行時、初めて動的ライブラリの関数を参照するときにメモリのロード時間が長くなる
- 実行環境に動的ライブラリが正しく配置されているか配慮する必要がある
- ライブラリに互換性のないバージョン変更があったとき、実行ファイルの更新を忘れてしまっても実行してみるまでミスに気づかない
- Pathの通し忘れなどで動的ライブラリが発見できなかった場合、プログラムがクラッシュする
3.4. 動的読み込み(dynamic loading)
動的読み込みは、動的リンクを拡張したようなライブラリの形式です。
動的リンクでは、実行ファイルのコンパイルの時点でヘッダファイルと動的リンクライブラリファイルが必要でした。
一方、動的読み込みではビルドの段階ではライブラリ関連のファイルを一切必要としません。
そのかわりに、自分のソースコードの中で明示的に「xxx
という名前の動的リンクライブラリの中のyyy
という関数を呼び出す」という風に記述しておきます。
こうすることでライブラリファイルを後付けで動的リンクさせることができます。
利用方法
多くの環境ではdlfcn.h
を通して動的読み込みの機能が提供されています。
ライブラリ側には、普通の動的リンクライブラリファイルをそのまま使用します。
利点
- ライブラリファイルの探索やロードの処理もプログラム上で細かく実装できる
- 実行ファイルのコンパイルの時点でライブラリが完成していなくてもよい
これらの利点から、プラグイン機能の開発に利用されることが多いです。
欠点
- 実行ファイルの作成の時点ではライブラリの名前と関数名が文字列として埋まっているだけなのでコンパイル時の型チェックの恩恵を受けられない
- 型や関数名の管理を手動でしなければならない
以上、C/C++のビルドとライブラリの仕組みについてまとめてみました。
こんなに書くはずじゃなかったんですが、気付いたらめちゃくちゃ長くなってました。。。
このブログではC++開発でよく使うCMakeについても紹介しているので、よければこちらも読んでみてください。
もしよければ↓の☆を1クリックお願いします!