ALearning Material
A microcontroller (MCU) is a whole computer on one chip: CPU + memory + peripherals, designed to control hardware. Unlike a PC (which boots an OS and runs apps), an MCU usually runs one program directly "on the bare metal."
A microcontroller is a whole computer squeezed onto one chip (CPU, memory, and peripherals) built not to run apps but to control hardware. Unlike a PC that boots an operating system, an MCU usually runs a single program directly 'on the bare metal', which means you are much closer to the silicon and responsible for things a desktop hides from you.
The central idea that unlocks everything is memory-mapped peripherals: you control hardware by reading and writing special memory addresses called registers. Set a particular bit and a pin goes high; set another and a timer starts. The datasheet is the map from peripheral to address. GPIO is the simplest example, and even it teaches a fundamental gotcha. An input pin left unconnected floats and reads random noise, so it needs a pull-up or pull-down to give it a defined level. Registers, bits, and defined logic levels are the vocabulary of every peripheral that follows.
What's inside:
- CPU core (e.g. ARM Cortex-M): executes your instructions.
- Flash: non-volatile program storage (your code lives here).
- RAM: volatile working memory (variables); small, often KB.
- Peripherals: hardware blocks: GPIO, timers, ADC, UART/SPI/I²C, PWM.
Memory-mapped peripherals: the central idea. You control hardware by reading and writing special memory addresses called registers. Setting a bit in a register can switch a pin, start a timer, or send a byte. The MCU's datasheet maps every peripheral to addresses.
GPIO (General-Purpose Input/Output) is the simplest peripheral: pins you can set HIGH/LOW (output) or read as HIGH/LOW (input). Each GPIO pin needs configuring:
- Direction: input or output.
- For inputs: often a pull-up or pull-down resistor so a disconnected pin reads a defined level instead of "floating" (random noise). A button to GND with a pull-up reads HIGH when released, LOW when pressed.
Two ways to write the same blink (Arduino-style vs register-level):
// High-level (Arduino) - readable, portable
pinMode(13, OUTPUT);
digitalWrite(13, HIGH); // LED on
// Register-level (what's really happening on an AVR/STM32-like MCU)
DDRB |= (1 << 5); // set pin 5 of port B as output (Direction Register)
PORTB |= (1 << 5); // drive it HIGH (Port Output Register)
PORTB &= ~(1 << 5); // drive it LOW
The |= (1 << n) / &= ~(1 << n) idiom (bit manipulation) sets/clears a single
bit without disturbing the others, essential embedded vocabulary.
Syntax you need here. Two ways to reach a pin, and you need both: the portable one to get working, and the register one because that is what the datasheet documents and what a debugger shows you.
// THE PORTABLE FORM (Arduino and most vendor HALs offer an equivalent)
pinMode(pin, OUTPUT); // OUTPUT, INPUT, INPUT_PULLUP
digitalWrite(pin, HIGH); // HIGH or LOW
int v = digitalRead(pin); // HIGH or LOW
analogRead(pin); // an ADC channel, not a digital pin
// THE REGISTER FORM: a peripheral register is a variable at a fixed address
#define GPIOA_MODER (*(volatile uint32_t *)0x40020000) // volatile: never cache it
// THE FOUR BIT OPERATIONS, and they are the whole vocabulary
reg |= (1u << n); // SET bit n, leaving the others alone
reg &= ~(1u << n); // CLEAR bit n
reg ^= (1u << n); // TOGGLE bit n
if (reg & (1u << n)) { } // TEST bit n
// a multi-bit FIELD: clear the field first, then write it. Two steps, always.
reg &= ~(0x3u << (2*n)); // clear the 2-bit field for pin n
reg |= (0x1u << (2*n)); // then write the new value into it
volatile is not decoration. It tells the compiler that this location can change without any code
in your program writing to it, so it must be re-read every time rather than kept in a register. Omit
it and a loop polling an input pin is optimised into a loop that reads once and spins on the stale
value forever, which compiles cleanly and is nearly impossible to see in the source.
Read-modify-write rather than assignment is the other habit. Writing reg = (1u << n) sets your bit
and clears every other bit in that register, which is how one pin's configuration silently
deconfigures the pin next to it. |= and &= ~ touch only what you named.
Why it exists. A microcontroller is the brain inside almost every robot, tool, and appliance. A whole computer on one chip that runs your code directly on the bare metal. The first mental shift is from "a program" to "flipping hardware bits at memory addresses."
Mental model. Registers are a wall of labelled light switches wired to the hardware. Flipping switch #5 in the "direction" panel makes a pin an output; flipping #5 in the "output" panel turns the actual pin on. Your code just flips switches.
Common misunderstandings.
- "
PORTB = (1<<5)is fine to set one pin." Plain=overwrites all the bits; use|=/&= ~to change one bit without disturbing the others. - "An unconnected input pin reads 0." It floats and reads random noise. Give it a pull-up or pull-down for a defined level.
- "The MCU runs an operating system like a PC." Usually not. It runs one bare-metal program directly; your code is the system.
Connections. Memory-mapped registers and bit manipulation are the foundation for interrupts and timers (next lesson), the Turn-2 STM32-peripherals lesson, and the button+UART project. Pull-ups reappear on the I2C bus (Turn 2) and the PCB project, and the fixed-rate loop from Python Turn 1 becomes a timer interrupt here.
BImmediate Active Recall
QUERYHow does a microcontroller control its hardware peripherals?
REVEAL
Through memory-mapped registers: special addresses where writing/reading bits configures and operates peripherals (GPIO, timers, etc.).
QUERYWhat does PORTB |= (1 << 5) do, and why use |= instead of =?
REVEAL
PORTB |= (1 << 5) do, and why use |= instead of =?Sets bit 5 of PORTB to 1 (drives that pin high). |= changes only that bit, leaving the other pins untouched; = would overwrite all bits.
QUERYWhy does a digital input pin need a pull-up or pull-down resistor?
REVEAL
An unconnected ("floating") input picks up noise and reads random HIGH/LOW. A pull resistor ties it to a defined level so it's stable when nothing drives it.
QUERYDifference between Flash and RAM on an MCU?
REVEAL
Flash is non-volatile program storage (your code persists without power); RAM is volatile working memory for variables (lost on power-off), and is usually much smaller.
CConceptual Questions
Answer each in your own words in the box, then reveal the model answer to compare. These ask why, not how, and your answers are saved.
What does 'memory-mapped peripherals' mean, and why is it the central idea of microcontroller programming?
REVEAL MODEL ANSWER
Hardware blocks (GPIO, timers, UART) are controlled by reading and writing special memory addresses called registers. Setting a bit in a register can flip a pin, start a timer, or send a byte, and the datasheet maps every peripheral to its addresses. So controlling hardware reduces to writing the right bits to the right memory locations. That single mechanism underlies every peripheral you'll ever use.
Why does a floating (unconnected) input pin need a pull-up or pull-down resistor?
REVEAL MODEL ANSWER
An undriven CMOS input is high-impedance and picks up electrical noise, so it reads a random, fluctuating HIGH/LOW, 'floating'. A pull-up or pull-down resistor gently ties the pin to a defined level when nothing else drives it. For example, a button to ground with a pull-up reads HIGH when released and LOW when pressed, a deterministic, noise-immune reading.
Comparing Arduino's digitalWrite to the register-level DDRB/PORTB version, what does the high-level call hide?
REVEAL MODEL ANSWER
It hides the direct register writes: configuring the data-direction register (DDR) to make the pin an output, then setting or clearing the corresponding bit in the port output register to drive it HIGH or LOW. The Arduino call is readable and portable, but underneath it's just bit manipulation of memory-mapped registers, which is what's really happening on the chip.
DPractice Problems
P1 (easy). Write the bit operation to clear bit 3 of register PORTC without
affecting other bits.
P2 (medium). A button wired between a pin and GND reads random values when not pressed. What's missing, and which level will the pin read when the button is pressed?
P3 (harder). Work out the bit patterns and show what each write actually leaves in the register.
Starting from PORTB = 0b10100011 (pins 0, 1, 5 and 7 driven high by other parts of the program), compute the resulting register for PORTB = (1 << 5), PORTB |= (1 << 5), PORTB &= ~(1 << 5) and PORTB ^= (1 << 5). Produce the table in binary. Then write the four idioms as named macros, and state the one hazard read-modify-write has that a plain write does not, with the fix.
Solutionsclick to reveal
P1. PORTC &= ~(1 << 3);. (1<<3) is 0b1000; ~ inverts it to ...11110111;
ANDing forces only bit 3 to 0.
P2. A pull-up resistor (enable the internal pull-up, or add an external one to VCC). With a pull-up, the released button reads HIGH; pressing it connects the pin to GND, so it reads LOW (active-low input).
P3. Starting state.
PORTB = 0b10100011 pins 7, 5, 1, 0 high; pins 6, 4, 3, 2 low
||||||||
76543210
The four writes.
| Operation | Result | What happened |
|---|---|---|
PORTB = (1 << 5) |
0b00100000 |
pins 7, 1 and 0 were cleared: the whole register was overwritten |
PORTB \|= (1 << 5) |
0b10100011 |
bit 5 set (it already was); everything else preserved |
PORTB &= ~(1 << 5) |
0b10000011 |
bit 5 cleared; everything else preserved |
PORTB ^= (1 << 5) |
0b10000011 |
bit 5 toggled, so it went high to low; everything else preserved |
The first row is the bug. PORTB = (1 << 5) is a write, not a modification: it assigns 0b00100000 to the entire port, so every other output on port B is driven low in the same instruction.
What that means physically: if pin 0 was an enable line, the driver is now disabled; if pin 1 was a chip select, an SPI transaction is aborted mid-word; if pin 7 was a relay, the relay drops out. Three unrelated subsystems fail because one LED was turned on, and nothing in the source mentions them.
And it is intermittent in the worst way. The program works while nothing else uses port B and breaks when a colleague adds a pin, in a function neither of you is looking at.
The four idioms, named.
#define BIT(n) (1u << (n))
#define SET_BIT(reg, n) ((reg) |= BIT(n)) // drive high, leave others alone
#define CLR_BIT(reg, n) ((reg) &= ~BIT(n)) // drive low, leave others alone
#define TGL_BIT(reg, n) ((reg) ^= BIT(n)) // invert, leave others alone
#define GET_BIT(reg, n) (((reg) >> (n)) & 1u) // read one bit
SET_BIT(PORTB, 5); // instead of PORTB |= (1 << 5)
CLR_BIT(PORTB, 5);
~BIT(n) is the part that is easy to get wrong. &= ~(1 << 5) clears bit 5 by ANDing with 0b11011111; writing &= (1 << 5) instead clears everything except bit 5, which is the same class of error as the first row.
The hazard read-modify-write has that a plain write does not: it is not atomic.
PORTB |= BIT(5) compiles to three instructions on most architectures: load the register, OR the bit, store it back. If an interrupt fires between the load and the store and that ISR also writes to PORTB, the ISR's change is overwritten by the store that was already in flight.
main: load PORTB -> 0b10100011
<-- INTERRUPT: the ISR does SET_BIT(PORTB, 3), PORTB = 0b10101011
main: OR bit 5 -> 0b10100011
main: store -> 0b10100011 the ISR's bit 3 is GONE
A lost pin change, at random, once every few thousand interrupts. It is untraceable from the source, because both pieces of code are individually correct.
The three fixes, in order of preference.
Use the hardware's atomic bit-access if it has one. ARM Cortex-M3 and M4 provide bit-banding, a memory region where each word maps to one bit, so a single store changes one bit atomically. STM32 also has BSRR, a set/reset register where writing a bit sets a pin and no read is involved at all:
GPIOB->BSRR = (1u << 5); // atomic set, no read-modify-write
GPIOB->BSRR = (1u << (5 + 16)); // atomic clear, no read-modify-write
Disable interrupts around the sequence, if no atomic access exists:
uint8_t s = SREG; cli();
PORTB |= BIT(5);
SREG = s; // restore, do not blindly sei()
Saving and restoring the flag matters: calling sei() at the end re-enables interrupts even if the caller had deliberately disabled them.
Or partition the pins, so that one port is written by the ISR and another by main, and the conflict cannot arise. That is the cheapest fix of all when the board layout is still yours to choose.
EFeynman Exercise
Explain to a beginner what a microcontroller register is, using the "wall of labelled
light switches" analogy. Then explain why we flip just one switch (|=/&=)
instead of resetting the whole panel, and what "a floating input" means using the
idea of a switch wired to nothing.
REVEAL MODEL ANSWER
Think of a microcontroller's registers as a wall of labelled light switches, each at a fixed address. Flipping the switch at one address turns on a pin; flipping another starts a timer. Programming the chip is really just walking up to the right switch (memory address) and toggling the right toggle (bit). And a switch with nothing wired to it doesn't sit reliably up or down. It flutters in the breeze (a floating input), which is why you tie it down with a little resistor so it has a definite resting position.
FError Analysis Framework
- Overwriting a whole register. Why: using
=not|=/&=. Recognise: unrelated pins change. Avoid: read-modify-write single bits. - Floating inputs. Why: no pull resistor. Recognise: random reads. Avoid: enable pull-up/down; know active-high vs active-low.
- Wrong direction register. Why: setting output value before direction. Recognise: pin won't drive. Avoid: configure direction first, then value.
- Bit-position off-by-one. Why: counting pins from 1 not 0. Recognise: wrong pin toggles. Avoid: check datasheet bit numbering (usually 0-based).
GMini Challenge
A button wired from an MCU pin to ground reads erratically. Sometimes 'pressed' when it isn't. Name the cause and the one-component fix, and state what level the pin reads when pressed versus released once it's fixed.
REVEAL MODEL ANSWER
The cause is a floating input: with the button open, nothing drives the pin, so it picks up noise and reads randomly. The fix is a pull-up resistor (often the MCU's built-in internal pull-up). With a pull-up and the button to ground, the pin reads HIGH when released (pulled up to VCC) and LOW when pressed (the button shorts it to ground), a clean, deterministic reading.
Quiz Check
A quick auto-graded check, separate from the recall cards above. Your score is pooled with the recall cards into this module's Mastery score, and completing this lesson requires the quiz submitted with pooled mastery at 80% or above.