Win64-Delphi-Code kann an Stellen scheitern, an denen dieselbe Quelle auf Win32 sauber läuft, und die HotPDF Delphi PDF component ist während einer kürzlichen Härtungsrunde auf fünf solcher Fälle gestoßen: Power(10, N), das an den Single-Overload bindet, eine while-Schleife, die ein veraltetes TList.Count liest, eine High(Int64)-Grenze, die auf 2^63 aufrundet, 15-stelliger Float-Text auf FPC und Test-Asserts, die aufhören zu kompilieren
Keines davon zeigt sich, wenn Sie nur Win32 bauen und testen — genau deshalb sind sie hereingerutscht. Die Fälle unten kommen aus HotPDFs SVG- und XPS-Importern, seinem Seitenrenderer und seinem JSON-Job-Reader, und die zitierten Zahlenergebnisse wurden mit kleinen Probe-Programmen für Win32 und Win64 reproduziert. Wenn Sie eine Delphi-Codebasis auf 64 Bit umziehen, ist jeder Fall ein grep wert
Warum läuft Power(10, 100) nur auf Win64 über?
Auf Win64 löst sich System.Math.Power(10, N) mit Integer-Argumenten auf den Single-Overload auf, das Ergebnis wird also in Single-Präzision berechnet und zurückgegeben, und alles über grob 3,4E38 läuft über. Auf Win32 bindet derselbe Aufruf an den Extended-Overload und läuft auf der x87-FPU mit 80-Bit-Präzision, Power(10, 100) ist also schlicht 1E100
System.Math deklariert Power für Extended, Double und Single, dazu eine passende IntPower-Familie, die Power aufruft, wenn der Exponent eine ganze Zahl ist. Auf Win64 ist Extended nur ein Alias für Double (SizeOf(Extended) = 8), und bei zwei Integer-Argumenten wählt der Compiler die Single-Version. Der Hinweisgeber ist die Präzision, nicht nur der Überlauf: Auf Win64 liefert Power(10, 20) 1.0000000200408773E20 zurück, was exakt Single(1E20) ist. Ein Double-Ergebnis würde als 1E20 drucken. Wir haben dieselbe Bindung mit jedem Win64-Compiler gesehen, den wir versucht haben, von Delphi 10.3 bis Compiler-Version 37.0
Was danach passiert, hängt von der Gleitkomma-Exception-Maske ab. Delphi 12 und später maskieren alle Gleitkomma-Exceptions per Default, der Überlauf ist also still: Power(10, 100) liefert +Inf, und Power(10, -100) liefert 0. Delphi 11 und früher lassen exOverflow unmaskiert, derselbe Aufruf wirft also EOverflow. Anwendungen, die die Maske selbst setzen, und DLLs, die in solche Hosts geladen werden, bekommen das Verhalten, das der Host gewählt hat — eine Bibliothek kann deshalb keines der beiden Ergebnisse annehmen
uses
System.SysUtils, System.Math;
procedure ShowPowerOverload;
var
N: Integer;
OldMask: TArithmeticExceptionMask;
begin
N := 20;
// Win32 druckt 1E20; Win64 druckt 1.0000000200408773E20 (Single-Overload)
Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));
// Reproduziert, was Delphi 11 oder ein Host mit strengen FP-Einstellungen tut
OldMask := GetExceptionMask;
SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
try
N := 100;
Writeln(Power(10, N)); // Win64: EOverflow; Win32: 1E100
finally
SetExceptionMask(OldMask);
end;
end;
exOverflow und exInvalidOp für die Dauer eines Tests zu demaskieren ist der billigste Weg zu sehen, was ein älterer Compiler oder ein strenger Host sieht. Auf einem modernen Compiler mit Default-Einstellungen stürzt der Bug nicht, er produziert Unendlichkeiten und Nullen, und die sind in einem Testlog viel schwerer zu entdecken. Stellen Sie die vorherige Maske in finally wieder her: Die Maske ist Thread-lokaler Zustand, und der Rest des Testlaufs erbt, was Sie zurücklassen
Wie der Overload in HotPDFs SVG- und XPS-Import gelangte
HotPDFs SVG- und XPS-Pfadleser teilen sich einen Zahlen-Scanner, und dieser Scanner skalierte die Mantisse mit Power(10, Exponent), sobald er einen Exponenten gelesen hatte. Jedes SVG, das an THotPDF.ImportSVGFormXObject geht (der Einstiegspunkt hinter SVG als wiederverwendbare Form XObjects in PDF importieren), und jede Pfadgeometrie, die während XPS- und OpenXPS-zu-PDF-Konversion behandelt wird, konnte also eine Koordinate wie 1e100 oder 5e99 in diesen Aufruf speisen
v2.770.91 hatte den Exponenten bereits auf 100 begrenzt und Werte abgewiesen, die über 1E300 hinausgehen würden, was nach genug aussah: 1E100 ist meilenweit von der Double-Grenze von etwa 1,8E308 entfernt. Auf Win64 lief es trotzdem über, denn die Berechnung geschah nie in Double. Seit v2.770.155 baut der Scanner die Zehnerpotenz selbst, und Zahlen wie 1e-100 oder eine lange Mantisse mit einem großen negativen Exponenten lesen sich als ihr echter Wert, statt auf 0 zu kollabieren
Eine sichere Zehnerpotenz für begrenzte Exponenten
Ist der Exponent begrenzt, ist die sicherste Zehnerpotenz eine, die Sie selbst mit Double-Multiplikation bauen. Eine Schleife von höchstens 100 Multiplikationen kostet nichts neben dem Scannen des Texts drumherum, sie produziert nie ein Zwischenergebnis größer als der finale Skalierungsfaktor, und sie verhält sich identisch auf Win32, Win64 und Free Pascal
const
MaxDecimalExponent = 100;
function TryScaleByPowerOf10(const Value: Double; Exponent: Integer;
out Scaled: Double): Boolean;
var
Scale: Double;
I: Integer;
begin
Scaled := 0;
Result := False;
if (Exponent < -MaxDecimalExponent) or (Exponent > MaxDecimalExponent) then
Exit;
// Ergebnisse abweisen, die die Double-Range verlassen würden
if (Exponent > 0) and (Value <> 0) and
(Log10(Abs(Value)) + Exponent > 300) then
Exit;
Scale := 1.0;
for I := 1 to Abs(Exponent) do
Scale := Scale * 10.0; // übersteigt nie 1E100
if Exponent >= 0 then
Scaled := Value * Scale
else
Scaled := Value / Scale; // dividieren: 1E-100 hat kein exaktes Double
Result := True;
end;
Drei Details tragen das Gewicht. Der Range-Check nutzt zwei Vergleiche statt Abs(Exponent) <= 100, denn Abs(Low(Integer)) ist weiterhin negativ und würde glatt durchsegeln. Negative Exponenten dividieren durch den Skalierungsfaktor, statt mit einer vorberechneten 1E-100 zu multiplizieren, die kein exaktes Double hat und einen Rundungsschritt mehr einfügen würde. Und der Log10-Vorabcheck weist Ergebnisse außerhalb der Double-Range ab, bevor die Multiplikation Gelegenheit hat zu überlaufen
Seien Sie klar darüber, was die Schleife hergibt. Zehnerpotenzen bis 1E22 sind in Double exakt; danach rundet jede Multiplikation, und nach 100 davon sitzt der Skalierungsfaktor ein paar Units in der letzten Stelle neben dem korrekt gerundeten 1E100. Für Zeichenkoordinaten ist das unsichtbar. Für eine Allzweck-Text-zu-Double-Konversion, die jeden Wert bit für bit reproduzieren muss, reicht es nicht, da brauchen Sie einen korrekt rundenden Konversionsalgorithmus
Wenn dcc64 ein veraltetes TList.Count in einer while-Schleife liest
Wir haben beobachtet, dass der Win64-Compiler (dcc64, Compiler-Version 37.0) Code für eine while List.Count > Start do-Schleife erzeugte, die vom Ende der Liste löschte und gegen ein Stack-Temporary verglich, statt Count neu zu lesen. Die Umschreibung, die es fixte, war eine for ... downto-Schleife, deren Grenzen per Definition genau einmal ausgewertet werden
Die Schleife kam in v2.769.3, das dem Transparenzgruppen-Code des Renderers beibrachte, in einer Gruppe erzeugte Soft Masks über ein zweiphasiges Render hinweg am Leben zu halten und sie danach freizugeben. Der Aufräumdienst saß in einem finally-Block nach einer ein- oder zweiphasigen for-Schleife, innerhalb der Kachel-Schleife. Auf ihre Form reduziert, sehen vorher und nachher so aus:
// Die Form, die wir von dcc64 fehlkompiliert sahen (Compiler-Version 37.0)
procedure DropMasksWhile(Masks: TList; Start: Integer);
begin
while Masks.Count > Start do
begin
TObject(Masks[Masks.Count - 1]).Free;
Masks.Delete(Masks.Count - 1);
end;
end;
// Ersatz: die Grenzen werden einmal ausgewertet, kein Temporary, das veralten kann
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
Idx: NativeInt; // TList.Count ist NativeInt seit Delphi 12
begin
for Idx := Masks.Count - 1 downto Start do
begin
TObject(Masks[Idx]).Free;
Masks.Delete(Idx);
end;
end;
Im erzeugten Win64-Code teilten sich das Count in der Schleifenbedingung und das im Körper gelesene Count einen Stack-Slot. Die Bedingung verglich beim Eintritt gegen diesen Slot, bevor irgendetwas ihn geschrieben hatte, und nichts frischte ihn nach Delete auf. Als eine Gruppe keine eigenen Soft Masks erzeugt hatte, lief der Körper trotzdem und fragte eine leere Liste nach Item -1, jeder Seite mit so einer Transparenzgruppe scheiterte in 64-Bit-Builds also mit EListError. Der Win32-Code für dieselbe Quelle war korrekt, und v2.770.1 ersetzte die Schleife
Wir haben das nicht auf eine minimale Reproduktion reduziert, und eine kleine eigenständige Schleife wie DropMasksWhile kompiliert vermutlich korrekt; das umgebende try/finally und die verschachtelten Schleifen scheinen zu zählen. Behandeln Sie es als Codegenerierung, die wir auf einer Compiler-Version beobachtet haben, nicht als bekannten Defekt jedes Win64-Compilers. Die praktische Lektion ist billiger als die Ursache: Eine Schleife, deren Bedingung den Count einer Collection erneut liest, während der Körper sie schrumpft, verdient eine Umschreibung als for ... downto mit fester Grenze, und Renderer-Änderungen brauchen einen vollen Win64-Testlauf, nicht nur Win32
Einen Absturz orten, den nur ein optimierter Win64-Build zeigt
Der Ausfall reproduzierte nur im optimierten Win64-Build, der Ort kam also aus Tools außerhalb der IDE. Ein kleines Probe-Programm registrierte einen Vectored Exception Handler über AddVectoredExceptionHandler, fing den Stack bei der ersten Exception mit RtlCaptureStackBackTrace ein und übersetzte die Rückgabeadressen über die detaillierte Map-Datei, die der Linker mit -GD schreibt, in Funktionsnamen. Das Disassemblieren dieser Funktion zeigte dann den Vergleich, der einen Stack-Slot las, [rbp+0x298], der nur je im Schleifenkörper geschrieben wurde. Das ist die Beweisebene, die Sie wollen, bevor Sie einem Compiler die Schuld geben, und es kostete weniger Zeit als das Durchsteppen eines Release-Builds
Warum ist High(Int64) keine sichere Obergrenze für ein Double?
Ein Double kann High(Int64) nicht darstellen: 9223372036854775807 nach Double zu konvertieren rundet auf exakt 2^63 auf, einen hinter dem größten Int64. Auf Win64 passiert diese Konversion innerhalb des Vergleichs selbst, D <= High(Int64) ist für D = 2^63 also True, und das folgende Round oder Trunc läuft über
Win32 versteckt das aus demselben Grund, aus dem es das Power-Problem versteckte. Der Vergleich läuft in 80-Bit-Extended-Präzision mit 64-Bit-Mantisse, wo High(Int64) exakt ist und 2^63 korrekt als größer verglichen wird. Win64 hat keinen breiteren Typ als Rückfall. Die Konversion außerhalb des Bereichs ist auch nicht hübsch: In unseren Win64-Tests lieferte Round(2^63) Low(Int64) zurück, eine stille Vorzeichenumdrehung, ob exInvalidOp maskiert war oder nicht. Win32 liefert denselben Wert bei Maskierung und wirft EInvalidOp bei Demaskierung
| Ausdruck | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), Exceptions maskiert (Delphi 12+-Default) | 1E100 | +Inf |
Power(10, 100), exOverflow unmaskiert | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp unmaskiert | EInvalidOp | Low(Int64) |
HotPDF ist dem im JSON-Reader hinter seinen Dokumentjob-Werten begegnet. JSON setzt keine Range-Grenze für Zahlen, und der alte Serialisierer machte aus jedem Wert mit Frac(Value) = 0 über Round einen Integer, aus einem völlig legalen 1e19 wurde also entweder ein falscher Integer oder eine Exception, je nach Maske. Seit v2.770.169 wird eine ganze Zahl nur dann als Integer geschrieben, wenn sie in Int64 passt, alles andere behält seinen Gleitkommatext, und die Integer-Getter liefern den Default des Aufrufers für Werte außerhalb des Bereichs statt eines gewrappten
const
TwoPow63 = 9223372036854775808.0; // 2^63, exakt in Double und Extended
function TryDoubleToInt64(const Value: Double; out R: Int64): Boolean;
begin
R := 0;
Result := not IsNan(Value) and not IsInfinite(Value) and
(Frac(Value) = 0) and (Value >= -TwoPow63) and (Value < TwoPow63);
if Result then
R := Trunc(Value);
end;
function JsonNumberText(const Value: Double): string;
var
R: Int64;
begin
// Aufrufer weisen NaN und Unendlichkeiten zuerst ab: JSON hat keine Schreibweise dafür
if TryDoubleToInt64(Value, R) then
Result := IntToStr(R)
else
begin
{$IFDEF FPC}
Str(Value:24, Result); // FPC Win64 ffGeneral stoppt bei 15 Ziffern
Result := Trim(Result);
{$ELSE}
Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
end;
end;
Die Obergrenze ist das Literal 9223372036854775808.0 mit einem strikten <. Diese Konstante ist 2^63, exakt in Double wie Extended, der Vergleich bedeutet also auf jeder Plattform dasselbe. Die Untergrenze darf >= nutzen, denn -2^63 ist exakt Low(Int64). IsNan und IsInfinite zuerst zu testen, mit Short-Circuit-Auswertung, hält NaN und Unendlichkeiten von Frac und den Vergleichen fern, die EInvalidOp werfen können, wenn der Host sie demaskiert hat
Wie viele Ziffern gibt Float-zu-Text auf Win64 wirklich her?
Weniger als angefragt, auf zwei von drei Compilern. Free Pascal 3.3.1s FloatToStrF(Value, ffGeneral, 17, 0) stoppt auf Win64 bei 15 signifikanten Ziffern, 1/3 kommt also als 0.333333333333333 zurück, und zwei unterschiedliche Double-Werte können zu identischem Text serialisieren. Str(Value:24, Text) gefolgt von Trim produziert 17 signifikante Ziffern in wissenschaftlicher Notation, 3.3333333333333331E-001 für denselben Wert, und schreibt stets einen Punkt als Dezimaltrenner, egal welche Locale. Wenn HotPDF auf FPC Teil Ihrer Build-Matrix ist, behandeln die HotPDF Free Pascal und Lazarus Win64 Support-Hinweise den Rest der Plattformunterschiede
Delphi akzeptiert die 17-Ziffern-Anfrage, aber die beiden Delphi-Ziele sind sich bei der Ausgabe weiterhin uneinig: FloatToStrF(0.1, ffGeneral, 17, 0) liefert 0.10000000000000001 auf Win32 und 0.1 auf Win64. Die Win64-RTL kann zudem einen Rundungsfehler in der letzten Ziffer einführen, sowohl beim Formatieren als auch beim Parsen, mehr Ziffern verengen die Lücke also, ohne zu garantieren, dass jedes Double-Bitmuster einen Text-Roundtrip überlebt. HotPDFs Dokumentation verspricht das nicht, und Ihre sollte es auch nicht, es sei denn, Sie liefern einen korrekt rundenden Formatter und Parser aus eigener Fertigung. Übergeben Sie TFormatSettings.Invariant, oder ersetzen Sie den Trenner selbst auf älteren Delphi-Versionen, damit eine deutsche oder französische Locale kein Komma in JSON schreibt
Warum hört Assert.AreEqual auf Win64 auf zu kompilieren?
Assert.AreEqual(3, Length(Arr)) auf einem dynamischen Array kompiliert für Win32 und scheitert für Win64 mit E2532, „Couldn't infer generic type argument from different argument types“, denn Length eines dynamischen Arrays liefert auf Win64 NativeInt zurück. Mit einem Integer-Literal auf der einen und einem 64-Bit-NativeInt auf der anderen Seite kann DUnitXs generisches Assert.AreEqual<T> sich nicht auf ein einziges T einigen, und der Build stoppt
TList.Count löst denselben Fehler seit Delphi 12 aus, wo die Property zu NativeInt wurde; Delphi 11 deklariert sie weiterhin als Integer. Length eines string liefert auf beiden Plattformen Integer und ist nicht betroffen, der Fehler taucht also in manchen Test-Units auf und in anderen nicht. Schreiben Sie das Typargument explizit, Assert.AreEqual<NativeInt>(3, Length(Arr)), und kompilieren Sie das Testprojekt mit dcc64, bevor Sie committen. Eine Suite, die nur je für Win32 baut, verrät Ihnen nicht, dass ihr Win64-Build kaputt ist, bis es jemand anders versucht
Win64-Portierungs-Checkliste für Delphi-Zahlenkode
- Nach
Power(- undIntPower(-Aufrufen mit Integer-Argumenten suchen;Double-typisierte Werte übergeben oder begrenzte Zehnerpotenzen selbst bauen - Zahlentests mindestens einmal mit
exOverflowundexInvalidOp, entfernt überSetExceptionMask, fahren, auf Win32 wie Win64 - Die
Int64-Obergrenze als< 9223372036854775808.0schreiben, nie<= High(Int64), und NaN und Unendlichkeiten vor jedem Vergleich abweisen - Eine geparste Zahl nicht deshalb nach
Int64konvertieren, weilFrac0 ist; JSON-Zahlen können weit größer sein while-Schleifen, dieCounterneut lesen, während Items gelöscht werden, alsfor ... downto-Schleifen mit fester Grenze umschreiben- Auf FPC Win64
Str(Value:24, Text)nutzen, wenn Sie mehr als 15 signifikante Ziffern brauchen Assert.AreEqual<NativeInt>fürLength- undCount-Asserts nutzen und Tests mit dcc64 kompilieren, bevor Sie committen- Nach jeder Änderung an einem Parser oder Renderer die volle Regressionssuite auf Win32 und Win64 fahren, nicht nur auf einem von beiden
Die bibliotheksseitigen Fixes, die hier beschrieben werden, sind alle seit v2.770.169 in HotPDF, SVG-Import, XPS-Konversion, Transparenz-Rendering und JSON-Job-Behandlung verhalten sich auf Win64 also genauso wie auf Win32. Wenn Sie PDF-Dateien aus Delphi oder C++Builder für beide Plattformen erzeugen oder verarbeiten, hat die HotPDF Delphi PDF component-Seite die Downloads und die vollständige Feature-Liste