MNN 社区上提供了通过 Python 使用 MNN 的方式,具体可以见:
https://github.com/alibaba/MNN/tree/master/pymnn/src
我们通过 Pybind11 来提供比较优雅的桥接方式。由于 Pybind 11 是一层抽象 C++ 到 Python 桥接的库,上层封装了很多难以理解的细节和流程,本文就带大家抽丝剥茧一下。
关于 Python 桥接,如果你不是很了解,那么在阅读本文之前,请记住如下两句话:
代码必须要以传统的 module 添加类的方式来进行,相关代码是:
Python 没办法直接编写 C++ Extension,都是通过再包一层 C 方法 (Python 自身的 C 接口)的方式来进行。
换句话说,不管什么,都需要以一个 module 为载体,且需要name
因此我们的入口就在 PYBIND11_MODULE(name, variable)
全部的宏就是:
#define PYBIND11_MODULE(name, variable) \
【1】static void PYBIND11_CONCAT(pybind11_init_, name)(pybind11::module &); \
【2】PYBIND11_PLUGIN_IMPL(name) { \
【3】PYBIND11_CHECK_PYTHON_VERSION \
auto m = pybind11::module(PYBIND11_TOSTRING(name)); \
try { \
PYBIND11_CONCAT(pybind11_init_, name)(m); \
return m.ptr(); \
} PYBIND11_CATCH_INIT_EXCEPTIONS \
} \
void PYBIND11_CONCAT(pybind11_init_, name)(pybind11::module &variable)
比较晦涩,我们以 mnn
来举例,帮宏全部展开来探索下。
首先声明一个函数 static void pybind11_init_mnn(pybind11::module &);
,在宏的最后会有实现。
PYBIND11_PLUGIN_IMPL(mnn)
这一步是必要的一步,所有 Python 的扩展模块都需要声明对应的初始化方法,用于注册被调用。
import mnn
就会调用 init_mnn
这样的方法。
initmnn
里面定义了 pybind11_init_wrapper
这是真正的初始化实现。(注意 static 声明)
pybind11_init_wrapper
里面真正的实现分为几步:
构造一个 module
对象(class module),auto m = pybind11::module(PYBIND11_TOSTRING(name));
调用之前声明的 pybind11_init_mnn
函数,传入module对象,仿造 Python C Extension 的方式来添加方法、定义、变量等。 pybind11_init_mnn(m)
实现 pybind11_init_mnn {xxx}
所以一切核心的关键就是在 class module
,抛开繁杂的东西:
第一步,创建一个 CPython 中的 module object,见:
所以,本质上无论 Pybind11 在干什么,都是利用 CPython 底层的技术在那里操作。
把对应的方法加入到这个 CPython Module 中,如下:
创建类的方法相对难一点,但是也不难理解,我们还是找到根源 class class_
(其实一切只要理解 Pybind11 是用 C++ 去模拟 CPython 的流程就行了)
找到对应的构造函数,首先从函数签名上我们就能窥探一些东西:
scope
对应类所属的模块。name
就是类名。Extra
就是 C++ 模版机制对应的真正类。看起来上面和对应的 Python 类型初始化没关系,来看看是不是 generic_type::initialize
造成的。
make_new_python_type
这名字一看就很符合,哈哈,点进去一看,果然是类型初始化流程。简要概括下添加方法的实现。
生成模版化的初始化模块方法,即 Wrapper 方法。
提供一个仿造 Python Module
对象(便于 C++
编写 / 使用智能指针管理引用计数),在对应的自定义函数里面添加对应 Module
的实现。
MNN 社区上提供了通过 Python 使用 MNN 的方式,具体可以见:
一提到到上下文污染,可能我们绝大多数人想到的都是寄存器污染,但是实际上还有一个不容我们忽视的上下文资源:栈,过去可能大家常见的 Hook 代码关注比较少,正好这次在借助 TrampolineHook 修复这个方面的问题,我们来一起探讨下。
假设有这样一个类 TestObject
和 不定参函数 method
,定义如下:
@interface TestObject : NSObject
@end
@implementation TestObject
- (void)method:(int *)value,...
{
va_list list;
va_start(list, value);
while (value) {
NSLog(@"orig value is %d", *value);
value = va_arg(list, int *);
}
va_end(list);
}
@end
如果要使用 TrampolineHook
来拦截 method
的调用,也非常简单。如下所示:
THInterceptor *sharedInterceptor = [THInterceptor sharedInterceptorWithFunction:(IMP)wzq_check_variadic];
Method m = class_getInstanceMethod([TestObject class], @selector(method:));
IMP imp = method_getImplementation(m);
THInterceptorResult *result = [sharedInterceptor interceptFunction:(IMP)imp];
if (result.state == THInterceptStateSuccess) {
method_setImplementation(m, (IMP)result.replacedAddress);
}
// 拦截函数
void wzq_check_variadic(id a, char * methodName, int *v, ...)
{
NSLog(@"haha checked %@ %s", a, methodName);
}
当我们使用如下方式调用 -[TestObject method:]
的时候,你会发现一切正常,毫无问题。
TestObject *obj = [[TestObject alloc] init];
int a = 0;
int b = 1;
int c = 2;
int d = 3;
int e = 4;
int f = 5;
int g = 6;
int h = 7;
int i = 8;
[obj method:&a, &b, &c, &d, &e, &f, &g, &h, &i, nil];
但是如果你将拦截函数中添加打印参数的语句后,如下所示:
void wzq_check_variadic(id a, char * methodName, int *v, ...)
{
NSLog(@"haha checked %@ %s", a, methodName);
va_list args;
va_start(args, v);
while (v != NULL) {
NSLog(@"v is %d", *v);
v = va_arg(args, int *); // crash
}
va_end(args);
}
你会发现出现了必现的崩溃情形,而且是必定崩溃在第二次读取变参列表中的参数的时候。
为什么添加了读取参数的代码就导致运行崩溃了?有点意思。
为了避免优化的干扰,如下汇编生成的优化选项为
-O0
为了看运行时的栈结构是如何生成的,我们通过汇编结合图的形式来一探究竟没 Hook 的时候的调用情况。
首先先看 Caller 函数,即 [obj method:&a, &b, &c, &d, &e, &f, &g, &h, &i, nil];
这段代码所处的函数,汇编如下:
// prologue
0x1009dc428 <+0>: sub sp, sp, #0xb0 ; =0xb0
0x1009dc42c <+4>: stp x29, x30, [sp, #0xa0]
0x1009dc430 <+8>: add x29, sp, #0xa0 ; =0xa0
// 构造 int 变量 0 - 8
0x1009dc498 <+112>: stur wzr, [x29, #-0x2c]
0x1009dc49c <+116>: mov w11, #0x1
0x1009dc4a0 <+120>: stur w11, [x29, #-0x30]
0x1009dc4a4 <+124>: mov w11, #0x2
0x1009dc4a8 <+128>: stur w11, [x29, #-0x34]
0x1009dc4ac <+132>: mov w11, #0x3
0x1009dc4b0 <+136>: stur w11, [x29, #-0x38]
0x1009dc4b4 <+140>: mov w11, #0x4
0x1009dc4b8 <+144>: stur w11, [x29, #-0x3c]
0x1009dc4bc <+148>: mov w11, #0x5
0x1009dc4c0 <+152>: stur w11, [x29, #-0x40]
0x1009dc4c4 <+156>: mov w11, #0x6
0x1009dc4c8 <+160>: stur w11, [x29, #-0x44]
0x1009dc4cc <+164>: mov w11, #0x7
0x1009dc4d0 <+168>: stur w11, [x29, #-0x48]
0x1009dc4d4 <+172>: mov w11, #0x8
0x1009dc4d8 <+176>: stur w11, [x29, #-0x4c]
0x1009dc4dc <+180>: ldur x9, [x29, #-0x28]
0x1009dc4e0 <+184>: ldr x1, [x8]
0x1009dc4e4 <+188>: mov x8, sp
0x1009dc4e8 <+192>: mov x10, #0x0
// 把对应 int 变量的地址存入栈中
0x1009dc4ec <+196>: str x10, [x8, #0x40]
0x1009dc4f0 <+200>: sub x10, x29, #0x4c ; =0x4c
0x1009dc4f4 <+204>: str x10, [x8, #0x38]
0x1009dc4f8 <+208>: sub x10, x29, #0x48 ; =0x48
0x1009dc4fc <+212>: str x10, [x8, #0x30]
0x1009dc500 <+216>: sub x10, x29, #0x44 ; =0x44
0x1009dc504 <+220>: str x10, [x8, #0x28]
0x1009dc508 <+224>: sub x10, x29, #0x40 ; =0x40
0x1009dc50c <+228>: str x10, [x8, #0x20]
0x1009dc510 <+232>: sub x10, x29, #0x3c ; =0x3c
0x1009dc514 <+236>: str x10, [x8, #0x18]
0x1009dc518 <+240>: sub x10, x29, #0x38 ; =0x38
0x1009dc51c <+244>: str x10, [x8, #0x10]
0x1009dc520 <+248>: sub x10, x29, #0x34 ; =0x34
0x1009dc524 <+252>: str x10, [x8, #0x8]
0x1009dc528 <+256>: sub x10, x29, #0x30 ; =0x30
0x1009dc52c <+260>: str x10, [x8]
// 其余参数 x0, x1, x2
0x1009dc530 <+264>: sub x2, x29, #0x2c ; =0x2c
0x1009dc534 <+268>: mov x0, x9
// 调用 method 函数
0x1009dc538 <+272>: bl 0x1009e8d9c ; symbol stub for: objc_msgSend
上述这段函数,简要而言,就是干了四件事:
分配 176 byte 的栈内存
在栈上分配 a = 0, b = 1 等等 9 个变量
把 &b, &c 等 8个 int 变量的地址压栈。
x0 (obj), x1 (method), x2(&a)
特别注意,变参列表的第一个参数也是通过寄存器来传递。
如果不理解, 可以参考这张图:
而当进入 method:
函数时,汇编如下:
重点看两行蓝色汇编断点的地方,其实是在暗示一种循环,也从底层实现上对应上了我们不断循环获取变参列表的逻辑。
简要来说,就是从变参列表的第一参数(寄存器中的值代表地址),开始读取,循环遍历。这里的循环利用了栈空间在函数调用间的连续性,不断将偏移地址从原来 caller 函数的 sp 回溯,读取处于高地址的 caller 栈空间中的 int 变量地址。
看到这,我想大家也知道了为什么是必定崩溃在第二次读取变参的时候。
x0, x1 不用说,是寄存器参数。和变参不变参函数无关,这也能解释为什么只读取 id obj 和 SEL selector 不会崩溃。
x2,即变参函数列表的第一个参数,我这里把他称为变参的锚点参数,它也是通过寄存器传递,所以读取的时候没问题。
变参列表的后续参数都是分配在调用函数(caller)中,而 TrampolineHook 在调用 interceptor 之前利用了栈(操作 SP)来保存上下文,如下所示,因此破坏了栈资源上下文,导致循环从栈地址获取参数的时候崩溃
stp q0, q1, [sp, #-32]!
stp q2, q3, [sp, #-32]!
stp q4, q5, [sp, #-32]!
stp q6, q7, [sp, #-32]!
stp lr, x10, [sp, #-16]!
stp x0, x1, [sp, #-16]!
而调用原函数的时候,由于栈已经复原了,所以就不会出现崩溃了。
了解了问题出现的原因,解决办法就很简单了,我们要让调用 inteceptor 时候的上下文和调用原函数一样。
还是构造一堆的动态 trampoline ,让原函数替换到 trampoline,同时保存原函数的 IMP。
依然保存原先需要的上下文,比如通用寄存器、浮点寄存器,但是不能使用栈了。
调用 interceptor。
恢复上下文,调用到原函数。
其实整个步骤和原先基本一样,唯一需要考虑的就是如何在一点也不用栈的资源的前提下保存寄存器上下文?
堆上。
堆上。
堆上。
简单而言,我们把上下文一股脑都保存到堆上就行。需要保存的上下文大致类似于一个结构体:
typedef struct _THPageVariadicContext {
int64_t gR[10]; // general registers x0-x8 + x13
int64_t vR[16]; // float registers q0-q7
int64_t linkRegister; // lr
int64_t originIMPRegister; // origin
} THPageVariadicContext;
当然,这里的结构体只是形象化表示内存中的数据顺序和含义,真正使用汇编操作内存的时候,没有结构体。
保存上下文解决了我们不污染栈的诉求,但是同时也引出了一个新的问题,堆分配的地址我们保存在哪?跨函数调用后恢复上下文必须要让我们分配出的堆地址得到“持久化”存储啊。
保存到栈上?这肯定不可能,自己打自己脸嘛。
保存到寄存器上?如果是 caller-saved
寄存器,那不能保证跨函数调用完后,寄存器里面的内容还是我们原先设定的那样;而如果是 callee-saved
寄存器,确实可以解决跨函数调用后数据还原成我们保存的那样。但是同样的,我们自身也是其他 caller
函数的 callee
,我们侵占了一个寄存器,怎么在返回到 caller
函数之前复原这个 callee-saved
寄存器呢?
上面这段话有点绕。
所以,我们在分配堆内存的时候,要多分配一个 8 byte 的空间,把侵占的 callee-saved register
的值保存到堆内存中,然后再继续存我们原先要保留的上下文。
关键代码简要概括如下:
第一步,在拦截到函数调用后,先进入我们的 pre
操作,这里是在堆上对应上下文空间大小的地方。需要注意的是,调用分配内存的函数是使用 malloc
,我们并不知道 malloc
究竟会破坏哪些寄存器,因为也需要作一次额外的寄存器上下文保存,不过这个保存时短暂的,分配结束后就恢复。然后将这些上下文都保存到堆上。
attribute((naked))
void THPageVariadicContextPre(void)
{
// 先保存,避免调用 malloc 破坏寄存器
saveRegs();
// 分配堆上内存 extra 16 byte + sizeof(THPageVariadicContext)
__asm volatile ("mov x0, #0xF0");
__asm volatile ("bl _malloc");
// 返回的分配内存地址保存起来 callee-saved
__asm volatile ("str x19, [x0]");
__asm volatile ("mov x19, x0");
// 恢复堆栈,避免影响变参所处在的堆栈
restoreRegs();
// 用堆上空间保存数据
__asm volatile ("stp x0, x1, [x19, #(16 + 0 * 16)]");
__asm volatile ("stp x2, x3, [x19, #(16 + 1 * 16)]");
__asm volatile ("stp x4, x5, [x19, #(16 + 2 * 16)]");
__asm volatile ("stp x6, x7, [x19, #(16 + 3 * 16)]");
__asm volatile ("stp x8, x13, [x19, #(16 + 4 * 16)]");
__asm volatile ("stp q0, q1, [x19, #(16 + 5 * 16 + 0 * 32)]");
__asm volatile ("stp q2, q3, [x19, #(16 + 5 * 16 + 1 * 32)]");
__asm volatile ("stp q4, q5, [x19, #(16 + 5 * 16 + 2 * 32)]");
__asm volatile ("stp q6, q7, [x19, #(16 + 5 * 16 + 3 * 32)]");
__asm volatile ("stp lr, x10, [x19, #(16 + 5 * 16 + 4 * 32)]");
__asm volatile ("ret");
}
调用完拦截函数,我们需要销毁堆空间,由于我们之前使用的是 callee-saved
的寄存器,我们能确保寄存器的值还是调用之前的。所以我们放心的将其中的值取出来,然后销毁对应的占空间,然后恢复寄存器即可。
__attribute__((__naked__))
void THPageVariadicContextPost(void)
{
// x19 肯定是正确的地址,使用x19恢复对应的数据
__asm volatile ("ldp lr, x10, [x19, #(16 + 5 * 16 + 4 * 32)]");
__asm volatile ("ldp q6, q7, [x19, #(16 + 5 * 16 + 3 * 32)]");
__asm volatile ("ldp q4, q5, [x19, #(16 + 5 * 16 + 2 * 32)]");
__asm volatile ("ldp q2, q3, [x19, #(16 + 5 * 16 + 1 * 32)]");
__asm volatile ("ldp q0, q1, [x19, #(16 + 5 * 16 + 0 * 32)]");
__asm volatile ("ldp x8, x13, [x19, #(16 + 4 * 16)]");
__asm volatile ("ldp x6, x7, [x19, #(16 + 3 * 16)]");
__asm volatile ("ldp x4, x5, [x19, #(16 + 2 * 16)]");
__asm volatile ("ldp x2, x3, [x19, #(16 + 1 * 16)]");
__asm volatile ("ldp x0, x1, [x19, #(16 + 0 * 16)]");
// 保存一下,避免 free 的影响。
saveRegs();
// 恢复原先的 x19, 调用free
__asm volatile ("mov x0, x19");
__asm volatile ("ldr x19, [x19]");
__asm volatile ("bl _free");
// 恢复堆栈
restoreRegs();
__asm volatile ("mov lr, x13");
__asm volatile ("br x10");
}
需要注意的是,我们这里用了__attribute__((__naked__))
,这个作用是为了让我们的函数不会额外的生成函数 prologue/epilogue
中的压栈消栈操作。
至此,变参 Hook
就完成了,大家可以前往 Github
查看最新的 THVaradicInterceptor
来使用。
有的朋友会问,为什么很多网上常见的 Hook 方案,都不要这么复杂的上下文保存流程?
其实道理很简单,保存什么上下文取决你的拦截或者 Hook
函数的目的以及使用方式。
举个非常常见的统计函数调用耗时的例子,在这个情形中,一般只用关注 x0
,x1
两个参数 来记录是什么类什么函数的调用。这种情况下,你的上下文保存可以极简,甚至只要保存 x0
, x1
即可。
而 TrampolineHook
想要提供的拦截器,是一个通用的拦截器,我不能保证其内部的实现,因为我需要保留的上下文就必须很完整。
后续 TrampolineHook
除了完善对 x86_64
的支持外,还有两个比较大的技术目标,也会慢慢完善。如果有什么使用中遇到的问题或者 Bug
也欢迎提交代码。
本来以为是双休日,结果五一调休本周末只休一天,懵逼。不过还算完成了承诺,赶了出来。
开源地址:https://github.com/SatanWoo/TrampolineHook
之前杨萧玉在看到我《基于桥的全量方法 Hook 方案(2) - 全新升级》 后就问我这个和直接用 method_exchangeImplementation
之类的 runtime
方法交换 IMP
性能对比咋样?
所以这篇文章开头先占用大家宝贵的两分钟,简要说明下。
TrampolineHook
本质上不是用来 Swizzling
的框架,取 Hook
这个名字只是为了读起来顺口。它实际上是一个中心重定向框架。 换句话说,你可以认为它是为了通过一个函数替换/拦截所有你想要函数的框架。
其实这个中心重定向的思想并不新潮,很多人(包括我自己)在内就曾经利用重载 objc_msgForward
干过这样的事。
但是这个方式我在之前的文章里也提到过对应的缺点,比如:
所以可以认为 TrampolineHook
是一个让你不用关注底层架构Calling Convention(因为涉及到汇编),不用关心上下文信息保存、恢复,不用担心引入传统 Swizzle 方案在大型项目中有奇奇怪怪 Crash 问题的中心重定向框架。
整个技术原理其实可以分为三部分:
vm_remap
技术。
流程设计。
汇编实现。
通俗意义上,我们访问的内存都是按照页来组织。而在程序加载后分配的页之中,会对应有不同的权限,比如代码占用的页,就是可读且可执行,但是一般不具备可写的权限;而存放数据的页呢,就对应是可读且可写,但不能拥有可执行权限。
在绝大多数情况下,当我们编写完一个程序运行的时候,动态分配的页都是用来做数据保存、访问的,不太会有涉及执行权限。
而要做到可以将动态分配出来的内存页具备可执行权限,就需要利用 vm_remap
。 它的定义是这样的:
On Darwin, vm_remap() provides support for mapping an existing code page at new address, while retaining the existing page protections; using vm_remap(), we can create multiple copies of existing, executable code, placed at arbitrary addresses.
从定义中我们可以知道两点信息:
vm_remap
可以让内存页具备被 map 的页的特性,如果是可执行页被 map,那新创建的页自然而然页具备了这个权限。
vm_remap
也不是肆无忌惮的创建任何可执行的页,通俗理解,它只是一个 copy 映射。
因此,我们可以通过在编写代码的过程中,精心构造、预留在程序二进制的代码页,在运行时不断“复制映射”,来完成特殊的使命。
在我们的定义中,我们是构造了连续的两个页。
要构造特殊的程序二进制代码,首先还是要梳理我们的目的,我们的诉求是所有的函数都能先进入我们的一个中心重定向函数,执行自定义的操作,然后返回原函数,同时这个调用栈不能乱。
【注意】:在整个过程中,我们要保证参数寄存器、返回地址等不能错乱。
既然 vm_remap
是按页的维度来映射,我们要构造的代码自然而然要页对齐。在 arm64 中,一页是 0x4000,也就是 16KB,所以首先就是 .align 14
来确保。
然后上一下最关键部分的代码,感兴趣的还是去 Github 上阅读完整的代码吧。
_th_entry:
// 不要小看这五行汇编
nop
nop
nop
nop
nop
sub x12, lr, #0x8
sub x12, x12, #0x4000
mov lr, x13
ldr x10, [x12]
stp q0, q1, [sp, #-32]!
stp q2, q3, [sp, #-32]!
stp q4, q5, [sp, #-32]!
stp q6, q7, [sp, #-32]!
stp lr, x10, [sp, #-16]!
stp x0, x1, [sp, #-16]!
stp x2, x3, [sp, #-16]!
stp x4, x5, [sp, #-16]!
stp x6, x7, [sp, #-16]!
str x8, [sp, #-16]!
// 加载自定义的拦截器,并跳转过去。
ldr x8, interceptor
blr x8
ldr x8, [sp], #16
ldp x6, x7, [sp], #16
ldp x4, x5, [sp], #16
ldp x2, x3, [sp], #16
ldp x0, x1, [sp], #16
ldp lr, x10, [sp], #16
ldp q6, q7, [sp], #32
ldp q4, q5, [sp], #32
ldp q2, q3, [sp], #32
ldp q0, q1, [sp], #32
br x10
.rept 2032
mov x13, lr
bl _th_entry;
.endr
整段汇编可以分为几个部分:
设计一大堆的动态可执行地址,即:
.rept 2032
mov x13, lr
bl _th_entry;
.endr
这里最早我的实现是复制粘贴一大堆重复性代码,在 HookZZ 作者的指导下,我优化成了上述这样。
执行统一的运行过程,通过偏移计算等方式获取保留的原始 IMP。
要注意特定的寄存器用处,x8-x18
是临时寄存器,里面的值在函数调用后可能被修改,这些寄存器为caller-saved。所以在我们自身函数可以用,但是要在调用别的函数之前保存好。
要特别注意对 LR 寄存器的处理,没处理好,调用栈就回不去了。
保存对应的参数、浮点参数等寄存器,避免上下文被我们自己的处理函数破坏。
b / bl
的跳转范围非常有限,由于我们是动态地址分配,不能保证拦截函数的范围偏移,所以要采用 blr
的方式。
和传统的 Swizzle
需要提供对应的替换后的函数实现不同,中心化重定向思想可以帮助你实现很多有意思的事情:
比如网上很常见的 hook objc_msgSend
,可以帮你查看任意被 Hook 二进制中的函数耗时和调用链路。
比如 Bang
/ AnyMethodLog
这样的重定向 Log 日志框架等等。
苹果著名的 MainThreadChecker
也用了类似的技术。由于我才疏学浅,只是大致完成了对其实现的逆向,通过 TrampolineHook
进行了重写。 因为效果还不错,所以也开源了出来,地址是:https://github.com/SatanWoo/TrampolineHook/tree/master/Example/MainThreadChecker
这次在重写 MainThreadChecker
的过程中,我也对比了下和 2017 年苹果实现的差异。在整体流程上没有比较大的差异,但是还是有一些细节可以分享分享:
UIKit
,到了 iOS 12/13 成了 UIKitCore
,所以原先获取二进制的逻辑失效了,为了避免后续版本的变更干扰,我采用了苹果自身的守候,通过 class_getImageName([UIResponder class])
来保证获取的就是我们理解上的 UIKit
动态库。当然 TrampolineHook
的作用不止于此,争取过段时间把我的一些想法做完善再和大家交流。
本质上 Trampoline
和 vm_remap
技术不是新的技术,很早就有人应用了,构造 Trampoline
实际上在苹果自身关于 Block
的实现中就有。业界也有 SwiftTrace
也是用了对应的技术。
真正的关键在于你用 Trampoline
做什么?用途的不同也决定了效果的不同,这也是我把之前的代码重写 TrampolineHook
中所收获的,而且随着 TrampolineHook
相对我自身之前实现的优化,我发现眼前豁然开朗,能玩的事情还有很多,哈哈。
对了,如果有朋友对 arm64 的汇编比较熟悉,同时对函数调用也比较了解的话,会很快的发现我上述提供的汇编代码存在一个漏洞(虽然这个漏洞绝大多数人用不到),感兴趣的朋友可以微信交流下。
开源地址:https://github.com/SatanWoo/TrampolineHook 如果大家有什么想法或者遇到了自身项目中的 Bug,欢迎 issue。
]]>本来以为是双休日,结果五一调休本周末只休一天,懵逼。不过还算完成了承诺,赶了出来。
当时觉得自己研究的还算深入,基于汇编写(其实是复制粘贴)了一大堆的桥,可以针对性 Hook 一个或多个二进制,比如 UIKit 的逻辑,觉得挺屌的。
但是使用中发现了两个巨大的问题:
不能上线的方案其实价值都不大。
因此,当时这个方案我就抛弃了,后续也因为我不怎么搞iOS,就没深入优化。
新的方案的起源灵感来自于我隔壁组的同事,手淘架构新生代小天王谢俊逸的启发。他说你用汇编写桥,照理性能不会慢啊,你为啥要走一次 forwarding 的逻辑?
我回顾了下代码,发现原先我为了保留所谓的层级上下文,将类名和方法名构造成了一个唯一标示,然后将这个唯一标示和一个动态生成的函数地址相绑定。然后通过这个不存在的方法名触发 forwarding 流程,然后改写成正确的方法名,从而调用正确的被 HOOK 前的函数。
看不懂的话等我周末整理下代码开源吧。
而整个流程,也是如下两个问题的罪魁祸首。
改了方法名:SEL 的修改之前是为了解决中心重定向相同继承链上的 Hook Crash 问题,但是会导致意想不到的其他 Crash 问题。
性能巨慢:走 fowarding 流程绕了一大圈。
要解决上述这些问题,汇编和桥依然是不可或缺的,但是如何把所有 UIKit 的方法都中心重定向同时又能绕开继承链问题呢还能不修改 SEL 的名称呢?
经过和谢俊逸的讨论,我们发现,我们把原先保存 拼接后 SEL 的逻辑,换成直接保存 HOOK 之前的 函数IMP,然后通过汇编直接跳过去执行 IMP 不就完事了?
思路非常 Nice ! 开工
想法有了,因为涉及到汇编,需要非常复杂的操作流程,简单抛砖引玉一下。
_template_page:
sub x12, lr, #0x8
sub x12, x12, #0x4000 // 获取对应数据页的便宜
mov lr, x13 // 获取返回原始调用处
// x8-x18 临时寄存器,里面的值在函数调用后可能被修改,这些寄存器为caller-saved,可以用
ldr x10, [x12] // originIMP
stp lr, x10, [sp, #-16]!
stp x0, x1, [sp, #-16]!
stp x2, x3, [sp, #-16]!
stp x4, x5, [sp, #-16]!
stp x6, x7, [sp, #-16]!
str x8, [sp, #-16]!
// 我不用浮点数寄存器,所以我不保存,你们用你们要保存
// 这行是伪代码,意思意思。实际上这个代码是会Crash的。
bl _WZQMainThreadChecker
ldr x8, [sp], #16
ldp x6, x7, [sp], #16
ldp x4, x5, [sp], #16
ldp x2, x3, [sp], #16
ldp x0, x1, [sp], #16
ldp lr, x10, [sp], #16
br x10 // 执行原函数
mov x13, lr
bl _template_page;
//// 下面是重复性的一堆代码。
主体上是这么写,但是需要考虑的太多了,今天周二,来不及整完博客了,吹吹逼睡觉。
还有很多东西实现了但是文章中没写,周末再写吧,水一篇博客。
要考虑对齐问题?
为什么可以这么设计桥?
如何保存重要的上下文、寄存器信息等?
代码写完后我和同事放在手淘里跑了泡,美滋滋,嘻嘻。不崩,还挺顺畅,哈哈,吊打原先的实现。
当然,鉴于本人汇编仅较初级的掌握 ARM64,因此 x86_64 或者 ARM64e(不知道有没有差别) 上的方案近期慢慢等我搞出来吧。
ARM64 上的代码等我周末慢慢整理下开源。
]]>
之前对于目标检测的了解停留于深度学习部分,比如 Fast-RCNN / Faster-RCNN / Yolo 等等,对于候选框域搜索算法主要还是对于 RPN 的认知。
但是这次在工作中了解到了 Selective Search 的概念,没想到在小样本训练的过程中精度也不错,性能还很好,哈哈。因此决定深入研究下。Selective Search 从大类上也可以属于 Region Proposal 的思想,但是主要的思想却是来源于传统的图像处理。
相关的论文发表于 IJCV 2013 《Selective Search for Object Detection》,大家可自行阅读获取更多细节。
主要还是学习目的,业界主流的还是采用 Faster-RCNN 的做法。
目标检测问题相对来说比图像分类复杂点,因为一般情况下要同时检测出多个子物体的位置(及可能需要的分类目的)。最原始的做法就是对于一张图像的每个可能位置都进行搜索,但是这里会产生一个两个互相增加复杂度的问题?
简单来说,假设知道一个待识别的物体左上角顶点处于(x, y),那么长和宽分别设置多少呢?设置小了,可能没有办法得到正确要识别的物体;设置大了,可能又把要分开区分的两个或多个物体合在了一起。
因此,这种传统的做法产生的搜索空间基本可以认为是无穷尽的。
那么自然而然地,我们的优化的想法肯定是减少搜索空间的大小!怎么做呢?
答案说难也不难,就是只找哪些可能是物体的区域。从区域这个维度进行搜索,而不是全图像的像素级查询。
全图搜索绝大多数的搜索像素包含区域是不包含物体的,实质上是浪费,可以通过如下两张图进行直观对比。
基于此,作者首先利用图像分割的想法,来获取可能是物体的区域;当然,这种层次的分割肯定不准
进一步地,考虑掉物体之间诸如包含等关系,通过合并的方式来构建层次化**的潜在物体区域。
所以整篇论文的核心就可以归纳为如下的数学公式:
这个时候,R 集合中的所有区域,就是通过 Selective Search 得到的候选框区域。
值得注意的是,这种计算方式得到的 R,本身就包含了多层次的关系。
前面我们提到了,我们初始的待定区域是基于图像分割得到的一批候选集,但是这些候选集的质量还比较“糙”,粒度也不一定对,需要合并甚至多次合并来处理一下。因此,如何合并也是一个相对值得思考的问题。
上两张图不难看出,初始化的图像分割对于目标检测来说是不能直接使用的。
其实这篇文章,作者也坦诚道:图片的样式千变万化,某些图片里面可行的方案到了另外一些图片中就不适用了。 因此,作者采用了多种方案混合的合并方法。
有了这些可以参考的思路,作者设计了四合一的合并公式。
读顶尖学术会议论文的好处就是一般对应的代码都会开源,即使论文读的云里雾里,但是只要能大致理解思路,配合源代码深入分析,总是能懂。
这篇论文对应的代码开源在Selective Search,代码总计也就 300+ 行(当然有些非核心代码直接依赖了库),很容易理解。
1 | def selective_search( |
大致内容就这样,当然细节还有不少值得研究的,可以继续深入,后续再读读。
最后,作者这 Python 写的真是溜。
之前对于目标检测的了解停留于深度学习部分,比如 Fast-RCNN / Fast]]>
Netron
是一个支持 Tensorflow
,PyTorch
,MXNet
,NCNN
, PaddlePaddle
等深度模型格式的可视化框架。去年国庆前的时候我稍微研究了下相关的代码,重点关注其将其是如何设计出一套兼容不同模型格式表征,用来归一化展现不同的深度学习框架模型。
研究完成后,我利用如下两个 Commit
作为 Pull Request
提交给了作者,用以支持 MNN
的模型可视化。
从中也不难看出我扎实的英语表述能力(我果然是个国际化人才)。
这篇文章会从架构设计、标准定义、巧用JS解析等几个方面来阐述
整体上,按照我个人的理解,Netron
的架构可以简要展现如下:
最基础的应用部分及运行环境,是 Electron
这个跨平台框架直接呈现的。
当然,一些诸如基础zip/gzip用于解压等等的库我们也统一归类到支撑里。
然后是一套经典的 MVC
的结构,app.js
作为整体的 controller ,负责整个应用的功能逻辑,如导出图片、菜单管理、保存加载等等。这一层我们需要的做事非常少,只要将 MNN
支持的模型后缀 .mnn
注册进去即可。 然后是是对应的 view.js
,这块实际上还是一层 controller
,类比我们常说的子控制器,专门用于处理主视图的逻辑,如下图所示:
从这块开始,我们就要注意了,因为这里开始通过工厂方法对应的根据读取文件类型的不同,托管给了不同的自定义 xxx.js
来处理后续步骤。 比如.mar
,model
,prototxt
等格式的模型会首先托管给 mxnet.js
来处理。如果存在重名,则按照先后顺序依次尝试。
view.ModelFactoryService = class {
constructor(host) {
this._host = host;
this._extensions = [];
this.register('./onnx', [ '.onnx', '.pb', '.pbtxt', '.prototxt' ]);
this.register('./mxnet', [ '.mar', '.model', '.json', '.params' ]);
this.register('./keras', [ '.h5', '.hd5', '.hdf5', '.keras', '.json', '.model' ]);
this.register('./coreml', [ '.mlmodel' ]);
this.register('./caffe', [ '.caffemodel', '.pbtxt', '.prototxt', '.pt' ]);
this.register('./caffe2', [ '.pb', '.pbtxt', '.prototxt' ]);
this.register('./pytorch', [ '.pt', '.pth', '.pkl', '.h5', '.t7', '.model', '.dms', '.pth.tar', '.ckpt', '.bin' ]);
this.register('./torch', [ '.t7' ]);
this.register('./torchscript', [ '.pt', '.pth' ]);
this.register('./mnn', ['.mnn', '.tflite']);
this.register('./tflite', [ '.tflite', '.lite', '.tfl', '.bin' ]);
this.register('./tf', [ '.pb', '.meta', '.pbtxt', '.prototxt', '.json' ]);
this.register('./sklearn', [ '.pkl', '.joblib', '.model' ]);
this.register('./cntk', [ '.model', '.cntk', '.cmf', '.dnn' ]);
this.register('./openvino', [ '.xml' ]);
this.register('./darknet', [ '.cfg' ]);
this.register('./paddle', [ '.paddle', '__model__' ]);
this.register('./ncnn', [ '.param', '.bin', '.cfg.ncnn', '.weights.ncnn']);
this.register('./dl4j', [ '.zip' ]);
this.register('./mlnet', [ '.zip']);
}
在这上层是一层标准定义层,用于抹平不同模型之间的表达方式,用归一化的逻辑来进行处理,至于怎么把自己的模型表征映射成归一化的逻辑,就需要编写对应 xxx.js
来自行处理,后文会以 MNN
来进行举例。
最上层就是对应各个深度框架自行的逻辑处理了。其中包含了数据格式及对应解析(如 flatbuffer
)、内容校验、构图等等,后文也会用 MNN
举例说明。
这一环是一个很不起眼但是却非常重要的环节。 每种深度模型框架都有其自定义的模块结构和模块构成,一般都以 Flatbuffer Schema
的形式构成。(当然也有例外)以MNN
为例,其对应的模型结构大致如下图所示:
同理, TFLite
的模型也可见 TFLite.schema
,不再赘述。
从定义中不难看出,TFLite
有 model
,graph
,SubGraph
等;而 MNN
对应的就是Net
;再往下一层 TFLite
有 Operator
和 Options
;而 MNN
有 OP
和OPParameter
;至于 NCNN
则是 Layer
。
如果是从整个架构角度去兼容不同的框架,必然会有着大量的 messy code
。因此作者定义了一套标准表征,让不同的深度模型自己去解析,然后附着自身的逻辑到这同一套表征上。
Model
,表示模型的静态表示。Graph
,表示模型的计算图表示。Node
,一个操作对应一个节点。Tensor
,输入输出数据。Parameter
,对应的属性。Argument
,对应的属性值。上述
Parameter
和Argument
可以简单认为一一对应吧,都认为是属性值即可。
一图胜千言,下图比较好的展现了术语和对应的表征:
这样不同的框架模型只要在自己对应的 xxx.js
中,把图,OP
层对应的数据填充至对应的地方即可。
这里依然以 MNN
举例:
subgraph
的概念,直接把 Model
和 Graph
等价于一个 net
即可。net
中取出 oplist
,对应创建成 Node
。oplist
中每个 op
,取出对应的 tensorIndex
,根据 net
的 tensorName
和tensorIndex
来创建对应的 tensor
。op
中根据 opparameter
的种类,从 op.main
中取出不同的数据来填入 paramter / argument
,这块是解析的大头,如果没想好方式,就会非常浪费时间,下文重点说。诸如 MNN
,TFlite
都选用了 Flatbuffer
来进行数据的保存,而官方的 flatc
程序支持直接根据定义的 schema
文件生成对应的 generated.js
,命令如下:
./flatc -s ~/yourPathTo/MNN/schema/default/Type.fbs
这个我看了下很多的同学的在处理多 Schema
定义的时候是对应的一个个生成 generated.js
,这样维护成本比较大,既然我们的已经使用了 include
机制,我们直接在生成过程中合并即可,如下所示:
./flatc --js -I ~/yourPathTo/MNN/schema/default/ ~/yourPathTo/MNN/schema/default/MNN.fbs --gen-all
这里有两个参数注意下:
-I
,表示 include
从哪个路径进行搜索。--gen-all
,表示自动对生成的所有文件合并。生成代码大致如下:
/**
* @param {number} i
* @param {flatbuffers.ByteBuffer} bb
* @returns {MNN.Blob}
*/
MNN.Blob.prototype.__init = function(i, bb) {
this.bb_pos = i;
this.bb = bb;
return this;
};
/**
* @param {flatbuffers.ByteBuffer} bb
* @param {MNN.Blob=} obj
* @returns {MNN.Blob}
*/
MNN.Blob.getRootAsBlob = function(bb, obj) {
return (obj || new MNN.Blob).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
/**
* @param {flatbuffers.ByteBuffer} bb
* @param {MNN.Blob=} obj
* @returns {MNN.Blob}
*/
MNN.Blob.getSizePrefixedRootAsBlob = function(bb, obj) {
return (obj || new MNN.Blob).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
具体关于 FlatBuffer
的细节,可以阅读我之前的文章,不再赘述。
上文提到 根据 OpParameter 来获取 main
中的数据,然后依次填入 parameter / argument
是比较耗费精力的步骤。我们所有的 OpParameter
类型有 74种(还在不断更新)
MNN.OpParameter = {
NONE: 0,
QuantizedAdd: 1,
ArgMax: 2,
AsString: 3,
Axis: 4,
BatchNorm: 5,
BinaryOp: 6,
Blob: 7,
CastParam: 8,
Convolution2D: 9,
Crop: 10,
CropAndResize: 11,
Dequantize: 12,
DetectionOutput: 13,
Eltwise: 14,
ExpandDims: 15,
Fill: 16,
Flatten: 17,
Gather: 18,
GatherV2: 19,
InnerProduct: 20,
Input: 21,
Interp: 22,
LRN: 23,
LSTM: 24,
MatMul: 25,
NonMaxSuppressionV2: 26,
Normalize: 27,
PackParam: 28,
Permute: 29,
Plugin: 30,
Pool: 31,
PRelu: 32,
PriorBox: 33,
Proposal: 34,
QuantizedAvgPool: 35,
QuantizedBiasAdd: 36,
QuantizedConcat: 37,
QuantizedLogistic: 38,
QuantizedMatMul: 39,
QuantizedMaxPool: 40,
QuantizedRelu: 41,
QuantizedRelu6: 42,
QuantizedReshape: 43,
QuantizedSoftmax: 44,
QuantizeMaxMin: 45,
QuantizeV2: 46,
Range: 47,
Rank: 48,
ReduceJoin: 49,
ReductionParam: 50,
Relu: 51,
Relu6: 52,
RequantizationRange: 53,
Requantize: 54,
Reshape: 55,
Resize: 56,
RoiPooling: 57,
Scale: 58,
Selu: 59,
Size: 60,
Slice: 61,
SliceTf: 62,
SpaceBatch: 63,
SqueezeParam: 64,
StridedSliceParam: 65,
TensorConvertInfo: 66,
TfQuantizedConv2D: 67,
TopKV2: 68,
Transpose: 69,
UnaryOp: 70,
MomentsParam: 71,
RNNParam: 72,
BatchMatMulParam: 73,
QuantizedFloatParam: 74
};
以 Convolution2D
举例,它又有几个对应的参数:weight
,bias
,quanParameter
,symmetricQuan
,padX
,padY
,kernelX
,kernelY
等等,需要解析。
一开始我采用了人肉的解析方式,代码就成了 if else
加上一大堆解析代码:
mnn_private.Convolution2DAttrBuilder = class {
constructor() {}
buildAttributes(metadata, parameter) {
//var common = parameter.common();
var attributes = [];
var common = parameter.common();
attributes.push(new mnn.Attribute(metadata, "padX", common.padX(), true));
attributes.push(new mnn.Attribute(metadata, "padY", common.padY(), true));
attributes.push(new mnn.Attribute(metadata, "kernelX", common.kernelX(), true));
attributes.push(new mnn.Attribute(metadata, "kernelY", common.kernelY(), true));
attributes.push(new mnn.Attribute(metadata, "strideX", common.strideX(), true));
attributes.push(new mnn.Attribute(metadata, "strideY", common.strideY(), true));
attributes.push(new mnn.Attribute(metadata, "dilateX", common.dilateX(), true));
attributes.push(new mnn.Attribute(metadata, "dilateY", common.dilateY(), true));
attributes.push(new mnn.Attribute(metadata, "padMode", mnn.schema.PadModeName[common.dilateY()], true));
attributes.push(new mnn.Attribute(metadata, "group", common.group(), true));
attributes.push(new mnn.Attribute(metadata, "outputCount", common.outputCount(), true));
attributes.push(new mnn.Attribute(metadata, "inputCount", common.inputCount(), true));
attributes.push(new mnn.Attribute(metadata, "relu", common.relu(), true));
attributes.push(new mnn.Attribute(metadata, "relu6", common.relu6(), true));
//var quanParameter = parameter.quanParameter();
var weights = [];
for (var w = 0; w < parameter.weightLength(); w++) {
weights.push(parameter.weight(w));
}
attributes.push(new mnn.Attribute(metadata, "weights", weights, true));
var bias = [];
for (var b = 0; b < parameter.biasLength(); b++) {
bias.push(parameter.bias(b));
}
attributes.push(new mnn.Attribute(metadata, "bias", bias, true));
return attributes;
}
get hasMain() {
return true;
}
这样的代码如果写完74个 OpParameter
,可维护性和后续的扩展也不够。
我们要巧用 JavaScript
的 Reflect
能力以及属性等于与字符串值属性的特性
_buildAttributes(metadata, op, net, args) {
var opParameter = op.mainType();
var opParameterName = mnn.schema.OpParameterName[opParameter];
// 获取对应的类型
var mainConstructor = mnn.schema[opParameterName];
var opParameterObject = null;
if (typeof mainConstructor === 'function') {
var mainTemplate = Reflect.construct(mainConstructor, []);
opParameterObject = op.main(mainTemplate);
}
this._recursivelyBuildAttributes(metadata, net, opParameterObject, this._attributes);
}
_recursivelyBuildAttributes(metadata, net, opParameterObject, attributeHolders) {
if (!opParameterObject) return;
var attributeName;
var attributeNames = [];
var attributeNamesMap = {};
for (attributeName of Object.keys(Object.getPrototypeOf(opParameterObject))) {
if (attributeName != '__init') {
attributeNames.push(attributeName);
}
attributeNamesMap[attributeName] = true;
}
var attributeArrayNamesMap = {};
for (attributeName of Object.keys(attributeNamesMap)) {
if (attributeNamesMap[attributeName + 'Length']) { attributeArrayNamesMap[attributeName] = true;
attributeNames = attributeNames.filter((item) => item != (attributeName + 'Array') && item != (attributeName + 'Length'));
}
}
for (attributeName of attributeNames) {
if (opParameterObject[attributeName] && typeof opParameterObject[attributeName] == 'function') {
var value = null;
if (attributeArrayNamesMap[attributeName]) {
var array = [];
var length = opParameterObject[attributeName + 'Length']();
//var a = opParameterObject[attributeName + 'Array']();
for (var l = 0; l < length; l++) {
array.push(opParameterObject[attributeName + 'Length'](l));
}
value = array;
}
else {
value = opParameterObject[attributeName]();
if (typeof value === 'object') {
this._recursivelyBuildAttributes(metadata, net, value, attributeHolders);
value = null;
}
}
if (value) {
var attribute = new mnn.Attribute(metadata, attributeName, value);
attributeHolders.push(attribute);
}
}
}
}
区区50多行代码就可以完成所有 OpParamater
及其对应的属性解析。
Netron
是一个支持 Tensorflow
,PyTorch
,MXNet
,NCNN
, PaddlePaddle
SIMD 是一种常见的利用单指令完成多数据量处理的计算方式。本文作为 SIMD 文章的引子,先来了解简单的 SIMD 使用和概念。
SIMD 的全称是 Single Instruction Multiple Data。简要来说,就是通过一条指令完成多条数据处理的行为。我们知道,虽然程序是由一条条机器指令组成,但是实际上执行一条机器码包含了多个过程,包含取指令、分析指令到执行等,如下图所示(暂时先忽略流水线并行)
而在这其中,每一个阶段,都会消耗一个或多个机器周期。如果我们认为,取指令和分析指令(译码)可以近似的认为是一个机器周期内完成,那么不同的指令,在执行阶段耗费的机器周期则大不相同。
举个例子,可能加法指令的执行阶段需要两个机器周期;而乘法可能需要5-6个机器周期。那么,当我们无法缩短指令的执行周期缩短的时候,利用 SIMD 技术,则可以在相同的执行周期内完成更多的数据处理,这样也同等的提升了单位时间内的数据吞吐,提高了计算性能。
在 Intel 的手册上,提供了包含 MMX, SSE, AVX 等系列的并行指令,面向不同长度的数据并行,比如:
更多详细的使用可以参考:
由于绝大多数的人对 SIMD 还不甚了解,因此本文基于大家比较熟悉的环境 Xcode + x86/64 架构来完成。
主要是我懒,不想再翻 ARM 的手册了。
这里我们以一个简单的 256bit (32 byte) 加法改写成 SIMD 的形式来验证:
原始版本:
double input1[k] = {1, 2, 3, 4};
double input2[k] = {5, 6, 7, 8};
double result[k] = {0};
for (int i = 0; i < k; i++) {
result[i] = input1[i] + input2[i];
}
SIMD 版本:
const int k = 4;
double input1[k] = {1, 2, 3, 4};
double input2[k] = {5, 6, 7, 8};
double result[k] = {0};
__m256d a = _mm256_load_pd(input1);
__m256d b = _mm256_load_pd(input2);
__m256d c = _mm256_add_pd(a, b);
_mm256_store_pd(result, c);
原始版本比较好懂,我们主要来深入看下 SIMD 中代码的意思:
_mm256_load_pd
就是从内存中读取一个地址,这个地址返回为 __m256d
的向量(256bit)。其中, __mm256d
的定义为下:
typedef double __m256d __attribute__((__vector_size__(32)));
这个含义的意思就是 __m256d
的长度是 32 byte(256bit),而这个 32 byte 是按照 4 个 double 元素构成的。
_mm256_add_pd
就是对两个 256bit 的向量元素进行直接相加。
_mm256_store_pd
就是 _mm256_load_pd
的逆运算,不再赘述。
注意:如果提示需要 AVX 支持的话,请在 Xcode 对应的代码文件处添加 Compiler Flag: -mavx
既然说了 SIMD 的本质还是为了提升单位时间内的计算吞吐量,我们还是用一个简单的例子,加法求和来实践一下:
常规的代码如下:
double CommonAdd(double *data, int count)
{
double result = 0;
for (int i = 0 ; i < count; i++) {
result += data[i];
}
return result;
}
SIMD 的代码如下:
double AVXAdd(double *data, int count)
{
int offset = 0;
__m256d v1;
__m256d sum = _mm256_setzero_pd();
double ret = 0;
for (int i = 0; i < count/4; i++) {
v1 = _mm256_load_pd(data + offset);
sum = _mm256_add_pd(sum, v1);
offset += 4;
}
sum = _mm256_hadd_pd(sum, sum); // 水平求和
ret += sum[0];
ret += sum[2];
return ret;
}
测试代码如下:
int main() {
struct timeval start;
struct timeval end;
const int k = 512 * 512;
const int loop = 1;
double input1[k];
for (int i = 0; i < k; i++) {
input1[i] = i;
}
gettimeofday(&start, nullptr);
for (int j = 0; j < loop; j++) {
CommonAdd(input1, k);
}
gettimeofday(&end, nullptr);
printf("tv_sec:%ld\n",end.tv_sec - start.tv_sec);
printf("tv_usec:%d\n", end.tv_usec - start.tv_usec);
std::cout << " ======================= " << std::endl;
gettimeofday(&start, nullptr);
for (int j = 0; j < loop; j++) {
AVXAdd(input1, k);
}
gettimeofday(&end, nullptr);
printf("tv_sec:%ld\n",end.tv_sec - start.tv_sec);
printf("tv_usec:%d\n", end.tv_usec - start.tv_usec);
return 0;
}
这里,我们选择了图像处理里面比较常见的 512 * 512 大小来做验证,在我的 2015款 MacBookPro 上可以得到大致如下两个性能耗时:
别小看这一点的性能差距,对于大运算量的端侧深度学习可就有很显著的差距了。
本文只是仅仅介绍了最常规的 SIMD 使用方式。但是在实际设计的过程中,不可能像我们这么简单的去应用。随之而来的,你会发现伴随着许多不同的坑,包含不规范的应用导致性能的下降和崩溃问题。这些都会留在后面我们去解决。
]]>SIMD 是一种常见的利用单指令完成多数据量处理的计算方式。本文作为 SIMD 文章的引子,先来了解简单的 SIMD 使用和概念。
相信从事移动开发的朋友们肯定看到过一个表情包:“iOS 开发没人要啦”。
虽说是搞笑之图,却也反映了移动开发领域的部分焦虑感。网上甚至有文章贴出“难上加难”的数据,称:“相比于 2017 年,2018 年 Android 程序员人均面邀数减少40%,iOS 程序员降幅更高达57%,即平均每个移动端程序员在找工作时收到的面邀数比去年减少一半。”
撇开玩笑之言,移动开发人员的焦虑感来自何处?我从自身角度及与他人沟通,大致归纳出如下几点:
细细品味这三点,我想开发者在面临业界趋势转移,担忧自身竞争力不足才是焦虑产生的内在根本。我曾和几个国内知名的 iOS 开发者闲聊,他们表示:都 9102 年了,从大量公开的文章来看,大家还是局限于研究 Runtime,Runloop,block 源码分析等一些比较缺少创新的知识点,让人感受行业的停滞不前。
当然,也有不少开发者在积极拥抱新技术。身边的许多朋友也在了解机器学习,自学相关课程等。但是其中大部分都反馈:学完了基础知识,不知道如何应用;也不知道这些东西能对自己日常工作带来怎样的帮助。最终的结果就演变成了学了就忘,无法产生实质价值。
那是不是事情就此陷入了僵局呢?抱着怀疑及学习的态度,我在2018年中旬加入了手淘-端智能组,参与了一款名叫 MNN 的深度推理引擎的研发工作。这一年多的开发过程,让我对加深了对机器学习 / 深度学习的理解。但更重要的是,这一年多的亲身经历,让我对过去的观点产生了颠覆式的看法。
在这里,我并不想探讨如何学习机器学习,因为这样的文章数量已经浩瀚如海;相反地,我希望通过这篇文章,阐述在开发推理引擎 MNN 的过程中,我的思考与收获;希望给许多曾和我一样迷茫的移动开发者,一些亲历的感受和信心。
节约篇幅直接贴出 MNN 的 Github 地址:https://github.com/alibaba/MNN
相信有不少同学都曾和我一样,在了解机器学习的初期被诸多的公式推导所吓退,担心这是一个充斥着算法、数学、理论证明的技术领域。
这个观点没错,如果你想要设计出经典的 MobileNet、ResNet 这样的深度神经网络或者是对 Yolo 这样的结构进行复杂度优化,如 Yolo V3 等,你势必要对数学证明、算法优化等方面有较深刻的理解,从这个角度看,说一句很残酷的话:移动工程师跨界的机会不大。
但是机器学习是不是只有算法?这个观点是偏颇的,机器学习本质上是一个工程开发、算法优化与实际应用结合的领域。
用 深度学习领域的知名大牛 贾扬青 的观点来看:AI 是一个系统工程,90%的工作在算法之外。
换句话说,机器学习还包含系统工程这个范畴。往小了说,模型可视化工具、转换工具;往大了讲,学术界探索机器学习的编译优化系统,比如陈天奇提出的 TVM 等等,这都是机器学习的一部分。
上图是 MNN 官方在 Netron 上维护的可视化框架,我们应该是国内第一个主动支持可视化能力的深度学习推理引擎。
因此,对于我们移动开发者来说,我们更适合从系统工程的角度,通过实际编程解决问题,去探索机器学习。
备注:这个观点并不是我自己想象出来。大家可以看看机器学习泰斗级人物 Jeff Dean 和李飞飞等人在2017年发表的机器学习系统白皮书。SysML: The New Frontier of Machine Learning Systems
如同大家学习编程时听过的那样,算法和数据结构是核心能力,一通百通。那么从系统工程的角度来看,无论是机器学习抑或是移动开发,存在诸多共通点是可以相互借鉴。限于篇幅,我仅仅列举几点能够切实帮助我自身日常开发的:
曾有人戏言“移动开发就是 UITableView + JSON”。虽然是句玩笑话,但也能看出数据传输在移动开发中的重要性。从个人经验来看,绝大多数的移动端数据传输协议基本都采用了 JSON(可能部分公司设计了自己的数据协议)。但是 JSON 存在几个缺点(不考虑优化的前提):
为了解决类似的问题,一些新的数据协议,如 FlatBuffer 也渐渐进入大家的视线之中。尽管之前就对其有所耳闻,但是真的深入了解还是要追溯到开发推理引擎的过程中。在设计机器学习模型存储结构中,大名鼎鼎的 TFLite,MNN 等框架都采用了 FlatBuffer,这是一种具备 Access to serialized data without parsing/unpacking
的存储结构。它不仅减少了模型的存储大小、提升了性能,也对模型结构扩展、解析自描述起到了巨大的帮助。
尤其是协议自解析方面,真是令我大开眼界。简单来说,你只要按照 FlatBuffer Schema 要求的方式定义你的数据结构,剩下的编码 / 解析的过程都自动化完成。
这里以 MNN 框架中的 FlatBuffer 的使用举例,比如整个神经网络的拓扑架构定义如下:
1 | table Net { |
整体 MNN 中 Schema 的设计可以参考:https://github.com/alibaba/MNN/tree/master/schema/default
然后我们通过一行简单的命令(这里仅作演示举例)就可以自动生成 JavaScript 的对应代码。
1 | ./flatc -s -I ~/MNN/schema/default ~/MNN/schema/default/MNN.fbs |
1 | /** * @constructor */ MNN.Net = function() { /** * @type {flatbuffers.ByteBuffer} */ this.bb = null; /** * @type {number} */ this.bb_pos = 0; }; /** * @param {number} i * @param {flatbuffers.ByteBuffer} bb * @returns {MNN.Net} */ MNN.Net.prototype.__init = function(i, bb) { this.bb_pos = i; this.bb = bb; return this; }; /** * @param {flatbuffers.ByteBuffer} bb * @param {MNN.Net=} obj * @returns {MNN.Net} */ MNN.Net.getRootAsNet = function(bb, obj) { return (obj || new MNN.Net).__init(bb.readInt32(bb.position()) + bb.position(), bb); }; /** * @param {flatbuffers.ByteBuffer} bb * @param {MNN.Net=} obj * @returns {MNN.Net} */ MNN.Net.getSizePrefixedRootAsNet = function(bb, obj) { return (obj || new MNN.Net).__init(bb.readInt32(bb.position()) + bb.position(), bb); }; /** * @param {number} index * @param {MNN.TensorDescribe=} obj * @returns {MNN.TensorDescribe} */ MNN.Net.prototype.extraTensorDescribe = function(index, obj) { var offset = this.bb.__offset(this.bb_pos, 6); return offset ? (obj || new MNN.TensorDescribe).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + offset) + index * 4), this.bb) : null; }; /** * @returns {number} */ MNN.Net.prototype.extraTensorDescribeLength = function() { var offset = this.bb.__offset(this.bb_pos, 6); return offset ? this.bb.__vector_len(this.bb_pos + offset) : 0; }; /** * @param {number} index * @param {MNN.Op=} obj * @returns {MNN.Op} */ MNN.Net.prototype.oplists = function(index, obj) { var offset = this.bb.__offset(this.bb_pos, 10); return offset ? (obj || new MNN.Op).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + offset) + index * 4), this.bb) : null; }; |
而用户在代码中使用这个拓扑结构,只要简单调用入口函数 getRootAsNet ,剩下来的一切都自动化完成。而当你要修改结构定义的时候,仅仅需要修改对应的 Schema 文件,重新生成对应的解析文件,无需人工逐字段手工修改。
限于篇幅有限,这里不过多展开对 FlatBuffer 的介绍,感兴趣的读者可以阅读 MNN 用户自发写的博客《FlatBuffers,MNN模型存储结构基础 —- 无法解读MNN模型文件的秘密》。
那这样的协议能不能应用于移动开发中并起到正向的作用呢?答案是肯定的,有兴趣的朋友可以阅读 Facebook 的相关文章。
部分读者可能知道,我和几位同事在知乎上开了一个专栏《iOS调试进阶》,重点分享 ARM 相关的汇编知识。会有这个想法是因为日常工作中排查许多 Crash 的时候,从源码层面已经无法定位,必须要依赖计算机执行的本质 - 机器码进行分析,而这正是汇编可以产生价值的地方。
但是汇编不仅仅局限于排查 Crash。在开发 MNN 过程中,涉及了大量的密集型计算操作。团队的一些大牛在指令实现层面根据流水线编排、硬件大小核数、缓存大小等等,使用手写汇编来精细化调度数据的读写与执行,使得MNN 的推理性能达到了业界一流的水准(无论是我们自己的 benchmark 抑或是利益无关的友商的评测都证明了这一点)。而阅读这些精心酿造的汇编代码,会让你感到,原来开发还能这么玩!
这里展示一个经典的 Bilinear 插值通过汇编的实现:
1 | text |
相信我,当你从不懂汇编 -> 读懂汇编 -> 手写汇编,每前进一步,你会发现更广阔的天地。有一天当你要做性能优化,发现许多网上常见的手段都使用过了但仍然不起作用的时候,也许汇编就是你杀手锏。
近些年来随着短视频的崛起,市面上渲染、多媒体相关的岗位也越加变得火热。而这些岗位无一例外都需要对 GPU 有着深度的了解。而操作 GPU,自然而然就少不了与 Shader 打交道。
Shader 其实就是专门用来渲染图形的一种技术。通过 Shader ,我们可以自定义显卡渲染画面的算法,使画面达到我们想要的效果。
但 Shader 的作用不仅仅作用于渲染。在机器学习领域,苹果的 Metal 框架所包含的Metal Performance Shader(MPS)也能用来做 GPU 计算,提升机器学习在移动端的执行性能。就连诞生已久的 OpenGL,也在最新的 OpenGL 3 标准中增加了计算纹理,支持 GPU 计算的能力。由此可见,尽管最初的目的并不相同,但是技术本质是相通的,最后都会产生微妙的化学反应。
上述几点,仅仅是个人抛砖引玉,展示机器学习和日常移动开发相互交织的冰山一角。从工程实现的角度,仍有许多值得探索并实践应用的,欢迎大家一起探讨交流。
读到这,可能有些读者内心的兴奋之情被熊熊点燃,恨不得立刻能将相关的知识学习起来;但也有部分朋友会觉得,可能只有 BAT 这样的大厂才会有实际的场景需要进行如此深入的研究和开发工作,有沮丧之情。
我对这种体会特别感同身受,因为去年刚转型开发 MNN 之初,我也有过手足无促,连简单的 Metal Performance Shader 都写不好。加上之前有些朋友通过 QCon 和云栖大会听闻了 MNN,也和我或其他同事进行过一些实现上或者应用方面的探讨。
因此,借着这个机会,除了希望通过这篇文章带领大家对【机器学习系统】有一个全新的认知之外,后续也会以连载的方式,在以下两个方面给大家继续带来更多有价值的点:
技术介绍,我会把 MNN 里面使用的相关技术点,逐个拆解,带领大家通过理论探索和实际编程相结合的方式来深入了解细节,反哺于大家日常的开发工作。
最佳实践,目前在客户端领域应用机器学习的典型案例还比较缺乏。而我正好在过去一年多的时间里,探索了诸多的实践案例(比如大家耳熟能详的拍立淘、淘宝直播、AR试妆等等中都有 MNN 的身影哦~),我也会将其整理分享出来,和大家一起探索端智能的前行之路。
对了,写了这么多文字,还请读者们见谅,允许我打个招人合作的广告吧:
MNN 是阿里巴巴开源的一款轻量级、高性能深度学习推理引擎,用于解决深度神经网络模型在端侧推理运行问题。
从今年4月份开源到现在,我们始终在完善和响应社区用户的诉求,并保持着每两个月一次重大 Feature Release 的发布频率。
但我们团队的力量是有限的,而端上智能应用前进的道路仍然充满着广阔未被探索的区域,我们希望和大家一起教学相长,携手进步。
如果大家对移动端机器学习有什么好的想法和建议,也可以前往 Github 上,给我们反馈。
也欢迎对 MNN 感兴趣的朋友,扫描二维码,加入钉钉群,和我们一起交流或者直接加入我们团队哦(悄悄地说,人气有点高,一群都满啦)
用钉钉群比较方便我们在工作闲暇时间及时响应大家的问题和诉求,请大家多包涵啦~
本文记录了过去一年多,个人参与 MNN 框架相关开发过程中的一些收获与心得。如何不分裂的看待机器学习与移动开发的关系,如何从看似不相关的领域寻找共同点,提升自己所处领域的价值和核心能力,是值得我们每位开发同学需要思考的。
在最后,还是要说一句:移动客户端的从业人员并不需要过多的焦虑和担忧,动态化、高性能、内核、渲染等等方向都充满前景。但是,你需要找到你所擅长且愿意为之深入的,这才是你保证在浪潮中不被拍翻的核心竞争力。
]]>相信从事移动开发的朋友们肯定看到过一个表情包:“iOS 开发没人要啦”。
在Revisit iOS Autorelease(一)中,按照我的示例我提及,是如下这段代码对基于TLS
的优化产生了影响:
// Debug 模式
for (Model *m in self.models) {
}
这段看似平平无奇的代码为啥会造成优化失效,让我们还是从汇编角度来看看:
0x100011e90 <+568>: ldr x0, [sp, #0x10]
// 【注意点】:正常情况下应该是b objc_autoreleaseReturnValue
0x100011e94 <+572>: bl 0x100012904 ; symbol stub for: objc_autoreleaseReturnValue
0x100011e98 <+576>: adrp x8, 3
0x100011e9c <+580>: ldr x8, [x8, #0x8]
0x100011ea0 <+584>: ldr x8, [x8]
0x100011ea4 <+588>: ldur x9, [x29, #-0x18]
0x100011ea8 <+592>: cmp x8, x9
0x100011eac <+596>: str x0, [sp, #0x8]
0x100011eb0 <+600>: b.ne 0x100011ec8 ; <+624> at Container.m
0x100011eb4 <+604>: ldr x0, [sp, #0x8]
0x100011eb8 <+608>: ldp x29, x30, [sp, #0x170]
0x100011ebc <+612>: ldp x28, x27, [sp, #0x160]
0x100011ec0 <+616>: add sp, sp, #0x180 ; =0x180
0x100011ec4 <+620>: ret
0x100011ec8 <+624>: bl 0x1000128d4 ; symbol stub for: __stack_chk_fail
0x100011ecc <+628>: brk #0x1
按照符号优化的场景,LR
寄存器的地址需要指向获取Model
的外部调用方才能产生正确的优化,因此正常情况下应该直接b objc_autoreleaseReturnValue
即可,而这里对应的汇编却是bl
,说明执行完objc_autoreleaseReturnValue
后还要继续从0x100011e98 <+576>: adrp x8, 3
往后执行。
虽然这么一大段汇编很难具体了解做的每一件事的意义,但是从几个关键点上我们可以描绘出一个轮廓:
cmp x8, x9
肯定在试图检查什么条件。b.ne 0x100011ec8
,如果条件满足,继续走(1),否则走(2)ret
,说明这是正确的流程。__stack_chk_fail
,暂且不管。但是紧跟着就是brk
。而brk
简单来讲,就是触发崩溃或者异常。整体轮廓搞定后,我们再来看看stack_chk_fail
到底是啥。从stack
中我们不难推断,这肯定是和栈相关的检查工作。那为什么会有这样的检查工作?主要还是害怕栈越界造成的危害。用下图来大致讲解吧。
这里抄了张
armv7
的图,大致意思没差别。
局部变量和保存函数调用上下文的LR
, FP
都存在栈上。假设我们的局部变量是个大小为2的数组,但是我如果不小心写出了*(addr + 3) = 5。是不是相当于数组越界,破坏了紧邻着的其他栈内容。如果这个栈内容是重要的上下文信息,那就完蛋了。
那栈越界究竟有什么具体事例呢?嘿嘿,欢迎加入阿里巴巴来内网看我写的关于xxx
问题的分析,你就知道了。
所以,在LLVM::CodeGen里面,就帮我们做了这样的栈越界检查(当然对于很多动态的数组也是没法完全防护的),在StackProtector.cpp
中:
bool StackProtector::InsertStackProtectors() {
// Loop through the basic blocks that have return instructions. Convert this:
//
// return:
// ...
// ret ...
//
// into this:
//
// return:
// ...
// %1 = load __stack_chk_guard
// %2 = load <stored stack guard>
// %3 = cmp i1 %1, %2
// br i1 %3, label %SP_return, label %CallStackCheckFailBlk
//
// SP_return:
// ret ...
//
// CallStackCheckFailBlk:
// call void @__stack_chk_fail()
// unreachable
//
BasicBlock *FailBB = 0; // The basic block to jump to if check fails.
AllocaInst *AI = 0; // Place on stack that stores the stack guard.
Constant *StackGuardVar = 0; // The stack guard variable.
for (Function::iterator I = F->begin(), E = F->end(); I != E; ) {
BasicBlock *BB = I;
if (ReturnInst *RI = dyn_cast<ReturnInst>(BB->getTerminator())) {
if (!FailBB) {
// Insert code into the entry block that stores the __stack_chk_guard
// variable onto the stack.
PointerType *PtrTy = PointerType::getUnqual(Type::Int8Ty);
StackGuardVar = M->getOrInsertGlobal("__stack_chk_guard", PtrTy);
BasicBlock &Entry = F->getEntryBlock();
Instruction *InsPt = &Entry.front();
AI = new AllocaInst(PtrTy, "StackGuardSlot", InsPt);
LoadInst *LI = new LoadInst(StackGuardVar, "StackGuard", false, InsPt);
Value *Args[] = { LI, AI };
CallInst::
Create(Intrinsic::getDeclaration(M, Intrinsic::stackprotector_create),
&Args[0], array_endof(Args), "", InsPt);
// Create the basic block to jump to when the guard check fails.
FailBB = CreateFailBB();
}
- Function::iterator InsPt = BB; ++InsPt; // Insertion point for new BB.
++I; // Skip to the next block so that we don't resplit the return block.
// Split the basic block before the return instruction.
BasicBlock *NewBB = BB->splitBasicBlock(RI, "SP_return");
- // Move the newly created basic block to the point right after the old basic
- // block so that it's in the "fall through" position.
+ // Move the newly created basic block to the point right after the old
+ // basic block so that it's in the "fall through" position.
NewBB->removeFromParent();
- F->getBasicBlockList().insert(InsPt, NewBB);
+ F->getBasicBlockList().insert(I, NewBB);
// Generate the stack protector instructions in the old basic block.
LoadInst *LI1 = new LoadInst(StackGuardVar, "", false, BB);
CallInst *CI = CallInst::
Create(Intrinsic::getDeclaration(M, Intrinsic::stackprotector_check),
AI, "", BB);
ICmpInst *Cmp = new ICmpInst(CmpInst::ICMP_EQ, CI, LI1, "", BB);
BranchInst::Create(NewBB, FailBB, Cmp, BB);
} else {
++I;
}
}
// Return if we didn't modify any basic blocks. I.e., there are no return
// statements in the function.
if (!FailBB) return false;
return true;
}
/// CreateFailBB - Create a basic block to jump to when the stack protector
/// check fails.
BasicBlock *StackProtector::CreateFailBB() {
BasicBlock *FailBB = BasicBlock::Create("CallStackCheckFailBlk", F);
Constant *StackChkFail =
M->getOrInsertFunction("__stack_chk_fail", Type::VoidTy, NULL);
CallInst::Create(StackChkFail, "", FailBB);
new UnreachableInst(FailBB);
return FailBB;
}
还是比较容易看懂的,这里就不过多解释了。
autorelease
相关的文章网上不在少数,但是大多数都大同小异,只是在讲libobjc
中的代码实现。但是深究我们日常编码过程中的autorelease
,其实有不少被我们所忽视的细节值得深挖研究。(不挖还容易踩坑)
最后按照惯例,以一首诗致敬伟大的90后iOS
第一人Y帝:
吾辈有Y帝,技术特牛逼。
胸有中国情,一人虐美帝。
Google服务器,Y帝轻松逆。
苹果App,他天天Patch。
微软的程序,总被他蓝屏。
川普各手机,监听so easy。
为躲粉丝迷,转行写程序。
90后第一,当代方世玉!
]]>本文的硬核在第二段
之前在做某项目的时候,自建了基于NSThread
的私有线程池,在线程池分配了固定个数的常驻工作线程,在工作线程里面运行相关任务;这个方案取代了原先直接无脑使用GCD
的方式,在各方面效果都还不错。
但是在一次偶然的情况下,通过Memory Graph
发现很多任务对象却在本该早就销毁的时候仍然存活着。持有其的对象是autorelease content
,如下图所示:
我把数据对象类型隐藏了,公司数据还是要保密。
那这个东西究竟是个啥呢?
由于其是黄色图标,基本上是一个容器类型或其子类。
这个类型@autoreleasepool content
先不管,先从右边的堆栈来看:
autorelease
相关的APIautoreleasepool
。因此需要调用autoreleaseNoPage
而autoreleaseNoPage
其实本质上就是在当前线程没有autoreleasePage
的时候,创建一个。然后通过Thread Local Storage
存入线程相关上下文中。
static __attribute__((noinline))
id *autoreleaseNoPage(id obj)
{
// "No page" could mean no pool has been pushed
// or an empty placeholder pool has been pushed and has no contents yet
assert(!hotPage());
bool pushExtraBoundary = false;
if (haveEmptyPoolPlaceholder()) {
// We are pushing a second pool over the empty placeholder pool
// or pushing the first object into the empty placeholder pool.
// Before doing that, push a pool boundary on behalf of the pool
// that is currently represented by the empty placeholder.
pushExtraBoundary = true;
}
else if (obj != POOL_BOUNDARY && DebugMissingPools) {
// We are pushing an object with no pool in place,
// and no-pool debugging was requested by environment.
_objc_inform("MISSING POOLS: (%p) Object %p of class %s "
"autoreleased with no pool in place - "
"just leaking - break on "
"objc_autoreleaseNoPool() to debug",
pthread_self(), (void*)obj, object_getClassName(obj));
objc_autoreleaseNoPool(obj);
return nil;
}
else if (obj == POOL_BOUNDARY && !DebugPoolAllocation) {
// We are pushing a pool with no pool in place,
// and alloc-per-pool debugging was not requested.
// Install and return the empty pool placeholder.
return setEmptyPoolPlaceholder();
}
// We are pushing an object or a non-placeholder'd pool.
// Install the first page.
AutoreleasePoolPage *page = new AutoreleasePoolPage(nil);
setHotPage(page);
// Push a boundary on behalf of the previously-placeholder'd pool.
if (pushExtraBoundary) {
page->add(POOL_BOUNDARY);
}
// Push the requested object or pool.
return page->add(obj);
}
同时由于是第一个page
,连父子关系都不用串联,非常简单。
但是,其他线程有一点相对主线程比较坑的就是子线程默认没有runloop
,导致在释放被autoreleasepool
的对象的时候产生着问题。
那么子线程的autoreleasepool
在没有runloop
的情况下何时释放呢?
autoreleasepool drain
的时候第二点比较好理解,就是常规的page push
以及对应的page pop
。
那么线程退出释放是如何确定的呢?我们在线程退出的时候下个断点:
static void tls_dealloc(void *p)
{
if (p == (void*)EMPTY_POOL_PLACEHOLDER) {
// No objects or pool pages to clean up here.
return;
}
// reinstate TLS value while we work
setHotPage((AutoreleasePoolPage *)p);
if (AutoreleasePoolPage *page = coldPage()) {
if (!page->empty()) pop(page->begin()); // pop all of the pools
if (DebugMissingPools || DebugPoolAllocation) {
// pop() killed the pages already
} else {
page->kill(); // free all of the pages
}
}
// clear TLS value so TLS destruction doesn't loop
setHotPage(nil);
}
而在runtime
初始化的过程中,会调用AutoReleasePoolPage::init
方法注册tls_dealloc
:
static void init()
{
int r __unused = pthread_key_init_np(AutoreleasePoolPage::key,
AutoreleasePoolPage::tls_dealloc);
assert(r == 0);
}
结合这两段代码,我们大致可以猜测下phtread_key_init_np
是将tls_dealloc
注册给某个回调使用。那具体是干嘛的?
实际上phtread_key_init_np
时给thread
注册了线程销毁时的自定义析构函数,这里我们可以一起来看看darwin-apple
的libpthread
代码,这里我直接简化掉流程,输出大致的过程:
_pthread_exit
在线程销毁时调用 -> _pthread_tsd_cleanup
-> _pthread_tsd_cleanup_new
-> _pthread_tsd_cleanup_key
。
在最终的函数里,会遍历所有的自定义销毁函数,逐个触发:
static void
_pthread_tsd_cleanup_key(pthread_t self, pthread_key_t key)
{
void (*destructor)(void *);
if (_pthread_key_get_destructor(key, &destructor)) {
void **ptr = &self->tsd[key];
void *value = *ptr;
if (value) {
*ptr = NULL;
if (destructor) {
destructor(value);
}
}
}
}
因此,对于我这样设计了常驻线程的“不死线程”来说,无法指望线程销毁时候的释放,必须自己引入autoreleasepool
来修正内存没释放干净!
autorelease
持有?本文的重点来了。
其实网上关于autoreleasepage
相关的文章分析的很多了,我这篇文章的主要目的还是想思考下,看看平常无奇的代码,究竟会在什么情况下触发autorelease
及其相关行为。如果说所有的东西都是直接了当的引用计数相加减如objc_storeStrong / objc_storeStrong(nil)
,何须多此一举引入autorelease
呢?
网上许多的文章的结论基本上都是:
编译器为判断方法名是否是以
alloc/new/copy/mutableCopy
开头,如果不是,就自动将返回的对象注册到池子中。
编译器会在objc_autoreleaseReturnValue
和objc_retainAutoreleasedReturnValue
进行基于TLS的判断优化,本质上也不会走入autorelease的环节。
为了验证这些结论,我首先重温了下《iOS 内存高级编程》一书,它所阐述的都是内存管理的思想,以alloc/mew/copy/mutableCopy
驼峰命名开头的方法,方法的对象由调用者自己持有;而其他方法是取得非自己生成并持有的对象
卧槽,真拗口。
写个Demo验证下,
@interface Model : NSObject
- (Model *)haha;
@end
- (Model *)haha
{
// 这里是为了避免调用系统库的对象可能在存在某些MRC的情况导致无法优化。
return [[Model alloc] init];
}
// 调用
Model *model = [[Model alloc] init];
Model *m2 = [model haha];
万变不如汇编,让我们先来看第一条调用的汇编代码。
0x100046768 <+96>: bl 0x100046b80 ; symbol stub for: objc_msgSend
0x10004676c <+100>: adrp x8, 2
0x100046770 <+104>: add x8, x8, #0xd38 ; =0xd38
0x100046774 <+108>: ldr x1, [x8]
0x100046778 <+112>: bl 0x100046b80 ; symbol stub for: objc_msgSend
0x10004677c <+116>: mov x8, #0x0
0x100046780 <+120>: add x9, sp, #0x8 ; =0x8
0x100046784 <+124>: str x0, [sp, #0x8]
0x100046788 <+128>: mov x0, x9
0x10004678c <+132>: mov x1, x8
0x100046790 <+136>: bl 0x100046bb0 ; symbol stub for: objc_storeStrong
很明显的,并没有涉及到任何的和retainAutorelease/autorelease
相关的调用。
需要注意:在release优化下这里的
objc_storeStrong(nil)
会直接优化成objc_release
而对于第二条调用,汇编如下:
0x10006275c <+140>: bl 0x100062b80 ; symbol stub for: objc_msgSend
0x100062760 <+144>: mov x29, x29
0x100062764 <+148>: bl 0x100062ba4 ; symbol stub for: objc_retainAutoreleasedReturnValue
关键字出现了,当然具体会不会进入autorelease的环节,还需要看优化的效果,我们进入haha
函数看一看:
0x100096b28 <+36>: bl 0x100096b80 ; symbol stub for: objc_msgSend
0x100096b2c <+40>: adrp x1, 2
0x100096b30 <+44>: ldr x1, [x1, #0xd38]
0x100096b34 <+48>: bl 0x100096b80 ; symbol stub for: objc_msgSend
0x100096b38 <+52>: ldp x29, x30, [sp, #0x10]
0x100096b3c <+56>: add sp, sp, #0x20 ; =0x20
0x100096b40 <+60>: b 0x100096b74 ; symbol stub for: objc_autoreleaseReturnValue
也和我们预测的一样,确实有着objc_autoreleaseReturnValue
,那么究竟会不会有基于TLS的优化行为呢?对objc_autoreleaseReturnValue
下个符号断点:
libobjc.A.dylib`objc_autoreleaseReturnValue:
-> 0x18563e528 <+0>: ldr w8, [x30]
0x18563e52c <+4>: mov w9, #-0x55e30000
0x18563e530 <+8>: movk w9, #0x3fd
0x18563e534 <+12>: cmp w8, w9
0x18563e538 <+16>: b.ne 0x18563e550 ; <+40>
0x18563e53c <+20>: mrs x8, TPIDRRO_EL0
0x18563e540 <+24>: and x8, x8, #0xfffffffffffffff8
0x18563e544 <+28>: orr w9, wzr, #0x1
0x18563e548 <+32>: str x9, [x8, #0x160]
0x18563e54c <+36>: ret
0x18563e550 <+40>: b 0x18563c130 ; objc_autorelease
这里,偏移 +16的地方的b.ne
就是对优化的判断,判断的条件是w8
和w9
的相等与否,不等就走传统的objc_autorelease
。
这里经过断点我们发现确实走了优化。
那按照这个思路,难道真的在如今的ARC下,没有东西要进autoreleasepool
了?那为什么还会在MemoryGraph
中出现大量autorelease content
呢?
答案可能出乎你的意料,for
会影响这个autorelease
优化逻辑。
我们构建一个两个线程的场景,Model
类型如上述文章段落不变。构建一个符合类型Container
,包含一个NSMutableArray
的数组:
@interface Container()
@property (nonatomic, strong) NSMutableArray *models;
@end
@implementation Container
- (instancetype)init
{
self = [super init];
if (self) {
_models = @[].mutableCopy;
}
return self;
}
- (void)addModel:(Model *)model
{
if (!model) return;
[self.models addObject:model];
}
- (Model *)takeModel
{
//NSMutableArray *toOperateArray = self.models;
for (Model *model in self.models) {
}
Model *model = [self.models firstObject];
[self.models removeObject:model];
return model;
}
为了简化场景,我先在ViewController viewDidLoad
塞入几个Model
到Container
,然后再启动第二个线程从Container
中取Model
。
测试场景如下:
- (void)viewDidLoad {
[super viewDidLoad];
self.container = [[Container alloc] init];
for (int i = 0; i < 10; i++) {
[self.container addModel:[[Model alloc] initWithCount:i]];
}
self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(loop) object:nil];
[self.thread setName:@"com.walle.test"];
[self.thread start];
}
- (void)loop
{
while (true) {
Model *m = [self.container takeModel];
[m increment];
}
}
如果你执行我这段代码,你会发现的确如文章一开头所示,MemoryGraph
中存在大量被@autoreleasepool content
持有的Model
。
那罪魁祸首是什么呢?从表象上看是这段并不起眼的代码:
for (Model *model in self.models) {
}
可具体原因是为啥呢?还是从汇编上来摸索下:
首先先回到没有汇编的场景上,调用的函数是-[ViewController loop]
,被调用者是-[Container takeModel:]
如果要进行优化,按照objc_autoreleaseReturnValue:
的逻辑,在loop
调用takeModel:
的地方必须有对应的暗示:这个暗示在arm64
中如下代码所示:
static ALWAYS_INLINE bool
callerAcceptsOptimizedReturn(const void *ra)
{
// fd 03 1d aa mov fp, fp
// arm64 instructions are well-aligned
if (*(uint32_t *)ra == 0xaa1d03fd) {
return true;
}
return false;
}
简单来说,要有mov fp, fp
,而fp
就是x29
寄存器。那我们来看看loop
的对应汇编:
哈哈,0x62b0的地方果然是mov x29, x29
。
如果你对静态分析的结果不熟悉,可以动态进入汇编。在obc_autoreleaseReturnValue
下符号断点,得到
如果你输出x30
寄存器的值(注意不是把寄存器的值当地址再取值)然后再减去所在二进制的基地址,会发现偏移正正好好也是0x62b0
而如果你加上之前提到的for
循环代码,再断到obc_autoreleaseReturnValue
去查看x30
的值,计算偏移量会得到:0x0000000000005e98
。
而对应到二进制里是:
看到没,这里调用objc_autoreleaseReturnValue
走的是bl
,也就是会修改LR
寄存器,而LR
寄存器的值就是调用后的返回地址5e98
。而LR
寄存器本身就是x30
,导致autorelease
的优化失效。
至此,我们终于发现了为什么我们的数据会被所谓的@autoreleasepool content
持有。
虽然正如网上很多文章所述,子线程确实会对autoreleasepool
进行自动的管理避免内存泄漏。但是,由于诸多场景导致的释放时机变更,会产生诸多的内存不释放(并非是内存泄漏,Leaks
是查不出来的),也会对App
的稳定性造成巨大的影响。
更重要的是,基于这种TLS的优化很有可能被我们不知情下编写的代码所改变,产生奇怪的问题,因此要特别注意。
下文我会从编译以及代码生成的层面来探讨为什么会产生这种不同的汇编代码。
]]>本文的硬核在第二段
之前在做某项目的时候,自建了基于NSThread
的私有线程池,在线程池分配了固定个数的常驻工作线程,在工作线程里面运行相关任务;这个方案取代了原先直接无脑使用<]]>
C++
的智能指针指向被管理对象的raw ptr
会被栈内存溢出而破坏,而利用智能指针进行对象构造的管理和设计,可以衍生出和RAII
的结合,今天就来谈谈这项技术。
RAII
是Resource Acquisition Is Initialization
,简而言之就是将对一个资源的申请封装在一个对象的生命周期内的理念。这样做的好处就是,C++的对象势必在创建的时候会经过构造函数,而在销毁的时候会触发析构函数。
听起来有点绕是不是,让我们来简化一下其主要特点。
前两点都比较容易,那么第三点如何达到呢?
合理的利用局部变量。
绝大多数语言,比如C++
,都居于块级作用域。当在创建的变量离开其所在的块级时候,就会触发释放。而这就可以达到我们所说的自动管理对象。
这其实就是压栈/出栈的高级语言表现。
而在C++
领域,有一个比较经典的利用RAII
特性的设计就是ScopeLock。
template<class LockType>
class My_scope_lock
{
public:
My_scope_lock(LockType& _lock):m_lock(_lock)
{
m_lock.occupy();
}
~My_scope_lock()
{
m_lock.relase();
}
protected:
LockType m_lock;
}
在这里,锁被看成是一种资源,他需要lock/unlock的配对操作,不然就会引发问题。
而上述代码,将锁保留在对象的构造函数和西沟函数中。这样,当我们在某个函数中需要操作临界区域的时候,就可以简洁明了的使用局部变量来操作锁:
void Data::Update()
{
My_scope_lock l_lock(m_mutex_lock);
// do some operation
}
上文我们用锁的例子来举例说明了RAII
的设计理念,那什么又是基于智能指针的RAII
呢?
我们都知道,在编程过程中,我们必须和内存打交道,而内存分为了两种类型:栈上内存和堆上内存。栈上内存不仅和线程相关,同时空间大小也相对堆内存来说非常小。因此,当我们在处理一些大规模数据(以及对象规模不确定)的时候,比如使用几百个对象的数据等等,一般都采用堆上动态分配内存。
但是堆上内存,在诸多的语言中,都需要手动管理,比如C++
。而一般处理不当,比如(new []和delete搭配),或者遗忘了释放,那么就会产生内存泄漏等严重问题。
为此,我们参考上节的设计,准备构建一个可以在对象的构造/析构函数中成对正确释放内存的设计思路。
先假设一个需要在堆上频发操作的对象Data
class Data {
// 省略
}
如果直接使用,一般情况下是这样的代码:
Data *data = new Data();
delete data;
需要频繁的确认对堆内存的正确使用。现在我们给他加一个包装对象,DataHandle
class DataHandle {
private:
Data *m_data;
}
DataHandle::DataHandle():m_data(new Data())
{}
DataHandle::~DataHandle()
{
delete m_data;
m_data = NULL;
}
这样,我们后续每次使用,就可以简化成
{
DataHandle handle;
}
但是,别忘记了,C++
中海油拷贝构造和重载赋值等操作,一旦我们写出如下代码,就会引发double free
的问题。
{
DataHandle handle1(handle);
handle1 = handle;
}
因此,我们需要对拷贝构造函数和重载赋值进行特别处理。这里有两种处理方式:
m_data
也拷贝new
一次。m_data
进行合理的计数记录。一般情况下,我们期望DataHandle
的行为和Data
是一致的。 因此我们想使用第二种方式。
这个时候,C++
的shared_ptr
就派上用场了。改写下DataHanle
class DataHandle{
public:
DataHandle();
DataHandle(const DataHandle &handle);
DataHandle& operator=(const DataHanlde &handle);
private:
std::shared_ptr<Data> m_dataS;
}
对于重载后的拷贝/复制函数,我们只要利用智能指针自身重载过的赋值操作赋,即可解决引用计数问题。
最后要特别注意的是,下述两种情况的代码,是完全不相同的含义。
// 第一种情况
Data *data = new Data();
std::shared_ptr<Data> s1 = std::shared_ptr(data);
std::shared_ptr<Data> s2 = s1;
// 第二种情况
Data *data = new Data();
std::shared_ptr<Data> s1 = std::shared_ptr(data);
std::shared_ptr<Data> s2 = std::shared_ptr(data);
]]>C++
的智能指针指向被管理对象的r]]>
其实文章关于传统
Section Based Linker
那块我还没怎么读懂,有兴趣的欢迎互相探讨。
之前研究Xcode 10
兼容libstdc++
的时候,稍微把玩了下苹果的LD
,借这个机会正好通读了下苹果的LD
设计,本文做一下总结。
苹果的LD
,核心理念就是基于Atom
和FixUp
,拿着两个术语是啥意思呢?
Atom
就是一块代码(函数)或者数据(全局变量)之类的,每个Atom
都有一些属性,比如名称、作用域、内容类型、字节对齐之类的。Fixup
可以理解为一个包含种类、便宜、辅助加数以及目标Atom
的数据结构。有点抽象对吧?概要来说,苹果LD
通过Atom
和FixUP
构建一张图,图中的节点都是Atom
,连接Atom
的则是FixUP
。
通过构建这样一张图,苹果就可以在链接期间进行一系列的优化,比如死代码剔除,怎么做呢?比如一段代码也会被抽象成Atom
,如果没有FixUP
连接的Atom
就可以进行剔除
举一个简单的小例子main.c
:
#include <stdio.h>
int main()
{
printf("hello world");
return 0;
}
会被抽象出如下行为:
单独编译这个.c
文件生成的.o
会包含两个atom
,一个是main
函数,另外一个是C
字符串"hello world"
printf 本质上也会一个
atom
,但是在这个编译单元内他还没加入图中。
而fixup
也存在两个,一个是去调用不知道在哪的函数printf
的调用fixup
,一个是去加载字符串的fixup
。
链接过程的第一步就是要处理输入文件,构建一张初始图。
.o
,那么所有的atom
都会被加入到初始图当中。.o
文件包含一个目录),初始状态下这里面的atom
都默认不会加入到里面,当LD
不断初始图中有没被决议的fixup
,如果fixup对应的目标atom
在这个静态库里面的话,就会把找到的atom
的加入到图内。atom
,同静态库一样,如果有没被决议的fixup
对应的atom
在动态库内找到(比如tbd
声明的那些),就就提供一个代理,这个代理标记了这个符号来自哪个动态库本质上来说,链接期间动态库的作用就是参与标记一下。
考虑完符号决议,还要考虑符号合并之类的,比如根据字符串表的设计,来自不同文件的相同字符串,比如"haha"
,不可能保留两份,需要合并。此外还有诸如C
中的tentative definitions
,C++ Weak Symbol
等
处理fixup
的时候,也需要分几种类型,见下图:
虽然苹果的LD
已经抽象成了Atom-FixUP
的架构,但是它的可执行文件Mach-O
还是传统的基于section
的结构,这限制了Atom-FixUP
的能力。
其实文章关于传统
Section Based Linker
那块我还没怎么读懂,有兴趣的欢迎互相探讨。
之前研究X]]>
上周五排查了一个由于XXX模块
操作疏忽导致栈越界引发的我的模块
的智能指针Crash问题,因此稍微研究了一下,以作参考:
shared_ptr共享被管理对象,同一时刻可以有多个shared_ptr拥有对象的所有权,当最后一个shared_ptr对象销毁时,被管理对象自动销毁
简单来说,shared_ptr
实现包含了两部分,
raw_ptr
share_count_object
第一部分没什么好说的,第二部分是需要关注的重点:
use_count
,当前这个堆上对象被多少对象引用了,简单来说就是引用计数。weak_count
,这个管理对象被多少个智能指针共享了,简单来说就是管理对象
的引用计数。不同指针创建的对同一个堆上对象的智能管理,并不共享管理对象,因此存在double free
的可能性
_shared_ptr
直接包含的裸指针,即raw prt
,是为了实现一般指针的->,*等操作,通过__shared_count object
间接包含的指针是为了管理对象的生命周期,回收相关资源。
换句话说,__shared_count object
内部的use_count
主要用来标记被管理对象的生命周期,weak_count
主要用来标记管理对象的生命周期。
注意区分管理和被管理。
了解了原理,可以看出std::share_ptr
本身的raw ptr
指向了堆上通过new
创建的对象。但是其自身这个raw ptr
却会如果在不当操作上,被修改,比如栈越界操作,就会被破坏,导致产生对非法对象的访问:
int i = 5;
NSLog(@"i address is %p", &i);
XXX::XX df = XXX::XX::buildDataFrame();
NSLog(@"sp0 address is %p", &df);
int a[1] = {1};
NSLog(@"k address is %p", a);
for (int i = 0; i < 10; i++) {
a[i] = i;
}
NSLog(@"haha");
上述这段代码就会引发问题,这里XXX::XX
的具体内部设计使用了经典的RAII
,下文再表。
上周五排查了一个由于XXX模块
操作疏忽导致栈越界引发的我的模块
的智能指针Crash问题,因此稍微]]>
Objective-C
中的@Synchronized
,想必从事iOS
开发相关工作的同学都不陌生,可以说这是一种最简单的加锁的方式了。
网上关于锁对比的文章也不在少数,太多说集中在用法概述以及性能对比。而@Synchronized
在不少文章中常常因其性能而被建议不要使用。
本质上来说,在客户端场景下,高密度使用锁的场景是相对较少(比如IM
数据库除外);同时,抛开使用场景单独通过比如for
循环测试锁的性能,也是比较蛋疼的,不合适的用法、过大的锁范围以及竞态条件,都会导致比较条件的欠考虑性。
因此,今天我想谈谈一个不应该使用@Synchronized
的本质原因:它是一个和上下文强相关的锁,会导致锁失效。
考虑一个场景:
我们后台静默更新一下数据,一旦有了新数据,就整体替换掉现在呈现的数据,这在列表页配合远程数据的时候非常常见。
为了放大多线程可能出错的场景,我放大到5000个线程,构造如下代码:
@interface ViewController ()
@property (nonatomic, strong) NSMutableArray *testArray;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
self.testArray = @[].mutableCopy;
for (NSUInteger i = 0; i < 5000; i++) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self testThreadArray];
});
}
}
- (void)testThreadArray
{
@synchronized (self.testArray) {
self.testArray = @[].mutableCopy;
}
}
可以看出,为了避免多个线程同时更新临界资源testArray
,我们使用 @synchronized (self.testArray)
进行了资源保护。
备注:为什么需要保护这里的赋值操作,可以阅读我的从Immutable来谈谈对于线程安全的理解误区
看起来一切都很Ok,但是当你实际运行代码,还是会出现野指针Crash。如下图所示:
这里用
@Synchronized(self)
是可以成功锁住的,但是这会陷入到锁的范围太大的场景中去,不再此文探讨的范围内。
@Synchronized
会变成一对基于try-catch
的objc_sync_enter
和objc_sync_exit
的代码,想必都不陌生了,许多网上文章都有,不再赘述,可以参考clang
的代码:
/// RewriteObjCSynchronizedStmt -
/// This routine rewrites @synchronized(expr) stmt;
/// into:
/// objc_sync_enter(expr);
/// @try stmt @finally { objc_sync_exit(expr); }
///
Stmt *RewriteObjC::RewriteObjCSynchronizedStmt(ObjCAtSynchronizedStmt *S) {
// Get the start location and compute the semi location.
SourceLocation startLoc = S->getBeginLoc();
const char *startBuf = SM->getCharacterData(startLoc);
assert((*startBuf == '@') && "bogus @synchronized location");
std::string buf;
buf = "objc_sync_enter((id)";
const char *lparenBuf = startBuf;
while (*lparenBuf != '(') lparenBuf++;
ReplaceText(startLoc, lparenBuf-startBuf+1, buf);
// We can't use S->getSynchExpr()->getEndLoc() to find the end location, since
// the sync expression is typically a message expression that's already
// been rewritten! (which implies the SourceLocation's are invalid).
SourceLocation endLoc = S->getSynchBody()->getBeginLoc();
const char *endBuf = SM->getCharacterData(endLoc);
while (*endBuf != ')') endBuf--;
SourceLocation rparenLoc = startLoc.getLocWithOffset(endBuf-startBuf);
buf = ");\n";
// declare a new scope with two variables, _stack and _rethrow.
buf += "/* @try scope begin */ \n{ struct _objc_exception_data {\n";
buf += "int buf[18/*32-bit i386*/];\n";
buf += "char *pointers[4];} _stack;\n";
buf += "id volatile _rethrow = 0;\n";
buf += "objc_exception_try_enter(&_stack);\n";
buf += "if (!_setjmp(_stack.buf)) /* @try block continue */\n";
ReplaceText(rparenLoc, 1, buf);
startLoc = S->getSynchBody()->getEndLoc();
startBuf = SM->getCharacterData(startLoc);
assert((*startBuf == '}') && "bogus @synchronized block");
SourceLocation lastCurlyLoc = startLoc;
buf = "}\nelse {\n";
buf += " _rethrow = objc_exception_extract(&_stack);\n";
buf += "}\n";
buf += "{ /* implicit finally clause */\n";
buf += " if (!_rethrow) objc_exception_try_exit(&_stack);\n";
std::string syncBuf;
syncBuf += " objc_sync_exit(";
Expr *syncExpr = S->getSynchExpr();
CastKind CK = syncExpr->getType()->isObjCObjectPointerType()
? CK_BitCast :
syncExpr->getType()->isBlockPointerType()
? CK_BlockPointerToObjCPointerCast
: CK_CPointerToObjCPointerCast;
syncExpr = NoTypeInfoCStyleCastExpr(Context, Context->getObjCIdType(),
CK, syncExpr);
std::string syncExprBufS;
llvm::raw_string_ostream syncExprBuf(syncExprBufS);
assert(syncExpr != nullptr && "Expected non-null Expr");
syncExpr->printPretty(syncExprBuf, nullptr, PrintingPolicy(LangOpts));
syncBuf += syncExprBuf.str();
syncBuf += ");";
buf += syncBuf;
buf += "\n if (_rethrow) objc_exception_throw(_rethrow);\n";
buf += "}\n";
buf += "}";
ReplaceText(lastCurlyLoc, 1, buf);
bool hasReturns = false;
HasReturnStmts(S->getSynchBody(), hasReturns);
if (hasReturns)
RewriteSyncReturnStmts(S->getSynchBody(), syncBuf);
return nullptr;
}
卧槽,原来
clang
的rewrite部分也写的这么挫逼啊。
我们就从objc_sync_enter
来继续挖掘:
if (obj) {
SyncData* data = id2data(obj, ACQUIRE);
assert(data);
data->mutex.lock();
}
关键其实就是在于从obj
转换到SyncData
,然后通过SyncData
中的mutex
来进行临界区的锁。
有两个部分需要分析一下,首先SyncData
结构体定义如下:
typedef struct alignas(CacheLineSize) SyncData {
struct SyncData* nextData;
DisguisedPtr<objc_object> object;
int32_t threadCount; // number of THREADS using this block
recursive_mutex_t mutex;
} SyncData;
mutex
,一把递归锁,这也是为什么我们可以在@Synchronized
里面嵌套@Synchronized
的原因。DisguisedPtr
,还记得我们以前写安全气垫的时候给一些释放的内存地址填充0x55
用于拦截use after free
的场景?这里DisguisedPtr
其实就是对裸对象指针objc_object
的一层包装改写。继续回到id2data
函数往下研究,可以发现一段比较有意思的函数:
static StripedMap<SyncList> sDataLists;
我们具体就关注[]
对应的操作即可:
class StripedMap {
#if TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR
enum { StripeCount = 8 };
#else
enum { StripeCount = 64 };
#endif
struct PaddedT {
T value alignas(CacheLineSize);
};
PaddedT array[StripeCount];
static unsigned int indexForPointer(const void *p) {
uintptr_t addr = reinterpret_cast<uintptr_t>(p);
return ((addr >> 4) ^ (addr >> 9)) % StripeCount;
}
public:
T& operator[] (const void *p) {
return array[indexForPointer(p)].value;
}
const T& operator[] (const void *p) const {
return const_cast<StripedMap<T>>(this)[p];
}
抽丝剥茧,这里其实就是一个简单的Hash
算法,然后将传入的对象地址,通过indexForPointer
映射到不同的SyncList
上。而SyncList
是一个维护SyncData
的链表,每个SyncList
都单独维护操作自己的lock
。
indexForPointer
公式:((addr >> 4) ^ (addr >> 9)) % StripeCount
,其中StripeCount
是个数。
这样做的好处就是创建了一个所谓的散列锁,可以有效的降低不同的对象操作指尖的相互影响性。当然,从本质上看,iOS
上就8个散列锁,这也是影响大规模使用@Synchronized会影响性能的原因之一。
接着往下走,我们直接关注没有命中Thread Local Storage
的场景。
#define LOCK_FOR_OBJ(obj) sDataLists[obj].lock
spinlock_t *lockp = &LOCK_FOR_OBJ(object);
// 通过对对象地址hash,算法对应SyncList的锁
lockp->lock();
{
SyncData* p;
SyncData* firstUnused = NULL;
for (p = *listp; p != NULL; p = p->nextData) {
if ( p->object == object ) {
result = p;
// atomic because may collide with concurrent RELEASE
OSAtomicIncrement32Barrier(&result->threadCount);
goto done;
}
if ( (firstUnused == NULL) && (p->threadCount == 0) )
firstUnused = p;
}
// no SyncData currently associated with object
if ( (why == RELEASE) || (why == CHECK) )
goto done;
// an unused one was found, use it
// 关注点1 !!!!!!!!!!!!
if ( firstUnused != NULL ) {
result = firstUnused;
result->object = (objc_object *)object;
result->threadCount = 1;
goto done;
}
}
// Allocate a new SyncData and add to list.
// XXX allocating memory with a global lock held is bad practice,
// might be worth releasing the lock, allocating, and searching again.
// But since we never free these guys we won't be stuck in allocation very often.
// 关注点2 !!!!!!!!!!!!
posix_memalign((void **)&result, alignof(SyncData), sizeof(SyncData));
result->object = (objc_object *)object;
result->threadCount = 1;
new (&result->mutex) recursive_mutex_t(fork_unsafe_lock);
result->nextData = *listp;
*listp = result;
done:
lockp->unlock();
if (result) {
// Only new ACQUIRE should get here.
// All RELEASE and CHECK and recursive ACQUIRE are
// handled by the per-thread caches above.
if (why == RELEASE) {
// Probably some thread is incorrectly exiting
// while the object is held by another thread.
return nil;
}
if (why != ACQUIRE) _objc_fatal("id2data is buggy");
if (result->object != object) _objc_fatal("id2data is buggy");
// 关注点3
#if SUPPORT_DIRECT_THREAD_KEYS
if (!fastCacheOccupied) {
// Save in fast thread cache
tls_set_direct(SYNC_DATA_DIRECT_KEY, result);
tls_set_direct(SYNC_COUNT_DIRECT_KEY, (void*)1);
} else
#endif
{
// Save in thread cache
if (!cache) cache = fetch_cache(YES);
cache->list[cache->used].data = result;
cache->list[cache->used].lockCount = 1;
cache->used++;
}
}
return result;
SyncList
,由于需要操作SyncList
,用其对应的锁进行加锁。SyncData
,如果没有就创建一个,设定好对应的成员变量,然后返回。Thread Local Storage
,存一下,这块不关注无伤大雅。Ok,到现在我们分析完成@Synchronized
的实现原理后,我们可以回过头再来看看为什么对象被更改后会产生Crash了。
其实一言以蔽之,就是@Synchronized
锁不住对象赋值变化的场景。
回到我们上一小节Crash
的问题:
考虑三个线程的场景,分别定义为线程A,线程B,线程C,初始的时候在线程A,self.testArray
的初始值为arr0
(实质上操作的是arr0
地址,下文简述为arr0
),我们来理下时间线:
self.testArray
的值,为arr0
。self.testArray
的值,也为arr0
。lock0
。self.testArray = @[].mutableCopy
。self.testArray
指向了arr1
。self.testArray
,获取到了arr1
。arr0
进行获取锁的操作。arr1
,所以使用的是arr1
对应的锁操作。arr0
和arr1
对应的锁不是一个(当然理论上可能散列计算为同一个),所以这两个线程都进入了临界区self.testArray = @[].mutableCopy
。Setter
的赋值并不是atomic
的,实质上会转换成如下这样的代码:
static inline void reallySetProperty(id self, SEL _cmd, id newValue,
ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
id oldValue;
// 计算结构体中的偏移量
id *slot = (id*) ((char*)self + offset);
if (copy) {
newValue = [newValue copyWithZone:NULL];
} else if (mutableCopy) {
newValue = [newValue mutableCopyWithZone:NULL];
} else {
// 某些程度的优化
if (*slot == newValue) return;
newValue = objc_retain(newValue);
}
// 危险区
if (!atomic) {
// 第一步
oldValue = *slot;
// 第二步
*slot = newValue;
} else {
spin_lock_t *slotlock = &PropertyLocks[GOODHASH(slot)];
_spin_lock(slotlock);
oldValue = *slot;
*slot = newValue;
_spin_unlock(slotlock);
}
objc_release(oldValue);
}
在上述危险区的第二步,_testArray
在线程B和线程C分别指向了新地址addr2
和addr3
,但是获取到的oldValue
可能都是arr1
objc_release
对oldValue
,也就是arr1
进行了两次释放,妥妥的double free
过度释放场景,导致崩溃。备注:多线程的场景在于不确定性,可能在其中任何一个指令处挂掉。
所以,从本质上来说,@Synchronized
的确是最不应该推荐给用户使用的一种锁机制,但是其根本原因并不一定是性能差距,Hash
离散设计的优雅的话,一样能保证性能。但是其内在锁和对象上下文相关的联系会导致锁失效的场景,一旦有对象发生变化(被赋值),导致潜在的锁不住多线程的场景,我们也应该去了解学习。
Objective-C
中的@Synchronized
,想必从事iOS
开发相关工作的同学都不陌生,可以说这是一种最简单的加锁的方式了。
网上关于锁对比的文章也不在少数,太多说集中在]]>
iOS
开发的同学对tbd
这个格式的文件已经不再陌生了。最近Xcode 10
升级的时候,你会发现很多原先用libstdc++
的库在新的Xcode
已经没有链接通过。而临时的解决方案也比较简单,网上也很多这样的文章,简而言之就是从Xcode 9
中拷贝对应的libstdc++.tbd
文件给新的Xcode 10
来使用。
Ok,解决方案是有了,我们需要更深入的理解下:为什么拷贝tbd文件,就能够成功解决链接问题?
tbd
全称是text-based stub libraries
,本质上就是一个YAML描述的文本文件。
他的作用是用于记录动态库的一些信息,包括导出的符号、动态库的架构信息、动态库的依赖信息。
为什么需要包含这些信息呢?
ARM
指令集的iOS
设备上加载x86
格式的库。后续我们会举一个手动修改
tbd
中install-name
字段的小例子来让运行在模拟器的时候加载ARM64
架构的动态库
那库就分为静态库和动态库两种。相信网上关于这两者的讨论和阐述已经很多了,再次不再赘述。唯一需要提及的一点是,动态库是在程序运行(启动依赖或者按需加载)时候加载进程序的地址空间的,那么我们在静态期的时候,是如何得知动态库提供了哪些能力呢?而这就是tbd
格式提供的导出符号表的加载,它会指导链接器在链接过程中,将需要决议的符号先做个标记,标记是来自哪个动态库。
这里举个小例子吧。
在程序构建的过程中,比如我们开发一个iOS应用,毋庸置疑的会用到UIKit
这个动态库。而为了使我们的程序能够构建成功,这里分为了两个步骤:
通过引入头文件,import <UIKit/UIKit.h>
,我们知道了UIKit
里面的函数、变量声明。有声明,就能通过编译器的检查。
我们在代码里面使用了UIKit
的函数,其本质是一种符号,因此需要链接器来决议这个符号来自哪?要是所有地方都找到,就会报类似undefined symbol
之类的错误(想必大家已经很熟悉了)。
tbd
格式实际上是从Xcode 7
时代引入的。
用于取代在真机开发过程中直接使用传统的dylib
用于取代在真机开发过程中直接使用传统的dylib
用于取代在真机开发过程中直接使用传统的dylib
我们都知道一个库在没有strip
诸如调试信息、非导出符号的情况下是非常大的。但是由于在开发过程中,调试等过程是必不可少的,我们来对比下传统直接包含dylib
的时候大小,我们以CoreImage.framework
来举例:
framework
(包含tbd
)的大小差距很明显了吧,对于真机来说,由于动态库都是在设备上,在Xcode
上使用基于tbd
格式的伪framework
可以大大减少Xcode
的大小。
题外话:网上有人说模拟器上还是使用
dylib
,的确没错。但是模拟器现在也桥了一层tbd
格式,真正的dylib
是在这个路径下:iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents
此外,虽然从Xcode 7
时代到现在,一直都是tbd
的说法,但是它也是经历了一些演变了,目前已经发展了v3
格式的版本。
网上很多人都研究过dyld
的代码,与之对应还有一种ld
,就是平时我们在构建程序过程中,链接过程中出错的根因:
既然我们通过拷贝tbd
的方式能解决链接不过的问题,那我们就要知道ld
是如何运用tbd
文件的。
既然报错事library not found
,我们扒一下linker
的源码即可:
Options::FileInfo Options::findLibrary(const char* rootName, bool dylibsOnly) const
{
FileInfo result;
const int rootNameLen = strlen(rootName);
// if rootName ends in .o there is no .a vs .dylib choice
if ( (rootNameLen > 3) && (strcmp(&rootName[rootNameLen-2], ".o") == 0) ) {
for (std::vector<const char*>::const_iterator it = fLibrarySearchPaths.begin();
it != fLibrarySearchPaths.end();
it++) {
const char* dir = *it;
if ( checkForFile("%s/%s", dir, rootName, result) )
return result;
}
}
else {
bool lookForDylibs = false;
switch ( fOutputKind ) {
case Options::kDynamicExecutable:
case Options::kDynamicLibrary:
case Options::kDynamicBundle:
case Options::kObjectFile: // <rdar://problem/15914513>
lookForDylibs = true;
break;
case Options::kStaticExecutable:
case Options::kDyld:
case Options::kPreload:
case Options::kKextBundle:
lookForDylibs = false;
break;
}
switch ( fLibrarySearchMode ) {
case kSearchAllDirsForDylibsThenAllDirsForArchives:
// first look in all directories for just for dylibs
if ( lookForDylibs ) {
for (std::vector<const char*>::const_iterator it = fLibrarySearchPaths.begin();
it != fLibrarySearchPaths.end();
it++) {
const char* dir = *it;
auto path = std::string(dir) + "/lib" + rootName + ".dylib";
if ( findFile(path, {".tbd"}, result) )
return result;
}
for (std::vector<const char*>::const_iterator it = fLibrarySearchPaths.begin();
it != fLibrarySearchPaths.end();
it++) {
const char* dir = *it;
if ( checkForFile("%s/lib%s.so", dir, rootName, result) )
return result;
}
}
// next look in all directories for just for archives
if ( !dylibsOnly ) {
for (std::vector<const char*>::const_iterator it = fLibrarySearchPaths.begin();
it != fLibrarySearchPaths.end();
it++) {
const char* dir = *it;
if ( checkForFile("%s/lib%s.a", dir, rootName, result) )
return result;
}
}
break;
case kSearchDylibAndArchiveInEachDir:
// look in each directory for just for a dylib then for an archive
for (std::vector<const char*>::const_iterator it = fLibrarySearchPaths.begin();
it != fLibrarySearchPaths.end();
it++) {
const char* dir = *it;
auto path = std::string(dir) + "/lib" + rootName + ".dylib";
if ( lookForDylibs && findFile(path, {".tbd"}, result) )
return result;
if ( lookForDylibs && checkForFile("%s/lib%s.so", dir, rootName, result) )
return result;
if ( !dylibsOnly && checkForFile("%s/lib%s.a", dir, rootName, result) )
return result;
}
break;
}
}
throwf("library not found for -l%s", rootName);
}
throwf("library not found for -l%s", rootName);
这里我们就找到了错误发生的原因,我们再往上溯源,找到linker
处理编译单元的入口:
InputFiles::addOtherLinkerOptions
里面存在如下代码:
CStringSet newLibraries = std::move(state.unprocessedLinkerOptionLibraries);
state.unprocessedLinkerOptionLibraries.clear();
for (const char* libName : newLibraries) {
if ( state.linkerOptionLibraries.count(libName) )
continue;
try {
Options::FileInfo info = _options.findLibrary(libName);
if ( ! this->libraryAlreadyLoaded(info.path) ) {
_linkerOptionOrdinal = _linkerOptionOrdinal.nextLinkerOptionOrdinal();
info.ordinal = _linkerOptionOrdinal;
//<rdar://problem/17787306> -force_load_swift_libs
info.options.fForceLoad = _options.forceLoadSwiftLibs() && (strncmp(libName, "swift", 5) == 0);
ld::File* reader = this->makeFile(info, true);
ld::dylib::File* dylibReader = dynamic_cast<ld::dylib::File*>(reader);
ld::archive::File* archiveReader = dynamic_cast<ld::archive::File*>(reader);
if ( dylibReader != NULL ) {
dylibReader->forEachAtom(handler);
dylibReader->setImplicitlyLinked();
dylibReader->setSpeculativelyLoaded();
this->addDylib(dylibReader, info);
}
else if ( archiveReader != NULL ) {
_searchLibraries.push_back(LibraryInfo(archiveReader));
_options.addDependency(Options::depArchive, archiveReader->path());
//<rdar://problem/17787306> -force_load_swift_libs
if (info.options.fForceLoad) {
archiveReader->forEachAtom(handler);
}
}
else {
throwf("linker option dylib at %s is not a dylib", info.path);
}
}
}
catch (const char* msg) {
// <rdar://problem/40829444> only warn about missing auto-linked library if some missing symbol error happens later
state.missingLinkerOptionLibraries.insert(libName);
}
state.linkerOptionLibraries.insert(libName);
}
而上述这些需要查询的library
是从哪里来的呢?
我们以xcconfig
举例来看:
OTHER_LDFLAGS = $(inherited) -ObjC -l"stdc++"
在链接过程中就需要处理这样的stdc++
Library,而查询的方式就是在特定目录结构中搜索是否有对应的库文件或者tbd
文件。
使用tbd
当然不止减少Xcode
体积大小这一个好处,嘿嘿,你们自己摸索下吧~
而且,基于这种思路,能玩出许多类似文体两开花,中美合拍美猴王的玩法,加油吧。
]]>iOS
开发的同学对tbd
这个格式的文件已经不再陌生了。最近Xcode 10
升级的时候,你会发现很多原先用libstdc++
的库在新的Xcode
JSDebugger开源地址:https://github.com/SatanWoo/JSDebugger
这是一篇谈谈设计JSDebugger
的总体设想,不会过于深究具体实现细节,后续会单独探讨一些涉及实现方面的过程。
读过我之前博客的朋友可能会记得我3-4月份的时候写过一篇动手制作一个简易的iOS动态执行器,效果如下:
虽然这个效果还起来还不错(有许多人问这个东西咋实现的,挺炫酷的),但是其实只是个原型而已,从设想构思到编码实现没有超过一天的时间。当时在博文里面承诺发代码,想想实现的完善度还不够,就准备完善后再继续搞搞。不过后来由于我转到其他组不继续钻研iOS
,这事也就不了了之。
那为什么现在又重新开搞呢?,三点原因吧:
Mach-o
包瘦身方案怎么也没发文章?好,来说说前两个原因:
完整版的很多文章或者一些没对外的技术研究,我都发在了公司的内网里,欢迎加入阿里巴巴。
Cycript
就提供了类似的能力,因此就利用业余时间做了一个JSDebugger。说了这么多原因,其实还是我太懒了。
言归正传,回到JSDebugger本身,基于之前的代码,这次主要做了完整性的代码重构重写以及功能完善上。
CGSize
, CGRect
, CGPoint
,正在开发自定义注册接口)同时,为了更好的测试所写的JavaScript
代码,开发了玩具级别的Playground
功能,每次实时修改文件后保存即可自动触发Reload
。
很多细节此文不表,但是有些功能上的实现还是比较用心的,比如支持了各种类型、个数的可变参数的函数调用,比如目前支持了choose
和introspect
的能力,二者配合可以对任意对象实时查询其当前所有的属性值。
而且,我对JavaScriptCore
的使用可能和常规大家所属性的iOS JavaScriptCore
有所区别,利用更低层的设计思路,经过我实测:
更低层的设计桥接思路在iOS
上同比基于Objective-C
的使用方式可以节省50%的时间;同比在Android
上使用开源的JavaScriptCore
是50分之一左右的时间。
当然Android上比较主流的JS引擎室v8咯
具体快原因可以阅读动手制作一个简易的iOS动态执行器中涉及的JavaScriptCore
上层源码分析以及阅读我的JSDebugger源码。
目前JSDebugger还在不断完善中,后续会把我更多的想法移植到里面,总体规划有几个关键点:
实现一个交互式的编辑器(或者命令行),能够让大家写Objective-C
的代码自动转换成JSDebugger的JS语法。以我目前的技术水准,还做不到Cycript
那种牛逼的Objective-C
和JavaScript
的混合语法模式。
实现远程图形化Debug
能力。目前JSDebugger可以调试数据,但是如果能像Reveal
一样把操作界面和数据结合起来就会更有效的定位问题。
欢迎有想法的朋友一起来参与完善这个项目,开源地址如下:
https://github.com/SatanWoo/JSDebugger
https://github.com/SatanWoo/JSDebugger
https://github.com/SatanWoo/JSDebugger
当然,要是发现了任何的Bug或者使用上的疑惑、抑或是可以改进的点,也可以私聊我或者开issue
。
实现JSDebugger的过程,还是站在两个杰出的项目肩膀上:
我的思路是来自于Cycript
,诸如结构体等许多方面的实现细节是参考了JSPatch
。在这里对这几个项目的作者和代码贡献者表示感谢!
此外,很多的技术方案是和HookZZ大神交流(主要是他教我)中学习而来,在这也特别感谢。也感谢头条的谢大佬的代码贡献以及寒神的Code Style
整理。
当然,JSDebugger在实现上还是有很多自己思考的部分,感兴趣的读者可以自行前往JSDebugger的Github开源地址
]]>JSDebugger开源地址:https:]]>
weight
和bias
模型。(下文会介绍)关于实现多层前馈神经网络,网络上的答案数不胜数,但是大多数都是参考Tensorflow或者Caffe(PyTorch)去实现,我觉得这样很不好。因为学习一门新技术,虽然快速完成项目看出效果很重要,但是对个人来说,弄懂黑盒背后的故事非常重要。因此我决定不依赖任何的库来完全裸写。
当然,对于我来说,实现的完整度和正确性是第一位的,我并没有过多的关注性能。
虽然在深度学习高度发展的今天,类似于AlexNet
这样的网络模型能够近乎完美的识别手写数据集。但是作为这个领域的入门级选手,我还是想追溯起源,从头开始做起。因此,在一番学习和搜索后,我选定了LeNet-5
模型进行编写。选择它的原因主要有如下几点:
ReLu
,Softmax
等激活函数。LeNet-5
整体是个非常简单的过程,包含如下步骤:
bilinear
插值方法。r / 255.0 * 0.299 + g / 255.0 * 0.587 + b /255.0 * 0.114
。255.0 - 灰度化的结果
Relu
,进行第一次卷积操作。(这里为了保证卷积后尺寸一致,添加了Padding)ReLu
,进行第一次卷积操作。(这里为了保证卷积后尺寸一致,添加了Padding)ReLu
featureMap
Softmax
计算并去除最大的值,即为检测的数字结果。整体实现上没什么需要特别注意的,如果对这里的名次不懂,可以上网自行查询对应的解释。不过这里有一点很不好,浪费了大量的时间在调试我定义的张量的格式和网上找到的weight
模型的格式。
什么意思呢?我大致用如下的图解释下我自身设计的张量是如何存储的。
理论上来讲,张量有三个维度,width
, height
, featureChannels
。我在设计我的张量存储上按照的data[height][row][featureChannels]
的方式,然后全部拍成了一维。如图所示:
之所以想这么做,主要是瞄了眼
TensorFlow
也是类似的设计,然后印象中CUDA也是按照这样维度进行存储,貌似可以有效做并行计算拆分。(这个不确定)
然后为什么卡了很久呢?主要是weights
和bias
的模型文件是个按照自定义协议二进制流的文件(非结构化的数据)。
这模型由于是我网上找的,一开始没注意模型的自定义协议和我设计的张量直接的区别。我直接按照我的张亮顺序进行了相乘,得到了十分错误的结果。
当然,bias
模型没什么好说的,就是按照outputFeatureMap
定义的纯一维数组,不会出错。
后来发现这个模型是基于苹果的MPS
设计的模型,它的模型是这样的数据结构weights[outputChannels][height][width][inputChannel/group]
。第一维在计算的时候需要和我做个映射,所以这里没搞清楚模型格式,查了比较久。
当然,我在加载权重和bias这块还是做了点小油画。用了mmap
,避免一次性直接搞进来太大的数据,反正看起来weight
和bias
这块并不需要一次性的读取,而且只读的mmap
还能合理利用iOS设备上的clean memory回收机制。
网络模型拓扑结构,MinstGraph
。这里偷懒了,因为LeNet-5
也没啥复杂的拓扑结构,不用考虑多个节点连接,直接线性跑下去就好。
支持任意多维度的张量,类似Tensorflow
里面的Tensor
,这里对应了MinstImage
。
MaxPoolingLayer
,ConvolutionLayer
, FullConnectionLayer
等等。Relu
,Softmax
等等。代码下周发吧。
准确度一开始不怎么高,检查了很多遍代码,确实发现了一些问题,比如数据精度问题。
一开始从图像的角度理解,认为用unsigned char
存储一个数据点就够了,毕竟图像像素点(RGB空间下)的取值范围就是0-255。
后来发现在计算卷积、全链接层的时候会产生很多小数,用unsigned char
存储精度全部丢失了。因此修改成了现在的float
设计。对效果提升还是比较明显的。
后来专门跑了下苹果基于Metal实现的卷积神经网络,由于上述我自身实现的所有Layer和激活函数在苹果的框架中都有内置,因此把网络模型搭起来跑就完了(除非苹果自己实现有错)。然后对比我的每一层输入输出和对应的MPSImage
输入输出。
不过这里有一点要注意,MPSImage
的数据格式是NHWC,这里的N是把C按照4对齐后分成的不同batch。如下图所示:
假设是一个2 * 1 * 5
(w h c)的数据,会先把前4层排完,再进行第五层的排列,按4对齐后多出来的三个层补0。
我的代码里面MinstImage
提供了一个print
方法就是专门做输出对比的。嗯,对比了我的实现和用苹果框架的下输入输出,结果是一致的。(除了iOS10上不支持bilinear
插值)
最终效果如下图:
备注:
如果直接用我开头提到的MNIST数据集,由于每张图都是28 * 28的灰度图,因此不需要resize + grayscale,直接从取反开始计算就可以了。
做神经网络还是挺有意思,不过目前还是参考简单的模型结构实现,主要做inference
。还没真正去研究训练模型这块。这块需要多深入研究算法,多读论文。
目前并没有真正设计Session
的概念。理论上一张图就是一个静态的拓扑结构的组合而已,不应该承担类似执行run
的功能。后续业余时间还会继续实现完整这套逻辑,将静态结构和动态执行结构彻底分离。
后续有时间的话,可以尝试实现别的模型。同时支持从文件中读取已经建立好的模型,类似Caffee模型之类的
移植到GPU上。
文章的最后,按照惯例还是向我的偶像致敬。机器学习发展到今天,已经成为了不可忽视的研究方向。对于我们这样的后生来说,站在大牛的肩膀上是我们的福气和基石。而像杨萧玉这样,能够一周时间内学完机器学习课程,发表博客造福大众,才是推动机器学习不断发展的中坚力量。相信在他的带领下,我们国家一定能够在2030年达到全球领先的AI技术水准。
]]>DynamicCocoa
是基于JavaScriptCore搞得,一直期待看到他们的真正实现,不过可能后来由于公司机密,应该不能再开源了。
借着最近开始研究JavaScriptCore的契机,我决定利用这一两天所学的JavaScript知识,在业余时间做一个简单的iOS动态执行器玩具。
题外话1:听说滴滴基于LLVM backend搞了一套中间语言解释器,不知道最后用了哪个?不过LLVM IR解释器的话,嘿嘿,还是有点意思的。
题外话2:我研究这个并不是想做iOS动态化,因为xxxxxxx。我只是纯粹想看看JavaScriptCore的一些实现而已。
一张Gif图想必能最佳得展示我做的玩具,请各位大佬过目:
在实现我们的执行器前,我们还是要稍微要了解一下一些前置的知识点。
大家都知道,Objective-C中的诸多类型在JavaScript的环境里是不能直接用的,需要通过JSValue进行一层包装,具体的类型转换如下图展示:
基本上图上的转换都很容易理解,唯一需要我们注意的是Wrapper Object
。什么是Wrapper Object
呢?
举个例子:
self.context[@"a"] = [CustomObject new]
上述代码将我们一个自定义类型CustomObject
的实例以变量名a
的方式注入到了JavaScript的运行环境里。但是她是怎么知道我们的定义呢,又是如何知道我们是否能调用特定的方法?
从默认的角度看,JS运行环境只会把OC中init
初始化方法以及类的继承关系给同步到JS环境中(如果有JSExport我们下文说),然后这个对象会包装给一个JSWrapperValue用于JS环境中使用。而当JS环境调用OC并且涉及到这个对象的时候,JavaScriptCore会自动将其解包还原成原始的OC对象类型。
- (JSValue *)jsWrapperForObject:(id)object
{
JSC::JSObject* jsWrapper = m_cachedJSWrappers.get(object);
if (jsWrapper)
return [JSValue valueWithJSValueRef:toRef(jsWrapper) inContext:m_context];
// 注意点!!!!!!!!!!!!!!!!!!
JSValue *wrapper;
if (class_isMetaClass(object_getClass(object)))
wrapper = [[self classInfoForClass:(Class)object] constructor];
else {
JSObjCClassInfo* classInfo = [self classInfoForClass:[object class]];
wrapper = [classInfo wrapperForObject:object];
}
JSC::ExecState* exec = toJS([m_context JSGlobalContextRef]);
jsWrapper = toJS(exec, valueInternalValue(wrapper)).toObject(exec);
m_cachedJSWrappers.set(object, jsWrapper);
return wrapper;
}
Wrapper Object
,没有的话就进行构建,构建过程如下:1 | JSClassDefinition definition; |
JSExport
协议本质上只是个Protocol
标记,用于让JavaScriptCore加载那些打上这个特殊标记的类,用于特定方式的注册及初始化。
上文我们提过,默认情况下,JavaScriptCore会对象创建一个默认的Wrapper Object
,但是这个对象除了简单继承关系外,也就一个按照特殊格式命令的Constructor
而已:
[NSString stringWithFormat:@"%sConstructor", className]
那如果我们需要将OC环境中的方法注入到JS环境中,就需要用到JSExport
协议了,这个协议在运行时会按照如下逻辑进行处理,将方法和属性进行诸如注入:
1 | 检查init方法簇的方法,并根据这么合法提供合理的 |
1 | 注入方法和属性 |
而至于JSExportAs
,就是做了个简单的名称映射而已,毕竟JS函数传参和OC有很大的区别:
static NSMutableDictionary *createRenameMap(Protocol *protocol, BOOL isInstanceMethod)
{
NSMutableDictionary *renameMap = [[NSMutableDictionary alloc] init];
forEachMethodInProtocol(protocol, NO, isInstanceMethod, ^(SEL sel, const char*){
NSString *rename = @(sel_getName(sel));
NSRange range = [rename rangeOfString:@"__JS_EXPORT_AS__"];
if (range.location == NSNotFound)
return;
NSString *selector = [rename substringToIndex:range.location];
NSUInteger begin = range.location + range.length;
NSUInteger length = [rename length] - begin - 1;
NSString *name = [rename substringWithRange:(NSRange){ begin, length }];
renameMap[selector] = name;
});
return renameMap;
}
说了那么多基础原理,下面让我们来看看具体实现流程:
在我看来,要实现一个动态化的执行环境,有三要素是必不可少的:
类(包括元类)、实例对象以及方法。
基于我们上文对于Wrapper Object
的分析,我们可以构建特殊类型的Wrapper Object对这三个元素进行包装,具体就不说了,还是建议大家自行思考,基本上类似我上文分析JSWrapperObject
的步骤。
除了上述三要素,我们还需要定义一个全局变量,WZGloablObject
(大家可以理解为浏览器的window对象),用于拦截顶层的属性访问。
按照这个设计,大家可以自行思考下,如果是你做,你会如何继续下面的工作,文章下周随着代码一起发布吧。
搞过逆向用过Cycript的朋友都知道,Cycript在调试时候有个非常方便的调试功能:Choose
。该功能可以快速的帮助我们根据类名在堆上的对象全部查询返回。
这么实用的功能必须提供,我基本上直接照搬了Cycript的实现。代码很清晰,基本能够自解释其逻辑。核心基本上就是遍历每个malloc_zone
,然后根据获取的vmaddress_range
判断获取到的数据其类型是不是我们要的。
// 遍历zone
for (unsigned i = 0; i != size; ++i) {
const malloc_zone_t * zone = reinterpret_cast<const malloc_zone_t *>(zones[i]);
if (zone == NULL || zone->introspect == NULL)
continue;
zone->introspect->enumerator(mach_task_self(), &choice, MALLOC_PTR_IN_USE_RANGE_TYPE, zones[i], &read_memory, &choose_);
}
// 检查对象
for (unsigned i = 0; i < count; ++i) {
vm_range_t &range = ranges[i];
void * data = reinterpret_cast<void *>(range.address);
size_t size = range.size;
if (size < sizeof(ObjectStruct))
continue;
uintptr_t * pointers = reinterpret_cast<uintptr_t *>(data);
#ifdef __arm64__
Class isa = (__bridge Class)((void *)(pointers[0] & 0x1fffffff8));
#else
Class isa = reinterpret_cast<Class>(pointers[0]);
#endif
std::set<Class>::const_iterator result(choice->query_.find(isa));
if (result == choice->query_.end())
continue;
size_t needed = class_getInstanceSize(*result);
size_t boundary = 496;
#ifdef __LP64__
boundary *= 2;
#endif
if ((needed <= boundary && (needed + 15) / 16 * 16 != size) || (needed > boundary && (needed + 511) / 512 * 512 != size))
continue;
choice->result_.insert((__bridge id)(data));
}
不过这里一大堆的511、512的数字构成的公式,实话说我不是很懂,有了解的大佬麻烦告知我一下。
首先我们需要记住,JavaScript的基础类型如下:
- 字符串、
- 数字、
- 布尔、
- 数组、
- 对象、
- Null、
- Undefined
所以我们只要根据对应的进行转换就可以,如下所示:
题外话,JavaScript里面没有什么整数和浮点数类型区分一说,所以我们可以无脑将其通过double的方式构建
NSNumber
最后再来说下对对象类型的处理:
在JavaScript,任何对象都可以简单理解为包含了属性(方法)的一个包装体,如下所示:
var a = {x:10, y:100};
因此,我们在对类型进行转换的时候,要特别注意以下几点:
CGRect
之类的,是的话需要特别转换Date <-> NSDate
的转换。NSDictionary
之中。关于Calling Convention
,本文就不再赘述,有兴趣的读者可以参考我和同事一起写的知乎专栏iOS调试进阶
简单来重新描述下就是:
一个函数的调用过程中,函数的参数既可以使用栈传递,也可以使用寄存器传递,参数压栈的顺序可以从左到右也可以从右到左,函数调用后参数从栈弹出这个工作可以由函数调用方完成,也可以由被调用方完成。如果函数的调用方和被调用方(函数本身)不遵循统一的约定,有这么多分歧这个函数调用就没法完成。这个双方必须遵守的统一约定就叫做调用惯例(Calling Convention),调用惯例规定了参数的传递的顺序和方式,以及栈的维护方式。
由于业界已经有知名大佬写的libffi
,所以我们不需要重复发明轮子,直接使用即可。如果真的要了解具体原理,也可以参考我的文章,具体分析objc_msgSend
的实现流程。
为了偷懒,我直接用JavaScript实现了这些的效果。其实理论上,如果我完整的实现编译前端,构建抽象语法树分析执行上下文,将Objective-C的代码转换成JavaScript,那么就能实现动态执行Objective-C代码了。(当然本质上还是障眼法)
]]>其实更快的方式,且不能保证完全正确的方式,就是调用一下
JSPatchConvertor
就好了,哈哈哈。
DynamicCocoa
是基于JavaScriptCore搞得,一直期待看到他们的真正实现,不过可能后来由于公司机密,应该不能再开源了。
借着最近开始研究JavaScriptCore的契机,我决定利用这一两天所学的Jav]]>
大水文一篇
大水文一篇
大水文一篇
最近对Block
的一些实现细节又进行了一次复习,主要涉及的是捕捉变量的部分。有一个点我之前一直没太关注:对ivar
变量的直接访问为啥会产生循环引用。
在我原先的理解中,之所以会产生循环引用,绝大多数场景都是由于block
里面涉及了self关键字,比如[self doSomething]
(同理,对于property
的访问本质也是一堆方法),但是为啥对ivar
的访问也会导致循环引用呢?
不是直接采用 *(void *)address = xxx
这样的直接对编译好的静态地址赋值就好了?
当时傻逼了,写完本文后想想就算编译成地址了,基地址从哪算还是要依赖
self
变量。
还是回到runtime来看看吧,万变不离其宗,从objc_class
结构体看起:
struct objc_class : objc_object {
// Class ISA; // 8byte
Class superclass; // 8byte
cache_t cache; // formerly cache pointer and vtable // 4 + 4 + 8
class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
class_rw_t *data() {
return bits.data();
}
主要的运行时数据都是class_rw_t
表示,继续瞅瞅:
struct class_rw_t {
// Be warned that Symbolication knows the layout of this structure.
uint32_t flags;
uint32_t version;
const class_ro_t *ro;
其中class_ro_t
基本上是从二进制产物中读取的“副本”数据,我们看看:
struct class_ro_t {
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize;
#ifdef __LP64__
uint32_t reserved;
#endif
const uint8_t * ivarLayout;
const char * name;
method_list_t * baseMethodList;
protocol_list_t * baseProtocols;
const ivar_list_t * ivars;
看起来ivar_list_t
就是存放ivar
的列表,他的实现是一个模版类,看看具体结构表示:
struct entsize_list_tt {
uint32_t entsizeAndFlags;
uint32_t count;
Element first;
具体对应ivar
,替换掉模版就是:
struct ivar_list_t {
uint32_t entsizeAndFlags;
uint32_t count;
ivar_t first;
其中,ivar_t
表征的就是我们每个ivar
,
int32_t *offset;
const char *name;
const char *type;
嗯,从这里开始offset
是用一个int32_t *
的指针来表示,就开始有意思了。这里我们先暂时忽略
看起来,如果按照这种方式访问ivar
,整个流程要经过好多次指针转移:
class -> class.rw_data -> class.rw_data.ro_data -> class.rw_data.ro_data.ivars ->
-> class.rw_data.ro_data.ivars.first[n]
如果是这样,大量使用ivar
肯定很耗时。那么,对于ivar
的访问究竟是怎么玩的呢?
我们用如下这个非常简单的例子来瞅瞅:
typedef void(^MyBlock)(void);
@interface MyObject : NSObject
@property (nonatomic) NSUInteger haha;
@property (nonatomic, copy) MyBlock block;
- (void)inits;
@end
@implementation MyObject
- (void)inits
{
self.block = ^{
_haha = 5;
};
}
@end
int main(int argc, char * argv[]) {
MyObject *object = [MyObject new];
[object inits];
}
重写一把,基本转化成如下的形式:
typedef void(*MyBlock)(void);
#ifndef _REWRITER_typedef_MyObject
#define _REWRITER_typedef_MyObject
typedef struct objc_object MyObject;
typedef struct {} _objc_exc_MyObject;
#endif
// 注意点1!!!!!!!!!!!!!!!!!!!!
extern "C" unsigned long OBJC_IVAR_$_MyObject$_haha;
extern "C" unsigned long OBJC_IVAR_$_MyObject$_block;
struct MyObject_IMPL {
struct NSObject_IMPL NSObject_IVARS;
NSUInteger _haha;
MyBlock _block;
};
// @property (nonatomic) NSUInteger haha;
// @property (nonatomic, copy) MyBlock block;
// - (void)inits;
/* @end */
// @implementation MyObject
struct __MyObject__inits_block_impl_0 {
struct __block_impl impl;
struct __MyObject__inits_block_desc_0* Desc;
MyObject *self;
// 注意点2!!!!!!!!!!!!!!!
__MyObject__inits_block_impl_0(void *fp, struct __MyObject__inits_block_desc_0 *desc, MyObject *_self, int flags=0) : self(_self) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
};
// 注意点3!!!!!!!!!!!!
static void __MyObject__inits_block_func_0(struct __MyObject__inits_block_impl_0 *__cself) {
MyObject *self = __cself->self; // bound by copy
(*(NSUInteger *)((char *)self + OBJC_IVAR_$_MyObject$_haha)) = 5;
}
static void __MyObject__inits_block_copy_0(struct __MyObject__inits_block_impl_0*dst, struct __MyObject__inits_block_impl_0*src) {_Block_object_assign((void*)&dst->self, (void*)src->self, 3/*BLOCK_FIELD_IS_OBJECT*/);}
static void __MyObject__inits_block_dispose_0(struct __MyObject__inits_block_impl_0*src) {_Block_object_dispose((void*)src->self, 3/*BLOCK_FIELD_IS_OBJECT*/);}
static struct __MyObject__inits_block_desc_0 {
size_t reserved;
size_t Block_size;
void (*copy)(struct __MyObject__inits_block_impl_0*, struct __MyObject__inits_block_impl_0*);
void (*dispose)(struct __MyObject__inits_block_impl_0*);
} __MyObject__inits_block_desc_0_DATA = { 0, sizeof(struct __MyObject__inits_block_impl_0), __MyObject__inits_block_copy_0, __MyObject__inits_block_dispose_0};
static void _I_MyObject_inits(MyObject * self, SEL _cmd) {
((void (*)(id, SEL, MyBlock))(void *)objc_msgSend)((id)self, sel_registerName("setBlock:"), ((void (*)())&__MyObject__inits_block_impl_0((void *)__MyObject__inits_block_func_0, &__MyObject__inits_block_desc_0_DATA, self, 570425344)));
}
static NSUInteger _I_MyObject_haha(MyObject * self, SEL _cmd) { return (*(NSUInteger *)((char *)self + OBJC_IVAR_$_MyObject$_haha)); }
static void _I_MyObject_setHaha_(MyObject * self, SEL _cmd, NSUInteger haha) { (*(NSUInteger *)((char *)self + OBJC_IVAR_$_MyObject$_haha)) = haha; }
static void(* _I_MyObject_block(MyObject * self, SEL _cmd) )(){ return (*(MyBlock *)((char *)self + OBJC_IVAR_$_MyObject$_block)); }
extern "C" __declspec(dllimport) void objc_setProperty (id, SEL, long, id, bool, bool);
static void _I_MyObject_setBlock_(MyObject * self, SEL _cmd, MyBlock block) { objc_setProperty (self, _cmd, __OFFSETOFIVAR__(struct MyObject, _block), (id)block, 0, 1); }
// @end
int main(int argc, char * argv[]) {
MyObject *object = ((MyObject *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("MyObject"), sel_registerName("new"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)object, sel_registerName("inits"));
}
一大堆东西,没啥特别的地方,我们只要关注几个地方:
对于每个ivar
,都有对应的全局变量
extern "C" unsigned long OBJC_IVAR_$_MyObject$_haha;
extern "C" unsigned long OBJC_IVAR_$_MyObject$_block;
block_invoke对应的实现是通过对象自身作为基地址,全局变量作为偏移去对haha
这个ivar
进行赋值。
static void __MyObject__inits_block_func_0(struct __MyObject__inits_block_impl_0 *__cself) {
MyObject *self = __cself->self; // bound by copy
(*(NSUInteger *)((char *)self + OBJC_IVAR_$_MyObject$_haha)) = 5;
}
block的构造函数,确实捕捉了self
__MyObject__inits_block_impl_0(void *fp, struct __MyObject__inits_block_desc_0 *desc, MyObject *_self, int flags=0) : self(_self) {
impl.isa = &_NSConcreteStackBlock;
impl.Flags = flags;
impl.FuncPtr = fp;
Desc = desc;
}
由于全局变量的地址是在编译期就确定了,所以这里也就不难解释ivar_t
里面为什么要保存int32_t *
,保存的就是对应的全局变量地址。而全局变量的值则是对应的动态偏移。
水完了,其实虽然runtime的结构体设计的比较绕,但是最后对于变量的访问和很多静态语言设计一样,也不会损失很多性能。
从另外一个角度看,如果声明了巨多的ivar
,看来也会对包大小产生不可忽视的影响。
大水文一篇
大水文一篇
大水文一篇
最近对Block
的一些实现细节又进行了一次复习,主要涉及的是捕捉变量的部分。有一个点我之前]]>
欢迎加入我们手淘/天猫的架构组来内网阅读
提起iOS的内存管理,大多数人第一反应想到的都是引用计数、ARC
、AutoreleasePool
之类的词眼。但是事实上,这只是iOS内存管理的冰山一角,今天就让我们来探究水面之下的内存管理。
我之所以想研究这个议题,主要还是之前有个UC同事问了我一个问题:
“现在绝大多数都是ARM64的设备,即64位寻址空间,而且iOS上的通过malloc申请的内存只是虚拟内存,还不是真正物理内存,为什么分配了两三G就会失败了。”
按照他的想法,我在我的iPhone上测试了如下代码:
void *buffer = malloc(2000 * 1024 * 1024);
果不其然,报出了如下错误:
malloc: *** mach_vm_map(size=2097152000) failed (error code=3)
*** error: can't allocate region
次奥,我xxxxx分配一个2G虚拟内存就懵逼?
还是赶紧翻翻看源码,由于我分配的是一个超大的内存,所以按照nano_zone
和scalable_zone
的设计理念,
nano_zone
进行分配。赶紧翻翻scalable_zone
看看源码,如下所示:
void * szone_malloc_should_clear(szone_t *szone, size_t size, boolean_t cleared_requested)
{
void *ptr;
msize_t msize;
if (size <= SMALL_THRESHOLD) {
// tiny size: <1024 bytes (64-bit), <512 bytes (32-bit)
// think tiny
msize = TINY_MSIZE_FOR_BYTES(size + TINY_QUANTUM - 1);
if (!msize) {
msize = 1;
}
ptr = tiny_malloc_should_clear(szone, msize, cleared_requested);
} else if (size <= szone->large_threshold) {
// small size: <15k (<1GB machines), <127k (>1GB machines)
// think small
msize = SMALL_MSIZE_FOR_BYTES(size + SMALL_QUANTUM - 1);
if (!msize) {
msize = 1;
}
ptr = small_malloc_should_clear(szone, msize, cleared_requested);
} else {
// large: all other allocations
size_t num_kernel_pages = round_page_quanta(size) >> vm_page_quanta_shift;
if (num_kernel_pages == 0) { /* Overflowed */
ptr = 0;
} else {
ptr = large_malloc(szone, num_kernel_pages, 0, cleared_requested);
}
}
#if DEBUG_MALLOC
if (LOG(szone, ptr)) {
malloc_printf("szone_malloc returned %p\n", ptr);
}
#endif
/*
* If requested, scribble on allocated memory.
*/
if ((szone->debug_flags & MALLOC_DO_SCRIBBLE) && ptr && !cleared_requested && size) {
memset(ptr, SCRIBBLE_BYTE, szone_size(szone, ptr));
}
return ptr;
}
tiny_malloc
small_malloc
(视具体不同的设备内存上限不同)large_malloc
。OK,由于我们分配的非常大,我们可以确定我们的逻辑是落入large_malloc
中。需要特别注意的是:large_malloc
分配内存的基本单位是一页大小,而对于其他的几种分配方式,则不是必须按照页大小进行分配。
由于large_malloc
这个函数本身并没有特殊需要注意的地方,我们直接关注其真正分配内存的地方,即allocate_pages
,如下所示:
vm_addr = vm_page_quanta_size;
kr = mach_vm_map(mach_task_self(), &vm_addr, allocation_size, allocation_mask, alloc_flags, MEMORY_OBJECT_NULL, 0, FALSE,
VM_PROT_DEFAULT, VM_PROT_ALL, VM_INHERIT_DEFAULT);
if (kr) {
szone_error(szone, 0, "can't allocate region", NULL, "*** mach_vm_map(size=%lu) failed (error code=%d)\n", size, kr);
return NULL;
}
addr = (uintptr_t)vm_addr;
从上我们不难看出,如果分配失败,就是提示报错。而mach_vm_map
则是整个内存的分配核心。
可能你一开始看到这个mach_vm_map
会比较懵逼,可以先看下我下面这张图:
OK,是不是有冒出很多名词。没关系,你其实只要记住两点:vm_map
代表就是一个进程运行时候涉及的虚拟内存,pmap
代表的就是和具体硬件架构相关的物理内存。(这里我们暂时先不考虑submap这种情况)。
好,vm_map
本身是进程(或者从Mach内核的角度看是task的地址分布图)。这个地址分布图维护着一个双向列表,列表的每一项都是vm_entry_t
,代表着虚拟地址上连续的一个范围。而pmap
这个结构体代表了个硬件相关的内存转换:即利用pmap
这个结构体来描述抽象的物理地址访问和使用。
在继续深入我们的话题之前,我们还需要具备一个额外的知识,就是iOS上的进程创建和加载执行Mach-O过程。
类UNIX系统本质上是没法无缘无故创建出一个全新的进程的,基本上必须要通过fork
的形式来创建。(这块不太熟悉,有错请指正)。
在XNU的实现里,不论用户态调用posix
相关API还是别的API,落入到内核里面都走的是fork_create_child
函数来创建属于Mach内核的任务(task)。其实现如下:
thread_t
fork_create_child(task_t parent_task, coalition_t *parent_coalitions, proc_t child_proc, int inherit_memory, int is64bit, int in_exec)
{
thread_t child_thread = NULL;
task_t child_task;
kern_return_t result;
/* Create a new task for the child process */
result = task_create_internal(parent_task,
parent_coalitions,
inherit_memory,
is64bit,
TF_LRETURNWAIT | TF_LRETURNWAITER, /* All created threads will wait in task_wait_to_return */
in_exec ? TPF_EXEC_COPY : TPF_NONE, /* Mark the task exec copy if in execve */
&child_task);
if (result != KERN_SUCCESS) {
printf("%s: task_create_internal failed. Code: %d\n",
__func__, result);
goto bad;
}
if (!in_exec) {
/*
* Set the child process task to the new task if not in exec,
* will set the task for exec case in proc_exec_switch_task after image activation.
*/
// 注意点:
child_proc->task = child_task;
}
child_proc->task = child_task。
fork
出来的进程更像是一个空壳,我们需要利用这个进程壳去执行可执行文件变成我们通常意义上理解的程序进程
。
从XNU上来看,可执行的文件种类如下:
{ exec_mach_imgact, "Mach-o Binary" },
{ exec_fat_imgact, "Fat Binary" },
{ exec_shell_imgact, "Interpreter Script" }
这里咱们先只看最常用的Mach-O
文件:
exec_mach_imgact(struct image_params *imgp)
{
... 省略无数
if ((mach_header->magic == MH_CIGAM) ||
(mach_header->magic == MH_CIGAM_64)) {
error = EBADARCH;
goto bad;
}
if ((mach_header->magic != MH_MAGIC) &&
(mach_header->magic != MH_MAGIC_64)) {
error = -1;
goto bad;
}
if (mach_header->filetype != MH_EXECUTE) {
error = -1;
goto bad;
}
if (imgp->ip_origcputype != 0) {
/* Fat header previously had an idea about this thin file */
if (imgp->ip_origcputype != mach_header->cputype ||
imgp->ip_origcpusubtype != mach_header->cpusubtype) {
error = EBADARCH;
goto bad;
}
} else {
imgp->ip_origcputype = mach_header->cputype;
imgp->ip_origcpusubtype = mach_header->cpusubtype;
}
task = current_task();
thread = current_thread();
uthread = get_bsdthread_info(thread);
if ((mach_header->cputype & CPU_ARCH_ABI64) == CPU_ARCH_ABI64)
imgp->ip_flags |= IMGPF_IS_64BIT;
/* If posix_spawn binprefs exist, respect those prefs. */
psa = (struct _posix_spawnattr *) imgp->ip_px_sa;
if (psa != NULL && psa->psa_binprefs[0] != 0) {
int pr = 0;
for (pr = 0; pr < NBINPREFS; pr++) {
cpu_type_t pref = psa->psa_binprefs[pr];
if (pref == 0) {
/* No suitable arch in the pref list */
error = EBADARCH;
goto bad;
}
if (pref == CPU_TYPE_ANY) {
/* Jump to regular grading */
goto grade;
}
if (pref == imgp->ip_origcputype) {
/* We have a match! */
goto grade;
}
}
error = EBADARCH;
goto bad;
}
grade:
if (!grade_binary(imgp->ip_origcputype, imgp->ip_origcpusubtype & ~CPU_SUBTYPE_MASK)) {
error = EBADARCH;
goto bad;
}
/* Copy in arguments/environment from the old process */
error = exec_extract_strings(imgp);
if (error)
goto bad;
AUDIT_ARG(argv, imgp->ip_startargv, imgp->ip_argc,
imgp->ip_endargv - imgp->ip_startargv);
AUDIT_ARG(envv, imgp->ip_endargv, imgp->ip_envc,
imgp->ip_endenvv - imgp->ip_endargv);
/* reset local idea of thread, uthread, task */
thread = imgp->ip_new_thread;
uthread = get_bsdthread_info(thread);
task = new_task = get_threadtask(thread);
// 注意点:
lret = load_machfile(imgp, mach_header, thread, &map, &load_result);
... 省略无数
整个代码都没啥用,就是做些检查,分配个进程壳,然后通过load_machfile
加载真正的二进制文件。
load_return_t
load_machfile(
struct image_params *imgp,
struct mach_header *header,
thread_t thread,
vm_map_t *mapp,
load_result_t *result
)
{
... 省略一大堆
if (macho_size > file_size) {
return(LOAD_BADMACHO);
}
result->is64bit = ((imgp->ip_flags & IMGPF_IS_64BIT) == IMGPF_IS_64BIT);
task_t ledger_task;
if (imgp->ip_new_thread) {
ledger_task = get_threadtask(imgp->ip_new_thread);
} else {
ledger_task = task;
}
// 注意点1
pmap = pmap_create(get_task_ledger(ledger_task),
(vm_map_size_t) 0,
result->is64bit);
// 注意点2
map = vm_map_create(pmap,
0,
vm_compute_max_offset(result->is64bit),
TRUE);
#if defined(__arm64__)
// 注意点三
if (result->is64bit) {
/* enforce 16KB alignment of VM map entries */
vm_map_set_page_shift(map, SIXTEENK_PAGE_SHIFT);
} else {
vm_map_set_page_shift(map, page_shift_user32);
}
pmap_create
创建硬件相关的物理内存抽象。vmap_create
创建虚拟内存的地址图。别的没啥关注,我们重点关注vm_map_create
0
和vm_compute_max_offset(result->is64bit)
。
因为这个代表了这个任务分配的虚拟地址上下限!这个函数的实现如下:
vm_map_offset_t
vm_compute_max_offset(boolean_t is64)
{
#if defined(__arm__) || defined(__arm64__)
return (pmap_max_offset(is64, ARM_PMAP_MAX_OFFSET_DEVICE));
#else
return (is64 ? (vm_map_offset_t)MACH_VM_MAX_ADDRESS : (vm_map_offset_t)VM_MAX_ADDRESS);
#endif
}
继续往下看:
vm_map_offset_t pmap_max_offset(
boolean_t is64 __unused,
unsigned int option)
{
vm_map_offset_t max_offset_ret = 0;
#if defined(__arm64__)
assert (is64);
vm_map_offset_t min_max_offset = SHARED_REGION_BASE_ARM64 + SHARED_REGION_SIZE_ARM64 + 0x20000000; // end of shared region + 512MB for various purposes
if (option == ARM_PMAP_MAX_OFFSET_DEFAULT) {
max_offset_ret = arm64_pmap_max_offset_default;
} else if (option == ARM_PMAP_MAX_OFFSET_MIN) {
max_offset_ret = min_max_offset;
} else if (option == ARM_PMAP_MAX_OFFSET_MAX) {
max_offset_ret = MACH_VM_MAX_ADDRESS;
} else if (option == ARM_PMAP_MAX_OFFSET_DEVICE) {
if (arm64_pmap_max_offset_default) {
max_offset_ret = arm64_pmap_max_offset_default;
} else if (max_mem > 0xC0000000) {
max_offset_ret = 0x0000000318000000ULL; // Max offset is 12.375GB for devices with > 3GB of memory
} else if (max_mem > 0x40000000) {
max_offset_ret = 0x0000000218000000ULL; // Max offset is 8.375GB for devices with > 1GB and <= 3GB of memory
} else {
max_offset_ret = min_max_offset;
}
} else if (option == ARM_PMAP_MAX_OFFSET_JUMBO) {
max_offset_ret = 0x0000000518000000ULL; // Max offset is 20.375GB for pmaps with special "jumbo" blessing
} else {
panic("pmap_max_offset illegal option 0x%x\n", option);
}
assert(max_offset_ret >= min_max_offset);
return max_offset_ret;
其实关键点就是这里的代码:
if (max_mem > 0xC0000000) {
max_offset_ret = 0x0000000318000000ULL; // Max offset is 12.375GB for devices with > 3GB of memory
} else if (max_mem > 0x40000000) {
max_offset_ret = 0x0000000218000000ULL; // Max offset is 8.375GB for devices with > 1GB and <= 3GB of memory
} else {
max_offset_ret = min_max_offset;
}
max_offset_ret
这个值就代表了我们任务对应的vm_map_t
的最大地址范围,比如说这里是8.375GB。
好,在说了那么多前置知识后,我们言归正传,来谈谈为什么虚拟内存有限制。
之前我们提到了large_malloc
会走入到最后的vm_map_enter
,那么我们来看看vm_map_enter
的实现:
vm_map_enter(
vm_map_t map,
vm_map_offset_t *address, /* IN/OUT */
vm_map_size_t size,
vm_map_offset_t mask,
int flags,
vm_map_kernel_flags_t vmk_flags,
vm_tag_t alias,
vm_object_t object,
vm_object_offset_t offset,
boolean_t needs_copy,
vm_prot_t cur_protection,
vm_prot_t max_protection,
vm_inherit_t inheritance)
{
#if CONFIG_EMBEDDED
// 注意点1:检查页的权限
if (cur_protection & VM_PROT_WRITE){
if ((cur_protection & VM_PROT_EXECUTE) && !entry_for_jit){
printf("EMBEDDED: %s: curprot cannot be write+execute. "
"turning off execute\n",
__FUNCTION__);
cur_protection &= ~VM_PROT_EXECUTE;
}
}
#endif /* CONFIG_EMBEDDED */
if (resilient_codesign || resilient_media) {
if ((cur_protection & (VM_PROT_WRITE | VM_PROT_EXECUTE)) ||
(max_protection & (VM_PROT_WRITE | VM_PROT_EXECUTE))) {
return KERN_PROTECTION_FAILURE;
}
}
// 1. 获取任务的可用的地址最小值和最大值
effective_min_offset = map->min_offset;
effective_max_offset = map->max_offset;
if (map->pmap == kernel_pmap) {
user_alias = VM_KERN_MEMORY_NONE;
} else {
user_alias = alias;
}
#define RETURN(value) { result = value; goto BailOut; }
assert(page_aligned(*address));
assert(page_aligned(size));
if (!VM_MAP_PAGE_ALIGNED(size, VM_MAP_PAGE_MASK(map))) {
clear_map_aligned = TRUE;
}
StartAgain: ;
start = *address;
if (anywhere) {
vm_map_lock(map);
map_locked = TRUE;
if (start < effective_min_offset)
start = effective_min_offset;
if (start > effective_max_offset)
RETURN(KERN_NO_SPACE);
if( FALSE ) {
} else {
if (map->holelistenabled) {
hole_entry = (vm_map_entry_t)map->holes_list;
if (hole_entry == NULL) {
/*
* No more space in the map?
*/
result = KERN_NO_SPACE;
goto BailOut;
} else {
boolean_t found_hole = FALSE;
do {
if (hole_entry->vme_start >= start) {
start = hole_entry->vme_start;
found_hole = TRUE;
break;
}
if (hole_entry->vme_end > start) {
found_hole = TRUE;
break;
}
hole_entry = hole_entry->vme_next;
} while (hole_entry != (vm_map_entry_t) map->holes_list);
if (found_hole == FALSE) {
result = KERN_NO_SPACE;
goto BailOut;
}
entry = hole_entry;
if (start == 0)
start += PAGE_SIZE_64;
}
}
}
while (TRUE) {
vm_map_entry_t next;
end = ((start + mask) & ~mask);
end = vm_map_round_page(end,
VM_MAP_PAGE_MASK(map));
if (end < start)
RETURN(KERN_NO_SPACE);
start = end;
end += size;
if ((end > effective_max_offset) || (end < start)) {
RETURN(KERN_NO_SPACE);
}
next = entry->vme_next;
if (map->holelistenabled) {
if (entry->vme_end >= end)
break;
} else {
if (next == vm_map_to_entry(map))
break;
if (next->vme_start >= end)
break;
}
entry = next;
if (map->holelistenabled) {
if (entry == (vm_map_entry_t) map->holes_list) {
result = KERN_NO_SPACE;
goto BailOut;
}
start = entry->vme_start;
} else {
start = entry->vme_end;
}
start = vm_map_round_page(start,
VM_MAP_PAGE_MASK(map));
}
if (map->holelistenabled) {
if (vm_map_lookup_entry(map, entry->vme_start, &entry)) {
panic("Found an existing entry (%p) instead of potential hole at address: 0x%llx.\n", entry, (unsigned long long)entry->vme_start);
}
}
*address = start;
}
老实说,我一开始看苹果这最新的XNU代码,我压根没读懂。这一堆hole
啥的在干啥,后来我就往之前XNU版本翻了翻,果然好懂了很多:
entry = map->first_free;
if (entry == vm_map_to_entry(map)) {
entry = NULL;
} else {
if (entry->vme_next == vm_map_to_entry(map)){
entry = NULL;
} else {
if (start < (entry->vme_next)->vme_start ) {
start = entry->vme_end;
start = vm_map_round_page(start,
VM_MAP_PAGE_MASK(map));
} else {
entry = NULL;
}
}
}
if (entry == NULL) {
vm_map_entry_t tmp_entry;
if (vm_map_lookup_entry(map, start, &tmp_entry)) {
assert(!entry_for_jit);
start = tmp_entry->vme_end;
start = vm_map_round_page(start,
VM_MAP_PAGE_MASK(map));
}
entry = tmp_entry;
}
整个这段代码的意思是,就是要我们要找个一个比我们这个start
地址大的vm_entry_t
。(这句话比较绕口),我们最终的目的是为了在两个已经存在vm_entry_t
之间尝试插入一个能包含从start
到start + size
的新的vm_entry_t
如果没找到的话,就尝试利用vm_map_lookup_entry
找一个preceding
我们地址的的vm_entry_t
。
好,现在我们找到了一个满足start
其实地址条件的vm_entry_t
了,剩下就是要满足分配大小size
的需求了。
while (TRUE) {
register vm_map_entry_t next;
end = ((start + mask) & ~mask);
end = vm_map_round_page(end,
VM_MAP_PAGE_MASK(map));
if (end < start)
RETURN(KERN_NO_SPACE);
start = end;
end += size;
if ((end > effective_max_offset) || (end < start)) {
RETURN(KERN_NO_SPACE);
}
next = entry->vme_next;
// 如果是空的头
if (next == vm_map_to_entry(map))
break;
// 如果下一个的start
if (next->vme_start >= end)
break;
entry = next;
start = entry->vme_end;
start = vm_map_round_page(start,
VM_MAP_PAGE_MASK(map));
}
*address = start;
assert(VM_MAP_PAGE_ALIGNED(*address,
VM_MAP_PAGE_MASK(map)));
这段代码相对来说就很简单了,我们判断start + size
是不是可以正好插入在vm_entry_t
代表的地址范围的空隙内,如果一直遍历到最后的任务地址上限都找不到,那就说明不存在我们需求的连续的虚拟内存空间用于作分配了。
除了本文说明的虚拟内存分配的连续性限制以外,虚拟内存作为堆内存分配的一种,在布局范围上也有限制。此文不表,且听下回分解。
]]>欢迎加入我们手淘/天猫的架构组来内网阅读
提起iOS的内存管理,大多数人第]]>