Hardware Academy
Robotics

Microcontrollers and firmware

GPIO, ADC, PWM, I2C, SPI, UART, interrupts and the shape of a control-loop firmware.

Day 8 · 55 min

After this lesson

  • You can compare I2C, SPI and UART and choose one for a sensor.
  • You can structure firmware with a fixed-rate control loop.
  • You can explain interrupts vs polling and when an RTOS helps.

Inside the microcontroller, and the shape of good firmware

A microcontroller (MCU) is a computer on one chip: a CPU core, flash for the program, SRAM for data, a clock, and hardware peripherals that do timing-critical work on their own. The peripherals are the point. A PWM timer toggles a pin 20,000 times a second while the CPU does something else, and a pulse counter tallies encoder edges without a single interrupt.

Choosing an MCU

ChipCore and clockRAMADCKnown for
ATmega328P (Arduino Uno)8-bit AVR, 16 MHz2 KB10-bit, about 10 k samples/s5 V logic, simplest to learn
ESP32 (WROOM-32)2 × Xtensa LX6, 240 MHz520 KB12-bit, nonlinear at the endsWi-Fi, Bluetooth, pulse counters
RP2040 (Raspberry Pi Pico)2 × Cortex-M0+, 133 MHz264 KB12-bit, 500 k samples/sPIO state machines for custom I/O
STM32F411Cortex-M4F, 100 MHz128 KB12-bit, 2.4 M samples/sMotor-control timers, mature debugging

The course robot uses the ESP32 for its headroom and Wi-Fi telemetry. Its pins are 3.3 V and not 5 V tolerant, GPIO 34-39 are input-only, and the strapping pins (GPIO 0, 2, 5, 12 and 15) must sit at the right levels during boot.

GPIO, ADC and PWM

A GPIO pin can be an input (floating, pulled up or pulled down), a push-pull output that drives both high and low, or an open-drain output that only pulls low. A floating input reads noise, so switches and open-collector sensors need a pull-up; the ESP32's internal ones are about 45 kΩ, fine for a button and too weak for a bus.

An ADC converts a voltage into a number, and resolution sets the step: 12 bits gives 4,096 codes, 0.8 mV each over 3.3 V. Sample rate matters too: the Uno needs about 104 µs per conversion, roughly 9,600 samples per second, while the RP2040 manages 500,000. Accuracy is another matter. The ESP32's ADC is nonlinear near both ends of its range, and ADC2 is unavailable while Wi-Fi runs, so analog sensors belong on ADC1.

PWM comes from hardware timers that count clock ticks and flip a pin at a compare value, so resolution and frequency trade off: an 80 MHz clock at 20 kHz leaves 4,000 ticks per period, at most 11 bits. The ESP32's LEDC block has 16 channels; MCPWM adds dead time and fault inputs for half-bridges.

Serial buses: I2C, SPI and UART

  • I2C uses two open-drain wires, SDA and SCL, with pull-up resistors (typically 4.7 kΩ at 100 kHz, 2.2 kΩ at 400 kHz). Each device has a 7-bit address (the MPU-6050 is 0x68) and acknowledges every byte. The pull-up and the bus capacitance set the rise time, about 0.85·R·C, which must stay under 300 ns at 400 kHz: a 2.2 kΩ pull-up allows about 160 pF of wiring. Two wires serve dozens of sensors, but the bus is slow and one confused device can hold it.
  • SPI uses a clock, two data lines (MOSI and MISO) and a chip-select per device. It is full duplex at tens of MHz, ideal for displays, SD cards and fast IMUs.
  • UART has no clock: both ends agree on a baud rate. 115200 baud with 8N1 framing moves 11,520 bytes per second, point to point; GPS modules, lidars and your debug console use it.

Interrupts, timers and scheduling

Polling reads a signal whenever the loop gets to it; an interrupt runs a short routine within microseconds of a hardware event. Use interrupts for edges you cannot miss, such as encoder channels or an echo pin, and keep each ISR to a few lines: update a volatile counter and return. Never print, delay or touch a bus inside one, and copy multi-byte shared variables atomically in the main code.

A superloop, Arduino's loop(), runs tasks one after another and is easy to reason about, but any blocking call (delay(), pulseIn(), a full Serial buffer) stalls everything. An RTOS such as FreeRTOS, which the ESP32's Arduino core already runs, gives tasks priorities, so a 100 Hz control task can preempt telemetry. vTaskDelayUntil() or a hardware timer keeps the period fixed instead of drifting.

Firmware structure, debugging and OTA

Good robot firmware is layered (see the figure): a hardware abstraction layer, device drivers that hide each part's quirks, a fixed-rate control layer and a behavior layer on top. Telemetry and logging sit beside the stack, never inside the control step. Two safety nets are mandatory: the hardware watchdog, which resets a hung MCU, and a command timeout that stops the motors if no fresh command arrives within about 200 ms.

Serial prints are the first debugging tool and the most intrusive: an 80-character line takes about 7 ms at 115200 baud. Toggling a spare GPIO around the control step and watching it on a scope or a $10 logic analyzer shows the real loop period and jitter, and the same analyzer decodes I2C, SPI and UART traffic. For breakpoints, the ESP32 supports JTAG with OpenOCD and GDB. Over-the-air (OTA) updates write new firmware to a second app partition and reboot into it, rolling back if it fails; stop the motors before an update starts.

Firmware layers of the course robot, from hardware up to behavior, with services beside the stacksensor data flows upcommands flow downBehaviorstate machine: follow line · avoid · recover10-20 HzControlPID per wheel · odometry · motor mixing100 Hz, fixedDevice driversMotor · Encoder · Ultrasonic · LineSensor · Imunon-blockingHardware abstractionArduino core / ESP-IDF: gpio · ledc · pcnt · adc · i2c · uartESP32 hardwaretwo cores · peripherals · pinsbeside the stackTelemetryserial or Wi-Fi, 10 Hz, low priorityConfigurationPID gains, calibration in NVSWatchdogshardware WDT + 200 ms command timeoutOTA updatestwo app partitions, rollbackFreeRTOStasks, priorities, vTaskDelayUntil
Firmware layers: data flows up from the hardware, commands flow down, and services such as telemetry and OTA sit beside the control path, not inside it.

Interactive: the ESP32's peripherals and an I2C transaction

ESP32 peripheral map

Click a block to see what it does, how the course robot uses it, and the trap to avoid. Green dots mark peripherals the robot uses.

Clickable block diagram of the ESP32's cores, memory, radio and peripheralsESP32 (WROOM-32 module)peripherals reach any pin through the GPIO matrixGPIO34 pins, 3.3 VADC2 × 12-bit SARLEDC PWM16 channelsMCPWM2 motor-control unitsPCNT8 pulse countersI2C2 controllersSPI4 hosts, 2 freeUART3 portsRMT8 pulse channelsTimers4 × 64-bitCPU cores2 × Xtensa LX6, 240 MHzMemory520 KB SRAM, 4 MB flashWi-Fi + Bluetooth2.4 GHz, BLE 4.2

PCNT · 8 pulse countersused by the robot

What it does
Pulse counter: 8 units with 16-bit signed counters. Each unit has two channels that can decode quadrature in hardware, and a glitch filter ignores pulses shorter than a set number of clock cycles.
On the course robot
Both wheel encoders. The counters run with zero CPU load and never miss an edge, so the 100 Hz loop only reads and clears them.
Watch out
The counter is 16-bit: at 1,400 counts per wheel turn it wraps after about 23 turns in one direction, so read it every cycle and accumulate in software.
Highlight

I2C transaction, bit by bit

The ESP32 talks to an MPU-6050 at 0x68. Watch START, the address, the ACKs, the register and the data byte, and who drives SDA at each moment.

SDA and SCL waveforms of an I2C transaction with the current bit highlightedSaddr 0x68WAreg 0x6BAdata 0x00APSCLSDAS11010000A01101011A00000000APgrey = master drives SDAamber = slave drives SDAsymbol 1 of 29ESP32S

START: SDA falls while SCL is high. Only START and STOP change SDA while SCL is high, so every device on the bus now listens for its address.

Transaction
Bus speed
27 clock pulses at 400 kHz (2.5 µs per bit): about 73 µs for this transaction.

Switch on "No device at 0x68" and watch the ninth clock: nobody pulls SDA low, the pull-up holds it high, and the master gives up with a STOP. That is the 2 that Wire.endTransmission() returns.

In practice

Practice

Write a 100 Hz control-loop skeleton

Write an Arduino loop() for the ESP32 that runs the control step every 10 ms without drift, runs the ultrasonic sensor at 20 Hz and telemetry and behavior at 10 Hz, stops the motors when commands go stale, and measures its own timing.

control_loop.inocpp
#include <Arduino.h>

constexpr uint32_t LOOP_US = 10000;         // 100 Hz control period
constexpr uint32_t CMD_TIMEOUT_MS = 200;    // stop the motors if commands go stale

// motorsBegin(), readEncoders(), setMotors() and friends are your drivers;
// pidLeft and pidRight are the Pid controllers written in the next lesson.

uint32_t nextTick = 0;
uint32_t cycle = 0;
uint32_t overruns = 0;
uint32_t worstUs = 0;
uint32_t lastCommandMs = 0;
float targetLeft = 0.0f, targetRight = 0.0f;  // wheel speed setpoints, mm/s

void setup() {
  Serial.begin(115200);
  motorsBegin();     // LEDC PWM at 20 kHz, direction pins, STBY high
  encodersBegin();   // PCNT units in quadrature mode
  sensorsBegin();    // line sensors on ADC1, ultrasonic state machine
  nextTick = micros() + LOOP_US;
}

void controlStep(float dt) {
  readEncoders();                            // copy and clear the PCNT counters
  readLineSensors();                         // ADC1, non-blocking
  updateOdometry(dt);
  bool stale = millis() - lastCommandMs > CMD_TIMEOUT_MS;
  float left = stale ? 0.0f : pidLeft.update(targetLeft, speedLeft(), dt);
  float right = stale ? 0.0f : pidRight.update(targetRight, speedRight(), dt);
  setMotors(left, right);                    // -1..1 duty to the TB6612FNG
}

void loop() {
  // Not time yet? The signed difference stays correct when micros() wraps (every ~71 min).
  if ((int32_t)(micros() - nextTick) < 0) return;
  uint32_t start = micros();
  nextTick += LOOP_US;                       // schedule from the deadline, not from "now": no drift

  controlStep(LOOP_US * 1e-6f);
  if (cycle % 5 == 0) ultrasonicService();   // 20 Hz: trigger or collect an echo, never block
  if (cycle % 10 == 0) runBehavior();        // 10 Hz state machine sets targetLeft / targetRight
  if (cycle % 10 == 5) sendTelemetry();      // 10 Hz, in a different cycle from the behavior
  cycle++;

  uint32_t used = micros() - start;
  if (used > worstUs) worstUs = used;        // report this in telemetry
  if ((int32_t)(micros() - nextTick) >= 0) { // already late for the next tick
    overruns++;
    nextTick = micros() + LOOP_US;           // resync instead of running a burst of late cycles
  }
}

Why it works. The deadline advances by exactly 10 ms each cycle (nextTick += LOOP_US), so the average rate is 100 Hz however long each cycle takes; writing nextTick = micros() + LOOP_US instead would add every cycle's lateness to the period. Slow jobs run at divided rates in different cycles, nothing blocks, and the loop reports its worst-case time and overruns so you can prove it meets its budget. The same schedule as a FreeRTOS task:

control_task.cppcpp
// The same schedule as a FreeRTOS task, pinned away from the Wi-Fi core.
void controlTask(void*) {
  TickType_t last = xTaskGetTickCount();
  for (;;) {
    controlStep(0.01f);
    vTaskDelayUntil(&last, pdMS_TO_TICKS(10));   // wakes every 10 ms, measured from the last wake-up
  }
}

void setup() {
  // ... same initialization as above ...
  xTaskCreatePinnedToCore(controlTask, "control", 4096, nullptr, 5, nullptr, 1);  // priority 5, core 1
}

Real-world scenario

Real-world scenario

The IMU stops answering until the robot is power-cycled

A robot reads an MPU-6050 IMU at address 0x68 over I2C at 400 kHz, through 30 cm of ribbon cable to the ESP32. When a second sensor was added, the breakout's pull-up resistors were removed, so the bus now relies on the ESP32's internal pull-ups. On the bench it runs for hours. On the robot, after a few minutes of driving or right after an ESP32 brownout reset, every IMU read fails and only unplugging the battery brings it back.

Symptoms

  • Wire.endTransmission() returns 2 (NACK on address) or reads time out, and the accelerometer and gyro values freeze.
  • A scope shows SDA held low continuously while SCL idles high.
  • Before the lockups, the scope shows slow, rounded rising edges on SDA and SCL that take about 4 µs to reach the high level.
  • Dropping the bus to 100 kHz makes failures rarer but does not stop the lockups after resets.

Interview questions

screenCompare I2C, SPI and UART. Which would you use for an IMU, a display and a GPS module?
I2C uses two open-drain wires shared by many devices, each with a 7-bit address, at 100 or 400 kHz; it saves pins but is slow and can lock up. SPI uses four wires with a chip-select per device and runs at tens of MHz full duplex, which suits displays, SD cards and fast IMUs. UART is asynchronous point-to-point serial at an agreed baud rate such as 115200, typical for GPS modules, lidars and debug consoles. So I would put the IMU on I2C at 400 kHz unless I need kilohertz sample rates, the display on SPI and the GPS on a UART.
screenInterrupts versus polling: when do you use each, and what must an ISR not do?
I use interrupts for short, time-critical events that would be missed between polls, such as encoder edges or an echo pin changing, and polling for slow signals that are fine to read once per loop, such as a line sensor or the battery voltage. An ISR should update a volatile counter or timestamp and return within microseconds. It must not call delay(), print to Serial, allocate memory or wait on a bus, because it blocks everything else, including other interrupts. Shared multi-byte variables need an atomic copy in the main code, by briefly disabling interrupts or using a critical section.
deepHow do you structure the firmware for a robot with a 100 Hz control loop?
I layer it: a hardware abstraction layer (the Arduino core or ESP-IDF drivers), device drivers for the motors, encoders, ultrasonic sensor and IMU, a control layer with PID and odometry, and a behavior layer with a state machine on top. The control step runs on a fixed schedule, either a FreeRTOS task using vTaskDelayUntil with a 10 ms period or a micros()-based loop that adds the period to the deadline so it never drifts. Everything in that step is non-blocking; ultrasonic pings, telemetry and Wi-Fi run at divided rates or in lower-priority tasks. I measure the step's execution time and count overruns, and I add a hardware watchdog plus a command timeout that stops the motors.
deepAn I2C device stops responding after a reset. How do you debug and recover?
First I look at the bus with a scope or logic analyzer: if SDA is stuck low while SCL is high, a slave is holding the line, usually because a reset interrupted a transaction while it was sending a 0. I also check the rise times, because weak pull-ups and long wires cause bit errors that end in the same state. To recover, I reconfigure SCL as a GPIO and clock it up to nine times until the slave finishes its byte and releases SDA, then issue a STOP and re-initialize the driver. Prevention is correctly sized pull-ups, short wiring, a timeout on every transaction, running the bus clear at every boot, and a power switch on the sensor as a last resort.
screenWhy use an ESP32 for this robot rather than an Arduino Uno, and when would you pick an RP2040 or an STM32?
The Uno's ATmega328P runs at 16 MHz with 2 KB of RAM, 10-bit ADCs and no wireless, which is tight for odometry, PID and telemetry together. The ESP32 has two 240 MHz cores, 520 KB of SRAM, Wi-Fi and Bluetooth, and hardware pulse counters for the encoders, for a few dollars. I would pick an RP2040 when I need deterministic custom I/O from its PIO state machines, and an STM32 when I need advanced motor-control timers and a mature debugging workflow. For a robot that streams telemetry over Wi-Fi, the ESP32 is the natural fit.

Quiz

1. An ESP32 LEDC timer runs from an 80 MHz clock. What is the highest PWM resolution available at 20 kHz?
2. An MPU-6050 has the 7-bit address 0x68. Which byte does the master send to start a write?
3. Why is loop() { work(); delay(10); } not a 100 Hz control loop?
4. Which ESP32 ADC cannot be used while Wi-Fi is running?
5. At 115200 baud with 8N1 framing, how many bytes per second can a UART carry?
6. What should an encoder interrupt service routine do?

References and further reading

Where the ideas in this lesson come from, and what to read next. Books are the standard texts; papers are the original sources; standards are what employers work to.

  1. Book

    Elecia White (2024). Making Embedded Systems: Design Patterns for Great Software. O'Reilly, 2nd edition.

    Read: The chapters on drivers, state machines and timing.

  2. Book

    Paul Horowitz and Winfield Hill (2015). The Art of Electronics. Cambridge University Press, 3rd edition.

    Read: The microcontroller chapter: peripherals and buses.