Win64 Delphi 코드는 같은 소스가 Win32에서 멀쩡히 돌아가는 곳에서 실패할 수 있고, HotPDF Delphi PDF 컴포넌트는 최근 강화 패스에서 그런 케이스 다섯을 만났습니다. Power(10, N)이 Single 오버로드에 바인딩되는 것, 낡은 TList.Count를 읽는 while 루프, 2^63으로 올림되는 High(Int64) 경계, FPC의 15자리 부동소수점 텍스트, 컴파일이 멈추는 테스트 assert입니다
Win32만 빌드하고 테스트하면 이들 중 하나도 드러나지 않는데, 그게 정확히 슬며시 들어온 길입니다. 아래 케이스들은 HotPDF의 SVG와 XPS 임포터, 페이지 렌더러, JSON 작업 리더에서 나왔고, 인용된 수치 결과는 Win32와 Win64용으로 빌드한 작은 프로브 프로그램으로 재현했습니다. Delphi 코드베이스를 64비트로 옮기는 중이라면 각각이 grep할 가치가 있습니다
Power(10, 100)은 왜 Win64에서만 오버플로할까?
Win64에서 정수 인수를 받는 System.Math.Power(10, N)은 Single 오버로드로 해석되므로, 결과는 단정도로 계산되고 반환되며 대략 3.4E38 위의 것은 오버플로합니다. Win32에서 같은 호출은 Extended 오버로드에 바인딩되어 80비트 정밀도의 x87 FPU에서 돌기 때문에, Power(10, 100)은 그저 1E100입니다
System.Math는 Extended, Double, Single용 Power를 선언하고, 지수가 정수일 때 Power가 부르는 짝 IntPower 계열도 함께 둡니다. Win64에서 Extended는 Double의 별칭일 뿐이고(SizeOf(Extended) = 8), 정수 인수 둘에는 컴파일러가 Single 버전을 고릅니다. 결정적 단서는 오버플로만이 아니라 정밀도입니다. Win64에서 Power(10, 20)은 1.0000000200408773E20을 반환하는데, 이는 정확히 Single(1E20)입니다. Double 결과라면 1E20으로 찍혔을 겁니다. 시험한 모든 Win64 컴파일러, Delphi 10.3부터 컴파일러 버전 37.0까지에서 같은 바인딩을 봤습니다
그다음 일은 부동소수점 예외 마스크에 달려 있습니다. Delphi 12 이상은 모든 부동소수점 예외를 기본으로 마스크하므로 오버플로는 조용합니다. Power(10, 100)은 +Inf를 돌려주고 Power(10, -100)은 0을 돌려줍니다. Delphi 11 이하는 exOverflow를 마스크하지 않은 채 두므로 같은 호출이 EOverflow를 던집니다. 마스크를 직접 설정하는 애플리케이션과 그런 호스트에 로드되는 DLL은 호스트가 고른 동작을 그대로 받으니, 라이브러리는 어느 결과도 가정할 수 없습니다
uses
System.SysUtils, System.Math;
procedure ShowPowerOverload;
var
N: Integer;
OldMask: TArithmeticExceptionMask;
begin
N := 20;
// Win32는 1E20 출력, Win64는 1.0000000200408773E20 출력(Single 오버로드)
Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));
// Delphi 11이나 엄격한 FP 설정의 호스트가 하는 일 재현
OldMask := GetExceptionMask;
SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
try
N := 100;
Writeln(Power(10, N)); // Win64: EOverflow, Win32: 1E100
finally
SetExceptionMask(OldMask);
end;
end;
테스트 동안 exOverflow와 exInvalidOp의 마스크를 푸는 것이 오래된 컴파일러나 엄격한 호스트가 보는 것을 보는 가장 싼 방법입니다. 기본 설정의 현대 컴파일러에서 이 버그는 크래시하지 않고 무한대와 0을 내놓는데, 그것들은 테스트 로그에서 훨씬 잡기 어렵습니다. 이전 마스크는 finally에서 복구하세요. 마스크는 스레드별 상태이고 테스트 실행의 나머지가 여러분이 남긴 것을 물려받습니다
오버로드는 어떻게 HotPDF SVG와 XPS 임포트에 닿았나
HotPDF의 SVG와 XPS 경로 리더는 하나의 수 스캐너를 공유하고, 그 스캐너는 지수를 읽고 나면 가수를 Power(10, Exponent)로 스케일했습니다. THotPDF.ImportSVGFormXObject(SVG를 PDF에 재사용 가능한 form XObject로 임포트하기 뒤의 진입점)에 넘긴 어떤 SVG든, 그리고 XPS와 OpenXPS의 PDF 변환 중 다뤄진 어떤 경로 지오메트리든, 그래서 1e100이나 5e99 같은 좌표를 그 호출에 먹일 수 있었습니다
v2.770.91은 이미 지수를 100에서 잘랐고 1E300을 넘을 값은 거부했는데, 충분해 보였습니다. 1E100은 약 1.8E308인 Double 한계와는 거리가 멀으니까요. 그래도 Win64에서는 오버플로했습니다. 계산이 애초에 Double에서 일어난 적이 없기 때문입니다. v2.770.155부터 스캐너는 10의 거듭제곱을 직접 만들고, 1e-100 같은 수나 큰 음수 지수를 가진 긴 가수는 0으로 무너지는 대신 실제 값을 읽어 들입니다
유계 지수를 위한 안전한 10의 거듭제곱
지수가 유계라면 가장 안전한 10의 거듭제곱은 Double 곱셈으로 직접 만드는 것입니다. 최대 100번의 곱셈 루프는 주변 텍스트 스캔에 견줘 비용이 들지 않고, 최종 스케일보다 큰 중간값을 결코 만들지 않으며, Win32, Win64, 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;
// Double 범위를 벗어날 결과는 거부
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; // 결코 1E100을 넘지 않음
if Exponent >= 0 then
Scaled := Value * Scale
else
Scaled := Value / Scale; // 나눔: 1E-100은 정확한 Double이 없음
Result := True;
end;
무게를 지는 세부가 셋 있습니다. 범위 검사는 Abs(Exponent) <= 100 대신 비교 둘을 씁니다. Abs(Low(Integer))는 여전히 음수라 그대로 통과해 버리니까요. 음수 지수는 미리 계산한 1E-100을 곱하는 대신 스케일로 나눕니다. 1E-100에는 정확한 Double이 없어 반올림 단계가 하나 더 붙으니까요. 그리고 Log10 사전 검사는 곱셈이 오버플로할 기회를 갖기 전에 Double 범위 밖의 결과를 거부합니다
루프가 포기하는 것은 분명히 알아두세요. 1E22까지의 10의 거듭제곱은 Double에서 정확합니다. 그 너머로는 모든 곱셈이 반올림하고, 100번을 넘긴 뒤의 스케일은 올바르게 반올림된 1E100에서 최하위 자리 몇 단위 떨어진 곳에 앉습니다. 드로잉 좌표에는 보이지 않습니다. 모든 값을 비트 단위로 재현해야 하는 범용 텍스트-더블 변환에는 부족하고, 올바르게 반올림하는 변환 알고리즘이 필요합니다
dcc64가 while 루프에서 낡은 TList.Count를 읽을 때
Win64 컴파일러(dcc64, 컴파일러 버전 37.0)가 while List.Count > Start do 루프에 대해 Count를 다시 읽는 대신 리스트 끝에서 삭제하고 스택 임시와 비교하는 코드를 만드는 걸 봤습니다. 그것을 고친 재작성은 경계가 정의상 정확히 한 번 평가되는 for ... downto 루프였습니다
그 루프는 v2.769.3에 들어왔는데, 이 버전은 렌더러의 transparency-group 코드에게 그룹 안에서 만들어진 soft mask를 2패스 렌더에 걸쳐 살려 뒀다가 나중에 해제하도록 가르쳤습니다. 정리 코드는 타일별 루프 안에서 한두 패스 for 루프 뒤의 finally 블록에 앉아 있었습니다. 형태로 줄이면 전후는 이렇습니다:
// dcc64(컴파일러 버전 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;
// 교체: 경계는 한 번 평가, 상한이 되는 임시 없음
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
Idx: NativeInt; // TList.Count는 Delphi 12부터 NativeInt
begin
for Idx := Masks.Count - 1 downto Start do
begin
TObject(Masks[Idx]).Free;
Masks.Delete(Idx);
end;
end;
생성된 Win64 코드에서 루프 조건의 Count와 본문 안에서 읽는 Count는 하나의 스택 슬롯을 공유했습니다. 조건은 진입 시, 아무것도 쓰기 전에 그 슬롯과 비교했고, Delete 뒤에 그것을 새로 고치는 것도 없었습니다. 그룹이 자기 soft mask를 하나도 만들지 않았을 때 본문은 어차피 돌아 빈 리스트에게 아이템 -1을 청했으니, 64비트 빌드에서 그런 transparency group을 담은 모든 페이지가 EListError로 실패했습니다. 같은 소스의 Win32 코드는 올바랐고, v2.770.1이 루프를 교체했습니다
우리는 이것을 최소 재현으로 줄이지 않았고, DropMasksWhile 같은 작은 독립 루프는 잘 올바르게 컴파일될 수 있습니다. 주위의 try/finally와 중첩 루프가 관계해 보입니다. 이것을 모든 Win64 컴파일러의 알려진 결함이 아니라 한 컴파일러 버전에서 관찰한 코드 생성으로 취급하세요. 실용적 교훈은 근본 원인보다 쌉니다. 본문이 컬렉션을 줄이는 동안 조건이 컬렉션의 개수를 다시 읽는 루프는 고정 경계 for ... downto로 다시 쓸 가치가 있고, 렌더러 변경에는 Win32만이 아니라 Win64 전체 테스트 실행이 필요합니다
최적화된 Win64 빌드만 보여 주는 크래시 찾기
실패는 최적화된 Win64 빌드에서만 재현됐으므로 위치는 IDE 밖의 도구에서 나왔습니다. 작은 프로브 프로그램이 AddVectoredExceptionHandler로 vectored exception handler를 등록하고, 첫 예제에서 RtlCaptureStackBackTrace로 스택을 잡고, 링커가 -GD로 쓰는 상세 map 파일로 반환 주소를 함수 이름으로 번역했습니다. 그 함수를 디스어셈블하니 비교가 루프 본문 안에서만 쓰인 스택 슬롯 [rbp+0x298]을 읽는 것이 드러났죠. 컴파일러를 탓하기 전에 원하는 바로 그 수준의 증거이고, 릴리스 빌드를 스텝으로 따라가는 것보다 시간이 덜 들었습니다
High(Int64)는 왜 Double의 안전한 상한이 아닐까?
Double은 High(Int64)를 표현할 수 없습니다. 9223372036854775807을 Double로 변환하면 정확히 2^63, 가장 큰 Int64보다 하나 위로 반올림됩니다. Win64에서 그 변환은 비교 자체 안에서 일어나므로, D = 2^63일 때 D <= High(Int64)는 True이고 이어지는 Round나 Trunc가 오버플로합니다
Win32는 Power 문제를 가린 것과 같은 이유로 이것도 가립니다. 비교는 64비트 가수를 가진 80비트 Extended 정밀도로 돌고, 거기서는 High(Int64)가 정확하고 2^63이 올바르게 더 크다고 비교됩니다. Win64에는 기댈 더 넓은 타입이 없습니다. 범위 밖 변환도 예쁘지 않습니다. Win64 테스트에서 Round(2^63)은 exInvalidOp가 마스크되든 아니든 Low(Int64), 즉 조용한 부호 반전을 돌려줬습니다. Win32는 마스크됐을 때 같은 값을 돌려주고 마스크를 풀면 EInvalidOp를 던집니다
| 식 | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), 예외 마스크됨(Delphi 12+ 기본) | 1E100 | +Inf |
Power(10, 100), exOverflow 마스크 안 됨 | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp 마스크 안 됨 | EInvalidOp | Low(Int64) |
HotPDF는 문서 작업 값 뒤의 JSON 리더에서 이것을 만났습니다. JSON은 수에 범위 제한을 두지 않고, 옛 직렬화기는 Frac(Value) = 0인 모든 값을 Round로 정수로 바꿨으므로, 완전히 합법적인 1e19는 마스크에 따라 엉뚱한 정수나 예외 중 하나가 됐습니다. v2.770.169부터 정수는 Int64에 들어갈 때만 정수로 쓰이고, 나머지는 부동소수점 텍스트를 유지하며, 정수 getter는 범위 밖 값에 대해 랩된 값 대신 호출자의 기본값을 반환합니다
const
TwoPow63 = 9223372036854775808.0; // 2^63, Double과 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
// 호출자는 NaN과 무한대를 먼저 거부: JSON에는 표기가 없음
if TryDoubleToInt64(Value, R) then
Result := IntToStr(R)
else
begin
{$IFDEF FPC}
Str(Value:24, Result); // FPC Win64 ffGeneral은 15자리에서 멈춤
Result := Trim(Result);
{$ELSE}
Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
end;
end;
상한은 엄격한 <를 단 리터럴 9223372036854775808.0입니다. 그 상수는 2^63으로 Double과 Extended 양쪽에서 정확하므로, 비교는 모든 플랫폼에서 같은 뜻입니다. 하한은 >=를 쓸 수 있습니다. -2^63은 정확히 Low(Int64)이니까요. 단락 평가로 IsNan과 IsInfinite를 먼저 검사하면 NaN과 무한대가 Frac과 비교에 닿지 않는데, 호스트가 마스크를 풀었다면 그것들은 EInvalidOp를 일으킬 수 있습니다
Win64에서 float-텍스트 변환은 실제로 몇 자리를 주나?
셋 중 두 컴파일러에서는 요청한 것보다 적게 줍니다. Free Pascal 3.3.1의 Win64 FloatToStrF(Value, ffGeneral, 17, 0)는 유의숫자 15자리에서 멈추므로, 1/3은 0.333333333333333으로 돌아오고 서로 다른 두 Double 값이 같은 텍스트로 직렬화될 수 있습니다. Str(Value:24, Text) 뒤의 Trim은 과학적 표기법으로 유의숫자 17자리, 같은 값에 대해 3.3333333333333331E-001을 내놓고, 로케일과 무관하게 소수점 구분자로 마침표를 항상 씁니다. FPC의 HotPDF가 빌드 매트릭스의 일부라면 HotPDF Free Pascal과 Lazarus Win64 지원 노트가 나머지 플랫폼 차이를 다룹니다
Delphi는 17자리 요청을 받아들이지만 두 Delphi 타깃은 여전히 출력이 엇갈립니다. FloatToStrF(0.1, ffGeneral, 17, 0)은 Win32에서 0.10000000000000001을, Win64에서 0.1을 줍니다. Win64 RTL은 포맷할 때와 파싱할 때 양쪽에서 최하위 자리 반올림 오류를 만들 수 있으므로, 자리를 더 늘리면 격차는 좁아지지만 모든 Double 비트 패턴이 텍스트 왕복을 살아남는다는 보장은 없습니다. HotPDF 문서는 그런 약속을 하지 않고, 직접 올바르게 반올림하는 포매터와 파서를 배송하지 않는 한 여러분도 하지 말아야 합니다. TFormatSettings.Invariant를 넘기거나 오래된 Delphi 버전에서는 구분자를 직접 바꿔, 독일어나 프랑스어 로케일이 JSON에 쉼표를 쓰지 않게 하세요
Assert.AreEqual은 왜 Win64에서 컴파일을 멈출까?
동적 배열의 Assert.AreEqual(3, Length(Arr))은 Win32로는 컴파일되고 Win64로는 E2532, "Couldn't infer generic type argument from different argument types"로 실패합니다. 동적 배열의 Length가 Win64에서 NativeInt를 반환하기 때문입니다. 한쪽에 Integer 리터럴, 다른 쪽에 64비트 NativeInt가 놓이면 DUnitX의 제네릭 Assert.AreEqual<T>는 단일 T로 못 정하고 빌드가 멈춥니다
TList.Count는 그 속성이 NativeInt가 된 Delphi 12부터 같은 오류를 일으킵니다. Delphi 11은 여전히 Integer로 선언하죠. string의 Length는 양쪽 플랫폼에서 Integer를 반환해 영향 받지 않으니, 오류가 어떤 테스트 유닛에는 나타나고 다른 데는 안 나타나는 이유입니다. 타입 인수를 명시적으로 쓰세요. Assert.AreEqual<NativeInt>(3, Length(Arr))이고, 커밋 전에 테스트 프로젝트를 dcc64로 컴파일하세요. Win32로만 빌드해 온 스위트는 다른 누군가 시도하기 전까지 자기 Win64 빌드가 깨졌다는 걸 말해 주지 않습니다
Delphi 수치 코드의 Win64 포팅 체크리스트
- 정수 인수를 받는
Power(와IntPower(호출을 검색할 것.Double타입 값을 넘기거나 유계 10의 거듭제곱을 직접 만들 것 - 수치 테스트를 적어도 한 번은
SetExceptionMask로exOverflow와exInvalidOp를 빼고 Win32와 Win64 양쪽에서 돌릴 것 Int64상한은< 9223372036854775808.0으로 쓸 것,<= High(Int64)는 결코. 어떤 비교보다 먼저 NaN과 무한대를 거부Frac가 0이라는 이유만으로 파싱된 수를Int64로 변환하지 말 것. JSON 수는 훨씬 클 수 있음- 아이템을 삭제하면서
Count를 다시 읽는while루프는 고정 경계for ... downto루프로 다시 쓸 것 - FPC Win64에서 유의숫자 15자리 이상이 필요하면
Str(Value:24, Text)를 쓸 것 Length와Countassert에는Assert.AreEqual<NativeInt>를 쓰고 커밋 전에 테스트를 dcc64로 컴파일할 것- 파서나 렌더러를 바꾼 뒤에는 회귀 스위트 전체를 Win32와 Win64 둘 다에서 돌릴 것, 하나만이 아니라
여기서 기술한 라이브러리 쪽 수정은 모두 v2.770.169부터 HotPDF에 들어 있으므로, SVG 임포트, XPS 변환, transparency 렌더링, JSON 작업 처리는 이제 Win64에서 Win32와 똑같이 동작합니다. 양쪽 플랫폼용으로 Delphi나 C++Builder에서 PDF 파일을 생성하거나 처리한다면 HotPDF Delphi PDF 컴포넌트 페이지에 다운로드와 전체 기능 목록이 있습니다