调试不是记住 Xcode 有哪些按钮,而是回答六个问题:现象是什么、怎样稳定复现、当前假设是什么、什么证据能证伪它、根因在哪里、修复后如何证明问题消失。
本文围绕 DebugLab 中的真实故障展开。每个案例都有独立源码,完整目录见 CASES.md。
一条足够用的工具选择图
不要从工具出发。先描述症状,再选择能够区分两个假设的证据。
一次完整调试应该留下什么
“我打了断点,改完好了”无法复盘。一个可交接的 Bug 记录至少保留以下六项:
复现输入:设备、系统、构建配置、账号/数据条件、操作序列
第一现场:异常类型、崩溃线程或卡住线程、顶部有效栈帧
候选假设:至少两个,并写出各自可被什么证据否定
关键证据:断点命中条件、LLDB 输出、Instruments 时间区间、日志事件序列
最小修复:改变了哪个运行时契约,不只是“加了一个 if”
回归证据:原场景、相邻边界、重复次数、真机/模拟器范围
objc.io 的 Debugging 专题很值得借鉴的一点,是把调试当成调查过程,而不是工具展示。工具只有在能区分假设时才有价值。例如页面白屏同时可能来自根控制器未安装、主线程被阻塞、首帧前截图和资源加载失败。BUILD SUCCEEDED 对这四个假设几乎没有区分能力。
断点教程:不要只会点红点
普通、条件、日志与符号断点
| 类型 | 设置位置 | 最适合回答的问题 |
|---|---|---|
| 普通断点 | 源码行号 | 这条路径是否执行、局部变量是什么 |
| 条件断点 | Breakpoint → Edit Breakpoint → Condition | 第 N 次或特定对象为什么异常 |
| Breakpoint Action | Edit Breakpoint → Action | 高频路径留证据但不停止进程 |
| Exception Breakpoint | Breakpoint Navigator 左下角 + | Objective-C/Swift 异常最初在哪里抛出 |
| Symbolic Breakpoint | 输入函数或 selector | 没有源码行号时谁调用了系统入口 |
约束冲突应断在 UIViewAlertForUnsatisfiableConstraints;Objective-C 未识别 selector 可断在 objc_exception_throw,再从调用栈向上找第一个业务帧。符号断点命中系统函数不等于系统有 Bug,它只是保存了更靠近第一现场的位置。
用条件断点抓“第 37 个对象才出错”
假设列表中只有 model.id == "bad-id" 时布局异常,可以在断点 Condition 写:
model.id == "bad-id"
Swift 条件表达式可能触发求值器和 getter,复杂条件会显著拖慢循环。更稳定的做法是在代码中先计算一个简单布尔量,或给断点添加 Action:
po model.id
bt 8
勾选 “Automatically continue after evaluating actions” 后,它就成为可筛选日志点。操作完成后必须删除或禁用,避免把调试行为误带进后续性能测试。
LLDB 教程:从当前帧逐步扩大范围
LLDB 官方教程把命令组织为 noun verb options arguments。不需要一次背完,按调查范围使用即可:
frame variable # 当前帧所有局部变量,通常不执行业务代码
v model # 简写读取变量
bt # 当前线程调用栈
thread backtrace all # 所有线程调用栈,卡死/死锁必看
thread list # 线程停止原因和队列概览
image lookup -n functionName # 符号属于哪个镜像
image lookup -a 0xADDRESS # 地址对应的符号
breakpoint set -S selector: # 按 Objective-C selector 断点
watchpoint set variable value # 谁修改了内存中的值
推荐顺序是:先 v 看当前数据,再 bt 理解调用路径;只有怀疑并发或卡死时才扩大到 thread backtrace all。一上来打印所有线程会产生大量噪声,也容易忽略真正的停止原因。
po、p、expression 可能在暂停的进程中执行代码。若 getter 需要主线程而当前主线程正等待被暂停的工作线程,po object.description 甚至可能制造新的卡住现象。读取证据优先使用 v;只有明确知道副作用时才使用表达式求值。
一段真实的 LLDB 现场:字段应该怎样读
假设断点停在一个列表更新方法,当前要确认「model 为什么为空」以及「这段代码来自哪个镜像」。下面不是固定输出,地址、模块名和偏移会随构建变化;重点是每一列的含义。
(lldb) frame variable model index
(MyModel *) model = 0x0000600001c88120 {
identifier = @"bad-id"
}
(Int) index = 37
(lldb) p index + 1
(Int) $R0 = 38
(lldb) po model
<MyModel: 0x600001c88120; identifier = bad-id>
frame variable(或 v)从当前栈帧的调试信息读取局部变量,适合先确认值和类型。p 是 expression -- 的简写,会编译并执行表达式,结果变量 $R0 可以在当前暂停会话继续使用。po 也会执行表达式,只是尝试按 Objective-C/Swift 对象可读形式打印;它常会触发 description、getter 或桥接逻辑,因此不是“无副作用版 p”。
(lldb) thread list
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
thread #2, queue = 'com.apple.root.user-initiated-qos', name = 'DecodeWorker'
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
* frame #0: 0x00000001022c90b0 DebugLab`CaseViewController.apply(model:) + 176
frame #1: 0x00000001022c8e60 DebugLab`closure #1 in CaseViewController.reload() + 96
frame #2: 0x00000001b3da6230 UIKitCore`-[UITableView _endCellAnimationsWithContext:] + 812
thread #1 是 LLDB 线程编号,queue/name 是 GCD 队列或线程名,stop reason 是为什么停住。frame #0 是当前栈帧;行首 * 表示当前选中的线程或帧。每行地址是当前进程的运行时地址,反引号左边是镜像名,右边是符号名,+ 176 是当前 PC 相对于该函数入口的偏移。找业务根因时,优先沿着当前线程从 frame #0 向下找第一个自己的模块,而不是直接把 UIKit 的最后一帧当成根因。
(lldb) image list -o -f DebugLab
[ 0] 0x00000001022c4000 /.../DebugLab.app/DebugLab
(lldb) image lookup -n '-[CaseViewController applyModel:]'
1 match found in /.../DebugLab.app/DebugLab:
Address: DebugLab[0x00000000000050b0] (DebugLab.__TEXT.__text + 176)
Summary: DebugLab`-[CaseViewController applyModel:]
(lldb) image lookup -a 0x00000001022c90b0
Address: DebugLab[0x00000000000050b0] (DebugLab.__TEXT.__text + 176)
Summary: DebugLab`-[CaseViewController applyModel:]
image 指的是 Mach-O 镜像:App 可执行文件、动态库、系统 Framework 都是 image。image list -o -f 的 0x... 是该镜像的 load address(slide 后的加载基址),-f 显示路径。image lookup -n 用符号名反查地址;Objective-C 实例方法要带 -[Class selector:],类方法写 +[Class selector:]。image lookup -a 用运行时地址反查符号。输出中的 DebugLab[0x50b0] 是 image-relative address,不是完整运行时地址;两者关系是:
runtime address = image load address + image-relative address
0x10022c90b0 = 0x10022c4000 + 0x50b0
这也解释了为什么排查崩溃时不能把 image lookup 的相对地址直接喂给 atos:atos -l 需要的是崩溃日志 Binary Images 中的 load address,地址、架构与 dSYM UUID 三者必须属于同一构建。
寄存器和内存:只在怀疑 ABI 或野指针时下钻
(lldb) register read pc sp fp x0 x1
pc = 0x00000001022c90b0 DebugLab`-[CaseViewController applyModel:] + 176
sp = 0x000000016fdfd9c0
fp = 0x000000016fdfda30
x0 = 0x0000600001c88120
x1 = 0x0000000102a0b4f0
(lldb) memory read --format x --size 8 --count 4 0x0000600001c88120
0x600001c88120: 0x00000001045a4f90 0x0000000000000000
0x600001c88130: 0x0000000000000001 0x0000000000000000
在 arm64 上,pc 是当前指令地址,sp 是栈顶,fp 是当前栈帧基址;x0、x1 是通用寄存器。对 Objective-C 实例方法而言,常见情况下 x0 承载 self、x1 承载 _cmd,但这属于 ABI 层调试,不应把某个特定优化级别或调用约定当成所有语言/所有场景的规则。memory read 读取的是原始字节;看到一个看似对象的地址并不能证明对象仍然有效,内存可能已经被释放或复用。普通业务问题停在 frame variable 即可,只有 ASan、崩溃地址、错误函数签名或野指针怀疑时才下钻到这里。
常用命令的“输入是什么,输出回答什么”
| 命令 | 输入 | 输出回答的问题 | 常见误用 |
|---|---|---|---|
v model | 当前帧变量名 | 此刻变量的类型和值 | 以为它能查看别的线程/别的栈帧 |
p expr | 可求值表达式 | 表达式结果与 $R0 | 忘记它会执行进程代码 |
po object | 对象表达式 | 对象的可读描述 | 用它证明对象没有副作用或仍有效 |
bt | 无 | 当前线程为何走到这里 | 只看系统 frame,不找第一个业务 frame |
thread backtrace all | 无 | 是否存在互等、阻塞或错误队列 | 对普通断点无差别全量打印 |
image lookup -n | 符号名 | 函数/selector 在哪个 image | 把 Objective-C selector 写成裸方法名 |
image lookup -a | 运行时地址 | 地址属于什么函数和 section | 用 image-relative address 代替运行时地址 |
register read | 寄存器名 | 当前 ABI 现场 | 在普通 UI Bug 中跳过高层证据直接看寄存器 |
memory read | 地址、格式、大小、数量 | 原始内存内容 | 把十六进制字节直接解释成业务对象 |
Watchpoint 适合什么问题
变量值“在某处莫名其妙变了”时,普通断点只能看到读的位置,watchpoint 能在内存写入发生时停住:
watchpoint set variable state
watchpoint list
硬件 watchpoint 数量有限,而且对象搬迁、变量离开作用域或观察计算属性时可能失效。它适合短时间锁定写入者,不适合作为长期监控机制。
Scheme Diagnostics:先按故障类型选
| 工具 | 主要抓什么 | 典型输出 | 不擅长什么 |
|---|---|---|---|
| Address Sanitizer | 堆/栈越界、use-after-free 等地址错误 | 错误类型、访问栈、分配栈 | Objective-C 逻辑生命周期 |
| Thread Sanitizer | 数据竞争与部分同步问题 | 两条冲突访问栈 | 死锁的完整业务语义、性能结论 |
| Zombie Objects | 已释放 Objective-C 对象再次收消息 | 原对象类型和 selector | C 缓冲区越界、Swift 纯值类型 |
| Main Thread Checker | UIKit/AppKit 的后台线程误用 | 违规 API 调用栈 | 所有线程安全问题 |
| Malloc Scribble/Guard Edges | 堆内存破坏辅助定位 | 更早暴露非法访问 | 业务对象引用环 |
Sanitizer 会改变内存布局和时序,TSan 与 ASan 通常不要同时当作一次测试结论。正确流程是保存稳定复现步骤,分别启用工具运行,再回到普通构建确认修复没有依赖诊断环境。
Instruments 教程:时间区间必须对齐
以掉帧为例:
- 用真机和接近 Release 的配置启动 Instruments。
- 在 Animation Hitches 或 Core Animation 中复现一次,标记掉帧时间段。
- 切换或重新录制 Time Profiler,复现同一操作。
- 只选择故障时间段,查看主线程 Call Tree。
- 勾选 Separate by Thread、Hide System Libraries,再判断最重的业务栈。
- 修改后用同一设备、同一数据和相同操作时长复测。
只看到某函数 Self Weight 高,并不自动说明它是根因。采样栈回答“CPU 时间花在哪里”;主线程同步等待、锁竞争、I/O 和 GPU 提交可能需要 System Trace、Hangs/Hitches 或 signpost 时间线补充。不同录制的时间区间不一致,也不能直接比较百分比。
内存问题同理:Memory Graph 更适合回答“谁还强引用这个对象”,Allocations 回答“何时分配、数量如何增长”,Leaks 只报告它能识别的不可达泄漏。对象一直可达但业务上应释放时,Leaks 可能完全安静。
案例一:编译和单测都通过,App 启动却白屏
这是 DebugLab 复审中真实发生的问题。
现象
- 独立工程
BUILD SUCCEEDED。 - 8 项 XCTest 全部通过。
- 模拟器进程正常存在,没有崩溃。
- 启动截图只有白色窗口,没有“Bug 定位实验室”。
这组证据已经排除了“代码没有编译”“启动即崩溃”,但没有证明根控制器真的挂到了当前 Scene。
错误实现
final class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = DebugLabModule.makeViewController()
window.makeKeyAndVisible()
self.window = window
return true
}
}
在 Scene 生命周期下,UIWindow(frame:) 没有绑定系统传入的 UIWindowScene。编译器无法检查 Info.plist、AppDelegate、SceneDelegate 和窗口归属是否形成完整运行时契约。
用日志区分假设
模拟器日志中可以看到系统创建了 UIWindowScene,但错误版本的窗口并不属于它。修复后的关键日志则显示窗口在目标 Scene 中成为 key window。日志只是支持证据,最终验收仍是可见首屏。
修复
Info.plist 声明 SceneDelegate,AppDelegate 只选择配置,窗口交给 SceneDelegate:
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = DebugLabModule.makeViewController()
window.makeKeyAndVisible()
self.window = window
}
回归验证中的第二个坑
第一次修复后,simctl launch 紧接着截图仍得到白屏;运行日志已经显示 key window 正常。隔开启动与截图后,首屏和案例列表完整出现。原因是截图发生在首帧提交前。
结论:涉及生命周期、路由和资源加载时,构建与单测不是完整验收。至少需要“安装 → 启动 → 等待首帧稳定 → 检查可见内容”。
可复用的白屏排查顺序
- Pause 后检查主线程栈,排除首屏同步阻塞。
- 在 SceneDelegate 的
willConnectTo和根控制器viewDidAppear打断点。 - LLDB 中查看
UIApplication.shared.connectedScenes与目标UIWindowScene.windows。 - 确认 key window 的
rootViewController,再看 presented/navigation 层级。 - 等待首帧稳定后截图,不用“进程存在”代替 UI 验收。
这套顺序从运行时对象关系取证,不依赖“我记得 Info.plist 应该这样配”的猜测。
案例二:两个后台线程死锁,主线程为什么还能操作
对应文件:ControlledDeadlockCase.swift。
错误场景
线程 A 先拿 first 再等 second,线程 B 顺序相反:
first.lock()
second.lock()
// 另一个线程
second.lock()
first.lock()
如果调度碰巧让一个线程连续拿到两把锁,实验不会稳定死锁。因此 DebugLab 不是简单同时 dispatch 两段代码,而是先确认双方都持有第一把锁,再同时放行第二次加锁。
如何取证
- 点击“死锁与全线程栈”。
- 触发后点击 Xcode 的 Pause。
- 在 LLDB 执行:
thread backtrace all
正确证据不是页面显示“已经死锁”,而是两个线程栈分别停在第二次 NSLock.lock(),并能还原互相等待关系。
取到所有线程栈后,先找处于 mutex/lock wait 的线程,再向上找到第一个业务方法。只有看到 A 持有 first 等 second、B 持有 second 等 first,才能证明互锁。主线程可操作说明它没有参与等待,不代表进程没有死锁;后台线程泄漏、任务永不完成和资源无法释放仍是真实故障。
修复边界
生产修复通常是统一锁顺序、合并临界区或去掉嵌套锁。给 lock() 随意加超时只能改变症状,不能自动修复共享状态一致性。
DebugLab 禁止重复触发,因为每次死锁都会永久占用两个线程,直到进程结束。
案例三:FPS 下降只是现象,怎样制造可测量掉帧
对应文件:FrameDropCase.swift。
失败的教学案例
仅创建大量 View 但不加入层级,或执行一次很短循环,可能根本不会跨过帧预算。这类 Demo 页面写着“掉帧”,但 Instruments 中没有稳定区间。
可重复实现
DebugLab 使用真实 CADisplayLink 回调,并连续 45 帧占用主线程约 28ms:
@objc private func handleFrame(_ link: CADisplayLink) {
if let previousTimestamp {
largestFrameGap = max(largestFrameGap, link.timestamp - previousTimestamp)
}
previousTimestamp = link.timestamp
let deadline = CACurrentMediaTime() + 0.028
var checksum = 0.0
while CACurrentMediaTime() < deadline {
checksum += sin(checksum + 0.1)
}
}
60Hz 一帧预算约 16.67ms,28ms 主线程工作必然跨帧。页面最后输出实际最大帧间隔,而不是声称固定 FPS。
工具分工
- Animation Hitches / Core Animation:确定发生问题的时间区间。
- Time Profiler:在同一区间查看主线程采样栈,定位 CPU 热点。
CADisplayLink:提供帧回调和间隔证据,不解释根因。
离开页面必须 invalidate DisplayLink;它会强持有 target,否则“性能案例”会额外制造生命周期泄漏。模拟器适合验证流程,不能替代真机性能结论。
案例四:同样是内存崩溃,为什么 Zombie 完全没用
对应文件:ZombieCase.swift。
故障代码
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: 8)
buffer.initialize(repeating: 0, count: 8)
buffer.advanced(by: 16).pointee = 0xFF
这里只分配 8 字节,却向偏移 16 写入。必须在 Scheme → Diagnostics 开启 Address Sanitizer 后触发。
预期关键证据:
ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 1
ASan 会给出越界写入栈和原始分配栈。未开启 ASan 时属于未定义行为:可能崩溃,也可能静默破坏其他内存。
Zombie 解决的是另一类问题:Objective-C 对象释放后仍收到消息。它让对象不真正释放,以便报告原类型和 selector;它不管理普通堆缓冲区边界。看到“内存问题”就打开 Zombie,是从工具名称猜根因。
案例五:约束日志、符号断点和 View Debugger 各回答什么
对应文件:ConstraintConflictCase.swift。
案例给同一视图添加两个带 identifier 的宽度约束。触发后:
Unable to simultaneously satisfy constraints
debug.fixed-width.120
debug.conflicting-width.220
三个证据的职责不同:
| 证据 | 回答的问题 |
|---|---|
| 控制台约束日志 | 哪些约束不能同时成立,系统打破了哪一条 |
UIViewAlertForUnsatisfiableConstraints 符号断点 | 冲突约束在哪里被创建或激活 |
| View Debugger | 系统处理冲突后,最终层级、尺寸和遮挡是什么 |
只看 View Debugger 可能看到最终宽度,却丢失创建现场;只看日志又无法理解页面几何。它们是证据链,不是互相替代的三个 API。
案例六:拿到一个崩溃地址,为什么不能直接跑 atos
对应文件:CrashSymbolicationCase.swift 和 SymbolicationAddressCalculator.swift。
必须先确认的输入
- 发布该版本时保存的二进制与 dSYM。
- dSYM UUID 与崩溃日志 Binary Images 中 UUID 一致。
- 架构一致。
- runtime address、image load address 和 preferred image base 来源明确。
dwarfdump --uuid MyApp.app.dSYM
地址关系:
slide = runtime image load - preferred image base
unslid address = runtime address - slide
示例:
runtime address = 0x100012345
runtime image load = 0x100000000
preferred image base = 0x100000000
slide = 0
unslid address = 0x100012345
如果 UUID 不匹配,重新编译同一份源码得到的新 dSYM 通常仍然无效。地址换算正确也不能绕过二进制、架构和 UUID 前置条件。
一套可跟做的符号化检查
# 1. 查看 dSYM 中的架构和 UUID
dwarfdump --uuid MyApp.app.dSYM
# 2. 查看归档 App 可执行文件 UUID
dwarfdump --uuid MyApp.app/MyApp
# 3. 两者与崩溃日志 Binary Images 对照后,再做地址解析
xcrun atos -arch arm64 \
-o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp \
-l 0x100000000 \
0x100012345
-l 是崩溃发生时该镜像的 load address,不是随手复制的某个地址。Apple 的 Symbolication 文档将 UUID 匹配放在命令行解析之前,这是因为 dSYM 不是“相同源码的符号字典”,而是某次具体构建产物的调试信息。重新编译得到的地址布局可能已经不同。
LLDB 的一个高频误区:断点停在语句执行前
let result = service.load()
appendEvidence(result) // 应在这里停,才能观察 result
如果断点放在初始化语句本身,该行通常尚未执行,result 还不可用。优先使用 v 或 frame variable 读取变量;po、p 和 expression 可能执行 getter、description 或修改进程状态。
v / frame variable 通常只读取当前栈帧变量
po / p / expression 可能执行被调试进程代码
bt / thread backtrace all 当前线程栈 / 所有线程栈
最终排查模板
现象:用户能观察到什么,不要先写原因
复现:设备、系统、数据、网络、构建配置、概率
假设:至少列两个可能原因
证据:什么结果会支持或否定每个假设
根因:哪一条代码和哪一个运行时契约被破坏
修复:最小改动及其副作用
回归:用相同场景证明问题消失,并覆盖相邻边界
不要在线上开启 Sanitizer 或 Zombie,也不要记录令牌、密码和完整个人数据。性能、并发和生命周期问题必须使用真实运行证据,页面上的一句“已触发”不算证明。
运行工程
DebugLab 可单独打开,也以 CocoaPods Development Pod 接入外层工程。打开 nodeOCAndSwift.xcworkspace 后,Pods 中看到的就是 04-debugging 本地源码。
案例故意包含崩溃、竞态、互锁和泄漏。一次只运行一个故障场景;无法恢复的案例通过结束进程清理。
参考资料
- Apple:Diagnosing issues using crash reports and device logs
- Apple:Adding identifiable symbol names to a crash report:UUID、dSYM 与命令行符号化的权威流程。
- WWDC21:Symbolication — Beyond the basics:理解地址、镜像、符号和工具之间的关系。
- Apple:Addressing crashes from Swift runtime errors
- Apple:Gathering information about memory use
- LLDB Tutorial:断点、线程、栈帧、watchpoint 和命令结构。
- LLDB command map:从 GDB/常见操作快速映射到 LLDB。
- objc.io Issue 19:Debugging:案例调查、LLDB 实战与调试检查表。
- Bugsee:iOS Crash Symbolication for dummies:通过源码、机器地址和崩溃栈对照理解符号化为什么必要。