Arduino Speicher voll? RAM sparen mit PROGMEM, F() und den richtigen Datentypen
Der Sketch kompiliert. Die IDE meldet „Low memory available, stability problems may occur“ – und du drückst trotzdem auf Upload. Zehn Minuten später zeigt das Display Buchstabensalat, der Sensor liefert Nullen oder das Board startet endlos neu. Willkommen im SRAM-Überlauf. Die gute Nachricht: In fast jedem Anfänger-Sketch liegen 40 bis 60 Prozent des belegten RAMs völlig unnötig herum. Dieses Tutorial zeigt dir, wo sie stecken und wie du sie mit vier Techniken zurückholst – ohne eine einzige Funktion zu streichen.
Drei Speicher, die ständig verwechselt werden
Ein Nano V3 oder UNO R3 hat nicht „einen“ Speicher, sondern drei völlig verschiedene – mit stark unterschiedlicher Größe:
| Speicher | Größe (ATmega328) | Inhalt | Bleibt nach Reset? |
|---|---|---|---|
| Flash (Programmspeicher) | 32 KB (ca. 30 KB nutzbar) | dein kompilierter Code, Konstanten mit PROGMEM | ja |
| SRAM (Arbeitsspeicher) | 2 KB | Variablen, Puffer, Stack, String-Objekte | nein |
| EEPROM | 1 KB | Einstellungen, Kalibrierwerte | ja |
Der Engpass ist praktisch immer das SRAM. 2048 Bytes – das sind zwei einzelne Textzeilen im Browser. Und davon gehen schon vor deiner ersten Zeile Code rund 200 Bytes für die serielle Schnittstelle drauf.
| Board | Flash | SRAM | Typischer Engpass |
|---|---|---|---|
| Nano V3 / UNO R3 | 32 KB | 2 KB | SRAM – fast immer |
| UNO R4 Minima | 256 KB | 32 KB | selten |
| RP2040-Zero | 2 MB | 264 KB | selten |
| ESP32 DevKit | 4 MB | 320 KB | Heap-Fragmentierung bei WLAN + TLS |
| ESP32-S3 N16R8 | 16 MB | 512 KB + 8 MB PSRAM | praktisch nie |
⚠️ Der Klassiker: Es kompiliert, also passt es. Falsch. Der Compiler kennt nur die statischen Variablen. Was zur Laufzeit dazukommt – Stack für Funktionsaufrufe, lokale Puffer, dynamisch wachsende String-Objekte – sieht er nicht. Die Meldung „Globale Variablen verwenden 1750 Bytes (85%)“ heißt nur: Vor dem Start sind 298 Bytes frei. Ob die für Stack und Heap reichen, entscheidet sich erst im Betrieb.
Das brauchst du
Für die Beispiele reicht ein Board und ein Display – der Rest ist reine Software.
1Testaufbau verkabeln
Der Aufbau ist bewusst „zu groß“ für einen Nano – genau darum geht es:
| Modul | Pin am Modul | Nano / UNO | Hinweis |
|---|---|---|---|
| OLED SSD1306 | SDA / SCL | A4 / A5 | I2C, Adresse 0x3C |
| OLED SSD1306 | VCC / GND | 5V / GND | Modul hat Regler an Bord |
| LCD1602 I2C | SDA / SCL | A4 / A5 | gleicher Bus, Adresse 0x27 |
| DHT22 | DATA | D2 | 10 kΩ Pull-Up nach 5V |
| Micro-SD | CS / MOSI / MISO / SCK | D10 / D11 / D12 / D13 | SPI, 5V-Modul mit Pegelwandler |
2Erst messen, dann optimieren
Bevor du irgendetwas änderst, brauchst du eine Zahl. Der Compiler liefert die eine Hälfte, der laufende Sketch die andere. Diese Funktion berechnet den Abstand zwischen Heap-Ende und Stack-Spitze – also den tatsächlich freien SRAM:
// Funktioniert auf allen AVR-Boards (UNO, Nano, Mega, Pro Mini)
#ifdef __AVR__
extern unsigned int __heap_start;
extern void *__brkval;
int freierSpeicher() {
int frei;
// Adresse einer lokalen Variablen = aktuelle Stack-Spitze
return (int) &frei - (__brkval == 0 ? (int) &__heap_start : (int) __brkval);
}
#else
// ESP32 / ESP8266 bringen das selbst mit
int freierSpeicher() { return ESP.getFreeHeap(); }
#endif
void zeigeSpeicher(const __FlashStringHelper *wo) {
Serial.print(wo);
Serial.print(F(": "));
Serial.print(freierSpeicher());
Serial.println(F(" Bytes frei"));
}
void setup() {
Serial.begin(115200);
delay(200);
zeigeSpeicher(F("nach setup-Start"));
// ... deine Initialisierungen ...
zeigeSpeicher(F("nach Init"));
}
void loop() {
static unsigned long letzte = 0;
if (millis() - letzte >= 5000) {
letzte = millis();
zeigeSpeicher(F("loop")); // muss konstant bleiben!
}
// ... dein Code ...
}
💡 Der wichtigste Test dauert fünf Minuten. Lass den Sketch laufen und beobachte die loop-Ausgabe. Bleibt die Zahl konstant? Dann ist alles gut. Sinkt sie langsam aber stetig, hast du ein Speicherleck – meist durch String-Verkettungen. Sinkt sie unter 200 Bytes, ist der Absturz nur eine Frage der Zeit.
Und so liest du die Compiler-Ausgabe richtig:
Der Sketch verwendet 24518 Bytes (79%) des Programmspeicherplatzes.
Globale Variablen verwenden 1743 Bytes (85%) des dynamischen Speichers,
verbleiben 305 Bytes fuer lokale Variablen. Maximum sind 2048 Bytes.
- 79 % Flash – völlig unkritisch, bis 95 % ist alles in Ordnung.
- 85 % SRAM – kritisch. Ab 75 % solltest du handeln, ab 85 % wird es unzuverlässig.
-
305 Bytes verbleibend – davon geht der Stack ab. Jede verschachtelte Funktion, jedes
float-Argument, jeder lokale Puffer knabbert daran.
3Textkonstanten ins Flash: F() und PROGMEM
Das ist der mit Abstand größte Hebel. Jeder Text in Anführungszeichen wird beim Start vom Flash ins SRAM kopiert – auch wenn er sich nie ändert. Ein Sketch mit 30 Debug-Ausgaben à 25 Zeichen verbrennt so 750 Bytes, über ein Drittel des gesamten Arbeitsspeichers.
// ---------- SCHLECHT: 84 Bytes SRAM fuer reinen Text ----------
void setupSchlecht() {
Serial.begin(115200);
Serial.println("Wetterstation v2.1 startet"); // 27 Bytes SRAM
Serial.println("Sensoren werden geprueft..."); // 28 Bytes SRAM
Serial.println("Bereit. Warte auf Messwerte."); // 29 Bytes SRAM
}
// ---------- GUT: 0 Bytes SRAM, Text bleibt im Flash ----------
void setupGut() {
Serial.begin(115200);
Serial.println(F("Wetterstation v2.1 startet"));
Serial.println(F("Sensoren werden geprueft..."));
Serial.println(F("Bereit. Warte auf Messwerte."));
}
Das F()-Makro sagt dem Compiler: „Lass diesen Text im Flash und lies ihn Byte für Byte aus, wenn er gebraucht wird.“ Es kostet ein paar Mikrosekunden pro Ausgabe – und spart dir jedes einzelne Byte.
Bei Arrays von Texten – Menüeinträgen, Fehlermeldungen, Wochentagen – reicht F() nicht. Hier brauchst du PROGMEM mit einer Indirektion:
#include <avr/pgmspace.h>
// Jeder Text einzeln ins Flash
const char txt0[] PROGMEM = "Temperatur";
const char txt1[] PROGMEM = "Luftfeuchte";
const char txt2[] PROGMEM = "Luftdruck";
const char txt3[] PROGMEM = "Bodenfeuchte";
const char txt4[] PROGMEM = "Helligkeit";
const char txt5[] PROGMEM = "Einstellungen";
// Und die Tabelle der Zeiger ebenfalls
const char *const MENUE[] PROGMEM = { txt0, txt1, txt2, txt3, txt4, txt5 };
const uint8_t MENUE_ANZAHL = 6;
char puffer[16]; // ein einziger Arbeitspuffer fuer alle Eintraege
const char *menueText(uint8_t index) {
if (index >= MENUE_ANZAHL) return "";
strcpy_P(puffer, (char *) pgm_read_word(&MENUE[index]));
return puffer;
}
void zeigeMenue(uint8_t auswahl) {
for (uint8_t i = 0; i < MENUE_ANZAHL; i++) {
Serial.print(i == auswahl ? F("> ") : F(" "));
Serial.println(menueText(i));
}
}
ℹ️ Rechnung für dieses Menü: Ohne PROGMEM belegen die sechs Texte plus Zeigertabelle rund 84 Bytes SRAM. Mit PROGMEM sind es genau 16 Bytes – der eine Arbeitspuffer. Bei einem OLED-Menüsystem mit 20 Einträgen wird daraus schnell ein Unterschied von 300 Bytes.
Zahlentabellen – Kalibrierkurven, Kennlinien, Tonleitern – funktionieren nach demselben Prinzip:
// Kennlinie eines NTC: 31 Stuetzstellen von -20 bis 100 Grad
const uint16_t NTC_TABELLE[] PROGMEM = {
929, 895, 858, 817, 774, 728, 681, 632, 583, 535,
487, 442, 398, 358, 320, 285, 253, 224, 198, 174,
153, 135, 118, 104, 91, 80, 70, 62, 54, 48, 42
};
float temperaturAusADC(uint16_t adc) {
// Tabelle ist absteigend sortiert -> passende Stuetzstelle suchen
for (uint8_t i = 0; i < 30; i++) {
uint16_t oben = pgm_read_word(&NTC_TABELLE[i]);
uint16_t unten = pgm_read_word(&NTC_TABELLE[i + 1]);
if (adc <= oben && adc >= unten) {
float anteil = (float)(oben - adc) / (float)(oben - unten);
return -20.0 + (i + anteil) * 4.0; // 4 Grad pro Stuetzstelle
}
}
return NAN; // ausserhalb des Messbereichs
}
62 Bytes, die sonst dauerhaft im SRAM lägen, liegen jetzt im Flash – wo ohnehin 20 % frei sind.
4Die String-Klasse: bequem, teuer, gefährlich
Die String-Klasse von Arduino ist der zweitgrößte Speicherfresser – und der einzige, der deinen Sketch nach Stunden abstürzen lässt statt sofort. Der Grund heißt Heap-Fragmentierung.
Jedes + auf einem String legt einen neuen Speicherblock an, kopiert um und gibt den alten frei. Nach ein paar tausend Durchläufen ist der Heap ein Schweizer Käse: In Summe wären 400 Bytes frei, aber kein einziger zusammenhängender Block von 60 Bytes. Die nächste Zuweisung schlägt fehl – und der Sketch hängt.
// ---------- SCHLECHT: fragmentiert den Heap bei jedem Durchlauf ----------
void sendeSchlecht(float temp, float feuchte, int boden) {
String zeile = "";
zeile += millis();
zeile += ",";
zeile += String(temp, 2);
zeile += ",";
zeile += String(feuchte, 2);
zeile += ",";
zeile += boden;
Serial.println(zeile);
}
// ---------- GUT: fester Puffer, null Heap-Nutzung ----------
void sendeGut(float temp, float feuchte, int boden) {
char zeile[48];
char sTemp[8], sFeucht[8];
// AVR kann kein %f in printf -> dtostrf benutzen
dtostrf(temp, 4, 2, sTemp);
dtostrf(feuchte, 4, 2, sFeucht);
snprintf(zeile, sizeof(zeile), "%lu,%s,%s,%d",
millis(), sTemp, sFeucht, boden);
Serial.println(zeile);
}
// ---------- NOCH BESSER: gar kein Puffer noetig ----------
void sendeAmBesten(float temp, float feuchte, int boden) {
Serial.print(millis()); Serial.print(',');
Serial.print(temp, 2); Serial.print(',');
Serial.print(feuchte, 2); Serial.print(',');
Serial.println(boden);
}
⚠️ snprintf statt sprintf – immer. sprintf kennt die Puffergröße nicht und schreibt fröhlich darüber hinaus. Auf einem PC gibt das einen Segfault, auf einem ATmega überschreibt es einfach die nächste Variable – und du suchst tagelang nach einem Sensorfehler, der keiner ist. snprintf mit sizeof(puffer) kostet nichts und schneidet im Zweifel ab.
💡 Wann ist String trotzdem okay? Auf einem ESP32 mit 320 KB RAM ist die Fragmentierung deutlich unkritischer – dort ist String für JSON-Aufbau und HTTP-Antworten völlig normal. Und selbst auf dem AVR: Ein String, der einmal in setup() angelegt und nie verändert wird, fragmentiert nichts. Das Problem sind ausschließlich Änderungen in der loop().
5Datentypen: das billigste Kilo Speicher der Welt
int ist auf dem AVR 2 Bytes, long 4, float ebenfalls 4. Wer Zählvariablen von 0 bis 20 als int deklariert, verschenkt pro Variable ein Byte. Klingt lächerlich – bei 50 Variablen und einem Array sind es plötzlich 200.
| Typ | Bytes (AVR) | Wertebereich | Wofür |
|---|---|---|---|
bool |
1 | true / false | Zustandsflags |
uint8_t / byte
|
1 | 0 bis 255 | Pins, Zähler, Helligkeit |
int8_t |
1 | -128 bis 127 | Temperaturen in ganzen Grad |
uint16_t |
2 | 0 bis 65535 | ADC-Werte, Millisekunden-Intervalle |
int |
2 | -32768 bis 32767 | Standard, oft zu groß gewählt |
uint32_t / unsigned long
|
4 | 0 bis 4,29 Mrd. |
millis()-Zeitstempel – hier Pflicht |
float |
4 | ca. 7 Stellen | nur wenn wirklich nötig |
Der größte Einzelgewinn liegt meist in Messwert-Arrays. Ein Ringpuffer für 60 Temperaturmessungen als float kostet 240 Bytes – über 10 % des gesamten SRAM. In Zehntelgrad als int16_t sind es 120 Bytes bei identischer Sensorgenauigkeit:
// ---------- SCHLECHT: 240 Bytes ----------
// float verlauf[60];
// ---------- GUT: 120 Bytes, gleiche Aussagekraft ----------
// Temperatur in Zehntelgrad: 23.4 C -> 234
const uint8_t N = 60;
int16_t verlauf[N];
uint8_t schreibIndex = 0;
void speichere(float tempCelsius) {
verlauf[schreibIndex] = (int16_t) round(tempCelsius * 10.0);
schreibIndex = (schreibIndex + 1) % N;
}
float leseCelsius(uint8_t i) {
return verlauf[i] / 10.0;
}
// Mittelwert ohne float-Zwischenrechnung
int16_t mittelwert() {
int32_t summe = 0;
for (uint8_t i = 0; i < N; i++) summe += verlauf[i];
return (int16_t)(summe / N);
}
Und wenn du viele Ja-Nein-Zustände hast, packe sie in Bitfelder statt in einzelne bool-Variablen:
// ---------- SCHLECHT: 8 Bytes ----------
// bool pumpeAn, lichtAn, luefterAn, alarmAktiv;
// bool wlanVerbunden, sdBereit, sensorOk, nachtModus;
// ---------- GUT: 1 Byte fuer alle acht ----------
struct Zustand {
uint8_t pumpeAn : 1;
uint8_t lichtAn : 1;
uint8_t luefterAn : 1;
uint8_t alarmAktiv : 1;
uint8_t wlanVerbunden : 1;
uint8_t sdBereit : 1;
uint8_t sensorOk : 1;
uint8_t nachtModus : 1;
};
Zustand z = {0};
void pruefeZustand() {
if (!z.sensorOk) return;
if (z.nachtModus && z.lichtAn) {
z.lichtAn = 0;
digitalWrite(LICHT_PIN, LOW);
}
}
6Bibliotheken: der unsichtbare Verbrauch
Die größten Brocken kommen oft gar nicht aus deinem Code. Der Framebuffer der Adafruit-GFX-Bibliothek für ein 128x64 OLED belegt 1024 Bytes – die Hälfte des gesamten SRAM eines Nano, bevor du eine einzige Zeile geschrieben hast.
| Bibliothek / Modul | SRAM ca. | Sparsame Alternative |
|---|---|---|
| Adafruit_SSD1306 (128x64) | 1024 B | U8x8lib (Textmodus, ca. 40 B) oder Seitenpuffer bei U8g2 |
| Adafruit_SSD1306 (128x32) | 512 B | 0,91 Zoll OLED halbiert den Puffer allein durch die Auflösung |
| SD / SdFat | 500 bis 1100 B | SdFat mit SdFat32 und kleinem Puffer |
| ArduinoJson (Dokument 512) | 512 B und mehr | CSV statt JSON auf AVR |
| LiquidCrystal_I2C (LCD1602) | ca. 30 B | bereits sehr sparsam |
| Servo (SG90) | ca. 10 B pro Servo | unkritisch |
💡 Der schnellste Gewinn im ganzen Artikel: Brauchst du auf dem OLED nur Text – Messwerte, Menüs, Statuszeilen – dann ersetze Adafruit_SSD1306 durch U8X8_SSD1306_128X64_NONAME_HW_I2C aus der U8g2-Bibliothek. Der Textmodus kommt ohne Framebuffer aus und gibt dir rund 1000 Bytes zurück. Grafiken und freie Positionierung fallen weg – für eine Wetterstation oder ein Menü stört das nicht.
// Vorher: 1024 Bytes Framebuffer
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 display(128, 64, &Wire, -1);
void zeigeAlt(float temp, float feuchte) {
display.clearDisplay();
display.setCursor(0, 0);
display.print(F("Temp: "));
display.println(temp, 1);
display.print(F("Feucht:"));
display.println(feuchte, 1);
display.display(); // schiebt 1 KB ueber I2C
}
// Nachher: ca. 40 Bytes, kein Framebuffer
#include <U8x8lib.h>
U8X8_SSD1306_128X64_NONAME_HW_I2C u8x8(U8X8_PIN_NONE);
void setupNeu() {
u8x8.begin();
u8x8.setFont(u8x8_font_chroma48medium8_r);
}
void zeigeNeu(float temp, float feuchte) {
char zeile[17];
char wert[8];
dtostrf(temp, 4, 1, wert);
snprintf(zeile, sizeof(zeile), "Temp: %s C", wert);
u8x8.drawString(0, 0, zeile);
dtostrf(feuchte, 4, 1, wert);
snprintf(zeile, sizeof(zeile), "Feuchte:%s %%", wert);
u8x8.drawString(0, 2, zeile);
}
7Praxisbeispiel: von 85 % auf 46 %
Eine typische Wetterstation auf dem Nano: DHT22, OLED, 60-Werte-Verlauf, ein paar Debug-Ausgaben. Vorher 1743 Bytes SRAM (85 %). Nach den Schritten oben:
| Maßnahme | Ersparnis | Aufwand |
|---|---|---|
Alle Serial-Texte in F()
|
minus 310 B | 5 Minuten, Suchen & Ersetzen |
| SSD1306 auf U8x8 Textmodus | minus 984 B | 20 Minuten, Ausgaben umschreiben |
Verlauf float auf int16_t
|
minus 120 B | 10 Minuten |
String-Verkettung auf Serial.print
|
minus 64 B, kein Leck mehr | 10 Minuten |
| Menütexte nach PROGMEM | minus 68 B | 15 Minuten |
8 mal bool auf Bitfeld |
minus 7 B | 5 Minuten |
| Summe | minus 1553 B, also 46 % | gut eine Stunde |
Der Sketch kann jetzt eine SD-Karte dazubekommen, ohne dass irgendetwas kippt.
Troubleshooting
⚠️ Das Board startet in Endlosschleife neu: Klassischer Stack-Overflow. Der Stack wächst in den Heap hinein, überschreibt Variablen und der Watchdog greift ein. Prüfe große lokale Arrays in Funktionen – char puffer[256] in einer Funktion ist auf 2 KB SRAM tödlich. Mach ihn static oder global.
⚠️ Das Display zeigt Buchstabensalat, aber nur manchmal: Der Framebuffer wird von einem überlaufenden Stack überschrieben. Miss den freien Speicher direkt vor dem display()-Aufruf – liegt er unter 150 Bytes, ist das die Ursache.
⚠️ freierSpeicher() sinkt in jedem Durchlauf um ein paar Bytes: Heap-Leck durch String. Suche nach += auf String-Objekten in der loop() und ersetze sie durch snprintf auf einen festen Puffer.
⚠️ F() lässt sich nicht kompilieren: Innerhalb von Serial.print() funktioniert es immer. Übergibst du den Text aber an eine eigene Funktion, muss deren Parameter const __FlashStringHelper * sein – siehe zeigeSpeicher() weiter oben.
⚠️ snprintf gibt bei %f nur ein Fragezeichen aus: Die AVR-Standardbibliothek unterstützt Fließkomma in printf aus Platzgründen nicht. Nutze dtostrf(wert, breite, nachkomma, puffer) und setze das Ergebnis mit %s ein.
⚠️ Nach dem PROGMEM-Umbau erscheinen nur wirre Zeichen: Du liest die Flash-Adresse wie eine SRAM-Adresse. Es braucht immer pgm_read_byte, pgm_read_word oder strcpy_P – ein direktes MENUE[i] greift ins Leere.
⚠️ Flash ist voll, nicht SRAM: Anderes Problem, andere Lösung. Prüfe, ob du float-Berechnungen durch Ganzzahlarithmetik ersetzen kannst – die Fließkomma-Routinen kosten allein rund 1,5 KB Flash.
Praxistipps
-
Miss zuerst, optimiere danach. Ohne
freierSpeicher()optimierst du blind und opferst womöglich Lesbarkeit an Stellen, die gar nichts kosten. -
F()von Anfang an benutzen. Es ist genauso schnell zu tippen wie ohne und erspart dir den späteren Umbau komplett. -
Ein Puffer für alle. Statt in jeder Funktion ein eigenes
char[32]anzulegen, nimm einen globalen Arbeitspuffer. Der Stack dankt es dir. - Ganzzahlen statt Fließkomma, wo es geht. Millivolt statt Volt, Zehntelgrad statt Grad, Millimeter statt Zentimeter. Spart RAM, Flash und Rechenzeit gleichzeitig.
- Wenn du zwei Stunden optimierst, um 200 Bytes zu sparen – nimm ein größeres Board. Ein ESP32-C3 Mini kostet wenig und hat den 200-fachen Arbeitsspeicher. Optimieren lohnt sich, wenn das Board feststeht – nicht als Selbstzweck.
-
Konstanten mit
constdeklarieren.const uint8_t LED_PIN = 13;landet im Flash,uint8_t LED_PIN = 13;im SRAM. Ein Wort, ein Byte gespart – und der Compiler warnt bei versehentlichen Änderungen. - Einstellungen ins EEPROM statt in RAM-Kopien. Wie das sauber geht, zeigt der Artikel Arduino EEPROM & ESP32 Preferences.
🚀 Passende Hardware
Zum Nachbauen und Ausprobieren:
- Nano V3 ATmega328 (USB-C, CH340) oder UNO R3 mit USB-C – die 2 KB, um die es geht
- 0,96 Zoll OLED SSD1306 128x64 und 0,91 Zoll OLED 128x32 – Framebuffer im Vergleich
- LCD1602 mit I2C – die RAM-sparsame Anzeige
- AM2302 (DHT22) – Messwerte für den Testaufbau
- Micro-SD-Kartenmodul – der Speicherfresser zum Nachmessen
- ESP32 DevKit USB-C, ESP32-S3 N16R8 mit 8 MB PSRAM oder RP2040-Zero – wenn Optimieren nicht mehr reicht
- Breadboard 400 Kontakte und Dupont-Jumperkabel
Fazit
Speichermangel auf dem AVR fühlt sich an wie eine harte Grenze, ist aber fast immer ein Verteilungsproblem: Texte, Tabellen und Konstanten liegen im teuren SRAM, obwohl im billigen Flash reichlich Platz frei ist. F() und PROGMEM verschieben sie dorthin, wo sie hingehören.
Merk dir die Reihenfolge: erst messen mit freierSpeicher(), dann die Bibliotheken prüfen – ein Framebuffer schlägt jede Mikrooptimierung –, dann alle Texte in F(), und zuletzt die Datentypen. Wer so vorgeht, holt in einer Stunde mehr heraus als in einem ganzen Abend planlosem Kürzen. Und wenn danach immer noch 85 % belegt sind, ist das kein Scheitern, sondern die ehrliche Antwort: Das Projekt ist größer als das Board.