阅读视图

发现新文章,点击刷新页面。

Pybind11 理解

Pybind11 源码分析

MNN 社区上提供了通过 Python 使用 MNN 的方式,具体可以见:

https://github.com/alibaba/MNN/tree/master/pymnn/src

我们通过 Pybind11 来提供比较优雅的桥接方式。由于 Pybind 11 是一层抽象 C++ 到 Python 桥接的库,上层封装了很多难以理解的细节和流程,本文就带大家抽丝剥茧一下。

关于 Python 桥接,如果你不是很了解,那么在阅读本文之前,请记住如下两句话:

  • 代码必须要以传统的 module 添加类的方式来进行,相关代码是:

    • PyInitModule
    • PyModule_AddObject(xxx)
  • 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 这样的方法。

    截屏2020-07-27 下午5.33.02.png

  • 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,见:

      截屏2020-07-27 下午6.14.57.png

      所以,本质上无论 Pybind11 在干什么,都是利用 CPython 底层的技术在那里操作。

    • 把对应的方法加入到这个 CPython Module 中,如下:

      截屏2020-07-27 下午6.17.23.png

      截屏2020-07-27 下午6.17.30.png

创建类

创建类的方法相对难一点,但是也不难理解,我们还是找到根源 class class_(其实一切只要理解 Pybind11 是用 C++ 去模拟 CPython 的流程就行了)

找到对应的构造函数,首先从函数签名上我们就能窥探一些东西:

截屏2020-07-27 下午6.38.07.png

  • scope 对应类所属的模块。
  • name 就是类名。
  • Extra 就是 C++ 模版机制对应的真正类。

看起来上面和对应的 Python 类型初始化没关系,来看看是不是 generic_type::initialize 造成的。

截屏2020-07-27 下午6.44.13.png

  • make_new_python_type 这名字一看就很符合,哈哈,点进去一看,果然是类型初始化流程。

截屏2020-07-27 下午6.46.15.png

结论:

简要概括下添加方法的实现。

  • 生成模版化的初始化模块方法,即 Wrapper 方法。

  • 提供一个仿造 Python Module 对象(便于 C++ 编写 / 使用智能指针管理引用计数),在对应的自定义函数里面添加对应 Module 的实现。

TrampolineHook - 解决栈污染问题支持变参 Hook

在之前的文章《基于桥的全量方法 Hook 方案(3)- TrampolineHook》 的文末,我说如果对汇编熟悉的同学可能会发现我之前实现的一个错误 - 关于上下文污染

一提到到上下文污染,可能我们绝大多数人想到的都是寄存器污染,但是实际上还有一个不容我们忽视的上下文资源:,过去可能大家常见的 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 函数

如果不理解, 可以参考这张图:

而当进入 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 函数的目的以及使用方式。

举个非常常见的统计函数调用耗时的例子,在这个情形中,一般只用关注 x0x1 两个参数 来记录是什么类什么函数的调用。这种情况下,你的上下文保存可以极简,甚至只要保存 x0, x1 即可。

TrampolineHook 想要提供的拦截器,是一个通用的拦截器,我不能保证其内部的实现,因为我需要保留的上下文就必须很完整。

后续 TrampolineHook 除了完善对 x86_64 的支持外,还有两个比较大的技术目标,也会慢慢完善。如果有什么使用中遇到的问题或者 Bug 也欢迎提交代码。

基于桥的全量方法 Hook 方案(3)- TrampolineHook

本来以为是双休日,结果五一调休本周末只休一天,懵逼。不过还算完成了承诺,赶了出来。

开源地址:https://github.com/SatanWoo/TrampolineHook

TrampolineHook 是什么

之前杨萧玉在看到我《基于桥的全量方法 Hook 方案(2) - 全新升级》 后就问我这个和直接用 method_exchangeImplementation 之类的 runtime 方法交换 IMP 性能对比咋样?

所以这篇文章开头先占用大家宝贵的两分钟,简要说明下。

TrampolineHook 本质上不是用来 Swizzling 的框架,取 Hook 这个名字只是为了读起来顺口。它实际上是一个中心重定向框架。 换句话说,你可以认为它是为了通过一个函数替换/拦截所有你想要函数的框架。

其实这个中心重定向的思想并不新潮,很多人(包括我自己)在内就曾经利用重载 objc_msgForward 干过这样的事。

但是这个方式我在之前的文章里也提到过对应的缺点,比如:

  • 性能慢
  • 不能替换/拦截同一个继承链上的多个类。

所以可以认为 TrampolineHook 是一个让你不用关注底层架构Calling Convention(因为涉及到汇编),不用关心上下文信息保存、恢复,不用担心引入传统 Swizzle 方案在大型项目中有奇奇怪怪 Crash 问题的中心重定向框架。

TrampolineHook 技术原理

整个技术原理其实可以分为三部分:

  • vm_remap 技术。

  • 流程设计。

  • 汇编实现。

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 映射。

上述图片引用自Implementing imp_implementationWithBlock()

因此,我们可以通过在编写代码的过程中,精心构造、预留在程序二进制的代码页,在运行时不断“复制映射”,来完成特殊的使命。

在我们的定义中,我们是构造了连续的两个页

流程设计

要构造特殊的程序二进制代码,首先还是要梳理我们的目的,我们的诉求是所有的函数都能先进入我们的一个中心重定向函数,执行自定义的操作,然后返回原函数,同时这个调用栈不能乱。

  • 把一个我们要替换的原方法 IMP A 取出来,保存起来。
  • 给这个原方法塞一个动态分配的可执行地址 B。
  • 当执行这个原方法的时候,会跳转到 可执行地址 B。
  • 这个 B 经过一段简短的运算操作,可以获取到原先保存的 IMP A。
  • 在跳转回 IMP A 之前,统一拦截函数先做些事情,比如检查是不是主线程调用之类的。

【注意】:在整个过程中,我们要保证参数寄存器、返回地址等不能错乱。

汇编实现

既然 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 的方式。

TrampolineHook 用处

和传统的 Swizzle 需要提供对应的替换后的函数实现不同,中心化重定向思想可以帮助你实现很多有意思的事情:

  • 比如网上很常见的 hook objc_msgSend,可以帮你查看任意被 Hook 二进制中的函数耗时和调用链路。

  • 比如 Bang / AnyMethodLog 这样的重定向 Log 日志框架等等。

苹果著名的 MainThreadChecker 也用了类似的技术。由于我才疏学浅,只是大致完成了对其实现的逆向,通过 TrampolineHook 进行了重写。 因为效果还不错,所以也开源了出来,地址是:https://github.com/SatanWoo/TrampolineHook/tree/master/Example/MainThreadChecker

这次在重写 MainThreadChecker 的过程中,我也对比了下和 2017 年苹果实现的差异。在整体流程上没有比较大的差异,但是还是有一些细节可以分享分享:

  • iOS 10 的时候对应的二进制是 UIKit,到了 iOS 12/13 成了 UIKitCore,所以原先获取二进制的逻辑失效了,为了避免后续版本的变更干扰,我采用了苹果自身的守候,通过 class_getImageName([UIResponder class]) 来保证获取的就是我们理解上的 UIKit 动态库。

当然 TrampolineHook 的作用不止于此,争取过段时间把我的一些想法做完善再和大家交流。

后续思考

本质上 Trampolinevm_remap 技术不是新的技术,很早就有人应用了,构造 Trampoline 实际上在苹果自身关于 Block 的实现中就有。业界也有 SwiftTrace 也是用了对应的技术。

真正的关键在于你用 Trampoline 做什么?用途的不同也决定了效果的不同,这也是我把之前的代码重写 TrampolineHook 中所收获的,而且随着 TrampolineHook 相对我自身之前实现的优化,我发现眼前豁然开朗,能玩的事情还有很多,哈哈。

对了,如果有朋友对 arm64 的汇编比较熟悉,同时对函数调用也比较了解的话,会很快的发现我上述提供的汇编代码存在一个漏洞(虽然这个漏洞绝大多数人用不到),感兴趣的朋友可以微信交流下。

开源地址:https://github.com/SatanWoo/TrampolineHook 如果大家有什么想法或者遇到了自身项目中的 Bug,欢迎 issue。

基于桥的全量方法 Hook 方案(2) - 全新升级

如果读过我的博客的人可能知道,我在 2017 年曾经研究过当时苹果出的一个新玩意 MainThread Checker,并以此为基础推导了一个基于桥的全量 Hook 方案,基于桥的全量方法Hook方案 - 探究苹果主线程检查实现 。当时简单写了下 ARM64 的方案代码,并放在了 Github 上,不过已废弃。

当时觉得自己研究的还算深入,基于汇编写(其实是复制粘贴)了一大堆的桥,可以针对性 Hook 一个或多个二进制,比如 UIKit 的逻辑,觉得挺屌的。

但是使用中发现了两个巨大的问题:

  • 性能问题。由于我是运行时的方案,没法对二进制产物进行修改(比如编译插桩),因此如果要能达到对二进制所有方法中心重定向的效果,借助了 forwarding 的流程(不是objc_msgForward)。但是这个方案懂的人肯定明白,性能巨慢。

不能上线的方案其实价值都不大。

  • Crash 问题。尽管我通过汇编的方式解决了中心重定向 Hook 方案上对一条继承链重复 Hook 会死循环的 Crash 问题(如果你不理解,可以回到我文章开头所提及的文章了解原因或者查看 Aspects 库中对应的 issue), 但是却出乎意料的引入了系统库新的 Crash,这个我会开一篇新的文章来分析。

因此,当时这个方案我就抛弃了,后续也因为我不怎么搞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 上的代码等我周末慢慢整理下开源。

目标检测之 Selective Search


最近因为工作上的事,搞了一点非常基础目标检测相关的东西。正好在学习之余梳理了下之前自己认知错误的一些地方,记录一下。

起因


之前对于目标检测的了解停留于深度学习部分,比如 Fast-RCNN / Faster-RCNN / Yolo 等等,对于候选框域搜索算法主要还是对于 RPN 的认知。


但是这次在工作中了解到了 Selective Search 的概念,没想到在小样本训练的过程中精度也不错,性能还很好,哈哈。因此决定深入研究下。Selective Search 从大类上也可以属于 Region Proposal 的思想,但是主要的思想却是来源于传统的图像处理。


相关的论文发表于 IJCV 2013 《Selective Search for Object Detection》,大家可自行阅读获取更多细节。

主要还是学习目的,业界主流的还是采用 Faster-RCNN 的做法。

Selective Search


目标检测问题相对来说比图像分类复杂点,因为一般情况下要同时检测出多个子物体的位置(及可能需要的分类目的)。最原始的做法就是对于一张图像的每个可能位置都进行搜索,但是这里会产生一个两个互相增加复杂度的问题?

  • 我们要识别的物体在哪?我们要识别的物体大小是多少?长宽比要不要考虑?


简单来说,假设知道一个待识别的物体左上角顶点处于(x, y),那么长和宽分别设置多少呢?设置小了,可能没有办法得到正确要识别的物体;设置大了,可能又把要分开区分的两个或多个物体合在了一起。


因此,这种传统的做法产生的搜索空间基本可以认为是无穷尽的。


那么自然而然地,我们的优化的想法肯定是减少搜索空间的大小!怎么做呢?


答案说难也不难,就是只找哪些可能是物体的区域。从区域这个维度进行搜索,而不是全图像的像素级查询。

全图搜索绝大多数的搜索像素包含区域是不包含物体的,实质上是浪费,可以通过如下两张图进行直观对比。








基于此,作者首先利用图像分割的想法,来获取可能是物体的区域;当然,这种层次的分割肯定不准


进一步地,考虑掉物体之间诸如包含等关系,通过
合并的方式来构建层次化**的潜在物体区域。


所以整篇论文的核心就可以归纳为如下的数学公式:



  • 通过图像分割算法得到初始区域集合 R = {r1, ….. rn},这个很容易理解吧,就是图像分割。
  • 设定一个相似集合 S,初始为
  • 对于初始区域集合相邻中的每一对(ri, rj),计算相似度(下文会说如何计算相似度),得到 s(ri, rj),将其加入之前的相似集合 S 中。
  • 当 S 不为空的时候,从 S 中获取相似度最大的一对 s(ri, rj),将这两个 ri, rj 区域合并,称为 rt。
  • 把所有和 ri, rj 相关的相似度对都从 S 中移除掉。(ri, rj 已经不存在了,变身为 rt)
  • 把新得到的 rt,在分别和其邻区域的 rx 们,计算相似度对,存入 S 中。
  • 把 rt 加入到区域集合 R 中。
  • 重复步骤,知道合并到最后只有一个区域了(即 S 为空)。


这个时候,R 集合中的所有区域,就是通过 Selective Search 得到的候选框区域。


值得注意的是,这种计算方式得到的 R,本身就包含了多层次的关系。

如何合并


前面我们提到了,我们初始的待定区域是基于图像分割得到的一批候选集,但是这些候选集的质量还比较“糙”,粒度也不一定对,需要合并甚至多次合并来处理一下。因此,如何合并也是一个相对值得思考的问题。

截屏2020-04-07上午12.33.29.png

上两张图不难看出,初始化的图像分割对于目标检测来说是不能直接使用的。


其实这篇文章,作者也坦诚道:图片的样式千变万化,某些图片里面可行的方案到了另外一些图片中就不适用了。 因此,作者采用了多种方案混合的合并方法。

  • 比如,背景色大块区域和前景色不同的主体可以很明显区分。
  • 比如,材质 / 纹理等也可以比较明显区分出待检测的物体。
  • 比如,形状和大小也可以做为检测手段区分待检测物体。


有了这些可以参考的思路,作者设计了四合一的合并公式。

  • 颜色相似度
  • 纹理相似度,这里使用了 SIFT 算法。
  • 小区域合并优先级度。这里解释下,作者为了避免出现“大鱼吃小鱼”的现象,即一块区域不断膨胀,吞并周围区域,所以采用了尽量将小区域先分别合并,始终保持大小类似的方式。
  • 距离。如果区域ri包含在rj内,毫无疑问应该立刻合并,另一方面,如果ri很难与rj相接,不应该合并在一块。这里定义区域的合适度距离主要是为了衡量两个区域是否更加“吻合”,其指标是合并后的区域的Bounding Box(能够框住区域的最小矩形BBij)越小,其吻合度越高。

Selective Search 代码理解


读顶尖学术会议论文的好处就是一般对应的代码都会开源,即使论文读的云里雾里,但是只要能大致理解思路,配合源代码深入分析,总是能懂。


这篇论文对应的代码开源在Selective Search,代码总计也就 300+ 行(当然有些非核心代码直接依赖了库),很容易理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def selective_search(
im_orig, scale=1.0, sigma=0.8, min_size=50):
'''Selective Search

assert im_orig.shape[2] == 3, "3ch image is expected"

# load image and get smallest regions
# region label is stored in the 4th value of each pixel [r,g,b,(region)]


1】图像分割
img = _generate_segments(im_orig, scale, sigma, min_size)

if img is None:
return None, {}


2】获取对象总大小
imsize = img.shape[0] * img.shape[1]

3】获取初始集合
R = _extract_regions(img)

# extract neighbouring information

4】计算相邻的区域
neighbours = _extract_neighbours(R)

5】计算初始化的相邻区域相似度
S = {}
for (ai, ar), (bi, br) in neighbours:
S[(ai, bi)] = _calc_sim(ar, br, imsize)


6】就是之前我们说的搜索过程
# hierarchal search
while S != {}:

# get highest similarity
i, j = sorted(S.items(), key=lambda i: i[1])[-1][0]

# merge corresponding regions
t = max(R.keys()) + 1.0
R[t] = _merge_regions(R[i], R[j])

# mark similarities for regions to be removed
key_to_delete = []
for k, v in list(S.items()):
if (i in k) or (j in k):
key_to_delete.append(k)

# remove old similarities of related regions
for k in key_to_delete:
del S[k]

# calculate similarity set with the new region
for k in [a for a in key_to_delete if a != (i, j)]:
n = k[1] if k[0] in (i, j) else k[0]
S[(t, n)] = _calc_sim(R[t], R[n], imsize)

regions = []
for k, r in list(R.items()):
regions.append({
'rect': (
r['min_x'], r['min_y'],
r['max_x'] - r['min_x'], r['max_y'] - r['min_y']),
'size': r['size'],
'labels': r['labels']
})

return img, regions

  • 第一步,通过经典的图像分割算法获取分割的块。这一步留到后续研究 felzenszwalb 算法再说吧,暂时我也不会。
  • 其实第一步已经得到对应的区域了,但是在算法实现上只是做了一个个标记,所以还需要处理下,变成我们需要的 R 集合。这步里面已经做好了大量的计算处理,后续直接按照论文层级化调用就行。
  • 计算相邻的区域,对应产生初始的 S 集合。
  • 对相邻的区域计算最大相似度,然后合并。
  • 后面就重复我上问的内容了。


大致内容就这样,当然细节还有不少值得研究的,可以继续深入,后续再读读。


最后,作者这 Python 写的真是溜。

实现 MNN 模型的可视化工具

Netron 是一个支持 TensorflowPyTorchMXNetNCNNPaddlePaddle 等深度模型格式的可视化框架。去年国庆前的时候我稍微研究了下相关的代码,重点关注其将其是如何设计出一套兼容不同模型格式表征,用来归一化展现不同的深度学习框架模型。

研究完成后,我利用如下两个 Commit 作为 Pull Request 提交给了作者,用以支持 MNN 的模型可视化。

从中也不难看出我扎实的英语表述能力(我果然是个国际化人才)。

这篇文章会从架构设计、标准定义、巧用JS解析等几个方面来阐述

架构设计

整体上,按照我个人的理解,Netron 的架构可以简要展现如下:

最基础的应用部分及运行环境,是 Electron 这个跨平台框架直接呈现的。
当然,一些诸如基础zip/gzip用于解压等等的库我们也统一归类到支撑里。

然后是一套经典的 MVC 的结构,app.js 作为整体的 controller ,负责整个应用的功能逻辑,如导出图片、菜单管理、保存加载等等。这一层我们需要的做事非常少,只要将 MNN 支持的模型后缀 .mnn 注册进去即可。 然后是是对应的 view.js,这块实际上还是一层 controller,类比我们常说的子控制器,专门用于处理主视图的逻辑,如下图所示:

从这块开始,我们就要注意了,因为这里开始通过工厂方法对应的根据读取文件类型的不同,托管给了不同的自定义 xxx.js 来处理后续步骤。 比如.marmodelprototxt 等格式的模型会首先托管给 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 为例,其对应的模型结构大致如下图所示:

上图引用自FlatBuffers,MNN模型存储结构基础 —- 无法解读MNN模型文件的秘密

同理, TFLite 的模型也可见 TFLite.schema,不再赘述。

从定义中不难看出,TFLitemodelgraphSubGraph 等;而 MNN 对应的就是Net;再往下一层 TFLiteOperatorOptions;而 MNNOPOPParameter;至于 NCNN 则是 Layer

如果是从整个架构角度去兼容不同的框架,必然会有着大量的 messy code。因此作者定义了一套标准表征,让不同的深度模型自己去解析,然后附着自身的逻辑到这同一套表征上。

  • Model ,表示模型的静态表示。
  • Graph ,表示模型的计算图表示。
  • Node ,一个操作对应一个节点。
  • Tensor ,输入输出数据。
  • Parameter ,对应的属性。
  • Argument ,对应的属性值。

上述 ParameterArgument可以简单认为一一对应吧,都认为是属性值即可。

一图胜千言,下图比较好的展现了术语和对应的表征:

这样不同的框架模型只要在自己对应的 xxx.js 中,把图,OP对应的数据填充至对应的地方即可。

这里依然以 MNN 举例:

  • 我们不存在 subgraph 的概念,直接把 ModelGraph 等价于一个 net即可。
  • net 中取出 oplist ,对应创建成 Node
  • oplist 中每个 op ,取出对应的 tensorIndex,根据 nettensorNametensorIndex 来创建对应的 tensor
  • op 中根据 opparameter 的种类,从 op.main 中取出不同的数据来填入 paramter / argument这块是解析的大头,如果没想好方式,就会非常浪费时间,下文重点说。

数据格式

诸如 MNNTFlite 都选用了 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 举例,它又有几个对应的参数:weightbiasquanParametersymmetricQuanpadXpadYkernelXkernelY 等等,需要解析。

一开始我采用了人肉的解析方式,代码就成了 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 ,可维护性和后续的扩展也不够。

我们要巧用 JavaScriptReflect 能力以及属性等于与字符串值属性的特性

_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 及其对应的属性解析。

了解 SIMD 指令

了解 SIMD 指令

SIMD 是一种常见的利用单指令完成多数据量处理的计算方式。本文作为 SIMD 文章的引子,先来了解简单的 SIMD 使用和概念。

SIMD 的含义

SIMD 的全称是 Single Instruction Multiple Data。简要来说,就是通过一条指令完成多条数据处理的行为。我们知道,虽然程序是由一条条机器指令组成,但是实际上执行一条机器码包含了多个过程,包含取指令、分析指令到执行等,如下图所示(暂时先忽略流水线并行)

而在这其中,每一个阶段,都会消耗一个或多个机器周期。如果我们认为,取指令和分析指令(译码)可以近似的认为是一个机器周期内完成,那么不同的指令,在执行阶段耗费的机器周期则大不相同。

举个例子,可能加法指令的执行阶段需要两个机器周期;而乘法可能需要5-6个机器周期。那么,当我们无法缩短指令的执行周期缩短的时候,利用 SIMD 技术,则可以在相同的执行周期内完成更多的数据处理,这样也同等的提升了单位时间内的数据吞吐,提高了计算性能。

在 Intel 的手册上,提供了包含 MMX, SSE, AVX 等系列的并行指令,面向不同长度的数据并行,比如:

  • MMX 并行计算 64bit 的数据。
  • SSE 并行计算 128bit 的数据。
  • AVX 并行计算 256bit 的数据。
  • AVX512 并行计算 512bit 的数据。

更多详细的使用可以参考:

Intel 手册

SIMD 的使用方式

由于绝大多数的人对 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 实现求和加法

既然说了 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 上可以得到大致如下两个性能耗时:

  • 常规方法 【774 us】
  • SIMD 【560 us】

别小看这一点的性能差距,对于大运算量的端侧深度学习可就有很显著的差距了。

后记

本文只是仅仅介绍了最常规的 SIMD 使用方式。但是在实际设计的过程中,不可能像我们这么简单的去应用。随之而来的,你会发现伴随着许多不同的坑,包含不规范的应用导致性能的下降崩溃问题。这些都会留在后面我们去解决。

浅谈移动工程师跨界机器学习之路

题记

相信从事移动开发的朋友们肯定看到过一个表情包:“iOS 开发没人要啦”。

15706761042966.jpg

虽说是搞笑之图,却也反映了移动开发领域的部分焦虑感。网上甚至有文章贴出“难上加难”的数据,称:“相比于 2017 年,2018 年 Android 程序员人均面邀数减少40%,iOS 程序员降幅更高达57%,即平均每个移动端程序员在找工作时收到的面邀数比去年减少一半。”

撇开玩笑之言,移动开发人员的焦虑感来自何处?我从自身角度及与他人沟通,大致归纳出如下几点:

  • 跨平台框架、如 Flutter 对 Native 研发模式的冲击。
  • 业界关注重点从移动时代向人工智能等领域转移。
  • 对自身掌握技术壁垒的担忧。

细细品味这三点,我想开发者在面临业界趋势转移,担忧自身竞争力不足才是焦虑产生的内在根本。我曾和几个国内知名的 iOS 开发者闲聊,他们表示:都 9102 年了,从大量公开的文章来看,大家还是局限于研究 Runtime,Runloop,block 源码分析等一些比较缺少创新的知识点,让人感受行业的停滞不前。

当然,也有不少开发者在积极拥抱新技术。身边的许多朋友也在了解机器学习,自学相关课程等。但是其中大部分都反馈:学完了基础知识,不知道如何应用;也不知道这些东西能对自己日常工作带来怎样的帮助。最终的结果就演变成了学了就忘,无法产生实质价值

那是不是事情就此陷入了僵局呢?抱着怀疑及学习的态度,我在2018年中旬加入了手淘-端智能组,参与了一款名叫 MNN 的深度推理引擎的研发工作。这一年多的开发过程,让我对加深了对机器学习 / 深度学习的理解。但更重要的是,这一年多的亲身经历,让我对过去的观点产生了颠覆式的看法。

在这里,我并不想探讨如何学习机器学习,因为这样的文章数量已经浩瀚如海;相反地,我希望通过这篇文章,阐述在开发推理引擎 MNN 的过程中,我的思考与收获;希望给许多曾和我一样迷茫的移动开发者,一些亲历的感受和信心。

节约篇幅直接贴出 MNN 的 Github 地址:https://github.com/alibaba/MNN

定义清晰的跨界目标

相信有不少同学都曾和我一样,在了解机器学习的初期被诸多的公式推导所吓退,担心这是一个充斥着算法、数学、理论证明的技术领域。

这个观点没错,如果你想要设计出经典的 MobileNet、ResNet 这样的深度神经网络或者是对 Yolo 这样的结构进行复杂度优化,如 Yolo V3 等,你势必要对数学证明、算法优化等方面有较深刻的理解,从这个角度看,说一句很残酷的话:移动工程师跨界的机会不大。

但是机器学习是不是只有算法?这个观点是偏颇的,机器学习本质上是一个工程开发、算法优化与实际应用结合的领域。

用 深度学习领域的知名大牛 贾扬青 的观点来看:AI 是一个系统工程,90%的工作在算法之外

IMG_2784.JPG

换句话说,机器学习还包含系统工程这个范畴。往小了说,模型可视化工具、转换工具;往大了讲,学术界探索机器学习的编译优化系统,比如陈天奇提出的 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
2
3
4
5
6
7
8
9
10
11
table Net {
bizCode: string;
extraTensorDescribe: [TensorDescribe];
gpulibrary: GpuLibrary;
oplists: [Op];
outputName: [string];
preferForwardType: ForwardType = CPU;
sourceType: NetSource = CAFFE;
tensorName: [string];
tensorNumber: int = 0;
}

整体 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
text
.align 5

asm_function MNNBilinearProcC1
//void MNNBilinearProcC1(const unsigned char *sample, unsigned char* dst, const int16_t* xFactor, const int16_t* yFactor, size_t w);

//Auto: x0:sample, x1:dst, x2:xFactor, x3:yFactor, x4:w

ld1 {v31.s}[0], [x3]
//Now x3 is no used
dup v30.4h, v31.h[0]
dup v31.4h, v31.h[1]

L8:
cmp x4, #8
blt End

LoopL8:
ld4 {v4.8b, v5.8b, v6.8b, v7.8b}, [x0], #32
ld2 {v0.8h, v1.8h}, [x2], #32//q0, q1
//(x00,x01) -> (y0)
uxtl v2.8h, v4.8b
uxtl v3.8h, v5.8b
umull v16.4s, v2.4h, v0.4h
umull2 v17.4s, v2.8h, v0.8h
umlal v16.4s, v3.4h, v1.4h
umlal2 v17.4s, v3.8h, v1.8h

uqshrn v18.4h, v16.4s, #4
uqshrn v19.4h, v17.4s, #4

//(x10,x11) -> (y1)
uxtl v2.8h, v6.8b
uxtl v3.8h, v7.8b
umull v16.4s, v2.4h, v0.4h
umull2 v17.4s, v2.8h, v0.8h
umlal v16.4s, v3.4h, v1.4h
umlal2 v17.4s, v3.8h, v1.8h

uqshrn v20.4h, v16.4s, #4
uqshrn v21.4h, v17.4s, #4

//(y0,y1) -> dst
umull v16.4s, v18.4h, v30.4h
umull v17.4s, v19.4h, v30.4h
umlal v16.4s, v20.4h, v31.4h
umlal v17.4s, v21.4h, v31.4h

uqshrn v2.4h, v16.4s, #16
uqshrn2 v2.8h, v17.4s, #16

uqrshrn v0.8b, v2.8h, #2

st1 {v0.8b}, [x1], #8


sub x4, x4, #8
cmp x4, #8
bge LoopL8

End:

相信我,当你从不懂汇编 -> 读懂汇编 -> 手写汇编,每前进一步,你会发现更广阔的天地。有一天当你要做性能优化,发现许多网上常见的手段都使用过了但仍然不起作用的时候,也许汇编就是你杀手锏。

GPU 相关知识融合

近些年来随着短视频的崛起,市面上渲染、多媒体相关的岗位也越加变得火热。而这些岗位无一例外都需要对 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 框架相关开发过程中的一些收获与心得。如何不分裂的看待机器学习与移动开发的关系,如何从看似不相关的领域寻找共同点,提升自己所处领域的价值和核心能力,是值得我们每位开发同学需要思考的。

在最后,还是要说一句:移动客户端的从业人员并不需要过多的焦虑和担忧,动态化、高性能、内核、渲染等等方向都充满前景。但是,你需要找到你所擅长且愿意为之深入的,这才是你保证在浪潮中不被拍翻的核心竞争力。

Revisit iOS Autorelease之二

Revisit iOS Autorelease(二):为啥生成的优化没有了。

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)
  • (1)最后是栈恢复和ret,说明这是正确的流程。
  • (2)看到了一个比较陌生的符号__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后第一,当代方世玉!

Revisit iOS Autorelease 之不经意间可能被影响的优化

本文的硬核在第二段

之前在做某项目的时候,自建了基于NSThread的私有线程池,在线程池分配了固定个数的常驻工作线程,在工作线程里面运行相关任务;这个方案取代了原先直接无脑使用GCD的方式,在各方面效果都还不错。

但是在一次偶然的情况下,通过Memory Graph发现很多任务对象却在本该早就销毁的时候仍然存活着。持有其的对象是autorelease content,如下图所示:

屏幕快照 2019-06-20 下午8.22.39

我把数据对象类型隐藏了,公司数据还是要保密。

那这个东西究竟是个啥呢?

由于其是黄色图标,基本上是一个容器类型或其子类。

这个类型@autoreleasepool content先不管,先从右边的堆栈来看:

  • 某个方法调用了autorelease相关的API
  • 由于我是在子线程触发的,没有显示创建的autoreleasepool。因此需要调用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-applelibpthread代码,这里我直接简化掉流程,输出大致的过程:

_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_autoreleaseReturnValueobjc_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就是对优化的判断,判断的条件是w8w9的相等与否,不等就走传统的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塞入几个ModelContainer,然后再启动第二个线程从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的对应汇编:

屏幕快照 2019-07-01 下午1.15.11

哈哈,0x62b0的地方果然是mov x29, x29

如果你对静态分析的结果不熟悉,可以动态进入汇编。在obc_autoreleaseReturnValue下符号断点,得到

屏幕快照 2019-07-01 下午1.16.57

如果你输出x30寄存器的值(注意不是把寄存器的值当地址再取值)然后再减去所在二进制的基地址,会发现偏移正正好好也是0x62b0

而如果你加上之前提到的for循环代码,再断到obc_autoreleaseReturnValue去查看x30的值,计算偏移量会得到:0x0000000000005e98

而对应到二进制里是:

屏幕快照 2019-07-01 下午1.46.10

看到没,这里调用objc_autoreleaseReturnValue走的是bl,也就是会修改LR寄存器,而LR寄存器的值就是调用后的返回地址5e98。而LR寄存器本身就是x30,导致autorelease的优化失效。

至此,我们终于发现了为什么我们的数据会被所谓的@autoreleasepool content持有。

后记

虽然正如网上很多文章所述,子线程确实会对autoreleasepool进行自动的管理避免内存泄漏。但是,由于诸多场景导致的释放时机变更,会产生诸多的内存不释放(并非是内存泄漏,Leaks是查不出来的),也会对App的稳定性造成巨大的影响。

更重要的是,基于这种TLS的优化很有可能被我们不知情下编写的代码所改变,产生奇怪的问题,因此要特别注意。

下文我会从编译以及代码生成的层面来探讨为什么会产生这种不同的汇编代码。

基于智能指针和RAII的对象内存管理设计

从C++ std::shared_ptr 原理来看看栈溢出的危害,我提及了C++的智能指针指向被管理对象的raw ptr会被栈内存溢出而破坏,而利用智能指针进行对象构造的管理和设计,可以衍生出和RAII的结合,今天就来谈谈这项技术。

什么是RAII?

RAIIResource 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的设计理念,那什么又是基于智能指针的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);

LD-2

了解苹果的LD

其实文章关于传统Section Based Linker那块我还没怎么读懂,有兴趣的欢迎互相探讨。

之前研究Xcode 10兼容libstdc++的时候,稍微把玩了下苹果的LD,借这个机会正好通读了下苹果的LD设计,本文做一下总结。

Atom & FixUp

苹果的LD,核心理念就是基于AtomFixUp,拿着两个术语是啥意思呢?

  • Atom就是一块代码(函数)或者数据(全局变量)之类的,每个Atom都有一些属性,比如名称、作用域、内容类型、字节对齐之类的。
  • Fixup可以理解为一个包含种类、便宜、辅助加数以及目标Atom的数据结构。

有点抽象对吧?概要来说,苹果LD通过AtomFixUP构建一张图,图中的节点都是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 definitionsC++ Weak Symbol

  • 处理fixup的时候,也需要分几种类型,见下图:

其他

虽然苹果的LD已经抽象成了Atom-FixUP的架构,但是它的可执行文件Mach-O还是传统的基于section的结构,这限制了Atom-FixUP的能力。

从C++ std::shared_ptr 原理来看看栈溢出的危害

C++ std::shared_ptr 实现原理

上周五排查了一个由于XXX模块操作疏忽导致栈越界引发的我的模块的智能指针Crash问题,因此稍微研究了一下,以作参考:

shared_ptr共享被管理对象,同一时刻可以有多个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,下文再表。

抛开性能,谈谈不该用@Synchronized的原因

关于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。如下图所示:

屏幕快照 2018-12-27 下午2.02.28.png

这里用@Synchronized(self)是可以成功锁住的,但是这会陷入到锁的范围太大的场景中去,不再此文探讨的范围内。

Crash的根因

@Synchronized会变成一对基于try-catchobjc_sync_enterobjc_sync_exit的代码,想必都不陌生了,许多网上文章都有,不再赘述,可以参考clang的代码:

https://clang.llvm.org/doxygen/RewriteObjC_8cpp_source.html

/// 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;
    1. 通过散列,计算这个对象应该落入的SyncList,由于需要操作SyncList,用其对应的锁进行加锁。
    1. 关注点1和2,其实本质干的是一件事,就是找出一个可以被使用的SyncData,如果没有就创建一个,设定好对应的成员变量,然后返回。
    1. 关注点3,就是做完了以后,利用一下Thread Local Storage,存一下,这块不关注无伤大雅。

Ok,到现在我们分析完成@Synchronized的实现原理后,我们可以回过头再来看看为什么对象被更改后会产生Crash了。

其实一言以蔽之,就是@Synchronized锁不住对象赋值变化的场景。

回到我们上一小节Crash的问题:

考虑三个线程的场景,分别定义为线程A,线程B,线程C,初始的时候在线程A,self.testArray的初始值为arr0(实质上操作的是arr0地址,下文简述为arr0),我们来理下时间线:

  • 线程A获取self.testArray的值,为arr0
  • 线程B获取self.testArray的值,也为arr0
  • 线程A,B由于对象地址一致,产生竞争,A获取到了对应的锁,我们称之为lock0
  • 线程A在锁的保护下,执行self.testArray = @[].mutableCopyself.testArray指向了arr1
  • 线程Aunlock
  • 此时线程C开始尝试获取self.testArray,获取到了arr1
  • 这个时候线程B由于线程A释放锁了,线程B继续,线程B使用之前获取的arr0进行获取锁的操作。
  • 这个时候线程C也尝试进行锁操作,由于线程C是arr1,所以使用的是arr1对应的锁操作。
  • 由于arr0arr1对应的锁不是一个(当然理论上可能散列计算为同一个),所以这两个线程都进入了临界区
  • 线程B和线程C都执行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分别指向了新地址addr2addr3但是获取到的oldValue可能都是arr1

  • 通过objc_releaseoldValue,也就是arr1进行了两次释放,妥妥的double free过度释放场景,导致崩溃。

屏幕快照 2018-12-28 上午11.05.22.png

备注:多线程的场景在于不确定性,可能在其中任何一个指令处挂掉。

结语

所以,从本质上来说,@Synchronized的确是最不应该推荐给用户使用的一种锁机制,但是其根本原因并不一定是性能差距,Hash离散设计的优雅的话,一样能保证性能。但是其内在锁和对象上下文相关的联系会导致锁失效的场景,一旦有对象发生变化(被赋值),导致潜在的锁不住多线程的场景,我们也应该去了解学习。

通过Xcode 10链接libstdc++来深入分析tbd文件

相信玩iOS开发的同学对tbd这个格式的文件已经不再陌生了。最近Xcode 10升级的时候,你会发现很多原先用libstdc++的库在新的Xcode已经没有链接通过。而临时的解决方案也比较简单,网上也很多这样的文章,简而言之就是从Xcode 9中拷贝对应的libstdc++.tbd文件给新的Xcode 10来使用。

Ok,解决方案是有了,我们需要更深入的理解下:为什么拷贝tbd文件,就能够成功解决链接问题?

tbd格式解析

tbd全称是text-based stub libraries,本质上就是一个YAML描述的文本文件。

他的作用是用于记录动态库的一些信息,包括导出的符号、动态库的架构信息、动态库的依赖信息。

为什么需要包含这些信息呢?

  • 动态库的架构信息是了确保运行的程序在运行的平台加载正确的库。比如你不能在运行ARM指令集的iOS设备上加载x86格式的库。

后续我们会举一个手动修改tbdinstall-name字段的小例子来让运行在模拟器的时候加载ARM64架构的动态库

  • 导出的符号。写过程序的人都知道,我们肯定会依赖别人提供的一些函数方法。一般业界都会把这些函数或者方法封装成库的形势。

那库就分为静态库和动态库两种。相信网上关于这两者的讨论和阐述已经很多了,再次不再赘述。唯一需要提及的一点是,动态库是在程序运行(启动依赖或者按需加载)时候加载进程序的地址空间的,那么我们在静态期的时候,是如何得知动态库提供了哪些能力呢?而这就是tbd格式提供的导出符号表的加载,它会指导链接器在链接过程中,将需要决议的符号先做个标记,标记是来自哪个动态库。

这里举个小例子吧。
在程序构建的过程中,比如我们开发一个iOS应用,毋庸置疑的会用到UIKit这个动态库。而为了使我们的程序能够构建成功,这里分为了两个步骤:

  • 通过引入头文件,import <UIKit/UIKit.h>,我们知道了UIKit里面的函数、变量声明。有声明,就能通过编译器的检查。

  • 我们在代码里面使用了UIKit的函数,其本质是一种符号,因此需要链接器来决议这个符号来自哪?要是所有地方都找到,就会报类似undefined symbol之类的错误(想必大家已经很熟悉了)。

为什么要改造成tbd格式

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格式的版本。

为什么拷贝tbd文件能解决Xcode 10上的问题

网上很多人都研究过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体积大小这一个好处,嘿嘿,你们自己摸索下吧~

而且,基于这种思路,能玩出许多类似文体两开花,中美合拍美猴王的玩法,加油吧。

谈谈JSDebugger

务虚乱弹

JSDebugger开源地址:https://github.com/SatanWoo/JSDebugger

这是一篇谈谈设计JSDebugger的总体设想,不会过于深究具体实现细节,后续会单独探讨一些涉及实现方面的过程。

读过我之前博客的朋友可能会记得我3-4月份的时候写过一篇动手制作一个简易的iOS动态执行器,效果如下:

虽然这个效果还起来还不错(有许多人问这个东西咋实现的,挺炫酷的),但是其实只是个原型而已,从设想构思到编码实现没有超过一天的时间。当时在博文里面承诺发代码,想想实现的完善度还不够,就准备完善后再继续搞搞。不过后来由于我转到其他组不继续钻研iOS,这事也就不了了之。

那为什么现在又重新开搞呢?,三点原因吧:

  1. 有朋友在群里问我之前提过的Mach-o包瘦身方案怎么也没发文章?
  2. 看到同事查非Crash产生的Bug的过程还是比较累。

好,来说说前两个原因:

  • 第一点,有些文章为什么后续不发,主要原因还是很多产出和收获都是工作中和同事一起探讨研究出来的,发出来一是泄漏了公司的数据(职业操守还是很重要的),二是相当于霸占了一些别人的工作成果。比如说我之前写的内存等等,对外发的一般都是只和开源代码相关的同时删减了大部分略微关键的部分,所以导致有些读者读起来断断续续云里雾里。

完整版的很多文章或者一些没对外的技术研究,我都发在了公司的内网里,欢迎加入阿里巴巴。

  • 第二点,我之前有段时间负责手淘的稳定性,除了有堆栈的Crash问题外,更多是用户反馈的使用异常以及毫无头绪的奇怪现象。针对这种问题,我相信各家(甚至开源方案)一般都有自己的监控体系,比如日志啊、埋点等等。但是怎么说呢,从我的感受来看,都是使用起来偏繁琐,而且还依赖对应的开发把错误信息等现场保留写入到日志内。我想要的就是一个能像我在自己手机上调试应用一样调试用户手机的能力(前提是用户授权同意),因此我就想做了一个这样的工具。结合自己以前玩逆向的些许经历,Cycript就提供了类似的能力,因此就利用业余时间做了一个JSDebugger

说了这么多原因,其实还是我太懒了。

JSDebugger

言归正传,回到JSDebugger本身,基于之前的代码,这次主要做了完整性的代码重构重写以及功能完善上。

  • 类方法调用
  • 实例方法调用
  • Setter & Getter的调用
  • 可变参数的函数调用
  • C Pointer的使用
  • 基础类型的使用
  • 对象和类的使用
  • 结构体的使用(目前支持CGSize, CGRect, CGPoint,正在开发自定义注册接口)
  • 插件化的扩展功能。

同时,为了更好的测试所写的JavaScript代码,开发了玩具级别的Playground功能,每次实时修改文件后保存即可自动触发Reload

很多细节此文不表,但是有些功能上的实现还是比较用心的,比如支持了各种类型、个数的可变参数的函数调用,比如目前支持了chooseintrospect的能力,二者配合可以对任意对象实时查询其当前所有的属性值。

而且,我对JavaScriptCore的使用可能和常规大家所属性的iOS JavaScriptCore有所区别,利用更低层的设计思路,经过我实测:

更低层的设计桥接思路在iOS上同比基于Objective-C的使用方式可以节省50%的时间;同比在Android上使用开源的JavaScriptCore50分之一左右的时间。

当然Android上比较主流的JS引擎室v8咯

具体快原因可以阅读动手制作一个简易的iOS动态执行器中涉及的JavaScriptCore上层源码分析以及阅读我的JSDebugger源码。

目前JSDebugger还在不断完善中,后续会把我更多的想法移植到里面,总体规划有几个关键点:

  • 实现一个交互式的编辑器(或者命令行),能够让大家写Objective-C的代码自动转换成JSDebugger的JS语法。以我目前的技术水准,还做不到Cycript那种牛逼的Objective-CJavaScript的混合语法模式。

  • 实现远程图形化Debug能力。目前JSDebugger可以调试数据,但是如果能像Reveal一样把操作界面和数据结合起来就会更有效的定位问题。

欢迎有想法的朋友一起来参与完善这个项目,开源地址如下:

https://github.com/SatanWoo/JSDebugger

https://github.com/SatanWoo/JSDebugger

https://github.com/SatanWoo/JSDebugger

当然,要是发现了任何的Bug或者使用上的疑惑、抑或是可以改进的点,也可以私聊我或者开issue

最后

实现JSDebugger的过程,还是站在两个杰出的项目肩膀上:

  • Cycript
  • JSPatch

我的思路是来自于Cycript,诸如结构体等许多方面的实现细节是参考了JSPatch。在这里对这几个项目的作者和代码贡献者表示感谢!

此外,很多的技术方案是和HookZZ大神交流(主要是他教我)中学习而来,在这也特别感谢。也感谢头条的谢大佬的代码贡献以及寒神的Code Style整理。

当然,JSDebugger在实现上还是有很多自己思考的部分,感兴趣的读者可以自行前往JSDebugger的Github开源地址

C++实现一个识别MNIST数字的卷积神经网络

新的一个财年加入了新的组,从事机器学习相关的工作。由于之前做的一直是iOS(略微底层)方面的事情,初来乍到,对很多东西不熟悉,在超级大神ZB的建议下,用C++实现一个多层前馈神经网络,来识别MNIST数据中的各种手写图片。

素材寻找

  • 感谢这位不知名的大佬提供的MNIST数据集,可以直接下载纯图片数据集
  • 搜索下载已经调整好的weightbias模型。(下文会介绍)

实现过程

关于实现多层前馈神经网络,网络上的答案数不胜数,但是大多数都是参考Tensorflow或者Caffe(PyTorch)去实现,我觉得这样很不好。因为学习一门新技术,虽然快速完成项目看出效果很重要,但是对个人来说,弄懂黑盒背后的故事非常重要。因此我决定不依赖任何的库来完全裸写。

当然,对于我来说,实现的完整度和正确性是第一位的,我并没有过多的关注性能。

虽然在深度学习高度发展的今天,类似于AlexNet这样的网络模型能够近乎完美的识别手写数据集。但是作为这个领域的入门级选手,我还是想追溯起源,从头开始做起。因此,在一番学习和搜索后,我选定了LeNet-5模型进行编写。选择它的原因主要有如下几点:

  • 它自身是一个多层的前馈网络模型。
  • 麻雀虽小,五脏俱全。包含了卷积层、全联接层、放缩、灰度以及池化层。同时还引入了ReLuSoftmax等激活函数。
  • 实现简单,哈哈哈哈哈

LeNet-5

LeNet-5整体是个非常简单的过程,包含如下步骤:

  1. 接受一个RGBA的图像。这个很简单,直接在RGBA的颜色空间下读取即可。
  2. 放缩到28 * 28(保留所有的feature)的大小,采用的是bilinear插值方法。
  3. 灰度化,公式如下:r / 255.0 * 0.299 + g / 255.0 * 0.587 + b /255.0 * 0.114
  4. 取反,即255.0 - 灰度化的结果
  5. 5 * 5大小的卷积,加Relu,进行第一次卷积操作。(这里为了保证卷积后尺寸一致,添加了Padding)
  6. 最大池化层降采样。
  7. 5 * 5大小的进行第二次卷积,加ReLu,进行第一次卷积操作。(这里为了保证卷积后尺寸一致,添加了Padding)
  8. 最大池化层降采样。
  9. 全链接计算 + ReLu
  10. 全链接输出10个featureMap
  11. Softmax计算并去除最大的值,即为检测的数字结果。

整体实现上没什么需要特别注意的,如果对这里的名次不懂,可以上网自行查询对应的解释。不过这里有一点很不好,浪费了大量的时间在调试我定义的张量的格式和网上找到的weight模型的格式。

什么意思呢?我大致用如下的图解释下我自身设计的张量是如何存储的。

理论上来讲,张量有三个维度,width, height, featureChannels。我在设计我的张量存储上按照的data[height][row][featureChannels]的方式,然后全部拍成了一维。如图所示:

之所以想这么做,主要是瞄了眼TensorFlow也是类似的设计,然后印象中CUDA也是按照这样维度进行存储,貌似可以有效做并行计算拆分。(这个不确定)

然后为什么卡了很久呢?主要是weightsbias的模型文件是个按照自定义协议二进制流的文件(非结构化的数据)。

这模型由于是我网上找的,一开始没注意模型的自定义协议和我设计的张量直接的区别。我直接按照我的张亮顺序进行了相乘,得到了十分错误的结果。

当然,bias模型没什么好说的,就是按照outputFeatureMap定义的纯一维数组,不会出错。

后来发现这个模型是基于苹果的MPS设计的模型,它的模型是这样的数据结构weights[outputChannels][height][width][inputChannel/group]。第一维在计算的时候需要和我做个映射,所以这里没搞清楚模型格式,查了比较久。

当然,我在加载权重和bias这块还是做了点小油画。用了mmap,避免一次性直接搞进来太大的数据,反正看起来weightbias这块并不需要一次性的读取,而且只读的mmap还能合理利用iOS设备上的clean memory回收机制。

框架设计

  • 网络模型拓扑结构,MinstGraph。这里偷懒了,因为LeNet-5也没啥复杂的拓扑结构,不用考虑多个节点连接,直接线性跑下去就好。

  • 支持任意多维度的张量,类似Tensorflow里面的Tensor,这里对应了MinstImage

  • 各种Layer,如MaxPoolingLayerConvolutionLayer, FullConnectionLayer等等。
  • 各种激活函数,如ReluSoftmax等等。
  • 一些辅助函数之类的。

代码下周发吧。

效果

准确度一开始不怎么高,检查了很多遍代码,确实发现了一些问题,比如数据精度问题。

一开始从图像的角度理解,认为用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,直接从取反开始计算就可以了。

后续规划

  1. 做神经网络还是挺有意思,不过目前还是参考简单的模型结构实现,主要做inference。还没真正去研究训练模型这块。这块需要多深入研究算法,多读论文。

  2. 目前并没有真正设计Session的概念。理论上一张图就是一个静态的拓扑结构的组合而已,不应该承担类似执行run的功能。后续业余时间还会继续实现完整这套逻辑,将静态结构和动态执行结构彻底分离。

  3. 后续有时间的话,可以尝试实现别的模型。同时支持从文件中读取已经建立好的模型,类似Caffee模型之类的

  4. 移植到GPU上。

最后

文章的最后,按照惯例还是向我的偶像致敬。机器学习发展到今天,已经成为了不可忽视的研究方向。对于我们这样的后生来说,站在大牛的肩膀上是我们的福气和基石。而像杨萧玉这样,能够一周时间内学完机器学习课程,发表博客造福大众,才是推动机器学习不断发展的中坚力量。相信在他的带领下,我们国家一定能够在2030年达到全球领先的AI技术水准。

动手制作一个简易的iOS动态执行器

之前听说滴滴的DynamicCocoa是基于JavaScriptCore搞得,一直期待看到他们的真正实现,不过可能后来由于公司机密,应该不能再开源了。

借着最近开始研究JavaScriptCore的契机,我决定利用这一两天所学的JavaScript知识,在业余时间做一个简单的iOS动态执行器玩具。

题外话1:听说滴滴基于LLVM backend搞了一套中间语言解释器,不知道最后用了哪个?不过LLVM IR解释器的话,嘿嘿,还是有点意思的。

题外话2:我研究这个并不是想做iOS动态化,因为xxxxxxx。我只是纯粹想看看JavaScriptCore的一些实现而已。

效果

一张Gif图想必能最佳得展示我做的玩具,请各位大佬过目:

前置知识点

在实现我们的执行器前,我们还是要稍微要了解一下一些前置的知识点。

JSWrapper Object

大家都知道,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
2
3
4
5
6
7
JSClassDefinition definition;

definition = kJSClassDefinitionEmpty;
definition.className = className;
m_classRef = JSClassCreate(&definition);

[self allocateConstructorAndPrototypeWithSuperClassInfo:superClassInfo];
  • 没啥特别的,就是OC对象创建对应的JS对象,类型对类型。
  • OC类型的继承关系在JS里面通过设置Constructor和Prototype进行构建,其实就是简单的JavaScript原型链继承。

JSExport协议 & JSExportAs

JSExport协议本质上只是个Protocol标记,用于让JavaScriptCore加载那些打上这个特殊标记的类,用于特定方式的注册及初始化。

上文我们提过,默认情况下,JavaScriptCore会对象创建一个默认的Wrapper Object,但是这个对象除了简单继承关系外,也就一个按照特殊格式命令的Constructor而已:

[NSString stringWithFormat:@"%sConstructor", className]

那如果我们需要将OC环境中的方法注入到JS环境中,就需要用到JSExport协议了,这个协议在运行时会按照如下逻辑进行处理,将方法和属性进行诸如注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
检查init方法簇的方法,并根据这么合法提供合理的

__block HashMap<String, Protocol *> initTable;
Protocol *exportProtocol = getJSExportProtocol();
for (Class currentClass = cls; currentClass; currentClass = class_getSuperclass(currentClass)) {
forEachProtocolImplementingProtocol(currentClass, exportProtocol, ^(Protocol *protocol) {
forEachMethodInProtocol(protocol, YES, YES, ^(SEL selector, const char*) {
const char* name = sel_getName(selector);
if (!isInitFamilyMethod(@(name)))
return;
initTable.set(name, protocol);
});
});
}

for (Class currentClass = cls; currentClass; currentClass = class_getSuperclass(currentClass)) {
__block unsigned numberOfInitsFound = 0;
__block SEL initMethod = 0;
__block Protocol *initProtocol = 0;
__block const char* types = 0;
forEachMethodInClass(currentClass, ^(Method method) {
SEL selector = method_getName(method);
const char* name = sel_getName(selector);
auto iter = initTable.find(name);

if (iter == initTable.end())
return;

numberOfInitsFound++;
initMethod = selector;
initProtocol = iter->value;
types = method_getTypeEncoding(method);
});

if (!numberOfInitsFound)
continue;

if (numberOfInitsFound > 1) {
NSLog(@"ERROR: Class %@ exported more than one init family method via JSExport. Class %@ will not have a callable JavaScript constructor function.", cls, cls);
break;
}

JSObjectRef method = objCCallbackFunctionForInit(context, cls, initProtocol, initMethod, types);
return [JSValue valueWithJSValueRef:method inContext:context];
}
1
2
3
4
5
6
注入方法和属性
Protocol *exportProtocol = getJSExportProtocol();
forEachProtocolImplementingProtocol(m_class, exportProtocol, ^(Protocol *protocol){
copyPrototypeProperties(m_context, m_class, protocol, prototype);
copyMethodsToObject(m_context, m_class, protocol, NO, constructor);
});

而至于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对象),用于拦截顶层的属性访问。

按照这个设计,大家可以自行思考下,如果是你做,你会如何继续下面的工作,文章下周随着代码一起发布吧。

Choose 调试

搞过逆向用过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

所以我们只要根据对应的进行转换就可以,如下所示:

  • JS字符串 <-> NSString
  • 数字 <-> NSNumber
  • 数组 <-> NSArray
  • Null <-> NSNull
  • Undefined <-> Void (仅当返回值的时候处理,否则直接抛出异常)

题外话,JavaScript里面没有什么整数和浮点数类型区分一说,所以我们可以无脑将其通过double的方式构建NSNumber

最后再来说下对对象类型的处理:

在JavaScript,任何对象都可以简单理解为包含了属性(方法)的一个包装体,如下所示:

var a = {x:10, y:100};

因此,我们在对类型进行转换的时候,要特别注意以下几点:

  • 这个对象是不是我们刚刚上文提过的类、实例、方法,是的话在其进入到Objective-C执行上下文的之前从JSWrapperObject中取出来。
  • 这个对象是不是特定类型的结构体,是的话我们将其转换成结构体,比如CGRect之类的,是的话需要特别转换
  • 是不是可以直接转换成特定类型的对象,比如Date <-> NSDate的转换。
  • 最后,将其可遍历的属性和对应的属性值,转换到NSDictionary之中。
  • 当然,别忘了,需要注意递归处理

Calling Convention

关于Calling Convention,本文就不再赘述,有兴趣的读者可以参考我和同事一起写的知乎专栏iOS调试进阶

简单来重新描述下就是:

一个函数的调用过程中,函数的参数既可以使用栈传递,也可以使用寄存器传递,参数压栈的顺序可以从左到右也可以从右到左,函数调用后参数从栈弹出这个工作可以由函数调用方完成,也可以由被调用方完成。如果函数的调用方和被调用方(函数本身)不遵循统一的约定,有这么多分歧这个函数调用就没法完成。这个双方必须遵守的统一约定就叫做调用惯例(Calling Convention),调用惯例规定了参数的传递的顺序和方式,以及栈的维护方式。

由于业界已经有知名大佬写的libffi,所以我们不需要重复发明轮子,直接使用即可。如果真的要了解具体原理,也可以参考我的文章,具体分析objc_msgSend的实现流程。

其他

为了偷懒,我直接用JavaScript实现了这些的效果。其实理论上,如果我完整的实现编译前端,构建抽象语法树分析执行上下文,将Objective-C的代码转换成JavaScript,那么就能实现动态执行Objective-C代码了。(当然本质上还是障眼法)

其实更快的方式,且不能保证完全正确的方式,就是调用一下JSPatchConvertor就好了,哈哈哈。

谈谈ivar的直接访问

大水文一篇
大水文一篇
大水文一篇

起因

最近对Block的一些实现细节又进行了一次复习,主要涉及的是捕捉变量的部分。有一个点我之前一直没太关注:对ivar变量的直接访问为啥会产生循环引用。

在我原先的理解中,之所以会产生循环引用,绝大多数场景都是由于block里面涉及了self关键字,比如[self doSomething](同理,对于property的访问本质也是一堆方法),但是为啥对ivar的访问也会导致循环引用呢?

不是直接采用 *(void *)address = xxx这样的直接对编译好的静态地址赋值就好了?

当时傻逼了,写完本文后想想就算编译成地址了,基地址从哪算还是要依赖self变量。

谈谈ivar的访问是啥形式

还是回到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,看来也会对包大小产生不可忽视的影响。

❌