こんにちは。田原です。

私はC#もよく使うのですが凄く羨ましい機能にスタック・トレースがあります。スタック・トレースはC++でも標準化できないことはないと思うのですが、残念ながら標準規格ではサポートされていません。しかし、比較的最近(去年の夏)にboostに導入されました。その内標準規格化されると期待したいですね。
さて、このStackTraceを近々使う予定があり使い方を調査しましたので解説してみたいと思います。

1.スタック・トレースとは

まず、スタック・トレースについて簡単に説明します。

関数からreturnする際、戻り先のアドレスは通常スタックに積まれています。スタックには戻り先以外にも実引数やローカル変数も記録されていますのでそれなりの解析が必要ですが、適切に処理すれば各関数呼び出しの戻り先アドレスをリストにすることができます。このリストがスタック・トレースです。

#include <iostream>
#include <boost/stacktrace.hpp>
void bar()
{
    std::cout << "bar()\n";
    std::cout << boost::stacktrace::stacktrace();
}

void foo()
{
    bar();
}

int main()
{
    foo();
}

例えば、上記のようなプログラムがあり、boost::stacktrace::stacktrace()が呼び出された直後の戻り先アドレスのリストは次のようなイメージになります。

アドレス 意味
0x00007FF6CC2710F8 bar()関数から呼び出しているboost::stacktrace::stacktrace()からの戻り先アドレス
0x00007FF6CC271139 foo()関数から呼び出しているbar()からの戻り先アドレス
0x00007FF6CC271159 main()関数から呼び出しているfoo()からの戻り先アドレス

(実際にはこの前後にもう少し履歴がありますし、ビルド・モードによって内容が異なります。詳しくは後述します。)

さて、上記プログラムのbar()関数でスタック・トレースを出力することを知っているので意味付けできますが、どこでスタック・トレースを出力したのか知らない時(普通はこちらです)、単なるアドレスのリストを見ても何が何やら分からないですね。

そこで、コード内のアドレスとソースの位置を対応付けるためのデバッグ用シンボルを利用して、上記アドレスをソースへ対応付けることができます。例えば、次のような出力を得られます。

 1# bar at c:\cpp-school2\12.stack_trace\stack_trace.cpp:36
 2# foo at c:\cpp-school2\12.stack_trace\stack_trace.cpp:42
 3# main at c:\cpp-school2\12.stack_trace\stack_trace.cpp:47

(実際にはこの前後にもう少し履歴がありますし、...以下略。)

戻り先は、多くの場合関数呼び出しの直後にありますので、このようなスタック・トレース出力により関数呼び出しの「履歴」として使用できます。

ちなみに、「スタック・トレース」は現在までの呼び出しを遡って表示するので「バックトレース」と呼ばれることも多いです。

関数呼び出しの「履歴」
実際には戻り先のリストですから、読み取るときにはちょっと注意が必要です。

if (hoge)
{
    bar();
}

//	this is coment.
//	this is coment.
//	this is coment.

int x=123;

のようなコードが合った時、bar()の先でスタック・トレースを取ると、その戻り先(マシン語のコードがある位置)は`int x=123;`の行になっている処理系がほとんどです。このように厳密な呼び出し位置の記録ではないのでご注意下さい。

2.スタック・トレースはどんな時にありがたいのか?

何か不具合が発生した時、不具合の原因箇所付近のスタック・トレースがあるとデバッグが凄く捗ります。
不具合発生箇所へ至る呼び出し履歴と適切なログを組み合わせて読み取ることで不具合が発生した状況をかなりの精度で推測できます。それだけでバグの原因も見つかることも意外に多いです。また不具合を再現出来なかった場合、更にログを仕込んで再現を待つしかありませんが、その際により精度良くログを追加できるためくデバッグが捗るという仕掛けです。

ところで、近代的なOSでは異常終了するとスタック・トレースを取ることができるオプションがありますので、それに頼ることもできます。ただ、これはお客様に負担がかかります。それなりの手順を指示してスタック・トレースを保存し、送って頂かないといけません。
異常終了が発生し、そのダイアログが表示されている状態で問合せに応えて指示することができればよいのですが、業務時間の関係(3交代の現場もあるでしょうし、海外かもしれません。)やサポート要員の人数の問題、そもそもお客様が問合せ出来る環境にいるのか?(電話と問い合わせ先電話番号が近くにあるのか?)の問題もあります。更にお客様はお客様の仕事を遂行しているので問合せしている余裕がない時も多いです。そのような時はとりあえずダイアログを閉じて再度アプリを立ち上げて作業を継続するお客様も少なくないでしょう。
なかなかスタック・トレースを送って頂くハードルが高いのです。

そこで、異常発生時にプログラム側で自動的にスタック・トレースをログへ残しておけば、事後にそのログを送って頂く、もしくは、自動的にアップロードする機能を仕込んでおく等により、不具合解析をたいへんスムーズに進めることができます。

C++の場合、処理系毎にスタック・トレースの取り方は異なっている上に、あまり簡単ではないことが頭の痛い問題です。必要なプログラムのコード量も少なくはないため、調査を行いスタック・トレースを出力するプログラムを作るのは時間がかかります。C#のように極簡単な調査と極簡単なコードでスタック・トレースをとれればよいのですが、C++はそのような状況ではありません。

そこに光を当てたのが、boostのマルチプラットフォームなStackTraceなのです。
流石にC#ほど簡単ではないですが、少なくともVisual C++でスタック・トレースを取得する手間に比べるとかなり簡単でした。それがマルチプラットフォーム対応なのですから、本当にありがたいです。

3.使い方

ざっくり下記手順で使えます。

  • boostのダウンロードと解凍(ビルドはしなくて良いです)
  • ご自身のプロジェクトの設定
    1. boostのインクルードパス追加
    2. リリース・モードでもデバッグ・シンボルを出力するように設定
    3. gccはlibdlをリンク
  • ソースの修正
    1. StackTraceへ指示する#defineと必要なヘッダのインクルード(1~2行)
    2. スタック・トレース出力コードの記述(1行)

3-1.boostのダウンロードと解凍

StackTraceは1.65.0で導入されましたが、折角ですので現時点(2018年6月3日)の最新版を使いましょう。
まずは、boostの公式から以下のどれかをダウンロードして解凍して下さい。

platform File SHA256 Hash
unix boost_1_67_0.tar.bz2 2684c972994ee57fc5632e03bf044746f6eb45d4920c343937a465fd67a5adba
boost_1_67_0.tar.gz 8aa4e330c870ef50a896634c931adf468b21f8a69b77007e45c444151229f665
windows boost_1_67_0.7z 1cd94f03a71334a67d36f5161b57f5931e0cd6ecf726d7aca8bd82a3be720b74
boost_1_67_0.zip 7e37372d8cedd0fd6b7529e9dd67c2cb1c60e6c607aed721f5894d704945a7ec

StackTraceはヘッダオンリー(ヘッダのインクルードだけで使用でき、ライブラリのリンクが不要)で使うことができますので、ここではヘッダオンリーで使用します。

なお、解説は省略しますが、StackTraceモジュールをビルドすることも可能です。事前にビルドしておくとStackTraceを使うコンパイル単位のコンパイル時間が少し短縮されます。

3-2.プロジェクトの設定

折角のマルチプラットフォームですのでCMakeを使って解説します。
CMakeの少しだけ詳しい解説をここでしていますのでもしよろしければ参考にして下さい。

3-2-1.boostのインクルード・パス指定

まずはboostのインクルード・パスを設定します。直接指定することもできますが、CMakeがサポートしているライブラリの場合find_packageを使うのが定番です。

find_package(Boost)
message(STATUS "  Boost_INCLUDE_DIR=${Boost_INCLUDE_DIR}")
message(STATUS "  Boost_LIBRARY_DIR=${Boost_LIBRARY_DIR}")
message(STATUS "  Boost_LIBRARIES  =${Boost_LIBRARIES}")
include_directories("${Boost_INCLUDE_DIR}")
link_directories("${Boost_LIBRARY_DIR}")

find_package(Boost)は、Boostのインストール先としてよく使われるフォルダを自動的に探してくれますが、ご自身でダウンロードして解凍したフォルダまでは流石に探してくれません。
そこで、インストール先フォルダをします。Boostの場合、BOOST_ROOTというCMake変数で指定します。(具体的な指定方法は後述します。)

find_package(Boost)は、boostを見つけたら以下のように設定してくれます。

CMake変数名 設定する値
Boost_INCLUDE_DIR インクルード・パス
Boost_LIBRARY_DIR ライブラリ・パス
Boost_LIBRARIES ライブラリのリスト

今回の場合ヘッダオンリーで使いますので、リンクするモジュールを指定していません。ですのでBoost_LIBRARY_DIRとBoost_LIBRARIESは空のままです。(リンクするモジュールを指定してもBoost_LIBRARY_DIRは空のままで、Boost_LIBRARIESでフルパスでライブラリ・ファイル名が指定される場合もあるようです。)

設定されたこれらのCMake変数を使ってインクルード・パスを指定します。

include_directories("${Boost_INCLUDE_DIR}")

今回は不要ですが、ライブラリ・パスを指定する場合もあるので定型文として書いてます。

link_directories("${Boost_LIBRARY_DIR}")

find_packageの動作
find_package(Boost)についてはCMakeの公式にドキュメントがあります。
またfind_packageの動作について詳しい解説をされている方がいらっしゃいます。

3-2-2. リリース・モードでもデバッグ・シンボルを出力するように設定

スタック・トレースは現在までに呼び出された関数の戻り先アドレスのリストです。単なるアドレスですからそれだけでは意味を把握できません。
ところで、コード上のアドレスをソース・ファイル名と行番号、および、関数名へ展開する仕組みがデバッグ・モードにはあります。ビルド時にデバッグ・シンボルを出力しておきデバッグ時にそのシンボル・テーブルを使ってソース・ファイル名などへ自動的に変換しデバッグしやすくしています。この仕組みを使えば戻り先アドレスをソース上の位置へ対応付けることができます。
ただし、このデバッグ・シンボルはデフォルトではリリース・ビルドしたときに出力されません。
スタック・トレースはリリースしたプログラムがお客様のところで動作している時に発生した不具合のデバッグに有効なものですので、リリース・モードでビルドした時にも使いたいです。

そこで、リリース・モードでビルドした時にもデバッグ・シンボルを出力するように指定します。
オプション自体は処理系によって異なります。Visual C++とgccについて説明します。(clangとMinGWはgccと同じです。)

if(MSVC)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /W4 /EHsc")
    set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} /Zi")
    set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF")
else()
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -std=c++11")
    set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g")
endif()

2行目と6行目の設定は当ブログの推奨設定です。
3,4行目はVisual C++用のデバッグ・シンボルをなるべく必要最小限に出力する設定です。このQA回答者のお勧めです。
7行目はgcc用のデバッグ・シンボル出力指定です。

3-2-3. gccはlibdlをリンク

boost公式に説明があるのですが、gccではlibdlのリンクが必要です。
この表には複数のオプションが記載されていますが、結局gccでスタック・トレースを出力したい時には必須のようです。

CMakeLists.txtのadd_executableより後で次の指定をします。

if(WIN32)
    target_link_libraries(stack_trace ${Boost_LIBRARIES})
else()
    target_link_libraries(stack_trace ${Boost_LIBRARIES} dl)
endif()

MinGWにはlibdlは提供されていないようですので、WIN32で振り分けています。
Boost_LIBRARY_DIRと同様、今回はBoost_LIBRARIESは空ですので指定する意味はありませんが、モジュールを指定した時は必要になるので定型文として書いています。

3-3. ソースの修正

単にスタック・トレースを出力だけならびっくりするほど簡単です。ほぼインクルードとstd::coutへの出力だけです。

3-3-1. StackTraceへ指示する#defineと必要なヘッダのインクルード

スタック・トレースを出力するコンパイル単位で以下の設定をします。

#ifdef _WIN32
#else
    #define BOOST_STACKTRACE_USE_ADDR2LINE
#endif
#include <boost/stacktrace.hpp>

いくつかオプションがあるのですが、ほとんどの場合、これで決定で良いと思います。
詳しくはboostの公式に説明があります

因みに、MinGWでBOOST_STACKTRACE_USE_ADDR2LINEを定義するとインクルード・エラーが出てしまいます。

3-3-2. スタック・トレース出力コードの記述

以下の文でスタック・トレースを標準出力へ出力できます。びっくり簡単です。

std::cout << boost::stacktrace::stacktrace();

4.サンプルとお試し実行

以上をまとめて、スタック・トレースを出力する簡単なサンプルを用意しました。

4-1.ソース

#include <iostream>

#ifdef _WIN32
#else
    #define BOOST_STACKTRACE_USE_ADDR2LINE
#endif
#include <boost/stacktrace.hpp>

void sub(int level)
{
    if (0 < level)
    {
        sub(level-1);
return;
    }
    std::cout << "stack_trace\n";
    std::cout << boost::stacktrace::stacktrace();
}

int main()
{
    sub(5);

//  this is coment.
//  this is coment.
//  this is coment.

}

4-2.CMakeLists.txt

cmake_minimum_required(VERSION 3.5.1)
project(stack_trace)

find_package(Boost)
message(STATUS "  Boost_INCLUDE_DIR=${Boost_INCLUDE_DIR}")
message(STATUS "  Boost_LIBRARY_DIR=${Boost_LIBRARY_DIR}")
message(STATUS "  Boost_LIBRARIES  =${Boost_LIBRARIES}")
include_directories("${Boost_INCLUDE_DIR}")
link_directories("${Boost_LIBRARY_DIR}")

if(MSVC)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /W4 /EHsc")
    set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} /Zi")
    set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF")
else()
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -std=c++11")
    set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g")
endif()

add_executable(stack_trace stack_trace.cpp)

if(WIN32)
    target_link_libraries(stack_trace ${Boost_LIBRARIES})
else()
    target_link_libraries(stack_trace ${Boost_LIBRARIES} dl)
endif()

4-3. CMake生成とビルド方法

 > mkdir msvc
 > cd msvc
 > cmake -G "Visual Studio 15 2017" .. "-DBOOST_ROOT=<boostを解凍したフォルダのパス>"
 > rem デバッグ・ビルド
 > cmake --build . --config Debug
 > rem リリース・ビルド
 > cmake --build . --config Release
 > mkdir gcc-debug
 > cd gcc-debug
 > cmake .. -DCMAKE_BUILD_TYPE=Debug "-DBOOST_ROOT=<boostを解凍したフォルダのパス>"
 > make
 mkdir gcc-release
 cd gcc-release
 cmake .. -DCMAKE_BUILD_TYPE=Release "-DBOOST_ROOT=<boostを解凍したフォルダのパス>"
 make
 ./stack_trace

BOOST_ROOTの指定方法について
あなたのPCだけで使うCMakeLists.txtの場合は、CMakeLists.txtの中でsetコマンドで直接設定することもできます。

set(BOOST_ROOT "<boostを解凍したフォルダのパス>")

しかし、CMakeLists.txtは他のPC向けに配布することの方が多いので直接書かずに、CMake生成コマンドで指定する場合が多いです。

4-4. 実行結果

 0# boost::stacktrace::basic_stacktrace<std::allocator<boost::stacktrace::frame> >::init at n:\foss\boost\boost_1_67_0\boost\stacktrace\stacktrace.hpp:75
 1# boost::stacktrace::basic_stacktrace<std::allocator<boost::stacktrace::frame> >::basic_stacktrace<std::allocator<boost::stacktrace::frame> > at n:\foss\boost\boost_1_67_0\boost\stacktrace\stacktrace.hpp:128
 2# sub at c:\cpp-school2\12.stack_trace\stack_trace.cpp:17
 3# sub at c:\cpp-school2\12.stack_trace\stack_trace.cpp:14
 4# sub at c:\cpp-school2\12.stack_trace\stack_trace.cpp:14
 5# sub at c:\cpp-school2\12.stack_trace\stack_trace.cpp:14
 6# sub at c:\cpp-school2\12.stack_trace\stack_trace.cpp:14
 7# sub at c:\cpp-school2\12.stack_trace\stack_trace.cpp:14
 8# main at c:\cpp-school2\12.stack_trace\stack_trace.cpp:28
 9# invoke_main at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:79
10# __scrt_common_main_seh at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:283
11# __scrt_common_main at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:326
12# mainCRTStartup at f:\dd\vctools\crt\vcstartup\src\startup\exe_main.cpp:17
13# BaseThreadInitThunk in KERNEL32
14# RtlUserThreadStart in ntdll
 0# boost::stacktrace::basic_stacktrace<std::allocator<boost::stacktrace::frame> >::init at n:\foss\boost\boost_1_67_0\boost\stacktrace\stacktrace.hpp:75
 1# sub at c:\cpp-school2\12.stack_trace\stack_trace.cpp:17
 2# main at c:\cpp-school2\12.stack_trace\stack_trace.cpp:28
 3# __scrt_common_main_seh at f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:283
 4# BaseThreadInitThunk in KERNEL32
 5# RtlUserThreadStart in ntdll

リリース・ビルドの方は、sub関数の表示が1回しかありません。subがinline展開されて最適化により呼び出しが省略されてしまったのだと思います。

 0# sub(int) at /home/yoshinoritahara/Projects/3.Serializer/3.Prepare/cpp-school2/12.stack_trace/stack_trace.cpp:17
 1# sub(int) at /home/yoshinoritahara/Projects/3.Serializer/3.Prepare/cpp-school2/12.stack_trace/stack_trace.cpp:14
 2# sub(int) at /home/yoshinoritahara/Projects/3.Serializer/3.Prepare/cpp-school2/12.stack_trace/stack_trace.cpp:14
 3# sub(int) at /home/yoshinoritahara/Projects/3.Serializer/3.Prepare/cpp-school2/12.stack_trace/stack_trace.cpp:14
 4# sub(int) at /home/yoshinoritahara/Projects/3.Serializer/3.Prepare/cpp-school2/12.stack_trace/stack_trace.cpp:14
 5# sub(int) at /home/yoshinoritahara/Projects/3.Serializer/3.Prepare/cpp-school2/12.stack_trace/stack_trace.cpp:14
 6# main at /home/yoshinoritahara/Projects/3.Serializer/3.Prepare/cpp-school2/12.stack_trace/stack_trace.cpp:28
 7# __libc_start_main in /lib/x86_64-linux-gnu/libc.so.6
 8# _start in ./stack_trace
 0# std::vector<boost::stacktrace::frame, std::allocator<boost::stacktrace::frame> >::size() const at /usr/include/c++/5/bits/stl_vector.h:655
 1# main at /home/yoshinoritahara/Projects/3.Serializer/3.Prepare/cpp-school2/12.stack_trace/stack_trace.cpp:28
 2# __libc_start_main in /lib/x86_64-linux-gnu/libc.so.6
 3# _start in ./stack_trace

4-5. MinGWの場合のおまけ

MinGW 5.4.0でも同様のことをやってみました。デバッグ・ビルドのみ示します。

CMake生成
> mkdir mingw-debug
> cd mingw-debug
> cmake -G &quot;MinGW Makefiles&quot; .. -DCMAKE_BUILD_TYPE=Debug &quot;-DBOOST_ROOT=&lt;boostを解凍したフォルダのパス&gt;&quot;
> cmake --build .
実行結果
 0# 0x00402DAB in C:\cpp-school2\12.stack_trace\build\mingw-debug\stack_trace.exe
 1# 0x00401684 in C:\cpp-school2\12.stack_trace\build\mingw-debug\stack_trace.exe
 2# 0x0040164B in C:\cpp-school2\12.stack_trace\build\mingw-debug\stack_trace.exe
 3# 0x0040164B in C:\cpp-school2\12.stack_trace\build\mingw-debug\stack_trace.exe
 4# 0x0040164B in C:\cpp-school2\12.stack_trace\build\mingw-debug\stack_trace.exe
 5# 0x0040164B in C:\cpp-school2\12.stack_trace\build\mingw-debug\stack_trace.exe
 6# 0x0040164B in C:\cpp-school2\12.stack_trace\build\mingw-debug\stack_trace.exe
 7# 0x004016DB in C:\cpp-school2\12.stack_trace\build\mingw-debug\stack_trace.exe
 8# 0x004013E2 in C:\cpp-school2\12.stack_trace\build\mingw-debug\stack_trace.exe
 9# 0x74EE8674 in C:\WINDOWS\System32\KERNEL32.DLL
10# 0x77D74B47 in C:\WINDOWS\SYSTEM32\ntdll.dll
11# 0x77D74B17 in C:\WINDOWS\SYSTEM32\ntdll.dll

残念なことにソース上のとこなのか分かりません。

MinGWでソースとの対応を取る場合は、libbacktraceとのリンクが必要と言うことでした

5.まとめ

今回は、スタック・トレースの有用性と、boost1.65.0で導入されたStackTraceを使ってスタック・トレースをマルチプラットフォームで出力する方法を解説しました。

しかし、本文中でも述べたように実際にスタック・トレースを取る場面は、プログラムの異常終了時が多いです。今回の手順ではそれに対応していません。そこで、次回はこの異常終了時のスタック・トレースのとり方を中心に解説したいと思います。

実はboostで解説されているものの半ば受け売りになりそうですのでお急ぎの方は、boost公式の解説をご覧になるとよいです。

それではまた次回までご機嫌よう!