Kurs esp32/esp8266 | #5 – freeRTOS

freertos | esp-idf

Programując mikrokontrolery esp32/esp8266 w środowiskach dostarczanych przez producenta, prędzej czy później będziesz musiał się zaznajomić z tematem freertos. RTOS (Real Time Operating System), to taki jakby system operacyjny dla mikrokontrolerów. Z jego pomocą możemy tworzyć zadania (taski), kolejkować je, ustawiać im różne priorytety itp.

W dużym skrócie freertos dodaje naszemu układowi wielozadaniowość, choć tak naprawdę to po prostu cały czas, bardzo szybko przełącza się po wszystkich taskach.

W środowiskach espressif rtos jest standardem, jednak możesz się obyć bez jego używania. Pytanie, czy warto? Większość przykładów programów, czy to producenta, czy społeczności wykorzystuje właśnie rtosa – możesz mieć problem z ich rozumieniem bez podstawowej znajomości freertos. Ponadto, kiedy zaczniesz robić bardziej skomplikowane projekty, zauważysz jak nieoceniony jest tutaj rtos.

Poniższy tekst jest tylko pobieżnym wprowadzniem do tematu freertos’a na potrzeby kursu. Wszystkie przykłady tutaj odnosić się będą tylko do mikrokontrolerów esp. Radzę ci zapoznać się z dedykowanymi kursami dotyczącymi freertos’a dla lepszego zrozumienia omawianego tematu.

Freertos w naszych środowiskach jest nieco zmodyfikowany na potrzeby mikrokontrolerów esp.

Przykłady będą się opierać na miganiu diodami, oraz wysyłaniu tekstu po uart’cie.


Zadania – tasks

Zadania (tasks) to po prostu funkcje, które system będzie traktował jak osobne programy, między którymi będzie się przełączał. Poniżej przykład jak wygląda typowe zadanie (miganie diodą).

Void zadanie1(void *arg)
{
    while (1)
    {
        gpio_set_level(16, 0);
        vTaskDelay(500 / portTICK_RATE_MS);
        gpio_set_level(16, 1);
        vTaskDelay(500 / portTICK_RATE_MS);
    }
}

Jak możesz zauważyć w funkcji tej znajduje się nieskończona pętla, co raczej nie jest typowe dla programów na mikrokontrolery. O to jednak chodzi w rtos’ie, zadania mają być jakby osobnymi programami, między którymi system będzie się przełączał. I takich nieskończonych pętli może działać w jednym momencie więcej. Poza tym, przekazujemy argument typu void *, czyli dowolnego typu. Jako delay w taskach korzystamy z funkcji „vTaskDelay”.

Po zdefiniowaniu zadania musimy je utworzyć za pomocą polecenia „xTaskCreate” w funkcji app_main:

xTaskCreate( zadanie1, "zad1", 256,  NULL,  10, NULL);

Przekazujemy jej nazwę funkcji naszego zadania, nazwę zadania – tutaj dowolnie, używana jest tylko do debugowania. Kolejno podajemy wielkość stosu pamięci jaki przypisujemy zadaniu, argumenty (w moim przypadku NULL), priorytet i na końcu uchwyt (handler) zadania.

Temu zadaniu przypisałem 256 bajtów pamięci dla stosu, stos jest używany do przechowywania informacji o zadaniu oraz zmiennych, które w nim deklarujemy. Warto dbać o to by przydzielać zadaniom tyle pamięci ile potrzebują.

Do prostego sprawdzenia ile wolnego stosu nam pozostaje w zadaniu, możemy użyć polecenia:

uxTaskGetStackHighWaterMark(NULL);

Przykładowa implementacja:

void zadanie1(void *arg)
{
    while (1)
    {
        gpio_set_level(16, 0);
        vTaskDelay(500 / portTICK_RATE_MS);
        gpio_set_level(16, 1);
	 
        vTaskDelay(500 / portTICK_RATE_MS);
        uint16_t dat = uxTaskGetStackHighWaterMark(NULL);
        ets_printf("stack: %d\n\r",dat);
    }
}

Po wgraniu programu działa on tak jak oczekiwałem. Dioda podłączona do portu GPIO16 miga z częstotliwością 0.5Hz, a w podglądzie portu szeregowego wyświetla się tekst „stack: 48” (pozostała pamięć z przydzielonego stosu). Więc wiem, że mogę trochę zmniejszyć przydzielony zadaniu stos.

Cały program:

#include <stdlib.h>
#include "driver/gpio.h"
#include "driver/uart.h"

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

void zadanie1(void *arg)
{
    while (1)
    {
		gpio_set_level(16, 0);
        vTaskDelay(500 / portTICK_RATE_MS);
        gpio_set_level(16, 1);
	    vTaskDelay(500 / portTICK_RATE_MS);
	    uint16_t dat = uxTaskGetStackHighWaterMark(NULL);
	    ets_printf("stack: %d\n\r",dat);
    }
}


void app_main(void)
{
 gpio_config_t io_conf;
    io_conf.intr_type = GPIO_INTR_DISABLE;
    io_conf.mode = GPIO_MODE_OUTPUT;
    io_conf.pin_bit_mask = ((1<<16) | (1<<0));
    io_conf.pull_down_en = 0;
    io_conf.pull_up_en = 0;
    gpio_config(&io_conf);
    
    uart_config_t uart_config = {
        .baud_rate = 115200,
        .data_bits = UART_DATA_8_BITS,
        .parity    = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE
    };
    
    uart_driver_install(UART_NUM_0, 1024 * 2, 0, 0, NULL, 0);
    uart_param_config(UART_NUM_0, &uart_config);
  
    xTaskCreate(zadanie1, "zad1", 256, NULL, 1, NULL);
}

Zwróć uwagę, że w naszym kodzie na ma pętli głównej. Jest to normalne w przypadku programów wykorzystujących RTOS’a.


Priorytety

Jak wspomniałem, zadaniom możemy przydzielać priorytety, dzięki którym określimy jakie zadania są dla nas istotniejsze i mają się wykonywać jako pierwsze. Dodam więc do naszego programu dwa kolejne zadania wyświetlające tekst na terminalu.

void zadanie2(void *arg)
{
    while(1)
    {
        ets_printf("zadanie2\n\r");
        vTaskDelay(1000 / portTICK_RATE_MS);
    }
    vTaskDelete(NULL);	
}

void zadanie3(void *arg)
{
    while(1)
    {
        ets_printf("zadanie3\n\r");
        vTaskDelay(1000 / portTICK_RATE_MS);
    }
    vTaskDelete(NULL);	
}

Ale utworzę je w taki sposób:

xTaskCreate(zadanie2, "zad2", 200, NULL, 1, NULL);
xTaskCreate(zadanie3, "zad3", 200, NULL, 2, NULL);

Jak widać pierwsze tworzymy zadanie2, jednak ma ono niższy priorytet niżeli zadanie3. W efekcie po otworzeniu podglądu portu szeregowego jako pierwszy ujrzymy tekst „zadanie3”. Myślę, że nie trzeba tutaj więcej tłumaczyć. Po prostu – funckja z wyższym priorytetem wykona się wcześniej.


Argumenty zadań freertos

Do zadań możemy przekazywać argumenty dowolnego typu, robimy to w funkcji xTaskCreate(). Zmodyfikuję więc nasze zadanie2, by zamiast tekstu „zadanie2” wyświetlał tekst mu przekazany jako argument.

TaskCreate(zadanie2, "zad2", 200, (void *)”argument”, 1, NULL);

Oczywiście jako argument możemy takżę podać zmienną np…

char tekst[] = ‘’argument’’.
xTaskCreate(zadanie2, "zad2", 200, (void *)tekst, 1, NULL);

Musimy więc naszą zmienną przekazać do zadania zrzutowaną na typ „void *”, ponieważ taką zdefiniowaliśmy przy tworzeniu funkcji zadanie2. Następnie w funkcji zadanie2 rzutujemy ją z powrotem na typ „char *”. Tak modufykujemy zadanie2:

void zadanie2(void *arg)
{
    while(1)
    {
        ets_printf("%s\n\r", (char *)arg);
        vTaskDelay(1000 / portTICK_RATE_MS);
    }
    vTaskDelete(NULL);	
}

Teraz zadanie będzie wysyłać na terminal tekst „argument”.


Task handler | uchwyt zadania freertos

Handler/Uchwyt służy nam do obsługi zadań, z jego pomocą możemy usuwać, wstrzymywać i wznawiać zadania.

Każde z zadań możemy wstrzymac, lub usunąć bez potrzeby definiowania uchwytu, ale tylko wewnątrz tego zadania, przykładowo…

Do naszego programu dodamy zmienną counter, która będzie inkrementowana przez zadanie1 o 1. Teraz sprawmy by zadanie1 zostało usunięte gdy counter osiągnie wartość 5.

void zadanie1(void *arg)
{
    while (1)
    {
	counter++;
	if (counter == 5) vTaskDelete(NULL);
        gpio_set_level(16, 0);
        vTaskDelay(500 / portTICK_RATE_MS);
        gpio_set_level(16, 1);
	vTaskDelay(500 / portTICK_RATE_MS);    
    }
}

Jak widać dodałem tutaj polecenie vTaskDelete(), wprowadzając za jego argument NULL spowoduje tym, że usunięte zostanie właśnie to zadanie – zadanie1. Ale załóżmy że chcemy tylko wstrzymać działanie tego zadania na jakiś czas. W tym celu funkcje vTaskDelete(NULL), zastąpimy vTaskSuspend(NULL).

Na pierwszy rzut oka wynik będzie ten sam – w pewnym momencie dioda przestanie migać. Jednak używając tej funkcji (vTaskSuspend) tylko wstrzymaliśmy zadanie, a nie je usunęliśmy. W takim razie, zawsze możemy je wznowić prawda? Tak, jednak musimy coś jeszcze zmienić w naszym programie – przypisać uchwyt naszemu zadaniu. Teraz nie mamy możliwości odwołania się do tego zadania, uchwytu nie musi być jeżeli chcemy wyłączyć/wstrzymać zadanie na którym aktualnie pracuje nasz program. Inaczej jest jeśli chcemy to zrobić z innego miejsca programu, co właśnie będziemy robić.

Zmodyfikuję teraz program tak by zadanie2 wznowiło działanie zadania1 po odczekaniu 5 sekund.

Najpierw trzeba zdefiniować uchwyt zadania1 w sposób następujący:

TaskHandle_t HandleZad1 = NULL;

A kolejno zmodyfikować funkcje tworzącą zadanie:

xTaskCreate(zadanie1, "zad1", 256, NULL, 1, &HandleZad1);

W zadaniu 1 zmieniamy polecenie vTaskDelete(NULL), na vTaskSuspend(HandleZad1), czyli wstrzymanie zadania, które obsługuje uchwyt „HandleZad1”.

Do zadania 2 natomiast dodajemy taki zestaw poleceń:

if (counter >= 5)
{
    counter++;
    if (counter == 10)
    {
        counter = 0;
        vTaskResume(HandleZad1);	
    }	
}

Jak widać, w momencie kiedy zmienna counter osiągnię wartość równą/większa od 5, zadanie2 zacznie inkrementować zmienną, a gdy osiągnie wartość 10 to wyzeruje counter i wznowi zadanie1. Powyższy kod to tylko przykład wykorzystania uchwytów, bardziej optymalne byłoby umieszczenie całego kodu dotyczącego inkrementacji zmiennej i wstrzymania/wznawiania zadania1 w funkcji zadanie2.

Cały program:

#include <stdlib.h>
#include "driver/gpio.h"
#include "driver/uart.h"

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"


TaskHandle_t HandleZad1 = NULL;
uint8_t counter=0;

void zadanie1(void *arg)
{
    while (1)
    {
	counter++;
	if (counter == 5) vTaskSuspend(NULL);
        gpio_set_level(16, 0);
        vTaskDelay(500 / portTICK_RATE_MS);
        gpio_set_level(16, 1);
	vTaskDelay(500 / portTICK_RATE_MS);    
    }
}

void zadanie2(void *arg)
{
    while(1)
    {
        if (counter >= 5)
        {
            counter++;
            if (counter == 10)
            {
                 counter = 0;
                 vTaskResume(HandleZad1);	
            }	
        }
        ets_printf("%s\n\r", (char *)arg);
        vTaskDelay(1000 / portTICK_RATE_MS);
    }
}

void zadanie3(void *arg)
{
    while(1)
    {
        ets_printf("zadanie3\n\r");
        vTaskDelay(1000 / portTICK_RATE_MS);
    }
}


void app_main(void)
{
    gpio_config_t io_conf;
    io_conf.intr_type = GPIO_INTR_DISABLE;
    io_conf.mode = GPIO_MODE_OUTPUT;
    io_conf.pin_bit_mask = ((1<<16) | (1<<0));
    io_conf.pull_down_en = 0;
    io_conf.pull_up_en = 0;
    gpio_config(&io_conf);
    
    uart_config_t uart_config = {
        .baud_rate = 115200,
        .data_bits = UART_DATA_8_BITS,
        .parity    = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE
    };
    
    uart_driver_install(UART_NUM_0, 1024 * 2, 0, 0, NULL, 0);
    uart_param_config(UART_NUM_0, &uart_config);
  
    xTaskCreate(zadanie1, "zad1", 256, NULL, 1, &HandleZad1);
    xTaskCreate(zadanie2, "zad2", 200, (void *)"argument", 1, NULL);
    xTaskCreate(zadanie3, "zad3", 200, NULL, 2, NULL);
}

Załaduj sobie powyższy kod do mikrokontrolera, podłącz diody pod zdefiniowane wyprowadzenia i obserwuj ich zachowanie. Jak można zauważyć obsługa freeRTOS’a jest całkiem prosta i myślę, że po przeczytaniu tej części powinieneś opanować jego podstawy. Oczywiście wypróbuj jego funkcje także we własnych programach.


Kolejna część: #6 – ADC

Kurs esp32 i esp8266 – spis treści

Dodaj komentarz

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