ひさしぶりにプログラミングの話題でも。
現在のRubyのtrunkをx86_64上のFedora12でmake test-allすると8個ぐらいテストが失敗するのだが、その中の1つにこういうエラーがある
test/dl/test_dl2.rbというのが、なにをしているテストかというと、ようするに以下のようにdlモジュールを使ってlibmのsin関数を呼んでいるわけだ
dlモジュールというのは(僕は知らなかったのだが)RubyからCのコードを直接呼ぶためのしくみで、引数をpackとunpackを使ってlongの配列にしておくと、なぜか関数の型に関わらず正しい結果がかえって来るという魔法のライブラリである。
まあ sin(π/2)が1.0にならなかったら、バグだよね。
では、その実装をみていこう
まず、RubyのArray(ary)をCのArray(stack[])に変換する。
んで、CFunc.newで渡したtypeに応じてクソ長い switch caseが始まる。ここでさらに引数の数に応じて場合分けしたいが最大引数数がrubyのdlモジュールの仕様上20個もあるため、いちいち書くのがめんどくさいのでCASE, CALLCASEマクロで量産している。CALL_CASEマクロの定義は以下
というわけで、cfunc.call()すると
はCFunc.newでlookupした関数ポインタcfunc->ptr(この場合はsin())をfに代入して、
fをcallして
それをrubyのfloatオブジェクトに変換して返す。ということをしているのだと分かる。実装の詳細を見るために
DLSTACK_PROTO1
DECL_FUNC_CDECL
DLSTACK_ARGS1
のマクロ定義も見ていこう。(あ、今回は引数1つなので##nは1になる。CALL_CASEマクロをもう一度みると納得できると思います。)
まず、DLSTACK_PROTO1とDECL_FUNC_CDECLから見る
ようするに DLSTACK_PROTO1 → "long, ..." で結果として
DECL_FUNC_CDECL(f,double,DLSTACK_PROTO##n) = cfunc->ptr; \
という行は
double (*f)(long, ...) = cfunc->ptr;
となる。
わざわざ可変長引数にしている理由は、すぐそばに以下のコメントががが
つまり、x86_64では...を特別扱いするから、これでうまくいくんだよ。と言いたいようだ。
でもって、DLSTACK_ARGS1は
なので、
f(DLSTACK_ARGS##n(stack)); \
は
f(stack[0]);
という普通の関数呼び出しになる。
あれ?なんかx86_64対策も入ってるのになんでうまくいかんのだ?という話になるので、参照されているx86_64 ABI
の原典を読む
超訳。ABI的にはINTEGER, SSE, SSEUP, X87, X87UP, COMPLEX_X87, NO_CLASSという
引数のタイプがあるよ。
超訳
(signed and unsigned) _Bool, char, short, int, long, long long, およびポインタは INTEGERだよ
float, double and __m64 はSSEだよ
(この後、__float128とか構造体とかの説明がつづくが割愛)
超訳。
MEMORYならスタック渡し確定
INTEGERならrdi, rsi, rdx, rcx, r8, r9の順に使うよ
SSEならSSEレジスタをxmm0からxmm7の順につかうよ
超訳
レジスタが足りなくなったらスタックを使って引数を渡すよ。
スタックには逆順(右から左)でpushするよ
で、ここで問題のセンテンスが現れる
超訳
varargsまたはstdargsな関数(プロトタイプがなかったり、宣言で...を使っていたり)を呼ぶときは
%al に何個SSEレジスタを使ったか隠し引数で入れるよ。
つまり、ここではprintf()的なまっとうな可変長引数の事を語っているだけであって、呼び出し元と呼び出し先で
関数の型が適合しない場合については何も語っていない。
今回の場合で言うとsin関数はlibmの中では
double sin(double)
という型でコンパイルされていて、呼び出し側では
double f(long, ...)
という型だと思ってcallしている。
じゃあ、実際に何が起こるかを示すためにテストプログラムを書いてみた。
main.c
f.c
実行結果
はい、57*2で114 が返ってくるはずが0が返ってきてしまいました。
んではdisassemble,
funcでは素直にxmm0を引数だと思って二倍しています。
でもf1ではrdi(INTEGERの第一引数)に l を、eaxに0を入れて関数コールしています。
つまり、これが起きていることのすべてです。
呼び出し側がrdiに引数を詰んでいるのに、呼び出され側はxmm0を呼んでいるのですから
正しい答えが返るはずがありません。
というわけで、これはdlモジュール制作者のABIの誤読ですね。
なお、Cの規格的には呼び出し元と呼び出し先で関数型が適合しない場合は未定義。たぶん、dlモジュール作者は規格を元に考えたのではなく
1)全CPUアーキテクチャでlongで無理矢理callしても安心と考えた。または
2)x86_32 だけで動けばいいや。と割り切った
のどちらかだと思いますが、イマドキx86_64は普通ですからねぇ。。。
現在のインターフェースだと直しようがないので、RubyのCFuncの仕様を大きく変えないと x86_64対応は出来ないでしょう。
というわけで、RubyユーザのみなさんはCFuncでfloat, doubleを使わないようにしておくと将来幸せになれるかもしれないというお話でした。
ではでは
追記: なぜこの問題がx86_64だけで発生するのかを、つらつらと考えてみたのだが、やはり歴史的な理由だろう。昔は関数宣言を忘れるとか当たり前だったので、昔からあるアーキだと、こういう関数型が不適合な呼び出しに対してABIを寛容に作ることが多かったのではないか?
追記2: 関数の型の適合性とか安易に書いた気がする。でもCの規格を読み直す気がおきない。めどい
簡単にいうと
1)呼び出し元と呼び出し先が両方とも関数宣言有り: 引数、戻り値の型が全部一致していた場合のみOK
2)どちらかが宣言なし: 戻り値の型は厳密一致が必要だけど、引数は型がshortとintみたいに十分近ければOK
みたいなノリだったと思う。今回はケース1だけど、2の基準で反転してもアウトという論外さ
現在のRubyのtrunkをx86_64上のFedora12でmake test-allすると8個ぐらいテストが失敗するのだが、その中の1つにこういうエラーがある
1) Failure:
test_sin(DL::TestDL) [/home/kosaki/linux/ruby/test/dl/test_dl2.rb:95]:
<1.0> expected but was
<1.38523885234213e-309>.
test/dl/test_dl2.rbというのが、なにをしているテストかというと、ようするに以下のようにdlモジュールを使ってlibmのsin関数を呼んでいるわけだ
module DL
class TestDL < TestBase
# TODO: refactor test repetition
def test_sin()
pi_2 = Math::PI/2
cfunc = CFunc.new(@libm['sin'], TYPE_DOUBLE, 'sin')
x = cfunc.call([pi_2].pack("d").unpack("l!*"))
assert_equal(Math.sin(pi_2), x)
end
end
end # module DL
dlモジュールというのは(僕は知らなかったのだが)RubyからCのコードを直接呼ぶためのしくみで、引数をpackとunpackを使ってlongの配列にしておくと、なぜか関数の型に関わらず正しい結果がかえって来るという魔法のライブラリである。
まあ sin(π/2)が1.0にならなかったら、バグだよね。
では、その実装をみていこう
static VALUE
rb_dlcfunc_call(VALUE self, VALUE ary)
{
struct cfunc_data *cfunc;
int i;
DLSTACK_TYPE stack[DLSTACK_SIZE];
VALUE result = Qnil;
rb_secure_update(self);
memset(stack, 0, sizeof(DLSTACK_TYPE) * DLSTACK_SIZE);
Check_Type(ary, T_ARRAY);
TypedData_Get_Struct(self, struct cfunc_data, &dlcfunc_data_type, cfunc);
if( cfunc->ptr == 0 ){
rb_raise(rb_eDLError, "can't call null-function");
return Qnil;
}
for( i = 0; i < RARRAY_LEN(ary); i++ ){
if( i >= DLSTACK_SIZE ){
rb_raise(rb_eDLError, "too many arguments (stack overflow)");
}
rb_check_safe_obj(RARRAY_PTR(ary)[i]);
stack[i] = NUM2LONG(RARRAY_PTR(ary)[i]);
}
まず、RubyのArray(ary)をCのArray(stack[])に変換する。
switch( cfunc->type ){
case DLTYPE_VOID:
#define CASE(n) case n: { \
DECL_FUNC_CDECL(f,void,DLSTACK_PROTO##n) = cfunc->ptr; \
f(DLSTACK_ARGS##n(stack)); \
result = Qnil; \
}
CALL_CASE;
#undef CASE
break;
・・・
case DLTYPE_DOUBLE:
#define CASE(n) case n: { \
DECL_FUNC_CDECL(f,double,DLSTACK_PROTO##n) = cfunc->ptr; \
double ret; \
ret = f(DLSTACK_ARGS##n(stack)); \
result = rb_float_new(ret); \
}
CALL_CASE;
#undef CASE
んで、CFunc.newで渡したtypeに応じてクソ長い switch caseが始まる。ここでさらに引数の数に応じて場合分けしたいが最大引数数がrubyのdlモジュールの仕様上20個もあるため、いちいち書くのがめんどくさいのでCASE, CALLCASEマクロで量産している。CALL_CASEマクロの定義は以下
#define CALL_CASE switch( RARRAY_LEN(ary) ){ \
CASE(0); break; \
CASE(1); break; CASE(2); break; CASE(3); break; CASE(4); break; CASE(5); break; \
CASE(6); break; CASE(7); break; CASE(8); break; CASE(9); break; CASE(10);break; \
CASE(11);break; CASE(12);break; CASE(13);break; CASE(14);break; CASE(15);break; \
CASE(16);break; CASE(17);break; CASE(18);break; CASE(19);break; CASE(20);break; \
default: rb_raise(rb_eArgError, "too many arguments"); \
}
というわけで、cfunc.call()すると
DECL_FUNC_CDECL(f,double,DLSTACK_PROTO##n) = cfunc->ptr; \
はCFunc.newでlookupした関数ポインタcfunc->ptr(この場合はsin())をfに代入して、
double ret; \
ret = f(DLSTACK_ARGS##n(stack)); \
fをcallして
result = rb_float_new(ret); \
それをrubyのfloatオブジェクトに変換して返す。ということをしているのだと分かる。実装の詳細を見るために
DLSTACK_PROTO1
DECL_FUNC_CDECL
DLSTACK_ARGS1
のマクロ定義も見ていこう。(あ、今回は引数1つなので##nは1になる。CALL_CASEマクロをもう一度みると納得できると思います。)
まず、DLSTACK_PROTO1とDECL_FUNC_CDECLから見る
#define DLSTACK_TYPE long
#define DLSTACK_PROTO1_ DLSTACK_TYPE
#define DLSTACK_PROTO1 DLSTACK_PROTO1_, ...
#if !defined(FUNC_CDECL)
# define FUNC_CDECL(x) x
#endif
# define DECL_FUNC_CDECL(f,ret,args) ret (FUNC_CDECL(*f))(args)
ようするに DLSTACK_PROTO1 → "long, ..." で結果として
DECL_FUNC_CDECL(f,double,DLSTACK_PROTO##n) = cfunc->ptr; \
という行は
double (*f)(long, ...) = cfunc->ptr;
となる。
わざわざ可変長引数にしている理由は、すぐそばに以下のコメントががが
/*
* Add ",..." as the last argument.
* This is required for variable argument functions such
* as fprintf() on x86_64-linux.
*
* http://refspecs.linuxfoundation.org/elf/x86_64-abi-0.95.pdf
* page 19:
*
* For calls that may call functions that use varargs or stdargs
* (prototype-less calls or calls to functions containing ellipsis
* (...) in the declaration) %al is used as hidden argument to
* specify the number of SSE registers used.
*/
つまり、x86_64では...を特別扱いするから、これでうまくいくんだよ。と言いたいようだ。
でもって、DLSTACK_ARGS1は
#define DLSTACK_ARGS1(stack) stack[0]
なので、
f(DLSTACK_ARGS##n(stack)); \
は
f(stack[0]);
という普通の関数呼び出しになる。
あれ?なんかx86_64対策も入ってるのになんでうまくいかんのだ?という話になるので、参照されているx86_64 ABI
の原典を読む
3.2.3 Parameter Passing
After the argument values have been computed, they are placed in registers, or
pushed on the stack. The way how values are passed is described in the following
sections.
Definitions We first define a number of classes to classify arguments. The
classes are corresponding to AMD64 register classes and defined as:
INTEGER This class consists of integral types that fit into one of the general
purpose registers.
SSE The class consists of types that fits into a SSE register.
SSEUP The class consists of types that fit into a SSE register and can be passed
and returned in the most significant half of it.
X87, X87UP These classes consists of types that will be returned via the x87 FPU.
COMPLEX_X87 This class consists of types that will be returned via the x87 FPU.
NO_CLASS This class is used as initializer in the algorithms. It will be used for
padding and empty structures and unions.
MEMORY This class consists of types that will be passed and returned in memory
via the stack.
超訳。ABI的にはINTEGER, SSE, SSEUP, X87, X87UP, COMPLEX_X87, NO_CLASSという
引数のタイプがあるよ。
Classification The size of each argument gets rounded up to eightbytes.
The basic types are assigned their natural classes:
• Arguments of types (signed and unsigned) _Bool, char, short, int,
long, long long, and pointers are in the INTEGER class.
• Arguments of types float, double and __m64 are in class SSE.
(しばらく略)
超訳
(signed and unsigned) _Bool, char, short, int, long, long long, およびポインタは INTEGERだよ
float, double and __m64 はSSEだよ
(この後、__float128とか構造体とかの説明がつづくが割愛)
Passing Once arguments are classified, the registers get assigned (in left-to-right
order) for passing as follows:
1. If the class is MEMORY, pass the argument on the stack.
2. If the class is INTEGER, the next available register of the sequence %rdi,
%rsi, %rdx, %rcx, %r8 and %r9 is used.
3. If the class is SSE, the next available SSE register is used, the registers are
taken in the order from %xmm0 to %xmm7.
4. (略)
5. (略)
超訳。
MEMORYならスタック渡し確定
INTEGERならrdi, rsi, rdx, rcx, r8, r9の順に使うよ
SSEならSSEレジスタをxmm0からxmm7の順につかうよ
If there is no register available anymore for any eightbyte of an argument, the
whole argument is passed on the stack. If registers have already been assigned for
some eightbytes of this argument, those assignments get reverted.
Once registers are assigned, the arguments passed in memory are pushed on
the stack in reversed (right-to-left) order.
超訳
レジスタが足りなくなったらスタックを使って引数を渡すよ。
スタックには逆順(右から左)でpushするよ
で、ここで問題のセンテンスが現れる
For calls that may call functions that use varargs or stdargs (prototype-less
calls or calls to functions containing ellipsis (. . . ) in the declaration) %al is used
as hidden argument to specify the number of SSE registers used. The contents of
%al do not need to match exactly the number of registers, but must be an upper
超訳
varargsまたはstdargsな関数(プロトタイプがなかったり、宣言で...を使っていたり)を呼ぶときは
%al に何個SSEレジスタを使ったか隠し引数で入れるよ。
つまり、ここではprintf()的なまっとうな可変長引数の事を語っているだけであって、呼び出し元と呼び出し先で
関数の型が適合しない場合については何も語っていない。
今回の場合で言うとsin関数はlibmの中では
double sin(double)
という型でコンパイルされていて、呼び出し側では
double f(long, ...)
という型だと思ってcallしている。
じゃあ、実際に何が起こるかを示すためにテストプログラムを書いてみた。
main.c
#define _GNU_SOURCE
#include
#include
void f1(void) {
double (*func)(long, ...);
double d = 57.0;
long l = *(long*)&d;
double ret;
func = dlsym(RTLD_DEFAULT, "func");
ret = func(l);
printf("%f\n",ret);
}
int main(void)
{
f1();
return 0;
}
f.c
double func(double d)
{
return d*2;
}
実行結果
% gcc *.c -ldl -Wall -g -rdynamic; ./a.out
0.000000
はい、57*2で114 が返ってくるはずが0が返ってきてしまいました。
んではdisassemble,
0000000000400834:
400834: 55 push %rbp
400835: 48 89 e5 mov %rsp,%rbp
400838: f2 0f 11 45 f8 movsd %xmm0,-0x8(%rbp)
40083d: f2 0f 10 45 f8 movsd -0x8(%rbp),%xmm0
400842: f2 0f 58 c0 addsd %xmm0,%xmm0
400846: c9 leaveq
400847: c3 retq
funcでは素直にxmm0を引数だと思って二倍しています。
00000000004008e0:
4008e0: 55 push %rbp
4008e1: 48 89 e5 mov %rsp,%rbp
4008e4: 48 83 ec 20 sub $0x20,%rsp
4008e8: 48 b8 00 00 00 00 00 mov $0x404c800000000000,%rax
4008ef: 80 4c 40
4008f2: 48 89 45 e0 mov %rax,-0x20(%rbp)
4008f6: 48 8d 45 e0 lea -0x20(%rbp),%rax
4008fa: 48 8b 00 mov (%rax),%rax
4008fd: 48 89 45 f0 mov %rax,-0x10(%rbp)
400901: b8 48 0b 40 00 mov $0x400b48,%eax
400906: 48 89 c6 mov %rax,%rsi
400909: bf 00 00 00 00 mov $0x0,%edi
40090e: e8 2d fe ff ff callq 400740
400913: 48 89 45 e8 mov %rax,-0x18(%rbp)
400917: 48 8b 45 f0 mov -0x10(%rbp),%rax
40091b: 48 8b 55 e8 mov -0x18(%rbp),%rdx
40091f: 48 89 c7 mov %rax,%rdi
400922: b8 00 00 00 00 mov $0x0,%eax
400927: ff d2 callq *%rdx
400929: f2 0f 11 45 f8 movsd %xmm0,-0x8(%rbp)
40092e: b8 4d 0b 40 00 mov $0x400b4d,%eax
400933: f2 0f 10 45 f8 movsd -0x8(%rbp),%xmm0
400938: 48 89 c7 mov %rax,%rdi
40093b: b8 01 00 00 00 mov $0x1,%eax
400940: e8 db fd ff ff callq 400720
400945: c9 leaveq
400946: c3
でもf1ではrdi(INTEGERの第一引数)に l を、eaxに0を入れて関数コールしています。
つまり、これが起きていることのすべてです。
呼び出し側がrdiに引数を詰んでいるのに、呼び出され側はxmm0を呼んでいるのですから
正しい答えが返るはずがありません。
というわけで、これはdlモジュール制作者のABIの誤読ですね。
なお、Cの規格的には呼び出し元と呼び出し先で関数型が適合しない場合は未定義。たぶん、dlモジュール作者は規格を元に考えたのではなく
1)全CPUアーキテクチャでlongで無理矢理callしても安心と考えた。または
2)x86_32 だけで動けばいいや。と割り切った
のどちらかだと思いますが、イマドキx86_64は普通ですからねぇ。。。
現在のインターフェースだと直しようがないので、RubyのCFuncの仕様を大きく変えないと x86_64対応は出来ないでしょう。
というわけで、RubyユーザのみなさんはCFuncでfloat, doubleを使わないようにしておくと将来幸せになれるかもしれないというお話でした。
ではでは
追記: なぜこの問題がx86_64だけで発生するのかを、つらつらと考えてみたのだが、やはり歴史的な理由だろう。昔は関数宣言を忘れるとか当たり前だったので、昔からあるアーキだと、こういう関数型が不適合な呼び出しに対してABIを寛容に作ることが多かったのではないか?
追記2: 関数の型の適合性とか安易に書いた気がする。でもCの規格を読み直す気がおきない。めどい
簡単にいうと
1)呼び出し元と呼び出し先が両方とも関数宣言有り: 引数、戻り値の型が全部一致していた場合のみOK
2)どちらかが宣言なし: 戻り値の型は厳密一致が必要だけど、引数は型がshortとintみたいに十分近ければOK
みたいなノリだったと思う。今回はケース1だけど、2の基準で反転してもアウトという論外さ
コメント
コメント一覧 (5)
ということで、何も考えず gem install ffi しましょう。
なので、代替案を提示されても、あんまり御利益がなかったり。すまぬ
あれ、そうすると、x86-32でも-mregparm=3でコンパイルしたライブラリはどうなんだろうか?exportされるときにスタック渡しに戻されるのかな?
らしいので、再度test-allしてみると、x86_64でなにか進展があるかもしれません。
# ついでにIA-64のレポートとか…(ボソッ