Programowanie STM32 w C++ – dziedziczenie

Jeśli czytałeś poprzednie artykuły, wiesz już, jak korzystać z podstaw języka C++ w świecie embedded i jakie korzyści daje nam programowanie obiektowe. Dziś robimy krok dalej i przyglądamy się jednemu z kluczowych mechanizmów programowania zorientowanego obiektowo – dziedziczeniu.

Dziedziczenie pozwala tworzyć nowe klasy w oparciu o już istniejące, dzielić kod, dodawać nowe funkcjonalności i zachować porządek w większych projektach. To narzędzie, które może znacząco ułatwić pracę przy sterownikach, obsłudze peryferiów czy projektowaniu warstw abstrakcji. W tym artykule pokażemy, jak dziedziczenie wygląda w praktyce na mikrokontrolerach STM32, czym różnią się jego typy, oraz jak działa mechanizm metod wirtualnych i tablica vtable – a wszystko to w sposób przystępny dla początkujących, z przykładami i ciekawostkami.

Gotowi? Zapraszam do lektury!

Dziedziczenie — fundament programowania obiektowego

Dziedziczenie w C++ to jeden z fundamentów programowania obiektowego, który pozwala tworzyć nowe klasy na podstawie już istniejących, dzieląc się przy tym wspólnym kodem i zachowaniami. Wyobraź sobie, że masz klasę AnalogSensor, która obsługuje podstawowe funkcje wszystkich czujników podłączonych do wejścia ADC w STM32. Następnie tworzysz klasy pochodne, takie jak LightAnalogSensor czy DistanceAnalogSensor, które dziedziczą jej właściwości, ale dodają też własne funkcje specyficzne dla danego czujnika. Dzięki temu możesz wielokrotnie używać kodu bez konieczności jego kopiowania, co ułatwia utrzymanie i rozwój projektu.

W C++ mamy kilka rodzajów dziedziczenia: public, protected i private. Każdy z nich kontroluje, które elementy klasy bazowej są dostępne w klasach pochodnych. Dziedziczenie public zachowuje interfejs klasy bazowej w klasie pochodnej, dzięki czemu metody publiczne pozostają publiczne i mogą być wywoływane z zewnątrz. Dziedziczenie protected ukrywa publiczne elementy klasy bazowej przed kodem zewnętrznym, ale nadal pozwala korzystać z nich wewnątrz klas pochodnych. Natomiast dziedziczenie prywatne sprawia, że wszystkie elementy klasy bazowej stają się prywatne w klasie pochodnej i nie są dostępne z zewnątrz.

Warto pamiętać, że jeśli przy dziedziczeniu nie określimy typu (public, protected czy private), C++ stosuje domyślną regułę. Dla klas (class) domyślnie jest to private, co oznacza, że publiczne metody klasy bazowej stają się prywatne w klasie pochodnej. Dla struktur (struct) domyślnie jest to publiczne, więc publiczne metody pozostają dostępne na zewnątrz klasy pochodnej. Ta subtelna różnica jest ważna, zwłaszcza gdy szybko tworzymy proste struktury danych lub wrappery dla peryferiów STM32. Dzięki temu możemy świadomie kontrolować, które funkcje są częścią interfejsu, a które pozostają ukryte dla bezpieczeństwa i porządku kodu.

Mechanizm metod wirtualnych

W C++ mechanizm metod wirtualnych pozwala klasom pochodnym nadpisywać funkcje klasy bazowej, umożliwiając polimorfizm. Gdy w klasie bazowej oznaczymy metodę jako virtual, kompilator tworzy dla niej specjalną tablicę vtable, w której przechowywane są wskaźniki do właściwych implementacji funkcji. Każdy obiekt klasy z metodami wirtualnymi posiada wskaźnik do swojej vtable, dzięki czemu wywołanie funkcji jest dynamicznie rozstrzygane w czasie wykonania.

Koszt wywołania funkcji wirtualnej jest wyższy niż zwykłej funkcji, ponieważ wymaga odczytania wskaźnika z vtable i wykonania pośredniego wywołania funkcji. Na mikrokontrolerach STM32 zwykle jest to kilka dodatkowych instrukcji assemblera i jeden wskaźnik w strukturze obiektu. Pomimo tego narzutu, zaletą jest elastyczność i możliwość jednolitego traktowania różnych klas pochodnych. Polimorfizm pozwala np. tworzyć listę wskaźników do różnych peryferiów i wywoływać na nich te same metody bez wiedzy o konkretnym typie obiektu.

Dla programistów embedded oznacza to łatwiejsze utrzymanie kodu i większą modularność. Warto jednak świadomie stosować funkcje wirtualne w krytycznych fragmentach czasu rzeczywistego, gdzie minimalizacja opóźnień jest kluczowa. Dzięki zrozumieniu vtable możemy efektywnie korzystać z obiektowego podejścia na mikrokontrolerach, nie tracąc przy tym kontroli nad pamięcią i wydajnością.

Dziedziczenie na mikrokontrolerze

W praktyce na mikrokontrolerach STM32 dziedziczenie w C++ jest realizowane przez kompilator poprzez odpowiednie rozmieszczenie składowych obiektu w pamięci Flash i RAM. Każdy obiekt klasy pochodnej zawiera w sobie dane klasy bazowej, co oznacza, że pamięć jest ciągła i przewidywalna. Metody nie-wirtualne są zwykłymi funkcjami, które kompilator może inline’ować, co minimalizuje narzut pamięciowy. Natomiast metody wirtualne wymagają dodatkowego wskaźnika do tablicy vtable, co zwiększa rozmiar obiektu o kilka bajtów. Aby sprawdzić faktyczny rozmiar obiektów w pamięci, możemy użyć operatora sizeof lub przeanalizować plik map wygenerowany przez linker, który dokładnie pokazuje, ile pamięci zajmuje każda sekcja i obiekt.

Warto też porównać podejście obiektowe z klasycznym stylem strukturalnym w C, gdzie używa się struktur i wskaźników do funkcji. W C musimy ręcznie przypisywać wskaźniki do odpowiednich funkcji, co bywa bardziej podatne na błędy i mniej czytelne w dużych projektach. Dziedziczenie w C++ pozwala automatycznie korzystać z kodu klasy bazowej, a przy tym zachować elastyczność przy rozszerzaniu funkcjonalności. Obiektowa hierarchia umożliwia też łatwe tworzenie list wskaźników do różnych peryferiów, które mogą być traktowane jednolicie dzięki polimorfizmowi. Pomaga to zachować czytelność i modularność projektu, co jest szczególnie istotne w kodzie embedded.

Co więcej, w przypadku STM32 kompilatory ARM GCC potrafią optymalizować wywołania metod wirtualnych, a czasami nawet je inline’ować, jeśli typ obiektu jest znany w czasie kompilacji. Dzięki temu narzut w RAM i Flash jest często minimalny, a kod pozostaje przejrzysty i elastyczny. W praktyce oznacza to, że możemy korzystać z dziedziczenia i polimorfizmu, nie martwiąc się nadmiernym zwiększeniem rozmiaru firmware’u. Dla początkujących jest to ogromna zaleta, ponieważ pozwala pisać bezpieczny i łatwy w utrzymaniu kod nawet na mikrokontrolerach o ograniczonej pamięci.

Przykład – czujniki z wyjściem analogowym

Załóżmy, że mamy kilka czujników, które działają podobnie — każdy ma metodę init() i readRaw(), ale bez potrzeby wirtualnych metod. W takim wypadku klasa bazowa AnalogSensor może wyglądać tak:

class AnalogSensor {
protected:
    uint32_t pin;
public:
    AnalogSensor (uint32_t p) : pin(p) {}

    void init() {
        // inicjalizacja kanału ADC, jeśli potrzebne
    }

    uint32_t readRaw() const {
        // metoda do odczytu surowej wartości ADC
        return getAnalogValue(pin);
    }
};

W funkcji init możemy umieścić inicjalizację konkretnego kanału ADC, jeśli nie robimy tego w jakiś bardziej ogólny sposób w funkcji main przez główną pętlą. Jeżeli korzystamy z inicjalizacji wygenerowanej w CudeMX – implementację init() można pominąć. Funkcja readRaw ma za zadanie zwrócić „surową” wartość odczytaną bezpośrednio z konwertera ADC – np. umieszczoną w tablicy po doczytaniu danych przez DMA.

Teraz przejdźmy do przykładowej klasy czujnika analogowego np. analogowego czujnika temperatury.

class TempSensor : public AnalogSensor {
public:
    static constexpr uint32_t CONVERT_FACTOR= 500;
    static constexpr uint32_t ADC_MAX    = 1023;

    TempSensor(int p) : AnalogSensor(p) {}

    uint32_t readTemperatureC() const {
        uint32_t raw = readRaw();
        return (raw * CONVERT_FACTOR) / ADC_MAX;
    }
};

Klasa TempSensor reprezentuje czujnik temperatury oparty na odczycie analogowym, dziedziczący funkcjonalność z klasy AnalogSensor. Udostępnia metodę readTemperatureC(), która konwertuje surową wartość ADC na temperaturę w stopniach Celsjusza przy użyciu stałych konwersji zdefiniowanych jako constexpr. Współczynnik CONVERT_FACTOR określa przelicznik pomiędzy wartością ADC a temperaturą, natomiast ADC_MAX definiuje maksymalną rozdzielczość przetwornika. Klasa zapewnia prosty i wydajny sposób uzyskania temperatury z sygnału analogowego.

W podobny sposób możemy zdefiniować klasę do obsługi analogowego czujnika odległości.

class DistanceSensor : public AnalogSensor {
public:
    // Stałe zamiast magic number
    static constexpr uint32_t NUMERATOR = 37376UL;
    static constexpr int32_t  OFFSET     = 4;

    DistanceSensor(int pin)
        : AnalogSensor(pin) {}

    uint32_t readDistanceCm() const {
        uint32_t raw = readRaw();
        if (raw == 0) { 
            return UINT32_MAX; // albo inna obsługa błędu
        }

        return convertAdcToDistance(raw);
    }

private:
    static uint32_t convertAdcToDistance(uint32_t adcVal) {
        return (NUMERATOR / adcVal) - OFFSET;
    }
};

Klasa DistanceSensor reprezentuje czujnik odległości oparty na odczycie analogowym i rozszerza funkcjonalność klasy AnalogSensor. Udostępnia metodę readDistanceCm(), która przelicza surową wartość ADC na odległość w centymetrach przy użyciu stałych NUMERATOR i OFFSET, eliminując tzw. magic numbers. Konwersja wykonywana jest w dedykowanej metodzie convertAdcToDistance(), co poprawia czytelność i ułatwia utrzymanie kodu. Klasa dodatkowo obsługuje przypadek błędnego odczytu, zwracając wartość graniczną w sytuacji, gdy pomiar ADC wynosi zero.

Co daje zastosowanie klasy bazowej?

Zastosowanie klasy bazowej AnalogSensor pozwala uprościć architekturę kodu poprzez skupienie wspólnej logiki odczytu danych analogowych w jednym miejscu. Dzięki temu klasy pochodne, takie jak TempSensor i DistanceSensor, mogą koncentrować się wyłącznie na implementacji specyficznych dla siebie przeliczeń, co zwiększa czytelność i przejrzystość kodu. Dziedziczenie eliminuje powielanie kodu odpowiedzialnego za konfigurację i obsługę wejść analogowych, co znacząco ułatwia późniejsze utrzymanie oraz zmniejsza ryzyko błędów. Wprowadzenie zmian w sposobie odczytu ADC wymaga modyfikacji tylko w klasie bazowej, a wszystkie czujniki automatycznie z tych usprawnień korzystają. Taka architektura ułatwia również rozbudowę systemu o kolejne typy czujników analogowych, które mogą wdrażać własną logikę konwersji przy zachowaniu jednolitego interfejsu. Dzięki temu projekt staje się bardziej skalowalny i modularny.

Jak dziedziczenie wpływa na wydajność programu?

Dziedziczenie samo w sobie zazwyczaj nie spowalnia programu w zauważalny sposób, zwłaszcza gdy używa się zwykłych metod (nie wirtualnych). Kod jest wtedy optymalizowany przez kompilator tak samo jak w przypadku funkcji niezależnych. Niewielkie spowolnienie może pojawić się dopiero przy intensywnym użyciu funkcji wirtualnych, które wymagają jednego pośredniego wywołania przez tablicę wirtualną, jednak koszt ten jest zwykle bardzo mały.

Dziedziczenie ma sens tam, gdzie różne obiekty współdzielą wspólną funkcjonalność – np. wiele rodzajów czujników, urządzeń, driverów lub modułów logiki, które różnią się szczegółami implementacji, a korzystają z tego samego interfejsu. Sprawdza się również tam, gdzie ważna jest czytelna hierarchia, możliwość rozszerzania systemu i eliminacja duplikacji kodu.

Nie powinno się go stosować natomiast w miejscach bardzo krytycznych czasowo, takich jak ISR-y (przerwania) czy krótkie pętle wykonujące się w twardym czasie rzeczywistym, gdzie nawet minimalne opóźnienie może wpłynąć na stabilność systemu. W takich przypadkach lepiej używać prostych funkcji, kodu „płaskiego” i unikać dynamicznego polimorfizmu na rzecz bezpośrednich wywołania lub statycznych konstrukcji (np. constexpr). Dzięki temu unikamy zarówno dodatkowej warstwy abstrakcji, jak i potencjalnych, choć niewielkich, narzutów czasowych.

Podsumowanie

Dzisiejszy artykuł pokazuje, jak dziedziczenie w C++ może ułatwić życie programiście embedded pracującemu z mikrokontrolerami STM32. Wyjaśniamy, dlaczego ten mechanizm pomaga utrzymać porządek w projekcie oraz jak pozwala ponownie wykorzystywać kod, co jest zbawieniem przy rozbudowanych aplikacjach. Omawiamy różne typy dziedziczenia — public, protected i private — oraz to, jak wpływają na dostępność elementów klasy bazowej.

W artykule przyglądamy się również mechanizmowi metod wirtualnych i tablicy vtable, pokazując, kiedy ich użycie ma sens, a kiedy lepiej ich unikać (np. w ISR-ach – tam nie ma miejsca na niespodzianki!). Pokazujemy praktyczne przykłady na bazie klas czujników analogowych, gdzie dziedziczenie pozwala oddzielić wspólną logikę odczytu od specyficznych przeliczeń, co znacząco poprawia czytelność projektu. Porównujemy także podejście obiektowe z klasycznym stylem w C, podkreślając, że C++ może być równie przewidywalny, jak i wydajny, jeśli dobrze rozumie się jego mechanizmy. Co więcej, kompilatory ARM GCC często optymalizują wywołania wirtualne, dzięki czemu nie musisz bać się, że projekty „urosną” bardziej niż zakładałeś.

Całość tworzy przystępne, a jednocześnie merytoryczne wprowadzenie do tego, jak bezpiecznie i efektywnie stosować dziedziczenie w embedded, nie tracąc kontroli nad pamięcią ani czasem wykonania — a zyskując dużo elastyczności i czytelniejszy kod.

Dodaj komentarz

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