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:

  1. Preprocessing - Der Präprozessor verarbeitet die Direktiven im Quellcode (z.B. #include, #define, #ifdef, ...).
  2. Compiling - Der Compiler übersetzt den Quellcode in Assemblersprache.
  3. Assembling - Der Assembler übersetzt die Assemblersprache in Maschinensprache.
  4. 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:

Compiler

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:

  1. 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)
  1. 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
  1. 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
  1. 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:

  1. Groß- und Kleinschreibung - C ist case-sensitive
  2. 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)
  3. Leerzeilen - Erhöhen die Lesbarkeit des Quellcodes
  4. Leerzeichen - Erhöhen die Lesbarkeit des Quellcodes
  5. Einrückungen - Erhöhen die Lesbarkeit des Quellcodes
  6. Klammern - Begrenzen Blöcke von Anweisungen (beginnen mit { und enden mit })
  7. 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;
}