Programmierung – Erweiterte Grundlagen

Arduino ist da, um schnell einen Einstieg zu haben. Ohne Studium, ohne Ausbildung, ohne Vorkenntnisse. Vieles ist schnell im Internet zusammenkopiert, einiges versteht man, das andere nimmt man als gegeben hin. Daraus baut man seinen Wissen langsam auf, vieles wird auch noch nach Jahren gleich gemacht, obwohl es viel einfachere Möglichkeiten gibt. Ich möchte hier ein paar Basics aus allen Schichten aufgreifen, die in vielen Programmiersprachen ebenfalls so oder ähnlich sind.

Übersicht:

i++

Besonders häufig sehe ich Konstrukte, die grundlegend okay, aber eigentlich unnötig sind. Ein Beispiel mit vielen Verbesserungsmöglichkeiten:
long i = 0;
double j;
void interruptHandler {
i = i+1;
if (i == 8) {
i = 0;
}
j = i / 2;
}

Zu allererst, i = i+1; kann man eliminieren. Man kann vor das Gleichzeichen einen der Operatoren * / + - << >> | & ^ % setzen, sodass in diesem Fall i += 1; angebracht wäre. Es gilt i = i o j → i o= j Allerdings geht das beim De- oder Inkrementieren um 1 noch leichter. Mit der Schreibweise i++; passiert selbiges mit deutlich weniger Zeichen.

Exkurs i++/++i
Es gibt auch noch die Variante ++i; In diesem Fall wäre es egal, da wir keine Auswertung benötigen, sondern einfach nur die Variable verändern wollen. Anders sieht es bei einer Zuweisung aus:
j = ++i verhält sich anders, als j = i++. Aber wieso? Bei ++i wird zuerst i um 1 erhöht, dann zurückgegeben, also sozusagen i += 1; j = i;. Bei i++ hingegen bekommt j den ursprünglichen Wert von i, i wird jedoch ebenfalls um 1 erhöht. Vergleichbar mit j = i; i += 1;.

Ternärer Operator

Der ternäre Operator (ternär = drei Grundeinheiten) ist ein dreiteiliger Ausdruck, der die if-Abfragen auch zum Einzeiler reduzieren können. i += i < 8 ? 1 : -8; entspricht einer Funktion, die einen Integer zurück gibt (kann auch String, double oder anderes sein), basierend auf dem vorangestellten Ausdruck:
int ternary() {
if (i < 8) {
return 1;
} else {
return -8;
}
}
/*************/
if (i < 8) {
i += 1;
} else {
i += -8;
}

Ist jetzt hier nicht unbedingt das beste Beispiel, aber als Erklärung genügt es.

Modulo

Rechnen mit Rest ist Grundschul-Spielkram. Komisch, ich benutze sie jeden Tag. Denn der sogenannte Modulo gibt immer den Rest der Division zurück und ist in vielerlei Hinsicht hilfreich. Auch bei obigem Beispiel, denn so viele Zeichen und Logik, wie beim ternären Operator sind gar nicht nötig: i %= 8;. Was passiert hier? Simple Mathematik, in vielen Augen aber Hokus-Pokus. Für die Zahlen 0-7 bleibt alles beim Alten, 0 durch 8 geteilt ist bekanntlich 0, 6 durch 8 sind 0,75 oder bei der Division mit Rest 0 Rest 6. Da der Rest in die Variable geschrieben wird, bleiben hier die 6 stehen. Bei 8 kippt es allerdings: 8/8 = 1 Rest 0, also i = 0.

Exkurs: Modulo für Ziffern in z.B. Datumsanzeigen
Mit einem 7-Segment Display ist schnell eine Ausgabe erstellt, allerdings kann man normalerweise keine Strings oder Zahlen ausgeben, also muss man Ziffer für Ziffer ausgeben. Da sind mir schon einige Dinge untergekommen, bei denen ich mir sagte: Funktioniert, aber schön ist was anderes. Für die zweite Ziffer des Jahres etwa
int y[4];
y[0] = year/1000;
i = year - y[0] * 1000;
y[1] = i / 100;
...

Einfacher:
int y[4];
y[0] = year/1000;
y[1] = (year/100) % 10;
y[2] = (year/10) % 10;
y[3] = year % 10;
Exkurs: Modulo statt for-Schleife mit delay
Ein gern genutztes Konstrukt sind for-Schleifen, nicht immer, um Redundanz (Berechne für alle Werte des Arrays die Wurzel) zu reduzieren/eliminieren, sondern auch für Animationen (Lauflicht, etc.). Meist möchte man aber während einer Animation noch Interaktionen (Modus mit Taster umschalten) zulassen. Durch ein delay kann die Abfrage des Tasters aber nur alle x Millisekunden abgefragt werden.
for (int8_t i = -10; i < 10; i++) {
//animation basierend auf i
if (digitalRead(TASTER) != taster) {
taster = !taster;
if (taster)
modus = !modus;
}
delay(100);
}

Die Differenz zwischen Minimal- und Maximalwert beträgt hier 10-(-10) = 20, der delay ist mit 100ms angegeben. Ein delay erzeugt immer ungenutzte Taktzyklen (nop, „nichts tun“), die Schleife wird damit zum sogenannten blocking-statement – andere Anweisungen werden an der Ausführung gehindert – geblockt. Um das ganze nun in ein non-blocking statement zu überführen reicht eine Zeile Code, die das delay und for gleichzeitig eliminiert:
int8_t i = (millis()/100)%20-10;
//animation basierend auf i
if (digitalRead(TASTER) != taster) {
taster = !taster;
if (taster)
modus = !modus;
}

millis()/100 bewirkt, dass sich der resultierende Wert nur alle 100ms ändert, also indirekt unser delay(100). Modulo 20 begrenzt unseren Wertebereich vorerst auf 0-19, also die oben berechnete Differenz. Addieren wir nun den Minimalwert, -10 (also Subtraktion), so erhalten wir Werte für i zwischen -10 und +9.
So kann der Taster ohne die Verzögerung abgefragt werden. Anderer Nebeneffekt: vorher wurde das delay zusätzlich zum restlichen Code ausgeführt, bedeutet die effektive Wartezeit betrug 100ms+Taktzyklen für Rest. Wem es auf eine exakte Wartezeit ankommt, sollte dieses nicht so mit einem delay umsetzen.
Da millis() die Zeit seit Programmstart (also Reset) misst, kommt es vor, dass i nicht beim Minimalwert, sondern mittendrin initialisiert wird. Sollte es wichtig sein, dass i bei -10 startet, so kann man mit einer Hilfsvariable arbeiten. Diese sollte möglichst spät (z.B. als letztes in setup()), jedoch unbedingt vor der Schleife (wahrscheinlich im loop()) gesetzt werden.
unsigned long millisOffset;
void setup() {
...
millisOffset = millis();
}
void loop() {
int8_t i = ((millis()-millisOffset)/100)%20-10;
...
}

Bitoperatoren

Dass ich nun ausgerechnet 8 im Beispiel gewählt habe, kommt nicht von ungefähr. Mit ein Lieblingswerkzeug von mir sind Bit-Operationen. Man betrachtet die Zahlen nicht mehr als Dezimalzahl, sondern als Binärzahl und führt darauf Modifikationen aus. Modulo und Bit-Operation sind in diesem Fall gleichviel Schreibaufwand für den Programmierer, die Bit-Operation nimmt allerdings deutlich weniger Zyklen in Anspruch. Mag bei 16 MHz und simplen Programmen nicht schwer in’s Gewicht fallen, kann einem später aber bei komplexen, Laufzeitintensiven Programmen zum Verhängnis werden.
i &= 7 agiert ähnlich, wie i %= 8, dahinter steckt allerdings eine andere Macht. Nehmen wir an, i wäre aktuell 3, also 0b11:
0b00011 //3
& 0b111 //7
= 0b011 //3

Die Alternative mit i = 12, also 0b1010:
0b01100 //12
& 0b111 //7
= 0b100 //4

Die Werte sind identisch zu % 8. Es wird durch das Muster 0b111 sozusagen in eine leere Schale gesiebt, eine 1 stellt ein Loch dar, wo ein Bit durchfallen kann, eine 0 ist geschlossen, dort kann kein Bit passieren. Zwischen 0b (definition der Zahl durch Bitfolge, statt Dezimalzahl) und der ersten 1 stehen unendlich (eigentlich nur max. so viele, wie der Datentyp groß ist, für dieses Beispiel aber egal) nullen. Die erste 1 prallt also ab, danach folgt eine 0, fällt durch das Sieb. Die 1 und 0 fallen ebenfalls durch.
Anders läuft es bei | (oder), hier werden die beiden Zahlen ohne Sieb übereinander gelegt. Nur Bits, die bei beiden null sind, bleiben null.
Das Gegenteil bewirkt ^ (exklusives oder). Nur unterschiedliche Bits werden hier übernommen, also 0^0 = 0, 1^1 = 0, 1^0=1, 0^1=1.
Die Variable j bekommt i/2 zugewiesen. In weniger Zyklen geht es auch, indem wir das niedrigste Bit löschen und die ganze Zahl nach rechts rutscht. j = i >> 1 macht aus 0b100 (4) 0b10 (2), also durch zwei geteilt. Dieses lässt sich mit jeglichen Zweierpotenzen machen: x/2n = x >> n. Auch in die andere Richtung geht es mittels <<.

Exkurs: Datenspeicherung als Muster
Windrichtungen mit 8 Schritten (N/NW/W/…) können einfach durch ein Bitmuster repräsentiert werden. 0bNWSO. Um zu gucken, ob es nördlich ist, kann man eine if-Abfrage schnell schreiben:
if (wind & 0b1000) {//nördlich}

Datentypen

Im Beispiel sind allerdings auch noch weitere Fehler.
Zum einen: j ist als double, also Dezimalzahl, wird aber nie Nachkommastellen besitzen, die von .0 abweichen. Also statt einer reellen Zahl werden wir immer eine natürliche Zahl bekommen. Das ist dem geschuldet, dass wir durch die natürliche Zahl 2 teilen. Geben wir diese hingegen als 2.0 an, so wird j auch etwa 1.5 annehmen. Je nach Kompiler wird dieses aber auch ignoriert. Double ist bei allen 8-Bit AVRs in Arduino jedoch identisch mit float, welche mit 5-6 Bit Genauigkeit arbeitet. Würde in unserem Fall ausreichen, bei anderen Anwendungen ärgert man sich schnell darüber. Besonders, weil teilweise Ergebnisse heraus kommen, die mathematisch nicht stimmen (etwa 6.0/3.0=1.9999). Beim Due (und wohl anderen 32-Bit Arduinos) werden doubles als 64bit große Zahl abgelegt, haben dann bis zu 15 Bit Genauigkeit.
Auch wird der Speicher teilweise eng, da sollte man sich Gedanken über die Datentypen machen, die man einsetzt. long ist in diesem Fall komplett überdimensioniert, da wir nur Werte zwischen 0 und 7 haben, nie jedoch zwei Milliarden, geschweige etwas negatives. Dass man den negativen Wertebereich nicht benötigt drückt man durch unsigned datentyp aus. Wir benutzen nur 4 Byte, einen Datentyp in der Größe gibt es nicht, also nehmen wir den nächst größeren. char ist meist 1 Byte groß, kann jedoch auch bis zu 4 Byte in Anspruch nehmen, um etwa UTF-8 zu unterstützen. Um das Dilemma nicht zu haben, gibt es bei Arduino sogenannte typedefs, die einen Datentyp-Alias erstellen:

Datentyp Minimum Maximum
boolean 0 1
int8_t, char -128 127
uint8_t, unsigned char 0 255
int16_t, short, int -32768 32767
uint16_t, unsigned short, unsigned int 0 65535
int32_t, long, teilweise auch int -2147483648 2147483647
uint32_t, unsigned long, teilweise auch unsigned int 0 4294967295

RAM/Flash

Apropos Speicher:
Einige Programme stürzen ab, weil der RAM voll ist. Besonders bei Strings füllt sich der RAM schnell, hier ist es von Vorteil, wenn man die Texte im Flash mittels
#include <avr/pgmspace.h>
const datentyp name PROGMEM = …

ablegt. Die Werte sind durch const dann nicht mehr veränderbar, um Strings für Funktionen wieder verwertbar zu machen, ist statt nur der Variable allein das Konstrukt F(name) vonnöten.

Volatile

Da wir es hier symbolisch mit einem Interrupt zu tun haben, kann es vorkommen, dass im Hauptprogramm manchmal veraltete Werte ausgeworfen werden. Der Prozessor hat die Variable bereits in ein Register geladen, dann wird sie durch den Interrupt verändert, im weiteren Programm wird die Variable wieder verwertet. Sofern das Register noch nicht anderweitig überschrieben wurde, so denkt der Prozessor, dass die Variable darin weiterhin bestand hat. Mit dem Schlüsselwort volatile vor der Variablendeklaration wird klar gemacht, dass diese Variable jedes mal neu geladen werden sollen. Egal, ob das Register unverändert geblieben ist (push/pop, etwa durch Funktionsaufrufe, von dem man in C eh nichts mitbekommt gilt als unverändert), es wird überschrieben.