Hardware Meets Software

Introduction to Embedded
Systems

The invisible computers running your car, your microwave, your pacemaker — and how to program them from scratch.

Prerequisites: Basic programming + Binary numbers. That's it.
10
Chapters
10+
Simulations
0
Assumed Knowledge

Chapter 0: What IS an Embedded System?

Your microwave has a computer inside it. So does your car — over 100 of them. Your thermostat, your washing machine, your electric toothbrush, your elevator. These aren't laptops. They're purpose-built computers with ONE job, running forever, with no operating system to save them if something crashes.

A laptop can run Photoshop, play games, browse the web. An embedded system does one thing: read a temperature sensor and control a heater. Or fire an airbag within 10 milliseconds of impact. Or keep a pacemaker's rhythm at exactly 72 BPM while consuming less power than a watch battery.

The definition: An embedded system is a computer designed to perform a dedicated function within a larger mechanical or electrical system, often with real-time constraints and severe resource limitations.

What makes embedded different from your PC?

ConstraintYour LaptopEmbedded System
RAM16 GB2 KB – 512 KB
Clock3+ GHz8 – 168 MHz
Power65 W0.001 – 1 W
Cost target$1000+$0.50 – $10
Latency"Fast enough"Microsecond deadline
OSWindows/macOS/LinuxNone (bare metal) or RTOS

These constraints aren't annoyances — they're design requirements. A pacemaker that needs a wall outlet kills the patient. A $50 microcontroller in every car door sensor makes the car unaffordable. An airbag that fires 200ms late is useless.

Key insight: Embedded programming isn't "programming but worse." It's a different discipline. You don't write code that runs on an OS — you write code that IS the system. There's nothing between you and the hardware.
Embedded Device Explorer

Click a device to see its real-world constraints. Notice how wildly they differ — yet all are "embedded systems."

What distinguishes an embedded system from a general-purpose computer?

Chapter 1: Microcontrollers vs Microprocessors

You've heard "microcontroller" and "microprocessor" used interchangeably. They're not the same thing. The difference is like a Swiss Army knife vs a chef's knife — one is self-contained, the other needs a kitchen.

A microprocessor (MPU) is just a CPU. It's powerful, but it needs external RAM, external flash storage, external peripherals — all connected on a circuit board. Think: Intel Core i7, ARM Cortex-A72 (Raspberry Pi). These live in phones, PCs, and servers.

A microcontroller (MCU) puts EVERYTHING on one chip: the CPU, the RAM, the flash memory for your program, the ADC for reading sensors, the UART for serial communication, the timers, the GPIO pins. One chip, one package, done. Think: STM32, ESP32, ATmega328P (Arduino).

The analogy: An MCU is a studio apartment — kitchen, bed, bathroom all in one room. An MPU is a kitchen appliance that needs a house built around it.
FeatureMCU (e.g., STM32F103)MPU (e.g., Cortex-A72)
CPU + MemoryAll on-chipCPU only, external RAM
RAM20 KB SRAM1–8 GB DDR4
Clock72 MHz1.5 GHz
Flash64–512 KB on-chipSD card / eMMC (external)
PeripheralsBuilt-in ADC, UART, SPI, I²C, timersNeeds external chips
Cost$1–$10$10–$100+
Power10–100 mW1–5 W
OSBare-metal or RTOSLinux, Android
Boot time<1 ms5–30 seconds
Use caseMotor control, sensors, IoTGUI, networking, ML

The classic comparison: Arduino Uno (ATmega328P: 2 KB RAM, 32 KB Flash, 16 MHz, $3) vs Raspberry Pi 4 (Cortex-A72: 4 GB RAM, 64 GB SD, 1.5 GHz, $55). The Arduino runs one C program forever. The Pi runs Linux with a full desktop.

When to use which? If you need to read a sensor and toggle a relay every 10ms with zero boot time: MCU. If you need a web server with a camera feed and machine learning: MPU. The boundary is blurring (ESP32 does WiFi + dual-core for $4), but the mental model holds.
MCU Chip Anatomy

Click each block to learn what it does. Everything lives on ONE silicon die.

Why does an MCU boot in under 1 millisecond while a Raspberry Pi takes 30 seconds?

Chapter 2: Registers & the Memory Map

Here's the single most important concept in embedded programming: everything is a memory address. That LED on pin 13? It's controlled by a single bit at address 0x4001_100C. That timer counting microseconds? It's a 16-bit number at address 0x4001_2424. Want to send a byte over UART? Write it to address 0x4001_3804.

A register in embedded programming is not a CPU register (like R0-R15). It's a specific memory address that has hardware attached to it. Writing to that address doesn't just store a number — it makes physical things happen. This is called memory-mapped I/O.

The key revelation: In a microcontroller, peripherals (GPIO, timers, UART, ADC) are all controlled by reading and writing specific memory addresses. The "memory map" is a table telling you which address controls what hardware.

Here's a simplified memory map for an STM32F103 (a popular ARM Cortex-M3 MCU):

Address RangeWhat Lives ThereSize
0x0800_0000Flash — your compiled code64–512 KB
0x2000_0000SRAM — variables, stack20 KB
0x4001_0800GPIOA registers28 bytes
0x4001_0C00GPIOB registers28 bytes
0x4001_2C00TIM1 (timer 1) registers84 bytes
0x4001_3800USART1 registers28 bytes
0x4001_2400ADC1 registers80 bytes
0xE000_E100NVIC (interrupt controller)varies

Each peripheral has a base address, and its individual control registers are at fixed offsets from that base. For example, GPIOA's base is 0x4001_0800:

OffsetRegisterPurpose
+0x00CRLConfigure pins 0–7 mode
+0x04CRHConfigure pins 8–15 mode
+0x08IDRRead all 16 pins (input data)
+0x0CODRSet all 16 pins (output data)
+0x10BSRRSet/reset individual pins atomically

So to read the state of GPIOA pin 5, you read address 0x4001_0808 (base + IDR offset) and check bit 5. In C:

c
// Read pin PA5 (is the button pressed?)
volatile uint32_t *GPIOA_IDR = (uint32_t *)0x40010808;
uint8_t pin5 = (*GPIOA_IDR >> 5) & 1;  // Extract bit 5

// Set pin PA5 HIGH (turn on LED)
volatile uint32_t *GPIOA_ODR = (uint32_t *)0x4001080C;
*GPIOA_ODR |= (1 << 5);  // Set bit 5 = 1
Why volatile? Without it, the compiler might "optimize away" your read — it thinks the value can't change between reads. But hardware CAN change a register at any time (a button press, a timer tick). volatile forces the CPU to actually read the address every time.
Interactive Memory Map

Click an address region to see what peripheral lives there. "Write" a value to a register and watch the hardware respond.

If GPIOB's base address is 0x4001_0C00 and ODR is at offset +0x0C, what address do you write to set GPIOB output pins?

Chapter 3: GPIO — Your First Peripheral

General Purpose Input/Output (GPIO) is the simplest peripheral, and the one you'll use most. Each GPIO pin is a physical metal pad on the chip that connects to the outside world. You configure each pin to be one of several modes:

ModeWhat It DoesExample
InputRead external voltage (HIGH/LOW)Button, switch, digital sensor
OutputDrive voltage out (3.3V or 0V)LED, relay, motor driver enable
Alternate FunctionPin is controlled by a peripheralUART TX, SPI MOSI, PWM output
AnalogRead continuous voltage (0–3.3V)Temperature sensor, potentiometer

Let's walk through the exact register writes to blink an LED on PA5 (Port A, Pin 5) of an STM32F103. This is the "Hello World" of embedded.

Step 1: Enable Clock
RCC->APB2ENR |= (1 << 2) — GPIOA can't work without its clock enabled
Step 2: Configure Mode
GPIOA->CRL: set bits [23:20] = 0b0011 — pin 5, output push-pull, 50MHz
Step 3: Set Output
GPIOA->ODR |= (1 << 5) — drive PA5 HIGH (3.3V → LED lights up)
Step 4: Clear Output
GPIOA->ODR &= ~(1 << 5) — drive PA5 LOW (0V → LED off)
↻ loop with delay
c
// Blink LED on PA5 — bare metal STM32F103
#include "stm32f1xx.h"

void delay(volatile uint32_t count) {
    while(count--);
}

int main(void) {
    // 1. Enable GPIOA clock (bit 2 of APB2ENR)
    RCC->APB2ENR |= (1 << 2);

    // 2. Configure PA5 as output, push-pull, 50MHz
    //    CRL controls pins 0-7, each pin uses 4 bits
    //    Pin 5: bits [23:20] = 0b0011 (output 50MHz push-pull)
    GPIOA->CRL &= ~(0xF << 20);   // Clear pin 5 config
    GPIOA->CRL |=  (0x3 << 20);   // Set output push-pull 50MHz

    while(1) {
        GPIOA->ODR |= (1 << 5);    // LED ON
        delay(500000);
        GPIOA->ODR &= ~(1 << 5);   // LED OFF
        delay(500000);
    }
}
Why enable the clock first? MCUs save power by disabling clock signals to unused peripherals. If you try to write to GPIOA registers without enabling its clock, the writes silently do nothing. This is the #1 "why isn't it working" bug for beginners.

For reading a button on PA0 (input with internal pull-up):

c
// Read button on PA0 (active low — pressed = 0)
GPIOA->CRL &= ~(0xF << 0);   // Clear pin 0 config
GPIOA->CRL |=  (0x8 << 0);   // Input with pull-up/pull-down
GPIOA->ODR |=  (1 << 0);     // Select pull-UP (ODR bit activates pullup)

// In main loop:
if (!(GPIOA->IDR & (1 << 0))) {
    // Button is pressed (pin reads LOW)
}
Virtual MCU — GPIO Control

Write to registers to control the LED and read the button. This is exactly what happens inside the chip.

You write to GPIOA->ODR but the LED doesn't light up. You haven't done anything else. What did you forget?

Chapter 4: Timers — Counting at Hardware Speed

A timer is the most versatile peripheral on any MCU. At its core, it's stupidly simple: a counter register that increments every clock cycle. When it reaches a target value, it overflows (wraps to zero) and can trigger an event. That's it. But from this simple mechanism, you get:

ApplicationHow It Works
Periodic interruptsTimer overflows every 1ms → ISR runs → sample sensor
PWM outputTimer sets pin HIGH at start, LOW at compare → variable duty cycle
Input captureTimer records its count when an external edge arrives → measure pulse width
Delay generationStart timer, wait for overflow flag, stop → precise blocking delay

Let's work through the math for a 1 kHz periodic interrupt (fires every 1ms). Our STM32F103 runs at 72 MHz. The timer has two controls:

foverflow = fclock / ((PSC + 1) × (ARR + 1))

Where PSC (prescaler) divides the clock before it reaches the counter, and ARR (auto-reload register) is the value the counter counts up to before overflowing.

Worked example: We want foverflow = 1000 Hz from fclock = 72,000,000 Hz.

1000 = 72,000,000 / ((PSC+1) × (ARR+1))
(PSC+1) × (ARR+1) = 72,000

Many valid combinations. Common choice: PSC = 71 (divide clock by 72 → 1 MHz tick), ARR = 999 (count 0 to 999 = 1000 ticks). Verify: 72MHz / 72 / 1000 = 1000 Hz. Done.

c
// Configure TIM2 for 1kHz overflow interrupt
RCC->APB1ENR |= (1 << 0);   // Enable TIM2 clock

TIM2->PSC = 71;               // Prescaler: 72MHz / (71+1) = 1MHz
TIM2->ARR = 999;              // Auto-reload: count to 999 = 1000 ticks
TIM2->DIER |= (1 << 0);      // Enable update interrupt (UIE bit)
TIM2->CR1 |= (1 << 0);       // Start counter (CEN bit)

// In NVIC: enable TIM2 interrupt (IRQ #28)
NVIC->ISER[0] |= (1 << 28);
PWM explained: For PWM (Pulse Width Modulation), the timer counts 0 → ARR. A "compare register" (CCR) sets a threshold. While counter < CCR, output is HIGH. While counter ≥ CCR, output is LOW. By changing CCR, you change the duty cycle (0–100%). This is how you dim an LED or control a motor's speed.
Duty Cycle = CCR / (ARR + 1) × 100%
Timer Counter Visualization

Watch the counter count up. Adjust prescaler and ARR to change overflow frequency. Enable PWM mode to see the output waveform.

Prescaler 71
ARR 999
PWM Duty 50%
You need a 500 Hz timer overflow from a 72 MHz clock. If PSC = 71 (giving 1 MHz timer tick), what should ARR be?

Chapter 5: Interrupts — Hardware Calling Your Code

Without interrupts, your CPU would have to constantly poll every peripheral: "Is the button pressed? No. Is the button pressed? No. Is the button pressed? No. Is UART data ready? No." This wastes 99.99% of CPU time checking things that haven't happened.

Interrupts flip this model. The hardware watches for events. When an event occurs (button press, timer overflow, byte received), it yanks the CPU out of whatever it's doing, forces it to run a specific function (the Interrupt Service Routine, or ISR), then returns to the original code as if nothing happened.

The analogy: Polling is checking your phone every 5 seconds for a text. Interrupts are turning on notifications — the phone taps you on the shoulder when something arrives. You can focus on other work.

Here's the sequence when an interrupt fires:

1. Event Occurs
Timer overflows, pin changes state, UART byte arrives
2. Hardware Saves Context
CPU pushes R0-R3, R12, LR, PC, PSR onto stack (8 registers, automatic)
3. NVIC Looks Up Vector
Reads the ISR address from the vector table at 0x0000_0000 + 4*IRQ_number
4. ISR Executes
Your handler function runs — keep it SHORT (set a flag, copy data, clear interrupt)
5. Return from ISR
CPU pops saved registers, resumes main code exactly where it left off

The NVIC (Nested Vectored Interrupt Controller) is the hardware that manages all of this. "Nested" means a higher-priority interrupt can interrupt a lower-priority ISR. "Vectored" means each interrupt source has its own entry in a table (no polling to figure out which interrupt fired).

c
// Timer 2 ISR — called every 1ms (from our Ch.4 config)
void TIM2_IRQHandler(void) {
    if (TIM2->SR & (1 << 0)) {    // Check update interrupt flag
        TIM2->SR &= ~(1 << 0);   // CLEAR the flag (critical!)
        milliseconds++;             // Increment global counter
    }
}

// External interrupt on PA0 (button press)
void EXTI0_IRQHandler(void) {
    if (EXTI->PR & (1 << 0)) {    // Check pending bit
        EXTI->PR |= (1 << 0);    // Clear by writing 1
        button_pressed = 1;        // Set flag for main loop
    }
}
Critical rule: CLEAR THE INTERRUPT FLAG. If you forget to clear the flag in your ISR, the interrupt fires again immediately after returning — infinite loop. The CPU never returns to main. This is the #1 embedded debugging nightmare.
ISR rules of thumb: (1) Keep ISRs SHORT — set a flag, copy data, get out. (2) Never call blocking functions (delay, printf, malloc) inside an ISR. (3) Variables shared between ISR and main must be volatile. (4) Always clear the interrupt flag.
Interrupt Flow Visualization

Watch main code execute (green). Click "Fire Interrupt" to see context save, ISR execution (orange), and context restore in real time.

Your timer ISR runs but then immediately runs again in an infinite loop without ever returning to main. What's the most likely cause?

Chapter 6: The Showcase — Digital Thermometer

Time to put it all together. We'll build a complete embedded system from scratch: a digital thermometer that samples temperature every 100ms (timer interrupt), displays the reading on a 7-segment display (GPIO output), and responds to a button press (external interrupt) to switch between °C and °F.

This uses every concept from Chapters 0–5:

ComponentChapterMechanism
Temperature sensor → ADCCh 2 (Registers)Read analog voltage at memory-mapped ADC register
100ms samplingCh 4 (Timers)TIM2 overflow at 10 Hz triggers ISR
ISR reads ADCCh 5 (Interrupts)Timer ISR starts ADC conversion, stores result
7-segment displayCh 3 (GPIO)7 output pins drive segment LEDs
°C/°F buttonCh 5 (Interrupts)External interrupt on PA0 toggles unit flag
c
// Digital thermometer — complete bare-metal implementation
volatile uint16_t adc_value = 0;
volatile uint8_t  new_sample = 0;
volatile uint8_t  use_fahrenheit = 0;

// TIM2 ISR — fires every 100ms (PSC=7199, ARR=999 at 72MHz)
void TIM2_IRQHandler(void) {
    TIM2->SR &= ~(1 << 0);      // Clear interrupt flag
    ADC1->CR2 |= (1 << 0);      // Start ADC conversion
    while(!(ADC1->SR & (1<<1))); // Wait for EOC (end of conversion)
    adc_value = ADC1->DR;         // Read 12-bit result (0-4095)
    new_sample = 1;              // Signal main loop
}

// EXTI0 ISR — button press toggles °C/°F
void EXTI0_IRQHandler(void) {
    EXTI->PR |= (1 << 0);       // Clear pending bit
    use_fahrenheit ^= 1;         // Toggle unit
}

int main(void) {
    setup_clocks();  setup_gpio();
    setup_adc();    setup_timer();
    setup_exti();

    while(1) {
        if (new_sample) {
            new_sample = 0;
            float temp_c = (adc_value * 3.3 / 4095.0 - 0.5) * 100.0;
            float display_temp = use_fahrenheit ? temp_c*9.0/5.0+32.0 : temp_c;
            update_7seg((int)display_temp);
        }
    }
}
Notice the pattern: ISRs do the minimum (read data, set flag). The main loop does the heavy work (convert units, update display). This separation keeps ISRs fast and the system responsive.
Full MCU Simulation

Watch the complete system: timer counts, interrupt fires, ADC reads, display updates. Press the button to toggle °C/°F. Drag the temperature slider.

Temperature 22.0°C

Chapter 7: Communication Protocols

Your MCU lives on a board with sensors, displays, memory chips, and other MCUs. They need to talk. Three protocols dominate the embedded world:

ProtocolWiresSpeedAddressingBest For
UART2 (TX, RX)9600–921600 baudPoint-to-pointDebug console, GPS, Bluetooth
SPI4 (CLK, MOSI, MISO, CS)1–50+ MHzChip select lineDisplays, SD cards, fast sensors
I²C2 (SCL, SDA)100–400 kHz7-bit addressSensors, EEPROMs, multi-device

UART (Universal Async Receiver/Transmitter)

Asynchronous means no clock line — both sides agree on a baud rate beforehand. Data format: 1 start bit (LOW) + 8 data bits (LSB first) + 1 stop bit (HIGH). Total: 10 bits per byte. At 9600 baud: 9600 bits/second ÷ 10 bits/byte = 960 bytes/second.

c
// Send one byte over USART1
void uart_send(uint8_t byte) {
    while(!(USART1->SR & (1<<7)));  // Wait until TX empty (TXE bit)
    USART1->DR = byte;              // Write byte to data register
}

// Receive one byte
uint8_t uart_recv(void) {
    while(!(USART1->SR & (1<<5)));  // Wait until RX not empty (RXNE bit)
    return USART1->DR;              // Read received byte
}

SPI (Serial Peripheral Interface)

Synchronous (has a clock line). Master controls CLK. Data shifts out on MOSI (Master Out Slave In) and simultaneously in on MISO (Master In Slave Out). Full duplex. CS (Chip Select) line goes LOW to select a specific slave. Very fast (50+ MHz) but requires 4 wires + one CS per slave device.

I²C (Inter-Integrated Circuit)

Only 2 wires (SCL clock + SDA data), supports up to 127 devices on the same bus. Each device has a unique 7-bit address. Master sends address first, the matching slave responds. Slower than SPI (100–400 kHz) but uses minimal pins. Perfect for multiple sensors on one bus.

Rule of thumb: Need debug output? UART. Need speed (display, SD card)? SPI. Need many sensors on few pins? I²C. Many real boards use all three simultaneously.
Protocol Waveform Viewer

Select a protocol and watch byte 0x42 ('B') transmitted bit by bit on the wire(s).

You need to connect 8 temperature sensors to one MCU with minimal wiring. Which protocol?

Chapter 8: Software Patterns for Embedded

You've mastered the hardware. Now: how do you structure the software that runs on it? There are three dominant patterns in bare-metal embedded, each with clear trade-offs:

Pattern 1: Super-Loop

The simplest possible architecture. One infinite loop that checks everything sequentially:

c
while(1) {
    read_sensors();       // Poll ADC
    process_data();      // Convert, filter
    update_display();    // Write to 7-seg
    check_buttons();     // Poll GPIO inputs
    handle_comms();      // Poll UART RX
}

Pros: Dead simple, easy to debug, deterministic order. Cons: Wastes CPU time polling things that haven't changed. Can't respond to events faster than loop time. If one function blocks (slow sensor read), everything else waits.

Pattern 2: Interrupt-Driven

ISRs handle time-critical events immediately. Main loop processes flags:

c
volatile uint8_t flags = 0;
#define FLAG_TIMER   (1<<0)
#define FLAG_BUTTON  (1<<1)
#define FLAG_UART_RX (1<<2)

void TIM2_IRQHandler() { TIM2->SR=0; flags|=FLAG_TIMER; }
void EXTI0_IRQHandler(){ EXTI->PR=1; flags|=FLAG_BUTTON; }

while(1) {
    if(flags & FLAG_TIMER)  { flags&=~FLAG_TIMER;  sample_sensor(); }
    if(flags & FLAG_BUTTON) { flags&=~FLAG_BUTTON; toggle_unit();   }
    if(flags & FLAG_UART_RX){ flags&=~FLAG_UART_RX;process_cmd();  }
    __WFI();  // Sleep until next interrupt (saves power!)
}

Pros: Instant response to events, CPU sleeps when idle (low power), no wasted polling. Cons: Race conditions between ISR and main, priority inversion bugs, harder to reason about.

Pattern 3: State Machine

The system is in one state at any time. Events cause transitions to other states. Each state has defined entry/exit/loop actions:

c
typedef enum { IDLE, HEATING, COOLING, ALARM } State;
typedef enum { EVT_TEMP_HIGH, EVT_TEMP_LOW, EVT_TEMP_OK, EVT_FAULT } Event;

State current = IDLE;

void handle_event(Event e) {
    switch(current) {
        case IDLE:
            if(e == EVT_TEMP_LOW)  { heater_on();  current = HEATING; }
            if(e == EVT_TEMP_HIGH) { cooler_on();  current = COOLING; }
            if(e == EVT_FAULT)     { alarm_on();   current = ALARM;   }
            break;
        case HEATING:
            if(e == EVT_TEMP_OK)   { heater_off(); current = IDLE;    }
            if(e == EVT_FAULT)     { heater_off(); alarm_on(); current = ALARM; }
            break;
        // ... COOLING, ALARM cases
    }
}

Pros: Extremely debuggable (print current state), impossible to be in two states at once, easy to extend. Cons: Boilerplate for simple systems, state explosion for complex ones.

In practice: Most real embedded systems combine patterns 2 and 3. Interrupts handle hardware events and set flags. The main loop runs a state machine that processes those flags. This gives you both instant hardware response AND clean, debuggable logic.
State Machine Simulator

A thermostat state machine. Generate events and watch the system transition between states.

Your MCU must respond to a button within 1µs but also run complex filtering math. Which pattern?

Chapter 9: Mastery & Connections

You now understand the fundamental building blocks of embedded systems. Every device — from a $0.50 sensor node to a $50 drone flight controller — is built from these same primitives: GPIO, timers, interrupts, communication protocols, and software patterns.

Register Cheat Sheet

PeripheralKey RegistersWhat They Do
GPIOCRL/CRH, IDR, ODR, BSRRConfigure mode, read inputs, set outputs
TimerCR1, PSC, ARR, CCR1, SR, DIERControl, prescaler, reload, compare, status, interrupt enable
NVICISER, ICER, ISPR, IPREnable, disable, set-pending, priority
USARTCR1, BRR, SR, DRControl, baud rate, status, data
RCCAPB1ENR, APB2ENR, CFGRPeripheral clock enable, clock config

Interrupt Priority Design

PrioritySourceRationale
0 (highest)Safety-critical (fault, motor shutoff)Must never be delayed
1Timing-critical (high-freq PWM, encoder)Jitter = position error
2Periodic sampling (ADC, sensor read)Late sample = filter error
3Communication (UART RX, SPI)Buffer absorbs short delays
4 (lowest)UI (button, display update)Humans can't tell 1ms vs 5ms

Clock Tree Basics

The MCU has a clock tree that distributes timing from an oscillator (8 MHz crystal) through PLLs and dividers to all peripherals. The STM32F103 tree: 8 MHz HSE → PLL ×9 → 72 MHz SYSCLK → AHB /1 → 72 MHz → APB2 /1 → 72 MHz (GPIO, USART1, TIM1) → APB1 /2 → 36 MHz (TIM2-4, USART2-3, I²C, SPI).

Watch out: APB1 timer clocks get ×2 when APB1 prescaler ≠ 1. So TIM2 on APB1 at 36 MHz actually ticks at 72 MHz (same as TIM1). This trips up everyone at least once.

Design Challenge: Traffic Light Controller

Design a traffic light system with these requirements:

Your design: 4 states (GREEN, YELLOW, RED, EMERGENCY). TIM2 at 1Hz counts seconds. EXTI for pedestrian button (priority 3) and emergency input (priority 0). State machine in main loop. Can you identify all the registers you'd need to configure?

What Comes Next

This LessonNext Steps
Bare-metal registersHAL libraries (STM32Cube), Arduino framework
Super-loop / interrupt-drivenRTOS (FreeRTOS — tasks, queues, semaphores)
GPIO + Timer + UARTDMA (Direct Memory Access — transfers without CPU)
Single MCUMulti-processor systems, CAN bus (automotive)
C programmingRust for embedded (memory safety without runtime cost)
The takeaway: Embedded systems aren't mysterious. They're just computers with clear constraints, programmed by reading and writing specific memory addresses. Every peripheral is a set of registers. Every behavior emerges from timers, interrupts, and state machines. Once you see the pattern, every MCU — from a $0.10 Padauk to a $20 STM32H7 — works the same way.

"What I cannot create, I do not understand." — Richard Feynman