こんにちは、42tokyo Advent Calendar 2021 の15日目を担当する、在校生のnori26です。
はじめに
この記事はC言語で可変長引数を実現するためのva_list
やその他マクロについて、42で学んだことをまとめたものです
処理系はx86-64 Linux
を想定して書いています
話を簡単にするため、浮動小数点レジスタの話は省きます
もっとちゃんとした記事1や記事2があったのでより詳しく知りたい方はこっち見た方がいいと思います
結論
-
va_list
は32bitと64bitでデータ構造が違う - 多分引数の渡し方が違うからだと思う
-
va~
系マクロは処理系ごとの違いを隠蔽してくれてる
va_list
のデータ構造
32bit
i386 stdarg.h
typedef char *va_list;
64bit
System V AMD64 ABI
typedef struct {
unsigned int gp_offset; //汎用レジスタ分のオフセット
unsigned int fp_offset; //浮動小数点レジスタ分のオフセット、この記事では無視する
void *overflow_arg_area; //スタック渡し分のアドレス
void *reg_save_area; //レジスタ渡し分の先頭アドレス
} va_list[1];
前提知識
呼び出し規約
32bitと64bitでは関数を呼び出すときのルールが違う
32bitでは引数はスタックで渡される
64bitでは6個までレジスタで渡され、それ以降はスタックで渡される(今回は浮動小数点は無視する)
その他ルールはここでは割愛
詳しくは すごい方の記事, すごい方の記事2, System V AMD64 ABI
スタック
メインメモリの領域の一つで、普通に変数を宣言したときに割り付けられる領域、いつも使ってるやつ
メインメモリは低速だが、容量が大きい
スタックには変数の他に関数の始まりのアドレスや、関数からreturnしたあとコードのどこに戻ればいいかなど、C言語のコード上からは見えていない色々な情報が格納されている
メモリレイアウトの詳しい説明は割愛
詳しくは ここ がいいかも
レジスタ
CPUに付いてる記憶装置
非常に高速だが、容量が小さい
演算装置とメモリ間のデータのやり取りを中継したりとか色々やる
メモリとは別の装置なのでメモリアドレスは振られていない
C言語では基本的に扱えない(レジスタを意識しなくても書けるようにしてくれてる)
詳しくは ここ がいいかも
va~
系ってなんだ
実行環境
$ LC_ALL=C lscpu | grep -E '^(Architecture|Model name)'
Architecture: x86_64
Model name: AMD Ryzen 5 5600X 6-Core Processor
$ uname -srmo
Linux 5.4.0-90-generic x86_64 GNU/Linux
$ lsb_release -d
Description: Ubuntu 18.04.6 LTS
$ clang --version | head -n 3
clang version 9.0.0-2~ubuntu18.04.2 (tags/RELEASE_900/final)
Target: x86_64-pc-linux-gnu
Thread model: posix
$ /lib/x86_64-linux-gnu/libc.so.6 | grep version
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1.4) stable release version 2.27.
Compiled by GNU CC version 7.5.0.
va_arg
はスタックを掘り返す
printf
を指定子だけで呼び出してみる ※未定義動作
printf("%p%p%p%p%p\n");
謎の出力が出ると思う
コード上で既にスタックに配置した変数や、C言語が暗黙的にスタックに格納した情報などをva_arg
が順に取り出して出力させてる気がする
確認してみる ※未定義動作
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint64_t ary[10] = {1,2,3,4,5,6,7,8,9,0xdeadbeef};
printf("%p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p\n");
}
出力
0x400690 0xdeadbeef 0x5 0x6 0x7 0x9 0x7f91baa51660 0x1 0x2 0x3 0x4 0x5 0x6 0x7 0x8 0x9 0xdeadbeef 0x4005b0
スタックに置いた配列 {1..9, 0xdeadbeef}
がいる!
でも0xdeadbeef 0x5, 0x6 0x7 0x9
は何故か最初にも出てる
なんでだ
va_start
はレジスタの中身をスタックに配置する
配列ary
に値を代入するとき、CPUは値を一度レジスタにセットし、レジスタからary
のメモリ領域に格納する
そのままprintf
が呼び出され、va_start
がレジスタの中身をスタックに保存している
レジスタは容量が小さく、不要な値はすぐに別の用途で上書きされてしまうため、配列の残骸が中途半端にメモリに複写されたようだ
でもこれだけだとva_arg
はどこからスタックを掘り始めればいいのかわからないので、開始アドレスが必要かも
va_start
はどこかにアドレスをセットしてるはずだ!
va_list
のデータ構造
32bit
ここで一旦レジスタのことは忘れて32bitのことを考えてみる
先述のとおり、32bitでは引数は関数呼び出し直前にスタックに連続して全て配置される
そうすると話は単純で、va_start
は最終仮引数の次の領域を先頭のアドレスとしてva_list
にセットすればいい
va_list
がポインタ型変数を一つ持っていれば、va_arg
は呼びだされる度にそいつを進めていくだけで次の引数を取り出せる
typedef char *va_list;
これでおっけー!
64bit
一方64bitではレジスタ渡し分(1~6個目)とスタック渡し分(7個目~)で少し扱いが変わる
まずレジスタ分とスタック分それぞれの先頭アドレスと、レジスタ分を何回取り出したかを知るためのオフセット変数が必要になるらしい
アドレスが二つ必要なのは、32bitとは違って6個目と7個目の引数が連続したアドレスに配置されないからだと思う
7個目と6個目の間に関数呼び出しが入ることで、スタックに色々追加されて隔たりができる
(1~6個目は関数呼び出し後va_start
によってスタックに配置されるのに対して、それ以降の引数は32bitと同様関数呼び出し前に配置されるから)1
でアドレス連続してないから今何個目の引数かを把握するためのオフセット変数が必要になる
typedef struct {
unsigned int gp_offset; //オフセット変数
unsigned int fp_offset; //これは今回は無視
void *overflow_arg_area; //スタック渡し分のアドレス
void *reg_save_area; //レジスタ渡し分の先頭アドレス
} va_list[1];
あとポインタ型と同じように扱いたいから構造体の配列で定義してるのかな多分
おわり
va~
系マクロは処理系ごとの違いを隠蔽してくれてる抽象化レイヤーなんだなーと思いました
明日は、@harinez2さんが最強のMakefileについて書いてくれる予定ですので、そちらの記事もお楽しみに!
参考URL
自作 OS で可変長引数実装 (for x86-64)
x86-64とARM64の可変長引数関数の呼び出し規約
Cの可変長引数とABIの奇妙な関係
-
あと本当は間に浮動小数点レジスタ分の領域も入る ↩