元数据卡
- 前置知识:第3章(Git 基本操作)
- 预计时间:40 分钟
- 核心难度:(进阶)
- 阅读模式: 高度专注
- 完成标志:能够独立使用 VS Code 或 IntelliJ 设置断点、单步执行、监视变量、分析调用栈
你在哪
你还在出发前的工坊里。Git 的时光机教会了你如何来回穿梭,但代码跑起来结果不对——它没报错,它就是算错了。或者更糟——它有时候对,有时候错,完全看心情。
你盯着屏幕上的代码——一行行检查。"这不挺对的吗?怎么跑出来就是乱的呢?"
这时候,你需要的不是更专注的眼睛,而是一台 显微镜。
你的任务
你写了一个函数来处理用户订单。它接收一个列表,按金额排序,过滤掉无效订单,然后返回前十条。看起来逻辑清晰——但每次跑出来,前十条里总混着一些金额为 0 的"空气订单"。你加了一堆 print() 到处打日志,满屏幕的输出来回翻,像在黑暗的房间里找一根掉在地上的针。
你需要一种方法,能在代码运行到一半的时候冻结住程序,然后问它:"现在你的变量们是什么值?你面前这行代码到底走没走过?"
这就是调试器做的事。
本章分层
- 必读:断点的作用、单步执行(Step Over / Step Into)、通过 VARIABLES 面板观察变量、通过 CALL STACK 面板追溯调用来源
- 选读:条件断点、调试控制台求值与修改变量、日志断点(Logpoint/Tracepoint)
- 深水区:GDB 命令行调试(只对 C/C++ 开发者需要)、远程调试
本章不会要求你掌握
- GDB 调试——如果你不写 C/C++,现在完全不用学
- 远程调试(挂在服务器进程上调试)
- 反汇编级调试(Disassembly View)
遭遇战 → 获得技能
第一场战斗:print() 打到手软
你刚锻造完一把匕首的剑刃,淬火后拿起来一称——重量不对。不是尺寸差了——是材料比例有问题。
你盯着剑身看了半天。一面看一面回想锻造步骤,步骤清晰,手法标准。"这不可能错啊……"你喃喃自语。
然后你开始用锉刀一点一点地刮下铁屑丢进秤盘。满桌的铁屑,你一粒一粒地翻,"嗯,这一下的重量是 100 克,理论应该是 200 克……等等,我记错了还是秤坏了?"
满桌的铁屑来回倒腾,像在黑暗的工坊里找一根掉在地上的缝衣针。你都快把记录本戳破了。
"这不挺对的吗?怎么称出来就是不对呢?"
假设这是你那布满 bug 的函数:
# order_processor.py
def get_top_orders(orders, n=10):
valid = []
for o in orders:
if o["amount"] > 0:
valid.append(o)
sorted_orders = sorted(valid, key=lambda x: x["amount"], reverse=True)
return sorted_orders[:n]
orders = [
{"id": 1, "amount": 100},
{"id": 2, "amount": 0}, # 无效订单
{"id": 3, "amount": 200},
{"id": 4, "amount": -50}, # 无效订单——等等,-50 应该被过滤掉吗?
]
print(get_top_orders(orders, 2))语言: Python 3 如何运行: python order_processor.py预期输出(理论上): 金额最高的前 2 个有效订单 实际输出(如果逻辑有问题): 可能混入了 -50 的订单,因为条件只判断了 > 0
传统做法:塞一堆 print() 进去,看每一步的数据长什么样。但每次改了代码你还得重启整个程序,而且调试完还要记得把这些 print() 删干净,否则就污染了代码。
你能用一种更好的办法——直接在运行时把程序冻住,逐行步进观察。
获得第一个技能:断点
"我受够了每次刮铁屑、记读数、再刮铁屑的循环了。"你把头靠在操作台上。"要是能让锻炉里的铁停在某一刻,让我亲眼看看炉温到底是多少该多好……"
工坊主人不知道什么时候出现在你身后。"那就让它停住观察。"
"什么?"
他绕过你,在仪器面板上指了一下——一个红色的标记亮了。"这叫断点。你告诉测量仪:'操作到这一步,给我停住观察。'"
断点就是你告诉调试器:"在这行的代码到达的时候,停住。"
在 VS Code 或 IntelliJ IDEA 里,你只需要点一下行号左侧的空白区域——一个红色圆点就会出现。
假设你用 VS Code 打开 order_processor.py,在 if o["amount"] > 0: 这行左侧点击一下:
# 调试器会在每次循环到这行时暂停
for o in orders:
● if o["amount"] > 0: # ← 红点 = 断点
valid.append(o)如何运行: 按 F5 或点 VS Code 左边栏的"运行和调试" → 选择"Python File" 预期效果: 编辑器会停在那一行,整行高亮,程序没有退出——它在等你下指令。
当程序停住后,一个调试工具栏会出现:
▶️ 继续 | ⤵️ 步入 | ⤴️ 步出 | 🔄 重启 | ⏹️ 停止从左到右分别是:
- 继续(F5): 程序继续执行,直到遇到下一个断点或结束
- 步入(F11): 进入当前行调用的函数里面去
- 步过(F10): 当前行执行完,停到下一行(不进入函数内部)
- 步出(Shift+F11): 跳出当前函数,回到调用者
- 重启/停止
现在你可以用这几个按钮在时间线上"蠕动"——每次前进一行,看看变量怎么变。
看看变量在发生什么
当断点停住时,VS Code 左边会自动出现一个 VARIABLES 面板,里面列着当前作用域所有变量的值和类型:
VARIABLES
Locals
o: {'id': 1, 'amount': 100}
orders: [{'id': 1, 'amount': 100}, ...]
valid: []
n: 10你不需要猜了。你可以亲眼看到 o["amount"] 此时此刻等于多少。甚至可以在 WATCH 面板里输入表达式,比如 o["amount"] * 2,调试器会实时算出结果。
这就是调试器凌驾于 print() 之上的最大优势:它是活的。 你不必提前想好打印什么——你可以在断点停住的瞬间,去看任何你想看的东西。
第一场战斗的复盘
让我们回到那个 o["amount"] > 0 的过滤器。你的原始代码过滤掉 amount <= 0 的订单。但 amount = -50 算有效订单吗?
如果你不是用大脑模拟循环逻辑,而是用调试器一节一节地跑,你会立刻看到——当 o 轮到 {"id": 4, "amount": -50} 这个字典时,if o["amount"] > 0 判断为 False,所以它不会进入 valid 数组。
但等一下——如果你本意是 "amount >= 0 才算有效"(0 也是无效的,因为白送),那条件就应该是 >= 0 而不是 > 0。如果用 >,那 amount = 0 也会被放进来。
你观察到这个问题只需要一次断点暂停——看 o["amount"] 的值,再看程序走了哪条分支。用 print() 你得打一堆日志,再重新跑。调试器让你当场参与程序的运行过程。
第二场战斗:一个只在特定条件下出现的 bug
你修好第一个铸造缺陷后,信心满满地重新开炉锻造了。
这次成品出炉了——但成品里还是混了一条不该出现的气孔。你翻回去看铸造记录,发现有一批材料的编号是 99999 但标签却是 0。
"这什么情况……"你尝试设置观察点。但问题来了——你的锻造流程要经过一万个步骤,每一步都会在观察点停住。你要拉一万次闸才能看到那个特殊的步骤。
"我不可能一个个拉啊……"
前一个问题修好了,但你又发现了一个新问题:有一批材料编号 99999,但它上面的标签却是 0。你怀疑材料柜的登记有问题——但出这种问题的情况很少,你的观察点总是停在不该停的地方,流程还没跑到那个特殊批次就被别的东西打断了。
你不需要在循环里每个元素都停——你可以在特定条件下才断住。
在 VS Code 里,右键点击断点 → 选择"Edit Breakpoint..."(或 IntelliJ 里右键断点):
for o in orders:
● if o["amount"] == 99999: # ← 只有当金额等于 99999 时才停
valid.append(o)如何设置: 右键红点,输入条件表达式 o["amount"] == 99999
现在你按 F5 继续运行——程序一路顺畅地跑着,前面的 amount = 100、amount = 200 都不停。直到循环到那条 amount = 99999 的记录——啪,停住了。
你停在 VARIABLES 面板里看 o 的值:
o: {'id': 0, 'amount': 99999}id = 0——这个订单的 ID 生成器果然有问题。如果你没有条件断点,你得打 10000 个 print(),或者疯狂按 F5 跳过不感兴趣的循环迭代。
条件断点的本质: 它还是同一个断点,但加了一个"门卫"。程序每次经过这行都会检查条件——条件为
true才停,否则直接过。这叫无副作用的观察——不影响程序性能(除了很小的条件检查开销),不影响状态。
第三场战斗:你调用我,我调用它——到底是谁调用谁?
新问题出现了。你的淬火池的温度读数返回了 0,但你知道它不该是 0。你在温控仪旁边设了个观察点——停下了,仪表看了看,连线没问题。
"那问题出在哪?"你挠头。"这个温控仪是被人操作的,谁操作了它?传进去的参数是什么?"
你从工坊的记录板上看到了自己的测量点,但看不出来是谁把你叫到这一步的。就像有人从背后拍了拍你,你回过头——没人。
你修好了温度探头的问题后,测试通过了。但另一个工位调用淬火流程时莫名其妙地报错了。你从记录板上看到了自己的工序名,但它之前是谁在操作的?
你需要在操作链条里回溯——看是谁把我带到这里来的。
假设有这样的代码:
# payment_gateway.py
def process_payment(order_id, amount):
# 计算折扣
discount = calculate_discount(amount)
print(f"处理订单 {order_id}, 折扣后金额: {amount - discount}")
def calculate_discount(amount):
ratio = get_discount_ratio()
return amount * ratio
def get_discount_ratio():
return 0 # 总是 0——等等,这意味着永远不打折?你在 calculate_discount 里设置了一个断点。程序停住后,你看到屏幕上出现一个 CALL STACK 面板:
CALL STACK
calculate_discount (payment_gateway.py:10)
process_payment (payment_gateway.py:6)
<module> (payment_gateway.py:15)阅读方向:从下往上。
- 最下面一行
<module>是脚本入口——你直接运行了这个文件 - 再往上
process_payment调用了calculate_discount - 最上面是当前停住的位置
calculate_discount
这就像在案发现场倒着翻录像带:你知道手机在这(当前函数),你想知道"是谁把手机递过来的"(调用链)。调用栈就是你身后那一串递手机的手。
你的目光回到 calculate_discount 这一步——你看到 amount = 100,ratio = 0。因为 get_discount_ratio() 返回了 0,所以折扣是 0。但这是你想的吗?也许 get_discount_ratio 本身就有 bug,或者你只是忘了实现逻辑?
你双击调用栈上的 process_payment——调试器带你飞到了调用者的那一行,而所有变量状态都保留着。你可以在调用者那一帧观察它的变量。这就是调试器的"时空穿梭"。
第四场战斗:让我算算这个表达式
你看清了测量仪上的数值——铁锭重量 = 100,杂质比例 = 0.1。但你脑子里的计算停不下来:"铁锭 × (1 - 杂质比例) 是 90 斤,但如果杂质再翻一倍呢?180?不对,等等……"
你在脑子里算了一遍又一遍,总觉得自己算错了。但你不想在工坊记录本上多加一页临时草稿来验证——加了还得撕掉,太麻烦了。
"要是能当场算一下就好了……"你盯着操作台。"让算盘算,别让我算。"
你看着变量的值,但是你想看到更复杂的推导结果——比如 amount * (1 - ratio) 在特定条件下等于多少。你不想在代码里加一个新变量来存它。
在 WATCH 面板里输入任意表达式:
WATCH
amount * (1 - ratio): 100.0
round(amount * (1 - ratio), 2): 100.0
f"折扣后:{amount - amount * ratio}": "折扣后:100.0"语言: 任何支持调试的 IDE 如何运行: 在断点停住时,在 WATCH 面板中输入表达式 效果: 调试器在当前上下文中求值,显示结果
你也可以在断点停住的编辑器区域,选中一段代码,右键 → "Evaluate in Console"(或按 Ctrl+Shift+Enter 打开 Debug Console):
>>> amount * (1 - ratio)
100.0
>>> [o["amount"] for o in orders if o["amount"] > 100]
[200, 99999]你可以直接在调试控制台里写 Python 表达式,包括列表推导式、函数调用、甚至修改变量的值——程序还在跑,但你已经可以"动手术"了。
** 小心:** 在调试控制台里修改变量会影响程序后续的执行结果。这不是模拟——你是真的在改内存值。
🏔 深入冒险:调试不只是 IDE 技术
日志断点
有时候你不想停程序,你只想看到某个变量在每个循环里的变化——但又懒得写 print()。VS Code 和 IntelliJ 都支持日志断点(Logpoint / Tracepoint)。
右键断点 → 选择"Log Message"(VS Code) 或右键断点 → "More" → 勾选"Log message to console"(IntelliJ)
● # 日志断点:不暂停,只输出
# Log message: "当前订单: {o['id']}, 金额: {o['amount']}"每次循环经过这行时,程序不会停(像普通断点那样),但调试控制台里会打印:
当前订单: 1, 金额: 100
当前订单: 2, 金额: 0
当前订单: 3, 金额: 200这是一种"零侵入"的调试方法——你不需要删这些日志,因为它们不是代码,是 IDE 的配置。关掉调试模式,它们就消失了。
如果你不写 C/C++,以下内容现在不用学。 直接跳到下一节。
附录:GDB——另一种调试器
如果你以后接触 C/C++,调试器叫 GDB(GNU Debugger)。概念跟 IDE 调试器一样,只是换成命令行操作:
# 用 -g 参数编译(包含调试信息)
gcc -g myprog.c -o myprog
# 启动调试
gdb ./myprog
# GDB 内部
(gdb) break main # 在 main 函数设断点
(gdb) run # 运行
(gdb) print x # 查看变量 x
(gdb) next # 步过
(gdb) step # 步入
(gdb) backtrace # 看调用栈(等价于 CALL STACK)
(gdb) quit语言: Bash / GDB 如何运行: g++ -g program.cpp -o program && gdb ./program预期输出(起点):
Reading symbols from ./program...
(gdb)GDB 虽然不如 IDE 图形化,但在嵌入式、服务器调试中仍不可替代——当你只能 SSH 到一个没有桌面的服务器上 debug 时,GDB 是你唯一的可能。但这跟你现在没关系。等你真的需要写 C/C++ 时,再回来翻这页。
常见陷阱
故事一:我花了一晚上 debug,结果发现断点打错了地方
有一次我遇到了一个诡异的问题——程序每次运行到某处就不动了,但也没报错。我花了两个小时,加断点、单步、看变量——啥都没错。最后发现,我打的断点在注释行上。IDE 允许你在一段注释上设断点——但它永远不可能被执行到,所以程序永远不会停在那里。
调试器不会提醒你"嘿,这行是注释"。它只会默默地尊重你的决定,即使那是一个无效的请求。所以你一直在等一个永远不会到来的停顿。
教训: 确认你的断点打在可执行代码行上。一个有效断点的红点是实心的;如果它变成了空心或带问号,说明 IDE 知道你打在了无效位置。
故事二:优化后的代码 vs 源代码行号
C/C++ 代码在编译器开启优化(-O2)的情况下,可能会重排指令、内联函数、删除未使用变量。你设的断点可能被编译器"优化没了"——程序跑过去不会停,因为那行代码在编译后的二进制里不存在了。
解决: 开发环境用 -O0 -g 编译,发布环境才用 -O2。永远不要在优化后的二进制上 debug。
通关挑战
🗡 热身(5 分钟,必做)
- 在 VS Code 或 IntelliJ 中打开任意 Python/Java 项目
- 在你熟悉的函数内设置一个断点
- 以调试模式运行(F5 / Debug run)
- 单步执行(F10 / Step Over)至少 5 行
- 打开 VARIABLES 面板观察变量变化
挑战(30 分钟,选做)
复现一个带 bug 的递归函数并用调试器定位问题:
# factorial_debug.py
def factorial(n):
# 这是有 bug 的版本
if n == 0: # 应该是 n == 1 或 n <= 1
return 1
return n * factorial(n - 1)
print(factorial(5))任务:
- 在
return n * factorial(n - 1)行设置断点 - 观察调用栈的变化——每一次递归调用都会往栈上压一层
- 对
n加一个条件断点n == 2,只在递归深度到 2 时停住 - 在 WATCH 面板输入
n * factorial(n - 1)——等等,它为什么是<error>?因为递归函数还没有返回时,表达式无法求值
🪲 排障
场景 1: 你设置了断点,按 F5 运行,程序直接结束了,没有停。 诊断: 可能是 (a) 断点打在了无效位置(注释行、非执行行);(b) 你运行的是错误的目标文件;(c) 编译器优化把代码行抹掉了。 解决: 确认红点为实心状态,确认你点击了正确的文件,确认调试配置选择了正确的入口。
场景 2: 你单步步进(F11)时,调到了标准库内部(比如 sorted() 的实现)。 诊断: IDE 默认会进入你安装的库代码。这在大部分情况下你不需要。 解决: 调试器工具栏通常有一个"Skip over library code"按钮(VS Code 里叫"Just My Code"),开启后只调试你自己的代码。在 Java 中 IntelliJ 默认开启此功能。
验收标准
- [ ] 能理解断点的作用——在代码执行到指定行时暂停
- [ ] 会使用条件断点过滤不必要的暂停
- [ ] 能通过 VARIABLES / WATCH 面板观察变量值
- [ ] 能通过 CALL STACK 面板追溯调用来源
- [ ] 懂得 "Step Over"(步过)和 "Step Into"(步入)的区别
- [ ] 能在调试控制台求值和修改变量
- [ ] 知道日志断点(Logpoint)能替代临时 print() 调试
常见卡点
"步进(F11)和步过(F10)到底选哪个?"
- 步过:不进入函数内部,直接把当前行的函数执行完,停在下一行。如果你不想关心
sorted()的内部实现,用步过。 - 步进:跳进当前行的函数内部。如果你想看
get_top_orders里循环怎么跑,用步进。
- 步过:不进入函数内部,直接把当前行的函数执行完,停在下一行。如果你不想关心
"调试控制台里改了变量,程序真的会变吗?"
- 会。你在调试控制台里写
n = 100,程序的n真的变成了 100。这是一种"作弊"手段,可以用来测试边界情况,但要记得改回来。
- 会。你在调试控制台里写
"为什么我看不到某些模块里定义的上层变量?"
- 调试器只显示当前栈帧(当前作用域)里的变量。要看调用者的变量,在 CALL STACK 面板里点击那层帧。
"断点太多,每次都要删吗?"
- 不需要。你可以禁用断点(点掉红点上的勾),或者一次清除所有断点(VS Code:Debug 面板 → 断点区域 → 全选删除)。
现在不需要理解
- 远程调试 Remote Debugging(挂在服务器进程上调试——等你真正需要部署到远端再学)
- 内存转储(Core Dump)分析——极端崩溃场景
- 条件断点的副作用表达式(在断点条件里调用函数改变状态——太危险,几乎从不需要)
- 反汇编级调试(Disassembly View)——除非你写底层 C/汇编代码
旅人笔记
调试器不是报错的工具,是观察的工具。print() 像是推测,"这里应该有问题吧";断点像是目击,"我亲眼看到它干了什么"。调用栈让你看清"谁叫谁"的因果链——很多 bug 不是最后一行的错,而是谁把程序引到那条路上去的。
→ 下一站预告
调试器在手,bug 渐行渐远。但你的项目开始依赖越来越多的外部库——这些代码从哪里来?怎么安装?怎么保证你电脑上的版本和别人电脑上的一致?下一章,我们聊聊包管理器。