技术文章

Delphi 对比 FPC:PDFium 构建中 4 个隐秘的 PDF 代码陷阱

相同的 Object Pascal 源码在 Delphi 和 FPC/Lazarus 下的四个行为差异,反复困扰着 PDFium Component 代码:FPC 在 in 成员关系测试完成对其读取之前就会销毁函数结果的临时记录,dcc32 默认关闭范围检查导致越界的数组索引静默读取垃圾数据,只有 Delphi 13 允许在不进行强制类型转换的情况下将匿名 array of Byte 分配给 TBytes,而 Delphi 的 AnsiString 拼接可能会通过隐藏的代码页双向转换来破坏 $80 或以上的字节。每一个差异都会导致测试套件在一个编译器上是绿色的,而在另一个编译器上是红色的,或者更糟糕的是,静默产生错误的结果

如果您是第一次建立双编译器项目,Lazarus 和 FPC 查看器演练涵盖了最顺畅的路径:包、搜索路径以及在屏幕上获取渲染窗口。而本文与教程相反,它是我们在顺畅路径起作用后遇到的麻烦列表,当时 CI 在 FPC 下是绿色的,在 Delphi 下也是绿色的,然后在一侧通过的修改却在另一侧引爆了。下面的每一个陷阱都来自 PDFiumPas测试套件或其演示中的真实失败,我们将提交级别的取证凝聚为了最小的复现、根本原因以及我们标准化采用的修复方案

为什么集合在 FPC 下读回为空,而在 Delphi 中却不为空?

一句话总结:FPC 可能会在读取函数结果记录的某个字段的表达式完成之前,就已经释放了持有该结果的临时变量,因此 X in Func().Issues 可能会针对已释放的集合测试成员关系,而等价的 Delphi 表达式却能正常工作。我们的 PDF/E 一致性测试在第一个版本中遇到了这种情况。验证器返回一个记录,其 Issues 字段是一组违规标志集合,而断言内联了该调用

// 在 FPC 下不可靠:函数结果的临时记录
// 可能会在 'in' 测试读取 Issues 之前被释放
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// 在两个编译器上均可靠:先将结果固定到局部变量
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

内联形式在 FPC 下读取的集合为空,因此预期含有标志的每个断言都失败了,而完全相同的 Delphi 构建却通过了。根本原因在于两个编译器在管理较大表达式中的函数结果临时变量生命周期的方式上存在差异:Delphi 将临时变量的生命周期保留至语句结束,而 FPC 对临时记录的销毁可能会与仍在读取它的集合成员运算符产生竞态条件。我们之前已经在 PDF/A 测试单元的 FlagPresent 辅助函数的注释中记录过完全相同的行为,但从头编写新测试时仍然重新引入了该 bug,这说明坏掉的形式看起来是多么自然。修复是机械的,值得作为一条普适规则采纳:切勿将字段访问或集合测试直接挂接到返回记录的函数调用上;先将结果赋值给局部变量,然后读取字段。这只需多花一行代码,却能消除整整一类依赖于编译器的不稳定性

为什么 Delphi 接受的数组索引在 FPC 中却拒绝编译?

一句话总结:dcc32 会把固定边界数组的越界索引编译进去,且在其默认禁用范围检查的情况下,在运行时静默读取或写入相邻的内存而不会发生任何错误,而 FPC 会在编译时拒绝相同的索引。PDFium Component 将四边形点声明为基于 1 的数组,TQuadrilateralPoint = array [1..4] of TPdfPoint,这与 PDF 的 QuadPoints 项通常的编号方式相匹配。一个使用基于 0 反射循环来填充它的演示在 Delphi 下运行了数月

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 立即产生编译时范围检查错误,而修复索引随后又暴露了库的注释路径中的第二个更深层次的 bug(垃圾读取之前一直在掩盖它),即在四边形点注释文章中剖析的那处 bug。那次事件带来了两个教训。第一,只要数组类型在构建上不是基于 0 的,就优先使用 Low()High() 而不是字面边界。第二,将 FPC 编译,或者至少将启用了 {$R+} 的 Delphi 构建,作为任何新演示或测试的强制性首轮门槛:dcc32 的默认设置不会告诉您这类 bug,而一个能运行的程序并不能证明它是正确的

只有 Delphi 13 接受的 TBytes 赋值

一句话总结:将声明为匿名 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;在 Delphi 12 Athens
                                 // 及更早版本上会出现 E2010
  OrigBytes := TBytes(FBuffer);  // 在所有地方都能编译;字节布局相同,
                                 // 安全的强类型转换
end;

我们在 Delphi 13 上本地开发和测试的一个验证例程中正好发布了这段代码,其中隐式转换被默默接受了。全源码安装程序服务于大量的 Delphi 12 及更老版本的用户,对他们来说该单元根本无法编译。结构性修复要么是上文展示的强类型转换(这是安全的,因为匿名 array of ByteTBytes 共享相同的动态数组布局),或者更好的是,一开始就将该字段声明为命名类型(如 TBytes),这样就根本不会产生转换问题。过程性修复更为重要:在一个最新的工具链上能够编译的结构不能证明您的用户实际运行的旧编译器上的情况,且这类退化在您针对每个支持的版本进行构建之前是隐形的。我们的发布脚本现在正好要在完整的编译器矩阵上编译库,因为本地的 37.0 构建抓不住仅在 13 中拥有的宽容度

在中国 Windows 机器上消失的 AnsiString 字节

一句话总结:在 Delphi 下使用 + 将 $80 或以上的原始字节拼接进 AnsiString 可能会静默将该字节替换为 ? ($3F),因为该表达式会通过系统代码页进行隐式的 AnsiString 到 UnicodeString 再到 AnsiString 的双向转换。我们通过一个 PDF/A 测试发现了这一点,该测试构造了一个包含孤立 $FE 字节的名称(这绝不是合法的 UTF-8 引导字节),以验证验证器是否会根据 ISO 19005-2 条款 6.1.8 标记非有效 UTF-8 的名称

var
  BadName: AnsiString;
begin
  // 在带有双字节系统代码页(在 CP936 上观察到)的 Delphi 上,
  // 拼接会通过 UnicodeString 进行双向转换,而 $FE
  // 不是有效的 CP936 序列,因此会变回为 '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // 安全:使用 ASCII 占位符构建,然后就地修补该字节;
  // 对已安顿好的 AnsiString 进行索引赋值不会导致双向转换
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

在中国 Windows 系统运行代码页 936 上,拼接后的字符串从未包含过 $FE,因此验证器正确地没有报告任何问题,测试变红,表现得像是一个库的 bug。但库实际上没有错:将真正包含 $FE 字节的 PDF 输入到 FPC 测试框架中,得到了预期的标记。损坏发生在使用 Delphi 编译的测试可执行文件计算字符串表达式时,因为 Delphi 的 Unicode 优先字符串模型会通过 UnicodeString 转换混合的 AnsiString 表达式,而 $FE 在 CP936 中不是有效的引导字节,因此双向转换会将其替换掉。要对界限保持诚实:在 CP1252 等单字节西方代码页上,相同的表达式通常可以幸存,这正是该 bug 在大多数开发机器上隐藏,仅在东亚系统或本地化 CI 运行器上暴露的原因。我们采用的规则是:决不通过 AnsiString 拼接来构建包含 $80 或以上高位字节的二进制测试向量;要么像上文一样在字符串定型后就地修补字节,要么一开始就用 TBytes 构建向量

双编译器工作流默认应该检查什么

四个陷阱,一个模式:每个编译器都会告诉您关于 bug 的不同子集。FPC 的编译时范围分析抓住了一个 dcc32 静默运行了数月的越界索引,而 dcc32 的 Unicode 字符串模型暴露了一个纯字节导向型 FPC 构建永远不会触发的代码页依赖性。在实践中的结果是,任何单一的绿色管道单独来看都是不够的。交叉编译不仅是一个可移植性复选框,它还是应用于相同源文件的第二个静态分析器和第二个运行时模型,这与ABI 与内存安全性加固文章中的防御性边界检查的精神是一致的

从这些事件中总结出的现行规则足够简短,便于记忆。在读取字段之前,将函数结果记录固定到局部变量。使用 Low()High() 迭代固定边界数组,并在信任任何新演示之前运行至少一次范围检查或 FPC 构建。显式转换匿名动态数组字段,或者使用命名类型声明它们,并在发布前构建完整的编译器矩阵。完全将原始高字节排除在 AnsiString 拼接之外。一旦它们成为习惯,其中任何一项都不会花费可测量的精力,且每一项都封闭了单一编译器工作流在结构上无法看到的故障模式

在维护 PDFium Component 的过程中发现并修复了所有这四个问题,该组件为 Delphi、C++Builder 和 FPC/Lazarus 提供相同的 Object Pascal 源码,并在所有这些工具链上运行其一致性和回归测试套件,因此本文中的陷阱是由测试而不是由内存守护着的