기술 문서

Delphi Linux64 LP64 함정: Longint, 포인터, WIN64

Delphi Linux64는 LP64 데이터 모델을 따르므로 Longint와 LongWord가 이 플랫폼에서는 8바이트다. 반면 Windows용 Delphi(Win32와 Win64)와 모든 타깃의 Free Pascal은 4바이트를 유지한다. Delphi용 PDF 라이브러리인 PDFlibPas를 dcclinux64로 포팅하면 이 차이를 세 가지 서로 다른 모양으로 마주치게 되고, 여기에 포인터 폭 함정이 하나 더 얹힌다. 포인터 크기 정수를 고르려고 쓰던 {$IFDEF WIN64} 검사는 Windows 외의 모든 64비트 타깃에서 조용히 엉뚱한 폭을 골라 버린다

이 함정들 가운데 특이한 것은 하나도 없다. 하나같이 Windows가 우연히 안전하게 만들어 준 습관이다. RTL이 Longint라고 쓴 자리에 Integer를 쓰거나, C 구조체를 Longint 필드로 옮겨 적거나, 진짜 질문이 '포인터가 8바이트인가'일 때 '이거 Win64인가'라고 묻는 것 따위가 그렇다. Win32, Win64, FPC에서는 두 표기가 같은 뜻이므로 코드가 이런 습관을 여러 해 동안 아무 탈 없이 끌고 다닐 수 있다. Delphi Linux64에서는 두 표기가 같은 뜻이 아니게 되고, 제일 먼저 눈치채는 쪽은 컴파일러다

이하 내용은 전부 컴파일러가 잡아 주는 문제다. PDFlibrary.pas는 하나의 소스트리에서 dcc32, dcc64, dcclinux64로 빌드되며, 여기서 다루는 것은 Windows 전용 습관과 세 번째 타깃 사이를 가로막는 문제들이다

Delphi가 Linux64를 타깃으로 삼으면 무엇이 바뀌는가?

Delphi Linux64에서 폭이 바뀌는 정수 타입은 정확히 Longint와 LongWord 둘이다. Integer와 Cardinal은 모든 Delphi 플랫폼에서 4바이트를 유지하고, NativeInt는 포인터를 따라간다. Windows는 LLP64라서 64비트에서 커지는 것은 포인터뿐이다. Delphi의 64비트 POSIX 타깃(Linux64, 그리고 macOS와 iOS 64비트 컴파일러)은 LP64라서 C의 long까지 커지며, Embarcadero는 Pascal의 Longint가 이를 따르도록 만들었다

타입Delphi Win32Delphi Win64Delphi Linux64Free Pascal
Integer / Cardinal4444
Longint / LongWord448모든 타깃에서 4
NativeInt / Pointer488타깃의 포인터 크기
C long(플랫폼 ABI)448OS ABI를 따른다

크로스 컴파일러 라이브러리 입장에서 함정은 Free Pascal 열이다. PDFlibPas는 이미 FPC를 통해 Linux 빌드를 배포하고 있는데, FPC는 Linux x86-64에서도 Longint를 32비트로 유지한다. 그래서 'Linux에서는 동작한다'는 사실이 참이었지만 Delphi Linux64에 대해서는 아무것도 말해 주지 못했다. FPC의 비 Windows 백엔드가 Delphi 포팅에 맞는 기반이었음이 밝혀졌지만, 그 밑에 깔린 타입 크기는 다르다

PDFlibPas PDF 라이브러리에서 Delphi Win32, Win64, Linux64, Free Pascal 타깃별 Integer, Longint, NativeInt, C long 폭 매트릭스
Delphi Linux64에서 폭이 늘어나는 것은 Longint, LongWord, C long뿐이며 FPC는 모든 타깃에서 4바이트를 유지한다

WIN64 검사는 왜 Linux에서 포인터를 잘라 버리는가?

짧게 답하면 이렇다. WIN64는 '64비트'가 아니라 '64비트 Windows'를 뜻하며, 둘을 동의어로 취급하면 엉뚱한 정수 폭을 고르게 된다. PDFlibDither는 24비트 비트맵을 채널당 몇 단계로 줄이는 Floyd-Steinberg 오차확산 디더링기로, 오차 항 세 행을 있는 그대로의 포인터 연산으로 훑는다. 그 연산 뒤에 깔린 포인터 크기 정수는 운영체제가 아니라 CPU 폭을 따라가야 한다. 순진한 조건부 컴파일은 이 지점을 틀리게 잡는다

// 순진한 방식: 폭을 OS가 결정하므로 Windows 외의 64비트 타깃은 전부 Integer
{$IFDEF WIN64}
Type
  OSPtr= IntPtr;
{$ELSE}
Type
  OSPtr= Integer;
{$ENDIF}

// serpentine 스캔이 오차 행 포인터를 한 항씩 전진시킨다
Inc(OSPtr(ErrorR), SizeOf(TErrorTerm));  // FPC x86-64: 하위 32비트만 다시 쓴다
Dec(OSPtr(ErrorG), SizeOf(TErrorTerm));  // dcclinux64: E2064, 왼쪽 변은 대입할 수 없다

// 올바른 방식: 어디서나 하나의 포인터 크기 타입
Type
  OSPtr= TPLIntPtr;  // PDFlibTypes: Windows 외에는 NativeInt, Win64 / Win32에서는 IntPtr / Integer

이 조건부 컴파일에서는 Linux x86-64, macOS x64, ARM64용 FPC에서 OSPtr이 4바이트 Integer로 해석되고, Inc(OSPtr(ErrorR), ...)는 8바이트 포인터의 앞 4바이트를 정수로 재해석해 그 값만 증가시킨다. 리틀 엔디안 CPU에서 그것은 주소의 하위 절반이다. 상위 절반은 건드리지 않기 때문에 이 실수는 이렇게 잘 숨는다. 하위 32비트가 자리넘침하기 전까지는 결과가 멀쩡하다가, 자리넘침이 일어나는 순간 포인터가 4 GiB만큼 조용히 뒤로 점프한다. FPC는 이런 변수 타입캐스트를 아무 불평 없이 컴파일한다. Delphi는 대입문 왼쪽에서 다른 크기 타입으로의 변수 타입캐스트를 거부하므로 dcclinux64는 첫 Inc에서 E2064로 멈춘다

여기서 쓸모 있는 비대칭이 바로 이것이다. 타입캐스트를 거부하는 컴파일러는 다른 컴파일러가 조용히 받아 넘기는 포인터 폭 실수를, 새 타깃에서 단 한 줄도 실행되기 전에 잡아 낸다. PDFlibPas는 이 용도로 PDFlibTypes의 TPLIntPtr을 쓰는데, 이 타입은 OS_WINDOWS를 먼저 검사하고 그다음에야 Win64와 Win32를 가르므로 비 Windows 타깃은 항상 NativeInt를 얻는다

얻은 교훈은 이렇다. OS 디파인으로 포인터 폭을 추정하지 마라. 포인터 크기 정수가 필요하면 NativeInt / NativeUInt(Delphi와 FPC 모두에 있다)나 그것을 기반으로 만든 프로젝트 별칭을 쓰고 조건부 컴파일 자체를 생략하라. Delphi 코드에서 폭으로 분기할 일이 정말 생긴다면, 실제로 물으려는 질문에 답하는 디파인은 CPU64BITS다

PDFlibPas Delphi 코드에서 포인터 폭 타입을 고르는 결정 흐름: WIN64 디파인은 비 Windows 64비트 타깃에서 4바이트 Integer를 고르다 NativeInt가 이를 막는다
WIN64 디파인이 묻는 대상은 운영체제이므로 포인터 폭 타입은 NativeInt나 CPU64BITS 검사에서 가져와야 한다

TStream override: RTL 시그니처를 한 글자까지 그대로 복사하라

Delphi Linux64에서 TStream 후손 클래스는 Read, Write, SetSize를 RTL이 선언한 그대로 Longint 매개변수로 선언하고 구현해야 한다. Integer가 더 이상 같은 타입이 아니기 때문이다. PDFlibPas에는 유니코드 파일 이름을 위한 자체 TPDFlibWideFileStream이 있는데, 클래스 선언은 Longint를 쓰고 있었다. 그런데 구현부는 Integer를 쓰고 있었다

Type
  TPDFlibWideFileStream= Class(TStream)
  Protected
    Procedure SetSize(NewSize: Longint); Override;
  Public
    Function Read(Var Buffer; Count: Longint): Longint; Override;
    Function Write(Const Buffer; Count: Longint): Longint; Override;
  End;

// 이전: Integer = Longint인 Win32, Win64, FPC에서는 수년간 통과했다
Function TPDFlibWideFileStream.Read(Var Buffer; Count: Integer): Longint;

// 이후: 구현 헤더가 선언을 글자 그대로 반복한다
Function TPDFlibWideFileStream.Read(Var Buffer; Count: Longint): Longint;
Begin
  Result:= FileRead(FHandle, Buffer, Count);
  If Result= - 1 Then
    Result:= 0;
End;

이 라이브러리가 만나 온 모든 컴파일러에서 Integer와 Longint는 같은 4바이트 타입이었으므로 어긋난 헤더와 맞는 헤더를 구분할 방법이 없었다. Delphi Linux64에서는 구현이 자기 선언과 더 이상 맞지 않아 유닛 전체가 컴파일을 거부한다. 이 문제는 크기에 비해 더 아프다. PDFlibExtra는 라이브러리 대부분이 의존하는 기반 유닛이라서, 헤더 하나만 어긋나도 유닛 대부분이 컴파일되지 않는다

같은 논리는 새 코드에서는 반대 방향으로도 통한다. RTL 메서드를 override할 때는 두 타입이 같다는 것을 '알고' 있더라도 매개변수 타입을 글자 그대로 복사하라. Delphi Linux64는 그 지식이 더 이상 참이 아니게 되는 첫 번째 타깃일 뿐이다

어떤 레코드는 Longint 대신 Integer가 필요하고, 어떤 레코드는 Longint를 유지해야 하는가?

파일 포맷이나 Windows 구조체를 그대로 반영하는 레코드는 고정 폭 타입(Integer, Cardinal, Int32)을 써야 하고, C에서 long으로 선언된 구조체를 반영하는 레코드는 Longint / LongWord를 유지해야 한다. 두 경우는 소스에서 똑같이 보이는데 정반대 답이 필요하다. Windows의 LONG은 언제나 32비트이고 C의 long은 플랫폼 ABI가 정하는 대로이기 때문이다

LP64에서 Delphi 레코드 필드를 고르는 결정 매트릭스: 디스크의 바이트에는 고정 폭 Integer, PDFlibPas의 C long 바인딩에는 Longint 유지
바이트를 반영하는 레코드는 고정 폭을 쓰고, C long 바인딩만 Longint를 유지하며 한 줄짜리 크기 단언이 이를 뒷받침한다
Type
  // Uniscribe GOFFSET: Windows LONG 둘, 합계 8바이트 고정
  TGoffset= Packed Record
    du: Integer;  // Longint였음: Delphi Linux64에서 레코드가 16바이트로 늘었다
    dv: Integer;
  End;

  // EMF LOGCOLORSPACE: 헤더는 레코드 데이터에서 고정 68바이트 Move로 채운다
  TPLEMFLogColorSpace= Packed Record
    Signature, Version, Size, ColorType, Intent: Cardinal;
    Endpoints: Array[0..8] Of Integer;  // Longint였음: 36바이트가 72로 늘어난다
    Gamma: Array[0..2] Of Cardinal;
    Filename: Array[0..259] Of WideChar;
  End;

  // PDFium FPDF_FILEACCESS: m_FileLen은 C unsigned long이므로 LongWord가 맞다
  FPDF_FILEACCESS= Record
    m_FileLen: LongWord;  // Windows에서 4바이트, LP64에서 8바이트, C 쪽과 같다
    m_GetBlock: TFPDF_GetBlock;
    m_Param: Pointer;
  End;

EMF 사례는 이 문제가 얼마나 조용히 실패하는지 보여 준다. 임포터는 TPLEMFLogColorSpace에 68바이트를 복사한다. 헤더 20바이트, CIE 엔드포인트 36바이트, 감마 12바이트다. LP64에서 엔드포인트가 Longint라면 같은 68바이트가 72바이트짜리 엔드포인트 배열에 들어가고 감마 값은 영원히 기록되지 않으며, 아무것도 오류를 내지 않는다. 두 레코드 모두 Windows 전용 코드 경로에 있고 어차피 고정 폭 타입을 쓴다. 디스크의 바이트를 반영하는 레코드가 어떤 컴파일러로 빌드되는지에 좌우되어서는 안 되기 때문이다

PDFium 바인딩은 반대 사례다. m_FileLen은 C의 unsigned long을 반영하는데, 이는 Windows에서 32비트, LP64에서 64비트이고 LongWord는 양쪽에서 그 값을 정확히 따라간다. 정리 작업에서 이것을 Cardinal로 고쳐 쓰면 PDFium 호스트가 POSIX 로더를 갖게 되는 순간 깨진다. PDFium 호스트는 Windows 전용이므로 이것은 미래를 위한 메모다. C 바인딩의 호출 규약 쪽에도 함정이 따로 있는데, 이는 PDF Library DLL, ActiveX, dylib 바인딩에 관한 글에서 다룬다

바이트를 반영하는 레코드라면 값싼 방어막 하나를 두자. 시작 시 크기를 한 번 단언하는 것이다. 초기화부에 Assert(SizeOf(TGoffset) = 8) 같은 코드를 넣는 식이다. 비용은 없고, 조용한 레이아웃 변화를 새 타깃의 첫 실행에서 즉각적인 실패로 바꿔 준다

조건부 디파인 바로 잡기: DELPHI_LINUX, Android, Types

타입 문제가 표면으로 나오기 전에, Linux의 Delphi가 Windows인 척하는 것부터 멈춰야 했다. PDFlibDefines.inc는 Delphi Linux에 대해 아무것도 정의하지 않았으므로 컴파일러는 곧장 Windows/VCL 기본값으로 떨어졌고, 첫 오류는 Vcl.Graphics를 찾지 못하는 것이었다. 디파인은 Delphi Linux를 FPC가 비 Windows 타깃에서 쓰는 것과 같은 백엔드로 안내한다. 런타임에 로드되는 Cairo 렌더링, fontconfig, OpenSSL, libcurl이 그것이다

{$IFDEF LINUX}
{$IFNDEF FPC}
{$IFNDEF ANDROID}
{$DEFINE DELPHI_LINUX}
{$DEFINE NOVCL}
{$DEFINE MD5_MAC}
{$DEFINE NOCRYPTAPI}
{$DEFINE USE_Cairo}
{$ENDIF}
{$ENDIF}
{$ENDIF}

이 블록에는 놓치기 쉬운 디테일이 두 개 있다. ANDROID 배제가 있는 이유는 Delphi가 Android 타깃에도 LINUX를 정의하지만 이 백엔드는 Android를 커버하지 않기 때문이다. 그리고 새 디파인은 기존의 Delphi macOS 분기(FPC 없는 OS_MAC)와 일부러 분리되어 있다. 그쪽은 Macapi 유닛에 의존하며, Linux 디파인은 대신 FPC 비 Windows 경로 위에 세워진다

또 하나 반복된 오류는 TPoint와 TRect가 선언되지 않았다는 것이었다. Windows에서는 이 타입들이 Windows 유닛을 통해 대부분의 유닛에 간접 도달한다. Delphi Linux의 uses 절에는 Windows 유닛이 없으므로 PDFlibFont나 PDFlibPageTree 같은 유닛은 이제 DELPHI_LINUX 아래에 Types를 명시적으로 나열한다. 여러분 코드가 Windows가 System.Types를 끌고 다녀 주기 때문에만 Windows에서 컴파일된다면 같은 벽을 만날 것이다. 우연히 한 컴파일러에서만 동작하는 코드의 일반적인 패턴은 FPC가 드러내기 전까지 우연으로만 동작하는 Delphi 습관 다섯 가지의 주제다

컴파일러가 더 이상 도와 주지 못하는 지점

위의 LP64 문제들은 컴파일러가 찾을 수 있는 것들이다. 컴파일러가 찾지 못하는 것들, 이를테면 크기는 틀린데 타입캐스트를 통과하지 않는 레코드 같은 문제는 테스트가 실제로 타깃에서 돌아갈 때 비로소 나타난다. 앞서 말한 크기 단언이 정확히 그 자리를 맡는다

애초에 1비트 출력이 이 디더링 코드로 데려온 이유라면, 렌더링된 페이지를 위한 지원 경로는 다른 쪽이다. 렌더러 바깥에서 이뤄지는 GDI halftone 다운컨버전으로, PDF 페이지를 1비트 모노크롬 비트맵으로 렌더링하기에서 설명한다

여기서 설명한 크로스 컴파일러 기반 작업은 Delphi용 PDF Library에 담겨 있다. 오늘날 이 라이브러리는 Windows의 Delphi와 C++Builder에 더해 Linux와 macOS의 헤드리스 Free Pascal 빌드를 타깃으로 하며, 제품 페이지에 지원 컴파일러와 플랫폼 전체가 정리되어 있다