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...