一個傳回記錄的 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; // 受管理:參考計數
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;
為什麼洩漏只從第二次呼叫開始?
迴圈中的第一次呼叫總是無害的,而這正是讓此缺陷在測試中容易漏掉的原因。一個受管理記錄型別的局部變數一開始為零,而在一次迴圈與下一次之間沒有任何東西自動把它重新歸零,所以當迴圈第一次把函式的傳回值指派進那個變數時,它的 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); // 清除位元組,而不是一個存活的參考
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); // 已修正:原本是 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 Component 一同出貨,並與本部落格其他地方涵蓋的其餘 PDF 讀取、繪製與註解表面並列