C言語の本質(3):構造体と共用体

構造体と共用体

逆アセンブリ手法を使用して、C 言語の構造を学習してみましょう。

例:

      #include <stdio.h>
      int main(void)
      {
          struct {
                  char a;
                  short b;
                  int c;
                  char d;
          } s;
          s.a = 1;
          s.b = 2;
          s.c = 3;
          s.d = 4;
          printf("%u\n", sizeof(s));
          return 0;
      }

main 関数内のいくつかのステートメントの逆アセンブリ結果は次のとおりです。

          s.a = 1;
      80483ed:   c6 44 24 14 01          movb   $0x1,0x14(%esp)
          s.b = 2;
      80483f2:   66 c7 44 24 16 02 00   movw   $0x2,0x16(%esp)
          s.c = 3;
      80483f9:   c7 44 24 18 03 00 00   movl   $0x3,0x18(%esp)
      8048400:   00
          s.d = 4;
      8048401:   c6 44 24 1c 04          movb   $0x4,0x1c(%esp)

構造体のメンバーにアクセスするための命令から分かるように、スタック上の構造体の 4 つのメンバーの配置は図 18.5 に示されています。

スタックは上位アドレスから下位アドレスに成長します構造体のメンバーも下位アドレスから上位アドレスに配置され、これは配列と同様です。

しかし、配列とは異なる点が 1 つあります。構造体のメンバーは隣り合って配置されていません。中央にはパディングと呼ばれる隙間があります。それだけでなく、構造体の最後には 3 バイトのパディングもあります。構造体なので、sizeof(s) の値は 12 です。

コンパイラはなぜこれを行うのでしょうか? これまで避けてきた知識が 1 つあります。ほとんどのコンピューター アーキテクチャでは、メモリにアクセスする命令に制限があります。32 ビット プラットフォームでは、命令が 4 バイトにアクセスする場合 (上記の movl など)、開始メモリのアドレスは次のようになります。 4 の整数倍になります。命令が 2 バイトにアクセスする場合 (上記の movw など)、開始メモリ アドレスは 2 の整数倍でなければなりません。これはアラインメント (Alignment) と呼ばれ、1 バイトにアクセスする命令 (たとえば、上記の movb) には位置合わせ要件はありません。

命令によってアクセスされたメモリ アドレスが正しくアライメントされていない場合はどうなりますか? 一部のプラットフォームではメモリにアクセスできず例外が発生しますが、x86 プラットフォームではメモリにアクセスできますが、アライメントされていない命令の実行効率はアライメントされた命令よりも低いため、コンパイラはさまざまな変数のアドレスを配置する際の考慮事項、アライメントの問題。

この例の構造の場合、コンパイラはベース アドレスを 4 バイト境界に合わせます。つまり、アドレス esp+0x14 は 4 の整数倍でなければなりません。

  • sa は 1 バイトを占有するため、位置合わせの問題はありません。

  • sb は 2 バイトを占めます。sb が sa のすぐ後ろにある場合、そのアドレスは 2 の整数倍にはなり得ないため、コンパイラは sb のアドレスが 2 の整数倍になるように構造内にパディング バイトを挿入します。

  • アドレス esp+0x18 も 4 の整数倍であるため、sc は sb のすぐ後ろの 4 バイトを占めます。

なぜ sd の後ろに 4 バイト境界までパディング ビットが必要なのでしょうか? これは、この構造体の後ろに変数アドレスを配置する便宜のためです。このタイプの構造体で配列を形成する場合、前の構造体の最後には 4 バイト境界にアラインされたパディング バイトがあるため、後者の構造体のみが必要になります。前の構造の隣に配置されます。

実際、C 標準では、各要素のアドレスが「ベース アドレス + n × 各要素のバイト数」に従って簡単に計算できるように、配列要素を隙間なく並べて配置する必要があると規定しています。

構造体のメンバーの配置順序を合理的に設計することで、記憶領域を節約できます。上記の例の構造体を次のように変更すると、パディング バイトの生成を回避できます。

      struct {
              char a;
              char d;
              short b;
              int c;
      } s;

さらに、gcc は、構造体のパディング バイトを排除するための拡張構文を提供します。

      struct {
              char a;
              short b;
              int c;
              char d;
      } __attribute__((packed)) s;

ただし、この方法では構造体メンバの位置合わせが保証されず、b、c へのアクセス時に効率が悪くなったり、アクセスできなくなったりする可能性があるため、特別な理由がない限り、通常はこの構文を使用しないでください。

前に説明したデータ型は少なくとも 1 バイトを占有し、ビット フィールド構文を構造内で使用して、数ビットしか占有しないメンバーを定義することもできます。次の例は、Wang Cong の Web サイト (http://www.wangcong.org/) からのものです。

ビットフィールド

      #include <stdio.h>
      typedef struct {
              unsigned int one:1;
              unsigned int two:3;
              unsigned int three:10;
              unsigned int four:5;
              unsigned int :2;
              unsigned int five:8;
              unsigned int six:8;
      } demo_type;
      int main(void)
      {
              demo_type s = { 1, 5, 513, 17, 129, 0x81 };
              printf("sizeof demo_type = %u\n", sizeof(demo_type));
              printf("values: s=%u,%u,%u,%u,%u,%u\n",
                    s.one, s.two, s.three, s.four, s.five, s.six);
              return 0;
      }

s 構造体のレイアウトを図 18.6 に示します。

ビットフィールドも整数型であり、符号付きか符号なし数値を示す int または unsigned int で宣言できますが、通常の int 型のように 4 バイトを占有せず、コロンの後の数字はこのビットフィールドが占有していることを示します少し。上の例の unsigned int :2; は、2 ビットを占める名前のないビットフィールドを定義します。名前のないビットフィールドが書き込まれていなくても、コンパイラは 2 つのメンバーの間にパディング ビットを挿入することがあります (たとえば、図 18.6 では 5 と 6 の間にパディング ビットがあるため、6 つのメンバーは 1 バイトを占有するだけです)。アクセス効率が高くなるため、この構造体の最後に 3 バイトが埋め込まれて 4 バイト境界に揃えられます。x86 のバイト オーダーはリトル エンディアンであると前に述べましたが、図 18.6 の 1 と 2 の配置順序から、バイトがさらに細分化されると、バイト内のビット オーダーもリトル エンディアンであることがわかります。ボディの先頭のメンバ(下位アドレス側に近いメンバ)がバイトの下位ビットを取る構造になっています。ビット フィールドの配置方法は、C 標準では明確に規定されていません。これは、バイト オーダー、ビット オーダー、アライメントなどの問題に関連しています。プラットフォームやコンパイラが異なれば、ビット フィールドの配置方法も大きく異なる可能性があります。移植可能なコードを記述することは不可能です。ビットフィールドが特定の固定された方法で配置されていると仮定します。多くの場合、デバイス レジスタ内の 1 つまたは複数のビットを個別に操作する必要があるため、ビット フィールドはドライバーで非常に便利ですが、慎重に使用する必要があります。最初に各ビット フィールドとデバイス内の各ビットの対応を理解する必要があります。レジスタ関係。

上の例では、分解結果を示さずに、この構造の配置はこうですと直接絵を描いたのですが、何を根拠にそう言えるでしょうか?上記の例の逆アセンブリ結果はかなり複雑ですが、別の方法を使用してこの構造のメモリ レイアウトを取得できます。

コンソーシアム

      #include <stdio.h>
      typedef union {
          struct {
                  unsigned int one:1;
                  unsigned int two:3;
                  unsigned int three:10;
                  unsigned int four:5;
                  unsigned int :2;
                  unsigned int five:8;
                  unsigned int six:8;
              } bitfield;
              unsigned char byte[8];
      } demo_type;
      int main(void)
      {
              demo_type u = {
   
   { 1, 5, 513, 17, 129, 0x81 }};
              printf("sizeof demo_type = %u\n", sizeof(demo_type));
              printf("values: u=%u,%u,%u,%u,%u,%u\n",
                    u.bitfield.one, u.bitfield.two, u.bitfield.three,
                    u.bitfield.four, u.bitfield.five, u.bitfield.six);
              printf("hex dump of u: %x %x %x %x %x %x %x %x\n",
                    u.byte[0], u.byte[1], u.byte[2], u.byte[3],
                    u.byte[4], u.byte[5], u.byte[6], u.byte[7]);
              return 0;
      }

キーワード Union は、ユニオンと呼ばれる新しいデータ型を定義します。その構文は構造体に似ています。共用体の各メンバーは同じメモリ空間を占有し、共用体の長さは最も長いメンバーの長さに等しくなります。たとえば、u の共用体は 8 バイトを占めます。メンバー u.bitfield にアクセスすると、8 バイトはビットフィールドから構成される構造体とみなされ、メンバー u.byte にアクセスすると、8 バイトはセクションとして扱われます。配列。

共用体が Initializer で初期化される場合、その最初のメンバーのみが初期化されます (例、demo_type u ={ { 1, 5, 513, 17, 129, 0x81 }}; 初期化は u.bitfield であるため、u のみがわかります)ビットフィールド構造の各メンバーの値は何ですが、そのメモリ レイアウトがどのようなものであるかはわかりません。次に視点を変えると、同じ 8 バイトが u.byte 配列として見えることがわかります。各バイトがどのくらいの大きさで、メモリのレイアウトがどのように見えるか。

C99 の Memberwise 初期化構文を使用すると、次のように共用体の任意のメンバーを初期化できます。

      demo_type u = { .byte = {0x1b, 0x60, 0x24, 0x10, 0x81, 0, 0, 0} };

最後に、これまで説明したこれらの概念を確認してください。

  • 1. データ型の長さ (例: ILP32、LP64)
  • 2.呼び出し規約
  • 3. メモリアドレスにアクセスするためのアライメント要件
  • 4. 構造体とビットフィールドの充填方法
  • 5. バイトオーダー(ビッグエンディアン、リトルエンディアン)
  • 6. システムコールの実行命令と各種システムコールのパラメータ
  • 7. 実行可能ファイルおよびライブラリファイルの形式 (ELF 形式など)

これらは総称して、アプリケーション バイナリ インターフェイス仕様 (ABI、アプリケーション バイナリ インターフェイス) と呼ばれます。2 つのプラットフォームが同じアーキテクチャを持ち、同じ ABI に従っている場合、あるプラットフォーム上のバイナリ プログラムを別のプラットフォームに直接コピーできることが保証されます。再コンパイルせずに実行できるプラットフォーム。たとえば、2 台の x86 コンピューターがあり、1 台は PC、もう 1 台はネットブックで、それらに異なる Linux ディストリビューションがインストールされている場合、あるマシンから別のマシンにバイナリ プログラムをコピーすることもできます。同じアーキテクチャであり、オペレーティング システムも同じ ABI に従っています。

Linux と Windows という 2 つのオペレーティング システムが同じコンピュータにインストールされている場合、2 つのオペレーティング システムの ABI が異なるため、Windows システム上で Linux バイナリ プログラムを実行することはできません。

Cインラインアセンブリ

C でプログラムを作成することは、アセンブリで直接プログラムを作成するよりも簡潔で読みやすいですが、最新のコンパイラは十分な機能を備えていますが、結局 C プログラムはコンパイラを通じてアセンブリ コードを生成する必要があるため、効率はアセンブリ プログラムほど良くない可能性があります。最適化は行われますが、それでも手書きのアセンブリ コードほど優れたものではありません。

また、プラットフォーム関連の命令の一部は手作業で記述する必要があり、C 言語の概念はさまざまなプラットフォームを抽象化したものであり、各プラットフォームに固有のものの一部は C 言語には現れないため、C 言語には同等の構文がありません。 , 例えば、x86ではポートI/Oですが、C言語にはその概念がないため、in/out命令はアセンブリで記述する必要があります。

C 言語は、簡潔で読みやすく、大規模なコードを整理しやすく、アセンブリ効率が高く、一部の特殊な命令をアセンブリで記述する必要があるという 2 つの特徴を活かすために、gcc では拡張構文が提供されています。 Cコードで使用可能 インラインアセンブリ(Inline Assembly)を使用します。

最も単純な形式は __asm__("アセンブリ コード"); です。

たとえば、__asm__("nop"); の場合、nop 命令は何もせず、CPU を 1 命令実行サイクルの間アイドル状態にするだけです。

複数のアセンブリ命令を実行する必要がある場合は、各命令を \n\t で区切る必要があります。次に例を示します。

      __asm__("movl $1, %eax\n\t"
              "movl $4, %ebx\n\t"
              "int $0x80");

通常、インライン アセンブリは C コード内の変数に関連付ける必要があり、完全なインライン アセンブリ形式が使用されます。

      __asm__(assembler template
              : output operands                /* optional */
              : input operands                 /* optional */
              : list of clobbered registers   /* optional */
              );

この形式は 4 つの部分で構成されており、

  • 最初の部分は上記の例と同様に組み立て説明書です。
  • 2 番目と 3 番目の部分は制約で、アセンブリ命令の演算結果を C 言語のどのオペランドに出力するかをコンパイラに指示します。これらのオペランドは左辺値式である必要があります。
  • 3 番目の部分は、アセンブリ命令がどの C 言語オペランドから入力を取得する必要があるかをコンパイラーに伝えます。
  • 4 番目の部分はアセンブリ命令で変更されるレジスタ リスト (クロバー リストと呼ばれます) で、この __asm__ ステートメントの実行時にどのレジスタ値が変更されるかをコンパイラに指示します。

最後の 3 つの部分はオプションです。ある場合は入力してください。ない場合は、コロンを入力してください。たとえば、次のようになります。

      #include <stdio.h>
      int main(void)
      {
            int a = 10, b;
              __asm__("movl %1, %%eax\n\t"
                    "movl %%eax, %0\n\t"
                    :"=r"(b)       /* output */
                    :"r"(a)        /* input */
                    :"%eax"        /* clobbered register */
                    );
              printf("Result: %d, %d\n", a, b);
              return 0;
      }

このプログラムは変数 a の値を b に代入します。"r" (a) は、変数 a の値をアセンブリ命令の入力、つまり命令内の %1 として保存するためのレジスタを割り当てるようにコンパイラに指示します (制約の順序に従って、b は %0 に対応します)。 a は 1% に相当します)。どのレジスタ %1 が表すかはコンパイラ次第であるためです。

アセンブリ命令は、まず %1 で表されるレジスタの値を eax に渡します (プレースホルダ %1 と区別するために、eax の前に 2 つの % 記号が必要です)、次に eax の値を表されるレジスタに渡します。 %0 レジスタによって。「=r」(b)は、%0で表されるレジスタの値を変数bに出力することを意味します。

これら 2 つの命令の実行中に、レジスタ eax の値が変更されるため、4 番目の部分に「%eax」を記述して、この __asm__ ステートメントの実行時に eax が書き換えられることをコンパイラに指示します。そのため、ここでは eax を使用しないでください。期間中に他の値を保存します。

このプログラムの逆アセンブル結果を見てみましょう。

          __asm__("movl %1, %%eax\n\t"
      80483f5:   8b 54 24 1c         mov   0x1c(%esp),%edx
      80483f9:   89 d0               mov   %edx,%eax
      80483fb:   89 c2               mov   %eax,%edx
      80483fd:   89 54 24 18         mov   %edx,0x18(%esp)
                  "movl %%eax, %0\n\t"
                  :"=r"(b)       /* output */
                  :"r"(a)        /* input */
                  :"%eax"        /* clobbered register */
                  );

%0 と %1 は両方とも edx レジスタを表し、最初に変数 a (esp+0x1c の位置) の値を edx に渡し、次にインライン アセンブリの 2 つの命令を実行して、値を渡していることがわかります。 edx の b (esp+ 0x18 の位置) に。

参考文献

「Cプログラミングをワンストップで学べる」

おすすめ

転載: blog.csdn.net/weixin_45264425/article/details/132324809
おすすめ