Inhaltsverzeichnis
AVR Assembler Einführung
Es gibt mehrere Gründe, sich mit dem AVR-Assembler zu beschäftigen:
- reines Interesse
- Man hat eben keinen anderen Compiler, Bascom kostet ja was und GCC-C kann man möglicherweise genausowenig, also warum nicht gleich.
- Endlich Programmieren ohne Sprach-Restriktionen
- (theoretisch) sagenhaft schneller und kurzer Code
Dadurch ergeben sich aber auch verschiedene Haupt-Zielgruppen für eine Einführung. Die eine ist in einer oder mehreren anderen Sprachen durchaus versiert, und möchte endlich auch mal richtig in die Tiefen der Hardware steigen. Die andere ist totaler Neueinsteiger und hat gehört, daß man Micro-Controller am besten mit Assembler programmiert.
Es soll hier nur eine Starthilfe zum Einstieg gegeben werden. Viele Details und Varianten werden hier bei weitem nicht erschöpfend behandelt.
Unterschiede zu anderen Sprachen
Bildhaft ist der Unterschied der: Beim Assembler hat man ein weißes Stück Papier (ohne Linien) und ein Alphabet von A-Z. Aus diesen Buchstaben soll man nun erstmal Worte suchen und dann damit einen sinnvollen Text verfassen.
Bei "höheren" Sprachen ist das Papier zumindest mal liniert, und dazu kriegt man auch ein Wörtebuch und die Grammatik. Teile des Textes sind schon vorgeschrieben und ich muß nurmehr bei den ..... Punkten was einsetzen.
- Daten
Höherer Sprachen kennen alle möglichen Datentypen in allen möglichen Längen, integer, floating point, Strings. Dadurch sind aber auch gleich die möglichen und zulässigen Operationen schon eingeschränkt.
Beim (AVR) Assembler gibt auch einige Datentypen. Sie unterscheiden sich aber nur durch die Größe, d.h. die Anzahl der Bytes, die sie belegen. Es gibt aber keine Einschränkungen bezüglich der Befehle, die man darauf anwenden kann.
- Vor- und Nachteile
Wenn man den Assembler mit höheren Sprachen vergleicht, um vielleicht Vor- und Nachteile herausarbeiten zu können, kommt man schnell drauf, daß in jedem Vorteil für eine der Seiten auch schon der Nachteil drinnen steckt. Was ich beim Assembler an Performance heraushole, weil ich z.B. "Goodies" eines Controllers ausnutze, bezahle ich, wenn ich mir einen anderen kaufe und dann das ganze Programm umbauen muß.
Struktur eines AVR-Maschinenprogrammes
Bei höheren Sprachen wird diese Struktur vom Compiler/Linker erzeugt, beim Assembler muß man sich selbst darum kümmern.
Der Controller beginnt mit der Abarbeitung links oben bei der Programmspeicheradresse 0000. Wenn man Interrupts verwenden will, muß man gleich das nächste Bereich (ISR-Vectoren) überspringen (mit "JMP"). Wenn nicht, kann man sich diese Vektoren aber auch wegdenken.
Man landet so oder so bei den Befehlen, die der Initialisierung dienen (Setzen der Startbedingung). Das ist zumindest die Festlegung des Stack-Pointers, sonst kann man keine "CALLS" oder Interrupts durchführen.
Danach kommt man von oben in die Haupt-Programm-Schleife, die (üblicherweise) immer wieder ohne Ende durchlaufen wird.
Das "End" ist bei Microcontrollern daher auch kaum wirklich notwendig. Wenn es da ist, ist das eine ewige Wiederholung eines einzigen Befehls.
Source-Code Muster
Das folgende Codebeispiel ist eine reine Basis-Initialisierung ohne irgendeine erkennbare Funktion. Für den Einstieg ist es am besten, das einfach abzuschreiben, wie es ist, und dann an den bezeichnenten Stellen mit eigenen Befehlen nach und nach zu erweitern.
.NOLIST ; List-Output unterdrücken .INCLUDE <m8def.inc> ; das gibt es für jeden Controllertyp .LIST ; List-Output wieder aufdrehen .CSEG ; was nun folgt, gehört in den FLASH-Speicher ;------------------------------------------------------ ; Start Adresse 0000 ;------------------------------------------------------ RESET: jmp INIT ; springen nach "INIT" ;------------------------------------------------------ ; ISR VECTORS ;------------------------------------------------------ ; ..... hier kommen dann die Sprungadressen für die Interrupts rein ; dazu kommen wir noch .ORG INT_VECTORS_SIZE ; dadurch haben wir für die Vektoren Platz gelassen INIT: ;------------------------------------------------------ ; INITIALIZE ;------------------------------------------------------ ldi r24,high(RAMEND) ;Stack Pointer setzen out SPH,r24 ; "RAMEND" ist in m8def.inc (s.o.) festgelegt ldi r24,low(RAMEND) ; out SPL,r24 ; ;------------------------------------------------------ ; eigene Initialisierungen ;------------------------------------------------------ ;.... ;.... ;.... ;------------------------------------------------------ ; HAUPTSCHLEIFE ;------------------------------------------------------ Hauptschleife: ;.... eigene befehle ;.... eigene befehle ;.... eigene befehle rjmp Hauptschleife ; immer wiederholen ;------------------------------------------------------ ; ENDE ;------------------------------------------------------ Ende: rjmp Ende
Der Ablauf der Übersetzung
Der Assembler ist ja selbst auch nur ein Programm und zu Beginn recht spartanisch ausgestattet: Er hat einen Daten-Buffer zur Verfügung, in den er den Maschinencode hineinschreiben soll, einen Pointer (Zeiger) dazu, damit er immer weiß, wo er grad ist, und eine allgemeine Tabelle, wo er Texte mit Zahlen verknüpfen kann. Alles ist zu Beginn leer bzw. auf NULL. Mit diesen Voraussetzungen fängt er an, unseren Source-Text zeilenweise zu lesen. Und in jeder Zeile (die nicht leer ist) erwartet er eine Anweisung, was zu tun ist
Assembler Direktiven/Präprozessor
Das sind Anweisungen an den Assembler selbst und erzeugen keine Maschinenbefehle. Im obigen Source-Beispiel sind einige enthalten.
.NOLIST
Normalerweise schreibt der Assembler ja eine Übersetzungsliste. Nach dieser Anweisung läßt er das bleiben
.INCLUDE <m8def.inc> ; das gibt es für jeden Controllertyp
sagt ihm, er soll jetzt erstmal die (text) datei "m8def.inc" lesen und genauso behandeln, als wäre diese Datei mit dem Editor hier reinkopiert worden.
.LIST
Ab nun wird wieder alles in der Übersetzungsliste protokolliert.
.CSEG
was danach folgt, bezieht sich auf den FLASH-Programm-Speicher
Gleich ein Hinweis: Der "AVR Assembler 2" geht dazu über, die C-Syntax für Präprozessoranweisungen zu verwenden. Diese haben dann als Kennzeichen ein "#" vorangestellt.
Bemerkungen / Kommentare
;------------------------------------------------------
Alles, was NACH einem Strichpunkt steht, schreibt er einfach nur in die Übersetzungsliste, sonst macht er nix.
Label (Sprungmarke)
RESET:
Was gleich ganz links in der Zeile beginnt (und mit einem Doppelpunkt abgeschlossen ist) schreibt er einfach in die genannte Tabelle und den momentanen Wert des Pointers dazu. Sonst hat das im Moment keine weitere Bedeutung. Natürlich, wenn er den gleichen Text schon mal hatte, gibt es eine Fehlermeldung, denn ein Label muss eindeutig sein
Commands
jmp INIT ; springen nach "INIT"
Alles, was nicht ganz links steht und auch nix anderes ist, betrachtet der Assembler als "Command". Normalerweise ist das eben eine AVR-Instruktion aus dem "Instruction Set". Hier ist das "JMP", also im Programm einfach woanders weitermachen. Gut, aber wo ? Der Rechner braucht nun unbedingt eine Zahl, denn "INIT" sagt ihm ja nichts. Also sieht er nun in der Tabelle nach, ob er den Text "INIT" schon mal hatte. Hatte er nicht, den zu der Stelle weiter hinten, wo "INIT:" als Sprungmarke steht (s.o) ist er ja noch garnicht gekommen.
Ich kann leider nicht genau sagen, welche Strategie der AVR-Assembler nun anwendet, dazu gibt es keine Doku. Manche Assembler lesen erstmal überhaupt nur den Sourcetext von vorn bis hinten durch, um alle "Sprungmarken" zu sammeln (Pass I) und fangen dann nochmal von vorne an (Pass II), diesmal mit schon gefüllter Tabelle. Andere machen das etwas gefinkelter. Ist uns aber eigentlich egal.
Jedenfalls findet er letztlich den Text "INIT" und daneben steht die Nummer der Speicherstelle, wo wir eben "INIT:" hingeschrieben haben. Und nun schreibt der Assembler also den Maschinencode für "JMP" in den Buffer und dazu die Zahl aus der Tabelle. Das war mal ein Befehl, also addiert er auf seinen Zeiger eins drauf und ist mit diesem Befehl fertig.
ORG / JMP
Das, was für den Controller beim Ablauf dann ein "JMP" ist, ist das ".ORG" für den Compiler. Wenn also bislang der Assembler den FLASH-Speicher von 0 - nn mit Befehlen befüllt hat, macht er durch
.ORG INT_VECTORS_SIZE
mit der Stelle weiter, die er über die gleiche Tabelle unter "INT_VECTORS_SIZE" gefunden hat. Dieser Tabelleneintrag, genauso wie "RAMEND" an anderer Stelle, entsteht aus der Datei "m8def.inc". Wie im vorliegenen Fall macht man diese "Assembler-Jumps", um Speicherstellen freizuhalten.
Merkwürdiges
Befehlsadressen / Byteadressen
Ein Maschinenbefehl besteht aus maximal drei, bit-mäßig verschachtelten Teilen. Die meisten Befehle brauchen dafür genau 16 Bit (also zwei Byte), manchmal reicht das nicht, da gehören dann nochmal 16 Bit dazu. Kleiner als 16 Bit ist aber keiner. Aus diesem Grund wäre es natürlich sinnlos, Adressen für Befehle in Bytes auszudrücken, wenn es eh immer Vielfache von 2 sein müssen, das kleinste Bit einer solchen Adresse wäre ja immer null.
Also adressiert man Commands in "Worten" von 16 - Bit bzw. 2 Bytes
Beim Ablegen eines Labels (Sprungmarke) werden also Wortadressen in der Tabelle angelegt. Solange der Wert auch als Befehls-Adresse verwendet wird (z.B. "JMP Label"), braucht das nicht zu kümmern, denn genauso will es der AVR ja dann auch haben. Wenn dort aber Daten stehen und wir wollen diese adressieren, müssen wird den Wert mal 2 nehmen (oder einmal nach links schieben, was dasselbe ist), sonst landen wir im Nirwana.
3 ist nicht gleich 3
Eine Zahl bedeutet also bei der Maschinensprache unter Umständen ganz was anderes, je nachdem, in welchem Zusammenhang sie verwendet wird. Bei allen Befehlen, die nur für die Register 16 - 31 gehen, steht im Maschinencode für das Register 16 nicht 16 drin, sondern 0. die "16" addiert er dann bei der Befehlsausführung drauf (für den Techniker: er braucht ja nur das Bit 24 hochzuziehen)
GPR ist nicht gleich GPR
Was auch beim Einstieg leicht irritieren kann ist der Umstand, daß die 32 sogenannten "General Purpose Register" irgendwie so gar nicht generell und gleich sind. Manche Befehle kann ich mit allen 32 Registern machen, einige nur mit R16-R31, andere von R24 aufwärts und dann sind da noch R26 bis R31, die heissen dann manchmal auch noch X, Y und Z
SFR ist gar nicht gleich GPR
Special Function Register sind überhaupt seltsam, die meisten GPR-Befehle gehen da gleich garnicht, dafür gibt es andererseits ein paar Bit-Manipulationen, die gehen wiederum nur mit diesen. Dann gibt es auch Bit-Abfragen, die gehen da wie dort und bewirken auch das Gleiche, aber dafür heissen sie dann anders.
Assembler-Befehle
Viele dieser Probleme nimmt uns der Assembler ab, viele aber nicht. Dort erscheinen die Instruktionen immer so
CMD | ARG1 | ARG2 |
wobei die Argumente 1 u. 2 optionell, d.h. bei Bedarf erscheinen. Command ist natürlich immer da. Trotz der Hilfe des Assemblers durch "Warnings" und "Errors" ist bei den Argumenten aber immer Vorsicht geboten. Wir haben gehört, daß Arg 1 und Arg 2 im Grunde einfach nur zwei Zahlen sind, die dann zusammen mit dem Command-Code zum Maschinenbefehl werden. Wenn es dem Assembler also auch nur irgendwie gelingt, aus dem, was wir da hinschreiben, Zahlen zu machen die er umwandeln kann, nimmt er sie auch.
Zwischenbilianz
Einiges dieser sonderbaren Dinge lassen sich leicht erklären, manche schwer, manche mangels Unterlagen darüber garnicht. Deswegen versuch ich es hier auch gleich garnicht.
So oder so sind es Fakten, an die man sich am besten einfach gewöhnt. Das scheint und ist am Anfang sicher schwer, solange man noch mit den Befehlen einzeln kämpft. RISC-Befehle werden aber erst in bestimmten Gruppierungen überhaupt wirklich sinnvoll, und durch diese sich immer wieder wiederholenden Gruppen wird es sehr schnell leichter.
Beim Briefe schreiben denkt man ja auch nicht in Buchstaben, das wäre mühsam, sondern in Worten oder in ganzen Sätzen.
Load & Store
Die meisten Befehle für den AVR laufen nach dem Load & Store (Laden und Speichern) Prinzip ab. Das heißt, die Daten werden zuerst aus dem SRAM-Speicher (oder den I/O bzw. SFR Registern) in die allgemeinen Register (GPR) geladen, dort verarbeitet, und danach wieder in den SRAM oder die SFR zurückgestellt.
Das ist natürlich eine recht allgemeine Darstellung, aber es hilft, das Instruction Set des AVR schon ein wenig besser zu verstehen.
Es bedeutet, um z.B. ein Byte aus der einen Ecke des SRAM in eine andere zu kopieren, brauchen wir wenigstens 2 Maschineninstruktionen, einmal Load und einmal Store. Und wenn wir was an dem Byte ändern wollen, kommt diese Befehle natürlich dazu.
Es gibt zu (fast) jeder Art "Load"-Befehl ein Äquivalent als "Store", und zum Finden genügt es meist, statt "L" an der selben Stelle ein "S" zu nehmen.
LDS GPR, adresse ; lade das Byte von der Adresse in ein GPR STS adresse, GPR ; speichere den Inhalt von GPR in der Adresse
Wenn es um die I/O Register (SFR) geht:
IN GPR, I/O-Register ; GPR <-----I/O Register OUT I/O-Register, GPR ; I/O Register <---- GPR
Für feste Werte (Literals) gibt es natürlich keine Store-Variante, denn diese Werte stehen ja im FLASH (Programm) Speicher, und da kann man nicht einfach einzelne Bytes verändern.
LDI GPR, wert
Beispiel
An PORTC haben wir (mit Vorwiderständen) ein paar LED angeschlossen. Und die sollen einfach nur zeigen, welche 0-er und 1-er am PORTB anliegen.
; Eigene Initialisierungen LDI R24, 0 ; der wert "0" ins allgemeine Register R24 OUT DDRB, R24 ; das kommt ins Controll-register f. PortB ; dadurch sind diese PINs als INPUT festgelegt LDI R24, 255 ; der wert "255" = FF ins allgemeine Register R24 OUT DDRC, R24 ; das kommt ins Controll-register f. PortC ; dadurch ist das Port auf OUTPUT gestellt
; Eigene Befehle (in der Hauptschleife) IN R24, PINB ; die Bits vom PortB ins allgemeine Register R24 OUT PORTC, r24 ; schreiben wir nach PortC ; wo ein 1-er ist, leuchtet nun die LED
Wenn wir diese Befehle in das Programm-Schema oben einfügen, haben wir also das erste Assemblerprogramm schon geschrieben.
Pointer-Register
In vielen Fällen ist es nicht sinnvoll, für Load & Store explizit jedesmal die Speicheradresse dazuschreiben zu müssen. Es können auch die Pointer Register X, Y und Z verwendet werden. Das sind die Register
R26:R27 = X R28:R29 = Y R30:R31 = Z
Da wir ja oft mehr Speicher haben als 256 Byte, werden immer zwei Register zusammengefaßt und können dadurch die Bereiche 0 - 65535 abdecken (64k). Gut, aber wie kriegen wir so große Zahlen da rein ? Wir haben ja nur einen 8-Bit Processor und nicht 16.
Wir müssen also die Adresse 8-Bit-weise übergeben, aber der Assembler hilft uns ja dabei.
LDI R26, LOW(Adresse) ; R28 für "Y" u. R30 für "Z" LDI R27, HIGH(Adresse) ; R29 für "Y" u. R31 für "Z"
Mit diesem "LOW" und "HIGH" teilt uns der Assembler die Adresse genau in die richtigen 8-Bit Portionen, die wir also so in den "X" Pointer laden können.
Noch eine Hilfe gibt es: Wir können, besser zu merken, auch schreiben:
LDI XL, LOW(Adresse) ; YL für "Y" u. ZL für "Z" LDI XH, HIGH(Adresse) ; YH für "Y" u. ZH für "Z"
Also "LOW" für "L" und "HIGH" für "H"
Indirekte Adressierung
Mit den Pointer-Register kann man also Indirekte Adressieren(=einmal um die Ecke). Statt:
LDS GPR, adresse ; lade das Byte von der Adresse in ein GPR
Schreiben wir
LDI XL, LOW(Adresse) LDI XH, HIGH(Adresse) LD GPR, X ; '''Indirekt''': lade das Byte, wo der Pointer X hinzeigt
beziehungsweise
LDI XL, LOW(Adresse) LDI XH, HIGH(Adresse) ST X, GPR ; '''Indirekt''': speichern, wo der Pointer X hinzeigt
Warum sollte man sowas kompliziertes tun ? Nun, zum Beispiel dann,
- wenn man an einer Stelle im Programm die Adresse festlegt, aber ganz woanders braucht (Subroutinen)
- wenn man für mehrere Folge-Befehle die gleiche Adresse wieder braucht
- wenn man mit der Adresse (als Zahl) rechnen will
Post-Increment/Pre-Decrement
Wenn ich zum Beispiel 4 Byte hintereinander im Speicher auf "0" löschen will
LDI XL, LOW(Adresse) LDI XH, HIGH(Adresse) LDI GPR, 0 ST X+, GPR ;speichern, wo X hinzeigt, und dann 1 auf X addieren ST X+, GPR ;das 2.Byte dahinter ST X+, GPR ;das 3.Byte dahinter ST X+, GPR ;das 4.Byte dahinter
Durch das automatische Addieren brauche ich nichts weiter zu tun. Als Gegenstück dazu kann man auch jedesmal 1 abziehen lassen, das aber immer vorher
ST -X, GPR ;1 von X abziehen und dann speichern, wo X hinzeigt
All das gibt's für X, Y und Z. Es geht aber immer nur
- Load & Store und dann addieren
- erst subtrahieren und dann Load & Store
Diese Kombinationen werden einfach am häufigsten gebraucht
Displacement
Für die beiden Pointer Y und Z kann man auch mit Displacement arbeiten
LDD GPR, Y+4 ; wo Y hinzeigt, aber 4 Byte dahinter
Welches Register ?
Bei den Load & Store Befehlen könnten als GPR alle 32 Allgemeinen Register beliebig verwendet werden, um das entsprechende Byte aufzunehmen oder abzugeben. Aber als Zwischenspeicher oder Arbeitsregister ist es am besten, Registern wie r26->r31, die möglicherweise als Pointer gebraucht werden, eher auszuweichen.
Wird das Assemblerprogramm durch inline oder libraries in eine Hochsprache eingebunden, muß man bei sich natürlich an die Register-Konventionen dieser Umgebung halten.
Schreibt man ein "stand-alone" Programm, kann (und sollte) man sich seinen eigenen Konventionen und Regeln schaffen. Das ist das, was der Assembler-Fan so liebt.
Benutzerdaten
Daten wie Variable oder Felder werden üblicherweise im SRAM abgelegt und (s.o) von dort auch geholt. Bei allen Compilern und natürlich auch beim Assembler können zum Programmierzeitpunkt nur die Adressen davon festgelegt werden, aber keine Inhalte. Erst zur Laufzeit des Programmes kommen hier Werte rein. Manche Compiler gaukeln es zwar vor, aber (ein C-Beispiel):
char MeinName[] = "Sepp Meier"
bewirkt in Wirklichkeit drei Dinge: Im SRAM wird die nächste, noch nicht benutzte Adresse dem Text "MeinName" in der oben genannten Tabelle abgelegt, der String "Sepp Meier" wird im FLASH Programmspeicher bereitgestellt, und es wird eine Transfer-Routine generiert, die beim tatsächlichen Start des fertigen Programmes diesen String in das Feld "MeinName" transferiert.
Im Assembler ist es eben genauso: Wir können durch die Anfangsadressen und einen Namen dazu Felder festlegen, die dann zur Laufzeit benützt werden. Mehr eigentlich nicht. Und erst, wenn wir Befehle schreiben, die diese Daten verwenden, ergibt sich daraus ein Datentyp. Vorher sind das einfach nur Bytes.
Das ist bei Hochsprachen anders: Schon in der Source legen wir durch verschiedene Angaben den Datentyp (char, integer, etc.) fest und daraus entscheidet dann der Compiler, welche Maschinen-Instruktionen er dafür verwenden wird. Wir können das nurmehr eingeschränkt beeinflussen.
Daten im SRAM
.DSEG ; Das ist eine Datensegment, d.h. es werden SRAM-Adressen festgelegt CharBuf: .byte 24 OtherData: .byte 4 ....
Anmerkung: Standardgemäß beginnt der Assembler mit der kleinsten SRAM Adresse. Bei den meisten AVRs ist das die tatsächliche Byte-Adresse 0x0060 (d.i. 96 dezimal). Siehe Adress-Mapping
Dadurch, daß wir geschrieben haben ".byte 24" bestimmen wir, daß als nächste Datenadresse automatisch dann 96 + 24 = 120 gilt. Also ist mit "OtherData" dann 0x0078 (dez. 120) verknüpft.
Noch eins: Wir haben ja gesagt, für den Assembler ist alles letztlich eine Zahl. Wir können daher auch damit rechnen. Wenn wir also die "24" nicht selbst wo hinschreiben wollen, verwenden wir einfach den Ausdruck
LDI R10 , Otherdata - CharBuf
und laden damit das Register 10 mit der Zahl 24
Daten im FLASH (Literale)
Literale sind Werte, die schon in der Source festgelegt werden können. Das geht im Prinzip ähnlich, nur müssen wir nun auch Datentypen festlegen
OneByte: .db 1 ; ein byte mit dem inhalt 0x01 MeinName: .db "Sepp Meier" ; mehrere Bytes mit Text OneWord: .dw 1,2 ; zwei WORDs (16 Bit) mit dem inhalt 0x0001 u. 0x0002 ....
Diese Definitionen können wir auch direkt in den Programmcode reinmischen. Wir müssen aber natürlich schauen, daß der AVR zum Laufzeitpunkt nicht versucht, diese Zeichen als Befehl auszuführen.
Daten im EEPROM (ERAM)
genauso geht es, wenn man für den EEPROM feste Werte vorgeben will:
.ESEG ; Das ist ein EEPROM-Segment OneByte: .db 1 ; ein byte mit dem inhalt 0x01 MeinName: .db "Sepp Meier" ; mehrere Bytes mit Text OneWord: .dw 1,2 ; zwei WORDs (16 Bit) mit dem inhalt 0x0001 u. 0x0002 ....
Autor: PicNick