feature image

2024年8月31日 | ブログ記事

C言語の可変長引数についてアセンブリを見て考える

この記事はtraP夏のブログリレー2024の12日目の記事です。日付越えてますが広義12日目です。

23MのNapoliNです。学生最後の夏も後半戦に突入しました。少しでも爪痕を残していくために久々にブログを綴ります。お題は「C言語の可変長引数について」です。

可変長引数を扱う関数、自分で実装する機会はあまりないので馴染みは薄い気がします。しかし、可変長引数はC言語学習者の100%が使ったことがあると言えるでしょう。そうです printf です。 printf は第1引数にフォーマットを指定して、第2引数以降でフォーマット指定子に埋め込む値を指定します。

今回は、可変長引数はアセンブリレベルでどのように実現されているのか?について説明したいと思います。とりあえず、以下のコードをご覧ください。

#include <stdio.h>

int main(){
    printf("%ld %ld %ld %ld %ld %ld %ld %lf %lf %lf %lf %lf %lf %lf %lf %lf\n",1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,1L,2L,3L,4L,5L,6L,7L);
}
ヤバそうなprintf

多倍長整数7個、倍精度浮動小数点数9個をスペース区切りで出力するだけの簡単なコードです。ただし、書式指定子と引数の順番が逆で、書式指定子では整数→浮動小数点数の順番で並べているのに、引数は浮動小数点数→整数の順番で並んでいます(当然コンパイル時にWarningで怒られる)。

さて、このコードを実行したときの出力はどうなるのでしょうか?

> 1 2 3 4 5 4621256167635550208 6 1.000000 2.000000 3.000000 4.000000 5.000000 6.000000 7.000000 8.000000 0.000000

かなり意味不明な出力になりました。察しの良い人はお気づきの通り、「4621256167635550208」は浮動小数点数9.0をバイナリ表記した0x4022000000000000を64bit符号あり整数として解釈したときの値です。また、0.000000は7Lをバイナリ表記した0x0000000000000007を倍制度浮動小数点数として解釈したときの値です(小さすぎて0に見えますが)。

フォーマット指定子と出力されているであろう値の対応を取ると、以下のようになります。

フォーマット指定子 %ld %ld %ld %ld %ld %ld %ld %lf %lf %lf %lf %lf %lf %lf %lf %lf
対応する値 1L 2L 3L 4L 5L 9.0f 6L 1.0f 2.0f 3.0f 4.0f 5.0f 6.0f 7.0f 8.0f 7L
出力 1 2 3 4 5 4621256167635550208 6 1.00 2.00 3.00 4.00 5.00 6.00 7.00 8.00 0.00

なぜこのような奇々怪々な出力の順番になるのか?答えはアセンブリに隠されています。

上のコードのアセンブリを見ても、 printf がcallされるだけなので、可変長引数用のマクロを使った次の関数 func をコンパイルしてアセンブリを表示してみます。関数について軽く説明しておくと、 va_list は可変長引数を管理する構造体で、 va_start マクロを呼び出すと初期化してくれます。また、 va_arg マクロを呼び出すことで可変長引数の値を順番に取り出すことができます。

void func(char *format, ...)
{
    va_list args;
    va_start(args, format);
    long integer_in = va_arg(args, long);
}
可変長引数を用いた関数
func(char*, ...):
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $104, %rsp
        movq    %rdi, -216(%rbp)
        movq    %rsi, -168(%rbp)
        movq    %rdx, -160(%rbp)
        movq    %rcx, -152(%rbp)
        movq    %r8, -144(%rbp)
        movq    %r9, -136(%rbp)
        testb   %al, %al
        je      .L5
        movaps  %xmm0, -128(%rbp)
        movaps  %xmm1, -112(%rbp)
        movaps  %xmm2, -96(%rbp)
        movaps  %xmm3, -80(%rbp)
        movaps  %xmm4, -64(%rbp)
        movaps  %xmm5, -48(%rbp)
        movaps  %xmm6, -32(%rbp)
        movaps  %xmm7, -16(%rbp)
.L5:
        movl    $8, -208(%rbp)		#va_startの処理ここから
        movl    $48, -204(%rbp)
        leaq    16(%rbp), %rax
        movq    %rax, -200(%rbp)
        leaq    -176(%rbp), %rax
        movq    %rax, -192(%rbp)	#va_startの処理ここまで
        movl    -208(%rbp), %eax	# va_argの処理ここから
        cmpl    $47, %eax
        ja      .L3
        movq    -192(%rbp), %rax
        movl    -208(%rbp), %edx
        movl    %edx, %edx
        addq    %rdx, %rax
        movl    -208(%rbp), %edx
        addl    $8, %edx
        movl    %edx, -208(%rbp)
        jmp     .L4
.L3:
        movq    -200(%rbp), %rax
        leaq    8(%rax), %rdx
        movq    %rdx, -200(%rbp)	# va_argの処理ここまで
.L4:
        movq    (%rax), %rax
        movq    %rax, -184(%rbp)	# integer_inへの代入処理
        nop
        leave
        ret
コンパイル結果

最適化なし(O0)コンパイルなので、愚直なコードを書いてくれます。まず目につくのは5-20行目のレジスタの内容をひたすらスタックに退避している部分ですね。rdiレジスタだけ微妙に離れた位置に退避しているのを覚えておいてください。それ以外は隣り合った領域に保存されています。

あと分かるのは45行目でしょうか。コメントの通りここはローカル変数 integer_in にraxレジスタに入った値を代入しています。それ以外は睨めっこしてもしょうがないので答え合わせをしましょう。System V ABI 3.5.7節 Variable Argument Lists を参照してみます。

System V ABIって

色んな呼び出し規約とかを定義してるドキュメント。x86-64版は以下から最新版がダウンロードできます。

x86 psABIs / x86-64 psABI · GitLab
GitLab.com

レジスタ保存領域

まず5-20行目のスタック退避を行っているコードについて。これはレジスタ保存領域と呼ばれ、関数が可変長引数をもつ場合に用意されます。またこれは固定サイズの領域において管理され、general purpose argument (整数、ポインタ型)を扱う領域が48Byte(8Byte×6)、floating purpose argument(浮動小数点数)を扱う領域が128Byte(16Byte×8)で計176Byteとなります。

注: 以前の版 ではxmm15まで使っていたようですが、最新版ではxmm7までになっているらしい。ので古いCPUだと多少挙動が変わるかも。

レジスタと保存領域は次のように対応づけられ、これは通常の関数におけるレジスタの引数渡しの順番に対応します(これにより、callerは通常の関数と同様に呼出しを行える)。また、レジスタ保存領域に入りきらなかった分は、オーバーフロー領域(呼び出し元のスタック)に書き込まれています。これも通常の関数と同じですね。

通常の関数の呼び出し規約に関しては以下などを参照すると良いかもしれないです。

[GDB] Linux x86-64 の呼出規約(calling convention)を gdb で確認する - th0x4c 備忘録
目的 Linux x86-64 の呼出規約(calling convention)を gdb で確認する。 環境 OS: CentOS 5.5Kernel: 2.6.18-194.el5 x86_64GCC: gcc 4.1.2 20080704GDB: GNU gdb 7.0.1-23. …
レジスタ名 相対位置
%rdi 0
%rsi 8
%rdx 16
%rcx 24
%r8 32
%r9 40
%xmm0 48
%xmm1 64
... ...
%xmm7 160

va_list構造体

次に、22行目から27行目について。これは va_start マクロによる処理ですが、やっている内容としては va_list 型の変数 arg の初期化です。 va_list 型は次のように定義される構造体です。

typedef struct {
	unsigned int gp_offset;
	unsigned int fp_offset;
	void *overflow_arg_area;
	void *reg_save_area;
} va_list[1];

それぞれのメンバの役割は次の通り。

reg_save_area レジスタ保存領域へのポインタを指します。

overflow_arg_area レジスタ保存領域に保存しきれなかった引数を配置する領域(オーバーフロー領域)へのポインタを指します。

gp_offset 次に読むべきgeneral purpose argumentの reg_save_area からの相対位置です。レジスタ保存領域には6つのgeneral purpose argumentを保存できるので、gp_offset = 6*8 = 48 のとき、次の引数は overflow_arg_area から参照されます。

fp_offset 次に読むべきfloating purpose argumentの reg_save_area からの相対位置。レジスタ領域には8つのfloating point argumentを保存できるので、fp_offset = 48 + 16 * 16 = 304のとき、次の引数は overflow_arg_area から参照されます。

va_arg

最後に、 va_arg マクロについて。このマクロが行う処理は、1行で説明すると次のように表せます。

「レジスタ保存領域から1個値を取ってきて。取れなければオーバーフロー領域から取ってきて。」

取ってくるべき値が整数(ポインタ)値か浮動小数点数値であるかは、静的に判明しているので、どちらの保存領域から取るべきかはわかります。ただし、オーバーフロー領域は整数も浮動小数点数も1つのスタックで管理されているので、取ってきた値が元々整数だったのか、浮動小数点だったのかは分からなくなります。これが、最初に示したprintfの挙動の原因です。

詳細には、次のような処理が行われます。実装視点で書いたので、ABIの記述とは多少異なるかもしれませんがご容赦ください。

  1. gp_offset > 48 ( fp_offset > 176) ならば、4に遷移。
  2. reg_save_area + gp_offset (fp_offset)は取ってくる値が保存されているアドレスになる。これをraxレジスタに入れておく。
  3. gp_offset に8を加算する( fp_offset に16を加算する)。6に遷移。
  4. overflow_arg_area は取ってくる値が保存されているアドレスになる。これをraxレジスタに入れておく。ただし型が16byteなら、先にアラインメントしておく。
  5. overflow_arg_area をfetchする型のsize分進めて、8バイトアラインメントする。

6. raxレジスタの指し先を読む。これが va_arg がfetchする値になる。

さて、ここまでの記載とアセンブリコードを照らし合わせると、スタックの構造は次のようになっていることが分かります。

rdiレジスタの退避先が微妙に離れている、という点も説明がつきました。rdiレジスタだけは可変長引数ではなく、固定の引数であるので引数のスタック退避として別の領域が確保されます。

また、上の表とアセンブリを照らし合わせると、前述のアセンブリコードの内容は次のように説明できます。

.L5:
        movl    $8, -208(%rbp)		# gp_offsetを8で初期化
        movl    $48, -204(%rbp)		# fp_offsetを16で初期化
        leaq    16(%rbp), %rax		
        movq    %rax, -200(%rbp)	# overflow_offset_areaは呼び出し元のstack topで初期化
        leaq    -176(%rbp), %rax	
        movq    %rax, -192(%rbp)	# reg_save_areaはRegister Save Areaの先頭で初期化
        movl    -208(%rbp), %eax	
        cmpl    $47, %eax			# gp_offset < 48 を計算
        ja      .L3					
        movq    -192(%rbp), %rax	
        movl    -208(%rbp), %edx
        movl    %edx, %edx
        addq    %rdx, %rax			# reg_save_area + gp_offset を計算(raxには読むべきアドレスが入る)
        movl    -208(%rbp), %edx
        addl    $8, %edx	
        movl    %edx, -208(%rbp)	# gp_offsetに8加算する
        jmp     .L4
.L3:								# overflow領域から読み込む
        movq    -200(%rbp), %rax	# overflow_offset_areaの値(raxには読むべきアドレスが入る)
        leaq    8(%rax), %rdx
        movq    %rdx, -200(%rbp)	# overflow_offset_areaを8バイト進める
.L4:
        movq    (%rax), %rax		# raxの指すアドレスの値を読む
        movq    %rax, -184(%rbp)	# integer_inへの代入処理

まとめ

まとめると、可変長引数の引数は次の場所に保管(退避)されます。

6番目までの整数(ポインタ)引数…レジスタ保存領域(整数)

8番目までの浮動小数点数引数…レジスタ保存領域(浮動小数点数)

7番目以降の整数引数、9番目以降の浮動小数点数引数…オーバーフロー領域(通常の関数の引数と全く同じ扱い)

特にprintf関数では、書式指定子を順番に読み取って整数(ポインタ)から取るか、浮動小数点数から取るかを決定します。翻って、冒頭のコード(以下は再掲)では、次の順番で値が参照されます(第1引数はフォーマット文字列である点に留意)。

#include <stdio.h>

int main(){
    printf("%ld %ld %ld %ld %ld %ld %ld %lf %lf %lf %lf %lf %lf %lf %lf %lf\n",1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0,1L,2L,3L,4L,5L,6L,7L);
}
  1. fmtが%ldなので、gpの2番目の1L
  2. fmtが%ldなので、gpの3番目の2L
  3. fmtが%ldなので、gpの4番目の3L
  4. fmtが%ldなので、gpの5番目の4L
  5. fmtが%ldなので、gpの6番目の5L
  6. fmtが%ldだが、gpからこれ以上取れないので、overflow areaの1番目から9.0を取り出し、このバイナリ表現の10進変換である4621256167635550208
  7. fmtが%ldだが、gpからこれ以上取れないので、overflow areaの2番目から6L
  8. fmtが%lfなので、fpの1番目の1.0
  9. fmtが%lfなので、fpの2番目の2.0
  10. fmtが%lfなので、fpの8番目の8.0
  11. fmtが%lfだが、fpからこれ以上取り出せないので、overflow areaの3番目から7Lを取り出し、これを浮動小数点解釈して0.0

蛇足ですが、この動作はABIに乗っ取った「正しい動作」なので未定義動作を踏んでいるわけではないはずです。 つまりこのコードは正しいコード。 でもこんな書き方をしても一切得はないので普通に順番に書きましょう。コンパイラに怒られるしね。

(追記ここから)

Xにて指摘を頂いたので修正(8/31)。 C言語規格 に従うと、printfで書式指定子を正しい順番で書かないのは未定義動作に当たるようです。p.231にprintfに関する説明があります。

If a conversion specification is invalid, the behavior is undefined.288) If any argument is not the correct type for the corresponding conversion specification, the behavior is undefined.

(訳):書式指定子が不正であるとき、振る舞いは未定義です。引数が対応する書式指定子に対して正しい型でないとき、振る舞いは未定義です。

同様に、p.198では va_arg マクロに対して次の記述があります。

If there is no actual next argument, or if type is not compatible with the type of the actual next argument (as promoted according to the default argument promotions), the behavior is undefined, except for the following cases ...

(訳): 次の実引数が存在しない、もしくは指定した型が実引数の型と互換性がない(型はtype promotion=upcast?する)とき、動作は未定義である。ただし次の場合を除く:(省略)

可変長引数がi個しかないときに va_arg マクロをi+1回以上呼び出したり、j番目の可変長引数がlong型なのにj回目の va_arg マクロでdouble型の値を持ってきたりする操作は未定義動作にあたるということですね。

まとめると、x86-64のABIに従って実装しているので、この環境においては今回の記述のような結果が得られる、という話のようです。

C言語の規格は特定の実装によらず定められるので、実装とのInterfaceであるABIは切り離して考えるべきですね。ご指摘ありがとうございます。

(追記ここまで)

いかがでしたか?少しでもLow Levelの世界おもしれ~って思っていただければ幸いです。

明日の記事は@masataro, @kitsne, @imoimo, @masu_kou, @Elmer, @tobuhitodesuさんの「初音ミク17歳を祝うだけ」です!初音ミクの曲は何が好きですか?私は小林オニキスさんのサイハテがお気に入りです。では明日の記事もお楽しみに!

参考文献

Cの可変長引数とABIの奇妙な関係 - Qiita
printf に関する以下のツイートが流行っていました。https://twitter.com/kaityo256/status/1167756472312184832上のツイートでは割とあっさ…
x86 psABIs / x86-64 psABI · GitLab
GitLab.com
NapoliN icon
この記事を書いた人
NapoliN

情報理工学院情報工学系。アイコンは一応自作です。 ぷよぐやみんぐできないのに情報系きちゃったよぉふぇぇな美少女だよ。

この記事をシェア

このエントリーをはてなブックマークに追加
共有
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記