
1. 先回答一个问题为什么你的调试工具链里必须有LLDB1.1 LLDB的身世从LLVM项目里长出来的调试器LLDB的全称是Low Level Debugger但它并不是很多人误以为的“低级调试器”准确说它是LLVM项目下的调试器组件。2010年之前Xcode的默认调试器是GDB苹果当时的处境很尴尬GDB的GPL许可证和自家工具链的整合诉求越来越拧巴加上GDB对Objective-C对象的表达、对Swift这类新语言的支持天生就弱所以苹果干脆从LLVM编译器基础设施里自研了LLDB从Xcode 4开始把它扶正成了默认调试器。你只要在macOS上写过C、C、Objective-C或Swift用Xcode打开工程点一次运行底层其实已经在跟LLDB打交道了。它的覆盖面远不止苹果生态。Linux、FreeBSD这些平台上装了LLVM工具链基本都会带上lldb命令行所以你在Linux上写C/C时同样可以用它替代gdb。LLDB的架构设计跟GDB最大的不同是模块化核心把调试对象抽象成target、process、thread、frame四层不同平台的后端可以即插即用表达式解析直接复用Clang/LLVM的能力。对使用者来说直观感受就是启动快解析OC/Swift对象时的自然程度远胜GDBXcode里面你看到的变量视图、内存视图、调用栈列表全部都是LLDB在后台供数据。1.2 GDB用户切换到LLDB需要改掉哪些肌肉记忆如果你是老C语言玩家之前习惯了gdb那一套单字母命令切换到lldb后最需要适应的不是“命令变少”而是命令风格完全不同。GDB的命令大都是一个动作一个词LLDB的完整写法是“命令 子命令 选项”三段式比如breakpoint set --name main这个写法看起来啰嗦但很有规律。breakpoint表示对象set表示动作--name是选项名main是选项值。等你记住这个结构发现它的可猜测性比gdb强得多。下面这张对照表是我当年从gdb迁过来时最重要的备忘功能GDBLLDB运行程序runrun / process launch下断点break mainbreakpoint set --name main简写 b main继续执行continuecontinue简写 c单步跳过nextnext简写 n单步进入stepstep简写 s查看变量print xprint x / frame variable查看调用栈btthread backtrace简写 bt我的建议是不要把精力花在记缩写上先把完整的命令词敲几遍。比如你多次敲breakpoint setLLDB的tab补全会自动帮你补全敲着敲着肌肉记忆就形成了。网上大量老教程还是GDB语法你照着敲进LLDB会直接报错遇到这种情况先看一眼命令词是break还是breakpoint就能判断这篇文章到底在讲谁。2. 五秒钟进入调试环境命令行启动与Xcode控制台2.1 命令行直接启动并搞清楚-g和-O0的意义命令行是理解LLDB最干脆的入口。假设你已经有一个源码文件比如main.c编译出来带调试信息的版本需要两个关键参数clang -g -O0 main.c -o hello lldb ./hello-g是让编译器生成DWARF调试信息没有它LLDB就只知道“内存里有代码”但不知道哪个地址对应源码第几行、哪个变量叫什么名字。-O0是关闭编译器优化这一点经常被忽略实际踩过坑的人都知道开-O2编译后单步代码会乱跳变量经常显示“value optimized out”因为优化器把变量放进寄存器甚至直接算成常量了调试器读不到你以为它还在的东西。进入LLDB后会看到(lldb)提示符敲run或者直接敲continue就能运行程序。需要传参时这样写(lldb) run arg1 arg2 (lldb) process launch -- input.txt后一条的意思是把input.txt作为标准输入喂给程序比在程序内部临时改文件路径来测试方便得多。另外调试一个已经运行中的进程也是日常刚需lldb -n 进程名 lldb -p 进程ID第一种按进程名附加上去第二种按进程号。服务类程序“卡住不动”或者“内存一直在涨”的时候这一招比重新启动复现问题高效太多。2.2 Xcode里怎么打开LLDB控制台以及一个常见小坑如果你主要在Xcode里开发断点命中后窗口底部会出现调试区左侧是变量列表右侧是控制台。这个控制台默认就是LLDB的交互环境你直接输p、po、bt这些命令跟终端里的lldb完全等价还自带代码高亮和自动补全。Xcode用户最容易踩的一个坑是明明断点命中了调试区底部的输入框却怎么按都没反应。多数情况不是LLDB挂了而是焦点不在控制台区域。点一下控制台再点调试工具栏上的暂停/继续图标基本都能恢复。另一个坑是断点设了但不生效这时候先去Product Scheme Edit Scheme里确认当前Build Configuration是Debug。Release配置下编译器会做一堆优化很多断点会失灵命令写得再对也没用。2.3 第一批只需要记住五条命令LLDB的命令总量非常大但你完全不需要在一开始全背下来。我建议第一批只记五条run运行程序breakpoint set --name xxx给函数下断点continue继续执行next单步跳过frame variable查看当前函数的所有局部变量这五条覆盖了我刚开始调试时八成以上的操作。剩下的需求用help临时查就行。LLDB的help不是摆设它会列出命令的所有子命令和选项比如help breakpoint就能看到set、list、delete这些子命令。如果你只记得一个模糊的关键词用apropos搜索比如敲apropos thread它会把所有带thread字样的命令全部列出来。我用了这么多年遇到不熟悉的子命令还是靠help救急这不算丢人。3. 下断点的方式比你想象的多得多3.1 按文件、按行号、按函数名、按正则批量下最基本的断点是指定文件加行号breakpoint set --file main.c --line 12 b main.c:12指定函数名更是家常便饭breakpoint set --name factorial b factorial这里有一个值得养成的习惯先想清楚自己到底想在“哪个粒度”停住。想停在某个具体位置选文件加行号想停在一个业务入口选函数名。函数名断点有个隐藏优势就是当这个函数被多个地方调用时无论从哪里进入都会停这对排查“某个公共函数被谁调用之后状态被改坏”非常有用。C里如果函数有重载用--name可能会命中多个同名符号第一次列出断点时你会看到同一个名字出现了好几条。这时可以再加--shlib限定动态库或者干脆先全部停住再手动disable掉不要的。LLDB还支持正则断点一条命令批量下断点breakpoint set --regex fact.*这个能力是图形界面很难替代的。Xcode里让你手动点一百个断点你肯定崩溃LLDB里一句话就全部搞定适用于在某个模块的所有接口入口处插桩排查。3.2 条件断点怎么设置以及两个容易踩的暗坑循环类bug比如“第5次的时候结果就不对了”硬断点会很痛苦需要一次次continue。正确做法是条件断点breakpoint set --file main.c --line 20 --condition i 5 b main.c:20 -c i 5写条件断点时有两个暗坑我专门拿出来说。第一个条件表达式里的变量必须能在当前断点位置的帧上下文中被解析到也就是说断点所在的那一行作用域里必须能看到这个变量否则LLDB每次命中都会静默地当作条件不成立不会报错。第二个条件表达式的求值是实打实执行的如果这个断点所在的循环一周执行一亿次哪怕条件是简单的比较性能也会肉眼可见地下降。碰到这种场景我会考虑先用普通断点停一下改成watchpoint或者把循环次数改小再验证逻辑。3.3 断点管理list、disable、delete三件套断点设多了之后管理就变得重要。breakpoint list会列出当前所有断点每个断点都有编号后面会标注enabled还是disabled。精力最应该花在记住这三个子命令上breakpoint list breakpoint disable 1 breakpoint enable 1 breakpoint delete 1我的经验是尽量用disable而不是delete。排查问题的时候你可能前后设了五个断点最后确认只有一个是关键点另外四个每次命中都打断节奏。把它们disable掉而不是删掉万一结论被推翻或者你想复现一次完整流程重新enable回来就行不用费劲回忆刚才的断点到底设在哪个文件哪一行。真正确认不需要了再delete。这个习惯能帮你省掉大量重复操作。4. 第一堂实战用递归程序把断点、单步、变量全走一遍4.1 准备一段能说明问题的小程序理论说再多不如直接开一次实弹演练。我经常给刚入门的同事用下面这个程序演示保存成fact.c然后clang -g -O0 fact.c -o fact编译#include stdio.h int factorial(int n) { int result; if (n 1) { result 1; } else { result n * factorial(n - 1); } return result; } int main() { int total 0; for (int i 1; i 5; i) { total i; } printf(sum %d\n, total); int f factorial(5); printf(factorial(5) %d\n, f); return 0; }选递归而不是简单循环是因为递归是理解调用栈最直观的场景。你亲眼看着factorial(5)层层调用factorial(4)、factorial(3)栈帧一个个压上去再一个个退出来“栈”这个概念就不再是教科书里的抽象名词了。4.2 停下来的第一件事frame variable启动lldb ./fact先b factorial再run。程序会在第一次进入factorial的地方停下。这时敲frame variable(lldb) frame variable (int) n 5 (int) result 0你会看到当前函数本级的所有局部变量和参数。frame variable跟p最大的区别在于它直接根据DWARF调试信息读取寄存器或者栈上的值不会执行任何调试表达式代码所以没有副作用。而p的全称是expression --它会真的去编译并执行一段表达式。当你只是想“看一眼值”优先用frame variable当你要“算一个表达式”比如p n * 2或者p foo(3)再用expression。这个区分能帮你避免很多莫名其妙的意外尤其是当变量的getter方法或自定义运算符本身有副作用的时候用p等于在调试过程中悄悄改了程序状态排查起来会非常迷惑。4.3 print、expr、po到底什么时候用在LLDB提示符下p、expr、po这三兄弟是最容易被混淆的。我用最直白的话理一遍p是expression --的别名适合打印基本类型、结构体、地址也可以看C字符串指针本身。expr和p基本等价但expr更常用来“写”而不是“读”例如expr n 3可以直接改写当前帧里的n然后继续执行。程序后续全部基于n3计算这是调试时最被低估的杀招。po是expression -O --的别名O是Object Description意思是对OC/Swift对象调用description方法打印描述。在Swift里po出来的对象信息比p友好得多OC的NSObject同理。举个例子停在factorial里后(lldb) p n (int) $0 5 (lldb) expr n 3 (lldb) p n (int) $1 3 (lldb) continue后面printf打印factorial(5)会变成6但这里的关键不是结果对不对而是你不需要改源码、重新编译现场就能验证“如果这里是3会怎样”的假设。真实调bug时这个能力能帮你快速二分定位极大减少“改代码-编译-跑一下”的笨循环。4.4 读调用栈递归函数的最直观打开方式停在factorial里后敲bt(lldb) bt看到的结果里最上面一行是当前停住的帧也就是最内层的factorial调用越往下越是外层最底层一般是你程序的入口。很多人第一次读bt会懵以为最上面是最早的调用恰恰相反栈顶是frame 0栈底才是入口。对factorial(5)来说你会看到一串factorial互相套着一直套到main。如果想把某个栈帧切过来看局部变量用frame select(lldb) frame select 2 (lldb) frame variableframe select之后当前语境就变成了第2帧frame variable看到的就是那一帧的变量。这个“切换帧”的能力在排查“这个参数是从哪一层传进来的”时特别有用。接着单步操作。n是next单步跳过不会进入任何函数s是step会进入函数finish是把当前函数跑完回到调用者。一个很自然的练习路径是breakpoint set --name mainrun然后一路n走完循环观察total的变化再在main的factorial(5)那一行敲s进入函数再n、再finish体会单步的三种粒度。这一轮走下来LLDB入门的主干操作你就全部碰过了。5. watchpoint是入门阶段最被低估的功能5.1 数据断点怎么设让调试器帮你“盯梢”断点是在“某一行代码执行到”的时候停住watchpoint则是在“某个变量被读写”的时候停住。想象你要查“这个变量的值怎么就被改成-1了”传统做法是在所有可能改写它的地方加断点太累了。watchpoint一句话搞定(lldb) watchpoint set variable n (lldb) continue程序继续跑一旦n的值发生改变LLDB立刻停住并且报告watchpoint命中会打印新旧值。这在排查“循环跑了几次后变量突然变成奇怪值”的问题时是神器。watchpoint set variable要求变量在当前帧上下文里能被解析如果解析不到就用更底层的写法watchpoint set expression n直接给内存地址。这里有个重要限制LLDB的watchpoint优先复用CPU的硬件调试寄存器数量非常有限x86平台上也就是寥寥几个设多了会直接失败。所以watchpoint不能当成断点那样可劲造一次观察一两个关键变量最现实。5.2 查“谁改了我的值”时最容易忽略的坑watchpoint是针对内存地址的这一点既让它强大也让它有坑。最典型的坑发生在局部变量上。局部变量在栈上如果这个变量所在的函数已经返回这块栈内存会被其他函数复用你设的watchpoint还在观察这个地址但它已经“名存实亡”要么误报要么干脆失效。所以对局部变量的watchpoint尽量限制在这段生命周期内真想抓“谁改了某个全局对象”用watchpoint set expression g_global是更靠谱的选择。观察完记得清理(lldb) watchpoint list (lldb) watchpoint delete 1我实际遇到过一种情况watchpoint命中后LLDB停在一条赋值指令上但表面上看那个值并没有变很可能是别的线程或异步回调改的。这时候配合thread list和thread backtrace all看看当前所有线程的调用栈往往能直接看到真凶。6. 让LLDB顺手起来别名、.lldbinit与几条实用配置6.1 用command alias把高频命令压缩成一个单词LLDB默认给了很多缩写但每个人的使用习惯不同你完全可以把最高频的操作定义成自己的短命令command alias bp breakpoint set --name command alias bk breakpoint set --file command alias ptv frame variable --show-types之后你就有了bp factorial和bk main.c:12这样的私有命令甚至ptv可以让你每次查看变量时直接带上类型信息。别担心换电脑的问题把这些配置写进配置文件之后同步一切就都带走了。这里要区分一个概念command alias是LLDB内部定义的调试器命令不是shell alias。它只在(lldb)提示符下生效不会污染你终端的其他命令。有些人刚接触时会在.bashrc里alias一个lldb命令那是另一回事——那个只是减少启动命令的长度真正进入调试器之后还得靠command alias。6.2 .lldbinit里值得长期沉淀的配置LLDB启动时会自动读取~/.lldbinit文件所以把个人偏好的配置沉淀在这里是最省事的做法。我的.lldbinit常年保持这样几项settings set auto-confirm true settings set use-color true command alias bp breakpoint set --name command alias ptv frame variable --show-typesauto-confirm true的作用是减少一些危险操作的二次确认比如delete breakpoint时需要确认的地方use-color true让输出有颜色读大段结构体时会轻松很多。你还可以改提示符让它提醒你目前在哪个环境settings set prompt (lldb) 想深入了解有哪些配置项settings list可以列出当前所有设置比文档管用。需要提醒的是写在.lldbinit里的配置对新启动的LLDB进程全局生效但如果某个配置写坏了可能会导致每次启动都报错改回来就行了没什么可怕的。6.3 入门之后的三个自然扩展方向这篇入门指南到这里你已经有能力完成“启动程序、下断点、查变量、看调用栈、观察变量变化”这一整套循环。后续我建议顺着三个方向继续深入第一符号与内存相关命令。比如image lookup -n main可以反过来查地址对应哪个符号disassemble -n factorial可以反汇编一个函数memory read可以按指定长度读内存。这些是排查崩溃、分析底层动作用得上的。第二多线程调试。真实项目里问题经常出在“哪个线程改了共享数据”thread list、thread backtrace all、thread select是下一批该掌握的命令。第三脚本化。LLDB内置了Python支持script命令可以直接进入Python解释器也可以把复杂动作写成Python函数挂到命令里。这是LLDB对比GDB的杀手锏但不是入门阶段该碰的东西。我个人实际使用LLDB这么多年最深刻的体会是一定要先在命令行环境里把命令本身练熟。因为Xcode的图形界面虽然好看但它把很多能力藏在了菜单里而命令行的每一个命令都是确定的、可组合的、可脚本化的。等你习惯了命令行再回头看Xcode的调试区会发现那些看似高级的UI操作其实只是LLDB某个命令的图形化外壳。