1. 为什么还在用NSLog做iOS调试
刚入行那会儿,我调试iOS代码的方式极其原始——在可疑的地方插满print和NSLog,跑一遍,看控制台输出,猜哪里出了问题,然后再插一轮。一个稍微复杂点的崩溃,来回折腾十几遍是常事。后来有一次排查一个多线程环境下的野指针崩溃,NSLog完全帮不上忙,因为崩溃发生时根本来不及打印任何东西。当时团队里一位老手看了一眼,直接在控制台敲了几行命令,几秒钟就定位到了问题对象的内存地址和调用栈。那一刻我才意识到,Xcode底部的那个控制台,远不止是看日志用的。
LLDB(Low Level Debugger)是Xcode默认集成的调试器,它不只是一个"看日志"的工具,而是一个完整的交互式调试环境。你可以用它暂停程序、检查变量、修改变量值、调用方法、设置条件断点、分析汇编指令,甚至在不重新编译的情况下动态修改行为。很多开发者对LLDB的认知停留在"Xcode断点触发后自动弹出的那个东西",但实际上,主动使用LLDB命令能让你在调试效率上拉开巨大差距。
这篇文章适合所有iOS开发者——无论你是刚接触调试的新人,还是已经用了几年Xcode但从未主动敲过LLDB命令的老手。我会从最基础的命令讲起,逐步深入到多线程调试、内存问题排查、运行时动态修改等进阶场景,把每个命令背后的逻辑和实际使用中的坑都讲清楚。所有内容都基于我在实际项目中的使用经验,不是对着文档抄一遍。
2. LLDB命令的基本语法与表达式求值
2.1 命令结构:动词加参数加选项
LLDB的命令遵循一套相对统一的语法结构:命令 子命令 参数 -选项。比如breakpoint set -n viewDidLoad,breakpoint是命令,set是子命令,-n是选项(name的缩写),viewDidLoad是参数值。理解这个结构之后,你就能举一反三,不用死记硬背每个命令的完整写法。
LLDB支持命令缩写,这是提高效率的关键。比如breakpoint可以缩写成br,expression可以缩写成expr或e,thread可以缩写成th。但缩写有个坑:不同版本的Xcode对缩写的支持可能略有差异,而且缩写冲突时LLDB会报错。我的建议是,常用的命令用缩写(比如po、p、expr),不常用的命令老老实实写全称,避免记混。
还有一个非常实用的技巧:在LLDB提示符下直接敲help,会列出所有命令分类。敲help breakpoint会列出breakpoint下的所有子命令和选项。这比去网上搜教程快得多,而且信息是最准确的。
2.2 expression命令:不只是打印变量
expression(缩写expr或e)是LLDB中使用频率最高的命令,它的核心能力是在当前断点暂停的上下文中执行任意Objective-C或Swift表达式。很多人只知道po(print object)和p(print),但expr的能力远不止于此。
po的本质是expression -O --,其中-O表示object description,它会调用对象的description方法(Swift中是debugDescription)。p的本质是expression --,它会用LLDB的格式化器输出变量的值。两者的区别在于:po适合看对象的可读描述,p适合看基本类型和结构体的原始值。
但expr真正强大的地方在于它可以执行赋值操作。比如你在调试一个UI布局问题,想临时改一下某个视图的背景色看看效果:
expr someView.backgroundColor = UIColor.red执行完之后,你需要手动触发一次界面刷新(比如expr [CATransaction flush]),就能在不重新编译的情况下看到效果。这个技巧在调UI细节时极其好用,省去了改代码、编译、运行的时间。
还有一个常见场景:你想在断点处调用某个方法看看返回值,但这个方法可能有副作用。这时候可以用expr -i false --来禁止LLDB在表达式求值时中断(默认情况下,如果表达式触发了断点,LLDB会停下来)。这个选项在调试复杂表达式时能避免很多意外中断。
注意:
expr执行的表达式是在当前线程的上下文中运行的。如果你在子线程断点处执行了一个必须在主线程运行的UI操作,可能会遇到问题。这时候需要先切换到主线程再执行。
2.3 变量声明与临时对象创建
在LLDB中,你可以用expr创建临时变量,这在需要多步操作时非常有用。比如:
expr NSArray *tempArray = @[@1, @2, @3] expr NSLog(@"%@", tempArray)第一行创建了一个临时数组,第二行打印它。这些临时变量只在当前调试会话中有效,不会影响你的源代码。但要注意,LLDB中的变量声明有一些限制:不能声明与当前作用域中已有变量同名的变量,否则会报错。如果遇到这种情况,换个名字就行。
Swift项目中使用expr创建临时变量稍微麻烦一些,因为Swift的类型推断在LLDB中有时不太可靠。建议显式声明类型:
expr let tempStr: String = "hello"另外,Swift的expr默认是在Objective-C上下文中求值的,如果要执行纯Swift代码,需要用expr -l swift --来指定语言。这个细节很多Swift开发者都不知道,导致在LLDB中执行Swift表达式时经常遇到莫名其妙的错误。
3. 断点管理:从基础到条件断点
3.1 用命令设置断点比点鼠标更快
Xcode的图形界面设置断点很方便,但LLDB命令设置断点有几个图形界面做不到的优势:可以设置条件、可以批量操作、可以在调试过程中动态添加。
最基础的断点设置命令是breakpoint set -n 方法名。比如breakpoint set -n viewDidLoad会在所有名为viewDidLoad的方法上设置断点。注意,这会在所有类的viewDidLoad上都设断点,如果你只想在特定类上设断点,需要加上文件或类名限定:
breakpoint set -n "-[MyViewController viewDidLoad]"对于Swift方法,格式略有不同:
breakpoint set -n "MyViewController.viewDidLoad() -> ()"Swift的方法名在LLDB中会经过名称修饰(name mangling),直接写方法名经常匹配不到。这时候可以用breakpoint set -r(正则匹配)来模糊查找:
breakpoint set -r "viewDidLoad"-r选项会匹配所有包含该字符串的符号,虽然可能匹配到多个结果,但至少不会漏掉。设置完之后用breakpoint list查看所有断点,确认是否设对了位置。
3.2 条件断点与忽略计数
条件断点是LLDB最实用的功能之一。想象一下,你在一个循环里调试,循环执行1000次,你只关心第500次时某个变量的值。用图形界面设置条件断点也可以,但LLDB命令更灵活:
breakpoint set -n "processData:" -c "index == 500"-c选项指定条件表达式,只有当条件为真时断点才会触发。条件表达式可以是任意合法的Objective-C或Swift表达式,比如self.dataArray.count > 10、error != nil等。
另一个实用选项是-i(ignore count),指定忽略前N次触发:
breakpoint set -n "processData:" -i 499这表示前499次触发都直接跳过,第500次才停下来。-i和-c可以组合使用,比如忽略前100次,之后只在满足条件时停下来。
这里有个实际经验:条件断点的表达式求值是有性能开销的。如果断点在一个高频调用的方法上(比如tableView:cellForRowAtIndexPath:),条件表达式又比较复杂,程序可能会明显变慢。我遇到过在滚动列表时因为条件断点导致帧率骤降的情况。解决办法是尽量让条件表达式简单,或者用-i先跳过大部分触发。
3.3 断点命令:触发时自动执行操作
断点命令(breakpoint command)是很多人忽略的功能。你可以让断点在触发时自动执行一系列LLDB命令,然后继续运行,完全不打断你的操作。这在需要持续监控某个值时特别有用。
比如你想监控某个方法的参数变化,但不想每次都手动敲po:
breakpoint set -n "updateProgress:" breakpoint command add 1 > po $arg1 > continue > DONEbreakpoint command add 1表示给编号为1的断点添加命令。$arg1是第一个参数(Objective-C方法的前两个参数是self和_cmd,所以实际参数从$arg3开始)。continue表示执行完命令后自动继续运行。DONE表示命令输入结束。
这样设置之后,每次updateProgress:被调用,LLDB都会自动打印参数值然后继续运行,你可以在控制台看到完整的调用记录,完全不影响程序执行。这个技巧在排查偶现问题时特别好用——你可以让程序跑着,同时记录关键方法的调用情况,等复现之后回头分析日志。
4. 线程与调用栈:多线程崩溃的排查利器
4.1 查看和切换线程
多线程崩溃是iOS开发中最头疼的问题之一。崩溃日志往往只显示"EXC_BAD_ACCESS",没有任何有用信息。这时候LLDB的线程命令就能派上大用场。
当程序在断点处暂停时,thread list会列出所有线程的概要信息,包括线程编号、当前函数、以及是否是崩溃线程。thread backtrace(缩写bt)会打印当前线程的完整调用栈。thread backtrace all会打印所有线程的调用栈——这个命令在排查死锁时特别有用,你可以看到每个线程都卡在哪个函数上。
切换线程用thread select 编号。比如thread select 3切换到3号线程。切换之后,所有的expr、bt等命令都会在那个线程的上下文中执行。
这里有个实际经验:崩溃发生时,LLDB默认会停在崩溃线程上。但有时候崩溃的真正原因在另一个线程——比如一个线程在释放对象,另一个线程还在访问它。这时候你需要用thread backtrace all查看所有线程的状态,找到那个"不该在那里"的线程。我排查过一个崩溃,主线程在正常处理UI事件,但后台线程的调用栈显示它正在访问一个已经被释放的对象。两个线程的调用栈一对比,问题就很明显了。
4.2 栈帧导航与变量查看
frame命令用于操作当前线程的栈帧。frame info显示当前栈帧的信息,包括函数名、文件路径、行号。frame variable(缩写fr v)显示当前栈帧中所有局部变量和参数的值——这个命令比逐个po高效得多,特别是在函数参数很多的时候。
frame select 编号可以切换到调用栈中的其他帧。比如你在viewDidLoad中断下来,想看是谁调用了它,可以用frame select 1切换到上一层调用帧,然后frame variable查看那一层的变量。up和down命令也可以用来在栈帧之间上下移动。
frame variable有一个很实用的选项:-o(object description),它会像po一样输出对象的描述信息:
frame variable -o self这比先frame select再po self快得多。另外,frame variable支持通配符过滤,比如frame variable -o self.data*只显示self中以data开头的属性。
4.3 用LLDB分析崩溃现场
当程序因为信号(如SIGABRT、SIGSEGV)崩溃时,LLDB会停在崩溃点。这时候第一件事是bt查看调用栈,找到崩溃发生的具体位置。但仅仅看调用栈往往不够,你需要结合register read查看寄存器状态。
register read显示所有寄存器的当前值。在ARM64架构下,x0到x7通常用于传递函数参数,x0也用于存储返回值。如果崩溃发生在objc_msgSend中,x0通常是被发送消息的对象地址。你可以用po加上这个地址来查看对象信息:
register read x0 po 0x0000000283a4c800如果对象已经被释放,po会报错或者输出乱码,这本身就说明问题——你向一个已释放的对象发送了消息。
还有一个技巧:memory read命令可以读取指定内存地址的内容。当你不确定某个地址是否有效时,可以用memory read 地址看看那里有什么。如果读取失败,说明地址无效;如果读到了看起来像对象isa指针的数据,说明那里可能是一个有效的对象。
注意:
register read和memory read属于比较底层的操作,需要对ARM64架构和Objective-C运行时有一定了解。新手可以先从bt和frame variable开始,熟练之后再深入这些底层命令。
5. 运行时动态修改与调试技巧
5.1 不重新编译修改代码行为
LLDB最强大的能力之一是运行时动态修改。你可以在不停止程序、不重新编译的情况下,改变方法的实现、修改属性的值、甚至替换整个类的实现。
修改属性值是最简单的:
expr self.title = "新标题" expr [self.tableView reloadData]这两行命令把标题改了并刷新了表格,整个过程程序一直在运行。这在调UI时特别方便——你可以快速尝试不同的值,找到最合适的那个,然后再去改源代码。
更高级的用法是方法替换(method swizzling)。你可以在LLDB中动态交换两个方法的实现:
expr [MyClass exchangeImplementations:@selector(methodA) with:@selector(methodB)]这需要你的类实现了相应的交换方法。如果没有,也可以用运行时函数:
expr method_exchangeImplementations(class_getInstanceMethod([MyClass class], @selector(methodA)), class_getInstanceMethod([MyClass class], @selector(methodB)))这个技巧在调试第三方库的行为时特别有用——你可以临时替换某个方法的实现,观察程序行为的变化,从而理解这个方法的实际作用。
5.2 用LLDB脚本自动化重复操作
如果你发现自己反复在LLDB中敲同样的命令序列,可以考虑写一个LLDB脚本。LLDB支持Python脚本,你可以把常用操作封装成命令。
比如,你经常需要打印某个视图的层级结构,可以写一个Python脚本:
import lldb def print_view_hierarchy(debugger, command, result, internal_dict): target = debugger.GetSelectedTarget() process = target.GetProcess() thread = process.GetSelectedThread() frame = thread.GetSelectedFrame() view = frame.EvaluateExpression(command) # 递归打印子视图... print("View hierarchy printed") def __lldb_init_module(debugger, internal_dict): debugger.HandleCommand('command script add -f view_hierarchy.print_view_hierarchy pvh')把这个脚本保存为view_hierarchy.py,然后在LLDB中用command script import /path/to/view_hierarchy.py导入,之后就可以用pvh someView来打印视图层级了。
这个功能的学习曲线比较陡,但一旦掌握,调试效率会有质的提升。我的建议是先从简单的脚本开始,比如封装一个"打印当前视图控制器所有子视图"的命令,用熟了再逐步扩展。
5.3 调试中的常见坑与应对
LLDB使用中有几个坑几乎每个人都会踩。第一个是po在Swift中的行为差异。Swift的po有时会输出大量冗余信息,或者因为类型推断问题而失败。这时候可以试试expr -l swift -- print(someVariable),用Swift原生的print函数来输出。
第二个坑是expr执行后的状态残留。你在LLDB中创建的临时变量、修改的属性值,在继续运行后仍然有效。这有时候是好事(比如你故意改了某个值来测试),但有时候会导致意外的行为。如果发现程序在继续运行后行为异常,先想想是不是之前在LLDB中改了什么。
第三个坑是断点命令中的continue可能导致死循环。如果你在一个高频调用的方法上设置了带continue的断点命令,而命令本身又触发了这个方法(比如命令中调用了会再次触发断点的方法),就会陷入无限循环。遇到这种情况,赶紧在断点命令中加上条件判断,或者用breakpoint disable临时禁用断点。
第四个坑是LLDB中的表达式求值可能改变程序状态。比如你po一个懒加载属性,可能会触发它的初始化,从而改变程序行为。这在调试初始化逻辑时要特别小心——你以为只是"看一眼",实际上已经改变了执行流程。
6. 把LLDB变成日常调试习惯
说了这么多命令和技巧,最后聊聊怎么把这些东西真正用起来。我的经验是,不要试图一次记住所有命令。挑三五个最常用的——po、expr、bt、frame variable、breakpoint set -n——先在日常调试中刻意使用,等形成肌肉记忆之后再逐步扩展。
我自己的习惯是,每次遇到崩溃,第一反应不再是加日志,而是先bt看调用栈,然后frame variable看局部变量,再用expr验证我的猜测。这套流程走下来,大部分问题在几分钟内就能定位。只有在需要持续监控某个值的变化时,我才会用断点命令来自动打印。
另外,Xcode的调试控制台支持命令历史(上下箭头翻看),也支持Tab补全。多用这两个功能,能省不少打字时间。还有一个隐藏技巧:在控制台输入command alias可以给常用命令设置别名。比如command alias btl thread backtrace all,之后敲btl就能打印所有线程的调用栈。
LLDB的学习曲线确实存在,但投入产出比极高。你花一个下午熟悉基本命令,接下来几年都会受益。而且这些知识不依赖于特定的Xcode版本或iOS版本,是真正可以长期积累的底层技能。