GCCとcdecl規約とmrb_value

mrubyのContextThreading化で大変なバグを2つ見つけた。それを直したらブロックの呼び出しができるようになって、mrblibを含めたmrubyが作れた。
一つはOP_JMPIF/OP_JMPNOTのVM命令側で、ここに書くのも恥ずかしいのだが、昨日の記事に書いたコードがすでに恥ずかしいことになっているので致し方ない。修正コードを置いておく。

    CASE(OP_JMPIF) {
      char tt0 = regs[GETARG_A(i)].tt; // ←コレと

      /* A sBx  if R(A) pc+=sBx */
      if (mrb_test(regs[GETARG_A(i)])) {
        pc += GETARG_sBx(i);
        i=*pc;
      } 
      else {
        i=*++pc;
      }
      asm("addl $40, %esp;");
//      asm("cmpb $0, %0" : : "r" (regs[GETARG_A(i)].tt));
      asm("cmpb $0, %0" : : "r" (tt0)); // ←コレ
      asm("ret $0");
      NEXT;
    }

なんと、元のやつはジャンプ先の命令に埋め込まれたレジスタと比較していたのだ。よく動いていたなほんとに。

そしてもう一つはMAKE_CT_FUNCマクロ内のret命令。

    *adr = 0xc2;\
    *(adr + 1) = 0x00;\
    *(adr + 2) = 0x00;\

となっていたのを

    *adr = 0xc2;\
    *(adr + 1) = 0x04;\
    *(adr + 2) = 0x00;\

に変えた。c2はret nnという形の3byte命令で、nnには16bitの値が入る。リターンアドレスをスタックから取り出した後に、更にnnのぶんだけスタックを移動する。
スタック上のリターンアドレスはcall時に自動的に積まれるもので、これより先頭側は関数内のローカル変数として使うスタックフレームとなる。逆に後ろ側は呼び出し元の関数が使う領域である。ret 4とするこの修正は、自分のスタックフレームではなく、呼び出し元の領域を削り取ることになる。しかしこれで正しく動くようになる。

今回は、なぜこのような修正が発生したのか、という話。長いけどmrubyにはあんまり関係なくて、GCCの吐き出すコードとスタックの使い方、cdecl規約の決まりごとなどの関連となる。


1.序章 - pushが多い話

もともと、ContextThreading化の前にgccに-Sオプションをつけて出力したコードが何か変なことは気づいていた。例えばOP_STRING。

    CASE(OP_STRING) {
      /* A Bx           R(A) := str_new(Lit(Bx)) */
      regs[GETARG_A(i)] = mrb_str_literal(mrb, pool[GETARG_Bx(i)]);
      mrb->arena_idx = ai;
      NEXT;
    }

ちょっと長いがアセンブラのコード。

L413:
	subl $40, %esp

	movl	%ebx, %eax
	shrl	$23, %eax
	sall	$4, %eax
	addl	-800(%ebp), %eax
	movl	%eax, -1004(%ebp)
	shrl	$3, %ebx
	andl	$1048560, %ebx

	movl	-868(%ebp), %esi
	addl	%ebx, %esi
	leal	8(%esp), %edi
	movl	$4, %ecx
	rep movsl
	movl	12(%ebp), %ecx
	movl	%ecx, 4(%esp)
	leal	-792(%ebp), %ebx
	movl	%ebx, (%esp)
	call	_mrb_str_literal
	pushl	%ecx

	movl	$4, %ecx
	movl	-1004(%ebp), %edi
	movl	-1324(%ebp), %esi
	rep movsl

	movl	-848(%ebp), %edi
	movl	12(%ebp), %esi
	movl	%edi, 4236(%esi)

	addl	$4, -796(%ebp)
	movl	-796(%ebp), %eax
	movl	(%eax), %ebx
	addl $40, %esp;ret $0
	movl	%ebx, %eax
	andl	$127, %eax
	movl	_optable.3830(,%eax,4), %eax

	jmp	*%eax

一番上がインラインアセンブラで書いたスタックの調整。次の塊がGETARG_Bx(i)、その次が引数の設定とmrb_str_literal呼び出し、戻り値のコピー、arena_idx、NEXTルーチンとなっている。
俺が疑問に思ったのはcall _mrb_str_literal直後のpushだ。この時点でecxはcall先の動きにより値は不定となっているはずで、スタックにpushする意味がわからない。そもそもこのコードを見る限り、pushしてるのにpopしてない。スタックが伸びているんじゃないか。GCCのバグか?という具合だ。
気づいても意味がわからないしスタックもズレずに何故かちゃんと動いてるようだったから、とりあえず放置してあった。まあ、マシンスタックを調整して無理矢理動かすのがContextThreadingなのだから、こういうスタック関連で理解不能な部分は後々必ず問題にはなるんだろう、とは思いつつ。


2.cdecl規約

Cなどの言語には呼び出し規約というものがある。関数を呼び出すときに、引数や戻り値はどのように渡すのか、という取り決めをしておいて、そのルールに従って呼び出し側/呼び出され側がデータのやり取りをすれば、きちんと動く。そのルールのことを言う。けっこうたくさん種類があって、引数は右から順にスタックに積むだとか、左から積むだとか、レジスタに入れるだとか、引数に使ったスタックはどっち側が解放するだとか、変更するレジスタの保存はどうするだとか、まあいろいろ事細かに。
んで、だいたいのCコンパイラはcdecl規約というのがデフォルトになっている。GCCもそうだ。これは、
・引数は右から順にスタックに積む
・引数に使ったスタックは呼び出し元の関数側で処分する
・戻り値はeaxで返す
・戻り値がeaxに入らないサイズだった場合、戻り値を格納する領域のアドレスをスタックの最後に積む
・関数内でeax、ecx、edxは破壊してよい。それ以外は変えちゃダメ。
といった感じのルールらしい。正確な定義がどこにあるのかはよくわからない。ぼんやりとした決め事なんだろうか。


3.スタックがズレている件

ContextThreading化したmrubyでブロックを呼ぶとコケるので、その原因を調べていた。どうやらブロックのOP_RETURN後にコケていることがわかってきたので、gdbを使ってスタックの状態を調べてみた。
実行時に生成したコードを動かしているのでどうにもデバッグしにくい。ちょっと強引に、

void breakpoint(void){}

とか関数を作っておいて、OP_RETURNの最後に

        regs[acc] = v;
      }
breakpoint();
      JUMP;
    }

としてブレークすることに。以下gdbの操作と結果。はじめてのgdbなので微妙なのは笑って許して。

(gdb) b breakpoint
Breakpoint 1 at 0x41080c: file vm.c, line 180.
(gdb) r test.rb
Starting program: xxxxxx\mirichi\ct_mruby\bin\mruby.exe test.rb
[New Thread 3608.0xe4c]

Breakpoint 1, breakpoint () at vm.c:180
180     void breakpoint(void){}
(gdb) n
mrb_run (mrb=0x5d4038, proc=0x5d8774, self=...) at vm.c:1237
1237          JUMP;
(gdb) l
1232            syms = irep->syms;
1233
1234            regs[acc] = v;
1235          }
1236    breakpoint();
1237          JUMP;
1238        }
1239
1240        CASE(OP_TAILCALL) {
1241          /* A B C  return call(R(A),Sym(B),R(A+1),... ,R(A+C-1)) */
(gdb) x/100xb $esp
0x22f750:       0x38    0x40    0x5d    0x00    0x24    0xa1    0x5d    0x00
0x22f758:       0x01    0x01    0x00    0x00    0xe4    0x86    0x5d    0x00
0x22f760:       0x0c    0x00    0x00    0x00    0x0c    0xa5    0x5d    0x00
0x22f768:       0x30    0xdd    0x5d    0x00    0x5c    0x87    0x5d    0x00
0x22f770:       0x90    0xf9    0x22    0x00    0x94    0xf7    0x22    0x00
0x22f778:       0x0e    0xeb    0x5e    0x00    0x5f    0x7c    0x41    0x00
0x22f780:       0x00    0xf9    0x22    0x00    0x30    0x83    0x5e    0x00
0x22f788:       0x01    0x00    0x00    0x00    0x08    0xff    0x22    0x00
0x22f790:       0x94    0x5c    0xbe    0x77    0x00    0x00    0x00    0x00
0x22f798:       0x00    0x00    0x00    0x00    0x00    0x00    0x00    0x00
0x22f7a0:       0x00    0x00    0x00    0x00    0x76    0x00    0x00    0x00
0x22f7a8:       0x04    0xed    0x5e    0x00    0xb5    0x14    0x41    0x00
0x22f7b0:       0x68    0xfa    0x22    0x00
(gdb) c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x00000076 in ?? ()
(gdb)

最後はSEGVして終わっている。0x00000076というアドレスの命令を実行しようとして盛大にコケているわけだ。ブレークした部分でスタックの中身を出力してある。これを読み解いてみる。
まず、OP_RETURNは先頭でスタックを40byte進めているので、esp先頭から40byteはゴミである。その次の4byteがct_func内に戻るためのリターンアドレスで、その次の4byteはOP_SENDなどでct_funcを呼び出しているところに戻るリターンアドレス、そこから40byteがゴミで、トップのct_func内に戻るリターンアドレス、mrb_runに戻るリターンアドレス、という感じになっているはずだ。
つまり、こう。

0x22f750:       0x38    0x40    0x5d    0x00    0x24    0xa1    0x5d    0x00 |
0x22f758:       0x01    0x01    0x00    0x00    0xe4    0x86    0x5d    0x00 |
0x22f760:       0x0c    0x00    0x00    0x00    0x0c    0xa5    0x5d    0x00 | ←ゴミ
0x22f768:       0x30    0xdd    0x5d    0x00    0x5c    0x87    0x5d    0x00 |
0x22f770:       0x90    0xf9    0x22    0x00    0x94    0xf7    0x22    0x00 |
                                                                                                                                                          • +
0x22f778: | 0x0e 0xeb 0x5e 0x00 | 0x5f 0x7c 0x41 0x00 |
                                                                                                                                                          • +
↑ct_func内 ↑OP_SEND内 0x22f780: 0x00 0xf9 0x22 0x00 0x30 0x83 0x5e 0x00 | 0x22f788: 0x01 0x00 0x00 0x00 0x08 0xff 0x22 0x00 | 0x22f790: 0x94 0x5c 0xbe 0x77 0x00 0x00 0x00 0x00 | ←ゴミ 0x22f798: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 | 0x22f7a0: 0x00 0x00 0x00 0x00 0x76 0x00 0x00 0x00 |
                                                                                                                                                          • +
0x22f7a8: | 0x04 0xed 0x5e 0x00 | 0xb5 0x14 0x41 0x00 |
                                                                                                                                                          • +
↑ct_func内 ↑mrb_run内 0x22f7b0: 0x68 0xfa 0x22 0x00

x86アーキテクチャなのでリトルエンディアンになる。上下Byteが逆に並ぶから注意が必要だ。5eで始まるアドレスはmallocで確保したヒープのあたり、41で始まるのはCで書かれた実行コードが入っているあたり、22で始まるとスタックのあたりである。詳細はわからないがだいたいそのへんのアドレスだということがわかれば上出来だろう。だって0x00000076とかが動いてコケてるんだから。
これを見る限り、スタックそのものはきちんと積まれていることがわかる。では0x00000076というのは何かというと、なんかゴミのエリアの中にそれっぽいやつが見える。

                                             +-------------------------------+
0x22f7a0:       0x00    0x00    0x00    0x00 |  0x76    0x00    0x00    0x00 | ←この右側のやつがいかにも怪しい
                                                                                                                                                          • +
0x22f7a8: | 0x04 0xed 0x5e 0x00 | 0xb5 0x14 0x41 0x00 |
                                                                                                                                                          • +
↑ct_func内 ↑mrb_run内

つまり、ct_func内に戻るはずが、スタックポインタが4byte進んでいて、ゴミ内のデータを拾ってジャンプしているのではないか、と。こうなると例のpush1個が非常に疑わしい。


4.GCCのcdecl実装の詳細(呼び出し側)

mrb_str_literal関数はこのようなプロトタイプ宣言となっている。

mrb_value mrb_str_literal(mrb_state*, mrb_value);

mrb_stateポインタとmrb_valueの値を引数として、mrb_valueを戻り値とする。mrb_valueは16byteなのでレジスタには当然入らない。引数側は愚直にスタックに積むとして、戻り値はcdecl規約だとちょっと特殊になる。おそらく余分なpushはGCCのこのあたりのやり取りが影響しているのではないかと予想できる。

OP_STRINGはちょっと難しいので、簡単なテストコードを作ってそのアセンブラを見てみることにする。

mrb_value callee(mrb_value t)
{
  return t;
}
void caller(void)
{
  mrb_value v;
  SET_NIL_VALUE(v);
  callee(v);
}

mrb_valueを作って、それを引数で渡してそのまま値として戻ってくる。呼び出し側のアセンブラはこのようになった。

_caller:
	pushl	%ebp
	movl	%esp, %ebp
	pushl	%edi
	pushl	%esi
	pushl	%ebx
	subl	$84, %esp

	movb	$0, -32(%ebp)
	movl	$0, -40(%ebp)
	leal	-56(%ebp), %eax
	movl	%eax, -60(%ebp)

	leal	4(%esp), %edx
	leal	-40(%ebp), %ebx
	movl	$4, %eax

	movl	%edx, %edi
	movl	%ebx, %esi
	movl	%eax, %ecx
	rep movsl

	movl	-60(%ebp), %eax
	movl	%eax, (%esp)
	call	_callee
	subl	$4, %esp

	leal	-12(%ebp), %esp
	addl	$0, %esp
	popl	%ebx
	popl	%esi
	popl	%edi
	leave
	ret

pushではないがcall直後にespを操作するsublがある。同じ意味だ。スタックの状態図を書きながら動きを追ってみることにする。
まず関数が呼ばれるとスタックの先頭に戻りアドレスがセットされる。

                                • +
戻りアドレス esp
                                • +

最初にebpをスタックに保存して、espをebpに入れる。これはCのプログラムの作法のようなもので、これによりebpが突入してきたときのスタックの位置、espは関数内で伸ばしたり縮めたりした結果のスタックの先頭になる。ebpとespではさまれたエリアがスタックフレームである。

                                • +
元のebp ebp(=esp)
                                • +
戻りアドレス ebp + 4
                                • +

pushでediとesi、ebxを保存する。これらはcdecl規約では破壊してはダメなので、使う場合はこのように保存してやる必要がある。

                                • +
ebx esp
                                • +
esi
                                • +
edi
                                • +
元のebp ebp
                                • +
戻りアドレス ebp + 4
                                • +

更にsublでスタックを84byte伸ばす。

                                • +
esp
                                • +
:
                                • +
ebx esp + 84
                                • +
esi
                                • +
edi
                                • +
元のebp ebp
                                • +
戻りアドレス ebp + 4
                                • +

84byteの根拠がよくわからないが、余裕を持って取っているのかもしれない。O0でコンパイルしたし。
次のmovbとmovbはローカル変数vへのnil値のセットだ。mrubyではMRB_TT_FALSEの値を0にすればNILとなる。

                                • +
esp
                                • +
:
                                • +
0(NIL) ebp - 40(ローカル変数v)
                                • +
ゴミ ebp - 36
                                • +
tt=MRB_TT_FALSE ebp - 32
                                • +
ゴミ ebp - 28
                                • +
:
                                • +
ebx esp + 84(ebp - 12)
                                • +
esi
                                • +
edi
                                • +
元のebp ebp
                                • +
戻りアドレス ebp + 4
                                • +

んで、leal -56(%ebp), %eaxでeaxに入れた値を-60(%ebp)に格納している。lealは数字とレジスタの値を足してレジスタに入れるアドレス計算用の3オペランド加算命令だ。ebp-56が何なのかはこの時点ではよくわからない。

                                • +
esp
                                • +
:
                                • +
ebp-56 ebp - 60
                                • +
何かの領域 ebp - 56
                                • +
:
                                • +
0(NIL) ebp - 40(ローカル変数v)
                                • +
ゴミ ebp - 36
                                • +
tt=MRB_TT_FALSE ebp - 32
                                • +
ゴミ ebp - 28
                                • +
:
                                • +
ebx esp + 84(ebp - 12)
                                • +
esi
                                • +
edi
                                • +
元のebp ebp
                                • +
戻りアドレス ebp + 4
                                • +

続いてesp+4のアドレスをedxに、ebp-40のアドレスをebxに、4をeaxに入れる。これらのレジスタはそのままそれぞれedi、esi、ecxに入れられて、movslで使われる。movslはesiのアドレスからediのアドレスにecxの数だけ4byte単位でコピーする命令で、つまり、ローカル変数vに入っている値を、esp+4にコピーするということだ。

                                • +
esp
                                • +
0(NIL) esp + 4
                                • +
ゴミ esp + 8
                                • +
tt=MRB_TT_FALSE esp + 12
                                • +
ゴミ esp + 16
                                • +
:
                                • +
ebp-56 ebp - 60
                                • +
何かの領域 ebp - 56
                                • +
:
                                • +
0(NIL) ebp - 40(ローカル変数v)
                                • +
ゴミ ebp - 36
                                • +
tt=MRB_TT_FALSE ebp - 32
                                • +
ゴミ ebp - 28
                                • +
:
                                • +
ebx esp + 84(ebp - 12)
                                • +
esi
                                • +
edi
                                • +
元のebp ebp
                                • +
戻りアドレス ebp + 4
                                • +

最後にebp-60に入っている値をespにコピーして、calleeを呼び出す。スタックの先頭はcdecl規約に従い、戻り値が格納されるアドレスが入っているので、calleeの中でこの値を参照してcallerのスタックフレーム内に値を書き込むこととなる。はずだ。
戻ってきた後でsublしてて意味がわからないが、そのあとでebp-12をespに突っ込んでいるのでespはどうなっていてもまあ、問題は無い。

                                • +
ebp-56 esp
                                • +
0(NIL) esp + 4
                                • +
ゴミ esp + 8
                                • +
tt=MRB_TT_FALSE esp + 12
                                • +
ゴミ esp + 16
                                • +
:
                                • +
ebp-56 ebp - 60
                                • +
戻り値用領域 ebp - 56
                                • +
戻り値用領域 ebp - 52
                                • +
戻り値用領域 ebp - 48
                                • +
戻り値用領域 ebp - 44
                                • +
0(NIL) ebp - 40(ローカル変数v)
                                • +
ゴミ ebp - 36
                                • +
tt=MRB_TT_FALSE ebp - 32
                                • +
ゴミ ebp - 28
                                • +
:
                                • +
ebx esp + 84(ebp - 12)
                                • +
esi
                                • +
edi
                                • +
元のebp ebp
                                • +
戻りアドレス ebp + 4
                                • +


5.GCCのcdecl実装の詳細(呼び出され側)

んじゃあcallee側を見てみよう。

_callee:
	pushl	%ebp
	movl	%esp, %ebp
	pushl	%edi
	pushl	%esi
	pushl	%ebx
	subl	$20, %esp

	leal	-32(%ebp), %edx
	leal	12(%ebp), %ebx
	movl	$4, %eax

	movl	%edx, %edi
	movl	%ebx, %esi
	movl	%eax, %ecx
	rep movsl

	movl	8(%ebp), %edx
	leal	-32(%ebp), %ebx
	movl	$4, %eax

	movl	%edx, %edi
	movl	%ebx, %esi
	movl	%eax, %ecx
	rep movsl

	movl	8(%ebp), %eax
	addl	$20, %esp
	popl	%ebx
	popl	%esi
	popl	%edi
	leave
	ret	$4

流れとしてはさっきと似たようなもんだ。レジスタを保存してespを進めてスタックフレームを確保する。

                                • +
esp (ebp - 32)
                                • +
ebp - 28
                                • +
ebp - 24
                                • +
ebp - 20
                                • +
ebp - 16
                                • +
ebx ebp - 12
                                • +
esi ebp - 8
                                • +
edi ebp - 4
                                • +
元のebp ebp
                                • +
戻りアドレス ebp + 4
                                • +
戻り値アドレス ebp + 8
                                • +
0(NIL) ebp + 12(引数)
                                • +
ゴミ ebp + 16(引数)
                                • +
tt=MRB_TT_FALSE ebp + 20(引数)
                                • +
ゴミ ebp + 24(引数)
                                • +
:

ebp+12からの4byteをebp-32にコピーする。ebp-32からの領域がcalleeのローカル変数tである。

                                • +
0(NIL) esp (ebp - 32) ローカル変数t
                                • +
ゴミ ebp - 28 ローカル変数t
                                • +
tt=MRB_TT_FALSE ebp - 24 ローカル変数t
                                • +
ゴミ ebp - 20 ローカル変数t
                                • +
ebp - 16
                                • +
ebx ebp - 12
                                • +
esi ebp - 8
                                • +
edi ebp - 4
                                • +
元のebp ebp
                                • +
戻りアドレス ebp + 4
                                • +
戻り値アドレス ebp + 8
                                • +
0(NIL) ebp + 12(引数)
                                • +
ゴミ ebp + 16(引数)
                                • +
tt=MRB_TT_FALSE ebp + 20(引数)
                                • +
ゴミ ebp + 24(引数)
                                • +
:

ebp-32からの4byteをebp+8に入っているアドレスの領域にコピーする。ローカル変数を戻り値として返すための動きだ。
最後に、ebp+8に入っている値をeaxに入れて、スタックを戻す。ebp+8は戻り値のアドレスなので、eaxにそれを入れるて返しているのだが、これがcdecl規約の決まりごとなのかどうかはわからない。leave命令は最初のpush ebpとmovl esp,ebpを巻戻す便利命令だ(遅いけど)。leaveが終わった時点でスタックはこうなる。

                                • +
戻りアドレス esp
                                • +
戻り値アドレス esp + 4
                                • +
0(NIL) esp + 8(引数)
                                • +
ゴミ esp + 12(引数)
                                • +
tt=MRB_TT_FALSE esp + 16(引数)
                                • +
ゴミ esp + 20(引数)
                                • +
:

で、残ったのはret命令だ。ret $4となっているので、戻りアドレスをpopしてから4byteスタックを捨てる。callerに戻ったときのスタックはこのようになる。

                                • +
0(NIL) esp(引数)
                                • +
ゴミ esp + 4(引数)
                                • +
tt=MRB_TT_FALSE esp + 8(引数)
                                • +
ゴミ esp + 12(引数)
                                • +
:

caller側からcalleeを呼び出したときのスタックはこのような感じだった。

                                • +
ebp-56 esp
                                • +
0(NIL) esp + 4
                                • +
ゴミ esp + 8
                                • +
tt=MRB_TT_FALSE esp + 12
                                • +
ゴミ esp + 16
                                • +
:

callerからcalleeを呼び出して戻ったらスタックフレームが4byte縮んでるじゃないですかやだー。
つまり、スタックの整合性が取れなくなるのでcaller側では呼出し後にpushしているのだ。


6.この動きについての考察と問題と対策

通常Cコンパイラの実装では、スタックフレームとして確保するのはローカル変数の部分までで、関数呼び出しの引数は呼び出す直前にpushされ、呼び出し後に捨てられる。GCCが引数の部分までスタックフレームに含めているのが特殊だ。
GCCでは戻り値用のアドレスはcallee側で捨てることになっていて、実際ret 4として捨てているが、caller側は引数部分をスタックフレームとして持っているため、捨てられるとスタックフレームが短くなってしまう。だから戻ってきてから短くなった分を補填して調整しているのだ。

ところでcdecl規約について調べてみても、レジスタに入りきらないサイズの戻り値の場合に、スタックの先頭に入れた戻り値用アドレスをcaller/calleeのどっちが捨てるのかの記述は出てこない。どうやらコンパイラ依存ということらしく、実はVCでこのコードをコンパイルするとcalleeのretは普通にret 0となり、caller側がその領域もまとめて捨てていた。

mrubyのContextThreading化ではmrb_valueを返す関数という定義でct_funcを生成しているので、呼び出し側は戻ってきたときにスタックフレームが短くなっていることを想定している。ct_funcは戻るときにスタックフレームを4byte短くしてやらないと整合性が取れない、というわけだ。
だからスタックがズレたし、ret 4にしてこれが綺麗に流れるようになった。
これがVCだったらret 0できちんと動いていたはずだ。


7.次の展開

ブロックの呼び出しができるようになったのでmrblibをコンパイルしてmrubyに含めることができるようになった。これでpメソッドとかtimesメソッドとかが使える。が、AOベンチを動かしたらコケてしまった。
コケている箇所はArray.new(3)というところ。どうやら省略可能引数の処理が問題になっているようだ。これまたとても厄介なシロモノで、もう一段ややこしいbranch-inliningを作りこむ必要があるかもしれない。
これが解決できたら次のContextThreadingネタということになる。