技术文章

PDFlibPas 中 FPC Win32 的 OMF 到 COFF 目标文件链接

PDFlibPas 要在 32 位 Windows 的 Free Pascal 下构建,难点从来不在 Pascal 本身,而在目标文件上:Delphi 构建链接的 AES 和 OpenJPEG 对象是 OMF 格式,Free Pascal 的内部链接器只认 COFF,而两者之间的转换会产出让链接器报内部错误而不是诊断信息的段名和段定义符号

往 Pascal 库里链接过 C 对象的人都熟悉这片泥潭。Win64 相对文明:一种对象格式、一种调用约定、没有名字修饰。Win32 则把平台积累的每一层历史都保留了下来,而静态链接第三方 C 代码的库会一次性撞上全部这些历史

编译器目录并不能告诉你目标是什么

先从构建入口说起,因为这一步搞错的话,还没碰目标文件就会浪费好几个小时。Free Pascal 安装目录的名字只说明主编译器装在哪,不说明它产出什么。32 位宿主编译器可以调用旁边的交叉编译器,在传对目标开关的情况下产出 64 位代码,所以从路径推断目标是碰运气的做法,只在没人重排工具链布局之前恰好有效

可靠的做法是问编译器本身:通过编译器自己的信息开关查询实际的目标处理器和操作系统,同时兼容两种常见安装布局——扁平的 bin 目录和按版本嵌套的目录——因为不同的安装器和工具链管理器产出的目录形状不一样。把任何一种布局写死的构建脚本,只会在恰好那一台机器上能跑

转换过的目标文件为什么会让内部链接器崩掉?

因为格式转换保留了 OMF 的段命名约定,还合成了与 COFF 链接器预期不匹配的段定义符号。把 OMF 对象转成 COFF 是必要条件,但不是充分条件:转出来的文件带着经典的 _TEXT_DATA_BSS 段名,以及由它们派生的段定义符号名,把这样的文件喂给 Free Pascal 内部链接器,得到的是 internal compiler error,而不是一条关于段命名的提示

内部错误是构建问题里最糟糕的失败形态,因为它对输入到底哪里有问题只字不提。修复办法是对 COFF 文件做一次转换后的规范化处理:把段名重写成预期形式,把对应的段定义符号一并改写,而符号索引、代码字节和重定位一概不动。最后这条约束才是全部难点所在——一次重新编号符号或移动偏移的改写,会产出一个能链接成功、然后运行时崩溃的对象

两套对象里有一套还要先走一步预处理。经典 32 位 C++ 编译器构建的 OpenJPEG 对象依赖 Delphi 私有的 64 位整数辅助例程,Free Pascal 并不提供这些例程,所以无论怎么转格式都用不了。这批对象得先用基于 Clang 的编译器重新构建(它不会产出这些依赖),然后再做转换

PDFlibPas 静态 C 对象在 Win32 上的链接流水线:从 Lib\thirdparty\Win32 中的 Delphi OMF 转到 Lib\thirdparty\Win32f 中可被 FPC 链接的 COFF,包括 OMF 到 COFF 的转换、一次重写段名与段定义符号而不触碰符号索引、代码字节和重定位的规范化处理,以及针对调用 Delphi 64 位辅助例程的 OpenJPEG 对象的 Clang 重建
转换是必要条件但不是充分条件:把只改了名而未规范化的 COFF 喂给 Free Pascal 内部链接器,得到的答复是内部错误,所以转换之后还要有一轮只改名字与符号、不碰偏移的处理
// FPC 目标用的对象放在自己的目录里。它们并不取代 Delphi 那套
// 对象,因为两套工具链从同一份源码树构建,各自需要自己的
// 链接输入
//
//   Lib\thirdparty\Win32   Delphi OMF 对象,原样保留
//   Lib\thirdparty\Win32f  FPC COFF 对象,已转换并规范化
//
// 构建入口:
//   build-Win32-Lib-FPC.cmd
//   build-Win64-Lib-FPC.cmd

编译器私有的辅助例程不可移植,它们的调用约定同样不可移植

Delphi 运行时在 32 位 x86 上为 64 位整数运算提供了汇编 trampoline,为 Delphi 预编译的 C 对象会调用它们。Free Pascal 有自己的一套安排,所以这些引用必须换一种方式满足,而不是简单转发。让转发根本行不通的细节是调用约定:成像代码用到的计时辅助例程由被调用方清理四字节参数,而 64 位除法辅助例程清理十六字节,并把结果放在经典的寄存器对里返回。两个辅助例程、两种约定,为其中一个写的 trampoline 用在另一个上会悄悄把栈搞坏

名字修饰补上了问题的另一半。在 Win32 上,Free Pascal 会自动给外部 C 导入加下划线前缀,而 public name 声明则按原样导出,同一条桥的导入侧和导出侧遵循的规则并不一样。因此 OpenJPEG 需要的 C 运行时桥必须导出精确的 C 符号名,而可变参数入口点需要 32 位间接跳转而不是直接跳转。这些说出来没有一条稀奇,但每一条挂掉时都是同一个链接错误:一个谁都没写过的符号

是什么让 Win32 可执行文件还没到 main 就死了?

是搜索路径上的一个 64 位 DLL——之所以会被找到,是因为 Free Pascal 的 zlib 单元走动态绑定而不是静态链接。症状是进程带着 invalid-image 状态码立即退出,程序里任何 Pascal 代码都还没执行,于是你盯着刚构建出来的程序找问题,而真正的故障是加载器按错误架构解析了一个导入

教训在于假设,而不是 zlib 本身。一个以压缩库命名的单元未必装着压缩库,它可能只是一个运行时期待共享库的绑定,而非你有意引入的动态依赖——就算它碰巧能解析成功,也是一种部署负担。改用纯 Pascal 流实现之后,两个目标都能获得静态编入的压缩路径,完全无外部依赖,这本来就是一个要嵌进别人应用程序的库应有的样子

同样的直觉也适用于外部 JBIG2 编码器后端。在 32 位目标上外部编码器不参与链接,请求会回落到内置 Pascal 编码器,所以验证这件事的测试必须检查当前目标的注册状态,而不是把一次成功的编码当作外部后端存在的证据。一个能正常工作的回退,恰恰是掩盖缺失依赖的东西,这正是 静默 stub 故障诊断一文中分析的失败模式。64 位静态链接的工作则在 FPC 下静态链接 jbig2enc 一文中有讲述

Free Pascal 下 Win32 可执行文件在 main 之前退出的诊断流程:单元初始化执行期间加载器解析导入,zlib 绑定在搜索路径上找到 64 位 DLL,进程在任何 Pascal 语句之前带着 invalid-image 状态死亡,推动 PDFlibPas 转向静态编入的纯 Pascal 压缩路径
故障从来不在刚构建的程序里:名为 zlib 的单元只是一个按错误架构解析的运行时绑定,而像内置 JBIG2 编码器这样能正常工作的回退,掩盖的正是缺失的依赖

内存流上的 32 位算术

用指针宽度无符号算术操作缓冲区大小的代码,在 Win64 上正确,在 Win32 上离溢出只差一张大图。喂给 JPEG 2000 编解码器的内存流按翻倍增长、按加法前进,在 32 位目标上这两种操作都可能在大而完全合法的输入上回绕

所以每一次写入、跳过、seek 和初始分配都先检查再计算,容量上限取有符号指针宽度的最大值,与块搬运例程和回调返回值能表达的范围对齐。请求被拒绝时的行为要求很容易做错:拒绝不得改变流的位置或长度。先部分修改再报错,会把流留在调用方无法推理的状态,下一个操作还会雪上加霜

// 先检查再计算。在 Win32 上,一张大 JPEG 2000 图像合法产生
// 的输入量就足以让这两处回绕
if Needed > NativeUInt(High(NativeInt)) - FPosition then
  Exit(False);                    // 拒绝请求,位置和大小保持原样

NewCapacity := FCapacity;
while (NewCapacity < FPosition + Needed) do
begin
  if NewCapacity > NativeUInt(High(NativeInt)) shr 1 then
    Exit(False);                  // 翻倍会溢出
  NewCapacity := NewCapacity shl 1;
end;

两个比移植本身活得更久的构建输出陷阱

按目标架构把测试和示例可执行文件分进各自的输出目录,显然是对的,但它会立刻打破一切靠向上数目录层级来定位测试数据的代码。修复办法是向上搜索资产目录而不是假设固定深度,但保留一条刻意的限制:签名示例只接受来自自己项目目录的证书回退,绝不接受任意上级目录——在目录树更上层发现的同名证书是安全意外,不是便利

第二个陷阱在每次移植中都活着,值得带给任何 FPC 项目。编译器升级后,让编译器拒绝过期的 PPU 文件还不够,因为链接器仍然偏好单元搜索路径里残留的目标文件,哪怕它加载的 PPU 来自正确目录,加一个显式的对象输出路径也压不过这个偏好。唯一可靠的答案是每轮构建用全新的临时单元目录。少做任何一步,产出的就是由两个编译器版本链接出来的二进制,故障表现起来像源码 bug

平台条件编译是最后一块拼图,选对轴心比看起来更重要。正确的问题通常应该是「这段代码是不是 Windows 专属」,而不是「某个控件库是否存在」,正如 EMF 矢量导入与平台条件编译的工作所示:把那个守卫从控件库条件换成平台条件,一场以为要重写的工作变成了改一条指令。Free Pascal 与 Lazarus 对两个 Windows 目标的支持随 PDFlibPas Delphi PDF library 一同提供,与 Delphi 和 C++Builder 包从同一份源码构建