Robienie 3 rzeczy jednocześnie, czyli jak zrealizować Timer Programowy? | STM32 na Rejestrach #5

Poznajmy zatem jeden ze skutecznych sposobów na to, aby nie blokować pracy procesora oczekiwaniem. Będzie to Timer Programowy. Jest to absolutna podstawa programisty embedded. Takie abecadło.

Często możesz się spotkać ze stwierdzeniem wielozadaniowości. Na przykład filmy tłumaczące ten sam mechanizm na Arduino często właśnie nazywają się pracą wielozadaniową na Arduino. To jest jednocześnie prawdą i kłamstwem.

👉 Kłamstwem, bo na procesorze nie może dziać się więcej niż jedno zadanie, jedna instrukcja w jednym czasie. Nie wrzucamy tutaj dwóch i więcej zadań jednocześnie.

👉 Dlaczego więc to jest prawdą i się udaje? Chodzi o złudzenie wielozadaniowości. Zdania wykonywane są naprzemiennie i w tak szybkim tempie, że jako ludzie mamy wrażenie jednoczesności ich wykonywania. Ot cała magia.

Sztuka teraz polega na tym, aby te zadania były tak rozłożone czasowo na procesorze, aby cały wielki system sprawiał wrażenie, że jego wszystkie elementy pracują jednocześnie i poprawnie. W tej serii nie będziemy budowali wielkiego systemu. Pokażę Ci dzisiaj przykład wielozadaniowości i braku blokowania kodu na przykładzie kilku diod LED.

👉 Chcesz nauczyć się programowania STM32 na rejestrach w pełni? Zapraszam do mojego pełnego kursu na ten temat: https://stm32narejestrach.pl

Seria STM32 na Rejestrach na YouTube

Wpisy te powstają równolegle do serii na moim YouTube o tej samej tematyce. Jeśli wolisz wersję video to zapraszam Cię właśnie tam. Artykuły te są skrótem z tego, co pokazuję na YouTube.

Link do całej Playlisty Youtube

Na czym polega Timer Programowy?

Musimy poznać coś takiego jak Timer Programowy.

Timerem Programowym nazywamy technikę programowania polegającą na sprawdzaniu upływającego czasu i odblokowywaniu akcji jeśli minie go wystarczająco dużo. Pewnie nie mówi nam to zbyt wiele…

Wyobraź sobie linię czasu naszego programu. Jeśli za punkt startowy weźmiemy sobie zero, a chcemy wykonywać akcję co 500 milisekund, to przy jakich wartościach czasu musimy wykonać akcję?

500, 1000, 1500, 2000, 2500 itp. W tych momentach i nigdzie pomiędzy.

Jeśli zegarek, który odmierza czas będzie wskazywał coś innego, to nie robimy nic (albo inne akcje zaplanowane i innym czasie). To jest świat idealny. W naszym nieidealnym świecie moment zrównania się biegnącego czasu z czasem akcji “odblokuje” nam akcję do wykonania i zrobimy to przy najbliższej możliwej okazji.

Co będzie naszym zegarkiem? Już go skonfigurowaliśmy i używaliśmy w poprzednich lekcjach. SysTick Timer. On cały czas odmierza nam czas działania programu. W tle zlicza co 1 milisekundę ile czasu już upłynęło. Nie bez powodu nazywa się on SysTick – bo służy do zliczania “czasu systemowego” czy inaczej mówiąc – do podstawy czasu.

Wystarczy więc sprawdzać ile czasu minęło i porównywać z tym w których momentach chcemy wyzwolić akcję. Jak to się robi?

Od bieżącego globalnego czasu zaznaczonego w jakiś sposób powiedzmy “markerem” czekamy wyznaczony odcinek czasu. Na przykład te nasze 500 ms z początku akapitu. Jeśli czas wykonania akcji “wybił”, to wykonujemy akcję i przesuwamy nasz “marker” do przodu. I tak w kółko.

Jeszcze nie jest jasne? Będzie jaśniejsze z algorytmem i kodem 🙂

Algorytm działania Timera Programowego

Ogólny algorytm działania może być następujący:

  1. Tworzymy zmienną do zapaniętania bierzącego czasu – “markera”.
  2. “Karmimy” marker aktualnym czasem (tym systemowym, bo on jest naszym wyznacznikiem).
  3. Powtarzamy w pętli:
    1. Sprawdzamy, czy upłynął zadany odcinek czasu
    2. Jeśli tak:
      1. Wykonujemy zaplanowaną akcję
      2. Nadpisujemy marker aktualnym czasem*

* jest kilka możliwości i momentów na to w jaki sposób przeładowywać marker czasem. Możemy to zrobić:

1) Przed akcją / Po akcji. Wtedy decydujemy od którego momentu odliczamy ten “stały” odcinek czasowy

2) Przeładowujemy bieżącym czasem / dodajemy stałą wartość. Decydujemy o tym, czy czas wykonywania akcji wlicza się do czasu oczekiwania na kolejną akcję. Niebezbieczeństwem może być tutaj to, że czas wykonania się akcji będzie dłuższy niż interwał między kolejnymi akcjami.

Moim ulubionym sposobem jest przeładowanie bieżącym czasem przed wykonaniem akcji i tak też zrobimy. Ten sposób jak do tej pory najlepiej mi się spisywał. Może Tobie podejdzie inny? Spróbuj i się przekonaj. U mnie nie ma żadnych przymusów 😉

Kod Timera Programowego

Teorię mamy za sobą. Musimy więc przejść do praktyki. Naszym ćwiczeniem będzie miganie wieloma diodami z różną częstotliwością.

Więcej diod = więcej konfiguracji pinów. Na szczęście to już potrafimy. Podłączmy diody do pinów sąsiednich do diody na NUCLEO-C031. LD4 którą migaliśmy była na PA5. Podłączmy zatem LD5 na PA6 i LD6 na PA7. One są obok siebie na złączu Arudino Uno. Upewnijmy się na schemacie, że nie są podłączone do czegoś innego.

Jak widzimy są to wolne piny. Po prostu są wyprowadzone na złącze Arduino, więc z nich skorzytamy. Piny mikrokontrolera podłącz do rezystora ok 330 ohm a rezystor do katody diody LED. Anodę do 3,3V na Nucleo. Dokładny schemat wygląda następująco.

Teraz kod. Możemy w pierwszej kolejności skopiować funkcje dla LD4 i je dostosować.

// LEDs control macros
#define LD4_ON GPIOA->BSRR = GPIO_BSRR_BS5
#define LD4_OFF GPIOA->BSRR = GPIO_BSRR_BR5
#define LD4_TOGGLE GPIOA->ODR ^= GPIO_ODR_OD5

#define LD5_ON GPIOA->BSRR = GPIO_BSRR_BS6
#define LD5_OFF GPIOA->BSRR = GPIO_BSRR_BR6
#define LD5_TOGGLE GPIOA->ODR ^= GPIO_ODR_OD6

#define LD6_ON GPIOA->BSRR = GPIO_BSRR_BS7
#define LD6_OFF GPIOA->BSRR = GPIO_BSRR_BR7
#define LD6_TOGGLE GPIOA->ODR ^= GPIO_ODR_OD7

// LEDs Configuration
void ConfigureLD4(void);
void ConfigureLD5(void);
void ConfigureLD6(void);

void ConfigureLD4(void)
{
	// Enable Clock for PORTA
	RCC->IOPENR |= RCC_IOPENR_GPIOAEN;

	// Configure GPIO Mode - Output
	GPIOA->MODER |= GPIO_MODER_MODE5_0;
	GPIOA->MODER &= ~(GPIO_MODER_MODE5_1);

	// Configure Output Mode - Push-pull
	GPIOA->OTYPER &= ~(GPIO_OTYPER_OT5);

	// Configure GPIO Speed - Low
	GPIOA->OSPEEDR &= ~(GPIO_OSPEEDR_OSPEED5);

	// Configure Pull-up/Pull-down - no PU/PD
	GPIOA->PUPDR &= ~(GPIO_PUPDR_PUPD5);
}

void ConfigureLD5(void) // PA6
{
	// Enable Clock for PORTA
	RCC->IOPENR |= RCC_IOPENR_GPIOAEN;

	// Configure GPIO Mode - Output
	GPIOA->MODER |= GPIO_MODER_MODE6_0;
	GPIOA->MODER &= ~(GPIO_MODER_MODE6_1);

	// Configure Output Mode - Push-pull
	GPIOA->OTYPER &= ~(GPIO_OTYPER_OT6);

	// Configure GPIO Speed - Low
	GPIOA->OSPEEDR &= ~(GPIO_OSPEEDR_OSPEED6);

	// Configure Pull-up/Pull-down - no PU/PD
	GPIOA->PUPDR &= ~(GPIO_PUPDR_PUPD6);
}

void ConfigureLD6(void) // PA7
{
	// Enable Clock for PORTA
	RCC->IOPENR |= RCC_IOPENR_GPIOAEN;

	// Configure GPIO Mode - Output
	GPIOA->MODER |= GPIO_MODER_MODE7_0;
	GPIOA->MODER &= ~(GPIO_MODER_MODE7_1);

	// Configure Output Mode - Push-pull
	GPIOA->OTYPER &= ~(GPIO_OTYPER_OT7);

	// Configure GPIO Speed - Low
	GPIOA->OSPEEDR &= ~(GPIO_OSPEEDR_OSPEED7);

	// Configure Pull-up/Pull-down - no PU/PD
	GPIOA->PUPDR &= ~(GPIO_PUPDR_PUPD7);
}

Zwrócenie bierzącego czasu

Nasze akcje będę nazywał Taskami, czyli zadaniami. Każdy Task powinien wykonywać się w innym interwale. Każde zadanie więc będzie inaczej ten czas traktowało. Potrzebujemy mieć 3 zmienne (”markery”), aby każde zadanie mogło zapamiętać swój punkt odniesienia.

Typ zmiennej Timera powinien być taki sam jak typ zmiennej zliczającej czas systemowy.

// Software Timers variables
uint32_t Timer_LD4;
uint32_t Timer_LD5;
uint32_t Timer_LD6;

Powinniśmy załadować bieżący czas do tych zmiennych. Tylko skąd go wziąć? Można byłoby po prostu przypisać zmienną Tick od SysTick Timera, ale TAK SIĘ NIE ROBI!

W przyszłosci będziemy dzielić nasz kod na pliki, więc zacznijmy się na to przygotowywać. Potrzebujemy funkcji, która nam zwróci ten czas. Nie możemy się za każdym razem zastanawiać w jaki sposób uzyskujemy czas globalny. Bo co jak zmienimy metodę pozyskiwania go? Będziemy szperać w całym programie i zmieniać każdą linijkę? No nie. Musi to być opakowane w wygodną funkcję.

// Tick for System Time
__IO uint32_t Tick;

uint32_t GetSystemTick(void)
{
	return Tick; // Return current System Time
}

Mając taka funkcję zwracającą czas systemowy będzie dużo łatwiej i poprawniej.

Możemy skorzystać z niej, aby nakarmić stan początkowy naszych zmiennych Timerowych. Zróbmy to zaraz za konfiguracją LEDów.

	// Configure LEDs
	ConfigureLD4();
	ConfigureLD5();
	ConfigureLD6();

	// Software Timers - first fill
	Timer_LD4 = GetSystemTick();
	Timer_LD5 = GetSystemTick();
	Timer_LD6 = GetSystemTick();

Mamy pierwsze zaznaczenie markerem. Zróbmy najpierw jedno zadanie na znanej nam już diodzie LD4. Blokująco z użyciem Delay zmienialiśmy jej stan co 500 ms. Zróbmy to teraz nieblokująco.

Po pierwsze przydałoby się wygodna defnicja tego czasu oczekiwania.

// Constants for Software Timer's actions
#define LD4_TIMER 500

Mając ten czas możemy w końcu napisać kod algorytmu wyzwalania Taska.

  1. Trzeba sprawdzić czy upłynęła zadana ilość czasu. Porównujemy różnicę między bieżącym czasem, a tym “zaznaczonym”. Jesli różnica ta jest większa niż wymagany czas między akcjami, to zaczynamy wywoływać akcjię.
  2. Możemy od razu przeładować Timer, czyli zaznaczyć kolejny punkt odniesienie na osi czasu od którego chcemy, aby minął wymagany czas do ponownego wykonania naszej akcji. Umieszczenie w tym miejscu przeładowania daje nam to, że rozpoczęcie akcji będzie zawsze co wyznaczony czas niezależnie od tego ile czau trwa wykonanie zadania.

    Zadania mogą być różne i trwać różną ilość czasu w zależności od aktualnych warunków. Takie umieszczenie przeładowania na początku jest bardzo dobrym wyborem.
  3. Czym przeładować? Tak jak mówiłem – ja preferuję aktualnym czasem.

Jak już mamy wszystkie sprawy w okół działania timera załatwione to możemy w końcu przejść do wykonania naszej akcji. My migamy diodą, ale to może być dowolna akcja. Odświeżenie ekranu, odczyt tempratury, odpytanie jakiegoś układu o pomiar. W zasadzie wszystko co potrzebujemy wykonywać cyklicznie.

Kod wykonujący te czynności będzie wyglądał na przykład tak:

		// LD4
		if((GetSystemTick() - Timer_LD4) > LD4_TIMER) // Check if is time to make  action
		{
			Timer_LD4 = GetSystemTick(); // Refill action's timer
			LD4_TOGGLE; // ACTION!
		}

Proste, prawda? Ten mechanizm jest niesamowicie prosty, a jak bardzo użyteczny!

Pozostałe diody

Pozostałe nasze akcje na diody robimy analogicznie zmieniając jedynie czas między wywołaniami.

Możemy śmiało skopiować fragmenty dla pierwszej diody. Musimy pamięctć o tym, aby zmienić im piny i definicje które się ich tyczą. Przecież są to inne piny i z inną częstotliwością chcemy migać.

Gotowy kod naszego programu będzie wyglądał następująco:

/**
 ******************************************************************************
 * @file           : main.c
 * @author         : Mateusz Salamon
 * @brief          : STM32 na Rejestrach
 ******************************************************************************
 ******************************************************************************

 	 	 	 	 https://msalamon.pl
 	 	 	 	 https://sklep.msalamon.pl
 	 	 	 	 https://kursstm32.pl
 	 	 	 	 https://stm32narejestrach.pl

 */
#include "main.h"

// LEDs control macros
#define LD4_ON GPIOA->BSRR = GPIO_BSRR_BS5
#define LD4_OFF GPIOA->BSRR = GPIO_BSRR_BR5
#define LD4_TOGGLE GPIOA->ODR ^= GPIO_ODR_OD5

#define LD5_ON GPIOA->BSRR = GPIO_BSRR_BS6
#define LD5_OFF GPIOA->BSRR = GPIO_BSRR_BR6
#define LD5_TOGGLE GPIOA->ODR ^= GPIO_ODR_OD6

#define LD6_ON GPIOA->BSRR = GPIO_BSRR_BS7
#define LD6_OFF GPIOA->BSRR = GPIO_BSRR_BR7
#define LD6_TOGGLE GPIOA->ODR ^= GPIO_ODR_OD7

// Constants for Software Timer's actions
#define LD4_TIMER 500
#define LD5_TIMER 222
#define LD6_TIMER 147

// Tick for System Time
__IO uint32_t Tick;

// LEDs Configuration
void ConfigureLD4(void);
void ConfigureLD5(void);
void ConfigureLD6(void);

// Get current System Time
uint32_t GetSystemTick(void);

// Software Timers variables
uint32_t Timer_LD4;
uint32_t Timer_LD5;
uint32_t Timer_LD6;

int main(void)
{

	// 1s = 12 000 000
	// 0,001 = X
	SysTick_Config(12000000 / 1000);

	// Configure LEDs
	ConfigureLD4();
	ConfigureLD5();
	ConfigureLD6();

	// Software Timers - first fill
	Timer_LD4 = GetSystemTick();
	Timer_LD5 = GetSystemTick();
	Timer_LD6 = GetSystemTick();

    /* Loop forever */
	while(1)
	{
		// LD4
		if((GetSystemTick() - Timer_LD4) > LD4_TIMER) // Check if is time to make  action
		{
			Timer_LD4 = GetSystemTick(); // Refill action's timer
			LD4_TOGGLE; // ACTION!
		}

		// LD5
		if((GetSystemTick() - Timer_LD5) > LD5_TIMER)
		{
			Timer_LD5 = GetSystemTick();
			LD5_TOGGLE;
		}

		// LD6
		if((GetSystemTick() - Timer_LD6) > LD6_TIMER)
		{
			Timer_LD6 = GetSystemTick();
			LD6_TOGGLE;
		}


	}
}

void ConfigureLD4(void)
{
	// Enable Clock for PORTA
	RCC->IOPENR |= RCC_IOPENR_GPIOAEN;

	// Configure GPIO Mode - Output
	GPIOA->MODER |= GPIO_MODER_MODE5_0;
	GPIOA->MODER &= ~(GPIO_MODER_MODE5_1);

	// Configure Output Mode - Push-pull
	GPIOA->OTYPER &= ~(GPIO_OTYPER_OT5);

	// Configure GPIO Speed - Low
	GPIOA->OSPEEDR &= ~(GPIO_OSPEEDR_OSPEED5);

	// Configure Pull-up/Pull-down - no PU/PD
	GPIOA->PUPDR &= ~(GPIO_PUPDR_PUPD5);
}

void ConfigureLD5(void) // PA6
{
	// Enable Clock for PORTA
	RCC->IOPENR |= RCC_IOPENR_GPIOAEN;

	// Configure GPIO Mode - Output
	GPIOA->MODER |= GPIO_MODER_MODE6_0;
	GPIOA->MODER &= ~(GPIO_MODER_MODE6_1);

	// Configure Output Mode - Push-pull
	GPIOA->OTYPER &= ~(GPIO_OTYPER_OT6);

	// Configure GPIO Speed - Low
	GPIOA->OSPEEDR &= ~(GPIO_OSPEEDR_OSPEED6);

	// Configure Pull-up/Pull-down - no PU/PD
	GPIOA->PUPDR &= ~(GPIO_PUPDR_PUPD6);
}

void ConfigureLD6(void) // PA7
{
	// Enable Clock for PORTA
	RCC->IOPENR |= RCC_IOPENR_GPIOAEN;

	// Configure GPIO Mode - Output
	GPIOA->MODER |= GPIO_MODER_MODE7_0;
	GPIOA->MODER &= ~(GPIO_MODER_MODE7_1);

	// Configure Output Mode - Push-pull
	GPIOA->OTYPER &= ~(GPIO_OTYPER_OT7);

	// Configure GPIO Speed - Low
	GPIOA->OSPEEDR &= ~(GPIO_OSPEEDR_OSPEED7);

	// Configure Pull-up/Pull-down - no PU/PD
	GPIOA->PUPDR &= ~(GPIO_PUPDR_PUPD7);
}

void SysTick_Handler(void)
{
	Tick++; // Increase system timer
}

uint32_t GetSystemTick(void)
{
	return Tick; // Return current System Time
}



Jeśli mamy wszystko skopiowane, to możemy uruchamić program i zobaczyć czy to działa. To już oczywiście potrafisz 🙂

Podsumowanie

Timer Programowy to świetny sposób na realizację zadań w systemie embedded bez blokowania procesora. Działa na zasadzie sprawdzania upływającego czasu i wyzwalania akcji w odpowiednich momentach. Dzięki temu możemy stworzyć złudzenie wielozadaniowości i zapewnić płynne działanie wielu elementów systemu.

Podstawą działania jest SysTick Timer, który w tle zlicza czas w milisekundach. Wystarczy przechowywać „marker” czasu, porównywać go z aktualnym czasem systemowym i w odpowiednich momentach wyzwalać akcje, np. miganie diodą LED. Kluczowe jest odpowiednie przeładowanie znacznika czasu, co pozwala uniknąć błędów związanych z długim czasem wykonywania zadania.

Dzięki tej metodzie możemy niezależnie sterować wieloma diodami LED o różnych częstotliwościach migania, bez konieczności używania blokujących funkcji delay. Mechanizm ten jest uniwersalny i może być używany do sterowania dowolnymi cyklicznymi zadaniami – odczytami czujników, obsługą komunikacji czy odświeżaniem wyświetlacza.

W kolejnym wpisie zajmiemy się kolejnym elementem praktycznego programowania na rejestrach. Będzie to pierwsza komunikacja ze światem zewnętrznym. Dotkniemy interfejsu UART 🙂

Daj znać w komentarzu czy Ci się podobał ten wpis! Może masz jakąś propozycję co pokazać w ramach cyklu STM32 na Rejestrach? Podziel się tym artykułem ze znajomymi.

Zapraszam Cię również do mojego sklepu, gdzie kupisz ciekawe moduły do eksperymentów, w tym NUCLEO-C031C6, z którego korzystamy w tej serii:
🔗 https://sklep.msalamon.pl

Projekt z tego artykułu znajdziesz na:
📂 https://github.com/lamik/stm32narejestrach_5

Podobne artykuły

.

0 komentarzy

Dodaj komentarz

Symbol zastępczy awatara

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