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

Delphi против FPC: 4 скрытые ловушки в кодовой базе PDFium

Один и тот же исходный код на Object Pascal может вести себя по-разному в Delphi и FPC/Lazarus. Существует четыре ключевых архитектурных различия, которые регулярно затрагивают кодовую базу PDFium Component: FPC уничтожает временные переменные записей, возвращаемых функциями, до завершения проверки членства in; компилятор dcc32 по умолчанию собирает код без проверки диапазонов, из-за чего выход индекса за границы массива молча читает мусор; только в Delphi 13 разрешено присваивать анонимный тип array of Byte переменной TBytes без явного приведения; а конкатенация AnsiString в Delphi может искажать байты со значениями от $80 и выше из-за скрытого преобразования кодовых страниц. Каждая из этих ловушек приводит к тому, что тесты успешно проходят в одном компиляторе и завершаются с ошибками (или, что хуже, работают неверно без ошибок) в другом

Если вы впервые настраиваете кросс-компиляцию для двух компиляторов, руководство по созданию приложения просмотра в Lazarus и FPC описывает стандартный рабочий процесс: пакеты, пути поиска и вывод окна рендеринга на экран. Эта статья — прямая противоположность обычному туториалу. Это список технических проблем, с которыми мы столкнулись после прохождения стандартного пути, когда CI-тесты успешно завершались на FPC, успешно завершались на Delphi, но затем изменение, прошедшее на одной стороне, полностью ломало сборку на другой. Каждая из описанных ниже проблем произошла в реальных тестах или демонстрационных проектах PDFiumPas

Почему проверка множества возвращает ложь на FPC, но работает в Delphi?

Коротко: компилятор FPC может финализировать временную переменную, хранящую запись-результат функции, до завершения оценки выражения, считывающего поле этой записи. Поэтому выражение X in Func().Issues на FPC проверяет вхождение элемента в уже уничтоженное множество, тогда как в Delphi аналогичный код работает корректно. Наши тесты соответствия PDF/E столкнулись с этим в первой же версии: метод валидатора возвращает запись, поле Issues которой представляет собой множество флагов нарушений, а код проверки был написан в одну строку

Диаграмма: Delphi держит временную запись-результат функции PDFium живой до конца оператора, тогда как FPC освобождает её до того, как оператор in прочитает набор Issues, поэтому утверждение проходит только под Delphi
Delphi держит временную запись результата функции живой до конца оператора, тогда как FPC может освободить её прежде, чем оператор in прочтёт множество Issues
// Ненадёжно под FPC: временная запись результата функции
// может быть освобождена до того, как проверка 'in' прочитает Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Надёжно в обоих компиляторах: сначала закрепите результат в локальной переменной
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Встраиваемая форма проверки на FPC считывала множество как пустое, из-за чего все проверки AssertionError завершались сбоем, в то время как сборка на Delphi проходила успешно. Первопричина кроется в различиях управления жизненным циклом временных результатов функций внутри больших выражений: Delphi сохраняет временную переменную живой до конца выполнения текущего оператора, тогда как в FPC уничтожение временной записи может опередить оператор проверки вхождения во множество, который все еще считывает данные. Ранее мы уже сталкивались с аналогичным поведением и оставили комментарий у вспомогательного метода FlagPresent в модуле тестирования PDF/A, но затем снова допустили эту ошибку при написании новых тестов с нуля — настолько естественным кажется некорректный вариант. Решение простое и его стоит сделать общим правилом: никогда не обращайтесь к полям и не проверяйте вхождение во множество непосредственно из функции, возвращающей запись; сначала присвойте результат локальной переменной, а затем считывайте ее свойства

Почему Delphi пропускает индекс массива, который FPC отказывается компилировать?

Коротко: компилятор dcc32 собирает код с заведомо неверным индексом массива с фиксированными границами и при отключенной по умолчанию проверке диапазонов молча читает или пишет данные в соседние области памяти во время выполнения программы, тогда как FPC блокирует этот код еще на этапе компиляции. В компоненте PDFium Component четырехточечные полигоны (quad points) объявлены как массив с базой 1: TQuadrilateralPoint = array [1..4] of TPdfPoint (согласно нумерации записей QuadPoints в спецификации PDF). Демонстрационный пример, заполняющий его в цикле с 0 до 3, успешно работал в Delphi на протяжении месяцев

Диаграмма PDFium Component: массив quad-points PDF с основанием 1, где индекс 0 под dcc32 молча трогает соседнее поле записи, а FPC останавливает тот же цикл ошибкой проверки диапазона на этапе компиляции
При выключенной проверке границ dcc32 индекс 0 молча приземляется на соседнее поле записи, тогда как FPC отвергает тот же цикл на этапе компиляции
var
  I: Integer;
begin
  for I := 0 to 3 do                       // неверно: массив имеет диапазон [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 по умолчанию: компилируется, индекс 0
                                           // молча затрагивает соседнюю память
                                           // FPC: ошибка проверки диапазона на этапе компиляции
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // корректно в обоих компиляторах
end;

Успешная работа в Delphi была ложноположительной: при отключенной проверке диапазонов (стандартная настройка dcc32) индекс 0 ссылался на область памяти перед массивом внутри записи, и пример внешне работал без сбоев. Портирование того же примера в Lazarus сразу вызвало ошибку компиляции FPC (range check error), а исправление индекса выявило скрытую логическую ошибку в обработке аннотаций, которую ранее маскировало чтение неверных данных (эта деталь разобрана в статье о создании текстовых аннотаций с QuadPoints). Мы извлекли два вывода: во-первых, всегда используйте Low() и High() вместо жестко прописанных констант, если массив по своей природе не начинается с 0; во-вторых, всегда выполняйте тестовую компиляцию в FPC или как минимум сборку в Delphi с включенной опцией {$R+} — стандартные настройки dcc32 не сообщат вам о подобных ошибках, а факт запуска программы не гарантирует ее корректность

Проблема присвоения TBytes, компилируемая только в Delphi 13

Коротко: присвоение поля, объявленного как анонимный тип array of Byte, переменной строгого типа TBytes успешно собирается в Delphi 13 (версия компилятора 37.0), но вызывает ошибку сборки в Delphi 12 Athens и во всех более ранних версиях: E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Эта проблема связана не столько с различиями Delphi и FPC, сколько с эволюцией самого компилятора Delphi: новая версия молча допускает конструкцию, которую все предыдущие версии считали некорректной

type
  TValidator = class
  private
    FBuffer: array of Byte;   // анонимный тип динамического массива
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // только в Delphi 13; E2010 в Delphi 12
                                 // Athens и ранее
  OrigBytes := TBytes(FBuffer);  // компилируется везде; та же раскладка байтов,
                                 // безопасное жёсткое приведение
end;

Мы написали этот код в процедуре валидации, разрабатывая и проверяя его локально в Delphi 13, где неявное приведение прошло незамеченным. Однако инсталлятор исходного кода поставляется в том числе пользователям с Delphi 12 и более старыми версиями, и у них модуль просто перестал собираться. Решением является либо явное приведение типов (поскольку анонимный array of Byte и TBytes имеют идентичное представление динамического массива в памяти), либо, что предпочтительнее, изначальное объявление поля с типом TBytes для исключения преобразований. Вывод на будущее: успешная сборка на новейшем компиляторе не гарантирует совместимость с предыдущими версиями, которые используют ваши клиенты. Наши скрипты сборки теперь компилируют библиотеку во всех поддерживаемых версиях компиляторов перед релизом

Искажение байтов в AnsiString на китайской версии Windows

Коротко: конкатенация символа со значением байта от $80 и выше с помощью оператора + в тип AnsiString в Delphi может молча заменить этот символ знаком вопроса ? ($3F), так как выражение претерпевает неявное преобразование AnsiString -> UnicodeString -> AnsiString через системную кодовую страницу. Мы обнаружили это в тесте PDF/A, который генерирует имя файла с изолированным байтом $FE (заведомо некорректный байт для UTF-8) с целью проверки работы валидатора имен на соответствие стандарту ISO 19005-2 (раздел 6.1.8)

Диаграмма кругового пути AnsiString → UnicodeString, заменяющего сырой байт $FE вопросительным знаком на китайской машине Windows с CP936, рядом с безопасной правкой байта на месте в Delphi
Неявный обход UnicodeString «туда-обратно» заменяет несмаппируемый байт $FE на $3F под CP936, поэтому безопасный путь латает байт на месте
var
  BadName: AnsiString;
begin
  // В Delphi с многобайтовой системной кодовой страницей (наблюдалось на CP936),
  // конкатенация проходит через UnicodeString и $FE, что
  // не является допустимой последовательностью CP936 и возвращается как '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Безопасно: соберите с ASCII-заполнителем, затем исправьте байт на месте;
  // присваивание по индексу в устоявшемся AnsiString не проходит через округление
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

На китайской версии Windows с системной кодовой страницей CP936 конкатенированная строка вообще не содержала байта $FE, из-за чего библиотека не регистрировала ошибку, а тест завершался сбоем (что выглядело как баг в самой библиотеке). Сама библиотека работала верно: тест в FPC, передававший файл с байтом $FE напрямую, успешно регистрировал ошибку. Искажение происходило в самом тестовом приложении Delphi при вычислении выражения: Unicode-ориентированная модель строк Delphi преобразует смешанные выражения AnsiString через промежуточный UnicodeString, а так как $FE не является допустимым байтом в CP936, преобразование заменило его символом '?' ($3F). Эта ошибка коварна тем, что на западных кодовых страницах (например, CP1252) выражение отрабатывает без потерь, из-за чего баг годами маскируется на компьютерах разработчиков и проявляется только на азиатских версиях Windows или на локализованных CI-серверах. Принятое нами правило: никогда не собирайте тестовые бинарные последовательности с байтами от $80 и выше путем конкатенации строк; либо записывайте байты по индексам в готовую строку, либо формируйте массив в типе TBytes изначально

Что должна включать проверка при кросс-компиляции

Четыре разные ловушки иллюстрируют одну суть: каждый компилятор помогает найти свой уникальный набор багов. Анализ выходов за границы в FPC выявил неверный индекс, успешно собиравшийся в dcc32, а модель строк Unicode в dcc32 обнаружила зависимость от системной локали, незаметную в чисто байтовом представлении FPC. Кросс-компиляция — это не просто галочка в списке совместимости, а дополнительное средство статического и динамического анализа кода, аналогичное проверкам границ в материале по обеспечению безопасности памяти и ABI

Правила по итогам этих инцидентов просты. Всегда сохраняйте результаты функций, возвращающих записи, в локальных переменных перед чтением полей. Обходите массивы фиксированной длины через Low() и High() и проверяйте код сборкой с контролем диапазонов или в FPC. Явно приводите анонимные типы динамических массивов или сразу объявляйте их именованными типами. Никогда не используйте байты от $80 и выше в конкатенации строк AnsiString. Эти привычки не требуют усилий при написании кода, но защищают от целого класса ошибок, которые невозможно обнаружить в рамках одного компилятора

Все четыре проблемы были обнаружены и устранены в процессе поддержки компонента PDFium Component, поставляющего один исходный код для Delphi, C++Builder и FPC/Lazarus и регулярно тестирующего сборки на всех целевых платформах