Zum Inhalt springen

C-Programmierung mit AVR-GCC: Druckversion

Aus Wikibooks


Über dieses Buch

Dies ist ein Buch für Personen jeden Alters, die sich mit den Grundlagen der Programmierung von Microcontrollern der AVR Familie des Herstellers Atmel in der Programmiersprache C vertraut machen wollen.

Das Buch wurde explizit für Einsteiger in die Welt der Microntroller entworfen. Es werden keine Erfahrungen im Umgang mit Microcontrollern benötigt.

Als einzige Voraussetzung ist ein gewisses Grundwissen über die Programmiersprache C gefordert. Im Vergleich zu gewöhnlichen in der Programmiersprache C geschriebenen Projekten, stellt die Programmierung von AVR Microcontrollern einen Sonderfall dar. Der Einsatz komplexer Datenstrukturen und Algorithmen ist hier in der Regel nicht nur nicht erforderlich. In den meisten Fällen verbietet er sich zudem schon allein aufgrund extrem begrenzter Ressourcen. Es genügen also in der Tat bereits minimale Grundkenntnisse der Programmiersprache C.

Benötige Materialien

Benötigte Hardware

Arduino Duemilanove
Arduino Serial
Steckplatine mit AtMega328p
RS 232 zu USB Konverter
AVR ISP Programmer

Programme für den Microcontroller werden am PC bearbeitet und übersetzt. Das fertige Programm wird dann auf den Microcontroller übertragen. Neben einem funktionierenden PC benötigst Du daher sowohl einen Microcontroller der AVR Familie, als auch eine Möglichkeit auf dem PC erstellte Programme auf den Microcontroller zu übertragen. Zu diesem Zweck stehen mehrere Möglichkeiten zur Verfügung.

Die einfachste Möglichkeit besteht in einem fertigen Arduino Experimentier-Board mit USB Schnittstelle. Boards dieser Sorte enthalten neben dem Microcontroller auch einen USB zu RS-232 Konverter. Microcontroller, die mit diesen Boards ausgeliefert werden, sind mit einem speziellen Bootloader vorprogrammiert, der es ermöglicht Programme über die serielle Schnittstelle auf den Microcontroller zu übertragen. Alles, was Du bei dieser Variante tun musst, ist, das Board mit USB Kabel mit dem PC zu verbinden. Ein Programm, dass die Übertragung übernimmt, ist in den Werkzeugen enthalten.

Diese Möglichkeit ist zugleich die in diesem Buch empfohlene, wenn Du das erste Mal mit einem Microcontroller arbeitest. Alle Beschreibungen und Beispiele des Buchs beziehen sich auf diese Möglichkeit. Sie wurden auf einem Arduino Duemillanove Board mit AtMega328p getestet.

Wenn Du bereits ein wenig Erfahrung im Umgang mit Microcontrollern gesammelt hast, kannst Du überlegen, ob Du eine der anderen Alternativen wählst.

Mit dem beschriebenen Bootloader vorprogrammierte Microcontroller sind im Handel erhältlich. Bereits mit minimaler Beschaltung können sie in Betrieb und über die seriellen Schnittstelle programmiert werden. Mit einem USB zu RS-232 Konverter Kabel kann das Programm auf dem gleichen Weg auf den Microcontroller übertragen werden.

Die aufwändigste Möglichkeit besteht in der Benutzung spezieller Hardware, mit der das Programm über die SPI Programmierschnittstelle aufgespielt werden kann. Mit dieser Methode bist Du nicht mehr darauf angewiesen einen Microcontroller mit vorinstalliertem Bootloader zu verwenden.

Für einige in diesem Buch vorgeschlagenen Projekte benötigst Du neben den in der Anleitung aufgeführten elektronischen Bauteilen auch eine Steckplatine oder einen Lötkolben samt Zubehör.

Benötigte Software

Um C-Programme für AVR Microcontroller zu übersetzen werden ein C-Compiler und eine Implementierung der C-Standardbibliothek benötigt. Sowohl die in diesem Buch verwendete Compiler Suite avr-gcc, als auch die C Bibliothek AVR Libc kommen aus dem GNU Projekt. Informationen darüber, wie Du die genannte Software auf dem PC installieren kannst, findest Du im nächsten Kapitel.

Wikibook

Dieses Buch ist kein gewöhnliches Buch, sondern ein Wikibook. Anders als bei einem klassischen Buch sind Verbesserungen und Erweiterungen eines Wikibooks nicht nur jederzeit sondern zugleich auch durch jede Nutzerin und jeden Nutzer möglich. Auch Du bist herzlich dazu eingeladen Dich jederzeit an der Verbesserung und Erweiterung dieses Buchs zu beteiligen.

Auch wenn Du Dich nicht dazu entschließen solltest selbst als Autorin oder Autor tätig zu werden, kannst Du zur Verbesserung dieses Buches beitragen. Für kleinere Korrekturen von Rechtschreibung und Grammatik, die Dir beim Lesen auffallen, sind Dir nicht nur Autoren, sondern auch nach Dir kommende Leser dankbar.

Hinweise zu Stellen die unverständlich, unklar oder missverständlich formuliert sind, kannst Du auf der zugehörigen Diskussionsseite hinterlassen. Auch Fragen zu Themen, die im Buch bisher viel zu knapp oder gar nicht beschrieben werden, kannst Du auf der Diskussionsseite stellen. Anregungen und Wünsche für die Ergänzung bestehender Kapitel oder für das hinzufügen weiterer Kapitel werden dort ebenfalls gern in Empfang genommen.

Je genauer Du dabei Deine Hinweise formulierst, umso höher ist die Chance, dass sie schon bei der nächsten Überarbeitung berücksichtigt werden können. Auf Rückmeldungen wie ‚das Buch ist hässlich‘ oder ‚der Autor ist doof‘ solltest Du deshalb nach Möglichkeit verzichten. Auch wenn der zweite Punkt sicher richtig ist, liefern Hinweise wie diese kaum genug Anhaltspunkte um den bestehenden Mangel zu beheben.

Rechtschreibung und Form der Anrede

Das Buch ist darum bemüht, sich an die aktuellen Regeln zur Rechtschreibung zu halten.

Eine (mögliche) Ausnahme hiervon ist die in diesem Buch verwendete Form der Anrede. Wie Dir vermutlich bereits aufgefallen ist, wird der Leser in diesem Buch durchgehend mit der Anrede ‚Du‘ angesprochen. Sowohl Nähe, als auch Respekt vor dem Leser sollen durch diese Schreibweise zum Ausdruck gebracht werden. Nach Ansicht des Verfassers können weder die Verwendung eines respektvoll distanzierten ‚Sie‘, noch die Verwendung eines respektlos nahen ‚du‘ beide Aspekte in gleichem Maße transportieren.

Das gute an diesem Buch ist, dass Du es ganz einfach zur Seite legen kannst. Wenn Du Dich ganz und gar nicht mit der gewählten Anredeform anfreunden kannst, bleibt Dir diese Option stets offen. Niemand zwingt Dich dieses Buch zu lesen.

Falls es doch jemanden gibt, der Dich zum Lesen dieses Buchs zwingt, dann solltest Du diesen Umstand nicht nur kritisch hinterfragen, sondern auch nach allen Regeln der Kunst Widerstand dagegen leisten.

Typographische Konventionen

Um Dir das Lesen des Buchs und das Auffinden von Informationen zu erleichtern, bemüht sich dieses Buch darum sich an eine Reihe typographischer Konventionen zu halten. Für den Fall, dass Du Dich selbst als Autor betätigen möchtest, findest Du im Anschluss an die jeweilige Erklärung sowohl den Wikitext, der für die jeweilige Formatierung nötig ist, als auch den generierten HTML Code.

Definition

Fachbegriffe und Begriffe, die im Rahmen des jeweiligen Kontexts eine besondere Bedeutung haben, sind an der Stelle, an der sich erstmalig auftreten und näher definiert werden in fetter Schrift gesetzt. Solltest Du beim Lesen feststellen, dass Dir die Bedeutung eines Fachbegriffs entfallen ist, oder Du ein Detail noch einmal nachlesen wollen, so kannst Du die betreffende Stelle auf diesem Weg schnell finden.

Wiki Markup: '''fetter Text''' HTML Code: <b>fetter Text</b>

Fachbegriff

Nach ihrer Definition im Text auftretende Fachbegriffe und Begriffe, die mit einander besonderen Bedeutung belegt sind, sind in kursiver Schrift gesetzt. Auf diese Weise kannst Du schnell erkennen, ob ein bestimmtes Wort in seiner alltäglichen Bedeutung verwendet wird, oder in einer für den aktuellen Kontext besonderen Bedeutung auftritt. Auch an Stellen, an den Missverständnisse vermieden werden sollen, wird das betonte Wort in kursiv gesetzt.

Wiki Markup: ''kursiver Text'' HTML Code: <em>kursiver Text</em>

Eingabe

Text, den Du selbst eingibst.

Wiki Markup: <kbd>text</kbd> HTML Code: <kbd>text</kbd>

Programmausgabe

Die Augabe von Programmen

Wiki Markup: <samp>text</samp> HTML Code: <samp>text</samp>

Parameter

Variable Parameter in Befehlen, die Du selbst eingibst und variable Parameter in der Ausgabe von Programmen.

Wiki Markup: <var>text</var> HTML Code: <var>text</var>

Zum Quellcode

Bei der Formulierung des Quellcodes in diesem Buch wurde besondere Aufmerksamkeit auf gute Lesbarkeit gelegt. Auch an Stellen, an denen es wegen Rangfolge und Assoziativität der Operatoren nicht zwingend erforderlich ist, wurde versucht die Reihenfolge der Auswertung durch setzten zusätzlicher Klammern explizit deutlich zu machen. Im Zweifelsfall im Interesse besserer Lesbarkeit zudem auf effizientere Formulierungsmöglichkeiten verzichtet.

Die Programmbeispiele in diesem Buch sind zwar vollständig in dem Sinne, dass sie die einzelnen Aspekte des jeweiligen Kapitels genauer beleuchten, dennoch bieten sie zahlreiche Möglichkeiten zur Verbesserung und Erweiterung. Insbesondere der Fehlerbehandlung wurde bei der Formulierung nur geringe Priorität eingeräumt.

Kürze und Unvollständigkeit der Codebeispiele sind nur zum Teil auf sparsamen Umgang mit dem zur Verfügung stehenden Platz zurückzuführen, sie sollen Dich auch dazu motivieren den wiedergegebenen Quellcode zu verbessern und zu erweitern. Um Dich bei der Programmierung möglichst schnell zurecht zu finden und Dir die wichtigsten Dinge schnell anzueignen ist es erforderlich, dass Du neben der Lektüre auch selbst aktiv an der Erstellung von Quellcode arbeitest. Die Codebeispiele in diesem Buch sind dazu gedacht Dich hierzu einladen.


Quickstart Linux

Dies ist ein Buch für Personen jeden Alters, die sich mit den Grundlagen der Programmierung von Microcontrollern der AVR Familie des Herstellers Atmel in der Programmiersprache C vertraut machen wollen.

Das Buch wurde explizit für Einsteiger in die Welt der Microntroller entworfen. Es werden keine Erfahrungen im Umgang mit Microcontrollern benötigt.

Als einzige Voraussetzung ist ein gewisses Grundwissen über die Programmiersprache C gefordert. Im Vergleich zu gewöhnlichen in der Programmiersprache C geschriebenen Projekten, stellt die Programmierung von AVR Microcontrollern einen Sonderfall dar. Der Einsatz komplexer Datenstrukturen und Algorithmen ist hier in der Regel nicht nur nicht erforderlich. In den meisten Fällen verbietet er sich zudem schon allein aufgrund extrem begrenzter Ressourcen. Es genügen also in der Tat bereits minimale Grundkenntnisse der Programmiersprache C.

Benötige Materialien

Benötigte Hardware

Arduino Duemilanove
Arduino Serial
Steckplatine mit AtMega328p
RS 232 zu USB Konverter
AVR ISP Programmer

Programme für den Microcontroller werden am PC bearbeitet und übersetzt. Das fertige Programm wird dann auf den Microcontroller übertragen. Neben einem funktionierenden PC benötigst Du daher sowohl einen Microcontroller der AVR Familie, als auch eine Möglichkeit auf dem PC erstellte Programme auf den Microcontroller zu übertragen. Zu diesem Zweck stehen mehrere Möglichkeiten zur Verfügung.

Die einfachste Möglichkeit besteht in einem fertigen Arduino Experimentier-Board mit USB Schnittstelle. Boards dieser Sorte enthalten neben dem Microcontroller auch einen USB zu RS-232 Konverter. Microcontroller, die mit diesen Boards ausgeliefert werden, sind mit einem speziellen Bootloader vorprogrammiert, der es ermöglicht Programme über die serielle Schnittstelle auf den Microcontroller zu übertragen. Alles, was Du bei dieser Variante tun musst, ist, das Board mit USB Kabel mit dem PC zu verbinden. Ein Programm, dass die Übertragung übernimmt, ist in den Werkzeugen enthalten.

Diese Möglichkeit ist zugleich die in diesem Buch empfohlene, wenn Du das erste Mal mit einem Microcontroller arbeitest. Alle Beschreibungen und Beispiele des Buchs beziehen sich auf diese Möglichkeit. Sie wurden auf einem Arduino Duemillanove Board mit AtMega328p getestet.

Wenn Du bereits ein wenig Erfahrung im Umgang mit Microcontrollern gesammelt hast, kannst Du überlegen, ob Du eine der anderen Alternativen wählst.

Mit dem beschriebenen Bootloader vorprogrammierte Microcontroller sind im Handel erhältlich. Bereits mit minimaler Beschaltung können sie in Betrieb und über die seriellen Schnittstelle programmiert werden. Mit einem USB zu RS-232 Konverter Kabel kann das Programm auf dem gleichen Weg auf den Microcontroller übertragen werden.

Die aufwändigste Möglichkeit besteht in der Benutzung spezieller Hardware, mit der das Programm über die SPI Programmierschnittstelle aufgespielt werden kann. Mit dieser Methode bist Du nicht mehr darauf angewiesen einen Microcontroller mit vorinstalliertem Bootloader zu verwenden.

Für einige in diesem Buch vorgeschlagenen Projekte benötigst Du neben den in der Anleitung aufgeführten elektronischen Bauteilen auch eine Steckplatine oder einen Lötkolben samt Zubehör.

Benötigte Software

Um C-Programme für AVR Microcontroller zu übersetzen werden ein C-Compiler und eine Implementierung der C-Standardbibliothek benötigt. Sowohl die in diesem Buch verwendete Compiler Suite avr-gcc, als auch die C Bibliothek AVR Libc kommen aus dem GNU Projekt. Informationen darüber, wie Du die genannte Software auf dem PC installieren kannst, findest Du im nächsten Kapitel.

Wikibook

Dieses Buch ist kein gewöhnliches Buch, sondern ein Wikibook. Anders als bei einem klassischen Buch sind Verbesserungen und Erweiterungen eines Wikibooks nicht nur jederzeit sondern zugleich auch durch jede Nutzerin und jeden Nutzer möglich. Auch Du bist herzlich dazu eingeladen Dich jederzeit an der Verbesserung und Erweiterung dieses Buchs zu beteiligen.

Auch wenn Du Dich nicht dazu entschließen solltest selbst als Autorin oder Autor tätig zu werden, kannst Du zur Verbesserung dieses Buches beitragen. Für kleinere Korrekturen von Rechtschreibung und Grammatik, die Dir beim Lesen auffallen, sind Dir nicht nur Autoren, sondern auch nach Dir kommende Leser dankbar.

Hinweise zu Stellen die unverständlich, unklar oder missverständlich formuliert sind, kannst Du auf der zugehörigen Diskussionsseite hinterlassen. Auch Fragen zu Themen, die im Buch bisher viel zu knapp oder gar nicht beschrieben werden, kannst Du auf der Diskussionsseite stellen. Anregungen und Wünsche für die Ergänzung bestehender Kapitel oder für das hinzufügen weiterer Kapitel werden dort ebenfalls gern in Empfang genommen.

Je genauer Du dabei Deine Hinweise formulierst, umso höher ist die Chance, dass sie schon bei der nächsten Überarbeitung berücksichtigt werden können. Auf Rückmeldungen wie ‚das Buch ist hässlich‘ oder ‚der Autor ist doof‘ solltest Du deshalb nach Möglichkeit verzichten. Auch wenn der zweite Punkt sicher richtig ist, liefern Hinweise wie diese kaum genug Anhaltspunkte um den bestehenden Mangel zu beheben.

Rechtschreibung und Form der Anrede

Das Buch ist darum bemüht, sich an die aktuellen Regeln zur Rechtschreibung zu halten.

Eine (mögliche) Ausnahme hiervon ist die in diesem Buch verwendete Form der Anrede. Wie Dir vermutlich bereits aufgefallen ist, wird der Leser in diesem Buch durchgehend mit der Anrede ‚Du‘ angesprochen. Sowohl Nähe, als auch Respekt vor dem Leser sollen durch diese Schreibweise zum Ausdruck gebracht werden. Nach Ansicht des Verfassers können weder die Verwendung eines respektvoll distanzierten ‚Sie‘, noch die Verwendung eines respektlos nahen ‚du‘ beide Aspekte in gleichem Maße transportieren.

Das gute an diesem Buch ist, dass Du es ganz einfach zur Seite legen kannst. Wenn Du Dich ganz und gar nicht mit der gewählten Anredeform anfreunden kannst, bleibt Dir diese Option stets offen. Niemand zwingt Dich dieses Buch zu lesen.

Falls es doch jemanden gibt, der Dich zum Lesen dieses Buchs zwingt, dann solltest Du diesen Umstand nicht nur kritisch hinterfragen, sondern auch nach allen Regeln der Kunst Widerstand dagegen leisten.

Typographische Konventionen

Um Dir das Lesen des Buchs und das Auffinden von Informationen zu erleichtern, bemüht sich dieses Buch darum sich an eine Reihe typographischer Konventionen zu halten. Für den Fall, dass Du Dich selbst als Autor betätigen möchtest, findest Du im Anschluss an die jeweilige Erklärung sowohl den Wikitext, der für die jeweilige Formatierung nötig ist, als auch den generierten HTML Code.

Definition

Fachbegriffe und Begriffe, die im Rahmen des jeweiligen Kontexts eine besondere Bedeutung haben, sind an der Stelle, an der sich erstmalig auftreten und näher definiert werden in fetter Schrift gesetzt. Solltest Du beim Lesen feststellen, dass Dir die Bedeutung eines Fachbegriffs entfallen ist, oder Du ein Detail noch einmal nachlesen wollen, so kannst Du die betreffende Stelle auf diesem Weg schnell finden.

Wiki Markup: '''fetter Text''' HTML Code: <b>fetter Text</b>

Fachbegriff

Nach ihrer Definition im Text auftretende Fachbegriffe und Begriffe, die mit einander besonderen Bedeutung belegt sind, sind in kursiver Schrift gesetzt. Auf diese Weise kannst Du schnell erkennen, ob ein bestimmtes Wort in seiner alltäglichen Bedeutung verwendet wird, oder in einer für den aktuellen Kontext besonderen Bedeutung auftritt. Auch an Stellen, an den Missverständnisse vermieden werden sollen, wird das betonte Wort in kursiv gesetzt.

Wiki Markup: ''kursiver Text'' HTML Code: <em>kursiver Text</em>

Eingabe

Text, den Du selbst eingibst.

Wiki Markup: <kbd>text</kbd> HTML Code: <kbd>text</kbd>

Programmausgabe

Die Augabe von Programmen

Wiki Markup: <samp>text</samp> HTML Code: <samp>text</samp>

Parameter

Variable Parameter in Befehlen, die Du selbst eingibst und variable Parameter in der Ausgabe von Programmen.

Wiki Markup: <var>text</var> HTML Code: <var>text</var>

Zum Quellcode

Bei der Formulierung des Quellcodes in diesem Buch wurde besondere Aufmerksamkeit auf gute Lesbarkeit gelegt. Auch an Stellen, an denen es wegen Rangfolge und Assoziativität der Operatoren nicht zwingend erforderlich ist, wurde versucht die Reihenfolge der Auswertung durch setzten zusätzlicher Klammern explizit deutlich zu machen. Im Zweifelsfall im Interesse besserer Lesbarkeit zudem auf effizientere Formulierungsmöglichkeiten verzichtet.

Die Programmbeispiele in diesem Buch sind zwar vollständig in dem Sinne, dass sie die einzelnen Aspekte des jeweiligen Kapitels genauer beleuchten, dennoch bieten sie zahlreiche Möglichkeiten zur Verbesserung und Erweiterung. Insbesondere der Fehlerbehandlung wurde bei der Formulierung nur geringe Priorität eingeräumt.

Kürze und Unvollständigkeit der Codebeispiele sind nur zum Teil auf sparsamen Umgang mit dem zur Verfügung stehenden Platz zurückzuführen, sie sollen Dich auch dazu motivieren den wiedergegebenen Quellcode zu verbessern und zu erweitern. Um Dich bei der Programmierung möglichst schnell zurecht zu finden und Dir die wichtigsten Dinge schnell anzueignen ist es erforderlich, dass Du neben der Lektüre auch selbst aktiv an der Erstellung von Quellcode arbeitest. Die Codebeispiele in diesem Buch sind dazu gedacht Dich hierzu einladen.


Arbeiten mit make

Worum geht's?

Im vorangegangenen Kapitel hast Du bereits ein erstes Programm übersetzt und auf den Microcontroller übertragen. Alle Arbeitsschritte vom Übersetzen der Quelldatei, dem Linken und Konvertieren, bis hin zum Installieren hast Du dabei von Hand vorgenommen.

Bei Aufgaben die nur einmal durchgeführt werden müssen, führt ein solches manuelles Arbeiten schnell zum Ergebnis. Auch um einen Überblick über den Umgang mit den beteiligten Werkzeugen zu gewinnen, kann das Arbeiten von Hand durchaus nützlich sein. Bei der Bewältigung immer wieder kehrender Aufgaben, wie dem Arbeiten an einer Quelldatei, kann es auf Dauer aber auch viel Zeit und Mühe kosten, immer wieder alle Schritte übersetzen, linken und konvertieren von Hand auszuführen.

In diesem Kapitel soll deshalb eine Möglichkeit vorgestellt werden, mit der einige dieser Aufgaben automatisiert werden können. Um mit den folgenden Kapiteln zu arbeiten, ist es ist nicht zwingend erforderlich, dass Du dieses Kapitel sofort liest. Wenn Du zunächst erst Mal ein wenig programmieren und experimentieren möchtest, kannst Du dieses Kapitel ruhig überspringen und zu einem späteren Zeitpunkt hierher zurückkehren. Vielleicht findest Du ja auch heraus, dass Du dieses Kapitel gar nicht brauchst, und findest eine Alternative die für Dich viel besser funktioniert.

In diesem Kapitel kannst Du die folgenden Dinge lernen:

  • Das Programm make installieren
  • Dokumentation und Hilfe zum Programm make finden
  • Das Programm make bedienen
  • Ein Makefile schreiben
  • Regeln in einem Makefile vorgeben
  • Variablen definieren und nutzen

Das Programm Make

Für das in diesem Kapitel vorgestellte Buildsystem wird das Programm make[1] verwendet.

Über das Programm make sind bereits zahlreiche gute Bücher wie [Mec04] geschrieben worden. Ziel dieses Kapitels ist es deshalb nicht einen weiteren Versuch zu unternehmen das Rad neu zu erfinden. Vielmehr soll es als Einstiegs- und Orientierungshilfe für eigenständiges Arbeiten und Recherchieren mit der entsprechenden Fachliteratur dienen.

Installation

Das Programm make gibt es in verschiedenen Varianten. Alle Angaben in diesem Kapitel beziehen sich auf die Variante GNU make, die vom GNU Projekt zur Verfügung gestellt wird.

Sie sollte in jeder gängigen Linux Distribution über die Paketverwaltung installierbar sein. In auf debian basierenden Distributionen Ubuntu, Kubuntu, Xubuntu, Lubuntu, etc. kann es mit dem Paket make installiert werden.

sudo apt-get install make

Hilfe

Wie die meisten Komponenten, die vom GNU Projekt zur Verfügung gestellt werden, verfügt auch GNU make über eine umfangreiche Dokumentation.[2]

Es lohnt sich parallel zur Lektüre dieses Kapitels einen Blick in die Dokumentation zu werfen. Der Text dieses Kapitels wird an den entsprechenden Stellen einen Verweis auf den zugehörigen Teil der Dokumentation liefern. Es schadet aber auch nicht, die Dokumentation auch über explizit im Text erwähnte Abschnitte und Passagen hinaus zu studieren.

Vorbereitung

Bevor es mit dem ersten Makefile los geht, muss noch ein letztes Mal der Suchpfad für Programme anpasst werden.

export PATH=/opt/arduino-1.8.0/hardware/tools/avr/bin:$PATH

Für diesen Makel wird bald Abhilfe geschaffen werden. Vorerst wird der manuell gesetzte Pfad aber noch gebraucht.

Erste Schritte

Das Arbeiten mit dem Programm make erfolgt in zwei Phasen.

In der ersten Phase wird eine Steuerungsdatei für das Programm make erstellt und bearbeitet. Typischer Weise trägt diese Steuerungsdatei den Namen Makefile[3]. Aufgaben, die make übernehmen soll, werden in diesem Makefile hinterlegt.

Die zweite Phase besteht im Aufruf des Programms make. In dieser Phase liest make die Steuerungsdatei ein und arbeitet die darin hinterlegten Aufgaben ab.

Makefile schreiben

Im vorangegangenen Kapitel wurde der Befehl avr-gcc -c -o source.o -Os -mmcu=atmega328p -DF_CPU=16000000UL source.c verwendet, um die Datei source.o aus der Datei source.c zu erzeugen.

Dieser Sachverhalt kann wie folgt in eine für Make verständliche Form gebracht werden.

source.o: source.c
	avr-gcc -c -o source.o -Os -mmcu=atmega328p -DF_CPU=16000000UL source.c

Die Datei source.o, die mit dieser Regel erzeugt werden kann, steht dabei auf der linken Seite. Eine solche Datei wird auch Target oder Ziel genannt.

Die Bearbeitungsschritte, mit denen die Zieldatei erstellt werden kann, stehen in der zweiten Zeile. Eine solche Folge von Bearbeitungsschritten, mit denen eine Zieldatei erzeugt werden kann, wird auch als Rezept bezeichnet. Zeilen, die zu einem Rezept gehören, müssen mit einem Tabulator (ASCII Zeichen 0x09) eingerückt sein. [4]

Die Datei source.c, die als Zutat benötigt wird, muss auf die rechte Seite geschrieben werden. Zutaten dieser Art werden auch Voraussetzung genannt.

Übertragen wir die anderen Befehle des vorangegangenen Kapitels in eine für make verständliche Form, so erhalten wir folgendes Makefile.

source.o: source.c
	avr-gcc -c -o source.o -Os -mmcu=atmega328p -DF_CPU=16000000UL source.c

source.elf: source.o
	avr-gcc -mmcu=atmega328p -o source.elf source.o

source.hex: source.elf:
	avr-objcopy -O ihex -R .eeprom source.elf source.hex

Achtung! Die Fehlermeldung, die make ausgibt, wenn für die Einrückung eines Rezepts kein Tabulator verwendet wurde, ist leider nicht sehr aussagekräftig:

Makefile:«Zeilennummer»: *** missing separator.  Schluss.

Wenn Du eine Fehlermeldung dieser Art siehst, dann hast Du für die Einrückung vermutlich Leerzeichen

anstelle des Tabulatorzeichens verwendet.

Make starten

Um make dazu aufzufordern, das Ziel «Target» zu erstellen, kann der Befehl make «Target» aufgerufen werden. Wird make ohne Angabe eines Ziels gestartet, so wird automatisch das erste Ziel in der Steuerdatei gewählt.

Beide Varianten können wir an dem bisher erstellten Makefile ausprobieren:

make

make: „source.o“ ist bereits aktuell.
make source.elf

make: „source.hex“ ist bereits aktuell.

Bisher scheint sich nicht viel getan zu haben. In beiden Fällen verabschiedet sich make mit dem Hinweis das gewählte Ziel ist bereits aktuell.

Die Ursache dafür liegt in der speziellen Arbeitsweise von make.

Arbeitsweise

Das Programm make arbeitet auf der Basis von Dateien. Rezepte für die Aktualisierung einer Datei führt es nur dann aus, wenn eine der Voraussetzung aktueller ist als das zugehörige Target. Die Reihenfolge, in der die Regeln in der Steuerdatei angegeben sind, spielt dabei keine Rolle.

Bei jedem Versuch die Datei «Datei» zu aktualisieren, überprüft make zunächst, ob es eine Regel gibt, mit der die gewünschte Datei erstellt werden kann.

Wenn es keine solche Regel gibt, so gibt es nicht viel, das make für uns tun kann.

Wenn die Datei existiert informiert make uns mit der folgenden Meldung über diesen Umstand:

make: Für das Ziel „«Datei»“ ist nichts zu tun.

Existiert die Datei nicht, so haben wir make vor ein unlösbares Problem gestellt und es gibt sich mit der folgenden Fehlermeldung geschlagen:

make: *** Keine Regel, um „«Datei»“ zu erstellen.  Schluss.

Wenn es eine Regel gibt, um die Datei «Datei» zu erstellen, so aktualisiert make zunächst alle Dateien, die als Voraussetzung genannt sind. Erst nach der Aktualisierung aller Voraussetzungen bestimmt es anhand der Zeitstempel der beteiligten Dateien die Aktualität der Datei «Datei».

Nur wenn es mindestens eine Voraussetzung gibt, die neuer ist, als die gewünschte Datei «Datei», macht sich make daran das zugehörige Rezept abzuarbeiten.

Andernfalls informiert uns make mit folgender Meldung darüber, dass für die Aktualisierung der Datei «Datei» gar nichts zu tun ist:

make: „«Datei»“ ist bereits aktuell.

Make wird also nur dann aktiv, wenn sich bei der Abarbeitung aller am Entstehen einer Datei beteiligten Rezepte auch wirklich etwas an der erzeugten Datei ändert.

Achtung! Diese Stärke von make kann sich auch als Nachteil erweisen. Wenn vergessen wird eine Abhängigkeit anzugeben, kann make zu unrecht davon ausgehen, dass nichts zu tun ist.

Bei der Formulierung von Regeln ist es daher wichtig immer darauf zu achten alle Abhängigkeiten anzugeben. Insbesondere eingebundene Header Dateien, deren Inhalt sich ändern kann, dürfen bei der Angabe nicht vergessen werden.

Ein Sonderfall tritt auf, wenn die Regeln des Makefiles eine zirkuläre Abhängigkeit enthalten. Auch in diesem Fall haben wir make vor ein unlösbares Problem gestellt, worüber uns make wie folgt informiert:

make: Zirkuläre Abhängigkeit «Datei A» <- «Datei B» wird nicht verwendet

Um make in unseren beiden Beispielen dazu zu bringen, die Rezepte auszuführen, müssen wir dafür sorgen, dass für make nach den genannten Regeln auch tatsächlich eine Aktualisierung nötig ist. Das können wir erreichen, indem wir vor dem Aufruf von make die Zeitstempel der beteiligten Dateien mit dem Befehl touch künstlich aktualisieren.

touch source.elf
make source.hex

avr-objcopy -O ihex -R .eeprom source.elf source.hex
touch source.c
make source.hex

avr-gcc -c -o source.o -Os -mmcu=atmega328p -DF_CPU=16000000UL source.c
avr-gcc -mmcu=atmega328p -o source.elf source.o
avr-objcopy -O ihex -R .eeprom source.elf source.hex

Diesmal hat make tatsächlich alle Arbeitsschritte durchgeführt.

Das Makefile überarbeiten

Die wichtigsten Grundlagen um erfolgreich mit make zu arbeiten hast Du bereits zu diesem Zeitpunkt kennengelernt. Im Folgenden wird es nun darum gehen, eine Reihe von Techniken vorzustellen, die das Schreiben von Makefiles erleichtern können. Ein Einsatz dieser Techniken ist optional, es besteht kein Zwang sie zu verwenden.

Kommentare

Zu Dokumentationszwecken kann ein Makefile mit Kommentaren versehen werden. Kommentare in einem Makefile werden mit einem Rautezeichen (#) eingeleitet und setzten sich bis zum Zeilenende fort. Kommentare sind somit also von folgender Gestalt.

# Das ist ein Kommentar

Durch den Einsatz von Kommentaren kann die Lesbarkeit eines Makefiles erheblich erhöht werden. Im Allgemeinen ist es daher eine gute Idee, nicht zu sparsam bei der Verwendung von Kommentaren zu sein.

Die Code-Schnipsel in diesem Kapitel stellen eine Ausnahme von dieser Faustregel dar. Zum einen übernimmt hier der umgebende Text die Aufgabe der Dokumentation, zum anderen kann der Blick so gezielter auf die vorgestellte Syntax gelenkt werden.

Erst das finale Makefile am Ende dieses Kapitels wird mit Kommentaren versehen sein.

Variablen

Um benutzerdefinierte Angaben an zentraler Stelle verwalten zu können, stellt make die Möglichkeit zur Verfügung Variablen[5] zu nutzen und sie an benötigten Stellen auszuwerten.

Die Werte verwendeter Variablen können dabei aus drei verschiedenen Quellen stammen. Sie können beim Aufruf von make auf der Kommandozeile übergeben werden, im Makefile selbst definiert oder durch eine Umgebungsvariable vorgegeben werden.

Im Falle eines Konflikts haben auf der Kommandozeile übergebene Werte die höchste, über Umgebungsvariablen festgelegte Werte die geringste Priorität.

Benutzerdefinierte Variablen

Eine Möglichkeit das bisher erstellte Makefile durch die Nutzung von Variablen ein wenig komfortabler zu gestalten, besteht darin, den Anwender von der Last zu befreien, den Suchpfad für Programme vor der Benutzung in der Konsole anpassen zu müssen.

Ein offensichtlicher Weg dies auch ohne Variablen zu erreichen, beseht darin, die vollständigen Pfade zu den verwendeten Werkzeugen direkt in die einzelnen Rezepte einzuarbeiten. Er weist jedoch den Nachteil auf, dass die jeweiligen Pfade auf diese Weise über das gesamte Makefile verstreut werden.

Um die Pfade zu den eingesetzten Werkzeugen stattdessen zentral verwalten zu können, werden wir stattdessen Variablen verwenden.

AVR_PATH   = /opt/arduino-1.8.0/hardware/tools/avr

CC         = $(AVR_PATH)/bin/avr-gcc
LD         = $(AVR_PATH)/bin/avr-gcc
OBJCOPY    = $(AVR_PATH)/bin/avr-objcopy

Um eine Variable «Variable» mit dem Wert «Wert» zu definieren kann die Syntax «Variable» = «Wert» verwendet werden. Leerzeichen sowohl vor und nach dem = Zeichen, als auch am Zeilenende werden von make ignoriert.

An einer Stelle, an der der Wert einer Variable «Variable» eingesetzt werden soll, kann dieser mit der Syntax $(«Variable») abgerufen werden.

Achtung! Das ist eine runde Klammer nicht die geschweifte Klammer, wie Du sie eventuell von Bash oder Perl kennst.

Automatische Variablen

Neben benutzerdefinierten Variablen stellt make zudem automatische Variablen[6] zur Verfügung, die bei der Formulierung von Rezepten verwendet werden können.

Der Name des aktuellen Ziels kann über die Variable $@ abgerufen werden. Die zugehörigen Voraussetzungen können über die Variable $^ abgerufen werden.

Zusammen mit automatischen und den von uns selbst definierten Variablen können wir die Regeln des Makefile wie folgt anpassen:

source.o: source.c
	$(CC) -c -o $@ -Os -mmcu=atmega328p -DF_CPU=16000000UL $^

source.elf: source.o
	$(LD) -mmcu=atmega328p -o $@ $^

source.hex: source.elf:
	$(OBJCOPY) -O ihex -R .eeprom $^ $@

Zuweisungsoperatoren

Angaben zum Prozessortyp und Taktrate liegen noch immer verstreut im Makefile und auch die Zahl der an Compiler und Linker übergebenen Optionen liegt, als Relikt der Phase, in der wir alle Befehle von Hand eingegeben haben, beim absoluten Minimum. Auch diesem Mangel können wir mit Variablen zu Leibe rücken.

Bei der Vorgabe der Compilerschalter ist ein wenig Fingerspitzengefühl gefragt. Zwar soll der Compiler im Normalfall mit dem Schalter -Os angewiesen werden, den Code so zu optimieren, dass er möglichst wenig Speicherplatz benötigt. Andererseits kann von Zeit zu Zeit auch ein anderes Verhalten, etwa Erstellung von Debugging Symbolen ohne Optimierung, wünschenswert sein.

Mit dem Zuweisungsoperator ?= kann make angewiesen werden, den angegebenen Wert nur dann zuzuweisen, wenn er nicht bereits definiert ist. Der Zuweisungsoperator Operator += erlaut es dem Wert einer Variablen weitere Werte hinzuzufügen.

Beide genannten Operatoren können wir verwenden, um dafür zu sorgen, dass die vom Compiler eingesetzte Optimierung im Bedarfsfall über die Umgebungsvariable CFLAGS gesteuert werden kann.

ARCH        = atmega328p
F_CPU       = 16000000UL

CPPFLAGS    = -mmcu=$(ARCH) -DF_CPU=$(F_CPU)
LDFLAGS     = -mmcu=$(ARCH)
CFLAGS      = -std=gnu99 -Os -Wall

CFLAGS     += -fpack-struct
CFLAGS     += -ffunction-sections -fdata-sections
LDFLAGS    += -Wl,--gc-sections

Substitution

Bevor es daran geht, die Regeln des Makefile ein weiteres Mal anzupassen, können wir die Chance nutzen um das Makefile auch für die Arbeit an zukünftigen Projekte vorzubereiten.

Der Name des fertigen Programms soll nicht mehr zwingend source.hex heißen. Auch wird es auf lange Sicht vermutlich nicht bei einer einzigen Quelldatei bleiben. Bei mehreren Quelldateien muss zunächst jede Quelldatei in eine zugehörige Objektdatei übersetzt werden. Die Liste der erstellten Objektdateien muss dann dem Linker übergeben werden.

Um make anzuweisen aus einer Liste SOURCES, in der wir alle Quelldateien angeben, eine Liste OBJS aller zugehörigen Objektdateien zu erstellen, können wir folgende Zeilen in das Makefile aufnehmen. [7]

PROJECT = source
SOURCES = source.c

OBJS = $(SOURCES:.c=.o)

Die Regeln können nun wie folgt angepasst werden.

source.o: source.c
	$(CC) -c -o $@ $(CPPFLAGS) $(CFLAGS) $^

$(PROJECT).elf: $(OBJS)
	$(LD) $(LDFLAGS) -o $@ $^ $(LDLIBS)

$(PROJECT).hex: $(PROJECT).elf
	$(OBJCOPY) -O ihex -R .eeprom $^ $@

Mit Ausnahme der ersten Regel, die wir analog für jede weitere Quelldatei in das Makefile schreiben müssen, sind wir nun alle expliziten Angaben los geworden. Im nächsten Abschnitt wird es darum gehen, auch diesen letzten Punkt in Angriff zu nehmen.

Musterregeln

Bisher haben wir in den Regeln des Makefile alle Ziele und alle Voraussetzungen, sei es direkt oder mit Hilfe einer Variable, mit ihrem vollständigen Dateinamen angegeben. Regeln dieser Art werden auch explizite Regeln genannt.

Um Rezepte anzugeben, die für eine ganze Klasse von Dateien beschreiben, wie sie aus einer zweiten Klasse von Dateien erstellt werden können, steht eine alternative Art von Regeln zur Verfügung.

Den Umstand, dass Dateien der Form «Stamm».o stets mit einem Rezept der Form $(CC) -c -o «Stamm».o $(CPPFLAGS) $(CFLAGS) «Stamm».c aus Dateien der From «Stamm».c erzeugt werden können, können wir make wie folgt mitteilen:

%.o: %.c
	$(CC) -c -o $@ $(CPPFLAGS) $(CFLAGS) $<

Regeln dieser Art werden auch Musterregeln[8] genannt.

Der Teil des Dateinamens, der bei Voraussetzung und Ziel übereinstimmt, wird in einer Musterregel mit dem dem Zeichen % angegeben.

Über die beiden automatischen Variablen $@ und $< kann im zugehörigen Rezept auf den Dateinamen des Ziels und den Dateinamen der Voraussetzung zugegriffen werden.

Für eine Reihe typischer Aufgaben enthält make bereits einen Vorrat von eingebauten Regeln[9]

Auch das Übersetzen einer C Quellcode Datei in eine Objektdatei gehört zu diesen typischen Aufgaben. Aus diesem Grund brauchen wir die oben angegebene Regel nicht in das Makefile zu übernehmen. Es genügt stattdessen einfach die überflüssige Regel, die Bezug auf source.c und source.o nimmt zu entfernen.

Pseudoziele

Der letzte Arbeitsschritt, den wir im vorangegangenen Kapitel vorgenommen haben, die Installation des fertigen Programms, ist bisher ausgeklammert geblieben. Wenn wir ein wenig "schummeln", können wir make dazu überreden, auch diesen Bearbeitungsschritt für uns auszuführen.

install:
	avrdude -vvv -C $(AVR_PATH)/etc/avrdude.conf -p $(ARCH) -c stk500v1 -P /dev/ttyUSB0 -b 57600 -D -U flash:w:$(PROJECT).hex

Bei jedem Aufruf von make install wird make feststellen, dass es noch gar keine Datei mit dem Namen install gibt und das zugehörige Rezept ausführen. Die kleine Schummelei geht allerdings nur so lange gut, solange es keine Datei mit dem Namen install gibt.

Die saubere Variante besteht darin, dem Programm make mitzuteilen, dass das Target install immer so behandelt werden soll, als wäre es nicht aktuell. Ein solches Target wird auch Pseudoziel genannt. [10]

Um make mitzuteilen, dass install ein Pseudoziel ist, können wir dem Makefile den folgenden Abschnitt hinzufügen.

.PHONY: install

Die selbe Technik können wir nutzen, um das Makefile um weitere Aktionen zu bereichern, die nicht an die Erstellung einer Datei gebunden sind. Neben dem Ziel install genießen wenigstens zwei weitere Ziele all und clean so große Prominenz, dass sie in beinahe jedem auf make basierenden Buildsystem erwartet werden können. Auch wir können sie unserem Makefile wie folgt hinzufügen.

.PHONY: all clean install

all: $(PROJECT).hex
	@echo "done"

install:
	avrdude -vvv -C $(AVR_PATH)/etc/avrdude.conf -p $(ARCH) -c stk500v1 -P /dev/ttyUSB0 -b 57600 -D -U flash:w:$(PROJECT).hex

clean:
	rm -f source.o source.elf source.hex

Das Makefile

Abschließend hier das finale Makefile.

Einige letzte Änderung im Vergleich zu der im Text erarbeiteten Variante betreffen die Schalter für Compiler und Linker. Um Platz zu sparen und nicht zu viele Vorgriffe auf das folgende Kapitel nehmen zu müssen, wurden sie im Text bewusst kurz gehalten.

Für make spielt die Länge der Zeilen in einem Makefile keine Rolle. Für die Lesbarkeit durch einen Menschen hingegen kann es von Vorteil sein, wenn einzelnen Zeilen nicht zu lang werden. Lange Zeilen wurden daher wie folgt in mehrere Zeilen zerlegt:

Um eine Zeile zwar umzubrechen, den Zeilenumbruch aber vor make zu verstecken kann die Zeile mit einem Backslash (\) abgeschlossen werden.

Achtung! Der Backslash muss wirklich das letzte Zeichen vor dem Zeilenumbruch sein. Leerzeichen zwischen Backslash und Zeilenumbruch können schwer zu erkennen sein.

Auch sie sind dort nicht erlaubt!
# Projekt
# ============================================================
PROJECT = MAIN
SOURCES = main.c

OBJS = $(SOURCES:.c=.o)

# Micorcontroller / serielle Schnittstelle
# ============================================================
ARCH          = atmega328p
F_CPU         = 16000000UL

PORT          = /dev/ttyUSB0
PROGRAMMER_ID = stk500v1
BAUDRATE      = 57600

# Pfade
# ============================================================
AVR_PATH       = /opt/arduino-1.8.0/hardware/tools/avr
AVRDUDE_CONFIG = $(AVR_PATH)/etc/avrdude.conf

# Programme
# ============================================================
CC         = $(AVR_PATH)/bin/avr-gcc
LD         = $(AVR_PATH)/bin/avr-gcc
OBJCOPY    = $(AVR_PATH)/bin/avr-objcopy
OBJDUMP    = $(AVR_PATH)/bin/avr-objdump

AVRDUDE    = $(AVR_PATH)/bin/avrdude

STTY       = stty

# Schalter
# ============================================================
CFLAGS   ?= -Os

# Schalter für scanf/printf
# -------------------------
LDFLAGS  += -Wl,-u,vfprintf -Wl,-u,vfscanf 
LDLIBS   += -lprintf_flt -lscanf_flt -lm

# Schalter für Microcontroller
# ----------------------------
CPPFLAGS  += -mmcu=$(ARCH) -DF_CPU=$(F_CPU)
LDFLAGS   += -mmcu=$(ARCH)

# Schalter für Sprachversion
# --------------------------
CFLAGS   += -std=gnu99

# Schalter für warnings
# ---------------------
CFLAGS  += -Wall

# Spezielle Warnungen
CFLAGS  += -Wstrict-prototypes -Wmissing-prototypes \
           -Wmissing-declarations -Wredundant-decls \
           -Wnested-externs -Wbad-function-cast \
           -Wshadow -Wpointer-arith \
           -Wsign-compare -Wfloat-equal \
           -Wunreachable-code  \
           -Wwrite-strings -Wconversion \
           -Waggregate-return  -Winline -Wcast-align

# Schalter um Platz zu sparen
# ---------------------------
# Strukturen packen
CFLAGS  += -fpack-struct

# Unbenutzten Code entfernen
CFLAGS  += -ffunction-sections -fdata-sections 
LDFLAGS += -Wl,--gc-sections

# Explizite Regeln
# ============================================================
all: $(PROJECT).hex
	@echo "done"

$(PROJECT).elf: $(OBJS)
	$(LD) $(LDFLAGS) -o $@ $^ $(LDLIBS)

install:
	$(STTY) -F $(PORT) hupcl
	$(AVRDUDE) -vvv -C $(AVRDUDE_CONFIG) -p $(ARCH) -c $(PROGRAMMER_ID) -P $(PORT) -b $(BAUDRATE) -D -U flash:w:$(PROJECT).hex

clean:
	rm -f $(OBJS) $(PROJECT).elf $(PROJECT).hex

# Muster Regeln
# ============================================================
%.hex: %.elf
	$(OBJCOPY) -O ihex -R .eeprom $< $@

%.lst: %.elf
	$(OBJDUMP) -h -S -t -C $< > $@

%.lst: %.o
	$(OBJDUMP) -h -S -t -C $< > $@

%.E:%.c
	$(CC) $(CPPFLAGS) -E -o $@ $<

%.s:%.c
	$(CC) $(CPPFLAGS) -S -o $@ $<

Rückschau und Ausblick

Glückwunsch, mit Abschluss dieses Kapitels hast Du nicht nur das Programm make installiert und Dich mit seiner grundlegenden Arbeitsweise vertraut gemacht. Du hast zudem ein Makefile erstellt, das Du sowohl für die Arbeit an eigenen Projekten, als auch für alle in diesem Buch vorgestellten Codebeispiele verwenden kannst.

Du hast dabei die wichtigsten Fachbegriffe kennen gelernt, um zielgerichtet in Dokumentation und Fachliteratur recherchieren zu können. Somit bist Du nun nicht nur in der Lage das erarbeitete Makefile an Deine Bedürfnisse anzupassen, sondern auch eigenständig neue Makefiles für andere Aufgaben zu entwickeln.

Du hast die wichtigsten Grundlagen zum Arbeiten mit make geschaffen, indem Du:

  • das Programm make installiert hast
  • Hilfe und Dokumentation zu make studiert hast
  • ein Makefile erstellt hast

In dem Makefile hast Du Regeln erstellt indem Du:

  • das Ziel und Voraussetzungen angegeben hast
  • ddas Rezept geschrieben hast

Du hast das Makefile flexibler gestaltet indem Du:

  • Variablen definiert und expandiert hast
  • bei der Formulierung von Rezepten auf automatische Variablen zugegriffen hast

Von nun an bist Du bei Deiner Recherche zum Programm make auf Dich selbst gestellt. Es gibt beinahe keine Aufgabe für deren Bewältigung das Programm make nicht eingesetzt werden kann. Die Grenzen zwischen sinnvoller Anwendung und Missbrauch sind dabei fließend.

Fußnoten

  1. siehe: Wikipedia Artikel Make
  2. siehe: Info Manual GNU make
  3. Trägt die Steuerungsdatei einen anderen Namen als Makefile, so muss beim Aufruf von make der Name der Datei mit dem zusätzlichen Schalter -f «Dateiname» angegeben werden.
  4. Die Variable .RECIPEPREFIX bietet die Möglichkeit ein alternatives Zeichen für die Einrückung von Rezepten festzulegen. Für Makefiles, die in freier Wildbahn angetroffen werden können ist das allerdings mehr als ungewöhnlich. (siehe: Info Manual GNU make Special Variables)
  5. siehe: Info Manual GNU make Using Variables
  6. siehe: Info Manual GNU make Automatic Variables
  7. siehe: Info Manual GNU make Substitution References
  8. siehe: Info Manual GNU make Pattern Intro
  9. siehe: Info Manual GNU make Implicit Rules
  10. siehe: Info Manual GNU make Phony Targets

Compiler und Standardbibliothek

Worum geht's?

Sowohl der C Compiler der avr-gcc Suite als auch die Implementierung der Standardbibliothek AVR Libc sind bemüht sich konform zum C Standard zu verhalten. Dennoch gibt es eine Reihe von Abweichungen und Besonderheiten, über die Du Dich in diesem Kapitel informieren kannst.

In diesem Kapitel geht es nicht um Informationen, die im Sprachstandard nachgelesen werden können. Vielmehr geht es um Informationen, die sowohl für den Anwendungsfall AVR Microcontroller und, als auch für die eingesetzten Werkzeuge speziell sind.

Für erste Gehversuche mit der Microcontroller Programmierung ist es nicht zwingend erforderlich sich alle Details dieses Kapitels auswendig zu merken.

Das Kapitel soll einen Überblick über die wichtigsten Besonderheiten geben und als Anlaufstelle dienen.

Datentypen und Werte

Abweichend vom C-Standard verfügt der Datentyp double nur über 32 Bit. Mit dem Compiler Schalter -mint8 können alle Datentypen bis hinauf zum Datentyp int auf eine Länge von 8 Bit verkürzt werden.

Achtung! Die Verwendung des Schalters -mint8 sollte tunlichst vermieden werden, da er nicht mit der AVR Libc verträglich ist.

Werte

Die Byte Reihenfolge, in der Daten im Speicher abgelegt werden, entspricht dem Little-Endian Format. Sowohl der Datentyp float als auch der Datentyp double werden im IEEE 754 single Format mit 32-bit gespeichert.

Ergänzend zu den im C Standard festgelegten Darstellungsformaten für literale Zahlenwerte stellt der C Compiler der avr-gcc Compiler Suite eine weitere Schreibweise zur Angabe von ganzzahligen Werten zur Verfügung. Mit dem Präfix 0b können literale Werte im Quelltext im Binärsystem angegeben werden.

uint8_t number = 0b00110011;

Größe

Die Grunddatentypen der Programmiersprache C sollten Dir bekannt sein. Der C-Standard gibt keine genaue Auskunft über die Größe dieser Datentypen. Stattdessen lässt er im Rahmen gewisser Grenzen Spielraum.[1]

Gerade für Microcontroller ist es besonders interessant. Der Speicherplatz ist begrenzt. Zudem steht nur ein 8-bit ALU mit eingeschränktem Befehlssatz zur Verfügung. Kompliziertere Rechnungen und Rechnungen mit größeren Datentypen muss der Compiler in eine Serie von Maschinenbefehlen zerlegen.

Im allgemeinen ist es eine gute Idee die mit C99 eingeführten Datentypen fester Größe der Header Datei <stdint.h> zu verwenden. Spätestens bei der Verwendung von Funktionen der Standardbibliothek lässt sich die Frage nach der Größe der Grunddatentypen nicht umgehen. Die Signaturen dieser Funktionen sind mit Grunddatentypen angegeben.

Die folgende Tabelle enthält eine Übersicht über die Größe der Datentypen sowie der wichtigsten typedefs.

Typ char short int long long long wchar_t void* float double size_t ptrdiff_t
Größe in Byte / alias 1 2 2 4 8 2 2 4 4 unisgned int int

Seit der avr-gcc Version 4.8 steht zusätzlich zu diesen Datentypen auch eine eingeschränkte Unterstützung für Festkommazahlen nach ISO/IEC TR 18037 zur Verfügung.

Angaben zur Größe der Datentypen, sowie zur Länge der Vor- und Nachkommastellen finden sich in folgender Tabelle:

Typ Größe in Byte unsigned signed
short _Fract 1 0 8 ± 0 7
_Fract 2 0 16 ± 0 15
long _Fract 4 0 32 ± 0 31
short _Accum 2 8 8 ± 8 7
_Accum 4 16 16 ± 16 15
long _Accum 8 32 32 ± 32 31

Rechnen

Welcher Datentyp bei der Programmierung verwendet wird spielt, ist nicht nur dann wichtig, wenn es darum geht Speicherplatz zu sparen. Auch wenn es darum geht Rechenzeit zu minimieren kann die Wahl der verwendeten Datentypen eine Rolle spielen.

Der Microcontroller arbeitet mit einer Geschwindigkeit die um Größenordnungen geringer ist als die eines PC. Die CPU und das Rechenwerk des Microcontrollers arbeiten nur mit 8 Bit. Rechenoperationen mit größeren Zahlen müssen in eine Serie von Maschinenbefehlen zerlegt werden. Für Division und Modulo Rechnung gibt es keinen Maschinenbefehl – auch nicht mit 8 Bit. Auch diese Operationen müssen in jedem Fall in eine Serie von Maschinenbefehlen zerlegt werden.

Das Rechenwerk bietet keine Maschinenbefehle für das Rechnen mit Fließkommazahlen. Berechnungen mit den Datentypen double und float müssen ebenfalls in Software ausgeführt werden. Der Compiler bringt zwar eine Standardimplementierung mit. Die Mathematik Bibliothek der AVR Libc bringt von Hand optimierte Routinen für die Berechnung mit. In der Regel ist es besser diese Funktionen zu verwenden. Um sie zu verwenden muss dass Programm gegen die Mathematik Bibliothek (libm) gelinkt werden.

Daten im Programmspeicher

Die Programmiersprache C wurde für Rechner mit von von Neumann Architektur entworfen. Daten und Programmcode liegen bei Rechnern dieses Architekturmodells im selben Speicher. AVR Microcontroller folgen dem Modell der Harvard-Architektur. Rechner dieses Architekturmodells verfügen über zwei separate Speicherwerke – Datenspeicher und Befehls- oder auch Programmspeicher – in denen Daten, bzw. Programmcode liegen.

Um Platz im Datenspeicher zu sparen bieten AVR Microcontroller die Möglichkeit zusätzlich zum Programmcode auch konstante Werte im Programmspeicher abzulegen und von dort zu lesen. Die Programmiersprache C verfügt über keine Sprachmittel um dem Compiler mitzuteilen, dass bestimmte konstante Werte im Programmspeicher abgelegt, bzw. von dort gelesen werden sollen. Die Header Datei <avr/pgmspace.h> der AVR Libc stellt Makros und Funktionen zur Verfügung, die diese Aufgaben übernehmen können.

Um konstante Werte im Programmspeicher abzulegen kann das Makro PROGMEM verwendet werden.

#include <avr/pgmspace.h>

const float pi PROGMEM = 3.141;
const uint8_t values[] PROGMEM = { 1, 2, 3, 4 };

const char message[] PROGMEM = "hello world";

Um auf diese Weise im Programmspeicher abgelegte Werte im Programm verwenden zu können, müssen sie mit den in <avr/pgmspace.h> deklarierten Funktionen der AVR Libc eingelesen werden. Funktionen, die mit Zeichenketten arbeiten, die im Programmspeicher abgelegt sind, tragen die Endung _P.

float f = pgm_read_float(&pi);
uint8_t v = pgm_read_byte(values + 2);

size_t l = strlen_P(message);

Compiler Schalter

Auf der Kommandozeile kann sowohl das Verhalten des C Compilers, als auch des Linkers der GNU Compiler Collection mit zahlreichen Optionen beinflusst werden. Die Übersicht hier beschränkt sich auf die Schalter, die im Makefile des vorangegangenen Kapitels verwendet werden und bisher noch nicht erklärt wurden. Eine vollständige Liste aller verfügbaren Optionen findet sich im GCC Manual.[2]

Verarbeitungsschritte

Um eine Quelldatei in eine Objektdatei zu übersetzen nimmt der Compiler eine Serie von Verarbeitungsschritten vor. Auf dem Weg zur fertigen Objektdatei durchläuft die Quelldatei so eine Reihe von Zwischenstadien. Hin und wieder kann es nützlich sein den Übersetzungsvorgang nach einem bestimmten Verarbeitungsschritt zu unterbrechen um einen Blick auf den aktuellen Zwischenstand der Verarbeitung zu werfen.

Mit dem Schalter -E kann der Compiler dazu angewiesen werden die Verarbeitung einer Quelldatei zu beenden nachdem sie durch den Präprozessor gelaufen ist. Das Makefile des vorangegangenen Kapitels enthält eine Regel, die es ermöglicht diese Option zu nutzen.

%.E:%.c
	$(CC) $(CPPFLAGS) -E -o $@ $<

Um aus der Datei quelle.c eine Datei quelle.E zu erzeugen, die den Zustand des Quellcodes nach der Bearbeitung durch den Präprozossor enthält, kann das Makefile mit dem folgenden Aufruf gestartet werden.

 make quelle.E

Mit dem Schalter -S kann der Compiler angewiesen werden die Verarbeitung einer Quelldatei nach der Umwandlung in Assembler Code zu beenden. Auch um diese Option zu nutzen enthält das Makefile des vorangegangenen Kapitels eine entsprechende Regel.

%.s:%.c
	$(CC) $(CPPFLAGS) -S -o $@ $<

Mit dem folgenden Aufruf kann das Makefile genutzt werden, um die Datei quelle.s mit dem aus der der Datei quelle.c erzeugten Assembler Code zu erstellen.

 make quelle.s

Sprachversion

Der C Compiler des avr-gcc unterstützt verschiedene Sprachversionen. Die Sprachversion kann mit dem Schalter -std Sprachversion gewählt werden.

Die im Makefile des vorangegangenen Kapitels vorgegebene Version gnu99 erlaubt es den Quellcode im ISO C99 Format zu schreiben. GNU Erweiterungen[3] wird im Falle eines Konflikts allerdings Vorrang eingeräumt.

Eine Liste verfügbarer Sprachversionen findet sich im GCC Manual.[4]

Achtung! Auch bei der Option c99 bleiben GNU Erweiterungen, die nicht im Konflikt mit dem Standard stehen weiterhin aktiv. Wenn Du Wert darauf legst ISO C99 konformen Code zu schreiben, kannst Du den Schalter -pedantic verwenden. Der Compiler wird dann für alle nicht ISO C konformen Konstrukte Warnungen ausgeben.

Warnungen

Warnungen bieten eine nützliche Hilfe. Im allgemeinen ist es deshalb eine gute Idee die Ausgabe von Compiler Warnungen nicht nur zu aktivieren, sondern ausgegebene Warnungen auch ernst zu nehmen.

Der Compiler mag bei der Ausgabe von Warnungen häufig ein wenig kleinlich erscheinen und auch vor Schwierigkeiten warnen, die bereits im Programmfluss abgefangen wurden. Trotzdem können sie eine wertvolle Hilfe sein.

Die Wahrscheinlichkeit, dass der Compiler Stellen im Code anmahnt, an denen tatsächlich etwas schief gehen kann, mag zwar verschwinden gering erscheinen. Die Wahrscheinlichkeit, dass wenn etwas schief geht, dies an einer Stelle geschieht, die der Compiler angewarnt hat ist, ist dafür nahezu sicher.

Warnungen, die der avr-gcc ausgeben kann, können sehr fein kontrolliert werden. Der einfachste Weg einen gewissen Vorrat an Warnungen zu aktivieren besteht in der Benutzung des Schalters -Wall. Eine vollständige Liste aller Warnungen, die mit diesem Schalter aktiviert werden, findest Du im GCC Manual.[5] Auch alle weiteren Warnungen, die im Makefile aktiviert werden, findest Du an gleicher Stelle Dokumentiert

Optimierung

Optimierung: -Os Beim Debuggen kann es nützlich sein, den Compiler anzuweisen den generierten Code nicht ganz so aggressiv zu optimieren. Zu diesem Zweck kann der Schalter -Og angegeben werden. Wenn alle Stricke reißen, kann die Optimierung mit dem Schalter -O0 ganz deaktiviert werden.

Zusätzlich zu den allgemeinen Einstellungen können einzelne Aspekte der Optimierung separat über Schalter konfiguriert werden. Eine Liste aller Optionen findest Du im GCC Manual.[6]

Platz sparen

Ohne zusätzliche Optionen, legt der C Compiler generierten Code und initialisierte Variablen je in einer einzigen Sektion der erstellten Objektdatei ab. Generierter Code landet dabei in der Sektion .text, globale Daten in der Sektion .data.

Mit den Schaltern -ffunction-sections und -fdata-sections kann der Compiler dazu angewiesen werden den generierten Code für jede Funktion bzw. die Daten jeder Variable in einer eigenen Sektion abzulegen. Der generierte Code einer Funktion «funktion» liegt dann in der Sektion .text.«funktion», Daten der globalen Variable «daten» liegen dann in der Sektion .data.«funktion»

Liegen die einzelnen Funktionen und Variablen auf diese Weise getrennt je in eigenen Sektionen, so kann der Linker dazu angewiesen werden nur tatsächlich verwendete Funktionen und Daten in das fertige Programm zu linken. Im fertigen Programm wird dann nur Platz für Funktionen und Variablen benötigt, die auch tatsächlich verwendet werden. Der Schalter, der für diesen Zweck an den Linker übergeben werden muss trägt die Bezeichnung --gc-sections. Die Syntax, die wir verwenden müssen, um diese Option an den Linker weiter zu reichen findet sich im folgenden Abschnitt.

Linker Optionen

Optionen, die an den Linker weiter gegeben werden sollen, können mit der folgenden Syntax auf der Kommandozeile angegeben werden.[7]

 -Wl,<options>

Mehrere Optionen sowie Paare der Form Option Wert können durch ein Komma getrennt werden. Um die Option --gc-sections des vorangegangenen Abschnitts an den Linker weiter zu reichen, müssen wir folgende Syntax verwendet werden.

 -Wl,--gc-sections

Dokumentation zum Linker selbst findet sich nicht im GCC Manual sondern in der Dokumentation des Pakets binutils.[8]

Standardbibliothek

Die Implementierung der C Standardbibliothek AVR Libc wird vom GNU Projekt zur Verfügung gestellt.

Bitmanipulation

Bei der Programmierung von Microcontrollern müssen sehr häufig einzelne Bits manipuliert und getestet werden. Für eine Reihe typischer und häufig anstehender Aufgaben stellt die AVR Libc Präprozessor Makros bereit, dir für die Umsetzung der jeweils gewünschten Operation verwendet werden können.

Mit den Makros bit_is_set(X, Y) und bit_is_clear(X, Y) kann getestet werden, ob ein bestimmtes Bit auf dem Wert 1 oder 0 steht.

Mit den Makros loop_unitl_bit_is_set(X, Y) und loop_unitl_bit_is_clear(X, Y) kann auf den jeweils gewünschten Zustand des angegebenen Bits gewartet werden.

stdio stdout und stderr

Die Headerdatei <stdio.h> der AVR Libc stellt zwar die Dateihandles stdin, stdout und stderr bereit, sie sind aber nicht initialisiert.

Bevor Funktionen der printf() und scanf() Familie verwendet werden können, müssen die zugehörigen Dateihandles zuerst eingerichtet werden. Zum Einrichten eines Dateihandles steht das Makro FDEV_SETUP_STREAM() bereit. Dem Makro müssen zwei Funktionszeiger und eine Angabe darüber, ob das Dateihandle für die Eingabe bzw. für die Ausgabe verwendet werden kann, übergeben werden.

int put(char c, FILE *stream)
int write (FILE *stream)

Die Funktion put soll einen Rückgabewert von 0 liefern, wenn die Schreiboperation erfolgreich war.

Die Funktion write soll den gelesenen Wert als int zurückliefern, wenn die Leseoperation erfolgreich war. Bei Erreichen des Dateiendes soll sie den Wert _FDEV_EOF zurückliefern. Im Falle eines Fehlers soll sie den Wert _FDEV_ERR zurückliefern.

Eine Möglichkeit stdout mit dem FDEV_SETUP_STREAM() Makro so einzurichten, dass die Ausgabe über die serielle Schnittstelle erfolgt, findest Du im folgenden Kapitel.

printf und scanf

Funktionen der printf() und der scanf() Familie können in verschiedenen Versionen verwendet werden. Neben der Standardversion stehen eine minimale und eine erweiterte Version bereit.

Um eine andere Version als die Standardversion zu verwenden, müssen dem Linker die folgenden Optionen übergeben werden.

Familie minmale Version erweiterte Version
printf() -Wl,-u,vfprintf -lprintf_min -Wl,-u,vfprintf -lprintf_flt -lm
scanf() -Wl,-u,vfscanf -lscanf_min -Wl,-u,vfscanf -lscanf_flt -lm

Formatstring

Der Aufbau des Formatstrings entspricht den üblichen Regeln.

Eine Besonderheit: Formatstring im Programmspeicher (ROM) -> Funktionen haben das Suffix _P

Flags

Flags werden von allen Versionen korrekt geparst. Flags, die von der eingesetzten Version nicht unterstüzt werden, werden dabei stillschweigend ignoriert.

Flag Beschreibung m s e
# Fügt bei o eine führende 0 hinzu. Fügt bei x und X ein führendes 0x bzw. 0X hinzu
0 Füllt auf die vorgegebene Länge mit 0 statt Leerzeichen auf
- Stellt den Wert linksbündig statt rechtsbündig dar
<space> Fügt bei d und i bei positiven Zahlen ein führendes Leerzeichen ein
+ Positive Zahlen werden mit führendem + dargestellt

Konvertierungs Spezifikation

Die folgenden Konvertierungs Spezifikationen werden von allen Version korrekt geparst. Die Werte von nicht unterstützten Konvertierungen werden in der Ausgabe als ? dargestellt.

Zeichen Argument Ausgabeformat m s e
s char* im RAM Zeichenkette '\0' terminiert
S char* im ROM Zeichenkette '\0' terminiert
c int konvertiert zu unsigned char
d i int signed decimal
u int unsigned decimal
x X int unsigned hexadecimal
o int unsigned octal
p void* unsigned hexadecimal
e E float [-]d.ddde(+/-)dd
f F float [-]ddd.ddd
g G float Für exponent < -4 oder exponent >= precision Ausgabe im Stil e/E sonst im Stil f/F

Rückschau und Ausblick

Glückwunsch! Mit Abschluss dieses Kapitels hast Du bereits einen ersten Überblick über die wichtigsten Besonderheiten der Werkzeuge gewonnen.

Auch wenn Du Dir bei der ersten Lektüre nicht alle Details auswendig behalten hast, so hast Du doch Deinen Blick für Besonderheiten geschärft. Wenn Du beim Programmieren eine Stelle entdeckst, an der Du mehr über die genauen Details wissen musst, kannst Du zu diesem Kapitel zurückkehren und die Einzelheiten nachlesen.

Fußnoten

  1. (siehe [WCP]: Kapitel Datentypen)
  2. siehe: GCC Manual
  3. siehe: GCC Manual Extensions to the C Language Family
  4. siehe: GCC Manual Language Standards Supported by GCC
  5. siehe: GCC Manual Options to Request or Suppress Warnings
  6. siehe: GCC Manual Options That Control Optimization
  7. siehe: GCC Manual Options for Linking
  8. siehe Binutils Manual Command Line Options

Tipps und Tricks

Worum geht's?

Solange alles wie gewünscht funktioniert, verläuft die Entwicklung von Programmen für den Mikrocontroller nicht sehr viel anders, als die Entwicklung von Programmen für den PC. Sobald die Dinge nicht mehr ganz so wie gewollt ablaufen, zeigen sich deutliche Unterschiede.

Ohne den Einsatz spezialisierter Zusatzhardware steht die „Standardlösung“ Debugger nicht zur Verfügung. Selbst mit entsprechender Ausrüstung sind die Möglichkeiten begrenzt. Ohne Kontrolle der CPU ablaufende Vorgänge, wie das Voranschreiten von Timern lassen sich selbst mit entsprechender Debug-Hardware weder überwachen noch kontrollieren.

In diesem Kapitel wird es darum gehen, eine Reihe von Tipps und Tricks vorzustellen, die falls die Dinge Mal nicht ganz so sollten, wie gedacht, dabei helfen können das Problem zu finden.

Die folgenden Dinge kannst Du in diesem Kapitel lernen:

  • Log Daten mit dem Mikrocontroller über die serielle Schnittstelle versenden
  • über die serielle Schnittstelle versendete Daten mit dem PC entgegen nehmen
  • den Inhalt generierter Programme auflisten

Logging

Wie im vorangegangenen Kapitel angesprochen, verfügt die AVR Libc zwar über die nötige Infrastruktur, um Ausgaben mit der printf() Familie von Funktionen durchzuführen. Standardmäßig stehen allerdings keine Dateihandles bereit, an die Ausgaben auch tatsächlich geschickt werden können.

Um den Fortschritt eines Programms, das auf dem Mikrocontroller läuft zu protokollieren, kann es nützlich sein zumindest eine Ausgabemöglichkeit für Zeichenketten zur Verfügung zu haben. Eine Möglichkeit, die sich auch ohne externe Beschaltung weiterer Bauteile, wie LCD Display zu realisieren, bietet die serielle Schnittstelle. Über die serielle Schnittstelle versandt, können die zu protokollierenden Daten dann mit einem geeigneten Terminalprogramm[1] am PC empfangen und ausgegeben werden.

Experimentier-Boards mit integriertem RS-232 zu USB Konverter stellen bereits die passende Anschlussmöglichkeit zur Verfügung. Ein RS-232 zu USB Konverterkabel kann andernfalls für Abhilfe sorgen.

serielle Schnittstelle

Eine ausführliche Untersuchung aller Möglichkeiten, die für die Bedienung der seriellen Schnittstelle verantwortlich Funktionseinheit des Mikrocontrollers zur Verfügung stellt, ist einem späteren Kapitel vorbehalten. In diesem Abschnitt wird es aus diesem Grund nur um die Aspekte gehen, deren Verständnis nötig ist, um die serielle Schnittstelle zum Protokollieren von Ausgaben zu verwenden.

Damit die Kommunikation über die serielle Schnittstelle funktioniert, ist es erforderlich dafür zu sorgen, dass zwei für den Betrieb wichtige Parameter auf dem Mikrocontroller und im Terminalprogramm auf dem PC übereinstimmen.

Eine Quelldatei, die sich um die Initialisierung der seriellen Schnittstelle auf dem Mikrocontroller kümmert, findest Du hier. Um sie in einem Programm zu verwenden, musst sie sowohl übersetzt, als auch mit den restlichen Komponenten des Programms gelinkt werden. Wenn Du das Makefile aus dem Kapitel Arbeiten mit make verwendest, musst Du der Variable SOURCES den Namen der Quelldatei log.c hinzufügen.

log.c

#include <avr/io.h>
#include <util/delay.h>
#include <stdio.h>

#include "log.h"

#define BAUDRATE 57600L
#define SERIAL_FORMAT_8N1 0b00000110

#define sbi(port, bit) (port) |= (uint8_t)  (1 << (bit))
#define cbi(port, bit) (port) &= (uint8_t) ~(1 << (bit))

static int serial_put(char c, FILE *stream) 
{
  loop_until_bit_is_set(UCSR0A, UDRE0);
  UDR0 = (uint8_t) c;
  return c;
}

static FILE the_log = FDEV_SETUP_STREAM(serial_put, NULL, _FDEV_SETUP_WRITE);

FILE* log_init()
{
  // baud rate
  UBRR0H = 0;
  UBRR0L = (uint8_t) (F_CPU / 4 / BAUDRATE - 1) / 2;

  sbi(UCSR0A, U2X0);            // double speed

  UCSR0B |= _BV(TXEN0);         // enable transmit
  UCSR0C  = SERIAL_FORMAT_8N1;  // set frame format

  return &the_log;
}

Die zugehörige Header Datei findest Du hier.

log.h

#ifndef LOG_H
#define LOG_H

#include <stdio.h>

FILE* log_init(void);

#endif // LOG_H

Sie stellt nur eine Funktion log_init() zur Verfügung, die ein Dateihandle zurückliefert. Um dafür zu sorgen, dass printf() über die Standardausgabe ausgegebene Zeichenketten über die serielle Schnittstelle versendet werden, kann das von log_init() zurückgegebene Dateihandle an stdin zugewiesen werden.

Davon, dass alles wie gewünscht funktioniert, können wir uns mit einem kleinen Testprogramm überzeugen.

Quellcode

#include <avr/io.h>
#include <util/delay.h>

# include "log.h"

// Forward Declarations
void setup(void);
void loop(void);


void setup() 
{
  stdout = log_init();
}

void loop() 
{
  printf("hello world\r\n");
  _delay_ms(1000);
}

#ifndef ARDUINO
int main(void) 
{
  setup();
  while(1)
    loop();
}
#endif

Objekt- und Programmdateien inspizieren

Hin und wieder kann es nützlich sein einen Blick in die vom Compiler generierten Dateien zu werfen. Sowohl das fertige Programm, als auch die Objektdateien, aus den es zusammen gelinkt wird, legt die avr-gcc Compiler Suite im ELF (executable and link format) Format ab.

Mit dem in der Compiler Suite enthaltenen Werkzeug avr-objdump kann der Inhalt von Dateien, die im ELF Format vorliegen inspiziert werden.

Kommandozeile

Eine vollständige Liste aller verfügbaren Optionen erhälst Du, wenn Du avr-objdump mit der Option --help auf der Kommandozeile aufrufst. Wenn das Programm avr-objdump nicht im Suchpfad liegt, musst es mit dem vollen Pfadnamen aufgerufen werden. Wenn Du dem Vorschlag aus dem Kapitel Quickstart Linux gefolgt bist, und die Dateien der Arduino IDE vollständig in das Verzeichnis /opt kopiert hast, kannst Du den folgenden Befehl verwenden.

 /opt/arduino-1.8.0/hardware/tools/avr/bin/avr-objdump --help   

Makefile

Das Makefile aus dem Kaptiel Arbeiten mit make enthält zwei Regeln, die es erlauben eine Reihe von Informationen aus dem Inhalt von Objektdateien und daraus erstellten Programmdateien aufzulisten. Mit der Hilfe der Dokumentation aus dem vorangegangenen Abschnitt kannst Du die verwendeten Schalter an Deine Bedürfnisse anpassen, wenn Dir die getroffene Vorauswahl nicht gefällt.

%.lst: %.elf
	$(OBJDUMP) -h -S -t -C $< > $@
%.lst: %.o
	$(OBJDUMP) -h -S -t -C $< > $@

Um den Inhalt der aus der Datei quelle.c generierten Objektdatei aufzulisten kann das Makefile mit dem folgenden Aufruf gestartet werden.

 make quelle.lst

Eine Auflistung des zugehörigen Inhalts findet sich dann in der Datei quelle.lst. Analog kann der Inhalt der für das Projekt project generierten Programmdatei durch folgenden Aufruf von make in der Datei project.lst ausgegeben werden.

 make project.lst

Rückschau und Ausblick

TODO

Fußnoten

  1. das Programm, das ich für diesen Zweck auf meinem PC verwende trägt den Namen gtkterm

Hardware und IO-Register

Worum geht's?

Neben Prozessor und Speicher verfügen Microcontroller der AVR-Familie über eine Reihe zusätzlicher auf dem Chip integrierter Komponenten, wie Timer, deren Verhalten durch die Programmierung das Controllers beeinflusst werden kann. Diese integrierten Komponenten werden auch als Funktionseinheiten bezeichnet.

Der Zugriff auf diese Funktionseinheiten findet mit Hilfe sogenannter Ein-/Ausgabe Register über eine einheitliche Schnittstelle statt.

Achtung! Der Begriff Register ist mehrfach besetzt. In diesem Kapitel wird es nicht um die Rechenregister der CPU gehen, sondern ausschließlich um Ein-/Ausgabe Register.

Das Datenblatt [M328p] unterscheidet bei der Bezeichnung von Ein-/Ausgabe Registern zwischen I/O Registern und Extended I/O Registern, da es für die Manipulation der erstgenannten Register spezielle Maschinenbefehle gibt.

Für die C-Programmierung spielt dieser Unterschied keine Rolle, da hier auf einer höheren Abstraktionsebene gearbeitet wird. Der Begriff Ein-/Ausgabe Register oder auch kurz E/A Register ist in diesem Kapitel stets so verstehen, dass er beide Varianten des Datenblatts umfasst.

Die Zahl der Funktionseinheiten und damit auch die Zahl der Ein-/Ausgabe Register hängt vom Typ des Microcontrollers ab. Für einen AtMega328p stehen etwa 90 Ein-/Ausgabe Register bereit. Je nach Microcontroller können mehr oder weniger Funktionseinheiten und damit auch mehr oder weniger Ein-/Ausgabe Register zur Verfügung stehen.

In diesem Kapitel kannst Du die folgenden Dinge lernen:

  • Informationen im Datenblatt finden
  • IO-Register lesen
  • IO-Register schreiben
  • Besonderheiten beim Umgang mit I/O Registern beachten

Syntax

Die Ein-/Ausgabe Register eines Microcontrollers der AVR-Familie sind in einen festen Bereich des Hauptspeichers eingeblendet / gemappt. Bei einem AtMega328p liegt dieser Adressbereich zwischen 0x20 und 0x0FF. Der gewöhnliche Speicher beginnt dort erst ab der Adresse 0x0100.

Zugriff

Prinzipiell ist es möglich direkt mit der Speicheradresse eines E/A Registers zu arbeiten. Im Kapitel Quickstart Linux haben wir ein Programm übersetzt, das die Leuchtdiode L zum Blinken gebracht hat. Wenn Du einen Blick auf den Stand des Quellcodes wirfst, nachdem er durch den Präprozessor gelaufen ist, kannst Du sehen, dass der Compiler über die beiden Adressen 0x23 und 0x24 auf die beteiligten E/A Register zugreift.

void setup() 
{
  sbi((*(volatile uint8_t *)((0x04) + 0x20)), 5);
}

void loop() 
{
  sbi((*(volatile uint8_t *)((0x03) + 0x20)), 5);
  _delay_ms(500);
}

Dieser Weg E/A Register direkt über Angabe der zugehörigen Speicheradresse anzusprechen ist nicht nur beim Schreiben des Quellcodes schmerzhaft, er macht auch späteres Lesen des Quelltexts und das Portieren auf einen anderen Microconotroller der AVR-Familie zu einem zeitaufwändigen und fehleranfälligen Unterfangen.

Eine geschicktere Möglichkeit auf E/A Register zuzugreifen bietet die Header Datei <avr/io.h> der AVR Libc. Abhängig von der bei der Übersetzung mit dem Schalter -mmcu angegebenen Microcontroller Variante stellt sie eine Reihe von Präprozessor Makros bereit, über die E/A Register über einen symbolischen Namen angesprochen werden können. Die symbolischen Namen, über die die E/A Register angesprochen werden können, folgt der Benennung im zugehörigen Datenblatt.

Zum Vergleich hier noch einmal der gleiche Code-Schnipsel. Diesmal unter Verwendung der symbolischen Namen.

void setup() 
{
  sbi(DDRB, DDB5);
}

void loop() 
{
  sbi(PINB, PINB5);
  _delay_ms(500); 
}

Bitmanipulation

Auch für die Manipulation einzelner Bits eines E/A Registers stellt die Header Datei <avr/io.h> eine Reihe von Makros bereit, über die sie mit den Bezeichnungen des Datenblatts angegeben werden können. Die zugehörigen Makros expandieren dabei zu der Position des entsprechenden Bits im jeweiligen E/A Register.

Achtung! Eine Überprüfung, ob die symbolischen Namen von E/A Register und Bit zusammen passen findet nicht statt. Sowohl X als auch Y expandieren zum selben Wert Z. Wenn Du sowohl Dich selbst, als auch andere Leser verwirren möchtest, kannst Du den Quellcode also auch so schreiben.

TODO: Beispiel

Weder für den Compiler noch für das fertige Programm macht es einen Unterschied.

Im eigenen Interesse solltest Du allerdings auf solche Verwirrspiele verzichten.

Besonderheiten

Auf Ein-/Ausgabe Register kann auf dem gleichen Weg zugegriffen werden, wie auf gewöhnlichen Speicher. Dennoch sind beim Zugriff auf E/A Register eine Reihe von Unterschieden zu beachten.

Die meisten E/A Register können sowohl gelesen, als auch geschrieben werden, aber das muss nicht zwingend so sein. Genaue Auskunft darüber, in welcher Art und Weise auf ein Register zugegriffen werden kann, findet sich in der Beschreibung des jeweiligen Registers im Datenblatt.

Die Speicheradressen der Ein-/Ausgabe Register sind nicht durchlaufend vergeben. An die Speicheradresse reservierter E/A Register sollte nicht geschrieben werden.[1]

Einige E/A Register enthalten reservierte Bits. Reservierte Bits sollten beim Schreiben immer auf den Wert 0 gesetzt werden.[2]

Semantik

Die Syntax mit der in einem Programm auf Ein-/Ausgabe Register zugegriffen werden kann, unterscheidet sich in C nicht von der Syntax, die für den Zugriff auf gewöhnlichen Speicher verwendet wird. Die Semantik, d.h. die Bedeutung von Zugriffen auf gewöhnlichen Speicher und E/A Register unterscheidet sich hingegen gravierend.

Gewöhnlicher Speicher

Der Sinn und Zweck von gewöhnlicher Speicher ist, es Werte für die spätere Verwendung zwischen zu speichern. Eine Variable, in die ein bestimmter Wert geschrieben wird, behält den Wert so lange bei, bis ein neuer Wert in die Variable geschrieben wird. Das Zurücklesen eines in einer Variable liefert stets den Wert, der zuletzt geschrieben wurde. Auch bei mehrmaligem Zurücklesen.

E/A Register

Der Sinn und Zweck von E/A Registern ist es, das Verhalten des Microcontrollers zu beeinflussen und Informationen über den aktuellen Zustand des Microcontrollers abzufragen.

Das Schreiben in ein E/A Register dient nicht dem Zweck, einen Wert für die spätere Nutzung zwischen zu speichern, sondern den Microcontroller zu steuern, d.h. Parameter vorgeben und Aktionen auszulösen. Es gibt keine Garantie, dass ein geschriebener der Wert, beim zurück lesen von der selben Speicheradresse den geschriebenen Wert zurückliefert und in vielen Fällen wird er es auch nicht sein.

Das Lesen eines E/A Registers dient somit nicht dem Zweck, einen zwischengespeicherten Wert zurück zu lesen, sondern dazu, den aktuellen Status einzelner Komponenten des Microcontrollers abzufragen. Abhängig von Ausführungsstatus der jeweiligen Komponente kann mehrmaliges zurücklesen des selben E/A Registers unterschiedliche Werte zurückliefern.

Die Makros der Header Datei <avr/io.h> qualifizieren die symbolischen Namen, über die die E/A Register angesprochen werden können aus diesem Grund als volatile. Der Compiler ist damit gezwungen Werte aus E/A Registern bei jedem lesenden Zugriff tatsächlich von der jeweiligen Speicheradresse zu lesen. Kettenzuweisungen an E/A Register sind aus diesem Grund keine besonders gute Idee.

Rückschau und Ausblick

Glückwunsch! Mit Abschluss dieses Kapitels hast Du Dir alle Grundlagen angeeignet, die Du benötigst um erfolgreich mit E/A Registern zu arbeiten.

Dabei hast Du Dich nicht nur mit dem Aufbau des Datenblatts vertraut gemacht. und bist somit in der Lage, gezielt die Informationen zu einer bestimmten Funktionseinheit und die Beschreibung der zugehörigen Register ausfindig zu machen.

Zudem kennst Du auch die Gemeinsamkeiten und Unterschiede von gewöhnlichem Speicher und E/A Registern. Im einzelnen sind Dir dabei die folgenden Unterschiede bekannt:

  • Schreiben in gewöhnlichen Speicher dient dem Zweck einen Wert zu speichern
  • Schreiben in E/A Register dient dem Zweck um das Verhalten des Microcontrollers zu steuern
  • Lesen von gewöhnlichem Speicher dient dem Zweck einen gespeicherten Wert zurück zu lesen
  • Lesen von E/A Registern dient dem Zweck den Status des Microcontrollers abzufragen

Du bist mit den Besonderheiten vertraut, die bei der Arbeit mit E/A Registern beachtet werden sollten.

Beim Schreiben von Werten in E/A Registern beachtest Du die folgenden Dinge:

  • in reservierte Register sollten nicht geschrieben werden
  • in reservierte Bits sollte immer der Wert 0 geschrieben werden
  • Kettenzuweisungen sollten vermieden werden

Fußnoten

  1. (siehe [M328p]: 12.5 IO Memory, S.37)
  2. (siehe [M328p]: 12.5 IO Memory, S.37)


GPIO Pins

C-Programmierung mit AVR-GCC: GPIO

Projekt 1

Idee

In diesem Projekt soll es darum gehen, eine PS/2 Tastatur mit dem Microcontroller zu verbinden.

PS/2 Schnittstelle

Die PS/2 Schnittstelle verwendet einen 6-poligen Stecker. Es werden aber nur 4 der 6 verfügbaren Stecker verwendet.

Der PS/2 Bus ist ein serieller Bus. Daten, die über den PS/2 Bus übertragen werden, werden in Paketen transportiert, deren Bits sequentiell eines nach dem anderen übertragen werden. Die Nutzdaten, die in einem Paket transportiert werden können, haben immer eine Länge von 8-Bit.

Leitungen

Für die Übertragung werden nur zwei Leitungen benötigt. Eine der beiden Leitungen dient der Übertragung der einzelnen Datenbits. Im Folgenden wird sie daher auch als Datenleitung oder kurz DATA bezeichnet werden. Die zweite Leitung dient der Synchronisation. Über sie wird geregelt, zu welchem Zeitpunkt Daten auf die Datenleitung gelegt, bzw. von dort entnommen werden. Die Synchronisation erfolgt mit einem Taktsignal. Im Folgenden wird die zweite Leitung daher auch als Taktleitung oder kurz CLK bezeichnet werden.

Anschluss

1 DATA
3 GND
4 Vcc = 5V
5 CLK

PS/2 Tastatur

Üblicherweise fließen die Daten hierbei von einem Eingabegerät, wie in unserem Beispiel der Tastatur, zu einem Gerät, dass die Daten verarbeitet. Es können aber auch Daten in die umgekehrte Richtung, also zum Eingabegerät hin transportiert werden. In unserem Fall können auf diese Weise z.B. die mit "Num Lock", "Caps Lock" und "Scroll Lock" beschrifteten Leuchtdioden der Tastatur angesteuert werden.

Tastatur -> Host

Scancode Set 1
Scancode Set 2
Scancode Set 3

Eine PS/2 Tastatur versendet eigenständig Daten, die über den Zustand der Tastatur Auskunft geben.

Wenn eine Taste gedrückt wird versendet die Tastatur eine Serie von Bytes, die darüber Auskunft geben, welche Taste gedrückt wurde. Eine solche Serie von Bytes wird auch als Make Code der jeweiligen Taste bezeichnet.

Wird eine Taste wieder losgelassen, so versendet die Tastatur erneut eine Serie von Bytes, die Auskunft darüber geben, welche Taste losgelassen wurde. Eine solche Serie von Bytes wird auch als Break Code der Taste bezeichnet.

Die Zuordnung der Make Codes und Break Codes zu den einzelnen Tasten der Tastatur wird als als Scancode Set bezeichnet. Für eine PS/2 Tastatur gibt es 3 Scancode Sets, die von 1 bis 3 nummeriert sind. Nach einem Reset verwendet die Tastatur immer das Scancode Set 2.

Eine Übersicht über die Make- und Break Codes der einzelnen Tasten in den drei Scancode Sets findest Du in den Abbildungen rechts. Die erste Zeile gibt Auskunft über den jeweiligen Make Code der zugehörigen Taste. Die zweite Zeile gibt den zugehörigen Break Code an.

Wird eine Taste für eine gewisse Zeit festgehalten, so wird der Make Code der Taste in einem vorgegebenen Intervall so lange wiederholt, bis sie wieder losgelassen, oder eine andere Taste gedrückt wird. Dieses Verhalten der Tastatur wird auch Typematic genannt.

Die Verzögerung, nach der die Typematic startet, wird auch als Typematic Delay bezeichnet. Die Wiederholrate, mit der die Zeichen erneut ausgegeben werden, wird auch als Typematic Rate bezeichnet.

Host -> Tastatur

Um Befehle zu versenden und das Verhalten der Tastatur zu konfigurieren können Daten an die Tastatur gesendet werden.

Genaue Einzelheiten zu den einzelnen Befehlen und den Parametern werden wir uns zu einem späteren Zeitpunkt ansehen, wenn es um die Implementierung geht.


elektrische Eigenschaften

Im Ruhezustand liegen beide Leitungen CLK und DATA auf high.

Datentransport

Daten werden mit einem Taktsignal auf der CLK Leitung transportiert. Der Takt wird immer vom Eingabegerät, in unserem Fall also der Tastatur, erzeugt.



Timer und Counter

Worum geht's?

Mit der Funktionseinheit Timer und Controller verfügen AVR Mikrocontroller über eine Komponente, die vielseitig verwendet werden kann.

Als einfache Zähler eingesetzt können sie für die Zeitmessung verwendet werden. Über für diesen Zweck vorgesehene Ausgabepins können sie für die Erzeugung elektrischer Signale herangezogen werden. Schließlich können Timer und Controller auch so konfiguriert werden, dass sie periodisch Interrupts auslösen und so für die Ausführung nebenläufiger Tätigkeiten herangezogen werden.

Bei einem so großen Leistungsspektrum ist es kein Wunder, dass die Beschreibung von Timern einen beinahe einschüchternden Umfang von Seiten im Datenblatt verschlingt. Ziel dieses Kapitels ist es, Dir zu helfen Dich in diesem dichten Papierdschungel ein wenig schneller zurecht zu finden. Ein Ersatz für die Lektüre des Datenblatts ist es nicht.

In diesem Kapitel kannst die folgenden Dinge lernen:

  • Ein/Ausgabepins von Timern lokalisieren
  • Zählerstände von Timern auslesen und vorgeben
  • Die Betriebsart von Timern vorgeben
  • Zählintervalle und Taktquellen von Timern konfigurieren
  • Das Verhalten der Ausgabepins von Timern festlegen
  • Timer für die Erzeugung periodischer Interrupts verwenden

Der Streifzug in die Welt der Timer und Counter in diesem Kapitel orientiert sich am Funktionsumfang eines AtMega328p Microcontrollers. Angaben zu Seitenzahl und Sektion beziehen sich auf das zugehörige Datenblatt [M328p].

Anzahl und Funktionsumfang verfügbarer Timer können bei anderen AVR Modellen abweichen. In diesem Kapitel beschriebene Grundprinzipien und Funktionsweisen sollten aber übertragbar sein. Unterschiede in den Details müssen in diesem Fall mit dem Datenblatt abgeglichen werden.

Grundlagen

Der AtMega328p verfügt über 3 Timer/Counter. Die Timer haben die Namen TC0 TC1 und TC2.

Jeder der Timer kann in einer Reihe verschiedener Betriebsarten verwendet werden. Nach einem Reset befinden sich alle Timer in einer Betriebsart, die im Handbuch Normal Mode genannt wird.

In diesem Abschnitt wird es ausschließlich um diese Betriebsart gehen. Eigenschaften und Konzepte, die Du beim Studium dieser Betriebsart in diesem Abschnitt kennen lernst, werden in folgenden Abschnitten auf andere Betriebsarten übertragen und verallgemeinert werden.

Stromsparmodus

Nach einem Reset befinden sich alle Timer im Stromsparmodus und sind daher deaktiviert.

Bevor es daran gehen kann, mit einem der Timer zu arbeiten, muss er also zunächst erst einmal aktiviert werden. Um die Timer TC0, TC1 bzw. TC2 zu aktivieren, muss das zugehörige Bit PRTIM0, PRTIM1 bzw. PRTIM2 im PRR (power reduction) Register auf den Wert 0 geschrieben werden.[1]

Achtung! Die Information im Handbuch [M328p] ist leider widersprüchlich formuliert:

Zu Timer TC0 heißt es: [2]

The TC0 is enabled by writing the PRTIM0 bit in ”Minimizing Power Consumption” to '0'.
The TC0 is enabled when the PRTIM0 bit in the Power Reduction Register (PRR.PRTIM0) is written to '1'.

Die erste Angabe ist die richtige.

Zu Timer TC1 heißt es: [3]

The Power Reduction TC1 bit in the Power Reduction Register (PRRPRR.PRTIM1) must be written to zero to enable the TC1 module.

Die Abkürzung für das Power Reduction Register ist PRR nicht PRRPRR, ansonsten ist die Angabe korrekt.

Zu Timer TC2 heißt es: [4]

The TC2 is enabled when the PRTIM2 bit in the Power Reduction Register (PRR.PRTIM2) is written to '1'.

Diese Angabe ist leider falsch. Auch hier muss der Wert 0 in das PRTIM2 Bit geschrieben werden.

Das zugehörige Bit PRTIMn muss also stets auf den Wert 0 geschrieben werden.

Im C Quellcode kann das Aktivieren und Deaktivieren der einzelnen Timer wie folgt implementiert werden.

void t0_power(uint8_t on) {
  if (on) cbi(PRR, PRTIM0);
  else    sbi(PRR, PRTIM0);
}
void t1_power(uint8_t on) {
  if (on) cbi(PRR, PRTIM1);
  else    sbi(PRR, PRTIM1);
}
void t2_power(uint8_t on) {
  if (on) cbi(PRR, PRTIM2);
  else    sbi(PRR, PRTIM2);
}

Zählregister

Jeder Timer verfügt über einen Zähler, der von einer Taktquelle gespeist wird. Bei jedem Eintreffen eines Taktsignals schreitet der Zählerstand um einen Schritt voran.

Die Zählrichtung (aufwärts oder abwärts) und der maximale Stand eines Zählers sind dabei von der gewählten Betriebsart abhängig.

Im Normal Mode, mit dem wir uns in diesem Abschnitt befassen, ist die Zählrichtung immer aufwärts. Bei jedem eintreffen eines Taktsignals wird der aktuelle Zählerstand um eins erhöht. Für die Timer TC0 und TC2 liegt der maximale Zählerstand bei 0xFF, für den Timer TC1 beträgt der maximale Zählerstand 0xFFFF. Nach Erreichen des maximalen Zählerstands wird die Zählung beim Eintreffen des nächsten Taktsignals mit Zählerstand 0 fortgesetzt.

Der aktuelle Stand eines Zählers kann über zugehörige Zählregister sowohl gelesen, als auch geschrieben werden. Die Timer TC0 und TC2 verfügen je über einem 8-bit Zähler, dessen Stand über das Register TCNT0, bzw. TCNT2 manipuliert werden kann. Der Timer TC1 verfügt über einen 16-bit Zähler. Zugriff auf den Zählerstand des Timers TC1 ist über die Register TCNT1L und TCNT1H möglich, über die hochwertiges und niederwertiges Byte manipuliert werden können.

Achtung! Beim Zugriff auf 16-bit Register ist eine feste Reihenfolge einzuhalten:[5]

  • Beim Schreiben muss das high Byte vor dem low Byte geschrieben werden.
  • Beim Lesen muss das low Byte vor dem high Byte gelesen werden.

Abschließend sind die Eigenschaften der Zählregister in folgender Tabelle wiedergegeben.

Timer Bitlänge Zählregister max
TC0 8-bit TCNT0 0xFF
TC1 16-bit TCNT1H TCNT1L 0xFFFF
TC2 8-bit TCNT2 0xFF

Taktquelle

Das Tempo, mit dem der Stand eines Zählers voranschreitet, wird durch eine Taktquelle vorgegeben, mit der der zugehörige Timer versorgt wird. Nach einem Reset ist keiner der Timer mit einer Taktquelle verbunden. Bevor es mit dem Zählen los gehen kann, muss der betreffende Timer also zunächst mit einer Taktquelle versorgt werden.

Die Timer TC0 und TC1 können sowohl von einer externen Taktquelle, als auch von einer internen Taktquelle mit einem Takt versorgt werden. Der Timer TC2 kann nur von einer internen Taktquelle versorgt werden. In diesem Abschnitt wird es ausschließlich um die intern zur Verfügung stehenden Taktquellen gehen. Die genaue Lage der Pins, mit denen die Timer TC0 und TC1 extern versorgt werden können, werden wir uns zu einem späteren Zeitpunkt ansehen.

Alle internen Taktquellen werden von zwei prescalern bereit gestellt. Über sie können fest vorgegebene Bruchteile des Systemtakts abgegriffen werden. Ein prescaler Wert von N gibt an, dass der prescaler nur für jeden N-ten CPU Takt ein eigenes Taktsignal erzeugt. Die Timer TC0 und TC1 teilen sich den selben prescaler, an dem sie unabhängig von einander Taktwerte abgreifen können. Der Timer TC2 verfügt über einen eigenen prescaler.

Die Taktquellen der Timer können mit einem 3-bit Wert vorgegeben werden. Die Taktquelle des Timers TCn kann mit den Bits CSn[0:2] (clock source) im zugehörigen TCCRnB (timer counter control register B) Register festgelegt werden.

Über zur Verfügung stehende Wahlmöglichkeiten gibt folgende Tabelle Auskunft. .[6]

CSn[0:2] TC0 / TC1 TC2
0 000 deaktiviert deaktiviert
1 001 clk / 1 clk / 1
2 010 clk / 8 clk / 8
3 011 clk / 64 clk / 32
4 100 clk / 256 clk / 64
5 101 clk / 1024 clk / 128
6 110 fallende Flanke an TO/T1 clk / 256
7 111 steigende Flanke an TO / T1 clk / 1024

Die Wahl der Taktquelle kann im C Quellcode mit folgendem Code Schnipsel implementiert werden.

void t0_cs(uint8_t cs) {
  uint8_t tccr_b = TCCR0B & 0b11111000;
  TCCR0B = tccr_b | cs;
}
void t1_cs(uint8_t cs) {
  uint8_t tccr_b = TCCR1B & 0b11111000;
  TCCR1B = tccr_b | cs;
}
void t2_cs(uint8_t cs) {
  uint8_t tccr_b = TCCR2B & 0b11111000;
  TCCR2B = tccr_b | cs;
}

Codebeispiel

Mit Aktivierung eines Timers und Vorgabe der zugehörigen Taktquelle haben wir bereits alle Schritte durchgeführt, die für den Betrieb eines Timers zwingend erforderlich sind. Davon, dass wir einen Timer mit diesen Schritten tatsächlich in Gang setzen können, können wir uns mit einem kurzen Programm überzeugen. Für die Ausgabe machen wir dabei Gebrauch von der im Kapitel Tipps Tricks und kleine Helferlein vorgestellten Möglichkeit Ausgaben über die serielle Schnittstelle zu versenden und am PC zu empfangen.

void setup() {
  stdout = log_init();

  // TIMER 0
  t0_power(1);  // power on
  t0_cs(1);     // clock source: clk/1
}

void loop() {
  printf("%d ", TCNT0);
  _delay_ms(500);   // delay ~ 500 ms
}

Nachdem Du das Programm übersetzt und auf den Microcontroller übertragen hast, sollte der Microcontroller über die serielle Schnittstelle nun kontinuierlich neue Zahlen ausgeben:

8 204 91 95 104 232 109 237 109 242 114 247 119 247 124 252 129 1 197 84 88 97 101 229 106 234 111

Glückwunsch! Du hast den Timer 0 erfolgreich zum laufen gebracht.

Noch ist das Programm zwar nicht sehr nützlich. Aber mit ihm hast Du bereits den wichtigsten Grundstein für die Erstellung umfangreicherer Programme mit Timern gelegt. Die Schritte, die Du bis hier hin durchgeführt hast, müssen in jedem Programm, das mit Timern arbeitet, ausgeführt werden.

In den folgenden Abschnitten wirst Du weitere Möglichkeiten kennen lernen, Timer zu nutzen und mit ihrer Hilfe komplexere Programme zu erstellen.

Timer und Interrupts

Überlauf

Wie Du bereits erfahren hast, setzt ein Zähler, der seinen maximalen Zählerstand erreicht hat, die Zählung bei erneutem Eintreffen eines Taktsignal bei 0 fort. Ein weiteres Detail ist dabei bisher außer Acht geblieben. Zusätzlich zur bekannten Arbeitsweise wird bei einem solchen Überlauf des Zählers ein Statusflag gesetzt.

Der aktuelle Zustand des Overflow Flags des Timers TC0 kann über das Bit TOV0 (timer overflow) im Register TIFR0 (timer interrupt flag) ausgelesen werden. In analoger Art und Weise sind die Bits TOV1 und TOV2 in den Registern TIFR1 bzw. TIFR2 für die Timer TC1 und TC2 zuständig. Einmal gesetzt bleibt das Overflow Flag eines Timers TCn so lange gesetzt, bis es durch Schreiben einer 1 in das zugehörige TOCn Bit manuell zurückgesetzt wird.

Interrupt

Eine besondere Bedeutung gewinnt das Overflow Flag TOVn eines Timers TCn erst dann, wenn es zum Auslösen von Interrupts verwendet wird. Um diese zu erreichen muss sowohl das zugehörige Bit TOIEn (timer overflow interrupt enable) Bit im TIMSKn (timer interrupt mask) Register gesetzt werden, als auch die Zustellung von Interrupts global aktiviert werden.

Bei Ansprung der zugehörigen Service Routine wird das Overflow Flag automatisch gelöscht. Ein manuelles Rücksetzen ist in diesem Fall also nicht nötig.

Zum Aktivieren und Deaktivieren des Interruptbetriebs kann der folgende Code Schnipsel verwendet werden.

void t0_interrupt_ov(uint8_t on) {
  if (on) sbi(TIMSK0, TOIE0);
  else    cbi(TIMSK0, TOIE0);
}
void t2_interrupt_ov(uint8_t on) {
  if (on) sbi(TIMSK2, TOIE2);
  else    cbi(TIMSK2, TOIE2);
}
void t1_interrupt_ov(uint8_t on) {
  if (on) sbi(TIMSK1, TOIE1);
  else    cbi(TIMSK1, TOIE1);
}

Die zugehörigen Interrupt Service Routinen können mit dem ISR() Makro der Header Datei avr/interrupt.h definiert werden. Die Indices der zugehörigen Einträge in der Interruptvektortabelle können mit den Präprozessormakros TIMER0_OVF_vect, TIMER1_OVF_vect bzw. TIMER2_OVF_vect angegeben werden.

Codebeispiel

Das Arbeiten mit dem Overflow Interrupt können wir mit einem kleinen Programm ausprobieren. Durch zählen der zwischen zwei Überläufen des Zählers verstrichenen Taktschritte soll es die seit dem Systemstart verstrichene Zeit in Sekunden ausgeben. Die Anzahl der Taktschritte, die pro Sekunde ausgeführt werden, kann dem Makro F_CPU entnommen werden.

void setup() {
  stdout = log_init();

  // TIMER 0
  t0_power(1);       // power on
  t0_cs(5);          // clock source: clk/1024

  t0_interrupt_ov(1); // enable overflow interrupt
  sei();              // global interrupt enable
}

volatile uint32_t uptime = 0;

void loop() {
  printf("\r%5ld s", uptime);
  _delay_ms(500);   // delay ~ 500 ms
}

ISR(TIMER0_OVF_vect) {
  static uint32_t clocks = 0;
  clocks += 1024L * 256;
  if (clocks > F_CPU) {
    uptime += 1;
    clocks -= F_CPU;
  }
}

Nachdem Du das Programm übersetzt und auf den Microcontroller übertragen hast, sollte der Microcontroller über die serielle Schnittstelle nun kontinuierlich die aktuelle Anzahl Sekunden seit dem Systemstart anzeigen.

Vergleichsregister

Zusätzlich zu den Registern, die Du bereits kennen gelernt hast, verfügt jeder Timer über zwei Vergleichsregister OCRnA (output compare A) und OCRnB (output compare B).

Der Inhalt dieser beiden Vergleichsregister wird permanent mit dem aktuellen Zählerstand des zugehörigen Timers verglichen. Bei einer Übereinstimmung werden die Flags OCRFnA (output compare flag A) bzw. OCRFnB (output compare flag B) im TIFRn (timer interrupt flag register) Register gesetzt.

Die Timer TC0 und TC2 verfügen jeweils über zwei 8-bit Vergleichsregister OCR0A und OCR0B bzw. OCR2A und OCR2B. Der Timer TC1 verfügt über zwei 16-bit Vergleichsregister, mit zughörigen Werten in den Registern OCR1AL und OCR1AH sowie OCR1BL und OCR1BH.

Achtung! Bei der Arbeit mit den Vergleichsregistern sind folgende Besonderheiten zu beachten:

  • Bei schreibendem Zugriff auf das Zählregister eines Timers wird der Vergleich für einen Taktzyklus ausgesetzt.
  • Bei Zugriff auf die 16-bit Register OCR1A und OCR1B ist dieselbe Reihenfolge, wie bei Zugriff auf die 16-bit Zählerstände einzuhalten.

Zum Setzen der Werte der Vergleichsregister kann folgender Code Schnipsel verwendet werden

void t0_ocra(uint8_t ocra) {
  OCR0A = ocra;
}
void t0_ocrb(uint8_t ocrb) {
  OCR0B = ocrb;
}
void t2_ocra(uint8_t ocra) {
  OCR2A = ocra;
}
void t2_ocrb(uint8_t ocrb) {
  OCR2B = ocrb;
}
void t1_ocra(uint16_t ocra) {
  OCR1AH = (uint8_t) (ocra >> 8);
  OCR1AL = (uint8_t) (ocra & 0xFF);
}
void t1_ocrb(uint16_t ocrb) {
  OCR1BH = (uint8_t) (ocrb >> 8);
  OCR1BL = (uint8_t) (ocrb & 0xFF);
}

Interrupts

Analog zur Arbeitsweise des Overflow Flags können auch diese beiden Flags zum Auslösen von Interrupts verwendet werden. Damit ein Interrupt ausgelöst wird müssen die Bits OCIEnA (output compare interrupt enable A) bzw. OCIEnB (output compare interrupt enable B) im TIMSKn (timer interrupt mask) Register auf den Wert 1 gesetzt werden.

Um den Interruptbetrieb zu aktivieren / deaktivieren kann der folgende Code Schnipsel verwendet werden:

void t0_interrupt_ocra(uint8_t on) {
  if (on) sbi(TIMSK0, OCIE0A);
  else    cbi(TIMSK0, OCIE0A);
}
void t0_interrupt_ocrb(uint8_t on) {
  if (on) sbi(TIMSK0, OCIE0B);
  else    cbi(TIMSK0, OCIE0B);
}

void t2_interrupt_ocra(uint8_t on) {
  if (on) sbi(TIMSK2, OCIE2A);
  else    cbi(TIMSK2, OCIE2A);
}
void t2_interrupt_ocrb(uint8_t on) {
  if (on) sbi(TIMSK2, OCIE2B);
  else    cbi(TIMSK2, OCIE2B);
}

void t1_interrupt_ocra(uint8_t on) {
  if (on) sbi(TIMSK1, OCIE1A);
  else    cbi(TIMSK1, OCIE1A);
}
void t1_interrupt_ocrb(uint8_t on) {
  if (on) sbi(TIMSK1, OCIE1B);
  else    cbi(TIMSK1, OCIE1B);
}

Die Indices der zugehörigen Interruptvektor Einträge können mit den Präprozessormakros TIMER0_COMPA_vect, TIMER0_COMPB_vect, TIMER1_COMPA_vect, TIMER1_COMPB_vect, TIMER2_COMPA_vect bzw. TIMER2_COMPB_vect angegeben werden.

Codebeispiel

Auch am Ende dieses Abschnitts soll ein Teil der neu gewonnen Möglichkeiten anhand eines kleinen Beispiels demonstriert und überprüft werden.

In diesem Programm soll die Leutdiode L in schnellem Wechsel ein- und ausgeschaltet und somit gedimmt werden. Um das zu bewerkstelligen, können wir zwei Interrupts verwenden. Wenn wir sowohl den Overflow Interrupt, als auch den Compare Match A Interrupt verwenden, können wir die zugehörigen Service Routinen so implementieren, dass die Leuchtdiode bei jedem Überlauf eingeschaltet und bei jedem Compare Match wieder ausgeschaltet wird.

Über das zugehörige Vergleichsregister kann dann die Leuchtdauer festgelegt werden.

void setup() {
  stdout = log_init();

  sbi(DDRB, DDB5);      // LED: PORTB5 out 
  
  // TIMER 0
  t0_power(1);          // power on
  t0_cs(5);             // clock source: clk/1024

  t0_interrupt_ocra(1); // enable compare match A interrupt
  t0_interrupt_ov(1);   // enable overflow interrupt
  sei();                // global interrupt enable
}

void loop() {
  static uint8_t i;

  i += 1;
  if (i == 128)
    i = 0;
    
  t0_ocra(i);
  _delay_ms(10);
}

ISR(TIMER0_OVF_vect) {
  sbi(PORTB, PORTB5);     // high LED
}

ISR(TIMER0_COMPA_vect) {
  cbi(PORTB, PORTB5);     // low LED
}

Glückwunsch! Du hast nun alle Möglichkeiten kennen gelernt, die Timer im 'normal mode zur Verfügung stellen.

Im nächsten Abschnitt wird es darum gehen, dieses Wissen auch auf die anderen Betriebsarten von Timern zu übertragen.

Betriebsarten

Bisher wurde ausschließlich die Arbeitsweise im normal mode betrachtet. In diesem Abschnitt wird es darum gehen einen Überblick über die verschiedenen anderen Modi zu erhalten, in denen die Timer betrieben werden können. Die verschiedenen Betriebsarten von Timern werden im Handbuch unter der Bezeichnung Wave Generation Modes geführt, da sie vor allem für die Signalerzeugung von besonderer Bedeutung sind.

Eigenschaften

Die Timer TC0 und TC2 verfügen über den gleichen Satz an Betriebsarten. Der gewünschte Wave Generation Mode kann für jeden der beiden Timer mit einem 3-bit Wert konfiguriert werden. Der Timer1 verfügt über einen umfangreicheren Satz von Betriebsarten. Für ihn kann der gewünschte Wave Generation Mode mit einem 4-bit Wert vorgegeben werden.

Die verschiedenen Wave Generation Modes unterscheiden sich in den folgenden Eigenschaften:

  • maximaler Zählerstand
  • Zählintervall
  • Pufferung der Vergleichsregister

Der maximale Zählerstand kann, so wie Du es bereits vom Normal Mode kennst, durch einen für den jeweiligen Modus spezifischen festen Wert vorgegeben sein. In einigen Modi kann der maximale Stand eines Zählers stattdessen durch Vorgabe des gewünschten Werts im OCRA bzw. dem ICR Register vorgegeben werden.

Das Zählintervall eines Timers durchläuft entweder das Intervall von 0 bis zum Maximalwert, oder es durchläuft zunächst aufsteigend die Werte von 1 bis zum Maximalwert um danach schrittweise bis auf den Wert 0 abzusinken.

Im Normal Mode fand keine Pufferung der Vergleichsregister statt. In die Register OCRnA und OCRnB geschriebene Werte wurden sofort übernommen und haben sich somit sofort auf das Verhalten der Timer ausgewirkt. In anderen Modi können die die OCRnx Register stattdessen gepuffert sein. Werte in diesen Registern werden dann nur zu bestimmten, durch den jeweiligen Modus bestimmten Zeitpunkten übernommen.

Überblick

Achtung! Die Benennung der Betriebsarten im Handbuch [M328p] folgt keinem einheitlichen Schema:

  • Bei Modi mit gepufferten OCRx Registern erfolgt die Benennung anhand des Zählintervalls und des Zeitpunkts der Übernahme der gepufferten Werte. Bei diesen Modi wird zwischen Fast PWM Mode, Phase Correct PWM Mode und Phase and Frequency Correct PWM Mode unterschieden.
  • Bei ungepufferten OCRx Registern wird abhängig vom maximalen Zählerstand zwischen Normal Mode und CTC Mode unterschieden.

In der folgenden Übersicht wurden Normal Mode und Clear Timer on Compare Match Mode zur Kategorie Non-PWM Mode zusammengefasst. Die folgenden Kombinationen aus Zählintervall und Pufferung der Vergleichsregister sind möglich. Das Overflow Flag wird dabei stets beim Überschreiten des in der Spalte Zählintervall ganz rechts angegebenen Werts gesetzt.

Kategorie Zählintervall OCRx update
n/ctc Non-PWM Mode 0...max sofort
f Fast PWM Mode 0...max 0
pc Phase Correct PWM Mode 1...max...0 max
pfc Phase and Frequency Correct PWM Mode 1...max...0 0

Durch die Kategorie ist das Verhalten eines Timers bereits bis auf den maximalen Zählerstand bestimmt. Abhängig davon, um welchen Timer es sich handelt, sind folgende Kombinationen aus Kategorie und maximalem Zählerstand möglich.

Timer 0 / 2

WGM 0 0000 1 0001 2 0010 3 0011 4 0100 5 0101 6 0110 7 0111
Typ n pc ctc f - pc - f
max 0xFF 0xFF OCRA 0xFF - OCRA - OCRA

Timer 1

WGM 0 00000 1 0001 2 0010 3 0011 4 0100 5 0101 6 0110 7 0111
Typ n pc pc pc ctc f f f
max 0xFFFF 0x00FF 0x01FF 0x03FF OCRA 0x00FF 0x01FF 0x03FF
WGM 8 10000 9 1001 10 1010 11 1011 12 1100 13 1101 14 1110 15 1111
Typ pfc pfc pc pc ctc - f f
max ICR OCRA ICR OCRA ICR n/a ICR OCRA

Konfiguration

Die Modi der Timer TC0 und TC2 können mit einem 3-bit Wert konfiguriert werden. Die zugehörigen Bits liegen über zwei Register verteilt. Die Bits WGMn0, WGMn1 finden sich im Register TCCRnA, das Bit WGMn2 findet sich im TCCRnB

Der Modus von Timer1 kann mit einem 4-bit Wert konfiguriert werden. Auch hier sind die Bits über zwei Register verteilt. Die Bits WGM10 und WGM11 finden sich im Register TCCR1A, die Bits WGM12 und WGM13 im Register TCCR1B.

Um die Modi der Timer vorgeben zu können kann folgender Code Schnipsel verwendet werden.

void t0_wgm(uint8_t wgm) {
  uint8_t wgm_01 = wgm & 0b00000011;
  uint8_t wgm_2  = (uint8_t) ( (wgm & 0b00000100) << 1 );
  
  uint8_t tccr_a = TCCR0A & 0b11111100;
  uint8_t tccr_b = TCCR0B & 0b11110111;
  
  TCCR0A = tccr_a | wgm_01;
  TCCR0B = tccr_b | wgm_2;
}
void t2_wgm(uint8_t wgm) {
  uint8_t wgm_01 = wgm & 0b00000011;
  uint8_t wgm_2  = (uint8_t) ( (wgm & 0b00000100) << 1 );
  
  uint8_t tccr_a = TCCR2A & 0b11111100;
  uint8_t tccr_b = TCCR2B & 0b11110111;
  
  TCCR2A = tccr_a | wgm_01;
  TCCR2B = tccr_b | wgm_2;
}
void t1_wgm(uint8_t wgm) {
  uint8_t wgm_01 = wgm & 0b00000011;
  uint8_t wgm_23 = (uint8_t) ( (wgm & 0b00001100) << 1 );
  
  uint8_t tccr_a = TCCR1A & 0b11111100;
  uint8_t tccr_b = TCCR1B & 0b11100111;
  
  TCCR1A = tccr_a | wgm_01;
  TCCR1B = tccr_b | wgm_23;
}

Codebeispiel

Im Codebeispiel am Ende dieses Abschnitts geht es noch einmal darum die LED L zu dimmen. Im Normal Mode, in dem wir den Timer im letzten Codebeispiel betrieben hatten, mussten wir dafür zwei Interrupt Routinen verwendet. Wenn wir den Timer im Phase Correct PWM Mode betreiben, genügt eine Interrupt Routine. Sowohl beim Aufwärtszählen, als auch beim Abwärtszählen wird ein Compare Match mit dem Vergleichsregister OCRA ausgelöst. Es genügt also bei einem Compare Match den Zustand der LED zu toggeln.

void setup() {
  stdout = log_init();

  sbi(DDRB, DDB5);      // LED: PORTB5 out 
  
  // TIMER 0
  t0_power(1);          // power on
  t0_cs(5);             // clock source: clk/1024
  t0_wgm(1);

  t0_interrupt_ocra(1); // enable overflow interrupt
  sei();                // global interrupt enable
}

void loop() {
  static uint8_t i;

  ++i;
    
  t0_ocra(i);
  _delay_ms(10);
}

ISR(TIMER0_COMPA_vect) {
  sbi(PINB, PINB5);            // toggle LED
}

Signalerzeugung

Die verschiedenen Betriebsarten von Timern sind vor allem dann von Nutzen, wenn sie zum Erzeugen periodischer Signale eingesetzt werden. Zeitgesteuerte Aufgaben wie diese können, wie Du bereits gesehen hast, mit Interrupts in Software ausgeführt werden. Kosten in diesem Fall aber auch Rechnzeit des Microcontrollers.

Eine elegante Möglichkeit, mit der Aufgaben wie diese erledigt werden können, auch ohne die CPU des Micorcontrollers zu belasten, kannst Du in diesem Abschnitt kennen lernen.

Ausgabepins konfigurieren

Für jeden Timer TCn sind zwei Ausgabepins OCnA und OCnB vorgesehen. Wenn ein Timer nur zum zählen verwendet wird, können die zugehörigen Ausgabepins für einen anderen Zweck benutzt werden.

Die genaue Lage der Pins kann im Datenblatt nachgesehen werden.[7]

Timer Taktquelle (Tn) output A (OCRnA) output B (OCRnB) capture (ICP1)
TC0 PD4 PD6 PD5 -
TC1 PD5 PB1 PB2 PB0
TC2 - PB3 PD3 -

Damit die entsprechenden Pins als Ausgabepins verwendet werden können, muss zunächst die Datenrichtung auf Ausgabe gestellt werden.

Das kann mit folgendem Code Schnipsel realisiert werden.

void t0_a_out() { // OC0A = PD6
  sbi(DDRD, DDB6);
};
void t0_b_out() { // OC0B = PD5
  sbi(DDRD, DDB5);
};
void t2_a_out() { // OC2A = PB3
  sbi(DDRB, DDB3);
};
void t2_b_out() { // OC2B = PD3
  sbi(DDRD, DDD3);
};
void t1_a_out() { // OC1A = PB1
  sbi(DDRB, DDB1);
};
void t1_b_out() { // OC1B = PB2
  sbi(DDRB, DDB2);
};

Ausgabemodus

Das Verhalten der Ausgabepins von Timer TC0 kann kann über die Bits COM0A[1:0] (compare output mode A), bzw. COM0B[1:0] (compare output mode B) im TCCR0A (timer counter control register A) Register vorgegeben werden. Analog kann das Verhalten der Ausgabepins der Timer TC1 und TC2 über die Bits COM1A[1:0] und COM1B[1:0] im TCCR1A Register, bzw. die Bits COM2A[1:0] und COM2B[1:0] im TCCR2A Register vorgegeben werden.

Die Ausgabepins können so konfiguriert werden, dass sie abhängig vom Zählerstand des zugehörigen Timers gesetzt, gelöscht oder getoggelt werden. Die Konfigurationsmöglichkeiten hängen dabei von dem Modus ab, in dem der jeweilige Timer betrieben wird.

M:match B:bottom U:match when up-counting D: match when down counting

COMxA/B normal/ctc fast PWM phase (and frequency) correct PWM
0 00 disconnected disconnected disconnected
1 01 M:toggle siehe Tabelle X siehe Tabelle Y
2 10 M:clear M:clear B:set U:clear D:set
3 11 M:set M:set B:clear U:set D:clear

Der Wahl des Ausgabemodus kann im C Quellcode mit folgendem Code Schnippsel implementiert werden.

void t0_coma(uint8_t coma) {
  uint8_t tccr_a = TCCR0A & 0b00111111;
  TCCR0A = tccr_a | (uint8_t) (coma << COM0A0);
}
void t0_comb(uint8_t comb) {
  uint8_t tccr_a = TCCR0A & 0b11001111;
  TCCR0A = tccr_a | (uint8_t) (comb << COM0B0);
}
void t2_coma(uint8_t coma) {
  uint8_t tccr_a = TCCR2A & 0b00111111;
  TCCR2A = tccr_a | (uint8_t) (coma << COM0A0);
}
void t2_comb(uint8_t comb) {
  uint8_t tccr_a = TCCR2A & 0b11001111;
  TCCR2A = tccr_a | (uint8_t) (comb << COM0B0);
}
void t1_coma(uint8_t coma) {
  uint8_t tccr_a = TCCR1A & 0b00111111;
  TCCR1A = tccr_a | (uint8_t) (coma << COM0A0);
}
void t1_comb(uint8_t comb) {
  uint8_t tccr_a = TCCR1A & 0b11001111;
  TCCR1A = tccr_a | (uint8_t) (comb << COM0B0);
}

Frequenz und Tastgrad

Allen Signalen, die mit die Ausgabepins generiert werden können, ist gemein, dass der Ausgabewert nur zwischen den beiden Pegeln low und high wechseln kann. Es können also immer nur Rechtecksignale erzeugt werden.

Typische Kenngrößen eines solchen Rechtecksignals sind:

Bei der Betrachtung dieser Kenngrößen stellt der Vergleichsmodus 1 einen Sonderfall dar. Aus diesem Grund wird er erst zu einem späteren Zeitpunkt betrachtet werden.

Vergleichsmodus 2 und 3

Non-PWM Mode

Hier wird gar kein Rechtecksignal erzeugt. Der Ausgabepegel wird einmalig auf high, bzw. low gesetzt und verbleibt auf diesem Stand.

Fast PWM Mode

Der Zähler durchläuft das Intervall in Schritten. Nach einem weiteren Schritt des Zählers beginnt die Zählung von vorn. Eine Periode ist somit genau nach Schitten des Zählers durchlaufen. Die Periodendauer beträgt somit Taktschritte. Um aus diesem Wert die Periodendauer in Sekunden zu erhalten, muss er mit dem CPU Takt verrechnet werden. Die Frequenz kann dann als Kehrwert der Periodenlänge berechnet werden.

s

Hz

Phase Correct / Phase and Frequency Correct PWM Mode

Der Zähler durchläuft das Intervall in Schritten. Nach einem weiteren Zählschritt, also insgesamt Schritten, ist eine Periode beendet. Die Periodendauer beträgt somit Taktschritte. Für die Umrechnung in Sekunden muss dieser Wert mit dem CPU Takt verrechnet werden. Die Frequenz kann dann als Kehrwert der Periodenlänge in Sekunden berechnet werden.

s

Hz

Vergleichsmodus 1

Non-PWM Mode

Der Zustand des Ausgabepins wird in diesem Vergleichsmodus bei jedem Erreichen des maximalen Zählerstands invertiert. In zwei aufeinander folgenden Durchläufen des Zählintervalls steht der Ausgabepin so einmal low und einmal high und erzeugt auf diese Weise ein Rechtecksignal.

Das Intervall, das für die Erzeugung einer Periode des Rechtecksignals durchlaufen wird, besteht aus Zählerständen . Erst bei Eintreffen des nächsten Taktsignals beginnt die Generierung von vorn. In dieser Zeit verstreichen Zählschritte. Die Periodendauer beträgt somit Taktschritte. Um hieraus die Periodendauer in Sekunden und die Frequenz zu berechnen, muss der CPU Takt verrechnet und der Kehrwert gebildet werden.

s

Hz

Fast PWM Mode

Der Zustand des Ausgabepins verhält sich in diesem Vergleichsmodus im Fast PWM Mode genauso wie im Non-PWM Mode. Für Periodenlänge und Frequenz ergeben sich somit dieselben Werte wie zuvor.

Phase Correct / Phase and Frequency Correct PWM Mode

TODO

Zusammenfassung

Eine Zusammenfassung findest Du in den folgenden Tabellen.

Output Compare Mode 1
Typ Frequenz Tastgrad
n/ctc 50 %
f 50 %
pc/pfc
Output Compare Mode 2 / 3
Typ Frequenz Tastgrad
n/ctc kein Rechtecksignal -
f
pc/pfc

Achtung! Im ctc Modus findet keine Doppelpufferung des OCRnA Registers statt.

Wird OCRnA auf einen Wert gesetzt, den der Zähler bereits überschritten hat, findet der nächste Match erst nach Überlauf des Zählers statt.

Rückschau und Ausblick

An dieser Stelle hast den Rundgang durch die Welt der Timer und Counter absolviert. Du hast nun alle Möglichkeiten von Timern kennen gelernt, die Dir bei der Realisierung eigener Projekte zur Verfügung stehen.

Gegebenenfalls hast Du die Informationen dieses Kapitels mit dem Datenblatt Deines Microcontrollers abgeglichen. Du kannst jetzt:

  • die Ein- und Ausgabepins von Timern lokalisieren
  • Zählerstände von Timern auslesen und vorgeben

Gegebenenfalls hast Du die Code Schnippsel dieses Kapitels an Deinen Microcontroller angepasst. Mit den Funktionen, die Du Dir in diesem Kapitel erarbeitet hast, kannst Du:

  • Timer aktivieren und deaktivieren

Du kannst das Zählintervall von Timern vorgeben, indem Du:

  • dem jeweiligen Timer eine Taktquelle zuweist
  • die Betriebsart des Timers festlegst
  • gegebenenfalls den maximalen Zählerstand vorgibst

Du kannst dafür sorgen, dass Timer periodisch Interrupts auslösen, indem Du:

  • den Overflow Interrupt von Timern aktivierst
  • den Compare Match Interrupt von Timern aktivierst
  • gewünschte Zeitpunkt über die zughörigen Vergleichsregister festlegst
  • die Zustellung von Interrupts global aktivierst

Du kannst die Ausgabepins von Timern für die Signalerzeugung verwenden, indem Du:

  • die Datenrichtung der Pins als Ausgabepin festlegst
  • den Ausgabemodus des zugehörigen Vergleichsregisters vorgibst
  • gewünschte Werte in das Vergleichsregister schreibst

In folgenden Kapiteln findest Du Beispiele und Anregungen zu Aufgaben, die mit Hilfe von Timern realisiert werden können.

Register Übersicht

Für Timer 0 und Timer 2

Kontroll Register
TCCRnA compare output mode A compare output mode B waveform generation mode
COMnA1 COMnA0 COMnB1 COMnB0 x x WGMn1 WGMn0
TCCRnB force output A force output B waveform gen mode clock select
FOCnA FOCnB x x WGMn2 CSn2 CSn1 CSn0
Interrupt Register
TIFRn compare B compare A overflow
x x x x x OCFnB OCFnA TOVn
TIMSKn compare A interrupt enable compare B interrupt enable overflow interrupt enable
x x x x x OCIEnB OCIEnA TOIEn
Vergleichsregister
OCRnA output compare A
OCRnA_7 OCRnA_6 OCRnA_5 OCRnA_4 OCRnA_3 OCRnA_2 OCRnA_1 OCRnA_0
OCRnB output compare B
OCRnB_7 OCRnB_6 OCRnB_5 OCRnB_4 OCRnB_3 OCRnB_2 OCRnB_1 OCRnB_0
Zählregister
TCNTn counter value
TCNTn_7 TCNTn_6 TCNTn_5 TCNTn_4 TCNTn_3 TCNTn_2 TCNTn_1 TCNTn_0


Kontroll Register
TCCR1A compare output mode A compare output mode B waveform generation mode
COM1A1 COM1A0 COM1B1 COM1B0 x x WGM11 WGM10
TCCR1B input capture noise cancel input capture edge select waveform generation mode clock select
ICNC1 ICES1 x WGM13 WGM12 CS12 CS11 CS10
TCCR1C force output A force output B
FOC1A FOC1B x x x x x x
Interrupt Register
TIFR1 input capture compare B compare A overflow
x x ICF x x OCF0B OCF0A TOV0
TIMSK1 input capture interrupt enable compare B interrupt enable compare A interrupt enable overflow
x x ICIE x x OCIE1B OCIE1A TOIE1
Vergleichsregister
OCR1AH output compare A high byte
OCR1AH7 OCR1AH6 OCR1AH5 OCR1AH4 OCR1AH3 OCR1AH2 OCR1AH1 OCR1AH0
OCR1AL output compare A low byte
OCR1AL7 OCR1AL6 OCR1AL5 OCR1AL4 OCR1AL3 OCR1AL2 OCR1AL1 OCR1AL0
OCR1BH output compare B high byte
OCR1BH7 OCR1BH6 OCR1BH5 OCR1BH4 OCR1BH3 OCR1BH2 OCR1BH1 OCR1BH0
OCR1BL output compare B low byte
OCR1BL7 OCR1BL6 OCR1BL5 OCR1BL4 OCR1BL3 OCR1BL2 OCR1BL1 OCR1BL0
Capture Register
ICR1H input capture high byte
ICR1H7 ICR1H6 ICR1H5 ICR1H4 ICR1H3 ICR1H2 ICR1H1 ICR1H0
OCR1AL input capture low byte
ICR1L7 ICR1L6 ICR1L5 ICR1L4 ICR1L3 ICR1L2 ICR1L1 ICR1L0
Zählregister
TCNT1H counter high byte
TCNT1H7 TCNT1H6 TCNT1H5 TCNT1H4 TCNT1H3 TCNT1H2 TCNT1H1 TCNT1H0
TCNT1L counter low byte
TCNT1L7 TCNT1L6 TCNT1L5 TCNT1L4 TCNT1L3 TCNT1L2 TCNT1L1 TCNT1L0

Fußnoten

  1. (siehe [M328p]: 14.12.3. Power Reduction Register, S.71)
  2. (siehe [M328p]: Sektion 19.2 Overview S.125)
  3. (siehe [M328p]: Sektion 20.3 Block Diagram S.149)
  4. (siehe [M328p]: Sektion 22.2 Overview S.189)
  5. (siehe [M328p]: Sektion 20.6. Accessing 16-bit Registers, S.152)
  6. (siehe [M328p]: Table 19-10. Clock Select Bit Description, S. 142, Table 20-7. Clock Select Bit Description, S. 173 sowie Table 22-10. Clock Select Bit Description, S. 206 )
  7. (siehe [M328p]: Sektion 5. Pin Configurations, S.14)

Projekt 1

Die Angaben in diesem Kapitel orientieren sich am Funktionsumfang eines AtMega328p Microcontrollers. Angaben zu Seitenzahl und Sektion beziehen sich auf das zugehörige Datenblatt [M328p].

Idee

Die Timer/Counter eins AVR Microcontrolers können für die Klangerzeugung missbraucht werden. Um einen Ton zu erzeugen kann an einem der Ausgabepins ein Rechtecksignal erzeugt werden. Das Rechtecksignal kann dann mit einem Piezo-Lautsprecher ausgegeben werden.

Die gewünschte Tonhöhe kann dabei über die Länge des Zählintervalls variiert werden. Wird für die Ausgabe des Rechtecksignals einer der beiden Ausgabepins eines Timers verwendet und der Compare Output Mode des Pins entsprechend konfiguriert, so kann das Umschalten zwischen high und low der Hardware überlassen werden.

Konfiguration Timer

Um bei der Wahl der Frequenz des Timers einen großen Spielraum zu haben, wird der 16-bit Zähler TC1 verwendet.

Damit die Länge des Zählintervalls vorgegeben werden kann, muss ein Modus gewählt werden, bei dem der maximale Zählerstand im Register OCR1A vorgegeben werden kann. In diesem Projekt werden wir den Timer im Fast PWM Mode betreiben. Es muss also der Wave Genration Mode 15 gewählt werden.

Als Ausgabepin soll der Pin OCA1 verwendet werden. Damit er zur richtigen Zeit high und low geschaltet wird, muss ein geeigneter Output Compare Mode vorgegeben und die Datenrichtung das Pins auf Ausgabe gesetzt werden. Für den Output Compare Mode werden wir den Wert 1 vorgeben. In diesem Modus wird der Zustand des Pins bei jedem Erreichen des maximalen Zählerstands invertiert. In zwei aufeinander folgenden Durchläufen des Zählintervalls steht der Ausgabepin so einmal low und einmal high und erzeugt so das gewünschte Rechtecksignal.

Dem Umstand, dass für die Erzeugung einer Periode des Rechtecksignals zwei Zählerdurchläufe nötig sind, müssen wir später bei der Berechnung der Frequenz des erzeugten Signals Rechnung tragen.

Mit den Hilfsfunktionen des vorangegangenen Kapitels kann die Initialisierung des Timers wie folgt vorgenommen werden:

void setup() {
  t1_power(1);
  t1_wgm(15);

  t1_a_out();
  t1_coma(1);
}

Tonhöhen

Um eine Folge von Tönen abspielen zu können, muss die Folge der gewünschten Tonhöhen in irgendeiner Form codiert werden. Bei der Wahl dieser Codierung sind wir frei. Für dieses Projekt wurde entschieden, die Tonhöhe mit der zugehörigen MIDI Tonnummer codieren.

Die Stufen der MIDI Tonnummern entsprechen den Tonstufen der gleichstufigen Stimmung. Die Tonnummer 69 entspricht dem Kammerton A (440 Hz). Die Frequenz der MIDI Tonummer m kann somit wie folgt berechnet werden:

Da der Microcontroller über keine Maschinenbefehle für Berechnungen mit Fließkommazahlen verfügt, muss jede Berechnung mit float Werten in eine Serie von Maschinenbefehlen übersetzt werden. Es ist deshalb ratsam die Zahl benötigter float Berechnungen so gering wie möglich zu halten. Für die Berechnung im C Quellcode kann der Exponent durch Division mit Rest in zerlegt werden. Einsetzten liefert.

mit für

Mit den vorberechneten Werten kann folgender Code Schnipsel verwendet werden:

float F_i = {
   8.175798,  8.661957,  9.177023,  9.722718, 10.300861, 10.913382,
  11.562325, 12.249857, 12.978271, 13.749999, 14.567617, 15.433853
};

float f_m(uint8_t m) {
   float value = (2 << (m/12)) * F_i[m%12];
   return value;
}

Frequenz

Die Frequenz, mit der Timer das Zählintervall durchläuft bestimmt die Tonhöhe. Für die Erzeugung einer Periode des Rechtecksignals muss das Zählintervall zwei Mal durchlaufen werden. Die damit erzeugt Frequenz hängt sowohl vom Wert des OCR1A Registers ab, als auch von der Taktrate, mit der der Zählerstand voranschreitet. Für die Berechnung der erzeugten Frequenz gilt:

Sowohl den Wert von OCR1A, als auch den Prescaler Wert können wir in gewissen Grenzen vorgeben. Der maximale Wert, der für OCR1A (16-bit) gewählt werden kann beträgt 65535. Der Prescaler Wert kann durch Wahl der gewünschten Taktquelle nur in den Stufen 1 / 8 / 64 / 256 / 1024 vorgegeben werden. Umstellen der obigen Formel liefert:

Je geringer der Prescaler Wert gewählt ist, umso höher ist die Auflösung, die wir durch Vorgabe von OCR1A erreichen können. Wir machen bei der Wahl eines Paars OCR1A, prescaler zu gegebener Frequenz also sicher keinen Fehler, wenn wir im Rahmen der Möglichkeiten den Wert von OCR1A so groß wie möglich, d.h. den Wert von prescaler so klein wie möglich wählen.

Im C Quellcode kann die Wahl von Taktquelle cs und OCRA Wert ocra in Abhängigkeit von der gewünschten Frequenz f wie folgt implementiert werden:

uint32_t ocra  = (F_CPU / 2) / (uint32_t) f;

uint16_t prescaler[] = { 0, 1, 8, 8, 4, 4 };
uint8_t cs     = 1;

while (1) {
   if (ocra < 65536 || cs == 5)
      break;
   ++cs;
   ocra /= prescaler[cs];
   }
ocra -= 1;

Melodie

Mit den Code Schipseln der vorangegangenen Abschnitte haben wir bereits alle Voraussetzungen geschaffen, um für eine gegebene Tonnummer den zugehörigen Ton auszugeben. Die Aufgabe den Ausgabepin zu den richtigen Zeitpunkten high und low zu setzen haben wir dabei auf die Hardware der Timer Einheit abgewälzt. Die einzige Aufgabe, die wir zum abspielen einer ganzen Melodie in Software durchführen müssen, besteht darin die gewünschten Tonhöhen im richtigen Rhythmus vorzugeben.

Die Folge der gewünschter Tonnummern können wir zu diesem Zweck in einem Array vorgeben. Im Programm werden wir sie diesem Array in einem festen Rhythmus entnehmen und abspielen. Um mit dieser einfachen Herangehensweise auch Töne unterschiedlicher Länge abspielen zu können, müssen wir die zugehörige Tonnummer entsprechend der gewünschten Länge mehrfach hintereinander in das Array legen. Eine Möglichkeit Pausen zu spielen haben wir nicht.

Das Repertoire, das wir mit diesem Ansatz abspielen können ist sehr beschränkt. Zu jedem Zeitpunkt kann immer nur ein einziger Ton gespielt werden. Mehrstimmige Melodien können wir also nicht abspielen.

Ein Versuch die ersten Takte des Präludium in C-Dur von Johann Sebastian Bach an die Einschränkungen des Programms anzupassen, findet sich in folgendem Code Schnipsel. Um konventionellen Speicherplatz zu sparen, kann das Array in den Programmspeicher gelegt werden. Die einzelnen Werte können dann im Programm mit der Funktion pgm_read_byte() eingelesen werden.

const uint8_t song[] PROGMEM = {
   60, 64, 67, 72, 76, 67, 72, 76,    60, 64, 67, 72, 76, 67, 72, 76,
   60, 62, 69, 74, 77, 65, 74, 77,    60, 62, 69, 74, 77, 65, 74, 77,
   59, 62, 67, 74, 77, 67, 74, 77,    59, 62, 67, 74, 77, 67, 74, 77,
   60, 64, 67, 72, 76, 67, 72, 76,    60, 64, 67, 72, 76, 67, 72, 76,
   60, 64, 69, 76, 81, 69, 76, 81,    60, 62, 66, 69, 74, 66, 69, 74,
   59, 62, 67, 74, 79, 67, 74, 79,    59, 62, 67, 74, 79, 67, 74, 79,
   59, 60, 64, 69, 72, 64, 69, 72,    57, 60, 64, 69, 72, 64, 69, 72,
   50, 57, 62, 66, 72, 62, 66, 72,    55, 59, 62, 67, 71, 62, 67, 71,
};

Quellcode

Hier abschließend der vollständige Quellcode des Projekts. Um ein lauffähiges Programm daraus zu machen nicht vergessen ihn mit den Timerfunktionen aus dem vorangegangenen Kapitel zu linken.

#include <avr/io.h>
#include <util/delay.h>
#include <stdio.h>

#include <avr/interrupt.h>
#include <avr/pgmspace.h>

#include "log.h"
#include "timer.h"

// Forward Declarations
void setup(void);
void loop(void);

float f_m(uint8_t m);

#define sbi(port, bit) (port) |= (uint8_t)  (1 << (bit))
#define cbi(port, bit) (port) &= (uint8_t) ~(1 << (bit))

const uint8_t song[] PROGMEM = {
  60, 64, 67, 72, 76, 67, 72, 76,    60, 64, 67, 72, 76, 67, 72, 76,
  60, 62, 69, 74, 77, 65, 74, 77,    60, 62, 69, 74, 77, 65, 74, 77,
  59, 62, 67, 74, 77, 67, 74, 77,    59, 62, 67, 74, 77, 67, 74, 77,
  60, 64, 67, 72, 76, 67, 72, 76,    60, 64, 67, 72, 76, 67, 72, 76,
  60, 64, 69, 76, 81, 69, 76, 81,    60, 62, 66, 69, 74, 66, 69, 74,
  59, 62, 67, 74, 79, 67, 74, 79,    59, 62, 67, 74, 79, 67, 74, 79,
  59, 60, 64, 69, 72, 64, 69, 72,    57, 60, 64, 69, 72, 64, 69, 72,
  50, 57, 62, 66, 72, 62, 66, 72,    55, 59, 62, 67, 71, 62, 67, 71,
};

float F_i[] = {
   8.175798,  8.661957,  9.177023,  9.722718, 10.300861, 10.913382,
  11.562325, 12.249857, 12.978271, 13.749999, 14.567617, 15.433853
};

float f_m(uint8_t m) {
  float value = (2 << (m/12)) * F_i[m%12] + 0.5;
  return value;
}

void setup() {
  t1_power(1);  // disable power reduction
  t1_wgm(15);   // wave generation mode 15: fast pwm max=OCR1A

  t1_a_out();   // 
  t1_coma(1);   // output compare mode 1: toggle
}

void loop() {
  unsigned int i;

  for (i=0; i< sizeof(song); ++i) {

    uint8_t m = pgm_read_byte(song + i);  // MIDI number
    float f = f_m(m);                     // frequency
    
    // choose: cs, ocra
    uint32_t ocra = (F_CPU / 2) / (uint32_t) f;
    uint16_t prescaler[] = { 0, 1, 8, 64, 256, 1024 };
    uint8_t cs = 1;
    while (1) {
      if (ocra < 65536 || cs == 5)
	break;
      ++cs;
      ocra /= prescaler[cs];
    }
    ocra += 1;
    
    // set: cs, ocra
    t1_cs(cs);
    t1_ocra((uint16_t)ocra);
    
    _delay_ms(300);
  }
}

#ifndef ARDUINO
int main(void) {
  setup();
  while(1)
    loop();
}
#endif

Ergebnis

Versuchsaufbau
Ergebnis

Fußnoten


Projekt 2

Idee

Versuchsaufbau

Mit einer Kombination aus einem Widerstand und einem Kondensator können wir ein PWM-Signal in ein quasi analoges Signal umwandeln. Eine solche Kombination aus einem Widerstand und einem Kondensator wird auch RC-Glied genannt. In der Konfiguration, in der wir das RC-Glied verwenden, wird es auch Tiefpass genannt.

Mit zwei Timern können wir ein (quasi-)analoges Sinussignal erzeugen.

Für die Erzeugung des Ausgabepegels wird ein PWM-Signal mit einer festen Frequenz verwendet. Das Signal wird dann mit einem Tiefpass gefiltert. Der (quasi-)analoge Ausgabepegel des Signals kann über den Tastgrad festgelegt werden.

Die zeitliche Veränderung der Ausgabewerte wird über einen zweiten Timer gesteuert.

Anstelle einer Berechnung können wir den jeweils gewünschten Ausgabepegel einer Tabelle mit vorberechneten Werten entnehmen.

Konfiguration Timer 0

Für die Ausgabe der gewünschten analogen Pegel wird der Timer TC0 verwendet. Die Ausgabe selbst soll über den Pin OC1A erfolgen. Zu diesem Zweck muss der Timer aktiviert werden und der gewünschte Pin als Ausgabepin konfiguriert werden.

t0_a_out();
t0_power(1);

Das Produkt aus der Freqzenz des Timers und der bit-Tiefe der Samples legt eine obere Grenze für die maximale Samplerate fest, die wir erreichen können. Der Timer soll aus diesem Grund mit der höchstmöglichen Frequenz arbeiten. Als Taktquelle wird daher der CPU Takt mit Prescaler Wert 1 direkt abgegriffen. Die Taktquelle muss hierfür mit dem Wert 1 angegeben werden.

t0_cs(1);

Die bit-Tiefe der Samples, also die Stufen, mit der die Amplitude aufgelöst werden kann, entsprechen dem maximalen Zählerwert. Für 8-bit Auflösung muss der maximalen Wert 0xFF betragen. Der Timer TC0 wird deshalb im Modus 0x3 (Fast PWM Mode mit Maximalwert 0xFF) betrieben. Damit der Pin bei Erreichen des Zählestands OCRA auf HIGH gesetzt wird und bei Erreichen des Zählerstands 0 auf LOW zurückgesetzt wird, muss der Vergleichsmodus 3 gewählt werden.

t0_wgm(3);
t0_coma(3);

Konfiguration Timer 2

Die Aufgabe von Timer 2 ist es, im Takt der gewünschten Samplerate den gewünschten analogen Ausgabewert in das OCRA Register von Timer 0 zu schreiben. Um überhaupt eine Aufgabe übernehmen zu können, muss er zunächst aktiviert werden. Er muss aktiviert werden

t2_power(1);

Um eine Samplereate von 31.25 kHz zu erreichen, kann der Timer im Modus 0x7 (Fast PWM Mode mit Maximalwert OCRA) betrieben werden.

  t2_wgm(7)

Für die Taktquelle und den Maximalwert des Timers kann die Kombination CLK/256 und OCRA=1 gewählt werden. Der Wert, der bei Timer 2 für einen Takt von CLK/256 angegeben werden muss ist 6.

t2_cs(6);
t2_ocra(1);

Bei Erreichen des Maximalen Zählerstands muss ein Interrupt ausgelöst werden, damit der nächste Samplewert in das OCRA Register von Timer 0 geschrieben wird.

t2_interrupt_ocra();

Interrupt Service Routine

Die Interrupt Service Routine soll den nächsten Samplewert aus der Sinus-Tabelle ausgeben.

ISR(TIMER2_COMPA_vect) {
   t0_ocra(sin_table3[i]);
   ++i;
   if (i >= 71)
      i = 0;
}

Sinus-Tabelle

Um die Werte nicht berechnen zu müssen, werden sie in einer Tabelle vorgegeben. Um bei der gewählten Samplerate von 31.25 kHz ein Signal von ungefähr 440 Hz ausgzugeben muss die Tabelle 71 Werte enthalten.

uint8_t sin_table3[] = {
   128, 139, 150, 161, 172, 182, 192, 202, 210, 219,
   226, 233, 239, 244, 248, 251, 253, 255, 255, 254,
   252, 250, 246, 241, 236, 230, 223, 215, 206, 197,
   187, 177, 166, 155, 144, 133, 122, 111, 100,  89,
    78,  68,  58,  49,  40,  32,  25,  19,  14,   9,
     5,   3,   1,   0,   0,   2,   4,   7,  11,  16,
    22,  29,  36,  45,  53,  63,  73,  83,  94, 105,
   116
};

Quellcode

Abschließend der vollständige Quellcode des Projekts. Um ein lauffähiges Programm daraus zu machen nicht vergessen ihn mit den Timerfunktionen zu linken.

#include <avr/io.h>
#include <util/delay.h>
#include <stdio.h>

#include <avr/interrupt.h>
#include <avr/pgmspace.h>

#include "timer.h"

uint8_t wave_table[] = {
  128, 139, 150, 161, 172, 182, 192, 202, 210, 219,
  226, 233, 239, 244, 248, 251, 253, 255, 255, 254,
  252, 250, 246, 241, 236, 230, 223, 215, 206, 197,
  187, 177, 166, 155, 144, 133, 122, 111, 100,  89,
   78,  68,  58,  49,  40,  32,  25,  19,  14,   9,
    5,   3,   1,   0,   0,   2,   4,   7,  11,  16,
   22,  29,  36,  45,  53,  63,  73,  83,  94, 105,
  116
};

// Forward Declarations
void setup(void);
void loop(void);

#define sbi(port, bit) (port) |= (uint8_t)  (1 << (bit))
#define cbi(port, bit) (port) &= (uint8_t) ~(1 << (bit))

void setup() {
  // TIMER 0:

  t0_power(1);  // disable power reduction
  t0_cs(1);     // clock source 1: clk/1
  t0_wgm(3);    // wave generation mode 3: fast pwm max=0xFF
  t0_coma(3);   // output compare A mode 3: B:clear M:set

  t0_a_out();

  // TIMER 2:
  t2_power(1);  // disable power reduction
  t2_cs(2);     // clock source 6: clk/8

  t2_wgm(7);    // wave generation mode 7: fast pwm max=OCR2A
  t2_ocra(63);  // => 31.25 kHz
  t2_interrupt_ocra(); //
  sei();
}


uint8_t i;

ISR(TIMER2_COMPA_vect) {
  t0_ocra(wave_table[i]);
  ++i;
  if (i >= 71)
    i = 0;
}

void loop() {
  // do nothing 
}

#ifndef ARDUINO
int main(void) {
  setup();

  while(1)
    loop();
}
#endif

Ergebnis

Ergebnis
Plot 500µs
Plot 100µs

Fußnoten


Die asynchrone serielle Schnittstelle - UART

Die Abkürzung UART steht für Universal Asynchronous Receiver Transceiver (siehe wikipedia Artikel).

Der Quellcode und die Angaben dieser Seite wurden mit einem AtMega328p Microcontroller auf einem Arduino Board getestet. Alle Angaben zu Seitenzahlen und Sektionsnummern beziehen sich auf das zugehörige Datenblatt [M328p].

Mit Hilfe des UART können Daten zwischen dem Arduino und dem Computer über die serielle Schnittstelle übertragen werden.

Der Arduino verfügt über eine Funktionseinheit USART die in 3 verschiedenen Modi betrieben werden kann. Dem Titel folgend wird sich dieser Artikel ausschließlich auf den Asynchronous USART-Modus beziehen. Beide Bits UMSEL01 und UMSEL00 im UCSR0C Register müssen auf den Wert 0 gesetzt sein, damit der Arduino in diesem Modus arbeitet.[1]

Betriebsart

Die Daten werden mit einer vorgegebenen Geschwindigkeit und in einem vorgegebenen Format übertragen. Sender und Empfänger müssen sich über die Geschwindigkeit und das Format einig sein. Die Geschwindigkeit wird in der Einheit Baud (bit pro Sekunde) angegben. Aus diesem Grund wird die Geschwindigkeit auch als Baudrate bezeichnet.

Baudrate

Die Baudrate kann als Bruchteil von 1/16 bzw. 1/8 der CPU Taktfrequenz eingestellt werden. [2] Der Bruchteil kann mit einem 12-bit Wert angegeben werden. Formeln für die Umrechnung zwischen den 12-bit Werten im UBRR0 (usart bit rate) Register sowie dem U2X0 Flag im UCSR0A (usart control & status) Register und der resultierenden Baudrate können im Handbuch nachgelesen werden:[3]

U2X0 Baudrate UBBR0
0
1

Eine Übersicht, wie genau sich einige typische Baudraten bei gegebener Taktfrequenz erreichen lassen, findet sich ebenfalls im Handbuch. [4] Eine Berechnung für beliebige Konfigurationen ist online z. B. mit dem WormFood's AVR Baud Rate Calculator möglich.

Der folgende Code-Schnipsel kann verwendet werden, um die Werte für das UBRR0 Register und das U2X0 Flag im UCSR0A Register zu berechnen.

uint8_t  u2x0  = 1;
uint32_t ubrr0 = (F_CPU / 4 / baudrate - 1) / 2;

if (ubrr0 > 4095) {
   u2x0 = 0;
   ubrr0 = (F_CPU / 8 / baudrate - 1) / 2;
}

uint8_t ubrr0_h = (uint8_t) (ubrr0 >> 8);
uint8_t ubrr0_l = (uint8_t) (ubrr0 & 0xFF);

Format

Angaben zu unterstützten Formaten finden sich im Datenblatt.[5]

Daten werden als Folge von Zeichen fester Größe übertragen. Die Zeichengröße kann dabei als 5, 6, 7, 8 oder 9 Bit gewählt werden. Die Zeichengröße kann man den Bits UCSZ01 und UCSZ00 im UCSR0C Register und dem UCSZ02 Bit im UCSR0B festlegen.[6]

Jede übertragene Einheit wird mit einem oder zwei Stoppbits abgeschlossen. Die Anzahl der Stoppbits kann mit dem Bit USBS0 im Register UCSR0C vorgegeben werden.[7]

Optional kann jede übertragene Einheit mit einer Prüfsumme Parity versehen werden. Die Einstellungen für die Parität können mit den Bits UPM01 UPM00 im Register UCSR0C vorgenommen werden.[8]

Initialisierung

Bevor Baudrate oder Format geändert werden sollte laut Handbuch sichergestellt werden, dass keine Übertragung mehr im Gange ist.[9] Die Flags TXC0 (tx complete) und RXC0 (rx complete) im UCSR0A Register können zu diesem Zweck überprüft werden.

Damit der Arduino mit der serielle Schnittstelle arbeiten kann, muss das Power Reduction Bit PRUSART0 im PRR Register auf dem Wert 0 stehen.[10]

Sender und Empfänger können mit den Flags RXEN0 und TXEN0 im Register UCSR0B aktiviert bzw. deaktiviert werden.[11]

Arbeitsweise

Rx Block Diagramm

Für den Transfer von Daten von und zur seriellen Schnittstelle steht das Datenregister UDR0 zur Verfügung. Daten, die über die serielle Schnittstelle empfangen wurden, können aus diesem Register ausgelesen werden. Daten, die über die serielle Schnittstelle versendet werden sollen, können in dieses Register geschrieben werden.[12]

Für das Senden und das Empfangen von Daten verwendet der Arduino intern zwei Schieberegister.

Empfangene Datenpakete transferiert der Arduino vom Receive Schieberegister in einen internen Empfangspuffer, der über das Datenregister UDR0 ausgelesen werden kann. Der interne Empfangspuffer kann maximal 2 Zeichen aufnehmen.[13]

Treffen Datenpakete schneller ein, als sie abgeholt werden, so wird das DOR0 (data overrun) Flag im UCSR0A Register gesetzt, sobald bei gefülltem Empfangspuffer und Schieberegister ein neues Startbit empfangen wird.[14]

Zu versendende Datenpakete transferiert der Arduino vom Datenregister in das Transmit Schieberegister. Das Bit UDRE0 (data register empty) im Register UCSR0A gibt Auskunft darüber, ob Daten in das Datenregister geschrieben können, oder ob es noch in Benutzung ist.[15]

Außer internem Empfangspuffer sowie Receive und Transmit-Schieberegistern verfügt der Arduino über keinen Hardware-Puffer für die Kommunikation mit der seriellen Schnittstelle. Ohne Software Lösung können Daten somit nur Zeichen für Zeichen übertragen werden.

I/O ohne Interrupts

Wenn für den Betrieb der seriellen Schnittstelle keine Interrupts verwendet werden, kann es bei dem Versuch Daten zu senden bzw. zu empfangen vorkommen, dass die Hardware noch nicht bereit ist, die gewünschte Operation direkt auszuführen.

Bei dem Versuch, zu sendende Daten zu schreiben, kann es vorkommen, dass das Datenregister noch belegt ist und nicht beschrieben werden kann. Bei dem Versuch empfangende Daten zu lesen, kann es vorkommen, dass noch keine neuen Daten verfügbar sind. In beiden Fällen muss entschieden werden, wie mit der Situation weiter zu verfahren ist.

Zwei typische Ansätze mit dem Problem umzugehen bestehen darin, den Zugriff auf die Hardware blockierend bzw. nicht-blockierend durchzuführen. Bei einer Implementierung, die den Zugriff blockierend durchführt, wird der Programmfluss so lange angehalten, bis die gewünschte Operation ausgeführt werden kann. Bei einer Implementierung, die den Zugriff nicht-blockierend durchführt, wird die gewünschte Operation verworfen und diese Tatsache im zurückgelieferten Statuscode vermerkt.

Lesen

Das Bit RXC0 (rx complete) im UCSR0A Register gibt an, ob über die serielle Schnittstelle eingetroffene Daten verfügbar sind. Wenn Daten zur Verfügung stehen, kann der empfangene Wert im Register UDR0 ausgelesen werden.

Ist ein Format mit Paritätsprüfung im Einsatz, so gibt das Flag UPE0 (parity error) im UCSR0A Register Auskunft darüber ob ein Fehler bei der Paritätsprüfung aufgetreten ist.

blockierend

int serial_blocking_read_byte() {
   loop_until_bit_is_set(UCSR0A, RXC0);
   if (! bit_is_clear(UCSR0A, UPE0))
      return EOF;
   uint8_data = UDR0;
   return data;     
}

nicht-blockierend

int serial_read_byte() {
   if (! bit_is_set(UCSR0A, RXC0))
      return EOF;
   if (! bit_is_clear(UCSR0A, UPE0))
      return EOF;
   uint8_data = UDR0;
   return data;
}

Schreiben

Das Bit UDRE0 (data register empty) im UCSR0A Register gibt an, ob der Datenpuffer frei oder belegt ist. Wenn der Datenpuffer frei ist, kann der gewünschte Wert in das Register UDR0 geschrieben werden.

blockierend

int serial_blocking_write_byte(uint8_t data) {
   loop_until_bit_is_set(UCSR0A, UDRE0);
   UDRO = data;
   return data;
}

nicht-blockierend

int serial_write_byte(uint8_t data) {
   if (! bit_is_set(UCSR0A, UDRE0))
      return EOF;
   UDRO = data;
   return data;
}

Beispiel Projekt

Zum Ausprobieren der bis hier erarbeiteten Operationen kann das folgende Projekt verwendet werden.

Es kann sowohl mit den im Kapiel TODO: link eintragen vorgestellten Werkzeugen als pures C Programm von der Kommandozeile aus übersetzt und in den Arduino geladen werden, als auch mit der Arduino IDE übersetzt werden.

Das Preprozessor Flag ARDUINO, das im #ifdef Wrapper um die main Funktion abgefragt wird, wird von der Arduino IDE beim Compileraufruf gesetzt wodurch sie für die Arduino IDE, die ihre eigene main Funktion in das Binary linkt unsichtbar bleibt.

Die nur bei Übersetzung von der Kommondozeile für den Compiler sichtbare main Funktion empfindet die Arbeitsweise der Arduino IDE wie folgt nach: Nach Start ruft sie einmalig die Funktion setup() und hiernach periodisch die loop() Funktion auf.

/* ANTI-COPYRIGHT NOTICE
 * ---------------------
 * This work is free. You can redistribute it and/or modify it under the
 * terms of the Do What The Fuck You Want To Public License, Version 2,
 * as published by Sam Hocevar. See http://www.wtfpl.net/ for more details.
*/

#include <avr/io.h>
#include <util/delay.h>
#include <stdio.h>

// Forward Declarations
void serial_init(unsigned long baudrate, uint8_t frame_format);

int serial_read_byte(void);
int serial_blocking_read_byte(void);

int serial_write_byte(uint8_t data);
int serial_blocking_write_byte(uint8_t data);

void setup(void);
void loop(void);

/*
UCSR0C         mode            parity       stop       size*         polarity
    +-------------------+---------------+-------+-----------------+---------+
    | UMSEL01 : UMSEL00 | UPM01 : UPM00 | USBS0 | UCSZ01 : UCSZ00 |  UPOL0  |
    +-------------------+---------------+-------+-----------------+---------+
  UPM0[1:0]   none(00) even(10) odd(01)
  USBS0       1bit(0)   2bit(1)
  UCSZ0[2:0]  5bit(000) 6bit(001) 7bit(010) 8bit(011) 9bit(111)
*/

//  size 8 / parity none / 1 stop bit
#define SERIAL_FORMAT_8N1 0b00000110
#define BAUDRATE 57600L

#define BLINK_DELAY_MS 1000

#define sbi(port, bit) (port) |= (uint8_t)  (1 << (bit))
#define cbi(port, bit) (port) &= (uint8_t) ~(1 << (bit))

void serial_init(uint32_t baudrate, uint8_t frame_format){
  // calculate baud rate settings
  uint8_t  u2x0 = 1;
  uint32_t ubbr0 = (F_CPU / 4 / baudrate - 1) / 2;

  if (ubbr0 > 4095) {
    u2x0 = 0;
    ubbr0 = (F_CPU / 8 / baudrate -1) / 2;
  }

  // set baud rate
  UBRR0H = (uint8_t) (ubbr0 >> 8);
  UBRR0L = (uint8_t) (ubbr0 & 0xFF);
  if (u2x0) sbi(UCSR0A, U2X0);
  else      cbi(UCSR0A, U2X0);
    
  // enable receive / transmit
  UCSR0B |= _BV(RXEN0) | _BV(TXEN0);
           
  // set frame format
  UCSR0C = frame_format;
}

// READ
int serial_read_byte() {
  if (! bit_is_set(UCSR0A, RXC0))
    return EOF;
  uint8_t data = UDR0;
  return data;
}

int serial_blocking_read_byte() {
  loop_until_bit_is_set(UCSR0A, RXC0);
  uint8_t data = UDR0;
  return data;
}

// WRITE
int serial_write_byte(uint8_t data) {
  if (! bit_is_set(UCSR0A, UDRE0))
    return EOF;
  UDR0 = data;
  return data;
}

int serial_blocking_write_byte(uint8_t data) {
  loop_until_bit_is_set(UCSR0A, UDRE0);
  UDR0 = data;
  return data;
}

void setup() {
  sbi(DDRB, DDB5);                // LED: PORTB5 out 
  serial_init(BAUDRATE, SERIAL_FORMAT_8N1);
}

void loop() {
  int c;
    
  c = serial_blocking_read_byte();
  //c = serial_read_byte();
    
  if (c != EOF)
    serial_blocking_write_byte((uint8_t) c);

  sbi(PINB, PINB5);            // toggle LED
  _delay_ms(BLINK_DELAY_MS);   // delay  
}

#ifndef ARDUINO
int main(void) {
  setup();
  while(1)
    loop();
}
#endif

In Betrieb nehmen

gtkterm Einstellungen
gtktern Port Settings

Boards wie das des Arduino UNO verfügen bereits über einen fest auf der Platine verdrahteten RS232-Konverter. In diesem Fall ist für die Verbindung zum PC lediglich nötig, beide Geräte mit einem USB-Kabel zu verbinden.

Andernfalls kann ein separater USB-RS232-Konverter verwendet werden. Für das Experiment habe ich einen Konverterkabel mit FT232 Serial (UART) IC verwendet, es sollte aber auch mit jedem anderen Konverter gelingen. Des weiteren wird eine Leuchtdiode an PORTB5 benötigt, mit deren Hilfe der Fortschritt des Programms beobachtet werden kann. Bei Verwendung eines Arduino Boards sollte dort bereits eine LED verdrahtet sein.

Das Beispiel stellt ein einfaches echo-Programm zur Verfügung. Über die serielle Schnittstelle eintreffende Daten werden mit serial_blocking_read_byte() entgegengenommen und mit serial_blocking_write() wieder über die serielle Schnittstelle ausgegeben.

Der Port B5, an dem die Leuchtdiode verdrahtet ist, wird durch setzen des Bits DDB5 im DDRB (data direction) Register mit sbi(DDRB, DDB5) als Ausgabeport konfiguriert. Setzen des PINB5 Bits im Register PINB mit sbi(PINB, PINB5) sorgt dafür, dass der Schaltzustand von Port B5 getoggelt wird und die Leuchtdiode somit ihren Zuständen von an auf aus, bzw. von aus auf an wechselt.

Für das Senden und Empfangen von Daten über die serielle Schnittstelle am PC habe ich das Programm gtkterm verwendet. Das Beispielprogramm verwendet eine Baudrate von 57600 und ein Format mit 8-bit Zeichenlänge, ohne Parität mit einem Stoppbit. Bevor die Kommunikation mit dem Arduino stattfinden kann, müssen Geschwindigkeit und Format der seriellen Schnittstelle des PC an diese Werte angepasst werden.

Nutzer, die die Arduino-IDE verwenden, können alternativ auch auf den mitgelieferten seriellen Monitor zurückgreifen. Er findet sich unter Werkzeuge->Serieller Monitor. Bei Benutzung des seriellen Monitors der Arduino-IDE ist zu beachten, dass eingegebene Zeichen erst nach Betätigung des Buttons send am Stück verschickt werden und nicht direkt nach der Eingabe.

Auf der Tastatur eingegebene Zeichen sollten nun über die serielle Schnittstelle an den Arduino verschickt und von dort wieder zurück geschickt werden, um schließlich im Terminal zu erscheinen. Nach jedem eingegebenen Zeichen sollte der Schaltzustand der Leuchtdiode von an aus aus, bzw. von aus auf an wechseln.

Beobachtungen

typische Ausgabe

Beobachtungen, die mit dem Programm gemacht werden können:

  1. Der Schaltzustand der Leuchtdiode wechselt nur nach Empfang eines Zeichens. (blockierendes Lesen wartet auf Empfang des nächsten Zeichens - wenn es sein muss für immer!)
  2. Bei schneller Eingabe werden einige Zeichen 'verschluckt' und nicht ausgegeben.

Durch Auskommentieren von Zeile 105 und Entfernen der Kommentarzeichen am Anfang von Zeile 106 kann die Wirkung der nicht-blockierenden Variante Empfangsfunktion ausprobiert werden.

Beobachtungen, die mit dieser Version gemacht werden können:

  1. Der Schaltzustand der Leuchtdiode wechselt nun periodisch unabhängig vom Empfang von Zeichen. (nicht-blockierendes Lesen wartet nicht auf Empfang des nächsten Zeichens)
  2. Schnell eingegebene Zeichen werden noch immer 'verschluckt' und nicht ausgegeben.

Phänomen zwei resultiert aus der begrenzten Größe des Empfangspuffers und tritt in beiden Varianten auf. Eine typische Ausgabe zeigt nebenstehender Screenshot, aufgenommen nach zweimaligem schnellen Streifen über die Ziffernreihe der Tastatur. [16]

Gut sichtbar ist die Wirkung des internen Empfangspuffers. Jeweils die ersten zwei Zeichen 1 und 2 hat der Arduino direkt nach Empfang vom Schieberegister in den Empfangspuffer verschoben. Zeichen die danach versandt wurden landeten zunächst im Schieberegister, wurden dort aber jeweils durch das nächste eintreffende Zeichen ersetzt. Das jeweils zuletzt versandte Zeichen: im ersten Durchgang die 5, im zweiten Durchgang die 0 steckte noch im Schieberegister. [17]

Ohne Nutzung von Interrupts kann eine Verbesserung nur erreicht werden, wenn häufiger gepollt, d.h. versucht wird eventuell bereits eingetroffene Daten abzufragen. In diesem Fall steigt aber auch die Rechenzeit, die darauf verwendet wird zu prüfen, ob eventuell bereits neue Daten anstehen. Selbst wenn gar keine neuen Daten eingetroffen sind.

Ein alternativer Ansatz besteht darin, die Daten genau dann in Empfang zu nehmen, wenn sie auch tatsächlich beim Arduino eintreffen. Dies ist durch die Nutzung von Interrupts möglich, um die es im nächsten Abschnitt gehen wird.

I/O mit Interrupts

Mit Interrupts ist es möglich direkt auf das Eintreffen von empfangenen Daten zu reagieren und zu versendende Daten auch ohne aktives Warten genau dann zu schreiben, wenn das Datenregister bereit ist. Für die Serielle Schnittstelle stehen drei Interrupts zur Verfügung, die einzeln aktiviert und deaktiviert werden können.

Das Bit RXCIE0 (rx complete interrupt enable) im USCR0B Register legt fest, ob beim Empfang neuer Daten ein Interrupt ausgelöst wird. Das Bit TXCIE0 (tx complete interrupt enable) im USCR0B Register legt fest, ob nach dem Datenversand ein Interrupt ausgelöst wird. Das Bit UDRIE0 (data register empty interrupt enable) im UCSR0B Register legt fest, ob ein Interrupt ausgelöst wird, wenn der Datenpuffer bereit für die Aufnahme neuer Daten ist.

Wie bei allen Interrupts muss auch hier zusätzlich zu den gesetzten Bits die Zustellung von Interrupts global aktiviert werden. Zu diesem Zweck kann die Funktion sei() verwendet werden.

Die Interrupt Routinen können mit dem ISR() Makro im Header avr/interrupt.h definiert werden. Die Indices für die genannten Interrupts können mit den Makros USART_RX_vect, USART_RX_vect und USART_UDRE_vect angegeben werden.

Lesen

Um mit einem Interrupt darüber informiert zu werden, dass an der seriellen Schnittstelle neue Daten eingetroffen sind, muss das Bit RXCIE0 im USCR0B Register gesetzt sein. Zudem muss die Zustellung von Interrupts global aktiviert werden. Dies kann mit dem folgenden Code Schnipsel umgesetzt werden.

  sbi(UCSR0B, RXCIE0);    // rx complete interrupt enable
  sei();                  // enable interrupts

Für die zugehörige Interrupt Service Routine kann folgendes Skelett verwendet werden.

ISR(USART_RX_vect) {
   // RX COMPLETE: do something here !
}

Für die Implementierung des Rumpfs stehen zwei Ansätze zur Verfügung.

Zum einen können die eintreffende Daten direkt verarbeitet werden, zum anderen können können sie zunächst nur zwischengespeichert werden um sie zu einem späteren Zeitpunkt zu verarbeiten. Im ersten Fall spricht man auch davon, dass die Daten ungepuffert verarbeitet werden, im zweiten Fall, davon dass sie gepuffert' verarbeitet werden.

Ungepuffert

Das echo Programm aus dem vorangegangenen Abschnitt liefert ein Beispiel für ein Programm, das ohne Pufferung auskommt.

Empfang und Versand von Daten sollen nun von der Interrupt Routine übernommen werden. Sämtliche Zugriffe auf die Serielle Schnittstelle können deshalb aus der Funktion loop() entfernt werden.

void loop() {
  sbi(PINB, PINB5);            // toggle LED
  _delay_ms(BLINK_DELAY_MS);   // delay  
}

Um über das Eintreffen neuer Daten mit einem Interrupt informiert zu werden, müssen das Bit RXCIE0 im USCR0B Register gesetzt und die Zustellung von Interrupts global aktiviert werden. Eine gute Stelle das zu tun ist die setup() Funktion, die wie folgt erweitert werden kann:

void setup() {
  sbi(DDRB, DDB5);              // LED: PORTB5 out 
  serial_init(BAUDRATE, SERIAL_FORMAT_8N1);

  sbi(UCSR0B, RXCIE0);         // rx complete interrupt enable
  sei();                       // enable interrupts
}

Nächster Schritt ist die Erstellung der zugehörigen Interrupt Service Routine. Bei ihrer Implementierung kann auf die bereits erstellten Funktionen zurückgegriffen werden.

ISR(USART_RX_vect) {  
  int c = serial_read_byte();
  serial_blocking_write_byte((uint8_t) c);
}

Es fehlt ein letztes include des Headers avr/interrupt.h und schon sollte das neue echo Programm fertig programmiert sein.

#include <avr/interrupt.h>

Datenpufferung

Variablen, auf die sowohl vom Hauptprogramm als auch von einer Interrupt Service Routine zugegriffen werden, müssen vor konkurrierendem Zugriff geschützt werden. Im allgemeinen Fall genügt es dafür nicht, die betreffenden Variablen volatile zu deklarieren.

Stattdessen muss im Hauptprogramm für die Zeit des Zugriffs die Ausführung von Interrupt-Routinen global deaktiviert werden und dafür Sorge getragen werden, dass der Compiler den betreffenden Zugriff im Zuge der Optimierung beim Code Re-ordering nicht aus dem gewünschten Bereich verschiebt. Zu diesem Zweck können die Makros des Headers util/atomic.h der avr-libc verwendet werden.

Nur im Spezialfall, dass schreibende Zugriffe auf die Variable entweder ausschließlich im Hauptprogramm oder ausschließlich in Interrupt-Routinen stattfinden und es sich um Variablen der Länge 8-bit handelt, genügt es, die betreffende Variable volatile zu deklarieren, um sie vor konkurrierendem Zugriff zu schützen.

Warteschlange

Für die Pufferung von Daten kann eine Warteschlange - je nach persönlicher Vorliebe manchmal auch als Queue oder FIFO bezeichnet verwendet werden. Zugriff auf die von ihr verwalteten Daten stellt sie über die Operationen push_tail() und pop_head() zur Verfügung, mit denen Werte am Ende der Schlange eingfügt, bzw. am Kopf der Schlange entnommen werden können.

Datenstruktur

Für den Puffer selbst wird ein Array fester Länge, dessen Elemente vom Typ char sind verwendet. Die Speicherposition des Arrays soll im Folgenden in der Variable buffer festgehalten sein. Buchführung darüber, an welcher Stelle des Arrays sich Anfang (head) und Ende (tail) der Schlange befinden erfolgt mit Hilfe zweier weiterer Variablen.

Die Variable head wird dabei immer den Index des Array Elements am Anfang der Schlange angeben. Die Variable tail hingegen soll stets den Index der nächsten freien Speicherposition am Ende der Schlange angeben.

Einen Sonderfall stellt eine leere Schlange dar. Für sie sollen die Werte von head und tail identisch sein. Dies ist zugleich der Zustand, auf den die Schlange zu Beginn initialisiert wird.

Operationen

Neue Daten können mit push_tail() in die Schlange eingereiht werden, indem ihr Wert an die durch tail angegebene Position des Puffers geschrieben wird und der Wert von tail um 1 erhöht wird. Erreicht der Wert von tail einen Wert, der einem Index außerhalb des Puffers entspricht, so wird tail auf 0 zurückgesetzt, um die Speicherung von Daten am Anfang des Puffers fortzusetzen.

Bevor auf die beschriebene Art und Weise neue Daten in die Schlange eingereiht werden, muss allerdings zunächst geprüft werden, ob der Puffer groß genug ist, um weitere Daten aufzunehmen. Das ist nur dann der Fall, wenn der berechnete neue Wert für tail nicht identisch zu head ist.

Analog können mit pop_head() in der Schlange aufgereihte Daten vom Kopf der Schlange entnommen werden, indem sie von der durch head angegebenen Position des Puffers gelesen und der Wert von head anschließend um 1 erhöht und gegebenenfalls auf 0 zurückgesetzt wird.

Auch hier ist zunächst zu prüfen, ob die gewünschte Operation überhaupt zulässig ist, d.h. ob die Schlange überhaupt Daten enthält. Eine leere Schlange liegt nur im oben genannten Sonderfall, dass head und tail identisch sind, vor.

Code Beispiel

Eine Möglichkeit die Warteschlange wie beschrieben zu implementieren findet sich im folgenden Code Beispiel.

Achtung! Direkt verwendet werden kann der Code Schnipsel so noch nicht! Der Zugriff auf die Schlange ist nicht vor konkurrierendem Zugriff aus Hauptprogramm und Interrupt Routinen geschützt!

#define BUFFER_SIZE 64
#define NIL         -1
   
char buffer[BUFFER_SIZE];
uint8_t head = 0;
uint8_t tail = 0;

int push_tail(char value) {
   uint8_t next_tail = (tail + 1) % BUFFER_SIZE;
   if (next_tail == head)
      return NIL;
   buffer[tail] = value;
   tail = next_tail;
   return 0;
}

int pop_head() {
   if (head == tail)
      return NIL;
   uint8_t data = buffer[head];
   head = (head + 1) % BUFFER_SIZE;
   return data;
}

Implementierung

Um mit einem Interrupt darüber informiert zu werden, dass an der seriellen Schnittstelle neue Daten eingetroffen sind, muss das Bit RXCIE0 im USCR0B Register gesetzt sein. Zudem muss die Zustellung von Interrupts global aktiviert werden. Dies kann mit dem folgenden Code Schnipsel umgesetzt werden.

  // enable rx interrupt
  sbi(UCSR0B, RXCIE0);
  // global interrupts enable
  sei();

Die eintreffenden Daten sollen in einer Warteschlange gesammelt werden. Um konkurierenden Zugriff zu vermeiden werden sowohl head als auch tail volatile deklariert. Zudem muss darauf geachtet werden, dass jeweils nur Hauptprogramm, bzw. Interrupt Routinen schreibend auf eine der Variablen zugreift.

Das Hinzufügen neuer Daten am Ende der Schlange wird ausschließlich in der Interrupt Routine stattfinden. Hierfür muss nur auf die Variable tail schreibend zugegriffen werden.

Des Entfernen von Daten am Kopf der Schlange hingegen wird ausschließlich im Hauptprogramm stattfinden. Hier muss die Variable tail nur gelesen werden. Schreibend wird ausschließlich auf die Variable head zugegriffen.

// BUFFERED READ
#define RX_BUFFER_SIZE 64

static uint8_t rx_buffer[RX_BUFFER_SIZE];
static volatile uint8_t rx_head = 0;
static volatile uint8_t rx_tail = 0;

int serial_rx_get() {
  if (rx_head == rx_tail)
    return EOF;
  uint8_t c = rx_buffer[rx_head];
  rx_head = (uint8_t) (rx_head + 1) % RX_BUFFER_SIZE;
  
  return c;
}

ISR(USART_RX_vect) {
  
  if (bit_is_set(UCSR0A, UPE0)) {
    UDR0;
    return;
  }

  uint8_t next_tail = (uint8_t) (rx_tail + 1) % RX_BUFFER_SIZE;
  uint8_t c = UDR0;

  if (next_tail == rx_head)
    return;

  rx_buffer[rx_tail] = c;
  rx_tail = next_tail;
}

Register Übersicht

Register bit 7 bit 6 bit 5 bit 4 bit 3 bit 2 bit 1 bit 0
Baud Rate Registers
UBRR0L baud rate low
UBBR0_7 UBRR0_6 UBBR0_5 UBBR0_4 UBBR0_3 UBBR0_2 UBBR0_1 UBBR0_0
0 0 0 0 0 0 0 0
UBRR0H reserved baud rate high
x x x x UBBR0_11 UBBR0_10 UBBR0_9 UBBR0_8
0 0 0 0
Control and Status Registers
UCSR0A rx complete tx complete data register empty frame error data overrun parity error double speed mpc mode
RXC0 TXC0 UDRE0 FE0 DOR0 UPE0 U2X0 MPCM0
0 0 1 0 0 0 0 0
UCSR0B rx complete interrupt tx complete interrupt data register empty interrupt rx enable tx enable size* rx 9-th bit tx 9-th bit
RXCIE0 TXCIE0 UDRIE0 RXEN0 TXEN0 UCSZ02 RXB80 TXB80
0 0 0 0 0 0 0 0
UCSR0C mode parity stop bits size* polarity
UMSEL01 UMSEL00 UPM01 UPM00 USBS0 UCSZ01 UCSZ00 UPOL0
0 0 0 0 0 1 1 0
Data Register
UDR0 tx / rx data
UDR0_7 UDR0_6 UDR0_5 UDR0_4 UDR0_3 UDR0_2 UDR0_1 UDR0_0
0 0 0 0 0 0 0 0

Fußnoten

  1. (siehe [M328p]: Table 24-8 USART Mode Selection S. 249)
  2. Das Bit U2X0 des UCSR0A Registers gibt Auskunft darüber mit welchem Bruchteil gerechnet werden soll. Ist es gesetzt wird 1/8 genommen ansonsten 1/16
  3. (siehe [M328p]: Table 24-1. Equations for Calculating Baud Rate Register Setting, S. 227)
  4. (siehe [M328p]: 24.11. Examples of Baud Rate Setting, S. 240)
  5. (siehe [M328p]: Sektion 24.5. Frame Formats)
  6. (siehe [M328p]: Table 24-11. Character Size Settings, S. 250)
  7. (siehe [M328p]: Table 24-10. Stop Bit Settings, S. 250)
  8. (siehe [M328p]: Table 24-9. USART Mode Selection, S. 249) - Bei dem Titel USART Mode Selection scheint es sich um einen Druckfehler zu handeln. Bereits Table Table 24-8. trägt diese Bezeichnung. Der korrekte Titel der Tabelle wäre wohl Parity Mode Selection
  9. (siehe [M328p]: 24.6. USART Initialization, S. 230)
  10. (siehe [M328p]: 14.12.3. Power Reduction Register, S. 71)
  11. (siehe [M328p]: 24.8. Data Reception – The USART Receiver, S. 233 / 24.7. Data Transmission – The USART Transmitter, S. 231)
  12. Wird ein 9-bit Format verwendet, so werden die Bits TXB80 und RXB80 im Register UCSR0B zur Angabe des höchstwertigen Bits von empfangenen bzw. von zu versendenden Daten verwendet. Es ist in diesem Fall laut Handbuch wichtig die höchstwertigen Bits des Datenpakets (TXB80, bzw. RXB80 im Register UCSR0B) vor dem Inhalt des Datenregisters (UDR0) zu verarbeiten.
  13. siehe [M328p]: 24.8.4. Receiver Error Flags, S. 235, Absatz 3 ...occurs when the receive buffer is full (two characters)... das ist meines Wissens die einzige Stelle, an der die Größe des Empfangspuffers zumindest indirekt angegeben ist
  14. (siehe [M328p]: 24.8.4. Receiver Error Flags, S. 235)
  15. (siehe [M328p]: 24.7.3. Transmitter Flags and Interrupts)
  16. Bei Verwendung des Seriellen Monitors der Arduino IDE lässt sich der beschriebene Effekt noch einfacher beobachten. Hier werden eingegebene Zeichen erst nach Betätigen des send Buttons versandt. Man braucht also keine ganz so schnellen Finger.
  17. Je nachdem ob die blockierende Variante oder die nicht-blockierende Variante gewählt wird und je nach Glücklichkeit des Timings können statt der ersten zwei hier auch die ersten drei eingetippten Zeichen und das zuletzt getippte Zeichen sichtbar sein.


Two Wire Interface

Worum geht's?

Über den TWI Bus können bis zu 120 externe Geräte mit dem dem Microcontroller verbunden werden.[1] Da der TWI Bus nur aus zwei Leitungen besteht, werden dabei nur 2 Pins des Microcontrollers benötigt.

Einige Microcontroller der AVR Familie verfügen über eine Funktionseinheit, die es ermöglicht, den Microcontroller über zwei für diesen Zweck vorgesehene Pins am TWI Bus zu betreiben. Die Geschwindigkeitsanforderungen an den TWI Bus sind so gering, dass auch ein Microcontroller ohne TWI Funktionseinheit mit einer Software Lösung am TWI Bus betreiben werden kann. Um diese Möglichkeit wird es allerdings erst im folgenden Kapitel gehen.

In diesem Kapitel werden wir ausschließlich mit der integrierten TWI Funktionseinheit arbeiten. Kenntnis der in diesem Kapitel vorgestellten Konzepte und Begriffe, sowie ein grundlegendes Verständnis der prinzipiellen Arbeitsweise des TWI Bus sind über dieses Kapitel hinaus auch für die Implementierung einer Software Lösung wichtig.

Auch wenn Dein Microcontroller über keine integrierte Funktionseinheit verfügt, Du aber an der Software Lösung des folgenden Kapitels interessiert bist, solltest Du Dich zumindest grob mit dem Inhalt dieses Kapitels vertraut machen.

Folgende Dinge kannst Du in diesem Kapitel lernen:

  • für die TWI Funktionseinheit vorgesehene Pins lokalisieren
  • externe Geräte über den TWI Bus mit dem Microcontroller verbinden
  • wichtige Begriffe und Konzepte des TWI Bus
  • Schritte und Ablauf des am TWI Bus eingesetzten Protokolls
  • den Microcontroller als TWI Bus Master konfigurieren
  • im synchronen Master Betrieb Daten zwischen Microcontroller externen Geräten übertragen

Grundlagen

Der TWI Bus (Two Wire Bus) läuft auch unter dem Namen I²C Bus.

Um einen AVR Microcontroller mit integrierter TWI Funktionseinheit am TWI Bus zu betreiben, wird keine umfangreiche Kenntnis der genauen elektrischen Eigenschaften des TWI Bus benötigt. Mit den elektrischen Eigenschaften werden wir uns daher erst im nächsten Kapitel beschäftigen und eine genaue Beschreibung nachliefern.

In diesem Kapitel werden wir den TWI Bus zunächst als eine Black-Box betrachten, mit deren Hilfe der Microcontroller mit anderen, für einen Betrieb am TWI Bus vorgesehenen Geräten Daten austauschen kann.

Leitungen und Anschluss

Beispiel: Beschriftung SDA/SCL (sainsmart LCD2004)

Der TWI Bus ist ein serieller Bus. Daten, die über den TWI Bus transportiert werden, werden sequentiell ein Bit nach dem anderen übertragen. Für die Übertragung werden nur zwei Leitungen benötigt.

Eine der beiden Leitungen dient der Übertragung der einzelnen Datenbits. Sie wird daher auch als Datenleitung oder kurz SDA (Serial DAta) bezeichnet. Die zweite Leitung dient der Synchronisation. Über sie wird geregelt, welches Gerät zu welchem Zeitpunkt Daten auf den Datenleitung legen, bzw. sie von dort entnehmen darf. Die Synchronisation erfolgt mit einem Taktsignal. Die zweite Leitung wird daher auch als Taktleitung oder kurz SCL (Serial CLock) bezeichnet.

Damit die Kommunikation über den TWI Bus funktioniert, müssen beim Anschließen eines Geräts die richtigen Leitungen miteinander verbunden werden. Der SDA Pin muss immer an die SDA Leitung und der SCL Pin immer an die SCL Leitung angeschlossen werden. Beim Anschluss an den TWI Bus findet kein Überkreuzen der Leitungen, wie Du es vielleicht von RX und TX bei der seriellen Schnittstelle kennst, statt.

Das Datenblatt des Geräts, dass Du anschließen möchtest, sollte Auskunft darüber geben, an welcher Stelle SDA Leitung und SCL Leitung angeschlossen werden müssen. In vielen Fällen findet sich zusätzlich eine entsprechende Beschriftung am Gerät selbst.

Slave und Slave Adresse

Ein Gerät, das über den TWI Bus angesprochen werden kann, wird auch Slave genannt.

Jeder Slave verfügt über eine 7-Bit lange Adresse, mit der er identifiziert werden kann. Diese Adresse wird auch Slave-Adresse des Geräts genannt.

Die Slave-Adresse eines Geräts kann, mit Ausnahme der folgenden reservierten Adressen, frei vergeben werden.

  • Die Adresse 0b0000000 ist als General Call Adresse reserviert.
  • Alle Adressen vom Format 0b1111xxx sind für zukünftige Verwendung reserviert.

Bei vielen Geräten sind einige Bits der Slave-Adresse bereits fest vorgegeben und die restlichen Bits können durch die Beschaltung konfiguriert werden. Das zugehörige Datenblatt des jeweiligen Geräts sollte dabei Auskunft darüber geben, welcher Teil der Adresse fest vorgegeben, und welcher Teil konfigurierbar ist.

Master

Bevor ein Datenaustausch über den TWI Bus stattfinden kann, muss zunächst ein Gerät die Kontrolle über den Bus übernehmen. Ein Gerät, das aktuell die Kontrolle über den Bus hat, wird Master genannt.

Der Datenaustausch auf dem TWI Bus findet immer zwischen einem Master und einem oder mehreren Slaves statt.

Bei der Kommunikation mit einem Slave kann der Master sowohl als Sender, als auch als Empfänger agieren. Bei einer Kommunikation mit mehreren Slaves können nur Daten vom Master zu den Slaves transportiert werden.

Datenpakete

Der Datenaustausch auf dem TWI Bus findet in Einheiten von 9-bit statt. Die einzelnen Bits eines Datenpakets werden im Takt der SCL Leitung sequentiell auf die SDA Leitung gelegt. In den ersten 8 Bit legt der Sender die zu übertragenden Daten auf den Bus. Das letzte Bit wird vom Empfänger gesetzt. Die beiden möglichen Zustände dieses Bits werden auch als ACK und NACK bezeichnet.

Protokoll

Start und Stop Condition

Zu ein und demselben Zeitpunkt kann maximal ein Gerät auf dem TWI Bus Master sein. Ein TWI Bus auf dem gerade ein Gerät Master ist wird auch als busy bezeichnet.

Ist der TWI Bus nicht busy, so kann sich ein Gerät selbst zum Master machen, indem es ein spezielles Signal versendet. Dieses Signal wird auch START condition genannt. Sendet ein Gerät, das bereits Master ist, erneut das spezielle Start Signal, so wird dieses Signal auch RESTART condition genannt.

Um die Kontrolle über den TWI Bus wieder abzugeben, kann der Master ein spezielles Signal versenden. Dieses Signal wird auch STOP condition genannt.

Adressierung

Um mitzuteilen mit welchem Gerät er kommunizieren möchte, und ob er als Sender oder Empfänger fungieren möchte, muss der Master nach einer START bzw. einer RESTART condition ein Adress-Byte über den Bus versenden.

Die hochwertigen 7-bit des Adress-Bytes geben die Slave-Adresse des Geräts an, mit dem kommuniziert werden soll. Das niederwertige Bit gibt an, ob der Master lesen (1) oder schreiben (0) möchte. Abhängig von diesem Bit wird das Adress-Byte auch als SLA+W bzw. SLA+R Byte bezeichnet.

Befindet sich ein Gerät mit der gewünschten Adresse am TWI Bus und ist es aktuell in der Lage mit dem Master zu kommunizieren, so setzt es das letzte Bit des Adress-Datenpakets auf den Wert ACK. Ist kein Gerät mit der gewünschten Adresse vorhanden, oder ist das adressierte Gerät aktuell nicht in der Lage mit dem Master zu kommunizieren, so bleibt das letzte Bit auf dem Wert NACK.

Ein Adress-Byte kann nur direkt nach dem Senden einer START condition oder einer RESTART condition versendet werden.

Datenaustausch

Nachdem die Kommunikationspartner und ihre Rollen als Sender und Empfänger festgelegt sind, kann mit der Übertragung der Daten begonnen werden. Der Sender verschickt die Daten ein Byte nach dem anderen. Der Empfänger teilt im letzten im letzten Bit der Datenpakete durch Setzen von ACK mit, dass er am Empfang weiterer Daten interessiert ist. Ist er nicht am Empfang weiterer Daten interessiert, so setzt er das letzte Bit auf NACK.

AVR Microcontroller

Der AVR Microcontroller kann am TWI Bus sowohl als Master als auch als Slave betrieben werden.

Die Lage der vorgesehenen Pins kann im Datenblatt nachgesehen werden. Für einen AtMega328p kann die Zuordnung der Pins der folgenden Tabelle entnommen werden.

TWI Pin alias
SDA PC4
SCL PC5

Damit sie für die Ansteuerung des TWI Bus verwendet werden können, müssen beide Pins als Input Pins konfiguriert werden und die internem Pull-up Widerstände beider Pins aktiviert werden.

Initialisierung

Damit er am TWI Bus arbeiten kann muss das Power Reduction Bit PRTWI0 im PRR Register auf dem Wert 0 stehen. Nach einem Wechsel des PRTWI0 Bits von 1 auf 0 sollte die TWI Schnittstelle des Arduino laut Handbuch reinitialisiert werden. [2]

Master

Wenn der Controller als Master arbeitet, gibt er die Taktfrequenz der SCL Leitung vor. Die Frequenz des SCL Takts kann als Bruchteil der CPU Taktfrequenz eingestellt werden.

Der Bruchteil kann mit einem 8-bit Wert im TWBRR Register und einem 2-bit Wert mit den Bits TWPS1 und TWPS0 im Register TWSR Register angegeben werden. Die Formel zur Berechnung kann im Handbuch nachgesehen werden. [3]

Achtung! Beim Setzen des Prescalers (Bits TWPS1, TWPS1 im Register TWSR) ist darauf zu achten die restlichen Bits im TWSR Register unverändert zu lassen! In ihnen ist der Status codiert.

Wird er versehentlich verändert bringt dies die TWI Hardware durcheinander.

Slave

Wenn der AVR Controller als Slave betrieben werden soll, so muss ihm eine Geräteaddresse zugeteilt werden. Die Bits TWAR6 ... TWAR0 im Register TWAR geben die Slave-Adresse an.

Wenn der Controller auch auf die General Call Adresse (0x00) reagieren soll, dann muss das TWGCE Bit im Register TWAR auf 1 gesetzt werden. Wenn er nicht auf sie reagieren soll, muss sie auf 0 gesetzt werden.

Arbeitsweise

Die TWI Funktionseinheit modeliert die Kommunikation über den TWI Bus mit einem Zustandsdiagramm.

Zustände

Jeder Knoten repräsentiert einen Zustand, in dem sich TWI Funktionseinheit und TWI Bus befinden. Der Zustand, in dem sich die TWI Funktionseinheit befindet, kann im TWSR (TWI Status Register) Register ausgelesen werden.

Achtung! Beim Auslesen des TWSR (Two Wire Status Register) Registers nicht vergessen die Prescaler-Bits auszumaskieren.

Die Header Datei <util/twi.h> der AVR Libc enthält ein Makro TW_STATUS, das diese Aufgabe übernimmt. Es ist wie folgt definiert.

#define TW_STATUS_MASK          (_BV(TWS7)|_BV(TWS6)|_BV(TWS5)|_BV(TWS4)|_BV(TWS3))

#define TW_STATUS               (TWSR & TW_STATUS_MASK)
In der Regel dürfte es bequemer und wenig er fehleranfällig sein dieses Makro zu verwenden, als das Ausmaskieren bei jedem Zugriff manuell vorzunehmen.

Übergänge

Übergänge können mit dem TWCR (TWI Control Register) ausgelöst werden. Das Flag TWINT (TWI Interrupt Flag) im TWCR (TWI Control Register) Register wird nach jedem Ereignis auf dem TWI Bus auf den Wert 1 gesetzt.

Ein Blick auf das vollständige Diagramm kann unübersichtlich wirken. Bevor wir einen Blick auf das vollständige Diagramm werfen, werden wir uns daher zunächst eine Reihe einfacher Fälle ansehen.

Single Master System

Der häufigste Fall.

Master

Der häufigste Fall. Der Microcontroller wird für die Ansteuerung eines oder mehrerer Slaves verwendet. Dabei kann es sich z.B. um ein LCD Display oder einen EEPROM Speicher handeln.

Slave

In einem Single Master System gibt es drei Einstiegspunkte

  • read
  • write (Slave Adresse)
  • write (gcall Adresse)

Multi Master System

TODO:

Interruptbetrieb

Die TWI Funktionseinheit kann sowohl synchron mit dem Programmfluss, als auch mit Interrupt betrieben werden. Um den Interrupt Betrieb zu aktivieren muss das Bit TWIE (TWI Interrupt Enable) im TWCR (TWI Control Register) Register auf den Wert 1 gesetzt werden.


Synchroner Master

Die TWI Funktionseinheit kann sowohl synchron mit dem Programmfluss, als auch mit Interrupt betrieben werden. Wenn sie synchron mit dem Programmfluss verwendet wird, muss das Bit TWINT im TWCR Register regelmäßig überprüft/gepollt werden, um herauszufinden, ob eine Aktion ausgeführt werden muss. Für einen Betrieb als Slave scheint dieser Ansatz nicht besonders praktikabel, da zu bearbeitende Ereignisse zu unvorhersehbaren Zeitpunkten eintreffen können. Ein wenig anders liegt die Sache bei einem Betrieb als Master. Die Zeitpunkte, zu denen der Status des TWINT Bits abfragt werden muss, sind hier durch den Programmfluss vorgegeben.

Initialisierung

Operationen

void twi_stop() {
   TWCR = _BV(TWINT) | _BV(TWSTO) | _BV(TWEN);
}

uint8_t twi_start() {
   TWCR = _BV(TWINT) | _BV(TWSTA) | _BV(TWEN);
   loop_until_bit_is_set(TWCR, TWINT);
   return TW_STATUS;
}

uint8_t twi_write(uint8_t data) {
   TWDR = data;
   TWCR = _BV(TWINT) | _BV(TWEN);
   loop_until_bit_is_set(TWCR, TWINT);
   return TW_STATUS;  
}

uint8_t twi_sla_w(uint8_t slave_address) {
   uint8_t byte = (uint8_t) (slave_address << 1) | TW_WRITE;
   return twi_write(byte);
}

uint8_t twi_sla_r(uint8_t slave_address) {
   uint8_t byte = (uint8_t) (slave_address << 1) | TW_READ;
   return twi_write(byte);
}


uint8_t twi_read_ack(uint8_t *data) {
   TWCR = _BV(TWINT) | _BV(TWEA) | _BV(TWEN);
   loop_until_bit_is_set(TWCR, TWINT);
   *data = TWDR;
   return TW_STATUS;
}

uint8_t twi_read_nack(uint8_t *data) {
   TWCR = _BV(TWINT) | _BV(TWEN);
   loop_until_bit_is_set(TWCR, TWINT);
   *data = TWDR;
   return TW_STATUS;
}

Rückschau und Ausblick

Glückwunsch! Du bist nun mit allen Grundlagen vertraut, die Du benötigst, um an den TWI Bus angeschlossene Slaves mit dem Microcontroller steuern.

Im nächsten Kapitel wird es ausschließlich um die elektrischen Eigenschaften des TWI Bus und die Implementierung einer Software Lösung gehen, mit der der Microcontroller auch ohne integrierte TWI Funktionseinheit am TWI Bus betrieben werden kann.

Wenn Du nicht so sehr an den technischen Details interessiert bist und über einen Microcontroller mit integrierter TWI Funktionseinheit verfügst, kannst Du das folgende Kapitel überspringen.

Fußnoten

  1. Das Datenblatt [M328p] nennt in 26.2 Two-Wire Serial Interface Bus Definition, S.260 die Zahl 128. Faktisch dürften es allerdings nur 120 = 128 - 1 (General Call) - 7 (reserviert für zukünftige Verwendung) Geräte sein.
  2. (siehe [M328p]: 14.12.3. Power Reduction Register, S. 71)
  3. (siehe [M328p]: 26.5.2. Bit Rate Generator Unit, S. 266)

Projekt 1

Worum geht's?

In diesem Kapitel soll ein sainsmart LCD2004 LCD Display über den TWI Bus mit dem Microcontroller verbunden und in Betrieb genommen werden.

Für die Ansteuerung des TWI Bus kann wahlweise die TWI Funktionseinheit des Microcontrollers oder die Software-Lösung des vorangegangenen Kapitels verwendet werden.

Hardware

sainsmart LCD2004 Frontansicht
sainsmart LCD2004 Rückansicht

Das LCD Display des sainsmart LCD2004 wird von von einem HD44780 kompatiblen Chip angesteuert. Ein direkter Zugriff auf die Pins des HD44780 ist nicht möglich.

Der HD44780 ist fest mit einem PCF8574 I2C I/O Expander verdrahtet, der über die TWI Schnittstelle angesprochen werden kann.

Um den Schritten folgen zu können, ist es gut die zugehörigen Datenblätter [HD44780] und [PCF8574] der genannten Komponenten griffbereit zu haben.

I/O Expander

Eine Kommunikation mit dem für die Steuerung des LCD Displays verantwortlichen Chip ist nur über den I/O Expander möglich. Bevor wir uns daran machen können das Display zu steuern müssen wir uns also zunächst mit der Ansteuerung des I/O Expanders vertraut machen.

Slave Adresse

Die wichtigste Information, die wir benötigen, um über den TWI Bus mit dem PCF8574 zu kommunizieren, ist die zugehörige Slave Adresse. Die Bits 6...3 der Slave Adresse des PCF8574 sind fest vorgegeben. Die Bits 2...0 können durch die Verdrahtung über die Pins A0 A1 und A2 vorgegeben werden.[1] In unserem Fall sind die Pins A2 A1 und A0 alle auf 1 gelegt. Die Slave Adresse ist damit also 0x27.

0 1 0 0 A2 A1 A0 0 1 0 0 1 1 1 = 0x27

Der PCF8574 reagiert nicht auf General Call Adresse.[2]

I/O Pins

Der PCF8574 verfügt über 8 I/O Pins (P0–P7), deren Zustand über den TWI Bus sowohl gesetzt als auch abgefragt werden kann.

Um den Zustand der Pins zu setzen kann folgender Code Schnipsel verwendet werden.

#define IO_EXPANDER_ADDR    0x27

static void io_expander_write(uint8_t data) {
  twi_start();

  twi_sla_w(IO_EXPANDER_ADDR);
  twi_write(data);
  
  twi_stop();
}

Verdrahtung

Mit einer Ausnahme ist jeder der Daten Pins P7..P0 des PCF8574 mit einem Pin des HD44780 verdrahtet.

Einzig der Datenpin P3 ist nicht mit der LCD Steuerung verdrahtet. Über ihn kann stattdessen die Hintergrundbeleuchtung des LCD Displays ein- und ausgeschaltet werden.

Die Verdrahtung der Pins des I/O Expanders mit den Pins der LCD Steuerung sind in folgender Tabelle wiedergegeben. Der Eintrag (bl*) steht dabei für den nicht mit der LCD Steuerung verdrahteten Pin, der für die Hintergrundbeleuchtung (backlight) zuständig ist.

I/O Expander P7 P6 P5 P4 P3 P2 P1 P0
Display Steuerung D7 D6 D5 D4 (bl*) E RW RS

Display Steuerung

Das Display wird von einem HD44780 kompatiblen Chip angesteuert.

Das Display verfügt über zwei Speicherbereiche.

  • Display Data RAM (DDRAM)
  • Character Generator RAM (CGRAM)

Register und Befehlssatz

Das Verhalten des HD44780 kann über ein Befehlsregister IR (Instruction Register) und ein Datenregister DR (Data Register) gesteuert werden. [3] Beide Register haben eine Größe von 8-Bit.

Daten, die in das Datenregister geschrieben werden, werden automatisch in den Speicher DDRAM /CGRAM übernommen.

Daten, die in das Befehls Register geschrieben werden, dienen der Steuerung des LCD Displays. Die folgende Tabelle enthält eine Übersicht über die Befehle, die zur Verfügung stehen. Eine vollständige Liste findet sich im Datenblatt.[4]

DB7 DB6 DB5 DB4 DB3 DB2 DB1 DB0 Instruction
0 0 0 0 0 0 0 1 Clear Display 0x01
0 0 0 0 0 0 1 * Return Home 0x02
0 0 0 0 0 1 I/D S Entry Mode Set flags
0 0 0 0 1 D C B Display On/Off flags
0 0 0 1 S/C R/L * * Cursor/Display Shift flags
0 0 1 DL N F * * Function set flags
0 1 aaa aaa aaa aaa aaa aaa Set CGRAM Address addr
1 aaa aaa aaa aaa aaa aaa aaa Set DRAM Address addr
FLAG 0 1
I/D decrement increment
S don't shift display shift display
D display off display on
C cursor off cursor on
B cursor blink off cursor blink on
S/C cursor move display shift
R/L shift left shift right
DL data length 4-bit data length 8-bit
N 1 line 2 lines
F 5x8 dots 5x10 dots

Kommunikation

Für einen Transport von Daten nötige Parameter können wie folgt über die Pins des HD44780 vorgegeben werden.

  • mit dem Pin RS (Register Select) wird das Register ausgewählt, mit dem kommuniziert werden soll
  • mit dem Pin RW (Read/Write) wird die Richtung des Datentransfers vorgegeben
Pin 0 1
RS Instruction Register (IR) Data Register (DR)
RW write read

Daten können sowohl im 8-bit Modus, als auch im 4-bit Modus gesendet und empfangen werden.

Im 8-Bit Modus können Daten über die Datenpins D0...D7 gesendet und empfangen werden.

Im 4-Bit Modus wird ein Byte mit 2 aufeinander folgenden 4-Bit Transfers durchgeführt. Das high-Nibble muss dabei zuerst übertragen werden, danach das low-Nibble.

Der Datentransfer kann über eine fallende Flanke am E (Enable) Pin initiiert werden. Dabei ist es erforderlich, dass die gewünschten Parameter und Daten vor und nach fallender Flanke des Enable Pins anliegen.

Um ein Byte zum LCD transportieren müssen somit also insgesamt vier Bytes zum IO Expander gesendet werden.

#define E_HIGH   0b00000100

#define BACKLIGHT_OFF  0
#define BACKLIGHT_ON   8

static uint8_t backlight_flag = BACKLIGHT_ON;

void lcd_write(uint8_t flags, uint8_t data) {

  flags |= backlight_flag;

  uint8_t n_high = (uint8_t) ( (data & 0xF0) | flags );
  uint8_t n_low  = (uint8_t) ( (data << 4)   | flags );

  io_expander_write(n_high | E_HIGH);
  io_expander_write(n_high);

  io_expander_write(n_low  | E_HIGH);
  io_expander_write(n_low);
}

4-Bit Modus

Nach einem Reset befindet sich der HD44780 im 8-Bit Modus. Bevor er im 4-Bit Modus betrieben werden kann, muss er zunächst mit einer speziellen Sequenz von Befehlen in diesen Modus gebracht werden. Eine Anleitung hierzu findet sich im Datenblatt. [5]

Der erste Schritt besteht im Versenden der beiden folgenden Spezialpakete

Paket RS RW DB7 DB6 DB5 DB4
magic_1 0 0 0 0 1 1
magic_2 0 0 1 0 0 0

Anzahl Wiederholungen und Timing müssen sich dabei an folgende Prozedur halten:

  1. warte: mehr als 40 ms (sichere Variante)
  2. schreibe: magic_1
  3. warte: mehr als 4.1 ms
  4. schreibe: magic_1
  5. warte: mehr als 100us
  6. schreibe: magic_1
  7. schreibe: magic_2

Wurden alle Spezialpakete versendet, muss mit den folgenden Befehlen fortgefahren werden:

  1. FUNCTION_SET
  2. DISPLAY_ON_OFF
  3. CLEAR_DISPLAY
  4. ENTRY_MODE_SET
  5. DISPLAY_ON_OFF

Regulär muss vor jedem versenden darauf gewartet werden, dass die LCD Steuerung mit dem Busy Flag Bereitschaft signalisiert. Bei direkter Ansteuerung der LCD Steuerung sollte dieser Punkt unbedingt beachtet werden.

Der Datentransport über den TWI Bus ist langsam genug, dass in der Regel davon ausgegangen werden kann, dass die LCD Steuerung die Abarbeitung des vorangegangenen Befehls bereits abgeschlossen hat, wenn der nächste Befehl eintrifft. Eine Überprüfung des Busy Flags kann aus diesem Grund in der Regel ausgelassen werden.

Genaue Angaben zu den garantierten Ausführungszeiten der einzelnen Befehle finden sich im Datenblatt. In unserem Fall ist einzig der Befehl CLEAR_DISPLAY kritisch. Um auch hier eine Überprüfung des Busy Flags auslassen zu können, muss vor dem Versenden des nächsten Befehls eine geeignete Wartezeit eingehalten werden.

Für die Initialisierung der LCD Steuerung kann folgender Code Schnipsel verwendet werden

#define CLEAR_DISPLAY  0x01
#define ENTRY_MODE_SET 0x04
#define DISPLAY_ON_OFF 0x08
#define FUNCTION_SET   0x20

FILE* lcd_init() {

  twi_init();

  // Initializing by Instruction (page 46)

  uint8_t magic_1 = 0b00110000;
  uint8_t magic_2 = 0b00100000; 

  _delay_ms(50);
  
  io_expander_write(magic_1 | E_HIGH);
  io_expander_write(magic_1);

  _delay_ms(5);

  io_expander_write(magic_1 | E_HIGH);
  io_expander_write(magic_1);

  _delay_ms(1);

  io_expander_write(magic_1 | E_HIGH);
  io_expander_write(magic_1);
  
  io_expander_write(magic_2 | E_HIGH);
  io_expander_write(magic_2);

  /* ----------------------------------------- */

  lcd_write(IR_W, FUNCTION_SET   | 0x08);
  lcd_write(IR_W, DISPLAY_ON_OFF);
  lcd_write(IR_W, CLEAR_DISPLAY);

  _delay_ms(100);
  lcd_write(IR_W, ENTRY_MODE_SET | 0x02);
  lcd_write(IR_W, DISPLAY_ON_OFF | 0x04);

  return &the_lcd;
}


Ausgabe

Textausgabe

static FILE the_lcd = FDEV_SETUP_STREAM(lcd_put, NULL, _FDEV_SETUP_WRITE);

static int lcd_put(char c, FILE *stream) {
  lcd_write(DR_W, (uint8_t) c);
  return c;
}

Positionierung

void lcd_goto(uint8_t addr) {
  lcd_write(IR_W, SET_DRAM_ADDR | addr);
}

Hello World

#include <stdio.h>

#include "twi.h"
#include "lcd.h"

// Forward Declarations
void setup(void);
void loop(void);

/* ---------------------------------------------------------------------- */
FILE *lcd;

void setup() {
   lcd = lcd_init();

   fprintf(lcd, "hello world");
}

void loop() {
   ; // do nothing
}

#ifndef ARDUINO
int main(void) {
   setup();
   while(1)
      loop();
}
#endif
sainsmart LCD2004 Verkabelung
Ergebnis mit interner TWI Funktionseinheit
Ergebnis mit Software TWI Lösung

Fußnoten

  1. (siehe [PCF8574]: 8.3.2 Interface Definition S.12)
  2. (siehe [PCF8574]: 8.3.2 Interface Definition S.12)
  3. (siehe [HD44780]: Registers S.9)
  4. (siehe [HD44780]: Table 6 Instructions S.24)
  5. (siehe [HD44780]: Figure 24 4-Bit Interface, S.46)