×

深入iOS系统底层之XCODE对汇编的支持介绍

96
欧阳大哥2013 595a1b60 08f6 4beb 998f 2bf55e230555
2018.01.18 09:42* 字数 5175

工欲善其事必先利其器 --《论语·卫灵公》

一个好的IDE不仅要提供舒适简洁和方便的源代码编辑环境,还要提供功能强大的调试环境。XCODE是目前来说对iOS应用开发支持的最好的IDE(虽然Visual Studio2017也开始支持iOS应用的开发了),毕竟XCODE和iOS都是苹果公司的亲生儿子。唯一要吐槽的就是系统和编译环境绑的太死了,每当手机操作系统的一个小升级,都需要去升级一个好几G的新版本程序,这确实是有点坑爹!
目前市面上有很多反编译的工具,比如IDAHopper Disassembler等还有操作系统自带的工具诸如otool、lldb。这些工具里面有的擅长静态分析有的擅长调试的,这里就不展开分析了。如果在程序运行时去窥探一些系统内部实现以及做实时调试分析我觉得XCODE本身也非常的棒,既然深入系统我们必须要了解和学习一些关于汇编的东西,那么就必须要了解和掌握一些工具,而XCODE其实就是你手头上最方便的工具之一。

XCODE的汇编模式切换

你是否在联机运行时因为系统崩溃而出现过如下的画面:

程序运行的崩溃界面或者切换函数调用栈时的界面

不要慌!它其实就是XCODE的汇编模式的界面。我们不仅在程序崩溃时可以看到它,我们也可以人为的进入到这个界面模式里面。这篇文章更像是一个XCODE工具使用上的一些介绍,您可以经常在使用它们,也可能还从来没有接触和了解过它们。对于汇编代码和源代码之间的切换可以通过菜单:Debug -> Debug Workflow -> Always Show Disassembly 来完成。

汇编代码切换的操作菜单

记得要设置有断点并运行到断点处时切换才能看到汇编指令啊!

上一篇文章深入iOS系统底层之指令集介绍中我们有说过模拟器上运行的是Intel指令,而真机上运行的是arm指令,在这里我们分别看模拟器和真机下的汇编指令的差异性:

源代码

模拟器下的指令代码
真机下的指令代码

通过上面三张图你会发现其中的源代码和汇编代码之间有很大的差异,以及不同指令集下的汇编代码之间也有很大的差异!汇编代码的差异其实就是不同CPU上运行的指令的差异。还记得前一篇文章所说的指令集吗?前者是在模拟器上运行的所以展示的是x64的指令,而后者是在真机上运行的因此展示的是arm64指令。通过图片对比你能否发现他们之间的相同点和差异吗?

  • 系统所有的代码都是由一个个的函数或者说方法组成,即使是类中定义的方法以及Block里面的方法也是如此。在编译时系统将所有定义的函数方法依次编译链接为机器指令并保存到文件的代码段中,一个函数内的机器指令是连续存储的,但是函数之间却不一定是连续存储的。
  • 上面的图片中每条汇编指令都和一条机器指令唯一对应,这里要注意的是虽然显示的是汇编代码,但是真实存储和运行的还是机器代码,只不过我们通过汇编代码来展示能够容易阅读和理解而已。
  • 每条指令前面的地址表示的是这条指令在运行时所处在的内存地址。也许你会问指令不是在CPU上吗?没有错,指令虽然是在CPU上执行,但是存储还是要在内存或者磁盘上。CPU上有一个叫ip(Intel)或者pc(arm)的寄存器保存着下一条将要执行的指令的内存地址,这样每执行一条指令时都是从ip/pc中所指定的内存地址读取出指令并执行,并同时将当前指令的下一条继续保存在ip/pc上,就这样不停重复的方式来完成指令的执行(实际上CPU为了加快处理速度会将一部分内存中的指令缓存到CPU的内部缓存中去,而不是每条指令都从内存中读取)。
  • 每个函数方法的第一个地址,就是这个函数的入口地址,也就是说我们进行函数调用时,实际上是让CPU跳转到这个地址并执行,更加具体的就是将ip/pc寄存器的值设置为这个函数的入口地址。 对于OC类中的方法来说方法入口地址其实就是这个方法的IMP。
  • 在模拟器下你会发现每条指令的长度是不一样的,有1个字节到7个字节不等,所以你看到的每条指令的偏移量都不一样,而真机时你会发现每条指令的长度总是固定为4个字节。这其实就是CISC和RISC指令集中的一个非常显著的差别:CISC指令长度不固定而RISC指令则长度固定。你还会发现模拟器下的汇编代码数量要比真机下的汇编代码数量要少,这也是CISC指令和RISC指令的差别:CISC指令复杂而且众多,一条指令完成的功能要比RISC多;而RISC则指令简单,因此某些功能需要多条指令来完成。
  • 在汇编模式下的注释都是由;号开头的。大家在通过汇编语言研究内部实现时建议看模拟器下的AT&T汇编,原因其实就是模拟器下运行的汇编注释要比真机模式下的汇编指令要详细一些。
  • 每条汇编指令的格式总是由: 操作码, 操作数1,操作数2,操作数3组成。 操作数要么就是常数,要么就是寄存储器,要么就是内存地址。你所看到的操作数中的RAX,RSI,RDI,R0,R1... 这些都是CPU中的寄存器(关于寄存器部分我将在下一篇文章中具体介绍)。而且在XCODE的左下角部分我们可以查看当前CPU中的所有寄存器的值,你可以打印并修改他们。

断点

可能有的同学会说为什么我打开了汇编模式我还是看不到汇编代码?那是因为你没有给你的代码设置断点!什么是断点?为什么设置了断点程序就会暂停运行? 一般情况下CPU总是按照顺序依次执行指令并完成任务,当正在执行某个任务时如果遇到了特殊事件或者更高优先级的任务时就需要打断现有执行的代码并去执行优先级更高的代码,这种机制就是中断。中断有因为外部硬件设备事件而产生的硬中断, 同时CPU也提供一个软中断指令。当在代码里面执行一条软中断指令时,程序就会暂停运行,同时CPU把操作权限提交给操作系统来执行中断处理程序。当我们在程序某处设置了断点或者某个指令处设置断点时,系统会将断点处的指令保存到一个临时的断点列表中,同时将断点处的指令替换为软中断指令,这样当程序运行到断点处时因为执行的其实是软中断指令,而导致系统调用的发生,并执行软中断处理程序,软中断处理程序等待用户处理断点处的操作,比如当用户按下的是键盘上的Ctrl + F7时,软中断处理程序就会把保存在临时断点列表中真实断点处的指令恢复到指定的内存,同时把下次要执行的指令改为真实的指令,然后再次执行真实的指令,这样就完成了断点处指令的继续执行。(要想了解断点的具体实现,需要具有一些汇编的知识,这里就不展开了,后面我会在专门的章节里面详解介绍断点的实现原理)。

符号断点

当我们在程序代码某处设置了断点或者指令某处设置了断点后,程序执行到断点处时就会暂停下来。这时候如果我们是在汇编模式下,您看到的就是汇编程序断点,而当你在源代码模式下时,你看到的将是源代码断点。 除了在代码处设置断点外我们还可以设置符号断点。我们先来考察下面3个应用场景:

  1. 我们程序的某个视图的frame值在运行时不知道什么原因总是被莫名其妙的改变了,但是你就是不知道在哪里执行了视图frame的更改设置。这时候一个解决方法就是重载setFrame方法并设置断点来调试查看frame被何时调用。

  2. 我们的上线程序出现了在某个系统方法被调用时的crash问题,但是因为是系统的方法我们无法看到其中的源代码,从而无法进行crash问题分析(比如我们遇到的很多没有上下文的crash).

  3. 假如我懂汇编语言,我想研究一下系统框架的某个方法是如何实现的。

上面的三个问题我不知道大家会如何去解决? 其实这三种场景我们都可以借助于符号断点来完成。一般情况下我们可以在源代码某处设置断点来调试程序,对于没有源代码的情况下我们则可以通过设置符号断点来实现程序的调试和运行。要设置符号断点很简单。你只需要在XCODE的菜单:Debug -> Breakpoints -> Create Symbolic Breakpoint 或者快捷键:option + command + \ 来建立符号断点:

符号断点的设置

建立符号断点后,当某个与符号名相同某个函数或者方法在执行开始前就会产生断点,从而可以窥探某个方法的内部实现。还可以帮助我们对那些没有上下文以及非源代码处产生的崩溃进行分析和重现,从而帮助我们定位问题。下面是运行符号断点后的我们看到的两处符号断点的汇编语言内容:

VCTest1`-[ViewController setA:]:
->  0x1029855e0 <+0>:  sub    sp, sp, #0x20             ; =0x20 
    0x1029855e4 <+4>:  adrp   x8, 4
    0x1029855e8 <+8>:  add    x8, x8, #0x70             ; =0x70 
    0x1029855ec <+12>: str    x0, [sp, #0x18]
    0x1029855f0 <+16>: str    x1, [sp, #0x10]
    0x1029855f4 <+20>: str    w2, [sp, #0xc]
    0x1029855f8 <+24>: ldr    w2, [sp, #0xc]
    0x1029855fc <+28>: ldr    x0, [sp, #0x18]
    0x102985600 <+32>: ldrsw  x8, [x8]
    0x102985604 <+36>: add    x8, x0, x8
    0x102985608 <+40>: str    w2, [x8]
    0x10298560c <+44>: add    sp, sp, #0x20             ; =0x20 
    0x102985610 <+48>: ret    

-----------------

libsystem_c.dylib`abs:
->  0x1813dd984 <+0>: cmp    w0, #0x0                  ; =0x0 
    0x1813dd988 <+4>: cneg   w0, w0, mi
    0x1813dd98c <+8>: ret    

你是否看到了属性setA的内部实现以及函数abs的内部实现了?

调试

调试程序是一个程序员应该掌握的最基本的工夫,这里就不介绍其他的详细的调试命令以及方法,其他很多文章里面都有介绍了。主要介绍一下调试代码时单步运行的几个菜单和快捷键:

  • 源代码模式下
 F7 :  代码单步执行,当遇到函数调用时会跳入函数内部。
 F6:   代码单独执行,当遇到函数调用时不会跳入函数内部。
 F8:   跳出函数执行,返回到调用此函数的下一句代码。
  • 汇编模式下
 control + F7 :  指令单步执行,当遇到函数调用时会跳入函数内部。
 control + F6:   指令单独执行,当遇到函数调用时不会跳入函数内部。
  • 多线程之间的切换:
 control + shift + F7:  切换到当前线程,并执行单步指令。
 control  + shift + F6:  切换到当前线程,并跳转到函数调用的者的下一条指令。

在调试运行时当出现断点时我们可以在lldb命令行中输入各种调试命令,其他的不介绍,就单独介绍一下expr命令。expr命令其实是p或者po的完整版本,通过expr命令除了能够用来显示外,还可以用来进行数据的修改、方法的调用等强大能力。下面展示一下一些常用的expr方法:

   expr   变量|表达式              //显示变量或者表达式的值。
   expr -f h --  变量|表达式     //以16进制格式显示变量或表达式的内容
   expr -f b --  变量|表达式    //以二进制格式显示变量或者表达式的内容。
   expr -o --  oc对象              //等价于po  oc对象
   expr -P  3 -- oc对象           //上面命令的加强版本,他还会显示出对象内数据成员的结构,具体的P后面的数字就是你要想显示的层次。
   expr my_struct->a = my_array[3]    //给my_struct的a成员赋值。
   expr (char*)_cmd           //显示某个oc方法的方法名。
   expr (IMP)[self methodForSelector:_cmd]    //执行某个方法调用.

查看内存地址

程序运行时,操作系统为其构建出一个进程,同时构建出一个虚拟的内存空间。操作系统将进程中的虚拟内存空间划分为代码存储区域、全局数据存储区域、堆存储区域、栈存储区域等区域。每种区域都有特殊的用途:代码存储区域保存的是程序中的代码部分(这部分也可称为映像image);全局数据存储区域保存的是一些全局数据、常量以及一些描述信息(比如runtime里面的所有OC类的定义描述信息也是存储在这个区域中);堆存储区域则用来进行堆内存的动态分配;栈存储区域则保存着函数中的局部变量。因此可以看出无论是代码和数据在运行时都保存在内存中。每个进程能访问的内存空间的尺寸大小由操作系统决定,一般来说32位的操作系统中每个进程的内存空间为2^32 = 4GB;而64位的操作系统中每个进程的内存空间为2^64 = 4TB。需要注意的是这个空间是虚拟的可访问空间并不是真实的物理内存可访问的空间,操作系统内部通过分页映射的方式将虚拟空间转化为真实的物理空间。
进程的虚拟内存空间是一个可以连续存储和访问的线性空间,为了能够访问这些内存空间,操作系统为其进行了编码,这个编码就是内存的地址。地址也被称为指针,因此我们所说的某个变量的指针其实就是这个变量在内存中的地址。为了更好的理解内存和地址的概念,你可以将内存理解为一个数组,而地址则是访问这个数组元素时所用到的索引。我们对数组中元素的读写操作总是通过索引进行,同样CPU对内存中的数据访问时也是通过内存地址进行的。进程中的内存地址总是从0开始编码,并以字节为单位进行递增,直到虚拟内存空间的上限。
上面说过进程中的代码和数据都保存在内存中,当我们要想一览整个进程内存中的代码和数据时,你可以在程序运行时通过菜单:Debug -> Debug Workflow -> View Memory 或者通过快捷键:shift+command + m 来调用内存查看界面:

内存地址查看工具

上面的图片刚好展示的是一个类的所有方法名称在内存中的位置和布局。可以看出我们可以很方便的借助查看内存地址菜单的功能来了解以及分析代码以及数据在内存中的结构。你可以在地址输入栏中输入你想查看的任意内存地址。比如你想查看某个函数代码的机器指令,那么你只需要在汇编模式下将函数最开始的地址输入到内存查看界面的地址栏中,那么就会展示出这个函数代码的所有机器指令字节码。这里还要注意一点的是因为内存地址是从低位按字节依次排列而来,所以对于比如int类型的值的读取我们就要从高位到低位开始读取。

计算器 应用

程序调试时代码和地址以及一些数据都经常以16进制的形式显示。数据处理时,尤其是计算地址偏移都以16进制的形式进行展示。你可以在lldb中通过expr或者p命令来计算。如果你喜欢界面形式的工具,则可以启动mac OS操作系统中的应用:计算器 来处理各种计算,你要做的就是在显示菜单中选择编程型即可,编程型界面的效果如下(别告诉我作为一个程序员的你不会操作这些功能):

计算器应用

bc 命令

如果你喜欢命令行的方式来做计算,那么还可以介绍给你一个系统提供的命令式计算工具:bc。这个工具的官方定义是:一个任意精度计算器语言(An arbitrary precision calculator language)。我们可以以交互的方式进入bc:
bc -i

bc命令

使用bc时你可以通过ibase = [2|8|10|16]的值来指定输入数字的进制,可以通过指定obase=[2|8|10|16]的值来指定输出数字的显示格式。你还可以通过scale=n来指定输出的小数位数,你可以在里面用表达式、函数、运算符、甚至可以定义变量和函数。可以看出bc可不是只有计算的功能这么简单,你可以用bc来编写程序!!具体bc的使用你可以在终端下执行 man bc 查看bc的使用手册。下面是一段用bc语言写的代码(请在执行了bc -i 命令后编写如下代码):

sum = 0
for (i = 0; i < 100; i++)
{
   sum += i
}
sum

敬请期待下一篇:深入iOS系统底层之CPU寄存器介绍


目录
1.深入iOS系统底层之汇编语言
2.深入iOS系统底层之指令集介绍
3.深入iOS系统底层之XCODE对汇编的支持介绍
4.深入iOS系统底层之CPU寄存器介绍
5.深入iOS系统底层之机器指令介绍
6.深入iOS系统底层之赋值指令介绍
7.深入iOS系统底层之函数调用介绍
8.深入iOS系统底层之其他常用指令介绍
9.深入iOS系统底层之函数栈介绍
10.深入iOS系统底层之函数栈(二)介绍
11.深入iOS系统底层之不定参数函数实现原理介绍
12.深入iOS系统底层之在高级语言中嵌入汇编语言介绍
13.深入iOS系统底层之常见的汇编代码片段介绍
14.深入iOS系统底层之OC中的各种属性以及修饰的实现介绍
15.深入iOS系统底层之ABI介绍
16.深入iOS系统底层之编译链接过程介绍
17.深入iOS系统底层之可执行文件结构介绍
18.深入iOS系统底层之MACH-O文件格式介绍
19.深入iOS系统底层之映像文件操作API介绍
20.深入iOS系统底层之知名load command结构介绍
21.深入iOS系统底层之程序加载过程介绍
22.深入iOS系统底层之静态库介绍
23.深入iOS系统底层之动态库介绍
24.深入iOS系统底层之framework介绍
25.深入iOS系统底层之基地址介绍
26.深入iOS系统底层之模块内函数调用介绍
27.深入iOS系统底层之模块间函数调用介绍
28.深入iOS系统底层之机器指令动态构造介绍
29.深入iOS系统底层之crash问题解决方法
30.深入iOS系统底层之无上下文crash解决方法
31.深入iOS系统底层之常用工具和命令的实现原理介绍
32.深入iOS系统底层之真实的OC类内存结构介绍


欢迎大家访问我的github地址简书地址

深入iOS底层系列
Web note ad 1