2.2 AVR C-Programmierung
2.2.1 Allgemeines
2.2.2 Compiler und Entwicklungsumgebung
Als Entwicklungsumgebung (IDE - Integrierte Entwicklungsumgebung, engl. Integrated Development Environment) wird Microchip Studio (früher Atmel Studio) verwendet. Dieser verwendet GCC als Compiler.
Was ist ein Compiler?
Um ein Programm in einer höheren Programmiersprache (z.B. C) in Maschinensprache zu übersetzen, wird ein Compiler benötigt. Der Compiler übersetzt den Quellcode in eine für den Mikrocontroller verständliche Form. Der Compiler prüft den Quellcode auf Syntaxfehler und erzeugt eine ausführbare Datei (z.B. HEX-Datei), die auf den Mikrocontroller übertragen werden kann.
Dabei wird der Quellcode in mehrere Schritte übersetzt:
- Preprocessing - Der Präprozessor verarbeitet die Direktiven im Quellcode (z.B. #include, #define, #ifdef, ...).
- Compiling - Der Compiler übersetzt den Quellcode in Assemblersprache.
- Assembling - Der Assembler übersetzt die Assemblersprache in Maschinensprache.
- Linking - Der Linker verknüpft die verschiedenen Teile des Programms (z.B. Bibliotheken) zu einer ausführbaren Datei.
Dies soll im folgenden Ablaufdiagramm verdeutlicht werden:

Die .hex-Datei enthält den Maschinencode, welcher auf den Mikrocontroller übertragen wird. Der Mikrocontroller kann diesen Code ausführen und somit das Programm starten. Die Datei ist im Intel HEX-Format gespeichert und enthält Informationen über die Speicheradressen und die Daten, die an diese Adressen geschrieben werden sollen. D.h. das Programmiergerät bzw. der Bootloader kann die .hex-Datei entgegen nehmen und die Daten an die entsprechenden Speicheradressen des Mikrocontrollers schreiben.
Die .o-Datei hingegen enthält keinen ausführbaren Code, sondern nur den Maschinencode, der noch nicht an die Speicheradressen gebunden ist. Diese Datei wird benötigt, um die .hex-Datei zu erstellen.
2.2.3 Programmierung des AVR Mikrocontrollers
Um ein zuvor kompiliertes Programm auf den Mikrocontroller zu übertragen ("Flashen", d.h. Beschreiben des Flash Programmspeichers), wird ein Programmiergerät (z.B. Atmel-ICE) benötigt. Dieses wird über USB mit dem Computer sowie mit dem Mikrocontroller verbunden. Auf der MEGACARD ist ein Bootloader vorinstalliert, dieser ermöglicht das Flashen über USB ohne zusätzliches Programmiergerät. Stattdessen wird eine Software (avrdude) verwendet, welche mithilfe des Bootloaders das kompilierte Programm auf den Mikrocontroller überträgt. Ein Bootloader ist ein kleines Programm, das beim Start des Mikrocontrollers ausgeführt wird. Der Bootloader muss jedoch zuerst auf den Mikrocontroller geladen werden.
Hinweis: Sollte die MEGACARD nicht über einen Bootloader verfügen oder dieser überschrieben worden sein, kann der Bootloader mittels Programmiergerät auf den Mikrocontroller geladen werden. Wenn avrdude die MEGACARD nicht erkennt, liegt es am wahrscheinlichsten an:
1. Falscher Port (COM-Port) ausgewählt
2. Bootloder nicht installiert
2.2.4 Struktur eines C-Programms
Ein typisches C-Programm besteht aus verschiedenen Teilen, die in der folgenden Reihenfolge im Quellcode angeordnet sind:
- Direktiven (Preprocessor direcives)
Beginnen mit # und werden vom Präprozessor, d.h. vor dem eigentlichen kompilieren, verarbeitet. Beispiele sind #include, #define, #ifdef, ...
#include <stdio.h> // Include standard input/output header file, part of gcc
#include "display.h" // Include user-defined header file
#define PI 3.14159
#ifdef DEBUG
#define DEBUG_PRINT(x) printf(x)
- Deklarationen von Funktionen (Function declarations)
Enthalten den eigentlichen Programmcode. Jedes C-Programm muss mindestens eine Funktion enthalten, die Funktion main().
int16_t add(int8_t, int8_t); // Function prototype for addition
- Globale Variablen (Global variables)
Speichern Daten, die im Programm verwendet werden und sind überall im Programm verfügbar, d.h. nicht nur innerhalb einer Funktion. Hier können auch globale Konstanten definiert werden.
int8_t a = 10; // Global variable declaration and initialization
int8_t a, b; // Global variable declaration
const int8_t MAX = 100; // Global constant declaration
- Funktionen (Functions)
Enthalten den eigentlichen Programmcode. Jedes C-Programm muss mindestens eine Funktion enthalten, die Funktion main().
// Function to add two integers
int16_t add(int8_t x, int8_t y) {
return x + y;
}
void main(int argc, char **argv) {
// program execution starts here
int8_t x = 5, y = 10; // Local variable declaration and initialization
int16_t sum; // Local variable declaration of sum
sum = add(x, y); // Call of function add
display_printf_pos(0, 4, "Sum of %d and %d is %d\n", x, y, sum); // Output the result on the display
}
Zu beachten sind weiters folgende Punkte:
- Groß- und Kleinschreibung - C ist case-sensitive
- Kommentare - Erläutern den Quellcode und erhöhen die Lesbarkeit (beginnen mit // oder / ... /). Es wird empfohlen, den Quellcode so zu kommentieren, dass auch Personen, die nicht mit dem Code vertraut sind, diesen verstehen (vorzugsweise in der Sprache Englisch)
- Leerzeilen - Erhöhen die Lesbarkeit des Quellcodes
- Leerzeichen - Erhöhen die Lesbarkeit des Quellcodes
- Einrückungen - Erhöhen die Lesbarkeit des Quellcodes
- Klammern - Begrenzen Blöcke von Anweisungen (beginnen mit { und enden mit })
- Semikolons - Beenden Anweisungen (";")
2.2.4 Datentypen in AVR-GCC
Datentypen sind "Formal die Zusammenfassung von Objektmengen mit allen darauf definierten Operationen" (Wikipedia).
Ganzzahlige Datentypen
Standardisierte Datentypen werden seit C99 in der Header-Datei stdint.h zur Verfügung gestellt. Diese Datei ist z.B. über Dependencies in einem Projekt einsehbar. Aus dem Namen ist die Bitbreite erkennbar.
Integertypen mit Vorzeichen
| Typename | Wertebereich | Alias (AVR-GCC) |
|---|---|---|
| int8_t | -128..127 | signed char |
| int16_t | -32768..32767 | signed int |
| int32_t | -2147483648..2147483647 | signed long int |
| int64_t | -9223372036854775808..9223372036854775807 | signed long long |
Integertypen ohne Vorzeichen
| Typename | Wertebereich | Alias (AVR-GCC) |
|---|---|---|
| uint8_t | 0..255 | unsigned char |
| uint16_t | 0..65535 | unsigned int |
| uint32_t | 0..4294967295 | unsigned long int |
| uint64_t | 0..18446744073709551615 | unsigned long long |
Weitere Datentypen
Floats/Doubles werden im Kapitel Verschiedenes behandelt.
char ... todo
Eigene Datentypen (struct / typedef)
Mit struct können in C mehrere Variablen unterschiedlichen Typs zu einem zusammenhängenden Datentyp gruppiert werden. Das ist besonders hilfreich, wenn zusammengehörige Werte – z.B. mehrere Sensormesswerte – als eine Einheit behandelt werden sollen.
Mit typedef wird dem struct ein Aliasname zugewiesen, sodass man beim Deklarieren nicht jedes Mal struct davorschreiben muss.
Beispiel: Sensorwerte
#include <stdint.h>
typedef struct {
uint16_t temperatur; // in 0.1 °C (z.B. 235 = 23.5 °C)
uint16_t luftfeuchtigkeit; // in 0.1 % (z.B. 456 = 45.6 %)
uint16_t druck; // in hPa
uint32_t timestamp; // in ms seit Start
} Sensorwerte;
Zugriff auf die Felder
Der Zugriff auf die einzelnen Felder eines structs erfolgt über den Punkt-Operator (.):
Sensorwerte aktuell;
aktuell.temperatur = 235;
aktuell.luftfeuchtigkeit = 456;
aktuell.druck = 1013;
aktuell.timestamp = 12345;
Wird über einen Pointer auf das struct zugegriffen, verwendet man stattdessen den Pfeil-Operator (->):
Sensorwerte *p = &aktuell;
p->temperatur = 240; // entspricht (*p).temperatur = 240;
Anwendung
Ein häufiges Anwendungsbeispiel ist das Speichern mehrerer Messungen in einem Array:
Sensorwerte messungen[10];
for (uint8_t i = 0; i < 10; i++) {
messungen[i].temperatur = read_temperature();
messungen[i].timestamp = get_millis();
}
Oder die Übergabe an eine Funktion – als Pointer, um unnötiges Kopieren zu vermeiden:
void sende_werte(const Sensorwerte *s) {
printf("T=%u H=%u p=%u\n", s->temperatur, s->luftfeuchtigkeit, s->druck);
}
sende_werte(&aktuell);
2.2.5 Bitmanipulation
Hardwarenahe Programmierung erfordert oft die direkte Manipulation von Bits. Dies kann z.B. notwendig sein, um Register zu setzen oder zu löschen. Dazu werden Bitmasken verwendet, die mit den logischen Operatoren AND, OR, XOR und NOT verknüpft werden. Im folgenden sind die grundlegenden Bitoperationen aufgelistet.
2.2.7 Formatierung von Datentypen
Die Formatierung von Datentypen erfolgt über die Funktion printf() bzw. in unserem Fall über eine Funktion aus der Bibliothek "display.h", nämlich display_printf_pos. Die Formatierung erfolgt über Platzhalter, die durch die entsprechenden Variablen ersetzt werden.
Diese können z.B. sein:
| Formatierung | Beschreibung | Beispiel |
|---|---|---|
| %d oder %i | Dezimalzahl | 123 |
| %x | Hexadezimalzahl | 0x7B |
| %o | Oktalzahl | 0173 |
| %c | Zeichen | 'A' |
| %s | Zeichenkette | "Hello, World!" |
| %f | Fließkommazahl | 3.14 |
| %e | Fließkommazahl in Exponentialdarstellung | 3.14e+00 |
| %g | Fließkommazahl in kürzester Darstellung | 3.14 |
| %p | Zeiger | 0x7fffe0 |
sprintf und snprintf
Während printf() die formatierte Ausgabe auf die Standardausgabe (z.B. Konsole) schreibt, kann man mit sprintf() und snprintf() den formatierten Text stattdessen in einen Puffer (char-Array) schreiben. Das ist bei Mikrocontrollern besonders wichtig, da es dort meist keine "Konsole" im klassischen Sinne gibt – die erzeugte Zeichenkette wird stattdessen über UART gesendet oder auf einem Display ausgegeben.
sprintf() – ohne Längenbegrenzung
char buffer[32];
uint16_t temp = 235;
sprintf(buffer, "T = %u.%u C", temp / 10, temp % 10);
// buffer enthält nun: "T = 23.5 C"
Problem: Wenn der formatierte Text länger ist als der Puffer, überschreibt sprintf() angrenzenden Speicher – ein klassischer Buffer-Overflow. Das kann zu Abstürzen oder schwer zu findenden Fehlern führen.
snprintf() – mit Längenbegrenzung (empfohlen)
snprintf() ist die sichere Variante: sie schreibt maximal n-1 Zeichen in den Puffer (das letzte Byte ist für die Null-Terminierung \0 reserviert).
char buffer[32];
uint16_t temp = 235;
uint16_t hum = 456;
snprintf(buffer, sizeof(buffer), "T=%u.%u C H=%u.%u %%",
temp / 10, temp % 10, hum / 10, hum % 10);
// buffer enthält nun: "T=23.5 C H=45.6 %"
Der Rückgabewert von snprintf() gibt an, wie viele Zeichen geschrieben worden wären – ist der Rückgabewert größer oder gleich sizeof(buffer), wurde der Text abgeschnitten.
Anwendungsbeispiel: Sensorwert per UART senden
char msg[64];
Sensorwerte s = { .temperatur = 235, .luftfeuchtigkeit = 456, .druck = 1013 };
snprintf(msg, sizeof(msg), "T=%u.%u H=%u.%u p=%u\r\n",
s.temperatur / 10, s.temperatur % 10,
s.luftfeuchtigkeit / 10, s.luftfeuchtigkeit % 10,
s.druck);
uart_send_string(msg);
Hinweis: Das doppelte Prozentzeichen %% im Formatstring gibt ein literales %-Zeichen aus.
Faustregel: In der Praxis immer snprintf() verwenden – sprintf() gilt als unsicher.
2.2.8
In Bearbeitung.
2.2.9 Debugging
2.2.10 Verschiedenes
Float-Unterstützung in Microchip Studio
Standardmäßig ist in Microchip Studio die Unterstützung für floats ausgeschaltet.
Eine Anleitung, wie die Unterstützung für floats in Microchip-Studio eingeschaltet werden kann findet sich z.b. hier.
NOTE: Durch die Aktivierung der Unterstützung für floats, wird die compilierte Datei größer.
Wird mit float-Werten gerechnet, ist auf die Verwendung von Literals zu achten. So ergibt z.B. folgendes Beispiel...
int i;
i = 3 / 2;
... als Ergebnis 1.
Werden hingegen Float Literals verwendet (hier 3.0)...
int i;
i = 3.0 / 2;
... wird das korrekte Ergebnis 1.5 ausgegeben. Dabei spielt es keine Rolle, ob der erste Wert, der zweite Wert oder beide Werte als Literals angegeben sind (jedoch mindestens einer).
Üblicherweise sind in C float und double unterschiedlich definiert.
float 32-bit
double 64-bit
NOTE: In Compiler von Microchip-Studio sind float und double gleich groß (32-bit). Überprüfbar ist dies z.B. über den Aufruf der Funktion sizeof(double), welche dann die Anzahl an Bytes zurückgibt.
Literals
Literals in C sind Daten, welche direkt in den Quellcode eingefügt werden können und deren Werte sich während der Laufzeit des Programms nicht ändern.
Beispiele sind:
| Bezeichnung | Beispiel |
|---|---|
| Integer Literals | 123 (dezimal), 0x42 (hexadezimal), 077 (octal) |
| Floating-point Literals | 2.0 oder 145.2 |
| Character Literals | 'a', '\n' |
| String Literals | "Hello, World!" |
| Boolean Literals | true, false |
Pointer
Zeiger (engl. Pointer) ermöglichen den direkten Zugriff auf die Speicheradressen von Daten. Sie sind im Wesentlichen eine Variable, deren Wert die Adresse einer anderen Variable ist. Dadurch kann ein Programm effizienter - sprich platzsparender - mit Speicherressourcen umgehen und es können dynamischen Datenstrukturen (wie z.B. Listen) implementiert werden.
Beim AVR ist ein Pointer immer 16 bit breit und enthält zudem Informationen über den Datentypen, auf den er zeigt.
Switch-case-Anweisung
Die switch-case-Anweisung ist eine alternative Möglichkeit zur Verzweigung von Programmen. Sie ist besonders nützlich, wenn eine Variable auf verschiedene Werte geprüft werden soll.
#include <stdio.h>
int main() {
int i = 2; // Variable i wird auf 2 gesetzt
switch(i) { // Abfrage nach Variable i
case 1:
printf("i ist 1\n");
break;
case 2:
printf("i ist 2\n"); // Wird ausgegeben
break;
case 3:
printf("i ist 3\n");
break;
default:
printf("i ist weder 1, 2 noch 3\n");
}
return 0;
}