In der Serie “Wie Rechner rechnen” habe ich versucht, die physikalischen Grundlagen eines Computers zu erklären und darzulegen, was in so einem Ding überhaupt im Inneren vorgeht. Speziell im letzten Teil haben wir dabei betrachtet, wie ein Computer nun genau Befehle ausführt. Heute nun soll es darum gehen, wie man diese Befehle eigentlich auf vernünftige Weise in den Rechner hineinbekommt (die Wünsche einiger Leser nach mehr Details zu modernen Techniken bei der Befehlsausführung habe ich vermerkt und reiche sie noch nach – ich möchte nur vorher einige Dinge einführen, die dafür nötig sind).
Fassen wir noch einmal kurz zusammen: irgendwo im Computer, meist im Cache oder im Arbeitsspeicher, stehen Instruktionen – Bitketten, die eine auszuführende Aktion codieren. Eine Instruktion setzt sich aus dem Opcode, der eigentlichen Aktion, und den Operanden, also den zu benutzenden Daten, zusammen. Das fiktive Beispiel aus dem letzten Beitrag war die Instruktion “0110110101”, welche in die Bestandteile 0110, 11 und 0101 unterteilt werden kann, wobei “0110” der Opcode ist und die Anweisung “addiere einen festen Wert zu dem in einem Register gespeicherten Wert” codiert, “11” das zu benutzende Register (im Beispiel den Akkumulator) beschreibt und “0101” schließlich den festen Wert, hier eine 5, codiert. Zusammengenommen ergibt die Bitkette also die Instruktion “addiere den Wert 5 auf den im Akkumulator gespeicherten Wert” (ich weiße – nur zur Sicherheit – noch einmal darauf hin, dass das Beispiel rein fiktiv ist und keine Entsprechung in der Realität hat). Man sagt, die Instruktionen sind im Maschinencode geschrieben; mehrere Instruktionen zusammen bilden ein Programm.
Nun könnte man einen Computer natürlich programmieren, indem man viele dieser Instruktionen von Hand schreibt, in der richtigen Reihenfolge aneinanderreiht und dann in den Speicher eines Computers lädt (wie heutzutage das Laden funktionieren würde, ist noch einmal eine Sache für sich und eine Variante des bekannten Henne-Ei-Problems). In den Anfangsjahren der Informatik wurde das tatsächlich auch noch so gemacht; die Bitketten konnten allerdings nicht immer einfach komfortabel am Rechner eingegeben werden, sondern mussten teilweise noch manuell auf ein Trägermedium transferiert werden – die allseits bekannten Lochstreifen und Lochkarten. Lochstreifen (siehe Abbildung; Quelle: Wikipedia) waren lange Streifen aus Papier oder Kunststoff, auf welche Informationen in Form von Bitketten gespeichert wurden, wobei ein Loch etwa der logischen “1” entsprach und kein Loch entsprechend der logischen “0”. War der Lochstreifen breit genug für 8 nebeneinanderstehende Löcher, so konnte eine Bitkette der Länge 8 in jeder Zeile gespeichert werden. Eine Lochkarte war im Grunde das gleiche, nur in der Länge begrenzt (übrigens gab es Lochkarten und -streifen nicht erst mit dem Aufkommen von Computern – bereits vorher wurden sie eingesetzt, zum Beispiel in den bekannten Jacquardwebstühlen). “Programmiert” wurde also, indem man kleine Löcher in Papier gebohrt hat.
Wer jetzt der Meinung ist, dass ein derartiges Vorgehen höchst umständlich anmutet, hat natürlich vollkommen recht; in den Anfangsjahren gab es dazu aber keine Alternative. Dennoch war man natürlich bemüht, ein besseres Verfahren zu finden, da das Stanzen von Löchern doch ein wenig umständlich und der Code auch ziemlich schlecht zu lesen war. Hinzu kam, dass jeder Prozessortyp seine ganz eigene Codierung der Instruktionen hatte – Programme waren also nicht wirklich gut auf andere Architekturen portierbar.
Die Lösung des Problems wurde in den 1950er Jahren entwickelt: die Assemblersprachen1. Eine Assemblersprache ist in ihrer einfachsten Form nichts weiter als eine Möglichkeit, Maschinencode (also Instruktionen) in einer besser lesbaren Form zu notieren. Obgleich es unterschiedliche Arten gibt, Assemblercode niederzuschreiben, ähneln sich Assemblersprachen in vielen Punkten. So werden die Opcodes, die ja nichts anderes als binäre Zahlen sind, durch besser verständliche (und vor allem besser merkbare) Wörter ersetzt. Aus mir nicht näher bekannten Gründen (ich tippe auf Speicherplatzersparnis) wurden dabei nicht irgendwelche Wörter gewählt, sondern möglichst kurze, eindeutige und natürlich englische. Die Anweisung “addiere” wird folgerichtig üblicherweise mit add abgekürzt, Subtrahieren mit sub
und die Anweisung zum Verschieben von Speicherinhalten mit mov
. In Anlehnung an die Funktion der Wörter, nämlich das Merken der komplizierten Opcodes zu vereinfachen, werden sie übrigens auch Mnemonics genannt. Hinter dem Wort für die Aktion folgen nun die dazugehörigen Operanden; auch diese können über Mnemonics dargestellt werden, etwa im Fall von Registern; Speicheradressen und Zahlen lassen sich dagegen ganz normal im bekannten Dezimalsystem angeben.
Ein kleines Beispiel: die bereits oben erwähnte (fiktive) Instruktion “0110110101”, ausgesprochen “addiere den Wert 5 auf den im Akkumulator gespeicherten Wert”, würde sich mit Hilfe einer Assemblersprache auf die folgende Art schreiben lassen:
Das Mnemonic al
bezeichnet hier übrigens das Akkumulator-Register. Die Zusammenfassung mehrerer Assemblerbefehle, die zusammenhängend ausgeführt werden sollen, um eine Aufgabe zu lösen, nennt man Assemblerprogramm. Assemblerprogramme werden in der Regel auch nicht mehr über externe Geräte eingegeben und dann in Lochkarten gestanzt, sondern direkt im Computer eingegeben. Die Übersetzung in echten Maschinencode (denn nur solchen kann der Prozessor verstehen) übernimmt dann ein sogenannter Assembler. Das ist ein relativ einfaches Programm, welches die geschriebenen Assembler-Befehle aus einer Datei lesen und in Maschinencode übersetzen kann und am Ende zum Beispiel eine ausführbare Datei (unter Windows sind das die Dinger mit “.exe” hinten dran) erzeugt.
An dieser Stelle möchte ich noch auf eine kleine Eigenheit in Bezug auf Assemblersprachen hinweisen, um spätere Verwirrung zu vermeiden: in der Umgangssprache (unter Informatikern) werden nicht nur die Übersetzungsprogramme “Assembler” genannt, sondern mitunter auch der geschriebene Code selber. Man sagt dann, dass ein Programm “in Assembler geschrieben” ist – gemeint ist natürlich, dass es in einer Assemblersprache geschrieben wurde, aber Informatiker sind zuweilen recht faul, was diese Unterscheidung angeht. Ich werde nach Möglichkeit versuchen, die beiden Begriffe immer zu unterscheiden; wenn es mir einmal nicht gelingt, sollte es aber deswegen jetzt auch keine Verwirrung mehr geben.
Nun ist allein schon die Möglichkeit, Mnemonics anstelle von komplizierten Instruktions-Codes benutzen zu können, eine große Erleichterung beim Programmieren. In einer Assemblersprache sind allerdings noch mehr Dinge möglich. Eine in den Assembler-Anfangszeiten besonders wichtige Eigenschaft war die Unterstützung bei der Behandlung von Variablen und Sprungbefehlen.
Genauso, wie ein Programm im Speicher liegt, da es nicht nur ausschließlich in den Registern des Prozessors gehalten werden kann (dafür gibt es nicht genügend), können die Daten eines Programms meist auch nicht nur in den Registern vorhanden sein. Bisher habe ich es noch nicht erwähnt, aber eigentlich ist klar, dass auch Daten im Speicher gehalten werden. Sie sind genauso wie Instruktionen über eine Adresse ansprechbar; so könnte etwa das Register al in der obigen add-Anweisung auch durch eine Adresse ersetzt werden, so dass die 5 nicht mehr in den Akkumulator, sondern in den Speicher geschrieben wird. Nun ist das Hantieren mit Speicheradressen natürlich höchst unkomfortabel; aus diesem Grund können in einem Assemblerprogramm sogenannte Variablen deklariert werden. Das sind im Grunde nichts anderes als eindeutige Bezeichner für bestimmte Adressen im Speicher; die zugrundeliegende Adresse dabei entweder von Hand angegeben oder – und hier liegt der große Vorteil – automatisch vom Assembler bestimmt werden. Die Deklaration und Benutzung von Variablen im Code ist relativ simpel; nachfolgend ein kleines Beispiel, in welchem eine Variable var deklariert und anschließend an die von ihr bezeichnete Adresse im Speicher der Wert 5 geschrieben wird (das db bedeutet nur, dass lediglich ein einziges Byte an Speicher reserviert werden soll):
mov var, 5
Für die Bearbeitung komplexerer Aufgabenstellungen, die über das reine Verrechnen von Zahlen hinausgehen, ist die Kontrolle des Programmflusses unerlässlich. In unseren bisherigen Betrachtungen sah es immer so aus, als würde streng eine Instruktion nach der anderen geladen; bei näherer Betrachtung erweist sich das allerdings als sehr nachteilig, da so nur wirklich lineare Probleme zu lösen sind. In der Regel sind allerdings auch Verzweigungen im Programmfluss und Wiederholungen von Programmcode nötig, außerdem auch der Aufruf sogenannter Unterprogramme (ein Unterprogramm ist im Grunde nichts anderes als ein Codeabschnitt, der eine meist kleinere Aufgabe löst). Und hier kommen die Sprungbefehle ins Spiel; sie ermöglichen, dass der sonst streng lineare Programmablauf unterbrochen werden kann, indem die nächste zu ladende Instruktion angegeben wird. Springt man dabei zurück zu einer bereits ausgeführten Instruktion, hat man eine Wiederholung; überspringt man einen Teil der Instruktionen, hat man eine Verzweigung im Programmcode; und springt man schließlich an eine ganz andere Stelle im Code, so entspricht dies dem Aufruf eines Unterprogramms. Wir erinnern uns: die Adresse der nächsten zu ladenden Instruktion steht im Instruktionsregister; modifiziert man nun also nun diesen Wert, kann ein Sprung in der Instruktionsreihenfolge durchgeführt werden. Der mnemonische Code für die einfachste derartige Anweisung, den unbedingten Sprung, ist jmp (als Abkürzung für “jump”, also Sprung), dass als Operanden das Sprungziel, also die Adresse der nächsten Instruktion erwartet. Zu Zeiten der Erstellung von Maschinencode per Hand war es dabei noch nötig, die exakte Adresse (die Adresse der konkreten Speicherzelle, welche die gewünschte Instruktion enthält) zu kennen, was insbesondere bei Vorwärtssprüngen kompliziert war. Außerdem hatte jede Änderung am Maschinencode zur Folge, dass unter Umständen die Sprungziele angepasst werden mussten (da sich durch das Einfügen von Befehlen ja die Adressen aller nachfolgenden Instruktionen ändern). Im Assemblercode ist es nun möglich, dieses Problem zu umgehen, und zwar dank der Verwendung von Sprungmarken; dies sind einfache Bezeichner, die an beliebigen Stellen im Code zwischen zwei Instruktionen eingefügt werden können und bei der Übersetzung in Maschinencode automatisch in konkrete Adressen umgewandelt werden. Hierzu ein kleines (nicht sinnvolles!) Beispiel (var ist unsere Variable von vorhin):
loop:
add al, 1
jmp loop
Der Code macht nichts anderes, als zu Anfang in das Register al
den Wert zu schreiben, der im Speicher an der durch var
beschriebenen Stelle steht und dann in einer (unendlich lang laufenden) Schleife immer wieder den Wert 1 darauf zu addieren.
Neben den unbedingten Sprüngen gibt es – wenig überraschend – auch die bedingten Sprünge, die nur dann ausgeführt werden, wenn eine bestimmte Bedingung erfüllt ist. Auch hierzu ein diesmal etwas sinnvolleres Beispiel (die Erklärung folgt darunter, die Zeilen sind nummeriert):
(2) mov bl, var1
loop:
(3) sub bl, var2
(4) cmp bl, 0
(5) jl end
(6) inc al
(7) jmp loop
end:
(8) add bl, var2
Nehmen wir an, dass in den Variablen var1 und var2 zwei Werte gespeichert sind, wobei wir die Gleichung var1 / var2 berechnen wollen, und zwar ganzzahlig und mit Rest. In den ersten beiden Zeilen (1) und (2) des Codes wird das Register al mit dem Wert 0 und das Register bl mit dem Wert von var1 gefüllt. Anschließend folgt eine Sprungmarkierung. In der darauffolgenden Zeile (3) wird vom Wert in bl der Wert von var2 abgezogen. Anschließend erfolgt in (4) ein Vergleich (cmp von englisch “compare”) von bl und dem festen Wert 0. Dabei werden intern im Prozessor einige sogenannte Flags (separate Register mit einer Größe von nur einem Bit) gesetzt, je nachdem, ob der erste Wert größer, kleiner oder gleich dem zweiten Wert ist. Die Magie passiert in der nächsten Zeile (5): jl end ist ein bedingter Sprung (jl von englisch “jump less”), der in diesem Fall genau dann ausgeführt wird, wenn der vorhergehende Vergleich ergeben hat, dass der erste Wert kleiner als der zweite ist (für die Kenner: der Sprung wird durchgeführt, wenn das Zeroflag auf 0 und das Signflag ungleich dem Overflowflag ist). Ist dies nicht der Fall, wird in Zeile (6) der Wert im Register al um eins erhöht (inc von englisch “increment) und anschließend in (7) zurück zum Anfang der Schleife gesprungen. Wird der bedingte Sprung dagegen durchgeführt, wird noch die letzte Anweisung (8) hinter der Schleife ausgeführt, in welcher auf den Wert in Register bl noch einmal der Wert von var2 draufaddiert wird, um den korrekten Rest zu berechnen.
Eigentlich alles ganz klar, oder? Nein? Gut, hier eine kleine Wertetabelle für eine Beispielrechnung; nehmen wir an var1 hat den Wert 8 und var2 den Wert 3, wir wollen also 8/3 berechnen. Damit ergibt sich die folgende Tabelle; jede Zeile markiert einen Ausführungszyklus:
Codezeile | al | bl | Bemerkung |
---|---|---|---|
2 | 0 | 8 | Initialisierung |
3 | 0 | 5 | Berechnung von: bl – var2 |
4 | 0 | 5 | Vergleich: 5 < 0? |
5 | 0 | 5 | 5 ist nicht kleiner als 0, also passiert nichts |
6 | 1 | 5 | Inkrement: al |
7 | 1 | 5 | Sprung zurück zu “loop” |
3 | 1 | 2 | Berechnung von: bl – var2 |
4 | 1 | 2 | Vergleich: 2 < 0? |
5 | 1 | 2 | 2 ist nicht kleiner als 0, also passiert nichts |
6 | 2 | 2 | Inkrement: al |
7 | 2 | 2 | Sprung zurück zu “loop” |
2 | 2 | 2 | Berechnung von: bl – var2 |
4 | 2 | -1 | Vergleich: -1 < 0? |
5 | 2 | -1 | -1 ist kleiner als 0, also Sprung zur Markierung “end” |
8 | 2 | 2 | Addition: bl + var2 |
Und in der Tat: 8 geteilt durch 3 ergibt bei ganzzahliger Division 2, Rest 2. Mal ehrlich: so schwer war es am Ende nun doch nicht, oder? Mit ein wenig Konzentration und gutem Willen lässt sich Assemblercode doch relativ gut verstehen. Zumindest besser als reiner Maschinencode. Aber ich gebe gerne zu, dass auch Assemblercode nicht optimal ist; zumindest wird er den Anforderungen der modernen Programmierwelt definitiv nicht gerecht. Er bietet jedoch die Möglichkeit, auf einer sehr niedrigen Ebene Programme schreiben (und vor allem auch später wieder lesen) zu können und wird auch heutzutage noch ab und zu benutzt. Die hauptsächliche Programmierung von Computern wird aber mit sogenannten Hochsprachen durchgeführt – aber die schauen wir uns ein andermal an.
1 Wer die erste Assemblersprache entwickelt hat (und wie sie hieß) weiß ich leider nicht – wer Informationen dazu hat, möge sich bitte in den Kommentaren melden!em beschriebenen Stelle steht und dann in einer (unendlich lang laufenden) Schleife immer wieder den Wert 1 darauf zu addieren.
Neben den unbedingten Sprüngen gibt es – wenig überraschend – auch die
Kommentare (20)