GRUNDLAGEN DER PROGRAMMIERUNG
...speziell des AT-PC
Ich beginne mit Logik und Maschinensprache, weil dieser Weg in die Welt des Computers der
einzige ist, ihn beherrschen zu lernen.
Dabei kann man am besten feststellen, wie lächerlich einfach das Prinzip ist...
(Als erste aber bitte die letzten Sätze dieses Textes lesen!)
GATTERLOGIK:
Eine CPU (Central-Processor-Unit) besteht aus Transistoren, die nur in einer einzigen von
drei möglichen Grundschaltungen betrieben werden: der Sourceschaltung. Und es spielen nur
zwei Zustände von fast beliebig vielen möglichen am Ausgang dieser Schaltung eine Rolle.
Ein Transistor hat drei Anschlüsse. Am einen fliessen die Elektronen rein, am zweiten raus,
und der dritte, das Gate (oder die Basis), erlaubt, das Fliessen der Elektronen zu
verhindern oder zu ermöglichen.
Damit ist der Transistor ein gesteuerter Widerstand. In Logikschaltungen ist der nur sehr
gross oder sehr klein. Was an Zwischenwerten erscheint, wird durch Taktung ausgeblendet
und erzeugt nur unerwünschte Wärme.
Weil es nahe liegt, den Transistor mit der bekannteren Funktion eines Wasserhahns zu
erklären, muss zunächst gesagt werden, dass dabei nicht die ganze Wahrheit zutage treten kann.
Die ganze Wahrheit ist, dass erst ein weiterer Widerstand (bei CMOS ist das ein weiterer,
komplementärer Transistor) die Funktion ermöglicht, mit der man logische Schaltungen aufbaut.
Die Erklärung geht also besser von einem sogenannten Spannungsteiler aus.
Stellen wir uns vor, dass zwischen dem linken und dem rechten Rand dieses Textes eine
Spannung von 5 Volt herrscht. Wir legen zwei Widerstände in Serie an diese Spannung, etwa so:
0---------------Widerstand--------Widerstand---------------------5 Volt
Messen kann man nur bei realen Widerständen. Hier muss man mir und Herrn Ohm glauben, dass
an den Widerständen (zwischen erstem und letztem Buchstaben) eine Spannung von jeweils
2,5 Volt anliegt, wenn die Widerstände gleich gross sind (gleichen Widerstandswert haben).
Diese einfache Schaltung nennt man einen "Spannungsteiler", weil er nicht nur 5 Volt durch
zwei teilen kann, sondern jede beliebige Spannung in jedem beliebigen Verhältnis - je nach
Wahl der Werte der beiden Widerstände. Dabei liegt am Widerstand mit dem kleineren Wert auch
die kleinere Spannung an (Formel: R=U/I; dabei ist: R=Widerstand, U=Spannung, I=Strom).
Lassen wir den einen Widerstand, wie er ist, und ersetzen wir den anderen, den linken durch
einen Transistor vom NMOS-Typ, dann liegt sein Source-Anschluss links an 0 Volt, sein
Drain-Anschluss rechts am Widerstand, und sein Gate liege an einem weiteren Spannungsteiler.
Das sieht dann etwa so aus:
0------------------Source__________Drain----------Widerstand-----5 Volt
0-Widerstand----------------Gate------------------Widerstand-----5 Volt
Nun kommen die wichtigen Feststellungen - die, die uns begreiflich machen, wie man mit einem
elektronischen Teil, dem Transistor, auf die Logik kommt:
Legen wir das Gate an 0 Volt, machen also den linken Widerstand am Steuer-Spannungsteiler
sehr klein, dann wird der Widerstand zwischen Source und Drain sehr gross. Liegt dagegen das
Gate an 5 Volt, ist der Widerstand zwischen Source und Drain sehr klein. (So ist das Wesen
des Transistors in dieser Verschaltung!)
Betrachten wir nun die Spannungen am Gate und die am Drain, dann stellen wir fest, dass
sie stets ungleich sind. Nennen wir die 0 Volt "logisch L" und die 5 Volt "logisch H",
und nennen wir das Gate den "Eingang" und das Drain den "Ausgang", dann können wir die
Lehre kurz fassen. Die Schaltung befindet sich in jeweils einem von zwei Zuständen:
Eingang=L wird zu Ausgang=H resultiert
Eingang=H wird zu Ausgang=L resultiert
Die Übertragungsfunktion ist also die einer Wippe. Aber es gibt einen ganz wichtigen
Unterschied! Beim Transistor sind die Seiten nicht vertauschbar! Er hat ganz eindeutig einen
Eingang und einen Ausgang. Diese Schaltung nennt man "Inverter".
Noch eine weitere Feststellung, und wir sind wirklich bei der Logik...
Wenn wir nämlich zunächst mal nur zwei Transistoren parallel schalten (beide Sources und
Drains verbinden), dann haben wir zwei Eingänge, aber nur einen Ausgang. Und nun zeigt sich
etwas, was einen verblüffen darf! Obgleich wir zwei Inverter verschaltet haben, hat keiner
von beiden mehr die oben aufgeschriebene Übertragungsfunktion!
Elektronisch betrachtet ist das ganz selbstverständlich, denn die Widerstände, die hier durch
zwei Transistoren erzeugt werden, liegen parallel. Der Gesamtwiderstand von parallel
liegenden Widerständen ist allerdings stets noch kleiner als der kleinste Einzelwiderstand.
An diesem Gesamtwiderstand aber fällt die Spannung ab. Es ist also egal, ob der eine oder der
andere Transistor leitet - ist auch nur einer von beiden ein kleiner Widerstand, dann
liegt an beiden Drains, die ja kurz geschlossen sind, dieselbe kleine Spannung an. Es zeigt
sich die Spannung, der wir den logischen Wert L zuordneten.
In diesem Fall sieht die Sache also so aus, dass bei Betrachtung der zugehörigen Eingangswerte
der Wert H über den Wert L am anderen Eingang triumphiert. Die logische Situation ist etwa die:
Wenn es regnet(=H) ODER schneit(=H), gehe ich nicht barfuss. Bei Sonne(regnet=L UND schneit=L)
aber schon.
Bilden wir mit unseren "logischen" Werten L und H eine kleine Wertetabelle,
und lassen das auf uns wirken. Links stehen die vier möglichen Eingangswert-Paare, rechts
das zugehörige Resultat:
L L =H
L H =L
H L =L
H H =L
Wer mir nicht traut, lese woanders nach, dass dies die Übertragungsfunktion des "Boole'schen
Operators" NOR (NICHT-ODER) ist. Schaltet man nur an den Ausgang einen Inverter in Serie, hat
man ein OR (ODER) vor sich. Schaltet man vor jeden Eingang noch einen Inverter, dann hat man
ein NAND (NICHT-UND) vor sich, und wenn der zusätzliche Inverter am Ausgang wieder entfernt
wird, erhält man ein AND (UND).
Das XOR (Exclusiv-ODER) ist eine Verknüpfung mehrerer solcher "Gatter"("Gate" - und nicht
zu verwechseln mit dem Gate eines Feldeffekt-Transistors!); es kann ausserdem auf
verschiedene Weise aufgebaut werden. Weil die Übertragungsfunktion aber sehr wichtig ist,
schreibe ich sie nach obigem Schema mal eben hin:
L L =L
L H =H
H L =H
H H =L
Wenn beim XOR die Eingangswerte ungleich sind, resultiert die Funktion H, andernfalls L.
Weiter unten zeige ich, wozu diese Funktion gut ist.
Weil ich mich nicht in Details verlieren will, die den Überblick erschweren, mache ich
ohne weitere Erklärung ein paar abrundende Feststellungen:
-> In der Boole'schen Algebra werden statt des Wertes L (für "Low") "False" und statt des
Wertes H (für "High") "True" benutzt. In manchen Büchern zum Thema findet man deshalb
diese allzu vielsagenden Werte des Mr.Boole. Tatsächlich kam er nicht von
Transistorschaltungen ausgehend, sondern umgangssprachlichen Wendungen (den Syllogismen des
Aristoteles) zu seinen Ausdrücken - und da kann man den Quatsch auch verzeihen.
Man muss aber beim Programmieren sehr sorgfältig wenigstens zwei Formen der Verneinung
("Negation") auseinander halten:
die Invertierung einerseits und die algebraische Verneinung des NICHT (NOT) andererseits,
die mit einem Strich über dem Wert (Negationsstrich) ausgedrückt wird. Beides wird oft in
einen Topf geschmissen. Das tut jedenfalls garnicht gut, weil die Invertierung auf ein und nur
ein Gegenteil führt, und nur im Binärsystem definiert ist, während ein algebraisch verneintes
Ding durchaus mehrere verschiedene Dinge sein können. Da kann nämlich ein beliebiger
NICHT-Affe zum Menschen werden, also auch ein Wurm, der ebenfalls ein NICHT-Affe ist...
-> Weitere Werte als die logischen Werte L und H kommen im Computer nicht vor. Jede Zahl,
jeder Buchstabe, jede Farbe, jeder Ton, den der Computer von sich gibt, ist nur aus
solchen Werten zusammen gesetzt! Nur mit diesen Werten geht der Programmierer tatsächlich
um - nichts sonst!
-> Alle Rechenoperationen, die in einer CPU oder einem Arithmetik-Prozessor ablaufen, werden
nur mit logischen Schaltungen der oben beschriebenen Art bewältigt. Eine mechanische
Rechenmaschine ist ungleich komplizierter!
Ein "Addierwerk" ist also nur eine Logik - einige XORs und NORs.
-> Alle Speicherstellen in Prozessoren sind ebenfalls nur solche logischen Schaltungen!
Nur im Arbeitsspeicher wählt man "dRAM" (dynamic Random Access Memory). Eine Speicherstelle,
die sonst aus mindestens 9 Transistoren aufgebaut wird (sRAM :static Random Acces Memory),
wird im dRAM mit weniger Transistoren und einer Kapazität (Ladungsspeicher) aufgebaut.
Das spart Platz auf dem Chip, erfordert aber ständiges Nachladen der Kapazitäten ("Refresh")
mit einer besonderen Schaltung.
Aber natürlich stellt sich nun die Frage: Wie kommt der Sinn in die Maschine?
Ich sage es gleich: Nur durch zuordnen von Bedeutung. Also auf dieselbe Weise, wie oben aus
Spannungen logische Werte H und L wurden - wie zu Beginn der Elektronik, die nämlich
als Digitalelektronik mit Morses Alphabet begann...
BINÄRWERTE UND DUALZIFFERN:
Binärwerte müssen von Dualziffern strengstens unterschieden werden. Der Unterschied liegt
in der Bedeutung von ein und derselben Anordnung von logischen Werten L und H. Und wenn
eine Sache schon nicht für sich selbst spricht, muss man sehr genau sein beim vergeben
von Bedeutung.
Oben habe ich zwei 1-stellige Argumente, also zwei logische Werte, die zu gleicher Zeit
anliegen, eingeführt, um das NOR zu demonstrieren. Hier will ich verallgemeinern auf
beliebige Stellenzahlen.
Der Beliebigkeit sind allerdings praktische Grenzen gesetzt, weil irgendwann vor dreissig
Jahren jemand einen Ausdruck mit acht logischen Werten für ausreichend hielt, die Welt der
Worte darstellbar zu machen.
Ein Argument bzw. Resultat mit einer einzigen Stelle wurde "Bit" genannt, 8-stellige
Argumente bzw. Resultate "Byte". Später kamen vielfache davon hinzu. Diese Festlegung ist
aber - dem Einsteiger muss es deutlich gesagt werden! - vollkommen willkürlich. Es gibt
sehr wohl Maschinen, die mit 12-stelligen Argumenten umgehen (PIC) oder mehr oder weniger.
Nur die Vereinfachung der Herstellung von Digitalbausteinen war der Grund der Regel.
Ein Byte kann nun auf verschiedene Weise Bedeutung erlangen. Ich schreibe mal eins hin, damit
ich darauf beziehen kann: HLLLHLLL
In vielen Maschinen hat dieses Byte die Bedeutung von zwei Argumenten HLLL, die auch "Nibble"
genannt werden. Mit einem Nibble kann man minimal eine Stelle einer Dezimalzahl codieren
(6 Wertkombinationen werden dann als irrelevant verworfen). Deshalb ist dies in jeder Maschine
das "Format" von binär codierten Dezimalziffern, abgekürzt "BCD" (Binary coded Decimal).
Man kann aber auch in den beiden links stehenden Stellen vier Wertekombinationen verpacken,
die in einem 8-stelligen Befehl bedeuten, dass der Speicher adressiert wird...
Solche Art, den Stellen Bedeutung zu geben, macht aus einem Byte einen Binärwert. Zu einer
Dualziffer wird das Byte dagegen nur bei einer ganz bestimmten Bedeutung der Stellen!
Betrachten wir mal eben die vertrauten Dezimalziffern und stellen fest, dass in jeder Stelle
zehn verschiedene Ziffern stehen können. Diese Ziffern sind nicht Zahlen! Sie sind Namen
für Zahlen und können beliebig ersetzt werden! Sehen wir ab von römischen Ziffern, weil die
Römer zu dämlich für schöne stellen-bewertete Ziffernsysteme waren. Aber nehmen wir doch
einfach unsere logischen Werte H und L und machen damit dasselbe wie mit den Ziffern im
dezimalen System. Wir legen fest, dass L der 0 entspricht und kleiner als H der 1
entsprechend sein soll, und wir schreiben eine weitere Stelle links davon für den nächst
grösseren Wert, wenn die rechts stehenden Stellen "voll" sind.
Während wir also im dezimalen System in einer Stelle die Ziffern von 0 bis 9 setzen, um die
Stelle "voll" zu machen, erreichen wir diesen Zustand bei nur zwei Werten schon nach einem
Zählschritt von 0 auf 1. Wie im dezimalen System die um eins erhöhte 9 zu einer 1 in einer
weiteren, linkeren Stelle führt, ist nun die um einen Zählschritt erhöhte 1 eine 10.
Ich schreibe mal die ersten acht Zahlen mit den Ziffern 1 und 0 hin, um es klar zu machen:
000, 001, 010, 011, 100, 101, 110, 111
Nur solche Binärwerte sind Dualziffern! (...und auch nur, wenn sie für den Menschen lesbar
hingeschrieben werden. In der Gatterlogik kann die links stehende Stelle durchaus recht stehen!
- Tatsächlich ist das der für Programmierer wichtigste Unterschied zwischen Intel-Prozessoren
und denen von Motorola)
Ganz analog kommt man mit Oktal"zahlen" und Hexadezimal"zahlen" zurecht, wobei letztere beim
Programmieren sehr wichtig sind. Mit einer Hexadezimalziffer kann man nämlich genau
ein Nibble darstellen.
Im Oktalziffernsystem kommen je Stelle nur die Ziffern 0 bis 7 vor. Bei Hexadezimalziffern
kommen je Stelle 16 verschiedene Ziffern vor - 0 bis 9 und weiter mit A bis F.
Eine hexadezimale 10 ist also eine dezimale 16, eine oktale 20 und duale 10000.
Und jetzt ist wohl auch klar, warum ich nicht nur Binärwerte von Dualziffern, sondern auch
Ziffern von Zahlen unterschieden habe. Denn eben habe ich ein und die selbe Zahl mit
vier verschiedenen Ziffernanordnungen geschrieben. Allerdings sind diese Namen von Zahlen,
wie wir ja wissen, nicht ganz willkürlich gewählt, sondern stellen etwas dar, was ich hier
"Stellen-bewertetes System" nennen will, weil damit die Verwandtschaft mit noch ganz anderen
Systemen der Namensgebung ausgedrückt werden kann - und weil solche Stellen-bewerteten
Systeme beim Programmieren sehr wichtig sind.
DUALARITHMETIK:
Ansich ist dazu nur zu sagen, dass mit Dualziffern nicht anders als mit Dezimalziffern
gerechnet wird. Das gilt insbesondere für die Übertragsverrechnung bei der Addition.
Allerdings erlauben Dualziffern Vereinfachungen bei verschiedenen Rechenoperationen, und
um die geht es jetzt.
Besondere Bedeutung hat das addieren oder subtrahieren von 1 - weiter unten wird das klar.
Das Addieren von 1 nennt man "inkrementieren". Das "dekrementieren" ist das subtrahieren von
1, wird aber tatsächlich als inkrementieren mit "invertieren" von Minuend und Resultat
ausgeführt.
Man kann zwar Dualziffern ebenso voneinander subtrahieren wie Dezimalziffern. Das ist aber sehr
unpraktisch. Man müsste in eine CPU ein Subtrahierwerk einbauen. Keiner tut das, weil
bei Dualziffern etwas sehr einfach geht - das "invertieren".
Invertieren ist eigentlich eine logische Operation, weil sie davon Gebrauch macht, dass je
Stelle nur zwei Werte möglich sind. Man kann deshalb ganz eindeutig einen Wert in ein und
nur ein Gegenteil verkehren: Aus H wird L und aus L wird H.
Zwar spielt das Invertieren bei Binärwerten auch eine wichtige Rolle. Wichtiger ist aber die
Tatsache, dass bei Invertieren einer Dualziffer mit einer bestimmten Zahl von Stellen genau
die Zahl erzeugt wird, die man addieren muss, wenn man in allen Stellen eine 1 haben will -
bzw. die grösste Zahl mit der gegebenen Stellenanzahl.
Wozu das gut ist?
Wenn ich im dezimalen System von einer 3 eine 7 abziehen will, dann kann ich diese Operation
auch vollziehen, indem ich die 3 von 9 abziehe (der grösstmöglichen Dezimalziffer mit einer
Stelle), das Resultat um 1 erhöhe, mit der 7 addiere und den Übertrag, nämlich die führende 1
beim Resultat 14 ignoriere bzw. als Minuszeichen nehme.
Diese Vorgehensweise, die im dezimalen System keineswegs zu einer Vereinfachung führt,
funktioniert im dualen System bestens, weil das Abziehen der 3 von der 9 hier durch eine
Invertierung ersetzt werden kann (3+6=grösste Zahl bei gegebener Stellenzahl)
- einer einfachen logischen Operation.
Das Resultat einer Invertierung wird "Einerkomplement" genannt, mit dem man ebenfalls
subtrahieren kann. Dabei müssen Minuend und Resultat invertiert werden.
Gebräuchlich ist aber die Subtraktion mit "Zweierkomplement", was ein inkrementiertes
"Einerkomplement" ist.
In diesem Fall muss nur der Subtrahend invertiert werden und die höchste Stelle ist bei
negativen Ergebnissen immer 1. Sie muss dann ( und nur dann ) invertiert werden, um auf die
Ziffer zu kommen, die die negative Zahl darstellt - mit Minuszeichen.
Ein Beispiel macht das schnell klar:
Wir wollen von 00000100, dem Minuenden, 00001011, den Subtrahenden abziehen...
Durch invertieren des Subtrahenden wird aus 00001011 das Einerkomplement 11110100
Das Einerkomplement des Subtrahenden wird durch inkrementieren zum Zweierkomplement 11110101
Addition dieser Operanden:
00000100 (Minuend)
11110101 (+Zweierkomplement des Subtrahenden)
_________
11111001 (Resultat)
Diese Methode erlaubt auch negative Zahlen darzustellen, obwohl es ja keinen einstelligen
Wert gibt, der dafür übrig wäre; L ist 0 und H ist 1 , "Minus" ist aber auch H! Als Minuszeichen
wird es nur erkannt, weil es in der höchsten Stelle steht, und das auch nur, weil in der CPU
eine logische Verknüpfung diese Stelle zu einer besonderen macht.
Zu merken ist also, dass solche negativen Dualziffern "grösser" sind als positive. Die 1 in
der höchsten Stelle wird das "Signbit" genannt. Mit solcher Art von positiven und negativen
Zahlen lässt sich ganz wie gewohnt rechnen. Man muss das Vorzeichen während der Rechnung
garnicht beachten, muss nur beachten, dass die höchste Stelle, das "MSB"(Most significant Bit)
für das Vorzeichen (="Signbit") reserviert ist! Die Operanden müssen also ein Bit weniger breit
sein als das Register fassen kann, das damit umgeht. Die Operanden sind also nur halb so gross
wie die eigentlich mögliche, grösste Zahl bei gegebener Registerbreite.
Schliesslich muss bei negativen Resultaten ebenfalls das Zweierkomplement gebildet werden, um auf
den wahren Betrag zu kommen, wie er im Dezimalsystem benutzt wird- z.B. -7 in der obigen Rechnung.
Aus dem obigen Resultat mit gesetztem Signbit wird dann 110 +1 =111
Diese Umwandlung muss aber nur einmal am Ende eine langen Kette stehen...
Da in einem Computer, wie oben bereits angedeutet, nur Werte mit einer bestimmten Zahl
von Stellen verarbeitet werden, erscheinen sowohl der Übertrag wie das Signbit immer in einer
bestimmten Stelle. Zahlen können deshalb in einem Rechengang auch nur bis zu einer bestimmten
Stellenzahl, der "Genauigkeit" verarbeitet werden. Für grössere Genauigkeit muss ein
Rechengang in mehrere Teile zerlegt werden. Deshalb geht grosse Genauigkeit immer auf
Kosten der Geschwindigkeit!
So wie im dezimalen System die Multiplikation aus Additionen zusammengesetzt wird, wird
auch im dualen System verfahren. Hier kommt aber noch eine Operation ins Spiel, die man
in der Schule ebensowenig wie das Invertieren kennenlernt, die in der Welt des Computers
aber ebenfalls von zentraler Bedeutung ist: das "schieben" ("shift")
Wenn man im dezimalen System um eine Stelle nach links schiebt, indem man eine 0 anhängt,
dann hat man die geschobene Zahl mit 10 multipliziert. Auch in allen anderen Ziffernsystemen
kann man das machen. Das Schieben nach links führt zur Multiplikation mit der jeweiligen
Basis des Systems - also 8 bei Oktalziffern, 16 bei Hexadezimalziffern und schliesslich 2
bei Dualziffern.
Das sollte man sich gut merken, weil Schieben um eine Stelle sehr viel schneller abläuft,
als die Multiplikation mit 2 ! Schieben geht in einem Takt, Multiplizieren ist auch dann,
wenn eine CPU oder ein Arithmetikbaustein ein Kommando dafür erlaubt, ein kleines
Stück Programm, das mehrere Takte zur Ausführung braucht.
So wie man mit Links-Schieben multiplizieren kann, kann man mit Rechts-Schieben dividieren.
Bei beiden Operationen wird ein aus dem Binärwert (mit der vielfachen Stellenzahl von 8 Bit)
heraus geschobenes Bit als Übertrags-Bit dargestellt. Damit ist in jeder CPU auch eine
Verwertung des Restes einer Division möglich, was weiter unten genauer abgehandelt wird.
Will man bei Multiplikation oder Division grosse Genauigkeit, also viele wohl definierte
Stellen, dann kann man jedenfalls keine einzelnen Kommandos benutzen, sondern muss ein
kleines Stück Programm schreiben, in dem mit Schiebe- und Addier- bzw. Subtrahierkommandos
die gewünschte Multiplikation oder Division erledigt wird. Unten gehe ich genauer darauf ein,
kann hier aber schon sagen, dass wegen der dualen Ziffern beide Operationen deutlich
einfacher werden. Beim Multiplizieren ist je Stelle im Multiplikator nur der geschobene
Multiplikand zum Resultat zu addieren oder nicht. Beim Dividieren ist je Resultatstelle
nur festzustellen, ob der Divisor sich vom Dividenden bzw. dem Rest abziehen lässt oder
nicht.
Die Arithmetik macht nur einen besonderen Ausdruck für das Minuszeichen nötig.
Das Komma ist, wie jeder weiss, ebenfalls sehr wichtig. Aber auch Exponenten und Mantissen
oder Real- und Imaginär-Teil von komplexen Zahlen möchte man gerne binär ausdrücken können.
Man kann! - selbstverständlich im Rahmen einer logischen Schaltung mit besonderen
Stellenbewertungen und Binärwerten. Solche Rechenwerke nennt man dann "FPU", was "Floating
Point Unit" abkürzt und mit "Fliesskommarechner" oder "Gleitkommarechner" übersetzt wird.
Weil solche "Formate" nicht mehr so zwingend wie das Sign-Bit hergeleitet werden können,
wurde auch schon jede Variante irgendwo realisiert. Ich verweise deshalb auf Spezialliteratur
und weiter unten abgehandelte Assemblerkommandos, sofern es um den AT-PC geht.
Ich habe, obwohl ich hier Arithmetik abgehandelt habe, schon zwei Operationen vorgestellt,
die jedenfalls nicht unter den gängigen Arithmetik-Begriff fallen. Und ich habe
ja auch schon gesagt, dass alle Arithmetik in einer CPU tatsächlich mit logischen Schaltungen
erledigt wird. Es ist also an der Zeit, die logischen Operationen vorzustellen.
BINÄRWERT-LOGIK:
Es müssen sehr streng zwei Arten von logischen Operationen unterschieden werden. Die
eine erscheint in der Form "H OR L" , die andere in der Form "Sonne oder Regen". Obgleich
letzlich beide Arten der Oderierung mit einer logischen Schaltung resultiert werden, sind
es völlig verschiedene Wege, auf denen aus den Argumenten der Oderierung resultiert wird.
Zuerst werde ich mit Binärwerten umgehen.
Wie ich oben gezeigt habe, würden NORierung und Invertierung genügen, um alle logischen
Bedürfnisse zu stillen. Man muss das im Hinterkopf haben, wenn man kompliziertere
Zusammenhänge logeln will.
Der normale Bedarf ist aber mit AND, OR, XOR und Invertierung erfüllt, und deshalb sind diese
Bool'schen Operatoren in den meisten CPUs festverdrahtet verfügbar. Es werden dabei zwei
Binärwerte immer in den jeweils entsprechenden Stellen verknüpft - Stelle 1 des einen Operanden
mit der Stelle 1 des anderen usw....
Argumente sind also (wie oben) Bits, die je nach Stellenzahl einen Operanden bilden, z.B.
ein Byte (Operand ist lateinisch "der zu behandelnde", Operator "der Behandler").
Der 'normale' Bedarf ist das "setzen"("set") oder "rücksetzen"("reset") und das "vergleichen"
("compare") von Bits. Dabei wird von "setzen" gesprochen, wenn eine Stelle den logischen Wert
H annehmen soll, und rückgesetzt wird sie dann zu L ( L setzt man also nicht!). Will man in einem
Binärwert z.B. in der 3.Stelle H setzen, dann verknüpft man diesen Binärwert mit einem
anderen, der das H nur in seiner 3.Stelle enthält. Diesen anderen Binärwert nennt man "Maske"
("mask").Die Operation 'Binärwert OR Maske' resultiert dann in der 3.Stelle H, in allen
anderen Stellen erscheinen nur dann die Werte H, wenn sie im maskierten Binärwert enthalten
waren.
Rücksetzen kann man dieses H in der 3.Stelle, indem man dieselbe Maske invertiert und Maske
und Binärwert mit AND verknüpft.
Weil diese Art zu rechnen sehr wichtig ist bei der Erzeugung von "Steuerworten" oder Erfassung
von "Statusworten", will ich das Beispiel noch sinnfälliger hinschreiben:
LHLLHLLL In diesem Steuerwort soll das 3.Bit gesetzt werden,...
LLLLLHLL ...wozu man diese Maske braucht
LHLLHHLL ...und woraus sich dieses Resultat einer OR-Verknüpfung von Steuerwort und Maske ergibt
HHHHHLHH Invertiert man die Maske und verknüpft damit das Resultat mittels AND-Verknüpfung...
LHLLHLLL ...dann erhält man als Resultat den ursprünglichen Wert
Alles Klar?
Die Verknüpfung mit XOR ist nur eine von zwei Möglichkeiten, zwei Werte auf Übereinstimmung
zu prüfen. Deshalb wird der Ausdruck "compare", den ich oben eingeführt habe, üblicherweise
nicht synonym für die XORierung, sondern eine Subtraktion mit Einerkomplement verwendet, bei
der nur der Übertrag ("Carry") resultiert wird. Während diese besondere Operation also kein
Resultat erzeugt, das wieder zum Operanden werden könnte, wird bei der XORierung wie bei AND
und OR ein Binärwert resultiert. Man kann nämlich mit dieser Funktion Bitweise umschalten
("switch"):
LHLLHLLL In diesem Steuerwort soll das 3.Bit umgeschaltet werden
LLLLLHLL ...wozu man diese Maske braucht...
LHLLHHLL ...und dieses Resultat mittels XOR-Verknüpfung erhält
Beim Zurückschalten wird nun aber mit genau derselben Maske gearbeitet!
Es zeigt sich, dass ein H in der Maske wie ein Inverter in dieser Stelle wirkt. Ein L dagegen
lässt die jeweilige Stelle unverändert. Das ist praktisch z.B. für die Erzeugung einer
Cursormarke in Grafik.
Beim Vergleichen mittels XOR macht man sich aber die oben genannte Eigenschaft dieser
Funktion zunutze, dass gleiche Argumente stets zu L resultiert werden. Eine Funktion,
die zu prüfen gestattet, dass das Resultat in allen Stellen L zeigt, ist also eine Funktion,
die Gleichheit von zwei XORierten Operanden zu prüfen gestattet.
Schliesslich kann man den Wert in einem Register zu 0 machen, indem man den Wert mit sich
selbst xoriert - gern benutzt beim Programmieren mit Assembler.
Wenn wir nun die eingangs genannte Funktion "Sonne oder Regen" betrachten, ist zunächst
mal klar, dass wir hier nicht Sonne bitweise mit Regen oderieren wollen, sondern etwas ganz
anderes meinen!
Wir meinen die Feststellung, ob die mit dem 'oder' verknüpften Zustände zunächst mal
zutreffen oder nicht und dann erst alternativ eine Konsequenz haben sollen. Es handelt sich
also bei den Werten um mehrstellige Binärwerte aus einer Menge von mehr als zwei. Wir können
uns z.B. noch eine "Bewölkung" vorstellen. Bei einer solchen Operation wird also ein
fraglicher Binärwert "Wetter" zunächst auf Gleichheit mit der Maske "Sonne", dann
auf Gleichheit mit der Maske "Regen" geprüft. Ist also Wetter=Regen oder Wetter=Sonne, dann
werden weitere Schritte getan - z.B. "vor die Türe".
Weil dieses Beispiel auch so gesehen werden kann, dass mit dem Ausdruck "Sonne" das Gegenteil
von "Regen" - der algebraische NICHT-Regen - gemeint ist, also nicht weitere Werte wie eine
"Bewölkung" existieren, wäre keine Alternative vorhanden, sondern grundsätzliches
Zutreffen! Dies Beispiel zeigt, wie fatal unklare oder falsche Bedingungen wirken können.
Damit sind alle Binär-Operationen erklärt, die in einem Computer benutzt werden. Alle
Rechenvorschriften ("Algorithmus"/plural:"Algorithmen"), die beispielsweise in höheren
Programmiersprachen oder als Arithmetik-Befehle für Programmierer verfügbar sind, sind aus
diesen Operationen zusammengesetzt.
CPU UND OPCODE:
Es gibt nur zwei weitere Begriffe, die ich zum Verständnis einer CPU geben muss. Der erste
ist der Begriff "Adresse".
Ich erweitere die Schaltung des oben gezeigten NOR um weitere Eingänge und vereinfache das
Bild, indem ich für einen Transistor nur "Inverter" schreibe. Dabei sei das Gate am
Wortanfang, dem "I" und das Drain am Wortende. Alle Wortenden sollen kurzgeschlossen sein
und an einem Widerstand liegen:
Stelle 1-------------Inverter
Stelle 2-------------Inverter
Stelle 3-------------Inverter
Stelle 4-------------Inverter-------Widerstand------------------------5 Volt
Diese Schaltung ist ein NOR mit 4 Eingängen, in der gezeigten Form mit Widerstand wird sie
auch "wired NOR" (verdrahtetes NICHT-ODER) genannt. Die Ausgänge nennt man "Open Drain" bei
MOS-Transistoren oder "Open Collector" bei Bipolaren Transistoren, wenn der Widerstand als
separates Teil verlötet werden muss.
Hier ist wichtig, dass diese Schaltung nur bei Anliegen eines einzigen Wertes am
Arbeitswiderstand H resultiert - wenn in allen Stellen L Argument ist. Es ist also eine
Schaltung, mit der man einen einzigen Binärwert von 15 anderen 4-stelligen Binärwerten
unterscheiden kann.
Will man einen anderen Binärwert unter den 16 möglichen herauspicken, muss die Schaltung
um maximal einen weiteren Inverter vor einem Eingang erweitert werden.
Die folgende Schaltung beispielsweise resultiert HLLH zu H:
Stelle 1--Inverter---Inverter
Stelle 2-------------Inverter
Stelle 3-------------Inverter
Stelle 4--Inverter---Inverter-------Widerstand------------------------5 Volt
Diese Schaltung, die natürlich um beliebig viele Eingänge erweitert werden kann, nennt man
einen "Adressdecoder". Man kann den Ausgang mit einem und nur einem Binärwert auf H bringen
- und damit z.B. den Strom für einen Türöffner einschalten. Adressen sind also Binärwerte,
die bei Anliegen an einer bestimmten Schaltung wie ein Schlüssel funktionieren.
Der zweite der wichtigen Begriffe ist "Register".
Ein Register ist eine besondere Verschaltung einiger NAND oder NOR mit einer besonderen
Eigenschaft: Der Ausgang kann über Steuer-Eingänge veranlasst den zuletzt am "Dateneingang"
anliegenden Wert auch dann noch zeigen, wenn am Eingang längst ein anderer Wert anliegt.
Diese Schaltung speichert.
Ein Register ist ein Speicher für Binärwerte mit einer bestimmten Anzahl von Stellen.
Weil Register unter vielen anderen ohne Adresse sinnlos sind, können wir auch von Adressen
sprechen, wenn wir tatsächlich das Register meinen. Wir müssen nur sehr streng unterscheiden,
was Adresse und was Wert unter einer Adresse ist!
Eine CPU ist nur wenig mehr als eine Anzahl solcher Register, die bei Anlegen ihrer Adresse
Binärwerte lesen oder schreiben. Im Gegensatz zu einem Speicherbaustein, der ausser ein paar
Steuereingängen tatsächlich nur Anschlüsse für Adressen und Binärwerte ("Daten") hat, sind
die Register einer CPU nicht nur mit Datenleitungen verknüpft, sondern auch mit logischen
Verknüpfungen, also NORierungen und Invertierungen verbunden. Deshalb ist das ganze nicht
mehr allzu einfach.
Man stelle sich vor, man könnte unter einer bestimmten Telefonnummer (die auch eine Adresse
ist) einen Minuenden abgeben, unter einer weiteren einen Subtrahenden, unter einer
dritten könnte man das Resultat hören und unter einer vierten könnte man erfahren, ob es
ein Signbit bei der Subtraktion gegeben habe - dann hat man schon ein sehr klares Bild
von dem, was eine ALU (Arithmetik-Logical-Unit) ist.
Will man also eine der in einer CPU verfügbaren Funktionen auf Werte anwenden, wird der
Vorgang nicht mit kommandieren dieser Funktion, sondern adressieren bestimmter Register
ausgeführt, entsprechend dem Anwählen bestimmter Telefonnummern. Natürlich sind es ganz
bestimmte Adresswerte, die eine bestimmte logische oder arithmetische Funktion auf bestimmte
Werte wirken lassen. Deshalb kann man eine bestimmte Adressierung auch ein
"Additions-Kommando" nennen.
Ausser Registern, die durch logische Verknüpfungen zu einer ALU werden, enthält eine CPU
noch andere Register, die unverzichtbar sind, wenn aus einem Speicher Werte in die Register
der ALU kopiert werden sollen. Dies sind Register, die die aktuellen Adressen speichern.
Ich werde sie im weiteren "Adressgeber" oder "Adressgeber-Register" nennen.
Das wichtigste Register dieser Art ist der Programmzähler (program counter / kurz: pc).
Dieses Register ist mit einer Additionsschaltung verknüpft, die die Adresse nach Gebrauch
um bestimmte Werte erhöht oder erniedrigt, um den nächsten Programmcode im Programmspeicher
zu adressieren.
Tatsächlich ist dieser Ablauf das einzige, was eine CPU tatsächlich tut. Und damit das völlig
klar wird, schreibe ich als "Programm", was getan werden muss:
1. adressiere mit dem Programmzähler im Speicher
2. interpretiere den gelesenen Wert als Kommando
3. inkrementiere die Adresse im Programmzähler und mache mit 1. weiter...
Aber dem aufmerksamen Leser ist natürlich nicht entgangen, dass sich beim 2. Schritt in dieser
"Schleife" die Welt des Computers auftut. Denn was ich "Kommando" genannt habe, sind die Adressen
innerhalb der CPU, wo Werte Bedeutungen erhalten - also z.B. zum Minuenden, Subtrahenden oder
Resultat werden oder auch zu einer Adresse.
Das eben vorgestellte "Mikro-Programm" führt offenbar dazu, dass eine Folge von Binärwerten
als Kommando interpretiert wird, deren Abfolge unabänderlich ist. Tatsächlich wird diese Schleife
sogar meistens von einer Uhr getaktet, womit auch die Zeitdauer unabänderlich wird. Man spricht
deshalb auch von einem Zyklus - genauer: "CPU-cycle". In einem solchen Zyklus wird also genau ein
Kommando in die CPU kopiert und interpretiert. Weil dieses Kommando meistens mehr als nur ein Byte
lang sein muss, um brauchbar zu sein, enthält das erste Byte als wichtigste Bedeutung die einer
Schrittzahl, die entscheidet, wie viele weitere Zyklen bis zur Komplettierung erforderlich sind.
Dieses komplette Kommando, das also alle aktuell nötigen Adressierungen von Bedeutungen enthält,
nennt man einen "Opcode", was die Abkürzung von "Operation code" ist.
Man muss sich aber fragen, wie man mit dieser einfältigen Vorgehensweise zu mehr kommen kann als
der Bewegung eines Zeigers in einer Uhr. Schon die Ausführung eines Kochrezeptes (eines
alltäglichen Programmes) erfordert, dass nicht nur alles, was kochen soll, hintereinander in
den Topf geschmissen wird, sondern dass z.B. vor einem weiteren Schritt abgewartet und
festgestellt wird, ob das Wasser kocht.
Ich habe bereits einen Fall vorgeführt, wo ein Ablauf keineswegs geradeaus weiter gehen kann -
wenn nämlich ein negatives Resultat anders als ein positives verwertet werden muss. Dabei habe
ich festgestellt, dass die Unterscheidung mit einem einzigen Bit in einer bestimmten Stelle
erfolgen kann, dem Signbit.
Nachdem ich den Begriff der Adresse gegeben habe, kann ich nun zu dem kommen, was eine CPU
macht, wenn verschiedene Gegebenheiten zu verschiedenen Konsequenzen führen sollen.
Eine Konsequenz aus dem Zustand während eines CPU-Zyklus habe ich bereits vorgestellt:
Die Programmausführung geht mit dem nächsten vom Programmzähler adressierten Kommando weiter.
Wollen wir eine weitere Konsequenz, muss also jedenfalls eine andere Adresse ein anderes
Kommando adressieren. Es muss also ein weiteres Adressgeber-Register vorhanden sein, das ebenso
wie der Programmzähler mit der CPU-Funktion verknüpft ist, den gelesenen Binärwert als Kommando
zu interpretieren.
Wenn die Konsequenz aus dem Erscheinen eines Signbit gezogen werden soll, ist also nur das
gleiche Signbit mal invertiert und mal nicht invertiert in die Adressierung der beiden
Programm-Adressgeber einzufügen und schon ist alles geschehen! Ich will das noch als Schaltung
zeigen, weil ohne das Verständnis dieser Art von Logik nicht verstanden werden kann, wie in
einer CPU Konsequenzen aus Zuständen abgeleitet werden. Dabei benutze ich das oben schon
verwendete Bild der Adressdecoder mit einer anderen Bedeutung der Eingänge:
-----------------------------Inverter
-----------------------------Inverter
-----------------------------Inverter
Signbit ---------------------Inverter-------Einschalten des Programmzählers
Signbit ----------Inverter---Inverter-------Einschalten des weiteren Programm-Adressgebers
-----------------------------Inverter
-----------------------------Inverter
-----------------------------Inverter
Diese beiden Adressdecoder adressieren Register innerhalb der CPU.
Wie schon gesagt, ist mehr als ein Adressgeber an der Ausführung eines Kommandos beteiligt!
Im Falle der Subtraktion sind die Register für die beiden Operanden und das Resultat
einzuschalten. Und ausserdem ist das Signbit als Teil der Adressierung von Programm-Adressgebern
einzuschalten! Die Schaltung zeigt also nur den wesentlichen Teil einer Adressierung,
die ein Opcode erzeugt.
So wie hier kann die Abfolge von Kommandos noch durch andere Bits umgeschaltet werden, die Teil
eines Resultats in der ALU sind. Während das Signbit nur beim Rechnen mit negativen Zahlen von
Bedeutung ist, sind vor allem zwei einstellige Resultate von elementarer Bedeutung für den
Ablauf von Programmen:
-> Alle Stellen des Resultates sind =L (also eine "0")
-> Ein Übertrag ("carry") erscheint nach einer Addition
Solche einstelligen Resultate nennt man "flag". Die eben vorgestellte Konsequenz einer anderen
Adresse zur Adressierung des nächsten Opcode nennt man "Sprung" ("jump") oder "Verzweigung"
("branch") - Auch wenn manchmal ein Unterschied bei Verwendung der beiden Ausdrücke gemacht
wird, kommt der tatsächlich nicht vor und bezieht sich nur auf Varianten der Adressherleitung.
Und es gibt natürlich mehr als eine Möglichkeit, die Adresse zu erzeugen, die im zweiten
Programm-Adressgeber steht!
Ausserdem kann man vor dem Sprung die nächste Programmadresse in einem bestimmten Register
speichern, das bei einem weiteren besonderen Sprung als Adressgeber benutzt wird.
Das zwischen diesen besonderen Sprungkommandos ausgeführte Programmstück wird also in den
normalen Programmablauf eingeschleift. Es kann auch unverändert an anderen Stellen des
Programms eingeschleift werden und erspart damit das wiederholte Schreiben des immer gleichen
Ablaufes. Solche wieder verwendbaren Programmstücke nennt man "Prozeduren" ("procedure"),
das hinführende Sprungkommando ist ein "Prozeduraufruf" ("call procedure") und der Sprung
zurück mit gespeicherter Adresse ist "return from procedure".
Der weitaus grösste Teil von Programmen besteht aus solchen Prozeduren. Und es gibt natürlich
mehr als eine Möglichkeit, den Anlauf zum Prozedursprung zu gestalten!
Tatsächlich entsteht die ganze Mannigfaltigkeit von Abläufen (Programmen) in einem Computer
allein durch die Herleitung von Adressen in Adressgebern (auch im Datenbereich) und die
Verzweigung aufgrund von einstelligen Resultaten.
Nüchtern betrachtet hat ein Computer auch keinen Speicher, den man wie im englischen
gebräuchlich "memory"=Gedächtnis nennen könnte! Das ganze Gedächtnis besteht aus den paar
flags in der CPU und dem Wert im Programmzähler! Nur das verknüpft den Zustand im letzten Zyklus
mit dem augenblicklichen. Alles andere muss erst noch adressiert werden und ist infolge dessen
im gleichen Zyklus noch hinter dem Horizont bzw. aus der Welt.
Nur das muss man verstanden haben, um das Wesen eines Computers zu verstehen! Nichts anderes
macht den Unterschied zu den Maschinen, die vor dem Computerzeitalter in Gebrauch waren, um zu
rechnen oder Abläufe zu steuern wie das Weben von Mustern in Stoff.
Es bleibt nur noch eine weitere Art einstelliger Kriterien für Verzweigungen zu erwähnen, die
zwar wie ein flag wirken, aber "Unterbrechung" ("interrupt") genannt werden, weil sie nicht
in der ALU gesetzt werden, sondern ausserhalb der CPU.
Der wichtigste Interrupt ist der NMI (="Non Maskable Interrupt"), der als Eingang in jeder CPU
vorkommt. Der Wert an diesem Eingang schaltet zwingend und nicht abschaltbar ("maskierbar")
einen ganz bestimmten Adressgeber in der CPU ein. Wie wichtig das ist, wird aber erst nach
weiteren Erklärungen klar. Dann wird auch gleich klar, wozu man mehr als einen Interrupt braucht,
und warum es gut tut, solche Unterbrechungen auch mal unmöglich zu machen, zu "maskieren".
ADRESSIERUNG UNG ADRESSBEREICHE:
Ich habe bis jetzt vor allem die Adressen abgehandelt, die in einer CPU und der ALU zu finden
sind, und die das sind, wo ein Programm seine Bedeutung erhält, "ausgeführt" wird.
Dabei habe ich gezeigt, welch überragende Bedeutung Adressgeber haben.
Ich habe schon angedeutet, dass neben dem Programmzähler weitere Adressgeber mit der gleichen
Schaltung verknüpft sind, die ab einer Adresse gelesenen Binärwerte als Opcode zu
interpretieren. Dieser Opcode besteht zwar auch aus einer Anzahl von Adressen, adressiert damit
aber offenbar in einem anderen Bereich als der Programmzähler!
Ich werde nun unterschiedliche Adressierungsarten abhandeln und die Unterschiede zwischen
Adressierungen, die dadurch zustande kommen, dass sie bestimmte Bereiche betreffen.
Den Ausdruck Adress-"Raum" werde ich dabei nicht verwenden. Er ist zwar beliebt, aber völlig
daneben, weil Adressen nur eine einzige in Bits gequantelte Dimension haben, die Bytes oder
vielfache davon betreffen. Es gibt insbesondere keine räumliche Nähe oder Ferne - auch "weite"
Sprünge führen nur zum nächsten Kommando und im nächsten Zyklus!
Wir haben festgestellt, dass mit einer Adresse stets der Wert im adressierten Register
verfügbar wird. Was aber ist Lesen in einem Computer?
Tatsächlich wird der adressierte Wert nicht aus dem Register entfernt !
Er wird lediglich kopiert, undzwar unter einer weiteren Adresse, die zu gleicher Zeit
anliegen muss. Es gibt also stets ein schreibendes und ein lesendes Register und als
Resultat eine Übereinstimmung der Binärwerte in beiden Registern. Der Ausgang des
Schreibers wird beim lesen mit dem Eingang des Lesers kurz geschlossen (verbunden) und
ausserdem muss das lesende Register mit einer Steuerleitung von "speichern" auf "lesen"
umgeschaltet werden.
Diese Steuerleitung ist ein Teil der Adressierung (R/W-Bit) und kann unterschiedlich mit der
Ablaufsteuerung für den CPU-cycle verknüpft sein.
Nach diesem Vorgang, den auch ich im weiteren einen "Transfer" oder eine "Zuweisung" nennen
werde, sind deshalb zwar nach wie vor zwei Adressen aber nur ein Wert unter beiden vorhanden!
Weil die ausdrückliche Programmierung von Leser und Schreiber längeren Opcode, mehr Zeit zur
Ausführung und mehr Leitungen für Adressen erfordert, sind bei der Konstruktion von
Computern weniger aus logischen als aus wirtschaftlichen Gründen gewisse Adressen festgelegt
worden, die ohne besondere Anweisung, also "implizit" verfügbar sind - die wichtigste ist
die im Programmzähler. Ein anderer Weg wurde gewählt für den Transfer zwischen Registern,
die nur Werte speichern.
Ich habe bereits erwähnt, dass in einem Computer eine Anzahl von Registern vorhanden ist, wo
Opcode zur Ausführung bereit gespeichert ist. Ich nenne die zugehörige Anzahl von Adressen
im weiteren den "Programmbereich". In den meisten Computern ist dieser Bereich in dem
gleichen Adressbereich wie der "Datenbereich" angesiedelt. In dieser "von Neumann Struktur"
gibt es nur zwei Busse (Menge von Leitungen für ein Datenwort) für Daten oder Programmcode
einerseits und Adressen andererseits. Beide Busse sind einerseits mit Anschlüssen an der CPU,
andererseits mit Anschlüssen an Speicherbausteinen verbunden. Diese Speicherbausteine werde
ich im weiteren den "Arbeitsspeicher" nennen.
Weil es für den Arbeitspeicher nur einen Adressbus gibt, können Schreiber und Leser nicht
zu gleicher Zeit adressiert werden. Eine zweite Adresse kann aber ein Register in der CPU
adressieren. Deshalb geht jeder Transfer im Arbeitsspeicher grundsätzlich durch die CPU !
Der Opcode kann kurz ausfallen, weil die Adressen in der CPU meist mit 1-3 Byte für
Operation, Schreiber und Leser vollständig definiert werden können und nur für Festwerte und
Adressen im Arbeitsspeicher bis zu 72 Bit angefügt werden müssen.
Der Arbeitsspeicher zerfällt grundsätzlich in zwei Teile, weil eine gewisse Menge Opcode sofort
nach dem Einschalten durch die CPU adressierbar sein muss. Für diesen Programmcode hat sich
der Name "BIOS" (="Basic In Out System") eingebürgert.
Hier ist zunächst wichtig, dass es nicht nur diesen Teilbereich gibt, sondern
auch je nach Konstruktion weitere Teilbereiche, die mehr oder weniger unveränderbar sind
und durch ihre erste Adresse, die Basis-Adresse gekennzeichnet sind. Alle Teilbereiche
werden jedoch über den gleichen Adressbus adressiert!
Wir bleiben aber zunächst beim wichtigsten Teilbereich im Arbeitsspeicher, dem BIOS.
Damit es beim Ausschalten nicht verloren geht, ist es "eingebrannt". Dieser Ausdruck kommt
von der ursprünglich verwendeten Art der Register, mit denen dieser Teilbereich technisch
realisiert wurde. Man konnte die Bits durch Aufschmelzen von Leitungen auf dem Chip,
also gezielte Kurzschlüsse, tatsächlich einbrennen. Heute wird weniger rabiat ein
Häufchen Ladung in die gewünschte Ecke verschoben, wo sie H oder L bedeutet (EEPROM).
Das BIOS beginnt mit einem ersten Opcode auf einer bestimmten Adresse, die ebenfalls nach
dem Einschalten sofort vorhanden ist. Manchmal ist es die kleinste, also =0, oft aber (und
jedenfalls im AT-PC) die grösste auf dem Adressbus darstellbare. Allerdings sind im AT-PC
zunächst weniger Adress-Bits als möglich verfügbar.
Wir sehen also schon mal ein Programm vor uns, das im wesentlichen nicht verzichtbar ist
und bereits die CPU beschäftigt hat, bevor auch nur ein weiteres Programm ausgeführt werden
kann. Und weil es auf einer bestimmten Adresse beginnt, steht auch jeder Opcode und jede
Konstante für irgendwelche Vorbesetzungen auf einer ganz bestimmten Adresse. Wer solch ein
Programm schreibt, kann also jedem Opcode, der nach einem Sprung ausgeführt werden soll,
eine Adresse zuordnen, die als Teil des Sprungbefehls auf den Adressbus gelegt wird.
Ganz offenbar funktioniert das aber nicht mehr, wenn dieses Programm auf einer beliebigen
anderen Adresse beginnt!
Wir müssen also feststellen, dass Programme nur mit einer immer gleichen Anfangsadresse
gestartet werden können.
Wir können uns zwar ausmalen, ein BIOS zu programmieren, das alle gewünschten Zustände im
Computer herstellt. Aber als diese Idee noch verlockend war, war der Adressbereich im
Arbeitsspeicher so beschränkt, dass sie nicht realisiert werden konnte. Das eine Programm
für alles musste allein deshalb schon in Teile zerlegt werden. Da diese Teile keineswegs
alle gleich gross gemacht werden können, hat man auf jeden Fall besondere Anfangsadressen
zu verwenden, deren Herleitung und Verwaltung ein besonderes Ding sind. Erst weiter unten
werde ich es abhandeln, weil erst in einem anderen Zusammenhang klar gemacht werden kann,
dass man diese Aufgabe nicht nur verschieden, sondern auch so lösen kann, dass der Bedarf
an Speicherplatz immer die technischen Gegebenheiten übertreffen kann.
Zunächst stelle ich Adressierungsweisen und Adressbereiche vor, die den Umgang mit immer
grösser werdenden Wertemengen ohne besondere Neuerungen möglich machen.
Dabei spielt ein Register am Adressbus eine zentrale Rolle, das ich im weiteren das
"Index-Register" nenne. Weil einige Varianten möglich, nötig oder auch nur sinnvoll sind,
bleibe ich bei diesem Ausdruck auch dann, wenn andere Ausdrücke üblich sind.
Eine Form des Indexregisters habe ich bereits vorgestellt für den Fall einer Verzweigung
in Programmen. Diese Form enthält eine Definition aller Adressbits, die aus einem Opcode
dorthin kopiert wurden. Damit kann ein mit flags bedingter Sprung gemacht werden, der
natürlich auch ohne Beachtung von flags als unbedingter Sprung getan werden kann. Das
wäre dann keine Verzweigung mehr, sondern die Fortsetzung des Programmes mit einem
anderen Adressinkrement als dem in den Programmzähler eingebauten. So eine Möglichkeit
ist unverzichtbar, weil ein Adressbereich nur eine Dimension hat, folglich die "Äste"
einer Verzweigung Abschnitte eines Stammes (,um im Bild zu bleiben...)sind. Irgendwann
muss man vom Ast springen und kann nicht geradeaus weitermachen, weil dort z.B. der
gerade verschmähte andere "Ast" beginnt.
Auch beim Prozeduraufruf wird die im Opcode enthalte Sprungzieladresse über ein
Indexregister auf den Adressbus gelegt. Für den besonderen Rücksprung kann ein weiteres
besonderes Indexregister die Rücksprungadresse speichern, die aus der Adresse des
Opcodes für den Prozeduraufruf abgeleitet wird, undzwar während der Ausführung von
der CPU. Ganz allgemein wird jedoch zur Speicherung der Rücksprungadressen von
Prozeduren ein Teilbereich im Arbeitsspeicher verwendet, den man "stack" (=Stapel) nennt.
Während bei der zuerst abgehandelten Verwendung des Indexregisters die Adresse aus dem
Opcode gelesen wurde, wird die Sache eine völlig andere, wenn die Adresse auch aus dem
Datenbereich gelesen werden kann!
Dabei gilt es wieder zu unterscheiden, ob die Indexadresse (=Adresse im Index-Register)
mit einem besonderen Prozedur-Rücksprung vom Stack gelesen wird oder aus einem anderen
Teilbereich des Arbeitsspeichers.
Die zweite Variante ist der "indizierte" Sprung bzw. Prozeduraufruf. Dann dient eine
beliebige andere Speicheradresse als Behälter der Adresse, auf die gesprungen werden
soll. Solche unter bestimmter Adresse liegenden Adressen können während des Programm-
Ablaufes umdefiniert werden. Unten betrachte ich das genauer...
Eine sehr wichtige dritte Variante ist ein Interrupt, der ähnlich wie ein indizierter
Prozeduraufruf abläuft, aber bedingt ist durch den Zustand am IRQ-Eingang der CPU
(Interrupt ReQuest). Allerdings kann diesen Eingang auch die CPU selbst erregen, wenn
ein bestimmter Opcode im Programm ist.
Wesentlich ist, dass beim Interrupt-Aufruf die Adressierung von einer Interrupt-Nummer
abgeleitet wird, die meist auf dem Datenbus liegen muss, während der IRQ-Pin erregt wird.
Diese Nummer wird durch Schieben multipliziert zum Offset, der zu einer Basis-Adresse
addiert die Sprungziel-Adresse des "Interrupt handlers" adressiert. Dies ist eine
Prozedur, die oft mit einer besonderen Rücksprunganweisung beendet werden muss.
Die Tabelle mit den Sprungzieladressen ist ein nicht immer verschieblicher Teilbereich
im Arbeitsspeicher. Man spricht dann auch von "Interrupt-Vektoren".
Völlig analog kann man Tansfers zwischen Daten-Registern sowohl mit Adresse im Opcode,
wie mit Adresse in einem Index-Register veranstalten.
Ganz allgemein spricht man dann von "indizierter" Adressierung, wenn ein Index-Register
die Adresse an den Adressbus legt, in das die Adresse aus dem Datenbereich geschrieben
wurde. Im Gegensatz dazu spricht man von "direkter" Adressierung, wenn die Adresse aus
dem Opcode stammt - auch wenn sie tatsächlich ebenfalls mittels einem Index-Register an
den Adressbus gelangt.
Schliesslich ist noch möglich, nur einen Teil der Adresse aus dem Opcode zu entnehmen
oder dort keine auf den Programmanfang bezogene "absolute" sondern auf die Adresse des
Opcodes bezogene relative Adresse zu verwerten. Im zweiten Fall ist ein Addierwerk Teil
des Indexregisters, das auch einfach zur Verwertung negativer, relativer Adressen
aufgerüstet werden kann.
Solch ein Addierwerk kann schliesslich noch so erweitert werden, dass es die Werte in
mehreren Registern der CPU mit einer relativen Adresse im Opcode kombiniert.
Ein Index-Register ist also unverzichtbar, wenn man einen Prozeduraufruf veranstalten will -
auch wenn der Ablauf völlig äquivalent mit Opcodes für Sprünge vollzogen werden kann, ist
die Speicherung einer Rücksprungadresse im Datenbereich unverzichtbar, wenn man innerhalb
einer Prozedur noch eine weitere rufen will. Solche Schachtelungen erwiesen sich schon sehr
früh als Zauberei, mit der man grosse Programme sehr klein kriegen konnte. Nachdem diese
Zauberkraft entdeckt worden war, suchte man noch mehr Zauberkraft mit ausgewachsenen
Indexrechnern. Sie sind heute in jeder CPU mit 32-stelligem Adressbus vorhanden.
Eine bestimmte Variante eines Indexrechners war allerdings schon sehr früh nötig. Es war
der Moment, als entdeckt wurde, dass man eine Möglichkeit brauchte, ein Programm an einer
beliebigen Adresse im Arbeitsspeicher starten zu können.
Wie ich am Beispiel des BIOS erklärt habe, muss ein Programm an einer bestimmten Adresse
beginnen, auf die im weiteren alle Adressangaben im Opcode bezogen werden können.
Andernfalls führt schon der erste Sprung aus dem gewünschten Zustand heraus.
Wie aber soll das gehen, wenn ich ein Programm mal auf Adresse =0 und mal auf einer anderen
Adresse beginnen lassen will?
Die Lösung ist sehr einfach und innerhalb der CPU lösbar:
Statt Adressen aus dem Programm unverändert in das Indexregister zu schreiben, muss jede
Adresse dort noch mit einer Basis-Adresse (der Start-Adresse des Programms) addiert werden,
bevor der Wert an den Adressbus gelangt.
Dann allerdings entsteht eine weitere Aufgabe, die in dem Programm gelöst werden muss, das
das neue Programm startet, indem dorthin gesprungen wird. Das startende Programm muss
die Basis-Adresse des neuen Programms in den Index-Rechner als Addend schreiben. Mit der
gleichen Adresse kann dann auch indiziert gesprungen werden. Dann adressiert der
Programmzähler im neuen Programm. Die Adressen, die im Programm stehen, sind nun nicht mehr
die tatsächlich absoluten im Arbeitsspeicher, sondern nur noch ein Teil des Binärwertes,
der auf dem Adressbus liegt. Letzterer wird die "wahre" oder auch "physikalische", besser
aber "physische" Adresse genannt. Bei den Adressen im Programm spricht man dann auch von
"Offset-Adressen", während "relative Adressen" auf irgendeine Offsetadresse bezogen sind.
Auch wenn die technische Ausführung eines solchen Indexrechners je nach Hersteller einer
CPU verschieden ausgefallen ist - um die Notwendigkeit einer solchen Einheit kam keiner
herum ! In jeder CPU mit 32-stelligem Adressbus ist ein Rechenwerk dieser Art vorhanden.
Und oft wird nicht nur mit einer Basis-Adresse gearbeitet. Im AT-PC und im protected mode
beispielsweise wird mit der Descriptor-Tabellen-Basis-Adresse+Selector (beides in
besonderen CPU-Registern gespeichert) ein 64-stelliger Descriptor adressiert, in dem
die absolute Basis-Adresse steckt (neben anderem), zu der wiederum die Offsetadresse
im Programm addiert wird.
So abwegig das hier erscheint - so raffiniert erlaubt diese unten genauer beschriebene
Adressierung der Basis-Adressen, ein Programm beliebig im Speicher zu verschieben und
dennoch alle aktuell gebildeten Bezüge in anderen Programmen unverändert zu lassen, weil
diese Bezüge über die im Selector enthaltene Offsetadresse entstehen.
Das Konzept der Deskriptoren, die mit dem protected mode eingeführt wurden, wurde sehr
pfiffig gleich noch insofern erweitert, als die damit adressierten Adressbereiche dort
auch mit ihrer Länge und ihrem Zweck definiert werden müssen. Insbesondere gibt es einen
Unterschied zwischen Programm- und Daten-speicher (im ansich gleichen Adressbereich).
Damit darf dieser Modus tatsächlich "protected" (=geschützt) genannt werden. Ein
Betriebssystem kann zuverlässig verhindern, dass Programmsegmente an irgendwelche Daten
vergeben werden! Allerdings haben alle(!) bekannten Betriebssysteme Hintertüren, über die
automatisch etwas zum Programm werden und dann als "Virus" erscheinen kann.
Da solche Hintertüren jedenfalls zusätzlichen Programmcode im Betriebssystem kosten, kann
kein vernünftiger Zweifel bestehen, dass Viren gewollt sind - für ein Geschäft mit
Anti-Virus-Programmen, mindestens aber für die Ausspähung argloser Computernutzer.
Die Notwendigkeit für solchen Aufwand bei Adressen ergibt sich dann, wenn nicht nur
verschiedene Programme mit verschiedenen Basis-Adressen im Arbeitsspeicher liegen sollen,
sondern auch zusammen wirken sollen, als wären sie ein Programm. Dann muss nämlich aus dem
einen Programm mit einem Opcode in ein anderes gesprungen werden können, der keine Adresse
für das Sprungziel enthalten kann! Denn zu der Zeit, zu der das Programm geschrieben
wurde, war nicht bekannt, dass das Sprungziel nun auf Adresse soundso steht.
Das scheinbar unlösbare Problem, die richtige Sprungziel-Adresse in das Programm zu
bringen, kann verschieden gelöst werden (was zu unvereinbaren Welten im Bereich der
Betriebssysteme und Programmiersprachen geführt hat). Die Lösung ist im wesentlichen
aber sehr einfach: Ein Betriebssystem lädt nicht nur die Programme A,B,C in den
Speicher, sondern speichert für sie erreichbar auf einer wohl bekannten Adresse die
Basis-Adressen aller drei Programme zusammen mit je einem Namen, damit jedes Programm über
den zur Programmierzeit bekannten Namen die aktuelle Basis-Adresse der jeweils beiden
anderen Programme finden kann.
Natürlich gibt es noch eine Reihe weiterer Probleme bei dieser Art von Zusammenarbeit.
Die resultieren aber nicht aus der Adressierung und werden deshalb woanders abgehandelt.
Aber nicht nur im Programmbereich ist solche Adressarithmetik nötig. Gerade im Datenbereich
sind Basis-Adressen von Tabellen allgegenwärtig. Man kann mit beliebig vielen gleichartigen
Tabellen umgehen, dabei aber nur eine Programmsequenz benutzen, in der mittels indizierter
Adressierung auf die jeweils aktuelle Tabelle zugegriffen wird. Ein einziger Transfer
einer neuen Basis-Adresse führt dann zum Tausch der ganzen Tabelle. Das ist z.B. nötig,
wenn das Schriftbild auf dem Bildschirm oder dem Drucker getauscht werden soll.
Nachdem einigermassen klar ist, was an Adressierungsarten möglich und nötig ist, will
ich nun die zum Teil schon erwähnten Adressbereiche behandeln.
Schon gesagt ist, dass ein Teilbereich des Arbeitsspeichers notwendig ein BIOS speichern
muss. Die Basis-Adresse dieses Bereiches ist durch die Konstruktion festgelegt und nicht
zu ändern. Ein anderer meistens durch die Konstruktion fixierter Teilbereich ist der
"Bildwiederholspeicher", aus dem ein Video-Controller die Binärwerte für die Pixel auf
dem Bildschirm liest. Ein dritter, nicht notwendig fixierter Teilbereich ist die
Interrupt-Vektor-Tabelle.
Immer verschieblich sind der stack, Programm- und Datenbereiche. Ausserdem können diese
Bereiche mehrfach vorkommen. Sie werden vom BIOS erstmals definiert, aber meistens von
einem Betriebssystem umdefiniert.
Ebenfalls verschieblich und stark abhängig von Betriebssystemen und den damit eingestellten
Zuständen sind verschiedene Tabellen für die Einteilung des Arbeitsspeichers. Im AT-PC
sind dies die GDT (Global Descriptor Table) und falls der page mode eingeschaltet wurde,
die damit notwendig gewordenen page-Tabellen. Weitere Bereiche, die wie heilige Bezirke
unantastbar sein können, sind nicht mehr in der Logik des Computers begründete
Einteilungen, die hier erst mal nicht interessieren. Sinnvoll ist nur eine Aufteilung des
Arbeitsspeichers in Bereiche für Programmcode einerseits und Daten andererseits, wie ich
sie oben schon am Beispiel des protected mode vorgestellt habe.
Ich will aber noch den Teilbereich stack genauer erklären, der in jeder Maschine vorkommt
und für den in jeder CPU auch besondere Opcodes zur Verfügung stehen. Wie schon erwähnt
ist der Stack jener Bereich, in dem die Rücksprungadressen nach Prozeduraufrufen
gespeichert werden. Dieser Zweck hat auch zu der besonderen Gestaltung eines Adressgebers
geführt, der "stackpointer" genannt wird. Die Adresse in diesem Register wird implizit
inkrementiert oder dekrementiert, wenn ein Transfer in diesen Bereich mit speziellen
Opcodes angewiesen wird. Ein Prozeduraufruf enthält wiederum implizit einen solchen
Opcode, ist also schon ein kleines Programmstück.
Das wesentliche am Stack wird durch seinen Namen bestens ausgedrückt (deutsch:Stapel).
Wie bei einem Stapel Papier wächst der Stapel durch Auflegen eines Blattes und schrumpft
mit der Entnahme eines Blattes, wobei der stackpointer stets das zuoberst liegende Blatt
adressiert. Der Vorteil dieser Adressierung ist, dass Prozeduraufrufe beliebig
verschachtelt sein können. Immer liegt die richtige Rücksprungadresse zuoberst.
Als Speicherplatz noch knapp war, bürgerte sich ausserdem ein, auch andere vorübergehend
gebrauchte Binärwerte dort abzulegen - oft mehr als sinnvoll ist. Der Nachteil ist
nämlich das möglicherweise unkalkulierbare Wachsen des Stapels mit sehr fatalen Folgen.
Für Stapel dieser Art ist auch der Ausdruck "LIFO" üblich (="Last In First Out" also etwa:
Der Letzte wird der erste sein...). Im Gegensatz dazu werden z.B. Warteschlangen nach
dem Prinzip FIFO (="First In First Out") abgehandelt.
Ein wichtiger Adress-Bereich, der in vielen Computern Teilbereich des Arbeitsspeichers ist,
ist der I/O-Bereich, wo jene Geräte mit einer CPU verbunden sind, die für den Laien den
Computer charakterisieren: Tastatur, Bildschirm, Maus, Drucker, Festplatte und vieles,
von dessen Existenz der Laie nichts ahnt, das aber unverzichtbar ist - z.B.eine Stoppuhr.
Im AT-PC ist der I/O-Bereich dagegen ein besonderer Bereich mit eigenem Adress- und
Daten-Bus und für diesen Bereich speziellen Opcodes.
Seit Einführung des PCI-Busses sind aber die Geräte, die bis dahin praktisch direkt mit
dem I/O-Bus der CPU verbunden waren, nun ebenfalls über Teilbereiche des Arbeitsspeichers
adressierbar (was erhebliche Vorteile hat - insbesondere bei Videokarten).
Hier soll nur erwähnt werden, dass beim Transfer im I/O-Bereich meist auch zeitliche
Dimensionen im Programm behandelt werden müssen, wobei der dabei nötige Timer-Controller
selbst im I/O-Bereich liegt. Ausser dem richtigen Timing sind auch völlig asynchrone
Zustände zu berücksichtigen, die nur über Interrupts abhandelbar sind, die der ebenfalls
im I/O-Bereich liegende Interrupt-Controller für die CPU erfassbar macht.
Neben solchen Bezügen zwischen verschiedenen Geräten spielen jede Menge Opcodes,
Statuswerte und besondere Steuer-Bits der Controller die tragende Rolle. Es sind nämlich
Controller und nicht wirklich die Geräte, mit denen die CPU im I/O-Bereich verkehrt.
Und die haben ihre eigene Logik und ihre eigenen Adressbereiche.
COMPUTER UND BETRIEBSSYSTEM
Nach dem Einschalten eines Computers wird durch eine besondere Logikschaltung ("Reset") ein
Sprung in den sicheren, eingebrannten Bereich des BIOS erzwungen. Der Zufall hat gewaltet,
und der darf keine Konsequenzen in einer programmierbaren Maschine haben!
Nicht nur der Programmzähler muss ausdrücklich mit der ersten Adresse geladen werden. Nicht
nur der erste Opcode muss ein bestimmter sein (meistens ein indizierter Sprung). Auch
viele andere Werte wie die flags, die R/W-Bits und auch gewisse Register müssen ausdrücklich
einen bestimmten Start-Wert bekommen.
In modernen Computern müssen durchaus so viele Transistoren allein für den Reset eingesetzt
werden, wie früher eine ausgewachsene 8-Bit-CPU enthielt. Es ist aber nur ein einziges Bit,
mit dem dieser Zustand in der CPU hergestellt wird: der oben erwähnte NMI. Weil aber jeder
Controller und auch die Spannungsversorgung Reset-Eingänge haben, muss ein separater
Reset-Controller alles in die Reihe bringen. Vieles geht dabei noch durch die Lappen und
muss nach dem Reset mittels Programm abgehandelt werden. Wichtigste Aufgabe danach ist die
Prüfung des Arbeitsspeichers, ohne den ja nichts geht.
Ein ausgeschalteter Computer befindet sich also in einem Dornröschenschlaf, aus dem er nur
liebevoll heraus geführt werden darf.
So wie nach dem ersten Augenaufschlag Dornröschens zwar die ersten Dinge wie Herzschlag und
Atmung noch selbstverständlich sind, alles weitere aber immer verschiedener weiter gehen kann
(Prinz stinkt aus dem Maul...), so ist das Wirken des BIOS nur der Beginn einer
Initialisierung, die sehr verschieden gemacht werden kann, schliesslich aber in eine
"unendliche" Schleife mündet - im AT-PC normalerweise und mindestens eine ständig wiederholte
Tastaturabfrage.
Es ist der I/O-Bereich und die immer wieder neue Anpassung an Controller und ihre Eigenarten,
die schon sehr früh in der Entwicklung der Computer dazu führte, dass man das Booten in zwei
Teile stückelte, die getrennt entwickelt und optimiert werden können:
1.) "BIOS"
2.) "OS", die Abkürzung für "Operating System" (Betriebssystem)
Die Grenze zwischen den Aufgabenbereichen dieser Systeme ist eigentlich beliebig. Es gibt
aber einen wichtigen praktischen Grund für Stückelung und Grenzziehung: den Speicherbedarf.
Das BIOS liegt im Arbeitsspeicher und sollte deshalb möglichst wenig Platz verbrauchen für
Opcode, der im wesentlichen nur nach dem Einschalten dort liegen muss. Das OS aber liegt auf
Plattenspeichern oder Disketten, wo der Speicherplatz wesentlich billiger ist.
Schliesslich erlaubt die Tauschbarkeit des Betriebssystems den Prinzen an Dornröschens
Geschmack und an den des Benutzers der Beziehung anzupassen. Märchenhafte Träume sind nämlich
schnell vergessen, wenn man mit einem Coumputer bestimmte Aufgaben erledigen will...
Nach einigen Kontrollen, die bei jedem Reset stets von neuem sein müssen, macht das
BIOS schliesslich vor allem mal eins - es startet zunächst Floppydrive, Festplatte oder
neuerdings auch CDROM oder USB und liest aus dem ersten Sektor Binärwerte, die es in den
Speicher schreibt und als Programm startet, indem ein Sprungbefehl dorthin ausgeführt wird.
Dieses Stückchen Programm nennt man den "Bootsector". Er besteht im AT-PC aus 512 Byte.
Beginnend mit dem Bootsector wird das Betriebssystem transferiert und schliesslich
angesprungen. Diesen Teil der Intialisierung eines Computers nennt man "Boot(en)",
abgeleitet vom englischen Ausdruck für "Der Lotse geht an Bord".
So kommt man vom Allgemeinen zum Besonderen.
Ursprünglich war die Aufgabe eines Betriebssystems nur der Umgang mit magnetischen
Speichermedien. Deshalb hatten diese Betriebssysteme immer die drei Buchstaben DOS
("Disk Operating System") im Namen. Dort muss eine Ordung geschaffen werden, die erlaubt,
gespeicherte Daten verschiedener Menge auch wiederzufinden - als "Dateien" in einem
"Dateisystem". Dateisysteme sind im wesentlichen die Verknüpfung einer Sektoradressierung zu
Namen von Dateien. Das ist die Lösung eines Problems, das sich bei der Speicherung von Daten
immer dann stellt, wenn sie ihre Bedeutung behalten sollen, obwohl sie unter beliebiger
Adresse liegen. Dann nämlich kann die Adresse nicht mehr die Bedeutung repräsentieren.
Oft erscheint solche Namensgebung als Vorteil, weil sie dem Menschen verständlicher ist.
Logisch betrachtet muss man sie vermeiden, weil die Suche nach einem Namen in einer Liste
wesentlich umständlicher ist als eindeutige Adressbezüge zu Elementen gleichen Umfangs!
Weil aber Dateien im Laufe ihres Lebens an- und abschwellen oder erlöschen und damit wieder
verfügbaren Speicherplatz freigeben können, sind eindeutige Adressbezüge nicht wie im
Arbeitsspeicher möglich.
Eine weitere ursprüngliche Aufgabe eines Betriebssystems ist die Verwaltung des
Arbeitsspeichers und das Starten von "Anwendungen", also den Programmen, mit denen der
Benutzer eines Computers umgeht, wenn er schreibt, Fotos bearbeitet oder Klänge. Weil auch
diese Programme im Prinzip unkalkulierbaren Umfang bei beliebigen Anfangsadressen haben können,
sind auch bei dieser Aufgabe Namen notwendig. Insbesondere dann, wenn ein Programm mit
bestimmten anderen Programmen zu einer funktionellen Einheit verknüpft werden soll.
Die Art und Verwaltung solcher Verknüpfungen ("linking") und die Bereitstellung zigfach
gebrauchter Programmteile macht schliesslich die Eigenart eines Betriebssystems aus.
Es diktiert mit einem Linker, wie im Prinzip immer gleiche notwendige Teile des Systems
verknüpft werden müssen, um brauchbar zu sein.
Mit einem Betriebssystem wird jedenfalls entschieden, wie Treiber gerufen werden müssen,
wie der Speicher für Programme und Daten aufgeteilt wird, wo unverzichtbare Konstanten und
Variable gespeichert werden, wo der stack liegt, wie mit dem stackpointer umgegangen werden
darf (manchmal garnicht) und vieles mehr, was zunächst nicht betrachtet werden soll.
Solche Entscheidungen, die immer auch Einschränkungen sind, müssen jedenfalls gefallen sein
und manche können beim besten Willen nicht aufgehoben werden!
Ein Programmierer hat sich deshalb nicht nur mit den logischen Gegebenheiten in CPU und
Controllern zu befassen, sondern auch mit den Eigenarten des Betriebssystems, unter dem sein
Programm laufen soll. Dabei spielt natürlich eine grosse Rolle, ob man solche Eigenarten gut
dokumentiert und frei verfügbar erhält, oder ob man womöglich noch viel Geld zahlen muss für
Geheimwissen. Selbst wenn ein Betriebssystem umsonst zu haben ist und der Programmcode für
jedermann lesbar gemacht wurde, kann alles so unüberschaubar sein, dass auch Geheimniskrämerei
Programme nicht geheimnisvoller machen könnte! Weil Betriebssysteme ihre jeweils eigene und
der Willkür von Programmierern folgende Logik haben, gehe ich hier nur insoweit darauf ein,
als die weiter unter vorgestellte Programmiersprache C/C++ eine bestimmte Art von
Betriebssystem voraussetzt. Hier geht es jedenfalls um Programmieren und nicht Programme.
Es muss aber gesagt werden, dass viele Dinge, die ich in folgenden Absätzen anspreche, gerade
in den am häufigsten genutzten Betriebssystemen garnicht programmierbar sind bzw. nur über
verschlungene Pfade aus einem Programm heraus regierbar sind.
PROGRAMMIERSPRACHEN UND PROGRAMME:
Zunächst mal ist ja klar, dass der Umgang mit der CPU nur gepflegt werden kann, wenn man sie
mit der richtigen Anordnung von Opcodes füttert. Wegen einst knappem Speicherplatz war aber
auch klar, dass diese Opcodes verschiedene Anzahlen von Stellen enthalten mussten, wenn man
nicht unzählige Stellen verschwenden wollte. Dann aber kann aus der Stellung eines Opcodes
in einem Programm nicht mehr auf die Adresse des Opcodes im Programm geschlossen werden,
bevor nicht alles bis zu diesem Kommando übersetzt wurde. Damit wurde die Festlegung von Adressen
in diesen Opcodes zum grossen Problem. Wenn man nämlich eine Adresse in einen Opcode einfügen muss,
die noch garnicht bekannt sein kann, weil sie erst später im Programm feststeht, dann kann nur
mit Namen adressiert werden, die schliesslich gegen Adressen getauscht werden.
Damit ist also auch klar, dass das Erzeugen von Programmen äusserst schwierig wird, wenn man
nicht nur die Bedeutungen von Stellen in Binärwerten zu bedenken hat, sondern auch noch
neben dem Programm selbst Tabellen fortschreiben muss, die einem erlauben, Adressen in Opcodes
einzufügen, wenn man sie denn endlich kennt...
Sehr früh in der Entwicklung des Computers zum allgegenwärtigen Ding stand deshalb fest, dass
das Schreiben von Programmen in zwei Schritten zu erfolgen hat, zwischen denen ein ganz
besonderes Programm steht. Es hatte mindestens den Zweck zu erfüllen, Adressierungen mit Namen
ausdrückbar zu machen. Weil aber damit ohnehin das Erkennen von Buchstabenfolgen erledigt sein
musste, konnten auch die Bitbedeutungen mit Buchstabenkürzeln ausdrückbar gemacht werden.
Damit war der erste Schritt zu einer höheren Programmiersprache getan, in der mittels
Buchstaben programmiert wird, die nicht wie Ziffern die Bitbedeutungen in den Opcodes
repräsentieren, sondern erst durch ein Übersetzungsprogramm zu Opcodes werden.
Diese ersten Schritte gingen jedoch nicht so weit, dass die Abfolge der Opcodes in
irgendwelche neuen Formen gegossen wurde. Man schuf Kürzel für Operationen ("mnemonic") und
erlaubte Namen für Operanden und eine Rechtschreibung, in der solche Elemente in einem
"Quelltext" (="sourcecode") auftreten durften.
Ganz allgemein nennt man solche Sprachen und Übersetzungsprogramme "Assembler".
Damit kann man Adressen zwar noch mit einer Ziffer definieren, muss (und kann) aber alle
Adressen, die während der Programmerzeugung noch nicht bekannt sein können, mit einem
Namen definieren. Diese Namen werden "label" genannt.
Neben der Namensgebung für Adressen wurden auch Kommentare ermöglicht, die das
Übersetzungsprogramm ignoriert, und Vereinfachungen für die resevierten Kürzel der Opcodes
eingeführt - z.B. ein "mov"-Kommando für verschiedene Opcodes in der Intel-CPU...
In einem Assemblerprogramm werden jedenfalls alle Opcodes 1:1 in Buchstaben abgebildet und
insbesondere ist die Abfolge der Kürzel exakt die gleiche wie die der Opcodes. Deshalb wird
Assembler auch allgemein nicht als "höhere" Programmiersprache bezeichnet.
Mit der Prozedur habe ich eine höhere Einheit eines Programms vorgestellt, die zwar noch
eine Entsprechung in der Logik der Maschine hat (wegen der automatischen Benutzung des Stapels),
die aber im Gegensatz zu Opcodes eine offene Stellenzahl hat. Tatsächlich ist damit also über
die Bedeutung der einzelnen Opcodes eine gestülpt worden, deren Adressierung nicht feststeht
wie die der Register in der CPU, aber ähnlich wie ein Addierwerk hinter Registern eine ganz
bestimmte Funktion hat. Weil ein in einer Prozedur enthaltener Ablauf aber wie ein Kommando
an prinzipiell beliebigen Stellen eingefügt werden kann, kann mit Prozeduren sozusagen die
Zahl der möglichen Kommandos in einer Maschine beliebig erweitert werden.
Dennoch ist ein Prozedurruf nichts weiter als eine Variante eines Sprunges!
Aber die Prozedur erlaubte Namen für ganze Abläufe und war damit nicht nur geeignet für
ihren eigentlichen Zweck, den Speicherbedarf zu minimieren, sondern auch den Aufwand zum
Schreiben von Programmen. Für viele Rechenarten gab es ursprünglich keine Kommandos in
Form einzelner Opcodes. Um eine Multiplikation anzuweisen, hatte man deshalb viele Zeilen
Assembler zu schreiben. Die Idee, solche Abläufe nicht nur in Prozeduren zu packen,
sondern die Aufrufe zu Quasikommandos an das Übersetzungsprogramm zu machen, das diese
Abläufe dann aus Dateien extrahierte und automatisch in Quelltexte oder Binary einfügte,
lag nahe. Das war der erste Schritt zu "höheren" Programmiersprachen, in denen deshalb
der Prozedurbegriff eine wichtige Rolle spielt.
Ohne weitere scharfe Grenzen geht schliesslich die kleine Einheit Prozedur in die grosse
eines Programms über, das meistens mehr als eine Prozedur enthält. Ein Programm von einer
Prozedur zu unterscheiden, macht nur Sinn, wenn man (wie üblich) unter einem Programm die
Menge Code versteht, die als "Übersetzungseinheit" von einem Übersetzungsprogramm übersetzt
wird. Damit sind vor allem mal die Namen in ihrer Bedeutung für den Opcode beschränkt.
Der gleiche Name in verschiedenen Programmen kann sehr verschiedene Bedeutungen haben.
Aber auch diese Grenze ist verwaschen, wie ich unten noch genauer darstellen werde.
Ich will deshalb gleich sagen: Jede weitere Unterscheidung von Mengen von Opcodes hat nichts
mehr mit der Logik der CPU zu tun, sondern Zwecken des Opcodes oder auch besonderen Strukturen
in Betriebssystemen.
Ursprünglich war ein wichtiger Zweck, die Programmstücke, die die Bedienung der Controller
behandelten, als unabhängige Teile handhaben zu können. Sie wurden "Treiber" (="driver")
genannt und ihre Verfügbarkeit für ein bestimmtes Gerät konnte den Verkauf enorm steigern.
Einst war nämlich sehr komplizierter Code nötig, weil die Controller kaum eigene Logik
enthielten und infolgedessen die gesamte Steuerung von der CPU erledigt werden musste.
Mittlerweile sind nicht nur Controller einfacher anzusteuern. Auch der Arbeitsspeicher ist
grösser geworden und höhere Rechenarten inclusive trigonometrischer Funktionen sind zu Opcodes
geworden. Treiber sind als BIOS eingebrannt und mit einer Interruptanforderung verfügbar.
Damit sind die ursprünglichen Zwecke für "höhere" Programmiersprachen eigentlich
verschwunden.
Es gibt allerdings einen wichtigen Zweck, der eine "höhere" Programmiersprache als Assembler
nötig macht. Mittlerweile sind nämlich Computer auch nicht mehr überschaubar in Büros
miteinander vernetzt, sondern über das Telephonnetz weltweit. Damit sind die Möglichkeiten
für Angriffe auf einzelne Maschinen enorm gewachsen.
Da aber der Empfang von Anweisungen nicht ausgeschlossen werden darf, weil nur der Sender
wissen kann, wie die gesendeten Daten zur sinnvollen Botschaft werden können, ist eine
Programmiersprache nötig, die dem Empfänger dennoch die vollständige Kontrolle über die
Interpretation gibt.
Eine solche Programmiersprache baut darauf auf, dass man mit Prozeduren Kommandos geben
kann, deren Wirkung gegenüber Opcode eingeschränkt ist. Man kann also mit Prozedurnamen eine
Zielmaschine programmierbar machen, die tatsächlich nicht die Empfängermaschine ist, sondern
eine Untermenge der dort vorhandenen Möglichkeiten.
Programmiersprachen, deren Befehlsvorrat vollständig aus Prozedurnamen besteht, nennt man
"Interpretersprachen". Damit kann man zuverlässig die Fernsteuerung von Maschinen auf der
Empfängerseite beschränken. Das "HTML", mit dem ich die Darstellung dieses Textes für den Leser
programmiert habe, ist das derzeit prominenteste Beispiel einer Interpretersprache. Programmcode,
wie ihn ein "Computervirus" darstellt, kann also nur dann die gefürchtete Wirkung entfalten, wenn
der Empfänger (bzw.sein Betriebssystem) das erlaubt!
Zum Begriff "Programm" ist schliesslich zu sagen, dass ein Computer keineswegs einen Sinn dafür
hat, was ein Benutzer für nützlich und deshalb für ein Programm hält. Wie oben schon gezeigt,
ist ein Computer die denkbar dümmste Maschine. Beinahe jeder Binärwert bedeutet ihr was, was
blindlings exekutiert wird, wenn es vom Programmzähler adressiert wird.
Wie bedeutsam diese Form des Begriffes ist, merkt ein Programmierer als erstes, wenn er
sein selbst geschriebenes Programm mal startet. Dann nämlich macht er Bekanntschaft mit der
Natur der Sache:
Die Zahl der möglichen Programme ist ungeheuer viel grösser als die Zahl der nützlichen!
Dass das menschliche Hirn überhaupt imstande ist, die paar Stecknadeln im unermesslichen
Heuhaufen zu finden, muss einen wundern. Und das nützliche unterscheidet sich vom Schrott
oft nur durch ein nicht fehlendes kleines "h", ein "e" statt einem "f" usw...
Aber ich gehe erst am Ende dieses Textes auf das ein, was tatsächlich der Alltag beim
Programmieren ist: die Fehlersuche.
Erst mal also, wie man Fehler überhaupt mal machen kann...
ASSEMBLER:
Diese Programmiersprache ist die einzige, die gestattet, wirklich jede Eigenheit einer CPU
und angeschlossener Controller zu programmieren.
Weil aber bereits vorgestellte grosse Unterschiede zwischen verschiedenen CPUs und Controllern
bestehen, sind folgerichtig dem jeweils besonderen "Befehlssatz" jeweils besondere Assembler
zugeordnet, weil die ja den Opcode 1:1 abbilden.
Wie ebenfalls schon gesagt, führt kein Eigensinn daran vorbei, dass kein Programm mehr
enthält als Kommandos für Transfers von Binärwerten! - Aber natürlich mit sehr verschiedenen
Konsequenzen...
Die Unterschiede liegen also im einzig verbleibenden Teil der Angelegenheit, den besonderen
Adressen und Adressierungsarten, womit neben CPU und FPU auch solche Dinge wie "cache"
(=schneller Zwischenspeicher) und besondere Teilbereiche des Arbeitsspeichers wie GDT oder
page-tables gemeint sind.
In Motorola-CPUs kommen sehr viel mehr Adressierungsarten und Register vor als in einer Intel-CPU.
Letztere dagegen kennt einen Adressbereich, die I/O-Adressen, der in anderen CPUs nicht
vorhanden ist.
Immer aber gibt es I/O-Adressen, die in der Intel-Welt über einen eigenen Adressbus, in der
Motorola-Welt aber über den Adressbus des Arbeitsspeichers gegeben werden.
Weitere Unterschiede sind durch die Breite der Datenbusse gegeben, weshalb man oft dies Merkmal
zur Klassifizierung einer CPU verwendet ("8-Bitter"). Ansich ist das Unsinn, weil man sehr wohl
einen 8-stelligen Bus zwischen 32-stelligen Registern haben kann - oder Adressen für 8-stellige
Register bei einem 32-stelligen Datenbus, wie im AT-PC seit Einführung der CPU i486 von Intel.
Die Vorführung solcher Unterschiede könnte noch viele weitere Seiten füllen. Ich werde sie aber
abbrechen, weil ich auf wesentliches hinaus will.
Assemblerkommandos haben immer die gleiche Gestalt. Sie bestehen aus einem Kürzel (="Mnemonik"),
das die Operation definiert, und keinem oder mehr Operanden, die Register adressieren oder
Festwerte definieren. Und man kann ausserdem feststellen:
-> Für alle CPUs gibt es ein Kommando namens "mov" oder ähnlich für das Wort "move".
Das bedeutet den Transfer von Daten.
-> Für alle CPUs gibt es ein Kommando namens "jmp" für das Wort "jump".
Das bedeutet einen Sprung.
-> Immer gibt es auch ein Kommando namens "call" oder ähnlich für den Ausdruck "call procedure".
-> Es gibt immer Kommandos für logische und arithmetische Operationen der ALU. Sie sind ebenfalls
Kürzel (die der Leser selbst enträtseln kann): "add","sub", "mul", "nor", "neg", "and"....
-> Immer gibt es Kürzel für die Register der CPU, die als Zwischenspeicher für Operanden dienen,
oder solche, wo ihre flags, der Status und die Konfiguration von Maschinenzuständen stehen.
Hier unterscheiden sich die Befehlssätze aber am meisten.
Die Bedeutung von speziellen Kommandos für Operationen, die diese Register betreffen, ist nur durch
die Bauweise einer CPU begründet. Im Zusammenhang mit dem nur bei den Intel-CPUs für den AT-PC
vorhandenen "protected mode" beispielsweise gibt es gleich eine ganze Rasselbande von Kommandos
sowie spezielle Registerkürzel und Bitbedeutungen in Registern.
Auch die Art der Operanden ist nicht beliebig. Es gibt bei Transfers eine Quelle und ein Ziel,
bei Sprüngen ein Ziel, bei arithmetischen oder logischen Operationen ein oder zwei (selten drei)
Operanden. Diese Argumente eines Kommandos folgen dem Mnemonik, das die Operation definiert.
Man muss also, um ein Assemblerprogramm zu schreiben, eigentlich nur die Kommandos "mov", "jmp" und
"call" und die paar Varianten für verschiedene Adressierungsarten oder Kriterien für bedingte Sprünge
im Kopf haben! Dazu kommen ein paar arithmetische und logische Operationen. Alles andere braucht man
fast nie. Man kann es nachlesen. Es genügt die Ahnung, dass da noch was zu haben ist...
Man muss wissen, wie man ein Label schreibt und insbesondere, womit man Kommentar von Kommandos
trennen kann. Ein Assemblerprogramm enthält dann also Zeilen wie folgende:
mov ax,bx ; Kommentar zu diesem Kommando entsprechend einem Opcode
weiter: ; Label = Name einer Adresse
Ein Label kann der Name einer Adresse sein, unter der ein Variablenwert oder ein Opcode steht.
Steht dort eine Variable ("Konstanten" sind ebenfalls Variable!) muss oft nicht nur ein
Ausgangswert, sondern auch dessen Stellenzahl definiert werden - in Intel-CPUs immer.
Die Kürzel in einem Kommando entsprechen 1:1 dem daraus abgeleiteten Opcode. Hat man sich einmal
an die Ausdrucksweise gewöhnt, kann man Programme wie ein Buch lesen und Abläufe sehr konkret wie
Schachzüge verstehen. Man kann insbesondere Abläufe selbst aus Programmen für ganz verschiedene
Zielmaschinen kopieren und mit wenigen Änderungen im eigenen, neuen Programm verwenden.
Das ist bei den Abstraktionen, die anderen Programmiersprachen zugrunde liegen, nicht mehr möglich.
Bei einer Sprache wie C/C++ beispielsweise ist der Bezug zu maschinellen Abläufen zugunsten
einer mathematisch wirkenden Ausdrucksweise fast völlig verloren gegangen und man ist auf
Übersetzungsprogramme angewiesen, die zwischen Assembler und Quellprogramm stehen und "compiler"
heissen. Wie ich unten noch etwas genauer ausführe, wird damit aber keineswegs die bei Assembler
nötige Umarbeitung eines Quelltextes völlig erübrigt (obgleich diese Absicht hoch gehalten wird).
Vielmehr sind nun die Umarbeitungen fällig im Bezug zu Ziel-Betriebssystemen und Ziel-Compilern
und Ziel-Linkern und Ziel-Bibliotheken.
Weil aber insbesondere C/C++ eine sehr beliebte Programmiersprache ist, werde ich das
wesentliche vorstellen, nachdem ich zunächst am Beispiel eines Assemblers für den AT-PC klar
gemacht habe, was tatsächlich kommandiert werden kann - was man also schmerzlich vermissen kann
in höheren Sphären.
PROGRAMMIEREN MIT NASM-ASSEMBLER:
Auch über die Ausdrucksweise in einem Assemblerprogramm konnte keine Einigkeit erzielt werden!
Es gibt mindestens ein halbes Dutzend Dialekte und zwei Normungsversuche allein für die
Programmierung des AT-PC.
Ich muss also endgültig die allgemeine Betrachtung hinter mir lassen und abhandeln, was
tatsächlich nur im AT-PC von Bedeutung ist und wie das in einem bestimmten Assemblerdialekt
geschrieben wird.
Ich werde den NASM-Assembler-Dialekt vorstellen und damit in die Assemblerprogrammierung
einführen. Diese Schreibweise ist der am ähnlichsten, die von Intel eingeführt wurde und auch
in der Fachliteratur gebräuchlich ist. Der NASM-Assembler ist im Internet frei erhältlich und
kommt mit sehr guter Dokumentation, auf die ich zur Ergänzung verweise.
Davon ausgehend werde ich in weiteren Kapiteln den GNU-Assembler-Dialekt beschreiben, mit dem
der inline-Assembler für C/C++ unter Linux formuliert wird - wenn ich in C/C++ eingeführt habe.
Erst dann kann man nämlich verstehen, warum da so eigenartig anders formuliert werden muss.
In einer anderen Datei stelle ich einen von mir entwickelten Assembler-Dialekt vor, der wohl
nicht mehr zu vereinfachen ist. Der ist aber nur unter dem von mir geschriebenen Betriebssystem
ASMOS benutzbar, das im NASM-Dialekt geschrieben wurde.
Ich werde nicht den kompletten Befehlssatz der CPUs in einem AT-PC vorstellen. Er besteht aus
rund 300 Kürzeln, die alle sehr gut beschrieben in der Dokumentation zu NASM aufgeführt werden.
Wer ins Detail der Assemblierung eindringen will, ist mit den Quellprogrammen meiner in NASM
geschriebenen Assembler ASMn bzw. ASMat bestens bedient, wo ausserdem im Kommentar auch die
abgehandelten Kommandos vollständig beschrieben werden (auch deutsch).
Ich werde hier also nur die Kommandos vorstellen, die 90% aller Programme ausmachen. Das sind
nämlich nur zwei Dutzend. Vor allem aber will ich die Anwendung von Adressierungsarten und
elementare Abläufe vorstellen, die immer in jedem Programm enthalten sind und deshalb zum
Gegenstand neuer Ausdrücke in "höheren" Programmiersprachen wurden.
Ein Kommando kann im NASM-Dialekt entweder auf ein Label folgen oder muss als erstes links in
einer Zeile stehen. Die Mnemoniks bestehen meistens aus drei Buchstaben. Darauf folgt ein
Zwischenraum, dem abhängig vom Mnemonik Operandendefinitionen folgen, die durch Kommata
voneinander getrennt werden.
Das wichtigste Mnemonik überhaupt ist "mov", mit dem ein Kommando so aussieht:
mov Ziel,Quelle ; ...gefolgt evtl. von Kommentar, der in NASM stets mit einem Semikolon
eingeleitet werden muss!
"mov" weist den Transfer eines Binärwertes an, muss also Adressen von Schreiber und Leser
enthalten, wobei mindestens einer von beiden ein Register in der CPU sein muss - das Kommando
darf also höchstens eine Adresse im Arbeitsspeicher enthalten!
Während die Registeradressen innerhalb der CPU unveränderliche Namen haben (eax,ebx,ecx,edx...),
sind Adressen im Arbeitsspeicher mit "effektiver" Adresse zu definieren, einer Ausdrucksweise,
die ich zunächst für den Fall vorstelle, dass die Adresse innerhalb des Programms liegt und
beliebig benamt als "Label" im Programm definiert wird.
Namen von Labeln können in NASM entweder mit abschliessendem Doppelpunkt oder ohne geschrieben
werden. Nach einem Zwischenraum folgt ein Opcode oder eine von drei Längendefinitionen,
mit denen tatsächlich alles gemacht werden kann, weil es im AT-PC bei Speicherzugriffen nur
die nach 8-, 16- oder 32-stelligen Werten gibt.
EinByte: DB 0FFh
EinWord: DW 0FFFFh
EinDword: DD =0FFFFFFFFh
Ausser diesen Längendefinitionen können auch weitere Typdefinitionen sein, die hier aber
zunächst nicht interessieren sollen, weil sie nicht durch die Logik der CPU bedingt sind (nicht
adressierbar sind). Wichtig ist nur noch die Schreibweise für Ziffern, die Dezimalziffern wie
gewohnt erlaubt, Hexadezimalziffern aber mit Postfix "h" und Dualziffern mit Postfix "b"
Alle benamten Adressen sind immer eindeutig in einer Übersetzungseinheit. Deshalb darf der
gleiche Name in der gleichen Übersetzungseinheit nicht zweimal auftauchen!
Ein reales Kommando sieht dann so aus:
mov eax,[variablenwert]
....womit der Wert unter der Adresse von "variablenwert" an eax geschrieben wird.
Wann immer der Wert unter der Adresse Operand ist, müssen in NASM eckige Klammern gesetzt
werden! Eine ganz andere Bedeutung hat also:
mov eax,variablenwert
....womit die Adresse von "variablenwert" an eax geschrieben wird.
In beiden Fällen fügt das Übersetzungsprogramm den gleichen Wert für "variablenwert" in den
Opcode ein - aber das Mnemonik "mov" wird verschieden übersetzt! Es ist im zweiten
Falls nämlich ein "Immediate"-Kommando (immediate=unmittelbar), das mit dem zweiten Operanden
(der Quelle) nicht adressiert, sondern diesen im Opcode stehenden Wert an eax schreibt.
Sind Immediatewerte nicht benamte Adressen, sondern beliebige andere Werte, brauchen sie auch
keinen Namen. Sie müssen auch keineswegs mit einem Label definiert werden, sondern werden
explizit im Kommando in Form einer Ziffer definiert:
add eax,100h
...womit die Addition des zweiten Operanden zum ersten, dem Inhalt des Registers eax angewiesen
wird. Bei solchen Operationen steht das Resultat stets im Zielregister, überschreibt also den
ersten Operanden. Schreibt man dagegen:
add eax,[100h]
...dann wird der zweite Operand unter der Adresse =100h geholt. Unter dieser Adresse kann auch
der erste Operand und schliesslich das Resultat stehen, wenn man schreibt:
add [100h],eax
Auch dann kann natürlich mit einem Festwert operiert werden. Weil dann aber eine Unsicherheit
auftreten kann, die die Stellenzahl des Resultatwertes betrifft, muss ausserdem diese
Unsicherheit beseitigt werden:
add BYTE [100h],10h
...addiert also zum Bytewert unter der Zieladresse den Festwert =10h.
Wie gesagt sind im AT-PC unter einer Adresse 1,2 oder 4 Byte als Operanden greifbar, während
das kleinste Adressinkrement eine Byteadresse ist. Neben historischen Gründen für Byteadressen
gibt es weitere gute Gründe, an der Byteadressierung festzuhalten.
Auf diese Operandenbreite beziehen sich ausserdem auch die Betätigungen der flags!
Bei Speicherzugriffen muss also die Operandenbreite durch besondere Kürzel angewiesen werden:
BYTE (8 Bits), WORD (16 Bits) oder DWORD (32 Bits).
Das kann wegbleiben, wenn der andere Operand ein Register ist, das dann durch seinen Namen die
Operandenbreite definiert. Für die Universalregister *=a,b,c,d gilt,
dass alle 32 Stellen des jeweiligen Registers den Namen e*x tragen (z.B. "eax"),
die untersten 16 Stellen den Namen *x (z.B. "ax") und
die untersten 8 Stellen den Namen *l (z.B. "al").
Auch die Stellen 8-15 haben noch einen eigenen Namen *h (z.B. "ah"), nicht aber weitere 8-
oder 16-stellige Teilbereiche des Registers!
Weil im Gegensatz dazu im Arbeitsspeicher wegen der Byteadressierung 8-stellige Operanden immer
separat gegriffen werden können, sind trickreiche, byteweise Manipulationen von 32-stelligen
Werten möglich.
Hier alle Registernamen nochmal als Liste:
32-stellige Universalregister : eax, ebx, ecx, edx
16-stellige Universalregister : ax, bx, cx, dx
8-stellige Universalregister : ah, al, bh, bl, ch, cl, dh, dl
32-stellige Adressregister : edi, esi,
32-stellige Stackpointerregister: ebp, esp
16-stellige Segmentregister: cs, ds, es, fs, gs
Weitere Namen von Spezialregistern können in der NASM-Dokumentation oder meinen Quelltexten
gefunden werden.
Völlig analog zu "mov" werden viele Kommandos geschrieben, die Operationen in der ALU anweisen.
Diese Kommandos sind "add", "sub", "and", "or", "xor", "cmp", "test"...
Daneben gibt es Kommandos, die nur einen Operanden erfordern wie "inc", "dec", "not", "neg",
aber auch "mul" und "div", weil implizit die Register eax und edx bzw.Teile davon für den
zweiten Operanden und auch das Resultat benutzt werden.
Schliesslich können drei Operanden bei einigen Varianten von "imul" vorkommen, wo Vorzeichen
richtig verarbeitet werden.
Darüberhinaus gibt es Kommandos, die zwar zwei durch Kommata getrennte Operanden erfordern, aber
nur eine Adresse. Solche Kommandos weisen die verschiedenen Schiebe- und Rotier-Operationen an.
Während das Zielregister ein beliebiges Register sein darf, muss die Zahl der Schiebetakte
(1Takt =Versetzung um 1 Bit) als Festwert oder in cl stehend definiert werden!
Ebenfalls nur eine Adresse folgt den verschiedenen Sprungkommandos. Sind diese bedingt durch ein
flag, ist diese Angabe Teil des Mnemoniks. Solche Sprünge sind nur mit relativer Adressierung
des Sprungziels möglich, was aber bei der Formulierung mittels Label keine Rolle spielt.
Statt einem Namen sind in NASM auch Ziffern erlaubt. Weil die Adressierung aber relativ ist, ist
das wohl kaum kalkulierbar und besser zu vermeiden!
Es ist also der unbedingte, relative Sprung ("branch") erlaubt:
jmp Sprungziel
....womit ein Sprung auf die Adresse mit Namen "Sprungziel" kommandiert wird.
...und der bedingte, relative:
je FallsGleich
....womit ein Sprung auf die Adresse mit Namen "FallsGleich" kommandiert wird, falls das
flag "equal" =H ist
Dem unbedingten Sprung völlig analog wird der Prozeduraufruf formuliert:
call Sprungziel
....womit ein Sprung auf die Adresse mit Namen "Sprungziel" kommandiert wird, undzwar mit
relativer Adresse im Opcode und ablegen der Adresse des folgenden Opcodes auf dem Stack.
weiter:
....hierher "zurück" führt der Absprung aus der Prozedur mittels Mnemonik "ret" (ohne Argument)
Ein eigenartiger, indizierter Sprung kann getan werden, indem ein Registername statt einem Namen
eines labels benutzt wird:
jmp eax
....womit ein Sprung auf eine absolute Adresse kommandiert wird, die im Register eax steht,
während
jmp [eax]
....die auf die Datensegmentbasis bezogene Sprungzieladresse unter der Adresse in eax liest.
Sehr ähnlich werden alle indizierten Sprünge und Rufe programmiert, die allerdings nicht bedingt
sein können. Immer steht dann die Sprungzieladresse unter der Adresse, die zwischen den eckigen
Klammern definiert wird.
Adressen zwischen eckigen Klammern werden eine "effektive" Adresse genannt wird, weil sie in
einem CPU-Zyklus im Adressrechner aktuell aus bis zu vier Teilen errechnet wird. Immer wird
mit diesem Resultat im Arbeitsspeicher adressiert, wobei natürlich noch die Basisadresse des
aktuellen Segmentes addiert wird. Sie wird entweder mittels Selektor der GDT entnommen oder,
falls der "page mode" eingeschaltet ist, den zugehörigen Tabellen. Im page mode sind ausserdem
mindestens 2 zusätzliche Lesezugriffe im Arbeitsspeicher nötig, um diese page-Tabellen zu
verwerten. Und es muss ein ansich bereits übersetztes Programm noch aktuell ergänzt werden, weil
im page mode nur 10 bit der 32-stelligen Adressen beim Schreiben des Programmes feststehen.
Das muss ein besonderes Programm leisten, der "linker", das bei Verwendung von "höheren"
Programmiersprachen, die mit der Segmentierung im AT-PC nicht umgehen können, immer nötig ist.
Nur dadurch, dass der Opcode mit effektiver Adresse aus mehr oder weniger Byte bestehen kann,
verlängert sich eventuell die Befehlsholzeit die gesamte Ausführungszeit des Opcodes. Das ist aber
selten erheblich, weil die CPU immer automatisch einen Zwischenspeicher mit Bytes voll liest,
die dem gerade durch den Programmzähler adressierten folgen (="opcode prefetch"). Ausserdem ist
zu berücksichtigen, dass die andernfalls nötige Errechnung der effektiven Adresse mit mehreren
Opcodes auf jeden Fall mehr Zeit braucht!
Oben habe ich Ziffern oder Namen zwischen eckigen Klammern vorgestellt. Es sind unabänderlich
definierte "displacements", die aber nur dann eine im aktuellen Segment absolute Adresse
darstellen, wenn alle anderen der erlaubten Teile einer effektiven Adresse =0 sind.
Die Intel-CPUs erlauben nämlich sehr viel raffiniertere Ausdrücke, die der Adressrechner dennoch
in der gleichen Zeit kalkuliert. In diesem Adressrechner wird die zur Adressierung benutzte Adresse
stets aus vier Teilen zusammengesetzt:
Basis+Index*Skalierung+-Displacement
Der Wert des Displacements kann mit Ziffer oder Label definiert und addiert oder subtrahiert
werden, während die Werte für Basis oder Index in Registern stehen müssen. Die Skalierung ist
tatsächlich eine Schrittangabe für Schiebetakte, mit denen der im Index-Register stehende Wert vor
der Summenbildung geschoben werden soll. 0,1,2 oder 3 Stellen sind möglich, was mit *2,*4 oder *8
definiert wird.
Bei effektiven Adressen spielt ausserdem noch eine wichtige Rolle, in welchem "Segment" adressiert
werden soll. Selbst im page mode ist die Segmentierung nicht etwa ausgeschaltet. Weil ich in einer
anderen Datei den Gegenstand sehr genau beschrieben habe, hier nur das wichtigste:
Das bereits oben angesprochene Problem einer Aufteilung des gesamten im Arbeitsspeichr verfügbaren
Adressbereichs in Teilbereiche mit definierter Basisadresse, wird im AT-PC mittels "Segmentadressen"
gelöst. Sie waren ursprünglich ein Addend der physischen Adresse und sind es im "real mode" noch
heute. Weil in diesem Modus effektive Adressen ohnehin nicht benutzt werden können, komme ich also
gleich auf "Selektoren", die im protected mode in den Segmentregistern stehen. Diese haben die
Namen: "cs" (=Programmsegment), "ds" (=Datensegment), "ss" (=Stacksegment), "es", "fs", "gs"
Die 16-stelligen Selektoren, die in diesen Registern stehen, sind Offsetadressen, also Addenden
der Adressen von 64-stelligen "Deskriptoren" in der "Global Descriptor Table" (=GDT). Allerdings
sind die Stellen 0,1,2 der Selektoren noch anderen Zwecken als der Adressierung in der GDT geweiht,
weil die Deskriptoren ja nur im Abstand von 8 Byteadressen in der GDT vorkommen. Erst in den
Deskriptoren steht, verblümt mit einigen Statusbits und Segmentlänge, die Basisadresse eines
Segmentes, das allerdings vollkommen eindeutig mit dem Selektor in Kraft tritt, wenn er in einem
Segmentregister steht. Eine effektive Adresse ist immer bezogen auf den mit Selektor in ds
selektierten Teilbereich des Arbeitsspeichers, definiert im damit adressierten Deskriptor.
Normalerweise wird aber dieser Datenbereich vom Betriebssystem (das Programmen Segmente zuweist),
mit Programmsegmenten (Selektor in cs) deckungsgleich gemacht, weil, wie in den Beispielen hier
schon zu sehen ist, ein Programm in einer von Neumann Struktur auch Daten enthält - die Variablen.
Weil mit einem Programmselektor nicht geschrieben werden kann, muss ein "Alias"-Deskriptor
vorhanden sein, der das erlaubt und natürlich nur mit einem anderen Selektor gemeint sein kann,
der dann in ds zu schreiben ist.
Was tatsächlich noch um einiges detailreicher und komplizierter ist, interessiert hier nur
insoweit, als ein Programmierer in aller Regel für die richtigen Selektoren in den
Segmentregistern sorgen muss und meistens wenigstens mit dem Register "es" auch noch umgeht.
Dann sieht die vollständige effektive Adresse z.B.so aus:
mov [es:esi],eax
Der Name des Segmentregisters steht also als erster in der Klammer und ist mit einem Doppelpunkt
von der effektiven Adresse abgesetzt, die damit als Offsetadresse zu der mit Selektor in es
gemeinten Basisadresse addiert wird, undzwar vollautomatisch und ohne Zeitverlust. Die Definition
von es statt der Ausgangslösung ds bedingt aber noch 1 Byte Präfix vor dem Opcode.
Die effektive Adresse adressiert immer einen Operanden, also in Sprungkommandos nicht etwa schon
das Sprungziel (der dort stehende Opcode wäre kaum als Operand zu gebrauchen...). Bei indizierten
Sprüngen und Rufen wird vielmehr die Sprungzieladresse (ein brauchbarer Operand...) mit
effektiver Adresse adressiert. Dies muss man sich gut merken, weil ursprünglich unter einem
indizierten Sprung (wie oben beschrieben) ein Sprung mit Adresse im Indexregister verstanden
wird. Anders ausgedrückt: eine Intel-CPU erlaubt eine beliebige Anzahl solcher Indexregister,
deren aktuelles mit einer effektiven Adresse definiert wird.
Dies bringt gewaltige Vorteile bei der Gestaltung von Abläufen, die ich hier nur andeutungsweise
vorstellen kann. Wer meine Quellprograme liest, wird dort aber erschöpfend viele Beispiele
finden.
Der wichtigste Vorteil ist, dass man sowohl Verzweigungen wie auch Abläufe bahnen kann, ohne dort
an jeder Ecke fragen zu müssen, welcher Zustand gerade der herrschende sei. Man prüft also einmal,
ob der Zustand A oder B herrschen soll und setzt entsprechend eine zugehörige Sprungzieltabelle
A oder B in einen bestimmten Adressbereich. Beide Tabellen enthalten in einem Stapel die jeweils
zu A bzw. B gehörenden Sprungzieladressen.
In dem Ablauf, in dem der Unterschied gemacht werden muss, hat man dann nicht mehr aufgrund
eines Statusbits bedingt zwischen den jeweils aktuellen Zweigen zu verzweigen, sondern macht,
statt bedingt zu springen, einen indizierten Sprung mit der soundsovielten Adresse in der
aktuellen Sprungzieltabelle.
Auch wenn das nicht schneller läuft, falls nur zwischen zwei Varianten eines Ablaufes verzweigt
werden soll - es läuft immer gleich schnell bei beliebig mehr Varianten. Und es erlaubt eine
enorme Einsparung von dummen Fragen und Code.
Der wichtigste Vorteil ist aber, dass die Sprungzieladressen irgendwo im Arbeitsspeicher liegen
können, wo sie keineswegs mittels Namen zu orten sind. Es genügt dann zu wissen, welcher Selektor
aktuell zu verwenden ist, und auf welcher im Segment absoluten Adresse die begehrte
Sprungzieladresse liegt. In "fernen" Segmenten liegende Adressen sind dann immer auffindbar.
Diese Verwendung von Adressen von Adressen von Adressen... wirkt sehr kompliziert, ist aber nicht
nur sehr einfach zu nutzen (mittels effektiver Adresse), sondern auch einfach zu verstehen, wenn
ich daran erinnere, dass der Adressbereich einer CPU prinzipiell eindimensional ist, mehr
Dimensionen also nur dadurch zustande kommen können, dass Teilbereiche mit eigener Basis
definiert werden können. Wenn die Adressen dann relativ zu einer solchen Basis definiert werden,
können sie immer gültig sein, egal wo die Basis gerade liegt.
Davon wird schon mittels CPU-Logik beim ganz wesentlichen Teil des "protected mode", der GDT
Gebrauch gemacht. Die Addition von Basisadresse der GDT, die in einem speziellen Register "gdtr"
steht, und Selektor macht schon die Basis der GDT beliebig verschieblich, bzw. erlaubt beliebige
andere GDTabellen allein durch Umschreiben von gdtr. Die Trennung von Adresse eines Deskriptors
und Basisadresse eines Segmentes erlaubt schliesslich, den ganzen Inhalt eines Segmentes quer
durch den Speicher zu verschieben, ohne den Selektor umdefinieren zu müssen. Nur im damit
gemeinten Deskriptor muss natürlich die richtige neue Basisadresse stehen. Aber ein Programm,
das mit dem Selektor im verschobenen fernen Segment adressiert, kann nicht daneben hauen,
obwohl sich die physische Adresse des adressierten Wertes meilenweit verschoben hat!
Es müssen folglich nach einer solchen Verschiebung nicht alle möglichen "Prozesse" neue
Basisadressen zugewiesen bekommen, eine zentrale Prozessverwaltung ist überflüssig usw...
Wer das einigermassen verstanden hat, wird diese scheinbar umständliche Adressarithmetik nicht
mehr missen wollen. Ob sie aber wirklich nutzbar ist, ist abhängig vom Betriebssystem. Und sie
ist unmöglich in "höheren" Programmiersprachen.
Ein ganz wesentlicher Teil der Funktion von Opcodes ist schliesslich, dass die Flags betätigt
werden - oder auch ausgesprochen nicht.
Wie schon ganz oben gesagt, sind diese paar Bits alles, was den Computer zur Entscheidung
über Abläufe befähigt. Damit einige wichtige Konsequenzen aus Operationen schneller
ablaufen können, ist nicht nur die Zahl der bedingten Sprünge sehr gross gemacht worden,
sondern auch eine kleine Zahl von Opcodes geschaffen worden, die in ihrer Funktion auch
das Übertragsbit als Operanden nehmen. Die Mnemoniks sind "adc" und "sbb", die wie "add"
und "sub" wirken, falls zuvor mittels "clc" das Übertragsbit, das dann einer 1 oder 0 entspricht,
=L gesetzt wurde. Andernfalls wird ein wie auch immer zustande gekommenes Übertragsbit
in Form eines gesetzten CARRY-flags noch als weiterer Addend bzw. Subtrahend genutzt.
Das erlaubt Abläufe bei Addition oder Subtraktion beliebiger Stellenzahlen von Operanden
ohne bedingte Verzweigungen. Zum Beispiel addiert man mit:
add eax,[untere32Stellen]
adc ebx,[nächste32Stellen]
adc ecx,[weitere32Stellen]
...ohne weiteres zwei 96-stellige Operanden, deren einer in eax,ebx,ecx steht und deren
anderer im Arbeitsspeicher liegt.
Wer das in einer "höheren" Programmiersprache machen will, wird sich schwer wundern. Dort
sind Verwertungen von Überträgen nämlich nur in IF-Schachteln verwertbar und mit wesentlich
mehr Schreibarbeit verbunden.
Meist unmöglich aber sind dann Verwertungen von Überträgen, die nach Schieben oder Rotieren
in der CPU stehen. Wenn überhaupt, dann mit der abwegigen Erfassung eines "grösser"-Zustands,
der aber von vielen Compilern erst nach einem Grössenvergleich zustande kommt, der das
herausgeschobene LSB (="Last Significant Bit") oder MSB (="Most Significant Bit") wieder
überschreibt.
In Assembler aber kann ohne weiteres eine ganze Datei um eine Stelle geschoben werden -
allerdings nur mittels bedingter Sprünge (Kommando: "jc").
Eine Rotier-Operation sucht man jedenfalls in C/C++ vergeblich. Sie erlaubt die Erfassung
eines Bit in bestimmter Stelle, indem der ganz Operand so geschoben wird, dass das an der
einen Seite raus geschobene Bit an der anderen Seite wieder in den Operanden rein geschoben
wird. Man kann nicht nur links, sondern auch rechts rum rotieren, über das Übertragsbit als
zusätzlicher Stelle oder nicht, und man kann schliesslich auch noch eine ganze Rasselbande
von Bitscan-Kommandos nutzen, die ebenfalls mit dem Übertragsbit umspringen.
PROGRAMMSEQUENZEN:
Ich komme nun zu den aus mehreren Kommandos bestehenden Abläufen, die in verschiedenen
Abwandlungen in jedem Programm vorkommen. Dabei benutze ich Begriffe, die man sich nicht
unbedingt merken muss, weil sich die Eigenarten eines Ablaufes in einem Assemblerprogramm
immer auch aus den Kommandos direkt ableiten lassen. Dann muss man sich zunächst kaum mehr
merken als das, was ich oben schon aufschrieb.
In "höheren" Programmiersprachen allerdings werden all diese Begriffe zu reservierten
Ausdrücken und sind mit einem zwingenden Regelwerk für die Schreibweise verbunden. Weil
beeindruckende Formulierungskünste vorspiegeln, dass es sich jeweils um ganz neues, nur
durch Verwendung einer gewissen Sprache überhaupt mögliches handle, will ich ausdrücklich
gesagt haben:
"Höhere" Programmiersprachen erlauben stets nur eine Untermenge dessen zu programmieren,
was in Assembler ausgedrückt werden kann!
Ich beginne mit dem Begriff der Prozedur, die nur insofern was besonderes ist, als der in ihr
enthaltene Ablauf zwischen verschiedenen Teilen eines Programms eingeschoben werden kann, ohne
abgeändert werden zu müssen. Denn das, was sich zwischen Prozedurruf und Rückkehr abspielt,
spielt sich ja exakt zwischen zwei Kommandos ab.
Sinnvollerweise entscheidet man sich für die Definition einer Prozedur nur dann, wenn man beim
Programmieren feststellt, dass man sich wiederholen muss. Das praktische Vorgehen besteht dann
darin, den nochmal gebrauchten Ablauf im Quelltext woanders anzuordnen, mit Einsprunglabel zu
versehen und "ret"-Kommando abzuschliessen. Und natürlich muss dann an die ursprüngliche Stelle
dieses Ablaufes im Quelltext ein Prozeduraufruf gesetzt werden. Man wird das dann noch ein
bisschen zurecht stutzen müssen, braucht eventuell einen anderen Anlauf für andere Ansprünge
über ein darüber liegendes weiteres label, oder muss in einem der Abläufe, wo die Prozedur
gerufen werden soll evtl. Register anders benutzen...lauter Kleinigkeiten, die am Zweck
orientiert sind, und die keineswegs mit Systemphilosophien geregelt werden müssen.
Natürlich gibt es häufig gebrauchte Abläufe, die man als solche schon erkennt, bevor man sie
geschrieben hat, z.B.Zugriffe im Dateisystem.
Das aber ist der Ausnahmefall und kein Grund für eine Globalisierung von Prozeduren, wie sie
in "höheren" Programmiersprachen vorkommt!
Schliesslich kann ein Ablauf, der als Prozedur ausgegliedert wird, auch ohne "call" und "ret"
genau gleich oder sogar brauchbarer arbeiten. Es kann nämlich die Situation gegeben sein, dass
innerhalb der Prozedur Fehlerzustände möglich sind, die die Rückkehr zum "Rufer" zur
schlechtesten Wahl machen. Es kann aber auch andere Gründe geben, vielfach gebrauchte Abläufe
nicht zu einem Rufer "zurück" kehren zu lassen, sondern je nach Rufer zu verschiedenen weiteren
Abläufen. Dies wird mit indizierten Sprüngen erzielt, die ich oben bereits angesprochen habe.
Statt mit dem "call"-Kommando eine Rückkehradresse auf dem stack abzulegen, kann man auch mehr
als eine Rückkehradresse in Variablen schreiben, die in der nun nicht gerufenen, sondern
angesprungenen Sequenz fallweise als "Rückkehr"-Adressen in einem indizierten Sprung verwendet
werden. Ein Assembler-Programmierer hat aber meistens mehr als eine Möglichkeit zur Lösung
eines Problems! Er kann nämlich auch innerhalb der Prozedur den Stapel inkrementieren (+4 !),
damit die Rücksprungadresse tilgen (ein "ret" ohne Sprung tun...), und dann mit einem Sprung
direkt z.B. zur geigneten Fehlerabhandlung springen. Er kann schliesslich mitten aus seiner
Prozedur raus und in eine andere Prozedur rein und mit deren "ret"-Kommando zum Rufer
zurückkehren - weil kein "call" zum Einsprung in die zweite Prozedur benutzt wurde, steht
tatsächlich die originale Rücksprungadresse bereit!
All diese raffinierten Sprünge sind in "höheren" Programmiersprachen nicht möglich.
Insbesondere ist der Umgang mit dem Stack nicht möglich.
Der in vielen "höheren" Programmiersprachen erlaubte "rekursive" Ruf einer Prozedur ist in
Assembler natürlich ebenfalls zu machen, indem die Einganswerte stets über einen Stapel
definiert werden, der wachsen kann.
Aber tatsächlich ist diese Form der Rekursion reichlich dumm, weil man sie nämlich ohne viel
Trara auf dem Stack schneller in einer Schleife macht - in der natürlich auch ein Stapel
für verschiedene Rekursionsebenen benutzt werden muss. Dann aber kann ein Schleifenzähler die
Zahl der Rekursionen kontrollieren, und die "Rückkehr" von der letzten Rekursion muss nicht
eine Kette von Rücksprüngen durch die Ebenen sein, sondern kann ein einfacher Sprung sein!
Nur bedingt sind auch eine besondere Art von Prozeduren formulierbar, die im Gegensatz zu dem
eben beschriebenen, am besten intuitiv entwickelten Typ, sehr wohl überlegt sein müssen:
Interrupt-Behandler
Diese Prozeduren unterscheiden sich auf den ersten Blick nur durch das Rückkehrkommando "iret"
statt "ret" bzw. "retf" von den anderen Prozeduren, werden aber vor allem mal auf eine völlig
andere Weise gerufen. In einem Programm dürfen sie auf keinen Fall mittels "call"-Kommando
gerufen werden!
Oben habe ich schon angesprochen, dass es die Möglichkeit gibt (und geben muss), den normalen
Ablauf eines Programmes zu unterbrechen, undzwar immer dann, wenn zeitlich nicht kalkulierbare
Ereignisse, die aber im Programm verwertet werden sollen, synchronisiert werden sollen. Das
passiert meistens in einer Warteschleife, in der auf ein solches Ereignis gewartet wird, das
z.B. eine Betätigung der Tastatur ist.
Das hier wichtige Detail ist allerdings, dass die Einsprungadresse (der "Interrupt vector")
auf einem besonderen Stapel liegen muss. Im protected mode heisst dieser Stapel "Interrupt
Descriptor Table" (=IDT) und enthält ebenfalls 64-stellige Deskriptoren, die allerdings völlig
anders aufgebaut sind als die Deskriptoren, die in der GDT erlaubt sind. Insbesondere enthalten
diese Interrupt-Deskriptoren (mehrere Typen gibt es) eine vollständige Adresse des Labels
eines Interrupt-Behandlers, undzwar als 48-stellige Zeiger ("pointer") definiert.
Die Deskriptoren, und damit die Pointer in der IDT werden statt mit einem Selektor mit einer
8-stelligen (=1Byte) Interruptnummer adressiert, die zu einer Basisadresse im Register "idtr"
addiert wird, nachdem sie um drei Stellen links geschoben und damit mit 8 multipliziert wurde.
Hier ist zunächst nicht weiter interessant, dass praktisch alle Geräte, die über einen
Controller an den AT-PC angeschlossen sind, sowie CPU und FPU mindestens einen Interruptgeber
enthalten und folglich nur regierbar sind, wenn ein Interrupt-Behandler geschrieben wurde. Diese
Interrupt-Behandler sind nämlich normalerweise schon Teil des Betriebssystems.
Es gibt aber ausser diesen "hardware interrupts" auch "software interrupts", die durch das
"int"-Kommando betätigt werden. Dieses Kommando, gefolgt von der Interruptnummer, entspricht
also dem "call"-Kommando, wobei allerdings alle Register auf den Stack gerettet werden und
eben deshalb nur mit dem besonderen Rückkehrkommando "iret" wieder zurück geschrieben werden.
"int"-Kommandos sind eigentlich Blödsinn, aber je nach Betriebssystem dann unverzichtbar,
wenn dies keine Chance lässt, in der IDT herum zu schreiben, also auch das Schreiben von
Interrupt-Behandlern verbietet. Alle gängigen Betriebssysteme sind dieser Art und stellen dann
mindestens einen "Betriebssysteminterrupt" zur Verfügung, über den mittels Funktionsnummern
Zubringerdienste geordert werden können, z.B. den Code einer gedrückten Taste zu geben.
Manchem mag angenehm sein, sich nicht um oftmals komplizierte Controller kümmern zu müssen -
Anderen ist das garnicht angenehm, weil erstens ein Betriebssysteminterrupt enorm Zeit frisst
und man zweitens dem ausgeliefert ist, was einer in den Interrupt-Behandler reingeschrieben
hat. Das ist oft nicht die beste Lösung und manchmal absolut untauglich.
Ausserdem ist nur ein Betriebssysteminterrupt vorhanden, über den viele Dienste natürlich nur
mit vielen Funktionsnummern geordert werden können, die wiederum eine zeitraubende Selektion
nötig machen.
Will man eigene Interrupt-Behandlung, muss man mindestens ein Betriebssystem haben, das man
selber umschreiben kann, das also als Quelltext zu haben ist.
Die eben angesprochenen 48-stelligen Zeiger sind Adressen, die immer aus einer 32-stelligen
Offsetadresse in den Stellen 0-31 und einem 16-stelligen Selektor in den Stellen 32-47
bestehen. Sie liegen mit dem untersten Byte auf niederster Adresse im Speicher und können
dort mit einem besonderen Kommando direkt in die CPU transferiert werden:
lds eax,[pointer]
... schreibt die unteren vier Byte ab Adresse von "pointer" an eax, während die darüber
stehenden zwei Byte an das Segmentregister ds geschrieben werden. Als Ziel der Offsetadresse
kann jedes beliebige Register dienen, als Ziel der Selektorbytes ausser cs jedes beliebige
andere, indem dessen Name mit Präfix "l" versehen zum Mnemonik wird: lds, les, lfs, lgs, lss
Diese Zeiger werden immer zur Adressierung benutzt, wenn in einem fernen Segment adressiert
werden muss und infolgedessen auch Selektoren definiert werden müssen.
Ein Beispiel für einen Ruf über Segmentgrenzen hinweg mit Adresse der Sprungzieladresse
auf absoluter Adresse:
lfs eax,[es:1000h]
....womit ein 48-stelliger Zeiger ab Adresse es:1000h gelesen wird.
call FAR [fs:eax]
....womit der nun in fs:eax liegende Zeiger zur Adressierung der Sprungzieladresse dient,
die ebenfalls als Zeiger definiert sein muss, weil FAR (=fern im nächsten Zyklus) gerufen
werden soll.
Prozeduren können nur entweder nah oder fern gerufen werden, weil ja wohl zweierlei ist, ob
nur eine 32-stellige Offsetadresse oder auch noch eine Segmentadresse definiert werden muss.
Und diese unterschiedlichen Adressen sind auch beim Rücksprung nötig!
Eine mit dem Schlüsselwort "FAR" gerufende Prozedur muss deshalb stets mit dem Kommando
"retf" statt "ret" abgeschlossen sein, damit die Rückkehradresse ebenfalls ein Pointer ist.
Der Zeiger für den Rücksprung liegt in der vorgestellten Ordnung auf dem Stack, wobei der
Stackpointer aber um 8 Byteadressen statt sonst 4 dekrementiert wurde (normalerweise
"wächst" der Stack abwärts, undzwar immer in Schritten von 4 Byteadressen).
Mit solchen Rufen hat man nur zu tun, wenn das Betriebssystem die Segmentierung nutzt und
den page mode nicht eingeschaltet hat. Solche Rufe werden schneller erhört als irgendwelche
anderen über page- und linker-Tabellen aber ohne Segmentangaben, wie sie mit "höheren"
Programmiersprachen implizit ausgestossen werden können.
Der nächste Begriff ist der einer Schleife, die im wesentlichen so aussieht:
Schleife:
mov eax,ebx ;...tue irgendwas
jmp Schleife ;...und gleich nochmal
Dieser aus zwei Kommandos bestehende Ablauf wird vollzogen bis der Stecker gezogen wird, weil er
mit dem zweiten Kommando, einem unbedingten Sprung, stets auf das erste Kommando zurück springt.
Macht man einen bedingten Sprung und etwas in der Schleife, was irgendwann das Sprungkriterium
ergibt, hat man den Normalfall einer Schleife:
mov ecx,10h
Schleife:
dec ecx ; dekrementiere den Wert in ecx
jne Schleife ; springe so oft, bis das ZERO-flag gesetzt wird
Diese Schleife wird so oft durchschritten, bis die Operation innerhalb der Schleife das Setzen
des ZERO-flags ergibt, also 16 mal. Natürlich ist sowas nicht besonders brauchbar (höchstens als
Zeitverzögerung), zeigt aber, dass nur wenig mehr getan werden muss, um beliebige Abläufe
abzählbar häufig stattfinden zu lassen. Weil dabei normalerweise auch nicht immer der gleiche
Wert operiert werden soll, ist auch eine Adressmanipulation nötig:
mov ecx,10h
mov esi,10h ; klassisches Adressregister
mov edi,0 ; klassisches Adressregister - wird es operiert, ändert sich auch der Wert in ebx !
Schleife:
dec ecx ; dekrementiere den Wert in ecx
mov eax,[esi+ecx] ; die Werte in esi und ecx werden in einem Zyklus addiert zur
mov [edi+ecx],eax ; ...Adresse im Arbeitsspeicher und transferiert
jne Schleife ; springe so oft, bis das ZERO-flag gesetzt wird
Weil während einem "mov"-Kommando keine flags berührt werden, bleibt das beim Dekrementieren
auf =0 gesetzte ZERO-flag unverändert. Das ändert sich schlagartig, wenn eine weitere
arithmetische oder logische Operation danach erfolgt!
In der Schleife werden nicht nur 16 Werte um 16 Byteadressen nach unten (zu kleineren
Adressen zu) versetzt, sondern auch beginnend mit dem auf der höchsten Adresse stehenden
Dword (=4 Byte). Die Schleife enthält also dann einen Fehler, wenn die höchste Zieladresse im
Bereich der Quelladressen liegt.
Fehler entstehen auch dann leicht, wenn man einen Zusammenhang zwischen Schleifenzähler in ecx
und Adressen hat. Im Beispiel findet der letzte Transfer von Adresse esi+0 nach edi+0 statt.
Würde man das Kommando "dec ecx" als letztes Kommando vor dem Sprung schreiben, wäre der letzte
Transfer zwischen esi+1 und edi+1 !
Ebenso würde sich der erste Transfer um eine Adresse verschieben!
Auch der schlimmste Fehler sei noch genannt: ecx=0
Dann nämlich erscheint nach dem ersten Dekrementieren die grösstmögliche Zahl =FFFFFFFFh
und nicht etwa ein gesetzter Übertrag (,der hier ja auch nicht verwertet würde).
Damit wird klar, wie wichtig die Abfolge von Kommandos in einer Schleife ist. Man kann aber
natürlich auch den sicheren Weg gehen, wie er üblicherweise durch Hochsprachenübersetzung erzeugt
wird:
...........................
Schleife:
mov eax,[esi]
mov [edi],eax
inc esi
inc edi ; Falls edi operiert wird, wird auch der Wert in ebx geändert!
dec ecx ; dekrementiere den Wert in ecx
jne Schleife ; springe so oft, bis das ZERO-flag gesetzt wird
Die Ausführung dieser Schleife dürfte etwa 50% mehr Zeit und Arbeitsspeicher kosten. Und das
kann ganz schön lähmend sein, wenn viele Megabyte in der Schleife transferiert werden sollen.
Noch mehr Zeit kostet eine Formulierung, die als Abbruchkriterium nicht einen Zählerstand,
sondern das Erreichen einer Grenze verwendet. Und so richtig schauerlich werden
verschiedene alternative Abbruchkriterien. Aber in Assemblerprogrammen kann man sehr einfach
einschätzen, was länger dauert, eben weil jedes Kommando geschrieben muss.
Man kann also leicht sehen, dass folgende Schleife zwar ebenfalls den Zweck erfüllt (und in
Hochsprachen als beliebige Alternative erscheint), aber deutlich mehr Zeit kostet:
mov ecx,10h
mov esi,10h ; klassisches Adressregister
mov edi,0 ; klassisches Adressregister
xor edx,edx
Schleife:
mov eax,[esi+edx] ; die Werte in esi und ecx werden in einem Zyklus addiert zur
mov [edi+edx],eax ; ...Adresse im Arbeitsspeicher und transferiert
inc edx ; inkrementiere den Wert in edx
cmp edx,ecx ; quasi-Subtraktion edx-ecx, wobei nur die flags gesetzt werden
jne Schleife ; springe so oft, bis das ZERO-flag gesetzt wird
Allerdings ist die Herleitung eines Sprungkriteriums mittels Subtraktion, wonach auch das
CARRY-flag nutzbar ist, oder mittels Undierung durchaus sinnvoll. Dies ist immer dann der Fall,
wenn nicht abzählbar ist, wie oft ein Schleifendurchlauf benötigt wird, weil das Abbruchkriterium
erst in der Schleife gefunden werden muss (z.B. durch Zeichenvergleiche).
Schliesslich ist noch zu bemerken, dass eine grosse Anzahl von Kommandos in die CPUs des AT-PC
eingebaut sind, die zwar knappere Formulierungen im Quelltext erlauben, aber Einschränkungen in der
Benutzung der Register erzwingen, die sie nur in Ausnahmefällen brauchbar machen. Dazu gehören
alle mit dem Kommando "rep" (das ein Opcode-Präfix wird) erweiterten Zeichenkettenkommandos
("string-commands") und das Kommando "loop", mit dem nicht Adressregister implizit in- oder
dekrementiert werden, sondern das Register ecx dekrementiert wird. Zwei weitere Varianten erlauben
ausserdem die Abbruchbedingung mittels ZERO-flag, das bei diesem Kommando nicht durch
dekrementieren von ecx beeinflusst wird. Dennoch ist das Resultat nicht eine schnellere Schleife,
der Wert in ecx kann nicht als Teil der Adresse benutzt werden wie im Beispiel oben, und wegen der
nur 7-stelligen relativen Adressierung darf die Schleife nicht besonders lang werden.
Das darf man also einfach vergessen...
Neben Schleifen, die die Entscheidung über den Sprung zurück in die Schleife enthalten und die
weiter auch garnicht kategorisiert werden müssen, sind auch andere Herleitungen von Entscheidungen
etwas, was immer sein muss. Hier muss ich an das ganz oben gesagte anknüpfen, wo ich feststellte,
dass nur wenige Bits in Gestalt von flags eine Rolle spielen.
Als Sprungkriterium sind im AT-PC neben ZERO- und CARRY-flag noch das OVERFLOW-, SIGN- und
PARITY-flag verfügbar. Es sind auch gleichzeitig mehrere dieser Kriterien in verschiedenen
Relationen benutzbar.
Man kommt aber normalerweise mit Feststellung von Resultat=0 oder Übertrag und den jeweiligen
Verneinungen aus und muss sich für den Anfang eine Liste der verfügbaren Kürzel und ihrer
Bedeutungen in Griffweite halten.
Dabei sollte man sich genau anschauen, welche flags tatsächlich wie verwertet werden,
denn manche Mnemonics sind völlig identisch. Das oben benutzte "jne Schleife" kann ebenso gut als
"jnz Schleife" geschrieben werden.
Weitere allfällige Äquivalenzen sind jae=jnb=jnc, jz=je, jb=jc=jnae .
Und natürlich sollte man im Kopf haben, welche flags nach welchen arithmetischen oder logischen
Operationen tatsächlich eine Rolle spielen! Das CARRY-flag wird z.B. beim Dekrementieren von 0
oder Inkrementieren von FFFFFFFFh nicht gesetzt und dient bei Schiebeoperationen nicht im
ursprünglichen Sinne, sondern als Stelle neben den geschobenen Registern, in dem man heraus
geschobene Werte nach schieben oder rotieren findet.
Ausserdem wird das CARRY-flag gerne benutzt, um Verzweigungen in gerufenen Prozeduren oder
nach der Rückkehr zu bahnen. Es ist nämlich mit besonderem Kommando setzbar:
Mit dem Mnemonik "stc" ohne weitere Operatoren setzt man es, mit "clc" setzt man es zurück.
In NASM muss man ausserdem in bedingten Sprungkommandos noch häufiger den Kürzel NEAR zwischen
Mnemonik und label einsetzen, weil zunächst die als Opcode kürzere Form des relativen Sprungs
übersetzt wird ("SHORT" mit relativen Byteadressen, die +-128 Byteadressen Reichweite ergeben).
Nun zum Begriff der Fallentscheidungen, deren einfachste Form ich gerade vorgestellt habe:
Bei der Entscheidungsfindung muss vor allem sorgfältig beachtet werden, dass das Dach nicht vor
dem Haus gebaut werden kann. Wenn man also will, dass die Differenz zweier Werte je nach positiven
oder negativen Resultaten verschieden abgehandelt wird, muss zunächst die Subtraktion gemacht
werden und damit das CARRY- oder ZERO-flag gesetzt werden.
Die flag-Bits sind wie gesagt die einzige Erinnerung, die die CPU leisten kann!
Zuächst ein Beispiel für allfällige Fallunterscheidungen:
mov eax,100h ; Definiere einen Vergleichswert zum Operanden
Fall1:
cmp eax,[wert1] ; subtrahiere den Wert in "wert1" vom unveränderten Vergleichswert
je wert1_ist_gleich ; springe, falls das ZERO-flag gesetzt wurde!
Fall2:
cmp eax,[wert2] ; subtrahiere den Wert in "wert1" vom unveränderten Vergleichswert
je wert2_ist_gleich ; springe, falls das ZERO-flag gesetzt wurde!
..................................weitere Fallabfragen
wert1_ist_gleich: ; Abhandlung für diesen Fall folgt:
..................................
jmp Erledigt ; ...und springe unbedingt zum Ende der Fallabfrage
wert2_ist_gleich: ; Abhandlung für diesen Fall folgt:
..................................
Erledigt:
Zunächst ist zu sehen, dass der Programmzähler von "Fall1" zu "Fall2" und weiteren Fallabfragen
vorrückt, wenn nicht nach irgendeiner der Quasi-Subtraktionen das ZERO-flag gesetzt erscheint.
Diese Quasi-Subtraktionen mittels "cmp" sind also das Mittel der Fallabfrage. Sie verändern im
Gegensatz zur echten Subtraktion mit "sub" nicht den Minuenden in eax, der deshalb auch in der
folgenden Fallabfrage verwendet werden kann!
Analog arbeitet man mit der Quasi-Undierung "test", bei der im Gegensatz zu "and" ebenfalls kein
Wert im Zielregister geändert wird, ein Resultat also nur durch flag-Betätigung erscheint.
Im Beispiel ist ausserdem zu sehen, dass alle Fallabfragen untereinander stehen, während die
Konsequenzen ganz woanders abgehandelt werden. Natürlich muss das nicht so sein:
...................
Fall1:
cmp eax,[wert1] ; subtrahiere den Wert in "wert1" vom unveränderten Vergleichswert
jne Fall2 ; springe, falls das ZERO-flag NICHT gesetzt wurde!
.................. ; Handle den Fall der Gleichheit ab:
jmp Erledigt ; ...und springe unbedingt zum Ende der Fallabfrage
Fall2:
..................................
Erledigt:
In der Praxis stellt sich heraus, dass die hier völlig aequivalent erscheinenden Abläufe das
keineswegs mehr sind, wenn das erste Kommando nach "wertx_ist_gleich" ein indizierter Sprung
oder FAR-sprung oder ein Prozeduraufruf ist. Dann nämlich sind Besonderheiten in der CPU zu
berücksichtigen, um auf die optimale Lösung zu kommen - hier die zweite.
Zunächst mal muss man immer Kopf haben, dass die zu Entscheidungen nötigen Bedingungen nur mit
bedingten Sprüngen verwertet werden können, die im Opcode relative Adressen enthalten.
Man kann bedingt weder direkt mit (Segment-) absoluter Adresse springen noch indiziert!
Und bedingte Prozedurrufe gibt es schon garnicht!
Schliesslich ist zu bedenken, dass die CPU im AT-PC stets automatisch folgende Opcodes einliest,
während die Opcodes nach einer Verzweigung erstmal geholt werden müssen. Der Geradeauslauf geht
also schneller, während ein Absprung das Leeren der pipelines in der CPU, also eine Art
Schluckauf zur Folge hat.
Auch diese Entscheidungsherleitungen müssen keineswegs kategorisiert werden, weil die gezeigten
Beispiele leicht abzuwandeln sind, wenn andere Fallunterscheidungen nötig sind, wo nicht
verschiedene Werte mit einem einzelnen in eax verglichen werden, sondern beispielsweise mehrere
aufeinander folgende Werte bei einem Zeichenketten-Vergleich. Obgleich das auch mit einem
besonderen Mnemonik gemacht werden kann, empfiehlt sich in jedem Falle die ausgeschriebene
Form! Sie läuft immer schneller oder ist sogar die einzig mögliche, wenn die zu vergleichenden
Werte in verschiedenen Segmenten liegen, die nicht immer mit Selektoren in ds und es angesprochen
werden können - falls dort nämlich bestimmte andere Selektoren liegen müssen.
Schliesslich erlaubt die gezeigte Form auch, dass mit abgewandeltem Startwert erneut in die
gleiche Selektion gesprungen werden kann. Das kann viel Opcode einsparen und ist in Hochsprachen
nicht ausdrückbar!
Ebenfalls nicht ausdrückbar sind Fallentscheidungen mit indizierten Sprüngen, die zigmalige
Entscheidungen der gleichen Art vollkommen erübrigen können. Zum Beispiel sei die Entscheidung
abhängig von einem bestimmten Bit in einer Statusvariablen, undzwar in zwei Verzweigungen:
status: DD 10b ; bit 1 kann gesetzt sein oder nach bestimmten Vorgängen nicht...
Verzweigung1: DD Lesen ; mit Adresse von "Lesen" vorbesetzter pointer
Verzweigung2: DD Prüfen ; mit Adresse von "Prüfen" vorbesetzter pointer
...............................
weiteres Programm, in dem die Variablen "Verzweigung*" mit anderen pointern besetzt werden können.
...dann sehen Verzweigungen nur noch so aus:
jmp [Verzweigung1] ; springe auf Adresse unter Adresse von "Verzweigung1"
...............................
jmp [Verzweigung2] ; springe auf Adresse unter Adresse von "Verzweigung2"
In "höheren" Sprachen ist dagegen stets erneut das bit 1 in "status" zu testen und angepasst zu
verzweigen! In einem Assemblerprogramm kann man sich diese Statusvariable meist schenken. Das ist
aber der geringste Teil des Gewinns, der noch sehr viel grösser werden kann durch etwas weiteres,
was in "höheren" Sprachen nicht zu machen ist - Prozeduren mit mehreren Einsprungadressen:
Prozedur_Festplatte_lesen:
mov eax,Lesen
mov [Verzweigung1],eax
..................
jmp Los_gehts
Prozedur_Festplatte_schreiben:
mov eax,Schreiben
mov [Verzweigung1],eax
..................
Los_gehts:
..................
jmp [Verzweigung1] ; hier wird je nach Einsprung auf "Lesen" oder "Schreiben" verzweigt
Schreiben:
..................
ret
Lesen:
..................
ret
Solange nur zwei alternative Abläufe in einer Prozedur vorkommen, kann ebenso gut mit dem
Zustand des CARRY-Flag gleich nach dem Ansprung verzweigt werden.
Solche Verzweigungen statt zwei oder mehr verschiedenen Prozeduren machen dann Sinn, wenn die
Abläufe in den Prozeduren teilweise gleich sind.
Der nächste Begriff sind I/O-Zugriffe.
So oder ähnlich werden alle Abläufe ausgedrückt, die mit dem I/O-Adressbereich, also den
Registern in Controllern umgehen. Sie sind grundsätzlich nicht in "höheren" Programmiersprachen
ausdrückbar, was als Qualitätsmerkmal gilt. Sie sind dann ebenso grundsätzlich in der Gestalt
von Prozeduraufrufen zu machen, die in aller Regel über den Betriebssysteminterrupt mit Treibern
im Betriebssystem verknüpft sind. Weil dieser Zugriff um drei Ecken natürlich nicht besonders
schnell geht, sind nur Machworte verwendbar, die genug Definition enthalten, um den Zugriff
zeitlich nicht in Erscheinung treten zu lassen. Ein in aller Regel sehr zeitkritischer Zugriff
auf die Controller selbst findet dann nur innerhalb des Treibers im Betriebssystem statt.
In Assembler sind solche Zugriffe allerdings so einfach, dass man sie keineswegs noch
vereinfachen müsste (bzw.könnte). Es ist nur zu beachten, das das "mov"-Kommando und effektive
Adressen nicht taugen, um Controller-Register zu adressieren. Erst mit Einführung des PCI-Busses
hat sich daran etwas geändert (sofern nicht "Rückwärtskompatibilität" nötig ist).
Stattdessen spielen die beiden Mnemoniks "in" und "out" die tragende Rolle. Dabei hat die Adresse
stets in dx zu stehen, wenn sie nicht als Immediatewert in einem Byte angegeben werden kann. Ziel-
oder Quellregister können nur eax bzw. niederwertige Teile des Registers sein, also: eax,ax,al
Bevor also ein I/O-Transfer kommandiert wird, muss die I/O-Adresse in dx definiert werden! Falls
"out" gegeben wird, muss natürlich auch eax besetzt werden. Das sieht dann etwa so aus:
mov edx,[AdresseTastatur]
in al,dx
....womit ein Wert von der Tastatur aus dem Controller gelesen wird.
mov edx,[timeout]
mov eax,20h
out dx,al
....womit der Dekrementierer des Timers mit einem Startwert geladen wird. Wenn der auf =0
herunter gezählt wurde, gibt der Timer-Controller eine Unterbrechungsanforderung aus
(="Interrupt ReQuest"=IRQ). Auch die allermeisten anderen Controller haben Unterbrechungsausgänge,
um sich nach Ausführung eines Kommandos zu melden. Daneben sind aber auch Status-Register in
Gebrauch, die in Schleifen periodisch abgefragt werden müssen.
Beispiele für solche Abläufe sind z.B. in meinem FDOS1 bestens kommentiert zu besichtigen.
Hier ist deshalb nur noch zu sagen, dass abgesehen von den beiden Mnemoniks "in" und "out"
nichts weiteres anders ist als in anderen Abläufen auch. Allerdings kann die Programmierung sehr
knifflig werden, wenn ein Controller nicht richtig dokumentiert ist, denn natürlich hat man sich
sehr genau mit dessen Registeradressen und Bitbedeutungen zu befassen! Das heisst mindestens
hundert Seiten zu lesen und keinen Halbsatz zu übersehen.
Schliesslich ist der Begriff der Fliesskommarechnung noch zu erwähnen, der zunächst besondere
"Zahlenformate" bedingt, weil binär natürlich Komma oder Potenz ebensowenig wie Minuszeichen
ausdrückbar sind, wenn nicht besondere Vereinbarungen für Stellenbedeutungen getroffen werden.
Dieses Problem und die ursprünglich nicht in einer CPU verfügbaren höheren Rechenarten waren
einst einer der Gründe, sich überhaupt eine "höhere" Programmiersprache auszudenken und hoffen
zu können, damit Geld zu machen.
Seit dem Typ i486 der Fa.Intel sind aber Fliesskommarechner auf dem selben Chip integriert, auf
dem die CPU siedelt. Bereits im Befehlssatz der CPU sind die Kommandos zum Multiplizieren und
Dividieren ganzer, ohne oder mit Vorzeichen behafteter Zahlen zu haben. Die FPU enthält darüber
hinaus alles für den Umgang mit Stellen vor und hinter einem Komma und mit Winkelfunktionen.
Zur Darstellung der Zahlen musste natürlich eine bestimmte Form erdacht werden und zur Regel
gemacht werden. Das im AT-PC verwendete "Fliesskommaformat" ist unter dem Kürzel "IEEE 754"
bekannt und genormt.
Für die Ein- und Ausgabe von mehr als 32 Stellen je Wert wurde ursprünglich ein FPU-interner
Stapel vorgesehen. Mittlerweile sind aber auch spezielle Register zu haben, die mit den Kürzeln
"mmx" oder "3DNow!" gemeint sind. Da diese nicht in jeder CPU zu haben sind, müssen in einem
Programm, das damit umgeht, Vorkehrungen für den Fall des Fehlens getroffen werden. Man hat mit
dem "cpuid"-Kommando den Typ der aktuellen CPU zu erfassen!
Abgesehen von etwa einem Dutzend spezieller Mnemoniks, ist bei der Programmierung der FPU auch
sehr wichtig, einen Interrupt-Behandler für mindestens die Interrupt-Nummer 10h zu schreiben,
die für die FPU reserviert ist. Beispielsweise wird mittels Interrupt die Division von =0
moniert oder ein Resultat von unendlich angezeigt. Weil es natürlich verschiedene Wege der
Verwertung gibt, ist hier nicht mehr dazu zu sagen.
Ähnlich wie bei I/O-Kommandos dx und ax definiert werden, müssen vor den FPU-Kommandos die
Stapelregister der FPU definiert werden. In den Kommandos kann dann darauf Bezug genommen werden.
Irgendwelche Schwierigkeiten kann das aber nicht machen! Eine genauere Dokumentation findet sich
in den Quellen zu NASM und auf deutsch in den Quellen zu meinen Übersetzungsprogrammen ASMn bzw.
ASMat. Meine Übersetzungsprogramme enthalten ausserdem eine reichlich kommentierte und in
Assembler programmierte Wandlung von dezimal definierten Fliesskommawerten in die eigentlich
nötigen dualen Werte. Dabei werden Multiplikationen in 128 Stellen und Divisionen in 64 Stellen
ohne FPU getan. In diesen Beispielen ist auch eine Potenzierung von 5 mit beliebigen Exponenten
zu besichtigen, ebenfalls ohne FPU.
Tatsächlich ist die FPU nicht zu gebrauchen, falls mehr als 64 Stellen definiert sein sollen.
Die CPU kann aber weit darüber hinaus dienlich sein - falls Zeit nicht unbedingt eine Rolle spielt.
Bei solcher Arithmetik sind natürlich Schleifen zu formulieren, in denen Schiebetakte,
arithmetische Kommandos und durch Übertrag oder Vorzeichen bedingte Verzweigungen wesentlich sind.
Unverzichtbar sind dann aber die Rotierkommandos "rcl", "rcr", die Schiebekommandos "shld","shrd"
und die Einzelbitkommandos, insbesondere "bsr", das gestattet die Bitadresse der führenden 1 mit
diesem einen Kommando statt einer Schiebeschleife zu erfassen.
Mit diesen Einzelbitkommandos und der dabei verwendeten Bitadresse kann man ausserdem noch eine
Bitadressierung in 1G Bit grossen Feldern einrichten - das aber nur am Rande.
Es bleibt nur zu sagen, dass die über 64 Stellen Genauigkeit hinaus gehende Arithmetik nur mit
Assemblerprogrammierung zu haben ist, und dass die 64-Bit-Genauigkeit wegen nötiger Rundungen
nur in Ausnahmefällen mit der FPU zu haben ist.
Bei allen Programmsequenzen ist stets zu beachten, dass die Bedeutung eines Ablaufes immer dem
Ablauf folgt! Nur Bedeutungsgebung kommt vor. Irgendeine Sinngebung ist jenseits aller Logik.
Dies hat weitreichende Konsequenzen, die ich im Zusammenhang mit "höheren" Programmiersprachen
noch zu betrachten habe, weil es Andersgläubige gibt. Hier ist aber festzustellen, dass der
gleiche Ablauf durchaus sehr verschiedene Bedeutung abhängig von An- und Auslauf haben kann, und
dass das nicht ein Nachteil ist, den man wegbügeln müsste, sondern alle Herrlichkeit auf Erden,
die kürzeste Wege von A nach B erlaubt.
Es ist also stets der weitere Zusammenhang zu betrachten, undzwar genau so, wie bei einem
einzelnen Kommando auch! Und stets ist auch die Abfolge der Kommandos nicht beliebig! Deshalb
sollte man sich den Überblick leichter machen, indem man Einrückungen vornimmt.
Optimierungen ergeben sich normalerweise während des Schreibens ganz und gar beiläufig, wenn
man mit den Registerinhalten haushaltet. Man kann dann auch leicht feststellen, dass man den
ausdrücklichen Transfer zu Registern besser nicht wegoptimieren sollte und dass der ausdrückliche
Bezug zu den Flags eigentlich unverzichtbar ist. Diese paar Bits sind schliesslich das ganze
Gedächtnis der CPU!
PROGRAMMIEREN MIT GNU-C/C++ :
Weil Viele, die gerne Computer programmieren wollten, mit der Logik, die diese Maschinen
kennzeichnet, haderten, wurde fleissig daran gearbeitet, sie nicht mit ihrer in Assembler 1:1
übersetzten Sprache, sondern über neue Ausdrucksweisen ansprechbar zu machen - ganz so wie
Hundebesitzer auch lieber nicht bellen, sondern "Platz!" oder "Fass!" rufen wollen.
Und natürlich wollte keiner dieser Vielen sein Unverständnis eingestehen, sondern edle
Zwecke vortragen für sein Bemühen, Computer zu dressieren...
Es gibt deshalb angeblich insgesamt rund 1000 Programmiersprachen - ich habe nicht nachgezählt.
Sie alle können aber nicht mehr ausdrückbar und programmierbar machen, als das, was der Opcode
einer Zielmaschine ausdrückbar und programmierbar macht, und was infolgedessen 1:1 in Assembler
programmierbar ist.
Das darf man nicht vergessen, während man das folgende liest!
Neben in der Frühzeit durchaus vorhandenen vernünftigen Zwecken für Ergänzungen des Assemblers,
die ich oben bereits ansprach, gab es aber bald auch andere Gründe.
Es stellte sich heraus, dass viel Geld mit neuen Programmiersprachen gemacht werden konnte
- es konnten sogar Weltfirmen darauf gegründet werden!
Das darf man auch nicht vergessen, während man das folgende liest!
Auch für die Dressur des AT-PC gibt es nicht nur mehrere "höhere" Programmiersprachen, es gibt
insbesondere bei C/C++ noch mindestens ein halbes Dutzend Dialekte, die nur bedingt kompatibel
sind. Das Übersetzungsprogramm, der "Compiler", für den einen Dialekt verarbeitet den anderen
nicht. Man muss allerdings den GNU-Compiler "g++/gcc" ausnehmen, wo Optionen für verschiedene
Dialekte vorhanden sind. Man kann auch einige Ausdrücke mehr als in der standardisierten Form
verwenden, aber die werden nicht mehr von anderen Compilern akzeptiert werden.
Ausgangspunkt dieser Sprach-Entwicklung waren 1970 erste Schritte in der Firma AT&T Bell
Laboratories zu einem universellen Betriebssystem, das UNIX genannt wurde. Aber erst durch
die Absicht, UNIX auf DEC-Rechnern zu implementieren, wurde aus der wesentlich von Dennis
M.Ritchie aus B entwickelten Sprache C der erste Haufen Funktionen, die zunächst benutzt
wurden, UNIX für die Zielmaschine PDP-11 zu programmieren.
Seit 1983 bemüht sich ein ANSI-Normungsgremium um eine C-Sprachdefinition, die ausdrücklich
"maschinenunabhängig" sein sollte. Damit ist gemeint:
-> Weniger Buchstaben für Mnemoniks und ihre Verallgemeinerung.
-> Erübrigung des "Umwegs" über CPU-Register beim Transfer von Werten.
-> Statt vieler Namen für Label eine formelartige Ausdrucksweise mit Klammern und Klammerebenen.
-> Formulierungen mathematischer Operationen in der aus der Schule gewohnten Form.
-> Ausgrenzung von CPU-spezifischen Methoden der Adressierung zugunsten allgemeiner.
-> Ausgrenzung von Controller-spezifischen Methoden des Zugriffs auf Daten.
Solche Abstraktionen sollten erlauben, Programme auf beliebigen Zielmaschinen laufen zu lassen,
ohne sie umschreiben zu müssen. Den Umgang mit Eigenarten einer Maschine auf ein angepasstes
Betriebssystem und den Compiler zu verschieben, gelang aber bis heute nicht, wie man an der
Unzahl von Optionen leicht feststellen kann, die die Bedienungsanleitung (="manpage") des
GNU-Compilers auflistet. Ein Programmierer hat also sowohl die Zielmaschine, als auch das
Ziel-Betriebssystem mit dem darunter verfügbaren Dialekt zu berücksichtigen!
Mit Verallgemeinerungen wird zwar eine Zielmaschine Mensch fertig, weil der aufgrund
von Erfahrungen Einzelheiten rekonstruieren kann, nicht aber eine Zielmaschine CPU und auch
dann nicht, wenn ein Compiler die menschliche Erfahrung einbringen soll, die dann nämlich nur
eine Suche nach dem kleinsten gemeinsamen Nenner sein kann.
Tatsächlich sind die ursprünglichen Vorstellungen Zug um Zug in einem langwierigen
Normungsprozess erodiert worden bis hin zum "inline-Assembler", der mittlerweile gemäss
Normierung Teil eines C-Compilers zu sein hat, dessen Dialekt aber nicht normiert ist!
Dennoch fällt den meisten schwer, wahr zu haben, dass damit der Ansatz der Entwicklung "höherer"
Programmiersprachen ad absurdum ist.
Immerhin wird nicht mehr nur die Unabhängigkeit von der Maschine, sondern auch die Nähe zu
ihr als erstrebenswert und erreicht gefeiert.
Weil aber tatsächlich das eine nur auf Kosten des anderen gehen kann, ist C/C++ auch höchstens
den Maschinen "näher" als andere "höhere" Programmierssprachen, nicht aber "maschinennah".
Als Beispiel für "maschinenfern" soll "PASCAL" erwähnt sein, wo man überhaupt nur auf niedrigere
Adressen springen konnte. Ein besser nicht namentlich genannter Professor aus Zürich war nicht
imstande, seinem PASCAL-Compiler eine Label-Adress-Zuweisung einzubauen, die die Mühe wert
gewesen wäre. Allerdings wurde auch diese Programmiersprache bald nach der Einführung um einen
inline-Assembler ergänzt.
Die Maschinennähe von C/C++ geht jedenfalls nicht allzu weit und bedeutet für den AT-PC, dass
der page mode eingeschaltet werden muss, weil in C keinerlei Möglichkeiten für den Umgang mit
Selektoren vorhanden sind. Der page mode arbeitet Prinzip bedingt mit mindestens drei
zusätzlichen Arbeitsspeicherzugriffen bei jedem einzelnen Arbeitsspeicherzugriff, der andernfalls
nur einen zusätzlichen Zugriff auf die GDT erfordert. Dadurch wird die Ausführungsgeschwindigkeit
jedes Programms auf weniger als die Hälfte reduziert!
Es muss aber auch ein UNIX-artiges Betriebssystem vorhanden sein, weil in C/C++ auf
Abstraktionen bezogen wird, die UNIX kennzeichnen. Das gilt insbesondere für die Verwertung
von Flags und die Behandlung von Daten-, Datei- und I/O-Transfer.
Deshalb muss ich einige Sätze zu UNIX machen, das zum Stammvater aller Betriebssysteme
geworden ist.
UNIX basierte zunächst auf Abstraktionen, die angesichts wirklich niedlicher Maschinen
entstanden. 1970 hatte der Computer im Pentagon nicht mal die Arbeitsspeichergrösse, die heute
jeder PC-Benutzer zur Verfügung hat. Angesichts der damals verfügbaren Geräte für Ein- und
Ausgabe konnte man auch die Hoffnung haben, das Wirken von Controllern hinter der Abstraktion
von "device" und die Eigenart von Datenmengen hinter der Abstraktion von "files" verschwinden
lassen zu können, die flüssig werden in "streams" und sich dann in "channels" bewegen - kurz:
mit einigen wenigen Funktionen wie printf(....), fread(....) usw. alles regierbar zu machen.
Tatsächlich sind solche Abstraktionen ebenso sinnfällig wie daneben. Schon die Abläufe
beim Schreiben der immer gleichen Datei auf verschiedene Magnetmedien wie Band, Diskette
oder Festplatte sind sehr verschieden. Ein Benutzer möchte zwar den gleichen Effekt, wenn er
eine Datei abspeichert, aber ein Programmierer hat sich konkret mit den Gegebenheiten und
eben nicht mit Ähnlichkeiten zu befassen. Deshalb ist auch in C trotz eines immer gleichen
Funktionsrufes namens fread(...) stets über das, was zwischen den Klammern steht, genau zu
definieren, welches Gerät Ziel oder Quelle eines Transfers ist. Das ist kein bisschen
abgekürzter als ein aequivalenter Ruf in einem Assemblerprogramm! Während dort aber eine
bezifferte Adresse den Unterschied macht, muss in "höheren" Programmiersprachen in den
Namensbereich ausgewichen werden.
Die notwendige Folge sind immer neue Namen für Dinge, die keinen Namen brauchen, und die
maschinell mit Namen schwieriger als ohne Namen, aber mit Adresse zu finden sind.
"Höhere" Programmiersprachen sind grundsätzlich dadurch gekennzeichnet, dass Namen als Abstraktion
nicht nur für Adressen in Quellprogrammen (Label) zugelassen sind, sondern verschwenderisch an
alles und jedes vergeben werden, das irgendwie unterscheidbar sein sollte - und das ist sehr viel.
Viel mehr, als sich die Abstrahierer der ersten Stunde denken konnten! Und leider kann die
Namensschöpfung auch noch Gewinn bringend betrieben werden.
Während also in Assembler längst alles ausdrückbar ist, ringen Hochsprachler immer noch um Worte...
Das führt zu ewigen Listen und langwieriger Zeichenkettenerkennung als Grundlage von Abläufen,
die ohne weiteres auch mit Adressen und Bit-Tests vollzogen werden könnten. Erst moderne AT-PCs
verdecken mit ihrer enormen Geschwindigkeit die enorme Verschwendung von Speicherplatz und
Laufzeit solcher Philosophie.
Ein sofort erfahrbarer Effekt aber ist, dass man ein Programm überhaupt nur verstehen kann, wenn
man alle standardisierten Bezeichnungen kennt und sie nicht als Label verkennt.
Ausserdem gibt es Bibliotheken von Standardfunktionen, die ebenfalls reservierte Namen haben.
Allein die Auflistung der Namen mit kürzest möglicher Nennung der Funktion füllt mindestens 200
Buchseiten. Wer unbedingt auf der Höhe der Entwicklung sein will, muss unter den Namen der
Normungsgremien im Internet stöbern:
"ANSI X3J16" oder auch "ISO WG21"
Die Standardfunktionen sind allerdings nur ein Bruchteil der Funktionen, die in zahlreichen
anderen Bibliotheken schlummern. Die werden dann wichtig, wenn man mehr als eine Ein- und Ausgabe
von Zeichenketten haben will - z.B.farbige Darstellung von Bildern. Diese weiteren Bibliotheken
sind allerdings stark abhängig vom Ziel-Betriebssystem.
Hier führe ich deshalb nur in die Programmiersprache C so gründlich ein, dass man ohne weitere
Informationen damit programmieren kann. Ich handle also auch die praktische Nutzung des
GNU-Compilers und notwendiges Drumherum unter LINUX ab, sowie diejenigen C-Standardfunktionen,
die für normale Zwecke unverzichtbar sind.
Die Erweiterung C++ dagegen handle ich vor allem im Hinblick auf Eigenarten und Schlüsselworte ab,
die als Vorteile gegenüber C gepriesen werden.
Das Wissen um die zugehörigen Bibiliotheksfunktionen muss woanders bezogen werden. Ohne weitere
Informationen kann man deshalb nicht mit C++ programmieren, kann aber C++ Quelltexte wenigstens
als solche erkennen und vielleicht sogar verstehen.
Fangen wir also an mit den standardisierten Ausdrücken der Programmiersprache C/C++ :
Mnemoniks bestehen hier aus ein oder zwei Zeichen, die nicht Abkürzungen englischer Worte sind,
sondern eher gewohnte. Allerdings ist die Bedeutung oder Abfolge oft keineswegs die aus dem
Mathematikunterricht gewohnte. Das heisst: Nichts ist wie gewohnt, sondern täuschend anders!
Man spart auch nicht etwa irgendein Kommando ein! Mindestens implizit müssen genau so viele
(wenn nicht mehr) Kommandos als in Assembler gegeben werden.
Daneben aber gibt es eine grosse Anzahl standardisierter Ausdrücke, die nicht mehr Abkürzungen,
sondern vollständige englische Ausdrücke sind. Diese "reservierten Bezeichner", die jeweils ganz
bestimmte Bedeutung haben, dürfen nicht beliebig als Namen verwendet werden. Sie müssen, um vom
Compiler erkannt zu werden, von einem Zwischenraum gefolgt werden, sofern sie nicht als einzelne
Zeichen anstelle von Mnemoniks Bedeutung haben.
Das, was sie in einen verständlichen Zusammenhang bringt - der Kommentar - steht in C immer
geklammert zwischen den Doppelzeichen /*..blabla...*/ (Abfolge von "/" und "*" beachten!).
Dazwischen kann jedes beliebige Zeichen und auch das Absatzzeichen verwendet werden.
In GNU-C/C++ kann man alternativ bis zum jeweiligen Zeilenende Text als Kommentar mittels
dem eigentlich nur in C++ genormten Präfix "//" kennzeichnen.
Die Sprache C/C++ ist "format-frei". Es gibt also nicht das Absatzzeichen als Trenner zwischen
Kommandos wie in Assembler, sondern darüber hinaus das Semikolon und schliessende Klammern.
Weil dadurch allerlei undurchschaubares rauskommt, findet man in jedem Lehrbuch ein Flehen,
es doch soundso zu machen. Die reinste Lehre besteht darin, Leerzeilen nach jeder Zeile und nur
einen Befehl in ihr zu schreiben. Das macht ein C-Programm jedenfalls noch platzraubender als
ein Assembler-Programm...
Zum Beispiel schreibt man eine Addition:
Resultat=Minuend-Subtrahend;
Links steht immer der benamte Resultatwert. Vom Minuszeichen ausgehend bewertet dann der
Compiler den ersten Operanden nach dem Gleichheitszeichen als Minuenden und den zweiten
Operanden als Subtrahenden. Die Abfolge von Operanden ist aber natürlich genauso zwingend
einzuhalten wie bei einem Assemblerkommando!
Die Zeichen, die mindestens vorhanden sein müssen, damit das Geschriebene als Programm
compiliert werden kann, sind:
int main(){int x,y,z;x=y+z;}
...wobei die Buchstaben x,y und z beliebig ersetzbare Namen für Variable sind. Auch die auf
das erste Semikolon folgende Anweisung ist beliebig ersetzbar.
Mit der reservierten Bezeichnung "main" wird die Einsprungadresse eines Programms gekennzeichnet,
die natürlich nur einmal vorkommt.
Das ganze ist ein Funktionsaufruf, den ich aber erst genauer abhandle, wenn ich Operanden und
Operatoren abgehandelt habe.
Nachdem ich bereits andeutete, dass Binärwerte, die in C mit Namen versehen werden,
immer auch mit ihrer Stellenzahl definiert werden müssen, womit sie "typisierte Variable" sind,
werde ich mal eben auflisten, welchen Typ die Variablen standardmässig haben können:
"bool" ist in C++ eine Binärstelle (das LSB in einem ganzen Byte)
"char " sind 8 Stellen
"short int " oder nur "short" sind 16 Stellen
"long int " oder nur "long" sind 32 Stellen
"float " sind 32 Stellen einer Zahl mit Komma und Exponent
"double " sind 64 Stellen einer Zahl mit Komma und Exponent
"long double " sind 80 Stellen einer Zahl mit Komma und Exponent
Vor den Typangaben kann noch ausdrücklich "unsigned" stehen, was einen vorzeichenlosen
Wert kennzeichnet im Gegensatz zu "signed", womit die höchste Stelle als Vorzeichen verwertet
wird, die Zahl der Stellen, die eine Zahl repräsentieren, also um eine vermindert ist.
Ausserdem kommen in den Standardbibliotheken weitere Typen vor.
Solche Typangaben werden ein einziges Mal je Programm vor einen Namen geschrieben, undzwar in der
Deklaration. Die wird stets mit einem ";" abgeschlossen. Variable gleichen Typs können davor mit
Kommata getrennt werden.
Solche Typdefinitionen erweitern aber nicht etwa die Möglichkeiten einer CPU!
Sie wurden ausgedacht, um Fehlermeldungen des Compilers möglich zu machen, sind also nicht nötig,
machen aber überflüssige Umstände nötig. Schliesslich erhalten Binärwerte ihre Bedeutung ja nicht
durch irgendwelche Namen, sondern in dem Programmcode, der damit umgeht. Insofern stellen
Typdefinitionen also eigentlich die Logik der Maschinen auf den Kopf.
Deklarationen können an verschiedenen Stellen im Programm stehen mit der Konsequenz, dass auch der
Gültigkeitsbereich (die "Sichtbarkeit") der Namen verschieden sein kann.
Ausserdem kann man "Konstanten" festlegen, indem vor die Deklaration einer Variablen
ein "const " geschrieben wird. Damit werden die Variablen aber nicht in der Weise konstant,
dass sie im Speicher kleben blieben - es wird nur der Compiler prüfen, ob innerhalb des
Programms neue Werte zugewiesen werden und falls ja, die Compilierung verweigern.
Die Typisierung mit "const" erfordert natürlich die Zuweisung des Wertes bei der Definition! Sie
sieht so aus:
const int Festwert=10;
Eine weitere Typisierung eines Typs verschiebt die Definition einer Konstanten in eine andere
Übersetzungseinheit. Die Zuweisung muss dann woanders stehen:
volatile int Festwert;
Man kann auf die selbe Weise wie Konstanten auch Variable initialisieren, wobei die Definition
natürlich zur Deklaration passen muss. man kann also nicht schreiben:
int Ganzzahl=5,34;
...weil der zugewiesene Wert vom Typ float wäre.
Man kann aber schreiben:
char Zeichen='a';
...obgleich eigentlich nur ein 8-stelliger Binärwert Typ ist. Der Compiler besorgt die Übersetzung
nach der ASCII-Norm, die bestimmten Buchstaben bestimmte Zahlen zuordnet. Man könnte deshalb ein
'a' auch zuweisen, indem man den entsprechenden Dezimalwert schreibt:
char Zeichen=97;
Zuweisungen von Zahlen können dezimal, wie gewohnt und oben gezeigt ausgedrückt werden,
oder hexadezimal mit voran gestelltem "0x" und grossen oder kleinen Buchstaben - z.B. "0x0A" für
den dezimalen Wert 10 oder das ASCII-Absatz-Zeichen, abgekürzt "LF" ("Line-Feed").
Auch Oktalziffern sind möglich und werden mit voran gestellter "0" gekennzeichnet.
Dualziffern sind aber unmöglich, was einen Taschenrechner nötig macht, wenn es um Bitbedeutungen
geht.
Doch damit nicht genug - auch der Unterschied zwischen Werten, die als Adresse verwendet werden
sollen und solchen, die anderes bedeuten sollen, muss ausdrücklich gemacht werden. Dazu dient das
Zeichen "*", das dummerweise auch eine Multiplikation definiert und deshalb immer zweimal
betrachtet werden sollte. Im hier beschriebenen Falle nennt man den Operator "Indirektion".
Eine Adressvariable, die "Zeiger" ("pointer") genannt wird, wird damit so definiert:
long int *Zeiger
...wobei die Typisierung nicht die Stellenzahl der Adresse betrifft, sondern den mit ihr
adressierten Wert! Diese Zeiger sind im At-PC immer 32-stellige Offsetadressen, enthalten also
keinen Segmentbezug.
Soll die Adresse aber in Standardfunktionen als Basisadresse dienen, muss sie jedenfalls so
typisiert sein:
void *Zeiger
Weil diese überflüssigen, besonderen Umstände noch einen Rattenschwanz von Konsequenzen haben,
will ich diese gleich abhaken.
Noch weitere Quasioperatoren werden nämlich wichtig, weil der falsche Umgang mit Adressen
verbaut werden soll: "&" und "&&", die ebenfalls dummerweise auch zur Definition von UNDierungen
dienen. Ausserdem sind noch weitere Zeichen und Typisierungen nötig, wenn weiter unten
beschriebene grössere Datenansammlungen (C++Klassen) mittels Zeigern adressiert werden sollen.
Jeder der drei Quasioperatoren muss für die Adressrechnung immer als Präfix vor einem Namen stehen.
Während eine Variable mit vorangestelltem "*" zum Behälter einer Adresse gemacht wird, wird
die Adresse einer Variablen als Wert zugewiesen durch den Quasioperator "&", der dann als
Präfix vor dem Namen dieser Adresse stehen muss. Das sieht so aus:
Variablenadresse=&Variablenname;
...wobei das in der Deklaration gesetzte Sternchen vor "Variablenadresse" nun fehlen muss!
Wie durch den Namen des Behälters angedeutet, gilt diese Ausdrucksweise für Adressen von
Variablen ("pointer of object"), nicht aber solchen eines Sprungziels (Labels).
Dann darf der Zeiger nur vom Typ void sein und das Präfix wird verdoppelt:
Sprungzieladresse=&&Sprungzielname;
Das Label wird wie in Assembler mit Postfix ":" definiert:
Sprungzieladresse:
...aber folgendem Semicolon, falls in der selben Zeile noch eine Anweisung folgt!
Ein besonderer Wert zur Initialisierung von Zeigern ist:
long int *zeiger=NULL
Der Wert "NULL" muss in Grossbuchstaben geschrieben werden und ist anders als die Ziffer "0"
ein Wert, der einen Zeiger nirgendwohin zeigen lässt. Die Ziffer "0" oder eine andere Ziffer
für eine Zeigerdefinition kann eine sehr heimtückische Fehlerquelle sein und der Compiler kann
diesen Fehler nicht feststellen!
Will man an den Wert unter der im Zeiger definierten Adresse heran kommen, wird das
"Dereferenzierung" (="dereference") genannt, und sieht so aus:
WertunterAdresse=*zeiger
...während der in der Variablen "zeiger" definierte Wert, die Adresse so zugewisen wird:
Adresseselbst=zeiger
...wobei "Adresseselbst" natürlich ebenfalls als Zeiger typisiert sein muss!
Trotz des besonderen Aufwands zur Fehlervermeidung bleibt der Zweck verfehlt. Insbesondere
kann man immer noch versehentlich eine Adresse statt den Wert unter ihr inkrementieren -
anderfalls könnte man nämlich mit Adressen überhaupt nicht umgehen!
Was aber zuverlässig verbaut wurde, ist eine einfache Adressarithmetik, die keiner CPU Schaden
zufügt. Will man z.B. einfach nur eine Adresse in Schritten von +8 inkrementieren, muss man
weitere Umstände machen. Arithmetische Operationen mit Zeigern sind nämlich verboten! Man
kann nur mit Zwischenvariablen und "Type-cast" dran vorbei kommen - dazu weiter unten...
Nur im GNU-C/C++, compiliert also mit g++, sind Zeiger auch auf Funktionen definierbar.
Die folgende Definition wird also nicht mit einer Fehlermeldung verworfen:
long int *sprungziel=&funktion();
Der indizierte Funktionsaufruf wird dann formuliert wie eine Zeigeroperation:
Rückgabewert=*sprungziel;
Solche Zeiger auf Funktionen darf man keinesfalls operieren. Der Sprung ins Nirwana ist
sonst programmiert! Auch sind Funktionen ohne Rückgabewert so natürlich nicht rufbar...
Neben diesen einfacheren Typen sind komplexere zu haben. Alles, was also in Assemblerprogrammen
nichts weiter als Abfolgen von Werten oder Namen ist und allerdings in beliebiger Weise verwertet
werden kann, hat nun besondere Ausdrucksformen bekommen, die man fehlerfrei hinschreiben muss,
damit wenigstens eine Untermenge der Verwertungsmöglichkeiten programmierbar wird. Und diese
Verwertungsmöglichkeiten sind ebenfalls mit bestimmten Ausdrucksweisen geregelt.
Sehr häufig benutzt wird ein Typ, der eine Zeichenkette enthält, die stets zwischen Gänsefüsschen
stehen muss, wenn sie definiert wird. Zwischen den reservierten Gänsefüsschen sind noch sogenannte
Formatanweisungen erlaubt, die ich hier nicht vorstelle (einige Dutzend spezieller Ausdrücke!).
Solche Zeichenketten werden stets mit einem Zeiger auf eine Basisadresse definiert und einer
dualen Null am Ende:
char *Zeichenkettenname="Hallo!"
...wobei die Indirektion auch als Postfix des Typs und nicht Präfix des Zeigers geschrieben
werden kann.
Eine besondere Variable mit zweierlei Typ ist ein "Feld" ("array"). Ein Feld besteht aus
Variablen, die alle desselben Typs sind, und die auf einander folgenden Adressen liegen.
Der zweite besonders ausgedrückte Typ eines Feldes ist die Anzahl der im Feld mit laufender Nummer
zusammen gefassten Variablen.
Eine solche Menge von Variablen gleichen Namens schreibt man so:
typ feld[x]
...wobei "typ" einer der oben genannten ist, "feld" der Name und das "x" die Anzahl der
Variablen gleichen Typs im Feld angibt, dezimal oder hexadezimal und immer eckig geklammert.
Wir können also z.B. 3 Ganzzahl-Variable deklarieren:
int number[3]
und sie weiter unten im Program mit Wertzuweisungen definieren:
number[0]=1;number[1]=2;number[2]=number[0]+number[1];
...wobei das nullte Element immer vorhanden ist und mitgezählt wird! In diesem Beispiel gibt es
also keine Variable "number[3]", die definiert werden könnte! Bei dieser Art von Zuweisungen steht
also reichlich verwirrend nicht die Nummer des soundsovielten Elementes, sondern eine Offsetadresse
zwischen den eckigen Klammern.
Wir können aber auch gleich in einem die Nennung und die Wertzuweisungen erledigen:
int number[3]={1,2,3,4};
Hinter der letzten Zuweisung in den geschweiften Klammern darf kein Komma stehen! Und es muss hier
mit der Anzahl der Elemente in den Klammern definiert werden.
Es kann aber die Nennung der Anzahl auch weg bleiben - der Compiler zählt dann:
int number[]={1,2,3,4};
Bei Buchstabenfeldern geht zweierlei:
char wort[3]={'r','o','t'};
char wort[]="rot";
Eine oft gebrauchte Form von Feldern, die Zeichenketten enthalten, ist:
char *meldung[]={"Hallo!","Gut geschlafen?"};
Bei dieser Felddefinition enthält das Feld zweierlei: Zeiger auf das erste Zeichen einer in
Gänsefüsschen gesetzten Zeichenkette und die Zeichenkette selbst.
Die Adresse des "H" in "Hallo" ist verfügbar mit:
AdressedererstenMeldung=&meldung[0];
...Das "H" selbst wird transferiert mit:
*ZielH=*meldung[0]
Inkrementiert man beide Adressen, kann man auf diese Weise Sätze transferieren.
Dabei ist insbesondere:
meldung[0]++;
...oder eine Zwischenvariable, zu inkrementieren (mittels Operator "++") und nicht die Zahl
zwischen den eckigen Klammern! Mit meldung[1] nämlich erhalten wir die Adresse von "G" im
Beispiel, mit weiteren Ziffern in den eckigen Klammern garnichts mehr.
Stösst man auf den unsichtbaren Wert 0 hinter dem "o", hat man das Ende
der Zeichenkette erreicht - so definiert heisst sie "Z-string", die Abkürzung für "mit Null
beendete Zeichenkette"(="Zero terminated string"). Diese Form der Zeichenkettenspeicherung
ist UNIX-Standard. Die Null muss nicht ausdrücklich hingeschrieben werden, ist aber immer da.
Will man ein voranstehendes Längenbyte, was auch gern zur Terminierung benutzt wird, muss man
jede Zeichenkette separat mit Namen versehen, selber die Zeichen abzählen und in einer
Bytekonstanten davor definieren (Wie in Assembler), dabei aber die abschliessende Null
eventuell berücksichtigen.
Man kann nicht nur eindimensionale Felder wie eben beschrieben definieren, man kann mit den
gleichen Ausdrucksformen auch mehrdimensionale Felder haben.
Ein dreidimensionales Feld etwa sieht so aus:
int dreidimensional[2][5][3];
Wenn es definiert wird, stehen folgerichtig drei geschweift geklammerte Zuweisungen hinter
dem Gleichheitszeichen. Aber auch jede Bezugnahme auf eine einzelne Variable solcher Felder
muss nun so umständlich in allen Dimensionen definiert werden. Also etwa so:
dreidimensional[0][1][2]=2;
Ein weiterer Typ einer Variablen ist ein "struct", das nichts weiter als ein benamter Stapel von
Variablen mit bereits beschriebener Typisierung ist. Da die in Assembler verfügbare
Adressarithmetik zum Zugriff auf solche Stapel und die elegante Formulierung mittels effektiver
Adresse in C/C++ nicht zu haben ist, ist ein struct auch gleich mit weiteren Ausdrücken
verschwistert, um wenigstens ein bisschen was vom eigentlich möglichen Komfort zu haben.
Es wird etwa so deklariert:
struct Strukturname { byte variable1;int variable2;};
...wobei das Semikolon hinter der schliessenden Klammer keinesfalls vergessen werden darf!
Vor diesem Semikolon kann ausserdem noch die "Strukturvariable" stehen, die aber auch erst bei
der Definition gegeben werden kann. Das ist dann ein untypisierter Name oder Feldname.
Das obige Beispiel wäre dann so zu erweitern:
struct Strukturname { byte variable1;int variable2;}desgleichen[3];
Im Gegensatz zum Namen der Struktur, der die Anordnung und Typisierung der Variablen benamt,
also den allgemeinen Typ struct als Untertyp konkretisiert, benamt die Strukturvariable eine
Realisierung, wie sie für eine Definition nötig ist bzw. irgendeinen Zugriff auf Teile der
Struktur.
Es gibt also die Möglichkeit, die Anzahl der realisierten Strukturen offen zu lassen und jede
einzeln mit einem weiteren Namen zu kennzeichnen, oder bereits in der Deklaration festzulegen,
dass die Basisadressen der Realisierungen mit einer bestimmten Anzahl in einem Feld angeordnet
sind. Im Beispiel definiert "desgleichen[3]" also drei Realisierungen einer Struktur des Typs
Strukturname. Solche Realisierungen werden "object" genannt, was diesen ansich allgemeinen
Ausdruck nicht gerade bezeichnend sehr speziell macht.
Solche Objekte können aber auch anders deklariert werden:
Strukturname erstestruktur,zweitestruktur;
...wobei also zwei Strukturen des mit "Strukturname" benannten Typs deklariert werden.
Was innerhalb der geschweiften Klammern einer Struktur steht, kann nun nicht mehr nach der
oben vorgestellten Methode gegriffen werden! Es kommt ein Zugriffsoperator ins Spiel, der nur
einer von mehreren ist. Hier ist es ein Punkt, mit dem im Beispiel eine einzelne Variable, die
dann auch gerne "Strukturelement" genannt wird, gegriffen wird:
struct Strukturname desgleichen[0].variable1=128;
Die ganze Struktur dagegen wird so definiert:
struct Strukturname desgleichen[0]={128,12999};
...wobei die Wertzuweisungen durch Kommata getrennt in der gleichen Abfolge wie die zu
definierenden Variablen innerhalb geschweifter Klammern geschrieben werden müssen!
Strukturen können auch geschachtelt werden, wobei der Strukturvariablenname der Unterstrukturen
stets deklariert werden muss, weil er bei Zuweisungen unverzichtbar ist. Ich erweitere das obige
Beispiel um eine Unterstruktur mit der Strukturvariablen "unter":
struct Strukturname { byte variable1;int variable2;
struct Unterstrukturname {*char unter1,unter2;}unter;}desgleichen[3];
Die Zuweisungen von Werten zu Strukturelementen im ersten Beispiel sind so zu formulieren:
Wert=desgleichen[0].variable1;
Im zweiten Beispiel ist eventuell ein weiter Punkt nötig:
Wert=desgleichen[0].unter.unter1;
...falls auf Objekte in der Unterstruktur zugegriffen werden soll.
Genau wie ein struct wird eine "union" deklariert, die verschieden typisierte Variablen zu
einer einzigen macht:
union Einsauszwei {byte variable1;int variable2;}Vierbyte;
Es wird wird dabei der maximal nötige Platz von 4 Byteadressen reserviert, von aber nur eine
adressierbar ist mittels:
union Vierbyte.variable1=123
Und genau wie ein struct wird auch ein "enum" deklariert und adressiert. Aber hier werden
Konstanten gestapelt, die man nur lesen kann! Diese Konstanten sind grundsätzlich von oben
nach unten bei 0 beginnende natürliche Zahlen, wobei das Inkrement von Zahl zu Zahl =1 ist,
wenn nichts anderes definiert wird, also etwa so:
enum Abfolge { eins,zwei,drei=4,fuenf;};
Die Konstante "eins" ist hier =0 und weil die Konstante "drei"=4 ist, ist "fuenf" um 1 grösser.
Dieser Typ dient in Selektionen oder als Schrittgeber in Zählschleifen.
Der Typ struct kann auch mit Zeigern operiert werden, wobei dann die oben beschriebenen
Deklarationen für den Zeiger innerhalb der Deklaration einer Struktur zu verwenden sind -
also z.B:
struct MitZeigern {long int *Zeiger,...;};
...und die Wertzuweisung bzw. Definition in der oben beschriebenen Weise erfolgt - also etwa so:
struct MitZeigern erststruct={&variable,...;};
struct MitZeigern zweitstruct={AndererZeiger,...;};
Die Dereferenzierung kann mit einer Zwischenvariablen so aussehen:
AequiZeiger=MitZeigern.Zeiger;
...wobei die Adresse transferiert wird. Die Dereferenzierung erfolgt im nächsten Schritt:
Variable=*AequiZeiger;
Diese Form der Dereferenzierung wird bei Ausgaben mittels "cout" in C++ benutzt, das auch zwei
Formen der Dereferenzierung im Zusammenhang mit unten beschriebenen "Klassen" erlaubt:
Variable=MitZeigern.*Zeiger; Variable=MitZeigern->*Zeiger;
Völlig klar ist, dass man die Typisierung auch aufheben können muss, weil sie nicht nur
in den meisten Fällen überflüssig, sondern manchmal richtig hinderlich ist. Auch diese
Typunterdrückung muss jedenfalls ganz ausdrücklich stattfinden. Die Änderung des Typs
nennt man "type-cast". Man kann dabei dumme Fehler machen, die der Compiler nicht mehr als falsch
erkennt. Deshalb hier, wie und worum es eigentlich geht:
Während man nämlich einen 8-stelligen Wert vom Typ "char" auch in die untersten 8 Stellen eines
16- oder 32-stelligen Wertes legen kann, geht sowas umgekehrt nicht mehr.
Bei einer Typisierung:
short unterhose;long hose;
...ist gleich zu sehen, dass man mit der Unterhose in die Hose passt, kaum aber mit der Hose
in die Unterhose. Dennoch kann man nicht wie in Assembler das Byte in die untersten 8 der 32
Stellen schreiben (wobei die benamte Adresse die gleiche ist!), sondern muss ausdrücklich
eine Anpassung der Typen vornehmen, die als "Type-cast" etwa so aussieht:
hose=(long)unterhose;
Die Anpassung ist also die an das Resultat und erfolgt mit einer runden Klammer um die
Typisierung. Ein Type-casting muss vor allem immer dann sein, wenn man Zeiger arithmetisch
operieren will. Dazu muss man eine mit long int typisierte Zwischenvariable benutzen.
In oben gezeigten Beispielen ist ein Gleichheitszeichen, das in C nicht ganz das gewohnte ist.
Da wird nämlich streng unterschieden zwischen einer Gleichheit, die sein soll, der "Zuweisung",
die also einem "mov"-Kommando in Assembler entspricht, und einer Gleichheit, die sein sollte
oder schon ist.
Tatsächlich wird dann also mindestens eine Quasi-Subtraktion und der Wert des ZERO-flag benutzt.
Diese gefragte oder bestehende Gleichheit wird mit einem doppelten "==" ausgedrückt.
Ein sehr beliebter Fehler ist die Verwechslung, wenn in einer Bedingungsprüfung Gleichheit
gefragt ist. Also z.B.
if(Jacke==Hose){.....}
,was aber erst nach der Zuweisung
Jacke=Hose;
der Fall ist. Definitionen von Zuweisungen sind übrigens auch verkettet erlaubt:
Jacke=Hose=Hemd=Socken=0;
...setzt alle benamten Variablen auf =0, macht also fast nackt in kürzester Formulierung.
Solche Verknüpfungen von Variablen nennt man "Operator(en)". Sie entsprechen Mnemoniks in
Assembler. Und obgleich das Resultat erst nach der Operation vorhanden ist, wird in C das
Resultat wie aus dem Mathematikunterricht gewohnt links vor die Operation gestellt, die nach
dem "=" definiert wird oder auch davor, wobei dann nur die Operanden dem "=" folgen.
Wenn man überhaupt nur einen Operanden hat, kürzt man wiederum in besonderer Weise ab. Dann
wird der Operator zum nachgestellten Anhängsel ("Postfix").
Wenn man z.B. zu Kartoffeln 1 hinzuzählen will, dann kann man das auf dreierlei Weise
ausdrücken:
Kartoffeln=Kartoffeln+1;
Kartoffeln+=1;
Kartoffeln++;
An diesem Beispiel sieht man schön, dass keine Version den tatsächlichen Ablauf des Geschehens
in der CPU wiedergibt. Die liest nämlich im Binary zuerst das Byte des Opcodes für die Operation
des Inkrementierens. Wirklich logisch ist also (wenn schon irgendwie fremd) "inc Kartoffeln"
Die verkürzte Schreibweise im 2. Beispiel ist bei Zuweisungen und zwei Operanden mit allen
Operatoren von C möglich.
Andernfalls kann man wie aus dem Mathematikunterricht gewohnt die Abfolge von Operationen aber
mit runden Klammen bestimmen. Dabei werden die Anweisungen zu einer Operation innerhalb der
Klammern aber nicht mit ";" abgeschlossen!
Die Operatoren in C/C++ entsprechen den Mnemoniks, die die Operationen der ALU betreffen. Die
Zahl entspricht aber insbesondere bei Schiebe- und Rotier-Operationen nicht der praktisch in
jeder CPU verfügbaren Zahl von Varianten. Vor allem kann aber nicht ausdrücklich mit den immer
irgendwie benutzten Flags umgegangen werden.
Ich liste die erlaubten Operatoren nachfolgend zwischen Gänsefüsschen " " mit anschliessender
Erläuterung ihrer Anwendung auf:
"+" arithmetische Addition von zwei Operanden
"++"arithmetisch inkrementieren eines Operanden
Ist der Operand ein Zeiger, ist kein "type-casting" nötig. Es wird dann
aber typabhängig evtl. nicht nur um 1 inkrementiert, sondern 2 oder 4
"-" arithmetische Subtraktion von zwei Operanden
"--"arithmetisch dekrementieren eines Operanden
"*" arithmetische Multiplikation von zwei Operanden
"/" arithmetische Division von zwei Operanden
Der Compiler macht bei Multiplikation und Division automatisch eine Typwandlung zu höheren
Stellenanzahlen, wenn einer der Operanden und das Resultat mehr Stellen enthalten.
Statt dem Resultat einer Division kann auch nur der Rest interessant sein...
"%" Rest der Division ist Resultat ("Modulo")
Die Operation kann nur auf vorzeichenlose Ganzzahlen (Typ "int") angewendet werden,
entspricht also "mul" und "div" in Assembler!
"=" Zuweisung
">>" Rechtsschieben
...macht Binärzahlen kleiner bzw. dividiert je geschobene Stelle durch 2
Der Operator für Linksschieben kann wegen der HTML-Formatierung dieses Textes nicht dargestellt
werden (macht Binärzahlen grösser bzw. multipliziert je geschobene Stelle mit 2). Er besteht aus
zwei hintereinander geschriebenen, nach links weisenden Winkeln ("kleiner"-Zeichen).
Die Schiebeoperationen haben nur einen Operanden, eine Variable; es muss aber die Anzahl der
Schiebetakte als Variable oder Ziffer angegeben werden. Dummerweise sind genau die gleichen
Zeichen in C++ noch mit anderer Bedeutung zu benutzen. Man muss also genau hinsehen und einen
Zuweisungsoperator in der selben Anweisung entdecken, damit man dieser Bedeutung sicher sein
kann!
Beispiele:
"~" Bitweise Invertieren, nur ein Operand. Ein Beispiel: Inverses=~Operand
"&" Bitweise Undieren von zwei Operanden
"|" Bitweise Oderieren von zwei Operanden
"^" Bitweise Xorieren von zwei Operanden
Neben diesen Operatoren, die Mnemoniks ersetzen, gibt es weitere, teilweise schon erwähnte, die
durch die Typisierungen nötig werden und eigentlich Anweisungen an den Compiler darstellen.
Teilweise wird damit letztlich Adressarithmetik angewiesen, die also trotz blumiger Ausdrucksweise
expliziert werden muss! Und obgleich keine Mühe gescheut wurde, aus drei Buchsstaben für ein
Mnemonik einen einzigen zu machen, sind dann regelmässig mehr als drei Buchstaben fällig.
Will man z.B. die Länge eines Typs in Byte wissen, was bei zusammen gesetzten Typen nötig sein
kann, dann braucht man ein weiteres Schlüsselwort:
sizeof(Zeichenkettenname)
...und eine Zuweisung, um den Wert verfügbar zu machen.
Ich werde weitere Operatoren dieser Art aber erst erklären, wenn ich weiter unten vorgestellt
habe, was sie nötig macht.
Ausserdem ist festzustellen, dass höhere Rechenarten, die schon lange mit Mnemoniks angewiesen
werden können, weil sie in der FPU eingebaut sind, nicht als Operatoren in C/C++ verfügbar sind.
Sie sind nur als Rufe von Standardfunktionen zu haben (mit entsprechend langsamerer Ausführung).
Von diesen auf den Binärwert der Resultate orientierten Operatoren sind strengstens zu
unterscheiden die folgenden, deren Operanden und Resultate stets einstellig sind!
Der Typ solcher Resultate ist also "bool" und kann in Variablen dieses Typs gehandhabt werden.
Obgleich dieser Typ die Flags repräsentiert, also einzelne Bits, werden für solche bool'schen
Werte stets ganze Bytes vergeudet.
Die folgenden Operationen sind jedenfalls die Dinge, die in Assembler mit bedingten Sprüngen
und ihrer logischen oder arithmetischen Herleitung gemacht werden. In C/C++ aber sind sie mit
weiteren Regeln und Schlüsselworten verknüpft zu verwenden. Bedingte Sprünge werden dabei zu
Beziehungen zwischen Klammerebenen, die man sich denken muss. Falsch gesetzte oder vergessene
Klammern wirken folglich wie falsche oder nicht vorhandene Verzweigungen.
Weil innerhalb einer Klammerebene viel zu schreiben sein kann und damit leicht der Überblick
verloren geht, hat sich eingebürgert, die jeweils öffnende und schliessende Klammer einer Ebene
in die gleiche Spalte zu schreiben.
Die Liste der Operatoren für Sprungbedingungen zeigt, dass jede Menge fehlt, jedenfalls in
Bezug zu CPUs in AT-PCs (SIGN-, OVERFLOW-, PARITY-Flag):
"&&" Undiert zwei einstellige Vergleichsoperanden
"||" oderiert zwei einstellige Vergleichsoperanden
"!" Negation eines einstelligen Operanden in Vergleichsoperationen.
Das darf nicht mit Invertieren einer Variablen verwechselt werden!
Bei Negationen muss man ausserdem sorgfältig beachten, ob man Operand oder Resultat negieren will.
Weil beide vom Typ bool sind, kann der Compiler Fehler nicht erkennen, die sehr schlimm sein
können! Für die Erzeugung von Vergleichsresultaten stehen die Quasi-Subtraktion (="cmp") und
die Quasi-Undierung (="test") zur Verfügung. Während die Quasi-Subtraktion aber mit den folgenden
Operatoren implizit angewiesen wird:
"==" Gleich
"!=" Nicht gleich
...wird die Quasi-Undierung nur möglich mit einer Undierungsanweisung:
Status&Bitmaske,
Es muss der Zuweisungsoperator fehlen, damit nicht der Wert in "Status" verändert wird! Es darf
in diesem Fall also keinesfalls geschrieben werden:
"Status&=Bitmaske,
Ausserdem können die aus der Schulmathematik bekannten Zeichen für "Kleiner", "Kleiner oder Gleich",
"Grösser", "Grösser oder Gleich" zur Bedingungsprüfung dienen. Dann wird ausser dem Null-flag
auch das Übertrags-flag zum Kriterium. Die Zeichen können hier wegen der HTML-Formatierung dieses
Textes ebenfalls nicht geschrieben werden.
Wie bereits gesagt, sind diese Operatoren nur im Zusammenhang mit weiterem Regelwerk verwendbar.
Ich gebe deshalb ein paar Beispiele für solche Vergleichsoperationen, die nicht mit den (anderen)
logischen Operationen verwechselt werden dürfen, die Bitweise operieren.
Die Resultate regieren die Verzweigungen in mit reservierten Bezeichnern benamten Funktionen.
Diese Funktionen sind tatsächlich keine Prozeduraufrufe, sondern durch bedingte Sprünge verzweigte
Abläufe. Die Bedingungsprüfungen stehen anstelle von Parametern zwischen runden Klammern.
Diese Quasi-Funktionen sind:
If(){...}else{...} bedingte Ausführung der Blöcke bei Zutreffen der Bedingung für den
if-Block, Nicht-Zutreffen für else-Block.
switch(){case ...} Fallprüfung
while(){...} Schleife mit bedingtem Abbruch vor der Ausführung des Blocks.
do{...}while(); Schleife mit bedingtem Abbruch nach der Ausführung des Blocks.
for(){...} Schleife mit durch Zählerstand bedingten Abbruch der Ausführung des Blocks.
Unbedingt merken muss man sich, dass diese Funktionen ausser der Schleife do{}while(); nicht
durch ";" abgeschlossen werden!
Schliesslich sind die Bedingungsprüfungen zwischen den runden Klammern nach verschiedenen
Regeln zu formulieren! Ich werde also je ein Beispiel mit Erläuterung geben:
if((n>m)&&(vektor!=mark)){make;}else{undo}
Weil alle Operatoren in C/C++ auch noch einen Rang haben, können Klammern um Teiloperationen
eventuell auch entfallen. Besser und verständlicher ist aber, sich nicht auf den Rang zu
verlassen - deshalb sage ich zu Rangfolgen kein Wort. Mit dieser Anweisung wird also "{make;}" nur
dann ausgeführt, wenn sowohl die Variable "n" grösser als "m" ist, als auch "vektor" nicht den
gleichen Binärwert wie "mark" darstellt. Die Anweisung "else" enthält, ohne dass das hingeschrieben
werden muss, die Invertierung des Resultates vom Typ bool der if(Bedingung).
Diese else-Bedingung würde ausgeschrieben so aussehen:
(!((n>m)&&(vektor!=mark)))
Man kann diese Bedingungen beliebig schachteln. Aber wehe, wenn man da nicht richtig gegrübelt
hat! Alles, was der Compiler an solchen Bedingungen prüft, ist die Zahl der öffnenden und
schliessenden Klammern, die gleich sein muss. Aber nicht mal da reagiert er eindeutig! Er
findet womöglich ganz woanders plötzlich Fehler, die garkeine sind, oder reagiert sonstwie
unverständlich.
Ganz übersichtlich kann es aber werden, wenn nur eine einzige Anweisung bedingt ausgeführt
werden soll. Dann kann die geschweifte Klammerung des Blocks weggelassen werden.
Besteht auch der else-Block nur aus einer Anweisung, geht's sogar noch einfacher mit zwei
Operatoren und drei Operanden. Dabei wird bedingt verzweigt zwischen zwei Resultaten:
Maximum=(Zahl1 > Zahl2) ? Falls : Fallsnicht
...wobei alle Namen für Zahlen stehen, die zum Wert von "Maximum" werden, und die natürlich
alle vorher definiert werden müssen.
Weil es zwischen if und else wie zwischen Scylla und Carybdis zugehen kann, haben sich alle
Programmierer angewöhnt, Beginn und Ende von Blöcken durch sinnvolles Einrücken hervorzuheben.
Bei der folgenden Fallprüfung aber schreibt man besser auf die volle Breite, weil die
Zahl der Fälle oft sehr gross ist, der Rest des Programms also schnell ausser Sicht gerät.
switch(binary){case 0:make-a;case 1:make-b;case 2:make-c;case 3:make-d; default:undo;}
Hier steht ein zu prüfender Binärwert, der eine Ganzzahl sein muss und nicht eine
Bedingungsprüfung zwischen den runden Klammern. Nach dem reservierten Bezeichner "case " kann
ein Sollwert mit den drei erlaubten Darstellungen für Zahlen oder Schriftzeichen eingetragen
werden (dezimal, hexadezimal oder als einzelner(!) Buchstabe zwischen ' '), auf einen
trennenden Doppelpunkt folgt dann die Verzweigungsanweisung. Der Sollwert kann aber auch den
Namen einer Variablen haben. Die taugt als Kriterium der Verzweigung allerdings nur, wenn sie
eine Ganzzahl darstellt.
Es können auch mehrere "case"-Anweisungen oderiert werden, indem sie hinter einander geschrieben
werden, etwa so:
switch(binary){case oben: case unten: raus(); default:links-oder-rechts }
Der reservierte Bezeichner "default :" schliesslich leitet die Anweisungen ein, die ausgeführt
werden sollen, wenn kein mit case-Anweisungen erfasster Wert gegeben ist.
Solche switch-Anweisungen benutzt man z.B., um aus Binärwerten, die eine Tastatur liefert, zu
den zur Taste (jede gibt einen anderen Binärwert) gehörenden Funktionen zu verzweigen, bzw.
die richtigen Zeichenmuster zu adressieren. Will man aber nach negativen oder Fliesskommazahlen
selektieren, muss man mit if()-Schachteln arbeiten!
while((n>mark)^^(m>=begin)){n++;m-=3;make;}
Dies ist eine Schleife, die unendlich oft ausgeführt wird, wenn nicht ein Abbruchkriterium
genannt wird (das auch taugen muss!). Will man den Abbruch innerhalb der Schleife besorgen und sie
deshalb unendlich haben, schreibt man
while(1){...};
Die Eins wird als "Wahr" genommen, der logische Wert, der zum Weitermachen zwingt.
Während also die while-Schleife nie ausgeführt wird, wenn die Abbruchbedingung nicht
zutrifft, wird eine Schleife mit gleichen Abbruchkriterium, aber mit "do" und "while" formuliert,
einmal ausgeführt. Die sähe so aus:
do{n++;m-=3;make;}while((n>mark)^^(m>=begin));
Weil mit einer falschen Ausführung schon das Unheil passiert sein kann, kann man diese
do-while-Schleifenanweisung auch getrost vergessen. Sie fällt auch durch das abschliessende
Semikolon aus der Reihe...aber vielleicht braucht sie doch irgendjemand?
Wie in dem Beispiel zu sehen ist, werden die Variablen, deren Wert den Absprung aus der Schleife
begründet, im Block verändert - n wird inkrementiert, m mit drei dekremeniert. Das ist das, was
mindestens im Block geschehen muss! Andernfalls könnte die Abbruchbedingung niemals erreicht
werden.
Weil hier zwei Bedingungen xoriert werden, sind es tatsächlich zwei Abbruchbedingungen, die
keinen Abbruch bewirken, wenn sie beide gleichzeitig zutreffen. Und Schlaumeier haben schon
gemerkt, dass der Block überhaupt nur dann ausgeführt wird, wenn mindestens eine
Abbruchbedingung der beiden xorierten schon zutrifft....
Während man in der DOS-Welt aus einer falsch gebauten while-Schleife mit dem Ziehen des
Netzsteckers abspringen konnte, geht das in der Linux-Welt nur mit Nachsitzen!
Weil Linux im Hintergrund stets irgendwas vor sich hinmurmelt, Protokolle schreibt und
Dämonen anfeuert, rächt es sich böse bei Unterbrechungen mittels Resetknopf! Man kann sich dann
stundenlang mit Rettungsmassnahmen für das Dateisystem beschäftigen...oder neu installieren.
Man muss sich also während der Testphase ein Abbruchkriterium in den Block bauen (nicht in die
runde Klammer!), das sicher erreicht wird, z.B.:
long rettung=0; /*man kann die Variable bei der Deklaration gleich mit einem Wert versehen!*/
while(womöglichnie>=irgendwas){womöglichnie--;make;rettung++;if(rettung>1000000000)goto raus;}
raus:
"goto" ist ein unbedingter Sprung auf die Adresse von "raus:", der hier möglich ist, weil der
Block eben nicht der einer Funktion ist, nach der es aussieht. Das Label wurde im Beispiel
"lokal" definiert und ist damit auch innerhalb des Blocks "sichtbar", der wiederum in einem
Funktionsblock eingeklammert ist - das heisst, dass es so funktioniert.
"Globale", also ausserhalb main() deklarierte Label sind aber nicht erlaubt!
Solche unbedingten Sprünge sind bei vielen Programmierern verpönt. Sie glauben, alles mit den weiter
unten beschriebenen Anweisungen "break" und "continue" machen zu können. Das aber ist nicht wahr.
Aus verschachtelten Schleifen und Selektionen kommt man nur mit einem "goto" raus, wenn man nicht in
jeder Schachtel erneut Bedingungen für Abbrechen prüfen will...
So heikel wie in while-Schleifen sind die Abbruchbedingungen in for-Schleifen nicht.
for(n=0;n>limit;n++){make;};
Diese "Zählschleife" enthält die Abbruchbedingung in der zweiten Quasi-Anweisung zwischen den
runden Klammern. Man muss sich nicht nur die besondere Schreibweise merken, sondern auch, dass
es nur eine einzige auf den Schleifenzählwert bezogene Abbruchbedingung gibt, die so definiert
werden kann. Der rund geklammerte Ausdruck enthält deshalb stets eine Zuweisung an eine bereits
deklarierte Variable, die den Anfangswert setzt. Danach ein ";" und der Endwert der Zählung,
ebenfalls gefolgt von einem ";". Schliesslich der Zählschritt, der nicht mehr mit ";"
abgeschlossen wird!
Vor dem ersten ";" kann auch mehr als eine Zuweisung stehen, die aber wie gesagt nicht als
Abbruchkriterium taugen kann.
All diese durch Bedingungen gesteuerten Abläufe lassen sich schachteln. Allerdings gibt es
Probleme, wenn zwischen den Zweigen gewechselt werden soll oder mitten aus der Schachtelung
heraus gesprungen werden soll. Während das in Assembler keinerlei formale Besonderheit bedingt
und wegen benamter Einsprungziele auch leicht zu überschauen ist, markieren hier immer gleiche
Klammern Ein- und Absprungziele. Man muss dann bedingte Sprünge fingieren, indem wie oben
bereits gezeigt der unbedingte Sprung mittels "goto" in eine Bedingung gekapselt wird.
Darüber hinaus gibt es aber noch was zu merken für Fälle, die in Assembler nichts besonderes
sind. Es gibt nämlich noch zwei vereinfachende Bezeichner, die die Nennung und Definition
eines Sprungziels erübrigen:
Mit "break " wird ein unbedingter Sprung hinter die nächste schliessende "}" vollzogen,
mit "continue " vor die nächste öffnende "{".
"break " setzt man am besten routinemässig in Fallprüfungen mit "case :"ein, weil sonst alle
folgenden Fallprüfungen ebenfalls ausgeführt werden - und ja dann völlig sinnlos! Also so:
{case 0:make;break;case 1:make-a;break;}
"continue" ist ein Rücksprung, der die Abkürzung von Schleifen gestattet, wenn irgendwo auf der
Hälfte schon alles entschieden sein sollte.
Diese Ab- oder Rücksprünge lassen aber ebensowenig wie "goto" Sprünge zwischen Prozeduren oder
irgendwelche indizierten Sprünge zu.
FUNKTIONEN UND KLASSEN:
Nachdem nun alles vorgestellt ist, was nötig ist, Werte zu definieren, die Adressen, wo sie
stehen, die Adressierungsarten und die Kommandos, die damit umgehen, kann ich genauer abhandeln,
was ich oben nur dem Anschein nach vorgestellt habe: Funktionen.
Eine Funktion ist, was ich weiter oben als Prozeduraufruf vorgestellt habe. In C allerdings mit
zusätzlichen Schikanen: den bereits genannten Parametern, die vor dem Sprung mit Werten besetzt
werden müssen und einem einzigen oder keinem "Rückgabewert", dessen Art und Stellenzahl ausdrücklich
angegeben werden muss. Vor allem aber hat der Name global Bedeutung, kann also auch in anderen
Programmen bedeutsam gemacht werden. Schliesslich können unter C++ sogar mehrere Funktionen
gleichen Namens existieren, sofern sie sich im Parameterteil unterscheiden.
Wesentliches Kennzeichen von C und anderen "prozeduralen" Sprachen ist die Idee, dass alles mit
solchen Funktionen machbar wäre. Deshalb wurden sie auch mit einem langen Schwanz auf dem Stack
ausgerüstet, um in immer der gleichen Form bestehen zu können. Sie haben ihren eigenen
Variablenbereich auf dem Stack, in den beim Aufruf erstmal alles umkopiert werden muss, was
sehr wohl auch von dort gegriffen werden kann, wo es erstmal steht - das sind die Parameter.
Sie erlauben zwar, dass eine Prozedur sich selbst rufen kann. Aber das ist wirklich nur nötig,
wenn man vernarrt ist in die Idee, alles mit Prozeduren machen zu müssen.
Der Erfolg ist jedenfalls ein riesiger Stackbereich in UNIX-Systemen.
Die Übergabe von Werten in Registern ist ausgeschlossen, ebenso die Verwertung von flags.
Während in "main()" ein Befehl für die Rückgabe entfallen kann (erst nach Programmende kann
das interessieren), muss jede andere Funktion die Anweisung "return wasduzurückwillst;"
enthalten, wenn man nicht "void" vor den Funktionsnamen geschrieben hat. Das meint dann nicht den
Typ des Rückgabewertes, sondern das Fehlen eines solchen!
Die Anweisung "return" ist keine Variable und deshalb nicht Teil einer Zuweisung. Vielmehr hat
die als Rückgabewert typisierte Variable den Namen der Funktion, die damit zu einem Wertspender
wird, der wie eine Variable als Teil arithmetischer oder logischer Anweisungen eingesetzt werden
kann. Eine Funktion hat also meist die Form:
Typ-des-Rückgabewertes Name-der-Funktion(Parameter...){Anweisungen.....}
Ihren Rückgabewert gibt sie her z.B.mit folgender Formulierung:
Variable=Name-der-Funktion(Parameter...);
...womit also tatsächlich ein Prozeduraufruf stattfindet und anschliessend eine Wertzuweisung an
"Variable"
Beliebt ist aber nicht der Umweg über eine Variable, sondern die direkte Verwertung des
Rückgabewertes in einem Rechengang. Während dieser arithmetische Einsatz noch empfehlenswert ist,
ist der Einsatz in logischen Verknüpfungen innerhalb von Bedingungsanweisungen meistens völlig
daneben. Das sieht etwa so aus:
while (x!==Funktion(a,b,c){Anweisungen, die den Wert von "x" manipulieren....}
Hier wird nicht nur der logische Ablauf verdreht, sondern bei jedem Schleifendurchgang erneut die
Funktion gerufen! Tatsächlich hat zunächst das zu geschehen, was im Block der Funktion steht und
dann hat der Rückgabewert in einer Zwischenvariablen als Kriterium zu dienen.
Weil auch dem C-Compiler (jedenfalls ursprünglich) eine Label-Adress-Zuweisung fehlt, die während
der Übersetzung noch unbekannte Namen später rückwirkend durch Adressen ersetzt, darf die
Definition einer Funktion nicht erst dann auftreten, wenn ein Programmierer das Bedürfnis
empfindet, einen Ablauf in eine Prozedur zu kapseln.
Immer muss mindestens eine formale Angabe, die dann "Deklaration" heisst, im Kopf der
Übersetzungseinheit auf niederen Adressen stehen! Die "Definition" einer solchen Funktion enthält
schliesslich den gesamten Ablauf als "Block", der in geschweiften Klammern steht. Sie kann dann
irgendwo im Quelltext auftreten.
Man kann allerdings im Kopf des Programms auch Definition und Deklaration in einem machen. Damit
spart man überflüssige Schreibarbeit.
Wie gesagt, besteht ein C-Programm aus mindestens einer Funktion namens main(), mit deren Ruf der
Programmstart bewirkt wird. Diese Funktion ist in der GNU-LINUX-Welt stets vom Typ int
und nicht void wie anderswo - wird also immer geschrieben:
int main()
Innerhalb der runden Klammern stehen normalerweise noch maximal drei besondere Parameter, die in
einem UNIX-System Zwecke regeln, die beim Programmstart definiert sein müssen. Deshalb haben sie
auch noch den besonderen Namen "Kommandozeilenparameter" und die jeweils eigenen Namen:
(int argc, char *argv[], char *envp[])
...wobei "argc" definiert werden muss, die eckigen Klammern aber leer bleiben.
Mit "int argc" wird die Anzahl der in char *argv[] definierten Parameter angegeben:
int argc=3
...definiert also eine Wertemenge von 3 Zeichenketten, deren letzte immer den Wert NULL
enthalten muss, und deren erste immer den Namen des Programms inclusive Pfad im Dateisystem und
Laufwerksnamen enthalten muss. Weil das dem Programmierer selten bekannt sein kann, wird diese
Definition erst bei der Installation vom Linker eingefügt, dem Programm, das Verknüpfungen von
Programmen regiert. Ein Binary kann deshalb nicht auf beliebigen Stellen im Dateisystem stehen,
Es muss im Zieldateisystem compiliert und ausgeführt werden!
Ein Binary ist also nur portierbar, wenn das Zielsystem eine genau gleiche Dateisystemordnung hat,
und diese auch noch auf dem gleichen Gerät. Damit wird jedenfalls ein überflüssiges Problem
geschaffen, das besondere Massnahmen bei der Compilation von Programmen und der Abspeicherung
von Binaries erfordert. Sicherheit vor "Viren" kann damit nicht geschaffen werden...
Die folgenden Zeichenketten (im Beispiel eine) werden dagegen im Programm definiert und sind als
"Option" in der "Kommandozeile" (,die ich auch erst weiter unten abhandeln werde...) nennbar.
Damit werden üblicherweise Verzweigungen im Programm gebahnt oder Dateien und Werte definiert,
die Eingangs- oder Ausgangs-Daten betreffen und die während der Ausführung nicht mehr infrage
gestellt werden, sondern evtl. zu angepassten Fehlermeldungen beim Programmabbruch verarbeitet
werden. Was also innerhalb von Programmen in Form von Werten als Parameter anzugeben
ist, wird hier als Kürzel gehandhabt, dessen Form im Programm definiert wird ( weil dort die
Interpretation stattfinden muss), der aber nach gängiger Schreibweise jedenfalls mit einem
Minuszeichen als Präfix geschrieben werden sollte.
Ohne Zähler, aber ebenfalls mit einem letzten Element =NULL wird "char *envp[]" definiert,
das als Kürzel "Umgebungsvariable" enthält, die der Anpassung an das Betriebssystem dienen.
Diese Parameterdefinitionen sind allerdings nicht zwingend nötig. Die Klammern hinter main()
können leer bleiben, wenn z.B. mit den GNU-Compilern g++/gcc compiliert wird. Dann wird
automatisch der Dateiname in das "object file" eingetragen, das das binary enthält.
Zwischen den runden Klammern hinter main() tut sich also ein weiteres Universum mit eigenen
Ausdrücken und Regeln auf, auf die ich hier nur skizzierend eingehe, die aber unbedingt beachtet
werden müssen, wenn das Programm z.B. in einem LINUX-System arbeiten soll und Bezug auf dort
schon eingebaute andere Programme nehmen soll.
Auch vor (und hinter) main() ist noch ein Universum zu handhaben, das über den "Präprozessor"
regiert wird, dessen Regelwerk ich erst sehr viel weiter unten abhandeln kann. Hier ist nur der
wichtigste Zweck vorzustellen:
Das "Einbinden" (="link(en)") von weiteren Funktionen in das Programm, insbesondere der Funktionen,
die als "Standardfunktionen" Teil der standardisierten "C-library" bzw. "C++class-library" sind.
In Assemblerprogrammen gibt es eine natürliche Grenze für die Bedeutung (="Sichtbarkeit") von
Namen, die dadurch gegeben ist, dass sie ja nur Statthalter für Adressen sind, die ein
Übersetzungsprogramm dann in physische Adressen umsetzt, bezogen auf eine Basisadresse =0 des
Programms. Die Namen spielen weiter keine Rolle, während die physischen Adressen
selbstverständlich immer erreichbar sind, wenn die aktuelle Basisadresse eines Programmes gegeben
ist, also der richtige Selektor im richtigen Segmentregister steht. Und es ist natürlich sehr
einfach, Adressen bestimmten Namens dadurch weithin sichtbar zu machen, dass man sie auf
bestimmten Offset-Adressen im Kopf des Programms anordnet. Die nackte Zahl und nicht ihr Name ist
allein erheblich!
In Assemblerprogrammen ist also im Prinzip jede Adresse immer sichtbar und jeder Name immer und
überall in einer Übersetzungseinheit. Einschränkungen müssen nicht ausdrücklich definiert werden,
sondern ergeben sich auf sehr einfache Weise dadurch, dass man eben nicht hinsieht.
Den Schöpfern von UNIX und C/C++ oder anderen "höheren" Programmiersprachen war die Adressierung
mit nackten Zahlen zu obszön. Auch die tatsächlich logisch bedingten Grenzen der Sichtbarkeit
solcher Nacktheit gefielen nicht.
Zur (System)Philosophie wurde deshalb, dass beliebig mit der Sichtbarkeit gespielt werden kann.
Damit wurde eine grosse Zahl von überflüssigen Übersetzungen, Regeln und Schlüsselworten nötig,
und es ist nicht nur sorgfältig zu beachten wie, sondern auch wo Namen definiert werden.
Und all diese Namen sind nicht etwa Schall und Rauch wie in Assembler, sondern Lehr- und
Lern-Stoff, der unbedingt im Kopf zu stehen hat, bevor man irgendwelche eigenen Worte zum
Zwecke der Programmierung macht oder auch nur die Programme anderer verstehen können will.
Aus dieser schönen, neuen Welt konnten physische Adressen aber natürlich nicht weg gezaubert
werden. Und immer ist auch die endgültige Basisadresse eines Programms zur Laufzeit unbekannt.
Werden aber Namen nicht schnellstens bei der Übersetzung in Offsets physischer Adressen umgesetzt,
hat man jedenfalls mit riesigen Mengen Namen beim Linking und Programmstart zu tun. Dann muss
nämlich nachgeholt werden, was das Übersetzungsprogramm versäumen musste, um die Namen bedeutsam
zu halten. Ausserdem lässt diese Philosophie das Binary enorm anschwellen. Die nun so genannten
"object-files" in UNIX-Systemen enthalten bis zu 50% Namen im Klartext!
Die Idee, dass man diese Schwellungen hinter einer Automatik des Linkers verstecken könne, ist
wegen des Zeitaufwands bei Namensvergleichen ohnehin absurd. Aber tatsächlich gibt es weit mehr
Konsequenzen, die auch keineswegs ohne weitere Regelwerke und Ausdrücke zu machen sind.
Nur eines der dann nötigen Regelwerke steckt im Präprozessor, der eigentlich vollkommen
überflüssig ist. Weil er aber nun schon mal da sein musste, bekam er weitere, ebenfalls
überflüssige Aufgaben, die ihn als "Makroprozessor" erscheinen lassen. Weil ein C-Programmierer
daran nicht vorbei kommt, handle ich das Regelwerk weiter unten ab.
Nun sind allerdings nicht nur die in C/C++ mit besonderem Namen rufbaren Standardfunktionen nicht
ohne weiteres zu rufen. Die Umstände zum linken, die für Rufe über Programmgrenzen hinaus so oder
so gemacht werden müssen, sind auch innerhalb der Übersetzungseinheit nötig, weil die
Funktionsnamen ja weithin sichtbar sein sollen. Diesen Zustand einer Funktion nennt man in C
"extern". Während er in C definierte Funktionen implizit betrifft, muss man die Angelegenheit
explizit mit einem weiteren Ausdruck festlegen, wenn man separat übersetzte Programme, die dann
"modul" genannt werden, rufbar machen will:
extern "C" int AssemblerModul(int, int)
...beispielsweise ist die Deklaration fremdartiger Abläufe, die allerdings nur mit einem Rahmen
funktioniert, der wie ein C-Programm wirkt. Da muss dann jedenfalls der Stack benutzt werden. Für
die genaue Beschreibung des Rituals verweise ich auf die Dokumentation zu NASM.
Tatsächlich wurde damit die Entscheidung gefällt, Prozeduren in Programmen normalerweise als
FAR rufbar (also mit "retf"-Kommando abgeschlossen) zu übersetzen. Das muss aber nicht heissen,
dass die Segmentierung tatsächlich auch benutzt wird!
Eine mit "ret" zurück kehrende Prozedur wird übersetzt mit:
static int Funktion(int)
...womit der Name "Funktion" nur innerhalb der Übersetzungseinheit sichtbar bleibt.
Mit dem Ausdruck "inline" schliesslich wird aus einer Funktion allein der im Block definierte
Ablauf. Damit kann man das Theater beim Funktionsaufruf zugunsten kürzerer Ausführungszeit
einsparen, denn der Compiler übersetzt den Funktionsaufruf nicht, sondern fügt stattdessen den
Ablauf in die Übersetzung ein. Wäre es aber eine Assembler-Prozedur, würde man durch diese
Verkürzung höchstens ein paar Prozent gewinnen - nur in C-Programmen macht so ein Unsinn also
Sinn. Weil Inline-Funktionen aber gerne als Mittel zur Beschleunigung der Ausführungszeit
gepriesen werden, muss hier ergänzt werden, dass sie immer auch ein Mittel sind, das Binary enorm
anschwellen zu lassen und damit genau das zu bewirken, was mit der Erfindung des Prozeduraufrufes
vermieden werden sollte.
Ganz ähnlich wirkt übrigens eine ebenfalls gepriesene "Makroexpansion"...
Jedenfalls steht der Ausdruck "inline" nicht vor Deklaration oder Definition einer Funktion.
Er darf nur innerhalb eines Funktionsblockes vor einem Ruf der Funktion stehen, dem natürlich
alles vorausgehen muss, was auch vor dem normalen Funktionsaufruf geschehen müsste.
Während die Sichtbarkeit von Namen ausserhalb von Blockgrenzen global ist, ist sie andererseits
nichtmal in der ganzen Übersetzungseinheit gegeben, wenn die Namen innerhalb von Blockgrenzen
definiert werden. Die sind nur dann ausserhalb sichtbar, wenn das unter C++ mit den
Schlüsselworten "namespace" und "using" definiert und angewiesen wird.
Man hat den "Namensbereich" wie einen Typ mit Namen zu versehen und dann zwischen geschweiften
Klammern zu definieren, was diesen Namen haben soll - etwa so:
namespace Bereichsname {long variable1;void function(){....}}
Benutzt wird dieser Quasityp in einer anderen Funktion, indem er dort zunächst mittels "using"
bekannt gemacht wird:
using namespace Bereichsname;
Dann kann auf die Variable "variable1" mittels Operator "::", der auch andere Verknüpfungen als
"Bereichsoperator" erlaubt, in der anderen Funktion zugegriffen werden - etwa so:
lokalvariable=Bereichsname::variable1+3;
Die Unsichtbarkeit von Namen innerhalb von Blockgrenzen macht auch weitere Regeln für die
"Parameterübergabe" nötig, die von dem regiert wird, was hinter Funktionsnamen zwischen runden
Klammern steht. Die dort definierten Typen und Namen beziehen sich stets auf das, was innerhalb
des Blocks geschieht. Aber beim Ruf sind andere Namen als bei der Definition zu benutzen!
Bei der Definition wiederum dürfen keine Namen für Parameter benutzt werden, die ausserhalb
aller Blockgrenzen definiert sind! Stets werden bei der Definition der Parameter "lokale", nur
innerhalb des Blocks sichtbare Variable definiert.
Weil nun beim Ruf natürlich die Werte der Parameter diesen im Block unsichtbar versteckten
Variablen nicht direkt zugewiesen werden können, muss sorgfältig die Abfolge und die Typisierung
der Parameter in der Definition der zu rufenden Funktion beachtet werden. Die runde Klammer
definiert mit der Abfolge der Parameter eigentlich eine Kette von Zuweisungen.
Die Zuordnung erkläre ich besser vom folgenden Beispiel ausgehend:
int var1=1,irgendwas;char var2=2;
int Funktion1(int loc1, char loc2){++loc2;return ++loc1;};
int main(){irgendwas=Funktion1(var1,var2);}
Durch den impliziten Ruf bei der Zuweisung wird der in "var1" stehende Wert der mit "loc1"
benamten Variablen zugewiesen, die unbedingt gleichen Typs sein muss. Während dieser Wert
inkrementiert zum Wert von "irgendwas" wird, wird der mit "var2" übergebene Wert zwar
inkrementiert, aber unerreichbar im Block gehalten.
Man kann allerdings auch die runden Klammern bei Definition und Ruf leer lassen, und im Block
der Funktion mit "var1" und "var2" umgehen, die hier "global" definiert sind. Dann ist auch
das Inkrement des zweiten Parameters sichtbar!
Und natürlich kann man auch Adressen an Zeiger übergeben. In C++ kann man ausserdem Namen
an Adressen übergeben. Die Definition sieht dann so aus:
int Funktion1(int &loc1){return ++loc1;};
...wobei "loc1" ein alias-Name für die beim Ruf namentlich genannte Variable ist.
In beiden Fällen werden globale Variable benutzt.
In C++ spielen die Parameter noch eine Rolle als Teil des Namens, weil der Compiler mehr als
eine Funktion gleichen Namens akzeptiert. Nur verschiedene Parameter führen dann zur nötigen
Unterscheidung. Dies wird als "überladen" (="overloading") bezeichnet und ist auch bei fast allen
Operatoren mittels Schlüsselwort "operator" möglich - mehr zu solchen Mehrdeutigkeiten weiter
unten.
Der in geschweiften Klammern stehende Block einer Funktion enthält schliesslich den eigentlich
gerufenen Ablauf. Dabei ist die öffnende "{" natürlich komplett überflüssig und dient nur
der Schönschreibung bzw. der Arbeit des Compilers, der daraus externes macht. Aber auch die
schliessende "}" ist nicht etwa eine Kurzform für "retf", das sie nur dann ersetzt, wenn die
Funktion für ihren Rückgabetyp mit void typisiert wurde.
Will man dann aber den Normalfall (wie in Assembler), nämlich verschiedene, jeweils mit "retf"
abgeschlossene Verzweigungen in der Prozedur, dann kann man nicht entsprechend geschweifte
Klammern setzen! Man kann auch nicht mit "void" auf den Rückgabewert verzichten! Nun nämlich
ist die Entsprechung von "retf" ein "return egalWas;". Dabei ist der Ausdruck "return" aber
nicht das letzte Kommando vor dem Rücksprung, sondern die Einläutung eines Abgesangs, der einen
ganzen Rechengang in Form einer Anweisung oder sogar einen Funktionsaufruf beinhalten kann.
Deshalb kann man den gesamten Ablauf innerhalb der Funktion allein mit diesem Abgesang füllen:
int Funktion(a,b){return a+b}
...womit auf denkbar ergreifende Weise zwei Werte addiert werden. Und besonders schön wird das,
wenn es inline typisiert wird...Tatsächlich sind solche Formulierungen nicht gerade selten!
Beispielsweise werden Registerzuweisungen mittels "in"- und "out"-Assemblerkommandos auf
ähnliche Weise verkappt und schliesslich mittels "inline" beschleunigt ausgeführt.
Es ist also nicht zu übersehen, dass ein enormer Aufwand an Machtworten und Verrenkungen nötig
ist, um die zunächst sinnfällig und einfach erscheinenden Abstraktionen der "höheren"
Programmiersprache C auch nur annähernd so tauglich wie Assemblerkommandos zu machen.
Weil aber die in Assembler übliche Parameterübergabe in Registern garnicht möglich ist, sondern
nur entweder umständliche Transfers über den Stack oder (bei Zeigern als Parameter) viel
Aufwand beim Linking für all die globalen Variablen, ist die Effizienz von Prozeduraufrufen
unerreichbar. Gerade bei grösseren Zusammenhängen, die gerne "System" genannt werden, führen die
Abstraktionen, die so funktionell daher kommen, zu Problemen, die nur durch weitere, noch
"höhere" Programmiersprachen einigermassen überschaubar zu bewältigen sind.
Auch deshalb sucht man in manchen Programmpaketen vergeblich nach einer Funktion namens main(),
von der aus man wie in Assemblerprogrammen den Programmzähler begleitend die Abläufe verstehen
könnte. Solche Programme werden erst während der Installation zum fertigen Zusammenhang. Dabei
spielt nicht nur der Präprozessor eine Rolle. Auch besondere Übersetzungsprogramme, für die
noch in eigener Sprache Programme geschrieben werden müssen, sind dann nötig - z.B. die
Programme "make" und "bash", die in verschiedenen Varianten vorkommen.
Natürlich blieb den Schöpfern der "höheren" Programmiersprache C nicht verborgen, dass beim
Aufbruch zur Maschinen-Unabhängigkeit nicht Freiheit, sondern bedenkenswert untaugliches heraus
kam. Es gab aber wegen der mittlerweile gewachsenen Bedeutung dieser höheren Sphären für die
Bilanz von Unternehmen nicht etwa reuige Einkehr, sondern noch eins drauf. Eine angeblich neue
Vorgehensweise bei der Programmierung solle einreissen, "object orientiert" solle formuliert
werden, undzwar mit neuen Compilern. Aber tatsächlich wurde so ein Schritt zurück zu dem
gemacht, was Assemblerprogramme vorteilhafter als C-Programme macht. Das zum C++ erweiterte
C ist also in wesentlichen Teilen eine Aufhebung von Fehlentscheidungen - allerdings zugunsten
neuer, und natürlich mit erweiterter Wortgewalt...
Die Objektorientierung kommt durch eine Erweiterung des Variablenstapels struct zustande, dessen
Realisierung ich als "object" bereits vorgestellt habe. Elemente solcher Objekte können in C++
auch Einsprungsdressen von Funktionen sein, womit dann ein struct zur "class" (="Klasse") wird
(und ein Objekt evtl. auch "Instanz" genannt wird). In GNU-C/C++ kann das Schlüsselwort
auch entfallen, falls mit g++ compiliert wird (was die Andersartigkeit eventuell schwer erkennbar
macht...).
Obgleich die nun neu zugelassenen Elemente einer Struktur zwar wie Funktionen formuliert sind,
werden sie im Zusammenhang mit Klassen gleich mit mehreren Synonymen benannt:
"member"="method"="interface"="element-function"...
Sie müssen auch noch ganz anders deklariert werden, weil natürlich klar gemacht werden muss, dass
sie nicht in der Weise global rufbar sind wie C-Funktionen, sondern standardmässig nur im
Zusammenhang mit der Klasse, in deren Block sie implizit "inline" definiert werden.
Wie Strukturen können Klassen vom Compiler vervielfältigt werden, nicht aber vom Programm
zur Laufzeit und damit auch nicht etwa Platz sparend. Und auch nicht etwa Mühen sparend,
weil das, was in Assembler nach dem Umkopieren von Abläufen an Spezifizierung sein muss, natürlich
nicht weg gezaubert werden kann! Während in Kopien von Assemblercode aber allein der Bezug zum
Problem zu beachten ist, ist in C++ auch noch ein Problem mit der Ausdrucksweise zu haben.
Ausdrücke wie "Vererbung" ("heritage") erhalten neue Bedeutung, "Polymorphismus" und
"Homonymbildung" werden zu Schlachtrufen. Dahinter verbergen sich natürlich alte Hüte, die in
Assembler keinen Buchstaben wert sind. Die Bedeutungsgebung kommt ja wie gesagt nicht etwa dadurch
zustande, dass man etwas an die Namen von Variablen klebt, sondern Abfolge und Umgang mit Variablen
und Programmsequenzen gestaltet.
Die Zeiger auf Funktionen sind nicht nur nicht ausserhalb der Klasse benutzbar, sie sind auch nicht
etwa während der Laufzeit irgendwie umdefinierbar. Diese Methoden erlauben also jedenfalls keine
indizierte Adressierung, die Abläufe in anderem Zusammenhang dienstbar machen könnte, sondern nur
den Anschein dessen. Der Zweck wird tatsächlich nur durch (vom Compiler absolviertes) Umkopieren
der ganzen Klasse erreicht und heisst: "Instanzierung"
Deklariert werden Klassen etwa so:
class Klassenname {void Methode(int);....};
Und innerhalb des Klassenblocks sieht die Definition etwa so aus:
class Klassenname {void Methode(int){};....};
...wobei die Methode implizit "inline" definiert wird. Definiert man eine solche Funktion ausserhalb
des Klassenblocks, dann muss das im Gegensatz zur Typisierung von Funktionen bei der Definition
explizit "inline" getan werden:
inline void Klassenname::Methode(int variable;){....};
...womit die Funktion also eine Art Familiennamen erhält, der tatsächlich eine Adressarithmetik
anordnet und sie unterscheidbar macht von einer anderen (echten) Funktion gleichen Namens.
Ganz analog zur Vervielfältigung von Strukturen wird die Instanzierung von Klassen ausgedrückt:
Klassenname klasse1,klasse2,...;
Der Ruf der Methoden erfolgt ebenfalls analog zum Umgang mit Strukturen mittels Punktoperator:
klasse1.Methode(...);
Schliesslich sind in jeder Klasse noch Kopf und Schwanz in der Form von Methoden vorhanden, die
"construktor" und "destructor" heissen, wenn man über sie sprechen will. Beide Methoden müssen aber
nur ausdrücklich definiert werden, wenn man nicht die vom Compiler gefertigte Ausgangslösung will.
Dann allerdings hat man zu beachten, dass beide Methoden stets den Namen der Klasse haben müssen,
wobei der Name des Destruktors noch mit Präfix "~" versehen werden muss. Beide Methoden müssen vom
Typ void sein. Der Konstruktor ist eine Initialisierung, in der beispielsweise die Variablen der
Klasse definiert werden. Der Destruktor ist ein Abgesang, der vor allem dazu dient, eventuell
benutzten Speicherplatz wieder frei zu geben.
Jeder Umgang mit einer Klasse hat also eingerahmt zu sein von Rufen nach Konstruktion und
Destruktion, falls die Methoden dafür ausdrücklich definiert sind.
Innerhalb der Methoden ist immer ein Zeiger namens "this" auch explizit nutzbar, der auf das
Objekt selbst zeigt und vom Compiler erzeugt wird. Das Vorhandensein dieses Zeigers macht klar, dass
Klassen vor allem durch feste Adressrelationen nicht nur zwischen Datenelementen, sondern auch
Methoden gekennzeichnet sind. Tatsächlich werden die Methoden keineswegs als Ablauf "inline" in eine
Struktur gestellt. Sie sind genauso "FAR" rufbare Prozeduren wie die C-Funktionen ausserhalb. Ihre
Einsprungadressen sind aber auf "this" bezogen und deshalb nur mit Adressarithmetik erzeugbar. Das
erübrigt andererseits das Theater auf dem Stack zugunsten eines zur Klasse gehörenden
Parameterbereiches. Bei Methoden wird also gegenüber Funktionen gerade mal der oft untaugliche
Umgang mit dem Stack weg optimiert. Dass das überhaupt wahrnehmbare Vorteile in Bezug auf die
gepriesene Effizienz von Klassen hat, beweist nicht etwa deren Effizienz, sondern nur die
Ineffizienz von C-Funktionen.
In Assembler macht man sowas wie Klassen mit einem Variablenstapel unterhalb der Einsprungadresse
einer Prozedur, der mit Einsprungadresse minus Displacement adressierbar ist. Das ist keinen
Buchstaben mehr an Kraftausdrücken wert und kommt allein durch die Displacementadressierung und
Anordnung der Label zustande - und kann weitaus raffinierter gestaltet werden! Insbesondere
unterscheiden sich solche Prozeduren nicht von beliebigen anderen FAR rufbaren und können also
auch indiziert gerufen werden. Die dabei nötige Adressarithmetik ist mit der Nutzung effektiver
Adressen äusserst kompakt und elegant formulierbar.
Diese Adressarithmetik wird dagegen im Zusammenhang mit Klassen zu vielen neuen, schönen
Ausdrücken, Operatoren und Regeln, die ich im weiteren vorstelle.
Die Instanzierung von Klassen kann auch in die Deklaration weiterer Klassen eingebunden sein.
Dabei dient der Doppelpunkt als Verknüpfungsanweisung etwa so:
class Klasse1{...};
class Klasse2 : Klasse1 {...};
...womit die Elemente der "Klasse1" auch zu Elementen der "Klasse2" werden, und solcherart
"vererbt" werden. Das Erbgut der "Klasse1" kann durch weitere Schlüsselworte beschädigt werden.
Die können auch die Sichtbarkeit bei Zugriffen betreffen. Auch hier wird wieder der Doppelpunkt
verwendet, der natürlich auch hier keine Label kennzeichnen soll! Gemeint sind stattdessen
Gültigkeitsbereiche, die aber nach besonderen Regeln Bedeutung erlangen:
"public:", "private:", "friend:", "protected:"
Ohne besondere Qualifizierung mit solchen Quasilabeln sind Klassenelemente "public". Jede
Einschränkung mit den anderen drei Ausdrücken muss dagegen ausdrücklich vor jeder Deklaration
eines Klassenelementes stehen, das betroffen ist. Es können aber auch andersrum ganze Klassen
damit qualifiziert werden und damit Einschränkungen in verknüpften Klassen aufgehoben werden.
Ich gehe aber nicht weiter auf diese besondere Vererbungslehre ein, die allein durch die Regeln
der Kompilation bedingt ist und ansonsten höchstens den Zweck haben kann, Bibliotheken und
Betriebssysteme User-feindlich zu verstecken. Andernfalls könnte auch peinlich klar werden, wie
wenig mit viel Geld bezahlt werden soll.
Ein bemerkenswertes Problem entsteht aber durch die auch bei Methoden erlaubte Mehrdeutigkeit
von Funktionsnamen, falls Ableitungen von Klassen und Vererbung ins Spiel kommen. Ausserdem kann
nach der eben skizzierten Vererbungslehre auch insofern Inzucht getrieben werden, als eine
abgeleitete Klasse über verschiedene Ableitungen zweimal das gleiche erbt. Man muss dann
ausdrücklich Mehrdeutigkeit und falsche Zuordnungen mit dem Machwort "virtual" korrigieren. Ich
zeige hier aber nur die Verwendung im Zusammenhang mit Methoden, weil mit einer besonderen
Formulierung für Zeiger auf Methoden und deren Dereferenzierung dann etwas herauskommt, was den
Namen "Polymorphismus" trägt. Er soll dazu dienen, mit dem Zeiger auf eine Methode der Basisklasse
verschiedene Methoden unter gleichem Namen in abgeleiteten Klassen rufen zu können. Wer also eine
Flächenberechnung, die für Kreis und Rechteck natürlich verschieden sein muss, in Instanzen unter
dem gleichen Namen einer in der Basisklasse definierten Methode zuordnen will, hat dennoch
in der Deklaration höllisch aufzupassen, damit das, was zweierlei ist, sich wenigstens im
Parameterteil der Methode unterscheidet. Nur beim Ruf nach der Methode darf dann vergessen werden,
dass "Rechteck.Flächenberechnung();" sich von "Kreis.Flächenberechnung();" genau da unterscheidet,
wo dem Namen nach das gleiche geschieht.
Die Formulierungen dieser Adressarithmetik für Zeiger auf Methoden sehen folgendermassen aus, wenn
in Basisklasse und zwei abgeleiteten Klassen drei verschiedene, aber gleich mit "Flaeche" benamte
Methoden definiert sind:
class Basisklasse *zeiger;zeiger=&Basisklasse;
... definiert mit besonderer Typisierung einen Zeiger auf die Basisklasse. Mit der gleichen
Typisierung kann auch jede abgeleitete Klasse zum Wert des Zeigers werden.
Von der Basisklasse abgeleitet seien zwei weitere Klassen namens "Kreis" und "Rechteck", die
jeweils ausser der in der Basisklasse definierten Methode namens "Flaeche" noch eine jeweils
zweckgerecht formulierte Methode gleichen Namens enthalten. Dann kann man die richtigen Resultate
bekommen mit dem oben schon gezeigten Punktoperator:
kreisflaeche=Kreis.Flaeche();rechteckflaeche=Rechteck.Flaeche();
Benutzt man den eben definierten Zeiger auf die Methode, bekommt man aber nur das raus, was die
Methode der Basisklasse resultiert! Dabei ist jedenfalls ein besonderer Dereferenzierungsoperator
nötig, der relativ zum Zeigertyp (eine Klasse!) kalkuliert:
resultat=zeiger->Flaeche();
...ergibt sogar dann das (falsche) Resultat aus der Basisklasse, wenn man zuvor die Adresse des
Zeigers mit einer der abgeleiteten Klassen umdefiniert hat! Kann man sich aber als
Assemblerprogrammierer die Adressbereiche vorstellen, die als "Klassen" vorgestellt werden, dann
ist völlig klar, dass ja das ererbte Zeug aus der Basisklasse zuunterst in den abgeleiteten
Klassen gestapelt wird und infolgedessen die relative Adresse dort die ererbte Methode meint!
Dieser Konstruktionsfehler muss also ausdrücklich noch mit "virtual" vor der Methode der
Basisklasse repariert werden - also etwa so:
class Basisklasse {virtual Flaeche(int a,int b){return a*b;}.......};
...womit diese Methode nur für den Fall, dass der Zeiger auf die Basisklasse zeigt, nicht zum
Geist wird. Wenn nun der Zeiger umdefiniert wird, dann ergibt die genau gleiche Anweisung für
eine Rechnung das Resultat für Rechteckfläche bzw. Kreisfläche nur abhängig von der Definition
des Zeigers. Das noch im Beispiel:
zeiger=&Kreis;kreisflaeche=zeiger->Flaeche();zeiger=&Rechteck;rechteckflaeche=zeiger->Flaeche();
Es bleibt zu sagen, dass Polymorphismus ein Spuk ist, der allein durch die ganz und garnicht
mathematische Mehrdeutigkeit von Namen zustande kommt, die bei der komischen Vererbungslehre
für Klassen aber nicht zu vermeiden ist. Allerdings muss einen dieser Spuk beschäftigen,
wenn man mit der Klassenbibliothek umgehen will. Dort gibt es ganze Stammbäume von Klassen,
deren genaue Vererbungslinien wichtig werden bei der Paarung eigener Werke mit Bibliotheksklassen.
Etwas weniger spukhaft ist die "Homonymbildung" in "templates" (="Schablonen"), wo die
Typisierung überladbar ist. Ich habe schon gesagt, welche Gründe es geben kann, Typen zu
definieren - und ich habe auch gesagt, wann es keinen Grund dafür gibt. Dass es sich tatsächlich
so verhält, zeigt sich in diesem Fortschritt namens template, der allerdings nicht ohne weitere
besondere Ausdrücke dahin zurückkehrt, wo Assemblerprogrammierung schon immer war.
Ein template kann für Funktionen und Klassen gebildet werden und ist durch ein erstes
Schlüsselwort "template" und eine Typangabe gekennzeichnet - etwa so:
template "class Typname"
...wobei der Ausdruck "class" hier was ganz anderes als eine Klasse meint! Er bezeichnet hier den
verallgemeinerten Typ, mit dem der Compiler bei der Übersetzung umgehen soll - natürlich nicht
ohne Eigennamen. Statt der Gänsefüsschen muss mit kleiner-grösser-Zeichen geklammert werden, die
ich in diesem HTML-Text nicht verwenden kann.
Eine Funktionsdefinition mit einem solchen allgemeinen Typ sieht dann etwa so aus:
template "class T" Funktionsname(Tx,Ty){return x>y?x:y}
Diese Funktion arbeitet nun unabhängig davon richtig, ob der Typ von x und y beim Ruf als
byte, int, float, char oder sonstwas in 32 Stellen definiert ist, weil der Compiler den
mächtigsten Datentyp definiert und beim Ruf das Type-casting automatisch einfügt. Was also in
Assemblerprogrammen völlig selbstverständlich ist und ohne irgendwelche besonderen Verrenkungen
getan ist, ist in C++ "Homonymbildung" und macht eine ganz eigene Standardbibliothek nötig, die
Standard Template Library (=STL), die neben der Standard-C- (="libc")Teil der
Standard-Klassen-Bibliothek (C++class library) ist.
Weil in einem C/C++Programm nicht nur beliebig zwischen den Ausdrucksformen für C und C++
gewählt werden kann, sondern auch beliebig Funktionen aus zweierlei Bibliotheken eingebunden
werden können, können sehr gut fühlbare Compilierzeiten für banale Dinge zustande kommen.
Und ausserdem kommen sehr gut fühlbare Lernphasen zustande, wenn man sich in die Eigenarten
der jeweils verfügbaren Funktionen, Klassen oder Templates einarbeiten muss. Da ist nämlich
nichts ohne weitere besondere Zauberworte und Rituale zu haben!
Wie schon eingangs bemerkt, gibt es eine zu grosse Zahl von Bibliotheksfunktionen, um sie in
einem Text wie diesem (der sich mit Grundlagen befasst) auch nur aufzuzählen. Ich stelle aber
fest, dass jede notwendige Funktionalität (z.B.Dateitransfer) jeweils in jeder der zwei
Standardbibliotheken für C und C++ vorkommt (mit jeweils verschiedener Ausdrucksweise).
Die Klassen-Bibliothek enthält aber gegenüber der C-Standardbibliothek deutlich mehr mit
deutlich mehr besonderen Ausdrücken rufbares. Was da standardisiert wurde, geht dennoch nicht
über denkbar komlipiziert aufgemachte Banalitäten hinaus. Alles ist in Assembler mit einem
Repertoire von einem Dutzend Kommandos schneller getan als eine Suche nach der passenden Klasse.
Und hat man eine Teilaufgabe mal gelöst, hat man insofern die denkbar beste Schablone für
ähnliche Aufgaben geschaffen, als man sie nicht nur durch einfaches Umkopieren verfügbar machen
kann, sondern auch in jeder erdenklichen Weise abändern kann.
Abschliessend kann gesagt werden, dass die scheinbar mathematische Weise, Dinge des Denkens in
C/C++ auszudrücken, trügt. Schon die eindeutige Zuordnung von solchen Dingen zu Zeichen (oder
Bezeichnungen) ist nicht mathematisch korrekt. Aber auch die Bezeichnungen selbst sind meist
irritierend daneben. Beispielsweise sind C-Funktionen ganz und garnicht Funktionen, die dem
mathematischen Begriff genügen. Dies ist aber nur ein Beispiel vieler Verballhornungen.
Auch wer das verkraftet, hat mit einem Compiler und einem Linker zu tun, der alles verkraften muss!
Da wird nämlich der Zeitbedarf von Logik bzw. Unsinn unbestreitbar, und es wird völlig klar, was
Abkürzungen sind und was nicht.
Auch wer verkraften will, dass sein Programm letztlich in mehr Binärcode übersetzt wird als ein
Äquivalent in Assembler, weil doch ein "+" deutlich weniger Buchstaben enthält als "add", irrt.
Nur in kleinen Beispielen führt das oder der implizite Transfer über CPU-Register tatsächlich zu
weniger Schreibarbeit!
Reale Programme enthalten dagegen stets Bezüge zu Standardbibliotheken, Besonderheiten des
Ziel-Betriebssystems und Compilers und besondere Anordnungen und Typisierungen von Variablen.
Das alles entfällt bei Assemblerprogrammierung jedenfalls dann, wenn man mit einem
Assembler-Betriebssystem wie meinem ASMOS zu tun hat. Dann hat man sogar weniger zu schreiben.
Aber auch bei Nutzung eines "inline" Assemblers wie dem im folgenden vorgestellten "GNU-assembler"
können schon viele Dinge deutlich einfacher und schneller gemacht werden.
INLINE-ASSEMBLER:
Während ein Inline-Assembler nach ANSI-Standard Bestandteil von C/C++ sein muss, ist der zu
verwendende Assembler-Dialekt keineswegs standardisiert, sondern vorgeschrieben durch den
jeweiligen Erzeuger eines Compilers. Ich handle hier nur den GNU-Assembler-Dialekt ab, der z.B.
unterhalb LINUX zu verwenden ist. Er kommt aber auch gleich in zwei Varianten daher, deren Wahl
abhängig ist vom Zweck. Ich gehe hier aber nicht auf den Zweck ein, eine Übersetzungseinheit ohne
C/C++ Code zu schreiben. Ich stelle nur die Schreibweise für den Zweck des inline Assemblers vor.
Wird dieser inline-Assembler benutzt, muss mit g++ ,dem C++-Compiler compiliert werden!
Alle Variablen, die in Assemblerkommandos benutzt werden, müssen global deklariert werden, also
ausserhalb jeden Blocks vor main()!
Der Assemblercode selbst ist dagegen keineswegs global, sondern lokal innerhalb von Funktionen.
Allerdings kann man zwischen Teile von Assemblercode innerhalb einer Funktion Funktionsaufrufe
in C schreiben und auch darüber hinweg springen. Es ist also z.B. erlaubt, mit:
asm("....;je drüberweg;raus: \n"........);irgendeine_C_Funktion();asm("drüberweg: \n"..........)
einen bedingten Funktionsaufruf von "irgendeine_C_Funktion()" zu erreichen, der in C so aussieht:
...if(1)irgendeine_C_Funktion();else goto drüberweg;
Abweichend von der genormten Ausdruckweise für den genormten C++-inline-Assembler wird mit dem
GNU-inline-Assembler ein Stück Assemblerprogramm mit einem Schlüsselwort so ausgedrückt:
asm("Assembleranweisungen":*:*:*);
Sowohl die Assembleranweisungen zwischen Gänsefüsschen wie das, was anstelle der Sternchen steht,
hat nicht nur besondere Bedeutung für den Compiler, sondern wird auch eigenartig formuliert.
Zunächst zu den Assemblerkommandos:
Alle Mnemoniks müssen ein die Operandenbreite definierendes Postfix enthalten. Das Mnemonik "mov"
kommt also nur als "movb","movw" oder "movl" vor, wobei die Bedeutung der Postfixe
ja eigentlich gleich klar ist: "b" für Byte, "w" für Word und "l" für Longword.
Die Fehlerprüfung ist in diesem Punkt sehr genau, und sie verwirft auch Kommandos, die solche
Postfixe nicht enthalten dürfen wie z.B. der Wiederholungsbefehl "rep".
Alle Registernamen müssen nicht nur das vom GNU-Assmbler gebrauchte Präfix "%" enthalten, sondern
noch ein weiteres "%" davor, weil die Assembleranweisungen nicht direkt assembliert werden, sondern
zunächst als Zeichenkette mit Formatanweisungen vom GNU-C++-Compiler verarbeitet werden.
Der Transferbefehl für ein Byte aus dem Register "al" an das Register "bl" sieht also so aus:
movb %%al,%%bl;
Als Trenner der Kommandos dient ";" oder "\n" für einen Absatz.
Und wie der Kenner schon gemerkt haben kann, muss im GNU-Assembler auch die Abfolge von Quell-
und Zielregister anders als oben für NASM vorgestellt formuliert werden! Das Quellregister steht
vor dem Zielregister!
Auch Immediatezuweisungen, zwingend mit Präfix "$" geschrieben, stehen also links:
movb $5,%%al;
Ebenso der Subtrahend und der Schein-Subtrahend in Vergleichen:
"subl %%eax,%%ebx;" subtrahiert den Wert in "eax" von dem in "ebx"
"subl $5,%%ebx;" subtrahiert 5 vom Wert in "ebx"
"cmpl %%eax,%%ebx;" vergleicht mit Flag-bedienung,
indem der Wert in "eax" von dem in "ebx" subtrahiert wird.
Ein "jl sprungziel" bezieht sich also auf den Wert in "ebx", wo das Resultat
(scheinbar) steht - es wird gesprungen, falls der Wert in "eax" grösser als der in "ebx" ist.
(Wer das alles schon anders eingeübt hat, muss höllisch aufpassen!)
Wie schon bemerkt, werden die Kommandos als Zeichenkette formuliert, formatiert wie für die
Standardheader der libc vorgeschrieben. Da gilt "%" als Sonderzeichen, das zum normalen Zeichen
nur durch Präfix "%" wird. Auch dürfen keine Absatzzeichen mit der ENTER-Taste eingefügt werden.
Absätze dürfen innerhalb der Gänsefüsschen, die also stets eine Zeile klammern, nur als "\n"
(natürlich ohne die Gänsefüsschen!) eingefügt werden. Eine Zeile sieht also z.B. so aus:
"movl $5,%%eax;movl $3,%%ebx;subl %%eax,%%ebx;jl irgendwohin;movl %%ebx,variable\n"
Wobei der Name der Variablen irgendein global deklarierter im umliegenden C-Programm sein darf.
Das Sprungziel ("label") wird wie gewohnt mit Namen und Postfix ":" deklariert:
"irgendwohin: \n"
Die Ziffern in Zahl-Konstanten-Zuweisungen sind wie für den C-Compiler gefordert zu schreiben.
Ebenso wird Kommentar als /*blabla*/ wie in C geübt und ausserhalb(!) der Gänsefüsschen eingefügt.
Damit nicht genug der Besonderheiten - nun zu den Doppelpunkten in der asm()-Anweisung:
Es müssen stets drei solcher durch beliebige Trenner getrennte Doppelpunkte vorkommen, undzwar
ausserhalb der Gänsefüsschen, die den Assembler klammern. Sie dienen dem C-Compiler, die
Registerbelegung zu erfassen. Hinter dem letzten Doppelpunkt muss jedenfalls immmer was stehen!
Dort werden die Register aufgezählt, deren Inhalt vor der Abhandlung des Assemblers gerettet
werden soll, bzw. die innerhalb des Assemblerblocks verwendet werden.
Hier werden die Registernamen aber nicht mit Präfix "%" geschrieben! Sie werden aber jeder
für sich zwischen Gänsefüsschen gesetzt und mit Kommata getrennt. Bei Nennung aller Register
sieht das also so aus:
: "eax","ebx","ecx","edx","edi","esi","ebp","cc","memory"
Der reservierte Ausdruck "memory" erzwingt ausserdem noch die Rettung aller Speicherbereiche des
Programms. Das ist aber überflüssig, wenn man nicht in der Entwicklungsphase steckt, und sogar
dämlich, wenn man einen mit Assembler bearbeiteten Speicherbereich im weiteren C-Programm zur
Verfügung haben will.
Hinter den beiden ersten Doppelpunkten muss nicht, kann aber was stehen. Hinter dem ersten wird
einem einzigen Register eine Ausgangs-Variable zugeordnet, hinter dem zweiten beliebig vielen
Registern jeweils eine Eingangs-Variable. Geschrieben wird das z.B. so:
: "eax" (ausgangsvariable)
: "ebx" (eingangsvara), "edx" (eingangsvarb)
Natürlich muss der Typ der Variablen zur Registerbreite passen!
Die Ausgangsvariable darf nicht im Assembler gelesen werden, die Eingangsvariable nicht
angeschrieben werden.
Diese Variablen können auch nur innerhalb des Blocks, in dem der inline-Assembler steht, deklariert
werden - also lokal.
Wir können dies aber (im gcc-manual genau beschrieben) einfach vergessen, wenn wir, wie ich eingangs
vorgeschlagen habe, im umliegenden C-Programm globale Variable deklarieren. Die können wir dann
in beliebiger Anzahl vorbereiten und beliebig im Assembler benutzen.
Aber VORSICHT! - global deklarierte Variable werden vom Compiler anders angelegt als lokale. Man kann
sie zwar auf Vorrat deklarieren ohne dass sie vom Compiler mit der Fehlermeldung "unused variable..."
bedacht werden, müssen dafür aber - ob benutzt oder nicht - vielfache von 32 Stellen (long) belegen,
wenn sie short (=16 Stellen) sind, und jedenfalls geradzahlige Anzahlen bilden, wenn sie char sind.
Wenn das nicht der Fall ist (weil z.B. gerade mal eine int Variable nötig wurde...), können völlig
unerklärliche Abstürze die Folge sein! Also muss man bei scheinbar unerklärlichem Fehlverhalten
eventuell eine unbenutzte Variable zusätzlich deklarieren.
Das war jetzt aber auch schon alles, was zu den Formalien des inline-Assemblers zu sagen ist.
Es ist nur noch zu erwähnen, dass man IDT oder GDT durch kein Kommando erreichen kann und auch den
I/O-Transfer nur programmieren kann, wenn im rahmenden C-Programm die Funktion ioperm() gerufen wird.
Man kann allerdings die Funktionen von Linux mittels "int $0x80;"aufrufen. Die Funktionsnummern
müssen dann an %eax transferiert werden, sowie möglicherweise bis zu
fünf Parameter an %ebx, %ecx, %edx, %esi, %edi
Resultate werden in %eax übergeben, negative Zahlen sind Fehlermeldungen. Resultate sind also nur
31-stellig, erlauben aber bedingte Verzweigungen zu Fehlerabhandlungen mittels:
"js Fehlerabhandlung;"
Die Funktionsnummern sind im libc-header "asm/unistd.h" definiert. Dort erhalten sie Namen, die
die Nummern zum einen in C/C++ handhabbar machen, und die andererseits die erreichbaren Zwecke klar
machen. Die Namen haben die Form "__NR_syscallname", wobei "syscallname" variiert.
In unistd.h sind auch weitere Funktionen definiert, meist aber nur deklariert.
Sollen dem Programm beim Aufruf Parameter übergeben werden können, gilt, was auch für C/C++-Programme
gilt: Die Parameter liegen auf dem Stack. Wie das aussieht und wie man damit umgeht, lässt sich
aus libc...crt0.S oder crt1.S sowie den Kerneldateien exec.c und binfmt_*.c in linux/fs schliessen.
Ausserdem ist in der Dokumentation von NASM (natürlich in anderem Dialekt) nicht nur das Rufritual
in UNIX-Systemen dargestellt. Auch "header" für Programme in anderer Umgebung werden vorgestellt.
Adressen von Labeln müssen zur Laufzeit mittels lea variable,%%eax; ermittelt werden. Damit kann dann
aber auch eine C-Funktion gerufen werden.
STANDARDFUNKTIONEN:
Die C/C++Standardfunktionen arbeiten nur innerhalb einer UNIX-artigen Betriebssystemumgebung und
gehen deshalb alle durch den Flaschenhals des Interrupts 0x80, der jedenfalls unter LINUX der
Betriebssysteminterrupt ist. Neben der Abfolge und Bedeutung der Parameter sind besondere Typen
für Eingaben oder Rückgabewerte zu beachten und eventuell zu konvertieren (mit type-cast):
"void ", "size_t " und "FILE ".
Hier stelle ich nur die wichtigsten C-Standardfunktionen mit den unter LINUX gültigen Regeln und
Kürzeln vor. Alle Funktionen sind ausserdem gut innerhalb der LINUX-Quellen dokumentiert, undzwar
im Verzeichnis /usr/scr/linux/Documentation/....
Die Funktionen sind stets der Inhalt von "header"-Dateien, die mittels Präprozessoranweisungen
zum Teil eines Programms gemacht und damit "eingebunden" (="included") werden. Wie solche
Anweisungen formuliert werden müssen, erkläre ich erst weiter unten. Die hier genannten Funktionen
befinden sich alle in einer der beiden Headerdateien "stdio.h" und "stdlib.h". Beide müssen
praktisch immer eingebunden wurden (oder die Äquivalente in C++).
Wer für irgendwelche Daten-Ein- oder -Ausgaben Speicherplatz braucht, darf niemals annehmen, dass
der auch verfügbar sei. Stets gibt es eine erste (niedrigste) Adresse, die frei ist, und eine
letzte, die darüber noch verfügbar ist. Nur das Betriebssystem kann die Arbeitsspeicherbelegung
kennen und muss deshalb mit verschiedenen Standardfunktionen zur Freigabe von Speicherplatz
(="allocation") bewegt werden, deren wichtigste "void *malloc(size_t Speicherplatzbedarf)" ist.
Die Freigabe des mit dieser Funktion aquirierten Speicherplatzes erfolgt mit
free(unsigned long int Basisadresse)
Es ist also unbedingt zu beachten, dass der Rückgabewert von malloc() bereits mittels type-cast in
einen Typ gewandelt wird, der in free() wieder taugt.
Andererseits kann bei direkten Angaben für den gewünschten Speicherplatz eine Typisierung natürlich
entfallen. Das ganze Ritual sieht also etwa so aus:
unsigned long int *Basisadresse;...........................................
size_t Speicherplatzbedarf;................................................
Speicherplatzbedarf=0x100000;..............................................
Basisadresse=(unsigned long int *)malloc(Speicherplatzbedarf);.............
free(Basisadresse);........................................................
Alternativ zu malloc() kann calloc() verwendet werden, was dazu führt, dass der geforderte Bereich
gleich mit Nullen initialisiert wird. Nach malloc() kann realloc() benutzt werden, um den Bereich zu
vergrössern.
Mit allen Funktionen kann auch "dynamisch" Speicher aquiriert werden, also angepasst an
Augenblicksbedarf, der z.B. mit sizeof() ermittelt werden kann. Das ist allerdings wenig
empfehlenswert, weil bei diesen Funktionen ein unheimlicher Apparat in Bewegung gesetzt wird,
der nicht mehr allzu dynamische Abläufe erlaubt. Das wird auch nicht wirklich anders durch kürzere
Ausdrücke, die als Operatoren zum Sprachumfang von C++ gehören. Deshalb seien hier nur noch die
Namen der Operatoren "new" und "delete" genannt, die natürlich auch nur nach eigenen Regeln
mit Operanden gepaart werden können und "sizeof()" implizit nutzen.
Will man mit Dateiinhalten umgehen, hat man die Funktionen "fopen()", "fseek()", "ftell()",
"fread()", "fwrite()" und "fclose()" zur Verfügung.
Immer muss jeder Dateitransfer zwischen fopen() und fclose() geklammert werden!
All diese Funktionen gehen mit Zeigern vom Typ FILE um, die am besten global deklariert werden.
Ausserdem ist ein Zeiger auf Basisadressen zu transferierender Dateiinhalte vom Typ void anzulegen
und ein Rückgabewert vom Typ size_t zu verarbeiten, bei fseek() aber vom Typ signed short und bei
ftell() vom Typ unsigned long int.
Das ganze Ritual sieht also etwa so aus:
FILE *Datei................................................................
void *Basisadresse;........................................................
size_t Rückgabewert;.......................................................
unsigned long int Dateilaenge;.............................................
signed short Rückgabewertseek;.............................................
Dateizeiger=fopen("PfadnameDerDatei","r");.................................
Während der erste Parameter als Zeichenkette zwischen Gänsefüsschen den vollständigen Pfadnamen
enthalten sollte, ist der zweite Parameter eine Art Option, die ebenfalls zwischen Gänsefüsschen
zu stehen hat (r=lesen,w=schreiben...genaueres in der LINUX-Dokumentation).
Ein Fehler wird mit dem Wert NULL des Dateizeigers ausgegeben.
Danach kann vor dem Lesen die Länge der Datei interessieren, um Speicherplatz zu aquirieren. Das
geht mit den beiden Funktionen fseek() und ftell() etwa so:
Rückgabewertseek=fseek(Datei,0,SEEK_END);
Mit dem dritten Parameter, einem Schlüsselwort, wird der Dateizeiger auf das Ende der Datei
gesetzt. Ein negativer Rückgabewert signalisiert einen Fehler. Die Länge der Datei kann nun aus
dem Wert des versteckten Dateizeigers gelesen werden mit:
Dateilaenge=ftell(Datei);
Danach muss der Dateizeiger natürlich auf den Anfang zurückgesetzt werden, wo das Lesen beginnt:
Rückgabewertseek=fseek(Datei,0,SEEK_SET);
Hat man genug Speicherplatz aquiriert und den Zeiger auf die Basisadresse definiert, kann es
mit Lesen weitergehen, wobei der zweite Parameter die Elementgrösse in Byte angibt:
Rückgabewert=fread(Basisadresse,1,Dateilaenge,Datei);
fclose(Datei);
Es gibt weitere Varianten dieses Ablaufs, weil statt eines Dateinamens auch ein Gerätename
verwendet werden kann. Nur durch die Wahl dieses Parameters kann also auch aus dem Internet oder
einer Videokarte gelesen werden (Analog geht das beim Schreiben).
Will man aus einem Arbeitsspeicherbereich in eine Datei schreiben, wird zunächst die noch leere
Datei mit Namen und Schreiboption angelegt (oder eine vorhandene zum Schreiben geöffnet):
Dateizeiger=fopen("PfadnameDerDatei","w");
Dann wird völlig analog zu fread() geschrieben:
Rückgabewert=fwrite(Basisadresse,1,Dateilaenge,Datei);
Wenn man nicht eine Datei vollschreiben will, sondern nur ein paar Werte auf dem Bildschirm
ausgeben will, dann benutzt man den "Standardkanal", der in Zeilen schreibt, die unter der
Kommandozeile erscheinen. Ich zeige nur die einfachste Form:
printf("die zu zeigende Zeichenkette/n");
Von den zahlreichen Formatieranweisungen ist nur gezeigt "/n". Diese zwei Buchstaben habe ich
oben schon beim Inline-Assembler als Absatzzeichen vorgestellt.
Will man direkt mit irgendwelchen I/O-ports umgehen, muss die Headerdatei
"/usr/scr/linux/include/asm/io.h" eingebunden und die Funktion "ioperm()"oder "iop1()" gerufen
werden. Informationen zu Einzelheiten sind im Verzeichnis"/usr/doc/..." zu finden
Abschliessend will ich am Beispiel der Standardfunktion malloc() noch klar machen, welche
Vorteile eine Programmierung mittels C/C++ unter einem UNIX-System gegenüber einem
Assembler-Betriebssystem hat, wie ich es als "ASMOS" geschrieben habe.
Das mit dieser Funktion abgehandelte Problem ist nämlich äusserst einfach vom Wert zweier
Variablen abhängig. Normalerweise wird der Speicher von einem Betriebssystem von niederen zu
höheren Adressen aufsteigend voll gestapelt. Es gibt also eine untere, erste freie Adresse
und eine obere, letzte noch beschreibbare Adresse. Die untere Adresse ist diejenige, die malloc()
zurückgibt. Die obere Adresse wird berücksichtigt bei der Feststellung, ob der gewünschte
Bereich auch vorhanden ist. Das ist also eine denkbar einfache Rechnung, die mit folgenden
Kommandos unter ASMOS zu machen ist:
mov eax,[es:100010h] ; auf dieser absoluten Adresse stehe die untere Adresse
add eax,[Speicherplatzbedarf] ;...kann meist immediate als Ziffer definiert werden
cmp eax,[es:100014h] ; auf dieser absoluten Adresse stehe die obere Adresse
jc NixDa
Das ist beinahe nicht der Rede wert, jedenfalls weder einen Funktionsruf, noch einen
Betriebssysteminterrupt! Weil aber systemphilosophisch anders entschieden wurde, wird unter
UNIX-C/C++ mindestens 100 mal mehr Binärcode, Zeit und sogar Quellcode nötig als unter einem
Assembler-Betriebssystem wie meinem.
Bei anderen Standardfunktionen ist der eingebaute Unsinn nicht ganz so bar zu haben.
Aber es genügt ein Blick in den Quelltext der Headerdateien, um die Vermutung stark zu machen,
dass man mit "höheren" Programmiersprachen Mäuslein auch zum kreissen von Bergen bringen kann.
Jedenfalls ist unbestreitbar, dass z.B. bei fread() nicht etwa der Compiler ausgehend vom
Parameter, der die Quelle bezeichnet, einen spezifizierten Ruf übersetzt. Auch nicht der Linker
löst den Ruf auf. Stattdessen wird stets mit der gleichen Funktionsnummer der
Betriebssysteminterrrupt betätigt, und dahinter wird stets erst zur Laufzeit eine Selektion der
Quelle veranstaltet. Das ist in LINUX die Wahl zwischen etwa 3000 möglichen Gerätetreibern! - Eine
Wahl, die der Programmierer längst getroffen hat, die aber systemphilosophisch bei jedem Ruf
wiederholt werden muss.
PRÄPROZESSOR:
Das GNU-Programm für diesen Zweck heisst "cpp". Es ist nicht nur ein Instrument, das Einfügen
von Programmteilen vorzunehmen, sondern erlaubt auch abhängig von der maschinellen Umwelt das
Entfernen von Programmteilen. Funktionen und Konstanten im Programm können also bedingungsabhängig
ein und aus geblendet werden.
Ausserdem kann man mit dem Präprozessor noch Abkürzungen für beliebige Programmteile bilden, die
man "Makro" nennt. Damit wird der Präprozessor auch zum "Makroprozessor". Das Ersetzen der selbst
gewählten Abkürzungen durch die abgekürzten Programmteile nennt man "Makroexpansion". Dann sind
verallgemeinerte Typen möglich wie bei Templates.
Mit solchen Methoden kann aus einem Programm ein ganz anderes gemacht werden, ohne den C/C++
Quelltext umschreiben zu müssen. Der Präprozessor ist innerhalb der UNIX-Welt das unverzichtbare
Mittel, den Traum von maschinenunabhängigen Programmen zu verwirklichen. Mit C/C++ allein geht's
nämlich nicht.
Neben der Ausführung solcher Anweisungen erfüllt der Präprozessor seine eigentlichen Zwecke,
-> ersetzt alle Kommentare durch einen Zwischenraum,
-> ersetzt alle Wagenrücklauf- und Absatzzeichen durch einen Zwischenraum
-> ersetzt "Trigraphsequenzen" ("Drei-Zeichen-Folgen", die im ASCII-Zeichensatz nicht
definierte Zeichen als Zeichenfolgen darstellen)
-> kürzt alle Folgen von Zwischenräumen auf einen zusammen.
Alle anderen Zwecke erreicht man mit einer nicht allzu grossen Menge von Präprozessoranweisungen,
die alle mit dem Präfix "#" als erstem Zeichen in einer Zeile geschrieben werden müssen.
Ich stelle hier nur die wichtigsten vor.
Weil der Präprozessor mittels "Umgebungsvariablen" auch ein Bild des aktuellen UNIX-Systems und
der Maschine enthält, ist er natürlich nicht stets der gleiche! Man hat sich also mit dem jeweils
gegebenen Präprozessor und Betriebssystem zu beschäftigen. Innerhalb LINUX findet man die
cpp-Dokumentation normalerweise im Verzeichnis /usr/doc/Books
#include"Dateiname"
...führt zum Einfügen einer kompletten Datei anstelle dieser Anweisung. Die Klammerung des
Dateinamens besteht statt der Gänsefüsschen meistens aus den kleiner-grösser-Zeichen, die hier
wegen der HTML-Formatierung nicht benutzbar sind. Diese Klammerung ist gebräuchlich für
den wichtigsten Zweck, nämlich das Einbinden von einer Headerdatei. Das ist eine Datei, die die
Funktionen enthält, die in der Einheit, die den #include-Befehl enthält, aufgerufen werden, aber
nicht enthalten sind. Solche Dateien kann man selber anlegen. Meist geht man aber mit den
Headerdateien in vorhandenen Bibliotheken um - insbesondere in Standard-Bibliotheken für C/C++
In der GNU-Welt gibt es aber jede Menge weitere Bibliotheken...
Liegen eigene Headerdateien nicht auf den Standardsuchpfaden, muss mit kompletter Pfadangabe
definiert werden! Dann spielen Dateisysteme die mit "/" oder "\" trennen auch noch eine Rolle.
Headerdateien sind Quelltexte! Sie können in C/C++ geschriebene Definitionen enthalten, enthalten
aber im Falle von Standardbibliotheken fast nur Deklarationen und Präprozessoranweisungen. Der
Dateiname ist dann üblicherweise mit dem Postfix ".h" versehen.
Man kann am Inhalt solcher Dateien leicht feststellen, welch grossartiger Aufwand getrieben werden
muss, nur um maximal zwei Kommandos in Assemblerprogrammen zu "vereinfachen".
Das mit dem Präprozessor realisierte Konzept der Einfügungen hatte einst den guten Grund, grosse
Programme in einem kleinen Arbeitsspeicher fertigen zu können, der nämlich zur Programmierzeit
auch alle Werkzeuge zur Programmerzeugung enthalten können muss. Man konnte sich wünschen
Kopieren&Einfügen nicht in Handarbeit vollziehen zu müssen und entwickelte zu diesem Zweck den
Präprozessor mit seinen Anweisungen "#include" und "#define". Deshalb wurde auch zunächst kein
Gedanke daran verschwendet, dass damit ein weiterer Apparat für uferlose Schwellungen etabliert
wurde. Ursprünglich und nach wie vor wird nämlich immer eine ganze Headerdatei eingebunden, auch
wenn tatsächlich nur eine Funktion von vielen gerufen wird. Wenn also nicht mit vielen "#if" und
"#define" aufgeräumt wird, wird niemals genutzter Code in ein Programm eingebaut und nicht etwa
nur der Ruf nach einer sonstwo stehenden Funktion weiter gegeben. Deren Verknüpfung mit dem
Programm ist ausserdem Aufgabe des Linkers, der mit Präprozessoranweisungen nicht regierbar ist!
Dass das natürlich genau das Gegenteil dessen bewirkt, was einen Prozedurruf eigentlich
wünschenswert macht, wurde erst spät klar. Es gibt seitdem Bemühungen, Bibliotheken "shared"
(="geteilt" im Sinne von "allen verfügbar") zu gestalten. Natürlich ging das nicht ohne weitere
Regelwerke und Linkerkonzepte! Und es ist nach wie vor keineswegs selbstverständlich, dass
eine Bibliothek eine "shared library" ist. Insbesondere ist der Apparat für das "sharing"
verschieden realisiert, nicht genormt und folglich schlecht dokumentiert, falls vorhanden.
Wer sich für dieses Problem interessiert, kann auf meiner Homepage nachlesen, wie ich es mit
meinem Assembler-Betriebssystem ASMOS gelöst habe. Da ist zu erfahren, worum es eigentlich geht
und wie einfach das Problem tatsächlich zu lösen ist. Linkerkonzepte in UNIX-Systemen verschwenden
dagegen unter Schlimmstfallbedingungen bis zum 1000-fachen an Platz und erfordern sogar in den
Quelltexten mehr Aufwand.
#define dies das
...ersetzt jedes Vorkommen der Zeichenkette "dies" durch die Zeichenkette "das". Was das soll?
Weil "das" ganze Programmteile sein können, kann das wuchtigste Ding mit "dies" in ein Programm
eingesetzt werden, undzwar beliebig oft. "das" sind Makros, deren Namen oft wie Variable erscheinen,
das aber ganz und garnicht sind. Um die völlig andere Natur erkennbar zu machen, haben sich nicht
genormte Schreibweisen eingebürgert - Grossbuchstaben oder auch Präfix "_"
Während die #include-Anweisung also den Inhalt einer Datei einfügt, wird mit Makros
etwas eingefügt, was man in einer #define-Anweisung ausgeschrieben hat, und was an die Stelle von
Schlüsselwörtern tritt, die in derselben #define-Anweisung als gleichbedeutend gegeben werden -
einfach durch hintereinanderschreiben. Deshalb kann man auch Wertzuweisungen an Variable mit
dieser Anweisung erledigen. Damit kommt aber nicht mehr zustande als das, was man mit
Kopieren&Einfügen sogar leichter tun kann, weil man dann nicht noch neue Ausdrücke fehlerfrei
hinschreiben muss.
Wer die Quelltexte zu Linux und den GNU-Programmen liest, wird allerdings mit solchen Anweisungen
derart eingedeckt, dass er nichts mehr durchschaut, wenn er sich nicht durch Dutzende Dateien
gequält hat, um auf die ferne Definition zu stossen. Sie sind aber der Wunderkleber für die
Anpassung von Programmen an konkrete Maschinen wie den PC, den MAC oder die VAX, weil sie
bedingungsabhängig eingefügt werden können. Dazu sind die folgenden Präprozessoranweisungen
gedacht, die jedenfalls klar machen, dass die "höhere" Programmiersprache nicht taugt, wozu sie
taugen soll.
Die bedingte Compilierung von Quelltext wird mittels "#if","#ifdef","#ifndef","#else" und "#endif"
kommandiert.
Eine ganz besondere Bedeutung hat die Klammerung mit den Präprozessoranweisungen:
#if 0 ....irgendwas... #endif
"....irgendwas..." kann ein beliebiges Stück Programm inclusive Kommentar sein, und es wird so
insgesamt zu Kommentar. Man kann auf diese Weise versuchsweise während der Entwicklung von
Programmen Teile des Programms uncompilierbar machen, als wären sie Kommentar. Die in C/C++
gebräuchlichen Kommentarklammern /*.....*/ eignen sich für solche Zwecke nämlich nicht, weil
man sie nicht schachteln darf. Allerdings dürfen zwischen #if 0 und #endif nicht die Zeichen
"'" und "`" stehen! (s. cpp-manual 1.5.3)
PROGRAMMIERUMGEBUNG FÜR ASSEMBLER UND C/C++:
Mit dem Wissen um die richtige Ausdrucksweise beim Programmieren ist es längst nicht getan!
Abgesehen davon, dass Quelltexte mit jedem Editor geschrieben werden können, sind der Umgang
mit Übersetzungsprogrammen und der Test erklärungsbedürftig. Weil es auch da sehr viele
Möglichkeiten gibt, beschränke ich mich auf die Paarung von LINUX und g++/gcc und die
Programmierung mit NASM in dieser Umgebung. Ich gehe ausserdem davon aus, dass einer der
Windowmanager benutzt wird, also z.B. KDE oder wmaker.
Für den schnellen Einstieg stelle ich zunächst Programme vor, die unbedingt nötig sind:
bash, binutils, gcc, g++, glibc, mc, vim
Der Dateimanager mc muss meist ausdrücklich installiert werden, während die übrigen Programme in
einer Installation vorhanden sind, wenn die als Paket formulierten Programmier-Programme
installiert werden. "mc" ist ein einfaches, fehlerfreies Programm, das dennoch wichtige
Operationen erlaubt, die in den sonst unter Windowmanagern benutzten Dateimanagern nicht zu
haben sind. Es ist auch ein Editor eingebaut, der die aktuelle Datei unter dem Cursor
hexadezimal dumpen kann. Diese Datei kann auch ein Binary sein!
Zum Schreiben eines Programms benutzt man aber besser ein Programm wie "ate"(="kate"). Ähnlich
wie im deutlich umfangreicheren "emacs" sind dann synoptische Darstellungen (Teilfester) möglich,
was bei umfangreichen Programmen unverzichtbar ist.
Neben diesen Programmen braucht man noch einige Kenntnisse über die Benutzung von "bash-scripten"
und "bash", die als "shell" die Nahtstelle zwischen OS und Benutzer bildet. Das Programm bash
hat nur eine sichtbare Stelle, allerdings mit überragender Bedeutung - die Kommandoeingabe,
den "Prompt". Dieses Programm verbirgt sich auch hinter dem Menüpunkt "xterm" bzw."run".
In UNIX-artigen Betriebssystemen ist das mehr als ein Befehlsinterpreter für Handbedienung. Man
kann statt Namen auch Dateien zur Eingabe machen, die in einer besonderen Programmiersprache
formuliert sein müssen. Diese "bash-scripte" sind immer dann wichtig, wenn nicht nur ein Programm
gestartet werden soll, sondern mindestens ein Ablauf mit bestimmten Ein- und Ausgabedateien.
Ein bash-script wird mit beliebigem Editor geschrieben und mit einem besonderen Kommando zum
ausführbaren Programm gemacht.
Das Programm bash ist in LINUX der Standard-Kommandointerpreter und (auf englisch) bestens
dokumentiert mittels Kommando:
man bash(1)
oder auch:
info bash
...sowie in HOWTO-Publikationen.
Ich stelle deshalb hier nur das Wesentliche vor, nötig, um Programme übersetzen zu können und
mit anderen Programmen verknüpfbar zu machen, indem dort main() gerufen wird.
Ein bash-script ist eine normal editierbare Datei mit drei Besonderheiten:
Die beiden ersten Zeichen müssen sein:
#!
Danach muss die benutzte shell mit Pfadnamen erscheinen, gefolgt von LF
Die Datei muss zur ausführbaren Programmdatei gemacht worden sein mit dem Befehl am Prompt:
chmod -c 100 Pfadname/Dateiname_des_bash_scripts
(s.man chmod und info chmod für weiteres!)
Ich stelle ein bash-script für den Start von g++ vor:
#!/bin/sh
# Kompilierung eines Quelltextes in "cquelle.c" zum Programm in "CPROGRAMM"
# Meldungen des Compilers werden in die Datei "fehler" geschrieben
# Die Datei "fehler" wird mittels Editor "vi" lesbar gemacht, nachdem "vi" gestartet wurde
# Compiliert wird im Verzeichnis /root
cd /root
2>fehler g++ -g -o2 -fomit-frame-pointer -Wall cquelle.c -o CPROGRAMM
vi fehler
Der Compiler bewertet die Dateiendung *.c ebenso wie *.C als Kennzeichnung einer Datei mit
C++ Quelltext. Die ganze Liste der Optionen für g++ erfährt man jedenfalls mittels Kommando:
man g++
Ich stelle ein weiteres bash-script vor für den Start von NASM:
#!/bin/sh
# Kompilierung eines Quelltextes in "QUELLE" zum Programm in "BINARY"
# Meldungen des Compilers werden in die Datei "fehler" geschrieben
# Die Datei "fehler" wird mittels Editor "vi" lesbar gemacht, nachdem "vi" gestartet wurde
# Das Listing wird in die Datei "LISTING" geschrieben
# Assembliert wird im Verzeichnis /root
cd /root
2>fehler nasm -f bin QUELLE -o BINARY -l LISTING
vi fehler
Die im Beispiel benutzte Option "-f" bewirkt eine Übersetzung des Quelltextes ohne "headerspace"
(="Vorspann"oder"Kopf"). Das erste Kommando im Quelltext steht damit auf Adresse =0 im Binary und
das Programm wird mit einem Sprung dorthin gestartet. Solche "stand-alone"-Programme sind die von
mir veröffentlichten Quelltexte.
Soll ein Programm aber unter einem Betriebssystem wie LINUX laufen, dann muss ein genau
definierter headerspace vorhanden sein, der dem Linking dient. Und natürlich muss auch im Programm
dieser Zweck berücksichtigt werden! Eine genaue Beschreibung solcher Rituale findet man in der
Dokumentation von NASM, auf die ich verweise. Dort sind auch die anderen Optionen beschrieben, die
dann nötig sind.
Die beiden Beispiele zeigen die wichtigsten Sonderzeichen der Interpretersprache, die zum Schreiben
von bash-scripten benutzt werden muss. Dabei dienen die beiden ersten Zeichen der Datei "#!", den
Start der "shell" zu veranlassen, die folgende Kommandos interpretiert - ausser dem Programm bash
können das noch mindestens ein halbes Dutzend andere Programme sein, die jedenfalls mit ihrem
Pfadnamen gekennzeichnet werden müssen.
Das Zeichen "#" dient in weiteren Zeilen nur noch als Präfix von Kommentar im Rest der Zeile.
Das Ende einer Zeile wird grundsätzlich mit dem "LineFeed" (=LF=0x10) gegeben und die Trennung
von Teilen der Kommandos mit dem Zwischenraum (=0x20).
Da ein Kommando mit einem LF abgeschlossen werden muss, kann der Fall vorkommen, dass es länger als
eine Zeile in der Datei ist. Dann kann für den Interpreter der LF unsichtbar gemacht werden mit dem
Zeichen "\" am Zeilenende, das das "Escape"-Zeichen ist. Dieses Zeichen bewirkt auch die
Interpretation anderer Zeichen als Steuerworte der Interpretation. Es sind insbesondere
Formatierungsanweisungen benutzbar, die in C-Standardfunktionen gebräuchlich sind.
Wie oben schon angedeutet, gibt es in UNIX-Systemen "channels" (="Kanäle"), durch die "streams"
(="Ströme") fliessen. bash-scripte gehen im wesentlichen mit solchen Strömen um, die Datenmengen
sind. Dabei sind dann Quellen und Ziele zu definieren. In den beiden Beispielen sind die Quellen
die Übersetzungsprogramme, die mehr als einen Ausgabestrom erzeugen, der allerdings mit Optionen
dirigiert wird. Jedes Programm erzeugt aber auch eine Ausgabe in den Standard-Fehlerkanal, der
die Nummer 2 hat. Der normale Weg dieses Stroms führt auf den Bildschirm, sichtbar unterhalb der
Eingabe. Will man ihn in eine Datei "fliessen" lassen, dann wird das w.o. ausgedrückt mit:
2>Zieldateiname
Der Dokumentation der bash kann man entnehmen, welche weiteren Sonderzeichen noch definiert sind,
um den Ausgangsstrom des einen Programms zum Eingangsstrom des nächsten zu machen, für die
Programme noch "Umgebungsvariable" (="environment") zu definieren, oder Programme sogar als
Kommandos in Überprogrammen zu nutzen, wobei bedingungsabhängig Programmstarts und Arithmetik
mit Umgebungsvariablen innerhalb von Blöcken betrieben werden kann. Man kann in LINUX sehr viele
solcher Interpreterprogramme finden, hat also genügend Beispiele. Wie schon angedeutet, sind
solche bash-scripte der Klebstoff, mit dem Programmpakete gepackt werden und auch die Schere
bei Installationen solcher Pakete. Letztere bash-scripte haben meist den Namen ".install" oder
".configure" und sind die Eingabe für das Programm "make".
Ausgehend von solchen Beispielen kann man lernen, eigene Programme in Strömen fliessen zu lassen.
PRAXIS:
Das wichtigste an einer Programmierumgebung ist natürlich der Programmierer!
Der muss nicht nur fit sein, sondern sich gezielt fit halten, indem er irgendeinen Sport treibt
oder wenigstens zwischendurch spazieren geht. Auch während der Arbeit ist nicht das richtig,
was als "Ergonomie" gepriesen wird. Richtig ist, ständig mit der körperlichen Lage zu spielen,
sich zu fletzen und zu räkeln, und aufzustehen, wenn's reicht.
Ich sage das aus bitterer Erfahrung, denn der Alltag eines Programmierers besteht eben nicht aus
dem Absondern von Machtworten, sondern allfälliger Ohnmacht angesichts von Fehlern! Kämpfe und
Krämpfe sind das physische Resultat.
Der Kern des Problems ist, dass man nicht nur Fehler macht, sondern sich dessen schämt!
Wenn einem im Laufe eines Tages die denkbar dümmste Maschine der Welt ein halbes Dutzend mal
und meistens frech (=schwarzer Bildschirm) mitgeteilt hat, dass man zu blöde ist, dann geht
das an die Ehre. Und tatsächlich bedarf es eines gezielten Trainings von zweckgebundener
Schamlosigkeit, damit man nicht Adrenalin anstaut, was der natürliche Reflex in solchen
Situationen ist. Und Adrenalin wird natürlicherweise mit körperlicher Bewegung abgebaut.
Wer glaubt, auch nur ein Dutzend Programmzeilen fehlerfrei schreiben zu können, macht den
wichtigsten Fehler. Wer glaubt, auf Kommentar in seinen Programmen verzichten zu können,
macht den zweitwichtigsten Fehler.
Fehler werden nicht kraft Glaubens oder mit reservierten Bezeichnern verhindert, sondern damit,
dass man mit möglichst wenig Regeln, möglichst wenig besonderen Ausdrücken und möglichst wenig
Suchvorgängen zu tun hat.
Programmieren ist keine Kunst! Es ist auch keine erfinderische Tätigkeit!
Man braucht weder motorische oder sensorische Fertigkeiten wie bei künstlerischen Treiben, das
letztlich menschliche Hirne programmiert, noch kann man für die erstrebten Zwecke andere und
neue physikalische oder chemische Effekte nutzen, wie bei erfinderischem Treiben.
Programmieren ist eine besondere Sorte Mathematik und anspruchsvoller als jede andere, weil zwar
das kleine Einmaleins bei jedem Schritt im Adressbereich genügt, Abläufe und Querbezüge aber
äusserst kompliziert werden können. Weil die Zahl der Lösungen einer Aufgabe selten =1 ist, ist
der Lösungsweg eher mit einem Spiel zu vergleichen, einem sehr schwierigen, weil auch der
Lösungsweg selbst ein Teil der Lösung ist - er muss kurz ausfallen.
Schach ist wesentlich anspruchsloser, weil man da mit 64 Adressen und 6 verschiedenen Operatoren
für das Versetzen von Figuren zu tun hat, weil der Ablauf eines Spiels beliebig lang sein darf,
und weil es überhaupt nur 1,5 Zwecke für das Vorgehen gibt - Schach oder Schachmatt.
Ich habe schon festgestellt, dass die Zahl der Möglichkeiten für Programme gigantisch gross ist.
Es gibt also nicht nur eine grosse Zahl von erstrebenswerten Zielen, sondern vor allem mal sehr
viel mehr, was man nicht erstreben wird. Anders als bei anderer Mathematik hat man sich aber mit
dem nicht erstrebenswerten genau so gründlich zu befassen. Oft besteht die Hälfte der Programme
nur aus der Prüfung von Grenzfällen oder Zuständen. Damit hat man ingenieurmässig umzugehen, hat
also Testphasen zu veranstalten, in denen unerlaubte Zustände produziert werden, um den Umgang
des Programms damit zu erfahren.
Die Logik der Maschine macht schliesslich eine Konzentration beim Schreiben von Programmen nötig,
die keine Ablenkung durch Dinge neben dieser Logik erlaubt. Also sind nicht nur Musik oder
Gequatsche mit Hausgenossen daneben...
Ärger und Ablenkung können auch durch falsches Werkzeug erzeugt werden.
Das heisst ganz praktisch, dass man zunächst einen Editor (ein Schreibprogramm) braucht, den
man im Halbschlaf bedienen kann. Eine Mausbedienung für Vorgänge, die man häufig braucht, ist
gerade kein Vorteil! Das nämlich zwingt, die Hände von der Tastatur zu nehmen und nicht gerade
im Schlaf eine Marke zu bewegen. Ganz besonders störend sind womöglich nötige Wühlarbeiten in
Stapeln von Fenstern oder Kommandos, die man nicht im Menü findet, sondern fehlerfrei aus dem
Gedächtnis tippen muss - Kennzeichen der UNIX-Welt.
Der Editor sollte unbedingt eine synoptische Darstellung verschiedener Dateiinhalte in einem
Bild ermöglichen und weitest gehende Menuführung haben.
Beim Programmieren von C-Programmen gibt es mindestens eine weitere Datei oder Dokumentation,
die im Blickfeld sein muss. Beim Programmieren mit Assembler ist es meist nur eine Datei, mit
der man arbeitet; die aber muss in verschiedenen Abschnitten zu gleicher Zeit überschaubar sein.
Auch Kopier- und Einfüge-Funktionen müssen einfach bedienbar sein, weil sie sehr häufig
gebraucht werden. Programme schreibt man nämlich nicht wie Briefe runter. Man hat ja im
wesentlichen mit Abläufen zu tun, die auch dann falsch sein können, wenn die nötigen Anweisungen
zwar alle richtig dastehen, die Abfolge aber falsch ist. Oder man hat irgendeine Definition
oder einen Bezug vergessen.
Dann schreibt man natürlich nicht alles nochmal, sondern nutzt die Kopierfunktionen. Auch
Namen sollte man nur einmal schreiben, im weiteren aber als Kopien und nicht durch Tipperei
in den Quelltext einfügen.
Wenn man in Assembler programmiert, hat man ausserdem den Vorteil, dass man viele Abläufe
ebenfalls durch einfaches Kopieren&Einfügen wieder verwenden kann. Man hat dann nur noch
mit Abänderungen (insbesondere natürlich der Namen!) zu tun, nicht aber mit viel Tipperei.
In C-Programmen ist allerdings selten etwas auf diese Weise wieder verwendbar.
Aber auch die Möglichkeit, Buchzeichen zu setzen und Namen zu suchen, ist vorteilhaft. Ebenso
wünschenswert ist, dass der Anlauf zum Öffnen der für die Arbeit wichtigen Dateien schnell
geht.
Ärger und Ablenkung können aber auch durch falsches Vorgehen entstehen.
Weil ich Literatur zum Thema kenne, in der eine breit angelegte konzeptionelle Phase empfohlen
wird, die 40% der gesamten Arbeitszeit am Programm betragen solle, will ich die Dinge zurecht
rücken.
Ich habe nie mehr als ein, zwei Tage damit verschwendet, eine Idee und einen Plan zur Umsetzung
zu erbrüten. Dann kam der erste Schritt!
Aber dieser erste Schritt darf niemals so aussehen, dass man sich hinsetzt und das ganze Programm
erstmal runterschreiben will, um es dann an einem hohen Feiertag erstmals zu übersetzen und sich
am gelungenen Streich zu ergötzen. Der Misserfolg ist garantiert!
Nicht nur die CPU arbeitet schrittig, auch der Programmierer hat schrittig vorzugehen. Und er hat
seine Schritte so zu setzen, wie man sie im sumpfigen Gelände zu setzen hat:
Das Gewicht erst dann auf das bewegte Bein verlagern, wenn es Halt gefunden hat.
Ein Problem in einer konzeptionellen Phase vollständig überschauen zu wollen, ist vollkommen
vermessen, wenn ein Programm im Quelltext den Inhalt einiger Schreibmaschinenseiten übersteigt.
Es ist im übrigen genau diese Erfahrung, die der Ausgangspunkt von Systemphilosophien ist.
Der eine hält das funktionelle Denken hoch, der andere beschwört Objekt-orientierte Kapseln.
Ich sage mit Franz Beckenbauer: "Schaun' mer mal, dann sehn' wir schon...!"
Das heisst ganz konkret:
Man schreibt nicht ein Programm, man schreibt mindestens doppelt soviel, wie man am Ende stehen
lassen kann. Und man sollte an allen Stolperstellen Kommentar absondern, damit man nicht vergisst,
was man nicht gleich sehen konnte.
Die erste Prüfung, die ein Quelltext zu bestehen hat, ist, dass er sich übersetzen lässt.
Das sollte man so häufig wie möglich prüfen, damit die Zahl der Fehlermeldungen beim Durchgang
klein bleibt (und der Ärger). Dabei sollte man den Quelltext zunächst als Kommentar formulieren,
den man schliesslich während der Arbeit fortschreibt und verfeinert. Auf diese Weise erarbeitet
man Abläufe und im gleichen Zuge eine einfach lesbare Beschreibung.
Die zweite Prüfung, die ein Quelltext zu bestehen hat, ist, dass er sich starten und auch wieder
beenden lässt. Und weil das keineswegs selbstverständlich ist, schreibt man Zug um Zug immer nur
das rein, was für einen prüfbaren Effekt nötig ist und macht ihn dann auch prüfbar. Man schreibt
also immer auch Programmzeilen, die nicht zum Programm gehören, sondern der Prüfung dienen!
Dabei kann man garnicht umsichtig und gründlich genug sein, denn das Ziel solcher Prüfungen muss
sein, bestimmte Abschnitte so fehlerfrei zu bekommen, dass man dort nicht mehr nach Fehlern suchen
muss. Dann muss man im günstigen Falle Fehler nur noch im zuletzt geschriebenen Teil suchen.
Andernfalls riskiert man sehr viel umständlichere Einkreisungen des Fehlers. Ist das Programm
gross, wird das Auffinden nämlich zum Glücksspiel. Als ich an meinen Übersetzungsprogrammen
für Assembler schrieb, hatte ich z.B. am Ende nur noch einen rätselhaften Fehler, der allerdings
das ganze Programm untauglich machte. Unter einer Viertelmillion Buchstaben war ein einziger
falsch - eine 3 statt einer 4 in einem Inkrement. Hätte ich den Abschnitt, in dem der Fehler
steckte, genau geprüft, nachdem ich ihn geschrieben hatte, hätte ich das sofort gefunden.
So aber hatte ich tagelang zu grübeln...
Natürlich ist das prüfende Vorgehen abhängig von der Aufgabe, die man sich gestellt hat, und nur
über das muss man sich in einer konzeptionellen Phase den Kopf zerbrechen. Schreibt man einen
Gerätetreiber, ist z.B. der Griff nach Dokumentation die erste Regung in Richtung Ziel. Und bei
der Prüfung hat man auch zu bedenken, dass die Dokumentation falsch, ungenügend oder
missverständlich ist - ich habe es nie anders erlebt. Entwirft man ein Dateisystem, dann hat man
sich zunächst mit dem zu beschäftigen, was bei der Benutzung vorkommen kann oder wünschenswert
wäre. Und erst nach vielen Rekursionen kann man sich endlich mit der Struktur von Dateiattributen
befassen, weil das das Dach ist, das nicht vor dem Haus gebaut werden kann. Will man eine
Formatwandlung für gewisse Dateiformate, dann gibt es meistens ein Beispiel, das man nur noch
abzuwandeln hat. Dann braucht man eine konzeptionelle Phase =0 ...
Und natürlich muss man stets bereit sein, zu verwerfen, was man konzeptionell erbrütet hat!
Es ist also geistige Beweglichkeit, die zum Ziel führt und nicht die Kenntnis von Regeln, die als
Formeln angepriesen werden, tatsächlich aber keine sind, weil andernfalls die nimmermüde verheissenen
"noch besser optimierenden" Compiler längst vorhanden wären.
Ich will das zu einigen Ratschlägen zusammen fassen:
> Als erstes jede Information zum Gegenstand des Programms finden und eventuell in einer eigenen
Zusammenfassung aufarbeiten. Das ist die Problemanalyse.
Sie kann auch von einem Beispiel für ähnliche Aufgaben ausgehen.
Sie kann auch aus ein paar handschriftlichen Notizen vor dem Einschlafen bestehen.
> Als zweites die Aufgabe so zerlegen, dass prüfbare Abschnitte entstehen, die gründlich getestet
werden können. Dabei ist auch die Prüfung zu durchdenken, die alle Eventualitäten erfassen muss.
> Ein Programm ausgehend von umgangssprachlichen Notizen gestalten, die man während der Arbeit
und Prüfung zum lesbaren, aussagekräftigen Kommentar verfeinert. Ohne Kommentar versteht man
auch die eigenen Programme binnen weniger Wochen nicht mehr!
> Funktionierende Versionen des Programms nicht fortschreiben, ohne sie in einer weiteren Datei
gesichert zu haben! Und regelmässig alle wichtigen Dateien auf FD oder CDROM retten, undzwar doppelt
oder dreifach, um Transferfehler reparierbar zu haben. Wer das versäumt, kann viel Arbeit verlieren,
falls sein Betriebssystem neu installiert werden muss. (Mein LINUX musste ich in 7 Jahren mindestens
einmal pro Jahr neu installieren, wobei alle nicht geretteten Dateien verloren gingen...)
> Wenn man einen Fehler nicht finden kann, erstmal ganz was anderes machen. Der Abstand bringt
die dann nötige Übersicht. Böse Fehler stecken nämlich da, wo man sie erstmal nicht sucht.
> Höllische Fehler nicht weiter suchen, sondern das Programm von einer funktionierenden
Zwischenversion ausgehend neu aufbauen und die neuen Schritte noch sorgfältiger prüfen.
Denkfehler findet man nur so.
> Alle Erwartungen prüfen! Ein Programm ist erst dann fertig, wenn jeder Eventualfall getestet
wurde. Meistens muss man diese Eventualfälle künstlich erzeugen, wenn es um irgendwelche
Bereichsgrenzen geht. Das muss sein, wenn man böse Überraschungen vermeiden will!
Dem Einsteiger, der mir bis hierher gefolgt ist, muss ich wohl nicht mehr ausdrücklich sagen,
dass er umso weniger Regeln verletzen kann, je weniger Regeln er beachten muss. Er kann allein
aus dem Umfang der Erläuterungen in diesem Text ermessen, dass der Einstieg leichter über
Assemblerprogramme zu schaffen ist - ein ganzes Drittel des Textes betrifft ja allein die
Abhandlung von C/C++, wo schaumreduziert draufsteht, aber nicht drin ist.
Es ist nur noch hinzu zu fügen, dass auch die Fehlersuche bei Assembler-Programmen
erheblich einfacher zu machen ist, undzwar schon deshalb, weil das Übersetzungsprogramm
so schnell arbeitet, dass man damit jede Zeile auf Richtigkeit prüfen kann - das kostet
jedenfalls in meinen Übersetzungsprogrammen ASMn und ASMat nicht mehr Zeit als ein Absatz.
Die Fehlermeldungen erfolgen dort übrigens anders als in anderen Assemblern nicht durch
Meldungen und einer Zuordnung über Zeilennummern, sondern Schwärzen im Listing ab der
Stelle im Mnemonik, die unverdaulich war. Nur in gewissen Ausnahmefällen wird nicht der
ganze Quelltext übersetzt.
Beide Programme sind ausserdem der beste Beweis dafür, dass man mit Assembler nicht nur
kürzere Binaries bekommt (was niemand bestreitet), sondern dass man bei gleicher
Funktionalität auch weniger zu schreiben hat (was jeder Hochsprachler sofort bestreitet).
Man kann aber mein ASMn mit dem in C geschriebenen NASM vergleichen und feststellen, dass
das stimmt - undzwar selbst dann, wenn man berücksichtigt, dass ich eine Menge Unsinn
(Makros, Linkerheader...) wegoptimiert habe. Ausserdem zeige ich mit dem Zusammenwirken
meiner Programme mit meinem Betriebssystem ASMOS auch, wie einfach man solche Dinge machen
kann. Auch diesbezüglich lohnt sich der Vergleich mit einem unter LINUX laufenden NASM.
Natürlich muss man LINUX oder andere UNIX-artige Betriebssysteme nutzen, wenn man in C/C++
programmieren will. Abgesehen davon, dass der GNU-Compiler und alles nur denkbare Drumherum
praktisch kostenfrei zu haben ist, ist das alles auch auf dem höchsten Stand, der auf dem
Gebiet erreicht wurde! Allerdings hat man viel zu suchen und zu lesen, bevor man
einigermassen weiss, wohin man wie greifen muss...
Will man in Assembler programmieren, ist wichtig, was man erstrebt. Ich habe klar gemacht,
dass man unter den gängigen Betriebssystemen nur sehr umständlich bis garnicht an die
wesentlichen Teile der Maschine ran kommt, und dass man infolgedessen nicht daran vorbei
kommt, mindestens einen Rahmen für den Assemblercode in C/C++ zu schreiben. Will man
aber mit der fast jungfräulichen Maschine umgehen und jede Wahl für eigene Projekte haben,
gibt es meines Wissens nur mein ASMOS als Sprungbrett und Entwicklungsumgebung. Ich hätte
es nicht geschrieben, wenn ich ähnliches gefunden hätte. Es ist ausserdem sehr ausführlich
und auch auf deutsch kommentiert und taugt deshalb jedenfalls als Lehrmaterial und Wühlkiste
für Codeschnipsel und Methodik.
Weitere Besonderheiten, Vorzüge und Einschränkungen erfährt man auf meiner Homepage und
in den Quelltexten...
Dieser Text wird unter den Bedingungen der GPL veröffentlicht und ist geistiges Eigentum
von Rainer Cornelius Friz. Er darf nur ungekürzt, ansonsten aber beliebig verbreitet und
genutzt werden, sofern kein Geld dafür verlangt wird!
Der Text ist HTML-formatiert auf einer einzigen HTML-Seite und kann mit üblichen
Browserfunktionen in irgendein Dateisystem des Lesers übertragen werden.
Dann ist er auch offline lesbar, was wegen der Länge von ca.60 Schreibmaschinenseiten und des
auch neben der Arbeit nützlichen Informationsgehaltes sehr empfehlenswert ist für private Zwecke.
Die Verbreitung erfolgt aber besser mit einem Link auf meine Homepage, weil ich dort
ausserdem noch etwa 10000 Arbeitsstunden in Form von entwanztem Code verschenke.
HOMEPAGE: www.rcfriz.de
EMAIL: in meinen Quellenprogrammen zu finden...
Der kurze Weg zu meiner Homepage mit einem Click hier...