AVR in C: Blink

Beispiele mit einer oder mehreren Leuchtdioden (LED).

Dieser Artikel zeigt, wie man die LED an einem Arduino-Board ohne Verwendung des Frameworks zum Leuchten bringt.


Table of Contents


Datenblatt besorgen

In der Mikrocontrollerprogrammierung ist es sehr wichtig zu verstehen, wie sich der Mikrocontroller verhalten wird. Daher ist das Datenblatt ein ständiger Begleiter.

Im Datenblatt zum ATmega328P steht auf der zweiten Seite, dass dieser über 23 programmierbare Ein- beziehungsweise Ausgänge verfügt. Diese werden im Kapitel 13 "I/O-Ports" näher beschrieben.

Da die LED nicht nur leuchten, sondern auch blinken soll, wird auch ein Timer/Counter benötigt um die verstrichene Zeit zu zählen. Die Timer werden in den Kapiteln 14 und 15 beschrieben.

 

Eine LED blinken lassen

Auf dem Arduino-Board ist eine LED ist zwischen PB5 und GND (Masse) angeschlossen. Um diese anzusteuern, muss PB5 als Ausgang konfiguriert werden. Anschließend kann man PB5 umschalten, um nach einer Verzögerung von vorn zu beginnen. Die LED blinkt.

Ausgänge sind Wechselschalter, die zwischen GND und VCC umschalten können. Man erhält an dem Pin eine (geringfügig belastbare) Verbindung entweder zur Masse (0 V) oder zur Betriebsspannung (5 V).

Im Programm können Ausgänge dann jederzeit umgeschaltet werden und man spricht dabei von den Zuständen LOW (0) und HIGH (1).

Welche Register werden benötigt?

Wie im Kapitel 13 beziehungsweise 13.2 beschrieben sind für die Ein- und Ausgabefunktionalität der Pins drei Register je Port zuständig: DDRx, PORTx und PINx.

Data Direction Register (DDRx)

Der Inhalt eines solchen Registers bestimmt den Modus eines Pins. Ist ein Bit gesetzt fungiert der Pin als Ausgang und andernfalls als Eingang:

Jedes Bit kann gelesen und geschrieben werden und alle Bits sind nach einem RESET gelöscht.

I/O Port Register (PORTx)

Jedes Bit in diesem Register bestimmt den Zustand eines Ausgangs. Setzt man ein Bit so wird auch der jeweilige Ausgang gesetzt:

Jedes Bit kann gelesen und geschrieben werden und alle Bits sind nach einem RESET gelöscht.

Pin Register (PINx)

Jedes Bit kann gelesen werden und es wird immer der tatsächliche Zustand zurückgegeben. Ist ein Pin jedoch als Ausgang konfiguriert und wird dessen Bit hier gesetzt, dann wird das jeweilige PORTxn-Bit und somit der Ausgang umgeschaltet. Das heißt, von LOW nach HIGH oder von HIGH nach LOW.

Hier wird angegeben, dass jedes Bit nur gelesen werden kann. Tatsächlich verhält sich diese Art von Register etwas anders.

Wie ändert man den Modus eines Pins?

Da beim Start des Mikrocontrollers alle GPIOs als Eingang voreingestellt werden, muss PB5 zu einem Ausgang gemacht werden. Ob ein Pin als Eingang oder Ausgang fungiert, wird über das jeweilige Data Direction Register festgelegt - hier DDRB:

void main() {
  DDRB = DDRB | bit(5);
  for (;;) {
  }
}

Das Programm konfiguriert PB5 als Ausgang und hält die Ausführung dann an.

Wie schaltet man einen Ausgang um?

Nun kann man über das zugehörige Port Register PORTB den Ausgangspegel ändern. Dazu werden alle Bits des Registers gelesen, das jeweilige Bit gesetzt oder gelöscht und dann wieder ins Register geschrieben. Zum Setzen (HIGH):

PORTB = PORTB | bit(5);

Und zum Löschen (LOW):

PORTB = PORTB & !( bit(5) );

Möchte man einen Ausgang umschalten, also von LOW nach HIGH oder von HIGH nach LOW, dann kann man auch das Pin Register PINB dafür verwenden. Setzt man ein Bit im PINx Register, dann wird das zugehörige Bit im PORTx Register umgeschaltet:

PINB = PINB | bit(5);

Kompletter Quelltext am Stück:

void main() {
  DDRB = DDRB | bit(5);
  for (;;) {
    PINB = PINB | bit(5);
  }
}

Das Programm konfiguriert PB5 als Ausgang und schaltet dann kontinuierlich um. Dies passiert jedoch zu schnell und die LED wird, wenn überhaupt, nur schwach glimmen.

Wie verzögert man den Programmablauf?

Je nach gewähltem Takt wiederholt sich der Wechsel nun Tausend- bis Millionen-fach. Um ein sichtbares Blinken zu erzeugen, muss zwischen den Umschaltvorgängen eine kurze Verzögerung eingefügt werden.

Da der Takt und die Anzahl an Taktzyklen je Befehl bekannt ist, kann man sich dies zu nutze machen und einen Befehl eine gewisse Anzahl wiederholen. Eben so lang, bis die Zeit verstrichen ist.

Die Berechnung und die Schleife dafür muss man nicht extra schreiben, denn das SDK bietet hierfür schon eine fertige Lösung an:

#include <util/delay.h>

Darin enthalten ist eine _delay_ms(ms)-Funktion. Diese hält den Programmablauf an und führt die Ausführung erst nach Ablauf der übergebenen Anzahl von Millisekunden fort.

Die Anwendung lässt sich leicht an folgendem Beispiel demonstrieren:

#include <util/delay.h>

void main() {
  DDRB |= bit(5);
  for (;;) {
    PINB |= bit(5);
    _delay_ms(1000);
  }
}

Das Programm konfiguriert PB5 als Ausgang und geht dann in eine Endlosschleife aus Umschalten und Abwarten.

Sollen mehrere Aufgaben gleichzeitig abgearbeitet werden oder mit anderen Bausteinen kommuniziert werden, dann kann die _delay_ms(ms)-Funktion zu Problemen führen. Deshalb ist es wichtig, auch das nächste Kapitel zu verstehen!


Warten ohne zu blockieren

Sollen mehrere Tätigkeiten gleichzeitig ausgeführt werden, kann der Programmablauf nicht einfach angehalten werden. Es muss also eine andere Lösung gefunden werden.

Eine Lösung wäre die seit dem RESET verstrichene Zeit zu zählen und mit einem gespeicherten Zeitpunkt zu vergleichen. Der Programmablauf wird dann verzweigt und die Befehle nur ausgeführt, falls die Bedingung zutrifft:

unsigned long now = millis();
if ((now - last) >= interval) {
  last = now;
  // mindestens 1 Sekunde verstrichen..
}

Doch die Funktion millis() gibt es ohne Arduino-Framework nicht und muss kurzerhand selbst geschrieben werden.

Wie kann man die Zeit zählen?

Die Timer/Counter können im Takt und in verschiedene Richtungen zählen. Immer aufwärts, immer abwärts oder auf- und abwärts - dies benötigt man beispielsweise bei der Generierung von Wellenformen (Sinus, Rechteck, Sägezahn, ...).

Dabei kann der 8-bit Timer/Counter0 von 0 bis 255 zählen und der 16-bit Timer/Counter1 kann von 0 bis 65535 zählen.

Zusätzlich lässt sich ein Vergleichswert einstellen, der beim Erreichen einen Alarm auslöst - der sogenannte Clear Timer on Compare Match (CTC) Modus.

Es wird zu einem späteren Zeitpunkt einen Artikel geben, der die Timer/Counter noch detailierter beschreiben wird.

Um das Zählen zu verlangsamen verfügen die Timer/Counter über Vorteiler. Diese verringern den Takt um den eingestellten Wert. Mögliche Werte sind hier 8, 64, 256 und 1024.

Bei einem Takt von 16 MHz und einem Vorteiler von 8 wird der Timer/Counter mit 2 MHz zählen. Würde man Timer/Counter 0 verwenden, würde der Zählerstand also alle 500ns beziehungsweise alle 0,0005 ms erhöht werden.

Ziel ist es jedoch einen Alarm jede Millisekunde auszulösen. Würde man mit Vorteiler 8 und dem Maximum von Timer0 zählen, dann würde der Alarm alle ~0,13ms ausgelöst werden.

Es muss also entweder Timer1 mit einem größeren Zählbereich oder ein anderer Vorteiler gewählt werden. Da die Prozedur beziehungsweise der Vorteiler und der Vergleichswert vom Takt abhängen gibt es keine Generallösung.

Das Ziel sollte immer eine Abweichung von 0 sein.

Mit einem Vorteiler von 64 reduziert sich der Takt von 16 MHz schon auf 250 kHz und somit ließe sich mit Timer/Counter 0 und ohne Abweichung ein Takt von 1 kHz erzeugen. Dazu setzt man den Vergleichswert auf 249 (250-1) und erhält einen Alarm jede Millisekunde.

Wie zählt man Millisekunden?

Sobald der Vergleichswert erreicht wird, kann der Alarm mit einer sogenannten Interrupt Service Routine (ISR) abgefangen werden. Dazu muss eine Funktion mit dem Namen TIMERx_COMPA als ISR registriert werden:

volatile unsigned long timer_millis = 0;

ISR(TIMER0_COMPA_vect) { timer_millis++; }

void main() {
  for (;;) {}
}

Das Schlüsselwort volatile besagt, dass diese Variable indirekt addressiert wird und daher vom Compiler nicht wegoptimiert werden darf. Innerhalb der Routine wird bei jedem Aufruf eine globale Variable um 1 erhöht.

Wie startet man einen Zähler?

Doch noch passiert nichts, denn der Timer/Counter 0 muss ja noch eingestellt werden:

Es wird zu einem späteren Zeitpunkt einen Artikel geben, der die Timer/Counter noch detailierter beschreiben wird.

volatile unsigned long timer_millis = 0;

ISR(TIMER0_COMPA_vect) { timer_millis++; }

void main() {
  TCCR0A |= (1 << WGM01);
  TCCR0B |= (1 << CS01) | (1 << CS00);
  OCR0A = 249;
  TIMSK0 |= (1 << OCIE0A);
  for (;;) {}
}

Der Timer/Counter 0 löst nun jede Millisekunde einen Alarm aus, der eine Routine aufruft, die eine globale Variable um eins erhöht. Doch eine kleine Hürde gibt es noch..

Wie ruft man die Anzahl von Millisekunden ab?

Aufgrund der Größe der globalen Variable, muss beim Lesen darauf geachtet werden, dass diese nicht zwischenzeitlich überschrieben wird.

Bei einem Alarm wird der Programmablauf angehalten, die Ausführungsposition gesichert und, falls registriert, eine Routine aufgerufen. Nach Rückkehr aus der ISR wird der Programmablauf fortgesetzt.

Da in der ISR ein Wert erhöht wird, könnte dies dazu führen, dass das Hauptprogramm einen veralteten oder ungültigen Wert erhält. Daher muss eine Funktion geschrieben wird, die kurzzeitig alle Alarm deaktiviert:

unsigned long millis(void)
{
  unsigned long res;
  ATOMIC_BLOCK(ATOMIC_FORCEON)
  {
    res = timer_millis;
  }
  return res;
}

Nun kann man die millis() Funktion wie zuvor beschrieben einsetzen:

unsigned long interval = 1000;

volatile unsigned long timer_millis = 0;

ISR(TIMER0_COMPA_vect) { timer_millis++; }

unsigned long millis(void) {
  unsigned long res;
  ATOMIC_BLOCK(ATOMIC_FORCEON) {
    res = timer_millis;
  }
  return res;
}

void main() {
  TCCR0A |= (1 << WGM01);
  TCCR0B |= (1 << CS01) | (1 << CS00);
  OCR0A = 249;
  TIMSK0 |= (1 << OCIE0A);

  DDRB |= bit(5);

  unsigned long last = 0;
  for (;;) {
    unsigned long now = millis();
    if ((now - last) >= interval) {
      last = now;
      PINB |= bit(5);
    }
    // ... andere Dinge erledigen ...
  }
}

Die LED schaltet sich im Sekundentakt ein und wieder aus.

 

Related post