デバッグの題材:「型変換の不具合」

一応、前半「DLL をデバッグする 〜原因究明変〜」からの続きです。例に用いているのは、次の修正です:

  • 命令の引数の型を変換する際の不具合を修正 (r189)

後半「〜バグ修正編〜」では、前半で突き止めた原因事象がなぜ起こるのか調べて、その部分を修正していきます。

呼び出し履歴

前回調べたことを再確認しておきます。「乱数初期化」命令の実態 sys_randomize 関数を実行した時点で既に渡された引数の中身は nil だらけで、ステップ実行・対称実験の結果からこの nil が原因でエラーを発生しているようです。 nil チェックなどを行えばエラーは回避できるかもしれませんが、それでは本質的な解決にならないので、「 nil だらけ」を引き起こしている本質的な原因を探す必要があります。

しかし、実行された時点で既におかしな値が入っているということは、この現象を引き起こしているのはもっと前の段階で実行しているコードということになります。いったいどうやって今まで実行したコードを辿ればいいのでしょうか?

答えは呼び出し履歴です。デフォルトのデバッグビューでは、呼び出し履歴ペインが左上に表示されています。言語や IDE によって色々異なると思いますが、実行スレッドからツリー状に表示されていて辿れるものなどもあります。

例えば、 sys_randomize 関数にブレークポイントを設定して実行を中断したとします。

呼び出し履歴に、今まで関数(処理)をどのように実行してきたのかがリストアップされる。

一番上が sys_randomize それ自身、つまり最も若い関数です。このように、実行履歴が下から古い順に表示されます。

  1. hima_function.sys_randomize
  2. hima_parser.TSyntaxFunction.callSysFunc
  3. hima_parser.TSyntaxFunction.getValue
  4. hima_system.THiSystem.RunNode
  5. …(以下略)

この実行履歴を辿って検証すれば、どこで nil だらけが発生したのか分かりそうです。一応上の実行履歴の意味を考えてみると、「構文木実行」→「文(命令)を実行(して値を取得)」→「命令を実際に実行(することで値を取得)」→「乱数初期化」という流れのようです。

hima_parser

まずは sys_randomize を呼び出している callSysFunc から調べていきましょう。実行履歴の2行目をダブルクリックすると、 callSysFunc の行にジャンプできます。

function TSyntaxFunction.callSysFunc: PHiValue;
var
  a: THiArray;
begin
  a := getArgStackToArray;
  try
    Result := THimaSysFunction(HiFunc.PFunc)(a);
  finally
    //a.ClearNotFree;
    a.Free ;
  end;
end;

これだけでした。やはり何もおかしな所はなさそうですが・・・? 1 行目の a := getArgStackToArray; にブレークポイントを設定して実行してみます。

ブレークしたら、まずローカル変数ペインもしくは監視式ペインで Self.FDebugFuncName を調べます。

Self.FDebugFuncName '乱数初期化'

もし '乱数初期化' という文字列が見つかれば、今なでしこの「乱数初期化」命令を実行しているということです。実行しているなでしこのプログラム(test.nako)の一番最初に「乱数初期化」命令があったので運良く目的の所で中断できましたが、いつもそうとは限りません。 callSysFunc は他のシステム関数を実行する時にも呼び出されるので、目的の段階でブレークしていないかもしれないわけです。

的確にブレークするには、「ブレークポイントの設定」で細かいブレーク条件を設定するか、監視式を設定するなどしてコードを追うのが得策です。 hima_parser.pas の場合、このように、 Self.FDebugFuncName を調べることで今どの命令を実行中か分かるので、それを活用しようというわけです。

さて、本題。一番最初に getArgStackToArray という命令で引数を取得してきているようです。引数・・・怪しいですね。トレース実行(F7)して注意深く見ていきます。

function TSyntaxFunction.getArgStackToArray: THiArray;
var
  res: THiArray;
  arg: THimaArg;
  i: Integer;
  n: TSyntaxNode;
  v, tmp: PHiValue;

  procedure _getDefaultValue;
  begin
    // 省略された場合:初期値があるか?
    if arg.Value.VType <> varNil then
    begin
      // 参照渡し/値渡しに関係なくデータ自体をコピーして渡す
      // 省略値が壊されないように注意
      v := hi_var_new;
      hi_var_copyData(arg.Value, v);
      // 引数に名前をつける
      v.VarID := arg.Name;
      // 型をチェック
      hi_var_ChangeType(v, arg.VType);
      res.Values[i] := v;
    end else
    begin
      // 省略されたが初期値はない
      // そのまま nil を返す
      res.Values[i] := nil;
    end;
  end;

  procedure _getGroupValue;
  begin
    if not (n is TSyntaxValue) then raise Exception.Create('指定された引数の型と合わない型が指定されてます。');
    // グループを取得
    tmp := TSyntaxValue(n).GetValueNoGetter(False);
    v   := makeArgVar(tmp, True);
    // 引数に名前をつける
    v.VarID := arg.Name;
    // 型をチェック ... 不要
    // hi_var_ChangeType(v, arg.VType);
    res.Values[i] := v;
  end;

begin
  res := THiArray.Create;
  res.ForStack := True;

  if Stack = nil then begin Result := res; Exit; end;
  // stack の値を配列に取得
  for i := 0 to Stack.Count - 1 do
  begin
    arg := HiFunc.Args.Items[i];
    n   := Stack.Items[i];

    // スタックにある構文木 n を実行し結果を 引数 res.Value[i] にコピーする

    // (1) 引数が省略されているときの処理
    if n = nil then
    begin
      _getDefaultValue;
      Continue;
    end;

    // (2) 引数を実行

    // 例外...グループが引数に指定されているときは、デフォルト引数を参照しない
    if arg.VType = varGroup then
    begin
      _getGroupValue;
      Continue;
    end;

    // 通常の引数取得
    tmp := HiSystem.RunNode(n);
    // 引数に乗せるために変数を複製する(そのまま乗せると、引数開放のときにデータ自体が始末されてしまうため)
    v := makeArgVar(tmp, arg.ByRef);

    if (tmp <> nil)and(tmp.Registered = 0)and(arg.ByRef = False) then
    begin
      hi_var_free(tmp);
    end;
    
    // 名前をつける
    if v <> nil then v.VarID := arg.Name;
    // 型をチェック
    hi_var_ChangeType(v, arg.VType);
    // 引数配列に代入
    res.Values[i] := v;

  end;
  Result := res;
end;

長いですね。。。しかしコメントが細かく入っているので、内容は分かりやすいです。一通り目を通して重要な部分をまとめてみると、

  • 構文木のスタックから必要な(実)引数を持ってくる処理
    1. (仮)引数へのコピー・型チェックを行う(複製データはv)
    2. 配列 resi 番目に v をセット
    3. 最終的に、 Result := res;
  • 今知りたいのは、なぜ(仮)引数が nil だらけになるのかということ

監視式

ということで、データを複製する細かい処理のどこかで nil だらけを引き起こしていないか、ステップ実行しながら見ていきます。そのため、引数を扱っているデータ v を常に見ていたいのですが、ローカル変数ペインでは、ステップ実行する度にクリックして展開しないと v の細かい内容が分からず不便です。

そこで、監視式ペインに v を設定します。すると、ローカル変数ペインと同じように v の内容が表示されます。ステップ実行しても監視式ペインのツリーは勝手に閉じたりしないので、文字通り「監視」するのに便利です。監視式には、値を返す Delphi のコードなら何でも書くことができます。例えば、 v.VTypev.ptr_s などです。

監視ペインで v.VType などの値を監視する。

さて、監視しながら慎重にステップ実行していきましょう。すると、最初に v に値を代入した後は、ちゃんと v.ptr_s に文字列 'A' が入っています。 sys_randomize のときはここが nil でした。

続けてみます。5784行目の hi_var_ChangeType 命令を実行した直後、 v.ptr_s が nil になりました。 v の他のプロパティも同様です。ほとんど 0 とか nil になっています。

ということで、根気よく今度は hi_var_ChangeType にステップインです。なぜこれを実行した後に nil だらけになるのでしょう。

procedure hi_var_ChangeType(var v: PHiValue; vType: THiVType); // 変数の型を変換する
var
  tmp: PHiValue;
begin
  // 変換不可能ならエラー
  if v = nil then v := hi_var_new;

  // リンク先を得る
  tmp := hi_getLink(v);

  // 型チェック
  case vType of
    varNil      : ; // 何も変換しない
    varInt      : tmp^.int := hi_int(tmp);
    varFloat    : hi_setFloat(tmp, hi_float(tmp));
    varStr      : hi_setStr(tmp, hi_str(tmp));
    varPointer  : tmp^.ptr := Pointer(hi_int(tmp));
    varFunc     : if tmp^.VType <> varFunc then raise Exception.Create('関数型に変換できません。');
    varArray    : hi_ary_create(tmp);
    varHash     : hi_hash_create(tmp);
    varGroup    : hi_group_create(tmp);
    varLink     : ; //if v^.VType <> varLink then raise Exception.Create('リンク型に変換できません。');
  end;
end;

処理 hi_var_ChangeType の引数 vType は変換したい型です。「乱数初期化」命令の引数 A は、登録時に {整数} と型指定されているため、 vType は varInt になっています。そして、なでしこの引数 A 、すなわち複製用データ v を整数型に変換しようとします: tmp^.int := hi_int(tmp);

ところがローカル変数ペインで確認すると、変換後の変数型 tmp^.VType が文字列 varStr のままです。よく見てみると、コードでは tmp^.int に変換した整数の値を代入していますが、整数に変えましたよ、と明示していないようです。

そのせいで、文字列型と言っているのに中身は整数型(文字列用の領域が nil になっている)という変な箱ができました。 nil だらけの文字列変数が引数として渡されているように見えたのは、こういうわけです。だから sys_randomize でもう一度「文字列→整数」変換をしようとして失敗し、エラーを出したと。

ということは、きちんと「整数型に直しましたよ」と明示すれば問題ないはずです。修正してみましょう。

    varInt      : begin
      tmp^.int   := hi_int(tmp);
      tmp^.VType := varInt;
    end;

コンパイルして、実行してみると、エラーなく実行できました!

今までブレークポイントを多用してきたので、普通に実行すると色々引っかかってしまい面倒なので、エラーなしに実行できる自信があれば「デバッグなしで実行」(Shift + Ctrl + F9)してみるのもいいでしょう。また、ブレークポイントは無効/有効をチェックで切り替えることもできます。

まとめ

今回は、前回同様ステップ実行を駆使しつつ、「呼び出し履歴」と「監視式」の紹介も入れてみましたが、どちらもコードを追う作業を円滑に進めるための道具に過ぎません。デバッグというのは、文字通りバグを取り除くことですが、しかしそのためにはまずバグを見つけないといけません。バグ修正の 99% は、細かいステップ実行とローカル変数のチェックなどの根気が必要なコードを追うという作業なのです。

そんなわけで、前回からひたすら追って追って追っての繰り返しでした。地道な作業でしたね。。。あと、長過ぎた前回の続きということで「修正編」と銘打ちましたが、実際には「修正する」というステップなんてほとんどありませんでしたね。普通に前編・後編というタイトルにすればよかったと少し反省中。

ところで、これまで頑張って解説を書いてきた Turbo Delphi ですが、なんとぽっくりと無償配布を終了してしまいました(ナ、ナンダッテー)。せっかく頑張って Turbo Delphi を基準に色々書いてたのに……。まぁ、これからもなでしこのバグ修正等 Turbo Delphi でやっていきたいので、また何かできたらここに書いていこうと思います。

関連・参考リンク