かみのメモ

コンピュータビジョン・プログラムな話題中心の勉強メモ

C/C++のビルドの仕組みとライブラリ

研究でVisual C++を書くうちに、そういえばリンカとかライブラリの仕組みを理解していなかったな、と思ったので色々調べてみました。

書きやすいからという理由で説明口調の文章にしていますが、多分に間違いを含む可能性があります!(オイ)

気付いた部分があればコメントしていただけるとありがたいです。

※ 2018/12/01 : #pragma once周りを追記しました
※ 2018/12/23 : ライブラリを使うときのビルド設定の話を追記しました

もくじ

1. ビルドとは

まずビルドとはなんでしょうか?

コンピュータの世界において、ビルドは「材料となるファイルから何らかの成果物を作成する処理」を意味します。 C/C++のようなプログラムのビルドの場合、「材料」はソースコード、「成果物」はライブラリ実行ファイルのことを指します。 ライブラリって何?という話についてはこの記事の後半で解説します。

f:id:kamino-dev:20171209201008p:plain:w300

2. C/C++のビルドの仕組み

では、C/C++のビルド処理は具体的に何をしているのでしょうか? 次の図を使いながら説明していきます。

f:id:kamino-dev:20171209201347p:plain

C/C++のビルド処理はプリプロセッサコンパイラアセンブラリンカという4つのパートから成ります。

2.1. プリプロセッサ

プリプロセッサとは名前の通りコンパイルのための下準備をするパートのことです。 ビルドを開始すると、まずプリプロセッサがソースファイル(*.c/*.cppなど)1つ1つに対して次のような処理を行います。

ここで特に重要なのは#includeの解決です。 ソースファイルに#includeが指定されていた場合、プリプロセッサは指定されたヘッダファイルを探してきて、ソースファイルのその位置に中身をまるっと貼り付けます

つまり*.c/*.cppのようなソースファイル中の#includeの部分には*.h/*.hppの中身が丸コピされるということです。 この処理は再帰的に実行されるので、インクルードしたヘッダファイルに#includeが書かれていればそのファイルの中身もコピーされます。

ただし再帰の中で同じヘッダファイルが何度か出てくることも想定されます。 たとえば#include <vector>があちこちのヘッダファイルに書かれている、なんてことはよくあると思います。 このとき同じヘッダファイルを何度もインクルードしてしまうのは効率が悪いです。

そのためヘッダファイルを書くときは中の実装を#ifndef XXXX_HPPで囲むか、先頭に#pragma onceというキーワードを追加することで1度しかインクルードしないようコンパイラに指示するのがC/C++の作法となっています。

ヘッダファイルの実装例

// mylib.h
#ifndef MYLIB_HPP

#define MYLIB_HPP
// ここにコードを書く

#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.cmylib.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.hstdlib.hなどの標準ライブラリもこの仕組みを利用しています。

ここからはこのライブラリの仕組みについて説明していきたいと思います。

3.1. ソースコードを配布

一番単純なライブラリの形式は、ソースコードをそのまま配布するというものです。 プログラムを分割していないのでライブラリと呼べるか微妙なところですが、他人が書いたプログラムを再利用しているのでとりあえずライブラリの一種ということにしておきます。

ライブラリのコードを1つのヘッダファイルにまとめたシングルファイルライブラリとかよく見かけます。

f:id:kamino-dev:20171209202127p:plain:w400

利用方法

自分が書いたソースコードと全く同じようにビルドします。

利点

  • 利用環境に合わせてビルドできる
  • ソースコードを利用者側で改変できる

欠点

  • 利用者側でビルドする手間がかかる

3.2. 静的リンク(static linking)

ライブラリのソースコード事前にコンパイルしオブジェクトコードの状態で配布する方式を静的リンクライブラリ(static link library)といいます。

通常は複数のオブジェクトファイルをまとめた<>静的リンクライブラリファイル(Windowsでは*.libOSX/Linuxではlib*.a)の形で配布されます。

f:id:kamino-dev:20171209202353p:plain:w400

利用方法

静的ライブラリを自分のプログラムに組み込むためには、ビルドのときにヘッダファイル静的リンクライブラリファイルが必要になります。

自分のソースコードにこのヘッダファイルを#includeさせ、リンクのタイミングで静的ライブラリを混ぜ込むようコンパイルオプションを設定してビルドします。 するとライブラリのコードも内包した実行ファイルが作成されます。

一例としてgcclibmylib.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では*.dllOSXではlib*.dylibLinuxではlib*.so)。 設定すべきオプションは開発環境によってまちまちなので、ここでは紹介を省きます。

f:id:kamino-dev:20171209202501p:plain:w400

利用方法

動的リンクライブラリを自分のプログラムから利用するときはヘッダファイル動的リンクライブラリファイルが必要になります。

自分のソースコードにこのヘッダファイルを#includeさせ、先ほど静的リンクのところに書いたのと同じ設定をしてビルドします。 すると一部のコードを内包しない代わりに動的リンクの情報を持った実行ファイルが作成されます。

この実行ファイルが起動され、動的リンクになっている箇所を実行することになった段階で、動的リンクライブラリファイルの探索とメモリへのロードが行われ、ライブラリ側のコードが実行されます。

ただし実行時に動的リンクライブラリを発見できるようにライブラリファイルへのパスを指定しておく必要があります(Windowsの場合は実行ファイルのあるディレクトリも探索するので、ここにdllをコピーするだけでもOKです。ただ、コピーしてしまうと以下で説明するメモリ使用量のメリットが消えますが…)。 動的ライブラリを探索するときの細かい挙動はちょっと検証しきれていないのでこれくらいの表現に留めておきます(ごめんなさい)。 また調べて別の記事で書くかもしれません。

なお、一般的なライブラリでは動的リンクライブラリを単体で配布せず、動的リンクの情報を持った静的ライブラリファイル(*.lib or *.a)と動的ライブラリファイル(*.dll or *.dylib or *.so)の組み合わせを配布していることが多いです。 こうしておけば、ビルドの際には一律に静的ライブラリだけ読み込めばいいことになるからです。 もちろん、この静的ライブラリは処理の実装を持っていないので、実行時に動的リンクが行われます。

利点

  • よく使うライブラリを動的ライブラリにしておくことでファイル容量・メモリ使用量を削減できる
  • ライブラリの内部実装だけを変えるとき、実行ファイルをビルドし直す必要がない
  • 必要になった時点でメモリにロードされるため、フローによっては呼び出されないライブラリがあるときにメモリの節約になる
  • コンパイル済みの状態で配布するので、ライブラリ利用者の手間が減る

欠点

  • 実行時、初めて動的ライブラリの関数を参照するときにメモリのロード時間が長くなる
  • 実行環境に動的ライブラリが正しく配置されているか配慮する必要がある
    • ライブラリに互換性のないバージョン変更があったとき、実行ファイルの更新を忘れてしまっても実行してみるまでミスに気づかない
    • Pathの通し忘れなどで動的ライブラリが発見できなかった場合、プログラムがクラッシュする

3.4. 動的読み込み(dynamic loading)

動的リンクを拡張したような利用形式です。

動的リンクでは、実行ファイルのコンパイルの時点でヘッダファイルと動的リンクライブラリファイルが必要でした。 一方、動的読み込みではビルドの段階ではライブラリ関連のファイルを一切必要としません。 そのかわりに自分のソースコードの中で明示的に「**という名前の動的リンクライブラリの中の**という関数を呼び出す」という風に記述しておきます。 こうすることでライブラリファイルを後付けで動的リンクさせることができます。

f:id:kamino-dev:20171209202524p:plain:w400

利用方法

多くの環境ではdlfcn.hを通して動的読み込みの機能が提供されています。 ライブラリ側は動的リンクライブラリファイルをそのまま使用できます。

利点

  • ライブラリファイルの探索やロードの処理もプログラム上で細かく実装できる
  • 実行ファイルのコンパイルの時点でライブラリ側が完成していなくてもよい

これらの利点から、プラグイン機能の開発に利用されることが多いです。

欠点

  • 実行ファイルの作成の時点ではライブラリの名前と関数名が文字列として埋まっているだけなのでコンパイル時の型チェックの恩恵を受けられない
    • 型や関数名の管理を手動でしなければならない

以上、C/C++のビルドとライブラリの仕組みについてまとめてみました。

こんなに書くはずじゃなかったんですが、気付いたらめちゃくちゃ長くなってました。。。

もしよければ↓の☆を1クリックお願いします!