标签:动态加载 用户需求 上下文 帧结构 link view mac 开关 ocp
I. 方案简介OCPack
是一种 iOS 平台上 App 动态化技术方案,用户可以使用 Objective-C 语言编写待动态化的功能逻辑(生成.m
文件),然后通过OCPack
提供的工具链生成 patch 文件(.bin
格式)。客户端则内置了一个基于 Native 环境的的虚拟栈机,它可以动态加载并执行存储在客户端的 patch 文件中的方法。Patch 文件可根据业务需要随时下载、更新并由虚拟机重新加载、运行。
此方案的主要优点:
OCPack
的实现覆盖了c
语言的基本语法和 Objective-C 中比较常用的语法,保证开发者在使用中大部分常用的写法都能直接支持,而部分不能支持的语法也有相应的替代实现方式。OCPack
的工具链能够明确地给出错误原因提示及错误代码位置,方便定位开发中遇到的问题。作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这有个iOS交流群:642363427,不管你是小白还是大牛欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术!
mmap
)中,尽量不占用应用程序的内存配额,提高应用的稳定性。II. 技术方案OCPack
复用 clang 前端来分析目标 Objective-C 代码的语法树,通过自定义 ASTFrontendAction 来遍历语法树,生成自定义指令集的汇编程序。在客户端,由自研的虚拟栈机来解释执行汇编程序中的二进制指令。
生成 patch 文件的基本数据流程是:
OCPack
编译模块转换为自定义指令集的汇编程序(.s)。此过程主要是通过解析 LLVM 生成的 Objective-C 代码语法树(AST)实现的。注:OCPack
自定义的栈机汇编指令主要有67
条,除基本指令以外,主要是依据语法树结点类型设计。
在运行时,客户端内置的虚拟栈机能够根据用户需求加载指定的 patch 文件,然后就可以执行其中任意方法了。
以下分模块来介绍主要技术点:
编译模块:
功能:Objective-C程序(.m
) -> 语法树 -> 汇编程序(.s
)
1. 独立的编译程序
主要使用 clang 的 libTooling 接口,实现了 AST FrontendAction,通过实现自定义的 ASTConsumer 递归遍历语法树,对不同的节点类型作相应处理,生成可执行的汇编指令程序。
OCPack
支持 Objective-C 语言中常用的语法类型,对于不支持的语法,在编译期间会生成相应的日志文件,具体标明了错误类型和错误位置 ,方便开发定位问题。注:为了进一步提高开发效率,OCPack
还实现了一个独立的 clang plugin,可以通过给工程添加一个 .xcconfig 文件(替换默认的 clang),实现在 Xcode 中显示相关的编译错误,并能一键生成 .bin文件,省去了获取编译选项和手工查看错误日志的步骤,简化了开发流程。
2. 栈机汇编指令集
为了连接包含有 Objective-C 代码逻辑的语法树和客户端运行的虚拟机,OCPack
需要定义一套比较完整的汇编指令集。该指令集应该满足以下两个条件:
以下简要介绍几个比较典型的指令的设计方案:
2.1 push 和 pop 指令
栈机中最基本的部件是操作栈,用于存放正在进行中的操作数和操作结果。如:要计算 1 + 2,栈机需要执行类似以下指令:
push instant 1
push instant 2
add
先将两个操作数1
和2
依次 push 进操作栈,再执行 add 操作。add 操作负责先 pop 对应的操作数,经过加法计算后再将结果 push 进栈。以上指令执行完后,操作栈顶存放的就是操作结果3
。
但只有操作栈是不够的,程序逻辑的复杂性要求像局部变量、方法参数等数据拥有确定不变的内存地址,因此OCPack
将局部变量、静态变量、常量、指针、立即数等分别对应一个段,每种类型的变量对应于所属段中的一个序号(index)。
local //局部变量
arg //方法实参
this //存放self(用于super的实现)
that //用于实现struct的成员变量
pointer //用于辅助实现this, that
//注: 线程相关的段数据存放在thread_context(线程局部存储)中,只对本线程可见
线程无关的数据段主要包括:
const //常量字符串
static //静态变量
instant //立即数
//注:线程无关的段数据存放在machine中,各线程都可见
在对语法树进行遍历的过程中,OCPack
编译器会维护一个符号表,对每个变量声明(VarDecl)建立相应的符号表项,存放其段名和序号(index)。
对语法树中的变量引用(VarDeclRef 结点),OCPack
编译器会找到其相应的 VarDecl 的符号表项,生成相应的 push、pop 指令。
push和pop指令的参数就是段名和序号(index):
segment
index
—— 将 segment 段中的 index 处数据 push 到操作栈顶segment
index
—— 将操作栈顶的数据 pop 到 segment 段中的 index 处2.2 prolog 指令
arg_size
local_size
方法调用和传参这块的设计需要一些特别的考虑,主要需要满足几个要求:
为了满足这些需求,OCPack
中设计了 prolog 这一指令:
2.3 ret 指令
retSize
此处有一次数据拷贝,拷贝大小即为返回值的大小。为尽量减少对调用者的影响,在编译期给 ret 方法增加 retSize 参数,以便在执行 ret 的时候就能完成数据拷贝,栈帧回退到调用者后,调用者可以预期返回值就在自己操作栈的栈顶,后续逻辑不受当前栈顶值是由方法调用返回还是自行 push 得到的影响,逻辑较清晰。
2.4 跳转指令
为了实现条件判断 if/else 和 for 循环等流程控制语法,OCPack
指令集定义了jmp
和jmp_if
指令,根据语法树中对应类型的节点具体情况,生成相应的跳转指令和跳转 label。这些文本跳转 label 会被存储在 .s 文件中,然后在下一阶段(Assembler 将 .s 转换为 .bin时)被替换成相应的偏移地址。
2.5 switch指令
1) switch跳转表
switch需要运行时决定跳到哪个 case label 对应的地址,只用jmp_if
需要在 case列表代码尾部插入多条比较语句,而栈机又需要每次比较前都push相应的参数,实现比较繁琐而且性能较差,因此OCPack
在指令集中增加了cmp_n
、resolve_label
和jmp_tos
指令。
OCPack
编译器在生成指令时会先将 switch 要比较的目标 push 进操作栈,然后再将各个 case 的值 push 进栈,然后添加cmp_n n
指令。在运行时,cmp_n n
指令会从栈上 pop 出n
个数据,与栈顶的数据(即 switch 的目标)进行比较,如果与第i
个相等,则将i
push到栈顶。resolve_label label_prefix
。此指令在运行时会将 label_prefix 与栈顶的i
进行字符串拼接,生成目标 label 名,并在 machine 中进行查找,找到对应的 label 地址,push 到栈顶。其中 label_prefix 是每个 switch 语句唯一的,可以支持 switch 嵌套。jmp_tos
。此指令在运行时会跳转到栈顶的地址,从而实现 switch 的功能。2) continue和break的支持:
分别维护一个 break和 continue 的 label 栈,栈顶元素为当前 break 或 continue 调用时应该 jmp 到的目标 label,在目标表达式开始和结束时进行入栈和出栈操作。在遇到语法树上结点为 break 或 continue 时,取出当前栈顶的目标 label,生成jmp 目标label
指令。
2.6 call指令
OCPack
自定义的字符串,一一对应到 libffi 的类型。对于 struct 来说,生成指令时需要递归找到 struct 所有成员的类型,拼成相应的字符串,然后在运行期反解字符串,构造出 libffi 所需的数据类型。2.7 基本一元、二元运算符指令
注: 此指令只支持整型、浮点型等基本数据类型的运算,不支持自定义类型重载的运算符
2.8 左右值转换
OCPack
中 push 指令,类似push seg index
都是将 seg 段 index 处的地址 push 进操作栈,而取对应地址处的具体内容由左右值转换指令来完成。注: 在实现初期,OCPack
的 push 指令是直接将 seg 段 index 处变量的右值 push 进操作栈(即这种情况下忽略左右值转换的结点),但后来发现在类似赋值操作中的左值变量的情况下,AST 中没有左右值转换结点,如果对这些情况特殊处理,逻辑会变得较为复杂且难以保证覆盖完全,后来决定完全依照 AST 中结点的排布逻辑,将 push 操作的对象改成了对应变量的左值,牺牲部分性能换取程序的可靠性。
2.9 Objective-C 方法调用指令
注: 实现过程中,Objective-C 调用指令所需的输入数据的内存排布顺序也经历了一番修改。因为对于 Objective-C 方法来说,只有拿到 selector 才能知道具体有多少个参数,所以之前设计是参数表倒着放,即第一个参数放栈顶,第二个参数依次往下排。这样可以稳定地 pop 两次就得到 selector 的声明,然后再根据 selector 中指明的参数个数及大小 pop 所有的参数。但这种方法在参数大小大于 64 bit 的情况下(如 struct)就比较难处理了,因为要得到正确的 struct 数据,程序需要 pop 对应个数的64 bit,然后做拼接,烦琐而且容易出错。经权衡,还是在指令参数中增加了参数表长度(编译期得到),在调用 OBJC_MSG_CLASS/OBJC_MSG_INST 指令前,参数还是按顺序push(即第一个参数先 push,栈顶是最后一个参数),在指令的实现中,根据指令参数中提供的参数表长度,直接从 sp 算出第一个参数的起始位置,这样所有的参数都可以用指针访问,而不用关心其大小了。原先需要的多次 pop 指令,变成只需在指令退出前,将 sp 回退参数表长度即可。
汇编模块
功能:汇编程序(.s) -> 二进制补丁程序(.bin)
解析整个 .s 文本,将文本 token 转换为对应的二进制数据,主要包括:
转换完成后将各数据存入内存中相应的数据段,再将整个内存 dump 成一个二进制文件。
注: 二进程文件在运行时所需的大部分数据其排布都与 .bin 文件里的排布完全相同,这样能方便地使用内存映射来实现 .bin 文件的加载,从而可以减少私有内存的占用量。
加载模块
功能:二进制补丁程序(.bin)加载
在调用 load_image 时,虚拟机会先将 .bin 文件 mmap 到一段内存中,检测 magic number, bin version 及 arch 是否匹配。
然后按全局区的大小申请一段 shared anonymous mmap 内存。
然后分别加载各个数据段,建立必要的运行时内存数据,主要的数据段包括:
注:bin
文件具体格式如下:
执行模块
功能:二进制补丁程序(.bin)的执行
1) 虚拟机基本信息
注:VM 函数栈帧具体内存布局如下:
2) Objective-C 调用虚拟机方法
OCPack
会根据存储返回值的变量是否是强引用而决定是否需要对返回的对象做__brige_retained
操作,用以中和调用方对 strong 类型变量的 release 操作。其他情况下因为返回的都是 autorelease 的对象,返回时不做特殊处理(详见编译模块
小节中 2.9 段Objective-C方法调用指令
)。3) 虚拟机调用 OC 方法(f1),f1 又调用到了虚拟机的方法(f2)
要支持此流程,需保证 f2 调用完成后虚拟机当前栈帧的 sp 与调用前完全一致,以保证 f1 的执行不受影响。
4) 虚拟机方法间互相调用
在调用OC方法时,会先检测对应的方法是否在导出函数表中,如果在,则走此流程。这也要求调用虚拟机方法时的参数表应该与直接调用 OC 方法是一致的,否则还需要重新拷贝参数做适配,降低虚拟机性能。
5) 多线程支持
OCPack
注册了线程退出的回调函数,当一个线程退出时OCPack
会删除所有虚拟机在此线程中的上下文相关数据。6) 内存占用
7) 崩溃时的栈回溯
8) 崩溃符号解析
由OCPack
编译器生成或指定一个 GUID,后续生成的所有相关文件(包括.s、.sym、.bin以及运行时生成的崩溃 log)中都存有此 GUID。线上的崩溃日志发送回来后,崩溃解析服务器能够根据日志中的 GUID 查找到相应的符号文件进行符号解析。同时构建服务器数据库中存储了对应 GUID 的 bin 文件打包时所有依赖项的源信息(包括对应的 .s 文件、bin 代码对应的源代码版本、OCPack
工具链的版本等),方便开发重现、定位相关问题。
9) Hook Objective-C 方法
OCPack
的方法调用省去了大量的字符串解析操作,参数大都可直接传入虚拟机进行处理,方法调用的整体开销比 JSPatch 小。性能优化
OCPack
在实现初期,采用了模板类的方式实现一、二元操作符的对不同操作数和返回值类型的支持,这样调试起来比较方便。但后来发现这种方案会导致代码体积会暴增。模板方法会根据输入、输出参数的类型生成大量的方法,而其中大部分方法都只有很短的几条指令,从最后的二进制内容分析看,光方法名就占用了大量内存。
III. 未来计划
链接器
其他语法支持
性能优化
Debug工具
最后有什么疑惑问题这有个iOS交流群:642363427有一个共同的圈子很重要,结识人脉!里面都是iOS开发,全栈发展,欢迎入驻,共同进步!(群内会免费提供一些群主收藏的免费学习书籍资料以及整理好的几百道面试题和答案文档!)
标签:动态加载 用户需求 上下文 帧结构 link view mac 开关 ocp
原文地址:https://www.cnblogs.com/qfww/p/13884212.html