Teknisk artikel

Win64-enbart Delphi-buggar hittade vid härdning av HotPDF

Win64 Delphi-kod kan fallera där samma källa kör rent på Win32, och HotPDF Delphi PDF-komponenten råkade ut för fem sådana fall under en nyligen härdningsomgång: Power(10, N) bunden till Single-overloaden, en while-loop som läser en inaktuell TList.Count, en High(Int64)-gräns som avrundas upp till 2^63, 15-siffrig float-text på FPC, och test-asserts som slutar kompilera

Inget av detta dyker upp om du bara bygger och testar Win32, vilket är exakt hur de smög in. Fallen nedan kommer från HotPDF:s SVG- och XPS-importörer, dess sidrenderare och dess JSON-jobbläsare, och de numeriska resultat som citeras återskapades med små sondprogram byggda för Win32 och Win64. Flyttar du en Delphi-kodbas till 64 bitar är vart och ett värt en grep

Varför ger Power(10, 100) överspill bara på Win64?

På Win64 löser System.Math.Power(10, N) med heltalsargument upp sig till Single-overloaden, så resultatet beräknas och returneras i single precision och allt över ungefär 3,4E38 ger överspill. På Win32 binder samma anrop till Extended-overloaden och kör på x87-FPU:n med 80-bitars precision, så Power(10, 100) är helt enkelt 1E100

System.Math deklarerar Power för Extended, Double och Single, plus en matchande IntPower-familj som Power anropar när exponenten är ett helt tal. På Win64 är Extended bara ett alias för Double (SizeOf(Extended) = 8), och för två heltalsargument plockar kompilatorn Single-versionen. Avslöjandet är precisionen, inte bara överspillen: på Win64 returnerar Power(10, 20) 1.0000000200408773E20, vilket är exakt Single(1E20). Ett Double-resultat skulle skrivas som 1E20. Vi såg samma bindning hos varje Win64-kompilator vi prövade, från Delphi 10.3 till kompilatorversion 37.0

Vad som händer sedan beror på flyttalsexceptionmasken. Delphi 12 och senare maskar alla flyttalsexceptioner som standard, så överspillen är tyst: Power(10, 100) returnerar +Inf och Power(10, -100) returnerar 0. Delphi 11 och tidigare lämnar exOverflow omaskad, och samma anrop kastar EOverflow. Applikationer som sätter masken själva, och DLL:er laddade i sådana värdar, får det beteende värden valt, vilket är varför ett bibliotek inte kan anta något av utfallen

HotPDF Win64 numerisk fälla där System.Math Power med heltalsargument binder till Single-overloaden, så att Power av 10 upphöjt till 20 returnerar 1.0000000200408773E20 i stället för 1E20 och Power av 10 upphöjt till 100 ger plus oändlighet när exceptioner är maskade eller EOverflow när de inte är det
precisionsförlusten är avslöjandet: kommer en tiopotens tillbaka med Single-brus påhängt vann fel overload — bygg skalan själv
uses
  System.SysUtils, System.Math;

procedure ShowPowerOverload;
var
  N: Integer;
  OldMask: TArithmeticExceptionMask;
begin
  N := 20;
  // Win32 skriver 1E20; Win64 skriver 1.0000000200408773E20 (Single-overload)
  Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));

  // Återskapa vad Delphi 11, eller en värd med strikta FP-inställningar, gör
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Att avmaska exOverflow och exInvalidOp under en tests varaktighet är det billigaste sättet att se vad en äldre kompilator eller en strikt värd ser. På en modern kompilator med standardinställningar kraschar inte buggen, den framställer oändligheter och nollor, och de är mycket svårare att upptäcka i en testlogg. Återställ föregående mask i finally: masken är per-tråd-tillstånd, och resten av testkörningen ärver vadhelst du lämnar kvar

Hur overloaden nådde HotPDF SVG- och XPS-import

HotPDF:s SVG- och XPS-sökvägsläsare delar en numerisk skanner, och den skannern skalade mantissan med Power(10, Exponent) så snart den läst en exponent. Vals SVG som skickas till THotPDF.ImportSVGFormXObject (ingångspunkten bakom att importera SVG till PDF som återanvändbara form XObjects), och all sökvägsgeometri hanterad under XPS- och OpenXPS-till-PDF-konvertering, kunde därför mata en koordinat som 1e100 eller 5e99 in i det anropet

v2.770.91 hade redan taklagt exponenten vid 100 och avvisat värden som skulle passera 1E300, vilket såg tillräckligt ut: 1E100 är långt ifrån Double-gränsen på omkring 1,8E308. På Win64 gav det ändå överspill, för beräkningen skedde aldrig i Double alls. Sedan v2.770.155 bygger skannern tiopotensen själv, och tal som 1e-100, eller en lång mantissa med en stor negativ exponent, läses som sitt verkliga värde i stället för att kollapsa till 0

En säker tiopotens för avgränsade exponenter

När exponenten är avgränsad är den säkraste tiopotensen en du bygger själv med Double-multiplikation. En loop på högst 100 multiplikationer kostar inget jämte att skanna texten runt den, den framställer aldrig en mellanprodukt större än den slutliga skalan, och den beter sig identiskt på Win32, Win64 och 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;
  // Avvisa resultat som skulle lämna Double-området
  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;       // överstiger aldrig 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // dividera: 1E-100 har ingen exakt Double
  Result := True;
end;

Tre detaljer bär tyngden. Intervallkontrollen använder två jämförelser i stället för Abs(Exponent) <= 100, för Abs(Low(Integer)) är fortfarande negativ och skulle segla rakt igenom. Negativa exponenter dividerar med skalan i stället för att multiplicera med en förberäknad 1E-100, som inte har någon exakt Double och skulle lägga till ytterligare ett avrundningssteg. Och Log10-förkontrollen avvisar resultat utanför Double-området innan multiplikationen har en chans att svämma över

Var tydlig med vad loopen ger upp. Tiopotenser upp till 1E22 är exakta i Double; därutöver avrundar varje multiplikation, och efter 100 av dem sitter skalan några enheter i sista platsen från den korrekt avrundade 1E100. För ritkoordinater är det osynligt. För en generell text-till-double-konvertering som måste återskapa varje värde bit för bit räcker det inte, och du behöver en korrekt avrundad konverteringsalgoritm i stället

När dcc64 läser en inaktuell TList.Count i en while-loop

Vi observerade Win64-kompilatorn (dcc64, kompilatorversion 37.0) framställa kod för en while List.Count > Start do-loop som raderade från listans slut och jämförde mot en stacktemporär i stället för att läsa om Count. Omskrivningen som fixade det var en for ... downto-loop, vars gränser utvärderas exakt en gång per definition

Loope kom i v2.769.3, som lärde renderarens transparency group-kod att hålla soft masks skapade inuti en grupp vid liv över en tvåpassrendering och frigöra dem efteråt. Städningen satt i ett finally-block efter en en- eller tvåpass-for-loop, inuti per tile-loopen. Reducerad till sin form ser före och efter ut så här:

// Formen vi såg felkompilerad av dcc64 (kompilatorversion 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;

// Ersättning: gränserna utvärderas en gång, ingen temporär som blir inaktuell
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count är NativeInt sedan Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

I den genererade Win64-koden delade Count i loopvillkoret och Count-läsningen inuti kroppen en stackplats. Villkoret jämförde mot den platsen vid ingång, innan något skrivit den, och inget uppdaterade den efter Delete. När en grupp inte skapat egna soft masks körde kroppen ändå och bad en tom lista om post -1, så i 64-bitars byggen feller varje sida som innehöll en sådan transparency group med EListError. Win32-koden för samma källa var korrekt, och v2.770.1 bytte ut loopen

HotPDF Win64-kodgenereringsfälla i renderarstädningen: en while-loop som läser om TList.Count delade en stackplats mellan villkoret och kroppen, dcc64 uppdaterade den aldrig efter Delete, tomma transparency groups frigjorde post -1 och kastade EListError, och fixen är en for downto-loop vars gränser utvärderas en gång
den praktiska lärdomen kostar mindre än rotorsaken: fastgränsade downto-loopar kan inte bli inaktuella, och renderararbete är inte klart förrän dcc64 kört sviten

Vi har inte reducerat detta till en minimal reproduktion, och en liten fristående loop som DropMasksWhile kan mycket väl kompileras korrekt; den omgivande try/finally och nästlade loopar verkar spela roll. Behandla det som kodgenerering vi observerade på en kompilatorversion, inte som ett känt fel hos varje Win64-kompilator. Den praktiska lärdomen är billigare än rotorsaken: en loop vars villkor läser om en samlings antal medan kroppen krymper samlingen är värd att skriva om som en fastgränsad for ... downto, och renderarändringar behöver en fullständig Win64-testkörning, inte bara Win32

Lokalisera en krasch som bara ett optimerat Win64-bygge visar

Misslyckandet återskapades bara i det optimerade Win64-bygget, så platsen kom från verktyg utanför IDE:n. Ett litet sondprogram registrerade en vectored exception handler med AddVectoredExceptionHandler, fångade stacken vid första exceptionen med RtlCaptureStackBackTrace, och översatte returadresserna till funktionsnamn med hjälp av den detaljerade map-filen länkaren skriver med -GD. Att disassemblera den funktionen visade sedan att jämförelsen läste en stackplats, [rbp+0x298], som bara någonsin skrevs inuti loopkroppen. Det är den nivån av bevis du vill ha innan du skyller på en kompilator, och det tog mindre tid än att stega igenom ett release-bygge

Varför är High(Int64) ingen säker övre gräns för en Double?

En Double kan inte representera High(Int64): att konvertera 9223372036854775807 till Double avrundar upp till exakt 2^63, ett förbi det största Int64. På Win64 sker den konverteringen inuti själva jämförelsen, så D <= High(Int64) är True för D = 2^63, och den Round eller Trunc som följer ger överspill

Win32 döljer detta av samma skäl det dolde Power-problemet. Jämförelsen körs i 80-bitars Extended-precision med en 64-bitars mantissa, där High(Int64) är exakt och 2^63 korrekt jämförs större. Win64 har ingen bredare typ att falla tillbaka på. Konverteringen utanför området är inte heller vacker: i våra Win64-tester returnerade Round(2^63) Low(Int64), en tyst teckenvändning, oavsett om exInvalidOp var maskad eller inte. Win32 returnerar samma värde när maskad och kastar EInvalidOp när omaskad

HotPDF Int64-gränssfälla: en Double kan inte representera High(Int64), så en Win64-jämförelse konverterar gränsen upp till 2^63, D lika med 2^63 passerar kontrollen och Round returnerar tyst Low(Int64), medan Win32 jämför i 80-bitars Extended där gränsen är exakt och samma jämförelse är False
en konvertering är hela buggen: gränsen avrundas på exakt det värde du utesluter, så skriv taket som en literal med en strikt mindre än
UttryckWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), exceptioner maskade (Delphi 12+-standard)1E100+Inf
Power(10, 100), exOverflow omaskad1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp omaskadEInvalidOpLow(Int64)

HotPDF mötte detta i JSON-läsaren bakom sina dokumentjobbvärden. JSON sätter ingen intervallgräns på tal, och gamla serialiserare gjorde om varje värde med Frac(Value) = 0 till ett heltal med Round, så en helt laglig 1e19 blev antingen ett fel heltal eller en exception, beroende på masken. Sedan v2.770.169 skrivs ett helt tal som ett heltal bara när det ryms i Int64, allt annat behåller sin flyttalstext, och heltalsgettersna returnerar anroparens standardvärde för värden utanför området i stället för ett omslutet värde

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, exakt i Double och 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
  // Anropare avvisar NaN och oändligheter först: JSON har ingen stavning för dem
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral stannar vid 15 siffror
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

Den övre gränsen är literalen 9223372036854775808.0 med en strikt <. Den konstanten är 2^63, exakt i både Double och Extended, så jämförelsen betyder samma sak på varje plattform. Den undre gränsen kan använda >= för -2^63 är exakt Low(Int64). Att testa IsNan och IsInfinite först, med kortslutningsevaluering, håller NaN och oändligheter borta från Frac och jämförelserna, som kan kasta EInvalidOp när värden avmaskat den

Hur många siffror ger float-till-text verkligen på Win64?

Färre än du ber om, hos två av tre kompilatorer. Free Pascal 3.3.1:s FloatToStrF(Value, ffGeneral, 17, 0) på Win64 stannar vid 15 signifikanta siffror, så 1/3 kommer tillbaka som 0.333333333333333 och två olika Double-värden kan serialiseras till identisk text. Str(Value:24, Text) följt av Trim framställer 17 signifikanta siffror i vetenskaplig notation, 3.3333333333333331E-001 för samma värde, och skriver alltid en punkt som decimaltecken oavsett locale. Är HotPDF på FPC en del av din byggmatris täcker HotPDF Free Pascal- och Lazarus Win64-stödsanteckningarna resten av plattformsskillnaderna

Delphi accepterar 17-siffrorsbegäran, men de två Delphi-målen är ändå oense om utdatat: FloatToStrF(0.1, ffGeneral, 17, 0) ger 0.10000000000000001 på Win32 och 0.1 på Win64. Win64-RTL:n kan också införa ett sista-siffer-avrundningsfel både vid formatering och vid tolkning, så fler siffror smalnar av gapet utan att garantera att varje Double-bitmönster överlever en text tur och retur. HotPDF:s dokumentation lovar inget sådant, och din ska inte heller det om du inte skeppar en korrekt avrundad formaterare och tolkare av egen hand. Skicka TFormatSettings.Invariant, eller byt ut separatorn själv på äldre Delphi-versioner, så en tysk eller fransk locale inte skriver ett kommatecken in i JSON

Varför slutar Assert.AreEqual kompilera på Win64?

Assert.AreEqual(3, Length(Arr)) på en dynamisk array kompileras för Win32 och faller för Win64 med E2532, "Couldn't infer generic type argument from different argument types", för Length hos en dynamisk array returnerar NativeInt på Win64. Med en Integer-literal på ena sidan och en 64-bitars NativeInt på andra kan DUnitX generiska Assert.AreEqual<T> inte landa på en enda T, och bygget stannar

TList.Count utlöser samma fel sedan Delphi 12, där egenskapen blev NativeInt; Delphi 11 deklarerar den fortfarande som Integer. Length hos en string returnerar Integer på båda plattformar och påverkas inte, vilket är varför felet dyker upp i vissa testunits och inte i andra. Skriv typargumentet explicit, Assert.AreEqual<NativeInt>(3, Length(Arr)), och kompilera testprojektet med dcc64 innan commit. En svit som bara någonsin byggs för Win32 avslöjar inte att dess Win64-bygge är trasigt förrän någon annan prövar det

Win64-porteringschecklista för Delphi numerisk kod

  • Sök efter Power(- och IntPower(-anrop med heltalsargument; skicka Double-typade värden eller bygg avgränsade tiopotenser själv
  • Kör numeriska tester minst en gång med exOverflow och exInvalidOp borttagna genom SetExceptionMask, på både Win32 och Win64
  • Skriv Int64-övre gränsen som < 9223372036854775808.0, aldrig <= High(Int64), och avvisa NaN och oändligheter före varje jämförelse
  • Konvertera inte ett tolkat tal till Int64 bara för att Frac är 0; JSON-tal kan vara mycket större
  • Skriv om while-loopar som läser om Count medan poster raderas till fastgränsade for ... downto-loopar
  • På FPC Win64, använd Str(Value:24, Text) när du behöver fler än 15 signifikanta siffror
  • Använd Assert.AreEqual<NativeInt> för Length- och Count-asserts, och kompilera tester med dcc64 innan commit
  • Efter varje ändring av en parser eller renderare, kör hela regressionssviten på Win32 och Win64, inte bara en av dem

Fixarna på bibliotekssidan som beskrivs här finns alla i HotPDF sedan v2.770.169, så SVG-import, XPS-konvertering, transparensrendering och JSON-jobbhantering beter sig nu likadant på Win64 som på Win32. Genererar eller processerar du PDF-filer från Delphi eller C++Builder för båda plattformarna har HotPDF Delphi PDF component-sidan nedladdningarna och den fullständiga funktionslistan