Delphi 或 FPC 中返回记录的函数,不会在每次调用时都得到全新的清零 Result。这个隐藏的 Result 变量只会在开始时自动清零一次,调用之间不会再次自动清零,因此入口清理是函数本身的责任。如果使用 FillChar(Result, SizeOf(Result), 0) 清理,从第二次调用开始,例程就会直接覆盖仍然存活的字符串或动态数组引用,而不是先释放它,从而使该引用指向的堆内存失去所有者
这种问题出现的场景很普通:批处理程序打开一批第三方 PDF,遍历每一页上的所有注释,并把注释文本写入审计日志。这个循环看起来没有任何危险之处:每次调用只是返回一个普通记录的普通函数,看不到指针,也没有任何类似手动内存管理的操作。记录中的引用计数是 Object Pascal 的基本内存管理规则,并非某个库特有的行为,因此任何把 FillChar 与包含字符串或动态数组的记录类型混用的 Delphi 或 FPC 代码库都会暴露在同一种缺陷之下
为什么对记录结果使用 FillChar 会泄漏字符串
FillChar 会泄漏字符串,是因为它根本不知道自己覆盖的是什么类型的数据。FillChar(X, Count, Value) 可以作用于任何变量:它接收一段无类型的 Count 字节,并将每个字节写成 Value,这就是它的全部契约。FillChar 之所以快速且通用,正是因为它不会检查 X 的类型,也不会根据底层字节的含义分支处理。记录中的 UnicodeString 或 WideString 字段并不是字符本身,而是指向堆块的指针,字符数据之前还存放着引用计数。FillChar 看到的只是一组恰好保存指针值的字节,并像覆盖 Integer 或 Double 字段那样将它们写成零。指针消失了,本应先递减的引用计数却完全没有被处理,而它指向的堆块仍保持分配状态,再也没有任何引用指向它
编译器如何跟踪记录中的字符串和动态数组
在 Object Pascal 中,如果编译器必须额外生成代码,才能让某种类型在赋值和作用域退出时保持正确,就称该类型为托管类型。AnsiString、UnicodeString 和 WideString 等长字符串类型属于托管类型,动态数组、接口和 Variant 也属于托管类型;包含这些字段的记录或固定数组同样属于托管类型。对于每个托管字段,编译器会默默生成那些手写起来繁琐且容易出错的记账逻辑:赋值时增加引用计数,持有变量被覆盖或离开作用域时递减引用计数,并在计数归零后释放底层堆块。正因为有这套机制,普通 Pascal 代码不需要手动分配或释放 string,把一个动态数组赋给另一个动态数组也是廉价而安全的操作,而不是手写复制循环。System.Default 和 Finalize 是文档记录的两种按需调用同一释放逻辑的方式,记录清理代码应调用它们,而不是使用原始内存填充
type
TLineItem = record
Description: string; // managed: reference-counted
Quantity: Integer; // unmanaged: plain ordinal
end;
function GetLineItem(Index: Integer): TLineItem;
begin
FillChar(Result, SizeOf(Result), 0); // clears bytes, not the reference
Result.Quantity := Source[Index].Qty;
Result.Description := Source[Index].Text;
end;
var
Item: TLineItem;
I: Integer;
begin
for I := 0 to High(Source) do
begin
Item := GetLineItem(I); // second pass onward: leaks the prior Description
Log.Add(Item.Description);
end;
end;
为什么泄漏只会从第二次调用开始
循环中的第一次调用总是没有问题,这正是该缺陷在测试中很容易被忽略的原因。托管记录类型的局部变量初始值为零,在循环的一次迭代结束到下一次迭代开始之间不会自动重新清零,因此循环第一次把函数返回值赋给该变量时,它的 Description 或 ContentsText 字段仍然是 nil。FillChar 把 nil 覆盖为零,就引用计数而言没有改变任何东西,调用返回的结果看起来完全正确。第二次调用则不同:同一个局部变量已经保存了第一次调用写入的内容,新调用的 Result 会直接写入这块已有存储,而不是写入全新的空白内存。第二次调用开始,FillChar 会把一个不再为 nil 的字段清零,从这个字节模式向下的所有处理都会悄悄出错。只调用一次函数再检查结果的测试永远看不到问题,只有循环或任何针对同一目标反复调用该函数的代码路径才能暴露它
真实的泄漏:注释、书签和链接记录
PDFiumPas 在 1.56.4 版本之前确实存在这个缺陷,涉及三个各自返回至少包含一个托管字段的记录的函数:页面级注释读取器返回携带 ContentsText 和 AuthorText 字符串的 TPdfAnnotation,书签读取器返回携带 Title 字符串的 TBookmark,链接注释读取器返回携带 ActionPath 字符串和 Points 动态数组的 TLinkAnnotation。这三个函数都采用下面所示的相同模式:先用原始 FillChar 清除 Result,再逐个从底层页面数据填充字段。逐个遍历页面上的所有注释,是构建审计列表或审阅面板的常见方式;这种循环会在第一次之后的每次迭代中泄漏前一个注释的文本,如果 PDF 刻意包含大量带文本的注释,长时间运行的进程就可能在整个运行期间持续增长内存。修复只触及每个函数的一行:把 FillChar(Result, SizeOf(Result), 0) 替换为 Result := Default(TPdfAnnotation) 就足够了,因为将 Default 赋给托管记录时,编译器会执行普通的先释放再清零序列,而不是原始内存填充
function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
Annotation: FPDF_ANNOTATION;
ContentLength: LongWord;
begin
Annotation := FPDFPage_GetAnnot(Page, Index);
FillChar(Result, SizeOf(Result), 0); // clears bytes, not a live reference
Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
ContentLength := FPDFAnnot_GetStringValue(Annotation,
FPDFANNOT_TEXTTYPE_Contents, nil, 0);
if ContentLength >= 4 then
begin
SetLength(Result.ContentsText, ContentLength div 2 - 1);
FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
Pointer(Result.ContentsText), ContentLength);
end;
end;
var 参数背后的同一种风险
书签读取器展示了同一个问题更隐蔽的一种形式,因为使用 FillChar 清除的记录并不是函数自己的 Result,而是向下传递一层的 var 参数。SetBookmarkData 将输出作为 var Data: TBookmark 接收,并曾在函数体开头用 FillChar 清除 Data;真正公开返回 TBookmark 的函数 GetBookmark 会调用 SetBookmarkData,并把自己的 Result 原样传入这个 var 参数。var 参数按引用传递,因此 SetBookmarkData 内的 Data 与 GetBookmark 内的 Result 实际上是同一块存储的两个名称,适用于函数自身 Result 的任何别名风险,也同样直接适用于通过引用接收它的辅助例程。只检查明确声明记录返回类型的函数,会漏掉这种形态;搜索还必须跟踪所有接收 Result 转发值的 var 和 out 参数
procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
BufferSize: LongWord;
begin
Data := Default(TBookmark); // fixed: was FillChar(Data, SizeOf(Data), 0)
Data.Handle := Bookmark;
if Bookmark <> nil then
begin
BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
if BufferSize >= 4 then
begin
SetLength(Data.Title, BufferSize div 2 - 1);
FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
end;
end;
end;
function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
CheckActive;
SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;
什么时候 FillChar 仍然是正确选择
对于完全由整数、浮点字段、这些类型组成的定长数组,或由相同类型构成的其他普通记录,FillChar 仍然是正确的选择,而且通常略微便宜一些,因为其中没有需要编译器终结的内容。PDFiumPas 自己的矩形类型正属于这种情况:TPdfRectangle 只包含四个 Double 字段,没有其他内容;用 FillChar 清除它不会释放任何东西,因为没有任何引用计数对象需要释放。区分这两种情况的检查方法很简单:记录的任何字段在任意嵌套深度上,是否属于 string、AnsiString、WideString、动态数组、接口或 Variant?如果某个字段本身是一个在更深层嵌套字符串的记录,记录顶层看起来全是数值也仍然会失败,因此检查必须一路跟踪所有嵌套记录,不能停在最外层字段列表。审计现有代码中的这种模式不需要穷举:搜索每一个目标为记录变量的 FillChar 调用,再将该记录的字段列表与上面的托管类型清单对照即可。PDFiumPas 自己的 v1.56.4 审计正是对整个库进行了这样的搜索,并在一个单元中发现了这种暴露;其他 FillChar 调用点清除的都是普通数值记录,在这些场景中 FillChar 当时是、现在仍然是正确工具
使复用 Result 在这里变得危险的同一种编译器行为,也造成了这份代码库其他位置一系列与 Delphi 和 FPC 相关的差异;关于跨编译器陷阱的配套文章介绍了一个案例,其中 FPC 和 Delphi 对单个表达式中记录结果临时值究竟何时终结存在分歧,这是同一基本事实的另一种表现:函数的记录 Result 并不总是它看起来那样全新且私有的存储。本文始终使用的注释循环示例也不是假设场景,而是你在构建注释审阅面板时会编写的逐页遍历,正是这种代码形态让一行 FillChar 最终变成缓慢内存泄漏的根源
这一切都不需要更换库,也不需要追查别人编译代码中的错误:这是 Object Pascal 语言本身的属性,每个 Delphi 和 FPC 开发者每天都在与它打交道;一旦知道要寻找什么,修复只需要调用一个函数。本文介绍的注释、书签和链接注释 API,作为面向 Delphi、C++Builder 和 Lazarus/FPC 的PDFium 组件的一部分提供,同时还有本博客其他文章所涉及的 PDF 读取、渲染和注释功能