Unter die Haube schauen - der Arduino-Compile-Prozess
https://oer-informatik.de/arduino_compile_prozess
tl/dr; (ca. 10 min Lesezeit): Ein Knopfdruck, ein bisschen warten, und schon ist der aktuelle Sketch auf den Mikrocontroller geladen und läuft. Aber was passiert eigentlich im Hintergrund? Erhalten wir “Assembler”-Code auf dem Weg? Finden wir es heraus!
Die einzelnen Schritte der Verarbeitung
Wir wollen an Hand eines minimalen Codebeispiels einmal den Weg durch alle Build-Phasen beim Kompilieren und Laden auf einen Microcontroller gehen. Als Codebeispiel habe ich das LED-Blink
-Beispiel minimal erweitert, um ein paar Dinge zeigen zu können:
#define SIMPLE_BUTTON 5 //Festlegung einer Konstanten als Präprozessor-Direktive
void setup() {
pinMode(LED_BUILTIN, OUTPUT); //Anpassung des Datenrichtungsregisters von Port B (Pin 13, PB5)
pinMode(SIMPLE_BUTTON, INPUT_PULLUP); //Anpassung des Datenrichtungsregisters von Port D (Pin 4, PD4)
}
void loop() {
bool tasterDeaktiv = digitalRead(SIMPLE_BUTTON); // Tasterzustand einlesen und speichern
if (!tasterDeaktiv) {
digitalWrite(LED_BUILTIN, HIGH); // turn the LED on (HIGH is the voltage level)
delay(100); // wait for 100ms
digitalWrite(LED_BUILTIN, LOW); // turn the LED off by making the voltage LOW
delay(100); // wait for 400ms
}else{
kurzePause();
}
}
void kurzePause(){
delay(1000); // wait for a second
}
Diesen Quelltext (Blink.ino) geben wir in die Verarbeitungskette: Er wird von der IDE angepasst, vom Compiler in mehreren Schritten bearbeitet, in eine Datei mit hexadezimalzahlen umgewandelt und schließlich auf den Arduino gespielt.

Wie aktiviere ich zusätzlich Informationen und wo finde ich sie?
Die Arduino IDE versucht, möglichst viel der Komplexität im Hintergrund des Build-Prozesses zu verstecken. Die Dateien mit den Zwischenständen liegen verborgen in temporären Dateien. Wir können uns diese Pfade und weitere Infos aber anzeigen lassen, wenn wir die Option unter File
/Preferences
aktivieren:

Wenn wir mit dieser Option unseren Sketch kompilieren finden auf der Konsole kryptische Befehle in dichter Abfolge - darin die Pfade zu den Verarbeitungszwischenprodukten und einige der im Hintergrund ablaufenden Befehle. Stark abgekürzt, mit vereinfachten Pfaden und weggelassenen Parametern, ist die Ausgabe etwa folgendermaßen (wen es interessiert: die Orginalausgabe mit allen Details findet sich hier):
Using board 'uno' from platform in folder: HARDWARE_PLATFORM
Using core 'arduino' from platform in folder: HARDWARE_PLATFORM
Detecting libraries used...
"GCC-PATH/avr-g++" -c -g -Os -w -std=gnu++11 "TEMP_BUILD_PATH\\sketch\\Blink.ino.cpp" -o nul
Generating function prototypes...
avr-g++ -c -g -Os -w -E -mmcu=atmega328p "TEMP_BUILD_PATH\\sketch\\Blink.ino.cpp" -o "TEMP_BUILD_PATH\\preproc\\ctags_target_for_gcc_minus_e.cpp"
Compiling sketch...
avr-g++" -c -g -Os -w -mmcu=atmega328p "TEMP_BUILD_PATH\\sketch\\Blink.ino.cpp" -o "TEMP_BUILD_PATH\\sketch\\Blink.ino.cpp.o"
Compiling libraries...
Compiling core...
Using precompiled core: PRECOMPILED\core_arduino_avr_uno_c11f2e2e83244cce132c3e699a030307.a
Linking everything together...
avr-gcc -w -Os -g -flto -fuse-linker-plugin -Wl,--gc-sections -mmcu=atmega328p -o "TEMP_BUILD_PATH/Blink.ino.elf" "TEMP_BUILD_PATH\\sketch\\Blink.ino.cpp.o" "TEMP_BUILD_PATH..\\arduino-core-cache\\core_arduino_avr_uno_c11f2e2e83244cce132c3e699a030307.a" "-LTEMP_BUILD_PATH/" -lm
avr-objcopy -O ihex -j .eeprom "TEMP_BUILD_PATH/Blink.ino.elf" "TEMP_BUILD_PATH/Blink.ino.eep"
avr-objcopy -O ihex -R .eeprom "TEMP_BUILD_PATH/Blink.ino.elf" "TEMP_BUILD_PATH/Blink.ino.hex"
avr-size -A "TEMP_BUILD_PATH/Blink.ino.elf"
Sketch uses 1102 bytes (3%) of program storage space. Maximum is 32256 bytes.
Global variables use 9 bytes (0%) of dynamic memory, leaving 2039 bytes for local variables. Maximum is 2048 bytes.
Irgendwo gegen Ende dieser Ausgabe wird auf eine *.HEX
-Datei verwiesen, im Log oben “TEMP_BUILD_PATH/Blink.ino.hex” - der Pfad ist natürlich eigentlich länger. Das ist das fertige Programm, was später auf den Microcontroller geladen wird.
Den Pfad, den ich der Einfachheit halber TEMP_BUILD_PATH
genannt habe, steckt im Userordner - in Windows z.B. unter HOME/AppData/Local/Temp/arduino-sketch-HASHWERT
. Wer neugierig genug ist, diesen Artikel zu lesen, sollte einen Compileprozess anstoßen und einen Blick in diesen - dann frisch erzeugten - Ordner werfen. Ich habe ein Beispiel hier hinterlegt.
Als kleiner Teaser: so etwa sieht das dann aus:

In der oben abgedruckten Log-Datei finden sich bereits die meisten Phasen, die wir jetzt im Einzelnen anschauen werden.
Schritt für Schritt durch den Build-Prozess
Im Hintergrund durchläuft unser Sketch viele Phasen. In jeder dieser Phasen entsteht ein Artefakt (Daten in einem bestimmten Format). Nicht immer werden diese Artefakte als Datei ins Dateisystem geschrieben - manche werden direkt weiterverarbeitet.
Ich habe versucht, zusammenzutragen, was in den Phasen jeweils passiert, wo die erzeugten Dateien zu finden ist (oder wie sie generiert werden können, falls sie nur intern vorliegen).
Zum Compilieren werden Hardwareabhängige Tools genutzt, diese liegen in verschiedenen Ordnern - genaue Pfade kann man der Konsolenausgabe (siehe oben) entnehmen. Beispielhaft liegt der C-Kompiler GCC/G++:
für AVR ATMega-Microcontroller im Ordner
"C:\\Users\Musternutzer\AppData\Local\Arduino15\packages\arduino\tools\avr-gcc
,für ESP8266-basierte Systeme im Odrner
C:\\Users\Musternutzer\AppData\Local\Arduino15\packages\esp8266\tools\xtensa-lx106-elf-gcc\3.0.4-gcc10.3-1757bed\bin\xtensa-lx106-elf-g++
undfür ESP32-Microcontroller unter
C:\\Users\Musternutzer\AppData\Local\Arduino15\packages\esp32\tools\xtensa-esp32-elf-gcc\gcc8_4_0-esp-2021r2-patch3\bin
(je nach verwendetem ESP32 weicht der Name für denxtensa
-Ordner ab).
Schritt 1: Preprocessing der Arduino-IDE
Die Arduino-IDE bereitet den Sketch (*.ino-Datei) so vor, dass gcc ihn verarbeiten kann: - Der Sketch (*.ino
) wird in eine C++ (*.cpp
)-Datei kopiert - Die nötigen Bibliotheken werden gesucht und ggf. geeignete Kandidaten des Arduino-Frameworks identifiziert. So wird beispielsweise auch #include <Arduino.h>
als erste Zeile angefügt: das ist die zentrale Datei, in der die Arduino-IDE viel Komplexität versteckt - z.B. Konstanten, interne Abhängigkeiten, Arduino-spezifische Funktionen.
- Diese Datei wird angepasst: vor der Implementierung der ersten Funktion werden alle im Sketch existierenden Funktionen angemeldet (also die Signatur ohne Rumpf deklariert, man nennt das in C++ einen Funktionsprototypen). Neben dem Namen, den Parametern und den Rückgabewerten wird auch jeweils die Zeile und Datei notiert, in der die Funktion zu finden ist:
#include <Arduino.h>
#line 1 "C:\\Users\\hanne\\Desktop\\Blink\\Blink.ino"
#define SIMPLE_BUTTON 5 //Festlegung einer Konstanten als Präprozessor-Direktive
#line 3 "C:\\Users\\hanne\\Desktop\\Blink\\Blink.ino"
void setup();
#line 8 "C:\\Users\\hanne\\Desktop\\Blink\\Blink.ino"
void loop();
#line 20 "C:\\Users\\hanne\\Desktop\\Blink\\Blink.ino"
void kurzePause();
#line 3 "C:\\Users\\hanne\\Desktop\\Blink\\Blink.ino"
void ensureWIFIConnection();
Die entstehende Datei (Blink.ino.cpp
) findet sich im Ordner TEMP_BUILD_PATH
/sketch
/ und dient als Eingabedatei für die folgenden Schritte.
Schritt 2: Preprocessing von gcc
Die folgenden Schritte werden vom C++-Compiler avr-g++
übernommen. Dahinter steckt der GNU-C-Compiler gcc
. Bei Aufruf ohne spezielle Optionen würde aus dem Code und den per Argument übergebenen Abhängigkeiten direkt ein ELF-File gebildet. Es würden also alle Schritte ablaufen, die ich oben im grauen Kasten des Flussdiagramms zusammengefasst hatte. Wir wollen jedoch Schritt für Schritt vorgehen (und auch die Arduino-IDE macht das so. gcc
erlaubt es, den Prozess nach definierten Phasen abzubrechen - die Funktion nutzen wir im Folgenden (mit einer Ausnahme macht das die IDE genauso). Der Grundaufruf ist immer identisch:
Das Format der Ausgabedatei (oben: result.file
) kann per Option bestimmt werden:
Option
-E
bricht nach dem Präprozessor ab:> avr-g++ -E source.cpp -o preprocessedsource.cpp
Option
-S
bricht nach dem Erstellen der Assembler-Datei ab:> avr-g++ -S source.cpp -o assemblersource.a
Option
-c
bricht nach Erstellen des Object-Files ab:> avr-g++ -c source.cpp -o objectfile.o
ohne Optionen wird das ELF-File erzeugt:
> avr-g++ source.cpp -o executable_and_linking_format.elf
Mit dem Wissen schauen wir uns den weiteren Verlauf an:
Im nächsten Schritt wird der Sourcecode für die Verarbeitung durch den Compiler vorbereitet. Ein erster Durchlauf identifiziert die benötigten Bibliotheken, es entsteht noch keine neue Datei (-o nul
leitet die Ausgabe ins leere):
Ein zweiter Durchlauf fügt den Inhalt der per # include
verknüpften Dateien an die Stellen der Verknüpfung ein. Auch andere Präprozessor-Direktiven werden jetzt verarbeitet: einfaches Suchen/Ersetzen mit #define
, bedingtes Einsetzen / Löschen mit #ifdef
usw. Alle Präprozessor-Direktiven (Codezeilen, die mit #
beginnen) werden gesucht und entsprechende Konstanten und Makros durch Werte ersetzt.
Das betrifft sowohl eigene Konstanten, die im Programmtext gesetzt wurden (in unserem Beispiel SIMPLE_BUTTON
). Das betrifft aber auch Konstanten, die durch importierte Bibliotheken gesetzt wurden. So ist HIGH
eigentlich nur eine solche Konstante (Präprozessor-Direktive), die in diesem Schritt überall durch die hexadezimale 1 ersetzt wird (0x1
). INPUT_PULLUP
ist ein weiteres Beispiel hierfür. Kommentare, die bei den Direktiven gesetzt wurden, werden eingefügt - das kann man im folgenden Beispiel gut erkennen, in dem der Quellcode:
#define SIMPLE_BUTTON 5 //Festlegung einer Konstanten als Präprozessor-Direktive
pinMode(SIMPLE_BUTTON, INPUT_PULLUP); //Anpassung des Datenrichtungsregisters von Port D (Pin 4, PD4)
umgewandelt wird in der Ausgabe nach diesem Schritt zu:
Die neu entstandene Datei ctags_target_for_gcc_minus_e.cpp
ist deutlich größer als das Ausgangsdokument der Phase: sie enthält auch den Inhalt der eingebundenen Header-Dateien (.h
, z.B. Arduino.h
1) und aller in diesen eingebundenen Header-Dateien. Ganz am Ende findet sich dann unser Quellcode, ergänzt um die Funktionsprototypen und mit ersetzten Konstanten der Präprozessor-Direktiven.
In einem weiteren Aufruf werden aus dieser Datei mit dem Programm ctags
weitere Informationen extrahiert - insbesondere die Funktionsaufrufe werden gelistet. Eine Beispielausgabe habe ich hier im Repository hinterlegt.
Der zweite Befehls-Aufruf der Preprocessing-Phase sieht - vereinfacht - so aus:
Schritt 3: Kompilieren
Mit dem nächsten Befehl geht gcc
einen Schritt weiter: wir stoppen den Ablauf mit der Option -c
erst vor dem Linken.
Eingabe ist wieder der Sourcecode, den die IDE zur Verfügung gestellt hat, d.h. die ersten Schritte laufen erneut ab.
Als Ergebnis dieser Phase wird ein Object-File erstellt (Dateiendung *.o
) und eine Liste der eingebundenen Dateien (*.d
-Datei). Genau genommen: mehrere Object-Files. Für jedes Modul ein eigenes.
Erst in diesem Stadium finden die Syntax-Checks des Compilers statt und wir erhalten die Fehlermeldungen, z.B.:
Compiling sketch...
C:\Blink\Blink.ino:9:1: error: expected ',' or ';' before 'const'
Objectfiles enthalten die Befehle bereits in Maschinensprache, sind also auf die jeweilige Hardware zugeschnitten. Eigentlich könnte hier ein Zwischenschritt eingebaut sein: Umwandlung in menschenlesbaren Assemblercode, wie man ihn zur Microcontrollerprogrammierung auch verwendet, also z.B. LOAD: ldi
, JUMP:rjmp
, out
usw.). Wir erhalten diesen Code nur auf Umwegen (dazu unten mehr). Aber: Assemblerprogrammteile werden ab diesem Schritt identisch behandelt: auch aus ihnen erzeugt der Compiler Objectfiles.
Das Objectfile ist bereits vom Datenformat “Executeable and Linking Format” (ELF) und hat dessen Struktur. Diese Struktur kann z.B. über den Befehl avr-readelf.exe -a Blink.ino.cpp.o
ausgegeben werden.
In dem Objectfile sind Informationen in unterschiedlichen Bereichen gespeichert:
Allgemeine Infos im ELF-Programm-Header: für welchen Prozessor ist der Code ausgelegt, wie groß sind die Header usw… (per
avr-readelf.exe -h Blink.ino.cpp.o
)der Programmcode im Segment .text (Ausgabe per
avr-objdump.exe -t Blink.ino.cpp.o
- später mit dem*.elf
-File noch etwas komfortabler)die Variablen- und Funktionsnamen im Segment .symtab (per
avr-objdump.exe -t Blink.ino.cpp.o
)die Variablen im Segment .data bzw. .bss, Strings ins Segment .shstrtab
Wer tiefer einsteigen will und noch ein bisschen herumsuchen möchte: neben avr-objdump
und avr-readelf
kann man auch mit 7Zip die Objectfiles öffnen und deren Inhalt extrahieren. Das ist aber starker Tobak.
Schritt 4: Linken
Man muss es sich wohl so vorstellen: der Linker verknüpft alle für das Programm erstellten ObjectFile mit dem Speicher der Hardware, auf der der Code später laufen soll. Hierzu benötigt der Linker Informationen zur Hardware (der verwendeten Microcontroller-Architektur). Welche Hardware genutzt wird, findet sich in der folgenden Option des Linker-Aufrufs (Beispiel für einen Uno mit 328P MCU): -mmcu=atmega328p
. gcc
wiederum verfügt über Tabellen wie diese hier um nachzuschlagen, in welcher Datei die Hardwareinfos der Prozessoren stecken (für den Uno ist hier avr5
gelistet). Diese Hardware-Infos schließlich finden sich in einem Unterordner der Tools/Gcc, bei mir: Arduino15\packages\arduino\tools\avr-gcc\7.3.0-atmel3.6.1-arduino7\avr\lib\ldscripts
).
Zugegeben, wir verlassen hier komplett den Bereich, in dem ich mich noch wohlfühle. Um eine Ahnung zu bekommen, was darin steht hier ein Auszug. Der erste Abschnitt legt fest, welche Speicherbereiche es gibt, bei welcher Adresse sie starten (ORIGIN
) und wie groß sie sind (LENGTH
). Außerdem findet sich ein Hinweis auf die Berechtigungen der Speicherbereiche (z.B. rw!x
):
MEMORY
{
text (rx) : ORIGIN = 0, LENGTH = 128K
data (rw!x) : ORIGIN = 0x800060, LENGTH = 0xffa0
eeprom (rw!x) : ORIGIN = 0x810000, LENGTH = 64K
fuse (rw!x) : ORIGIN = 0x820000, LENGTH = 1K
lock (rw!x) : ORIGIN = 0x830000, LENGTH = 1K
signature (rw!x) : ORIGIN = 0x840000, LENGTH = 1K
user_signatures (rw!x) : ORIGIN = 0x850000, LENGTH = 1K
}
Im zweiten Abschnitt wird festgelegt, welche der Sections, die im ObjectFile erstellt wurden, in welchen Speicherbereich geschrieben werden soll:
Der Linker fügt nun alle nötigen Abhängigkeiten an, ordnet sie den Speicherbereichen zu und erstellt daraus eine Datei im Executable and Linking Format (Dateiendung *.elf
).
Insbesondere werden hier auch Hardware-abhängige Bibliotheken hinzugefügt - wie in dem Beispiel unten arduino-core-cache/core_arduino_avr_mega_cpu_atmega2560.a
Zur Einordnung aus anderen Bereichen: bei Desktop-Programmen würde der Linker auch Bibliotheken einfügen, die als *.lib
oder *.dll
vorliegen.
Der abgekürzte Befehl zum Linken ist etwa der folgende:
> avr-gcc -fuse-linker-plugin -o Blink.ino.elf sketch/Blink.ino.cpp.o arduino-core-cache\\core_arduino_avr_uno.a
Da die entstehende *.elf
-Datei vom gleichen Dateityp ist wie die *.o
-Datei (das Objectfile) können wir die gleichen Tricks nutzen, wenn wir einen Blick hinein werfen wollen (objdump
, readelf
, 7Zip).
Kurzer Einschub: Kann man aus dem Code auch ausführbaren Assembler-Code machen?
Aprospos einen Blick hineinwerfen: Wer schonmal mit Assembler gearbeitet hat - oder vor hat, das zu tun, möchte vielleicht mal sehen, welcher Assembler-Code aus dem C++ Code generiert wird, der uns vorliegt. Es gibt verschiedenste Wege, das zu tun.
Für Erforschungszwecke finde ich die Ausgabe mit folgendem Befehl am elegantesten: hier wird der Assemblercode mit Textsymbolen dargestellt und die zugehörigen C++
-Befehle dazu geschrieben:
Das Ergebnis sieht dann etwa so aus (Mini-Auszug):
void loop() {
bool tasterDeaktiv = digitalRead(SIMPLE_BUTTON); // Tasterzustand einlesen und speichern
if (!tasterDeaktiv) {
digitalWrite(LED_BUILTIN, HIGH); // turn the LED on (HIGH is the voltage level)
41a: 81 e0 ldi r24, 0x01 ; 1
41c: 0e 94 9e 00 call 0x13c ; 0x13c <digitalWrite.constprop.1>
Blink/Blink.ino:12
delay(100); // wait for 100ms
420: 64 e6 ldi r22, 0x64 ; 100
422: 70 e0 ldi r23, 0x00 ; 0
424: 80 e0 ldi r24, 0x00 ; 0
426: 90 e0 ldi r25, 0x00 ; 0
428: 0e 94 ee 00 call 0x1dc ; 0x1dc <delay>
Blink/Blink.ino:13
digitalWrite(LED_BUILTIN, LOW); // turn the LED off by making the voltage LOW
42c: 80 e0 ldi r24, 0x00 ; 0
42e: 0e 94 9e 00 call 0x13c ; 0x13c <digitalWrite.constprop.1>
Die komplette Ausgabe findet sich hier.
Man kann den gcc
-Prozess mit der Option -S
auch unterbrechen, um an den Assemblercode zu kommen:
> avr-g++ -S source.cpp -o assemblersource.a
Ob es sich bei den kryptischen Ausgaben aber tatsächlich um binären Assemblercode handelt - ich vermag es nicht zu sagen und nenne diese Option nur der Vollständigkeit halber. Eine gute Quelle für dieses Themengebiet ist Matt Godbolt - siehe unten in den Links.
Schritt 5: Object Copy
Mit der erstellten ELF-Datei ist das Programm eigentlich fertig. Jetzt muss es nur noch in eine Datei mit Operationen in hexadezimaler Schreibweise umgewandelt werden. Microcontroller benötigen den Code in dieser Form (Dateiendung *.hex
).
Das erledigt das Programm avr-objcopy
:
> avr-objcopy -O ihex -R .eeprom Blink.ino.elf Blink.ino.hex
Schritt 6: Programmer
- Die
*.hex
-Datei wird schließlich von einem Programmer (z.B.avrdude
) auf den Microcontroller geladen. Um diesen Teil kümmere ich mich in einem späteren Blog-Artikel, in dem ich zeige, wie man ein kleines Assembler-Programm schreiben und hochladen kann.
Fazit
Zugegeben: es gibt nicht viele Anwendungsfälle, in denen erforderlich ist, die einzelnen Schritte vom Code zum laufenden Microcontrollerprogramm zu verstehen. Wer so tief unter der Oberfläche buddelt, der nutzt vermutlich ohnehin nicht mehr die Arduino-IDE.
Für neugierige Menschen wie mich ist es aber manchmal wichtig, Blackbox-Prozesse zu entschlüsseln und wenigstens rudimentäre Zusammenhänge und Kausalketten zu verstehen. Mit diesem Wissen möchte ich mir als nächstes die Grundlagen von Assembler anschauen, aber dazu später mehr…
Links und weitere Informationen
Wer sich näher mit dem Erzeugen lesbaren Assembler-Codes aus C++ Code beschäftigen will, für den ist Matt Godbolt’s Compiler explorer ein guter Anlaufpunkt
Eine schöne Beschreibung des ELF-Formats und der genutzten Befehle findet sich in dem Beitrag von Raghavendra Chandra Ganiga auf OpenSourceForU.comm
Weitere gute Infos zu AVR-Microcontrollern finden sich generell hier: https://wiki.ubuntuusers.de/AVR/
Quellen und offene Ressourcen (OER)
Die Ursprungstexte (als Markdown), Grafiken und zugrunde liegende Diagrammquelltexte finden sich (soweit möglich in weiterbearbeitbarer Form) in folgendem git-Repository:
https://gitlab.com/oer-informatik/mcu/arduino-esp.
Sofern nicht explizit anderweitig angegeben sind sie zur Nutzung als Open Education Resource (OER) unter Namensnennung (H. Stein, oer-informatik.de) freigegeben gemäß der Creative Commons Namensnennung 4.0 International Lizenz (CC BY 4.0).
zu finden unter
~\AppData\Local\Arduino15\packages\arduino\hardware\avr\1.8.6\cores\arduino
↩