Geschwindigkeit und Tücken gängiger MCUs

Grundsätzlich lässt sich sagen, dass die MCUs auf den Boards für Maker und Industrieanwendungen, wie Arduino, Mbed oder Pi, eine recht anständige Performance bringen, vergleichbar mit PC-Rechnern Anfang der 90er Jahre. Allerdings ist der Stromverbrauch bei diesen MCUs deutlich geringer und sie sind schon für wenige Euro erhältlich.

Dieser Artikel schaut aber auch hinter die Kulissen, da die CPU-Geschwindigkeit nur ein Aspekt ist. Denn oft gibt es auch tückische Probleme bei der Interrupt-Verarbeitung oder den begrenzten Möglichkeiten beim Stromsparen sowie große Unterschiede in der Speichergeschwindigkeit (Flash).

Wir besprechen hier ausschließlich 32-bit MCUs, da die 8-bit Atmel AVRs zu schwach sind und über zu wenig Speicher verfügen, um hier sinnvoll verglichen werden zu können.

Getestete MCUs

Es wurden gängige MCUs getestet, wobei oft mehrere MCUs einer MCU-Familie existieren, die sich durch Speichergröße oder Anschlusspins unterscheiden. Ansonsten sind diese relativ zur Taktfrequenz identisch schnell.

Da ich schon mehrere Boards mit ESP32-, D21- sowie verschiedenen STM32L4-MCUs mit LoRa-Funktechnik selbst entwickelt und produziert habe, ist da einiges an Know-How vorhanden. Selbstverständlich habe ich unsere eigenen LoRa-Boards (LongRa, Turtle, ECO Power und Eagle) ebenfalls mit identischen Ergebnissen getestet. Mehr dazu unter www.radioshuttle.de.

Millionen von Instruktionen pro Sekunde

Zum Testen werden hier einfach verschiedene C/C++ Operationen verwendet um zu sehen, wie schnell diese durchlaufen werden. Als Beispiel wird die Addition als nur eine Instruktion gerechnet. In Wirklichkeit sind dies mehrere Einzelschritte, da erst einmal ein Wert geladen wird und danach ein zweiter. Diese beiden Werte werden addiert und das Ergebnis gespeichert.

Im Test spreche ich von Mega-Instruktionen pro Sekunde, wobei die Instruktionen in mehreren Schritten hintereinander durchgeführt werden:

  • Schritt 1: Addition (addieren von zwei Werten)
  • Schritt 2: Multiplikation (das Ergebnis von Schritt 1 wird multipliziert)
  • Schritt 3: Subtraktion (das Ergebnis von Schritt 2 wird abgezogen)
  • Schritt 4: Division (das Ergebnis von Schritt 3 wird geteilt)

Das sind in der Summe 4 Schritte, die in einem Speicherarray mit 1.000 Elementen (z. B. 1.000 Integers für den Int-Test) durchgeführt werden. Dies wird in einer Schleife 2.000 mal durchlaufen, also: 4 Schritte mal 1.000 Elemente mal 2.000 Durchläufe entspricht 8 Millionen Instruktionen, welche ich der Einfachheit halber „Mega“ nenne. In der folgenden Tabelle sind sämtliche Ergebnisse mit Megas/s und der Gesamtlaufzeit aufgeführt:

Megas/s (Int)

Megas/S (Float)

Das sind schon ganz beachtliche Ergebnisse; die Float- und Double-Geschwindigkeit beim Arduino Zero (Atmel D21 MCU) ist extrem langsam, da dieser keine FPU (Floating Point Unit) besitzt, welche Floats direkt rechnen kann und daher die Fließkommazahl in einzelnen Schritten per C-Runtime ermitteln muss. Das gleiche gilt für die Doubles und 64-bit-Integers, welche nur der Pi 3 nativ rechnen kann.

Die Anzahl der Speicherelemente wurde mit Absicht auf 1.000 begrenzt (also 1.000 mal 4 Bytes für einen Integer macht 4.000 Bytes), damit sie auch leicht in den Hauptspeicher hinein passen.

Der Raspberry Pi 3 wurde nur als Vergleich mit aufgenommen, wird aber hier nicht weiter behandelt und im Diagramm nicht dargestellt, da dieser eigentlich als Mini-Linux-Server zu einer anderen Kategorie Rechner gehört.

Das Benchmark-Programm

Der vollständige Code des Beispielprogramms „CPUBench“ für Arduino und Mbed OS ist in unserer RadioShuttle-Software enthalten. Ursprünglich hatte ich dieses Testprogramm beruflich entwickelt, um die Geschwindigkeit verschiedener Server meiner Software zu vergleichen.

Für die RadioShuttle-Boards habe ich den Benchmark-Test noch einmal komplett mit C++ Templates modernisiert, sodass verschiedene Datentypen wie Integer, Floats, 64-bit Integer usw. automatisch gemessen werden können. Da ein Durchlauf recht lange dauern kann, habe ich regelmäßig einen „.“ ausgegeben, der vorhandene Aktivität anzeigt.

Benchmark-Funktion:
template <class T>
int MegaOperations(T, float *millions, int *m_secs, CPUProgressFunc m_progresscb, int cpuId)

Aufgerufen wird sie über (in diesem Beispiel für Integer):
MegaOperations((int)1, &f, &m_secs, &BenchProgress, 0);

Zum vollständigen Programmcode „CPUBench“

Sowohl der ESP32 als auch der Raspberry Pi 3 haben mehrere CPU-Einheiten, die identische Geschwindigkeiten liefern (was ich auch getestet habe). Der ESP32 ist also doppelt so schnell, wenn die CPU-Einheiten parallel aufgerufen werden. Zum besseren Vergleich wurden die Ergebnisse mit nur einer CPU dargestellt.

Besonderheiten des RAM-Speichers

Der SRAM-Speicher ist eigentlich bei allen MCUs ähnlich aufgebaut und läuft in der Regel mit der CPU-Frequenz. Allerdings gibt es beim ESP32 wichtige Unterschiede bzw. Begrenzungen.

Hinweise zum ESP32-RAM

Der ESP32 hat eine Busfrequenz von 80 MHz, mit welcher der Speicherzugriff vermutlich läuft. Zwar läuft die CPU mit 240 MHz, Speicherzugriffe werden jedoch mit der Busfrequenz synchronisiert. Ob der ESP32 Xtensa-LX6 Prozessor noch weitere Bytes an Cache-Speicher hat, konnte ich bisher nicht ermitteln. Die Xtensa-LX6-Spezifikation hat sehr viele Optionen, was der ESP32 an CPU-Features alles mitbringt, ist aber lieder nicht dokumentiert.
Der ESP32 verfügt zusätzlich noch über einen 2 x 8 kB RTC-Memory – einen Slow sowie einen Fast RTC-Memory-Speicherbereich. Dort lassen sich Daten speichern, welche im „deepsleep“ erhalten bleiben.

Probleme mit der ESP32 PSRAM-Option


Die ESP32 PSRAM-Option, welche beim ESP32-WROVER Modul verbaut ist, bringt weitere 4 MB RAM-Speicher. Beim PSRAM gilt zu beachten, dass dieser per SPI seriell angebunden und daher um Faktoren langsamer ist als der in der MCU integrierte RAM-Speicher. Ein weiteres Problem ist, dass Interrupt-Routinen nicht auf diesen Speicher zugreifen dürfen, da in einem Interrupt keine SPI-Transaktionen ausgeführt werden dürfen. Hinzu kommt noch, dass der PSRAM-Speicher in das interne RAM vom ESP32 geladen wird (Virtual Memory Paging), was das Ganze wieder komplizierter und langsamer macht. Für eine normale Anwendung, z. B. in der Arduino-Funktion loop(), merkt man davon überhaupt nichts, sie läuft eben nur erheblich langsamer. Bei Interrupt-Funktion wie Timer und GPIO-Interrupts ist das natürlich die Pest.

Besonderheiten des Flash-Speichers

Der Flash-Speicher der MCUs ist schon genial, da dieser viel schneller als reguläre Festplatten ist und die Kapazität oft auch die der damaligen Diskettenlaufwerke übersteigt. Aber auch hier gibt es Tücken. Intern ist so ein Flash-Speicher in Blöcke aufgeteilt (ähnlich wie Sektoren bei Festplatten/Disketten). Um ein Byte im Flash-Speicher zu ändern, muss der ganze Sektor gelesen, dann gelöscht und neu beschrieben werden. Dazu kommt, dass der Flash-Speicher pro Sektor eine maximale Anzahl von Löschzyklen hat; in der Regel liegt der Wert zwischen 1.000 und 100.000 Löschzyklen pro Sektor.

Es ist also kein Problem, Programme in das Flash zu schreiben, denn so häufig wird die MCU nicht neu programmiert. Werden allerdings Variablen in den Flash-Speicher geschrieben, kann so eine MCU einen Sektor innerhalb von Minuten so oft beschreiben, dass dieser defekt ist. Das führt dann zu Problemen …

Um dieses Problem zu umgehen, habe ich eine NVProperty-Library für diese MCUs entwickelt, die Werte im Flash („Properties“) optimiert rotierend speichert und somit bis zu 256 unterschiedliche Properties über viele Jahre hinweg alle 10 Sekunden neu schreiben kann. Verweise zur NVProperty-Library befinden sich am Ende des Artikels.

Probleme mit dem ESP32 Flash-Speicher

Der ESP32 Flash-Speicher ist nicht in der MCU integriert sondern per SPI seriell angebunden. Identisch zum PSRAM gibt es hier die Begrenzungen in Interrupt-Routinen (Timer, GPIO, usw.). Diese Funktionen müssen über das Compiler-Attribut „IRAM_ATTR“ deklariert werden, damit sie im RAM abgelegt werden können. Allerdings muss auch jede Funktion, die in einer Interrupt-Routine aufgerufen wird, ebenfalls im RAM liegen. Somit liegt eine einfache strcpy()-Funktion im Flash und beim Aufruf gibt es einen Crash, es sei denn, genau diese Flash-Page wurde per Zufall vorher ins RAM kopiert. Das ist richtig grausam, da hier unwissentlich viele Fehler im Interrupt-Code schlummern.
Ich selber hatte mal einen 64-bit-Timerwert in einer Timer-Interruptfunktion bearbeiten müssen, und da die MCU nur 32-bit-Werte teilen kann, wurde intern ein Compiler-Helper aufgerufen, der die 64-bit-Division vornimmt. Dieser lag natürlich im Flash-Speicher und da war der zufällige Crash. Ich habe damals Tage gebraucht, um dieses Problem zu erkennen.

OTP-Speicher

Der OTP-Speicher (One Time Programmable) ist dann interessant, wenn man einmalige Informationen wie Kalibrierungswerte, Seriennummer, Hardware-Revision, MAC-Adressen, Public Keys usw. speichern möchte. Der OTP-Speicher kann pro Wert nur einmal geschrieben werden, überlebt dafür aber eine neue Programmierung („Flash Erase“) und bietet somit eine hervorragende Möglichkeit, um Werte permanent zu speichern.

Meine NVProperty-Library unterstützt auch den OTP-Speicher. So wird ein Wert erst im Flash-Speicher gesucht und falls er dort nicht vorhanden ist, wird im OTP nachgeschaut. Die Library kann den OTP auch mehrfach beschreiben, wobei die hinteren Daten die gültigen Daten sind.

Strom sparen per „deepsleep“

Beim „deepslepp“ für den Batteriebetrieb gibt es richtig große Unterschiede. Den Prozessor anzuhalten und abzuwarten bis der nächste Timer kommt oder eine GPIO-Pegeländerung stattfindet, ist mit Problemen behaftet. Eigentlich funktioniert das nur richtig gut beim STM32L4, weshalb dieser in unserem LoRa Turtle-Board zum Einsatz kommt und auch über 10 Jahre im Batteriebetrieb läuft. Die Unterschiede dabei sind, ob überhaupt ein Timer weiterläuft, wie lange die CPU braucht um aufzuwachen und ob die MCU an der letzten Stelle einfach weitermacht oder komplett neu starten muss. Hier eine Tabelle zu den Unterschieden:

Man sieht in der Tabelle, dass der ESP32 fast eine halbe Sekunde braucht, um nach einem „deepsleep“ zu reagieren. Zusätzlich ist das ganze RAM gelöscht (nur das RTC-Memory bleibt erhalten), da helfen die 7µA beim ESP32 auch nicht viel, wenn alles weg ist und ein Interrupt siebzigtausend mal länger dauert als beim STM. Beim D21 ist das besser, da hier nichts verloren geht. Allerdings braucht dieser leider 150 mal mehr Strom im „deepsleep“ als der STM, und die Erkennung der GPIO Rise/Fall funktioniert nicht bzw. nur auf „Change“.

Hier spielt die STM32L4-Serie in einer eigenen Liga.

Interrupt-Funktionen Timer, GPIO usw.

Interrupts, Timer und GPIOs funktionieren eigentlich bei allen beschriebenen MCUs recht gut. Nur beim „deepsleep“ gibt es wesentliche Unterschiede, die bereits im obigen Abschnitt Strom sparen per „deepsleep“ erläutert worden sind . Solange kein Batteriebetrieb benötigt wird, spielt das keine Rolle und alles funktioniert bei allen gleich gut.

Die ESP32 Interrupt-Routinen machen Probleme, wenn Funktionen nicht mit dem Attribut „IRAM_ATTR“ versehen sind. Das wurde bereits oben in den Abschnitten zum Flash- bzw. RAM-Speicher schon genauer beschrieben.

Überzeugend sind die 64-bit-Timer im ESP32, die aufgrund der Größe einfach nicht überlaufen können. Da sieht man, dass im ESP32 einfach vieles komplett neu gemacht wurde.

Bei den MCUs ist natürlich bei den Interrupts zu beachten, dass diese unterschiedliche Prioritäten haben können. Auch hier ist der STM32L4 den anderen MCUs überlegen, da er mehr Prioritäten verarbeiten kann. Bei den ARM-Prozessoren funktionieren die Interrupts alle identisch, es gibt halt nur eine unterschiedliche Anzahl von Prioritäten abhängig von der Version des ARM-Cortex.
In Interrupt-Routinen sollte man kleine Floats verwenden, da die Fließkommaregister (Floating Point Registers) nicht gesichert werden.

RTC-Uhrzeiten

Alle besprochenen MCUs haben einen eigenen Quarz und arbeiten somit im regulären Betrieb für die meisten Anwendungsfälle hinreichend genau. D21 und STML4 haben eine RTC, die auch im „deepsleep“ oder nach einem Reset sekundengenau weiter läuft. Der ESP32 verliert sein Uhrzeit im „deepsleep“ bzw. die Uhrzeit ist dann so ungenau, dass diese für eine Zeitmessung nicht mehr zu gebrauchen ist.

Der ESP32 hat allerdings bei aktivem WiFi den Vorteil, dass er sich per NTP die aktuelle Uhrzeit holen kann. Das muss aber erst erst programmiert werden (WiFi aktivieren, NTP-Update).

Zusammenfassung

Der ESP32 ist auf der einen Seite sehr leistungsfähig – um Faktoren der schnellste im Test und der einzige mit WiFi – kann aber hinsichtlich der Interrupt-Funktionen und beim Flash-Speicherzugriff auch zum Biest werden. Zusätzlich überzeugt beim ESP32 der 512 kB Hauptspeicher. Auch moderne 64-bit-Timer gibt es nur beim ESP32. Lediglich „deepsleep“ funktioniert nicht besonders gut.

Der D21 ist eine einfache Standard-MCU mit ARM-Prozessor. Er ist langsam, kann nicht besonders viel, läuft aber zuverlässig, und der eingebaute USB-Port gefällt. Die ADC-Messungen beim D21 funktionieren um Längen besser als beim ESP32.

Der STM32L4 ist eine moderne Cortex-M4-MCU mit Floating-Point-Unterstützung: USB funktioniert einwandfrei, Programmierung über USB-OTG wird unterstützt, ADC und alle GPIO-Modes funktionieren ebenfalls einwandfrei, er verfügt über einen Low-Power-Timer und der „deepsleep“ Modus ist unschlagbar. Mit 80 MHz ist diese MCU natürlich langsamer als der ESP32, und obwohl sie mit 64 kB RAM schon einen ordentlichen Speicher hat, bietet der ESP32 ein Vielfaches.

Ich hoffe, der Artikel bringt einiges Licht in das Dunkel der verschiedenen MCUs. Ich bin von den 32-bit-MCUs begeistert, da es hier vielfache Möglichkeiten für Projekte und Industrieanwendungen gibt. Eigentlich schon sensationell, was da alles so möglich ist!

Wenn es nach Covid-19 mit den Arduino-Hannover-Treffen wieder weiter geht, könnt Ihr gerne vorbei kommen und wir diskutieren über die MCUs und Eure Projekte.

Bis demnächst!

Links