Problem mit dem mechanischen Rechner ist halt,
dass man die Anzahl der Operationen,
die man pro Sekunde ausführen kann, doch so ein bisschen begrenzt,
weil es halt rein mechanisch durch die bewegten Massen und so nicht so besonders schnell geht.
Ich werde das jetzt kurz unterbrechen. Und wir gehen zurück zum Inhalt.
Letztes Mal gab es die erste interessante Frage zur Klausur. Für diejenigen,
die noch nicht auf der Webseite nachgeschaut haben und möglicherweise nicht
in der Übung waren, Andreas wird eine Probeklausur anbieten,
wenn ich das richtig sehe, ist das am 9. Februar.
Details bei uns auf der Webseite. Und je nachdem,
ob das ihre allererste Uni-Klausur ist oder möglicherweise schon Erfahrung bestehen,
kann es recht nützlich sein, da mal hinzugehen.
Und dann halt die Probeklausur mitzuschreiben. Das ist dann vermutlich
eine von den Klausuren aus den Vorjahren, Originalklausur.
Und anschließend wird das dann besprochen. Es wird also nicht korrigiert,
aber sie können unter den Originalbedingungen mitschreiben und dann schauen,
ob sie mit Uni-Klausuren zurechtkommen.
So, was wir heute hier vorhaben,
ist das Thema Speicher ein bisschen zu Ende
zu bringen
Dann auf ein anderes Themenfeld zu wechseln, nämlich wie wir
die Performance des Rechners steigern können. Weil es so wichtig ist,
weil das außerdem die Quelle von vielen,
vielen Fehlern ist in Programmen und der Grund,
warum man halt alle paar Tage irgendwelche sinnlosen Patches eingespielt bekommt,
deswegen wollen wir uns hier den dynamischen Speicher,
also die Zuordners anfordern und das Freigeben von Speicher auf dem Hieb nochmal anschauen.
Und zur Erinnerung für diejenigen, die nicht tagtäglich C programmieren,
im Wesentlichen handelt sich um drei Funktionen,
die man da berücksichtigen muss.
Das eine ist halt Malok
In nacktem Zeh, mit der man halt versucht,
einen Speicherbereich anzufordern, der dann anschließend hoffentlich zur Verfügung
gestellt wird und ein beliebtes Betriebssystem beziehungsweise der Laufzeitumgebung.
Und was wir da angeben,
ist die Anzahl der Bytes, die wir haben wollen,
beziehungsweise in diesem Fall halt die Anzahl der Elemente von einer gegebenen Größe.
Dann bekommen wir eine Adresse zurück, nämlich die Speicheradresse,
wo der Speicherbereich für uns zur Verfügung steht.
Und wenn wir stattdessen halt null zurückbekommen,
dann gab es keine Möglichkeit mehr,
den Speicher zuzuordnen
Gut, wenn man dann den Speicher nicht mehr braucht,
dann sollte man einen Früh aufrufen,
weil man sonst früher oder später halt ein Out-of-Mamory bekommt,
wenn man halt regelmäßig Sachen anfordert, aber nie zurückgibt.
Das heißt, diese beiden Funktionen sind besonders wichtig und diese
hier wird seltener verwendet. Damit kann man die Größe eines Speicherbereichs
möglicherweise ändern, eines Speicherbereichs, den man bereits hat.
Da war hier so ein kleines Beispiel, wie das gemacht wird.
Wir kaufen uns zu Anfang mit diesem Malok einen bestimmten Speicherbereich,
der von uns angeforderten Größe, nämlich hier Endstück vom Typ Integer,
also N mal 4 Bytes, so wollen wir haben und wenn das klappt,
dann kriegen wir halt einen gültigen Pointer, also eine Adresse.
Und wenn wir null zurückbekommen, dann ist es falsch,
dann ist es schiefgegangen. Dann können wir mit diesem Speicherbereich spielen.
Hier wird dann vorgeführt, wie dieses Riolak zur Ändern der
Speichergröße funktioniert. Und wenn wir damit fertig sind,
dann geben wir den Speicherbereich an die Laufzeitumgebung zurück.
Wie das dann funktioniert, war jetzt hier mal so ein
Spielbeispiel mit nur ganz, ganz wenig Speicherstellen illustriert.
Das ist, glaube ich, relativ klar.
Das möchte ich nicht nochmal zeigen. Stattdessen dann halt
die Sachen, die wir nicht ganz fertig gemacht haben,
dann doch nochmal fertigstellen.
Das heißt,
wie können wir das jetzt tatsächlich implementieren
Und die einfachste Möglichkeit war halt, dass die Laufzeitumgehung,
das Betriebssystem, unsere eigene Implementimentierung von Malok,
halt einen unterliegenden Bereich von Speicher hat, der zur Verfügung steht,
der im Prinzip mit solchen dynamisch angeforderten Datenstrukturen gefüllt werden kann.
Und damit das funktioniert, müssen wir uns irgendwie merken,
wie groß der Block war, der da angefordert wurde.
Das kann man also tun, indem man zusätzlich zu den
hinterher für den Benutzer freigegebenen Speicherstellen sich irgendwie merkt, wie das funktioniert.
Hier ist das halt noch nicht komplett implementiert,
sondern nur gezeigt, dass wir hier einen Block der Länge
5 haben
Und wenn wir wissen, dass das eine Länge 5 ist,
dann können wir halt fünf Speicherstellen weiter nachgucken,
wie groß ist das, was da dann angefordert ist.
Vier, dann können wir halt hier so durchhangeln,
bis wir zum Ende des Bereichs kommen, wo wir Speicher zur Verfügung haben.
So, wie kann man das implementieren? Eine Möglichkeit ist hier gezeigt,
das wird man in der Praxis nicht so machen,
weil es nicht effizient genug ist,
aber das ist halt vom Verwaltungsoverhand her, Overhead her minimal vermutlich.
Wir haben hier jetzt halt nicht von links nach rechts,
sondern von oben nach unten unseren Speicherbereich, den wir zuordnen können.
Und wenn wir einen bestimmten Blog an den Benutzer ausgeben,
also dann ist das hier dieser Payload-Bereich, dann merken wir uns innerhalb von Malok,
wie groß der Block ist, den wir zuteilt haben.
Und wenn wir uns darauf einigen, dass wir nicht breitweise solche Datenblöcke ausgeben,
sondern sagen wir wortweise, dann haben wir hier unten irgendwelche Bits frei. Das heißt,
wir können im untersten Bit ein kleines Fleck einbauen,
was uns sagt, wenn der Wert hier null ist,
dann also das unterste Bit in dieser Vierbyte-Speicherstelle,
in diesem Integer null ist,
dann ist der Block frei und wenn das Bit gesetzt ist,
dann ist der Block belegt und auf diese Weise könnte man
sich dann halt hier entlang dieser Verwaltungsinformationen langhangeln,
immer schauen,
ist der Block belegt oder ist der Block der Länge 6
hier frei
Gut, das kann man machen. Wahrscheinlich wird man noch
zusätzliche Verwaltungsbits benötigen oder haben wollen. Nicht nur ein Bit
und dann brauchen wir halt gegebenenfalls mehr von diesen Verwaltungsinformationen und
dann kommen die Daten, die an den Benutzern ausgegeben werden.
Wie kann das jetzt funktionieren? Also das ist jetzt ein
Beispiel für finsteren C-Code, wie man sowas schreiben kann.
Das ist halt recht kompakt zu schreiben,
aber um es vielleicht zu verstehen,
muss man dann das doch letztlich Zeichen für Zeichen parsen,
um zu gucken, was da passiert.
Wir wollen jetzt versuchen, in einem großen Speicherbereich,
der entsprechend verwaltet wird,
wie das eben gezeigt wurde,
einen freien Blog zu finden
Dazu nehmen wir uns einen Pointer auf einen Intager und initialisieren das mit dem Startbereich,
also das ist dann halt ganz links in unserem Speicherbereich.
Also P wird jetzt hier auf den Start gesetzt und
jetzt wollen wir versuchen, einen freien Blog zu finden.
Und das passiert dann in einer Weilschleife,
die hat den drei geschachtelten Bedingungen. Sie erinnern sich,
dass in C und Java solche geschachteten logischen Ausdrücke von links
nach rechts ausgewertet werden und sobald das Resultat feststeht,
wird das entsprechend nicht mehr, werden die weiteren Dinge nicht mehr ausgewertet aus Effizienzgründen.
Wir können also jetzt hier gucken, Solange der Pointer kleiner ist,
als das Ende des zur Verfügung stehenden Speicherbereichs, machen wir weiter.
Das ist dann also die Abbruchbedingung,
wenn wir durch unsere Liste durchgelaufen sind und am Ende ankommen,
dann ist offensichtlich kein Speicherbereich mehr frei.
Ansonsten gucken wir, der Pointer zeigt im Moment auf einen von
unseren Blöcken und Stern-P guckt also jetzt in dem Datenwort,
in dem 32-Bit-Wort nach, was da drin steht.
Und wenn dieses hier, also der Inhalt der Speicheradresse,
auf die das P gerade zeigt,
logisch verwundet mit der 1 gesetzt ist, Das war jetzt hier gesagt.
Wenn dieses Bild gesetzt ist und das ist das,
was wir gerade prüfen, P ist ein Pointer,
der auf diese Speicherstelle zeigt, wenn P und 1 den Wert 1 hat,
dann ist offensichtlich das Ding belegt.
Dann müssen wir also auch weitermachen. Und ansonsten,
wenn das, was P uns jetzt anzeigt,
kleiner ist als die Länge, die wir haben wollen,
das ist also jetzt hier, jetzt gucken wir hier,
P zeigt weiterhin auf dieses Ding.
Jetzt gucken wir in dieser Größenfeldnacht, ist,
dass der Blog, an dem wir gerade nachgucken,
groß genug.
Wenn nein,
dann müssen wir halt weitermachen
Das sind also jetzt die drei Abbruchbedingungen.
Denn wir am Ende angekommen sind, keine Chance.
Wenn der Blog bereits belegt ist, keine Chance.
Und wenn der Blog zu klein ist, auch kleine Chance.
So, und was man dann halt tun muss, ist,
dass dann das P entsprechend initialisiert wird auf den nächsten Blog.
Auf diese Art und Weise können wir jetzt diese Liste der Blöcke durchlaufen.
Und ja, was würden wir tun, wenn wir einen freien Blog finden?
Dann können wir natürlich den ersten passenden Blog zurückliefern.
Dann fängt man halt immer vom Anfang an. Und der Nachteil dabei ist,
dass halt früher oder später die Blöcke, die am Anfang liegen,
alle belegt sind. Die sind natürlich dann ausgegeben,
weil wir immer von Anfang ausgehend die Blöcke zuteilen und das bedeutet,
je länger das läuft,
desto mehr von den vorderen Blöcken sind belegt und desto weiter müssen wir
in dieser Liste laufen und es wird langsamer und langsamer und langsamer,
je mehr man Malok aufgerufen hat.
Das motiviert dann die nächste Strategie,
das ist dann Next Fit, das heißt,
wir laufen diese Liste durch und beim nächsten Malok-Aufruf starten wir nicht ganz von vorne,
sondern von da, wo wir beim letzten Mal waren.
Das ist dann also im Prinzip schneller. Allerdings führt es dazu,
dass die Fragmentierung schlechter wird,
weil wir halt im Prinzip Große Blöcke zur Verfügung haben,
die aber zuteilen und damit zum Schluss keine großen Blöcke mehr zur Verfügung stehen.
Was man auch machen kann, ist,
dass man jedes Mal die gesamte Liste durchläuft und dann
den Block mit dem kleinsten Verschnitt zurückliefert.
Das heißt, wenn wir irgendwie 13 Megabyte haben wollen,
dann würden wir gucken, ob wir einen Block der Größe
13 Megabyte plus Y kriegen und den halt dann zurückliefern würden.
Das sind also drei beliebte Strategien. Da kann man sich
noch mehr ausdenken und da kann man halt tatsächlich auch noch
interessante eigene Algorithmen einbauen
So, nehmen wir an, wir haben einen Blog gefunden.
Wie wird das jetzt hier in der Verwaltungsinformation eingeführt? Also wieder mit diesem Ding,
wo wir in dem Pointer und dem untersten Bit markieren,
ob ein Blog belegt ist und ansonsten halt die Bloggröße in
dem allerersten Interager des Blogs eintragen.
Das ist jetzt hier als Beispiel gezeigt, wie man das
tatsächlich schreiben könnte. Wir haben also jetzt hier eine Funktion,
die innerhalb von Malok aufgerufen würde,
um einen Speicherbereich tatsächlich zuzuordnen,
mit einem Pointer
Den wir jetzt brauchen. Und das ist also der Pointer
auf den Anfang des Speicherbereichs, den wir zuteilen wollen.
Und dann haben wir übergeben bekommen, wie viel Bytes möchte der Benutzer haben.
Gut, jetzt kommt hier eine finstere Sache,
die vielleicht motiviert, sich nochmal mit diesen Shift-Operationen auseinanderzusetzen.
Das könnte man auch mit einem kleinen if than Ells schreiben, aber C-Programmierer lieben solche Dinge hier.
Was wird da gemacht? Wir nehmen zum nächsten Mal,
wir brauchen ja, weil wir, wenn wir,
was weiß ich, fünf Bytes zuordnen wollen,
brauchen wir mindestens, also fünf Worte sagen, nochmal zuordnen wollen,
brauchen wir mindestens eins zusätzlich hier für die Verwaltungsinformationen.
Das heißt, was da jetzt gemacht wird, ist,
wir nehmen die angeforderte Länge plus eins,
shiften das erstmal um ein Bit nach rechts,
das teilt das durch zwei und dann wiederum ein Bit nach links,
das packt dann das wieder zurück.
Das heißt, auf die Art und Weise ist das,
was wir hier rauskriegen, einen größer als lang und durch eine Zweierpotenz,
durch zwei teilbar.
Gut, das ist also dann der Trick,
dass wir immer eine gerade Anzahl von Bytes zuordnen.
Das ist dann halt hier praktischer,
weil wir das unterste Bit für diese Statusinformation belegt oder
frei brauchen
Gut, dann holen wir uns die Länge des Speicherblocks,
die aktuelle Länge, also hier zum Beispiel die Sechs und ziehen davon zwei ab,
weil wir, was weiß ich, hier zwei für unsere Verwaltungsinformationen brauchen,
setzen das unterste Bit in diesem Speicherblock, um da anzuzeigen, dass er jetzt belegt ist.
Und wenn die Größe, die da rauskommt,
kleiner ist als das, was wir zur Verfügung haben,
dann würden wir den Pointer halt weiter fortsetzen auf den Rest,
also was weiß ich, hier jetzt auf den hier,
das ist sowieso der nächste Block und wenn wir,
was weiß ich, nur ein oder zwei Bytes anfordern,
dann würden wir halt P hierhin weiter fortsetzen bekommen halt in diesem Beispiel,
heißt hier unten angedeutet,
wenn wir hier einen freien Block der Größe 6 haben und zwei anfordern,
dann würden halt zwei plus dieses Verwaltungsding, sind mindestens drei,
wir müssen das aber auf zweier Grenzen ausrichten,
also müssen wir hier aus dieser zwei eine vier machen.
Wir belegen dann also hier einen Vierer-Block, zwei davon bekommt er Benutzer.
Einer ist Verschnitt und dann haben wir aus diesem Sechser-Block,
den wir hatten, noch einen Zweier-Block übrig.
Das ist also dann die Möglichkeit, wie man
dynamischen Speicher in dem Heb zuordnen kann
Und effizienter, also bei diesem hier ist es jetzt schwierig,
wenn wir irgendwas hier zurückgeben wollen,
dann muss man halt immer vom Anfang diese Liste hier durchlaufen und dann versuchen,
von hier, da das so in Freie wieder zurück in der Liste freizugeben.
Was halt deswegen schon mal effizienter ist, ist dann diese Datenstruktur,
wo man diese Verwaltungsinformationen einmal am Anfang und einmal am Ende unterbringt.
Und dazwischen ist dann das, was der Benutzer bekommt. Die Idee ist jetzt,
dass wenn wir sowas hier haben und wir wollen,
was weiß ich, diesen Block hier freigeben,
dann können wir mit diesem Verweis hier leicht zu dem kommen.
Und wenn wir von dem hier jetzt zweimal zurückgehen,
können wir zu dem kommen und entsprechend dann, was weiß ich,
hier das Ding wieder freigeben, also dass man mehrere benachbarte,
freie Blöcke schneller verschmelzen kann und nicht jeweils die komplette Liste durchsuchen muss.
Das sind dann also Optimierungen, mit denen dann das Malok beschleunigen kann.
So, jetzt noch ein Beispiel,
wie dann halt die Speicherstruktur in einem echten System aussehen könnte,
dass es jetzt halt Linux, allerdings schon eine ältere Version,
wo dann halt Hier jetzt mal tatsächlich Adressen angegeben sind.
Und die Adressen sind jetzt halt nicht von 0 bis 256
, sondern man muss sich das ergänzen.
Also FF steht jetzt hier für FFF, FFFFFFFFFF.
Bei 32 Bit. Und das sind dann halt die Maschinenadressen,
wo das Linux die verschiedenen Speicherbereiche unterbringen würde.
Hier ist ein Speicherbereich Seite 0, die halt tatsächlich für
nichts verwendet wird, um die beliebten Nullpointer abzufangen.
Dann ist jeder Programmtext. Hier sind die initialisierten und nicht
initialisierten Daten. Das war halt das Punkt Data und Punkt
BSS-Segment aus dem 11. Dann ist hier ein Bereich für den Hieb
hier ein weiterer Bereich für den Hieb.
Und hier der Bereich für den Stank
Die Idee ist,
dass der Sack jetzt von hier von oben nach unten wachsen kann,
solange bis halt kein Speicher mehr zur Verfügung ist.
Und andererseits, wenn wir ganz wenig Stack benutzen,
dann kann halt der Hieb von hier bis da oben wachsen,
so viel wir Speicher halt zur Verfügung haben.
Auf diese Art und Weise können halt einige Programme viel hieb und wenig Stack benutzen.
Andere Programme viel Stack und es bleibt nur weniger hieb übrig.
Man muss sich aber nicht von vornherein darauf einigen,
wie viel Hieb und Stack man tatsächlich zuordnen will
Das ist hoffentlich einigermaßen plausibel. Und hier wäre ein Beispiel dazu,
wie das halt aussieht, wenn das Programm jetzt auf
der Maschine gestartet ist und noch gar nichts aluziert hat,
dann sind halt nur die statischen Dinge bereits belegt.
Das ist halt zum einen das Programm, also hier die Maschinen- oder Semmlerbefehle,
dann die statischen Daten. Und der Teil des Decks,
der bereits von der Laufzeitumgebung oder dem Betriebssystem belegt wurde.
Gut, wenn wir dann mit dynamischen Bibliotheken arbeiten,
die werden halt erst dann geladen,
wenn das Programm bereits gestartet wurde,
dann kommt hier ein Stück auf dem Stack hinzu für entsprechend
das Laden dieser Bibliotheken
Und diese Shared-Libraries, das hatten wir ins letztes Mal angeguckt,
wie das beim Kompilieren Assemblyan Linken funktioniert,
die werden dann hier in den Speicher gepackt.
Und jetzt fangt das Programm mit seinem eigentlichen Programmablauf an,
wird also hier weitere Funktionen, also zum Beispiel Year Maine oder sowas,
belegen und dann weitere Funktionen aufrufen,
belegt also ein bisschen was auf dem Stack.
Und wenn Malok aufgerufen wird oder eben New in CIPlus oder Yava
oder sowas, dann werden entsprechend Speicherbereiche auf dem Heb belegt.
Und das wächst halt in diese Richtung weiter.
Und ein bisschen später mag es dann hier so aussehen und
dann gegebenenfalls wird dann auch hier dieser kleinere Speicherbereich für
den Heb auch belegt
Und das kann halt so lange weitergehen, bis in dieser Bereich
hier voll ist. Und der obere Teil des Heeps,
der Breakpointer, auf den Stackpointer trifft und zu dem Zeitpunkt
ist dann halt kein Speicher mehr zur Verfügung.
Und das Programm würde halt beendet werden.
Gut, hier gibt es dann noch ein kleines Beispiel.
Das ist auch ganz interessant, wenn man mal so ein
eigenes Assembler-Programm testen möchte. Hier als Beispiel für den Gnoody-Bugger,
dann würde man halt nach dem Laden des Programms Breipoint setzen auf Main,
dann kann man ranmachen. Das Programm wird vom Debugger gestoppt,
wenn Main aufgerufen wird und man könnte sich jetzt hier zunächst mal ausgeben,
Wo ist denn im Moment mein Stackpointer?
Das heißt, wo ist das oberste Frame auf dem Stack
belegt? Und dann kriegt man halt irgendeine Adresse,
wie diese hier beide beim Speicherlayout, wie das hier für das Red-Head Linux gezeigt ist. So,
und dann gibt es hier in The Main ein paar Malock-Befehle,
die halt versuchen, dynamisch Speicher anzufordern und ein paar statische Datenstrukturen,
nämlich hier ein relativ großes Array, mit 16 Megabyte,
ein ganz großes Array mit 256 Megabyte und ein bisschen Krümelkram,
nämlich so ein paar Pointer unten
Und dann hier zur Lauf, also das ist statisch angelegt.
Wo kommt das gleich hin? Und diese Dinge werden halt zur Laufzeit
dynamisch angelegt und dann würde das Programm hier irgendwas Sinnvolles tun,
damit die Malox nicht gleich komplett wegoptimiert werden.
So, was passiert jetzt? Also nach dem Staaten des Ganzen,
noch bevor man ihn aufrufen wird, ist der Stackpointer
halt hier irgendwann dieser Stelle relativ weit oben im Speicherbereich.
Und die anderen Dinge, die sind dann halt,
also die statischen Dinge sind dann bereits zugeordnet.
Das sind halt diese Arays hier,
die offensichtlich beide jetzt hier in diesen Bereich reingekommen.
Nee, sorry. Nee, die beiden sind ja statisch.
Die müssten also dann hier drin sein. Und jetzt ist
die Frage bei den dynamisch angeforderten RAS, wo kommen die hin?
Und ja, das ist dann halt in diesem Beispiel,
das kann man dann halt,
wenn man das selbst auf seinem eigenen System ausführt, mal nachvollziehen.
Hier sind halt kleine Speicherbereiche angefordert worden, nämlich eins um
acht Bit nach links geschiftet, also 256 Bytes.
Und dieses hier ist eins um 28 Bit nach links geschiftet,
also 256 Megabyte. So, also P2 und P4
sind klein und P1 und P3 sind relativ groß
Und hier sind dann halt die Adressen eingegeben.
Hier kommen halt P2 und P4, die beiden Kleinen,
in diesen Teil des Heebs und die beiden Großen Arrays kommen hier in den Heeb.
Hat jemand eine Idee, warum man das so machen würde?
Warum man unterschiedliche Speicherbereiche macht für eher kleine und eher große Datenstrukturen?
Das ist eben gerade diese Fragmentierung. Wir hatten hier das Beispiel,
Beziehungsweise dieses Beispiel hier, dass wir halt hier im Speicherbereich anfordern,
hier weiteren Speicherbereich anfordern, dann hier nochmal was da hinten anfordern,
dann hier zwischendurch irgendwas freigeben, nämlich den hier.
Und jetzt haben wir halt nur noch einen Block mit
fünf Einheiten zur Verfügung, fordern aber einen Sechser an.
Und wenn das halt für längere Zeit läuft mit großen und kleinen Allidikationen,
dann passiert es halt schnell, dass wir so kleine Dinge hier haben,
die irgendwo dazwischen liegen und die man nicht mehr gut benutzen kann.
Das kann man so ein bisschen umgehen,
indem man halt zwei getrennte Bereiche anlegt
Und die getrennt verwendet, nämlich den hier,
der relativ groß ist und viel Platz hat,
nehmen wir für die großen Datenstrukturen und den hier für die Kleinen,
sodass wir letztlich Fragmentierung nur zwischen den Kleinen und nur zwischen den ganz Großen haben,
was die Wahrscheinlichkeit reduziert,
dass man halt irgendwann tatsächlich in der Situation läuft,
wo man was anfordert, was man nicht mehr erfüllen kann,
obwohl eigentlich noch genug Speicher da ist.
Gut. Damit könnten wir das eigentlich abschließen für Leute,
die nicht mit nacktem C programmieren,
sondern sagen wir mal nur Java oder eine andere Sprache verwenden,
wo man Manage-Speicher hat mit Garbage Collection,
da könnte man sich halt bequem zu zurücklehnen und zu sagen,
das interessiert mich nicht.
Aber die verschiedenen Dinge, die man da falsch machen kann,
sind so häufig, dass es sich lohnt, das mal anzuschauen.
Das Beliebteste ist halt, Adressen zuzugreifen, die nicht gültig sind,
also ungültige Pointer zu referenzieren oder Variablen anzufordern, die gar nicht existieren.
Beliebt es auch, dass man Speicherwerte ausliest,
die man nicht vorher initialisiert hat,
sodass man dann halt letztlich zufällige Werte bekommt oder Werte,
die von irgendeinem Programm vorher initialisiert worden sind möglicherweise
Und was man auch gerne macht, ist Blöcke entweder mehrfach freizugeben oder gar nicht freizugeben.
Und dann hat man früher oder später keinen Speicher mehr frei.
Ich will das mal kurz durchgehen. Diejenigen,
die jetzt nicht gut sehen können,
die werden vielleicht nicht alles sofort verstehen,
aber dann haben wir gleich noch einen Schmankel,
was halt zeigt, wie man,
was weiß ich,
aus solchen Programmierfehlern dann tatsächlich zu Exploits kommen kann und
sich Kot einschleppen kann
Dieses hier passiert sehr schnell. Da müsste man jetzt halt
in der Dokumentation für diese Funktion Scan-F,
die einen String entgegennimmt und dann verschiedene Sachen parsen, kann entgegennehmen.
Das ist sehr schnell gemacht. Werde es jetzt also eine Variable
und man möchte halt einen Integer-Wert aus einem String parsen und in diese Variante reinschreiben.
Das Problem ist, Scan kann halt Scan-F,
kann ich direkt Affariablen zugreifen, sondern muss eine Referenz haben.
Also wenn man hier dieses Unzeichen hat für die Adresse,
dann ist alles okay, wenn man das weglässt,
dann wird der Wert, der hier gepasst wurde,
halt nicht in die Variable reingeschrieben,
sondern an die Adresse
Die gerade hier den Inhalt hat. Also zum Beispiel
die Adresse 0 oder 1 oder was auch immer,
der Wert der Variable aktuell ist.
Der Compiler beschwert sich da nicht groß. Gut,
jedenfalls ältere Compiler nicht. Und das ist halt sehr,
sehr schnell gemacht und ziemlich häufig. Das hier ist die
nächste beliebte Sache. Hier haben wir halt eine Funktion.
Und die sollen Pointer auf ein Intitel zurückgeben.
Das ist soweit keine schlimme Sache. Dann aluzieren wir auf
dem Stack halt einen Wert. Das kann man auch machen
. Und jetzt geben wir einen Wert zurück,
nämlich die Adresse von diesem Objekt auf dem Stack
Bis hierhin ist das alles noch gut. Wenn jetzt aber die Funktionen,
also die Funktion fängt in ihrem eigenen Stackframe an,
packt diesen Wert Value auf den Stack,
macht dann möglicherweise noch irgendwas anderes und springt dann zurück.
Dann liegt dieser Wert anfangs immer noch auf dem Stack,
ist aber ungültig. Das heißt,
wenn man jetzt einen Test schreibt für diese Funktion,
gleichen ja, auf dem diese Funktionen beendet worden ist,
ist alles noch gut und dieser Wert ist noch gültig.
Sobald man aber die nächste Funktion auf den Stacken und die nächste Funktion aufruft,
wird die nächste Funktion den gleichen Speicherbereich auf dem Stack bekommen
und dann ihre Daten da reinschreiben.
Das heißt, was man hier jetzt bekommen hat,
ist eine Adresse auf irgendwas, was einem gar nicht mehr gehört.
Und das merkt man eben häufig als einige Zeit später,
weil zu Anfang die Daten erstmal noch auf dem Stack
liegen und erst später überschrieben werden
Ja, hier lohnt es sich wahrscheinlich nicht komplett durchzugehen.
Wir wollen hier eine Matrix mit einem Vektor multiplizieren und zunächst mal
kaufen wir uns jetzt hier vom Malok einen Speicherbereich ein,
der genügend Platz hat und nennen den halt Y. Das ist das,
was wir hinterher zurückgeben wollen, einen Speicherbereich.
Und ja, jetzt gibt es hier zwei geschachtelte Schleifen,
die Matrix Vektor multiplizierte Multiplikationen ausführen. Das heißt,
hier haben wir das zweidimensionale REA, also unsere Matrix,
hier den Vektor, der uns übergeben worden ist.
Wir wollen Matrix mal Vektor multipliziert
Platzieren. Und das ist dann halt elementweise. Das Problem ist,
dass wir an dieser Stelle einen Speicherbereich kriegen,
der nicht initialisiert worden ist und entsprechend möglicherweise von der vorherigen Programmausführung irgendwas enthält.
Und wir fangen an dieser Stelle immer nur an,
Y von I aufzusummieren. Es wird aber Y von I
nie initialisiert. Was also hier fehlt,
ist an dieser Stelle Y von I gleich Null,
dann wird es funktionieren.
So funktioniert es nicht und man bekommt an dieser Stelle
irgendwas raus
Und zusätzlich haben wir natürlich,
also an dieser Stelle können wir das übrigens machen.
Also wir haben jetzt hier einen Pointer auf einen Speicherbereich angefordert
und geben diesen Pointer zurück. Das funktioniert in diesem Fall,
weil jetzt halt dieses Y nicht auf dem Stack liegt,
sondern es liegt auf dem Hieb.
Und da ist der im Speicherbereich halt so lange gültig,
nach dem Malok, bis wir entsprechend einen Free auf dieses Y aufrufen.
Gut, es gibt so eine Funktion, C-Alog,
die Clear-Malok macht, die halt den Speicherbereich mit null initialisieren würde.
Aber die ist halt ein bisschen teurer vom Laufzeitverhalten her.
Und deswegen nehmen viele effizienzorientierte Leute immer noch Malok
statt in C-Alog
Gut, denn dieses hier ist ein bisschen subtil.
Hier wird ein Speicherbereich angefordert. Der N mal Size of Intert.
Und anschließend werden da aber Pointer reingetan. Das heißt,
hier ist das falsch. Also hier müsste man eigentlich,
sei es of in Stern schreiben. Das fällt erst dann auf,
wenn man, also dieser Code wird in 32-Bit-Umgebungen funktionieren.
Und wenn man dann irgendwann auf 64-Bit umsteigt,
dann wird es knallen. Es ist ein bisschen seltener.
Dieses hier passiert natürlich ständig. Hier kaufen wir einen Speicherbereich
mit genügend Platz für N-Pointer auf Integer
Aber diese Schleife läuft dann eben von null, kleiner gleich N,
für N plus 1 Elemente und wird halt an dieser Stelle halt statt i kleiner N,
was gut wäre, kleiner gleich N,
läuft sie um einen zu weit und wird dann ein stechen,
hier ein P von i von N irgendwas reinschreiben,
was hier in diesem Speicherbereich liegt, was dann passiert ist,
dass man sich, ich gehe ein paar Folien zurück,
wenn wir halt hier so eine Datenstruktur haben,
wir bekommen an dieser Stelle halt hier jetzt einen Speicherbereich zurück,
der genug Platz für 2 hat durch dieses Aufrunden,
auf einen geraden Wert, hätten wir Speicherbereich von drei,
wäre gerade noch gut,
ist uns nicht zugeteilt worden, aber ist noch frei.
Wenn wir dann aber noch einen weiterlaufen,
also dann würden wir halt hier irgendwas reinschreiben,
was dann diese Verwaltungsinformationen für das Madok kaputt macht und anschließend ist das Verhalten halt komplett undefiniert.
So, das waren diese Dinge, oft bei wann ist klar?
Den gucken wir uns gleich im Detail an.
Hier haben wir halt im Speicherbereich, auf dem Stack reserviert,
für acht Zeichen und rufen Gets dringend auf,
was halt vom Terminal, von der Tastatur eine Benutzereingabe einliest.
Und wenn der Benutzer mehr als acht Zeichen eintippt,
dann läuft der Speicherbereich halt hier über.
Und wir wollen uns gleich anschauen,
dass das nicht nur ein Programmierfehler ist,
sondern dass man auf diese Art und Weise sich eben auch Schadsoftware einschleppen kann.
Missverständnis der Pointer-Arrathmetik, nochmal ein kleines Quiz,
sieht jemand, was an dieser Stelle
kaputt ist
Und was halt hier gemacht worden ist, wir haben einen Pointer auf
einen Speicherbereich und wir suchen in diesem Speicherbereich nach einem Wert.
Wie machen wir das? Wir gucken zunächst mal nach,
ob dieses Pointer-Ding überhaupt gültig ist, sodass wir zum Beispiel,
wenn wir da auf das letzte Element treffen und das Element null ist,
dann diese Schleife beenden.
Das ist soweit alles okay. Und wenn der Wert,
in dem wir da gerade nachschauen,
mich der Wert von dem nicht der Wert ist,
den wir suchen, dann machen wir hier dieses hier P plus gleich.
Ja, das ist ein Pointer auf ein Int,
also Size of End
Und ja, wenn wir aus dieser Schleife rauskommen,
dann liefern wir den Pointer zurück.
Der zeigt also entweder auf das Element,
auf das gesuchte Element oder auf null,
wenn wir es nicht gefunden haben.
Im Prinzip ist das also okay.
Wie sieht jemand trotzdem den Fehler?
Wo hier zum
Ja gut, das ist auch ein guter Punkt.
Also das könnte natürlich auch sein,
dass man sich hier die Operatorpriorität nachschlagen muss.
Also dieses, da weiß ich jetzt im Moment selber nicht,
was die höhere Priorität hat.
Also das logische und das Bullshit und hier oder das Ungleich.
Ich gehe aber davon aus, dass das korrekt ist,
obwohl ich tatsächlich hier auch vorschlagen würde,
dass explizit zu klammern
Genau. Das war genau die richtige Antwort. Die Pointer Arrethmetik sorgt eben da schon dafür,
dass wenn wir irgendwas auf dem Pointer drauf addieren, dass das immer genau die richtige Größe hat. Das heißt,
was wir hier haben wollen, ist,
wenn wir von dem aktuellen Element auf das nächste Element gehen wollen,
dann wollen wir halt P plus gleich eins machen und der C-Compiler sorgt dafür,
dass wir P plus gleich eins, der weiß,
das ist ein Pointer auf dem Int,
sodass er dann weiß, ich muss um vier erhöhen,
damit ich halt von einem aktuellen Adresse auf die Adresse des nächsten Integers komme.
Wenn man dieses hier macht, size of in,
das ist eben vier, dann würde man hier P plus gleich vier berechnen.
Das heißt, die Schleife guckt das, sagen wir mal,
das nullte Element an, Elemente, 1, 2,
3 werden überspringen und dann wird der Pointer eben auf das Element 4 gesetzt.
Und bei der nächsten Iteration gehen wir dann von 4 auf Element 8.
Und das ist definitiv nicht, was man hier wollte.
Das ist auch eine Sache, die ziemlich übel ist,
weil manchmal aus dem Quelltext halt überhaupt gar nicht klar ist,
ist das ein Pointer und wie groß ist das Denke?
Wie groß ist der Datentyp, auf den das gerade zeigt
Ja, das ist jetzt auch klar,
dass man das nicht unbedingt tun sollte hier.
Das ist einfach ein Tippfehler. Wir kaufen uns
einen Speicherbereich in die Variable X ein, mit Anne-In-Titschern.
Wir machen was damit und dann geben wir das zurück.
Dann kaufen wir uns einen neuen Speicherbereich ein für Y,
manipulieren das und dann würden wir wahrscheinlich Y zurückgeben wollen,
also Free Y, dann hat er bei Free X geschrieben.
Das heißt, der Speicherreich, den wir hier hatten,
der wird nochmal wieder freigegeben und wenn zufällig der Speicherbereich
halt zwischendurch wie irgendwas anderes benutzt wurde,
dann ist das halt,
sind die Datenstrukturen
auch endgültig kaputt
Der umgefehlt kehrte Fall ist, dass wir halt hier
Speicher anfordern, irgendwas damit machen, den Speicher zurückgeben.
Und anschließend an dieser Stelle immer noch mal benutzen.
Das merkt der Compiler auch nicht. Und auch an
dieser Stelle hat man dann halt letztlich undefiniertes Verhalten.
Der andere Fall ist halt, dass wir Speicherbereiche anfordern und
anfordern und anfordern. Der hieb wächst und wächst und wächst
. Und wenn man das so macht wie hier,
dann hat man überhaupt keine Chance mehr.
Man hat halt hier einen Pointer, der lokal auf dem Stack angelegt wird.
Der Pointer zeigt dann hier auf den Speicherbereich,
den Malok uns im Hieb belegt hat
Und nachdem wir aus der Funktion zurückspringen,
ist der hier nicht mehr definiert und nicht zugreifbar.
Und das heißt, wir haben gar keine Möglichkeit,
jemals frie aufzurufen, weil wir die Adresse,
die wir hier angefordert hatten, gar nicht mehr kennen.
Das kann möglicherweise tagelang unentdeckt bleiben. Das heißt,
der Speicher, den das Programm belegt, der wächst und
wächst und wächst und wächst und wächst immer so ein bisschen.
Ganz gut kann man das bei Bildverarbeitungs- oder Videoprogrammen sehen,
wenn das da passiert, weil so ein Videoprogramm,
sagen wir mal, was weiß ich,
30 mal pro Sekunde irgendwie ein paar Megabyte anfordert.
Und dann ist möglicherweise eben nach ein paar Sekunden schon Schluss,
weil einfach kein Speicher mehr zur Verfügung steht
Noch schlimmer ist das halt, wenn man in
der Mitte von dynamischen Datenstrukturen vergisst, sowas zu machen,
das werde ich jetzt aus Zeitgründen nicht genau vorführen.
Also hier wird eine Liste angelegt und dann einige Elemente der
Liste sind nicht mehr zugreifbar.
Das heißt, die können auch nie mehr zurückgegeben werden.
So was aber wichtig ist, ist jetzt dieses Beispiel.
Und das zeigt jetzt die tatsächliche Implementierung einer Funktion aus der Standard C-Library,
aus der String-Library, nämlich die Funktion Get Ass.
Die Idee ist, dass Get Ass halt von der Tastatur
Eingaben liest,
Taste 4 Taste mit der Funktion Get C,
die halt auf dem Tastendruck wartet und wenn dann Taste gedrückt wird,
dann gibt es einen Charakter, was man dann anschließend auswerten kann.
Und diese Funktion Get S soll halt nicht einzelne Zeichen zurückliefern,
sondern einen ganzen String. Und damit sie Speicher hat,
wo sie das tun kann,
wird dieser Funktion ein Pointer auf ein Zeichen,
auf einen Speicherbereich übergeben,
in den
diese Funktionszeichen reinschreiben kann
Das ist also soweit ganz okay. Und zu
der Zeit in den 60ern, als das geschrieben wurde,
war Performance sehr kostbar und ein Speicher sehr kostbar.
Deswegen kann man dieser Funktion keinen Vorwurf machen. Also zur
damaligen Zeit war das okay. Und es gab noch kein Internet.
Aber das ist halt jetzt der Inhalt der Funktion.
Was tut sie? Wir gucken uns, wir warten auf
ein Zeichen von der Tastatur. Wenn das Enterfile ist,
das heißt, benutzt hat Return gedrückt oder die Datei,
aus der wir da lesen, ist zu Ende.
Dann, solange das noch nicht das Dateiende ist und solange
es nicht Return ist,
holen wir uns das Zeichen und packen das an der Stelle
Pointer auf diesen, ja, der wurde hier, dieser Speicherbereich,
wir kaufen jetzt einen Pointer auf den Anfang des Speicherbereichs und dann
wird an der Adresse dieses Points das Zeichen in den Speicherbereich reingeschrieben und dann der Pointer plus plus Pointer-Arbeit-Meeting auf das nächste Zeichen gesetzt.
Wir holen uns das nächste Zeichen von der Tastatur und dann
geht es hier wieder rein für ein Dateiende ist oder Return,
dann sind wir fertig,
dann hängen wir noch ein Null-Zeichen daran und geben das zurück.
Das war die Idee dieser Funktion. Das Problem dabei ist,
dass diese Funktion keinerlei Abfrage hat,
wie groß dieser Speicherbereich eigentlich ist. Das heißt,
wenn man da nicht mehr weiter Tasten eintippt,
dann wird diese Funktion immer neue Zeichen hier in diesen Speicherbereich
anfügen,
egal ob der Speicherbereich groß genug ist oder nicht
So, das ist soweit nicht problematisch, wenn man weiß,
wie man diese Funktion benutzen sollte und benutzen darf.
Was man aber tatsächlich machen kann, ist sowas hier schreiben.
Ein kleines Echo-Programm. Die Idee ist halt,
dass das Programm Tasten drücke vom Benutzer entgegennimmt und die
eingegebenen Tasten drücke seinerseits wieder ausgibt. Gut,
als Alternativ zu diesem GetS gibt es halt auch eine Funktion Putters,
die halt die Zeichen, die in einem Stringen drinstehen, wieder ausgibt.
Also jetzt hier unsere erste Implementierung von Echo. Wir kaufen uns Aluzieren,
einen Speicherbereich, mit in diesem Fall vier Zeichen.
Das kommt also jetzt hier auf den Stack. Und dann
lesen wir von der Tastatureingaben einen ganzen String in diesen Puffer hinein.
Und anschließend geben wir das wieder aus. So,
vier ist jetzt einfach nur aus didaktischen Gründen,
damit das Schönen wieder auf den Folien passt.
Typischerweise würde man wahrscheinlich so was wie 80 machen oder 1024
oder sowas und sagen, das ist ja dann erstmal gut.
Aber das ändert nichts an dem grundlegenden Problem, dass dieser Puffer,
der hier angelegt wird, eine feste Größe hat.
Und es gibt keine Möglichkeit zu kontrollieren,
wie viele Eingaben der Benutzer oder eben ein böser Programmierer,
der irgendwelche bösartigen Webseiten erstellt hat,
hier tatsächlich eingeben kann
Gut, das ist also jetzt eine fehlerhafte Funktion, Echo.
Und was wir jetzt tun würden, ist halt Funktion Main,
Print F, ein String ausgeben. Ja,
bitte gib einen String ein, dann rufen wir dieses Echo auf,
das adduziert den Puffer auf dem Stack,
ruft Get S auf und anschließend putt er es.
So, wenn man das ausführt, dann wird man
halt sowas in dieser Art bekommen, einen String eingeben.
Hier ist jetzt die Benutzereingabe gezeigt, 1, 2,
3, dann Return und was das Programm dann ausgibt,
ist halt 1,
2,
Scheint also zu funktionieren. Wenn man jetzt aber mehr eingibt,
also in diesem Fall 1, 2, 3,
4, 5, wir wissen schon,
wir haben nur Platz für vier Zeichen,
das sind also jetzt schon fünf und dann wird dann noch so eine Null eingefügt.
Das ist also zu groß. Was passiert dann? Das
Programm stürzt ab. Wir werden gleich sehen, warum.
Und wenn man dann noch mehr Zeichen eingibt,
dann passiert eben das Gleiche. Wir werden aber sehen,
dass dann tatsächlich hinter den Kulissen immer schlimmere Dinge passieren können.
Also in diesem Fall ist halt auch dieser Puffer wieder
zu groß und das Programm ist auf irgendwelchen Gründen stürzt es
halt sofort ab
Was da natürlich passiert ist,
dass die zusätzlich eingegebenen Zeichen den Stack überschreiben und damit halt
die Rücksprungadressen und diese Framepointer überschreiben und überschrieben werden.
Also, wir gucken uns jetzt noch mal an,
was hat der Compiler aus dieser Funktion Echo eigentlich gemacht?
Den ersten Teil erkennen Sie wieder,
das ist wieder das Verwalten des Framepointers.
Wir retten halt den Framepointer der aufrufenden Funktion auf den Speicher
auf unseren Speicherbereich und merken uns dann,
wo unser eigener Speicherbereich anfängt
Anfängt, dann aluzieren wir Platz für diesen Puffer hier.
Und anschließend werden wir dann hier irgendwann Call Get S aufrufen.
Die Funktion halt, die den Speicherbereich, die Zeichen von
der Tastatur einliest. Und das, was direkt davor passiert,
ist halt, dass wir dieser Funktion ihren Parameter übergeben.
Das ist halt dann die Adresse des Speicherbereichs, den sie benutzen soll.
Und das wurde dann hier mit diesem Ding hier berechnet.
Das heißt, der Stack-Layout unserer Funktion sieht dann so aus.
Das ist der Framepointer, den wir uns von der
aufrufenden Funktion gemerkt haben
Haben wir hier den Puffer mit unseren Zeichen und dann den weiteren Bereich,
also insbesondere was weiß ich hier, dieses Argument,
das wir für die Funktion Get S benötigen.
Und oberhalb des Bereichs für diese Funktionen liegen halt die Daten der aufrufenden Funktionen,
insbesondere eben hier die Rücksprungadresse aus der Funktion Echo.
So, die Sachen würden jetzt gut funktionieren,
wenn man das im lebenden Objekt macht.
Das ist aber mittlerweile nicht mehr so schön machbar,
weil das Linux dazugelernt hat und sich ein bisschen vor solchen Sachen schützt.
Wenn sie das selbst probieren, dann werden sie halt sehen,
dass das Stacklayout möglicherweise ein bisschen anders aussieht,
als das hier noch dargestellt ist
Wir würden also jetzt hier das Programm im Debugger starten.
Genug Debugger Pufferdemo. Ein Braypoint in dem Echo setzen.
Und dann ist halt dieses hier die Speicheradresse,
die dir das Programm zugeteilt bekommen hat.
Und wenn man dann jetzt hier ranmacht,
dann läuft es halt hier in die Funktion Echo ein und
wir können uns angucken, wo ist unser Framepointer?
Das ist dann halt dieses hier.
Das ist also jetzt hier die Adresse in dem Textbereich.
Und dieses ist eine Adresse in dem Stack.
Und dann kann man sich hier
die Daten anschauen
So, also hier, so war das Konzeptionelle.
Und wenn man sich das jetzt hier tatsächlich am erlebenden Objekt anschaut,
die Adressen werden dann halt unterschiedlich sein, je nachdem,
wo man das gestartet hat.
Hier ist die Rücksprungadresse. Und das wäre jetzt hier der
Framepointer für die Funktion. Und das ist jetzt noch nicht zugewiesen,
das sind die Speicherstellen, in die der String eingetragen wird,
den der Benutzer jetzt eingibt.
So, jetzt gibt der Benutzer 1, 2, 3 ein.
Das sind also jetzt ganz zurückblätternd in der Vorlesung Aski-Code.
Was war die Kodierung von der Ziffer 1 im Aski-Code?
Und sich daran ändert, der wird sehen,
dass das dann halt Hex 3, 1 war.
Hex 3, 2 ist die Ciffer 2 und Hex 3
, 3, die Ciffer
Und dann anschließend teilt noch, dass die terminierende Null,
die nach der C-Konvention angibt, dass der es dringend hier fertig ist.
Das heißt, wenn wir das so machen,
dann haben wir halt 1, 2, 3 eingetippt.
Die Zeichen werden nacheinander von dem Get S hier bei uns
in diesen Puffer hineingeschrieben. Das wird mit dem Null terminiert
und das passt gerade soeben noch rein.
Und dann können wir anschließend zurückspringen, dieser Frame-Pointer,
hat noch den richtigen Wert. Das heißt, wenn wir zurückspringen,
dann können wir diesen Wert hier wieder eintragen und sind wieder
in einem Stack-Frame der aufrufenden Funktionen.
Und hier haben wir
unsere Returner-Wasse
Sieht also gut aus. So,
jetzt gibt der Benutzer aber nicht nur 1, 2,
3 an, sondern 1, 2, 3, 4,
5. Das Get S funktioniert trotzdem und das Get S wird
also jetzt hier nacheinander in diese Speicherstellen hier die 1,
2, 3 eintragen.
Dann hier die 4 und die 5 kommt dann hier
rein und dann wird das noch mit einer Null terminiert.
Das heißt, hier wird dann die terminierende Null eingegeben und
unser Stack sieht jetzt so aus
Das ist noch nicht sofort lebensbedrohlich, aber es bedeutet halt,
also unsere Funktion Echo kann jetzt noch weiterlaufen, aber in dem Moment,
wo wir versuchen, diesen Pointer hier zurück zu zurückzuschreiben,
hat er nicht mehr den richtigen Wert,
sondern irgendeinen anderen Wert und in diesem Fall halt einen Wert,
der hier weiter unten im Stack liegt.
Also wir hätten erwartet, BFFFF, FF8,
D8,
das wäre dann der hier und stattdessen kriegen wir einen Wert,
wo wir halt dieses FF8D8 ersetzt haben durch was anderes,
nämlich hier die Null als Terminator und die Ziffer 5 und das zeigt dann halt hierhin,
was auch immer da in dem Speicherbereich drin ist
Das ist auch noch nicht sofort schlimm,
aber wenn wir dann halt anschließend in diese Funktion zurückspringen,
dann hat diese Funktion jetzt halt nicht mehr ihren eigenen Frame,
der müsste hier sitzen,
sondern irgendeinen Frame hier,
mit was auch immer für Daten da drin liegen und vermutlich wird das dann halt ein ungültiges,
ungültige Befehle enthalten und entsprechend beziehungsweise erstmal noch mit ungültigen Daten arbeiten und wenn das dann zurückspringt,
wird es nicht funktionieren,
weil halt keine gültige Return-Adresse mehr zu finden ist,
weil die halt dann hier liegen müsste und
da unten gesucht wird
Gut, das ist halt, das ist halt erst im zweiten Schritt schiefgeht.
Wenn wir noch mehr Zeichen eingeben, dann passiert dieses hier.
Hier werden also jetzt nacheinander die 1, 2,
3, 4, 5, 6, 7,
8 und dann hier die Füten terminierenden Null eingetragen.
Und jetzt haben wir nicht nur den Framepointer der aufrufenden Funktion
überschrieben, sondern auch die Rücksprungadresse. Das heißt,
wenn wir jetzt hier mit unserem Funktion Echo fertig sind und den Returnbefehl aufrufen,
dann guckt, dass Return im Stack nach,
wo soll ich hin?
Und statt der gültigen Returnadresse haben wir jetzt eine andere Adresse,
die irgendwo anders hinzeigt
Und an dieser Stelle ist das Programm eben korrupted, kaputt.
Er springt nicht mehr an die richtige Stelle im Code zurück, sondern an irgendeine andere Stelle.
Das wird in den meisten Fällen eben dazu führen,
dass das Programm dann früher oder später sofort abgebrochen wird,
weil halt ungültige Adressen da sind.
Es geht aber noch schlimmer. Und zwar in dem wir jetzt halt nicht einen Programmierfehler einbauen,
wie wir das eben gemacht haben,
sondern indem wir uns die Daten von jemand anders holen,
also zum Beispiel indem wir das mal aus einer Internetverbindung holen,
wo jetzt halt böse Leute versuchen werden, uns Daten unterzuschieben,
die geeignet präpariert worden sind.
So, die grobe Idee ist jetzt halt,
also vier ist natürlich keine gute Puffergröße, sagen wir mal,
wir sind ein bisschen großzügige gewesen und haben uns schon 64 Zeichen immerhin reserviert.
Das wäre dann vielleicht schon mal so eine Größenordnung,
mit der man tatsächlich echte Programme sehen kann.
Wir gucken uns jetzt nochmal
den Stack an
In dem Moment, wo das Get S aufgerufen wird,
sehen wir halt die verschiedenen Funktionen hier, insbesondere halt,
fuh, das ist halt eine Funktion, die irgendwas macht,
die ruft in weitere Funktionen bar auf.
Das ist dann das Deckframe von Bar. Und in dem Bar
ist halt in dem Stackframe von Bar hier der Bereich,
der von dem Get S geschrieben wird. Und wenn man jetzt dafür sorgt,
dass das, was hier eingegeben wird,
entweder indem man auf der Tastatur halt fisselig irgendwelche Hackspots schreibt,
oder indem man sich das als Beispiel aus einer Datei oder einer Netverbindung holt,
Dann haben wir hier den Pointer auf unser Speicherbereich,
den wir eigentlich haben wollen.
Und der wird jetzt hier sukzessive mit Zeichen zugeschrieben. So,
und was der Angreifer jetzt tun kann, ist halt hier Zeichen einzugeben,
die tatsächlich nicht einfach nur Zeichen, 1, 2,
3, 4, 5 sind, sondern die Maschinenbefehle sind.
Zum Beispiel Maschinenbefehle, um sich eine Shell zu besorgen oder
irgendwelche Dateien zu löschen. Wir wissen aber nicht so genau,
wie das Deckframe aussieht, wo genau wir zurückspringen,
wenn wir Also an dieser Stelle haben wir uns hier
die Return-Adresse überschrieben und wir wissen noch nicht so genau,
wo wir denn eigentlich hinschreiben.
Wenn wir aber dafür sorgen, dass wir genau,
sagen wir mal, diesen Fremdpointer, der interessiert uns nicht mehr,
weil wir ja im Wesentlichen darin interessiert sind im Moment,
diese Return-Adresse zu überschreiben.
Wenn wir halt nur die untersten Bytes oder sagen wir die untersten beiden Bytes überschreiben,
dann sind wir im Wesentlichen noch in der richtigen Region des Speichers,
allerdings hier an der falschen Stelle und so genau wissen wir nicht, wo wir hinwollen.
Was man deswegen macht, ist, dass man zusätzlich hier
so ein bisschen Padding einbaut auf der Ebene des Maschinencodes,
würde das halt lauter Nops wirken.
Also Maschinenbefehle, die eigentlich gar nichts tun. Wenn wir jetzt dafür sorgen,
dass wir halt hier den Chartcode haben, hier irgendwas,
wo der Prozessor einfach sukzessive, nop, nop, nop,
nop durchläuft, dann wird er halt früher oder später bei unserem Chartcode ankommen.
Wir müssen also nur dafür sorgen, dass die Return-Adresse irgendwo hier reinzeigt. Das heißt,
wir übergeben jetzt hier in diese Funktion irgendwas,
was Chartcode enthält und davor einen Bereich von Dingen,
die uns nicht interessieren, die nur dafür sorgen,
dass der Prozessor bei der Ausführung,
wenn er irgendwo hier reinkommt,
tatsächlich bis dahin durchläuft
Und der Witz ist also jetzt,
dass wir halt genau so viel Zeichen hier eingeben müssen,
dass wir gute Chancen haben, diese Rücksprungadresse einmal zu überschreiben.
Und zwar so, dass wir in diesem Bereich hier reinspringen.
Hier wäre dann ein Beispiel, wie das aussehen kann,
weil man einen angreifbaren Web-Server hat, dann würde halt jemand versuchen,
eine Datei anzufordern, die sowas so aussieht.
Diese NNNNs sind dann halt, wenn man das als Assembler-Ding anguckt, gerade die X-86-Nop-Befehle.
Und dann würden hier jetzt mit Unicode-Notationen halt irgendwelche Zahlen,
die natürlich dann Maschinenbefehlen entsprechen, die Shardcode sind,
und auf diese Weise, wenn es gelingt, halt hier einen Pufferüberlauf einzufüllen,
dann werden diese Dinge hier auf den Zeck geschrieben.
Und wenn man die Rücksprungadresse so geeignet,
überschreibt das halt anschließend der Prozessor irgendwo hier reinspringt,
dann läuft er dann anschließend bei der Ausführung bis hier rein
und beginnt dann hier mit dem bösen Code.
Und das ist dann halt die beliebte Art und Weise,
wie man es schaffen kann,
dass man aus eingegebenen Daten tatsächlich eigenen Code in den Rechner
einschleppt und damit eben letztlich das Angreifbare,
fehlerhafte Programm übernommen hat
Gut. Das soll an dieser Stelle dann erstmal ausreichen,
um die verschiedenen Dinge der Assembler-Ebene, der Assembler-Programmierung zu beenden.
Was haben wir gelernt? Sie erinnern sich,
die Umsetzung von Kontrollstrukturen, If than Ells,
die verschiedenen Schleifen, Repeat and Till, weil du,
weil, und entsprechend die Vorschleife, dann haben wir gesehen,
wie man Funktionsaufrufe realisieren kann,
indem man halt die Parameter,
die Rücksprungadressen auf den Stack packt und die lokalen Variablen,
die die Funktion haben will und dass das
auch für rekursive Funktionen ausreicht
Dann haben wir uns das Speicherlayout angeschaut von Arrays,
eindimensional, zweidimensional und von strukturierten Daten und Klassen.
Und so ganz kurz, wie man objektorientierte Konzepte machen kann.
Dann ganz kurz dynamische Speicherverwaltung und jetzt vorhin halt angeschaut,
wie es passieren kann,
dass man sich mit fehlerhaftem Code nicht nur Programmierfehler einhandelt,
sondern tatsächlich
auch ausführbaren Code einschleppen kann
Damit ist dann das Thema Assembler und Speicher erstmal beendet.
Und wir wechseln jetzt komplett das Thema. Als Literatur kann ich
nur nochmal hier auf das Buch von Bryant und O'Haloran hinweisen.
Das ist halt ein ganz, ganz exzellente Einführung ist in die,
nicht nur in die Rechnerarchitektur, sondern eben auch in die Programmierung und Systemprogrammierung.
Gut. Jetzt kommt ein kompletter Themenwechsel und ab jetzt soll es darum gehen,
in den nächsten verbleibenden Kapiteln ein paar Konzepte zu illustrieren,
die in eigentlich allen aktuellen Rechnern implementiert sind,
vorhanden sind,
um die Performance der Befehlsausführung zu verbessern.
Und das Wichtigste dafür ist halt
das sogenannte Pipelining
Die Idee ist jetzt,
dass wir so ähnlich wie bei einer Autofabrik von forteingeführt,
nicht mehr alle Dinge direkt nacheinander machen,
sondern dass wir das in einzelne Stufen unterteilen,
wo dann einzelne Befehle halt in verschiedenen Stadien der Befehlsausführung in Betrieb sind oder in Bearbeitung sind.
Die Idee ist sei,
dass wir hier irgendeine komplizierte Operation haben,
wie zum Beispiel ein Auto zusammenzubauen oder eben einen Befehl auszuführen,
den wir
in verschiedene einzelne Stufen zerlegen können
Die dann hintereinander weiter ausgeführt werden sollen. Die einzelne Stufe kann allerdings
wesentlich schneller ausgeführt werden, als die Gesamtoperationen dauert. So,
bei uns auf dem Rechner bedeutet,
dass wir halt die gesamte Ausführungsform,
also zum Beispiel die Befehlsausführung hier als Schalten jetzt realisieren könnten,
komplett von Anfang bis Ende, das wollen wir aber jetzt beschleunigen,
indem wir das in einzelne Stufen zerlegen und da werden wir dann jeweils zwischen die einzelnen Rechen Schritte,
also hier Schaltnetze, jeweils Register schalten, um Informationen zwischenzuspeichern.
Das wird Messer, ich überspringe jetzt mal zwei Folien,
wenn wir das hier an diesem Beispiel sehen.
Wir haben jetzt irgendeine Berechnung, die wir als Schaltnetz
realisiert haben und die bisherige Realisierung dauert halt 300 Pico-Sekunden.
Das wäre vielleicht ein typischer Wert,
um bestimmte arithmetische Operationen in einem Prozessor auszuführen
Gut, und um den wir zwischenzuspeichern,
haben wir jetzt hier einen Register und das Register hat natürlich dann seine Vorlaufenthaltezeiten und sowas,
braucht bestimmte Zeit, mit der es getaktet werden kann.
Und jetzt nehmen wir einfach mal als weiteren typischen Wert an,
das 24 Pico-Sekunden. So, die Gesamtzeit für diese Schaltung
ist also jetzt hier, wenn neue Daten ankommen,
300 Pico-Sekunden hier durch und dann 20 Pico-Sekunden bis in das Register.
Das heißt, die Gesamtzeit von hier bis der Ausgangswert
zur Verfügung steht,
sind 320 Pico-Sekunden
So, was ist jetzt der Durchsatz oder das Ruped?
Das ist halt die Anzahl der Operationen, der Rechenschritte,
die man pro Sekunde ausführen kann. Und um das zu berechnen,
gucken wir jetzt also einfach eine solche Operation,
kostet 320 Pico-Sekunden, also 10 Uhr minus 12 Sekunden.
Wenn wir jetzt eins durch davon rechnen, dann kommen wir genau,
also auf eins durch 320 mal 10 Uhr minus 12.
Und das wären dann halt 3,1 2 mal 10 Uhr minus 9. Das heißt,
wir könnten von diesem Ding hier drei Milliarden Operationen pro Sekunde,
also Giga-Operation pro Sekunde ausführen
Also dieses hier ist die Durchlaufzeit, es ist vergleichsweise offensichtlich.
Und der Durchsatz ist halt die Anzahl dieser Operationen,
die wir pro Sekunde ausführen könnten. Das ist also eins durch
die Verzögerungszeit, also in diesem Fall 3,12 Milliarden auf Operationen.
So, jetzt versuchen wir das in einzelne Teilschritte zu unterteilen.
Das heißt, dieses 300 Pico-Sekunden Schaltnetz wird jetzt in drei Blöcke zerlegt,
jeweils 100 Pico-Sekunden brauchen. So, und außerdem,
nach jedem Zwischenschritt haben wir jetzt wieder ein Register,
das heißt, wir haben jetzt hier den ersten Teilschritt,
100 Pico-Sekunden, dann ein Register,
der nächste Zahl Schritt von der Pico-Sekunden und nochmal der
dritte Teilschritt wieder 100 Pico-Sekunden.
Und jetzt eben drei Register
Das kostet es zunächst mal ein bisschen was, weil die gesamte Durchweise,
die Durchlaufszeit von hier bis hier hat sich jetzt vergrößert.
Wir haben also jetzt hier 360 Pico-Sekunden,
weil wir halt zwei von diesen Registern, also dieses Register
hatten wir schon, aber die beiden Register haben wir hinzugefügt.
Das heißt, es dauert jetzt insgesamt ein bisschen länger.
Jetzt ist aber die Behauptung, dass der Durchsatz sich erhöht hat.
Nämlich warum? Die Idee ist jetzt, dass wir,
wenn wir die ersten Daten hier reingeschickt haben,
dann werden die hier beim nächsten Takt in dieses Register gespeichert
Und statt jetzt noch einen Takt zu warten,
bis die hier durch sind und noch einen Takt bist,
die hier durch sind, können wir das jetzt verschachteln.
Das heißt, im ersten Takt,
also zu Anfang packen wir die Daten hier rein,
nach 100 Pico Sekunden können wir einen Takt geben.
Und im nächsten Schritt nehmen wir dieses Zwischenergebnis,
was hier ist, lassen das da durchlaufen,
können zur gleichen Zeit aber schon neue Daten von hier bis da durchlaufen lassen.
Das heißt, die Verzögerungszeit für so ein einzelnes Datenhäppchen ist
jetzt nicht mehr von hier bis da,
sondern immer nur noch von einem kombinatorischen Logikblock bis zum
nächsten Register
Das heißt, die Gesamtzahl,
eine Gesamtzeit bis eine Operation komplett fertig ist,
die muss durch die Schritte A, B, C durch,
ist jetzt halt 100 plus 100 plus 100 plus dreimal die Register,
360 Pico-Sekunden.
Das ist langsamer geworden. Aber andererseits können wir jetzt mit
jedem Takt immer einen neuen Takt geben,
wenn wir 100 Pico-Sekunden plus 20 Pico-Sekunden abgewartet haben.
Das heißt, der Durchsatz von diesen Dingern ist jetzt eins
durch 100 Pico-Sekunden plus 20 Pico-Sekunden und das wären eben 8,3 Milliarden Operationen.
Wenn wir das vergleichen, also hier hatten wir ein bisschen schnellere Durchlaufzeit,
aber wir konnten nur nur drei Milliarden Operationen in der Sekunde starten.
Hier haben wir etwas längere Durchlaufzeit. Wir können aber acht
Milliarden Operationen pro Sekunde starten. Das ist also die Idee.
Statt eine Operation komplett durchlaufen zu lassen, dann zu warten,
bis das fertig ist und dann die nächste zu starten,
teilen wir das in Häppchen auf, in einzelne Stufen.
Und wir beginnen mit dem ersten Teil.
Und wenn die erste Operation den zweiten Teil startet,
dann startet die zweite Operation schon den ersten Teil.
Und so wird das
ineinander gefügt
Genau, durch diese zusätzlichen Register,
die jetzt hier weggelassen worden sind,
wird die einzelne Operation eben nicht schneller oder sogar langsamer,
aber dadurch,
dass wir halt jeweils neue Stufen starten können für die Gesamtausführungszeit
hier von Staat hier bis Ende der dritten Operation hier,
deutlich kürzer als vom Staat bis zur dritten Operation
fertig hier
Gut, jetzt muss man sich natürlich so ein
bisschen Gedanken machen, wie das mit diesem Timing funktioniert.
Sie erinnern sich daran, wie das mit getakteten Registern ist.
Also hier haben wir so ein Taktsignal und das Register hier,
das übernimmt jetzt halt bei der steigenden Flanke des Takts den
Wert von A und stellt ihn dann halt der nächsten Stufe zur Verfügung.
Das heißt, dieses Ding läuft jetzt hier rein,
dann gibt es einen Takt, dann ist halt A,
braun A erstmal hier drin und die Situation,
die jetzt hier in dem Bild gezeigt ist,
ist die Situation zu diesem rot indizierten Zeitpunkt,
kurz bevor wir halt den zweiten Takt geben
Die ersten Daten hier, die braunen Daten, sind dann bereits verarbeitet worden,
wurden zwischengespeichert und sie jetzt hier in Bearbeitung und sind so
gut wie fertig und können jetzt hier in dieses Register übernommen werden.
Und die zweite Operation, also die hellblaue Operation,
die ist jetzt hier gestartet worden und ist zu diesem Zeitpunkt
kurz bevor der nächste Takt kommt,
halt hier schon fertig und mit dem nächsten Takt wird halt dieses Zwischenergebnis,
dieses Register getan,
dieses Zwischenergebnis in das Register
So, kurz nachdem dieser Takt gekommen ist,
wir gehen jetzt, dann haben das halt hier wieder sozusagen gelöscht.
Wir haben halt jetzt diesen Wert in dieses Register übernommen,
den Wert hier in dieses Register und jetzt beginnen die verschiedenen Schaltnetze wieder mit der Bearbeitung.
Und zusätzlich ist jetzt hier,
das sieht man hoffentlich so ein bisschen,
dass jetzt hier der Operation 3 hier an den Eingang des
ersten Logikblocks A angelegt worden ist
Das heißt, hier beginnen jetzt diese Logikblöcke gleichzeitig zu arbeiten,
aber auf drei unterschiedlichen Eingabedaten. Die sind also jetzt beschäftigt,
das ist jetzt dadurch visualisiert, dass jetzt hier die Daten
sich mehr oder weniger durch die einzelnen Blöcke schon vorgearbeitet haben.
Also der hier ist jetzt in seinem letzten Stufe, in dem C,
die Eingabedaten waren halt aus diesem Register, was hier zwischenliegt,
laufen jetzt in dieses Schaltnetz und früher oder später wird das hier fertig sein.
Genauso ist jetzt halt dieses Schalten jetzt die Stufe B, damit beschäftigt Stufe
B von dem Blauen zu berechnen und Hier ist das erste Schaltennetz,
also Schaltennetz A, damit beschäftigt,
den ersten Teil von dem Operanten 3 zu berechnen.
So, jetzt kann man das natürlich so durchlaufen lassen.
Kurz bevor jetzt der nächste Takt kommt,
wären dann halt hier die Block A,
wir mit dem Grauen fertig, Block B,
wir mit dem hier fast fertig und Block C ist mit dem
hier fast fertig und dann sind die Daten da und können entsprechend in das Register übernommen werden.
Das heißt, wenn jetzt der nächste Takt kommt,
dann wären die Daten von C hier in einem Ausgangsregister,
diese Teile werden dann dieses Register übernommen worden und
der wird hier,
wird denn
das Register übernommen
Das klingt also soweit ganz vielversprechend. Gut,
leider gelingt es nicht in der Praxis,
diese Dinge alle in gleich große Häppchen zu zerschneiden.
Wir hatten also jetzt ursprünglich den kompletten Logikblock mit 300 Pico-Sekunden
und wenn es nicht klappt, denen gleich große Blöcke zu zerlegen,
dann könnte zum Beispiel sowas hier passieren.
Der hier hat da jetzt 50 Pico-Sekunden-Verzögerung, der 150 und
der 100. Das ist also die Gesamtverzögerung ist gleich.
Das war wie 300 und jetzt haben wir halt hier
unsere Register dazwischen
Gut, jetzt steht hier, Delay 510 dicke Sekunden.
Wie kann das sein? Also wenn man jetzt hier 50 plus 150 plus 100 aufrechnet,
das sind weiterhin 300 plus 20, 40, 60, 360.
Wie kommt das zu den 510? Naja, das kommt jetzt dadurch,
dass halt, dass wir immer auf den langsamsten Block warten müssen.
Das heißt, der braucht nur 50,
aber damit das in diesem festen Paktraster passt,
müssen die Takte so gemacht werden, dass wir immer auf den Block B warten.
Das heißt, wir haben jetzt hier 50 Sekunden netto,
aber brutto müssen wir 150 Sekunden warten, also 150,
nochmal 150 sind 300, nochmal 150 hier,
es sind 450 plus dann 20,
40,
60 sind die 510 Pico Sekunden
Gut, und wenn man jetzt hier diesen Durchsatz berechnet,
dann haben wir halt 5,88. Warum?
Weil halt der einzelne Block 150 plus 20, also 170 braucht.
Was lernen wir denn? Die Taktfrequenz, mit der wir
sowas betreiben können, ist also offensichtlich limitiert durch die langsamste Stufe.
Und wir werden halt versuchen müssen, die Schaltung so umzubauen,
dass die einzelnen Stufen möglichst gleich lange Bearbeitungszeit haben.
Gut, jetzt kann man versuchen,
das möglichst schnell zu machen, indem wir das in ganz,
ganz viele ganz kleine Häppchen zerteilen
Also zum Beispiel dieses hier. Hellblau ist also jetzt wieder
sechsmal 50 Pico-Sekunden-Blöcke, sind unsere ursprünglichen 300 Pico-Sekunden.
Wenn wir das jetzt in sechs Blöcke zerteilen,
brauchen wir aber auch entsprechend viele Register. Das heißt,
die Gesamtdurchlaufzeit durch dieses Ding hat sich jetzt nochmal vergrößert.
Das ist also jetzt hier 50 plus 20, bla bla bla bla.
Und insgesamt halt 420 Pico-Sekunden. Das ist also langsamer geworden
, als es ursprünglich war. Und der Durchsatz,
der theoretische Durchsatz wäre jetzt eben eins durch die durchaus Zeit
durch eine Stufe, also eins durch 70 Pico-Sekunden,
entsprechend 14 Giga-Operationen pro Sekunde
Das könnte man also jetzt noch weiter verfeinern,
also indem wir, was weiß ich,
jetzt hier schon 50 auf 25 oder sowas übergehen oder sogar auf zehn Pico-Sekunden.
Und man sieht, irgendwann macht es keinen Sinn mehr,
weil die Gesamtzeit irgendwann begrenzt ist durch die anteilige Zeit,
für die Register, die wir da haben.
Wenn man das hier an dem Beispiel nochmal durchrechnet,
bei nur einem Register, das war also die Version ohne Pipelining,
hatten wir ein Register mit 20 Pico-Sekunden Schallzeit und 300 Pico-Sekunden für die Berechnung.
Insgesamt also 20 von 320 Pico-Sekunden oder sagen wir mal
6 Prozent
Bei Drei-Registern sind es dann schon 16 Prozent.
Bei sechs Registern hier schon 30 Prozent.
Und irgendwann bei zwölf Registern oder sowas und 20 100 Registern
würde das halt Richtung 100 Prozent gehen.
Das heißt, es gibt irgendwo so ein praktisches Limit,
wie viele einzelne Teilstufen noch Sinn machen.
Darüber hinaus ist, dass halt einfach dann Overhead durch die
zusätzlichen Register. Soweit so gut. Es gibt aber natürlich
ein fundamentales Problem
Nämlich das Problem der Datenabhängigkeiten. Wir haben jetzt wieder diese
Situation, dass wir halt hier so ein Scheitnetz haben.
Wir wollen das Ergebnis zwischenspeichern, aber das Ergebnis geht wieder
in die Berechnung ein. Wenn wir kein Pipelining,
Pipelining haben, dann macht das nichts weiter.
Dann berechnen wir halt das erste Ergebnis zu Ende,
packen das in das Register und können dann mit dem zweiten,
mit dem zweiten Eingangsdaten weitermachen,
berechnen das bis zum Ende,
können dann
mit den nächsten Eingabedaten weitermachen
Wenn wir jetzt versuchen, das zu verschachteln,
dann ist halt die Frage, wie wir das tun sollen,
weil in diesem Fall halt die Berechnung hier von den vorherigen Daten abhängt.
Wir müssen also warten, bis dies hier fertig ist,
damit wir überhaupt das nächste starten können.
Und das ist dann die nächste zweite Limitierung des Pipelinings.
Da werden wir jetzt Beispiele sehen,
wie das aussieht, wenn man das tatsächlich versuchen,
um die Befehlsausführung auf einem Rechner zu beschleunigen.
Also das ist jetzt hier nochmal illustriert.
Wir haben jetzt hier das, was wir eben gezeigt haben,
den großen Logikblock mit Rückkopplung aufgeteilt in kleinere Schritte
Und ja, wenn wir das jetzt machen wie bisher,
dann wird halt diese Operation hier reingetan.
Nach einem Takt ist sie dann hier, nach zwei Takten
da, nach drei Takten ist das Ergebnis hier fertig.
Zwischendurch hätten wir jetzt aber schon weitere Daten hier gestartet,
also den hier gestartet und denen gestartet.
Es gibt aber jetzt halt hier diese Abhängigkeit von dem hier zu dem,
das heißt, er ist nach diesem Takt ist das Ergebnis
fertig und kann überhaupt hier in das A wieder reingetan werden.
Das heißt, diese beiden hier sind offensichtlich ungültig,
weil sie halt hätten warten müssen,
bis die Zwischenergebnisse hier das erste Mal fertig sind.
Das heißt, wenn man solche Datenabhängigkeiten hat,
dann kann man das so nicht machen oder man muss halt Spezialfälle ausnutzen,
bei denen halt die Berechnungen, die wir schon gestartet haben,
doch nicht von dem hier abhängen.
Das heißt, letztlich ändert das Pipelining das Verhalten des gesamten
Systems und wir werden dann sehen, was man tun kann.
Eine Möglichkeit ist halt, dass wenn sowas auftritt,
dass man das erkennt und diese hier entsprechend verzögert und den
hier dann halt erst da startet
So, das war ein relativ allgemein Pipelining, das würde
jetzt eben auch für Fabriken oder für sonstige Schritte gelten.
Jetzt geht es aber hier speziell um Rechnerarchitektur und halt die Frage,
wie die Befehlsausführung beschleunigen können. Und da erinnern Sie sich,
dass wir verschiedene Phasen hatten, die so als typisch gelten können,
wenn wir den von Neumann-Rechner haben,
nämlich zunächst mal neuen Befehl aus dem Speicher holen.
Instruction Fetch. Dann zu analysieren, was ist das eigentlich
für ein Befehl, welche Operation soll ausgeführt werden,
welche Operanten brauchen wir dafür.
Das wäre halt die Code-Phase und gegebenenfalls Operant Fetch,
je nachdem,
welche Operanten der Befehl tatsächlich braucht
Dann wollen wir die Berechnung tatsächlich ausführen.
Das ist dann halt häufig, dass wir auf der Alu,
eine Addition oder eine Schiffs oder sowas machen, also eine Execute-Operation.
Und dann gegebenenfalls einen Speicherzugriff machen, um Daten zu laden oder abzuspeichern.
Und das werden wir gleich sehen, warum man das so macht.
Wright Back, das wäre die Operation,
wo wir die Wert, den wir hier berechnet haben,
tatsächlich in einen Register zurückschreiben wollen.
Das wäre jetzt eine Aufteilung der gesamten Befehlsausführung,
sagen wir erstmal in fünf logische Schritte
Da kann man sich jetzt überlegen,
wie man das halt in so ein Pipelining einbauen könnte.
Und das ist also jetzt hier gezeigt ohne Pipelining.
Hier mit Samler-Befehlen, die jetzt nicht spezifisch sind für
den Intel, sondern für den Mips-Prozessor.
Aber nur Befehle, die sich relativ leicht analysieren lassen.
Da brauchen wir also die Details nicht zu kennen.
LW steht für Load Word. Und dann sind halt jetzt
hier diese Dollars irgendwelche Registernamen und hier irgendwelche Adressaufsätze und
ein Register
So, die Idee ist also jetzt,
wir haben ein Programm, das aus mehreren Maschinenbefehlen besteht,
in diesem Fall besteht unser Programm,
also hier zunächst mal aus einem Load-World-Befehl,
dann einem weiteren Load-World-Befehl, was ist denn das Register,
also der lädt in das Register 1,
der lädt in das Register 2 und der lädt irgendwas in der Suegister 3 und die Adressen auf dem Speicher,
die sind jetzt halt hier kodiert.
Jetzt kann man sich anschauen,
wie lange dauert das eigentlich mit echter Hardware und jetzt sind hier so Zeiten angegeben,
die für, sagen wir mal, einen Rechner,
der 1980er bis 1990er Zeit typisch sind, Instruction Fetch,
200 Pico-Sekunden dann irgendwie die Register zuzugreifen, die da,
die wir bearbeiten wollen, dann halt eine Alu-Operation,
dann der Datenzugriff und dann noch wieder ein Registerzugriff.
Das waren also unsere fünf Phasen von eben und wenn wir damit durch sind
, können wir den nächsten Befehl anfangen. Gut,
man könnte jetzt auch versuchen,
das hier noch wieder in Unterphasen zu unterteilen,
aber wir fangen jetzt erstmal an mit dem,
was wir so eine klassische Pipeline nennen, nämlich fünf Stufen,
die genau aus diesem Minustraction Fetch, dem Decode,
in dem Operanten holen,
der Alu-Operation,
der Speicherzugriff und der Writeback-Phase bestehen
So, wenn man das jetzt hintereinander schachtelt,
dann haben wir genau diese Situation von eben mit den abstrakten Operationen,
dass die nicht alle gleich lang sind.
Aber das Raster passt schon einigermaßen. Also hier haben wir
jetzt den Instruction Fetch von dem ersten Befehl.
Und während wir den dann dekodieren und uns die Operanten für
diesen Befehl holen, können wir den zweiten Befehl schon dekodieren.
Das dauert eben kürzer. Deswegen haben wir hier so ein bisschen Platz.
Und dann führen wir im dritten Takt die Alu-Operation hier aus.
Also die dritte Stufe des ersten Befehls und während wir das tun,
können wir schon die Operanten bereitstellen für den zweiten Befehl und für
den dritten Befehl holen wir uns schon mal den Befehl.
Und während wir dann hier Speicherzugriff ausführen und hier eine Berechnung machen,
holen wir uns hier die Operanten für den dritten Befehl
und dann sind wir hier fertig mit dem ersten Befehl,
holen uns gleichzeitig im vierten Takt für den zweiten Befehl
die Daten und für den als drittes gestarteten Befehl berechnen wir
hier die AL-Operation
Das ist also dann dasselbe Prinzip, nur jetzt angewendet auf
den von Leimann-Rechner. Wenn wir das so hinkriegen,
dann sind wir offensichtlich deutlich schneller,
als wenn wir das nicht machen,
weil wir halt in diesem Beispiel halt innerhalb von 1300 beziehungsweise 1400 Zeiteinheiten,
Pikosekunden mit drei Befehlen fertig sind.
Und ansonsten hätten wir halt 1000, 2000, 3000 braucht.
Das ist also in diesem Fall schon mal
mindestens doppelt bis dreimal so schnell.
Und das wird sich also lohnen
Gut, damit das funktioniert,
müssen wir jetzt halt in unseren Rechner diese komischen Pipeline-Register einführen,
die jeweils die Zwischenergebnisse der einzelnen Stufen speichern.
Schematisch sieht das dann so aus,
dass wir in dem ursprünglichen Rechner halt hier in Scruction Fetch
hatten und dann geht das nach Decode, nach dem Execute,
alle Operationen Speicherzugriff und dem Rückschreiben der Ergebnisse.
Und um das jetzt verschachteln zu können,
Pipelining machen zu können,
setzen wir zwischen die einzelnen Stufen Register und die Register sind jetzt so ausreichend dimensioniert,
dass alle Zwischenergebnisse,
die auftreten,
tatsächlich da reinpassen
Gut, jetzt muss man natürlich sich anschauen,
wie groß ist die Verzögerung durch so ein Register?
Wie groß ist der Zeitbedarf für die einzelnen Stufen? Wenn man das macht,
dann stellt man halt fest, dass man mit diesem Konzept
etwa drei- bis fünfmal schneller sein kann als bei serieller Ausführung.
Das lohnt sich also durchaus. Und der Hardwareaufwand, den
man hat, ist vergleichsweise gering. Diese Blöcke hier,
wie auch immer sie tun, ist ins Straction Fett,
dekodieren die Alu, ein Speicherzugriff und das Rückschreiben der Register,
das brauchen wir sowieso, die Scheitnütze müssen wir bereitstellen,
unabhängig davon,
ob wir das alles nacheinander machen
Oder nicht, wenn man das Pipeling Pipelining einfügen,
dann brauchen wir eigentlich nur diese Register.
Das ist also vergleichsweise preiswert und ist für viele,
viele Jahre ein sehr guter Kompromiss aus Leistung und Hardwareaufwand gewesen.
Heutzutage fügt man noch mehr von diesen Stufen ein,
also wir gleich noch sehen, dass man da bis 20 oder 30 Pipeline-Stufen geht.
Dieses hier ist aber noch mit am einfachsten zu verstehen.
Wir haben also jetzt hier eine fünfstufige Pipeline und das
war so die allgemeine Architektur der meisten allerersten Pipeline-Prozessoren,
insbesondere eben die ersten Risk-Architekturen,
Spark und MIPS und die IBM
Gut, die Sache war, ja, damals, 1985,
1987, IBM 801 noch ein bisschen davor, halt brandaktuell,
das ließ ich mit den älteren Rechnern nicht direkt umsetzen,
das heißt, es wurden einen Rechner neu gebaut,
diese Reduced Instruction Set Computer,
bei denen halt der gesamte Befehlssatz und die Architektur darauf hinausgelegt war,
dass man dieses Pipelining umsetzen kann und Dann haben die Leute mit den Älteren,
komplexeren Architekturen irgendwann nachgezogen.
Da ist das dann aber auf der Hardware-Seite viel schwieriger.
Das ist also zu meinen Motorola mit der 68.000-Architektur und Intel
halt mit dem 4.86er und dem Pentium.
Gut, das gucken wir uns im Detail nicht an,
weil das halt sehr, sehr schwierig ist,
die verschiedenen komplizierten Befehle alle in dieses Pipeline-Corset reinzuzwingen.
Wir schauen uns also jetzt hier erstmal die einfachen Architekturen an,
bei denen das Pipeline direkt umgesetzt werden kann
So, das ist ein Blockchain-Bild von dem typischen Risk-Prozessor,
wo jetzt die verschiedenen Funktionsblöcke alle drauf sind.
Wir gehen da einmal kurz durch. Wir haben irgendwo das Register mit
dem Programm-Counter oder halt bei dem Intel war das dieses EEP-Instruction-Pointer.
Das Register, was halt angibt, aus welcher Speicherstelle wir
den nächsten Befehl holen wollen. Und dann ist halt hier
der Speicher skizziert mit dem Namen Instruction Memory.
Das ist also jetzt der Speicher, der die Maschinenbefehle enthält.
Der kriegt eine Adresse, die er aus dem Programm-Account da rauskommt.
Und was er uns liefert, ist halt der Maschinenbefehl,
den wir als nächstes ausführen wollen
So, während wir jetzt hier weitermachen, können wir schon
mal anfangen, den Programm-Accounter auf den nächsten Befehl weiterzuschalten.
Das ist dann halt dieser Adira. Und bei 32-Bit-Architektur und Breitweiser-Adressierung
müssen wir halt den Programm-Accounter von einem zum nächsten Rüstbefehl immer um vier erhöhen.
Das ist jetzt natürlich schon so von links nach rechts skizziert,
dass wir gleich sehen werden, wie man das als Pipeline-Architektur aufbauen kann.
Also damit fängt es an, wir wollen einen Befehl holen
und während wir tatsächlich aus dem Speicher den Befehl auslesen,
können wir schon mal hier dem Befehlszähler vorbereiten auf den nächsten Wert,
auf die nächste Instruktion.
So, dieser Befehl wird jetzt zerlegt und bei den Risk-Architekturen ist es halt typischerweise so,
dass wir das, was wir hier aus dem Speicher kriegen,
einfach so in verschiedene Blöcke zerlegen können, die uns dann angeben,
was getan werden kann.
Also hier haben wir jetzt unsere Register,
da werden also jetzt abhängig vom Befehl ein paar Register adressiert
und entsprechend diese Register dann halt am Ausgang der Registerbank
als Werte zur Verfügung gestellt
Hier ist noch ein weiterer Block. Also das ist dann,
wenn wir ermiedet, Emediate-Operanten in dem Befehl drin haben,
dann könnten wir hier zum Beispiel 16 Bit Emediates schon mal
auf 32 Bit Vorzeichen behaftet aus erweitern, während wir hier die Register auslesen.
Anschließend geht das dann in die Alu und die Alu-Operation hier,
die kommt natürlich auch aus dem Befehlsfort. Gut,
wenn die Alu dann fertig ist,
dann wollen wir mit den Alu-Daten irgendwas machen,
also zum Beispiel die Werte wieder in den Speicher schreiben oder die Werte schwupp hier über diesen Multiplexer,
sorry,
also wenn wir irgendwas mit Registern berechnet haben,
dann geht das hier rum über diesen Multiplexer
Und wieder in ein Register zurück oder es wird halt in
den Speichel geschrieben. Und was jetzt noch fehlt,
sind die Dinger hier oben und das ist natürlich dann für Sprungbefehle notwendig.
Hier haben wir einen neuen Wert für den Programm-Counter und den
können wir jetzt einfach hier für den nächsten Befehl verwenden und wenn wir springen wollen,
dann addieren wir zu diesem Programm-Counter-Wert hier irgendwas dazu und würden
also dann auf die Art und Weise Sprungbefehle realisieren können
So, jetzt kann man sich anschauen,
was ist da der längste Pfad, der da drin vorkommt.
Das ist halt Staaten von hier, Programm-Counter,
dann Instruction-Memory, hier, dann dekodieren wir Register,
dann durch diesen Multiplexer, durch die Alu durch,
durch den Speicher, durch den Multiplexer wieder in Register zurück.
Das ist also das, was wir im Prinzip Schritt
für Schritt abwarten müssten. Und das wird jetzt beschleunigt,
indem wir die Funktionsblöcke so ein bisschen umsortieren und entsprechend
diese Pipeline-Register dazwischen schalten
Annotiert ist, dass jetzt hier mit diesen fünf Phasen,
also Befehl holen, dekodieren und ausdekodieren und Apparanten holen,
ausführen, Speicherzugriff und dann die Werte in die Register zurückschreiben.
Das sind also die gleichen Funktionsblöcke wie oben wie bisher,
sondern jetzt so ein bisschen eingefärbt und entsprechend umsortiert.
Also hier das Programmkrater, dann der Speicher,
aus dem wir den Befehl auslesen,
was da dann rauskommt,
wird zwischengespeichert in diesem ersten Pipeline-Register
Was da dann rauskommt, geht, wird halt benutzt als
Adressen für die Register. Und was aus der Registerbank rauskommt,
wird dann halt hier in das nächste Pipeline-Ringste geschrieben.
Hier ist dann die Alu mit den verschiedenen Multiplexern,
die wir brauchen. Hier der Speicher wieder für die Datenzugriffe.
Und dann hier die Frage, das Alu-Resultat läuft hier entweder
direkt durch und geht wieder in die Register rein.
Oder es kommt halt aus dem Speicher, wenn wir einen Lesebefehl haben,
würde halt hier eine Adresse rankommen, den Speicher.
Der Speicher liefert die Daten. Und was der Speicher
uns geliefert hat, kommt dann an dieser Stelle wieder in
die Register rein
Das ist also soweit ganz vielversprechend. Die Frage ist jetzt,
wie wir das halt tatsächlich im Detail umsetzen können.
Motivation war eben, dass wir mit dem gleichen Befehlssatz und
der gleichen Hardware im Wesentlichen drei- bis fünfmal schneller abarbeiten können.
Dadurch, dass wir halt die Befehlsausführung in die einzelnen Schritte
unterteilen und verschiedene Stufen der verschiedener Befehle gleichzeitig bearbeiten.
Das ist also eine kurze Zeit für eine Zwischenzusammenfassung.
Hier am Beispiel von den klassischen Rüstarchitekturen, also konkret Mips.
Fünfstufige Pipeline
Die eigentlichen Hardware-Blöcke, die wir brauchen für die Befehlsausführungen, sind komplett unverändert.
Also der Programm-Counter und der Adira für das Inkrementieren des Programm-Counters
und für die Sprungbefehle, dem brauchen wir sowieso.
Die Register brauchen wir sowieso, die Rechenwerke brauchen wir auch alle.
Und die meisten Leitungen und zwischen diesen Komponenten,
die sind eben auch schon vorher da gewesen.
Also all diese Leitungen, die wir jetzt hier haben,
die sind immer noch da,
die sind ja halt jetzt nur teilweise durch diese Register geleitet
Der Hardware-Zusatzaufwand hält sich in engen Grenzen. Wir brauchen halt
vier zusätzliche Register zwischen diesen Schufen. Das erste Register enthält im Wesentlichen
die dekodierten Befehle. Das nächste enthält die Zwischenergebnisse und zusätzlich
brauchen wir dann halt eine ganze Menge von Steuersignalen,
die wir auch zwischen diesen Stufen zwischenspeichern müssten.
Und der längste Pfad ist jetzt nicht mehr durch alles,
sondern längste Pfad ist jetzt zwischen jeweils einem Pipeline-Register und dem nächsten Pipeline-Register.
Die Frage ist jetzt natürlich, ob das jetzt auch mit
der Software einfach so weiterläuft oder was wir da tatsächlich machen müssen,
damit das dann funktioniert.
Und ich glaube, ich habe jetzt noch eine Minute,
da macht es keinen Sinn, noch weiterzumachen,
sondern das verschieben wir dann auf Freitag.
Ich hoffe, Sie haben jetzt die zentrale Idee schon mal mitgekriegt,
statt eine komplexe Sache von vorne bis hinten nacheinander abzuarbeiten.
Wollen wir das in verschiedene Stufen aufteilen,
wie halt bei einer klassischen Fließbandarbeit in der Fabrik.
Nur, dass jetzt teilt die einzelnen Schritte,
die Schritte sind, die wir für die Befehlsausführung des Rechners brauchen.
Und was wir bekommen ist seit bei, sagen wir,
fünf Stufen etwa maximal fünffachen Durchsatz. Also wir können das
Programm fünfmal schneller abarbeiten und das ist halt eine Sache,
wo sich das schon tatsächlich durchaus lohnt.
Okay, dann mache ich für heute Schluss und dann
sehen wir uns Freitag für das nächste Mal