Jak używać dyrektywy #define

W dzisiejszym artykule omówię element składni języka C, jakim jest dyrektywa preprocesora #define. Nie będzie to tekst przeznaczony dla początkujących. Skupię się raczej na bardziej zaawansowanych zastosowaniach, przydatnych sztuczkach i dobrych praktykach.

Zastosowanie dyrektywy #define – stałe i makra

Dyrektywa preprocesora #define umożliwia zdefiniowanie nazwy, która znaleziona w kodzie zostanie przez preprocesor zamieniona na odpowiednią wartość. Dyrektywa #define jest wykorzystywana w języku C do dwóch głównych celów. Można za jej pomocą definiować stałe:

#define CPU_FREQ   80000000UL
#define BUS_DIV    4
#define BUS_FREQ   (CPU_FREQ / BUS_DIV)

Ostatni z powyższych przykładów to też jest stała, ponieważ wszystkie elementy są znane podczas kompilacji i zamiast wyrażenia zostaje wstawiona konkretna wartość.

Drugim sposobem wykorzystania dyrektywy define są makra, które różnią się od stałych tym, że przyjmują argumenty wejściowe:

#define ARRAY_SIZE(a) (sizeof(a) / sizeof(a[0]))
#define ABS(a)        (((a) < 0) ? (-(a)) : (a))
#define MAX(a, b)     (((a) > (b)) ? (a) : (b))
#define SET_PIN()     PORTA |= 1 << PIN_NR

Ostatni przykład do macro bezargumentowe. Możliwe jest również pominięcie pustych nawiasów w definicji macra. Dobrą praktyką jest używanie pustych nawiasów jeśli nasz define nie jest tylko stałą liczbową, ale kryje się za nim jakaś operacja wykonywana podczas działania programu. Dzięki temu w kodzie możemy łatwo odróżnić takie operacje od zwykłych stałych liczbowych:

/* Przypisana wartosc jest stala */
a = SPEED;

/* Przypisana wartosc jest wynikiem jakiejs operacji runtimeowej */
b = GET_PIN_STATUS();

/* Operacja runtimeowa */
TOGGLE_PIN();

Nazwy zarówno makr, jak i stałych zaleca się pisać z wielkich liter, żeby odróżniały się od zmiennych i funkcji.

Przydatne własności dyrektywy #define

Przedstawię teraz kilka własności dyrektywy define, które warto znać.

Rozbicie define na kilka linii

Często zdarza się chcemy stworzyć #define zawierajacy skomplikowaną operację arytmetyczną, którą chcieli byśmy rozbić na wiele linii. Z pomocą przychodzi nam znak \ umożliwiający kontynuację wyrażenia w nowej linii:

/** Minimum allowed value read from ADC. */
#define VBAT_THRESHOLD_VAL  \
    ((VBAT_DIV_UP_KOHM * VBAT_MIN_VOLTAGE_MV * ADC_MAX_VAL) / \
((VBAT_DIV_UP_KOHM + VBAT_DIV_DOWN_KOHM) * ADC_MAX_VOLTAGE_MV))

Macra składające się z wielu operacji

Macra mogą składać się z wielu operacji zakończonych średnikami. Znak kontynuacji w nowej linii przydaje się wtedy, aby zwiększyć czytelność kodu.

#define SET_PIN1()         PORTA |= 1 << PIN1_NR
#define SET_PIN2()         PORTB |= 1 << PIN2_NR

#define SET_BOTH_PINS()    \
    SET_PIN1(); \
    SET_PIN2();

Takie macra coraz bardziej przypominają funkcje i w większości przypadków lepsze będzie właśnie użycie funkcji niż macra.

Macra zwracające wartość

Macra mogą też zwracać wartości.  Przy stałych liczbowych albo krótkich macrach składających się z jednej operacji takie zachowanie jest intuicyjne. Po prostu wynik operacji można przypisać do jakiejś zmiennej. Na przykład w define VBAT_THRESHOLD_VAL, zaprezentowanym wyżej, zwracaną wartością jest obliczony próg napięcia z ADC. Preprocesor po prostu zamienia symbol na jego rozwinięcie, więc takie zachowanie jest spodziewane. Okazuje się jednak, że można również zwracać wartość przez macro składające się z kilku operacji:

#define SET_PIN1()         PORTA |= 1 << PIN1_NR
#define SET_PIN2()         PORTB |= 1 << PIN2_NR

#define SET_BOTH_PINS_AND_GET_PORTA()    \
    SET_PIN1(); \
    SET_PIN2(); \
    PORTA & 0x000F;

W powyższym przykładzie macro ustawi piny i zwróci wartość PORTA & 0x000F znajdującą się w ostatniej linii. Aby zwrócić wartość należy więc dodać do macra osobną linię kodu zawierającą wyrażenie, które chcemy zwrócić. Makra zwracające wartości mogą nie działać na wszystkich kompilatorach, albo na niektórych składnia może być inna. Przedstawiony przykład działa na kompilatorach opartych na GCC.

Operacje na stringach

Dyrektywa #define pozwala również wykonywać ciekawe operacje na stringach. Rozważmy następujący przykład:

#define CPU_MODEL    STM32F401RB

#define STRINGIFY(x) #x
#define TOSTRING(x)  STRINGIFY(x)

printf(STRINGIFY(CPU_MODEL));
printf(TOSTRING(CPU_MODEL));

Operator # zamienia argument podany do macra na string. Dzięki temu możemy np. drukować nazwy symboli używanych w kodzie na konsoli. Jednak czai się tutaj pewien haczyk. W kodzie powyżej są dwa printfy. Pierwszy z nich używa macra STRINGIFY wykorzystujący operator #. Wydrukuje on tekst „CPU_MODEL”, czyli nazwę symbolu podanego do macra. Aby wydrukować wartość kryjącą się pod podanym definem zamiast jego nazwy należy użyć pomocniczego macra TOSTRING. Drugi printf wydrukuje już spodziewany tekst „STM32F401RB”.

Takie zachowanie jest związane z działaniem preprocesora. Argumenty podane do makr są najpierw rozwijane na docelowe wartości, a dopiero potem wklejane do ciała makra. Wyjątkiem od tej reguły jest operator #, który przyjmuje bezpośrednio tekst przekazany w parametrze macra. Stosując macro pomocnicze TOSTRING wymuszamy konwersję symbolu przekazanego jako argument i dopiero potem przekazujemy go do zamiany na string.

Innym ciekawym operatorem działającym na stringach jest operator łączenia stringów ##. Można go użyć w następujący sposób:

#define CONCAT(a, b) a ## b

printf(CONCAT(text1, text2));

Powyższy kod wydrukuje na konsolę „text1text2”. Łączenie stringów jest wykorzystywane na przykład przez frameworka do unit testów unity do tworzenia nazwy funkcji na podstawie nazwy testu i grupy:

TEST(test_group, test_name)
{
}

Niebezpieczeństwa związane z używaniem makr

Używanie makr nie jest zalecane. Zamiast tego tam gdzie to możliwe lepiej używać funkcji. Dzięki temu uzyskujemy kontrolę typów argumentów, lepsze możliwości debugu i brak efektów ubocznych. O jakie efekty uboczne chodzi? Weźmy na przykład zdefiniowane wyżej macro ABS(a) i wywołajmy je w następujący sposób:

b = ABS(a++);

Preprocesor rozwinie je do postaci:

b = (((a++) < 0) ? (-(a++)) : (a++));

Jak widać zmienna b wcale nie będzie miała wartości bezwzględnej z a. Wartość zostanie przekłamana, ponieważ po porównaniu zmienna jest inkrementowana. Ze względu na ten problem w makrach nie powinno się używać wyrażeń powodujących efekty uboczne.

Inny efekt uboczny ilustruje poniższy przykład:

#define SUM(a, b)    a + b

x = SUM(2, 3) * 5;

Spodziewamy się, że x będzie równać się (2 + 3) * 5, czyli 25. Jednak w rzeczywistości będzie się równać 2 + 3 * 5, czyli 17. Dlatego definicja macra powinna umieszczać wyrażenie arytmetyczne w nawiasach. W osobnych nawiasach powinno znaleźć się także każde wywołanie argumentu macra. W końcu jako argument możemy na przykład podać wyrażenie arytmetyczne.

Kolejny przykład pokazuje efekt uboczny związany z macrami zawierającymi wiele operacji:

#define SET_PIN1()         PORTA |= 1 << PIN1_NR
#define SET_PIN2()         PORTB |= 1 << PIN2_NR

#define SET_BOTH_PINS()    \
    SET_PIN1(); \
    SET_PIN2();

if (0 == timeout)
    SET_BOTH_PINS();
else
    SET_PIN1();

Ten kod spowoduje błąd kompilacji. Rozwinięcie macra SET_BOTH_PINS spowoduje, że w ifie wykona się tylko operacja SET_PIN1(), druga operacja z macra nie będzie już elementem ifa, a else nie będzie połączony z żadnym ifem, co spowoduje błąd kompilacji. Aby się przed tym zabezpieczyć, wykorzystuje się w makrze pętlę do-while:

#define SET_PIN1()         PORTA |= 1 << PIN1_NR
#define SET_PIN2()         PORTB |= 1 << PIN2_NR

#define SET_BOTH_PINS()    do {\
    SET_PIN1(); \
    SET_PIN2(); \
    } while (0)

if (0 == timeout)
    SET_BOTH_PINS();
else
    SET_PIN1();

Dzięki temu wiele operacji zostaje spiętych w jedno wyrażenie i nie ma opisanych wcześniej efektów ubocznych.

Jak unikać zaśmiecenia kodu kompilacją warunkową

Nieraz zdarza się, że chcemy aby jakiś kod wykonywał się lub nie w zależności od parametrów kompilacji. Przyczyną może być chęć posiadania większych informacji debugowych, dodatkowych statystyk, czy różnych konfiguracji. Aby to osiągnąć, często stosuje się dyrektywy define aktywujące pewne części kodu. W funkcjach, które mają wykonywać ten opcjonalny kod często umieszcza się dyrektywy kompilacji warunkowej #ifdef:

#define DEBUG_LOG(x)     printf(x)

void some_function(uint32_t arg1, uint32_t arg2)
{
#if DEBUG == 1
    DEBUG_LOG("Function started");
#endif

    some_code();
    more_code();

#if DEBUG == 1
    DEBUG_LOG("Function ended");
#endif
}

Zastosowane rozwiązanie ma jedną poważną wadę – dyrektywy kompilacji warunkowej znajdują się w wielu miejscach. Przez to kod jest zaśmiecony dodatkowym kodem, który zmniejsza czytelność. Poza tym, jeśli na przykład chcieli byśmy zmienić nazwę define DEBUG, musimy zedytować wiele miejsc w kodzie. Istnieje jednak bardziej praktyczne rozwiązanie:

#if DEBUG == 1
    #define DEBUG_LOG(x)    printf(x)
#else
    #define DEBUG_LOG(x)
#endif

void some_function(uint32_t arg1, uint32_t arg2)
{
    DEBUG_LOG("Function started");

    some_code();
    more_code();

    DEBUG_LOG("Function ended");
}

W tym przypadku mamy jedno miejsce, gdzie występuje kompilacja warunkowa. Jeśli kompilujemy bez define’a DEBUG, miejsca występowania macra DEBUG_LOG zostaną podmienione na puste linie. Jest to bardzo przydatna technika i warto o niej pamiętać.

Podsumowanie

W niniejszym artykule opisałem zastosowanie dyrektywy #define w C. Po krótkim omówieniu podstaw skupiłem się na ciekawych sposobach jej użycia i pewnych sztuczkach, które warto znać. Należy jednak pamiętać, żeby nie nadużywać tego narzędzia. Za jego pomocą można robić bardzo kreatywne i nieintuicyjne rzeczy mogące skutkować trudnym do zrozumienia kodem i różnymi efektami ubocznymi. Chyba, że chcecie wystartować w Obfuscated C code contest – wtedy nadużywanie preprocesora jest wręcz wskazane.

 

 

3 Comments

  1. Genialny artykuł – dzięki 🙂

  2. Czy można wartości #define zmienić z poziomu uruchomionej aplikacji ?

    Chciałbym w aplikacji mieć możliwość włączania i wyłączania opcji DEBUG. W kodzie chciałbym wykorzystać #if.

    • GAndaLF

      18 listopada 2020 at 23:09

      Cześć Dawid. Niestety define to mechanizm preprocesora, który działa jeszcze przed kompilacją i nie można modyfikować za jego pomocą działającego programu. Jeżeli chcesz włączać/wyłączać opcje debugowe w działającej aplikacji – musisz mieć te opcje w skompilowanym kodzie a za pomocą zmiennych i logiki warunkowej możesz jedynie kontrolować, czy mają być wypisywane, czy nie.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *