Binärlogik und Portmanipulation

Über den Beitrag

In vielen Beiträgen, so auch in meinem letzten über IR Fernbedienungen, habe ich Binärlogik und Portmanipulationen verwendet. Damit meine ich Ausdrücke wie z. B. PORTD |= (1<<PD2) anstelle von digitalWrite(2, HIGH). In diesem Beitrag möchte ich nun einmal gesondert auf dieses Thema eingehen.

Zugegebenermaßen ist das ein wenig trocken. Interessant wird es eigentlich erst dann, wenn man es mit einer Anwendung verbindet. In den anwendungsbezogenen Beiträgen war aber bisher kein Platz dafür. Betrachtet diesen Beitrag also als eine Art Referenz, auf die ich verweisen werde, wenn ich mal wieder Binärlogik und Port Manipulation verwende.

Ich beginne mit der Binärlogik und gehe dann auf die Ports des ATmega 328 P und die Portmanipulation ein. Dann kommt eine kleine praktische Übung. Zum Schluss gibt es einen Geschwindigkeitsvergleich und ich beantworte die Frage, warum die Portmanipulation so viel schneller ist.

Warum sind Binärlogik und Portmanipulationen wichtig?

Portmanipulation, also der direkte Zugriff auf Pins des Arduino (oder andere Boards oder Microcontroller) über seine Ports ist wesentlich schneller als die gewohnten Arduino Funktionen. Meistens spielt das keine Rolle, manchmal aber schon. Und die Binärlogik mit ihren Bitoperatoren ist dafür das Rüstzeug. Die Grammatik sozusagen. 

Eigentlich mag ich den Begriff „Portmanipulation“ nicht besonders. Es klingt irgendwie nach Verbotenem. Im Grunde ist es aber die natürlichere Art Mikrocontroller zu programmieren. digitalWrite, pinMode und Co hingegen vernebeln eigentlich den Blick darauf, was auf Hardwareebene tatsächlich passiert.

Die Binärlogik kommt auch dann ins Spiel, wenn ihr euch allgemein mit der Programmierung von Registern beschäftigt, wie z.B. in Sensoren. Dabei hat man es oft mit Aufgaben zu tun wie z.B. „schreibe eine 101“ in die Bits 3-5 des Registers XY. So etwas ist nur mit Binärlogik vernünftig abbildbar.

Binärlogik: die Bitoperatoren

Logische Bitoperatoren und Shiftoperatoren

Die logischen Bitoperatoren sind:

  • & logisches UND
  • | logisches ODER
  • ^ exklusives ODER (XOR)
  • ~ Negation (NICHT)

Ein wesentlicher Unterschied zu den gewohnten Operatoren wie z.B. Plus oder Minus ist, dass die Bitoperatoren bitweise angewendet werden.

Die Shiftoperatoren sind:

  • >> Rechtsshift
  • << Linksshift

Bitoperator UND (&)

Der Bitoperator UND prüft, ob die beiden Operanden 1 (true) sind. Ist das der Fall, dann ist das Ergebnis 1, sonst 0 (false).

0 & 0 = 0
1 & 0 = 0
0 & 1 = 0
1 & 1 = 1

Anwendung auf ein ganzes Byte:

0b10011100 & 0b01010111 = 0b00010100

Bitoperator ODER (|)

Der Bitoperator ODER prüft, ob mindestens einer der beiden Operanden 1 (true) ist. Wenn ja, dann ist das Ergebnis 1, sonst 0.

0 | 0 = 0
1 | 0 = 1
0 | 1 = 1
1 | 1 = 1 

Anwendung auf ein ganzes Byte:

0b10011100 | 0b010101111 = 0b11011111

Bitoperator XOR (^)

Der Bitoperator XOR prüft, ob genau einer der beiden Operanden 1 ist. Wenn ja, ist das Ergebnis 1, sonst 0.  Im Gegensatz zum ODER liefert dieser Operator also auch dann 0, wenn beide Operanden 1 sind.

0 ^ 0 = 0
1 ^ 0 = 1 
0 ^ 1 = 1 
1 ^ 1 = 0

Anwendung auf ein ganzes Byte:

0b10011100 ^ 0b010101111 = 0b11010011

Bitoperator NICHT (~)

Der Bitoperator NICHT hat nur einen Operanden. NICHT kehrt den Wert um, aus 1 (true) wird also 0 (false) und umgekehrt.

~0 = 1
~1 = 0

Anwendung auf ein ganzes Byte:

~0b10011100 = 0b01100011

Shiftoperatoren

Shiftoperatoren verschieben einen Wert bitweise nach rechts oder links. Eine Verschiebung um x Stellen nach links entspricht dabei einer Multiplikation mit 2x. Eine Verschiebung um x Stellen nach rechts entspricht einer Division durch 2x.

Bei der Verschiebung nach rechts fallen alle Stellen weg, die hinter die erste Stelle (LSB = least significant bit) verschoben werden. Bei der Verschiebung nach links fallen alle Stellen weg, die sich außerhalb des Wertebereichs der Variable befinden (jenseits des MSB = most significant bit).

(0b1 << 1) = 10
(0b101 << 1) = 1010
(0b111 >> 1) = 11
(0b11110000 << 1) = 0b11100000 // wenn 0b11110000 als byte definiert wurde

Dabei gibt es ein paar Stolperfallen. Überlegt einmal welche Ausgabe der folgende Sketch gibt:

byte a,b,c;
unsigned int d;
int e;

void setup() {
  Serial.begin(9600);
  a = 0b1111;
  b = a<<5;
  c = a>>1;
  d = a<<5;
  e = a<<12;
  Serial.print("a = "); Serial.println(a, BIN);
  Serial.print("b = "); Serial.println(b, BIN);
  Serial.print("c = "); Serial.println(c, BIN);
  Serial.print("d = "); Serial.println(d, BIN);
  Serial.print("e = "); Serial.println(e, BIN); 
  Serial.print("e(dezimal) = "); Serial.println(e); 
}

void loop() {}

Hier die (erwartete?) Auflösung:

Angewandte Binärlogik: Ausgabe des Sketches shiftoperator_test.ino
Ausgabe des Sketches shiftoperator_test.ino

Die Ports des Arduino UNO

Der Arduino UNO besitzt vierzehn digitale I/O Pins (0 – 13) und die sechs „quasi-analogen“ I/O Pins A0 bis A5. Schaut ihr ins Datenblatt des ATmega 328 P, dem Herz des Arduino, findet ihr die Pins in einer anderen Organisationsstruktur wieder.

Pinout Schema des Atmega 328 P, daneben die Arduino UNO Entsprechungen
Pinout des ATmega 328 P, links und rechts davon die Arduino UNO Entsprechungen

Die I/O Pins sind in den Ports B, C und D organisiert. Die Pinbezeichnungen sind entsprechend PBx, PCx und PDx mit x = 0 bis maximal 7. Zu diesen Gruppen gibt es je drei 8 Bit Register, auf die ich gleich näher eingehen werde, nämlich DDRx, PORTx und PINx mit x = B, C oder D.

Die Pins PB6 und PB7 sind bei Verwendung des Arduino UNO nicht zugänglich, da der 16 MHz Taktgeber fest verdrahtet dranhängt. PC6 ist auf dem Arduino Board als Reset festgelegt und entsprechend nicht als I/O Pin zugänglich. PC7 existiert schlicht nicht.

Die Richtungsregister DDRx

Wollt ihr einen I/O Pin als Input oder Output nutzen, dann stellt ihr das in der „Arduino Sprache“ über die pinMode Funktion ein. Im ATmega 328 P werden dabei die entsprechenden Bits im zuständigen Richtungsregister (DDR = Data Direction Register) gesetzt. Hier stellvertretend die Struktur des Richtungsregisters für den Port B:

Richtungsregister DDRB
Richtungsregister DDRB

Als Beispiel möchte ich den digitalen Pin 13 des Arduinos auf OUTPUT setzen. Dieser entspricht gemäß dem Pinout Schema von oben dem Pin PB5. Das bedeutet, dass das Bit Nr. 5 im Register DDRB gesetzt werden muss. Der Zugriff auf das Register ist denkbar einfach. Es reicht folgende Zuweisung:

DDRB = 0b100000 oder DDRB = 0x20 oder DDRB = 32

Dieser Zugriff ist deshalb so einfach möglich, weil „DDRB“ über ein #define Anweisung in den AVR Bibliotheken (avr/io.h –> avr/iom328p.h) die notwendigen Anweisungen enthält:

#define DDRB _SFR_IO8(0x04)

Möchtet ihr mehrere Pins des Ports B auf OUTPUT setzen, z.B Pin 5 und Pin 3, dann sieht die Anweisung folgendermaßen aus:

DDRB = 0b101000, oder hexadezimal: DDRB = 0x28, oder dezimal: DDRB = 40

Die Port Daten Register PORTx

Wollt ihr einen I/O Pin, den ihr zuvor auf OUTPUT gesetzt habt, nun in den Zustand HIGH versetzen, dann greift ihr im Rahmen der Portmanipulation auf das zuständige Port Daten Register (PORTx) zu, hier PORTB als Beispiel:

PORTB Daten Register
PORTB Daten Register

Ein Pin ist HIGH, wenn ihr das entsprechende Bit gesetzt habt. Um beim obigen Beispiel vom Arduino Pin 13 zu bleiben:

PORTB = 0b100000 entspricht also digitalWrite(13, HIGH)

Halt, stopp! Natürlich gilt die Entsprechung nur in Bezug auf den Pin 13 (PB5), denn die erste Anweisung schaltet alle anderen PORTB Pins auf LOW. Dahingegen ist digitalWrite selektiv.

Das Port Input Pin Register PINx

Den Zustand, also LOW oder HIGH, eines Input Pins könnt ihr über das entsprechende PINx (x = B, C, D) Register abfragen. Für Arduino Pin 13 zum Beispiel:

PINB == 0b100000 anstelle von digitalRead(13)

Wieder gilt die Einschränkung, dass digitalRead selektiv ist, wohingegen die ob PINB Abfrage in dieser Form prüft, ob an PINB nur PB5 HIGH ist.

Einsatz der Binärlogik bei der Portmanipulation

Selektives Setzen von Bits

Um nun ein einzelnes oder mehrere Bits selektiv zu setzen, verwendet Ihr das logische ODER. Folgendermaßen könnt ihr zum Beispiel PB5 auf HIGH zu setzen, ohne die restlichen Pins des PORTB zu beeinflussen:

PORTB = PORTB | 0b100000 bzw. PORTB |= 0b100000 oder, bevorzugt:

PORTB |= (1<<PB5)

PB5 ist über ein #define schlicht als 5 definiert. Man könnte also genauso gut PORTB |= (1<<5)schreiben. Ihr könntet PB5 sogar durch PC5 oder PD5 ersetzen. Gut lesbar wäre das natürlich nicht. 

Mehre Bits, zum Beispiel PB5 und PB3, setzt ihr folgendermaßen:

PORTB |= (1<<PB5) | (1<<PB3), da

(1<<PB5) | (1<<PB3) = 0b100000 | 0b1000 = 0b101000 

Gelegentlich findet man auch den Ausdruck _BV(x) anstelle von (1<<x). Dabei steht „BV“ für Bitvalue. Beides ist identisch wie ein Blick in die Bibliotheksdatei sfr_defs.h verrät:

#define _BV(bit) (1 << (bit))

Selektives Löschen von Bits

Auch das selektive Löschen von Bits ist sehr einfach, es erschließt sich aber vielleicht nicht unbedingt auf den ersten Blick. Ich nehme wieder PB5 als Beispiel:

PORTB &= ~(1<<PB5),  gleichbedeutend mit:

PORTB &= ~(0b100000) bzw. PORTB &= 0b11011111

Ihr könnt natürlich auch mehrere Bits gleichzeitig löschen, hier zum Beispiel PB3 und PB5:

PORTB &= ~((1<<PB5)|(1<<PB3))

Selektives Invertieren von Bits

Für das Invertieren einzelner Bits eignet sich das logische XOR. Dabei macht man sich zunutze, dass ein ^1 aus einer 0 eine 1 macht und umgekehrt. Ein ^0 hingegen verhält sich neutral. So z. B. invertiert ihr PB5 selektiv:

PORTB ^= (1<<PB5)

Oder für mehrere Bits, die invertiert werden sollen:

PORTB ^= (1<<PB5)|(1<<PB3)

Wenn ihr einen ganzen Port invertieren wollt, kommen zwei Varianten infrage:

PORTB = ~PORTB oder PORTB ^= 0b11111111

Selektives Abfragen von Pinzuständen

Das ist jetzt keine Überraschung mehr. Der Ausdruck

PINB &= (1<<PB5)

liefert 0, also false, wenn PB5 LOW ist. Ist PB5 HIGH, dann ist der Ausdruck ungleich 0, also true. Der genaue Wert, hier 0b100000, also 32, ist dabei nicht von Belang.

Ein kleiner Übungssketch

Nun könnt ihr das ganze einmal praktisch ausprobieren, wenn ihr wollt. Dazu baut folgende Schaltung auf:

Binärlogik - Schaltung für den Sketch Portmanipulation_test.ino
Schaltung für den Sketch Portmanipulation_test.ino

Dann probiert den folgenden Sketch aus und spielt ein bisschen damit herum. Passiert das, was ihr erwartet?

int dTime = 2000; //delay time 

void setup(){ 
  DDRD = 0xFF; 
} 

void loop() { 
  PORTD = 0b10101010; 
  delay(dTime); 
  PORTD |= (1<<PD6); 
  delay(dTime); 
  PORTD ^= (1<<PD6)|(1<<PD7);
  delay(dTime);
  PORTD |= (1<<PD0)|(1<<PD2)|(1<<PD4); 
  delay(dTime); 
  PORTD = (PORTD >> 3); 
  delay(dTime); 
  PORTD &= ~((1<<PD0)|(1<<PD2)); 
  delay(dTime); 
  PORTD = ~PORTD; 
  delay(dTime);
 }

 

Portmanipulation an anderen Microcontrollern

Die Portmanipulation auf andere Microcontroller zu übertragen ist einfach, zumindest was AVR Vertreter wie ATmegas und ATtinys angeht. Ein Blick ins Datenblatt auf das Pinout Schema reicht meistens. Die ATtinys 25 / 45 / 85 haben beispielsweise nur einen PORTB mit den Pins PB0 bis PB5:

Pinout des ATtiny 25 bzw. 45 bzw. 85

Bei Nicht-AVR Microcontrollern kann sich der direkte Portzugriff stark unterscheiden. Im Falle des ESP8266 ESP01 gibt es beispielsweise ein Register zum Setzen der Bits und ein anderes zum Löschen:

GPOS |= (1<<Pin) für HIGH, bzw. GPOC |= (1<<Pin) für LOW

Ein kleiner Geschwindigkeitstest

Portmanipulation vs. digitalWrite

Wie viel Unterschied macht nun die Portmanipulation gegenüber digitalWrite aus? Um diese Frage zu klären, habe ich den folgenden Minisketch verwendet und mir das Ergebnis beider Methoden am Oszilloskop angeschaut.

void setup() {
  DDRD = 0xFF;
}

void loop() {
  //PORTD |= (1<<PD3);
  //PORTD &= ~(1<<PD3);
  digitalWrite(3, HIGH);
  digitalWrite(3, LOW);
}
Maximale Frequenz mit digitalWrite
Maximale Frequenz mit Portmanipulation

Ein HIGH/LOW Zyklus wird bei Verwendung von digitalWrite mit einer maximalen Frequenz von 113 kHz durchlaufen, mit Portmanipulation sind es 2 MHz, also gute 17 Mal schneller. 2 MHz bedeutet, dass lediglich 8 Prozessortakte Takte pro Durchlauf benötigt werden.

Portmanipulation vs. digitalRead

Um die Geschwindigkeit von digitalRead vs. PINx &= (1<<Pin) zu ermitteln, habe ich beides 100.000 Mal angewendet und die dafür benötigte Zeit ermittelt.

unsigned long numberOfReads = 100000;
unsigned long startTime = 0;
unsigned long readTimeLength = 0;
bool pinStatus;

void setup() {
  Serial.begin(9600); 
  pinMode(5,INPUT); 
}

void loop() {
  startTime = millis();
  for(unsigned long i=0; i<numberOfReads; i++){
    pinStatus = digitalRead(5);
  }
  readTimeLength = millis() - startTime;
  Serial.print("digitalRead: ");
  Serial.print(readTimeLength);
  Serial.print(" ms ");
  Serial.print(" / ");
  delay(1000);
  
  startTime = millis();
  for(unsigned long i=0; i<numberOfReads; i++){
    pinStatus = (PIND & (1<<PD5));
  }
  readTimeLength = millis() - startTime;
  Serial.print("PIND Abfrage: "); 
  Serial.print(readTimeLength);
  Serial.println(" ms");
  delay(5000);
}

Das Ergebnis:

digitalRead vs. direkte PINx Abfrage - der Vorteil von Binärlogik und Portmanipulation
digitalRead vs. direkte PINx Abfrage

Die direkte PIND Abfrage ist also gute achtmal schneller als digitalRead. Teilen durch 100000 ergibt, dass die digitalRead Funktion ca. 3,5 µs benötigt, das direkte Abfragen hingegen nur ca. 0,44 µs. Ganz exakt ist das natürlich nicht, da auch die Schleifenbearbeitung selbst etwas Zeit benötigt. Es zeigt aber zumindest, dass der Unterschied erheblich ist.

Woher kommt der Unterschied?

Der Unterschied in der Geschwindigkeit liegt darin begründet, dass digitalRead und digitalWrite einiges mehr leisten als die einfachen Portmanipulationsfunktionen. Die zusätzlichen Maßnahmen, die digitalRead und digitalWrite treffen, machen die Funktionen robuster gegenüber Fehlern. Wenn ihr den Geschwindigkeitsgewinn durch Portmanipulation nicht braucht, dann bleibt deshalb auch gerne bei digitalRead und digitalWrite.

Hier als Beispiel die digitalWrite Funktion, die ihr übrigens in der Datei Arduino\hardware\arduino\avr\cores\arduino\wiring_digital.c findet:

void digitalWrite(uint8_t pin, uint8_t val)
{
  uint8_t timer = digitalPinToTimer(pin);
  uint8_t bit = digitalPinToBitMask(pin);
  uint8_t port = digitalPinToPort(pin);
  volatile uint8_t *out;

  if (port == NOT_A_PIN) return;

  // If the pin that support PWM output, we need to turn it off
  // before doing a digital write.
  if (timer != NOT_ON_TIMER) turnOffPWM(timer);

  out = portOutputRegister(port);

  uint8_t oldSREG = SREG;
  cli();

  if (val == LOW) {
    *out &= ~bit;
  } else {
    *out |= bit;
  }

  SREG = oldSREG;
}

 

Danksagung

Den Arduino im Beitragsbild habe ich von Daan Lenaerts auf Pixabay. Die Nullen und Einsen (die ich allerdings ausgeschnitten, eingefärbt und als Ebene eingefügt habe) habe ich Gerd Altmann zu verdanken, auch Pixabay.

Schreibe einen Kommentar

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