Delphi 程式帶著 ERangeError 和一句光禿禿的「Range check error」停了下來,而且往往只在兩種組建設定的其中一種出現。當以啟用範圍檢查({$R+})編譯的程式碼把陣列或字串索引到邊界之外,或把一個塞不進目標序數型別的值存進去時,Delphi 就會擲出這個例外。修法幾乎總是落在算出壞索引或壞值的地方,而那很少是偵錯器停下來的那一行
Delphi 的「Range check error」是什麼意思?
ERangeError 來自編譯器在範圍檢查開啟時插入的檢查。開了 {$R+}(長寫法 {$RANGECHECKS ON},或專案選項 › 建置 › Delphi 編譯器 › 編譯 › 範圍檢查)之後,每個陣列與字串下標都會拿來與宣告邊界比對,每次對較小序數、子界或列舉變數的賦值也都會與該型別的範圍比對。預設狀態是 {$R-},這樣編譯出來的程式碼完全沒有檢查,越界寫入就直接落在隔壁的記憶體上。檢查失敗時,若 SysUtils 有連結進來,會以資源字串「Range check error」擲出 ERangeError;從不使用 SysUtils 的程式則直接以執行期錯誤 201 終止。編譯期錯誤 E1012(Constant expression violates subrange bounds)只涵蓋編譯器看得到是常數的索引;任何執行期才算得出來的值都取決於 {$R+}。這個開關也可以按單元生效:預編譯的 DCU 保留它建置當下的狀態,而單元開頭的 {$R-} 會為該單元覆蓋專案選項
溢位檢查是另一個開關、另一個例外。{$Q+}({$OVERFLOWCHECKS ON})檢查 +、-、*、Inc、Dec 這類整數算術,結果塞不下時擲出 EIntOverflow;{$R+} 檢查的是索引與存放。兩者在小型態上交會。Byte 算術以 Integer 求值,所以 B = 255 時 B := B + 1 算出 256、毫無溢位,真正讓範圍檢查失敗的是存回 Byte 的那一刻。同一個值改用 Inc(B) 就是溢位檢查的運算:{$Q+} 下擲出 EIntOverflow,只開 {$R+} 時則悄悄繞回 0。哪些設定開了這些開關,取決於 Delphi 版本與專案的建立方式。在您相信「錯誤為什麼只出現在某個組建」的理論之前,先去專案選項把每個組建設定的實際值讀一遍
常見成因,附最小重現
真實 Delphi 程式碼裡的範圍檢查錯誤,多半落在幾種模式裡,每種都有小巧而機械化的修法。陣列是最常見的案例。靜態 array[0..9] 接受 0 到 9 的索引,動態陣列或 TBytes 接受 0..Length(A) - 1,字串則是 1 起算,所以迴圈請對著 Low 與 High 寫,別寫死邊界。空集合正是程式碼破掉的地方:空動態陣列的 High 是 -1,所以用 Integer 計數器的 for I := 0 to High(A) 迴圈會正確地跑零次,而拿 A[High(A)] 或 A[Count - 1] 去「取最後一個元素」讀到的卻是索引 -1。相關案例的行為又不太一樣:
- 容器類別自己檢查索引,不管
{$R}狀態為何:TList與TStringList擲出EListError或EStringListError,TList<T>擲出EArgumentOutOfRangeException - 從檔案表頭或 API 進來、型別是
Cardinal或DWORD的數量,在零的時候很危險,因為Count - 1求值成什麼取決於編譯器怎麼給運算式定型;迴圈計數器維持Integer,數量驗證一次、轉換一次,並明確測試Count > 0
procedure ArrayRepros(const Data: TBytes; Buf: Pointer);
var
Last: Byte;
P: PByte;
I: Integer;
begin
// 空陣列:High(Data) = -1,所以這行讀的是 Data[-1]
Last := Data[High(Data)]; // {$R+} 下擲出 ERangeError
// 修法:取最後一個元素之前先測長度
if Length(Data) > 0 then
Last := Data[High(Data)];
// TByteArray 宣告為 array[0..32767] of Byte,所以 {$R+} 下
// 縱使 Buf 抱著幾 MB,索引超過 32767 一樣失敗
I := 40000;
Last := PByteArray(Buf)^[I]; // I = 40000 時擲出 ERangeError
// 修法:索引真正的緩衝區,或改用 PByte 算術
P := PByte(Buf);
Inc(P, I);
Last := P^; // 沒有範圍檢查,I 的有效性請自己把關
end;
PByteArray 這個案例連資深開發者都會中招,因為宣告的邊界與緩衝區毫不相干。Delphi 的 SysUtils 和 Free Pascal 的版本都把 TByteArray 宣告為 array[0..32767] of Byte——一個源自 16 位元時代的 32 KB 窗口。{$R+} 之下,檢查拿您的索引與 32767 比對,所以對 100 KB 串流的合法偏移會擲出 ERangeError;{$R-} 之下沒有檢查,越過緩衝區真正結尾的索引永遠不會被抓到。直接索引 TBytes 本身,拿到的就是對實際長度的檢查,這也是它該當預設選擇的原因。PByte 算術則完全沒有範圍檢查,請搭配對緩衝區大小的明確測試使用
序數賦值是另一大家族。把 256 塞給 Byte、70000 塞給 Word、13 塞給 1..12 子界,都會讓檢查失敗;把負 Integer 塞給 Cardinal 也一樣。Delphi 把混合正負的 32 位元運算元當 Int64 求值,所以算術本身通常沒錯,錯誤出現在存放那一刻,正好指向型別太窄的那個變數。這張網的漏洞是硬型轉換:Byte(I)、Cardinal(I)、TMyEnum(I) 不產生任何範圍檢查,默默截斷或重新解讀。當一個從檔案讀來的 32 位元值要挑選列舉成員時,先以 Cardinal 與 Ord(High(TMyEnum)) 比對,再做轉型
var
B: Byte;
C: Cardinal;
I: Integer;
Addr: Integer;
Obj: TObject;
begin
B := 255;
B := B + 1; // 以 Integer 求值成 256,存放失敗:ERangeError
I := -1;
C := I; // 負值塞進 Cardinal:ERangeError
C := Cardinal(I); // 硬轉型:無檢查,C = 4294967295
// Win64:指標 8 位元組,Integer 仍是 4
Obj := TObject.Create;
Addr := NativeInt(Obj); // Integer:超過 2 GB 失敗(Cardinal:超過 4 GB)
// 修法:指標大小的值放在 NativeInt / NativeUInt
end;
// 對 2^32 取模的算術,在 Delphi 與 FPC 下行為必須一致
function AddMod32(A, B: Cardinal): Cardinal; inline;
begin
Result := Cardinal((UInt64(A) + UInt64(B)) and $FFFFFFFF);
end;
Win64 又加了指標寬度的變體。Win64 上 Pointer、NativeInt、TComponent.Tag(XE2 起是 NativeInt)、LPARAM、WPARAM 都是 8 位元組,而 Integer、Longint、Cardinal 仍是 4。一旦位址超出目標型別的範圍——Integer 是 2 GB、Cardinal 是 4 GB——經由有範圍檢查的賦值存位址就會擲出 ERangeError;經由硬轉型存放則會截斷它,之後在某處引發 access violation。小型測試程式往往配置在低位址區,所以這類 bug 常出現在長時間執行的行程裡,而不是單元測試。凡是持有指標、handle 或訊息參數的東西,一律宣告成 NativeInt 或 NativeUInt。Linux64 上的 Longint 與指標陷阱把同一套邏輯帶到 LP64 目標上——那裡連 Longint 本身都會變寬
為什麼錯誤只在 Debug、或只在 FPC 下出現?
只出現在一種組建的範圍檢查錯誤,幾乎總是真實的越界存取,只是另一種組建沒在檢查。{$R-} 的 Release 組建照跑同一個壞索引、一聲不吭,結果不是讀到鄰居的記憶體就是把它改壞——這比丟例外更糟。Free Pascal 另有一套開關:{$mode delphi} 改的是語言語法與語義,不會打開任何檢查;FPC 的範圍檢查來自 {$R+} 或命令列選項 -Cr,溢位檢查來自 -Co,在 Lazarus 的專案選項 › 編譯器選項 › 偵錯底下。FPC 還會以原生整數大小或更大來求值整數運算式,所以在 32 位元 Delphi 裡默默繞回的運算式,在 64 位元 FPC 目標上可能算出超過 High(Cardinal) 的值,存回 Cardinal 時檢查就失敗。我們自己的 SHA-256 程式碼就精準踩過這個坑:寫成普通 Cardinal 加法的對 232 取模加法,在開檢查的 FPC 組建裡擲出 Range check error。像上面的 AddMod32 那樣用 UInt64 求和再遮罩,兩個編譯器的輸出逐位元組一致,也不必為整個單元關掉檢查。更多這類可攜性陷阱,收錄在只在 FPC 下碰巧能動的 Delphi 程式碼
讓偵錯器停在丟出例外的那一行
找範圍檢查錯誤最快的方法,是讓 IDE 偵錯器在它身上中斷,然後沿呼叫堆疊往上走到產出壞值的程式碼。例外標記的是檢查開火的位置,成因通常在下面幾層框架:
- 以開著範圍檢查的 Debug 設定執行,並確認
ERangeError不在偵錯器選項的語言例外頁面的忽略清單裡 - 當對話框回報「raised exception class ERangeError with message 'Range check error'」,選擇中斷(Break)並打開呼叫堆疊視窗;頂層以下的框架會告訴您索引或值從哪裡來
- 如果頂層框架在 RTL 或 VCL 裡,開啟「Use debug .dcus」讓那些框架解析到原始碼
- 在算出索引的那行賦值上設條件中斷點(例如
Index < 0),比例外早一步抓到錯誤 - 只出現在客戶機器上的錯誤:出貨的組建保持範圍檢查開啟,並搭配 madExcept、EurekaLog 或 JCL Debug 之類的堆疊追蹤記錄器,把同樣的呼叫堆疊寫進記錄檔
該不該把範圍檢查關掉?
每個 Debug 與 CI 組建都保持範圍檢查開啟;要關只關在局部,圈住您自己驗證過邊界的實測熱點迴圈。全域關掉不會移除 bug,只會移除報告。經典案例是一個呼叫 SetLength(Result, 16) 卻寫入第 17 到 24 字元的十六進位格式化常式:{$R+} 下它立刻失敗,{$R-} 下它每次呼叫都往字串結尾後面寫八個字元,寫進緊隨其後的任何堆積記憶體,很久之後在某個不相干的地方崩潰。某個常式真的熱到不容檢查時,請正確圈定範圍:{$R-} 後接 {$R+} 看起來乾淨,卻會強制讓單元其餘部分開檢查——就算專案原本沒開。正確做法是保存並還原先前狀態,並把無檢查區域收在一個完整的小常式裡,由它的呼叫者負責邊界
{$IFOPT R+}
{$DEFINE RANGECHECKS_WERE_ON}
{$R-}
{$ENDIF}
procedure XorBlock(Dst, Src: PByte; Count: Integer);
var
I: Integer;
begin
// 呼叫者保證 Count 同時塞得進兩塊緩衝區;迴圈外檢查一次
for I := 0 to Count - 1 do
Dst[I] := Dst[I] xor Src[I];
end;
{$IFDEF RANGECHECKS_WERE_ON}
{$UNDEF RANGECHECKS_WERE_ON}
{$R+}
{$ENDIF}
PDF 解析程式碼裡的範圍檢查錯誤
解析 PDF 檔案的程式碼格外暴露,因為它拿來當索引的幾乎每個數字都來自檔案本身。交叉參考表裡的位元組偏移、串流的 /Length 值、交叉參考串流 /W 陣列裡的欄位寬度(ISO 32000-1 §7.5.8)、物件數量,以及簽章裡的 /ByteRange 條目,在畸形或惡意檔案裡全都不可信任。天真的守衛像 if A + B > Length(Data),遇上精心構造的 /ByteRange [9223372036854775807 1 0 0] 就破功:溢位檢查關著時,A + B 繞回負的 Int64,測試通過,Data[A] 之後才失敗。請把每個值對剩餘長度檢查((A >= 0) and (B >= 0) and (A <= Len) and (B <= Len - A)),讓任何加法都無法繞回。複合守衛另藏一個陷阱:Delphi 的 and 由左至右短路求值,但只有上界測試擋不住負索引,因為 -1 < Length(FPages) 是 True;帶正負號的下界必須先測
// 看起來安全,但負的 PageIndex 能通過上界測試:
// if FDocOpen and (PageIndex < Length(FPages)) and
// (FPages[PageIndex].PageObj <> nil) then ...
if (PageIndex >= 0) and (PageIndex < Length(FPages)) then
begin
if FDocOpen and (FPages[PageIndex].PageObj <> nil) then
Result := FPages[PageIndex].PageObj
else
Result := nil;
end
else
raise ERangeError.CreateFmt(
'Page index %d is out of range (0..%d)',
[PageIndex, Length(FPages) - 1]);
守衛擋住了崩潰,卻沒解釋索引為什麼會錯;在頁面處理程式碼裡,常見原因是兩種排序對不上。ISO 32000-1 §7.7.3 透過頁面樹的 Kids 陣列定義頁面順序;物件編號與位元組偏移對此隻字未提,物件 20 可以裝著第 1 頁、物件 3 裝著第 2 頁。按檔案順序掃描物件來建頁面清單的程式碼,在每份產生器依序寫頁的文件上都正確,碰到任何不這麼做的文件就出錯——例如事後加了封面頁的編輯過檔案。呼叫者按顯示順序算出的索引,於是落在錯的頁面或清單結尾之外。PDF 頁面順序案例研究示範了如何用 qpdf 證實不符,並從頁面樹重建頁面清單
想把自己的解析器洗乾淨,就帶著 {$R+} 與 {$Q+} 組建,跑過一份來源多元的語料庫:Word、LaTeX、掃描器韌體、分割合併工具產生的檔案,再加幾份故意截斷的。每份丟例外的檔案都是一個資料點,指出您程式碼裡的一條假設;把假設修一次,別加一條下一個來源就會在不對的時機開火的產生器特例。呼叫 PDF 程式庫的程式碼,則把程式庫的頁數當權威。在 HotPDF Delphi PDF component 裡,LoadFromFile 回傳載入文件的頁數,LoadedPageCount 事後回報同一個數;頁面索引遵循頁面樹的顯示順序,所以凡是從外部資料推導的索引,0..LoadedPageCount - 1 就是安全範圍