Bug: Verbund mit Fließkommazahlen

Ein recht interessanter, nicht direkt offensichtlicher Fehler, kam mir vor einiger Zeit unter die Finger. Hierzu ein Stück Code:

class CMyVariant
{
public:
    CMyVariant();
    virtual ~CMyVariant();
 
    // Getter & Setter
 
protected:
    union
    {
        int64_t nTime;
        float fValue;
    };
};
 
int main()
{
    CMyVariant v1;
    ...
    // Variant v1 setzt nTime
    ... 
    CMyVariant v2 = v1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

Auswirkungen

Die Klasse soll einen Variant-Datentypen darstellen, der zum Transport von Werten über Netzwerke und innerhalb von Anwendung benutzt werden kann. Der Wert nTime hält eine Zeit in Millisekunden, dieser sollte die aktuelle Uhrzeit tragen, war jedoch ca. alle 24 Tage um 70 Minuten versetzt. Wie dass?

Analyse

Da es eine Regelmäßigkeit gibt, die scheinbar mit der Zeit zusammenhängt, lassen sich die Werte gut zurückrechnen (in Millisekunden):

  • 70 Minuten = 4200000 Millisekunden = 0x00401640
  • 24 Tage = 2073600000 Millisekunden = 0x7B98A000

Großzügig gerundet sind das 0x00400000 und 0x80000000. 0x80000000 klingt nach einem Überlauf, was macht aber 0x00400000 dabei?

Zeit, die Variantgeschichte genauer unter die Lupe zu nehmen und mit echten Werten zu füttern. Uhrzeiten des Auftretens sind bekannt und lassen sich in Millisekunden zurückrechnen. Also folgte ein kurzer Test mit:

int64_t nTime = 0xC0DE7F812345;
CMyVariant v;
v.SetTime(nTime);
if(v.GetTime()!=nTime)
    Log("Ouch! Difference found.");
1
2
3
4
5

Leider lief es perfekt und die Meldung blieb aus. Nun folgte das Suchen nach allen möglichen Arten der Benutzung dieser Variantklasse. Alle verwendeten definierten Methoden schienen nicht der Übeltäter zu sein, bis das Licht auf ein unscheinbares Kopieren dieses Objektes fiel, dazu ließ sich der vorhandene Testcode schnell umbauen.

int64_t nTime = 0xC0DE7F812345;
CMyVariant v1;
v1.SetTime(nTime);
CMyVariant v2 = v1;
if(v2.GetTime()!=nTime)
    Log("Ouch! Difference found.");
1
2
3
4
5
6

Treffer, die Meldung kommt. Der Zeitwert hatte sich um 0x00400000 erhöht und es geschah um ein 0x80000000-Vielfaches erhöhten/verringerten Wert genauso. Soweit so gut und die Gelegenheit den Kopieroperator zu inspizieren. Interessanterweise wurde keiner definiert/bzw. vergessen und vom Kompiler selbst erstellt.

Der automatisch erzeugte Kopieroperator, kopiert den Objektinhalt bei nativen Datentypen selbst im Verbund/Union, im einfachsten Falle mit Funktionen für diesen Datentyp, in den anderen Speicherblock. Vom dem Verbund wird, in diesem Beispielfall, erst der 64Bit-Ganzzahlwert kopiert dann die 32Bit-Fließkommazahl. Da aber in beiden Fällen nicht einfach Byte für Byte übertragen wird, sondern jeweils eine entsprechende Operation für jeden Datentyp ausgeführt wird, kann hier das Ergebnis verändert werden.

Nach Entnahme des Fließkommadatentyps lief alles einwandfrei, also war er der Grund dieser Probleme. Ein kurzer Check der Fließkommazahl, ergibt des es sich um einen NaN-Wert (keine Zahl) handelt.

NaN-Werte sind unterteilt in SNaN und QNaN für unterbrechende und stille Keine-Zahl-Werte. Sie unterscheiden sich binär nur durch Bit 22 bei 32Bit-Fließkommanzahlen. Dieses wird je nach Implementation gesetzt, so dass es System- und/oder Kompilerabhängig sein kann. Zum Beispiel kompiliert der VC++-Kompiler scheinbar standardmäßig mit /fp:fast und tatsächlich ist das Problem an sich mit /fp:strict verschwunden. Aber es ist an sich keine gute Idee unnötigerweise an den Kompilerparametern zu spielen, zumal sowas wieder schnell vergessen ist. Eine andere überall anwendbare Lösung sollte diese Maßnahme ersetzen.

Beispiel (Quellcode): union.cpp
Download (Quellcode): union.zip

Der Beispielcode zeigt nur durch VC++ kompiliert dieses Problem. Möglicherweise ist er auch mit anderen Übersetzungsprogrammen reproduzierbar, in Abhängigkeit von den Einstellungen dieser.

Verbesserungen

Dem Problem, ist am besten dort zu begegnen, wo der Auslöser dieser Situation sitzt. Der fehlende Kopieroperator. Anstelle des automatisch Generierten, ist der Eigene bei nicht einfachen Kopiervorgängen zu bevorzugen. Hier kann mit Hilfe des Varianttyps entschieden werden, welches Datenfeld kopiert werden soll.

Fazit

  1. Verbund/Union meiden, sofern es geht.
  2. Bei Objekten mit Verbund möglichst immer einen eigenen Kopieroperator verwenden.
  3. Von Datentypenoperationen, die zum Lesen oder Schreiben verwendet werden, keine exakte binäre Kopie erwarten, wenn sie nicht als solche ausgezeichnet sind.