Já pensou em ter a possibilidade de executar múltiplas tarefas simultaneamente num microcontrolador? Neste artigo (já publicado no portal Embarcados) vamos demonstrar que escrever um simples e pequeno agendador/comutador de tarefas não é tão complicado assim!

A multitarefa possibilita maior liberdade ao programador, pois é possível escrever diferentes rotinas de código que rodam praticamente de forma simultânea. A multitarefa pode auxiliar o programador de diversas formas: desde piscar leds em frequências diferentes (como nosso primeiro exemplo vai demonstrar), ler teclados, escrever em displays, processar dados, tudo de forma “paralela”.

Conceitos Básicos

O ponto principal na multitarefa é a alteração do ponto de execução do programa. Este conceito deve ser familiar a qualquer programador de firmware, já que as interrupções fazem exatamente isso: interrupções são alterações no fluxo de código provocadas pelo hardware, ou seja, ao contrário de desvios e chamadas de função, que são alterações no fluxo de código provocadas pelo programador, as interrupções essencialmente não podem ser previstas (isto é especialmente verdade no caso de interrupções assíncronas).

Mas o quê ocorre numa interrupção? Resumidamente podemos dizer que ocorre o salvamento do contexto atual e a alteração do fluxo de código para o novo contexto, no caso o código de tratamento de interrupção (ou ISR em inglês). Ao término do processamento da interrupção o contexto anterior é restaurado e a execução retorna ao ponto onde estava antes da interrupção.

isr_br

Figura 1 – Uma interrupção interrompe o código principal

É importante esclarecer o significado de contexto em termos computacionais: trata-se do estado dos registradores da CPU envolvidos no processamento cotidiano. Normalmente estes registradores são o acumulador, o registrador de estado, o apontador de pilha (SP), o contador de programa (PC) e outros registradores internos que possam ser utilizados no processamento cotidiano. Podemos dizer que o contexto é uma “fotografia” que é tirada da CPU e que mostra exatamente o que ela estava fazendo. Com base nesta “fotografia” deve ser possível restaurar a execução da forma como se encontrava.

Um aspecto importante a ser observado é que o contexto computacional deve ser armazenado em memória e isso normalmente é feito utilizando-se a pilha ou o conceito de pilhas de memória.

Agora que já entendemos como ocorre a comutação de uma tarefa principal para uma outra secundária (a interrupção), é hora de identificar os conceitos associados a multitarefa.

Em primeiro lugar vamos lembrar o óbvio: um sistema dotado de uma única CPU e uma única unidade de execução não pode executar mais de uma tarefa simultaneamente. Mesmo uma CPU com mais de uma unidade de execução e capaz de executar mais de uma instrução simultaneamente (uma CPU superescalar) também não consegue executar mais de uma tarefa simultaneamente.

A técnica para se fazer com que uma CPU execute mais de uma tarefa simultaneamente é a de multiplexação por divisão de tempo, ou seja, divide-se o tempo útil da CPU entre cada tarefa, cada uma executando por uma fração do tempo útil total da CPU.

Esta multiplexação pode ser dividida em outras duas categorias: a multiplexação cooperativa e a multiplexação preemptiva. Na multiplexação ou multitarefa cooperativa, cada tarefa tem de provocar a alteração do fluxo de código para a tarefa seguinte. Isto significa que depende de cada tarefa individualmente (e da cooperação entre elas) a eficiência da multiplexação. Um artigo muito interessante sobre multitarefa cooperativa foi publicado pelo Pedro Bertoleti no portal Embarcados.

Na multiplexação preemptiva um programa especial (o agendador ou escalonador de tarefas) é encarregado de selecionar uma das tarefas disponíveis para ser executada. Existem diversos algoritmos utilizados para construir agendadores de tarefas, sendo o round-robin um dos mais simples, eficiente e bastante utilizado. O agendador round-robin procura dividir o tempo da CPU igualmente entre as tarefas disponíveis. Em algumas variações as tarefas de maior prioridade são executadas antes (e as vezes por mais tempo) que as tarefas de menor prioridade.

Os agendadores de tarefas utilizam também indicadores para sinalizar o estado de cada tarefa, normalmente encontramos os seguintes estados básicos: iniciando, pronta, executando, bloqueada, E/S e encerrada.

O nosso ULWOS utiliza um agendador de tarefas preemptivo round-robin, mas inicialmente não teremos nenhum mecanismo de prioridades e nem indicatores para as tarefas, ou seja, uma vez que cada tarefa seja criada, ela permanecerá executando indefinidamente (nem que seja executando um loop no final, mas a tarefa continuará recebendo periodicamente tempo da CPU). Obviamente que isso está longe de ser ideal, mas por hora é o suficiente para demonstrar o conceito. No futuro iremos incluir mais mecanismos para aumentar a eficiência do agendador e do nosso pequeno SO (mesmo ele não sendo exatamente um sistema operacional).

A figura a seguir representa, de uma forma simplificada, tudo o que foi dito e visto até aqui.

tarefas

Figura 2 – Um agendador de tarefas

Inicialmente, todas as pilhas de contexto do agendador de tarefas apontarão para o respectivo ponto de entrada de cada uma das tarefas. Assim, quando o agendador inicia a execução da tarefa 1, ele carrega o contexto da tarefa e desvia para a mesma. Após um intervalo de tempo (um quantum, o heart-beat ou batida de coração do sistema operacional) o agendador interrompe a tarefa 1 (que se encontrava no ponto hipotético designado “b”), salva o contexto da mesma, carrega o contexto da tarefa 2 e desvia para a mesma, após outro heart-beat o agendador interrompe a tarefa 2 (que se encontrava no ponto “d”), salva o contexto, carrega o contexto da tarefa 3 e esta operação segue até que todas as tarefas sejam executadas e em seguida reinicia o ciclo. Neste caso, observe que quando o agendador retornar para a tarefa 1, a execução prosseguirá do ponto “b” que era o ponto anterior em que estava a tarefa 1.

Desta forma, é possível que uma única CPU execute diversos programas de forma virtualmente simultânea!

É claro que num sistema operacional comercial existe um rígido controle sobre a área de memória e de I/O que cada tarefa pode acessar, impedindo que uma tarefa altere memória que pertence ao kernel do sistema operacional ou a outras tarefas. Este tipo de controle é realizado com a ajuda de hardware dedicado (como as unidades de gerenciamento de memória ou MMUs) e de modos especiais de operação da CPU (modos supervisores ou de execução privilegiada). Além disso, um sistema operacional deverá prover mecanismos para que possa ocorrer a comunicação entre as tarefas (IPC – inter-process communication).

No presente caso, nenhuma destas funcionalidades de hardware está presente, razão pela qual não há como impedir que uma tarefa acesse código ou dados de outra ou do kernel do sistema operacional. Já com relação a comunicação entre tarefas, o tema será visto futuramente.

Implementando um Agendador de Tarefas no RL78

Agora que já temos uma ideia geral de como funciona a comutação de tarefas num sistema multitarefa, vamos tratar do nosso pequeno comutador de tarefas e da sua implementação no RL78.

A linha RL78 da Renesas é derivada da linha 78K que por sua vez deriva do precursor da microcomputação em geral, o Intel 8080.

A arquitetura dos RL78 inclui um conjunto de registradores de uso geral de 8 bits : A, X, B, C, D, E, H e L que podem ser agrupados para formar registradores de 16 bits: AX, BC, DE e HL (de maneira geral o AX atua como acumulador e os registradores BC, DE e HL podem atuar como apontadores). Além disso, estão disponíveis até 4 bancos de registradores, resultando num total de 32 registradores de 8 bits ou 16 registradores de 16 bits.

Além dos registradores de uso geral, encontramos também um registrador de estado (PSW, de 8 bits), um apontador de pilha (SP de 16 bits), dois registradores de segmentação de memória (ES para dados e CS para programa) e um contador de programa (PC de 20 bits). Todos estes registradores (e outros) estão mapeados na memória, exceto o PC.

A arquitetura permite até 1Mibibyte de endereçamento de memória e predispõe os últimos 64kibibytes de memória para a área de RAM e de registradores.

No que diz respeito ao nosso agendador/comutador de tarefas, o grande número de registradores significa igual quantidade de RAM para armazenamento do contexto da tarefa e um ligeiro impacto no tempo necessário para troca de contexto, já que quanto maior o número de registradores, mais tempo se gasta empilhando e desempilhando os mesmos! Felizmente este tempo não é muito elevado graças a arquitetura CISC com um set de instruções bastante otimizado e rápido, onde uma boa parcela das instruções possui tamanho de um a dois bytes e são executadas em um ciclo de clock (graças a um pipeline de 3 estágios)!

A base do nosso agendador/comutador de tarefas consiste em realizar duas operações essenciais: o salvamento do contexto da tarefa que está sendo paralisada e a recuperação do contexto da tarefa cuja execução será retomada. O salvamento de contexto utiliza instruções assembly PUSH que permitem salvar registradores na pilha (apontada por SP) e a recuperação de contexto utiliza instruções POP que retiram valores da pilha apontada por SP. Uma observação importante: nos RL78 a pilha é decrescente, ou seja, a cada empilhamento (PUSH) o SP é decrementado em 2 e a cada desempilhamento (POP) o SP é incrementado em 2!

Por padrão, o compilador GCC utiliza apenas os três primeiros bancos de registradores para os programas em C, reservando o último para o tratamento de interrupções. Sendo assim, o nosso código de salvamento e recuperação de contexto deverá preservar apenas os três primeiros bancos e poderá fazer uso dos registradores do banco 3 para as operações internas.

A seguir temos o código do nosso agendador de tarefas round-robin. O agendador utiliza a interrupção do timer de intervalo (IT) disponível em todos os RL78. O timer é configurado para gerar uma interrupção a cada 1ms, sendo este o heart-beat do ULWOS. Isto significa que a cada 1ms esta interrupção é disparada, o PC e o PSW são salvos na pilha (da tarefa) e a execução desvia para a função INT_IT.

Dentro da função de tratamento de interrupção ocorrem três operações: o salvamento do contexto (função save_context), o avanço do apontador de tarefa (ulwos_current_task) para a próxima tarefa válida e a recuperação do contexto desta tarefa. Ao final da ISR a pilha atual é a da tarefa a ser executada, os três primeiros bancos de registradores estão com os seus valores recuperados (o contexto anterior da tarefa), então a execução da instrução assembly RETI desempilha o PC e PSW da pilha e o código da nova tarefa passa a ser executado de onde havia parado anteriormente!

Note que a função INT_IT é declarada com o atributo naked. Este atributo informa ao compilador GCC que ele não deve gerar código de entrada (prólogo) e nem de saída (epílogo) para a função. Apesar da observação do manual do GCC não recomendar a utilização de extended inline assembly ou mistura de assembly e C dentro da função, acredito que esta recomendação não pode ser aplicada ao presente caso, pois todos os cuidados com salvamento e recuperação de registradores foram tomados. Além disso, o código da ISR utiliza (como veremos a seguir) apenas o banco de registradores número 3 e o simples fato de ser uma ISR já separa esta função das funções normais chamadas por software.

Observando a simplicidade do código do agendador é fácil perceber que toda a “mágica” é feita pelas funções de salvamento e recuperação de contexto. Então que tal darmos uma olhada na operação das mesmas? Mas antes de seguir, é importante conhecer as estruturas de dados utilizadas para armazenamento do contexto. O ULWOS utiliza três estruturas básicas para armazenamento do estado e contexto das tarefas: ulwos_task_context é um array bidimensional que armazena o conteúdo dos registradores da CPU (AX, BC, DE e HL dos bancos 0, 1 e 2), o array ulwos_taskSP armazena o conteúdo do stack pointer de cada tarefa e o array ulwos_task_stack é um array bidimensional que armazena a pilha de memória de cada tarefa. Atualmente o ULWOS não preserva o estado dos registradores ES e CS, mas isso pode ser facilmente modificado caso necessário!

O salvamento de contexto consiste basicamente em: salvar o conteúdo do SP e salvar o contexto. A pilha da tarefa não necessita ser salva pois cada tarefa possui a sua pilha individual.

Infelizmente o salvamento do SP e do contexto não pode ser feito diretamente em C, já que não há como garantir que o compilador não irá alterar o conteúdo do SP durante a execução do código. Por isso, é melhor utilizar o assembly para termos a garantia de que o conteúdo de todos os registradores da CPU será preservado fielmente. Além disso, o uso de assembly permite escrever o código mais eficiente possível (limitado apenas à eficiência do programador, é claro!).

A seguir temos o código da função de salvamento de contexto. Ela é declarada como inline pois o objetivo é que o compilador insira todo o código da função no local da sua chamada (evitando assim o uso da pilha, o que seria prejudicial ao funcionamento do nosso agendador).

O código assembly é totalmente auto-explicativo, para uma revisão sobre o funcionamento geral do assembly inline no GCC, eu recomendo a leitura do meu artigo “Utilizando Assembly inline do RL78 no GCC”.

Note que save_regs() não é uma função, mas uma macro criada para simplificar e melhorar a legibilidade do código, ela é definida como:

Após a execução da função save_context, todo o conteúdo dos registradores dos bancos 0, 1 e 2 estará preservado na pilha ulwos_task_context da tarefa e o SP da tarefa estará preservado em ulwos_taskSP. O conteúdo do PSW e do PC já foi preservado automaticamente na pilha da tarefa quando ocorreu a interrupção!

O passo seguinte é selecionar a próxima tarefa a ser executada. O agendador round-robin simplesmente incrementa o indicator de tarefa atual (ulwos_current_task) e verifica se o valor corresponde a uma tarefa válida, caso negativo, o indicador retorna a zero,reiniciando o ciclo de tarefas.

Após a seleção da nova tarefa a ser executada, é necessário recuperar o contexto da mesma, de forma que a execução possa ser restabelecida do ponto em que estava anteriormente. A função restore_context é encarregada de tal operação. O código assembly é autoexplicativo e dispensa maiores comentários.

Assim como no caso de save_regs(), restore_regs() nada mais é do que uma macro que desempilha os registradores da memória. Sua definição é a seguinte:

Agora que já temos código capaz de comutar tarefas em execução, é necessário uma função para criar uma nova tarefa. Apesar de isso parecer trivial, na verdade não é, pois criar uma nova tarefa implica em preparar a pilha da mesma, ou seja, armazenar o PSW e o endereço de entrada da tarefa na pilha da mesma, de forma que quando o agendador recuperar o contexto da tarefa a execução seja iniciada do ponto de entrada da tarefa. O ULWOS utiliza o valor 0x86 para inicializar o PSW de uma nova tarefa, este valor habilita as interrupções (IE=1) e seleciona o banco 0 (RP0=RP1=0) e prioridade baixa de interrupções (ISP0=ISP1=1).

Ufa! Agora que já temos um agendador de tarefas funcional e uma função para criar uma nova tarefa, tudo o que é necessário é iniciar o sistema!

Isto significa que devemos configurar o timer de intervalo (IT), habilitar a sua interrupção, inicializar o SP de forma que ele aponte para o topo da pilha da primeira tarefa e em seguida executar uma instrução RETI (retorno de interrupção)!

Aí o leitor pode questionar: mas como assim? Como vamos retornar de uma interrupção se não estamos em uma ISR? A verdade meu caro leitor é que ao utilizar a RETI fazemos com que a CPU desempilhe o PSW e o PC da pilha da tarefa (aqueles que tivemos o trabalho de configurar quando criamos a tarefa), fazendo com que o fluxo do programa seja desviado para o ponto de entrada da tarefa e automaticamente habilitando as interrupções! Ou você não reparou que não há uma instrução EI no código? As interrupções são habilitadas dentro da primeira tarefa pois o PSW que é retirado da pilha (por RETI) possui o bit IE setado!

 

Todas estas funções estão escritas no arquivo ulwos.c. O arquivo ulwos.h, por sua vez, contém as definições básicas de configuração do nosso agendador: o símbolo ULWOS_TASK_STACK_SIZE permite definir o tamanho da pilha de memória reservada para cada tarefa (o valor padrão é 128 bytes) e o símbolo ULWOS_NUM_TASKS determina o número máximo de tarefas admitidas no sistema. É importante que se configure estes parâmetros de forma compatível com a aplicação, em especial o número máximo de tarefas, que deve ser igual ao número de tarefas que serão efetivamente criadas na aplicação.

O tamanho da pilha das tarefas é substancialmente mais complexo de ser definido. Ele vai depender especialmente da complexidade do código da tarefa, da quantidade de chamadas de funções e do número de variáveis locais dentro de todas as funções chamadas pela tarefa. Futuramente poderemos adicionar ao agendador um pequeno sistema de detecção de estouro de pilha, de forma a auxiliar o desenvolvedor.

Exemplos

Bom, agora que já vimos todo o código desta etapa do ULWOS, vamos ver dois exemplos simples da utilização e teste do nosso pequeno agendador de tarefas e aspirante a sistema operacional!

O primeiro é um simples pisca-pisca com dois leds, cada led é comandado por uma tarefa independente. Este exemplo pode ser testado no simulador ou na placa YRPBRL78G13 (ou qualquer outro hardware, desde que se conecte leds aos pinos P76 e P77 ou se altere o programa).

Repare que as tarefas (task1 e task2) são funções declaradas com o atributo noreturn, isto faz com que o compilador não gere código de retorno para a função, reduzindo ligeiramente o o tamanho das mesmas!

O projeto completo para o E2Studio com GCC está disponível no GITHUB.

O segundo exemplo é mais elaborado e foi projetado para o starter kit do RG78/G13 (RSK). Ele cria duas tarefas independentes, uma pisca o led conectado ao pino P63 e a outra escreve uma contagem progressiva no display da placa. O projeto completo para o E2Studio com GCC está disponível no GITHUB.

 

Antes de encerrar este artigo, é importante fazer algumas observações relevantes:

  1. Num sistema multitarefas existe o problema do compartilhamento de recursos: imagine que a tarefa 1 esteja fazendo a leitura de uma variável de 32 bits chamada X, durante este processo a tarefa é interrompida e a tarefa 2 assume e altera o conteúdo de X. Qual seria o resultado disso? Provavelmente o valor lido para X na tarefa 1 será corrompido e provocará uma falha na execução. A solução para o compartilhamento de recursos nestes casos é a utilização de semáforos ou simplesmente desabilitar temporariamente as interrupções durante a execução deste tipo de operação. Obviamente que este tipo de expediente deve ser utilizado com bastante precaução;
  2. Não devem ser utilizadas interrupções de múltiplas prioridades ou, no mínimo, o seu uso deve ser bastante cauteloso, pois elas podem causar o estouro da pilha de memória da tarefa;
  3. Na forma atual do ULWOS, uma tarefa NUNCA deve encerrar, ou seja, ela não deve atingir o seu ponto final de execução e retornar. Lembre-se: as tarefas do ULWOS são funções mas elas não são chamadas pelo código C, logo, se uma destas tarefas tentar retornar, ocorrerá um erro de stack underflow, já que ela desempilhará um endereço de retorno que efetivamente não existe na pilha! Via de regra cada tarefa deve estar codificada dentro do corpo de um loop como while(1) ou similar.

Por hora é isso, espero que este artigo seja útil a todos os leitores e aguarde pois em breve o ULWOS vai ganhar novas funcionalidades como: tarefa idle com modo low power, contador de tempo em idle, status de tarefas e semáforos!

ULWOS – Multitarefa no Renesas RL78
Classificado como:                                    

Deixe uma resposta