iOS 之编译

himg

在查询编译原理的过程中, 我被网上的那些不规范用语彻底给整蒙了, 首先这里对机器语言和汇编语言称呼做下总结:

  • 机器指令 = 机器语言 = 机器码 = 机器代码 = 101010101010
  • 汇编指令 = 汇编语言 = mov ax,bx

为什么需要编译

计算机的核心是 CPU, CPU 中有上亿个晶体管, 运行的时候, 每个晶体管会根据电流的流通与关闭来确认两种状态, 也就是我们说的 0 或 1.

为了对计算机发送指令, 人们发明了汇编语言, 这种语言使用了人类容易理解的字母组合来表示指令, 但是计算机是理解不了这种语言的, 因此还需要通过特定的编译器将汇编语言转换为 CPU 能理解的机器语言 (二进制)

写代码时我们使用的都是高级语言 (c, c++, java, oc, swift 等), CPU 是不认识这些语言的, 编译的过程就是将高级语言转换为 CPU 可以识别的二进制. 在 iOS 开发中, xcode 调用 LLVM 来完成编译过程, 将 Swift 语言经历 前端 -> 优化 -> 后端 转换成机器可以识别的二进制指令. 这整个过程如下图所示:

himg

再此过程中, 语言经历了: 高级语言汇编语言机器语言 (二进制). 通过机器语言可以反编译为汇编语言, 通过汇编语言也可以反编译出高级语言, 不过很难, 因为有可能两个不同的高级语言命令产生的汇编语言是相同的

汇编语言分类

汇编语言根据书写格式分为三种:

  • AT&T 格式 (UNIX, MAC 阵营):8086 汇编 (16bit 架构)x64 汇编 (64bit 架构)
  • Intel 格式 (WIN 阵营): x86 汇编 (32bit 架构)
  • ARM 格式 (移动 设备阵营): 只用在 arm 处理器上

汇编严重依赖硬件设备, iOS 模拟器使用 AT&T 格式汇编 (因为 Mac 是基于 Unix 开发的), iOS 真机使用 ARM 汇编

编译过程

himg

Preprocessing

预处理步骤的目的是将你的程序做一些处理然后可提供给编译器. 它会处理宏定义, 发现依赖关系, 解决预处理器指令.

Xcode 解决依赖关系通过底层 llbuild 构建系统. 它是开源的, 你可以在 Github swift-llbuild 页面了解更多信息.

Compiler - LLVM

编译器是一个程序, 将一种语言的源程序用另一种语言映射到一个语义上等价的目标程序. 换句话说, 它转换 Swift, objective-CC / C++ 代码到机器码.

Xcode 使用两个不同的编译器: 一个用于 Swift, 另一个用于 Objective-C, Objective-C++ 和 C / C++ 文件.

  • clang 是苹果官方的 C 语言编译器.
  • swiftc 是 Xcode 用来编译和运行 Swift 源代码的 Swift 编译器.

编译器工作流程如下:

himg

编译器由两个主要部分: 前端和后端.

前端负责词法分析, 语法分析, 生成中间代码; 它还创建并管理符号表, 收集关于源程序的信息.

Swift 编译器, 中间语言表示名为 Swift Intermediate Language(SIL). 它是用于进一步分析和优化的代码. 不可能直接从 Swift 中间语言生成机器代码, 因此 SIL 经历了一系列转变到 LLVM 中间表示.

后端以中间代码作为输入, 进行行架构无关的代码优化, 接着针对不同架构生成不同的汇编代码.

Assembler

Assembler 翻译开发者可读的汇编代码为可重定位的机器码, 最终生成包含数据和代码的 Mach-O 文件.

机器代码是一种数字语言, 表示一组指令, 可以直接由 CPU 执行. 它被是可重定位的, 因为无论目标文件的地址空间在哪, 它将执行的指令相对地址.

Mach-O 文件是一种特殊的 iOS 和 MacOS 文件格式, 操作系统用它来描述对象文件, 可执行文件和库. 它是一串字节组合形成的有意义的程序块, 将运行在 ARM 处理器上或英特尔处理器.

Linker

链接器将各种对象文件和库链接合并为一个可以在 iOS 或 macOS 系统上运行的 Mach-O 可执行文件. 链接器主要有两种文件作为输入, 包括这些对象文件的汇编程序和库的几种类型 (.dylib, .tbd 和 .a).

链接器的作用, 就是完成变量, 函数符号和其地址绑定这样的任务. 例如, 如果在代码中使用 printf , 链接器链接这个符号和 libc 库 printf 函数实现的地方. 通常在编译阶段通过创建符号表来解决不同对象文件和库的引用.

Loader

最后, 加载程序是操作系统的一部分, 将一个程序加载到内存中, 并运行执行它. 加载程序负责分配运行程序内存空间和初始化寄存器所需的初始状态.

iOS 项目编译过程简介

我们的项目是一个 target, 一个编译目标, 它拥有自己的文件和编译规则, 在我们的项目中可以存在多个子项目, 这在编译的时候就导致了使用了 Cocoapods 或者拥有多个 target 的项目会先编译依赖库. 这些库都和我们的项目编译流程一致.

  1. 写入辅助文件: 将项目的文件结构对应表, 将要执行的脚本, 项目依赖库的文件结构对应表写成文件, 方便后面使用; 并且创建一个 .app 包, 后面编译后的文件都会被放入包中;
  2. 运行预设脚本: Cocoapods 会预设一些脚本, 当然你也可以自己预设一些脚本来运行. 这些脚本都在 Build Phases 中可以看到;
  3. 编译文件: 针对每一个文件进行编译, 生成可执行文件 Mach-O, 这过程 LLVM 的完整流程, 前端, 优化器, 后端;
  4. 链接文件: 将项目中的多个可执行文件合并成一个文件;
  5. 拷贝资源文件: 将项目中的资源文件拷贝到目标包;
  6. 编译 storyboard 文件: storyboard 文件也是会被编译的;
  7. 链接 storyboard 文件: 将编译后的 storyboard 文件链接成一个文件;
  8. 编译 Asset 文件: 我们的图片如果使用 Assets.xcassets 来管理图片, 那么这些图片将会被编译成机器码, 除了 icon 和 launchImage;
  9. 运行 Cocoapods 脚本: 将在编译项目之前已经编译好的依赖库和相关资源拷贝到包中.
  10. 生成 .app 包
  11. 将 Swift 标准库拷贝到包中
  12. 对包进行签名
  13. 完成打包

LLVM

从上面的编译流程中我们知道 compilerlinker 是不同的步骤. 在我们的 xcode 中使用 llvm 将这两者进行了整合

LLVM 是一个著名的编译器, 由大神 Chris Lattner 开发, 可用于常规编译器, JIT 编译器, 汇编器, 调试器, 静态分析工具等一系列跟编程语言相关的工作.

通常我们所说的 LLVM 并不仅仅是 LLVM, 还包括了实现前端的 Clang/swiftc.

LLVM 的编译架构分为三个阶段

  1. 前端

    进行语法分析, 语义分析, 生成中间代码.

    实际上在 Xcode 中写代码的时候会实时提示错误就是因为持续在调用 LLVM 的前端部分

  2. 公用优化器

    将生成的中间文件进行优化, 去除冗余代码, 进行结构优化.

  3. 后端

    将优化后的中间代码再次转换, 变为汇编语言, 再次进行优化. 最后将各个文件代码转换为二进制代码 (机器语言) 并链接以生成一个可执行文件.

LLVM 架构的优点

  • 不同的前端后端使用统一的中间代码 LLVM Intermediate Representation (LLVM IR)
  • 如果需要支持一种新的编程语言, 那么只需要实现一个新的前端 (Swift 就是新增了一个针对于 Swift 的前端)
  • 如果需要支持一种新的硬件设备, 那么只需要实现一个新的后端
  • 优化阶段是一个通用的阶段, 它针对的是统一的 LLVM IR, 不论是支持新的编程语言, 还是支持新的硬件设备, 都不需要对优化阶段做修改
  • LLVM 现在被作为实现各种静态和运行时编译语言的通用基础结构 (GCC 家族, Java, .NET, Python, Ruby, Scheme, Haskell, D 等)

LLVM 架构的目标是可以编译任何代码, 虽然目前还没有达到, 但是这样的架构是极为优秀的,

  • 内存占用查询
    • MemoryLayout<Password>.stride // 分配占用的内存大小, 40(因为实际用了 33, 最小对齐参数是 8, 因此最接近的数就是 40 了)
    • MemoryLayout<Password>.size //  实际用到的内存大小, 33
    • MemoryLayout<Password>.alignment // 对齐参数, 一般为 1, 2, 4, 8

调试

断点分类

普通断点

himg

条件断点

himg

himg

异常断点

断点的功能不限于上面所述. 开发 iOS 知道, 如果我们因为异常然后程序 crash 了, 代码就直接跑到 main.m 的 main 函数中去了. 为什么就不能跑到出现异常的代码中呢? ? ? 异常断点就为我们解决该问题, 程序就会在异常出现的那行代码终止. 创建异常断点图例如下:

himg

leak 检查器

可以检查程序中内存泄露的位置 (19.12.10 测试不能正常顺利通过 leak 打开应用)

himg

NSLog 打印

试图调试

himg

手机截屏

Debug-->View Debugging-->Take Screenshot

断点执行动作

himg

  • deactive breakpoint: 将所有断点沉默 (相当于临时删除了所有断点)
  • continue: 忽略本断点一次, 直至遇到下一个断点才会停止
  • step over: 向下执行一步, 如果有子函数, 执行整个子函数作为完整一步
  • step into: 向下执行一步, 如果有子函数则进入子函数 (不执行子函数的第一步, 再点击一次才会执行, 相当于跳到了函数的左括号上)
  • step out: 向下执行至本函数末尾并跳出本函数 (不执行外函数的第一步, 再点击一次才会执行, 相当于跳到了函数的右括号上)

LLDB 调试常用命令

  • p: 输出值 + 值类型 + 引用名 + 内存地址

  • po: 输出值, 只要能编译通过的表达式, 都可以作为 po 的参数.

  • expression:

    在调试时, 动态的执行赋值表达式, 同时打印出结果. 简写为 expr

    1
    2
    3
    4
    5
    6
    (lldb) p i
    (NSInteger) $16 = 1
    (lldb) expression i = 5
    (NSInteger) $17 = 5
    (lldb) po i
    5
    • -A ( --show-all-children ): Ignore the upper bound on the number of children to show.
    • -D <count> ( --depth <count> ): Set the max recurse depth when dumping aggregate types (default is infinity).
    • -F ( --flat ): Display results in a flat format that uses expression paths for each variable or member.
    • -G <gdb-format> ( –gdb-format ): Specify a format using a GDB format specifier string.
    • -L ( --location ): Show variable location information.
    • -O ( --object-description ): Display using a language-specific description API, if possible.
    • -P <count> ( --ptr-depth <count> ): The number of pointers to be traversed when dumping values (default is zero).
    • -R ( --raw-output ): Don’t use formatting options.
    • -S <boolean> ( --synthetic-type <boolean> ): Show the object obeying its synthetic provider, if available.
    • -T ( --show-types ): Show variable types when dumping values.
    • -V <boolean> ( --validate <boolean> ): Show results of type validators.
    • -X <source-language> ( --apply-fixits <source-language> ): If true, simple fix-it hints will be automatically applied to the expression.
    • -Y[<count>] ( --no-summary-depth=[<count>] ): Set the depth at which omitting summary information stops (default is 1).
    • -Z <count> ( --element-count <count> ): Treat the result of the expression as if its type is an array of this many values.
    • -a <boolean> ( --all-threads <boolean> ): Should we run all threads if the execution doesn’t complete on one thread.
    • -d <none> ( --dynamic-type <none> ): Show the object as its full dynamic type, not its static type, if available. Values: no-dynamic-values | run-target | no-run-target
    • -f <format> ( --format <format> ): Specify a format to be used for display.
    • -g ( --debug ): When specified, debug the JIT code by setting a breakpoint on the first instruction and forcing breakpoints to not be ignored (-i0) and no unwinding to happen on error (-u0).
    • -i <boolean> ( --ignore-breakpoints <boolean> ): Ignore breakpoint hits while running expressions
    • -j <boolean> ( --allow-jit <boolean> ): Controls whether the expression can fall back to being JITted if it’snot supported by the interpreter (defaults to true).
    • -l <source-language> ( --language <source-language> ): Specifies the Language to use when parsing the expression. If not set the target.language setting is used.
    • -p ( --top-level ): Interpret the expression as a complete translation unit, without injecting it into the local context. Allows declaration of persistent, top-level entities without a $ prefix.
    • -r ( --repl ): Drop into Swift REPL
    • -t <unsigned-integer> ( --timeout <unsigned-integer> ): Timeout value (in microseconds) for running the expression.
    • -u <boolean> ( --unwind-on-error <boolean> ): Clean up program state if the expression causes a crash, or raises a signal. Note, unlike gdb hitting a breakpoint is controlled by another option (-i). -v[] ( –description-verbosity=[] ): How verbose should the output of this expression be, if the object description is asked for. Values: compact | full
  • call:

    动态调用函数, 在不修改代码, 不重新编译的情况下, 修改界面上的视图.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    (lldb) po cell.contentView.subviews
    <__NSArrayM 0x60800005f5f0>(
    <UILabel: 0x7f91f4f18c90; frame = (5 5; 300 25); text = '2 - Drawing index is top ...'; userInteractionEnabled = NO; tag = 1; layer = <_UILabelLayer: 0x60800009ff40>>,
    <UIImageView: 0x7f91f4d20050; frame = (105 20; 85 85); opaque = NO; userInteractionEnabled = NO; tag = 2; layer = <CALayer: 0x60000003ff60>>,
    <UIImageView: 0x7f91f4f18f10; frame = (200 20; 85 85); opaque = NO; userInteractionEnabled = NO; tag = 3; layer = <CALayer: 0x608000039860>>
    )

    (lldb) call [label removeFromSuperview]
    (lldb) po cell.contentView.subviews
    <__NSArrayM 0x600000246de0>(
    <UIImageView: 0x7f91f4d20050; frame = (105 20; 85 85); opaque = NO; userInteractionEnabled = NO; tag = 2; layer = <CALayer: 0x60000003ff60>>,
    <UIImageView: 0x7f91f4f18f10; frame = (200 20; 85 85); opaque = NO; userInteractionEnabled = NO; tag = 3; layer = <CALayer: 0x608000039860>>
    )
  • bt: 打印堆栈, 如果不想打印全部可以在其后加上值限制, 如: bt 10

  • thread return 跳出当前方法的执行

  • 流程控制: 与 xcode 左下方的几个流程控制按钮作用相同

    • 继续: continue, c
    • 下一步: next, n
    • 进入: step, s
    • 结束: finish, f
  • frame select 1: 跳帧, 在 bt 10 之后打印了 10 帧, 如果想调到第一帧可以用这个命令

  • frame variable: 查看帧变量

  • image lookup -address <address>: 查找崩溃位置

  • image lookup -name <method name>: 查找方法来源

  • image lookup –type: 查看成员

  • break NUM: 在指定的行上设置断点

  • clear: 删除设置在特定源文件, 特定行上的断点. 其用法为: clear FILENAME:NUM.

  • continue: 继续执行正在调试的程序. 该命令用在程序由于处理信号或断点而导致停止运行时.

  • display EXPR: 每次程序停止后显示表达式的值. 表达式由程序定义的变量组成.

  • file FILE: 装载指定的可执行文件进行调试.

  • help NAME: 显示指定命令的帮助信息.

  • info break: 显示当前断点清单, 包括到达断点处的次数等.

  • info files: 显示被调试文件的详细信息.

  • info func: 显示所有的函数名称

  • info local: 显示当函数中的局部变量信息.

  • info prog: 显示被调试程序的执行状态.

  • info var: 显示所有的全局和静态变量名称.

  • kill: 终止正被调试的程序.

  • list: 显示源代码段.

  • make: 在不退出 gdb 的情况下运行 make 工具.

  • next: 在不单步执行进入其他函数的情况下, 向前执行一行源代码.

  • print EXPR: 显示表达式 EXPR 的值.

  • expr variable = false: 修改 variable 变量值, 避免重复编译

  • print-object: 打印一个对象

  • print (int) name: 打印一个类型

  • print-object [artist description] 调用一个函数set artist = @”test” 设置变量值

  • whatis: 查看变量的数据类型

  • register read / 格式 寄存器: 读取寄存器的值

    • x:16 进制

    • f: 浮点

    • d:10 进制

      1
      register read/x rax # 读取寄存器 rax 里面的值
  • register write 寄存器: 修改寄存器的值

    1
    2
    3
    4
    5
    (lldb) register read/x rax
    rax = 0x0000000000000003
    (lldb) register write rax 4 # 修改为 4
    (lldb) register read/x rax
    rax = 0x0000000000000004

p, po, v 区别

po

himg

po 是 expression -O -- 的缩写 (通过 help po 可以查看)

可以使用 command alias my_po expression --object-description -- 自定义一个 po 命令

过程如下:

  1. LLDB 会先把语句生成一小段代码

    himg

  2. 然后编译并执行, 再生成取结果的代码

    himg

  3. 然后再编译并执行, 拿到对应的结果, 并显示出来

    himg

p

himg

注意到有个 $R0, 这是 LLDB 给我们的结果设置了一个自增的名字. 我们可以直接使用起了名字的变量:

himg

p 是 expression -- 的缩写

过程如下:

himg

与 po 不同的是 p 会进行动态类型推断

himg

在上面的例子中, cruise 静态的类型是 Activity, 运行时的实际类型是 Trip. 这时候如果我们 p cruise, 得到的结果和修改例子之前并没有区别. 因为 LLDB 读取了代码的 metadata (元数据) , 去判断在特定时间点, 特定变量的类型.

但动态类型推断只会发生在表达式的结果部分, 所以如果尝试直接 p cruise.name, 并不会成功:

himg

p 的过程中最后一步是进行格式化, 如果使用 expression --raw -- cruise.name 则可以得到未格式化的数据

总结

himg

  • 只有 po 有描述的过程
  • pv 都有格式化参与
  • 因为 pop 有编译执行的能力, 所以可以更随意的执行一些逻辑
  • 因为 v 访问的是内存中实际的值, 类型推断可以不断执行, 最终再到格式化逻辑

iOS 内存分类

himg

AT&T 汇编语言实现

AT&T 中常用寄存器

寄存器 的存在是非常有意义的. cpu 从内存中读取数据, 但是 cpu 速度快, 内存 速度较慢, 如果直接进行交换数据的话必然对系统运行速度产生影响, 而 寄存器 速度快, 因此 CPU 通过 寄存器内存 交换数据. 寄存器 有各自的名称, 这样 CPU 寻找指定名称的 寄存器 交换数据

  • %rax: 作为函数返回值, 一般来说, 考虑到向后兼容, 64 位寄存器会兼容 32 位寄存器, 3264 可以一起使用. (64 位是 8 个字节, 以 r 开头. 32 位则是 4 个字节, 以 e 开头)

    himg

    64 位寄存器 rax 为了兼容分配了较低的 32 位, 也就是 4 个字节给了 eax. 汇编出现的 eax 就是代表 rax, eaxrax 的一部分, 其他的大部分寄存器也适用于这个道理

  • %rdi, %rsi, %rcx, %r8, %r9: 作为函数参数 (r8, r9 这种的 32 位的表示法通常在后面加 d, 变为 r8d, r9d)

  • %rip: 指令指针, 存储 CPU 即将执行的下一个指令的地址

    截取 2 句汇编:

    1
    2
    7 --  0x100000a64 <+20>:  movq   $0x1, 0x719(%rip)
    8 -- 0x100000a6f <+31>: movl %edi, -0x34(%rbp)

    第 7 行中的  0x719(%rip)  中的  rip  就是指令指针, 即将执行的 地址 就是 第 8 行 开头的那个地址 0x100000a6f

    所以这里 rip 的地址就是  0x100000a6f, 有了 rip 的地址

  • %rbp: 栈基址指针也称为帧指向, 指向栈底

  • %rsp: 栈指针, 指向栈顶

AT&T 中常用寄指令

  • $0x1: 立即数, 立即数就是常量, 前面加 $ 表示

  • movq $0x1, %rdi: 寻址 mov, 将 1 赋值给 寄存器 rdi, 从左往右

  • leaq %rbp,%rax: 内存赋值 lea, 将 rbq 的内存地址值赋给 rax

  • xorl %eax, %eax: 异或 xor, 将 eax0, 自己异或自己

  • jmp 0x80001: 跳转 jmp, 跳转到函数地址为 0x80001 的地址

  • jmp *(%rax): 间接跳转 *(), rax 是个内存地址, *(rax) 是拿到 rax 地址里的值

  • callq 0x80001: 函数调用 call, 调用地址为 0x80001 的函数, 一般配合 retq

  • b: byte 字节, 操作位宽 1 个字节

  • w: word , 2 个字节

  • l: long , 4 个字节

  • q: quadword, 8 个字节. 意味着, 寄存器操作的数据类型 需要占用的 操作位宽, 当然这根据你的 数据类型决定.

    1
    movq \$0x1, 0x719(%rip) # 意思是, 立即数 1 寻址 (0x719 + %rip), 并赋值. 将 1 赋值给 (0x719 + 0x100000a6f) 这个地址, 操作位宽是 8 个字节

栈帧

  • 站着的帧, 画面立体了起来, 不单单是一个角度, 里面包含了

    • 每一次函数调用涉及的相关信息
    • 局部变量, 函数返回地址, 函数参数等
  • 函数的调用是会在 栈上分配内存 的, 分配多少取决于 函数的参数和局部变量, 那么一个函数的占用的内存大小, 函数的返回地址, 我们就需要保存起来, 这就用到了栈帧

  • 为什么需要保存函数的信息? 因为函数运行完毕, 在栈上需要释放内存, 以及继续执行上一层代码, 我们需要上一层函数的返回地址, 在本次函数执行完毕后, 恢复父函数的栈帧结构

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    类比一下接力赛中, 4 位选手
    栈顶 1 -> 2 -> 3 -> 4 栈底, 每一位选手都要在拿到接力棒后, 才会开跑
    那么 1 号选手, 就需要保存 2 号选手的信息, 他不需要知道 3 号 和 4 号
    下一个接棒者 长什么样? 身上的号码牌? 站在哪里?
    1 号选手结束之后, 赛场队伍就只剩 2 -> 3 -> 4, 此时焦点就集中在 2 号选手
    选手跑步 -> 函数调用
    选手信息 -> 栈帧保存的信息
    视线焦点 -> 栈指针, 指向当前选手
    只有我们清楚了下一位的接棒人 (在栈中对应上一层函数), 我们才能在本次结束之后找到正确的位置, 继续执行流程

    - 至于信息的保存者? 取决于寄存器的标识 Caller Save 和 Callee Save. 当子函数调用的时候, 也会用到父函数的寄存器, 可能会存在覆盖寄存器的值.

    * Caller Save, 调用者保存

    父函数调用子函数之前, 将寄存器的值保存一份, 这样子函数就可以随意覆盖 Callee Save, 被调用者保存
    父函数不保存, 交由子函数 保存和恢复 寄存器的值

占位符

  • %@: 对象, String, Array, Dictionary 等都是对象
  • %%: 『%』字符
  • %d,%D: 带符号的 32 位整数
  • %u,%U: 无符号的 32 位整数
  • %x,%X: 无符号的 32 位整数, 按照 16 进制输出
  • %o,%O: 无符号的 32 位整数, 按照 8 进制输出
  • %f: 64 位浮点数
  • %e,%E: 64 位浮点数, 按照科学记数法输出
  • %c: 八位无符号字符
  • %C: 16 位 Unicode 字符
  • %a,%A: 64 位浮点数, 按照科学记数法输出
  • %F: 64 位浮点数, 按照 16 进制输出

参考