前置知识: 零基础起步

调试方法学:pdb、breakpoint 与 traceback 阅读

12 min入门

接着性能篇的 40 秒巡检脚本,讲「找得到热点但改不对逻辑」的另一半:breakpoint() 与 pdb 常用命令(n/s/c/p/l/w)、post-mortem 事后调试、traceback 栈帧阅读,以及与 logging、IDE 调试器的分工,练习给带 bug 的文件批处理脚本定位修复。

知识点地图

  • 知识类别:Python 调试工具与方法学——标准库 pdb、内置函数 breakpoint()、traceback 阅读与 post-mortem 调试,是测试(找 bug 的存在性)与日志(留证据)之外的第三条防线(找 bug 的位置)。
  • 解决什么问题:程序能跑、不报错,但结果不对;或者报错栈一长串看不懂、崩在第三方库深处。性能篇(性能优化)的 cProfile 能告诉你「时间花在哪」,本文解决「值在哪一步变得不对」。
  • 什么时候用到:测试失败但看不出原因、线上 traceback 指向看不懂的库代码、多线程/异步任务本地无法复现。本模块此前所有篇目都用 print 输出验证行为,本文把「临时 print 大法」升级成一套可复用的调试流程。

你现在要解决什么问题

40 秒巡检脚本(性能优化 篇同款现场)优化后只跑 8 秒了,但测试同事发现报表里「告警平均持续时间」是预期的 10 倍。性能工具帮不上忙——程序不慢了,是算错了。你贴了几处 print,重跑一次 8 秒,输出两千行,没看出来。

print 调试的三个致命伤在这里全部出现:输出与代码两处维护、每加一行 print 就要重跑一次、成千上万行输出里找一行像大海捞针。pdb 的解法是把「重跑一千次看一千个变量」变成「停在那一次执行里,任意查看」。

python
def avg_duration(alarms: list[dict]) -> float:
    total = sum(a["end"] - a["start"] for a in alarms)
    return total / len(alarms)          # 崩溃?负数?除零?停进来看

在可疑行之前放一个 breakpoint(),程序运行到那里会自己停下来,交出交互式控制台。

先动手:五个命令跑通第一轮调试

把有问题的脚本简化成最小现场:

python
# buggy_report.py
def parse_line(line: str) -> dict:
    parts = line.strip().split("|")
    return {"start": float(parts[1]), "end": float(parts[2])}

def avg_duration(alarms: list[dict]) -> float:
    total = sum(a["end"] - a["start"] for a in alarms)
    return total / len(alarms)

def main() -> None:
    alarms = [parse_line(line) for line in open("alarm.log", encoding="utf-8")]
    breakpoint()                        # <- 停在这
    print(f"平均持续时间: {avg_duration(alarms):.1f}s")

if __name__ == "__main__":
    main()
text
$ python buggy_report.py
> buggy_report.py(11)main()
-> print(f"平均持续时间: {avg_duration(alarms):.1f}s")
(Pdb)

终端换成 (Pdb) 提示符,程序冻结在这一行,下面的命令就是你的手和眼(记法:n 下一行、s 进函数、c 跑到底、p 打印、l 看上下文、w 看调用栈):

text
(Pdb) p alarms[0]           # p:打印任意表达式的值
{'start': 100.0, 'end': 160.0}
(Pdb) p len(alarms)
1200
(Pdb) n                     # n:next,执行当前行,停在下一行
> buggy_report.py(11)main()
-> print(f"平均持续时间: {avg_duration(alarms):.1f}s")
(Pdb) s                     # s:step,进入被调用的函数内部
--Call--
> buggy_report.py(6)avg_duration()
-> def avg_duration(alarms: list[dict]) -> float:
(Pdb) p alarms[5]           # 在函数内部也能访问参数
{'start': 300.0, 'end': -40.0}
(Pdb) l                     # l:list,看当前停点前后的源码
  1     def parse_line(line: str) -> dict:
  ...
(Pdb) w                     # w:where,打印完整调用栈
  ...
  buggy_report.py(14)<module>()
-> main()
  buggy_report.py(11)main()
-> print(f"平均持续时间: {avg_duration(alarms):.1f}s")
> buggy_report.py(6)avg_duration()
-> def avg_duration(...) -> float:
(Pdb) c                     # c:continue,放行到结束或下一个断点
平均持续时间: -12.3s

alarms[5] 的 end 是 -40.0——负的结束时间。根链条出来一半了:不是求平均的代码错,是上游解析就喂了脏数据。继续用 b(break,设断点)下钻:

text
(Pdb) b buggy_report.py:3     # 在 parse_line 的 return 行设断点
Breakpoint 1 at buggy_report.py:3
(Pdb) c
> buggy_report.py(3)parse_line()
-> return {"start": float(parts[1]), "end": float(parts[2])}
(Pdb) p parts
['bedroom-01', '300.0', '-40.0']
(Pdb) c

原始日志里 end 就是 -40——设备跨零点时的回绕没有处理。到此定位完成,全程只重跑了一次。

命令速查与易错

命令全称作用最常用误用
nnext执行当前行,不进函数内部想进函数时用了 n,直接跳过去了
sstep执行当前行,进入函数内部误入 print/第三方库内部出不来,此时用 r 或 c 逃出
ccontinue放行到下一断点或结束没设断点就 c,一口气跑完了
p exprprint求值并打印表达式对生成器用 p 只看到对象地址,先 p list(gen)
pp exprpretty print结构化打印(列表/字典友好)深嵌套结构用 p 挤成一行看不清
llist显示停点附近源码3.13+ 的 pdb 默认带语法高亮,l 后接回车翻页
wwhere打印当前调用栈忘了 w 直接下结论,其实你在错误的那一层栈帧里
b 行号break设行断点在注释行设断点,断点从未命中
aargs打印当前函数全部参数—
rreturn执行到当前函数返回—
qquit放弃调试退出程序想继续跑却按了 q,进程直接没了

易错点一:s 会进入一切被调用的函数,包括 print 和第三方库内部,新手常被卷进深处迷路。逃出组合拳:r 跳到当前函数返回处,或 c 直接放行。原则是先用 n 走读,确认要下钻再 s。

易错点二:(Pdb) 里敲的 p 命令与 Python 变量名可能撞车——某个变量恰好叫 p 时,p p 才能打印它;要执行任意 Python 语句(赋值等)用 ! 前缀:!result = []。

易错点三:断点行号是当前文件的,跨文件断点写 b module.py:12;调试器停住时改了源码文件,断点行号不会自动跟随。

traceback:崩溃现场的第一手证据

程序崩溃时 Python 打印的 traceback 是免费的 post-mortem 报告,读它的顺序是从最后一行往上读:

text
Traceback (most recent call last):
  File "crawler.py", line 42, in main
    record = parse_detail(html)
  File "crawler.py", line 17, in parse_detail
    price = item.select_one(".price").text
AttributeError: 'NoneType' object has no attribute 'text'

逐行解读:

  1. 最后一行是异常类型与消息:'NoneType' object has no attribute 'text'——对 None 取了 .text。组合出根因假设:select_one(".price") 返回了 None(页面里没有 .price 节点);
  2. 倒数第二行是异常发生的精确位置:crawler.py, line 17——直接去看这一行;
  3. 更上面的帧是调用链:main(42) -> parse_detail(17)。外层帧通常不是错误源头,但告诉你「带着什么参数进来的」。

这个例子对应爬虫开发的最高频崩溃——字段缺失(Python 爬虫实战 篇的反爬现场:目标页改版或返回了错误页 HTML)。读栈三步走:最后一行定类型、倒数第二行定位置、外层帧定上下文。修法通常是防御性判空,而不是改栈上层的代码。

易错点:Traceback (most recent call last) 的字面意思是「最近的调用在最下面」——栈是倒着印的,别从第一行开始读。

post-mortem:崩溃后尸检,不用先插桩

程序已经崩了才想起来没插 breakpoint?不需要重跑。用 pdb 的事后模式,直接「复活」在崩溃现场:

python
# 崩溃后,在同一终端进入交互解释器
>>> import pdb, buggy
>>> buggy.main()
Traceback (most recent call last):
  ...
AttributeError: ...
>>> pdb.pm()          # post-mortem:停在崩溃的那一帧
(Pdb) p alarms

更顺手的等价形式:启动时就挂上 pdb,崩了自动进入尸检:

bash
python -m pdb buggy_report.py        # 单步调试启动
python -m pdb -c continue buggy_report.py   # 启动后直接跑,崩溃时自动进入 pdb

-c continue 是工程上最省心的组合:不崩就是全自动跑完,崩了自动停在尸检帧——相当于给脚本免费配了一个「崩溃时自动停靠」的黑匣子。pdb.pm() 检查的是「异常发生时」的栈帧,能查看当时的全部局部变量,但改不了已经发生的流程——尸检用于定位,修复仍要改代码重跑。

与 Celery 场景的组合:异步任务在 worker 里崩,本地复现常用「同步模式 + post-mortem」——把任务函数原样在本地脚本里调用(不走 broker),崩溃自动进 pdb,比反复重启 worker 快一个数量级(Celery 部署细节见 分布式任务队列)。

调试手段的分工:一张决策表

手段适用现场强项局限
日志(日志系统)线上、长跑、事后留存证据、无侵入看不到没打印的变量
print本地一次性验证零成本一次一跑,残留难清理
pdb / breakpoint()本地、可复现停在任意时刻任意查看阻塞执行,多人服务不能用
post-mortem已崩溃、栈深不插桩直接尸检只能看不能改流程
IDE 图形调试器本地、复杂工程鼠标设断点、watch 窗口依赖 IDE,远程环境不便
logging 级别的断点生产事故回溯结合日志时间线需要提前埋点

分工口诀:线上留日志,本地停断点,崩溃先尸检。IDE 调试器(VS Code/PyCharm 的图形界面)底层就是 pdb 同源机制,会了命令版再去用图形版是顺路;反过来只依赖图形版的人,到了服务器、容器、CI 这些没有 GUI 的现场会束手无策——这正是标准库 pdb 的存在意义。

breakpoint() 相比 import pdb; pdb.set_trace() 的优势不只是短:Python 3.7+ 它尊重环境变量 PYTHONBREAKPOINT——设为 0 可以一键全局禁用所有 breakpoint()(CI 里跑测试时防止误停),设为其他调试器入口可以统一切换工具,插桩代码一行不用改。

推导式里的变量:一个 pdb 才能看清的遮蔽现场

「变量值明明对,循环里就是不对」类 bug 的一个经典来源是作用域混淆。模块顶层(Python 2 遗风)与推导式内部的行为差异:

python
x = 100
squares = [x * x for x in range(3)]
print(x)          # 100 —— 推导式有自己的作用域,Python 3 不泄漏

# 但如果函数内外同名参数被不小心复用:
def report(alarms):
    alarms = [a for a in alarms if a["end"] >= a["start"]]  # 过滤
    ...
    total = sum(a["end"] - a["start"] for a in alarms)       # 这里的 a 是推导式的 a

生成器表达式里的 a 属于表达式自己的作用域——手写 for 循环时 a 会留在函数作用域里,循环结束后仍可访问;换成生成器/推导式后「循环变量」出不去。混用两种写法时,误以为「上一次循环的 a 还在」就是 bug。这类问题的 pdb 查法:s 进表达式求值帧,p a 看当前绑定;或在 w 栈视图里确认你看到的 a 属于哪一帧。作用域的系统讲解见 函数详解,此处记住调试口诀:名字对不上号时,先用 w 看自己站在哪一帧。

动手实践

练习一(预测题):不运行代码,判断下面 pdb 会话在 p total 处打印什么——

python
def f(nums):
    total = 0
    for n in nums:
        total += n
    breakpoint()
    return total

f([1, 2, 3])

在 (Pdb) 提示符下依次执行 p nums、p n、n、p total。

提示:断点停在 breakpoint() 所在行,此时 for 循环已完整执行完;n 会执行 return 行。

参考实现
text
p nums  ->  [1, 2, 3]
p n     ->  3            (for 循环变量保留着最后一次的值,函数作用域不清理)
n       ->  执行 return total,函数即将返回
p total ->  6

要点:断点停的是「还没执行」的当前行;for 循环变量 n 在循环结束后依然存在(值为最后一个元素 3)——这与推导式不同,推导式的变量不外泄。

练习二(实战题):下面的文件批处理脚本有一个 bug:统计的「有效行数」总是比预期多 1,且偶尔抛 ValueError。不许删代码重写,用 breakpoint()/pdb 定位两处问题并给出最小修复。

python
# batch_count.py
def count_valid(path: str) -> int:
    count = 0
    with open(path, encoding="utf-8") as f:
        lines = f.readlines()
        for line in lines:
            if not line.strip():
                continue
            value = float(line.split(",")[1])
            if value > 0:
                count += 1
    return count

if __name__ == "__main__":
    print(count_valid("sample.csv"))

测试数据 sample.csv:

text
name,value
alpha,1.5
beta,2.0

gamma,3.5

提示:先别看参考。把 breakpoint() 放进循环里,p line 逐行看;注意表头行 name,value 与空行各自的走向;再想想 float("value") 会怎样。

参考实现
python
def count_valid(path: str) -> int:
    count = 0
    with open(path, encoding="utf-8") as f:
        lines = f.readlines()
        for line in lines:
            if not line.strip():
                continue          # 空行跳过:没有它 count 也不受影响,
                                  # 但 float("") 会先抛 ValueError
            parts = line.strip().split(",")
            if len(parts) < 2:
                continue
            try:
                value = float(parts[1])
            except ValueError:
                continue          # 表头 "value" / 脏数据走这里
            if value > 0:
                count += 1
    return count

调试路径还原:把 breakpoint() 放在 value = float(...) 之前,第一轮 n 单步 + p line,会看到表头行 name,value 没被任何条件拦下,float("value") 抛 ValueError——这是「偶尔崩溃」的来源;修复是 try-except 或表头判断。而「多数 1」来自 lines 的最后一个换行:readlines 保留行尾 \n,末尾空行 "\n" 的 line.strip() 是空串被 continue 跳过没问题,但如果 CSV 最后一行是 delta,4.0 后紧跟一个只有空白字符的行,split(",")[1] 对某些格式会取到带 \n 的片段——更稳的写法是先 line.strip() 再 split(参考实现已改)。 pdb 里可用 p lines[-3:] 直接观察文件尾部,比 print 快得多。

练习三(实战题):用 post-mortem 而不是插桩定位下面脚本的崩溃根因,并说出「如果你只看 traceback 最后一行会得出什么错误结论」:

python
# nested.py
def load(config: dict) -> str:
    return config["db"]["primary"]["host"]

CONFIGS = {
    "prod": {"db": {"primary": {"host": "10.0.0.1"}}},
    "dev": {"db": {"replica": {"host": "127.0.0.1"}}},
}

print(load(CONFIGS["dev"]))

提示:先跑一遍让它崩;python -m pdb -c continue nested.py 进入尸检;p config 看现场。

参考实现
text
$ python -m pdb -c continue nested.py
Traceback ...: KeyError: 'primary'
Uncaught exception. Entering post mortem debugging
> nested.py(2)load()
-> return config["db"]["primary"]["host"]
(Pdb) p config
{'db': {'replica': {'host': '127.0.0.1'}}}
(Pdb) p config["db"].keys()
dict_keys(['replica'])
(Pdb) q

只看最后一行 KeyError: 'primary' 容易得出「配置里没有 primary,改成 config[“db”].get(“primary”, …)”」的结论——方向对但层次浅:真正的问题是 dev 环境的层级命名不同(replica)。尸检帧里 p config 直接看到完整数据形状,一步定位到「数据形态不一致」,修法应是统一配置结构或按环境取键。这与 结构模式匹配 的映射模式降级分支是同一类问题的两种处理路线。

练习四(找错题):调试会话里出现了下面的 w 输出,同事断言「bug 在 third_party/client.py 第 88 行」,请判断这个结论是否成立,并说明你的排查顺序:

text
  File "app.py", line 30, in main
    result = client.fetch(url)
  File "third_party/client.py", line 88, in fetch
    return self._parse(resp.json())
  File "app.py", line 12, in _parse
    return record["items"][0]
IndexError: list index out of range
参考实现

结论不成立。栈是「最近调用在最下」:异常真正发生在最底部的 app.py, line 12——record["items"][0] 对空列表取下标。third_party/client.py:88 只是调用链中间一帧,它忠实地把响应交给了 app 自己的 _parse。排查顺序:先修最底帧的越界(record["items"] 为空的防御),再决定是否要在 fetch 层校验响应——多层栈时「谁的契约被违反」要看最底帧的异常类型指向哪一行的哪个表达式。

常见坑点速记

  • 断点停的是「即将执行」的当前行,p 看到的变量是执行到此刻的快照;
  • s 会走进一切函数包括标准库,迷路时 r 或 c 逃出;先 n 后 s 是默认节奏;
  • traceback 从最后一行往上读,最底帧才是异常发生地;
  • p 是 pdb 命令,打印名为 p 的变量要写 p p;赋值等 Python 语句加 ! 前缀;
  • CI 里记得 PYTHONBREAKPOINT=0,防止残留的 breakpoint() 卡死流水线;
  • post-mortem 只读不改流程,定位后仍需改代码重跑验证。

与之前和之后的知识的关系

  • 往前:异常类型与 traceback 的产生机制见 异常处理;本文的现场脚本沿用 性能优化 的巡检案例,工具分工是「性能看 cProfile,逻辑看 pdb」;
  • 并行:日志(日志系统)负责线上留证,pdb 负责本地停点;异步任务的调试(asyncio 的 TaskGroup 报错聚合)见 异步编程深水区;
  • 往后:测试先行能减少调试总量——pytest 的失败报告自带短 traceback(Python 测试);Python 与 C 扩展 的 C 层崩溃需要 gdb/lldb 等系统级调试器,是 pdb 的下一层。

官方文档

自我检查

  • 能默写 n/s/c/p/l/w 六个命令的作用,并说出「先 n 后 s」的节奏理由;
  • 能从一段 traceback 里按「最后一行定类型、倒数第二行定位置、外层帧定上下文」三步说出根因假设;
  • 能用 python -m pdb -c continue 脚本.py 做到「不崩全自动、崩了自动尸检」;
  • 能说出 breakpoint() 相比 pdb.set_trace() 的两点优势;
  • 能给「线上 / 本地可复现 / 已崩溃」三种现场各指定一种首选调试手段。