Padrões de design para comunicação interprocessos em software embarcado (2/3)

«Primeira parte»

4.3. Queuing Pattern

Quando tasks têm baixo acomplamento temporal, i.e., virtualmente1 o tempo em que um processo consumidor leva para resgatar o resultado de um processo produtor não é condição para diferenciar falha de sucesso, o padrão de Message Queueing é bastante comum.

O processo produtor envia uma mensagem a uma fila (um buffer, normalmente circular) e algum tempo depois o processo consumidor resgata esta mensagem para seguir adiante. A mensagem pode ser desde um simples booleano ou qualquer outra estrutura mais complexa.

1 isto não significa que o sistema não tenha condições temporais. Significa somente que determinadas tasks não têm relação temporal rígida entre si naquele ponto de sincronização (coordenação).

Figura 10. Message Queue para coordenar processos [2]
Figura 11. Generalização do padrão Queuing em UML

4.3.1. Rationale

Se os processos são fracamente acoplados no tempo, podemos coordená-los (ou sincronizá-los) com um mecanismo assíncrono: um processo envia a mensagem e o outro lê “quando puder”.

4.3.2 Elementos

Na Figura 11, diversas tarefas QTask compartilham uma única MessageQueue. Um Mutex agregado a esta fila de mensagens, coordena o acesso de cada tarefa à fila de mensagens. A fila tem QUEUE_SIZE elementos do tipo Message, que podem ser qualquer dado/comando – desde um simples booleano até uma PDU do tipo CAN, por exemplo. Este tamanho deve comportar o pior caso para filas no sistema1. Poderíamos dispensar o Mutex se agregássemos a cada processo a sua própria caixa de mensagens, obviamente ao custo de utilizarmos mais memória.

1 Um dos métodos empregados para calcular o tamanho da fila é o Teorema de Little

4.3.3. Implementação

No que diz respeito à complexidade, este tipo de mecanismo de IPC é relativamente simples, mas existem muitas variantes. Algumas mensagens podem ser mais prioritárias que outras, em sistemas complexos o tamanho da fila é difícil de ser optimizado e algumas implementações fazem uso de alocação dinâmica (o que em embarcados, é controverso) ou até mesmo guardam mensagens pouco prioritárias no sistema de arquivos [1]. A utilização de linked lists para implementação também é uma abordagem que conta com a flexibilidade do próximo item a ser lido ser passado por referência, e com esta flexibilidade também os potenciais problemas que o extensivo uso de ponteiros trás.

Neste artigo um scheduler cooperativo manejava as tarefas em fila, onde aquelas mais urgentes (baseados na deadline) eram colocadas no início da fila. Fundamentalmente isto é um mecanismo de comunicação interprocessos do padrão queueing: a fila coordena o disparo dos processos que concorrem para utilizar um recurso: o microprocessador.

4.3.4. Exemplo

A Figura 12 mostra um sistema, em que sensores de determinados gases compartilham um queue para registrar os valores medidos na estrutura de dados GasData. Um GasDataQueue tem GAS_QUEUE_SIZE elementos do tipo GasData. Este por sua vez contém um enum GAS_TYPE.

Figura 12. Queing Example [1]

O programa GasDataQueue.c é o núcleo deste design pattern, com os métodos para incluir novos dados na fila e remover os antigos (este código mostra uma boa implementação de buffer circular, um workhorse em sistemas embebidos). A Figura 12 nos diz que duas threads, GasProcessingThread e SensorThread, compartilham o recurso GasDataQueue, como consumidor e provedor respectivamente.

A SensorThread atua provendo os dados à fila: atualiza os dados recebidos pelos sensores e os concatena para corretamente alocá-los em uma das estruturas GasData de GasDataQueue. Este processo de alocar dados de um objeto em uma outra estrutura, para transmiti-los, representá-los e/ou armazená-los de forma significativa, em computação é conhecido como marshalling.

Do lado consumidor, a thread GasProcessingThread periodicamente resgata dados da fila. No exemplo em questão, os dados estão somente sendo impressos na tela de um computador.

Lista 5. Implementação em C do modelo descrito na Figura 12

/*********************************
@file GasDataExample.h
*********************************/
#ifndef QueuingExample_H
#define QueuingExample_H
struct GasController;
struct GasData;
struct GasDataQueue;
struct GasDisplay;
struct GasProcessingThread;
struct HeSensor;
struct N2Sensor;
struct O2Sensor;
struct OSSemaphore;
struct SensorThread;
typedef enum GAS_TYPE {
 O2_GAS,
 N2_GAS,
 HE_GAS,
 UNKNOWN_GAS
} GAS_TYPE;
/* define the size of the queue */
#define GAS_QUEUE_SIZE (10)
/* OS semaphore services */
struct OSSemaphore* OS_create_semaphore(void);
void OS_destroy_semaphore(struct OSSemaphore* sPtr);
void OS_lock_semaphore(struct OSSemaphore* sPtr);
void OS_release_semaphore(struct OSSemaphore* sPtr);
#endif

/*********************************
@file GasData.h
*********************************/
#ifndef GasData_H
#define GasData_H
#include "QueuingExample.h"
typedef struct GasData GasData;
struct GasData {
double conc;
unsigned int flowInCCPerMin;
GAS_TYPE gType;
};
/* Constructors and destructors:*/
void GasData_Init(GasData* const me);
void GasData_Cleanup(GasData* const me);
GasData * GasData_Create(void);
void GasData_Destroy(GasData* const me);
#endif

//EOF
/*********************************
@file GasDataQueue.h
*********************************/
#ifndef GasDataQueue_H
#define GasDataQueue_H
#include "QueuingExample.h"
#include "GasData.h"
#include "OSSemaphore.h"
typedef struct GasDataQueue GasDataQueue;
struct GasDataQueue {
  int head;
  OSSemaphore * sema;
  int size;
  int tail;
  struct GasData itsGasData[GAS_QUEUE_SIZE];
};
/* Constructors and destructors:*/
void GasDataQueue_Init(GasDataQueue* const me);
void GasDataQueue_Cleanup(GasDataQueue* const me);
/* Operations */
int GasDataQueue_insert(GasDataQueue* const me, GasData g);
GasData * GasDataQueue_remove(GasDataQueue* const me);
int GasDataQueue_getItsGasData(const GasDataQueue* const me);
GasDataQueue * GasDataQueue_Create(void);
void GasDataQueue_Destroy(GasDataQueue* const me);
#endif

//EOF
/*********************************
@file GasDataQueue.c
*********************************/
#include "GasDataQueue.h"
#include <stdio.h>
/* private (static) methods */
static void cleanUpRelations(GasDataQueue* const me);
static int getNextIndex(GasDataQueue* const me, int index);
static unsigned char isEmpty(GasDataQueue* const me);
static unsigned char isFull(GasDataQueue* const me);
static void initRelations(GasDataQueue* const me);

void GasDataQueue_Init(GasDataQueue* const me) 
{
  me->head = 0;
  me->sema = NULL;
  me->size = 0;
  me->tail = 0;
  initRelations(me);
  me->sema = OS_create_semaphore();
}
void GasDataQueue_Cleanup(GasDataQueue* const me) 
{
  OS_destroy_semaphore(me->sema);
  cleanUpRelations(me);
}
/*
Insert puts new gas data elements into the queue
if possible. It returns 1 if successful, 0 otherwise.
*/
int GasDataQueue_insert(GasDataQueue* const me, GasData g)
{
  OS_lock_semaphore(me->sema);
  if (!isFull(me)) {
    me->itsGasData[me->head] = g;
    me->head = getNextIndex(me, me->head);
    ++me->size;
    /* instrumentation */
    /* print stuff out, just to visualize the insertions */
    switch (g.gType) 
    {
      case O2_GAS:
      printf("+++ Oxygen ");
      break;
      case N2_GAS:
      printf("+++ Nitrogen ");
      break;
      case HE_GAS:
      printf("+++ Helium ");
      break;
      default:
      printf("UNKNWON ");
      break;
   };
   printf(" at conc %f, flow %d\n",g.conc,g.flowInCCPerMin);
   printf(" Number of elements queued %d, head = %d, tail = %d\n",
   me->size, me->head, me->tail);
   /* end instrumentation */
   OS_release_semaphore(me->sema);
   return 1;
  }
  else 
  { 
    /* return error indication */
    OS_release_semaphore(me->sema);
    return 0;
  }
}
/*
remove creates a new element on the heap, copies
the contents of the oldest data into it, and
returns the pointer. Returns NULL if the queue
is empty
*/
GasData * GasDataQueue_remove(GasDataQueue* const me) 
{
  GasData* gPtr;
  OS_lock_semaphore(me->sema);
  if (!isEmpty(me)) 
  {
    gPtr = (GasData*)malloc(sizeof(GasData));
    gPtr->gType = me->itsGasData[me->tail].gType;
    gPtr->conc = me->itsGasData[me->tail].conc;
    gPtr->flowInCCPerMin = me->itsGasData[me->tail].flowInCCPerMin;
    me->tail = getNextIndex(me, me->tail);
    /* instrumentation */
    switch (gPtr->gType) 
    {
       case O2_GAS:
       printf("— Oxygen ");
       break;
       case N2_GAS:
       printf("— Nitrogen ");
       break;
       case HE_GAS:
       printf("— Helium ");
       break;
       default:
       printf("— UNKNWON ");
       break;
    };
    printf(" at conc %f, flow %d\n",gPtr->conc,gPtr->flowInCCPerMin);
    printf(" Number of elements queued %d, head = %d, tail = %d\n",
    me->size, me->head, me->tail);
 /* end instrumentation */
   OS_release_semaphore(me->sema);
   return gPtr;
  }
  else 
  { /* if empty return NULL ptr */
    OS_release_semaphore(me->sema);
    return NULL;
  }
}
static int getNextIndex(GasDataQueue* const me, int index) 
{
   /* this operation computes the next index from the
   first using modulo arithmetic
   */
   return (index+1) % QUEUE_SIZE;
}
static unsigned char isEmpty(GasDataQueue* const me) 
{
  return (me->size == 0);
}
static unsigned char isFull(GasDataQueue* const me) 
{
  return (me->size == GAS_QUEUE_SIZE);
}
int GasDataQueue_getItsGasData(const GasDataQueue* const me) 
{
  int iter = 0;
  return iter;
}
GasDataQueue * GasDataQueue_Create(void) 
{
  GasDataQueue* me = (GasDataQueue *)
  malloc(sizeof(GasDataQueue));
  if(me!=NULL) 
  {
    GasDataQueue_Init(me);
   }
 return me;
}
void GasDataQueue_Destroy(GasDataQueue* const me)
{
  if(me!=NULL) 
  {
    GasDataQueue_Cleanup(me);
  }
  free(me);
}
static void initRelations(GasDataQueue* const me) 
{
  int iter = 0;
  while (iter < GAS_QUEUE_SIZE)
  {
    GasData_Init(&((me->itsGasData)[iter]));
    iter++;
  }
}
static void cleanUpRelations(GasDataQueue* const me) 
{
  int iter = 0;
  while (iter < GAS_QUEUE_SIZE)
  {
    GasData_Cleanup(&((me->itsGasData)[iter]));
    iter++;
  }
}

De posse destes dados, entretanto, tarefas mais úteis poderiam ser executadas a depender das necessidades da planta, como manter a concentração de determinado gás constante, através de algum tipo de controle retroalimentado, por exemplo.

A Figura 13 mostra o sistema em execução. A tarefa GasProcessingThread é disparada primeiro, com um período de 1000 ms e a a tarefa SensorThread é posteriormente disparada a um período de 500 ms. A fila tem 10 elementos. Note que apesar de cada um dos três sensores terem uma chance de 1/3 de produzir dados neste intervalo, os dados estão sendo mais rapidamente inseridos do que removidos, até que a fila enche. [1]

Figura 13. Queueing exemplo sendo executado

4.4. Rendez-Vous

Se as condições para coordenar uma task são mais complexas que as apresentadas anteriormente, quando fundamentalmente estávamos a proteger um recurso de acesso mútuo, podemos “concretizar” estas condições em um objeto de fato. O padrão Rende-Vouz modela as pré-condições para a coordenação de tasks, na forma de um objeto explicitamente separado com seus próprios dados e funções. É um padrão generalista aplicado para garantir que um conjunto de pré-condições arbitrárias sejam atendidas em tempo de execução. [1]

Figura 14. Rende-vouz pattern modelado em UML [1]

4.4.1 Rationale

Duas ou mais tarefas podem ser sincronizadas utilizando-se de uma estratégia a sua escolha que será codificada na classe Rendevouz. O padrão é facilmente adaptável. Quando uma thread encontra um ponto de sincronização, ela registra-se em um objeto da classe Rendevouz, e bloqueia-se até que este objeto a libere para ser executado, usando qualquer que seja a política de scheduling do sistema. É como se um veículo parasse em algum ponto de inspeção na estrada – o fiscal, i.e., o objeto Rendevouz, só a deixa seguir adiante se as condições (papelada, pneus, etc.), estiverem todas satisfeitas.

4.4.2. Elementos

A classe Rendezvous coordena as tarefas através de duas funções primárias:

void reset(void): reseta os critérios de sincronização, i.e., coloca-os de volta em suas condições iniciais.

void synchronize(void): este método é chamado quando uma task quer ser sincronizada. Se os critérios não estão satisfeitos, esta task será de alguma forma bloqueada. A estratégia para pode ser através de um Observer Pattern ou um Guarded Call Pattern, por exemplo. A complexidade deste padrão concentra-se primariamente neste método que avalia se as condições estão satisfeitas. Estas condições podem ser internas (como a Thread Barrier do padrão), externas, ou qualquer combinação das duas.

Normalmente o objeto RendeVouz é agnóstico em relação às threads que coordena. Se não o for, o método synchronize() terá um mais parâmetros para identificação das threads. [1]

O Semaphore é um semáforo comum, com as operações de lock e release.

SynchronizingThread representa uma task que utiliza o objeto Rendezvous para ser sincronizada: quando atinge seu ponto de sincronização, deve explicitamente chamar o método synchronize().

4.4.3 Implementação

Se o padrão for implementado com auxílio de um Observer Pattern, então as tasks precisam registrarem-se com o endereço de um callback a ser chamado quando os critérios de sincronização foram atendidos. Se o padrão Guarded Call Pattern (veja publicação anterior) for utilizado, então cada objeto RendezVous tem somente um único semáforo, que maneja uma fila de tasks que registraram-se naquele objeto RendezVous. Note que neste último caso, o padrão acaba por ser uma solução custosa (overkill), mas que simplifica a implementação de guarded calls, ao concentrá-las em um único componente do programa. É o típico trade-off reusabilidade/custo, que normalmente nos deparamos nas escolhas de design.

4.4.4 Exemplo

No exemplo da Figura 15, uma forma específica do padrão Rendezvous, conhecida como Thread Barrier Pattern [1] é implementada.

Figura 15. Rendezvous implementando uma Thread Barrier

Note que neste snippet, a única informação que o objeto recebe, fora do seu escopo, é o número de tarefas a serem sincronizadas, através do atributo expectedCount. Quando o número de tasks que invocaram o método synchronize() atinge 3, as tarefas são liberadas para o kernel manejá-las com a política de agendamento que utiliza. Os objetos do tipo semáforo e barrier são ponteiros que assumem a referência passada pela task que chamou o ThreadBarrier. O snippet não mostra nenhuma lógica que avalie informações externas, portanto estamos simplesmente a implementar um Guarded Call Pattern, de uma maneira bastante reutilizável. O importante é perceber o forte caráter extensível deste padrão.

Vale aqui replicar a Figura 3 da primeira parte do artigo:

Figura 16. Representação dinâmica de uma Thread Barrier; neste caso expectedCount = 4

Lista 6. Implementação em C do modelo descrito na Figura 15

/******************************
@file ThreadBarrier.h
******************************/
#ifndef ThreadBarrier_H
#define ThreadBarrier_H
/*# # auto_generated */
#include <oxf/Ric.h>
/*# # auto_generated */
#include "RendezvousExample.h"
/*# # auto_generated */
#include <oxf/RiCTask.h>
/*# # package RendezvousPattern::RendezvousExample */
/*# # class ThreadBarrier */
typedef struct ThreadBarrier ThreadBarrier;
struct ThreadBarrier {
  int currentCount;
  int expectedCount;
  OSSemaphore* barrier;
  OSSemaphore* mutex;
};
/* Constructors and destructors:*/
void ThreadBarrier_Init(ThreadBarrier* const me);
void ThreadBarrier_Cleanup(ThreadBarrier* const me);
/* Operations */
void ThreadBarrier_reset(ThreadBarrier* const me, int x);
void ThreadBarrier_synchronize(ThreadBarrier* const me);
ThreadBarrier * ThreadBarrier_Create(void);
void ThreadBarrier_Destroy(ThreadBarrier* const me);
#endif
// EOF
/******************************
@file ThreadBarrier.c
******************************/
#include "ThreadBarrier.h"
void ThreadBarrier_Init(ThreadBarrier* const me) 
{
  me->currentCount = 0;
  me->expectedCount = 3;
  if (me->barrier) 
  {
    OSSemaphore_lock(me->barrier);
    printf("BARRIER IS LOCKED FIRST TIME\n");
 }
}
void ThreadBarrier_Cleanup(ThreadBarrier* const me) 
{
  OSSemaphore_Destroy(me->barrier);
  OSSemaphore_Destroy(me->mutex);
}
void ThreadBarrier_reset(ThreadBarrier* const me, int x) 
{
  me->expectedCount = x;
  me->currentCount = 0;
}
void ThreadBarrier_synchronize(ThreadBarrier* const me) 
{
/*
protect the critical region around
the currentCount
*/
  OSSemaphore_lock(me->mutex);
  ++me->currentCount; /* critical region */
  OSSemaphore_release(me->mutex);
/*
are conditions for unblocking all threads met?
if so, then release the first blocked
thread or the highest priority blocked
thread (depending on the OS)
*/
  if (me->currentCount == me->expectedCount) 
  {
   printf("Conditions met\n");
   OSSemaphore_release(me->barrier);
   //let the scheduler do its job
  }
/*
lock the semaphore and when condition met
then release it for the next blocked thread
*/
  OSSemaphore_lock(me->barrier);
  /* code to check if condition met */
  OSSemaphore_release(me->barrier);
}
ThreadBarrier * ThreadBarrier_Create(void) 
{
  ThreadBarrier* me = (ThreadBarrier *) malloc(sizeof(ThreadBarrier));
  if(me!=NULL)
  {
    ThreadBarrier_Init(me);
    return me;
  }
void ThreadBarrier_Destroy(ThreadBarrier* const me) 
{
   if(me!=NULL)
   ThreadBarrier_Cleanup(me);
   free(me);
}

Fim da segunda parte.

Todos os padrões e exemplos aqui apresentados são de: 
[1] Douglass, Bruce Powel. Design patterns for embedded C: an embedded software engineering toolkit, 1st ed. ISBN 978-1-85617-707-8

Padrões de design para comunicação interprocessos em software embarcado (1/3)

A quem interessar: A depender do dispositivo em que escrevi parte dos textos, algumas palavras estão grafadas em português brasileiro (o meu nativo) ou português europeu (quando o auto corretor assim desejou).

1. Introdução

Nos dois últimos artigos demonstrei um pequeno kernel com escalonador de tarefas cíclico, porém preemptivo (round-robin) implementado em um ARM Cortex-M3. Além do escalonamento de tarefas também separavam-se os processos de usuário dos processos de supervisor, o que trouxe a necessidade da implementação de mecanismos de System Calls.

Durante os artigos, muitas vezes mencionei que o kernel ainda não estava preparado para a sincronização de tarefas e gerenciamento de recursos, ou de forma geral, não havia mecanismos para comunicação interprocessos.

Se quisermos generalizar o uso deste kernel – i.e., reutilizá-lo para uma solução em que as tasks não atuam isoladamente, um ou mais mecanismos de IPC é fundamental. Se por um lado os processos precisam cooperar para atingir determinados objetivos, por outro eles concorrem pelo uso limitado de recursos. [4]

2. IPCs: proteger e coordenar

O fenômeno que nos leva à necessidade de comunicação interprocessos é o da  Concorrência: os recursos finitos de um computador faz com que seja necessário coordenar as diversas actividades que concorrem simultaneamente ou paralelamente. Isto nos leva a ter que proteger uma atividade dos efeitos de outra e sincronizar atividades que são mutualmente dependentes. [4]

Figura 1. Multithreading e multiprocessing, conceitualmente [2]

Na publicação passada em que três tasks acessavam o mesmo hardware para imprimir na tela do PC, a coordenação para compartilhar o recurso foi feita com o sinal de TX_READY lido no registro do próprio dispositivo. Independente da quota de tempo que cada task tinha para ser executada, o kernel só liberava o processador após garantir que os dados da UART haviam sido transmitidos ao computador. Uma forma de IPC que não abstrai o hardware (UART) ou a aplicação (transmitir dados).

static void SysCallUART_PrintLn_(const char* args)
{
    __disable_irq(); // sessão crítica inicio
    uart_write_line(UART, args); // transmite dados        
    while (uart_get_status(UART) != UART_SR_TXRDY); //aguarda fim da transmissão
    __enable_irq(); // sessão crítica fim
    exitKernel_();
}

Lista 0. System Call lendo diretamente o sinal de TX_READY em uma critical region para coordenar as tarefas de acesso à UART

2.1. A Unidade Concorrente

O design do modelo de concorrência é uma parte crítica na arquitetura do sistema a ser desenvolvido. Vamos definir Process, Task e Thread.

Entendo um Process como algo estático (previamente definido) e encapsulado. Uma Task é um processo em execução. As Threads são tasks que acabam por ser dependentes entre si: uma consequência da topologia do sistema em que estes processos são executados [2, 3]. Se analisarmos a Figura 1, é intuitivo notar que a multithreading implica em complexidade de implementação em troca de economia/limitação de recursos.

Para efetivamente entender estes conceitos e modelar a arquitectura mais adequada ao gerenciamento da concorrência de tarefas em um dado sistema, a melhor definição que eu conheço é a de Unidade Concorrente [1]: 

«Unidade Concorrente é um conjunto de ações seqüenciais no mesmo segmento de execução. Também conhecido como task ou thread.» Em [2] uma Thread é definida como o «contexto de um processo em execução» – i.e., o contexto de uma Task – o que não se sobrepõe ou contradiz a definição dada por [1], e complementa a ideia de que uma thread é essencialmente dinâmica. Não à toa, a estrutura de dados em que salvamos o contexto de uma task é classicamente chamada de Thread Control Block.

Powell em [1] ainda observa: «muitos fazem uma distinção entre Task, Thread e Process. Entretanto, todos são o mesmo conceito em diferentes escopos.» Novamente analise a Figura 1. Se o “escopo”for a granularidade, diante do que consta na literatura apresentada, podemos dizer sem danos que: a thread é a menor unidade concorrente de um sistema quando em execução.

Figura 2. A, B, C e D são Unidades Concorrentes: a) A compartilhar um único core (pseudoconcorrentes)  b) Cada unidade tem seu próprio core c) Em execução, somente uma unidade concorrente estará activa em um dado instante de tempo (adaptado de [3])

2.2 A necessidade de sincronização

Antes de prosseguirmos, mais uma definição:

«Pontos de sincronização são aqueles em que as tasks compartilharão informações e/ou garantirão que determinadas pré-condições estejam satisfeitas antes  de prosseguirem.» [1]

A Figura 3 ilustra esta ideia. A barreira de sincronização só permite que o programa siga adiante quando todos os processos tiverem atingido aquele ponto – i.e., as condições para que todas as ações do sistema estejam corretamente sincronizados são verdadeiras.

Figura 3. a) Processos em execução b) Todos os processos exceto C estão prontos c) Assim que o processo C atinge o ponto de sincronização, o programa segue adiante. [3]

Eu prefiro o termo coordenação a sincronização, porque sincronia não é necessariamente um fenômeno desejável ou relacionado entre duas partes, portanto, gosto da ideia de pontos de coordenação. A literatura entretanto normalmente utiliza o termo sincronizado como sinônimo de coordenado.

A Figura 4 mostra unidades concorrentes, modeladas em três tasks. O que sabemos sobre elas? Se a analisarmos individualmente fica claro que na Task 1 a Acção A ocorre antes da B, que percorre um caminho condicional até chegar a E. O mesmo raciocínio  vale para as Tasks 2 e 3.

De que forma as acções individuais das Tasks 1, 2, e 3 relacionam-se entre si? Se a resposta (verdadeira) for “não se relacionam, ou não importa“, não há nenhum problema de concorrência a ser resolvido. E provavelmente o teu sistema é muito simples ou  muito bem desenhado.

Figura 4. Unidades concorrentes representadas como fluxogramas [1]

Entretanto, se em algum ponto é necessário garantir, por exemplo, que as Acções F, X e Zeta sejam executadas somente depois que as Ações E, Z e Gamma atingirem determinadas condições, precisamos de alguma forma reunir (join) cada um dos estados destas tasks em logicamente uma única thread, a partir de um ponto de sincronização. Após as condições necessárias terem sido atingidas no ponto de sincronização, as threads são novamente separadas (fork), e o programa segue.

Isto significa que sempre que houver condições entre dois ou mais processos para atingir determinado objectivo, precisamos criar mecanismos para que estes se comuniquem.

2.2.1. Mecanismos de Exclusão mútua

Recursos podem ser classificados como compartilháveis ou não compartilháveis. Isto depende da natureza física ou lógica de determinado recurso [4]. Por exemplo, a menor unidade de memória do sistema não pode ser lida e escrita ao mesmo tempo por dois processos distintos. Já uma estrutura de dados que guarda um resultado para ser consumido por um processo, precisa ser protegida para não ser sobrescrita antes de consumida (ou enquanto consumida, se pensarmos em pseudo-paralelismo), porque isto causará uma falha lógica no sistema. A exclusão mútua coordena o acesso de várias unidades concorrentes a um recurso.

2.2.2. Falha por deadlock

Quando processos concorrem por recursos, podemos chegar em uma situação em que nenhum deles consegue seguir adiante. Suponhamos dois processos A e B. O processo A está bloqueado, a espera de que um recurso seja liberado para seguir adiante. O processo B por sua vez só vai liberar este recurso quando determinada condição for satisfeita. Se esta condição depender do processo A, que já está bloqueado, temos um deadlock

3. Modelagem de Tasks em UML

Em UML há 3 maneiras de modelar concorrência [1]: objetos com o estereótipo «active» em diagramas estruturais, forks e joins em diagramas de seqüência, e regiões ortogonais em diagramas de máquinas de estado. Um objeto com o estereótipo «active» em geral é uma estrutura de outros objetos. O objeto ativo tem uma thread associada, que executa a ações sequenciais que correm em um contexto (contexto este que pode ou não estar representado ou inferido do diagrama). O símbolo de um objeto ativo pode tanto dar-se como um objeto comum com o estereótipo «active», ou com as bordas esquerda e direita destacadas. Na Figura 5, uma Task é modelada com cinco objetos ativos, um recurso (database) compartilhado e protegido por um semáforo e comentários com os metaparâmetros de cada thread associada (prioridade, período e deadline – parâmetros estes que dependem escalonador utilizado). Objetos «active» que não são um conjunto de outros objetos, como itsBuiltInTestThread  ou itsControlThread correm na sua própria thread. Por fim, em alguns casos utilizamos portas nas Tasks para conectarem-se diretamente com objetos – o que geralmente indica que passaremos estes valores por cópia, e em outros utilizamos links, o que normalmente indica que estes valores serão passados por referência.

4. Padrões para exclusão mútua

Quatro padrões de design serão aqui apresentados com os seus respectivos modelos UML: Critical Region, Guarded Call, Message Queue e Rendez-vous. Todos estes padrões estão descritos em [1].

4.1. Critical Region Pattern

Esta é a forma mais primitiva de coordenar tasks: desligar os mecanismos que poderiam interromper um trecho de execução, para evitar que a task ceda lugar a outra antes de as condições necessárias serem atendidas, ou para evitar a concorrência de acesso a um recurso.

Figura 6.Generalização do Padrão Critical Section em UML [1]

4.1.1. Rationale

Este padrão é utilizado em sistemas com escalonamento preemptivo, quando múltiplas unidades concorrentes podem acessar um mesmo recurso ou interromper uma tarefa em andamento. Ao desabilitarmos a troca de tarefas em determinado trecho de execução, garantimos a atomicidade da operação.

4.1.2 Elementos

CRSharedResource: O recurso a ser protegido é o  atributo value. Qualquer trecho de código que utilize os métodos setValue e getValue precisam ser colocados em regiões críticas.

TaskWithSharedResource: Este elemento simboliza o conjunto de tasks que acessam o recurso anteriormente mencionado (por isso a multiplicidade ‘*’ na associação de dependência). As tarefas não têm conhecimento de que recurso esta a ser utilizado; ele está encapsulado dentro do objeto CRSharedResource.

4.1.3. Implementação

Usualmente o kernel provê uma API com funções como osDisableTaskSwitch()/osEnableTaskSwitch(). Caso não, alguma diretiva em C/ASM pode ser utilizada para desabilitar as interrupções diretamente em hardware – no caso da API de abstração de hardware CMSIS para processadores ARM, esta diretiva seria __disable_irq()/__enable_irq(), e estamos na verdade a desabilitar toda e qualquer interrupção do sistema. Na publicação anterior, vários trechos do código do kernel estavam em critical regions porque necessitavam ser atômicos.

4.1.4. Exemplo

Na Figura 6, um hipotético sistema para mover o braço de um robô. Uma única Task move este braço (CRRobotArmManager). As entradas do usuário são as coordenadas (x, y, z) no espaço,  passadas por referência à Task.

É importante notar a estratégia para desacoplamento entre a Task e os objetos a ela associados: as regiões críticas são implementadas dentro dos métodos da Task, e não no método moveTo, por exemplo. A definição de regiões críticas dentro do método moveTo fatalmente acoplaria uma entidade que não é da natureza do kernel, o braço do robô - que deve poder ser movimentado por outros sistemas operacionais - à uma entidade totalmente acoplada ao kernel, a Task. Se não houver justificativa, o contrário é uma má escolha de design.
Figura 7. Exemplo de Task modelada que necessita do uso de sessão crítica [1]

Abaixo, o cabeçalho do programa, e a implementação de uma operação para facilitar a compreensão da linguagem UML [1]:

Lista 1. Cabeçalho da Task CRRobotArmManager [1]

#ifndef CRRobotArmManager_H
#define CRRobotArmManager_H
struct CRDisplay;
struct RobotArm;
struct UserInput;
typedef struct CRRobotArmManager CRRobotArmManager;
struct CRRobotArmManager 
{
  struct CRDisplay* itsCRDisplay;
  struct RobotArm* itsRobotArm;
  struct UserInput* itsUserInput;
};
/* Constructors and destructors:*/
void CRRobotArmManager_Init(CRRobotArmManager* const me);
void CRRobotArmManager_Cleanup(CRRobotArmManager* const me);
/* Operations */
void CRRobotArmManager_motorZero(CRRobotArmManager* const me);
void CRRobotArmManager_moveRobotArm(CRRobotArmManager* const me);
struct CRDisplay* CRRobotArmManager_getItsCRDisplay(const
CRRobotArmManager* const me);
void CRRobotArmManager_setItsCRDisplay(CRRobotArmManager* const
me, struct CRDisplay* p_CRDisplay);
struct RobotArm* CRRobotArmManager_getItsRobotArm(const
CRRobotArmManager* const me);
void CRRobotArmManager_setItsRobotArm(CRRobotArmManager* const me,
struct RobotArm* p_RobotArm);
struct UserInput* CRRobotArmManager_getItsUserInput(const
CRRobotArmManager* const me);
void CRRobotArmManager_setItsUserInput(CRRobotArmManager* const me,
struct UserInput* p_UserInput);
CRRobotArmManager * CRRobotArmManager_Create(void);
void CRRobotArmManager_Destroy(CRRobotArmManager* const me);
#endif

Lista 2. Método com o uso de critical regions [1]

void CRRobotArmManager_moveRobotArm(CRRobotArmManager* const me) 
{
 /* local stack variable declarations */
 int x, y, z, success = 1; 
 /*noncritical region code */
 /* note that the function below has its own critical region and so cannot be
 called inside of the critical region of this function
 */
 CRRobotArmManager_motorZero(me);
 x = UserInput_getX(me->itsUserInput);
 y = UserInput_getY(me->itsUserInput);
 z = UserInput_getX(me->itsUserInput);
 /* critical region begins */
 OS_disable_task_switching();
 /* critical region code */
 success = RobotArm_moveTo(me->itsRobotArm, x, y, z);
 /* critical region ends */
 OS_enable_task_switching();
 /* more noncritical region code */
 CRDisplay_printInt(me->itsCRDisplay, "Result is ", success);
}

4.2. Guarded Call Pattern

O padrão Guarded Call, serializa o acesso a um conjunto de serviços que potencialmente causam danos se chamados simultaneamente. Neste padrão, o acesso serializado é implementado através de um semáforo que previne que outras threads invoquem este serviço enquanto ele estiver em uso.

Figura 8. Generalização do Guarded Call Pattern modelado em UML [1]

4.2.1 Rationale

Múltiplas tarefas preemptivas acessam um recurso protegido invocando os métodos deste. O escalonador de tarefas por sua vez deve colocar a task que tentou acessar um recurso ocupado em uma fila de tasks bloqueadas, desbloqueando-a assim que o recurso for liberado.

4.2.2. Elementos

GuardedResource é um recurso compartilhado que emprega um Semaphore do tipo mutex para forçar a exclusão mútua entre múltiplas PreemptiveTasks (note que existe um único semáforo agregado a um único recurso, e portanto é um semáforo para um elemento, ou seja, um mutex1). Quando uma tarefa quer utilizar este recurso, as funções relevantes deste chamarão ao semáforo para colocá-lo em lock. Caso o recurso esteja livre, ele é locked e a tarefa pode utilizá-lo, e liberá-lo ao final. Caso o recurso já esteja em locked, este sinaliza ao escalonador para colocar a tarefa atual em estado blocked até que ele seja liberado. Outras tarefas que tentam acessar o recurso enquanto ele está bloqueado também tornar-se-ão blocked. Cabe ao escalonador do sistema fazer o manejo desta fila de tarefas bloqueadas a espera do recurso; ou seja, quais critérios serão utilizados para cada tarefa na fila – pode ser a prioridade, por exemplo.2

1 a implementação de semáforos/ mutexes é um assunto que será abordado na continuidade da construção do kernelzito já apresentado.

2 um problema comum nesta implementação é quando ocorre a chamada “inversão de prioridade”

4.2.3 Implementação

A parte mais complicada é a implementação dos semáforos. Normalmente o sistema operacional provê semáforos, acessados através de uma API do tipo:

  • Semaphore* osCreateSemaphore(): cria um semáforo e retorna um ponteiro para a instância.
  • void osDestroySemaphore(Semaphore*): destrói a instância semáforo para o qual aponta.
  • void osLockSemaphore(Semaphore*): trava o dispositivo associado ao semáforo se ele estiver livre. Caso não esteja, o contexto da tarefa que o requisitou é salvo, bem como a ID do semáforo do recurso requisitado, e a tarefa é colocada em blocked.
  • void osUnlockSemaphore(Semaphore*): destrava o dispositivo associado ao semáforo, e libera a tarefa que estava a sua espera.

4.2.4 Exemplo

A Figura 9 mostra a porção de um hipotético controlador de vôo. A classe KinematicData é compartilhada por diversos clientes. Para garantir a serialização de acesso um semáforo é instanciado neste recurso.

Figura 9. Padrão Guarded Pattern aplicado a um controlador de vôo [1]

Isto evita que o método FlightDataDisplay::ShowFlightData()seja interrompido enquanto disponibiliza os dados de vôo, pelo método Navigator::updatePosition() por exemplo. Perceba que as Tasks não estão modeladas. A utilização do semáforo está acoplada ao escalonador – o seu estado quando a thread é disparada influencia no manejo da fila de threads no qual cada método em espera será executado. Abaixo snippets de parte da implementação para facilitar o entendimento.

Lista 3. Cabeçalho da classe KinematicData

#ifndef KinematicData_H
#define KinematicData_H
#include "GuardedCallExample.h"
#include "Attitude.h"
#include "OSSemaphore.h"
#include "Position.h"
typedef struct KinematicData KinematicData;
struct KinematicData 
{
 struct Attitude attitude;
 struct Position position;
 OSSemaphore* sema; /*## mutex semaphore */
};
/* Constructors and destructors:*/
void KinematicData_Init(KinematicData* const me);
void KinematicData_Cleanup(KinematicData* const me);
/* Operations */
Attitude KinematicData_getAttitude(KinematicData* const me);
struct Position KinematicData_getPosition(KinematicData* const me);
void KinematicData_setAttitude(KinematicData* const me, Attitude a);
void KinematicData_setPosition(KinematicData* const me, Position p);
KinematicData * KinematicData_Create(void);
void KinematicData_Destroy(KinematicData* const me);
#endif

Lista 4. Implementação da classe KinematicData

#include "KinematicData.h"
void KinematicData_Init(KinematicData* const me) 
{
  Attitude_Init(&(me->attitude));
  Position_Init(&(me->position));
  me->sema = OS_create_semaphore();
}
void KinematicData_Cleanup(KinematicData* const me) 
{
  OS_destroy_semaphore(me->sema);
}
struct Position KinematicData_getPosition(KinematicData* const me) 
{
  Position p;
  /* engage the lock */
  OS_lock_semaphore(me->sema);
  p = me->position;
  /* release the lock */
  OS_release_semaphore(me->sema);
  return p;
}
void KinematicData_setAttitude(KinematicData* const me, Attitude a) 
{
/* engage the lock */
  OS_lock_semaphore(me->sema);
  me->attitude = a;
  /* release the lock */
  OS_release_semaphore(me->sema);
}
void KinematicData_setPosition(KinematicData* const me, Position p) 
{
  /* engage the lock */
  OS_lock_semaphore(me->sema);
  me->position = p;
  /* release the lock */
  OS_release_semaphore(me->sema);
}
KinematicData * KinematicData_Create(void) 
{
  KinematicData* me = (KinematicData *)
  malloc(sizeof(KinematicData));
  if(me!=NULL) 
  {
   KinematicData_Init(me);
  }
  return me;
}
void KinematicData_Destroy(KinematicData* const me) 
{
  if(me!=NULL) 
  {
   KinematicData_Cleanup(me);
  }
  free(me);
}
Attitude KinematicData_getAttitude(KinematicData* const me) 
{
  Attitude a;
  /* engage the lock */
  OS_lock_semaphore(me->sema);
  a = me->attitude;
  /* release the lock */
  OS_release_semaphore(me->sema);
  return a;
}

Fim da primeira parte

Referências:
[1] Design Patterns for Embedded Systems in C (Douglass Powell, 2009)
[2] Embedded Systems: Real-Time Operating Systems for Arm Cortex-M MCUs (Jonathan Valvanno, 2012)
[3] Modern Operating Systems (Andrew Taneunbaum, Herbert Boss, 2014)
[4] Fundamentals of Operating Systems (A. M. Lister, 1984)

SOA para diminuição da hardware-dependência em C

aa480021.aj1soa01(en-us,msdn.10)
(MS Software Architecture Journal, 2004)

1. Impacto do software hardware-dependente (HdS)

É dito na literatura que o custo do software embarcado tem dominado o projeto de sistemas eletrônicos [1]. Sistemas embarcados, tradicionalmente limitados em memória e processamento, conseguem hoje rodar aplicações mais complexas fruto do avanço no projeto e fabricação de circuitos integrados. Ora, a introdução de hardware mais complexo permite a utilização de recursos de programação também mais complexos. De fato, mais de 90% das inovações na indústria automotiva da última década são decorrência direta do desenvolvimento em software embarcado. A especialização do dispositivo leva à especialização do software e ao conceito de software hardware-dependente:

  • é especialmente desenvolvido para um bloco de hardware específico: o software é inútil sem aquele hardware
  • software e hardware juntos implementam uma funcionalidade: isto é, o hardware é inútil sem aquele software

Muito provavelmente ao ler esta descrição pensaste em um sistema operacional para determinada plataforma e seus controladores (drivers). Abaixo segue um diagrama em camadas de um típico sistema embarcado. Conforme subimos as camadas, menos hardware-dependente será o nosso software, de forma que para escrevermos em linguagem interpretada, digamos, eu não preciso tomar conhecimento do microprocessador, e muito menos das tensões de operação dos meus dispositivos. O HAL (camada de abstração de hardware) é onde encontra-se o software mais hardware-dependente, i.e., mudanças no hardware invariavelmente implicarão em mudanças no software desta camada (Figura 1).

hal

Figura 1. Componentes e camadas de um típico sistema embarcado [ECKER]

2. Orientação a objetos para aumentar a coesão

O paradigma de orientação a objetos parte de um conceito muito intuitivo que é particionar um sistema em objetos classificados consoante à sua natureza. Não é necessário muito para perceber como isto aumenta o reuso e a portabilidade. A utilização do paradigma aumenta a coesão dos componentes de nossa arquitetura na medida da nossa habilidade em pensar de maneira orientada a objetos. Ao contrário do domínio de software aplicativo, em software embarcado sobretudo quando os requisitos são bastante específicos e o hardware limitado, a portabilidade e reuso esbarram na hardware-dependência. Se a OO é um bom paradigma para escrever software coeso, muitas vezes nestes domínio este recurso está indisponível. Podemos utilizar o C para escrever em um (meta-)padrão orientado a objetos, se soubermos fazer bom uso de ponteiros.

3. Exemplo de arquitetura orientada a serviços em C

Como exemplo vou descrever uma arquitetura para desacoplar a aplicação do hardware e que se beneficia dos conceitos de OO, entre eles o polimorfismo. O objetivo é escrever software para comunicação serial coeso o suficiente para quando o hardware mudar (ou for definido), somente o código mais fortemente hardware-dependente precise ser adaptado (ou escrito). O conceito de arquitetura orientada a serviços está presente em sistemas operativos baseados em microkernel (dois exemplos extremos: Windows NT e MicroC/OS). A arquitetura proposta está representada na Figura 2. Todo o software representado daqui em diante refere-se somente à camada de Serviços. A camada Board Support Package desacopla (ou diminui o acoplamento) entre o HAL e os Serviços; é a API do HAL para o programador dos Serviços.

layers

Figura 2. Layers em uma arquitetura orientada a serviços [do autor]

É uma boa ideia permitir que a aplicação utilize dois únicos métodos para transferir e receber um número de dados via serial, aplicados sob diferentes componentes de hardware: enviar(interface, x_bytes) e receber(interface, x_bytes). Estes são métodos que assumem uma forma ou outra a depender do objeto. Assim chegamos aos conceitos de interface (Classe abstrata), herança e polimorfismo. Abaixo o diagrama de classes da proposta. A assinatura completa dos métodos foi omitida.

class
Figura 3. Diagrama de classes da proposta

O padrão de design Proxy/Server permite que o hardware seja configurado de forma genérica abstraindo seus detalhes específicos. As mudanças no BSP são isoladas pela proxy ao programador da aplicação. O servidor por sua vez sustenta-se nos dados de configuração da proxy e na API do HAL para configurar e inicializar os serviços (this_proxy->this_service).

(Errata: na figura 3 os métodos de construção/inicialização e enviar/receber da classe SPI_proxy estão representados como se fossem iguais ao da UART, porém são métodos distintos.)

3.1. Implementação da Classe Abstrata

Uma interface pode ser percebida como um barramento de funções virtuais que aponta para um método ou outro em tempo de execução. Este apontamento segue por um caminho de endereços até chegar ao método para ser executado naquele contexto (etapa do fluxo do programa). Poderíamos também atribuir através de setters os métodos de cada objeto, i.e., ainda em tempo de compilação.

Começaremos por implementar a estrutura de dados que compreende a classe. Esta estrutura implementa 2 métodos que recebem e enviam frames de bytes. Uma classe Serial_Comm, portanto dá cria a um objeto que contém uma tabela de funções genéricas de enviar e receber. Os métodos públicos da interface são somente construtores e desconstrutores. Os métodos de enviar e receber ficam privados, haja vista que o objeto a ser utilizado pelo programador da aplicação é que vai defini-los. A estrutura com as funções virtuais Serial_Table é declarada mas não definida inicialmente. Mais adiante declaramos que a estrutura é somente leitura (definida em tempo de compilação) contém dois ponteiros para funções, e finalmente as definimos de forma limitada ao escopo. Isto é necessário para que ocorra o alinhamento do endereço desta tabela ao endereço da tabela do objeto que a utiliza em tempo de execução.

Abaixo o cabeçalho do Serial_Comm.

serial_comm_h-e1569800116804.png
Figura 4. Cabeçalho da classe Serial_Comm

No programa .c da interface, uma estrutura SerialTable é inicializada com endereço de funções dummy. A definição destes métodos com o assert(0) é uma forma de acusar um erro de programação em tempo de execução, se estas funções forem chamadas sem a devida sobrescrita.

serial_comm_c
Figura 5. Programa da Classe Virtual

3.2. Implementação do serviço UART

As classes de comunicação serial seguirão este padrão de design. O construtor inicialmente define uma estrutura SerialTable. Perceba que esta estrutura só pode ser definida uma vez, mas os elementos de vtbl podem assumir qualquer valor; i.e., se construímos um objeto que herda esta classe podemos estender os parâmetros e definir o endereço dos métodos, que ficarão ligados a uma e somente uma interface de funções virtuais.

Na estrutura de dados de uma classe herdeira a sua superclasse precisa ser declarada primeiramente. De outro modo não poderemos estender os atributos desta.

Na construção da proxy, uma tabela de funções definidas localmente é o endereço destino que contém a definição dos métodos virtuais da interface.

uart_proxy_hhh.png
Figura 6. Cabeçalho da classe Uart_proxy
uart_proxy_c-e1569704959728.png
uart_proxy_c_2
Figura 7. Programa uart_proxy.c

Um ponto chave é o downcast (linhas 51 e 57) feito nos métodos privados Uart_Send_ e Uart_Get_. Um ponteiro para Uart_proxy é inicializado com o endereço de uma interface Serial_Comm. O alinhamento da memória permite que acessemos os métodos desta proxy a partir desta interface. Além disso como servidor e proxy estão agregados, os metodos do BSP podem ser chamados na proxy. As funções que começam com “_” (_uart_init, _uart_get …) fazem parte deste BSP, cujo cabeçalho uart_driver_api.h está incluído no programa. No BSP podem-se utilizar as mesmas técnicas, para permitir por exemplo, que a definição do endereço do dispositivo a ser construído se dê também por uma interface abstrata que não muda com as características do dispositivo (mapeado em porta ou memória, plataforma alvo, etc.)

uart_server_hh
Figura 8. Cabeçalho da classe Uart_Server

Logicamente, a implementação do serviço para SPI segue o mesmo padrão de design.

4. Utilização dos serviços

Para a utilização dos serviços pela camada de aplicação, basta construir uma proxy com parâmetros de inicialização do dispositivo, neste exemplo UART ou SPI, e utilizar os métodos SendFrame e GetFrame que acessam a interface de cada objeto proxy.

main2.png
Figura 9. Um programa utilizando os Serviços e seus métodos polimórficos
run
Figura 10. Programa em execução

5. Conclusões

Conceitos do paradigma de programação orientado a objetos podem ser implementados, virtualmente, em qualquer linguagem se partirmos da ideia de que classes são estruturas de dados agregadas a um conjunto de funções que fornecem uma interface externa comum. Tanto estes métodos quanto estes dados podem ser privados ou não. No primeiro caso está o conceito de encapsulamento. A alocação destas estruturas com seus valores e funções, é a instância de um objeto. A instanciação de um objeto de uma classe dentro de outra classe implementa a herança. Uma interface que não implementa métodos pode ser utilizada para conectar-se à interface de uma outra classe, o que chamamos de funções virtuais. As funções virtuais podem então assumir uma forma ou outra a depender do fluxo do programa, o que caracteriza o polimorfismo.

Uma arquitetura orientada à serviço eleva a coesão dos componentes e a consequente reutilização e portabilidade, mitigando os problemas gerados pela natural hardware-dependência do software para sistemas embarcados. A orientação a objetos por sua vez é o paradigma natural para a construção de sistemas orientado a serviços. Este artigo demonstrou uma forma de aplicar estes conceitos em sistemas embarcados quando linguagens OO (comumente neste domínio C++ ou Ada) não estão disponíveis.


O texto desta postagem bem como as figuras não referenciadas são do autor.
As seguintes referências foram consultadas:
[1] Hardware-Dependent Software: Principles and Practices. Ecker, Muller, Dommer. Springer, 2009.
[2] Design Patterns for Embedded Systems in C. Bruce Douglass. Elsevier, 2001.
[3] Object-Oriented Programming in C: Application Note. Quantum Leaps. April, 2019.