Edited at

リンクエラーの話

More than 1 year has passed since last update.

初心者C++er AdCということで、初心者向けの記事を書こうと思います。

昨年みたいにあたまのおかしい記事を書いたりはしません。本当です。


リンカーとは

一般的なC/C++の開発環境では、ソースコードから実行ファイルにする途中に、リンカーというものが利用されます。

C/C++には分割コンパイルという仕組みがあり、プログラムを複数のソースファイルに分け、別々にコンパイルすることができます。この時、分割されたそれぞれのソースファイルを翻訳単位と言います。

一つのアプリケーションを単一の翻訳単位にすると、変更があった時に全て再コンパイルしないといけなくなるのですが、分割コンパイルの仕組みがあれば変更のあった箇所だけ再コンパイルすればいいので、コンパイル時間の短縮につながります。

しかし、分割コンパイルされたままでは、プログラムを実行させることができません。そこで、分割コンパイルされたファイル(オブジェクトファイルと言います)を結合する仕組みが必要になります。

そこで登場するのがリンカーです。

リンカーはコンパイラが出力したオブジェクトファイルのシンボルテーブルを参照し、別の翻訳単位内で定義された関数やグローバル変数などを結合します。それにより、分割コンパイル時には宣言しか存在しなかった関数や変数を、実行時に呼び出すことができるようになるのです。

ハローワールドだとか、FizzBuzzだとか、そういった単一ファイルで完結する小さなプログラムを作る時にリンカーを意識することはあまりないでしょう。

主流なコンパイラであるGCC, Clang, MSVCのいずれも、デフォルトの挙動で自動的にリンカーまで呼び出してくれるようになっています。


リンクエラー

さて、コンパイル時に出るエラー、すなわちコンパイルエラーに対し、リンク時に出るエラーはリンクエラーと言います。

C++は膨大なコンパイルエラーを出すことで有名ですが、リンクエラーはリンクエラーで、ソースコードの情報が失われているため、原因箇所の特定が難しいという欠点があります。

それでも、昔はマングリング1された名前がエラーメッセージに含まれていて何が何だか分からなかったのが、最近のリンカーはデマングル2した名前を表示してくれたりするので、だいぶ優しくなったものだと思います。

リンクエラーの発生する原因は、


  • 実装忘れ

  • ODR違反

  • オブジェクトファイルのリンク忘れ

  • 依存ライブラリのリンク忘れ

  • ライブラリのバージョン違い

等が考えられます。


実装忘れの場合

例えば以下のようなソースをコンパイルすると、リンクエラーが発生します。


sample1.cpp

void f();

int main() {
f();
}



shell

g++ -c sample1.cpp -o sample1.o

g++ sample1.o


stdout

sample1.o: In function `main':

sample1.cpp:(.text+0x5): undefined reference to `f()'
collect2: error: ld returned 1 exit status

見れば分かる通り、fの定義がありません。おそらくこれはすぐに気づくことでしょう。

とりあえず宣言だけ書いておいて、後で実装しようとか思ってたらうっかり、なんて場合ですね。

普通に実装者が気づくと思うので、すぐに修正できると思います。

ただ、気をつけたいのが、マルチプラットフォームの開発でどうしてもプラットフォームごとに別々の実装が必要になった場合などです。

Linuxで開発してたら大丈夫だったけどWindowsの実装を忘れてたとかそういうこともありえなくはありません。

まあ、Windowsをターゲットで開発しているなら、WindowsマシンでCIをすればそんなことにはならないと思いますが。


ODR違反の場合

実装忘れの場合のリンクエラーは、あるべきものが見つからなかった場合のエラーです。

それとは逆に、1個だけで良いものが2個以上見つかってしまった場合にもリンクエラーが発生します。

これがODR違反です。

ただし、ODR違反はかならずリンクエラーになるとは限りません。

実装はコンパイルの時点でODR違反を検知してエラーになる場合もあれば、ODR違反を起こしていても何も警告すらしてくれないこともあります。

ここでは、ODR違反による典型的なエラーの例を挙げてみます。


sample2-1.cpp

int f() {}

int main() {}


sample2-2.cpp

int f() {}



shell

g++ -c sample2-1.cpp -o sample2-1.o

g++ -c sample2-2.cpp -o sample2-2.o
g++ sample2-1.o sample2-2.o


stdout

sample2-2.o: In function `f()':

sample2-2.cpp:(.text+0x0): multiple definition of `f()'
sample2-1.o:sample2-1.cpp:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

関数 f の定義が2つあるので、2つ目が見つかった時点でエラーになっています。リンカーはオブジェクトを渡された順にシンボルを調べていくので、この例では、「sample2-2.oの中でf()の多重定義が見つかった、最初の定義はsample2-1.oの中にある」ということを報告しています。

グローバル空間で名前が衝突するとこのエラーが発生することになります。

短い名前は衝突しやすいので、衝突を防ぐためにも名前空間を使いましょう。

それから、初心者がやりがちなODR違反の例として、ヘッダーファイルに関数の実装を書いてしまう場合などがあります。


sample2.hpp

void f() {}



sample2-3.cpp

#include "sample2.hpp"

int main() {}


sample2-4.cpp

#include "sample2.hpp"



shell

g++ -c sample2-3.cpp -o sample2-3.o

g++ -c sample2-4.cpp -o sample2-4.o
g++ sample2-3.o sample2-4.o


stdout

sample2-4.o: In function `f()':

sample2-4.cpp:(.text+0x0): multiple definition of `f()'
sample2-3.o:sample2-3.cpp:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

上記のような例では、一回しか定義を書いていないつもりでも、"sample2-3.cpp"と"sample2-4.cpp"の両方に定義が存在することになるので、ODR違反です。

また、この例では実際に問題があるのは"sample2.hpp"なのですが、リンク時にはヘッダーファイルの情報は失われているので、リンカーはsample2.hppにエラーがあると知らせてくれません。

エラーで通知されたファイルで原因が見つからなければ、そこからインクルードしているファイルを疑ってみましょう。

蛇足ですが、ヘッダーファイルに定義を書いたからといって、必ずしもODR違反になるとは限りません。

クラス定義・クラス定義内での関数定義・テンプレート・インライン関数・インライン変数などはヘッダーファイル内にも定義を書くことができます。


オブジェクトファイルのリンク忘れの場合

新しいファイルを追加した、けどビルドスクリプトを更新していなかった、といった場合に発生します。

これも実装忘れと同様、実装者が大抵気づくと思います。


依存ライブラリのリンク忘れ

これもありがちなエラーです。

特に、リンカーについての理解が浅い初心者のうちは、何を追加すればいいのか分からないことが多いでしょう。

GCCやClangの場合は、-Lオプションでライブラリの存在するディレクトリを、-lオプションでライブラリ名を追加することでライブラリをリンクすることができます3が、それを知らないといきなり変なエラーが出て混乱することになります。

例えば、Linuxではマルチスレッドプログラミングをするために、"pthread"というライブラリに依存します。

pthreadはGCCやClangのデフォルトではリンクされないので、例えば以下のようなコードをビルドすると、


sample3.cpp

#include <thread>


int main() {
std::thread([](){
}).join();
}


shell

g++ -std=c++14 -c sample3.cpp -o sample3.o

g++ sample3.o


stdout

sample3.o: In function `std::thread::thread<main::{lambda()#1}>(main::{lambda()#1}&&)':

sample3.cpp:(.text+0x213): undefined reference to `pthread_create'
collect2: error: ld returned 1 exit status

と、ちょっと「うっ」となるエラーが出て来ることになります。

このエラーで大事なのはundefined reference to `pthread_create'の部分なのですが、それが分かっても「pthread_createってなんだよ」となる人も多いのではないでしょうか。

実は、GCCでは、pthreadに依存したマルチスレッド環境をターゲットにビルドするには、-pthreadというオプションをつける必要があります。

-pthreadオプションをつけると、pthreadのライブラリファイルがリンクされます4

pthreadはやや例外的な部分もありますが、マルチスレッドプログラミング初心者が陥りがちな罠なので紹介しました。

他のライブラリの場合は、-lオプションを使ってライブラリを指定することになります。


ライブラリのバージョン違い

同じライブラリであっても、バージョンが変わると互換性を失うことがあります。

特に、新しいライブラリで定義されたものが、古いライブラリに存在していないことなどは珍しくありません。

普通はヘッダーファイルとライブラリファイルを同時に更新すれば問題ない(あるいは問題があってもコンパイルエラーになる)のですが、ビルド環境にいつの間にか複数のバージョンがインストールされていて、古いバージョンのライブラリがリンクされていた、なんてこともありえないとは言えません。

こうなってしまうと、原因の特定に非常に時間がかかることもありえます。

インストールされたバージョンはしっかり把握して、できれば開発環境の依存ライブラリは他とは分離しておきましょう。


おまけ:使うコマンドを間違えた

GCCでC++をコンパイルするにはg++コマンドを、ClangでC++をコンパイルするにはclang++コマンドを利用しますが、実はgccコマンドやclangコマンドを使ってもC++のコンパイルを行うことはできます。

拡張子が.cppや.cxxなどのファイルは(-xオプションを指定しない限り)C++として解釈されるためです。

しかし、gccコマンドやclangコマンドはC++の標準ライブラリをデフォルトでリンクしてくれません。

そのため、例えば以下のようにこれだけのコードでも、


sample4.cpp

#include <iostream>


int main() {
std::cout << "Hello, World!" << std::endl;
}


shell

gcc -c sample4.cpp -o sample4.o

gcc sample4.o


stdout

sample4.o: In function `main':

sample4.cpp:(.text+0xa): undefined reference to `std::cout'
sample4.cpp:(.text+0xf): undefined reference to `std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)'
sample4.cpp:(.text+0x14): undefined reference to `std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)'
sample4.cpp:(.text+0x1c): undefined reference to `std::ostream::operator<<(std::ostream& (*)(std::ostream&))'
sample4.o: In function `__static_initialization_and_destruction_0(int, int)':
sample4.cpp:(.text+0x4a): undefined reference to `std::ios_base::Init::Init()'
sample4.cpp:(.text+0x59): undefined reference to `std::ios_base::Init::~Init()'
collect2: error: ld returned 1 exit status

大量に謎のリンクエラーが出ることになります。

これが意外と原因に気づきにくいです。気をつけましょう。


終わりに

リンカーはコンパイラーよりも普段意識することが少なく、初心者にとっても分かりづらい要素だろうと思ったので記事を書いてはみたものの、自分でもちゃんと理解できていない部分も多く、勉強不足を実感しています。

この記事が、一人でも多くのリンクエラーに悩める初心者を救ってくれることを祈って、末筆とさせていただきます。

それではみなさん、良いC++ライフを。





  1. C++の名前を、名前空間や型情報などを含んだシステムが認識しやすいシンボル文字列に変換すること。ちょうど先日のAdvent Calenderで扱われています。 



  2. マングリングされた名前を元に戻すこと。 



  3. -lオプションで指定した名前の先頭にlib、拡張子に.aや.soを付けたファイルが-Lで指定したディレクトリとデフォルトのライブラリディレクトリから検索されることになります。例えば、/path/to/lib/libfoo.aをリンクしたい時は、-L/path/to/lib -lfooを指定します。 



  4. -pthreadの代わりに-lpthreadとしてもライブラリはリンクされるのですが、-pthredを指定した場合はプリプロセスの段階が異なるようです(参照)。なので、pthreadの場合に限り、-pthreadを使うべきでしょう。