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.
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. Guardarmillis()em umintoulongcom sinal reintroduz o erro imediatamente, eintem um AVR transborda após 32 segundos. - Nunca teste igualdade.
now == targetdepende 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.
- ProjetoConstrua um plotter de caneta cartesiano de dois eixos
- ProjetoConstrua um robô de cobertura que percorre todo o ambiente
- ProjetoConstrua um robô de coleta e colocação que sabe quando falhou
- ProjetoConstrua um robô Mecanum que se desloca lateralmente
- ProjetoConstrua um robô mini sumo: preensão, cunha e detecção de borda
- Caminho de aprendizagemFundamentos de eletrônica de robôs: roteiro de aprendizagem
Leitura complementar