UP | HOME

可変引数とは - 2023年度 システムプログラミング

可変引数とは

C言語では,可変長の引数を扱うために, … を使った構文が用意されています. 例えば,

int myprintf(char *fmt, ...)

第2以降の引数の個数は不定で,0個でも構いません.代表的な使用例としては, printf があります.

可変引数を宣言した関数の中身は,どのようにして実装されているのでしょうか. 以下の疑問があります.

  1. 呼び出された関数内で,引数をどう参照すればいいのか. 第1引数は,変数名(上記の例では fmt) で参照できるが, 第2以降の引数を名前で参照することができない.
  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 + 15004 です. もっと一般的に書くと, p + 15000 + sizeof(int) となるのです.

一方, p が 5000 で, p の型が char* の場合は p + 15001 です. もっと一般的に書くと, p + 15000 + 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;

p2char* 型であるので,実際に中身を取り出す場合は,第2引数の型の ポインタにキャストしておく必要があります. つまり第2引数が int の場合は, a2int 型として上記のようになるわけです.

同様に,

第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引数の中にある % の数から残りの引数の数が分かりますね. また, % に続くそれぞれの sc といった文字から対応する引数の型も分かります. つまり, 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 などの桁数指定を考慮する場合は,もう少し複雑な制御が必要でしょう.

Author: Yoshinari Nomura

Emacs 27.1 (Org mode 9.3)