Hardware-Oriented Embedded C
Microcontroller Architecture and Memory, Bit Manipulation and Register Programming, GPIO β General-Purpose Input/Output, Timers, Counters and PWM

#Microcontroller Architecture and Memory#
Notes on what is inside a microcontroller, how its memory is organized, and how your C code actually gets from Flash to running on the chip.
Table of Contents
- CPU, ALU, Registers and Program Counter
- General-Purpose Registers and Special-Function Registers (SFRs)
- Flash, ROM, SRAM, EEPROM and NVRAM
- Memory Maps and Address Spaces
- Data Memory vs Program Memory
- Stack Pointer and Stack Frames
- Interrupt Vector Table
- Memory-Mapped I/O vs Port-Mapped I/O
- Register Access and Peripheral Registers
- Linker Scripts, Sections and Startup Code
- Bootloaders and Reset Sequences
- Quick Revision Cheat Sheet
The Big Picture
A microcontroller (MCU) is a tiny computer on one chip. Everything your C code does ends up as the CPU reading instructions from memory, moving data through registers, and reading or writing peripheral registers.
+-------------------------------------------------+
| MICROCONTROLLER |
| |
| +--------------------+ |
| | CPU | |
| | ALU | Registers | PC | |
| +---------+----------+ |
| | buses (address / data / control) |
| +---------+---------+----------+--------+ |
| | | | | | |
| +-v--+ +--v--+ +--v---+ +--v---+ +--v--+ |
| |Flash| |SRAM | |EEPROM| |Periph| | IRQ | |
| |code | |data | |config| |GPIO, | | ctrl| |
| | | |stack| | | |UART, | | | |
| +-----+ +-----+ +------+ |Timer | +-----+ |
| +------+ |
+-------------------------------------------------+
1. CPU, ALU, Registers and Program Counter
The CPU (Central Processing Unit)
The CPU is the brain of the MCU. Its job is a repeating cycle: fetch an instruction from memory, decode it, execute it, and move on to the next one.
Main parts of the CPU
| Part | What it does |
|---|---|
| ALU (Arithmetic Logic Unit) | Performs math (add, subtract, compare) and logic (AND, OR, XOR, shifts) |
| Registers | Very small, very fast storage inside the CPU |
| Program Counter (PC) | Holds the address of the next instruction to run |
| Control unit | Decodes instructions and directs everything else |
| Instruction register (IR) | Holds the instruction currently being decoded |
| Status / flag register | Records results of operations (zero, carry, negative, overflow) |
| Stack pointer (SP) | Points to the top of the stack (see section 6) |
| Buses | Wires carrying addresses, data, and control signals |
ALU (Arithmetic Logic Unit)
The ALU is the part that actually does calculations.
- Arithmetic: add, subtract, increment, decrement, sometimes multiply (and divide on bigger cores).
- Logic: AND, OR, XOR, NOT.
- Shifts and rotates.
- Comparisons: done by subtracting and checking flags.
After each operation, the ALU updates status flags:
| Flag | Meaning | Set when... |
|---|---|---|
| Z (Zero) | Result is zero | 5 - 5 |
| C (Carry) | Carry out of (or borrow into) the top bit | Unsigned overflow |
| N (Negative) | Result's top bit is 1 | Negative signed result |
| V (Overflow) | Signed overflow | 127 + 1 in 8-bit signed |
Conditional branches (if, loops) use these flags. A C statement like if (a == b) becomes a compare (a subtraction) followed by a branch on the Z flag.
Registers
Registers are small storage locations inside the CPU. They are the fastest memory in the system (no bus access needed). The ALU works directly on registers: values are loaded from memory into registers, processed, and stored back.
Register width matches the CPU word size: 8-bit (AVR, 8051), 16-bit (MSP430), or 32-bit (ARM Cortex-M). A 32-bit CPU has 32-bit registers.
Program Counter (PC)
The Program Counter holds the address of the next instruction to be fetched.
How it works
- CPU fetches the instruction at the address in PC.
- PC is automatically advanced to the next instruction.
- CPU executes the fetched instruction.
- Repeat.
What changes the PC (other than normal advance)
- Jumps and branches (
if,while,for,goto). - Function calls: PC is saved (on the stack or in a link register) and replaced with the function's address.
- Returns: the saved address is put back into PC.
- Interrupts: PC jumps to the interrupt handler, then restores afterwards.
- Reset: PC loads the reset vector (see sections 7 and 11).
The fetch-decode-execute cycle
+-----------+ +-----------+ +-----------+
| FETCH | --> | DECODE | --> | EXECUTE | --+
| read instr| | figure out| | ALU / mem | |
| at PC | | what to do| | access | |
+-----------+ +-----------+ +-----------+ |
^ |
+------------- PC updated ---------------------+
Pipelining (brief)
Modern MCUs (like ARM Cortex-M) overlap these stages: while one instruction executes, the next is decoded and the one after is fetched. This increases speed, and it's why branches (which invalidate the prefetched instructions) can cost extra cycles.
CISC vs RISC (quick note)
- RISC (ARM, AVR, RISC-V): simple, fixed-size instructions, many registers, load/store architecture (the ALU works only on registers).
- CISC (8051, x86): more complex instructions, some operating directly on memory.
2. General-Purpose Registers and Special-Function Registers (SFRs)
MCUs have two broad families of registers that people often mix up.
General-Purpose Registers (GPRs)
Registers the programmer/compiler can use freely to hold temporary data, operands, results, and addresses.
Examples
| Architecture | General-purpose registers |
|---|---|
| ARM Cortex-M | R0 to R12 (13 registers, 32-bit); R0-R7 "low", R8-R12 "high" |
| AVR (ATmega) | R0 to R31 (32 registers, 8-bit) |
| 8051 | 4 register banks of R0 to R7 (8-bit each); accumulator A and register B |
| PIC (8-bit) | Working register W and file registers |
Typical roles (compiler conventions)
- Argument and return registers: pass function parameters and return values (ARM: R0-R3).
- Scratch/caller-saved registers: a function may freely overwrite them.
- Callee-saved registers: a function must preserve them (save and restore if used).
- Pointer registers: hold addresses (AVR: X, Y, Z register pairs).
- Accumulator: a special-purpose register used implicitly by many operations (8051
A).
The set of rules for which registers do what is part of the ABI (Application Binary Interface), such as ARM's AAPCS.
Core special-purpose CPU registers
These sit inside the CPU, have a fixed meaning, and are partly reachable by instructions.
| Register | Purpose |
|---|---|
| PC | Address of the next instruction (ARM: R15) |
| SP | Stack pointer (ARM: R13; Cortex-M has MSP and PSP) |
| LR | Link register: holds the return address after a call (ARM: R14) |
| Status register | Flags (ARM: xPSR; AVR: SREG; 8051: PSW) |
Special-Function Registers (SFRs)
SFRs (also called peripheral registers or I/O registers) are the registers used to control and monitor the MCU's peripherals and features. They are not for temporary data. Each bit or group of bits has a defined hardware function.
Examples of what SFRs control
| Peripheral | Typical SFRs |
|---|---|
| GPIO | Direction register (input/output), output data register, input data register |
| Timer | Control register, counter value, compare/capture value, prescaler |
| UART | Baud rate register, control/status register, data register |
| ADC | Control register, channel select, result register |
| Interrupt controller | Enable register, pending flags, priority registers |
| Clock control | Clock source select, PLL settings, peripheral clock enables |
| Power control | Sleep mode configuration |
Types of bits inside an SFR
| Bit type | Meaning |
|---|---|
| Control bits | You write them to configure hardware (enable, mode) |
| Status bits | Hardware writes them to report state (busy, ready); usually read-only |
| Flag bits | Set by hardware on an event; often cleared by writing 1 (or 0), or by reading |
| Data registers | Hold data going in or out (UART data, ADC result) |
| Reserved bits | Don't touch (or write the documented value); may need to be preserved |
Register access attributes (found in datasheets)
| Notation | Meaning |
|---|---|
| R | Read only |
| W | Write only |
| RW | Read and write |
| W1C / RC_W1 | Write 1 to clear a flag |
| RC_R | Cleared when read |
| Reserved | Keep at the reset value |
Reading a data register can have side effects (for example, reading the UART receive register may clear the "data received" flag). That's one reason the
volatilekeyword is vital.
GPR vs SFR
| General-Purpose Register | Special-Function Register | |
|---|---|---|
| Location | Inside the CPU | In the peripheral/IO space (or mapped into memory) |
| Purpose | Temporary data and calculations | Control and status of hardware |
| Used by | Compiler/ALU automatically | Your code (via named C symbols) |
| Meaning of bits | None (any value) | Each bit has a hardware function |
| Access in C | Not directly (compiler handles it) | Directly, via volatile pointers or vendor headers |
| Side effects | No | Often yes |
Quick example (8051 style)
P1 = 0xFF; // P1 is an SFR for Port 1
TMOD = 0x01; // timer mode SFR
TR0 = 1; // a single bit in the TCON SFR: start Timer 0
Vendor headers (like reg51.h, avr/io.h, stm32f4xx.h) define these names for you.
3. Flash, ROM, SRAM, EEPROM and NVRAM
MCUs contain several kinds of memory, each with a different job. The key difference is volatile vs non-volatile.
- Volatile memory: loses its contents when power is removed.
- Non-volatile memory (NVM): keeps its contents without power.
Summary table
| Memory | Volatile? | Writable at runtime? | Typical use | Speed | Typical size on MCU |
|---|---|---|---|---|---|
| ROM | No | No (fixed at manufacture) | Boot code, fixed firmware | Fast read | KBs |
| Flash | No | Yes, but slow, block erase, limited cycles | Program code, constants | Fast read, slow write | 16 KB to several MB |
| SRAM | Yes | Yes (fast) | Variables, stack, heap | Very fast | 1 KB to a few hundred KB |
| EEPROM | No | Yes, byte-wise, slow | Small settings, calibration | Slow write | 128 B to a few KB |
| NVRAM | No (retains) | Yes | Data that must survive power loss | Varies | Varies |
ROM (Read-Only Memory)
- Contents fixed at manufacturing (mask ROM) and cannot be changed.
- Variants: PROM (programmable once), EPROM (erase with UV light), EEPROM, and Flash (modern electrically erasable successors).
- On modern MCUs, a small factory ROM often holds a built-in bootloader (system memory) that can program the Flash over UART/USB.
- Very cheap and reliable for high-volume products.
Flash memory
The main storage for your program (firmware) and constant data.
Properties
- Non-volatile: firmware survives power-off.
- Electrically erasable and reprogrammable (in-system programming).
- Erase is by sector/page/block, not by single byte. You erase a whole sector (setting all bits to 1), then program bits down to 0. You can't change a 0 back to 1 without erasing the sector.
- Write/erase is slow (milliseconds) compared to reads.
- Limited endurance: typically 10,000 to 100,000 erase cycles per sector.
- Read is fast and can execute code directly from Flash (XIP, execute in place).
- Often has wait states at high CPU clocks (the CPU must wait for Flash), mitigated with caches or prefetch buffers ("ART accelerator" on STM32).
- While Flash is being erased or programmed, the CPU may stall if code is running from the same bank.
Two common types
| NOR Flash | NAND Flash | |
|---|---|---|
| Access | Random access, byte-addressable reads | Page-based |
| Use | Code storage, execute in place (MCU internal Flash) | Bulk data storage (SD cards, SSDs) |
| Cost per bit | Higher | Lower |
SRAM (Static RAM)
Fast, volatile working memory.
- Holds variables, the stack, and the heap while the program runs.
- Static means it doesn't need refreshing (unlike DRAM) as long as power is applied.
- Very fast (zero or single wait states), no wear-out.
- Contents are lost at power-off, and are undefined at power-up.
- More expensive per bit than Flash, so MCUs have little of it.
- Some MCUs retain SRAM in low-power modes (retention SRAM) or have battery-backed SRAM.
- Often split into banks (some may be special, such as CCM, "core coupled memory", fast and not reachable by DMA).
EEPROM (Electrically Erasable Programmable ROM)
Small non-volatile memory with byte-level erase and write.
- Store configuration, calibration values, counters, serial numbers, user settings.
- Can be written a byte at a time (unlike Flash sectors).
- Slow writes (about 3 to 10 ms per byte) and limited endurance (typically 100,000 to 1,000,000 cycles).
- Writing often requires a specific sequence (unlock/enable) to prevent accidental writes.
- Many newer MCUs don't have true EEPROM, instead emulating it in Flash with wear-leveling.
- External EEPROM chips connect through I2C or SPI.
Wear-leveling tip: don't write the same address on every loop pass. Write only when a value changes, and spread writes over multiple locations.
NVRAM (Non-Volatile RAM)
A general term for memory that behaves like RAM (fast, easy to write) but keeps data when power is lost.
Common implementations
| Type | How it retains data |
|---|---|
| Battery-backed SRAM | SRAM kept alive by a coin cell/backup supply (also backup registers and RTC RAM on many MCUs) |
| FRAM / FeRAM (ferroelectric RAM) | Truly non-volatile, very fast writes, huge endurance (10^12+ cycles), low power (e.g., TI MSP430FR) |
| MRAM (magnetoresistive) | Non-volatile, fast, high endurance |
| nvSRAM | SRAM combined with a shadow EEPROM that stores data on power loss |
Used for logging, counters, data that changes frequently but must survive resets or power-down.
Where does each part of your C program live?
| Program part | Memory used |
|---|---|
Code (.text) | Flash |
const data, string literals (.rodata) | Flash |
Initial values of initialized globals (.data source) | Flash (copied to RAM at startup) |
Initialized globals/statics (.data) | SRAM |
Zero-initialized globals/statics (.bss) | SRAM |
| Stack and heap | SRAM |
| Persistent settings | EEPROM / emulated EEPROM / Flash page |
| Hardware control | Peripheral registers (not memory in the usual sense) |
Flash vs EEPROM vs SRAM (practical view)
| Question | Answer |
|---|---|
| Where do I keep my firmware? | Flash |
| Where do runtime variables go? | SRAM |
| Where do I keep a calibration value that survives reset? | EEPROM (or Flash page) |
| Where do I keep fast-changing data that must survive power loss? | FRAM / battery-backed RAM |
| What's fastest to write? | SRAM (and FRAM) |
| What has the most limited write endurance? | Flash |
4. Memory Maps and Address Spaces
Address space
The address space is the range of addresses the CPU can generate. An N-bit address bus can address 2^N locations.
| Address width | Addressable space |
|---|---|
| 16-bit | 64 KB |
| 20-bit | 1 MB |
| 32-bit | 4 GB (0x0000_0000 to 0xFFFF_FFFF) |
Having a big address space doesn't mean the chip has that much memory. Most of it is empty/unused, and accessing unmapped addresses usually causes a fault.
Memory map
A memory map shows which address ranges belong to which memory or peripheral. It's in the MCU's datasheet and reference manual. It tells you where Flash, SRAM, and peripheral registers sit.
Example: a typical ARM Cortex-M memory map (generic layout)
0xFFFF_FFFF +---------------------------------+
| System (private peripheral bus,|
| NVIC, SysTick, debug) | 0xE000_0000
0xE000_0000 +---------------------------------+
| External device | 0xA000_0000
0xA000_0000 +---------------------------------+
| External RAM | 0x6000_0000
0x6000_0000 +---------------------------------+
| Peripherals (GPIO, UART, ...) | 0x4000_0000
0x4000_0000 +---------------------------------+
| SRAM (data, stack, heap) | 0x2000_0000
0x2000_0000 +---------------------------------+
| Code (Flash, boot ROM, ...) | 0x0000_0000
0x0000_0000 +---------------------------------+
A concrete (STM32F4-style) example:
| Region | Start address | Contents |
|---|---|---|
| Flash | 0x0800_0000 (aliased at 0x0000_0000 at boot) | Your program |
| System memory | 0x1FFF_0000 | Built-in ROM bootloader |
| SRAM | 0x2000_0000 | Variables, stack, heap |
| Peripherals (APB/AHB) | 0x4000_0000 and up | GPIO, UART, timers, etc. |
| Core peripherals | 0xE000_0000 | NVIC, SysTick, debug |
(Exact addresses differ per chip. Always check the reference manual.)
Address spaces: one or several?
| Architecture | Address spaces |
|---|---|
| Von Neumann / unified (ARM Cortex-M, MSP430, RISC-V) | One address space for code, data, and peripherals (memory-mapped) |
| Harvard (AVR, 8051, PIC) | Separate spaces: program memory, data memory (SRAM), and sometimes EEPROM and I/O |
Example: AVR (ATmega328P) separate spaces
| Space | Size | Notes |
|---|---|---|
| Program (Flash) | 32 KB | Word-addressed, separate bus |
| Data (SRAM + registers + I/O) | 2 KB SRAM + 32 GPRs + I/O regs | Registers and I/O are mapped into the low data addresses |
| EEPROM | 1 KB | Separate address space, accessed through registers |
Example: 8051 spaces
Internal RAM (direct/indirect), SFR space (0x80-0xFF), external data memory (XDATA), and code memory (CODE).
Address aliasing and remapping
- The same physical memory may appear at more than one address (e.g., Flash visible at
0x0000_0000and at0x0800_0000). - Boot configuration (boot pins) decides what appears at address
0x0after reset (Flash, system ROM, or SRAM). - Bit-banding (some Cortex-M3/M4): lets you access single bits of SRAM and peripheral regions through alias addresses, making bit read-modify-write atomic.
Reserved and unmapped regions
- Accessing reserved or unmapped addresses may trigger a bus fault / hard fault, or return garbage.
- Writing to reserved register bits can have unpredictable results.
Memory protection (some MCUs)
MPU (Memory Protection Unit) lets you set per-region access rules (read-only, no-execute, privileged-only) to catch bugs and isolate tasks.
Why memory maps matter in C
- You write fixed addresses into code:
#define GPIOA_BASE 0x40020000UL. - The linker script uses the map to place
.textin Flash and.data/.bss/stackin SRAM. - Debugging faults requires knowing which address region was accessed.
5. Data Memory vs Program Memory
The concept
A CPU needs two kinds of storage:
- Program memory: holds instructions (your compiled code) and constants.
- Data memory: holds variables that change while the program runs.
How the chip separates or merges them depends on its architecture (Harvard vs Von Neumann).
Program memory
- Usually Flash (sometimes ROM).
- Non-volatile and read-mostly (writes are slow and limited).
- Holds: machine code,
constdata, string literals, interrupt vector table, initial values for variables. - Instructions are fetched from here.
- Typically larger than data memory.
Data memory
- Usually SRAM.
- Volatile, fast, freely readable and writable.
- Holds: global and static variables (
.data,.bss), the stack, the heap, buffers. - Typically small.
- Sometimes also includes register file and I/O registers mapped into the same space (AVR, 8051).
Comparison
| Feature | Program memory | Data memory |
|---|---|---|
| Technology | Flash / ROM | SRAM (and EEPROM for persistent data) |
| Volatility | Non-volatile | Volatile |
| Write at runtime | Rare and slow | Frequent and fast |
| Contents | Code, constants | Variables, stack, heap |
| Size | Larger (KB to MB) | Smaller (bytes to hundreds of KB) |
| Used for | Fetching instructions | Loads/stores of data |
Harvard architecture MCUs (separate buses and address spaces)
Examples: AVR, PIC, 8051.
- The CPU can fetch an instruction and access data at the same time.
- Program and data addresses are different spaces: address
0x0100in program memory is not the same as0x0100in data memory. - Consequence in C: a normal pointer can only point into data memory. Constants placed in Flash need special access.
AVR example: keeping a string in Flash to save RAM
#include <avr/pgmspace.h>
const char msg[] PROGMEM = "Hello, world"; // stored in Flash only
char c = pgm_read_byte(&msg[0]); // special instruction (LPM) reads Flash
Without PROGMEM, the string's initial copy is moved into the scarce SRAM at startup.
8051 example: keywords select the memory space:
unsigned char data d; // internal direct RAM
unsigned char xdata x; // external RAM
unsigned char code c[] = {1, 2, 3}; // program memory (read-only)
Modified Harvard / unified-address MCUs
Examples: ARM Cortex-M, RISC-V, MSP430.
- Internally have separate buses (instruction bus and data bus) for speed.
- But present one unified address space: Flash, SRAM, and peripherals share one map.
- A normal pointer can point to Flash or RAM, so
constarrays in Flash can be read with ordinary code. - Code can run from SRAM too (useful for Flash-programming routines or speed-critical code).
Von Neumann style
A single memory and bus for code and data (simple, but fetches and data accesses compete).
Practical implications for embedded C
| Topic | What to remember |
|---|---|
| RAM is precious | Put large constant tables in Flash using const (and PROGMEM/code on Harvard parts) |
| Pointer types | On Harvard chips, a pointer into Flash differs from one into RAM (special qualifiers or functions) |
| Startup copy | Initialized globals live in Flash and are copied into SRAM before main (section 10) |
| Execute from RAM | Possible on unified-address chips; place routines with special section attributes |
| Write to program memory | Only through Flash controller routines, not plain assignments |
| Linker script | Assigns .text/.rodata to program memory and .data/.bss to data memory |
6. Stack Pointer and Stack Frames
What is the stack?
The stack is a region of SRAM managed as a LIFO (Last In, First Out) structure. It stores temporary information for function calls.
Stack pointer (SP)
The stack pointer is a CPU register that holds the address of the current top of the stack.
Operations
- Push: store a value on the stack and move SP.
- Pop: read a value from the stack and move SP back.
Growth direction On most MCUs (ARM, x86, AVR, MSP430) the stack grows downward: it starts at a high address and SP decreases with each push.
High address +----------------+ <- stack base (initial SP value)
| (older data) |
| pushed data |
| pushed data |
SP --------> | top of stack |
| (free) |
| | |
| v | stack grows downward
Low address +----------------+
Types of stack (by SP convention)
| Type | Meaning |
|---|---|
| Full descending (ARM default) | SP points to the last item pushed; grows toward lower addresses |
| Empty descending | SP points to the next free slot |
| Ascending | Grows toward higher addresses (e.g., 8051) |
Initializing SP
At reset, SP must be set to a valid value. On ARM Cortex-M, the first word of the vector table is the initial SP value, loaded by hardware automatically. On AVR and others, the startup code sets SP to the top of RAM (RAMEND).
Stack frames
Each function call creates a stack frame (also called an activation record), a block of stack memory belonging to that call.
What a frame typically contains
| Item | Purpose |
|---|---|
| Return address | Where to resume in the caller (on ARM, often kept in LR and saved only when needed) |
| Function arguments | Those not passed in registers |
| Saved registers | Callee-saved registers the function will modify |
| Saved frame pointer | Link to the caller's frame (if used) |
| Local variables | Non-static variables declared in the function |
| Temporary values / alignment padding | Compiler needs |
Example
int square(int x) {
int result = x * x; // local variable lives in square's frame
return result;
}
int main(void) {
int a = 5; // local variable in main's frame
int b = square(a);
return 0;
}
Stack during the call to square:
High address
+----------------------+
| main's frame |
| a = 5 |
| b |
| (saved registers) |
+----------------------+
| square's frame |
| return address | <- tells CPU where to continue in main
| x = 5 |
| result = 25 |
+----------------------+ <- SP
Low address
When square returns, its frame is discarded by moving SP back up (the data isn't erased, it's just no longer protected).
Frame pointer (FP)
A register (e.g., R7 or R11 on ARM, BP on x86) that stays fixed during a function while SP can change. It lets the compiler address locals and parameters with fixed offsets. Many embedded builds omit it (-fomit-frame-pointer) to save a register.
Function call mechanics (generic)
- Caller places arguments (registers or stack).
- Call instruction saves the return address (stack or link register) and jumps.
- Callee allocates a frame (adjusts SP), saves registers it will use.
- Callee runs; locals live in the frame.
- Callee restores registers, frees the frame, puts the return value in a register.
- Return instruction loads PC from the saved return address.
Interrupts and the stack
On an interrupt, the CPU automatically pushes key registers (PC, status, and some GPRs; on Cortex-M: R0-R3, R12, LR, PC, xPSR) onto the stack, runs the ISR, then restores them. So interrupts consume stack space, and nested interrupts consume more.
Two stacks on Cortex-M
Cortex-M can use two stack pointers:
- MSP (Main Stack Pointer): used by the OS kernel, exceptions, and bare-metal code.
- PSP (Process Stack Pointer): used by tasks in an RTOS.
This isolates each task's stack and keeps the kernel's stack safe.
Stack size and stack overflow
- The stack is fixed size on an MCU (chosen in the linker script or startup code).
- Stack overflow: the stack grows past its limit and overwrites other data (often the heap or
.bss), causing mysterious crashes. - Typical causes
- Deep or infinite recursion.
- Large local arrays (
char buf[2048];in a function). - Nested interrupts plus deep call chains.
- Functions like
printfthat use big stack buffers.
- Typical memory layout (stack and heap grow toward each other)
High +----------------------+ <- _estack (initial SP)
| Stack | grows down
| v |
| |
| ^ |
| Heap | grows up
+----------------------+
| .bss (zeroed) |
| .data (initialized)|
Low +----------------------+ <- RAM start
Detecting and preventing overflow
- Stack painting: fill the stack with a known pattern (e.g.,
0xDEADBEEF) at startup and check how much remains unmodified (high-water mark). - Stack canaries and stack limit registers (Cortex-M33 has hardware stack limit checks).
- MPU guard region below the stack.
- Static analysis of worst-case stack usage; compiler option
-fstack-usage. - Avoid recursion, large locals, and big
printfusage. - Size stacks with a safety margin.
7. Interrupt Vector Table
What is an interrupt?
An interrupt is a signal that makes the CPU pause what it is doing, run a special function called an Interrupt Service Routine (ISR) (also called an interrupt handler), and then resume the original code. Sources include timers, UART data arrival, buttons (external pins), ADC completion, and faults.
What is the interrupt vector table (IVT)?
The interrupt vector table is a table in memory that maps each interrupt/exception source to the address of its handler. When an interrupt occurs, the hardware looks up the handler address in the table and jumps to it.
Interrupt occurs ---> CPU finishes (or aborts) current instruction
---> looks up entry N in the vector table
---> loads PC with that handler address
---> ISR runs
---> return from interrupt restores state
Contents: vectors
Each entry is a vector. Entry 0 or 1 is always the reset vector.
Layout on ARM Cortex-M
The table is an array of 32-bit words starting at a base address (default 0x0000_0000 or Flash start; relocatable with VTOR).
| Index | Offset | Vector |
|---|---|---|
| 0 | 0x00 | Initial stack pointer value (not a handler) |
| 1 | 0x04 | Reset handler (entry point after reset) |
| 2 | 0x08 | NMI (non-maskable interrupt) |
| 3 | 0x0C | HardFault |
| 4 | 0x10 | MemManage fault (Cortex-M3/M4) |
| 5 | 0x14 | BusFault |
| 6 | 0x18 | UsageFault |
| 11 | 0x2C | SVCall |
| 14 | 0x38 | PendSV |
| 15 | 0x3C | SysTick |
| 16+ | 0x40+ | External interrupts (IRQ0, IRQ1, ...) specific to the chip: timers, UART, SPI, ADC, GPIO, etc. |
// Example (startup file, GCC ARM style)
__attribute__((section(".isr_vector")))
void (* const vector_table[])(void) = {
(void (*)(void))(&_estack), // 0: initial stack pointer
Reset_Handler, // 1: reset
NMI_Handler, // 2
HardFault_Handler, // 3
/* ... */
SysTick_Handler, // 15
TIM2_IRQHandler, // 16 + IRQ number
USART1_IRQHandler,
/* ... */
};
Layout on AVR (ATmega)
- Table starts at address 0. Each entry is usually a jump instruction (
JMP/RJMP) to the handler (not just an address). - Entry 0 = RESET, followed by INT0, INT1, PCINT, timers, USART, ADC, etc.
- Position in the table also sets priority: lower address = higher priority.
#include <avr/interrupt.h>
ISR(TIMER1_COMPA_vect) { // compiler places a jump to this in the vector table
/* handle timer compare match */
}
Layout on 8051
Fixed vector addresses spaced 8 bytes apart (0x0003 for External 0, 0x000B for Timer 0, etc.), each holding a jump instruction.
void timer0_isr(void) interrupt 1 { /* ... */ }
Sequence on an interrupt (Cortex-M example)
- Peripheral raises an interrupt request; the NVIC (Nested Vectored Interrupt Controller) checks enable and priority.
- CPU stacks registers (R0-R3, R12, LR, PC, xPSR) automatically.
- CPU fetches the handler address from the vector table.
- ISR executes.
- On return (special EXC_RETURN value), registers are unstacked and execution resumes.
Latency (time from event to first ISR instruction) is short and deterministic (about 12 cycles on Cortex-M).
Interrupt control concepts
| Concept | Meaning |
|---|---|
| Enable/disable (mask) | Turn individual interrupts or all interrupts (global) on or off |
| Pending flag | Hardware records an interrupt request even if it can't run yet |
| Priority | Decides which interrupt runs first when several arrive |
| Nesting | A higher-priority interrupt can interrupt a running ISR |
| Maskable vs non-maskable (NMI) | NMI can't be disabled (used for critical events) |
| Clearing the flag | The ISR often must clear the interrupt flag, or it re-triggers forever |
| Interrupt latency | Delay between event and ISR start |
Relocating the vector table
- Bootloader + application: the application has its own vector table at a different Flash address. The bootloader or application sets VTOR (Cortex-M) to point to it.
- Table can be placed in SRAM to change handlers at runtime (copy and relocate).
ISR writing rules (summary)
- Keep ISRs short and fast; set flags and defer heavy work.
- Variables shared with main must be
volatileand protected against races. - Clear the interrupt source flag.
- Don't call blocking or non-reentrant functions (such as
printfandmalloc). - Make sure the handler name matches the vector table entry (otherwise the default handler, an infinite loop, runs).
Common problems
- Misspelled handler name leads to the default handler (hang).
- Missing flag clear leads to an interrupt storm.
- Vector table in the wrong place (wrong address or not relocated) leads to crashes.
- Stack too small for interrupt nesting.
8. Memory-Mapped I/O vs Port-Mapped I/O
Both are ways for the CPU to communicate with peripherals (GPIO, UART, timers, etc.).
Memory-Mapped I/O (MMIO)
Peripheral registers are placed in the normal memory address space. Reading or writing a specific address talks to the peripheral instead of RAM.
- Uses the same instructions as memory (
LDR/STR,mov, etc.). - In C, you simply use pointers.
- Used by ARM Cortex-M, RISC-V, MSP430, most modern MCUs, and also AVR and 8051 (their I/O registers sit in the data space).
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014UL)
GPIOA_ODR |= (1u << 5); // write to the peripheral like a variable
Address space
0x0000_0000 +-----------+
| Flash |
0x2000_0000 +-----------+
| SRAM |
0x4000_0000 +-----------+
| Peripheral| <-- reads/writes here go to hardware registers
| registers |
+-----------+
Port-Mapped I/O (PMIO) / isolated I/O
Peripherals have a separate I/O address space, accessed with special instructions (like IN and OUT on x86), plus a control signal telling the hardware "this is an I/O access, not memory".
- Separate address space for I/O (x86 has 65,536 ports).
- Needs dedicated instructions (
IN,OUT), which C doesn't have natively. Compilers provide intrinsics or you use inline assembly. - Used by x86 PCs (legacy devices), and some older/specialized processors.
- Some AVR instructions (
IN,OUT,SBI,CBI) access the low I/O registers directly (these are also mapped into the data space, so AVR is effectively both).
// x86 style, using inline assembly (GCC)
static inline void outb(uint16_t port, uint8_t val) {
__asm__ volatile ("outb %0, %1" : : "a"(val), "Nd"(port));
}
Comparison
| Feature | Memory-Mapped I/O | Port-Mapped I/O |
|---|---|---|
| Address space | Shared with memory | Separate I/O space |
| Instructions | Normal load/store | Special (IN, OUT) |
| Access from C | Plain pointers (with volatile) | Intrinsics / inline assembly |
| Address space used | Consumes part of the memory map | Doesn't consume memory addresses |
| Flexibility | Any instruction that works on memory (read-modify-write, bit ops, etc.) works on registers | Limited to the I/O instructions |
| Hardware complexity | Simpler (no extra control line) | Needs extra I/O signal |
| Protection (MPU) | Can protect registers with the MPU | Separate I/O permissions |
| Typical processors | ARM, RISC-V, MSP430, most MCUs | x86 (classic) |
| Caching concerns | Must mark registers non-cacheable | Not applicable |
Important MMIO concerns in C
- Always use
volatileso the compiler doesn't optimize away or reorder accesses. - Access width matters: a 32-bit register should be accessed with 32-bit reads/writes (not byte-wise).
- Reads can have side effects (clearing flags).
- Write ordering: on systems with write buffers or caches, you may need memory barriers so writes reach the device in order.
- Alignment rules apply.
- Don't use
memcpyor standard functions on registers carelessly (they may use different widths or orders).
Related: DMA
Peripherals and DMA controllers also appear in the memory map, and DMA moves data between memory and peripherals without CPU involvement.
9. Register Access and Peripheral Registers
Peripheral registers
A peripheral (GPIO, UART, timer, ADC, SPI, I2C...) is controlled through a block of registers at a fixed base address. Each register sits at an offset from the base.
GPIOA base address: 0x4002_0000
+0x00 MODER (mode: input/output/alternate/analog)
+0x04 OTYPER (output type)
+0x08 OSPEEDR (output speed)
+0x0C PUPDR (pull-up / pull-down)
+0x10 IDR (input data)
+0x14 ODR (output data)
+0x18 BSRR (bit set/reset)
Register address = base address + offset.
Typical register roles in any peripheral
| Register type | Role |
|---|---|
| Control (CR / CTRL) | Enable the peripheral, choose modes |
| Configuration (CFG / MODE) | Detailed settings (baud rate, prescaler, resolution) |
| Status (SR / STAT) | Flags: busy, ready, error, data available |
| Data (DR) | Data to send or received data |
| Interrupt enable (IER) / flag (ISR) / clear (ICR) | Interrupt control |
| Direction (DDR / MODER) | Pin input/output |
Method 1: Raw pointer macros
#define GPIOA_BASE 0x40020000UL
#define GPIOA_MODER (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_ODR (*(volatile uint32_t *)(GPIOA_BASE + 0x14))
GPIOA_MODER |= (1u << 10); // set PA5 as output
GPIOA_ODR ^= (1u << 5); // toggle PA5
How the macro works
0x40020014ULis an integer.(volatile uint32_t *)casts it to a pointer to a volatile 32-bit value.- The leading
*dereferences it, so the macro behaves like a variable at that address.
Method 2: Structure overlay (preferred, how vendor headers do it)
typedef struct {
volatile uint32_t MODER; // offset 0x00
volatile uint32_t OTYPER; // 0x04
volatile uint32_t OSPEEDR; // 0x08
volatile uint32_t PUPDR; // 0x0C
volatile uint32_t IDR; // 0x10
volatile uint32_t ODR; // 0x14
volatile uint32_t BSRR; // 0x18
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *)0x40020000UL)
GPIOA->MODER |= (1u << 10);
GPIOA->ODR ^= (1u << 5);
- The struct members line up with the register offsets (check for padding; use
_Static_assertonoffsetof). - Members are
volatile, so each access is real. - Reserved gaps are represented with padding members (
uint32_t RESERVED0[2];). - Add
constfor read-only registers (volatile const uint32_t IDR;). - Standards like CMSIS (ARM) provide such headers for each chip family.
Method 3: AVR / 8051 style (named registers from vendor headers)
DDRB |= (1 << PB5); // data direction register: PB5 output
PORTB |= (1 << PB5); // set pin high
Headers define DDRB, PORTB as volatile memory-mapped bytes.
Method 4: Using a HAL or driver library
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
Simpler and portable, but adds overhead and hides details.
Bit manipulation on registers (from earlier topics)
REG |= (1u << n); // set bit
REG &= ~(1u << n); // clear bit
REG ^= (1u << n); // toggle
if (REG & (1u << n)) { } // test
// Multi-bit field
REG = (REG & ~(0x3u << 4)) | (value << 4);
Important concerns when accessing registers
| Concern | Detail |
|---|---|
volatile | Mandatory; otherwise the compiler may remove or reorder accesses |
| Read-modify-write hazards | `REG |
| Atomic set/clear registers | Many peripherals offer BSRR, SET/CLR/TOGGLE registers (write 1 to the bit to act), so a single write changes only chosen bits |
| Write-1-to-clear flags | Writing `REG |
| Read side effects | Reading may clear flags or pop FIFO data; don't read registers "just to check" |
| Access width | Use the width the hardware expects (8, 16, 32 bits) |
| Reserved bits | Preserve or write as documented |
| Clock enable | A peripheral's registers often don't respond until its clock is enabled in the clock control (RCC) registers |
| Write order and timing | Some peripherals need steps in a strict sequence (unlock key, enable, wait for ready flag) |
| Waiting for flags | Poll status bits (with a timeout) or use interrupts |
| Memory barriers | May be required after writes (e.g., to enable a clock or change vector table) |
| Endianness and alignment | Follow the datasheet |
Typical initialization pattern (generic peripheral)
// 1. Enable the peripheral's clock
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
// 2. Configure its mode/settings
GPIOA->MODER &= ~(0x3u << (5 * 2));
GPIOA->MODER |= (0x1u << (5 * 2)); // output
// 3. (Optional) enable interrupts
// 4. Use it
GPIOA->BSRR = (1u << 5); // atomic set (no read-modify-write)
// 5. Wait on status flags when needed
while (!(USART1->SR & USART_SR_TXE)) { } // wait for transmit buffer empty
USART1->DR = 'A';
Reading the datasheet/reference manual
For each register, look at: address/offset, reset value, access type, bit-field table (name, position, width, description). Reset values tell you the state right after reset.
Good practices
- Use vendor/CMSIS headers with named bits and masks (
USART_SR_TXE). - Name your masks and positions rather than using magic numbers.
- Use set/clear registers when available.
- Add timeouts to polling loops.
- Keep hardware access in a driver layer, not scattered in the application.
10. Linker Scripts, Sections and Startup Code
How the pieces fit together
Source (.c) --compile--> object files (.o) --LINK (with linker script)--> firmware.elf
|
Flash image <------------------------- objcopy ------------------------------+
|
On reset: startup code runs --> copies .data, zeros .bss, sets up --> main()
Sections
The compiler and linker group code and data into named sections. Each section has a type and needs to go somewhere in memory.
| Section | Contents | Where it lives at runtime | Where it is stored in the image |
|---|---|---|---|
.isr_vector | Interrupt vector table | Flash (start) | Flash |
.text | Executable code | Flash | Flash |
.rodata | Read-only data (const, string literals) | Flash | Flash |
.data | Initialized global/static variables | SRAM | Flash (initial values), copied at startup |
.bss | Uninitialized or zero-initialized globals/statics | SRAM | Not stored (only its size is recorded); zeroed at startup |
.stack | Reserved space for the stack | SRAM | Not stored |
.heap | Reserved space for dynamic allocation | SRAM | Not stored |
.init_array / .ctors | Initialization function pointers | Flash | Flash |
int counter = 5; // .data (value 5 stored in Flash, copied to RAM)
int total; // .bss (zeroed in RAM at startup)
const int limit = 100; // .rodata (Flash)
static int mode = 0; // .bss (zero-initialized)
void foo(void) { } // .text
Why
.datais in two places: RAM is empty at power-up, so the initial values must live in non-volatile Flash. The startup code copies them into RAM. In linker-script terms: VMA (Virtual Memory Address, where it runs) is in RAM, and LMA (Load Memory Address, where it's stored) is in Flash.
Custom sections
Place code or data in specific regions:
__attribute__((section(".fast_code"))) void isr_fast(void) { } // e.g., run from RAM
__attribute__((section(".noinit"))) uint32_t boot_flags; // survives reset, not initialized
__attribute__((section(".config"))) const Config cfg = { /* ... */ };
Linker script
A linker script (.ld file) tells the linker:
- What memory exists (names, addresses, sizes, permissions).
- Which sections go into which memory, and in what order.
- Where to place symbols (start/end of sections, stack top) that the startup code needs.
Example (simplified GNU ld script for a Cortex-M MCU)
ENTRY(Reset_Handler) /* program entry point */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_estack = ORIGIN(RAM) + LENGTH(RAM); /* top of stack = end of RAM */
_Min_Heap_Size = 0x400;
_Min_Stack_Size = 0x800;
SECTIONS
{
.isr_vector : { /* vector table first in Flash */
KEEP(*(.isr_vector))
} > FLASH
.text : { /* code */
*(.text*)
*(.rodata*) /* read-only data */
. = ALIGN(4);
_etext = .; /* end of code/rodata */
} > FLASH
_sidata = LOADADDR(.data); /* where .data's initial values sit in Flash */
.data : { /* runs in RAM, loaded from Flash */
. = ALIGN(4);
_sdata = .; /* start of .data in RAM */
*(.data*)
. = ALIGN(4);
_edata = .; /* end of .data in RAM */
} > RAM AT > FLASH
.bss : { /* zeroed in RAM */
. = ALIGN(4);
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
._user_heap_stack : { /* reserve space for heap and stack */
. = ALIGN(8);
. = . + _Min_Heap_Size;
. = . + _Min_Stack_Size;
. = ALIGN(8);
} > RAM
}
Key parts
| Element | Meaning |
|---|---|
MEMORY { } | Describes physical memory regions (ORIGIN, LENGTH, attributes r/w/x) |
SECTIONS { } | Maps input sections (from object files) to output sections and memory |
> FLASH | Place in the FLASH region |
AT > FLASH | Load address in Flash while the run address is the region before it (RAM) |
ENTRY(...) | The first instruction to run (debuggers use it) |
KEEP(...) | Prevent removal by garbage collection (--gc-sections) |
ALIGN(n) | Align the location counter |
. (dot) | The location counter, i.e., the current address |
Symbols like _sdata, _edata, _sbss, _ebss, _estack | Exported to C/startup code as addresses |
Reading linker symbols from C
extern uint32_t _sdata, _edata, _sidata, _sbss, _ebss;
// Use their ADDRESSES (&_sdata), not their values.
Startup code
The startup code runs before main(). It's usually in assembly or C (startup_xxx.s / startup.c) supplied with the chip's tools.
Typical responsibilities
- Define the vector table (with the initial SP and the reset vector).
- Reset handler, which does the steps below.
- Set the stack pointer (on Cortex-M it's loaded from the vector table by hardware).
- (Optional) Enable the FPU, set up clocks early (
SystemInit()), configure Flash wait states. - Copy
.datafrom Flash to RAM. - Zero
.bss. - (Optional) Fill the stack with a pattern, initialize the heap.
- Call static constructors/init arrays (
__libc_init_array). - Call
main(). - If
mainreturns, loop forever (or reset).
Reset handler in C
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss;
extern int main(void);
extern void SystemInit(void);
void Reset_Handler(void) {
SystemInit(); // clocks, Flash latency, etc.
uint32_t *src = &_sidata;
uint32_t *dst = &_sdata;
while (dst < &_edata)
*dst++ = *src++; // copy .data from Flash to RAM
for (dst = &_sbss; dst < &_ebss; )
*dst++ = 0; // zero .bss
main();
while (1) { } // never return
}
void Default_Handler(void) { while (1) { } }
// Unused interrupt handlers are weak aliases of Default_Handler:
void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
Why zero .bss and copy .data? The C standard guarantees that uninitialized globals are zero and initialized globals have their stated values at the start of main. RAM contents are random at power-up, so the startup code must make this true.
Weak symbols
Default handlers are declared weak, so your own function with the same name overrides them automatically at link time.
Map file
The linker can output a .map file showing the final address and size of every section and symbol, essential for:
- Checking Flash and RAM usage.
- Finding big functions or tables.
- Verifying section placement.
Useful tools: arm-none-eabi-size firmware.elf (shows text/data/bss sizes), objdump, nm.
Common linker/startup problems
| Symptom | Likely cause |
|---|---|
region 'RAM' overflowed / 'FLASH' overflowed | Program too large; reduce usage |
| Globals have garbage at start | .data copy or .bss zeroing missing or wrong |
| Crash immediately after reset | Wrong stack top, wrong vector table placement, wrong entry point |
| Interrupt does nothing / default handler hit | Handler name mismatch or vector table not linked first |
| Code works under debugger but not standalone | Debug halted while the clock/Flash timings differ; missing init |
| Heap/stack collision | Insufficient reserved space |
11. Bootloaders and Reset Sequences
Reset
A reset puts the MCU into a known state and starts execution from a fixed place.
Reset sources
| Source | Description |
|---|---|
| Power-on reset (POR) | Triggered when power is first applied |
| Brown-out reset (BOR) | Supply voltage dropped below a threshold |
| External reset pin (NRST) | Reset button or external signal |
| Watchdog reset | Watchdog timer wasn't serviced in time (software hung) |
| Software reset | Code requests a reset (e.g., NVIC_SystemReset()) |
| Debugger/JTAG reset | Issued by a debug probe |
| Low-power wake reset | Exiting certain deep-sleep modes |
| Fault-induced reset | Lockup or similar CPU fault condition |
A reset-status register records the cause (flags like PORRSTF, IWDGRSTF), useful for diagnosing field failures.
What a reset does
- CPU registers get reset values and peripherals return to their default state (most are disabled).
- PC is loaded from the reset vector.
- SRAM contents are undefined after power-on (and typically preserved on warm resets, though you can't rely on that unless designed to).
- Clocks start from a default, slow internal oscillator (e.g., HSI).
- Interrupts are disabled.
Reset sequence on ARM Cortex-M (step by step)
- Reset is released; hardware reads boot configuration (BOOT pins or option bytes) to decide which memory is mapped at address
0x0: Flash, system memory (ROM bootloader), or SRAM. - CPU reads the first word of the vector table and loads it into MSP (initial stack pointer).
- CPU reads the second word (reset vector) and loads it into PC (setting the Thumb bit).
- Execution begins at
Reset_Handler. - Startup code:
SystemInit()(clock setup), copy.data, zero.bss, run constructors. main()is called.
Power on / reset
|
v
Hardware reads boot pins/option bytes ---> chooses boot memory
|
v
Load SP <- [0x0000_0000] (first vector)
Load PC <- [0x0000_0004] (reset vector)
|
v
Reset_Handler: SystemInit -> copy .data -> zero .bss -> init libc
|
v
main()
Reset on other architectures
| MCU | Reset behavior |
|---|---|
| AVR | PC set to 0x0000 (or the bootloader start if the BOOTRST fuse is set); the instruction there is a jump to the reset handler; startup sets SP, clears r1, zeroes .bss, copies .data |
| 8051 | PC = 0x0000, SP = 0x07; registers reset; code typically starts with a LJMP to startup |
| PIC | PC = reset vector (0x0000); GOTO main or startup |
| MSP430 | PC loaded from the reset vector at 0xFFFE |
Boot modes (typical MCU feature)
Boot pins or option bytes select where the MCU boots from:
| Boot mode | Starts from |
|---|---|
| Main Flash | Your application (normal operation) |
| System memory | Built-in ROM bootloader (for programming over UART/USB/CAN/I2C/SPI) |
| Embedded SRAM | Code loaded into RAM (debugging and special uses) |
| External memory | Some MCUs boot from external Flash (QSPI) |
Bootloader
A bootloader is a small program that runs first after reset. Its main job is to load, verify, and start (or update) the main application firmware.
Types of bootloaders
| Type | Description |
|---|---|
| ROM / system bootloader | Built into the chip by the manufacturer; permanent; can program Flash through serial, USB, etc. |
| User bootloader (custom) | Written by you and stored in a protected Flash region; supports your update method and security |
| Secondary/second-stage bootloader | A larger loader started by a first-stage loader (used on MPUs with external memory) |
Why use a bootloader?
- Update firmware in the field (no programmer needed): via UART, USB, CAN, SPI, Ethernet, or OTA (wireless).
- Verify integrity (checksum/CRC, signature) before running.
- Recover from a bad update (fallback image).
- Security: authenticate and decrypt firmware.
- Select among multiple application images.
Memory layout with a bootloader
Flash:
0x0800_0000 +-------------------------+
| Bootloader | (protected from erase)
0x0800_8000 +-------------------------+
| Application | has its own vector table
| (vector table, code) |
+-------------------------+
| Config / parameters |
+-------------------------+
The application is linked to start at its own address (FLASH ORIGIN = 0x08008000) using a modified linker script.
Typical bootloader flow
Reset
|
v
Bootloader starts
|
+-- Check trigger: button held? flag in RAM/backup register? no valid app?
| yes -> enter update mode: receive new firmware, erase and write Flash, verify
| no -> continue
|
+-- Validate application (CRC / signature / magic number)
| invalid -> stay in the bootloader (or fallback)
|
+-- Jump to application
Jumping to the application (Cortex-M example)
#define APP_ADDR 0x08008000UL
typedef void (*app_entry_t)(void);
void jump_to_application(void) {
uint32_t app_sp = *(volatile uint32_t *)(APP_ADDR); // word 0: initial SP
uint32_t app_reset = *(volatile uint32_t *)(APP_ADDR + 4); // word 1: reset handler
__disable_irq(); // stop any bootloader interrupts
/* de-initialize peripherals, stop SysTick, clear pending IRQs */
SCB->VTOR = APP_ADDR; // point to the application's vector table
__set_MSP(app_sp); // load the application's stack pointer
((app_entry_t)app_reset)(); // jump (does not return)
}
Checks before jumping: make sure the SP value points into RAM and the reset vector points into Flash; verify a CRC or magic number.
Application-side requirements
- Linker script with the new Flash origin and size.
- Vector table offset set (
VTOR/SCB->VTOR = APP_ADDR; often done inSystemInitthrough aVECT_TAB_OFFSET). - Avoid overlapping bootloader memory.
- Optional application header (version, size, CRC, magic number).
Update mechanisms
| Method | Notes |
|---|---|
| UART/serial | Simple; protocols like XMODEM, YMODEM, custom framing |
| USB (DFU, mass storage) | Fast, user-friendly |
| CAN, I2C, SPI | Common in automotive and industrial networks |
| Ethernet/TFTP | Network update |
| OTA (wireless) | BLE, Wi-Fi, cellular |
| SD card or external Flash | Image stored then installed |
Safety and robustness features
- Dual-bank/A-B update: write the new firmware to an inactive bank, verify, then swap (allows rollback).
- Power-fail safety: an interrupted update must not brick the device.
- CRC/hash and digital signatures for integrity and authenticity.
- Write-protect the bootloader region (option bytes / lock bits).
- Read-out protection to guard firmware IP.
- Watchdog during the update.
- Version checks and anti-rollback.
- Secure boot: chain of trust verifying each stage before it runs.
Bootloader constraints
- Small and highly reliable (it must be the part that always works).
- Runs from Flash while erasing and programming Flash. Programming routines may need to run from RAM, because the CPU can stall when Flash is busy.
- Must leave hardware in a clean state before jumping to the app.
Bootloader vs startup code
| Startup code | Bootloader | |
|---|---|---|
| Part of | Every application | Separate program (optional) |
| Job | Prepare C runtime (stack, .data, .bss) and call main | Choose, verify, update, and launch the application |
| Runs | Right before main in your app | Before the application starts |
| Required? | Yes | No (only if you need updates/security) |
Quick Revision Cheat Sheet
| Topic | Key points |
|---|---|
| CPU | Fetch, decode, execute; ALU does math/logic and sets flags (Z, C, N, V) |
| Program counter | Address of next instruction; changed by branches, calls, interrupts, reset |
| GPRs | Temporary data inside CPU; used by compiler (ARM: R0-R12) |
| SFRs | Control and status of peripherals; each bit has a hardware meaning |
| Flash | Non-volatile code storage; sector erase; limited endurance; slow write |
| ROM | Fixed at manufacture; often holds the built-in bootloader |
| SRAM | Fast, volatile; variables, stack, heap |
| EEPROM | Byte-writable non-volatile settings; slow, limited cycles |
| NVRAM | RAM-like memory that survives power loss (FRAM, battery-backed SRAM) |
| Memory map | Which addresses are Flash, SRAM, and peripherals (check reference manual) |
| Address spaces | Unified (ARM, MSP430, RISC-V) vs separate (AVR, 8051, PIC) |
| Data vs program memory | Harvard needs special access for Flash constants; unified uses plain pointers |
| Stack pointer | Points to top of stack; grows downward on most MCUs |
| Stack frame | Per-call block: return address, args, saved registers, locals |
| Stack overflow | Caused by deep recursion, large locals, nested interrupts; detect with stack painting |
| Vector table | Array of handler addresses; entry 0 = initial SP, entry 1 = reset (Cortex-M) |
| Memory-mapped I/O | Peripherals in the memory space; use volatile pointers |
| Port-mapped I/O | Separate I/O space with special instructions (x86 IN/OUT) |
| Register access | Base + offset; struct overlays; beware read-modify-write and W1C flags; enable clock first |
| Sections | .text (code), .rodata, .data (init vars), .bss (zeroed), stack, heap |
| Linker script | MEMORY regions + SECTIONS placement; defines symbols like _sdata, _ebss, _estack |
| Startup code | Sets SP, copies .data, zeros .bss, calls main |
| Reset sequence | Hardware loads SP and PC from vector table, runs Reset_Handler, then main |
| Bootloader | Runs first; verifies/updates/launches app; app needs its own origin and VTOR |
Common interview-style questions
- What do the ALU, registers, and program counter each do? How does the fetch-decode-execute cycle work?
- What is the difference between general-purpose registers and special-function registers?
- Compare Flash, ROM, SRAM, EEPROM, and NVRAM. Which are volatile?
- Why does Flash have limited endurance and require sector erase?
- What is a memory map, and why does the linker script depend on it?
- How do Harvard and Von Neumann (unified) architectures differ in how C accesses constants in Flash?
- What is the stack pointer? What is stored in a stack frame? What causes a stack overflow?
- What is the interrupt vector table? What do the first two entries hold on Cortex-M?
- What is the difference between memory-mapped I/O and port-mapped I/O?
- Why must peripheral register pointers be
volatile? What is a read-modify-write hazard? - Why are
.dataand.bsshandled differently at startup? What are LMA and VMA? - What does the startup code do before
main()? - Describe what happens from power-on to the first line of
main(). - What is a bootloader, and what must an application do to run behind one?
- What reset sources exist, and how can you tell which one occurred?
#Bit Manipulation and Register Programming#
In embedded systems, you rarely talk to hardware with fancy function calls. You talk to it by reading and writing individual bits inside special memory locations called registers. One bit might turn on an LED, another might start a timer, another might tell you a button was pressed.
These notes walk you through everything you need to do that confidently, starting from how numbers look in binary and ending with why the keyword volatile can make or break your firmware.
Table of Contents
- Binary, Decimal, Hexadecimal and Conversions
- Bitwise Operators: AND, OR, XOR, NOT
- Left and Right Shifts
- Setting, Clearing and Toggling Bits
- Checking a Bit Using Masks
- Creating and Extracting Bit Fields
- Setting and Clearing Selected Register Bits
- Register Masks and Symbolic Definitions
- Register Access Using Pointers
- Why
volatileMatters for Hardware Registers - Read-Modify-Write Operations
- Atomic Bit Operations
- Quick Revision Cheat Sheet
1. Binary, Decimal, Hexadecimal and Conversions
Why three number systems?
| System | Base | Digits used | Why embedded engineers use it |
|---|---|---|---|
| Binary | 2 | 0, 1 | This is what the hardware actually stores. Every bit is visible. |
| Decimal | 10 | 0β9 | Natural for humans: counts, delays, sensor values. |
| Hexadecimal | 16 | 0β9, AβF | A compact way to write binary. Perfect for registers and addresses. |
The magic of hex
One hex digit = exactly 4 bits (a nibble). That is why hex is everywhere in embedded work: you can convert between hex and binary by eye.
| Hex | Binary | Decimal |
|---|---|---|
| 0 | 0000 | 0 |
| 1 | 0001 | 1 |
| 2 | 0010 | 2 |
| 3 | 0011 | 3 |
| 4 | 0100 | 4 |
| 5 | 0101 | 5 |
| 6 | 0110 | 6 |
| 7 | 0111 | 7 |
| 8 | 1000 | 8 |
| 9 | 1001 | 9 |
| A | 1010 | 10 |
| B | 1011 | 11 |
| C | 1100 | 12 |
| D | 1101 | 13 |
| E | 1110 | 14 |
| F | 1111 | 15 |
Bit numbering
Bits are numbered from the right, starting at 0. Bit 0 is the LSB (Least Significant Bit) and the leftmost bit is the MSB (Most Significant Bit).
Bit position: 7 6 5 4 3 2 1 0
Value: 1 0 1 1 0 0 1 0 = 0xB2
β β
MSB LSB
Conversions
Binary β Decimal: add up the place values (powers of 2) where there is a 1.
1011 0010
= 1Γ128 + 0Γ64 + 1Γ32 + 1Γ16 + 0Γ8 + 0Γ4 + 1Γ2 + 0Γ1
= 128 + 32 + 16 + 2
= 178
Decimal β Binary: divide by 2 repeatedly and note the remainders (read them bottom to top).
178 Γ· 2 = 89 remainder 0 β LSB
89 Γ· 2 = 44 remainder 1
44 Γ· 2 = 22 remainder 0
22 Γ· 2 = 11 remainder 0
11 Γ· 2 = 5 remainder 1
5 Γ· 2 = 2 remainder 1
2 Γ· 2 = 1 remainder 0
1 Γ· 2 = 0 remainder 1 β MSB
β 1011 0010
Binary β Hex: group the bits in fours from the right, convert each group.
1011 0010
B 2 β 0xB2
Hex β Binary: replace each hex digit with its 4-bit pattern.
0x3C β 0011 1100
Hex β Decimal: multiply each digit by a power of 16.
0xB2 = 11Γ16 + 2Γ1 = 176 + 2 = 178
Decimal β Hex: divide by 16 repeatedly, or convert to binary first and then group.
How C writes these numbers
uint8_t a = 178; // decimal
uint8_t b = 0xB2; // hexadecimal (prefix 0x)
uint8_t c = 0b10110010; // binary (prefix 0b; supported by most embedded compilers, though not part of older C standards)
uint8_t d = 0262; // octal (leading 0), a classic trap! Avoid it by accident.
Pitfall:
int x = 010;is 8, not 10. A leading zero means octal in C.
Fixed-width types
Embedded code uses types from <stdint.h> so you always know how many bits you have:
| Type | Bits | Range |
|---|---|---|
uint8_t | 8 | 0 to 255 |
uint16_t | 16 | 0 to 65535 |
uint32_t | 32 | 0 to 4,294,967,295 |
Use unsigned types for bit work. Bitwise operations on signed numbers can give surprising results (see shifts below).
2. Bitwise Operators: AND, OR, XOR, NOT
These operators work on each bit independently, position by position.
Truth tables
| A | B | A & B (AND) | A | B (OR) | A ^ B (XOR) |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 1 | 0 | 1 | 1 |
| 1 | 0 | 0 | 1 | 1 |
| 1 | 1 | 1 | 1 | 0 |
| A | ~A (NOT) |
|---|---|
| 0 | 1 |
| 1 | 0 |
What each one is good at
| Operator | Symbol | Think of it as | Typical embedded job |
|---|---|---|---|
| AND | & | A filter / "keep only these bits" | Clear bits, check bits, extract fields |
| OR | | | A "force these bits ON" tool | Set bits, combine fields |
| XOR | ^ | A "flip where there's a 1" tool | Toggle bits, compare for differences |
| NOT | ~ | An inverter | Build "everything except" masks |
Worked example
A = 1100 1010 (0xCA)
B = 1111 0000 (0xF0)
A & B = 1100 0000 (0xC0) β keeps the upper nibble of A
A | B = 1111 1010 (0xFA) β forces upper nibble to 1s
A ^ B = 0011 1010 (0x3A) β flips upper nibble of A
~A = 0011 0101 (0x35) β every bit inverted
Don't confuse these!
| Bitwise (works on bits) | Logical (works on true/false) |
|---|---|
& | && |
| | || |
~ | ! |
if (flags & 0x01) // checks bit 0
if (flags && 0x01) // checks "is flags non-zero AND 1 non-zero", which is almost never what you want
Integer promotion warning
In C, uint8_t and uint16_t are promoted to int before most operations. So ~ on a uint8_t gives a result with many upper 1 bits:
uint8_t x = 0x0F;
uint8_t y = ~x; // OK: stored back into 8 bits, y = 0xF0
if (~x == 0xF0) { } // FALSE! ~x is actually 0xFFFFFFF0 (as int)
if ((uint8_t)~x == 0xF0) { } // TRUE: cast fixes it
3. Left and Right Shifts
Shifts slide all bits to the left or right.
Left shift <<
Moves bits toward the MSB. New bits entering from the right are 0.
0000 0101 (5)
<< 2
0001 0100 (20)
Shifting left by n is the same as multiplying by 2βΏ (as long as bits don't fall off the end).
Right shift >>
Moves bits toward the LSB.
0001 0100 (20)
>> 2
0000 0101 (5)
Shifting right by n is the same as dividing by 2βΏ (integer division, discarding the remainder).
Logical vs. arithmetic right shift
| Type | What fills the left side | Applies to |
|---|---|---|
| Logical | 0 | Unsigned types |
| Arithmetic | Copy of the sign bit | Signed types (compiler-dependent in C) |
Right-shifting a negative signed number is implementation-defined. This is another reason to use uint types for register and bit work.
The most important shift idiom: 1 << n
(1 << n) creates a number with only bit n set. This is the building block for almost every bit operation.
(1 << 0) // 0000 0001 = 0x01
(1 << 3) // 0000 1000 = 0x08
(1 << 7) // 1000 0000 = 0x80
Shift pitfalls
- Shifting by a count β₯ the type's width is undefined behavior. Shifting a 32-bit value by 32 is a bug.
- Use the right literal size. On a 16-bit
intplatform,1 << 20overflows. Write1UL << 20or(uint32_t)1 << 20when working with 32-bit registers. - Shifting a
1into the sign bit of a signedint(e.g.,1 << 31with plainint) is undefined behavior in C. Use1U << 31.
uint32_t mask = 1UL << 31; // safe
4. Setting, Clearing and Toggling Bits
These three operations are the daily bread of register programming. Memorize them as a trio.
Let reg be the variable (or register) and n the bit number.
Setting a bit (make it 1) β use OR
reg |= (1U << n);
reg = 1010 0001
mask (1<<3) = 0000 1000
reg | mask = 1010 1001 β bit 3 is now 1, everything else untouched
Clearing a bit (make it 0) β use AND with inverted mask
reg &= ~(1U << n);
reg = 1010 1001
mask (1<<3) = 0000 1000
~mask = 1111 0111
reg & ~mask = 1010 0001 β bit 3 is now 0, everything else untouched
Toggling a bit (flip it) β use XOR
reg ^= (1U << n);
reg = 1010 0001
mask (1<<3) = 0000 1000
reg ^ mask = 1010 1001 β bit 3 flipped from 0 to 1
reg ^ mask = 1010 0001 β do it again and it flips back
Summary table
| Goal | Operator | Code | Mnemonic |
|---|---|---|---|
Set bit n | OR | reg |= (1U << n); | OR turns bits on |
Clear bit n | AND + NOT | reg &= ~(1U << n); | AND with a hole at the bit |
Toggle bit n | XOR | reg ^= (1U << n); | XOR flips |
Working with several bits at once
Combine masks with |:
reg |= ((1U << 2) | (1U << 5)); // set bits 2 and 5
reg &= ~((1U << 2) | (1U << 5)); // clear bits 2 and 5
reg ^= ((1U << 2) | (1U << 5)); // toggle bits 2 and 5
Handy macros
#define SET_BIT(reg, n) ((reg) |= (1U << (n)))
#define CLEAR_BIT(reg, n) ((reg) &= ~(1U << (n)))
#define TOGGLE_BIT(reg, n) ((reg) ^= (1U << (n)))
Notice the parentheses around
regandn. Macros without them can break when you pass expressions likeSET_BIT(x, a+1).
Example: blinking an LED
#define LED_PIN 5
PORT |= (1U << LED_PIN); // LED on
PORT &= ~(1U << LED_PIN); // LED off
PORT ^= (1U << LED_PIN); // flip LED state (blink!)
5. Checking a Bit Using Masks
A mask is a number that isolates the bit(s) you care about. To check a bit, AND the register with a mask and see whether the result is non-zero.
if (reg & (1U << n)) {
// bit n is 1
} else {
// bit n is 0
}
reg = 1010 1001
mask (1<<3) = 0000 1000
reg & mask = 0000 1000 β non-zero β bit 3 is SET
mask (1<<2) = 0000 0100
reg & mask = 0000 0000 β zero β bit 2 is CLEAR
Getting the bit as exactly 0 or 1
reg & mask returns the mask value (e.g., 8), not 1. If you need a clean 0 or 1:
uint8_t bit = (reg >> n) & 1U; // shift down, then mask
uint8_t bit = !!(reg & (1U << n)); // double negation trick
Checking multiple bits
// Are ALL of bits 1 and 3 set?
#define MASK ((1U << 1) | (1U << 3))
if ((reg & MASK) == MASK) { }
// Is ANY of bits 1 or 3 set?
if (reg & MASK) { }
// Are BOTH of bits 1 and 3 clear?
if ((reg & MASK) == 0) { }
Polling (waiting for a flag)
A very common embedded pattern is waiting for hardware to set a status bit:
while (!(STATUS_REG & READY_FLAG)) {
// wait until the READY flag becomes 1
}
Common mistake: operator precedence
In C, == has higher precedence than &. So this is a bug:
if (reg & MASK == MASK) // WRONG: parsed as reg & (MASK == MASK)
if ((reg & MASK) == MASK) // CORRECT
Rule of thumb: always put parentheses around bitwise expressions.
6. Creating and Extracting Bit Fields
A bit field is a group of adjacent bits that together represent one value. Examples: a 3-bit prescaler selector, a 4-bit channel number, a 2-bit mode setting.
Bit: 7 6 5 4 3 2 1 0
- - [ F F F ] -
ββ 3-bit field at bits 4:2
You describe a field with two numbers:
- Position (shift): where its lowest bit sits
- Width: how many bits it has
Making a mask for a field
A field of width bits has the mask (1U << width) - 1, shifted into position.
// 3-bit field starting at bit 2
#define FIELD_POS 2
#define FIELD_WIDTH 3
#define FIELD_MASK (((1U << FIELD_WIDTH) - 1U) << FIELD_POS) // 0001 1100 = 0x1C
How (1U << 3) - 1 works:
1 << 3 = 0000 1000 (8)
8 - 1 = 0000 0111 (7) β three 1s in a row!
<< 2 = 0001 1100 β moved into position
Extracting a field
Mask first, then shift down.
uint8_t value = (reg & FIELD_MASK) >> FIELD_POS;
reg = 1011 0110
FIELD_MASK = 0001 1100
reg & mask = 0001 0100
>> 2 = 0000 0101 β field value = 5
Creating (inserting) a field value
Clear the old field, then OR in the new value shifted into place.
reg = (reg & ~FIELD_MASK) | ((newValue << FIELD_POS) & FIELD_MASK);
Step by step, writing 3 (binary 011) into the field:
reg = 1011 0110
~FIELD_MASK = 1110 0011
reg & ~FIELD_MASK = 1010 0010 β field cleared
3 << 2 = 0000 1100
(β¦) & FIELD_MASK = 0000 1100 β protects against too-large values
OR them = 1010 1110 β field now holds 3
The extra & FIELD_MASK is a safety net: if newValue is too big for the field, it won't spill into neighboring bits.
Reusable macros
#define GET_FIELD(reg, mask, pos) (((reg) & (mask)) >> (pos))
#define SET_FIELD(reg, mask, pos, val) ((reg) = ((reg) & ~(mask)) | (((val) << (pos)) & (mask)))
A second flavour: C struct bit-fields
C also lets you declare bit fields directly in a struct:
struct ControlReg {
uint8_t enable : 1;
uint8_t mode : 3;
uint8_t speed : 2;
uint8_t : 2; // unused/reserved
};
They look neat, but for hardware registers many professionals prefer explicit masks and shifts because the C standard does not fix the bit order, packing, or layout of struct bit-fields. The same code can behave differently on different compilers or architectures. Masks and shifts give you exact, portable control.
7. Setting and Clearing Selected Register Bits
Real registers often contain a mix of single-bit flags and multi-bit fields. The goal is to change only the bits you intend and leave every other bit alone.
Single bits
CTRL_REG |= ENABLE_BIT; // set one bit
CTRL_REG &= ~ENABLE_BIT; // clear one bit
Several selected bits at once
CTRL_REG |= (ENABLE_BIT | INTERRUPT_BIT); // set both
CTRL_REG &= ~(ENABLE_BIT | INTERRUPT_BIT); // clear both
Changing a multi-bit field to a specific value
You must clear the field first, then set the new value. Skipping the clear step is a classic bug, because OR can only add 1s.
// WRONG: if the field already held 0b111, OR-ing 0b010 leaves 0b111
MODE_REG |= (2U << MODE_POS);
// RIGHT:
MODE_REG &= ~MODE_MASK; // 1. clear the whole field
MODE_REG |= (2U << MODE_POS); // 2. write the new value
Setting and clearing in one expression
MODE_REG = (MODE_REG & ~MODE_MASK) | (2U << MODE_POS);
This reads, modifies, and writes the register once, which also matters for hardware (see the next sections on read-modify-write).
Writing a whole register vs. selected bits
| Style | Code | Effect |
|---|---|---|
| Whole-register write | REG = 0x05; | Overwrites all bits. Unlisted bits become 0. |
| Selected-bit update | REG |= 0x05; | Only touches bits 0 and 2. |
Use whole-register writes only when you truly mean to define every bit (for example, during initialization with a known configuration).
Reserved bits
Datasheets often mark bits as "reserved". As a general rule, don't change them and, when you must write the whole register, follow what the datasheet says (some require writing back the value you read, others require writing 0). Using read-modify-write keeps the reserved bits as they were.
8. Register Masks and Symbolic Definitions
The problem with magic numbers
GPIOA_CTRL |= 0x20; // What does 0x20 mean? Bit 5? Of what?
Nobody (including you, in three months) can tell what this does. Symbolic definitions give names to positions, masks, and values.
Defining positions and masks
// Position of each bit within the register
#define UART_CTRL_EN_Pos 0U
#define UART_CTRL_TXIE_Pos 1U
#define UART_CTRL_RXIE_Pos 2U
#define UART_CTRL_BAUD_Pos 4U
// Mask built from the position
#define UART_CTRL_EN_Msk (1U << UART_CTRL_EN_Pos)
#define UART_CTRL_TXIE_Msk (1U << UART_CTRL_TXIE_Pos)
#define UART_CTRL_RXIE_Msk (1U << UART_CTRL_RXIE_Pos)
#define UART_CTRL_BAUD_Msk (0x7U << UART_CTRL_BAUD_Pos) // 3-bit field
Now the code explains itself:
UART->CTRL |= UART_CTRL_EN_Msk; // enable UART
UART->CTRL &= ~UART_CTRL_TXIE_Msk; // disable TX interrupt
UART->CTRL = (UART->CTRL & ~UART_CTRL_BAUD_Msk)
| (5U << UART_CTRL_BAUD_Pos); // select baud setting 5
Why Pos and Msk pairs?
_Postells you where the field begins, which you need for shifting._Msktells you which bits belong to it, which you need for AND/OR operations.
Having both lets you write generic get/set macros without hard-coding numbers.
Naming good practices
| Practice | Why |
|---|---|
Use UPPER_CASE for macros | Visually distinct from variables |
| Include the register and peripheral name | Avoids clashes (UART_CTRL_EN vs. SPI_CTRL_EN) |
Define field values too (e.g., MODE_FAST) | Readable configuration |
| Put definitions in a header file | One place to fix mistakes |
Use U / UL suffixes | Avoids signed-shift problems |
Defining field values
#define MODE_SLOW 0U
#define MODE_NORMAL 1U
#define MODE_FAST 2U
CTRL = (CTRL & ~MODE_MSK) | (MODE_FAST << MODE_POS);
Single source of truth
Define the position once and derive everything else from it. If the datasheet changes or you port to a new chip, you edit one line.
Where do these definitions come from?
Chip vendors usually ship a header file with all of these already defined. Learning to write your own teaches you what those headers actually contain and lets you read them confidently.
9. Register Access Using Pointers
Registers live at fixed addresses
In most microcontrollers, peripherals are memory-mapped: each register has a specific address in the memory map, and reading or writing that address talks to the hardware. The datasheet gives you the address.
So to touch a register, you need a pointer to that address.
Building the pointer
// Suppose a control register sits at address 0x40020000, and is 32 bits wide
#define CTRL_REG (*(volatile uint32_t *)0x40020000U)
Read this from the inside out:
| Piece | Meaning |
|---|---|
0x40020000U | The raw address (a number) |
(volatile uint32_t *) | Cast that number into "a pointer to a volatile 32-bit unsigned integer" |
*( β¦ ) | Dereference it, so CTRL_REG behaves like a normal variable located at that address |
Now you can use it like a variable:
CTRL_REG = 0x01; // write to the hardware register
CTRL_REG |= (1U << 3); // set bit 3
uint32_t v = CTRL_REG; // read the hardware register
Using a named pointer instead of a macro
volatile uint32_t * const ctrl = (volatile uint32_t *)0x40020000U;
*ctrl |= (1U << 3);
Notice * const: the pointer itself never changes (the address is fixed), while the data it points to is volatile.
Grouping registers with a struct
Peripherals have several registers laid out next to each other. A struct that mirrors that layout lets one base address cover them all:
typedef struct {
volatile uint32_t CTRL; // offset 0x00
volatile uint32_t STATUS; // offset 0x04
volatile uint32_t DATA; // offset 0x08
} UART_TypeDef;
#define UART ((UART_TypeDef *)0x40011000U)
UART->CTRL |= UART_CTRL_EN_Msk;
while (!(UART->STATUS & UART_STATUS_TXE_Msk)) { }
UART->DATA = 'A';
This is exactly how vendor-supplied headers are organised. The compiler computes each register's offset from the struct layout, so make sure the struct members match the datasheet's register map (including any reserved gaps).
Register width matters
Use the width the datasheet specifies:
| Register width | Type |
|---|---|
| 8-bit | volatile uint8_t |
| 16-bit | volatile uint16_t |
| 32-bit | volatile uint32_t |
Accessing a register with the wrong width can read or write neighbouring registers or cause a bus fault on some architectures.
Common mistakes
- Forgetting
volatile(next section explains why that's dangerous) - Forgetting the dereference
*and just assigning to the pointer - Using a wrong-sized type
- Writing the address as a signed
intconstant instead of using theUsuffix
10. Why volatile Matters for Hardware Registers
What volatile tells the compiler
"This value can change at any time, outside the normal flow of the program, or reading/writing it has side effects. Do not optimise away or reorder accesses to it."
Why the compiler needs to be told
Compilers are smart optimisers. They assume that a variable only changes when your code changes it. Hardware registers break that assumption, because the hardware itself can change the value.
Example 1: the vanishing wait loop
uint32_t *status = (uint32_t *)0x40011004U; // NOT volatile
while ((*status & READY) == 0) {
// wait for hardware to set READY
}
The compiler reasons: "Nothing inside the loop modifies *status, so the value can't change, so I'll read it once and, if it's 0, loop forever (or skip the check entirely)." The result is an infinite loop even though the hardware does set the bit.
With volatile, the compiler is forced to re-read the register from its address on every iteration:
volatile uint32_t *status = (volatile uint32_t *)0x40011004U;
Example 2: writes that get deleted
LED_REG = 1; // turn on
LED_REG = 0; // turn off
Without volatile, the compiler may decide the first write is pointless because it's immediately overwritten, and remove it. For a pulse-generating register or a start-then-stop command, deleting a write breaks the hardware sequence. With volatile, every write really happens, in the order written.
Example 3: repeated reads
uint8_t a = RX_DATA;
uint8_t b = RX_DATA;
For a receive-data register, each read may pop a new byte from a hardware FIFO. Without volatile, the compiler could merge the two reads into one. With volatile, both reads occur.
Where volatile is needed
| Situation | Why |
|---|---|
| Memory-mapped hardware registers | Hardware changes values; reads/writes have side effects |
| Variables shared with an interrupt service routine (ISR) | The ISR changes the value outside the main code's flow |
| Variables shared between tasks or threads (together with proper synchronisation) | Value changes from another context |
Placement of volatile with pointers
volatile uint32_t *p; // pointer to volatile data β typical for registers
uint32_t * volatile p; // volatile pointer to normal data
volatile uint32_t * volatile p; // both
For registers, you almost always want "pointer to volatile data" (the first form), plus const on the pointer if the address never changes.
Read-only registers
Use volatile const for hardware registers that you may only read:
volatile const uint32_t *adc_result = (volatile const uint32_t *)0x40012000U;
What volatile does not do
This is an important and often misunderstood point:
volatiledoes not make an operation atomic.REG |= BITis still read, modify, write: three steps (next sections).volatiledoes not act as a lock or mutual exclusion.- It only guarantees the compiler won't skip, merge, or reorder accesses to that object relative to other volatile accesses.
11. Read-Modify-Write Operations
What it is
Most bit changes in C compile into three steps:
- Read the current value of the register
- Modify the value in a CPU register (set/clear/toggle some bits)
- Write the result back
CTRL_REG |= (1U << 3);
is conceptually:
temp = CTRL_REG; // 1. READ
temp = temp | (1U << 3); // 2. MODIFY
CTRL_REG = temp; // 3. WRITE
Why it is needed
A register often holds many unrelated bits. You can't write just bit 3 on its own (on most hardware registers you write the full register width). So you read the whole thing, change only your bit, and write it all back so all other bits keep their values.
Why it can bite you
The value you read can become stale before you write it back.
Scenario A: an interrupt in the middle
Main code: READ CTRL_REG β 0b0000
ISR fires: sets bit 7 in CTRL_REG β 0b1000_0000
Main code: MODIFY and WRITE 0b0000 | 0b0001 β 0b0001
Result: the ISR's change to bit 7 is LOST
Scenario B: hardware changes the register between read and write
If the hardware updates a status bit between your read and your write-back, your write can overwrite it with the old value.
Scenario C: registers with special bit behaviour
| Bit type | Behaviour | RMW danger |
|---|---|---|
| Read-only | Writes ignored | Usually harmless |
| Write-1-to-clear (W1C) | Writing 1 clears the flag | A read-modify-write that reads flags as 1 and writes them back clears every pending flag, including ones you never handled |
| Read-clears | Reading the register clears it | The RMW read itself changes the hardware state |
| Write-only | Reads return garbage/0 | You can't read the old value reliably, so RMW is invalid |
Dealing with W1C (write-1-to-clear) flags
Don't do:
STATUS |= FLAG_A_MSK; // reads all pending flags, writes them all back as 1 β clears them all!
Do:
STATUS = FLAG_A_MSK; // plain write: writing 1 only to flag A clears just flag A
(Always confirm the exact behaviour in your chip's datasheet.)
Techniques to make RMW safe
| Technique | How |
|---|---|
| Disable interrupts around the RMW | Short critical section (see atomic section) |
| Use dedicated set/clear registers | Hardware does the bit change atomically |
| Use hardware bit-banding or atomic instructions (where available) | Single-step bit operation |
| Keep ISR and main code from touching the same register | Design-level avoidance |
| Shadow register | Keep a software copy, update the copy, write once (only suitable when no one else changes the hardware bits) |
12. Atomic Bit Operations
What "atomic" means
An operation is atomic if it completes as one indivisible step: nothing (no interrupt, no other bus master) can slip in halfway. Either it fully happens or it hasn't happened yet.
As seen above, REG |= BIT is not automatically atomic because it's a read-modify-write sequence, even if the C source is one line.
Why embedded systems care
- Interrupts can fire between any two machine instructions.
- Multiple interrupts or tasks may touch the same register.
- DMA or other bus masters may access memory too.
A lost update leads to bugs that appear rarely and are very hard to reproduce.
Strategy 1: Disable interrupts briefly (critical section)
uint32_t primask = save_and_disable_interrupts(); // platform specific
REG |= BIT; // RMW now cannot be interrupted
restore_interrupts(primask);
Guidelines:
- Keep the critical section as short as possible. Long sections increase interrupt latency.
- Save and restore the previous interrupt state instead of blindly enabling; otherwise you may re-enable interrupts that were meant to be off.
Strategy 2: Hardware set/clear registers
Many peripherals give you separate registers so you can change bits with a single write and no read:
| Register | Writing 1 to a bit | Writing 0 |
|---|---|---|
SET register | Sets that bit in the main register | No effect |
CLR register | Clears that bit in the main register | No effect |
TOGGLE register (if present) | Flips that bit | No effect |
GPIO_SET = (1U << 5); // set pin 5 only. Other pins unaffected, no RMW needed.
GPIO_CLR = (1U << 5); // clear pin 5 only.
Because it's a plain write that only affects the bits you put a 1 on, there is no read step and no way to lose someone else's update. Some chips combine this into one register where the lower half sets and the upper half clears.
Strategy 3: Bit-banding (on architectures that provide it)
Some architectures (for example certain ARM Cortex-M devices) map each individual bit of a region of memory to its own full word address in an alias region. Writing 1 or 0 to that alias word changes just that one bit, as a single atomic bus operation.
Alias address = alias_base + (byte_offset Γ 32) + (bit_number Γ 4)
This is hardware-specific. Check whether your device supports it.
Strategy 4: Atomic instructions / compiler support
Some processors offer instructions that atomically set, clear, or test-and-modify a bit, and some compilers expose them as intrinsics or atomic library functions. Availability depends on the architecture.
Strategy 5: Avoid sharing
The simplest and often the best solution: make sure only one context (main code or ISR) modifies a given register's bits. If both need to, use one of the methods above.
Atomicity of single variable accesses
Even a plain read or write may not be atomic if the data is wider than the CPU's native word:
volatile uint32_t counter; // on an 8-bit MCU, accessing this takes 4 separate byte operations
An ISR could modify it in the middle of your read and you'd get a "torn" value. Protect multi-byte accesses on small CPUs with a critical section.
Summary
| Method | Atomic? | Notes |
|---|---|---|
REG |= BIT | No (generally) | Read-modify-write |
| Disable interrupts + RMW | Yes (against interrupts) | Keep it short |
| SET/CLR hardware registers | Yes | Best option when available |
| Bit-banding | Yes | Architecture-specific |
| Atomic instructions/intrinsics | Yes | Architecture-specific |
volatile alone | No | Only stops compiler optimisation |
13. Quick Revision Cheat Sheet
The big three
reg |= (1U << n); // SET bit n
reg &= ~(1U << n); // CLEAR bit n
reg ^= (1U << n); // TOGGLE bit n
Check a bit
if (reg & (1U << n)) { /* bit is 1 */ }
Bit fields
#define MASK (((1U << WIDTH) - 1U) << POS)
value = (reg & MASK) >> POS; // extract
reg = (reg & ~MASK) | ((newVal << POS) & MASK); // insert
Register access
#define REG (*(volatile uint32_t *)0xADDRESSU)
Operator purpose at a glance
| Operator | Use it to |
|---|---|
& | Clear bits, test bits, extract fields |
| | Set bits, combine fields |
^ | Toggle bits, find differences |
~ | Create inverted masks |
<< | Build masks, position fields (Γ2βΏ) |
>> | Move fields down to bit 0 (Γ·2βΏ) |
Golden rules
- Use unsigned types and
U/ULsuffixes for bit work. - Always wrap bitwise expressions in parentheses.
- Give masks and positions names. Avoid magic numbers.
- To change a multi-bit field: clear first, then set.
- Hardware registers are
volatile. No exceptions. volatileβ atomic. Protect read-modify-write when interrupts or other masters can touch the same register.- Be careful with write-1-to-clear, read-clears and write-only registers. Check the datasheet.
- Match the register's width with the right type.
Common interview-style quick checks
| Question | Short answer |
|---|---|
| How do you set bit 4? | reg |= (1U << 4); |
| How do you clear bit 4? | reg &= ~(1U << 4); |
| How do you toggle bit 4? | reg ^= (1U << 4); |
| How do you test bit 4? | if (reg & (1U << 4)) |
What does x << 3 do? | Multiplies by 8 (if no overflow) |
Why volatile? | Stops the compiler from caching/removing/reordering register accesses |
Is reg |= bit atomic? | Not generally. It is a read-modify-write |
#GPIO β General-Purpose Input/Output#
GPIO pins are the most basic way a microcontroller touches the outside world. Each pin is a tiny metal leg on the chip that your code can turn into a digital input (to listen, e.g., "is the button pressed?") or a digital output (to speak, e.g., "turn the LED on"). "General-purpose" means the pin isn't tied to one job: you decide what it does.
These notes take you from "what is a pin?" all the way to configuring pins safely, with the real-world details (resistors, debouncing, current limits) that decide whether your circuit works or misbehaves.
Table of Contents
- GPIO Ports, Pins and Registers
- Input, Output and Bidirectional Modes
- Pull-Up and Pull-Down Resistors
- Push-Pull and Open-Drain Outputs
- Reading Switches and Driving LEDs
- Button Debouncing
- Active-High vs Active-Low Signals
- Pin Multiplexing and Alternate Functions
- Interrupt-Capable GPIO
- Input/Output Current Limitations
- GPIO Initialization and Safe States
- Quick Revision Cheat Sheet
1. GPIO Ports, Pins and Registers
Pins and ports
- A pin is one physical GPIO line. It carries one digital signal: HIGH (close to the supply voltage) or LOW (close to 0 V).
- A port is a group of pins managed together, typically 8, 16 or 32, so the CPU can read or write them using one register. Ports are usually named by letter or number:
PORTA,PORTB, orP0,P1. - A pin is referred to as port + number, for example PA5 (port A, pin 5) or P1.3.
PORT A β PA0 PA1 PA2 PA3 PA4 PA5 PA6 PA7
β
"PA5": one pin of port A
Voltage levels
| Logic level | Meaning | Roughly |
|---|---|---|
| HIGH (1) | Pin is near the supply voltage (e.g., 3.3 V or 5 V) | Logic 1 |
| LOW (0) | Pin is near ground | Logic 0 |
The exact thresholds an input considers HIGH or LOW are given in the datasheet (often written VIH and VIL). Voltages in between are an undefined zone, which is one reason floating inputs misbehave (see Section 3).
The registers that control a port
Every GPIO peripheral is controlled by memory-mapped registers. Names differ between chip families, but the roles are very similar:
| Register role | Typical name(s) | What it does |
|---|---|---|
| Direction / mode | DDR, TRIS, MODER, DIR | Chooses each pin as input or output (and other modes) |
| Output data | PORT, LAT, ODR, OUT | The value you want a pin to drive when it is an output |
| Input data | PIN, PORT(read), IDR, IN | The actual voltage level currently seen on each pin |
| Pull configuration | PUPDR, PUR, PUE | Enables internal pull-up or pull-down resistors |
| Output type | OTYPER, ODCON | Push-pull or open-drain |
| Output speed / drive strength | OSPEEDR, DRIVE | Controls edge speed or current strength |
| Alternate function select | AFR, PINSEL, PMUX | Hands the pin to a peripheral (UART, SPIβ¦) |
| Set / clear registers | BSRR, SET, CLR | Change individual output bits without disturbing others |
| Interrupt config | EXTI, IES, IE, IFG | Edge/level selection, enable and flag for pin interrupts |
Tip: Always read the datasheet or reference manual for your exact chip. The ideas are universal, but the register names and bit layouts are not.
Typical code pattern
Each register is typically 8, 16 or 32 bits wide, with one bit (or a small group of bits) per pin. You use the bit-manipulation techniques (set, clear, toggle, check) on them.
// Generic example, names vary per chip
#define GPIOA_DIR (*(volatile uint32_t *)0x40020000U)
#define GPIOA_OUT (*(volatile uint32_t *)0x40020004U)
#define GPIOA_IN (*(volatile uint32_t *)0x40020008U)
GPIOA_DIR |= (1U << 5); // PA5 as output
GPIOA_OUT |= (1U << 5); // drive PA5 HIGH
GPIOA_OUT &= ~(1U << 5); // drive PA5 LOW
if (GPIOA_IN & (1U << 2)) { /* PA2 reads HIGH */ }
Registers are declared volatile because the hardware can change them and reads/writes have real effects.
Output register vs input register: an important difference
- Writing the output register sets what the pin tries to drive.
- Reading the input register tells you what voltage is actually on the pin.
These can differ! For example, if you try to drive a pin HIGH but something external is pulling it LOW (a short circuit or an open-drain pin with no pull-up), the input register will show 0.
Many chips also power gate the peripheral
On many microcontrollers, a GPIO port's clock must be enabled before you can use its registers. If you forget, writes appear to do nothing.
2. Input, Output and Bidirectional Modes
Input mode
The pin senses the outside voltage. Internally, the pin connects to a high-impedance input buffer: it draws almost no current and does not try to force the voltage.
- Use for: buttons, switches, digital sensors, logic signals from other chips.
- You read the value from the input data register.
Output mode
The pin drives the voltage: HIGH or LOW, according to the output register. It can source or sink a limited amount of current (see Section 10).
- Use for: LEDs, transistors/MOSFET gates, buzzers (through drivers), logic signals to other chips.
- You write the value to the output data register.
Bidirectional mode
A bidirectional pin switches between input and output at runtime, using the same physical pin for both directions. The code changes the direction register as needed.
Typical uses:
- Shared data lines (e.g., a single-wire protocol where the same wire carries data both ways)
- Reading a sensor on the same pin that was just used to trigger it
- Saving pins when the chip has few
// Drive the line LOW briefly to signal a device, then listen for the reply
DIR |= (1U << PIN); // output
OUT &= ~(1U << PIN); // drive low
delay_us(500);
DIR &= ~(1U << PIN); // switch to input and release the line
// ... read the response ...
Other modes you will see in datasheets
| Mode | Description |
|---|---|
| Analog | Pin is connected to an ADC/DAC or comparator; the digital input buffer is disabled to save power and avoid interference |
| Alternate function | Pin is handed over to a peripheral like UART or SPI (Section 8) |
| Floating / high-impedance input | Input with no pull resistor (Section 3) |
| Input with pull-up / pull-down | Input with an internal resistor to the supply or ground |
| Output push-pull / open-drain | The two output styles (Section 4) |
Caution when switching direction
- When a pin flips from input to output, the output register's current value appears on the pin immediately. Set the output register to the desired level before changing direction to output so you don't create a glitch or an unwanted pulse.
- When a pin is an input, writing to its output register usually doesn't change the pin, but it often preloads the value used when the pin becomes an output later (and on some chips controls the pull-up/pull-down choice).
3. Pull-Up and Pull-Down Resistors
The floating input problem
An input pin that is connected to nothing is floating. Its voltage is not defined: it drifts due to noise, nearby wires and even your finger. The code might read random 0s and 1s.
MCU pin ββββ (connected to nothing) ββββ reads garbage
The fix: give the pin a default level
A pull resistor gently pulls the pin to a known level when nothing else is driving it.
| Type | Connects pin to | Default (idle) level |
|---|---|---|
| Pull-up | Supply voltage (VCC) through a resistor | HIGH |
| Pull-down | Ground (GND) through a resistor | LOW |
Typical button circuits
With a pull-up (button connects pin to ground):
VCC ββ[R pull-up]βββ¬ββ MCU pin
β
[Button]
β
GND
Not pressed β pin pulled HIGH (reads 1)
Pressed β pin shorted to GND (reads 0)
With a pull-down (button connects pin to VCC):
VCC ββ[Button]βββ¬ββ MCU pin
β
[R pull-down]
β
GND
Not pressed β pin pulled LOW (reads 0)
Pressed β pin connected to VCC (reads 1)
Why a resistor and not a direct wire?
The resistor is "weak": it sets the default level but lets the button (or any driver) easily override it. If you wired the pin straight to VCC, pressing a button to GND would create a short circuit.
Internal vs external resistors
| Internal (built into MCU) | External (on the board) | |
|---|---|---|
| Enabled by | A register bit | Soldering a resistor |
| Cost / board space | Free | Extra part |
| Typical value | Tens of kΞ© (e.g., 20β50 kΞ©, check datasheet) | You choose (commonly 4.7 kΞ©β100 kΞ©) |
| Best for | Simple buttons, short wires | Noisy environments, long wires, tighter control over speed/strength |
// Enable internal pull-up on PA2 (register names vary per chip)
PULL_CFG |= PULLUP_ENABLE_FOR(2);
Choosing the value
- Larger resistor (e.g., 100 kΞ©): less current wasted when the line is pulled to the opposite level and lower power, but the line is "weaker" and more sensitive to noise and slow to recover.
- Smaller resistor (e.g., 4.7 kΞ©): more noise-resistant and faster, but wastes more current when the button is pressed.
Remember
- Never leave an input floating. Either use a pull resistor, or drive it from something that always outputs a defined level.
- Unused pins are commonly set to a safe configuration (see Section 11).
4. Push-Pull and Open-Drain Outputs
Output pins can be built in two different ways. The difference is in how they produce a HIGH.
Push-pull output
Inside the pin there are two transistors: one connected to VCC (the "push" side), one to GND (the "pull" side).
VCC
β
[P-FET]βββ
βββ Pin
[N-FET]βββ
β
GND
- Writing
1β the upper transistor turns on β the pin is actively driven HIGH - Writing
0β the lower transistor turns on β the pin is actively driven LOW
The pin is always firmly driven in both directions. It is the default choice for LEDs, logic signals and anything with a single driver.
Open-drain output
Only the lower transistor exists (or is used). The pin can pull the line to LOW, but it cannot drive HIGH; instead it goes high-impedance ("released").
(external pull-up resistor to VCC)
β
Pin βββββ¬ββ
β
[N-FET]
β
GND
- Writing
0β transistor ON β pin pulled LOW - Writing
1β transistor OFF β pin floats, and an external (or internal) pull-up resistor pulls the line HIGH
(An "open-collector" output is the same idea using a bipolar transistor.)
An open-drain pin requires a pull-up to ever reach a HIGH level. Without one, the line floats when "high".
Why use open-drain?
| Advantage | Explanation |
|---|---|
| Wired-AND (wired-OR) buses | Multiple devices can share one line. Any device can pull it LOW; the line is HIGH only when all of them release it. No device ever fights another by driving HIGH against a LOW. |
| Level shifting | The pull-up can go to a different voltage than the chip's supply (within the pin's voltage tolerance), so a 3.3 V device can talk to a 5 V-pulled line, if the pin is rated for it. |
| Safe bidirectional lines | A single wire can be used for both directions: release it to listen. |
| Driving certain loads | Handy for sinking current from loads connected to a higher supply (check the pin's voltage rating). |
Shared-bus protocols such as IΒ²C use open-drain lines with pull-ups for exactly this reason.
Comparison
| Feature | Push-pull | Open-drain |
|---|---|---|
| Can drive HIGH actively | Yes | No (needs pull-up) |
| Can drive LOW actively | Yes | Yes |
| Several outputs on one wire | No: conflict if one drives HIGH and another LOW (a short-circuit current) | Yes |
| Switching speed (rising edge) | Fast | Slower; depends on pull-up value and line capacitance |
| Power | No static current in HIGH | Current flows through the pull-up when pulled LOW |
| Typical use | LEDs, logic outputs | Shared buses, level shifting, wired-AND |
Setting it in code
OUTPUT_TYPE |= (1U << PIN); // select open-drain (register name varies)
OUTPUT_TYPE &= ~(1U << PIN); // select push-pull
5. Reading Switches and Driving LEDs
Driving an LED
An LED is a diode: it needs a current-limiting resistor in series, otherwise too much current flows and damages the LED and/or the pin.
Two ways to wire an LED:
(a) Current SOURCING: pin HIGH turns LED ON
MCU pin ββ[R]βββΊ|ββ GND
LED
(b) Current SINKING: pin LOW turns LED ON
VCC ββ[R]βββΊ|ββ MCU pin
LED
| Wiring | LED ON when pin is | Pin's job |
|---|---|---|
| (a) pin β R β LED β GND | HIGH | Sources current |
| (b) VCC β R β LED β pin | LOW | Sinks current |
Many chips can sink a bit more current than they can source (or the reverse), so check the datasheet. Option (b) is common.
Choosing the resistor
Use Ohm's law on the voltage that is left over after the LED's own forward voltage drop:
R = (V_supply β V_LED_forward) / I_LED
Example: 3.3 V supply, red LED with about 2.0 V forward drop, desired 5 mA:
R = (3.3 β 2.0) / 0.005 = 260 Ξ© β use the next standard value, e.g., 270 Ξ©
LEDs are often bright enough at a few mA. Lower current also respects the pin limits (Section 10).
LED code
#define LED_PIN 5
// Setup
DIR |= (1U << LED_PIN); // output
// Active-high wiring (a)
OUT |= (1U << LED_PIN); // ON
OUT &= ~(1U << LED_PIN); // OFF
OUT ^= (1U << LED_PIN); // toggle (blink)
Driving bigger loads
Never drive motors, relays, buzzers or high-power LEDs straight from a GPIO pin. Use the pin to control a transistor, MOSFET or driver IC that switches the load from a proper supply.
Reading a switch
- Configure the pin as an input.
- Give it a defined idle level (pull-up or pull-down).
- Read the pin and interpret the level according to your wiring.
#define BTN_PIN 2
// Setup: input with internal pull-up (button wired to GND)
DIR &= ~(1U << BTN_PIN);
enable_pullup(BTN_PIN);
// Read
if ((IN & (1U << BTN_PIN)) == 0) {
// pin is LOW β button is pressed (pull-up wiring)
}
Simple button-controls-LED example
while (1) {
if ((IN & (1U << BTN_PIN)) == 0) { // pressed (active-low)
OUT |= (1U << LED_PIN); // LED on
} else {
OUT &= ~(1U << LED_PIN); // LED off
}
}
Detecting a press event vs. a held state
Reading the level tells you if the button is currently down. To detect a press event (the moment it goes from released to pressed), remember the previous state:
uint8_t prev = 1; // released (pull-up)
while (1) {
uint8_t now = (IN >> BTN_PIN) & 1U;
if (prev == 1 && now == 0) {
// falling edge β button just pressed
OUT ^= (1U << LED_PIN); // toggle LED once per press
}
prev = now;
}
Without debouncing, this will misfire. See the next section.
6. Button Debouncing
The problem: mechanical bounce
A mechanical switch doesn't change state cleanly. When the contacts meet, they bounce, rapidly connecting and disconnecting for a few milliseconds before settling.
Ideal: βΎβΎβΎβΎβΎβΎβΎβΎ\_______________
Actual: βΎβΎβΎβΎβΎβΎβΎβΎ\_/βΎ\_/\__/\_____
β bounce (about 1β20 ms, varies by switch)
The microcontroller is fast enough to see every bounce as a separate press. A single click might be counted as 3, 5 or 10 presses.
Solutions
1. Software delay debounce (simplest)
When you notice a change, wait a short time (e.g., 10β50 ms), then check again.
if (button_is_pressed()) {
delay_ms(20); // wait for bounce to settle
if (button_is_pressed()) {
// confirmed press
while (button_is_pressed()) { } // optional: wait for release
delay_ms(20); // debounce the release too
}
}
- Pros: very easy.
- Cons:
delay_msblocks the CPU. Nothing else can run during the wait.
2. Counter / sampling debounce (non-blocking)
Sample the pin at regular intervals (e.g., every 1β5 ms from a timer). Only accept a new state if it has been stable for N consecutive samples.
#define STABLE_COUNT 5 // 5 samples Γ 2 ms = 10 ms stable
static uint8_t stable_state = 1; // debounced state (1 = released)
static uint8_t counter = 0;
void debounce_tick(void) // call every 2 ms
{
uint8_t raw = (IN >> BTN_PIN) & 1U;
if (raw == stable_state) {
counter = 0; // no change: reset
} else {
if (++counter >= STABLE_COUNT) { // new state held long enough
stable_state = raw;
counter = 0;
// stable_state has really changed β handle press/release here
}
}
}
- Pros: non-blocking, reliable, works for several buttons at once.
- Cons: needs a periodic timer or a regular call.
3. Shift-register debounce
Keep an 8-bit history of recent samples, shifting in the newest each tick. Decide "pressed" when the history reaches a particular pattern (e.g., all zeros) and "released" when all ones.
static uint8_t history = 0xFF;
void debounce_tick(void)
{
history = (uint8_t)((history << 1) | ((IN >> BTN_PIN) & 1U));
if (history == 0x00) { /* stable pressed */ }
if (history == 0xFF) { /* stable released */ }
}
4. Hardware debounce
An RC filter (a resistor and capacitor) smooths the bounce so the pin sees a clean edge. It is often followed by a Schmitt-trigger input or buffer to turn the slow, soft edge into a sharp, decisive transition.
VCC ββ[R pull-up]βββ¬βββββ¬ββ MCU pin (ideally Schmitt-trigger input)
β β
[Button][C]
β β
GND GND
- Pros: no CPU time or code needed.
- Cons: extra components; fixed behaviour.
Dedicated debounce ICs and SR latch (set-reset) circuits are other hardware options.
Which to choose?
| Method | CPU cost | Parts cost | Best for |
|---|---|---|---|
| Blocking delay | Wastes CPU time | None | Quick tests, trivial programs |
| Sampling / counter | Low | None | Most real firmware |
| Shift register | Very low | None | Compact, multi-button code |
| Hardware RC | None | Small | When you can add parts or software time is scarce |
Tips
- Debounce both press and release.
- Typical settle time is a few ms to tens of ms. Test your actual switch.
- Debouncing is crucial for counting presses and toggling things. For a "while held, do X" function it matters less, but it still prevents flicker.
- Debouncing also matters for interrupt-driven buttons (Section 9), where one press can trigger many interrupts.
7. Active-High vs Active-Low Signals
The idea
"Active" means "the condition is true / the function is ON". The question is: which voltage level represents that?
| Type | Active when⦠| Idle (inactive) level |
|---|---|---|
| Active-high | Signal is HIGH (1) | LOW (0) |
| Active-low | Signal is LOW (0) | HIGH (1) |
Examples
| Situation | Active-high | Active-low |
|---|---|---|
| LED | Pin β R β LED β GND: HIGH turns it ON | VCC β R β LED β pin: LOW turns it ON |
| Button | Pull-down + button to VCC: pressed = 1 | Pull-up + button to GND: pressed = 0 |
| Chip enable / reset pins | Device enabled when pin is 1 | Device enabled when pin is 0 |
Notation
Active-low signals are marked in schematics and datasheets with:
- A bar over the name: RESET
- A trailing
#or_Norn:CS#,RESET_N,nCS - A small circle (bubble) at the pin of a symbol
Why active-low is so common
- Many logic families can sink more current than they source, which is better for driving LEDs and loads.
- A pull-up is the common default for an idle line, so "idle = HIGH, pressed/asserted = LOW" fits naturally.
- Open-drain lines can only actively drive LOW, so wired signals like interrupts or resets are naturally active-low.
- A broken wire or unpowered device tends to leave a pulled-up line in its idle (inactive) state, a safer failure mode in many designs.
Writing clean code: hide the polarity
Don't scatter == 0 and == 1 across your program. Define the meaning once:
#define LED_ACTIVE_LOW 1
static inline void led_on(void)
{
#if LED_ACTIVE_LOW
OUT &= ~(1U << LED_PIN);
#else
OUT |= (1U << LED_PIN);
#endif
}
static inline void led_off(void)
{
#if LED_ACTIVE_LOW
OUT |= (1U << LED_PIN);
#else
OUT &= ~(1U << LED_PIN);
#endif
}
static inline uint8_t button_pressed(void)
{
return ((IN >> BTN_PIN) & 1U) == 0U; // active-low button
}
If the hardware polarity changes, you edit one place. Your main logic simply reads led_on() and button_pressed().
Common bug
Treating a pull-up button as active-high: the LED stays on when you don't press, or a "pressed" check is inverted. When something behaves "backwards", check polarity first.
8. Pin Multiplexing and Alternate Functions
Why multiplexing exists
Microcontrollers have many peripherals (UART, SPI, IΒ²C, timers, ADC, PWMβ¦) but a limited number of physical pins. To make the most of each pin, each pin can serve several different functions, and you choose which one is active. This is pin multiplexing ("pin muxing").
ββ GPIO (plain I/O)
PA9 pin βββMUXββΌβ UART1 TX
ββ Timer1 channel 2
ββ other peripheral signal
Alternate functions
The plain GPIO role is the default. The other roles are called alternate functions (AF). When a pin is set to an alternate function, the pin is controlled by the peripheral, not by your GPIO data registers.
| Mode | Who controls the pin |
|---|---|
| GPIO input/output | Your code via the GPIO registers |
| Alternate function | The selected peripheral (UART, SPI, IΒ²C, timerβ¦) |
| Analog | The analog block (ADC/DAC/comparator) |
How you configure it
Typically in two steps:
- Set the pin's mode to "alternate function".
- Select which alternate function (often AF0, AF1, AF2β¦ in an alternate function register).
// Illustrative pattern (names/bits depend on the chip)
MODE_REG = (MODE_REG & ~MODE_MASK(9)) | MODE_ALTFUNC(9); // PA9 β alternate function
AF_REG_HIGH = (AF_REG_HIGH & ~AF_MASK(9)) | AF_SELECT(9, 7); // pick AF7 (e.g., UART TX)
Some chips use a single "function select" field per pin, or a central pin-control/I/O-routing block.
Things to watch
- Check the pin table in the datasheet to see which functions each pin offers. You can't make any pin do any job.
- Resolve conflicts: one pin can serve only one function at a time, and a peripheral's signals must land on specific pins. Plan pin assignments early.
- Enable clocks for both the GPIO port and the peripheral.
- Configure the electrical settings that suit the function: for example, open-drain with pull-up for IΒ²C, push-pull for SPI/UART outputs, a suitable output speed for fast signals.
- Some pins have special functions at reset (debug/programming interface, boot selection). Re-purposing them can lock you out of debugging, so be careful.
- Some peripherals can be remapped to alternative pin sets, which gives layout flexibility.
Common pitfall
"My UART transmits nothing": often the pin is still in GPIO mode, the wrong alternate function number was chosen, or the port clock was not enabled.
9. Interrupt-Capable GPIO
Polling vs interrupts
| Polling | Interrupt | |
|---|---|---|
| How | Code repeatedly reads the pin | Hardware notifies the CPU when something happens on the pin |
| CPU use | Constantly busy checking | CPU is free until the event |
| Latency | Depends on loop speed | Fast, near-immediate |
| Low power | Hard | Easy: sleep until a pin event wakes the CPU |
| Complexity | Simple | Needs ISR care |
Trigger types
An interrupt-capable pin can be configured to trigger on:
| Trigger | When the interrupt fires |
|---|---|
| Rising edge | Pin goes LOW β HIGH |
| Falling edge | Pin goes HIGH β LOW (e.g., button press with pull-up) |
| Both edges | Any change |
| Low level | While the pin is LOW |
| High level | While the pin is HIGH |
Edge triggers react to the change; level triggers react as long as the state persists (so the ISR will keep re-firing unless the source is removed or the interrupt is masked).
Typical setup steps
- Configure the pin as input (with the proper pull-up/down).
- Select the trigger (edge or level).
- Clear any pending flag (to avoid a false initial interrupt).
- Enable the interrupt for that pin (pin-level enable).
- Enable the line in the interrupt controller and set its priority.
- Make sure global interrupts are enabled.
// Generic pattern
DIR &= ~(1U << BTN_PIN); // input
enable_pullup(BTN_PIN);
INT_EDGE_FALLING(BTN_PIN); // trigger on falling edge
INT_FLAG_CLEAR(BTN_PIN); // clear stale flag
INT_ENABLE(BTN_PIN); // pin interrupt on
nvic_enable(BTN_IRQ); // interrupt controller on
Writing the ISR
volatile uint8_t button_event = 0; // volatile: shared with main code
void BTN_IRQ_Handler(void)
{
if (INT_FLAG(BTN_PIN)) {
INT_FLAG_CLEAR(BTN_PIN); // acknowledge, or it fires forever
button_event = 1; // just set a flag
}
}
int main(void)
{
// ... init ...
while (1) {
if (button_event) {
button_event = 0;
// do the real work here, outside the ISR
}
}
}
Good ISR habits
- Keep ISRs short. Set a flag, record a timestamp, then return. Do heavy work in the main loop.
- Clear the interrupt flag as the datasheet requires, otherwise the interrupt re-triggers immediately.
- Use
volatilefor any variable shared between an ISR and main code. - Protect multi-step updates of shared data against being interrupted mid-way.
- Avoid blocking calls and long delays inside an ISR.
Shared interrupt lines
On many chips, several pins share one interrupt line (for example, all pin number 3s across ports, or all pins of one port). The ISR must then check which pin caused it using the pending/flag register.
Interrupts and bounce
A bouncing button can fire the ISR many times per press. Handle this by:
- Ignoring further interrupts for a short period after the first one (e.g., a timestamp check or a timer),
- Disabling that pin's interrupt briefly and re-enabling after a debounce timer,
- Using hardware debouncing.
Wake-up from sleep
Interrupt-capable pins can wake the microcontroller from low-power modes, which makes them ideal for battery-powered devices that wait for a button or sensor signal. Not every pin supports every wake-up mode, so check the datasheet.
Pulse width caveat
Very short pulses may be missed (or may not be latched) depending on the hardware. Edge detection and flag latching generally capture short pulses, while level triggering depends on how long the level lasts.
10. Input/Output Current Limitations
GPIO pins are signal pins, not power pins. They can only handle small currents, and exceeding the limits can cause wrong logic levels, overheating or permanent damage.
Key terms
| Term | Meaning |
|---|---|
| Source current (IOH) | Current flowing out of the pin into a load when the pin is HIGH |
| Sink current (IOL) | Current flowing into the pin from a load when the pin is LOW |
| Per-pin limit | Max current for one pin (often a few mA to ~20β25 mA, chip dependent) |
| Per-port / total limit | Max combined current through a group of pins or the whole chip's supply pins |
| VOH / VOL | Guaranteed output HIGH/LOW voltage at a given load current |
| Input leakage | Tiny current into/out of an input pin (usually microamps or less) |
| Absolute maximum ratings | Limits that must never be exceeded, even briefly |
Sourcing and sinking
SOURCING (pin HIGH): SINKING (pin LOW):
MCU pin βββΊ load βββΊ GND VCC βββΊ load βββΊ MCU pin
current flows OUT of the pin current flows INTO the pin
What happens as the current rises
The more current you draw, the more the pin's output voltage sags (HIGH drops below VCC, LOW rises above 0 V). Beyond a point, the logic levels are no longer reliable for downstream inputs, and heating can damage the pin.
Design rules
- Always check the datasheet for per-pin and total limits. They differ widely between chips.
- Use current-limiting resistors for LEDs and other direct loads.
- Add up the currents. Many LEDs on one port can exceed the port or chip total even if each is within its own limit.
- Use a transistor/MOSFET/driver for motors, relays, buzzers, strips of LEDs or anything that needs more than a few mA.
- Add flyback protection (e.g., a diode) when switching inductive loads such as relays and motors.
- Don't connect two push-pull outputs directly together: if one drives HIGH and the other LOW, a large short-circuit current flows.
- Respect input voltage limits. Never apply a voltage above the pin's rated maximum or below ground. Some pins are 5 V tolerant while others aren't, so check the "tolerant" specification.
- Use series resistors on inputs exposed to the outside world to limit current if the voltage goes out of range, and consider ESD/transient protection for exposed connectors.
- Mind the output speed/drive setting. Higher drive strength or faster edges can increase supply noise and EMI.
Quick LED current example
If the chip's per-pin limit is 20 mA but a total limit across a port is 80 mA, eight LEDs at 15 mA each (120 mA total) would be fine individually yet exceed the port limit. Lower the current (e.g., 3β5 mA each) or use a driver.
Inputs also have limits
- Input pins draw almost nothing, but the input voltage must stay within the allowed range.
- When an input is driven from a different supply voltage (such as a 5 V device into a 3.3 V pin), use a level shifter, a resistor divider or an open-drain/pull-up scheme, as the datasheet allows.
11. GPIO Initialization and Safe States
Why initialization matters
At power-up, pins are typically in a default state (often a high-impedance input, but it varies by chip). Until your code runs, whatever is wired to the pins may see a floating or undefined level. A badly ordered setup can cause glitches, like a motor twitching or a relay clicking at boot.
A safe, repeatable initialization order
- Enable the clock for the GPIO port (if the chip requires it).
- Preload the output level into the output data register: the value you want the pin to have when it becomes an output.
- Choose the output type (push-pull or open-drain) and the speed/drive strength.
- Configure pulls for inputs (pull-up/down) or disable them where not needed.
- Set the direction/mode last (input, output, alternate function, analog). This is the moment the pin becomes active, so everything else should already be ready.
- If using alternate functions, select the function and enable the peripheral.
- If using interrupts, configure trigger, clear flags, then enable the interrupt.
// Example: LED on PA5 (active-low wiring: LOW = ON), start with LED OFF
GPIO_CLOCK_ENABLE(PORTA);
OUT |= (1U << 5); // preload HIGH β LED OFF (BEFORE enabling output)
OUTPUT_TYPE &= ~(1U << 5); // push-pull
SPEED_LOW(5); // slow edges are fine for an LED
PULL_NONE(5);
DIR |= (1U << 5); // finally make it an output
Because the output value was preloaded, the LED never flashes during setup.
What is a "safe state"?
A safe state is the pin level/mode in which the connected hardware does no harm and consumes minimal power. It depends on what is attached:
| Connected hardware | Typical safe state |
|---|---|
| LED | Off |
| Motor driver / H-bridge enable | Disabled (motor stopped) |
| Relay or heater control | Off (de-energised) |
| Chip-select line of a peripheral | Inactive (deselected), often HIGH for active-low CS |
| Reset line of another device | Not asserted (or whichever level keeps the system stable) |
| Buzzer | Off |
Design so that the default level = safe level, which can be reinforced with an external pull resistor so that even during reset or before your code runs, the line sits in the safe state.
Pins at reset vs. after your init
- During and just after reset, most pins are high-impedance inputs. A pull-up or pull-down resistor on the board keeps critical outputs (relay drivers, enables, chip-selects) at a defined level until firmware takes over.
- Some chips have boot-strap pins read at reset to choose boot mode; avoid connecting loads that would disturb their levels.
Unused pins
Don't leave unused pins floating (floating inputs can draw extra current and cause noise). Common safe choices, per the datasheet's recommendations:
- Configure as output driving a defined level (if not connected to anything), or
- Configure as input with an internal pull-up/pull-down enabled, or
- Configure as analog mode (input buffer off), when supported.
Safe state on faults or shutdown
Code can also return pins to the safe state when something goes wrong: before entering a low-power mode, on error handling or in a fault/emergency routine.
void outputs_safe_state(void)
{
OUT |= (1U << LED_PIN); // LED off (active-low)
OUT &= ~(1U << MOTOR_EN_PIN); // motor disabled
OUT &= ~(1U << RELAY_PIN); // relay off
}
Many chips also offer a watchdog or fault-protection system that forces known pin states if the program hangs. Where available, consider it for outputs that control anything hazardous.
Initialization checklist
- Port clock enabled
- Output level preloaded before switching to output
- Output type, speed, and pulls set deliberately
- Direction/mode set last
- Alternate functions selected and peripherals enabled where needed
- Interrupts configured (edge/level, flag cleared, enabled)
- Unused pins put in a defined state
- Power-up/reset default state of every output is safe
12. Quick Revision Cheat Sheet
Core concepts
| Term | One-line meaning |
|---|---|
| Pin | One physical I/O line |
| Port | A group of pins controlled by shared registers (e.g., PORTA) |
| Direction register | Chooses input vs output per pin |
| Output register | Value the pin drives when it is an output |
| Input register | Actual voltage level seen on the pin |
| Pull-up / pull-down | Weak resistor giving an input a default HIGH / LOW |
| Push-pull | Output actively drives both HIGH and LOW |
| Open-drain | Output only pulls LOW, relies on a pull-up for HIGH |
| Active-high / low | Which level means "ON/true" |
| Alternate function | Pin handed to a peripheral such as UART or SPI |
| Debouncing | Ignoring the rapid on/off chatter of a mechanical switch |
| Source / sink | Current out of / into the pin |
Everyday code patterns
DIR |= (1U << n); // pin n β output
DIR &= ~(1U << n); // pin n β input
OUT |= (1U << n); // drive HIGH
OUT &= ~(1U << n); // drive LOW
OUT ^= (1U << n); // toggle
if (IN & (1U << n)) { } // read: is it HIGH?
Button wiring at a glance
| Wiring | Idle reads | Pressed reads | Polarity |
|---|---|---|---|
| Pull-up + button to GND | 1 | 0 | Active-low |
| Pull-down + button to VCC | 0 | 1 | Active-high |
Golden rules
- Never leave an input floating. Use a pull resistor.
- Always use a series resistor with an LED.
- Check the pin's current limits and the port/chip totals. Use a driver for anything heavy.
- Open-drain needs a pull-up.
- Debounce every mechanical switch (software or hardware).
- Keep ISRs short, clear the flag and use
volatilefor shared variables. - Preload the output value before enabling output to avoid glitches.
- Set the default and reset states to be safe for whatever is connected.
- Enable the port clock and select the correct alternate function before expecting a peripheral to work.
- Hide polarity (active-high/low) behind helper functions or macros.
#Timers, Counters and PWM#
Almost everything in embedded systems depends on time: blinking an LED every second, dimming it smoothly, spinning a motor at a chosen speed, measuring how long a button was held, or resetting the system if the software freezes. Doing all of that by making the CPU "count in a loop" would be wasteful and inaccurate. Instead, microcontrollers include timer/counter hardware that counts on its own, in parallel with your code, and tells you when something has happened.
These notes explain how that hardware works and how to use it, step by step.
Table of Contents
- Timer and Counter Fundamentals
- Prescalers and Clock Sources
- Timer Overflow and Compare Match
- Input Capture and Output Compare
- Periodic Interrupts
- Delay Generation Using Timers
- Hardware vs Software Delays
- PWM Generation
- Duty Cycle and Frequency Calculations
- PWM for LED Dimming and Motor Control
- Input Pulse Measurement
- Watchdog Timer
- Real-Time Clock Basics
- Quick Revision Cheat Sheet
1. Timer and Counter Fundamentals
What is a timer/counter?
A timer/counter is a hardware register that increments (or decrements) automatically every time it receives a clock "tick". The CPU doesn't need to do anything for the count to advance. The CPU only reads or configures it.
clock ticks βββΊ [ Counter register: 0, 1, 2, 3, 4 β¦ ]
Timer vs. counter: same hardware, different input
| Name | What drives the count | What you measure |
|---|---|---|
| Timer | A regular, known-frequency clock (the MCU's own clock, possibly divided) | Time, because ticks are evenly spaced |
| Counter | An external signal on a pin (pulses, edges, events) | Number of events |
Because the counting hardware is identical, most MCUs call the peripheral just "Timer" and let you choose the clock source. When counting external pulses it acts as a counter.
Key parts of a timer
| Part | Role |
|---|---|
Counter register (often CNT, TCNT) | The current count value |
Compare / match register(s) (OCR, CCR, CMP) | Values the counter is compared against |
Auto-reload / top register (ARR, TOP, ICR) | The value at which the counter wraps or restarts |
| Prescaler | Divides the input clock to slow the counting |
Control register(s) (TCCR, CR1) | Start/stop, mode, direction, clock select |
Status / flag register (TIFR, SR) | Flags: overflow, compare match, capture |
| Interrupt enable bits | Allow those events to interrupt the CPU |
Counter width
The number of bits decides the maximum count before the counter wraps around:
| Width | Count range | Wraps after |
|---|---|---|
| 8-bit | 0 to 255 | 256 ticks |
| 16-bit | 0 to 65,535 | 65,536 ticks |
| 32-bit | 0 to 4,294,967,295 | about 4.29 billion ticks |
Counting direction and modes
| Mode | Behaviour |
|---|---|
| Up-counting | 0 β 1 β 2 β β¦ β top, then wraps back to 0 |
| Down-counting | top β β¦ β 1 β 0, then reloads |
| Up/down (center-aligned) | Counts up to top, then back down to 0, repeatedly |
Basic timing relationship
Each tick lasts one period of the timer clock:
Tick time = 1 / f_timer
Timer clock = f_cpu / prescaler
Example: 16 MHz clock, no prescaler β each tick = 1 / 16,000,000 = 62.5 ns.
A first look at configuring a timer
Most timers need roughly the same steps:
- Enable the timer's clock (on chips that gate peripheral clocks).
- Choose mode (normal, auto-reload, PWMβ¦) and direction.
- Set the prescaler.
- Set the top/reload and/or compare values.
- Clear flags and enable interrupts if needed.
- Start the timer.
// Generic pattern; register names vary by chip
TIMER_PRESCALER = 63; // divide the clock
TIMER_RELOAD = 999; // count 0..999
TIMER_CNT = 0; // start from zero
TIMER_CTRL |= TIMER_ENABLE; // go!
2. Prescalers and Clock Sources
Why a prescaler?
A fast clock makes a small counter overflow very quickly. For example, a 16-bit counter with a 16 MHz clock wraps in about 4 ms. To measure longer times (like 1 second) you need to slow the counting down. That is the job of the prescaler: a divider placed between the clock and the counter.
f_cpu βββΊ [ Prescaler Γ·N ] βββΊ f_timer βββΊ [ Counter ]
Prescaler effects
| Larger prescaler | Smaller prescaler |
|---|---|
| Slower ticks | Faster ticks |
| Longer maximum time before overflow | Shorter maximum time |
| Coarser resolution (each tick is longer) | Finer resolution |
This is a trade-off: range vs. resolution.
Worked example
16 MHz clock, 16-bit timer:
| Prescaler | Timer clock | Tick period | Overflow time (65,536 ticks) |
|---|---|---|---|
| 1 | 16 MHz | 62.5 ns | 4.096 ms |
| 8 | 2 MHz | 0.5 Β΅s | 32.768 ms |
| 64 | 250 kHz | 4 Β΅s | 262.144 ms |
| 256 | 62.5 kHz | 16 Β΅s | 1.048 s |
| 1024 | 15.625 kHz | 64 Β΅s | 4.194 s |
Common prescaler values are powers of two (1, 2, 4, 8, 16, 64, 256, 1024), though the allowed set depends on the chip. Some chips provide a prescaler register that accepts any integer value (divide by N+1).
Clock sources
A timer can usually count from more than one source:
| Source | Description | Typical use |
|---|---|---|
| Internal system/peripheral clock | Derived from the CPU/bus clock, optionally prescaled | Precise timing, delays, PWM |
| External pin (counter mode) | Rising and/or falling edges on a pin | Counting pulses, events, encoders, flow sensors |
| Low-frequency crystal (e.g., 32.768 kHz) | A slow, accurate oscillator | Real-time clock, low-power timing |
| Another timer's output | One timer feeds another ("chaining") | Extending range (e.g., two 16-bit timers act like a 32-bit one) |
| Internal RC oscillator | Built-in on-chip oscillator | Watchdog, simple timing when accuracy isn't critical |
Accuracy note: timing is only as good as its clock. Crystals are very accurate; internal RC oscillators drift with temperature and voltage.
Choosing the prescaler
Aim for the smallest prescaler that still lets the required time fit in the counter, which gives the best resolution.
Required ticks = desired_time Γ f_cpu / prescaler
Must be β€ (counter max + 1)
3. Timer Overflow and Compare Match
Two events are at the heart of nearly everything a timer does.
Timer overflow
An overflow occurs when the counter goes past its maximum value and wraps back to 0 (or past the auto-reload value in reload mode). The hardware sets an overflow flag and can raise an overflow interrupt.
count: β¦ 65533, 65534, 65535, 0 β overflow! (flag set), 1, 2 β¦
Time between overflows:
T_overflow = (counter_max + 1) Γ prescaler / f_cpu
With a reload value, replace (counter_max + 1) with (reload + 1).
Compare match
A compare match occurs when the counter equals a value stored in a compare register. The hardware sets a compare flag (and can trigger an interrupt) and can even automatically change an output pin, with no CPU involvement.
count: β¦ 497, 498, 499, 500 == COMPARE β match! flag set, 501 β¦
Why compare match is more flexible than overflow
| Overflow | Compare match | |
|---|---|---|
| Triggered when | Counter wraps | Counter equals a chosen value |
| Period | Fixed by counter width (unless reload used) | Any value you choose |
| Number of events per cycle | One | Several (many timers have multiple compare channels) |
CTC / auto-reload mode (clear on compare)
In this very useful mode the timer is reset to 0 when it hits the compare/top value, so you get an exact, programmable period:
count: 0 β 1 β β¦ β TOP ββ(match, flag, reset)βββΊ 0 β 1 β β¦
Period = (TOP + 1) Γ prescaler / f_cpu
Calculating the compare value
Want a 1 ms period with a 16 MHz clock and prescaler 64:
Timer clock = 16,000,000 / 64 = 250,000 Hz (tick = 4 Β΅s)
Ticks needed = 1 ms / 4 Β΅s = 250
TOP = 250 β 1 = 249 (counting 0..249 is 250 ticks)
The "β1" is important: counting from 0 to TOP takes TOP + 1 ticks.
Handling flags
- Flags usually have to be cleared, either automatically when the interrupt service routine (ISR) runs or by software. Some chips clear a flag by writing 1 to it, others clear it by reading the status register and then touching the data register. Check the datasheet.
- If you poll instead of using interrupts, remember to clear the flag each time or you'll see the event forever.
// Polling example
while (!(TIMER_STATUS & OVERFLOW_FLAG)) { } // wait for overflow
TIMER_STATUS = OVERFLOW_FLAG; // clear (write-1-to-clear style)
Overflow vs. compare: when to use which
- Overflow: simple periodic tick, counting long times by accumulating overflows.
- Compare match: exact periods, scheduling events at specific counts, generating output waveforms and PWM.
4. Input Capture and Output Compare
These are two special timer features that connect the counter to a pin, so timing can be done in hardware with great precision.
Input capture: "timestamp an external event"
When a chosen edge (rising, falling or both) occurs on a capture pin, the hardware copies the current counter value into a capture register at that exact moment. It then sets a flag and optionally raises an interrupt.
Pin: ____ββββββ____ββββββ
β β
Counter: tβ=1200 tβ=5200 β values latched in hardware
Since the capture happens in hardware, interrupt delay doesn't affect accuracy. Even if your ISR runs late, the stored timestamp is correct.
Uses:
- Measuring pulse width (time between rising and falling edge)
- Measuring period/frequency of a signal (time between two rising edges)
- Timestamping events (e.g., ultrasonic echo, tachometer pulses)
- Decoding signals (e.g., remote-control pulse trains)
Configuration points:
- Which edge triggers the capture
- Optional input noise filter
- Optional capture prescaler (capture every Nth event)
- Enable the capture interrupt and read the capture register in the ISR
Output compare: "do something to a pin at an exact time"
When the counter matches the compare register, the hardware can automatically act on an output pin, with no CPU needed:
| Output compare action | Effect on pin |
|---|---|
| Toggle | Flip the pin state on each match |
| Set (to 1) | Drive pin HIGH on match |
| Clear (to 0) | Drive pin LOW on match |
| None | Only set the flag/interrupt |
| Force | Software-triggered output action |
Uses:
- Generating a precise-frequency square wave (toggle on each compare match)
- Generating single pulses of an exact width
- Generating PWM (set/clear on matches, covered in Section 8)
- Scheduling pin changes at specific times
Example: square wave by toggling on compare match
Toggle on match, CTC mode with TOP = N:
output frequency = f_timer / (2 Γ (N + 1))
(The factor of 2 appears because the pin flips once per period, so a full cycle needs two matches.)
Summary
| Feature | Direction | Hardware does | You get |
|---|---|---|---|
| Input capture | Pin β timer | Latches counter value when pin edge occurs | Precise measurement of external timing |
| Output compare | Timer β pin | Changes pin when counter matches value | Precise generation of output timing |
5. Periodic Interrupts
The idea
A periodic interrupt is a timer interrupt that fires at a regular interval (for example, every 1 ms). It gives your firmware a steady heartbeat, a "system tick", to build time-based behaviour on.
How to produce one
- Configure the timer in compare/auto-reload (CTC) mode with the period you need (Section 3 calculation).
- Enable the timer interrupt (compare match or overflow).
- Enable the interrupt in the interrupt controller and enable global interrupts.
- Write the ISR (interrupt service routine).
volatile uint32_t ms_ticks = 0;
void TIMER_ISR(void) // runs every 1 ms
{
TIMER_STATUS = COMPARE_FLAG; // clear flag if required by the chip
ms_ticks++;
}
What to do in the ISR
Keep ISRs short. Typical safe actions:
- Increment a counter (e.g., milliseconds)
- Set a flag for the main loop
- Sample an input or update an output quickly
- Run a very short state update
Do heavy work (printing, long calculations, blocking waits) in the main loop.
Building software timers from the tick
With one hardware tick you can create many "software timers":
volatile uint32_t ms_ticks = 0; // volatile: changed in ISR, read in main
uint32_t now(void) { return ms_ticks; }
int main(void)
{
uint32_t last_led = 0;
while (1) {
if (now() - last_led >= 500) { // every 500 ms
last_led += 500;
LED_TOGGLE();
}
// other tasks keep running here, nothing is blocked
}
}
Overflow-safe time comparison: use
now() - start >= intervalwith unsigned variables. This works correctly even when the tick counter wraps around from its maximum back to 0. Avoid writingnow() >= start + interval, which breaks at the wrap.
Typical uses
- Scheduling tasks ("run every 10 ms")
- Debouncing buttons by sampling at regular intervals
- Sampling sensors at a fixed rate (ADC readings)
- Timeouts (communication, waiting for events)
- Software timers and timestamps
- Blinking indicators
Precautions
- Make the period longer than the ISR execution time, or interrupts will pile up.
- Variables shared between ISR and main code must be
volatile. - Reading a multi-byte counter (like a 32-bit tick on an 8-bit CPU) may need a short critical section (briefly disabling interrupts) so the ISR doesn't change it mid-read.
- Don't forget to clear the flag if the hardware requires it.
6. Delay Generation Using Timers
Three common approaches
A. Polling the timer flag (blocking, accurate)
Set up the timer for the desired delay, start it, and wait for the flag.
void delay_ms_timer(uint16_t ms)
{
while (ms--) {
TIMER_CNT = 0; // restart
while (!(TIMER_STATUS & MATCH_FLAG)) { } // wait for 1 ms compare match
TIMER_STATUS = MATCH_FLAG; // clear flag
}
}
- Pro: accurate; does not depend on how long instructions take.
- Con: the CPU is stuck waiting (blocking).
B. Using the system tick (non-blocking or lightly blocking)
Use the periodic interrupt's counter:
void delay_ms(uint32_t ms)
{
uint32_t start = ms_ticks;
while ((ms_ticks - start) < ms) { } // blocking, but timing from hardware tick
}
Better still, don't block. Check elapsed time in the main loop (as in Section 5) so other tasks keep running.
C. One-shot timer with interrupt
Start a timer configured to fire once after the delay, then do other things. The ISR signals completion.
timer_start_oneshot(ms_to_ticks(250)); // illustrative
// β¦ continue other work β¦
// ISR sets 'delay_done' flag when the time elapses
Long delays: combining overflows
For delays longer than one counter cycle, count multiple overflows or compare events:
Total delay = number_of_overflows Γ T_overflow (+ remainder using a compare value)
Or chain timers, or use a larger prescaler/32-bit timer.
Microsecond delays
Short delays (microseconds) can use a fast timer with a small prescaler and poll the counter:
void delay_us(uint16_t us)
{
uint16_t start = TIMER_CNT; // 1 tick = 1 Β΅s (set up beforehand)
while ((uint16_t)(TIMER_CNT - start) < us) { } // unsigned subtraction handles wrap-around
}
Tips for accuracy
- Calculate tick counts from the real clock frequency, and be careful about integer rounding.
- Remember ISR and instruction overhead in very short delays.
- A delay is a minimum: interrupts can lengthen a blocking delay that isn't protected.
7. Hardware vs Software Delays
Software delay (busy-wait loop)
The CPU wastes time by running instructions:
void delay_loop(volatile uint32_t count)
{
while (count--) { } // 'volatile' stops the compiler removing the loop
}
Hardware (timer-based) delay
A timer peripheral counts clock ticks on its own, and you wait for its flag, tick count or interrupt.
Comparison
| Aspect | Software loop delay | Hardware timer delay |
|---|---|---|
| Accuracy | Poor; depends on compiler, optimisation level, clock speed | Good; set by the clock and timer settings |
| Portability | Breaks if the clock or compiler changes | Recalculate from clock frequency; robust |
| Interrupt effect | Interrupts silently extend the delay | Count continues regardless; timing remains correct |
| CPU usage | CPU 100% busy | Can be non-blocking; CPU free for other tasks or sleep |
| Power | Wastes power | Allows low-power sleep while waiting |
| Setup effort | None | Needs timer configuration |
| Resolution | Hard to be fine | Down to a single timer tick |
| Best use | Very rough, short pauses; quick tests | Anything where timing matters |
Why busy loops are unreliable
- The compiler may optimise the loop away unless the counter is
volatile. - Changing the optimisation level changes how many cycles each iteration takes.
- Interrupts firing during the loop add extra, unpredictable time.
- Different CPU frequencies change the delay.
When is a software delay acceptable?
- Quick prototypes and tests
- Very short, non-critical pauses (e.g., waiting a few cycles for a signal to settle)
- When the hardware timers are already all in use
Some toolchains provide calibrated delay helpers based on a configured clock frequency. They are better than hand-made loops but still blocking and still affected by interrupts.
Rule of thumb
If timing matters, if the CPU has other work to do, or if power matters: use a hardware timer.
8. PWM Generation
What is PWM?
Pulse Width Modulation switches a digital pin ON and OFF very quickly, and varies how long it stays ON in each cycle. The average power delivered changes even though the pin is only ever fully ON or fully OFF.
Duty 25%: βββ βββ βββ
β ββββββββ ββββββββ βββ
Duty 50%: βββββ βββββ βββββ
β ββββββ ββββββ ββ
Duty 75%: βββββββ βββββββ βββββββ
β ββββ ββββ β
|β one period β|
How a timer makes PWM in hardware
Two registers do the job:
| Register | Role | Sets |
|---|---|---|
| TOP / auto-reload (ARR) | Where the counter restarts | The period β frequency |
| Compare value (CCR/OCR) | Where the output changes | The ON time β duty cycle |
Basic edge-aligned PWM (non-inverting):
- Counter starts at 0 β the output pin goes HIGH.
- Counter reaches the compare value β the output goes LOW.
- Counter reaches TOP β wraps to 0 β output goes HIGH again.
Counter: 0 ββββββββΊ compare ββββββββΊ TOP β 0 ...
Output: HIGH ββββββββββββββ LOW βββββββββ HIGH ...
(match) (wrap)
Once set up, the hardware produces PWM by itself. The CPU only updates the compare value when it wants a different duty cycle.
PWM modes
| Mode | How it counts | Notes |
|---|---|---|
| Edge-aligned (fast PWM) | Counts up, resets at TOP | Highest frequency for a given resolution; all channels' rising edges align |
| Center-aligned (phase-correct) | Counts up then down | Symmetric pulses; lower noise for motor drives; frequency roughly halved for the same TOP |
Polarity
| Type | Behaviour |
|---|---|
| Non-inverting | Output HIGH at the start of the period and LOW at the match, so a larger compare value means more ON time |
| Inverting | Output reversed, so a larger compare value means less ON time |
Choose according to your hardware: an active-low LED, for example, may need inverted PWM.
Resolution
PWM resolution is how many distinct duty steps you have. It equals the number of counts in one period:
Resolution (steps) = TOP + 1
Resolution (bits) β log2(TOP + 1)
A larger TOP gives finer duty control but, for the same timer clock, a lower PWM frequency. You balance frequency against resolution.
Setup checklist
- Choose the timer and PWM channel whose output pin is available (the pin needs to be set to the alternate function for the timer output; see the GPIO notes).
- Select the PWM mode and polarity.
- Set the prescaler and TOP for the desired frequency.
- Set the compare value for the desired duty cycle.
- Enable the output and start the timer.
// Generic example, names vary by chip
TIMER_PRESCALER = 15; // timer clock = f_cpu / 16
TIMER_TOP = 999; // period = 1000 ticks
TIMER_COMPARE = 250; // 25% duty
TIMER_MODE = PWM_EDGE_ALIGNED | PWM_NON_INVERTING;
TIMER_OUTPUT_ENABLE();
TIMER_START();
// Change duty at runtime:
TIMER_COMPARE = 750; // now 75%
Updating duty cycle safely
Many timers use buffered (preload) compare registers: a new value is only transferred at the start of the next period. This avoids glitches (a partial or broken pulse) when you change duty cycle while the PWM is running. Use that feature when available.
Software PWM
PWM can also be produced by toggling a pin from a timer interrupt, useful when no hardware PWM pin is free. It costs CPU time and has lower frequency/resolution and more jitter than hardware PWM.
9. Duty Cycle and Frequency Calculations
Definitions
| Term | Meaning | Formula |
|---|---|---|
| Period (T) | Time of one full cycle | T = 1 / f |
| Frequency (f) | Cycles per second | f = 1 / T |
| ON time (t_on) | Time the signal is HIGH in a cycle | |
| Duty cycle (D) | Fraction of the period spent ON | D = t_on / T Γ 100% |
Average output
For a digital signal swinging between 0 V and Vsupply:
V_average = D Γ V_supply
Example: 3.3 V supply, 40% duty β average β 1.32 V.
PWM frequency from timer settings
For edge-aligned (fast) PWM:
f_PWM = f_cpu / (prescaler Γ (TOP + 1))
For center-aligned (up/down) PWM:
f_PWM = f_cpu / (2 Γ prescaler Γ TOP) (exact formula varies slightly by chip)
Duty cycle from the compare value
For edge-aligned, non-inverting PWM:
Duty (%) = compare / (TOP + 1) Γ 100
compare = duty_fraction Γ (TOP + 1)
(Some chips define it as compare / TOP or Β±1. Confirm in the datasheet.)
Worked example 1: choose settings for a given frequency
Goal: 1 kHz PWM with a 16 MHz clock and good resolution.
- Try prescaler = 1: ticks per period = 16,000,000 / 1,000 = 16,000.
- A 16-bit timer can hold 16,000 (β€ 65,536), so no larger prescaler is needed.
- TOP = 16,000 β 1 = 15,999.
- For 25% duty: compare = 0.25 Γ 16,000 = 4,000.
- Resolution β 14 bits (16,000 steps), excellent.
Worked example 2: an 8-bit timer
Goal: about 1 kHz PWM with a 16 MHz clock, 8-bit timer (TOP = 255).
f_PWM = 16,000,000 / (prescaler Γ 256)
prescaler = 64 β f = 16,000,000 / (64 Γ 256) = 976.6 Hz β 1 kHz
compare for 50% = 0.5 Γ 256 = 128
Resolution here is only 256 steps (8-bit), so there is a visible trade-off versus the 16-bit example.
Worked example 3: from measured values
A pulse is HIGH for 3 ms in a 10 ms period:
f = 1 / 10 ms = 100 Hz
D = 3 / 10 = 30%
Choosing the PWM frequency
| Application | Typical consideration |
|---|---|
| LED dimming | High enough to avoid visible flicker (commonly a few hundred Hz or more) |
| DC motor drive | Often a few kHz to tens of kHz; above the audible range (β 20 kHz) avoids whine, but higher frequency increases switching losses |
| Servo control | Fixed frame (commonly about 50 Hz), where pulse width carries the information |
| Using PWM as a crude DAC (with an RC filter) | Higher frequency β smoother filtered output |
Quick tips
- Always compute with the real clock frequency.
- Remember the +1 in
(TOP + 1); it's a common off-by-one source. - Use integer math carefully:
compare = (duty_percent * (TOP + 1)) / 100. - Increasing prescaler lowers PWM frequency; increasing TOP raises resolution but lowers frequency.
10. PWM for LED Dimming and Motor Control
LED dimming
An LED's brightness is roughly proportional to the average current. PWM switches the LED on and off fast enough that your eye averages it into a steady brightness.
Duty 10% β dim
Duty 50% β medium
Duty 100% β full brightness
Choosing frequency: it must be fast enough to avoid visible flicker. A few hundred Hz is a typical lower bound; higher is safer, particularly for cameras or moving objects.
Active-low wiring: if the LED turns on when the pin is LOW, then either use inverted PWM or compute compare = TOP + 1 β brightness.
Perceived brightness is non-linear
Human eyes respond roughly logarithmically. A 50% duty LED does not look half as bright as 100%: it looks much brighter than half. For smooth fading, apply gamma correction, often a lookup table or a power curve (a squared or cubed value is a quick approximation).
// Simple gamma-like correction (approximate)
uint16_t level = (brightness * brightness) / MAX_BRIGHTNESS; // brightness: 0..MAX
TIMER_COMPARE = level;
Fading effect
Change the compare value gradually, driven by a periodic interrupt or a timed loop:
for (uint16_t b = 0; b <= MAX; b++) {
TIMER_COMPARE = b;
delay_ms(2);
}
Practical notes
- Keep the LED's series resistor and the pin's current limits in mind (see the GPIO notes). A GPIO pin can only drive small LEDs directly; use a transistor or driver for power LEDs and LED strips.
- RGB LEDs need three PWM channels.
Motor control
DC motor speed
PWM changes the average voltage reaching the motor, and so its speed. The motor's own inductance smooths the current.
Speed β proportional to duty cycle (roughly; depends on load)
Never connect a motor straight to a GPIO pin. Use a transistor, MOSFET or motor-driver IC (such as an H-bridge), where the PWM signal controls the driver's input.
MCU PWM pin βββΊ [Driver / MOSFET] βββΊ Motor βββ motor supply
Add a flyback (freewheel) diode across the motor when you switch it with a single transistor, to handle the voltage spikes from the inductive load.
Direction control
An H-bridge driver typically needs:
- A direction pin (or two inputs) set by GPIO
- A PWM input for speed
Choosing the frequency
| Too low | Too high |
|---|---|
| Audible whine, jerky motion, current ripple | More switching losses and heating in the driver; may exceed driver's limits |
Several kHz up to ~20 kHz is common; follow the driver's datasheet.
Other motor-related notes
- A minimum duty may be needed before a motor actually starts turning (it has to overcome friction).
- Soft start: ramp the duty gradually to limit inrush current.
- Dead time (a brief gap where both transistors in a half-bridge are off) prevents shoot-through. Advanced timers often provide it in hardware.
- Center-aligned PWM gives lower noise and symmetrical current ripple in some motor applications.
Servo motors
Hobby servos use PWM differently: a fixed period of about 20 ms (β 50 Hz) and a pulse width (commonly around 1 ms to 2 ms) that sets the position, not a power level.
Pulse ~1.0 ms β one end of travel
Pulse ~1.5 ms β center
Pulse ~2.0 ms β other end of travel
(Exact ranges vary by servo, so check its datasheet.)
11. Input Pulse Measurement
Measuring how long a pulse lasts, or how fast a signal repeats, is a classic timer task. Here are the main methods.
Method 1: Input capture (best)
Use the timer's input capture feature (Section 4). The hardware latches the counter at each edge.
Pulse width (time HIGH):
1. Configure capture on the RISING edge β read value t1
2. Switch to FALLING edge β read value t2
3. width_ticks = t2 β t1
4. width_time = width_ticks Γ (prescaler / f_cpu)
Period/frequency (rising-to-rising):
period_ticks = t2 β t1 (both captured on rising edges)
f_signal = f_timer / period_ticks
volatile uint16_t t_rise, t_fall;
volatile uint8_t state = 0;
volatile uint16_t pulse_ticks;
void CAPTURE_ISR(void)
{
uint16_t t = TIMER_CAPTURE; // value latched by hardware
if (state == 0) { // first: rising edge
t_rise = t;
set_capture_edge_falling();
state = 1;
} else { // second: falling edge
t_fall = t;
pulse_ticks = (uint16_t)(t_fall - t_rise); // unsigned: wrap-safe
set_capture_edge_rising();
state = 0;
}
}
Overflow handling: if the pulse can last longer than one timer cycle, also count overflow interrupts and extend the time:
total_ticks = overflow_count Γ (counter_max + 1) + (t2 β t1) (with care when overflow happens between edges)
or choose a larger prescaler / 32-bit timer so the pulse always fits.
Method 2: Edge interrupts + free-running timer (no capture hardware)
Use GPIO edge interrupts (see the GPIO notes) and read the free-running counter inside the ISR.
- Pro: works on any interrupt-capable pin.
- Con: less precise, as interrupt latency adds jitter to the timestamp.
Method 3: Polling (simplest, blocking)
while (!pin_is_high()) { } // wait for rising edge
TIMER_CNT = 0;
while (pin_is_high()) { } // wait for falling edge
uint16_t ticks = TIMER_CNT;
Easy, but the CPU is stuck waiting and may miss things. Add a timeout so a missing signal doesn't hang the program.
Method 4: Gated counter
For frequency measurement, count external pulses for a fixed gate time (e.g., exactly 1 second) using the timer in counter mode:
frequency (Hz) = pulse_count / gate_time_seconds
Which method for what?
| Signal | Better approach |
|---|---|
| Low frequency (long periods) | Measure period with input capture, then compute f = 1/T |
| High frequency | Use the gated counter (count pulses in a fixed time); period measurement would have too few ticks per period for good resolution |
Typical applications
- Ultrasonic distance sensors: echo pulse width is proportional to distance
- Tachometers / speed sensors: pulse rate gives rotational speed
- PWM input from receivers or other devices: measure period and width to get duty cycle
- Flow sensors, frequency counters, remote-control decoding
Accuracy tips
- Pick a prescaler that gives enough ticks across the shortest pulse but doesn't overflow too soon.
- Use unsigned subtraction so wrap-around is handled automatically.
- Use the input filter for noisy signals.
- Handle missing or very long pulses with timeouts.
12. Watchdog Timer
What it is
A watchdog timer (WDT) is a special hardware timer that resets the microcontroller if the software stops responding. It protects against hangs, infinite loops and crashes, especially when no human is nearby to press reset.
How it works
- The WDT counts down (or up) on its own, usually from a separate, independent oscillator.
- Healthy software must periodically "kick" or "feed" the watchdog ("pet the dog"), which reloads the counter.
- If the software fails to feed it in time, the counter reaches its limit and the watchdog triggers a system reset (or sometimes an interrupt first).
Normal: ββfeedββββββfeedββββββfeedβββββΊ system keeps running
Hang: ββfeedββββββ(stuckβ¦)ββtimeoutβββΊ RESET β system restarts
Key features
| Feature | Meaning |
|---|---|
| Timeout period | Configurable (often from milliseconds up to several seconds, depends on chip) |
| Independent clock | Often runs from its own low-power oscillator, so it still works if the main clock fails |
| Reset action | Forces a full or partial system reset |
| Early-warning interrupt | Some watchdogs interrupt before resetting, so you can log or save data |
| Windowed watchdog | Feeding is only allowed inside a time window. Feeding too early or too late causes a reset, which catches runaway code that feeds the watchdog uncontrollably |
| Cannot be easily disabled | Once started, it is often locked on (by design, or by option bytes/fuses) |
| Low-power behaviour | May or may not run in sleep modes, so check the datasheet |
Basic usage
int main(void)
{
system_init();
watchdog_enable(TIMEOUT_1S); // illustrative: 1 second timeout
while (1) {
do_task_A();
do_task_B();
watchdog_feed(); // reached only if the loop runs normally
}
}
Good practices
- Feed the watchdog from one well-chosen place in the main flow, after you've confirmed the important tasks ran. Don't feed it blindly from a timer interrupt, because the ISR may keep running while the main program is stuck, defeating the purpose.
- Choose a timeout longer than the longest normal loop time but short enough to recover quickly.
- After a reset, check the reset-cause flag to see whether the watchdog caused it. You can log this or enter a safe mode.
- On restart, make sure outputs go to a safe state (see the GPIO notes).
- Avoid long blocking operations, or feed the watchdog inside them in a controlled way.
- Test it: deliberately hang the code and confirm the reset occurs.
What it protects against
- Infinite loops and deadlocks
- Software crashes and runaway code
- Unexpected hangs caused by noise or glitches
- Waiting forever for a hardware response that never comes
The watchdog is a last line of defence, not a substitute for good code. It brings the system back to a known state, but the underlying bug still needs fixing.
13. Real-Time Clock Basics
What is an RTC?
A Real-Time Clock (RTC) keeps track of calendar time: seconds, minutes, hours, day, date, month and year. Unlike a general-purpose timer that counts ticks, an RTC works in human units and is designed to keep running for long periods, even when the main system is asleep or powered down.
Key characteristics
| Feature | Description |
|---|---|
| Time/date registers | Hold seconds, minutes, hours, weekday, day, month, year (often in BCD format) |
| Low-frequency crystal | Usually a 32.768 kHz watch crystal. This frequency is 2ΒΉβ΅, so a 15-bit divider produces exactly 1 Hz (one tick per second) |
| Low power | Designed to run on very little current |
| Battery backup | Can be powered from a coin cell or backup supply so time survives main power loss |
| Alarms | Raises an interrupt/flag when the time matches a set value |
| Periodic wake-up | Wakes the MCU from sleep at regular intervals |
| Calibration | Registers to trim for crystal inaccuracy |
How the 32.768 kHz crystal becomes seconds
32,768 Hz βββΊ [Γ· 32,768 divider chain] βββΊ 1 Hz βββΊ seconds counter βββΊ minutes βββΊ hours βββΊ days β¦
BCD format
Many RTCs store each decimal digit in 4 bits (Binary-Coded Decimal):
Decimal 47 β BCD 0100 0111 (0x47)
Convert in code:
uint8_t bcd_to_bin(uint8_t bcd) { return (uint8_t)(((bcd >> 4) * 10U) + (bcd & 0x0FU)); }
uint8_t bin_to_bcd(uint8_t bin) { return (uint8_t)(((bin / 10U) << 4) | (bin % 10U)); }
Typical RTC usage
- Enable access to the RTC (some chips need the backup domain unlocked).
- Select the clock source (32.768 kHz crystal preferred for accuracy).
- Set the time and date (once, or after a battery loss).
- Read time registers when needed.
- Optionally set an alarm or wake-up interval.
rtc_set_time(14, 30, 0); // 14:30:00 (illustrative functions)
rtc_set_date(2026, 10, 10);
rtc_time_t t = rtc_get_time(); // read
Reading the time safely
Time registers keep changing. If you read seconds, then minutes, a rollover could happen in between and give a wrong combined time (e.g., 10:59 β 11:00). Hardware often offers shadow registers or a "read-sync" mechanism, and a common software trick is to read twice and confirm both reads match.
Alarms and wake-up
- Alarm: "interrupt me at 07:30:00", useful for scheduled tasks or waking a sleeping system.
- Periodic wake-up: "wake every 10 seconds, take a reading, go back to sleep", the basis of many battery-powered sensors.
Accuracy and drift
- Crystal accuracy is typically specified in ppm (parts per million). Even 20 ppm drifts about 1.7 seconds per day, as shown below, so critical systems calibrate or use temperature-compensated RTCs, or periodically synchronise with a reference time source.
20 ppm Γ 86,400 s/day = 1.728 s/day
- Temperature changes alter crystal frequency.
- Losing backup power resets the time, so firmware should detect an invalid time and ask to be set again.
RTC vs. general timer
| General-purpose timer | RTC | |
|---|---|---|
| Counts | Raw clock ticks | Calendar time (s, min, h, date) |
| Clock | Fast system clock (MHz) | Slow crystal (32.768 kHz) |
| Survives power-off | No | Yes, with battery backup |
| Typical uses | Delays, PWM, measurements | Timestamps, scheduling, alarms, low-power wake-up |
| Resolution | Very fine (nsβΒ΅s) | Seconds (sometimes sub-second) |
External RTC chips
Some systems use a separate RTC chip (communicating over a serial bus) when the MCU has no built-in RTC or when more accuracy or backup features are needed. The concepts above stay the same.
14. Quick Revision Cheat Sheet
Essential formulas
| Quantity | Formula |
|---|---|
| Timer clock | f_timer = f_cpu / prescaler |
| Tick period | 1 / f_timer |
| Overflow period | (max + 1) Γ prescaler / f_cpu |
| CTC period | (TOP + 1) Γ prescaler / f_cpu |
Compare/TOP value for time t | TOP = t Γ f_cpu / prescaler β 1 |
| Toggle-on-match output frequency | f_timer / (2 Γ (TOP + 1)) |
| Fast PWM frequency | f_cpu / (prescaler Γ (TOP + 1)) |
| Duty cycle | t_on / T Γ 100% |
| Compare for given duty | duty_fraction Γ (TOP + 1) |
| Average output voltage | D Γ V_supply |
| Signal frequency from capture | f_timer / period_ticks |
| Frequency from gated count | pulses / gate_time |
| Crystal drift | ppm Γ 86,400 s = seconds/day (Γ·10βΆ) |
Concept map
| Concept | One-line summary |
|---|---|
| Timer | Counter fed by a known clock β measures time |
| Counter | Counter fed by external pulses β counts events |
| Prescaler | Slows the clock: longer range, coarser resolution |
| Overflow | Counter wraps past its max value |
| Compare match | Counter equals a chosen value |
| Input capture | Hardware stores counter value on an external edge |
| Output compare | Hardware changes a pin when counter matches |
| Periodic interrupt | Regular timer interrupt β system tick |
| PWM | Fast on/off switching; duty cycle sets average power |
| Watchdog | Resets the MCU if software forgets to feed it |
| RTC | Low-power calendar clock, often battery-backed |
Golden rules
- Do the math with the real clock frequency, and don't forget the
+1/β1inTOPcalculations. - Pick the smallest prescaler that still fits the required range, for best resolution.
- Use hardware timers, not busy loops, when timing matters.
- Keep ISRs short, clear flags as required and make shared variables
volatile. - Use unsigned subtraction for elapsed time and tick differences, so counter wrap-around is handled.
- Use input capture for precise pulse measurement, since hardware timestamps beat interrupt-time reading.
- Never drive motors or big loads directly from a PWM pin. Use a driver, and add flyback protection.
- Match PWM frequency to the load: no visible flicker for LEDs, no audible whine or excess losses for motors.
- Feed the watchdog only from the main flow after confirming things work, not from an unconditional ISR.
- Treat RTC time as invalid after backup power loss, and plan for crystal drift.
*End of notes
π§ Continue Learning
Related technical blueprints and practical guides in Firmware & Embedded Systems.

Embedded C Interview Preparation
Frequently tested concepts, Practical Embedded C programs

Microcontroller-Specific Embedded C
Embedded C is not completely platform-independent. You should learn the concepts above and then implement them on a real microcontroller

Debugging, Testing and Production Firmware
Hardware debugging, Firmware testing, Reliability, Security, Performance,...

Advanced Firmware Development
ADC, DAC and Sensor Interfacing, DMA and Data Transfer, Embedded Software Architecture, RTOS β Real-Time Operating Systems, Embedded Build Process and Linker