Programowanie STM32 w C++ – podstawowe typy danych

Witaj w czwartym artykule z serii poświęconej programowaniu mikrokontrolerów STM32 w języku C++. Jeśli dopiero zaczynasz swoją przygodę z embedded, dobrze trafiłeś! W poprzednich częściach zajmowaliśmy się m.in. konfiguracją środowiska, pierwszymi krokami z C++ i prostym sterowaniem sprzętem. Teraz pora zająć się fundamentem każdego programu — typami danych.

Typy danych określają, jakie informacje przechowuje nasz program i jak duże miejsce w pamięci one zajmują. Może to brzmieć jak coś bardzo podstawowego, ale w świecie mikrokontrolerów — gdzie każdy bajt pamięci RAM i cykl zegara mają znaczenie — odpowiedni dobór typu danych może być kluczowy dla działania i wydajności całego systemu.

W tym artykule:

  • przyjrzymy się podstawowym typom danych w języku C++,
  • dowiemy się, które z nich najlepiej nadają się do programowania mikrokontrolerów,
  • zobaczymy, jak unikać typowych błędów i pisać bardziej czytelny, bezpieczny kod.

Nawet jeśli masz już jakieś doświadczenie z językiem C++, warto spojrzeć na typy danych z perspektywy embedded. To, co w aplikacji desktopowej uchodzi na sucho, na mikrokontrolerze może prowadzić do trudnych do wykrycia błędów lub nadmiernego zużycia zasobów. Zaczynajmy!

Typy podstawowe w C++

Język C++ udostępnia zestaw podstawowych typów danych, które są fundamentem każdego programu. Należą do nich: int (liczby całkowite), char (znaki), float i double (liczby zmiennoprzecinkowe), a także bool (wartości logiczne: true lub false). W codziennym programowaniu to właśnie te typy najczęściej spotkasz w kodzie.

Typy te można modyfikować za pomocą modyfikatorów, takich jak signed i unsigned (czy liczba może być ujemna), short (krótsza wersja) czy long (dłuższa wersja). Na przykład unsigned int to liczba całkowita, która nie może być ujemna, a long long to liczba całkowita o większym zakresie niż zwykłe int.

Ważne jest, aby pamiętać, że rozmiar tych typów nie jest taki sam na każdej platformie. Dla przykładu: na 8-bitowych mikrokontrolerach int często zajmuje tylko 16 bitów, natomiast na 32-bitowych — takich jak STM32 — int zazwyczaj ma 32 bity (4 bajty). To samo dotyczy innych typów — ich dokładna wielkość może się różnić zależnie od kompilatora i architektury sprzętowej. Dlatego warto być świadomym, ile pamięci zajmuje dany typ, zwłaszcza w systemach embedded, gdzie pamięć jest ograniczona. Poniżej zamieściłem tabelę z podstawowymi typami i ich rozmiarem w zależności od architektury.

Typ8-bit MCU (AVR)32-bit MCU (STM32)PC (x86/x64)
bool888
char888
short161616
int163232
long323232
long long646464
float323232
double326464

Na platformie STM32, która oparta jest o architekturę ARM Cortex-M (czyli 32-bitową), operowanie na typach 32-bitowych, takich jak int, jest często bardziej efektywne niż używanie np. char lub short, mimo że te zajmują mniej miejsca. Dzieje się tak, ponieważ procesor i tak przetwarza dane w 32-bitowych porcjach, więc może być mniej kosztowne czasowo użycie „pełnowymiarowych” typów.

Typy całkowite o znanym rozmiarze (C++11)

W programowaniu embedded szczególnie ważna jest dokładna kontrola nad rozmiarem danych — zarówno ze względu na ograniczoną pamięć, jak i bezpośredni dostęp do sprzętu, gdzie każdy bit może mieć znaczenie. Z pomocą przychodzi nam biblioteka <cstdint>, wprowadzona w standardzie C++11, która definiuje typy całkowite o jednoznacznie określonej długości.

Przykłady takich typów to:

  • std::uint8_t – liczba całkowita bez znaku, 8-bitowa
  • std::int8_t – liczba całkowita ze znakiem, 8-bitowa
  • std::uint16_t – liczba całkowita bez znaku, 16-bitowa
  • std::int16_t – liczba całkowita ze znakiem, 16-bitowa
  • std::uint32_t – liczba całkowita bez znaku, 32-bitowa
  • std::int32_t – liczba całkowita ze znakiem, 32-bitowa
  • std::uint64_t – liczba całkowita bez znaku, 64-bitowa
  • std::int64_t – liczba całkowita ze znakiem, 64-bitowa

W bibliotece <cstdint> oprócz typów o dokładnie określonym rozmiarze, takich jak int32_t, znajdziemy również typy oznaczone jako fast i least, np. int_fast8_t czy uint_least16_t.

Typy least to najmniejsze typy całkowite, które mają co najmniej określoną szerokość bitową. Na przykład uint_least16_t będzie najmniejszym typem bez znaku, który zajmuje co najmniej 16 bitów. Są one przydatne, gdy zależy nam na oszczędności pamięci, ale nadal potrzebujemy gwarancji minimalnej szerokości.

Typy fast z kolei to najszybsze typy całkowite o co najmniej zadanym rozmiarze. Na niektórych platformach może się okazać, że szybsze jest użycie większego typu — np. na 32-bitowym mikrokontrolerze int_fast8_t może być faktycznie 32-bitowym int, bo procesor lepiej operuje na danych 32-bitowych. Dzięki temu możemy pisać przenośny kod, który będzie działał optymalnie na różnych architekturach. Stosowanie tych typów pomaga znaleźć kompromis między szybkością działania a oszczędnością pamięci — ważny aspekt w programowaniu embedded.

Typy danych opisane w tym akapicie mają zawsze taki sam rozmiar, niezależnie od platformy czy kompilatora. Dzięki typom z <cstdint>:

  • zyskujemy przenośność – kod zachowuje się tak samo na różnych architekturach,
  • mamy pełną kontrolę nad pamięcią i strukturą danych – co jest kluczowe przy pracy z rejestrami, komunikacją (np. protokoły UART, I2C, CAN) czy przy mapowaniu pamięci.

Dla czytelności kodu często stosuje się również aliasy, np. using u8 = std::uint8_t, który stanowi nowocześniejszy odpowiednik typedef. Alias pozwala skrócić zapisy i nadać bardziej kontekstowe nazwy. Dzięki aliasowi możemy zadeklarować zmienną typu uint8_t taki sposób:

using u8 = std::uint8_t
u8 var = 1;

W świecie mikrokontrolerów typy z <cstdint> są absolutnym standardem i dobrą praktyką, którą warto stosować od samego początku.

Typ logiczny bool

W C++ typ bool służy do przechowywania wartości logicznych — true lub false. W pamięci jest on zazwyczaj reprezentowany jako pojedynczy bajt (8 bitów), choć technicznie do przechowania samej wartości wystarcza jeden bit. Niestety, ze względu na ograniczenia sprzętowe i optymalizacje procesora, bool nie jest pakowany bitowo, co oznacza, że każdy bool w strukturze zajmuje co najmniej 1 bajt.

Często programiści embedded zamiast bool używają typu uint8_t z biblioteki <cstdint>, ponieważ mają wówczas większą kontrolę nad rozmiarem i łatwiej im operować na wartościach w sposób typowy dla mikrokontrolera. Jednak w praktyce uint8_t zajmuje tyle samo miejsca co bool i nie przynosi większych korzyści, jeśli chodzi o oszczędność pamięci.

Ważniejszy wpływ na rozmiar struktur ma to, jak te typy są rozmieszczone i wyrównane w pamięci. Ponieważ bool i uint8_t mają rozmiar 1 bajta, pojedyncze wartości są oszczędne, ale jeśli mamy wiele pól logicznych w strukturze, warto rozważyć stosowanie bitfieldów lub specjalnych mechanizmów pakowania, by zminimalizować zużycie pamięci.

Typ auto i dedukcja typów

W C++ auto to specjalny sposób deklarowania zmiennych, który pozwala kompilatorowi samodzielnie wywnioskować typ na podstawie przypisanej wartości. Dzięki temu kod staje się bardziej czytelny i mniej podatny na błędy, zwłaszcza gdy typy są złożone lub długie do zapisania.

W programowaniu embedded auto ma swoje zalety — pozwala łatwiej pisać pętle lub operacje na rejestrach sprzętowych, gdzie typy mogą być skomplikowane (np. wskaźniki do struktur rejestrów czy iteratory kontenerów). Przykładowo zamiast wpisywać cały typ iteratora, można napisać auto i = 0; lub auto reg = *reinterpret_cast<volatile uint32_t*>(adres_rejestru), co upraszcza kod i zmniejsza ryzyko błędów.

Jednak auto ma też swoje ograniczenia w embedded. Ponieważ typ jest wyliczany podczas kompilacji, nie zawsze jest oczywiste, jaki dokładnie typ otrzymujemy, co może utrudniać debugowanie lub prowadzić do niezamierzonych konwersji. Ponadto w bardzo krytycznych fragmentach kodu, gdzie ważna jest precyzyjna kontrola nad rozmiarem i właściwościami zmiennej, warto jednak jawnie określać typy.

Podsumowując, auto jest świetnym narzędziem do pisania czystszego i bardziej elastycznego kodu w embedded, ale zawsze warto mieć świadomość, co kompilator „wymyśla” za nas i w razie potrzeby stosować jawne deklaracje.

Typ void, czyli „typ nieokreślony”

Typ void w C++ pełni specjalną rolę – oznacza brak wartości. Nie jest to typ danych przechowujący informacje, lecz raczej sposób na wskazanie, że funkcja nic nie zwraca lub że wskaźnik jest „bez typowy” (void*). W programowaniu embedded typ void jest często używany przy definiowaniu funkcji, które wykonują operacje, ale nie zwracają żadnego wyniku — na przykład obsługa przerwań czy konfiguracja sprzętu. Dzięki temu możemy jasno określić, że dana funkcja służy tylko do wykonania pewnej czynności, a nie do obliczania wartości.

Równie ważne są wskaźniki void*, które pozwalają na operowanie na dowolnych adresach pamięci bez przypisania im konkretnego typu. To szczególnie przydatne w embedded, gdzie często trzeba odczytywać lub zapisywać dane bezpośrednio do rejestrów sprzętowych lub obszarów pamięci o specyficznej strukturze. Warto jednak pamiętać, że operacje na wskaźnikach void* wymagają późniejszego rzutowania na konkretny typ, aby program mógł poprawnie interpretować zawartość pamięci. Dlatego typ void jest raczej narzędziem pomocniczym niż typem do bezpośredniego przechowywania danych.

Typ enum i enum class

W C++ typy wyliczeniowe (enum) służą do definiowania zbiorów nazwanych wartości stałych, co ułatwia czytelność i organizację kodu. Klasyczny enum pozwala na stworzenie listy wartości, które w rzeczywistości są przypisane do liczb całkowitych. Na przykład:

enum State {
    INIT,
    RUNNING,
    ERROR
};

Takie podejście jest proste, ale ma pewne ograniczenia — wszystkie nazwy zdefiniowane w enum trafiają do przestrzeni nazw otaczającego je zakresu, co może prowadzić do konfliktów. Ponadto klasyczny enum jest słabo typowany, czyli wartości mogą być swobodnie konwertowane na i z innych typów całkowitych, co niesie ryzyko błędów.

Dlatego w nowoczesnym C++ wprowadzono enum class, który rozwiązuje te problemy. Wartości enum class są zamknięte w zakresie samego typu, dzięki czemu unikamy kolizji nazw. Ponadto enum class jest silnie typowany — nie można go przypadkowo konwertować na inne typy bez jawnej konwersji. Przykład enum class:

enum class LedState {
    OFF,
    ON,
    BLINK
};

W programowaniu embedded, szczególnie w sterowaniu stanami maszyn, GPIO czy konfiguracji peryferiów, enum i enum class są bardzo przydatne. Pozwalają jasno określić różne stany lub tryby pracy, co zwiększa czytelność i zmniejsza ryzyko pomyłek.

Na przykład, do sterowania stanem LED można użyć:

LedState currentState = LedState::OFF;

if (currentState == LedState::ON) {
    // włącz LED
}

Podsumowując, enum class jest zalecanym rozwiązaniem w nowoczesnym kodzie embedded ze względu na lepszą kontrolę i bezpieczeństwo typów, podczas gdy klasyczny enum nadal bywa stosowany w prostych lub projektach legacy (wykorzystujących starszy kod).

Czym różnią się typy danych w C i C++?

Podstawowe typy danych w C i C++ są bardzo podobne, ponieważ C++ został stworzony jako język rozszerzający C. Zarówno w C, jak i C++ znajdziemy takie typy jak int, char, float, double czy bool (choć typ bool został wprowadzony do C dopiero w standardzie C99).

Różnice zaczynają się pojawiać w kwestii typów o znanym rozmiarze zdefiniowanych w <stdint.h> (C) i <cstdint> (C++). Obie biblioteki dostarczają te same precyzyjne typy (int8_t, uint16_t, int32_t itd.), które gwarantują stały rozmiar niezależnie od platformy. Jednak w C++ typy te są umieszczone w przestrzeni nazw std::, co pomaga unikać konfliktów nazw i poprawia organizację kodu.

Dodatkowo C++ oferuje szersze możliwości związane z typami — na przykład silniejsze typowanie dzięki enum class czy mechanizmy automatycznej dedukcji typów (auto). W embedded oba języki korzystają z tych samych typów bazowych, ale C++ daje więcej narzędzi do pisania bezpieczniejszego i czytelniejszego kodu, zachowując pełną kompatybilność z typami C.

Konwersja typów

Mówiąc o typach danych, nie sposób nie wspomnieć o ich konwersji. Konwersja typów to proces zmiany wartości jednego typu danych na inny, który jest często niezbędny w programowaniu embedded. Na przykład, gdy odczytujemy dane z rejestru sprzętowego (zazwyczaj jako uint32_t), ale chcemy je przetworzyć jako liczby całkowite ze znakiem (int), musimy wykonać odpowiednią konwersję.

W C++ istnieją dwa główne sposoby konwersji: ukryta, niejawna (inaczej automatyczną) i jawna (ręczną, czyli rzutowanie). Niejawna konwersja odbywa się wtedy, gdy kompilator sam zmienia typ, np. przypisując int do float. Dobrą praktyką jest zawsze świadome zarządzanie konwersjami, by uniknąć utraty danych (np. przy zmianie z uint32_t na uint8_t) oraz błędów związanych z nieprawidłowym rzutowaniem, które mogą prowadzić do awarii systemu embedded.

Operator static_cast<> to najczęściej używany rodzaj rzutowania. Służy do konwersji między typami, które są ze sobą powiązane lub mają sens logiczny — np. rzutowanie liczby zmiennoprzecinkowej na całkowitą, lub wskaźników w hierarchii dziedziczenia. static_cast<> jest stosunkowo bezpieczny — kompilator sprawdza zgodność typów, ale nie zabezpiecza przed błędami logicznymi, np. utratą danych.

uint32_t tempInt = static_cast<uint32_t>(23.7f);

W C++ można również używać tradycyjnego rzutowania w stylu C, czyli zapisu:

uint32_t i = (uint32_t)23.7f;

Jednak w C++ to rzutowanie jest mniej zalecane i traktowane jako mniej bezpieczne, ponieważ:

  • Nie rozróżnia różnych rodzajów rzutowań (konwersji), co może prowadzić do ukrytych błędów.
  • Jest mniej czytelne
  • Trudniej jest śledzić i kontrolować takie rzutowania podczas debugowania i przeglądu kodu.

Dlatego w nowoczesnym C++ — zwłaszcza w projektach embedded, gdzie bezpieczeństwo i czytelność kodu są bardzo ważne — rekomenduje się używanie jawnych operatorów rzutowania takich jak static_cast<>.

Podsumowanie

W artykule zagłębiamy się w temat typów danych w C++, z naciskiem na ich zastosowanie w programowaniu mikrokontrolerów STM32. Choć może się wydawać, że to tylko nudna teoria, w świecie embedded każdy bajt i każda cyfra naprawdę mają znaczenie. Omawiamy podstawowe typy, jak int, char czy bool, oraz ich modyfikatory, które pomagają dostosować je do konkretnej sytuacji. Zwracamy uwagę, że rozmiar typów zależy od platformy — coś, co na PC zajmuje 4 bajty, na mikrokontrolerze może być o połowę mniejsze.

Wchodzimy też w temat typów o znanym rozmiarze z <cstdint>, które są nieocenione, jeśli chcemy mieć kod przewidywalny i przenośny. Przyglądamy się typom enum i enum class, które świetnie sprawdzają się przy opisie stanów urządzenia. Mówimy też o auto — sprytnym narzędziu, które ułatwia życie, choć wymaga trochę ostrożności. Nie zabrakło również void i wskaźników bez typu, które często przewijają się w kodzie niskopoziomowym. Całość kończymy krótkim przeglądem konwersji typów, z naciskiem na bezpieczne rzutowanie przez static_cast<>. Jeśli bawisz się mikrokontrolerami i chcesz lepiej panować nad pamięcią i szybkością działania, jestem przekonany, że ten artykuł jest właśnie dla Ciebie.

Materiały dodatkowe

Dokumentacja biblioteki <cstdint>

Dodaj komentarz

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