环境集成程序员iOS Developer

iOS开发调试 - LLDB使用概览

2017-07-24  本文已影响1101人  Noskthing

前言


LLDB是个开源的内置于XCode的具有REPL(read-eval-print-loop)特征的Debugger,其可以安装C++或者Python插件。在日常的开发和调试过程中给开发人员带来了非常多的帮助。

(lldb)po std.name
Noskthing

了解并熟练掌握LLDB的使用是非常有必要的。这篇文章将会为大家总结日常高频使用的一些技巧。文章分节的主要依据是功能的相关性,并且省略了很多Xcode已经集成并且可视化的操作。

一些基础


LLDB的基本语法如下

<command> [<subcommand> [<subcommand>...]] <action> [-options [option-value]] [argument [argument...]]

其中内置了非常多的功能,选择去硬背每一条指令并不是一个明智的选择。我们只需要记住一些常用的指令,在需要的时候通过help命令来查看相关的描述即可。

(lldb)help
Debugger commands:

  apropos           -- List debugger commands related to a word or subject.
  breakpoint        -- Commands for operating on breakpoints (see 'help b' for shorthand.)
  ...

还可以通过apropos来获取具体命令的合法参数信息以及含义

(lldb) apropos breakpoint
The following commands may relate to 'breakpoint':
  _regexp-break                         -- Set a breakpoint using one of
                                           several shorthand formats.
  _regexp-tbreak                        -- Set a one-shot breakpoint using one
                                           of several shorthand formats.
  ...

断点相关


Xcode本身已经将大部分的操作用UI展示了出来,比如说

日常开发中大部分有关断点的操作我们都可以不使用命令行直接通过Xcode的可视化操作来实现,命令行的操作似乎是一种多余。但是使用(lldb)help breakpoint查看一下LLDB提供的所有帮助,你会发现在命令行中使用LLDB能够给予我们更多更详细的调试信息以及更广阔的操作空间。

(lldb)help breakpoint

举一个简单的例子,我们需要为某一个函数设置一个断点。比如说给ViewController的VviewDidLoad方法设置一个断点。这对于Xcode而言非常的简单。

添加断点

编辑每一个断点的各个选项也因为可视化的操作而变得非常的简单。但是如果我们需要在系统调用的某个函数里设置断点呢,抑或某个函数我们只能在crash log茫茫碌的堆栈信息里才能看到一点它的痕迹,这个时候如何操作呢?

crash log

假设我们现在需要给objc_msgSend函数设置断点。首先先想办法获取objc_msgSend的地址。我们在Appdelegate.m文件给函数- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions打一个断点,运行程序如下图所示。


我们可以通过(lldb)br set -a 0x0000000103c04ac0来为objc_msgSend()设置一个断点。输入continue继续执行你会发现如果程序再次调用objc_msgSend()会暂停。
断点设置

Tips
* 图片是用模拟器运行所以是x86,移动设备是arm。两者的指令有所不同。图中的callq指令对应arm中的bl
* 由于 ASLR(地址空间配置随机载入) 的原因地址是不固定的,所以图中objc_msgSend()的地址在你的机器上是不可用的。

断点相关的指令很多很杂,这里为大家列举一些常用的。如果以后遇到一些特殊的需求,可以借助help()指令来自行查找相关指令。

设置断点

(lldb)breakpoint set —name xx
(lldb)br s -n xx
(lldb)b xx
(lldb)breakpoint set —file F —line L
(lldb)br s -f F -l L
(lldb)b F:L
(lldb)breakpoint set —method xx
(lldb)br s -M xx
(lldb)breakpoint set —name “[objc msgSend:]”
(lldb)b -n “[objc msgSend:]”
(lldb)breakpoint set —selector xx
(lldb)br s -S count
(lldb)breakpoint set --func-regex regular-expression
(lldb)br s -r regular-expression
(lldb)br set -a func_addr

断点查看

(lldb)breakpoint list
(lldb)br l

断点删除

(lldb)breakpoint delete index
(lldb)br del index

index指明断点的序号,如果为空则删除所有断点

watchpoint

iOS开发当中有一个重要的概念KVO,我们会给一个重要的变量设置一个观察者,用以在它发生变化的时候做出相应的操作。在调试过程中我们也可以借助LLDB来监视某个变量或某一块内存的读写情况。

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
    NSString * str = @"First";
    [self printString:str];
    str = @"Second";
    [self printString:str];
}

- (void)printString:(NSString *)str
{
    NSLog(@"%@",str);
}

我们利用watchpoint指令来监视变量str。需要重点说明的是-w选项,下例中并没有写出,缺省值是write,这意味着只有在str被写入的时候程序会暂停。

(lldb) watchpoint set variable str
Watchpoint created: Watchpoint 1: addr = 0x7fff5997f9e8 size = 8 state = enabled type = w
    declare @ '/Users/noskthing/Desktop/LLDBTest/LLDBTest/ViewController.m:22'
    watchpoint spec = 'str'
    new value: 0x0000000106280078
2017-07-22 17:35:13.534 LLDBTest[4585:521823] First

Watchpoint 1 hit:
old value: 0x0000000106280078
new value: 0x0000000106280098

(lldb) image lookup -a 0x0000000106280098
      Address: LLDBTest[0x0000000100003098] (LLDBTest.__DATA.__cfstring + 32)
      Summary: @"Second"
(lldb) image lookup -a 0x0000000106280078
      Address: LLDBTest[0x0000000100003078] (LLDBTest.__DATA.__cfstring + 0)
      Summary: @"First"

当你输入watchpoint list查看设置的watchpoint时系统会提示你当前测试的机器允许设置的最大个数。

(lldb) watchpoint list
Number of supported hardware watchpoints: 4
No watchpoints currently set.

参数检查


当我们调试程序遇到断点的时候Xcode会自动的将当前作用域下的局部变量以及全局变量展示出来


当前作用

借助命令行我们也能够轻松的获取这些参数的信息

(lldb)frame variable
(lldb)fr v
(lldb)frame variable --no-args
(lldb)fr v -a
(lldb)frame variable *var*
(lldb)fr v *var*
(lldb)p *var*
(lldb)target variable
(lldb)ta v

细心的朋友应该能够有所发现,这些操作都有一个局限:我们查看的各个变量都是当前作用域的。这意味着程序遇到断点的时候暂停,所有的操作都是局限于当前函数以及当前函数所在线程的内部。可视化的操作并没有给我们太多操作的空间,但是借助命令行我们可以打破这样一个局限。

命令行输入(lldb)thread backtrace可以获取当前线程函数的调用栈

(lldb)thread backtrace
* frame #0: 0x0000000100057204 test`-[ViewController viewDidLoad](self=0x000000014fe0ad10, _cmd=<unavailable>) at ViewController.m:99 [opt]
  frame #1: 0x000000018e1cfec0 UIKit`-[UIViewController loadViewIfRequired] + 1036
  frame #2: 0x000000018e1cfa9c UIKit`-[UIViewController view] + 28
  frame #3: 0x000000018e1d631c UIKit`-[UIWindow addRootViewControllerViewIfPossible] + 76
  frame #4: 0x000000018e1d37b8 UIKit`-[UIWindow _setHidden:forced:] + 272
  frame #5: 0x000000018e245224 UIKit`-[UIWindow makeKeyAndVisible] + 48

输入frame select指令我们可以任意的去选择一个作用域去查看。

(lldb)frame select 2

类比frame的操作我们可以轻松看出线程选择相关的操作

(lldb) thread list
Process 21035 stopped
* thread #1: tid = 0x27361a, 0x00000001892df224 libsystem_kernel.dylib`mach_msg_trap + 8, queue = 'com.apple.main-thread', stop reason = signal SIGSTOP
  thread #2: tid = 0x273639, 0x00000001892fda88 libsystem_kernel.dylib`__workq_kernreturn + 8
  thread #3: tid = 0x27363a, 0x00000001893c2ca8 libsystem_pthread.dylib`start_wqthread
  thread #4: tid = 0x27363e, 0x00000001892fda88 libsystem_kernel.dylib`__workq_kernreturn + 8
  thread #5: tid = 0x27363f, 0x00000001892df224 libsystem_kernel.dylib`mach_msg_trap + 8, name = 'com.apple.uikit.eventfetch-thread'
  thread #6: tid = 0x273640, 0x00000001892fda88 libsystem_kernel.dylib`__workq_kernreturn + 8
  thread #7: tid = 0x273641, 0x00000001892fda88 libsystem_kernel.dylib`__workq_kernreturn + 8
  thread #8: tid = 0x273642, 0x00000001892fda88 libsystem_kernel.dylib`__workq_kernreturn + 8
  thread #10: tid = 0x273646, 0x00000001892df224 libsystem_kernel.dylib`mach_msg_trap + 8, name = 'com.apple.NSURLConnectionLoader'
  thread #11: tid = 0x273644, 0x00000001892df224 libsystem_kernel.dylib`mach_msg_trap + 8, name = 'AFNetworking'
  thread #12: tid = 0x27364a, 0x00000001892fda88 libsystem_kernel.dylib`__workq_kernreturn + 8
  thread #13: tid = 0x27364b, 0x00000001892fd23c libsystem_kernel.dylib`__select + 8, name = 'com.apple.CFSocket.private'
(lldb) thread select 2

以上提到的几个指令意味着借助命令行,我们可以在断点发生的时候跳转到当前存在的任一线程里的任一作用域去进行操作。

除却frame的操作,我们很多时候习惯借助NSLog去打印某些关键的信息。如果运行到一半的时候发现漏写了某个地方的NSLog,加入相关代码并重新运行也许不是一个让人省心的方法。我们可以在需要打印的地方设置一个断点,然后运行p object或者po object指令来查看指定对象。

(lldb) p userInfo
(__NSDictionaryM *) $0 = 0x0000000174242010 4 key/value pairs
(lldb) po userInfo
{
    macAddressString = "60:01:94:80:37:6c";
    payload =     {
        TimerAction = 0;
        TimerStat = 0;
        brightness = 70;
        colortemp = 93;
        remaining = "-1";
        switch = 1;
    };
    serialNumberString = 60019480376C;
    tcpPortString = "192.168.199.124";
}

两个指令实际都是expression指令的缩写。p打印的是当前对象的地址而po则会调用对象的description方法,做法和NSLog是一致的。

expression指令


expression命令是执行一个表达式,并将表达式返回的结果输出。包括上文提到的p指令在内,以下几个都是expression指令的别名。

(lldb)expression userInfo
(__NSDictionaryM *) $5 = 0x0000000174242010 4 key/value pairs 
(lldb) p userInfo
(__NSDictionaryM *) $2 = 0x0000000174242010 4 key/value pairs
(lldb) print userInfo
(__NSDictionaryM *) $3 = 0x0000000174242010 4 key/value pairs
(lldb) e userInfo
(__NSDictionaryM *) $4 = 0x0000000174242010 4 key/value pairs
(lldb) call userInfo
(__NSDictionaryM *) $5 = 0x0000000174242010 4 key/value pairs

打印对象的时候我们也可以指定特定格式,详细的格式查阅参见这里。

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

但是expression指令真正强大的部分应该是它的写入能力。我们可以通过expression来执行一个表达式动态的修改我们程序中变量的值。

(lldb) p count
(NSUInteger) $4 = 12
(lldb)e count = 42
(lldb) p count
(NSUInteger) $5 = 42

在断点处我们首先打印count变量的值,之后通过执行expression指令来修改count变量,再次打印可以发现此时count已经被修改。这对于调试时模拟一些极端情况非常的有帮助。这里有一个特殊一点的情况需要指明,如果你尝试通过expression来修改UI可能会失效。

(lldb)expression -- self.view.backgroundColor = [UIColor redColor]

因为执行断点会打断更新UI的进程导致你的修改没有及时渲染出来,执行flush命令可以让机器渲染出你修改后的界面。

实际上一些复杂的调试操作单单靠每次命令行去手动输入指令是非常的繁琐的,仅仅依靠单条指令和它提供的参数选项在一些针对界面的调试上并不能给予我们足够多的支持。令人兴奋的是facebook开源的Chisel为我们提供了更多实用的功能。整个开源库是用Python实现的,基于LLDB 内建的,完整的 Python 支持。这一部分我们后面聊到script指令再细细探讨。

hook的概念


hook翻译成中文是钩子的意思。这个名词在我从事iOS开发的过程中确实没有太多的接触,第一次碰到是在学习Flask框架时遇到的请求钩子。我并不觉得钩子的中文翻译对于我们理解有所帮助,在我初学的阶段甚至给我产生了一定的误解,所以我后续还是以hook来描述。

简单来说hook一个处理消息的程序段,通过系统调用,把它挂入系统。每当特定的消息发出,在没有到达目的窗口前,钩子程序就先捕获该消息,此时hook函数先得到控制权。这时hook函数即可以加工处理(改变)该消息,也可以不作处理而继续传递该消息,还可以强制结束消息的传递。这意味着借助hook函数我们可以在指定在某些特殊的情况下做出一些包括但不限于参数验证,消息拦截等操作来查验当前情况和修改后续程序的运行。

在LLDB中常见的操作有以下这些。本身指令并不复杂,但配合上其它的指令确实在某些情况下能节省我们很多的精力。

(lldb)target stop-hook add --one-liner stop-hook
(lldb) target stop-hook add --name func --one-liner stop-hook
(lldb)target stop-hook add -- className MyClass --one-liner stop-hook
stop-hook示例

流程控制


Xcode已经为我们提供了可视化的工具,但是如果你习惯了命令行操作不希望双手离开键盘降低你的效率,了解一下也是很有帮助的。

流程控制
(lldb)process continue
(lldb)continue
(lldb)c
(lldb)thread step-over
(lldb)next
(lldb)n
(lldb)thread step-in
(lldb)step
(lldb)s
(lldb)thread step-out
(lldb) finish
(lldb)f

除此以外我们还可以通过Thread return来控制流程。该指令有一个可选参数,在执行时它会把可选参数加载进返回寄存器里,然后立刻执行返回命令,跳出当前栈帧。这意味这函数剩余的部分不会被执行。当然这也可能会给 ARC 的引用计数造成一些问题,或者会使函数内的清理部分失效。但是在函数的开头执行这个命令,是个非常好的隔离这个函数,伪造返回值的方一个方法。

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
    if ([self isEvenNumber:2])
    {
        NSLog(@"First");
    }
    else
    {
        NSLog(@"Second");
    }
}

- (BOOL)isEvenNumber:(NSInteger)num
{
    if (num % 2 == 0)
    {
        return YES;
    }
    else
    {
        return NO;
    }
}

我们在isEvenNumber:函数中设置断点利用thread return函数返回NO。

(lldb) thread return NO
(lldb) c
Process 4784 resuming
2017-07-22 18:48:14.654 LLDBTest[4784:569378] Second

script


LLDB 有内建的,完整的 Python支持。在LLDB中输入 script,会打开一个 Python REPL。你也可以输入一行 python 语句作为 script 命令的参数,这可以运行 python 语句而不进入REPL

(lldb) script print 'Hello World'
Hello World

借助LLDB提供的Python API我们可以实现很多复杂的功能。这里列举一个简单的例子,将以下内容写入~/myCommands.py文件

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

在LLDB中执行

command script import ~/myCommands.py

或者将这条指令写入~ /.lldbinit中,每次进入LLDB都会自动执行这些函数。

如果没有~ /.lldbinit 终端执行touch ~ /.lldbinit生成文件
你可以在这里提前设置好一些指令,然后disable。调试过程中再设置enable打开。相信经过整理之后LLDB会让你的调试如鱼得水。

Facebook开源的Chisel就是基于此实现。我们通过brew安装Chisel

brew install Chisel
chise文件层次

fblldbbase.py文件中定义了各个基础类,fblldb.py负责遍历commands文件夹里的各个类来加载自定义的指令。在Chisel基础上我们也可以轻松的自定义指令。在commands文件夹内新建py文件,实现函数lldbcommands返回一个数组,包含对象的类都是FBCommand的子类。

def lldbcommands():
  return [
    FBPrintAccessibilityLabels()
  ]

class FBPrintAccessibilityLabels(fb.FBCommand):
  def name(self):
    return 'pa11y'

  def description(self):
    return 'Print accessibility labels of all views in hierarchy of <aView>'

  def args(self):
    return [ fb.FBCommandArgument(arg='aView', type='UIView*', help='The view to print the hierarchy of.', default='(id)[[UIApplication sharedApplication] keyWindow]') ]

  def run(self, arguments, options):
    forceStartAccessibilityServer();
    printAccessibilityHierarchy(arguments[0])

每一个类都继承自FBCommand,我们需要分别复写以下几个函数

image


image指令是target module指令的缩写,借助它我们能够查看当前的Binary Images相关的信息。日常开发我们主要利用它寻址。

在日常开发的过程中,我们会收集到用户各式各样的crash log。log中会为我们提供崩溃前函数栈的运行情况,每一个函数都会对应一个函数地址。

crash log

要解决问题首先我们需要确定的是程序最后调用了什么函数。由于ALSR的原因crash log中的函数地址我们不能够直接的去使用,我们需要在测试的机器上自己去计算出对应的函数地址。一般情况下crash log中会附带一个Binary Images。我们要利用这个来计算出每一个函数地址相对于所在框架的偏移量。

Binary Images
之后利用image指令来查看本机的Binary Images
(lldb) image list
[  0] 48EA38EC-6E36-3E77-A680-A4D04D3D3868 0x00000001014ac000 /Users/noskthing/Library/Developer/Xcode/DerivedData/LLDBTest-dfmaxwkizubjskftkbnlfzumauje/Build/Products/Debug-iphonesimulator/LLDBTest.app/LLDBTest 
[  1] 322C06B7-8878-311D-888C-C8FD2CA96FF3 0x0000000107c66000 /usr/lib/dyld 
[  2] 14AD0238-D077-378B-82A8-AC2D2ADC9DDF 0x00000001014b4000 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/usr/lib/dyld_sim 
[  3] 61CD1144-BB93-3571-BDB3-9F9B56CECFFE 0x0000000101543000 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk//System/Library/Frameworks/Foundation.framework/Foundation 
[  4] 5F0E622C-86EC-3969-ACFB-CAAA10E21A31 0x0000000101a76000 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk//usr/lib/libobjc.A.dylib 

有了本机的Binary Images我们就可以通过之前计算出的偏移量来获取本机对应函数的地址。通过image lookup指令查找对应地址的函数就可以确定崩溃前究竟执行了哪些函数

(lldb) image lookup -a 0x1025dd00a
      Address: UIKit[0x00000000001cb00a] (UIKit.__TEXT.__text + 1869978)
      Summary: UIKit`-[UIViewController loadViewIfRequired] + 1219

register


register指令能够获取和修改各个寄存器的信息。

我们需要明白一个典型的CPU是由运算器、控制器、寄存器等器件构成的,而寄存器进行的就是信息存储。我们利用汇编语言来操作寄存器。

汇编

这里是苹果官方文档,介绍的是armv6。需要注意的是自从iPhone 5s之后已经全部换到64-bit,在arm64下整数寄存器的个数已经增加到31个。我们可以通过register read来进行查看。

register

其中x0-x7八个寄存器是用来保存参数的。objc_msgSend会有两个默认参数,这也就意味着x0保存的是self,x1保存的是_cmd。fr对应frame point,lr对应link point,在汇编中分别为x29,x30。最近正在准备一篇从汇编的层面分析objc_msgSend的文章,会在那里结合官方文档详细介绍包括函数调用过程以及各个寄存器的作用。有兴趣的朋友可以先关注一下作者:)

利用runtime动态调用Objective-C任意对象的任意方法,需要为NSInvocation设置参数。参数的index就是从2开始的。具体的实现可以参考Github-Tools中的NSObject+Runtime这个Category的实现。

虽然我们更多的时候只是借助read指令来获取一下当前各个寄存器的信息,但是对于一些替换参数,模拟特殊输入的需求,write指令也是非常的有帮助。

实现一个简单的例子。

- (void)viewDidLoad {
    [super viewDidLoad];

    NSString * str = [NSString stringWithFormat:@"First"];
    NSString * str1 = [NSString stringWithFormat:@"Second"];
    [self printString:str];
    [self printString:str1];
}

- (void)printString:(NSString *)str
{
    NSLog(@"%@",str);
}

函数非常的简单,会依次打印出First和Second。我们首先在第一次调用printString:之前打印一个断点,调用frame variable来查看一下当前两个参数的地址.

(lldb) frame variable
(ViewController *) self = 0x000000010090ac30
(SEL) _cmd = <variable not available>

(NSTaggedPointerString *) str = 0xa000074737269465 @"First"
(NSTaggedPointerString *) str1 = 0xa00646e6f6365536 @"Second"

如果你的参数是unused编译器会把它优化掉,这样你就无法获取它的地址。注意NSString的创建方式,字符串常量创建会把str分配到常量区,查看参数会得到<variable not available>的提示。

之后在printString:中的NSLog之前设置一个断点,continue。在printString:中遇到断点的时候我们执行register read指令。

(lldb) register read
General Purpose Registers:
        x0 = 0x000000010090ac30
        x1 = 0x000000010000995f  "printString:"
        x2 = 0xa000074737269465
        x3 = 0x000000016fdfd876
        x4 = 0x0000000000000000
        x5 = 0x0000000000000000
        x6 = 0x0000000000000064
        x7 = 0x0000000000000000
        x8 = 0x00000001ae36bc20  libsystem_pthread.dylib`_thread + 224
        x9 = 0x00000001ae364fec  runtimeLock + 28
       x10 = 0x00000001ae364ff0  runtimeLock + 32
       x11 = 0x003c6d01003c6d80
       x12 = 0x0000000000000000
       x13 = 0x00000000003c6d00
       x14 = 0x00000000003c6e00
       x15 = 0x00000000003c6dc0
       x16 = 0x00000000003c6d01
       x17 = 0x0000000100007250  test`-[ViewController printString:] at ViewController.m:103
       x18 = 0x0000000000000000
       x19 = 0x000000010090ac30
       x20 = 0xa00646e6f6365536
       x21 = 0xa000074737269465
       x22 = 0x000000010000995f  "printString:"
       x23 = 0x0000000000000000
       x24 = 0x0000000000000010
       x25 = 0x0000000000000258
       x26 = 0x000000018ed0e90e  "window"
       x27 = 0x0000000000000001
       x28 = 0x0000000000000000
        fp = 0x000000016fdfddc0
        lr = 0x000000010000721c  test`-[ViewController viewDidLoad] + 156 at ViewController.m:100
        sp = 0x000000016fdfddb0
        pc = 0x000000010000725c  test`-[ViewController printString:] + 12 at ViewController.m:106
      cpsr = 0x60000000

对比地址可以发现x0保存的是viewController的地址,x1注明了是函数printString:的地址,而x2就是str的地址。我们通过register write指令来修改x2的值。

(lldb) register write x2 0xa00646e6f6365536

contine之后你会发现打印出的不是First而是Second。

如果有朋友对汇编和函数调用感兴趣,我会在之后结合objc_msgSend汇编部分的代码在另一篇文章里来做个介绍。

结语


文章的目的是希望给大家展示LLDB强大的能力以及命令行的优点,但实际以上篇幅介绍的只是冰山一角。希望这篇文章能够给大家一些帮助,来更多的了解LLDB。

以下是一些有关LLDB的资料和文档

上一篇下一篇

猜你喜欢

热点阅读