Pfad: Home => AVR-Überblick => Programmiertechniken => Hochsprache vs. Assembler    (This page in English: Flag EN) Logo

Programmiertechnik für Anfänger in AVR Assemblersprache

Die wesentlichen Unterschiede zwischen Hochsprachen und Assembler

Die beliebte Frage des Anfängers, mit was er denn bitte schön anfangen sollte, mit C oder mit asm, wird ihm heute meistens abgenommen: der Arduino kann nur C und hat etliche Hürden vor die Verwendung von asm eingebaut. Also fängt er halt mit C an. Was macht man also, wenn man denn schon C kann? Man lernt halt was Neues dazu.

Wenn man in Assembler einsteigt, kann man meistens schon andere Programmiersprachen auswendig. Das prägt: man fragt sich dauernd, warum das in Assembler alles so ganz anders geht als in der bislang gewohnten Sprachumgebung.

Für denjenigen, der sich beim Lernen von Assembler das immer wieder fragt, ist dieses Dokument geschrieben. Es zeigt an einem Beispiel, wo die wichtigsten Unterschiede zwischen Hochsprachen und AVR-Assembler liegen, zeigt aber auch Gemeinsamkeiten auf. Das soll ein wenig beim Lernen helfen und Lernhürden überwinden helfen.

Bei den Hochsprachen verwende ich hier in den Beispielen Pascal, weil ich das gut behersche. Andere, wie C, Java, VBA oder PHP, funktionieren ganz ähnlich, nur halt mit { und } statt mit begin und end;. Und auch die Unterschiede zu Assembler sind bei allen Sprachen grundsätzlich diesselben.

Das Beispiel: eine Uhr mit Sekunden-Ticker

Die Beispiel-Aufgabe: eine Uhr mit Sekunden, Minuten und Stunden soll gestellt und um eine Sekunde erhöht werden. Die Uhr soll getestet werden, ob sie richtig geht und nach einem Tag wieder bei Null ankommt. Die Uhrzeit soll auch in eine Zeichenkette verwandelt werden, die so aussieht: "01:23:45".

Zuerst zeige ich, wie das in Pascal geht. Dann kommt das Ganze in Assembler. Und Du wirst merken, wo genau die Unterschiede sind und warum diese Unterschiede so sind wie sie nun mal sind.

1 Die Aufgabe in Pascal

1.1 Variablendefinitionen in Pascal

Die Variablen für die Zeit könnte man in Pascal so definieren:

Var bSek,bMin,bStd: Byte;

Merke: in vielen Hochsprachen geht nix ohne solche Ankündigungen an den Compiler, sei es VAR, DIM oder was weiß ich nicht alles. Manche kommen aber auch ohne das aus und machen es sich selber.

Soweit so einfach. Was fängt der Pascal-Compiler nun damit an? Er legt irgendwo im Speicher drei Bytes an. Nein, nicht ganz genau: er legt nur drei Adressen fest, unter denen er später die Sekunden, Minuten und Stunden ansprechen möchte. In den so angelegten Speicherzellen steht noch irgendwas von vorher drin, es kommt erst später etwas rein.

Physisch ist bis jetzt noch gar nichts passiert. In den adressierten Speicherzellen steht nur Müll drin. In der vom Compiler produzierten ausführbaren Datei steht auch noch gar nix drin, das VAR wird vom Compiler einfach verschluckt und bleibt erst mal folgenlos.

1.2 Uhr stellen in Pascal

Aber jetzt geht es endlich los: wir schreiben die Uhr-Variablen.

Das geschehe in einer im Programm später aufgerufenen Prozedur zum Rücksetzen oder Stellen der Uhr. Die könnte dann zum Beispiel so aussehen:

Procedure UhrStellen(Std,Min,Sek:Byte);
begin
bSek:=Sek;
bMin:=Min;
bStd:=Std;
end;

Und im späteren würde die Uhr dann mit

{ Setzt die Uhr zurueck }
Procedure UhrRuecksetzen;
begin
UhrStellen(0,0,0);
end;

{ Stellt die Uhr auf eine feste Startzeit }
Procedure UhrStartzeit;
begin
UhrStellen(1,23,44);
end;

rückgesetzt oder auf eine vorgegebene Uhrzeit eingestellt. Damit stünde dann im Speicher an den Orten bSek, bMin und bStd die tatsächlich rückgesetzte oder die voreingestellte Uhrzeit herum.

Endlich ist mal was passiert.

1.3 Uhr um eine Sekunde weiterdrehen in Pascal

Wenn wir nun die Uhr um eine Sekunde weiterdrehen wollen, dann brauchen wir dafür eine Prozedur UhrTick. Wenn wir um 00:00:00 noch einen Tageswechsel veranlassen wollen, muss uns die Tickprozedur noch einen Wahrheitswert zurückgeben und wird zu einer Function mit einem Rückgabewert:

Function UhrTick:Boolean;
begin
Result:=False;
Inc(bSek);
If bSek >= 60 Then
  begin
  bSek:=0;
  Inc(bMin);
  If bMin >= 60 Then
    begin
	bMin:=0;
	Inc(bStd);
	If bStd >= 24 Then
	  begin
	  bStd:=0;
	  Result:=True;
	  end;
	end;
  end;
end;

Der Rückgabewert "Result" kommt nun bei jedem Aufruf mit einem Wahrheitswert False oder True zurück und kann dann weiterverwendet werden.

1.4 Uhr testen in Pascal

Um sicher zu gehen, dass die Uhr auch korrekt funktioniert, müssen wir die Uhr auf Null setzen und die Tickroutine 60*60*24 = 86.400 mal aufrufen. Danach sollte die Uhr wieder auf Null sein. Wir könnten die Uhr auch so lange aufrufen, bis ein True zurückkommt. In Pascal ginge das dann so:

Procedure UhrTest;
Var n:LongInt;
begin
UhrRuecksetzen;
n:=0;
Repeat Inc(n) Until UhrTick;
Writeln(n);
end;
Was da nun der Compiler macht ist erstens mal eine mehr-bytige Variable mit Namen "n" zu definieren. Die ist nötig, weil die 86.400 weder in ein 8-bittiges Byte noch in ein 16-bittiges Wort passen werden. Mit LongInt wird dann genügend viel zählbar. Wenn es noch höher würde, müssten wir einen INT64 für n nehmen, das höchste der Gefühle in Pascal.

Und hier stoßen wir auf Ähnlichkeiten und Unterschiede zu Assembler. Auch der AVR-Assembler ist mit mehr als 8 Bit langen Zahlen erst mal überfordert. Aber wenn wir ihm erlauben, weitere Speicherstellen dazu zu nehmen, können wir auch große Zahlen handhaben. Da so ein kleiner AVR alleine schon 32 Register hat, kann er so bis zu 256 Bit lange Zahlen. Das kann so kein Pascal, auch kein 64-Bit-Prozessor. Und wenn es noch mehr sein muss, nimmt der Assembler-Programmierer halt noch das SRAM dazu. Gigantisch!

Wieder kümmert sich nur der Compiler darum, wo genau nun die vielen Bytes von n herumliegen, den Programmierer kümmert das nicht weiter. Vermutlich wird n irgendwo auf dem Stapel herumliegen, wir wissen es nicht.

Mit dem Repeat zählen wir die Sekunden, bis der Tag zu Ende ist. Er sollte mit einem n von 86.400 enden, das uns die Kommandozeile mit dem Writeln dann auch anzeigen wird.

1.5 Umwandlung der Uhrzeit in eine Zeichenkette in Pascal

Wenn wir jetzt noch die Uhrzeit in eine Zeichenkette umwandeln wollen, müssen wir führende Nullen vor die Sekunden-, Minuten- und Stundenwerte schreiben. Da das drei Mal gemacht werden muss, machen wir das mit einer Funktion, die empfangene Bytes mit führenden Nullen als String zurückliefert. Das sähe dann als Quellcode in Pascal so aus:

Function ByteInStr(b:Byte):String;
begin
If b < 10 Then
  Result:='0'+IntToStr(b) else
  Result:=IntToStr(b);
end;

Function ZeitInStr:String;
begin
Result:='Zeit = '+ByteInStr(bStd)+':'+ByteInStr(bMin)+':'+ByteInStr(bSek);
end;

Wenn wir nun irgendwo "Writeln(ZeitInStr);" aufrufen, kriegen wir die Uhrzeit genau so wie sie soll auch auf die Kommandozeile zurückgeliefert und angezeigt.

Damit hätten wir alles zusammen, was man so braucht, um die Uhrzeit zu setzen, ticken zu lassen, zu testen und sie auf dem Bildschirm in ansprechender Form auszugeben. Und das in der Hochsprache Pascal.

2 Das Ganze in Assembler

Für alle genannten Operationen gibt es in Assembler analoge Konstruktionen. Allerdings hat es in Assembler noch einige Überraschungen.

2.1 Zwei Orte für die Zeit in Assembler

Wie teilt man dem Assembler mit, dass er bitte schön drei Speicherplätze für die Nutzung als Zeit bereitlegen möge?

So ein AVR bietet zwei Möglichkeiten, um diese zu platzieren:
  1. in dreien seiner insgesamt 32 Register, oder
  2. in seinem üppigen statischen RAM, auch SRAM genannt.
Das ist jetzt schon ein gewaltiger Unterschied zu Pascal: wir sind als Assembler-Programmierer verpflichtet, den genauen Ort für alles und jedes angeben und festlegen zu müssen. Der Assembler macht so was, im Gegensatz zum Pascal-Compiler, nicht von sich aus: wir müssen ihm mitteilen, wo er bitte schön alles hintun soll.

Und das zwingt den Programmierer eben auch zu Entscheidungen: hat er freie Register zur Genüge, wird er Möglichkeit 1 nehmen. Wenn nicht, halt 2. Wenn er mit 1 mal angefangen hat und seine Mega-Maschine immer größer und Register-hungriger wurde, muss er irgendwann die drei Register für Wichtigeres freischaufeln und die ganze Uhrzeit-Mimik, die nur in jeder Sekunde ein einziges Mal tatsächlich verwendet wird, in das SRAM verlegen. Wir sehen: die Entscheidungsfreiheit des Assembler-Programmierers hat zwar den Nachteil, dass er sich um was kümmern muss, das der Pascal-Programmierer nicht mal kennt, dass er aber auch alle Dinge in seinem Griff behält und optimieren kann.

Entsprechend sehen die beiden Möglichkeiten etwas unterschiedlich aus. Zuerst die Variante mit den Registern:

.def rSek = R17
.def rMin = R18
.def rStd = R19
Das macht jetzt dasselbe wie die Variablendefinition in der Hochsprache: es legt einfach drei Namen fest. Es sagt aber auch, wohin die Uhrzeit genau gelegt werden soll: in drei Register. Hier sind es die drei Register R17, R18 und R19.

Und warum nicht R0, R1 und R2, am Anfang des Registerparks, den so ein AVR (außer den ATtiny4/5/9/10) nun mal hat? Nun, das weiß der Assembler-Programmierer schon aus dem Kopf, dass er dort weder die "LDI"-Instruktion zum Stellen der Uhr noch die "CPI"-Instruktion zum Feststellen verwenden könnte, ob die Uhr schon 60 Sekunden oder Minuten oder auch 24 Stunden erreicht hat. Wenn er die Uhrzeit in R0/R1/R2 verlegen will, braucht er jedesmal zwei Instruktionen, um sie zu stellen oder vergleichen zu können. Und der Assembler-Programmierer hasst nichts so sehr wie unnütze Instruktionen, die er einfach mit R17/R18/R19 vermeiden kann: die können LDI und CPI ohne weiteres.

Wie macht der Pascal-Compiler das? Wir wissen es nicht, und nur ein tiefer Blick in den vom Compiler produzierten Assembler-Code könnte uns das offenbaren. Wer's mag, kann es herausfinden. Man muss es nicht. Nicht so der Assembler: der will es vom Programmierer ganz genau wissen. Der selbst muss die AVR-Instruktion genau angeben, die die Uhr stellt oder die Sekunden, Minuten oder Stunden mit irgendwas vergleicht. Und sich höchstselbst um etwas kümmern, das der Pascal-Programmierer nicht mal kennt.

Wie beim "Var" auch sind auch beim .def dies nur Mitteilungen an den Assembler, damit ist noch nix geschrieben, nur Platz definiert für die Uhrzeit.

Soll das Ganze in das SRAM, dann sähe das so aus:

.dseg
.org SRAM_START
sSek:
.byte 1
sMin:
.byte 1
sStd:
.byte 1
Die drei Label sSek, sMin und sStd haben jetzt in den Interna des Assemblers Adressen. Assemblieren wir das mit gavrasm mit der Option -s (oder mit avr_sim, in dem ein gavrasm werkelt) und schauen uns die Symbolliste im produzierten Assembler-Listing an, dann sähen wir das hier (andere Assembler zeigen einem das nicht!):

List of symbols:
Type nDef nUsed             Decimalval           Hexval Name
  T     1     1                     19               13 ATTINY13
  L     1     0                     96               60 SSEK
  L     1     0                     97               61 SMIN
  L     1     0                     98               62 SSTD

Die drei Labels im SRAM (Typ L) haben die Adressen 96, 97 und 98. Wenn Du statt des Tiny13 einen großen ATmega verwendest, liegen die drei halt woanders, weil bei denen das SRAM woanders beginnt.

Auch hier passiert mit den Speicherzellen im SRAM, 96 bis 98, rein gar nix, wie beim "Var" auch. Der Assembler weiß jetzt aber, wo "sSek", "sMin" und "sStd" liegen, wo deren Adressen sind.

Damit hätten wir schon einen ganz gehörigen Unterschied: in Assembler gibt der Programmierer den Ort, an dem irgendwas passieren soll, ganz genau und schon in den ersten Zeilen des Codes an. Bei Hochsprachen interessiert dieser Ort gar nicht, nur der Compiler weiß ihn und kümmert sich alleine drum.

Das sind in Assembler im SRAM jetzt mehr als doppelt so viele Zeilen als bei der Platzierung in Registern. Aber auch immer dasselbe, nur halt woanders (und in Pascal total intransparent). Passiert ist bis jetzt noch gar nix, nur drei Namen sind vergeben.

Rücksetzen der Uhr in Assembler

Auch beim Rücksetzen der Uhr und bei ihrem Stellen sind nun zwei verschiedene Varianten nötig. Zuerst das Rücksetzen mit den drei Registern:

; Uhr neu starten
UhrStartReg:
  clr rSek
  clr rMin
  clr rStd
  ret
Aber selbst dafür gibt es noch eine weitere Möglichkeit, die genau dasselbe tut:

; Uhr neu starten
UhrStartReg:
  ldi rSek,0
  ldi rMin,0
  ldi rStd,0
  ret
Mach doch, was Du willst.

Nun können wir mit einem beherzten "rcall UhrStartReg" an irgendeiner Stelle des Programms die Uhr in Registern auf Null stellen. Aber gemach: was in Hochsprachen ganz automatisch gemacht wird (nämlich eine Stapelverwaltung), muss der Assembler-Programmierer erst dem Chip mühsam beibringen: den Stapel einzurichten.

Start:
  ; Init des Stapels
  .ifdef SPH
    ldi R16,High(RAMEND)
	out SPH,R16
	.endif
  ldi R16,Low(RAMEND)
  out SPL,R16
Das setzt den 8- oder 16-Bit-Stapelzeiger auf das Ende des SRAMs. Wenn wir nun "rcall UhrStartReg" aufrufen, dann legt er die aktuelle Ausführungsadresse auf diesem Stapel ab und kehrt bei dem "ret" dorthin auch wieder zurück.

Im Unterschied zum Hochsprachenprogrammierer muss sich der Assemblerprogrammierer hiermit um den Stapel höchstselbst kümmern. Macht er das nicht, weil er es nicht weiß oder vergisst, dann passieren im programmierten Chip ganz eigenartige Dinge, jedenfalls funktioniert rein gar nix wie es soll. Der Assembler-Programmierer hat die Pflicht, zu entscheiden, ob mit Stapel gearbeitet werden soll oder nicht. Kein Assembler wird ihn irgendwie warnen, wenn er das nicht tut. Der Assembler behandelt den Programmierer nicht als Kind: "wenn er keinen Stapel einrichtet, wird er schon wissen, was er tut, da rede ich ihm gar nicht drein!"

Ein Vorteil hat der Assembler-Programmierer: er kann jetzt die mit dem Namen "UhrStartReg:" benannte Prozedur nun hinschreiben, wo er möchte (nur nicht an den Anfang des Programmspeichers oder mehr als 2-Kilo-Worte entfernt vom rjmp-Aufruf). Viele Hochsprachen verlangen, dass verwendete Prozeduren und Funktionen immer erst definiert werden (oder mit FORWARD deklariert sind), bevor sie im Programm verwendet werden können. Da Assembler immer zwei Durchläufe durchführen (two-pass-assembler), braucht man solche Regeln hier nicht beachten.

Hier noch die Assembler-Formulierung für das Nullsetzen der Zeit, wenn die im SRAM platziert ist:

UhrStartSram:
  ldi R16,0
  sts sSek,R16
  sts sMin,R16
  sts sStd,R16
  ret
Auch nicht sehr viel anders, aber anders. Statt dem LDI jetzt ein STS (STore in Sram), aber mit zwei Parametern: der Adresse und dem zu setzenden Wert in einem Register (R16).

2.2 Uhr stellen in Assembler

Schon ein wenig interessanter und vielfältiger sind die Möglichkeiten, die Uhr zu Beginn auf eine feste Zeit einzustellen. Am einfachsten ist es, einfach die Register mit einem festen Wert zu beschreiben:

; Uhr in Registern stellen auf 01:23:44
UhrStellenReg:
  ldi rSek,44
  ldi rMin,23
  ldi rStd,1
  ret
Bei der Variante mit SRAM geht das dann so:

; Uhr im SRAM stellen auf 01:23:44
UhrStellenSram:
  ldi R16,44
  sts sSek,R16
  ldi R16,23
  sts sMin,R16
  ldi R16,1
  sts sStd,R16
  ret
So, jetzt soll die Uhr morgen aber mit 20:00:01 zu Laufen beginnen (eine Schrecksekunde nach Tagesschau). Jetzt sucht sich der Assembler-Programmierer mühsam alle Stellen im Programm zusammen, in denen rSek/sSek, rMin/sMin und rStd/sStd vorkommen und schreibt dort die neuen Zeiten dahin. Weil die aber sehr oft vorkommen, findet er auch viele Vorkommen, die rein gar nix mit Uhr-Stellen zu tun haben.

Um das zu vermeiden, schreibt er stattdessen in den Kopf seines Quellcodes die folgenden drei Zeilen:

.equ cUhrStartStd = 20
.equ cUhrStartMin = 0
.equ cUhrStartSek = 1
Anstelle von "ldi rStd,1" schreibt er nun "ldi rStd,cUhrStartStd", und braucht sich fürderhin nur noch um die Konstanten im Kopf zu kümmern.

Selbiges würde er in in Pascal mit den Zeilen

Const
  cUhrStartStd = 20;
  cUhrStartMin = 0;
  cUhrStartSek = 1;
und mit dem Aufruf "UhrStellen(cUhrStartStd,cUhrStartMin,cUhrStartSek) auch erreichen.

Folglich: nur um Nuancen unterschiedlich. Nur, dass man ".equ Name,Zahl" überall im ganzen Quelltext und "Const" besser nur im Kopf der Pascal-Quelle schreiben sollte. Und ".equ Name,Zahl" kann man sogar hinter die Zeilen schreiben, in denen Name schon verwendet wird, ohne dass man das, wie in Pascal nötig vorher als FORWARD deklariert.

Und wo bleiben jetzt die Aufrufparameter von "UhrStellen", die in Pascal angeben, auf welchen Wert die Uhr gestellt werden soll? Hier kommt dann mal wieder ein echter Unterschied: Aufrufparameter gibt es in Assembler rein gar nicht. Dafür gibt es in Assembler immer und überall Zugriff auf alles! So was wie übergebene Parameter, mit denen nur die aufgerufene Prozedur oder Funktion etwas anfangen kann, die aber ansonsten unsichtbar sind, gibt es in Assembler nicht. Wenn ein aufgerufenes Unterprogramm in Assembler was kriegen soll, muss das irgendwo in Registern, im SRAM oder auch in irgendwelchen Portregistern schon rumstehen. Wo das steht, ist in Assembler völlig egal: das Unterprogramm holt es sich da ab, wo es herumsteht.

Was der Compiler so alles veranstalten muss, wenn er Aufrufparameter an eine Prozedur oder Funktion übergeben muss, ist dem Assembler herzlich egal und wurscht: so was tut man nicht und kann es auch gar nicht. Und verschwendet auch keine Zeit dafür.

Dafür ist alles in Assembler überall und immer PUBLIC und GLOBAL. Diese beiden Worte, auf die der Hochsprachen-Programmierer immerzu fixiert starrt, weil sie darüber entscheiden, ob es geht oder nicht geht, kennt der Assembler-Programmierer gar nicht. Er kann jede Speicherzelle immer und von überall her kaputtschreiben. Nix ist irgendwie geschützt, alles ist immer möglich. Der Preis dieser Freiheit ist: Spagehtti-Code und von irgendwoher hat da irgendwas den Timer abgestellt. Schutz spielt sich im Kopf des Assembler-Programmierers ab, kein Compiler nimmt ihm irgendwas davon ab: der Assembler lässt alles zu, verhindert nichts, lässt selbst den übelsten Unsinn machen. It's all up to you.

Übergabewerte an aufgerufene Unterprogramme machen also in Assembler keinerlei Sinn, weil sowieso alles zugänglich ist. Selbiges gilt auch für die Resultate von Funktionen, wie das Beispiel der Tick-Routine in Assembler im nächsten Kapitel zeigt.

2.3 Die Tick-Routine in Assembler

So sähe die Tick-Routine in Assembler aus, wenn die Uhr in Registern untergebracht ist:

Tick:
  inc rSek
  cpi rSek,60
  brcs TickRet ; Springe wenn kleiner 60 
  clr rSek
  inc rMin
  cpi rMin,60
  brcs TickRet ; Springe wenn kleiner 60
  clr rMin
  inc rStd
  cpi rStd,24
  brcs TickRet ; Springe wenn kleiner 24
  clr rStd
TickRet:
  ret
"inc" erhöht den Registerinhalt um Eins, "cpi" vergleicht den Registerinhalt mit 60 oder 24, und "brcs" verzweigt (branch), wenn die Carry-Flagge des CPU-Statusregisters gesetzt ist (BRanch on Carry Set). Sind die 60 Sekunden erreicht, wird rSek auf Null gesetzt (CLeaR) und die Minuten erhöht. Und so weiter bis der Tag um ist.

Und wo bleibt nun das "Result" der Funktion? Nun, es gibt sogar zwei davon. Beide befinden sich im Statusregister SREG und sind ein Bit darin:
  1. die Z-Flagge: sie gibt an, ob bei der letzten Addition, Subtraktion oder einem Vergleich (CPI) Gleichheit war. Da bei allen Operationen, bei denen die Berechnung vorzeitig verlassen wurde (mit brcs), der Vergleich mit UNGLEICH ausgegangen war (weil der Vergleichswert ja noch nicht erreicht wurde), ist die Z-Flagge nicht gesetzt. Erst beim Vergleich mit 24 war sie gesetzt, wenn der Tag zu Ende ist. Auch beim "clr rStd" wird die Z-Flagge gesetzt. Nur wenn der Tag zu Ende ist, kehrt die Routine mit gesetzter Z-Flagge zurück.
  2. die C-Flagge: sie ist dann gesetzt, wenn beim Vergleich die Zahl, mit der verglichen wurde, größer war. So ergibt "cpi rSek,60" immer ein gesetztes Carry, solange rSek noch kleiner als 60 ist. Mit brcs verzweigt dann das Programm mit gesetzter C-Flagge zur Rückkehr. Nicht, wenn beim Vergleich "cpi rStd,24" der Tag um ist. Auch die Instruktion "clr rStd" löscht die C-Flagge. Nur wenn der Tag zu Ende ist, kehrt die Routine mit gelöschter C-Flagge zurück.
Das Tagesende kann bei der Rückkehr von der Tick-Routine also sowohl an einer gesetzten Z-Flagge als auch an einer gelöschten C-Flagge erkannt werden. Beide können verwendet werden, um festzustellen, ob das Datum um einen Tag erhöht werden muss. Mit brne (Z-Flagge gelöscht) oder mit brcs (C-Flagge gesetzt) kann die Datumsanpassung einfach übersprungen werden.

Auch hier dasselbe Prinzip: bei der Rückkehr von einer Routine können von dieser beliebige Resultate übergeben werden. Ob es ein Bit, zwei Bits, ein Byte, zwei Bytes oder 50 Bytes oder wie in diesem Fall eine Flagge im Statusregister, kann sich der Assembler-Programmierer selbst aussuchen. Da sowieso alles PUBLIC und GLOBAL ist, kann das Ergebnis auch stehen wo es will. Hier war der Übergeber das SREG, das zwei Flaggen zurückgab. Das SREG kann von überall her und zu jeder Zeit gelesen und zu weiteren Sprungentscheidungen herangezogen werden.

Fazit: In Assembler vergiss das Konzept mit der Übergabe von Parametern beim Aufruf von Prozeduren und Funktionen, einschließlich der Rückgabe von Resultaten. Das Konzept macht in Assembler keinen Sinn, weil alle Programmteile alles dürfen und weil der Programmierer überall und jederzeit alleine darüber entscheidet, was mit welchem Register, welcher SRAM-Speicherzelle oder mit welcher EEPROM-Zelle wann geschehen soll.

2.4 Die Testroutine in Assembler

Das Pendant zur "UhrTest"-Routine in Pascal ist in Assembler etwas länger, weil wir ja immer angeben müssen, wo alles hinkommen soll und weil wir noch den Stapel einrichten müssen.

Die Testroutine zählt, nach wievielen Aufrufen ein Tag zu Ende ist. In Assembler geht das so (mit der Zeit in Registern):

.def rCnt0 = R0 ; Zaehler, Byte 0
.def rCnt1 = R1 ; dto., Byte 1
.def rCnt2 = R2 ; dto., Byte 2
.def rmp = R16 ; Vielzweckregister
.def rSek = R17 ; Sekunden
.def rMin = R18 ; Minuten
.def rStd = R19 ; Stunden
;
Start:
.ifdef SPH
  ldi rmp,High(RAMEND)
  out SPH,rmp
  .endif
  ldi rmp,Low(RAMEND)
  out SPL,rmp
; Uhr auf Null
StartUhr:
  rcall UhrStart
  clr rCnt0 ; Zaehler auf Null
  clr rCnt1
  clr rCnt2
Loop:
  inc rCnt0 ; Tick zaehlen
  brne Tick
  inc rCnt1
  brne Tick
  inc rCnt2
Tick:
  rcall UhrTick ; Eine Sekunde aufwaerts
  brcs Loop
  nop ; Hier Breakpoint setzen
  rjmp StartUhr
;
; Unterprogramme
; Uhr starten
UhrStart:
  clr rSek
  clr rMin
  clr rStd
  ret
; Uhr um eine Sekunde vorwärts
UhrTick:
  inc rSek
  cpi rSek,60
  brcs UhrTickEnd
  clr rSek
  inc rMin
  cpi rMin,60
  brcs UhrTickEnd
  clr rMin
  inc rStd
  cpi rStd,24
  brcs UhrTickEnd
  clr rStd
UhrtTickEnd:
  ret  

Das ist nun auch ziemlich straight-forward. Die beiden Aufrufe von "UhrStart" und "UhrTick" sind fast wie in der Hochsprache, nur die Verwendung der Flaggen Z und C in Assembler gibt es in Hochsprachen rein gar nicht: Statusregister der CPU sind in Hochsprachen streng verbotene Gebiete, nur Assembler kann und darf das - und benutzt so was wirklich oft.

2.5 Die Zeit als Zeichenkette in Assembler

Auch bei der Umwandlung der drei Variablen in eine Zeichenkette - beim Pascal war das eine Funktion, die einen String zurückgab - laufen wir in eine Besonderheit von AVR-Assembler: da eine Funktion nix (oder alles) zurückgibt, muss der String schon an eine Stelle geschrieben werden, die der AVR halt so hat und von wo sie das Hauptprogramm dann abholen kann.

Und noch was: da der AVR auch rein gar kein Kommandozeilenfenster kennt (dafür ist er einfach zu klein), muss schon ein Bereich im SRAM dafür herhalten, den String zu beherbigen. Und im Simulator können wir uns dann auch angucken, ob der String auch wirklich die Uhrzeit zeigt. Sonst müssten wir dem kleinen Kerl eine LCD oder eine serielle Schnittstelle anbauen, damit er darüber die Uhrzeit in die große weite Welt hinausposaunen kann.

Auch das Assembler-Programm beginnt mit einer Routine, die in einem Register (hier: rmp) übergebene Bytes mit führenden Nullen in eine zweistellige Zeichenfolge umwandelt. Diese Routine zieht solange zehn von der Zahl ab, bis ein Überlauf auftritt (brcc bedeutet BRanch on Carry Clear). Der Zähler, hier rmp2, beginnt bei dem ASCII-Zeichen '0' - 1, da rmp2 schon vor dem ersten Abziehen ausgeführt wird. Falls das schon beim ersten Abziehen ein Carry ergibt, dann steht korrekterweise eine ASCII-'0' im Register rmp2.

Auch das macht jetzt wieder mal einen Unterschied zu Hochsprache: der Assembler-Programmierer ist ständig am Code-Optimieren und spart Instruktionen, wo er nur kann. Hätte er den Zähler einfach auf Null gesetzt, dann müsste er am Ende Eins abziehen und auch noch die ASCII-'0' dazu zählen. Diese eine Instruktion hat er sich gespart, indem er den Zähler nicht auf Null, sondern auf ASCII-'0' minus Eins setzt. Den Pascal-Programmierer juckt so was rein gar nicht, er denkt nicht mal darüber nach. Allein das Umpacken der erzeugten ASCII-Zeichen in einen String (auf dem Stapel, die Adresse ist nur dem Compiler bekannt) dauert ja auch schon länger. Und das nimmt er fraglos in Kauf - oder merkt es gar nicht.

Beim Abspeichern der Zehner und der Einer verwendet der AVR-Assembler-Programmierer gerne Zeiger: hier das Registerpaar Z. Es besteht aus den beiden Registern R31 (MSB) und R30 (LSB). Mit "st Z+,rmp2" schreibt er den ASCII-Zehner-Zähler in rmp2 in den SRAM-Speicher, auf den Z jeweils zeigt. Anschließend erhöht der Prozessor auch noch den Zeiger um Eins (Z+), so dass er gleich auf die nächste Adresse zeigt.

Und so sieht der ganze Quellcode der Umwandlerei aus (rechts das Ergebnis im SRAM, in der untersten Zeile tobt sich wegen der RCALLs der Stapel aus): Die Uhrzeit im SRAM

; SRAM-Position fuer Uhrzeit
.dseg
UhrString:
.byte 8
;
; Code fuer Uhrzeit
.cseg
; Byte in rmp als ASCII-Zeichen in Z
;   mit fuehrenden Nullen
Int2Str:
  ldi rmp2,'0'-1
Int2Str1:
  inc rmp2
  subi rmp,10
  brcc Int2Str1
  st Z+,rmp2
  subi rmp,-'0'-10
  st Z+,rmp
  ret
; Zeit in eine Zeichenkette im Sram umwandeln
ClkString:
  ldi ZH,High(UhrString)
  ldi ZL,Low(UhrString)
  ldi rmp,'Z'
  st Z+,rmp
  ldi rmp,'e'
  st Z+,rmp
  ldi rmp,'i'
  st Z+,rmp
  ldi rmp,'t'
  st Z+,rmp
  ldi rmp,' '
  st Z+,rmp
  ldi rmp,'='
  st Z+,rmp
  ldi rmp,' '
  st Z+,rmp
  mov rmp,rStd
  rcall Int2Str
  ldi rmp,':'
  st Z+,rmp
  mov rmp,rMin
  rcall Int2Str
  ldi rmp,':'
  st Z+,rmp
  mov rmp,rSek
  rcall Int2Str
  ret

Das ist jetzt in der Summe aber ganz viel länger als in Pascal. Weil aber jede dieser Zeilen genau einer einzigen Prozessoroperation entspricht, was in Pascal beileibe nicht der Fall ist, kriegt man mit diesem Programmcode ein sehr kleines, schlankes Ausführbares hin. Siehe weiter unten.

3 Gegenüberstellung von Pascal- und AVR-Asm-Programm

Hier in der Tabelle sind mal die wichtigsten Eigenschaften der ausführbaren Programme gegenübergestellt.

ParameterPascal-UhrAVR-ASM-Uhr
Prozessortakt1,8 GHz1 MHz
Dauer eines Tags4 ms1,30 s
daraus: Dauer pro Tick46,30 ns15,05 µs
dto. pro MHz Takt83,33 µs
Größemit FPC: 115 kB57 Worte (= 114 Byte)

Das Folgende fällt daran auf:
  1. Der Prozessortakt meines Laptop ist 1.800-mal höher als eines Standard-AVRs. Gut, man könnte den AVR noch maximal 20-fach schneller machen, aber wozu sollte das gut sein (außer für Batterie-Leer)?
  2. Mein Laptop absolviert die 86.400 Sekunden eines Tages in 4 ms, wofür der AVR etwas mehr als eine Sekunde braucht. Aber eine Sekunde pro Tag ist nun auch nicht gerade eine beeindruckende Dauer und eher nicht der Rede wert.
  3. Pro Tick errechnet sich daraus für den Laptop 46,3 ns, für den AVR 15,05 µs. Das ist um das 300-fache schneller.
  4. Hätte mein Laptop den gleichen Prozessortakt wie ein AVR, dann bräuchte er mit 83µs mehr als sechsfach so lang wie ein AVR. Daran sieht man, wie verschwenderisch Laptops mit der Rechenzeit umgehen: da ist so ein AVR bei gleichem Takt viel, viel besser. Wenn man heute noch, wie das früher bei geleasten IBM-Mainframes üblich war, pro Rechenminute bezahlen müsste: alle würden nur noch Assembler machen.
  5. Und zum Schluss noch die Programmgröße. Die ist mit 115 kB um das 1.000-fache größer. Wozu braucht der Pascal das alles? Nun, er schleppt jede Menge Unsinn mit sich herum, den er niemals braucht. Seine ganze Speicherverwaltung ist dabei, damit er die famosen Aufruf- und Rückgabespektakel veranstalten kann. Und das Fenster will auch angezeigt sein. Was man halt als Pascal-Programm so braucht. Das braucht der AVR so alles gar nicht, kennt er auch gar nicht, um im Endeffekt dennoch dasselbe zu tun.

4 Fazit

Ein wichtiges Fazit: wenn Du als Hochsprachen-Programmierer in Assembler einsteigst, musst Du Dich um ein paar wenige Dinge, wie die Platzierung von allem und jedem oder das Initiieren der Stapelverwaltung, selber kümmern, die Dir bislang der Compiler abgenommen hat. Dafür bist Du jetzt Herr über alle Dinge und Du weißt exakt, was Du tust. Das hat Dich bislang nicht interessiert, das muss es jetzt aber.

Und? Habe ich zuviel versprochen, dass Assembler in Mikroprozessoren optimal zu Hause ist und wirkt? Und dass es sich lohnt, in dieser Sprache genausogut zu Hause zu sein wie in irgendwelchen Hochsprachen auf dem PC oder Laptop?

Kein vernünftiger Mensch wird auf dem PC oder Laptop in Assembler programmieren. Aber um einen AVR dazu zu kriegen, mittels acht verschiedenen Interrupt-Quellen zeitkritische Signale zu senden und zu empfangen, und das innerhalb von Mikrosekunden, wenn es sein muss. Und das Ganze dann auch noch in die 4 kB Flashspeicher passend zu kriegen, dafür ist nur Assembler wirklich geeignet.

Also, ran an die Buletten und Assembler gelernt. Und den knappen Flashspeicher nicht mit unnützem Datenmüll vollstopfen, der sowieso niemals gebraucht wird und nur als Füllstoff vom Compiler in die Speicherzellen gefüllt wird. Das kann mit Assembler nicht passieren: da kommt nur rein, was auch Sinn macht und was Du auch wirklich dort haben willst!

Zum Seitenanfang

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