Apostila de Programação com Arduino

6. Programando com tarefas: aplicação Alarme com escalonamento estático periódico

A programação com tarefas consiste em separar o código em blocos de processamento com diferentes funcionalidades. Esta é uma abordagem bastante utilizada para programar sistema de tempo real, pois permite estabelecer requisitos temporais às tarefas. Em sistemas operacionais de tempo real (Real Time Operating Systems ou RTOS), tais restrições são impostas por um algoritmo de escalonamento.

Mesmo em sistemas sem requisitos temporais críticos, a estruturação com tarefas pode melhorar a organização do código e facilitar a identificação de bugs. Este último é especialmente importante no caso da existência de rotinas com periodicidade e/ou processamento de interrupções, pois ambas possuem características temporais importantes.

6.1 Programação com "super loop"

Vamos analisar a nossa aplicação Alarme para identificar possíveis tarefas. No geral, os programas de Arduino utilizam a estrutura "super loop", que significa que todas as tarefas são processadas dentro da função loop(), como mostrado na Fig. abaixo.

Cada uma das tarefas listadas como exemplos são responsáveis por funcionalidades distintas do sistema. À parte de alguns pontos de sincronização, elas poderiam ser executadas em paralelo. No entanto, isso não é sempre possível em sistemas embarcados, além de introduzir mais complexidade no código.

6.2 Divisão em tarefas

Na realidade, a separação entre tarefas não é tão clara como o exemplo da Fig. anterior. Se considerarmos duas tarefas, por exemplo:

  1. processamento da máquina de estados; e
  2. obtenção dos eventos (entrada da porta serial timeout);

notamos que cada uma está em um local bastante diferente do código.

Para separar as tasks, vamos criar novas funções para cada uma. A máquina de estados pode ser escrita na função taskMaqEstados(), com o código:

void taskMaqEstados() {
  for(;;) {
    if (eventoInterno != NENHUM_EVENTO) {
        codigoEvento = eventoInterno;
    }
    if (codigoEvento != NENHUM_EVENTO)
    {
        codigoAcao = obterAcao(estado, codigoEvento);
        estado = obterProximoEstado(estado, codigoEvento);
        eventoInterno = executarAcao(codigoAcao);
        Serial.print("Estado: ");
        Serial.print(estado);
        Serial.print(" Evento: ");
        Serial.print(codigoEvento);
        Serial.print(" Acao: ");
        Serial.println(codigoAcao);
    }
  }
}

onde removemos a instrução codigoEvento = obterEvento();.

Já a obtenção de eventos pode ser implementada com a função taskObterEvento, com:

void taskObterEvento() {
  for(;;) {
    codigoEvento = NENHUM_EVENTO;

    teclas = ihm.obterTeclas();
    if (decodificarAcionar()) {
      codigoEvento = ACIONAR;
      return;
    }
    if (decodificarDesacionar()) {
      codigoEvento = DESACIONAR;
      return;
    }
    if (decodificarTimeout()) {
      codigoEvento = TIMEOUT;
      return;
    }
    if (decodificarDisparar()) {
      codigoEvento = DISPARAR;
      return;
    }
  }
}

Se as duas tarefas pudessem ser executadas em paralelo, a aplicação poderia funcionar de modo similar à original. Porém, como esse não é sempre o caso, temos que distribuir a execução de cada tarefa de modo a "simular" este paralelismo.

Uma forma simples de se solucionar esse problema é transformar cada função em um tarefa periódica, isto é, que executa a cada X milissegundos, como mostrado na Fig.:

Se a periodicidade for alta, ou seja, o intervalo entre execuções for muito curto, a alternância de tarefas é muito rápida e, assim, aparenta estar executando concorrentemente.

6.3 Implementando um algoritmo de escalonamento periódico: configurando a interrupção tick

Em geral, algoritmos de escalonamento dividem o tempo em intervalos discretos, chamados de ticks. Assim, todos os requisitos devem ser reescritos em unidades de ticks, que devem ser pequenos, uma vez que delimitam a resolução do nosso escalonamento. Vamos implementar a contagem de ticks utilizando uma interrupção gerada pelo timer do Arduino:

void setupTickInterrupt(long uSecs) {
  noInterrupts();           // disable all interrupts

  TCCR1A = 0;
  TCCR1B = 0;
  TCNT1  = 0;

  // compare match register 16MHz/256 * t(s) - 1
  OCR1A = (16e6 / 256L * uSecs) / 1e6 - 1;            

  TCCR1B |= (1 << WGM12);   // CTC mode
  TCCR1B |= (1 << CS12);    // 256 prescaler 
  TIMSK1 |= (1 << OCIE1A);  // enable timer compare interrupt

  interrupts();             // enable all interrupts
}

Como pode ser visto, a configuração da interrupção é feita manipulando os bits do registrador, cuja explicação está fora do escopo desta disciplina. O importante é compreender que, uma vez chamada a partir do setup(), esta função vai configurar uma interrupção que ocorre a cada uSecs microssegundos; este será o intervalo do nosso tick.

6.4 Implementando um algoritmo de escalonamento periódico: criando a estrutura de dados do descritor de tarefa

Vamos implementar o nosso escalonador na classe TaskSwitcher, que será instanciada como uma biblioteca de Arduino. As declarações da classe e do struct auxiliar estão incluídas no arquivo task_switcher.h:

#ifndef TASK_SWITCHER_H_INCLUDED
#define TASK_SWITCHER_H_INCLUDED

#define MAX_TASKS 3

typedef struct {
void (*task)();
long     interval;
long     current_time;
int      status;
} TaskControl;

class TaskSwitcher {
    public:
    TaskSwitcher();
    void begin(long timerInterruptInuSecs);
    void createTask(void (*t)(), long interval);
    void runCurrentTask();
    void updateTickCounter();

    private:
    TaskControl taskList[MAX_TASKS];
    int taskCount;
};

extern TaskSwitcher TaskController;

#endif // TASK_SWITCHER_H_INCLUDED

Note que a função begin contém o código de configuração de interrupções de tick descrita na seção anterior. [comment]: # (Contém o código ou é a própria função definida anteriormente? Não está muito claro.)

Quanto ao struct TaskControl, este é o descritor usado para armazenar os dados da tarefa, que contém:

  • um ponteiro para a função (p. ex.: taskObterEvento());
  • o intervalo de execução da tarefa(período);
  • e duas variáveis para controle do escalonador (status e current_time).

Por fim, o membro taskList armazena os descritores de todas as tarefas, que são criados e adicionados com o método createTask.

6.5 Implementando um algoritmo de escalonamento periódico: processamento da interrupção

Para entender o funcionamento do escalonador, vamos observar o que ocorre dentro da função da interrupção:

TaskSwitcher TaskController;

ISR(TIMER1_COMPA_vect) {
   TaskController.updateTickCounter();
}

void TaskSwitcher::updateTickCounter() {
  int i;
  for (i=0; i< taskCount; i++) {
    if (taskList[i].status == WAIT) {
      taskList[i].current_time++;
      if (taskList[i].current_time >= taskList[i].interval) {
        taskList[i].status = READY;
      }
    } // if task is WAITing
  } // for each task
}

Basicamente, para cada tarefa, o valor de current_time é incrementado e o status da tarefa pode ser atualizado (de WAIT para READY).

E também vamos investigar a função runCurrentTask(), que deve ser chamada no laço infinito do programa Arduino:

void TaskSwitcher::runCurrentTask() {
  int i;
  void (*task)();
  for (i=0; i<  taskCount; i++) {
    if (taskList[i].status == READY) {
      noInterrupts();
      taskList[i].status = WAIT;
      taskList[i].current_time = 0;
      interrupts();
      task = taskList[i].task;
      (*task)();
    } // if task is READY
  } //for each task
}

Aqui cada tarefa em READY é executada (na instrução (*task)();) e o seu estado é atualizado para WAIT.

Explicação do funcionamento do TaskSwitcher:

Cada tarefa implementa a seguinte máquina de estados:

O current_time mede o tempo (em unidades de tick) desde a última execução (transição de READY para WAIT). Assim, quando é detectado que passou o tempo do intervalo da tarefa, o seu estado é modificado para pronta para execução (READY). A execução de fato da tarefa é feita na função runCurrentTask(), que, ao agendar tarefa, a move novamente para o estado de espera (WAIT).

6.6 Implementado um algoritmo de escalonamento periódico: aplicação Alarme

Para utilizar a biblioteca TaskSwitcher, precisamos modificar apenas o arquivo alarme.ino.

Após incluir o header task_switcher.h, vamos criar as funções da tarefa:

void taskMaqEstados() {
  if (eventoInterno != NENHUM_EVENTO) {
      codigoEvento = eventoInterno;
  }
  if (codigoEvento != NENHUM_EVENTO)
  {
      codigoAcao = obterAcao(estado, codigoEvento);
      estado = obterProximoEstado(estado, codigoEvento);
      eventoInterno = executarAcao(codigoAcao);
      Serial.print("Estado: ");
      Serial.print(estado);
      Serial.print(" Evento: ");
      Serial.print(codigoEvento);
      Serial.print(" Acao: ");
      Serial.println(codigoAcao);
  }


void taskObterEvento() {
  codigoEvento = NENHUM_EVENTO;

  teclas = ihm.obterTeclas();
  if (decodificarAcionar()) {
    codigoEvento = ACIONAR;
    return;
  }
  if (decodificarDesacionar()) {
    codigoEvento = DESACIONAR;
    return;
  }
  if (decodificarTimeout()) {
    codigoEvento = TIMEOUT;
    return;
  }
  if (decodificarDisparar()) {
    codigoEvento = DISPARAR;
    return;
  }

Note que, diferente das funções descritas na seção 6.2, as tarefas não são implementadas em loop infinito. Elas foram adaptadas para se adequarem ao nosso algoritmo de escalonamento, que não tem preempção, ou seja, não consegue interromper a execução de uma tarefa.

O próximo passo é criar as tarefas na função setup():

void setup() {
  Serial.begin(9600);

  // configure tasks
  TaskController.createTask(&taskMaqEstados, 500);
  TaskController.createTask(&taskObterEvento, 500);
  
  // set up timer interrupt 
  TaskController.begin(1000); // tick @1ms (1000 us)

  iniciaSistema();
  Serial.println("Alarme iniciado");
} // setup

onde foram criadas duas tarefas periódicas com intervalo de 500ms (500 * 1000 microssegundos).

Agora só falta chamar o métodorunCurrentTask a partir da função loop():

void loop() {
  TaskController.runCurrentTask();
} // loop

Ao executar, o comportamento temporal do processamento das tasks deve seguir de forma aproximada o padrão da Fig.:

No caso de dúvidas, o código final da aplicação pode ser consultado em

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

Bibliografia para este capítulo