技术文章

在任意目标平台上加载 PDFium 原生库

PDFium 组件通过一条固定、有序的搜索链找到自己的原生库,而不是留给操作系统加载器,因为显式的部署树才是可调试的部署树。在 Windows 上,这条链寻找安装程序已经附带的 Win32Win64 子目录。在其他目标上,它从 Free Pascal 目标宏构建子目录名,形如 <cpu>-<os>,使部署树读起来与编译单元树完全一致。最后这个决定引入了一个值得用整篇文章来讲的 bug,因为成因是一个大写字母,症状是一片寂静

搜索链,按顺序

四个位置按顺序尝试,然后才是平台加载器这个最后手段。第一是首选布局:可执行文件旁的 DLLs 目录,其中每个目标一个子目录。第二是备用布局:目标子目录直接挨着可执行文件。第三是扁平的旧式布局:库紧挨可执行文件,完全没有子目录。第四,仅限 Windows,是系统目录——这里要小心,因为 32 位进程必须查 SysWOW64,64 位进程查 System32,而在 32 位 Windows 上前者不存在,查找必须回退。全部试完之后,才轮到让加载器自行搜索

Delphi 下 PDFium 原生库搜索链示意图:从 DLLs 目标子目录,经备用、扁平与 Windows 系统目录布局,直到平台加载器
四个显式位置按顺序探测,之后才让操作系统加载器自行搜索

在 Windows 之外刻意没有系统目录一步。平台加载器自己的搜索路径——由运行时链接器配置与库路径环境驱动——已覆盖那片地面,用 Pascal 重复它意味着重新实现各发行版各异的规则。Windows 链上的故障诊断在部署 PDFium DLL 与诊断加载失败中单独展开

子目录名从哪来

在 Windows 上是 Win32Win64,由运行进程的位数而非操作系统的位数决定,因为决定能加载哪个二进制的正是前者。其他所有地方,名字由编译器目标宏构建:为两种架构构建的机器产出两棵清晰分离的树,存放原生库的文件夹与存放编译单元的文件夹同名相邻

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;

一个大写字母如何弄断整条链

编译器宏把目标操作系统拼成首字母大写:Win64LinuxDarwin。Lazarus 包把单元输出写进一个由它自己的目标变量命名的目录,那是小写:win64linuxdarwin。同一个东西的两种拼法,而且在 Windows 上无从察觉,因为那里的文件系统不区分它们

在 Linux 上它们是两个不同的目录。把共享库放进 DLLs/x86_64-linux 的部署,对一个寻找 DLLs/x86_64-Linux 的加载器是不可见的,于是链条的四个显式步骤全部落空,代码落到让平台加载器自行搜索。有时能成——库恰好装在系统范围;有时不能。无论哪种,精心布置的部署树都毫无贡献。失败没有错误消息,因为什么都没失败:每一步都正确地报告了文件不在它查找的地方

FPC 目标宏里的一个大写字母如何在 Linux 上破坏 PDFium DLL 查找:加载器搜索 DLLs/x86_64-Linux,部署的文件夹却是 DLLs/x86_64-linux,只有大小写不敏感的 Windows 才会匹配
同一目标的两种拼法在 Windows 上匹配,在大小写敏感的文件系统上悄悄落空

探测程序:编译,更要运行

这类 bug 读不出来,也编译不出来。验证一条在开发机上永远不参与编译的平台分支,常用手法是把单元拷到临时目录、改名、把平台条件换成永不定义的符号、编译副本;如果能编译,那条路径上的 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 子句的顺序。限定调用点,是那种不依赖日后有人保住该顺序的修法

PDFium 组件中的 UnloadLibrary 名字冲突:Delphi 绑定单元里不加限定的调用递归进自身,而带单元限定的调用抵达可移植加载器单元并释放句柄
限定调用点让释放经由加载器单元完成,而不是递归进绑定单元

部署清单

路径算术对了之后,大多数加载失败归于三件事。架构必须匹配进程而不是机器,所以 64 位 Windows 上的 32 位应用需要 32 位二进制。启用 V8 的构建文件名不同,混用的部署看起来一切正常却什么也加载不了。而且系统目录里同一时刻只能有一个变体——这是优先选择显式子目录布局、而不是往系统里装任何东西的好理由

对 Lazarus 而言,把原生库放在小写的 DLLs/<cpu>-<os> 下、可执行文件旁边,链条第一步就会在所有目标上找到它。在 Lazarus 上演练这一切的查看器示例见Lazarus 与 FPC 查看器一文,当前平台支持列在 PDFium Delphi component 产品页