Техническая статья

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

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

Почему DLL казалась единственным вариантом?

DLL казалась единственным вариантом, потому что три маршрута линковки проваливались тремя не связанными друг с другом способами, а ни один ключ компилятора до этих проблем не добирался. Внутренний линкер напрямую отвергает ассоциативные секции COMDAT. Внешняя линковка через поставляемые binutils падает внутри сборки мусора секций, которую Free Pascal безусловно передаёт для 64-битной цели Windows. Более новые binutils вообще не умеют обрабатывать скрипт линковки Free Pascal. Пересборка части C++ другой цепочкой инструментов лишь меняет один отказ на другой, потому что инстанцирование шаблонов и inline по самой природе порождает слабые внешние символы, а Free Pascal сообщает о них как о Unsupported COFF symbol type 105. Ни одно из этих наблюдений не было ошибочным, и прежний разбор backend-ов кодировщика JBIG2 и линкера Free Pascal по-прежнему воспроизводит каждый тупик. Ошибочным было предположение о месте, где может находиться исправление. Каждая попытка проходила через компилятор или линкер, а ни один из них не может изменить уже записанное в объектный файл. Проблемой всё это время был объектный файл. ObjConv читает COFF и записывает COFF, а для каждой конструкции, на которой спотыкается Free Pascal, существует механический эквивалент, который он принимает

Ошибка, которая никогда не называет причину

Внутренний линкер Free Pascal реализует pick-any COMDAT лишь наполовину, и именно эта недореализованность сложнее всего поддаётся диагностике. Дублирующиеся определения он действительно сворачивает, как того требует формат. Но TExeOutput.RemoveUnreferencedSections при пометке секций как используемых перенаправляет через exesymbol к победившему определению, тогда как TCoffexeoutput.DoRelocationFixup напрямую читает objreloc.symbol.objsection. Когда используемая секция ссылается на символ, который её собственный объект определяет в копии, проигравшей сворачивание, два прохода видят разные секции, и линковка останавливается с ошибкой Internal error 200603061

Сравните это с двумя ограничениями по обе стороны от неё. Unsupported COFF symbol type 105 означает слабый внешний символ. Associative or exact match COMDAT sections are not yet supported означает ассоциативный COMDAT и даже называет проблемный символ. Internal error 200603061 не сообщает вообще ничего: ни имени символа, ни имени секции, ни имени файла, ни этапа. Это также обычный случай, а не редкий крайний сценарий, потому что MSVC помещает каждый строковый литерал и каждую inline- или template-инстанциацию в pick-any COMDAT, а в 186 объектах этого кодировщика линкер выполнил 2656 свёрток. Сборка с /Gy- убирает обычные функции из COMDAT-секций отдельных функций, но оставляет строковые литералы и инстанциации шаблонов ровно там, где они были

Почему заглушки символов CRT всегда создают впечатление, будто сборку сломала последняя?

Потому что линкер доходит до прохода исправлений только после разрешения всех символов. Пока чего-либо не хватает, выполнение рано заканчивается сообщением Undefined symbol, и проблема COMDAT не получает шанса проявиться. Когда добавлена последняя заглушка времени выполнения C, линкер продвигается на один этап — прямо в internal error 200603061. Поэтому наблюдаемый симптом систематически вводит в заблуждение: при последовательном добавлении тел Pascal для упомянутых символов C всегда кажется, что сборку сломало последнее добавление или что пройден некоторый порог примерно в сотню заглушек. Неверно и то и другое. Какой символ добавили последним и сколько их добавили всего — неважно, потому что ошибка была скрыта уже в первом объекте и стала достижимой только после успешного разрешения. Когда линкер меняет жалобу после исправления проблемы, не связанной с ним, сначала спросите себя, продвинулись ли вы на следующий этап, а не вызвали ли регрессию

Исправление — один проход ObjConv, а не флаг компилятора

Вся коррекция сводится к одной команде постобработки, запускаемой для каждого скомпилированного объекта: ObjConv -fcoff64 -xw -xc -xn -np:__imp_:pdflibimp_. Три из этих параметров были добавлены специально для этой работы. -xw разрешает символы IMAGE_SYM_CLASS_WEAK_EXTERNAL в обычные внешние символы. -xn нормализует символы IMAGE_SYM_CLASS_NULL, например _fltused, о которых Free Pascal сообщает как о Unsupported COFF symbol type 0. Основную работу выполняет -xc: он понижает каждую секцию COMDAT до обычной секции и делает определяемые ею символы статическими. Ошибка исчезает вместе с самим решением, которое к ней приводит: без секций COMDAT нет свёртки, нет победившей копии, к которой один проход может перенаправить ссылку, а другой — не найти её, и вместе с этим исчезают ассоциативные unwind-секции .pdata и .xdata. Цена реальна, но невелика: копии, которые можно было законно объединить, теперь сохраняются каждая отдельно

Переименование с префиксом -np:__imp_:pdflibimp_ устраняет отдельное столкновение. MSVC вызывает импортированные API Win32 через ячейки косвенной адресации с именами __imp_*, Free Pascal резервирует этот префикс для собственной импортной механики, и явное определение одного из таких имён вызывает ту же внутреннюю ошибку 200603061. Переименование ячеек позволяет стороне Pascal публиковать их как обычные переменные и заполнять во время выполнения. Сами объекты компилируются с включённым флагом статической линковки, /GS- /Gs999999 /Gy- /Zl /GR- /EHs-c-, и с отключёнными image codec, поэтому мёртвым путям файлового ввода-вывода и кодеков требуется гораздо меньше заглушек только для линковки. Они попадают в Lib\thirdparty\Win64f, а маршрут Delphi и C++Builder продолжает линковать собственный набор Win64x без изменений, что и нужно для исправления переносимости, ограниченного одной цепочкой инструментов

Что ещё должна экспортировать сторона Pascal

Free Pascal разрешает импорт C-объекта по имени символа, поэтому имя должно быть указано явно: каждая процедура Pascal, заменяющая точку входа C, получает клаузу public name. Delphi принимает имя процедуры за имя символа и такая клауза ему не нужна, поэтому один модуль обслуживает оба компилятора, а клаузы находятся под {$IFDEF FPC}. Ловушка в том, что декларация external 'msvcrt.dll' не удовлетворяет ни одной ссылке: она создаёт импорт, но не определение, к которому может привязаться связанный объект. Тело-переадресация должно существовать

// Внешняя декларация создаёт только импорт. Ни один связанный объект не может
// привязаться к ней.
function crt_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  external 'msvcrt.dll' name 'memcmp';

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

Вариативные точки входа нарушают этот шаблон, потому что обёртка Pascal не может передать собственные varargs другой функции с varargs. Выход — перестать быть обёрткой: экспортировать naked-процедуру под именем C и выполнить tail-jump к настоящей реализации, оставив регистры аргументов и стек ровно в том виде, в котором их подготовил вызывающий код. Слой JPEG 2000 уже обрабатывает snprintf и vsnprintf таким способом, переходя к именам msvcrt с подчёркиванием, потому что обычные имена экспортирует только UCRT. Ещё одно родственное ограничение связано с той же внутренней ошибкой: переименованные импортные ячейки заполняются из секции initialization через GetModuleHandleA и GetProcAddress, а не статическими инициализаторами, поскольку взятие адреса импортированной процедуры в инициализаторе заставляет компилятор сгенерировать fixup, который он не умеет обрабатывать, и снова заканчивается 200603061

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

var
  SPrintfTarget: Pointer = @crt_sprintf;

// Varargs нельзя передать из обёртки Pascal, поэтому экспортируемый
// символ выполняет tail-jump, оставляя кадр ровно таким, каким его подготовил вызывающий код.
procedure jbig2_sprintf; assembler; nostackframe;
  public name 'sprintf';
asm
  jmp qword ptr [rip + SPrintfTarget]
end;

Что теперь делает проект Free Pascal иначе

Ничего, кроме имени модуля в списке uses, и развёртывать больше нечего. Backend регистрируется из собственной секции инициализации через RegisterJBIG2EncoderBackend, а вызывающий код запрашивает его точно так же, как раньше: битом опций PDF_JBIG2_OPTION_EXTERNAL_ENCODER со значением 4 или аргументом UseExternalEncoder расширенных точек входа. Запрос остаётся предпочтением, а не гарантией: если модуль исключён из сборки, происходит тихий возврат к нативному Pascal-кодировщику MMR, который создаёт более крупные файлы вместо ошибки

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
      // Интерполировать, извлечь символы, использовать внешний кодировщик, пропустить чёрные точки,
      // размер чёрной точки, уровень потерь
      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, поэтому на любой другой цели Free Pascal внешняя точка входа кодирования сообщает об ошибке, а работу выполняет нативный Pascal-кодировщик. И регрессия, которая всё это контролирует, сравнивает результат рендеринга, а не размер: оба кодировщика работают без потерь на одном источнике, поэтому их вывод рендерится и сравнивается побайтно, а набор Lazarus проходит все 26 из 26 тестов, включая этот. Сравнение размеров сжатых потоков ничего бы не доказало, потому что инвертированная страница сжимается примерно до того же размера, что и правильная

Более общий вывод применим не только к JBIG2. DLL — правильная форма, когда граница действительно динамическая, и именно такой случай обслуживают поверхности интеграции DLL, ActiveX и dylib; но это неправильная форма, когда она лишь служит обходом для COFF-ридера, потому что добавляет файл в каждый установщик, путь поиска в каждое развёртывание и режим отказа из-за несовпадения версий, которого не бывает при статической линковке. Важен и upstream: способ формирования полутонового изображения сильнее влияет на итоговый размер, чем сам кодировщик, а монохромный рендеринг по областям в Delphi рассматривает эту часть конвейера. Охват цепочек инструментов, наборы объектов для каждого компилятора и поддерживаемые цели перечислены на странице продукта losLab PDF Developer Library