技術文章

在函式結果上 FillChar 會造成 Object Pascal 字串洩漏

一個傳回記錄的 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 的型別,也從不依底層位元組的涵義分支。一個記錄裡的 UnicodeStringWideString 欄位並不是字元本身;它是指向一個堆積區塊的指標,該區塊在字元資料之前帶有一個參考計數。FillChar 看到的是恰好持有指標值的少數幾個位元組,並把它們以零覆寫,就如同它會覆寫一個 IntegerDouble 欄位一樣。指標消失了,它本應先遞減的參考計數從未被碰觸,而它所指的區塊仍配置在那裡,再也沒有任何東西參照它

FillChar 把 Delphi 記錄中 Description 指標位元組歸零,其堆積區塊的參考計數停在 1 而洩漏的圖解
指標位元組被蓋上零,計數的堆積區塊卻仍處於配置狀態、再也到不了

編譯器如何追蹤記錄內部的字串與動態陣列

當編譯器必須執行額外程式碼,才能讓某個型別在指派與範圍結束時保持正確,Object Pascal 就稱之為受管理的型別。長字串型別如 AnsiStringUnicodeStringWideString 都算在內,動態陣列、介面與 Variant 也算,連同任何把上述其中之一當作欄位的記錄或固定陣列也一樣。對每一個受管理欄位,編譯器悄悄發出那些否則會很繁瑣、又容易手動出錯的簿記:指派時遞增參考計數、在持有變數被覆寫或脫離範圍時遞減它,並在計數到達零時釋放底層區塊。那套機制正是為什麼普通的 Pascal 程式碼從不手動配置或釋放一個 string,也是為什麼把一個動態陣列指派給另一個是一次便宜、安全的操作,而不是一個手動複製迴圈。System.DefaultFinalize 是兩個有文件記載、能依需求叫用同樣釋放邏輯的方式,而它們正是記錄清除程式碼應該呼叫的,而非原始的記憶體填填

type
  TLineItem = record
    Description: string;  // 受管理:參考計數
    Quantity: Integer;    // 非受管理:一般序數
  end;

function GetLineItem(Index: Integer): TLineItem;
begin
  FillChar(Result, SizeOf(Result), 0);  // 清除位元組,而不是參考
  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);  // 從第二次通過起:會洩漏先前那個 Description
    Log.Add(Item.Description);
  end;
end;

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

迴圈中的第一次呼叫總是無害的,而這正是讓此缺陷在測試中容易漏掉的原因。一個受管理記錄型別的局部變數一開始為零,而在一次迴圈與下一次之間沒有任何東西自動把它重新歸零,所以當迴圈第一次把函式的傳回值指派進那個變數時,它的 DescriptionContentsText 欄位仍是 nil。FillChar 用零覆寫 nil,就參考計數而言什麼也沒改變,而那次呼叫看起來完全正確地返回。第二次呼叫就不同了:同一個局部變數已經持有第一次呼叫寫進去的東西,而新呼叫的 Result 直接寫進同一塊儲存,而不是寫進全新、空白的記憶體。第二次呼叫頂端的 FillChar,把一個不再是 nil 的欄位歸零,而從那一刻起,那個位元組模式下游的一切都悄悄錯了。一個只呼叫函式一次並檢查結果的測試,永遠看不到這個問題;只有一個迴圈,或任何對同一個目的地重複呼叫該函式的程式碼路徑,才會暴露它

比較圖:FillChar 覆寫活躍字串指標的 Delphi 迴圈中,第一次呼叫無害、第二次呼叫洩漏
第一次呼叫覆寫 nil 欄位,第二次呼叫覆寫上一次的結果,之後每一趟都再洩漏一次

一個真實的洩漏:註解、書籤與連結記錄

PDFiumPas 在 1.56.4 版之前,就附帶了這個缺陷,出現在三個各自傳回持有至少一個受管理欄位之記錄的函式:頁面層級的註解讀取器傳回一個帶有 ContentsTextAuthorText 字串的 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);   // 清除位元組,而不是一個存活的參考
  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 內部的 DataGetBookmark 內部的 Result,是同一塊儲存的兩個名字,而任何適用於函式自己 Result 的別名風險,也同樣直接適用於任何以傳址方式接收它的輔助常式。只審查那些字面上宣告記錄傳回型別的函式,會漏掉這個形狀;搜尋也必須跟隨 Result 被轉發進去的每一個 varout 參數

procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
  BufferSize: LongWord;
begin
  Data := Default(TBookmark);   // 已修正:原本是 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 清除一個矩形什麼也不釋放,因為沒有任何參考計數的東西可釋放。分辨兩種情況的檢查,說起來很簡單:在任意的巢狀深度上,這筆記錄的任何欄位,是否具有 stringAnsiStringWideString、動態陣列、介面或 Variant 型別?一筆記錄可以在頂層看起來完全數值化,卻仍然無法通過這個測試——如果它的一個欄位本身就是一筆在幾層之下藏著字串的記錄,所以這個檢查必須一路跟隨巢狀記錄,而不是停在最外層的欄位清單。為這個模式稽核既有的程式碼庫是機械性的,而非窮盡式的:搜尋每一個目標是記錄變數的 FillChar 呼叫,再對照上面的受管理型別清單檢查那筆記錄的欄位清單。PDFiumPas 自己的 v1.56.4 稽核,正是對整個程式庫執行了那樣的搜尋,並在一個單元中發現了這個暴露;其餘每一個 FillChar 呼叫位置,都早已是在清除一筆純數值記錄,在那裡 FillChar 過去是、現在仍是正確的工具

決策圖:依任意巢狀深度下的受管理字串與陣列欄位,決定用 FillChar 或 Default 清除 Object Pascal 記錄
任何深度都沒有受管理欄位的記錄可以繼續用 FillChar;其餘一律透過 Default 清除

讓一個被重用的 Result 在這裡變得危險的同一種編譯器行為,也驅動了這個程式碼庫中其他地方一個相關的 Delphi 對 FPC 分歧家族;一篇關於跨編譯器陷阱的配套文章涵蓋了一個案例,FPC 與 Delphi 對於一筆記錄結果的暫存,究竟在一個單一運算式內的哪個時刻被終結意見不一,那是同一個底層事實的另一種症狀——一個函式的記錄 Result,並不總是它表面看來的那種全新、私有的儲存。本文通篇用作運作範例的註解迴圈也並非假設性的:它就是你在建置註解審查面板時會寫的那種逐頁走訪,而那正是把一行 FillChar 變成緩慢記憶體洩漏的程式碼形狀

這一切都不需要更換程式庫,或去追某人已編譯程式碼裡的錯:它是 Object Pascal 語言本身的屬性,是每個 Delphi 與 FPC 開發者每天與之共事的屬性,而修復一旦你知道要找它,就是一次函式呼叫。此處描述的註解、書籤與連結註解 API,隨供 Delphi、C++Builder 與 Lazarus/FPC 使用的PDFium Component 一同出貨,並與本部落格其他地方涵蓋的其餘 PDF 讀取、繪製與註解表面並列