Привязка Pascal к библиотеке C выглядит как обычный код на Pascal. Вы вызываете метод, получаете запись и освобождаете выделенные ресурсы. Проблема в том, что PDFium — это библиотека на C и C++ со своим соглашением о вызовах, своей разрядностью целых чисел и своими правилами того, кто владеет памятью и кто её освобождает. Ничто из этого само не переходит границу языков. Каждое из этих условий приходится заново объявлять вручную в декларациях Pascal, и одно неверное слово превращает внешне корректный вызов в повреждение стека, усечённое смещение или двойное освобождение памяти. Для каждого контракта ниже показан классический способ ошибиться и тот вариант, который вместо него использует привязка PDFium Component. Их стоит разобрать подробно, потому что они не специфичны для этой привязки. Это постоянные опасности обёртывания любого C API в Delphi или Lazarus
Директива cdecl является частью типа функции, а не декорацией
Библиотека PDFium скомпилирована на C. На платформе Win32 ее экспортируемые функции и вызываемые обратные вызовы используют соглашение cdecl. При соглашении cdecl вызывающая сторона очищает стек после возврата управления. Стандартным соглашением в Delphi является register, а в некоторых библиотеках C для Win32 используется stdcall, при котором стек очищает вызываемая функция. Когда структура передает указатель на функцию в PDFium, а вы забываете указать cdecl в типе этого указателя, стороны расходятся во мнении о том, кто должен корректировать указатель стека. В итоге это делают либо оба модуля, либо никто из них, и при каждом вызове указатель стека смещается на размер аргументов
Эту ошибку трудно обнаружить, так как сбой проявляется не сразу. Поврежденный вызов завершается успешно и выглядит корректно. Рассогласование обнаруживается позже, в другой функции, фрейм которой оказывается на смещённом указателе стека. Это выражается в неверном чтении данных, неправильном адресе возврата или аварии с трассировкой стека, которая не указывает на проблемный обратный вызов. Заполнение форм является классическим местом проявления этой ошибки, поскольку этот интерфейс представляет собой запись с обратными вызовами. Один из них, FFI_OpenFile, передает в PDFium функцию для открытия внешнего файла, объявленную как function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Важно сохранить завершающее cdecl. Без него код скомпилируется и запустится, но завершится сбоем при вызове этой функции со стороны PDFium. Соглашение принадлежит самому типу функции. Это обязательное требование, и компилятор не выдаст предупреждение при его отсутствии, так как обычный тип функции является допустимым типом Pascal. Единственная защита — относиться к соглашению о вызовах как к обязательному элементу каждой импортируемой сигнатуры и каждого передаваемого наружу обратного вызова
Тип size_t имеет ширину указателя, что на FPC Win64 означает 64 бита
Вторая опасность — несоответствие разрядности целых чисел, которое проявляется только на одной целевой платформе. В языке C тип size_t определен так, чтобы вмещать размер любого объекта, что на 64-битной платформе означает 64-битное целое число без знака. Интерфейсы прогрессивной загрузки PDFium работают со смещениями в байтах типа size_t. Запись поставщика доступности FX_FILEAVAIL содержит обратный вызов IsDataAvail, который PDFium вызывает со смещением и размером, и обратный вызов AddSegment записи FX_DOWNLOADHINTS принимает те же параметры. Оба параметра имеют тип size_t
IsDataAvail = function(
pThis : PFX_FILEAVAIL;
offset, size: size_t): FPDF_BOOL; cdecl;
AddSegment = procedure(
pThis : PFX_DOWNLOADHINTS;
offset, size: size_t); cdecl;
Если объявить эти смещения как 32-битный тип, привязка будет работать на Win32 и на Delphi Win64, но незаметно сломается на FPC и Lazarus Win64. Причина скрыта в деталях. На платформе FPC Win64 тип NativeUInt является полноценным 64-битным типом ширины указателя, и size_t сопоставлен с ним. В разделе типов привязки есть комментарий, предупреждающий против переопределения NativeUInt на FPC, поскольку уменьшение его разрядности до 32 бит сведет тип size_t к 32 битам и повредит каждый параметр size_t, передаваемый в библиотеку или записываемый ею. При передаче 64-битного смещения в 32-битный параметр теряется его старшая часть. Для небольшого файла все смещения умещаются в 32 бита, и проблемы не возникает. Для большого файла, как только смещение пересекает границу в четыре гигабайта, усеченное значение указывает на неверный адрес, из-за чего PDFium запрашивает не тот диапазон байтов, и прогрессивная загрузка зависает или считывает некорректные данные. Ошибка остается невидимой до тех пор, пока файл не станет достаточно большим, а целевой платформой не окажется та, где тип size_t действительно расширился
Исключение Pascal никогда не должно проходить через фрейм C
Третий класс дефектов связан с моделью исключений, которая отсутствует в C. Когда PDFium вызывает один из ваших обратных вызовов, код Pascal выполняется внутри стека фреймов C и C++, которые ничего не знают об обработке исключений Delphi. Если ваш обратный вызов вызывает исключение и позволяет ему распространяться дальше, стек развертывается через фреймы, не предназначенные для этого. Очистка ресурсов со стороны PDFium не выполняется, внутреннее состояние остается частично обновленным, а процесс переходит в непредсказуемое состояние. Контракт для этих обратных вызовов предусматривает возврат кода ошибки, а не генерацию исключения
Два обратных вызова делают это наглядным. FPDF_FILEWRITE служит приемником, в который PDFium записывает сохраненный документ, а FPDF_FILEACCESS является источником для чтения входного документа. Оба реализованы поверх Delphi TStream и могут завершиться ошибкой, характерной для потоков: переполнение диска, закрытие потока или чтение за пределами данных. Обратный вызов записи оборачивает работу с потоком и преобразует любой сбой в код ошибки PDFium, не позволяя исключению выйти наружу
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium считает любой результат, отличный от 1, ошибкой записи. Исключение Pascal
// не должно раскручиваться через этот фрейм cdecl/C++, поэтому перехватываем его и сообщаем
// об ошибке вместо этого.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
Сторона чтения работает аналогично: неудачное чтение возвращает ноль в соответствии с контрактом FPDF_FILEACCESS вместо вызова исключения через границу ABI. Пустой блок except без повторного вызова выглядит некорректным для программиста на Pascal, приученного никогда не замалчивать ошибки. Однако на границе ABI это правильный подход, так как единственным безопасным значением для вызывающей стороны C является код состояния, который она умеет обрабатывать. Ошибка все равно передается, но через возвращаемое значение, а вызывающий код над библиотекой преобразует ее в EPdfError, когда управление возвращается на сторону Pascal
Двойное освобождение скрывается на пути обработки ошибок
Четвёртая опасность касается владения ресурсами. Дескриптор документа PDFium открывается библиотекой и должен закрываться ровно один раз с помощью FPDF_CloseDocument. Опасность заключается в путях обработки ошибок, освобождающих дескриптор, которым также владеет другой механизм очистки. Представьте процедуру, которая создает объект-обертку, присваивает ему только что открытый дескриптор документа, а затем выполняет дополнительную настройку, которая может завершиться ошибкой. Если настройка вызывает исключение, обработчик с ранним возвратом, вызывающий FPDF_CloseDocument для исходного дескриптора, закроет его, а собственный деструктор объекта-обертки попытается закрыть его снова при уничтожении объекта. Дескриптор освобождается дважды, что ведет к неопределенному поведению и аварийному завершению работы
Этот шаблон важнее всего на пути импорта в стиле спуска полос, создающем TPdf вокруг уже открытого дескриптора. Лекарство состоит в том, чтобы сделать передачу владения единственным источником истины. Как только дескриптор присваивается полю обертки, обертка принимает владение им, и единственным действием по очистке на пути ошибки является освобождение обертки. Деструктор обертки вызывает FPDF_CloseDocument автоматически, поэтому второе явное закрытие привело бы к двойному освобождению одного документа. Обработчик ошибок ниже освобождает объект и повторно вызывает исключение, обеспечивая ровно один путь к закрытию ресурса
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result теперь владеет дескриптором
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free закрывает дескриптор. Второй вызов FPDF_CloseDocument(NewDoc)
// здесь привёл бы к двойному освобождению одного и того же PDFium-документа.
Result.Free;
raise;
end;
Управляемые записи и динамический импорт библиотеки требуют явного освобождения ресурсов
Последний класс дефектов связан с памятью, которой компилятор управляет от вашего имени, и которую привычные методы C могут незаметно повредить. Многие вспомогательные функции этой привязки возвращают запись, содержащую WideString или динамический массив. Эти поля используют подсчет ссылок, и компилятор создает скрытый код для отслеживания их количества. Привычка из C побуждает очищать новую запись с помощью FillChar(Result, SizeOf(Result), 0). Это записывает нули поверх управляемой ссылки внутри записи без предварительного уменьшения счетчика. Компилятор повторно использует одну скрытую временную переменную для результата функции между итерациями цикла, поэтому на второй итерации FillChar перезаписывает активный указатель на строку, который не был освобожден, вызывая утечку памяти. Вызов такой функции в цикле для тысячи аннотаций приведет к утечке тысячи строк
Решение состоит в том, чтобы позволить языку очистить запись стандартным способом с помощью Default(T), который корректно освобождает любые управляемые поля перед обнулением памяти
// Default() вместо FillChar: компилятор переиспользует один скрытый временный объект
// внутри результата функции на итерациях цикла, поэтому FillChar обнулил бы живые
// указатели WideString, не освобождая их.
Result := Default(TPdfAnnotation);
Схожая проблема владения ресурсами возникает на этапе загрузки библиотеки. Эта привязка разрешает несколько сотен указателей на функции из библиотеки PDFium DLL с помощью GetProcAddress после вызова LoadLibrary. Если одна из обязательных функций отсутствует, частично связанное состояние становится опасным: часть указателей валидна, остальные равны nil или устарели, а любой последующий вызов через один из них приведет к переходу в модуль, который уже может быть выгружен. Привязка решает эту проблему путем выгрузки библиотеки и запуска полной очистки ClearAllBindings, сбрасывающей каждый импортированный указатель в nil при ошибке разрешения экспорта. Благодаря этому указатели не ссылаются на выгруженный модуль, а последующий вызов завершается ошибкой проверки на nil вместо выполнения произвольного кода в освобожденной памяти
Обертка выполняет роль места, где вручную переопределяются четыре контракта
Ни одна из этих пяти опасностей не является экзотической. Это предсказуемые сбои тонкого слоя Pascal поверх C API, и они возникают потому, что именно на этом слое необходимо повторно объявлять четыре отдельных контракта. Соглашение о вызовах должно быть явно указано как cdecl для каждого обратного вызова. Разрядность целого числа должна соответствовать size_t на целевой платформе, где этот тип расширяется. Модель исключений должна преобразовываться в коды возврата для всех обратных вызовов, выходящих за рамки Pascal. Владение каждым дескриптором и управляемым полем должно быть четко определено и соблюдаться на всех путях выполнения, включая обработку ошибок. Ошибка в любом из этих пунктов приводит к сбою, симптомы которого проявляются далеко от его причины, что делает этот класс ошибок сложным в отладке. Ценность здесь не в каком-то отдельном правиле, а в том, чтобы проверять каждое из этих требований как собственную дисциплину по всей привязке
Если вы хотите увидеть привязку в действии, методы кэширования рендеринга и масштабирования описаны в нашей заметке о производительности кэша рендеринга и зума, а руководство по кросс-компиляции в статье о создании программы просмотра на Lazarus и FPC наглядно показывает, где именно имеет значение поведение size_t на Win64. Оба решения базируются на обеспечении безопасности памяти и совместимости ABI, поставляемых в составе компонента PDFium для Delphi, Lazarus и C++Builder вместе с API для рендеринга, извлечения текста и работы с формами