AVR-Tutorial: Stack

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
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 bei avr-asm-tutorial.net.

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 wäre 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 Menü 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 drei 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.

A propos 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.

Multitasking

Mit nur wenig Aufwand lässt sich kooperatives Multitasking oder Multithreading zwischen 2 Threads realisieren. Nahezu alle AVRs haben das Register SPL oder SPH:SPL zum Lesen und Verändern des Stapelzeigers. Während für den Hauptthread der Stapelzeiger in irgendeiner Form initialisiert und ans Ende des RAM verweist, muss für jeden weiteren Thread ein Stapel-Bereich angelegt werden. Sinnvollerweise im nicht initialisierten RAM.

Für das Umschalten zwischen den beiden Stapeln, mithin der Threads, braucht es noch einen Speicher für den jeweils anderen Stapelzeiger. Für ATtinys mit 8-Bit-adressierbarem RAM (also nur SPL) und genau 2 Threads sieht das so aus:

byte stacksave NOINIT;

void initThread(void(*p)()) {	// p = Funktionszeiger
 static byte stack[30] NOINIT;
 byte*stackend=stack+sizeof stack;
 stacksave=byte(unsigned(stackend-7));	// POP macht Pre-Inkrement auf AVR
// Die vier darüber liegenden Bytes werden zu W16 und Y
// für den Thread. Diese werden bei yield() thread-lokal gesichert.
 stackend[-2]=unsigned(p)>>8;	// Lo und Hi sind vertauscht auf dem Stack!
 stackend[-1]=unsigned(p)&0xFF;	// <p> kommt als Wortadresse, nicht halbieren
}

// Threadwechsel-Funktion, in Assembler implementiert
extern "C" void yield();

void worker() __attribute__((noreturn));
void worker() {
 for(;;) {
// tue irgendwas
  yield();	// typischerweise im Innern von Funktionen, die auf ein Interrupt-Ereignis warten
 }
}

// in der Initialisierungssequenz
initThread(worker);

// in der Hauptschleife
yield();		// zum Worker-Thread

Und der Assembler-Teil:

 
.global yield
// Multithreading: Da die Anzahl der Threads von vornherein bekannt und 2 ist,
// ist diese Yield-Funktion (yield = Vorfahrt gewähren) einfach ein Stack-Toggler.
// Beachte: Obwohl es so aussieht als ob yield nur W24 verändert,
// verändert diese Funktion (aus Sicht des Aufrufers) alle Register
// und rettet nur W16 und Y.
// Interessanterweise müssen keine Interrupts gesperrt werden!
// (Das ist erforderlich bei AVR-Controllern _mit_ SPH, falls man nicht
// geschickterweise beide Stacks in eine „Page“ mit gleichem SPH sperrt.)
yield:	push	YH
		push	YL
		push	r17
		push	r16
		in		r24,SPL
		lds		r25,stacksave
		sts		stacksave,r24
		out		SPL,r25
		pop		r16
		pop		r17
		pop		YL
		pop		YH
		ret			// zum anderen Thread

Für mehr als 2 Threads wird das Ganze deutlich aufwändiger und lohnt sich nur noch für ATmegas. Diese Implementierung ist kompatibel zur avr-g++-ABI und „wegreservierten“ Registern R2..R15.

Das Ganze beißt sich prinzipiell nicht mit sleep_cpu().

Preemptives Multitasking entsteht daraus, wenn die (eine) Yield-Funktion vom Timer zyklisch aufgerufen wird. Dabei müssen ALLE, wirklich ALLE Register (auch R0 und R1) (außer die wegreservierten) sowie die Flags gerettet werden. Das benötigt mehr als die o.a. 30 Byte Platz auf dem Stack.

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 → 2-Byte-Stack-Pointer.)