iOS/OSX 调试:跳舞吧!与LLDB共舞华尔兹

0

原文链接:http://www.objc.io/issue-19/lldb-debugging.html

// 速翻,无校对版

前言

你是否呕心沥血的尝试去理解代码和打印出来的变量内容?

NSLog(@"%@", whatIsInsideThisThing);

或是漏过函数调用来就简化工程行为?

NSNumber *n = @7; // theFunctionThatShouldReallyBeCalled();

或者短路的检查逻辑?

if (1 || theBooleanAtStake) { ... }

亦或者是函数的伪实现?

int calculateTheTrickyValue {
  return 9;

  /*
   Figure this out later.
   ...
}

那是不是要不断的重编译,然后又开始新的轮回?

构建软件是复杂的而且BUG无处不藏。一个正常的修正过程是修改代码,编译,再次运行,然后祈祷上帝。

似乎也不用墨守成规。你可以用调试器啊!假设你已经知道怎么检视变量值,这里有更多你需要掌握的东西。

这篇文章的目的是挑战你的调试知识,把你可能知道得基础知识点解析的更透彻,然后向你展示了一系列有趣的栗子。开始吧!

LLDB

LLDB是个开源调试器,REPL特性,自带C++以及Python插件。它与Xcode绑定并且驻在控制台界面化于窗口的下端。

调试器允许你在一个特定执行时刻暂停程序,检视变量值,执行自定义命令,以及按你认为合适得步骤进行程序步骤操控。(调试器主要功能戳这里

你以前使用调试器的部分很可能仅限于Xcode的UI上打个断点。但是这有些技巧,你可以做一些更酷比的事情。通过GDB与LLDB之间对比是针对所有支持的命令行的一个很好鸟瞰式的学习法,你还可能想要去安装Chisel,一套开源的LLDB插件让你的调试更加有趣。

与此同时,让我们开始如何使用调试器打印变量值的旅程吧。

基础

这里有一个简单短小的程序来打印字符串。注意到断点被添加到了第八行:
图片描述
程序到此会停下来然后打开控制台,让我们能与调试器进行交互。此时我们应该输入什么呢?

帮助

最简单得命令是键入help,你可以获取一个命令行列表。如果你忘记一个命令或者想知道该命令更细致的使用方法,那么你可以通过调用help <command>,比如help printhelp thread。如果你甚至忘记了命令本身,你可以尝试使用help help,但是如果你懂得足够多,你可能已经彻底不要这个命令了。

打印

打印值很容易,只要试着键入print命令:
图片描述
LLDB实际上支持前缀命令判断,所以你同样可以使用prin, pri或者p。但是你不能使用pr,因为LLDB不能分辨出你是否是想执行process命令。(吐槽幸好p没有歧义,暴露属性)

你同时也注意到了结果带一个$0。实际上你可以用这个来引用变量!试着键入$0 + 7然后你会看到106。任何带美元符号是LLDB的命名空间,其存在是为了为你提供帮助。

表达式

如果你想修改一个值?修改,你说的算?好吧,修改!下面来一个简单得表达式命令行:
图片描述
这并不修改调试器中的值。实际上修改的是程序中的值!如果你继续程序,它很神奇地会打印出42红气球(上下文)。

从现在开始注意一点,我们为了方便用pe代替printexpression

什么是打印命令?

这里有一个有意思的表达式来考虑下:p count = 18。如果我们执行命令然后打印count的内容,我们会看到它确实相当于执行了表达式count = 18

这两者的区别是print命令不带参数,这点与expression不同。考虑e -h +17。在选择是否要进行输入源为+17,带-h标志的操作,还是选择是否要进行计算区分17h操作,在这两个选择上面是不明确的。调试器认为连字符导致了混淆,你可能得不到想要的结果。

幸运的是,这个解决方法十分简单。使用--来表示表示符号的结束以及输入源的开始。此时如果你想要用-h标志,你可以使用e -h -- +17,如果你想要进行区分,则你可以执行e -- -h +17。不带标志则是十分普通,它(e --)有一个别名print

如果你键入help print并且往下拖拽,你会看到:

'print' is an abbreviation for 'expression --'.

打印对象

如果我们尝试键入

p objects

那输出会有点冗繁:

(NSString *) $7 = 0x0000000104da4040 @"red balloons"

当尝试打印一个更加复杂的数据结构时候会情况会更糟:

(lldb) p @[ @"foo", @"bar" ]

(NSArray *) $8 = 0x00007fdb9b71b3e0 @"2 objects"

好吧,我们想看下对象的description方法。我们需要告诉expression命令作为对象来打印这个结果,使用-O标志(这不是0):

(lldb) e -O -- $8
<__NSArrayI 0x7fdb9b71b3e0>(
foo,
bar
)

很走运,e -O --也有别名,其别名为po,我们可以只要这样使用:

(lldb) po $8
<__NSArrayI 0x7fdb9b71b3e0>(
foo,
bar
)
(lldb) po @"lunar"
lunar
(lldb) p @"lunar"
(NSString *) $13 = 0x00007fdb9d0003b0 @"lunar"

打印变量

print命令有许多种不同的格式可以由你来指定。它们以命令格式为print/<fmt>或者更简单p/<fmt>。接下来举个栗子。

默认的格式:

(lldb) p 16
16

16进制格式:

(lldb) p/x 16
0x10

二进制格式(t代表tow):

(lldb) p/t 16
0b00000000000000000000000000010000
(lldb) p/t (char)16
0b00010000

你还可以使用p/c打印字符,或者是p/s打印一个非终止类型的字符串char *。完整列表戳这里

变量

至此你可以打印对象跟简单得类型,并可以在调试器中使用expression命令更改它们的值,让我们使用一些变量来减少我们输入工作。你可以声明一个变量C来表示int a = 0,同样你可以在LLDB中做同样的事情。然后,变量必须以美元符号作为开头:

(lldb) e int $a = 2
(lldb) p $a * 19
38
(lldb) e NSArray *$array = @[ @"Saturday", @"Sunday", @"Monday" ]
(lldb) p [$array count]
2
(lldb) po [[$array objectAtIndex:0] uppercaseString]
SATURDAY
(lldb) p [[$array objectAtIndex:$a] characterAtIndex:0]
error: no known method '-characterAtIndex:'; cast the message send to the method's return type
error: 1 errors parsing expression

噢。LLDB不能识别出所牵扯的变量类型。不时会遇到,我们可以给一点提示:

(lldb) p (char)[[$array objectAtIndex:$a] characterAtIndex:0]
'M'
(lldb) p/d (char)[[$array objectAtIndex:$a] characterAtIndex:0]
77

变量特性让调试器更容易被使用,你这么认为吗?

流程控制

你的程序会在你打上断点的位置停下来。

此时你看到在调试工具栏有四个按钮,通过使用它们你可以控制程序的执行流程:
图片描述

这四个按钮从左到右依次为:继续,单步,跳入,跳出。

首先,继续按钮将会让你得程序继续正常执行(可能一直运行或者遇到下一个断点)。在LLDB中,你可以使用process continue来继续执行,别名为c

其次,单步执行将会将单行代码当做黑盒一样执行。如果那行你调用了函数,那将不会进入这个函数,而是直接执行这个函数后继续运行。LLDB中相对应的命令是thread step-overnext,或者 n

如果你想进入一个函数调用来检查调试该函数的执行,你可以使用第三个按钮,跳入,LLDB同样提供了thread step-instep, 和s。注意到nextstep在当前行代码不涉及函数调用的时候效果是一样的。

大部分知道c,n,s。但是还有第四个按钮,跳出。如果你不小心跳入了一个函数而你本意是想跳过它,一般反应是不断的按n知道函数返回。跳出帮你节省时间。它会执行到return语句(知道执行了出栈操作),然后会停下来。

举个栗子

来看下如下的代码片段:
图片描述

代码停在断点,然后我们执行如下的命令行:

p i
n
s
p i
finish
p i
frame info

这里,frame info将会告诉你当前行以及源文件是啥,可以通过键入help framehelp thread,以及help process获取更多信息。那么输出什么呢?先思考之前的描述想下答案!

(lldb) p i
(int) $0 = 99
(lldb) n
2014-11-22 10:49:26.445 DebuggerDance[60182:4832768] 101 is odd!
(lldb) s
(lldb) p i
(int) $2 = 110
(lldb) finish
2014-11-22 10:49:35.978 DebuggerDance[60182:4832768] 110 is even!
(lldb) p i
(int) $4 = 99
(lldb) frame info
frame #0: 0x000000010a53bcd4 DebuggerDance`main + 68 at main.m:17

仍在17行的原因是finish命令会让程序运行直到isEven()函数返回,然后马上停止。但是请注意,17行已经执行完了。

线程返回

还有一个特别帮的功能是你在调试的时候可以用thread return来控制程序流程。它使用可选参数,将这个参数载入寄存器,单后马上执行返回命令,然后函数出栈。这意味着剩下函数没有被执行。这样因为ARC的引用计数/记录出现问题,或者遗漏一些清除操作。但在一个函数的开头执行这个命令是一个非常棒得函数打桩并且反悔了一个伪结果。

让我们来对上述相同的代码段跑如下的指令:

p i
s
thread return NO
n
p even0
frame info

在看答案之前乡下结果,答案如下:

(lldb) p i
(int) $0 = 99
(lldb) s
(lldb) thread return NO
(lldb) n
(lldb) p even0
(BOOL) $2 = NO
(lldb) frame info
frame #0: 0x00000001009a5cc4 DebuggerDance`main + 52 at main.m:17

断点

我们一直都使用断点来让程序停止,检视当前状态从而捕获BUG。但是如果我们转变对断点的理解,我们可以获得更多可能。

A breakpoint allows you to instruct a program when to stop, and then allows the running of commands.

考虑在函数刚开始处打一个断点,使用thread return来重写函数行为,然后继续。现在想象下自动实现这种处理。是不是听起来很牛X,不是么?

断点管理

Xcode提供了一套工具来创建和操作断点。我们将会逐一过一遍并且进行描述与之对应的LLDB命令行。

在Xcode的左面板上,有一堆按钮集合。有一个长得很像断点。点击打开断点导航栏,进去之后你一眼看到你所操作的所有断点:
图片描述

这里你可以看到所有的断点 - 对应LLDB中的breakpoint list或者是br li。你可以点击单个断点进行打开或者关闭 - 对应LLDB中的breakpoint enable <breakpointID>breakpoint disable <breakpointID>

(lldb) br li
Current breakpoints:
1: file = '/Users/arig/Desktop/DebuggerDance/DebuggerDance/main.m', line = 16, locations = 1, resolved = 1, hit count = 1

  1.1: where = DebuggerDance`main + 27 at main.m:16, address = 0x000000010a3f6cab, resolved, hit count = 1

(lldb) br dis 1
1 breakpoints disabled.
(lldb) br li
Current breakpoints:
1: file = '/Users/arig/Desktop/DebuggerDance/DebuggerDance/main.m', line = 16, locations = 1 Options: disabled

  1.1: where = DebuggerDance`main + 27 at main.m:16, address = 0x000000010a3f6cab, unresolved, hit count = 1

(lldb) br del 1
1 breakpoints deleted; 0 breakpoint locations disabled.
(lldb) br li
No breakpoints currently set.

创建断点

(UI创建略了。。。是人都会吧。。)

在调试器中打断点,使用breakpoint set命令:

(lldb) breakpoint set -f main.m -l 16
Breakpoint 1: where = DebuggerDance`main + 27 at main.m:16, address = 0x

缩写可以用brb是另外一个完全不同的命令,是_regexp-break的别名,但是它足够健壮来进行创建上述命令一样效果的断点:

(lldb) b main.m:17
Breakpoint 2: where = DebuggerDance`main + 52 at main.m:17, address = 0x

你也可以防止一个断点在一个符号(C语言函数),而不用指定行数:

(lldb) b isEven
Breakpoint 3: where = DebuggerDance`isEven + 16 at main.m:4, address = 0x000000010a3f6d00
(lldb) br s -F isEven
Breakpoint 4: where = DebuggerDance`isEven + 16 at main.m:4, address

现在这些断点会停止正在将要执行的函数,同样适用与OC方法:

(lldb) breakpoint set -F "-[NSArray objectAtIndex:]"
Breakpoint 5: where = CoreFoundation`-[NSArray objectAtIndex:], address = 0x000000010ac7a950
(lldb) b -[NSArray objectAtIndex:]
Breakpoint 6: where = CoreFoundation`-[NSArray objectAtIndex:], address = 0x000000010ac7a950
(lldb) breakpoint set -F "+[NSSet setWithObject:]"
Breakpoint 7: where = CoreFoundation`+[NSSet setWithObject:], address = 0x000000010abd3820
(lldb) b +[NSSet setWithObject:]
Breakpoint 8: where = CoreFoundation`+[NSSet setWithObject:], address = 0x000000010abd3820

如果你想通过UI来创建象征性断点,你可以点击左下端断点导航栏的+号:
图片描述

然后选择第三个选项:
图片描述
此时出现弹出框让你输入比如-[NSArray objectAtIndex:]的符号,然后程序在这个函数调用的时候便可以停止下来,不管是你的代码或者还是大苹果的代码!

如果我们看下其他选项,我们可以发现一些有意思的选项,同样提供了各种条件触发的锻炼只要你点击了Xcode的UI并且选择了“Edit Breakpoint”选项:
图片描述

如上图,断点只有在i为99的时候才会停止程序。你可以同样设置“ ignore”选项来告诉断点在前n次调用的时候不用停止程序(条件为真)。

这里还有一个“Add Action”按钮。。。

断点动作

可能上面断点的栗子中,你想知道每次断点时候i值是多少。我们可以使用动作p i,然后当断点触发的时候我们进入调试器,它会预先执行这个命令在将控制流程交给你之前:
图片描述
你也可以加多重动作,可以是调试器指令,shell指令或者更健壮的打印信息:
图片描述

如上你可以看到打印出i值,还有强调语句,打印出自定义的表达式。

下面是上述功能用纯LLDB命令代替Xcode的UI:

(lldb) breakpoint set -F isEven
Breakpoint 1: where = DebuggerDance`isEven + 16 at main.m:4, address = 0x00000001083b5d00
(lldb) breakpoint modify -c 'i == 99' 1
(lldb) breakpoint command add 1
Enter your debugger command(s).  Type 'DONE' to end.
> p i
> DONE
(lldb) br li 1
1: name = 'isEven', locations = 1, resolved = 1, hit count = 0
    Breakpoint commands:
      p i

Condition: i == 99

  1.1: where = DebuggerDance`isEven + 16 at main.m:4, address = 0x00000001083b5d00, resolved, hit count = 0

自动化,我们来了!

计算值之后继续

如果视线停留在断点弹出框的底端,你会额外看到一个选项:“Automatically continue after evaluation actions(计算动作后自动执行)。”它只是一个勾选框,但是它却有强大的能力。如果你勾选上了,调试器将会苹果你所有的命令然后继续执行程序。表面上看上跟断点没有打住一样(除非你断点太多了,拖慢了程序进度)。

这个勾选框功能与最后一个动作断点继续执行效果一样,但是有勾选框更加容易点。对应调试器的指令如下:

(lldb) breakpoint set -F isEven
Breakpoint 1: where = DebuggerDance`isEven + 16 at main.m:4, address = 0x00000001083b5d00
(lldb) breakpoint command add 1
Enter your debugger command(s).  Type 'DONE' to end.
> continue
> DONE
(lldb) br li 1
1: name = 'isEven', locations = 1, resolved = 1, hit count = 0
    Breakpoint commands:
      continue

  1.1: where = DebuggerDance`isEven + 16 at main.m:4, address = 0x00000001083b5d00, resolved, hit count = 0

计算后自动继续运行让你可以单独通过使用断点来修改你的程序!你可以停止在单行,运行一个expression命令来改变变量,然后继续。

举个栗子

考虑下简陋残酷的“打印式调试”技术。不是用:

NSLog(@"%@", whatIsInsideThisThing);

而是用断点处设置打印变量值替代吊打印日志打印语句然后继续。

不是用:

int calculateTheTrickyValue {
  return 9;

  /*
   Figure this out later.
   ...
  */
}

而是用断点处调用thread return 9然后继续执行。

带动作的象征断点确实真的很强大。你也可以添加这些断点到你朋友的Xcode工程并且让动作将所有信息细致展示出来。接下来看看要耗时多久来进行计算以及会发生什么吧。

调试器完整操作

在起舞之前还有一点需要我们注意。你真的可以在调试器中执行任何的C/OC/C++/Swift命令。比较弱的是我们不能创建一个新的函数。。。这意味着没有新的类,块,函数,带虚方法的C++类等等。除了这个,调试器什么都能满足!

我们可以分配一些字节:

(lldb) e char *$str = (char *)malloc(8)
(lldb) e (void)strcpy($str, "munkeys")
(lldb) e $str[1] = 'o'
(char) $0 = 'o'
(lldb) p $str
(char *) $str = 0x00007fd04a900040 "monkeys"

或者我们可以检查一些内存(使用x命令)来看我们新数组的4个字节:

(lldb) x/4c $str
0x7fd04a900040: monk

我们还可以后三个字节:

(lldb) x/1w `$str + 3`
0x7fd04a900043: keys

当你所要的活结束的时候别忘记了释放内存避免造成内存泄露:

(lldb) e (void)free($str)

跳舞吧,骚年!

现在我们已经清楚基础步骤,是时候来整一些比较疯狂的东西了。我过去曾写过一篇博客(大家自己收藏。。。)发表在looking at the internals of NSArray。当时用了大量的NSLog语句,后来全用调试器搞定了。它是一个很好的调试器使用练习。

畅通无阻(无断点模式)

当你的应用在跑的时候,Xcode中的调试工具栏展示一个停止按钮而非继续状态的按钮:
图片描述

选中这个按钮的时候,应用遇到断点将会停止(就像输入了process interrupt)。这时候将会让你进入调试器。

这里有一个有趣的地方。如果你运行一个iOS应用,你可以尝试这个(全局变量可提供)

(lldb) po [[[UIApplication sharedApplication] keyWindow] recursiveDescription]
<UIWindow: 0x7f82b1fa8140; frame = (0 0; 320 568); gestureRecognizers = <NSArray: 0x7f82b1fa92d0>; layer = <UIWindowLayer: 0x7f82b1fa8400>>
   | <UIView: 0x7f82b1d01fd0; frame = (0 0; 320 568); autoresize = W+H; layer = <CALayer: 0x7f82b1e2e0a0>>

可以看到整个层级!Chisel(上文提及)用pviews来实现。

更新UI

然后,通过上述的输出,我们可以看到隐藏的视图:

(lldb) e id $myView = (id)0x7f82b1d01fd0

然后在调试器中修改它的背景色:

(lldb) e (void)[$myView setBackgroundColor:[UIColor blueColor]]

在你下次继续运行这个程序的时候你才会看到变化。这因为这个变化需要传递给渲染服务然后视图展示才会被更新。

渲染服务实际上是另一个进程(称作后台),并且甚至我们调试进程被停止了,这个后台也不会被停止!

这意味着不通过继续,你可以执行:

(lldb) e (void)[CATransaction flush]

在模拟器中或者设备中的UI会进行刷新而你还在调试器中!Chisel提供了一个别名函数叫做caflush,并且它被用来实现其它捷径像hide <view>show <view>还有其他许多许多。所有的Chisel命令都有对应的文档,所以就在安装它之后键入help来随心所欲的获取更多的信息吧。

压入视图控制器

想象一个简单的应用有一个UINavigationController作为根视图控制器。你可以在调试器中相当简易的执行如下操作:

(lldb) e id $nvc = [[[UIApplication sharedApplication] keyWindow] rootViewController]

然后压入子视图控制器:

(lldb) e id $vc = [UIViewController new]
(lldb) e (void)[[$vc view] setBackgroundColor:[UIColor yellowColor]]
(lldb) e (void)[$vc setTitle:@"Yay!"]
(lldb) e (void)[$nvc pushViewContoller:$vc animated:YES]

最后执行:

(lldb) caflush // e (void)[CATransaction flush]

你会看到马上压入了一个视图控制器。

找到按钮的目标

想象下你调试器中有一个变量,$myButton,你想要去创建它,并从UI中抓取它,或者简单地只是你想在断点停下来的时候将它作为个局部变量。你可能想知道当你点击它的时候是谁接收了这个动作。这里展示达到这点有多么的简单:

(lldb) po [$myButton allTargets]
{(
    <MagicEventListener: 0x7fb58bd2e240>
)}
(lldb) po [$myButton actionsForTarget:(id)0x7fb58bd2e240 forControlEvent:0]
<__NSArrayM 0x7fb58bd2aa40>(
_handleTap:
)

现在你可能想在事件发生的时候添加一个断点。只要在LLDB或者Xcode设置象征性断点在-[MyEventListener _handleTap:]。and you are all set to go!

观察实例变量值变化

想象一个假设的场景你有一个UIView且它的_layer实例变量被重写了。因为这里可能不涉及方法,我们不能使用象征性断点。取而代之的是我们想观察一个内存地址什么时候被写入了。

首先我们需要找到_layer对象在那里:

(lldb) p (ptrdiff_t)ivar_getOffset((struct Ivar *)class_getInstanceVariable([MyView class], "_layer"))
(ptrdiff_t) $0 = 8

现在我们知道($myView + 8)这个内存地址被写入了:

(lldb) watchpoint set expression -- (int *)$myView + 8
Watchpoint created: Watchpoint 3: addr = 0x7fa554231340 size = 8 state = enabled type = w
    new value: 0x0000000000000000

对应Chisel里面的wivar $myView _layer

在非重写方法上的象征性断点

想象你想知道什么时候-[MyViewController viewDidAppear:]被调用了。如果MyViewController实际上没有实现这个方法,但是父类实现了呢?我们可以设置一个断点来看看具体情况:

(lldb) b -[MyViewController viewDidAppear:]
Breakpoint 1: no locations (pending).
WARNING:  Unable to resolve breakpoint to any actual locations.

因为LLDB根据符号搜索,它找不到该方法,所以你的断点将不会被触发。你所需要做的是设置一个条件,[self isKindofClass:[MyViewController class]],然后见这个断点设在UIViewController上。一般来说,设置一个这样的条件是有效的,但是,这里无效是因为我们没有父类该方法的实现。

viewDidAppear:是大苹果写的,所以没有对应的符号;在方法内部也没有self。如果你想要使用在象征性断点内使用self,你需要知道它在那里(可能在寄存器也可能在栈上;在x86你可能在$esp+4找到它)。这是个通过的历程,因为你知道已经知道有四种体系架构了。吐槽略。。幸运的是,Chisel已经完成了这些封装,你可以调用bmessage

(lldb) bmessage -[MyViewController viewDidAppear:]
Setting a breakpoint at -[UIViewController viewDidAppear:] with condition (void*)object_getClass((id)$rdi) == 0x000000010e2f4d28
Breakpoint 1: where = UIKit`-[UIViewController viewDidAppear:], address = 0x000000010e11533c

LLDB与Python

LLDB有完整的内置Python支持。如果你在LLDB上输入脚本,它会打开一个Python REPL。如果你在LLDB中键入script,它会打开一个Python REPL。你可以传入一行Python语句到script命令来不进入REPL的情况下进行执行脚本:

(lldb) script import os
(lldb) script os.system("open http://www.objc.io/")

这允许你创建各种各样的酷比命令。将这个丢入文件,~/myCommands.py:

def caflushCommand(debugger, command, result, internal_dict):
  debugger.HandleCommand("e (void)[CATransaction flush]")

然后在LLDB中运行如下:

command script import ~/myCommands.py

或者,将这行代码放置于/.lldbinit让LLDB每次运行的时候都执行一次。Chisel不过就是一堆Python脚本用来组合字符串,然后告诉LLDB来执行这些字符串。听起来很简单吧!呃?

驰骋调试器

略。。。
“乐观”调试!

你可能感兴趣的

he15his · 2014年12月09日

+神v5

回复

Cruise_Chan 作者 · 2014年12月09日

v6 O(∩_∩)O哈哈~

回复

载入中...