Tutorial · Iniciante · 30 min

Por que delay() quebra robôs: temporização com millis()

Um laço bloqueado não enxerga nada. Veja o custo de delay() em milímetros, o padrão millis() e o erro do transbordamento.

Introdução

Quase todo robô deste site executa um laço que precisa fazer várias coisas ao mesmo tempo: ler um sensor, atualizar um controlador, acionar os motores e talvez atender a comandos de rádio. delay() é a primeira ferramenta de temporização que qualquer pessoa aprende, e é a que torna tudo isso impossível.

O problema não é que delay() seja lento. É que, durante um delay(), o robô não está fazendo absolutamente nada — não lê sensores, não controla o movimento e não percebe que está prestes a bater em uma parede. É um programa que parou voluntariamente.

Esta página trata do que isso realmente custa, do padrão que o substitui e do único erro que a substituição óbvia ainda tem.

O que delay() realmente faz

delay(50) fica girando em um laço apertado, observando um temporizador até que 50 ms tenham passado. Seu código não avança. As únicas coisas que continuam acontecendo são interrupções — o temporizador que incrementa millis(), a interrupção de recepção serial e tudo o que você anexou com attachInterrupt().

Essa exceção importa e também é uma armadilha: um encoder contado em uma interrupção continua contando durante um delay(), então o número continua certo enquanto o comportamento do robô fica parado. Isso torna a falha mais difícil de ver, não mais fácil.

O que custa, em milímetros

Um laço de controle só pode reagir na frequência em que faz amostragens. Algo que aparece logo depois de uma leitura só é percebido na próxima.

Duas linhas do tempo empilhadas ao longo de 220 milissegundos. A superior, rotulada como atraso de 50 milissegundos entre leituras, mostra leituras de sensor como marcas altas a cada 50 milissegundos, com os intervalos sombreados e marcados como bloqueados, sem nada mais em execução. A inferior, rotulada como millis com um laço de 5 milissegundos, mostra leituras agrupadas a cada 5 milissegundos. Uma linha vermelha pontilhada marca um obstáculo aparecendo em 51 milissegundos; a linha superior o percebe 49 milissegundos depois, e a inferior, 4 milissegundos depois.
O obstáculo aparece logo depois de uma leitura, que é quase o pior caso para os dois laços — o número que importa, porque um laço de controle é julgado pela pior latência, não pela média. A 0,5 m/s, o robô bloqueado percorre 24 mm antes de saber; o consultado, 2 mm. Baixar SVG

Vinte e quatro milímetros equivalem a quase toda a matriz de sensores de um seguidor de linha. Também é mais que suficiente para sair de uma célula de labirinto, ultrapassar uma interseção ou inclinar um robô autoequilibrado além do ponto de recuperação — este precisa de um laço interno de 200 Hz, que é um orçamento de 5 ms para tudo, e um único delay(50) gasta o equivalente a dez laços. Latência é sempre uma distância — a distância que o robô percorre antes de poder agir é o que decide se ele para a tempo ou sai da borda.

O padrão que o substitui

Em vez de parar até chegar a hora, pergunte a cada passagem se já é hora:

unsigned long lastRead = 0;
const unsigned long READ_INTERVAL = 50;

void loop() {
  unsigned long now = millis();

  if (now - lastRead >= READ_INTERVAL) {
    lastRead = now;
    readSensor();
  }

  // everything else keeps running, every pass
  updateMotors();
  checkBumper();
}

Agora loop() roda milhares de vezes por segundo e a leitura do sensor acontece no intervalo correto dentro dele. Nada é bloqueado, e adicionar uma segunda tarefa temporizada custa quatro linhas, não uma reescrita.

O erro do transbordamento

millis() retorna um unsigned long — 32 bits em um AVR — contando milissegundos desde a inicialização. Ele acaba em 2³² ms e volta para zero, o que equivale a 49,7 dias.

Há duas maneiras de escrever a mesma comparação, e só uma sobrevive a isso:

if (now - lastRead >= READ_INTERVAL)   // correct
if (now >= lastRead + READ_INTERVAL)   // wrong

Considere lastRead = 4294967290 com um intervalo de 100 ms, pouco antes do transbordamento. lastRead + 100 estoura para 94, e now >= 94 fica imediatamente verdadeiro — então o temporizador deixa de esperar e dispara a cada passagem até o contador alcançar esse valor. A forma com subtração não tem esse problema, porque a subtração sem sinal transborda exatamente de modo que a diferença sai certa.

Três regras decorrem disso:

  • Sempre subtraia, nunca some. now - last >= interval.
  • Mantenha todos os registros de tempo como unsigned long. Guardar millis() em um int ou long com sinal reintroduz o erro imediatamente, e int em um AVR transborda após 32 segundos.
  • Nunca teste igualdade. now == target depende de sorte; uma passagem ocupada passa direto e o evento nunca dispara.

micros() merece seu próprio aviso. Ele transborda em 2³² microssegundos, o que equivale a 71,6 minutos — um tempo de operação perfeitamente normal para um robô. As mesmas regras se aplicam, mas o transbordamento ocorre muito antes.

Várias coisas, em frequências diferentes

Quando houver mais de duas ou três tarefas temporizadas, organize-as em uma tabela em vez de uma pilha de variáveis:

struct Task {
  unsigned long last;
  unsigned long interval;
  void (*run)();
};

Task tasks[] = {
  { 0,   5, readSensors   },
  { 0,  10, updateControl },
  { 0, 200, reportStatus  },
};

void loop() {
  unsigned long now = millis();
  for (Task &t : tasks) {
    if (now - t.last >= t.interval) {
      t.last = now;
      t.run();
    }
  }
}

Esse é um escalonador cooperativo, e é tudo de que a maioria dos robôs realmente precisa. Duas observações sobre ele:

t.last = now reinicia o relógio a partir de quando a tarefa realmente executou, portanto uma passagem atrasada faz a próxima também atrasar e a cadência deriva lentamente. t.last += t.interval preserva a cadência — é melhor para um laço de controle, mas pior se uma tarefa demorar mais que seu intervalo, pois ela tentará recuperar o tempo e executará em sequência. Use += no laço cujo intervalo é importante e = now para todo o restante.

E tudo depende de nenhuma tarefa bloquear. Um delay() dentro de reportStatus() faz todas as outras tarefas atrasarem.

Quando bloquear ainda é a resposta certa

Nem toda espera é um erro. Verifique se a espera é mais curta que uma passagem do laço.

  • delayMicroseconds(10) para produzir o pulso de disparo de um HC-SR04 tudo bem. Dez microssegundos não são nada, e não há uma forma não bloqueante de produzi-lo.
  • pulseIn() não é aceitável. Ele bloqueia até o pulso de retorno chegar ou o tempo limite expirar. Um HC-SR04 no limite de 4 m faz um percurso de ida e volta de 8 m, que a 343 m/s leva 23 ms — e um eco perdido custa todo o tempo limite que você definiu. É por isso que aquele tutorial exige que você defina um.
  • Serial.print() esconde um atraso. Ele retorna imediatamente enquanto o buffer de 64 bytes tem espaço e bloqueia quando não tem. A 9600 baud, o buffer leva 67 ms para esvaziar, portanto imprimir uma linha de 40 caracteres a cada passagem a 100 Hz certamente bloqueará o laço. Use 115200, imprima menos e faça a impressão em uma tarefa lenta.

O tempo de execução do seu laço é um número real

Pare de estimá-lo:

unsigned long worst = 0;

void loop() {
  unsigned long t0 = micros();

  // ... the whole loop body ...

  unsigned long dt = micros() - t0;
  if (dt > worst) worst = dt;   // report this on a slow timer, not here
}

Acompanhe a pior execução, não a média. A média diz que o laço está tranquilo; a pior diz com o que o laço de controle realmente precisa lidar, e normalmente é um Serial.print, um tempo limite de pulseIn ou uma leitura I²C que teve de esperar.

Isso importa mais para um controlador PID. O termo derivativo divide uma mudança de erro pelo tempo entre as amostras, então a incerteza (jitter) nesse intervalo se transforma diretamente em ruído em D — e ganhos ajustados em uma frequência de laço deixam de estar corretos em outra. Ou execute o laço em um intervalo fixo e trate dt como constante, ou meça dt a cada passagem e use o valor real. Não fazer nenhum dos dois é o motivo de um controlador que funcionava na bancada se comportar de outro modo depois que o código de rádio foi adicionado.

O que usar em vez disso

Em vez de Use
delay() para espaçar uma leitura de sensor Verificação de intervalo com millis()
delay() para piscar um LED Um temporizador no mesmo laço
delay() para esperar um servo chegar Estime o tempo de percurso e verifique millis()
delay() para fazer debounce de um botão Registre o flanco e ignore mudanças por 20 ms
delay() entre passos de velocidade do motor Um perfil de movimento dirigido pelo tempo decorrido
delay() para desacelerar o laço inteiro Nada — execute rápido e temporize cada tarefa individualmente
pulseIn() sem tempo limite pulseIn(pin, HIGH, 30000) e um ramo de “sem eco”

Quando algo dá errado

Sintoma Causa provável
O robô ignora o para-choque enquanto vira Um delay() durante a curva
As leituras dos sensores chegam em rajadas Tudo compartilhando um temporizador em vez de intervalos separados
Funciona na bancada, fica lento com saída de log Serial.print bloqueando quando o buffer enche
O temporizador dispara continuamente após cerca de 50 dias A forma now >= last + interval
O temporizador se comporta mal após cerca de uma hora Transbordamento de micros() aos 71,6 minutos
O temporizador nunca dispara Registro de tempo guardado em int, ou um teste de igualdade
PID bem ajustado, mas se comporta de outro jeito A frequência do laço mudou; dt não é o que os ganhos pressupõem
O encoder conta certo, mas o robô reage com atraso As interrupções continuam rodando durante delay() — as contagens não são o problema

O hábito que vale construir é tratar loop() como algo que sempre precisa retornar rapidamente. Quando isso é verdade, adicionar um recurso é adicionar uma tarefa, e o robô mantém seus reflexos. Quando não é, cada novo recurso torna o robô um pouco mais cego, e a causa nunca fica onde o sintoma aparece.

Explore o grafo

Faz parte destas montagens

Projetos e caminhos de aprendizagem que incluem este tutorial.

Leitura complementar

Referências