Optymalizacja kodu dla mikrokontrolerów 8-bitowych
| TechnikaWielu konstruktorów podziela zapewne opinię, że mikrokontrolery 8-bitowe są narzędziem mało atrakcyjnym, nadającym się jedynie do bardzo prostych aplikacji. Kiedy zaś złożoność wykonywanych przez urządzenie zadań wzrasta, zbliżając się do granicy możliwości mikrokontrolera 8-bitowego, należy jak najszybciej zastąpić go 32-bitowym odpowiednikiem. Okazuje się jednak, że z pomocą kilku dość prostych zabiegów optymalizujących kod programu można znacznie poprawić zarówno szybkość jego działania, jak i zmniejszyć rozmiar. W artykule przedstawiono kilka praktycznych porad, które pozwalają lepiej wykorzystać możliwości procesora 8-bitowego.
Mogłoby się wydawać, że dokonywanie dodatkowych zabiegów optymalizacyjnych dla procesora 8-bitowego pozbawione jest większego sensu i wiąże się jedynie ze stratą czasu, gdyż dużo łatwiejszym i szybszym rozwiązaniem jest wykorzystanie któregoś z wielu popularnych i szeroko dostępnych mikroprocesorów 32-bitowych, charakteryzujących się znacznie większą mocą obliczeniową. Zamiana procesora 8-bitowego na jego 32-bitowy odpowiednik związana jest jednak z pewnymi kosztami, przede wszystkim pod względem finansowym oraz energetycznym. Procesory 8-bitowe charakteryzują się zazwyczaj znacznie lepszą energooszczędnością, wciąż są też tańsze.
Pełne wykorzystanie możliwości kompilatora
Najprostszym i najmniej czasochłonnym sposobem poprawy wydajności programu jest skorzystanie z możliwości, które w tym zakresie oferuje kompilator. Nowoczesne narzędzia tego typu są bardzo złożonymi programami, które potrafią dokonywać wielu różnych czynności optymalizacyjnych, dawniej osiągalnych jedynie poprzez odpowiednie zabiegi programisty podczas pisania kodu programu. Współczesny kompilator potrafi w znacznej mierze poprawić (pod względem wydajności) napisany przez programistę kod, który dzięki temu może poświęcić więcej uwagi innym jego aspektom, takim jak czytelność i przejrzystość.
Przykładowe zabiegi optymalizacyjne, które każdy współczesny kompilator powinien być w stanie wykonać, to m.in.: wstawienie treści funkcji w miejscu wywołania (inlining), rozwijanie pętli, eliminacja wspólnych podwyrażeń oraz jednokrotne obliczenie wartości niezależnych od pętli (hoisting). W zależności od dodatkowych ustawień, kompilator może również przeprowadzać bardziej agresywną optymalizację pozwalającą zmniejszyć rozmiar kodu kosztem szybkości działania i odwrotnie - zwiększać szybkość działania kosztem wzrostu objętości kodu wynikowego.
Odpowiednia struktura i organizacja kodu programu
Istnieją jednak zabiegi optymalizacyjne, których nie jest w stanie wykonać żaden kompilator, nawet najbardziej zaawansowany. Bardzo ważna jest zatem rola piszącego kod programisty, gdyż to od jego wiedzy i umiejętności zależy końcowa jakość i efektywność opracowanego oprogramowania, a co za tym idzie, poziom wykorzystania możliwości mikroprocesora.
Jednym z ważniejszych zagadnień jest sama organizacja kodu programu - właściwie przemyślana struktura kodu źródłowego pozwala przeprowadzać optymalizację różnych fragmentów programu w odmienny sposób.
By można było dokonać odpowiedniej strukturyzacji oraz związanego z tym zarządzania ustawieniami optymalizacji, konieczna jest znajomość całego programu, jak też sposobu, w jaki będzie on wykorzystywany. Przykładowo, często wykonywane fragmenty kodu, których czas realizacji ma duże znaczenie dla działania całej aplikacji (np. protokoły komunikacyjne), mogą zostać poddane agresywnej optymalizacji ze względu na szybkość działania. W przypadku protokołów komunikacyjnych takie działanie pozwoli uniknąć niepotrzebnych opóźnień i zmniejszyć zajętość kanału komunikacyjnego.
Tak jak pokazano na rysunku 1, kod źródłowy wymagający szczególnych ustawień optymalizacyjnych kompilatora powinien znajdować się w osobnych plikach źródłowych. Uporządkowanie struktury kodu programu niesie ze sobą również inne korzyści - zwiększa pielęgnowalność (maintainability) oraz przenośność (portability) programu.
Wielkość zmiennych
Architektura procesora zawsze promuje pewne typy danych, tzn. powoduje, że są one przetwarzane efektywniej niż inne. Przykładowo, operacje na zmiennych 32-bitowych będą dużo szybciej i wydajniej wykonywane w procesorze 32-bitowym niż w 8-bitowym. Z drugiej jednak strony, procesor 8-bitowy będzie sprawniejszy od 32-bitowego w wykonywaniu działań na zmiennych 8-bitowych.
Na rysunku 2 przedstawiono porównanie kosztów operacji arytmetycznych dla danych o różnych rozmiarach oraz dla odmiennych architektur. Proste dodawanie dwóch wartości 8-bitowych (typu char) wymaga jednego cyklu pracy procesora 8-bitowego, zaś w przypadku procesora 32-bitowego zajmuje aż dwa cykle.
Jeśli działanie to wykonywane jest na danych typu int, sytuacja jest dokładnie odwrotna - procesor 32-bitowy poradzi sobie z nią w jednym cyklu, lecz dla procesora 8-bitowego koszt obliczeniowy tej operacji będzie dwukrotnie większy.
Kontrola rozmiaru poszczególnych zmiennych jest bardzo ważna również w innych sytuacjach, np. w przypadku korzystania z metody MMIO (Memory-Mapped I/O) lub protokołów komunikacyjnych. Dobrą praktyką jest zatem definiowanie za pomocą słowa kluczowego typedef typów danych o określonej wielkości, tak jak zostało to pokazane na rysunku 3 (tego typu konstrukcje znaleźć można zazwyczaj również w pliku nagłówkowym stdint.h).
Zalecenia odnośnie do typów danych
W standardzie ANSI C preferowanym typem danych są dane ze znakiem (signed) - oznacza to, że zmienna dowolnego typu, której definicja nie zawiera słowa kluczowego signed/ unsigned, będzie zazwyczaj traktowana jako typ ze znakiem (czyli signed). Stosowanie zmiennych ze znakiem jest bardziej kosztowne niż korzystanie z typów bez znaku (unsigned), w szczególności dlatego, że uniemożliwiają one działania optymalizacyjne, podczas których operacje arytmetyczne mogą zostać zastąpione szybszymi operacjami bitowymi. Warto więc, jeśli jest to tylko możliwe, wykorzystywać typy danych bez znaku.
Jeszcze bardziej kosztowne jest korzystanie z typów zmiennoprzecinkowych. W przybliżeniu można przyjąć, że obliczenia na liczbach zmiennoprzecinkowych pojedynczej precyzji (typu float) generują trzykrotnie większy i wolniejszy kod wynikowy niż w przypadku liczb całkowitych. Bardziej dokładne liczby zmiennoprzecinkowe podwójnej precyzji (typu double) powodują kolejne trzykrotne zwiększenie rozmiaru i czasu wykonywania kodu programu w porównaniu do liczb pojedynczej precyzji.
Istnieją sytuacje, w których stosowanie liczb zmiennoprzecinkowych jest rzeczywiście niezbędne, ponieważ wymagana jest duża dokładność obliczeń. W wielu innych przypadkach okazuje się jednak, że liczby zmiennoprzecinkowe mogą być z powodzeniem zastąpione typami całkowitymi. Przykładowo, załóżmy, że istnieje potrzeba odczytania i przetworzenia danych z czujnika temperatury.
Jeśli wykorzystamy do tego celu typ uint16_t, będziemy mogli, chcąc uzyskać dokładność 0,01 stopnia, prowadzić obliczenia w zakresie od 0 do 650 kelwinów. Jeśli taki zakres okaże się niewystarczający, można sięgnąć jeszcze po 32-bitowy typ uint32_t.
Indeks pętli
Korzystanie z pętli jest jednym z podstawowych elementów programowania. Bardzo często pętla ma za zadanie przeprowadzić określoną liczbę iteracji, zaś sterowanie tym procesem odbywa się za pomocą zmiennej pomocniczej nazywanej indeksem pętli. Rozmiar kodu wynikowego wygenerowanego w procesie kompilacji takiej pętli będzie różny w zależności od tego, czy w pętli dokonuje się inkrementacji, czy dekrementacji jej indeksu. Różnice te pokazane zostały w tabeli 1.
Korzystanie z dekrementacji indeksu pętli jest szybsze i wydajniejsze - dzieje się tak, ponieważ w przypadku inkrementacji indeksu konieczne jest stosowanie instrukcji porównania, która przy każdej iteracji sprawdza, czy indeks pętli nie przekroczył już przyjętej maksymalnej wartości.
Modyfikator static
Należy, jeśli nie ma takiej potrzeby, unikać korzystania z modyfikatora static w przypadku zmiennych lokalnych. Słowo kluczowe static oznacza, że wartość zmiennej lokalnej musi być przechowywana w pamięci także pomiędzy kolejnymi wywołaniami danej funkcji.
W zasadzie wymusza więc przechowywanie wartości tej zmiennej pod określonym adresem pamięci RAM, jak również powoduje generację dodatkowego kodu pozwalającego na dostęp do tego adresu. Z punktu widzenia kompilatora operowanie zmienną statyczną jest bardzo zbliżone do korzystania ze zmiennej globalnej, z jednym wyjątkiem - zasięg widoczności tej zmiennej ograniczony jest do ciała funkcji, w której została zdefiniowana.
Stosowanie modyfikatora static może być za to korzystne w przypadku funkcji. Zakres widoczności funkcji statycznej ograniczony jest do pliku źródłowego, w którym została ona zdefiniowana - nie można zatem wywołać tej funkcji z żadnego innego pliku źródłowego. Dzięki temu kompilator ma większe możliwości optymalizacji tego typu funkcji. Jeśli jest ona wywoływana tylko jeden raz w całym obszarze pliku, zostanie potraktowana jako funkcja inline, co pozwoli uniknąć generowania dodatkowego kodu związanego z wejściem i opuszczeniem funkcji. Przykład wykorzystania modyfikatora static podczas deklaracji funkcji przedstawiono w tabeli 2.
Post- i predekrementacja w wyrażeniach warunkowych
Zwykle nie ma znaczenia, czy zmienna poddana zostanie post- lub predekrementacji (lub inkrementacji) - wyrażenia "i--;" oraz "--i;" wygenerują taki sam kod wynikowy. Inaczej wygląda to jednak w przypadku wyrażeń warunkowych (np. w konstrukcji if ... else lub jako warunek pętli). W takich sytuacjach stosowanie predekrementacji skutkuje wygenerowaniem krótszego kodu źródłowego, który będzie w dodatku szybciej wykonywany. Odpowiedni przykład przedstawiono w tabeli 3.
Podsumowanie
Wszystkie powyższe przykłady pokazują, że poprawa efektywności programu, zarówno pod względem rozmiaru kodu, jak i czasu jego wykonania, może zostać osiągnięta za pomocą dość prostych środków. Może okazać się, że umiejętne zastosowanie tego typu zabiegów pozwoli uchronić się przed wymianą 8-bitowego mikroprocesora na jego 32-bitowy odpowiednik, zaś dzięki temu uniknie się m.in. wzrostu kosztów całego urządzenia.
Damian Tomaszewski