CIの動的テストでclang sanitizerを使う

f:id:aptpod_tech-writer:20200924215032j:plain

はじめに

こんにちは、製品開発グループの落合です。主に エッジサイドミドルウェア(intdash Edge)の開発を担当しています。このintdash EdgeはC++で作成しているのですが、言語が何であろうと「面倒な事は自動化したい」ですよね。そして、特に面倒なのは「テスト」じゃないでしょうか?

そんな訳で、intdash Edgeのプロジェクトで使用している「CIでの動的テスト」を紹介させて頂こうと思います。「CIでテストなんて当たり前でしょ」と言われる気もしますが、clangsanitizerを使っている記事は意外と少ない気がするので今回記事にしてみました。
え、なんでvalgrindではなくsanitizerを使っているかですか?単純に検知できるエラーが多いのが理由です。

CIで行っているテスト

intdash Edgeのプロジェクトで行っているCIのテストは主に下記3つです。

  • コーディングスタイルチェック(clang-format)
  • 静的解析(CppCheck)
  • 動的テスト(calng sanitizer)← 今回の記事の主題

それぞれの費用対効果を(完全に主観で)表すと、こんな感じです。

テスト項目 バグの検知 コスト感と効果
スタイルチェック × フォーマッターが自動で行ってくれる。
予めチームでのフォーマットを決めておくことで、レビューなどでの非生産的な論争を避けられる。
静的テスト △~○ テストコードを書かなくても良い。
実装した関数が仕様通りに動くかのチェックはできない。
見つかるバグはツールの性能に大きく左右される。
動的テスト テストコードが必要。
動かしたコードに対して未初期化・メモリ関連のチェックができる。

継続的にメンテするプロジェクトなら、断然ユニットテストを書いて動的テストまで取り入れるべきだと思います。
なぜなら、ユニットテストをCIに取り入れることで、テスト対象が仕様通りに動作することを保証できるので、簡単にデグレを抑止できます。
さらに、動的テストを足す事で、テストで動かしたコードのメモリエラーをチェックできます。
さらにさらに、カバレッジも出力すればテストケースの考慮漏れも防げます。

では、本題のclangのscanitizerで動的テストを行ってみましょう。。。と行きたいのですが、その前に、clangの入った開発環境をささっとdockerで作りましょう。

開発環境の準備

dockerがインストールされている環境で下記コマンドを実行してください。

$ docker run --rm -it debian:10-slim /bin/bash
# apt-get update
# apt-get install -y gpg wget
# wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | gpg --dearmor >/usr/share/keyrings/llvm-snapshot.gpg
# echo "deb [signed-by=/usr/share/keyrings/llvm-snapshot.gpg] http://apt.llvm.org/buster/ llvm-toolchain-buster-9 main" >> /etc/apt/sources.list
# apt-get update
# apt-get install -y clang-9 clang-format-9

ささっとできましたね。エディタはお好きなモノを入れてください。

動的テスト

intdash Edgeのプロジェクトでは、動的テストに、clangsanitizerを使用しています。
valgrindではなくsanitizerを使っている理由は、前述の通り、検知できるエラーが多いからです。ただ、やり方によってはvalgrindでチェックできていた項目(UMR: uninitialized memory reads)がチェックされなくなってしまうので、この点の対応も紹介します(超単純ですが)。

Address Sanitizer

では、clangのsanitizerを使ってみましょう。
まずは、エラーの発生するコードを書いてみます(こちらのページのIntroductionに様々なパターンのエラーを発生するコードがあります)。

main.cc

#include <stdlib.h>

void *p;

int main() {
  p = malloc(7);
  p = 0; // The memory is leaked here.
  return 0;
}

次に、sanitizerを実行してみます。

# clang-++9 -fsanitize=address -g main.c
# ./a.out
=================================================================
==4219==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 7 byte(s) in 1 object(s) allocated from:
    #0 0x4961dd in malloc (/root/a.out+0x4961dd)
    #1 0x4c58b8 in main /root/main.c:6:7
    #2 0x7f44ae33709a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2409a)

SUMMARY: AddressSanitizer: 7 byte(s) leaked in 1 allocation(s).

リークを検知できましたね。

Undefined Behavior

では、次に不定の動作、Undefined Behaviorの検知もしてみましょう。

main.cc

#include <stdlib.h>

void *p;

int main(int argc, char **argv) {
  p = malloc(7);
  p = 0; // The memory is leaked here.

  int k = 0x7fffffff;
  k += argc; // 2147483647 + 1 = Undefined behavior
  return 0;
}
# clang-++9 -fsanitize=undefined,address -g main.c
# ./a.out 
main.cc:9:5: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior main.cc:9:5 in 

=================================================================
==4240==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 7 byte(s) in 1 object(s) allocated from:
    #0 0x4961dd in malloc (/root/a.out+0x4961dd)
    #1 0x4c8227 in main /root/main.cc:6:7
    #2 0x7f60b15bb09a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2409a)

SUMMARY: AddressSanitizer: 7 byte(s) leaked in 1 allocation(s).

Undefined BehaviorとLeakの両方検知できてますね。今回はビルド時のオプション -fsanitize に undefined が足されている点に注意してください。

Memory Sanitizer

では次は、AddressSanitizerは対応していない、未初期化メモリ(UMR: uninitialized memory reads)の検知をするためにMemorySanitizerを足してみましょう。

main.cc

#include <stdlib.h>
#include <stdio.h>
void *p;

int main(int argc, char **argv) {
  p = malloc(7);
  p = 0; // The memory is leaked here.

  int k = 0x7fffffff;
  k += argc; // 2147483647 + 1 = Undefined behavior

 int* a = new int[10];
  a[5] = 0;
  if (a[argc]) // Uninitialized memory read
    printf("xx\n");
  return 0;
}
# clang++-9 -fsanitize=undefined,address,memory -g main.cc
clang: error: invalid argument '-fsanitize=address' not allowed with '-fsanitize=memory'

あらら、ビルドエラーが出てしまいましたね。
実はエラーの内容の通り、残念なことに、AddressSanitizerとMemorySanitizerは同時には設定できません。 なので、おとなしく2回実行しましょう。

# clang++-9 -fsanitize=undefined,address -g main.cc -o address.out
# clang++-9 -fsanitize=memory -g main.cc -o memory.out
# ./address.out; ./memory.out 
main.cc:11:5: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior main.cc:11:5 in 
xx

=================================================================
==4260==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 7 byte(s) in 1 object(s) allocated from:
    #0 0x4961dd in malloc (/root/address.out+0x4961dd)
    #1 0x4c822d in main /root/main.cc:7:7
    #2 0x7fe83d8cc09a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2409a)

SUMMARY: AddressSanitizer: 7 byte(s) leaked in 1 allocation(s).
==4263==WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 0x49a8d3 in main /root/main.cc:15:7
    #1 0x7f595dd7109a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2409a)
    #2 0x41f269 in _start (/root/memory.out+0x41f269)

SUMMARY: MemorySanitizer: use-of-uninitialized-value /root/main.cc:15:7 in main
Exiting

二回実行したとしても処理速度はvalgrindより早いので、こちらの方が良いと思うのですがいかがでしょうか?

Coverage

そうそう、カバレッジの出力も忘れてはいけませんね(カバレッジ計測の仕組みについて興味がある方はこちらの記事も見てみてください)。

# clang++-9 -fsanitize=undefined,address -g main.cc -o address.out
# clang++-9 -fsanitize=memory -fprofile-instr-generate -fcoverage-mapping -g main.cc -o memory.out
# ./address.out; ./memory.out 
...出力は省略...
# llvm-profdata-9 merge -sparse default.profraw -o default.profdata
# llvm-cov-9 show -format=html -output-dir=coverage-report -instr-profile=default.profdata memory.out

coverage-reportフォルダに結果のhtmlページが作成されます。 これでカバレッジも計測できるようになりました。

コーディングスタイルチェック

ここまでで、clangを使ってのsanitizerのチェックを紹介しましたが、ついでなのでフォマッターも紹介します。 「そもそもフォーマッターなんていらないでしょ」と言われるかもしれませんが、コーディングスタイルを統一し、何も考えずにフォーマッターに任せることで、生産性の低い悩みが出る可能性は減らせると思います。

intdash Edgeのプロジェクトでの具体的な使用方法は、

  1. 開発者はコードエディタにclang-formatを適用して自動フォーマット(を推奨)
  2. CIではclang-formatを実行しフォーマット通りかチェック

となっています。

それではスタイルを規定して、フォーマッターを使ってみましょう。 (開発環境のセットアップ方法はこちらで紹介しています)

スタイルの規定

clang-formatのスタイルは様々な設定ができますが、導入しやすいのはベースとなるスタイルを選び、そこから必要な箇所だけ変更する方法です。

ベースとなるスタイル

  • LLVM A style complying with the LLVM coding standards
  • Google A style complying with Google’s C++ style guide
  • Chromium A style complying with Chromium’s style guide
  • Mozilla A style complying with Mozilla’s style guide
  • WebKit A style complying with WebKit’s style guide
  • Microsoft A style complying with Microsoft’s style guide

intdash Edge のプロジェクトでは、ベーススタイルはmozillaを使用し、そこから下記変更を行っています。

  • switch ブロック内の case X: 文をインデント:しない
  • インデントに使用する列数 :4
  • アクセス修飾子(public: protected: private:)のインデント:しない

このフォーマットを設定ファイルにしたものは下記になります。

.clang-format

---
BasedOnStyle: mozilla
IndentCaseLabels: false
IndentWidth: 4
AccessModifierOffset: -4
...

フォーマッターの実行

では、フォーマッターを下記コードに対して実行してみましょう。

main.cc

#include <stdio.h>
#include <stdlib.h>

class Class {private: Class();};

int main(int argc, char **argv) {
    switch (argc) {
            case 1: printf("hello"); break;
    }   
return 0;
}
# clang-format-9 -style=file -i main.cc
# cat main.cc
#include <stdio.h>
#include <stdlib.h>

class Class
{
private:
    Class();
};

int
main(int argc, char** argv)
{
    switch (argc) {
    case 1:
        printf("hello");
        break;
    }
    return 0;
}

フォーマットされましたね。includeは名前順に変更され、classやswitchは指定したフォーマットで整形されています。

まとめ

C++で開発している intdash Edge のプロジェクトで使用しているCIから、clangのsanitizerによる動的テストと、clang-formatによるフォーマットを紹介しました。 sanitizerは今回紹介した以外にも様々な機能があります。ぜひぜひ、導入を検討してみてください。