Precyzyjne opóźnienia w połączeniu z trybami oszczędzania energii w STM32, część 1

Częstym problemem, który pojawia się przy pisaniu programów na mikrokontrolery, jest zaimplementowanie opóźnienia. W artykule przedstawiono sposób precyzyjnego odmierzania opóźnień w połączeniu z trybami oszczędzania energii dla mikrokontrolerów STM32 z rdzeniem Cortex-M3. Do odmierzania czasu wykorzystano układy licznikowe. Na czas opóźnienia mikrokontroler jest usypiany w jednym z trybów oszczędzania energii. Mikrokontroler może zostać wybudzony wcześniej, przed upływem zadanego czasu, jeśli zajdzie potrzeba obsłużenia jakiegoś przerwania, a po jego obsłużeniu powraca do trybu oszczędzania energii, aby dokończyć „drzemkę”.

Jedna z najprostszych funkcji opóźniających, jaką da się napisać w języku C, wygląda następująco:

W wielu zastosowaniach jest to rozwiązanie zadowalające. Jednak funkcja ta ma kilka poważnych wad. Realizowane opóźnienie jest nieprecyzyjne. Zależnie od ustawionej dla kompilatora opcji optymalizacji i opóźnień wprowadzanych przez pamięć programu (flash latency) jedna iteracja pętli może trwać od kilku do kilkunastu taktów zegara. Jeśli w trakcie wykonywania tej funkcji zostanie zgłoszone przerwanie, to czas opóźnienia wydłuży się o czas jego obsługi. Ponadto mikrokontroler przez cały czas kręcenia się w pętli pobiera energię, która jest marnowana. Chyba jedyną zaletą tej funkcji jest prostota.
Można pokusić się o implementację bardziej precyzyjnych funkcji opóźniających. Przykładowy program został napisany w języku C na mikrokontroler STM32F107 i można go uruchomić na „motylu”, czyli zestawie uruchomieniowym STM32Butterfly, co zostanie zaprezentowane w drugiej części artykułu. Program bardzo łatwo daje się przenieść na inne modele z rodziny STM32, gdyż korzysta ze Standard Peripheral Library i biblioteki CMSIS (Cortex Microcontroller Software Interface Standard), które są dostarczane przez STMicroelectronics [1]. Do precyzyjnego odmierzania krótkich czasów od kilku do ok. 65535 mikrosekund lub od 1 do 32767 milisekund zostały wykorzystane dwa spośród liczników TIM2, TIM3, TIM4 lub TIM5, taktowane zegarem systemowym przez odpowiednio dobrane dzielniki wstępne (prescaler). Do realizacji długich opóźnień, wyrażanych w sekundach, zastosowano zegar czasu rzeczywistego (real time clock), taktowany kwarcem zegarkowym 32768 Hz, tykający z częstotliwością 1 Hz. Podczas czekania na zakończenie opóźnienia mikrokontroler jest przełączany w tryb oszczędzania energii. Gdy zostaje zgłoszone przerwanie, mikrokontroler wraca na czas jego obsługi do trybu aktywnego. Po zakończeniu obsługi przerwania ponownie jest wprowadzany w tryb oszczędzania energii. Czas obsługi przerwania nie wydłuża opóźnienia.

Tryby oszczędzania energii

Mikrokontrolery STM32 wyposażono w kilka trybów oszczędzania energii. W trybie uśpienia (sleep mode) wstrzymywane jest taktowanie rdzenia i pamięci, wszystkie układy peryferyjne pozostają taktowane. W trybach głębokiego uśpienia (deep sleep mode) wstrzymywane jest taktowanie rdzenia, pamięci i większości peryferii. Są dwa tryby głębokiego uśpienia: tryb zatrzymania (stop mode) i tryb czuwania (standby).

W trybie zatrzymania wyłączone są wszystkie sygnały taktujące w domenie 1,8 V. Wstrzymane są  wewnętrzny oscylator RC o częstotliwości nominalnej 8 MHz (HSI – high speed internal), zewnętrzny oscylator kwarcowy (HSE – high speed external) i wszystkie synchroniczne pętle fazowe (PLL – phase locked loop). Wewnętrzny regulator napięcia 1,8 V może pozostać w stanie normalnej pracy albo zostać przełączony w stan niskiego poboru energii (low power mode). W trybie zatrzymania zawartość rejestrów i pamięci pozostaje niezmieniona. Wybudzenie z tego trybu może nastąpić w wyniku zgłoszenia jednego z 16 przerwań zewnętrznych (EXTI – external interrupt), przerwania wygenerowanego przez programowalny detektor napięcia zasilania (PVD – programmable voltage detector), na skutek wyzwolenia alarmu RTC lub za pomocą sygnału budzenia (wake-up) wygenerowanego przez interfejs USB lub Ethernet.
W trybie czuwania wewnętrzny regulator napięcia 1,8 V jest wyłączony. Oscylatory HSI, HSE i wszystkie PLL są również wyłączone. Po wejściu do tego trybu zawartość pamięci i większości rejestrów zostaje utracona. Zachowywana jest jedynie zawartość rejestrów zapasowych (backup registers). Wybudzenie z trybu czuwania może nastąpić za pomocą wyprowadzenia RESET, resetu wywołanego przez niezależnego nadzorcę (IWDG – independent watchdog), narastające zbocze na wyprowadzeniu WKUP-PA0 lub alarm RTC. Tryb czuwania różni się od pozostałych jeszcze tym, że po wybudzeniu z niego wykonywanie programu rozpoczyna się od początku. W pozostałych trybach wykonywanie programu jest wznawiane od miejsca, gdzie nastąpiło uśnięcie. Program wykorzystujący tryb czuwania musi sprawdzić przyczynę rozpoczęcia wykonania, czy jest to wynik resetu, włączenia zasilania czy wybudzenia.
We wszystkich trybach oszczędzania energii mogą pozostawać włączone:

  • wewnętrzny oscylator RC o częstotliwości nominalnej 40 kHz (LSI – low speed internal), zwykle taktujący układ nadzorcy IWDG;
  • zewnętrzny oscylator kwarcowy 32768 Hz (LSE – low speed external), taktujący zegar czasu rzeczywistego (RTC – real time clock).

Według noty katalogowej producenta [2] wybudzanie z trybu uśpienia trwa typowo 1,8 µs, z trybu zatrzymania z włączonym regulatorem napięcia – typowo 3,6 µs, z trybu zatrzymania z regulatorem napięcia w stanie niskiego poboru energii – typowo 5,4 µs, a z trybu czuwania – typowo 50 µs. Jeśli korzystamy z oscylatora kwarcowego HSE, to przy wybudzaniu z głębokiego uśpienia (tryby zatrzymania i czuwania) do powyższych czasów należy jeszcze doliczyć, wynoszący typowo 2 ms, czas startu tego oscylatora (wyżej podane czasy obejmują jedynie start wewnętrznego oscylatora RC HSI) oraz czasy synchronizacji PLL, wynoszące maksymalnie 350 µs dla każdej pętli. Pętle pracują zwykle kaskadowo, więc czasy ich synchronizacji sumują się.
Do aktywowania trybu oszczędzania energii w mikrokontrolerach z rdzeniem Cortex-M3 służą instrukcje asemblerowe WFI (wait for interrupt) i WFE (wait for event). Dla ścisłości należy wspomnieć, że mikrokontroler w tryb uśpienia można też przełączyć, ustawiając bit SLEEPONEXIT w rejestrze SCB_SCR (system control block – system control register). Bit ten można też ustawić za pomocą funkcji bibliotecznej NVIC_SystemLPConfig. Mikrokontroler zaśnie wtedy po zakończeniu obsługi najbliższego przerwania, a następnie każdorazowo, gdy zostanie zgłoszone przerwanie, obudzi się, aby go obsłużyć i zaśnie po zakończeniu jego obsługi. Jednak w omawianym przykładzie nie korzystamy z funkcjonalności sleep-on-exit.
Po wywołaniu instrukcji WFI mikrokontroler natychmiast zasypia. Budzi się, gdy wystąpi wyjątek (exception), który ma ustawiony dostatecznie wysoki priorytet, aby wywołać procedurę obsługi. Wyjątkami są wszystkie przerwania zgłaszane przez peryferie oraz przerwania sprzętowe i programowe generowane przez sam rdzeń. Po obudzeniu mikrokontroler wykonuje procedurę obsługi zgłoszonego wyjątku (przerwania), a potem kontynuuje wykonywanie kodu znajdującego się bezpośrednio za instrukcją WFI, która spowodowała uśpienie. Ten mechanizm można spróbować wykorzystać do realizacji opóźnień. Algorytm mógłby wyglądać następująco:

Najpierw zerujemy licznik TIMER. Konfigurujemy go, aby zgłosił przerwanie po osiągnięciu zadanej wartości opóźnienia. Startujemy licznik, a następnie zasypiamy. Po wybudzeniu sprawdzamy, czy przyczyną obudzenia było przerwanie zgłoszone przez TIMER. Jeśli źródłem przerwania nie był TIMER, ponownie zasypiamy. Ten algorytm wydaje się być poprawny, jednak zawiera bardzo subtelny błąd. Jeśli najpierw zostanie zgłoszony wyjątek (przerwanie) niepochodzący od licznika TIMER, warunek pętli będzie prawdziwy i zostanie ponownie wywołana instrukcja WFI. Między przeczytaniem odpowiedniego rejestru statusu licznika a ponownym zaśnięciem mikrokontroler wykona kilka instrukcji maszynowych: sprawdzenie warunku, skok warunkowy. Jeśli w tym czasie TIMER zgłosi przerwanie, to zostanie ono obsłużone przed wywołaniem instrukcji WFI i jej wywołanie uśpi mikrokontroler, aż pojawi się kolejny wyjątek. Opóźnienie wydłuży się nieprzewidywalnie.
Poprawnym rozwiązaniem jest użycie instrukcji WFE. Instrukcja ta współpracuje z jednobitowym rejestrem zdarzenia (one-bit event register). Rejestr ten jest testowany po wywołaniu tej instrukcji. Jeśli rejestr zdarzenia jest wyzerowany, mikrokontroler zasypia. Jeśli rejestr zdarzenia jest ustawiony, mikrokontroler zeruje go i kontynuuje wykonywanie programu – nie zasypia. Jeśli mikrokontroler zasnął na instrukcji WFE i następnie został ustawiony rejestr zdarzenia, mikrokontroler budzi się, a rejestr zdarzenia zostaje wyzerowany. Rejestru zdarzenia nie można odczytywać programowo. Rejestr ten jest zerowany tylko przez instrukcję WFE i może być ustawiany sprzętowo. Z pewnymi ograniczeniami, o których będzie napisane później, źródłami, które ustawiają ten rejestr, mogą być te same źródła, które wyzwalają wyjątki (przerwania). Co będzie dla nas bardzo ważne, rejestr zdarzenia może być też ustawiony programowo instrukcją SEV (send event). Poprawny algorytm jest następujący:

Z poziomu języka C instrukcje WFE, WFI i SEV można wywoływać za pomocą, dostępnych w bibliotece CMSIS, makr:

Żeby wprowadzić mikrokontroler w tryb głębokiego uśpienia, trzeba przed wywołaniem instrukcji WFE lub WFI ustawić bit SLEEPDEEP w rejestrze SCB_SCR. Dodatkowo dla trybu zatrzymania (stop mode) należy wyzerować bit PDDS (power down deep sleep) w rejestrze PWR_CR (power control register) i skonfigurować w tym rejestrze bit LPDS (low power deep sleep) zależnie od wybranego sposobu pracy regulatora 1,8 V. Wyzerowanie tego bitu oznacza pozostawienia regulatora włączonego, a jego ustawienie – wprowadzenie regulatora w stan niskiego poboru energii. Prostszym sposobem jest wywołanie funkcji bibliotecznej:

Parametr PWR_Regulator specyfikuje sposób pracy regulatora i może przyjmować wartość PWR_Regulator_ON lub PWR_Regulator_Low. Natomiast parametr PWR_STOPEntry specyfikuje instrukcję wywoływaną w celu zaśnięcia i może przyjmować wartość PWR_STOPEntry_WFI lub PWR_STOPEntry_WFE.
Aby wprowadzić mikrokontroler w tryb czuwania (standby) należy przed wywołaniem instrukcji WFE lub WFI, oprócz ustawienia bitu SLEEPDEEP, wyzerować bit WUF (wake-up flag) w rejestrze PWR_CR przez ustawienie bitu CWUF (clear wake-up flag) oraz ustawić bit PDDS w tym rejestrze. Jednak prostszym sposobem jest wywołanie funkcji bibliotecznej:

Ta funkcja wywołuje instrukcję WFI. W przypadku przechodzenia w tryb czuwania nie ma różnicy między działaniem instrukcji WFE a WFI.

Funkcje opóźniające

Plik nagłówkowy definiujący sygnatury funkcji opóźniających umieszczony jest na listingu 1.

 

List. 1. Sygnatury funkcji opóźniających umieszczone w pliku sleep.h

Funkcja USleep dopuszcza wartości parametru wejściowego z zakresu od 0 do 65535 i realizuje opóźnienie o zadaną wartość mikrosekund. Funkcja MSleep dopuszcza wartości parametru wejściowego z zakresu od 0 do 32767 i realizuje opóźnienie o zadaną wartość milisekund. Funkcja Sleep dopuszcza wartości parametru wejściowego z zakresu od 0 do 232–1 i realizuje opóźnienie o zadaną wartość sekund. Funkcja Standby wprowadza mikrokontroler w tryb czuwania na zadaną liczbę sekund i dopuszcza wartości parametru wejściowego z zakresu od 0 do 232–1. Funkcja SleepConfigure konfiguruje moduł realizujący opóźnienia i powinna być wywołana, zanim zostanie wywołana któraś z funkcji opóźniających. Jej argumentem jest częstotliwość taktowania układów licznikowych TIMx w MHz. W omawianym przykładzie konfigurujemy ją tak, aby była równa częstotliwości taktowania rdzenia, co zostanie omówione w drugiej części artykułu.

 

List. 2. Implementacja funkcji opóźniających umieszczona w pliku sleep.c

Implementacja powyższych funkcji została pokazana na listingu 2. Najpierw definiujemy, że licznik TIM2 jest używany do opóźnień mikrosekundowych, a licznik TIM3 – do opóźnień milisekundowych. Dzięki temu, gdyby któryś z tych liczników był zajęty przez inną funkcję, łatwo można go zamienić na licznik TIM4 lub TIM5. TIM2 jest zwiększany z częstotliwością 1 MHz, aby łatwo było odliczać okresy 1 µs. TIM3 jest zwiększany z częstotliwością 2 kHz. Taki wybór spowodowany jest tym, że przyjęliśmy założenie o taktowaniu liczników tym samym zegarem co rdzeń, a przy maksymalnej częstotliwości taktowania rdzenia 72 MHz i największej możliwej wartości dzielnika wstępnego 65536 nie uda się uzyskać sygnału 1 kHz.
Funkcja USleep korzysta z trybu uśpienia, a jak zostało napisane wyżej, wybudzanie z tego trybu może trwać ok. 2 µs. Ten narzut jest niezależny od częstotliwości taktowania rdzenia. Dodatkowy narzut wprowadza konieczność konfigurowania licznika. Możemy starać się go zminimalizować, pomijając wywołania funkcji bibliotecznych i odwołując się bezpośrednio do rejestrów konfiguracyjnych licznika. Ten narzut można oszacować w taktach zegara i zależy on od częstotliwości taktowania rdzenia. Jeśli chcemy odmierzać opóźnienia bardzo precyzyjnie, możemy spróbować eksperymentalnie dobrać wartość zmiennej usCalibration i ustawić ją na początku funkcji SleepConfigure w zależności od częstotliwości taktowania rdzenia. Wymienione narzuty powodują, że w praktyce za pomocą funkcji USleep nie uda się osiągnąć opóźnienia krótszego niż ok. 3 µs.
Funkcja USleep kolejno:

  • kalibruje wartość opóźnienia;
  • zeruje licznik ? rejestr CNT (counter);
  • konfiguruje wartość count, po której osiągnięciu przez licznik ma nastąpić ustawienie rejestru zdarzenia ? rejestr CCR1 (capture-compare register 1);
  • uaktywnia licznik ? ustawia bit TIM_CR1_CEN (counter enable) w rejestrze CR1 (control register 1);
  • usypia mikrokontroler za pomocą instrukcji WFE;
  • po obudzeniu sprawdza, czy licznik zatrzymał się, tzn. czy wyzerowany jest bit TIM_CR1_CEN ? jeśli nie jest, ponownie usypia.

Funkcja USleep współpracuje z procedurą US_TIM_IRQHandler obsługującą przerwanie licznika mikrosekundowego. Zgodnie z opisem dla instrukcji WFE nie ma potrzeby implementowania obsługi przerwania. Wystarczyłoby, gdyby rejestr zdarzenia (event register) został ustawiony po zajściu zdarzenia zgodności wartości licznika z wartością count. Niestety, jeśli zdarzenie zgodności zaszłoby między sprawdzeniem warunku zakończenia pętli do while a wywołaniem instrukcji WFE, rejestr zdarzenia nie zostałby ustawiony ? mielibyśmy dokładnie taki sam problem, jak omówiony wyżej dla instrukcji WFI. Problem ten jest znany i opisany w [3] jako błąd „563915: Event Register is not set by interrupts and debug” oraz w [4] w podrozdziale „Cortex-M3 event register is not set by interrupts and debug”. Dlatego w procedurze obsługi przerwania wołamy makro __SEV, aby mieć pewność, że rejestr zdarzenia, na który czeka instrukcja WFE, zostanie ustawiony. Wcześniej zatrzymujemy licznik przez wyzerowanie bitu TIM_CR1_CEN.
Funkcja MSleep jest bardzo podobna do funkcji USleep. Różnice są następujące. Nie kalibrujemy czasu uśpienia, gdyż przy opóźnieniach milisekundowych narzut związany z usypianiem i budzeniem jest pomijalny. Sprawdzamy jedynie, czy funkcja nie została wywołana z zerową wartością czasu opóźnienia. Ponadto licznik milisekundowy tyka z częstotliwością 2 kHz, więc trzeba wartość count pomnożyć przez dwa. Implementujemy też odpowiednią procedurę obsługi przerwania MS_TIM_IRQHandler dla tego licznika.
Funkcja Sleep jest koncepcyjnie podobna do dwóch wyżej omówionych funkcji opóźniających.
Różnice wynikają głównie z innych interfejsów programistycznych dla liczników TIMx i RTC. Zapis do rejestrów konfiguracyjnych RTC trwa co najmniej trzy takty zegara LSE (32768 Hz) i jest wykonywany niejako w tle w stosunku do programu. Zatem przed każdym zapisem do rejestru konfiguracyjnego RTC musimy upewnić się, czy zakończył się poprzedni zapis. Służy do tego funkcja RTC_WaitForLastTask. Do odmierzania czasu wykorzystujemy funkcjonalność alarmu RTC.
Funkcja Sleep kolejno:

  • odczytuje bieżącą wartość licznika RTC (wywołanie RTC_GetCounter) i ustawia wartość, przy której ma nastąpić obudzenie (wywołanie RTC_SetAlarm);
  • zasypia w pętli, wywołując funkcję PWR_EnterSTOPMode, która z kolei wywołuje instrukcję WFE, stała PWR_REGULATOR_MODE definiuje sposób pracy regulatora 1,8 V;
  • opuszcza pętlę, jeśli obudzenie jest wynikiem zgłoszenia alarmu RTC, czyli gdy ustawiony jest znacznik zdarzenia alarmu RTC_IT_ALR (wywołanie RTC_GetFlagStatus);
  • po opuszczeniu pętli kasuje znacznik zdarzenia alarmu (wywołanie RTC_ClearITPendingBit).

Po obudzeniu z trybu zatrzymania mikrokontroler jest taktowany wewnętrznym oscylatorem RC (HSI, 8 MHz). Podczas wykonywania funkcji Sleep mikrokontroler obsługuje przerwania, będąc taktowany zegarem HSI. Przed zakończeniem tej funkcji trzeba przywrócić pracę oscylatora kwarcowego HSE oraz pętli PLL za pomocą funkcji ClockConfigure, która będzie omówiona w drugiej części artykułu. W przypadku funkcji Sleep nie musimy implementować procedury obługi przerwania, gdyż alarm RTC poprawnie ustawia rejestr zdarzenia.
Funkcja Standby jest bardzo prosta. Odczytuje bieżącą wartość licznika RTC za pomocą funkcji RTC_GetCounter. Ustawia wartość licznika RTC, przy której nastąpi wybudzenie – funkcja RTC_SetAlarm. Mikrokontroler zostaje przełączony w tryb czuwania – funkcja PWR_EnterSTANDBYMode. Mikrokontroler może zostać wybudzony z trybu czuwania, przed upływem zadanego czasu opóźnienia, przez narastające zbocze na wyprowadzeniu WKUP-PA0. Funkcja PWR_WakeUpPinCmd uaktywnia tę opcję.

 

List. 3. Pomocnicze makro umieszczone w pliku check.h

Ponieważ RTC nie jest resetowany w trybach głębokiego uśpienia (zatrzymania i czuwania), to sposób jego konfiguracji jest uzależniony od ustawienia znacznika PWR_FLAG_SB testowanego za pomocą funkcji PWR_GetFlagStatus. Jeśli znacznik PWR_FLAG_SB jest ustawiony, to nastąpiło wybudzenie, RTC pracuje i nie trzeba go rekonfigurować. Jeśli znacznik ten jest wyzerowany, to mikrokontroler został zresetowany lub zostało włączone zasilanie i trzeba skonfigurować RTC. Aby funkcja SleepConfigure nie zawiesiła się w przypadku uszkodzenia oscylatora zegarkowego, korzystamy z pomocniczego makra, które jest przedstawione na listingu 3. Makro to ogranicza czas oczekiwania na start oscylatora. Jeśli oscylator nie zadziała, funkcja SleepConfigure zwróci wartość ujemną, sygnalizującą błąd. Przy poprawnym zakończeniu funkcja zwraca zero.
Marcin Peczarski

Materiały referencyjne
[1] ARM-based 32-bit MCU STM32F10xxx standard peripheral library, wersja 3.2.0,

Autor: