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

Загрузка нативной библиотеки PDFium на любой платформе

Компонент PDFium находит свою нативную библиотеку через фиксированную упорядоченную цепочку поиска, а не оставляет это системному загрузчику ОС, потому что явное дерево развёртывания — дерево, которое можно отлаживать. На Windows эта цепочка ищет подкаталог Win32 или Win64, который уже поставляет установщик. На других целях она строит имя подкаталога из макросов цели Free Pascal, как <cpu>-<os>, чтобы дерево развёртывания читалось в точности как дерево скомпилированных модулей. Последнее решение внесло ошибку, стоящую всей статьи, потому что причиной была заглавная буква, а симптомом — тишина

Цепочка, по порядку

Четыре места, проверяемые по очереди, затем загрузчик платформы как последнее средство. Первое — предпочитаемая раскладка, каталог DLLs рядом с исполняемым файлом, содержащий по подкаталогу на цель. Второе — альтернативная раскладка с подкаталогом цели прямо рядом с исполняемым файлом. Третье — плоская унаследованная раскладка, библиотека рядом с исполняемым файлом вовсе без подкаталога. Четвёртое, только на Windows, системный каталог, требующий аккуратности, потому что 32-битный процесс должен искать в SysWOW64, а 64-битный — в System32, а на 32-битной Windows первого не существует, и поиску приходится откатываться. Лишь после всего этого загрузчика просят искать самостоятельно

Схема цепочки поиска нативной библиотеки PDFium для Delphi: от подкаталога цели DLLs через альтернативную, плоскую раскладки и системный каталог Windows к загрузчику платформы
Четыре явных места проверяются по порядку, прежде чем загрузчик ОС попросят искать самостоятельно

Шага с системным каталогом вне Windows сознательно нет. Собственный путь поиска загрузчика платформы, управляемый конфигурацией компоновщика времени выполнения и переменными окружения путей библиотек, уже покрывает эту территорию, и дублирование его в Паскале означало бы пере-реализацию правил, различающихся между дистрибутивами. Диагностика сбоев в цепочке Windows отдельно разобрана в материале о развёртывании PDFium DLL и диагностике сбоев загрузки

Откуда берётся имя подкаталога

На Windows это Win32 или Win64, что решается разрядностью работающего процесса, а не операционной системы, ведь именно она определяет, какой двоичный файл можно загрузить. Везде иначе имя строится из макросов цели компилятора, чтобы машина, собирающая для двух архитектур, давала два чётко разделённых дерева и чтобы папка с нативной библиотекой лежала рядом с папкой скомпилированных модулей под тем же именем

function BuildDllSubDir(UseV8: Boolean): string;
begin
{$IFDEF MSWINDOWS}
  if IsWin64 then
    Result := 'Win64'
  else
    Result := 'Win32';
{$ELSE}
  // Макросы компилятора пишут ОС с заглавной ("Linux", "Darwin"), а
  // выходной каталог модулей пакета — нет, поэтому оба совпадают лишь
  // после приведения регистра. На файловой системе, чувствительной
  // к регистру, это различие — весь поиск
  Result := LowerCase({$I %FPCTARGETCPU%} + '-' + {$I %FPCTARGETOS%});
{$ENDIF}
end;

Почему заглавная буква сломала всю цепочку

Макрос компилятора пишет целевую операционную систему с заглавной буквы: Win64, Linux, Darwin. Пакет Lazarus пишет вывод модулей в каталог, названный по собственной переменной цели, которая в нижнем регистре: win64, linux, darwin. Два написания одного и того же, и на Windows заметить это невозможно, где файловая система их не различает

На Linux это два разных каталога. Развёртывание, положившее разделяемую библиотеку в DLLs/x86_64-linux, невидимо для загрузчика, ищущего DLLs/x86_64-Linux, поэтому все четыре явных шага цепочки промахиваются, и код проваливается к тому, чтобы платформенный загрузчик искал сам. Иногда это работает, если библиотека случайно установлена общесистемно, иногда нет, и в любом случае аккуратно устроенное дерево развёртывания не даёт ничего. У отказа нет сообщения об ошибке, потому что ничего не отказало: каждый шаг корректно сообщил, что файла там, где он искал, не было

Как одна заглавная буква в макросе цели FPC ломает поиск PDFium DLL на Linux: загрузчик ищет DLLs/x86_64-Linux, а развёрнутая папка — DLLs/x86_64-linux, что совпадает только на нечувствительной к регистру Windows
Одна цель в двух написаниях совпадает на Windows и молча промахивается на файловой системе, чувствительной к регистру

Пробная программа: скомпилированная и запущенная

Этот класс ошибок нельзя найти чтением, и компиляцией тоже. Обычный приём проверки платформенной ветви, которая никогда не компилируется на машине разработки, — скопировать модуль во временный каталог, переименовать его, заменить платформенную условную компиляцию символом, который никогда не определён, и скомпилировать копию; если она компилируется, раздел uses и сигнатуры вызовов на этом пути хотя бы самосогласованы. Для самодостаточного модуля это хорошо работает

Здесь это не работает. Главный связывающий модуль очень велик и тянет LCL, поэтому его нельзя просто скопировать и скомпилировать с выключенным символом Windows. Вместо этого горстка функций, которых коснулось изменение, была дословно переписана в маленькую самодостаточную программу, и эта программа была запущена. Она напечатала x86_64-Win64, и несовпадение стало видно в одной строке вывода. Компиляция той же программы не сказала бы ничего, потому что строка совершенно валидна; неверно только её значение

program ProbeSubDir;
{$MODE DELPHI}
uses
  SysUtils;
begin
  // Печатайте, а не проверяйте утверждением. Суть — увидеть значение,
  // в которое макрос реально разворачивается на этом тулчейне
  Writeln('raw:    ', {$I %FPCTARGETCPU%}, '-', {$I %FPCTARGETOS%});
  Writeln('folded: ', LowerCase({$I %FPCTARGETCPU%} + '-' +
    {$I %FPCTARGETOS%}));
end.

Общий урок: когда кроссплатформенное изменение касается значения чего-либо, а не его типа, проверка одной компиляцией — не проверка. Напечатайте его. Более широкий набор межкомпиляторных различий между Delphi и Free Pascal собран в статье о межкомпиляторных ловушках Delphi и FPC

Позвольте платформе объяснять собственные сбои загрузки

Ветвь загрузчика под Windows вручную перечисляет причины, по которым загрузка может сорваться, потому что полезные там различия — несовпадение архитектуры, отсутствующая транзитивная зависимость, путь, который не разрешается, — отображаются в коды ошибок, достойные отдельных имён. Вне Windows переносимый модуль загрузчика уже возвращает описательную строку, покрывающую ту же территорию, поэтому ветвь для не-Windows использует её напрямую, а не выводит категории заново из номера ошибки, который на разных системах значит разное

Сопротивление желанию нормализовать эти два механизма в одно сообщение — сознательное. Сбой загрузки — проблема развёртывания, и человеку, читающему сообщение, нужен собственный словарь платформы, чтобы её искать

Коллизия имён, которая уходит в рекурсию

Ещё одна ловушка, маленькая и острая. Переносимый модуль загрузчика экспортирует процедуру UnloadLibrary, и у связывающего модуля есть процедура с тем же именем, которая ведёт собственный учёт перед освобождением дескриптора. Внутри той процедуры неквалифицированный вызов UnloadLibrary разрешается в одноимённую в текущем модуле, которая вызывает саму себя. Исправление — квалифицировать вызов именем модуля

Это тот же рисунок, что у проблем затенения идентификаторов, доминирующих в портах Free Pascal вообще: модуль Windows экспортирует целочисленные функции минимума и максимума, затеняющие вещественные, и тип синхронизации, затеняющий одноимённый класс, и в каждом случае разрешение зависит от порядка раздела uses. Квалификация места вызова — исправление, не зависящее от того, сохранит ли кто-то этот порядок позже

Коллизия имён UnloadLibrary в компоненте PDFium: неквалифицированный вызов в связывающем модуле Delphi уходит в рекурсию, а вызов, квалифицированный модулем, достигает переносимого модуля загрузчика и освобождает дескриптор
Квалификация места вызова ведёт освобождение через модуль загрузчика вместо рекурсии в связывающий модуль

Контрольный список развёртывания

Три вещи объясняют большинство сбоев загрузки, когда арифметика путей верна. Архитектура должна совпадать с процессом, а не с машиной, поэтому 32-битному приложению на 64-битной Windows нужен 32-битный двоичный файл. Сборка с V8 имеет другое имя файла, поэтому развёртывание, смешивающее их, будет выглядеть правильно и не загрузит ничего. И только один вариант может жить в системном каталоге единовременно, что является хорошим поводом предпочесть явную раскладку подкаталогов установке чего-либо общесистемно

Для Lazarus конкретно положите нативную библиотеку в DLLs/<cpu>-<os> в нижнем регистре, рядом с исполняемым файлом, и её найдёт первый шаг цепочки на каждой цели. Пример программы просмотра, упражняющий это на Lazarus, описан в статье о просмотрщике Lazarus и FPC, а текущая поддержка платформ перечислена на странице продукта PDFium Delphi component