Free Pascal 在 Win32 上会自动给每个 cdecl; external 导入加一个前导下划线,而 public name 导出的则是你写的那串字符,一字不差。HotPDF 必须在同一份源码树里同时满足这两种约定,因为 Delphi 构建已经发布了一批手工把下划线写进名字里的导入声明。这个不对称弄错了,产出的链接错误会指向一个谁都没写过的符号
把 Delphi 库扩展到 Free Pascal,通常被说成可移植性问题,在 Win64 上也确实主要是。Win32 不一样。32 位 x86 Windows 的 ABI 上积了三十年的约定:C 符号怎么拼写、谁清理栈、一个编译单元可以默认存在哪些编译器私有辅助例程——每一条都是两个在语言层面意见一致的 Pascal 编译器、在目标文件层面仍然可以不一致的地方
为什么同一个符号在 Win64 能解析、在 Win32 就挂?
因为下划线前缀是一条 32 位约定,Free Pascal 只对导入应用它,不对导出应用。声明一个 function deflate(...): Integer; cdecl; external;,FPC 在 Win32 的目标文件里找的是 _deflate,在 Win64 找的是 deflate。这是正确行为,和 C 编译器的产出一致。陷阱在桥的另一头:标了 public name 'deflate' 的例程在两个目标上都精确导出 deflate,不加任何前缀
再加一个让问题变得具体的历史细节。Delphi 构建已经把其中一些入口点连同下划线一起写进了名字里,因为它自己的目标文件里装的就是这个名字。把同样的声明喂给 Win32 上的 FPC,编译器 dutifully 地又补一层前缀,链接器于是去找 __deflate——一个谁都不导出的符号。凭直觉的修法是到处加一个下划线,结果把本来拼写正确的导入全弄坏了
行得通的方案是一对前缀常量,不是一个。HPDFFPCZLib 和 HPDFFPCCodecStubs 给普通 C 导入用一个前缀,给已经带着 Delphi 侧前缀的导入用另一个,而在 Win64 上两个常量都是空串,现有的链接名原样保留。用两个常量而不是一个,就是修复的全部;而这一点只有在你把导入规则和导出规则分开看待之后才显而易见
// 两个前缀,不是一个:普通 C 导入和已带手写
// Delphi 前缀的导入,在 FPC/Win32 下修饰方式不同
const
{$IF DEFINED(FPC) and DEFINED(CPU32)}
CPrefix = '_'; // cdecl external 时 FPC 自己会加
DelphiCName = ''; // 源码里已手写下划线
{$ELSE}
CPrefix = '';
DelphiCName = '';
{$IFEND}
// 导出侧:public name 声明在每个目标上都按字面导出
procedure hpdf_codec_free(P: Pointer); cdecl;
public name 'hpdf_codec_free';
WIN32 告诉你的是架构,不是 ABI
这是调试尾巴最长的条件编译错误,值得直白地说清楚:WIN32 和 WIN64 描述的是目标架构,对存在哪些编译器私有运行时辅助例程只字未提。Free Pascal 在对应的 Windows 目标上同样定义这两个符号,和 Delphi 一模一样。于是围绕调用 Delphi 运行时辅助例程的代码写一个 {$IFDEF WIN32} 守卫,在 FPC 下能编译通过,然后在链接时失败
具体来说,有三族代码会掉进这个陷阱。经由 System.@_ll 辅助例程触达的 Delphi 64 位整数 trampoline、MSVC 的 Win32 汇编支持例程,以及配套的导入槽位——它们的存在都是为了伺候 Delphi 构建所链接的预编译 C 对象。Free Pascal 不链接那些对象,所以这些机制一样都不需要,每一处引用都必须消失。微妙之处在于声明和实现必须一起排除。只排除一边,编译器会报一堆毫无帮助的、关于某个它无法匹配任何东西的标识符的抱怨
由此得出的规则很短。问题关于 ABI 或运行时支持时,按编译器分支;问题关于指针宽度或寄存器数量时,按架构分支;永远别让一个顶替另一个
声明和实现要一起守卫
接口区的条件编译块很容易在不知不觉中掉进去,而 resulting 的错误信息指向任何地方,就是不指向原因。往类接口里加一个方法声明,自然的位置是放在相关方法旁边——这本来没问题,直到那些邻居恰好位于一个已有 {$IFDEF} 块内部。条件编译指令不缩进,所以一个四十行之前打开的块,在你阅读周围声明时基本是隐形的
接下来发生的是:在一条工具链上编译成功,在另一条上产生级联报错。如果周围的守卫是一个 Free Pascal 不满足的 Delphi 版本检查,声明对 FPC 蒸发了,而无条件的实现还在,编译器于是报告一长串「期待却找不到某方法标识符」的抱怨。没有一条信息提到引发这一切的条件编译块
两个习惯可以防住这一整类失败。往接口区插入之前,向上找最近一个未闭合的条件编译指令,而不是相信视觉分组。另外,把一套全绿的 Delphi 测试当作只关于 Delphi 的证据:Free Pascal 库构建是另一道闸门,想知道它过没过,唯一办法是把 build-Win32-Lib-FPC.cmd 和 build-Win64-Lib-FPC.cmd 作为同一次改动的一部分跑起来
32 位算术代码里坏的是什么
有一条语言限制恰好出现在最不愿意改的代码里:32 位 Free Pascal 不接受 UInt64 作 for 循环控制变量。在承载 X25519 和 X448 的椭圆曲线单元里,走 limb 数组的循环当初用 64 位计数器写,纯粹因为文件里其他一切也都是 64 位的
修法必须精准,因为在域算术里,变量的宽度是正确性论证的一部分。循环下标改成 Integer——limb 数组就那么几个元素,下标永远够不着 32 位范围。参与算术的一切——limb 本身、进位传播、掩码——保持 UInt64,因为把其中任何一个收窄,都会在域素数取模下悄悄改变结果
// 32 位 FPC 拒绝 UInt64 循环变量。只收窄下标;
// limb、掩码和进位保持宽度,否则域运算结果就变了
var
I: Integer; // 原来是 UInt64
Carry, Mask: UInt64;
begin
Carry := 0;
for I := 0 to High(Limbs) do
begin
Limbs[I] := Limbs[I] + Carry;
Carry := Limbs[I] shr 51;
Limbs[I] := Limbs[I] and Mask;
end;
end;
这类改动的验证不能是往返测试。用同一个坏实现加密再解密,结果和它自己完美自洽——这就是为什么已知答案测试向量在这里没有商量余地:跑公开的 X25519 和 X448 测试向量,逐字节比对输出。Free Pascal 的 deflate 与 AES 编解码边界里讨论的对称原语,同样适用这条检查
一个 Win32 Free Pascal 构建值在哪
实际的回报是:面向 32 位 Windows 的 Lazarus 应用得到和 Delphi 版相同的文档引擎,不用再维护一份单独的二进制契约。对那些很少有人谈起部署最重要:工业控制器、POS 终端、长寿的业务软件——在那些地方,32 位运行时不是遗留选择,而是硬件约束
Win64 的故事在前,见 Win64 上的 Free Pascal 与 Lazarus 支持。Win32 不是它的重演。Win64 只有一种调用约定、没有名字修饰、没有需要绕开的 Delphi 私有整数辅助例程,所以本文几乎所有内容都是 32 位目标特有的。需要改循环变量的算术单元,正是 NIST 曲线上的 Montgomery 算术描述的那几个,宽度纪律在那篇里讲得更深
总的教训是:跨编译器可移植性工作,主要无关语言特性。两个编译器在这里接受同一份 Object Pascal。不同的是目标文件:符号怎么拼写、运行时被默认提供哪些辅助例程、链接里有哪些预编译对象。HotPDF 在 HotPDF Delphi PDF component 中把 Free Pascal 和 Lazarus 包与 Delphi、C++Builder 包一起发布,同一份源码树喂给每条工具链,而不是按编译器分叉