很多人第一次用pdb时都会觉得这玩意神秘得不行。实际上,Python自带的sys.settrace就是整个调试机制的入口。你只需要往里面塞一个回调函数,Python解释器就会在执行每一行代码、调用/返回函数时通知你。
这篇不是讲怎么用pdb,而是带你用标准库写一个极简但可用的调试器。支持单步执行、继续运行、打印任意表达式和退出,大约150行代码。写完之后你会发现,调试器背后根本没有魔法。
1. sys.settrace 是块敲门砖
先看最基础的东西。下面这段代码会在每次“执行一行”时触发回调:
import sys
def line_callback(frame, event, arg):
if event == "line":
print("正在执行第", frame.f_lineno, "行:",
frame.f_code.co_filename)
return line_callback
sys.settrace(line_callback)
print("hello")
x = 1
y = 2
print(x + y)
运行之后,你会在看到print结果前,先看到每一行的源文件行号。注意回调函数必须返回自身(或者另一个可调用对象),否则追踪会在下一行自动停止。frame是当前的栈帧,从中可以拿到局部变量、全局变量、代码对象、当前行号等等。
事件一共有四个:call、return、line、exception。我们要做一个调试器,主要用前三个。
2. 追踪函数调用与返回值
先不急着做交互,简单把函数调用和返回打出来看看:
import sys
def trace(frame, event, arg):
code = frame.f_code
if event == "call":
print(f"进入函数: {code.co_name} at {frame.f_lineno}")
elif event == "return":
print(f"离开函数: {code.co_name}, 返回值 = {arg!r}")
return trace
sys.settrace(trace)
def add(a, b):
s = a + b
return s
add(3, 4)
当事件是return时,arg就是函数的返回值。这个信息对理解递归很有帮助。不过真正调试器不需要把每个函数都打印出来,而是要有条件地暂停。
3. 设计一个“会暂停”的调试器
我们的迷你调试器核心逻辑很简单:
- 只追踪当前正在调试的进程(通过
sys.settrace)。 - 默认每执行一行就停下来,等待用户输入命令。
- 收到
n(next),继续执行下一行,然后再次暂停。 - 收到
c(continue),继续执行,直到函数返回。 - 支持
p 表达式打印当前作用域中的值。 - 收到
q退出调试。
注意:真正的pdb还会区分“单步进入”和“单步跳过”,这里为了保持代码简单,统一用n表示单行执行。另外我们只在被追踪的函数内部逐行暂停,遇到return就自动恢复,不等待命令。
4. 写一个MiniDebugger类
直接上代码。类的好处是可以保存暂停状态、调试上下文,后面扩展断点也方便。
import sys
import linecache
class MiniDebugger:
def __init__(self):
self.pause = False # 是否需要暂停
self.is_active = False
def trace(self, frame, event, arg):
# 确保所有事件都返回当前回调
if event == "call":
# 进入一个函数时,先暂停一次
self.pause = True
return self.trace
elif event == "line":
# 当前行是否要停下来?
if not self.pause:
return self.trace
# 从行缓存读取源代码行
line = linecache.getline(frame.f_code.co_filename, frame.f_lineno).rstrip()
print(f"> {frame.f_lineno}: {line}")
# 进入命令循环
self.command_loop(frame)
# 如果设置了 continue,则不再等待下一行
return self.trace
elif event == "return":
# 函数返回时不暂停,但把返回值打出来,方便看结果
print(f"返回值: {arg!r}")
self.pause = False
return None
return self.trace
def command_loop(self, frame):
while True:
try:
cmd = input("(mini-pdb) ").strip()
except (EOFError, KeyboardInterrupt):
cmd = "q"
if cmd in ("n", "next"):
self.pause = True
return
elif cmd in ("c", "continue"):
self.pause = False
return
elif cmd.startswith("p "):
expr = cmd[2:].strip()
try:
# 用 eval 在真实作用域里求值
value = eval(expr, frame.f_globals, frame.f_locals)
print(repr(value))
except Exception as e:
print(f"错误: {e}")
elif cmd == "q":
self.stop()
raise SystemExit("调试结束")
else:
print("未知命令。支持: n, c, p 表达式, q")
def start(self):
if not self.is_active:
self.is_active = True
sys.settrace(self.trace)
def stop(self):
self.is_active = False
sys.settrace(None)
这里有几个容易踩的坑:
- 在
trace回调里调用input(),会把程序阻塞住。这是故意的,不然没法交互。 - 通过
linecache读取源码,而不是直接看frame.f_code.co_filename里的内容,因为Python的源码不是直接保存在栈帧里的。 eval受到当前局部变量的限制,所以必须传frame.f_locals和frame.f_globals。
5. 试用我们的调试器
写一个待调试的函数:
def calc_total(price, count):
total = price * count
discount = total if total < 200 else total * 0.85
return round(discount, 2)
def main():
final_price = calc_total(79, 3)
print(f"最终价格: {final_price}")
if __name__ == "__main__":
dbg = MiniDebugger()
dbg.start()
main()
dbg.stop()
然后运行。你会看到类似这样的交互过程:
> 1: def calc_total(price, count):
> 2: total = price * count
(mini-pdb) p price
79
(mini-pdb) p count
3
(mini-pdb) n
> 3: discount = total if total < 200 else total * 0.85
(mini-pdb) p total
237
(mini-pdb) n
> 4: return round(discount, 2)
(mini-pdb) p discount
201.45
(mini-pdb) n
返回值: 201.45
> 7: final_price = calc_total(79, 3)
(mini-pdb) c
最终价格: 201.45
看到没?我们成功读到了局部变量price、total、discount,并且控制住了执行节奏。虽然简陋,但pdb的核心原理就是这样的。
6. 优化:为什么continue没有立刻生效?
细心的读者会发现,我们输入c之后,并没有立刻执行下去,而是还是先停了两次。原因在于:continue只是在command_loop里把self.pause设成了False,但当前这一行还没有执行完,之后解释器会继续执行下一行,触发line事件时发现pause=False,所以跳过。
但如果你在下一次line事件时还希望暂停,需要将n和c的行为分开。n在下一行暂停,c直接跑到函数结束。在我们的实现里,c只让当前剩余的行不再暂停,但在函数返回后,新的函数调用又会暂停。这是因为我们设计为“每次call都暂停”。如果你想彻底continue,可以增加一个“跳过所有新的调用”的开关,但这就复杂了。教学上这个版本已经展示了核心机制。
7. 再进一步:加个“临时断点”
如果想设置断点,怎么做?其实只需要增加一个breakpoints集合。当pause=False时,检查(frame.f_code.co_filename, frame.f_lineno)是否在断点集合里,在就设置pause=True。这是pdb断点的简化版。
还有个好玩的是frame.f_trace_opcodes字段。如果设置它为True,你甚至能追踪到字节码级别。不过那会让交互极其频繁,通常不需要。
8. 这玩意儿能用吗?
这个迷你调试器肯定没法和真正的pdb比,但你已经知道它的所有零件:
sys.settrace是钩子。frame.f_locals是局部变量字典。linecache.getline负责读源码。input()提供交互。
你可以基于这个框架继续扩展:添加断点管理、条件停靠、单步跳过/进入、打印调用栈…… 这些加起来就是一个完整的调试器。
当别人用pdb的时候,你不再觉得那是黑魔法,而是能笑着点头:“我知道这背后是怎么跑起来的。”

