可変引数とは - 2024年度 システムプログラミング
可変引数とは
C言語では,可変長の引数を扱うために, … を使った構文が用意されています. 例えば,
int myprintf(char *fmt, ...)
第2以降の引数の個数は不定で,0個でも構いません.代表的な使用例としては,
printf
があります.
可変引数を宣言した関数の中身は,どのようにして実装されているのでしょうか. 以下の疑問があります.
- 呼び出された関数内で,引数をどう参照すればいいのか. 第1引数は,変数名(上記の例では fmt) で参照できるが, 第2以降の引数を名前で参照することができない.
- そもそも,何個の引数を伴って呼ばれたかをどう判断するのか. また,それぞれの引数の型をどうやって知ればよいのか.
第2以降の引数のアクセス
C言語で記述された, hanoi()
を gcc
でアセンブラに変換した物の冒頭部分を思い出してください.
1: _hanoi: 2: subu $sp,$sp,24 3: sw $ra,20($sp) 4: sw $fp,16($sp) 5: move $fp,$sp 6: sw $a0,24($fp) 7: sw $a1,28($fp) 8: sw $a2,32($fp) 9: sw $a3,36($fp)
関数冒頭の 6〜9行目のコードによって, 引数はスタックに順序正しく整列されていることは,以前述べました. つまり,関数が呼出された時点のスタックは下記の通りです.
$sp | offset | 内容 | 備考 |
---|---|---|---|
新$sp→ | -24 | - | 未使用 |
-20 | - | 未使用 | |
-16 | - | 未使用 | |
-12 | - | 未使用 | |
-08 | $fp | フレームポインタ | |
-04 | $ra | 戻りアドレス | |
旧$sp→ | +00 | $a0 | 第1引数 |
+04 | $a1 | 第2引数 | |
+08 | $a2 | 第3引数 | |
+12 | $a3 | 第4引数 | |
+16 | ?? | 呼出側で使用(あれば第5引数) | |
+20 | ?? | 呼出側で使用(あれば第6引数) | |
+24 | ?? | 呼出側で使用(あれば第7引数) | |
: | ?? | 呼出側で使用(あれば第8引数) |
この図から,第2引数は,アドレス = (旧$sp +04) からの 4バイトに格納されている事が分かります.
つまり,C言語で myprintf(char *fmt, …) を記述して,
第2引数以降の値を得ようとすると,
(旧$sp + 04) の値を C言語から取得できなければなりません.
しかし,C言語から直接 $sp
の値を得ることはできません.
そこで,旧$sp には第1引数(上の例では fmt) が格納されていることを利用します.
旧$sp -> 第1の引数のアドレス -> &fmt
となることから,
第2引数のアドレス -> &fmt から 4バイト先
として求めることができます.
ポインタの型とサイズ
ここまでで,
第2引数のアドレス -> &fmt から 4バイト先
となることは分かりました.話をもう少し一般化しましょう.
ここから先の議論で気をつけて欲しいのは,上の記述は,
あくまでも「アドレス」の話だということです.C言語では,
&fmt
は「ポインタ」として扱われるので,
正確には「アドレス」とは違います.
この違いを考慮して,C言語で記述するなら,
第2引数のアドレス = ((char*)&fmt) + ((sizeof(fmt) + 3) / 4) * 4
となります.
C言語で (あるポインタ) + 1 が実際の「アドレス」としていくつ増えるかは,
ポインタが指す型によって違いましたね.
つまり, p が 5000
のとき, p
の型が int*
の場合は, p + 1
は 5004
です.
もっと一般的に書くと, p + 1
は 5000 + sizeof(int)
となるのです.
一方, p が 5000
で, p
の型が char*
の場合は p + 1
は 5001
です.
もっと一般的に書くと, p + 1
は 5000 + sizeof(char)
です.
この仕組みのおかげで, *(p+1)
とした場合に
p
の型に基づいて,適切なアドレスから正しい値を取り出すことができるのです.
(前期のプログラミング演習の内容です)
char*
という型は, p + 1
がそのままアドレス上で1増えます.
そのため, (char*)&fmt
とキャストすることで,
値をアドレスと同じように( +1 がそのままアドレスの +1 に相当する)
操作できます.
そうしておいて,第2引数は,
((sizeof(fmt) + 3) / 4) * 4
バイト分先にあるので,
上記の式になるのです.
単なる sizeof(fmt)
ではないのは,
MIPS の gcc では引数の sizeof が 3 以下の場合は,4の倍数に切り上げる
(プロモートする)ようにメモリを使って引数を配置するので,
それを考慮して,((x + 3) / 4 * 4) という操作をしているからです.
このようにして得た
第2引数のアドレス = ((char*)&fmt) + ((sizeof(fmt) + 3) / 4) * 4
を利用することで,第2引数の値を a2
に得ることができます:
p2 = ((char*)&fmt) + ((sizeof(fmt) + 3) / 4) * 4; a2 = *(int*)p2;
p2
は char*
型であるので,実際に中身を取り出す場合は,第2引数の型の
ポインタにキャストしておく必要があります.
つまり第2引数が int の場合は, a2
を int
型として上記のようになるわけです.
同様に,
第3引数は p3 = p2 + (sizeof(第2引数の型) + 3) / 4) * 4 a3 = *(第3引数のポインタ型)p3;
となるでしょう.
ちなみに,上記を簡単に記述するためのマクロが va_list, va_arg
です.
(man va_arg 参照).今回は, va_list, va_arg
の動きを知るために,
これらのマクロは使用しないで実装してみてください.
引数の数と型
printf
の場合,引数の数とそれぞれの型は,第1引数の中にヒントがあります.
printf("%s is %c", name, v)
第1引数の中にある %
の数から残りの引数の数が分かりますね.
また, %
に続くそれぞれの s
や c
といった文字から対応する引数の型も分かります.
つまり, fmt
中の文字列を走査することで分かります.
それは,以下のようなプログラムになるでしょう.
1: while (*fmt){ 2: if (*fmt == '%'){ 3: fmt++; 4: switch (*fmt){ 5: case 'd': 6: %d の処理; 7: break; 8: case 's': 9: %s の処理; 10: break; 11: } 12: } else { 13: そのまま *fmt の1文字を表示; 14: } 15: fmt++; 16: }
%2d
などの桁数指定を考慮する場合は,もう少し複雑な制御が必要でしょう.