Detailed content
⏳ Why delay() gets in the robot’s way
In Track 1, delay(500) it seemed harmless: blink the LED and that’s it. But during those 500 ms, the processor does nothing else. It doesn’t read the distance sensor, adjust the motor, or notice the stop button. A rover moving at 0.5 m/s travels 25 cm "with its eyes closed" during every half-second wait. With one sensor and an LED, you might not notice; with Wi-Fi, PID, and telemetry running together, the robot starts hitting things, oscillating, and missing commands.
🌱 New here?
A function blocking only returns control to the program when it finishes. delay(), while waiting for a button and pulseIn() with no timeout are blocking. A non-blocking function does a small part of the work, then exits quickly so the rest of the code can run.
How to read this map: each blue bar represents one pass through the program, when it reads sensors and makes decisions. At the top, the delay() leaves only two slices per second; the rest (in red) is blind time. Below, the same program without waiting takes one short slice after another and sees the world all the time.
✓ Signs of healthy code
- ✓O loop() makes thousands of passes per second.
- ✓The stop button responds immediately, at any time.
- ✓Timed waits (pulseIn(pino, HIGH, 25000)).
✗ Signs of blocking code
- ✗The robot “takes a while” to react to an obstacle or your phone.
- ✗while (digitalRead(botao) == HIGH) {} waiting for someone.
- ✗Wi-Fi commands arrive late or in bursts.
Why learn this
The PID from module 2.3 only works if it runs at a fixed pace, and the line follower from 2.6 needs to read five sensors hundreds of times per second while sending telemetry. Without removing the blocking calls, none of these parts can work together.
Key concepts
⏱️ millis() and non-blocking tasks
millis() returns how many milliseconds have passed since the board powered on. Instead of waiting, you check the clock: “Have 500 ms passed since I last blinked? Then I’ll blink and note the time.” Each task gets its own timestamp and its own interval. The loop() goes through them all, does only what’s due, and moves on.
How to read this map: each line is a task with its own pace. The marks show when it actually runs. None waits for another: the same check from the last line, repeated with different clocks, fits them all into the same loop().
Goal: run four tasks in the same loop(), without delay(), and measure how many times the loop runs per second. In Wokwi: ESP32 + LED on GPIO 2 + potentiometer on GPIO 34.
// Four tasks in the same loop, without delay() (ESP32, esp32 package 3.x)
const int PINO_LED = 2; // LED blinks every 500 ms
const int PINO_BOTAO = 0; // board's BOOT button (LOW = pressed)
const int PINO_POT = 34; // potentiometer (input-only pin, ADC1)
unsigned long tLed = 0, tPot = 0, tSerial = 0, voltas = 0; // passes: since the last report
bool ledLigado = false;
int leituraPot = 0, botaoAntes = HIGH;
void setup() {
Serial.begin(115200);
pinMode(PINO_LED, OUTPUT); pinMode(PINO_BOTAO, INPUT_PULLUP);
Serial.println("Four tasks without delay: ready");
}
void loop() {
unsigned long agora = millis();
voltas++;
if (agora - tLed >= 500) { // task 1: blink
tLed = agora; ledLigado = !ledLigado; digitalWrite(PINO_LED, ledLigado);
}
if (agora - tPot >= 20) { // task 2: read the sensor 50 times per second (0 to 4095)
tPot = agora; leituraPot = analogRead(PINO_POT);
}
if (agora - tSerial >= 1000) { // task 3: report
tSerial = agora;
Serial.printf("pot=%d | loop passes in the last second=%lu\n", leituraPot, voltas);
voltas = 0;
}
int botao = digitalRead(PINO_BOTAO); // task 4: button, at any time
if (botaoAntes == HIGH && botao == LOW) Serial.println("Button pressed");
botaoAntes = botao;
}
How to check
- ✓The LED blinks once per second, always at the same pace, while you turn the potentiometer.
- ✓The report shows hundreds of thousands of passes per second, and “Button pressed” appears immediately.
- ✗Add a delay(500) at the end of the loop: the number of passes drops to 2 and the button starts missing presses.
💡 What happens when millis() “rolls over”?
The counter returns to zero after about 49 days. If you always compare using subtraction (agora - tLed >= 500) and variables unsigned long, the calculation stays correct even when it rolls over. Avoid agora >= tLed + 500: this form breaks.
Why learn this
This is the simplest technique, and it works on any board, including ESP32-C3 and C6, which have only one core. For most robots in Track 2, a well-organized loop with millis() is enough.
Key concepts
🔀 A real state machine
As the behavior grows, the if get mixed up: “if it’s moving and sees a wall, but isn’t already turning, unless...” The state machine brings order: the robot is always in one named state (WAITING, MOVING, BACKING UP, TURNING); each state knows what to do with the motors and which events take it to the next one. Time in each state comes from millis(), so nothing blocks.
How to read this map: each capsule is a state, and each blue arrow is a transition with its condition written beside it. The dashed red arrow applies to all states: the button stops the robot in any state. Draw this on paper before writing the switch.
Goal: the Track 1 rover with its obstacle-avoidance logic rewritten as a state machine. Connections: TB6612FNG (PWMA 25, AIN1 26, AIN2 27, PWMB 14, BIN1 16, BIN2 17, STBY to 3V3), HC-SR04 (TRIG 13, ECHO 4 through a voltage divider to 3.3 V), BOOT button.
// Rover with a state machine (ESP32 3.x + TB6612FNG + HC-SR04)
enum Estado { ESPERANDO, ANDANDO, RECUANDO, GIRANDO };
const char *NOMES[] = { "ESPERANDO", "ANDANDO", "RECUANDO", "GIRANDO" };
Estado estado = ESPERANDO;
unsigned long inicioEstado = 0, tSom = 0;
float dist = 999;
const int PWMA = 25, AIN1 = 26, AIN2 = 27; // left motor
const int PWMB = 14, BIN1 = 16, BIN2 = 17; // right motor
const int TRIG = 13, ECHO = 4, BOTAO = 0, VEL = 170; // VEL: cruising speed (0 to 255)
void motor(int pwm, int in1, int in2, int v) { // v from -255 to 255; ledcWrite takes the pin (3.x)
digitalWrite(in1, v > 0); digitalWrite(in2, v < 0); ledcWrite(pwm, abs(v));
}
void andar(int esq, int dir) { motor(PWMA, AIN1, AIN2, esq); motor(PWMB, BIN1, BIN2, dir); }
float distanciaCm() {
digitalWrite(TRIG, LOW); delayMicroseconds(2); digitalWrite(TRIG, HIGH); delayMicroseconds(10); digitalWrite(TRIG, LOW);
unsigned long us = pulseIn(ECHO, HIGH, 25000); // at most 25 ms of waiting
return us == 0 ? 999 : us / 58.0; // 0 = nothing ahead
}
void mudarPara(Estado novo) {
estado = novo; inicioEstado = millis();
Serial.printf("[%lu ms] -> %s\n", inicioEstado, NOMES[novo]);
}
bool botaoApertado() { // press edge only
static int antes = HIGH;
int agora = digitalRead(BOTAO);
bool borda = (antes == HIGH && agora == LOW);
antes = agora; return borda;
}
void setup() {
Serial.begin(115200);
pinMode(AIN1, OUTPUT); pinMode(AIN2, OUTPUT); pinMode(BIN1, OUTPUT); pinMode(BIN2, OUTPUT);
ledcAttach(PWMA, 20000, 8); ledcAttach(PWMB, 20000, 8); // pin, 20 kHz, 8 bits
pinMode(TRIG, OUTPUT); pinMode(ECHO, INPUT); pinMode(BOTAO, INPUT_PULLUP);
mudarPara(ESPERANDO);
}
void loop() {
unsigned long noEstado = millis() - inicioEstado;
bool botao = botaoApertado();
if (millis() - tSom >= 60) { tSom = millis(); dist = distanciaCm(); } // ping every 60 ms
if (botao && estado != ESPERANDO) { andar(0, 0); mudarPara(ESPERANDO); return; }
switch (estado) {
case ESPERANDO: andar(0, 0); if (botao) mudarPara(ANDANDO); break;
case ANDANDO: andar(VEL, VEL); if (dist < 20) mudarPara(RECUANDO); break;
case RECUANDO: andar(-VEL, -VEL); if (noEstado > 400) mudarPara(GIRANDO); break;
case GIRANDO: andar(VEL, -VEL); if (noEstado > 350) mudarPara(ANDANDO); break;
}
}
How to check (wheels off the ground first)
- ✓The Serial Monitor shows each transition: [5123 ms] -> MOVING, then BACKING UP and TURNING as you bring your hand closer.
- ✓Press BOOT to stop the motors immediately, in any state.
- ✗Wheels turning the wrong way? Swap that motor's wires or invert the sign of v.
Two common mistakes: one delay(400) inside BACKING UP (brings back blind time) and unnamed states (mode = 3), which nobody remembers the meaning of a month later.
Why learn this
Competition robots, vacuum cleaners, and Nav2 in Track 4 all think in states. The transition log becomes the robot’s diary: when something goes wrong, you can see what state it was in and what made it change.
Key concepts
🧠 FreeRTOS on ESP32: tasks and cores
The ESP32 already runs a small operating system underneath Arduino: FreeRTOS. Your loop() that’s its job, on core 1; Wi-Fi and Bluetooth run on core 0. You can create your own tasks, each with an for (;;) infinite loop, priority, and core. When a task calls vTaskDelay(), it sleeps and the scheduler gives the processor to another task. Here, waiting isn’t blind time.
🌱 New here?
A RTOS (real-time operating system) ensures the most important task runs when it needs to. A task is a function that runs “in parallel” with the others. The scheduler decides who uses each core at any given moment, based on priority: a higher number goes first.
How to read this map: time runs from left to right. On core 1, the control task (ctrl) wakes up every 10 ms on the dot and the loop() uses the spare time. On core 0, telemetry (tele) shares time with Wi-Fi. Splitting things this way prevents a network spike from delaying motor control.
| xTaskCreatePinnedToCore parameter | What it is |
|---|---|
| tarefaControle | the function with the for (;;) |
| “control” | name; appears in error messages |
| 4096 | stack in bytes on the ESP32 (in standard FreeRTOS, it’s measured in words) |
| NULL | parameter passed to the function |
| 2 | priority (the loop() has 1) |
| &hControle | “handle” for pausing or deleting the task later |
| 1 | core: 0 or 1 (C3 and C6 only have core 0) |
Goal: a control task running at exactly 100 Hz on core 1 (reads the potentiometer and adjusts the LED brightness) and a telemetry task on core 0. Same circuit as in topic 2.
// Two FreeRTOS tasks on different cores (dual-core ESP32, esp32 package 3.x)
const int PINO_LED = 2, PINO_POT = 34;
TaskHandle_t hControle = NULL, hTelemetria = NULL;
volatile int brilho = 0; // written by the control task, read by telemetry
volatile unsigned long ciclos = 0;
void tarefaControle(void *param) {
TickType_t ultimo = xTaskGetTickCount();
const TickType_t periodo = pdMS_TO_TICKS(10); // 100 times per second, with no drift
for (;;) {
brilho = analogRead(PINO_POT) / 16; // 0..4095 becomes 0..255
ledcWrite(PINO_LED, brilho); ciclos++;
vTaskDelayUntil(&ultimo, periodo); // sleeps until the next 10 ms tick
}
}
void tarefaTelemetria(void *param) {
for (;;) {
Serial.printf("core %d | brightness=%d | control cycles=%lu\n", xPortGetCoreID(), brilho, ciclos);
vTaskDelay(pdMS_TO_TICKS(500)); // yields the core for 500 ms
}
}
void setup() {
Serial.begin(115200);
ledcAttach(PINO_LED, 5000, 8);
// function, name, stack (bytes), parameter, priority, handle, core
xTaskCreatePinnedToCore(tarefaControle, "controle", 4096, NULL, 2, &hControle, 1);
xTaskCreatePinnedToCore(tarefaTelemetria, "telemetria", 4096, NULL, 1, &hTelemetria, 0);
Serial.printf("setup ran on core %d\n", xPortGetCoreID());
}
void loop() { vTaskDelay(pdMS_TO_TICKS(1000)); } // loop() is also a task; here it just rests
How to check
- ✓"setup ran on core 1" and, every half second, "core 0 | brightness=... | control cycles=..."
- ✓The cycle count increases by about 50 between lines (100 per second).
💡 “Task watchdog got triggered”
If a task enters a for (;;) that never calls vTaskDelay (or waits for a queue or semaphore), it never releases the core, the idle task doesn’t run, and the watchdog restarts the board. Every task needs to sleep or wait somewhere in its loop.
Why learn this
With Wi-Fi, MQTT, and PID running together, control needs a clock that the network can’t disrupt. vTaskDelayUntil provides exactly that: the same dt on every cycle, which is what the PID in module 2.3 assumes.
Key concepts
📬 Queues and semaphores: tasks communicating
Two tasks changing the same variable at the same time can end up reading incomplete data. FreeRTOS provides two safe ways to pass things around. A queue (queue) delivers messages in order: the sensor task adds a reading, and the motor task removes it. If the queue is empty, the waiting task sleeps without using processor time. A mutex is a single key: only the task holding it can use the resource (Serial, the SD card, the I2C bus), and then it returns the key.
How to read this map: below, data moves from left to right through the queue (the lighter squares are already occupied). Above, the dashed lines show both tasks requesting Serial, but only the one holding the key can write; the other waits its turn.
Goal: the sensor task sends readings through a queue; the actuator task adjusts the LED and, if no data arrives for 100 ms, shuts everything off (safe stop). Both write to Serial protected by a mutex.
// Queue (sensor -> actuator) and mutex (shared Serial), FreeRTOS on the ESP32
struct Leitura { uint32_t t_ms; int valor; };
QueueHandle_t fila;
SemaphoreHandle_t travaSerial;
const int PINO_LED = 2, PINO_POT = 34;
void logSeguro(const char *texto) {
if (xSemaphoreTake(travaSerial, pdMS_TO_TICKS(50)) == pdTRUE) { // gets the key
Serial.println(texto);
xSemaphoreGive(travaSerial); // returns the key
}
}
void tarefaSensor(void *p) {
for (;;) {
Leitura l = { (uint32_t)millis(), analogRead(PINO_POT) };
if (xQueueSend(fila, &l, 0) != pdTRUE) logSeguro("queue full: reading discarded");
vTaskDelay(pdMS_TO_TICKS(20));
}
}
void tarefaAtuador(void *p) {
Leitura l; bool alto = false; char msg[64];
for (;;) {
if (xQueueReceive(fila, &l, pdMS_TO_TICKS(100)) == pdTRUE) { // sleeps until data arrives
ledcWrite(PINO_LED, l.valor / 16);
bool agoraAlto = l.valor > 3500;
if (agoraAlto != alto) { // reports only when it changes
alto = agoraAlto; snprintf(msg, sizeof msg, "%lu ms: value %s (%d)", (unsigned long)l.t_ms, alto ? "alto" : "normal", l.valor);
logSeguro(msg);
}
} else {
ledcWrite(PINO_LED, 0); // no data for 100 ms: safe stop
logSeguro("no reading: actuator stopped");
}
}
}
void setup() {
Serial.begin(115200);
ledcAttach(PINO_LED, 5000, 8);
fila =xQueueCreate(10, sizeof(Leitura)); // holds 10 readings
travaSerial = xSemaphoreCreateMutex();
xTaskCreatePinnedToCore(tarefaSensor, "sensor", 3072, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(tarefaAtuador, "atuador", 4096, NULL, 2, NULL, 1);
logSeguro("queue and mutex ready");
}
void loop() { vTaskDelay(pdMS_TO_TICKS(1000)); }
How to check
- ✓The LED brightness follows the potentiometer; turn it all the way up to see “high value,” then turn it back to see “normal value.”
- ✓Comment out the xQueueSend: the LED turns off and “no reading: actuator stopped” appears. This is the safe stop from module 2.1, now between tasks.
✓ Use a queue when
- ✓One task produces data and another consumes it (sensor → control, control → log).
- ✓You want the consumer to sleep until something arrives, with a timeout.
✗ Pitfalls
- ✗Take the mutex and forget to return it: the other tasks will block forever.
- ✗Large shared struct without protection: data gets read halfway through.
Why learn this
This is how telemetry in module 2.5 will work without delaying control: the control loop only puts a packet in the queue, and another task, with no rush, writes it to the card or sends it over the network.
Key concepts
🗂️ Organizing code into files and classes
A 600-line sketch in a single file turns into a maze. Arduino IDE 2.x supports multiple tabs in the same folder: the .h says what a component knows how to do, and the .cpp says how. A class brings together data (the motor pins) and functions (turn, brake). Creating two objects from the same class, one for each wheel, eliminates repetition.
Step by step in Arduino IDE 2.x
Save the sketch
Save as robo-organizado. The IDE creates a folder with the same name and the .ino inside.
Create the tabs
In the three-dot menu next to the tabs, choose "New Tab" and create Motor.h and then Motor.cpp.
Paste the three blocks and upload
Each block below goes in the tab with the same name. The IDE compiles everything together. Test with the robot suspended, wheels off the ground.
// Motor.h: one DC motor on a TB6612FNG H-bridge channel
// (the DRV8833 has no separate PWM pin: adapt velocidade() to use PWM on IN1/IN2)
#pragma once
#include <Arduino.h>
class Motor {
public:
Motor(int pinoPwm, int pinoIn1, int pinoIn2, bool invertido = false);
void begin(); // configure pins and PWM (API 3.x)
void velocidade(int v); // -255 (reverse) to 255 (forward); 0 = coast
void freio(); // lock the wheel
int atual() const { return _v; }
private:
int _pwm, _in1, _in2, _v = 0;
bool _inv;
};
#include "Motor.h"
Motor::Motor(int pinoPwm, int pinoIn1, int pinoIn2, bool invertido)
: _pwm(pinoPwm), _in1(pinoIn1), _in2(pinoIn2), _inv(invertido) {}
void Motor::begin() {
pinMode(_in1, OUTPUT); pinMode(_in2, OUTPUT);
ledcAttach(_pwm, 20000, 8); // 20 kHz (inaudible), 8 bits = 0..255
velocidade(0);
}
void Motor::velocidade(int v) {
v = constrain(v, -255, 255); _v = v;
if (_inv) v = -v; // motor mounted as a mirror image
digitalWrite(_in1, v > 0); digitalWrite(_in2, v < 0);
ledcWrite(_pwm, abs(v));
}
void Motor::freio() { // TB6612FNG: IN1 = IN2 = HIGH locks
digitalWrite(_in1, HIGH); digitalWrite(_in2, HIGH); ledcWrite(_pwm, 255); _v = 0;
}
// robo-organizado.ino: the main sketch stays short and readable
#include "Motor.h"
Motor esquerdo(25, 26, 27); // PWMA, AIN1, AIN2
Motor direito(14, 16, 17, true); // PWMB, BIN1, BIN2 (mounted as a mirror image)
const int SEQ[] = { 150, 0, -150, 0 };
void setup() {
Serial.begin(115200);
esquerdo.begin();
direito.begin();
Serial.println("Motors ready: test with the wheels off the ground");
}
void loop() {
for (int v : SEQ) {
esquerdo.velocidade(v);
direito.velocidade(v);
Serial.printf("left=%d right=%d\n", esquerdo.atual(), direito.atual());
delay(1000); // this is fine here: it's a bench test, not the robot moving around
}
}
How to check
- ✓Both wheels turn forward, stop, turn backward, and stop every second, in the same direction.
- ✗One wheel turning the wrong way? Swap the true on that side in the constructor, without changing any wires.
Why learn this
Organized code makes it possible to return to the robot after a month, split the work across a group, or replace the H-bridge without rewriting everything. One tab per component (Motor, BarraIR, Pid, Telemetria), and the .ino just connects the components: it's the same format as the libraries you install through the IDE. Keep the folder in a Git repository and commit each adjustment that works.
Key concepts
✅ Module summary
To go further
- FreeRTOS on ESP-IDF (Espressif) — tasks, cores, and differences from standard FreeRTOS
- Queues in FreeRTOS — official kernel documentation
- Arduino-ESP32 (Espressif) — reference for package 3.x
- millis() in the Arduino reference
- Wokwi documentation — all the examples in this module run there
Next module
2.5 · Telemetry and data logging: see what the robot senses and decides, live and in a file.