Yellow Submarine (ähem) ... Subroutine ("Programmieren" lernen)

  • https://www.youtube.com/watch?v=7i1XD2yN4Ug ...


    Manchmal hat man schon bestimmte Routinen, die man direkt woanders wiederverwenden möchte. Oder es sieht einfach schöner im Programm aus, wenn man bestimmte Zusatzberechnungen quasi auslagert. Oder man hat ein Betriebssystem oder gar bereits eine Programmbibliothek, die einem bestimmte Dinge abnehmen und erledigen können.

    Für all diese Fälle bieten sich Unterprogramme an, sogenannte Subroutinen.

    Damit man diese verwenden kann, muß man natürlich wissen, wo im Speicher sie beginnen. Man benötigt also ihre Adresse.

    Dann könnte man vermuten, daß man dort einfach mit einem direkten Sprung hingelangt. Das ginge prinzipiell auch - aber woher soll die Subroutine denn, wenn sie einmal durchgelaufen und fertig ist, wissen, wo es dann weitergeht ? Der normale Sprung springt eben nur und macht sonst gar nichts weiter.


    Damit man trotzdem wieder vom Ende der Subroutine an die Stelle gelangt, von wo der Aufruf kam, muß also in irgendeiner Form die letzte Adresse gemerkt werden, die wo der Sprung herkam; besser noch: die Adresse, wo es dann weitergehen soll.


    Genau so einen Sprungbefehl, der das kann, gibt es dann auch (meistens).

    Im Fall vom 6502 heißt der auch so : Jump (to) Sub-Routine , JSR

    Woanders, z.B. beim großen "Nebenbuhler" Z80, benutzt man : Call

    auf den ARMs wäre das Equivalent ein : Branch (with) Link , BL


    Die Herkunftsadresse wird oft einfach auf den Stack (den Stapelspeicher) gelegt, eine Art Speicher, der es erlaubt auch aus der Subroutine heraus noch eine weitere Unterroutine anzuspringen, also eine Sub-Sub-Routine, und trotzdem beide Rücksprungadressen wiederzufinden.


    Am Ende der jeweiligen Subroutine muß man sich daher um Adressen nicht kümmern, sondern gibt man einfach einen Rückkehrbefehl (, ein RETURN). Dieser lädt i.P. einfach die gespeicherte Adresse und die CPU macht dann dort weiter, wo der Subroutinen-Aufruf stand.

    Beachten muß man aber, daß dieser Service i.a. nicht mehr bietet.

    Alle Werte die noch in Registern stehen, oder auch Flags, oder Werte in Adressen, die man als Zwischenspeicher benutzt werden NICHT automatisch gesichert.

    Ändert man nun in der Subroutine Werte in z.B. Registern, dann wird beim Zurückkehren ins Hauptprogramm auch der geänderte Wert mit zurückkommen !


    Darum muß man sich gut überlegen, welche Strukturen man in einer Subroutine "frei" beschreiben darf und von welchen man besser vorher eine Kopie anlegt, die man am Ende wieder in die Register lädt - bevor man mit RETURN zurückkehrt.



    Eine weitere Schwierigkeit ist, daß der Stack zwar bequem zu benutzen ist, aber nicht beliebig groß sein kann. Das kann er schon prinzipiell nicht, weil ja das RAM des Rechners nicht beliebig groß ist, im Speziellen ist es aber oft so, daß der Stack auf eine bestimmte fixe Maximalgröße ausgelegt ist. Wenn nun zu viele Subroutinen hintereinander aufgerufen werden, legt jede ihre Rücksprungadresse auf den Stack - und das geht nur so lange gut, bis dieser komplett gefüllt ist. Danach bricht das System i.a. einfach mit einem Fehler in sich zusammen.


    Besonders beachten muß man dies, wenn man sogenannte rekursive Strukturen benutzt - also Routinen, die sich selbst als ihre eigene Unterroutine aufrufen. Solche Konstrukte sind daher zwar interessant und auch mächtig, aber nicht beliebig oft hintereinander verwendbar (es sei denn man kümmert sich selbst um die Rücksprungadressen).


    Zusätzlich wird auf dem Stack meist auch noch anderes gespeichert, was diesen Platz dann nochmal reduziert.


    Deshalb auch muß man sich gut überlegen, ob man - was man durchaus machen kann, und was auch üblich ist - die Register, die man zwischenspeichert damit die Werte für die Hauptroutine erhalten bleiben, auf dem Stack abspeichern will oder vielleicht doch woanders.


    Diese Eingrenzung der möglichen Sprungzahl ist es auch, weshalb ich beim direkten Sprung geschrieben habe, daß er der einzige universale Sprung ist.

    -- 1982 gab es keinen Raspberry Pi , aber Pi und Raspberries

  • =6502=


    Der Unterroutinenaufruf heißt hier

    JSR $Adresse


    die Rückkehr erfolgt mit

    RTS



    Als Beispiel für den unbedingten Sprung war vorgeschlagen


    .5000 JMP $5500

    .5003 BRK


    .5500 JMP $5000

    .5503 BRK


    nun kann man das auch mal mit JSR probieren


    .5000 JSR $5500

    .5003 BRK


    .5500 JSR $5000

    .5503 BRK


    und mit G 5000 starten ( G 5500 geht natürlich ebenso )


    Frage: Warum passiert nicht das Gleiche wie bei JMP ?



    Bei dem "Flaggen und Abzweige" Teil gab es den Bedarf nach einer Warteschleife. Da man sowas evtl. öfters mal gebrauchen kann, bietet es sich an, daraus eine Subroutine zu machen. Zudem speichert sie bereits die benutzten Register XR und YR ab, wenn auch dort noch aus anderen Gründen.


    Damit sie schön wiedergefunden wird, bekommt sie erstmal einen Sonderplatz bei Adresse $6000


    Also - die Warteschleife als Subroutine (Version 1)


    .6000 STX $4000

    .6003 STY $4001

    .6006 LDX #$75

    .6008 LDY #$FF

    .600A DEY

    .600B BNE $600A

    .600D DEX

    .600E BNE $6008

    .6010 LDX $4000

    .6013 LDY $4001

    .6016 RTS




    Zum Abspeichern aus dem MONITOR heraus, kann man folgendes nutzen (für Laufwerk 8, also Disk)

    S "SUBWAIT",8,6000,6020


    Um nun zu schauen, ob das Unterprogramm funktioniert, benutzen wir das Vielfarb-Demo von Joe_IBM

    aber dazu kommt nun noch der Sprung in die Subroutine


    .5000 INC $FF19

    .5003 JSR $6000

    .5006 JMP $5000

    .5009 BRK


    Auch hier wieder: Werte für die Laufzeit mal ändern !


    >6007 ff

    für XR, oder

    >6009 10

    für YR

    oder andere, je nach Wahl.

    -- 1982 gab es keinen Raspberry Pi , aber Pi und Raspberries

    2 Mal editiert, zuletzt von ThoralfAsmussen ()

  • =Z80=


    Hier folgt jetzt die Version für die andere Fraktion. Der größte Teil behandelt dabei die Konstruktion der Schleife; das ist eigentlich nicht das Thema dieses Threads, bietet aber einige interessante Einsichten speziell in die Unterschiede zwischen den Prozessoren.

    Die Beispiele sind alles "Trockenübungen" und deshalb nicht verifiziert - sollte ich einen Fehler drin haben bitte ich um sachdienliche Hinweise :tüdeldü:


    Eine einfache Übersetzung der Syntax sähe etwa so aus:


    Leider funktioniert das so nicht, aber dazu später.


    Wir sehen zunächst, dass der Befehl zum Schreiben in oder aus dem RAM derselbe ist wie zum direkten Laden von Registern oder auch zum Transfer zwischen zwei Registern:


    LD [Ziel],[Quelle] ; kopiere (lade) den Wert aus Quelle in das Ziel


    Details hierzu folgen hoffentlich noch in dem entsprechenden Nachbarthread.


    Außerdem treffen wir den relativen Sprung wieder, diesmal mit Bedingung. Beim Z80 unterscheiden wir absolute Sprünge, die den gesamten Adressraum von 64K erreichen können, von den relativen Sprüngen im Bereich von 256 Adressen in unmittelbarer Nachbarschaft.

    Beide Sprungarten können mit Bedingungen versehen werden:


    Code
    JP nnnn       ; unbedingter absoluter Sprung
    JP Z,nnnn     ; absoluter Sprung, wenn Zeroflag gesetzt
    JR NZ,nnnn    ; relativer Sprung, wenn Zeroflag nicht gesetzt
    JR C,nnnn     ; relativer Sprung, wenn Carryflag (Übertrag) gesetzt
    etc.


    Jetzt aber zu der Frage, warum die obige Version nicht funktioniert:

    Tatsächlich ist es leider so, dass die Register B und C nicht einfach in und aus dem RAM übertragen werden können. Das ginge nur über den Umweg über den Akku.

    Allerdings hat der Z80 einen Befehl, um das Registerpaar BC im Speicher zu sichern bzw. zurück zu lesen:


    Code
         LD (4000),BC  ; BC nach 4000 und 4001 sichern
         LD BC,(4000)  ; BC von dort zurücklesen


    Damit ergibt sich dann folgende Version:


    Code
         LD (4000),BC
         LD BC, 7500
    L1:  DEC C
         JR NZ, L1
         DEC B
         JR NZ, L1
         LD BC,(4000)
         RET

    Das sollte jetzt funktionieren.

    Eine Kleinigkeit ist noch anders als im 6502-Beispiel: Hier startet die innere Schleife immer bei 0. Der erste DEC-Befehl liefert dann den Wert $FF; insgesamt zählen wir einmal mehr (256 mal gegenüber 255 mal). Dafür sparen wir uns das erneute Laden des C-Registers, da es - wenn nach 256 Durchläufen der inneren Schleife nun die äußere Schlife einen Schritt macht - wieder beim gleichen Startwert 0 angekommen ist.

    Außerdem nutzen wir wieder die Möglichkeit, die beiden Register B und C zusammenzufassen und mit einem Befehl zu laden.


    Unschön an dieser Lösung ist das Sichern und Zurücklesen des BC-Registers. Der Z80 braucht für diese Opcodes jeweils 4 Byte, und überhaupt ist es (zumindest beim Z80) üblich, sowas grundsätzlich auf dem Stack zu machen. Vielleicht liegt es daran, dass der Z80 problemlos alle (Doppel-)Register sichern kann:


    Code
         PUSH BC
         LD BC, 7500
    L1:  DEC C
         JR NZ, L1
         DEC B
         JR NZ, L1
         POP BC
         RET

    Während die erste Version 18 Byte benötigt, sind es hier nur noch 12. Schneller ist es auch, aber das sollte bei einer Warteschleife ja keine Rolle spielen ... ;)


    Sorry, wenn ich den Stack hier vorgezogen habe - da sollten wir schleunigst mal ein eigenes Kapitel zu schreiben.


    Und damit wir auch etwas über die Feinheiten des Z80 lernen, hier die "typische" Lösung unter maximaler Ausnutzung der 16 Bit Register:

    Code
         PUSH BC
         LD BC, 7500
    L1:  DEC BC
         LD A,B
         OR C
         JR NZ, L1
         POP BC
         RET

    In diesem Fall schachtelt man nicht zwei Schleifen mit je einem 8 Bit Zählern, sondern benutzt eine Schleife mit einem 16 Bit Zähler. Denn der Z80 inkrementiert und dekrementiert problemlos auch die Doppelregister.


    Dummerweise werden dabei aber (anders als bei den 8 Bit Decrements) die Flags nicht gesetzt. Das muss man unbedingt wissen, um kapitale Programmfehler zu vermeiden. Als Abhilfe wird der Umweg über den Akku genommen. Das ist ein klassischer Weg, die Doppelregister zu prüfen: ein Register wird in den Akku übertragen, und das zweite wird damit logisch "or" verknüpft. Das Ergebnis ist genau dann 0, wenn beide Register B und C den Wert 0 haben - und diesmal wird auch das Zeroflag gesetzt, das im nachfolgenden JR NZ abgefragt wird.


    Als Nachteil wird der Akku zerstört - wer das verhindern will, sichert den eben auch vorher auf dem Stack.


    Zu guter Letzt die Frage nach dem Aufruf: Der erfolgt beim Z80 über die Call-Befehle. Diese sind grundsätzlich absolut, d.h. sie können jede 16-Bit-Adresse erreichen, und es gibt sie sowohl unbedingt als auch mit den Bedingungen, wie wir sie schon bei den Sprüngen kennengelernt haben:


    Code
    CALL nnnn      ; unbedingter Aufruf einer Subroutine
    CALL Z,nnnn    ; Aufruf der Subroutine nur bei gesetztem Zeroföag
    CALL NC,nnnn   ; dito nur bei nicht gesetztem Übertragsflag
    etc.

    Ansonsten ist der Mechanismus für den geordneten Sprung zur Subroutine und zurück wie oben beschrieben: nach dem Einlesen des vollständigen Call-Befehls zeigt der Programmzähler (instruction pointer) auf den nächsten Befehl im Speicher. Bei Ausführung des Calls wird dieses Register auf den Stack gesichert und mit der Zieladresse neu geladen - wodurch die Programmausführung bei der Subroutine fortgesetzt wird.

    Wenn der Ret-Befehl gelesen und verarbeitet wird wird der letzte Eintrag vom Stack zurückgelesen und in den Programmzähler geladen, woraufhin die Ausführung an der Stelle hinter den Call fortgesetzt wird.


    Damit das Ganze auch sauber funktioniert muss die Subroutine dafür sorgen, dass sie den Stack so hinterlässt, wie sie ihn vorgefunden hat: wenn sie Register dort geretter hat muss sie genau so viele Register wieder zurücklesen (genausoviele POPs wie PUSHs). Andernfalls werden Daten als Adresse des Programms interpretiert, was in aller Regel zum Crash führt.