Das Bild in diesem Beitrag wurde von DALL-E erstellt.

Hast du schon mal einen Arduino Mega 2560 (oder ein ähnliches Board) benutzt und irgendwann im Entwicklungsprozess festgestellt, dass die LED auf mysteriöse Weise aufhört zu blinken, dass der Text verstümmelt gedruckt wird oder dass komische Artefakte auf den Bildern erscheinen? Und das alles ohne ersichtlichen Grund oder eine Fehler- oder Warnmeldung? Wenn du wissen willst, was dahinter steckt und wie du dieses Problem lösen kannst, lies weiter.

Grundlagen

Alles beginnt mit der Beobachtung, dass auf Prozessoren, die eine modifizierte Harvard-Architektur verwenden, wie z. B. die AVR MCUs, konstante Daten keinen kostbaren Platz im Datenbereich einnehmen sollten. Zum Beispiel sollten String-Konstanten wie „Hello world“ nicht im Datenbereich, sondern im Programmbereich gespeichert werden. Wenn eine solche Konstante im Datenbereich gespeichert werden soll — wie es in C und C++ üblich ist — wird der String-Wert ohnehin im Programmbereich gespeichert und beim Start des Programms in den Datenbereich kopiert. Um die Nutzung des Datenbereichs zu minimieren, wurde das PROGMEM-Makro eingeführt, das den Compiler anweist, eine Datenstruktur im Programmbereich, d.h. im Flash-Speicher, so abzulegen, dass man mit bestimmten Hilfsmitteln darauf zugreifen kann, wie in dem folgenden Sketch gezeigt.

#include <avr/pgmspace.h>

const char PROGMEM hello[] = "Hello world";
const unsigned int PROGMEM table[3] = { 1 , 2, 3 };

void setup() {
  Serial.begin(19200);
  Serial.println((const __FlashStringHelper *)hello);
  for (int i=0; i < 3; i )
    Serial.println(pgm_read_word(&table[i]));    
}

void loop() { 

Der Typecast im ersten Serial.println-Aufruf sieht etwas hässlich aus, ist aber notwendig. Wenn du den String nur einmal verwendest, kannst du das besser lesbare F-Makro verwenden:

  Serial.println(F("Hello world"));

Wie oben gezeigt, ist es nicht nur möglich, Strings im Programmbereich zu speichern, sondern alle Arten von Daten. Der Zugriff auf solche Arrays im Flash-Speicher kann nicht mit normalen Array-Operationen erfolgen, sondern du musst spezielle Makros verwenden, die aus dem Flash lesen, z.B. pgm_read_word(unsigned int addr), das ein Wort aus der Flash-Adresse addr abruft.

Der Zugriff auf solche Daten im Flash-Speicher ist normalerweise viel langsamer als der Zugriff auf Daten im SRAM. Dafür spart man aber Platz im Datenbereich, der auf AVR MCUs in der Regel recht begrenzt ist. Aus diesem Grund ist es unter Arduinoisten üblich, alle konstanten Strings, Nachschlagetabellen, Schriftarten, Bilddaten und Ähnliches mit dem PROGMEM-Makro im Flash-Speicher zu speichern.

Was macht das PROGMEM-Makro also? Es wird einfach zu __attribute((progmem))__ expandiert, was dem Compiler mitteilt, dass die dekorierte Variable in der Linker-Sektion progmem allokiert werden soll. Am Ende, nachdem alle Quelldateien kompiliert wurden, weist der Linker allen Objekten absolute Adressen zu, auch denen in der progmem-Sektion, die dann Teil des Programmbereichs wird.

Was ist das Problem?

Solange du mit MCUs arbeitest, die höchstens 64 kB Flash-Speicher haben, funktioniert alles reibungslos. Sobald du jedoch über diese Grenze hinausgehst, kannst du nicht mehr alle Bytes im Flash mit 16-Bit-Adressen ansprechen. Um dieses Problem zu entschärfen, wird der oben erwähnte progmem-Linker-Bereich zuerst allokiert. Was passiert aber, wenn man mehr als 64 kB PROGMEM-Daten verwendet? Die Antwort ist: Es können alle möglichen verrückten Dinge passieren. Alle Daten, die über die 64 kB-Grenze hinaus zugewiesen werden, sind für die üblichen PROGMEM-Makros nicht zugänglich. Das wird in dem folgenden Sketch veranschaulicht.

#include <avr/pgmspace.h>

const byte tab0[5] PROGMEM = { 1, 2, 3};
const byte tab1[32767] PROGMEM = { };
const byte tab2[32767] PROGMEM = { };

void setup()
{
  Serial.begin(19200);
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop()
{
  Serial.println();
  Serial.println(F("PROGMEM test"));
  Serial.println(pgm_read_byte(&tab0[0]));
  Serial.println((unsigned int)&tab0, HEX);
  Serial.println((unsigned int)&tab1, HEX);
  Serial.println((unsigned int)&tab2, HEX);
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);
  digitalWrite(LED_BUILTIN, LOW);
  delay(1000);
}

Dieser Sketch wird ohne Fehler- oder Warnmeldung kompiliert. Allerdings blinkt die LED nicht, es wird nicht „PROGMEM test“ gedruckt und der Inhalt von tab0[0] wird nicht ausgegeben.

Die herkömmliche Lösung

Die herkömmliche Lösung, die auch im avr-libc Handbuch vorgeschlagen wird, ist die Verwendung von Makros mit dem Suffix _far, z. B. pgm_read_word_far(unsigned long addr), bei denen 32-Bit-Adressen angegeben werden müssen. Mit dem Makro pgm_get_far_address kann man eine solche 32-Bit-Adresse aus einer Variablenreferenz erzeugen.

Es gibt jedoch mindestens zwei Probleme mit diesem Ansatz. Erstens sagt dir niemand, wenn du die 64 kB Grenze überschreitest. Du bekommst keine Fehlermeldung, Warnung oder ähnliches. Woher sollst du also wissen, dass du auf die teurere Form des Zugriffs auf deine PROGMEM-Daten umsteigen musst? Zweitens: Selbst wenn du erkennst, dass du das Limit überschritten hast und dein Programm so angepasst hast, dass es 32-Bit PROGMEM-Adressen verwendet, gibt es immer noch das Problem, dass der Arduino-Core und viele Bibliotheken keine 32-Bit-Adressen für den Zugriff auf PROGMEM-Variablen verwenden. Außerdem haben alle Strings, die mit dem F-Makro im PROGMEM-Bereich gespeichert werden, das gleiche Problem. In unserem Beispielsketch oben können wir zwar den Inhalt von tab0[0] ausdrucken, aber der F-Makro-String wird nicht angezeigt und die LED blinkt auch nicht.

Interessanterweise ist das alles schon seit mehr als 10 Jahren bekannt. Die vorgeschlagene Lösung wurde jedoch nie implementiert, weil sie unter dem Problem leidet, dass sich nur die Tabellen im Arduino-Kern garantiert im unteren Teil des Flash-Speichers befinden; das allgemeine Problem bleibt jedoch ungelöst.

Die vollständige Lösung

Eine Lösung sollte keine Leistungseinbußen für Sketche verursachen, die auf Arduinos mit 64 kB oder weniger laufen, sie sollte bei bestehendem Code nicht zu einem Compiler- oder Laufzeitfehler führen und nur minimale Änderungen erfordern. Die Idee, den gesamten Zugriff auf den Flash-Speicher in Makros mit 32-Bit-Adressen umzuwandeln, ist also eine der Ideen, die nicht sehr attraktiv klingen. Sie würde eine Menge Änderungen implizieren, bei bestehendem Code zu Fehlern führen und zu einer schlechteren Laufzeitleistung führen.

Eine Lösung für das Problem sollte zumindest potenziell problematische Situationen erkennen. Und dann möchte man eine Möglichkeit haben, PROGMEM-Daten in den Sketch einzufügen, ohne die String-Konstanten und die PROGMEM-Daten zu beeinträchtigen, die bereits in Bibliotheken und im Arduino-Kern vorhanden sind.

Potenziell gefährliche Situationen erkennen

Ein Teil der Lösung besteht darin, Situationen zu erkennen, in denen mehr als 64 kB an PROGMEM-Daten verwendet werden. Dazu kann eine Linker-Skript-Erweiterung benutzt werden, die progmemcheck.ld heißen könnte, und die wie folgt aussieht:

INSERT AFTER .text;

ASSERT (__ctors_start < 0x10000, "PROGMEM-Kapazität überschritten" );

Man könnte dann ein Flag -T progmemcheck.ld an den Aufruf des Linkers anhängen. Dann wird bei der Ausführung des erweiterten Linker-Skripts geprüft, ob die Adresse __ctors_start unterhalb der 64kB-Grenze liegt, was bedeutet, dass auch PROGMEM unterhalb dieser Grenze liegt. Der relevante Teil des Default-Linkerskripts sieht wie folgt aus:

.text   :
  {
    *(.vectors)
    KEEP(*(.vectors))
    /* For data that needs to reside in the lower 64k of progmem.  */
    *(.progmem.gcc*)
    /* PR 13812: Placing the trampolines here gives a better chance
       that they will be in range of the code that uses them.  */
    . = ALIGN(2);
    __trampolines_start = . ;
    /* The jump trampolines for the 16-bit limited relocs will reside here.  */
    *(.trampolines)
    *(.trampolines*)
    __trampolines_end = . ;
    /* avr-libc expects these data to reside in lower 64K. */
    *libprintf_flt.a:*(.progmem.data)
    *libc.a:*(.progmem.data)
    *(.progmem.*)
    . = ALIGN(2);
    /* For code that needs to reside in the lower 128k progmem.  */
    *(.lowtext)
    *(.lowtext*)
     __ctors_start = . ;
     *(.ctors)
     __ctors_end = . ;
... }

Dieser Teil des Linker-Skripts zeigt, wie die Ouput-Linker-Sektion .text mit verschiedenen Teilen des Programmcodes gefüllt wird. Vor allem die meisten .progmem-Teile kommen nach dem Abschnitt .trampolines und vor dem Abschnitt .lowtext. Der interessante Punkt ist, dass ich keine Teile des Arduino-Codes kenne, die in den .lowtext-Abschnitt gehören. Und selbst wenn es solche Teile gibt, ist die Größe eines solchen Codeabschnitts höchstwahrscheinlich nur ein paar Kilobytes groß. Daher ist es eine gute erste Annäherung, zu prüfen, ob der __ctors_start-Wert, der das Ende der .lowtext-Sektion markiert, unter 0x10000 liegt.

Auch wenn diese Lösung nur eine kleine Änderung erfordert, kann sie zu Problemen mit bestehendem Code führen. Es könnte Arduino-Code in freier Wildbahn geben, der mit mehr als 64 kB PROGMEM-Daten einwandfrei funktioniert. Anstatt eine Kompilierung mit einer Fehlermeldung abzubrechen, wäre eine Warnung vielleicht angemessener. Aus diesem Grund habe ich ein Shell-Skript progmemcheck.sh geschrieben:

#!/bin/bash
progmem_end=`$1 -t d $2 | grep __ctors_start | cut -d ' ' -f 1`
if [ $progmem_end -gt 65535 ]; then
    warning="| Severe Warning: PROGMEM section too large by $(expr $progmem_end - 65535) bytes.            "
    echo "_______________________________________________________________"
    echo "${warning:0:62}|"
    echo "| Your program will most probably be unstable!                |"
    echo "| Use the macro PROGMEM_FAR from <progmem_far.h> and          |"
    echo "| pgm_get_far_address/pgm_read_xxxx_far from <avr/pgmspace.h>.|"
    echo "|_____________________________________________________________|"
fi

Der erste Parameter des Skripts sollte das GNU avr-nm Tool sein und der zweite die ELF-Datei. Wenn dieses Skript als Teil des objcopy-Rezepts so aufgerufen wird,

## Check for progmem overflow
recipe.objcopy.progmem.pattern="/bin/bash" "{runtime.platform.path}/extras/progmemcheck.sh" "{compiler.path}/{compiler.symbol.cmd}" "{build.path}/{build.project_name}.elf"

dann könnte es dir eine Warnung wie die folgende geben:

Natürlich muss auch noch ein Windows-Batch-Skript geschrieben werden. Mit ein wenig Hilfe des Copiloten von VS Code ist mir das gelungen.

Der Nachteil des Warnansatzes ist, dass die Arduino IDE standardmäßig keine Kompilierungsmeldungen anzeigt. Du musst dies im Einstellungsdialog explizit einschalten. Wenn man jedoch Probleme wie die eingangs erwähnten hat, wird man wahrscheinlich Meldungen (und auch Compiler-Warnungen) zulassen, um zu erkennen, ob etwas nicht stimmt.

PROGMEM an das andere Ende schieben

Nachdem man eine Warnung wie oben erhalten hat, muss man etwas unternehmen. Die Frage ist, was man tun kann. Die Warnmeldung enthält bereits Hinweise. Du solltest das Makro PROGMEM_FAR anstelle des normalen PROGMEM-Makros verwenden. Falls die Arduino-Codebasis eine aktuelle AVR-GCC-Toolchain verwenden würde, wäre dieses Makro bereits Teil der avr-libc. Bis dahin stellt die kleine Bibliothek progmem_far.h das fehlende PROGMEM_FAR-Makro bereit. Diese Bibliothek kann einfach mit dem Arduino Bibliotheksmanager installiert werden. Der Code sieht wie folgt aus.


#define PROGMEM_FAR __attribute__((section (".fini0")))

void __terminate_program__(void) __attribute__((naked, section(".fini1")), used, weak));
void __terminate_program__(void)
{
  while(1);
}

Das Makro PROGMEM_FAR teilt dem Compiler mit, dass er den Abschnitt .fini0 zum Speichern der Daten verwenden soll. Woher kommt dieser Abschnitt? Hier ist ein weiterer Blick in den .text-Abschnitt aus dem Linker-Skript:

 
.text   :
  { ...
    *(.text)
    . = ALIGN(2);
     *(.text.*)
    . = ALIGN(2);
    *(.fini9)  /* _exit() starts here.  */
    KEEP (*(.fini9))
    *(.fini8)
    KEEP (*(.fini8))
    *(.fini7)
    KEEP (*(.fini7))
    *(.fini6)  /* C++ destructors.  */
    KEEP (*(.fini6))
    *(.fini5)
    KEEP (*(.fini5))
    *(.fini4)
    KEEP (*(.fini4))
    *(.fini3)
    KEEP (*(.fini3))
    *(.fini2)
    KEEP (*(.fini2))
    *(.fini1)
    KEEP (*(.fini1))
    *(.fini0)  /* Infinite loop after program termination.  */
    KEEP (*(.fini0))
     _etext = . ;
  } 

Nach den Input-Sektionen .text von jeder Übersetzungseinheit gibt es also 10 Abschnitte namens .fini9 bis .fini0, die sich mit der Beendigung des Programms befassen. .fini0 ist der letzte Abschnitt. Diesen missbrauchen wir dazu, den PROGMEM-Code zu platzieren, der an das andere Ende des Speichers geschoben werden darf. Um dennoch eine ordnungsgemäße Beendigung des Programms zu garantieren, fügen wir eine Endlosschleife in der benutzerdefinierbaren Sektion .fini1 ein. Und damit funktioniert alles perfekt. Das folgende Programm ist die „reparierte“ Version unseres obigen Beispiels. Es zeigt, dass die Dinge jetzt einwandfrei funktionieren.

#include <avr/pgmspace.h>
#include <progmem_far.h>

const byte tab0[5] PROGMEM = { 1, 2, 3};
const byte tab1[32767] PROGMEM_FAR = { };
const byte tab2[32767] PROGMEM = { };

void setup()
{
  Serial.begin(19200);
  pinMode(LED_BUILTIN, OUTPUT);
}

void Schleife()
{
  Serial.println();
  Serial.println(F("PROGMEM test"));
  Serial.println(pgm_read_byte(&tab0[0]));
  Serial.println(pgm_get_far_address(tab0), HEX);
  Serial.println(pgm_get_far_address(tab1), HEX);
  Serial.println(pgm_get_far_address(tab2), HEX);
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);
  digitalWrite(LED_BUILTIN, LOW);
  delay(1000);
}

Dieser Sketch unterscheidet sich von dem ursprünglichen Sketch dadurch, dass wir das PROGMEM_FAR-Makro auf tab1 anwenden und jetzt das pgm_get_far_address-Makro benutzen, um die 32-Bit Adressen unserer PROGMEM-Datenstrukturen zu bestimmen. Wenn wir den Sketch jetzt kompilieren und hochladen, erhalten wir keine Warnmeldung, die LED blinkt, die Meldung wird gedruckt und der Inhalt von tab0[0] wird ebenfalls korrekt gedruckt. Was will man mehr?

Ausblick

Ich werde einen Pull-Request für den MightyCore und MegaCore von MCUdude sowie für den Arduino AVR-Kern vorschlagen. Dieser PR wird die Überprüfung der PROGMEM-Größe wie oben beschrieben implementieren. Der Benutzer muss sich dann um den Rest kümmern.

UPDATE (1. Okt. 2025): Meine Änderungen wurden in MightyCore and MegaCore übernommen und werden dann bei der Erzeugung der nächsten Version ausgespielt.

Views: 20