C++
C ++デバッグとデバッグ防止ツールとテクニック
サーチ…
前書き
C ++開発者の多くの時間は、デバッグに費やされます。このトピックはこのタスクを支援し、テクニックのインスピレーションを与えるものです。前述のツールに関するツールやマニュアルで修正された問題や解決策のリストを期待しないでください。
備考
このトピックはまだ完全ではありませんが、以下のテクニック/ツールの例が役に立ちます:
- より多くの静的解析ツールの紹介
- バイナリ計測ツール(UBSan、TSan、MSan、ESan ...など)
- 硬化(CFI ...)
- ファジー
私のC ++プログラムはsegfault-valgrindで終わります
基本的な失敗プログラムを作ってみましょう:
#include <iostream>
void fail() {
int *p1;
int *p2(NULL);
int *p3 = p1;
if (p3) {
std::cout << *p3 << std::endl;
}
}
int main() {
fail();
}
ビルドする(デバッグ情報を含める-gを追加する):
g++ -g -o main main.cpp
実行:
$ ./main
Segmentation fault (core dumped)
$
valgrindでデバッグしましょう:
$ valgrind ./main
==8515== Memcheck, a memory error detector
==8515== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.
==8515== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info
==8515== Command: ./main
==8515==
==8515== Conditional jump or move depends on uninitialised value(s)
==8515== at 0x400813: fail() (main.cpp:7)
==8515== by 0x40083F: main (main.cpp:13)
==8515==
==8515== Invalid read of size 4
==8515== at 0x400819: fail() (main.cpp:8)
==8515== by 0x40083F: main (main.cpp:13)
==8515== Address 0x0 is not stack'd, malloc'd or (recently) free'd
==8515==
==8515==
==8515== Process terminating with default action of signal 11 (SIGSEGV): dumping core
==8515== Access not within mapped region at address 0x0
==8515== at 0x400819: fail() (main.cpp:8)
==8515== by 0x40083F: main (main.cpp:13)
==8515== If you believe this happened as a result of a stack
==8515== overflow in your program's main thread (unlikely but
==8515== possible), you can try to increase the size of the
==8515== main thread stack using the --main-stacksize= flag.
==8515== The main thread stack size used in this run was 8388608.
==8515==
==8515== HEAP SUMMARY:
==8515== in use at exit: 72,704 bytes in 1 blocks
==8515== total heap usage: 1 allocs, 0 frees, 72,704 bytes allocated
==8515==
==8515== LEAK SUMMARY:
==8515== definitely lost: 0 bytes in 0 blocks
==8515== indirectly lost: 0 bytes in 0 blocks
==8515== possibly lost: 0 bytes in 0 blocks
==8515== still reachable: 72,704 bytes in 1 blocks
==8515== suppressed: 0 bytes in 0 blocks
==8515== Rerun with --leak-check=full to see details of leaked memory
==8515==
==8515== For counts of detected and suppressed errors, rerun with: -v
==8515== Use --track-origins=yes to see where uninitialised values come from
==8515== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)
$
まずこのブロックに焦点を当てます:
==8515== Invalid read of size 4
==8515== at 0x400819: fail() (main.cpp:8)
==8515== by 0x40083F: main (main.cpp:13)
==8515== Address 0x0 is not stack'd, malloc'd or (recently) free'd
最初の行は、segfaultが4バイトを読み取ることによって発生していることを示しています。 2行目と3行目はコールスタックです。これは、main.cppのmain、line 13で呼び出されるmain.cppの8行目のfail()
関数で無効な読み取りが行われたことを意味します。
main.cppの8行目を見ると
std::cout << *p3 << std::endl;
しかし、最初にポインタをチェックするので、何が問題なのですか?他のブロックをチェックします:
==8515== Conditional jump or move depends on uninitialised value(s)
==8515== at 0x400813: fail() (main.cpp:7)
==8515== by 0x40083F: main (main.cpp:13)
それは7行目にユニット化された変数があり、それを読んでいることがわかります:
if (p3) {
これは、p2の代わりにp3を調べる行を指しています。しかし、p3はどのように初期化されていない可能性がありますか?初期化する:
int *p3 = p1;
Valgrindは--track-origins=yes
で再実行するようアドバイスします。それをやりましょう:
valgrind --track-origins=yes ./main
valgrindの引数はvalgrindの直後です。私たちのプログラムの後に置くと、プログラムに渡されます。
出力はほぼ同じですが、違いは1つだけです:
==8517== Conditional jump or move depends on uninitialised value(s)
==8517== at 0x400813: fail() (main.cpp:7)
==8517== by 0x40083F: main (main.cpp:13)
==8517== Uninitialised value was created by a stack allocation
==8517== at 0x4007F6: fail() (main.cpp:3)
これは、7行目で使用した初期化されていない値が3行目で作成されたことを示しています。
int *p1;
初期化されていないポインタに導いてくれます。
GDBによるSegfault分析
この例では、上記と同じコードを使用します。
#include <iostream>
void fail() {
int *p1;
int *p2(NULL);
int *p3 = p1;
if (p3) {
std::cout << *p2 << std::endl;
}
}
int main() {
fail();
}
まずコンパイルします
g++ -g -o main main.cpp
gdbで実行できるようにする
gdb ./main
今はgdbシェルになります。実行を入力します。
(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/opencog/code-snippets/stackoverflow/a.out
Program received signal SIGSEGV, Segmentation fault.
0x0000000000400850 in fail () at debugging_with_gdb.cc:11
11 std::cout << *p2 << std::endl;
セグメンテーションフォルトが11行目で起こっているのが分かります。この行で使用される唯一の変数はポインタp2です。内容の入力印刷を調べることができます。
(gdb) print p2
$1 = (int *) 0x0
今度は、p2がNULLを表す0x0に初期化されていることがわかります。この行では、NULLポインタを逆参照しようとしていることがわかります。だから我々はそれを修正します。
クリーンコード
デバッグは、デバッグしようとしているコードを理解することから始まります。
悪いコード:
int main() {
int value;
std::vector<int> vectorToSort;
vectorToSort.push_back(42); vectorToSort.push_back(13);
for (int i = 52; i; i = i - 1)
{
vectorToSort.push_back(i *2);
}
/// Optimized for sorting small vectors
if (vectorToSort.size() == 1);
else
{
if (vectorToSort.size() <= 2)
std::sort(vectorToSort.begin(), std::end(vectorToSort));
}
for (value : vectorToSort) std::cout << value << ' ';
return 0; }
より良いコード:
std::vector<int> createSemiRandomData() {
std::vector<int> data;
data.push_back(42);
data.push_back(13);
for (int i = 52; i; --i)
vectorToSort.push_back(i *2);
return data;
}
/// Optimized for sorting small vectors
void sortVector(std::vector &v) {
if (vectorToSort.size() == 1)
return;
if (vectorToSort.size() > 2)
return;
std::sort(vectorToSort.begin(), vectorToSort.end());
}
void printVector(const std::vector<int> &v) {
for (auto i : v)
std::cout << i << ' ';
}
int main() {
auto vectorToSort = createSemiRandomData();
sortVector(std::ref(vectorToSort));
printVector(vectorToSort);
return 0;
}
一貫したコーディング(および書式設定)スタイルを持つことで、コードを理解しやすくなります。
上記のコードを見ると、可読性とデバッグ性を向上させるためのいくつかの改善点を特定できます。
別々のアクションのための別々の機能の使用
別々の関数を使用すると、詳細に興味がない場合は、デバッガの一部の関数をスキップすることができます。この特定のケースでは、データの作成や印刷には関心がなく、ソートだけに進んでください。
もう一つの利点は、コードを踏んでいくうちに、より少ないコードを読む(そして暗記する)必要があることです。 main()
内の3行のコードを読むのは、関数全体ではなく、理解するためだけです。
3番目の利点は、見るコードが少なくて済み、このバグを数秒で発見するのに訓練された目を助けることです。
一貫した書式設定/構文の使用
一貫した書式設定と構造の使用は、コードから混乱を取り除き、テキストではなくコードに集中するのを容易にします。 「正しい」書式設定について多くの議論が行われました。そのスタイルにかかわらず、コード内に単一の一貫したスタイルを持たせることで、親しみやすさが向上し、コードに集中するのが容易になります。
書式設定コードは時間のかかる作業であるため、専用のツールを使用することをお勧めします。ほとんどのIDEはこれに少なくとも何らかのサポートをしており、人間よりも一貫性のあるフォーマットを行うことができます。
スタイルが空白や改行に限定されているわけではなく、フリースタイルとメンバ関数を混在させてコンテナの開始/終了を取得する必要はありません。 ( v.begin()
vs std::end(v)
)。
コードの重要な部分に注意を向ける。
選択するスタイルにかかわらず、上記のコードには、何が重要かを示すヒントを与えるマーカーがいくつか含まれています。
-
optimized
たコメント -
sortVector()
一部の早期返品は、私たちが特別なことをしていることを示しています -
std::ref()
は、sortVector()
で何かが起こっていることを示します。
結論
クリーンなコードがあれば、コードの理解に役立ち、デバッグに必要な時間が短縮されます。 2番目の例では、コードレビュー担当者が最初に一見してバグを発見するかもしれませんが、最初のバグは詳細に隠れているかもしれません。 (PS:バグは2
と比較しています)
静的解析
静的解析とは、既知のバグにリンクされたパターンをチェックする手法です。この手法を使用するとコードレビューよりも時間がかかりませんが、チェックはツールでプログラムされたものに限定されます。
チェックには、変数が初期化されていないかどうかを判断する高度なグラフアルゴリズムまで、if文の後ろにある不正なセミコロン( if (var);
)を含めることができます。
コンパイラの警告
静的解析を有効にするのは簡単ですが、最もシンプルなバージョンは既にコンパイラに組み込まれています。
これらのオプションを有効にすると、各コンパイラは他の人がしていないバグを発見し、特定のコンテキストで有効であるか有効であるかもしれないテクニックに誤りを犯すことに気づくでしょう。 while (staticAtomicBool);
while (localBool);
でも受け入れられるかもしれませんwhile (localBool);
そうではありません。
コードレビューとは違って、コードを理解し、有用なバグをたくさん教えてくれるツールと戦っていることがあります。この最後のケースでは、ローカルで警告を抑制する必要があります。
上記のオプションはすべての警告を有効にするので、望ましくない警告を有効にする可能性があります。 (なぜあなたのコードはC ++ 98と互換性がありますか?)もしそうなら、その特定の警告を単に無効にすることができます:
-
clang++ -Wall -Weverything -Werror -Wno-errortoaccept ...
-
g++ -Wall -Weverything -Werror -Wno-errortoaccept ...
-
cl.exe /W4 /WX /wd<no of warning>...
コンパイラの警告が開発中に役立つところでは、コンパイルがかなり遅くなります。そのため、デフォルトでは常に有効にするとは限りません。デフォルトでそれらを実行するか、より高価なチェック(またはそのすべて)との継続的な統合を有効にします。
外部ツール
あなたが何らかの継続的な統合を行うことに決めた場合、他のツールの使用はそんなに難しくありません。 clang-tidyのようなツールには、幅広い問題をカバーするチェックリストがあります。いくつかの例があります:
- 実際のバグ
- スライスの防止
- 副作用を伴うアサート
- 可読性チェック
- 誤解を招く刻み
- 識別子の命名を確認する
- 近代化チェック
- make_unique()を使用する
- nullptrを使用する
- パフォーマンスチェック
- 不要なコピーを探す
- 非効率的なアルゴリズム呼び出しを見つける
Clangはすでに多くのコンパイラの警告を受けているので、リストはそれほど大きくないかもしれませんが、高品質のコードベースに一歩近づくでしょう。
その他のツール
同様の目的を持つ他のツールが存在します:
- 外部ツールとしてのビジュアルスタジオスタティックアナライザ
- clazy 、QtコードをチェックするためのClangコンパイラプラグイン
結論
C ++のための多くの静的解析ツールが存在し、どちらも外部ツールとしてコンパイラに組み込まれています。それらを試してみると、簡単なセットアップのためにそれほど時間がかからず、コードレビューで見逃す可能性のあるバグが見つかるでしょう。
セーフスタック(スタック破損)
スタックの破損は、見て迷惑なバグです。スタックが壊れているので、デバッガは、スタック位置とスタック位置をスタックトレースできないことがよくあります。
これは安全スタックが作用する場所です。スレッドに1つのスタックを使用する代わりに、セーフスタックと危険なスタックの2つを使用します。安全スタックは、以前と同じように動作しますが、一部の部品は危険スタックに移動します。
スタックのどの部分が移動するのですか?
スタックを破損する可能性のあるすべての部品がセーフスタックから移動します。スタック上の変数が参照渡しされるか、または変数がこの変数のアドレスを受け取るとすぐに、コンパイラはこれを安全なスタックの代わりに2番目のスタックに割り当てることを決定します。
結果として、これらのポインタで行う操作(これらのポインタ/参照に基づいてメモリに加えた変更)は、2番目のスタックのメモリにのみ影響します。セーフスタックに近いポインタを得ることはないので、スタックはスタックを破壊することができず、デバッガはスタック上のすべての関数を読み込み、素晴らしいトレースを与えることができます。
それは実際に何のために使われていますか?
安全なスタックは、より良いデバッグ経験を提供するために考案されたものではありませんでしたが、厄介なバグに対する良い副作用です。それはコードインジケータの完全性(CPI)プロジェクトの一環として、コードインジェクションを防ぐためにリターンアドレスのオーバーライドを防ぎます。言い換えれば、彼らはハッカーコードを実行しないようにしようとします。
このため、この機能はクロムで起動され、CPUオーバーヘッドが1%未満であると報告されています。
それを有効にする方法?
今のところ、このオプションはclangコンパイラでしか利用できません。このコンパイラでは 、 -fsanitize=safe-stack
をコンパイラに渡すことができ-fsanitize=safe-stack
。 GCCの同じ機能を実装する提案がなされました。
結論
スタックの破損は、スタックが有効になっている場合にデバッグするのが容易になります。パフォーマンスのオーバーヘッドが低いため、ビルド構成ではデフォルトで有効にすることもできます。