パスワードを忘れた? アカウント作成
337121 journal

Yak!の日記: C における一時オブジェクトの生存期間 6

日記 by Yak!

Twitter 上で C++ STL の vector に関して評価順序不定ではまっているコードの例が流れていてそこから C 言語における(規格上の)落とし穴に行き着いたのでメモ。

// from http://www.jpcert.or.jp/sc-rules/c-exp35-c.html
#include <stdio.h>

struct X { char a[6]; };

struct X addressee(void) {
  struct X result = { "world" };
  return result;
}

int main(void) {
  printf("Hello, %s!\n", addressee().a);
  return 0;
}

上記コードは C++ では問題ないコードであるが、一方 C99 では未定義動作を含むコードである。理由はJPCERT のページに記述されているが、

一時オブジェクトの生存期間(という説明は C の規格上はされていないが)が次の副作用完了点までだと規定されているからである。この場合 printf 呼び出しの「前」が次の副作用完了点になる。

9899:1999 6.5.2.2 Function calls / 5
(snip) If an attempt is made to modify the result of a function call or to access it after the next sequence point, the behavior is undefined.

一方 C++ では full-expression の最後まで、と規定されているため printf 呼び出し「後」、となり問題がない。

14882:2003 12.2 Temporary objects / 3
(snip) Temporary objects are destroyed as the last step in evaluating the full-expression (1.9) that (lexically) contains the point where they were created. This is true even if that evaluation ends in throwing an exception.

この点は標準化委員会でも認識されている(Lifetime of temporaries)。この文書ではさらに嫌らしい例が挙げられていて

int f()
{
    return 'i';
}

int main()
{
    printf("%c%c!\n", salutation().a[0], f());
    return 0;
}

salutation() と a[0] の間に f() 呼び出しという副作用完了点が入るかもしれないというものである。

これに対して修正提案がされており(Extending the lifetime of temporary objects)、DIS(n1570) では以下のような少々無理矢理感がある記述となっている。

n1570 6.2.4 Storage durations of objects / 8
A non-lvalue expression with structure or union type, where the structure or union contains a member with array type (including, recursively, members of all contained structures and unions) refers to an object with automatic storage duration and temporary lifetime. Its lifetime begins when the expression is evaluated and its initial value is the value of the expression. Its lifetime ends when the evaluation of the containing full expression or full declarator ends. Any attempt to modify an object with temporary lifetime results in undefined behavior.

さて、もう一点補足しておくと、JPCERT のページでは gcc で -std=c99 オプションなしだと落ちるとの記述があるが実はこの問題のせいで落ちているわけではない(未定義動作であることに変わりはないが)。-Wallで出る警告も

eval.c:11: warning: format '%s' expects type 'char *', but argument 2 has type 'char[6]'

と書式指定文字列の不一致に対する警告である。これは配列がポインタに decay されずにそのまま配列として渡されているからだ(6 バイトスタックに積まれている)。この挙動は GCC マニュアルの 6.22 Non-Lvalue Arrays May Have Subscripts にも簡単に記述されている。C99 では右辺値の配列もポインタに decay されるが C89 では左辺値の配列のみに限定されている(らしい。C89 は規格を持っていないので引用できない)。ということで C99 に対応していないはずの VC ではコンパイルされないという挙動が正しいはずである。実際に Comeau C++ では C89/90 モードではコンパイルできずに(error: invalid use of non-lvalue array) C99 モードならばコンパイルできる。

この議論は賞味期限が切れたので、アーカイブ化されています。 新たにコメントを付けることはできません。
  • by Anonymous Coward on 2011年06月19日 20時14分 (#1972743)

    私なりにこの問題点を説明させていただきます。間違っていたらごめんなさい。結論から言うとメモリ領域を本来の意味から逸脱した状態で使っている瞬間があるからだと考えています。

    スタックは下に向かって伸びていくとしますので、スタックポインタより上が使用中、下が未使用と考えてください。

    1.addressee()に入った時にresultはスタック上に確保され初期化される。

    +--------------+
    |return address|←addressee()を呼び出した場所への戻り
    +--------------+
    |<-result      |
    |  a[world\0]  |
    +--------------+←スタックポインタ

    2.addressee()で使った領域を破棄し呼び出し元に戻る。

    +--------------+←スタックポインタ
    |return address|
    +--------------+
    |<-result      |
    |  a[world\0]  |
    +--------------+

    3.addressee()から返された←resultのアドレスからa[]のアドレスを求める。

    4.printf()を呼び出す為にスタックに引数を積む。左側が参考用で右側が実際の様子。

    +--------------+    +--------------+
    |return address|    |  argument 2  |←a[world\0]のアドレス
    +--------------+    +--------------+
    |<-result      |    |  argument 1  |←"Hello, %s!\n"のアドレス
    |  a[world\0]  |    +--------------+←スタックポインタ
    +--------------+

     この後printf()から戻る為のアドレスをスタックに積んでprintf()を実行しますが、a[world\0]の内容は既に破壊されているので期待通りに動きません。

     これはスタックポインタによりa[world\0]の領域が守られていないからですが、この状態はreturn result;の結果としてaddressee()の範囲を抜け}が実行された時から始まっています。なのでprintf()の引数を積まなくとも割り込み等でスタックが使用されるとa[world\0]の領域は壊れてしまいます。「さらに嫌らしい例f()」も同じ効果がありますし数値演算ライブラリを呼び出す計算なんかも同じだと思います。
     処理系の実装に大きく依存しますので、割り込みスタックが別にあったとしても}の後はa[world\0]の領域は本来の意味から逸脱しているので無効と考えるべきでしょう。

    struct X addressee(void) {
        struct X result = { "world" };
        return result;
    }

    同じ着眼点で適合コード [jpcert.or.jp]を見ると

      struct X my_x = addressee();

    の部分で、addressee()で使ったスタックが破棄されてから、struct X my_xに内容がコピーされ終わるまでのわずかな時間は本来の意味から逸脱して使っているので、これも間違いだと思います。

    この間に割り込みが起きてスタックが破壊されたなら、極めて対処のし辛いバグとして苦労する事になると思います。

    • 間違いだと思います。
      addressee を内部的に次のような形に変形し、
          void addressee(struct X *r) {
                struct X result = { "world" };
                *r = result;
          }
      呼び出し側が代入先オブジェクトへのポインタをこっそり渡せば
      きちんとスタック内で完結するコードを生成可能だからです。
      実際 cygwin の gcc (GCC) 4.3.4 20090804 (release) 1 はそのようなコードを生成しました。

      問題は呼び出し側がどう一時変数を用意するかという点にあります。
      例えば次のような関数呼び出しがあったとき、
          void foo(void)
          {
              printf("%s%s", addressee().a, addressee().a);
          }

      gcc拡張の書き方になりますが、次のような形に変換されるかもしれませんし、
          void foo(void)
          {
              /* gcc では ({ 文* }) という書き方で 文を式に変換できます。
                    式の値は内部の最後の式の値です。*/
              printf("%s%s",
                            ({ struct X tmp1; addressee(&tmp1); tmp1.a; }),
                            ({ struct X tmp2; addressee(&tmp2); tmp2.a; }));
          }

      次のように変換されるかもしれません。
          void foo(void)
          {
                struct X tmp1;
                struct X tmp2;
                addressee(&tmp1);
                addressee(&tmp2);
                printf("%s%s", tmp1.a, tmp2.a);
          }

      前者のやり方では printf が呼び出される前に一時変数の寿命が尽きますが、
      後者のやり方ではfoo()の終わりまで一時変数は生きています。
      Cコンパイラはどちらの変換を行ってもかまいません(未定義ですから)。

      適合コードに関しては
          struct X my_x = addressee();

          struct X my_x;
          addressee(&my_x);
      と変換するようなCコンパイラなら割り込みが入っても問題なく実行できます。
      親コメント
      • プログラムを変えちゃったら言語仕様の話にも、コンパイラの実装方法の話にも、根本原因の話にもならないじゃないですか。

        たぶん現行仕様での対応方法は皆判っていて、その上で自動変数の扱いをどの様に拡張したらC++の様に使えて、この様な使い方が誤りでなくなるのかと言う悩みだと思います。

        • いえ、不具合の修正方法の話でも言語仕様の拡張の話でもなく
          「元AC(#1972743)で述べられているスタック操作がおかしい」
          という話をしたつもりでした。
          つまりコンパイラの実装方法の話です。

          「スタックポインタの下は揮発性」という環境下で
          「addressee がスタックポインタの下を指すポインタを返す」というコードを吐くコンパイラは
          間違ってるのではないかと思います。
          親コメント
    • コメント付いているのに気付いてませんでした。

      その説明は誤りだと思います。2. の時点で既に領域が無効だ、という説明かと思いますが、その場合、同様の議論が struct ではなく int に対しても成立し以下のコードも誤りとなってしまいます(3. で持ってくるのはアドレスではなく値になりますが領域自体が無効なら議論は同じです)。

      int addressee(void) {
        int result = 5;
        return result;
      }

      int main(void) {
        printf("Hello, %d!\n", addressee());
        return 0;
      }

      さすがにこのコードが誤りだと関数の戻り値は全て使えないことになってしまいます。確かに result は関数内で確保されていますが値返しで返ってくるため再度スタック上に積まれて呼び出し側でも有効な状態で返ってきます。問題はそのスタック上の値がいつまで有効か、です。C99 規格上では次の副作用完了点まで有効ですので以下のコードは未定義動作を含みません(次の副作用完了点は printf 呼び出しなのでその前に a[0] の評価は完了する)。嫌な例でわざわざ f() 呼び出しが入っているのは別の副作用完了点を入れて問題を起こすため、です。

      int main()
      {
          printf("%c%c!\n", addressee().a[0]);
          return 0;
      }

      親コメント
  • by greentea (17971) on 2011年06月19日 21時17分 (#1972767) 日記

    > 実はこの問題のせいで落ちているわけではない(未定義動作であることに変わりはないが)。

    ということで、

    printf("Hello, %s!\n", &(addressee().a[0]));

    としてみたら、-Wallつけても何も出なくなって、実行しても落ちなくなりました。
    未定義動作であることには変わりないと思いますが……

    --
    1を聞いて0を知れ!
typodupeerror

目玉の数さえ十分あれば、どんなバグも深刻ではない -- Eric Raymond

読み込み中...