Das Bild in diesem Blogpost stammt von rawpixel.com auf Freepik.

AVR MCUs scheinen manchmal neu zu starten, ohne dass du den RESET-Knopf gedrückt hast. Ist das ein Zeichen von Unverwüstlichkeit oder von drohender Gefahr? Und wie findet man die Ursache?

Wenn deine AVR MCU plötzlich neu zu starten scheint, kann das viele Gründe haben. Und es ist eine gute Idee, die Ursache herauszufinden, weil das Verhalten der MCU nach einem scheinbaren Neustart fehlerhaft sein und die gleiche Ursache zu einem Absturz der MCU führen kann.

Gründe für Reboots

Es gibt echte und scheinbare Reboots. Wenn ich scheinbare Reboots schreibe, meine ich das Verhalten, nachdem die AVR MCU ohne Hardware-Reset an Adresse 0x0000 gestartet wird. Das ist etwas anderes als ein echter Reboot, bei dem die MCU-Register auf ihre ursprünglichen Standardwerte zurückgesetzt werden. Und das macht den scheinbaren Neustart zu einem sehr gefährlichen Imitat eines echten Neustarts.

Was kann einen echten Neustart verursachen? Ein Hauptgrund ist, dass der RESET-Pin für eine gewisse Zeit mit GND verbunden ist, z. B. wenn die RESET-Taste gedrückt wird. Außerdem führt das Anlegen von Strom an die MCU zu einem Power-On-Reset. Ebenso kann der Watchdog-Timer, wenn er aktiviert ist, einen Reset auslösen. Und schließlich wird ein Reset ausgelöst, wenn die Versorgungsspannung unter einen bestimmten Schwellenwert für einen Brownout fällt. In all diesen Fällen werden alle Register auf ihre Standardwerte zurückgesetzt und die Programmausführung beginnt bei 0x0000.

Um herauszufinden, ob ein echter Neustart stattgefunden hat, kannst du das MCUSR-Register untersuchen, in dem der Grund für einen Reset gespeichert ist.

Aus dem ATmega328 megaAVR®-Datenblatt

WDRF wird nach einem Watchdog-Reset gesetzt, BORF nach einem Brown-out-Reset, EXTRF nach einem externen Reset und PORF nach einem Power-on-Reset. Wenn du deine MCU mit einem Bootloader betreibst, wirst du den Inhalt dieses Registers nie sehen, da es vom Bootloader gelöscht wird. Wenn du jedoch keinen Bootloader verwendest, kannst du den Grund für den Reset wie folgt herausfinden.

#include  <avr/wdt.h> // dies ist optional!

byte mcusr_mirror __attribute__ ((section (".noinit")));

void mcusr_init(void) __attribute__((naked)) __attribute__((section(".init3"))) __attribute__((used));
void mcusr_init(void)
{
  mcusr_mirror = MCUSR;
  MCUSR = 0;
  wdt_disable(); // dies ist optional!
  zurück;
} 

Die Routine mcusr_init wird aufgerufen, bevor alles andere passiert, und setzt die Variable mcusr_mirror, die du später in deinem Programm überprüfen kannst. Wenn sie Null ist, weißt du, dass ein scheinbarer Neustart stattgefunden hat.

Die Einbindung der Header-Datei avr/wdt.h und der Aufruf von wdt_disable() sind optional. Du musst diese Funktion nur aufrufen, wenn du den Watchdog-Timer in deinem Sketch verwendest, denn nach einem Neustart wird der Watchdog-Timer immer mit dem kürzest möglichen Watchdog-Intervall aktiviert.

Wenn deine MCU also eine Neustart-Sequenz ausführt, obwohl die vier oben genannten Gründe ausgeschlossen werden können, was könnte die Ursache für einen Neustart sein?

Bad Interrupts

Ein Grund für scheinbare Neustarts sind bad Interrupts, d. h. Interrupts, für die keine Interrupt Service Routine (ISR) registriert wurde. Die Standardadresse für Interrupts ist einfach 0x0000. Wenn also ein Interrupt aktiviert ist, aber die Interrupt-Routine dafür nicht registriert ist, setzt die MCU ihre Ausführung an der Adresse 0x0000 fort.

Normalerweise würde man davon ausgehen, dass dies nie passieren wird, da ein vernünftiger Programmierer immer eine ISR bereitstellt, bevor er den entsprechenden Interrupt aktiviert. Wenn du jedoch den Namen eines Interrupt-Vektors falsch eingibst, wird eine ISR möglicherweise nicht registriert. Das führt nur zu Compiler-Warnungen, die in der Arduino IDE standardmäßig deaktiviert sind. Eine Möglichkeit, um herauszufinden, ob ein fehlerhafter Interrupt der Grund sein könnte, ist also, die Warnungen im Einstellungsdialog der IDE einzuschalten oder die Compiler-Option -Wall zu verwenden. Wenn du dann eine Meldung wie die folgende siehst

/Users/.../file.ino:55:5: warning: 'TIMER_COMPA_vect' appears to be a misspelled 'signal' handler, missing '__vector' prefix [-Wmisspelled-isr],

dann weißt du, dass du versuchen solltest, die richtige Schreibweise für diese ISR herauszufinden.

Natürlich könnte ein Programmierer statt eines falsch geschriebenen ISR-Namens auch den falschen Interrupt aktiviert haben. Wenn du so etwas vermutest, kannst du alle falschen Interrupts abfangen, indem du eine catch-all ISR registrierst.

#include <avr/interrupt.h>

ISR(BADISR_vect)
{
        // eigner Code
}

Fehlerhafter indirekter Funktionsaufruf

Es ist möglich, eine Funktion auf indirekte Weise aufzurufen, indem man einem Zeiger folgt. Dies wird im folgenden Beispiel veranschaulicht.

typedef void (*func_t)(void);

void sub(){
    Serial.println("Auf Wiedersehen");
}

void setup(void)
{
    Serial.begin(19200);
    Serial.println("Hallo Welt");
    func_t f_sub = ⊂
    f_sub();
}

void loop(void) { }

Wenn du nun f_sub einen falschen Wert zuweist, z.B., indem du die Variable nicht initialisierst, kann die MCU an eine beliebige Stelle im Flash-Speicher springen. Der gesamte Flash-Speicher nach der höchsten beschreibbaren Flash-Speicherzelle ist mit 0xFF belegt, was als NOP interpretiert wird. Das bedeutet, dass die MCU, nachdem sie zu einer solchen Stelle gesprungen ist, den gesamten Adressraum durchläuft, bis der Programmzähler überläuft und wieder bei 0x0000 beginnt. Voilà! Es kann aber auch sein, dass die MCU an eine beliebige Speicherzelle springt und sich völlig seltsam verhält oder gar nichts tut.

Allerdings ist es sehr unwahrscheinlich, dass du einen indirekten Funktionsaufruf verwendet hast. Wahrscheinlich ist es das erste Mal, dass du überhaupt von indirekten Funktionsaufrufen hörst. Und ich würde nicht empfehlen, sie in eingebetteten Systemen zu verwenden. Ein Sprung an eine beliebige Adresse im Flash-Adressraum kann aber auch andere Ursachen haben.

Stapelüberlauf

Der wahrscheinlichste Grund für einen scheinbaren Neustart ist ein Stapelüberlauf oder Stack-Überlauf. Der Stack ist ein Datenbereich, der zum Speichern von lokalen Variablen und Rücksprungadressen verwendet wird. Bei AVRs beginnt er am oberen Ende des Datenbereichs und wächst nach unten zum Anfang des Datenbereichs.

Wenn du eine Funktion aufrufst oder wenn eine Interrupt-Service-Routine aktiviert wird, wird die Rücksprungadresse auf den Stack gelegt. Zu Beginn jeder Funktion werden die Register gespeichert und ein Bereich für lokale Variablen zugewiesen. Dies alles wird bei der Rückkehr aus der Funktion wieder freigegeben. Als letzte Aktion in jeder Funktion wird die Anweisung RET ausgeführt, die die Rücksprungadresse vom Stack holt und in den Programmzähler lädt. Und das ist genau der Moment, in dem unsere MCU auf den Holzweg geraten kann. Wenn der Stack zu groß geworden ist, kann es sein, dass die Rücksprungadresse überschrieben oder gar nicht gespeichert wurde, und wir haben die gleiche Situation wie bei einem fehlerhaften indirekten Funktionsaufruf.

Wie findet man heraus, ob so etwas passiert ist? Das ist wirklich schwierig. Du könntest eine Funktion freeRam definieren, die misst, wie viel des Datenbereichs noch frei ist und die in jeder Funktion aufgerufen wird, wie in dem folgenden Sketch, der auch das Phänomen des Stack-Überlaufs demonstriert.

void setup(void)
{
  Serial.begin(19200);
  fun();
}

void loop(void) { }

void fun(void)
{
  Serial.println(freeRam());
  delay(50);
  fun();
  Serial.println(F("Returning"));
}

int freeRam(void)
{
  extern unsigned int __heap_start;
  extern void *__brkval;
  int free_memory;
  int stack_here;

  if (__brkval == 0) free_memory = (int) &stack_here - (int) &__heap_start;
  sonst free_memory = (int) &stack_here - (int) __brkval; 
  return free_memory;
}

Die Funktion fun, die in der Setup-Routine aufgerufen wird, tut nichts weiter, als den verbleibenden Stapelspeicherplatz auszugeben, und ruft sich dann selbst erneut auf. Nach einer Weile wird der Stack überlaufen und dann stürzt die MCU ab, d.h. sie reagiert nicht mehr, oder sie startet neu. Du könntest also freeRam verwenden, um den verbleibenden freien Speicherplatz zu überwachen und zu melden, wenn nicht mehr genug übrig ist.

Der beste Weg, um einen Stapelüberlauf zu vermeiden, ist, alle großen Datenstrukturen global zu deklarieren. Und mit groß meine ich alles, was größer als, sagen wir, 20 Byte ist. Dann werden die Variablen, die viel Speicherplatz benötigen, statisch zugewiesen, und am Ende der Kompilierung erhältst du einen Bericht darüber, wie viel Platz verbraucht wurde. Wenn ein paar hundert Bytes freier Speicherplatz vorhanden sind und du dir sicher bist, dass die Funktionsaufrufe nicht zu viel Speicherplatz verbrauchen, bist du auf der sicheren Seite. Beachte, dass auch der Aufruf von Systemfunktionen Speicherplatz auf dem Stack verbraucht. Und rekursive Funktionen wie im obigen Beispiel sind in einem eingebetteten Programmierkontext natürlich tabu!

Heap-Überlauf?

Die obige Beschreibung ist eigentlich nur eine Seite der Medaille, da die Stapelgrenze nicht statisch ist. Es gibt einen weiteren Datenbereich, den Heap, der von unten nach oben wächst (bei AVRs). Der Heap enthält alle dynamisch zugewiesenen Datenstrukturen. Das sind Strukturen, die du explizit mit den Funktionen new und malloc zuweist oder die unter der Haube dynamisch zugewiesen werden, wie es z. B. bei der Arduino-Klasse String geschieht.

Wenn der Heap zu groß wird, funktioniert die Zuweisung von Speicher irgendwann nicht mehr, d.h. statt einer Adresse für ein frisch zugewiesenes Stück Speicher erhältst du NULL als Ergebnis des Aufrufs der Funktion new oder malloc. Außerdem ergibt sich das Problem eines drohenden Stack-Überlaufs, weil der gesamte Platz für den Stack vom Heap aufgefressen wird. Während der Heap also nicht von sich aus den Stack überschreibt, können nachfolgende Funktionsaufrufe dennoch viel früher zu einem Stack-Überlauf führen, weil der freie Speicherplatz vom Heap aufgezehrt wird.

Mein Rat ist, die dynamische Speicherzuweisung in einem eingebetteten Programmierkontext auf jeden Fall zu vermeiden. Es ist einfach zu riskant und man hat keine Kontrolle darüber, wie viel Speicher zugewiesen wird. Da in der Regel nur eine geringe Menge an Datenspeicher zur Verfügung steht, z. B. 256 Bytes bis hin zu einigen Kilobytes, muss man die Verwendung dieses Speichers sorgfältig planen und kann sich nicht darauf verlassen, dass genügend Speicher vorhanden ist, wie es bei einem Desktop-Computer der Fall ist.

Pufferüberlauf

Während Stack-Überläufe auftreten, weil nicht genug Speicher vorhanden ist, sind Pufferüberläufe explizite Programmfehler, die durch das Überschreiten der Grenzen eines Arrays verursacht werden, wie im nächsten Beispiel.

void setup(void)
{
  Serial.begin(19200);
  Serial.println("Hallo Welt");
  delay(100);
  fun();
  Serial.println("Auf Wiedersehen");
}

void loop(void) { }

void fun(void)
{
  char buf[10];

  for (byte i=0; i < 19; i ) buf[i] = '\0';
  Serial.print(buf);
}

Hier schreiben wir in die Zelle buf[18], obwohl das Array nur 10 Array-Zellen hat. Das bedeutet, dass andere Daten auf dem Stack überschrieben werden. Insbesondere wird die Rücksprungadresse üerbschrieben, was dazu führt, dass die MCU zu 0x0000 springt und wir das „Auf Wiedersehen“ nie sehen! Übrigens: Wenn man mit der oberen Grenze in der Iteration herumspielt, passieren andere merkwürdige Dinge. Ein solcher Pufferüberlauf kann natürlich auch mit globalen Variablen passieren.

Die Moral von der Geschichte ist, dass man nicht über die Grenze eines Arrays hinaus schreiben sollte. Gehe also nie davon aus, dass eine Eingabe, die von außen kommt, immer die von dir angenommene Puffergröße einhält. Prüfe immer explizit, dass die Grenzen nicht verletzt werden. Es ist besser, eine Fehlermeldung auszugeben, wenn die Grenzen verletzt werden, als nach einer obskuren Pufferüberschreitung suchen zu müssen.

Oder ist es die Hardware?

Stapelüberläufe und Pufferüberläufe sind schwer genug zu finden. Bevor man also versucht, einen solchen Fehler aufzuspüren, ist es eine gute Idee, zunächst zu prüfen, ob die Hardware der Grund für die Neustarts ist. Vor allem fehlende Abblockkondensatoren von 100 nF in der Nähe der MCU können zu instabilem Verhalten führen. Ebenso sollte man sich vergewissern, dass die Versorgungsspannung hoch genug und stabil ist.

Zusammenfassung

Spontane scheinbare Reboots sowie gelegentliche Abstürze einer MCU deuten auf ernsthafte Probleme hin. Die wahrscheinlichsten Ursachen auf Seiten der Software sind:

  • Bad Interrupts, die in der Regel leicht zu diagnostizieren sind, weil der Compiler eine Warnung ausgibt, wenn ein ISR-Name falsch geschrieben ist;
  • fehlerhafte indirekte Funktionsaufrufe, die glücklicherweise nicht sehr häufig vorkommen, weil sie in der Embedded-Programmierung (aus guten Gründen) nicht oft verwendet werden;
  • Stapelüberläufe, die schwer zu diagnostizieren sind; vermeide sie, indem du alle großen Datenstrukturen global allozierst, keine dynamische Allokation von Datenstrukturen verwendest und sicherstellst, dass auf dem Stapel noch genügend Platz vorhanden ist;
  • Pufferüberläufe, d.h. die Zuweisung von Werten an Array-Zellen, die über die Grenzen des Arrays hinausgehen.

Wenn es also zu Neustarts oder Abstürzen kommt, solltest du zuerst nach diesen Ursachen suchen (nachdem du sichergestellt hast, dass die Hardware funktioniert).

Views: 0