iOS開發:教你動手實現objc_msgSend

來源:互聯網
上載者:User

iOS開發:教你動手實現objc_msgSend

   objc_msgSend 函數支撐了我們使用 Objective-C 實現的一切。Gwynne Raskind,Friday Q&A 的讀者,建議我談談 objc_msgSend 的內部實現。要理解某件事還有比自己動手實現一次更好的方法嗎?咱們來自己動手實現一個 objc_msgSend。

  Tramapoline! Trampopoline! (蹦床)

  當你寫了一個發送 Objective-C 訊息的方法:

  [obj message]

  編譯器會產生一個 objc_msgSend 調用:

  objc_msgSend(obj, @selector(message));

  之後 objc_msgSend 會負責轉寄這個訊息。

  它都做了什麼?它會尋找合適的函數指標或者 IMP,然後調用,最後跳轉。任何傳給 objc_msgSend 的參數,最終都會成為 IMP 的參數。 IMP 的傳回值成為了最開始被調用的方法的傳回值。

  因為 objcmsgSend 只是負責接收參數,找到合適的函數指標,然後跳轉,有時管這種叫做 trampoline(譯註:[蹦床](https://en.wikipedia.org/wiki/Trampoline(computing)). 更通用的來說,任何一段負責把一段代碼轉寄到另一處的代碼,都可以被叫做 trampoline。

  這種轉寄的行為使 objc_msgSend 變得特殊起來。因為它只是簡單的尋找合適的代碼,然後直接跳轉過去,這相當的通用。傳入任何參數組合都可以,因為它只是把這些參數留給 IMP 去讀取。傳回值有些棘手,但最終都可以看成 objc_msgSend 的不同變種。

  不幸的是,這些轉寄行為都不能用純 C 實現。因為沒有方法可以將傳入 C 函數的泛參(generic parameters)傳給另一個函數。 你可以使用變參,但是變參和普通參數的傳遞方法不同,而且慢,所以這不適合普通的 C 參數。

  如果要用 C 來實現 objc_msgSend,基本樣子應該像這樣:

  id objc_msgSend(id self, SEL _cmd, ...)

  {

  Class c = object_getClass(self);

  IMP imp = class_getMethodImplementation(c, _cmd);

  return imp(self, _cmd, ...);

  }

  這有點過於簡單。事實上會有一個方法緩衝來提升尋找速度,像這樣:

  id objc_msgSend(id self, SEL _cmd, ...)

  {

  Class c = object_getClass(self);

  IMP imp = cache_lookup(c, _cmd);

  if(!imp)

  imp = class_getMethodImplementation(c, _cmd);

  return imp(self, _cmd, ...);

  }

  通常為了速度,cache_lookup 使用 inline 函數實現。

  彙編

  在 Apple 版的 runtime 中,為了最大化速度,整個函數是使用彙編實現的。在 Objective-C 中每次發送訊息都會調用 objc_msgSend,在一個應用中最簡單的動作都會有成千或者上百萬的訊息。

  為了讓事情更簡單,我自己的實現中會儘可能少的使用彙編,使用獨立的 C 函數抽象複雜度。彙編代碼會實現下面的功能:

  id objc_msgSend(id self, SEL _cmd, ...)

  {

  IMP imp = GetImplementation(self, _cmd);

  imp(self, _cmd, ...);

  }

  GetImplementation 可以用更可讀的方式工作。

  彙編代碼需要:

  1. 把所有潛在的參數儲存在安全的地方,確保 GetImplementation 不會覆蓋它們。

  2. 調用 GetImplementation。

  3. 把傳回值儲存在某處。

  4. 恢複所有的參數值。

  5. 跳轉到 GetImplementation 返回的 IMP。

  讓我們開始吧!

  這裡我會嘗試使用 x86-64 彙編,這樣可以很方便的在 Mac 上工作。這些概念也可以應用於 i386 或者 ARM。

  這個函數會儲存在獨立的檔案中,叫做 msgsend-asm.s。這個檔案可以像源檔案那樣傳遞給編譯器,然後會被編譯並連結到程式中。

  第一件事要做的是聲明全域的符號(global symbol)。因為一些無聊的曆史原因,C 函數的 global symbol 會在名字前有個底線:

  .globl _objc_msgSend

  _objc_msgSend:

  編譯器會很高興的連結最近可使用的(nearest available) objc_msgSend。簡單的連結這個到一個測試 app 已經可以讓 [obj message] 運算式使用我們自己的代碼而不是蘋果的 runtime,這樣可以相當方便的測試我們的代碼確保它可以工作。

  整型數和指標參數會被傳入寄存器 %rsi, %rdi, %rdx, %rcx, %r8 和 %r9。其他類型的參數會被傳進棧(stack)中。這個函數最先做的事情是把這六個寄存器中的值儲存在棧中,這樣它們可以在之後被恢複:

  pushq %rsi

  pushq %rdi

  pushq %rdx

  pushq %rcx

  pushq %r8

  pushq %r9

  除了這些寄存器,寄存器 %rax 扮演了一個隱藏的參數。它用於變參的調用,並儲存傳入的向量寄存器(vector registers)的數量,用於被調用的函數可以正確的準備變參列表。以防目標函數是個變參的方法,我同樣也儲存了這個寄存器中的值:

  pushq %rax

  為了完整性,用於傳入浮點型別參數的寄存器 %xmm 也應該被儲存。但是,要是我能確保 GetImplementation 不會傳入任何的浮點數,我就可以忽略掉它們,這樣我就可以讓代碼更簡潔。

  接著,對齊棧。 Mac OS X 要求一個函數調用棧需要對齊 16 位元組邊界。上面的代碼已經是棧對齊的,但是還是需要顯式手動處理下,這樣可以確保所有都是對齊的,就不用擔心動態調用函數時會崩潰。要對齊棧,在儲存 %r12 的原始值到棧中後,我把當前的棧指標儲存到了 %r12 中。%r12 是隨便選的,任何儲存的調用者寄存器(caller-saved register)都可以。重要的是在調用完 GetImplementation 後這些值仍然存在。然後我把棧指標按位與(and)上 -0x10,這樣可以清除棧底的四位:

  pushq %r12

  mov %rsp, %r12

  andq $-0x10, %rsp

  現在棧指標是對齊的了。這樣可以安全的避開上面(above)儲存的寄存器,因為棧是向下增長的,這種對齊的方法會讓它更向下(move it further down)。

  是時候該調用 GetImplementation 了。它接收兩個參數,self 和 _cmd。 調用習慣是把這兩個參數分別儲存到 %rsi 和 %rdi 中。然而傳入 objc_msgSend 中時就是那樣了,它們沒有被移動過,所以不需要改變它們。所有要做的事情實際上是調用 GetImplementation,方法名前面也要有一個底線:

  callq _GetImplementation

  整型數和指標類型的傳回值儲存在 %rax 中,這就是找到返回的 IMP 的地方。因為 %rax 需要被恢複到初始的狀態,返回的 IMP 需要被移動到別的地方。我隨便選了個 %r11。

  mov %rax, %r11

  現在是時候該恢複原樣了。首先要恢複之前儲存在 %r12 中的棧指標,然後恢複舊的 %r12 的值:

  mov %r12, %rsp

  popq %r12

  然後按壓入棧的相反順序恢複寄存器的值:

  popq %rax

  popq %r9

  popq %r8

  popq %rcx

  popq %rdx

  popq %rdi

  popq %rsi

  現在一切都已經準備好了。參數寄存器(argument registers)都恢複到了之前的樣子。目標函數需要的參數都在合適的位置了。 IMP 在寄存器 %r11 中,現在要做的是跳轉到那裡:

  jmp *%r11

  就這樣!不需要其他的彙編代碼了。jump 把控制權交給了方法實現。從代碼的角度看,就好像發送訊息者直接調用的這個方法。之前的那些迂迴的調用方法都消失了。當方法返回,它會直接放回到 objc_msgSend 的調用處,不需要其他的操作。這個方法的傳回值可以在合適的地方找到。

  非常規的傳回值有一些細節需要注意。比如大的結構體(不能用一個寄存器大小儲存的傳回值)。在 x86-64,大的結構體使用隱藏的第一個參數返回。當你像這樣調用:

  NSRect r = SomeFunc(a, b, c);

  這個調用會被翻譯成這樣:

  NSRect r;

  SomeFunc(&r, a, b, c);

  用於傳回值的記憶體位址被傳入到 %rdi 中。因為 objc_msgSend 期望 %rdi 和 %rsi 中包含 self 和 _cmd,當一個訊息返回大的結構體時不會起作用的。同樣的問題存在於多個不同平台上。runtime 提供了 objc_msgSend_stret 用於返回結構體,工作原理和 objc_msgSend 類似,只是知道在 %rsi 中尋找 self 和在 %rdx 中尋找 _cmd。

  相似的問題發生在一些平台上發送訊息(messages)返回浮點類型值。在這些平台上,runtime 提供了 objc_msgSend_fpret(在 x86-64,objc_msgSend_fpret2 用於特別極端的情況)。

  方法尋找

  讓我們繼續實現 GetImplementation。上面的彙編蹦床意味著這些代碼可以用 C 實現。記得嗎,在真正的 runtime 中,這些代碼都是直接用彙編寫的,是為了儘可能的保證最快的速度。這樣不僅可以更好的控制碼,也可以避免重複像上面那樣儲存並恢複寄存器的代碼。

  GetImplementation 可以簡單的調用 class_getMethodImplementation 實現,混入 Objective-C runtime 的實現。這有點無聊。真正的 objc_msgSend 為了最大化速度首先會尋找類的方法緩衝。因為 GetImplementation 想模仿 objc_msgSend,所以它也會這麼做。要是緩衝中不包含給定的 selector 進入點(entry),它會繼續尋找 runtime(it fall back to querying the runtime)。

  我們現在需要的是一些結構體定義。方法緩衝是類(class)結構體中的私人結構體,為了得到它我們需要定義自己的版本。儘管是私人的,這些結構體的定義還是可以通過蘋果的 Objective-C runtime 開源實現獲得(譯註:http://opensource.apple.com/tarballs/objc4/)。

  首先需要定義一個 cache entry:

  typedef struct {

  SEL name;

  void *unused;

  IMP imp;

  } cache_entry;

  相當簡單。別問我 unused 欄位是幹什麼的,我也不知道它為什麼在那。這是 cache 的全部定義:

  struct objc_cache {

  uintptr_t mask;

  uintptr_t occupied;

  cache_entry *buckets[1];

  };

  緩衝使用 hash table(雜湊表)實現。實現這個表是為了速度的考慮,其他無關的都簡化了,所以它有點不一樣。表的大小永遠都是 2 的冪。表格使用 selector 做索引,bucket 是直接使用 selector 的值做索引,可能會通過移位去除不相關的低位(low bits),並與 mask 執行一個邏輯與(logical and)。下面是一些宏,用於給定 selector 和 mask 時計算 bucket 的索引:

  #ifndef __LP64__

  # define CACHE_HASH(sel, mask) (((uintptr_t)(sel)>>2) & (mask))

  #else

  # define CACHE_HASH(sel, mask) (((unsigned int)((uintptr_t)(sel)>>0)) & (mask))

  #endif

  最後是類的結構體。 這是 Class 指向的類型:

  struct class_t {

  struct class_t *isa;

  struct class_t *superclass;

  struct objc_cache *cache;

  IMP *vtable;

  };

  需要的結構體都已經有了,現在開始實現 GetImplementation 吧:

  IMP GetImplementation(id self, SEL _cmd)

  {

  首先要做的是擷取對象的類。真正的 objc_msgSend 通過類似 self->isa 的方式擷取,但是它會使用官方的 API 實現:

  Class c = object_getClass(self);

  因為我想訪問最原始的形式,我會為指向 class_t 結構體的指標執行類型轉換:

  struct class_t *classInternals = (struct class_t *)c;

  現在該尋找 IMP 了。首先我們把它初始為 NULL。如果我們在緩衝中找到,我們會賦值為它。如果尋找緩衝後仍為 NULL,我們會回退到速度較慢的方法:

  IMP imp = NULL;

  接著,擷取指向 cache 的指標:

  struct objc_cache *cache = classInternals->cache;

  計算 bucket 的索引,擷取指向 buckets 數組的指標:

  uintptr_t index = CACHE_HASH(_cmd, cache->mask);

  cache_entry **buckets = cache->buckets;

  然後,我們使用要找的 selector 尋找緩衝。runtime 使用的是線性鏈(linear chaining),之後只是遍曆 buckets 子集直到找到需要的 entry 或者 NULL entry:

  for(; buckets[index] != NULL; index = (index + 1) & cache->mask)

  {

  if(buckets[index]->name == _cmd)

  {

  imp = buckets[index]->imp;

  break;

  }

  }

  如果沒有找到 entry,我們會調用 runtime 使用一種較慢的方法。在真正的 objc_msgSend 中,上面的所有代碼都是使用彙編實現的,這時候就該離開彙編代碼調用 runtime 自己的方法了。一旦尋找緩衝後沒有找到需要的 entry,期望快速發送訊息的希望就要落空了。這時候擷取更快的速度就沒那麼重要了,因為已經註定會變慢,在一定程度上也極少的需要這麼調用。因為這點,放棄彙編代碼轉而使用更可維護的 C 也是可以接受的:

  if(imp == NULL)

  imp = class_getMethodImplementation(c, _cmd);

  不管怎樣,IMP 現在已經擷取到了。如果它在緩衝中,就會在那裡找到它,否則它會通過 runtime 尋找到。class_getMethodImplementation 調用同樣會使用緩衝,所以下次調用會更快。剩下的就是返回 IMP:

  return imp;

  }

  測試

  為了確保它能工作,我寫了一個快速的測試程式:

  @interface Test : NSObject

  - (void)none;

  - (void)param: (int)x;

  - (void)params: (int)a : (int)b : (int)c : (int)d : (int)e : (int)f : (int)g;

  - (int)retval;

  @end

  @implementation Test

  - (id)init

  {

  fprintf(stderr, "in init method, self is %p\n", self);

  return self;

  }

  - (void)none

  {

  fprintf(stderr, "in none method\n");

  }

  - (void)param: (int)x

  {

  fprintf(stderr, "got parameter %d\n", x);

  }

  - (void)params: (int)a : (int)b : (int)c : (int)d : (int)e : (int)f : (int)g

  {

  fprintf(stderr, "got params %d %d %d %d %d %d %d\n", a, b, c, d, e, f, g);

  }

  - (int)retval

  {

  fprintf(stderr, "in retval method\n");

  return 42;

  }

  @end

  int main(int argc, char **argv)

  {

  for(int i = 0; i < 20; i++)

  {

  Test *t = [[Test alloc] init];

  [t none];

  [t param: 9999];

  [t params: 1 : 2 : 3 : 4 : 5 : 6 : 7];

  fprintf(stderr, "retval gave us %d\n", [t retval]);

  NSMutableArray *a = [[NSMutableArray alloc] init];

  [a addObject: @1];

  [a addObject: @{ @"foo" : @"bar" }];

  [a addObject: @("blah")];

  a[0] = @2;

  NSLog(@"%@", a);

  }

  }

  以防因為一些意外調用的是 runtime 的實現。我在 GetImplementation 中加了一些調試的日誌確保它被調用了。一切都正常,即使是 literals and subscripting 也都調用的是替換的實現。

  結論

  objc_msgSend 的核心部分相當的簡單。但它的實現需要一些彙編代碼,這讓它比它應該的樣子更難理解。但是為了效能的最佳化還是得使用一些彙編代碼。但是通過構建了一個簡單的彙編蹦床,然後使用 C 實現了它的邏輯,我們可以看到它是如何工作的,它真的沒有什麼高深的。

  很顯然,你不應該在自己的 app 中使用替換的 objc_msgSend 實現。你會後悔這麼做的。這麼做只為了學習目的。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.