Aus RN-Wissen.de
Version vom 9. Dezember 2005, 11:42 Uhr von PicNick (Diskussion | Beiträge) (Unterschiede zu anderen Sprachen)

Wechseln zu: Navigation, Suche
LiFePO4 Speicher Test

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.

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.

Asm Flow.jpg

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

PreCompiler Anweisungen

Das sind Anweisungen an den Assembler selbst und erzeugen keine Maschinenbefehle. Das ob. Source-Fragment hat auch gleich mit sowas begonnen

.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" hat als Kennzeichen für den Precompiler ein "#" 
und andere Schlüsselwörter. Zum allgemeinen Verständnis ist das aber vorerst egal.

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.

Asm cmd.jpg

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.

.DSEG           ; Das ist eine Datensegment, d.h. es werden SRAM-Adressen festgelegt
.ORG 0x60       ; Ab der Adresse 0x60  (dieser Wert ist obligat)
CharBuf: 	.byte 24
OtherData: 	.byte 4 
....     

Auf diese Weise wird definiert, das unter dem Text "CharBuf" die Byte-Adresse hex 60 (d.i. 96 dezimal) verwendet wird. 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 0x78 (dez. 120) verknüpft.

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




Autor: PicNick


LiFePO4 Speicher Test