コンパイラと共に戦う【コンパイルオプション編】

公開:2021/02/10
更新:2021/02/16
7 min読了の目安(約6700字TECH技術記事

はじめに

バグとの戦いは辛く苦しいもの。
コンパイラと共にバグへ立ち向かっているプログラマの記録です。

今回はコンパイルオプション編。
バグとの遭遇確率を減らし、生まれてしまったバグは早期に潰す戦い方を目指します。

相棒コンパイラはClang

LLVM/Clangは多くの機能を提供している。
Clangはコンパイルエラーがこちらの意図を汲もうとしてくれていたり、相棒としても心強い。

というわけで、この記事に関しては Clang 9.0.0 にて動作確認。

Warningは全て有効にしエラーに

コンパイルオプションには -Weverything -Werror を指定する。
そこから自分に必要ないwarningを抜いていくのが一番良い。
私の場合、次のコンパイルオプションを指定している。

-Weverything -Werror -Wno-c++98-compat -Wno-c++98-compat-pedantic -Wno-padded

口うるさく感じる場合もあるがコードがクリーンに保てるのでバグと出会いにくくなる。
もちろん、Warningを回避するためにトリッキーなコードを書いては元も子もない。
どうしても局所的に回避したい場合は以下のように記述する。多用厳禁。

// -Wgnu-zero-variadic-macro-argumentsを回避したい例
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wgnu-zero-variadic-macro-arguments"

#define MY_PRINTF(format, ...) printf(format, ##__VA_ARGS__)

#pragma GCC diagnostic pop

各オプションについて

  • -Weverything
    すべての警告オプションを有効にする

  • -Werror
    すべての警告をエラーとする

  • -Wno-c++98-compat
    C++98互換とはしない

  • -Wno-c++98-compat-pedantic
    C++98互換とはしない

  • -Wno-padded
    パディングの発生を許容する

Sanitizerの力を借りる

gccやclangには、Address SanitizerやUndefined Behavior Sanitizerという、ランタイムでのエラー検出ツールが付属している。
これらは非常に強力で、何気なく記述したコードに存在するメモリに関する問題や未定義動作とされるコードを実行中に検出してくれる。

Address Sanitizer

メモリに関する問題を検出してくれる。
たとえば、以下のコード。

#include <functional>

void func()
{
    std::function<void(int)> f;

    // 関数作成
    {
        int value = 0;
        // ...
        f = [&](int v)
        {
            // ...
            value = v;  // !!!! テンポラリの変数を使用してしまっている !!!!
            // ...
        };
        // ...
    }

    f(10);
}

int main(int, char **)
{
    func();
    return 0;
}

関数作成のタイミングで、テンポラリ変数のvalueをキャプチャしてしまっている。
この関数を使用しようとすると値valueのメモリは死んでいるので、未定義動作となる。
これを-fsanitize=addressオプション付きでビルド・実行すると問題箇所でcrashして以下のようなエラーが得られる。

=================================================================
==98792==ERROR: AddressSanitizer: stack-use-after-scope on address 0x7ffee3639290 at pc 0x00010c5d3802 bp 0x7ffee3638f80 sp 0x7ffee3638f78
WRITE of size 4 at 0x7ffee3639290 thread T0
    #0 0x10c5d3801 in func()::$_0::operator()(int) const AddressSanitizer.cpp:14
    #1 0x10c5d36c9 in decltype(std::__1::forward<func()::$_0&>(fp)(std::__1::forward<int>(fp0))) std::__1::__invoke<func()::$_0&, int>(func()::$_0&, int&&) type_traits:3545
    #2 0x10c5d342e in void std::__1::__invoke_void_return_wrapper<void>::__call<func()::$_0&, int>(func()::$_0&, int&&) __functional_base:348
    #3 0x10c5d328c in std::__1::__function::__alloc_func<func()::$_0, std::__1::allocator<func()::$_0>, void (int)>::operator()(int&&) functional:1546
    #4 0x10c5cc657 in std::__1::__function::__func<func()::$_0, std::__1::allocator<func()::$_0>, void (int)>::operator()(int&&) functional:1720
    #5 0x10c5d545b in std::__1::__function::__value_func<void (int)>::operator()(int&&) const functional:1873
    #6 0x10c5ca205 in std::__1::function<void (int)>::operator()(int) const functional:2548
    #7 0x10c5c9b55 in func() AddressSanitizer.cpp:20
    #8 0x10c5ca2eb in main AddressSanitizer.cpp:25
    #9 0x7fff6a218cc8 in start+0x0 (libdyld.dylib:x86_64+0x1acc8)

Address 0x7ffee3639290 is located in stack of thread T0 at offset 112 in frame
    #0 0x10c5c997f in func() AddressSanitizer.cpp:4

  This frame has 3 object(s):
    [32, 80) 'f' (line 5)
    [112, 116) 'value' (line 9) <== Memory access at offset 112 is inside this variable
    [128, 136) 'ref.tmp' (line 11)
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-use-after-scope AddressSanitizer.cpp:14 in func()::$_0::operator()(int) const
Shadow bytes around the buggy address:
  0x1fffdc6c7200: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1fffdc6c7210: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1fffdc6c7220: 00 00 00 00 00 00 00 00 f1 f1 f1 f1 04 f3 f3 f3
  0x1fffdc6c7230: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1fffdc6c7240: 00 00 00 00 f1 f1 f1 f1 00 00 00 00 00 00 f2 f2
=>0x1fffdc6c7250: f2 f2[f8]f2 f8 f3 f3 f3 00 00 00 00 00 00 00 00
  0x1fffdc6c7260: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1fffdc6c7270: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1fffdc6c7280: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1fffdc6c7290: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1fffdc6c72a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
  Shadow gap:              cc
==98792==ABORTING

この例の場合ならば、ERROR: AddressSanitizer: stack-use-after-scopeのメッセージと共に

    ...
    #0 0x10c5d3801 in func()::$_0::operator()(int) const AddressSanitizer.cpp:14
    ...

という記述があるため、すぐに原因に気づくことができる。

Undefined Behavior Sanitizer

未定義動作とされる挙動があった場合に検出してくれる。
例えば、以下のコード。

#include <limits>

int main(int, char **)
{
    int value = std::numeric_limits<int>::max();
    value += 1; // !!!!! 最大値に+1している !!!!!
    return 0;
}

このコードではint最大値に対して+1してしまっているため、未定義動作となる。
これを-fsanitize=undefinedでビルド・実行するとcrashせずに以下のエラーメッセージが出力される。

UndefinedBehaviorSanitizer.cpp:6:11: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior UndefinedBehaviorSanitizer.cpp:6:11 in 

こちらも検出理由と行数が出力されるため、すぐに問題箇所を特定できるだろう。

開発ステージ別のビルド構成

ステージによってコンパイルオプションを切り替えて使う。

ステージ 構成
コーディング&テスト Develop
仕上げ(Runtime Errorチェック) Debug

仕上げのステージを用意しているのはC++未定義動作(Undefined Behavior)なコードを書いても正しく動いてしまう場合があるからである。
未定義動作なコードは最適化時に予想外なコードを生成されてしまったり、「数千回に1回ぐらい落ちる」のような退治しにくいバグを生成しかねないので1つ実装するたびにチェックしておく。

Develop構成

-Weverything -Werror -O0 -fno-rtti

ビルド速度重視。

Debug構成

-Weverything -Werror -g3 -O0 -fno-rtti -fsanitize=undefined,address -fno-omit-frame-pointer

実装エラーを検出するための構成。通常よりもビルドに時間がかかる。
実装時「なんかよくわからない動作やcrashが発生する」場合にとりあえずこの構成を使うこともできる。

おわりに

とりあえずコンパイルオプションから記載させていただきました。
次は コンパイラと共に戦う【プログラミング編】 です。