Kompilator jest Twoim towarzyszem w walce z błędami, jednak domyślne opcje nie pozwalają mu się w pełni wykazać. Zobacz, jak możesz go skonfigurować, aby był jeszcze bardziej pomocny.

Wszyscy wiemy, że oprócz informowania o błędach kompilator ma również możliwość ostrzegania o potencjalnych błędach. O tym, że flaga -Wall wcale nie włącza (jakby się mogło wydawać zważywszy na nazwę) wszystkich ostrzeżeń wie zdecydowana większość. Ale o tym, że -Wall w połączeniu z -Wextra nadal nie włącza wszystkich mechanizmów wykrywania błędów jest świadomych pewnie już znacznie mniej osób.

W dzisiejszym artykule pokażę kilka flag włączających ciekawe mechanizmy sprawdzające w GCC oraz prosty sposób na nieprzegapienie ważnych ostrzeżeń.

co przemilczy GCC?

Stosowanie -Wall -Wextra to według mnie podstawa, jeżeli chodzi o wczesne ostrzeganie o potencjalnych problemach. -Wpedantic może się przydać, gdy piszemy bibliotekę przenośną pomiędzy kompilatorami i nie chcemy, aby kod wykorzystywał rozszerzenia konkretnego kompilatora (np. zapis 0b0010 w GCC). Ale GCC widzi więcej niż nam się wydaje. O czym GCC nam nie powie, chyba, że go o to specjalnie poprosimy? Zapytajmy go!

$ gcc -Wall -Wextra -Wpedantic -Q --help=warnings | grep -v "enabled"

Fragment listy zwróconej przez powyższą komendę wygląda tak:

  -Wcompare-reals                       [disabled]
  -Wconditionally-supported             [disabled]
  -Wconversion                          [disabled]
  -Wconversion-extra                    [disabled]
  -Wctor-dtor-privacy                   [disabled]
  -Wdate-time                           [disabled]
  -Wdelete-non-virtual-dtor             [disabled]
  -Wdisabled-optimization               [disabled]
  -Wdouble-promotion                    [disabled]
  -Wduplicated-branches                 [disabled]
  -Wduplicated-cond                     [disabled]
  -Weffc++                              [disabled]

Jest to tylko mały wycinek. Cała lista jest długa i zawiera łącznie 165 pozycji dla GCC 7.4.0. Część warningów dotyczy C++ i Fortrana, a nie C. Wyjaśnienia poszczególnych flag można znaleźć w dokumentacji kompilatora.

Dalsze filtrowanie listy wykonałem ręcznie. Opierając się na dokumentacji i liście, którą dostałem prosto od GCC zrobiłem moje zestawienie najciekawszych flag, które nie zawierają się w -Wall – Wextra.

-Wlogical-op

Użycie operatorów logicznych tam, gdzie prawdopodobnie mieliśmy użyć operatorów bitowych. Pewnie rzadko taki błąd się zdarza, ale jak już go popełnimy to lepiej niech znajdzie go za nas kompilator niż my sami po paru godzinach debugowania.

uint16_t cnt = 0x1234;
uint8_t cntLow = cnt && 0xFF;
uint8_t cntHigh = (cnt >> 8) && 0xFF;  
$ gcc -Wall -Wextra -Wlogical-op warnings.c
warnings.c: In function ‘main’:
warnings.c:19:26: warning: logical ‘and’ applied to non-boolean constant [-Wlogical-op]
     uint8_t cntLow = cnt && 0xFF;
                          ^~
warnings.c:20:34: warning: logical ‘and’ applied to non-boolean constant [-Wlogical-op]
     uint8_t cntHigh = (cnt >> 8) && 0xFF;

-Wduplicated-cond

W dwóch gałęziach if-else-if użyte są te same warunki. Idealnie pasuje do wyłapywania błędów typu kopiuj-wklej czyli „skopiowałem warunek, ale zapomniałem zmienić sprawdzaną flagę”.

if(reg & (1 << 2)) 
{
    foo();
}
else if(reg & (1 << 2))
{
    bar();
}
$ gcc -Wall -Wextra -Wduplicated-cond warnings.c
warnings.c: In function ‘main’:
warnings.c:33:13: warning: duplicated ‘if’ condition [-Wduplicated-cond]
     else if(reg & (1 << 2))
             ^~~~~~~~~
warnings.c:29:8: note: previously used here
     if(reg & (1 << 2))

-Wduplicated-branches

Nazwa sama w sobie wyjaśnia sporo. Jeżeli w if-else w efekcie kopiuj-wklej zostaniemy z identycznym kodem w obu gałęziach, GCC nam o tym powie.

if(reg & (1 << 1))
{
    foo();
}
else
{
     foo();
}
$ gcc -Wall -Wextra -Wduplicated-branches warnings.c
warnings.c: In function ‘main’:
warnings.c:29:7: warning: this condition has identical branches [-Wduplicated-branches]
     if(reg & (1 << 1))

-Wstack-usage=<number>

Informuje o przekroczeniu zadanego przez parametr <number> rozmiaru stosu. Rozmiar podajemy w bajtach.

void bar(void)
{
    int a[100];
}

void foo(void)
{
    bar();
}

int main(void)
{
    foo();
}

Dla prostego przypadku, gdzie rozmiar stosu nie jest dynamiczny kompilator od razu informuje o przekroczeniu.

$ gcc -Wall -Wextra -Wstack-usage=100 warnings.c
warnings.c: In function ‘bar’:
warnings.c:17:9: warning: unused variable ‘a’ [-Wunused-variable]
     int a[100];
         ^
warnings.c:15:6: warning: stack usage is 416 bytes [-Wstack-usage=]
 void bar(void)
      ^~~

Dla kodu wykorzystującego np. Variable Length Array (VLA) ostrzeżenie nie będzie tak jednoznaczne. Natomiast, jeżeli zakładamy, że w naszym kodzie uruchamianym na małym procesorze nie zezwalamy na stosowanie VLA to w praktyce ten warning poinformuje nas o naruszeniu tej zasady.

$ gcc -Wall -Wextra -Wstack-usage=100 warnings.c
warnings.c: In function ‘bar’:
warnings.c:17:9: warning: unused variable ‘a’ [-Wunused-variable]
     int a[size];
         ^
warnings.c:15:6: warning: stack usage might be unbounded [-Wstack-usage=]
 void bar(size_t size)
      ^~~

-Winit-self

Włączając -Wall włączamy automatycznie -Wuninitialized, które ostrzega nas przed używaniem zmiennych bez ich uprzedniej inicjalizacji. Mając włączone -Wuninitialized możemy dodać -Winit-self – ostrzeże nas ono przed tak „śmiesznym” błędem, jak zainicjalizowanie zmiennej samą sobą.

int a = a;
$ gcc -Wall -Wextra  -Winit-self warnings.c
warnings.c: In function ‘main’:
warnings.c:27:9: warning: ‘a’ is used uninitialized in this function [-Wuninitialized]
     int a = a;

-Wnull-dereference

Gdy kompilatorowi uda się wydedukować, że gdzieś dochodzi do dereferencji NULL’a łaskawie się z nami tym podzieli. Pod warunkiem, że mamy włączoną tą flagę. Niestety do działania wymaga włączonych optymalizacji.

This option is only active when -fdelete-null-pointer-checks is active, which is enabled by optimizations in most targets. The precision of the warnings depends on the optimization options used.

https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html
int *ptr = NULL;
printf("*NULL = %d\n", *ptr);

W tak prostym przykładzie dla optymalizacji O1, O2, O3, O4, Os, Ofast na PC błąd został zgłoszony. Jeżeli wiesz coś więcej na temat skuteczności tego ostrzeżenia w bardziej złożonym kodzie to podziel się wiedzą w komentarzu.

$ gcc -Wall -Wextra -O1 -Wnull-dereference warnings.c
warnings.c: In function ‘main’:
warnings.c:31:5: warning: null pointer dereference [-Wnull-dereference]
     printf("*NULL = %d\n", *ptr);
     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~

-Wpointer-arith

Może nie jest to takie oczywiste ale sizeof(void) nie jest zdefiniowany w języku C. Co za tym idzie, operacje arytmetyczne na wskaźnikach na void nie są możliwe. GCC rozszerza standard i definiuje sizeof(void) równy jednemu bajtowi. To ostrzeżenia warto włączyć, jeżeli tworzymy kod z założenia przenośny na inne kompilatory. Poniższy kod kompiluje się i działa poprawnie pod GCC. W kompilatorze Cosmic natomiast od razu dostaniemy błąd kompilacji przy próbie inkrementacji wskaźnika na void. No, bo skoro rozmiar tego, na co wskaźnik wskazuje nie jest znany, to o ile bajtów przesunąć wskaźnik?

void copy(void *dst, void *src, size_t size)
{
    for(size_t i = 0; i < size; i++)
    {
        *(uint8_t *)dst = *(uint8_t *)src;
        dst++;
        src++;
    }
}

int main(void)
{
    uint8_t reg1[] = {1, 2, 3};
    uint8_t reg2[3] = {0};

    copy(reg2, reg1, sizeof(reg1));
    return 0;
}
$ gcc -Wall -Wextra -Wpointer-arith warnings.c
warnings.c: In function ‘copy’:
warnings.c:31:12: warning: wrong type argument to increment [-Wpointer-arith]
         dst++;
            ^~
warnings.c:32:12: warning: wrong type argument to increment [-Wpointer-arith]
         src++;
            ^~

warningowy szum

W idealnym świecie projekty powinny kompilować się po cichu, bez ostrzeżeń kompilatora. Rzeczywistość jest nieco bardziej brutalna i często widziałem jak projekt kompiluje się z dziesiątkami ostrzeżeń. W praktyce większość z nich informuje o tym, że jakaś zmienna albo funkcja jest nieużywana albo gdzieś jest porównanie zmiennej ze znakiem ze zmienną bez znaku. Ta sytuacja potrafi być oczywiście groźna, ale programiście wydaje się, że ma pod kontrolą zakresy porównywanych zmiennych, więc ostrzeżenie zostaje w projekcie. Takie podejście pozostawiania w kodzie błahych warningów powoduje coraz większy szum informacyjny. Jeśli w projekcie mamy 50 warningów informujących o nieużywanych zmiennych to zapominając o & przed zmienną nie zauważymy 51-ego ostrzeżenia mówiącego o niejawnym rzutowaniu integera na wskaźnik. A o tym już raczej wolelibyśmy wiedzieć.

Oczywiście najlepiej do takich sytuacji nie dopuszczać i redukować szum, gdy tylko się pojawia. Łatwiej powiedzieć niż zrobić. Wymaga to dyscypliny w całym zespole. Jeszcze trudniej jest, gdy dostajemy stary projekt do utrzymania. Z naszej perspektywy dopiero zaczynamy pracę, a już mamy 500 warningów. Niekoniecznie mamy czas na ich czyszczenie.

Tutaj z pomocą przychodzi -Werror=. Jest to flaga kompilatora pozwalająca podnieść wybrane ostrzeżenia do rangi błędów kompilacji. Jeżeli uznamy, pewnie całkiem słusznie, że używanie zmiennych bez jej uprzedniej inicjalizacji jest zawsze błędem w naszym projekcie to możemy poniższy kod kompilować z flagą

-Werror=uninitialized
uint32_t a;

if(a < 10000)
{
    foo();
}
else
{
    bar();
}
$ gcc -Wall -Wextra -Werror=uninitialized warnings.c
warnings.c: In function ‘main’:
warnings.c:31:7: error: ‘a’ is used uninitialized in this function [-Werror=uninitialized]
     if(a < 10000)
       ^
cc1: some warnings being treated as errors

Będąc uzbrojonym w tą wiedzę warto poświęcić chwilę, przejrzeć wszystkie flagi ostrzeżeń i złożyć własną, dopasowaną do potrzeb projektu listę flag traktowanych jako błędy. Może skoro mamy dostępny 1kB RAM’u to upewnijmy się, że nikt w zespole nie będzie mógł użyć VLA?

-Werror=stack-usage=200

Naciąłeś się kiedy na bardzo ciekawy błąd polegający na naruszeniu sequence-point’ów?

#define POWER_2(x) (x * x)

int n = 2;
int powN = POWER_2(++n);
printf("POWER_2(++n) = %d\n", powN);

Ten pozornie prosty kod powoduje warning w GCC i clang’u. Program generuje niezdefiniowane zachowanie. W jednej sekwencji zmienna n jest modyfikowana dwa razy. W tym przypadku łatwo zapomnieć, że makro nie jest funkcją i nasz kod po przejściu przez procesor rozwija się w poniższy sposób.

int powN POWER_2(++n)   => int powN = (++n * ++n)

Ot, ryzyko używania makr tam gdzie nie trzeba. Zgadniesz jaki będzie wypisany wynik tej operacji? GCC generuje 16, clang 12. Kompilując z flagą

-Werror=sequence-point

mamy pewność, że taki psikus nam nie umknie w natłoku innych, niewinnych ostrzeżeń.

co dalej?

Jak widzisz kompilator może być bardziej pomocny niż by się mogło wydawać, wystarczy go grzecznie poprosić. Na pewno można w opcjach GCC znaleźć więcej przykładów warningów, które w 99,99% można traktować jako błąd.

Dalej ruszaj sam i stwórz własną listę ostrzeżeń, które powinny kończyć się nieudaną kompilacją. Ucz się na własnych błędach i z każdym nowym warningiem, który okazał się błędem rozbudowuj swoją listę. Może nawet przechowuj ją w osobnym repozytorium. Jak znajdziesz coś ciekawego, napisz do mnie, a najlepiej podziel się informacją w komentarzu. Myślę, że każdy chciałby poznać dodatkowy sposób zabezpieczenia się przed błędami. Szczególnie, jeżeli wymaga to tylko odpowiedniej konfiguracji narzędzi, których i tak używamy. To już koniec, udało mi się Cię czymś zaskoczyć w tym artykule?