Dzisiaj omówię element składni języka C, który jest rzadko tłumaczony większości kursów. W książce K&R został wspomniany zaledwie w trzech zdaniach, które zupełnie nie sugerowały, że może on być ważny. Chodzi o modyfikator volatile. Jego nieprawidłowe stosowanie może powodować:
- Błędne działanie programu po włączeniu optymalizacji.
- Błędne działanie programów wykorzystujących przerwania lub współbieżność.
- Problemy z obsługą sterowników sprzętu.
- Problemy z wydajnością programu.
Definicja
Modyfikator volatile dodany do zmiennej jest informacją dla kompilatora, że jej zawartość może się zmienić w nieznanych momentach nawet jeśli kod danej funkcji jej nie zmienia. Konsekwencją jest niestosowanie optymalizacji dla zmiennej volatile. Oznacza to, że kompilator przy każdym użyciu odczytuje jej wartość z pamięci zamiast przechowywać ją w rejestrze jeśli wykonuje na niej kilka operacji. Poza tym kompilator nie zmienia kolejności działań wykonywanych przy użyciu tej zmiennej. Skutek zastosowania modyfikatora volatile został fajnie przedstawiony na anglojęzycznej wikipedii, gdzie porównano wynikowy kod asemblerowy dla tego samego programu z wykorzystaniem volatile i bez – link.
Składnia modyfikatora volatile
Zmienną volatile można deklarować na dwa równorzędne sposoby:
volatile uint32_t var; uint32_t volatile var;
Moim zdaniem pierwszy sposób, kiedy modyfikatory są przed typem zmiennej, jest bardziej czytelny. Modyfikatora volatile możemy użyć również przy deklaracji wskaźników:
volatile uint8_t * ptr1; uint8_t * volatile ptr2; volatile uint8_t * volatile ptr3;
Wskaźnik ptr1 zawiera adres do danych volatile. Oznacza to, że sam wskaźnik jest traktowany przez kompilator normalnie, ale wartość, na którą wskazuje jest traktowana jako volatile. Przy wskaźniku ptr2 sytuacja jest odwrotna – dane spod wskaźnika są traktowane standardowo, natomiast sam wskaźnik może się zmienić w nieoczekiwanym momencie. Wskaźnik ptr3 to połączenie obu wcześniejszych przypadków. Deklaracje wskaźników takie jak ptr2 i ptr3 są w praktyce wykorzystywane bardzo rzadko.
Typy złożone
Jak volatile działa z typami złożonymi? Używając volatile ze strukturami możemy zarówno deklarować całe struktury jako volatile, jak i pojedyncze elementy:
struct s_1 { uint32_t field1; uint32_t field2; volatile 32_t field3; } struct s_1 var1; volatile struct s_1 var2;
W zmiennej var1 tylko field3 jest volatile, w var2 – wszystkie elementy. Jeżeli chodzi o unie, to zmienna o typie będącym unią również może być volatile, natomiast deklarowanie pojedynczych pól unii jako volatile nie ma sensu. Jeśli fizyczna pamięć może być nadpisana w nieoczekiwanym momencie, to taka właściwość będzie się tyczyć wszystkich typów z unii.
Funkcje
Jeżeli chodzi o użycie volatile w deklaracjach funkcji, to użycie modyfikatora volatile ma sens jedynie ze wskaźnikami. Przyczyną jest fakt, że przekazywanie argumentów oraz zwracanie wartości przez funkcje odbywa się przez kopię. Jeżeli funkcja oczekuje wskaźnika na daną volatile, możemy do niej przekazać również zwykły wskaźnik. Zostanie on wtedy potraktowany jako volatile i wykonają się dodatkowe instrukcje, które w tym konkretnym przypadku nie są konieczne – czyli nie stanie się nic złego. Jeśli natomiast podamy wskaźnik volatile do funkcji oczekującej zwykłego wskaźnika, funkcja ta nie będzie wiedziała, że musi potraktować ten argument w szczególny sposób, co może być przyczyną błędów. W przypadku próby przekazania takiego argumentu kompilator powinien zgłosić warning. Nie powinno się również rzutować danych volatile na zwykłe typy.
Łączenie z innymi modyfikatorami
Mimo, że na pierwszy rzut oka wydaje się to dziwne, poniższe deklaracje są poprawne i mają sens:
const volatile uint32_t *ptr1; const volatile uint32_t var1;
Modyfikator const mówi, że nie możemy zmieniać wartości zmiennej, natomiast volatile, że wartość może się zmieniać w nieoczekiwanych momentach. Te dwie rzeczy nie stoją ze sobą w sprzeczności. To, że my z poziomu danej funkcji nie możemy zmodyfikować wartości, nie oznacza, że nie może ona być zmieniona gdzie indziej. Wskaźnik ptr1 może być na przykład rejestrem sprzętowym tylko do odczytu. Natomiast zmienna var1 może znajdować się w pamięci współdzielonej przez dwa procesory, gdzie tylko jeden może zmieniać jej wartość.
Zastosowanie modyfikatora volatile
Modyfikator volatile najczęściej stosowany jest w trzech przypadkach:
- Do interakcji ze sprzętem.
- Do przekazywania informacji pomiędzy przerwaniami a pętlą główną programu.
- Do przekazywania informacji między kontekstami w środowisku wielowątkowym (takie zastosowanie nie jest zalecane).
Jeżeli przyjrzymy się bibliotekom procesorów dostarczanym przez producentów mikrokontrolerów, zobaczymy, że wszystkie rejestry procesora są zadeklarowane jako wskaźniki do zmiennych typu volatile. Ta technika nosi nazwę memory mapping i dzięki niej w kodzie programu jesteśmy w stanie zareagować np. na zmianę wartości pinu, czy ustawienie flagi.
Innym przykładem zastosowania volatile przy interakcji ze sprzętem jest wykorzystanie DMA. Jeśli przekażemy jakiś adres pamięci do DMA, jego zawartość może się zmienić i nie mamy wpływu na to, kiedy to nastąpi. Nie możemy nawet obronić się przed zapisem do tej pamięci za pomocą wyłączenia przerwań. Dlatego bufory przekazywane do DMA powinny być deklarowane jako volatile. Chyba, że zaimplementujemy mechanizm zapewniający, że pamięć nie jest jednocześnie używana przez DMA i program.
Prawie każdy podczas nauki programowania mikrokontrolerów napisał program wykorzystujący zmienną globalną w pętli głównej i przerwaniu, uruchomił go i okazało się, że nie działa. Dla większości jest to pierwszy moment, kiedy dowiadujemy się o istnieniu takiego tworu jak volatile i konsekwencjach jakie niesie jego pominięcie. Zmienna wykorzystywana przez przerwanie i pętlę główną musi być zadeklarowana jako volatile. W każdym momencie wykonywania pętli głównej może przyjść przerwanie, które zmodyfikuje wartość zmiennej.
Jeśli chodzi o trzecie zastosowanie modyfikatora volatile, czyli wielowątkowość, jest on używany do deklaracji zmiennych współdzielonych przez różne wątki. W każdym momencie wykonywania danego wątku może nastąpić zmiana kontekstu i wywołanie drugiego wątku korzystającego z tej zmiennej. Mechanizm działania jest tu podobny jak w przypadku przerwań. Warto w tym miejscu podkreślić, że użycie volatile do komunikacji między wątkami nie jest najlepszym rozwiązaniem. Jeśli to możliwe, lepiej używać mutexów, semaforów, kolejek i innych mechanizmów komunikacji i synchronizacji między wątkami, które zapewniają większe bezpieczeństwo i lepszą wydajność. Problemy z volatile przy współbierzności spowodowały falę krytyki w społeczności związanej z Linuxem, o czym piszę dalej.
Kopiowanie danych volatile
Jeżeli mamy bufor volatile i chcemy skopiować jego zawartość, nie możemy mieć pewności co do integralności danych. W końcu zmienna volatile może zmienić swoją wartość w losowym momencie, czyli także w środku kopiowania. Skutkiem tego może być skopiowanie części starych i części nowych danych. Aby się przed tym zabezpieczyć, musimy na czas kopiowania wyłączyć źródło niespodziewanych zmian, czyli np. wyłączyć przerwania. Nie zawsze coś takiego jest możliwe np. nie wyłączymy zmian na portach wejściowych procesora.
Volatile considered harmful
W internecie można znaleźć dużo głosów krytyki wobec używania modyfikatora volatile w kodzie. Najbardziej rozpoznawalnym przeciwnikiem jego używania jest Linus Torvalds, któremu nieraz zdarzało się ostro zjechać autorów patchy do Linuxa wykorzystujących volatile. Powstał nawet dokument „Volatile considered harmful”. Ktoś niedoświadczony po przeczytaniu takich opinii głoszonych przez osobę będącą autorytetem, może wziąć to do siebie i przestać w ogóle stosować volatile. Należy jednak mieć na uwadze, że argumenty przeciwko volatile odnoszą się do wykorzystania w Linuxie i ogólnie w nowoczesnych systemach wielowątkowych, gdzie nie ma takich interakcji z hardware jak w systemach embedded oraz istnieją lepsze mechanizmy radzenia sobie z problemami, które rozwiązuje volatile – głównie dotyczącymi współbieżności.
Podsumowanie
Modyfikator volatile jest ważnym elementem składni języka C szeroko wykorzystywanym w systemach embedded. Jednocześnie jest bardzo często pomijany podczas nauki programowania, co prowadzi do braku umiejętności jego poprawnego użycia i powstawania różnych mitów. Błędne użycie volatile często może pozostać niezauważone, ponieważ program jest kompilowany bez optymalizacji. Efektem jest zwalanie winy za błędne działanie programu na „zbugowany kompilator”, który „gubi się przy optymalizacji”. Ze względu na pogorszenie wydajności użycie volatile jest często krytykowane. Należy mieć na uwadze, że ta krytyka nie jest kierowana do programistów embedded.
12 czerwca 2017 at 02:13
> Do przekazywania informacji między kontekstami w środowisku wielowątkowym.
Nope. https://stackoverflow.com/questions/2484980/why-is-volatile-not-considered-useful-in-multithreaded-c-or-c-programming
Dodatkowo dla większych lub niezalignowanych zapisów poza reorderingiem możesz mieć tzw. torn write/read, gdzie wczytasz część wartości przed zmianą, a część po.
Przykładowo, następujący kod wykonuje się niepoprawnie po skompilowaniu do x86 za pomocą mojego mingw:
„`
volatile uint64_t val = 1;
int main()
{
thread t{[&]{
for(int i = 0; i < 1000000; ++i) {
val = 0x100000000u;
if(val == 0x100000001u){
cout << "!!!" << endl;
}
}
}};
for(int i = 0; i < 1000000; ++i) {
val = 0x000000001u;
if(val == 0x100000001u){
cout << "***" << endl;
}
}
t.join();
}
„`
http://i.imgur.com/RP8opB6.png
Niestety, nie mam pod ręką żadnego online compilera generującego 32-bitowy kod.
12 czerwca 2017 at 18:41
No właśnie dlatego napisałem dalej, że używanie volatile przy wielowątkowości nie jest dobrym rozwiązaniem. Może nie podkreśliłem tego wystarczająco.
O możliwym przekłamaniu danych przy kopiowaniu większych bloków danych volatile też napisałem. W C nie ma za bardzo sposobu żeby to rozwiązać. Jedyne co można zrobić to sprawdzanie integralności na wyższych warstwach np. przez CRC.
A przykład całkiem fajny. Sekcja krytyczna na przypisanie do zmiennej załatwiła by sprawę?
Podobna sytuacja może być z bitfieldami. Przypisanie do bitfielda jest rozbijane w asemblerze na andy i ory i jeśli przypisujemy z dwóch kontekstów, któreś może się zgubić.
16 czerwca 2017 at 03:25
Tak, podkreśliłbym to bardziej, bo czytając widzę
Zastosowanie modyfikatora volatile
> Modyfikator volatile ma trzy główne zastosowania:
> […]
> 3) Do przekazywania informacji między kontekstami w środowisku wielowątkowym.
Może i ma takie zastosowanie w praktyce, ale nie jest to zastosowanie poprawne , o ile dana platforma nie daje dodatkowych gwarancji, ale nawet wtedy nie jest to przenośne rozwiązanie. Dlatego też nie pisałbym, że ma takie zastosowanie, nawet jeśli czasem jest w ten sposób niepoprawnie wykorzystywane.
Sekcja krytyczna/mutex załatwiłaby sprawę, ale to relatywnie dość droga operacja, co na pewno wiesz.
Jeśli chodzi o alternatywy, C11 ma _Atomic i typedefy takie jak atomic_int itd., które gwarantują poprawne zachowanie w środowisku wielowątkowym, w wersji lock-free tam, gdzie to możliwe.
http://en.cppreference.com/w/c/language/atomic
Wcześniej pewnie musiałbyś patrzeć u dostawców architektury/kompilatora.
16 czerwca 2017 at 15:41
O jejku, musiałem być mocno zmęczony jak to pisałem. Postaram się naprostować ten pierwszy akapit:
Napisanie, że „ma zastosowanie” jako stwierdzenie faktu, że są ludzie, którzy tak stosują – jest jak najbardziej prawdziwe. Jednak *nie powinno* być tak stosowane. Czasem, dostawca kompilatora/architektury zagwarantuje, że użycie volatile do komunikacji między wątkami będzie bezpieczne. Ale i tak bym uważał z użyciem, bo taki kod nie będzie przenoszalny. Zamiast tego bym zrobił jakiś wspólny define atomic, który by na normalnych architekturach oznaczał _Atomic, a tam gdzie to wymagane i bezpieczne – volatile.
16 czerwca 2017 at 20:02
Dzięki za uwagi. Zrobiłem edita, teraz tekst jaśniej mówi, żeby tak nie robić.
Teksty, które piszę są głównie z myślą o embedded, a tam posługujemy się głównie standardem C89, ewentualnie wchodzą niektóre elementy C99. Często zdarza się, że nie cała biblioteka standardowa jest wspierana. Aż jestem ciekawy jak jest z wsparciem C11 np. na STM32. Żeby uruchomić takiego atomica z FreeRTOSem pewnie trzeba by zaimplementować odpowiednie syscallsy. Fajny temat, może się tym pobawię jak będę miał trochę czasu.
1 stycznia 2019 at 19:38
Na prawdę dobry artykół – czytałem też odnośnie TDD i const. A trafiłem na Pana blog po przesłuchaniu podcastu Ja programista. Dziękuję za rzeczowe i wartościowe merytorycznie treści w internecie 🙂
1 stycznia 2019 at 19:38
Na prawdę dobry artykuł – czytałem też odnośnie TDD i const. A trafiłem na Pana blog po przesłuchaniu podcastu Ja programista. Dziękuję za rzeczowe i wartościowe merytorycznie treści w internecie 🙂