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.

12 thoughts on “Binärlogik und Portmanipulation

  1. 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

    Da stimmt was nicht. Mit 0b100000 veränderst du doch auch die anderen Bits, ist also nicht nur (1<<PB5). Hast du was bei &=0b11011111 vergessen?

    1. Hallo Achim, mit „&=“ vergleicht man jedes Bit einzeln. ~(0b100000) ist 0b11011111. In der Maske 0b11011111 ist nur das Bit 5 Null, alle anderen sind 1. Wenn man die Maske nun auf ein anderes Byte anwendet, also „&=“ ausführt, dann bleiben alle Einsen Eins und alle Nullen Null. Nur Bit 5 wird wird zwingendermaßen auf Null gesetzt. Z. B. : 0b10101010 &= 0b11011111 ergibt 0b10001010.

  2. Wenn ich das richtig verstanden habe, geht das mit Port A so:
    PORTA &= ~(1<<PINA1), gleichbedeutend mit:
    PORTA &= ~(0b00000010) bzw. PORTA &=0b11111101
    Nach deinem Artikel bedeutet & logische Und und ~ negation

    1. Genau, nur dass PINA5 glaube ich nicht definiert ist, sondern nur PA5. Die entsprechenden #define Anweisungen sind in der avr/io.h die man mit einbindet.

  3. Bei mir nimmt er Atmel Studio ohne define erst mal nur PINA1, mit define geht es natürlich auch anders. Liegt auch daran das ich kein arduino nehme

    1. PA1 geht aber auch. Hat nicht so viel mit dem Arduino zu tun, sondern kommt aus den avr Headerdateien, die man normalerweise bei Programmierung mit Atmel Studio einbindet. Da muss man auch kein #definr irgendwo hinschreiben. Wenn PINA1 auch geht, dann ist das auch irgendwo über ein #define definiert. Letztlich steckt schlicht „1“ dahinter. Nur ist PA1 oder PINA1 besser lesbar. PINA1 ist sogar noch besser lesbar- da habe ich auch noch was gelernt!

  4. Du verwendest das folgende:
    Selektives Abfragen von Pin-Zuständen
    Der Ausdruck
    PINB &= (1<<PINB2)
    liefert 0, also false (falsch), wenn PB2 LOW ist. Ist PB2 HIGH, dann ist der Ausdruck ungleich 0, also true (wahr).
    Ich verwende zur einfachen Abfrage der Taster an den Pins das folgende:
    if (PINB & (1<<PINB2)) // Taster T2
    { // Wenn T2 gedrückt…
    PORTA |=(1<<led1); // LED 1 PINA0 ein
    }
    else
    { // wenn nicht
    PORTA &=~(1<<led1); // LED 1 PINA0 aus
    }
    Es gibt dabei ein paar kleine Unterschiede in der Schreibweise. Wieso?
    Ist die Funktion in der if.. Abfrage gleich?

    1. Hallo Achim, das Gleichheitszeichen gehört da nicht hin. Gut bemerkt.
      &= wäre eine Zuordnung. Aber ich will ja nur den Pinzustand vergleichen. Werde es gleich ändern – Danke.

  5. Vielleicht könnterst du auch was zu if sagen. Stimmt das so rum? Bei meiner Platine liegt der PIN über einen R an Vcc 5V. Mit dem Taster ziehe ich den PIN auf GND. Wir muss es den jetzt sein? Mit meiner Hardware passt das nicht zusammen.

    1. Nächster Versuch:
      Wenn bei dir PINB2 auf 5 Volt hochgezogen ist und durch Tasterdruck auf 0 geht, dann soll bei Tasterdruck der Ausdruck PINB & (1 << PB2)) unwahr sein. D.h. du müsstest schreiben if(!(PINB & (1 << PB2))) was gleichbedeutend ist mit if( (PINB & (1 << PB2)) == false);

Schreibe einen Kommentar

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