Bardzo często zdarza mi się (i wielu z was pewnie również) dostrzegać pewien błąd popełniany przez wielu ludzi piszących programy na mikrokontrolery. Znaleźć można je w różnych projektach w sieci, znajduję je w starych projektach w pracy, kilka razy spotkałem się z nim również na mirko i ostatnim razem pomyślałem, że można by coś na ten temat napisać. Mimo że wiem, że fatalnie tłumaczę :-)
A o co chodzi? W programach na mikrokontrolery nierzadko wykorzystuje się przerwania. Normalnym jest więc że kod "programu głównego" styka się wciąż z "kodem przerwania". Czasami tym elementem stycznym jest jakaś zmienna globalna, która powinna być dostępna w dowolnym momencie zarówno dla programu jak i dla przerwania. Czy w takiej sytuacji trzeba zrobić coś więcej niż zawsze? Otóż bardzo często ludzie działają według zasady:
Jeśli zmienna będzie wykorzystywana przez przerwanie, musi mieć atrybut volatile.
Czasami jeszcze dodaje się, po co:
Inaczej zmienna nie będzie widziana w przerwaniu / w programie.
Takie wytłumaczenie widziałem w kilku książkach. Spotkałem się z nim kiedyś u wykładowców. W internecie 99% prób wytłumaczenia atrybutu
volatile będzie właśnie takie. W wielu przypadkach będzie ono prowadziło do poprawnego działania kodu, ale nie zawsze i w generalnym przypadku jest złym wytłumaczeniem. Dodatkowo wynika z niego, że
volatile jest związany wyłącznie z przerwaniami. Jest to nieprawda, gdyż
volatilejest wykorzystywany dużo częściej (w zasadzie w każdym programie) niż tylko w przypadku stosowania przerwań - o tym niżej.
Takie wytłumaczenie ponadto prowadzi często do pewnego rodzaju błędu. Część ludzi nie zdaje sobie z niego sprawy i - wychodząc z powyższej zasady - są przekonani, że jeśli użyli
volatile, to wszystko będzie poprawnie działało. A nie będzie. Niestety im więcej ludzi interesuje się projektami typu arduino, gdzie można coś łatwo i szybko zbudować z klocków, tym mniej ludzi zawraca sobie głowę tym, co dzieje się na niskim poziomie.
Przykłady o których piszę poniżej umieściłem tutaj (są skompilowane i gotowe do oglądania):
Przyklad.zip
Przykłady są mocno uproszczone i zupełnie nic nie robią, ale można na nich pokazać o co chodzi i każdy będzie mógł w swoim kodzie odnaleźć analogiczne miejsca.
Spójrzmy na poniższy kod:
Nie ma w nim nic skomplikowanego. Kolejno na porty A i B zostanie wpisana wartość ze
zmienna, czyli 0x0F. Chyba, że zdarzy się przerwanie. Trzeba wyraźnie zaznaczyć, że program nie wie, kiedy to nastąpi. W zależności od momentu jego wyzwolenia:
- jeśli zostanie wyzwolone przed operacją na porcie A, do obu portów wpisze się wartość 0x10;
- jeśli zostanie wyzwolone po operacji na porcie A a przed operacją na na porcie B, do portu A wpisze się wartość 0x0F, a do portu B wpisze się wartość 0x10;
- jeśli zostanie wyzwolone po operacji na porcie B, bo obu portów zostanie wpisana wartość 0x0F;
zmienna ma rozmiar 8 bitów i zadeklarowana jest bez atrybutu
volatile. Zgodnie z przedstawioną powyżej zasadą kod ten nie powinien działać. Jest to przykład zawarty w katalogu:
Przyklad1-O0-byte-nov
A teraz spójrzmy na drugi kod
Robi dokładnie to samo. Różnica polega na tym, że
zmienna została już opakowana w atrybut
volatile. Kod więc musi już działać poprawnie. Jest to przykład zawarty w katalogu:
Przyklad1-O0-byte-v
Ale zróbmy eksperyment. W wymienionych katalogach są już gotowe skompilowane pliki wynikowe (możecie je też sami skompilować, wiadomo - nie musicie mi ufać (-: ). Chodzi o pliki
Przyklad.hex. Porównajcie ich zawartość. Wyszło, że taka sama! Poniekąd to niemożliwe, bo przecież pierwszy kod nie powinien działać, a drugi powinien - zwyczajnie muszą się różnić.
W katalogach
buildznajdziecie pliki
Przyklad.lst. Jest to wynik przełożenia kodu przez kompilator na assembler. Pliki nie są identyczne co do znaku, ponieważ zawarte są w nim fragmenty kodu w C w formie komentarza, a przecież w jednym z plików jest jedno słowo więcej (
volatile). Jeśli jednak porównacie fragmenty zawierające kod wynikowy zobaczycie, że jest taki sam. Zobaczmy jak wygląda trochę czytelniejszy wyciąg z tego pliku:
Zaznaczyłem na rysunku miejsce z przerwaniem. Jak widać spełnia ono założoną rolę - inkrementuje
zmienna. Poniżej widać komendy:
LDS R18, zmienna
ST Z, R18
co nam wyraźnie mówi, że do portów A i B wpisywana jest wartość
zmienna. Co z tego wynika? Zarówno w kodzie bez
volatile jak i z
volatile ten kod działa tak samo i na dodatek poprawnie. I program główny i przerwanie "widzą" zmienną
zmienna.
Nie jest więc prawdą, że niezadeklarowanie zmiennej jako volatile zawsze uniemożliwia jej poprawne działanie na styku programu i przerwania.
Na rysunku powyżej zaznaczyłem też dwa miejsca. Zastanówmy się co się stanie, jeśli przerwanie zostanie wyzwolone w chwili wykonywania rozkazu z linii "1"? Otóż
zmienna ma wartość 0x0F. Jeśli przerwanie nastąpi w chwili wykonywania rozkazu z linii "1", po zakończeniu wykonywania tego rozkazu zostanie ono (przerwanie) obsłużone.
zmienna zostanie zinkrementowana. Po powrocie z przerwania wykonana zostanie kolejna linia kodu oznaczona jako "2". Para rozkazów LDS i ST załaduje więc do portu A wartość 0x10.
W przypadku, gdyby przerwanie zostało wyzwolone podczas wykonywania rozkazu z linii "2", rozkaz LDS załaduje jeszcze poprzednią wartość
zmienna, czyli 0x0F, gdyż przerwanie zostanie obsłużone dopiero po zakończeniu wykonywania rozkazu z tej linii.
I to jest poprawne działanie tego kodu. Mam nadzieję, że dość jasno udało mi się to wytłumaczyć,że zrozumieliście i się ze mną zgodzicie :-)
Spójrzmy zatem na dwa kolejne, krótkie kody. Pierwszy z nich znajduje się w katalogu:
Przyklad1-O0-word2-nov
Drugi z nich znajduje się w katalogu:
Przyklad1-O0-word2-v
Znowu są to takie same kody różniące się obecnością lub nie
volatile (nie zamieściłem drugiego rysunku, ale możecie spojrzeć w źródła). Tym razem
zmienna ma długość 16 bitów. Po porównaniu plików *.hex ponownie wyjdzie, że są takie same. Spójrzmy na plik assemblera:
Jeśli porównacie sobie pliki *.lst z obu przykładów - bez _volatile_ i z _volatile_ - zobaczycie, że kod wynikowy jest taki sam. Podobnie jak w przypadku przykładu ze zmienną długości 8 bitów, widać tutaj, że przykład będzie działał. Zarówno przerwanie jak i program operują na _zmienna_, więc "widzą ją" poprawnie. Powtórnie _volatile_ lub jego brak nie miało wpływu na działanie kodu.
Spójrzmy jednak na zaznaczone cyframi liczby. Co się stanie jeśli przerwanie wyzwoli się, gdy...:
- Jeśli przerwanie wyzwoli się podczas wykonywania rozkazu z linii "1", to po zakończeniu wykonywania tego rozkazu przerwanie zostanie obsłużone, zinkrementuje zmienna i na port A zostanie wpisana wartość 0x0100.
- Jeśli przerwanie wyzwoli się podczas wykonywania rozkazu z linii "3", to przerwanie będzie obsłużone już po tym, jak zmienna zostanie przepisana. Na port A zostanie wpisana wartość 0x00FF.
A co jeśli przerwanie zostanie wyzwolone podczas wykonywania rozkazu z linii oznaczonej "2"?
zmienna ma długość 16 bitów, ale użyty w przykładzie mikrokontroler ma rejestry 8-bitowe. To oznacza, że każdą daną dłuższą od 8 bitów musi odczytywać/zapisywać "w porcjach" 8 bitowych. Najpierw młodszy bajt, potem starszy (lub odwrotnie). Widać to zresztą w kodzie powyżej, gdzie znajdziemy rozkazy:
LDS R18, zmienna
LDS R19, zmienna+1
Jeśli
zmienna ma wartość 0x00FF, to poszczególne jej składniki mają wartości odpowiednio 0x00 oraz 0xFF. Do rejestru R18 zostanie więc wpisana wartość 0x00 a do rejestru R19 wartość 0xFF. Razem będą one stanowiły wartość 0x00FF. Po inkrementacji zmiennej
zmienna będzie ona miała wartość 0x0100, więc w tym samym punkcie pracy rejestry będą miały wartość odpowiednio R18=0x01 oraz R19=0x00. Poprawnie.
Ale! Jeśli przerwanie zostanie wyzwolone podczas wykonywania rozkazu z linii "2" scenariusz się zmieni. najpierw do rejestru R18 zostanie wpisany starszy bajt
zmienna: wartość 0x00. Potem zostanie obsłużone przerwanie, które zinkrementuje
zmienna i będzie ona miała już wartość 0x0100. Po wyjściu z przerwania do rejestru R19 zostanie wpisany młodszy bajt
zmienna: wartość 0x00. Jaka wartość finalnie zostanie wysłana do portu A? 0x0000!
Jeśli się rozejrzycie po kodach (być może i po swoich) to ten błąd jest naprawdę dość częsty:
Jeśli zmienne globalne mają długość przekraczającą możliwość operowania na nich za pomocą pojedynczego rozkazu odczytu/zapisu, manipulowanie nimi w przerwaniu i programie wykonywane w niewłaściwy sposób mogą prowadzić do odczytów i zapisów błędnych wartości i volatile nie ma na to wpływu.
O napisaniu tego artykułu pomyślałem, kiedy jeden z mirków pytał w #
elektronika o program do licznika prędkości. Kod, który wkleił, także zawierał powyższy błąd. Padło tam stwierdzenie, że "najwyżej raz na jakiś czas na liczniku pojawi się zła prędkość". To prawda, że przy czymś takim, jak licznik prędkości, który tylko pokazuje prędkość i nie jest niczym krytycznym, taki błąd do niczego nieprawidłowego nie doprowadzi. I w wielu innych przypadkach, projektach, programach i układach również. Ale po pierwsze - jest to jednak błąd i nie ma powodu, żeby pisać programy z błędami nawet, jeśli nic się ma z tego powodu nie stać. Po drugie - na błędach się przecież uczymy, ale jeśli nie będziemy sobie z niego zdawali sprawy, będziemy go w przyszłości powielać. A po trzecie - taki błąd nie jest już trywialny w zastosowaniach, gdzie jakieś dane muszą być po prostu prawidłowe. Tam, gdzie praca całego układu zależy ściśle od takich danych, jakieś przypadkowe wartości "raz na jakiś czas" po prostu spowodują jego nieprawidłową pracę.
A jak można ten problem rozwiązać? W takim wypadku stosuje się tzw. sekcję krytyczną (chociaż nazwy są różne, a najczęściej nie ma żadnej (-: ). Spójrzmy na poniższy kod znajdujący się w katalogu:
Przyklad1-O0-word2-prop
Cały trik polega na tym, że skoro przerwanie może nam w zmiennej naśmiecić, to po prostu je wyłączmy na czas operowania na tej zmiennej. A jeśli na wartości zmiennej będziemy operować jeszcze wielokrotnie w krótkim odstępie czasu, wykorzystajmy jakąś zmienną tymczasową (jak w przykładzie powyżej), żeby nie wyłączać i włączać przerwania zbyt często. Sekcje krytyczne wykorzystywane są wszędzie tam, gdzie jest potrzeba wykonywania pewnego kodu z bezwzględnym zakazem jego przerwania. Mogą to być tak jak powyżej manipulacje 16 bitowymi danymi na 8 bitowym mikrokontrolerze (lub 32 bitowymi danymi na 16 bitowym mikrokontrolerze, itd). Mogą to być precyzyjne pomiary czasu. Mogą to być precyzyjne manipulacje portami I/O, np. bardzo szybkie. Nie trzeba nawet mieć własnych zmiennych, gdyż np. sam AVR ma rejestry > 8 bitów:
Why do some 16-bit timer registers sometimes get trashed?
A co z
volatile?
Powyższe przykłady były ekstremalnie proste, żeby wymusić poprawne wygenerowanie kodu bez użycia zmiennych
volatile. Ale nie zawsze tak będzie i często
volatile jest niezbędne.
volatile mówi kompilatorowi żeby zawsze przy używaniu zmiennej z takim atrybutem zakładał, że mogła ona zmienić swoją wartość "gdzieś tam".
Dokładnie tak jak przy przerwaniu, ale jak widać w przykładach powyżej, nie działa to obligatoryjnie w drugą stronę. Zmienna zadeklarowana jako
volatile zawsze będzie miała poprawną wartość na styku programu i przerwania. Ale brak
volatile nie musi oznaczać, że będzie miała niepoprawną. Tam, gdzie kontrolujemy kod i jesteśmy świadomi tego, co robimy,
volatile będzie pewnie zbędne.
Stosowanie
volatile zawsze przy zmiennych dotykających przerwań to właściwie dobra praktyka i zawsze będziemy pewni, że jest ok (choć pewnie można by wymyśleć przykłady obalające to (-: ).
Ale volatile nie dotyczy wyłącznie przerwań! Wszędzie tam, gdzie jest potrzeba, aby kompilator zawsze korzystał z aktualnej wartości zmiennej, należy użyć
volatile. Nie jest to częste, ale jakieś mocno zagnieżdżone funkcje, zagnieżdżone sto razy wskaźniki, etc mogą tego wymagać. A przede wszystkim - jeśli spojrzycie w pliki nagłówkowe to zobaczycie że dostęp do portów, rejestrów i pamięci jest typu
volatile. Dlaczego? Bo bo ich wartości mogą po prostu się zmienić bez udziału kodu, np. inkrementujący się licznik albo stan portu wejściowego. Tak więc nawet jeśli nie korzystacie z przerwań w programie, sporo atrybutów
volatile jest w waszym programie za sprawą dołączanych przez was plików nagłówkowych dołączonych do kompilatora.
Sytuacja z przerwaniem jest jednak chyba najpowszechniejsza, jeśli chodzi o celowe użycie
volatile. Spójrzmy na poniższy przykład z katalogu:
Przyklad2-O0-novol
I na kod wynikowy. Ważne jest, że kod skompilowany jest z optymalizacją na poziomie "0".
Co wynika z tego kodu? Przerwanie inkrementuje
zmienna, czyli jest ok. W programie, w miejscu zaznaczonym jako "1",
zmienna otrzymuje wartość 1 przy użyciu rejestru R24. Też ok. Dalej, w miejscu "2",
zmienna jest przepisywana do portu A poprzez zmienną R18. A w miejscu "3"
zmienna jest testowana za pomocą rejestru R24. Wszystko ok. W całym kodzie
zmienna zawsze jest odczytywana więc zawsze ma poprawną wartość. Znowu - mimo operowania na
zmienna i w przerwaniu i w programie wszystko jest jednak ok.
W katalogu:
Przyklad2-O3-novol
znajdziecie ten sam kod, jednak w
makefile poziom optymalizacji został przestawiony na "3". Jak teraz będzie wyglądał kod wynikowy?
W miejscu "1" przerwanie inkrementuje
zmienna, czyli jest ok. W miejscu "2"
zmienna zyskuje wartość 1 za pomocą rejestru R24. Do tej pory jak wszystko jak wyżej. Dalej, w miejscu "3" na port A wysyłana jest wartość 1 za pomocą rejestru R25. Potem w miejscu "4"
zmienna jest testowana za pomocą rejestru R24.
Czy wszystko ok? Otóż nie. W miejscu oznaczonym "3" kompilator sobie uprościł:
Ponieważ _zmienna ma wartość 1, a programista chce wysłać na port A właśnie ową
zmienna to tak samo, jakby po prostu chciał wysłać tam wartość 1. To tak zrobię i będzie przecież ok._
Efekt jest taki, że pomimo poprawnie inkrementującej się zmiennej, na port A wysyłana jest niepoprawna wartość.
Jak widać - zmienna jest widoczna i dla programu i dla przerwania - mimo to nie będzie to działać!
Jeśli miałbym to ubrać w jakąś ogólną, mocno uproszczoną zasadę to napisałbym, że volatile nie powoduje, że taka zmienna będzie widoczna dla przerwania i/lub programu, bo jak widać i bez tego widoczna to ona może być a nie musi działać. volatile powoduje natomiast, że kompilator nie będzie stosował w danym miejscu takich uproszczeń, które mogą prowadzić do użycia nieaktualnej wartości zmiennej.
A co się stanie, jeśli w końcu użyjemy
volatile?
Ten kod będzie już działał poprawnie.
Dziękuję za uwagę i - skoro jest piątek - życzę
Komentarze (3)
najlepsze
chociaƶ ładniej by chyba było po prostu napisać, ƶe słowo kluczowe
volatile
informuje, ƶe kompilator nie moƶe optymalizować dostępów do zmiennej, poniewaƶ jej wartość moƶe się zmienić w sposób dla niego niewidoczny.edit: aha, jeśli mowa nie tylko o mikroprockach, to w przeciwieństwie do
Komentarz usunięty przez moderatora
Dzięki za informację. To prawda że wszędzie piszą "użyj volatile, bo tak". A tutaj ładnie wytłumaczone.