Modyfikator const w C

Po omówieniu modyfikatora volatile w poprzednim wpisie, dzisiaj zajmę się drugim podobnym modyfikatorem – const. Modyfikator const jest często przedstawiany jako sposób deklarowania stałych liczbowych. W artykule wytłumaczę, dlaczego do definiowania pojedynczych stałych liczbowych lepiej nadają się inne mechanizmy oraz jak używać const w deklaracjach funkcji, aby uzyskać lepszą kontrolę typów.

Definicja

Modyfikator const jest informacją dla kompilatora, że dana zmienna jest tylko do odczytu. Jej wartość nadana zostaje podczas inicjalizacji i potem nie może zostać zmieniona. Próba przypisania innej wartości do zmiennej const spowoduje błąd kompilacji (w gcc i większości innych kompilatorów, standard ANSI C mówi jedynie, że działanie jest nieokreślone).

Podstawowy sposób użycia

W przeciwieństwie do volatile, o istnieniu modyfikatora const zwykle dowiadujemy się dosyć szybko podczas nauki języka C. Jednak równie szybko dowiadujemy się o innym sposobie deklarowania stałych – za pomocą dyrektywy preprocesora define. Jaka jest różnica? Stała zadeklarowana przy pomocy słowa kluczowego const jest realizowana jako fizyczna komórka w pamięci o pewnym adresie, która jest tylko do odczytu i do której można się odwołać używając wskaźnika. W przypadku stałych zadeklarowanych jako define, przy każdym użyciu preprocesor podstawia tam liczbę, która nie znajduje się w żadnej komórce pamięci tylko jest wykorzystywana bezpośrednio w instrukcjach asemblerowych np. load immediate. Nowoczesne kompilatory potrafią (nawet przy fladze -o0) zoptymalizować prosty kod wykorzystujący const i nie przechowywać żadnej stałej w pamięci. Stałych const, w przeciwieństwie do define, nie można używać jako rozmiarów tablic albo wartości case w switchach. Istnieje jeszcze jeden sposób deklarowania stałych – za pomocą enumów. W ten sposób otrzymujemy stałą, którą możemy używać tak samo jak define, ale będącą dodatkowo symbolem rozumianym przez  debugger.

Zmienne z modyfikatorem const są często wykorzystywane do przechowywania większych danych, przy których nie da się skorzystać z define. Mogą to być na przykład stringi albo lookup table. Na mikrokontrolerach takie tablice kompilator zwykle umieszcza w pamięci FLASH. Czasem, żeby wymusić takie zachowanie niezbędne jest dodanie specjalnego atrybutu np. PROGMEM na procesorach AVR. Za pomocą odpowiednich atrybutów możemy również wymusić, aby zmienna typu const znajdowała się pod konkretnym adresem.

Składnia

Składnia zmiennych z modyfikatorem const jest identyczna, jak w przypadku modyfikatora volatile. Zmienną const możemy deklarować na dwa sposoby:

const uint32_t var1;
uint32_t const var2;

Możemy również deklarować wskaźniki na wartości const:

const uint32_t *ptr1;
uint32_t * const ptr2;
const uint32_t * const ptr3;

Wskaźnik ptr1 zawiera adres do danych read-only. Wskaźnik ptr2 jest tylko do odczytu, ale zawiera adres do danych, które można normalnie nadpisywać. Wskaźnik ptr3 jest tylko do odczytu i zawiera adres do danych, które również są tylko do odczytu.

W typach złożonych możemy deklarować całe struktury jako const, albo pojedyncze pola, możemy też używać wskaźników na const:

struct s_1
{
    uint32_t field1;
    const uint32_t field2;
    const uint8_t *field3;
};

const struct s_1 var1= {5, 10, NULL};

Jednak należy pamiętać, że zarówno pojedynczym polom, jak i całym strukturom typu const można przypisać wartość jedynie podczas inicjalizacji struktury. Struktura z pojedynczym polem typu const raczej nie jest zbyt przydatna, a poza tym rodzi różne dziwne problemy np. jeżeli chcemy ją inicjalizować za pomocą malloca.

W deklaracjach funkcji wskaźniki na const mogą być wykorzystywane zarówno jako argumenty wejściowe, jak i zwracane wartości:

const uint8_t * fun1(const uint8_t *data_in);

Jeżeli do funkcji oczekującej wskaźnika const przekażemy zwykły wskaźnik, nic złego się nie stanie. Natomiast jeśli do funkcji oczekującej zwykłego wskaźnika przekażemy wskaźnik const, otrzymamy warning. Zignorowanie warninga może spowodować runtime error np. jeżeli funkcja będzie próbowała zapisać wartość do pamięci FLASH tak jak do RAM. Argumenty i zwracane wartości const nie będące wskaźnikami nie mają za bardzo sensu, ponieważ są przekazywane jako kopia wartości przez rejestry lub stos.

Wskaźniki const jako argumenty funkcji

Jeżeli argument funkcji jest wskaźnikiem typu const, oznacza to, że funkcja nie modyfikuje wartości znajdujących się pod tym adresem. Nie musi to oznaczać od razu, że spodziewa się tylko argumentów zadeklarowanych jako read-only. Jak wspomniałem wcześniej, do funkcji spodziewającej się wskaźnika const można bez problemu przekazać wskaźnik na zwykłą zmienną.

Dzięki użyciu const w deklaracji wskaźnika będącego argumentem funkcji, uzyskujemy dodatkowe sprawdzenie poprawności działania funkcji w czasie kompilacji – błąd kompilacji w przypadku zapisu, warning w przypadku przekazania do funkcji nie spodziewającej się wskaźnika na const. Taka deklaracja funkcji niesie dla programisty dodatkowe informacje dotyczące sposobu jej użycia – argument jest używany tylko jako dane wejściowe i zawartość wskaźnika się nie zmieni.

Jednak takie podejście wymusza konsekwencję. Wszystkie funkcje powinny używać const dla wskaźników do danych wejściowych. Jeżeli użyjemy tej konwencji tylko w niektórych funkcjach i wywołamy z nich inne funkcje bez const do których przekażemy te wskaźniki jako argumenty, otrzymamy warningi.

Funkcje pośrednio modyfikujące bufor

Ostatnio w pracy mieliśmy dyskusję na temat przypadku, gdy funkcja bezpośrednio nie modyfikuje danych pod wskaźnikiem, ale przekazuje go do peryferiów, które docelowo nadpiszą dane np. do interfejsów komunikacyjnych, DMA. Jeżeli użyjemy const nie będzie żadnego errora ani warninga. W końcu jedynie przypisujemy do rejestru hardware’owego wartość liczbową adresu wskaźnika. Jednak w deklaracji takiej funkcji nie powinniśmy dodawać const z kilku powodów:

  • Zlecenie zapisu pośrednio przez HW oznacza, że logicznie funkcja zmienia zawartość wskaźnika. Dlatego użycie modyfikatora const wprowadza użytkownika w błąd.
  • Jeżeli programista korzysta z headera i nie ma wglądu w implementację (np. biblioteka statyczna) może na podstawie consta w nagłówku przyjąć złe założenia dotyczące działania funkcji np. przekazać wskaźnik do pamięci FLASH.
  • W docelowym produkcie nadpisywanie danych wykonują peryferia, ale na przykład w buildach testowych ich działanie może być mockowane przez zwykłe kopiowanie. Jeżeli w deklaracji funkcji będzie const – możemy mieć problem z kompilacją.

Podsumowanie

Modyfikator const służy do deklarowania zmiennych typu read-only. Do deklarowania pojedynczych stałych liczbowych nie nadaje się aż tak dobrze jak define, czy enum przez swoje ograniczenia – nie może być używana jako rozmiar tablicy, czy case w switchu. Bardzo dobrze natomiast nadaje się do deklarowania jako stałe większych bloków danych np. stringów czy lookup table. Poza tym zastosowaniem jest również bardzo przydatny przy deklaracjach funkcji, gdzie może służyć do określenia, czy wskaźnik jest wykorzystany jedynie jako dane wejściowe.

 

 

 

 

1 Comment

  1. Fajny post, ale rozszerzyłby opis różnicy między stałym widokiem na zmienną (przez wskaźnik)/stałą o dynamicznie ustalonej wartości, od stałej, której wartość jest znana w czasie kompilacji – bo generalnie o to się wszystko tutaj rozbija.

    Druga sprawa, nie wiem czy to materiał na osobnego posta, ale raczej nie, napisałbym o różnicy między wskaźnikiem na const, const wskaźnikiem i const wskaźnikiem na const.

Dodaj komentarz

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