技术文章

Free Pascal 静态链接 jbig2enc,无需 DLL

PDFlibPas 3.538.0 将外部 JBIG2 编码器静态链接进 Free Pascal 和 Lazarus 程序。项目加入 PDFlibJBIG2EncC 单元,这也是 Delphi 和 C++Builder 已经使用的同一个单元,最终编码器会进入可执行文件,除了它本身不再需要随程序分发任何额外文件。这推翻了此前对该功能的结论:Free Pascal 只能通过 DLL 访问外部编码器

为什么 DLL 看起来像是唯一选项

之所以看起来只有 DLL 可选,是因为三条链接路径分别以三种互不相关的方式失败,而且没有任何编译器开关能触及其中一个问题。内部链接器直接拒绝关联式 COMDAT 节。通过随附 binutils 进行外部链接时,会在节垃圾回收过程中崩溃,因为 Free Pascal 在 64 位 Windows 目标上无条件启用了这一步。更新版 binutils 又完全无法处理 Free Pascal 的链接脚本。用另一套工具链重建 C++ 侧只是把一种拒绝换成另一种,因为模板和 inline 实例化天然会发出弱外部符号,而 Free Pascal 将其报告为 Unsupported COFF symbol type 105。这些证据都没有错,此前关于 JBIG2 编码器后端与 Free Pascal 链接器的说明逐一走过了这些至今仍能复现的死路。错的是对修复应该落在哪里的假设。每次尝试都经过编译器或链接器,但它们都无法改变目标文件已经包含的内容。问题一直就在目标文件里。ObjConv 读取 COFF 并写回 COFF,而 Free Pascal 无法处理的每种构造,都有它能够接受的机械等价形式

这个错误从来不说明原因

Free Pascal 的内部链接器只实现了一半 pick-any COMDAT,而这半成品实现是这里最难诊断的部分。它确实会按格式要求折叠重复定义。但在标记节为已使用时,TExeOutput.RemoveUnreferencedSections 会通过 exesymbol 重定向到胜出的定义,而 TCoffexeoutput.DoRelocationFixup 却直接读取 objreloc.symbol.objsection。当一个已使用的节引用了某个符号,而该符号在自己的目标文件中定义于折叠失败的副本里时,两个阶段看到的是不同的节,链接就会以 Internal error 200603061 停止

把它和两侧的两个限制对比一下。Unsupported COFF symbol type 105 表示弱外部符号。Associative or exact match COMDAT sections are not yet supported 表示关联式 COMDAT,甚至会指出出问题的符号。内部错误 200603061 却什么也不说:没有符号名、节名、文件名或阶段信息。它还不是边角情况,而是常规情况,因为 MSVC 会把每个字符串字面量以及每个 inline 或模板实例化都放进 pick-any COMDAT;在这组编码器的 186 个对象中,链接器执行了 2656 次折叠。使用 /Gy- 可以让普通函数离开逐函数 COMDAT 节,却会让字符串字面量和模板实例化留在原处

为什么逐个补 CRT 符号时,总像是最后一个符号把它弄坏了

因为只有所有符号都解析完成后,链接器才会进入修复阶段。只要还有任何缺失,运行就会以 Undefined symbol 提前结束,COMDAT 问题根本没有机会暴露。补上最后一个 C 运行库桩函数后,链接器前进一个阶段,直接撞上内部错误 200603061。因此现场症状系统性地误导人:逐个为引用到的 C 符号添加 Pascal 函数体时,看起来总是最近添加的那个破坏了构建,或者仿佛跨过了大约一百个桩函数的某个阈值。两者都不对。最后加入的是哪个符号、总共加入了多少个符号都无关紧要,因为故障从第一个对象开始就潜伏着,只是在解析成功后才变得可达。当链接器在你修复无关问题后改变了报错内容,应该先问自己是否只是推进了一个阶段,而不是制造了回归

修复是一次 ObjConv 处理,而不是编译器开关

完整修正就是对每个已编译对象执行一次后处理命令:ObjConv -fcoff64 -xw -xc -xn -np:__imp_:pdflibimp_。其中三个选项是为这项工作新增的。-xwIMAGE_SYM_CLASS_WEAK_EXTERNAL 符号解析为普通外部符号。-xn 规范化 IMAGE_SYM_CLASS_NULL 符号,例如 _fltused,否则 Free Pascal 会报告 Unsupported COFF symbol type 0-xc 才是核心:它把每个 COMDAT 节降级为普通节,并将其中定义的符号设为静态。移除这个选择就移除了故障,因为没有 COMDAT 节,就没有折叠,没有一个阶段可以重定向到而另一个阶段找不到的胜出副本,关联式 .pdata.xdata 展开节也会一并消失。代价确实存在,但很小:原本可以合法合并的副本现在都会各自保留下来

-np:__imp_:pdflibimp_ 前缀重命名解决的是另一个冲突。MSVC 通过名为 __imp_* 的间接单元调用导入的 Win32 API,Free Pascal 则为自己的导入机制保留这个前缀,直接定义这些名字中的任意一个也会触发同一个内部错误 200603061。重命名单元后,Pascal 侧可以把它们作为普通变量发布,并在运行时填充。对象本身使用静态链接开关 /GS- /Gs999999 /Gy- /Zl /GR- /EHs-c- 编译,并关闭图像编解码器,因此无用的文件 I/O 和编解码路径需要的仅是链接阶段桩函数。它们进入 Lib\thirdparty\Win64f,而 Delphi 和 C++Builder 路径继续链接自己的 Win64x 集合不变;对于只涉及一个工具链的可移植性修复,这正是正确结果

Pascal 侧仍然必须导出的内容

Free Pascal 通过符号名解析 C 对象的导入,因此每个代替 C 入口点的 Pascal 例程都要带上明确的 public name 子句。Delphi 直接把例程名作为符号名,不需要任何子句,所以同一个单元可以通过 {$IFDEF FPC} 下的子句服务两个编译器。陷阱在于,external 'msvcrt.dll' 声明并不能满足链接需求:它只创建导入项,从来不是链接对象可以绑定的定义。必须存在转发函数体

// 外部声明只创建导入项。没有链接对象可以
// 绑定到它
function crt_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  external 'msvcrt.dll' name 'memcmp';

// 在准确的 C 符号名下发布 Pascal 函数体,才是
// 对象集合实际绑定的目标
function jbig2_memcmp(Buf1, Buf2: Pointer; Count: NativeUInt): Integer; cdecl;
  public name 'memcmp';
begin
  Result := crt_memcmp(Buf1, Buf2, Count);
end;

可变参数入口点打破了这个模式,因为 Pascal 包装器无法把自己的 varargs 转发给另一个 varargs 被调函数。解决办法是不再使用包装器:以 C 名称导出一个裸例程,并按照调用方已经安排好的参数寄存器和栈原样跳转到真正的实现。JPEG 2000 层已经用这种方式处理 snprintfvsnprintf,跳转到带下划线前缀的 msvcrt 拼写,因为普通名称只有 UCRT 导出。另一个相关限制也来自同一个内部错误:重命名后的导入单元通过 initialization 段中的 GetModuleHandleAGetProcAddress 填充,而不是使用静态初始化器,因为在初始化器中取导入例程地址会让编译器生成无法处理的修复项,再次以 200603061 失败

function crt_sprintf(Buffer: PAnsiChar; Fmt: PAnsiChar): Integer; cdecl; varargs;
  external 'msvcrt.dll' name 'sprintf';

var
  SPrintfTarget: Pointer = @crt_sprintf;

// Pascal 包装器无法转发 Varargs,因此导出符号会
// 在保留调用方栈帧的情况下直接跳转到目标实现
procedure jbig2_sprintf; assembler; nostackframe;
  public name 'sprintf';
asm
  jmp qword ptr [rip + SPrintfTarget]
end;

Free Pascal 项目现在需要怎样做

除了 uses 子句中的单元名,没有其他变化,而且现在也不再有需要部署的文件。后端通过自身的 initialization 段调用 RegisterJBIG2EncoderBackend 注册,调用方仍然像以前一样请求它:使用值为 4 的选项位 PDF_JBIG2_OPTION_EXTERNAL_ENCODER,或使用扩展入口点的 UseExternalEncoder 参数。请求它仍然只是偏好而不是保证,因为遗漏该单元的构建会静默回退到原生 Pascal MMR 编码器,生成更大的文件而不是报错

uses
  Classes, SysUtils,
  PDFlibrary,
  PDFlibJBIG2EncC;   // Delphi、C++Builder,以及从 3.538.0 起的 Free Pascal

var
  Pdf: TPDFlib;
  Scan: TStream;
  ImageId: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.NewDocument;
    Pdf.NewPage;
    Scan := TFileStream.Create('scan-page-1.tif', fmOpenRead);
    try
      // Interpolate、SymbolExtract、UseExternalEncoder、SkipBlackDots、
      // BlackDotSize、LossyLevel
      ImageId := Pdf.AddImageJBIG2FromStreamEx(Scan, 0, 1, 1, 0, 0, 0);
    finally
      Scan.Free;
    end;
    if ImageId = 0 then
      raise Exception.Create('JBIG2 encoding failed');
    Pdf.SaveToFile('archive.pdf');
  finally
    Pdf.Free;
  end;
end;

有两个限制需要直说。现在只有 Win64 对象集合,因此在所有其他 Free Pascal 目标上,外部编码入口点都会报告失败,由原生 Pascal 编码器完成工作。另一个决定整个功能是否可靠的回归测试是渲染比较而不是大小检查:两个编码器对同一源都是无损的,所以会渲染输出并逐字节比较,Lazarus 测试套件连同这项测试在内通过了 26 项中的 26 项。比较压缩流大小没有意义,因为一张倒置的页面压缩后与正确页面大致一样大

这个经验不只适用于 JBIG2。当边界确实需要动态时,DLL 是正确形态,DLL、ActiveX 和 dylib 集成接口正是为此服务;但当 DLL 只是 COFF 读取器的临时绕行方案时,它就是错误形态,因为它会给每个安装包增加一个文件,给每次部署增加一条搜索路径,还会引入静态链接不会有的版本错配故障。上游同样重要,因为二值图像的生成方式对最终大小的影响大于编码器本身,Delphi 中基于区域的单色渲染覆盖了流水线的另一半。工具链覆盖范围、各编译器的对象集合以及支持的目标平台列在 losLab PDF Developer Library 产品页