Arduino Port-Manipulation


von Prof. Jürgen Plate

Port-Manipulation

Bei manchen Anwendungen sind die Arduino-Input/Output-Funktionen wie digitalRead() oder digitalWrite() zu langsam. In solchen Fällen kann man die Ports des ATmega 328 direkt manipulieren. Für die unsprüngliche Zielgruppe waren die Einzelbit-Befehle sicher ideal, denn beim Zugriff auf die 8-Bit-Ports sind unter Umständen Bitmanipulationsoperationen wie AND, OR oder XOR notwendig um einzelne Bits zu ändern. So ist der Durchgriff auf die Ports ca. 50 mal schneller. Bei Arduino können drei Ports genutzt werden:

Wie Sie in der Grafik sehen, sind bei den Ports B und C nur sechs Bits nutzbar. Port D des ATmega führt auf die digitalen Pins 0 bis 7, Port B auf die digitalen Pins 8 bis 13 und Port C auf die analogen Pins 0 bis 5.

Ansprechen der Ports über Register

Die Ports werden von drei Registern gesteuert, die mit "DDRx", "PORTx" und "PINx" bezeichnet werden. Diese drei Register existieren für jeden Port, es gibt also zum Beispiel für Port B ein DDRB-, ein PORTB- und ein PINB-Register. Das jeweilige DDRx-Register steuert, ob die Pins als Input oder Output konfiguriert sein sollen (DDR = Data Direction Register), das PORT-Register ist ein Ausgang, es legt fest, ob die Pins HIGH oder LOW sind und das PIN-Register gibt den Zustand der Pins an, die im DDR-Register auf Input gesetzt wurden.

Ist im DDRx-Register ein Bit auf 1 gesetzt, so wird das korrespondierende Bit des PORTx (Ausgang) auf den Anschlusspin geschaltet, ist das DDRx-Bit dagegen auf 0 gesetzt, ist das korrespondierende Bit Eingang und es wird der entsprechende Anschlusspin an das zugehörige PINx-Bit (Eingang) geleitet.

Die einzelnen Register sind in der Arduino-Entwicklungsumgebung bereits als Namen vordefiniert, man kann also sofort loslegen. Beispielsweise setzt der folgende Befehl D0, D2, D6 und D7 als Eingänge sowie D1, D3, D4 und D5 als Ausgänge.

DDRD = B00111010;
Ähnlich funktionieren Ein- und Ausgabe. Auch hier werden alle Bits gleichzeitig ein- oder ausgegeben, z. B.:
PORTB = B00001100;

Das folgende Beispiel blinkt mit allen fünf Ausgängen von Port B im Wechsel, was mit den direkten Befehlen zur Portmaipulation recht einfach geht.

void setup()
  {
  DDRB = B00111111;      // alle Bits als Ausgang
  }

// Wechselblinker mit allen 5 Ausgaengen von Port B
void loop()
  {
  PORTB = B00101010;
  delay(300);
  PORTB = B00010101;
  delay(300);
  }
Das war jetzt noch nicht spektakulär. Das folgende Beispiel soll eine Siebensegmentanzeige ansteuern. Eine Siebensegmentanzeige ist ein Anzeigeelement aus sieben separat schaltbaren Balken, die in Form '8' angeordnet sind. Die Segmente werden mit den Buchstaben 'a' bis 'g' bezeichnet. Der Dezimalpunkt der Anzeige wird mit 'h' oder 'dp' bezeichnet.

Die Ansteuerung der Anzeige über einzelne Befehle für jeden Pin wäre recht mühsam, selbst wenn man sich für die Codierung ein zweidimensionales Array schafft:

// 7-Segmentanzeige: Segmentzuordnung fuer jede Ziffer
int segmente[10][7] = 
  {
  // a b c d e f g
    {1,1,1,1,1,1,0}, // 0
    {0,1,1,0,0,0,0}, // 1
    {1,1,0,1,1,0,1}, // 2
    {1,1,1,1,0,0,1}, // 3
    {0,1,1,0,0,1,1}, // 4
    {1,0,1,1,0,1,1}, // 5
    {1,0,1,1,1,1,1}, // 6
    {1,1,1,0,0,0,0}, // 7
    {1,1,1,1,1,1,1}, // 8
    {1,1,1,1,0,1,1}, // 9
  };
  
int ziffer = 0;
  
// Anschluss der Segmente 
//          a  b  c  d  e  f  g
int pins[]={2, 3, 4, 5, 6, 7, 8};  
  
void setup() 
  {
  int i;
  // Pins auf Ausgang setzen
  for (i = 0; i < 7; i++)
    {
    pinMode(pins[i],OUTPUT);
    digitalWrite(pins[i],LOW);
    }  
  }
    
void loop() 
  {
  int i;
  // Anzeige setzen
  for(i = 0; i < 7; j++)
    {
    digitalWrite(pins[i],segmente[ziffer][i];  
    }
  // ziffer modulo 10 hochzaehlen
  ziffer = (ziffer + 1)%10;
  delay(1000);
  }

Das war der konventionelle Ansatz. Mit der direkten Portmanipulation wird das Ganze sehr viel einfacher. Das Arry mit der Segmentdefinition ist nun eindimensional und das Setzen der Anzeige funktioniert ganz ohne Schleife.

byte segmente[] = 
  { // 0          1           2           3           4
  B00111111,  B00000110,  B01011011,  B01001111,  B01100110,
    // 5           6           7          8          9
  B01101101,  B01111101,  B00000111,  B01111111,  B01101111 
  };
 
void setup () 
  {
  // alle Bits von Port D auf Ausgang
  DDRD = B11111111;
  }

void loop() 
  {
  // Anzeige setzen
  PORTD = segmente[ziffer]; 
  // ziffer modulo 10 hochzaehlen
  ziffer = (ziffer + 1)%10;
  delay(1000);
  }

Um auf die gleiche Art und Weise Daten einzulesen, wird das Data Direction Register (DDRx) eines Ports auf Eingabe konfiguriert, indem die entsprechenden DDR-Bits auf 0 gesetzt werden. Der Status der Eingabe-Pins des jeweiligen Ports kann dann über das PINx Register abgefragt werden. Für das folgende Beispiel werden die Bits 0 und 1 von Port B als Eingang verwendet. Das entspricht den Arduino-Pins D8 und D9.

Damit die Schalter oder Taster an den Eingängen nicht beide Logik-Potentiale liefern müssen, nimmt man normalerweise einen Widerstand von 10 bis 50 kΩ und schaltet diesen gegen +5 V. Der Taster/Schalter muss dann nur gegen GND schalten. Beim Arduino kann man eingebaute Pullup-Widerstand verwenden. Es gibt nämlichPullup-Widerstände, die in den Atmega-Chip integriert sind und auf die man per Software zugreifen kann, indem man den PinMode() als INPUT_PULLUP definiert. Diese Widerstände haben einen Wert von 20 kΩ bis 50 kΩ. Dadurch wird das Verhalten des Eingangs aber auch umgekehrt, denn HIGH bedeutet, dass der Eingang offen ist und LOW bedeutet, dass der Taster/Schalter geschlossen ist. Im Programm ist deshalb oft eine Negation der Daten notwendig. Bei der direkten Portmanipulation erreicht man dies durch Schreiben einer '1' auf das entsprechende Input-Bit.

void setup() 
  {
  Serial.begin(9600);  // seriele Schnittstelle anschalten
  DDRB = 0;            // all Bits von Port B auf Input
  PORTB = B00000011;   // Pullup-Widerstaende einschalten
  }

void loop() 
  {
  boolean schalter1, schalter2;

  taste1 = !(PINB & B0000001);  // Schalter 1 auf '0'?
  taste2 = !(PINB & B0000010);  // Taste 1 auf '0'?

  if (taste1) 
    { Serial.println("Taste 1"); }
  else if (taste2)
    { Serial.println("Taste 2"); }
  else  
    { Serial.println("KEINE Taste"); }
   }

Was genau bedeutet der Ausdruck taste1 = !(PINB & B0000001) im Programm oben?

  1. Zuerst wird der Port B eingelesen.
  2. Dann wird per UND-Verknüpfung das Bit 0 ausmaskiert. Ist keine Taste gedrückt, wirkt der Pullup-Widerstand → 1 & 1 = 1. Ist die Taste gedrückt, gilt → 0 & 1 = 0.
  3. Die Invertierung über das vorangestellt Ausrufezeichen stellt die "normale" Logik her, Taste gdrückt → 1, Taste nicht gedrückt → 0.

Manipulation einzelner Bits

Es kommt eher selten vor, dass man jedesmal einen kompletten Port lesen oder schreiben muss. Wenn man zum Beispiel eine LED einschaten oder eine Taste einlesen will, soll bei der Ausgabe nur jeweils ein Pin geändert werden und bei der Eingabe analog nur ein Pin gelesen. Das ist mit den Standardfunktionen digitalWrite() und digitalRead() recht einfach. Da nun aber auf den kompletten Port als Byte zugegriffen wird, ändern sich auch die Algorithmen.

Zur Erinnerung: Bitoperationen in C

  • bitweises ODER → Setzen eines Bits:
    DDRD = DDRD | B00000110;  // setzt das zweite und dritte Bit, ohne die anderen zu ändern
    DDRD |= B00000110;        // Kurzform 
    
  • bitweises UND → Löschen eines Bits:
    DDRD = DDRD & B11111101; // löscht das zweite Bit, ohne die anderen zu verändern
    DDRD &= B11111101;       // Kurzform
    
  • bitweises EXOR → einzelne Bits invertieren:
    DDRD = DDRD ^ B11111100; // invertiert die ersten beiden Bits, ohne die anderen zu verändern
    DDRD ^= B11111100;       // Kurzform
    
  • bitweises NOT → alle Bits invertieren:
    DDRD = ~B11111100;       // invertiert alle Bits
    

Nicht nur die Ports und Datenrichtungsregister (DDRx, PORTx, PINx) sind als Konstante bereits vordefiniert, sondern auch die Werte der einzelnen Bits (z. B. PB0, PB1 ... PB6, PB7, PC0 ... PC7, PD0 ... PD7). Dabei ist PB0 = B00000000, PB1 = B00000001, PB2 = B00000010 usw. Es sind im Prinzip nur Namen für die Zahlen von 0 bis 7. Wozu man so etwas benötigt? Um die Programme verständlicher und lesbarer zu machen - und damit sind sie auch leichter zu ändern und anzupassen.

Nicht nur bei der Ein- und Ausgabe, sondern auch beim beim Konfigurieren von z. B. den Datenrichtungsregistern ist es wichtig, dass man eintelne Bits verändern kann und so nur die Pins setzt, die man auch nutzt. Damit vermeidet man Seiteneffekte, die den Programmablauf stören können. Setzt man beispielsweise das DDRB komplett auf 0, ist auch der Pin 13 (PB5) als Eingang geschaltet und man kann die interne LED des Boards nicht mehr nutzen. Um solche Effekte zu vermeiden, sollte man die oben aufgeführten Möglichkeiten der Bitmanipulation nutzen.

Um zum Beispiel den Pin PB0 als Eingang und PB5 als Ausgang zu nutzen stellen Sie in Ihrem Code folgendes ein (zur Erinnerung: '<<' bedeutet 'nach link schieben'):

void setup() 
  {
  // den Wert 1 0x nach links schieben ergibt B00000001
  // das dann invertiert ergibt B11111110
  // Die UND-Verknüpfung setzt nur das Bit 0 auf 0
  DDRB &= ~(1 << PB0);  

  // den Wert 1 5x nach links schieben ergibt B00100000
  // Die ODER-Verknüpfung mit dem vorheriegen Wert setzt nur Bit 5 auf '1' 
  DDRB |= (1 << PB5);
  }
Zweck des Ganzen ist, wie schon erwähnt, dass nur die Bits geändert werden, die man wirklich ändern will und alle anderen Bits unversehrt bleiben.

Bei der Ein- und Ausgabe verfährt man ähnlich. Ist ein Pin wie oben als Ausgang konfiguriert, kann man über das Register PORTB den Pin auf HIGH oder LOW ssetzen.

   ...
  PORTB |= (1 << PB5);  // PB5 High -> LED an
   ...
  PORTB &= ~(1 << PB5); // PB5 Low -> LED aus
   ...
Einen Eingang kann man auf die gleiche Art und Weise einlesen. Hier muss das gewünschte Bit ausgeblendet werden, um dessen Wert zu erhalten, z. B.:
   ...
  X = PINB & (1 << PB0);  
   ...

Zur Demonstration mal wieder das übliche "Blink"-Programm, aber diesmal mit Port-Manipulation und Einlesen von Port B, Bit 0. Vergleichen Sie das Programm doch mal mit dem Original-Beispiel.

void setup() 
  {
  Serial.begin(9600);  // seriele Schnittstelle anschalten
  DDRB &= ~(1 << PB0); // Bit 0 Eingang
  DDRB |= (1 << PB5);  // Bit 5 Ausgang (LED)
  PORTB |= (1 << PB0); // Pullup-Widerstand einschalten,
                       // dann genuegt eine Taste gegen GND
  }

void loop() 
  {
  byte X;
  // Blink-Funktion
  PORTB |= (1 << PB5);  // PB5 High -> LED an
  delay(1000);               
  PORTB &= ~(1 << PB5); // PB5 Low -> LED aus
  delay(1000);

  // Eingangspin 8 einlesen und ausgeben
  X = PINB & (1 << PB0);
  Serial.println(X);
  }
Primär geht es darum, alle Informationen so lesbar und wartbar wie möglich zu machen aber gleichzeitig die Vorteile der direkten Portmanipulation zu nutzen. Deshalb die Konstantennamen und die Schiebebefehle anstelle von sogenannten "magic numbers", die später keiner mehr versteht. Mit dem Bitoperatinen UND (&), ODER (|), NICHT (~) und EXOR (^) kann man dann gezielt die einzelnen Portbits setzen, rücksetzen oder invertieren.


Copyright © Hochschule München, FK 04, Prof. Jürgen Plate
Letzte Aktualisierung: