統計除錯給你的是一份排序過的 predicate 清單,而不是根本原因。你對每個分支與 null check 插樁,執行十萬次,然後演算法遞給你一個記分板。parser.c:144buffer_idx > max_len 重要性是 0.94。main.c:38log_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:42p2 最高分,因為它同時具有高 importance 與短距離。p1 的 importance 類似但距離較遠。p4 因為在不同檔案中而被懲罰。

從你已經在調查的當機開始

你不需要研究等級的框架。挑一個頻繁重現的當機。在呼叫堆疊中對五個最有趣的 predicate 加入插樁。執行你的測試套件或生產流量。計算 importance 與距離。如果一個 predicate 能區分當機與非當機執行,且位置靠近當機點,你就已經縮小了搜尋範圍。

統計除錯不是 stack trace 或除錯器的替代品。它是「閱讀從 main 到 segfault 之間每一行程式碼」的替代品。相關性讓你進入正確的鄰域。可達性、距離與一個快速的否定檢查讓你走到門口。