ARM の関数呼び出し

ARM の関数呼び出し、正確には関数呼び出しからの戻りかたの処理がおもしろかたのでメモしておく。

お試しソースはこれ。:

/* fcall.c */
int foo() {
  return 1;
}

int simple_call() {
  const int v = foo();
  return v;
}

int call_twice() {
  return foo() + foo();
}

これをアセンブリソースに変換する。:

$ gcc-arm-linux-gnu -O3 -fomit-frame-pointer -S fcall.c

(-S オプションつきで gcc を起動すると、コンパイルする代わりにアセンブリソースを .s 拡張子で出力してくれる。)

得られたアセンブリソースはこれ。:

	.arch armv5te
	.fpu softvfp
	.eabi_attribute 20, 1
	.eabi_attribute 21, 1
	.eabi_attribute 23, 3
	.eabi_attribute 24, 1
	.eabi_attribute 25, 1
	.eabi_attribute 26, 2
	.eabi_attribute 30, 2
	.eabi_attribute 18, 4
	.file	"fcall.c"
	.text
	.align	2
	.global	foo
	.type	foo, %function
foo:
	@ args = 0, pretend = 0, frame = 0
	@ frame_needed = 0, uses_anonymous_args = 0
	@ link register save eliminated.
	mov	r0, #1
	bx	lr
	.size	foo, .-foo
	.align	2
	.global	simple_call
	.type	simple_call, %function
simple_call:
	@ args = 0, pretend = 0, frame = 0
	@ frame_needed = 0, uses_anonymous_args = 0
	@ link register save eliminated.
	b	foo(PLT)
	.size	simple_call, .-simple_call
	.align	2
	.global	call_twice
	.type	call_twice, %function
call_twice:
	@ args = 0, pretend = 0, frame = 0
	@ frame_needed = 0, uses_anonymous_args = 0
	stmfd	sp!, {r4, lr}
	bl	foo(PLT)
	mov	r4, r0
	bl	foo(PLT)
	add	r0, r0, r4
	ldmfd	sp!, {r4, pc}
	.size	call_twice, .-call_twice
	.ident	"GCC: (GNU) 4.4.3"
	.section	.note.GNU-stack,"",%progbits

関数 foo はレジスター 0 に即値 1 をロードして、リンクレジスター (lr) にジャンプ (branch) するように翻訳されている。 ここからわかるとおり、 ARM アーキテクチャーでは、関数の呼び出し元に戻るのは単純に分岐処理でしかない。

simple_call は b foo(PLT) だけになっている。 つまり PLT (Procedure Linkage Table) を参照して、そこに分岐するだけ。 「呼び出し元へ戻る」という処理が消えている。 コメントに foo にあるものと同じように "link register save eliminated." とある。

わかりにくいけれど、わかるとおもしろい。 これは「末尾再帰の最適化」だ。

simple_call 関数が、自身のスタックに戻って処理をする必要がないので、戻りアドレスを(リンクレジスターに)設定しないまま次の関数を呼び出す。 そして呼ばれた関数がその戻り処理(ジャンプ/ブランチ)を実行すると、 simple_call のフレームをすっ飛ばして simple_call を呼び出したところに直接戻ることになっている。

末尾再帰の最適化を妨げるように foo を二回呼び出す call_twice では、 r4 と lr をスタックにプッシュしてから foo を二回呼び出し、 r4 レジスターを介して r0 レジスターに和を算出している。 そしてプッシュした r4 と lr を r4 と pc にポップしている。 ここがまた ARM のおもしろいところで、 lr に保存した値をプログラムカウンター pc に直接ロードすることで関数からの戻り処理を実現している。

ARM は pc (x86 での ip/eip) に直接代入するという力技で x86 にあった ret 命令を削っている。 これぞまさしく RISC

蛇足: call_twice のアセンブリで、なぜ foo が二回呼ばれるか?

.c ソースを見ると、固定値を返す foo を、 call_twice はなぜ二回律儀に呼び出しているのか。 一度呼び出して得た foo の値を、二回足して返せばいいじゃないか。

そうおもったあなた、関数型言語への移行を強くおすすめする。

さておき、 foo の呼び出しが b foo(PLT) になっているのが、二回呼び出さなければならない理由だ。 foo の実装がこのソースだけに限定されていたなら、たしかに foo の値を覚えておいて二回足して(あるいは左シフトを一回かまして二倍して)返せばよい。

しかし、この foo は外部の実装で書き換えられる可能性がある。 PLT 経由の呼び出しだからである。

PLT を経由して foo を呼び出すと、一度呼び出したブラックボックス関数 foo は、何らかの副作用を持っていて異なる値を返すかもしれない。(たとえば rand() の値を返すとか、 static 変数に何らかの値を足しこんで返すとか) こういう事態に対処するためには、律儀に foo を二回呼び出すしかないわけだ。 なお、 static や inline を foo につけることで結果は大きく変わる。(simple_call は即値 #1 を返すようになり、 call_twice は即値 #2 を返すようになる。 コンパイル時に計算が終わっている!)