 Ja, herzlich willkommen zurück zu Programmierparadigmen, das ist das Modul Control Abstraction, in dem es fünf Teile geben wird und wir sind jetzt gerade im ersten Teil. Vielleicht erkläre ich erst mal kurz, was Control Abstraction überhaupt ist und um das zu verstehen, kann man das am besten mit Data Abstraction vergleichen. Also bei Control Abstraction und Data Abstraction geht es darum, etwas das in einer Programmiersprache beschrieben wird oder etwas das in einem Programm passiert zu abstrahieren. Und zwar sind es zwei verschiedene Dinge, also bei Control Abstraction geht es darum eine bestimmte Operation, also eine Sequenz von Verhalten zu abstrahieren. Und zum Beispiel verwendet man dafür Subroutine oder Funktionen oder Exception-Händler oder Events und wir werden all diese Konstrukte hier in der Vorlesung kennenlernen. Im Gegensatz dazu geht es bei Data Abstraction darum zu abstrahieren, wie bestimmte Informationen repräsentiert wird. Also da geht es um Dinge wie Datentypen oder Klassen und Sachen, die wir ja schon in vergangenen Vorlesungen gesehen haben. Was wir hier jetzt machen werden, ist also uns um mit Control Abstraction näher zu beschäftigen und zwar in dieser und der nächsten Vorlesung beziehungsweise in den fünf Vorlesungsteilen, die jetzt kommen. So hier ist erstmal ein kleiner Überblick, was wir in den fünf Teilen machen wollen. Also jetzt in dem ersten Teil diesem Video hier geht es um Calling Sequences, also der Frage, was passiert überhaupt, wenn eine Funktion oder Subroutine aufgerufen wird und was genau passiert da in der Implementierung der Programmiersprache. Anschließend wird es dann zweiten Teil um Parameter Passing gehen, also der Frage, wie genau Parameter überhaupt übergeben werden und welche Informationen von der aufgerufenen Funktion oder Subroutine auch wieder zurückgegeben werden. Anschließend geht es im dritten Teil um Exceptions, wie man die werfen kann, wann die überhaupt geworfen werden und wie die auch behandelt werden und wir werden sehen, wie verschiedene Programmiersprachen das Ganze lösen. Dann im vierten Teil geht es um Koroutine, das ist ein Konstrukt, was der eine oder andere vielleicht schon mal gesehen hat, aber vermutlich nicht jeder, das ist sowas ähnliches wie Parallelität und wir werden sehen, wie genau das funktioniert. Und schlussendlich geht es im fünften Teil dann um Events, was auch ein ganz interessantes Konstrukt ist, denn es existiert sowohl auf Betriebssystemebene als auch in Programmiersprachen und wir werden uns das dann im fünften Teil genauer anschauen. Hier in dem ersten Teil soll es nun also um Calling Sequences gehen und bevor wir damit wirklich einsteigen, vielleicht erstmal ein kleines bisschen Terminologie. Also ein Begriff, den ich hier häufig verwenden werde, ist der eine Subroutine und was das im Prinzip ist, ist einfach nur ein Mechanismus oder vielleicht sogar der Mechanismus für Control Abstraction. Es gibt zwei Arten von Subroutinen, nämlich zum einen Funktionen und zum anderen Prozeduren. Diese drei Begriffe werden manchmal auch ein bisschen miteinander vermengt, aber die genaue Bedeutung ist eigentlich, dass eine Funktion eine Subroutine ist, die einen Wert zurückgibt, also die einen Return Value hat, wohingegen eine Prozedur eine Subroutine ist, die eben keinen Wert zurückgibt, sondern nur etwas macht, aber schlussendlich keinen Wert zurückgibt. Diese Subroutinen nehmen in der Regel Parameter entgegen und da unterscheidet man zwischen zwei Begriffen, nämlich zum einen die sogenannten Actual Parameters oder oft auch Argumente genannt und das sind die Daten, die der aufrufende Code in die Subroutine reingibt. Also wenn ich jetzt zum Beispiel eine Subroutine oder eine Funktion aufrufe und da etwas übergebe, dann sind das die Argumente oder die sogenannten Actual Parameters. Und im Gegensatz dazu sind die Formel Parameters, das ist nämlich genau das Gegenstück, das ist das, was man dann in der Subroutine selbst sieht, also das sind die Daten, die von dem aufgerufenen Code dann entgegengenommen werden. Also das Argument wird übergeben und erscheint dann als Formel Parameter in der Subroutine. Wenn so eine Subroutine aufgerufen wird, passieren eine ganze Reihe von Dingen und diese Dinge, die da passieren, die nennt man Calling Sequences. Also Calling Sequences sind im Prinzip all der Code, der ausgeführt wird, um den Callstack in der richtigen Form zu behalten und um sicherzustellen, dass die Argumente schlussendlich als Formel Parameter ankommen und alles, was zurückgegeben werden soll, dann auch wieder zum aufrufenden Code zurückkommt. Das, was da passiert, kann man im Prinzip in vier Phasen aufteilen und das sind diese vier Sachen, die wir hier sehen. Also zum ersten gibt es bestimmte Code, die ausgeführt wird, bevor die Subroutine überhaupt aufgerufen wird und zwar noch auf der Seite des aufrufenden Codes. Anschließend gibt es etwas, was passiert innerhalb der aufgerufenen Subroutine und zwar bevor der eigentliche Code dieser Subroutine ausgeführt wird und das nennt man den Prolog. Anschließend führt die Subroutine das aus, was sie eben macht, also sozusagen der Body der Funktion oder der Prozedur und am Ende bevor die Subroutine dann wirklich zurückkehrt, passiert auch wieder was und das ist der sogenannte Epilogue. Und dann schlussendlich, wenn der Kontrollfluss wieder zurückgekehrt ist zum aufrufenden Code, muss auch noch ein bisschen was passieren und das räumt sozusagen den Callstack wieder auf und bringt ihn dann in den Zustand, in dem er war, bevor wir die Subroutine aufgerufen haben. Jetzt könnte man fragen, ja, warum ist das überhaupt wichtig? Weil ich kann ja einfach die Funktion aufrufen und das hat bis jetzt auch alles funktioniert, ohne dass ich vielleicht wusste, was da eigentlich passiert. Aber es ist ganz gut zu wissen, was auf dem Level der Implimierung der Programmiersprache da tatsächlich vor sich geht und zwar aus einer ganzen Reihe von Gründen. Das eine ist Performance. Also wenn ich weiß, was eigentlich alles passiert, wenn ich eine Funktion aufrufe, dann überlege ich mir vielleicht manchmal auch, ob es dann wirklich nötig ist, eine Funktion aufzurufen oder wie ich vielleicht dafür sorgen kann, dass in der eigentlichen Ausführung des Programms das, was ich als Funktion beschreibe, dann doch nicht immer diesen ganzen Code, den wir jetzt gleich sehen werden, ausführt. Eine andere Punkt ist die Sicherheit des Programms. Also es gibt eine ganze Reihe von Angriffen, die ausnutzt, wie genau Function Stacks im Speicher repräsentiert werden und was alles passiert, wenn ich eine Funktion aufrufe und wenn die dann wieder zurückkehrt. Und wir werden eine dieser möglichen Angriffe, die es da gibt, nämlich Stack Smashing Attacks, ein bisschen genauer anschauen und um die zu verstehen, muss man natürlich erstmal verstehen, was überhaupt passiert, wenn so eine Funktion aufgerufen wird. Und schlussendlich ist alles natürlich interessant auch für Compiler. Also wenn jemand mal ein Compiler selbst implementiert oder in irgendeiner Form daran beteiligt ist, dann muss man verstehen, was passiert, wenn diese Subproutine aufgerufen werden. Und selbst wenn man das vielleicht nie selbst macht, ist es interessant das zu verstehen, denn man benutzt der Compiler ständig und will natürlich auch ein bisschen wissen, was die eigentlich genau machen. Wenn Funktionen ausgeführt werden, dann speichern die ja ihre lokalen Informationen auf dem Function Stack und in diesem Function Stack gibt es für jede Funktion einen sogenannten Stack Frame oder auch Activation Record. Das haben wir in einer der vorigen Vorlesungen ja schon gesehen, also das ist im Prinzip hier erstmal nur nochmal eine kurze Wiederholung. Also wir haben diesen Stack und auf dem Stack gibt es für jede ausgeführte Funktion, die aktuell gerade ausgeführt wird oder vielleicht auf dem Stack ist, weil sie eine andere Funktion aufgerufen hat, jeweils so ein Stack Frame. Innerhalb dieses Stacks gibt es dann zwei wichtige Pointer, und zwar zum einen den sogenannten Frame Pointer und zum anderen den sogenannten Stack Pointer. Was der Frame Pointer macht, ist, dass der immer auf den Anfang des aktuellen Stack Frames zeigt. Also der aktuelle Stack Frame ist der, der zu der Funktion gehört, in der ich mich gerade befinde und die Adresse wo dieser Stack Frame losgeht, das ist wo der Frame Pointer hin zeigt. Also der sagt mir sozusagen, wo geht das Stück Speicher los, das für die aktuell ausgeführte Funktion relevant ist. Der andere wichtige Pointer ist der sogenannte Stack Pointer. Und was der macht, ist, der zeigt nicht auf den Anfang des aktuellen Stack Frames, sondern irgendwo mitten rein, bzw. genauer gesagt dahin, wo das nächste Stück freier Speicher ist. Also das zeigt auf eine Adresse, wo wenn ich jetzt neuen Speicher innerhalb der aktuellen Funktion brauche, zum Beispiel weil ich da Speicher alleziere, der dann in der lokalen Variable gespeichert wird, dann wird das dahingeschrieben, wo der Stack Pointer aktuell hin zeigt. Und das ist immer die Stelle, wo als nächstes wieder Platz ist. Es gibt manchmal auch eine andere Konvention, wo der Stack Pointer auf den letzten verwendeten Stück Speicher zeigt. Also auf die Adresse, wo ich gerade eben nicht mehr hinschreiben soll. Und in dem Fall würde mir dann einfach diese Adresse noch plus eins rechnen und käme dann bei der Adresse raus, wo Platz ist für das nächste, was gespeichert werden soll. Schauen wir uns zur Vedeutigung mal ein Beispiel dafür an, wie dieser Stack Frame oder diese Function Stack aussieht. Also was wir hier sehen werden, ist dann das sogenannte Function Stack Layout. Es gibt auf diesem Function Stack wie gesagt für jede ausgeführte Funktion einen Stack Frame, also ein Stück Speicher, was für diese eine Funktion ausführung verantwortlich ist. Und wenn wir jetzt zum Beispiel die Situation haben, wo eine Funktion eine andere aufruft, dann haben wir also irgendwo den Frame für die aufrufende Funktion, sag mal mal das ist das hier unten, also das ist sozusagen der Stack Frame, der gerade eben aktiv war, jetzt aber eine andere Funktion aufgerufen hat. Und wenn wir davon ausgehen, dass unser Stack in diese Richtung wächst, also nach oben, dann bedeutet das, wenn wir eine andere Funktion aufrufen wird, also sozusagen hier oben drauf dann ein weiteres Stack Frame gepackt, der dann für die jetzt aktuelle Funktion verantwortlich ist. In den meisten Fällen ist diese Richtung in die das ganze wächst Richtung kleinere Adressen, also quasi umgedreht, als wie man das vielleicht erwarten würde. Im Prinzip ist das einfach nur eine Konvention und ist einfach wichtig zu wissen, in welche Richtung das üblicherweise wächst, nämlich zu den kleineren Adressen hin. So, wenn das jetzt der aktuelle Frame ist hier oben, dann haben wir am Anfang dieses aktuelle Frames den Frame Pointer, genauer gesagt, wie wir gleich sehen werden, ist hier noch ein bisschen was dazwischen und der Anfang ist sozusagen ein bisschen weiter hochgeschoben. Und dann haben wir in diesem Stack die ganzen Variablen, die es innerhalb dieser Funktionen gibt und auch noch ein paar andere Sachen. Und da, wo wir jetzt die nächste Variable, die wir vielleicht bräuchten oder einen dynamisch allizierten Speicher hinpacken würden, da haben wir dann diesen zweiten Pointer, nämlich den Stack Pointer. So, jetzt schauen wir uns mal ein bisschen genauer an, was denn eigentlich passiert, wenn eine Funktion eine andere aufruft und die aufgerufene Funktion dann irgendwann zurückkehrt. Also das sind im Prinzip fünf Aufgaben, die da erledigt werden müssen und die sind hier mal aufgelistet. Also das erste, vielleicht offensichtlichste, ist, dass man die Parameter natürlich übergeben muss an die aufgerufene Funktion und dass, wenn das eine Funktion ist und nicht nur eine Prozedur, der Rückgabewert dann schlussendlich auch zurückgegeben werden muss. Die zweite Aufgabe ist, den sogenannten Program Counter zu aktualisieren. Ich nehme an, Sie haben das in irgendeiner Veranstaltung schon mal gehört, aber kurz zur Erinnerung, der Program Counter ist einfach nur eine Adresse, die angibt, welchen Code wir gerade ausführen. Und wenn ich jetzt in eine andere Funktion reinspringe, muss ich natürlich dann auch den Code dieser Funktion anschließend ausführen. Und das mache ich, indem ich den Program Counter entsprechend auf den Anfang dieser aufgerufenen Funktion setze. Der dritte Punkt ist, dass ich mir merken muss, wo es danach weitergehen soll. Also dass ich sozusagen die Adresse, wo der Code wieder hin zurückspringen soll, wenn die aufgerufene Funktion dann irgendwann zu Ende ist. Die muss ich speichern, denn ansonsten wüsste ich ja nicht, wo es anschließend weitergeht. Ansonsten führe ich meine Funktion aus, komme dann irgendwann zu dem Stück Code, das dem Return Statement zum Beispiel entspricht und weiß dann nicht, wo es weitergeht. Also irgendwo muss ich mir die Return Adresse speichern, so dass das Programm dann weiß, wo es anschließend weitergeht. Der vierte Punkt ist, dass ich alle Register speichern und schlussendlich auch wieder herstellen muss. Kleine Erinnerung hier, also die Register ist einfach nur relativ kleine Speichereinheiten, die es auf so eine CPU gibt, die sehr, sehr schnell sind, weil sie eben direkt auf der CPU sind, noch schneller als der RAM zum Beispiel und wo bestimmte Sachen natürlich dann immer drin gespeichert werden, weil dieser Speicher eben so nah dran ist und deswegen so schnell ist. Und das Problem ist jetzt aber, dass das natürlich sehr eingeschränkt ist, also ich habe nur wenige Register und wenn ich eine neue Funktion aufrufe, dann möchte die bestimmte Register natürlich selbst verwenden. Das heißt, die Aufrufende Funktion muss ihre Registerwerte erstmal irgendwo abspeichern und wenn die aufgerufene Funktion dann zurückkehrt, diese Registerwerte wieder herstellen, sodass die Funktion, die aufgerufen hat, wieder in dem Urzustand ist. Und dann der fünfte Punkt hier ist, dass ich natürlich diese beiden Pointer, den Stack Pointer und den Frame Pointer, aktualisieren muss, denn sonst weiß ich natürlich nicht, wo ich in meinem Stack Frame überhaupt bin. Die interessante Frage ist jetzt, wo macht man das alles? Denn es gibt im Prinzip zwei Optionen. Das kann entweder alles in dem Caller, also in der aufrufenden Funktion geschehen oder das könnte auch alles im Callee, also der aufgerufenden Funktion passieren. Wie man das jetzt genau macht, hängt ein bisschen auf dem Compiler ab, aber in den meisten Fällen will man eigentlich so viel wie es geht in den Callee reinschieben und zwar aus dem ganz einfachen Grund, dass man dann den Code, der für diese ganzen Aufgaben verantwortlich ist, eben nur einmal in jeder Subroutine drin haben muss und dann wird automatisch, wenn dieser aufgerufen wird, dieser Code ausgeführt. Wenn man das Ganze im Caller machen würde, müsste man an jedem Funktionsaufruf einzeln diesen Code einfügen und das würde einfach das Programm größer machen, als es vielleicht sein muss. Also wir hätten quasi größere Binarys, die dann irgendwo auf der Festfestbatterie liegen und natürlich auch mal in den Speicher geladen werden müssen. So, jetzt haben wir gesehen, was da alles passieren muss und was wir jetzt machen werden, ist uns mal eine typische Calling Sequence anzuschauen, also eine typische Implementierung, wie diese Aufgaben jetzt umgesetzt werden können. Das wird vier Teile haben, und zwar diese vier möglichen Stellen, wo was passieren kann, nämlich einmal im Caller, bevor der eigentliche Call passiert, dann im Callee, also der aufgerufenen Funktion ganz am Anfang, dann am Ende der aufgerufenen Funktion und schlussendlich zurück im Caller, nachdem der Aufruf geschehen ist. Und wir werden jetzt für jede dieser vier Phasen anschauen, was genau da eigentlich passiert und das dann auch an einem kleinen Beispiel mal anschauen. Also bevor der Caller den eigentlichen Aufruf tätigt, macht er in der Regel drei Sachen. Das eine ist, dass die Register, deren Werte man später noch braucht, gespeichert werden, so dass die aufgerufene Funktion diese Register selbst verwenden kann, so wie sie das möchte und wir trotzdem die Werte, die in den Registern waren, dann in der aufrufenden Funktion weiterhin verwenden können, wenn der Funktionsaufruf dann irgendwann zurückgekehrt ist. Das zweite ist, dass der aufrufende Code natürlich die Werte für die Argumente aufrufen muss, also was auch immer ich an die aufrufende Funktion übergeben möchte, erst mal ausgerechnet und evaluiert werden und anschließend müssen diese Argumente natürlich in irgendeiner Form übergeben werden und das kann entweder über den Stack geschehen, indem ich die eine bestimmte Stelle im Stack schreibe, wir werden gleich sehen, wo, oder dass ich diese Werte einfach auch in Register schreibe, was natürlich die preferierte Option ist, weil die Register natürlich nah dran sind und dass deswegen schnell geht. Und das dritte, was man machen muss bevor der Funktionsaufruf geschieht, ist, dass die Return-Adresse, also die Adresse, wo es dann in der aufrufenden Funktion anschließend weitergehen soll, übergebe und an eine bestimmte Stelle im Stack schreibe und dann um den eigentlichen Aufruf auszuführen, in die aufgerufene Subroutine oder Funktion reinspringen. Schauen wir uns das Ganze mal wieder ein bisschen bildlich hier am Beispiel an. Also was ich jetzt hier erstmal aufmahle, ist der Stack vor dem eigentlichen Funktionsaufruf. Und zwar haben wir da wieder diese zwei hier relevanten Stackframes einmal, der Stackframe von der aufrufenden Funktion, also das ist der, der den Aufrufen macht und dann wird der nächste Stackframe hier erstellt für die Funktion, die aufgerufen wird. Noch einmal zur Erinnerung, wir haben diesen 1-Pointer, der uns anzeigt, wo der letzte oder der nächste freie Code innerhalb des Stackframes ist, nämlich den StackPointer und für den aufrufenden Code wäre der jetzt hier oben. Also das sieht so aus, als ist der quasi schon den nächsten Stackframe, und zwar aus dem einfachen Grund, dass der nächste Stackframe am Anfang gerne natürlich noch nicht existiert, sondern, dass einfach nur die nächste freie Adresse ist, wo wir Dinge hinschreiben könnten. Um die Argumente zu übergeben, werden die jetzt eine bestimmte Stelle im Stack geschrieben, und zwar üblicherweise ganz ans Ende des aufrufenden Stackframes. Also wir würden sozusagen, wenn es jetzt mal zwei Argumente gibt, das erste hier hinschreiben und das zweite dann hier, und falls es noch mehr gibt, natürlich auch noch mehr. Und die zweite Sache, die wir hier übergeben müssen, ist die Return Adress, also die Adresse, wo es nachdem der Collie aufgerufen wurde, weitergehen soll, und das wird üblicherweise ganz an den Anfang des Stackframes vom Collie geschrieben, und zwar einfach in der Form, dass die Adresse da in den Speicher geschrieben wird. So, das ist also der Zustand, bevor der eigentlicher Aufruf passiert, und was, wenn der Stack jetzt in diesem Zustand ist, dann passiert, ist, dass wir anschließend zu der Adresse springen, wo der Collie dann losgeht. Wenn wir dann in der aufgerufenen Funktion drin sind, beginnt die zweite Phase, nämlich der sogenannte Prolog. Was jetzt hier passiert, sind auch wieder drei Sachen. Das erste ist, dass die aufgerufene Funktion ihren eigenen Stackframe alluziiert, also sozusagen festlegt, wieviel Speicher sie da auf jeden Fall erstmal braucht, und das passiert im Prinzip einfach dadurch, dass vom aktuellen Stack-Pointer einen bestimmter Betrag abgezogen wird, weil wir gehen ja, wir wachsen ja in Richtung der kleineren Adresse, deswegen abgezogen, und ich sozusagen festlege, von hier bis hier wird jetzt mindestens erstmal der Stackframe der aufgerufenen Funktion sein. Das zweite, was dann passiert, ist, dass wir den alten Frame-Pointer, also die Adresse, wo der Stackframe des aufrufenden, der aufrufenden Funktion losgegangen ist, dass wir diesen Frame-Pointer speichern, und den Frame-Pointer dann auf den Beginn des jetzt aktuellen Stackframes setzen. Der Grund, warum wir den alten Frame-Pointer speichern, ist ganz einfach, dass wir, wenn wir von den Funktionen zurückkehren, natürlich wieder für die aufrufende Funktion wissen wollen, wo deren Stackframe eigentlich losgeht, und deswegen speichern wir diesen Anfang, also den alten Frame-Pointer. Und das Dritte, was dann passieren muss, ist, dass wir die Register, die die aufrufende Funktion vielleicht benutzt hat, und die wir aber überschreiben könnten, oder vielleicht auch überschreiben werden, in der aktuellen Funktion, diese Registerwerte müssen wir speichern, sodass wir sie am Ende dann wieder herstellen können, bevor wir zurückkehren zum aufrufenden Code. Schauen wir uns das Ganze mal wieder an dem Beispiel hier an, und zwar mal ich jetzt mal den Stack auf, nachdem dieser Prolog ausgeführt wurde, also nachdem sozusagen die aufrufende Funktion alles gemacht hat, was sie macht, bevor der eigentliche Code der Funktion losgeht. Also wir haben da wieder unsere zwei Stackframes, nämlich den der aufrufenden Funktion und dann den der aufgerufenen Funktion. Und nur noch mal zur Erinnerung, also die Richtung der unser Stack wächst, ist weiterhin nach oben. Allerdings sind die höheren Adressen weiter unten, also wir wachsen quasi in Richtung kleinere Adressen. So, jetzt hat mir gesagt, dass hier noch in dem Frame der aufrufende Funktion die Argumenten liegen, das hat man schon gesehen, dann hat mir gesehen, dass hier ja die Return Address steht, also die Adresse, wo dann der Code der aufrufenden Funktion nach dem Aufruf weitergeht. Und nachdem wir jetzt den Prolog ausgeführt haben, zeigt der Frame Pointer nun hier auf den Anfang des aktuellen Frames, also das ist jetzt unser neuer Frame Pointer. Und wir müssen ja wie gesagt auch den alten Frame Pointer, also sozusagen das, wo der Calling Frame losgeht, speichern. Und das macht man üblicherweise dann gleich am Anfang des aktuellen Frames. Eine andere Aufgabe, die im Prolog geschehen musste, ist die Register Werte zu speichern und die kommen dann zum Beispiel hierhin. Je nachdem, wie viele das sind, nimmt das mehr oder weniger Platz weg. Und dann haben wir vielleicht noch ein bisschen mehr Platz alleziiert, aber in der Regel sind wir erstmal nur hier und haben dann hier unseren und dann Stack Pointer, der uns sagt, dass hier sozusagen die erste freie Adresse in dem aktuellen Frame ist. Also wenn ich jetzt neue Variablen noch irgendwo hinlegen möchte, dann würde ich das eben genauer machen, wo der Stack Pointer hin zeigt. So, jetzt haben wir die ersten zwei Phasen gesehen, sind also an der Stelle, wo die aufgerufende Funktion und ihren eigentlichen Code beginnen kann und um erstmal zu überprüfen, ob das jetzt soweit verständlich war und ein bisschen was hängen geblieben ist, machen wir mal ein kleines Quiz. Wir wissen im Quiz um die Frage, wie die aufgerufende Funktion jetzt eigentlich an die Argumente rankommt. Und zwar gehen wir hier davon aus, dass der Frame Pointer, der Wert des Frame Pointers in einem bestimmten Register gespeichert ist, nämlich dem Register EBP und das Adressen jeweils vier bytes lang sind und zur Vereinfachung, dass alle übergebenen Argumente einfach 32-bit Integers sind. Und die Frage ist jetzt, was ist die Adresse, die der Callee, also die aufgerufende Funktion nutzen wird, um das zweite Argument, das übergeben wurde, zu erreichen, also um auf das zweite Argument zuzugreifen. Und ich würde Sie bitte an der Stelle das Video einfach mal wieder kurz anzuhalten, drüber nachzudenken, dann in Ilias abzustimmen und erst dann weiter zu schauen, was die Lösung nimmst. Gut, dann schauen wir uns die Lösung mal an. Also die richtige Antwort ist, dass wir dieses Register EBP plus 12 bytes nehmen müssen und das ergibt dann die Adresse, wo der Callee auf das zweite Argument zugreifen kann. Um zu sehen, warum das so ist, schauen wir uns gerade nochmal den Stack nach dem Prolog an, so wie wir ihn gesehen haben und ich kopiere einfach mal diese Seite und mal da noch ein bisschen drin rum. Also was ich jetzt zeigen will, ist, wie wir genau auf dieses EBP plus 12 kommen. Also wie gesagt, der Frame Pointer, den Wert davon speichern wir ja an EBP, also sozusagen das, was hierhin zeigt, die Adresse, die ich jetzt gerade umkringelt habe, das ist in EBP gespeichert und was wir bekommen wollen, ist dieses zweite Argument, was der die aufrufende Funktion an den Callee übergeben hat. Und da jede Adresse bei uns 4 bytes groß ist und jedes dieser Argumente auch 4 bytes groß ist, muss ich also sozusagen von diesem Frame Pointer runter zu dem zweiten Argument gehen, runter heißt aber, dass wir hier in Richtung höherer Adressen gehen. Das heißt, um hierhin zu kommen, müsste ich sozusagen EBP plus 4 rechnen, weil das genau eine Adresse weiter ist und dann um hier zu diesem Argument zu kommen, EBP plus 8 und schlussendlich um hierhin zu kommen, wo ich hinkommen will, nämlich zu dem zweiten Argument, müsste ich EBP plus 12 bytes rechnen und das wäre dann die Adresse, wo der Callee das zweite Argument findet. So, wenn der Callee jetzt all seinen Code ausgeführt hat, kommt er irgendwann an die Stelle, wo die Funktion wieder zurückkehren soll zur aufrufenden Funktion und da sind auch wieder ein paar Sachen zu machen und diese dritte Phase ist dann der sogenannte Epilogue. Hier passieren vier Sachen und zwar Folgendes. Das erste ist, dass der Return Value, sofern wir denn ein Wert zurückgeben, wollen entweder über ein Register oder über eine bestimmte Stelle auf dem Stack zurückgegeben werden muss. Also, die Funktion speichert den Return Value entweder ins Register, in eines Register oder an diese bestimmte Stelle, sodass der aufrufende Code anschließend den Return Value da dann finden kann. Das zweite, was wir machen müssen, ist, dass wir die Register, die wir am Anfang im Epilogue gespeichert haben oder deren Wert wir am Anfang gespeichert haben, dass diese Register jetzt wiederhergestellt werden müssen. Wir müssen also das, was wir da irgendwo gespeichert haben auf dem Stack jetzt wieder in die Register schreiben, sodass es dann für die aufrufende Funktion wieder so aussieht, wie es vorher aussah und die Werte einfach wieder in den Registern drin sind und die aufrufende Funktion dann einfach weiterrechnen kann, wo sie eben vor dem Aufruf auch war. Das dritte ist, dass wir unsere beiden Pointer wieder entsprechend aktualisieren müssen, also der Frame Pointer und der Stack Pointer müssen aktualisiert werden, sodass der Frame Pointer wieder auf den Anfang des Stack Frames der aufrufenden Funktion zeigt und der Stack Pointer auf das Ende dieses Stack Frames. Und wenn das dann alles passiert ist, dann kann der Code schlussendlich zur Return Address zurückspringen, also wieder dahin springen, wo wir in dem aufrufenden Code waren, sodass es dann nach dem Funktionsaufruf dort weitergehen kann. Schauen wir uns das Ganze mal wieder an unserem Beispiel an. Also was ich jetzt hier zeigen will, ist der Zustand vom Stack, nachdem der Epilogue ausgeführt wurde. Und ich benutze einfach mal den Stack, so wie er am Beginn der aufgerufenen Funktion aussah, so als Ausgangspunkt. Also während unsere Funktion jetzt ausgeführt wird, wären hier vielleicht irgendwo mal noch so ein paar lokale Variablen reingeschrieben und vielleicht ruft diese Funktion ja auch noch weitere Funktionen auf, in dem Fall würden dann hier auch wieder die entsprechenden Argumente übergeben und dann würde vielleicht sogar noch ein weiterer Stack Frame erstellt und irgendwann erreichen wir dann den Punkt, wo die Funktion zurückkehren soll und dann ist all das nicht mehr wichtig, sondern was wir jetzt machen ist vor allem die Stack Pointer und Frame Pointer und so weiter wieder in den richtigen Zustand zurück zu versetzen, indem wir sie eben nicht mehr hier haben, sondern wieder auf die Stellen setzen, wie sie vor dem Aufruf waren. Das heißt der Frame Pointer, also der Anfang des Frames der aufgerufenen Funktion ist irgendwo hier unten und wir wissen das, weil wir eben diesen alten Frame Pointer hier oben drin gespeichert hatten und der Stack Pointer, den müssen wir nicht speichern, sondern wir wissen, dass der einfach da ist, wo wir dann auch die Return Address haben, nämlich am Beginn des Stack Frames der aufgerufenen Funktion, also der neue bzw. auch wieder alte Stack Pointer zeigt dann sozusagen hierhin und das ist jetzt der Zustand des Stacks nachdem der Epilogue ausgeführt wurde und jetzt sind wir sozusagen in dem Zustand, wo der, die Ausführung tatsächlich zu dem Code-Stück wieder zurückspringen kann, wo der Funktionsaufruf im Calling in der aufgerufenen Funktion stattgefunden hat. So, und jetzt sind wir damit also in der vierten Phase, nämlich dem, was dann in der aufgerufenen Funktion passiert, nachdem der Funktionsaufruf fertig ist und zwar machen wir hier noch 2 Sachen. Zum einen nehmen wir diesen Return Value, den wir jetzt von der aufgerufenen Funktion bekommen haben und speichern ihn einfach dahin, wo wir ihn brauchen, also wenn der in dem Register ist, kann man vielleicht auch gleich da lassen und wenn nicht, wird er zum Beispiel in eine lokale Variable geschrieben und dann da einfach reingespeichert. Und die zweite Sache ist, dass es bestimmte Register gibt, die der aufrufende Code vor dem Aufruf selbst gespeichert hat und die müssen jetzt auch wieder zurückgesetzt werden auf den alten Wert, so dass wir dann in dem aufrufenden Code so weitermachen können, wie vor dem Aufruf. Jetzt habe ich schon viele über diese Register geredet und werde vielleicht einfach noch ein bisschen genauer erklären, was es damit so auf sich hat. Also das hängt je nachdem, es hängt von der Computerarchitektur, aber der das Programme schlussendlich ausgeführt wird, aber zum Beispiel für X86 gibt es 8 sogenannte General Purpose Registers, also es sind 8 Register, die der Kote Prinzip für alles Mögliche benutzen kann und die Frage ist jetzt, welche von diesen Registern müssen denn überhaupt bei jedem Funktionsaufruf gespeichert und dann wiederhergestellt werden, weil das kostet natürlich auch jedes mal Zeit das zu machen. Idealerweise würde man ein Register genau dann speichern, wenn der Caller, also die aufrufende Funktion dieses Register nach dem Aufruf auch wieder braucht, weil wenn das Register nach dem Aufruf eh nicht mehr gebraucht wird, dann müssen wir das natürlich auch nicht speichern und wenn der Callee, also die aufgerufende Funktion dieses Register selbst für irgendeinen anderen Zweck verwenden möchte. Denn nur wenn das der Fall ist, müssen wir den Wert des Register irgendwo anders hin speichern, so dass der Callee das Register dann selber benutzen kann. In der Praxis funktioniert es aber eher so, dass der Caller das nicht genau macht, wenn diese beiden Bedingungen gelten, sondern das überapproximiert, also sozusagen macht, wenn diese Bedingungen vielleicht gelten und der Caller weiß das manchmal nicht genau, weil zum Beispiel nicht klar ist, welcher Branch in dem aufgerufenen Code denn vielleicht genommen wird. Und der Gedanke, warum das einfach überapproximiert wird, ist, dass es natürlich besser ist, diese Register einmal zu viel zu speichern als einmal zu wenig, weil sonst sind die Daten dann ja weg. Die zweite Frage neben der Frage, welche Register überhaupt gespeichert werden müssen, ist wer das jetzt macht. Also es gibt im Prinzip zwei Optionen. Das eine ist, dass alle Register vom Caller gespeichert und dann auch wiederhergestellt werden. Also die aufrufende Funktionen würde sozusagen alle acht Register jedes Mal speichern und anschließend wiederherstellen. Die andere extreme Variante wäre, dass das alles der aufrufende Code macht und in der Praxis wie so oft gibt es im Prinzip einen Mittelweg und zwar gibt es da je nach Architektur gewisse Konvention die festlegen, welche Register denn vom Caller oder vom Calli gespeichert und wiederhergestellt werden sollen. Also zum Beispiel für X86 ist die Konvention, dass drei dieser acht Register Caller saved sind. Das heißt die aufrufende Funktion ist dafür verantwortlich dass die Werte in den Register abgespeichert werden vor dem Aufruf und anschließend wiederhergestellt werden. Und der Rest ist Calli saved. Das heißt hier ist dann die aufrufende Funktion verantwortlich das zu machen. Diese Konventionen sind wichtig aus dem einfachen Grund, dass jeder der Code für X86 kompiliert sich daran halten muss. Denn ansonsten kann es passieren, dass sich vielleicht Code, der von verschiedenen Compilern kompiliert wurde, miteinander verlinke und der eine Code dann denkt, dass das Register ja vielleicht schon abgespeichert ist, der andere aber gedacht hat auch, das macht der Calli und schlussendlich keiner den Wert in dem Register gespeichert hat und der entsprechende Wert dann weg wäre. So, jetzt haben Sie gesehen, was alles passieren muss, um so einen Funktionsaufruf tatsächlich durchzuführen und wie man ja gesehen hat, sind das doch eine ganze Menge Sachen und die Frage ist natürlich muss das dann überhaupt jedes Mal passieren, weil diese Calling Sequences schlussendlich ja doch relativ teuer sind, ganz einfach, weil da so viel passiert. Und eine Optimierung, die häufig angewandt wird, um das eben nicht jedes Mal machen zu müssen, ist das sogenannte Inlining. Was im Prinzip einfach nur bedeutet, dass eine Kopie des aufgerufenen Codes in den aufrufenden Code eingefügt wird. Das heißt, anstatt tatsächlich diese Funktion aufzurufen und diese ganze Calling Sequence auszuführen wird dieser Code sozusagen Teil des aufrufenden Codes, weil ich den wirklich reinkopiere, so dass ich eben mit den Aufwand dieser Calling Sequence sparen kann. Ein Nachteil davon ist, dass es natürlich die Größe des Binarys, also des Codes erhöht, weil ich ganz einfach immer diese Kopien darin hab. Also im extremen Fall könnte ich einfach jede Funktion immer inline und alles reinkopieren. Aber das würde natürlich ein riesengroßes Binary schlussendlich ergeben. Und was deswegen üblicherweise gemacht wird, ist, dass eigentlich nur sehr kleine Funktionen geinlined werden. Ein weiterer Vorteil von dem Inlining ist, dass es neben dem Span der Calling Sequence auch noch andere Optimierung erlaubt, die normalerweise nicht gemacht würden. Also wenn der Compiler zum Beispiel bestimmte Arithmetische Ausdrücke schon weiß, wie die evaluiert werden und deswegen das schon zur Compile Time machen kann, dann macht er das üblicherweise nicht über die Grenzen von verschiedenen Funktionen hinweg. Aber wenn ich Inlining gemacht hab, dann wird der Code der Aufgerufen, wird ja plötzlich Teil des Codes der Aufrufenden Funktion und dann kann dieser gesamte Code gemeinsam optimiert werden und Optimierung wie Constant Propagation z.B. können dann da auch gleich mitgemacht werden. In der Regel entscheidet der Compiler selbst, welche Funktion geinlined werden, basierend auf diversen Heuristiken wie z.B. die Größe des Codes oder auch was für Instruktionen in dem Code überhaupt drin sind. In manchen Sprachen kann man aber auch sogenannte Inlining Hints übergeben, also sozusagen dem Compiler vorschlagen, dass doch eine bestimmte Funktion oder so Proteine geinlined werden sollte. Eine Sprache, wo das so gemacht werden kann, ist z.B. C, wo es dann eben dieses Inline Keyword gibt, was ich einfach vor eine Funktion schreibe und damit angebe, dass der Compiler noch diese Funktion vielleicht inline sollte. Und üblicherweise macht man das für so kleine Funktionen, wie z.B. diese hier, wo einfach nur ein klein bisschen Code ist und vielleicht der Aufwand der durch die Coding Sequences entsteht gar nicht jedes Mal nötig ist, sondern dieses Stück Code einfach direkt in den Code reinkopiert werden könnte. Also ich bekomme damit sozusagen den Vorteil von Funktion, nämlich dass ich das aus Programmierersicht schön abstrahiere und eben das, was in dieser Funktion passiert, in einem separaten Stück Code habe, dem Stück Code einen Namen gebe und das dann einfach aufrufen kann, aber gleichzeitig bezahle ich nicht den Overhead, der durch die Coding Sequences entsteht. In der Praxis muss der Compiler diese Hints natürlich nicht beachten und je nach Compiler werden die dann verwendet oder nicht. Also es gibt Compiler, die die im Prinzip mehr oder weniger komplett ignorieren und einfach ihre eigene Höristik verwenden und andere benutzen diese Hints dann und inline wenn man das hinschreibt. So, vorhin hätte ich gesagt, ein Grund warum es wichtig ist überhaupt zu verstehen wie dieses ganze Stack Layout aussieht und was die Coding Sequences machen ist Sicherheit, nämlich dass es bestimmte Angriffe gibt auf Software, die genau dieses Stack Layout und Eigenschaften ausnutzen. Und einer dieser Angriffe ist das sogenannte Stack Smashing, was wir uns jetzt hier aber nochmal kurz anschauen wollen, weil es einfach eine ganz nette Anwendung von dem ist, was wir gerade gesehen haben. Stack Smashing ist eine bestimmte Art von Buffer Overflow Vulnerability oder Schwachstelle bei der ausgenutzt wird, dass an einer bestimmten Stelle etwas geschrieben wird, aber ohne zu schauen, wie groß das was geschrieben wird, denn eigentlich ist also irgendwo, wird sozusagen kein Bounce Checking betrieben, wo ich vielleicht erst schauen sollte, wie groß etwas ist bevor ich es an eine bestimmte Stelle schreibe und das erlaubt einem Angreifer unter Umständen an Stellen zu schreiben, wo man eigentlich nicht hinschreiben sollte. Also konkret gibt es da irgendeine lokale Variable, wo was reingeschrieben wird, aber das, was ich reinschreibe, ist dann größer als das, was da eigentlich reinpasst und damit überschreibe ich sozusagen andere Teile dieses Stacks. Eine Sache, die man damit überschreiben kann, ist die Return Address, also sozusagen den Punkt oder die Stelle im Code, wo wir hinspringen werden, wenn die aktuelle Funktion zurückkehrt und das ist natürlich eine gefährliche Sache, weil dann kann das Programm ja dazu gezwungen werden, an eine andere Stelle zurückzuspringen, als die Stelle, wo es eigentlich weitergehen sollte in der Ausführung und insbesondere kann ein Angreifer damit zum Beispiel zu maliziosem Code springen und das Programm macht dann schlussendlich etwas, was es normalerweise nicht machen würde. Als konkretes Beispiel haben wir hier mal ein Stück C Code, was eben leider unter so einer Stacksmashing Schwachstelle leidet und wo man dann Stacksmashing als Angreifer tatsächlich machen könnte. Also was der Code hier macht ist, dass hier ein File-Pointer übergeben bekommt beziehungsweise ein Stream, der aus einer Datei liest und versucht jetzt aus diesem bestimmte Daten auszulesen und zwar so lange bis wir das Ende der Zeile in der aktuellen Datei erreicht haben. So, das ist die Intention und das funktioniert so, dass wir hier so eine lokale Variable aditieren, wo 100 Characters reinpassen. Die Adresse wo dieses Array von den 100 Characters dann drin ist in die Variable P und dann anschließend hier in dieser Schleife ein Zeichen nach dem Nächsten aus dem Stream auslesen und jeweils ans Ende dieses Buffers schreiben. Und das passiert so lange bis wir das Ende der Zeile erreichen unter der hier implizierten Annahme, dass die Zeile natürlich nie länger als 100 Zeichen ist. Aber natürlich kann ein Angreifer hier vielleicht dafür sorgen, dass die Zeile doch länger als 100 Zeichen ist und dann überschreiben wir sozusagen mehr als den Speicher, den wir hier oben alloziiert haben und können unter Umständen auch die Return Address der aktuellen Funktion überschreiben. Zu verdeutlichen, wie das aussehen würde zeige ich das hier nochmal grafisch. Also das wird sozusagen einfach nur der Stack Smashing Angriff, den man mit diesem Code vielleicht machen könnte nochmal aufgemalt. Also wir haben da wieder unseren zwei Stack Frames, der Stack Frame der aufrufenden Funktion und der Stack Frame der aufgerufenen Funktion, die aufgerufenen Funktion, wer eben die, die man hier sieht, also dieses Read Number from File. Und wie gesagt gibt's da hier die Return Address, die da gespeichert wird, also die Adresse, wo wir hinspringen werden, wenn die Read Number from File Funktion zurückkehrt, dann sind hier so noch ein paar andere Sachen, die wir hier vorhin schon gesehen haben und irgendwo hier ist dann diese lokale Variable Buff alloziiert, die eben Platz für 100 Characters hat. So und wenn ich jetzt da was reinschreibe und aus dem Grund, dass die höheren Adressen ja nach, nach unten wachsen kann es eben passieren, dass ich nicht nur hier reinschreibe wie das gedacht ist, sondern dass ich über diese Grenze die ich ja eigentlich gelten sollte hinaus schreibe und dann insbesondere alles überschreibe, was hier ist und auch diese Return Address überschreibe, so dass der Code, wenn er dann tatsächlich das Return Statement erreicht, eben nicht dahin zurückspringt, wohin er normalerweise zurückspringen würde, sondern eben zu dieser neuen Return Address, die der Angreifer hier reingeschrieben hat. Also was ein Angreifer hier konkret machen würde, ist eine Datei konstruieren, die länger als 100, also die mehr als 100 Zeichen in der Zeile hat und das so konstruieren, dass dann an der richtigen Stelle, also irgendwo hinter dem 100. Zeichen, die Return Address drin ist, so dass die dann hier schlussendlich überschrieben wird und das Programm dann, wenn es ausgeführt wird, was anderes macht, als es eigentlich machen soll. Gut, und damit sind wir auch schon wieder am Ende dieses ersten Teils. Ich hoffe, Sie wissen jetzt ein bisschen genauer, was Calling Sequences eigentlich sind, also was passiert, wenn in so einem Programm eine Funktion aufgerufen wird und wenn die Funktion dann irgendwann zurückkehrt. Es ist etwas komplexer, als man das vielleicht aus Programmierersicht so üblicherweise sieht, weil da ruft man einfach die Funktion auf und irgendwann kommt die zurück, aber eigentlich passieren dann eine ganze Reihe von Sachen und die zu verstehen, ist sehr wichtig, um zum Beispiel so Dinge wie diese Stack Smashing-Angriffe verhindern zu können. Vielen Dank fürs Zuhören und bis zum nächsten Mal.