多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

LLDB调试入门:掌握断点、表达式与崩溃定位

LLDB调试入门:掌握断点、表达式与崩溃定位 如果你搞过 iOS 或 macOS 开发就一定对 Xcode 左下角那个控制台不陌生。每次程序崩溃、断点命中、或者想临时看一眼某个对象的值都会自动跳到那一块区域。那个控制台背后的调试器就是 LLDB。它几乎是每个 Apple 平台开发者每天都在碰、却一直没认真研究过的工具。很多同学用了几年 Xcode对 LLDB 的印象还停留在“po self.view”和“bt”这两个命令上遇到复杂的崩溃现场、诡异的逻辑问题只会打上断点一点点往上翻代码效率低到让人崩溃。这篇入门指南专门解决这个问题。这篇文章会从 LLDB 的定位和核心设计开始讲然后带你把最常用的断点、表达式、栈回溯、内存读写这些基础操作全部过一遍每一步都配上真实的调试场景和可以直接抄走的命令。适合刚接触 iOS 开发没太久的初学者也适合那些一直在用 Xcode 点击操作、想真正掌握命令行的开发同学。看完你能做什么至少下次遇到崩溃、遇到对象被提前释放、遇到不知道当前视图状态的时候你能从 GUI 交互切换到命令行思维用几条命令自己把问题挖出来。1. LLDB 到底是什么为什么调试离不开它1.1 LLDB 的身份定位LLVM 项目里的调试器LLDB 是 LLVM 项目下的一个组件定位就是高性能调试器。所谓调试器核心职责就三件事控制程序运行、查看程序内部状态、修改程序运行时的变量和内存。操作系统把运行中程序的资源和状态都向调试器开放调试器才能做到暂停、单步、读取内存、修改寄存器这一整套操作。用个生活化的类比程序就像一台正在运转的机器正常工作时你只能隔着玻璃看外壳看不到内部齿轮怎么转动。调试器相当于让你拿到一把检修口钥匙可以随时让机器暂停、拆开侧板、检查哪个齿轮卡住了、甚至手动拨动某个齿轮再继续运转。LLDB 就是这把钥匙在 Apple 平台上的标准形态它替代了更老的 GDB成为 Xcode 默认自带、默认使用的唯一调试器。为什么 Apple 要自己搞一套 LLDB而不继续用 GDB原因有几点。第一是 LLVM 编译器本身就在 Apple 手里有深度参与LLDB 能直接复用 LLVM 的底层库对编译器生成的调试信息理解最到位在源码映射、变量解析、类型识别这些场景能做到比 GDB 更精确。第二是 LLDB 采用了模块化架构核心是一个 C 库命令行工具只是它的一层壳所以 Xcode 里的调试功能其实调用的是同一套核心能力GUI 和命令行不会有行为差异。第三是 LLDB 的设计目标很明确要低延迟、低内存、高并发实际体验下来附加大型项目时它的启动速度和断点响应确实比 GDB 舒服很多。1.2 LLDB 在调试流程里的位置一套典型的调试流程是编译阶段编译器在二进制里埋入调试信息记录源码文件路径、函数起始地址、变量作用域和类型运行阶段调试器把程序进程控制住CPU 在执行指令时遇到断点指令会触发异常操作系统把异常转给调试器调试器再结合调试信息把当时的寄存器、内存、线程栈映射回源代码层面给你看。Xcode 里你做的一切可视化操作设置断点、看变量、编辑某个值最终调用的都是 LLDB 的底层能力。所以你会发现你在 Xcode 左下角的控制台能敲的命令通常比 GUI 能做的更多。例如给某个地址直接读写内存、调用某个对象的任意方法、修改某个变量的值然后继续跑这些在 GUI 上没有按钮但在 LLDB 里就是一条命令的事。理解 LLDB 的位置我个人的体会是你会突然意识到 GUI 只是它的一小部分能力真正灵活的是那个命令行入口。1.3 LLDB 能做什么解决什么问题把 LLDB 的能力拆开看日常调试中高频使用的主要有四类断点控制在源码行、函数名、地址、内存访问上设置断点设置条件与命中次数控制程序在哪里暂停、何时暂停。表达式求值暂停后执行任意表达式访问当前作用域变量甚至调用 Objective-C 的 runtime、Swift 的方法。线程与栈回溯查看当前线程的调用栈、在线程间切换、查看线程的调度状态定位崩溃位置和调用链。内存与寄存器读写某个内存地址的字节、查看寄存器的值用于底层问题的排查。这四类能力基本覆盖了 iOS 开发日常会遇到的所有调试场景。崩溃时你需要栈回溯逻辑不对时需要断点加表达式看中间变量怀疑内存污染时你需要直接查看内存字节。LLDB 把这些能力统一成了一套命令而且全部支持脚本化后续你想把常用操作做成自动化检查点也是基于这套命令体系来扩展。2. 启动与基础命令先把手动挡开起来2.1 Xcode 控制台这是大部分人接触 LLDB 的入口对 iOS 开发者来说最省事的启动方式就是直接运行 Xcode 项目程序启动时 Xcode 会自动把 LLDB 附加到进程上。这种方式的好处是完全不用关心进程附加的细节断点、暂停、变量查看全部自动接好。程序运行到断点处暂停时你可以直接在 Xcode 的 Debug Area 输入 LLDB 命令。不过这里有个很多人不知道的小细节Xcode 里每次启动 App、或者从后台把 App 切到前台LLDB 可能会显示重新附加或者加载镜像的日志。有些同学看到这些日志还以为程序出了问题其实这只是在暗示调试器已经接管了进程属于正常现象。如果你想绕开 Xcode在命令行里直接处理一个已安装的 App也有两种常用方式一种是用xcrun simctl launch --console-pty booted com.example.app启动模拟器里的 App再拿lldb attach附加另一种是直接把 App 的可执行文件路径传给lldb让调试器以 create 方式自己创建进程并加载。从实操角度如果只是做 App 层调试用 Xcode 自带的附加就足够如果要做批量自动化或者需要调试命令行工具、扩展这些命令行方式更灵活。2.2 第一个 LLDB 命令run、breakpoint、continue 的轮回不管从哪个入口进入 LLDB你都会很快碰到几个入门命令。第一次用 LLDB 的同学我建议直接拿一个简单的命令行程序做实验比如lldb /bin/ls然后输入run。这时候 LLDB 会创建/bin/ls进程并启动它程序正常执行完LLDB 会告诉你进程退出了返回一个退出码。接下来试一下让程序暂停在启动前先设置一个断点LLDB 里设置断点最常用的命令是breakpoint set为了方便简写为b。比如(lldb) breakpoint set --name main (lldb) run--name main的意思是给名为main的函数下断点。run之后程序会在刚进入main函数时暂停LLDB 会显示当前的进程状态、命中位置。这时候你可以输入continue简写c让程序继续跑完。这个“暂停 - 查看 - 继续”的循环就是所有调试的基本动作。命令行里还有一个非常实用的快捷键按下 CtrlC 可以直接暂停正在运行的进程不需要提前设置任何断点。这对于排查卡死、死循环问题特别有用。假如程序跑飞了你 CtrlC 暂停后先bt看一眼调用栈立刻就能知道它卡在哪个函数里。2.3 断点之后最常用的几个导航命令进入断点之后你需要掌握在代码里“走”的几个关键命令next/n执行当前行不进入函数内部。适合在循环里快速跳过函数调用。step/s执行当前行如果这一行有函数调用进入函数内部。适合追查函数具体实现。finish一直执行到当前函数返回然后暂停。适合你误入一个函数后想快速回退到调用方。continue/c继续运行直到下一个断点或程序结束。frame select或frame variable查看当前栈帧的参数和局部变量。组合起来的一个常见场景是你怀疑某个返回值不对先在函数调用行用s进入函数体一步步用n执行再用frame variable看看局部变量值判断出问题出在哪个赋值语句最后finish退出函数。这套“进入-观察-退出”的组合比用 GUI 逐个点击步进按钮舒服得多尤其是函数很长、调用层级很深的时候命令行能让你精准控制不会一步跑过头。2.4 help 不是摆设学会自己查命令LLDB 的命令体系非常庞大靠记忆不可能覆盖全部但自带了一套很完善的说明机制。输入help可以看到所有命令类别的概览输入help 命令名可以看具体某个命令的参数用法。比如help breakpoint会告诉你断点命令有哪些子命令help breakpoint set会列出--name、--file、--line、--condition、--ignore-count这些参数的含义。我强烈建议你把help用起来别把希望寄托在背参数上。尤其是在命令行模式下忘了某个参数是--name还是-n直接help breakpoint set比去翻博客快得多。LLDB 的命令设计整体上是动词-对象-属性的结构比如breakpoint set --file main.m --line 10含义一目了然。理解了这套组合逻辑新命令上手会非常快。3. 表达式与变量操作让程序在暂停时听你的3.1 expression 命令调试时最常用的一把瑞士军刀LLDB 在断点暂停后最实用的能力之一就是执行任意表达式。核心命令是expression简写为expr。你可以用它来查看变量、调用方法、修改状态基本上只要在 LLVM 能解析的语法范围内都能执行。举个例子在断点处你有一个NSArray *items想看看它有多少项(lldb) expression items.count如果对象是 Objective-C 对象你甚至可以调用 runtime 方法(lldb) expression [items firstObject]Swift 环境下可以直接写 Swift 表达式比如(lldb) expression items.first不过有个坑需要注意expression默认的输出方式偏向描述底层类型如果你希望看到对象在description/debugDescription里的描述要用p或者po。p是expression --的简写po是expression -O --的简写po会调用对象的debugDescription来输出所以对自定义模型类和 UIKit 对象特别友好。这个差异考试题里不会考但实际操作中能省掉你很多困惑。3.2 修改变量的值改完继续跑expression除了查看还能修改这是 GUI 里没有直接入口的功能。例如你在断点处发现一个NSString *name是 nil你可以直接(lldb) expression name debug_test然后继续运行程序后面的逻辑就会拿着这个修改后的值往下走。这在调试某些只在特定数据下才会触发的 bug 时非常有用。比如线上反馈某个用户购买流程崩了你怀疑是用户名为 nil 时生成订单会有问题那你完全可以在断点处手动把用户名改成 nil然后continue复现崩溃不需要去改服务器数据、不需要二次打包。修改值有一个需要注意的细节expression会插入一段代码到当前栈帧里执行这段代码的执行是有副作用的如果表达式里调用了一个有状态变更的方法程序的行为也会变。所以调试时要清楚自己敲的命令对当前状态的影响别改完忘了后面怎么跑都觉得诡异。3.3 打印对象内容别再用 NSLog 刷屏很多同学的调试习惯是到处加 NSLog 打印打完了还得清理、重新编译。用 LLDB 的po命令你可以在断点处随时查看几乎任何对象的内容不用污染代码。(lldb) po viewController.view (lldb) po self.tableView (lldb) po [NSUserDefaults standardUserDefaults]这三个命令分别能打印出当前视图控制器的 view、列表视图和用户默认配置。当你在调试 UI 布局问题时直接po someView看看它的 frame、superview、subviews 层级定位起来比猜代码快得多。要查看普通变量的值比如一个CGRect或者int直接用p(lldb) p frame.origin.x (lldb) p count从我自己踩过的坑来说刚开始用 LLDB 时最容易犯的错是把对象和值混着用po和p。对象你用p看到的是类似(Class) $0 0x0000000这样的指针用po才能看到内部内容。普通值你用po有时会被强制转成对象结果也会有些诡异。记住规律对象看内容用po数值和结构体用p基本不会错。3.4 在表达式里创建临时变量和容器调试场景中经常需要临时构建一个假数据用来验证某种边界条件。LLDB 里你可以直接这么干(lldb) expression NSDictionary *dict {key: value} (lldb) expression dict[key]甚至动态创建数组、模型对象都可以。不过有一点要特别提醒LLDB 里创建的变量默认是在一个临时作用域里命令执行完可能就会被清理不能保证下一个表达式里还能用。如果你希望它一直存在需要给表达式加--或使用持久变量机制。LLDB 里还有个很实用的持久化功能叫$var风格结果变量比如你执行p someObject之后LLDB 会返回一个$0、$1这样的临时变量你可以直接在后续命令里引用它(lldb) p self.button (lldb) po [$0 titleForState:UIControlStateNormal]这里的$0就是上一条命令的返回值这个机制让你可以在多个表达式之间传递数据复杂调试时非常顺手。4. 断点管理让程序在最合适的时机停下来4.1 按行、按函数、按文件三种最基础的断点设置在 UI 调试里最常用的断点模式就是按源码行设置但命令行模式下你也可以更灵活。常用三种按行设置语法是breakpoint set --file ViewController.m --line 42简写b ViewController.m:42。按函数名设置语法是breakpoint set --name viewDidLoad或者简写b viewDidLoad。这种不指定文件时所有同名函数都会命中需要小心。按文件加函数名语法是breakpoint set --file ViewController.m --name viewDidLoad更精准。实际调试中我经常用函数名断点配合条件断点一起用。比如你只关心某个初始化函数在特定数据下的表现直接给所有同名函数下断点会太“吵”这时候可以加条件。4.2 条件断点与命中次数解决“偶尔出现”的 bug有一类 bug 特别烦人代码逻辑本身看起来没问题但某段操作执行了几十次之后偶发一次崩溃。如果你每次都让它命中再继续会把人逼疯。这种场景下条件断点才是正确的解法。比如你有一个循环循环到第 200 次时某个对象会崩你可以这样(lldb) breakpoint set --file ViewController.m --line 88 --condition i 200--condition后面的表达式会被 LLDB 在每次命中前求值只有为真时才真正暂停。这意味着前面 199 次循环都会全速跑过到了第 200 次才停下来给你看现场。还有一种情况是你根本不关心条件只是想跳过前几次命中。比如一个 API 在启动时调用了三次第四次开始才进入异常逻辑。这时候用--ignore-count 3帮你在命中三次后再触发暂停(lldb) breakpoint set --name loadData --ignore-count 3两者的区别是条件断点每次命中都会做一次条件求值适合依赖具体数值的过滤ignore-count 只在命中次数上计数适合“看够了再停”的场景。组合使用也没有问题--condition和--ignore-count可以同时放在一个断点上。4.3 断点列表、停用、删除与保存用多了之后断点会变得很乱。这时你需要掌握几个管理命令breakpoint list简写br l查看当前所有断点包括是否启用、命中条件、文件行号。breakpoint disable 编号/breakpoint enable 编号停用和启用指定断点。这个非常实用不用删除临时不想要时先禁用后面想用它就能快速打开。breakpoint delete 编号删除断点。breakpoint set -n foo这种通过名称设置的断点可以用breakpoint clear清除按名称匹配的。还有一个高级玩法是断点文件化。首次调试时手动设置十几个断点很费事但你可以把断点配置导出到文件里之后用的时候直接加载(lldb) breakpoint write -f my_breakpoints.json (lldb) breakpoint read -f my_breakpoints.json这条命令在很多团队里被用来统一复杂的调试配置比如一键加载所有关键路径断点效率提升明显。虽然首次搭配置要花点时间但一次配好之后人人可用。5. 栈回溯与线程崩溃现场的正确打开方式5.1 bt 命令崩溃时第一个要用到的命令程序刚崩或者暂停在一个异常位置时我第一件事永远是输入bt。bt全称thread backtrace作用是把当前线程的调用栈完整打印出来。调用栈就是“当前这行代码是被谁一步一步调用进来的”有了它你就能从崩溃点一路回溯到最初的入口快速定位问题源头。比如一个数组越界崩溃bt会显示当前崩溃在某个系统方法内部上层是你自己的某个方法再上层是某个网络回调。看到这些信息心里一下就有数了崩溃发生在网络数据返回后的处理链路上接下来马上检查数据转换那段代码。默认情况下bt输出的帧数可能不够深如果你需要更多上下文可以用(lldb) bt allbt all会打印所有线程的调用栈而不仅仅是当前线程。这对排查死锁、主线程阻塞、后台线程崩溃特别重要。有时候崩溃不在主线程而在一个后台 GCD 队列里主线程的bt看起来很正常你这时候盲目找半天不如一句bt all看全局。5.2 在帧之间跳转查看任意调用者的变量只看栈上每层的函数名还不够你还经常需要查看某个栈帧内部的局部变量和参数。LLDB 允许你在帧之间跳转(lldb) frame select 3这条命令会把当前上下文切换到调用栈的第 4 层帧编号从 0 开始。切换之后你再执行frame variable或者p 某个变量看到的就是那一层的局部变量和参数。这个能力特别适合排查“参数传递到底有没有被中间过程改掉”的问题。比如你第 0 帧看到一个 nil但第 2 帧传参时明明是有效的对象。你怀疑中间某个方法把对象赋值给局部变量时发生了问题那就在第 1 帧、第 2 帧之间切换逐个查看对应帧的变量就能定位到在哪一步丢失了引用。注意跳帧查看是只读式的变量求值依赖那一帧的上下文所以别在同一帧里随便乱改值然后回跳容易让现场变得不可信。5.3 thread 相关能力多线程崩溃排查thread命令还有很多其他子命令比如thread info查看当前线程的详细信息包含线程序号、队列名、线程名称thread list列出所有线程thread return 值直接让当前函数返回一个指定值。thread return是个玩法非常独特的命令它能让当前函数不继续执行后面的代码直接返回你指定的值。在调试一个依赖网络结果的分支逻辑时如果不想等网络请求可以直接在函数入口设置断点用thread return返回一个假数据让流程继续往下走。这样就能快速验证后续页面逻辑是否正确省掉一堆 mock 麻烦。这个命令有副作用不适合用于正常流程的验证但在探索性调试里效率极高。6. 常见问题排查与实操心得6.1 常见问题速查表命令行调试总会遇到各种环境适配问题这里把我实际踩过的几个典型问题整理成表方便顺手对照问题现象可能原因解决方案po对象时输出error: property not found当前上下文类型不匹配LLDB 没识别出对象真实类型先p查看类型或用expression -l Swift强制指定语言命令没反应提示Could not resolve断点位置在系统框架内部源码映射缺失用image lookup --address反查或改用指令级调试expression修改变量没效果修改的是逻辑地址变量被编译器优化进了寄存器在 Debug 配置下调试或者对相关帧用frame variable --no-print确认是否被优化断点命中次数不对同一个函数有多个符号版本如分类、模块化 Swift 同名用文件函数名组合指定或者breakpoint set --source-pattern-regexp匹配源码附加到真机失败开发签名、设备调试权限问题重新信任开发者证书检查 Xcode 的设备窗口调试时expr无法调用 Objective-C 方法需要先加载 Objective-C runtime 库尝试expression -l objc --指定语言并确认 App 确实在运行 Objective-C 运行时6.2 现场实操一个典型的“崩溃定位 临时改值”流程用一个完整的小例子把这些命令串起来更能理解它们如何衔接。假设你在调试一个UITableView的崩溃报错信息指向index path row超出数据源范围。(lldb) bt看到当前崩溃发生在cellForRowAtIndexPath里往上翻发现是从某个reloadData后触发那基本可以确定是数据源和 UI 更新不同步。此时你在断点处查看一下数据源数组(lldb) po self.dataArray (lldb) po indexPath发现indexPath.row是 12但数组只到 9。如果你不想重新跑数据可以先临时改掉数据源让它先不崩溃继续看后面的逻辑(lldb) expression self.dataArray [self.dataArray subarrayWithRange:NSMakeRange(0, 13)] (lldb) continue这样 App 不会立刻崩溃你能继续观察后续 UI 表现定位真正导致数据源错乱的业务代码。这个思路非常实用先保活现场再排查源头而不是每次都从头重新运行去复现。6.3 我的个人习惯和小技巧给新手同学分享几个我实际固化下来的 LLDB 使用习惯。第一个习惯是断点前先计划不要盲目打很多断点先明确你怀疑的代码路径再用条件断点把范围缩小。断点打太多反而会打断对程序流程的理解只有在遇到复杂问题时才逐步增加断点密度。第二个习惯是把调试命令写成一条多语句用分号可以连接多条 LLDB 命令比如(lldb) p self.dataArray.count; p self.tableView.numberOfRowsInSection:0一次输入两条相关命令快速对比状态能省不少重复输入。第三个习惯是善用历史记录和 Tab 补全。命令行模式下按上下方向键可以翻历史命令按 Tab 可以补全命令和参数。调试到后半程你常常发现自己反复在敲同一组命令善用历史记录能大幅减少手误。第四个习惯也是最重要的一个不要只依赖 LLDB也不要只依赖打印把两者结合起来。LLDB 解决的是“此刻到底发生了什么”打印解决的是“一段时间内发生了什么”。遇到偶现的时序 bug光靠断点很难抓因为暂停本身会改变时间窗口遇到一步就能还原的异常光靠打印又太慢因为你得重新编译。知道什么时候用哪把工具才是调试能力真正提升的标志。LLDB 的入门其实不难关键是把命令和真实调试场景对应起来多敲几次就会形成肌肉记忆。这套东西一旦用顺你会发现自己调试问题的速度和精度都会有质的提升下次再被同事叫去帮忙看崩溃你可以淡定地打开控制台来一句bt。
返回列表