Języki programowania dla systemów wielordzeniowych
| TechnikaPrzy wyborze języka programowania mającego posłużyć do stworzenia aplikacji dla mikrokontrolera jednoukładowego trzeba mieć na względzie nie tylko to, że gotowy program w ogóle zadziała poprawnie, lecz także, że zadziała poprawnie w warunkach mniej lub bardziej ograniczonych zasobów, przy niewielkiej ilości pamięci, zasilaniu z baterii, małej przepustowości kanałów transmisji danych, określonych metodach komunikacji z użytkownikiem (klawiatura, ekran). W grę mogą także wchodzić zagadnienia bezpieczeństwa, przenośności itp.
Mikrokontroler jednoukładowy może być wyposażony przez producenta w system operacyjny dorównujący złożonością systemom dla komputerów osobistych - albo może nie mieć systemu operacyjnego wcale. W tym ostatnim wypadku niskopoziomowe procedury obsługi urządzenia (np. portów wejścia/ wyjścia, przerwań) trzeba napisać samodzielnie, na ogół w asemblerze.
Większość dzisiejszych mikrokomputerów jednoukładowych ma do zaoferowania gotowe systemy operacyjne, przeważnie jednak ich możliwości i funkcje są mocno ograniczone w porównaniu z systemami dla komputerów osobistych. Jeśli istnieje i system operacyjny i środowisko programowania, można użyć jakiegoś języka średniego lub wysokiego poziomu, jak C lub C++.
Aplikacje dla WWW to na ogół Java lub Python. Do zastosowań czasu rzeczywistego z wysokimi wymogami bezpieczeństwa dobrym wyborem będzie Ada. Można też użyć hybrydy dwóch lub większej liczby języków: języka wysokiego poziomu, w którym napiszemy większość kodu rozwiązującego najbardziej złożone zadania, i asemblera do partii krytycznych czasowo lub takich, których po prostu nie da się napisać w języku wysokiego poziomu bądź jest to trudne z powodu jego ograniczeń. Poniżej zamieszczamy krótkie charakterystyki języków używanych do programowania wielordzeniowych mikrokomputerów jednoukładowych, w kolejności od najbardziej popularnego. Niektóre ich właściwości zilustrowano przykładami.
Język C
Językami najczęściej używanymi do pisania programów dla mikrokontrolerów jednoukładowych jest język C i asembler. Asembler pozwala programiście wycisnąć z maszyny maksimum mocy przy minimalnym zapotrzebowaniu na pamięć. Ale postęp w dziedzinie kompilatorów języków programowania pozwala im generować kod wynikowy o jakości porównywalnej z tym napisanym przez człowieka w asemblerze.
A zastosowanie języka nawet średniego poziomu (takiego jak C) daje niemałe korzyści w postaci łatwości stworzenia i dalszego rozwijania aplikacji, krótszych cykli testowania i usuwania błędów oraz, co nie jest bez znaczenia, łatwości przeniesienia programu na inne platformy sprzętowe.
Język C zyskał popularność wśród programistów systemów jednoukładowych głównie ze względu na łatwy w tym języku dostęp do sprzętu, a zwłaszcza możliwość odczytu, zapisu i modyfikacji rejestrów sprzętowych przy użyciu wskaźników oraz logicznych operacji bitowych.
Modyfikacja wartości zmiennej przy użyciu wskaźnika:
unsigned char *wsk; /* wskaźnik ?wsk’ będzie zawierał adres zmiennej typu char */ unsigned char znak; /* zmienna typu char */ wsk = &znak; /* przypisanie wskaźnikowi adresu zmiennej */ *wsk = 1; /* przypisanie wartości zmiennej przez wskaźnik */
Tu drobna uwaga: ten i dalsze przykłady będą świetnie działały dla zwykłych zmiennych, natomiast w przypadku rejestrów sprzętowych (lub innych podobnego rodzaju zasobów współdzielonych) mogą pojawić się kłopoty. Kwestia ta zostanie zilustrowana przykładem w dalszej części tekstu.
Przykłady operacji bitowych:
unsigned char znak = 0; /* początkowa deklaracja zmiennej */ znak ^= 0x01; /* różnica symetryczna (XOR): odwrócenie stanu bitu 0 */ znak |= 0x01; /* ustawienie bitu 0 (OR 1) */ znak &= ~0x01; /* skasowanie bitu 0 (AND NOT 1, tj. AND 0xFE) */ znak >> 2; /* przesunięcie logiczne w prawo o 2 bity: dzielenie przez 4 */
Przesunięcie arytmetyczne uzyskujemy analogicznie do ostatniego przykładu deklarując początkowo zmienną jako signed (lub wymuszając taki typ chwilowo przez użycie rzutowania).
Programista może czasem potrzebować dokonać operacji, dla której język C nie przewiduje operatora ani słowa kluczowego. Przykładem mogą być obroty bitowe: jest to rodzaj przesunięcia logicznego, z tym że bity "wychodzące" z jednej strony przesuwanego słowa (lub bajtu) "wchodzą" do niego "z drugiej strony". Na przykład obrót bajtu o jedną pozycję (bit) w lewo to przesunięcie w lewo, po którym bit 0 uzyskuje poprzedni stan bitu 7.
Prawie każdy procesor dysponuje specjalnymi rozkazami do wykonywania różnego rodzaju obrotów, ale język C faktycznie nie daje programiście możliwości zrobienia czegoś takiego wprost. Z takiej sytuacji istnieją dwa wyjścia: po pierwsze, można spróbować "złożyć" daną operację z dostępnych operatorów, licząc na to, że kompilator "zrozumie", o co programiście chodziło, i wygeneruje odpowiedni kod, zawierający w kluczowym punkcie pożądany rozkaz lub sekwencję takowych. Na przykład wspomniany obrót bitowy o jedną pozycję w lewo można zrealizować następująco:
z = ((z << 1)|(z >> ((sizeof(z)*8)-1))); /* zakładamy tu, że zmienna typu char ma 8 bitów */
Większość kompilatorów języka C powinna bez większego trudu "pojąć", co w tym miejscu programista miał na myśli i, jeśli nie zachodzą jakieś przeciwwskazania, przetłumaczyć to (na oko dość skomplikowane) wyrażenie na pojedynczy rozkaz obrotu.
Drugie wyjście to użycie wstawki w asemblerze: zostanie to omówione w dalszej części tekstu.
Język C daje możliwość deklarowania elementów struktur jako grup bitów (są to tzw. pola bitowe, bit fields):
struct struktura { znacznik _ a : 1; /* 1 bit */ znacznik _ b : 1; /* 1 bit */ char zmienna _ a; /* 8 bitów */ long zmienna _ b; /* (na ogół) 32 bity */ }; struct struktura kontrolki; kontrolki.znacznik _ a = 0; kontrolki.znacznik _ b = 1; kontrolki.zmienna _ a = 45; kontrolki.zmienna _ b = -1000;
Elementów struktur zadeklarowanych w ten sposób można używać dokładnie tak samo, jak używa się wszystkich innych elementów struktur: widać to w powyższym przykładzie. Pozwala to uniknąć jawnego stosowania operatorów bitowych w celu zmiany stanu poszczególnych bitów lub grup bitów w obrębie większych zmiennych, a to wpływa dodatnio na przejrzystość programu. Co w rzeczywistości zrobi z tym kompilator, to już inna sprawa.
Optymalizacje programu dokonywane przez kompilator mogą spowodować kłopoty w przypadku odwołań do rejestrów sprzętowych (ta kwestia już była mimochodem poruszana powyżej). Rozważmy taki przykład:
int *control; void sterowanie() { *control = 1; ... *control = 0; }
Kompilator łatwo "zauważy", że wartości przypisywane do zmiennej wskazywanej wskaźnikiem control nie są używane nigdzie w obrębie programu, usunie zatem te przypisania, a może i sam wskaźnik, z kodu wynikowego jako elementy w programie zbędne. A nawet jeśli akurat tego z jakiegoś powodu nie zrobi, i tak zawsze może zmienić kolejność poszczególnych odwołań, przegrupować zmienne w pamięci, dowolnie rozmieścić pola bitowe itd. według tego, jak mu to będzie pasowało do "koncepcji" optymalizacji danej funkcji. Tego typu zabiegi w stosunku do zmiennych wewnętrznych programu, zwłaszcza lokalnych, są we współczesnych kompilatorach normalną praktyką. Tyle że jeśli te tak zwane zmienne są w istocie rejestrami sprzętowymi, tego rodzaju posunięcia kompilatora będą dla działania programu katastrofalne w skutkach. By tej katastrofy uniknąć, należy przy deklaracji użyć modyfikatora volatile, jak w przykładzie:
volatile int *control;
Sygnalizuje to kompilatorowi, że zmienna jest "współdzielona" przez program z jakimś innym zasobem (sprzętem, procedurą przerwania, innym wątkiem, systemem operacyjnym itp.), a w związku z tym, że ma zachować odwołania do niego w tej kolejności i liczbie, w jakiej występuje w kodzie źródłowym. Z drugiej strony jasno wynika z powyższego, że zmienne deklarowane jako volatile utrudniają kompilatorowi optymalizację programu, nie należy więc takich deklaracji nadużywać.
Tak język C jak i wiele innych języków programowania oferuje funkcje dynamicznej alokacji pamięci, dzięki którym program może pozyskiwać od systemu potrzebne mu w danej chwili porcje pamięci RAM. Robi się to, wywołując funkcję biblioteczną malloc(). Przydzielony blok pamięci znajdzie się w obszarze pamięci danych programu (tzw. heap).
Ale w wielu mikrokontrolerach ten obszar albo jest bardzo mały, albo nie ma go wcale, w związku z czym lepiej jest w ogóle zapomnieć o funkcji malloc() i, na ile to możliwe, przydzielić programowi niezbędny obszar pamięci w inny sposób, dajmy na to korzystając ze stosu albo alokując jakiś bufor statycznie w obszarze samego programu. W danym przykładzie program buduje listę liniową i wypisuje na ekranie kolejne wartości zapisane w jej węzłach (czyli tutaj "0 1 2"):
/* Sposób 1: dynamicznie */ struct node { int value; struct node *next; }; int main() { struct node *lista = (struct node *)malloc(sizeof(struct node)*3); lista[0].value = 0; lista[0].next = &lista[1]; lista[1].value = 1; lista[1].next = &lista[2]; lista[2].value = 2; lista[2].next = NULL; struct node *temp = lista; while (temp != NULL) { printf(" %d", temp->value); temp = temp->next; } mfree((void *)lista) return 0; } /* Sposób 2: na stosie */ struct node { int value; struct node *next; }; int main() { struct node lista[3]; lista[0].value = 0; lista[0].next = &lista[1]; lista[1].value = 1; lista[1].next = &lista[2]; lista[2].value = 2; lista[2].next = NULL; struct node *temp = lista; while (temp != NULL) { printf(" %d", temp->value); temp = temp->next; } return 0; } /* Sposób 3: statycznie */ struct node { int value; struct node *next; }; int main() { static struct node lista[3]; lista[0].value = 0; lista[0].next = &lista[1]; lista[1].value = 1; lista[1].next = &lista[2]; lista[2].value = 2; lista[2].next = NULL; struct node *temp = lista; while (temp != NULL) { printf(" %d", temp->value); temp = temp->next; } return 0; }
Różnica pomiędzy przykładem 2 a 3 polega tylko na jednym szczególe: w tym ostatnim strukturę lokalną lista zadeklarowano przy użyciu słowa kluczowego static, co w standardowym C powinno spowodować umieszczenie jej w "zwykłej" pamięci programu, a nie na stosie. Ale przy tego rodzaju rozważaniach trzeba oczywiście pamiętać, że ostateczną decyzję, gdzie umieścić konkretne zmienne, podejmuje kompilator: użyte przez programistę słowa kluczowe typu static albo register przy deklaracji zmiennej lokalnej to tylko różnej wagi sugestie, które kompilator może wziąć pod uwagę przy alokacji zasobów na zmienne, ale w ostatecznym rozrachunku wcale nie musi - zależy to od jego implementacji.
Inną praktyką, często zalecaną przy programowaniu mikrokomputerów jednoukładowych, jest unikanie rekurencji, jako że jest niezbyt efektywna (na rekurencyjne wywołania funkcji procesor zużywa stosunkowo sporo czasu), a na dodatek pamięciochłonna. W niektórych przypadkach można ją zastąpić iteracją:
/* Sposób 1: rekurencja */ int silnia(int n) { if (n <= 1) return 1; return n * silnia(n - 1); } /* Sposób 2: iteracja */ int silnia(int n) { int s = 1; if (n <= 1) return s; while (n > 1) { s *= n; n--; } return s; }
Następną dobrą praktyką jest deklarowanie wszystkich danych, które nie będą podlegały modyfikacji, jako stałych. Uzyskujemy to przez użycie w deklaracji modyfikatora const:
const char * const tydzien[] = { "Pon", "Wto", "Sro", "Czw", "Pia", "Sob", "Nie" };
Czyni się tak po to, żeby dane, co do których zachodzi pewność, że są stałymi, można było umieścić w pamięci ROM; a ta jest tańsza od pamięci RAM, w związku z czym mikrokomputery jednoukładowe na ogół zawierają jej dużo, w przeciwieństwie do RAM-u.
Wielowątkowość w języku C
W programowaniu procesorów wielordzeniowych istotne korzyści może przynieść podział programu na wykonujące się równolegle wątki. C11 to nowy standard tego języka opublikowany w roku 2011. Definiuje on standard biblioteki funkcji obsługującej wielowątkowość. Odpowiednie makrodefinicje, funkcje, typy danych itp. wspierające wielowątkowe wykonywanie programu deklaruje plik nagłówkowy <threads.h>. W starszych edycjach języka C można zamiast tego skorzystać z bibliotek POSIX Th reads, Win32 Th reads lub Boost Th reads.
Asembler
Programowanie w asemblerze pozwala wycisnąć z urządzenia maksimum jego mocy przy zużyciu minimum zasobów, zwłaszcza pamięci. Dla programisty mikrokomputerów jednoukładowych jest to bardzo pożądana cecha. Asembler był zresztą w tej dziedzinie tradycyjnie i powszechnie używanym narzędziem i, jakkolwiek zasięg jego stosowania się zmniejszył, pozostaje w użytku do dnia dzisiejszego, już to jako samodzielny język, już to w połączeniu z językiem C.
Mimo że asembler jest nieprzenośny (przeciwnie, jest bardzo silnie związany z konkretną architekturą) i jest językiem trudnym do perfekcyjnego opanowania, a programy w nim napisane trudno jest poprawiać i rozwijać, istnieje jednak garść przypadków, w których znajomość asemblera się przydaje, a jego użycie jest uzasadnione:
- niektóre tanie mikrokontrolery nie mają przeznaczonych dla siebie kompilatorów języków wysokiego poziomu ani nawet C, nie dają zatem programiście wyboru: pozostaje mu tylko asembler;
- są sytuacje, kiedy język wysokiego poziomu albo nie pozwala skorzystać z pewnych możliwości sprzętu, albo, jak w podanym w poprzedniej sekcji przykładzie obrotów bitowych w języku C, nawet jeśli wykonanie danej operacji jest możliwe, może być kłopotliwe, a na dodatek istnieje margines niepewności, czy wynik pracy kompilatora na pewno będzie optymalny i zgodny z naszymi oczekiwaniami; w takim układzie stosujemy wstawkę w asemblerze:
asm("mov %[result],%[value],rol#1": [result] "=r" (y) : [value] "r" (x));
- czasami zachodzi potrzeba uzyskania jak najszybszej reakcji urządzenia w czasie rzeczywistym, na przykład podczas pisania aplikacji dla procesora sygnałowego (DSP);
- przy pisaniu sterowników urządzeń zewnętrznych często zachodzi potrzeba precyzyjnego kontrolowania czasu;
- czasami programiści dokonują ręcznej optymalizacji kodu wynikowego kompilatora, osiągając przy tym efekty, których kompilator osiągnąć nie jest w stanie; a nawet jeśli programista nie zamierza tego czynić, i tak czasami warto przeanalizować niektóre porcje kodu asemblerowego wyprodukowanego przez kompilator, bo pozwala to na ocenę, jak kod wygląda i zachowa się już na poziomie maszyny.
Gdy żaden z powyższych przypadków nie zachodzi, lepiej jest użyć np. języka C, gdyż płynące z tego korzyści znacznie przeważają nad potencjalnymi stratami np. wydajności, jakie mogą wyniknąć z tego tytułu. Zresztą, dzisiejsze kompilatory są tak zaawansowane, że w praktyce straty te mogą się okazać dużo mniejsze od oczekiwanych.
Asembler i C można łączyć na dwa sposoby:
- załączając wstawki w asemblerze bezpośrednio do modułów źródłowych napisanych w języku C przy użyciu słowa kluczowego asm(), jak to pokazano na powyższym przykładzie. Wstawki te zostaną skompilowane razem z otaczającym je tekstem w języku C;
- przez dołączanie do programów osobnych modułów asemblerowych, oddzielnie kompilowanych i łączonych z głównym programem w procesie konsolidacji.
Dzięki temu najbardziej złożone i największe części programu można napisać w języku C, pozostawiając asemblerowi części najbardziej krytyczne np. czasowo. Taki program będzie stosunkowo wydajny, a jednocześnie stosunkowo łatwy w utrzymaniu i zachowa niezłą przenośność (bo przy przenoszeniu na inną maszynę przepisaniu ulegną jedynie niewielkie części asemblerowe).
Wielowątkowość w asemblerze
W idealnym wypadku obsługa wielu wątków polega na wywoływaniu z poziomu modułów asemblerowych odpowiednich funkcji bibliotecznych języka C, jeśli takowe zaimplementowano do systemu docelowego. W przeciwnym razie pozostaje użycie odpowiednich funkcji systemu operacyjnego. Z punktu widzenia programu w asemblerze nie ma większej różnicy pomiędzy jednym wątkiem a wieloma, gdyż każdy z wątków ma swój zestaw (czy też raczej "kontekst") rejestrów CPU, a przełączaniem wątków zajmie się system operacyjny (o ile istnieje).
C++
C++ to obiektowy język wysokiego poziomu, który oferuje wiele możliwości niedostępnych w języku C. Należy jednak z nich korzystać z pewną rozwagą, gdyż ich użycie obarczone jest czasami pewnymi kosztami. Programy napisane w C++ działają wolniej, a kod wynikowy kompilatora ma większą objętość w porównaniu do analogicznych aplikacji napisanych w zwykłym C. Z tego powodu programiści mikrokomputerów jednoukładowych używają pewnych podzbiorów C++, gdyż język ten zawiera poza tym funkcje i konstrukcje, których użycie w środowiskach dysponujących ograniczonymi zasobami prowadzi niekiedy do tak wielkich strat efektywności, że po prostu mija się z celem.
Definicja klasy
Definicja klasy obejmuje listę publicznych (wewnętrznych), prywatnych i chronionych pól obiektu oraz funkcji. Kompilator C++ używa słów kluczowych public i private do ustalenia już podczas procesu kompilacji, jakie metody dostępu są dozwolone, a jakie nie; co za tym idzie, kontrola dostępu do obiektów nie pociąga już za sobą strat wydajności podczas wykonywania programu. Prawidłowe i przemyślane stosowanie klas prowadzi za to do modularności struktury programu, a co za tym idzie - do poprawy jego przejrzystości.
Konstruktory i destruktory
Konstruktor wywoływany jest za każdym razem, gdy dochodzi do utworzenia jakiegoś obiektu zdefiniowanego wewnątrz klasy. Destruktor natomiast zostaje wywołany, kiedy dany obiekt ma "zniknąć z pola widzenia" aplikacji. Jedno i drugie powoduje niewielkie straty wydajności podczas wykonywania programu, ale za to eliminuje wiele rodzajów błędów znanych z programowania w języku C, jak niezainicjowane zmienne bądź wycieki pamięci. Pozwala to także ukryć dziwaczne niekiedy sekwencje inicjujące dany obiekt, a to czytelność kodu źródłowego tylko polepsza. W poniższym przykładzie String to deklaracja klasy z dwoma konstruktorami i jednym destruktorem:
class String { public: String() // konstruktor bezargumentowy { str = NULL; size = 0; } String(int len) // konstruktor jednoargumentowy { str = new _ char[len]; size = size; } ~String() // destruktor { delete [] str; } private: char str*; int size; };
Przeciążanie operatorów i funkcji
Przeciążanie funkcji polega na tym, że funkcje o tych samych nazwach, ale różnych parametrach, dostają podczas procesu kompilacji różne nazwy: kompilator za każdym wystąpieniem danej funkcji dobiera jej nową etykietę, a wyszukanie odpowiednich odwołań i sprawdzenie ich wzajemnej zgodności jest zadaniem linkera. Nie ma to wpływu na wydajność programu.
#include using namespace std; void print(int i) { cout << i << " jest liczbą całkowitą." << endl; } void print(double f) { cout << f << "jest liczbą zmiennoprzecinkową." << endl; } int main() { print(8); print(3.14); }
Wynik: 8 jest liczbą całkowitą, 3.14 jest liczbą zmiennoprzecinkową. Podobnie dzieje się przy przeciążaniu operatorów: gdy kompilator napotka deklarację operatora (jak dla operatora + w przykładzie poniżej), jego wystąpienie zastępuje wywołaniem odpowiedniej funkcji:
#include class CVector { public: CVector() {}; CVector(int a, int b) : x(a), y(b) {}; CVector operator+(CVector vec); private: int x,y; }; CVector CVector::operator+(CVector vec) { CVector temp; temp.x = x + vec.x; temp.y = y + vec.y; return (temp); } int main() { CVector a (3,1); CVector b (1,2); CVector c = a + b; cout << c.x << "," << c.y; return 0; }
Wynik: 4,3
Trzeba tu zauważyć, że z przeciążaniem operatorów nie należy przesadzać, gdyż można doprowadzić do sytuacji, kiedy w danym programie znaczenie (i działanie) operatora zaczyna całkowicie zależeć od operandów - a to znacznie utrudnia późniejszą analizę kodu źródłowego.
Metody wirtualne (polimorfizm)
C++ nie byłby prawdziwym językiem zorientowanym obiektowo, gdyby nie metody wirtualne, czyli polimorfizm. Polega on na zdolności do przypisania czemuś różnego znaczenia w zależności od kontekstu - a dokładniej na tym, że zmienna, funkcja albo obiekt może zostać użyta na wiele różnych sposobów. Omawianie tego zagadnienia - nawet powierzchowne - przekracza ramy tego artykułu, zostanie więc ono tylko zasygnalizowane.
Czego należy unikać w C++
Jak już zaznaczono powyżej, niektórych konstrukcji C++ po prostu nie opłaca się używać podczas pisania aplikacji dla mikrokomputerów jednoukładowych. Na ogół jest to wszystko to, czego użycie powoduje dramatyczne spowolnienie programu oraz równie dramatyczny wzrost zapotrzebowania na zasoby lub powiększenie objętości kodu wynikowego.
Szablony
Szablon (template) pozwala danej klasie obsługiwać wiele różnych typów danych. Na przykład, można użyć szablonu do tego, żeby dane o różnych typach przetwarzać za pomocą tego samego algorytmu zdefiniowanego w szablonie. Inne zastosowanie to tak zwane kontenery, czyli zbiory wektorów, listy, kolejki itp. Podczas procesu tłumaczenia programu kompilator typowo po prostu powiela szablony, tworząc oddzielną kopię kodu zawartego w szablonie dla każdego zestawu typów danych, na których operować ma zdefiniowany w szablonie algorytm. Łatwo przewidzieć, że może to prowadzić do nagłego wzrostu wielkości programu, który w efekcie nie zmieści się w pamięci mikrokontrolera.
Rzecz jasna, szablony mogą być skądinąd bardzo użyteczne. Ich rozsądne użycie może akurat pomóc w uniknięciu nadmiernego rozrostu programu, oferując jednocześnie prostsze i bardziej przejrzyste struktury kodu źródłowego. Rzecz w tym, żeby z tych możliwości korzystać z głową. Nie od rzeczy jest też w ogóle najpierw sprawdzić, czy kompilator potrafi je sprawnie i efektywnie wdrożyć w gotowym programie.
Wyjątki
Mechanizm wyjątków (exceptions) ma na celu oddzielenie obsługi błędów aplikacji od kodu wykonującego konkretne zadania. Wpływa to oczywiście bardzo korzystnie na przejrzystość tekstu programu. Działa to następująco: stan wyjątkowy występuje w miejscu, w którym wykryto jakieś nieprawidłowości.
W efekcie program zostaje przerwany, a sterowanie przekazane do zdefiniowanego w tym celu bloku kodu (catch block). Obsługa wyjątków w C++ jest nieefektywna, zajmuje sporo czasu i miejsca w programie: kiedy następuje stan wyjątkowy, kod jego obsługi wygenerowany przez kompilator wywołuje destruktory dla wszystkich obiektów, które zostały automatycznie utworzone od chwili, gdy wykonano odnośny blok try.
Rozmiary i czas wykonania tego ciągu destruktorów, w przypadku bardzo skomplikowanych aplikacji, mogą być trudne do oszacowania. Co więcej, kompilator tworzy kod obsługi wyjątku, żeby przywrócić kontekst stosu, jaki obowiązywał w chwili wywołania bloku try. Zapotrzebowanie tego wszystkiego na zasoby może być niełatwe do przewidzenia. Najlepiej jest więc tego wszystkiego unikać za wszelką cenę.
RTTI
Mechanizm RTTI (od Run-Time Type Information, informacja o typie w trakcie wykonywania programu) pozwala na odnalezienie dokładnej informacji o typie danego obiektu, gdy do dyspozycji jest tylko wskaźnik lub odwołanie do typu bazowego. Części tego mechanizmu w C++ stanowią operatory dynamic_ cast i typeid. Ten pierwszy przekształca wskaźnik danej klasy na wskaźnik jej klasy pochodnej i pozwala na odwołania do obiektów tej ostatniej. Natomiast operator typeid zwraca nazwę klasy, do której należy podany obiekt.
Użycie tego mechanizmu - jakkolwiek skądinąd bardzo użytecznego - ma swoją cenę w postaci zmniejszenia efektywności wykonywania programu oraz zwiększenia jego objętości. Z tego względu przy programowaniu dla mikrokontrolerów jednoukładowych mechanizm RTTI zaleca się po prostu wyłączać.
Trzeba mieć także na uwadze, że C++, inaczej niż "zwykłe" C, nie pozwala definiować pól bitowych wewnątrz struktur. Skutkiem tego może być zwiększone zapotrzebowanie takich struktur na pamięć, czyli, oczywiście, zwiększenie objętości i pamięciożerności całego programu w stosunku do analogicznej aplikacji napisanej w języku C. Czy faktycznie to nastąpi - to jest, czy pola bitowe programu w C faktycznie zajmą w pamięci mniej miejsca niż analogiczne deklaracje zmiennych w C++ - to już zależy od implementacji danego kompilatora języka C.
Wielowątkowość w C++
C++11, podobnie jak omawiane wyżej C11, zapewnia wsparcie dla wielowątkowości w postaci biblioteki thread. W przypadku użycia starszego standardu C++ rozwiązanie jest takie samo jak dla języka C.
Java
Java to język wysokiego poziomu umożliwiający pisanie programów mogących (w postaci wynikowej) działać na wielu platformach sprzętowych. Został on też specjalnie zaprojektowany tak, żeby kod był łatwiejszy do napisania i utrzymania w porównaniu z kodem analogicznych aplikacji w C i C++. Java jest w dzisiejszych czasach jednym z najpopularniejszych języków programowania, a rozpowszechniła się głównie w takich systemach jak smartfony, palmtopy i konsole do gier. Dobrze nadaje się do tworzenia aplikacji sieciowych.
Aplikacje napisane w Javie kompilowane są do postaci kodu pośredniego (bytecode), który może działać na każdym sprzęcie. Konieczne jest oczywiście użycie interpretera Javy oraz specjalnego środowiska zwanego wirtualną maszyną Javy (Java Virtual Machine, JVM). Kod pośredni może też zostać przetłumaczony "w locie" na natywny język danej maszyny przez kompilator JIT (Just-In-Time).
Java jest językiem obiektowym z wieloma cechami bardzo podobnymi do C++, takimi jak klasy, polimorfizm czy dziedziczenie. Są też różnice, przeważnie na korzyść:
- w Javie nie występują wskaźniki tego rodzaju jak w C lub C++, zamiast tego używa się tzw. referencji. Zapobiega to błędom powstałym w wyniku nadużycia wskaźnika celem zmuszenia programu do zapisania danych dowolnego typu pod dowolny adres;
- Java oferuje tak zwane metody natywne, dzięki którym z poziomu programu napisanego w Javie można wywołać kod napisany w asemblerze lub w C/C++, na przykład w celu dokonania operacji na rejestrach sprzętowych lub na pamięci przy użyciu wskaźników. W programowaniu mikrokomputerów jednoukładowych może się to bardzo przydać;
- odśmiecanie pamięci jest automatyczne, ułatwia to dynamiczne zarządzanie pamięcią i eliminuje jej wycieki;
- wielodziedziczenie klas znane z C++ w języku Java zastąpiono tzw. interfejsami;
- kontrola dostępu do danych jest automatyczna, dzięki czemu unika się np. omyłkowych zapisów lub odczytów poza końcem tablicy;
- podstawowe typy danych mają stałe rozmiary, np. typ int ma zawsze 32 bity, inaczej niż w C/C++ (gdzie, w zależności od maszyny, kompilatora itp., int może mieć np. 16, 32 lub 64 bity);
- wszystkie wyrażenia warunkowe muszą dawać w wyniku prawdę lub fałsz. Na przykład błędna konstrukcja typu if (x = 3), gdzie w rzeczywistości programista miał zamiar napisać if (x =3), zostanie wykryta i zgłoszona jako błąd już w trakcie kompilacji; tymczasem kompilator C i C++ zaakceptowałby ją jako może nietypową, ale do przyjęcia;
- operacje na tekstach są wbudowane w język, możliwe są np. wyrażenia typu "Hello" + "world";
- wsparcie wielowątkowości jest również wbudowane w język, aplikacje wielowątkowe napisane w Javie są przenośne i działają tak samo na różnych systemach operacyjnych;
- dostępnych jest wiele standardowych bibliotek graficznych, sieciowych, matematycznych itp.
Wielowątkowość w Javie
Istnieją dwa sposoby na utworzenie wątku: jednym jest utworzenie tzw. obiektu runnable:
public class IamRunnable Implements Runnable { public void run() { System.out.println("Jestem wątkiem"); } public static void main(String args[]) { (new Thread(new IamRunnable())).start(); } };
drugim - rozszerzenie klasy Th read o podklasę:
public class IamAThread extends Thread { public void run() { System.out.println("Jestem wątkiem"); } public static void main(String args[]) { (new IamAThread()).start(); } };
Synchronizacja procesów i komunikacja między nimi realizowana jest w języku Java za pomocą tzw. monitorów. Każdy obiekt ma swój monitor. Zadaniem monitora jest zapewnienie wyłącznego dostępu do krytycznych sekcji programu - natomiast krytyczne to te, w których następuje dostęp do tego samego obiektu z poziomu różnych wątków.
Są one oznaczane jako synchronized. Monitor blokuje zasób krytyczny w ten sposób, żeby dostęp zyskiwał tylko jeden wątek naraz: podczas gdy ten wątek realizuje dostęp, reszta "chętnych" musi czekać. Monitor jest więc czymś w rodzaju semafora, ale w przeciwieństwie do niego nie jest związany z konkretnym kodem czy konkretnymi danymi, ale, jak to już zaznaczono powyżej, z obiektem; w przykładzie poniżej dwa różne wątki mogą uzyskać jednoczesny dostęp do funkcji Increment() pod warunkiem, że wywołują ją na różnych obiektach:
class Counter { private int count = 0; public void synchronized Increment() { int n = count; count = n+1; } }
Pakiet java.util.concurrent zawiera też funkcje, których działanie nie opiera się na ogólnych dla Javy zasadach synchronizacji, ale nadal można ich bezpiecznie użyć w aplikacjach wielowątkowych, na przykład do komunikacji między wątkami:
- wait() - ta funkcja powoduje, że wywołujący ją wątek zwalnia blokadę nałożoną przez konkretny monitor i ulega uśpieniu do momentu, w którym inny wątek uzyska dostęp do tego samego obiektu i wywoła funkcję notify();
- notify() - ta funkcja "budzi" pierwszy wątek, który wywołał wait() dla tego samego obiektu;
- notifyAll() - jak się łatwo domyślić, ta funkcja budzi wszystkie wątki, które wywołały wait() dla konkretnego obiektu. Jako pierwszy wystartuje wątek o najwyższym priorytecie.
W Javie można również używać zwykłych semaforów, są one częścią tego samego pakietu java.util.concurrent:
static Semaphore sVar = new Semaphore(2); // inicjowany na 2 ... sVar.acquire(); criticalCode(); // sekcja krytyczna sVar.release(); ...
Uruchamianie aplikacji napisanych w języku Java wymaga zastosowania maszyny wirtualnej, a to z kolei zajmuje sporo zasobów i spowalnia wykonywanie programu. Z tego względu panuje opinia, że Java, jak dość zasobożerny język programowania, nie nadaje się do zastosowania w większości urządzeń budowanych w oparciu o mikrokontrolery jednoukładowe z ograniczoną ilością pamięci i zasilanych z baterii.
Zupełnie nie sprawdza się też w aplikacjach krytycznych pod względem czasu wykonywania się lub bezpieczeństwa. Z tego powodu jej głównym zastosowaniem pozostają aplikacje na zaawansowane urządzenia przenośne w rodzaju telefonów komórkowych, w których konieczny jest np. dostęp do Internetu. Niemniej podjęto próby okrojenia Javy do takich potrzeb, a rezultat tych prób znany jest jako Embedded Java. Można tego języka używać na trzy sposoby:
- z użyciem maszyny wirtualnej, ale bez kompilatora JIT - kompilator taki zużywa zbyt dużo pamięci;
- z użyciem specjalnej maszyny wirtualnej i natywnych bibliotek - ta wersja z kolei jest mocno okrojona;
- uruchamiając programy skompilowane do kodu natywnego, a nie pod interpreterem - to daje najlepsze rezultaty, jeśli chodzi o wydajność, ale taki program jest z kolei nieprzenośny.
Python
Pyton to język programowania wysokiego poziomu i ogólnego przeznaczenia, który zyskał znaczną popularność dzięki łatwości użycia oraz dzięki temu, że programista jest w stanie napisać w nim aplikację stosunkowo szybko. Jest to też język bardzo elastyczny: można go użyć jako języka zorientowanego obiektowo albo w formie języka skryptowego dla aplikacji sieciowych.
Z drugiej strony programy napisane w języku Python działają o wiele wolniej niż te napisane w języku C, wobec tego nie powinny być używane do takich zastosowań, w których czas gra istotną rolę. Python wymaga też dużych ilości pamięci RAM i miejsca na dysku, nie będzie zatem dobrym wyborem dla małych mikrokontrolerów. Ale nieźle nadaje się do pisania aplikacji - zwłaszcza sieciowych - i pozwala na szybkie stworzenie niezbędnych rozwiązań, co jest jednym z mocnych punktów tego języka.
Inną zaletą jest to, że programiści, którym nieobce są języki programowania takie jak Java, C albo Perl, są w stanie nauczyć się Pythona bardzo szybko, a kod tworzony w tym języku jest przejrzysty i zwarty. Aplikacje napisane w Pythonie można testować przy użyciu PC lub na emulatorach.
Wielowątkowość w Pythonie
Biblioteka threading.py zawiera wysokopoziomowe interfejsy do obsługi wielowątkowości. Jej podstawą jest moduł niższego poziomu wzorowany na bibliotece POSIX Th reads. Moduł ten definiuje następujące funkcje i obiekty, wzorowane z kolei na klasie Th read i interfejsie Runnable znanych z języka Java:
- Thread - ta klasa reprezentuje aktywność poszczególnych wątków. Podobnie jak w Javie, są dwa sposoby na utworzenie wątku: przez użycie interfejsu Runnable albo przez rozszerzenie klasy Thread o podklasę;
- Lock - zapewnia najprostszą metodę synchronizacji ze wszystkich dostępnych w Pythonie. Z Lock związane są dwie funkcje: blokowania semafora acquire() i zwalniania go release();
- RLock - od reentrant lock - działa podobnie jak Lock, ale pojedynczy wątek może go zablokować wielokrotnie: wywołania odpowiednich funkcji acquire() i release() można zagnieżdżać;
- Condition - jest to zmienna warunkowa, skojarzone są z nią funkcje acquire() i release(), które wywołują odpowiednie funkcje związane z daną blokadą; dodatkowo dostępne są odpowiednie funkcje wait(), notify() i notify- All() działające podobnie jak odpowiedniki w języku Java: wait() zwalnia blokadę dostępu i usypia wątek do chwili, kiedy zostanie on obudzony przez inny wątek żądający dostępu do tego samego zasobu i wywołujący funkcję notify() lub notifyAll();
- Semaphore - zawiera wewnętrzny licznik zmniejszany przez każde wywołanie acquire(), a zwiększany przez każde wywołanie release(). Ten licznik nie może osiągnąć wartości mniejszej niż zero: jeśli acquire() stwierdza, że licznik jest wyzerowany, usypia wątek do czasu, kiedy jakiś inny wątek wywoła release();
- Event - jest to jeden z najprostszych mechanizmów komunikacji między procesami: jeden wątek sygnalizuje wystąpienie zdarzenia, a inny na to czeka.
Ada
Ada to zorientowany obiektowo język wysokiego poziomu z wbudowanym wsparciem dla przetwarzania współbieżnego. W późnych latach 80. ubiegłego wieku Departament Obrony USA nakazał użycie tego języka wszystkim zespołom tworzącym aplikacje dla wojska: od tamtego czasu ten nakaz znacznie złagodzono, ale mimo że stosuje się wiele innych języków programowania, takich jak Fortran, C, C++, armia USA nadal preferuje język Ada. Pozostaje on także w użyciu w wielu zastosowaniach komercyjnych, zwłaszcza związanych z operacjami krytycznymi czasowo lub obarczonymi znacznymi wymogami bezpieczeństwa. Oczywiste przykłady to kontrola ruchu powietrznego czy transportu kolejowego oraz systemy bankowe.
W języku Ada zaimplementowano obsługę wyjątków, przetwarzanie współbieżne, modularyzację, hierarchiczne przestrzenie nazw, obiektowość, szablony. Inaczej niż to jest w C++ czy Javie, dynamiczna alokacja obszarów pamięci przeznaczonych na tablice czy rekordy zachodzi tylko wtedy, kiedy programista wyraźnie sobie tego zażyczy - jest to cecha bardzo pożądana w mikrokomputerach jednoukładowych.
Ada cechuje się też rozbudowanymi metodami kontroli poprawności kodu, zarówno takimi, które przeprowadza podczas kompilacji, jak i aktywnymi w trakcie wykonywania programu, co znacznie poprawia stabilność tworzonego kodu. Na przykład programista może podać zakres wartości dopuszczalnych dla danej zmiennej, przez co próbę przypisania wartości spoza tego zakresu można wykryć już na etapie kompilacji.
Kompilator języka Ada jest też w stanie zasygnalizować potencjalne zakleszczenia (deadlocks), co jest skutkiem błędów w programowaniu charakterystycznym dla aplikacji wielowątkowych. Z drugiej strony wielość mechanizmów kontrolnych powoduje uszczerbki na wydajności aplikacji napisanych w języku Ada, przez co wybór tego języka może okazać się nie najszczęśliwszym pomysłem, jeśli zależy nam na bardzo wysokiej wydajności wykonywania kodu (w takim układzie lepszym wyborem zapewne będzie język C). Tę niedogodność Ady można do pewnego stopnia skompensować po prostu wyłączając niektóre mechanizmy kontrolne.
Wielowątkowość w języku Ada
Podstawowa obsługa wielowątkowości i przetwarzania współbieżnego w języku Ada opiera się na obiekcie zwanym "zadaniem" (task). Zadania komunikują się z innymi zadaniami za pośrednictwem obiektów chronionych (protected obiects) lub kanałami komunikacji bezpośredniej zapewnianymi przez mechanizm zwany rendezvous. Dodatkowo jest do dyspozycji działający deterministycznie (i przenośny) mechanizm przydziału czasu poszczególnym zadaniom, który sprawdza się dobrze w aplikacjach czasu rzeczywistego.
Task (zadanie) to podstawa współbieżności w języku Ada. Jest to obiekt funkcjonalnie odpowiadający obiektowi Thread w Javie lub Pythonie. Zadanie rusza do działania z chwilą utworzenia go, może komunikować się z innymi zadaniami przez transmisję komunikatów funkcjami entry() i accept(), a także współdzielić obszary pamięci.
Rendezvous to mechanizm synchronizacji zadań przez wymianę komunikatów: zarówno nadawca, jak i odbiorca muszą poczekać. Natomiast obiekty chronione zawierają automatycznie działające semafory umożliwiające blokadę dostępu, kiedy obiekt jest w użyciu.
Podsumowanie
Istnieje bardzo wiele języków programowania i przy wyborze jednego z nich dla konkretnego projektu należy wykazać się rozwagą. Trzeba wziąć pod uwagę ograniczenia zasobów, wymagania wydajnościowe i wymogi bezpieczeństwa, ale nie można zlekceważyć ogólnego stopnia trudności w opanowaniu tego języka przez nowicjuszy oraz stopnia obycia z nim przez bardziej doświadczonych członków zespołu.
W przypadku procesorów wielordzeniowych i aplikacji wielowątkowych trzeba wziąć pod uwagę dostępność narzędzi deweloperskich, zwłaszcza tych, które ułatwiają testowanie i wprowadzanie poprawek do takiego programu (a zlokalizowanie błędu w aplikacji wielowątkowej może się okazać zadaniem nietrywialnym). W zasadzie każdy język z tych pokrótce zaprezentowanych powyżej w ten czy inny sposób nadaje się do realizacji nakreślonego celu.
Konrad Kokoszkiewicz