30分钟手写一个Python调试器:从sys.settrace到交互式断点控制

2026-08-15 0 308

很多人第一次用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是当前的栈帧,从中可以拿到局部变量、全局变量、代码对象、当前行号等等。

事件一共有四个:callreturnlineexception。我们要做一个调试器,主要用前三个。

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_localsframe.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

看到没?我们成功读到了局部变量pricetotaldiscount,并且控制住了执行节奏。虽然简陋,但pdb的核心原理就是这样的。

6. 优化:为什么continue没有立刻生效?

细心的读者会发现,我们输入c之后,并没有立刻执行下去,而是还是先停了两次。原因在于:continue只是在command_loop里把self.pause设成了False,但当前这一行还没有执行完,之后解释器会继续执行下一行,触发line事件时发现pause=False,所以跳过。

但如果你在下一次line事件时还希望暂停,需要将nc的行为分开。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的时候,你不再觉得那是黑魔法,而是能笑着点头:“我知道这背后是怎么跑起来的。”

30分钟手写一个Python调试器:从sys.settrace到交互式断点控制
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python 30分钟手写一个Python调试器:从sys.settrace到交互式断点控制 https://www.taomawang.com/server/python/2546.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务