>_ ctrlaltbrian
Tutorials ESP32 Arduino Raspberry Pi Pico About Queue

ctrlaltbrian

Arduino Robotics: servos, steppers, and sensors

Build small robots with Arduino. Servo arms, stepper motor control, ultrasonic distance, and the math that ties it all together.

14 chapters · ~6 hours · last updated 2026-09-24

$19

Chapter 01

Arduino: blink an LED (the classic first project)

arduino · 10 min

If you have an Arduino and you have not done anything with it yet, this is the project. It is also the project I do whenever I want to confirm a board works before I trust it with anything more interesting.

The blink sketch is short on purpose. You learn:

  • How to write a sketch
  • How the setup() and loop() functions work
  • How to set a pin as an output
  • How to use delay() to pause

What you need

  • Any Arduino board (Uno, Nano, Mega, MKR)
  • USB cable

You do not need an external LED or resistor. Every Arduino has an LED soldered to pin 13 (on some boards it is a different pin, but it is always on).

Install the Arduino IDE

Download from https://www.arduino.cc/en/software. The regular IDE, not the Web Editor.

For the Uno and Nano, no additional board installation is needed. Plug in the USB cable, the IDE should see it.

For the Nano Every, Nano 33, MKR, or Portenta boards, you will need to install the corresponding board package. The IDE will offer to install it when you select the board.

The sketch

File >> Examples >> 01.Basics >> Blink

That opens the canonical version:

Arduino (Uno, Nano, Mega)

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);
  digitalWrite(LED_BUILTIN, LOW);
  delay(1000);
}

LED_BUILTIN is a constant that points to whatever pin the onboard LED is on. For the Uno and Nano, that is pin 13. For other boards it might be different, but LED_BUILTIN is always right.

ESP32 (Arduino)

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);
  digitalWrite(LED_BUILTIN, LOW);
  delay(1000);
}

Same code, same LED_BUILTIN. The ESP32's onboard LED is on GPIO 2 (most dev boards), and the core defines LED_BUILTIN to point to it.

MicroPython (ESP32 or Pico)

from machine import Pin
import time

led = Pin(2, Pin.OUT)   # ESP32: GPIO 2. Pico: Pin(25, Pin.OUT)

while True:
    led.toggle()
    time.sleep(0.5)

On the Pico, use Pin("LED", Pin.OUT) (the original Pico: GPIO 25; the Pico W: the onboard LED on the Wi-Fi chip).

Raspberry Pi Python

The Raspberry Pi does not have an onboard blinkable LED in the same sense as a microcontroller (the activity LED on the Pi is wired to the SoC, not a GPIO pin you can drive). The closest equivalent is wiring an external LED to a GPIO pin:

from gpiozero import LED
import time

led = LED(17)   # BCM pin 17 (physical pin 11)

while True:
    led.toggle()
    time.sleep(0.5)

pip3 install gpiozero. The Pi's GPIO library is solid; this is the simplest pattern. If you want a hardware-PWM-dimmed LED, that is covered in the Pi LED dimming tutorial.

Upload it

  1. Select your board: Tools >> Board >> Arduino Uno (or whatever you have).
  2. Select the port: Tools >> Port >> COM3 (Windows) or /dev/cu.usbserial-1410 (macOS).
  3. Click Upload (or Ctrl+U).

The IDE compiles, uploads, and the LED on the board starts blinking.

What you learned

  • setup() runs once when the board powers up or resets.
  • loop() runs forever, as fast as it can.
  • pinMode(pin, OUTPUT) configures a pin as an output.
  • digitalWrite(pin, HIGH) sets a pin to 5V (or 3.3V on 3.3V boards).
  • digitalWrite(pin, LOW) sets it to 0V.
  • delay(1000) pauses for 1000 milliseconds.

Adding an external LED

The onboard LED is pin 13, but you can use any digital pin. Try pin 9 with an external LED and a 220 ohm resistor in series:

Arduino pin 9 --[ 220R ]-- LED anode (+) -- LED cathode (-) -- GND

LEDs are polarized. The longer leg (anode) goes to the resistor, the shorter leg (cathode) goes to ground. If your LED has a flat spot on the plastic, that is the cathode side.

void setup() {
  pinMode(9, OUTPUT);
}

void loop() {
  digitalWrite(9, HIGH);
  delay(1000);
  digitalWrite(9, LOW);
  delay(1000);
}

Making it fade instead of blink

Pin 9 supports PWM (Pulse Width Modulation), which lets you dim LEDs:

void setup() {
  pinMode(9, OUTPUT);
}

void loop() {
  for (int brightness = 0; brightness <= 255; brightness++) {
    analogWrite(9, brightness);
    delay(5);
  }
  for (int brightness = 255; brightness >= 0; brightness--) {
    analogWrite(9, brightness);
    delay(5);
  }
}

analogWrite() is not actually analog. It is PWM: a fast on/off signal where the duty cycle changes. For an LED, your eye sees it as dimming. For a motor, it sees it as slower speed.

PWM pins on the Uno: 3, 5, 6, 9, 10, 11. The pin number has a tilde (~) next to it on the board silkscreen.

When the upload fails

  • Port not found. Drivers. For the Uno, the CH340 or CP2102 driver may need installing. On modern Windows 10/11 it usually works automatically.
  • avrdude: stk500_recv(): programmer is not responding. The board is not in the right port, or the bootloader is corrupted. Try a different USB cable (yes, really; this fixes it more often than I want to admit), then try holding the reset button while clicking upload.
  • Sketch uses too much program storage space. You went over 32 KB. Pick a smaller sketch.

What to build next

  • A button-controlled LED (combines with the button debounce tutorial).
  • A traffic light sequence on three LEDs.
  • A "morse code blinker" that flashes your name in SOS.

The morse code version is one of the next tutorials on this site. The traffic light version is in the book Arduino Basics.


Chapter 02

Arduino: read a potentiometer and print the value

arduino · 15 min

After blink, this is the project that teaches you how the Arduino reads the real world. A potentiometer is a knob that varies its resistance as you turn it. Wire it to an analog pin and the Arduino reports the position as a number from 0 to 1023.

This is the foundation for "dim an LED with a knob," "control a motor speed with a knob," and "use a sensor that outputs an analog voltage."

What you need

  • Arduino (Uno, Nano, etc.)
  • 10k ohm potentiometer (the panel-mount kind with three legs)
  • Three jumper wires

Wiring

A potentiometer has three legs:

   _______
  /       \
 |    o    |   <-- the shaft (the part you turn)
 |    |    |
 1    2    3
  • Pin 1 (left): goes to GND
  • Pin 2 (middle): goes to A0 (analog input)
  • Pin 3 (right): goes to 5V

If the readings go the wrong way when you turn the knob, swap the GND and 5V wires.

The pot I have on my desk right now has the GND and 5V on the outside pins and the wiper on the middle. That is the convention. If yours is different (e.g. a Bourns panel-mount pot), check the datasheet.

The code

Arduino (Uno, Nano, Mega)

void setup() {
  Serial.begin(9600);
}

void loop() {
  int value = analogRead(A0);
  Serial.println(value);
  delay(100);
}

Upload it. Open the Serial Monitor (Tools >> Serial Monitor or Ctrl+Shift+M). Turn the knob. You should see the value change from 0 (all the way one direction) to 1023 (all the way the other direction).

ESP32 (Arduino)

The ESP32's analog input has a different range (0-4095 instead of 0-1023) and accepts up to 3.3V. Most potentiometers wired to 3.3V work the same way:

void setup() {
  Serial.begin(115200);
}

void loop() {
  int value = analogRead(34);   // GPIO 34 is an ADC-capable input
  Serial.println(value);
  delay(100);
}

The ESP32's ADC has a known non-linearity, especially at the high and low ends of the range. For a "just turn the knob and read" project, this is fine. For a calibrated sensor readout, average a few samples or use a dedicated ADC chip.

MicroPython (ESP32 or Pico)

The ESP32 and Pico both have ADCs. Same wiring, different pin numbers.

from machine import ADC, Pin
import time

# ESP32: GPIO 34 (ADC1_CH6). Pico: GP26 (ADC0).
pot = ADC(Pin(34))

while True:
    raw = pot.read_u16()      # 0-65535
    print(f'Raw: {raw}')
    time.sleep(0.1)

read_u16() returns a 16-bit value (0-65535). For a "0-100%" mapping, divide by 65535 and multiply by 100.

Raspberry Pi Python

The Raspberry Pi does not have a built-in ADC. To read a potentiometer on a Pi, use an external ADC chip like the MCP3002 (10-bit, SPI) or ADS1115 (16-bit, I2C). The wiring and chip selection are covered in the Pi ADC tutorial.

What you learned

  • analogRead(pin) reads the voltage on an analog pin and returns a value from 0 (0V) to 1023 (5V on a 5V Arduino, 3.3V on a 3.3V Arduino).
  • The Arduino's ADC (analog-to-digital converter) is 10-bit, which is why the range is 0-1023 instead of 0-255.
  • Reading takes about 100 microseconds. You can read at up to about 10 kHz if you need to.

Mapping the value to something useful

The 0-1023 range is rarely what you want. Use map() to rescale:

int raw = analogRead(A0);
int brightness = map(raw, 0, 1023, 0, 255);
analogWrite(9, brightness);

This maps the raw reading to the PWM range (0-255). Turn the knob and the LED on pin 9 fades up and down.

Smoothing noisy readings

A pot is mechanical. The wiper bounces a little, and the ADC has its own noise. If you are reading a sensor that needs to be steady (e.g. a temperature dial in a UI), average several readings:

int smoothRead(int pin) {
  int total = 0;
  for (int i = 0; i < 16; i++) {
    total += analogRead(pin);
  }
  return total / 16;
}

16 samples is a good default. More samples = smoother but slower to react. For a pot driving a UI, 16 is fine. For a fast-changing sensor signal, fewer samples.

Reading other analog sensors

Anything that outputs 0-5V works on the analog pins. Common ones:

  • Photoresistor (LDR): resistance changes with light. Wire it as a voltage divider with a 10k resistor.
  • Thermistor: resistance changes with temperature. Same voltage divider.
  • Soil moisture sensor: outputs 0-3V depending on wetness.
  • Flex sensor: resistance changes when bent.
  • Microphone breakout (e.g. MAX4466): outputs the audio waveform.

For all of these, the code is the same: analogRead(pin).

When the reading is stuck at 0 or 1023

  • 0: the pot is turned all the way to GND, or the wiper (middle pin) is disconnected.
  • 1023: the pot is turned all the way to 5V, or the wiper is shorted to 5V or GND.
  • Settles at 512: the wiper is disconnected (middle pin not connected).

Double-check the wiring. Potentiometers are easy to wire backwards.

When you need more precision

The Arduino's 10-bit ADC is fine for most projects. If you need 12-bit or 16-bit, use an external ADC chip:

  • ADS1115: 16-bit, I2C, 4 channels, about $2.
  • MCP3002: 10-bit, SPI, 2 channels.

These are also covered in the book Arduino Sensors.

What to build next

  • A knob-controlled LED dimmer.
  • A knob-controlled servo (rotate a small motor to match the knob).
  • A "meter" display on an OLED that shows the value as a bar.

The knob-controlled servo is in the book Arduino Robotics. The OLED meter is one of the next tutorials on this site.


Chapter 03

Arduino: control a servo motor

arduino · 20 min

A hobby servo is the motor I default to for "I need to move something to a specific angle." The little SG90 blue servos are about $1.50 and they will turn to any angle from 0 to 180 degrees on command.

This tutorial gets you from a fresh Arduino to a knob-controlled servo in about 20 minutes.

What you need

  • Arduino (Uno, Nano, etc.)
  • SG90 (small, weak) or MG996R (bigger, stronger) servo
  • Jumper wires
  • Optional: a potentiometer for the knob-control version

Wiring

Servos have three wires:

  • Red: power (5V on the Arduino)
  • Brown or black: ground
  • Orange or yellow: signal

The signal wire goes to a digital pin. Pin 9 is the most common pick.

Servo red    -- Arduino 5V
Servo brown  -- Arduino GND
Servo orange -- Arduino pin 9

The SG90 is small enough to power from the Arduino's 5V pin. The MG996R is not. If your servo stalls or jitters, it is drawing too much current from the Arduino. Use an external 5V supply, and connect the grounds together.

Install

No library needed for basic servo control. The Arduino IDE has Servo.h built in:

File >> Examples >> Servo >> Sweep

That opens the canonical sweep sketch.

The basic code

Arduino (Uno, Nano, Mega)

#include <Servo.h>

Servo myServo;

void setup() {
  myServo.attach(9);
}

void loop() {
  for (int angle = 0; angle <= 180; angle++) {
    myServo.write(angle);
    delay(15);
  }
  for (int angle = 180; angle >= 0; angle--) {
    myServo.write(angle);
    delay(15);
  }
}

Upload it. The servo should sweep back and forth between 0 and 180 degrees.

delay(15) gives the servo time to physically reach the position. Servos are slow. 15 ms per degree is enough; less than that and the servo will stutter.

ESP32 (Arduino)

The ESP32 Arduino core does not bundle Servo.h. Use the ESP32Servo library (Sketch >> Include Library >> Manage Libraries >> search ESP32Servo). The API is the same:

#include <ESP32Servo.h>

Servo myServo;

void setup() {
  myServo.attach(9);
}

void loop() {
  for (int angle = 0; angle <= 180; angle++) {
    myServo.write(angle);
    delay(15);
  }
  for (int angle = 180; angle >= 0; angle--) {
    myServo.write(angle);
    delay(15);
  }
}

On the ESP32, the LEDC peripheral drives the servo, which means the PWM is more accurate than the Uno's software-timed version. For most hobby servos the difference does not matter.

MicroPython (ESP32 or Pico)

from machine import Pin, PWM
import time

# ESP32: GPIO 9. Pico: GP9.
pwm = PWM(Pin(9), freq=50)

def write_angle(angle):
    duty = int(65535 * (0.05 + (angle / 180.0) * 0.05))
    pwm.duty_u16(duty)

while True:
    for angle in range(0, 181):
        write_angle(angle)
        time.sleep_ms(15)
    for angle in range(180, -1, -1):
        write_angle(angle)
        time.sleep_ms(15)

This is the MicroPython equivalent of Servo.write(). The Pico tutorial on servos has the full library wrapper that hides this math.

Raspberry Pi Python

The Raspberry Pi does not have hardware PWM on every GPIO (only GPIO 12, 13, 18, 19 have a hardware PWM peripheral). For hobby servos, use the gpiozero library, which uses software PWM:

from gpiozero import Servo
from time import sleep

servo = Servo(17)

while True:
    for angle in [-1, -0.5, 0, 0.5, 1]:
        servo.value = angle
        sleep(0.5)

pip3 install gpiozero. Servo.value takes a float from -1 (0 degrees) to 1 (180 degrees), with 0 being the center. The library handles the 50 Hz PWM internally. If you need precise angle control in degrees, write a wrapper:

def set_angle(angle):
    servo.value = (angle / 180.0) * 2 - 1

Why the servo jitters

The three usual causes:

  1. Insufficient power. The Arduino's 5V pin can deliver about 400 mA total (on USB power). A servo under load can draw that much. Use an external supply.
  2. Shared ground missing. If you are using an external supply, the Arduino's GND must be connected to the supply's GND, or the signal reference is floating.
  3. Noisy signal. Long wires or wires near motors pick up noise. Add a 100uF capacitor across the servo's power pins.

The knob-controlled version

Wire a potentiometer (10k) to A0 as in the previous tutorial. Then:

#include <Servo.h>

Servo myServo;

void setup() {
  myServo.attach(9);
  Serial.begin(9600);
}

void loop() {
  int raw = analogRead(A0);
  int angle = map(raw, 0, 1023, 0, 180);
  myServo.write(angle);
  delay(15);
}

Turn the knob, the servo follows. This is the foundation for "pan/tilt camera," "robotic arm," and "steering for a small robot."

Controlling multiple servos

Servo.h supports up to 12 servos on most boards (6 on the Nano, 48 on the Mega):

#include <Servo.h>

Servo servoA;
Servo servoB;
Servo servoC;

void setup() {
  servoA.attach(9);
  servoB.attach(10);
  servoC.attach(11);
}

void loop() {
  servoA.write(0);
  servoB.write(90);
  servoC.write(180);
  delay(1000);

  servoA.write(180);
  servoB.write(90);
  servoC.write(0);
  delay(1000);
}

For more than 12 servos, you need an external PWM driver (e.g. PCA9685 over I2C). The PCA9685 version is in the book Arduino Robotics.

Continuous rotation servos

There is a variant called a "continuous rotation servo." Same wiring, but write(angle) controls speed and direction instead of position:

  • write(90): stop
  • write(180): full speed one direction
  • write(0): full speed the other direction
  • write(45): half speed one direction

These are great for small wheeled robots. The trade-off is that you cannot control position with them; they are speed-control only.

When the servo just makes a clicking sound

The signal pin is on the wrong pin, or attach() got the wrong number. Re-check servo.attach(9) against the actual wiring.

If the signal is right and you still get clicking, the servo is being asked to move to a position it cannot reach (e.g. beyond 180 degrees, or against a physical stop). Lower the angle range.

What to build next

  • A pan/tilt bracket for a camera or sensor.
  • A 4-DOF robotic arm (uses 4 servos).
  • A walking robot with 8-12 servos.

The pan/tilt version is one of the next tutorials on this site. The robotic arm is in the book Arduino Robotics.


Chapter 04

Arduino: use I2C to talk to sensors and displays

arduino · 30 min

I2C is the protocol that almost every modern Arduino sensor uses. Two wires (SDA and SCL), up to 127 devices on the same bus, and a standardized address scheme. Once you understand I2C, you can wire up an OLED display, a temperature sensor, an accelerometer, and a port expander on the same two pins.

This tutorial covers the protocol basics, the wiring, and the I2C scanner that will save you hours of debugging.

What you need

  • Arduino (Uno, Nano, etc.)
  • Any I2C device (BME280, OLED display, MPU6050, etc.)
  • Jumper wires

Wiring

I2C has four wires total:

Signal Arduino pin (Uno/Nano)
SDA A4
SCL A5
VCC 5V (or 3.3V, depending on device)
GND GND

For the Mega, SDA is pin 20 and SCL is pin 21. For the Nano 33 IoT, SDA is pin A4 and SCL is pin A5 (same as the Uno, despite the different name).

Most breakout boards (e.g. Adafruit, SparkFun) have pull-up resistors already. If you are using bare sensors without a breakout, add 4.7k pull-ups from SDA to VCC and SCL to VCC.

The I2C scanner

When a sensor does not respond, the first thing I run is the I2C scanner. This sketch tells you every address on the bus:

Arduino (Uno, Nano, Mega)

#include <Wire.h>

void setup() {
  Serial.begin(9600);
  Wire.begin();
  Serial.println("I2C Scanner");
}

void loop() {
  for (byte addr = 1; addr < 127; addr++) {
    Wire.beginTransmission(addr);
    if (Wire.endTransmission() == 0) {
      Serial.print("Device found at 0x");
      if (addr < 16) Serial.print("0");
      Serial.println(addr, HEX);
    }
  }
  Serial.println("---");
  delay(2000);
}

Upload it. Open Serial Monitor. You should see the address(es) of any connected I2C device.

ESP32 (Arduino)

#include <Wire.h>

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);   // SDA, SCL on the ESP32
  Serial.println("I2C Scanner");
}

void loop() {
  for (byte addr = 1; addr < 127; addr++) {
    Wire.beginTransmission(addr);
    if (Wire.endTransmission() == 0) {
      Serial.print("Device found at 0x");
      if (addr < 16) Serial.print("0");
      Serial.println(addr, HEX);
    }
  }
  Serial.println("---");
  delay(2000);
}

Same code, but you usually need to pass the SDA/SCL pins to Wire.begin() on the ESP32 (defaults to GPIO 21/22 on most cores).

MicroPython (ESP32 or Pico)

from machine import I2C, Pin

# ESP32: GPIO 21 (SDA), 22 (SCL)
# Pico: GPIO 0 (SDA), 1 (SCL)
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100_000)

print('I2C devices:')
for addr in i2c.scan():
    print(f'  0x{addr:02X}')

This is the equivalent of the I2C scanner: it lists every address on the bus. i2c.scan() returns a list of integers; the f-string formats each as a 2-digit hex address.

Raspberry Pi Python

import smbus2

bus = smbus2.SMBus(1)   # /dev/i2c-1 on most Pi images

print('I2C devices:')
for addr in range(0x03, 0x78):
    try:
        bus.read_byte(addr)
        print(f'  0x{addr:02X}')
    except OSError:
        pass

Enable I2C on the Pi first: sudo raspi-config >> Interface Options >> I2C >> Enable. Then pip3 install smbus2.

Common addresses:

  • 0x3C or 0x3D: OLED displays
  • 0x76 or 0x77: BME280, BMP280
  • 0x68: MPU6050, DS3231 RTC
  • 0x57: AT24C32 EEPROM

If you see no devices, the wiring is wrong, the device is unpowered, or the device uses a different address than you expected.

Reading from a BME280 example

Once you know the address, you can talk to it.

Arduino (Uno, Nano, Mega)

#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

Adafruit_BME280 bme;

void setup() {
  Serial.begin(9600);
  Wire.begin();
  if (!bme.begin(0x76)) {   // change to 0x77 if scanner found it there
    Serial.println("Could not find BME280");
    while (1);
  }
}

void loop() {
  Serial.print("Temp: ");
  Serial.print(bme.readTemperature());
  Serial.print(" C, Humidity: ");
  Serial.print(bme.readHumidity());
  Serial.print(" %, Pressure: ");
  Serial.print(bme.readPressure() / 100.0);
  Serial.println(" hPa");
  delay(2000);
}

This is the canonical pattern: Wire.begin(), library constructor with the address, then call the library's methods.

ESP32 (Arduino)

#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

Adafruit_BME280 bme;

void setup() {
  Serial.begin(115200);
  Wire.begin(21, 22);   // SDA, SCL on the ESP32
  if (!bme.begin(0x76)) {
    Serial.println("Could not find BME280");
    while (1);
  }
}

void loop() {
  Serial.print("Temp: ");
  Serial.print(bme.readTemperature());
  Serial.print(" C, Humidity: ");
  Serial.print(bme.readHumidity());
  Serial.print(" %, Pressure: ");
  Serial.print(bme.readPressure() / 100.0);
  Serial.println(" hPa");
  delay(2000);
}

Same library, same code. The ESP32 version just passes the I2C pins explicitly and uses a faster serial baud.

MicroPython (ESP32 or Pico)

from machine import I2C, Pin
import time

i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100_000)

BME280_ADDR = 0x76

def read_bme280():
    data = i2c.readfrom_mem(BME280_ADDR, 0xF7, 8)
    press_raw = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4)
    temp_raw  = (data[3] << 12) | (data[4] << 4) | (data[5] >> 4)
    hum_raw   = (data[6] << 8)  | data[7]
    return temp_raw / 5120.0, hum_raw / 1024.0, press_raw / 256.0 / 100.0

print('BME280 reading (raw, not calibrated)')
while True:
    t, h, p = read_bme280()
    print(f'{t:.2f} C, {h:.2f} %, {p:.2f} hPa')
    time.sleep(2)

For calibrated readings on MicroPython, install the official BME280 driver: mip install bme280 on the Pico, or copy bme280.py from https://github.com/micropython-IMU/micropython-bme280.

Raspberry Pi Python

Install the proper driver:

pip3 install bme280 smbus2

Then:

from smbus2 import SMBus
import bme280
import time

bus = SMBus(1)
addr = 0x76

print('BME280 ready')
while True:
    data = bme280.sample(bus, addr)
    print(f'{data.temperature:.2f} C, {data.humidity:.2f} %, {data.pressure:.2f} hPa')
    time.sleep(2)

bme280.sample() returns a BME280Reading with temperature, humidity, pressure, and timestamp fields. The driver handles all the calibration math.

Why I2C devices can have two addresses

Most I2C chips have an "address select" pin. The BME280's SDO pin determines whether the I2C address is 0x76 (SDO to GND) or 0x77 (SDO to VCC). Some boards hard-wire one or the other. If your scanner finds 0x77 and your code says 0x76, you have the wrong address. Fix the code.

The bus speed

The default I2C speed is 100 kHz. Most devices support 400 kHz (called "Fast Mode"). To speed it up:

Wire.setClock(400000);

Some devices only work at 100 kHz (older sensors, some EEPROMs). If a device stops responding after you bump the speed, drop it back to 100 kHz.

Common gotchas

  1. No pull-ups. Bare I2C devices need external pull-ups. Breakout boards usually have them.
  2. Mixed voltage. 5V Arduino talking to 3.3V sensor will eventually fry the sensor. Use a level shifter, or pick a sensor with a 5V-tolerant version (e.g. the Adafruit BME280 board has a 3.3V regulator and level-shifted I2C lines, so it works on 5V Arduinos).
  3. Two devices with the same address. You cannot have two devices at the same address on the same bus unless one has a way to change its address. For the rare case you do, use an I2C multiplexer (TCA9548A).
  4. Wire length. I2C is meant for short distances (under 1 m on a hobbyist setup, much less at 400 kHz). For longer runs, use RS-485 or CAN, not I2C.

Reading from multiple devices

The pattern is the same. Construct each device with its address:

Adafruit_BME280 indoor;     // 0x76
Adafruit_BME280 outdoor;    // 0x77
Adafruit_SSD1306 display;   // 0x3C

void setup() {
  Wire.begin();
  indoor.begin(0x76);
  outdoor.begin(0x77);
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
}

All three live on the same two wires. The library handles the rest.

When I2C hangs

If Wire.endTransmission() never returns, or the bus is stuck low, the most common cause is a missing pull-up. The SDA or SCL line is floating and the device is holding it low waiting for an ACK that never comes.

Fix: power cycle the Arduino (the bus reset is hardware-level). Then add the missing pull-ups.

For a software reset of the bus:

Wire.end();
delay(100);
Wire.begin();

But the hardware reset is more reliable.

What to build next

  • An OLED display showing sensor data (I2C display + I2C sensor).
  • A weather station with multiple I2C sensors.
  • An I2C-controlled motor driver (e.g. DRV2605 haptic motor controller).

The OLED weather station is one of the next tutorials on this site. The multi-sensor version is in the book Arduino Sensors.


Chapter 05

Arduino: read temperature with a DS18B20 one-wire sensor

arduino · 25 min

The DS18B20 is the temperature sensor I reach for when accuracy matters. It is a "one-wire" sensor, which means you can put multiple DS18B20s on the same single Arduino pin and read each one individually.

This tutorial gets you from a fresh DS18B20 to a reading on the Serial Monitor in about 25 minutes, then shows you how to add more sensors on the same wire.

What you need

  • Arduino (Uno, Nano, etc.)
  • DS18B20 (the bare TO-92 package, or the waterproof stainless steel probe)
  • 4.7k resistor (for the pull-up)
  • Jumper wires

The waterproof version comes pre-wired with red (VCC), black (GND), and yellow (data). It is about $3 and is great for outdoor projects.

Wiring

DS18B20 GND -- Arduino GND
DS18B20 DATA -- Arduino pin 2 --[ 4.7k pull-up ]-- Arduino 5V
DS18B20 VCC -- Arduino 5V

That pull-up resistor is mandatory. Without it, the one-wire bus does not work.

For multiple DS18B20s on the same pin, just connect all the data lines to the same Arduino pin. Each DS18B20 has a unique 64-bit address burned into it, so the Arduino can tell them apart.

Install libraries

In the Arduino IDE:

  • Sketch >> Include Library >> Manage Libraries >> search OneWire by Paul Stoffregen. Install.
  • Also install DallasTemperature by Miles Burton. Install.

The code

Arduino (Uno, Nano, Mega)

#include <OneWire.h>
#include <DallasTemperature.h>

#define ONE_WIRE_BUS 2

OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

void setup() {
  Serial.begin(9600);
  sensors.begin();
}

void loop() {
  sensors.requestTemperatures();
  float tempC = sensors.getTempCByIndex(0);
  Serial.print("Temperature: ");
  Serial.print(tempC);
  Serial.println(" C");
  delay(2000);
}

Upload it. Open Serial Monitor. You should see the temperature.

ESP32 (Arduino)

#include <OneWire.h>
#include <DallasTemperature.h>

#define ONE_WIRE_BUS 4   // any GPIO; pin 4 avoids the boot-strapping pins

OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

void setup() {
  Serial.begin(115200);
  sensors.begin();
}

void loop() {
  sensors.requestTemperatures();
  float tempC = sensors.getTempCByIndex(0);
  Serial.print("Temperature: ");
  Serial.print(tempC);
  Serial.println(" C");
  delay(2000);
}

Same libraries, same code. The ESP32 version uses 115200 baud over its USB-serial bridge and a different GPIO pin (the Uno's pin 2 is not an GPIO on the ESP32; pick any other pin).

MicroPython (ESP32 or Pico)

from machine import Pin
import onewire, ds18x20
import time

# ESP32: GPIO 4. Pico: GP4.
ow = onewire.OneWire(Pin(4))
sensor = ds18x20.DS18X20(ow)

roms = sensor.scan()
print(f'Found {len(roms)} DS18B20 sensor(s)')

while True:
    sensor.convert_temp()
    time.sleep_ms(750)
    for rom in roms:
        t = sensor.read_temp(rom)
        print(f'Temperature: {t:.1f} C')
    time.sleep(2)

The ds18x20 driver is in micropython-lib. Install with mip install ds18x20 on the Pico, or copy onewire.py and ds18x20.py from the MicroPython repository into the ESP32's /lib/.

Raspberry Pi Python

The Raspberry Pi does not have OneWire support in the kernel's GPIO driver by default. To read a DS18B20 on the Pi:

  1. Enable 1-Wire on the GPIO: add dtoverlay=w1-gpio to /boot/config.txt (or /boot/firmware/config.txt on Bookworm), then reboot.
  2. The sensor shows up at /sys/bus/w1/devices/28-*/w1_slave.
import glob
import time

def read_ds18b20(device_path):
    with open(device_path) as f:
        lines = f.readlines()
    if lines[0].strip()[-3:] != 'YES':
        return None
    raw = lines[1].split('=', 1)[1]
    return int(raw) / 1000.0

devices = glob.glob('/sys/bus/w1/devices/28-*/w1_slave')
print(f'Found {len(devices)} DS18B20 sensor(s)')

while True:
    for path in devices:
        t = read_ds18b20(path)
        if t is not None:
            print(f'{path}: {t:.1f} C')
    time.sleep(2)

This works on any Pi with the 1-Wire overlay enabled. The kernel driver handles the timing; the Python code just reads the sysfs file.

If you see -127.00 C, the sensor is not responding. Check the wiring, especially the 4.7k pull-up.

Reading multiple sensors

Each DS18B20 has a unique address. You can find the address of every sensor on the bus with this:

#include <OneWire.h>
#include <DallasTemperature.h>

#define ONE_WIRE_BUS 2

OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

DeviceAddress addresses[10];   // up to 10 sensors
int numSensors;

void setup() {
  Serial.begin(9600);
  sensors.begin();
  numSensors = sensors.getDeviceCount();
  Serial.print("Found ");
  Serial.print(numSensors);
  Serial.println(" sensors");

  for (int i = 0; i < numSensors; i++) {
    sensors.getAddress(addresses[i], i);
    Serial.print("Sensor ");
    Serial.print(i);
    Serial.print(": ");
    for (int j = 0; j < 8; j++) {
      if (addresses[i][j] < 16) Serial.print("0");
      Serial.print(addresses[i][j], HEX);
    }
    Serial.println();
  }
}

void loop() {
  sensors.requestTemperatures();
  for (int i = 0; i < numSensors; i++) {
    float tempC = sensors.getTempC(addresses[i]);
    Serial.print("Sensor ");
    Serial.print(i);
    Serial.print(": ");
    Serial.print(tempC);
    Serial.println(" C");
  }
  delay(2000);
}

Run the address-discovery sketch first. Copy the addresses into a config file. Then read by address instead of index, so the order does not matter when you swap sensors.

DeviceAddress outsideSensor = {0x28, 0xFF, 0x64, 0x1E, 0xC2, 0x00, 0x00, 0x9A};
DeviceAddress insideSensor  = {0x28, 0xFF, 0x57, 0x32, 0xC2, 0x00, 0x00, 0x4D};

void loop() {
  sensors.requestTemperatures();
  float outTemp = sensors.getTempC(outsideSensor);
  float inTemp  = sensors.getTempC(insideSensor);
  Serial.print("Outside: ");
  Serial.print(outTemp);
  Serial.print(" C  Inside: ");
  Serial.print(inTemp);
  Serial.println(" C");
  delay(2000);
}

Parasitic power mode

There is a wiring variant where the DS18B20 draws power from the data line instead of a separate VCC wire. Wire it like this:

DS18B20 GND -- Arduino GND
DS18B20 DATA -- Arduino pin 2 --[ 4.7k pull-up ]-- Arduino 5V
DS18B20 VCC -- Arduino GND   (yes, both GND and VCC to ground)

Then in code:

sensors.setWaitForConversion(false);

Parasitic power is finicky for sensors on long wires. I use it for short runs (under 3 m) where saving one wire matters. For anything longer, use the normal wiring with three wires.

Why the DS18B20 is great

  • Accuracy: 0.5 C from -10 to 85 C.
  • Range: -55 to 125 C (with degraded accuracy outside the calibrated range).
  • Resolution: configurable from 9 to 12 bits (0.5 C to 0.0625 C).
  • Multiple sensors on one pin: up to about 100 sensors on a single Arduino pin if your wiring is clean.
  • Wire length: up to about 100 m on a single twisted pair.
  • No calibration: each DS18B20 is factory-calibrated and the calibration data is stored in ROM.

That last one is the killer feature for multi-sensor projects. No per-sensor calibration, no per-sensor offset, no per-sensor lookup table. Plug them in and they report the right number.

When to use DS18B20 vs. DHT22 vs. BME280

  • DS18B20: temperature only, accurate, multiple sensors on one wire. Best for "monitor temperature in 5 places" projects.
  • DHT22: temperature and humidity, slow (one reading every 2 s), cheap. Best for "one sensor, indoor, hobbyist" projects.
  • BME280: temperature, humidity, pressure. I2C, fast, accurate. Best for "weather station" projects.

For outdoor projects with a long wire run, the DS18B20 is the right call every time.

What to build next

  • A multi-zone home temperature monitor.
  • A sous-vide controller (DS18B20 + relay + PID loop).
  • A greenhouse monitor with sensors in different soil beds.

The sous-vide controller is one of the next tutorials on this site. The greenhouse version is in the book Arduino Sensors.


Chapter 06

Arduino: drive a 16x2 LCD display (with I2C backpack)

arduino · 25 min

The 16x2 character LCD is the display you find on every project box from the last 20 years. With the I2C backpack, you only need 4 wires (VCC, GND, SDA, SCL) instead of the 16 wires the parallel interface uses.

This tutorial covers the wiring, the I2C address scan (almost always needed), and the most common operations: print text, move the cursor, make custom characters.

What you need

  • Arduino (Uno, Nano, etc.)
  • 16x2 LCD with I2C backpack (e.g. the common HD44780-based one with PCF8574 backpack, about $3)
  • 4 jumper wires

Wiring

LCD VCC -- Arduino 5V
LCD GND -- Arduino GND
LCD SDA -- Arduino A4
LCD SCL -- Arduino A5

That is the entire wiring.

Install library

Sketch >> Include Library >> Manage Libraries >> search LiquidCrystal I2C by Frank de Brabander. Install.

The I2C address

Most I2C LCD backpacks use address 0x27. Some use 0x3F. If your code says 0x27 and the LCD does nothing, run the I2C scanner from the earlier tutorial and use whatever address it reports.

The code

Arduino (Uno, Nano, Mega)

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

LiquidCrystal_I2C lcd(0x27, 16, 2);   // address, columns, rows

void setup() {
  lcd.init();
  lcd.backlight();
  lcd.print("Hello, Arduino!");
}

void loop() {
  // (nothing)
}

Upload it. You should see Hello, Arduino! on the LCD with the backlight on.

If you see white blocks on the first row but no text, the contrast is wrong. Most backpacks have a small potentiometer on the back. Turn it with a screwdriver until the text appears.

ESP32 (Arduino)

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

LiquidCrystal_I2C lcd(0x27, 16, 2);

void setup() {
  Wire.begin(21, 22);   // SDA, SCL on the ESP32
  lcd.init();
  lcd.backlight();
  lcd.print("Hello, ESP32!");
}

void loop() {
  // (nothing)
}

Same library, same code. The only difference is that you pass the I2C pins to Wire.begin() on the ESP32 (defaults to GPIO 21/22).

MicroPython (ESP32 or Pico)

from machine import I2C, Pin
from esp8266_i2c_lcd1602 import I2cLcd1602

i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=100_000)

lcd = I2cLcd1602(i2c, 0x27, 2, 16)
lcd.backlight_on()
lcd.puts('Hello, MicroPython!')

The esp8266_i2c_lcd1602 library works on both ESP32 and Pico. On the Pico, copy the file from https://github.com/T-622/RPI-PICO-I2C-LCD and rename if needed.

Raspberry Pi Python

pip3 install RPLCD

Enable I2C on the Pi first: sudo raspi-config >> Interface Options >> I2C >> Enable.

from RPLCD.i2c import CharLCD

lcd = CharLCD('PCF8574', 0x27, cols=16, rows=2)
lcd.backlight_enabled = True

lcd.write_string('Hello, Pi!')

CharLCD handles the I2C backpack protocol. The first argument is the chip on the backpack (PCF8574 is the most common).

Moving the cursor

The LCD has a 16-column, 2-row character grid. Position (0, 0) is the top left:

lcd.setCursor(0, 0);   // column 0, row 0
lcd.print("Top left");

lcd.setCursor(0, 1);   // column 0, row 1
lcd.print("Bottom row");

lcd.setCursor(5, 1);   // column 5, row 1
lcd.print("Centered?");

Showing sensor values

The most common use is to display a sensor reading. Combine with the DHT22 or DS18B20 tutorial:

#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <DHT.h>

LiquidCrystal_I2C lcd(0x27, 16, 2);
DHT dht(2, DHT22);

void setup() {
  lcd.init();
  lcd.backlight();
  dht.begin();
  lcd.setCursor(0, 0);
  lcd.print("Sensor ready...");
  delay(2000);
  lcd.clear();
}

void loop() {
  float temp = dht.readTemperature();
  float hum  = dht.readHumidity();

  lcd.setCursor(0, 0);
  lcd.print("T:");
  lcd.print(temp, 1);
  lcd.print(" C    ");

  lcd.setCursor(0, 1);
  lcd.print("H:");
  lcd.print(hum, 1);
  lcd.print(" %    ");

  delay(2000);
}

Note the trailing spaces in the print statements. The LCD does not clear old characters when you print fewer new ones. If the new text is shorter, the old digits stay visible. Adding spaces after the new text overwrites them.

A more reliable pattern:

lcd.setCursor(0, 0);
lcd.print("T:");
lcd.print(temp, 1);
lcd.print(" C");

// clear the rest of the row
for (int i = lcd.print("") + 3; i < 16; i++) lcd.print(" ");

Or just lcd.clear() at the start of each update, but that creates a flicker.

Custom characters

The LCD has 8 slots for custom 5x8 pixel characters. Define a "thermometer" icon, a "bell," a "wifi signal," whatever:

byte thermometer[8] = {
  0b00100,
  0b01010,
  0b01010,
  0b01010,
  0b01010,
  0b10001,
  0b10001,
  0b01110
};

void setup() {
  lcd.init();
  lcd.backlight();
  lcd.createChar(0, thermometer);
  lcd.setCursor(0, 0);
  lcd.print("Temp: ");
  lcd.write(0);   // print custom char 0
  lcd.print(" 23.5 C");
}

You can define up to 8 custom characters (slots 0-7). The icon is 8 bytes, each byte is one row of 5 pixels.

The contrast pot

I keep mentioning this because it is the single most common "why does my LCD just show white blocks" issue. Get a small Phillips screwdriver, find the pot on the back of the backpack (usually blue), and turn it slowly while the LCD is powered. The text will appear at some point in the rotation.

Backlight control

Most backpacks have a jumper for the backlight. If you want to control it from code (e.g. to dim the display at night), the backpack pin labeled "LED" can be connected to an Arduino PWM pin:

analogWrite(10, 128);   // pin 10, half brightness

Some backpacks have a transistor for the backlight, and you can drive the transistor directly. Check your backpack's schematic.

When to use a 16x2 LCD vs. an OLED

  • 16x2 LCD: cheap, sunlight-readable, simple. Best for "show one number or two lines of text" projects.
  • OLED display (e.g. SSD1306): more expensive, looks fancier, supports graphics and any font size. Best for "show graphs, custom UI, or anything graphical."

For a basic temperature display, a 16x2 LCD is the right call. For a multi-line UI with progress bars or graphs, use an OLED. The OLED version is in the book Arduino Displays.

What to build next

  • A digital thermometer with min/max tracking.
  • A menu system (Up, Down, Select buttons + LCD).
  • A serial monitor on the LCD (echo anything from Serial to the LCD).

The menu system version is one of the next tutorials on this site.


Chapter 07

Arduino: control relays for home automation

arduino · 30 min

A relay is the chip-sized equivalent of a wall switch. You give it a low- voltage signal from the Arduino, and it switches a high-voltage circuit (e.g. a lamp, a fan, a pump). It is the bridge between "Arduino logic" and "real-world electrical things."

This tutorial covers the safe wiring (mains electricity is dangerous if you mess up), the relay module options, and the basic control code.

Safety first

Mains voltage (110V AC in the US, 230V AC elsewhere) will kill you. Not "hurt you." Kill you. If you are not 100% sure about your wiring, do not touch mains circuits. Use a low-voltage project (5V or 12V) for learning.

That warning out of the way, here is how to do it safely:

  • Use a relay module with opto-isolation (more on this below).
  • Keep mains wiring separate from low-voltage wiring.
  • Use wire nuts or screw terminals, not solder, for mains connections.
  • Mount the relay module in an enclosure.
  • Add a fuse on the mains side.

What you need

  • Arduino (Uno, Nano, etc.)
  • 1-channel, 2-channel, or 4-channel relay module with opto-isolation
  • Jumper wires
  • Wire nuts or screw terminals
  • The device you want to control (lamp, fan, etc.)

Relay module wiring

Most relay modules have three low-voltage pins on one side and three mains terminals on the other:

Low-voltage side (Arduino-facing):

  • VCC: 5V from the Arduino
  • GND: GND from the Arduino
  • IN1, IN2, IN3, IN4: signal pins, one per relay

Mains side:

  • COM: common (the live or hot wire goes here)
  • NO: normally open (the device goes here when you want it off until the relay is activated)
  • NC: normally closed (the device goes here when you want it on until the relay is activated)

For most projects, you use COM and NO. The device is off until the relay fires, then it turns on.

The wiring

For a single lamp:

Mains live wire  ----[ fuse ]----  COM on relay
Lamp live wire   ----  NO on relay
Mains neutral    ----  Lamp neutral
Mains ground     ----  Lamp ground (if the lamp has a ground wire)

If your relay module does not have an opto-isolator, the VCC, GND, and IN pins share a ground with the mains side. That is bad. The opto-isolator breaks that connection. Buy a module with an opto-isolator.

The code

Arduino (Uno, Nano, Mega)

#define RELAY_PIN 7

void setup() {
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, LOW);   // relay off initially
}

void loop() {
  // Turn the relay on for 5 seconds, off for 5 seconds
  digitalWrite(RELAY_PIN, HIGH);
  delay(5000);
  digitalWrite(RELAY_PIN, LOW);
  delay(5000);
}

Upload it. The relay should click every 5 seconds, and the lamp (or fan, or whatever) should turn on and off with it.

ESP32 (Arduino)

#define RELAY_PIN 5   // any GPIO; pin 5 avoids the boot-strapping pins

void setup() {
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, LOW);
}

void loop() {
  digitalWrite(RELAY_PIN, HIGH);
  delay(5000);
  digitalWrite(RELAY_PIN, LOW);
  delay(5000);
}

Same code. Pin 5 is a safe choice on the ESP32 (it is not a boot-strapping pin).

MicroPython (ESP32 or Pico)

from machine import Pin
import time

# ESP32: GPIO 5. Pico: GP5.
relay = Pin(5, Pin.OUT)

while True:
    relay.value(1)
    time.sleep(5)
    relay.value(0)
    time.sleep(5)

Same wiring as the Arduino version. Pin.value(1) is HIGH, Pin.value(0) is LOW.

Raspberry Pi Python

from gpiozero import OutputDevice
from time import sleep

relay = OutputDevice(17)   # BCM pin 17 (physical pin 11)

while True:
    relay.on()
    sleep(5)
    relay.off()
    sleep(5)

pip3 install gpiozero. The Raspberry Pi is a common pick for relay-controlled home automation because it has enough horsepower to run Home Assistant, Node-RED, or a small web UI, and the GPIO header is well-documented for 5V relay modules. The same wiring rules apply: keep the mains wiring separate from the Pi's GPIO, and use a relay module with opto-isolation.

Active HIGH vs. active LOW

Some relay modules are active HIGH (HIGH = relay on), others are active LOW (LOW = relay on, with a pull-down). Check the module's documentation.

You can figure it out by setting the pin to HIGH in setup() and seeing if the relay clicks:

void setup() {
  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, HIGH);   // does the relay click?
}

If it does, your module is active HIGH. If it does not, try LOW.

Multiple relays

#define RELAY1_PIN 7
#define RELAY2_PIN 8
#define RELAY3_PIN 9

void setup() {
  pinMode(RELAY1_PIN, OUTPUT);
  pinMode(RELAY2_PIN, OUTPUT);
  pinMode(RELAY3_PIN, OUTPUT);
}

void loop() {
  digitalWrite(RELAY1_PIN, HIGH);
  delay(1000);
  digitalWrite(RELAY2_PIN, HIGH);
  delay(1000);
  digitalWrite(RELAY3_PIN, HIGH);
  delay(2000);

  digitalWrite(RELAY3_PIN, LOW);
  delay(1000);
  digitalWrite(RELAY2_PIN, LOW);
  delay(1000);
  digitalWrite(RELAY1_PIN, LOW);
  delay(2000);
}

Each relay is just a digital pin. The pattern is the same.

Why use a relay instead of a transistor

A relay provides galvanic isolation. The Arduino and the device you are controlling have no electrical connection. If something goes wrong on the mains side (short, surge), the Arduino is safe.

A transistor (e.g. a MOSFET) shares ground with the device. That is fine for low-voltage DC loads (LEDs, motors, fans at 12V) but not for mains.

Use a relay for:

  • Mains voltage (110V/230V AC) devices
  • Anything where isolation matters (medical, safety)

Use a MOSFET for:

  • Low-voltage DC loads (LEDs, motors)
  • High-frequency switching (PWM motor control)

Mechanical relays vs. solid-state relays

Mechanical relays (the kind in the cheap modules) have moving parts. They click. They have a limited lifetime (about 100,000 cycles). They are cheap and they handle high currents easily.

Solid-state relays (SSRs) have no moving parts. They are silent. They last much longer. They are more expensive and they tend to leak a tiny bit of current when off (which can make some devices flicker or hum).

For most home automation projects, mechanical relays are fine. SSRs are the right call for high-cycle applications (e.g. a PID temperature controller that switches a heater every second).

What to build next

  • A wifi-controlled lamp (combine with the ESP32 MQTT tutorial).
  • A scheduled outlet (relay turns on at sunset, off at midnight).
  • A "wake up light" that gradually turns on a lamp over 30 minutes.

The wifi-controlled version is in the book Home Automation with Arduino. The wake-up light is in the book Arduino Fun Projects.


Chapter 08

Arduino: drive a 28BYJ-48 stepper motor with a ULN2003 driver

arduino · 30 min

The 28BYJ-48 is the stepper motor I use when I want precise position control and I do not need a lot of torque. It is cheap (about $2), it comes with a ULN2003 driver board, and it is the right call for "I need to move this thing to a specific angle, slowly."

This tutorial covers the wiring, the basic stepping code, and the slow-part- of-this-motor-is-X caveat that makes the 28BYJ-48 a learning tool, not a production motor.

What you need

  • Arduino (Uno, Nano, etc.)
  • 28BYJ-48 stepper motor
  • ULN2003 driver board (comes with the motor in most kits)
  • 5 jumper wires
  • External 5V power supply (recommended)

Wiring

The ULN2003 has 4 input pins (IN1-IN4) and 4 output pins (OUT1-OUT4) plus power:

ULN2003 IN1 -- Arduino pin 8
ULN2003 IN2 -- Arduino pin 9
ULN2003 IN3 -- Arduino pin 10
ULN2003 IN4 -- Arduino pin 11
ULN2003 GND -- Arduino GND (and external supply GND)
ULN2003 VCC -- External 5V+ (recommended) OR Arduino 5V (small motors only)

The motor plugs into the ULN2003's white connector. There is only one way to plug it in, so you cannot get this wrong.

The 28BYJ-48 draws about 200 mA when stepping. The Arduino's 5V pin can supply that, but if you are doing anything else on the Arduino at the same time, the voltage can sag and the Arduino can reset. Use an external 5V supply and connect the grounds together.

Install library

Sketch >> Include Library >> Manage Library >> search Stepper (the built-in Arduino library). No install needed; it ships with the IDE.

The code

Arduino (Uno, Nano, Mega)

#include <Stepper.h>

const int stepsPerRevolution = 2048;   // 28BYJ-48 in half-step mode
Stepper myStepper(stepsPerRevolution, 8, 10, 9, 11);
//                  ^                ^  ^   ^   ^
//                  steps            IN1 IN3 IN2 IN4
//                                   (note the pin order!)

void setup() {
  myStepper.setSpeed(10);   // RPM
}

void loop() {
  myStepper.step(stepsPerRevolution);    // one full turn clockwise
  delay(1000);
  myStepper.step(-stepsPerRevolution);   // one full turn counter-clockwise
  delay(1000);
}

Upload it. The motor should turn one full revolution, pause, then turn the other way.

ESP32 (Arduino)

#include <Stepper.h>

const int stepsPerRevolution = 2048;
Stepper myStepper(stepsPerRevolution, 16, 17, 18, 19);
//                  ^                  ^   ^   ^   ^
//                  steps              IN1 IN3 IN2 IN4

void setup() {
  myStepper.setSpeed(10);
}

void loop() {
  myStepper.step(stepsPerRevolution);
  delay(1000);
  myStepper.step(-stepsPerRevolution);
  delay(1000);
}

Same library, same code. Just pick four GPIO pins on the ESP32 that are not boot-strapping pins (avoid 0, 2, 5, 12, 15). Pins 16, 17, 18, 19 are fine.

MicroPython (ESP32 or Pico)

from machine import Pin
import time

IN1 = Pin(16, Pin.OUT)   # ESP32: GPIO 16. Pico: GP16.
IN2 = Pin(17, Pin.OUT)
IN3 = Pin(18, Pin.OUT)
IN4 = Pin(19, Pin.OUT)

# Full-step sequence (energize one coil at a time)
sequence = [
    (1, 0, 0, 0),
    (0, 1, 0, 0),
    (0, 0, 1, 0),
    (0, 0, 0, 1),
]

coils = [IN1, IN2, IN3, IN4]
STEP_DELAY_MS = 5   # 28BYJ-48 at 10 RPM: 60s / 2048 / 2 ~= 15 ms

def step():
    for pattern in sequence:
        for coil, val in zip(coils, pattern):
            coil.value(val)
        time.sleep_ms(STEP_DELAY_MS)

for i in range(2048):
    step()             # one full turn clockwise
time.sleep(1)
for i in range(2048):
    step()             # and the other way

MicroPython is fast enough to drive the 28BYJ-48 with bit-banged output. The Pico's PIO can do this in hardware if you need the CPU for something else.

Raspberry Pi Python

The Raspberry Pi runs Linux, which is not a real-time OS. For an occasional stepper move (e.g. rotating a display), software timing is fine. For position-critical work, an external stepper driver that takes STEP/DIR pulses (like the A4988) is the better pick.

pip3 install RPi.GPIO
import RPi.GPIO as GPIO
from time import sleep

IN1, IN2, IN3, IN4 = 17, 18, 27, 22
sequence = [
    (1, 0, 0, 0),
    (0, 1, 0, 0),
    (0, 0, 1, 0),
    (0, 0, 0, 1),
]

GPIO.setmode(GPIO.BCM)
for pin in (IN1, IN2, IN3, IN4):
    GPIO.setup(pin, GPIO.OUT)

coils = [IN1, IN2, IN3, IN4]
STEP_DELAY = 0.01   # 10 ms between steps

def step():
    for pattern in sequence:
        for coil, val in zip(coils, pattern):
            GPIO.output(coil, val)
        sleep(STEP_DELAY)

for _ in range(2048):
    step()
sleep(1)
for _ in range(2048):
    step()

GPIO.cleanup()

Same wiring as the Arduino version: each IN pin to a Pi GPIO. The Pi can drive the 28BYJ-48 fine; if you find steps are getting dropped, slow the step interval down. For anything that needs precise position control under load, an external driver (DRV8825, TMC2209) is the right move.

Note the pin order in the constructor: 8, 10, 9, 11, not 8, 9, 10, 11. This is a quirk of the Stepper.h library: the second and third pins are swapped to match the 28BYJ-48's internal coil order.

Half-step mode vs. full-step mode

The 28BYJ-48 has a 1:64 gear reduction inside. Combined with the 32 steps per revolution of the actual motor, you get 2048 steps per revolution at the output shaft.

stepsPerRevolution = 2048 is half-step mode (smoother, more torque, slower). stepsPerRevolution = 1024 is full-step mode (less smooth, less torque, faster). Use 2048 unless you have a reason.

Speed

setSpeed(10) sets 10 RPM. The 28BYJ-48's max usable speed is about 15-20 RPM. Above that, it starts missing steps (you tell it to move 100 steps but it only moves 90).

For "I want to position something precisely," slow is fine. For "I want to spin this fast," you want a different motor (e.g. a NEMA 17 with a A4988 driver).

Position control

Track the current position in a variable and step relative to it:

int currentPos = 0;

void moveTo(int target) {
  int delta = target - currentPos;
  myStepper.step(delta);
  currentPos = target;
}

void loop() {
  moveTo(0);     delay(1000);
  moveTo(512);   delay(1000);   // quarter turn
  moveTo(1024);  delay(1000);   // half turn
  moveTo(2048);  delay(1000);   // full turn
}

This is the foundation for "rotate the dial to 90 degrees" or "open the valve to half."

When to use a 28BYJ-48 vs. a NEMA 17

  • 28BYJ-48: cheap, low torque (~0.3 kg-cm), slow, geared. Best for small projects where the load is light (e.g. a small dial, a camera slider).
  • NEMA 17: more expensive, high torque (~4 kg-cm), fast, not geared. Best for 3D printers, CNC machines, anything that needs real power.

If you find yourself saying "the 28BYJ-48 keeps missing steps" or "it's too slow," you want a NEMA 17 with an A4988 or TMC2209 driver.

When the motor just hums but does not turn

  • The pin order is wrong. Swap the order in the constructor.
  • The motor is under-powered. Use an external 5V supply.
  • The speed is set too high. Drop setSpeed to 5 and try again.

What to build next

  • A camera slider that pans back and forth.
  • A "rotating display stand" for a 3D-printed model.
  • A 4-wire stepper-driven valve controller.

The camera slider version is one of the next tutorials on this site. The valve controller is in the book Arduino Home Automation.


Chapter 09

Arduino: persist data with EEPROM, the right way

arduino · 25 min

I built a soil moisture monitor once and ran it for a few weeks without saving the calibration. Then I unplugged it to move it. The calibration was gone. The new reading on the new soil was 0% or 100% depending on how I held the sensor. I had to re-calibrate.

EEPROM fixes this. EEPROM is a small chunk of non-volatile memory on the Arduino (1 KB on the Uno and Nano, 4 KB on the Mega) that survives power cycles. You write a value, you pull the plug, the value is still there when power comes back.

The "still there" part is the whole point. RAM forgets. Flash (program memory) is read-only at runtime. EEPROM is the only place to put small bits of state that need to survive.

What you need

  • Any Arduino board
  • USB cable
  • A reason to persist something (a calibration value, a counter, a setting)

For a real project, EEPROM matters. For a tutorial project, you might not need it. The use cases that come up most:

  • A calibration value (e.g. a temperature offset for a sensor)
  • A counter (how many times has the device been turned on?)
  • A setting the user picked (a threshold, a mode)
  • A "last state" so the device resumes where it left off

The code

#include <EEPROM.h>

const int CALIBRATION_ADDR = 0;   // byte 0 in EEPROM
const int MAGIC_ADDR = 100;       // byte 100, holds a "magic number"

void setup() {
  Serial.begin(9600);

  // Check if the magic number is present
  byte magic = EEPROM.read(MAGIC_ADDR);
  if (magic == 0xAB) {
    // We have saved data
    int saved = 0;
    EEPROM.get(CALIBRATION_ADDR, saved);
    Serial.print("Loaded calibration: ");
    Serial.println(saved);
  } else {
    // First boot, save a default
    int defaultCal = 250;
    EEPROM.put(CALIBRATION_ADDR, defaultCal);
    EEPROM.update(MAGIC_ADDR, 0xAB);   // update, not write
    Serial.println("First boot, saved default.");
  }
}

void loop() {
  // use the calibration value
}

Two things to notice. First, EEPROM.put() is the modern way to write any type. It uses EEPROM.update() under the hood, which only writes if the value changed. Second, EEPROM.read() returns a byte, and you read multi-byte values with EEPROM.get() (which also takes a type and reads the right number of bytes).

The "end()" gotcha (it is not what you think)

The old EEPROM library (before 1.6 or so) had an EEPROM.end() function that flushed pending writes. The new library does not have that. Each call to EEPROM.write() or EEPROM.update() writes immediately. The "end()" concern is a vestige of older code.

What you do need to know: the underlying write takes a few milliseconds. If you call EEPROM.write() in a tight loop, the chip stalls for each write. For a one-off save, this does not matter. For saving 50 values in a row, it can take a quarter second.

The wear-leveling concern

Every EEPROM cell is rated for about 100,000 writes. That sounds like a lot, until you write every second. A counter that increments once per second will wear out the cell in 27 hours. A counter that increments once per hour is fine for 11 years.

Three things to keep in mind:

  1. Do not write in tight loops. Cache the value in RAM, write it occasionally.
  2. If you need a high-frequency counter, write to RAM and only flush to EEPROM every minute (or on power-down).
  3. If you really need a high-frequency write, use wear-leveling: spread the writes across many cells, and track which cell has the latest value. The EEPROM library does not do this for you.

For the calibration use case (a value that changes once when the user re-calibrates), 100,000 writes is effectively infinite.

When to use EEPROM vs Flash vs SD

This is the part that comes up most when readers ask questions. The short version:

  • EEPROM: small, fast, no external chip, 1 KB on Uno. Use for settings, calibration, last state.
  • Flash (PROGMEM): read-only at runtime, baked into the sketch. Use for lookup tables, strings, fixed data.
  • SD card: large, slow, needs a chip and a file system. Use for logs, images, anything bigger than 4 KB.

If you are tempted to store more than a few hundred bytes, SD. If you are tempted to store data that never changes, PROGMEM. If you have a few bytes that change occasionally, EEPROM.

The "magic number" pattern

The sketch above has MAGIC_ADDR = 100 and a check for 0xAB. Why?

When the EEPROM is fresh (factory new, or after EEPROM.clear()), every byte reads as 0xFF. If you write a calibration value of 0 to address 0, then read it back later, you cannot tell whether the value is 0 because you saved 0 or because the EEPROM is fresh.

The fix: write a known value to a "magic number" address. When you boot, check it. If the magic number is there, your saved data is valid. If not, this is a fresh boot, and you need to write defaults.

const byte MAGIC = 0xAB;
const int MAGIC_ADDR = 100;

if (EEPROM.read(MAGIC_ADDR) == MAGIC) {
  // saved data is valid
} else {
  // first boot
  EEPROM.write(MAGIC_ADDR, MAGIC);
  // write defaults
}

This is the single most important pattern in EEPROM programming. If you only learn one thing from this tutorial, learn this.

Putting structs in EEPROM

EEPROM.put() and EEPROM.get() take any type, including structs. This is the right way to save a small bundle of related values:

struct Settings {
  int calibration;
  byte mode;
  unsigned long bootCount;
};

const int SETTINGS_ADDR = 0;

void saveSettings(const Settings& s) {
  EEPROM.put(SETTINGS_ADDR, s);
}

bool loadSettings(Settings& s) {
  if (EEPROM.read(SETTINGS_ADDR + sizeof(s) - 1) != 0xAB) {
    return false;   // not initialized
  }
  EEPROM.get(SETTINGS_ADDR, s);
  return true;
}

A few gotchas:

  • sizeof(Settings) can change between compiles. If you change the struct definition and the EEPROM has old data, you read garbage. The magic-number check at the end of the struct catches this.
  • On the AVR, sizeof(long) is 4 bytes. On other boards it can be 8. If you ever port the code, check.
  • Always initialize the struct before reading: Settings s = {}; then EEPROM.get(addr, s); This way the fields you did not read are zero instead of uninitialized.

The alternative: PROGMEM for read-only data

If the data never changes (a lookup table, a fixed string), it goes in PROGMEM, not EEPROM. PROGMEM lives in flash (program memory) and survives power cycles, just like EEPROM. The difference is you cannot write to it at runtime.

#include <avr/pgmspace.h>

const char message[] PROGMEM = "Hello from flash";

void setup() {
  Serial.begin(9600);
  char buf[32];
  strcpy_P(buf, message);
  Serial.println(buf);
}

The _P suffix on string functions means "read from PROGMEM." This is the AVR-specific version. ESP32 and other cores do this differently (or automatically, since flash and RAM are the same memory on those chips).

When something breaks

  • EEPROM.read() always returns 255. The chip is not communicating, or you have the wrong I2C address (if you are using an external EEPROM). Onboard EEPROM on the Uno uses no pins; it just works.
  • Writes appear to fail randomly. The cell is worn out, or you are writing too fast. Add a small delay between writes.
  • The magic number check fails on a fresh chip. You forgot to write the magic number on the first boot. Add the EEPROM.write(MAGIC_ADDR, MAGIC) line in the else branch.
  • You read a value but it is corrupted. A power loss happened mid-write. The cell holds a partial value. Use the magic number as a validity check, and never trust a value that does not have a valid magic.

What to build next

  • A settings menu on a small OLED: cycle through values, save to EEPROM on the press of a button.
  • A weather station that logs min/max temperatures across power cycles.
  • A "first-boot wizard" that prompts the user to calibrate the sensor, then saves the calibration to EEPROM forever.

Chapter 10

Arduino: read a rotary encoder, position and direction

arduino · 30 min

A potentiometer tells you absolute position. Turn it to 25%, you read 25%. Unplug it, plug it back in, you still read 25%.

A rotary encoder is different. It tells you "the shaft has moved N detents in the clockwise direction" or "M detents in the counter-clockwise direction." The absolute position is up to you to track. Unplug it, plug it back in, you start over from zero (unless you save the count to EEPROM).

The benefit is no end stops. A pot only turns 270 degrees. An encoder spins forever, and you count the clicks.

This is the sensor behind closed-loop motor control. The motor turns, the encoder counts the turns, the code knows how far the motor has gone and how fast. The H-bridge tutorial showed you how to spin the motor. This one shows you how to know what the motor is doing.

What you need

  • Rotary encoder (the KY-040 is the most common hobby one; the NEMA-17 with encoder is the typical stepper-style one)
  • Arduino (Uno, Nano, Mega)
  • Jumper wires
  • USB cable

For a motor with an encoder, you also need a motor driver (L298N, BTS7960, or DRV8871, covered in the previous tutorial). For just an encoder on a knob, you only need the encoder.

How a rotary encoder works

A rotary encoder has two output pins, usually called A and B (or CLK and DT). As the shaft turns, the two pins produce square waves that are 90 degrees out of phase with each other. The pattern is called "quadrature."

When the shaft turns clockwise, A leads B. When it turns counter-clockwise, B leads A. The 90-degree phase difference is what tells you the direction.

A "click" on a typical encoder is one full quadrature cycle. The encoder I am holding has 20 detents per revolution, which is 20 full cycles, which is 20 ticks if you read A and B as a pair. If you count every edge of A and every edge of B (rising and falling), you get 4 ticks per detent, or 80 per revolution. That is the 600 PPR vs 2400 PPR distinction: PPR is "pulses per revolution," and 4x decoding is the standard.

Wiring

Encoder VCC -- Arduino 5V (or 3.3V on ESP32, check the encoder)
Encoder GND -- Arduino GND
Encoder A   -- Arduino D2   (interrupt pin)
Encoder B   -- Arduino D3   (interrupt pin)

Some encoders (the KY-040) also have a pushbutton switch on the shaft. Wire that to another digital pin if you want it. The button is a separate component, not part of the encoder logic.

The encoder needs 5V on a 5V Arduino. On a 3.3V board, check the encoder's spec; most are fine with 3.3V, but the output is open-drain on the cheap ones, which means you need pull-up resistors. The KY-040 has built-in pull-ups, so the wiring is the same on either board.

The code

const int ENC_A = 2;
const int ENC_B = 3;

volatile long position = 0;

void setup() {
  Serial.begin(9600);
  pinMode(ENC_A, INPUT_PULLUP);
  pinMode(ENC_B, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(ENC_A), onChange, CHANGE);
  attachInterrupt(digitalPinToInterrupt(ENC_B), onChange, CHANGE);
}

void loop() {
  static long lastPrint = 0;
  long p = position;
  if (millis() - lastPrint > 100) {
    Serial.println(p);
    lastPrint = millis();
  }
}

void onChange() {
  bool a = digitalRead(ENC_A);
  bool b = digitalRead(ENC_B);
  // The state table:
  // A B  |  Action
  // 0 0  |  no change
  // 0 1  |  +1
  // 1 0  |  -1
  // 1 1  |  no change
  if (a == b) {
    position++;
  } else {
    position--;
  }
}

Both pins trigger the same ISR on every change. The ISR reads both pins and updates the position based on the current state.

The trick is the a == b line. When the two signals are 90 degrees out of phase, half the time they are equal (in the "both HIGH" or "both LOW" stable states) and half the time they are unequal (during transitions). Counting the equality transitions gives you 2x the basic rate; counting all transitions gives you 4x.

This code counts the equality transitions (2x decoding). To do 4x, change the ISR to track the previous state and look up the direction from a state table.

The "interrupt on change" pattern

The reason for the interrupts: the encoder can turn at any speed. If you turn the knob quickly, the edges come faster than your loop() runs. If you poll, you miss edges. The interrupt catches every edge, no matter how fast the shaft turns.

A 600 PPR encoder at 100 RPM is 1000 edges per second. The Arduino Uno can handle interrupts at that rate easily. At 10,000 RPM, you are at 100,000 edges per second, which is also doable but you will spend most of your CPU time in the ISR. For a knob on a UI, this does not matter. For a motor at high speed, the ISR needs to be tiny (which is why we update a volatile long and not a Serial.print inside the ISR).

The position math: one click = X counts

The relationship between "detents" and "counts" depends on the encoder and your decoding:

  • A typical encoder has 20 detents per revolution.
  • 1x decoding: 1 count per detent = 20 per revolution.
  • 2x decoding (the code above): 2 counts per detent = 40 per revolution.
  • 4x decoding: 4 counts per detent = 80 per revolution.

The encoder's datasheet calls this "PPR" (pulses per revolution) or "CPR" (counts per revolution, which is 4x PPR). A 600 PPR encoder with 4x decoding is 2400 CPR.

For a motor, the position math is the same: count the edges, multiply by degrees per count, you get the shaft angle.

Debouncing the encoder in software

Mechanical encoders bounce. The signal can chatter for a few microseconds before settling. The cheap encoders bounce a lot. The expensive ones bounce less.

The state table in the ISR above is itself a debounce: you only count a transition when you reach a stable state. If the transitions are bouncing between A and B rapidly, you never reach a stable state, so you do not count anything.

If you are still getting jittery readings, add a 1-2 microsecond delay in the ISR (delayMicroseconds(2)) and re-read. That is ugly but it works. A better fix: use a hardware filter (a small RC on the A and B lines) or use a chip that has built-in quadrature decoding.

Absolute vs incremental encoders

A regular rotary encoder (the one above) is incremental. It tells you "the shaft has moved N clicks" but not "the shaft is at position X." An absolute encoder (more expensive, more pins) tells you the position directly, like a pot, but with infinite rotation.

For most projects, incremental is enough. You start at 0 and track relative position. For a CNC machine or a servo, absolute is worth the extra cost.

When encoders are necessary (closed-loop motor control)

Open-loop: you tell the motor "go forward at 50% PWM." The motor goes forward at some speed that depends on the battery voltage, the load, the surface, and the gear friction. You do not know how fast it is actually going.

Closed-loop: you tell the motor "go forward at 50 RPM." The encoder measures the actual speed, the code compares to the target, and the PWM is adjusted to match. This is a PID controller, and it is the difference between "I spun the motor" and "I controlled the motor."

The encoder is the sensor. The PID is the algorithm. The L298N (or DRV8871) is the actuator. Together, that is a closed-loop motor. A robot with closed-loop motors can drive in a straight line (the two wheels stay at the same speed) and stop at a specific position (count the encoder ticks).

Reading encoders on the Arduino Mega (more interrupt pins)

The Uno has two interrupt pins (2 and 3). The Mega has six (2, 3, 18, 19, 20, 21). The ESP32 has interrupts on every pin. The SAMD boards (MKR, Nano Every) also have interrupts on every pin.

For two encoders, you need four interrupt pins. The Mega can do it. The Uno cannot (use a different board, or use Pin Change Interrupts, which are more code to set up). The ESP32 can do it easily. The Pico can do it easily.

If you are on a Uno and need two encoders, the upgrade path is either: switch to a Mega, switch to an ESP32, or read the encoders with a separate chip (e.g. a $2 STM32 "Blue Pill" that has more interrupts) and pass the counts over serial or I2C to the Uno.

When something breaks

  • Counts in the wrong direction. The A and B wires are swapped. Swap them and the direction flips.
  • Counts double. You are using 2x decoding when you wanted 1x, or your ISR is firing on both edges. Change CHANGE to RISING on one of the interrupts and adjust the math.
  • Counts are jittery. Mechanical bounce. Add the delayMicroseconds(2) re-read trick, or use a better encoder.
  • Position wraps around to weird numbers. long overflows after about 2 billion. Use long long or unsigned if you are counting fast for a long time. For a knob, long is fine for decades.
  • Counts miss at high speed. Your ISR is too slow. Move Serial.print out of the ISR (we did) and keep the ISR to just the increment.

What to build next

  • A motor with a target RPM (closed-loop speed control with PID).
  • A motor with a target position (closed-loop position control).
  • A two-wheel robot that drives in a straight line by matching the encoder counts on both wheels.
  • A "hand-cranked" position sensor for a UI: turn the knob to set a value, save the value to EEPROM on a button press.

The closed-loop position control is in the book Arduino Robotics, chapter 5. The two-wheel straight-line driving is the chapter after that, with a full PID implementation and tuning notes.


Chapter 11

Arduino: drive a DC motor with the L298N H-bridge

arduino · 30 min

You cannot drive a motor directly from an Arduino pin. The pin sources about 20 mA, a motor wants 200 mA to a few amps, and the back-EMF from a spinning motor will fry the pin in microseconds.

The L298N is the workhorse module that fixes this. It is a dual H-bridge on a small green PCB with screw terminals. You give it power (a battery, usually 7-12V for hobby motors) and three logic pins from the Arduino. It does the high-current switching and protects the Arduino from the motor's electrical noise.

This is the pattern I use in every robot I build. The same wiring works on a Uno, a Mega, an ESP32, a Pico. The code is the same in Arduino C. The differences are at the edges (PWM frequency, current capacity).

What you need

  • L298N motor driver module (the common green one, $2-$3)
  • DC motor (a small gearmotor, 6V to 12V, less than 2A)
  • Arduino (any board)
  • Battery pack (4xAA or a 7.4V LiPo, depending on the motor)
  • Jumper wires
  • USB cable

The L298N module has screw terminals for the motor power and the motor outputs, and header pins for the logic inputs. It also has a 5V regulator that can power the Arduino's 5V rail if you wire it correctly. Be careful with that: if you feed the Arduino's 5V from both USB and the L298N, you can back-power the USB port and confuse your computer.

Wiring

L298N 12V (screw terminal) -- Battery +
L298N GND (screw terminal) -- Battery - AND Arduino GND
L298N 5V (header pin)      -- (do NOT connect to Arduino 5V; remove jumper if powering Arduino from USB)

L298N IN1 -- Arduino D8
L298N IN2 -- Arduino D9
L298N ENA -- Arduino D10   (PWM-capable pin)

L298N OUT1 -- Motor +
L298N OUT2 -- Motor -

The L298N has two H-bridges. You can drive two motors. For one motor you use IN1, IN2, ENA, OUT1, OUT2. The other half of the module is unused.

The 5V header on the L298N is a 5V output from the on-board regulator, intended to power logic. With the jumper on (factory default), the regulator is enabled. If you want the Arduino to power the L298N's logic instead, remove the jumper. The most common setup is to leave the jumper on and let the L298N power the Arduino's 5V rail through this pin, but only if the Arduino is not also connected to USB.

The cleanest setup: remove the 5V jumper, run the Arduino from USB, share the GND between battery, Arduino, and L298N.

The code

const int IN1 = 8;
const int IN2 = 9;
const int ENA = 10;   // PWM pin

void setup() {
  pinMode(IN1, OUTPUT);
  pinMode(IN2, OUTPUT);
  pinMode(ENA, OUTPUT);
}

void stop() {
  digitalWrite(IN1, LOW);
  digitalWrite(IN2, LOW);
  analogWrite(ENA, 0);
}

void forward(int speed) {
  digitalWrite(IN1, HIGH);
  digitalWrite(IN2, LOW);
  analogWrite(ENA, speed);   // 0-255
}

void reverse(int speed) {
  digitalWrite(IN1, LOW);
  digitalWrite(IN2, HIGH);
  analogWrite(ENA, speed);
}

void loop() {
  forward(200);
  delay(2000);
  stop();
  delay(500);
  reverse(200);
  delay(2000);
  stop();
  delay(500);
}

Four functions: forward, reverse, stop, and the implicit coast (set both IN pins LOW but let ENA float). The stop() function above is "brake" (both pins LOW, ENA enabled at 0 PWM which short-circuits the motor). Brake stops the motor faster than coast. Coast lets the motor spin down on its own.

The four states: forward, reverse, brake, coast

The L298N can put the motor in four electrical states, and they behave differently:

  • Forward: IN1 HIGH, IN2 LOW, ENA PWM. Motor spins one way.
  • Reverse: IN1 LOW, IN2 HIGH, ENA PWM. Motor spins the other.
  • Brake: IN1 LOW, IN2 LOW, ENA HIGH (or PWM with any value). Motor stops hard. Energy is dissipated in the L298N.
  • Coast: IN1 LOW, IN2 LOW, ENA LOW. Motor is disconnected, spins down gradually. Energy goes back to the supply (a small amount, on the L298N; on a smarter driver, this is called "regenerative braking").

For a robot, brake is the right stop. For a wheel that needs to freewheel when the robot is not driving, coast is right.

The enable pin for PWM speed control

ENA (and ENB for the second channel) is the PWM speed control. You can drive ENA with analogWrite() on any PWM-capable pin. The frequency is the Arduino default, about 490 Hz on most pins (980 Hz on pins 5 and 6 on the Uno). For DC motors, the frequency does not matter much. For steppers or other inductive loads, you might want a higher frequency, which means changing the timer config (covered in the L298N with a stepper tutorial).

speed in the code above is 0-255, matching the analogWrite() range. speed = 0 with ENA HIGH is brake. speed = 0 with ENA LOW is coast. They are not the same.

The 2A per channel limit

The L298N is rated for 2A continuous per channel, 3A peak. The actual thermal limit is closer to 1A continuous without a heatsink. If you push more current than that, the chip goes into thermal shutdown, which looks like the motor randomly stopping and starting.

The fix is either: use a bigger motor driver (BTS7960, DRV8871), or add a real heatsink to the L298N. The little clip-on aluminum heatsink that ships with some modules is barely adequate. A proper one (with thermal paste) is better.

The voltage drop (1.5V per side)

The L298N uses bipolar transistors, not MOSFETs. Each side of the H-bridge drops about 1.5V. If you power the motor with 12V, the motor sees about 9V after the drop. If you power it with 6V, the motor sees 3V.

For a 6V motor, that means you cannot run it at full speed from a 6V supply through an L298N. You need a 7.5V or 9V supply to get the rated speed. This is the most common confusion when people first use the L298N. The motor "feels weak" because the voltage is too low after the drop.

MOSFET-based drivers (BTS7960, DRV8871) drop about 0.2V, which makes them a much better choice for low-voltage motors.

The heatsink requirement

The 1.5V drop times 1A of current is 1.5W of heat dissipated in the chip. That is enough to heat it well past 100 degrees C, which is the thermal limit. The chip will throttle, or shut down, or (worst case) desolder itself from the PCB.

If your motor draws more than about 0.5A continuously, attach the heatsink with thermal paste. If the motor draws more than 1A, use a bigger driver. The DRV8871 is a drop-in replacement for the L298N (different pinout, same logic) and handles 3.6A continuous without breaking a sweat.

Alternatives: BTS7960 and DRV8871

For higher current or lower voltage drop, the two common upgrades:

  • BTS7960: 43A, drops about 0.4V. Big module, big heatsink already attached. Use for wheelchair motors, large gearmotors. The pinout is different (uses an enable and a direction pin rather than two inputs).
  • DRV8871: 3.6A continuous, drops about 0.2V. Small breakout, no heatsink needed for typical hobby motors. The simplest upgrade path. Different pinout, same logical interface.

For a first robot, the L298N is fine. For a second robot, switch to the DRV8871. The code is the same.

The "regenerative braking" pattern

A regenerative brake turns the motor into a generator and dumps the energy back into the battery. The L298N does not do this natively (it can, but the internal diodes are too small for any real current). The DRV8871 does support it, in a limited way.

The effect on a robot: when you let off the throttle, a regen system slows the robot down (using the motor as a brake) and recovers some energy. For a small robot with a tiny battery, the energy recovered is negligible. For a larger robot, it matters.

You usually do not need regen. Brake (stop() with ENA HIGH) is fine for most projects.

When something breaks

  • Motor does not move at all. Check the wiring, especially the motor power and ground. The L298N needs both 12V (motor power) and 5V (logic) to switch.
  • Motor moves at full speed, no PWM control. ENA is not connected, or ENA is on a non-PWM pin. Move it to a PWM pin (3, 5, 6, 9, 10, or 11 on the Uno).
  • Arduino resets when the motor starts. The motor is drawing too much current and the supply is browning out. Add a decoupling capacitor (100 uF) on the L298N's 5V rail, or use a separate supply for the Arduino.
  • Motor gets hot but does not spin. The motor is shorted or the load is too high. Check for binding in the gearbox or wheels.
  • Motor runs backward when you call forward(). The motor wires are reversed. Either swap the motor wires on OUT1/OUT2, or swap the IN1/IN2 logic in your code.

What to build next

  • A two-wheel robot with two L298N channels (or one L298N with two motors).
  • A PID-controlled motor that holds a target speed.
  • A stepper motor (the L298N also drives steppers, but the current limiting is different; the A4988 is a better stepper driver).

The two-wheel robot is in the next tutorial. The PID motor control is in the book Arduino Robotics, with a chapter on encoder feedback and closed-loop speed control.


Chapter 12

Arduino: hardware interrupts, the right way to react to events

arduino · 30 min

I wired a button to pin 7 on an Arduino once and wrote a sketch that checked it in loop(). The button worked, mostly. Then I added a few delay(1000) calls for a status blink and the button started dropping presses. By the time I was blinking an LED, reading a sensor, and talking over serial, the button was missing roughly half the presses.

The fix is interrupts. The Arduino has dedicated hardware that watches a pin for you while your code does other things. When the pin changes, your code gets interrupted (which is the literal meaning), runs a small function you wrote, and picks up where it left off. No polling delay, no missed button press.

This is the tutorial I wish I had read before trying to debounce my way out of the problem.

What you need

  • Any Arduino board (Uno, Nano, Mega). The MKR and ESP32 also work, but with a slightly different API.
  • A momentary pushbutton
  • A 10k ohm resistor (pull-down)
  • Jumper wires
  • USB cable

The Uno has interrupt-capable pins on digital 2 and 3 only. If you want more, use the Mega (six interrupt pins) or the ESP32 (every pin is interrupt-capable).

Wiring

Arduino 5V --[ button ]-- Arduino D2
                          |
                       (button also has a leg on D2)

Arduino D2 --[ 10k ]-- GND

The resistor pulls D2 to ground when the button is open, so the pin reads LOW. When you press the button, D2 connects to 5V and reads HIGH. That is a pull-down resistor. The alternative is to wire the button to GND and use the Arduino's internal pull-up (more on that in a minute).

   5V o----[ button ]----o----o D2
                          |
                       [ 10k ]
                          |
                         GND

The code

const byte LED_PIN = 13;
const byte BUTTON_PIN = 2;
volatile bool buttonPressed = false;

void setup() {
  pinMode(LED_PIN, OUTPUT);
  pinMode(BUTTON_PIN, INPUT);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), onPress, RISING);
}

void loop() {
  if (buttonPressed) {
    buttonPressed = false;
    digitalWrite(LED_PIN, HIGH);
    delay(50);
    digitalWrite(LED_PIN, LOW);
  }
  // do other stuff here; the button still gets attention
}

void onPress() {
  buttonPressed = true;
}

The whole thing comes down to one line: attachInterrupt(...). The first argument is the interrupt number, but you almost never want the bare number. digitalPinToInterrupt(pin) converts a pin number to the right interrupt number for whichever board you are on. The second argument is the function to call when the event happens. The third is the trigger: RISING (LOW to HIGH), FALLING (HIGH to LOW), or CHANGE (either direction).

What you learned

The volatile keyword is the most important line in this sketch and it is the one that trips up beginners. volatile tells the compiler "this variable can change outside the normal flow of your code, so do not optimize reads of it into a register." Without volatile, the loop might cache the value and never see the change. With it, every read goes to RAM.

The second thing: the function you pass to attachInterrupt is called an ISR (Interrupt Service Routine). ISRs have rules.

  • No Serial.print. The serial hardware shares an interrupt with some ISRs, and you can deadlock. The fix is to set a flag in the ISR and print in loop().
  • No delay(). delay() itself uses interrupts to count milliseconds. Calling it from an ISR that preempts the timer interrupt will hang the chip.
  • No malloc. The heap is not safe inside an ISR. Same reason.
  • Keep it short. The shorter the ISR, the less time your main code spends paused. Milliseconds are too long. Microseconds are the budget.

A common pattern: the ISR sets a flag, the main loop checks the flag and does the work. That is what this sketch does.

When something breaks

  • The button triggers twice on one press. This is bounce. Real buttons have metal contacts that bounce for a few milliseconds before settling. Fix it with a 50-millisecond ignore window in your loop, or by tracking the last time you saw a press.
  • Nothing happens at all. You probably have the wrong pin. The Uno can only interrupt on digital 2 and 3. If you wired to pin 7 by accident, the attachInterrupt call still compiles but does nothing.
  • The LED stays on after the press. You forgot to clear the flag inside the if. The ISR set it, the loop saw it, the loop turned the LED on, but the flag is still set so next time through the loop the LED turns on again. Clear it.
  • The compiler complains about digitalPinToInterrupt. Some older cores do not have that macro. Use the raw interrupt number (0 for pin 2, 1 for pin 3 on the Uno) instead.

The "interrupt every microsecond" gotcha

If you wire a button directly without debouncing, the ISR can fire multiple times for one press. Now imagine the button is a 1 kHz square wave from a sensor. The ISR fires 1000 times per second, every second, forever. Your main loop never gets a chance to run.

Two fixes. First, debounce in software (a 50 ms ignore window in the loop, or a 50 ms ignore window in the ISR with millis()). Second, if you genuinely have a high-frequency signal, use a hardware timer interrupt to count pulses, not a pin-change interrupt.

When NOT to use interrupts

For a slow sensor you read once a second, polling in loop() is simpler and easier to debug. Interrupts add three things you have to get right (volatile, atomicity, debouncing) for one thing you get back (no missed events between polls). If you do not need instant response, do not pay the cost.

The threshold I use: if the event happens more often than my loop() runs, and the event matters, I use an interrupt. Otherwise I poll.

The 2-pin vs any-pin trade-off

The ATmega328P (Uno, Nano) has exactly two external interrupt pins (2 and 3) and 20 GPIO pins total. The ATmega2560 (Mega) has six. The ESP32 has an interrupt on every pin. The SAMD21 (MKR, Nano Every) also has interrupts on every pin.

If you are on an Uno and need more than two interrupt sources, you have three options: use Pin Change Interrupts (PCINT, more code), upgrade to a Mega, or multiplex the inputs and read them with a shift register.

ISR priority on different boards

The Uno has a fixed interrupt priority. Some interrupts can preempt others (e.g. the timer interrupt can preempt a pin change). On the ESP32 you can set priorities with attachInterrupt with a priority argument. On the Uno you cannot. If two interrupts fire at the same time on a Uno, the one with the lower vector number wins. There is no "interrupt priority" knob for you to turn.

What to build next

  • A button that toggles the LED on and off (track the state in a variable, flip it in the loop).
  • A button that controls motor speed through an H-bridge.
  • A rotary encoder, which is two interrupts plus a state machine (covered in the encoder tutorial).
  • A wake-up from sleep: attachInterrupt works while the chip is asleep, and a button press can wake it.

The encoder tutorial is the natural next project. The H-bridge plus encoder is a closed-loop motor, which is the start of a real robot drive train.


Chapter 13

Arduino: read an HC-SR04 ultrasonic distance sensor

arduino · 25 min

The HC-SR04 is the distance sensor I hand to anyone building their first robot. It is cheap (about $2), it works on a 5V Arduino, the wiring is four jumpers, and the math is one formula. It also has the most predictable failure modes of any sensor I have used, which is rare in this hobby.

The sensor works by sending out a 40 kHz chirp (above human hearing) and timing how long the echo takes to come back. The math is distance = (echo_time * speed_of_sound) / 2, and the 2 is because the sound has to go out and come back.

What you need

  • HC-SR04 ultrasonic distance sensor
  • Arduino (Uno, Nano, Mega)
  • 4 jumper wires
  • USB cable

The HC-SR04 has four pins: VCC, GND, TRIG, ECHO. The TRIG pin is the input (you send a pulse to start a measurement). The ECHO pin is the output (the sensor drives it HIGH for the duration of the echo).

The 5V logic level on the HC-SR04 works on a 5V Arduino. On a 3.3V board (ESP32, Pico, RP2040), the ECHO pin outputs 5V and you need a level shifter or a voltage divider. Otherwise you can fry the GPIO. I have done this. Do not do this.

Wiring

HC-SR04 VCC  -- Arduino 5V
HC-SR04 GND  -- Arduino GND
HC-SR04 TRIG -- Arduino D2
HC-SR04 ECHO -- Arduino D3

If you are on a 3.3V board, add a voltage divider on the ECHO pin. Two resistors, 1k and 2k, bring the 5V echo down to about 3.3V.

HC-SR04 ECHO --[ 1k ]--+-- Arduino D3
                        |
                     [ 2k ]
                        |
                       GND

The code (raw pulseIn, no library)

const int TRIG_PIN = 2;
const int ECHO_PIN = 3;

void setup() {
  Serial.begin(9600);
  pinMode(TRIG_PIN, OUTPUT);
  pinMode(ECHO_PIN, INPUT);
}

void loop() {
  // Send a 10-microsecond trigger pulse
  digitalWrite(TRIG_PIN, LOW);
  delayMicroseconds(2);
  digitalWrite(TRIG_PIN, HIGH);
  delayMicroseconds(10);
  digitalWrite(TRIG_PIN, LOW);

  // Read the echo pin; pulseIn returns the duration in microseconds
  long duration = pulseIn(ECHO_PIN, HIGH, 30000);
  // 30000 us timeout = 5.1 m max range (anything farther reads 0)

  if (duration == 0) {
    Serial.println("No echo (out of range)");
  } else {
    long cm = duration / 29 / 2;   // speed of sound is ~29 us/cm
    Serial.print("Distance: ");
    Serial.print(cm);
    Serial.println(" cm");
  }

  delay(100);
}

The trigger pulse is 10 microseconds of HIGH after a 2-microsecond LOW. The sensor sees that, fires the chirp, then drives the ECHO pin HIGH for the round-trip time. pulseIn() waits for the pin to go HIGH, times how long it stays HIGH, and returns the duration in microseconds. The timeout argument (30000) prevents pulseIn from hanging forever if the sensor does not see an echo.

The distance math: sound travels about 343 meters per second, which is 29.1 microseconds per centimeter. Round trip is twice that, so cm = duration / 29 / 2. The divide by 2 and divide by 29 (in either order) is the same as divide by 58.

The NewPing library vs raw pulseIn

The NewPing library wraps the same logic into a single function call and adds a few features: median filtering (drops outlier readings), built-in timing for multiple sensors on one Arduino, and a "ping_temperature" mode that compensates for the speed of sound at different temperatures (it is about 0.6 m/s slower per degree Celsius, which matters for precise measurements).

#include <NewPing.h>

#define TRIG_PIN 2
#define ECHO_PIN 3
#define MAX_DISTANCE 200   // cm

NewPing sonar(TRIG_PIN, ECHO_PIN, MAX_DISTANCE);

void setup() {
  Serial.begin(9600);
}

void loop() {
  int cm = sonar.ping_cm();
  if (cm == 0) {
    Serial.println("Out of range");
  } else {
    Serial.print(cm);
    Serial.println(" cm");
  }
  delay(100);
}

For a beginner project, raw pulseIn is fine. For a robot with multiple sensors, NewPing is worth the install.

The 3-meter range limit

The HC-SR04 is rated for 2 cm to 400 cm, but in practice the useful range is about 2 cm to 300 cm. Beyond 3 meters, the echo gets weak and you start getting phantom readings (the sensor returns a number that has no real-world meaning).

The reasons are physics: the 40 kHz chirp spreads out, the energy density drops, and the return is below the noise floor of the sensor's receiver. There is no code fix for this. If you need longer range, you need a different sensor (an outdoor-rated ultrasonic, or a LiDAR).

The "soft surfaces absorb sound" gotcha

A common confusion: the sensor reads 200 cm when pointed at a pillow, but reads the same 200 cm when pointed at nothing. The pillow absorbed the sound. Ultrasonic bounces off hard surfaces (wood, plastic, walls) and is absorbed by soft ones (cloth, carpet, fur, foam).

This matters for robots. If your robot needs to detect a person walking in front of it, and the person is wearing a thick winter coat, the HC-SR04 will not see them.

For a robot that needs to detect soft things, you need a different sensor. Time-of-flight LiDAR (VL53L0X, VL53L1X) works on most surfaces because it uses light, not sound.

Multi-sensor arrays

The HC-SR04 has a single TRIG and a single ECHO pin. To use multiple sensors on one Arduino, you give each one its own TRIG pin (outputs) but they all share the same ECHO pin, with a diode on each one to prevent the sensors from shorting into each other.

A simpler approach: trigger them one at a time. Connect all TRIG pins to separate Arduino outputs, share the ECHO pin through diodes, and call pulseIn() on the shared ECHO line. Trigger sensor 1, wait for the echo, then trigger sensor 2, wait, etc. This is slow but reliable.

For a fast multi-sensor setup, the I2C-controlled ultrasonic sensors (e.g. the SRF02) are easier.

When to use ultrasonic vs LiDAR vs IR

The three options for distance sensing on a hobby robot, and when to pick each:

  • HC-SR04 (ultrasonic): cheap, works in the dark, does not work on soft surfaces. Range about 3 m. Use for: simple obstacle detection at short range.
  • VL53L0X / VL53L1X (time-of-flight LiDAR): more expensive ($3-$5), measures actual distance to a point, works on most surfaces. Range 2 m. Use for: precise distance, detecting thin obstacles, anything that needs to work on cloth or skin.
  • Sharp IR (GP2Y0A21): cheap, analog output, short range (10 cm to 80 cm), works on most surfaces but is sensitive to ambient light. Use for: short-range sensing, line following, anything where you do not need precision.

For a first robot, start with the HC-SR04. For a second sensor on the same robot, add a VL53L0X. For a small line follower, use the Sharp IR.

When something breaks

  • Reads 0 every time. The wiring is wrong, or the sensor has no power. Check VCC and GND first.
  • Reads a constant 5 cm no matter what. The sensor is reading its own chirp before the trigger pulse ends. Increase the delay after the trigger from 10 us to 100 us, or use a slower trigger pattern.
  • Reads random values. Power supply issue. The HC-SR04 pulls 15 mA when chirping, which can cause brownouts if you are powering the Arduino from a weak USB port. Add a 100 uF capacitor on the sensor's VCC and GND.
  • Reads the right value but with high jitter. Soft surface, or the sensor is at the edge of its range. Add a median filter (take 5 readings, return the middle one).
  • Works on the Uno but not on the ESP32. Level shifter. The ESP32's GPIO is 3.3V and the HC-SR04's ECHO is 5V.

The temperature compensation

Speed of sound is 343 m/s at 20 degrees C. It varies by about 0.6 m/s per degree. For most projects, this does not matter. For a precision distance sensor (e.g. a tape measure replacement), it does.

float temperatureC = 20.0;   // read from a temp sensor if you have one
float speedOfSound = 331.0 + 0.6 * temperatureC;   // m/s
float usPerCm = 10000.0 / speedOfSound;            // us/cm round trip
float cm = duration / usPerCm / 2.0;

The correction is small (about 2% across a 30-degree temperature range), but for sub-centimeter accuracy it matters.

What to build next

  • A "wall follower" that drives a robot parallel to a wall at a constant distance.
  • A reversing "parking sensor" with a buzzer that beeps faster as you get closer.
  • A multi-sensor array on a robot for 360-degree obstacle detection.

The wall follower is in the book Arduino Robotics. The 360-degree obstacle array is a chapter in the Robot Drive Train book, with the wiring diagrams for four sensors on a single Arduino.


Chapter 14

Arduino: the watchdog timer, recover from hangs automatically

arduino · 30 min

A friend of mine had a weather station in a field that ran for eight months, then hung. The display still showed numbers. The sensor readings still updated. The only problem was the numbers were stuck on the value from eight months ago, because the I2C read had wedged the main loop.

He drove out, power-cycled it, and it ran another six months. Then it hung again.

The fix is a watchdog timer. It is a piece of hardware on the chip that counts down from a value you set, and if it ever hits zero, the chip resets. Your code has to "pet" the watchdog (the slang is actually "kick" or "feed") on a regular schedule to keep it from firing. If your code hangs, it stops petting, the watchdog fires, and the chip reboots.

This is the part of embedded work that separates "I made it work on my desk" from "I made it work in a field where I cannot push the reset button."

What you need

  • Any Arduino board (Uno, Nano, Mega). The watchdog API differs on ESP8266, ESP32, and SAMD boards. This tutorial covers the AVR (Uno/Nano/Mega) API. The pattern is the same elsewhere; the calls are different.
  • USB cable
  • A sketch that does something that can hang (e.g. an I2C read with no timeout, a blocking call to a sensor)

What a watchdog is

The ATmega328P has a built-in hardware timer separate from the timers you use for PWM and millis(). It runs off an internal 128 kHz oscillator (separate from the main 16 MHz crystal, which matters: if the main clock is stuck, the watchdog can still fire).

You set a timeout. If your code does not call wdt_reset() before the timeout expires, the chip resets. The whole thing takes one line to enable and one line to keep alive.

The code

#include <avr/wdt.h>

void setup() {
  Serial.begin(9600);
  wdt_enable(WDTO_2S);   // 2-second timeout
}

void loop() {
  // do the work that might hang
  int reading = readSensor();   // assume this can wedge

  Serial.println(reading);
  delay(500);

  wdt_reset();   // pet the dog
}

That is the entire pattern. Enable in setup(), reset (the polite term is "pet," but the API says "reset") in loop(). If loop() ever stops running, the watchdog fires and the chip reboots.

The full list of timeouts is in the header file. The common ones:

WDTO_15MS    // 15 milliseconds
WDTO_30MS    // 30 ms
WDTO_60MS    // 60 ms
WDTO_120MS
WDTO_250MS
WDTO_500MS
WDTO_1S
WDTO_2S
WDTO_4S
WDTO_8S

Pick a timeout that is longer than your longest legitimate work cycle, but short enough that a real hang gets noticed. For a weather station that reads every 30 seconds, 4 or 8 seconds is reasonable.

The "infinite loop rescue" pattern

If you have a sensor that you know can hang (a flaky I2C device, a Wi-Fi module that sometimes wedges), wrap the read in a tighter watchdog. Enable a short watchdog (say, 250 ms) right before the risky call, pet it during the call if you can, and disable it after. If the call hangs, the watchdog fires.

void readSensorSafely() {
  wdt_enable(WDTO_250MS);
  // pet repeatedly inside the call
  for (int i = 0; i < 5; i++) {
    wdt_reset();
    // do a small chunk of the read
  }
  wdt_disable();   // back to the long timeout in loop()
}

wdt_disable() exists but be careful: it is not symmetric with wdt_enable() on every chip. On the AVR, wdt_disable() actually works. On the ESP32, you have to use esp_task_wdt_delete and the API is more involved.

What NOT to put inside a watchdogged section

The point of a watchdog is to recover from a stuck chip. If the watchdog fires while you are in the middle of an EEPROM write, the write gets corrupted. Same for an SD card write, an I2C transaction that has not been closed, or a relay that is currently energized.

Two rules:

  1. Never enable a watchdog with a timeout shorter than the longest operation that must complete.
  2. Always think about "what state am I leaving things in if the watchdog fires right now." If the answer is "halfway through a critical write," the watchdog is too aggressive.

The 8-second timeout is the safety net. The 250-millisecond timeout is the local rescue for one specific risky call. Layer them.

The bootloader reset delay

When the watchdog fires on an AVR, the chip resets and the bootloader runs before your sketch does. That takes about a second on the Uno, longer on some boards. The board "looks" unresponsive for that second. If your project uses serial, the serial port disconnects and reconnects, which can confuse a host program listening on the other end.

The fix is to use a software reset (asm("jmp 0")) instead of the hardware watchdog if you need a clean restart. For an actual hang recovery, the hardware reset is what you want.

ESP8266 and ESP32 differences

The ESP8266 has a software watchdog (ESP.wdtFeed(), etc.) and a hardware one. The ESP32 has esp_task_wdt_init and friends. The APIs are completely different from the AVR ones.

The patterns are the same (enable, pet, recover from a hang), but you cannot copy-paste code from an Uno sketch to an ESP32 sketch. Look at the chip-specific docs.

When watchdogs save you vs mask real bugs

This is the part I want to be honest about. A watchdog is a recovery tool, not a debugging tool. If your code hangs every 4 seconds and the watchdog keeps rebooting it, the project "works" in the sense that the LED blinks, but the actual bug is still there. The watchdog is just hiding it.

Use the watchdog for: field deployment, where rebooting beats driving out. Use the watchdog for: libraries you do not control, where you cannot fix the underlying bug. Do not use the watchdog to cover up a bug you could fix with a timeout or a state check.

The infinite-loop rescue example

A common pattern is to add a software timeout around a blocking call. If the call returns, you are good. If it does not return in N milliseconds, you reset. The watchdog is the "if the loop is so wedged it cannot even check the timeout" safety net.

unsigned long startedWaiting = millis();
while (sensor.busy()) {
  if (millis() - startedWaiting > 1000) {
    // 1 second is too long; assume hang
    wdt_enable(WDTO_15MS);   // tight reset
    while (true);            // let the watchdog fire
  }
}

That pattern is the right shape. Detect the hang in code if you can. Use the watchdog as the last resort.

When something breaks

  • The board keeps rebooting in a loop. The watchdog is firing because loop() is not running. Add a Serial.println("alive") early in loop() and watch for the pattern. If the print appears once and then the board reboots, the hang is in loop(). If the print never appears, the hang is in setup().
  • The board reboots when you upload a new sketch. The watchdog is still enabled from the old sketch. Hold the reset button while you click upload. Most boards let you upload while the watchdog is running; some do not.
  • The 15-millisecond timeout feels like 1 second. It is not. The 15 ms is real, but the bootloader delay after reset adds a second. From the outside, the "hang" looks like a 1-second pause, not 15 ms.
  • wdt_disable() does nothing. On the AVR, you have to also reset the configuration register. The library handles it, but if you bypass the library and write to the register directly, you need WDTCSR = 0.

What to build next

  • A weather station that records a hang to EEPROM before resetting, so you can see the failure mode after the fact.
  • A greenhouse controller that reboots gracefully on hang, then sends a Wi-Fi message saying "I just reset, here is why."
  • A robot drive train where a stalled motor is detected by a timeout, and the watchdog is the last-resort reset.

© ctrlaltbrian.com

Published 2026-09-24 · Source: ctrlaltbrian.com

Built in the spirit of measure twice, flash once. Brian writes these so you can actually finish the project, not so you give up halfway and buy a pre-made one.

© 2026 ctrlaltbrian. Code samples are MIT. Tutorials are CC BY-NC-SA 4.0 (use them, share them, don't resell them).