Warten und Arbeiten

Programmieren

Wie ist es möglich, zu warten und gleichzeitig einen Knopfdruck zu erkennen? In diesem Tutorial wird eine mögliche Lösung für dies Problem vorgestellt.

Das Problem

Letztes Mal kamen wir zu dem Schluss, dass unsere Ampel verbessert werden muss. Das Problem ist, dass die Ampel sofort umschaltet, wenn jemand den Knopf drückt. Am Ende wären vermutlich viele Autofahrer ziemlich verärgert, weil die Ampel immer sofort wieder rot wird. Die Lösung ein Cooldown, der sicherstellt, dass die Grünphase immer lang genug ist. Aber wie setzen wir das um?

Das eigentliche Problem liegt darin, dass wir nicht gleichzeitig mit delay warten und einen Knopfdruck erkennen können. Da der Arduino ein Einkernprozessor ist, kann er nicht zwei Aufgaben gleichzeitig ausführen. Trotzdem gibt es einige Möglichkeiten, unser Ziel zu erreichen und wir werden uns jetzt angucken, wie das möglich ist.

Mögliche Lösungen

Es gibt mehrere mögliche Lösungen. Was dir vielleicht auffällt, ist die Tatsache, dass es tatsächlich möglich ist, mehrere Programme auf einem alten Computer mit nur einem Kern auszuführen. Dies macht jedoch die Tatsache, dass eine Single-Core-CPU immer nur eine Aufgabe gleichzeitig ausführen kann, nicht ungültig. Diese Illusion wird durch Hin- und Herwechseln zwischen verschiedenen Programmen erreicht. Wenn dies schnell genug durchgeführt wird, merkt man als Nutzer nicht, dass immer nur ein Programm zurzeit auf der CPU ausgeführt wird. Die Frage ist, können wir das auch beim Arduino so machen? Nun, theoretisch ja. Das Problem ist, dass dies eine sehr ungewöhnliche Sache wäre, die von der Arduino-Umgebung für das Arduino Uno nicht unterstützt wird. Es wäre theoretisch möglich, dies zu implementieren, aber das sprengt den Rahmen dieser Einführungsserie bei weitem.

Was sind die Alternativen? Wir können zwei Dinge parallel tun, wenn wir die Hardware eines davon übernehmen lassen. Klassischerweise würde man die Hardware auf den Knopfdruck warten lassen. Im Falle eines tatsächlichen Tastendrucks wird die Ausführung anderer Befehle von der CPU dann unterbrochen und eine spezielle Funktion zum Behandeln dieses Tastendrucks aufgerufen. Diese Technik wird Interrupts genannt. Interrupts und die zugehörigen Funktionen zum Behandeln von Interrupts sind etwas knifflig zu verwenden, deshalb habe ich mich entschlossen, sie in dieser Einführungsserie nicht zu verwenden. Es gibt keinen Grund, an dieser Stelle traurig zu sein, denn wir haben noch eine weitere Möglichkeit übrig.

Wir können die Hardware die Zeit messen lassen, die seit Beginn der grünen Phase vergangen ist. Zu diesem Zweck verfügt der Arduino Uno über mehrere Hardware-Timer. Einer dieser Timer läuft standardmäßig und kann verwendet werden, um Zeitstempel in Millisekunden zu erhalten. Arduino stellt eine Funktion namens millis zur Verfügung, die es uns ermöglicht, einen solchen Zeitstempel zu erhalten.
Wie immer, hier eine kurze Zusammenfassung dieser Funktion:

  • millis()
    • Gibt einen Zeitstempel in Millisekunden zurück

Der Code

Wie können wir diese neue Funktion in unserem Code verwenden? Nun, der zurückgegebene Zeitstempel hat keinen absoluten Bezugspunkt. Man kann ihn nicht zur Zeitangabe verwenden, aber er ermöglicht es uns, eine Zeitdifferenz zwischen einem Start- und einem Endzeitpunkt zu berechnen. Dazu müssen wir den Zeitstempel zu Beginn der grünen Phase speichern. Der Zeitpunkt gekommen das wir uns mit den Variablen vertraut machen. Eine Variable erlaubt es uns, einen Wert zu speichern und ihn später unter einem von uns definierbaren Namen zu referenzieren. Eine C++-Variable hat immer einen festen Typ, und da wir noch nicht über Typen gesprochen haben, sollten wir das zuerst tun.

Es gibt eine Vielzahl von Typen, und du kannst sie sogar selbst definieren. Diese Typen sind jedoch aus nur wenigen Grundtypen zusammengesetzt. Alles auf einem Computer setzt sich aus Einsen und Nullen zusammen. Für die Benutzerfreundlichkeit und für Leistungsoptimierungen gibt es in der Praxis einige weitere Datentypen auf höherer Ebene. Es gibt zwei Arten von Zahlen, die unterschieden werden müssen: Ganze Zahlen und reelle Zahlen. Bei reellen Zahlen liegen unendlich viele weitere Zahlen zwischen zwei Zahlen wie eins und zwei. Das macht es sehr schwierig, diese Zahlen darzustellen, und deshalb sind sie eine völlig andere Art von Zahlen in der Informatik. Tatsächlich können Computer sie nur bis zu einem gewissen Grad der Genauigkeit darstellen. In C++ gibt es den Standardtyp float und den genaueren Typ double. Je genauer, desto schwieriger ist es, diese Typen in Berechnungen zu verwenden. Für alle Mathe-Nerds unter uns: Fließkommazahlen werden in drei Teilen dargestellt, dem Vorzeichen, einer Mantisse und einem Exponenten zur Basis zwei. Für alle, die es nicht interessiert, werde ich die Konsequenzen kurz skizzieren: Man kann sehr große Zahlen in einer Fließkommazahl problemlos ausdrücken, aber die Genauigkeit wird dann ziemlich gering sein, was bedeutet, dass du in diesem Bereich auf korrekte Dezimalziffern verzichten musst. Auf der anderen Seite erhält man eine ziemlich gute Genauigkeit und eine Menge Dezimalstellen für kleine Zahlen. Dies ist ein großer Segen für wissenschaftliche Berechnungen. Es bedeutet aber auch, dass einige Zahlen überhaupt nicht hundertprozentig genau dargestellt werden können. Aufgrund der Ungenauigkeit in der Zahlendarstellung gibt es immer einige kleine Diskrepanzen bei Berechnungen, und aus diesem Grund kann es gefährlich sein, Fließkommazahlen auf Gleichheit zu überprüfen. Es kann sein, dass sie nicht genau übereinstimmen, sondern stattdessen nur verdammt nahe beieinander liegen, selbst wenn sie mathematisch gleich sein sollten. Wegen der möglichen Ungenauigkeit solltest du zudem keine Fließkommazahlen Nutzen, wenn du mit Geld rechnest. Verwende stattdessen eine ganze Zahl in Cents.

Ganze Zahlen sind viel einfacher zu handhaben. Was sind die Typen, die wir für ganze Zahlen nutzen können? Hier ein Überblick:

Typ Zahlenbereich
char (int8_t) -128 bis 128
unsigned char (uint8_t) 0 bis 255
short/int (int16_t) -32768 bis 32767
unsigned short/unsigned int (uint16_t) 0 bis 65535
long (int32_t) -2147483648 bis 2147483647
unsigned long (uint32_t) 0 bis 4294967295
long long (int64_t) -9223372036854775808 bis 9223372036854775807
unsigned long long (uint64_t) 0 bis 18446744073709551615

Warum all diese verschiedenen Typen? Warum nicht einfach eine vorzeichenbehaftete 64-Bit-Ganzzahl für alles verwenden? Der Grund ist die langsame Geschwindigkeit, in der der Mikrocontroller mit diesen Zahlen rechnen kann. Da der Arduino Uno einen 8-Bit-Mikrocontroller verwendet und mit diesen hohen Zahlen nicht zurechtkommt, muss sehr viel Arbeit in der Software geleistet werden. Der Code dafür wird vom Compiler hinzugefügt, so können wir diese Typen trotzdem verwenden. Dies erhöht jedoch nicht nur den Rechen-Overhead, sondern erfordert auch mehr Speicherplatz in RAM und Flash. Auf dem Arduino haben wir nur eine begrenzte Menge davon, folglich sollten wir nur einen großen Datentyp verwenden, wenn wir große Werte erwarten. Wenn wir nur positive Zahlen erwarten, können wir das Maximum aus dem Datentyp herausholen, wenn wir die vorzeichenlose unsigned-Variante nutzen.

Für diejenigen unter euch, die bereits auf anderen Plattformen programmiert haben, könnte gibt es eine kleine Falle: Auf normalen Computern bezieht sich der Typ int auf eine 32-Bit-Ganzzahl, auf dem Arduino ist es nur eine 16-Bit Ganzzahl. In der Computergeschichte wurde der Typ int so definiert, dass er der Wortgröße des Prozessors entspricht. Heute weiß das niemand mehr, und es wird auf verschiedenen Plattformen nicht mehr einheitlich umgesetzt. Aus diesem Grund gibt es alternative Namen wie int32_t, um deutlich zu machen, das ein 32-Bit-Integer verwendet werden soll. Es mag ein guter Stil sein, nur diese alternativen Namen zu verwenden, aber in der Praxis wirst du oft auch die anderen Namen finden. Ich werde diese alternativen Namen nicht verwenden, da sie in Arduino-Code, der an anderen Stellen zu finden ist, meist auch nicht genutzt werden.

Was passiert, wenn eine Zahl nicht in den Bereich eines Integer-Typs passt? In diesem Fall erhalten wir einen Integer-Überlauf. Das bedeutet, dass die Zählung wieder bei der niedrigsten Zahl des Typs beginnt. Wenn ich eine 'unsigned char'-Variable mit einem Wert von 255 um eins erhöhe, erhalte ich als Ergebnis null. Wenn ich sie um zwei erhöhe, erhalte ich als Ergebnis eins. Bei vorzeichenbehafteten Datentypen erhalten wir negative Zahlen. Tatsächlich wird für das Ergebnis unserer millis-Funktion unsigned long als Datentyp verwendet und der Zeitstempelwert wird nach etwas weniger als 50 Tagen überlaufen. Glücklicherweise befassen wir uns in unserem Beispiel nicht mit diesen langen Zeitspannen, sodass wir dieses Problem in diesem Tutorial ignorieren können. Was passieren würde ist, dass es nach 50 Tagen eine kleine Zeitspanne gibt, in der der Cooldown wieder von vorne beginnt und wir erneut warten müssen. Es ist unwahrscheinlich, dass es jemand merken würde, aber bei Anwendungen in der realen Welt sollte sorgfältig darüber nachgedacht werden, ob ein Integer-Überlauf dem Projekt schadet und ob zusätzliche Maßnahmen nötig sind, um mit diesem Fall umzugehen.

In C++ verwenden wir auch den Typ bool, der einen Wert von entweder true (wahr) oder false (falsch) erlaubt und zur Speicherung boolescher Werte verwendet wird. Und was ist mit Dingen wie Texten? Nun, jeder Buchstabe wird als char dargestellt. Das gibt diesem Datentyp seinen Namen (char für engl. Zeichen). Ein Text oder eine Zeichenkette, wie wir sie in der Programmierung nennen, besteht aus einer Menge von Zeichen direkt hintereinander. Ein Array vom Typ char kann verwendet werden, um dies darzustellen. Ich werde Arrays zu einem späteren Zeitpunkt behandeln, wir brauchen sie hier noch nicht.

Wie verwendet man einen Typ zusammen mit einer Variablen? Wir können eine Variable für unseren Startzeitstempel deklarieren, indem wir ihren Typ und ihren Namen schreiben:

unsigned long startTime;

Wir können auch einen Anfangswert zuweisen. In unserem Fall ist dies der Startzeitstempel. Diesen erhalten wir in unserem Fall, indem wir die Funktion millis aufrufen. Natürlich kann einer Variable auch ein numerischer Wert oder eine andere Variable zugewiesen werden, solang der Typ dies erlaubt.

unsigned long startTime = millis();

Wenn wir keinen Anfangswert zuweisen, ist der Wert undefiniert. An diesem Punkt unterscheidet sich C++ von anderen Programmiersprachen. In Java würden wir in diesem Fall einen Wert von null erhalten. In C++ sollte man sich nicht darauf verlassen, dass eine nicht initialisierte Variable einen bestimmten Wert hat.

Eine Variablendeklaration hat einen bestimmten Geltungsbereich, in dem sie verwendet werden kann. Die Variable wird für den aktuellen Codeblock und alle Untercodeblöcke deklariert. Wenn eine Variable auf oberster Ebene außerhalb aller Funktionen deklariert wird, wird sie als globale Variable bezeichnet und ist in allen Funktionen verwendbar. Wenn du eine Variable in einer Funktion oder in einer Schleife deklarierst, ist sie nur innerhalb dieser Funktion oder Schleife nutzbar. Wenn du eine Variable mit dem gleichen Namen im gleichen Codeblock deklarierst, wird der Compiler einen Fehler ausgeben und sich darüber beschweren, dass die Variable erneut deklariert wurde. Wenn du jedoch eine Variable mit dem gleichen Namen in einem Codeblock unterhalb des Blocks deklarieren, in dem die erste Deklaration stattfand, wird der Compiler dies akzeptieren. Wenn du eine globale Variable und eine lokale Variable mit dem gleichen Namen anlegst, wird die lokale Variable die globale Variable verstecken. Wenn der Name verwendet wird, wird auf die lokale Variable verwiesen. Du kannst nicht mehr auf die globale Variable zugreifen. Verwende deshalb gute und eindeutige Namen für deine Variablen und definiere sie nicht auf oberster Ebene, wenn du sie nur innerhalb einer bestimmten Funktion benötigst.

Wie dem auch sei, sobald wir den Startzeitstempel in einer Variable gespeichert haben, können wir die Zeitdifferenz berechnen und sie in einer "while"-Schleife wie dieser verwenden:

unsigned long startTime = millis();
while(millis()-startTime > 8000) {

}

Die Schleife wird 8000 ms lang ausgeführt, und wir können immer noch in ihr arbeiten und prüfen, ob die Taste gedrückt wird. Wir müssen die Schleife so lange laufen lassen, wie entweder der Cooldown noch nicht abgelaufen ist oder der Knopf noch nicht gedrückt wurde. Wenn die Taste gedrückt wird und wir die Schleife trotzdem fortsetzen müssen, weil der Cooldown noch nicht abgelaufen ist, müssen wir uns später daran erinnern, dass die Taste bereits gedrückt wurde. Dafür erstellen wir eine weitere Variable. In diesem Fall können wir eine Variable vom Typ bool verwenden. Wir können sie anfangs auf false setzen und müssen sie dann innerhalb der Schleife auf true setzen, wenn der Knopf gedrückt wird. Dazu brauchen wir eine weitere Kontrollstruktur, die if-else Struktur.
Diese Struktur sieht wie folgt aus:

if(condition) {
  statement1;  
  statement2;
} else {
  statement3;  
  statement4;
}

Falls die Bedingung erfüllt ist, wird der erste Block ausgeführt, andernfalls wird der zweite Block ausgeführt. Der else Block ist optional und kann weggelassen werden. Die Bedingung wird auf die gleiche Weise ausgedrückt wie in der while-Schleife und wir können auch direkt eine einzelne Anweisung anstelle eines ganzen Codeblocks schreiben, wenn wir das wollen. Das Ergebnis ist das folgende Stück Code, in dem die Variable buttonPressed bereits zur Bedingung der while-Schleife hinzugefügt wurde:

unsigned long startTime = millis();
bool buttonPressed = false;
while(millis()-startTime < 8000 || !buttonPressed) {
  if(digitalRead(2) == LOW) {
    buttonPressed = true;
  }
}

Ich habe eine zusätzliche Verzögerung von zwei Sekunden nach unserer Abklingzeit hinzugefügt. Auf diese Weise wird die Ampel nicht sofort gelb, wenn jemand auf den Knopf drückt. Insgesamt werden wir immer eine garantierte Grünphase von 10 Sekunden erhalten. In der Phase dieser zusätzlichen Verzögerung wurde der Tastendruck bereits erkannt, deshalb können wir ganz normal die Funktion delay aufrufen. Zu einem vollständigen Programm zusammengefügt, sieht der neue Code wie folgt aus:

void setup() {
  pinMode(8, OUTPUT);  // Green LED
  pinMode(9, OUTPUT);  // Yellow LED
  pinMode(10, OUTPUT); // Red LED

  pinMode(2, INPUT_PULLUP); // Button
}

void loop() {
  // Green
  digitalWrite(8, HIGH);
  
  unsigned long startTime = millis();
  bool buttonPressed = false;
  while(millis()-startTime < 8000 || !buttonPressed) {
    if(digitalRead(2) == LOW) {
      buttonPressed = true;
    }
  }
  delay(2000);
  
  digitalWrite(8, LOW);

  // Yellow
  digitalWrite(9, HIGH);
  delay(2000);
  digitalWrite(9, LOW);

  // Red
  digitalWrite(10, HIGH);
  delay(10000);

  // Red and Yellow
  digitalWrite(9, HIGH);
  delay(1000);
  digitalWrite(9, LOW);
  digitalWrite(10, LOW);
}

Das Ergebnis

Schauen wir uns unser Ergebnis an. Wie im Video zu sehen ist, wird die Ampel schnell (nach 2 s) umschalten, wenn niemand sie vorher gedrückt hat und auch, wenn wir lange genug zwischen zwei Tastendrücken warten. Es ist jedoch durch den Cooldown sichergestellt, dass die Ampel mindestens 10 s grün ist. Spitze!

Weiter geht's!

Ich definiere das Ampelprojekt an dieser Stelle als abgeschlossen. Es gibt jedoch noch einige Dinge, die du vielleicht noch hinzufügen möchtest. Ich betrachte dies als eine gute Aufgabe für dich, um zu überprüfen, ob du verstanden hast, was wir in allen vorherigen Tutorials gelernt haben.

Hier sind die Teile, die noch fehlen: Wir brauchen noch eine tatsächliche Fußgängerampel. Dafür wir zwei weitere LEDs an den Arduino anschließen. Danach musst du den Code anpassen und es gibt einige Dinge, die du dabei beachten musst. Wenn die Fußgängerampel rot wird, müssen die verbleibenden Fußgänger noch genug Zeit haben, um die Kreuzung zu verlassen, bevor die Ampel grün wird. Der Fußgänger muss zudem in der Lage sein, den Knopf zu drücken, sobald er die Straße nicht mehr überqueren darf. Nur in der Grünphase der Ampel das Drücken der Taste zu erkennen, ist nicht ausreichend. Folglich gibt es noch einige weitere Stellen, an denen du das neu gelernte Schema des Wartens und Arbeitens anwenden musst.

Es liegt an dir, dieses Projekt fertigzustellen!

Vorheriger Beitrag Nächster Beitrag