Erlernen der Assemblersprache
Die Assemblersprache ist das menschenlesbare Äquivalent zur untersten Softwareebene der Computerprogrammierung – dem Maschinencode.
Während der Computer alle Programme als Zahlen versteht, bei denen verschiedene Zahlen den Computer anweisen, verschiedene Operationen auszuführen, ist dies für den menschlichen Konsum (geschweige denn für das Schreiben) zu langweilig. Deshalb programmieren Menschen in Assemblersprache, die fast 1:1 mit dem Maschinencode übereinstimmt.
Lernen Sie die Programmiersprache C. Wenn Sie C (oder C++) kennen, wird Assembler einfacher sein.
C# und Java sind gute Sprachen, aber es gibt wenige bis gar keine Werkzeuge, die Ihnen zeigen, wie C#- und Java-Code in Assembler aussieht. (Tatsächlich haben Java und C# ihre eigene Bytecode-Assemblersprache, die sich ziemlich von den realen Hardware-CPU-Befehlssatzarchitekturen unterscheidet und weit davon entfernt ist.)
Anhand von C- (und C++-) Code können wir die Compiler verwenden, um uns zu zeigen, wie die Assemblersprache aussieht. C ist eine Low-Level-Sprache, und wenn Sie wissen, wie jedes Konstrukt in C in Assembler übersetzt wird, kennen Sie auch den Assembler! Beispiele für die C-Konstrukte, die wir für den Assembler kennen wollen, sind:
C Konstrukt | Assembler Äquivalent |
---|---|
lokale Variable | CPU Register oder Stack Speicherplatz |
globale Variable | Zugriff auf globalen oder const-Speicherplatz |
Array-Referenz | ein Muster aus Addition und Speicherzugriff |
goto | unbedingte Verzweigungsanweisung |
if-Anweisung | ein Muster in if-goto: bedingter Test & Verzweigungsanweisung |
while-Schleife | ein Muster in if-goto |
Zuweisung | Änderung von Registern oder Speicher |
arithmetische | Arithmetik-Anweisungen |
Parameter Übergabe | Änderung von Register- oder Stapelspeicher |
Parameterempfang | Zugriff auf Register- oder Stapelspeicher |
Funktion Aufruf | Aufrufbefehl |
Funktionseintritt | Prologmuster |
Funktionsausgang | Epilogmuster |
Rückgabewert | Registeränderung und Epilog |
Wie sich Assembler von anderen Programmiersprachen unterscheidet
In Assembler, sind Sie den bloßen Ressourcen der zugrunde liegenden CPU-Hardware ausgesetzt. (Außerdem benötigen Dinge, die wir in C in einer Zeile schreiben können, in Assembler mehrere Zeilen.)
-
Sie werden Dinge wie CPU-Register und den Aufrufstapel kennen – diese Ressourcen müssen verwaltet werden, damit ein Programm funktioniert (und in anderen Sprachen werden diese Ressourcen automatisch für uns verwaltet.) Die Kenntnis dieser Ressourcen ist entscheidend für den Aufruf und die Rückkehr von Funktionen. (CPU-Register sind im Vergleich zum Speicher klein, aber sie sind superschnell und auch gleichmäßig in ihrer Leistung.)
-
In Assembler gibt es keine strukturierten Anweisungen – daher müssen alle Kontrollflusskonstrukte im „If-Goto“-Stil programmiert werden (in anderen Sprachen übersetzt der Compiler strukturierte Anweisungen in If-Goto). Der If-Goto-Stil verwendet Labels als Ziele. (Obwohl C die Verwendung von goto und labels unterstützt, bevorzugen Programmierer meistens strukturierte Anweisungen, so dass wir goto’s und labels in C nicht oft sehen.)
Assemblersprache vs. Maschinencode
Assemblersprache ist dafür gedacht, dass Menschen sie sowohl lesen als auch schreiben können.
Labels
In Assemblersprache verwenden wir viele Labels. Die Verwendung der Labels wird im Maschinencode in Zahlen übersetzt und die Labels verschwinden im Maschinencode. Labels (im Gegensatz zur direkten Verwendung von Zahlen) machen es möglich, kleine Änderungen in Programmen vorzunehmen – ohne sie wäre selbst eine kleine Änderung eine große Änderung.
Der Prozessor muss wissen, wo ein Operand ist, vielleicht ein Datenoperand oder vielleicht eine Codestelle. Bei Codestellen will der Prozessor zum Beispiel wissen, wie weit diese Elemente von der Codestelle entfernt sind, die er gerade ausführt – das nennt man pc-relative Adressierung. Um eine Wenn-Dann-Anweisung auszuführen, könnten wir dem Prozessor also sagen: „Überspringe unter bestimmten Bedingungen N Anweisungen, wobei N die Größe oder Anzahl der zu überspringenden (oder nicht zu überspringenden) Anweisungen darstellt, d. h. N ist die Größe des Dann-Teils – das wäre dann Maschinencode. Wenn wir dem Then-Teil eine Anweisung hinzufügen, erhöht sich die Anzahl der zu überspringenden Anweisungen (und wenn wir eine Anweisung aus dem Then-Teil entfernen, verringert sie sich).
Die Verwendung von Labels berücksichtigt automatisch Änderungen (z.B. Hinzufügen, Entfernen von Anweisungen) – eine Verzweigungsanweisung, die ein Label als Ziel verwendet, berücksichtigt Änderungen in der Anzahl der Anweisungen, die bedingt übersprungen werden sollen.
Pseudo-Anweisungen
Die meisten Anweisungen in Assembler werden 1:1 in Maschinencode-Anweisungen übersetzt. Gelegentlich müssen wir dem Assembler jedoch etwas mitteilen, das sich nicht direkt in Maschinencode übersetzen lässt. Typische Beispiele sind, dem Assembler mitzuteilen, dass es sich bei den folgenden Anweisungen um Code (normalerweise über eine .text
-Anweisung) und nicht um Daten (normalerweise über eine .data
-Anweisung) handelt. Wie reguläre Anweisungen werden diese Anweisungen normalerweise in eine eigene Zeile geschrieben, aber sie informieren den Assembler, anstatt direkt Maschinencode zu erzeugen.
Schreiben von Assemblersprache
In C schreiben wir Anweisungen; diese Anweisungen laufen nacheinander ab, standardmäßig sequentiell, bis ein Kontrollflusskonstrukt (Schleife, if) den Fluss ändert. In Assembler gilt dasselbe.
In C haben Anweisungen Auswirkungen auf Variablen – wir können davon ausgehen, dass einzelne Anweisungen über Variablen miteinander kommunizieren. Dasselbe gilt für Assembler: Anweisungen kommunizieren miteinander über die Auswirkungen, die sie auf Variablen haben, obwohl es, da es mehr Anweisungen gibt (notwendigerweise, da die Anweisungen einfacher sind), auch notwendigerweise mehr Variablen gibt, von denen viele sehr kurzlebige Provisorien sind, die verwendet werden, um von einer Anweisung zur nächsten zu kommunizieren.
Grundlegende Assemblerprogrammierung
Wir wechseln zwischen Registerauswahl und Anweisungsauswahl. Da Assembler-Befehle ziemlich einfach sind, müssen wir oft mehrere Befehle verwenden, die über Werte als Variablen in CPU-Registern miteinander verbunden sind. Wenn dies geschieht, müssen wir ein Register auswählen, um Zwischenergebnisse zu speichern.
Registerauswahl
Um ein Register auszuwählen, brauchen wir ein mentales Modell davon, welche Register belegt sind und welche frei sind. Mit diesem Wissen können wir ein freies Register auswählen, um ein temporäres (kurzlebiges) Ergebnis zu speichern. Dieses Register bleibt beschäftigt, bis wir es zum letzten Mal benutzen, dann wird es wieder frei – sobald es frei ist, kann es für etwas ganz anderes verwendet werden. (In gewissem Sinne weiß der Prozessor nicht wirklich, dass wir diese ständige Umwidmung vornehmen, da er die Absicht des Programms nicht kennt.)
Befehlsauswahl
Wenn wir die zu verwendenden Ressourcen festgelegt haben, können wir den spezifischen Befehl auswählen, den wir benötigen. Oft finden wir keine Anweisung, die genau das tut, was wir wollen, so dass wir eine Aufgabe mit 2 oder mehr Anweisungen erledigen müssen, was wiederum bedeutet, dass wir mehr Register für Provisorien auswählen müssen.
Zum Beispiel sind MIPS und RISC V ähnliche Architekturen, die eine vergleichende & Verzweigungsanweisung bieten, aber sie können nur zwei CPU-Register vergleichen. Wenn wir also sehen wollen, ob ein Byte in einer Zeichenkette ein bestimmtes Zeichen ist (z.B. ein Zeilenumbruch), dann müssen wir diesen Zeilenumbruchwert (eine numerische Konstante) in einem CPU-Register bereitstellen (laden), bevor wir den Verzweigungsbefehl compare & verwenden können.
Funktionsaufruf & Rückgabe
Ein Programm besteht vielleicht aus Tausenden von Funktionen! Diese Funktionen müssen sich zwar den Speicher teilen, aber der Speicher ist riesig, und so können sie miteinander auskommen. Bei einer CPU gibt es jedoch eine begrenzte und feste Anzahl von Registern (z.B. 32 oder 16), und alle Funktionen müssen sich diese Register teilen!!!
Damit das funktioniert, definieren Softwareentwickler Konventionen – die man sich als Regeln vorstellen kann – für die gemeinsame Nutzung dieser begrenzten festen Ressourcen. Außerdem sind diese Konventionen notwendig, damit eine Funktion (ein Aufrufer) eine andere Funktion (ein Aufrufer) aufrufen kann. Die Regeln werden als Aufrufkonvention bezeichnet, die für den Aufrufer/Aufrufer angibt, wo Argumente und Rückgabewerte zu platzieren/finden sind. Eine Aufrufkonvention ist Teil der binären Anwendungsschnittstelle, die uns im Großen und Ganzen sagt, wie eine Funktion mit der Außenwelt (anderen Funktionen) im Maschinencode kommunizieren sollte. Die Aufrufkonvention ist für die Verwendung in der Software definiert (die Hardware interessiert nicht).
Diese Dokumente sagen uns, was wir bei der Übergabe von Parametern, der Verwendung von Registern, der Zuweisung/Deallokation von Stacks und der Rückgabewerte tun sollen. Diese Dokumente werden von den Anbietern von Befehlssätzen oder Compilern veröffentlicht und sind bekannt, so dass Code aus verschiedenen Quellen korrekt zusammenarbeiten kann. Die veröffentlichten Aufrufkonventionen sollten als gegeben hingenommen werden, auch wenn die Hardware sie nicht kennt.
Ohne eine solche Dokumentation wüssten wir nicht, wie wir diese Dinge tun sollen (Parameterübergabe usw.). Wir müssen nicht die Standard-Aufrufkonventionen verwenden, aber es ist unpraktisch, unsere eigenen zu erfinden, wenn es sich nicht um ein einfaches Spielzeugprogramm handelt, das auf einem Simulator läuft. Wenn wir unsere eigenen Aufrufkonventionen entwickeln, bedeutet das, dass wir nicht mit anderem Code interagieren können, was wir in jedem realistischen Programm tun müssen, z.B. System- und Bibliotheksaufrufe machen.
Die Hardware kennt die Softwareverwendungskonventionen nicht und kümmert sich nicht darum. Sie führt einfach blind eine Anweisung nach der anderen (sehr schnell) aus. Für die Hardware sind zum Beispiel die meisten Register gleichwertig, auch wenn die Register durch die Softwarekonventionen aufgeteilt oder bestimmten Verwendungszwecken zugewiesen sind.
Dedizierte Register
Einige CPU-Register sind für bestimmte Zwecke bestimmt. Zum Beispiel haben die meisten CPUs heutzutage irgendeine Form von Stapelzeigern. Bei MIPS und RISC V ist der Stapelzeiger ein gewöhnliches Register, das sich nicht von den anderen unterscheidet, außer durch die Definition der Softwarekonventionen – die Wahl des Registers ist willkürlich, wird aber tatsächlich getroffen, und diese Wahl wird veröffentlicht, damit wir alle wissen, welches Register es ist. Auf x86 hingegen wird der Stackpointer von dedizierten push
& pop
Befehlen unterstützt (ebenso wie Call- und Return-Befehle), so dass es für das Stackpointer-Register nur eine logische Wahl gibt.
Register Partioning
Um die anderen Register unter Tausenden von Funktionen aufzuteilen, werden sie per Softwarekonvention in der Regel in vier Gruppen aufgeteilt:
- Register für die Übergabe von Parametern
- Register für den Empfang von Rückgabewerten
- Register, die „flüchtig“
- Register, die „nichtflüchtig“
Parameter &Rückgaberegister
Parameterregister werden für die Übergabe von Parametern verwendet! Durch die Einhaltung der Aufrufkonvention wissen die Aufrufenden, wo ihre Argumente zu finden sind, und die Aufrufenden wissen, wo die Argumentwerte zu platzieren sind. Parameterregister werden für die Übergabe von Parametern nach den in der Aufrufkonvention festgelegten Regeln verwendet, und wenn mehr Parameter übergeben werden, als die Konvention für die CPU zulässt, werden die restlichen Parameter mit Hilfe des Stapelspeichers übergeben.
Rückgabewerte-Register enthalten die Rückgabewerte von Funktionen, so dass eine Funktion, wenn sie beendet ist, ihren Rückgabewert in das richtige Register legen muss, bevor sie den Kontrollfluss an den Aufrufer zurückgibt. Durch die Einhaltung der Aufrufkonvention wissen sowohl der Aufrufende als auch der Aufgerufene, wo der Rückgabewert der Funktion zu finden ist.
Flüchtige & Nichtflüchtige Register
Flüchtige Register können von jeder Funktion ohne Rücksicht auf die vorherige Verwendung oder die Werte in diesem Register verwendet werden. Sie eignen sich für temporäre Daten, die zur Verbindung einer Befehlssequenz benötigt werden, und können auch für lokale Variablen verwendet werden. Da diese Register jedoch von jeder Funktion frei verwendet werden können, können wir uns nicht darauf verlassen, dass sie ihre Werte über einen Funktionsaufruf hinaus behalten – per (Softwarekonvention) Definition! Daher kann der Aufrufer nach der Rückkehr der Funktion nicht sagen, welche Werte sich in diesen Registern befinden würden – jede Funktion kann sie neu verwenden, allerdings mit dem Wissen, dass wir uns nicht darauf verlassen können, dass ihre Werte nach der Rückkehr einer aufgerufenen Funktion erhalten bleiben. Daher werden diese Register für Variablen und temporäre Daten verwendet, die sich nicht über einen Funktionsaufruf erstrecken müssen.
Für Variablen, die sich über einen Funktionsaufruf erstrecken müssen, gibt es nichtflüchtige Register (und auch Stapelspeicher). Diese Register bleiben über einen Funktionsaufruf hinweg erhalten (durch Software, die sich an die Aufrufkonvention hält). Wenn also eine Funktion eine Variable in einem schnellen CPU-Register haben möchte, diese Variable aber über einen Aufruf hinweg aktiv ist (vor einem Aufruf gesetzt und nach dem Aufruf verwendet wird), können wir ein nichtflüchtiges Register wählen, mit der (durch die Aufrufkonvention definierten) Anforderung, dass der vorherige Wert des Registers bei der Rückkehr zum Aufrufer wiederhergestellt wird. Dies erfordert das Speichern des aktuellen Wertes des nichtflüchtigen Registers (im Stapelspeicher) vor der Wiederverwendung des Registers und dessen Wiederherstellung (unter Verwendung des vorherigen Wertes aus dem Stapelspeicher) bei der Rückkehr.