Технічна стаття

Статичне лінкування jbig2enc у Free Pascal без DLL

PDFlibPas 3.538.0 статично лінкує зовнішній JBIG2 encoder у програми Free Pascal і Lazarus. Проєкт додає модуль PDFlibJBIG2EncC, той самий модуль, який уже використовують Delphi та C++Builder, і encoder опиняється всередині виконуваного файлу без жодного додаткового файла для постачання. Це скасовує попередній висновок про цю можливість: нібито Free Pascal може дістатися зовнішнього encoder лише через DLL

Чому DLL здавалася єдиним варіантом?

DLL здавалася єдиним варіантом, бо три шляхи лінкування провалювалися з трьох не пов’язаних між собою причин, і жоден прапорець компілятора не діставався до них. Внутрішній linker безумовно відхиляє associative COMDAT sections. Зовнішнє лінкування через bundled binutils падає всередині збирання сміття секцій, яке Free Pascal безумовно передає для 64-бітної цілі Windows. Новіший binutils взагалі не може обробити linker script Free Pascal. Перезбирання C++-частини іншим toolchain міняє одну відмову на іншу, бо інстанціювання шаблонів і inline-функцій за самою природою генерує weak external symbols, а Free Pascal повідомляє про них як про Unsupported COFF symbol type 105. Жодне з цих спостережень не було помилковим, і попередній розбір backend-ів JBIG2 encoder та linker Free Pascal проходить кожен глухий кут у формі, яка й сьогодні відтворюється. Помилковим було припущення про місце, де може жити виправлення. Кожна спроба проходила через compiler або linker, а жоден із них не може змінити те, що вже містить object file. Увесь цей час проблемою був саме object file. ObjConv читає COFF і записує COFF, а кожна конструкція, на якій спотикається Free Pascal, має механічний еквівалент, який він приймає

Помилка, яка ніколи не називає причину

Внутрішній linker Free Pascal реалізує pick-any COMDAT лише наполовину, і саме ця напівреалізація тут найважче діагностується. Він справді об’єднує дубльовані визначення, як і передбачає формат. Але TExeOutput.RemoveUnreferencedSections під час позначення секцій як використаних через exesymbol перенаправляє на переможне визначення, тоді як TCoffexeoutput.DoRelocationFixup безпосередньо читає objreloc.symbol.objsection. Коли використана секція посилається на символ, який власний object визначає в копії, що програла об’єднання, два проходи бачать різні секції, і лінкування зупиняється з 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 поміщає кожен string literal і кожне inline- або template-інстанціювання в pick-any COMDAT, а в 186 об’єктах цього encoder-набору linker виконав 2656 об’єднань. Збирання з /Gy- прибирає звичайні функції з COMDAT-секцій для окремих функцій, але залишає string literals та template-інстанціювання саме там, де вони були

Чому заглушки CRT завжди створюють враження, що зламалася остання?

Тому що linker доходить до проходу виправлення лише після розв’язання всіх символів. Поки чогось бракує, запуск рано завершується з Undefined symbol, і проблема COMDAT не має нагоди проявитися. Додайте останню заглушку C runtime — і linker переходить до наступної фази, прямо у Internal error 200603061. Тому польовий симптом систематично вводить в оману: коли тіла Pascal для потрібних C symbols додають по одному, завжди здається, що збірку зламало найсвіжіше додавання або що пройдено певний поріг близько сотні заглушок. Ні те, ні інше не відповідає дійсності. Який символ додали останнім і скільки їх додали загалом — не має значення, бо збій був прихований від першого object і став досяжним лише після успішного розв’язання. Коли linker змінює скаргу після виправлення чогось не пов’язаного, запитайте себе, чи не просунули ви його на іншу фазу замість того, щоб спричинити регресію

Виправлення — один прохід ObjConv, а не прапорець компілятора

Усе виправлення — це одна команда постобробки для кожного скомпільованого object: ObjConv -fcoff64 -xw -xc -xn -np:__imp_:pdflibimp_. Три з цих опцій додали саме для цієї роботи. -xw перетворює символи IMAGE_SYM_CLASS_WEAK_EXTERNAL на звичайні externals. -xn нормалізує символи IMAGE_SYM_CLASS_NULL, наприклад _fltused, про які Free Pascal повідомляє як про Unsupported COFF symbol type 0. -xc виконує основну роботу: переводить кожну COMDAT-секцію у звичайну секцію та робить визначені нею символи static. Збій зникає разом із самим рішенням, яке його спричиняло: без COMDAT-секцій немає об’єднання, немає переможної копії, на яку один прохід може перенаправити, а інший — не знайти, і associative .pdata та .xdata unwind-секції зникають разом із цим. Ціна реальна, але невелика: копії, які можна було легітимно об’єднати, тепер усі зберігаються

Перейменування префікса -np:__imp_:pdflibimp_ розв’язує окрему колізію. MSVC викликає імпортовані Win32 API через комірки непрямої адресації з іменами __imp_*, Free Pascal резервує цей префікс для власної import machinery, а пряме визначення одного з таких імен знову спричиняє Internal error 200603061. Перейменування комірок дає стороні Pascal змогу опублікувати їх як звичайні variables і заповнити під час виконання. Самі objects компілюються зі встановленим static-link flag, /GS- /Gs999999 /Gy- /Zl /GR- /EHs-c-, і з вимкненими image codecs, тому мертвим file-I/O та codec paths потрібно набагато менше stub-ів лише для лінкування. Вони потрапляють до Lib\thirdparty\Win64f, тоді як маршрут Delphi та C++Builder і далі лінкує власний набір Win64x без змін — саме так і має виглядати portability fix, обмежений одним toolchain

Що ще має експортувати сторона Pascal

Free Pascal розв’язує імпорт C object за іменем символу і потребує, щоб це ім’я було вказане явно, тому кожна Pascal routine, що замінює C entry point, має явну директиву public name. Delphi бере ім’я routine як ім’я символу і не потребує жодної директиви, тому один unit обслуговує обидва компілятори з цими директивами всередині {$IFDEF FPC}. Пастка в тому, що оголошення external 'msvcrt.dll' нічого не задовольняє: воно створює import, але не definition, до якого може прив’язатися лінкований object. Потрібне forwarding body

// Зовнішнє оголошення створює лише імпорт. Жоден лінкований object не може
// прив’язатися до нього.
function crt_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  external 'msvcrt.dll' name 'memcmp';

// Саме Pascal body, опубліковане під точним іменем символу C, є тим,
// до чого фактично прив’язується набір objects.
function jbig2_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  public name 'memcmp';
begin
  Result := crt_memcmp(Buf1, Buf2, Count);
end;

Variadic entry points ламають цей шаблон, бо Pascal wrapper не може передати власні varargs іншому varargs callee. Вихід — перестати бути wrapper-ом: експортувати naked routine під іменем C і виконати tail-jump до справжньої реалізації з argument registers і stack точно в тому стані, у якому їх підготував caller. Шар JPEG 2000 уже так обробляє snprintf та vsnprintf, переходячи до написань msvcrt з префіксом underscore, бо звичайні імена експортує лише UCRT. Ще одне пов’язане обмеження випливає з тієї самої внутрішньої помилки: перейменовані import cells заповнюються із секції initialization через GetModuleHandleA та GetProcAddress, а не зі static initializers, оскільки взяття адреси імпортованої routine в initializer змушує compiler згенерувати 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-а, тому експортований
// symbol виконує tail-jump із frame, залишеним caller-ом без змін.
procedure jbig2_sprintf; assembler; nostackframe;
  public name 'sprintf';
asm
  jmp qword ptr [rip + SPrintfTarget]
end;

Що тепер інакше робить проєкт Free Pascal

Нічого, крім імені unit у uses clause, і deploy-ити більше немає що. Backend реєструє себе у власній initialization section через RegisterJBIG2EncoderBackend, а callers запитують його, як і раніше: через options bit PDF_JBIG2_OPTION_EXTERNAL_ENCODER зі значенням 4 або через аргумент UseExternalEncoder розширених entry points. Запит залишається побажанням, а не гарантією, бо build, у якому unit не підключено, безшумно повертається до native Pascal MMR encoder і створює більші файли замість помилки

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 set, тому на кожній іншій цілі Free Pascal external encode entry point повідомляє про помилку, а роботу виконує native Pascal encoder. І регресія, яка все це контролює, — це порівняння render, а не розміру: обидва encoder-и є lossless на тому самому source, тому їхній output рендериться й порівнюється побайтно, а Lazarus suite проходить 26 із 26 тестів, включно з цим. Порівняння розмірів стиснених потоків нічого б не довело, бо інвертована сторінка стискається приблизно до такого самого розміру, як правильна

Ширший висновок виходить за межі JBIG2. DLL має правильну форму, коли межа справді динамічна — саме для такого випадку існують поверхні інтеграції DLL, ActiveX і dylib; але це неправильна форма, коли вона лише обходить обмеження COFF reader, бо додає файл до кожного installer-а, search path до кожного deployment-а і режим version skew, якого не може мати static linking. Важливий і upstream: спосіб створення bilevel image сильніше впливає на фінальний розмір, ніж сам encoder, а монохромний render областями в Delphi охоплює цю половину pipeline. Покриття toolchain, набори objects для кожного compiler-а та підтримувані targets перелічено на сторінці продукту losLab PDF Developer Library