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
| Chip | Core and clock | RAM | ADC | Known for |
|---|---|---|---|---|
| ATmega328P (Arduino Uno) | 8-bit AVR, 16 MHz | 2 KB | 10-bit, about 10 k samples/s | 5 V logic, simplest to learn |
| ESP32 (WROOM-32) | 2 × Xtensa LX6, 240 MHz | 520 KB | 12-bit, nonlinear at the ends | Wi-Fi, Bluetooth, pulse counters |
| RP2040 (Raspberry Pi Pico) | 2 × Cortex-M0+, 133 MHz | 264 KB | 12-bit, 500 k samples/s | PIO state machines for custom I/O |
| STM32F411 | Cortex-M4F, 100 MHz | 128 KB | 12-bit, 2.4 M samples/s | Motor-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.
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.
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.
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.
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.
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.
#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:
// 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?
screenInterrupts versus polling: when do you use each, and what must an ISR not do?
deepHow do you structure the firmware for a robot with a 100 Hz control loop?
deepAn I2C device stops responding after a reset. How do you debug and recover?
screenWhy use an ESP32 for this robot rather than an Arduino Uno, and when would you pick an RP2040 or an STM32?
Quiz
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.
- 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.
- Book
Paul Horowitz and Winfield Hill (2015). The Art of Electronics. Cambridge University Press, 3rd edition.
Read: The microcontroller chapter: peripherals and buses.