技術文章

Object Pascal 中 FillChar 清除函式結果會洩漏字串

Delphi 或 FPC 中返回記錄的函式,不會在每次呼叫時取得全新的清除 Result。隱藏的 Result 變數只在開始時自動清除一次,呼叫之間不會再次清除,因此進入函式時的清理責任在函式本身。若使用 FillChar(Result, SizeOf(Result), 0),從第二次呼叫開始便會直接覆寫仍然存活的字串或動態陣列參照,而不是先釋放它,使堆積記憶體失去所有者

這個問題常出現在批次處理程序開啟第三方 PDF、走訪每頁註解並將文字寫入稽核記錄的情境。每次呼叫看似只是返回普通記錄的普通函式,沒有指標或手動記憶體管理;但記錄中的參照計數是 Object Pascal 的基本規則,因此任何把 FillChar 與含字串或動態陣列的記錄型別混用的 Delphi 或 FPC 程式碼庫都會暴露在同一缺陷下

為什麼對記錄結果使用 FillChar 會洩漏字串

FillChar 不知道自己覆寫的是哪種資料。FillChar(X, Count, Value) 接收無型別的 Count 位元組並全部寫成 Value,不會檢查 X 的型別。記錄中的 UnicodeStringWideString 欄位是指向堆積區塊的指標,並非字元本身;並像覆寫 IntegerDouble 欄位一樣寫成零;指標被清零後,本應先遞減的參照計數沒有被處理,所指向的區塊便失去所有參照

編譯器如何追蹤記錄中的字串和動態陣列

受管理型別需要編譯器在指派和離開作用域時額外維護。AnsiStringUnicodeStringWideString、動態陣列、介面、Variant,以及包含它們的記錄或固定陣列都屬於受管理型別。編譯器會在指派時增加參照計數,在覆寫或離開作用域時遞減並於歸零後釋放區塊,因此普通 Pascal 程式碼不必手動配置或釋放 stringSystem.DefaultFinalize 是按需呼叫相同釋放邏輯的方式,記錄清理程式碼應使用它們,而不是原始記憶體填充

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;

為什麼洩漏只會從第二次呼叫開始

迴圈第一次呼叫時,受管理記錄型別的區域變數仍為零,DescriptionContentsText 仍是 nil,所以 FillChar 看起來沒有造成問題。第二次呼叫時,同一變數已儲存第一次呼叫的內容,FillChar 卻直接清除不再是 nil 的欄位,後續處理便會出錯。只呼叫一次函式的測試看不到問題,反覆呼叫同一目標的程式路徑才能暴露它

真實的洩漏:註解、書籤和連結記錄

PDFiumPas 在 1.56.4 之前存在這個缺陷:註解讀取器返回帶有 ContentsTextAuthorTextTPdfAnnotation,書籤讀取器返回帶有 TitleTBookmark,連結註解讀取器返回帶有 ActionPathPointsTLinkAnnotation。三者都先以 FillChar 清除 Result,再填入頁面資料;將 FillChar(Result, SizeOf(Result), 0) 替換為 Result := Default(TPdfAnnotation),即可讓受管理記錄執行先釋放再清除的序列

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 清除的是向下傳遞一層的 var 參數,而不是函式自己的 ResultSetBookmarkData 接收 var Data: TBookmark,並曾在函式主體開頭用 FillChar 清除 DataGetBookmark 將自己的 Result 傳入,並返回 TBookmarkvar 參數按參照傳遞,因此 DataResult 實際上指向同一塊 儲存空間。只檢查記錄返回型別的函式會漏掉這種形態,搜尋也必須追蹤所有接收 Result 的 varout 參數

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 欄位。若記錄在任意巢狀深度含有 stringAnsiStringWideString、動態陣列、介面或 Variant,就必須使用受管理清除方式;稽核時應逐層檢查記錄欄位

跨編譯器陷阱的配套文章說明 FPC 和 Delphi 對記錄結果暫存值何時終結存在的差異,這是同一事實的另一種表現:函式的記錄 Result 不一定是全新的私有儲存空間。本文的註解迴圈也正是你在建立註解檢閱面板時會撰寫的逐頁走訪

這是 Object Pascal 語言本身的特性,修正只需要呼叫一個函式。本文介紹的註解、書籤和連結註解 API,作為面向 Delphi、C++Builder 和 Lazarus/FPC 的 PDFium Component 一部分提供,並涵蓋 PDF 讀取、算繪和註解功能