V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
iOS 开发实用技术导航
NSHipster 中文版
http://nshipster.cn/
cocos2d 开源 2D 游戏引擎
http://www.cocos2d-iphone.org/
CocoaPods
http://cocoapods.org/
Google Analytics for Mobile 统计解决方案
http://code.google.com/mobile/analytics/
WWDC
https://developer.apple.com/wwdc/
Design Guides and Resources
https://developer.apple.com/design/
Transcripts of WWDC sessions
http://asciiwwdc.com
Cocoa with Love
http://cocoawithlove.com/
Cocoa Dev Central
http://cocoadevcentral.com/
NSHipster
http://nshipster.com/
Style Guides
Google Objective-C Style Guide
NYTimes Objective-C Style Guide
Useful Tools and Services
Charles Web Debugging Proxy
Smore
Draven
V2EX  ›  iDev

深入分析 ObjC 中方法的结构

  •  
  •   Draven ·
    draveness · 2016-04-23 17:44:24 +08:00 · 4476 次点击
    这是一个创建于 3171 天前的主题,其中的信息可能已经有所发展或是发生改变。

    深入分析 ObjC 中方法的结构

    Blog: Draveness

    关注仓库,及时获得更新:iOS-Source-Code-Analyze

    因为 ObjC 的 runtime 只能在 Mac OS 下才能编译,所以文章中的代码都是在 Mac OS ,也就是 x86_64 架构下运行的,对于在 arm64 中运行的代码会特别说明。

    在上一篇分析 isa 的文章[从 NSObject 的初始化了解 isa]( https://github.com/Draveness/iOS-Source-Code-Analyze/blob/master/objc/从%20NSObject%20 的初始化了解%20isa.md)中曾经说到过实例方法被调用时,会通过其持有 isa 指针寻找对应的类,然后在其中的 class_data_bits_t 中查找对应的方法,在这一篇文章中会介绍方法在 ObjC 中是如何存储方法的。

    这篇文章的首先会根据 ObjC 源代码来分析方法在内存中的存储结构,然后在 lldb 调试器中一步一步验证分析的正确性。

    方法在内存中的位置

    先来了解一下 ObjC 中类的结构图:

    objc-method-class

    • isa 是指向元类的指针,不了解元类的可以看 Classes and Metaclasses
    • super_class 指向当前类的父类
    • cache 用于缓存指针和 vtable,加速方法的调用
    • bits 就是存储类的方法、属性、遵循的协议等信息的地方

    class_data_bits_t 结构体

    这一小结会分析类结构体中的 class_data_bits_t bits

    下面就是 ObjC 中 class_data_bits_t 的结构体,其中只含有一个 64 位的 bits 用于存储与类有关的信息:

    objc-method-class-data-bits-t

    objc_class 结构体中的注释写到 class_data_bits_t 相当于 class_rw_t 指针加上 rr/alloc 的标志。

    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags
    

    它为我们提供了便捷方法用于返回其中的 class_rw_t * 指针:

    class_rw_t* data() {
       return (class_rw_t *)(bits & FAST_DATA_MASK);
    }
    

    bitsFAST_DATA_MASK 进行位运算,只取其中的 [3, 47] 位转换成 class_rw_t * 返回。

    在 x86_64 架构上, Mac OS 只使用了其中的 47 位来为对象分配地址。而且由于地址要按字节在内存中按字节对齐,所以掩码的后三位都是 0 。

    因为 class_rw_t * 指针只存于第 [3, 47] 位,所以可以使用最后三位来存储关于当前类的其他信息:

    objc-method-class_data_bits_t

    #define FAST_IS_SWIFT           (1UL<<0)
    #define FAST_HAS_DEFAULT_RR     (1UL<<1)
    #define FAST_REQUIRES_RAW_ISA   (1UL<<2)
    #define FAST_DATA_MASK          0x00007ffffffffff8UL
    
    • isSwift()
      • FAST_IS_SWIFT 用于判断 Swift 类
    • hasDefaultRR()
      • FAST_HAS_DEFAULT_RR 当前类或者父类含有默认的 retain/release/autorelease/retainCount/_tryRetain/_isDeallocating/retainWeakReference/allowsWeakReference 方法
    • requiresRawIsa()
      • FAST_REQUIRES_RAW_ISA 当前类的实例需要 raw isa

    执行 class_data_bits_t 结构体中的 data() 方法或者调用 objc_class 中的 data() 方法会返回同一个 class_rw_t * 指针,因为 objc_class 中的方法只是对 class_data_bits_t 中对应方法的封装。

    // objc_class 中的 data() 方法
    class_data_bits_t bits;
    
    class_rw_t *data() { 
       return bits.data();
    }
    
    // class_data_bits_t 中的 data() 方法
    uintptr_t bits;
    
    class_rw_t* data() {
       return (class_rw_t *)(bits & FAST_DATA_MASK);
    }
    

    class_rw_tclass_ro_t

    ObjC 类中的属性、方法还有遵循的协议等信息都保存在 class_rw_t 中:

    struct class_rw_t {
        uint32_t flags;
        uint32_t version;
    
        const class_ro_t *ro;
    
        method_array_t methods;
        property_array_t properties;
        protocol_array_t protocols;
    
        Class firstSubclass;
        Class nextSiblingClass;
    };
    

    其中还有一个指向常量的指针 ro,其中存储了当前类在编译期就已经确定的属性、方法以及遵循的协议

    struct class_ro_t {
        uint32_t flags;
        uint32_t instanceStart;
        uint32_t instanceSize;
        uint32_t reserved;
    
        const uint8_t * ivarLayout;
        
        const char * name;
        method_list_t * baseMethodList;
        protocol_list_t * baseProtocols;
        const ivar_list_t * ivars;
    
        const uint8_t * weakIvarLayout;
        property_list_t *baseProperties;
    };
    

    在编译期间类的结构中的 class_data_bits_t *data 指向的是一个 class_ro_t * 指针:

    objc-method-before-realize

    然后在加载 ObjC 运行时的过程中在 realizeClass 方法中:

    1. class_data_bits_t 调用 data 方法,将结果从 class_rw_t 强制转换为 class_ro_t 指针
    2. 初始化一个 class_rw_t 结构体
    3. 设置结构体 ro 的值以及 flag
    4. 最后设置正确的 data
    const class_ro_t *ro = (const class_ro_t *)cls->data();
    class_rw_t *rw = (class_rw_t *)calloc(sizeof(class_rw_t), 1);
    rw->ro = ro;
    rw->flags = RW_REALIZED|RW_REALIZING;
    cls->setData(rw);
    

    下图是 realizeClass 方法执行过后的类所占用内存的布局,你可以与上面调用方法前的内存布局对比以下,看有哪些更改:

    ![objc-method-after-realize-class]( http://7xrlu3.com1.z0.glb.clouddn.com/2016-04-23-objc-method-after-realize-class.png)

    但是,在这段代码运行之后 class_rw_t 中的方法,属性以及协议列表均为空。这时需要 realizeClass 调用 methodizeClass 方法来将类自己实现的方法(包括分类)、属性和遵循的协议加载到 methodspropertiesprotocols 列表中

    XXObject

    下面,我们将分析一个类 XXObject 在运行时初始化过程中内存的更改,这是 XXObject 的接口与实现:

    // XXObject.h 文件
    #import <Foundation/Foundation.h>
    
    @interface XXObject : NSObject
    
    - (void)hello;
    
    @end
    
    // XXObject.m 文件
    
    #import "XXObject.h"
    
    @implementation XXObject
    
    - (void)hello {
        NSLog(@"Hello");
    }
    
    @end
    

    这段代码是运行在 Mac OS X 10.11.3 (x86_64)版本中,而不是运行在 iPhone 模拟器或者真机上的,如果你在 iPhone 或者真机上运行,可能有一定差别。

    ![objc-method-target]( http://7xrlu3.com1.z0.glb.clouddn.com/2016-04-23-objc-method-target.png)

    这是主程序的代码:

    #import <Foundation/Foundation.h>
    #import "XXObject.h"
    
    int main(int argc, const char * argv[]) {
        @autoreleasepool {
            Class cls = [XXObject class];
            NSLog(@"%p", cls);
        }
        return 0;
    }
    

    编译后内存中类的结构

    因为类在内存中的位置是编译期就确定的,先运行一次代码获取 XXObject 在内存中的地址。

    0x100001168
    

    接下来,在整个 ObjC 运行时初始化之前,也就是 _objc_init 方法中加入一个断点:

    objc-method-after-compile

    然后在 lldb 中输入以下命令:

    (lldb) p (objc_class *)0x100001168
    (objc_class *) $0 = 0x0000000100001168
    (lldb) p (class_data_bits_t *)0x100001188
    (class_data_bits_t *) $1 = 0x0000000100001188
    (lldb) p $1->data()
    warning: could not load any Objective-C class information. This will significantly reduce the quality of type information available.
    (class_rw_t *) $2 = 0x00000001000010e8
    (lldb) p (class_ro_t *)$2 // 将 class_rw_t 强制转化为 class_ro_t
    (class_ro_t *) $3 = 0x00000001000010e8
    (lldb) p *$3
    (class_ro_t) $4 = {
      flags = 128
      instanceStart = 8
      instanceSize = 8
      reserved = 0
      ivarLayout = 0x0000000000000000 <no value available>
      name = 0x0000000100000f7a "XXObject"
      baseMethodList = 0x00000001000010c8
      baseProtocols = 0x0000000000000000
      ivars = 0x0000000000000000
      weakIvarLayout = 0x0000000000000000 <no value available>
      baseProperties = 0x0000000000000000
    }
    

    objc-method-lldb-print-before-realize

    现在我们获取了类经过编译器处理后的只读属性 class_ro_t

    (class_ro_t) $4 = {
      flags = 128
      instanceStart = 8
      instanceSize = 8
      reserved = 0
      ivarLayout = 0x0000000000000000 <no value available>
      name = 0x0000000100000f7a "XXObject"
      baseMethodList = 0x00000001000010c8
      baseProtocols = 0x0000000000000000
      ivars = 0x0000000000000000
      weakIvarLayout = 0x0000000000000000 <no value available>
      baseProperties = 0x0000000000000000
    }
    

    可以看到这里面只有 baseMethodListname 是有值的,其它的 ivarLayoutbaseProtocolsivarsweakIvarLayoutbaseProperties 都指向了空指针,因为类中没有实例变量,协议以及属性。所以这里的结构体符合我们的预期。

    通过下面的命令查看 baseMethodList 中的内容:

    (lldb) p $4.baseMethodList
    (method_list_t *) $5 = 0x00000001000010c8
    (lldb) p $5->get(0)
    (method_t) $6 = {
      name = "hello"
      types = 0x0000000100000fa4 "v16@0:8"
      imp = 0x0000000100000e90 (method`-[XXObject hello] at XXObject.m:13)
    }
    (lldb) p $5->get(1)
    Assertion failed: (i < count), function get, file /Users/apple/Desktop/objc-runtime/runtime/objc-runtime-new.h, line 110.
    error: Execution was interrupted, reason: signal SIGABRT.
    The process has been returned to the state before expression evaluation.
    (lldb)
    

    objc-method-lldb-print-method-list

    使用 $5->get(0) 时,成功获取到了 -[XXObject hello] 方法的结构体 method_t。而尝试获取下一个方法时,断言提示我们当前类只有一个方法。

    realizeClass

    这篇文章中不会对 realizeClass 进行详细的分析,该方法的主要作用是对类进行第一次初始化,其中包括:

    • 分配可读写数据空间
    • 返回真正的类结构
    static Class realizeClass(Class cls)
    

    上面就是这个方法的签名,我们需要在这个方法中打一个条件断点,来判断当前类是否为 XXObject

    objc-method-lldb-breakpoint

    这里直接判断两个指针是否相等,而不使用 [NSStringFromClass(cls) isEqualToString:@"XXObject"] 是因为在这个时间点,这些方法都不能调用,在 ObjC 中没有这些方法,所以只能通过判断类指针是否相等的方式来确认当前类是 XXObject

    直接与指针比较是因为类在内存中的位置是编译期确定的,只要代码不改变,类在内存中的位置就会不变(已经说过很多遍了)。

    objc-method-breakpoint-before-set-r

    这个断点就设置在这里,因为 XXObject 是一个正常的类,所以会走 else 分支分配可写的类数据。

    运行代码时,因为每次都会判断当前类指针是不是指向的 XXObject,所以会等一会才会进入断点。

    在这时打印类结构体中的 data 的值,发现其中的布局依旧是这样的:

    objc-method-before-realize

    在运行完这段代码之后:

    objc-method-after-realize-breakpoint

    我们再来打印类的结构:

    (lldb) p (objc_class *)cls // 打印类指针
    (objc_class *) $262 = 0x0000000100001168
    (lldb) p (class_data_bits_t *)0x0000000100001188 // 在类指针上加 32 的 offset 打印 class_data_bits_t 指针
    (class_data_bits_t *) $263 = 0x0000000100001188
    (lldb) p *$263 // 访问 class_data_bits_t 指针的内容
    (class_data_bits_t) $264 = (bits = 4302315312)
    (lldb) p $264.data() // 获取 class_rw_t
    (class_rw_t *) $265 = 0x0000000100701f30
    (lldb) p *$265 // 访问 class_rw_t 指针的内容,发现它的 ro 已经设置好了
    (class_rw_t) $266 = {
      flags = 2148007936
      version = 0
      ro = 0x00000001000010e8
      methods = {
        list_array_tt<method_t, method_list_t> = {
           = {
            list = 0x0000000000000000
            arrayAndFlag = 0
          }
        }
      }
      properties = {
        list_array_tt<property_t, property_list_t> = {
           = {
            list = 0x0000000000000000
            arrayAndFlag = 0
          }
        }
      }
      protocols = {
        list_array_tt<unsigned long, protocol_list_t> = {
           = {
            list = 0x0000000000000000
            arrayAndFlag = 0
          }
        }
      }
      firstSubclass = nil
      nextSiblingClass = nil
      demangledName = 0x0000000000000000 <no value available>
    }
    (lldb) p $266.ro // 获取 class_ro_t 指针
    (const class_ro_t *) $267 = 0x00000001000010e8
    (lldb) p *$267 // 访问 class_ro_t 指针的内容
    (const class_ro_t) $268 = {
      flags = 128
      instanceStart = 8
      instanceSize = 8
      reserved = 0
      ivarLayout = 0x0000000000000000 <no value available>
      name = 0x0000000100000f7a "XXObject"
      baseMethodList = 0x00000001000010c8
      baseProtocols = 0x0000000000000000
      ivars = 0x0000000000000000
      weakIvarLayout = 0x0000000000000000 <no value available>
      baseProperties = 0x0000000000000000
    }
    (lldb) p $268.baseMethodList // 获取基本方法列表
    (method_list_t *const) $269 = 0x00000001000010c8
    (lldb) p $269->get(0) // 访问第一个方法
    (method_t) $270 = {
      name = "hello"
      types = 0x0000000100000fa4 "v16@0:8"
      imp = 0x0000000100000e90 (method`-[XXObject hello] at XXObject.m:13)
    }
    (lldb) p $269->get(1) // 尝试访问第二个方法,越界
    error: Execution was interrupted, reason: signal SIGABRT.
    The process has been returned to the state before expression evaluation.
    Assertion failed: (i < count), function get, file /Users/apple/Desktop/objc-runtime/runtime/objc-runtime-new.h, line 110.
    (lldb)
    

    objc-method-print-class-struct-after-realize

    最后一个操作实在是截取不到了

    const class_ro_t *ro = (const class_ro_t *)cls->data();
    class_rw_t *rw = (class_rw_t *)calloc(sizeof(class_rw_t), 1);
    rw->ro = ro;
    rw->flags = RW_REALIZED|RW_REALIZING;
    cls->setData(rw);
    

    在上述的代码运行之后,类的只读指针 class_ro_t 以及可读写指针 class_rw_t 都被正确的设置了。但是到这里,其 class_rw_t 部分的方法等成员都指针均为空,这些会在 methodizeClass 中进行设置:

    objc-method-after-methodizeClass

    在这里调用了 method_array_tattachLists 方法,将 baseMethods 中的方法添加到 methods 数组之后。我们访问 methods 才会获取当前类的实例方法。

    方法的结构

    说了这么多,到现在我们可以简单看一下方法的结构,与类和对象一样,方法在内存中也是一个结构体。

    struct method_t {
        SEL name;
        const char *types;
        IMP imp;
    };
    

    其中包含方法名,类型还有方法的实现指针 IMP

    obj-method-struct

    上面的 -[XXObject hello] 方法的结构体是这样的:

    name = "hello"
    types = 0x0000000100000fa4 "v16@0:8"
    imp = 0x0000000100000e90 (method`-[XXObject hello] at XXObject.m:13
    

    方法的名字在这里没有什么好说的。其中,方法的类型是一个非常奇怪的字符串 "v16@0:8" 这在 ObjC 中叫做类型编码(Type Encoding),你可以看这篇官方文档了解与类型编码相关的信息。

    对于方法的实现, lldb 为我们标注了方法在文件中实现的位置。

    小结

    在分析方法在内存中的位置时,笔者最开始一直在尝试寻找只读结构体 class_ro_t 中的 baseMethods 第一次设置的位置(了解类的方法是如何被加载的)。尝试从 methodizeClass 方法一直向上找,直到 _obj_init 方法也没有找到设置只读区域的 baseMethods 的方法。

    而且在 runtime 初始化之后,realizeClass 之前,从 class_data_bits_t 结构体中获取的 class_rw_t 一直都是错误的,这个问题在最开始非常让我困惑,直到后来在 realizeClass 中发现原来在这时并不是 class_rw_t 结构体,而是class_ro_t,才明白错误的原因。

    后来突然想到类的一些方法、属性和协议实在编译期决定的(baseMethods 等成员以及类在内存中的位置都是编译期决定的),才感觉到豁然开朗。

    1. 类在内存中的位置是在编译期间决定的,在之后修改代码,也不会改变内存中的位置。
    2. 类的方法、属性以及协议在编译期间存放到了“错误”的位置,直到 realizeClass 执行之后,才放到了 class_rw_t 指向的只读区域 class_ro_t,这样我们即可以在运行时为 class_rw_t 添加方法,也不会影响类的只读结构。
    3. class_ro_t 中的属性在运行期间就不能改变了,再添加方法时,会修改 class_rw_t 中的 methods 列表,而不是 class_ro_t 中的 baseMethods,对于方法的添加会在之后的文章中分析。

    参考资料

    关注仓库,及时获得更新:iOS-Source-Code-Analyze

    Blog: Draveness

    17 条回复    2016-04-27 18:49:11 +08:00
    racechao
        1
    racechao  
       2016-04-23 20:22:13 +08:00
    干的一塌糊涂
    yggd
        2
    yggd  
       2016-04-23 20:30:04 +08:00
    好干
    mthli
        3
    mthli  
       2016-04-23 20:32:50 +08:00
    哎呦,不错噢。
    neoblackcap
        4
    neoblackcap  
       2016-04-23 20:34:05 +08:00
    @racechao @Draven 真是曲高和寡,好东西就是这样,最能吸引人的往往是最简单的。
    Draven
        5
    Draven  
    OP
       2016-04-23 22:42:00 +08:00
    @neoblackcap 谢谢
    Draven
        6
    Draven  
    OP
       2016-04-23 22:42:18 +08:00
    @mthli 怎么每次都有你。。。(尴尬脸)
    Draven
        7
    Draven  
    OP
       2016-04-23 22:43:36 +08:00
    @racechao @yggd 谢谢资瓷
    Draven
        8
    Draven  
    OP
       2016-04-23 22:47:01 +08:00
    mthli
        9
    mthli  
       2016-04-23 23:18:27 +08:00 via Android
    @Draven 其实那是个机器人,每次你发消息的时候都会回调,接着从 Twitter 上选取出最合适的回复语进行回复。我通常只在晚上睡觉前上来看看。
    Draven
        10
    Draven  
    OP
       2016-04-24 00:00:45 +08:00
    @mthli 我都信了。。。
    cielpy
        11
    cielpy  
       2016-04-24 00:41:17 +08:00
    @neoblackcap 高的话,能和的人就少多了呀 (
    wohenyingyu01
        12
    wohenyingyu01  
       2016-04-24 00:48:21 +08:00
    呃看的不是很懂,我想问问怎样调用被 category 覆盖的原方法。。。
    Patiencec
        13
    Patiencec  
       2016-04-24 14:27:07 +08:00 via iPhone
    居然耐心看完了。。。
    neoblackcap
        14
    neoblackcap  
       2016-04-24 15:26:48 +08:00
    @cielpy 这样的文章你说『我就一个不写 GC ,就一个普通 web 开发者』又怎么会看。写这样的文章其实我是很赞成的,毕竟很多中级开发者很需要这样的文章,不过对于作者 @Draven 来说得到的成就感可能就不多了。毕竟中级开发者就那么点,他们还整天有业务任务,过来点赞真不多。
    不过若是 @Draven 你以后跳槽什么的话,这样的文章倒是很好的加分项。不过我觉得你能写出这样的文章,大概已经是身居中级管理层,骨干之类了吧。
    Draven
        15
    Draven  
    OP
       2016-04-24 16:05:17 +08:00
    @wohenyingyu01 分类中写的方法也是在编译期间就确定了,这些方法的在列表中的位置会比类中的方法位置靠前,
    Draven
        16
    Draven  
    OP
       2016-04-24 16:23:11 +08:00
    @neoblackcap 其实主要目的还是分享一点编程的经验,这样自己收获会更大一些,而且目前这种文章尤其是国内可能会比较少
    superleexpert
        17
    superleexpert  
       2016-04-27 18:49:11 +08:00
    Github 已 follow ,主页可读文章也很多。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2600 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 11:33 · PVG 19:33 · LAX 03:33 · JFK 06:33
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.