The student who presses the upload button in the Arduino IDE experiences a moment of magic: a few seconds later, an LED blinks. Between the press and the blink, however, a dozen distinct operations take place, carried out by just as many separate programs, none visible on screen. Today we open this black box: what does it actually mean to program a microcontroller, what forms does the text we write pass through until it becomes bits in Flash memory, what does the file that reaches the programmer look like, and why do the error messages in a first project almost never point at the real cause of the problem.
1Purpose and structure of the course7 min
Programming a microcontroller means, in the most concrete sense, changing the state of some cells of non-volatile memory inside the chip, so that, on the next power-up, the central unit finds there a sequence of instructions it can execute. Nothing more. The microcontroller does not "understand" C, does not know what a function is and has no notion of a file - it has a program counter pointing to an address in Flash, reads a 16-bit word from there, interprets it as an instruction, executes it and moves on.
The second difference concerns where the work happens. The program is written, compiled and transformed on
a host computer, of a different architecture than the target. A compiler that runs on x86 and produces
code for an 8-bit AVR is called a cross-compiler, and
the set of tools that go with it forms a toolchain - for AVR,
it is called avr-gcc.
- Flash memory holds the program and stays written after power is cut, unlike SRAM, which empties out (Lecture 02)
- The central unit reads instructions from Flash through the program counter, on a bus separate from the data one - the Harvard architecture (Lecture 02)
- The oscillator frequency determines the duration of an instruction cycle; today we will see what happens when the program assumes a different frequency than the real one (Lecture 02)
- List, in order, the five transformations the source code goes through on its way to bits in Flash, with the tool and resulting file for each
- Explain why an .elf file cannot be sent directly to the programmer
- Read a line of an Intel HEX file and check its checksum
- Compare programming via bootloader with ISP, in terms of the hardware needed and the space consumed
- Estimate the Flash and SRAM usage of a program and recognize the symptoms of each running out
- Identify the real cause of a program that "runs twice as slowly" or resets on its own
2The stages of programming: from source to ELF12 min
An IDE displays a single command, "Compile" or "Upload". Behind it, five successive transformations run in order, each producing an intermediate file with a precise role. IDEs delete the intermediate files at the end - which does not mean they never existed, just that the student never gets to see them.
1. Preprocessing
The first tool to touch the source code is not the compiler, but the preprocessor - it does not
know C, for it the file is just text. It executes the directives starting with a hash sign
(#include, #define, #ifdef) and hands the compiler a
text from which these have disappeared. This explains why a fifteen-line program grows, after
preprocessing, to tens of thousands of lines: the header <avr/io.h> pulls in the
device-specific file, with the definitions of all registers and their bits. When we write
PORTB, the preprocessor replaces it with the physical address of the register; the compiler
never sees the name, only access to an address.
2. Actual compilation
The compiler receives the preprocessed text and produces assembly for the target architecture. Here the high-level structure is permanently lost: loops, functions, data types become AVR instructions, and variables are placed in the 32 general registers or, if they do not fit, in SRAM.
volatile trapIf a variable is modified in an interrupt routine, but the main program only
reads it in a loop, the compiler, which only analyzes the loop, notices that nobody
modifies it and reads it just once, keeping it in a register - the loop becomes infinite, even though
the variable changes in memory. volatile forbids exactly this optimization: it is not
a trick, but information the programmer has and the compiler does not.
3. Assembling
The assembler translates the mnemonics one-to-one into binary machine code - no optimization
decisions, just a fixed correspondence. The result, the .o file, is not executable code: it is organized
into sections (.text for instructions, .data for non-zero-initialized
global variables, .bss for zero-initialized ones, .rodata for
constants) and is incomplete - if it calls a function from another file, the assembler does not know its address
and leaves a gap, noted in the relocation table.
4. Linking
The linker receives all the object files and libraries, with three tasks: resolving
symbols (finds the definition of every name undefined locally - hence "undefined
reference" and "multiple definition" errors), allocating addresses (concatenates sections of
the same kind and places them in the address space, according to a linker script - for the ATmega328P,
.text starts at address 0 in Flash, .data/.bss in SRAM) and
relocation (fills in the gaps left by the assembler, now that the addresses are known).
.data section from Flash into SRAM, fills .bss with zero and only then calls
main. The initial values of global variables therefore take up space both in Flash and in
SRAM - an observation that comes back in the discussion of SRAM exhaustion.The result is an ELF executable file - it contains the fully relocated code, plus debugging
information (symbol names, address-to-line correspondence, variable types), essential for
any debugger and the reason the .elf file must not be discarded.
3From ELF to Flash memory9 min
A chip programmer does not need symbols or debugging information - it needs
an answer to a single question: what byte goes at what address. From the ELF, only the
sections that actually end up in Flash are extracted (.text and the initial image of
.data) and transcribed into the Intel HEX format, with the utility
avr-objcopy.
The machine code is already ready in the ELF - the conversion is a simple repackaging, from a complex
container into a minimal text format. From the same ELF, the content destined for EEPROM is
extracted separately, into the .eep file.
| Extension | Produced by | Content |
|---|---|---|
| .i | preprocessor | source with includes resolved |
| .s | compiler | AVR assembly code |
| .o | assembler | machine code by section, addresses unresolved |
| .elf | linker | relocated executable, with symbols |
| .hex | objcopy | address-byte pairs, for the programmer |
| .map | linker | the address of every symbol |
The transfer into memory
The last stage is the only one that involves hardware. A program on the host computer
(avrdude - AVR Downloader Uploader) reads the .hex file, splits it into
pages and sends them, through a serial protocol, to the write logic in the microcontroller's
Flash.
For the ATmega328P, a page is 64 words (128 bytes), and the 32 KB of Flash are split into 256 pages. Flash technology allows flipping a bit from 1 to 0 by writing, but not the other way around - bringing it back to 1 can only be done by erasing, again at the page or whole-chip level. The correct sequence is therefore: erase, then load the page into a buffer register, then the write command. The number of erase-write cycles is finite, on the order of tens of thousands - enough for development, but relevant if someone wanted to use Flash as frequently rewritten data memory.
After writing, verification is recommended: the programmer reads back the content and compares it with the source. A mismatch indicates insufficient power, too high a programming frequency, or worn-out memory.
.hex file do not touch the microcontroller and do not depend
on it - they can run without the board being connected. Only the last stage needs hardware. If the
error message appears before the upload progress bar, the problem is in the code or the configuration, not in the
cable, board or port.4The Intel HEX file format10 min
The .hex file is the last object a human can inspect before the
program disappears into the chip - and the only one in the chain simple enough to be read with the naked
eye. The format, defined in the 1970s for transfer over slow and unreliable serial
links, is pure ASCII text, each line stands on its own and carries its own address, and each line has a
checksum.
:BBaaAATTDD...DDCCaaAA - the address from which the bytes are written, most significant byte first.
TT - the record type.
DD...DD - the actual data bytes, exactly the machine code generated by the compiler.
CC - the checksum of the line.
| Type | Name | Role |
|---|---|---|
| 00 | Data | program bytes, to be written at the given address |
| 01 | End of file | the last line, contains no data |
| 04 | Extended linear address | the upper address bytes, above 64 KB (does not appear for the ATmega328P) |
Given the line :0A000000182800000000000000288E. Identify the fields and check
the checksum.
See the solution
The byte count is 0A (10 data bytes). The address is 0000. The type is
00 (program data). The 10 data bytes follow: 18 28 00 00 00 00 00 00 00
28. The last byte, 8E, is the checksum.
All bytes are added, excluding the checksum:
0A+00+00+00+18+28+00×7+28 = 72₁₆ = 114₁₀.
The two's complement of 72₁₆ = 0111 0010₂: flip the bits
(1000 1101) and add one: 1000 1110 = 8E₁₆. This matches the byte in the
file - the line is correct.
A faster alternative check: 72+8E = 100₁₆, whose low byte is zero -
exactly what the checksum property requires: the sum of all bytes on the line, including the
checksum, gives zero over eight bits.
The last line of any Intel HEX file is always :00000001FF - zero data
bytes, type 01, checksum FF (the complement of 01). Its absence makes the programmer refuse the
file, treating it as truncated.
A typical first line: count 10 (16 bytes), address 0000, type
00, data 0C 94 34 00 followed by 0C 94 3E 00 repeated three
times.
See the explanation
The bytes are read two at a time, in reverse order (AVR stores words with the low byte
first): the pair 0C 94 is the word 940C, the opcode of the absolute jump
instruction jmp; the next pair gives the destination address. What we see at the start of
memory is therefore the interrupt vector table - a list of jumps, one for
each interrupt source. The first entry, at address 0, is the reset vector and jumps to the startup
code; the others, unused in this program, all jump to the default routine, which just
resets the microcontroller.
5The structure of Flash memory and the bootloader10 min
Who writes to Flash? The memory is internal to the chip, and its pins do not expose the
memory bus directly. There are two answers: either an external device takes control of the chip through a
dedicated serial interface, or the microcontroller programs itself, running a program already
present in it, using the spm (store program memory) instruction, which writes a page
into Flash. The program that does this is called a bootloader.
The two sections of Flash memory
For safety, the Flash of the ATmega328P is split into an application section (the
lower part, from address zero, the user's program) and a boot section (the upper part,
the bootloader). The distinction is enforced by hardware: the spm instruction has effect only
when the PC is inside the boot section - an ordinary application program cannot rewrite Flash
either by mistake or on purpose.
On reset, the bootloader initializes the communication interface and waits a few hundred
milliseconds for a synchronization message. If it arrives, it receives code pages, writes them with
spm and jumps to address zero. If it does not arrive, it jumps straight to the application - the
explanation for the one-second delay observed when powering up an Arduino board.
The Optiboot bootloader, used on the Arduino Uno, takes up 512 bytes - which is why the Arduino environment reports 32256 bytes available, not 32768. If the bootloader is erased (for example by an ISP programming session that performs a full erase), the microcontroller keeps executing the application perfectly, but loses the ability to be programmed serially - any later change requires an external programmer, until the bootloader is rewritten. This is what the "Burn Bootloader" command does: it programs the bootloader image via ISP and sets the BOOTRST and BOOTSZ fuses accordingly.
6Programming Arduino boards8 min
On an Arduino Uno board, besides the ATmega328P, a second chip handles the USB-to-serial conversion - the host computer sees it as a virtual serial port, whose transmit/receive lines are wired to the RXD/TXD pins of the main microcontroller. This chip only carries bytes, it does not program anything.
Automatic reset
The bootloader only listens right after a reset. On old boards, the user had to press reset at exactly the right moment. The current solution: the DTR line of the virtual serial port is wired to the reset pin through a small capacitor. When the program on the computer opens the port, it toggles DTR, and the capacitor passes only the edge, generating a short reset pulse - the board resets itself at exactly the right moment.
avrdude -C avrdude.conf -p atmega328p -c arduino \
-P /dev/ttyUSB0 -b 115200 -D \
-U flash:w:prog.hex:i
-p gives the target microcontroller, -c the programming protocol,
-P the port, -b the speed, -U describes the operation (flash
memory, write, the file, the Intel HEX format). -D disables a full chip erase -
precisely so as not to destroy the bootloader.
The dialog protocol is called STK500: the host requests synchronization, asks for the chip's signature, sends the address and content of each page, requests the write, moves to the next page. The Optiboot bootloader implements only a subset, enough for writing to Flash - it cannot write the fuses or read EEPROM, precisely to stay within the 512 bytes allocated to it.
The communication speed must match exactly between the two ends, being fixed in the bootloader's code (Optiboot: 115200 b/s; older bootloaders: 57600). The most frequent cause of the error is a wrong board type selected in the development environment compared to the board actually connected.
7Programming via ISP9 min
In-system programming (ISP, or ICSP) is the basic method, which does not depend on any code already present in the microcontroller. It uses the chip's synchronous serial SPI interface, in a special mode activated by holding the reset pin active - as long as the chip is held in reset, the central unit executes nothing, and the programming logic takes over control through MOSI/MISO/SCK.
The dialog: enable programming, read the chip's signature (three bytes: manufacturer and model), erase, load the pages, verify.
The SCK clock applied by the programmer cannot exceed a quarter of the microcontroller's clock frequency, because the programming logic is internally synchronized with the system clock. A brand-new chip, on the divided internal oscillator (1 MHz clock), accepts at most 250 kHz on SCK - a programmer configured for a higher frequency will report finding no device, even though the wiring is correct. Most programmers have a "slow clock" option for this situation.
| Criterion | Bootloader (serial) | ISP (SPI) |
|---|---|---|
| Hardware needed | just the USB cable | dedicated programmer |
| Code present on the chip | a bootloader already written | none; works on a brand-new chip |
| Flash space consumed | 512 bytes or more | none |
| Access to fuses / lock bits | no | yes |
| Can write the bootloader | no | yes |
| Startup delay | yes (the listening window) | no |
| Typical use | ongoing development, updates | first programming, repair, production |
8Programming languages and the cost of libraries11 min
A microcontroller can, in principle, be programmed in any language with a compiler for that architecture. In practice, the choice is constrained by resources: with 32 KB of Flash and 2 KB of SRAM, there is no room for a virtual machine, a garbage collector or a rich standard library.
Assembly, C, C++
Assembly gives the most compact and fastest code possible, with perfectly known resource use - essential in loops with a strict deadline or interrupts with guaranteed timing. It becomes, however, hard to read and unportable beyond a few dozen lines; today it is used sparingly, in short, critical routines.
C holds the dominant position, for solid reasons: the types match the processor's natural units, bitwise operators allow direct manipulation of registers, pointers allow access to physical addresses. A good compiler generates, for carefully written code, a result comparable to hand-written assembly. The price: a steep learning curve and no safety net - C allows, without complaint, writing past the end of an array or an uninitialized pointer.
C++ adds classes and encapsulation - the Arduino libraries (Serial,
Servo) benefit visibly. The cost: dynamic allocation, exception handling and
runtime type identification consume memory that 2 KB of SRAM cannot sustain, which is why
embedded practice uses a disciplined subset, without these mechanisms.
What the Arduino libraries hide
The Arduino environment hides the registers behind functions with readable names - a good pedagogical choice at first, costly later.
/* variant with Arduino functions */
void setup() { pinMode(13, OUTPUT); }
void loop() { digitalWrite(13, HIGH); digitalWrite(13, LOW); }
/* the same operation, directly on registers */
#include <avr/io.h>
int main(void) {
DDRB |= (1 << PB5);
while (1) {
PORTB |= (1 << PB5);
PORTB &= ~(1 << PB5);
}
}
The difference in behavior is nil - on the Uno board, pin 13 is exactly PB5. The difference in cost is large:
PORTB |= (1<<PB5) generates the sbi instruction, two cycles.
digitalWrite(13, HIGH) translates the pin number into a port and bit by reading two tables from
Flash, checks whether the pin is attached to a PWM channel and, if so, detaches it, disables
interrupts, modifies the register and re-enables them - dozens of cycles, instead of two.
ATmega328P at 16 MHz. Estimate the maximum frequency of a square-wave signal obtained by
toggling a pin in a loop, with digitalWrite (~50 cycles/call) versus direct
register access (sbi/cbi, 2 cycles).
See the solution
T_clk = 1/(16×10⁶) = 62.5 ns. A full period requires one toggle up and one
down.
With the library: 2×50 = 100 cycles, T ≈ 100×62.5 ns = 6.25 µs,
f ≈ 160 kHz.
With direct access: 2×2 = 4 cycles, T ≈ 4×62.5 ns = 250 ns,
f ≈ 4 MHz.
The ratio is ~25, exactly the ratio of the cycle counts. For turning on an LED or reading a button, the difference is completely irrelevant - a human does not perceive microseconds. For generating a signal, communication implemented in software, or an interrupt that must finish quickly, the difference decides whether the application works.
9Debugging a microcontroller9 min
Debugging a microcontroller is structurally harder than on a computer: it has no screen, console or file system for a log; it interacts with the physical world in real time (a motor does not stop for a breakpoint); many faults sit at the code-hardware boundary (a poorly soldered wire, a power supply that sags), invisible to a software debugger; and the resources needed for debugging - program memory, pins - are exactly the resources in short supply.
Hardware-level debugging
For small AVRs, the mechanism is called debugWIRE - it uses a single wire, the reset pin itself, turned into a bidirectional command link: it stops the processor, reads/writes registers and memory, executes step by step, sets breakpoints.
debugWIRE is enabled through the DWEN fuse - once enabled, the reset pin no longer works as an ordinary reset, so programming via ISP, which depends on it, becomes impossible. Getting out requires a special debugger command; if the connection is lost before that, the chip requires high-voltage parallel programming, with specialized equipment. Arduino Uno boards do not conveniently expose this mode, because reset is already wired to the automatic reset circuit.
JTAG (four or five pins, on larger AVRs and most ARM chips) and SWD (the
reduced two-pin variant, specific to ARM) offer the same basic functions - breakpoints,
step-by-step execution, inspecting variables and the stack - working because the debugging
device reads the .elf file and knows which address corresponds to which line of code.
Why serial messages remain the main tool
They need no extra hardware (the link already exists, the same one used for uploading), do not stop execution (the system keeps running while it reports), allow observing evolution over time - a value that drifts slowly shows up in a log, not in a single break.
It takes time - at 9600 b/s, a thirty-character message takes over thirty milliseconds, an eternity for an interrupt. It consumes SRAM (next section). And, most insidiously, it can change the program's behavior: a fault caused by tight timing can disappear when we add prints and reappear when we remove them. When timing is suspected, a better alternative is toggling a free pin, observed on an oscilloscope or logic analyzer.
10Flash full, SRAM exhausted: the memory budget11 min
Flash memory full
The simplest error, clearly reported by the development environment. The usual cause is not the code written by the student, but the included libraries - a graphics library, a networking one, or using floating-point arithmetic can each add thousands of bytes. Out of the 32768 bytes, the bootloader already consumes 512.
Solutions, in order of effort: removing unused libraries, giving up floating
point where integers suffice, moving constant tables into Flash with PROGMEM,
and, as a last resort, removing the bootloader and programming via ISP.
SRAM memory exhausted
Much more insidious - it produces no message. The program compiles, uploads, starts, works for a while, then produces absurd results or resets on its own.
SRAM holds three things: global/static variables (the .data and
.bss sections, known at compile time), dynamic allocation (if used), and the stack, which
grows from the highest address toward lower ones. The compiler reports the size of the first
category, but cannot know how deep the stack will grow at run time. When the stack drops below
the global variables area, it starts overwriting them, and vice versa - with no protection
unit stopping it.
A program uses twenty constant messages of thirty characters each, printed over serial. Estimate the SRAM consumption.
See the solution
A literal such as "Temperature measured: " is, by default, copied from Flash into SRAM by the
startup code - AVR has separate address spaces for program and data, and ordinary pointers
point into SRAM. Twenty messages of thirty characters: 20×30 = 600 bytes, almost
a third of the available memory, without the programmer having declared a single variable.
The fix: the F() macro from the Arduino library (respectively the
PROGMEM attribute and the _P functions in plain C) keeps the string in Flash and
reads it character by character, as needed, with the lpm instruction.
/* consumes SRAM */
Serial.println("Temperature measured: ");
/* keeps the string in Flash */
Serial.println(F("Temperature measured: "));
11The clock and other confusions7 min
The clock configured differently than the code assumes
Shows up as wrong delays and garbled serial communication. The code does not measure time,
it counts cycles: _delay_ms calculates the number of iterations starting from the
F_CPU macro, defined at compile time; the same F_CPU is also used to calculate the
speed divider of the serial interface.
A program compiled with F_CPU = 16000000 contains _delay_ms(500).
The microcontroller, however, runs on the internal oscillator at 1 MHz. How long does it actually take?
See the solution
The compiler calculated the cycles assuming the declared frequency:
N = 0.5 s × 16×10⁶ = 8×10⁶ cycles - a fixed number, baked into the code.
At run time, the cycles are consumed at 1 MHz: t = 8×10⁶ / 1×10⁶ = 8 s, sixteen
times longer than the intended 0.5 s - exactly the ratio of the frequencies (16 MHz / 1 MHz = 16).
Diagnostic method: measure a known delay and calculate the real frequency as the product of the declared frequency and the ratio between the expected and measured duration.
Programmed via ISP, where nobody set the fuses for the external oscillator, or when a board with a different crystal than the one physically mounted is selected in the development environment. A simple check: a loop that turns the LED on for one second and off for one second, timed - if the observed period is eight or sixteen times larger than expected, the cause is the clock, not the program.
Other frequent confusions
A wrongly selected board type leads to the wrong communication speed with the bootloader. The
serial port occupied by another application makes the upload fail without a clear explanation. A pin used
simultaneously by the application and the ISP interface (PB3, PB4, PB5) can prevent programming if an
external circuit loads the lines too heavily. Omitting volatile on variables shared with
interrupts produces a program that works at zero optimization and hangs at the
default level - wrongly suggesting a compiler bug.
12Frequent mistakes4 min
- "The program compiled without errors, so it is correct." Compiling only checks syntax and types. A program that fits in Flash, but exhausts SRAM at run time, compiles flawlessly and behaves randomly on the board. Read the memory report shown at the end of compilation, not just the "Done compiling" line, and watch the percentage of SRAM used by global variables.
- "I changed the code, but the board behaves the same - it is probably a hardware problem." Most often the upload never happened: the wrong port is selected, the board chosen in the menu does not match, or the transfer failed, and the previous program remained in Flash. Check the message at the end of the upload (the number of bytes written and verified) before suspecting the hardware.
- "I can copy the .elf file directly onto the microcontroller, it is just an executable."
False - ELF contains addresses, sections and debugging information the programmer cannot
interpret; the microcontroller has no file system and no loader.
The Intel HEX file, obtained through conversion with
avr-objcopy, is sent to the programmer; the .elf is kept only for debugging. - "I set F_CPU in the code, so the microcontroller will run at that frequency." F_CPU controls nothing in hardware - it is just a constant the compiler uses to calculate delay loops. The real frequency comes from the oscillator and the configuration fuses. Match F_CPU to the real oscillator; if delays come out twice as long or twice as short, that is where the cause is, not in the code.
13Summary and glossary5 min
Programming means writing to a non-volatile memory at exact physical addresses, not installing a
file. The transformation chain runs from the source code through preprocessing, compiling, assembling and
linking to an ELF executable, then through conversion to the Intel HEX text format,
written page by page into Flash by avrdude. The ATmega328P's Flash splits into the
application section and the boot section, where the bootloader lives - a program that writes
itself, through spm, enabled only in its own zone, at the cost of 512 bytes and a
startup delay. ISP is the universal alternative, which does not depend on any code present on the chip
and has access to the fuses. C dominates microcontroller programming for its
closeness to the machine; Arduino libraries trade speed and memory for readability. Debugging is
structurally harder than on a computer - serial messages remain the main tool, and the
most insidious faults (exhausted SRAM, mismatched clock) produce no error message.
14Self-check questions7 min
- Describe, in order, the transformations a C source file goes through until the code is written into Flash, naming the tool and resulting file for each stage.
- Explain why the .elf file cannot be sent directly to the programmer and what information is lost when converting to Intel HEX.
- What are the three tasks of the linker? What does relocation mean?
- Why can an ordinary application program not write to its own Flash memory, and what hardware condition must be met for spm to have any effect?
- Compare programming via bootloader with ISP from the point of view of the hardware needed, the space consumed and access to the configuration bits.
- Explain the mechanism by which an Arduino board resets itself at the start of an upload, and connect it to the fact that opening the serial monitor restarts the program.
- A program uses twenty constant messages of forty characters each. Estimate the SRAM consumption and explain how the F() macro reduces it.
15Directions for further study2 min
The next lecture moves down from the toolchain to the simplest and most used peripheral of a microcontroller: the input-output ports - configuring them bit by bit, reading and writing directly through registers, and the electrical pitfalls of a digital pin (floating, pull-up, multiplexing).