C++
Narzędzia i techniki debugowania i zapobiegania debugowaniu w C ++
Szukaj…
Wprowadzenie
Dużo czasu od programistów C ++ spędza na debugowaniu. Ten temat ma pomóc w realizacji tego zadania i dać inspirację dla technik. Nie oczekuj obszernej listy problemów i rozwiązań naprawionych przez narzędzia lub instrukcji na temat wspomnianych narzędzi.
Uwagi
Ten temat jeszcze się nie zakończył, przydatne byłyby przykłady następujących technik / narzędzi:
- Wymień więcej narzędzi do analizy statycznej
- Narzędzia oprzyrządowania binarnego (takie jak UBSan, TSan, MSan, ESan ...)
- Hartowanie (CFI ...)
- Fuzzing
Mój program w C ++ kończy się na segfault - valgrind
Miejmy podstawowy wadliwy program:
#include <iostream>
void fail() {
int *p1;
int *p2(NULL);
int *p3 = p1;
if (p3) {
std::cout << *p3 << std::endl;
}
}
int main() {
fail();
}
Zbuduj go (dodaj -g, aby uwzględnić informacje debugowania):
g++ -g -o main main.cpp
Biegać:
$ ./main
Segmentation fault (core dumped)
$
Debugujmy to za pomocą 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)
$
Najpierw skupiamy się na tym bloku:
==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
Pierwszy wiersz mówi nam, że segfault jest spowodowany odczytaniem 4 bajtów. Druga i trzecia linia to stos wywołań. Oznacza to, że niepoprawny odczyt jest wykonywany przy funkcji fail()
, wiersz 8 main.cpp, który jest wywoływany przez main, wiersz 13 main.cpp.
Patrząc na linię 8 main.cpp widzimy
std::cout << *p3 << std::endl;
Ale najpierw sprawdzamy wskaźnik, więc co jest nie tak? Pozwala sprawdzić drugi blok:
==8515== Conditional jump or move depends on uninitialised value(s)
==8515== at 0x400813: fail() (main.cpp:7)
==8515== by 0x40083F: main (main.cpp:13)
Mówi nam, że w linii 7 znajduje się zmienna jednostkowa i czytamy ją:
if (p3) {
Który wskazuje nam linię, w której sprawdzamy p3 zamiast p2. Ale jak to możliwe, że p3 jest niezainicjowany? Inicjalizujemy to przez:
int *p3 = p1;
Valgrind radzi nam, aby ponownie uruchomić z --track-origins=yes
, zróbmy to:
valgrind --track-origins=yes ./main
Argument za valgrindem jest zaraz po valgrind. Jeśli umieścimy go po naszym programie, zostanie on przekazany do naszego programu.
Wydajność jest prawie taka sama, jest tylko jedna różnica:
==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)
Co mówi nam, że niezainicjowana wartość, której użyliśmy w linii 7, została utworzona w linii 3:
int *p1;
który prowadzi nas do naszego niezainicjowanego wskaźnika.
Analiza segmentów za pomocą GDB
Użyjmy tego samego kodu jak powyżej dla tego przykładu.
#include <iostream>
void fail() {
int *p1;
int *p2(NULL);
int *p3 = p1;
if (p3) {
std::cout << *p2 << std::endl;
}
}
int main() {
fail();
}
Najpierw skompilujmy to
g++ -g -o main main.cpp
Uruchommy go z gdb
gdb ./main
Teraz będziemy w powłoce gdb. Wpisz polecenie run.
(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;
Widzimy, że błąd segmentacji występuje w linii 11. Zatem jedyną zmienną używaną w tej linii jest wskaźnik p2. Pozwala zbadać jego treść wpisując druk.
(gdb) print p2
$1 = (int *) 0x0
Teraz widzimy, że p2 został zainicjowany na 0x0, co oznacza NULL. W tym wierszu wiemy, że próbujemy wyrejestrować wskaźnik NULL. Więc idziemy to naprawić.
Wyczyść kod
Debugowanie rozpoczyna się od zrozumienia kodu, który próbujesz debugować.
Zły kod:
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; }
Lepszy kod:
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;
}
Bez względu na to, jaki styl kodowania preferujesz i którego używasz, spójny styl kodowania (i formatowania) pomoże ci zrozumieć kod.
Patrząc na powyższy kod, można zidentyfikować kilka ulepszeń w celu poprawy czytelności i debuggowania:
Zastosowanie oddzielnych funkcji do oddzielnych akcji
Korzystanie z oddzielnych funkcji pozwala pominąć niektóre funkcje w debuggerze, jeśli nie jesteś zainteresowany szczegółami. W tym konkretnym przypadku możesz nie być zainteresowany tworzeniem lub drukowaniem danych i chcesz tylko przystąpić do sortowania.
Kolejną zaletą jest to, że musisz czytać mniej kodu (i zapamiętywać go) podczas przechodzenia przez kod. Teraz musisz tylko odczytać 3 linie kodu w main()
, aby go zrozumieć, zamiast całej funkcji.
Trzecią zaletą jest to, że po prostu masz mniej kodu do obejrzenia, co pomaga wytrenowanemu oku w wykrywaniu tego błędu w ciągu kilku sekund.
Używanie spójnego formatowania / konstrukcji
Zastosowanie spójnego formatowania i konstrukcji usunie bałagan z kodu, ułatwiając skupienie się na kodzie zamiast na tekście. Wiele dyskusji dotyczyło „właściwego” stylu formatowania. Niezależnie od tego stylu posiadanie jednego spójnego stylu w kodzie poprawi znajomość i ułatwi skupienie się na kodzie.
Ponieważ formatowanie kodu jest czasochłonne, zaleca się do tego celu dedykowane narzędzie. Większość IDE ma co najmniej jakieś wsparcie i może formatować bardziej spójnie niż ludzie.
Możesz zauważyć, że styl nie ogranicza się do spacji i znaków nowej linii, ponieważ nie mieszamy już funkcji stylu swobodnego z elementami składowymi, aby uzyskać początek / koniec kontenera. ( v.begin()
vs std::end(v)
).
Wskaż ważne części kodu.
Niezależnie od stylu, który wybierzesz, powyższy kod zawiera kilka znaczników, które mogą dać ci wskazówkę, co może być ważne:
- Komentarz
optimized
, wskazuje na niektóre fantazyjne techniki - Niektóre wczesne powroty w
sortVector()
wskazują, że robimy coś specjalnego -
std::ref()
wskazuje, że coś się dzieje zsortVector()
Wniosek
Posiadanie czystego kodu pomoże ci zrozumieć kod i skróci czas potrzebny na jego debugowanie. W drugim przykładzie recenzent kodu może nawet wykryć błąd na pierwszy rzut oka, podczas gdy błąd może być ukryty w szczegółach w pierwszym. (PS: Błąd jest w porównaniu z 2
).
Analiza statyczna
Analiza statyczna to technika sprawdzania kodu pod kątem wzorców powiązanych ze znanymi błędami. Korzystanie z tej techniki jest mniej czasochłonne niż przegląd kodu, jednak jego kontrole są ograniczone tylko do tych zaprogramowanych w narzędziu.
Kontrole mogą obejmować niepoprawny średnik za instrukcją if ( if (var);
) do zaawansowanych algorytmów grafowych, które określają, czy zmienna nie jest inicjowana.
Ostrzeżenia kompilatora
Włączanie analizy statycznej jest łatwe, najbardziej uproszczona wersja jest już wbudowana w kompilator:
Jeśli włączysz te opcje, zauważysz, że każdy kompilator znajdzie błędy, których nie robią inne, i że wystąpią błędy dotyczące technik, które mogą być poprawne lub prawidłowe w określonym kontekście. while (staticAtomicBool);
może być akceptowalny, nawet jeśli while (localBool);
nie jest.
Tak więc, w przeciwieństwie do recenzji kodu, walczysz z narzędziem, które rozumie Twój kod, informuje o wielu przydatnych błędach, a czasem się z tobą nie zgadza. W tym ostatnim przypadku może być konieczne lokalne wyłączenie ostrzeżenia.
Ponieważ powyższe opcje włączają wszystkie ostrzeżenia, mogą włączyć ostrzeżenia, których nie chcesz. (Dlaczego twój kod powinien być kompatybilny z C ++ 98?) Jeśli tak, możesz po prostu wyłączyć to ostrzeżenie:
-
clang++ -Wall -Weverything -Werror -Wno-errortoaccept ...
-
g++ -Wall -Weverything -Werror -Wno-errortoaccept ...
-
cl.exe /W4 /WX /wd<no of warning>...
Tam, gdzie ostrzeżenia kompilatora pomagają podczas programowania, spowalniają kompilację. Dlatego nie zawsze możesz chcieć włączyć je domyślnie. Albo je uruchamiasz domyślnie, albo włączasz ciągłą integrację z droższymi czekami (lub wszystkimi).
Narzędzia zewnętrzne
Jeśli zdecydujesz się na ciągłą integrację, korzystanie z innych narzędzi nie będzie takie trudne. Narzędzie takie jak clang-tidy ma listę kontroli, która obejmuje szeroki zakres problemów, kilka przykładów:
- Rzeczywiste błędy
- Zapobieganie krojeniu
- Zapewnia ze skutkami ubocznymi
- Kontrola czytelności
- Wprowadzające w błąd wcięcie
- Sprawdź nazewnictwo identyfikatora
- Kontrole modernizacyjne
- Użyj make_unique ()
- Użyj nullptr
- Kontrole wydajności
- Znajdź niepotrzebne kopie
- Znajdź nieefektywne wywołania algorytmu
Lista może nie być tak duża, ponieważ Clang ma już wiele ostrzeżeń kompilatora, jednak zbliży Cię o krok do bazy wysokiej jakości kodu.
Inne narzędzia
Istnieją inne narzędzia o podobnym celu, takie jak:
- analizator statyczny studia wizualnego jako narzędzie zewnętrzne
- clazy , wtyczka kompilatora Clang do sprawdzania kodu Qt
Wniosek
Istnieje wiele narzędzi analizy statycznej dla C ++, które są wbudowane w kompilator jako narzędzia zewnętrzne. Wypróbowanie ich nie zajmuje dużo czasu na łatwe konfiguracje, a oni znajdą błędy, które możesz przegapić podczas przeglądu kodu.
Bezpieczny stos (uszkodzenia stosu)
Zepsucie stosu to irytujące błędy. Ponieważ stos jest uszkodzony, debugger często nie może dać ci dobrego śladu stosu, gdzie jesteś i jak się tam dostałeś.
W tym momencie pojawia się bezpieczny stos. Zamiast używać jednego stosu dla swoich wątków, użyje dwóch: Bezpiecznego stosu i niebezpiecznego stosu. Bezpieczny stos działa dokładnie tak, jak wcześniej, z tym wyjątkiem, że niektóre części są przenoszone na niebezpieczny stos.
Które części stosu zostaną przeniesione?
Każda część, która może spowodować uszkodzenie stosu, zostanie przeniesiona z bezpiecznego stosu. Gdy tylko zmienna na stosie zostanie przekazana przez odniesienie lub adres zostanie zmieniony, kompilator zdecyduje o przydzieleniu jej na drugim stosie zamiast na bezpiecznym.
W rezultacie każda operacja wykonywana za pomocą tych wskaźników, każda modyfikacja dokonana w pamięci (na podstawie tych wskaźników / referencji) może wpłynąć tylko na pamięć na drugim stosie. Ponieważ nigdy nie dostajemy wskaźnika zbliżonego do bezpiecznego stosu, stos nie może uszkodzić stosu, a debugger nadal może odczytać wszystkie funkcje na stosie, aby dać niezły ślad.
W jakim celu się go stosuje?
Bezpieczny stos nie został wymyślony, aby zapewnić lepsze wrażenia z debugowania, jednak jest to miły efekt uboczny w przypadku paskudnych błędów. Jego pierwotnym celem jest część projektu integralności wskaźnika kodu (CPI) , w którym próbują zapobiec zastąpieniu adresów zwrotnych, aby zapobiec wstrzyknięciu kodu. Innymi słowy, próbują zapobiec wykonywaniu kodu hakerów.
Z tego powodu funkcja została aktywowana na chromie i zgłoszono, że ma obciążenie procesora <1%.
Jak to włączyć?
W tej chwili opcja jest dostępna tylko w kompilatorze clang , gdzie można przekazać -fsanitize=safe-stack
do kompilatora. Wniosek został złożony do wdrożenia tej samej funkcji w GCC.
Wniosek
Uszkodzenia stosu mogą być łatwiejsze do debugowania, gdy włączony jest bezpieczny stos. Ze względu na narzut związany z niską wydajnością możesz nawet aktywować domyślnie w konfiguracji kompilacji.