lexit: デバッグを効率化する終了関数

C/C++ で,エラーを検出してプログラムを強制終了する場合, exit(EXIT_FAILURE) とか abort() を使う.このうち abort() はコアを吐いてから終了してくれるから(linux の場合),コアを gdb などで解析することにより,デバッグが楽になる.例えば gdb a.out core として gdb を起動し, bt コマンドを使えば,バックトレースできる.しかし abort() による終了は穏便ではない.例えば, exit だとグローバルオブジェクトが破棄されるが, abort() だと破棄されない.そこで, success:正常終了, qfail:正常終了(エラーを返す), btfail:正常終了(バックトレースし,エラーを返す), abort:異常終了, df:デフォルト という4+1段階の終了オプションを選択できる終了関数 lexit を定義した.

ちなみにバックトレースとは,プログラムのスタックを表示すること.終了時点での関数から,呼び出しもとの関数を順に main までたどっていく.

exit(int status), abort(void) の確認

最初に,いくつかのプログラムの終了方法を確認しておこう.

  • exit(EXIT_SUCCESS)
    • 正常終了
    • コアファイル(core)は生成されない
    • グローバルオブジェクトは破棄される
    • すべてのファイルストリームは閉じられる
    • プログラムの出力は true
  • exit(EXIT_FAILURE)
    • 正常終了
    • コアファイル(core)は生成されない
    • グローバルオブジェクトは破棄される
    • すべてのファイルストリームは閉じられる
    • プログラムの出力は false
  • abort()
    • 異常終了
    • コアファイル(core)が生成される
    • グローバルオブジェクトは破棄されない
    • すべてのファイルストリームは閉じられる
    • プログラムの出力は false

abort の「グローバルオブジェクトが破棄されない」とは,グローバルオブジェクトに対してデストラクタが実行されないということ.コアファイルが生成されるのは,シェル環境などでコアを吐く設定にしておいた場合に限る(おまけ参照).

glibc を利用したバックトレース

lexit を作る上で一番面倒なのが,バックトレースを出力することだ.さいわい, 普通のやつらの下を行け: C でバックトレース表示 - bkブログ にまさにその方法が載っていた*1

具体的には glibc (GNU C Library) の backtrace 関数群 (33.1 Backtraces) を使う.こんな感じで:

#include <cstdlib>  // for exit, abort
#ifdef __GLIBC__
  #include <execinfo.h>  // NOTE: glibc only; for backtrace* functions
#endif
// ...
static void stacktrace (void)
{
#ifdef __GLIBC__
  void *trace[256];
  int n = backtrace(trace, sizeof(trace) / sizeof(trace[0]));
  backtrace_symbols_fd(trace, n, STDERR_FILENO);
#endif
}

__GLIBC__ は glibc を読み込んだら定義されるマクロ. execinfo.h には backtrace 関数群の宣言がある.

このコードをコンパイルするときに, -rdynamic オプションをつけること. man backtrace によると,

シンボル名は特別なリンカ・オプションを使用しないと利用できない場合があ
る。 GNU リンカを使用するシステムでは、 -rdynamic リンカ・オプションを使
う必要がある。 "static" な関数のシンボル名は公開されず、バックトレースで
は利用できない点に注意すること。

このオプションをつけないと,関数名が取得できない.

lexit 関数

ここで定義する lexit は次のように動作する:

  • lexit(success)
    • 正常終了
    • プログラムは success コードを返す
  • lexit(qfail)
    • 終了位置を出力 (lexit が実行されたファイル名,行,関数名)
    • 正常終了
    • プログラムは failure コードを返す
  • lexit(btfail)
    • 終了位置を出力 (lexit が実行されたファイル名,行,関数名)
    • バックトレースを出力
    • 正常終了
    • プログラムは failure コードを返す
  • lexit(abort)
    • 終了位置を出力 (lexit が実行されたファイル名,行,関数名)
    • 異常終了 (abort)
  • lexit(df)
    • デフォルトの終了レベル (DEFAULT_EXIT_LEVEL) で終了する

lexit(abort) でバックトレースを出力しないのは,ダンプされたコアファイルから gdb を使って解析できるから.問題は,呼び出した関数のファイル名や行は,呼び出した関数しかわからないこと.そこで lexit をマクロとして実装する.呼び出しもとの関数については,gcc/g++ なら __PRETTY_FUNCTION__ というマクロを使えば取得できるのだが,これは gcc拡張機能だ.これに対してboost の boost/current_function.hpp で定義されている BOOST_CURRENT_FUNCTION は,ほとんどのコンパイラで現在の関数を取得できるマクロである. lexit ではこれを利用する.

具体的な実装は以下の通り.なおこの lexit は,基本的にデバッグを目的としたもので,商用プログラムの利用は前提にしていない.製品化するならもっと工夫が必要だろう.

#include <cstdlib>  // for exit, abort
#ifdef __GLIBC__
  #include <execinfo.h>  // NOTE: glibc only; for backtrace* functions
#endif
#include <boost/current_function.hpp>  // 現在の関数を文字列として返すマクロを定義
#include <iostream>
//-------------------------------------------------------------------------------------------
namespace some_namespace
{
static void stacktrace (void)
{
#ifdef __GLIBC__
  void *trace[256];
  int n = backtrace(trace, sizeof(trace) / sizeof(trace[0]));
  backtrace_symbols_fd(trace, n, STDERR_FILENO);
#endif
}
//-------------------------------------------------------------------------------------------
#ifndef DEFAULT_EXIT_LEVEL  // デフォルトの終了レベル
  #define DEFAULT_EXIT_LEVEL btfail
#endif
//-------------------------------------------------------------------------------------------
namespace _exitlv
{
enum TExitLevel {
  success=0  /*! 正常終了 & return success */,
  qfail      /*! 終了位置の出力, 正常終了 & return failure */,
  btfail     /*! 終了位置の出力, バックトレース, 正常終了 & return failure */,
  abort      /*! 終了位置の出力, 異常終了 (abort) */,
  df=1000    /*! デフォルトの終了レベルで終了 */};

void __lexit (TExitLevel exit_level,
              int linenum,
              const char *filename,
              const char *functionname)
{
  if (exit_level != success)
    std::cerr<<"------"<<std::endl
      <<"the program is terminated"<<std::endl
      <<"  at "<<filename<<":"<<linenum<<":"<<std::endl
      <<"  within the function: "<<functionname<<std::endl;
  switch (exit_level)
  {
    case success :
      std::exit(EXIT_SUCCESS);
    case qfail   :
      std::exit(EXIT_FAILURE);
    case btfail  :
      std::cerr<<"backtrace:"<<std::endl;
      stacktrace();
      std::exit(EXIT_FAILURE);
    case abort   :
      std::abort();
    default      :
      std::cerr<<"improper usage of LEXIT (_lexit); invalid exit_level: "
        <<static_cast<int>(exit_level)<<std::endl;
      std::exit(EXIT_FAILURE);
  }
}
inline void _lexit (TExitLevel exit_level,
                    int linenum,
                    const char *filename,
                    const char *functionname)
{
  if (exit_level==df)  exit_level= DEFAULT_EXIT_LEVEL;
  __lexit (exit_level, linenum, filename, functionname);
}
} // end of namespace _exitlv
//-------------------------------------------------------------------------------------------
#define lexit(_level)  some_namespace::_exitlv::_lexit( \
                                some_namespace::_exitlv::_level, \
                                __LINE__,  __FILE__, BOOST_CURRENT_FUNCTION)
//-------------------------------------------------------------------------------------------
} // end of some_namespace
//-------------------------------------------------------------------------------------------

名前空間 _exitlv の中で qfail や btfail といったシンボルを定義することで,外部でこれらのシンボルを使えないようにしている.この実装では lexit を

void __lexit (TExitLevel exit_level, ...);
inline void _lexit (TExitLevel exit_level, ...);
#define lexit(_level)  ...

のように3段階で実現しているが,これは,

  • lexit をマクロ化したのは,呼び出しもとのファイル名や関数位置を手軽に取得するため,および qfail や btfail といったシンボルを簡潔に記述するため
  • __lexit と _lexit を分けて定義したのは, __lexit の実装はライブラリのオブジェクトファイル (libhoge.a) に隠蔽し,かつユーザがデフォルトの終了レベルを DEFAULT_EXIT_LEVEL を変更することで自由に設定できるようにするため(_exitlv::df と DEFAULT_EXIT_LEVEL の対応づけを __lexit の中で行うと, DEFAULT_EXIT_LEVEL の値はコンパイル時点のものに固定されてしまう)

という理由による.

メモ: i386 に対する backtrace の実装 (glibc のコード)

glibcソースコードを展開したディレクトリの glibc-2.7/sysdeps/i386/backtrace.c が i386 に対する backtrace の実装だ.このコードを読めば, backtrace の出力を整形したりできるかもしれない.

おまけ: コアファイルの出力させ方

tcsh を使っている場合

プロンプトで

limit

コマンドを実行すると,

cputime      unlimited
filesize     unlimited
datasize     unlimited
stacksize    8192 kbytes
coredumpsize 0 kbytes
memoryuse    unlimited
vmemoryuse   unlimited
descriptors  1024 
memorylocked 32 kbytes
maxproc      32768 

こんな感じの出力が得られる.ここで coredumpsize が 0k になっているから,このままだとコアファイル (core) を出力させてくれない.そこで

  % limit coredumpsize unlimited

を実行する.すると, coredumpsize が unlimited (無制限) になって,コアファイルをダンプできるようになる.

bash を使っている場合

プロンプトで

ulimit -c

コマンドを実行すると,生成されるコアファイル (core) の最大サイズが出力される. 0 とか unlimited (無制限) とかが表示される. 0 だった場合はコアがダンプできないので,

ulimit -c unlimited

を実行すると, unlimited になる(コアダンプしほうだい).

参考文献

  • ISO/IEC 14882:2003(E)-18.3 Start and termination (C++ の標準規格2003)
  • ISO/IEC 9899:1999(E)-7.20.4 Communication with the environment (C の標準規格1999)

*1:実はこの記事を見付けたから lexit を作ろうと思いました.