可変引数とx86とx64

というわけで、x86でエラーとならなかったのにx64でエラーとなってしまったコードを調べていったら・・・という話です。

この「可変引数」という使用はおもしろいですよね~。この「可変引数」をどうやって実現しているか?とそのときに起こる現象についてです。

ほかのページにも似たような内容はありますが、今回x64系に踏み込んでこれを見ていきたいと思います。

そもそも可変引数とは?

可変引数とは読んで字のごとく

引数が可変個である(いくつの引数を渡すかを呼び出し元が決めることができる)というもの

です。特に文字列整形のルーチンではよく出てくる物です。

たとえば、C言語のprintf関数なんかがそれで

printf("%02d:%d",n,x);

のように、中の書式文字列の分だけ後ろにその場所に当てはまる値を引数として渡す、という構造をします。

呼び出す側(使う側)にとっては考えやすいルーチンだと思います。

で、これを呼び出された側では?

通常はこんな処理をします。第一引数に渡されれた文字配列(ポインタ)に後ろの可変文字列を結合するルーチンです。

サンプルなので実使用を考えているわけではありません。使うときには第一引数に十分なメモリが必要で、ないとバッファオーバーランが起こりますので注意してください。

#include <stdarg.h>
void vstrcat(char *str,...)
{
	va_list arg_ptr; va_start(arg_ptr,str); char *next;
	while((next = va_arg(arg_ptr,char *)) != NULL){ strcat(str,next); }
}

ヘッダーファイルとしてstdarg.hが必要になります。そしてva_list型変数va_start,va_arg(,va_end)関数(もしくはマクロ)を使って引数を解析していく、というやり方をとります。

使う側は

char buf[256]; strcpy(buf,"test");
vstrcat(buf," is"," big"," sum.",NULL);

のように使えるわけですね。

可変引数では「省略時の引数の格上げ(default argument promotion)」が発生する

可変でなくても発生することはありますが・・・。たとえば、こんな呼び出しをします。

void func(char c,...);
func('a',(short)2,(int)3,(long long)4ll,(float)5.0f,(double)6.0);

なお、キャストを書いているのは明示的に変数の方を指定するためなのであまり気にしないでください。

このとき、引数の状態はどうなるでしょうか?

このときに起こるのが「省略時の引数の格上げ」です。これにx86とx64での処理の差が一つ絡んできます。

これについてはVisualStudio 2005での検証なのでそれ以外では同じ結果が出ないこともあり得ます。(VisualStudio系ではたぶん同じ結果となると思いますが)

わかりやすく言うなら「可変引数だと変数幅がわからなくなるのでわかりやすい変数幅まで拡張しちゃえ」というやつです。

一般的な解説ページだとこのように書いてあるようです。

int型より小さい整数型はint型、doubleより小さい浮動小数型はdouble型になるように変換される

このように変換されるシステムもあるようですが、どうやらVisualStudioだとこのように適応されるようです。

ビット長がsizeof(void *)よりも小さい整数型はsizeof(void *)と同じビット長となる整数型、doubleより小さい浮動小数型はdouble型になるように変換される

(つまり、整数型においてはintptr_tより小さい整数型はintptr_t型になるということ。intptr_tは予約語ではないため予約語のみを使うと上のような記述になる)

と、整数型においてのみポインタ幅による指定が行われます。これはちょっとした注意点です。

なので、先ほどのfuncの呼び出しですが、コンパイルにより以下のように変換されます。

コンパイル前:func('a',(short)2,(int)3,(long long)4ll,(float)5.0f,(double)6.0);
コンパイル後(x86):func('a',(__int32)2,(__int32)3,(__int64)4,(double)5.0,(double)6.0);
コンパイル後(x64):func('a',(__int64)2,(__int64)3,(__int64)4,(double)5.0,(double)6.0);

呼び出される側が取り出すときの注意点は浮動小数型に関する昇格のみで、

void func(char c,...)
{
	va_list arg_ptr; short n1; int n2; long long n3; double f1,f2;
	va_start(arg_ptr,c);
	n1 = va_arg(arg_ptr,short);
	n2 = va_arg(arg_ptr,int);
	n3 = va_arg(arg_ptr,long long);
	f1 = va_arg(arg_ptr,double);
	f2 = va_arg(arg_ptr,double);
}

と、floatを渡しているつもりの場所であってもdoubleで受け取る必要があります。

整数型であればva_arg関数(マクロ)がその部分の処理をしますので大丈夫だったりします。

(VisualStudio+Windowsの場合。整数型の取得に成功し、浮動小数型の取得に失敗するのはva_argがマクロ処理であり、かつ整数型を基準に記述されているためだったりする。基本的には昇格を意識した方がよい)

可変引数は「基本的には引数の部分をスタックに格納することで可変個を処理する」

以前に書いた「関数の呼び出し規約」を見てもらえばわかると思いますが、基本的に引数はスタックで渡されます。

これにより、スタックに逆順に押し込められた引数群は呼び出された側ではある程度簡単に処理できます。(1番目の引数はスタックの先頭、2番目の引数はスタックの2番目、のような感じ)

が、書いたとおり「基本的に」という注意書きがあります。x64の呼び出し規約を読んでみるとこの意味が何となくわかると思います。

可変引数を使う側では必ずva_list型などの可変実引数(variable argument)に関する処理を使おう

これが今回x86でエラーとならずにx64でエラーとなっていた原因だったりします。

古いライブラリなんかで可変引数をもらった後でそれを可変引数対応の書式文字列処理を行おうとしたときにこう書いてある場合があります。

int errorout(const char *fmt,...)
{
	return vprintf(fmt,(va_list)(&fmt + 1));
}

この処理、x86では正常に動きます。が、x64では時と場合によっては似たような記述の場合エラーとなる場合があります。

意図するところは&fmt(変数fmtのスタック上の位置)+1(の次)で以降の可変引数を指そうとしているのですが・・・。たとえばこんなコードを考えればこれが問題だとわかります。

int errorout(short errcode,...)
{
	return vprintf(GetErrorString(errcode),(va_list)(&errcode + 1));
}

わかるとおり、(&errcode + 1)はスタック上での次の引数を指しません。

説明するなら、&errcodeはshort *になるので、+1してもアドレスはsizeof(short)しか加算されません。

このとき、省略時の引数の格上げによってshortはスタック上ではintptr_tへと昇格されているのでアドレスの加算分はsizeof(intptr_t)でなければなりません。

これによってvprintfの処理は正しい結果を返せなくなるわけです。

私の例ではここがshortではなくHRESULT(long)だったためにx86ではsizeof(long) == sizeof(void *)となりエラーとならず、x64ではsizeof(long) != sizeof(void *)となりエラーとなった、というわけでした。

そしてもう一つが、「x64の呼び出しは先頭から4つの整数引数をレジスタに格納して呼び出す」ということです。

つまり、上記の例(fmt)で言うなら、fmtはレジスタに割り振られるので、&fmtがどこのポインタを指すのか実はコンパイラにしかわからない、ということです。

(コンパイルエラーにはなりません。この場合、スタックからテンポラリをとってその部分を指すようにするはずです)

そうなると、&fmt + 1が何を指すのか結局コンパイラが自由に決めてしまうので、結局「次の引数」を指す物にはならなくなる、というわけです。

(なお、私がコンパイルしてテストしたところ、可変引数であるときは呼び出し先でその部分だけバックアップとして一度スタックに格納されるようです)

これを避けるためにも可変引数処理を使うときはstdarg.hを使った可変実引数処理を行わないと正しく動作しない、ということになります。

上記の例では正しくは

int errorout(const char *fmt,...)
{
	va_list argptr; va_start(argptr,fmt);
	return vprintf(fmt,argptr);
}
int errorout(short errcode,...)
{
	va_list argptr; va_start(argptr,errcode);
	return vprintf(GetErrorString(errcode),argptr);
}

とする必要があります。

可変引数も使いやすい代わりに面倒な使用だな~と

特にx86とx64というCPU処理の差がこんなところにも出るというのがびっくりです。

いろいろとコンパイルして調べましたが、知らなかったのはfloatが昇格してdoubleになる、というところです。

今までこの事実を一つも使っていなかったので知らなくても無理はない現象だったのですが・・・。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

この記事のトラックバック用URL