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 z sortVector()

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:

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.



Modified text is an extract of the original Stack Overflow Documentation
Licencjonowany na podstawie CC BY-SA 3.0
Nie związany z Stack Overflow