ARDUINO UNDER THE HOOD
The AVR/ATmega328P From Toolchain to Bare Metal
A Study Guide
Toolchain
& C Fundamentals • GPIO, Interrupts & Timers • ADC
UART
• SPI • I2C • 1-Wire • Power, Clocks & Non-Volatile Memory
Programming
& Debugging • Secure Coding (CERT-C)
Contents
Fourteen parts, covering the toolchain, C fundamentals, every major on-chip peripheral, power/memory management, programming and debugging, and secure-coding practices.
Part 1 — Toolchain & Foundations
1.1 From Arduino Sketch to Bare-Metal AVR: Course Roadmap
1.2 Driving a Pin: digitalWrite/pinMode vs. Direct Registers
1.3 What an Arduino Board Provides Beyond the Chip
1.4 The Compilation Flow: Preprocessor, Compiler, Assembler, Linker
1.5 Cross-Compiling for AVR (avr-gcc)
1.6 The AVR Library: _BV, Port Macros, and _delay_ms
1.7 Makefiles
1.8 From ELF to Hex: The Object Copy
1.9 Uploading with avrdude and the Bootloader
1.10 Simulating and Debugging the ATmega (simavr, simulavr, avr-gdb)
Part 2 — C Essentials for AVR
2.1 Basic Types and Control Flow, AVR-Style
2.2 Arrays and Pointer Arithmetic
2.3 Enum, Union, and Struct
2.4 Call by Value vs. Call by Reference
2.5 Memory Space, Registers, and Pointers
2.6 Where Is main()? Arduino.h and the Hidden Wrapper
2.7 The volatile Attribute
Part 3 — GPIO & Interrupts
3.1 How a Pin Works Internally
3.2 Floating Pins, Sleep, and the General Purpose I/O Registers
3.3 Interrupts vs. Polling
3.4 The Interrupt Subroutine (ISR)
3.5 The Interrupt Vector Table (IVT)
3.6 Activating Interrupts: Local and Global
3.7 External Interrupts: PCINTn Groups and INTn
3.8 Interrupt Priority and Nested Interrupts
3.9 Good and Bad Practice Inside an ISR
Part 4 — Timers, Counters & PWM
4.1 Timer/Counter Fundamentals: MAX, BOTTOM, TOP
4.2 Clock Sources and Prescalers
4.3 Normal Mode and Clear Timer on Compare Match (CTC)
4.4 Fast PWM
4.5 Phase Correct PWM
4.6 Phase and Frequency Correct PWM
4.7 Timer Differences and the Input Capture Unit
Part 5 — The Analog World: ADC & Comparator
5.1 Why Analog Values Need Converting
5.2 Reference Voltages
5.3 ADMUX: Selecting Reference and Input Channel
5.4 ADCSRA: Starting a Conversion
5.5 Sample Rate and ADLAR
5.6 Auto-Triggering and Free-Running Mode
5.7 DIDR: Noise, Power, and a Source of Randomness
5.8 The Internal Temperature Sensor and Its Calibration
5.9 The Bandgap Reference: Measuring Battery State
5.10 The Analog Comparator
Part 6 — Serial Communication: UART/USART
6.1 The UART Protocol and Its Frame
6.2 USART: Adding a Clock Line
6.3 Duplex, Half-Duplex, and Simplex
6.4 The Parity Bit
6.5 UART Hardware Blocks: Clock, Transmitter, Receiver
6.6 Configuration Registers: UCSRnA–UCSRnC
6.7 Transfer Speed: UBRRn and U2Xn
6.8 Worked Example: A Simplex UART Receiver
Part 7 — SPI
7.1 Where SPI Shows Up
7.2 SPI Topologies: Single Slave, Multiple Slaves, Daisy Chain, Quad SPI
7.3 The SPI Control Register (SPCR)
7.4 The SPI Status and Data Registers (SPSR, SPDR)
7.5 The Universal Serial Interface (USI) on ATtiny
Part 8 — I2C/TWI
8.1 Where I2C/TWI Shows Up
8.2 The Bus: Open-Drain Signaling and Pull-Ups
8.3 Transferring a Bit: Master-to-Slave and Slave-to-Master
8.4 Addressing: 7-Bit, 10-Bit, and the Frame Format
8.5 Multi-Master Conflict Resolution
8.6 I2C/TWI Registers on the ATmega328P
8.7 Mimicking Protocols: Fitting I2C, SPI, and UART onto USI
Part 9 — 1-Wire
9.1 The DHT11/DHT22: Similar, But Not 1-Wire
9.2 The 1-Wire Bus and Device IDs
9.3 Parasitic Power
9.4 The 1-Wire Protocol: Reset, Presence, and Bit Timing
9.5 The Search ROM Command
9.6 Bit-Banging 1-Wire with a UART Peripheral
Part 10 — Power, Clocks & Reset
10.1 Rebuilding Power and Reset Without the Arduino Board
10.2 Rebuilding the Clock: Internal Oscillators and the Low Fuse
10.3 Clock Frequency, VCC, and Current Draw
10.4 The Power Reduction Register (PRR)
10.5 Sleep Modes
10.6 Fast-and-Sleep vs. Slow-and-Awake: An Energy Trade-off
10.7 The Watchdog Timer
10.8 The Brown-Out Detector
Part 11 — Non-Volatile Memory: Flash & EEPROM
11.1 Harvard Architecture: Two Separate Memories
11.2 Storing Constants in Flash with PROGMEM
11.3 Storing Strings in Flash: A Pointer Pitfall
11.4 The __flash Keyword
11.5 Flash Pages, NRWW/RWW, and BOOTSZ
11.6 Writing to Flash and Locating Free Pages
11.7 The EEPROM: Registers and Write Sequence
11.8 EEPROM Timing, Helper Functions, and Initialization
11.9 Flash vs. EEPROM: Quick Comparison
Part 12 — Programming, Debugging & Protection
12.1 ISP/ICSP: Programming Fuses, Flash, and EEPROM via SPI
12.2 Turning an Arduino (or an FTDI232) into a Programmer
12.3 debugWIRE, the High Fuse, and RSTDISBL
12.4 Hardware Debuggers and an Open-Source Alternative
12.5 Hardware Breakpoints
12.6 Protecting Code: Boot Lock Bits (BLB0/BLB1)
12.7 Protecting Code: Lock Bits and the Chip Erase
Part 13 — Secure Coding for Embedded C (CERT-C)
13.1 Where These Rules Come From
13.2 The Classic Buffer Overflow
13.3 Watching a Buffer Overflow in GDB
13.4 Mitigations: Safe Functions and Stack Canaries
13.5 DCL30-C — Object Storage Duration
13.6 EXP33-C — Uninitialized Memory
13.7 EXP34-C — NULL Pointer Dereferences
13.8 EXP42-C — Padding and Structure Comparison
13.9 MEM30-C — Dangling Pointers
13.10 STR31-C — String Storage and Off-by-One Errors
13.11 FIO47-C — Format String Arguments
13.12 ENV33-C — Do Not Call system()
13.13 MSC30-C/MSC32-C — Pseudorandom Number Generators
13.14 FLP30-C — Floating-Point Loop Counters
Part 14 — Practical Exercises & Wrap-Up
14.1 Debugging Protocol Timing with an Oscilloscope
14.2 Course Recap
Part 1 — Toolchain & Foundations
1.1 From Arduino Sketch to Bare-Metal AVR: Course Roadmap
This guide follows a progression that starts from the familiar Arduino IDE and setup()/loop() sketches, and gradually strips away every layer of convenience until only the raw ATmega328P chip, its registers, and a hand-written toolchain remain. Along the way it explains what the Arduino library is actually doing underneath calls like digitalWrite() and pinMode(), why that convenience costs program memory and clock cycles, and what has to be rebuilt by hand (power supply, reset circuit, clock source, upload mechanism) to run the chip standalone.
The ATmega328P used on classic Arduino boards (Uno, Duemilanove) ships in different physical packages — for example the 28-pin DIP (dual in-line package) on full-size boards, or the smaller TQFP (thin quad flat package) soldered directly onto compact boards like the Arduino Mini Pro. The packaging changes the physical footprint, not the programming model or register layout.
1.2 Driving a Pin: digitalWrite/pinMode vs. Direct Registers
The running example throughout the early part of the course is the classic blinking LED, typically the onboard LED wired (through a resistor) to a specific pin — pin 13 on the Uno. A standard sketch configures that pin as an output in setup() and toggles it high/low with delay() calls in loop().
Measuring this simple program exposes real costs: on an ATmega328P with 32 KB of flash and 2 KB of RAM, a basic blink sketch already consumes several hundred bytes of program memory; adding a second blinking LED adds tens more bytes; and adding Serial output for debugging can add roughly 1 KB — nearly doubling the program just to print status text. In terms of clock cycles (at the Uno's 16 MHz clock), configuring a pin as output costs dozens of cycles, changing its state costs dozens more, Serial.begin() costs around 70 cycles, and printing over serial is comparatively expensive because the CPU blocks, sending one character at a time and waiting for each to finish transmitting before moving to the next — the lower the baud rate, the longer that wait.
The takeaway that motivates the rest of the course: digitalWrite(), pinMode(), and Serial printing are convenient but not free, and understanding what happens “on the other side of the pin” — i.e., the actual hardware registers being manipulated — is the path to smaller, faster, more predictable embedded code.
1.3 What an Arduino Board Provides Beyond the Chip
An Arduino board is a prototyping-friendly wrapper around the bare ATmega chip. Besides the MCU itself, a classic board supplies:
• Power supply — accepts a wider DC input (roughly 7–20 V) and regulates it down to 5 V, typically with a 7805-style linear regulator. A linear regulator dissipates the difference between input and output voltage as heat: e.g., a 12 V input at 1 A of load wastes (12−5) V × 1 A = 7 W as heat, which is enough to be genuinely dangerous to touch and to demand a heatsink; even a modest 9 V battery at 200 mA wastes about 0.8 W, shortening battery life. Regulator efficiency is therefore a real design constraint when a project runs from a battery.
• USB and a programmer/converter — historically an FTDI232 chip that translates USB signals to UART and triggers a reset so the bootloader runs before programming.
• Pin headers — expose every pin not used internally so peripherals can be wired in easily.
• Status LEDs (power, UART activity, the onboard user LED) plus their current-limiting resistors.
• A reset button, and a crystal oscillator (16 MHz on classic boards) with its two loading capacitors (commonly 22 pF).
Once a project moves from prototyping to a finished product, most of this — the headers, indicator LEDs, and the board itself — is unnecessary overhead in cost and size, and the ATmega328P can be run “bare” with just the minimum support circuitry rebuilt by hand (covered later in this guide: Part 10 on power and clocks, and Part 12 on programming without a bootloader).
1.4 The Compilation Flow: Preprocessor, Compiler, Assembler, Linker
Turning a C source file into an executable binary passes through several distinct GCC stages, each independently invocable to inspect intermediate output:
Preprocessor
Handles every directive starting with #. #define text substitution happens purely as a textual search-and-replace before the compiler ever runs, which is why a #define'd constant (unlike a const int) doesn't necessarily reserve any memory — it's simply gone, replaced by its literal value, by the time compilation starts. #include works the same way: the preprocessor copies the referenced file's entire contents in place of the #include line, recursively, until no directives remain. Running gcc -E stops the flow here and shows the fully expanded source.
Compiler
Converts preprocessed C into assembly (gcc -S) — instructions, registers, and named memory addresses — for one specific target architecture, since it now needs to know the CPU's register count, addressing, and instruction set. The ATmega328P supports 131 distinct instruction mnemonics; the reference manual documents each one's behavior, side effects (which status flags it sets), and clock-cycle cost — for example, ADD adds two registers into a destination register in 1 cycle and can affect the Z, C, N, V, and H flags.
Assembler
Converts assembly into object code (gcc -c) in the ELF (Executable and Linkable Format) — a standard binary layout with a header (architecture, OS, data format, file version) and named sections for different kinds of content: .text (instructions), .data (initialized read/write variables), .bss (uninitialized variables, which need no stored data — just space), .rodata (read-only constants, such as string literals), and .rela.text (relocation information for symbols, like external function calls, that aren't resolved yet). Tools like readelf -h, readelf -S, and readelf -x <section> can inspect an ELF file's header, section list, and raw section contents respectively.
Linker
Resolves the placeholders left by the assembler (such as a call to printf whose address wasn't yet known) into concrete addresses, producing a final executable. Dynamic linking leaves external library calls as references resolved at load time (small file, depends on shared libraries like libc, inspectable with ldd); static linking copies the needed library code directly into the executable (much larger file, no external dependency) — the norm in embedded systems, which typically have no filesystem or dynamic loader to resolve libraries at runtime.
1.5 Cross-Compiling for AVR (avr-gcc)
A PC's compiler collection produces binaries for the PC's own architecture (e.g., x86_64) by default. The AVR core inside the ATmega328P has an entirely different instruction set, so producing AVR-executable binaries on a PC requires cross-compilation — exactly the same relationship an Android IDE has to an ARM-based phone. The Arduino IDE installs a ready-made AVR cross-compiler toolchain (avr-gcc, avr-g++), which can be located by enabling verbose build output in the IDE's preferences.
Key avr-gcc parameters used throughout the course:
• -mmcu=atmega328p — selects the target chip (other options exist for the ATmega2560 in the Arduino Mega, various ATtinys, and more).
• -DF_CPU=16000000UL — declares the clock frequency for the preprocessor; since the AVR has no OS scheduler, the only notion of elapsed time it has is counted clock cycles, so this value is what timing functions like delays rely on to be accurate.
• -Os — optimizes for code size rather than speed, appropriate given the ATmega328P's tight 32 KB flash budget; -O0/-O1/-O2 remain available if space allows prioritizing speed instead.
• -Wall — enables compiler warnings (e.g., risky implicit type casts) that are good practice to review and address; -w silences warnings instead.
• -Wl,--gc-sections — a linker instruction to discard unused ELF sections, shrinking the final binary.
• -ffunction-sections / -fdata-sections — place each function and each variable in its own ELF section, so that anything never referenced can be individually identified and discarded by --gc-sections above.
1.6 The AVR Library: _BV, Port Macros, and _delay_ms
The AVR C library (avr-libc) predefines named macros for every register address, so DDRB can be written directly instead of manually casting the address 0x24 to a pointer. It also supplies the _BV(bit) macro, which just left-shifts a 1 into the given bit position — purely a preprocessor/compile-time convenience with no runtime cost — letting code read as DDRB |= _BV(DDB5); to set one bit without hand-counting bit positions, DDRB &= ~(_BV(DDB6)); to clear one, and PINB & _BV(PINB0) to test one, all while the compiler folds the constant bit math at compile time.
The library's _delay_ms()/_delay_us() functions are a leaner alternative to the Arduino delay() function. Arduino's delay() is built on a busy-wait loop that reads one of the chip's hardware timers and temporarily disables interrupts while doing so — tying up a timer resource and blocking interrupt handling for its duration. _delay_ms(), by contrast, is a simple loop with a fixed, known number of instructions; since the clock frequency (F_CPU) is known at compile time, the compiler can calculate exactly how many loop iterations equal the requested delay, with no timer or interrupt involvement at all. Rewriting a blink program to use DDRB/PORTB register macros, _BV(), and _delay_ms() instead of pinMode()/digitalWrite()/delay() cuts the compiled size meaningfully — in the course's example, from roughly 600 bytes down to under 500.
1.7 Makefiles
A Makefile automates and documents a project's build steps: it's a set of rules, each with a target, its prerequisites, and a recipe (the shell commands to produce the target from those prerequisites) — crucially, the commands in a recipe must be indented with a literal tab character, not spaces, or the rule fails.
foo.o: foo.c defs.h
gcc
-c -g foo.c
Make only re-runs a recipe when its target is missing or older than one of its prerequisites, which is what makes incremental builds fast — changing one source file in a large project (the course's example: the ~15-million-line Linux kernel, which can take tens of minutes to build in full) only triggers a rebuild of that file and anything depending on it, not the whole project.
Practical features used repeatedly:
• Comments start with #.
• Variables are defined as NAME = value and referenced as $(NAME) anywhere in the file.
• Automatic variables like $@ (the current target) and prerequisite variables shorten recipes and avoid repeating the target name.
• Implicit rules — Make already knows that a .o target with a same-named .c prerequisite should be built with the C compiler, so explicit compile recipes for straightforward object files can often be omitted entirely, leaving just the extra header-file dependencies (e.g., a shared defs.h) to declare.
• .PHONY: clean — marks a target like clean as not corresponding to an actual file, so Make doesn't get confused if a file happens to be named clean.
The first rule in a Makefile is treated as the default (final) target when make is run with no arguments, which is why the top-level target (e.g., the finished program) is conventionally written first, ahead of the rules for its individual components.
1.8 From ELF to Hex: The Object Copy
Embedded programmers/uploaders generally expect the Intel HEX format rather than a raw ELF binary. The objcopy tool (invoked with -O ihex) converts the linked ELF executable into this format. Each line of an Intel HEX file starts with a colon, followed by: a byte count for that line's data, a 2-byte address specifying where in program memory that data belongs, a record-type byte (00 for plain data, 01 for end-of-file), the data bytes themselves, and a trailing checksum — the data plus checksum bytes on a line must sum to a value whose least significant byte is zero, which is how a corrupted line can be detected.
1.9 Uploading with avrdude and the Bootloader
On a classic Arduino, uploading works through a small resident program called the bootloader rather than a dedicated hardware programmer. The upload sequence: the host issues a reset, which starts the bootloader; the bootloader waits about 2 seconds for incoming HEX-formatted data over serial; if data arrives, it's written to the indicated flash addresses (and optionally read back to verify); afterward the bootloader resets again, waits its 2 seconds once more with no new data arriving this time, and then hands control to the freshly uploaded user program. This is also why a freshly reset Arduino has a brief pause before user code actually starts running.
avrdude (AVR Downloader/UploaDEr) drives this process. Frequently used flags:
• -V — skip verifying the written data after upload (leaving it out, i.e. verifying, is safer — catches corruption from a flaky USB cable at the cost of a second or two).
• -F — skip the device-signature check, useful when a legitimate but slightly different part variant (e.g. ATmega328 vs. ATmega328P vs. ATmega328PU) would otherwise fail the check even though it's programmed the same way.
• -p <part> — the target MCU part number.
• -c <programmer> — the programmer type; arduino selects the USB-bootloader method used here.
• -b <baud> — transfer speed; the classic Arduino bootloader expects 57600 baud.
• -U <memtype>:w|r:<file> — which memory to act on (flash, eeprom, or the fuse bytes) and whether to write or read it — avrdude can also be used to pull data back out of EEPROM, for example.
• -P <port> — the serial device the board is attached to (e.g. /dev/ttyACM0 or /dev/ttyUSB0 on Linux; a permissions error here often means the current user needs to be added to the dialout group).
Once code no longer depends on any Arduino-library call, the Arduino IDE itself becomes optional — the avr-gcc toolchain plus a hand-written Makefile with a make upload target (wrapping avrdude with these parameters) is enough to build and flash a program independently.
1.10 Simulating and Debugging the ATmega (simavr, simulavr, avr-gdb)
Common ad-hoc debugging techniques — sprinkling Serial.println() calls through code, or toggling the onboard LED to signal that a section of code ran — are limited: they consume scarce program memory and I/O pins, can be too slow to observe fast events (an ISR that fires faster than an LED can visibly blink, or faster than a UART can report every tick), and can change the timed behavior of the very code being observed (inserting a UART print into a bit-banged protocol may itself violate that protocol's timing).
A proper debugger like GDB avoids these problems by inspecting a running program's memory, variables, and execution flow externally, without modifying the program's own code — but GDB needs something to execute the target binary. Since AVR machine code can't run directly on a PC, this is where AVR simulators come in: simavr and simulavr both emulate ATmega behavior and expose a GDB-compatible remote debugging port (typically 1234), letting avr-gdb attach to a simulated chip exactly as it would to real hardware.
A representative session: compile with debug symbols (-g), launch the simulator (which prints the binary's size and starts listening for a GDB connection), then in a separate terminal run avr-gdb <program>.elf, connect with target remote localhost:1234, and use standard GDB commands — list to browse source, break <line> to set breakpoints, continue/c to run to the next breakpoint, next to step over a line (including any function calls inside it), step to step into a called function, print (or print /x, print /t for hex/binary) to inspect a variable or register, and display to keep a variable continuously visible after every stop.
The two simulators differ in fidelity: simavr in the course's testing behaves more like a time-accurate simulator (it recognized that an ISR was meant to fire once a second and reproduced that timing, without the underlying timer-count register actually incrementing in the simulation), while simulavr behaves more cycle-accurately, genuinely incrementing the timer-count register (TCNT1) on each simulated clock cycle and triggering the interrupt only once it reaches the configured compare value — useful for cross-checking that ISR timing and instruction-level behavior are actually correct, using tools like avr-objdump -D -S to see the disassembly interleaved with source lines and count instructions/cycles by hand against the reference manual's instruction-timing table.
Both simulators' fundamental limitation is the physical world: neither one can automatically know how a real sensor or actuator connected to a pin should behave. simavr addresses this partially by letting a developer register C “hook” callbacks (IRQ hooks) that fire when a simulated pin's value changes — for example, printing a timestamp and the new pin state whenever a port toggles — which could in principle be extended to drive a real external device. But any such hand-written peripheral model risks being too idealized compared to real hardware, which is why, ultimately, being able to step through code running on the actual chip (covered later, in the debugging/programming Part of this guide) is often necessary to fully trust a result.
Part 2 — C Essentials for AVR
This Part assumes general familiarity with C and focuses on the handful of ways embedded C on the AVR differs in practice from C on a PC: narrower types, a much smaller int, direct addressability of hardware registers, and the absence of an operating system to catch mistakes.
2.1 Basic Types and Control Flow, AVR-Style
A few type quirks matter more on AVR than on a PC. bool is not a real C type but is defined for convenience by the Arduino/AVR libraries — and despite representing only two logical states, it still occupies a full byte (the AVR's smallest addressable unit), so switching a variable from char to bool saves no memory. As in standard C, any value other than 0 is treated as true.
Sized integer types (int8_t/uint8_t, int16_t/uint16_t, and so on, with the number indicating bit width) are used heavily since memory is scarce. Notably, on AVR-GCC a plain int is only 2 bytes (not 4, as on a typical PC) — use long for a 4-byte integer — and a pointer is likewise only 2 bytes, which is enough to address the ATmega328P's full memory space.
A recurring beginner trap is confusing the logical operators (&&, ||) with the bitwise operators (&, |): if (a & b) performs a bitwise AND of the two values and is true only if the result is nonzero, which is a very different test from if (a && b). Both have legitimate, distinct uses, so it's worth double-checking which one a given line actually needs.
Functions work as in standard C and remain valuable for the usual reasons — avoiding duplicated logic, giving a named, self-documenting unit of behavior, and centralizing bug fixes to one place. Two compiler optimizations are especially relevant on a size- and speed-constrained chip: unused functions can be stripped entirely from the final binary (Part 1.5's --gc-sections/-ffunction-sections), and small functions may be inlined (their body substituted directly at the call site) to avoid call/return overhead, entirely at the compiler's discretion.
2.2 Arrays and Pointer Arithmetic
An array is a block of same-typed values stored contiguously in memory, with the array name acting as a pointer to its first element. Choosing a tighter type matters directly here: an array of five plain int values (2 bytes each) takes twice the memory of the same array declared as uint8_t/int8_t — worth doing whenever the value range allows it, which is often, given how little RAM the ATmega328P has.
A pointer's declared type controls how far arithmetic on it actually moves in memory — this is the central idea to internalize:
• *a or a[0] dereferences the pointer / indexes the array to read the value at the first element.
• *(a+1) or a[1] moves to the second element — the +1 advances by one element's worth of bytes (1 byte for a uint8_t array, 2 bytes for an int array), not literally by one byte.
• &a[0] (or &a) gives the address of the first element — numerically the same address as a itself.
• &a, the address of the array as a whole, has a different type from a: a pointer to “N elements,” not a pointer to one element. Adding 1 to a moves by one element; adding 1 to &a moves past the entire array.
In short: * dereferences (“go to this address and get the value”), & takes an address (“where does this value live”), and pointer arithmetic always scales by the pointee's type size — which is exactly why getting a pointer's declared type right matters so much. Unlike a PC program, there's no operating system on a bare AVR to stop an out-of-bounds access with a segmentation fault — an errant pointer simply reads or writes whatever is actually at that address.
2.3 Enum, Union, and Struct
An enum defines a set of named integer constants (internally just integers, so nothing stops an out-of-range value from being assigned unless the developer is careful). A common trick is adding one extra terminal member to an enum purely so its value automatically equals the count of “real” members — handy for loop bounds that survive later additions to the enum without further code changes.
A union declares members that all overlap the same memory, so its total size equals its largest member, and writing through one member changes what every other member reads. This looks strange at first but is genuinely useful — for example, reading just the high byte of a 2-byte integer by accessing the second element of an overlapping char array, instead of manually shifting bits.
A struct, by contrast, gives each member its own dedicated space, so writing one member never disturbs another — the natural choice when values need to be grouped together but read/written independently.
2.4 Call by Value vs. Call by Reference
Passing a struct into a function by value copies the entire struct onto the stack; the function only ever sees and modifies that copy, so changes made inside it never affect the caller's original. This is simple and safe but has a real cost for large structures — time and stack space to copy data that may be discarded moments later. The compiler often optimizes obviously wasteful copies away entirely (e.g., inlining a small function and eliminating a struct member that's never actually used), but that optimization isn't guaranteed for every case.
Passing a pointer to the struct instead (call by reference) copies only the address — 2 bytes on AVR — regardless of the struct's size, and lets the function modify the original data directly. The arrow operator (ptr->member) is shorthand for dereferencing a struct pointer and then accessing a member ((*ptr).member), and is the idiomatic way to write this.
void func(Number *nr) {
nr->i = nr->i + 1; //
modifies the caller's struct directly
}
func(&n);
// pass the address of n
General embedded-vs-PC differences worth keeping in mind here: without an OS to reclaim control when a program “ends,” embedded main functions always end in an infinite loop (e.g. while(1)) so the CPU doesn't fall off into undefined memory and start executing garbage; fine-grained sized types (intN_t) matter more given tight RAM budgets; and priorities are often inverted compared to PC programming — an ATmega328P has only 32 KB of flash and runs at a few MHz, so code size is frequently optimized ahead of raw execution speed, the opposite of the usual PC default.
2.5 Memory Space, Registers, and Pointers
On the ATmega328P, ordinary RAM (SRAM) does not start at address 0. The address space instead begins with the 32 CPU general-purpose registers (addresses 0x00–0x1F), followed by 64 I/O registers, then 160 extended I/O registers, and only after all of that does SRAM begin. This is a genuinely different mental model from a PC, where address 0 is assumed to be (the start of) RAM.
Because this whole space is unified and byte-addressable, a peripheral register like DDRB or PORTB can be reached with an ordinary pointer, exactly like a normal variable — the datasheet documents each register's fixed address (for example, DDRB at 0x24, PORTB at 0x25 on the ATmega328P), its read/write access, and its reset (power-on) default value.
volatile unsigned char *ddrb =
(unsigned char *)0x24;
volatile unsigned char *portb = (unsigned char
*)0x25;
*ddrb |=
(1 << 5); // configure pin PB5 as
an output
*portb |= (1 << 5); // drive PB5 high
Using |= (OR-assign) rather than a plain assignment is a deliberate, important habit: it changes only the intended bit(s) and leaves every other pin's configuration untouched. Overwriting the whole register risks silently breaking other pins that may be wired to peripherals owned by other parts of a project — a general rule of thumb is not to touch bits your own code doesn't own.
Reworking the classic blink example this way — direct register pointers instead of pinMode()/digitalWrite() — cuts compiled size substantially in the course's measurements (roughly 940+ bytes down to about 640), and switching the toggle from separate OR/AND-based on/off code to a single XOR toggle (PORTB ^= (1<<5);) shrinks it further still (to about 602 bytes) while also cutting the clock cycles needed to change the pin's state from around 52 down to about 2 — one cycle to read the port, one to XOR and write it back (or as little as one cycle total if the other pins' current state doesn't need to be preserved). The underlying reason digitalWrite()/pinMode() cost so much more is that they perform extra runtime safety checks (e.g., confirming a pin is actually configured as output) that a developer who already understands the registers can safely skip.
2.6 Where Is main()? Arduino.h and the Hidden Wrapper
An Arduino sketch's setup()/loop() functions aren't the program's real entry point. Inspecting the Arduino IDE's verbose build output reveals a generated main.cpp that the IDE compiles alongside a sketch: it calls setup() once, then calls loop() repeatedly inside an infinite for(;;) loop with no exit condition — consistent with the bare-metal requirement (Part 2.4) that an embedded program never falls off the end of main. This main.cpp includes Arduino.h, which in turn declares the familiar Arduino API (pinMode, digitalWrite, digitalRead, millis, delay, and so on) and pulls in pieces of the underlying AVR library.
Understanding this hidden wrapper is what makes it possible to drop the Arduino IDE entirely: a hand-written Makefile (Part 1.7) that defines its own main(), calls avr-gcc directly with the right flags (Part 1.5), and uploads with avrdude (Part 1.9) reproduces exactly what the IDE was doing automatically — just without the extra convenience layer and its associated code-size cost.
2.7 The volatile Attribute
volatile tells the compiler that a variable's value can change through means the compiler cannot see in the normal flow of the code — most commonly, being written inside an Interrupt Subroutine (ISR) that is never explicitly “called” anywhere in the source. Without volatile, the compiler may reasonably conclude that a global variable which is never written to by any function it can see is effectively constant, and optimize away code that depends on it changing — for example, dropping an if (count == 2) check entirely, because from the compiler's point of view count could never become 2.
volatile uint8_t count = 0;
ISR(TIMER1_COMPA_vect) {
count++; // written
only here, from an interrupt
}
// in main():
if (count == 2) { count = 0; /* toggle the LED
*/ }
Adding volatile to count's declaration is what makes the compiler re-read the variable from memory on every access rather than trusting a cached value, restoring the intended behavior. This matters specifically for variables shared between an ISR and the main program flow — the interrupts chapter (Part 3) returns to this repeatedly, since it's one of the most common and hardest-to-notice bugs in embedded C.
Part 3 — GPIO & Interrupts
3.1 How a Pin Works Internally
Each I/O pin's internal circuitry breaks into four functional regions, matching the three registers introduced conceptually in Part 2.5 plus a sleep-related fourth:
• Data Direction (green) — a flip-flop, set by the DDR register, that acts as a switch: 0 disconnects the pin from the “write” logic (input mode), 1 connects it (output mode). Writing PORT while DDR is 0 still updates the underlying write flip-flop's stored state — it's simply not connected to the physical pin yet.
• Write Pin (red) — a flip-flop, set by the PORT register, that drives the pin's output level once Data Direction has connected it.
• Read Pin (blue) — the physical pin's voltage passes through a synchronizer (a small chain of flip-flops that re-times an asynchronous external signal onto the MCU's own clock domain, avoiding metastability) and then a buffer gated by the actual digitalRead()/PIN-register access, feeding the internal data bus. A pin can always be read, regardless of its configured direction — even a pin currently driven as an output can still be read back.
• Sleep (black) — during certain sleep modes, a SLEEP signal disconnects the read path from the synchronizer and actively pulls it to ground, so a pin read during those sleep modes always returns 0 no matter what's actually happening on the physical wire.
Because every pin has its own instance of these four regions, and the underlying registers (DDR, PORT, PIN) each pack 8 such pins together, a microcontroller with several 8-pin ports is really just many identical copies of this same per-pin circuit, addressed as a group.
3.2 Floating Pins, Sleep, and the General Purpose I/O Registers
An unconnected (“floating”) input pin has no defined voltage and can pick up electrical noise, causing its read-pin synchronizer to rapidly and randomly flip state — which wastes power switching for no useful reason. The fix is to enable the pin's internal pull-up (write 1 to its PORT bit while DDR stays 0, i.e. leave it as an input but pull it toward a defined high level) so the input settles to a stable, known value at essentially no power cost, since nothing is actually connected there to sink current.
The Digital Input Disable registers (DIDR0 for the ADC's pins, DIDR1 for the analog comparator's) exist for a related reason: an analog pin's digital input buffer left active while that pin is being used for analog sampling can itself waste power (or introduce noise) and should generally be disabled whenever the pin isn't also needed for digital logic.
Shared state between an ISR and the main loop needs careful handling. A naive pattern — disable interrupts (cli()), check and modify a shared flag, then re-enable them (sei()) — is correct but has real costs: it's easy to forget the wrapper somewhere, both cli()/sei() act as memory barriers (forcing pending register values out to SRAM, which is comparatively slow), and there's a subtle timing gap around going to sleep where a freshly-arrived interrupt can be missed until the next wake-up unless the check-then-sleep sequence is ordered carefully (checking that the flag is clear, immediately followed by sei() and sleep_mode(), relies on the AVR architecture's guarantee that the instruction immediately after sei() always executes before any pending interrupt is serviced).
A cleaner alternative for single-bit flags: the ATmega provides three General Purpose I/O registers (GPIOR0–GPIOR2) that aren't used by the compiler or tied to any pin. Storing a flag there instead of in an ordinary SRAM variable means the flag never needs a separate load-modify-store sequence — the AVR's single-instruction cbi (clear bit immediate) / sbi (set bit immediate) can flip one bit directly in 2 clock cycles, and because it's one instruction it can't be interrupted midway, making it atomic without any cli()/sei() wrapper at all.
The AVR library's sleep.h wraps the manual sleep sequence into convenient calls: set_sleep_mode(mode) selects a sleep mode (defined per-chip in headers like iom328p.h, pulled in via io.h, since not every AVR variant supports every mode), and sleep_mode() both enters and correctly cleans up after sleep (equivalent to sleep_enable(); sleep_cpu(); sleep_disable(); done manually). sleep_bod_disable() additionally powers down the Brown-Out Detector for extra power savings during sleep (Part 10 covers the Brown-Out Detector itself).
3.3 Interrupts vs. Polling
Polling means the CPU repeatedly checks a condition itself in a loop (e.g., continuously comparing a timer's count register against a target value) until it becomes true — simple, but wasteful: the CPU can do nothing else while waiting, and loop overhead makes exact timing (“exactly one second”) hard to guarantee. Interrupts flip this relationship: dedicated hardware watches for the event, and the CPU is only interrupted — automatically diverted to a handler function — once it actually occurs, leaving it free to do other work (or sleep) in between.
Timers expose this through internal “interrupt request” lines — OCnA/OCnB (compare match on unit A/B) and TOVn (counter overflow, i.e. wrapping from its maximum value back to zero) — which can be routed to fire an interrupt instead of requiring the program to poll the corresponding flag. Converting a polled CTC-mode one-second blink into an interrupt-driven one moves the toggle logic out of the polling while loop and into a small handler that only runs when the timer's compare-match interrupt actually fires.
3.4 The Interrupt Subroutine (ISR)
The AVR library's ISR(vector) macro defines a function that the compiler wires directly into the interrupt vector table rather than something explicitly called from elsewhere in the code — which is precisely why the compiler needs volatile (Part 2.7) to know a variable it touches can change outside of normal control flow. The vector name identifies exactly which interrupt source triggers it (e.g. TIMER1_COMPA_vect for Timer1's compare-match-A interrupt); the naming pattern generally follows the source name from the interrupt vector table with spaces replaced by underscores, plus a trailing _vect.
ISR(TIMER1_COMPA_vect) {
doSomething(); // runs
automatically on Timer1 compare-match A
}
Two special cases: an ISR can be aliased to also run for a second vector by declaring another ISR() for that vector and marking it as an alias of the first, since a single ISR() can't take two vector parameters directly. BADISR_vect defines a catch-all handler that runs if an interrupt fires with no ISR defined for it at all (distinct from EMPTY_INTERRUPT(vector), which explicitly declares “do nothing” for a specific vector that the developer intentionally wants to ignore).
3.5 The Interrupt Vector Table (IVT)
The IVT is a fixed table of jump instructions placed at fixed, hardwired addresses starting at address 0 in program memory — one slot per possible interrupt source, always in the same order for a given chip. Address 0 itself is the RESET vector, which is why every program “automatically” starts there: it's just a jump instruction to the beginning of the real startup code. When a configured interrupt fires, the CPU automatically jumps to that interrupt's fixed IVT slot, which in turn contains an unconditional jump to wherever the actual ISR code lives.
Disassembling a compiled program with avr-objdump -d shows this concretely: the vector table appears as a block of jump instructions at the very start of the binary (unused vectors typically jump to a shared __bad_interrupt handler, which itself jumps back to the reset vector — in effect, an unexpected/misconfigured interrupt resets the chip). One subtlety worth remembering when hand-reading a disassembly: addresses listed in the datasheet's interrupt vector table are word addresses (each unit is 2 bytes), while a disassembler like avr-objdump reports byte addresses — so a datasheet vector number has to be doubled to match the byte address seen in the disassembly.
3.6 Activating Interrupts: Local and Global
Interrupts need to be enabled at two levels. Each peripheral has its own local enable bits (for example, a timer's interrupt mask register has separate bits enabling the compare-match-A, compare-match-B, and overflow interrupts individually) — setting one of these arms that specific source. Separately, a single global interrupt enable bit (the I flag in the status register SREG) gates all interrupts at once; it defaults to 0 (disabled) after reset, and is toggled with the sei() (set interrupt — enable) and cli() (clear interrupt — disable) library calls, which compile directly to single AVR assembly instructions with no function-call overhead.
Disabling global interrupts is the standard way to protect a critical section of code that must not be interrupted under any circumstances. One consequence worth knowing: if a given interrupt condition (e.g. a timer overflow) occurs more than once while global interrupts are disabled, that repetition is not remembered — only one pending occurrence is serviced once interrupts are re-enabled, and any extra occurrences during the disabled window are simply lost.
3.7 External Interrupts: PCINTn Groups and INTn
Beyond internal sources (timers, communication peripherals, and so on), external interrupts are triggered by voltage changes on physical pins. Most ATmega328P pins can serve as Pin Change Interrupts (PCINT0–23), controlled by three 8-bit pin change mask registers (PCMSK0–PCMSK2, one bit per pin) and a Pin Change Interrupt Control Register (PCICR) that enables each of three groups of 8 pins (PCI0/PCI1/PCI2) as a whole.
The key limitation: the IVT has only one interrupt vector per group of 8 pins, not per individual pin — so if two enabled pins in the same group change, the same single ISR fires for either one, and the handler has to figure out on its own which specific pin actually changed. The standard technique: capture the port's current value immediately (as early as possible in the ISR, to minimize the chance it changes again before being read), XOR it against a remembered previous value to find which bits actually flipped, and test the relevant bit(s) of that XOR result to identify the pin. If distinguishing the exact pin unambiguously matters and this software approach isn't acceptable, the alternative is simply placing the pins of interest in two separate PCINT groups instead, so each gets its own dedicated vector.
Two dedicated pins (INT0 and INT1 on PD2/PD3) support a more selective mode via the External Interrupt Control Register (EICRA): rather than firing on any change, they can be configured to fire only on a rising edge, only on a falling edge, or on any logical level, avoiding the ambiguity a plain pin-change interrupt has about which direction the transition went.
The AVR has no dedicated software-interrupt instruction, but a workaround exists if one is genuinely needed: configure a pin as both an output and a pin-change-interrupt source, then toggle it in software to trigger its own ISR — though for straightforward cases, simply disabling global interrupts, calling the desired function directly, and re-enabling interrupts is usually simpler and avoids the pin/configuration overhead entirely.
3.8 Interrupt Priority and Nested Interrupts
If two interrupt conditions become true at effectively the same moment (e.g., two timer compare units matching on the same cycle), only one ISR can run at a time, and the interrupt vector table's fixed ordering settles the tie — lower vector number wins (RESET, at vector 0/address 0, has the highest priority of all). The lower-priority interrupt isn't lost, just deferred; its ISR runs immediately after the higher-priority one finishes.
Servicing any interrupt has fixed overhead: the current program counter is pushed so execution can resume afterward, the CPU looks up the ISR's address via the IVT (about 4 clock cycles), jumps to it (about 3 more cycles), and global interrupts are automatically disabled for the duration — so, by default, an ISR cannot itself be interrupted, and all of this unwinds via the special RETI instruction, which restores the saved program counter and re-enables global interrupts together.
Declaring an ISR with the extra ISR_NOBLOCK attribute overrides this default, allowing global interrupts to remain enabled while that specific ISR runs — meaning a higher-priority interrupt genuinely can preempt it mid-execution. This is called a nested interrupt; it's a deliberate opt-in specifically because unblocked ISRs reintroduce re-entrancy and shared-state hazards that the default (fully blocking) behavior avoids.
3.9 Good and Bad Practice Inside an ISR
Because interrupts are, by default, uninterruptible high-priority code, an ISR should do as little as possible and return quickly. Two patterns to avoid:
• Calling a delay function inside an ISR — since interrupts stay globally disabled for the ISR's whole duration, any other interrupt condition that becomes true during that delay is deferred, and if it recurs multiple times before the ISR finally returns, all but the last occurrence are silently lost (the same information-loss caveat as Part 3.6).
• Polling a peripheral's “ready” flag inside an ISR — for example, busy-waiting on an ADC completion bit. This blocks everything else for as long as the wait lasts. Where a polled condition has a matching interrupt of its own (the ADC does), the better pattern is to trigger the operation from the first ISR and let a second, dedicated ISR handle its completion, rather than nesting a wait inside a wait.
The general principle for this whole Part: treat an ISR as a short, atomic, high-priority function — move any nontrivial work (or waiting) back out into the main program loop, using a flag or small piece of shared state (ideally volatile, and ideally in a GPIO register per Part 3.2 if it's a single bit) to hand information from the ISR to the code that will actually act on it.
Part 4 — Timers, Counters & PWM
The ATmega328P has three timer/counter units (Timer0, Timer1, Timer2) that run automatically alongside the main program, driven by their own clock. They can count events, measure elapsed time, generate precisely timed interrupts, and produce waveforms — all without the CPU polling anything, provided they're paired with interrupts (Part 3). Timer0 and Timer2 are 8-bit; Timer1 is 16-bit and has extra capabilities covered later in this Part. The terms “timer” and “counter” are used interchangeably throughout.
4.1 Timer/Counter Fundamentals: MAX, BOTTOM, TOP
Each timer has a counting register, TCNTn (n identifies which timer), that increments by one on every rising edge of its clock signal. Three reference values recur throughout every timer mode:
• MAX — the largest value the counter's width allows: 0xFF (255) for an 8-bit timer, 0xFFFF (65535) for the 16-bit Timer1. Fixed by hardware, not configurable.
• BOTTOM — always 0, the lowest value the counter can hold.
• TOP — the configurable value the counter actually counts up to before resetting or reversing, which can be anywhere from BOTTOM to MAX. TOP equals MAX only in the simplest mode; every other mode gets its flexibility specifically from making TOP configurable.
If TCNTn is allowed to reach MAX without a lower TOP configured, the next increment overflows it back to 0 — there's simply no bit left to represent MAX+1.
Every timer shares a common internal shape: the TCNTn counting register; one or two Output Compare Registers (OCRnA, OCRnB) that TCNTn is continuously compared against; and configuration registers (TCCRnA, TCCRnB) controlling mode, clock source, and output behavior. Writes to TCNTn and the configuration registers take effect immediately, but OCRn is double-buffered: a value written to it is held in a temporary latch and only copied into the live comparison register at a specific point in the timer's cycle (the exact point depends on the mode, and matters a great deal for glitch-free PWM — see 4.5 and 4.6). Three families of mode exist: Normal, Clear Timer on Compare Match (CTC), and Pulse Width Modulation (PWM, itself split into Fast, Phase Correct, and — on Timer1 only — Phase and Frequency Correct).
4.2 Clock Sources and Prescalers
Timer0 and Timer1 can each take their clock either from an external pin (T0/T1, with a built-in edge detector so the timer literally counts external pulses — button presses, incoming signal edges, and so on) or from the internal I/O clock (the same 16 MHz system clock) divided by a configurable prescaler. A prescaler of 1 leaves the clock untouched; higher prescalers (2, 4, 8 … up to 1024, depending on the timer) slow the counting rate proportionally. The default clock source after reset is “no clock” — the timer is stopped — so selecting a clock source (the CSn2:0 bits) is mandatory before a timer does anything at all.
Timer2 has no external Tn pin or edge detector, but has a unique feature the other two lack: it can be clocked by its own crystal oscillator via the TOSC1/TOSC2 pins, entirely independent of the system clock, and supports two extra low prescaler values (32 and 128) that Timer0/Timer1 don't offer. A common, inexpensive application is connecting a 32.768 kHz watch crystal (about $0.50) to TOSC1/2 with a prescaler of 128: at that combination, Timer2's 8-bit counter overflows exactly once per second, turning it into an accurate, system-clock-independent real-time clock — useful, for instance, if the main clock needs to change or sleep for power savings without losing track of time.
4.3 Normal Mode and Clear Timer on Compare Match (CTC)
In Normal mode TCNTn simply counts up to MAX and overflows back to 0, with no early reset. Clear Timer on Compare Match (CTC) mode uses OCRnA as the TOP value: once TCNTn equals OCRnA, the comparator's equality signal both resets TCNTn to 0 and (optionally) toggles the OCnA output pin, generating a symmetric square wave whose period is set purely by OCRnA and the prescaler. Configuring CTC requires setting the WGM bits to select the mode, loading OCRnA with the desired TOP, choosing a clock source, and — if a physical waveform on the pin is wanted — setting COMnA/COMnB to enable pin toggling and configuring that pin as an output.
A useful formula falls out of this: the output frequency on OCnA is f_IO / (2 × prescaler × (OCRnA + 1)). The +1 accounts for the fact that counting from 0 up to OCRnA takes OCRnA cycles, plus one more cycle to actually roll over back to 0. With no prescaler at all, the fastest attainable output frequency is always half the I/O clock; larger OCRnA values or higher prescalers produce progressively lower frequencies. This makes CTC a natural way to generate an accurate clock signal for an external peripheral, or (paired with interrupts, as in Part 3.3) to fire code at precise, regular intervals without polling.
4.4 Fast PWM
Simply shortening the delay between LED toggles in a blink loop eventually reaches a point where the on/off transitions happen faster than the human eye can perceive, and the LED just looks dimmer rather than clearly blinking. The ratio of “on” time to the total period is the duty cycle (0% = always off, 100% = always on, 50% = equal on/off); varying it while holding the overall period fixed is exactly what's needed to control apparent brightness (or, more generally, an average output level) — and that's what PWM modes are for, as distinct from CTC, which instead varies the period itself while implicitly holding something like a 50% duty cycle.
In Fast PWM, TCNTn counts straight up to TOP (MAX by default, or a lower value set via OCRnA — see below) and then resets to 0, repeating continuously. The COMnx bits select how OCRnA/OCRnB events drive the pin: with COMnx = 2, the OCn pin is set high on overflow (start of each period) and pulled low when TCNTn matches OCRn; COMnx = 3 inverts that behavior. The result: OCRn's value (relative to TOP) sets the duty cycle for that period, while the period itself stays constant. Two edge cases: OCRn equal to TOP produces a constant (non-toggling) output, and OCRn equal to 0 produces a brief one-cycle spike each period.
Setting the WGM bits to select “mode 7” (all WGM bits = 1) combines CTC-style configurable TOP with PWM: OCRnA is then used to set TOP (controlling the period, as in CTC), while OCRnB independently sets the duty cycle for that period on the OCnB pin — letting a single timer control both frequency and duty cycle at once, at the cost of losing OCRnA as a second independent duty-cycle output.
4.5 Phase Correct PWM
Phase Correct PWM can produce the exact same duty cycle and period as Fast PWM, so the two modes exist for a different reason: how the output waveform behaves when OCRnx is changed. Instead of counting straight up and resetting, the counter in this mode counts up to TOP and then back down to BOTTOM before reversing again — a triangular count pattern rather than a sawtooth. The output pin is set/cleared based on which direction the counter is currently moving when it crosses OCRnx, which has the effect of centering the pulse symmetrically around the middle of each period rather than always starting flush at the beginning.
This centering (hence “phase correct”) is specifically valuable for motor control applications, where Fast PWM's edge-aligned pulses can occasionally produce a very short but nonzero glitch pulse (e.g. when OCRn briefly equals 0) — too brief to actually turn a motor, but still enough to draw current and waste power or introduce mechanical jitter. Phase Correct PWM avoids that failure mode by design. Fast PWM, in turn, is generally preferred for power regulation and DAC-like applications where a genuinely intermediate analog voltage is being generated (e.g., by filtering the PWM output through a capacitor): a shorter, higher-frequency period means a smaller capacitor is needed to smooth the signal, since it only has to hold charge for a much shorter interval — letting Fast PWM use physically smaller, cheaper external components for that purpose.
4.6 Phase and Frequency Correct PWM
Available only on Timer1, this mode looks nearly identical to Phase Correct PWM — same up/down triangular counting — but changes exactly when a new value written to OCRn actually takes effect (recall Part 4.1: OCRn is double-buffered). In Phase Correct PWM, a new OCRn value is latched in when the counter reaches TOP; in Phase and Frequency Correct PWM, it's latched in at BOTTOM instead.
This single difference matters because Phase Correct PWM's waveform is not always perfectly symmetric around TOP when OCRn changes mid-cycle — the portion of the waveform before vs. after the TOP crossing can differ slightly, occasionally dropping or altering a toggle. Phase and Frequency Correct PWM guarantees the waveform stays symmetric around TOP at all times; if perfect symmetry can't be maintained for a given toggle, that toggle is skipped entirely and deferred to the next period rather than producing a glitch — again, particularly valuable for motor-driving applications where an unexpected short pulse is undesirable.
This mode is selected via WGM bits 8 or 9, using either ICR1 or OCR1A (respectively) to hold TOP — introducing ICR1, the Input Capture Register, covered next.
4.7 Timer Differences and the Input Capture Unit
Summarizing what's unique to each timer: Phase and Frequency Correct PWM and the Input Capture Unit exist only on Timer1. Timer0 and Timer2 are 8-bit (max count 255); Timer1 is 16-bit (max count 65535), which is also why it needs more WGM bits to select among its larger set of modes. Timer0 and Timer1 can be clocked from an external pin with edge detection; Timer2 instead offers an independent crystal-driven clock and two extra prescaler options (Part 4.2).
The Input Capture Unit (ICU), unique to Timer1, is a fourth functional block alongside the two compare units. It takes an input signal from either the ICP1 pin or the Analog Comparator (selectable via ACIC, covered again in Part 5.10) optionally passes it through a noise canceler and an edge detector (configurable for rising or falling edge via ICES1), and — the moment a qualifying edge is detected — copies the current value of TCNT1 into the 16-bit Input Capture Register (ICR1), effectively timestamping that external event. Each new capture overwrites the previous one, so ICR1 needs to be read out promptly by software (or via its own dedicated interrupt) if consecutive events matter — for example, computing a signal's period from the difference between two successive captures.
A subtlety behind any 16-bit register access on an 8-bit MCU (TCNT1 and ICR1 both being 16-bit): the internal data bus is only 8 bits wide, so a 16-bit value has to cross it in two separate transfers. A hidden TEMP register handles this transparently — the high byte is latched into TEMP while the low byte crosses the bus, and combined automatically on the following access — preventing a torn read (e.g. reading a low byte just before an increment and a high byte just after, yielding a value that was never actually held by the counter). This mechanism requires no special handling in code; ordinary 16-bit reads/writes to TCNT1 or ICR1 just work correctly.
If the Input Capture Unit isn't needed, ICR1 can instead simply serve as the TOP register for Timer1's PWM/CTC modes (an alternative to using OCR1A for that purpose), freeing OCR1A to be used as an independent duty-cycle/compare register instead.
Part 5 — The Analog World: ADC & Comparator
5.1 Why Analog Values Need Converting
Digital logic only distinguishes two voltage ranges as valid — for a 5 V-powered ATmega, roughly below ~1.5 V reads as logic 0 and roughly above ~3 V reads as logic 1, with an undefined gap in between where a read could go either way depending on the specific chip. That's fine for on/off signals, but most real-world quantities (temperature, light, sound, a potentiometer's position) vary continuously and need more than two distinguishable states to be represented usefully.
An Analog-to-Digital Converter (ADC) solves this by mapping a continuous input voltage onto one of a fixed number of discrete integer levels — the ATmega328P's ADC uses 1024 levels (10 bits), so across a 0–5 V range each step represents about 4.88 mV, and any output integer (0–1023) can be converted back to an approximate voltage. More distinct levels means finer resolution, but never perfect accuracy, since a purely analog quantity can never be represented exactly by a finite number of discrete steps. Conceptually, the ADC's internal algorithm works much like a manual binary search: it tests each bit from most to least significant, tentatively setting it and checking (via an internal comparator and a fast internal digital-to-analog converter, or DAC) whether the resulting value is still below the actual input voltage, keeping the bit set only if so — this approach is called successive approximation, and it's why a 10-bit conversion takes a fixed number of internal comparison steps.
5.2 Reference Voltages
Every ADC reading is relative to a chosen reference voltage — the value that maps to the maximum output code (1023); any input above it saturates the reading at 1023. On the ATmega328P this reference is selected (via the ADMUX register's REFS bits) among three sources:
• AVCC — the analog supply voltage (on a 5 V Arduino, this gives the standard 5 V range and ~4.88 mV resolution). AVCC should always be connected even when the ADC goes unused, kept within ±0.3 V of VCC, and is best fed through a small low-pass filter to reduce supply noise.
• An internal 1.1 V reference — useful for measuring small-range signals (e.g. a sensor that only swings 0–1 V) at roughly 4× the resolution (about 1 mV/step) for free, with no extra external components.
• AREF — an external reference voltage supplied directly on that pin, which must stay between 1 V and AVCC. If AREF is driven externally, none of the internal reference options may be selected simultaneously, since there's no diode blocking reverse current flow between them — doing so risks current flowing from AVCC or the internal reference back through the AREF pin.
When AVCC or the internal 1.1 V reference is selected, that reference voltage is also made available at the AREF pin itself (mainly so it can be measured, not to power external circuitry — it can't supply meaningful current), and a 100 nF capacitor on AREF is recommended to reduce noise. Because that capacitor retains charge, switching reference sources isn't instantaneous: the internal 32 kΩ pull-down that discharges AREF gives an RC time constant meaning around 3.2 ms to settle to about 37% of the change, and closer to 22 ms to settle to 99.9% — so a reference-voltage change needs a deliberate settling delay in software before the next conversion can be trusted. Achieving an arbitrary reference voltage between 1.1 V and AVCC is possible by driving AREF through an external resistor divider, sized so the combination with the internal 32 kΩ pull-down still limits current through the pin to a safe level (the lecture material works through the resistor-divider math for a worked 1.8 V example, arriving at values around 4.2 kΩ and 5.9 kΩ for a 3.3 V source).
5.3 ADMUX: Selecting Reference and Input Channel
Besides the REFS bits (5.2), ADMUX controls two more things. ADLAR (ADC Left Adjust Result) determines whether the 10-bit conversion result is right-justified (LSB-aligned, the usual choice, split across ADCL/ADCH) or left-justified (MSB-aligned) in the two data registers — note this has nothing to do with big/little-endian byte order, despite the superficial resemblance; its real purpose becomes clear in the sample-rate discussion (5.5). The remaining MUX bits select which input channel is actually converted — one of the physical ADC0–ADC7 pins, or one of several internal sources: ground, the bandgap reference (5.9), or the internal temperature sensor (5.8).
A practical note when switching channels between conversions: residual charge can linger on the ADC's internal sampling capacitor from the previous channel, so the first reading immediately after a channel switch is best discarded (or preceded by a short settling delay) for an accurate result.
5.4 ADCSRA: Starting a Conversion
ADCSRA is the ADC's status/control register. ADEN is the master on/off switch (unlike most peripherals, the ADC defaults to off and must be explicitly enabled). ADSC (ADC Start Conversion) begins a sample once set; internally, the conversion logic performs the successive-approximation process described in 5.1, testing one bit at a time against the comparator.
A single conversion takes 13 ADC clock cycles (1.5 for sample-and-hold, then roughly one cycle per bit plus overhead) — except the very first conversion after ADEN is set, which needs 25 cycles because the sample-and-hold circuitry itself needs initializing. ADSC is automatically cleared once the conversion completes, and the ADIF (ADC Interrupt Flag) bit is set at the same moment to signal a result is ready. Changing MUX or REFS mid-conversion doesn't affect the conversion in progress — the new setting only takes effect starting with the next conversion, which avoids corrupting a reading in flight. Arduino's analogRead() call is, under the hood, just this ADSC-based flow wrapped with fixed default settings and a busy-wait for completion.
5.5 Sample Rate and ADLAR
The ADC's internal clock must run between roughly 50 and 200 kHz for full 10-bit accuracy — much slower than the 16 MHz system clock — so the ADPS prescaler bits divide the system clock down (the Arduino default uses a prescaler of 128, giving 125 kHz). At 125 kHz and 13 cycles per conversion (plus 1 to set ADSC), that caps the achievable sample rate at roughly 8,900–15,000 samples/second at full accuracy, depending on exact overhead assumptions.
The datasheet's headline sampling-rate figure (around 77 kSPS) is achievable only by deliberately overclocking the ADC beyond the accurate range, sacrificing the least significant bits of resolution for speed — up to roughly 1 MHz ADC clock is usable if only coarse readings are needed. This is exactly where ADLAR becomes genuinely useful: at high sample rates, the two least-significant bits are the least trustworthy ones anyway, so setting ADLAR to left-justify the result means a single read of ADCH alone gives an adequate 8-bit-ish answer with no bit-shifting or two-register arithmetic needed — versus the more verbose combination of ADCL and ADCH required to reconstruct the full 10-bit value when right-justified.
ADIF (5.4) can also drive an interrupt if ADIE is set, letting a program start a conversion, go do something else (or sleep), and be notified via ISR once the result is ready — a non-blocking alternative to the busy-waiting behavior of Arduino's analogRead().
5.6 Auto-Triggering and Free-Running Mode
ADATE (ADC Auto Trigger Enable), together with the ADTS bits (in ADCSRB), lets a conversion be started automatically by a source other than manually setting ADSC — options include an external interrupt pin, a timer overflow or compare match (useful for sampling at precise, fixed intervals tied to a timer, as in Part 4), or the ADC's own completion flag.
Selecting ADIF itself as the trigger source produces Free Running Mode: as soon as one conversion finishes (setting ADIF), that same event immediately starts the next one, giving the maximum achievable sample rate discussed in 5.5 with no software intervention needed to kick off each new conversion — only the very first conversion needs an explicit ADSC. Since only an edge on the trigger source starts a new conversion, ADIF must still be manually cleared (or cleared automatically by servicing its ISR) after each result is read, or the free-running sequence stalls.
A related subtlety when reading a free-running or auto-triggered result in code: because ADCH/ADCL together hold 10 bits split across two 8-bit registers, ADCH must be cast to a wider integer type before shifting it into the high bits of the combined value — otherwise the shift silently discards those upper bits and only ADCL's contribution survives. ADMUX is itself double-buffered like OCRn (Part 4.1): a channel changed mid-conversion in free-running mode only takes effect starting with the next conversion, so switching channels doesn't corrupt an in-flight reading.
5.7 DIDR: Noise, Power, and a Source of Randomness
A pin's digital input buffer (the flip-flop logic from Part 3.1's read-pin section) is designed for signals that sit cleanly in the logic-0 or logic-1 range. An analog signal, by contrast, often lingers in the undefined region between those ranges or fluctuates continuously — which can cause that digital input logic to toggle rapidly and pointlessly, wasting power and generating electrical noise on the pin. The Digital Input Disable Registers (DIDR0 for ADC pins, DIDR1 for the analog comparator's AIN pins) let this digital input path be turned off per-pin whenever a pin is being used purely for analog sampling, eliminating that waste.
A secondary, more curious use of ADC noise: a floating or antenna-connected pin's readings look essentially random, which can serve as a seed for a pseudo-random number generator — useful because the ATmega has no reliable independent entropy source (no clock-time-of-day, unlike a PC) and, absent something like this, would reproduce the exact same “random” sequence after every reset. This isn't cryptographically secure randomness, but it's a meaningfully better seed than a fixed constant for the many projects that don't need cryptographic-grade randomness.
5.8 The Internal Temperature Sensor and Its Calibration
The ATmega328P has a built-in temperature sensor selectable as an ADC input channel — convenient (no external part, no extra cost, board space, or power draw) but, unlike a dedicated external sensor (e.g. a DHT11/22, or the temperature sensor built into an RTC chip like the DS3231, both factory-calibrated to within a degree or a few degrees), it ships with no factory calibration at all. Uncalibrated, its readings can be off by roughly ±20°C from the true ambient temperature, and that error curve varies from one physical chip to the next even within the same part number — making per-chip calibration unavoidable if any real accuracy is needed.
Calibration works by modeling the sensor's error as a linear relationship between the actual and measured temperature, described by two constants: an offset (Toff, how far off the reading is at 0°C) and a gain factor (k, correcting the slope of the response). With two known calibration points (e.g., readings taken at two different verified ambient temperatures), both Toff and k can be solved for directly, giving actual = (measured − Toff) × k, which is accurate across the whole calibrated range. With only one calibration point available, a choice has to be made — either assume k = 1 and solve only for Toff, or assume Toff = 0 and solve only for k — and either choice is just an educated guess about the true shape of the error curve away from that single measured point; the resulting accuracy holds up best close to the calibration point (roughly ±1°C within about ±20°C of it) and degrades further away, with no way to know in advance which assumption will perform better for a given individual chip.
Since Toff and k are chip-specific, hard-coding them as constants in the program would require recompiling separately for every physical unit; the more practical approach is a one-time calibration routine that runs in a known, controlled-temperature environment on first boot and stores the resulting Toff/k values in the ATmega's EEPROM (Part 11 of this guide) for every subsequent run to read back. Two practical accuracy tips: always select the internal 1.1 V reference (not AVCC) for temperature readings, and, since noise from other active peripherals affects accuracy, consider using the ADC's low-noise sleep mode (Part 10) or a reduced CPU clock frequency while sampling.
5.9 The Bandgap Reference: Measuring Battery State
The bandgap reference is an internally generated voltage that's deliberately designed to stay very stable regardless of supply voltage or temperature — not perfectly constant (it varies a few millivolts with temperature, and droops slightly once VCC exceeds about 5 V), but stable enough to be treated as a fixed, known quantity (about 1.1 V) for practical purposes, especially if the operating temperature is roughly known.
This stability enables a clever reversal of the ADC formula: instead of using a known, fixed reference to measure an unknown input voltage, the bandgap reference (with its known, fixed value) is selected as the ADC input, while AVCC — normally the reference — is treated as the unknown. Rearranging the ADC conversion formula around a known bandgap voltage and an observed ADC reading yields AVCC directly, without needing any dedicated voltage-divider circuit or extra external components. Since AVCC tracks the actual supply/battery voltage, this becomes a free way to monitor battery state — for a Li-ion cell (e.g. an 18650, nominally operating between about 2.8 and 4.2 V), the raw ADC reading rising past a fixed threshold is enough to trigger a low-battery warning or shutdown in software, without ever needing to compute an actual voltage figure at all.
The main alternative — a resistor-divider directly sampling the battery voltage on an ADC pin — works too, but constantly draws a small current through the divider (unless switched off between measurements via an extra transistor), adding circuit complexity that the bandgap-reference trick avoids entirely.
5.10 The Analog Comparator
The Analog Comparator is a simpler, lower-power alternative to the full ADC for one common need: knowing only whether one analog voltage is higher or lower than another, without caring about the actual values. It compares its two dedicated pins, AIN0 (positive input) and AIN1 (negative input), and its output bit (ACO, in the ACSR control/status register) is 1 whenever AIN0 exceeds AIN1.
Key configuration bits in ACSR:
• ACD (Analog Comparator Disable) — unlike the ADC, the comparator is active by default after reset and must be explicitly disabled if unused, to avoid wasting power.
• ACBG — substitutes the same stable bandgap reference from 5.9 in place of AIN0, useful for comparing against a known fixed threshold without needing a separate reference voltage source wired to AIN0.
• ACI / ACIE — an interrupt flag and its enable, letting code react the moment the comparator's output changes rather than polling ACO continuously; ACIS1:0 further restrict exactly when ACI fires (any toggle, or specifically a rising or falling transition of ACO).
• ACIC — routes the comparator's output into Timer1's Input Capture Unit (Part 4.7) in place of the ICP1 pin, so a comparator threshold crossing can be timestamped in ICR1 just like an external capture-pin event.
If the ADC is powered off (ADEN = 0), setting ACME (Analog Comparator Multiplexer Enable — also part of the ADC's own control registers) repurposes the ADC's input multiplexer to feed AIN1 instead, giving the comparator access to any ADC channel, the bandgap reference, ground, or the temperature sensor in place of a dedicated AIN1 pin — effectively sharing the ADC's channel-selection hardware between the two peripherals when only one of them is in use at a time. Like the ADC pins, AIN0/AIN1 also have their own Digital Input Disable register (DIDR1) to reduce noise and power when used purely as analog inputs (Part 5.7).
The comparator's real advantage is simplicity for threshold-only use cases: detecting “is the temperature above 25°C” with the ADC requires calibrating and periodically sampling the temperature sensor; with the comparator, the threshold is set once by adjusting a reference voltage on AIN0, after which the program can sleep or do other work entirely and simply be notified (via ACI) the moment the threshold is crossed — no ongoing sampling required at all.
Part 6 — Serial Communication: UART/USART
6.1 The UART Protocol and Its Frame
UART (Universal Asynchronous Receiver/Transmitter) is a hardware unit that takes an 8-bit value, serializes it onto a single wire, and reconstructs it at the receiver — the interface behind Arduino's familiar Serial class. “Universal” refers to its configurability (data format, speed); “asynchronous” means transmission can begin at any moment with no prior handshake or setup exchange.
The line idles high when nothing is being sent. A frame begins with a start bit (the line pulled low), which the receiver uses to detect that a transmission is beginning. Between 5 and 9 data bits follow, then an optional parity bit, then one or two stop bits (always high). Everything about this frame — bit count, parity presence, stop-bit count, and bit duration (i.e. speed) — is configurable, but transmitter and receiver must agree on identical settings, since nothing in the frame itself announces how it's structured; a receiver configured differently from the sender has no way to tell where one field ends and the next begins.
Because there's no shared clock line, the receiver keeps its own internal clock, synchronizing it to the falling edge of the start bit and then sampling each subsequent bit roughly at the middle of its expected time slot. Any drift between the transmitter's and receiver's clock frequencies (clock skew) risks sampling at the wrong moment, especially for longer frames — the underlying reason both ends need well-matched, accurately configured baud rates (Part 6.7).
6.2 USART: Adding a Clock Line
USART adds a dedicated clock line (XCK) alongside the data line. Because the receiver can now synchronize directly to a shared clock instead of self-timing from a start bit, the start/stop bits used purely for that self-synchronization become unnecessary — shortening each frame and allowing meaningfully higher transfer rates and clock frequencies, since the receiver's clock-skew problem (6.1) simply doesn't arise when both sides share one clock signal.
6.3 Duplex, Half-Duplex, and Simplex
Three transmission arrangements are relevant on the ATmega:
• Simplex — the simplest: one dedicated TX/RX pair, data flows one direction only, no ambiguity about which side is master.
• Full duplex — both sides can transmit simultaneously over separate wires. Trivial in UART (each direction is already independent). In USART, it's more involved: only one side can drive the shared clock line (XCK) at a time, so a master/slave role has to be established for clock generation, and since the clock runs continuously to support duplex, the data line has to carry defined “dummy” filler data whenever there's nothing real to send — the receiver otherwise has no way to distinguish “idle, nothing to say” from genuine data. This buys higher throughput at the cost of continuous power draw from both a constantly clocking transmitter and an always-listening receiver.
• Half duplex — two TX/RX pairs sharing one physical link, taking turns; the ATmega has no dedicated hardware for this, so it typically needs external switching hardware, or a software convention (e.g., looping a transmitter's output back into its own receiver so it can detect a collision if both sides transmit at once, then backing off for a random interval before retrying).
6.4 The Parity Bit
Parity is a lightweight error check: even parity sets the parity bit so the total count of 1-bits (data + parity) is even; odd parity sets it so that count is odd. On mismatch at the receiver, the frame is flagged as corrupted and discarded — but the specific bit that flipped can't be identified, and since UART has no back-channel built into the frame itself, the receiver can't ask for a retransmission; that's left entirely to the application built on top.
Parity only reliably catches an odd number of bit flips within a frame — an even number of flips can cancel out and go undetected. Stronger error detection (e.g., CRC — Cyclic Redundancy Check, used in USB, Ethernet, Bluetooth, and 1-Wire, among others) is needed where that's not acceptable.
6.5 UART Hardware Blocks: Clock, Transmitter, Receiver
The UART/USART peripheral splits into three largely independent blocks connected mainly by a shared clock signal: a clock generator (built around the UBRRn baud-rate register and, in USART mode, the XCK pin), a transmitter, and a receiver. In pure UART mode (no shared external clock), transmitter and receiver are fully independent of each other, which is exactly what makes one-directional simplex communication possible.
The transmitter copies a byte written to UDRn into an internal shift register and clocks it out least-significant-bit first onto TxDn, appending a parity bit if enabled. The receiver mirrors this: it shifts incoming bits from RxDn into its own shift register, checks parity if configured, and makes the completed byte available for reading — in the same register, UDRn. Writing UDRn always addresses the transmitter's copy; reading UDRn always returns the receiver's copy — one register name, two physically distinct underlying registers, selected automatically by whether the access is a read or a write.
6.6 Configuration Registers: UCSRnA–UCSRnC
Three control/status registers configure and report on the UART/USART unit:
UCSRnA
• RXCn — set when UDRn holds a valid, unread received byte; cleared automatically on read.
• TXCn — set once transmission is fully complete (data copied out of UDRn and entirely shifted out).
• UDREn — set once UDRn's previous contents have been copied into the transmit shift register, meaning the next byte can already be written to UDRn while the current one is still being shifted out — UDREn always becomes set before TXCn for the same byte.
• FEn, DORn, UPEn — error flags for a framing error (bad stop bit), a data overrun (a new byte finished arriving while the previous one was still unread), and a parity error, respectively; all three flags must be written as 0 whenever the register is written.
• U2Xn — covered in 6.7 (doubles the transfer rate at the cost of receiver timing margin).
• MPCMn — Multi-Processor Communication Mode (relevant to bus arrangements covered alongside SPI/I2C multi-device topics).
UCSRnB
• RXCIEn, TXCIEn, UDRIEn — interrupt enables for received-data, transmit-complete, and transmit-buffer-ready, respectively, letting all three UCSRnA flags above drive interrupts instead of being polled.
• RXENn, TXENn — individually enable the receiver and transmitter (both default off; enabling one is what actually connects the corresponding pin to UART logic instead of leaving it as an ordinary GPIO pin) — setting only one of the two is exactly how simplex communication (6.3) is configured in practice.
• UCSZn2 — the third (high) bit of the data-bit-count setting, whose other two bits live in UCSRnC (see below).
• RXB8n, TXB8n — hold a 9th data bit, if 9-bit frames are configured; TXB8n must be written before writing the low 8 bits to UDRn.
UCSRnC
• UMSELn1:0 — select UART (00, the default) vs. USART (01) mode.
• UPMn1:0 — parity: disabled, even, or odd.
• USBSn — number of stop bits (1 or 2).
• UCSZn1:0 — together with UCSZn2 (in UCSRnB) selects the data bit count.
• UCPOLn — clock polarity/edge selection; only relevant in USART mode (set to 0 for plain UART) — the same underlying idea as SPI's clock polarity/phase settings, revisited in Part 7.3.
6.7 Transfer Speed: UBRRn and U2Xn
The UBRRn register value that produces a given baud rate is computed as UBRRn = F_CPU / (n × baud) − 1, where n is 16 normally or 8 when U2Xn is set (i.e., U2Xn effectively doubles the achievable transfer rate, at the cost of halving the timing margin available to the receiver for recovering the clock and data correctly). Because this formula rarely produces an exact integer, UBRRn has to be rounded, which introduces a small baud-rate error between the configured rate and the one actually achieved — worth checking, since a large enough mismatch between transmitter and receiver clocks can itself cause transmission errors, independent of parity or framing correctness.
Practical tools remove the need to do this arithmetic by hand: online calculators (e.g. the WormFood AVR Baud Rate Calculator) and the AVR library's setbaud.h header (given F_CPU and a desired BAUD, it computes UBRRH_VALUE, UBRRL_VALUE, and whether USE_2X should be defined) both report the resulting error percentage, and setbaud.h specifically emits a compiler warning if that error exceeds a safe threshold.
A brief terminology note: “baud” technically means symbols per second, not bits per second — a holdover from modem-era communication where each analog symbol could encode several bits at once — but in the UART/USART context (one bit per symbol), the two are effectively the same number, which is why the terms get used interchangeably today.
6.8 Worked Example: A Simplex UART Receiver
A representative minimal program: send an ASCII '1' from a PC terminal to turn the onboard LED on, and '0' to turn it off. The initialization sequence, once setbaud.h has computed the needed constants:
#define F_CPU 16000000UL
#define BAUD 9600
#include <util/setbaud.h>
void uart_init(void) {
UBRR0H
= UBRRH_VALUE;
UBRR0L
= UBRRL_VALUE;
#if USE_2X
UCSR0A
|= _BV(U2X0);
#else
UCSR0A
&= ~_BV(U2X0);
#endif
UCSR0C
= _BV(UCSZ01) | _BV(UCSZ00); // 8 data bits
UCSR0B
= _BV(RXEN0) | _BV(RXCIE0); // enable RX
+ RX-complete interrupt
}
ISR(USART_RX_vect) {
unsigned char received = UDR0;
if
(received == '1') { PORTB |= _BV(PORTB5); }
else
if (received == '0') { PORTB &= ~_BV(PORTB5); }
}
A few implementation details worth flagging: the character comparisons use the ASCII characters '1' and '0' (single-quoted), not the integers 1 and 0 — an easy mix-up. The correct ISR vector name (USART_RX_vect on the ATmega328P) is most reliably found by searching the AVR library's interrupt documentation for the exact part number, since similarly named vectors (UART0_RX, UART_RX, USART0_RXC) exist for other chips in the AVR family and won't compile or won't fire on this one. And compiling with -Wall (Part 1.5) is genuinely valuable here — in the course's own walkthrough, a stray assignment (=) where a comparison (==) was intended is exactly the kind of bug the compiler's warnings catch immediately, before it becomes a confusing runtime mystery.
Part 7 — SPI
7.1 Where SPI Shows Up
SPI (Serial Peripheral Interface) is extremely common in practice: SD cards (full-size and micro) use it, as do many SPI flash memory chips that store firmware in routers and other embedded devices, Ethernet controller chips like the ENC28J60 (the chip behind common Arduino Ethernet shields), and thermocouple amplifier ICs that digitize an analog temperature probe signal for readout. The ATmega328P itself has a built-in SPI unit and — notably — SPI is also the mechanism used to program the chip's flash memory and fuses when no bootloader is present (Part 12 of this guide covers this in depth). Fuses are special non-volatile configuration bits (clock source and similar low-level settings) deliberately made hard to write accidentally, since misconfiguring them can render the chip unresponsive (“bricked”) without specialized high-voltage recovery hardware.
7.2 SPI Topologies: Single Slave, Multiple Slaves, Daisy Chain, Quad SPI
The four standard SPI signals: MOSI (Master Out, Slave In), MISO (Master In, Slave Out), SCK (the shared clock, always generated by the master), and SS (Slave Select, active-low on most devices — pulling a slave's SS line low is what tells that specific slave to pay attention to the bus).
Both master and slave hold their data in shift registers; every SCK pulse shifts one bit from master to slave and simultaneously one bit from slave to master — the transfer is inherently full-duplex and bidirectional in both directions at once, on every single clock pulse. This has a direct practical consequence: if only one side actually has meaningful data to send, the other side still has to send something (a dummy/filler byte), since a transfer always moves exactly 8 bits in both directions together; SPI has no way to send in only one direction.
With multiple slaves on one bus, MOSI/SCK are shared, but each slave needs its own dedicated SS line — without one, every slave shifts in the master's data simultaneously (garbling nothing, since they're all just receiving), but multiple slaves driving MISO back at the same time causes a genuine electrical conflict (a short circuit) if their outputs disagree. Pulling exactly one slave's SS low before clocking is what avoids that.
An alternative wiring, daisy chaining, avoids needing one SS line per slave at all: slaves are chained MISO-out-to-MOSI-in in series, and clocking the whole chain shifts data through every slave in sequence like a shift register spanning the whole bus — saving wiring at the cost of more complex software (each slave has to shift out an equal-length dummy byte for however many devices sit behind it in the chain, and there's inherent ambiguity about whether incoming data on a given slave is meant for it or is simply passing through from an earlier device).
SPI isn't formally standardized beyond the broad shift-register concept, so implementations vary: some devices (e.g. certain temperature-sensor ICs) combine MISO/MOSI into a single bidirectional data pin, which can be adapted to a standard 4-pin SPI master by inserting a resistor (e.g. 10 kΩ) between MOSI and the shared data line, letting the ATmega's own MOSI output loop back onto its MISO input harmlessly (simply ignored in software). Quad SPI (QSPI) is a higher-throughput variant using four data lines (IO0–IO3) instead of one, transferring 4 bits per clock edge once switched into that mode via an initial standard single-line SPI exchange — common on SPI flash memory chips, where it lets a full address be sent in far fewer clock cycles, followed by a brief turnaround period while the bus direction reverses for the slave's reply. Some SPI devices push further still with double data rate transfers, sampling on both clock edges rather than just one.
Because of this variability, a new SPI device's datasheet always needs checking before wiring it into an existing bus — differing clock polarity, phase, bit order, or pin conventions between devices can make otherwise-compatible-looking parts unable to share a bus without extra care.
7.3 The SPI Control Register (SPCR)
SPCR configures the SPI unit:
• SPIE — enables an interrupt on transfer completion (the interrupt itself is signaled by the SPIF flag in SPSR, Part 7.4).
• SPE — the master enable switch; without it, nothing happens and the SPI pins behave as ordinary GPIO.
• DORD — bit order: most-significant-bit-first or least-significant-bit-first, chosen to match whatever a given slave device expects.
• MSTR — selects master mode (this device generates SCK). Important gotcha: if the SS pin is left configured as an input (its default state) and something external pulls it low, MSTR is automatically cleared by hardware — interpreted as another device on the bus claiming the master role. The safe practice is to explicitly configure SS as an output before configuring MSTR, so this automatic hardware behavior can't silently undo the intended configuration.
• CPOL — clock polarity: 0 means SCK idles low (leading edge rises, trailing edge falls); 1 means SCK idles high (leading/trailing reversed).
• CPHA — clock phase: 0 means data is sampled on the leading edge (and shifted/set up on the trailing edge); 1 reverses that. Combined with CPOL, these two bits define SPI's four standard “modes”; a slave's datasheet specifies which mode it expects, and mode 0 and mode 3 are, in practice, the most commonly encountered.
• SPR1:0 — clock prescaler (dividing the system clock down to the SCK frequency), combined with SPI2X in SPSR (Part 7.4) for a wider range of achievable speeds.
One hard timing constraint worth remembering: if the ATmega is configured as a slave, the incoming SCK frequency must not exceed roughly one quarter of the ATmega's own system clock for reliable operation — at a 16 MHz system clock, that puts a practical ceiling of about 4 MHz on SCK, which (since SPI transfers one full-duplex bit per clock with no protocol overhead) corresponds to a theoretical maximum throughput around 8 Mbit/s in each direction simultaneously.
7.4 The SPI Status and Data Registers (SPSR, SPDR)
SPSR is comparatively simple: SPIF is set once a transfer completes (usable either polled or as an interrupt source together with SPIE), and is also set if the device is configured as master but its SS pin gets pulled low externally — signaling that another device on the bus has claimed the master role, relevant if a design needs to support switching master roles dynamically. WCOL flags a write collision — an attempt to write SPDR while a transfer is still in progress — and should never actually occur in a correctly written program, since software should always wait for SPIF before touching SPDR again. SPI2X (paired with SPR1:0 in SPCR) doubles the SCK frequency, effectively halving the prescaler.
SPDR is both the data-in and data-out register: writing it immediately starts a transfer (shifting the written byte out while simultaneously shifting an incoming byte in from the other side), and reading it afterward retrieves whatever the other side sent back during that same transfer — which may simply be a dummy byte, safely ignorable if there was nothing meaningful to read.
7.5 The Universal Serial Interface (USI) on ATtiny
Smaller AVR parts like the ATtiny series generally lack dedicated hardware UART, SPI, and I2C peripherals, and instead offer a single, more general building block called the USI (Universal Serial Interface) that can be configured in software to approximate any of the three. Its core is an 8-bit shift register (USIDR) whose output feeds either a single data-out pin (for protocols with physically separate in/out lines, like SPI and UART) or a shared bidirectional pin (for protocols like I2C, where one wire serves as both). A 4-bit counter tracks shifted-bit count and can trigger an interrupt after a full byte (8 bits, i.e. 16 clock edges) has shifted through, letting software wait for interrupt notification rather than continuously polling.
The shift clock can be sourced from the USCK pin directly (or its inverted complement, to support different SPI clock-phase requirements), from a Timer0 compare match (letting a timer stand in for an external clock — the same technique used to bit-bang a UART or 1-Wire-like timing pattern in software when no dedicated hardware exists for that protocol), or toggled purely in software via the USICLK control bit (which can even be used to generate a software-triggered interrupt on demand, independent of any actual external signal).
What the USI does and doesn't handle differs meaningfully by protocol: for SPI, essentially all that's needed is loading USIDR and clocking it — the shift register mechanics match SPI directly. For I2C, more has to be done manually in software: start and stop conditions (Part 8 covers these) aren't automatically generated as they are on the ATmega328P's dedicated I2C/TWI hardware, so software has to drive the exact SDA/SCL sequences by hand, in the correct order, to stay protocol-compliant — a dedicated Two-Wire Control block on the USI does at least automatically detect an incoming start condition when acting as an I2C slave, which is useful for waking the chip from a low-power sleep mode on an incoming request. In short, USI provides hardware-assisted shifting with software-driven protocol logic — a middle ground between fully dedicated peripheral hardware and pure bit-banging.
Because it's general-purpose, the USI's pieces can also be repurposed outside of any communication protocol entirely: its 4-bit counter functions as a small standalone timer (counting up to 15), and chaining it off Timer0's own compare-match output effectively extends an 8-bit Timer0 into a combined 12-bit counter on ATtiny parts that lack a native 16-bit timer.
Part 8 — I2C/TWI
I2C (Inter-Integrated Circuit), also called TWI (Two-Wire Interface) on Atmel/Microchip parts, is a decades-old, widely used bus protocol supporting speeds from 100 kbit/s up to 5 Mbit/s in its newest revisions. Unlike UART/SPI's point-to-point wiring, I2C is explicitly a bus: many devices — real-time clocks, sensors, EEPROMs, and more — can share the same two wires, each individually addressable.
8.1 Where I2C/TWI Shows Up
Common I2C devices include real-time clock (RTC) modules (which often bundle a small I2C EEPROM alongside the clock chip on the same breakout board), ambient light sensors (e.g. the BH1750), and standalone I2C EEPROMs offering far more non-volatile storage than the ATmega's own built-in EEPROM (only about 1 KB — Part 11 of this guide covers it). For a battery-powered data-logging project, an external I2C EEPROM is often a better fit than relying on radio communication to offload data to another system, since communication is comparatively power-expensive.
8.2 The Bus: Open-Drain Signaling and Pull-Ups
I2C uses two lines, SDA (data) and SCL (clock) — on the ATmega328P, pins PC4 and PC5. Both lines are open-drain: no device actively drives them high; instead, external pull-up resistors (to VCC) hold them high by default, and any device on the bus can pull a line low by connecting it to ground. This is why I2C's low state is called “active low” — producing a 0 requires active participation, while a 1 simply means “nobody's pulling it down.” Omitting the pull-up resistor and directly connecting a line to a switch-to-ground would create an outright short circuit between VCC and ground whenever that switch closes.
Practical bus limits: cable runs are typically only around a meter or so (with the exact distance workable at a given speed depending on wire quality, shielding, and the number/type of attached devices), and total bus capacitance is generally recommended to stay under about 400 pF — a property sometimes referred to as the bus's “weight,” which increases with wire length, device count, and proximity between wires. The physical topology can be a simple bus, a star, or other arrangements, as long as this capacitance budget is respected.
8.3 Transferring a Bit: Master-to-Slave and Slave-to-Master
Only the master can drive SCL; both master and slave(s) can pull SDA low, but a slave can only sense SCL, never drive it. The master generates the clock by repeatedly pulling SCL low and releasing it; by protocol rule, SDA may only change state while SCL is held low, and is sampled by the receiving side while SCL is high.
To send a bit from master to slave: the master pulls SCL low (data is now free to change), sets SDA to the desired bit value (pulling it low for a 0, or simply releasing it for a 1), then releases SCL high again — the slave samples SDA while SCL is high. To receive a bit from slave to master, the roles on SDA reverse: the master still generates SCL, but the slave is the one driving (or releasing) SDA during the high phase, and the master does the sampling.
The protocol's start and stop conditions are deliberately built as controlled violations of the “SDA changes only while SCL is low” rule, which is exactly what makes them universally recognizable on the bus: a start condition is SDA pulled low while SCL is still high; a stop condition is the reverse sequence — SCL released high first, then SDA released high after it. Every device on the bus watches for these specific illegal-looking transitions to know when a transfer is beginning or ending; between a start and its matching stop, the bus is considered reserved by that master no matter how slowly it's clocking data.
8.4 Addressing: 7-Bit, 10-Bit, and the Frame Format
A full I2C transfer: a start condition, a 7-bit slave address, a direction bit (0 = master writes to slave, 1 = master reads from slave), then a mandatory single-bit acknowledgment (ACK) from the addressed slave after every 8 bits transferred — the slave pulls SDA low to ACK, or leaves it high (NACK) to signal it's not present, not ready, or didn't understand. Multiple data bytes, each individually acknowledged, can follow before a stop condition finally ends the transfer.
Of the 128 possible 7-bit addresses, 16 are reserved. Some of these reserved addresses are used to extend addressing to a full 10-bit space for buses with many devices: no ordinary 7-bit-address device is allowed to use an address beginning with the reserved bit pattern 11110, so a 10-bit-address slave can be unambiguously recognized by that same leading pattern, distinguishing it from any 7-bit device on the same bus. Reading from a 10-bit slave requires an extra step: after sending the first two bits of the address with a write direction bit and the second address byte, the master issues a repeated start (a fresh start condition without an intervening stop, which keeps the bus reserved for the same master rather than releasing it to a competitor) and re-sends the leading address bits with the direction bit now set to read, before the slave can actually begin replying.
Compared to UART: I2C shares one line for both directions per transfer (inherently half-duplex, not the “Universal” flexible-format kind of protocol UART is), but is a genuine multi-device bus rather than a fixed point-to-point pair, and — critically — has a built-in acknowledgment mechanism that UART entirely lacks, letting a master know immediately whether a byte was actually received. In exchange, I2C has no built-in parity or other error-detection field; any such check has to be added in software if it's needed.
8.5 Multi-Master Conflict Resolution
Two masters can begin transmitting simultaneously, and I2C resolves the resulting conflict automatically, without any explicit arbitration message. As both masters clock out a start condition and the leading bits of their target address, no conflict is detectable as long as both are writing the same bit values — the bus simply reads whatever both agree on, indistinguishable from a single master.
The moment their address bits actually diverge (one writing a 0, the other a 1) is where arbitration happens: since a bit that's actively pulled low always overrides one that's merely released high, the bus reads 0 regardless of what the losing master intended. Every master, while transmitting, simultaneously listens to what's actually on the bus and compares it to what it just wrote — the master that wrote 1 but reads back 0 immediately recognizes it's lost arbitration to another device with higher address priority, and drops out, waiting for the bus to go idle (a stop condition) before it can retry. The master whose bit matched what it read simply continues, entirely unaware a conflict even happened.
8.6 I2C/TWI Registers on the ATmega328P
TWBR sets the SCL clock frequency, computed from SCL freq = F_CPU / (16 + 2 × TWBR × prescaler), where the prescaler (configured via TWPS bits in TWSR) is one of 1, 4, 16, or 64. TWDR holds the byte currently being shifted out (data or an address+direction byte) or, after a read, the most recently received byte — it may only be written once TWINT is set.
TWCR (the control register) drives the protocol:
• TWINT — set by hardware whenever the TWI unit is ready for the next software action (e.g., after a start condition finishes transmitting, or after a byte transfer completes); unlike most interrupt flags covered so far, TWINT is not cleared automatically when its ISR finishes — it must be cleared manually by writing a 1 to it (not 0), and doing so is also what releases the bus to continue the operation, so software must load TWDR and check status before clearing TWINT for the next step.
• TWIE — together with global interrupts, lets TWINT drive an interrupt rather than requiring it be polled.
• TWEA — controls whether an acknowledgment is automatically sent after a received byte (as master reading from a slave) or after an address match (as slave); clearing it makes the device effectively unresponsive/unacknowledging on the bus.
• TWSTA — requests a start condition be sent once the bus is free (waiting for an in-progress transfer's stop condition first if necessary); should be cleared again once the status register confirms the start condition was actually sent.
• TWSTO — requests a stop condition, releasing the bus; cleared automatically once it's been sent. In slave mode, writing this bit is also a way to force the TWI hardware unit back to a known reset state if something has gone wrong.
• TWWC — flags a write collision (TWDR was written while the hardware was still mid-transfer); shouldn't occur in a correctly sequenced program that always waits for TWINT before touching TWDR.
• TWEN — the master enable switch, connecting the physical SDA/SCL pins to the TWI hardware; without it, nothing works.
TWSR (status register) reports, in its upper bits (the lower bits hold the prescaler setting from TWBR's frequency formula — mask them out when checking status), a numeric code describing exactly where the current transfer stands — for example, 0x08 confirms a start condition was successfully transmitted, while 0x38 indicates the master lost bus arbitration (Part 8.5) mid-transfer. Different sequences of codes apply depending on whether the device is acting as a master writing, a master reading, or in either slave role — the datasheet's status-code tables are the authoritative reference for each case, and, compared to UART's small handful of status flags, this code-driven state-machine style of programming is considerably more involved to implement correctly.
TWAR configures this device's own slave address (used when the ATmega itself is addressed as a slave) and includes a general-call-support bit (address 0 is a broadcast reaching every general-call-enabled slave on the bus at once, with no way for the sending master to know how many devices actually received it). A companion TWAMR (address mask register) lets specific bits of the configured slave address be treated as “don't care” during address matching — useful for a single physical device to respond to more than one related address (for example, separate addresses for “report temperature only,” “report light only,” and “report both” from one multi-sensor board).
8.7 Mimicking Protocols: Fitting I2C, SPI, and UART onto USI
Smaller AVR parts like the ATtiny series trade dedicated UART/SPI/I2C hardware for the single general-purpose USI (Part 7.5), and its usefulness rests on a real structural observation: UART, I2C, SPI, and even the DHT/1-Wire-style protocols (Part 9) share more in common than they first appear to. All of them idle high by default (SPI being the partial exception — MOSI's idle value doesn't actually matter as long as SCK isn't being clocked); most have a dedicated clock line, or can be given one (even UART, once upgraded to USART); the start of a transfer is signaled by pulling the data line low in nearly every case (UART's start bit, I2C's start condition); and every one of them is fundamentally a serial, bit-by-bit shift-register-style transfer at its core.
What differs between them is layered on top of that common shifting mechanism — I2C's addressing and acknowledgment scheme, SPI's separate MISO/MOSI wires and chip-select lines, UART's configurable frame format — and it's precisely that protocol-specific layer that USI leaves to software, while providing hardware assistance only for the universal part: shifting bits in and out on a clock edge. The next Part of this guide (1-Wire) revisits this same theme from the other direction — not simplifying multiple hardware peripherals down to one shared block, but instead showing how a UART peripheral's transmitter and receiver, individually, can be creatively repurposed to bit-bang a protocol (1-Wire) the chip has no dedicated hardware for at all.
Part 9 — 1-Wire
1-Wire (a Maxim/Dallas protocol) lets multiple devices communicate — and, in many cases, draw their operating power — over a single data wire plus ground. The ATmega328P has no dedicated 1-Wire hardware, so using it means either adding an external protocol-bridging chip or implementing the timing entirely in software (“bit-banging”).
9.1 The DHT11/DHT22: Similar, But Not 1-Wire
The popular DHT11/DHT22 temperature-and-humidity sensors communicate over a single data pin and are commonly assumed to be 1-Wire devices — they are not, despite superficial similarities. Both sensors share the same pinout (VCC, a data pin with roughly a 4.7–5 kΩ pull-up to VCC, an unconnected pin, and ground) and both start a reading by having the master pull the line low for at least 18 ms, then release it; the sensor answers with an 80 μs low / 80 μs high “presence pulse” confirming it's there and functioning, followed by the actual temperature/humidity data.
The key technical difference from real 1-Wire (9.4) is how a data bit's value is encoded: in the DHT protocol, the sensor always pulls the line low for the same ~50 μs, then the duration of the following high period is what encodes the bit — roughly 26–28 μs for a 0, roughly 70 μs for a 1 — so a bit's value is read by timing the gap between two edges. Genuine 1-Wire instead keeps every bit's total slot duration constant and instead varies when within that slot the line is pulled low (Part 9.4). The DHT protocol also has no addressing scheme at all — it assumes exactly one sensor on its dedicated line — whereas 1-Wire is a true multi-device bus with unique per-device IDs. Because these timing windows are narrow, any interrupt service routine that could run long enough to delay reading an edge is a real risk during a DHT (or 1-Wire) bit-banged transfer.
9.2 The 1-Wire Bus and Device IDs
Every 1-Wire device carries a unique, factory-programmed, read-only 64-bit ID, structured as an 8-bit family code (identifying the device type), a 48-bit serial number, and an 8-bit checksum. Unlike an I2C address (unique only within one bus, and freely reused across separate buses), a 1-Wire ID is globally unique — no two 1-Wire devices anywhere share one, which is precisely what makes the Search ROM algorithm (9.5) work reliably across arbitrary combinations of devices.
1-Wire is inherently half-duplex (a single shared wire) and supports only one master on the bus at a time, running at 15.4 kbit/s normally or up to 125 kbit/s in an optional “overdrive” mode — slower than I2C or SPI, but with minimal wiring. As with I2C's bus “weight” (Part 8.2), the practical cable length/topology limit depends on total bus capacitance, which grows with wire length, device count, and whether devices are drawing parasitic power (9.3); a strict bus topology isn't required — star and other wiring arrangements are workable within that same capacitance budget.
9.3 Parasitic Power
Some 1-Wire devices (a button-cell-shaped access-control “knob”, and small 1-Wire EEPROMs among them) have no independent battery or power pin at all — only ground and a single data/I-O pin. The data line's pull-up resistor keeps it charged high most of the time, and that same charge is what powers the device: an internal transistor-and-capacitor circuit stores charge while the line is idle-high, then supplies the device from that stored charge during the brief periods the master pulls the line low to transmit a 0. Because the device is effectively siphoning off power from what looks like a data line, this technique is called parasitic power — it works because the low periods used to encode data are short enough that a small onboard capacitor can bridge them.
9.4 The 1-Wire Protocol: Reset, Presence, and Bit Timing
Every 1-Wire bit slot has the same fixed total duration (60 μs), and it's when within that window the line goes low — not how long the low period lasts — that encodes the bit, the opposite of the DHT protocol's approach (9.1). To send a 0, the master pulls the line low and holds it low for the full 60 μs. To send a 1, the master pulls it low only briefly and then releases it, letting it recharge well before the slot ends. All participants sample the line's value at roughly the 15 μs mark within each slot. A slave replying to the master follows the same shape: the master pulls the line low and releases it, and the slave either holds it low for the remainder of the slot (a 0) or leaves it alone to recharge (a 1), with the master sampling at the same 15 μs point.
A full exchange starts with a reset pulse: the master holds the line low for 480–640 μs, resetting every attached slave's communication state, then releases it. Any slave present responds with a presence pulse (60–240 μs low), letting the master know at least one device exists on the bus — though not how many. Next comes a ROM command (bus-wide, not device-specific) — common ones include Read ROM (read a single device's ID directly, valid only when exactly one device is present), Skip ROM (bypass addressing entirely when only one device is on the bus), and Search ROM (9.5), used to enumerate every device present. If a specific device was addressed, a device-specific command follows (e.g., for an EEPROM: read or write memory at a given address, erase, and so on), after which the actual data transfer happens in whichever direction that command implies, ending when both sides withdraw and the bus returns to idle-high.
9.5 The Search ROM Command
Search ROM lets a master discover every device's 64-bit ID on a bus with no prior knowledge of what's connected, using a clever three-step-per-bit elimination process repeated once per bit position, with the whole sequence re-run as many times as needed to resolve every device:
• Step 1: every still-participating slave writes the current bit of its own ID onto the bus simultaneously; because a device pulling low always wins over the pull-up, the master reads a 0 if any participating slave has a 0 at that position.
• Step 2: every slave writes the complement of that same bit; the master reads a 0 here if any participating slave has a 1 at that position.
• Comparing the two reads tells the master what's actually present at this bit position: both 0 means some slaves have 0 and others have 1 (an unresolved conflict); 0 then 1 means all remaining slaves agree the bit is 0; 1 then 0 means all agree it's 1.
• Step 3: the master writes its choice of which branch to continue with (mandatory when the bits conflicted; the only possible value otherwise). Slaves whose actual bit doesn't match the master's choice silently drop out of this Search ROM pass — remaining silent, but not disconnected — while the survivors advance to the next bit position.
After all 64 bit positions have been walked through this way, exactly one device's full ID has been identified. Every point where the master faced a genuine 0/1 conflict and had to choose represents a branch not yet explored; the master reruns the entire Search ROM sequence, this time choosing differently at one of those remembered branch points, to discover another device. Repeating this (choosing every previously-unexplored branch across successive passes) eventually enumerates every device on the bus — in the lecture's four-device example, exactly four full passes are needed.
Since the ATmega has no dedicated 1-Wire hardware, using it means either an external bridge chip (translating 1-Wire to a supported protocol like I2C) or implementing this timing-sensitive protocol — Search ROM and the other mandatory commands, at minimum, since some 1-Wire features like overdrive mode are optional — entirely in software.
9.6 Bit-Banging 1-Wire with a UART Peripheral
A genuinely clever trick: because a UART frame's start bit and 1-Wire's bit-slot both begin with the line being pulled low from an idle-high state, a UART transmitter/receiver pair can be repurposed, with the right baud rate and framing, to bit-bang 1-Wire timing without manually toggling a pin in software at all.
Worked out for an 8-data-bit, no-parity, 1-stop-bit UART frame: the start bit plus 8 data bits (9 bits total, ignoring the stop bit, which sits outside the region that needs to match 1-Wire's timing) need to fit within a 60 μs 1-Wire bit slot, giving 60/9 ≈ 6.67 μs per UART bit — a baud rate of about 150 kBd. To send a 1-Wire 0, the UART transmits a data byte of all zeros (holding the line low for the full frame); to send a 1, it transmits all ones (pulling low only for the brief start bit, then releasing). Because the UART transmitter actively drives the line rather than merely releasing it as a true 1-Wire master would, a diode has to be inserted between the TX pin and the shared 1-Wire bus, oriented so current can only flow from the bus into the TX pin (never the reverse) — this stops the UART's TX driver from fighting a slave that's simultaneously trying to pull the same line low, which would otherwise create a direct short circuit capable of damaging the TX pin; the external pull-up resistor is still what's responsible for recharging the line high, since the diode blocks the TX pin from doing so itself.
Reading a slave's reply works by tying RX to the same shared bus: while TX transmits all 1s (to avoid interfering with the read), any slave pulling the line low is reflected directly in what RX receives, with the UART's own start/stop bits simply discarded as framing overhead rather than treated as real data.
The much longer reset pulse (480–640 μs) and presence pulse (60–240 μs) don't fit the same 150 kBd timing at all, so mimicking them means switching to a different, much slower UART baud rate just for that phase of the exchange — the lecture's worked example allocates 100 μs per bit (10 kBd) and spreads the reset/presence timing across a byte's worth of bits (5 bits' worth of low time for the reset pulse, 3 for the presence-pulse window) to land within spec. Since UBRR can be reloaded in a single CPU cycle, switching baud rates between the reset/presence phase and the regular 150 kBd bit-transfer phase of the same 1-Wire transaction is entirely practical in a real implementation.
Part 10 — Power, Clocks & Reset
Having established (Part 1.3) what an Arduino board supplies beyond the bare ATmega328P, this Part works through rebuilding those supporting pieces by hand — power, reset, and clock — and then turns to the chip's own built-in facilities for minimizing power draw, since that's usually the entire point of going bare-metal in a battery-powered project.
10.1 Rebuilding Power and Reset Without the Arduino Board
The ATmega328P's absolute maximum supply voltage is 6 V — crossing it risks permanent damage, so headroom matters when choosing a battery. Nominal battery voltages aren't the whole story: a fresh set of 4 AA cells in series can exceed 6 V, while a single 1.5 V cell's actual voltage sags under load depending on its internal resistance (low for Li-ion cells, meaning they can deliver high current at close to their rated voltage — also why a shorted or damaged Li-ion cell is a genuine fire risk: rapid internal discharge generates heat faster than it can dissipate, risking thermal runaway that can spread to neighboring cells; used cells should never go in ordinary trash for the same reason).
Running the chip directly from a battery (without a lossy linear regulator like the 7805 from Part 1.3) means the maximum usable clock frequency depends on the supply voltage — the full 20 MHz ceiling needs roughly 4.5 V or more, and decreases roughly linearly as voltage drops (e.g., around 12 MHz at 3 V, from two AA cells). Wiring: battery positive to VCC and to AVCC (which must stay within ±0.3 V of VCC, and is ideally fed through a small LC low-pass filter — a 10 μH inductor and 100 nF capacitor — if the ADC needs to be accurate under a noisy or fluctuating supply), battery negative to ground. The active-low RESET pin is held high (i.e., not resetting) through a 10 kΩ pull-up resistor to VCC; a push-button connecting RESET to ground provides a manual reset, mirroring the pull-up/switch pattern already familiar from I2C (Part 8.2) and 1-Wire (Part 9).
10.2 Rebuilding the Clock: Internal Oscillators and the Low Fuse
Without the Arduino board's external 16 MHz crystal, the ATmega328P can still run from one of its built-in oscillators — an internal calibrated oscillator (≈ 8 MHz) or an internal 128 kHz RC oscillator — needing zero extra components. Which clock source is used is set by the CKSEL bits in the low fuse byte (note: fuse bits are counter-intuitively active-low — 0 means “programmed”/enabled, 1 means the factory-default unprogrammed state).
Two other low-fuse bits matter here: CKDIV8, programmed (active) by default on a fresh chip, divides whatever clock is selected by 8 — e.g. turning the 8 MHz internal oscillator into a conservative 1 MHz, a safety margin ensuring the chip runs reliably across a wide range of supply voltages (10.3) out of the box; and CKOUT, which if programmed routes the system clock out on a pin (useful for clocking other chips, including a second ATmega, from one shared source). SUT (start-up time) bits control how long the chip waits after power-up or waking before actually executing code, giving surrounding circuitry (voltage regulators, sensors, etc.) time to stabilize first — longer is safer absent a specific need for a fast start.
CKDIV8's effect is also exposed at runtime via the CLKPR (Clock Prescale) register, letting software change the effective system clock speed on the fly. Writing it requires a specific two-step sequence for safety: first write CLKPCE (Clock Prescale Change Enable) alone with all other bits cleared, then — within 4 clock cycles — write the desired CLKPS (prescale select) bits; this sequence should not be interrupted (disable global interrupts around it, or run it from within an ISR, where interrupts are already blocked by default per Part 3.8). Changing the prescaler takes effect immediately and affects every downstream clock (CPU, ADC, general I/O, and communication peripherals alike) — which means any dependent timing (delay loops compiled against a fixed F_CPU, UART baud rate registers, the I2C bit-rate register) needs to be recomputed and reloaded for the new frequency; it's best to only change the prescaler between communication transactions, never in the middle of one.
10.3 Clock Frequency, VCC, and Current Draw
Power is current × voltage, and both factors are within a designer's control here: lowering the clock frequency lowers current draw, and lowering VCC lowers both current and (independently) the maximum safe clock frequency. Using the ATmega328P's datasheet figures as a baseline, the standard Arduino configuration (16 MHz, 5 V) draws roughly 9.5 mA active, for about 50 mW. Halving the clock to 8 MHz (switching to the internal oscillator) roughly halves both computational throughput and power draw; additionally dropping VCC to 3.3 V while staying at 8 MHz cuts power to roughly a fifth of the original, for the same 50% throughput; enabling CKDIV8 on top of that (down to 1 MHz) trades away the vast majority of remaining throughput for a power draw near 4% of the original figure.
This is a real trade-off, not a free lunch: there's no configuration that preserves full computational throughput while drawing near-zero power — a genuinely zero-voltage supply, of course, also means the chip does nothing at all. Picking an operating point means balancing how much computation a project actually needs against how long the power budget (typically a battery) needs to last.
10.4 The Power Reduction Register (PRR)
Any peripheral that's powered on but unused still draws current for nothing. PRR provides simple per-peripheral shutdown bits — PRTWI (I2C), along with bits for the timers, SPI, USART, and ADC — each individually disabling that peripheral's clock. If a disabled peripheral's configuration registers are needed again later, they generally need to be reconfigured from scratch on re-enabling, since their state may not be preserved.
One easy-to-miss trap: disabling Timer0 via PRR breaks the Arduino library's own delay() function, since it depends on that specific timer running — the AVR library's _delay_ms()/_delay_us() (Part 1.6), which use a pure fixed-instruction-count loop rather than any timer, are unaffected and remain safe to use regardless of PRR settings. Individually, each idle peripheral typically adds only a modest percentage to overall current draw (roughly 1% for a simple timer up to around 4% for the ADC) — but those percentages compound, and turning off everything genuinely unused (down to a lean 5.2 mA baseline in the course's measurements, versus 6.4 mA with everything left on) is a real, easy win before reaching for anything more involved.
10.5 Sleep Modes
Beyond selectively disabling individual peripherals, entire clock domains can be shut down together via the chip's sleep modes, ordered here from lightest to deepest:
• Idle — stops just the CPU and flash access clock; everything else (timers, ADC, communication peripherals, and their wake-up interrupts) keeps running normally.
• ADC Noise Reduction — stops everything except the ADC and Timer2's optional asynchronous clock, specifically to minimize electrical noise during a sensitive ADC conversion (referenced already in Part 5.8's temperature-sensor calibration tips).
• Power-Save — stops nearly everything, but keeps Timer2 (if clocked independently, e.g. by its own crystal per Part 4.2) running.
• Power-Down — stops every clock in the chip; the deepest standard sleep mode.
• Standby / Extended Standby — essentially Power-Down/Power-Save respectively, but with the main oscillator itself kept running (relevant only when using an external crystal/oscillator, not the internal RC oscillators) — trading a small amount of extra power for a dramatically faster wake-up: about 6 clock cycles instead of the full oscillator start-up delay set by the SUT fuse bits (10.2), which can otherwise run into the thousands of cycles.
Sleep is armed via the Sleep Mode Control Register (selecting the mode with the SM bits, and setting the Sleep Enable bit as a deliberate safety interlock — without it, a stray call to sleep_cpu() has no effect, guarding against an accidental sleep triggered by a runaway pointer or bug) and entered with the AVR library's sleep_cpu() call. The power savings are dramatic: the course's own measurements show a 500 mAh battery lasting roughly 50 hours running continuously at 16 MHz/5 V, about 208 hours (8.5 days) just from enabling Idle mode with the same workload pattern, and years in Power-Save or Power-Down (illustrative, since a chip permanently in Power-Down isn't actually doing anything useful).
Available wake-up sources shrink as sleep gets deeper: Idle mode can wake from nearly any interrupt (external pins, I2C address match, Timer2, ADC-ready, EEPROM/flash-write-complete, the watchdog timer, and more), while Power-Down can only be woken by an INT0/INT1 level change, an I2C/TWI address match, or the watchdog timer (10.7) — worth checking carefully against a project's actual wake-up needs before picking the deepest available mode.
10.6 Fast-and-Sleep vs. Slow-and-Awake: An Energy Trade-off
Given a fixed amount of work to do repeatedly (e.g., sample a sensor once a second), is it better to run the clock fast and sleep longer between bursts of work, or run the clock slow and stay awake longer doing the same work at a gentler pace? Energy (not just power) is what actually determines battery life — energy = power × time, measured in joules (the same unit, notably, used for food-energy labeling, alongside calories: 1 calorie ≈ 4.184 J, and a food-label “Calorie”, capital C, is 1000 small calories).
Working through the course's own numbers: current draw doesn't scale linearly with clock frequency the way computation time does — halving the clock might only cut active current to, say, 58% rather than a clean 50%. Comparing total energy consumed over an equivalent work-plus-sleep cycle at two different clock speeds (in the course's specific worked example, roughly 20.5 mJ running fast-and-sleeping-longer versus about 23.8 mJ running slow-and-sleeping-less, a 16% difference), running fast and sleeping longer came out ahead — broadly matching the general intuition that finishing work quickly and returning to a deep sleep state tends to be more energy-efficient than stretching the same work out at a lower clock speed, similar to how an unthrottled laptop CPU often outlasts a deliberately-slowed one on battery.
This general rule has real exceptions, though, particularly once communication is involved: a fixed-baud-rate UART transfer takes exactly as long regardless of CPU clock speed, so if communication time dominates a wake cycle, there's no throughput benefit to running the CPU flat-out during that phase — the better strategy becomes maximizing clock speed only for the actual computation portion, then dropping the clock down (via CLKPR, 10.2) for the communication portion, since a slower clock draws less current for no cost in transfer time. Where computation and communication can genuinely overlap (e.g., requesting fresh sensor data via interrupt-driven UART transmission while a previous reading is still being sent, rather than doing everything strictly in sequence), interleaving the two minimizes total awake time more than optimizing either alone — at the cost of noticeably more intricate program structure. Note also that before sleeping, code must confirm a pending UART transmission has actually finished by checking TXC0 (transmission complete) rather than UDRE0 (buffer merely empty and ready for the next byte, per Part 6.6) — checking the wrong flag risks sleeping (and cutting the clock that's driving the shift register) before the last bits have actually left the pin.
10.7 The Watchdog Timer
The watchdog timer is a countdown, clocked independently by the same 128 kHz internal oscillator available as a system clock source (10.2) — running regardless of whatever the main system clock is doing, which is exactly what makes it useful as a safety net: if the main program hangs or gets stuck in an unreachable state (memory corruption, an unanticipated edge case, radiation-induced upset in a hard-to-reach deployment), the watchdog can still force a full system reset back to a known-good state, something a stuck CPU obviously can't do for itself. Software is expected to periodically “kick” (reset) the countdown before it expires; if it doesn't, the countdown reaching zero triggers whatever response is configured.
Configuration bits (in the watchdog's own control register, itself protected by a similar timed-unlock sequence to CLKPR — set WDCE and WDE together first, then write the real settings within 4 clock cycles, specifically to prevent an accidental or buggy write from disabling the very safety mechanism meant to catch bugs):
• WDE and WDIE together determine the behavior on timeout: neither set — watchdog disabled; WDIE only — fires an interrupt instead of resetting; WDE only — forces an immediate system reset; both set — runs the ISR first, then resets afterward regardless (useful for last-second cleanup or logging before the reset takes effect).
• WDP bits set the timeout duration, from 16 ms up to roughly 8 seconds (16 ms × 2^9) — though because the underlying 128 kHz oscillator's actual frequency drifts meaningfully with temperature and supply voltage (commonly closer to 114 kHz in practice at room temperature/5V, per the course's own measurement), these timeout values are approximate, not precise.
• WDTON — a fuse (not a register bit), which if programmed forces a system reset on timeout unconditionally, ignoring whatever WDE/WDIE happen to be set to in software — a stronger guarantee than the register bits alone, precisely because fuses can't be altered by a running (and potentially malfunctioning) program.
A sharp practical gotcha: the watchdog's default post-reset state is WDP = 0 (a 16 ms timeout) with the prescaler active, so a program that doesn't immediately configure or disable the watchdog will keep resetting itself every 16 ms indefinitely — notably shorter than the roughly 2-second window some older Arduino bootloaders wait for a new program upload, which could leave a chip effectively unprogrammable via the bootloader until the watchdog issue was fixed. The AVR library provides wdt_enable(timeout) and wdt_reset() (the “kick”) as convenience wrappers; there's no equivalent helper specifically for interrupt-only mode (WDIE without WDE), which has to be configured by hand.
10.8 The Brown-Out Detector
A brownout is a partial, temporary supply-voltage sag (as opposed to a full blackout, a total loss of power) — caused by, for example, a failing or overloaded battery, or (for a solar-powered device) a passing cloud shadow. The ATmega's Brown-Out Detector (BOD) watches VCC and forces a reset if it drops below a configured threshold (V_BOT−), holding the chip in reset until voltage recovers above a slightly higher threshold (V_BOT+) — the small gap between the two thresholds (a fixed 50 mV hysteresis, split evenly above and below the nominal V_BOT trigger level) prevents rapid reset-cycling right at the boundary voltage.
The actual V_BOT trigger level (disabled, or roughly 1.8 V, 2.7 V, or 4.3 V, each with about ±0.2 V real-world tolerance) is set via fuse bits — the extended fuse byte on the ATmega328P specifically, though this varies by chip, so the datasheet should always be checked for a different part. Since the BOD continuously monitors voltage, it draws power even during sleep — a real cost specifically in the deepest sleep modes (10.5), where it can be a disproportionate share of total sleep current.
BOD can be disabled in software during sleep via two control-register bits (BODS — BOD Sleep enable, and BODSE — BOD Sleep Enable-enable, an extra interlock bit), following a tightly timed sequence: set both bits first, then within 4 clock cycles set BODS while clearing BODSE, and the actual sleep instruction must execute within the following 3 clock cycles or the BOD silently re-activates. Because this timing window is tight, the AVR library's own recommended order is: set the Sleep Enable bit first, disable the BOD via this sequence, re-enable global interrupts, then immediately execute the sleep instruction — and re-enable Sleep Enable's counterpart flag once woken, before the next sleep cycle.
Part 11 — Non-Volatile Memory: Flash & EEPROM
11.1 Harvard Architecture: Two Separate Memories
Unlike the von Neumann architecture used in PCs and phones — where a single memory holds both program code and data, simplifying compiler and CPU design but also opening certain classes of security vulnerabilities — the AVR core uses a Harvard architecture with two physically separate memory spaces: non-volatile flash for program code (and, as this Part covers, optionally read-only constant data), and volatile SRAM for runtime data, which also shares its address space with I/O registers (Part 2.5). A consequence worth remembering: an address like 0x0100 can refer to two completely different physical locations depending on whether it's interpreted as a flash address or an SRAM address — there's no ambiguity in the hardware, but C (designed originally for von Neumann machines with one unified address space) has no inherent concept of this distinction, which is exactly why storing data in flash needs special keywords and helper functions rather than just working automatically (11.2–11.4).
The ATmega328P's 32 KB of flash is organized as 16-bit words (16,384 addressable words total) grouped into 256 pages of 64 words (128 bytes) each — a boundary that matters a great deal once writing to flash at runtime is attempted (11.5). Flash is rated for roughly 10,000 write cycles per page on average; fine for normal programming/reprogramming cycles, but a real constraint if a project were tempted to misuse flash as frequently-rewritten storage. Flash is in-system programmable, meaning a running program can write to flash itself — exactly the mechanism a bootloader uses to write a newly uploaded program's bytes into flash over UART.
11.2 Storing Constants in Flash with PROGMEM
Large read-only data (lookup tables, sound/waveform data, and the like) often doesn't fit comfortably in the ATmega's much smaller 2 KB of SRAM, but fits easily in the much larger 32 KB flash. By default, though, even a variable declared const still lives in SRAM — const in C only tells the compiler there's no write access (enabling certain optimizations, and catching accidental writes at compile time), it says nothing about which physical memory the compiler should place the data in.
The AVR library's PROGMEM macro (from <avr/pgmspace.h>) is what actually redirects a declaration's storage into flash instead of SRAM. Because flash and SRAM are physically separate address spaces (11.1), a PROGMEM'd array can't simply be indexed with ordinary array syntax — the compiler would generate an SRAM read at what looks like the right address, but nothing meaningful lives there. Instead, dedicated read functions are required: pgm_read_byte(&array[i]) (and pgm_read_word, pgm_read_dword for 16-/32-bit reads) take the address of the desired element and explicitly read from flash.
const uint8_t sounddata_data[] PROGMEM = { 0x12, 0x34, /* ... */ };
uint8_t sample =
pgm_read_byte(&sounddata_data[3]);
PROGMEM applies specifically to the declaration it's attached to, and only that declaration — a subtlety that becomes important, and easy to get wrong, once arrays of pointers (rather than arrays of plain values) are involved (11.3).
11.3 Storing Strings in Flash: A Pointer Pitfall
A natural-looking attempt to move an array of string literals into flash — marking an array of char pointers as const and PROGMEM — doesn't actually move the strings themselves into flash; it only moves the array of pointers. The strings each pointer refers to remain ordinary string literals in SRAM, since PROGMEM (per 11.2) only affects the exact declaration it's attached to — here, that's the pointer array, not the character data those pointers happen to point at.
// WRONG: only the pointer array moves to flash; the strings stay in
SRAM
const char *const string_table[] PROGMEM = {
"one", "two", "three" };
// RIGHT: each string is individually placed in
flash first...
const char string0[] PROGMEM = "one";
const char string1[] PROGMEM = "two";
const char string2[] PROGMEM =
"three";
// ...then the array of pointers to them is
placed in flash too
PGM_P const string_table[] PROGMEM = { string0,
string1, string2 };
Reading a string back out requires reading the pointer itself from flash first (pgm_read_word(&string_table[i]), since a pointer is 2 bytes on AVR — Part 2.1), then using that recovered pointer with a flash-aware string function like strcpy_P(buffer, ptr) to copy the actual character data from flash into an ordinary SRAM buffer, ready to use normally (e.g., sent to a display). The AVR library provides a range of similar _P-suffixed helper functions for other flash-aware string/memory operations.
11.4 The __flash Keyword
A non-standard alternative to PROGMEM, __flash, is supported by some compiler versions and lets flash-resident data be declared and read with more ordinary-looking syntax, without needing the explicit pgm_read_* helper functions from 11.2–11.3. Since it isn't universally available, code that uses it typically wraps the declaration in a preprocessor #if/#else so a PROGMEM-based fallback compiles instead on toolchains that don't support it. Which approach to use is largely a matter of preference and toolchain compatibility rather than one being definitively better — both exist purely to work around the same underlying issue: C's type and syntax system was designed for a single, unified (von Neumann) address space, and has no built-in vocabulary for “this pointer's target lives in a different physical memory”, which the Harvard-architecture AVR fundamentally requires (11.1).
11.5 Flash Pages, NRWW/RWW, and BOOTSZ
Writing even a single byte to flash requires first erasing the entire containing page (128 bytes on the ATmega328P), then rewriting the whole page — there's no direct byte-level or word-level in-place update. Flash is additionally split into two functional regions: a No-Read-While-Write (NRWW) section and a Read-While-Write (RWW) section, whose combined boundary is fixed regardless of configuration, but whose individual split point shifts based on the BOOTSZ fuse bits, which set how much of the NRWW region is reserved for a bootloader (options on the ATmega328P: 4 KB, 2 KB, 1 KB, or 512 bytes — by default, fuses reserve the maximum, and the bootloader itself always resides within NRWW).
The distinction matters specifically when writing to flash while a program is running: writing anywhere in the RWW section is fine as long as the code actually performing that write executes from the NRWW section — while a page write to RWW is in progress, RWW itself temporarily can't be read (meaning code physically located there can't execute), but NRWW can still be read and executed normally, keeping the write routine itself running throughout. Writing to the NRWW section itself (e.g., updating the bootloader) is far more disruptive: since flash is the program memory and none of it can be read mid-write, the CPU effectively halts for the whole operation. The EEPROM (11.7) and flash also can't be written simultaneously — a pending EEPROM write has to finish first — and no interrupt may occur during a flash page erase/write, since the operation must run start-to-finish uninterrupted.
11.6 Writing to Flash and Locating Free Pages
A representative flash-write routine (adapted from the AVR library's own boot.h example) disables interrupts, waits for any pending EEPROM write to finish, erases the target page, fills a temporary page buffer with new data two bytes at a time (matching flash's 16-bit word size — the byte-oriented addressing C normally uses doesn't map 1:1 onto flash's addressing, hence the address-doubling arithmetic that shows up throughout this topic), writes the completed page with boot_page_write(), waits for that write to finish, re-enables reading of the RWW section (mandatory before program execution can safely continue there), and finally restores the interrupt-enable state to whatever it was on entry.
Because such a routine writes into RWW, it has to itself live in NRWW (11.5) — achieved by placing it in a dedicated linker section (the AVR library's BOOTLOADER_SECTION attribute) and pinning that section to a specific address via a linker flag. A recurring gotcha throughout this topic: C/toolchain addresses are byte-based, but flash itself is word-addressed (2 bytes/word), so an intended flash word-address always needs doubling to get the corresponding byte address the linker and disassembler actually report.
Finding safe, currently-unused flash pages to write into (without silently corrupting the bootloader or the running program) takes some detective work, since nothing marks “free” flash automatically. Several independent ways to locate the actual end of a compiled program were demonstrated: reading the linker-defined __data_load_end symbol (either statically, from the linker's verbose output or a generated map file, or dynamically at runtime, by taking that symbol's address in code and printing it); checking avr-size's reported program size (noting this can undercount if the linker has already placed some sections at fixed, non-contiguous addresses, leaving unaccounted-for gaps in between); or inspecting the uploaded Intel HEX file directly and computing the last used address from its final data record's address-plus-length fields. Once that end-of-program address is known, the next page boundary after it (rounding up to the next multiple of the page size, in word terms, then doubling back to a byte address for use in C) marks the first genuinely free page — usable as long as it still falls before the RWW/NRWW boundary and doesn't collide with anything else deliberately placed at a fixed address (such as the flash-write routine itself, pinned as described above).
An alternative to hunting for free space above the program is to work backward instead, allocating pages downward from the RWW/NRWW boundary (11.5) rather than upward from the end of the program — useful when the program's own size might grow over time and encroach on space assumed to be free. Whichever direction is used, avrdude can read back the entire flash contents afterward (using -U flash:r:filename[:i] to save it, optionally directly in Intel HEX format) for inspection with a hex editor, to confirm data actually landed where intended and that nothing important was overwritten.
11.7 The EEPROM: Registers and Write Sequence
Separate from flash, the ATmega328P also has 1 KB of EEPROM — byte-addressable (unlike flash's word-and-page-oriented access, 11.5), individually rewritable per byte with no page-erase requirement, and rated for roughly 100,000 write cycles per byte, about 10× flash's endurance. Three registers control it: EEAR (the 10-bit address register, needed since 1024 bytes requires 10 address bits), EEDR (holds the byte being written, or the byte just read), and EECR (the control register).
Key EECR bits: EEPM1:0 select the write mode (a combined atomic erase-and-write, or separately erase-only / write-only); EERIE enables an interrupt once a write completes (valuable given how slow EEPROM writes are — 11.8); EEMPE (EEPROM Master [Write] Enable) is a protective interlock, similarly timed to the CLKPR/watchdog patterns already seen in Part 10: it must be set immediately before EEPE (which actually commits the write) is set, within 4 clock cycles, or the write silently doesn't happen — protection against an accidental or buggy write corrupting EEPROM data; EEWE/EEPE can also be polled to check whether a previous write has finished; and EERE strobes a read, transferring the byte at the current EEAR address into EEDR.
The full manual write sequence: confirm no write to EEPROM or flash is currently in progress, load the target address into EEAR and the data into EEDR (either order), then set EEMPE, and within 4 clock cycles, set EEPE to actually commit the byte. In practice, the AVR library's helper functions (11.8) handle this exact sequence, so it rarely needs to be written by hand.
11.8 EEPROM Timing, Helper Functions, and Initialization
The AVR library provides eeprom_read_byte()/eeprom_write_byte() (plus word, double-word, float, and block variants) that wrap the manual register sequence from 11.7, each internally polling for EEPROM readiness before proceeding and disabling/restoring global interrupts around the write itself. A particularly useful variant, eeprom_update_byte(), only actually performs a write if the new value differs from what's already stored at that address — skipping unnecessary writes conserves the EEPROM's limited (though still substantial, at ~100,000 cycles/byte) write endurance essentially for free.
EEPROM writes are genuinely slow — a combined erase-and-write operation takes about 3.4 ms, during which the AVR library's blocking helper functions leave global interrupts disabled the entire time, meaning the CPU is unresponsive to everything else (incoming UART data, other pending interrupts) for that whole duration. For anything beyond an occasional isolated write, it's worth using the interrupt-driven alternative instead: commit a write, let the CPU do something else (handle other I/O, or sleep) in the meantime, and use the EEPROM-ready interrupt (enabled via EERIE) to trigger loading the next byte once the hardware signals it's actually free again.
For pre-loading EEPROM with fixed configuration values before a program's first run, two approaches exist: the EEMEM keyword marks a global variable as EEPROM-resident directly in source code (letting the compiler know its storage lives in EEPROM rather than SRAM), but if the program then unconditionally writes to it on every single reset, that both wastes write cycles and costs real time on every startup — better paired with eeprom_update_byte() (which at least skips the write when the value hasn't actually changed) or an explicit “has this been initialized yet” flag byte checked before writing. The alternative is supplying pre-computed initial values directly in the variable's declaration; the compiler can then export those values into a separate .eep file (generated alongside the normal flash-programming .hex file) that avrdude uploads to EEPROM once, entirely outside of the running program — no runtime write code needed at all, though this does mean re-uploading a fresh .eep file (via avr-objcopy, extracting or repositioning the .eeprom section as needed) any time the desired EEPROM contents actually need to change.
11.9 Flash vs. EEPROM: Quick Comparison
Flash is easy to read from and well suited to large, read-only constant data (Parts 11.2–11.4), but writing to it while a program is running is a genuinely delicate, multi-step process: it can be write-protected via lock bits, program and data share the same physical space (so a write can silently corrupt the running program if it lands somewhere it shouldn't), writes are page-granular rather than byte-granular, and the split between the NRWW and RWW regions constrains exactly what can be safely written from where (11.5–11.6).
EEPROM, by contrast, is comparatively simple: address goes into EEAR, a read is strobed and the result appears in EEDR, or data is placed in EEDR first and a write is strobed — no pages, no read/write-region distinction, no risk of colliding with the running program's own code. Its meaningfully smaller capacity (1 KB vs. 32 KB of flash) and slower per-byte write time are the trade-offs for that simplicity and safety, which is exactly why EEPROM (rather than flash) is the natural choice for small amounts of runtime-updatable configuration data — like the temperature-sensor calibration constants from Part 5.8 — while flash remains the better fit for large, fixed, read-only tables and data that's baked in once and never rewritten at runtime.
Part 12 — Programming, Debugging & Protection
This Part covers the tooling side of working with a bare ATmega: how fuses, flash, and EEPROM actually get programmed once no bootloader is available to do it over UART; how to debug code running on real hardware rather than in a simulator (Part 1.10); and how to lock a finished product against having its firmware extracted or overwritten.
12.1 ISP/ICSP: Programming Fuses, Flash, and EEPROM via SPI
Fuses cannot be programmed by a running user program, nor by a bootloader, nor over plain UART — they require a separate hardware protocol. That protocol is SPI (Part 7), exposed on most Arduino boards as a 6-pin header labeled ICSP (In-Circuit Serial Programming, also called ISP, In-System Programming): VCC, ground, SCK, MISO, and MOSI, letting the chip be reprogrammed while still fully wired into its target circuit, with no need to desolder or relocate it.
A dedicated USB-to-ICSP programmer is the simplest way to drive this interface, and many are available at a range of prices, some with a switch to select 3.3 V vs. 5 V target logic. But a second Arduino, running the Arduino IDE's bundled ArduinoISP example sketch, works just as well at no extra cost — it turns the Arduino's own SPI pins (13/12/11 for SCK/MISO/MOSI) plus a configurable reset-output pin into a fully functional ISP programmer for a second, target ATmega. A small capacitor (commonly around 10 μF) between the programmer Arduino's own reset pin and ground is a common trick to stop the programmer itself from resetting the moment its serial port is opened by avrdude — without it, the programmer can reset mid-session and the programming attempt fails; it needs to be removed again, though, before re-uploading the ArduinoISP sketch itself.
Once wired up, avrdude just needs -c avrisp (instead of -c arduino) and the appropriate baud rate (19200 by default for ArduinoISP) to talk to the target through the programmer. A successful connection reports actual current fuse values rather than the placeholder 0xFF a signature-check failure would otherwise produce — skipping the signature check here (via -F) specifically defeats the point of connecting at all, since if the connection can't reliably read the signature, it can't be trusted to reliably read or write fuse values either, which is a much higher-stakes operation.
Fuse values are famously easy to get subtly wrong (remember: 0 = programmed/active, 1 = unprogrammed/inactive — Part 10.2), and a wrong high-fuse or clock-related setting can leave a chip effectively unreachable by ordinary means. Online fuse calculators (given the target part number and desired settings) remove most of the guesswork and directly generate the correct avrdude -U lfuse:w:<value>:m command line, which is the recommended way to compute a fuse value rather than doing the bit arithmetic by hand. A practical workflow that minimizes risk: finalize and test fuse settings first while the chip is still socketed on the Arduino board (where pin headers make debugging easy), only then extract the chip (or program a fresh standalone one) with the same known-good fuse values, and leave any “lock the flash down” step (12.6–12.7) for the very last stage, after everything else is fully verified working.
12.2 Turning an Arduino (or an FTDI232) into a Programmer
Programming a chip that's already been extracted from an Arduino board (or was never mounted on one) means wiring directly to its physical pins — PB3/PB4/PB5 for MOSI/MISO/SCK respectively, plus RESET — rather than through a board's pin headers. A standalone FTDI232 USB-to-serial breakout (the same chip used on many Arduino boards for USB communication) offers another route: beyond its usual role handling ordinary UART traffic, its RESET pin can be pulled up through a resistor/capacitor combination, similar to the RESET circuit built in Part 10.1, and — more surprisingly — configuring it for FT232R Synchronous BitBang mode (available as a distinct avrdude programmer type, ft232r) lets it bit-bang a full SPI/ISP interface directly, turning an otherwise ordinary UART adapter into a functional chip programmer with no second microcontroller involved at all.
A detail easy to miss on many Arduino boards: unused solder pads often labeled X3 expose the onboard FTDI chip's RESET/MOSI/SCK/MISO lines directly — normally hard-wired only to the UART pins, but accessible here with a soldered-on pin header. Wiring those four pads to the board's own ICSP header effectively builds a self-contained ISP programmer out of a single Arduino board's existing onboard hardware, with no second Arduino or external programmer needed at all.
12.3 debugWIRE, the High Fuse, and RSTDISBL
JTAG is a widely used, standardized hardware debugging interface, but it needs several dedicated pins (including a separate test-mode-select line) — a real constraint on lower-pin-count AVR parts, where spare pins are scarce and every one already committed to a peripheral is one the debugger can't also use without disconnecting that peripheral (defeating the point of debugging a chip in its actual working environment). Atmel's proprietary answer, debugWIRE, needs only a single wire — achieved by reusing the RESET pin (PC6) itself for debug communication, trading away the reset button for debugging capability.
Enabling debugWIRE means programming the DWEN fuse bit (located, for the ATmega328P specifically, in the high fuse — check the datasheet for other parts, since this varies). This has real, sometimes awkward consequences: the RESET pin stops behaving as an ordinary reset once DWEN is active; the main clock stays running even during sleep modes, meaning any power-consumption measurements taken while debugWIRE is enabled won't reflect real deployed behavior (Part 10.5); and, critically, DWEN cannot be unprogrammed through the debugWIRE interface itself — doing so requires temporarily disabling debugWIRE to get back to the ordinary SPI/ISP interface (12.1), reprogram the fuse there, and only then is normal reset behavior restored. The SPIEN fuse bit (which enables the ISP interface used for exactly this kind of fuse recovery) should essentially never be touched: disabling it, combined with DWEN already being set, can leave a chip recoverable only with specialized high-voltage programming hardware capable of forcing 13 V onto the reset pin — a genuine “normal tools can't fix this anymore” scenario worth actively avoiding.
12.4 Hardware Debuggers and an Open-Source Alternative
Dedicated commercial hardware debuggers supporting debugWIRE — the Atmel-ICE (roughly $150), its predecessor the JTAG ICE MkII, and the AVR Dragon (which also supports JTAG, ISP, and several other programming modes, though capped to chips with 32 KB of flash or less — conveniently exactly the ATmega328P's own flash size) — connect to avr-gdb through a proxy layer (AVaRICE) much like the software simulators from Part 1.10 do, letting the same familiar GDB commands (breakpoints, stepping, memory/register inspection) operate against the genuine physical chip instead of a simulated one. These tools are a solid investment for a professional AVR developer, but a meaningful cost for occasional or hobbyist use.
A free, open-source alternative exists: Wayne Holder's DebugWireDebuggerProgrammer sketch turns a second ordinary Arduino into both an ISP programmer and a debugWIRE debugger, switchable via a single jumper wire (floating = ISP programmer mode; grounded = debugWIRE debugger mode). Its interactive serial-terminal interface supports identifying the target, reading and writing fuses, toggling the DWEN and clock-division fuses directly from its own menu, setting breakpoints, single-stepping, and reading/writing registers, I/O space, SRAM, and flash — a genuinely capable feature set for zero hardware cost beyond a spare Arduino. Its real limitation is that it doesn't (at least as of the course's recording) integrate with avr-gdb the way the commercial debuggers do, so it's used through its own bespoke command interface rather than GDB's more standardized one.
12.5 Hardware Breakpoints
Breakpoints placed through debugWIRE are hardware breakpoints, and only three can be active at any one time (a hardware limit, not a software one) — mechanically, setting one means the flash page containing the target instruction is actually reprogrammed, temporarily replacing that instruction with a break instruction and stashing the original elsewhere until the breakpoint is removed. Because this is a genuine flash page erase-and-rewrite each time, repeated debugging sessions on the same chip consume real flash write-cycle endurance (Part 11.1's ~10,000-cycle rating) — the practical implication being that a chip that's seen heavy debugging use shouldn't be the one that ships in a final product; use a separate, freshly-programmed chip for that.
12.6 Protecting Code: Boot Lock Bits (BLB0/BLB1)
Two AVR assembly instructions govern flash access from a running program: SPM (Store Program Memory — erase/write a flash page, used internally by every bootloader flash update and also whatever automatically updates the lock bits) and LPM (Load Program Memory — read from flash, including constants stored via PROGMEM per Part 11.2, and also how fuse/lock-bit values themselves get read back). Boot Lock Bits let an application restrict what SPM/LPM instructions — depending on which flash section (application vs. bootloader, Part 11.5) they physically execute from, and which section they target — are actually allowed to do.
BLB0 governs access to the application section: mode 1 (the unprogrammed default) applies no restrictions at all; mode 2 blocks any SPM write to the application section from anywhere (including from the bootloader itself, so a bootloader can no longer push application updates); mode 3 additionally blocks the bootloader from even reading the application section via LPM (blocking bootloader-side verification/readback), while the application can still read its own code; mode 4 blocks bootloader verification specifically but still permits the bootloader to write application updates.
BLB1 mirrors this for the bootloader section itself: mode 1 (default) is unrestricted; mode 2 blocks all writes to the bootloader section (from either section — combined with BLB0 mode 2, this makes the entire flash read-only); mode 3 permits the bootloader to verify (read) itself but not modify itself; mode 4 permits writes to the bootloader section (enabling bootloader self-update, or an application-driven bootloader update) while blocking the application from reading that section afterward to verify the result.
12.7 Protecting Code: Lock Bits and the Chip Erase
Separate from the more granular BLB0/BLB1 modes (which govern what a program running on the chip can itself do), two more bits in the same lock-bit fuse byte — simply called “Lock Bits” (LB1:0) — govern what an external programmer/debugger is allowed to do to the chip from outside: unprogrammed (the default) applies no restriction; mode 2 makes flash write-protected but still externally readable/verifiable; mode 3 blocks external reading entirely, which also disables hardware debuggers outright (Part 12.4) — deliberately, since debugWIRE reading out flash contents for debugging would otherwise be an easy way around the very protection the lock bits exist to provide.
As with any fuse byte, an online fuse calculator (Part 12.1) is the recommended way to compute the correct lock-bit value rather than working out the bit arithmetic by hand — and worth double-checking against current avrdude documentation, since only the lower 6 bits of this fuse byte are actually meaningful (the two most significant bits are unused, so a legal value tops out at 0x3F, not 0xFF, and some newer avrdude versions specifically warn about this).
Once lock bits are set, they can't be individually unprogrammed — the only way to reset them is a full chip erase, which wipes the entire contents of both flash and EEPROM (unless the EESAVE fuse bit is separately programmed to specifically exempt EEPROM from that wipe). Critically, a chip erase is not a factory reset: fuse settings (clock source, BOOTSZ, DWEN, and everything else covered in this Part and Part 10) are left completely untouched by it — only flash, EEPROM (unless exempted), and the lock bits themselves are affected. Practically, a chip erase happens automatically before every ordinary flash upload unless avrdude is given the -D flag to suppress it (meaning most of the exercises throughout this course already performed one, likely without it being obvious at the time); it can also be triggered deliberately and in isolation with avrdude -e, with no -U write parameter needed to accompany it.
Part 13 — Secure Coding for Embedded C (CERT-C)
13.1 Where These Rules Come From
The examples in this Part are drawn from the SEI CERT C (and, for C++ projects, a companion CERT C++) Coding Standard — free reference material (registration required for the formal e-book, but the same content is available without registration via its companion wiki) cataloguing well-known categories of C programming mistakes that lead to security vulnerabilities, crashes, or hard-to-reproduce bugs. Similar standards exist for other languages (Java, Perl, Android, and more) for projects that need them. Each rule has a short mnemonic identifier — a three-letter category code, a two-digit number, and a language suffix (e.g. DCL30-C: a DCLaration-category rule, numbered 30, for C) — which can be looked up directly on the wiki for more detail than covered here.
13.2 The Classic Buffer Overflow
A buffer is any contiguous block of allocated memory; a buffer overflow (or overrun) happens when more data is written into it than it was actually allocated to hold. A C string is a classic example of a buffer that's easy to under-allocate: it needs space for every character plus one more byte for the trailing NUL terminator — a 5-character string needs 6 bytes of storage, a detail that's easy to forget.
On a von Neumann machine (unlike the ATmega's own Harvard architecture, Part 11.1 — buffer overflows are a general software vulnerability independent of architecture, but are most classically demonstrated on a PC), a running program's memory is organized into sections mirroring the ELF layout from Part 1.4 (.text for code, .data/.bss for variables) plus two more: the heap (dynamically allocated memory, growing upward as malloc()/new reserve space and shrinking as free()/delete release it) and the stack (growing downward, holding function call parameters, return addresses, and local variables).
Calling a function pushes, in order: the function's arguments, the return address (where execution resumes once the function exits), a saved copy of the caller's base/frame pointer (a fixed reference point, unlike the constantly-moving stack pointer, used to reliably address local variables and parameters via a fixed offset regardless of what else has since been pushed onto the stack), and finally the function's own local variables. This ordering is exactly what makes a buffer overflow dangerous: a local array declared on the stack sits below (in typical layout) the saved frame pointer and the return address in memory, so writing past the end of that array — for example, via an unsafe strcpy() with no bounds checking, copying a source string longer than the destination buffer — progressively overwrites the saved frame pointer and then the return address itself with whatever data happens to overflow into them.
Once the function returns, the CPU jumps to whatever address now sits where the return address used to be — on a von Neumann architecture, where code and data share the same memory and there's no inherent distinction between the two, that address might be entirely attacker-controlled if the overflowing data was crafted deliberately. Best case, the corrupted address is invalid and the program crashes outright (a segmentation fault, if an OS is present to catch it); worst case, on a system with no OS to catch the fault (or with a carefully chosen bogus address), the CPU jumps into and executes whatever data happens to be there — the fundamental mechanism behind classic remote code execution exploits, dating back to the 1988 Morris worm and still a live category of vulnerability today.
13.3 Watching a Buffer Overflow in GDB
Stepping through a deliberately vulnerable strcpy() call in GDB makes the mechanism concrete: breakpoints placed just before and after the copy, combined with info stack, info frame, info registers, and info locals, reveal the return address, saved base pointer, and local buffer all sitting on the stack in the expected order, each identifiable by inspecting raw stack memory with x/16x $rsp (examine 16 words in hex, starting at the stack pointer) and cross-referencing the values against a disassembly of the calling code.
Feeding progressively longer strings into the vulnerable function demonstrates the overflow's escalating severity in stages: first, padding bytes between the buffer and the saved frame pointer are overwritten (harmless, since padding serves no purpose); next, the saved frame pointer itself is overwritten (still often survivable for a simple, non-nested call); finally, past a large enough input, the saved return address itself is overwritten — at which point, since the overwritten bytes are entirely attacker-controlled, execution can in principle be redirected to any chosen address, including one containing code (e.g. a function like attack()) that was never legitimately called anywhere in the program's own source.
13.4 Mitigations: Safe Functions and Stack Canaries
The most direct fix is avoiding unbounded C library functions in favor of length-limited alternatives — strncpy() in place of strcpy(), for instance, which takes an explicit maximum-length parameter and simply can't be persuaded to write past it.
A complementary compiler-supported technique is the stack canary: a secret value placed on the stack immediately after the frame pointer, checked for corruption right before a function actually returns — if a buffer overflow has occurred and overwritten it en route to the return address, the mismatch is detected and the program is deliberately terminated rather than allowed to jump to a corrupted return address. For a canary to be effective, its value must be unpredictable and generated fresh at runtime; a canary that's fixed or otherwise guessable (for instance, by disassembling the binary) can simply be reproduced by an attacker crafting the overflow, defeating the protection entirely. This runtime-randomness requirement is itself a genuine challenge on small embedded systems like the ATmega, which — lacking most of the entropy sources (mouse/keyboard timing, OS scheduler jitter, network traffic) a PC or server takes for granted — has few good options for real randomness, sometimes calling for a dedicated hardware random-number source when quality matters (echoing the ADC-noise-based approach discussed in 13.13 and Part 5.7).
13.5 DCL30-C — Object Storage Duration
Every object has a storage duration — the span of the program's execution during which its memory is guaranteed to remain valid and holding its last-written value. Returning a pointer to a local (automatic-duration) variable from a function is a classic way to violate this: the pointer looks fine and might even appear to work by coincidence (the memory hasn't been reused yet), but its target's actual guaranteed lifetime ended the moment the function returned, and using it afterward is undefined behavior — code that may work on one machine, or most of the time, but isn't guaranteed to, which is precisely what makes this class of bug so treacherous to track down.
The general fix is ensuring a pointer's target has a storage duration at least as long as the pointer itself needs to remain valid — for example, having the caller declare the actual storage and pass a pointer into a function to populate it, rather than having the function declare local storage and hand back a pointer to it.
13.6 EXP33-C — Uninitialized Memory
Local variables in C are not automatically initialized (unlike global/static variables, which the standard guarantees start at zero), and functions like malloc() likewise return memory with unpredictable existing contents (unlike calloc(), which explicitly zeroes what it returns). Reading such a variable or buffer before it's actually been written invites feeding garbage — including, in a worst case, a missing NUL terminator that causes a string function to read arbitrarily far past the buffer's real end, hunting for a terminator that may not appear for a long stretch of memory.
Where sprintf() is used, snprintf() (which takes an explicit maximum-length parameter, similar to strncpy() in 13.4) is the safer choice — and its return value (the actual number of characters written, or a negative value on failure) should be checked before assuming the operation succeeded, rather than assumed unconditionally.
13.7 EXP34-C — NULL Pointer Dereferences
Dereferencing a NULL pointer is undefined behavior in the C standard; on a system with memory protection this commonly manifests as an immediate crash (a segmentation fault), but that specific behavior isn't guaranteed everywhere — some platforms can genuinely read whatever happens to be at address 0, silently returning garbage instead of crashing, which can be even more dangerous than an obvious crash since it can go unnoticed.
Good defensive practice layers several checks around every pointer that could plausibly be NULL: validate a function argument before dereferencing it (e.g. calling strlen() on a string pointer that might itself be NULL); check the return value of malloc() (which returns NULL on allocation failure) before using the memory it was supposed to provide; and explicitly free() memory that's no longer needed, setting the pointer to NULL afterward as a defensive habit — worth remembering that free() itself does not do this automatically, so any other pointer that was copied from the one just freed (a second variable holding the same address) is left silently dangling and still non-NULL, a pitfall that leads directly into 13.9.
13.8 EXP42-C — Padding and Structure Comparison
Comparing two structures by treating them as raw byte arrays (e.g. with memcmp()) looks appealingly simple — it automatically adapts if the structure's definition grows, and can be faster than comparing each member individually — but is unreliable, because the compiler may insert padding bytes between structure members to keep each one properly aligned for efficient access (mirroring the alignment/packing trade-off already covered for the ATmega specifically back in the earlier study guide's Part 1.4/1.5). Padding bytes are typically left with unpredictable, uninitialized content, so two structures with identical, meaningfully-equal member values can still differ in their raw padding bytes and incorrectly compare as “different” under a byte-wise comparison. The reliable fix is comparing each member individually, skipping padding bytes entirely, rather than taking the byte-array shortcut.
The underlying reason padding exists at all ties back to how memory hardware actually reads data: on typical 32-/64-bit architectures, memory is organized in banks that can be read together in a single clock cycle when data is properly aligned; deliberately packing a structure (removing that padding, e.g. via a packed attribute) shrinks its size but can force a misaligned multi-byte member to require two separate memory reads plus extra shifting and OR-ing to reassemble correctly — real memory-vs-speed trade-offs, distinct from AVR's own situation, since the ATmega's SRAM (a single memory bank, unlike a multi-bank 32-/64-bit system) doesn't insert padding for this reason in the first place, though multi-byte AVR reads still cost multiple sequential memory accesses regardless. A related, often-overlooked space optimization: reordering a structure's members (e.g. grouping same-sized or naturally-aligned members together) can reduce total padding and shrink overall structure size, with no change to what data the structure actually holds.
13.9 MEM30-C — Dangling Pointers
A dangling pointer is one that still holds an address whose memory has already been freed; using it afterward is undefined behavior, and — as EXP34-C (13.7) noted — free() itself does nothing to prevent this, since it has no way to know about (or update) any other variable that might still be holding a copy of the same now-invalid address.
A classic, easy-to-miss version of this bug shows up when freeing every node of a linked list (Part 5.3's data structure, revisited here): freeing the current node before reading its next pointer destroys the very information needed to continue the traversal — the fix is saving the next pointer into a temporary variable before freeing the current node, so the freed memory is never touched again afterward.
// WRONG: reads p->next after p has already been freed
while (p != NULL) {
free(p);
p =
p->next;
}
// RIGHT: save next before freeing
while (p != NULL) {
Node
*q = p->next;
free(p);
p = q;
}
13.10 STR31-C — String Storage and Off-by-One Errors
Building on the buffer-sizing theme from 13.2, a hand-written string-copy loop is an easy place to introduce a classic off-by-one error: a loop intended to copy exactly n characters into a buffer of size n, but which also unconditionally appends a NUL terminator after the loop exits, actually needs n+1 bytes of destination storage — or, equivalently, the copy loop itself needs to run for at most n−1 characters, reserving the final slot for the terminator. This exact category of mistake (writing one element too many, or reading one too few) recurs constantly across otherwise-correct-looking loop and buffer code, which is why it has its own well-known name.
13.11 FIO47-C — Format String Arguments
Functions in the printf()/scanf() family interpret their format string and remaining arguments together, positionally — there's no independent type-checking connecting a %s or %d in the format string to the actual type of the corresponding argument supplied. Something as simple as accidentally swapping the order of two arguments (e.g. passing an integer error code where a format string expects a string pointer, or vice versa) causes the function to misinterpret raw argument data as a memory address and attempt to read a string starting there — at best producing garbled output, at worst crashing or leaking unintended memory contents. The general habit worth building: always double-check that a format string's placeholders and the actual argument list that follows are in matching order and matching type, especially after refactoring a function call's parameter list.
13.12 ENV33-C — Do Not Call system()
C's system() function hands a string straight to the underlying OS shell for execution — which means any part of that string an attacker can influence is effectively an opportunity for arbitrary command injection, not just running the one specific command a developer intended. Sanitizing the input string helps but isn't a complete fix on its own: even a fixed, hardcoded command name (e.g. ls or dir) is resolved by searching directories listed in the shell's PATH environment variable, and an attacker able to influence that variable (adding a malicious directory ahead of the legitimate one, containing a same-named but hostile executable) can hijack execution without touching the command string at all. Environment variables generally (not just PATH) are a broader version of the same risk — a command string built by expanding $HOME or similar can be redirected somewhere entirely unintended if that variable has been tampered with beforehand.
The general remedy is avoiding system() (and the whole class of “hand off to the shell” functions) in favor of calling the equivalent functionality directly through dedicated library functions (e.g., deleting a file via a language/library-provided remove() call rather than shelling out to rm or del) — removing the shell, and its associated environment-variable and PATH-search attack surface, from the picture entirely.
13.13 MSC30-C/MSC32-C — Pseudorandom Number Generators
A pseudorandom number generator (PRNG) produces a deterministic sequence of numbers entirely determined by its starting seed — the same seed always reproduces the exact same sequence, which is precisely why seed quality (its actual unpredictability) is what genuine randomness quality hinges on, not anything about the generator's internal algorithm. A PC or server has many sources of real-world entropy available for seeding (system time, user input timing, OS scheduler jitter, and similar) that a small embedded system like the ATmega largely lacks — a chip that resets into a fairly consistent initial state, with no wall-clock time source of its own, tends to produce a suspiciously repeatable “random” sequence run after run unless something is done about it.
Two other habits worth calling out regardless of platform: never use an unseeded generator (calling rand() without first calling srand() leaves the sequence fixed across every run of the program), and prefer a stronger generator over the classic (and comparatively weak) rand() where one is available — on platforms with wall-clock access, seeding from something like the current time in seconds XORed against sub-second precision (nanoseconds) is a common practical improvement, though not a cryptographically rigorous one. On the ATmega specifically, reading the ADC's noise floor from an intentionally unconnected (floating) pin — the same electrical-noise-as-entropy idea introduced back in Part 5.7 — is a practical, low-cost way to derive a usable seed given the platform's other constraints; Cloudflare's well-known “wall of lava lamps” filmed on camera to seed its production random-number generators is the same underlying idea (physical unpredictability standing in for a wall-clock source) taken to a much larger and more elaborate scale.
13.14 FLP30-C — Floating-Point Loop Counters
A 32-bit IEEE float represents a number as a sign bit, an 8-bit exponent, and a 23-bit significand (mantissa) — which, critically, means not every real number (including many completely ordinary decimal fractions, like 0.1) has an exact binary floating-point representation; most such values are stored as the closest available approximation instead.
Using a float as a loop counter (incrementing it by a fractional step each iteration, and comparing against a fractional target to decide when to stop) is risky precisely because of this inexactness: a value like 0.1 that isn't stored exactly accumulates a small representation error on every single increment, and that error compounds across iterations — easily causing a loop expected to run exactly 10 times to run 9 or 11 times instead, depending on which direction the rounding error happens to drift.
// RISKY: floating-point accumulation error can shift the iteration
count
for (float x = 0.0f; x < 1.0f; x += 0.1f) {
/* ... */ }
// SAFER: integer counter, float derived fresh
each iteration
for (int i = 0; i < 10; i++) {
float
x = i * 0.1f;
/* ...
*/
}
The general fix is using an integer loop counter and deriving any needed floating-point value fresh from it each iteration (as shown above), rather than letting a float itself accumulate error step by step across the loop's lifetime. The same underlying imprecision means floating-point values should essentially never be compared for exact equality either — comparing whether a computed float falls within a small acceptable tolerance (an epsilon) of a target value is the standard, reliable substitute for an exact == comparison.
