
做iOS开发这几年我越来越觉得LLDB是那种“平时不怎么在意、一到关键时刻能救命”的工具。这话一点不夸张你在Xcode里点那个“继续”按钮、在断点行上看变量、往控制台敲po self.model底层全是LLDB在干活。LLDB的全称是Low Level Debugger是LLVM项目里负责调试的核心模块从苹果的Xcode到Linux上各种IDE集成的调试器再到很多逆向分析工具背后都有它的身影。这篇入门指南写给谁给那些刚接触iOS开发、还在“只会点绿色三角”阶段的新手也给写了好几年代码但始终停留在图形界面操作、一进命令行就慌的程序员。目标只有一个让你在遇到断点、crash、变量看不到值这类问题时不再靠猜而是能主动去查、去定位、去解决。我不打算把LLDB的官方文档搬过来翻译一遍而是把平时真正用得上的命令、场景和坑按实际调试时的思考顺序串起来讲每一条都是可以直接上手的。1. 先搞明白LLDB是什么1.1 lldb命令背后的那套系统很多人的第一反应是LLDB不就是Xcode底部那个黑框吗其实不完全是。Xcode底部那个调试控制台只是LLDB的交互入口之一。lldb本身是一个独立的命令行调试器你可以直接在终端里启动它用它调试一个C、C、Objective-C或者Swift写的可执行文件。它和gdb是同一类东西但又不一样——gdb是GNU方案里的调试器而LLDB是LLVM这套编译器工具链中的调试组件和clang等共享底层架构。这意味着它对Swift、Objective-C这些Apple生态的语言支持比很多调试器要好很多特别是Swift的高级类型信息在LLDB里能展现出比gdb友好得多的视图。可能你会问我用Xcode不是已经可以调试了吗为什么还要学命令行因为图形界面只能覆盖最常见的那几条套路打断点、暂停、步进、看变量。但当你碰到“这个变量为什么被改成了0”或者“这个crash到底发生在哪一行”这种问题图形界面往往就帮不上忙了。图形界面上你能用到的每一个功能几乎都能在LLDB里找到更精确、更可控的命令形式。我之前遇到一个特别迷惑的内存被改写问题图形界面根本看不出什么最后就是用watchpoint在内存位置上下断点轻松定位到了是哪一行代码越界写的。1.2 你其实每天都在用LLDB可以这么说所有用过Xcode的人都已经在用LLDB了只是自己没意识到。你在代码行号左边点一下在那里弹出一个小红点运行后在断点处暂停然后在变量区看到当前对象的值——这背后的机制就是LLDB。你右键某个变量选择Print Description它会调用LLDB的po命令。你按下调试工具栏上的暂停键程序收到SIGSTOP信号后停在某个位置你在左边看到当前线程和调用栈这些窗口上的一举一动底层都是LLDB在响应。那为什么还要专门学命令行因为图形界面是一个“简化版”的LLDB它把命令包装成了按钮和菜单但很多高级能力没有暴露出来。比如给断点设置一个条件让它只在满足某个逻辑时才暂停比如给一个断点挂一组自动动作比如在暂停状态下任意修改一个对象的内存值再继续运行。这些东西在UI上要么藏得很深要么压根没有入口。而命令行版本把这些能力全打开了。你只要记住几个核心命令能做的事比点按钮多一个数量级。1.3 从环境准备开始如果你只用Xcode那LLDB已经内置好了不用额外安装。但如果想在终端里单练建议你先确认自己的命令行工具链是完好的。Mac上一般执行xcode-select --install就能把命令行工具装好。装好之后随便写个C文件试试cat main.c EOF #include stdio.h int main() { printf(hello\n); return 0; } EOF clang -g main.c -o main lldb ./main-g参数很重要它告诉编译器保留调试符号没有调试符号LLDB就看不到源码、变量名这些信息调试体验会大打折扣。进入lldb交互环境后你输入help就能看到所有支持的子命令输入quit退出。这一步走通后面的内容就可以一路玩下去了。2. 最常用的LLDB命令建立调试手感2.1 先学会用help查一切LLDB这套命令体系一开始最让人困扰的就是命令太多了。但其实它有很清晰的层级结构。最外层是help它会列出所有一级命令比如breakpoint、thread、frame、expression、memory、register等等。想知道某个一级命令下面还有哪些子命令就继续加参数比如help breakpoint会列出跟断点相关的所有操作。想再深入一层比如help breakpoint set就会显示breakpoint set支持的参数和示例。这套“层层help”的办法比硬记文档高效得多。我在带新人的时候经常看到这样的场景遇到一个不会用的命令立刻去搜索“LLDB XXX用法”。其实先敲一下help往往更快它的说明和示例都写得相当清楚。而且它会把参数缩写一并列出来比如-f对应--file-l对应--line这些缩写你在网上查到的代码片段里经常会碰到用help就能把它们对应起来。2.2 运行、暂停、步进的完整闭环调试一个程序核心就是“让它跑一会儿、停下来检查、再继续跑”。这几个操作在LLDB里分别是run缩写r启动或重新启动进程后面可以带参数比如run arg1 arg2。continue缩写c从当前暂停位置继续执行直到下一个断点或程序退出。step缩写s单步执行遇到函数调用会进入函数内部。next缩写n单步执行但把函数调用当作一步跳过。finish缩写f一直执行到当前函数返回然后停在返回处。举个例子我在调试一个排序算法时会用n一步步走外层循环用s钻进某个方法看看内部实现用c快速跳到下一个断点用f从当前函数里跳出来。这套组合拳配合断点基本能覆盖90%的“走到哪看到哪”的调试需求。2.3 别被缩写绕晕LLDB的一个贴心之处是它对很多常见命令提供了别名而且很好记。n就是nexts就是stepc就是continuef就是finishbt就是thread backtrace。这些别名不是凭空造的很多是为了兼容老gdb用户的习惯。所以你在网上看到的LLDB片段经常是一堆短命令像br s -f xxx.swift -l 12这种看着吓人其实拆开就是breakpoint set --file xxx.swift --line 12完全不神秘。还有两个高频别名要特别记p和po。p是expression --的别名用来求值一个表达式打印结果和类型po是expression -O --的别名会对对象调用debugDescription/description/dump这些方法来打印更友好的描述。在ObjC时代po几乎是无敌的因为大部分对象都实现了description到了Swift时代则要注意后面我会专门展开讲。2.4 手动暂停排查卡顿的第一步除了在断点处暂停LLDB还支持手动让进程暂停。你正在跑一个App突然感觉界面卡住了这时候去Xcode顶部点那个方形的“暂停”按钮程序就会立即停下来。这个操作本质上就是给进程发了一个SIGSTOP信号然后LLDB接管停在你当前正在执行的那一行。停下来之后第一件事不是瞎看而是输入bt看当前线程的调用栈。很多主线程卡死问题一看bt就能发现是某个耗时的网络回调或者同步锁把主线程堵死了。我在排查一个启动卡死问题时就是靠暂停主线程、再看bt一两分钟就锁定了那个在启动路径上执行同步磁盘读取的代码。这种排查方式不依赖任何断点和预先设置属于“飞行中体检”是每个iOS开发者都应该掌握的看家本领。3. 断点从图形界面走向命令行3.1 行断点、方法断点和符号断点在Xcode图形界面上你点行号就能打一个行断点对应的命令是breakpoint set。breakpoint set --file ViewController.swift --line 42 breakpoint set -f ViewController.swift -l 42这两行是等价的只是后者用了缩写。还有一个常见需求是给某个方法打断点比如每次进入viewDidLoad都停一下。breakpoint set --name viewDidLoad breakpoint set --method ViewController.viewDidLoad区别在于--name会用符号名做匹配只要名字对得上就会命中--method则更“语言化”一点对C、Swift这类带命名空间的方法更友好。如果你在做ObjC调试还经常用--selector按selector来打断点比如breakpoint set --selector viewDidLoad因为ObjC的消息发送本质是找selector。这些命令可能看起来没有图形界面直观但它们能解决图形界面解决不了的问题。比如你想给“所有类里的dealloc方法”都打断点图形界面一个一个点会疯掉命令行一条breakpoint set -n dealloc就搞定了。3.2 条件断点让LLDB只在特定情况下停实际调试中最常见的痛点是“循环到第某个值才出错”。如果你在循环体内打断点程序会停无数次你得一次次点继续点到手抽筋。这时候条件断点就派上用场了。在LLDB里给已有断点加条件的命令是breakpoint modify或者breakpoint set时直接带-c参数。breakpoint set -f main.swift -l 28 -c i 5也可以先创建一个断点拿到ID之后再补条件。假设它ID是2breakpoint modify -c i 5 2这里要特别注意条件表达式是在调试器里求值的不是在编译时求值所以它可以用你当前帧里能看到的所有变量。条件可以是i 10 arr.count 0这种组合也可以是调用某个方法只要表达式能被LLDB正常解析。不过也别写太重的表达式因为每执行到这一行它都会被求值如果里面调用了毫秒级耗时的函数程序会明显变慢。3.3 断点自动动作一条命令干一堆事条件断点只能解决“停止与否”的问题但有些场景下你根本不希望程序停只希望在某个位置自动打印一些信息然后继续跑。这就是断点动作的用途。breakpoint command add 2 frame variable continue DONE上面这段的意思是断点2每次命中后自动执行frame variable打印当前栈帧的局部变量然后执行continue立刻继续运行。最后一行DONE是结束输入的模式标志。如果想查看某个断点已经挂了什么动作用breakpoint command list 2想清掉动作用breakpoint command delete 2。这种打法非常适合日志型调试。我调试过一段很复杂的数据解析中间有个状态字段频繁变化但又不想手动一次次打断点。我把断点设在状态更新那行让每个断点自动打印当前状态和关键变量再自动继续跑完一次流程等于拿到一份自动生成的现场日志。这种方式比在代码里临时加print要干净得多因为它不需要改源码、不需要重新编译。3.4 断点启停删批量管理断点一多管理就是问题。breakpoint list缩写br l能列出所有断点每条都有编号和状态。breakpoint disable 1/breakpoint enable 1/breakpoint delete 1分别对应停用、启用、删除。这里有个细节disable不会移除断点信息只是暂时不生效适合临时不想停但又不想删的场景。还有一种“一次性断点”用-o参数创建比如breakpoint set -o -f main.swift -l 30它命中一次之后会自动删除。这个在图形界面上“右键断点 - Delete Once”差不多。我经常用它来做那种“我就要在这一行看一次看完就走”的临时观察。断点操作这件事熟练之后你根本不会想去图形界面里一个个右键点了。4. 查看数据frame variable、expression和po的真相4.1 用frame variable快速浏览当前栈帧的局部变量进程停住之后最自然的想法是“我现在能看到哪些变量”。最直接的命令是frame variable它会把当前栈帧里所有局部变量和参数都列出来包括变量名、类型、值。frame variable frame variable self frame variable self.name不加参数会列出全部加参数可以只看某个对象。它还可以配合--no-args、--no-locals这种选项过滤输出。对于Swift结构体frame variable输出往往比po更工整因为它走的是类型系统直接描述值而不是调用description方法。frame variable只能看当前栈帧能找到的东西如果你想看别的栈帧得先用frame select切换过去。4.2 expression正式状态下执行任何代码如果说frame variable是“看”那expression就是“干”。它能在调试暂停的进程里执行一段真实的表达式返回结果甚至可以修改内存里的值。expression self.title 新标题 expression self.view.alpha 0.5 expression print(hello) expr var x 5 expr x x 10这些代码不是开发时写在工程里的而是LLDB在你暂停的进程里临时编译出来并执行的所以你可以用它来操控正在运行的程序。这也是LLDB一个很强大的能力动态修改变量调试UI跳转、网络请求这类问题时会非常方便。比如我想看某个隐藏的页面长什么样可以在断点处直接expr self.navigationController?.pushViewController(nextVC, animated: true)不用重新编译就能跳到那个页面去观察。4.3 po不是万能药po在ObjC时代几乎是调试神器因为它能调用对象的description或者debugDescription方法输出一个可读性很高的描述。它的全名是expression -O --其中-O就是“object描述”的意思。但在Swift下有一个经典陷阱某些值的类型没有实现CustomStringConvertible或者CustomDebugStringConvertible直接po只能看到类似Some或者一堆地址数字完全没法看。这个时候可以试试expression -l swift --来强制用Swift语言模式求值或者直接frame variable看原始值。另一个办法是在代码里临时给这个类型扩展一个CustomStringConvertible实现这样LLDB在打印时也能受益。总之po很好用但遇到不给力的情况不要死磕换frame variable或者普通expression往往更有效。4.4 Swift调试里的几个细节Swift和ObjC在调试时有个大区别编译器优化和类型信息对调试的影响更明显。比如你写了一个let常量在Debug编译下应该还能看但如果那个值被编译器内联了frame variable可能也显示不出来。这时建议优先用expr -l swift -- let x ...这种显式模式来写表达式规避语言层的推导问题。还有一个细节Swift的字符串、数组、字典在LLDB里的展示非常依赖类型系统的支持。如果你在调试某个自定义的泛型容器时看到一堆奇怪的内部字段别急着崩溃那很可能是类型信息没完全加载换个时刻再试或者用type lookup这个命令去查看具体类型定义能帮你理解底层存储结构。5. 线程和调用栈崩溃现场的正确打开方式5.1 线程列表和切换程序停住后thread list会列出当前进程的所有线程每个线程的前面会有一个编号当前停住的线程会标记出来。thread select 编号可以切换到指定线程。在iOS上主线程通常是编号1但这条不是绝对的Apple平台API层面你随时可以用主队列来判断。我用这个组合最多的场景是UI没响应停住后先thread list看看各线程状态如果看到某个线程堆积了大量网络call基本就能锁定卡死的来源。切换线程之后再用frame variable看的自然就是那个线程里能看到的变量了。5.2 backtrace与栈帧切换thread backtrace简写bt输出的就是从当前执行点往上一直追溯到入口函数的调用路径。这几乎是crash排查里最高频的命令。后面可以带参数控制输出比如bt bt all bt -c 10bt all会把所有线程的调用栈都打印出来这是分析主线程是否卡死在另一个线程等待的场景的神器。bt -c 10是只打印最上面10帧栈很深时输出简洁很多。看到每一帧前面的数字比如frame #0、frame #3这些数字就是栈帧编号。用frame select 3可以切换过去之后frame variable看到的就是那个栈帧里的局部变量。我在定位一个深层次递归导致的crash时就是一层一层frame select上去最终找到出错的那一行输入参数。5.3 一个踩过的坑只看错误日志不看栈有一次我拿到一份crash报告错误日志里写着“Thread 1: EXC_BAD_ACCESS”对应的代码位置是个很正常的字典读取。单看这一行根本不知道为什么会崩。后来在真机上复现用bt看了完整调用栈才发现在进入这个方法之前一个对象的生命周期已经被提前结束字典读取时其实是在访问已释放的内存。如果只看表面错误日志这个问题是定位不了的。所以我在带人的时候反复强调crash的时候一定要先bt再看日志。栈是骨架日志只是皮肉。6. 进阶技巧image、watchpoint和memory6.1 image lookup按符号或地址找信息image这组命令是用来查模块和符号信息的。最常用的三个场景image lookup --name viewDidLoad按符号名反查它在哪个模块、哪个文件。这个对确认符号是否存在很有用。image lookup --address 0x10400abcd输入一个内存地址输出它对应的方法名、文件名、行号。这种场景在拿到崩溃地址时最常用比如日志里只有0x000000010400abcd你就能反解出它是哪一行代码。image list列出当前进程加载的所有镜像库和模块配合-o可以看模块偏移量是做符号还原和分析动态库加载时必备的命令。记得有次我们包里集成的一个第三方二进制库崩了公司内部没有源码只有一份崩溃地址。我打开工程在崩溃现场暂停用image lookup -a 崩溃地址直接反解出它落在这个库的哪个偏移段再结合反汇编和团队提供的符号表很快锁定了是哪个API传入的非法参数。6.2 watchpoint让内存改动无所遁形watchpoint是另一个我非常喜欢的调试功能简单说就是监听某个变量或内存地址只要它被修改程序就停下来并告诉你是在哪一行代码写变了它。常用命令watchpoint set variable self.count watchpoint set expression self.count watchpoint list watchpoint delete 1 watchpoint modify -c (value 0xff) 1watchpoint set variable比较直观指定变量名即可。但如果变量本身是被某个悬空指针写的那监听这个变量的地址可能不现实更稳妥的是watchpoint set expression xxx监听它真正的内存地址。条件watchpoint则能进一步过滤比如只在值变成某个特定数字时才停。这个功能在排查“某个属性莫名其妙被改”类问题时简直就是回放录像带能直接定位到肇事代码行。6.3 memory与register直接操作底层数据memory命令组用来读写指定地址的内存。比如memory read 0x16f4f0f00 memory read --size 4 --count 8 0x16f4f0f00 memory write 0x16f4f0f00 0xdeadbeef第一个是读默认长度第二个指定每4字节读8个值第三个是写入一个32位整数。这类操作对理解指针、数组越界、struct内存布局特别直观。register read则能看到当前CPU寄存器快照register write可以直接改某个寄存器的值。说起寄存器重点不是背寄存器名称而是理解在某个调用约定下参数怎么传、返回值放哪里。真机上的x0、x1通常就是函数参数碰到反汇编的时候会很有用。这些底层操作平时用得少但一旦遇到“内存被改”“运行时异常”这类疑难杂症它们能帮你绕过所有表象直接看真相。7. 配置你的.lldbinit让调试更顺手7.1 从.lldbinit设置默认行为LLDB每次启动时会自动读取~/.lldbinit这个文件里面可以写上所有想在启动时执行的命令。最常见的就是定义别名command alias pvc po self.view command alias bt-all thread backtrace all写完之后下一次启动lldb时pvc就会打印当前控制器视图的详细描述。这个文件还可以放settings set比如settings set target.max-string-summary-length 1024能让你一次性看到更长的字符串内容避免长字符串被截断看不清。只要在当前工程里用到这些命令它都会生效。7.2 Python脚本和类型格式化LLDB另一个大杀器是支持Python脚本。你可以用command script import xxx.py加载一个脚本脚本里能用Python定义命令、修改输出格式、给自定义类型添加漂亮的summary。比如你有一个Person类型默认打印一堆内部字段你可以写一个Type Summary脚本让它像输出name: 张三, age: 25这样展示。这个能力很强大但对入门用户来说不一定马上用得上建议先掌握前面的基础命令等真正遇到“每次都要重复敲一堆命令”的痛点再去学脚本化也不迟。7.3 小心你的别名变成坑有一点要提醒.lldbinit里的别名是全局生效的如果你在一台机器上设了某个别名换个同事的电脑或者CI环境命令可能就不存在了。所以里面最好只放个人习惯的增强配置而不要把某个命令的顺序或参数过度依赖在你的脚本上。另外一个容易忽视的点是~/.lldbinit的语法错误会导致LLDB启动时报警告但一般不会阻止后续使用。这个文件虽然小花几分钟规划一下还是值得的。8. 常见问题与排查技巧实录8.1 一张速查表解决日常卡壳我把入门阶段最常遇到的问题整理成一张表遇到同类问题直接对照着找思路。现象可能原因处理建议断点命中不了编译优化 / 源码路径不一致 / 模块未加载检查编译配置是否-Onone用image lookup -n确认符号存在po显示不出对象描述类型未实现description / Swift类型推导问题改用frame variable或expr -l swift --变量显示unavailable编译器把局部变量优化掉了尝试在更高一层栈帧查看或关闭优化重新编译expression执行报错表达式语法与调试语言不匹配显式指定语言模式比如-l swiftbt看不到代码行号缺少-g调试符号重新以Debug配置编译watchpoint创建失败变量不是可写内存 / 被优化掉改用watchpoint set expression var监听地址8.2 Debug模式下看不到变量值这个问题很常见尤其在旧设备和复杂类型上。Debug模式下LLDB默认能显示的只是带调试信息的部分并不是所有变量都能即时可见。如果遇到某个字段显示不出我一般会先用frame variable --format hex看原始内存看看地址和类型是不是合理再不行就切到汇编视图用寄存器去找。大多数时候是编译优化导致的那就回到编译设置里把优化关掉比如在Debug配置里确保Optimization Level是-Onone。这个设置对调试体验影响极大我见过不少团队Debug编译开-Os结果一堆断点变量看不到浪费时间排查。8.3 断点不命中的真实案例去年有位同事在Xcode里给某个文件第42行打了断点程序运行后无论如何都不停。他以为是LLDB坏了后来我用breakpoint list看发现断点状态显示“pending”意思是符号还没被解析到。进一步的image lookup -n确认目标方法根本不在当前二进制里。原因是那一块的代码是动态库里的在打了断点时动态库还没加载需要等库加载后再重新执行一次breakpoint set或者启用“wait for debugger”之类的机制。这个案例说明断点不命中先确认不是断点设置问题再看符号是否存在最后再考虑优化因素。排查问题要有顺序不然很容易白折腾半天。8.4 给新手的三个小建议第一把help当成随身字典用多了自然就记住了。第二给自己设计一套“调试三板斧”遇到问题先bt看栈再frame variable看变量最后用expression验证猜想。第三不要怕命令行LLDB的命令体系远比想象中简单它只是一层层的“动词宾语”多敲几次就肌肉记忆了。再分享一个小技巧调试UI问题时我经常把一个断点设在-[UIView layoutSubviews]这个符号上然后配合条件断点只在自己那个自定义视图的实例上暂停可以非常高效地观察到布局阶段的所有调用和参数。真机上跑不动的时候优先在模拟器上复现simulator的LLDB处理性能和稳定性通常比真机好不少。记住一点LLDB的意义不是让你变成命令行高手而是让你在别人还在瞎猜的时候能最快看到真相。这套东西越用越顺手多用几次你就不想回去了。