自作 OS で可変長引数実装 (for x86-64)
自作 OS (for x86-64) で可変長引数を扱えるようにしたいなと思い調べた内容をまとめました。
x86-64 では引数の受け渡しにレジスタもスタックも使用するので x86 に比べて可変長引数の実装もがしくなりそうだなと思っていたのですが、調べてみると結局実装するだけならコンパイラにおんぶに抱っこで良さそうということがわかりました。
なお作成中の OS では xmm レジスタは使用しない (MMX, SSE といった命令セットをサポートしない) ので、そのあたりの話は含まれないです (とはいえ関係するレジスタが増えるだけで話としてはあまり変わらないはず)。
可変長引数を利用するコード例
まず可変長引数の扱い方のおさらいです。
C 言語で可変長引数を扱う場合、 libc に含まれる stdarg.h
を使用します。
コード例:
#include <stdarg.h>
#include <stdio.h>
void foo(char *fmt, ...) {
va_list ap;
int d;
char c, *s;
va_start(ap, fmt);
while (*fmt) {
switch(*fmt++) {
case's':
s = va_arg(ap, char *);
printf("string %s\n", s);
break;
case 'd':
d = va_arg(ap, int);
printf("int %d\n", d);
break;
case 'c':
c = va_arg(ap, int);
printf("char %c\n", c);
break;
}
}
va_end(ap);
}
int main(int argc, const char *argv[]) {
foo("s d c", "hoge", 100, 'a');
return 0;
}
出力:
string hoge
int 100
char a
System V ABI における可変長引数の仕様
System V ABI の AMD64 仕様書 では 3.5.7 Variable Argument Lists で可変長引数について記述されています。以下で内容を抜粋します。
register save area
可変長引数を使用する関数は、まずレジスタを介して渡された引数をスタック上に確保した register save area と呼ばれる場所に保存します。x86-64 で汎用レジスタを介して渡される引数の個数は最大 6 つなので、今回の場合はこの大きさが 8 (bytes) x 6 = 48 (bytes) になります。
va_start
次に va_start
で va_list
の初期化が行われます。va_list
の定義は仕様で定められています。
typedef struct {
unsigned int gp_offset; # offset in bytes from reg_save_area to the next available general purpose argument register
unsigned int fp_offset; # offset in bytes from reg_save_area to the next available floating point argument register
void *overflow_arg_area; # pointer used to fetch arguments passed on the stack
void *req_save_area; # pointer to register save area
} va_list[1];
初期状態では gp_offset
, fp_offset
は 0 です。
va_arg
va_arg(va, type)
は type や offset の値を見て動作が変わります。
例えば type=int
で gp_offset < 8 * 6
の場合、対応する引数は reg_save_area にあるので、アドレス (reg_save_area + gp_offset)
から値を取得し、gp_offset を +8 します。
type=int
かつ gp_offset >= 8 * 6
の場合、対応する引数は overflow_arg_area にあるので、アドレス (overflow_arg_area)
から値を取得し、overflow_arg_area を +8 します。
va_end
va_end
はスタック上に確保した領域を解放するだけです。
ということでまとめると、以下のような方針で実装すればよいことがわかりました。
- 関数のはじめで、レジスタを介して渡された引数をスタック上に詰め直す
- va_list で状態を管理し、va_arg が呼ばれたときに reg_save_area か overflow_reg_area のいずれかから引数を取り出す
自作 OS での可変長引数の実装
上の処理を実装するのどれくらい大変かな、と思って普段使いの Linux 環境上での可変長引数の実装を調べていると、どうやら処理の大半をコンパイラに任せてしまえるということがわかりました。
stdarg.h
は自環境では /usr/lib/gcc/x86_64-pc-linux-gnu/10.2.0/include/stdarg.h
からやってきており、その内容を単純に見ると、
#define va_start(v,l) __builtin_va_start(v,l)
#define va_end(v) __builtin_va_end(v)
#define va_arg(v,l) __builtin_va_arg(v,l)
typedef __builtin_va_list __gnuc_va_list;
typedef __gnuc_va_list va_list;
のように全て必要なマクロが GCC によって定義されています。
試しに自作 OS 上でこれらのマクロを使用して可変長引数が実装できることを確認してみます。
コード:
void f(char *fmt, ...) {
__builtin_va_list va;
__builtin_va_start(va, fmt);
int a = __builtin_va_arg(va, int);
printf("1st: %d\n", a);
char *b = __builtin_va_arg(va, char *);
printf("2nd: %s\n", b);
int c = __builtin_va_arg(va, int);
printf("3rd: %d\n", c);
printf(fmt, a, b, c);
__builtin_va_end(va);
}
int main(int argc, char *argv[]) {
f("%d %s %d\n", 1, "hello", 3);
}
出力:
1st: 1
2nd: hello
3rd: 3
1 hello 3
出力アセンブリコードを見てみます。
void f(char *fmt, ...) {
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 41 56 push %r14
6: 41 55 push %r13
8: 41 54 push %r12
a: 53 push %rbx
b: 48 83 ec 50 sub $0x50,%rsp # 0x50 = sizeof(va_list) + 6 gp_reg args + 8
f: 48 89 fb mov %rdi,%rbx
12: 48 89 75 b8 mov %rsi,-0x48(%rbp) # set 1st vararg to the stack
16: 48 89 55 c0 mov %rdx,-0x40(%rbp) # set 2nd vararg to the stack
1a: 48 89 4d c8 mov %rcx,-0x38(%rbp) # set 3rd vararg to the stack
__builtin_va_list va;
__builtin_va_start(va, fmt);
1e: 48 8d 45 10 lea 0x10(%rbp),%rax
22: 48 89 45 a0 mov %rax,-0x60(%rbp) # set va.overflow_arg_area
26: 48 8d 45 b0 lea -0x50(%rbp),%rax
2a: 48 89 45 a8 mov %rax,-0x58(%rbp) # set va.reg_save_area
int a = __builtin_va_arg(va, int);
2e: c7 45 98 10 00 00 00 movl $0x10,-0x68(%rbp) # set va.gp_offset
35: 44 8b 65 b8 mov -0x48(%rbp),%r12d # fetch 1st vararg
printf("1st: %d\n", a);
39: 44 89 e6 mov %r12d,%esi
3c: bf 10 07 00 00 mov $0x710,%edi
41: b8 00 00 00 00 mov $0x0,%eax
46: e8 16 04 00 00 callq 461 <printf>
char *b = __builtin_va_arg(va, char *);
4b: 8b 45 98 mov -0x68(%rbp),%eax
4e: 83 f8 2f cmp $0x2f,%eax
51: 77 6d ja c0 <f+0xc0> # register に収まらない引数の場合 jmp
53: 89 c2 mov %eax,%edx
55: 48 03 55 a8 add -0x58(%rbp),%rdx # fetch 2nd vararg from (va.reg_save_area + va.gp_offset)
59: 83 c0 08 add $0x8,%eax
5c: 89 45 98 mov %eax,-0x68(%rbp) # va.fp_offset += 0x8
5f: 4c 8b 2a mov (%rdx),%r13
printf("2nd: %s\n", b);
62: 4c 89 ee mov %r13,%rsi
65: bf 19 07 00 00 mov $0x719,%edi
6a: b8 00 00 00 00 mov $0x0,%eax
6f: e8 ed 03 00 00 callq 461 <printf>
int c = __builtin_va_arg(va, int);
74: 8b 45 98 mov -0x68(%rbp),%eax
77: 83 f8 2f cmp $0x2f,%eax
7a: 77 52 ja ce <f+0xce>
7c: 89 c2 mov %eax,%edx
7e: 48 03 55 a8 add -0x58(%rbp),%rdx # fetch 3rd arg from (va.reg_save_area + va.gp_offset)
82: 83 c0 08 add $0x8,%eax
85: 89 45 98 mov %eax,-0x68(%rbp)
88: 44 8b 32 mov (%rdx),%r14d
printf("3rd: %d\n", c);
8b: 44 89 f6 mov %r14d,%esi
8e: bf 22 07 00 00 mov $0x722,%edi
93: b8 00 00 00 00 mov $0x0,%eax
98: e8 c4 03 00 00 callq 461 <printf>
printf(fmt, a, b, c);
9d: 44 89 f1 mov %r14d,%ecx
a0: 4c 89 ea mov %r13,%rdx
a3: 44 89 e6 mov %r12d,%esi
a6: 48 89 df mov %rbx,%rdi
a9: b8 00 00 00 00 mov $0x0,%eax
ae: e8 ae 03 00 00 callq 461 <printf>
__builtin_va_end(va);
}
b3: 48 83 c4 50 add $0x50,%rsp
b7: 5b pop %rbx
b8: 41 5c pop %r12
ba: 41 5d pop %r13
bc: 41 5e pop %r14
be: 5d pop %rbp
bf: c3 retq
# 以下は overflow_arg_area から値を取るためのコード
# 基本的には va_arg を呼ぶ度にこのコード片も一つ増えるっぽい
# (コンパイル時には reg_save_area か overflow_arg_area か判断できない)
char *b = __builtin_va_arg(va, char *);
c0: 48 8b 55 a0 mov -0x60(%rbp),%rdx
c4: 48 8d 42 08 lea 0x8(%rdx),%rax
c8: 48 89 45 a0 mov %rax,-0x60(%rbp)
cc: eb 91 jmp 5f <f+0x5f>
int c = __builtin_va_arg(va, int);
ce: 48 8b 55 a0 mov -0x60(%rbp),%rdx
d2: 48 8d 42 08 lea 0x8(%rdx),%rax
d6: 48 89 45 a0 mov %rax,-0x60(%rbp)
da: eb ac jmp 88 <f+0x88>
0x2e (va_start
直後のアドレス) 時点でのスタックの状態も載せておきます。
アセンブリコードと合わせてみると処理が見やすいと思います。
(gdb) x /128xb $rsp
0x3f40: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # $rsp
0x3f48: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # start of va (va.gp_offset and fp_offset)
0x3f50: 0xc0 0x3f 0x00 0x00 0x00 0x00 0x00 0x00 # va.overflow_arg_area
0x3f58: 0x60 0x3f 0x00 0x00 0x00 0x00 0x00 0x00 # va.reg_save_area
0x3f60: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # start of reg_save_area
0x3f68: 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x3f70: 0x2b 0x07 0x00 0x00 0x00 0x00 0x00 0x00
0x3f78: 0x03 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x3f80: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x3f88: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # end of reg_save_area
0x3f90: 0x50 0x4f 0x01 0x00 0x00 0x00 0x00 0x00
0x3f98: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x3fa0: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x3fa8: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x3fb0: 0xc0 0x3f 0x00 0x00 0x00 0x00 0x00 0x00 # $rbp (and store old rbp)
0x3fb8: 0xfe 0x00 0x00 0x00 0x00 0x00 0x00 0x00 # return address