聊聊 Objective-C 循環引用的檢測

Python實戰社羣

Java實戰社羣

長按識別下方二維碼,按需求添加

掃碼關注添加客服

進Python社羣▲

掃碼關注添加客服

進Java社羣


作者 | triplecc 
來源 | triplecc's blog

https://triplecc.github.io/2019/08/15/%E8%81%8A%E8%81%8A%E5%BE%AA%E7%8E%AF%E5%BC%95%E7%94%A8%E7%9A%84%E6%A3%80%E6%B5%8B/

Objective-C 使用引用計數作爲 iPhone 應用的內存管理方案,引用計數相比 GC 更適用於內存不太充裕的場景,只需要收集與對象關聯的局部信息來決定是否回收對象,而 GC 爲了明確可達性,需要全局的對象信息。引用計數固然有其優越性,但也正是因爲缺乏對全局對象信息的把控,導致 Objective-C 無法自動銷燬陷入循環引用的對象。雖然 Objective-C 通過引入弱引用技術,讓開發者可以儘可能地規避這個問題,但在引用層級過深,引用路徑不那麼直觀的情況下,即使是經驗豐富的工程師,也無法百分百保證產出的代碼不存在循環引用。

這時候就需要有一種檢測方案,可以實時檢測對象之間是否發生了循環引用,來輔助開發者及時地修正代碼中存在的內存泄漏問題。要想檢測出循環引用,最直觀的方式是遞歸地獲取對象強引用的其他對象,並判斷檢測對象是否被其路徑上的對象強引用了,也就是在有向圖中去找環。明確檢測方式之後,接下來需要解決的是如何獲取強引用鏈,也就是獲取對象的強引用,尤其是最容易造成循環引用的 block。

Block 捕獲實體引用

往期關於 Block 的文章 對 Block 的一點補充、用 Block 實現委託方法、Block技巧與底層解析

捕獲區域佈局初探

首先根據 block 的定義結構,可以簡單地將其視爲:

struct sr_block_layout {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(void *, ...);
    struct sr_block_descriptor *descriptor;
    /* Imported variables. */
};

// 標誌位不一樣,這個結構的實際佈局也會有差別,這裏簡單地放在一起好閱讀
struct sr_block_descriptor {
    unsigned long reserved; // Block_descriptor_1
    unsigned long size; // Block_descriptor_1
    void (*)(void *dst, void *src);  // Block_descriptor_2 BLOCK_HAS_COPY_DISPOSE
    void (*dispose)(void *); // Block_descriptor_2
    const char *signature; // Block_descriptor_3 BLOCK_HAS_SIGNATURE
    const char *layout; // Block_descriptor_3 contents depend on BLOCK_HAS_EXTENDED_LAYOUT
};

可以看到 block 捕獲的變量都會存儲在 sr_block_layout 結構體 descriptor 字段之後的內存空間中,下面我們通過 clang -rewrite-objc 重寫如下代碼語句 :

int i = 2;
^{
    i;
};

可以得到 :

struct __block_impl {
  void *isa;
  int Flags;
  int Reserved;
  void *FuncPtr;
};

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int i;
  ...
};

__main_block_impl_0 結構中新增了捕獲的 i 字段,即 sr_block_layout 結構體的 imported variables 部分,這種操作可以看作在 sr_block_layout 尾部定義了一個 0 長數組,可以根據實際捕獲變量的大小,給捕獲區域申請對應的內存空間,只不過這一操作由編譯器完成 :

struct sr_block_layout {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(void *, ...);
    struct sr_block_descriptor *descriptor;
    char captured[0];
};

既然已經知道了捕獲變量 i 的存放地址,那麼我們就可以通過 *(int *)layout->captured 在運行時獲取 i 的值。得到了捕獲區域的起始地址之後,我們再來看捕獲區域的佈局問題,考慮以下代碼塊 :

int i = 2;
NSObject *o = [NSObject new];
void (^blk)(void) = ^{
    i;
    o;
};

捕獲區域的佈局分兩部分看:順序和大小,我們先使用老方法重寫代碼塊 :

struct __main_block_impl_0 {
  struct __block_impl impl;           // 24
  struct __main_block_desc_0* Desc;   // 8 指針佔用內存大小和尋址長度相關,在 64 位機環境下,編譯器分配空間大小爲 8 字節
  int i;                              // 8
  NSObject *o;                        // 8
  ...
};

按照目前 clang 針對 64 位機的默認對齊方式(下文的字節對齊計算都基於此前提條件),可以計算出這個結構體佔用的內存空間大小爲 24 + 8 + 8 + 8 = 48字節,並且按照上方代碼塊先 i 後 o 的捕獲排序方式,如果我要訪問捕獲的 o 對象指針變量,只需要在捕獲區域起始地址上偏移 8 字節即可,我們可以藉助 lldb 的 memory read (x) 命令查看這部分內存空間 :

(lldb) po *(NSObject **)(layout->captured + 8)
0x0000000000000002
(lldb) po *(NSObject **)layout->captured
<NSObject: 0x10073f290>
(lldb) p *(int *)(layout->captured + 8)
(int) $6 = 2
(lldb) p (int *)(layout->captured + 8)
(int *) $9 = 0x0000000100740d18
(lldb) p layout->descriptor->size
(unsigned long) $11 = 44
(lldb) x/44bx layout
0x100740cf0: 0x70 0x21 0x7b 0xa6 0xff 0x7f 0x00 0x00
0x100740cf8: 0x02 0x00 0x00 0xc3 0x00 0x00 0x00 0x00
0x100740d00: 0x40 0x1d 0x00 0x00 0x01 0x00 0x00 0x00
0x100740d08: 0xb0 0x20 0x00 0x00 0x01 0x00 0x00 0x00
0x100740d10: 0x90 0xf2 0x73 0x00 0x01 0x00 0x00 0x00
0x100740d18: 0x02 0x00 0x00 0x00

和使用 clang -rewrite-objc 重寫時的猜想不一樣,我們可以從以上終端日誌中看出以下兩點 :

  • 捕獲變量 i、o 在捕獲區域的排序方式爲 o、i,o 變量地址與捕獲起始地址一致,i 變量地址爲捕獲起始地址加上 8 字節

  • 捕獲整形變量 i 在內存中實際佔用空間大小爲 4 字節

那麼 block 到底是怎麼對捕獲變量進行排序,並且爲其分配內存空間的呢?這就需要看 clang 是如何處理 block 捕獲的外部變量了。

捕獲區域佈局分析

首先解決捕獲變量排序的問題,根據 clang 針對這部分的排序代碼,我們可以知道,在對齊字節數 (alignment) 不相等時,捕獲的實體按照 alignment 降序排序 (C 結構體比較特殊,即使整體佔用空間比指針變量大,也排在對象指針後面),否則按照以下類型進行排序 :

  • __strong 修飾對象指針變量

  • __block 修飾對象指針變量

  • __weak 修飾對象指針變量

  • 其他變量

再結合 clang 對捕獲變量對齊子節數計算方式 ,我們可以知道,block 捕獲區域變量的對齊結果趨向於被 __attribute__ ((__packed__)) 修飾了的結構體,舉個例子 :

struct foo {
    void *p;    // 8
    int i;      // 4
    char c;     // 4 實際用到的內存大小爲 1
};

創建 foo 結構體需要分配的空間大小爲 8 + 4 + 4 = 16,關於結構體的內存對齊方式,這裏額外說幾句,編譯器會按照成員列表的順序一個接一個地給每個成員分配內存,只有當存儲成員需要滿足正確的邊界對齊要求時,成員之間纔可能出現用於填充的額外內存空間,以提升計算機的訪問速度(對齊標準一般和尋址長度一致),在聲明結構體時,讓那些對齊邊界要求最嚴格的成員最先出現,對邊界要求最弱的成員最後出現,可以最大限度地減少因邊界對齊而帶來的空間損失。再看以下代碼塊 :

struct foo {
    void *p;    // 8
    int i;      // 4
    char c;     // 1
} __attribute__ ((__packed__));

__attribute__ ((__packed__)) 編譯屬性告訴編譯器,按照字段的實際佔用子節數進行對齊,所以創建 foo 結構體需要分配的空間大小爲 8 + 4 + 1 = 13。

結合以上兩點,我們可以嘗試分析以下 block 捕獲區域的變量佈局情況 :

NSObject *o1 = [NSObject new];
__weak NSObject *o2 = o1;
__block NSObject *o3 = o1;
unsigned long long j = 4;
int i = 3;
char c = 'a';
void (^blk)(void) = ^{
    i;
    c;
    o1;
    o2;
    o3;
    j;
};

首先按照 aligment 排序,可以得到排序順序爲 [o1 o2 o3] j i c,再根據 strong、block、__weak 修飾符對 o1 o2 o3 進行排序,可得到最終結果 o1[8] o3[8] o2[8] j[8] i[4] c[1]。同樣的,我們使用 lldb 的 x 命令驗證分析結果是否正確 :

(lldb) x/69bx layout
0x10200d940: 0x70 0x21 0x7b 0xa6 0xff 0x7f 0x00 0x00
0x10200d948: 0x02 0x00 0x00 0xc3 0x00 0x00 0x00 0x00
0x10200d950: 0xf0 0x1b 0x00 0x00 0x01 0x00 0x00 0x00
0x10200d958: 0xf8 0x20 0x00 0x00 0x01 0x00 0x00 0x00
0x10200d960: 0xa0 0xf6 0x00 0x02 0x01 0x00 0x00 0x00  // o1
0x10200d968: 0x90 0xd9 0x00 0x02 0x01 0x00 0x00 0x00  // o3
0x10200d970: 0xa0 0xf6 0x00 0x02 0x01 0x00 0x00 0x00  // o2
0x10200d978: 0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00  // j
0x10200d980: 0x03 0x00 0x00 0x00 0x61                 // i c
(lldb) p o1
(NSObject *) $1 = 0x000000010200f6a0

可以看到,小端模式下,捕獲的 o1 和 o2 指針變量值爲 0x10200f6a0 ,對應內存地址爲 0x10200d960 和 0x10200d970,而 o3 因爲被 __block 修飾,編譯器爲 o3 捕獲變量包裝了一層 byref 結構,所以其值爲 byref 結構的地址 0x102000d990 ,而不是 0x10200f6a0 ,捕獲的 j 變量地址爲 0x10200d978,i 變量地址爲 0x10200d980,c 字符變量緊隨其後。

Descriptor 的 Layout 信息

經過上述的一系列分析,捕獲區域變量的佈局方式已經大致摸清了,接下來回過頭看下 sr_block_descriptor 結構的 layout 字段是用來幹嘛的。從字面上理解,這個字段很可能保存了 block 某一部分的內存佈局信息,比如捕獲區域的佈局信息,我們依舊使用上文的最後一個例子,看看 layout 的值 :

(lldb) p layout->descriptor->layout
(const char *) $2 = 0x0000000000000111 ""

可以看到 layout 值爲空字符串,並沒有展示出任何直觀的佈局信息,看來要想知道 layout 是怎麼運作的,還需要閱讀這一部分的 block 代碼 和 clang 代碼,我們一步步地分析這兩段代碼裏面隱藏的信息,這裏貼出其中的部分代碼和註釋 :

// block
// Extended layout encoding.

// Values for Block_descriptor_3->layout with BLOCK_HAS_EXTENDED_LAYOUT
// and for Block_byref_3->layout with BLOCK_BYREF_LAYOUT_EXTENDED

// If the layout field is less than 0x1000, then it is a compact encoding 
// of the form 0xXYZ: X strong pointers, then Y byref pointers, 
// then Z weak pointers.

// If the layout field is 0x1000 or greater, it points to a 
// string of layout bytes. Each byte is of the form 0xPN.
// Operator P is from the list below. Value N is a parameter for the operator.

enum {
    ...
    BLOCK_LAYOUT_NON_OBJECT_BYTES = 1,    // N bytes non-objects
    BLOCK_LAYOUT_NON_OBJECT_WORDS = 2,    // N words non-objects
    BLOCK_LAYOUT_STRONG           = 3,    // N words strong pointers
    BLOCK_LAYOUT_BYREF            = 4,    // N words byref pointers
    BLOCK_LAYOUT_WEAK             = 5,    // N words weak pointers
    ...
};

// clang 
/// InlineLayoutInstruction - This routine produce an inline instruction for the
/// block variable layout if it can. If not, it returns 0. Rules are as follow:
/// If ((uintptr_t) layout) < (1 << 12), the layout is inline. In the 64bit world,
/// an inline layout of value 0x0000000000000xyz is interpreted as follows:
/// x captured object pointers of BLOCK_LAYOUT_STRONG. Followed by
/// y captured object of BLOCK_LAYOUT_BYREF. Followed by
/// z captured object of BLOCK_LAYOUT_WEAK. If any of the above is missing, zero
/// replaces it. For example, 0x00000x00 means x BLOCK_LAYOUT_STRONG and no
/// BLOCK_LAYOUT_BYREF and no BLOCK_LAYOUT_WEAK objects are captured.

首先要解釋的是 inline 這個詞,Objective-C 中有一種叫做 Tagged Pointer 的技術,它讓指針保存實際值,而不是保存實際值的地址,這裏的 inline 也是相同的效果,即讓 layout 指針保存實際的編碼信息。在 inline 狀態下,使用十六進制中的一位表示捕獲變量的數量,所以每種類型的變量最多隻能有 15 個,此時的 layout 的值以 0xXYZ 形式呈現,其中 X、Y、Z 分別表示捕獲 strong、block、__weak 修飾指針變量的個數,如果其中某個類型的數量超過 15 或者捕獲變量的修飾類型不爲這三種任何一個時,比如捕獲的變量由 __unsafe_unretained 修飾,則採用另一種編碼方式,這種方式下,layout 會指向一個字符串,這個字符串的每個字節以 0xPN 的形式呈現,並以 0x00 結束,P 表示變量類型,N 表示變量個數,需要注意的是,N 爲 0 表示 P 類型有一個,而不是 0 個,也就是說實際的變量個數比 N 大 1。需要注意的是,捕獲 int 等基礎類型,不影響 layout 的呈現方式,layout 編碼中也不會有關於基礎類型的信息,除非需要基礎類型的編碼來輔助定位對象指針類型的位置,比如捕獲含有對象指針字段的結構體。舉幾個例子 :

unsigned long long j = 4;
int i = 3;
char c = 'a';
void (^blk)(void) = ^{
    i;
    c;
    j;
};

以上代碼塊沒有捕獲任何對象指針,所以實際的 descriptor 不包含 copy 和 dispose 字段,去除這兩個字段後,再輸出實際的佈局信息,結果爲空(0x00 表示結束),說明捕獲一般基礎類型變量不會計入實際的 layout 編碼 :

(lldb) p/x (long)layout->descriptor->layout
(long) $0 = 0x0000000100001f67
(lldb) x/8bx layout->descriptor->layout
0x100001f67: 0x00 0x76 0x31 0x36 0x40 0x30 0x3a 0x38

接着嘗試第一種 layout 方式 :

NSObject *o1 = [NSObject new];
__block NSObject *o3 = o1;
__weak NSObject *o2 = o1;
void (^blk)(void) = ^{
    o1;
    o2;
    o3;
};

以上代碼塊對應的 layout 值爲 0x111 ,表示三種類型變量每種一個 :

(lldb) p/x (long)layout->descriptor->layout
(long) $0 = 0x0000000000000111

再嘗試第二種 layout 編碼方式 :

NSObject *o1 = [NSObject new];
__block NSObject *o3 = o1;
__weak NSObject *o2 = o1;
NSObject *o4 = o1;
... // 5 - 18
NSObject *o19 = o1;
void (^blk)(void) = ^{
    o1;
    o2;
    o3;
    o4;
    ... // 5 - 18
    o19;
};

以上代碼塊對應的 layout 值是一個地址 0x0000000100002f44 ,這個地址爲編碼字符串的起始地址,轉換成十六進制後爲 0x3f 0x30 0x40 0x50 0x00,其中 P 爲 3 表示 __strong 修飾的變量,數量爲 15(f) + 1 + 0 + 1 = 17 個,P 爲 4 表示 __block 修飾的變量,數量爲 0 + 1 = 1 個, P 爲 5 表示 __weak 修飾的變量,數量爲 0 + 1 = 1 個 :

(lldb) p/x (long)layout->descriptor->layout
(long) $0 = 0x0000000100002f44
(lldb) x/8bx layout->descriptor->layout
0x100002f44: 0x3f 0x30 0x40 0x50 0x00 0x76 0x31 0x36

結構體對捕獲佈局的影響

由於結構體字段的佈局順序在聲明時就已經確定了,無法像 block 構造捕獲區域一樣,按照變量類型、修飾符進行調整,所以如果結構體中有類型爲對象指針的字段,就需要一些額外信息來計算這些對象指針字段的偏移量,需要注意的是,被捕獲結構體的內存對齊信息和未捕獲時一致,以尋址長度作爲對齊基準,捕獲操作並不會變更對齊信息。同樣地,我們先嚐試捕獲只有基本類型字段的結構體 :

struct S {
    char c;
    int i;
    long j;
} foo;
void (^blk)(void) = ^{
  foo;
};

然後調整 descriptor 結構,輸出 layout :

(lldb) x/8bx layout->descriptor->layout
0x100001f67: 0x00 0x76 0x31 0x36 0x40 0x30 0x3a 0x38

可以看到,只有含有基本類型的結構體,同樣不會影響 block 的 layout 編碼信息。接下來我們給結構體新增 __strong 和 __weak 修飾的對象指針字段 :

struct S {
    char c;
    int i;
    __strong NSObject *o1;
    long j;
    __weak NSObject *o2;
} foo;
void (^blk)(void) = ^{
  foo;
};

同樣分析輸出 layout :

(lldb) x/8bx layout->descriptor->layout
0x100002f47: 0x20 0x30 0x20 0x50 0x00 0x76 0x31 0x36

layout 編碼爲0x20 0x30 0x20 0x50 0x00,其中 P 爲 2 表示 word 字類型(非對象),由於字大小一般和指針一致,所以這裏表示佔用了 8 * (N + 1) 個字節,第一個 0x20 表示非對象指針類型佔用了 8 個字節,也就是 char 類型和 int 類型字段對齊之後所佔用的空間,接着 0x30 表示有一個 __strong 修飾的對象指針字段,第二個 0x20 表示非對象指針 long 類型佔用了 8 個字節,最後的 0x50 表示有一個 __weak 修飾的對象指針字段。由於編碼中包含了每個字段的排序和大小,我們就可以通過解析 layout 編碼後的偏移量,拿到想要的對象指針值。P 還有個 byte 類型,值爲 1 ,和 word 類型有相似的功能,只是表示的空間大小不同。

Byref 結構的佈局

由 __block 修飾的捕獲變量,會先轉換成 byref 結構,再由這個結構去持有實際的捕獲變量,block 只負責管理 byref 結構。

// 標誌位不一樣,這個結構的實際佈局也會有差別,這裏簡單地放在一起好閱讀
struct sr_block_byref {
    void *isa;
    struct sr_block_byref *forwarding;
    volatile int32_t flags; // contains ref count
    uint32_t size;
    // requires BLOCK_BYREF_HAS_COPY_DISPOSE
    void (*byref_keep)(struct sr_block_byref *dst, struct sr_block_byref *src);
    void (*byref_destroy)(struct sr_block_byref *);
    // requires BLOCK_BYREF_LAYOUT_EXTENDED
    const char *layout;
};

以上代碼塊就是 byref 對應的結構體。第一眼看上去,我比較困惑爲什麼還要有 layout 字段,雖然上文的 block 源碼註釋說明了 byref 和 block 結構一樣,都具備兩種不同的佈局編碼方式,但是 byref 不是隻針對一個變量麼,難道和 block 捕獲區域一樣也可以攜帶多個捕獲變量?帶着這個困惑,我們先看下以下表達式 :

__block  NSObject *o1 = [NSObject new];

使用 clang 重寫之後 :

struct __Block_byref_o1_0 {
    void *__isa;
    __Block_byref_o1_0 *__forwarding;
    int __flags;
    int __size;
    void (*__Block_byref_id_object_copy)(void*, void*);
    void (*__Block_byre/* @autoreleasepool */o{ __AtAutoreleasePool __autoreleasepool; e)(void*);
    NSObject *o1;
};

和 block 捕獲變量一樣,byref 攜帶的變量也是保存在結構體尾部的內存空間裏,當前上下文中,可以直接通過 sr_block_byref 的 layout 字段獲取 o1 對象指針值。可以看到,在包裝如對象指針這類常規變量時,layout 字段並沒有起到實質性的作用,那什麼條件下的 layout 才表示佈局編碼信息呢?如果使用 layout 字段表示編碼信息,那麼攜帶的變量又是何處安放的呢?我們一個個解答。

針對第一個問題,先看以下代碼塊 :

__block struct S {
    NSObject *o1;
} foo;
foo.o1 = [NSObject new];
void (^blk)(void) = ^{
  foo;
};

使用 clang 重寫之後 :

struct __Block_byref_foo_0 {
  void *__isa;
  __Block_byref_foo_0 *__forwarding;
  int __flags;
  int __size;
  void (*__Block_byref_id_object_copy)(void*, void*);
  void (*__Block_byref_id_object_dispose)(void*);
  struct S foo;
};

和常規類型一樣,foo 結構體保存在結構體尾部,也就是原本 layout 所在的字段,重寫的代碼中依然看不到 layout 的蹤影,接着我們試着輸出 foo :

(lldb) po foo.o1
<NSObject: 0x10061f130>
(lldb) p (struct S)a_byref->layout
error: Multiple internal symbols found for 'S'
(lldb) p/x (long)a_byref->layout
(long) $3 = 0x0000000000000100
(lldb) x/56bx a_byref
0x100627c20: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x100627c28: 0x20 0x7c 0x62 0x00 0x01 0x00 0x00 0x00
0x100627c30: 0x04 0x00 0x00 0x13 0x38 0x00 0x00 0x00
0x100627c38: 0x90 0x1b 0x00 0x00 0x01 0x00 0x00 0x00
0x100627c40: 0x00 0x1c 0x00 0x00 0x01 0x00 0x00 0x00
0x100627c48: 0x00 0x01 0x00 0x00 0x00 0x00 0x00 0x00
0x100627c50: 0x30 0xf1 0x61 0x00 0x01 0x00 0x00 0x00

看來事情並沒有看上去的那麼簡單,首先重寫代碼中 foo 字段所在內存保存的並不是結構體,而是 0x0000000000000100,這個 100 是不是看着有點眼熟,沒錯,這就是 byref 的 layout 信息,根據 0xXYZ 編碼規則,這個值表示有 1 個 __strong 修飾的對象指針。接着針對第二個問題,攜帶的對象指針變量存在哪,我們把視線往下移動 8 個字節,這不就是 foo.o1 對象指針的值麼。總結下,在存在 layout 的情況下,byref 使用 8 個字節保存 layout 編碼信息,並緊跟着在 layout 字段後存儲捕獲的變量。

以上是 byref 的第一種 layout 編碼方式,我們再嘗試第二種 :

__block struct S {
    char c;
    NSObject *o1;
    __weak NSObject *o3;
} foo;
foo.o1 = [NSObject new];
void (^blk)(void) = ^{
  foo;
};

使用 clang 重寫代碼之後 :

struct __Block_byref_foo_0 {
  void *__isa;
__Block_byref_foo_0 *__forwarding;
 int __flags;
 int __size;
 void (*__Block_byref_id_object_copy)(void*, void*/* @autoreleasepool */c{ __AtAutoreleasePool __autoreleasepool; _byref
struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags=0) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

emmmm …,上面代碼並不是粘貼錯誤,貌似 Rewriter 並不能很好地處理這種情況,看來又需要我們直接去看對應內存地址中的值了 :

(lldb) x/72bx a_byref
0x100755140: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x100755148: 0x40 0x51 0x75 0x00 0x01 0x00 0x00 0x00
0x100755150: 0x04 0x00 0x00 0x13 0x48 0x00 0x00 0x00
0x100755158: 0x10 0x1b 0x00 0x00 0x01 0x00 0x00 0x00
0x100755160: 0xa0 0x1b 0x00 0x00 0x01 0x00 0x00 0x00
0x100755168: 0x8d 0x3e 0x00 0x00 0x01 0x00 0x00 0x00
0x100755170: 0x00 0x5f 0x6b 0x65 0x79 0x00 0x00 0x00
0x100755178: 0xd0 0x6e 0x75 0x00 0x01 0x00 0x00 0x00
0x100755180: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
(lldb) x/8bx a_byref->layout
0x100003e8d: 0x20 0x30 0x50 0x00 0x53 0x52 0x4c 0x61

地址 0x100755168 中保存了 layout 編碼字符串的地址 0x0000000100003e8d ,將此字符串轉換成十六進制後爲 0x20 0x30 0x50 0x00 ,這些值的含義在結構體對捕獲佈局的影響一節中已經描述過,這裏就不重複說明了。

強引用對象的獲取

目前我們已經知道了 block / byref 如何佈局捕獲區域內存,以及如何獲取關鍵的佈局信息,接下來我們就可以嘗試獲取 block 強引用的對象了,這裏我把強引用的對象分成兩部分 :

  • 被 block 強引用

  • 被 byref 結構強引用

只要獲取這兩部分強引用的對象,任務就算完成了,由於上文已經將整個原理脈絡理清了,所以編寫出可用的代碼並不困難。這兩部分都涉及到佈局編碼,我們先根據 layout 的編碼方式,解析出捕獲變量的類型和數量 :

SRCapturedLayoutInfo *info = [SRCapturedLayoutInfo new];
    
if ((uintptr_t)layout < (1 << 12)) {
    uintptr_t inlineLayout = (uintptr_t)layout;
    [info addItemWithType:SR_BLOCK_LAYOUT_STRONG count:(inlineLayout & 0xf00) >> 8];
    [info addItemWithType:SR_BLOCK_LAYOUT_BYREF count:(inlineLayout & 0xf0) >> 4];
    [info addItemWithType:SR_BLOCK_LAYOUT_WEAK count:inlineLayout & 0xf];
} else {
    while (layout && *layout != '\x00') {
        unsigned int type = (*layout & 0xf0) >> 4;
        unsigned int count = (*layout & 0xf) + 1;
        
        [info addItemWithType:type count:count];
        layout++;
    }
}

然後遍歷 block 的佈局編碼信息,根據變量類型和數量,計算出對象指針地址偏移,然後獲取對應的對象指針值 :

- (NSHashTable *)strongReferencesForBlockLayout:(void *)iLayout {
    if (!iLayout) return nil;
    
    struct sr_block_layout *aLayout = (struct sr_block_layout *)iLayout;
    const char *extenedLayout = sr_block_extended_layout(aLayout);
    _blockLayoutInfo = [SRCapturedLayoutInfo infoForLayoutEncode:extenedLayout];
    
    NSHashTable *references = [NSHashTable weakObjectsHashTable];
    uintptr_t *begin = (uintptr_t *)aLayout->captured;
    for (SRLayoutItem *item in _blockLayoutInfo.layoutItems) {
        switch (item.type) {
            case SR_BLOCK_LAYOUT_STRONG: {
                NSHashTable *objects = [item objectsForBeginAddress:begin];
                SRAddObjectsFromHashTable(references, objects);
                begin += item.count;
            } break;
            case SR_BLOCK_LAYOUT_BYREF: {
                for (int i = 0; i < item.count; i++, begin++) {
                    struct sr_block_byref *aByref = *(struct sr_block_byref **)begin;
                    NSHashTable *objects = [self strongReferenceForBlockByref:aByref];
                    SRAddObjectsFromHashTable(references, objects);
                }
            } break;
            case SR_BLOCK_LAYOUT_NON_OBJECT_BYTES: {
                begin = (uintptr_t *)((uintptr_t)begin + item.count);
            } break;
            default: {
                begin += item.count;
            } break;
        }
    }
    
    return references;
}

block 佈局區域中的 byref 結構需要進行額外的處理,如果 byref 直接攜帶 __strong 修飾的變量,則不需要關心 layout 編碼,直接從結構尾部獲取指針變量值即可,否則需要和處理 block 佈局區域一樣,先得到佈局信息,然後遍歷這些佈局信息,計算偏移量,獲取強引用對象地址 :

- (NSHashTable *)strongReferenceForBlockByref:(void *)iByref {
    if (!iByref) return nil;
    
    struct sr_block_byref *aByref = (struct sr_block_byref *)iByref;
    NSHashTable *references = [NSHashTable weakObjectsHashTable];
    int32_t flag = aByref->flags & SR_BLOCK_BYREF_LAYOUT_MASK;
    
    switch (flag) {
        case SR_BLOCK_BYREF_LAYOUT_STRONG: {
            void **begin = sr_block_byref_captured(aByref);
            id object = (__bridge id _Nonnull)(*(void **)begin);
            if (object) [references addObject:object];
        } break;
        case SR_BLOCK_BYREF_LAYOUT_EXTENDED: {
            const char *layout = sr_block_byref_extended_layout(aByref);
            SRCapturedLayoutInfo *info = [SRCapturedLayoutInfo infoForLayoutEncode:layout];
            [_blockByrefLayoutInfos addObject:info];
            
            uintptr_t *begin = (uintptr_t *)sr_block_byref_captured(aByref) + 1;
            for (SRLayoutItem *item in info.layoutItems) {
                switch (item.type) {
                    case SR_BLOCK_LAYOUT_NON_OBJECT_BYTES: {
                        begin = (uintptr_t *)((uintptr_t)begin + item.count);
                    } break;
                    case SR_BLOCK_LAYOUT_STRONG: {
                        NSHashTable *objects = [item objectsForBeginAddress:begin];
                        SRAddObjectsFromHashTable(references, objects);
                        begin += item.count;
                    } break;
                    default: {
                        begin += item.count;
                    } break;
                }
            }
        } break;
        default: break;
    }
    
    return references;
}

完整代碼我放到了 BlockStrongReferenceObject,代碼並沒有進行過很嚴格的測試,可能存在一些未處理的邊界條件,需要嘗試 / 討論的同學可自取。

另一種強引用對象獲取方式

上文通過將 block 的佈局編碼信息轉化爲對應字段的偏移量來獲取強引用對象,這一節介紹另外一種比較取巧的方式,也是目前檢測循環引用工具獲取 block 強引用對象的常用方式,比如 facebook 的 FBRetainCycleDetector 。根據這塊功能對應的源碼,此方式大致原理如下 :

  • 獲取 block 的 dispose 函數 (如果捕獲了強引用對象,需要利用這個函數解引用)

  • 構造一個 fake 對象,此對象由若干個擴展的 byref 結構 (對象) 組成,其個數由 block size 決定,即把 block 劃分爲若干個 8 字節內存區域,就像以下代碼塊一樣 :

struct S {
    NSObject *o1;
    NSObject *o2;
};
struct S s = {
    .o2 = [NSObject new]
};
void **fake = (void **)&s;
// fake[1] 和 s.o2 是一樣的
  • 擴展的 byref 結構會重寫 release 方法,只在此方法中設置強引用標識位,不執行原釋放邏輯

  • 將 fake 對象作爲參數,調用 dispose 函數,dispose 函數會去 release 每個 block 強引用的對象,在這裏這些強引用對象被替換成了我們的 byref 結構,所以我們可以通過它的強引用標識位判斷 block 的哪塊區域保存了強引用對象地址

  • 遍歷 fake 對象,保存所有強引用標誌位被設置的 byref 結構對應索引,後面通過這個索引可以去 block 中找強引用指針地址

  • 釋放所有的 byref 結構

  • 根據上面得到的索引,獲取捕獲變量偏移量,偏移量爲索引值 * 8 字節 (指針大小) ,再根據偏移量去 block 內存塊中拿強引用對象地址

關於這種方案,我們需要明確下面幾個點。

首先這種方案也需要在明確 block 內存佈局的情況下才能夠實施,因爲 block ,或者說 block 結構體,實際執行內存對齊時,並沒有按照尋址大小也就是 8 字節對齊,假設 block 捕獲區域的對齊方式變成了這樣 :

struct __main_block_impl_0 {
  struct __block_impl impl;           // 24
  struct __main_block_desc_0* Desc;   // 8 指針佔用內存大小和尋址長度相關,在 64 位機環境下,編譯器分配空間大小爲 8 字節
  int i;                              // 4    FakedByref 8
  NSObject *o1;                       // 8    FakedByref 8 [這裏上個 FakedByref 後 4 個子節和當前 FakedByref 前 4 字節覆蓋 o1 對象指針的 8 字節,導致 miss ]
  char c;                             // 1
  NSObject *o2;                       // 8
}

那麼使用 fake 的方案就會失效,因爲這種方案的前提是 block 內存對齊基準基於尋址長度,即指針大小。不過 block 對捕獲的變量按照類型和尺寸進行了排序,__strong 修飾的對象指針都在前面,本來我們只需要這種類型的變量,並不關心其他類型,所以即使後面的對齊方式不滿足 fake 條件也沒關係,另外捕獲結構體的對齊基準是基於尋址長度的,即使結構體有其他類型,也滿足 fake 條件 :

struct __main_block_impl_0 {
  struct __block_impl impl;           // 24
  struct __main_block_desc_0* Desc;   // 8 指針佔用內存大小和尋址長度相關,在 64 位機環境下,編譯器分配空間大小爲 8 字節
  NSObject *o1;                       // 8    FakedByref 8
  NSObject *o2;                       // 8    FakedByref 8
  int i;                              // 4    FakedByref 8
  char c;                             // 1        
}

可以看到,通過以上代碼塊的排序,讓 o1 和 o2 都被 FakedByref 結構覆蓋到了,而 i, c 變量本身就不會在 dispose 函數中訪問,所以怎麼設置都不會影響到策略的生效。

第二點是爲什麼要用擴展的 byref 結構,而不是隨便整個重寫了 release 的類過來,這是因爲當 block 捕獲了 __block 修飾的指針變量時,會將這個指針變量包裝成 byref 結構,而 dispose 函數會對這個 byref 結構執行 Blockobject_dispose 操作,這個函數有兩個形參,一個是對象指針,一個是 flag ,當 flag 指明對象指針爲 byref 類型,而實際傳入的實參不是,就會出現問題,所以這裏必須用擴展的 byref 結構。

第三點是這種方式無法處理 __block 修飾對象指針的情況。

不過這種方式貴在簡潔,無需考慮內部每種變量類型具體的佈局方式,就可以滿足大部分需要獲取 block 強引用對象的場景。

對象成員變量強引用

對象強引用成員變量的獲取相對來說直接些,因爲每個對象對應的類中都有其成員變量的佈局信息,並且 runtime 有現成的接口,只需要分析出編碼格式,然後按順序和成員變量匹配即可。獲取編碼信息的接口有兩個, class_getIvarLayout 函數返回描述 strong ivar 數量和索引信的編碼信息,相對的 class_getWeakIvarLayout 函數返回描述 weak ivar 的編碼信息,這裏基於前者進行分析。

class_getIvarLayout 返回值是一個 uint8 指針,指向一個字符串,uint8 在 16 進制下佔用 2 位,所以編碼以 2 位爲一組,組內首位描述非 strong ivar 個數,次位爲 strong ivar 個數,最後一組如果 strong ivar 個數爲 0,則忽略,且 layout 以 0x00 結尾。下面舉幾個例子 :

// 0x0100
@interface A : NSObject {
    __strong NSObject *s1;
}
@end

起始非 strong ivar 個數爲 0,並且接着一個 strong ivar ,得出編碼爲 0x01 。

// 0x0100
@interface A : NSObject {
    __strong NSObject *s1;
    __weak NSObject *w1;
}
@end

起始非 strong ivar 個數爲 0,並且接着一個 strong ivar ,得出編碼爲 0x01,接着有個 weak ivar,但是後面沒有 strong ivar 了,所以忽略。

// 0x011100
@interface A : NSObject {
    __strong NSObject *s1;
    __weak NSObject *w1;
    __strong NSObject *s2;
}
@end

起始非 strong ivar 個數爲 0,並且接着一個 strong ivar ,得出編碼爲 0x01,接着有個 weak ivar,並且後面緊接着一個 strong ivar ,得出編碼 0x11 ,合併得到 0x0111 。

// 0x211100
@interface A : NSObject {
    int i1;
    void *p1;
    __strong NSObject *s1;
    __weak NSObject *w1;
    __strong NSObject *s2;
}
@end

起始非 strong ivar 個數爲 2,並且緊接着一個 strong ivar,得出編碼 0x21,接着有個 weak ivar,後面緊接着一個 strong ivar ,得出編碼 0x11 ,合併得到 0x2111 。

瞭解了成員變量的編碼格式,剩下的就是如何解碼並依次和成員變量進行匹配了,FBRetainCycleDetector 已經實現了這部分功能 ,主要原理如下 :

  • 獲取所有的成員變量以及 ivar 編碼

  • 解析 ivar 編碼,跳過非 strong ivar ,獲得 strong ivar 所在索引值 (把對象分成若干個 8 字節內存片段)

  • 利用 ivar_getOffset 函數獲取 ivar 的偏移量,除以指針大小就是自身的索引值 (對象佈局對齊基準爲尋址長度,這裏爲 8 字節)

  • 匹配 2、3 步獲得的索引值,得到 strong ivar

  • 當然 FBRetainCycleDetector 還實現了對結構體的處理,這塊就不細究了。

小結

以上是我認爲檢測循環引用兩個比較關鍵的點,特別是獲取 block 捕獲的強引用對象環節,block ABI 中並沒有詳細說明捕獲區域佈局信息,需要自己結合 block 源碼以及 clang 生成 block 的 CodeGen 邏輯去推測實際的佈局信息,所以得出的結論不一定正確,也歡迎感興趣的同學和我交流。

參考

[1] Circle - a cycle collector for Objective-C ARC https://github.com/mikeash/Circle/blob/master/Circle/CircleIVarLayout.m 
[2]FBRetainCycleDetector https://github.com/facebook/FBRetainCycleDetector 
[3]Automatic memory leak detection on iOS https://code.fb.com/ios/automatic-memory-leak-detection-on-ios/ 
[4]Objective-C Class Ivar Layout 探索 https://blog.sunnyxx.com/2015/09/13/class-ivar-layout/


程序員專欄 掃碼關注填加客服 長按識別下方二維碼進羣

近期精彩內容推薦:  

 阿里徹底拆中臺了!

 程序員相親圖鑑

 21 歲理工男開源的這個編輯器火了!

 996 違法???


在看點這裏好文分享給更多人↓↓

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章