AVR-GCC-Tutorial/Assembler und Inline-Assembler

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
Wechseln zu: Navigation, Suche

Gelegentlich erweist es sich als nützlich, C- und Assembler-Code in einer Anwendung zu nutzen. Typischerweise wird das Hauptprogramm in C verfasst und wenige, zeitkritische oder hardwarenahe Operationen in Assembler.

Die GNU-Toolchain bietet dazu zwei Möglichkeiten:

Inline-Assembler
Die Assembleranweisungen werden direkt in den C-Code integriert. Eine Quellcode-Datei enthält somit C- und Assembleranweisungen
Assembler-Dateien
Der Assemblercode befindet sich in eigenen Quellcodedateien. Diese werden vom GNU-Assembler (avr-as) zu Object-Dateien assembliert und mit den aus dem C-Code erstellten Object-Dateien zusammengelinkt.

Inline-Assembler

Inline-Assembler bietet sich an, wenn nur wenig Assembleranweisungen benötigt werden. Typische Anwendung sind kurze Codesequenzen für zeitkritische Operationen, Takt-genaue Warteschleifen oder das Einfügen spezieller Instruktionen, die nicht durch reinen C-Code eingefügt werden können.

Inline-Assembler wird mit asm oder asm volatile eingeleitet, zusätzlich gibt es das Ansi-konforme __asm__.

Die Assembler-Anweisungen werden als statisch konstanten String angegeben, der ähnlich wie bei einem printf verwendet wird: %-Platzhalter werden durch die Werte der nachfolgenden C-Ausdrücke ersetzt und die resultierende Zeichenkette in die Assembler-Ausgabe des Compiler eingefügt. Ähnlich wie bei printf gibt es auch bei Inline-Assembler Modifier, die es erlauben, Operanden in unterschiedlicher Weise darzustellen.

Die Output-Operanden folgen auf das Assembler-Template und werden von diesem durch einen Doppelpunkt getrennt. Danach folgen – wieder durch einen : getrennt – die Input-Operanden. Danach eine Clobber-Liste sowie eine Liste mit Labels.

Ein einfaches Beispiel für Inline-Assembler ist das Einfügen einer NOP-Anweisung (NOP steht für No OPeration). Dieser Assembler-Befehl benötigt auf einem AVR genau einen Taktzyklus, ansonsten hat er keinen Effekt.

    /* Verzögern der weiteren Programmausführung um 2 Taktzyklen */
    asm volatile ("nop");
    asm volatile ("nop");

Als Beispiel für mehrzeiligen Inline-Assembler dient eine präzise Delay-Funktion. Die Funktion erhält einen 16-Bit Wert als Parameter, und der Inline-Assembler dekrementiert diesen Wert so lange, bis er zu –1 unterläuft, was die Schleife beendet.

Inline-Assembler hat hier den Vorteil, dass die Laufzeit – abgesehen von Interrupts, die gegebenenfalls während der Scheifenausführung auftreten – unabhängig von der Optimierungsstufe des Compilers und der Compiler-Version ist.

static inline void __attribute__((always_inline))
delayloop16 (unsigned int count)
{
    /* Die Schleife dauert  4 * count + 3  Ticks */

    asm volatile ("1:"           "\n\t"
                  "sbiw %0,1"    "\n\t"
                  "brcc 1b"
                  : "+w" (count));
}
  • Jede Anweisung wird mit "\n\t" abgeschlossen. Der Zeilenumbruch teilt dem Assembler mit, dass ein neuer Befehl beginnt.
  • Als Sprung-Label wurde eine Ziffer verwendet. Diese speziellen Labels sind mehrfach im Code verwendbar. Dies ist notwendig, wenn delayloop16 mehrfach verwendet wird und durch Inlining mehrfach in der Assembler-Ausgabe erscheint. Gesprungen wird jeweils zurück (b) oder vorwärts (f) zum nächsten auffindbaren Label.
  • Das "+w" wird als Inline-Assembler Constraint (Nebenbedingung) bezeichnet und legt fest, wie der Compiler mit dem Ausdruck count umzugehen hat. w steht für eine bestimmte Registerklasse, nämlich die Register R24...R31: SBIW brauch eines dieser Register mit gerader Registernummer. Dass die Registernummer gerade ist, ergibt sich daraus, dass das avr-gcc ABI dies für 16-Bit Werte so vorschreibt.
Das + vor der Registerklasse besagt, dass count sowohl Eingabe als auch Ausgabe des Inline-Assembler ist und mithin vom Inline-Assembler verändert wird. Es ist wichtig, dies dem Compiler mitzuteilen, damit er count nicht an anderer Stelle wiederverwendet und damit falschen Code erzeugt.

Das Resultat zeigt ein Blick in die Assembler-Datei, die der Compiler mit der Option -save-temps nicht löscht:

 
	1:
	sbiw r24,1
	brcc 1b

Siehe auch:

Assembler-Dateien

AVR-Libc Doku: avr-libc and assembler programs
Codeerzeugung mit den GNU-Tools

Assembler-Module werden analog zu C-Modulen übersetzte: Während ein C-Modul modul.c mittels

   avr-gcc -c modul.c  (weitere Optionen)

zum Object übersetzt wird, ist der Compileraufruf für eine Assembler-Quelle modul.sx

   avr-gcc -c modul.sx  (weitere Optionen)

Durch die Endung .sx erkennt gcc, dass es sich bei der Datei um eine Assembler-Quelle handelt.

Die Quelle wird zunächst präprozessiert, d.h. man kann Direktiven wie #include und #define verwenden. Danach wird die präprozessierte Datei an den Assembler übergeben. Alternativ kann die Endung .S verwendet werden. Bei einer anderen Dateiendung kann das Quellformat mit -x auf Assembler mit C-Präprozessor eingestellt werden:

   avr-gcc -x assembler-with-cpp -c modul.asm  (weitere Optionen)

Der Unterschied zwischen .sx und .S einerseits und .s andererseits ist dass für s-Dateien kein Präprozessor aufgerufen wird. s-Dateien werden zum Beispiel temporär von gcc erzeugt wenn eine C- oder C++-Quelle übersetzt wird. Der Compiler erzeugt keine Präprozessor-Direktiven und daher kann der Aufruf den Präprozessors nach dem Compilieren gespart werden was den Übersetzungsvorgang beschleunigt.

In allen Fällen wird ein Modul modul.o erzeugt, das genauso verwendet werden kann wie ein Modul, das aus C-Code oder C++-Code erzeugt wurde. Soll die Object-Datei einen anderen Namen bekommen, dann dies mit -o dateiname erreicht werden.

Anhängig vom der verwendeten Build-Umgebung (eigenes Makefile, Mfile, Apache Ant, AVR Studio, Eclipe, Code::Blocks, Programmers Notepad, Shell-Skript, ...) wird die Assembler-Quelle zum Projekt hinzugefügt. Hierfür sei auf die Dokumentation der jeweiligen Entwicklungsumgebung verwiesen.


Verwenden der C-Runtime Umgebung

Stand-Alone Assembler

Globale Variablen für Datenaustausch

Oftmals kommt man um globale Variablen nicht herum, z. B. um den Datenaustausch zwischen Hauptprogramm und Interrupt-Routinen zu realisieren. Hierzu muss man im Assembler wissen, wo genau die Variable vom C-Compiler abgespeichert wird.

Hierzu muss die Variable, hier "zaehler" genannt, zuerst im C-Code als Global definiert werden, z. B. so:

#include <avr/io.h>

volatile uint8_t zaehler;

int16_t main (void)
{
    // irgendein Code, in dem zaehler benutzt werden kann
    
}

Im folgenden Assembler-Beispiel wird der Externe Interrupt0 verwendet, um den Zähler hochzuzählen. Es fehlen die Initialisierungen des Interrupts und die Interrupt-Freigabe, so richtig sinnvoll ist der Code auch nicht, aber er zeigt (hoffentlich) wie es geht.

Im Umgang mit Interrupt-Vektoren gilt beim GCC-Assembler das Gleiche, wie bei C: Man muss die exakte Schreibweise beachten, ansonsten wird nicht der Interrupt-Vektor angelegt, sondern eine neue Funktion - und man wundert sich, dass nichts funktionert (vgl. das AVR-GCC-Handbuch).

 
#include <avr/io.h>

temp = 16

.extern zaehler

.global INT0_vect
INT0_vect:

     push temp                      //; wichtig: Benutzte Register und das
     in temp,_SFR_IO_ADDR(SREG)     //; Status-Register (SREG) sichern!
     push temp

     lds temp,zaehler               //; Wert aus dem Speicher lesen
     inc temp                       //; bearbeiten
     sts zaehler,temp               //; und wieder zurückschreiben

     pop temp                       //; die benutzten Register wiederherstellen
     out _SFR_IO_ADDR(SREG),temp
     pop temp
     reti

.end

Globale Variablen im Assemblerfile anlegen

Alternativ können Variablen aber auch im Assemblerfile angelegt werden. Dadurch kann auf eine .c-Datei verzichtet werden. Für das obige Beispiel könnte der Quelltext dann die Dateien zaehl_asm.S und zaehl_asm.h abgelegt werden, so dass nur noch zaehl_asm.S mit kompiliert werden müsste.

Anstatt im Assemblerfile über das Schlüsselwort .extern auf eine vorhandene Variable zu verweisen, wird dazu mit dem Schlüsselwort .comm die benötigte Anzahl von Bytes für eine Variable reserviert.

zaehl_asm.S

 
#include <avr/io.h>

temp = 16

//; 1 Byte im RAM für den Zähler reservieren
.comm zaehler, 1

.global INT0_vect
INT0_vect:

...

In der Headerdatei wird dann auf die Variable nur noch verwiesen (Schlüsselwort extern):

zaehl_asm.h

#ifndef ZAEHL_ASM_H
#define ZAEHL_ASM_H
// Funktionsdeklaration in C++, z.B. für Arduino
#ifdef __cplusplus
    extern "C" {
#endif

extern volatile uint8_t zaehler;
uint8_t test(uint16_t parameter1, char *ptr);    

// Abschluss der Funktionsdeklaration in C++, z.B. für Arduino
#ifdef __cplusplus
    }
#endif
#endif

Im Gegensatz zu globalen Variablen in C werden so angelegte Variablen nicht automatisch mit dem Wert 0 initialisiert. Deshalb ist es meist besser, die globalen Variablen im C-Code zu definieren, dort kann man sie einfacher verwalten. Sie werden dann auch korrekt initialisiert, sei es mit 0 oder einem spezifischen Wert. Assemblerfunktionen brauchen natürlich auch eine Deklaration im Header, damit der Compiler weiß, wie der Aufruf aussieht. Für Interruptroutinen braucht man sie nicht, dort wird nur die Adresse in die ISR-Vektortabelle eingetragen. Dafür muss nur das Label per .global Direktive im Quelltext der .S Datei dem Linker mitgeteilt werden. Mit den beiden #ifdef __cplusplus Blöcken am Anfang und Ende ist die Headerdatei automatisch auch für einen C++ Compiler verdaulich, wie er z.B. beim Arduino genutzt wird. Wenn man nur mit einem C-Compiler arbeitet, kann man sie weglassen.

Variablen größer als 1 Byte

Variablen, die größer als ein Byte sind, können in Assembler auf ähnliche Art angesprochen werden. Hierzu müssen nur genug Bytes angefordert werden, um die Variable aufzunehmen. Soll z. B. für den Zähler eine Variable vom Typ unsigned long, also uint32_t verwendet werden, so müssen 4 Bytes reserviert werden:

 
...
// 4 Byte im RAM für den Zähler reservieren
.comm zaehler, 4
...

Die dazugehörige Deklaration im Headerfile wäre dann:

...
extern volatile uint32_t zaehler;
...

Bei Variablen, die größer als ein Byte sind, werden die Werte beginnend mit dem niederwertigsten Byte im RAM abgelegt. Das folgende Codeschnippsel zeigt, wie unter Assembler auf die einzelnen Bytes zugegriffen werden kann. Dazu wird im Interrupt nun ein 32-Bit Zähler erhöht:

 
#include <avr/io.h>

temp = 16

// 4 Byte im RAM für den Zähler reservieren
.comm zaehler, 4

.global INT0_vect
INT0_vect:

     push temp                      // wichtig: Benutzte Register und das
     in temp,_SFR_IO_ADDR(SREG)     // Status-Register (SREG) sichern !
     push temp

     // 32-Bit-Zähler incrementieren
     lds temp, (zaehler + 0)        // 0. Byte (niederwertigstes Byte)
     inc temp
     sts (zaehler + 0), temp
     brne RAUS
	
     lds temp, (zaehler + 1)        // 1. Byte
     inc temp
     sts (zaehler + 1), temp
     brne RAUS

     lds temp, (zaehler + 2)        // 2. Byte
     inc temp
     sts (zaehler + 2), temp
     brne RAUS

     lds temp, (zaehler + 3)        // 3. Byte (höchstwertigstes Byte)
     inc temp
     sts (zaehler + 3), temp
     brne RAUS
	
RAUS:
     pop temp                       // die benutzten Register wiederherstellen
     out _SFR_IO_ADDR(SREG),temp
     pop temp
     reti

.end

Todo

  • 16-Bit / 32-Bit Variablen, Zugriff auf Arrays (Strings)

Weblinks

Englisch

avr-libc-Manual
GCC-Manual
Sonstiges