A microcontroller is a whole computer on one chip — CPU, memory, timers, and I/O. The pivotal idea of this chapter: the hardware design problem moves into software. Instead of wiring a 555, counters, and gates, you wire a few parts to I/O pins and write a program. Four ways a chip talks to the world — digital I/O, analog input, PWM output, and serial — structure everything.
You want to build a small robot that wanders around a room and never crashes into anything. It has two infrared sensors up front to "see" obstacles, two servo-driven wheels to "walk," and a piezo buzzer to "talk." The behavior you want sounds simple: if something's on the left, turn right; if something's dead ahead, back up; otherwise drive forward.
Try to build that out of pure hardware and you hit a wall fast. You could feed each IR sensor into a comparator, AND/OR the comparator outputs together with logic gates, and wire a 555 timer to generate the servo pulses. But the moment the rule gets more interesting — "back up, then turn, then beep twice" — you are wiring a sequence of decisions over time. That is a flowchart. A flowchart is a program, not a circuit.
This is the moment a microcontroller earns its keep. A microcontroller is a complete tiny computer baked onto a single chip: a CPU to run instructions, flash memory to hold your program, RAM for scratch variables, and a set of I/O pins that connect to the outside world. A chip costing about a dollar reads the two sensor pins, runs the if/else logic in software, and writes pulse trains out to the servos. The hardware shrinks to "a few parts wired to pins"; the cleverness lives in code you can change without touching a soldering iron.
if/else in a loop(). Change behavior = re-flash, no rewiring.Here is the decision tree, written the way you'd actually write it for an Arduino. Notice it reads exactly like the English rule above:
void loop() {
bool leftBlocked = (digitalRead(LEFT_IR) == LOW); // IR sees obstacle
bool rightBlocked = (digitalRead(RIGHT_IR) == LOW);
if (leftBlocked && rightBlocked) {
backUp(); // dead ahead: reverse
tone(BUZZER, 880, 100);
} else if (leftBlocked) {
turnRight(); // something left: steer right
} else if (rightBlocked) {
turnLeft();
} else {
driveForward(); // clear road
}
}
That is the entire intelligence of the robot. The rest of this chapter is about the seams — the four ways the chip touches the physical world, and the handful of interface gotchas (pull-up resistors, the ~20 mA-per-pin current limit, inverted switch logic, 5V vs 3.3V) that trip up every beginner.
Toggle the two front IR sensors to "blocked" and watch which branch of the program fires and what the robot does. This whole logic block is software running on a $1 chip — no gates required.
Open up a desktop computer and you find separate chips: a CPU, sticks of RAM, a hard drive, a graphics card, a clock generator. A microcontroller takes the essential subset of all of that and fuses it onto one piece of silicon a few millimeters across. That integration is the whole point — it is a self-contained computer that needs almost nothing external to run.
Every microcontroller has the same functional blocks. The CPU fetches and executes instructions one after another, driven by a clock running at tens of megahertz. Flash (ROM) holds your program permanently, even with the power off. RAM (SRAM) holds variables while the program runs, and is wiped on power-down. EEPROM is a small slab of non-volatile memory for settings you want to survive a reset.
Then come the parts that make it a controller rather than just a calculator. I/O ports are pins the CPU can read as digital inputs or drive as digital outputs. Timers/counters measure time and generate precise pulses without the CPU babysitting them. Interrupts let external events yank the CPU's attention instantly. A UART handles serial communication. An ADC turns analog voltages into numbers, and sometimes a DAC goes the other way. All on one chip, all clocked together.
| Block | What it does | Robot example |
|---|---|---|
| CPU | Runs instructions, does the if/else logic | Decides turn vs back-up |
| Flash / ROM | Stores the program (non-volatile) | Holds the avoidance sketch |
| RAM / SRAM | Scratch variables (volatile) | Sensor readings, counters |
| I/O ports | Digital read/write pins | Read IR, write buzzer |
| Timer | Precise timing / pulse generation | Servo pulses, millis() |
| Interrupts | Instant response to events | Bumper-switch hit |
| ADC | Analog voltage → number | Read a TMP36 temp sensor |
| UART | Serial Tx/Rx | Debug printf to a PC |
The four "talking to the world" channels deserve special emphasis, because the rest of this chapter is organized around them. They are: digital I/O (Ch 3), analog input via the ADC (Ch 4), PWM output (Ch 5), and serial communication (Ch 8). Master those four and you can interface a microcontroller to almost anything.
Click any block to highlight it and read what it does. The CPU sits on a shared internal bus connecting it to memory and every peripheral.
The book anchors everything to two concrete chips so the abstractions stay grounded. Both are 8-bit AVR microcontrollers from the same family, running 5V logic at tens of megahertz. They sit at opposite ends of a size/capability spectrum.
The ATmega328 is the chip on an Arduino Uno. It has 32 KB of flash for your program, 2 KB of SRAM for variables, and 1 KB of EEPROM for settings, clocked at 16 MHz. It exposes 14 digital I/O pins and 6 analog input pins, plus a hardware UART. Six of those digital pins (3, 5, 6, 9, 10, 11) can do PWM. It is the workhorse of hobby electronics.
The ATtiny85 is an 8-pin chip the size of a grain of rice, with 8 KB of flash, runs from 2.7–5.5V, and costs about $1. After you subtract power and ground, it gives you about five usable I/O pins. When your project is "blink an LED on a moisture reading" you don't need the Uno's bulk — the ATtiny does it for a dollar and a fingernail of board space.
| Spec | ATmega328 (Uno) | ATtiny85 |
|---|---|---|
| Architecture | 8-bit AVR | 8-bit AVR |
| Flash | 32 KB | 8 KB |
| SRAM | 2 KB | 512 B |
| EEPROM | 1 KB | 512 B |
| Clock | 16 MHz | ~8–16 MHz |
| I/O pins | 14 digital + 6 analog | ~5 |
| Supply | 5V (3.3V variants exist) | 2.7–5.5V |
| Cost | ~$2 (chip) | ~$1 |
Suppose your robot sketch compiles to 6.2 KB of machine code and declares about 400 bytes of global variables (sensor arrays, state flags). On the ATmega328: program uses 6.2 KB of 32 KB flash = 19% of flash; variables use 400 B of 2 KB SRAM = 20% of RAM. Comfortable fit. On the ATtiny85: 6.2 KB of 8 KB flash = 78% of flash — tight but possible — while 400 B of only 512 B SRAM = 78% of RAM, dangerously close to overflow. Run out of SRAM and the stack collides with your variables and the chip crashes mysteriously.
String, every large array, every long text literal printed over serial eats SRAM. A 100-character debug message you forgot to wrap in F() can be the byte that tips a tight ATtiny program into a crash. Flash is roomy; SRAM is precious.Set your program and variable sizes, then pick a chip. The bars show flash and SRAM usage; a red bar means you've overflowed.
Reading a switch sounds trivial: wire a button to a pin and call digitalRead(). But there's a trap that catches everyone the first time. A microcontroller input pin reads HIGH when its voltage is near Vcc and LOW when it's near ground. The question is: what voltage is on the pin when the button is not pressed?
If the pin connects only to a button that goes to ground, then with the button open the pin connects to nothing. It is floating — its voltage drifts on stray noise, reading HIGH then LOW at random. The fix is a pull-up resistor: a resistor from the pin to Vcc that gently holds the pin HIGH when nothing else is driving it. Press the button and you connect the pin to ground, which wins (it's a hard short), pulling the pin LOW.
This produces the famous inverted logic: with a switch-to-ground and a pull-up, the pin reads HIGH when the button is released and LOW when pressed. Your code asks "is it LOW?" to detect a press. Microcontrollers include built-in internal pull-ups (20–40 kΩ on AVR) you enable in software with pinMode(pin, INPUT_PULLUP) — no external resistor needed.
pinMode(BUTTON, INPUT_PULLUP); // enable internal ~30k pull-up
...
if (digitalRead(BUTTON) == LOW) { // LOW = pressed (inverted!)
doSomething();
}
While the button is pressed, current flows from Vcc through the pull-up resistor straight to ground. That current is wasted as heat, so resistor choice is a trade-off between noise immunity (smaller R = stiffer pull, more immune) and wasted power (smaller R = more current).
With a 5V supply: a 1 kΩ pull-up draws I = 5 V / 1000 Ω = 5 mA while pressed. A 10 kΩ pull-up draws I = 5 V / 10000 Ω = 0.5 mA — ten times less waste. Go too small, say 270 Ω, and I = 5 V / 270 Ω ≈ 18.5 mA, which is near the ~20 mA-per-pin sink limit — wasteful and risky. The 10 kΩ value is the usual sweet spot, and the internal 20–40 kΩ pull-ups push waste even lower (~0.15 mA).
INPUT_PULLUP.A button wired switch-to-ground with a selectable pull-up to 5V. Click the button to press/release it. Watch the pin voltage, the HIGH/LOW reading (inverted!), and the live current through the resistor while pressed.
A digital pin only knows two states. But the world is analog: a temperature sensor outputs a smoothly varying voltage, a potentiometer sweeps continuously, a light sensor fades. To read these, the microcontroller uses an analog-to-digital converter (ADC) — a circuit that measures a voltage and returns an integer.
The Arduino's ADC is 10-bit, meaning it carves the range from 0V to a reference voltage (Vref, normally 5V) into 210 = 1024 levels, numbered 0 to 1023. A reading of 0 means 0V; a reading of 1023 means ~5V. Everything in between maps linearly. analogRead(pin) returns that integer.
The inverse — turning a count back into a voltage — is what you use to interpret a reading in your program:
The smallest voltage step the ADC can resolve, called 1 LSB (least significant bit), is the whole range divided by the number of steps: resolution = 5 V / 1023 ≈ 4.89 mV. Voltages closer together than that round to the same count — the ADC literally cannot tell them apart. This is quantization, and it's why analog readings come in a staircase, not a smooth ramp.
A TMP36 temperature sensor outputs a voltage proportional to temperature. Suppose it sits at 0.75 V. The ADC returns:
If the sensor drops slightly to 0.71 V: count = round(0.71 / 5 × 1023) = round(145.27) = 145. So a 0.04 V change moved the count by 8 steps — consistent with 1 step ≈ 4.89 mV (since 0.04 V / 4.89 mV ≈ 8). Going the other way, a reading of 512 means Vin = 512/1023 × 5 ≈ 2.50 V, exactly mid-scale.
Sweep the input voltage from 0 to 5V. The smooth analog input (orange dot) snaps to the nearest of 1024 quantization levels (the staircase). Read off the integer count and the reconstructed voltage.
analogRead() returns 512. What voltage is on the pin?You want to dim an LED or slow a motor. But a digital output pin only does two things: 5V or 0V. There is no "2.5V" setting. So how does an Arduino produce a continuous-looking brightness or speed? The answer is one of the most useful tricks in all of electronics: pulse-width modulation (PWM).
Instead of holding the pin at a steady in-between voltage, PWM flips it fully HIGH and fully LOW very fast — hundreds or thousands of times a second — and varies the fraction of time it spends HIGH. That fraction is the duty cycle D. At 50% duty the pin is HIGH half the time; at 25% it's HIGH a quarter of the time. The LED or motor can't react fast enough to follow each pulse, so it responds to the average.
On the Arduino you set PWM with analogWrite(pin, x) where x ranges 0 to 255 (8-bit). The duty cycle is D = x / 255. So analogWrite(pin, 0) is fully off, analogWrite(pin, 255) is fully on, and values between scale the average linearly. Only certain pins (3, 5, 6, 9, 10, 11 on the Uno) have the hardware timer support to do PWM.
analogWrite(9, 102); // D = 102/255 = 0.40 -> ~2.0V average analogWrite(9, 127); // D = 127/255 = 0.498 -> ~2.49V (half brightness) analogWrite(9, 0); // fully off analogWrite(9, 255); // fully on
You want a 2.0V average from a 5V supply to half-dim an LED. Solve for duty cycle:
Convert that duty cycle to an analogWrite value: x = D × 255 = 0.40 × 255 = 102. So analogWrite(pin, 102) gives a 40% duty cycle and a 2.0V average. Going the other way: analogWrite(pin, 127) gives D = 127/255 = 0.498, so Vavg = 0.498 × 5 ≈ 2.49 V — very nearly half-scale, the natural "half brightness" setting.
Drag the duty cycle. The square wave on top shows the pin flipping HIGH/LOW. The LED glows and the motor spins at a rate tracking the average Vavg = D×5V. The matching analogWrite value is computed live. Press Animate to watch the waveform scroll.
analogWrite(9, 64) produces approximately what duty cycle and average voltage?An output pin can flip 5V, but it cannot deliver much current. An AVR pin reliably sources or sinks about 20 mA, and the whole chip is limited to roughly 200 mA total. An LED with a series resistor (~10 mA) is fine straight off a pin. A motor, a relay, or a string of LEDs is not — ask a pin for 100 mA and you'll brown out or destroy it.
The solution is to use the pin as a control signal and let a beefier component carry the load current. A transistor (BJT or MOSFET) acts as a switch: a tiny pin current at its base/gate turns on a much larger current through its collector/drain. The pin commands; the transistor does the heavy lifting from the main supply.
Motors, relays, and solenoids are inductive. When you switch off the current through an inductor, its magnetic field collapses and tries to keep current flowing — generating a huge reverse voltage spike (V = −L·dI/dt) that can punch through your transistor. A flyback (freewheeling) diode placed across the coil, reverse-biased in normal operation, gives that collapsing current a safe loop to die out in. Leave it off and the spike eventually kills the switch.
// Pin -> resistor -> NPN base; motor from +supply to collector; // flyback diode across the motor (cathode to +supply). pinMode(MOTOR_DRIVE, OUTPUT); analogWrite(MOTOR_DRIVE, 153); // 60% speed via the transistor
You want to energize a relay coil that draws 70 mA at 5V. A pin maxes out near 20 mA, so direct drive is out by 3.5×. Instead, drive an NPN transistor through a base resistor. Pick the base current so the transistor saturates: for a transistor with current gain β ≈ 100, you need at least Ibase = 70 mA / 100 = 0.7 mA, and in practice 2–3 mA for solid saturation — well within the pin's 20 mA budget. The collector then sinks the full 70 mA from the supply, and a flyback diode across the coil tames the turn-off spike.
The robot's wheels use servos, controlled by a pulse-width signal: a pulse between 1000 µs and 2000 µs (1500 µs = center) repeated every ~20 ms. To run a plain DC motor both directions, you need an H-bridge — four transistors that can connect the motor either way across the supply, giving forward, reverse, and brake.
Set a load's current demand and decide: direct from a pin, or through a transistor? The meter turns red past the ~20 mA pin limit, showing when you must add a driver.
So far the robot runs a loop() that reads sensors, decides, and acts, over and over. This is polling: the CPU repeatedly asks "is anything happening?" Polling is simple and usually fine. But it has a fatal flaw — if the loop is busy doing something slow (a long delay(), a serial print, a sensor that takes milliseconds), it isn't looking at the other inputs, and a fast event can come and go unnoticed.
Imagine the robot's bumper switch. If the main loop is mid-way through a 500 ms turn maneuver and the bumper taps a wall for 5 ms, a polling loop checking the bumper only between maneuvers will miss the hit entirely. The robot keeps driving into the wall.
An interrupt flips this around. You tell the hardware "watch this pin, and the instant it changes, stop whatever you're doing and run this function." The function is an interrupt service routine (ISR). The CPU is yanked away from the main loop, runs the ISR, then resumes exactly where it left off. The event is caught no matter how busy the loop was.
volatile bool bumped = false;
void onBump() { bumped = true; } // ISR: keep it tiny
void setup() {
pinMode(2, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(2), onBump, FALLING);
}
void loop() {
if (bumped) { bumped = false; backUp(); } // handle it
... long maneuvers here ...
}
The other tool is the timer. millis() returns the milliseconds since boot and micros() the microseconds — both driven by a hardware timer that counts in the background, independent of your code. Instead of delay(1000) (which freezes the whole program for a second), you compare millis() against a saved timestamp, so the loop keeps spinning and stays responsive.
The main loop spends 50 ms per pass driving servos and printing debug text. A bumper pulse lasts 5 ms. With polling, the bumper is only checked once every 50 ms, so a 5 ms pulse has roughly a 5/50 = 10% chance of landing in the brief window where the code happens to look — it's missed ~90% of the time. With an interrupt, the pulse is caught in microseconds, every single time, regardless of what the loop is doing.
volatile flag and get out — because while an ISR runs, the rest of the system (including other interrupts) waits.A short event pulse fires while the main loop grinds through a busy block. Toggle between polling and interrupt mode and press Fire Event to see whether it gets caught or slips through the gap.
A microcontroller often has to talk to other chips or a PC — send a sensor reading, receive a command, chain to a display. Doing that with one wire per bit (parallel) eats pins fast. The dominant approach is serial: send the bits one at a time down a single wire, fast enough that the slowness doesn't matter.
The UART sends bytes over two wires: Tx (transmit) and Rx (receive). There's no shared clock — instead both ends agree in advance on a speed, the baud rate (bits per second), commonly 9600. Each byte is wrapped in a frame. The standard frame is 8N1: 1 start bit, 8 data bits, No parity, 1 stop bit — 10 bits total to carry 8 bits of data. The data bits go out LSB first.
At 9600 baud, each bit lasts tbit = 1 / 9600 ≈ 104 µs. A full 8N1 frame is 10 bits, so one byte takes 10 × 104 µs = 1.04 ms. The throughput is therefore baud / 10 = 9600 / 10 = 960 bytes/s — roughly a kilobyte per second. (Note it's baud/10, not baud/8, because the start and stop bits are overhead that carry no data.)
Serial.begin(9600); // 9600 baud, 8N1 by default Serial.println(analogRead(A0)); // send the reading + newline
When you're connecting chips on the same board, two clocked buses dominate. I2C uses just two wires — a clock (SCL) and a data line (SDA) — shared by many devices, each with an address; it tops out around 400 kbit/s. On the Uno, I2C lives on pins A4 (SDA) and A5 (SCL). SPI uses four wires (clock, MOSI, MISO, and a select line per device) and runs much faster — up to tens of Mbit/s — ideal for SD cards and displays.
| Bus | Wires | Speed | Best for |
|---|---|---|---|
| UART | 2 (Tx, Rx) | ~9600 – 115200 baud | PC link, GPS, debug |
| I2C | 2 (SCL, SDA) | ≤ 400 kbit/s | Many sensors on one bus |
| SPI | 4 (SCK, MOSI, MISO, SS) | up to ~80 Mbit/s | SD cards, displays |
Pick a character and a baud rate. Watch its 8 bits serialized LSB-first, wrapped in a start bit (line pulled LOW) and a stop bit (line HIGH), with each bit's duration annotated. Press Send to animate the line.
Time to close the loop on Chapter 0. We have the four interface channels — digital input (the IR sensors and bumper), PWM/servo output (the wheels), and a digital output for the buzzer. The same circuit can be programmed in two different toolchains, which is exactly the book's running comparison: PBASIC on a BASIC Stamp, and C on an Arduino. Same wires, same decision tree, two languages.
The BASIC Stamp's PBASIC is a friendly interpreted dialect: PULSOUT drives a servo pulse, IF...THEN branches, DEBUG prints. The Arduino's C is compiled and uses library calls like digitalRead, the Servo object, and tone. The differences are surface syntax — the structure (read sensors → decide → drive) is identical.
PBASIC (BASIC Stamp)
DO
left = IN0 ' read IR pins
right = IN1
IF left = 0 AND right = 0 THEN
GOSUB back_up ' both blocked
FREQOUT 4, 100, 880
ELSEIF left = 0 THEN
GOSUB turn_right
ELSEIF right = 0 THEN
GOSUB turn_left
ELSE
GOSUB forward
ENDIF
LOOP
Arduino C (ATmega328)
void loop() {
bool L = digitalRead(LEFT_IR)==LOW;
bool R = digitalRead(RIGHT_IR)==LOW;
if (L && R) {
backUp(); // both blocked
tone(BUZZER, 880, 100);
} else if (L) {
turnRight();
} else if (R) {
turnLeft();
} else {
forward();
}
}
Driving forward means sending both wheel servos a pulse. Recall from Ch 6 that a servo wants a 1000–2000 µs pulse every ~20 ms; a continuous-rotation servo reads 1500 µs as "stop," 2000 µs as "full forward," 1000 µs as "full reverse." In PBASIC that's a PULSOUT; in Arduino C the Servo library hides the timer arithmetic behind writeMicroseconds():
// Arduino: drive both wheels forward leftWheel.writeMicroseconds(2000); // full forward rightWheel.writeMicroseconds(1000); // mirrored servo -> also forward delay(20); // ~one servo frame
Toggle the IR sensors and bumper; the robot transitions between states (Forward, Turn, Back Up, Beep) and the matching servo pulse widths are shown. This is the program from Ch 0 made visible.
Chapter 13 made one big argument: a microcontroller lets the hardware design problem move into software. You wire a few parts to I/O pins and write a program. Everything else was about the four channels the chip uses to touch the world — and the interface gotchas that make or break a build.
| Channel | Direction | Key idea | Arduino call |
|---|---|---|---|
| Digital I/O | in / out | HIGH/LOW; pull-ups define resting state | digitalRead / digitalWrite |
| Analog input (ADC) | in | Voltage → 0–1023 (10-bit) | analogRead |
| PWM output | out | Duty cycle → average voltage | analogWrite |
| Serial (UART/I2C/SPI) | in / out | Bits one wire at a time | Serial / Wire / SPI |
| Quantity | Formula | Worked value |
|---|---|---|
| ADC count | round(Vin/Vref × 1023) | 0.75V → 153 |
| ADC inverse | Vin = count/1023 × 5 | 512 → 2.50V |
| ADC resolution | Vref / 1023 | 5/1023 ≈ 4.89 mV |
| PWM average | Vavg = D × Vcc | D=0.40 → 2.0V |
| PWM value | x = D × 255 | 0.40 → 102 |
| Pull-up current | I = Vcc / R | 5V/10k = 0.5 mA |
| Serial bit time | tbit = 1/baud | 9600 → 104 µs |
| Serial throughput | baud / 10 (8N1) | 9600 → 960 B/s |
| Servo pulse | 1000–2000 µs every 20 ms | 1500 µs = center |
INPUT_PULLUP.You can now read a sensor, dim an LED, drive a motor, and talk to another chip — the whole vocabulary of embedded control. Chapter 14 asks: what if even a fast CPU running code one instruction at a time is too slow? Then you stop writing a program and start configuring hardware.