기술 문서

DLL 없이 Free Pascal에 jbig2enc 정적 링크하기

PDFlibPas 3.538.0은 외부 JBIG2 인코더를 Free Pascal과 Lazarus 프로그램에 정적으로 링크합니다. 프로젝트에 Delphi와 C++Builder가 이미 사용하는 것과 같은 PDFlibJBIG2EncC 유닛을 추가하면 인코더가 실행 파일 안에 들어가므로 함께 배포할 추가 파일이 없습니다. 이는 이 기능에 대해 예전에 Free Pascal이 외부 인코더에 접근하려면 DLL뿐이라고 내렸던 결론을 뒤집습니다

DLL이 유일한 선택지처럼 보였던 이유

DLL이 유일한 선택지처럼 보였던 것은 세 가지 링크 경로가 서로 무관한 세 가지 방식으로 실패했고 어떤 컴파일러 스위치도 그 지점에 닿지 못했기 때문입니다. 내부 링커는 associative COMDAT 섹션을 즉시 거부합니다. 번들된 binutils를 통한 외부 링크는 Free Pascal이 64비트 Windows 타깃에서 무조건 넘기는 섹션 가비지 컬렉션 내부에서 충돌합니다. 최신 binutils는 Free Pascal 링크 스크립트를 아예 처리하지 못합니다. 다른 툴체인으로 C++ 쪽을 다시 빌드하면 한 거부가 다른 거부로 바뀔 뿐인데, template과 inline 인스턴스화는 원래 weak external 심볼을 내보내며 Free Pascal은 이를 Unsupported COFF symbol type 105로 보고하기 때문입니다. 이 증거들이 틀린 것은 아니며, JBIG2 인코더 백엔드와 Free Pascal 링커를 다룬 이전 글은 지금도 재현되는 각 막다른 길을 보여줍니다. 틀렸던 것은 수정이 들어갈 위치에 대한 가정이었습니다. 모든 시도는 컴파일러나 링커를 통과했지만 둘 다 object file에 이미 들어간 내용을 바꿀 수 없습니다. 문제는 처음부터 object file에 있었습니다. ObjConv는 COFF를 읽고 COFF를 쓰며 Free Pascal이 걸려 넘어지는 모든 구조에는 받아들일 수 있는 기계적인 대체 방식이 있습니다

원인을 전혀 말해 주지 않는 오류

Free Pascal 내부 링커는 pick-any COMDAT를 절반만 구현하고 있으며, 바로 그 불완전한 구현이 여기서 진단하기 가장 어려운 부분입니다. 형식이 의도한 대로 중복 정의를 fold하기는 합니다. 하지만 섹션을 사용 중으로 표시할 때 TExeOutput.RemoveUnreferencedSectionsexesymbol을 거쳐 승리한 정의로 리디렉션하는 반면 TCoffexeoutput.DoRelocationFixupobjreloc.symbol.objsection을 직접 읽습니다. 사용 중인 섹션이 자기 object에서 fold에 져서 남은 복사본에 정의된 심볼을 참조하면 두 pass가 서로 다른 섹션을 보게 되고 링크는 Internal error 200603061에서 멈춥니다

이 오류를 양쪽의 두 제한과 비교해 보세요. Unsupported COFF symbol type 105는 weak external이라고 말합니다. Associative or exact match COMDAT sections are not yet supported는 associative COMDAT이라고 말하고 문제의 심볼까지 이름으로 알려 줍니다. Internal error 200603061은 아무것도 말하지 않습니다. 심볼 이름도, 섹션 이름도, 파일 이름도, 단계도 없습니다. 게다가 이것은 특수한 경우가 아니라 일반적인 경우입니다. MSVC는 모든 문자열 리터럴과 모든 inline 또는 template 인스턴스화를 pick-any COMDAT에 넣으며 이 인코더 세트의 186개 object 전체에서 링커는 2656번 fold를 수행했습니다. /Gy-로 빌드하면 일반 함수가 함수별 COMDAT 섹션에 들어가는 것은 막지만 문자열 리터럴과 template 인스턴스화는 그대로 남습니다

CRT 심볼 stub을 채우면 마지막 항목이 문제를 일으킨 것처럼 보이는 이유

링커는 모든 심볼이 resolve된 뒤에야 fixup 단계로 들어가기 때문입니다. 아직 빠진 것이 있으면 실행은 Undefined symbol로 일찍 끝나 COMDAT 문제가 드러날 기회가 없습니다. 마지막 C runtime stub을 채우면 링커가 한 단계를 진행해 곧바로 Internal error 200603061로 들어갑니다. 따라서 현장에서 보이는 증상은 체계적으로 오해를 부릅니다. 참조된 C 심볼에 대한 Pascal 본체를 하나씩 추가하면 항상 가장 최근에 추가한 항목이 빌드를 망가뜨린 것처럼 보이거나, 100개 안팎의 stub을 넘는 임계값을 통과한 것처럼 보입니다. 둘 다 사실이 아닙니다. 마지막에 어떤 심볼을 넣었는지와 전체 몇 개를 넣었는지는 모두 무관합니다. 실패는 첫 object부터 잠복해 있었고 resolve가 성공해 접근 가능해졌을 뿐이기 때문입니다. 관계없는 수정을 한 뒤 링커의 불평이 바뀌면 회귀를 만든 것이 아니라 단계를 진행시킨 것은 아닌지 먼저 물어야 합니다

수정은 컴파일러 플래그가 아니라 한 번의 ObjConv pass

전체 수정은 컴파일된 각 object에 대해 실행하는 단 하나의 후처리 명령입니다: ObjConv -fcoff64 -xw -xc -xn -np:__imp_:pdflibimp_. 이 중 세 옵션은 이번 작업을 위해 추가되었습니다. -xwIMAGE_SYM_CLASS_WEAK_EXTERNAL 심볼을 일반 external로 resolve합니다. -xn_fltused 같은 IMAGE_SYM_CLASS_NULL 심볼을 정규화하며, Free Pascal은 이를 Unsupported COFF symbol type 0으로 보고합니다. 핵심 작업은 -xc입니다. 모든 COMDAT 섹션을 일반 섹션으로 낮추고 그 섹션이 정의하는 심볼을 static으로 만듭니다. .pdata.xdata 같은 associative unwind section도 함께 처리됩니다. COMDAT 섹션이 없으면 folding도, 한 pass가 리디렉션했지만 다른 pass가 찾지 못하는 승리 복사본도 없으므로 결정을 없애 실패를 제거합니다. 정당하게 병합될 수 있었던 복사본도 각각 남는다는 비용은 있지만 작습니다

-np:__imp_:pdflibimp_ 접두사 변경은 별도의 충돌을 해결합니다. MSVC는 __imp_*라는 이름의 indirection cell을 통해 가져온 Win32 API를 호출하고 Free Pascal은 이 접두사를 자체 import 메커니즘에 예약하므로, 이 이름 중 하나를 그대로 정의하면 같은 Internal error 200603061이 발생합니다. cell 이름을 바꾸면 Pascal 쪽에서 일반 변수로 공개하고 런타임에 값을 채울 수 있습니다. object 자체는 static-link 플래그 /GS- /Gs999999 /Gy- /Zl /GR- /EHs-c-를 사용하고 image codec도 끈 상태로 컴파일하므로, 죽은 file-I/O와 codec 경로에 필요한 링크 전용 stub이 훨씬 줄어듭니다. object는 Lib\thirdparty\Win64f에 배치되고 Delphi와 C++Builder 경로는 자체 Win64x 세트를 그대로 링크합니다. 한 툴체인에 한정된 portability 수정으로서는 이것이 올바른 결과입니다

Pascal 쪽에서 여전히 export해야 하는 것

Free Pascal은 C object의 import를 심볼 이름으로 resolve하므로 그 이름을 명시해야 합니다. 따라서 C entry point를 대신하는 모든 Pascal routine에는 명시적인 public name 절이 붙습니다. Delphi는 routine 이름을 심볼 이름으로 사용하므로 절이 전혀 필요 없습니다. 그래서 하나의 unit을 두 컴파일러에서 사용하면서 절만 {$IFDEF FPC} 아래에 둘 수 있습니다. 주의할 점은 external 'msvcrt.dll' 선언만으로는 아무것도 충족하지 못한다는 것입니다. 이것은 import만 만들 뿐 링크된 object가 bind할 definition을 만들지 않습니다. forwarding body가 실제로 존재해야 합니다

// external 선언은 import만 만듭니다. 링크된 object가
// 여기에 bind할 수는 없습니다
function crt_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  external 'msvcrt.dll' name 'memcmp';

// 정확한 C 심볼 이름으로 공개된 Pascal 본체가 object 세트가
// 실제로 bind하는 대상입니다
function jbig2_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  public name 'memcmp';
begin
  Result := crt_memcmp(Buf1, Buf2, Count);
end;

가변 인자 entry point는 이 패턴을 깨뜨립니다. Pascal wrapper는 자신의 varargs를 다른 varargs callee로 전달할 수 없기 때문입니다. 해결책은 wrapper이기를 그만두는 것입니다. C 이름 아래에 naked routine을 export하고 caller가 배치한 그대로의 argument register와 stack을 사용해 실제 구현으로 tail-jump합니다. JPEG 2000 계층은 이미 이 방식으로 snprintfvsnprintf를 처리하며, 일반 이름은 UCRT만 export하므로 underscore가 붙은 msvcrt 표기로 jump합니다. 같은 Internal error에서 나온 관련 제약도 있습니다. 이름을 바꾼 import cell은 static initializer가 아니라 initialization 섹션에서 GetModuleHandleAGetProcAddress를 사용해 채웁니다. initializer에서 imported routine의 주소를 취하면 컴파일러가 처리할 수 없는 fixup을 내보내 다시 200603061에서 실패하기 때문입니다

function crt_sprintf(Buffer: PAnsiChar; Fmt: PAnsiChar): Integer; cdecl; varargs;
  external 'msvcrt.dll' name 'sprintf';

var
  SPrintfTarget: Pointer = @crt_sprintf;

// varargs는 Pascal wrapper에서 전달할 수 없으므로 export된
// 심볼은 caller가 구성한 frame을 그대로 두고 tail-jump합니다
procedure jbig2_sprintf; assembler; nostackframe;
  public name 'sprintf';
asm
  jmp qword ptr [rip + SPrintfTarget]
end;

이제 Free Pascal 프로젝트에서 달라지는 것

uses 절의 unit 이름 외에는 아무것도 달라지지 않으며 더 이상 배포할 파일도 없습니다. 백엔드는 자체 initialization 섹션에서 RegisterJBIG2EncoderBackend를 통해 등록되고 호출자는 이전과 똑같이 요청합니다. 값이 4인 options bit PDF_JBIG2_OPTION_EXTERNAL_ENCODER를 사용하거나 확장 entry point의 UseExternalEncoder 인자를 사용하면 됩니다. 다만 요청은 보장이 아니라 선호로 남습니다. unit을 빼고 빌드하면 native Pascal MMR encoder로 조용히 fallback하고 오류 대신 더 큰 파일을 생성합니다

uses
  Classes, SysUtils,
  PDFlibrary,
  PDFlibJBIG2EncC;   // Delphi, C++Builder 및 3.538.0부터 Free Pascal

var
  Pdf: TPDFlib;
  Scan: TStream;
  ImageId: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;
    Pdf.NewPage;
    Scan := TFileStream.Create('scan-page-1.tif', fmOpenRead);
    try
      // Interpolate, SymbolExtract, UseExternalEncoder, SkipBlackDots,
      // BlackDotSize, LossyLevel
      ImageId := Pdf.AddImageJBIG2FromStreamEx(Scan, 0, 1, 1, 0, 0, 0);
    finally
      Scan.Free;
    end;
    if ImageId = 0 then
      raise Exception.Create('JBIG2 encoding failed');
    Pdf.SaveToFile('archive.pdf');
  finally
    Pdf.Free;
  end;
end;

두 가지 제한은 분명히 말해 둘 가치가 있습니다. Win64 object 세트만 존재하므로 다른 모든 Free Pascal 타깃에서는 external encode entry point가 실패를 보고하고 native Pascal encoder가 작업을 맡습니다. 그리고 이 모든 것을 통과시키는 회귀 조건은 크기 확인이 아니라 render 비교입니다. 같은 source에서 두 encoder는 모두 lossless이므로 출력을 render하고 byte 단위로 비교하며 Lazarus suite는 이 테스트를 포함해 26개 중 26개를 통과합니다. 압축된 stream 크기를 비교해도 아무것도 증명할 수 없습니다. 반전된 페이지가 올바른 페이지와 거의 같은 크기로 압축되기 때문입니다

더 넓은 교훈은 JBIG2를 넘어 일반화됩니다. 경계가 실제로 동적이라면 DLL이 올바른 형태이며, 이는 DLL, ActiveX와 dylib 통합 표면이 담당하는 경우입니다. 반대로 COFF reader의 우회책일 뿐이라면 잘못된 형태입니다. 매 installer에 파일 하나를 추가하고, 매 deployment에 search path 하나를 추가하며, static linking에는 없는 version-skew 실패 모드를 만들기 때문입니다. upstream도 중요합니다. 이진 이미지가 만들어지는 방식이 최종 크기에 대해 encoder보다 더 많은 것을 결정하고, Delphi의 영역 기반 monochrome rendering이 pipeline의 그 절반을 다룹니다. 툴체인 지원 범위, 컴파일러별 object 세트와 지원 타깃은 losLab PDF Developer Library 제품 페이지에 정리되어 있습니다