- Frühe Computer mussten Funktionsaufrufe auch ohne Stack und Heap umsetzen, und Compiler verwalteten den Aufrufzustand über versteckte globale Variablen für Parameter, Rücksprungadresse und lokale Variablen
- Der Aufrufer speicherte die Argumente, schrieb die Rückkehrposition in eine Rücksprungadressvariable und sprang dann zum Einstiegspunkt der Funktion; die Funktion sprang nach der Berechnung zur gespeicherten Adresse zurück
- Auch logisch lokale Variablen nutzten tatsächlich globalen Speicher, sodass der Code äußerlich wie eine Funktion wirkte, intern aber eher festen Speicherbereichen und
gotoentsprach - Manche ABIs und Prozessoren optimierten Argumentübergabe und Rücksprungadressbehandlung mit Registern oder
branch with link, doch die grundlegenden Einschränkungen blieben bestehen - Da die Rücksprungadresse derselben Funktion durch einen neuen Aufruf überschrieben wird, waren rekursive Aufrufe unmöglich; damalige Sprachen reagierten darauf, indem sie Rekursion verboten oder nur explizit erlaubten
Wie Funktionsaufrufe ohne Stack aufgebaut wurden
- In frühen Computerumgebungen gab es weder den heute selbstverständlichen Stack noch einen Heap
- Dynamische Speicherzuweisung ohne Heap konnte durch Puffer fester Größe ersetzt werden
- Auch bei Daten variabler Größe wurde vorab ein ausreichend großer fester Puffer reserviert
- Überschritten die angeforderten Daten die Pufferkapazität, beendete das Programm sich mit einem fatalen Fehler
- Freundlichere Implementierungen erlaubten es, die maximale Kapazität zur Compile-Zeit festzulegen
- Ausgereiftere Implementierungen legten einen eigenen Allocator über den festen Puffer und machten ihn wie
allocateundfreenutzbar
Aufrufkonvention auf Basis versteckter globaler Variablen
- Um Funktionsaufrufe ohne Stack umzusetzen, definierte der Compiler für jede Funktion mehrere versteckte globale Variablen
- Eine globale Variable je Eingabeparameter
- Eine globale Variable für die Rücksprungadresse der Funktion
- Globale Variablen, die lokalen Variablen entsprechen
- Der aufrufende Code lief in folgender Reihenfolge ab
- Er speicherte die Parameterwerte in den entsprechenden versteckten globalen Variablen
- Er schrieb die Rückkehrposition in die Rücksprungadressvariable der Funktion
- Er sprang per
gotozur Startposition der Funktion
- Die Funktion las und schrieb sowohl Parameter als auch lokale Variablen aus den versteckten globalen Variablen
- Nach Abschluss legte sie den Rückgabewert in ein Rückgabewert-Register und sprang zu der Adresse, die in der Rücksprungadressvariable der Funktion gespeichert war
Beispiel: C-ähnlicher Code wird zu goto-basiertem Code
- Die Beispielfunktion
add_two_values(int a, int b)könnte ohne Stack in folgende Speicherbereiche übersetzt werdena2v_aunda2v_bsind globale Variablen zum Speichern der Argumentea2v_cist eine globale Variable, die der lokalen Variablecentsprichta2v_retaddrist eine globale Variable zum Speichern der Rückkehradresse
- Der Aufrufer
sample()speichert31415und2718jeweils in den globalen Argumentvariablen - Anschließend schreibt er die Position
resumeina2v_retaddrund springt zuadd_two_values add_two_valuesspeichert das Berechnungsergebnis inreturn_value_registerund kehrt dann übera2v_retaddrzurück- Der zur Position
resumezurückgekehrte Aufrufer speichert den Wert aus dem Rückgabewert-Register insample_x
Optimierung mit Registern und branch with link
- Dieselbe Struktur ließ sich auf ABI-Ebene durch Registerübergabe beschleunigen
- Viele Prozessoren stellten ein spezielles
link registerund einebranch with link-Instruktion bereitbranch with linkspeichert automatisch die Adresse der Instruktion nach der Sprunganweisung im Link Register- Der Aufrufer kann die ersten beiden Argumente in
argument_register_1undargument_register_2legen - Die aufgerufene Funktion kann diese Registerwerte in ihre eigenen versteckten globalen Variablen übertragen und verwenden
- Auch die Rücksprungadresse kann aus
link_registerin die Rücksprungadressvariable der Funktion gespeichert werden - Diese Optimierung behält die Grundstruktur bei, dass Aufruf und Rückkehr auch ohne Stack möglich sind
Warum Rekursion verhindert wird
- Die zentrale Einschränkung dieser Aufrufweise ist: keine rekursiven Aufrufe
- Bei einem rekursiven Aufruf wird die Rücksprungadressvariable derselben Funktion mit der Rücksprungadresse des neuen Aufrufs überschrieben
- Wenn der äußere Aufruf endet, ist die ursprüngliche Rückkehrposition verschwunden, und es wird an die falsche Stelle gesprungen
- Die damaligen Programmiersprachen umgingen dieses Problem, indem sie Rekursion nicht unterstützten
- FORTRAN unterstützte anfangs nicht einmal Subroutinen; Subroutinen wurden 1958 hinzugefügt
- Rekursionsunterstützung wurde in FORTRAN erst 1991 standardisiert, und auch dann mussten Subroutinen ausdrücklich mit
RECURSIVEgekennzeichnet werden
Selbstmodifizierender Code und Subroutine-Instruktionen früher Prozessoren
- Manche Compiler nutzten auf raffiniertere Weise selbstmodifizierenden Code
- Das Adressfeld innerhalb der Sprunganweisung am Ende der Funktion diente faktisch als Rücksprungadressvariable
- Diese Methode war nicht nur ein einfacher Trick, sondern konnte eine praktische Notwendigkeit sein
- Manche Prozessoren unterstützten möglicherweise keine indirekten Sprünge
- Nachdem die praktische Nützlichkeit von Subroutinen anerkannt war, fügten mehrere Prozessoren eigene Aufrufinstruktionen hinzu
- Sie speicherten die Rücksprungadresse im ersten Wort der Subroutine
- Die eigentliche Ausführung begann beim zweiten Wort
- Für die Rückkehr wurde ein indirekter Sprung über das Startlabel der Subroutine ausgeführt
- Im Beispiel-Assembly speichert
bsr add_two_valuesdie Rücksprungadresse im ersten Wort vonadd_two_valuesund beginnt die Ausführung erst bei der tatsächlichen Instruktion nach dem als Platzhalter dienendennop
1 Kommentare
Hacker-News-Kommentare
Zu diesem Thema war The Art of Computer Programming wirklich großartig
Auf den ersten Blick wirkt es altmodisch, aber es enthält unglaublich viele Algorithmen für den Umgang mit dynamisch veränderlichen Arrays oder Datenstrukturen aus der Zeit vor Heap und Stack
Das Buch führt Schritt für Schritt bis zu Garbage Collection und der Implementierung von Lisp-Listen, und enthält genau das enzyklopädische Wissen, das man von Knuth erwartet
Ein Beispiel, das ich besonders mag, ist die Art, wie zwei Arrays dynamisch einen gemeinsamen Speicherbereich teilen. Wenn ein Array von
location#0nach vorn wächst und das zweite Array vonlocation#Endnach hinten, wird der statisch zugewiesene Speicher effizient aufgeteiltMan kann das auch auf eine beliebige Anzahl von Arrays erweitern, aber an dem Punkt ist es meist besser, einfach
MallocundRealloczu verwenden, und die Technik selbst kommt einer malloc-ähnlichen Routine schon ziemlich naheBeim Einfügen und Einfügen aus der Zwischenablage mussten keine Daten verschoben werden, beim Navigieren hingegen schon. Trotzdem funktionierte es gut
Wenn so viel nicht verfügbar war, wurde weniger als der bevorzugte Wert reserviert, und wenn nicht einmal das Minimum verfügbar war, schlug der Start fehl
Soweit ich mich erinnere, platzierte das System Heap und Bibliotheken im unteren Teil dieses physischen RAM-Stücks und den Stack im oberen Teil
Etwa mit System 8 kam eine Virtualisierungsschicht hinzu, wodurch dieser Ansatz weniger nötig wurde, und zur Zeit von MacOS X nutzte man wie andere Systeme Paging-Speicher, sodass solche Kunststücke nicht mehr erforderlich waren
Trotzdem ist es amüsant, daran zu denken, dass so ein „ein seltsamer Trick“ aus The Art of Computer Programming einmal die Art war, wie RAM für mehrere gleichzeitig laufende Apps zugewiesen wurde
Einer wuchs nach oben, der andere nach unten. Eine faszinierende Architektur, die die versprochene Leistung am Ende aber nicht lieferte
Innerhalb einer Page fester Größe wächst das Offset-Array nach vorn, während das Array variabel langer Zeilenwerte vom Ende aus nach hinten wächst. So wie ich es verstehe, können beim Löschen von Zeilen im hinteren Array Löcher entstehen
Da die Dokumentation für die B-Tree-Struktur selbst TAOCP zitiert, wäre es nicht überraschend, wenn dies eine direkte Inspiration gewesen wäre
Rekursive Funktionen in ALGOL aufzunehmen war ziemlich umstritten und ist bis heute eine interessante Geschichte: https://vanemden.wordpress.com/2014/06/18/how-recursion-got-...
How recursion got into programming: intrigue, betrayal, and advanced semantics - https://news.ycombinator.com/item?id=33123916 - Oktober 2022, 8 Kommentare
How Recursion Got into Programming (2014) - https://news.ycombinator.com/item?id=23061881 - Mai 2020, 47 Kommentare
How recursion got into Algol 60: a comedy of errors - https://news.ycombinator.com/item?id=10131664 - August 2015, 124 Kommentare
How recursion got into programming: a comedy of errors - https://news.ycombinator.com/item?id=8073361 - Juli 2014, 108 Kommentare
Forth-Interpreter für die SUBLEQ-Maschine (https://github.com/howerj/subleq) und einen Interpreter für eine bitserielle Maschine (https://github.com/howerj/bit-serial) habe ich geschrieben; beide hatten keinen für Forth nötigen Function-Call-Stack
SUBLEQ erlaubt nicht einmal indirektes Laden/Speichern, sodass man für alles auch nur halbwegs Komplexe selbstmodifizierenden Code braucht
Mein Ansatz war, für beide Maschinen eine virtuelle Maschine zu bauen, die solche Funktionen übernehmen kann, und auch kooperatives Multithreading einzubauen
Wenn man einen Heap braucht, schreibt man ihn in Forth; auch das Floating-Point-Word-Set schreibt man in Forth. Viele MCUs haben immer noch keine Floating-Point-Instruktionen, und man kann das über Software-Funktionsaufrufe implementieren
Andere Compiler wurden zwar nicht erwähnt, haben aber vermutlich ähnlich gearbeitet. Einige BASIC-Interpreter implementierten ebenfalls erst eine VM und zielten dann darauf; P-Code ist ähnlich
Der Großteil des Basissystemspeichers war Video-RAM und musste über eine ziemlich umständliche Prozedur durch
poke/peekauf die Register des Videochips angesprochen werdenDer Videochip hielt einen automatisch inkrementierenden aktuellen Speicherzeiger vor, sodass der Zeiger bei fortlaufendem Lesen oder Schreiben jeweils um 1 erhöht wurde; allein die Tatsache, dass der Großteil des Systemspeichers nur auf diese Weise erreichbar war, machte das Schreiben großer Programme aber schwierig
Deshalb baute TI eine abstrakte Maschine namens GPL, die diesen Zugriff auf Video-RAM natürlicher machte. Da sie jedoch auf dem TMS9900 interpretiert ausgeführt wurde, war sie langsamer als nativer Code; außerdem konnte die CPU nur dann auf das RAM des Videochips zugreifen, wenn der Chip gerade kein Bildschirm-Scanout machte, etwa während der horizontalen/vertikalen Austastlücken, was es noch langsamer machte
BASIC-Code und Variablen lagen ebenfalls vollständig in diesem Videospeicher, daher ist ziemlich offensichtlich, womit der BASIC-Interpreter des TI-99/4A geschrieben war. Schnell war er ganz und gar nicht
Interessant ist, dass der TMS9900 keine echten Allzweckregister hatte. Die Workspace-Register WR0 bis WR15 lagen irgendwo im Speicher, und das WP-Workspace-Pointer-Register zeigte darauf
Die physischen Register der CPU waren nur PC, WP und das Statusregister. Dadurch war eine sehr primitive Form von Register Windowing möglich: Verzweigte man mit der Instruktion
BLWP, wurde ein neuer Satz von „Registern“ an einer anderen Speicherstelle aktiv, und die Rücksprungadresse wurde im neuen Workspace gespeichertDass ich in letzter Zeit oft über den TI-99/4A spreche, liegt daran, dass ich als persönliches Projekt einen Assembler für diese Maschine baue
Die Aussage, dass einige Prozessoren die Rücksprungadresse im Wort direkt vor der ersten Instruktion der Subroutine speicherten, stimmt; der PDP-8 tat das
Die Evolution des PDP-8 lässt sich auch als Weg hin zu Hardware-Unterstützung für Rekursion betrachten
Anfangs schrieb die
JMS-Instruktion die Rücksprungadresse in das erste Wort der Funktion. Oft legte der Aufrufer die Argumente hinter dieJMS-Instruktion, und der Aufgerufene las die Argumente über Offsets relativ zur Return-Instruktion und inkrementierte sie dabei jedes Mal, sodass die Rücksprungadresse wieder auf die Codeposition zeigteSpäter wurde es recht üblich, mit einer der Auto-Increment-Positionen einen einfachen Stack zu bauen. Der PDP-8 hatte acht Speicherstellen, die sich bei jeder Verwendung als Pointer inkrementierten, und Funktions-Prolog/Epilog verwalteten diesen Stack selbst, was vollständige Rekursion ermöglichte
Noch später bekamen Mikroprozessor-Implementierungen wie der Harris 6120 einen Hardware-Stack, was die Performance verbesserte
R-Instruktion, also eine Instruktion zum Speichern der RücksprungadresseDiese Instruktion speicherte das bereits inkrementierte
PC+1im Adressteil der Instruktion an der Zielposition; per Konvention war dieses Ziel die unbedingte Sprunginstruktion direkt vor Beginn der SubroutineNach der
R-Instruktion stand eineU-Instruktion für den unbedingten Sprung zu der betreffenden SubroutineDie Subroutine kehrte zurück, indem sie an die Adresse vor sich verzweigte; dort stand ein unbedingter Sprung zurück direkt hinter die Aufrufstelle
Ohne fortgeschrittenere Calling Conventions war Rekursion unmöglich. Und alle Instruktionscodes der Assemblersprache bestanden aus einem einzigen Buchstaben
Bei Programmen für AVR-8 fühlt sich die Verwendung der C-Calling-Convention manchmal verrückt an
In Assembler kann man Variablen innerer Schleifen dauerhaft in der großen Registerdatei halten, oder man kann die im Artikel beschriebenen Methoden verwenden
Für solche Apps ist auch die Methode, Funktionen „einzufärben“, gut. Wenn man weiß, dass eine rote Funktion und eine grüne Funktion nie gleichzeitig aktiv sind, kann man lokale Variablen oder Parameter der beiden wiederverwenden
In einem Mikrocontroller-Codebase-Projekt, zu dem ich früher dazustieß, verfolgten mehrere Entwickler seit Wochen schwer zu fassende Bugs in mehreren Subsystemen
Wenn man Code verschob, wanderten die Bugs mit. Nach etwas Nachverfolgung und dem Setzen von Fallen konnte ich Code-Stellen finden, an denen der Call-Stack zu tief wurde und andere Datenstrukturen überschrieb
Als ich Programmieren lernte, wurde ich genau zu dieser Art des Programmierens gezwungen. Nicht in den 1970ern, sondern 2001
Denn meine erste Programmiererfahrung war die halb grafische Skripting-„Sprache“ des Spieleentwicklungstools RPG Maker 2000
Wenn ihr RM2K-Skripting noch nie gesehen habt, stellt euch eine Mischung aus Scratch und Emacs’ Paredit-Modus vor. Beispiel: https://forums.rpgmakerweb.com/data/attachments/21/21958-f89...
Es sieht aus wie Text, lässt sich aber nicht wie Text bearbeiten, sondern nur als Blöcke mit zugehörigen Eigenschaftsdialogen
Natürlich hatte die Skriptsprache des RPG Maker auch nichts Schickes wie einen Stack. Wenn man eine wiederverwendbare Subroutine brauchte, musste man geheime globale Variablen für die Parameter reservieren; Reentrancy gab es nicht
Rückblickend hätte man wohl, wenn man nur stur genug gewesen wäre, sowohl Register als auch einen Runtime-Stack im RPG Maker 2000 implementieren können
Anfangs sieht es einfach aus. Man kann Fake-„Register“ wie die Zero Page des 6502 bauen, und mit indirektem Variablenzugriff (https://rpgmaker.net/tutorials/523/) auch einen Stack
Das Problem ist, dass RM2K Nebenläufigkeit in Form von „Parallel Process“-Skripten hat. Wenn parallele Prozesse solche Abstraktionen verwenden, überschreiben verschiedene „Threads“ munter gegenseitig ihren Zustand
Daher bräuchte man für jeden „virtuellen Core“ mehrere Zero Pages und Stacks und müsste jedem parallelen Skript einen virtuellen Core zuweisen/an ihn binden/ihn schedulen. Mit anderen Worten: Jedes Skript müsste irgendwie einen nur ihm bekannten Stack Pointer haben
Um das auch bei Race Conditions robust zu machen, bräuchte man normalerweise so etwas wie einen Mutex
Wenn man bedenkt, wie hartnäckig RPG-Maker-Spieleentwickler sind, hat wahrscheinlich irgendwer einen Weg gefunden, irgendeine Runtime-Funktion so zu missbrauchen, dass sie sich wie ein Mutex verhält; aber ehrlich gesagt macht mir der Gedanke Angst, wie das in der Praxis aussah
Ich erinnere mich, wie ich auf rpgmaker.net ein Spiel mit einem custom battle system heruntergeladen habe. Das war eine Implementierung, die das gesamte eingebaute Kampfsystem mit Techniken wie den von dir beschriebenen ersetzt hatte
Als ich es im Editor geöffnet und mir angesehen habe, wie es funktionierte, war ich völlig überwältigt. Es gab Hunderte von „Variablen“, und wenn ich mich richtig erinnere, waren nur i64 erlaubt, dazu Hunderte von „Switches“. Die Switches waren Boolean-Werte
Damals hatte ich keinerlei Vorstellung von Stack, Heap oder Funktionsaufrufen
Ich kann mir nicht einmal vorstellen, wie viel Energie es gekostet haben muss, das zu bauen und zu warten/debuggen
Wenn ich mich richtig erinnere, schrieb man BASIC-Programme auf dem ZX81 ziemlich nah an „ohne Stack“
1 GOTO 3010 LET C = A + B20 RETURN30 LET A = 140 LET B = 250 GOSUB 1060 LET A = C70 LET B = 380 GOSUB 1090 PRINT CRUN6Im Grunde machte ich selbst das, was im Artikel der Compiler erledigt. Zeilennummern waren Speicheradressen, und die versteckten Variablen waren vor mir nicht versteckt. Denn ich war der Compiler
Das Einzige, was der Interpreter für mich tat, war, die Rücksprungadresse von
GOSUBzu speichernAllerdings kann der Code syntaktisch falsch sein oder meine Erinnerung verzerrt. 40 Jahre sind eine lange Zeit, aber die Grundidee stimmt
Außerdem hatte der Z80-Prozessor in der Maschine Stack-Management-Funktionen. Der BASIC-Interpreter war wirklich einfach, aber dafür gab es Entschuldigungen: nur 1 KB RAM und 8 KB ROM für OS, Interpreter und alles andere
GOSUBspeichert die Zeilennummer oder eine andere Referenz, auf dieRETURNzugreift, und wenn manGOSUB-Aufrufe verschachtelt, muss es mehrere Rücksprungpunkte merken, also braucht es in irgendeiner Form einen StackManche BASICs hatten allerdings statt eines allgemeinen Stacks nur ein festes Array von Return-Pointern und einen Index auf die aktuelle Position, sodass die Aufruftiefe zum Beispiel fest auf 7 begrenzt war. Aus Sicht des Programmierers verhielt sich das wie ein Call Stack
Natürlich ist das kein „richtiger“ Stack mit lokalen Variablen/Parametern, wie man ihn erwarten würde, wenn jemand Stack sagt
In der Standardumgebung von BBC BASIC konnte man eine interessante Demo zeigen, was bei verschachtelten Aufrufen, einschließlich Rekursion, passiert. Wenn man die Stack-Position ganz oben im Display-Speicher platzierte und dort nichts zeichnen ließ, konnte man sehen, wie der Stack während der Arbeit wuchs
Wegen der niedrigen Bildschirmauflösung waren die 2 Bytes einer Rücksprungadresse in Bildschirmmodus 1 oder 5 als acht dicke Pixel zu sehen. In Modus 2 waren es vier, aber mit blinkenden Farben war das weniger gut; in Modus 0, 3, 4 und 6 waren es 16, aber bitweise war es schwerer zu erkennen als wiederholte acht Farben
Bevor es beliebig erweiterbare Heaps gab, mussten Programmierer zumindest ein wenig Engineering-Urteil aufbringen
Denn sie mussten die probabilistische Verteilung der Eingaben berücksichtigen und alle Zwischenspeicher angemessen dimensionieren
Daher gab es dann „BUGS AND LIMITATIONS“
Deshalb wird alles zur Compile-Zeit statisch allokiert, und man muss wissen, wie viel Speicher die Eingaben verbrauchen werden
Aber die obere Grenze des Speicherverbrauchs zu kennen, war früher auch für Anwendungsprogrammierer normal. Schließlich will man niemals Out-of-Memory haben
Heutzutage lässt man die Speichernutzung wohl einfach YOLO laufen
Das war zum Beispiel eine große Verbesserung gegenüber Einschränkungen wie einer endlichen und kurzen Maximallänge für sed-Befehle
Ich habe zu lange funktional programmiert, sodass es mir wirklich schwerfällt, mir vorzustellen, wie man Code ohne Rekursion schreibt
Technisch weiß ich, wie man rekursive Algorithmen in iterative umwandelt, und ich habe das auch schon dort gemacht, wo die Ressourcen stark begrenzt waren, aber mögen tue ich es nicht
Meist ist die rekursive Variante schöner, und in 99 % der Fälle halte ich sie für schnell genug. Wenn der Compiler Tail Recursion unterstützt, nähert sich das 100 %, aber bei den meisten interessanteren Aufgaben muss man den Stack ohnehin selbst verwalten
Manchmal mache ich solche Dinge absichtlich, um zu lernen, wie man das vor meiner Geburt gemacht hat. Ich bastle gelegentlich an Commodore-64-Spielen herum, und dabei wird mir sehr deutlich, was für ein Luxus es ist, heute an schnelle, billige und leicht zu benutzende Hardware gewöhnt zu sein
Um auf solchen alten Maschinen Rekursion zu verwenden, musste man seinen eigenen Stack-Mechanismus bauen, und selbst dann blieb noch das Problem, dass man außer globalem Speicher im Grunde keine andere Möglichkeit hatte
Ich habe diese Zeit erlebt, würde sie aber niemandem empfehlen
Bei der
@let-Funktion von Enhanced GNU Awk ließ man den Compiler für@let-Blöcke außerhalb von Funktionen, etwa inBEGIN- oderEND-Blöcken, geheime globale Variablen anlegenDiese Variablen werden zwischen Blöcken so weit wie möglich wiederverwendet
$ ./gawk --dump-variables 'BEGIN { @let (a, b, c = 1) { } }'$ cat awkvars.out$let0001: untyped variable$let0002: untyped variable$let0003: 1ARGC: 1ARGIND: 0ARGV: array, 1 elementsBINMODE: 0[ .. snip many ]https://www.kylheku.com/cgit/egawk/about/
pinggeht nicht, undnc -z 104.37.63.7 443auch nichtUpdate: Offenbar ist die Sicherheitsinfrastruktur kaputt. Ich weiß nicht einmal, was das ist, und benutze auch kein Twitter. Wenn man das AS prüft, ist es Google Fiber
Und bitte doxxt mich nicht