今年は猛暑と台風が続いてます。皆さん如何お過ごしでしょうか? お久しぶりの田原です。
前々回、boostのstack traceの使い方をご紹介しました。その前後で本業でも使っていたのですが、その後問題があることに気が付きましたので補足します。
Windowsでは pdb ファイルを使ってアドレスをソース名と行番号へ変換しますが、他のPCへ持っていくとあら残念、アドレスがアドレスのまま意味のない文字列になってしまうのです。今回はその対策です。

1.原因は?

ご存知の方もいらっしゃると思いますが、原因は単純に .pdb ファイルの位置が絶対パスで .exe に埋め込まれているからです。.exeをビルドしたPCなら、.pdb ファイルはビルドした時のパスにそのまま置いていることが多いと思います。しかし、他のPCへ .exe を移動する時は .pdb ファイルも .exe と一緒に移動すると思いますが、ほとんどのケースでビルドした時の位置とは異なる位置へ置くでしょう。(msvcではDebugとかReleaseの下に生成されますが、ターゲットのPCでこれらの下へ置くケースはほとんどないように思います。)その結果、アドレスをシンボルへ変換するAPIが .pdb ファイルの位置を見失い変換できなくなるという落ちでした。
スタック・トレースを実装したのは久しぶりだったのですっかり失念してました。

2.対策は?

シンボル変換APIに.pdbファイルの場所を教える、シンボル変換APIが.pdbファイルを探す場所に置く等色々対策は考えられます。他にもネットワーク上に置くなどもできるような印象でした。あまり大げさな方法に手を出したくはないので比較的簡単な方法で対策してみました。

.pdbファイルを.exeと一緒に配布し同じフォルダに置くことを前提としています。.pdbファイルには.exeの解析に有用な情報が入っていますので、もし、解析をより困難にされたい方はまた別の対策をした方が良いです。
しかし、現実問題、ある程度大きなプロジェクトならそのソースを読むだけでもたいへんです。仮に.pdbファイルが有ったとしてもコメントはまるっと削られていますから解析はソースを読むより遥かにたいへんです。
それだけの費用を掛けてでも解析したくなるようなセキュリティや金融系のプログラムでなければ、実際に使われている場面で発生したトラブルをより短時間で対策できるように仕込んだ方が得るものが多いような気がします。

2-1. .pdbファイルの場所をboostに教える

残念ながらboost stacktraceはそのためのI/Fを持っていませんので、boostのソースを修正します。
そのかわりと言ってはなんですが、ユーザ側のプログラム修正は不要です。

boost 1.67.0の場合、boost_1_67_0/boost/stacktrace/detail/frame_msvc.ipp の155行目からの debugging_symbolsクラスのデフォルト・コンストラクタを次のように修正します。(ハイライト部分を挿入)

    debugging_symbols() BOOST_NOEXCEPT
        : com_()
        , idebug_(com_)
    {
        try_init_com(idebug_, com_);

#if !defined(BOOST_WINDOWS) && !defined(__CYGWIN__)
#else
        char aModulePath[MAX_PATH];
        if (::GetModuleFileNameA(nullptr, aModulePath, MAX_PATH))
        {
            std::string aPath(aModulePath);
            auto pos = aPath.find_last_of('\\');
            if (pos != std::string::npos)
            {
                aPath[pos]=0;
            }
            idebug_->AppendSymbolPath (aPath.c_str());
        }
#endif
    }
ライセンスについて(短すぎて著作権が発生しないかも知れないですが)
boostのソース・コードは Boost Software License – Version 1.0 にて使用許諾されています。

それ以外の部分はご自由に使って下さい。(ただし、 私からの保証はありません のでご自身の責任でお願いします。)

Windows限定の対策ですのでWindows APIを直接使っています。GetModuleFileNameAで.exeのフルパスを入手して.exeファイル名を取り除き .exeのあるフォルダのフルパスを得ます。そして、idebug->AppendSymbolPath()で.exeのあるフォルダのフルパスを与えることでシンボル変換APIが .pdbファイルがここにあると認識するようになります。GetModuleFileNameWを使った方が日本語等のワイド文字を含むパスへ .exeを配置した時にもトラブルが起きにくいと思いますが、実際にそのような使い方をするケースは稀と思いますので手抜きしています。

この idebug_ オブジェクトは com_holder< ::IDebugSymbols> クラスのインスタンスです。これは、IDebugSymbolsというWindowsの COMインタフェースです。

ところでコンパイル条件の定義が無駄に複雑に見えます(#if defined(_WIN32)で十分な筈)が、boost内部での条件判定をそのままコピペしました。「郷に入らば郷に従え」ですので。(必要もないのに郷に逆らうのはリスキーですから。)

昔、VC++でCOMインタフェースを呼び出す時に簡単なことをやるのに結構多くのコードを書かないと行けないのでむちゃくちゃ苦労した記憶があるのですが、boostはcom_holderというクラス・テンプレートを内部的に作って簡単に呼び出しています。Boost Software License – Version 1.0の条件はかなり緩い(自由に使える上バイナリでの著作権表示義務無し)ので取り出して使うのも良いかも知れません。

2-2. .pdbファイルのある場所を探すようにさせる

こちらの対策はboostのソースの修正が不要です。boostのソースを管理対象から外したい時有用です。(boostのソースは膨大ですから。)

リンカーの/PDBALTPATHパラメータを使って.pdbファイルのファイル名だけを指定するとカレント・ディレクトリを探すようになります。(カレント・ディレクトリが .exeのあるディレクトリと一致しないことも多々ありますからご注意下さい。一致することも多々ありますので混乱しやすいです。)

boost::stacktrace::stacktraceのオブジェクトを ostream::operator<<で出力する時(39行目)に、カレント・フォルダが .exeのあるフォルダになるように注意深くプログラミングして下さい。
プログラム自身で移動する場合もあるでしょうが、他にもファイル・オープン系のコモン・ダイアログがカレント・フォルダを移動する場合もありますし、その他思わぬ時に移動しているケースがあります(例えばVisual Studioから起動した時)ので要注意です。

そこで必要な時に、カレント・ディレクトリを.exeのあるフォルダへ移動してしまうと安心です。前々回のスタック・トレースのサンプルの最終形を修正してみました。

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} /Z7")
    set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF /PDBALTPATH:stack_trace.pdb")
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()
#include <iostream>
#include <fstream>
#include <signal.h>
#include <stdio.h>

#include <atomic>

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

#if defined(_WIN32)
#include <windows.h>
void setCurrentToSelf()
{
    char aModulePath[MAX_PATH];
    if (::GetModuleFileNameA(nullptr, aModulePath, MAX_PATH))
    {
        std::string aPath(aModulePath);
        auto pos = aPath.find_last_of('\\');
        if (pos != std::string::npos)
        {
            aPath[pos]=0;
        }
		::SetCurrentDirectoryA(aPath.c_str());
	}
}
#endif

void my_signal_handler(int signum)
{
    ::signal(signum, SIG_DFL);

    static /*thread_local*/ std::atomic_flag excluding=ATOMIC_FLAG_INIT;
    if (!excluding.test_and_set())
    {
        std::ofstream ofs("./backtrace.log");
        ofs << "my_signal_handler(" << signum << ")" << std::endl;
        boost::stacktrace::stacktrace st;
#if defined(_WIN32)
	setCurrentToSelf();
#endif
        ofs << st;
    }
    ::raise(SIGABRT);
}

void sub(int level)
{
    if (0 < level)
    {
        sub(level-1);
return;
    }

    int*    p=nullptr;
    *p=0;
}

int main()
{
    std::ifstream ifs("./backtrace.log");
    if (ifs)
    {
        std::cout << "Previous run crashed:\n" << ifs.rdbuf();

        // cleaning up
        ifs.close();
        remove("./backtrace.log");
    }

    ::signal(SIGSEGV, &my_signal_handler); 
    ::signal(SIGABRT, &my_signal_handler);

    sub(5);
}

カレント・ディレクトリの設定箇所が分かりやすいよう、boost::stacktrace::stacktraceインスタンスの生成と出力を分けました。
また、このサンプルはスタック・トレース取得後直ぐに終了するのでカレント・ディレクトリは戻していません。もし、スタック・トレース取得後もプログラムの実行を継続する場合にはスタック・トレース取得後に戻しておいた方がトラブルが起きにくいです。

2-3.PDBALTPATHオプションについて補足

/PDBALTPATH (別の PDB パスを使用)の最後に次のような記述があります。

pdb_file_name の値は、任意の文字列、環境変数、または %_PDB% にすることができます。 リンカーは %SystemRoot% などの環境変数をその値に拡張します。 リンカーは環境変数 %_PDB% および %_EXT% を定義します。 %_PDB% は実際の .pdb ファイルのファイル名にパス情報がないまま拡張し、%_EXT% は生成された実行ファイルの拡張子です。

便利そうな機能なので試してみたのですが、結局使い方が分かりませんでした。
.exeの中をバイナリ・エディタで見ながら下記のようなトライをしました。結果は惨敗でした。

  1. /PDBALTPATH:%_PDB%を指定してみた
    指定なしの時に .pdbファイルのフルパスが入っていた領域が%stack_trace.pdb%となりました。%が残っていますので、シンボル変換APIは .pdb ファイルをみつけることができません。この時のstack_trace.pdbは正しい名前なので前後の%が余計です。

  2. 環境変数が使えるらしいので、/PDBALTPATH:%ProgramFiles(x86)%を指定してみた
    間にスペースがあるため、File(x86)%部分がリンカにとって謎のパラメータとなりリンカ・エラーでした。

  3. そこで、/PDBALTPATH:'%ProgramFiles(x86)%'を指定してみた
    リンカはちゃんと認識し、エラーになりません。
    .exeの中をバイナリ・エディタで確認したところ、%C:\Program Files(x86)%へ展開されていました。相変わらず % が残っているのでシンボル変換APIは認識しません。
    しかも、開発環境の環境変数を展開してどうする!!(怒)

これって役に立つのでしょうか?(私は正しい使い方を理解できませんでした。)

3.まとめ

Windowsの場合、デフォルトで.pdbファイルの位置が絶対パスで .exeに埋め込まれているため、.pdbファイルを.exeと一緒に別のフォルダへ移動するとソース名や行番号がでなくなり、事実上役に立ちません。開発環境のPCでテストしている限り、これが顕在化しないのでなかなか頭の痛い不具合になりそうです。
今回はその対策を2つ説明しました。他にもデータベースに.pdb情報を登録するとか、インターネット上のサーバに置いておく等の手段もありそうでしたが、大掛かりなことになりそうなのでスルーしました。
興味のあるかたは調べてみると有用かも知れません。ただし、.pdbファイルを使うのはIDE(Visual Studio)が主ですので、IDEに探させるための記事が多くいです。もしかするとデータベースやインターネットの話もIDE登録のことかも知れません。

他の言語のようにC++でもお手軽にスタック・トレースが取れるようになることを祈りつつ、今回はこの辺で終わります。
お疲れ様でした。