Edited at

64bit環境のobjc_msgSendについて調べた

2015年からiOSアプリは64bit対応が必須になった。それに伴い、 objc_msgSend にキャストが必要になったのでそれについて書く。(途中まで書いてほったらかしになっていた)

objc_msgSend はオブジェクトのメソッドを呼び出す関数である。

ダイナミックObjective-C メッセージ送信(1) - objc_msgSendの実装


以前のプロトタイプ宣言

id objc_msgSend(id self, SEL op, ...);



今のプロトタイプ宣言

void objc_msgSend(void);


以前は可変長引数を取る関数として宣言されていた。任意の関数(引数が1個だったり2個だったり)を呼べるようにするため、便宜上可変長引数を取る関数として記述するというのはC言語ではよくあるテクニックである。これによりセレクタ以降の引数が何個であってもキャストなしで呼び出すことができた。

今はvoidになっていて、キャストしないと呼び出せないようになっている。

これは(iOSの)ARMの64bit環境にて、可変長引数を取る関数と、そうでない普通の関数の呼び出し規約が異なることに起因している。

Appleが定めた呼出し規約 ARM64 Function Calling Conventions は、ARMによる呼出し規約 Procedure Call Standard for the ARM 64-bit Architecture (AArch64) を何箇所か改変していて、可変長引数をスタックに置くようになっている。

呼び出し規約(calling conventions)とは、関数を呼び出す側(caller)と呼び出される関数(callee)について、呼び出すときの引数の渡し方や、戻り値の受け取り方など、レジスタやスタックの用途やレイアウトについて規定したものである。呼び出し規約はCPUや言語やコンパイラなど、プラットフォーム毎に規定される。ここではiOSのC言語の呼び出し規約について述べるが、下記のように、OS(iOS)と言語(C)が同じでもCPUアーキテクチャによって呼出し規約は異なる。

例として、9つの引数を受け取る hoge 、1つの固定引数と可変長の引数を受け取る fuga 、2つの固定引数と可変長の引数を受け取る piyo を、シミュレータと実機で比較してみる。


hoge

int64_t hoge(int64_t a, int64_t b, int64_t c, int64_t d, int64_t e, int64_t f, int64_t g, int64_t h, int64_t i) {

return a;
}

int64_t fuga(int64_t a, ...) {
return a;
}

int64_t piyo(int64_t a, int64_t b, ...) {
return a;
}

void test() {
hoge(1, 2, 3, 4, 5, 6, 7, 8, 9);
fuga(1, 2, 3, 4, 5, 6, 7, 8, 9);
piyo(1, 2, 3, 4, 5, 6, 7, 8, 9);
}


64bitのシミュレータ(x86-64)の場合、上記三つの関数呼び出しは同じレイアウトになる。

(以下はアセンブリ出力)

    0x1023e7669 <+121>:  movl   $0x1, %r8d

0x1023e766f <+127>: movl %r8d, %edi
0x1023e7672 <+130>: movl $0x2, %r8d
0x1023e7678 <+136>: movl %r8d, %esi
0x1023e767b <+139>: movl $0x3, %r8d
0x1023e7681 <+145>: movl %r8d, %edx
0x1023e7684 <+148>: movl $0x4, %r8d
0x1023e768a <+154>: movl %r8d, %ecx
0x1023e768d <+157>: movl $0x5, %r8d
0x1023e7693 <+163>: movl $0x6, %r9d
0x1023e7699 <+169>: movl $0x7, %r10d
0x1023e769f <+175>: movl %r10d, %eax
0x1023e76a2 <+178>: movl $0x8, %r10d
0x1023e76a8 <+184>: movl %r10d, %r11d
0x1023e76ab <+187>: movl $0x9, %r10d
0x1023e76b1 <+193>: movl %r10d, %ebx
0x1023e76b4 <+196>: movq $0x7, (%rsp)
0x1023e76bc <+204>: movq $0x8, 0x8(%rsp)
0x1023e76c5 <+213>: movq $0x9, 0x10(%rsp)
0x1023e76ce <+222>: movq %rbx, -0x70(%rbp)
0x1023e76d2 <+226>: movq %r11, -0x78(%rbp)
0x1023e76d6 <+230>: movq %rax, -0x80(%rbp)
0x1023e76da <+234>: callq 0x1023e7540 ; hoge

debug buildなので無駄なコードがあってわかりづらいが、第一引数から順番に、rdi、rsi、rdx、rcx、r8、r9、スタック、スタック、スタックに設定している。

一方、64bitの実機の場合は、レイアウトがすべて異なる。

    0x1000702ac <+136>:  orr    x0, xzr, #0x1

0x1000702b0 <+140>: orr x1, xzr, #0x2
0x1000702b4 <+144>: orr x2, xzr, #0x3
0x1000702b8 <+148>: orr x3, xzr, #0x4
0x1000702bc <+152>: movz x4, #0x5
0x1000702c0 <+156>: orr x5, xzr, #0x6
0x1000702c4 <+160>: orr x6, xzr, #0x7
0x1000702c8 <+164>: orr x7, xzr, #0x8
0x1000702cc <+168>: movz x8, #0x9
0x1000702d0 <+172>: str x8, [sp]
0x1000702d4 <+176>: bl 0x100070170 ; hoge

    0x100070340 <+284>:  mov    x8, sp

0x100070344 <+288>: movz w11, #0x9
0x100070348 <+292>: mov x9, x11
0x10007034c <+296>: str x9, [x8, #56]
0x100070350 <+300>: orr w11, wzr, #0x8
0x100070354 <+304>: mov x9, x11
0x100070358 <+308>: str x9, [x8, #48]
0x10007035c <+312>: orr w11, wzr, #0x7
0x100070360 <+316>: mov x9, x11
0x100070364 <+320>: str x9, [x8, #40]
0x100070368 <+324>: orr w11, wzr, #0x6
0x10007036c <+328>: mov x9, x11
0x100070370 <+332>: str x9, [x8, #32]
0x100070374 <+336>: movz w11, #0x5
0x100070378 <+340>: mov x9, x11
0x10007037c <+344>: str x9, [x8, #24]
0x100070380 <+348>: orr w11, wzr, #0x4
0x100070384 <+352>: mov x9, x11
0x100070388 <+356>: str x9, [x8, #16]
0x10007038c <+360>: orr w11, wzr, #0x3
0x100070390 <+364>: mov x9, x11
0x100070394 <+368>: str x9, [x8, #8]
0x100070398 <+372>: orr w11, wzr, #0x2
0x10007039c <+376>: mov x9, x11
0x1000703a0 <+380>: str x9, [x8]
0x1000703a4 <+384>: orr w11, wzr, #0x1
0x1000703a8 <+388>: mov x1, x11
0x1000703ac <+392>: stur x0, [x29, #-96]
0x1000703b0 <+396>: mov x0, x1
0x1000703b4 <+400>: bl 0x1000701b0 ; fuga

    0x1000704a8 <+644>:  mov    x8, sp

0x1000704ac <+648>: movz w11, #0x9
0x1000704b0 <+652>: mov x9, x11
0x1000704b4 <+656>: str x9, [x8, #48]
0x1000704b8 <+660>: orr w11, wzr, #0x8
0x1000704bc <+664>: mov x9, x11
0x1000704c0 <+668>: str x9, [x8, #40]
0x1000704c4 <+672>: orr w11, wzr, #0x7
0x1000704c8 <+676>: mov x9, x11
0x1000704cc <+680>: str x9, [x8, #32]
0x1000704d0 <+684>: orr w11, wzr, #0x6
0x1000704d4 <+688>: mov x9, x11
0x1000704d8 <+692>: str x9, [x8, #24]
0x1000704dc <+696>: movz w11, #0x5
0x1000704e0 <+700>: mov x9, x11
0x1000704e4 <+704>: str x9, [x8, #16]
0x1000704e8 <+708>: orr w11, wzr, #0x4
0x1000704ec <+712>: mov x9, x11
0x1000704f0 <+716>: str x9, [x8, #8]
0x1000704f4 <+720>: orr w11, wzr, #0x3
0x1000704f8 <+724>: mov x9, x11
0x1000704fc <+728>: str x9, [x8]
0x100070500 <+732>: orr w11, wzr, #0x1
0x100070504 <+736>: mov x1, x11
0x100070508 <+740>: orr w11, wzr, #0x2
0x10007050c <+744>: mov x2, x11
0x100070510 <+748>: str x0, [sp, #104]
0x100070514 <+752>: mov x0, x1
0x100070518 <+756>: mov x1, x2
0x10007051c <+760>: bl 0x1000701c0 ; piyo

hoge は最初の8個の引数がレジスタで渡され、9個目だけがスタックで渡される。 fuga は最初の引数だけ、 piyo は先頭の二つの引数だけがレジスタで渡される。


以前のプロトタイプ宣言

id objc_msgSend(id self, SEL op, ...);


objc_msgSend の古い宣言は piyo と同じレイアウトである。

iOSのARM64環境で古い宣言をそのまま使うと、呼び出す側は、先頭の二つ(idとセレクタ)だけをレジスタで渡す。

なので呼び出される方が(可変長引数ではない) hoge のような関数だと、3つ目以降の引数(Objective-Cでは一つ目の引数)を受け取ることができないのである。

ちなみに他の環境(ARMの32bit、x86の32/64bit)では、レイアウトが同じなのでこの問題は起きない。(渡すサイズによってはもしかすると同じでないケースがあるかも)

なぜこうしたのか理由はよくわからないが、可変長引数が常にスタックにあるほうがコンパイラの実装がシンプルになるのかもしれない。

この問題の対処のため、Xcodeのビルド設定のPreprocessingにEnable Strict Checking of objc_msgSend Callsが追加された。Yes(新規プロジェクトのデフォルト)だとキャストしないとコンパイルエラーになるので新しいプロジェクトでは問題はあまり起きていないと思われる。


  • 2017/08 補足

WWDCにて、呼び出し規約が異なる理由をAppleのエンジニアに確認したところ、「Appleは古いARMの呼び出し規約を使用しており、ARMが途中で変更したが、すでにコード資産があったため、Appleは変更しないことにした」という回答だった。

もしかするとLLVMに移行する前の呼び出し規約をそのまま使っているのかもしれない。