C++

g++の呼び出し規約を調べてみた話

こちらの記事を見て、C++の呼び出し規約が気になったので少し調べてみました、という話です。

(最初コメントに書こうかと思ったのですが、長くなりそうなのでこちらに書きます)

何が気になったか

ぼくの記憶だと、確かSystem VのAMD64でのCの呼び出し規約は次のようになっていたと思います。

  • 十分大きい構造体が引数であるとき、スタックに載せて呼び出し先に渡す
  • そうでないとき、レジスタで渡す

これを踏まえたとき、

  • もしstd::stringが「十分大きい構造体」でないなら、レジスタで渡されるはず。すると、printf("%s", std::stringのインスタンス)は、printf("%s", std::stringの「第一メンバ」, 「第二メンバ」...)と等価なので、変な文字はでなさそう(SEGVするか、正しい文字が出る?)。
  • もしstd::stringが「十分大きい構造体」なら、スタック経由で渡されるはず。この時、呼ばれた側の関数は可変長引数なんだけど、どう処理されるのだろうか。

という二点を疑問に思いました。

環境

  • Windows 10 Fall Creators Update上のWSL + Ubuntu 16.04.3 LTS(!)
    • g++ (Ubuntu 5.4.0-6ubuntu1~16.04.5)
    • libstdc++6 5.4.0-6ubuntu1~16.04.5
  • Linux 4.13.0-1-amd64 + Debian buster/sid amd64
    • g++ (Debian 7.2.0-12)
    • libstdc++6 7.2.0-12

のそれぞれ2つの環境でチェックしています。WSLがこういうローレベルな用途に使えるのは楽しいですね。
なおgdbはgdb-pedaを入れています。

std::stringの中身

std::stringstd::basic_string<T>のインスタンスになっていて、大体こんな感じの構造体です。
(具体的な中身は、ubuntu/WSLなら/usr/include/c++/5/bits/basic_string.hにあります)

class string {
  char* pointer;
  std::size_t size;
  char local_buffer[16];
}

local_bufferが何なのかまではまあ関係なさそうなので追いかけていないのですが、本稿では使わないので大丈夫です。

むりやり正しくprintfを呼んでみる

ということで、上のところで書いた、『もしstd::stringが「十分大きい構造体」でない』ケースでは、正しい文字が表示されてしまいそうな気がします。これを確かめるため、次のようなコードを書いてみます。

#include <string>
#include <cstdio>

int main() {
  std::string a = "hoge";
  // (C++的なキャストはわざと使っていません)
  void* b = &a;
  char* c = *(char **)b;

  std::printf("cast to char* %s\n", c);
  return 0;
}

これを実行すると、たしかにhoge、と表示されます。

構造体と呼び出しかた

さて、ではstd::stringは「十分大きい」のでしょうか?これを確かめるため、次のようなコードを書いてみます。

#include <string>
#include <iostream>
#include <cstdio>

struct fuga {
  const char* a;
  std::size_t size;
  char local_buffer[16];
};

int main() {
  std::cout << "sizeof string: " << sizeof(std::string) << std::endl;
  std::cout << "sizeof fuga: " << sizeof(fuga) << std::endl;

  fuga d;
  d.a = "fuga";

  std::printf("different class %s\n", d);
  return 0;
}

すると…、このコードはstd::printfのところでSEGVします。実際、gdbで見ていると、構造体fugaはstack経由で渡されていることがわかります。

ここはまだ確かめていないところなのですが、おそらく可変長引数の関数では、引数をva_argsとかで取り出すときに、サイズに応じてstack経由かregister経由かを決めているのだと思います。特にprintfでは、フォーマット文字列に応じて型を推測できます。%sならポインタ型が来ることが想定されますが、ポインタはレジスタ経由で渡されます。ですから、レジスタの値が指す先を見ようとしてSEGVを起こしてしまったのだと思います。

ではなぜstd::stringを呼んだときは、SEGVを起こさなかったのでしょうか。これを見るため、次のコードをgdbで実行してみます。

#include <string>
#include <cstdio>

int main() {
  std::string a = "hoge";

  std::printf("string %s\n", a);
  return 0;
}

すると、(使ったコードは微妙に違いますが、、)

[-------------------------------------code-------------------------------------]
   0x400cbc <main+182>: mov    rsi,rax
   0x400cbf <main+185>: mov    edi,0x400e6c
   0x400cc4 <main+190>: mov    eax,0x0
=> 0x400cc9 <main+195>: call   0x400a00 <printf@plt>
   0x400cce <main+200>: mov    rax,QWORD PTR [rbp-0x68]
   0x400cd2 <main+204>: mov    rsi,rax
   0x400cd5 <main+207>: mov    edi,0x400e77
   0x400cda <main+212>: mov    eax,0x0
Guessed arguments:
arg[0]: 0x400e6c ("string %s\n")
arg[1]: 0x7ffffffdde50 --> 0x7ffffffdde60 --> 0x7f0065676f68
[------------------------------------stack-------------------------------------]
0000| 0x7ffffffdde30 --> 0x1
0008| 0x7ffffffdde38 --> 0x7ffffffdde60 --> 0x7f0065676f68
0016| 0x7ffffffdde40 --> 0x7ffffffdde50 --> 0x7ffffffdde60 --> 0x7f0065676f68
0024| 0x7ffffffdde48 --> 0x7ffffffdde60 --> 0x7f0065676f68
0032| 0x7ffffffdde50 --> 0x7ffffffdde60 --> 0x7f0065676f68
0040| 0x7ffffffdde58 --> 0x4
0048| 0x7ffffffdde60 --> 0x7f0065676f68
0056| 0x7ffffffdde68 --> 0x400db7 --> 0x801f0fc35d
[------------------------------------------------------------------------------]

というような結果になりました。つまり、『std::stringはスタックでもレジスタでもなく、ポインタで渡されている』ということがわかりました!

さて、これまでの実験で、

  • C++の場合、引数が構造体のときの呼び方は構造体のサイズだけで決まっているわけではない
  • C++の場合、構造体の引数はポインタ経由で渡されることがある

ということがわかりました。これ以上実験をやってもそろそろ何もわからなくなってくるので、ここからは実際に呼び出し規約を眺めてみましょう。

呼び出し規約を見る

System V ABIの21ページ…かと思ったら、どうやらここによるとg++はItanium ABIを使っているようです。(ここのところ、情報が若干錯綜していてよくわかっていません。WikipediaによるとSystem V ABIを使っていると書かれています。しかし、System V ABIだと64 bit以下の構造体はレジスタ経由で渡される可能性があるはずなのですが、実際試してみてもそうなっているような感じがしません。実験してみると、16 bitより大きい構造体はスタック経由で渡されているような感じがあります。もしかしたらalignmentをよく理解していないかもしれない?)

関数呼び出し規約は、次のようになっています。

In general, C++ value parameters are handled just like C parameters. This includes class type parameters passed wholly or partially in registers. There are, however, some special cases.

If the parameter type is non-trivial for the purposes of calls, the caller must allocate space for a temporary and pass that temporary by reference.

A type is considered non-trivial for the purposes of calls if:

  • it has a non-trivial copy constructor, move constructor, or destructor, or
  • all of its copy and move constructors are deleted.

とにかく重要なのは、

  • it has a non-trivial copy constructor, move constructor, or destructor, or

のところで、結局std::stringはこれらを持っているため、リファレンス(=ポインタ)として渡されており、SEGVを起こさず変な文字が表示された、ということのようです。

まとめ

ということで中途半端ですがまとめです。

  • 非自明なコピーコンストラクタなどを持つ構造体は、リファレンスとして渡される。
  • g++の採用しているABIでは16 bitより大きい構造体はstackに載せて渡されている?要調査です。

多分まだわかっていないところがたくさんあるので、有識者の方は何か文献等をサジェストしていただけるとありがたいです。