STM32-Boards – Teil 2: Ausgewählte Funktionen

Über den Beitrag

Im letzten Beitrag habe ich gezeigt, wie ihr mit der Arduino IDE Sketche auf STM32-Boards hochladet. In diesem Beitrag möchte ich nun einen Schritt weitergehen und einige ausgewählte Funktionen vorstellen. Dazu gehören einerseits grundlegende Features, die im Arduino-Funktionsumfang nicht zum Standard gehören, aber doch oft benötigt werden, wie etwa Timer, PWM oder verschiedene Energiesparmodi. Andererseits gehe ich bei bekannten Funktionen wie I²C, SPI oder Interrupts auf die Besonderheiten der STM32-Implementierung ein. Und schließlich gibt es noch die gewissen Extras, die nicht jedes Arduino-Board bietet, wie den Digital-Analog-Wandler (DAC) oder die Real-Time Clock (RTC).

Hier die Themen im Überblick:

Programmierebenen der SM32-Boards

Mikrocontroller (MCUs) lassen sich auf verschiedenen Abstraktionsebenen programmieren. Bei den STM32-MCUs sind das im Wesentlichen:

  1. Registerebene („Bare-Metal“, „Low-Level“): Dabei greift ihr direkt über Strukturen und Bitmasken auf die Register zu. Das ist sehr effizient, aber am wenigsten portabel.
  2. CMSIS (Cortex Microcontroller Software Interface Standard): Ist von ARM vorgegeben und wird ST mitgeliefert. Dort werden beispielsweise IRQ-Namen und Core-Funktionen definiert. 
  3. ST eigene Abstraktionen:
    1. LL (Low-Layer): Immer noch nah an den Registern.
    2. HAL (Hardware Abstraction Layer): Höhere Abstraktion, höhere Portabilität, aber mehr Overhead.
  4. Frameworks / Cores für High-Level-Entwicklung: Hierzu gehört insbesondere das Arduino-Core Boardpaket von STM32duino, das ich schon im letzten Beitrag verwendet habe. HAL und LL sind die Grundlage dafür, „verstecken“ sich aber hinter den Arduino-typischen Funktionen. 

In diesem Beitrag geht es nur um die höchste Ebene. Da ihr aber bei Beschäftigung mit den STM32-Boards sicherlich über Begriffe wie HAL, LL oder Bare-Metal stoßen werdet, wollte ich diese nicht unerwähnt lassen.

Boardauswahl für diesen Beitrag

Ich kann hier natürlich nicht sämtliche STM32-Boards besprechen, sondern habe dieselbe Auswahl wie im ersten Teil des Beitrages getroffen:

  • Der „BluePill“ auf Basis des STM32F103C8 
  • Der „BlackPill“ auf Basis des STM32F411
  • Das Nucleo-L432KC Board, als Vertreter eines Nucleo-32-Boards
  • Das Nucleo-F446RE Board, als Vertreter eines Nucleo-64-Boards
  • Der Arduino GIGA R1 WIFI, als Verterter eines STM32-basierten Arduinos

Der Fokus liegt dabei auf den ersten drei Boards. 

Und noch ein Hinweis vorab: Wenn ich im Beitrag von „dem Blackpill“ oder „dem BluePill“ spreche, dann beziehe ich mich auf die o. g. Typen. Auch bei diesen Typen gibt es noch verschiedene Ausfertigungen oder Fakes. Deshalb kann es passieren, dass sich eure Boards in bestimmten Aspekten anders verhalten als hier besprochen.

Pinout der hier besprochenen Boards

„BluePill“ (STM32F103C8)

Das unten abgebildete Pinout-Diagramm für den BluePill habe ich aus diesem schönen Artikel von Renzo Mischianti. Wenn ihr tiefer in die spezifischen Eigenschaften der BluePill-Boards eintauchen wollt, dann kann ich Renzo Mischiantis Artikel sehr empfehlen.

Pinout BluePill (STM32F103C8) von Renzo Mischianti.
Pinout Bluepill (STM32F103C8), mit freundlicher Genehmigung von Renzo Mischianti (Copyright beachten!)

Besonderheiten dieses BluePill-Boards:

  • Einige Pins sind 5-Volt-tolerant (grüne Rechtecke), andere vertragen nur 3.3 Volt (rot-braune Rechtecke).
  • Für PC13, PC14, PC 15 gilt: 
    • Sie dürfen im HIGH-Zustand keinen Strom liefern, d. h. ihr könnt den „HIGH“-Zustand als logisches Signal nutzen, aber zum Beispiel keine LED damit betreiben.
    • Sie dürfen im LOW-Zustand maximal 3 mA aufnehmen (die LED an PC13 ist mit 3.3 Volt verbunden und leuchtet, wenn PC13 OUTPUT/LOW ist!).
    • Sie dürfen nur mit maximal 2 MHz angesteuert und mit maximal 30 pF belastet werden.
  • Das Board kann über den 3.3-Volt- oder den 5-Volt-Anschluss betrieben werden. VBAT versorgt lediglich die RTC und Backup-Register mit Strom.

„BlackPill“ STM32F411

Wie schon beim BluePill, habe ich mich auch beim BlackPill hinsichtlich des Pinout bei Renzo Mischianti bedient. Wenn ihr noch tiefer in die Details der BlackPill-Boards eintauchen wollt, dann empfehle ich auch hier Renzos Artikelserie. Dieser Artikel ist ein guter Startpunkt.

Pinout BlackPill (STM32F411) von Renzo Mischianti.
Pinout BlackPill (STM32F411), mit freundlicher Genehmigung von Renzo Mischianti (Copyright beachten!)

Besonderheiten dieses BlackPill-Boards: Es gelten die gleichen Einschränkungen und Anmerkungen wie für das BluePill Board, allerdings sind erheblich mehr Pins 5-Volt-tolerant.

Pinout Nucleo-L432KC

Das Nucleo-L432LC-Board versucht, eine gewisse Kompatibilität mit den Arduino Nano Boards zu erreichen. Das Pinout stimmt hinsichtlich der Pinbezeichnungen und der Funktionalitäten in weiten Teilen überein. 

Pinout Nucleo-L432KC Board
Pinout Nucleo-L432KC Board (Eigenkreation)

Besonderheiten des Nucleo-L432KC:

  • Die GPIOs sind grundsätzlich nur 3.3-Volt-kompatibel.
  • Die Pins D7 und D8 sind mit nichts verbunden. Ich komme bei den Solder Bridges weiter unten darauf zurück.
  • Ihr könnt die Pins „3.3V“ und „5V“ nutzen, um damit andere Bauteile mit Strom zu versorgen. Der maximal zulässige Strom hängt von der Art der Stromversorgung des Boards ab. Siehe dazu das User Manual UM1956.
  • Für die Versorgung des Boards über die 3.3V- und 5V-Pins müsst ihr die Konfiguration der Solder Bridges SBxy ändern (siehe weiter unten).
  • Über VIN könnt ihr das Board mit 7 bis 12 Volt Spannung versorgen.

Pinout Nucleo-F446RE und Arduino GIGA R1 WIFI

Ein Pinout-Diagramm des Nucleo-F446RE findet ihr hier. Insgesamt stehen euch mit diesem Board 50 GPIOs zur Verfügung. Ein Pinout-Diagramm des Arduino GIGA R1 WIFI findet ihr hier auf den Arduinoseiten. Dieses Board besitzt sogar stolze 76 GPIOs.

STM32 Boards Nucleo-F446RE und Arduino GIGA R1 WIFI
Nucleo-F446RE und Arduino GIGA R1 WIFI

Solder Bridges (Lötbrücken) der Nucleo-Boards

Wenn ihr euch das Nucleo-L432KC Board (oder andere Nucleo-Boards) anschaut, dann werdet ihr viele Lötbrücken erkennen, die mit „SBxy“ gekennzeichnet sind. Ein Teil davon ist offen, der andere ist über 0-Ω-Widerstände geschlossen. Eine detaillierte Beschreibung der Solder Bridges der Nucleo-32-Boards findet ihr im schon oben erwähnten UM1956. Hier einige ausgewählte Einstellungen:

  • Um das Board über den 3.3-Volt-Pin mit Strom zu versorgen, müssen SB9 und SB14 offen sein. Als Einschränkung steht der boardinterne ST-LINK dann nicht zur Verfügung.
  • Um das Board über den 5-Volt-Pin zu versorgen, muss SB9 offen sein. Der boardinterne ST-LINK steht dann so nicht zur Verfügung. Die Versorgung über den 5-Volt-Pin und Debugging über ST-LINK können kombiniert werden, dann ist allerdings eine bestimmte Prozedur einzuhalten (siehe wieder UM1956).
  • Wenn ihr die Pins D7 und D8 als GPIOs verwenden wollt (PC14/PC15), dann müssen SB4, SB5, SB7 und SB17 offen sein; SB6 und SB8 müsst ihr schließen. Dann könnt ihr allerdings den externen Quarz für die RTC nicht mehr nutzen.

Ihr seht schon – es ist recht komplex. Und bevor ihr jetzt munter an eurem Board herumlötet, lest unbedingt das User Manual UM1956 aufmerksam!

Auch das Nucleo-F446RE Board besitzt sogar noch mehr Solder Bridges. Entsprechend gibt es auch hier Besonderheiten, wie beispielsweise die offenen Solder Bridges (SB48 / SB49) an PC14 und PC15. Erläuterungen dazu findet ihr im User Manual UM2324.

Hardware Timer Interrupts

Nun starten wir aber endlich mit der Praxis. Als Erstes schauen wir uns an, wie man Timer-Overflow-Interrupts mithilfe der HardwareTimer-Klasse programmiert. Die Definition der Klasse findet ihr in HardwareTimer.h. Diese Datei befindet sich im Verzeichnis …\Arduino15\packages\STMicroelectronics\hardware\stm32\2.11.0\libraries\SrcWrapper\inc. Die zugehörige Datei HardwareTimer.cpp befindet sich „in der Nähe“, unter …\SrcWrapper\src. Falls ihr tiefer in das Thema einsteigen und alle Funktionen der HardwareTimer-Klasse kennenlernen wollt, dann lohnt der Blick in diese Dateien.

Die STM32-MCUs besitzen mehrere Timer (TIMx). Welche das sind, findet ihr leicht im Datenblatt des zugrundeliegenden Mikrocontrollers. Dort steht auch, ob es sich um 16-Bit- oder 32-Bit-Timer handelt.

Ich verwende für die Beispiele bevorzugt den Timer TIM2, da er auf allen betrachteten Boards zur Verfügung steht. Auf dem BluePill ist TIM2 ein 16-Bit-Timer, auf dem BlackPill und dem Nucleo-L432KC ist es ein 32-Bit-Timer.

Ein Interrupt

Steuerung über den Prescalefaktor und Overflow-Wert

Schauen wir uns das erste Beispiel an:

HardwareTimer myTim(TIM2);

void setup() {  
    uint32_t prescaleFactor = 10000;    // fits well to the BlackPill
    //  uint32_t prescaleFactor = 7200; // fits well to the BluePill
    //  uint32_t prescaleFactor = 8000; // fits well to the L432KC
    //  uint32_t prescaleFactor = 8400; // fits well to the F446RE
    uint32_t timerOverflow = 10000;
    Serial.begin(115200);
    myTim.setPrescaleFactor(prescaleFactor);
    myTim.setOverflow(timerOverflow);
    myTim.attachInterrupt(onTimer);
    myTim.resume();
}

void loop() {}

void onTimer() {
    Serial.println("1 second");
}

Als Ausgabe erhaltet ihr auf dem seriellen Monitor sekündlich die Meldung „1 second“.

Erklärungen zum Sketch

Mit HardwareTimer myTim(TIM2) erzeugt ihr das Objekt myTim und ordnet ihm TIM2 zu. Den Prescaler-Faktor legt ihr mit setPrescaleFactor() fest. Er darf einen Wert zwischen 1 und 216 haben. Die Zählfrequenz des Timers ist der Systemtakt, geteilt durch den Prescalerfaktor. Ihr solltet ihn so wählen, dass ihr bequem damit rechnen könnt. Die Beispiele im Sketch regeln die Zählfrequenz auf 10 kHz herunter. Den Overflow legt ihr mit setOverflow() fest. Bei 16-Bit-Timern ist der maximale Wert 216, bei 32-Bit-Timern ist es maximal 232-1, da timerOverflow als 32-Bit-Zahl übergeben wird.

In dem obigen Beispiel ist Overflow 10000 und damit ist die Überlauffrequenz nach der folgenden Formel 1 Hz:

    \[ F_{\text{overflow}} = \frac{\text{systemClock}}{\text{prescaleFactor}\cdot \text{timerOverflow}} \]

Entsprechend ist die minimale Interruptfrequenz bei Verwendung eines 16-Timers auf einem BlackPill Board 100 [MHz] / (216 * 216) = ~0.023 [Hz]. Für einen 32-Bit Timer wäre es ~3.55*10-7 [Hz], was einer Periode von ca. 32.5 Tagen entspricht.

Der Funktion attachInterrupt übergebt ihr die auszuführende Interrupt-Serviceroutine (ISR). Mit resume()⁣ startet ihr den Zähler bzw. setzt ihn fort.

Steuerung über die Periode

Alternativ legt ihr einfach die Periode fest. Dann müsst ihr nicht rechnen und könnt den Sketch auch unverändert auf andere Boards portieren:

HardwareTimer myTim(TIM2);

void setup() {
    uint32_t timerOverflow = 1000000; 
    Serial.begin(115200);
    myTim.setOverflow(timerOverflow, MICROSEC_FORMAT);
    myTim.attachInterrupt(onTimer);
    myTim.resume();
}

void loop() {}

void onTimer() {
    Serial.println("1 second");
}

Ich denke, der Sketch ist selbsterklärend.

Steuerung über die Frequenz

Als weitere Option könnt ihr auch die Überlauffrequenz festlegen. Allerdings ist dann 1 Hz die langsamste Einstellung, die ihr vornehmen könnt.

HardwareTimer myTim(TIM2);

void setup() {
    uint32_t timerOverflow = 1; // 1 Hz is the slowest frequency
    Serial.begin(115200);
    myTim.setOverflow(timerOverflow, HERTZ_FORMAT);
    myTim.attachInterrupt(onTimer);
    myTim.resume();
}

void loop() {}

void onTimer() {
    Serial.println("1 second");
}

Mehrere Interrupts

Ihr könnt natürlich auch mehrere Interrupts einrichten. Hier ein Beispiel, das die Timerobjekte und ISR in kompakter Form als Arrays zusammenfasst. In den ISRs werden LEDs „getoggelt“. Falls ihr den Sketch ausprobieren wollt, dann hängt LEDs an die entsprechenden Pins. 

HardwareTimer myTim0(TIM1);
HardwareTimer myTim1(TIM2);
HardwareTimer myTim2(TIM3); // TIM15 for L432KC; TIM13 for F446RE
HardwareTimer myTim3(TIM4); // TIM16 for L432KC; TIM14 for F446RE
HardwareTimer* myTimers[4] = {&myTim0, &myTim1, &myTim2, &myTim3};

const int ledPin[4] = {PA0, PA1, PA2, PA3}; // A0, A1, A2, A3: for L432KC

uint32_t timerOverflow[4] = {500000, 1200000, 1700000, 2200000};

void onTimer0() {
    digitalWrite(ledPin[0], !digitalRead(ledPin[0]));
}

void onTimer1() {
    digitalWrite(ledPin[1], !digitalRead(ledPin[1]));
}

void onTimer2() {
    digitalWrite(ledPin[2], !digitalRead(ledPin[2]));
}

void onTimer3() {
    digitalWrite(ledPin[3], !digitalRead(ledPin[3]));
}


void (*onTimer[4])(void) = {onTimer0, onTimer1, onTimer2, onTimer3}; 

void setup () {
    for (int i=0; i<4; i++){
        pinMode(ledPin[i], OUTPUT);
        myTimers[i]->setOverflow(timerOverflow[i], MICROSEC_FORMAT);
        myTimers[i]->attachInterrupt(onTimer[i]);
        myTimers[i]->resume();
    }
}

void loop() {}

 

Timer Interrupts für das Arduino GIGA R1 WIFI Board

Da die HardwareTimer-Klasse fest im Boardpaket des STM32 Arduino Core Paketes verankert ist, funktioniert sie nicht mit dem Arduino GIGA R1 WIFI Board, wenn ihr das Standard Arduino Boardpaket verwendet.

Als Alternative steht euch die Bibliothek Portenta_H7_TimerInterrupt, die ihr über den Arduino Bibliotheksmanager installieren könnt, zur Verfügung. Sie wurde für das Portenta H7 Board geschrieben, funktioniert aber auch mit dem Arduino GIGA R1 WIFI, da beide denselben Mikrocontroller besitzen.

Beim Kompilieren der Beispielsketche bekommt ihr die Fehlermeldung: „This code is intended to run on the MBED ARDUINO_PORTENTA_H7 platform! Please check your Tools->Board setting“. Der Kompiler sagt euch aber auch, welche Datei diesen Fehler produziert hat. Es ist zum einen Portenta_H7_ISR_Timer.hpp, zum anderen Portenta_H7_TimerInterrupt.h.

Geht in die Dateien und sucht die Zeile (wahrscheinlich 30):

#if ( ( defined(ARDUINO_PORTENTA_H7_M7) || defined(ARDUINO_PORTENTA_H7_M4) ) && defined(ARDUINO_ARCH_MBED) )

Ändert sie in:

#if ( ( defined(ARDUINO_PORTENTA_H7_M7) || defined(ARDUINO_PORTENTA_H7_M4) || defined(ARDUINO_GIGA) ) && defined(ARDUINO_ARCH_MBED) )

In den Beispielsketchen könnt ihr ähnlich verfahren oder ihr kommentiert die relevanten Zeilen heraus. Dann sollte es gehen.

Pulsweitenmodulation (PWM)

Auch Pulsweitenmodulation (PWM) lässt sich mithilfe der HardwareTimer-Klasse bequem realisieren. Hier ein einfaches Beispiel:

HardwareTimer Timer(TIM1); 

void setup() {
    uint32_t channel = 3;
    int pwmPin = PA10; // D0 for L432KC; D2 for F446RE
    uint32_t frequency = 1;
    uint32_t dutyCycle = 20; // 20%
    Timer.setPWM(channel, pwmPin, frequency, dutyCycle);
}

void loop() {}

Die Ausgänge für die verschiedenen Timer und Kanäle sind bestimmten Pins zugeordnet. Für die hier besprochenen Boards findet ihr die Zuordnung in den Pinout-Diagrammen. Ansonsten schaut in das zugehörige Datenblatt. Wenn ihr das obige Programm hochladet und eine LED an den PWM-Pin hängt, dann werdet ihr sehen, wie diese im Sekundentakt für 200 ms aufleuchtet.

Man sollte erwarten, dass man am T1C3N-Ausgang (z. B. PB1 am BlackPill) automatisch eine invertierte Ausgabe erhält. Dem ist aber nicht so.

Ihr könnt bei Beendigung der Periode und/oder des DutyCyles Callbackfunktionen aufrufen, um weitere Aktionen zu veranlassen. Hier ein kleines Beispiel:

HardwareTimer Timer(TIM1);

void setup() {
    uint32_t channel = 3;
    int pwmPin = PA10; // PA10 = D0 on the L432KC and D2 on the F446RE
    uint32_t frequency = 1;
    uint32_t dutyCycle = 20; // 20 %

    Serial.begin(115200);
    Timer.setPWM(channel, pwmPin, frequency, dutyCycle, periodCompleted, compareMatch);
}

void loop() {}

void periodCompleted(){
    Serial.print("Hi");
}

void compareMatch(){
    Serial.println("Ho");
}

Etwas mehr Einflussmöglichkeiten habt ihr mit dieser Variante:

HardwareTimer Timer(TIM1);

void setup() {
    //  uint32_t compareValue = 2000;
    Timer.setPrescaleFactor(10000); // -> 10 KHz für BlackPill (STMF411); adjust to your board
    Timer.setOverflow(10000); // -> 1 Hz        
    Timer.setMode(3, TIMER_OUTPUT_COMPARE_PWM1, PA10); // channel, mode, pwmPin
    Timer.setCaptureCompare(3, 20, PERCENT_COMPARE_FORMAT); // channel, duty cycle (%), format 
    //  Timer.setCaptureCompare(3, compareValue); // channel, compareValue
    Timer.resume();
}

void loop() {}

TIMER_OUTPUT_COMPARE_PWM1 ist einer der möglichen Timer-Modi. In HardwareTimer.h sind diese als enum (TimerModes_t) definiert. Schaut dort, wenn ihr die anderen Modi probieren wollt. 

PERCENT_COMPARE_FORMAT ist eines der möglichen Compare-Formate. Diese sind im enum TimerCompareFormat_t in HardwareTimer.h definiert. Wenn ihr setCaptureCompare() kein Format übergebt, dann greift TICK_COMPARE_FORMAT als Default. In diesem Fall übergebt ihr den Comparewert als absoluten Zählerwert.

PWM mit dem Arduino GIGA R1 WIFI

Für PWM auf dem Arduino GIGA R1 WIFI könnt ihr die Bibliotheken Portenta_H7_PWM und Portenta_H7_Slow_PWM verwenden. Aber auch hier müsst ihr, wie zuvor für die Bibliothek Portenta_H7_TimerInterrupt beschrieben, die Borddefinition für den Arduino GIGA R1 WIFI ergänzen.

Analog-Digital-Wandler (ADC)

Als Nächstes schauen wir uns den ADC der STM32-Boards an. Hinsichtlich der Referenzspannung ist zu beachten:

  • Die maximale Auflösung beträgt 12 Bit. Das BluePill- und das BlackPill-Board verfügen über keinen VREF-Pin. Grundsätzlich ist VDD die Standardreferenz.
  • Beim Nucleo-L432KC gibt es zwar einen VREF-Pin, allerdings könnt ihr die Referenzspannung dort nur abgreifen, aber nicht anlegen.
  • Beim Nucleo-F446RE lässt sich eine externe Referenz nutzen, wenn die Solder Bridges entsprechend gesetzt werden.

Die Programmierung erfolgt über die üblichen Arduinofunktionen. Für das Arduino GIGA R1 WIFI Board gibt es die Bibliothek Arduino_AdvanceAnalog, die die Nutzung seiner drei ADCs unterstützt.

Prüfung des ADC

Ich habe Spannungen zwischen 0 und 3.3 Volt angelegt, mit analogRead() gewandelt und das Ergebnis mit meinem Multimeter gegengeprüft. Von meinem Multimeter weiß ich, dass es sehr präzise ist. Ich habe jeweils 50 Messwerte gemittelt, was das Rauschen auf wenige Millivolt reduziert hat.

Hier der Sketch: 

const float vRef = 3.3;

void setup() {
    Serial.begin(115200);
    analogReadResolution(12);
}

void loop() {
    unsigned long int rawVal = 0;
    float voltage = 0.0;
    for (int i=0; i<50; i++) {
        rawVal += analogRead(PA1);
        delay(1);
    }
    voltage = (rawVal * vRef) / (4095 * 50);
    Serial.print("Raw: ");
    Serial.print(rawVal);
    Serial.print("\tVoltage [V]: ");
    Serial.println(voltage, 3);
    delay(1000);
}

Und hier eine typische Ausgabe:

Ausgabe adc_test.ino
Ausgabe adc_test.ino

Und hier die Ergebnisse über den gesamten Spannungsbereich das BlackPill-Board:

STM32 ADC Test: BlackPill
ADC-Test mit einem Blackpill-Board (STM32F411)

Das Ergebnis sieht ziemlich gut aus. Die Linearität ist einwandfrei. Wenn ihr genau hinschaut, dann seht ihr, dass die Steigung der ADC-Gerade ein kleines bisschen geringer ist als die der Multimeter-Gerade. Der Grund war die Betriebsspannung des BlackPill-Boards, die nicht 3.3 Volt, sondern 3.311 Volt betrug. Nach Anpassung von vref im obigen Sketch lagen die Geraden noch besser übereinander.

Ähnlich gute Ergebnisse gab es mit dem BluePill-Board (STM32F103C8) und dem Nucleo-L432KC. 

Digital-Analog-Wandler DAC

Normalerweise erzeugt ihr mit analogWrite() ein PWM-Signal. Wenn ihr aber analogWrite() auf einen DAC-Pin anwendet, dann erhaltet ihr automatisch ein echtes analoges Signal. DAC-Ausgänge findet ihr an den Nucleo-Boards und dem Arduino GIGA R1 WIFI, nicht jedoch an BluePill und BlackPill.

Mit dem folgenden Sketch gebt ihr DAC-Rohwerte vor und erhaltet die berechneten Spannungen:

void setup() {
    Serial.begin(115200);
    analogWriteResolution(12); // 12 bit resolution
}

void loop() {
    float vref = 3.3;
    if(Serial.available()){
        int rawVoltage = Serial.parseInt();
        analogWrite(A3, rawVoltage);
        Serial.print(rawVoltage);
        Serial.print(" -> ");
        float voltage = (vref * rawVoltage / 4095.0);
        Serial.print(voltage, 3);
        Serial.println(" [V] (calc.)");
    }
}

Ausgabe dac_test.ino
Ausgabe dac_test.ino

Den Sketch habe ich genutzt, um die berechnete Spannung mit der tatsächlichen zu vergleichen. Hier das Ergebnis: 

STM32 DAC Test: Nucleo-L432KC
DAC-Output: Gemessen vs. kalkuliert

Über einen weiten Bereich stimmen die tatsächlichen Werte hervorragend mit den berechneten Werten überein. Nur an den Enden knickt die Kurve mit den tatsächlichen Werten weg. Es war mir nicht möglich, Spannungen kleiner 0.045 Volt (analogWrite(0)) oder größer 3.28 Volt (analogWrite(4095)) zu erzeugen. Abgesehen davon war ich mit dem Ergebnis sehr zufrieden. Wer es noch genauer haben möchte, der misst die tatsächliche Boardspannung an (V)REF und korrigiert vref im obigen Sketch.

Auf dem Arduino GIGA R1 WIFI konnte ich Spannungen zwischen 0.02 Volt und 3.226 Volt erzeugen. Allerdings lag die Boardspannung hier auch nur bei 3.262 Volt.

Externe Interrupts

Ihr könnt an fast allen GPIOs externe Interrupts (EXTI) einrichten. Nur PB2 (auf dem BlackPill), PC13, PC14 und PC15 solltet ihr meiden.

Es gibt aber noch eine Einschränkung. Euch stehen 16 EXTI-Linien (0–15) zur Verfügung. Die EXTI-Linien können aber nur Pins zugeordnet werden, deren Zahl der EXTI-Linie entspricht. Also EXTI0 →  PA0, PB0; EXTI1 → PA1, PB1; usw. Und pro Linie dürft ihr nur einen Interrupt zuweisen. Konsequenz: Interrupts an PA0 und PB1 könnt ihr parallel einrichten, Interrupts an PA0 und PB0 nicht.

Mit einem BlackPill-Board habe ich die Einrichtung von vier externen Interrupts ausprobiert. Jeder der vier Pins ist über einen Taster mit GND verbunden.

STM32 - Beispielschaltung externe Interrupts
Schaltung für Interrupt Beispiel

Die Pins werden in den Modus INPUT_PULLUP gebracht. Die zugehörigen Interrupts werden bei fallender Flanke ausgelöst, also bei Druck auf die Taster. Auf dem seriellen Monitor wird der auslösende Pin angezeigt. Hier der Sketch: 

/* tested on a BlackPill (F411CE) */
int intPin[4] = {PA4, PA1, PB3, PB6};
volatile bool keyPressed = false;
volatile int intKey = 0;

void onKey0 () {
    keyPressed = true;
    intKey = 0;
}

void onKey1 () {
    keyPressed = true;
    intKey = 1;
}

void onKey2 () {
    keyPressed = true;
    intKey = 2;
}

void onKey3 () {
    keyPressed = true;
    intKey = 3;
}

void (*onKey[4])(void) = {onKey0, onKey1, onKey2, onKey3}; 

void setup() {
    Serial.begin(115200);
    for (int i=0; i<4; i++) {
        pinMode(intPin[i], INPUT_PULLUP);
        attachInterrupt (digitalPinToInterrupt(intPin[i]), onKey[i], FALLING);
        delay(10); 
    }
}

void loop() {
    if (keyPressed) {
        Serial.print("Key ");
        Serial.print(intKey);
        Serial.println(" has been pressed");
        delay(500);
        intKey = 0;
        keyPressed = false;
    }
}

 

I2C

Auf dem BluePill (STM32F103C8) könnt ihr zwei I²C-Schnittstellen nutzen. Auf dem BlackPill (STM32F411), dem Nucleo-L432KC, dem Nucleo-F446RE und dem Arduino GIGA R1 WIFI sind es drei Schnittstellen. Wenn ihr nur eine Schnittstelle nutzt und nichts anderes definiert, dann befinden sich SDA und SCL an folgenden Pins:

  • BluePill / BlackPill: SDAPB7 / SCLPB6
  • Nucleo-L432KC: SDAPB_7 (D4) / SCLPB_6 (D5) 
  • Nucleo-F446RE: SDAPB_9 (D14) / SCLPB_8 (D15) 
  • Arduino GIGA R1 WIFI: SDAPB11 (D20) / SCLPH4 (D21)

Anschauungsbeispiel: MPU6000/MPU6050/MPU6500/MPU9250

Die Nutzung von I2C auf den STM32-Boards möchte ich anhand praktischer Beispiele erklären. Dazu habe ich die Beschleunigungssensoren MPU6000, MPU6050, MPU6500 bzw. MPU9250 ausgesucht. Die hier vorgestellten Sketche funktionieren für alle vier Sensoren. Vereinheitlicht bezeichne ich sie als „MPUXXXX“.

Das soll hier kein Beitrag über diese Sensoren werden, aber eine kurze Erklärung ist sicherlich nötig, damit wir uns gleich auf die relevanten I2C-Funktionen konzentrieren können.

  • Die MPUXXXX werden durch einen Eintrag in ihr Power Management Register 1 (MPUXXXX_PWR_MGMT_1) geweckt und messen dann kontinuierlich die Beschleunigung in Richtung der x-, y- und z-Achse.
  • Die Beschleunigungswerte sind 16-Bit-Zahlen, die „in einem Rutsch“ aus 6 Registern ausgelesen werden, beginnend mit dem Register MPUXXXX_ACCEL_XOUT_H (höherwertiges Byte der Beschleunigung in x-Richtung).
  • Die 6 Bytes werden dann in die drei Beschleunigungswerte accX, accY und accZ umgerechnet.
  • In den Beispielen beträgt die Auflösung +/- 2 g, d. h. 214 (= 16384) entspricht einem g.

Anschluss

Stellvertretend seht ihr hier zwei MPU6500/MPU920 an I2C-1 (default) und I2C-2 eines BlackPillboards:

Zwei MPU6500/9250 per I2C am BlackPill
Zwei MPU6500/9250 per I2C am BlackPill

Ein I²C‑Gerät an den Standardpins

Wenn ihr die Standardschnittstelle mit den Standardpins nutzt, dann gibt es hinsichtlich der I2C-Funktionen keine Überraschungen:

#include "Wire.h" 
#define MPUXXXX_ADDR 0x68 // Alternatively set AD0 to HIGH  --> Address = 0x69
// MPUXXXX registers
#define MPUXXXX_PWR_MGMT_1 0x6B
#define MPUXXXX_ACCEL_XOUT_H 0x3B

char result[7]; // for formatted output

void setup() {
    Serial.begin(115200);
    Wire.begin();
    writeRegister(MPUXXXX_PWR_MGMT_1, 0x00); // wake up MPUXXXX
}

void loop() {
    uint8_t data[6];
    int16_t accX, accY, accZ;
    
    readRegister(MPUXXXX_ACCEL_XOUT_H, data, 6);
    
    accX = data[0] << 8 | data[1];
    accY = data[2] << 8 | data[3]; 
    accZ = data[4] << 8 | data[5]; 
   
    Serial.print("AcX = "); Serial.print(toStr(accX));
    Serial.print(" | AcY = "); Serial.print(toStr(accY));
    Serial.print(" | AcZ = "); Serial.println(toStr(accZ));

    delay(1000);
}

void writeRegister(uint8_t reg, uint8_t data) {
    Wire.beginTransmission(MPUXXXX_ADDR);
    Wire.write(reg); 
    Wire.write(data); 
    Wire.endTransmission(true);
}

void readRegister(uint8_t reg, uint8_t* buffer, uint8_t length) {
    Wire.beginTransmission(MPUXXXX_ADDR);
    Wire.write(reg);
    Wire.endTransmission(false);                             
    Wire.requestFrom((uint8_t)MPUXXXX_ADDR, length);
    for (int i=0; i<length; i++){
        buffer[i] = Wire.read();
    }
}

char* toStr(int16_t character) { 
  sprintf(result, "%6d", character);
  return result;
}

 

Der Vollständigkeit halber, hier noch eine Beispielausgabe:

Ausgabe i2c_standard.ino
Ausgabe i2c_standard.ino

Ein I²C‑Gerät an alternativen Pins

Wenn ihr alternative I²C-Pins nutzen wollt, dann müsst ihr ein TwoWire-Objekt erzeugen und ihm die Pins übergeben. Dabei spielt es keine Rolle, ob es sich um alternative Pins der I2C-Standardschnittstelle oder um alternative Schnittstellen handelt. 

#include "Wire.h" 
#define MPUXXXX_ADDR 0x68 // Alternatively set AD0 to HIGH  --> Address = 0x69
// MPUXXXX registers
#define MPUXXXX_PWR_MGMT_1 0x6B
#define MPUXXXX_ACCEL_XOUT_H 0x3B

char result[7]; // for formatted output

/* create a TwoWire object */
TwoWire myWire(PB9, PB8); // SDA, SCL

void setup() {
    Serial.begin(115200);
    myWire.begin();
    writeRegister(MPUXXXX_PWR_MGMT_1, 0x00); // wake up MPUXXXX
}

void loop() {
    uint8_t data[6];
    int16_t accX, accY, accZ;
    
    readRegister(MPUXXXX_ACCEL_XOUT_H, data, 6);
    
    accX = data[0] << 8 | data[1];
    accY = data[2] << 8 | data[3]; 
    accZ = data[4] << 8 | data[5]; 
   
    Serial.print("AcX = "); Serial.print(toStr(accX));
    Serial.print(" | AcY = "); Serial.print(toStr(accY));
    Serial.print(" | AcZ = "); Serial.println(toStr(accZ));

    delay(1000);
}

void writeRegister(uint8_t reg, uint8_t data) {
    myWire.beginTransmission(MPUXXXX_ADDR);
    myWire.write(reg); 
    myWire.write(data); 
    myWire.endTransmission(true);
}

void readRegister(uint8_t reg, uint8_t* buffer, uint8_t length) {
    myWire.beginTransmission(MPUXXXX_ADDR);
    myWire.write(reg);
    myWire.endTransmission(false);                             
    myWire.requestFrom((uint8_t)MPUXXXX_ADDR, length);
    for (int i=0; i<length; i++){
        buffer[i] = myWire.read();
    }
}

char* toStr(int16_t character) { 
  sprintf(result, "%6d", character);
  return result;
}

 

Mehrere I²C‑Schnittstellen nutzen

Wenn ihr mehrere I2C-Schnittstellen nutzen wollt, dann müsst ihr für jede ein eigenes TwoWire-Objekt erschaffen. Wenn ihr Funktionen definiert, die die TwoWire-Objekte benutzen (so wie writeRegister() und readRegister()⁣), dann übergebt ihr die TwoWire-Objekte als Pointer oder als Referenz.

#include "Wire.h" 
#define MPUXXXX_ADDR 0x68 // Alternatively set AD0 to HIGH  --> Address = 0x69
// MPUXXXX registers
#define MPUXXXX_PWR_MGMT_1 0x6B
#define MPUXXXX_ACCEL_XOUT_H 0x3B

char result[7]; // for formatted output

/* create two TwoWire instances */
TwoWire Wire_1(PB7, PB6); // you could also use the default Wire object instead
TwoWire Wire_2(PB3, PB10);

void setup() {
    Serial.begin(115200);
    Wire_1.begin();
    Wire_2.begin();
    writeRegister(&Wire_1, MPUXXXX_PWR_MGMT_1, 0x00); // wake up MPUXXXX-1
    writeRegister(&Wire_2, MPUXXXX_PWR_MGMT_1, 0x00); // wake up MPUXXXX-2
}

void loop() {
    uint8_t data[6];
    int16_t accX_1, accY_1, accZ_1, accX_2, accY_2, accZ_2;
    
    readRegister(&Wire_1, MPUXXXX_ACCEL_XOUT_H, data, 6);
    
    accX_1 = (data[0] << 8) | data[1];
    accY_1 = (data[2] << 8) | data[3];
    accZ_1 = (data[4] << 8) | data[5];

    Serial.print("AcX_1 = "); Serial.print(toStr(accX_1));
    Serial.print(" | AcY_1 = "); Serial.print(toStr(accY_1));
    Serial.print(" | AcZ_1 = "); Serial.println(toStr(accZ_1));
    Serial.flush();

    readRegister(&Wire_2, MPUXXXX_ACCEL_XOUT_H, data, 6);
    
    accX_2 = (data[0] << 8) | data[1];
    accY_2 = (data[2] << 8) | data[3];
    accZ_2 = (data[4] << 8) | data[5];

    Serial.print("AcX_2 = "); Serial.print(toStr(accX_2));
    Serial.print(" | AcY_2 = "); Serial.print(toStr(accY_2));
    Serial.print(" | AcZ_2 = "); Serial.println(toStr(accZ_2));
    Serial.println();

    delay(1000);
}

void writeRegister(TwoWire *_wire, uint8_t reg, uint8_t data) {
    _wire->beginTransmission(MPUXXXX_ADDR);
    _wire->write(reg); 
    _wire->write(data); 
    _wire->endTransmission(true);
}

void readRegister(TwoWire *_wire, uint8_t reg, uint8_t* buffer, uint8_t length) {
    _wire->beginTransmission(MPUXXXX_ADDR);
    _wire->write(reg);
    _wire->endTransmission(false);                             
    _wire->requestFrom((uint8_t)MPUXXXX_ADDR, length);
    for (int i=0; i<length; i++){
        buffer[i] = _wire->read();
    }
}

char* toStr(int16_t character) { 
  sprintf(result, "%6d", character);
  return result;
}

 

Hier die zugehörige Ausgabe:

Ausgabe i2c_two_interfaces.ino
Ausgabe i2c_two_interfaces.ino

Bibliotheken nutzen

In vielen Fällen werdet ihr für die Steuerung eurer I²C‑Geräte fertige Bibliotheken nutzen, wie z.B. meine Bibliothek MPU9250_WE. Die meisten Bibliotheken für I2C-Geräte erlauben es, TwoWire-Objekte zu übergeben. Das würde dann so oder so ähnlich aussehen:

TwoWire Wire_1(PB7, PB6);
TwoWire Wire_2(PB3, PB10);
MPU9250_WE myMPU9250_1 = MPU9250_WE(&Wire_1);
MPU9250_WE myMPU9250_2 = MPU9250_WE(&Wire_2);

SPI

Für SPI ist das alles ähnlich. Auf dem BluePill-Board (STM32F103C8), dem Nucleo-L432KC und dem Arduino GIGA R1 WIFI stehen euch zwei SPI-Schnittstellen zur Verfügung. Auf dem BlackPill (STM32F411) und dem Nucleo-F446RE sind es vier. Die Standardpins sind:

  • BluePill / BlackPill: SCLK – PA5 / MISO – PA6 / MOSI – PA7
  • Nucleo-L432KC: SCLK – PB_3 (D13) / MISO – PB_4 (D12) / MOSI – PB_5 (D11)
  • Nucleo-F446RE: SCLK – PA_5 (D13) / MISO – PA_6 (D12) / MOSI – PA_7 (D11)
  • Arduino GIGA R1 WIFI: SCLK – PH6 (D13) / MISO – PJ11 (D12) / MOSI – PJ10 (D11)

Hier stellvertretend zwei MPU6500/MPU9250 an SPI1 (default) und SPI2 eines BlackPill-Boards:

STM32 - Zwei MPU6500/9250 per SPI am BlackPill
Zwei MPU6500/9250 per SPI am BlackPill

Ein SPI-Gerät an den Standardpins

Der Sketch für die Nutzung der Standardpins birgt keine Überraschungen:

#include <SPI.h>
// MPUXXXX registers
#define MPUXXXX_PWR_MGMT_1 0x6B
#define MPUXXXX_ACCEL_XOUT_H 0x3B

char result[7]; // for formatted output
const int CS_PIN = PA4;  // Chip Select


void setup() {
    Serial.begin(115200);
  
    pinMode(CS_PIN, OUTPUT);
    digitalWrite(CS_PIN, HIGH); 
    SPI.begin();

    writeRegister(MPUXXXX_PWR_MGMT_1, 0x00); // wake up MPUXXXX
}

void loop() {
    uint8_t data[6];
    int16_t accX, accY, accZ;
    readRegisters(MPUXXXX_ACCEL_XOUT_H, data, 6);

    accX = (data[0] << 8) | data[1];
    accY = (data[2] << 8) | data[3];
    accZ = (data[4] << 8) | data[5];

    Serial.print("AcX = "); Serial.print(toStr(accX));
    Serial.print(" | AcY = "); Serial.print(toStr(accY));
    Serial.print(" | AcZ = "); Serial.println(toStr(accZ));

    delay(1000);
}

void writeRegister(uint8_t reg, uint8_t data) {
    SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));
    digitalWrite(CS_PIN, LOW);
    SPI.transfer(reg & 0x7F); // write access (MSB = 0)
    SPI.transfer(data);
    digitalWrite(CS_PIN, HIGH);
}

void readRegisters(uint8_t reg, uint8_t* buffer, uint8_t length) {
    SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));
    digitalWrite(CS_PIN, LOW);
    SPI.transfer(reg | 0x80); // read access (MSB = 1)
    for (uint8_t i = 0; i < length; i++) {
        buffer[i] = SPI.transfer(0x00); // send dummy byte        
    }
    digitalWrite(CS_PIN, HIGH);
}

char* toStr(int16_t value) {
    sprintf(result, "%6d", value);
    return result;
}

 

Ein SPI-Gerät an alternativen Pins

Wenn ihr alternative Pins nutzen wollt, dann müsst ihr diese mit SPI.setMOSI(), SPI.setMISO() und SPI.setSCLK() setzen. So zum Beispiel für das BlackPill-Board:

.....
const int CS_PIN = PA15;  // Chip Select

void setup() {
    Serial.begin(115200);
  
    pinMode(CS_PIN, OUTPUT);
    digitalWrite(CS_PIN, HIGH); 
    SPI.setMOSI(PB5); // MOSI1-1
    SPI.setMISO(PB4); // MISO1-3
    SPI.setSCLK(PB3); // SCK1-3
    SPI.begin();
.....

Es geht genauso, wenn ihr eine alternative SPI-Schnittstelle nutzen wollt. Hier beispielsweise an den SPI2 Pins eines BlackPill-Boards: 

.....
const int CS_PIN = PB9;  // Chip Select

void setup() {
    Serial.begin(115200);
  
    pinMode(CS_PIN, OUTPUT);
    digitalWrite(CS_PIN, HIGH); 
    SPI.setMOSI(PB15); // MOSI2
    SPI.setMISO(PB14); // MISO2
    SPI.setSCLK(PB13); // SCK2-4
    SPI.begin();
.....

Mehrere SPI-Schnittstellen nutzen

Wenn ihr mehrere SPI-Schnittstellen nutzen wollt, dann müsst ihr dafür eigene SPI-Objekte erzeugen, denen ihr die SPI-Pins mit dem Konstruktor übergebt. Merkwürdigerweise müsst ihr die SPI-Pins trotzdem noch einmal über die Funktionen setSCLK(), setMISO() und setMOSI() festlegen.

Hier ein Beispiel, das sowohl auf dem BlackPill- als auch auf dem BluePill-Board funktioniert:

#include <SPI.h>
// MPU6500 registers
#define MPU6500_PWR_MGMT_1 0x6B
#define MPU6500_ACCEL_XOUT_H 0x3B

char result[7]; // for formatted output
const int CS_PIN_1 = PA4;  // chip select MPU6500-1
const int CS_PIN_2 = PB9;  // chip select MPU6500-2

/* create two SPI instances */
SPIClass SPI_1(PA5, PA6, PA7); // SCLK, MISO, MOSI
SPIClass SPI_2(PB13, PB14, PB15); // SCLK, MISO, MOSI
  

void setup() {
    Serial.begin(115200);
    SPI_1.setMOSI(PA7);
    SPI_1.setMISO(PA6);
    SPI_1.setSCLK(PA5);
    SPI_2.setMOSI(PB15);
    SPI_2.setMISO(PB14);
    SPI_2.setSCLK(PB13);

    pinMode(CS_PIN_1, OUTPUT);
    digitalWrite(CS_PIN_1, HIGH); 
    pinMode(CS_PIN_2, OUTPUT);
    digitalWrite(CS_PIN_2, HIGH);
    SPI_1.begin(); 
    SPI_2.begin();

    writeRegister(&SPI_1, CS_PIN_1, MPU6500_PWR_MGMT_1, 0x00); // wake up MPU6500-1
    writeRegister(&SPI_2, CS_PIN_2, MPU6500_PWR_MGMT_1, 0x00); // wake up MPU6500-2
}

void loop() {
    uint8_t data[6];
    int16_t accX_1, accY_1, accZ_1, accX_2, accY_2, accZ_2;
    readRegisters(&SPI_1, CS_PIN_1, MPU6500_ACCEL_XOUT_H, data, 6);

    accX_1 = (data[0] << 8) | data[1];
    accY_1 = (data[2] << 8) | data[3];
    accZ_1 = (data[4] << 8) | data[5];

    Serial.print("AcX_1 = "); Serial.print(toStr(accX_1));
    Serial.print(" | AcY_1 = "); Serial.print(toStr(accY_1));
    Serial.print(" | AcZ_1 = "); Serial.println(toStr(accZ_1));
    Serial.flush();

    readRegisters(&SPI_2, CS_PIN_2, MPU6500_ACCEL_XOUT_H, data, 6);
    
    accX_2 = (data[0] << 8) | data[1];
    accY_2 = (data[2] << 8) | data[3];
    accZ_2 = (data[4] << 8) | data[5];

    Serial.print("AcX_2 = "); Serial.print(toStr(accX_2));
    Serial.print(" | AcY_2 = "); Serial.print(toStr(accY_2));
    Serial.print(" | AcZ_2 = "); Serial.println(toStr(accZ_2));
    Serial.println();

    delay(1000);
}

void writeRegister(SPIClass *_spi, int _csPin, uint8_t reg, uint8_t data) {
    _spi->beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));
    digitalWrite(_csPin, LOW);
    _spi->transfer(reg & 0x7F); // write access (MSB = 0)
    _spi->transfer(data);
    digitalWrite(_csPin, HIGH);
}

void readRegisters(SPIClass *_spi, int _csPin, uint8_t reg, uint8_t* buffer, uint8_t length) {
    _spi->beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0));
    digitalWrite(_csPin, LOW);
    _spi->transfer(reg | 0x80); // read access (MSB = 1)
    for (uint8_t i = 0; i < length; i++) {
        buffer[i] = _spi->transfer(0x00); // send dummy byte        
    }
    digitalWrite(_csPin, HIGH);
}

char* toStr(int16_t value) {
    sprintf(result, "%6d", value);
    return result;
}

 

HardwareSerial

Im vorherigen Beitrag habe ich gezeigt, wie ihr das „Standard-Serial“ aktiviert und dafür sorgt, dass ihr den seriellen Monitor über USB erreicht. Aber die STM32-Boards haben mehrere HardwareSerial-Schnittstellen, die ihr nutzen könnt.

  • BluePill (STM32F103C8) / BlackPill (STM32F411): 3
  • Nucleo-L432KC: 2 (plus Standard-Serial über USB)
  • Nucleo-F446RE: 6 (plus Standard-Serial über USB)
  • Arduino GIGA R1 WIFI: 4 (plus Standard-Serial über USB)

Die zusätzlichen HardwareSerial-Schnittstellen zu nutzen, ist einfach. In einem praktischen Beispiel lassen wir ein BluePill- und ein BlackPill-Board über HardwareSerial kommunizieren.

Hier zunächst der Aufbau:

Hardware Serial Verbindung: BluePill und BlackPill
Hardware Serial Verbindung: BluePill und BlackPill

Und hier der Sketch, den ihr auf beide Boards hochladet:

HardwareSerial mySerial(PA3, PA2); // RX, TX

void setup() {
    Serial.begin(115200);
    mySerial.begin(115200);
}

void loop() { 
    if (mySerial.available()) {
        Serial.write(mySerial.read());
    }
    if (Serial.available()) {
        mySerial.write(Serial.read());
    }
}

Für jeden Sketch macht ihr einen seriellen Monitor auf und könnt dann Nachrichten von einem zum anderen Monitor schicken.

Zusatzwissen: Die Standard-Serial Schnittstelle der Nucleo-Boards wird durch den Virtual COM Port des On-Board ST-LINK-Programmers realisiert.

Besonderheit des Arduino GIGA R1 WIFI

Wenn ihr den Arduino GIGA R1 WIFI mit dem Arduino Boardpaket nutzt, dann sind die HardwareSerial-Objekte samt Pins schon vordefiniert. Serial1 ist RX0/TX0 zugeordnet, Serial2 ist RX1/TX1 zugeordnet usw. 

SoftwareSerial

Das Arduino-Core Paket von stm32duino beinhaltet eine SoftwareSerial-Bibliothek. Aber wenn möglich, dann solltet ihr immer mit den HardwareSerial-Schnittstellen arbeiten. Das ist grundsätzlich sicherer und ressourcenschonender.

Hinzu kommt, dass SoftwareSerial auf den STM32-Boards nach meiner Erfahrung nur mit niedrigen Baudraten fehlerfrei läuft. Auf meinen BluePill- und BlackPill-Boards funktionierte es nur bis 57600. Beim Nucleo-L432KC war bei 38400 Schluss. 

Trotzdem hier noch ein Beispielsketch:

#include <SoftwareSerial.h>
SoftwareSerial mySerial(A5, A6); // RX, TX

void setup() {
    Serial.begin(9600);
    mySerial.begin(9600);
}

void loop() { 
    if (mySerial.available()) {
        Serial.write(mySerial.read());
    }
    if (Serial.available()) {
        mySerial.write(Serial.read());
    }
}

Independent Watchdog (IWDG)

Die STM32-Mikrocontroller haben einen Independent Watchdog (IWDG) und einen Window-Watchdog (WWDG). Der IWDG heißt independent (unabhängig), weil er komplett unabhängig vom Rest des Mikrocontrollers läuft. So unabhängig, dass er – einmal gestartet – nicht mehr per Software gestoppt werden kann. Der IWDG wird über einen Taktgeber gesteuert, der vom Systemtakt unabhängig ist. Das alles macht ihn ausgesprochen sicher.

Der Window-Watchdog hat seinen Namen von seiner Eigenschaft, nur in einem bestimmten Fenster „gefüttert“ werden zu können. WWDG und IWDG lösen einen Reset aus, wenn der Timeout erreicht ist. Aber nur der WWDG löst einen Reset aus, wenn er zu früh gefüttert wird.

Auch für den IWDG lässt sich eine Zeit definieren, in der sich der Watchdog nicht füttern lässt. Allerdings führt das verfrühte Füttern (der Reload) nicht zu einem Reset, sondern wird einfach nur ignoriert. Dieses verbotene Fenster lässt sich allerdings nicht bei allen STM32-Boards aktivieren.

Der IWDG hat im STM32-Boardpaket eine eigene Klasse spendiert bekommen, die sich einfach bedienen lässt. Für die Nutzung des WWDG müsst ihr eine Stufe tiefer auf die HAL-Funktionen gehen, was den Rahmen dieses Beitrags sprengen würde.

IWDG Beispielsketch

Nehmt ein STM32 Board eurer Wahl und hängt einen Taster an einen geeigneten Pin. Die andere Seite des Tasters verbindet ihr mit GND, also z. B. so: 

STM32 - IWDG Test
IWDG-Test

Wenn ihr ein BlackPill-Board nehmt, dann braucht ihr für den Beispielsketch unten keinen Taster, sondern könnt die KEY-Taste nutzen. Sie hängt zwischen PA0 und GND.

Der Beispielsketch löst nach 10 Sekunden einen Reset aus, sofern der IWDG nicht durch einen Druck auf den Taster gefüttert wurde. Nach einem durch den IWDG verursachten Reset blinkt die Board-LED fünfmal kurz auf.

#include <IWatchdog.h>
const int buttonPin = PA0; // BlackPill KEY pin
const int ledPin = PC13; // Board LED

void setup() {
    pinMode(ledPin, OUTPUT);
    digitalWrite(ledPin, HIGH); // BluePill/BlackPill board LED off 
    pinMode(buttonPin, INPUT_PULLUP);
 
    if (IWatchdog.isReset(true)) {
        // LED blinks to indicate watchdog reset
        for (uint8_t i = 0; i < 5; i++) {
            digitalWrite(ledPin, LOW);
            delay(100);
            digitalWrite(ledPin, HIGH);
            delay(100);
        }
    }

    // Init the watchdog timer with 10 seconds timeout
    IWatchdog.begin(10000000); // timeout is uint32_t
    // 10 s timeout, but ignore reload with the first 5 s
    // IWatchdog.begin(10000000, 5000000); // does not work with all STM32 MCUs

    if (!IWatchdog.isEnabled()) {
        // LED blinks indefinitely
        while (1) {
            digitalWrite(ledPin, LOW);
            delay(500);
            digitalWrite(ledPin, HIGH);
            delay(500);
        }
  }
}

void loop() {
    // Compare current button state of the pushbutton value:
    if (digitalRead(buttonPin) == LOW) {
        digitalWrite(ledPin, LOW);
        delay(1000);
        digitalWrite(ledPin, HIGH);
        // Uncomment to change timeout value to 6 seconds
        //IWatchdog.set(6000000);

        // Reload the watchdog only when the button is pressed
        IWatchdog.reload();
    } 
}

 

Erklärungen zum Beispielsketch:

Das IWDG-Objekt heißt IWatchdog und ist vordefiniert. Mit isReset() prüft ihr, ob der IWDG einen Reset ausgelöst hat. Wenn ihr der Funktion true übergebt, dann wird das Reset-Flag gelöscht. Übergebt ihr nichts oder false, dann bleibt das Reset-Flag stehen.

Ihr startet den IWDG mit begin(). Als Parameter übergebt ihr den Timeout in Mikrosekunden. Der optionale zweite Parameter legt die Länge des „verbotenen“ Fensters fest, in dem die Fütterung ignoriert wird. Wie schon erwähnt funktioniert das nicht mit allen STM32-Boards.

Jetzt fehlt nur noch:

  • isEnabled() prüft, ob der IWDG aktiv ist.
  • set() erlaubt euch, den Timeout neu festzulegen.
  • Mit reload() füttert ihr den Watchdog, sodass die Timeoutperiode wieder von neuem beginnt. 

Real Time Clock (RTC)

Die STM32-Boards bzw. die zugrunde liegenden Mikrocontroller besitzen eine Echtzeituhr. Die Zeit wird also nicht nur gemessen (das kann ja im Prinzip jeder Mikrocontroller), sondern als Jahr, Monat, Tag, Stunde, Minute und Sekunde verarbeitet.

Das stm32duino-Boardpaket hat keine integrierte RTC-Bibliothek. Ich empfehle die Verwendung von STM32duino RTC. Ihr könnt sie über den Bibliotheksmanager der Arduino IDE installieren.

Das Boardpaket für den Arduino GIGA R1 WIFI hat die RTC-Funktionen schon integriert. Im User Manual findet ihr sehr hilfreiche Beispielsketche. Ich gehe in diesem Beitrag nur auf die STM32duino_RTC Bibliothek ein.

Jede Uhr braucht einen Taktgeber. Dafür stehen euch drei Optionen zur Verfügung:

  • LSI_CLOCK (Low Speed Internal Clock): interner, recht ungenauer Oszillator mit ca. 32 kHz. Dieser Taktgeber ist in der Bibliothek voreingestellt. 
  • LSE_CLOCK (Low Speed External Clock): die meisten STM32-Boards besitzen einen externen Quarz, der mit 32768 Hz schwingt. In der Regel sind diese sehr genau. Diese Option solltet ihr wählen.
  • HSE_CLOCK (High-Speed External Clock): Nutzt den schnellen, externen Quarz auf dem Board (in der Regel 8 MHz).
  • (HSI_CLOCK) (High-Speed Internal Clock): Schneller, interner Oszillator, ziemlich ungenau. Wird nicht von der Bibliothek unterstützt.

RTC-Beispielsketch

Die Bibliothek beinhaltet eine Reihe schöner Beispielsketche. Zum Einstieg empfehle ich SimpleRTC.ino. Ich denke, der Sketch ist selbsterklärend. 

/*
  SimpleRTC

  This sketch shows how to configure the RTC and to display
  the date and time periodically

  Creation 12 Dec 2017
  by Wi6Labs
  Modified 03 Jul 2020
  by Frederic Pillon for STMicroelectronics

  This example code is in the public domain.

  https://github.com/stm32duino/STM32RTC
*/

#include <STM32RTC.h>

/* Get the rtc object */
STM32RTC& rtc = STM32RTC::getInstance();

/* Change these values to set the current initial time */
const byte seconds = 0;
const byte minutes = 0;
const byte hours = 16;

/* Change these values to set the current initial date */
/* Monday 15th June 2015 */
const byte weekDay = 1;
const byte day = 15;
const byte month = 6;
const byte year = 15;

void setup()
{
  Serial.begin(9600);

  // Select RTC clock source: LSI_CLOCK, LSE_CLOCK or HSE_CLOCK.
  // By default the LSI is selected as source.
  //rtc.setClockSource(STM32RTC::LSE_CLOCK);

  rtc.begin(); // initialize RTC 24H format

  // Set the time
  rtc.setHours(hours);
  rtc.setMinutes(minutes);
  rtc.setSeconds(seconds);

  // Set the date
  rtc.setWeekDay(weekDay);
  rtc.setDay(day);
  rtc.setMonth(month);
  rtc.setYear(year);

  // you can use also
  //rtc.setTime(hours, minutes, seconds);
  //rtc.setDate(weekDay, day, month, year);
}

void loop()
{
  // Print date...
  Serial.printf("%02d/%02d/%02d ", rtc.getDay(), rtc.getMonth(), rtc.getYear());

  // ...and time
  Serial.printf("%02d:%02d:%02d.%03d\n", rtc.getHours(), rtc.getMinutes(), rtc.getSeconds(), rtc.getSubSeconds());

  delay(1000);
}

 

Hier die Ausgabe:

Ausgabe des Bibliothekssketches SimpleRTC.ino
Ausgabe des Bibliothekssketches SimpleRTC.ino

Es lohnt sich, auch die anderen Beispielsketche der Bibliothek durchzugehen. Unter anderem lernt ihr dort, wie ihr einen Alarminterrupt programmiert. Ferner empfehle ich einen Blick in das README.md der Bibliothek, denn dort sind alle Funktionen erklärt.

Was ich in der Bibliothek vermisste, war eine Routine zum Stellen der RTC nach der Kompilierzeit. Die RTC geht dann zwar ein paar Sekunden nach, aber man ist schon einmal in der Nähe der aktuellen Uhrzeit. Ich habe SimpleRTC.ino entsprechend erweitert. Ihr findet den Sketch im Anhang.

Wie genau ist die RTC?

Die Genauigkeit der RTC hängt von der Genauigkeit ihres Taktgebers ab. Mit dem internen Oszillator (LSI) war das Ergebnis miserabel. Die Abweichung betrug bei meinen Messungen bis zu einer Sekunde pro Minute! Mit der HSI-Clock sah es nicht besser aus.

Bessere Ergebnisse gab es hingegen mit dem externen Quarz (LSE). Die Abweichungen lagen im Bereich einiger Sekunden pro Tag – immerhin. Zum regelmäßigen Nachstellen würde sich ein DCF77-Modul oder – insbesondere für den Arduino GIGA R1 WIFI – ein NTP-Server anbieten.

Für das BluePill- und das BlackPill-Board wird an vielen Stellen empfohlen, die Pins PC14 und PC15 an der Pinleiste zu entfernen, damit der externe On-Board-Quarz ungestörter und damit genauer schwingen kann. Ich habe das aber nicht weiter untersucht.

Low-Power Modi

Wenn ihr für batteriebetriebene Projekte wertvollen Strom sparen wollt, dann stehen euch dazu verschiedene Energiesparmodi zur Verfügung. Diesbezüglich empfehle ich euch die Bibliothek STM32duino Low Power, die ihr über den Bibliotheksmanager der Arduino IDE installieren könnt.

Die verfügbaren Modi sind: 

  • idle(): Niedrige Aufwach-Latenz (µs-Bereich). Speicher und Spannungsversorgung bleiben erhalten. Minimale Energieeinsparung hauptsächlich beim Kern selbst.
  • sleep(): Niedrige Aufwach-Latenz (µs-Bereich). Speicher und Spannungsversorgung bleiben erhalten. Minimale Energieeinsparung hauptsächlich beim Kern selbst, aber höher als im Idle-Modus.
  • deepSleep(): Mittlere Aufwach-Latenz (ms-Bereich). Taktfrequenzen werden reduziert. Speicher und Spannungsversorgung bleiben erhalten. Wenn unterstützt, ist Peripherie-Wake-up möglich (UART, I2C …).
  • shutdown(): Hohe Aufwach-Latenz (möglicherweise Hunderte von ms). Die Spannungsversorgung wird unterbrochen (außer im „Always-on“-Bereich), der Speicherinhalt geht verloren und das System wird neu gestartet.

Die „Weckmethoden“ sind:

  • Feste Zeitspanne,
  • RTC-Alarm,
  • externer Interrupt an WakeUp-Pin,
  • Peripherie-Ereignis (UART, I2C usw.).

Die Low-Power-Bibliothek ist mit einigen Beispielsketchen ausgestattet, die die verschiedenen Weckmethoden illustrieren. Hier ein Beispiel für das Aufwecken nach festgelegter Zeitspanne:

#include "STM32LowPower.h"
const unsigned long sleepTime = 2000; // sleeping time in ms

void setup() {
    pinMode(LED_BUILTIN, OUTPUT);
    for(int i=0; i<5; i++){
        digitalWrite(LED_BUILTIN, LOW);
        delay(50);
        digitalWrite(LED_BUILTIN, HIGH);
        delay(50);
    }
    LowPower.begin();
}

void loop() {
    digitalWrite(LED_BUILTIN, LOW);
    LowPower.deepSleep(sleepTime);
    // LowPower.shutdown(sleepTime); // try this instead of deepSleep
    digitalWrite(LED_BUILTIN, HIGH);
    LowPower.deepSleep(sleepTime);
}

Der Sketch schaltet die Board-LED alle zwei Sekunden an bzw. aus. Dazwischen geht es in den DeepSleep-Modus. Wenn ihr Zeile 17 aus- und Zeile 18 entkommentiert, dann geht es stattdessen in den Shutdown Modus, der mit einem Reset endet. Den Reset erkennt ihr daran, dass setup()⁣ ausgeführt wird und die LED schnell blinkt. 

Stromverbrauch im Schlafmodus

Ich habe ein paar Strommessungen im DeepSleep- und im Shutdown-Modus mit BluePill- und BlackPill-Boards durchgeführt.

Da sich der Stromverbrauch der Power-LED im Shutdown-Modus besonders stark bemerkbar macht, habe ich die Versuche in diesem Modus zunächst mit LED durchgeführt und dann die LED vom Board entfernt. 

Stromverbrauch von BluePill und BlackPill im DeepSleep- und Shutdown-Modus
Stromverbrauch von BluePill und BlackPill im DeepSleep- und Shutdown-Modus

Auf den Nucleo-Boards ist der ST-LINK ein erheblicher Stromfresser. Man kann ihn durch Einstellungen der Solder Bridges abklemmen, aber ich hatte schlicht keine Lust, das auszuprobieren. Auch den Arduino GIGA R1 WIFI habe ich diesbezüglich nicht unter die Lupe genommen.

Anhang – Erweiterter RTC-Sketch

Ich habe den Beispielsketch SimpleRTC.ino erweitert, sodass er die RTC nach dem Zeitpunkt des Kompilierens stellt. Dieser Zeitpunkt ist in den Zeichenketten __DATE__ und __TIME__ gespeichert. Beispiel: „Aug 11 2025“ und „22:17:25“. Es ist relativ einfach, daraus das Jahr, den Tag, die Stunden, Minuten und Sekunden zu extrahieren. __TIME__[1] ist beispielsweise die zweite Ziffer der Stunden, allerdings als ASCII-Code (z.B. 2 = ASCII-Code 50). Deswegen wird jeweils der ASCII-Code von 0 (= 48) abgezogen → __TIME__[1] – ‚0‘. 

Der Monat wird durch einen schlichten Stringvergleich ermittelt. Die Berechnung des Wochentages ist ein wenig schwieriger. Darüber haben sich gottlob schon schlaue Leute Gedanken gemacht. Mehr dazu in diesem Wikipedia-Artikel. Ich verwende den Algorithmus von Tomohiko Sakamoto. Allerdings liefert er die Wochentage als Zahlen von 0 (= So) bis 6 (= Sa), und die STM32duino-RTC-Lib erwartet Zahlen von 1 (= Mo) bis 7 (= So). Das korrigiert Zeile 41.

Ich habe dann noch eine Funktion zum genaueren Stellen der Uhrzeit eingebaut. Stellt dazu im seriellen Monitor „Kein Zeilenende“ ein. Dann gebt die Uhrzeit im Format hh:mm:ss ein.

#include <STM32RTC.h>

#define MONTH_IS(str) (__DATE__[0] == str[0] && __DATE__[1] == str[1] && __DATE__[2] == str[2])

/* get month from __DATE__ */
#define BUILD_MONTH ( \
    MONTH_IS("Jan") ?  1 : \
    MONTH_IS("Feb") ?  2 : \
    MONTH_IS("Mar") ?  3 : \
    MONTH_IS("Apr") ?  4 : \
    MONTH_IS("May") ?  5 : \
    MONTH_IS("Jun") ?  6 : \
    MONTH_IS("Jul") ?  7 : \
    MONTH_IS("Aug") ?  8 : \
    MONTH_IS("Sep") ?  9 : \
    MONTH_IS("Oct") ? 10 : \
    MONTH_IS("Nov") ? 11 : \
    MONTH_IS("Dec") ? 12 : 0 )

/* get day and year from __DATE__ */
#define BUILD_DAY  (((__DATE__[4] == ' ') ? 0 : __DATE__[4] - '0') * 10 + (__DATE__[5] - '0'))
#define BUILD_YEAR  ((__DATE__[7]-'0') * 1000 + (__DATE__[8]-'0') * 100 + (__DATE__[9]-'0') * 10 + (__DATE__[10]-'0'))
/* get hour, minute and second from __TIME__ */
#define BUILD_HOUR  ((__TIME__[0]-'0')*10 + (__TIME__[1]-'0'))
#define BUILD_MIN   ((__TIME__[3]-'0')*10 + (__TIME__[4]-'0'))
#define BUILD_SEC   ((__TIME__[6]-'0')*10 + (__TIME__[7]-'0'))

#define SAKAMOTO_T(m) \ 
  ((m)==1?0:(m)==2?3:(m)==3?2:(m)==4?5:(m)==5?0:(m)==6?3:\
   (m)==7?5:(m)==8?1:(m)==9?4:(m)==10?6:(m)==11?2:(m)==12?4:0)

/* Sunday = 0, ..., Saturday = 6 (Original-Sakamoto) */
#define WD_SUN0(y,m,d) \
  (((((y) - ((m) < 3))                                         \
    + (((y) - ((m) < 3)) / 4)                                  \
    - (((y) - ((m) < 3)) / 100)                                \
    + (((y) - ((m) < 3)) / 400)                                \
    + SAKAMOTO_T(m) + (d)) % 7))

/* Needed for the STM32 RTC lib: Monday = 1, ..., Sunday = 7 */
#define WD_MON1(y,m,d)  ((((WD_SUN0((y),(m),(d)) + 6) % 7) + 1))
#define BUILD_WEEKDAY  WD_MON1(BUILD_YEAR, BUILD_MONTH, BUILD_DAY)

char daysOfTheWeek[7][12] = {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"};

/* Get the rtc object */
STM32RTC& rtc = STM32RTC::getInstance();

/* time and date variables */
byte seconds = BUILD_SEC;
byte minutes = BUILD_MIN;
byte hours = BUILD_HOUR;
byte weekDay = BUILD_WEEKDAY;
byte day = BUILD_DAY;
byte month = BUILD_MONTH;
byte year = BUILD_YEAR - 2000;

void setup() {
    Serial.begin(115200);

    // Select RTC clock source: LSI_CLOCK, LSE_CLOCK or HSE_CLOCK.
    // By default the LSI is selected as source.
    rtc.setClockSource(STM32RTC::LSE_CLOCK);

    rtc.begin(); // initialize RTC 24H format

    // Set the time
    rtc.setHours(hours);
    rtc.setMinutes(minutes);
    rtc.setSeconds(seconds);

    // Set the date
    rtc.setWeekDay(weekDay);
    rtc.setDay(day);
    rtc.setMonth(month);
    rtc.setYear(year);

    // you can use also
    //rtc.setTime(hours, minutes, seconds);
    //rtc.setDate(weekDay, day, month, year);
}

void loop() {
    static unsigned long lastPrint = 0;
    
    if (millis() - lastPrint >= 1000){
        // Print date...
        Serial.printf("%s %02d/%02d/%02d ", daysOfTheWeek[weekDay-1], rtc.getDay(), rtc.getMonth(), rtc.getYear());
        // ...and time
        Serial.printf("%02d:%02d:%02d.%03d\n", rtc.getHours(), rtc.getMinutes(), rtc.getSeconds(), rtc.getSubSeconds());
        lastPrint = millis();
    }

    if (Serial.available()){  // set serial monitor to "No Line Ending"
        hours = Serial.parseInt();
        minutes = Serial.parseInt();
        seconds = Serial.parseInt();
        rtc.setTime(hours, minutes, seconds);
    }
}

 

So sollte dann die Ausgabe aussehen:

Ausgabe von rtc_advanced.ino
Ausgabe von rtc_advanced.ino

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert