O básico sobre atrasos em microcontroladores

Introdução

Ao trabalhar com programação é comum a invocação de rotinas que atrasam a execução do código por algum tempo (delay, sleep, wait, etc.), seja para esperar para que algum recurso esteja disponível (e.g. fazer com que uma tarefa espere que uma mensagem chegue a um buffer) ou para garantir que certo período de tempo passou até a continuidade do código (e.g. desde um atraso para garantir que um LED pisque a uma certa frequência, até casos mais complexos, como quando da configuração de alguns periféricos que se faz necessário aguardar algum tempo entre o envio de comandos).

Observando como essas rotinas de atraso são implementadas no mais baixo nível de abstração é uma tarefa importante, pois ajuda a entender quais tipos de implementações são adequadas para cada situação, assim melhorando o conhecimento geral de como os sistemas embarcados funcionam.

Atraso por instrução de máquina

Uma das instruções de máquina mais importantes e presente em várias arquiteturas de computadores é a instrução NOP. Sua função é, basicamente, fazer nada. Quando a instrução NOP é invocada, nada de especial acontece com o processador, porém ainda há um período de execução associado a ela, inerente à própria arquitetura da máquina.

Uma das representações mais simples da execução completa de uma instrução de máquina e que pode ser usada para ter uma noção de quanto tempo (em ciclos de clock) uma instrução é executada está exemplificada abaixo (vale considerar que isso pode variar de arquitetura e da existência de pipelines de execução):

  • Primeiramente a instrução é buscada na memória do programa
  • Em seguida essa instrução (binário) é decodificada para que o processador compreenda o que deve ser feito (se por acaso for uma instrução de soma, a instrução deve apontar quais operandos e o destino do resultado da operação, que pode ser armazenado em registradores, variáveis em espaços de memória e pode envolver operandos com valores constantes descritos na própria instrução)
  • Por fim, a instrução é executada.

Considerando que cada uma das duas primeiras etapas levam um ciclo de clock do processador para serem processadas, enquanto a etapa de execução pode levar o mesmo tempo ou mais, a depender da instrução. Por exemplo, instruções de soma com operandos em memória terão que primeiramente carregar os operandos em registradores antes da execução da soma, que irá consumir o tempo adicional para tal carregamento. No caso da instrução NOP, não há razão para que essa última etapa leve mais que um ciclo de clock.

Mesmo que uma instrução tenha que passar por essas três etapas, não quer dizer que ela leva três ciclos de clock (no mínimo) para ser executada. Na verdade, blocos independentes são responsáveis por cada etapa, de forma que quando uma instrução está sendo executada, a próxima já está sendo decodificada em paralelo e a seguinte está sendo buscada. Então, pode-se dizer que a instrução NOP levaria um ciclo de clock para ser executada.

Algumas arquiteturas de computador não implementam a NOP. No lugar, usam instruções que possuem igual efeito (i.e., nenhum), como mover valores cujo destino é o mesmo da origem (mover valor do registrador X para o registrador X), no intuito de obter o atraso de execução da instrução.

Considerando um Arduino UNO que possui o chip ATmega328 operando a um clock de 16MHz, podemos obter um atraso de 1 segundo ao executar 16 milhões dessas instruções NOP em seguida. Não seria nada prático repetir o NOP essa quantidade de vezes, então recorreríamos a um loop. Mas mesmo no loop, cuidados deveriam ser tomados, pois são necessárias mais instruções para que o loop seja implementado (instrução de incremento, para variar o iterador; instrução de pulo condicional, para decidir se o loop vai continuar ou parar, etc.), o que torna o cálculo do atraso um pouco mais complexo.
Porém, existem formas mais sofisticadas de implementar atrasos com instruções de máquina. Um bom exercício é entender a implementação da função delayMicroseconds (as funções mencionadas aqui são implementações do framework Arduino para a arquitetura AVR para placas como Arduino UNO e os detalhes de implementação podem variar de acordo com a placa ou chip utilizado). Para obter atrasos precisos de microssegun  dos por manipulação de software, a função recorre à execução de vários tipos de instruções de máquina e que sempre consideram a frequência do clock do processador. Na implementação da função podemos ver o uso da instrução NOP:

// https://github.com/arduino/ArduinoCore-avr/blob/1.8.6/cores/arduino/wiring.c#L148-L152
...
#elif F_CPU >= 20000000L
...
__asm__ __volatile__ (
    		"nop" "\n\t"
    		"nop" "\n\t"
    		"nop" "\n\t"
     		"nop"); // apenas esperando 4 ciclos
...

Mas outras técnicas são mais comuns, como retornar da função sem fazer nada quando for necessário um atraso de 1 microssegundo, pois o próprio overhead de tempo da chamada da função já consome esse tempo ou executar um número preestabelecido de outras instruções (trecho abaixo é válido apenas para clock de 16MHz):

// https://github.com/arduino/ArduinoCore-avr/blob/1.8.6/cores/arduino/wiring.c#L165C1-L180C25
...
#elif F_CPU >= 16000000L
// para o clock de 16MHz na maioria das placas Arduino

// para um atraso de 1 microssegundo, apenas retorne. O overhead
// da chamada da função consome 14 (16) ciclos, que é 1us
if (us <= 1) return; //  = 3 ciclos, (4 quando if for true)

// o loop a seguir consome 1/4 de microssegundo (4 ciclos)
// por iteração, então deve ser executado quatro vezes para cada
// microssegundo requisitado.
us <<= 2; // x4 us, = 4 ciclos

// contabilizando o tempo consumido nos comandos anteriores,
// foram consumidos 19 (21) ciclos, removendo 5, (5*4=20)
// a variável us é, ao menos 8, então subtrai 5
us -= 5; // = 2 ciclos
...

Considerando que essas funcionalidades já são implementadas, o uso se torna trivial, porém, sem essas funções, a tarefa de implementação de um atraso a nível de ciclos de instrução seria bastante dificultada, necessitando de conhecimentos específicos da arquitetura da máquina. Outro problema com essa abordagem de atraso é que a CPU vai ficar ocupada sem a possibilidade de executar outras tarefas.

Atraso por hardware especializado

Para garantir a precisão na marcação do tempo, o ideal é utilizar um hardware especializado para esse tipo de tarefa. Nesse caso, entram em ação os temporizadores (timers). 

No código do framework Arduino podemos encontrar as funções millis e micros, que internamente acessam os valores dos registrados equivalentes aos temporizadores e retornam seus valores de contagem, com a primeira retornando o valor em milissegundos e a segunda em microssegundos.  Essas funções devem ser ajustadas de acordo com cada microcontrolador, pois cada um possui suas particularidades quanto ao hardware de seus temporizadores.

Porém, mesmo usando temporizadores, o problema de deixar a CPU ocupada até o fim do atraso não é automaticamente resolvido. Se olharmos para a função delay (framework Arduino e arquitetura AVR), que é utilizada para obter atrasos na ordem de milissegundos, nota-se uma implementação simples, esperando que o tempo decorrido, mensurado pelas chamadas de temporizadores da função micros, seja o valor de atraso estipulado (busy-waiting).

// https://github.com/arduino/ArduinoCore-avr/blob/1.8.6/cores/arduino/wiring.c#L106

void delay(unsigned long ms)
{
uint32_t start = micros();

while (ms > 0) {
    		yield();
    		while ( ms > 0 && (micros() - start) >= 1000) {
        	ms--;
        	start += 1000;
    		}
}
}

Mas ainda assim, a CPU fica bloqueada até o término do atraso. Uma forma de contornar isso é reimplementar essa função ao redor do código da aplicação, fazendo com que o código que não depende do atraso execute normalmente, mas que o tempo ainda seja monitorado para executar o código dependente do atraso quando o momento certo chegar. Por exemplo, veja o template abaixo (podem ser utilizadas várias seções que verificam o tempo decorrido para implementar códigos dependentes de atrasos com intervalos diferentes):

unsigned long tempo_inicial = micros(); // ou millis()

void loop()
{
unsigned long tempo_agora = micros(); // ou millis()

if ( (tempo_agora - tempo_inicial) >= ATRASO_ESTIPULADO ) {
    		//
    	// executar código que depende do atraso
    		//

    	// reiniciar a contagem do tempo
    		tempo_inicial = micros(); // ou millis()
}

//
// código que não depende do atraso
//

}

Apesar dessa abordagem resolver o problema do bloqueio da CPU, outro problema surge: se o código não dependente do atraso for muito grande e consumir bastante tempo, a verificação do tempo decorrido vai demorar a ser feita, podendo reduzir a precisão do período que o código dependente do atraso é executado.

Se for necessário precisão na execução do código dependente do atraso devido a algum requisito de tempo real, sem que a CPU fique bloqueada esperando pela sua execução ou sem a necessidade de consultar os temporizadores para saber se o tempo de atraso já foi alcançado, uma alternativa é utilizar os temporizadores com interrupções.

Interrupções são geradas pelo hardware para indicar que alguma ação deve ser tomada. No contexto de um temporizador, uma interrupção seria disparada quando sua contagem do tempo programado fosse alcançada. Assim, para tratar da interrupção, a execução da CPU é interrompida o mais rápido possível para que uma função especial seja chamada (ISR ou Interrupt Service Routine) e execute o trecho de código dependente do atraso. O artigo “Timers do ATmega328 no Arduino” aborda detalhes sobre o uso de interrupções com os temporizadores do ATmega328.

Após tratada a interrupção, a execução do código retorna ao normal para o ponto em que foi interrompido. Mas ainda assim, a pessoa desenvolvedora deve ter o cuidado de que o código executado no contexto da interrupção seja o mais breve possível para evitar atrapalhar a operação normal do programa ou até o disparo de outras interrupções.

Conclusões

Esse artigo explorou algumas abordagens de como atrasos são implementados em microcontroladores. Apesar de terem sido utilizados exemplos baseados no ATmega328 do Arduino UNO, a metodologia de implementação pode ser expandida para outras plataformas, considerando as especificidades de cada hardware.

Foi ressaltado a diferença entre os atrasos obtidos por meio de software e hardware. O atraso por software obtido através da execução de instruções de máquina pode ser útil para pequenos intervalos de tempo, da ordem de microssegundos, quando necessário esperar por alguma resposta a algum comando, porém, para atrasos mais longos e precisos, o uso do atraso pelo hardware de temporizadores pode ser ideal.

Também foi visto que, mesmo utilizando temporizadores, problemas como o bloqueio da CPU ainda podem existir em algumas implementações, mas que podem ser contornados ao adaptar as rotinas padrões de atraso para operar em conjunto com a aplicação. No final, a necessidade de verificar ativamente o valor de tempo (polling) pode ser substituída por uma interrupção partindo do próprio hardware do temporizador.

Cabe à pessoa desenvolvedora conhecer essas técnicas e saber utilizá-las quando necessário, sempre lembrando que não há implementação certa ou errada, mas sim aquela que melhor se adapta à aplicação.

Referências

Para maiores informações, visite os links:

Licença Creative Commons Esta obra está licenciada com uma Licença Creative Commons Atribuição-CompartilhaIgual 4.0 Internacional.
Comentários:
Inscrever-se
Notificar de
0 Comentários
mais recentes
mais antigos Mais votado
Feedbacks embutidos
Ver todos os comentários
Home » Hardware » O básico sobre atrasos em microcontroladores

EM DESTAQUE

WEBINARS

VEJA TAMBÉM

JUNTE-SE HOJE À COMUNIDADE EMBARCADOS

Talvez você goste: