Statistical debugging 给你的是 predicates 的排序列表,而不是根因。你对每个分支和 null check 进行插桩,运行十万次执行,算法给你一个记分板。parser.c:144 处的 buffer_idx > max_len 的 importance 为 0.94。main.c:38 处的 log_file != NULL 也是。其中一个是 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 本身、捕捉到它的安全检查,或者是通往崩溃路径上的常见操作。
为什么看起来有罪的 predicates 往往是无辜的
考虑第 42 行的一个 off-by-one 错误,其中循环边界使用了 <= 而不是 <。崩溃发生在二十行之后。循环条件在每次迭代时触发。第 45 行的 bounds check 在每次迭代时触发。第 10 行的 null check 在启动时触发一次。它们都有很高的 support,并且都出现在崩溃运行中。
问题在于时间距离和因果距离。在崩溃前瞬间执行的 predicate 比启动时的 predicate 更可能相关。同一函数内的 predicate 比 utility logger 中的 predicate 更可能相关。Statistical debugging 忽略了这一点,除非你把它加回去。
如何通过可达性和距离进行过滤
第一个改进是控制流可达性。一个在崩溃运行中总是为真,但在到达相同代码的非崩溃运行中也总是为真的 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,在复现案例中强制它为假。如果崩溃消失了,你就找到了控制条件。Bug 通常是最初使 predicate 为真的赋值或比较。
统计排序将搜索范围从数千个 predicates 缩小到少数几个。一个有针对性的测试或短暂的调试器会话就能完成闭环。
权衡:覆盖率与精确度
你插桩的 predicates 越多,统计能力越强,但产生的 false positives 也越多。对每次内存访问进行插桩会产生数百万个 predicates。大多数会由于随机机会而与崩溃弱相关。
解决方案是选择性插桩。从 stack trace 中函数的 null checks、bounds checks、错误返回值和分支条件开始。这些最可能将崩溃行为与非崩溃行为区分开来。
你还需要足够的运行次数。Importance score 是一个样本统计量。运行一百次,噪音占主导。运行一万次,信号变得清晰。CBI 论文中的经验法则是每个 bug 至少运行一千次。罕见的崩溃需要大量数据。
这抓不到什么
Statistical debugging 找到表现为可观察 predicate 偏差的 bug。它不会发现性能回归、保持在边界内的逻辑错误,或被插桩扰动的 race condition。
它在本质上也是事后的。崩溃已经发生了。你在对一个数据集进行取证。价值在于将搜索空间从”整个 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 因为在不同文件中而受到惩罚。
从你已经在调查的崩溃开始
你不需要研究级别的框架。选择一个频繁复现的崩溃。对调用栈中五个最有趣的 predicates 添加插桩。运行你的 test suite 或生产流量。计算 importance 和 distance。如果一个 predicate 将崩溃运行与非崩溃运行分开,并且位于崩溃点附近,你就已经缩小了搜索范围。
Statistical debugging 不能替代 stack traces 或 debuggers。它是替代阅读 main 和 segfault 之间每一行代码的方法。相关性让你进入附近区域。可达性、距离和对否定的快速检查带你到门口。