AVR-Tutorial: Stack

Wechseln zu: Navigation, Suche

Motivation

Bisher war es so, dass wenn die Programmausführung an einer anderen Stelle fortgesetzt werden soll, als sich durch die Abfolge der Befehle ergibt, mittels rjmp an diese andere Stelle gesprungen wurde. Das ist aber oft nicht ausreichend. Oft möchte man den Fall haben, dass man aus der normalen Befehlsreihenfolge heraus eine andere Sequenz von Befehlen ausgeführt wird und wenn diese abgearbeitet ist, genau an die Aufrufstelle zurückgesprungen wird. Da diese eingeschobene Sequenz an vielen Stellen aufrufbar sein soll, gelingt es daher auch nicht, mittels eines rjmp wieder zur aufrufenden Stelle zurück zu kommen, denn dann müsste ja dieser Rücksprung je nachdem von wo der Hinsprung gekommen ist entsprechend modifiziert werden.

Die Lösung dieses Dilemmas besteht in einem eigenen Befehl rcall. Ein rcall macht prinzipiell auch den Sprung zu einem Ziel, legt aber gleichzeitig auch noch die Adresse von wo der Sprung erfolgt ist in einem speziellen Speicherbereich ab, so dass sein 'Gegenspieler', der Befehl ret (wie Return) anhand dieser abgelegten Information wieder genau zu dieser Aufrufstelle zurückspringen kann. Diesen speziellen Speicherbereich nennt man den "Stack". Stack bedeutet übersetzt soviel wie Stapel. Damit ist ein Speicher nach dem LIFO-Prinzip ("last in first out") gemeint. Das bedeutet, dass das zuletzt auf den Stapel gelegte Element auch zuerst wieder heruntergenommen wird. Es ist nicht möglich, Elemente irgendwo in der Mitte des Stapels herauszuziehen oder hineinzuschieben. Ein Stack (oder Stapel) funktioniert wie ein Stapel Teller. Der Teller, welcher zuletzt auf den Stapel gelegt wird, ist auch der erste, welcher wieder vom Stapel heruntergenommen wird. Und genau das wird in diesem Fall ja auch benötigt: jeder rcall legt seine Rücksprungadresse auf den Stack, so dass alle nachfolgenden ret jeweils in umgekehrter Reihenfolge wieder die richtigen Rücksprungadressen anspringen.

Bei allen aktuellen AVR-Controllern wird der Stack im RAM angelegt. Der Stack wächst dabei von oben nach unten: Am Anfang wird der Stackpointer (Adresse der aktuellen Stapelposition) auf das Ende des RAMs gesetzt. Wird nun ein Element hinzugefügt, wird dieses an der momentanen Stackpointerposition abgespeichert und der Stackpointer um 1 erniedrigt. Soll ein Element vom Stack heruntergenommen werden, wird zuerst der Stackpointer um 1 erhöht und dann das Byte von der vom Stackpointer angezeigten Position gelesen.

Aufruf von Unterprogrammen

Dem Prozessor dient der Stack hauptsächlich dazu, Rücksprungadressen beim Aufruf von Unterprogrammen zu speichern, damit er später noch weiß, an welche Stelle zurückgekehrt werden muss, wenn das Unterprogramm mit ret oder die Interruptroutine mit reti beendet wird.

Das folgende Beispielprogramm (AT90S4433) zeigt, wie der Stack dabei beeinflusst wird:

Download stack.asm

 
.include "4433def.inc"     ; bzw. 2333def.inc

.def temp = r16

         ldi temp, RAMEND  ; Stackpointer initialisieren
         out SP, temp

         rcall sub1        ; sub1 aufrufen

loop:    rjmp loop


sub1:
                           ; hier könnten ein paar Befehle stehen
         rcall sub2        ; sub2 aufrufen
                           ; hier könnten auch ein paar Befehle stehen
         ret               ; wieder zurück

sub2:
                           ; hier stehen normalerweise die Befehle,
                           ; die in sub2 ausgeführt werden sollen
         ret               ; wieder zurück

.def temp = r16 ist eine Assemblerdirektive. Diese sagt dem Assembler, dass er überall, wo er "temp" findet, stattdessen "r16" einsetzen soll. Das ist oft praktisch, damit man nicht mit den Registernamen durcheinander kommt. Eine Übersicht über die Assemblerdirektiven findet man hier.

Bei Controllern, die mehr als 256 Byte RAM besitzen (z. B. ATmega8), passt die Adresse nicht mehr in ein Byte. Deswegen gibt es bei diesen Controllern das Stack-Pointer-Register aufgeteilt in SPL (Low) und SPH (High), in denen das Low- und das High-Byte der Adresse gespeichert wird. Damit es funktioniert, muss das Programm dann folgendermaßen geändert werden:

Download stack-bigmem.asm

 
.include "m8def.inc"

.def temp = r16

         ldi temp, HIGH(RAMEND)            ; HIGH-Byte der obersten RAM-Adresse
         out SPH, temp
         ldi temp, LOW(RAMEND)             ; LOW-Byte der obersten RAM-Adresse
         out SPL, temp

         rcall sub1                        ; sub1 aufrufen

loop:    rjmp loop


sub1:
                                           ; hier könnten ein paar Befehle stehen
         rcall sub2                        ; sub2 aufrufen
                                           ; hier könnten auch Befehle stehen
         ret                               ; wieder zurück

sub2:
                                           ; hier stehen normalerweise die Befehle,
                                           ; die in sub2 ausgeführt werden sollen
         ret                               ; wieder zurück

Natürlich ist es unsinnig, dieses Programm in einen Controller zu programmieren. Stattdessen sollte man es mal mit dem AVR-Studio simulieren, um die Funktion des Stacks zu verstehen.

Als erstes wird mit Project/New ein neues Projekt erstellt, zu dem man dann mit Project/Add File eine Datei mit dem oben gezeigten Programm (stack.asm) hinzufügt. Nachdem man unter Project/Project Settings das Object Format for AVR-Studio ausgewählt hat, kann man das Programm mit Strg+F7 assemblieren und den Debug-Modus starten.

Danach sollte man im Menu View die Fenster Processor und Memory öffnen und im Memory-Fenster Data auswählen.

Das Fenster Processor

  • Program Counter: Adresse im Programmspeicher (FLASH), die gerade abgearbeitet wird
  • Stack Pointer: Adresse im Datenspeicher (RAM), auf die der Stackpointer gerade zeigt
  • Cycle Counter: Anzahl der Taktzyklen seit Beginn der Simulation
  • Time Elapsed: Zeit, die seit dem Beginn der Simulation vergangen ist

Im Fenster Memory wird der Inhalt des RAMs angezeigt.

Sind alle 3 Fenster gut auf einmal sichtbar, kann man anfangen, das Programm (in diesem Fall "stack.asm") mit der Taste F11 langsam Befehl für Befehl zu simulieren.

Wenn der gelbe Pfeil in der Zeile out SPL, temp vorbeikommt, kann man im Prozessor-Fenster sehen, wie der Stackpointer auf 0xDF (ATmega8: 0x45F) gesetzt wird. Wie man im Memory-Fenster sieht, ist das die letzte RAM-Adresse.

Wenn der Pfeil auf dem Befehl rcall sub1 steht, sollte man sich den Program Counter anschauen: Er steht auf 0x02 (ATmega8: 0x04).

Drückt man jetzt nochmal auf F11, springt der Pfeil zum Unterprogramm sub1. Im RAM erscheint an der Stelle, auf die der Stackpointer vorher zeigte, die Zahl 0x03 (ATmega8: 0x05). Das ist die Adresse im ROM, an der das Hauptprogramm nach dem Abarbeiten des Unterprogramms fortgesetzt wird. Doch warum wurde der Stackpointer um 2 verkleinert? Das liegt daran, dass eine Programmspeicheradresse bis zu 2 Byte breit sein kann, und somit auch 2 Byte auf dem Stack benötigt werden, um die Adresse zu speichern.

Das gleiche passiert beim Aufruf von sub2.

Zur Rückkehr aus dem mit rcall aufgerufenen Unterprogramm gibt es den Befehl ret. Dieser Befehl sorgt dafür, dass der Stackpointer wieder um 2 erhöht wird und die dabei eingelesene Adresse in den "Program Counter" kopiert wird, so dass das Programm dort fortgesetzt wird.

Apropos Program Counter: Wer sehen will, wie so ein Programm aussieht, wenn es assembliert ist, sollte mal die Datei mit der Endung ".lst" im Projektverzeichnis öffnen. Die Datei sollte ungefähr so aussehen:

listfile.gif

Im blau umrahmten Bereich steht die Adresse des Befehls im Programmspeicher. Das ist auch die Zahl, die im Program Counter angezeigt wird, und die beim Aufruf eines Unterprogramms auf den Stack gelegt wird. Der grüne Bereich rechts daneben ist der OP-Code des Befehls, so wie er in den Programmspeicher des Controllers programmiert wird, und im roten Kasten stehen die "mnemonics": Das sind die Befehle, die man im Assembler eingibt. Der nicht eingerahmte Rest besteht aus Assemblerdirektiven, Labels (Sprungmarkierungen) und Kommentaren, die nicht direkt in OP-Code umgewandelt werden. Der grün eingerahmte Bereich ist das eigentliche Programm, so wie es der µC versteht. Die jeweils erste Zahl im grünen Bereich steht für einen Befehl, den sog. OP-Code (OP = Operation). Die zweite Zahl codiert Argumente für diesen Befehl.

Sichern von Registern

Eine weitere Anwendung des Stacks ist das "Sichern" von Registern. Wenn man z. B. im Hauptprogramm die Register R16, R17 und R18 verwendet, dann ist es i.d.R. erwünscht, dass diese Register durch aufgerufene Unterprogramme nicht beeinflusst werden. Man muss also nun entweder auf die Verwendung dieser Register innerhalb von Unterprogrammen verzichten, oder man sorgt dafür, dass am Ende jedes Unterprogramms der ursprüngliche Zustand der Register wiederhergestellt wird. Wie man sich leicht vorstellen kann ist ein "Stapelspeicher" dafür ideal: Zu Beginn des Unterprogramms legt man die Daten aus den zu sichernden Registern oben auf den Stapel, und am Ende holt man sie wieder (in der umgekehrten Reihenfolge) in die entsprechenden Register zurück. Das Hauptprogramm bekommt also wenn es fortgesetzt wird überhaupt nichts davon mit, dass die Register inzwischen anderweitig verwendet wurden.

Download stack-saveregs.asm

  
.include "4433def.inc"            ; bzw. 2333def.inc

.def temp = R16

         ldi temp, RAMEND         ; Stackpointer initialisieren
         out SP, temp

         ldi temp, 0xFF
         out DDRB, temp           ; Port B = Ausgang

         ldi R17, 0b10101010      ; einen Wert ins Register R17 laden

         rcall sub1                ; Unterprogramm "sub1" aufrufen
 
         out PORTB, R17           ; Wert von R17 an den Port B ausgeben

loop:    rjmp loop                ; Endlosschleife


sub1:
         push R17                 ; Inhalt von R17 auf dem Stack speichern

         ; hier kann nach belieben mit R17 gearbeitet werden,
         ; als Beispiel wird es hier auf 0 gesetzt

         ldi R17, 0

         pop R17                  ; R17 zurückholen
         ret                      ; wieder zurück zum Hauptprogramm

Wenn man dieses Programm assembliert und in den Controller lädt, dann wird man feststellen, dass jede zweite LED an Port B leuchtet. Der ursprüngliche Wert von R17 blieb also erhalten, obwohl dazwischen ein Unterprogramm aufgerufen wurde, das R17 geändert hat.

Auch in diesem Fall kann man bei der Simulation des Programms im AVR-Studio die Beeinflussung des Stacks durch die Befehle push und pop genau nachvollziehen.

Sprung zu beliebiger Adresse

Dieser Abschnitt ist veraltet, da nahezu alle ATmega/ATtiny Typen IJMP/ICALL unterstützen.

Kleinere AVR besitzen keinen Befehl, um direkt zu einer Adresse zu springen, die in einem Registerpaar gespeichert ist. Man kann dies aber mit etwas Stack-Akrobatik erreichen. Dazu einfach zuerst den niederen Teil der Adresse, dann den höheren Teil der Adresse mit push auf den Stack legen und ein ret ausführen:

 
	ldi ZH, high(testRoutine)
	ldi ZL, low(testRoutine)
	
	push ZL
	push ZH
	ret

        ...
testRoutine:
	rjmp testRoutine

Auf diese Art und Weise kann man auch Unterprogrammaufrufe durchführen:

 
	ldi ZH, high(testRoutine)
	ldi ZL, low(testRoutine)
	rcall indirectZCall
	...


indirectZCall:
	push ZL
	push ZH
	ret

testRoutine:
	...
	ret

Größere AVR haben dafür die Befehle ijmp und icall. Bei diesen Befehlen muss das Sprungziel in ZH:ZL stehen.

Weitere Informationen (von Lothar Müller):

(Der in dieser Abhandlung angegebene Befehl MOV ZLow, SPL muss IN ZL, SPL heißen, da SPL und SPH I/O-Register sind. Ggf ist auch SPH zu berücksichtigen --> 2byte Stack-Pointer)