Apostila de Programação com Arduino

9. FreeRTOS: Comunicação entre Tarefas

Nas atividades anteriores, as tarefas executavam de forma independente. No entanto, muitas vezes é desejável alguma comunicação entre elas. Nesta apostila, vamos considerar apenas duas situações: o compartilhamento de recursos e a sincronização entre tarefas.

9.1 Compartilhamento de recursos: exclusão mútua

Quando duas tarefas atualizam uma mesma variável (recurso), precisamos empregar uma técnica de exclusão mútua. O objetivo é assegurar que, uma vez que uma tarefa acesse um recurso compartilhado, esta tenha acesso exclusivo até concluir sua operação. Desse modo, é garantida a consistência dos dados.

Para entender a necessidade deste processo, considere o seguinte exemplo:

int global_var = 0;

void vTask( void *pvParameters )
{
  for( ;; )
  {
    global_var++;
  }
}

int main( void )
{
    /* --- APPLICATION TASKS CAN BE CREATED HERE --- */
    xTaskCreate( vTask, "Task 1", 1000, NULL, 1, NULL);
    xTaskCreate( vTask, "Task 2", 1000, NULL, 1, NULL);
 
    /* Start the created tasks running. */
    vTaskStartScheduler();
 
    /* Execution will only reach here if there was insufficient heap to
    start the scheduler. */
    for( ;; );
    return 0;
}

Com uma análise superficial, não é possível detectar nenhuma inconsistência no programa. No entanto, a seguinte instrução em C:

global_var++;

equivale a múltiplas instruções na CPU.

Vamos considerar que a operação incremento realiza os seguintes três passos:

  1. lê o valor da memória e armazena em um registrador (READ);
  2. incrementa o valor no registrador (INC); e
  3. escreve o valor do registrador na memória (WRITE).

Agora, imagine a seguinte sequência de operações das tarefas, que corresponde a duas iterações da tarefa 1 e uma da tarefa 2:

Tarefa operação registrador global_var
Task 1 READ 0 0
Task 1 INC 0 → 1 0
Task 1 WRITE 1 0 → 1
Task 2 READ 1 1
Task 1 READ 1 1
Task 1 INC 1 → 2 1
Task 1 WRITE 2 1 → 2
Task 2 INC 1 → 2 1
Task 2 WRITE 2 1 → 2

Como podemos observar, o valor final de global_var foi 2, quando deveria ser 3 após três incrementos. A solução passa por realizar o incremento como uma operação atômica, ou seja, que não pode ser interrompida.

Para solucionar este problema, podemos utilizar um mutex, que funciona como uma fechadura controlando o acesso ao recurso. Quando uma tarefa deseja acessar o recurso, ela "trava" o mutex, impossibilitando outras tarefas de acessá-lo.

No FreeRTOS, mutex podem ser criados com a função xSemaphoreCreateMutex, "travados" com a função xSemaphoreTake() e destravados com xSemaphoreGive(). Quando alguma outra tarefa deseja acessar um recurso travado por um mutex, ela entra no estado bloqueado até a liberação do mutex ou um timeout. Este procedimento é ilustrado na Fig.:

Assim, o programa anterior pode ser reescrito com:

#include <stdio.h>
#include <stdlib.h>

/* Kernel includes. */
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"

int global_var = 0;
SemaphoreHandle_t xMutex;

void vTask( void *pvParameters )
{
  const TickType_t xMaxBlockTimeTicks = 0x20;
  for( ;; )
  {
    xSemaphoreTake( xMutex, portMAX_DELAY ); // will block if mutex is already taken
    global_var++;
    printf("%d\n", global_var);
    xSemaphoreGive( xMutex );
    vTaskDelay( ( rand() % xMaxBlockTimeTicks ) );
  }
}

int main( void )
{
  xMutex = xSemaphoreCreateMutex();

  /* Check the semaphore was created successfully before creating the tasks. */
  if( xMutex != NULL ) {
    /* --- APPLICATION TASKS CAN BE CREATED HERE --- */
    xTaskCreate( vTask, "Task 1", 1000, NULL, 1, NULL);
    xTaskCreate( vTask, "Task 2", 1000, NULL, 1, NULL);

    /* Start the created tasks running. */
    vTaskStartScheduler();
  }
  
  /* Execution will only reach here if there was insufficient heap to
  start the scheduler. */
  for( ;; );
  return 0;
}

onde foi adicionado um delay aleatório para simular acessos não regulares ao recurso.

9.2 Sincronização entre tarefas com semáforo binário

O semáforo binário é bastante similar ao mutex, pois possui dois estados e faz o bloqueio da tarefa caso não consiga obtê-lo.

No FreeRTOS, o semáforo binário é utilizado para sincronizar tarefas. Neste caso, a tarefa a ser executada fica em estado bloqueado esperando o semáforo. Outra tarefa é encarregada de "fornecer" o semáforo, sinalizando para aquela que deve iniciar o processamento. É mais fácil compreender esta situação a partir da Fig.:

O programa abaixo contém um exemplo de uso de um semáforo binário para sincronização:

#include <stdio.h>
/* Kernel includes. */
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"

#define BEEP_COUNT 5
SemaphoreHandle_t xBinarySemaphore;

void vTask1( void *pvParameters )
{
  volatile char buf[10];
  for( ;; )
  {
    buf[0] = '\0';
    scanf("%s", buf);
    if(buf[0]=='b')
      xSemaphoreGive(xBinarySemaphore);
  }
}

void vTask2( void *pvParameters )
{
  volatile uint32_t ul;
  const TickType_t xDelay1000ms = pdMS_TO_TICKS(1000);
  
  for( ;; )
  {
    xSemaphoreTake(xBinarySemaphore, portMAX_DELAY);
    for(ul = 0; ul < BEEP_COUNT; ul++) {
      printf("Beep!\n");
      vTaskDelay(xDelay1000ms);
    }
  }
}

int main( void )
{
  xBinarySemaphore = xSemaphoreCreateBinary();
  
  if(xBinarySemaphore != NULL) {
    /* --- APPLICATION TASKS CAN BE CREATED HERE --- */
    xTaskCreate( vTask1, "Task 1", 1000, NULL, 1, NULL);
    xTaskCreate( vTask2, "Task 2", 1000, NULL, 2, NULL);
   
    /* Start the created tasks running. */
    vTaskStartScheduler();
  }
 
  /* Execution will only reach here if there was insufficient heap to
  start the scheduler. */
  for( ;; );
  return 0;
}

A tarefa 1 espera por uma entrada de teclado, enquanto a tarefa 2 fica em estado bloqueado. Caso seja digitado b, o semáforo é sinalizado e, com isso, a tarefa 2 sai do bloqueio e entra em execução, pois possui prioridade mais alta. Após a execução, a tarefa 2 se bloqueia novamente e a tarefa contínua 1 entra em execução.

9.3 Criando uma fila de eventos para a aplicação Alarme

A fila do FreeRTOS permite combinar a sincronização com o compartilhamento de recursos. O seu funcionamento é similar ao de uma fila FIFO (first in, first out) tradicional.

Em geral, uma (ou mais) tarefa(s) é(são) responsável(is) por inserir itens no final da fila e outra(s) por remover itens do início dela. Assim como no caso do semáforo binário, a tarefa pode esperar em estado bloqueado caso não exista item na fila.

Vamos adaptar nossa aplicação alarme para utilizar uma fila de eventos para a máquina de estados. Para declarar a fila, primeiro precisamos incluir os headers apropriados e criar um handler do tipo QueueHandle_t com:

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"

QueueHandle_t xQueue;

Serão definidas duas tarefas: a primeira gera os eventos e põe na fila, e a segunda é a máquina de estados que consome e processa esses eventos da fila. A primeira tarefa pode ser escrita como:

void taskObterEvento(void *pvParameters) {
  int codigoEvento;
  BaseType_t xStatus;

  for( ;; ) {
    codigoEvento = NENHUM_EVENTO;

    teclas = ihm.obterTeclas();
    if (decodificarAcionar()) {
      codigoEvento = ACIONAR;
      xStatus = xQueueSendToBack( xQueue, &codigoEvento, 0 );
      if( xStatus != pdPASS )
        Serial.println("Erro ao enviar evento para fila");
      continue;
    }
    if (decodificarDesacionar()) {
      codigoEvento = DESACIONAR;
      xStatus = xQueueSendToBack( xQueue, &codigoEvento, 0 );
      if( xStatus != pdPASS )
        Serial.println("Erro ao enviar evento para fila");
      continue;
    }
    if (decodificarTimeout()) {
      codigoEvento = TIMEOUT;
      xStatus = xQueueSendToBack( xQueue, &codigoEvento, 0 );
      if( xStatus != pdPASS )
        Serial.println("Erro ao enviar evento para fila");
      continue;
    }
    if (decodificarDisparar()) {
      codigoEvento = DISPARAR;
      xStatus = xQueueSendToBack( xQueue, &codigoEvento, 0 );
      if( xStatus != pdPASS )
        Serial.println("Erro ao enviar evento para fila");
      continue;
    }
  }
}

onde a função xQueueSendToBack é usada para enviar o evento para a fila.

Já a tarefa de máquina de estados é alterada para:

void taskMaqEstados(void *pvParameters) {
  int codigoEvento;

  for( ;; ) {
    if( xQueueReceive( xQueue, &codigoEvento, portMAX_DELAY ) == pdPASS ) {
      if (codigoEvento != NENHUM_EVENTO)
      {
        codigoAcao = obterAcao(estado, codigoEvento);
        estado = obterProximoEstado(estado, codigoEvento);
        executarAcao(codigoAcao);
        printf("Estado: %d Evento: %d Acao:%d\n", estado, codigoEvento, codigoAcao);
      }
    }
    else {
      printf("Erro ao receber evento da fila\n");
    }
  }
}

Por fim, devemos inicializar a fila e as tarefas no main():

int main() {
  iniciaSistema();
  printf ("Alarme iniciado\n");

  // configure tasks
  xQueue = xQueueCreate(5, sizeof(int));
  if(xQueue != NULL)
  {
    xTaskCreate(taskMaqEstados,"taskMaqEstados", 1500, NULL, 2, &xTaskMaqEstados);
    xTaskCreate(taskObterEvento,"taskObterEvento", 1000, NULL, 1, &xTaskObterEvento);
    vTaskStartScheduler();
  }
  else
  {
    /* The queue could not be created. */
  }
} // main

Note que, como a prioridade da tarefa taskMaqEstados é a maior, a fila nunca terá mais de um elemento. Isto porque, uma vez inserido algum evento nela, a tarefa da máquina de estados é imediatamente desbloqueada e executada, consumindo o evento. Depois de processar o evento, a tarefa prioritária entra em estado de bloqueio e a taskObterEvento volta a executar.

A aplicação final do Alarme no FreeRTOS pode ser consultada em

https://gitlab.uspdigital.usp.br/andre.kubagawa/alarme_arduino/-/tree/master/alarme_freertos

Bibliografia para este capítulo