Unterprogramme und Stack - Gestapelte Zahlen
Etwas Hardware
Der Aufbau des Lehrgangs Erste Befehle - Mit Assembler das Laufen lernen reicht hier vollkommen aus.
Der Stack wird eingerichtet
In fast allen Programmen, gibt es Routinen die öfter benötigt werden und eigentlich immer wieder das Gleiche machen. Solche Routinen lagert man in Hochsprachen in Unterroutinen aus. Aber auch in Assembler ist dies möglich.
Hierzu muss man in Assembler ein Konstrukt haben, womit der Prozessor weiß, wohin er nach dem Ende des Unterprogramms zurück kehren soll. Dieses Konstrukt ist der Stack. Man kann sich den Stack wie ein Stapel kleiner Zettel vorstellen, auf denen die jeweiligen Rücksprungadressen stehen. Schauen wir uns einmal an, wie so ein Unterprogrammaufruf in Assembler aussehen muss:
- Hauptprogramm will Unterprogramm aufrufen
- Aktuelle Programmadresse wird auf den Stack gelegt
- Unterprogramm wird aufgerufen
- Abarbeiten des Unterprogramms
- Rücksprungadresse wird vom Stack geholt
- Es wird an die geholte Adresse zurück gesprungen
- Hauptprogramm wird nach Unterprogrammaufruf fortgesetzt
Sollen innerhalb des Unterprogramms weitere Unterprogramme aufgerufen werden, so werden auch dessen Rücksprungadressen auf dem Stack abgelegt. So weiß der Prozessor immer wohin er nach dem Unterprogramm zurück kehren soll.
Um nun den Stack zu verwenden, muss dieser erst eingerichtet werden. Hierzu besitzt der AVR einen so genannten Stack-Pointer. Dies ist ein spezielles Register welches immer auf das Ende des Stacks zeigt. Soll Werte auf den Stack abgelegt werden, so wird der Wert an die Position gespeichert, welche der Stack-Pointer angibt und anschließend wird der Stack-Pointer verringert.
Warum verringert? Müsste dieser nicht erhöht werden? Eine Besonderheit von Stack-Pointern, und nicht nur bei der AVR-Familie ist es, dass der Stack immer nach unten 'wächst'. Daher wird der Stack in der Regel auch am Ende des Arbeitsspeichers gelegt. Da der Stackpointer im IO-Bereich des AVR liegt müssen wir diesen mit dem out-Befehl laden. Des Weiteren ist der Stack-Pointer 16 Bit breit wodurch wir 2 out-Befehle brauchen.
Bauen wir die Einrichtung des Stacks ein mal in unser Blinkprogramm ein:
.include
"m8def.inc" ldi r16,LOW(RAMEND) out SPL,r16 ldi r16,HIGH(RAMEND)
out
SPH,r16 ... |
Nun ist der AVR vorbereitet und wir können den Stack z.B. durch Unterprogramme verwenden. Wie ruft man aber nun ein Unterprogramm auf?
Hierzu dient der rcall-Befehl. Will man von einem Unterprogramm zurück kehren, muss der ret-Befehl eingesetzt werden. Mit diesem Wissen können wir unser LED-Blinkprogramm umbauen:
.include
"m8def.inc" Start: ldi r16,LOW(RAMEND) out SPL,r16 ldi r16,HIGH(RAMEND)
out
SPH,r16
Schleife: rcall Warte ldi r16,0b00000000 out PORTD,r16 rcall Warte rjmp Schleife
Warte: ldi r18,5 Warte1: ldi r17,195 Warte2: ldi r16,170 Warte3: dec r16 brne Warte3 dec r17 brne Warte2 dec r18 brne Warte1 ret |
Wie man nun sehr schön sehen kann, wurde das Programm um Einiges kleiner. Das Hauptprogramm wurde auch gleich wieder übersichtlicher da, anstelle der ganzen Warteroutine, jetzt nur noch der rcall-Befehl zur Warteroutine steht.
Homogene Unterprogramme
Wir haben mit unserem Unterprogramm ein kleines Problem. Es werden bei der Warteroutine die Register r16, r17 und r18 verwendet. Dies bedeutet, dass wir, bei größeren Programmen, diese Register nicht verwenden können. Noch problematischer wird dies, wenn z.B. ein Unterprogramm ein weiteres Unterprogramm aufruft und ebenfalls die gleichen Register benötigt. Da wäre Chaos vorprogrammiert.
Eine Lösung ist, dass wir die verwendeten Register in der Unterroutine irgendwie abspeichern und, sobald die Routine verlassen werden soll, wieder restaurieren.
Auch hierzu kann der Stack verwendet werden. Um Registerinhalte auf dem Stack abzulegen, gibt es den Befehl 'push'. Sollen Registerinhalte wieder restauriert werden, muss man dies mit 'pop' machen. Man muss aber tunlichst darauf achten, dass man das Register, welches wir als erstes auf dem Stack sichern, als letztes wieder restaurieren. Ansonsten kommt es zu einem Daten-Chaos.
Schauen wir uns nun einmal das Unterprogramm mit den gesicherten Register an:
Warte: push r16 push r17 push r18 ldi r18,5 Warte1: ldi r17,195 Warte2: ldi r16,170 Warte3: dec r16 brne Warte3 dec r17 brne Warte2 dec r18 brne Warte1 pop r18 pop r17 pop r16 ret |
Nun bekommt das Hauptprogramm nicht mehr viel vom Unterprogramm mit. Man kann die Register einsetzen, als würde das Unterprogramm nicht aufgerufen. Es ist nur die Wirkung, in diesem Fall 0,5s warten, zu merken.
Parameterübergabe
Unser kleines Blinkprogramm ist ja schon ganz nett. Was aber, wenn wir eine andere Blinkfrequenz haben wollen? Dann muss die Warteroutine entsprechend umgeschrieben werden. Und was ist, wenn wir die Routine mit unterschiedlichen Zeiten haben wollen? Z.B. 0,1s LED an und 0,4s LED aus? Muss dann eine 2. Routine geschrieben werden, nur mit einer anderen Zeit?
Natürlich nicht. Wir lagern nur die Registerzuweisung für r18 ins Hauptprogramm aus. Da wir den Inhalt von r18 im Hauptprogramm nicht weiter benötigen, können wir auch auf das Sichern dieses Registers in der Warteroutine verzichten.
Ändern wir doch auch gleich die beiden Zeiten. Unser endgültiges Programm sieht nun so aus:
.include
"m8def.inc" Start: ldi r16,LOW(RAMEND) out SPL,r16 ldi r16,HIGH(RAMEND)
out
SPH,r16
Schleife: ldi r18,1 rcall Warte ldi r16,0b00000000 out PORTD,r16 ldi r18,4 rcall Warte rjmp Schleife
Warte: push r16 push r17 Warte1: ldi r17,195 Warte2: ldi r16,170 Warte3: dec r16 brne Warte3 dec r17 brne Warte2 dec r18 brne Warte1 pop r17 pop r16 ret |