自作 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_startva_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=intgp_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