Programowanie STM32 w C++ – const, constexpr i #define

Każdy programista mikrokontrolerów wie, że stałe to nie tylko drobny detal w kodzie, ale fundament czytelności i niezawodności programu. Pozwalają uniknąć umieszczenia w kodzie tzw. „magic numbers”, czyli liczb, które nie wiadomo na pierwszy rzut oka, co oznaczają. W języku C przyzwyczailiśmy się korzystać z #define, który sprawdza się jako szybki sposób na nazwanie wartości liczbowych czy adresów rejestrów. Jednak w świecie C++ podejście do stałych jest znacznie bardziej bezpieczne i elastyczne – mamy do dyspozycji const oraz constexpr, które rozwiązują wiele problemów znanych z preprocesora. Dzięki nim możemy pisać kod, który jest bardziej odporny na błędy, łatwiejszy do debugowania i lepiej optymalizowany przez kompilator. Różnica nie jest wyłącznie teoretyczna – w programowaniu embedded, wybór właściwego sposobu deklarowania stałych ma duże znaczenie. W tym artykule przyjrzymy się trzem podejściom: #define, const i constexpr, pokazując ich mocne i słabe strony w praktyce. Będzie trochę teorii, sporo kodu i kilka praktycznych wskazówek, które możesz od razu zastosować w projektach na STM32. Zapraszam do lektury – obiecuję, że po przeczytaniu będziesz patrzył na stałe w C++ zupełnie inaczej niż dotąd.

#define – szybkie, ale problematyczne dziedzictwo języka C

W świecie języka C #define to jeden z najczęściej spotykanych sposobów na definiowanie stałych. Działa on na etapie preprocesora, czyli jeszcze zanim kompilator „zobaczy” nasz kod. W praktyce oznacza to, że wszędzie tam, gdzie wpiszemy nazwę zdefiniowaną przez #define, preprocesor podmieni ją na wartość literalną. Na przykład w prostym projekcie dla STM32 możemy spotkać taki zapis:

#define LED_PIN 5
#define BUFFER_SIZE 128

Tutaj LED_PIN i BUFFER_SIZE to tak naprawdę tylko podstawione liczby – dla kompilatora nie mają żadnego typu, są czystym „kopiuj-wklej”. Przykładowy program:

#include <stdint.h>

#define LED_PIN 5
#define BUFFER_SIZE 128

int main(void)
{
    uint8_t buffer[BUFFER_SIZE];
    buffer[0] = LED_PIN;
    return 0;
}

Po wykonaniu operacji podstawienia przez preprocesor będzie wyglądał tak:

#include <stdint.h>

int main(void)
{
    uint8_t buffer[128];
    buffer[0] = 5;
    return 0;
}

Dyrektywa define jest tak naprawdę widoczna tylko dla programisty – dla kompilatora to jedynie zwykła liczba podstawiona w kodzie. Dzięki temu można łatwiej analizować i rozumieć źródła, bo zamiast „magicznych” wartości mamy czytelne nazwy. Niestety, dla kompilatora i debuggera takie symbole są bezużyteczne – nie mają typu, nie istnieją w pamięci i nie da się ich podejrzeć podczas działania programu. W języku C++ również można używać #define, ale obecnie traktuje się to raczej jako pozostałość po dawnych czasach.

Na pierwszy rzut oka takie podejście wydaje się wygodne – jest szybkie i działa zawsze. Problem pojawia się jednak, gdy zaczniemy pisać większe projekty. Po pierwsze, #define nie ma typów, więc kompilator nie uchroni nas przed błędami – jeśli przypadkiem użyjemy LED_PIN w miejscu, gdzie spodziewana jest np. liczba zmiennoprzecinkowa, kod się skompiluje, a problem wyjdzie dopiero w trakcie działania programu. Po drugie, takie stałe są trudniejsze do debugowania – debugger nie pokaże nam, że zmienna LED_PIN ma wartość 5, bo… takiej zmiennej w ogóle nie ma, istnieje tylko sama liczba. W projektach embedded może to prowadzić do trudnych do znalezienia błędów, szczególnie gdy #define używa się do definiowania masek bitowych czy adresów rejestrów.

W C++ zamiast #define znacznie częściej stosuje się const lub constexpr, które zapewniają bezpieczeństwo typów, lepszą czytelność kodu i wygodę debugowania. Można powiedzieć, że #define to stary młotek z czasów C, który nadal działa, ale mając do dyspozycji nowoczesne narzędzia, warto sięgnąć po coś lepszego.

const – bezpieczne i typowane stałe

W języku C i C++ słowo kluczowe const pełni bardzo ważną rolę, ale jego znaczenie i możliwości różnią się nieco w obu językach. W C stała const jest traktowana jak zwykła zmienna tylko z ograniczeniem, że nie można zmienić jej wartości. W C++ podejście jest bardziej zaawansowane: const może być rozpoznawane przez kompilator jako wartość stała w czasie kompilacji, co pozwala na optymalizację i użycie jej tam, gdzie w C trzeba byłoby zastosować #define. Różnica względem preprocesora jest ogromna – #define to tylko zamiana tekstu, podczas gdy const to pełnoprawna zmienna z typem. Oznacza to, że kompilator wie, czy const int pasuje do kontekstu, a ewentualne błędy typów zostaną wykryte od razu. To właśnie bezpieczeństwo typów odróżnia const od starego podejścia z makrami.

Przykład z życia wzięty: w starym stylu kodu embedded można spotkać

#define BUFFER_SIZE 128

W C++ lepiej napisać:

const uint16_t bufferSize = 128;

Dzięki temu kompilator zadba, by nikt nie użył tej wartości np. jako liczby zmiennoprzecinkowej w obliczeniach. Podobnie zamiast

#define LED_PIN 5 

możemy zdefiniować:

const uint8_t ledPin = 5;

W debuggerze zobaczymy nie tylko wartość, ale również jej nazwę i typ. W bardziej złożonych przypadkach przydaje się nawet const tablica, np., która na STM32 domyślnie trafi do pamięci Flash, dzięki czemu nie zajmuje RAM-u.

const uint8_t lookupTable[4] = {0x1, 0x2, 0x4, 0x8};

Kiedy piszemy kod na mikrokontrolery STM32, pojawia się jednak dodatkowa kwestia – gdzie taka stała jest przechowywana. Domyślnie const ląduje w sekcji pamięci Flash, co jest bardzo korzystne, bo nie zajmuje cennego RAM-u. Jednak w zależności od użytego kompilatora i opcji linkera, czasem const może trafić do RAM, szczególnie gdy jej adres jest używany w kodzie. Dlatego programista embedded powinien zawsze wiedzieć, że const nie jest równoznaczne z “zero cost” i czasami trzeba sprawdzić plik .map, by upewnić się, gdzie wylądowała dana stała.

W praktyce na STM32 stosowanie const jest świetnym sposobem na definiowanie częstotliwości zegarów, rozmiarów buforów czy adresów peryferiów – kod staje się wtedy czytelniejszy i bezpieczniejszy. W odróżnieniu od #define, taką stałą można łatwo podejrzeć w debuggerze i sprawdzić jej typ. Dzięki temu unika się wielu błędów, np. przypadkowego użycia liczby całkowitej tam, gdzie spodziewana jest zmienna typu float. Warto też pamiętać, że const można stosować nie tylko do liczb, ale również do wskaźników, struktur czy nawet tablic, co jeszcze bardziej zwiększa przejrzystość kodu.

constexpr – stałe obliczane w czasie kompilacji

Słowo kluczowe constexpr to jedno z tych udogodnień C++, które naprawdę robi różnicę w programowaniu embedded. Na pierwszy rzut oka wygląda podobnie do const, ale jego moc tkwi w tym, kiedy wartość zostaje ustalona – constexpr oznacza, że dana jest obliczana już na etapie kompilacji, zanim program w ogóle trafi do mikrokontrolera. W praktyce oznacza to, że kompilator nie tylko wie, że wartość jest stała, ale też potrafi wykonać proste obliczenia i wstawić wynik bez potrzeby wykonywania ich w runtime. To ogromna oszczędność cykli procesora i pamięci RAM, szczególnie w środowisku takim jak STM32, gdzie każdy bajt i mikrosekunda mają znaczenie.

W przeciwieństwie do const, który może być tylko „niezmienny” w trakcie działania programu, constexpr gwarantuje, że wartość jest znana z góry. Na przykład:

constexpr uint32_t clockHz = 16'000'000;
constexpr uint32_t tickPerMs = ClockHz / 1000;

Tutaj kompilator sam obliczy TickPerMs i wstawi gotową liczbę do kodu – żadnych dodatkowych operacji w runtime. Dzięki temu można tworzyć również tablice typu lookup, które generują się w czasie kompilacji:

constexpr uint8_t sinTable[8] = {0, 38, 71, 92, 100, 92, 71, 38};

Taka tablica znajdzie się w pamięci Flash, a procesor nie traci czasu na jej budowanie przy starcie programu.

W praktyce constexpr jest potężnym narzędziem optymalizacji – pozwala pisać kod, który wygląda jak dynamiczny, ale działa z szybkością kodu statycznego. Kompilator może dzięki temu usuwać całe fragmenty obliczeń, wstawiając gotowe wartości do instrukcji asemblera. W świecie mikrokontrolerów, gdzie wydajność i oszczędność pamięci to priorytet, constexpr to sprytny sposób na szybszy, mniejszy i bardziej deterministyczny kod.

Porównanie: #define vs const vs constexpr

W programowaniu STM32 i C++ wybór sposobu definiowania stałych ma znaczenie zarówno dla czytelności, jak i wydajności kodu. Poniższa tabela zestawia najważniejsze różnice między trzema podejściami:

Cecha / Podejście#defineconstconstexpr
RodzajMakro preprocesoraStała zmienna z typemStała obliczana w czasie kompilacji
TypowanieBrak typówTyp statyczny, kompilator sprawdzaTyp statyczny, kompilator sprawdza
Widoczność w debuggerzeNie istnieje, pokazuje tylko wartośćTak, widać nazwę i typTak, widać nazwę i typ
Miejsce w pamięciBrak (podstawione literały)RAM lub Flash (zależnie od kompilatora)Flash, znane w czasie kompilacji
OptymalizacjaBrak, tylko zamiana tekstuCzęściowa, zależy od kompilatoraPełna, obliczenia wykonane w kompilacji
Kiedy stosować które podejście?
  • #define – używaj tylko do kompilacji warunkowej (#ifdef DEBUG) lub do prostych, starych nagłówków, nie do definiowania stałych w nowym kodzie.
  • const – stosuj, gdy wartość ma być stała w trakcie działania programu, ale niekoniecznie musi być znana w czasie kompilacji. Idealne do numerów pinów, rozmiarów buforów, adresów rejestrów.
  • constexpr – używaj, gdy wartość może być obliczona w czasie kompilacji, np. generowanie tablic lookup, wartości konfiguracyjne, prescalery timerów – maksymalizuje wydajność i minimalizuje użycie RAM.

Częstym błędem w programowaniu STM32 jest nieświadome umieszczenie const w RAM zamiast w pamięci Flash, co zajmuje cenną pamięć operacyjną. Nadużywanie #define oraz brak świadomości różnic między const a constexpr może prowadzić do trudnych do wykrycia błędów i nieoptymalnego kodu. Świadome stosowanie const i constexpr pozwala tworzyć bezpieczny, czytelny i wydajny kod embedded.

Stałe definiowane za pomocą enum class

O enum class wspominałem już w poprzednim artykule „Programowanie STM32 w C++ – podstawowe typy danych”, ale warto przypomnieć o tym typie danych również przy okazji stałych.

Wprowadzenie enum class w C++ pozwala uniknąć konfliktów nazw i przypadkowego użycia niewłaściwych wartości, co bywa częstym problemem przy stosowaniu zwykłych #define. Dzięki typowanym wyliczeniom kompilator może sprawdzać poprawność przypisywanych wartości, co znacząco zwiększa bezpieczeństwo kodu i redukuje liczbę błędów logicznych.

W praktyce na STM32 można je stosować do reprezentowania trybów pinów, stanów diod LED, komunikatów protokołów czy etapów automatów stanów, co ułatwia zarządzanie hardware’em. Dodatkowo kod staje się bardziej czytelny i samodokumentujący się – od razu widać, jakie wartości są dozwolone i w jakim kontekście mogą być użyte. enum class wymusza jawne użycie nazw przestrzeni wyliczenia, dzięki czemu unika się kolizji nazw w większych projektach.

W porównaniu do zwykłych enum w C, typowane wyliczenia pozwalają na łatwiejsze sprawdzanie typów i kompatybilności, co jest szczególnie ważne w projektach embedded, gdzie stabilność jest kluczowa. Korzystanie z enum class poprawia też utrzymanie i debugowanie kodu, ponieważ debugger wyświetla zarówno nazwę wyliczenia, jak i przypisaną wartość. Choć na pierwszy rzut oka jest to niewielka zmiana w stosunku do C, w praktyce znacząco zwiększa jakość i bezpieczeństwo kodu, ułatwiając pracę nad większymi i bardziej złożonymi projektami STM32.

inline ze stałymi – czym jest i kiedy używać?

Od C++17 wprowadzono możliwość używania inline dla stałych, co jest szczególnie przydatne w projektach embedded. Dzięki temu można zdefiniować jedną wspólną stałą w pliku nagłówkowym, która będzie widoczna w wielu plikach źródłowych, bez ryzyka wielokrotnej definicji. W praktyce oznacza to, że nie musimy już tworzyć osobnych plików .cpp tylko po to, żeby zadeklarować globalne stałe. Przed C++ 17 wyglądało to tak:

// constants.hpp
extern const uint32_t bufferSize ;   // tylko deklaracja

// constants.cpp
const uint32_t bufferSize = 128;  // definicja w jednym pliku

Po wprowadzeniu inline można napisać:

// constants.hpp
inline constexpr uint32_t bufferSize = 128;

Taka deklaracja pozwala używać bufferSize w różnych modułach programu, a kompilator traktuje ją jak stałą znaną w czasie kompilacji. Dzięki temu stała trafia od razu do pamięci Flash, nie zajmując niepotrzebnie RAM-u. Ułatwia to również debugowanie, ponieważ debugger zna nazwę i typ stałej. Stosowanie inline constexpr w nagłówkach zwiększa czytelność kodu i eliminuje problemy z linkerem, które mogłyby pojawić się przy tradycyjnych stałych.

Warto jeszcze wspomnieć, dlaczego używamy inline do stałych (ale też i zmiennych) globalnych. Przecież jak zapiszemy to samo w pliku .hpp bez inline , też się skompiluje. Oczywiście, skompiluje się, ale zostanie utworzonych kilka zmiennych (kopie) w każdym pliku .cpp. Dodając inline mamy pewność, że korzystamy z jednej wspólnej stałej.

Podsumowanie

Wybór odpowiedniego sposobu definiowania stałych w programowaniu mikrokontrolerów ma kluczowe znaczenie dla czytelności, bezpieczeństwa i wydajności kodu. Makra #define są szybkie i działają zawsze, ale brak typów i trudności w debugowaniu sprawiają, że w nowoczesnym C++ powinny być używane jedynie do kompilacji warunkowej. Z kolei const wprowadza typowanie statyczne, umożliwia łatwe debugowanie i pozwala na bezpieczne definiowanie wartości, które nie zmieniają się w trakcie działania programu. Jeszcze bardziej zaawansowane jest constexpr, które pozwala na obliczenia w czasie kompilacji i generowanie tablic lookup, co optymalizuje kod i minimalizuje użycie RAM-u.

Dodatkowo C++ oferuje narzędzia takie jak enum class czy inline constexpr, które zwiększają przejrzystość kodu i eliminują problemy z wielokrotnymi definicjami. Świadome stosowanie tych konstrukcji pozwala unikać pułapek, takich jak niepotrzebne zajmowanie pamięci RAM czy błędy typów. W projektach embedded jest to szczególnie ważne, ponieważ każdy bajt pamięci i cykl procesora się liczy. Poprawne użycie stałych poprawia również utrzymanie i skalowalność kodu, ułatwiając pracę nad większymi projektami. Dzięki nowoczesnym funkcjom C++ można pisać kod, który jest zarówno bezpieczny, wydajny, jak i elegancki. Ostatecznie, zrozumienie różnic między #define, const i constexpr pozwala programistom STM32 wykorzystać pełen potencjał języka C++ w embedded.

Dodaj komentarz

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