Robotics and Embedded Systems

Lesson 2: Programming MicrocontrollersNew-age

A microcontroller is a tiny computer on a single chip: processor, memory and input/output pins in one package, designed to run one program, forever, with total reliability. It is not a small laptop — there is no operating system, no screen, no second chances. Your program is the only thing it does, which is exactly why microcontrollers run the world's washing machines, engine controllers, medical pumps and warehouse robots. This lesson teaches the structure every embedded program shares, the timing ideas that separate working robots from frustrating ones, and a complete program walked through line by line.

Three boards, one idea

BoardChipClockMemoryStandout feature
Arduino Uno R3ATmega328P16 MHz32 KB flash, 2 KB RAMThe teaching standard; huge library of examples
Raspberry Pi PicoRP2040 (dual-core)133 MHz2 MB flash, 264 KB RAMFast, cheap, runs MicroPython and C++
ESP32Xtensa (dual-core)240 MHz520 KB RAMBuilt-in Wi-Fi and Bluetooth

Read this table the way you read the LED datasheet in Lesson 1: the numbers decide what is possible. The Uno's 2 KB of RAM cannot hold a camera image; the ESP32's radio is why it appears in connected devices. All three, however, run the same pattern: a set-up phase, then an endless loop of sensing, deciding and acting.

The shape of every embedded program

Arduino-style C++ makes the pattern explicit. setup() runs once when power is applied; loop() then repeats forever — millions of times if you let it. Here is the traditional first program, blink, with each line's job:

const int LED_PIN = 13;        // pin 13 has a built-in LED on most boards

void setup() {
  pinMode(LED_PIN, OUTPUT);    // configure the pin to drive a signal out
}

void loop() {
  digitalWrite(LED_PIN, HIGH); // pin to 5 V: LED on
  delay(500);                  // wait 500 milliseconds doing nothing
  digitalWrite(LED_PIN, LOW);  // pin to 0 V: LED off
  delay(500);                  // wait again; loop restarts
}

Three habits are already visible. Pins are named with constants so the code reads as intent. pinMode is declared in setup(), because configuration happens once. And the loop never ends — there is no "end of program" on a microcontroller, only loss of power. The same blink in MicroPython on a Pico shows the pattern is language-independent:

from machine import Pin
import time

led = Pin(25, Pin.OUT)   # configure the pin once
while True:              # the loop
    led.toggle()
    time.sleep(0.5)      # 0.5 seconds

Reading inputs and the floating pin trap

A button is read with digitalRead. Wiring a button correctly hides a subtle problem: a disconnected input pin floats — it picks up electrical noise and reads HIGH and LOW at random. The fix is a pull-up resistor that holds the pin HIGH until the button pulls it to ground. Most microcontrollers have one built in, enabled with INPUT_PULLUP:

const int BTN_PIN = 2;

void setup() {
  pinMode(BTN_PIN, INPUT_PULLUP);  // pin rests HIGH
  Serial.begin(9600);              // start the USB serial link for debugging
}

void loop() {
  bool pressed = (digitalRead(BTN_PIN) == LOW);  // LOW means pressed
  Serial.println(pressed ? "pressed" : "released");
  delay(100);
}

Note the inversion, a classic source of bugs: with a pull-up, pressed reads LOW. Say it out loud when you write the code. The serial monitor output is your window into the program — printing values is the embedded equivalent of a debugger, and Lesson 3's calibration work depends on it.

PWM: fake analogue output with timing

Digital pins are only fully on or fully off, yet robots need in-between values: a dimmer LED, a slower motor. PWM (pulse-width modulation) switches the pin on and off thousands of times per second; the duty cycle — the fraction of each cycle spent on — sets the average. analogWrite(pin, value) takes 0–255, so duty cycle = value / 255.

  • analogWrite(pin, 64) → 64/255 ≈ 25% duty → average voltage ≈ 0.25 × 5 = 1.25 V on a 5 V system.
  • analogWrite(pin, 128) → about 50% → 2.51 V average.
  • A motor on a 9 V supply driven at 170/255 receives an average of 6.0 V and runs at roughly two-thirds speed.

Nothing about the voltage is truly smooth — the load (LED brightness, motor inertia) averages the pulses for you. PWM frequency matters too: too low and LEDs visibly flicker; motors whine at audible frequencies. Boards default to sensible values; know that the knob exists.

The blocking problem: delay() versus millis()

delay(1000) does not pause your program politely; it freezes it. During that second the microcontroller reads no buttons, checks no sensors and updates no outputs. A robot that delays for a second after seeing an obstacle is a robot that drives blind for a second. The professional pattern is non-blocking timing with millis(), which returns the milliseconds since power-up:

unsigned long lastChange = 0;
int ledState = LOW;

void loop() {
  unsigned long now = millis();
  if (now - lastChange >= 500) {   // 500 ms elapsed since last toggle?
    lastChange = now;
    ledState = (ledState == LOW) ? HIGH : LOW;
    digitalWrite(LED_PIN, ledState);
  }
  // ... the rest of the loop keeps running: buttons, sensors, motors
}

The blinking now costs the loop almost no time, so everything else stays responsive. Two details separate correct code from almost-correct code. Store times in unsigned long: on the Uno, millis() overflows after about 49.7 days (2³² milliseconds) and wraps to zero — subtracting two unsigned values still gives the right elapsed time across the wrap, which is why the pattern compares differences, never absolute times. And never use delay() inside a control loop except for tiny stabilising pauses.

A complete program, walked through: the night-light controller

This program combines everything so far: it reads the LDR divider from Lesson 1 on analogue pin A0, fades a lamp LED with PWM when the room is dark, and lets a button override the lamp — all without blocking.

const int LDR_PIN = A0;        // divider output from Lesson 1
const int LAMP_PIN = 9;        // a PWM pin
const int BTN_PIN = 2;
const int DARK_COUNT = 700;    // ADC count above which we call it dark

bool overrideOn = false;       // button toggles manual control
bool lastBtn = HIGH;           // pull-up: released reads HIGH

void setup() {
  pinMode(LAMP_PIN, OUTPUT);
  pinMode(BTN_PIN, INPUT_PULLUP);
  Serial.begin(9600);
}

void loop() {
  // 1. Read the button; react only on the press edge (HIGH to LOW)
  bool btn = digitalRead(BTN_PIN);
  if (lastBtn == HIGH && btn == LOW) {
    overrideOn = !overrideOn;
  }
  lastBtn = btn;

  // 2. Read the light level and decide the lamp brightness
  int light = analogRead(LDR_PIN);
  int brightness;
  if (overrideOn) {
    brightness = 255;                          // manual: full on
  } else if (light > DARK_COUNT) {
    brightness = 200;                          // dark room: lamp on
  } else {
    brightness = 0;                            // daylight: lamp off
  }

  // 3. Act, and report for debugging
  analogWrite(LAMP_PIN, brightness);
  Serial.println(light);
  delay(50);                                   // short pause: 20 checks/second
}

Walk through it: the constants name every pin and the threshold found by calibration. Section 1 detects the edge of a press — without that, one press would toggle the override hundreds of times as the loop repeats. Section 2 is the entire decision, readable at a glance: override beats sensor, sensor beats default. Section 3 acts and logs. The 50 ms delay is deliberate and small: it limits serial output to a readable rate while keeping the button responsive. Trace it against Lesson 1's numbers: at midday the ADC reads near 171, so brightness is 0; at dusk the count climbs past 700 toward 930 and the lamp comes on.

Debugging and reliability

Embedded bugs hide in three places, in this order of likelihood: wiring (loose jumper, wrong pin, missing ground), logic (inverted button sense, wrong threshold), and timing (blocking delays, missed edges). Work the list in order, print values over serial at every stage, and test one subsystem at a time — exactly the discipline Lesson 3 applies to sensors. Reliability is a design feature: programs should fail safe (a watering controller that loses its sensor should stop watering, not flood), avoid leaking memory, and behave predictably after a reset, because robots get reset at the worst possible moments.

Common mistakes

  • Floating inputs. Any input without a pull-up, pull-down or driven source is a random-number generator.
  • Treating pressed as HIGH with a pull-up. The inversion catches everyone once; make it your once.
  • Long delay() calls in the main loop. The program is blind while it waits. Use millis().
  • Writing to pins 0 and 1 on an Uno without thinking. They carry the USB serial link; your motor signal will fight your debug output.
  • Comparing millis() to an absolute deadline instead of elapsed differences — the 49.7-day overflow turns that into a time bomb.
Key idea. Sense, decide, act, repeat — and never let the loop stop sensing. Non-blocking timing is the difference between a demo and a machine.
Try it. Trace the night-light program with the override off and ADC readings of 171, 650, 705, 930. Write down the brightness after each reading. Then state what changes if the threshold were 600 instead of 700.

Real-World Challenge

The task. Design the program for a greenhouse vent controller. Requirements: a temperature sensor on A0 has been calibrated so that temperature in °C = (ADC count − 98) / 9.5. A fan on digital pin 8 must switch on when the temperature rises above 28 °C and off when it falls below 27 °C. A warning LED on pin 13 must blink at 2 Hz (250 ms on, 250 ms off) whenever the fan is running — without blocking the temperature checks. A button on pin 2 (with pull-up) silences the LED for as long as it is held, but must never stop the fan. Write the complete program and explain the two design decisions that make it correct: the 1 °C gap between on and off thresholds, and the blinking method.

Worked solution.

const int TEMP_PIN = A0;
const int FAN_PIN = 8;
const int LED_PIN = 13;
const int BTN_PIN = 2;

bool fanOn = false;
unsigned long lastBlink = 0;
int ledState = LOW;

float readTempC() {
  int raw = analogRead(TEMP_PIN);
  return (raw - 98) / 9.5;
}

void setup() {
  pinMode(FAN_PIN, OUTPUT);
  pinMode(LED_PIN, OUTPUT);
  pinMode(BTN_PIN, INPUT_PULLUP);
  Serial.begin(9600);
}

void loop() {
  float t = readTempC();

  // Fan control with a 1 C deadband (hysteresis)
  if (!fanOn && t > 28.0) fanOn = true;
  else if (fanOn && t < 27.0) fanOn = false;
  digitalWrite(FAN_PIN, fanOn ? HIGH : LOW);

  // Warning LED: blink while fan runs, unless silenced
  bool silenced = (digitalRead(BTN_PIN) == LOW);
  if (fanOn && !silenced) {
    unsigned long now = millis();
    if (now - lastBlink >= 250) {
      lastBlink = now;
      ledState = (ledState == LOW) ? HIGH : LOW;
      digitalWrite(LED_PIN, ledState);
    }
  } else {
    digitalWrite(LED_PIN, LOW);
    ledState = LOW;
  }

  Serial.println(t);
  delay(100);   // 10 temperature checks per second
}

Why the design is right. First, the deadband: with a single 28 °C threshold, a temperature hovering at 27.99–28.01 °C would switch the fan on and off several times a second, wearing the relay and motor. Separate on/off thresholds (28 on, 27 off) mean the temperature must genuinely fall before the fan stops — this is hysteresis, and Lesson 4 shows it is the simplest member of the control family. Second, the blink uses millis() differences, so the loop still checks temperature ten times a second while the LED blinks; a delay(250) version would check only four times a second and react late to a heat spike. The button only gates the LED branch — it can silence the warning but physically cannot stop the fan, which is the safety requirement stated in the task. Check the calibration at the thresholds: 28 °C corresponds to a count of 98 + 28 × 9.5 = 364, and 27 °C to about 355, so the deadband is about 9 ADC counts wide — comfortably larger than the sensor's few counts of noise.

Extension. The greenhouse also needs a dawn check: the fan should only run in daylight. Lesson 1's divider on pin A1 reads low counts in bright light (about 171) and high counts in the dark (about 930). Modify the fan rule so the fan can run only when the light count is below 500, and add serial output that reports why the fan is off (too cold, or too dark). Explain in two sentences why reporting the reason matters when a grower phones you to say "the fan didn't come on."

✅ Check your understanding

Quick recall check, generated from this lesson’s key terms. It is practice only and is not graded.

Keep reading to complete this lesson