AI 技术博客
返回首页
Python 基础 17 分钟阅读

调试第一课:print 与 pdb 的实战打法

Python调试pdb工具链

引言

这是第一章的第 8 篇。到目前为止,我们有了能跑代码的机器(第 2 篇)、理解了执行原理(第 3 篇)、会隔离环境与装包(第 4、5 篇)、能在 REPL 里快速验证想法(第 6 篇)、知道怎么写规范代码(第 7 篇)。但所有这些技能都默认一个前提:代码是错的,得先找到错在哪。写程序的主要时间其实花在”找出为什么不符合预期”上,这门手艺就叫调试(debugging),而 Python 的调试第一课,就从 print 和 pdb 开始。

本文解决的具体问题:程序报错了(或结果不对),我照着什么流程去定位?读者应已掌握第 6 篇的 REPL 操作(pdb 的交互风格与 REPL 一脉相承,但它是面向调试专用);本文的所有演示都基于 Python 3.10+ 的标准库行为,不安装任何第三方包——pdb 和 breakpoint 都是内置能力。

调试的本质:控制流 × 状态

任何 bug 都可以归结为两个问题的组合:程序执行到了哪一行(控制流,control flow)?执行到那一行时,各个变量的值是什么(状态,state)?当”实际的控制流或状态”与”你头脑中的预期”出现偏差时,bug 就产生了。调试的全部工作,就是不断缩小”实际”与”预期”之间的偏差区间:

  1. 观察到症状(报错信息、错误输出);
  2. 定位到可疑代码区间(通常是最近的几次函数调用);
  3. 在区间内检查关键变量的状态;
  4. 修正偏差,回到第 1 步,直到症状消失。

理解这个框架,你就明白为什么”看代码找 bug”效率低:人脑在模拟控制流时不可靠,尤其是循环和嵌套调用。工具的价值在于把控制流和状态变成可见的。

print 调试就是在代码里插入打印语句,观察关键位置的变量值。它的优点无可替代:零学习成本、任何环境都能用、不会引入新工具。但它的缺点会随代码规模放大:打印语句是侵入式的,插进源码就要从源码里删掉,而”忘了删”的 print 会污染真正的输出;print 也看不见控制流——你只知道某个值打印出来了,却不知道它是从哪条路径走到的;循环量大时,print 会疯狂刷屏。

在深入 pdb 之前,先把 print 调试的打法学规范。下面是一个典型场景:写一个统计学生成绩的脚本 scores.py,它计算相邻成绩的差值,运行即报错:

# scores.py —— 运行时抛出 IndexError
def diff_with_next(scores):
    diffs = []
    for i in range(len(scores)):
        diffs.append(scores[i + 1] - scores[i])
    return diffs

scores = [78, 92, 45]
print(diff_with_next(scores))

运行 python scores.py,报错:

Traceback (most recent call last):
  File "scores.py", line 8, in <module>
    print(diff_with_next(scores))
  File "scores.py", line 4, in diff_with_next
    diffs.append(scores[i + 1] - scores[i])
IndexError: list index out of range

回溯信息(traceback)已经指出了症状位置:第 4 行,scores[i + 1] 越界。但”为什么越界”——i 最后一次取值是多少、列表有多长——print 看不出。用 print 打法,你会改成:

    for i in range(len(scores)):
        print("i =", i, ",列表长度 =", len(scores))
        diffs.append(scores[i + 1] - scores[i])

输出 i = 0、i = 1、i = 2,然后异常。定位到:i 取到 2 时(最后一个下标),i + 1 = 3 已超出列表。结论:循环应该 range(len(scores) - 1)。print 完成了任务,但可以看出流程别扭:要插一行、跑一次、读输出、删除——更麻烦的是,如果循环在深层函数里,你根本不知道”此刻打印的 i 到底是哪次调用的”。这正是 pdb 登场的时机。

pdb:标准库自带的交互式调试器

pdb(The Python Debugger)是 Python 标准库里的交互式调试器,官方文档定位为”Python 调试器的互动源代码浏览器”。它复用第 6 篇讲过的交互式命令行风格:输入命令、回车、立刻得到反馈。它的底层机制值得知道一句:pdb 通过 sys.settrace 在解释器层面注册一个追踪函数(trace function),每当执行流跨过一行代码(或函数调用/返回事件)时,追踪函数就被回调,pdb 借此知道”现在执行到哪一行”,并决定是否停下来等待你输入命令。也就是说,pdb 不是”慢速 Python”,它就是 Python 本身,只是多了一个监控者。

pdb 有两种启动方式,行为略有差异,必须分清:

  • python -m pdb 脚本.py:以”调试模式”启动脚本,交互提示符在第一行执行前出现,你逐步命令控制它;如果让它继续运行到异常发生,pdb 会切换进 post-mortem 模式(死后诊断,拉丁语 post mortem 意为”死后”),停在异常发生的帧上,供你勘察现场;
  • 在代码中调用 breakpoint():程序运行到这一行时,如果环境允许(默认允许),就地进入调试器。这是内置函数,由 PEP 553 引入(Python 3.7+),等价于旧写法 import pdb; pdb.set_trace(),但更简洁且可被环境变量控制。

真实调试会话一:post-mortem 勘察异常现场

我们先用第一种方式处理刚才的 scores.py:

python -m pdb scores.py

敲 c(continue,继续运行),脚本跑起来并抛异常,pdb 自动进入 post-mortem。以下是本文作者在 Python 3.12 上实际运行的完整会话(为版面清晰,文件路径做了精简;你机器上的输出路径不同,但格式与数值一致):

(Pdb) c
Traceback (most recent call last):
  File "scores.py", line 8, in <module>
    print(diff_with_next(scores))
  File "scores.py", line 4, in diff_with_next
    diffs.append(scores[i + 1] - scores[i])
IndexError: list index out of range
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
> scores.py(4)diff_with_next()
-> diffs.append(scores[i + 1] - scores[i])
(Pdb) p i
2
(Pdb) p scores
[78, 92, 45]
(Pdb) p scores[i + 1]
*** IndexError: list index out of range
(Pdb) w
  pdb.py(1960)main()
  pdb.py(1754)_run()
  bdb.py(627)run()
  <string>(1)<module>()
  scores.py(8)<module>()
  -> print(diff_with_next(scores))
> scores.py(4)diff_with_next()
  -> diffs.append(scores[i + 1] - scores[i])
(Pdb) q

逐条解读这个会话,它展示了 pdb 的核心命令:

  • p 表达式(print 的缩写):求值并打印表达式。p i 得到 2——异常前最后一个 i 值;p scores 得到整个列表——长度 3,最大下标 2。两个信息一拼:i=2 时 i+1=3 越界,真相大白;
  • w(where):打印调用栈(call stack),从最底层往上直到当前帧。箭头 > 标出当前位置,正是异常帧第 4 行;
  • q(quit):退出调试器。post-mortem 模式下退出前 pdb 会提示”程序将被重新启动”,直接回车确认即可。

在 post-mortem 现场,你要回答的问题固定为三个:异常发生在哪个函数、哪一行;那一刻的关键变量是什么;调用链是怎么走过来的。p、w 恰好一一对应。这就是为什么”post-mortem 勘察”是 pdb 最高频的用法——比打 print 少改一行代码。

真实调试会话二:breakpoint() 单步进入函数

第二个场景:结果不对(不报错)的 bug 用 post-mortem 没用,因为根本没有异常。此时用 breakpoint() 在可疑位置”钉”一个断点,然后单步观察。把脚本改名 scores_step.py,在调用处之前插入断点:

def average(scores):
    total = 0
    for s in scores:
        total += s
    return total / len(scores)

breakpoint()            # 运行到这里停下,进入 pdb
scores = [78, 92, 45, 63]
print(average(scores))

直接运行 python scores_step.py:程序执行 breakpoint() 那一行之后,在下一行执行前停住,出现 (Pdb) 提示符。以下为实际会话:

> scores_step.py(8)<module>()
-> scores = [78, 92, 45, 63]
(Pdb) s
> scores_step.py(9)<module>()
-> print(average(scores))
(Pdb) s
--Call--
> scores_step.py(1)average()
-> def average(scores):
(Pdb) n
> scores_step.py(2)average()
-> total = 0
(Pdb) p total
*** NameError: name 'total' is not defined
(Pdb) n
> scores_step.py(3)average()
-> for s in scores:
(Pdb) p s
*** NameError: name 's' is not defined
(Pdb) n
> scores_step.py(4)average()
-> total += s
(Pdb) p total
0
(Pdb) c
69.5

这段会话信息量很大,留意三个细节:

  • s(step)与 n(next)的区别:s 会进入函数内部(--Call-- 事件标明”即将调用另一个函数”),n 则停留在当前函数内执行下一行。在 print(average(scores)) 这一行上,s 带我们进入了 average() 的内部,之后 n 一行一行推进它的循环;
  • NameError 也是有效信息:在 total = 0 执行前查 total 报 NameError,说明变量确实还没被赋值——这确认了执行顺序;在 for s in scores: 这一行上 s 尚未定义,说明循环体还没开始执行(for 行的取值发生在进入循环体之前)。能确认”此刻不该有值”,与能确认”此刻有某个值”同样有价值;
  • 断点行语义:起始提示符跟在 breakpoint() 之后的 -> scores = ...,即断点打在某一行,停住的位置是”这一行执行之前”,所以该行代码尚未执行。

最后敲 c,程序跑完循环打印 69.5。

断点管理:b 与 cl

breakpoint() 是写死在代码里的断点;pdb 还能在会话中动态下断点:b 行号(break 的缩写)在指定行设置断点,b 单独输入列出所有断点,c 运行到下一个断点停下,cl 编号(clear)按编号清除。搭配 python -m pdb 从第一行开始,就是”想在哪停在哪停”:

(Pdb) b 4
Breakpoint 1 at scores.py:4
(Pdb) b
1   breakpoint   keep yes   at scores.py:4
(Pdb) c
> scores.py(4)diff_with_next()
-> diffs.append(scores[i + 1] - scores[i])
(Pdb) p i
0
(Pdb) cl 1
Deleted breakpoint 1 at scores.py:4

(以上为同一演示的实际输出。)注意 cl 1 是按编号清除;不带编号的 cl 会弹出 “Clear all breaks?” 询问,回答 y 确认全部清除——这是个容易把人问住的交互细节。

常用命令速查

至此你已见过 pdb 六大核心命令。完整常用表如下,全部真实存在:n(next 单步不进入函数)、s(step 单步进入函数)、c(continue 继续到下一断点)、q(quit 退出)、p 表达式(print 求值)、pp 表达式(pretty-print 美化输出)、l(list 显示附近源码)、w(where 打印调用栈)、u/d(up/down 在调用栈帧间上下移动)、b [行号](break 设置/列出断点)、cl [编号](clear 清除断点)、until(继续到当前行号之后)、r(return 执行到当前函数返回)、h(help 查看命令帮助)、!语句(执行任意 Python 语句,如 !x = 42 修改变量值)。记不住没关系:h 可以随时查,还记得第 6 篇 REPL 吗?pdb 的提示符就是”面向调试的 REPL”。

易错点与陷阱

1. print 调试的两宗罪:缓冲与残留。 第一宗:print 写向 stdout,在非交互环境下是块缓冲(block buffering),输出不会立即落盘/落屏,而异常与日志走 stderr 是无缓冲的,于是常见”打印顺序错乱”的假象——正确做法是 print("debug:", x, flush=True)。第二宗:忘删的 print 混进正式输出,甚至可能被下游程序误解析;调试期在 print 前加 DEBUG: 等前缀,收尾时按前缀批量清理,是工程上的惯例。

2. pdb 命令名遮蔽变量。 pdb 的每个命令首字母都有含义:p、c、l、n、s……如果你的变量恰好叫 c 或 n,直接敲变量名回车,pdb 会把它当成 continue/next 命令,程序”莫名其妙”跑飞了。对策:查任何变量一律用 p 变量名(pdb 会把 p 之后的文本当表达式求值),赋值用 !变量 = 值。这是新手在 pdb 中最经典的”灵异事件”。

3. post-mortem 与调试模式的混淆。 python -m pdb 脚本.py 从第一行开始等你指挥(调试模式),不是”等异常再进”;异常发生后它才进入 post-mortem,且提示 “Uncaught exception. Entering post mortem debugging”。反过来,调试”结果不对但不报错”的逻辑 bug,post-mortem 帮不上忙——因为根本没有异常可进,这时要主动 breakpoint() 或 b 行号 预设断点。把两个模式分开记,能少踩一半坑。

4. breakpoint() 的”静默失效”。 breakpoint() 受环境变量 PYTHONBREAKPOINT 控制:置为 0 时它被完全禁用(直接返回,不停住)——这是为 CI 和生产环境准备的”一键关调试”开关。于是”调试时插了断点忘了删,代码却莫名其妙正常跑过了”的谜案,往往就是某处设了 PYTHONBREAKPOINT=0。发布前用 git grep breakpoint 扫描全仓,确保断点清零,是值得养成的习惯(第 7 篇讲过 flake8,F 类错误同样能帮你抓未删除的调试残留)。

小结

调试的本质是同时盯住控制流与状态两个维度。print 是零成本的探针,适合快速确认”某个值在此刻是什么”,但它是侵入式的,且对控制流无能为力;pdb 是标准库自带的交互式调试器,基于 sys.settrace 追踪函数的机制工作在解释器层面。本文通过两段真实会话演示了最核心的打法:python -m pdb 触发异常后进入 post-mortem 模式,用 p 查变量、w 看调用栈、q 退出;breakpoint()(PEP 553,3.7+)在代码中钉断点,用 s/n 单步、b/cl 管理断点。四个常见坑:print 的缓冲与残留、pdb 命令名遮蔽变量、两种启动模式的混淆、PYTHONBREAKPOINT=0 使断点静默失效。下一篇我们把调试阵地搬进图形界面——VS Code 的 Python 开发环境配置。

练习与思考题

  1. 新建 bug.py,让 average() 接收空列表:average([]) 会抛什么异常?为什么?尝试用 python -m pdb bug.py 在 post-mortem 中确认异常类型。(答案导向:ZeroDivisionError: division by zero,空列表使 len(scores) 为 0。)
  2. 在上面的 scores.py 中,用 b 4 设置断点并 c 运行,连续多次停在断点后,用 p i 观察 i 从 0 递增到 2 的过程。解释为什么最后一次停在断点时程序随后即异常。(答案导向:每次进循环体都会命中断点,i=2 时 i+1 越界,继续运行即抛异常。)
  3. 写出 n 与 s 在 print(average(scores)) 这一行上的行为差异,并说明为什么调试”结果不对”的逻辑 bug 时 s 比 n 更有用。(答案导向:s 进入被调函数内部逐行跟踪,n 把整个调用当一步。)
  4. 思考:把 breakpoint() 放进一个万次循环里会发生什么?如何避免”每轮迭代都停”?(答案导向:每轮迭代都会停下;把断点移到循环外,或在循环内用 p i 加上条件后手动 c,更工程化的做法是第 9 篇 VS Code 里的条件断点。)