彷徨えるフジワラ

年がら年中さまよってます

DTrace の sizeof() 問題を調べる

以前から気になっていた DTrace の sizeof() 問題の原因を調査することに。

自分のバグと言うわけではないけれど、ちょっと入り組んだ構造の受け渡しをする関数を監視しようとすると、途端にこの問題に足をすくわれかねないので、このまま放置しておくと眠れなくなりそうで落ち着かない。結構簡単に顕在化する問題なんだけど、中の人達はどうやって回避しているんだろう?

兎にも角にも、まずは入れ子になった構造体の境界整合が不正になる問題の究明から。

あらかじめ書いておきますが、このエントリは長いです

sizeof() 時の処理経路をソースで確認

dtrace コマンドが sizeof() に対してどのような処理を行っているのかを確認する。

以前の調査で D スクリプトに関する処理は概ね libdtrace.so で実施されることがわかっているので、usr/src/lib/libdtrace/common 配下にある dt_grammar.y ファイル、いわゆる yacc ソースを起点にしたソース調査から。

"sizeof" キーワードに該当する DT_TOK_SIZEOF シンボルを扱っている構文定義を探すと:

unary_expression:
....
    | DT_TOK_SIZEOF unary_expression { $$ = OP1(DT_TOK_SIZEOF, $2); }
    | DT_TOK_SIZEOF DT_TOK_LPAR type_name DT_TOK_RPAR {
          $$ = OP1(DT_TOK_SIZEOF, dt_node_type($3));
      }
....

後者の構文に対する処理中の dt_node_type() は、type_name($3) に対して dt_node_t 構造の引き当てを行っているだけなので、結局どちらの構文でも OP1(DT_TOK_SIZEOF, dt_node) な処理が実施されることに。

OP1() は dt_node_op1() のことなので、dt_parser.c 中の実装を見てみると:

    /*
     * If sizeof is applied to a type_name or string constant, we can
     * transform 'cp' into an integer constant in the node construction
     * pass so that it can then be used for arithmetic in this pass.
     */
    if (op == DT_TOK_SIZEOF &&
        (cp->dn_kind == DT_NODE_STRING || cp->dn_kind == DT_NODE_TYPE)) {
            dtrace_hdl_t *dtp = yypcb->pcb_hdl;
            size_t size = dt_node_type_size(cp);

型指定時限定だけど、その場で整数化する処理を実施している模様。ってことは、dt_node_type_size() が「型→サイズ」変換の肝なのね。

同じファイルで定義されている dt_node_type_size() を見てみると:

size_t
dt_node_type_size(const dt_node_t *dnp)
{
        if (dnp->dn_kind == DT_NODE_STRING)
                return (strlen(dnp->dn_string) + 1);

        if (dt_node_is_dynamic(dnp) && dnp->dn_ident != NULL)
                return (dt_ident_size(dnp->dn_ident));

        return (ctf_type_size(dnp->dn_ctfp, dnp->dn_type));
}

文字列指定では無いから最初の処理は無視。後は、dt_ident_size() と ctf_type_size() のどちらが呼ばれているか次第ということか。

内部構造を熟知しているわけでは無いので、dt_node_is_dynamic() の判定がどうなっているのかを(この時点では)判断するのはちょっと無理だけど、「dynamic」という字面から判定するに、定義済み型からサイズを引っ張っているのは ctf_type_size() ではないかなぁ、とあたりを付けてみる。

ctf_type_size() は、CTF(Compact C Type Format)の機能なのだろうけど、usr/src/lib/libctf 配下にはそれっぽい実装が置いてない。あれ?どういうこと?

困った時の全域検索= find + grep で見つけたのが usr/src/common/ctf 配下。うわ、そっちかよ! > CTF

ctf_type.c で定義されている ctf_type_size() は、取得対象の型に応じて、多少の処理は行うものの、結局のところ ctf_get_ctt_size() からの取得値を返却している模様。

ctf_get_ctt_size() は何をやっているかというと、ctf_type_t 構造に格納された値を返しているだけなので、取得されるサイズ情報の確定は、ctf_type_t の構築を行うところで行っているのではなかろうか、と。

そうすると、構造体定義の際に実行されるパスを洗ってみる必要があるのかなぁ。

構造体のメンバ定義における構文解析の処理は:

struct_declarator:
      declarator { dt_decl_member(NULL); }
    | DT_TOK_COLON constant_expression { dt_decl_member($2); }
    | declarator DT_TOK_COLON constant_expression {
              dt_decl_member($3);
      }
    ;

ということは、結局 dt_decl_member() なのね。

dt_decl.c の dt_decl_member() 実装を見てみると、最後に ctf_add_member()@ctf_create.c の呼び出しが。呼ばれた先の ctf_add_member() での処理は:

        if ((msize = ctf_type_size(fp, type)) == CTF_ERR ||
            (malign = ctf_type_align(fp, type)) == CTF_ERR)
                return (CTF_ERR); /* errno is set for us */

関数名からして、メンバのサイズ/境界整合値を扱っているっぽいので、多分ここで当たりかな?

ctf_add_member() のシグネチャは、呼び出し元と内部処理を付き合わせて見る限りでは:

ctf_add_member(ctf_file_t *fp, /* 仮想的なファイル構造へのポインタ */
               ctf_id_t souid, /* 追加先構造体/共用体識別子 */
               const char *name, /* メンバ名文字列 */
               ctf_id_t type /* メンバ型識別子 */
              )

という意味合いっぽいので、name 引数を見れば、当該メンバの追加処理か否かは判定出来そうな感じ。

今回問題になっているのは構造体の入れ子だから、追加先構造体を表す souid(Structure Or Union ID の略っぽい)も、追加メンバの型を表す type も、共に構造体定義処理がらみで払い出される識別子を使用する筈。

ということで、構文解析を眺めてみると:

struct_or_union_definition:
      struct_or_union '{' { dt_decl_sou($1, NULL); }
    | struct_or_union DT_TOK_IDENT '{' { dt_decl_sou($1, $2); }
    | struct_or_union DT_TOK_TNAME '{' { dt_decl_sou($1, $2); }
    ;

ここで呼び出している dt_decl_sou() を契機とする ctf_add_struct() 呼び出しがそれっぽい。

どちらの関数も引数に名前の指定があるので、DTrace で実行過程を監視すれば、構造体に割り当てられた識別子を特定することも出来るでしょう。

実行しつつの確認

実際に DTrace で sizeof() 算出を実施するには、以下のような D スクリプトを実行してみれば良い。

struct foo {
    char f1;
    void* f2;
};
struct bar {
    char f1;
    struct foo f2;
};
BEGIN {
    exit(sizeof(struct bar));
}

これを "-l -S" 付きで実行すると:

$ /usr/sbin/dtrace -s calc_sizeof.d -l -S

DIFO 0x8321130 returns D type (integer) (size 4)
OFF OPCODE      INSTRUCTION
00: 25000001    setx DT_INTEGER[0], %r1         ! 0x9
01: 23000001    ret  %r1
   ID   PROVIDER            MODULE                          FUNCTION NAME
    1     dtrace                                                     BEGIN

ということで、アセンブル結果から、sizeof(struct bar) は 9 バイトとみなされていることが確認できる。64bit 環境では、(integer) (size 8) 表示となり、sizeof(struct bar) の誤計算結果は 0x11 = 17 バイトになる筈。

C プログラムでの算出なら、32bit 環境の場合:

struct foo {
    char f1;       /* 1 byte */
    void* f2;      /* 3 byte(境界整合) + 4 byte(実データ) */
};
struct bar {
    char f1;       /* 1 byte */
    struct foo f2; /* 3 byte(境界整合) + 8 byte(実データ) */
};

ということで、12 バイトになる筈。64bit 環境なら、void* が 8 バイト境界なので、24 バイト。

DTrace では struct bar における f1 〜 f2 間のパディングが計上されていない(struct foo 内のパディングに関しては確保されていることを、以前確認している)。

先のソース調査で判明している機能分担から、調査方針としては:

  1. ctf_add_struct() の呼び出しと戻り値を監視することで、構造体への識別子割り当てを特定
  2. ctf_add_member() 契機での ctf_type_size()/ctf_type_align() で、当該識別子に対して返却される値を確認

という方向で。D スクリプトとしては以下の様な感じ。少々複雑なのは、ctf_type_align や ctf_type_size などの呼び出しが入れ子になるため。詳細は gihyo.jp の記事を参照のこと

BEGIN{
    self->traced = 0;
    self->namesp = 0;
    self->typesp = 0;
}
/* 構造体の定義を監視 */
pid$target:libctf.so:ctf_add_struct:entry
{
    self->namesp += 1;
    self->name[self->namesp] = arg2;
}
pid$target:libctf.so:ctf_add_struct:return
/0 <= arg1/
{
    printf("%s=%d", copyinstr(self->name[self->namesp]), arg1);
    self->namesp -= 1
}
/* 構造体メンバの追加を監視 */
pid$target:libctf.so:ctf_add_member:entry
{
    printf("%d/%s/%d", arg1, copyinstr(arg2), arg3);
    self->namesp += 1;
    self->name[self->namesp] = arg2;
    self->traced += 1;
}
pid$target:libctf.so:ctf_add_member:return
{
    self->traced -= 1;
    self->namesp -= 1;
}
/* 構造体メンバに関するサイズ/境界整合判定を監視 */
pid$target:libctf.so:ctf_type_size:entry,
pid$target:libctf.so:ctf_type_align:entry
/self->traced/
{
    self->typesp += 1;
    self->type[self->typesp] = arg1;
}
pid$target:libctf.so:ctf_type_size:return,
pid$target:libctf.so:ctf_type_align:return
/self->traced/
{
    printf("%d=%d", self->type[self->typesp], arg1);
    self->typesp -= 1;
}

で、実際に DTrace 実行を DTrace で観察してみる。実行の要領は以下の通り:

$ export LD_PRELOAD_32=/usr/lib/libctf.so
$ /usr/sbin/dtrace -s watch_ctf_funcs.d \
      -c '/usr/sbin/dtrace -s calc_sizeof.d -l -S'

LD_PRELOAD_32 設定の必要性に関しては、gihyo.jp の記事を参照のこと。

ちなみに、libctf.so ではなく dtrace バイナリ内部の関数を監視する場合は、/usr/sbin/dtrace ではなく、/usr/sbin/i86/dtrace や /usr/sbin/amd64/dtrace といった、アーキテクチャ固有バイナリを直接実行しないといけないので注意が必要。

実行により採取された情報は:

ctf_add_struct:return foo=36573      # struct foo(id=36573) の定義
 ctf_add_member:entry 36573/f1/4     #   "char f1" の追加
 ctf_type_size:return 4=1            #     サイズ:1
ctf_type_align:return 4=1            #     境界整合:1
 ctf_add_member:entry 36573/f2/29    #   "void* f2" の追加
 ctf_type_size:return 29=4           #     サイズ:4
ctf_type_align:return 29=4           #     境界整合:4
ctf_add_struct:return bar=36574      # struct bar(id=36574) の定義
 ctf_add_member:entry 36574/f1/4     #   "char f1" の追加
 ctf_type_size:return 4=1            #     サイズ:1
ctf_type_align:return 4=1            #     境界整合:1
 ctf_add_member:entry 36574/f2/36573 #   "sturuct foo f2" の追加
 ctf_type_size:return 36573=8        #     サイズ:8
ctf_type_align:return 4=1            #     (foo.f1 の境界整合)
ctf_type_align:return 36573=1        #     境界整合:1

ということになっていた。

struct foo は、4 バイト境界整合を要する void* メンバを保持しているため、他の構造体に対して入れ子になる場合には必ず 4 バイトで境界整合される必要がある。そうでないと、内部に保持している境界整合用のパディング領域との辻褄が合わなくなる。

ということで、struct bar 定義において、struct foo f2 を追加する際の境界整合値が、本来あるべき 4 ではなく 1 になっているのが問題であることが確認できた。

ctf_type_align() 実装の調査

さて、ctf_type_align() 実装を見てみると:

    case CTF_K_STRUCT:
    case CTF_K_UNION: {
....
        if (LCTF_INFO_KIND(fp, tp->ctt_info) == CTF_K_STRUCT)
                n = MIN(n, 1); /* only use first member for structs */
....

あれ?ひょっとして、最初のメンバの境界整合しか見てないから、入れ子構造体の境界整合値を上手く算出できてないのか?

物凄く意図的に2つ目以降のメンバの境界整合を無視しているようなコメントだけど、少なくとも C の言語仕様を考えたら、明らかにこの実装は意味が無いよね?

ひょっとして、共用体(CTF_K_UNION)判定と間違えた?いや、共用体でもこの実装は駄目でしょ?性能上の理由?うーん、ここで確認を省略して得られる程度の性能向上と、損なわれる C 言語的な適切さでは、後者の方が痛い気がするけどなぁ。

っつーことで、この部分を取り除いたソースで libctf.so をビルドして、LD_PRELOAD_32 で強制的に修正版をロードさせてみたところ....動いた!思った通りの sizeof() が算出されているよ!

末尾パディングの問題

入れ子構造体の境界整合に関しては解決したので、次は構造体の末尾パディングの問題。

っていうか、C プログラムでの sizeof() 算出値は末尾パディングを付加したものなので、「sizeof() 問題」という括りで言うとまだ問題の半分しか解決していない。

struct foo {
    void* f1;
    char* f2;
};

上記のような構造体 foo では、配列確保等の際に、2つ目以後の各要素の先頭が正しく境界整合するために、構造体末尾に 3 バイトのパディングを入れて、sizeof(struct foo) == 8 とする必要がある。

で、先の要領で DTrace による構造体の sizeof() 算出処理を追ってみると、struct foo に対する ctf_type_size() の返却値が 5 となっていて、末尾パディングを全く考慮していないことが判明。

ctf_type_size() の実装を見てみると....あれ?構造体(CTF_K_STRUCT)や共用体(CTF_K_UNON)の場合の分岐が無いぞ?これじゃぁ、単純にメンバ変数サイズ(+要素間の境界整合用パディング)の合計しか計上されなくても不思議じゃない。

ってことは、構造体/共用体用に、自身の境界整合値に見合った末尾パディングを付与する処理を追加してやれば良いのかな? > ctf_type_size()

ということで、以下の様な処理を ctf_type_size() に追加:

	case CTF_K_STRUCT:
	case CTF_K_UNION: {
		ssize_t align;

		if ((size = ctf_get_ctt_size(fp, tp, NULL, NULL)) == CTF_ERR ||
		    (align = ctf_type_align(fp, type)) == CTF_ERR)
			return (CTF_ERR); /* errno is set for us */

#define PADDING_SIZE(s, a) ((a - (s % a)) % a)
		return (size + PADDING_SIZE(size, align));
	}

再び修正版ソースから libctf.so をビルドして、動作確認をしてみると....動いた!これまた、期待通りの sizeof() 算出結果に!

pragma 対応の確認

一応念のため、"pragma pack" 指定による影響の有無を確認することに。

pragma 系の処理は、dt_pragma.c で定義されている dt_pragma() 関数に集約されている。

で、この関数が認識する pragma は、テーブル登録されている以下のものだけ。

  • attributes
  • bindings
  • depends_on
  • error
  • ident
  • line
  • option

ということで、DTrace は "pragma pack" を認識していない模様。

バグ情報の公開

ってな話で、修正確認まで出来たので、パッチをdtrace-disucss に投げたのだけど、今一つ反応が悪い。うーむ、再現手順を補足しておくか。

OpenSolaris 勉強会の際に加藤さんからアドバイスされたので、bugzillabugster にも投げておくことに。bugzilla の方は登録即公開なので、既に参照可能なのだけど、bugster は投函した障害が一般公開されるまでに時間がかかるのがちょっとなぁ。

と思ったら、"同じ障害が二重報告されているから、bugzilla の方は close するよ"(意訳)ということで、bugster の方に一本化されてしまった。

※ 2010/04/30 追記

そして、bugster の方は思いの外、素早く参照可能になっていた。あれ?こんなに早く公開されてたっけ?