技术文章

Delphi Linux64 LP64 陷阱:Longint、指针与 WIN64

Delphi Linux64 遵循 LP64 数据模型,那里的 Longint 和 LongWord 是 8 字节,而 Windows 上的 Delphi(Win32 与 Win64)以及所有目标平台上的 Free Pascal 都保持 4 字节。把 PDFlibPas 这套 Delphi PDF 库移植到 dcclinux64 时,这个差异会从三个不同的地方冒出来,外加一个指针宽度的坑:一个本想选出指针大小整数的 {$IFDEF WIN64} 判断,在每个非 Windows 的 64 位目标上都悄悄选错宽度

这些坑没有一个冷门。每一个都是 Windows 碰巧替你兜住的习惯:RTL 声明 Longint 的地方写成 Integer、用 Longint 字段照搬 C 结构体,或者把真正的问题“指针是不是 8 字节”问成“这是不是 Win64”。这样的代码可以带病运行多年毫无症状,因为在 Win32、Win64 和 FPC 上两种写法含义相同。到了 Delphi Linux64 上含义不再相同,而最先发现的是编译器

下面讲的都是编译器能抓住的问题。PDFlibrary.pas 用同一份源码树通过 dcc32、dcc64 和 dcclinux64 构建,而这些正是横在 Windows 专属习惯与第三个编译目标之间的问题

Delphi 面向 Linux64 时什么会变?

在 Delphi Linux64 上改变宽度的整数类型恰好就是 Longint 和 LongWord;Integer 和 Cardinal 在每个 Delphi 平台上都是 4 字节,NativeInt 跟着指针走。Windows 是 LLP64,64 位下只有指针变宽。Delphi 的 64 位 POSIX 目标(Linux64 以及 macOS、iOS 的 64 位编译器)是 LP64,C 的 long 也跟着变宽,Embarcadero 让 Pascal 的 Longint 一并跟随

类型Delphi Win32Delphi Win64Delphi Linux64Free Pascal
Integer / Cardinal4444
Longint / LongWord448所有目标上都是 4
NativeInt / Pointer488目标的指针大小
C long(平台 ABI)448跟随操作系统 ABI

对跨编译器的库来说,Free Pascal 那一列就是陷阱。PDFlibPas 早就通过 FPC 提供 Linux 版本,而 FPC 在 Linux x86-64 上把 Longint 保持在 32 位。所以“它在 Linux 上能跑”这句话是真的,但对 Delphi Linux64 毫无参考价值。事后证明,FPC 的非 Windows 后端正是这次 Delphi 移植的正确地基,只是它脚下的类型尺寸并不相同

PDFlibPas PDF 库中 Integer、Longint、NativeInt 与 C long 在 Delphi Win32、Win64、Linux64 和 Free Pascal 目标上的宽度矩阵
Delphi Linux64 上只有 Longint、LongWord 和 C long 变宽,FPC 在所有目标上都保持 4 字节

为什么 WIN64 判断会在 Linux 上截断指针?

简短回答:WIN64 的意思是“64 位 Windows”,不是“64 位”,把两者当同义词就会选错整数宽度。PDFlibDither 是一个 Floyd-Steinberg 误差扩散抖动器,把 24 位位图压到每通道几个灰阶,它用裸指针运算在三行误差项上游走,支撑这些运算的指针大小整数必须跟随 CPU 宽度而不是操作系统。朴素的条件编译在这里就错了:

// 朴素写法:宽度由操作系统决定,所有非 Windows 的 64 位目标都拿到 Integer
{$IFDEF WIN64}
Type
  OSPtr= IntPtr;
{$ELSE}
Type
  OSPtr= Integer;
{$ENDIF}

// 蛇形扫描每次把误差行指针移动一项
Inc(OSPtr(ErrorR), SizeOf(TErrorTerm));  // FPC x86-64:只重写低 32 位
Dec(OSPtr(ErrorG), SizeOf(TErrorTerm));  // dcclinux64:E2064,赋值号左边不能被赋值

// 正确写法:所有平台都用一个指针大小的类型
Type
  OSPtr= TPLIntPtr;  // PDFlibTypes:非 Windows 用 NativeInt,Win64 / Win32 用 IntPtr / Integer

在这个条件编译下,FPC 面向 Linux x86-64、macOS x64 和 ARM64 时 OSPtr 解析成 4 字节的 Integer,Inc(OSPtr(ErrorR), ...) 会把 8 字节指针的前 4 个字节重新解释成整数再递增。在小端 CPU 上那正是地址的低半段。高半段永远碰不到,所以这个错误藏得极好:结果一直正确,直到低 32 位回绕,指针悄悄往回跳 4 GiB。FPC 对这种变量类型转换毫无怨言地编译通过;Delphi 拒绝在赋值号左边把变量转换成不同大小的类型,所以 dcclinux64 在第一个 Inc 处报 E2064 停下

这正是这里有用的不对称性:拒绝类型转换的编译器,能在代码于新目标上跑一行之前,就揪出另一个编译器默默放行的指针宽度错误。PDFlibPas 为此用的是 PDFlibTypes 里的 TPLIntPtr,它先判断 OS_WINDOWS,之后才区分 Win64 与 Win32,所以非 Windows 目标总是拿到 NativeInt

我们总结出的规则:永远不要从操作系统宏推断指针宽度。需要指针大小的整数时,直接用 NativeInt / NativeUInt(Delphi 和 FPC 都有)或基于它们的项目别名,彻底跳过条件编译。如果确实需要在 Delphi 代码里按宽度分支,CPU64BITS 才是回答你真正想问的那个问题的宏

PDFlibPas Delphi 代码中指针宽度类型的决策流程:WIN64 宏在非 Windows 64 位目标上选出 4 字节 Integer,直到 NativeInt 兜底
WIN64 宏问的是操作系统,指针宽度类型必须改用 NativeInt 或 CPU64BITS 判断

TStream override:逐字符照抄 RTL 签名

Delphi Linux64 上的 TStream 子类必须用 Longint 参数声明并实现 Read、Write 和 SetSize,与 RTL 的声明一字不差,因为 Integer 已不再是同一个类型。PDFlibPas 有自己的 TPDFlibWideFileStream 用于 Unicode 文件名,它的类声明确实用了 Longint,而 implementation 部分用的却是 Integer

Type
  TPDFlibWideFileStream= Class(TStream)
  Protected
    Procedure SetSize(NewSize: Longint); Override;
  Public
    Function Read(Var Buffer; Count: Longint): Longint; Override;
    Function Write(Const Buffer; Count: Longint): Longint; Override;
  End;

// 之前:在 Integer = Longint 的 Win32、Win64 和 FPC 上被接受多年
Function TPDFlibWideFileStream.Read(Var Buffer; Count: Integer): Longint;

// 之后:implementation 头与声明逐字一致
Function TPDFlibWideFileStream.Read(Var Buffer; Count: Longint): Longint;
Begin
  Result:= FileRead(FHandle, Buffer, Count);
  If Result= - 1 Then
    Result:= 0;
End;

在这套库遇到过的所有编译器上,Integer 和 Longint 都是同一个 4 字节类型,所以不匹配的头与匹配的头根本无法区分。到了 Delphi Linux64,implementation 不再与自己的声明匹配,整个单元停止编译。这个问题比它看起来的分量更疼:PDFlibExtra 是库中大部分单元都依赖的基础单元,一个不匹配的 implementation 头就足以让大部分单元编译失败

对新代码来说,同样的逻辑反过来也成立。重写 RTL 方法时,逐字照抄它的参数类型,哪怕你“知道”两个类型相同。Delphi Linux64 只不过是第一个让这份“知道”失效的目标

哪些 record 要把 Longint 换成 Integer,哪些必须保留?

镜像文件格式或 Windows 结构体的 record 必须用定宽类型(Integer、Cardinal、Int32),而镜像用 long 声明的 C 结构体的 record 则应保留 Longint / LongWord。两种情况在源码里看起来一模一样,却需要相反的答案,因为 Windows 的 LONG 永远是 32 位,而 C 的 long 宽度由平台 ABI 说了算

LP64 上 Delphi record 字段的决策矩阵:磁盘上的字节用定宽 Integer,PDFlibPas 中 C long 绑定保留 Longint
镜像字节的 record 用定宽类型,只有 C long 的绑定保留 Longint,背后再配一行大小断言
Type
  // Uniscribe GOFFSET:两个 Windows LONG,总计恒为 8 字节
  TGoffset= Packed Record
    du: Integer;  // 原为 Longint:record 在 Delphi Linux64 上长到 16 字节
    dv: Integer;
  End;

  // EMF LOGCOLORSPACE:头部靠一次定长 68 字节的 Move 从 record 数据填充
  TPLEMFLogColorSpace= Packed Record
    Signature, Version, Size, ColorType, Intent: Cardinal;
    Endpoints: Array[0..8] Of Integer;  // 原为 Longint:36 字节变成 72
    Gamma: Array[0..2] Of Cardinal;
    Filename: Array[0..259] Of WideChar;
  End;

  // PDFium FPDF_FILEACCESS:m_FileLen 是 C 的 unsigned long,所以 LongWord 才是对的
  FPDF_FILEACCESS= Record
    m_FileLen: LongWord;  // Windows 上 4 字节,LP64 上 8 字节,与 C 侧一致
    m_GetBlock: TFPDF_GetBlock;
    m_Param: Pointer;
  End;

EMF 这个案例展示了这种错误可以悄无声息。导入器往 TPLEMFLogColorSpace 里复制 68 字节:20 字节头部、36 字节 CIE 端点、12 字节 gamma。如果端点用 Longint,在 LP64 上这同样的 68 字节会落进一个 72 字节的端点数组,gamma 值永远写不进去,而且不会报任何错。这两个 record 都在 Windows 专属的代码路径上,而且它们本来就用了定宽类型——镜像磁盘字节的 record 不应该取决于碰巧编译它的那个编译器

PDFium 绑定则是反例。它的 m_FileLen 镜像的是 C 的 unsigned long,Windows 上 32 位、LP64 上 64 位,LongWord 在两边都精确跟随。要是在某次清理中把它改写成 Cardinal,将来 PDFium 宿主一旦加上 POSIX 加载器就会出问题。PDFium 宿主目前只有 Windows 版,所以这只是给未来留的备忘。C 绑定的调用约定一侧另有陷阱,见 PDF Library 的 DLL、ActiveX 与 dylib 绑定一文

给任何镜像字节的 record 配个廉价保险:启动时断言一次它的大小,比如在 initialization 段里写 Assert(SizeOf(TGoffset) = 8)。这几乎零成本,却能把无声的布局变化变成新目标首次运行时的立即失败

把条件宏弄对:DELPHI_LINUX、Android 与 Types

在这些类型问题浮出水面之前,先得让 Linux 上的 Delphi 别再假装自己是 Windows。PDFlibDefines.inc 原先对 Delphi Linux 什么都没定义,编译器直接掉进 Windows/VCL 的默认分支,第一个错误就是找不到 Vcl.Graphics。这组宏把 Delphi Linux 引到与 FPC 非 Windows 目标相同的后端上:Cairo 渲染、fontconfig,以及运行时加载的 OpenSSL 和 libcurl

{$IFDEF LINUX}
{$IFNDEF FPC}
{$IFNDEF ANDROID}
{$DEFINE DELPHI_LINUX}
{$DEFINE NOVCL}
{$DEFINE MD5_MAC}
{$DEFINE NOCRYPTAPI}
{$DEFINE USE_Cairo}
{$ENDIF}
{$ENDIF}
{$ENDIF}

这个块里有两个细节容易漏看。排除 ANDROID 是因为 Delphi 对 Android 目标也会定义 LINUX,而这个后端并不覆盖 Android。另外,新宏刻意与既有的 Delphi macOS 分支(不带 FPC 的 OS_MAC)分开,后者依赖 Macapi 单元;Linux 宏则建立在 FPC 非 Windows 路径之上

另一个反复出现的错误是 TPoint 和 TRect 未声明。在 Windows 上,这些类型经由 Windows 单元间接进入大部分单元。Delphi Linux 的 uses 子句里没有 Windows 单元,所以 PDFlibFont、PDFlibPageTree 之类的单元现在都在 DELPHI_LINUX 下显式列出 Types。如果你自己的代码能在 Windows 上编译只是因为 Windows 顺手拖进了 System.Types,那就等着撞同一堵墙吧。代码碰巧只在单一编译器上能跑这个普遍模式,正是 五个碰巧才成立的 Delphi 习惯,直到 FPC 揭穿它们一文的主题

编译器帮不上忙的地方

上面的 LP64 问题都是编译器能发现的。它发现不了的,比如尺寸不对但从未经过类型转换的 record,只有在测试真正跑在目标平台上时才会暴露——这正是上面那组大小断言的用途

如果你最初是被 1-bit 输出引到这段抖动代码的,渲染页面受支持的路线其实是另一条:在渲染器之外做 GDI 半色调降转换,见 把 PDF 页面渲染成 1-bit 单色位图

这里描述的跨编译器地基都在 PDF Library for Delphi 里,如今它面向 Windows 上的 Delphi 与 C++Builder,外加 Linux 和 macOS 上的无界面 Free Pascal 构建;产品页完整列出了支持的编译器与平台