Wskaźniki – pole minowe dla programistów

| Technika

W świecie mikrokontrolerów oraz systemów embedded język C wciąż pozostaje powszechnie wykorzystywanym narzędziem, stanowiąc często pierwszy wybór dla wielu początkujących konstruktorów oraz programistów. Jest również ulubionym narzędziem przez doświadczonych profesjonalistów, którzy cenią go ze względu na prostotę oraz oferowane możliwości. Jednym z najpotężniejszych, lecz także najbardziej niebezpiecznych elementów tego języka jest możliwość bezpośredniego operowania oraz odwoływania się do adresów pamięci, oferowana przez mechanizm wskaźników. Warto zapoznać się z podstawowymi pojęciami oraz zasadami związanymi z tą konstrukcją.

Wskaźniki – pole minowe dla programistów

Zdolność do bezpośredniej operacji na określonych adresach pamięci gwarantowana jest przez pojęcie wskaźników, czyli jedną z najbardziej interesujących oraz jednocześnie wielce kłopotliwych cech języka programowania C. Operacje na wskaźnikach oferują programiście wiele możliwości, szczególnie w przypadku systemów embedded. Pozwalają na bezpośrednie odwoływanie się do określonych obszarów pamięci, reprezentujących na przykład rejestry konfigurujące wybrane układy peryferyjne oraz sterujące pracą mikroprocesora. Niekiedy ułatwiają też operacje na tablicach lub innych zbiorach danych, upraszczają i skracają kod źródłowy oraz pozwalają w szybki sposób przekazywać parametry pomiędzy funkcjami. Z drugiej strony, ich nieumiejętne wykorzystanie doprowadzić może do katastrofalnych błędów – wycieków danych, skutkujących nieświadomym nadpisaniem wartości zmiennej, a często też resetem. W rezultacie wielu programistów, szczególnie tych pracujących z większymi systemami komputerowymi, preferuje języki programowania pozbawione możliwości operacji na wskaźnikach, takie jak np. Java. Często uważane są one po prostu za bezpieczniejsze, jednak w wielu przypadkach nie najlepiej nadają się do zastosowania w świecie embedded. Każdy programista i konstruktor chcący biegle poruszać się w obszarach systemów mikroprocesorowych powinien zatem zapoznać się z podstawową wiedzą na temat wskaźników i prawidłowych zasad ich wykorzystania.

Czym jest wskaźnik?

Dla programisty korzystającego z asemblera dostępna pamięć jest po prostu zbiorem następujących po sobie adresów, reprezentujących kolejne bajty lub słowa. Nie ma tu miejsca na wprowadzanie pojęcia zmiennej, zaś wszystkie operacje polegają na manipulacji zawartością poszczególnych komórek pamięci. Zadanie śledzenia oraz kontroli tego, jakie informacje zapisane zostały pod poszczególnymi adresami, należy bezpośrednio do obowiązków programisty, wymagając jego szczególnej uwagi. Dane te oznaczać mogą wartości liczbowe, znaki (reprezentowane poprzez odpowiedni rodzaj kodowania za pomocą liczb) lub adresy innych komórek pamięci, pod którymi, ponownie, zapisane mogą być dane lub adresy, tworząc potencjalny łańcuch odwołań do pamięci. Istnieją również języki wyższego poziomu, jak na przykład Forth lub BCPL, które zorganizowane są w bardzo podobny sposób, nie pozwalając na określanie zmiennych i typów danych.

Większość języków programowania umożliwia jednak korzystanie ze zmiennych oraz określanie typów przechowywanych w nich danych. Oznacza to, że programista nie musi już znać adresu pamięci, pod którym przechowywane są poszczególne wartości liczbowe. Zadanie to wykonuje za niego kompilator danego języka. Sposobem na poznanie adresu, pod którym zapisana jest wartość wybranej zmiennej, jest utworzenie wskaźnika. Wskaźnik to szczególny typ zmiennej, pozwalający przechowywać jednocześnie dwie informacje – adres pamięci oraz typ zmiennej umieszczonej pod tym adresem, czyli charakter zapisanych danych.

Obecność wskaźników jest cechą charakterystyczną dla języków programowania pozwalających na bezpośredni dostęp do pamięci, takich jak C oraz C++. W większości nowszych języków, jak Java czy C#, wskaźniki zastąpione zostały przez referencje, które nie mogą wskazywać na pusty ani przypadkowy adres pamięci, z drugiej jednak strony cały proces zarządzania nimi wykonywany jest przez kompilator, bez możliwości ingerencji programisty. Wyższy poziom bezpieczeństwa osiągany jest więc poprzez częściowe ograniczenie funkcjonalności.

Wskaźniki i adresacja pamięci

W większości nowoczesnych procesorów oraz mikroprocesorów adres pamięci składa się z takiej samej liczby bitów co słowo danych. Przykładowo, większość 32-bitowych procesorów ma również 32-bitową przestrzeń adresową oraz preferuje operacje na 32-bitowych wartościach. Z tego powodu większość typów procesorów pozwala na bezpośrednie przechowywanie adresów pamięci w rejestrach oraz komórkach pamięci i wykonywanie na nich dokładnie takich samych operacji jak na zwykłych danych.

ogólności możliwe jest zatem przechowywanie wartości wskaźnika z pomocą "zwyczajnej" zmiennej, na przykład typu całkowitego bez znaku (unsigned int). Tego typu konstrukcje spotkać można niekiedy w kodzie źródłowym systemów embedded, na przykład w sterownikach dla układów peryferyjnych. Może to wyglądać następująco:

unsigned int normal;
unsigned int *pointer;
pointer = &normal;
normal = (unsigned int) pointer;

Wykonanie powyższego kodu spowoduje, że zmienna normal przechowywać będzie swój własny adres. Program taki powinien bez przeszkód uruchomić się na większości typów procesorów, choć jego poprawność może być przedmiotem dyskusji.

W ogólności, każdy poprawnie napisany kod powinien spełniać trzy podstawowe wymagania:

  • realizować wymaganą funkcjonalność
  • cechować się czytelnością oraz łatwością serwisowania,
  • charakteryzować się łatwością przenoszenia pomiędzy różnymi typami oraz architekturami procesorów.

Ostatnie z wymagań może zostać uznane za mniej istotne od dwóch pierwszych, w szczególności w przypadku projektów embedded. O ile prezentowany powyżej kod wciąż może spełniać wymaganie 1, czyli poprawną realizację funkcji, o tyle przedstawiony w nim sposób obsługi wskaźników z całą pewnością narusza zasadę 2, czyli czytelność i łatwość edycji. Jeśli programista rzeczywiście potrzebuje samodzielnie dokonywać konwersji typów, w szczególności typów wskaźnikowych, powinno to zostać bardzo dokładnie opisane i wyjaśnione w dołączonym do kodu komentarzu.

Arytmetyka wskaźników

Język C dopuszcza wykonywanie operacji na wskaźnikach, w sposób podobny do operacji na zwykłych zmiennych. Operacje takie są możliwe, ponieważ wskaźniki odnoszą się do określonych typów danych, zaś informacja o tym pozwala kompilatorowi na odpowiednie ich przetwarzanie. Dla niedoświadczonych programistów zapis taki niejednokrotnie wydawać może się dość mocno mylący i niejasny, w rzeczywistości jest jednak całkowicie logiczny. Za przykład może posłużyć następujący kod:

int x;
int *ptr;
ptr = &x;
ptr++;

Jeśli wartość × zlokalizowana jest pod adresem 0×80000000, zaś kod wykonywany jest na 32-bitowym procesorze (korzystającym z 4-bajtowych liczb całkowitych), to końcowa wartość wskaźnika ptr będzie wynosić 0×80000004. Jest to sensowne, ponieważ operator inkrementacji ++ zwiększa wartość adresu o jedną jednostkę danych, co w przypadku zmiennych typu int oznacza 4 bajty. Inkrementacja oraz dekrementacja wskaźników są powszechnie wykorzystywane m.in. w celu przeglądania kolejnych elementów tablicy, pozwalając uzyskać adresy następujących po sobie wartości danych.

Dla wielu programistów nie jest zapewne do końca oczywiste, co stanie się po drobnej modyfikacji przedstawionego kodu:

int x;
int *ptr;
ptr = &x;
ptr+=1; // lub ptr=ptr+1

W rezultacie wykonania powyższego programu końcowa wartość ptr wyniesie ponownie 0×80000004, co może wydawać się niezbyt intuicyjne. Jest to jednak całkowicie logiczne, gdyż dalej realizowana jest taka sama funkcja, czyli zmiana wartości wskaźnika o jednostkę danych. Nasunąć się może pytanie, co należy zrobić w sytuacji, kiedy rzeczywiście istnieje potrzeba zwiększenia wartości wskaźnika o 1, czyli przesunięcia adresu o 1 bajt. Dwie możliwe realizacje tego celu to:

((unsigned int)ptr)++;
// lub
((char*)ptr)++;

Każdorazowe użycie tego rodzaju nietypowej konstrukcji powinno być jednak bardzo szczegółowo wyjaśnione oraz udokumentowane, aby ułatwić późniejszą analizę takiego kodu.

Należy pamiętać, że wszelkie operacje dodawania oraz odejmowania wskaźników wykonywane są zatem nie na liczbach bezwzględnych, tylko na jednostkach danych, równych rozmiarowi typu danych, na który wskazuje wskaźnik. Przykładowo, w większości implementacji dla procesorów 32-bitowych będą to 4 bajty dla typu int oraz 1 bajt dla typu char.

Wskaźniki i tablice

Tablice w języku C zaimplementowane są w dość prosty sposób – są to w zasadzie nazwane serie danych umieszczone w ciągłym obszarze pamięci. Przykładowo, zdefiniowanie pięcioelementowej tablicy typu unsigned int oraz inicjalizacja jej trzeciego elementu możliwa jest w następujący sposób:

unsigned int array[5];
array[2]=99;

Dla każdego programisty interesująca powinna być relacja pomiędzy wskaźnikami oraz tablicami. Jak już wspomniano, wskaźnik jest często wykorzystywany do uzyskiwania dostępu do określonego elementu tablicy. Można zatem zapisać:

unsigned int* pointer;
pointer = &array[0];
*(pointer+2) = 99;

W powyższym kodzie wskaźnik ustawiany jest na zerowy element tablicy, co w efekcie pozwala, z pomocą arytmetyki wskaźników, na uzyskanie dostępu do każdego z jej elementów. Bardziej schludnym sposobem napisania tego samego kodu jest wykorzystanie faktu, że nazwa każdej tablicy jest jednocześnie wskaźnikiem (typu constant) na jej zerowy element. Może to zostać sformułowane następująco:

pointer = array;

Operację pobrania danych za pomocą wskaźnika nazywa się wyłuskiwaniem i w języku C jest ona reprezentowana przez symbol *. Ten sam symbol pojawia się również przy deklaracji wskaźnika, pełni on jednak wtedy inną funkcję. W wielu językach programowania, w tym również i w języku C, te same symbole oznaczać mogą różne zjawiska, w zależności od zastosowanej składni.

Składnia związana z wykorzystaniem arytmetyki wskaźników wygląda dość zawile, z tego też powodu w języku C wprowadzono operator []. Nawiasy kwadratowe są po prostu bardziej schludnym sposobem na wykonywanie operacji na wskaźnikach. Możliwe jest zatem stworzenie następującej konstrukcji:

unsigned int* pointer;
pointer = &array[0];
pointer[2]=99;

Wyrażenie to jest jak najbardziej poprawne, zaleca się jednak unikać korzystania z nawiasów kwadratowych w odniesieniu do wskaźników, dzięki czemu łatwiej utrzymać czytelność kodu. Zgodnie z zasadami dobrych praktyk, operator [] powinien być stosowany jedynie w połączeniu z nazwami tablic. W języku C istnieją zatem dwa sposoby dynamicznej inicjalizacji tablicy. Pierwszy z nich opiera się na wykorzystanie operatora []:

unsigned int array[5];
for(int i=0;i<5;i++)
{array[i]=99;}

Drugi polega na zastosowaniu arytmetyki wskaźników:

unsigned int array[5];
unsigned int* pointer;
pointer = array
for(int i=0;i<5;i++)
{*(pointer++) = 99;}}

Korzystanie z nazwy tablicy oraz operatora [] wydaje się zapewne bardziej czytelne, w niektórych przypadkach wykorzystanie wskaźników może być jednak sensowne oraz uzasadnione.

Podsumowanie

Wskaźniki są niezwykle potężną, ale również bardzo niebezpieczną cechą języka C. Wielu programistów niezbyt pewnie czuje się w ich obsłudze, nie zawsze dobrze rozumiejąc zasady ich wykorzystania. Dobrą praktyką jest zatem ograniczenie korzystania ze wskaźników do miejsc i zastosowań, w których są one rzeczywiście potrzebne i niezbędne. Nadużywanie wskaźników oraz związanych z nimi operacji nie poprawi zapewne czytelności kodu. Z drugiej strony, ich zasadne wykorzystanie doprowadzi z pewnością do poprawy szybkości działania i efektywności programu. W przypadku systemów embedded wskaźniki stanowią ponadto świetne narzędzie do uzyskiwania bezpośredniego dostępu do zasobów sprzętowych, reprezentowanych przez określone adresy pamięci.

 

Damian Tomaszewski