PDFlibPas는 32비트 Windows용으로 Free Pascal에서 빌드되는데, 어려운 부분은 처음부터 끝까지 Pascal이 아니었습니다. 문제는 오브젝트 파일이었습니다. Delphi 빌드가 링크하는 AES와 OpenJPEG 오브젝트는 OMF이고, Free Pascal의 내부 링커는 COFF를 요구하며, 둘 사이의 변환은 링커를 진단 메시지 대신 내부 에러로 실패하게 만드는 섹션 이름과 섹션 정의 심볼을 만들어 냅니다
C 오브젝트를 Pascal 라이브러리에 링크해 본 사람이라면 이 지형은 낯설지 않을 겁니다. Win64는 상대적으로 얌전한 세계입니다. 오브젝트 포맷이 하나, 호출 규약이 하나, 네임 데코레이션은 없음. Win32는 이 플랫폼이 쌓아 온 역사의 모든 층을 그대로 간직하고 있고, 서드파티 C 코드를 정적 링크하는 라이브러리는 그 전부를 한꺼번에 만나게 됩니다
컴파일러 디렉터리는 타깃을 알려 주지 않습니다
빌드 진입점부터 보는 게 좋습니다. 여기서부터 틀리면 오브젝트 파일이 등장하기도 전에 몇 시간을 날립니다. Free Pascal 설치 디렉터리 이름은 메인 컴파일러가 어디에 사는지를 말해 줄 뿐 무엇을 만들어 내는지는 말해 주지 않습니다. 32비트 호스트 컴파일러는 옆에 있는 크로스 컴파일러를 호출해 올바른 타깃 스위치만 주면 64비트 코드를 내보낼 수 있으므로, 경로에서 타깃을 추측하는 건 툴체인을 재배치하기 전까지 우연히 맞아 주는 추측에 불과합니다
믿을 만한 방법은 컴파일러에게 묻는 것입니다. 컴파일러 자체 정보 스위치로 실제 타깃 프로세서와 운영체제를 조회하고, 흔한 두 설치 레이아웃, 즉 평면적인 바이너리 디렉터리와 버전 중첩형을 모두 받아 들이세요. 인스톨러와 툴체인 매니저에 따라 생김새가 달라지기 때문입니다. 둘 중 하나를 하드코딩한 빌드 스크립트는 정확히 한 대의 머신에서만 동작합니다
변환된 오브젝트 파일은 왜 내부 링커를 깨뜨릴까?
변환이 OMF의 섹션 이름 규약을 그대로 보존하고, COFF 링커가 기대하는 것과 어긋나는 섹션 정의 심볼을 만들어 내기 때문입니다. OMF 오브젝트를 COFF로 변환하는 건 필요조건이지 충분조건이 아닙니다. 변환 결과물에는 익숙한 _TEXT, _DATA, _BSS 섹션 이름과 여기서 파생된 섹션 정의 심볼 이름이 그대로 남아 있고, 이것을 Free Pascal 내부 링커에 먹이면 섹션 이름에 대한 메시지가 아니라 내부 컴파일러 에러가 돌아옵니다
빌드 문제 치고 내부 에러는 최악의 실패 양상입니다. 입력의 무엇이 잘못됐는지 아무것도 말해 주지 않으니까요. 해결책은 변환 후 정규화 패스입니다. COFF 파일에 후처리를 한 번 돌려 섹션 이름을 기대하는 형태로 고치고 대응하는 섹션 정의 심볼도 맞게 고치되, 심볼 인덱스와 코드 바이트와 재배치는 그대로 둡니다. 이 마지막 제약이 난이도의 전부입니다. 심볼을 재번호 매기거나 오프셋을 밀어 버리는 재작성은 링크는 되지만 나중에 크래시하는 오브젝트를 만들어 냅니다
두 오브젝트 셋 중 하나에는 사전 작업이 필요합니다. 클래식 32비트 C++ 컴파일러로 빌드한 OpenJPEG 오브젝트는 Free Pascal이 제공하지 않는 Delphi 전용 64비트 정수 헬퍼 루틴에 의존하므로, 포맷 변환을 아무리 해도 쓸 수 있게 되지 않습니다. 이들은 Clang 기반 컴파일러로 먼저 재빌드합니다. 이 컴파일러는 그런 의존성을 만들어 내지 않으므로 변환은 그다음에 합니다
// FPC 타깃용 오브젝트는 별도 디렉터리에 둡니다. Delphi 오브젝트 셋을
// 대체하는 게 아니라, 두 툴체인이 같은 소스 트리에서 빌드되며 각자
// 자기 링크 입력이 필요하기 때문입니다
//
// Lib\thirdparty\Win32 Delphi OMF 오브젝트, 변경 없음
// Lib\thirdparty\Win32f FPC COFF 오브젝트, 변환 및 정규화 완료
//
// 빌드 진입점:
// build-Win32-Lib-FPC.cmd
// build-Win64-Lib-FPC.cmd
컴파일러 전용 헬퍼는 이식 불가능하고, 그 규약도 마찬가지입니다
Delphi 런타임은 32비트 x86에서 64비트 정수 연산을 위한 어셈블리 트램펄린을 제공하고, Delphi용으로 빌드된 프리컴파일 C 오브젝트는 이를 호출합니다. Free Pascal은 자기 방식이 따로 있으므로 이 참조는 다른 방식으로 충족해야지 리다이렉트해서는 안 됩니다. 리다이렉트가 불가능한 결정적 이유는 호출 규약입니다. 이미징 코드가 쓰는 타이밍 헬퍼는 4바이트 인자를 callee가 정리하지만, 64비트 나눗셈 헬퍼는 16바이트를 정리하고 결과를 클래식 레지스터 쌍으로 돌려줍니다. 헬퍼 둘, 규약 둘. 하나를 위해 작성한 트램펄린은 다른 쪽에서 스택을 조용히 망가뜨립니다
네임 데코레이션이 문제의 후반부를 담당합니다. Win32에서 Free Pascal은 외부 C 임포트에 언더스코어 접두사를 자동으로 붙이는 반면 public name 선언은 그대로 내보냅니다. 같은 브리지의 임포트 쪽과 익스포트 쪽이 서로 다른 규칙을 따르는 셈입니다. 그래서 OpenJPEG가 필요로 하는 C 런타임 브리지는 정확한 C 심볼 이름을 그대로 익스포트해야 하고, 가변 인자 진입점은 직접 점프가 아닌 32비트 간접 점프가 필요합니다. 적어 두면 지겨울 정도로 평범한 얘기인데, 하나같이 아무도 작성한 적 없는 심볼 이름을 가리키는 링크 에러로 실패합니다
Win32 실행 파일이 main 전에 죽은 이유는?
검색 경로에 있던 64비트 DLL 때문이었습니다. Free Pascal의 zlib 유닛이 정적 링크가 아니라 동적으로 바인딩하기 때문에 도달한 것이죠. 증상은 프로그램의 Pascal 코드가 한 줄도 실행되기 전에 invalid-image 상태 코드로 즉시 종료하는 것이었고, 원인은 로더가 잘못된 아키텍처에 대해 임포트를 해석하는 데 있었는데도 이 때문에 방금 빌드한 프로그램을 들여다보게 됩니다
교훈은 zlib가 아니라 전제에 관한 것입니다. 압축 라이브러리 이름을 단 유닛이 반드시 그 라이브러리를 담고 있는 건 아닙니다. 런타임에 공유 라이브러리를 기대하는 바인딩일 수 있고, 의도하지 않은 동적 의존성은 우연히 해석되더라도 배포 리스크입니다. 순수 Pascal 스트림 구현으로 갈아타면 두 타깃 모두 외부 의존성 없이 정적으로 포함된 압축 경로를 갖게 되는데, 남의 애플리케이션에 들어가는 라이브러리라면 애초에 이래야 마땅합니다
같은 직관은 외부 JBIG2 인코더 백엔드에도 적용됩니다. 32비트 타깃에서는 외부 인코더가 링크되지 않으므로 요청은 내장 Pascal 인코더로 폴백되고, 이를 검증하는 테스트는 인코딩 성공을 외부 백엔드 존재의 증거로 삼으면 안 되고 현재 타깃의 등록 상태를 확인해야 합니다. 잘 동작하는 폴백이야말로 빠진 의존성을 숨기는 주범이며, 이 실패 패턴은 조용한 스텁 실패 진단에서 다룹니다. 64비트 정적 링크 작업은 FPC에서 jbig2enc 정적 링크에 정리되어 있습니다
메모리 스트림 위의 32비트 산술
버퍼 크기를 포인터 폭 부호 없는 정수로 다루는 코드는 Win64에선 정확하고 Win32에선 큰 이미지 하나면 오버플로 직전입니다. JPEG 2000 코덱에 데이터를 공급하는 인메모리 스트림은 두 배씩 늘고 덧셈으로 전진하는데, 32비트 타깃에서는 크지만 전혀 무리 없는 입력에서 두 연산 모두 오버플로가 날 수 있습니다
그래서 모든 쓰기와 건너뛰기, seek, 초기 할당은 계산 전에 검사하고, 용량 상한은 부호 있는 포인터 폭 값의 최댓값으로 잡습니다. 블록 이동 루틴과 콜백 반환값이 표현할 수 있는 범위에 맞춘 것입니다. 요청을 거부할 때의 동작 요건도 놓치기 쉽습니다. 거부는 스트림 위치나 길이를 바꿔서는 안 됩니다. 부분 변경 후 에러로 끝나면 호출자가 추론할 수 없는 상태에 스트림을 남기고, 다음 연산은 그것을 증폭시킵니다
// 계산 전에 먼저 검사합니다. Win32에서는 큰 JPEG 2000 이미지가
// 정상적으로 만들어 내는 입력만으로도 둘 다 오버플로됩니다
if Needed > NativeUInt(High(NativeInt)) - FPosition then
Exit(False); // 거부하되 위치와 크기는 그대로 둠
NewCapacity := FCapacity;
while (NewCapacity < FPosition + Needed) do
begin
if NewCapacity > NativeUInt(High(NativeInt)) shr 1 then
Exit(False); // 두 배 증가 시 오버플로
NewCapacity := NewCapacity shl 1;
end;
포트 이후에도 남는 빌드 출력 함정 두 가지
테스트와 샘플 실행 파일을 타깃 아키텍처별 출력 디렉터리로 분리하는 건 명백히 옳은 일이고, 위로 디렉터리 층수를 세어 테스트 데이터를 찾던 모든 것을 즉시 깨뜨립니다. 해결책은 고정 깊이를 가정하는 대신 자산 디렉터리를 위로 검색하는 것이되, 하나의 의도적 제약을 둡니다. 서명 샘플은 인증서 폴백을 자기 프로젝트 디렉터리에서만 받고 임의의 상위 디렉터리에서는 받지 않습니다. 트리 위쪽에서 발견된 같은 이름의 인증서는 편의가 아니라 보안 사고입니다
두 번째 함정은 모든 포트를 살아남고 어떤 FPC 프로젝트에도 가치 있는 것입니다. 컴파일러 업그레이드 후 컴파일러가 낡은 PPU 파일을 거부하게 만드는 것만으로는 부족합니다. 링커는 로드한 PPU가 올바른 디렉터리에서 왔더라도 유닛 검색 경로에 남아 있는 오브젝트 파일을 여전히 선호하고, 명시적인 오브젝트 출력 경로를 추가해도 이 선호를 덮지 못합니다. 믿을 만한 유일한 답은 빌드 회차마다 새 임시 유닛 디렉터리를 쓰는 것입니다. 그보다 못한 방법은 두 컴파일러 버전에서 링크된 바이너리를 만들어 내며, 그 실패 양상은 소스 버그처럼 보입니다
플랫폼 조건부 컴파일이 마지막 조각인데, 어떤 축을 기준으로 할지가 보기보다 중요합니다. 올바른 질문은 대개 특정 위젯 라이브러리가 있는지가 아니라 그 코드가 Windows 전용인지 여부입니다. EMF 벡터 임포트와 플랫폼 조건부 컴파일의 메타파일 변환 작업이 보여 주듯, 그 가드를 컨트롤 라이브러리 조건에서 플랫폼 조건으로 바꾸자 재작성으로 보였던 것이 디렉티브 하나 바꾸는 일이 됐습니다. Free Pascal과 Lazarus의 양쪽 Windows 타깃 지원은 PDFlibPas Delphi PDF 라이브러리에 포함되어 배포되며, Delphi 및 C++Builder 패키지와 같은 소스에서 빌드됩니다