統計除錯給你的是一份排序過的 predicate 清單,而不是根本原因。你對每個分支與 null check 插樁,執行十萬次,然後演算法遞給你一個記分板。parser.c:144 的 buffer_idx > max_len 重要性是 0.94。main.c:38 的 log_file != NULL 也是 0.94。其中一個是 bug,另一個只是剛好在程式當機時總是為真。
高相關性代表 predicate 與當機同時發生。不代表 predicate 導致了當機。如果每次當機都通過同一條清理路徑,那條路徑上的每個 predicate 看起來都像有罪。你需要一種方法來區分兇手與旁觀者。
相關分數到底在量什麼
標準指標是 importance,由 Liblit 等人在 Cooperative Bug Isolation 中提出。Importance 是兩個數字的乘積:increase 與 support。
Increase 是 predicate 為真時的當機率與為假時的當機率之間的差異。如果 p != NULL 在 99% 的當機執行中為真,在 98% 的非當機執行中為真,increase 就是 0.01。那個 predicate 即使在每次當機都觸發,也毫無用處。
Support 是 predicate 為真的執行次數佔總執行次數的比例。它防止罕見但完美的預測器主導排序。一個只觸發一次但那次就當機的 predicate 會得到高 increase 與接近零的 support。
importance(pred) = (P(crash | pred=True) - P(crash | pred=False)) * P(pred=True)
這能很好地過濾雜訊。但它無法定位 bug。一個具有高 importance 的 predicate 可能是 bug、抓到 bug 的安全檢查,或是通往當機路徑上的常見操作。
為什麼看起來有罪的 predicate 通常是無辜的
考慮一個在 42 行的差一錯誤,迴圈邊界用了 <= 而不是 <。當機發生在二十行後。迴圈條件在每次迭代都觸發。45 行的邊界檢查在每次迭代都觸發。10 行的 null check 只在啟動時觸發一次。它們都有高 support,而且都出現在當機執行中。
問題在於時間與因果距離。一個在當機前幾個時刻執行的 predicate,比一個來自啟動階段的 predicate 更可能相關。同一個函式內的 predicate,比位於工具 logger 中的 predicate 更可能相關。統計除錯忽略了這一點,除非你手動加回去。
如何透過可達性與距離過濾
第一個精進是控制流可達性(control-flow reachability)。一個在當機執行中總是為真、但在到達相同程式碼的非當機執行中也總是為真的 predicate 並不可疑。它只是那條路徑的性質。
計算一個條件機率。不要問「這個 predicate 多常當機?」,而是問「考慮到程式已經到達控制流中的這個點,它當機的機率是多少?」如果當機率在你條件於「已經到達這個函式」時沒有改變,這個 predicate 就沒有提供新資訊。
第二個精進是空間距離。在按 importance 排序後,再按與當機點的距離重新排序。這是一個啟發式方法。Bug 可以傳播很遠。但大多數記憶體損毀與 null dereference bug 都位於行為首次偏離正確路徑的 predicate 的幾行以內。
以下是一個輕量級實作,計算 importance 並按距離過濾:
from dataclasses import dataclass
from typing import List, Set
@dataclass(frozen=True)
class Predicate:
pid: str
file: str
line: int
expr: str
@dataclass
class Run:
crashed: bool
crash_file: str = ""
crash_line: int = 0
predicates: Set[Predicate] = None
def importance(pred: Predicate, runs: List[Run]) -> float:
total = len(runs)
with_pred = [r for r in runs if pred in r.predicates]
without_pred = [r for r in runs if pred not in r.predicates]
if not with_pred or not without_pred:
return 0.0
crash_with = sum(1 for r in with_pred if r.crashed) / len(with_pred)
crash_without = sum(1 for r in without_pred if r.crashed) / len(without_pred)
support = len(with_pred) / total
return (crash_with - crash_without) * support
def avg_distance(pred: Predicate, runs: List[Run]) -> float:
crash_runs = [r for r in runs if r.crashed]
if not crash_runs:
return float('inf')
dists = []
for r in crash_runs:
if pred.file != r.crash_file:
return float('inf')
dists.append(abs(pred.line - r.crash_line))
return sum(dists) / len(dists)
def rank_predicates(runs: List[Run]) -> List[tuple]:
all_preds = set()
for r in runs:
all_preds.update(r.predicates)
scored = []
for p in all_preds:
imp = importance(p, runs)
dist = avg_distance(p, runs)
# Higher importance is better, lower distance is better.
if dist == float('inf'):
score = imp * 0.1
else:
score = imp / (1 + dist / 10)
scored.append((score, imp, dist, p))
scored.sort(key=lambda x: x[0], reverse=True)
return scored
在分層架構中,使用較弱的距離懲罰。在緊密的 parser 中,較強的懲罰效果更好。
從 predicate 到精確行號:三角測量
Predicate 仍然不是 bug 位置。它是一個布林表達式,例如 i <= buf->len。要定位到行號,請將 predicate 放在上下文中看。
關鍵問題是:這個 predicate 的否定是什麼,而否定是否能防止當機?如果 predicate 是 p == NULL 而當機是 null dereference,否定就能防止它。這使 predicate 成為直接原因。如果 predicate 是 log_level > 2 而當機是 buffer overflow,否定什麼也改變不了。這個 predicate 是旁觀者。
你可以部分自動化這個過程。對於每個高排序的 predicate,在重現案例中強制將它設為 false。如果當機消失,你就找到了控制條件。Bug 通常就是那個讓 predicate 在一開始變為 true 的賦值或比較。
統計排序將搜尋範圍從數千個 predicate 縮小到少數幾個。一個目標測試或短暫的除錯器 session 就能完成最後一哩路。
權衡:覆蓋率與精確度
你插樁的 predicate 越多,統計力量越大,但產生的誤報也越多。對每個記憶體存取都插樁會產生數百萬個 predicates。其中大多數會因為隨機機會而與當機弱相關。
解決方法是選擇性插樁。從 stack trace 中函式的 null check、邊界檢查、錯誤回傳值與分支條件開始。這些最可能區分當機與非當機行為。
你還需要足夠的執行次數。Importance score 是一個樣本統計量。一百次執行時,雜訊主導。一萬次時,訊號變清晰。CBI 論文的經驗法則是每個 bug 至少一千次執行。罕見的當機需要更多數量。
這方法抓不到的東西
統計除錯找到以可觀察 predicate 偏差形式顯現的 bug。它不會找到效能衰退、保持在邊界內的邏輯錯誤,或被插樁擾動的競態條件。
它也是本質上事後的。當機已經發生了。你正在對資料集進行鑑識。價值在於將搜尋空間從「整個 codebase」縮小到「大約五十行」。
一個最小化的端到端範例
以下是用模擬資料將各個環節組合起來的方法:
# Simulate runs for a bug at parser.c:42 (off-by-one loop bound)
runs = []
# 900 non-crashing runs
for _ in range(900):
runs.append(Run(
crashed=False,
predicates={
Predicate("p1", "parser.c", 10, "buf != NULL"),
Predicate("p2", "parser.c", 42, "i <= buf->len"), # the bug
Predicate("p3", "parser.c", 45, "i < buf->len"),
}
))
# 100 crashing runs: all hit the bug predicate
for _ in range(100):
runs.append(Run(
crashed=True,
crash_file="parser.c",
crash_line=62,
predicates={
Predicate("p1", "parser.c", 10, "buf != NULL"),
Predicate("p2", "parser.c", 42, "i <= buf->len"),
Predicate("p3", "parser.c", 45, "i < buf->len"),
Predicate("p4", "main.c", 5, "argc > 1"), # startup, irrelevant
}
))
results = rank_predicates(runs)
for score, imp, dist, pred in results[:3]:
print(f"{pred.file}:{pred.line} {pred.expr:20s} "
f"score={score:.3f} importance={imp:.3f} avg_dist={dist:.1f}")
執行這個會給 parser.c:42 的 p2 最高分,因為它同時具有高 importance 與短距離。p1 的 importance 類似但距離較遠。p4 因為在不同檔案中而被懲罰。
從你已經在調查的當機開始
你不需要研究等級的框架。挑一個頻繁重現的當機。在呼叫堆疊中對五個最有趣的 predicate 加入插樁。執行你的測試套件或生產流量。計算 importance 與距離。如果一個 predicate 能區分當機與非當機執行,且位置靠近當機點,你就已經縮小了搜尋範圍。
統計除錯不是 stack trace 或除錯器的替代品。它是「閱讀從 main 到 segfault 之間每一行程式碼」的替代品。相關性讓你進入正確的鄰域。可達性、距離與一個快速的否定檢查讓你走到門口。