2022年12月2日

レコード型のフィールドのオフセットを取得する

このアーティクルはDelphi Advent Calendar 2022の2日目の記事です(3年ぶり7回目)。

Delphiのレコード型(Cの構造体に相当)は、スタックに配置することができるということ以外にも、特定のメモリレイアウトを定義することができることから、固定長ファイルや指定されたメモリ上のデータを解釈するために使われることがあります。このような場合に、定義したレコード型の特定のフィールドのオフセットが(主にデバッグ用に)欲しくなったりするのですが、Delphiには(SizeOfはあるのに)C/C++のoffsetofマクロ(stddef.h)のようなものが存在しません(RSP-39559)。そこで軽く検索してみたところStack Overflowにそのものずばりな投稿がありました。

delphi - Get Position of a struct var AKA Offset of record field - Stack Overflow
Delphi: Offset of record field - Stack Overflow

ではちょっと試してみましょう。まずレコード型とそのポインタ型を定義します。
type
  TFoo1 = record
    Bar: Boolean;
    Baz: Double;
    Qux: array [0..8] of Byte;
    Quux: Integer;
  end;
  PFoo1 = ^TFoo1;
これで
procedure TForm1.Button1Click(Sender: TObject);
begin
  Memo1.Lines.Add(Format('TFoo1: SizeOf=%d',[SizeOf(TFoo1)]));
  Memo1.Lines.Add(Format('TFoo1.Bar: offset=%d',[NativeUInt(@(PFoo1(nil)^.Bar))]));
  Memo1.Lines.Add(Format('TFoo1.Baz: offset=%d',[NativeUInt(@(PFoo1(nil)^.Baz))]));
  Memo1.Lines.Add(Format('TFoo1.Qux: offset=%d',[NativeUInt(@(PFoo1(nil)^.Qux))]));
  Memo1.Lines.Add(Format('TFoo1.Quux: offset=%d',[NativeUInt(@(PFoo1(nil)^.Quux))]));
end;
のように、nilをレコード型へのポインタにキャスト→フィールドを参照→そのアドレスを取得→整数に変換とすることで、そのフィールドのオフセット値を取得できます(アドレスと同じサイズの符号なし整数はNativeUInt型)。では実行してみましょう。

TFoo1: SizeOf=32
TFoo1.Bar: offset=0
TFoo1.Baz: offset=8
TFoo1.Qux: offset=16
TFoo1.Quux: offset=28
Delphiのレコード型のデフォルトのアライメントマスクに従って配置されていることがわかります(TFoo1.QuuxのオフセットがDelphiのデフォルトのフィールドのアライメント(構造体アライメント)の8バイトに従った32ではなく、型(Integer)のサイズである4バイトで28になっていることに注意)。
しかし最初に書いたように、特定の(外部で規定された)メモリレイアウトに簡単にアクセスできるようにするためにレコード型を使用する、という目的を考えると、レコード型にはpackedを指定して、明示的にパディングするような場合が多いと思われます。ではレコード型をpacked recordにしてみましょう。
type
  TFoo2 = packed record
    Bar: Boolean;
    Baz: Double;
    Qux: array [0..8] of Byte;
    Quux: Integer;
  end;
  PFoo2 = ^TFoo2;

procedure TForm1.Button2Click(Sender: TObject);
begin
  Memo1.Lines.Add(Format('TFoo2: SizeOf=%d',[SizeOf(TFoo2)]));
  Memo1.Lines.Add(Format('TFoo2.Bar: offset=%d',[NativeUInt(@(PFoo2(nil)^.Bar))]));
  Memo1.Lines.Add(Format('TFoo2.Baz: offset=%d',[NativeUInt(@(PFoo2(nil)^.Baz))]));
  Memo1.Lines.Add(Format('TFoo2.Qux: offset=%d',[NativeUInt(@(PFoo2(nil)^.Qux))]));
  Memo1.Lines.Add(Format('TFoo2.Quux: offset=%d',[NativeUInt(@(PFoo2(nil)^.Quux))]));
end;
実行してみます。

TFoo2: SizeOf=22
TFoo2.Bar: offset=0
TFoo2.Baz: offset=1
TFoo2.Qux: offset=9
TFoo2.Quux: offset=18
意図通り、すべてのフィールドがパディングされることなく並んでいることがわかります。

逆アセンブルを見ると、コンパイラは各メンバのオフセットを知っててnil(=0)に加算しているので、最初からこれをもらう方法があれば…とは思います。また上記のStack Overflowの2番目の投稿にはRTTIを使った方法も紹介されていますが、RTTIのテーブルでループを回しながら文字列の比較をする必要があるため、それに比べればnilを使った方法のほうが優れていると考えられます。

ところで、packedを指定せずデフォルトのアライメントマスクに従って配置された場合、順序型はそのサイズでアライメントされる、との記述があります。つまり
type
  TFoo3 = record
    Bar: array [0..8] of Byte;
    Baz: Boolean;
    Qux: array [0..8] of Byte;
  end;
  PFoo3 = ^TFoo3;
のように1バイトアライメントされている配列のフィールドの次に1バイトの順序型のフィールドがあると、デフォルトのアライメントの8バイトに従ったパディングが行われることなく、連続した配置になる、ということになります(逆も同じ)。
procedure TForm1.Button3Click(Sender: TObject);
begin
  Memo1.Lines.Add(Format('TFoo3: SizeOf=%d',[SizeOf(TFoo3)]));
  Memo1.Lines.Add(Format('TFoo3.Bar: offset=%d',[NativeUInt(@(PFoo3(nil)^.Bar))]));
  Memo1.Lines.Add(Format('TFoo3.Baz: offset=%d',[NativeUInt(@(PFoo3(nil)^.Baz))]));
  Memo1.Lines.Add(Format('TFoo3.Qux: offset=%d',[NativeUInt(@(PFoo3(nil)^.Qux))]));
end;
実行してみると、

TFoo3: SizeOf=19
TFoo3.Bar: offset=0
TFoo3.Baz: offset=9
TFoo3.Qux: offset=10
と、確かにその通りになっています。またDelphi 2007までは型仕様が共通のフィールド(A, B: Extended;のように複数のフィールドが","で並べられて同じ型が指定されているフィールド)は暗黙にpackedとなる、という仕様も存在していました(いま知りました)。

このようなことから、レコード型を使用するときは、packedを指定せず完全にコンパイラにお任せにして特定のメモリレイアウトを必要としないようにするか、packedを指定してパディングも明示的に配置して特定のメモリレイアウトになるようにコーディングするか、どちらかにするべきだと考えられます(この結論そのものは当たり前すぎるものですが)。

なおpackedを指定してメモリレイアウトを完全に自分で制御する場合など、定義したレコード型が正しいサイズになっているかどうかは
{$IF SizeOf(TFoo2) <> 22}
{$MESSAGE ERROR 'SizeOf(TFoo2) is not 22 bytes.'}
{$IFEND}
のようにコンパイル時にチェックすることができます(Delphi 11.2 Alexandriaだとこれを書いた瞬間にLSPで判定が行われるため、{$MESSAGE}の行がエラー表示になるか淡色表示になるかでわかりますが)。

0 件のコメント: