技术文章

强化 PDFium Component 绑定:ABI 与内存安全性

包着 C 库的 Pascal 绑定读起来就像普通的 Pascal 代码:调用一个方法,拿回一个记录,释放自己分配的内存。麻烦在于 PDFium 是一个 C/C++ 库,有自己的一套调用约定、整数宽度,以及谁拥有内存、谁负责释放的规则。这些规矩不会自己跨过语言边界——每一条契约都得在 Pascal 声明里手工重申,而一个词写错,一次看起来正常的调用就可能变成栈损坏、偏移量截断或双重释放。下面每条契约都会给出典型的踩坑方式,以及 PDFium Component 绑定改用的写法。值得逐条过一遍,因为它们不是这个绑定特有的问题,而是在 Delphi 或 Lazarus 里封装任何 C API 时长期存在的隐患

cdecl 是函数类型的一部分,而不是修饰物

PDFium 是已编译的 C。在 Win32 上,它的导出项,更重要的是它调用的回调函数,使用的是 cdecl 调用约定。在 cdecl 下,调用者在调用返回后清理堆栈。Delphi 原生的默认约定是 register,而某些库中回调函数的 Win32 C 标准是 stdcall(其中由被调用者进行清理)。当一个结构向 PDFium 传递一个函数指针而你忘记了该指针类型上的 cdecl 时,双方就会在谁调整堆栈指针的问题上产生分歧。要么双方都调整,要么都不调整,堆栈指针在每次调用时都会偏离参数的大小

这类错误难找,是因为破坏是非局部的:出了错的调用照样返回,看起来一切正常。错位要到之后才显现——某个毫不相干的函数,它的栈帧恰好落在偏了几字节的栈指针上,于是表现为一次野读、一个错误的返回地址,或者一次崩溃,而回溯指向的位置离你真正写错的回调相距甚远。表单填充是这种问题最常见的发源地,因为表单填充接口本身就是一条塞满回调的记录,PDFium 会反过来调用它们。其中 FFI_OpenFile 交给 PDFium 一个用于打开外部文件的函数,声明为 function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl——值得照抄的正是末尾那个 cdecl。少写它,代码照样编译、照样链接、照样运行,直到 PDFium 真正调用这个函数为止。调用约定属于函数类型本身,不是可有可无的点缀;少了它编译器也不会报警,因为普通的函数类型是完全合法的 Pascal 类型。唯一的防线,是把调用约定当成每一条导入签名和每一个向外传递的回调的必填字段来对待

示意图:对比保持 PDFium 调用方栈平衡的 cdecl 回调与缺失 cdecl 导致栈指针漂移、在远处崩溃
cdecl 属于函数类型本身;丢掉它会让栈指针逐渐漂移,直到某个毫不相干的帧崩溃

size_t 是指针宽度的,在 FPC Win64 上这意味着 64 位

第二个隐患是只在某一个目标平台上才会出现的整数宽度错配。C 的 size_t 定义为宽到足以容纳任何对象的大小,在 64 位平台上就是一个 64 位无符号整数。PDFium 的渐进式加载接口用 size_t 字节偏移量说话:可用性提供方的 FX_FILEAVAIL 记录带着一个 IsDataAvail 回调,PDFium 调用它时会传入偏移量和大小,FX_DOWNLOADHINTS 记录的 AddSegment 回调收到的也是同样两个参数,二者都是 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 正是它的别名。绑定的类型声明区里有一条注释,专门警告不要在 FPC 下遮蔽 NativeUInt,因为在那里把它重定义成 32 位别名,就会把 size_t 压到 32 位,破坏传给库或由库写出的每一个 size_t 参数。64 位偏移量落进 32 位参数,高半部分直接丢失。小文件里每个偏移都在 32 位范围内,什么问题都没有;大文件里偏移量一过 4 GB 这条线,截断后的值就指向完全不同的位置,PDFium 询问的是错误的字节范围是否可用,渐进式加载随之停滞或读进垃圾数据。这个错误要等到文件足够大、而且目标平台恰好是 size_t 真的变宽的那一个,才会显形

PDFium Component 图:5 GB 的 size_t 偏移以完整 64 位值存活,但在 FPC Win64 上被 32 位别名收窄参数后回绕到约 1 GB
被强压到 32 位的 size_t 参数会截断超过 4 GB 的偏移,把渐进加载送向错误的字节范围

Pascal 异常绝不能通过 C 栈帧进行展开

第三类是关于异常模型的,而 C 语言没有这个模型。当 PDFium 调用你的一个回调函数时,你的 Pascal 代码是在对 Delphi 异常机制一无所知的 C 和 C++ 栈帧堆栈中运行的。如果你的回调函数抛出异常并允许异常传播,它将通过从未被设计为可以展开的栈帧进行展开。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 约定,而不是跨越边界抛出异常。对于被训练为永远不要吞掉异常的 Pascal 程序员来说,没有重新抛出异常的空 except 看起来是错误的,在普通的 Pascal 中确实也是错误的。但是在 ABI 边界处,它是正确的形式,因为交还给 C 调用者唯一的安全值是它知道如何解释的状态码。失败仍然会传播,只是通过返回值传播,一旦控制权回到 Pascal 侧,库上方的调用代码就会将其显现为 EPdfError

双重释放隐藏在错误路径上

第四个隐患是所有权。PDFium 文档句柄由库打开,必须由 FPDF_CloseDocument 关闭且只关闭一次。危险出在错误路径上:某个清理步骤释放了一个别人也要负责释放的句柄。设想一个例程,先创建包装器对象,把刚打开的文档句柄赋给它,接着做可能失败的后续设置。如果设置抛出异常,提前返回的处理程序对裸句柄调用一次 FPDF_CloseDocument,随后对象被释放时,包装器自己的析构函数又会再关一次。句柄被释放两次,属于未定义行为,多半直接崩溃

在围绕已打开句柄构建 TPdf 的拼版式导入路径上,这种模式最容易咬人。解法是让所有权转移成为唯一的事实来源:句柄一旦赋给包装器的字段,就归包装器所有,错误路径上唯一的清理动作就是释放包装器。包装器的析构函数会替你调用 FPDF_CloseDocument,再显式关闭一次就是对同一份文档的双重释放。下面这段错误处理只释放对象然后重新抛出,关闭句柄的路径有且只有一条

示意图:有缺陷的错误路径两次关闭 PDFium 文档句柄,对比单一属主修复——释放包装器恰好关闭句柄一次
把句柄移交包装器,其析构函数就成为通往 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 或动态数组的记录(record)。这些是引用计数的字段,编译器发出隐藏的簿记以维持它们的计数。从 C 语言带过来的直觉是用 FillChar(Result, SizeOf(Result), 0) 来清理新记录。这会在记录内的托管引用上盖上零,而没有先递减它。编译器在循环迭代中为函数结果重用一个隐藏的临时变量,因此在第二次迭代中,FillChar 覆盖了一个从未释放的活动字符串指针,导致它指向的字符串发生泄露。在循环中对一千个批注调用该函数,你就会泄露一千个字符串

修复方法是让语言以它知道的方式使用 Default(T) 清理记录,这会在将记录归零之前释放任何托管字段

// 用 Default() 而不是 FillChar:编译器在循环迭代中为函数结果
// 复用一个隐藏的临时变量,因此 FillChar 会把存活中的
// WideString 指针清零而不释放它们。
Result := Default(TPdfAnnotation);

所有权方面相关的一个问题存在于库加载边界。在 LoadLibrary 之后,此绑定使用 GetProcAddress 从 PDFium DLL 中解析了数百个函数指针。如果缺少一个必需的导出项,部分绑定的状态是危险的:几十个指针是有效的,其余的是 nil 或陈旧的,任何后来通过其中之一进行的调用都会跳转到可能已经被卸载的模块中。绑定通过在必需导出项无法解析时卸载该库并运行完整的 ClearAllBindings 将每个导入指针重置为 nil 来处理此问题。在此之后,没有函数指针悬空指向已卸载的模块,后来的调用会因为 nil 指针检查而干净地失败,而不是分支跳转到已释放的代码中

包装器是手动重新声明四个契约的地方

这五个隐患没有一个是稀奇玩意儿。它们是 C API 之上那层薄薄的 Pascal 代码可以预见的失败方式,之所以扎堆出现,是因为恰好在这层代码里,四条彼此独立的契约必须重新声明:每个回调都要写明 cdecl 调用约定;整数宽度要在唯一一个真正变宽的目标平台上对齐 size_t;每一条跨出 Pascal 的回调都要把异常模型转换成返回码;每个句柄、每个托管字段的所有权都要声明一次,并且在所有路径上遵守——包括那些直到上线才有人踩到的错误路径。漏掉任何一条,得到的都会是一个症状远离病因的故障,这类问题排查起来最贵。真正的价值不在某一条具体规则,而在于把这里每一项都当成一门独立的纪律,在整个绑定范围内逐条核对

如果你想看到绑定在做实际工作而不是保护其边缘,我们关于渲染缓存和缩放性能的笔记中展示了渲染路径,而构建 Lazarus 和 FPC 查看器的交叉编译器指南则是这里描述的 Win64 size_t 行为实际起作用的地方。两者都基于 Delphi、Lazarus 和 C++Builder 的 PDFium Component 中提供的相同内存安全和 ABI 工作,同时还包括本博客其他地方介绍的渲染、文本提取和表单 API