Zahlendarstellung auf dem Computer

Computer arbeiten mit dem binären Zahlensystem, unterscheiden also nur zwischen 0 und 1, den „Binary Digits“, kurz Bits. Allerdings kann ein Prozessor ganz unterschiedliche Dinge aus diesen Zahlen verstehen, eben das, was wir in den Programmiersprachen als primitive Datentypen verstehen (short und int oder float und double oder Zeiger).

Computer arbeiten üblicherweise mit 32 oder 64 Bit breiten Registern, es werden also 32 Bits nebeneinander gestellt und daraus eine Zahl gebildet, genauso wie im Dezimalzahlensystem Ziffer( 0, 1, 2, 3… ) nebeneinander gestellt werden ( 123 ) und damit eine Zahl ergeben. Da der Prozessor auf 32 oder 64 Stellen festgelegt ist, bedeutet das, dass uns - im Gegensatz zur Schulmathematik - irgendwann die Stellen ausgehen. Wer mit Computern rechnet, sollte also auf Besonderheiten gefasst sein.

Integer-Darstellung

Integer-Zahlen können entsprechend ihrer Breite groß werden, ihr maximaler Wert ist 2^(Anzahl der Bit-Stellen), da Computer im binären Zahlensystemrechnen, genauso wie die größte Zahl im Dezimalsystem 10^(Anzahl der Stellen)-1 ist. Wenn wir uns die größte Zahl vorstellen, die 4 Stellen breit ist, so werden wir viermal die größtmögliche Ziffer nehmen: 9'999, das entspricht 10^4-1. Das Häkchen habe ich nach 3 Stellen eingesetzt, bei dezimalen Stellen unterscheiden wir ja in tausender Schritten: Tausend, Million, Milliarde usw. Da ein Computer aber mit Bits arbeitet, gibt es nur Nullen und Einsen. Die größte Ziffer ist die 1 und die größte Zahl, die man sich auf einer Breite von 4 Stellen vorstellen kann ist also die 1111. Bei Bits unterscheidet man üblicherweise nach sogenannten Nibbles (4 Bits am Stück), da ein Nibble zum einen ein halbes Byte ist und einer hexadezimalen Ziffer (man zählt von 0 bis 15) entspricht. Der Wert 1111 (binär) entspricht also dem Wert 15 (dezimal) bzw. der Ziffer F (hexadezimal).

Wie funktioniert es?

Mit der Integerdarstellung können wir wunderbar durchzählen, darum heißen die Zahlen ja auch Integer („zählbar“, ein integer Mensch ist ein Mensch auf den man zählen kann). Bleiben wir bei den 4 Bit, damit das ganze überschaubar bleibt:

Wert Dez 1 Dez 0 Hex Bit 3 Bit 2 Bit 1 Bit 0
0 0 0 0 0 0 0 0
1 0 1 1 0 0 0 1
2 0 2 2 0 0 1 0
3 0 3 3 0 0 1 1
4 0 4 4 0 1 0 0
5 0 5 5 0 1 0 1
6 0 6 6 0 1 1 0
7 0 7 7 0 1 1 1
8 0 8 8 1 0 0 0
9 0 9 9 1 0 0 1
10 1 0 A 1 0 1 0
11 1 1 B 1 0 1 1
12 1 2 C 1 1 0 0
13 1 3 D 1 1 0 1
14 1 4 E 1 1 1 0
15 1 5 F 1 1 1 1

Damit haben wir jede Möglichkeit ausprobiert, wie man 4 Bits kombinieren kann. Für die dezimale Darstellung brauchen wir zwei Spalten mit jeweils einer Ziffer (Dez 1 und Dez 0), beim Sechzehner-Zahlensystem (Hexadezimal) reicht eine Spalte bereits aus. Beim binären Zahlensystem benötigen wir die volle Breite von 4 Stellen um bis 16 zu zählen.

Der Haken

Ende der 1960er bzw. in den frühen 1970er Jahren gab es zum Beispiel den Intel C4004, ein 4 Bit-Prozessor. Was passierte, wenn ein 4 Bit breites Register den Wert 15 speicherte und man nun eins addierte? Das Ergebnis ist

  |1111 | 15
+ |0001 |  1
--|-----|-----
 1|0000 |  0

Wie bei der Addition in der Grundschule findet an der ersten Stelle ein Überlauf statt, wir notieren die 1 an der nächsten linken Ziffer und erhalten wieder einen Überlauf, bis wir ganz rechts ankommen. Nun haben wir die Zahl 10000, die eine Breite von 5 Bits aufweist, aber der Prozessor hat nur eine Registerbreite von 4 Bit. Er schneidet das überstehende Bit damit einfach ab. Auf einem 4 Bit Register ist 15+1 = 0

Nun könnte man denken, dass man nun froh sein kann, dass man keine 4 Bit Computer mehr haben, aber das Problem bleibt schlussendlich doch bestehen:

#include <stdio.h>
 
int main( void )
{
  unsigned char x = 255;
 
  x = x + 1;
 
  printf( "255 + 1 = %d\n", x );
 
  x = 0 - 1;
 
  printf( "0 - 1 = %d\n", x );
 
  return 0;
}

Und erhalten als Ergebnis

$ ./a.out
255 + 1 = 0
0 - 1 = 255

In beiden Fällen werden die linken Bereiche der Zahl einfach abgeschnitten und nur gespeichert, was in den entsprechenden Datentyp passt. Bei einem 'unsigned char' ist das der Bereich von 0 bis 255.

Wie sieht dies aber bei signed char aus?
Nun, hier gibt es nichtmals die Möglichkeit bis 255 zu gehen, hier wird die gleiche binäre Darstellung der Zahlen verwendet, lediglich der Punkt an dem eine Addition bzw. eine Subtraktion merkwürdige Ergebnisse liefert ist verschoben. Schauen wir uns das nochmal auf einer Breite von vier Bit an:

Wert Dez 1 Dez 0 Hex Bit 3 Bit 2 Bit 1 Bit 0
0 0 0 0 0 0 0
+1 + 1 1 0 0 0 1
+2 + 2 2 0 0 1 0
+3 + 3 3 0 0 1 1
+4 + 4 4 0 1 0 0
+5 + 5 5 0 1 0 1
+6 + 6 6 0 1 1 0
+7 + 7 7 0 1 1 1
-8 - 8 8 1 0 0 0
-7 - 7 9 1 0 0 1
-6 - 6 A 1 0 1 0
-5 - 5 B 1 0 1 1
-4 - 4 C 1 1 0 0
-3 - 3 D 1 1 0 1
-2 - 2 E 1 1 1 0
-1 - 1 F 1 1 1 1

Nach der Hälfte der Bitmuster, fällt der Wert einfach ins Negative. Mit dieser Darstellung können wir in jedem Fall sagen, ob eine Zahl negativ ist, in dem wir uns das Bit ganz links ansehen. Ist es '1', so ist die Zahl negativ. Durch diese Darstellung, dass die negativen Zahlen sich wieder dem Bitmuster 0000 annähern, muss im Prozessor nichts geändert werden, dem Prozessor ist nämlich vollkommen egal, ob ihr mit signed oder unsigned Zahlen rechnet:

  |1111 | -1
+ |0001 |  1
--|-----|-----
 1|0000 |  0

Das Ergebnis ist das gleich, die Bits sind identisch, lediglich die Interpretation und die daraus resultierende Darstellung der Bits ist eine andere.

Fazit

Wer mit Integern rechnet, muss darauf aufpassen, dass er sich nicht aus dem Bereich bewegt, für den diese Zahl vorgesehen ist. Viele alte Spiele auf dem Commodore 64 hatten die Angewohnheit Punkte in einer 16 Bit Integer-Variablen zu speichern. Nachdem man viel Spaß mit dem Spiel hatte, erreichte man irgendwann 32767 Punkte und war ganz oben auf der High-Score-Liste. Und als man dann den nächsten Punkt hatte, war mit -32768 Punkten keine Chance mehr wenigstens auf dem letzten Platz der Liste zu landen. Der Programmierer des Spiels ging wohl nicht davon aus, dass jemand so viele Punkte erreichen würde. Für den Spieler konnte das allerdings recht frustrierend sein.

Ein Beispiel aus der Praxis, dass auch recht frustrierend für die Entwickler gewesen sein muss: Die Softwaresteuerung der Ariane 5 wurde von der Ariane 4 übernommen. Allerdings flog diese neue Transportrakete schneller als ihre Vorgängerin und sorgte so für einen Überlauf einer vorzeichenbehafteten 16 Bit Integervariable, die die Geschwindigkeit enthielt. Das Steuersystem stellte also fest, dass die Rakete plötzlich eine negative Geschwindigkeit erreicht hatte und gab Schub. Dies überlastete die Triebwerke, so dass die Rakete automatisch gesprengt wurde und damit einen Schaden von um die 500 Millionen US Dollar für Rakete und Satelliten verursachte.

Fließkommazahlen

Fließkommazahlen sind eine Überlegung, wie man aus dem Dilemma herauskommt, um noch größere Zahlen und vor allem reelle Zahlen (also Zahlen mit Ziffern hinter dem Komma) speichern zu können. Das Ergebnis waren die Fließkommazahlen. In den 1990er Jahren durfte man Fließkommazahl-Berechnungen noch mit einem zusätzlichen Prozessor, der FPU (Floating Point Unit), erledigen, die man zusätzlich zur CPU (Central Processing Unit) kaufen musste. Heute ist dies alles in einer handelsüblichen CPU eingebaut.

Eine 32-Bit Fließkommazahl kann Werte zwischen -10^37 und +10^37 aufnehmen. Damit ließe sich das Problem mit der High-Score doch erledigen. Aber irgendeinen Haken muss die Fließkommazahl ja auch haben, denn schließlich können wir 32 Bit mit einer Integerzahl durchzählen und kommen dabei gerade mal auf Werte zwischen -2*10^10 bis +2*10^10.

Wie funktioniert es?

Wieder sind es 32 Bit, die anders interpretiert werden als bei einer Integerzahl: Eine Fließkommazahl teilt sich auf in Vorzeichen, Exponent und Mantisse. Eine 'unsigned float' kann es so gar nicht geben, weil das Vorzeichen prinzipiell zur Fließkommazahl dazugehört:

Eine Fließkommazahl ist auf Standard-PCs so aufgeteilt:

float (32 Bit) double (64 Bit)
Vorzeichen 1 Bit 1 Bit
Exponent 8 Bit 11 Bit
Mantisse 23 Bit 52 Bit
addiert 32 Bit 64 Bit

Das Vorzeichen ist, denke ich mal, klar. 0 zeigt wie bei den signed Integerzahlen eine positive Zahl an, 1 eine Negative. Schauen wir uns die Mantisse an: Die Mantisse ist eine Integer-Zahl, entsprechend ihrer Breite von 23 bzw. 52 Bit. Mit einer 32 Bit-Float können wir also nicht so weit zählen wie mit einer 32-Bit-Integervariablen. Nun kommt allerdings noch der Exponent hinzu, der beschreibt, wo das Komma in dieser Integerzahl zu liegen hat. Der Exponent beschreibt also, wie oft der Integerwert verdoppelt oder halbiert werden muss, um den Wert der Fließkommazahl zu erhalten.

Machen wir das an einem Beispiel klar: Nehmen wir die Zahl 1.

Beginnen wir mit dem Vorzeichen, 1 ist positiv, damit erhält das Vorzeichen den Wert 0 für Positiv, statt 1 für Negativ.

Float 0 ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?

Nun schauen wir uns den Mantisse an und überlegen wie die 1 als binäre Darstellung aussehen würde: das wäre die 1.

Entsprechend wäre das:

Float 0 ? ? ? ? ? ? ? ? 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1

Nun haben sich die Entwickler überlegt, dass man die 23 Bit der Mantisse besser nutzen kann, wenn man die Zahl so formuliert, dass das erste Bit gar nicht mehr gespeichert werden muss. Jede Integerzahl - außer Null - hat irgendwo ein erstes Bit. Dieses erste Bit kann man auch weglassen, schließlich weiß man ja, dass jede Zahl mit dem ersten Bit beginnt. So hat man ein weiteres Bit für größere Zahlen. Obwohl die Mantisse nur 23 Bit groß ist, speichert sie also eigentlich 24 Bit.

Damit das erste Bit verschwinden kann, muss die Zahl allerdings auf der linken Seite beginnen. Die fettgedruckte 1 ist das Bit, dass in der Fließkommazahl nicht wirklich gespeichert wird, aber wir wissen, sie ist da. Da man es in der Bitdarstellung nicht sieht, nennt man es auch „Hidden Bit“. In Wirklichkeit steht die fettgedruckte 1 also nicht im Speicher.

Float 0 ? ? ? ? ? ? ? ? 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0

Die Zahl wird nun so ausgelegt, dass sie 1,00000000000000000000000 darstellt. Der Exponent muss entsprechend 0 sein. Auch hier wird wieder etwas getrickst: Die Exponent ist 8 Bit breit und läuft entsprechend von 0 bis 255. Die Nulldarstellung ist hier auf die 127 gelegt, so dass der Wert des Exponenten um 127 höher ist, als wirklich gerechnet wird. Wir wollen die Zahl 1 * 2^0 speichern, damit muss der Exponent 0+127 sein:

Float 0 0 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0

So lässt sich der Wert wie folgt ausdrücken:

Wert = (Vorzeichen) * 1,Mantisse * 2 ^ (Exponent - 127).

Der Exponent -128, also die Binärdarstellung von '0000000' im Bereich des Exponenten, steht für besondere Zahlen: dies sind Null, Unendlich (Inf) und NotANumber (NaN). Ist der Exponent und die Mantisse komplett mit Nullen gefüllt, so stellt dies den Wert 0 dar. Unendlich entsteht beispielsweise bei der Multiplikation großer Zahlen oder Division sehr kleiner Zahlen. NotANumber erhält man beispielsweise bei einer Division durch Null.

Nehmen wir an, dass wir die Zahl 13 kodieren wollen: Binär ist das 1101, Vorzeichen positiv, also 0. Wieder verschwindet die erste 1 von 1101 als „Hidden Bit“.

Float 0 ? ? ? ? ? ? ? ? 1 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0

Wäre der Exponent 0, so bedeutet dies 1,101, also 1*2^0 + 1*2^-1 + 0*2^-2 + 1*2^-3, also Dezimal 1+0.5+0+0.125 = 1,625. Damit 1101 vor dem Komma steht, müssen wir die Bits um drei Stellen verschieben, also Exponent + 3. 1,625 * 2^3 = 1,625 * 8 = 13 oder auch 1*2^(0+3) + 1*2^(-1+3) + 0*2^(-2+3) + 1*2^(-3+3), also 1*2^3 + 1*2^2 + 0*2^1 + 1*2^0 = 8+4+0+1 = 13.

Die Nulldarstellung beim Exponenten ist 127, um zur Drei zu kommen, müssen wir als Exponent 130 angeben. Binär ist das 10000010.

Und hier unsere Fließkommazahl:

Float 0 1 0 0 0 0 0 1 0 1 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0

Fertig ist die Floating-Point-Zahl. :-)

Folgendes kleine Programm druckt euch das Bitmuster einer Fließkommazahl aus:

void printfloat( float f )
{
  unsigned int u = *((unsigned int *) &f);
  int i;
 
  /* Vorzeichen */
  printf( "%f = %d|", f, (u & 1 << 31) ? 1 : 0 );
 
  /* Exponent */
  for( i=30; i >= 23; i-- )
    printf( "%d", (u & 1 << i) ? 1 : 0 );
 
  printf( "|" );
 
  /* Mantisse */
  for( i=22; i >= 0; i-- )
    printf( "%d", (u & 1 << i) ? 1 : 0 );
 
  printf( "\n" );
}

Der Haken

Wir wissen nun, dass Fließkommazahlen sich aus einer etwas verschlüsselt dargestellten Integerzahl und einem Exponenten zusammen setzen. Und das soll Werte zwischen -10^37 und +10^37 speichern können?

Probieren wir folgendes aus:

#include <stdio.h>
 
int main( void )
{
  float f = 0.0;
 
  while( f != f+1 )
    f = f + 1;
 
  printf( "%f ist gleich %f: %s\n", f, f+1.0, (f == f+1.0) ? "f = f+1" : "f != f+1" );
 
  return 0;
}

Was passiert hier? f muss doch immer ungleich f+1 sein, das wissen wir doch aus der Mathematik. Wir sind hier aber nicht in der Schulmathematik, sondern wir rechnen mit Computern und Computer interessieren sich überhaupt nicht für Mathematik. Es sieht so aus, aber ein guter Programmierer weiß, dass die Gesetze der Schulmathematik hier nur innerhalb der Begrenztheit eines Computers gelten.

Zum ersten können wir sagen, das ist keine Endlosschleife!

Das Programm wird nämlich gültig beendet und gibt folgenden Text aus:

16777216.000000 ist gleich 16777217.000000: f != f+1

Warum? Die Integerzahl der Mantisse ist nur 24 Bit breit. Wir können also in Einzelschritten solange voranschreiten, bis unsere Integerzahl breiter als 24 Bit wird. Ab da bestimmt der Exponent nicht nur den Wert unserer Zahl, sondern auch unsere Schrittweite, also den Abstand zwischen zwei darstellbaren Zahlen. Solange wir uns im Bereich von 0 und 2^24 aufhalten ist die Schrittweite 1. Wird die Zahl größer als 24 Bit passen nicht mehr alle Bits in die Mantisse und es gehen Bits verloren. Im Gegensatz zur Integerzahl, wo die höherwertigen Bits verloren gehen, gehen bei der Fließkommazahl die niederwertigen Bits verloren. Die Darstellung ist also nicht mehr exakt, sondern nur noch eine Annäherung an die gewünschte Zahl.

2^24 entspricht 16'777'216. Wird die Zahl 25 Bit breit, dann geht das Bit rechts außen verloren. Das hinterste Bit, die 0, ist verloren. Die 1, die den Wert 16'777'216 von 16'777'217 unterscheidet ist das verlorene hinterste Bit. Wird die 16'777'217 in die Fließkommazahl geschrieben, so geht die Eins hinten verloren und es wird nur die Annäherung 16'777'216 gespeichert, die dann wiederum der echten 16'777'216 entspricht. Damit entspricht f = f+1 und das Programm beendet sich.

Nun kann man sich wundern, weshalb printf() in der Lage ist, die 16'777'217 korrekt auszugeben. Hier wird ein wenig getrickst: die 1.0 ist eine Double-Zahl und das Resultat einer Addition von Float und Double ist eine Double-Zahl. Die Double-Zahl hat eine Mantisse von 52 Bit und mit den bisher benötigten 25 Bit noch kein Problem. Dieses Double wird dann von printf() ausgegeben.

Ein 32-Bit-Float hat genauso wie ein 32-Integer 2^32 Möglichkeiten dargestellt zu werden, nicht mehr. Wir sehen, dass diese 32-Bit bei einer 32-Fließkommazahl anders interpretiert wird, was zu anderen Fähigkeiten führt, aber auch zu anderen Problemen.

Fazit

Fließkommazahlen eignen sich gut, um mit realen Zahlen zu rechnen, allerdings werden Zahlen im Computer immer nur angenähert gespeichert. So kann ein Computer die dezimale Zahl 0,1 nur annähern, aber mit einer Fließkommazahl nicht exakt speichern. Wer mit Fließkommazahlen arbeitet, muss leider mit Rundungsfehlern rechnen. In der Informatik beschäftigt sich ein ganzer Zweig nur mit dem Thema, wie man derartigen Rundungsfehler begrenzen kann: die Numerik.

Wer große Zahlen mit kleinen Zahlen addiert, wie hier gerade geschehen (16'777'216 + 1), verliert die Exaktheit seines Ergebnisses.

Ein Beispiel aus der Praxis: Eine amerikanische Patriot-Rakete verfehlte am 25. Februar 1991 im Golfkrieg eine irakische Scud-Rakete. Die Zeitberechnungen wurden mit der Genauigkeit von 0,1 Sekunden durchgeführt. Wie schon geschrieben, lässt sich die Zahl 0,1 nicht in einer Fließkommadarstellung exakt ausdrücken, es entsteht ein Rundungsfehler von etwa 10^-7. Nach 100 Stunden Einsatzbereitschaft der Patriot-Rakete summierte sich dieser Fehler auf etwa 1/3 Sekunde auf. Die Positionsdaten zu einer gewissen Zeit und die interne Zeit der Patriot-Rakete sorgten dafür, dass die Patriot-Rakete mit chirurgischer Präzision den Punkt anflog, an dem die Scudrakete eine Drittelsekunde zuvor gewesen ist. Die Scud-Rakete war mit 1700 km/h innerhalb der Drittelsekunde bereits einen halben Kilometer weiter und tötete 28 Soldaten.

Binary-Coded-Decimals

Diese Darstellung von Zahlen stammt aus der fernen Geschichte der Computer, dennoch können viele Computer sie noch, während die meisten Programmiersprachen diese Darstellung komplett ignorieren. In den Anfängen der Computerzeiten wollte man die Darstellung im Speicher leicht lesbar halten und da man tatsächlich auch häufig noch den Speicher direkt ausgelesen hat, versuchte man Dezimalzahlen binär zu kodieren.

Wie funkioniert es?

Das funktioniert soweit recht einfach, in dem man ein Nibble (4 Bit) nicht für eine hexadezimale Darstellung verwendet, sondern einfach 6 Darstellungsmöglichkeiten ignoriert:

Wert Dez 0 Hex Bit 3 Bit 2 Bit 1 Bit 0
0 0 0 0 0 0 0
1 1 1 0 0 0 1
2 2 2 0 0 1 0
3 3 3 0 0 1 1
4 4 4 0 1 0 0
5 5 5 0 1 0 1
6 6 6 0 1 1 0
7 7 7 0 1 1 1
8 8 8 1 0 0 0
9 9 9 1 0 0 1
1 0 1 0
1 0 1 1
nicht verwendet 1 1 0 0
1 1 0 1
1 1 1 0
1 1 1 1

So ergab es sich, das die Zahl 13 auch wirklich in zwei Nibbles dargestellt wurde, nämlich '0001' und '0011'. Fertig.

Der Haken

Neben der Tatsache, dass der Computer wesentlich mehr Aufwand hat, Berechnungen mit diesen Datentypen zu machen, also langsamer rechnet, ist es auch eine große Speicherverschwendung. Möchte man eine vorzeichenbehaftete Zahl speichern, so hat man in einer 16 Bit Variable nur Platz für drei Stellen. Statt von -32768 bis +32767 kann man nur von -999 bis +999 rechnen.

Fazit

BCDs gerieten nicht umsonst in Vergessenheit. Eine größere Verwendung ist mir nicht bekannt. Sie werden heute bestenfalls noch in moderne Prozessoren integriert um zu alten Prozessoren und dafür geschriebenen Programmen kompatibel zu bleiben.