Umstieg von Arduino auf AVR
von newgenertion
Dieser Artikel soll eine kleine Hilfestellung für alle sein, die aktuell mit einem Arduino-Board arbeiten, sich aber mehr für die Materie interessieren und auf richtige Mikrocontroller-Programmierung umsteigen wollen.
Um den Umstieg zu erleichtern werden hier einige kleine, aber hilfreiche Schritte aufgezeigt. Diese Anleitung ist nur für die Arduinos mit einem 8bit-AVR als Prozessor (Uno, Mega, Leonardo, ...) gedacht und nicht für 32bit-Mikrocontroller (Due, ...).
Der Mikrocontroller wird in dieser Anleitung in C programmiert. Grund dafür ist die Verteilung von Programmiersprachen und deren Schwierigkeit zu erlernen. Auf Mikrocontrollern gibt es hauptsächlich Assembler und C, wobei auch andere Sprachen im Kommen sind, so zum Beispiel C++ (das Arduino-Framework ist in C++ geschrieben).
Trotzdem soll sich hier auf C beschränkt werden, weil
- C++ schwerer zu Beherrschen ist als C
- Die meisten Arduino-Sketches eher in C geschrieben sind, mit Ausnahme der Arduino-Libraries und derer Objekte
- Assembler nochmal eine ganz andere Sprache ist
Voraussetzungen
C-Kenntnisse
Wer schon C programmieren kann - damit ist mehr als if-else- und Copy&Paste-Programmierung gemeint - kann diesen Punkt selbstverständlich überspringen. Allen anderen kann ich nur wärmstens empfehlen, ein C-Buch oder wenigstens ein (gutes) C-Tutorial durchzuarbeiten.
Im Artikel C stehen einige Links zu Tutorials und Einführungen zur Sprache C. Es dürfte einfacher sein, sich die C-Kenntnisse auf einem PC zu erarbeiten, da man dort viel mehr Möglichkeiten hat, sein Programm zu analysieren und auf Fehler zu reagieren.
Achtung: Tutorials, vor allem die in deutscher Sprache, sollten teilweise Hinterfragt werden. Oftmals schreibt der Autor einfach nur seine (zum Teil begrenzte) Sicht der Dinge. Es kann nicht schaden, mehr als ein Tutorial zu lesen und bei Diskrepanzen den C-Standard zu Rate zu ziehen.
Andere Vorkenntnisse
- Bitmanipulation
- Harvard-Architektur
- Wissen über AVR und Mikrocontroller allgemein kann nicht schaden
Verändern des Arduino-Sketches
Als Erstes sollte man sich abgewöhnen von Sketchen zu reden, damit wird man nur belächelt. Es sind Programme, Anwendungen, Applikationen, ...
Anpassung von int-Typen
Fast sämtliche Arduino-Beispiele sehen irgendwie so aus (hier ein kleines Lauflicht):
int ledPin = 13; // LED connected to digital pin 13
void setup()
{
pinMode(ledPin, OUTPUT); // sets the digital pin as output
for(int i = 0; i < 8; i++) {
pinMode(i, OUTPUT); // sets the digital pin as output
}
digitalWrite(ledPin, HIGH); // sets the Board-LED on
}
void loop()
{
for(int i = 0; i < 8; i++) {
digitalWrite(i, HIGH); // sets the LED on pin <i> on
delay(100); // waits 100 milliseconds
digitalWrite(i, LOW); // sets the LED on pin <i> off
}
delay(1000); // waits for a second
}
/*
Der Sketch verwendet 1006 Bytes (3%) des Programmspeicherplatzes.
Das Maximum sind 32256 Bytes.
Globale Variablen verwenden 9 Bytes (0%) des dynamischen Speichers,
2039 Bytes für lokale Variablen verbleiben. Das Maximum sind 2048 Bytes.
*/
Fällt euch etwas auf? Nein? Wie groß ist ein int? Genau mindestens 16 bit. Und es ist ein Typ mit Vorzeichen.
Eine Variable vom Typ int kann auf einem AVR also Werte von -2^15 bis 2^15 - 1 annemhen. Das sind Zahlen zwischen -32768 und 32767. Und was wird in diesem Typ gespeichert?
- Eine Variable, deren Wert sich nie ändert: int ledPin = 13;
- Und zwei Laufvariablen von 0 bis 7.
Also beides nicht wirklich das, wofür man 16 bit Variablen braucht.
Okay, dann hat man halt Variablen mit einem zu großen Typ definiert, was macht das? Schon etwas, denn der AVR ist ein 8bit-Mikrocontroller, das bedeutet grob, dass er immer nur 8bit-Zahlen auf einmal manipulieren kann, alles größere braucht mehrere Befehle und ist somit langsamer. Mikrocontroller mögen vorzeichenlose Zahlen auch lieber, als solche mit Vorzeichen.
Man sollte also bei jeder Variable überlegen, welchen Wertebereich man benötigt und dann immer den Typen so klein wie möglich, aber so groß wie nötig nehmen.
Der C-Standard bietet Typen mit genauer Bitbreite an, dafür muss nur eine Header-Datei eingebunden werden:
#include <stdint.h>
In dieser werden dann unter anderem die folgenden Typen definiert:
Größe | Vorzeichenlos | Vorzeichenbehaftet |
---|---|---|
8 bit | uint8_t | int8_t |
16 bit | uint16_t | int16_t |
32 bit | uint32_t | int32_t |
64 bit | uint64_t | int64_t |
Die Nomenklatur ist eigentlich ganz einfach: [u]int[bits]_t, wobei das [u] für unsigned, also vorzeichenlos, steht und [bits] eben die Anzahl der Bits für die Variable angibt.
Der geänderte Source-Code sieht dann so aus:
#include <stdint.h>
#define LED_PIN 13 // ein Define erzeugt keinen Zusätzlichen Code,
// es erfolgt schließlich nur eine Textersetzung.
// Defines immer in GROSSBUCHSTABEN
void setup()
{
pinMode(LED_PIN, OUTPUT); // sets the digital pin as output
for(uint8_t i = 0; i < 8; i++) {
pinMode(i, OUTPUT); // sets the digital pin as output
}
digitalWrite(LED_PIN, HIGH); // sets the Board-LED on
}
void loop()
{
for(uint8_t i = 0; i < 8; i++) {
digitalWrite(i, HIGH); // sets the LED on pin <i> on
delay(100); // waits 100 milliseconds
digitalWrite(i, LOW); // sets the LED on pin <i> off
}
delay(1000); // waits for a second
}
/*Der Sketch verwendet 1006 Bytes (3%) des Programmspeicherplatzes.
Das Maximum sind 32256 Bytes.
Globale Variablen verwenden 9 Bytes (0%) des dynamischen Speichers,
2039 Bytes für lokale Variablen verbleiben.
Das Maximum sind 2048 Bytes.
*/
Bei diesem Minimal-Programm hat diese Änderung wie man sieht nichts gebracht, das zeigt aber nicht, dass diese Anpassung sinnlos ist, sondern, dass der Compiler sehr gut optimiert und diese unnötig großen Variablen eliminiert.
Ein anderes Beispiel ist dieses Programm. Es macht nichts außer eine volatile-Variable hochzuzählen. (Volatile zum verbieten der Optimierungen).
#include <stdint.h>
// 64bit
volatile int64_t a;
void setup()
{
a = 0;
}
void loop()
{
a++;
}
/*
Der Sketch verwendet 570 Bytes (1%) des Programmspeicherplatzes.
Das Maximum sind 32256 Bytes.
Globale Variablen verwenden 17 Bytes (0%) des dynamischen Speichers,
2031 Bytes für lokale Variablen verbleiben. Das Maximum sind 2048 Bytes.
*/
Wenn man jetzt die Variable verkleinert und auf unsigned ändert:
#include <stdint.h>
// 8bit
volatile uint8_t a;
void setup()
{
a = 0;
}
void loop()
{
a++;
}
/*
Der Sketch verwendet 458 Bytes (1%) des Programmspeicherplatzes.
Das Maximum sind 32256 Bytes.
Globale Variablen verwenden 10 Bytes (0%) des dynamischen Speichers,
2038 Bytes für lokale Variablen verbleiben. Das Maximum sind 2048 Bytes.
*/
Schon bei diesem Minimal-Programm sieht man einen kleinen Unterschied
Bei größeren Programmen mit mehreren Modulen kann der Compiler aber nicht mehr alles überblicken, deswegen lohnt sich spätestens dort diese Änderung.
Entfernen der Arduino-Libraries
Wer seinen Mikrocontroller richtig verstehen will, der sollte auch versuchen sämtliche Hardware-Ansteuerung selber zu programmieren.
Entfernen der delay()-Aufrufen
Zuerst einmal: delays sind so gut wie immer schlecht! Während der Controller im delay() wartet, kann er nichts anderes mehr tun!
Die Implementierung vom delay() in der Arduino-Bibliothek benutzt Interrupts und kann deswegen in Interrupts nicht funktionieren (Obwohl das sowieso eine sehr schlechte Idee ist). Um aber von Arduino und deren Library wegzukommen, benutzen wie eine andere Impementierung, nämlich die von der avr-libc. Diese bietet _delay_ms() und _delay_us() für taktgenaue Verzögerungen in Milli- bzw. Mikrosekunden-Bereich an. Dafür ist nur das Einbinden von <util/delay.h> nötig.
#include <stdint.h>
#include <util/delay.h>
#define LED_PIN 13 // ein Define erzeugt keinen Zusätzlichen Code,
// es erfolgt schließlich nur eine Textersetzung.
// Defines immer in GROSSBUCHSTABEN
void setup()
{
pinMode(LED_PIN, OUTPUT); // sets the digital pin as output
for(uint8_t i = 0; i < 8; i++) {
pinMode(i, OUTPUT); // sets the digital pin as output
}
digitalWrite(LED_PIN, HIGH); // sets the Board-LED on
}
void loop()
{
for(uint8_t i = 0; i < 8; i++) {
digitalWrite(i, HIGH); // sets the LED on pin <i> on
_delay_ms(100); // waits 100 milliseconds
digitalWrite(i, LOW); // sets the LED on pin <i> off
}
_delay_ms(1000); // waits for a second
}
/*
Der Sketch verwendet 828 Bytes (2%) des Programmspeicherplatzes.
Das Maximum sind 32256 Bytes.
Globale Variablen verwenden 9 Bytes (0%) des dynamischen Speichers,
2039 Bytes für lokale Variablen verbleiben. Das Maximum sind 2048 Bytes.
*/
Entfernen der I/O-Aufrufe
Dazu zählen unter anderem pinMode, digitalWrite und ditigalRead. Diese verbindet allesamt eines: Die eigenwillige Nummerierung der Pins.
Auf dem Arduino-Board sind sie zwar logisch angeordnet, aber nicht unbedingt logisch mit dem Prozessor verbunden! Deswegen muss man einmal nach seinem arduino board + Pinout googlen, dann kommen schöne Bilder, die recht Anschaulich zeigen, was womit verbunden ist. Beim Arduino Uno ist das Ganze noch recht ordentlich, beim Arduino Mega erinnert es mehr an Chaos...
Eine Seite, die viele Pinouts hat ist libraries.io. Dort sucht man sich einfach sein Board heraus und speichert sich am besten das Bild, denn das wird noch häufiger benötigt.
Um jetzt wirklich starten zu können fehlt nur noch eins: das Datenblatt des Prozessors. Auf der Arduino-Seite steht, was für ein Prozessor dort verbaut ist, nach diesem Datenblatt sollte man dann bei Google oder direkt beim Hersteller Atmel suchen. Beim Arduino Uno ist es der ATmega328p.
Im Datenblatt gibt es ein Kaptiel "I/O-Ports", wo haarklein erklärt wird, wie die Pins funktionieren und anzusteuern sind. Wichtig sind dazu vor allem drei Register:
- PORTx - The Port x Data Register
- DDRx - The Port x Data Direction Register
- PINx - The Port x Input Pin Register
Wobei das x für den Port steht. Welche Ports es gibt hängt vom jeweiligen AVR ab. Der Atmega328p hat zum Beispiel vier Stück: PORTA, PORTB, PORTC, PORTD. Ein ATmega2560 hingegen hat derer elf: PORTA - PORTH und PORTJ - PORTL. Gemeinsam ist allen, dass ein Port maximal 8 Pins enthält (PXN, X=Port-Buchstabe, N=Port-Bit).
Genaueres gibt es hier:
Eine Kurzfassung folgt nun:
Um auf die I/O-Register (beziehungsweise Register allgemein) zugreifen zu können braucht man eine weiter Header-Datei:
#include <avr/io.h>
Mittels diesen Registern kann man dann jeden einzelnen Pin steuern. Die folgende Tabelle zeigt die Einstellungsmöglichkeiten:
DDRx | PORTx | IO-Pin-Zustand |
---|---|---|
0 | 0 | Eingang ohne Pull-Up (Resetzustand) |
0 | 1 | Eingang mit Pull-Up |
1 | 0 | Push-Pull-Ausgang auf LOW |
1 | 1 | Push-Pull-Ausgang auf HIGH |
Das übrige PINx-Register hat nun auch wieder zwei Einsatzmöglichkeiten. Wenn der Pin ein Input-Pin ist (DDxn = 0), dann gibt dieses Register den Zustand des Pins aus, eine 1 für High und eine 0 für Low. Ist der Pin jedoch als Ausgang konfiguriert, dann können neuere AVRs (praktisch alle auf Arduinos) den Pin direkt "togglen", also umschalten: ist er aktuell High, dann wird er auf Low geschalten, und umgekehrt.
Mit diesem Wissen können wir wieder unseren Code anpacken:
#include <stdint.h>
#include <avr/io.h>
#include <util/delay.h>
//Der Arduino-Pin 13 ist auf dem Arduino Uno der Pin PB5
#define LED_DDR DDRB
#define LED_PORT PORTB
#define LED_BIT PB5
void setup()
{
LED_DDR |= (1 << LED_BIT); // sets the digital pin as output
//die 8 LEDs leigen alle auf PORTD, also diesen komplett auf Ausgang
DDRD = 0xFF;
LED_PORT |= (1 << LED_BIT); // sets the Board-LED on
}
void loop()
{
for(uint8_t i = 0; i < 8; i++)
{
PORTD |= (1 << i); // sets the LED on pin <i> on
_delay_ms(100); // waits 100 milliseconds
PORTD &= ~(1 << i); // sets the LED on pin <i> off
}
_delay_ms(1000); // waits for a second
}
/*
Der Sketch verwendet 534 Bytes (1%) des Programmspeicherplatzes.
Das Maximum sind 32256 Bytes.
Globale Variablen verwenden 9 Bytes (0%) des dynamischen Speichers,
2039 Bytes für lokale Variablen verbleiben. Das Maximum sind 2048 Bytes.
*/
Man sieht: der Speicherverbrauch wurde nochmal gedrückt, und schneller wurde das Programm auch noch.
Bleiben aber noch Fragen: Warum verbraucht dieses Mini-Programm immer noch so viel Flash? Un warum wird RAM verbraucht, obwohl keine einzige Variable verwendet wird?
TODO: Geschwindigkeit mittels Oszi messen (Darf gerne auch von jemand anderem gemacht werden).
main()-Funktion statt setup() & loop()
Die Arduino-IDE hat im Vergleich zu anderen IDEs eine "Gemeinheit" eingebaut. Um es dem Benutzer einfacher zu manchen, ändert diese stillschweigend den Code (fügt etwa eine main()-Funktion hinzu und das include <Arduino.h>) und zieht Code mitein, selbst wenn dieser nicht genutzt wird.
So zum Beispiel die Interrupt-Routine, die den Millisekunden-Timer für die delay()-Funktion bildet: Sowohl die Routine an sich, als auch die Konfigurierung des Interrupts und auch die generelle Erlaubnis aller ISRs geschieht automatisch, ohne das der User daran etwas ändern kann.
Das ist im Normalfall auch in Ordnung, da sich der 08/15-Arduino-Benutzer gar nicht dafür interessiert.
Bei uns ist das aber etwas anderes! Also wird das Programm an ein richtiges C-Programm angeglichen, also mit einer main()-Funktion, statt setup() und loop().
#include <stdint.h>
#include <avr/io.h>
#include <util/delay.h>
//Der Arduino-Pin 13 ist auf dem Arduino Uno der Pin PB5
#define LED_DDR DDRB
#define LED_PORT PORTB
#define LED_BIT PB5
int main(void)
{
LED_DDR |= (1 << LED_BIT); // sets the digital pin as output
//die 8 LEDs leigen alle auf PORTD, also diesen komplett auf Ausgang
DDRD = 0xFF;
LED_PORT |= (1 << LED_BIT); // sets the Board-LED on
while(1)
{
for(uint8_t i = 0; i < 8; i++)
{
PORTD |= (1 << i); // sets the LED on pin <i> on
_delay_ms(100); // waits 100 milliseconds
PORTD &= ~(1 << i); // sets the LED on pin <i> off
}
_delay_ms(1000); // waits for a second
}
}
/*
Der Sketch verwendet 222 Bytes (0%) des Programmspeicherplatzes.
Das Maximum sind 32256 Bytes.
Globale Variablen verwenden 0 Bytes (0%) des dynamischen Speichers,
2048 Bytes für lokale Variablen verbleiben. Das Maximum sind 2048 Bytes.
*/
Et voilà! Da haben wir es: Der Code-Verbrauch ist nochmal drastisch gesunken und vor allem: keine Varaible, kein RAM-Verbrauch!
Damit ist jeder Arduino-Code, der im Hintergrund dazukam, getilgt.
Anmerkung: Der Compiler mag shiften um Variablen nicht, also das (1 << i). Es ist wesentlich besser, wenn man so etwas schreibt:
uint8_t mask = (1 << 0);
for(uint8_t i = 0; i < 8; i++)
{
PORTD |= mask; // sets the LED on pin <i> on
_delay_ms(100); // waits 100 milliseconds
PORTD &= ~mask; // sets the LED on pin <i> off
mask = (mask << 1); // shift the bit to the left
}
Das ist erstens schneller und verbraucht zweitens weniger Speicher (im Beispiel nur noch 204 Bytes).
Weg von der Arduino-IDE
Auswahl der neuen IDE
So, nun ist man an dem Punkt angelangt, an welchem man sich dazu entscheiden kann (und meiner Meinung auch sollte), Abschied von der Arduino-IDE zu nehmen. Diese ist in vielerlei Hinsicht nicht optimal, sei es zum Beispiel beim highlighting von Code oder der mangelnden Konfigurationsmöglichkeit.
Es gibt zahlreiche Möglichkeiten, wie man nun weiter verfahren kann:
- bei der Arduino-IDE bleiben
- auf das Atmel Studio umsteigen (entweder Version 7 mit zahlreichen neuen Features, oder VErsion 4, falls es schnell und zuverlässig sein soll)
- auf eine andere IDE (z.B. eclipse) umsteigen
- Mittels Makefiles und einem Editor/einer IDE seiner Wahl arbeiten
Ich persönlich habe mich nach langen arbeiten mit jeder dieser Möglichkeiten (abgesehen von der Arduino-IDE, diese habe ich mehr oder weniger direkt verworfen) für die letzte, für das Arbeiten mit Makefiles, entschieden. Dort hat man völlige Kontrolle über alles: was wird wann mit welchen Option kompiliert und was wird hinzugelinkt?
Das sollte aber jeder für sich selber herausfinden. Die Liste oben ist von der Schwierigkeit her sortiert, das bedeutet, das Makefiles das anspruchsvollste sind.
Einarbeiten in die neue Umgebung
Sobald man sich auf eine IDE festgelegt hat, sollte man sich in diese erst einmal Einarbeiten. Auf diesem Punkt kann in dieser Anleitung nicht eingegangen werden, da sich mögliche Tipps oder Ähnliches ja nach IDE unterscheiden würden.
Am besten versucht man erstmal die für sich wichtigen Funktionen zu finden und mit der neuen Umgebung allgemein zurech zu kommen.
Ziel ist auf jeden Fall, das obige Programm zu kompilieren zu bekommen.
Das Programm übertragen
So weit, so gut.
Der neue Editor/Die neue IDE läuft, der Code kompiliert.
Doch wie bringt man nun den Code auf den AVR? Dazu gibt es unter Anderem zwei Möglichkeiten auf die ich hier eingehen möchte:
- mit extra Programmer/Debugger
- mittels dem eingebrannten Bootloader
Was das ist wird hier nicht erklärt, dafür sind die Artikel verlinkt.
Programmer/Debugger
Wer schon einen Programmer oder gar Debugger für AVRs sein Eigen nennen kann, der sollte diesen verwenden, da damit noch einmal der Speicherplatz für den Bootloader frei wird (Dadurch kann das Programm noch einmal ~2kB größer werden) und auch die Wartezeit nach jedem Reset entfällt.
Wer noch keinen Programmer hat, der muss sich nicht unbedingt einen solchen kaufen, solange er mit den eben genannten Nachteilen leben kann.
Wer aber jetzt in die Tasche greifen will, der kann sich überlegen, ob er vielleicht nicht lieber direkt einen vollwertigen Debugger kauft. Damit kann man, wie auch am PC, ein laufendes Programm anhalten, Werte von Registern anzeigen, etc. So etwas kann sehr hilfreich sein, wenn "unerklärliche" Phänomene auftreten.
Wie man mit dem Programmer dann schließlich den AVR programmiert hängt wieder von der IDE ab. Beim Atmel Studio wird man sicherlich auf den Programming Dialog zurückgreifen, bei anderen IDEs wird wahrscheinlich ein Drittprogram wie avrdude verwendet.
Bootloader
Alle Arduinos kommen mit einem vorinstalliertem Bootloader. Mit diesem lässt sich der Prozessor auch ohne Programmer über USB direkt flashen.
Also PC-Programm bietet sich dazu avrdude an. Dieses ist ein mehr oder weniger "Universales" Brennprogramm für fast alle AVR-Typen. Es beherrscht auch die Kommunikation mit dem Arduino-Bootloader. Damit kann man dann ganz einfach sein Programm übertragen. Die Kommandozeile ist leider je nach Arduino- und Bootloader-Versio etwas anderes
# beim Arduino mega ist es
$ avrdude -cwiring -patmega2560 -P<serial port> -b115200 -U flash:w:<file> -D
# andere Konfigurationen könnten sein (von mir ungetestet, gerne zu Vervollständigen)
$ avrdude -carduino -patmega328p -P<serial port> -b115200 -U flash:w:<file> -D
$ avrdude -carduino -patmega328p -P<serial port> -b57600 -U flash:w:<file> -D
$ avrdude -cstk500v2 -patmega328p -P<serial port> -b115200 -U flash:w:<file> -D
Arduino-Library Ersatz
Nachdem das Minimal-Programm von oben nun auf dem AVR-Board getestet wurde, geht es weiter.
Wir haben uns von Arduino verabschiedet, damit aber auch von allen Arduino-Libraries! Das bedeutet, dass selbst so banale Sachen wie Serial.println() nicht mehr existieren. Diese müssen wir nun selber schreiben.
Erstellen eigener Libraries
Hier soll nun ein Beispiel mit einer Schritt-für-Schritt-Anleitung gegeben werden. Dazu habe ich mir das UART-Modul ausgesucht.
Serielle Kommunikation mittels UART-Hardware
Das wird zwar dann die X-te UART library, aber zur Demonstration eignet sich das UART-Modul hervorragend.
TODO: Verlinkungen zu weiterführenden Artikeln, Beispiel für UART und LCD library selbst geschrieben, ...