Pfad: Home => AVR-Überblick => Absolute Beginner => Allererste Schritte
AVR-ICs

AVR-Assembler lernen - die allerersten Schritte

Es gibt schon viele kurze und lange Anleitungen zu AVR-Assembler. Das folgende wendet sich an absolute Laien und soll die allerersten Schritte beim Lernen erleichtern.

1. Vergessen Sie erst mal alles, was sie bislang über Programmiersprachen wissen

Wenn Sie schon irgendetwas über Programmiersprachen wie C, diverse Basic-Spielarten, Java, oder was auch immer wissen: legen Sie alles beiseite, was Sie darüber wissen. Es macht hier keinen Sinn, steht Ihnen beim Lernen im Weg herum und behindert Sie ganz ernsthaft. Beherzigen Sie also eine Grundregel der Pädagogik und des Alltagswissens: Lernen von Neuem beginnt immer mit der gründlichen Zerstörung von altem, schon Gewusstem. Auch wenn es weh tut: Wenn Sie das nicht machen, lernen Sie nix und enden mit so typischen Sätzen wie "Warum kann man das nicht so machen wie ich es schon kenne?" oder auch "Das ist mir zu kompliziert, das lerne ich nie!". Das schon Gewusste steht Ihnen echt im Weg herum und blockiert Sie.

1.1 Unser gewohnter Umgang mit Programmiersprachen

Ebenen eines Computers Warum ist das so? Assembler ist ganz, ganz anders als alle anderen Programmiersprachen und alle anderen Anwendungen, die auf Rechnern laufen.

So sehen die verschiedenen Ebenen aus, aus denen so ein üblicher Computer besteht und der unser Bild und unsere Sichtweise bestimmt. In der Mitte schwebt der eigentliche Prozessor, der zwar die ganze Arbeit macht, aber so gut wie gar nicht als solcher wahrgenommen wird. Von dem weiß ich eigentlich nix, außer dass er AMD-64-Bit-Irgendwie heißt. Ich sehe nur die äußerste Schicht: ich starte ein OpenOffice und schreibe damit Briefe oder male Grafiken oder konstruiere Tabellen, ich starte GIMP und bearbeite das Foto einer Dampfmaschine oder ich öffne einen Firefox-Browser und surfe zum SV-Darmstadt-98-Fanradio. Jeder dieser Schritte hat viele Millionen Einzelschritte im Prozessor ausgelöst, die ich aber weder gesehen noch irgendwie bemerkt habe oder auch nur im Ansatz verstanden habe. Wenn vor der Ausführung jedes Prozessorbefehls eine Abfrage käme, ob ich das auch wirklich tun will, würde mir der Prozessor bewusster werden. Aber dann wäre das Spiel Darmstadt-98 gegen Borussia Dortmund lange herum, bis mein Fanradio dann doch eine Woche später den Endstand anzeigt (11.2.2017: 2:1).

Schon die Schicht, die unter meinen Anwendungsprogrammen werkelt, das Betriebssystem des Computers, ist mir herzlich egal. Ob das nun ein Windows, ein Linux oder irgendetwas anderes ist, braucht mich als User gar nicht interessieren. Es sei denn, mein OpenOffice gibt es für dieses Betriebssystem gar nicht. Oder mein WLAN-Chip wird vom Betriebssystem nicht erkannt. Dann ist es Essig mit Surfen und ich muss in die Untiefen des Betriebssystems eintauchen, um das Problem zu lösen.

Und mein Verständnis reicht schon gar nicht tiefer hinab bis zum Prozessor. Der kann mir sowas von egal sein.

Was mache ich nun, wenn ich irgendwas z. B. in Java programmiere? Ich schreibe ein paar Programmzeilen, die das machen sollen, was ich brauche. Die werden dann kompiliert, oder vom Firefox interpretiert wenn es JavaScript war, und mein Browser macht dann, was ich ihm mit den Programmzeilen gesagt habe. Und malt ein buntes Viereck im Firefox auf den Bildschirm.

Wie geht das vor sich? Beim Kompilieren erzeugt der Compiler eine wilde Mischung aus Prozessoraktionen, Betriebssystemaufrufen und Treiberaktionen, um das Gewünschte zu realisieren. Wie er die Aufgabe löst, ist wieder total intransparent und interessiert uns auch nicht weiter. Obwohl wir programmiert haben, bleiben wir weiterhin Dumm-User, weil wir von dem, was da im Innern des Rechners dabei abgeht, rein gar nix raffen (und das auch gar nicht wissen und verstehen müssen). Es reicht, wenn es tut was es soll.

Programmieren lernen heißt hier also: unverstandenes Zeugs zusammenschreiben, das in Millionen unverstandenen Zwischenschritten letztlich zur gewünschten Aktion führt. Und wenn das nicht klappt, probieren wir solange rum bis es irgendwie hinhaut. Das ist ein Reiz-Reaktions-Schema ohne jeden Verstand oder auch eine Blackbox in edelster Verpackung. Genauso gut könnte man Würfeln als Schulfach einführen und es zur besseren Tarnung als "Angewandte Stochastik" aufhübschen.

Wenn wir in einer Hochsprache wie z. B. Pascal eine Programmzeile hinschreiben wie
C := A + B;

dann addiert der Rechner halt zwei Zahlen, die irgendwo in zwei Speicherbereichen herumstehen, die wir A und B genannt haben. Das Ergebnis kommt in einen Speicherbereich, den wir C genannt haben. Wo diese Speicherbereiche physisch im Speicher herumstehen, haben wir dem Compiler überlassen, der das für uns entschieden hat. Wissen tun wir es nicht, wir brauchen uns aber gar nicht damit herumschlagen, solange das Ergebnis in C korrekt ist. Und es wäre auch gar nicht so einfach, das herauszufinden. Und es wäre auch irgendwie "Heute hier, morgen da!", weil darüber auch noch andere darüber mitentscheiden (das Betriebssystem und seine Speicherverwaltung). Alles Entscheidungen, um die wir uns rein gar nicht kümmern müssen (und es auch gar nicht können).

Damit der Compiler weiß, wie groß er die Speicherbereiche für A, B und C machen muss, muss man ihm vorher mitteilen, von welchem Typ diese drei Zahlen sind. Irgendwo im Quellcode steht daher eine Zeile von der Art
Var A,B,C: Integer;, oder
Var A,B,C:Real;

Daraus entscheidet der Compiler dann für uns, wo die Speicherbereiche hinkommen und wie groß sie sind und welche Einzelschritte beim Addieren zu erledigen sind. Jedenfalls nicht so, dass wir wüssten, was da jetzt abgeht.

Nicht so bei Assembler: alle diese Entscheidungen, die wir so gerne der Maschinerie des Compilers überlassen, müssen wir hier selber treffen. Und alle Einzelschrittchen, die zum Addieren zweier Zahlen nötig sind, müssen wir auch selber hinschreiben. Das ist zwar mühsamer, aber im Endergebnis wissen wir dafür auch ganz genau, was da abgeht, weil wir es selber erfunden haben. Das ist das gänzlich Neue an Assembler, das wir lernen müssen: jedes Fitzelchen an Entscheidung, und sogar genau den Platz, den wir für das Speichern der drei Zahlen benutzen, müssen wir selber festlegen, das nimmt uns kein Compiler ab. Dafür redet uns auch rein gar nix in unsere Entscheidungen rein: Assembler macht uns zum Herren von allem und jedem - zum Preis von etwas mehr Aufwand.

1.2 Mikrocontroller

ATmega16 ATmega8 ATtiny12 Beim Mikrocontroller ist eigentlich alles ganz anders als beim normalen Computer: ein nackiger Chip mit mehr oder wenigen Anschlusspins, dem fehlt das ganze Zeugs außen drumherum. Nur ein bisschen Speicher, gar kein Betriebssystem, und schon gar keine Anwenderprogramme. Und deshalb müssen wir an den mit Bits und Bytes ran, der versteht gar nix anderes als diese Ursprache, die schon die ersten Zuse-Rechner beherrschten.

Aufbau eines Mikrocontrollers Das hier ist nun die Innerei eines nackigen Mikrocontrollers. Der hat zwar einiges an Hardware, aber weder ein Betriebssystem (und entsprechende Treiber für die Hardware) noch eine Tastatur oder einen Bildschirm. Dafür hat er aber
  1. einen Flashspeicher für das ablaufende Programm, und
  2. eingebauten flüchtigen Speicher (der sich leert, wenn die Betriebsspannung ausgeht, oder besser: sich mit lauter Einsen füllt), und
  3. einen nichtflüchtigen Speicher EEPROM, der seinen Inhalt auch beim Abschalten der Betriebsspannung behält, und
  4. jede Menge weitere Hardware wie Zeitgeber (Timer) und Zähler (Counter), AD-Wandler, u.v.a.m., und vor allem
  5. eine ziemlich große Anzahl an Pins, die vom Programm als Ein- oder Ausgang geschaltet werden können und über die Informationen in Form von Bits ein- oder ausgegeben werden können.
Das Gebilde versteht aber rein gar nix anderes als Nullen und Einsen. In seinem Flashspeicher stehen nach dem Programmieren solche Nullen und Einsen herum und werden als Befehle oder auch als Daten interpretiert. Auch im Speicher oder im EEPROM stehen nur Nullen und Einsen, und alle andere Hardware versteht auch nur Nullen und Einsen. Außer die AD-Wandler-Eingänge, die verstehen auch angelegte analoge Spannungen und wandeln sie in Nullen und Einsen um (wenn wir es ihnen mit der richtigen Abfolge von Nullen und Einsen sagen).

Damit ist jetzt schon klar: wer mit diesen Mikrocontrollern was anfangen will, muss Nullen und Einsen und sich in der Binärwelt auskennen. Im Gegensatz zu Java, C oder Pascal ist binär hier Pflicht und ohne das geht es einfach nicht.

Womit wir auch schon bei Assembler als Programmiersprache sind: der Prozessor versteht halt nur mal nur das, was in seiner zentralen Recheneinheit (CPU, Central Processing Unit) auch verstanden wird. Und das ist halt der mächtige, aber beschränkte Befehlssatz der AVR-CPUs. Die Binärkombination 0000.0000.0000.0000 versteht die CPU so: tue mal für einen Takt lang gar nichts. Und, wenn sie das aus dem Flashspeicher so gelesen hat, macht sie eben halt rein gar nichts und führt danach den nächsten Befehl aus.

2 Assembler als Sprache

Bekommt der Mikrocontroller Spannung und ist sein Reset auf hoher Spannung (annähernd seiner Betriebsspannung), so legt die CPU los: sie setzt ihren Adresszähler auf Null, liest die 16 Bits ein, die im Flashspeicher an dieser Stelle stehen und führt sie aus.

2.1 Befehlsbearbeitung durch die CPU

Stößt die CPU im Flashspeicher auf die Bitkombination 0000.1100.0000.0000, dann addiert sie den im Register R0 stehenden Inhalt mit sich selbst und schreibt das Resultat in das gleiche Register. Ein Register ist dabei so eine Art interner Speicherplatz für acht Bit breite Binärzahlen, auf die die CPU direkt und ohne Umschweife zugreifen und mit denen daher gerechnet werden kann. Die AVRs haben davon 32 Stück (R0 bis R31), was die allermeisten anderen CPUs dieser Welt bei weitem übertrifft. Für mich ein wichtiger Grund, gar nicht erst nach den popeligen PIC-Controllern zu schauen: die haben gerade mal nur ein einziges, und schon das Addieren zweier kleiner Zahlen ist bei denen ein Hängen und Würgen.

Nebenbei haben wir da auch schon ein paar wichtige Entscheidungen getroffen: unsere Zahl steht im Register R0 und ist acht Bit groß. Hätte uns sonst der Compiler abgenommen und uns entmündigt.

Was steht nach dem Addieren im Register R0? Nun, nur Nullen. Die Inhalte aller 32 Register wurden beim Start des Prozessors nämlich auf Null gesetzt. Und Null plus Null gibt nun mal Null. Oder binär hingeschrieben:
  0000.0000
+ 0000.0000
-----------
= 0000.0000
Echt langweilig.

Daher muss jetzt erst mal was in dieses Register rein: eine Eins. Dafür gibt es eine einfache Möglichkeit: Den Inhalt des Registers R0 (war: Null) um Eins erhöhen. Die entsprechende Bitkombination lautet: 1001.0100.0000.0011. Das zählt Eins dazu. (Die Bitkombinationen bloß nicht merken, die werden wir schnell wieder los!)

Wenn wir danach den Addierbefehl anschließen, dann passiert Folgendes:
  0000.0001
+ 0000.0001
-----------
= 0000.0010
Eins und Eins gibt zwei (dezimal), aber zwei gibt es binär nicht, deshalb wird, wie beim Addieren von Fünf und Fünf in der Dezimalwelt, ein Übertrag in die nächsthöhere Stelle geschoben. Aus der dezimalen Zwei wird binär 10.

Addieren wir die Zahl noch mal zu sich selbst, kriegen wir
  0000.0010
+ 0000.0010
-----------
= 0000.0100
Wir sehen schon: addieren mit sich selbst ist in der Binärwelt dasselbe wie einmal Links-Schieben, wobei in die freie unterste Bit-Stelle eine Null reinkommt.

Nun hat unser Programm schon drei CPU-Operationen: Erhöhen, Addieren, Addieren. Und es stellt sich die Frage: was macht der Prozessor, wenn er mit den dreien fertig ist? Nun, er läuft und läuft und läuft, sogar wenn es gar kein Programm mehr im Speicher gibt oder wenn der Speicher alle ist. Dann fängt er am Ende halt wieder bei Null an. Aber ohne dabei die Register zu löschen, das macht er nur beim Spannungsausfall!

2.2 Mnemonics

Kein Mensch kann sich 16-stellige Binärzahlen merken, wie sie die CPU versteht (außer einigen Gedächtnisgenies). Und definitiv geht einem spätestens beim Schreiben der dritten Binärzahl schon die Lust aus, so einen langwierigen Quatsch hinzuschreiben.

Um sich die länglichen Binärzahlen zu ersparen, hat man einfache Gedächtnisstützen erfunden. So heißt 1001.0100.0000.0011 einfach "inc R0", und 0000.1100.0000.0000 einfach "add R0,R0". Man sieht die Absicht: "inc" kommt vom englischen "Increase" und "add" versteht sich in beiden Sprachen von selbst. "inc" und "add" sind Mnemonics oder menschen-definierte Gedächtnisstützen. Die Angaben "R0" und "R0,R0" sind hingegen Parameter, im zweiten Fall durch ein Komma getrennt.

Das Mnemonic von Tue-nichts-Befehl lautet übrigens "nop", von englisch "no operation".

Nichts anderes ist und macht ein Assembler: er sucht aus dem Text Mnemonics und ihre Parameter und wandelt sie in die entsprechenden Binärcodes um. Füttert man die nacheinander zusammengestellten Binärcodes in den Flashspeicher, versteht das die CPU auf Anhieb und macht was sie soll. Das Zusammenstellen der Binärcodes heißt Assemblieren (von englisch "to assemble", deutsch zusammenbauen oder zusammenstellen), das Programm dazu ist ein Assembler, der umzuwandelnde Quellcode wird aber auch als vom Typ Assembler bezeichnet.

Wichtig: Nur solche Binärkombinationen haben ein Mnemonic, die auch von der CPU verstanden werden. AVR-Assembler bildet also nur das nach, was die AVR-CPU auch tatsächlich kann. Alles andere sind unknown programming objects (UPOs).

2.3 Hexadezimal statt binär

Und noch eine wesentliche Schreibvereinfachung wurde erfunden und wird in Assembler verwendet: Hexadezimalzahlen. Eine Hexadezimalziffer fasst vier Bits zu einem Zeichen zusammen, aus 8 Binärstellen werden derer zwei Hexadezimalziffern und aus 16 derer vier.

Bei binär 0000 bis 1001 ist es noch einfach: aus denen wird hexadezimal 0 bis 9. 1010 wird A, 1011 wird B, usw. bis 1111, das zu F wird. Da übliche Assembler sowieso den gesamten Text in Großbuchstaben umwandeln, ist es egal, ob a oder A bzw. f oder F geschrieben wird. Nicht egal ist es, ob 99 eine Dezimalzahl oder eine Hexadezimalzahl kennzeichnet. Deshalb kriegen Hexadezimalzahlen in Assembler vor die Zahl "0X" oder "0x", und aus 99 wird mit 0x99 dezimal 153.

Analog dazu kriegen Binärzahlen ein "0b" davor. So ist 0b101 dezimal nicht 101 sondern 5. Nur wo nix davorsteht geht der Assembler davon aus, dass die folgende Zahl dezimal ist.

2.4 Die No-No's bei Assembler - für Hochsprachenverwöhnte

Das Folgende ist in Assembler gründlich zu vergessen: Damit macht der Großteil der erlernten Konzepte und Regeln aus Hochsprachen in Assembler keinen Sinn. Sehr wohl macht es aber viel Sinn, sich über optimierte Datenstrukturen Gedanken zu machen (was kommt wo hin und in welcher Reihenfolge) und Spaghetticode (nutz- und hirnloses Herumspringen im Code) zu vermeiden. Das in Hochsprachen übliche "Erst mal anfangen, dazupfriemeln kann man immer!"-Konzept führt bei Assembler schnell zu undurchschaubaren Code- und Datenstrukturen, die nachträglich zu sortieren viel aufwändiger ist als es von vornherein systematisch zu planen.

3 Simulieren des Programmablaufs

Um das erste Assembler-Programm zu schreiben, starten wir Notepad (im Windows auch Editor genannt) oder Wordpad (beides im Startmenue unter Zubehör) oder unter Linux mit KWrite oder einem anderen einfachen Editor. Wir tippen die folgenden Zeilen ein:

.nolist
.include "tn13def.inc"
.list
  inc R0
  add R0,R0
  add R0,R0

Die erzeugte Datei speichern wir in einem Ordner mit einem aussagekräftigen Namen, z. B. im Verzeichnis "avr-assembler/code1/" als Datei "code1.asm".

3.1 Assemblieren

Zum Assemblieren dieser Assembler-Quellcode-Datei kann man jeden AVR-Assembler einsetzen, z. B. meinen eigenen, gavrasm. Den gibt es hier zum kostenlosen Herunterladen, fertig kompiliert für 64-Bit-Linux oder -Windows.

Kommandozeile mit CD Hat man die ausführbare Datei "gavrasm" (Linux, als ausführbar markieren) oder "gavrasm.exe" (Windows) im gleichen Verzeichnis abgelegt, kann man eine Kommandozeile starten (in Linux unter System und Konsole, in Windows unter Start-Programme- Zubehör-Eingabeaufforderung). In die tippt man zunächst ein, wohin man navigieren möchte (in den Ordner mit gavrasm und der Quellcode-Datei, mit CD "[Ganzer Pfadname]"). Dann startet man gavrasm (in Linux mit ./gavrasm -seb code1.asm, in Windows mit gavrasm.exe -seb code1.asm).

Assembler bei der Arbeit Nun sollte der Assembler sein Werk verrichten und mit Meldungen glänzen.

Die wichtigste davon ist: "keine Fehler". Die Warnung weiter oben können wir ignorieren.

DIR mit neuen Dateien Wenn wir nun mit DIR (Linux: ls) den Verzeichnisinhalt auflisten lassen, sehen wir zwei neue Dateien:
  1. Code1.hex: das ist die Datei, deren Inhalt in den Flashspeicher gebrannt werden muss, damit die CPU das dann ausführt, und
  2. Code1.lst: das ist die Datei, mit der der Assembler seine Assemblierarbeit kommentiert.


Assembler-Listing Die zweite der beiden Dateien sieht so im Editor so aus.

Die fünfte Codezeile (inc R0) wurde vom Assembler mit hexadezimal 9403 an der Adresse 000000 übersetzt. Die sechste und die siebte Zeile (beide add R0,R0) wurde in hexadezimal 0C00 an den Adressen 1 und 2 übersetzt. Aus nichts anderem besteht auch die erste, die Hex-Datei. Aber in einem etwas eigenartigen Format (in Intel-Hex).

Außerdem hat er noch eine Liste der Symbole ausgeworfen, in der aber nichts arg sinnvolles drinsteht außer der AVR-Typ.

In Kürze also und zum Merken: Ein Assembler übersetzt Assembler-Mnemonics in Binärcode für den Mikrocontroller, damit der genau nur das kriegt, was er auch versteht, nämlich binären Code.

3.2 Der Code im Simulator

Und jetzt alles wieder schnell vergessen: das mit der Kommandozeile braucht man nämlich gar nicht. Und ist auch viel zu umständlich. Es geht viel komfortabler, nachdem ich im vergangenen Jahr den Simulator avr_sim gebastelt habe. Und auch den gibt es kostenlos, nämlich hier. Als ausführbare Lin64- oder Win64-Datei.

AVR-Assembler-Simulator avr_sim Nach dem Auspacken und dem Starten von avr_sim wird man erst mal nach dem Standardpfad für Quellcode-Dateien gefragt (woraufhin wir zum Ordner avr-assembler navigieren). Aus dem Menü Project wählen wir "New from asm" und navigieren zur Datei "code1.asm".

avr_sim Typauswahl Jetzt nervt avr_sim mit der Frage, in welcher der drei Verpackungen der Prozessor ATtiny13 denn simuliert werden soll. Nachdem wir das mit Ok quittiert haben, sehen wir unseren Assembler-Quellcode im Editor vom Simulator.

Editor im avr_sim Der Inhalt der Datei taucht jetzt im Editor auf, und wir können nach Herzenslust darin herumfummeln und Codezeilen einfügen oder löschen.

Im linken Projektfenster gibt es allerhand Interessantes zum Projekt und zu den Innereien des ATtiny13. So erfahren wir, dass er mit 1,2 MHz getaktet ist (ja, er liest pro Sekunde 1,2 Millionen Befehle aus seinem Flashspeicher und führt sie aus), dass er einen 8-Bit-Timer und keinen 16-Bit-Timer hat und dass er vier AD-Wandlerkanäle hat.

Und das Schönste an dem Fenster ist: es hat jetzt einen Menüeintrag "Assemble". Wenn wir auf den klicken, dann werkelt ein eingebauter gavrasm-Assembler an der Datei Code1.asm.

Assemblieren im avr_sim Der Assembler sollte zum Schluss kommen, dass alles korrekt assembliert ist.

avr_sim zeigt jetzt automatisch das Listing an und hat einen weiteren Menüeintrag: "Simulate". Wenn wir den anklicken, öffnet sich das Simulatorfenster.

Simulatorfenster Hier sehen wir im Fenster "Simulation status" den Programmzähler in hexadezimal, die Anzahl Instruktionen, die schon simuliert sind, den Stapelzeiger und die Taktfrequenz. Im Fenster "SREG" sehen wir die Flaggen der CPU im sogenannten Status-Register). Und im Fenster "Register" die Inhalte aller 32 Register des Chips (alles Nullen, Anzeige in hexadezimal).

Mit dem Menüeintrag Step können wir nun unseren Code schrittweise ausführen.

Simulieren von inc R0 Was Wunder: im Register R0 steht jetzt wie vorhergeplant eine 1.

Beim Status hat sich auch was getan: der Programmzähler ist jetzt auf 1, die Anzahl ausgeführter Instruktionen ebenfalls und die abgelaufene Zeit zeigt die Dauer eines Takts an.

Auch im Listing im Editorfenster hat sich was getan: das kleine >-Zeichen im gelben Feld zeigt jetzt auf die nächste Instruktion.

Und so können wir nun schrittweise durch den Quellcode steppen und schauen, was das jeweils mit den Registern macht.

Fehlermeldung Ende Code Nach dem dritten Schritt zeigt jetzt der Programmzähler aber auf eine Speicherzelle im Flash, der gar keinen Code enthält. Der Simulator hat das gemerkt, der reale Chip würde es nicht merken und einfach immer so weiter machen.

4 Mehr zum Assembler-Lernen

Es gibt natürlich noch viel mehr Instruktionen als nur NOP, INC und ADD. Die sind hier alle einzeln erläutert. Aber: keine Angst! Hier sind diejenigen, die man oft braucht und diejenigen, die man niemals braucht (laut Auswertung der Quellcodes auf meiner Webseite):

Instruktionen nach Nutzungsstatistik, 21.573 Instructionen in 110 Dateien
80%95%99%100%Never
ldi (18,3%)lds (1,14%)sbc (0,445%)ldd (0,116%)asr
out (6,91%)lpm (1,05%)adiw (0,422%)cli (0,093%)bclr
rjmp (6,64%)andi (1,02%)cpc (0,348%)com (0,093%)brbc
rcall (6,46%)pop (0,997%)sbic (0,348%)std (0,083%)brbs
mov (4,74%)sbrc (0,997%)sbis (0,311%)brtc (0,079%)break
reti (3,87%)brcc (0,964%)sei (0,311%)call (0,079%)brge
clr (3,57%)ld (0,922%)or (0,283%)jmp (0,074%)brhc
brne (2,96%)sbr (0,904%)ori (0,227%)and (0,060%)brid
ret (2,55%)lsl (0,820%)swap (0,223%)brts (0,051%)brie
cpi (2,52%)tst (0,802%)sleep (0,218%)eor (0,042%)brmi
st (2,23%)cbr (0,797%)clc (0,199%)ijmp (0,042%)brpl
nop (2,23%)cp (0,714%)sec (0,167%)mul (0,042%)brsh
sbi (2,09%)lsr (0,570%)clt (0,130%)brhs (0,032%)brvc
brcs (1,86%)sbiw (0,496%)-ser (0,023%)brvs
rol (1,78%)ror (0,477%)-bld (0,014%)bset
cbi (1,75%)subi (0,477%)-brlt (0,014%)clh
add (1,52%)sbrs (0,473%)-bst (0,014%)cln
in (1,48%)--neg (0,009%)cls
inc (1,45%)--sbci (0,009%)clv
adc (1,30%)--wdr (0,009%)cpse
breq (1,28%)--brlo (0,005%)fmul
sts (1,26%)--clz (0,005%)fmuls
dec (1,20%)---fmulsu
----icall
----lds
----movw
----muls
----mulsu
----seh
----sen
----ses
----sev
----spm


Man fange also mit der ersten Spalte an, mache mit Spalte 2 weiter und komme mit der Zeit dann schließlich zu Spalte 3. Damit lassen sich schon 99% aller Aufgaben bewältigen.

Und so ein AVR-Assembler-Projekt macht auch nur richtig Spaß, wenn man den Hexcode in einen realen Chip brennt und der dann macht, was er soll. Hier gibt es einen ganzen Kurs mit solchen kleinen Anwendungen und dem zugehörigen Quellcode in Assembler.

Wer noch mehr praktische Beispiele braucht, findet sie hier oder hier nach Anwendungsarten sortiert.

Natürlich kann der Simulator noch viel mehr. Alles, was er kann, ist im jeweiligen Handbuch dargestellt, das es zum Simulator hier zum Download gibt.

Viel Erfolg beim Lernen.

Kommentare zu dieser Seite bitte hierhin.



zum Seitenanfang

5 Beispiel 1: Bits klappern lassen

Um das erste sinnvolle Assemblerprogramm zu schreiben, das in den Chip gebrannt werden kann und physisch auch was Sichtbares tut, starten wir im Simulator ein neues Projekt. Mit dem Menue-Eintrag Project - New kriegen wir das folgende Bild:

Neues AVR_SIM-Projekt Der kann Fragen stellen! Zuerst kriegt das Projekt den Namen klappern. Dann klicken wir in das Eingabefeld Project location und wählen einen Ordner für dieses Projekt aus. Wir entfernen die beiden Häkchen bei den Einstellungen und stellen damit die Projektart auf Linear (keine Interrupts) und von Comprehensive auf Short version. Aus den beiden Ausklappfenstern wählen wir unseren AVR-Typ aus, hier der ATtiny13A. Mit Ok verlassen wir das Befragungselend, beantworten noch die Frage nach der Packungsart und erhalten im Editor das Folgende:

Neue Programmvorlage avr_sim hat daraufhin schon mal ein einfaches Grundgerüst für das Assemblerprogramm erzeugt und die wichtigsten Einträge vorweggenommen. Mit dem Genüst fangen wir an und tragen darin Folgendes ein:

Quellcode klappern Alle Zeilen, in denen der Assembler gar nicht erst nachschauen soll, weil sie sowieso nur Kommentare für den menschlichen Leser enthalten, sind mit einem Semikolon vor seinem Zugriff gesperrt: er lässt diesen Teil dann in Ruhe und versucht gar nicht erst, irgendetwas verstehen zu wollen.

Im Kopf haben wir eine aussagekräftige Beschreibung dessen eingefügt, was das Programm so alles tut, damit wir den Quelltext auch noch Wochen später einer Funktion zuordnen können.

Hinter dem Label Main:, die dem Festlegen einer Sprungadresse dient, haben wir die Zeile sbi DDRB,DDB0 eingefügt. sbi ist das Assembler-Mnemonic für Set Bit I/O und setzt das im zweiten Parameter angegebene Bit (DDB0) im Richtungsregister, das als Parameter 1 angegeben ist (DDRB) auf High. Das macht das Portbit PB0 des ATtiny13A zu einem Ausgang, der dann elektrisch entweder Strom zieht oder liefert, je nachdem ob das zugehörige Portbit PORTB0 Null oder Eins ist.

Das Portbit PB0 beim ATtiny13A Dieses Portbit befindet sich an Pin 5 des Chips.

Register Summary des ATtiny13A Der Ort, an dem der Pin zum Ausgang gemacht werden kann, nämlich der Port DDRB und das Bit DDB0, kriegt man heraus, wenn man sich das Register Summary im Datenblatt des ATtiny13A anschaut (das es hier zum Download gibt). Dass ATMEL die Ports als Register bezeichnet ist nur zum Verwirren von Anfängern so gemacht, damit die mit den 32 Registern der AVR durcheinander kommen.

Neben allen anderen Ports sieht man hier den Port DDRB, den Datenrichtungs-Port (Data Direction Register B). Und das Bit DDB0 steht ganz rechts in der Spalte Bit 0. Alle Namen der Ports und Portbits finden sich in der Datei tn13adef.inc, die wir zu Beginn unseres Quellcodes mit .include "tn13adef.inc angegeben haben. Das Symbol DDRB übersetzt sich daher im Assembler zu hexadezimal 0x17, der Portadresse von DDRB, das Symbol DDB0 zu 0. Mit diesen Symbolen braucht man sich weder Portadressen noch Bitnummern zu merken: die anfangs hinzugefügte def.inc-Datei kennt sie alle.

Damit ist klar, was sbi DDRB,DDB0 tut: es setzt das Bit 0 im Port 0x17 auf Eins. Damit wird aus einem Eingang nun ein Ausgang.

Ports des ATtiny13A beim Start Ports des ATtiny13A nach der ersten Instruktion Den Effekt sehen wir, wenn wir im Simulatorfenster unter Show internal hardware den Haken bei Ports machen (die Portbits sind alles Nullen) und dann den Menue-Eintrag Step einmal drücken: das Bit 0 des Datenrichtungsports DDRB ist jetzt Eins geworden.

Analog wird mit den beiden Instruktionen cbi PORTB,PORTB0 und sbi PORTB,PORTB0 das Bit 0 im Port PORTB zuerst dieses Bit Null gesetzt (CBI = Clear Bit I/O) und dann auf Eins. Da das Datenrichtungsbit dieses Pins gesetzt (Eins) ist, kommt diese Änderung auch am elektrischen PB0-Ausgang an: er wechselt die Spannung, von null auf z. B. fünf Volt. Die zwei NOP-Instruktionen dazwischen ignorieren wir erst mal.

Wichtiger ist die Instruktion RJMP Loop. Sie bewirkt einen Sprung (jump), und zwar relativ (RJMP), zurück zur Marke Loop:. Wenn wir diese Instruktion im Simulator ausführen, landet der Adresszähler wieder bei der Adresse 1 und das Spiel beginnt von vorne. Lassen wir mit dem Menue-Eintrag Run im Simulatorfenster das ganze dauernd laufen, dann klappert das Bit 0 im Port B gar schnell hin und her.

Nun, wie schnell nun eigentlich? Wir könnten Update status Instructions auf 1 und Step delay ms auf 1000 einstellen und dann dem Treiben etwas gemächlicher zuschauen. Das wirkt sich aber nur auf die Simulation aus, nicht auf den realen Prozessor.

Dazu stoppen wir den Lauf, klicken im Menue auf Restart und führen mit Step den ersten Schritt aus. Dann klicken wir im Simulation status auf die Zeile Stop watch, was die Stoppuhr auf Null setzt. Klicken wir jetzt so lange auf Step, bis die Zeile mit dem Eins-Setzen des Portbits erreicht wird, zeigt die Stoppuhr 3,33µs an. Solange dauert also der Nullzyklus. Klicken wir dann solange weiter, bis wieder das Nullsetzen erfolgen wird, sind wir bei 6,67µs. Jetzt sehen wir auch den Sinn der beiden NOP: beide Zyklen dauern exakt gleich lang und wir erhalten ein Rechtecksignal mit 50/50-Pulsweite am Ausgang.

Oszilloskop-Einstellung Damit wir das auch schön sehen, schalten wir den Schalter Scope im Simulatorfenster ein, nennen die Oszilloskopanzeige PB0-Signal, schalten Beam 1 auf enabled, stellen das Digitalsignal auf I/O-Pin, den Port auf B, den Portpin auf PB0 und zeigen das Oszilloskop mit Show scope an. Klicken wir nun immerzu auf Step, dann baut sich das 150kHz-Rechtecksignal mit der Pulsweite 50% auf.

Das erzeugte Rechtecksignal Durch Einfügen von weiteren NOP in unseren Ablauf könnten wir eine etwas niedrigere Frequenz erreichen. Wer es assymmetrisch lieber mag, kann auch nur NOP in einen der beiden Pfade einpflegen. Aber in allen Fällen gilt: bis herunter auf 1 Hz kommt man mit der NOP-Methode nicht, dafür ist der Flashspeicher des ATtiny13 zu klein. Aber dafür gibt es auch viel effektivere Methoden - im nächsten Beispiel.

Rein praktisch gesehen könnte man eine LED an PB0 anschließen. Da so ein Ausgang Ströme von bis zu 80 mA antreiben kann und LEDs nicht so viel Strom vertragen, müsste man noch einen Vorwiderstand einbauen, der den LED-Strom auf z. B. 20 mA begrenzt. Die Folge des erzeugten Rechtecksignals wäre, dass die LED zu 50% an- und zu 50% ausgeschaltet ist, also mit halber Lichtstärke glänzt. Da das mit 150 kHz erfolgt, sieht man nur den Mittelwert und nicht das Blinken. Das wird im nächsten Beispiel anders.

6 Beispiel 2: den Timer zum Laufen kriegen

Alle AVR haben Timer/Counter. Das sind eingebaute Hardwareteile, die entweder externe Impulse zählen (z. B. Impulse, die an einem Eingang eintreffen) oder die mit dem Prozessortakt getaktet werden, nachdem dieser vorher durch 1, 8, 64, 256 oder 1.024 geteilt wurde (Prescaler).

Timer/Counter können 8 Bit oder 16 Bit breit sein, entsprechend zählen sie von Null bis 255 bzw. 65.535.

Ist der Prozessortakt 1,2 MHz (ATtiny13), hat der Timer 8 Bit Breite und wird der Timer mit einem Vorteiler von 1.024 betrieben, dann läuft er mit einer Frequenz von 1,2 MHz : 256 : 1.024 = 2,29 Hz über. Das ist langsam genug, um eine LED blinken zu lassen.

Um den Zähler TC0 im ATtiny13 einzuschalten, muss man nur eine Eins in das niedrigste Vorteilerbit CS00 des Ports TCCR0B schreiben, was man mit sbi TCCR0B,CS00 erledigen könnte. Macht man das, quittiert der Assembler das mit einer Fehlermeldung:
Error 021: Port value (51) out of range (0..31)!

Diese Beschränkung der Instruktionen cbi und sbi nötigt uns dazu, das Setzen des CS00-Bits über zwei andere Instruktionen vorzunehmen: ldi R16,1 und out TCCR0B,R16. Das ldi ist das Mnemonic für Load Immediate und lädt eine Konstante (hier: dezimal 1) in das Register R16. Warum R16 und nicht R0? Die Instruktion ldi geht nur mit Registern ab R16 aufwärts. Das out schreibt den Inhalt des zweiten Parameters, eines Registers (hier: R16) in einen Port (hier TCCR0B).

Timer TC0 eingeschaltet Machen wir das, in einem neuen Projekt, hinter dem Label Main: und lassen Loop: und rjmp Loop dahinter so stehen, dann assembliert sich das Ganze korrekt und kann simuliert werden. Lässt man sich den Timer anzeigen, indem man im Simulatorfenster Timers/counters aktiviert, dann sieht man nach der out-Instruktion, dass Timer 0 im normalen Modus ist, dass der Vorteiler auf 1 gesetzt ist, mit 1,2 MHz Takt läuft und dass der Timerport TCNT0 auf Eins steht. Dieser Port stellt den Zählwert dar, den der Timer erreicht hat.

Klicken wir im Menue auf Run, dann läuft der Zähler schnell aufwärts und beginnt nach 255 wieder von vorne.

Wem das zu schnell ist, muss den Prozessortakt ein wenig vorteilen. Die drei Vorteilerbits CS02, CS01 und CS00 bestimmen den Vorteiler.
CS02CS01CS00Zählertakt
000Zähltakt ausgeschaltet
001Prozessortakt
010Prozessortakt durch 8
011Prozessortakt durch 64
100Prozessortakt durch 256
101Prozessortakt durch 1.024
110Zähltakt vom T0-Pin, fallende Flanke
111Zähltakt vom T0-Pin, steigende Flanke
Der T0-Pin ist dabei Pin 7 (PB2).

Will man also den Vorteiler auf 1.024 einstellen, müssen CS02 und CS00 Eins und CS01 Null sein. Man könnte das dann so schreiben:
ldi R16,0b00000101

Da man diesem Konstrukt nicht ansieht, dass es CS02 und CS00 setzt, sollte man es besser so schreiben:
ldi R16,(1<<CS02)|(1<<CS00)

Das 1<<CS02 nimmt eine Eins und schiebt sie so oft nach links wie CS02 sagt (zwei mal), ergibt 0b00000100. Das 1<<CS00 nimmt eine Eins und schiebt sie so oft nach links wie CS00 sagt (null mal), ergibt 0b00000001. Das | verodert beide Teilergebnisse zu 0b00000101.

Diese etwas kryptische Formulierung hat den immensen Vorteil, dass die Bitpositionen nicht fix sind wie bei 0b00000101 sondern die Bitpositionen immer frisch von der def.inc bezogen werden. Manche Bits wurden nämlich bei verschiedenen AVR-Typen woanders hin verlegt, und beim Umstellen auf einen anderen Typ müsste jedes Bit überprüft werden. Die 1<<Bit-Konstruktion ist da hilfreicher.

Der Timer läuft jetzt zwar mit programmierbaren Taktraten, aber wenn wir zu bestimmten Zeiten irgendetwas machen wollen, z. B. ein Portbit klappern lassen wollen, müssten wir ständig den Zähler lesen (z. B. mit in R16,TCNT0 und dessen Wert mit dem gewünschten Vergleichswert vergleichen (z. B. mit cpi R16,100, cpi heißt Compare with Immediate) und abhängig vom Ergebnis des Vergleichs diesen oder einen anderen Programmcode ausführen (z. B. mit breq Label, breq heißt BRanch if EQual oder Verzweige wenn gleich). Das wäre unschön, weil es den Prozessor ständig nur mit unnützem Vergleichen beschäftigt.

Da das Vergleichen des Zählerstands ziemlich oft vorkommt, haben die Timer im AVR das Vergleichen schon hardwaremäßig eingebaut. Und zwar sogar zwei davon: A und B. Sie vergleichen ständig und ohne Zutun des Prozessors den Zählerstand in TCNT0 mit den Werten, die in den beiden Portregistern OCR0A und OCR0B stehen. Nach dem Ende der aktuellen Verarbeitung des Befehls und vor Beginn der nächsten Instruktion steht fest, ob der Zählerstand gleich war.

Das hilft jetzt nicht unbedingt weiter, weil die Feststellung keine Konsequenzen hätte. Das können wir ändern, indem wir ein paar Bits in Timer/Counter-Kontrollport A ändern. Die betreffenden Bits heißen COM0A0 und COM0A1 für den Vergleicher A und COM0B0 und COM0B1 für den Vergleicher B. Sind beide Bits Null, dann passiert wie gehabt gar nix. Ist COM0A0 aber Eins und COM0A1 bleibt Null, dann klappert das Portbit PORTB0 im PORTB bei jedem Vergleichsereignis: von Null auf Eins und beim nächsten erfolgreichen Vergleich von Eins auf Null. Falls das zugehörige Datenrichtungsbit DDB0 Eins ist, bewegt sich nun auch am PB0-Ausgang das Ausgabe-Potenzial. Der Pin torkelt und macht ein Rechtecksignal. Der PB0-Ausgang heißt neben PB0 auch noch OC0A, nämlich wenn er so zum Output-Compare geworden ist. Dasselbe passiert am Ausgang OC0B, wenn der Vergleicher Gleichheit mit dem Vergleichswert in OCR0B feststellt und wenn seine beiden Bits COM0B0 und COM0B1 auf Null und Eins stehen und wenn das Datenrichtungsbit DDB1 auf Eins steht.

Zum Simulieren machen wir wieder ein neues Projekt, nämlich klappernAB und tippen Folgendes ein:

Quellcode von klappernAB Nach dem Assemblieren können wir das simulieren und uns die Ports und den Timer anschauen.

Ports beim klappernAB Timer beim klappernAB Nachdem wir im Einzelschritt bis zu der Instruktion rjmp Loop gelangt sind, sind bei den Ports DDRB die beiden Richtungsbits auf Ausgang eingestellt. Beim Timer sind die beiden Vergleichswerte angekommen und die beiden Ausgaben PB0 und PB1 stehen auf Toggle.

Mit Run bewegt sich das ganze Geschehen nun etwas schneller. Der Prozessor bewegt sich zwar nicht mehr, er bleibt bei rjmp Loop stehen. Dafür rennt der Vorteiler des Timers von 0 bis 7 und TCNT0 erhöht sich. Immer wenn TCNT0 den Wert 64 erreicht, klappert A. Immer wenn er bei 128 ist, klappert B. So geht es lustig immer rund.

Scope-Einstellung klappernAB Und so stellen wir das Zweikanal-Oszilloskop ein, um die Rechtecke zu sehen: Beide Kanäle sind jetzt auf den OC-Ausgang eingestellt.

Oszilloskopanzeige bei klapperAB Die beiden Signale überlappen sich etwas, haben die gleiche Frequenz, nämlich 292,969 Hz. Das entspricht ungefähr dem Wert
fOC0x = 1.200.000 / 8 / 256 / 2 = 292,96875 Hz

Das /2 kommt daher, weil immer zwei Durchläufe des Timers nötig sind, bis ein ganzer Kurvenzug komplett ist.

Wenn wir an den Portausgängen einen Lautsprecher anschließen würden (über einen Elko, damit der Gleichstrom nicht die Spule des Lautsprechers killt), könnten wir die 292 Hz nun live hören.

Stellen wir den Vorteiler statt auf 8 auf 1.024 ein (CS02 und CS00 auf Eins), dann hat das Ausgangssignal eine Frequenz von
fOC0x = 1.200.000 / 1.024 / 256 / 2 = 2,28881 Hz

Jetzt haben wir schon eine Frequenz, die niedrig genug ist, um eine LED blinken zu sehen.

Oszilloskop mit 2 Hz Das ist das Signal bei einem Vorteiler von 1.024.

Wer es noch näher zu 1 Hz möchte, damit die LED nicht so schnell blinkt, muss den Takt des Prozessors halbieren und ihn mit 600 kHz statt mit 1,2 kHz takten. Dann sind 1,1444 Hz Blinktakt langsam genug, aber etwas ungenau. Wie man das hinkriegt? Nun, der Prozessor hat einen Taktvorteiler, der das eigentliche Oszillatorsignal von 9,6 MHz durch 8 teilt und damit das eigentliche Taktsignal des Prozessors zur Verfügung stellt. Der Taktvorteiler kann aber nicht nur durch 8 teilen, sondern durch jede Zweierpotenz zwischen 1 und 256. Damit kann der Prozessortakt bis auf 35,5 kHz herab abgebremst werden. Damit bräuchte die LED ca. 14 s, um einmal an und aus zu gehen.

Portregister CLKPR Der Taktvorteiler ist im Port CLKPR zugänglich. Er besteht aus den Bits CLKPS3 bis CLKPS0. Die sind aber nicht per se beschreibbar, damit man den Prozessor nicht aus Versehen auf einen anderen Takt umstellt. In einem ersten Schritt muss das Bit CLKPCE Eins gesetzt werden. Dann können im zweiten Schritt die CLKPSx-Bits beschrieben werden. Dazwischen darf nix anderes passieren, denn das Schreibfenster wird schnell wieder zugemacht.

Die Teilerraten sind in der Tabelle angegeben. Die angegebene Taktrate gilt nur für den ATtiny13, andere AVR haben einen 8 MHz-RC-Oszillator und daher andere Taktraten.
CLKPS3CLKPS2CLKPS1CLKPS0VorteilerTaktfrequenz
000019,6 MHz
000124,8 MHz
001042,4 MHz
001181,2 MHz
010016600 kHz
010132300 kHz
011064150 kHz
011112875 kHz
100025637,5 kHz


Die beiden Codezeilen

  ldi R16,1<<CLKPCE ; Clock Prescaler Enable
  out CLKPR,R16 ; im Taktvorteiler setzen
  ldi R16,11<<CLKPS2 ; Teilerrate 16
  out CLKPR,R16 ; im Taktvorteiler einstellen

sind im obigen Quellcode schnell am Anfang eingefügt und treiben den ATtiny13 mit 600 kHz Takt an, so dass die Blinkprozedur mit etwa 1 Hz abläuft.

7 Schlussfolgerungen aus den Beispielen

An den beiden Beispielen kann man Folgendes sehen:
  1. AVR-Assembler besteht vorwiegend aus dem Erlernen der 119 Mnemonics, die für die von der zentralen Recheneinheit beherrschten Instruktionen stehen. Davon kommen ca. 20 öfter vor, weitere ca. 20 gelegentlich, weitere ca. 12 wenig und der überwiegende Teil nur sehr selten oder gar nicht.
  2. Weiter sind die 29 Direktiven zu erlernen, die den Assembler steuern. Von diesen kommen 5 häufig, 5 gelegentlich und der weit überwiegende Teil nur selten vor.
  3. Viel wichtiger als die seltener verwendeten Instruktionen und ungewöhnliche Direktiven ist die Kenntnis über den internen Aufbau der Hardware und wie man diese mit Hilfe von Ports und Bits beeinflussen kann. So können Timer in acht oder 16 verschiedenen Modi betrieben und so dem zu lösenden Problem angepasst werden.
  4. Assembler ist nicht wegen der vielen Instruktionen und Direktiven komplizierter als Hochsprachen, sondern wegen der engen Verwobenheit von Hardware mit der Software. Ohne Kenntnis der Hardware hat man keine Chance, irgend etwas Sinnvolles zu programmieren.
  5. Das bisschen Binär- und Hexadezimalzeugs, das als Handwerkszeug in Assembler nötig ist, hat man auch schnell gelernt. Da der Prozessor intern so arbeitet, macht man sich besser früher als später daran. So wie das maßgerechte Feilen von Metallen für den Mechaniker (ungeliebt, langweilig und öde) ist das Beherrschen des Zweier-Zahlensystems und die Vertrautheit damit leider unvermeidlich.


©2019 by http://www.avr-asm-tutorial.net