ポインタとメモリと型(構造体)の関係 (2)

作成:

今回は、構造体のメモリ上のアライメントという非常に処理系依存のお話です。
初心者の人にとっては全く意味のない話かもしれませんし、 高度なプログラミングをするようになったとしても、 このような知識は必要ないという人もいるでしょう。
「へ~~そうなんだ」程度でいい知識といえます。
しかし、普段使っている記述法の実態がどうなっているかを知ることができれば、 一歩進んだ理解ができるのではないでしょうか。
・・・と言うスタンスです。
(ちなみにハードウェアに密着したプログラミングが必要になってくると、 処理の結果だけでなく処理の過程も理解する必要が出てきて結構重要な概念だったり)

では、実際の解説に入る前に、前置きとして各変数型のサイズは以下のようになっています。

charshortlonglong longlong double
124812

これは Cygwin 上の gcc での結果で Visual C++ を利用した場合 long double は 8Byte となります。
あと、 sizeof の結果は char いくつ分のサイズかを返す訳ですが、今回実験している環境だけではなく、 ほとんどの処理系では char は 8bit(1Byte)であるため(っていうかそうじゃない処理系ってあるのかなぁ)、 ここでは sizeof の返す値の単位を Byte であるとして話を進めています。
さらに、何度も書いていますがここでやっていることは、ほとんどが処理系依存で、 このような結果になることを前提にプログラムを書くと、非常に移植性の低いものになってしまいます。
また、同じ環境でもコンパイラの最適化の影響とかを受ける可能性もないわけではありません。

ハードウェア的にどうのといってる部分がいっぱいありますが、 正直なところ私はそっち方面の専門家ではないので、 ノリと勢いだけで理解していたりするため、 とんでもないことをいっている可能性があります。
変なところがあれば指摘してください。

構造体

今回説明するのは、構造体です。
構造体は複数の変数の集まりだと考えると、前回説明した配列に近いものがあります。 しかし、配列と違ってこの構造体の中にある変数の型は一定ではなく、様々なサイズ・型の変数が含まれる場合がほとんどです。
これらはメモリ上からみるとどのようにいるのでしょうか?
まず、以下のような構造体を定義します。

struct st1{
  char        v1;
  char        v2;
  char        v3;
  char        v4;
};
struct st2{
  char        v1;
  short       v2;
};
struct st3{
  char        v1;
  long        v2;
};
struct st4{
  char        v1;
  long long   v2;
};
struct st5{
  char        v1;
  long double v2;
};
struct st6{
  char        v1;
  char        v2[4];
};
struct st7{
  long        v1;
  char        v2;
};

これらについて sizeof をとり、それぞれのポインタの値について表示させてみます。 (下のコードでは構造体と同じ名前でインスタンスを宣言しています。)

printf("size of st1 : %d\n",sizeof(struct st1));
printf("pointer of st1    : %p\n",&st1);
printf("pointer of st1.v1 : %p\n",&st1.v1);
printf("pointer of st1.v2 : %p\n",&st1.v2);
printf("pointer of st1.v3 : %p\n",&st1.v3);
printf("pointer of st1.v4 : %p\n",&st1.v4);
printf("size of st2 : %d\n",sizeof(struct st2));
printf("pointer of st2    : %p\n",&st2);
printf("pointer of st2.v1 : %p\n",&st2.v1);
printf("pointer of st2.v2 : %p\n",&st2.v2);
printf("size of st3 : %d\n",sizeof(struct st3));
printf("pointer of st3    : %p\n",&st3);
printf("pointer of st3.v1 : %p\n",&st3.v1);
printf("pointer of st3.v2 : %p\n",&st3.v2);
printf("size of st4 : %d\n",sizeof(struct st4));
printf("pointer of st4    : %p\n",&st4);
printf("pointer of st4.v1 : %p\n",&st4.v1);
printf("pointer of st4.v2 : %p\n",&st4.v2);
printf("size of st5 : %d\n",sizeof(struct st5));
printf("pointer of st5    : %p\n",&st5);
printf("pointer of st5.v1 : %p\n",&st5.v1);
printf("pointer of st5.v2 : %p\n",&st5.v2);
printf("size of st6 : %d\n",sizeof(struct st6));
printf("pointer of st6    : %p\n",&st6);
printf("pointer of st6.v1 : %p\n",&st6.v1);
printf("pointer of st6.v2 : %p\n",&st6.v2);
printf("size of st7 : %d\n",sizeof(struct st7));
printf("pointer of st7    : %p\n",&st7);
printf("pointer of st7.v1 : %p\n",&st7.v1);
printf("pointer of st7.v2 : %p\n",&st7.v2);

実行結果

size of st1 : 4
pointer of st1    : 0x22fee4
pointer of st1.v1 : 0x22fee4
pointer of st1.v2 : 0x22fee5
pointer of st1.v3 : 0x22fee6
pointer of st1.v4 : 0x22fee7
size of st2 : 4
pointer of st2    : 0x22fee0
pointer of st2.v1 : 0x22fee0
pointer of st2.v2 : 0x22fee2
size of st3 : 8
pointer of st3    : 0x22fed8
pointer of st3.v1 : 0x22fed8
pointer of st3.v2 : 0x22fedc
size of st4 : 16
pointer of st4    : 0x22fec8
pointer of st4.v1 : 0x22fec8
pointer of st4.v2 : 0x22fed0
size of st5 : 16
pointer of st5    : 0x22feb8
pointer of st5.v1 : 0x22feb8
pointer of st5.v2 : 0x22febc
size of st6 : 5
pointer of st6    : 0x22fea8
pointer of st6.v1 : 0x22fea8
pointer of st6.v2 : 0x22fea9
size of st7 : 8
pointer of st7    : 0x22fea0
pointer of st7.v1 : 0x22fea0
pointer of st7.v2 : 0x22fea4

さて、この結果を順にみていきましょう
まず、 st1 です。これは4つの char 型の変数をもつ構造体です。 そのサイズは、単純に char 型4つ分の 4Byte です。 ここからも構造体は要素変数をひとかたまりにしたものであることが予想できます。
それぞれのポインタについてみてみますと、 構造体そのものを表す変数のポインタと構造体の1番目の要素の指すポインタの値は同じになっています。 その後の要素のポインタの値も1つずつずれていて、配列と同じような構成になっています。
視覚的に表してみると以下のようになっています。 ポインタの値は領域の先頭を表すので、 v1 のポインタと st1 のポインタは同じ値である理由もわかってもらえると思います。

st1 の結果を見ると、構造体とは、構造体の要素が詰め込まれたひとかたまりのメモリ領域が用意されていて、 それぞれの場所に型情報を伴ってアクセスできるようにしたものであるらしい、ということがわかると思います。 しかし、その詰め込み方というのが、単純に隙間なく詰め込んでいるわけでもない、ということが st2 以降の結果からわかります。

st2 についてみてみましょう。 st2 は char 型変数1つ、 short 型変数1つからなる構造体です。
単純に二つの変数の大きさを足すと 3Byte になるわけですが、構造体の大きさは4と表示されています。
また、 st2.v1 と st2.v2 のポインタの値をみてみると、この二つのデータは 2Byte 離れています。 st2.v1 は char 型なので 1Byte しか使用しません、つまり、 st2.v1 と st2.v2 の間には使われない 1Byte があるということになります。

同様に、 st3 の場合は 3Byte 、 st4 の場合は 7Byte の使われない領域が確保されています。

なぜ、このような結果になるのかというと、処理の効率化のためです。
例えば、この処理系のメモリバス幅に関係があります。 現在のいわゆる i386 系のメモリバス幅は 64bit つまり 8Byte です (一部 DualChannel アクセスをするタイプでは 128bit ですが、とりあえずおいときます)。 このメモリバス幅が意味していることは、 CPU がメモリから情報を読み出す場合 8Byte 単位で扱っているということで、 それより細かい単位でのアクセスはできないということになります (取り出すときの指定ポインタの精度が 8Byte 単位であるというイメージ)。
さて、 CPU にとってメモリアクセスというのは非常に遅い処理なので、できる限り避けたい処理です。 ところが、たとえばint型変数がメモリ上に以下のように配置されてしまった場合、 この変数の値を取り出すにはメモリアクセスが本来なら1回ですむはずが、2回必要になってしまいます。 またレジスタに格納する際、ビットシフトを行い結合させるなどの作業が必要となってきます。 (CPUによってはこのようなメモリ配置をしようとすると落ちてしまうものもあるらしいです。)

そのため、メモリアクセスができるだけ少なくなる配置法が用いられているのです。 (&レジスタとかのハードウェアのインプリメント上できるだけ楽(?)な配置。CPUが違ったりすると当然この配置は変わってきます)
具体的には、まず、メモリ領域が 8Byte 単位で区切られていると考えてください。
メモリはこの領域の先頭から順に配置されていきますが、 それぞれの変数は、その 8Byte の領域に隙間なく詰め込める配置(下図参照)をとろうとします。 たとえ前の領域の一部だけが使われていて、まだ余裕がある場合でも、下図のような配置をとります。 そのため、先に自分より小さなサイズの領域が確保されている場合隙間ができるのです。

さて、次に st5 について見てみましょう。 st5 は char 型と long double 型からなる構造体です。
今までと違うのは long double 型が先ほど説明したバス幅の 8Byte を超える大きさを持っている点です。
サイズが 16Byte 、またポインタの値から以下のような配置になっていることがわかります。

さて、これはどういうことかというと、答えは簡単。 12Byte のサイズを持つ long double という変数は 8Byte より大きく、どうやってもアクセスは最低2回必要になります。 そう考えたときに一番都合のよい配置は、以下のうちのどちらかとなります。つまり long 型と同じく、 先頭アドレスは 4Byte 単位になる訳です。

このように、メモリの先頭を何 Byte 単位で割り当てるかという値を、パッキング(もしくはアライメント)といいます。
char 型はパッキングが1、 short 型は2、 long 型は4、 long long 型は8、 long double 型は4ということになります。 このパッキングの値は配列や、構造体にもあります(入れ子の構造体などで確認できます)。
配列の場合はその要素1つと同じ扱いです。 構造体の場合は、その要素変数の中でもっとも大きな値のものと同じになります。 そのため、 st6 の様に、同じ 4Byte の要素変数を持つ構造体でも、 その配列のパッキングは char と同じ1ですので隙間なく詰められています。

そして、もう一つ、構造体のサイズです。 構造体のサイズは、パッキングの値の整数倍になるように決められます。 そのため、 st7 の様に先にサイズの大きな変数がきているような場合、2つの割り当てられるメモリの間には隙間ができませんが、 二つ目の変数の後に使われない領域ができて、そこまでを含めて構造体の領域になっています。

さて、本来ははじめの方に書いておくべきことなのかもしれませんが、 以上の説明を読んでみて、疑問に思ったことがあるのではないでしょうか (そんなことないって人は読み飛ばしてください)。 たとえば、 st2 や st3 の場合、わざわざ隙間をあけなくても 8Byte の領域をまたがないのでは?と思うかもしれません。
この構造体単体で考えた場合は確かにそうなのですが、 この構造体が他の構造体の中に入っていた場合どうなるか? とかこの構造体の配列を作った時どうなるか?を考えれば答えがでると思います。
このパッキングの規則に従っていれば、 一つの変数が必要以上の回数メモリアクセスが必要な形で配置されることがなく、 なおかつ、その方法の中でもっともメモリ使用効率が高い方法だとわかると思います (プログラム上で明示的に変な値を指定すれば状況が異なりますが・・)
構造体のパッキングの値の決め方や、サイズがパッキングサイズの整数倍になっているのもこの理由からです。

構造体の応用編(完全環境依存注意)

さてさて、構造体のメモリ配置なんて知ってどうするんでしょうか?
BMP形式の扱い方のところでは、バイナリへのエンコード、デコードに使いました。
プログラム上で扱う変数は、メモリ上の 0,1 の並びと「型(解釈)」によって意味のある情報になっています。 しかし、通信やファイルへの書き出し、読み出しでは 0,1 の並びしか扱えません。
本来はどのような環境でも使えるように、フォーマットを決めて、一つ一つの変数をバイナリへ変換し使うわけですが、 使用する環境を限定すれば、メモリの内容をそのまま書き出してしまえば、変換の作業がいらず、シンプルに書くことができるわけです。 (で、データの配置をフォーマットとして決めてしまえば、苦労するのは他の環境で互換対応しようとするひとだけと・・・)
たとえば、ファイルへの書き出しの場合、 BMP 形式のところでは以下のように書いています。

fwrite((void*)&bf,sizeof(BMPFILEHEADER),1,fp);

fwrite の書式は

fwrite(<書き出したいメモリ領域の先頭を表すポインタ>,
       <要素1つの大きさ>, <個数>, <書き込み先>);

書き出すサイズを要素一つの大きさと個数で指定していますが、結局これは二つの積が使われるだけなので、 一方を1にして、もう一方に両方の積を渡しても問題ないです(そんなことやる意味ないけど)。 なぜこのような形式をとったのかははっきりわかりませんが、配列を出力することを意識したのかな?

さて、このように書き出したものを以下のようにして読み出しています。

fread((void*)&bf,sizeof(BMPFILEHEADER),1,fp);

fread の書式は、 fwrite と同じで書き出すのではなく読み込むようになっているだけです。

しかし、 BMP のところでは問題が発生しいます。
BMPFILEHEADER 構造体の構成変数のサイズは 2,4,2,2,4 となっており、先頭と次の変数の間に2バイト隙間があいています。 しかし、ファイルフォーマットとしては、間に2バイト無駄な領域なんてない。 (というか自分でフォーマット決める場合でも、意味のない、しかも初期化されていないデータをフォーマットにするのはどうかと思いますが)
というわけで、さらに反則技を使います。

それが、 #pragma pack() です。
#pragma というのはプリプロセッサでコンパイラに指示を出すものです。 そして、 #pragma pack(n) とするとパッキングの値をnに変更することができます。(nを指定しない場合、デフォルトに設定される)
#pragma自体が環境依存ですので(コンパイラオプションみたいなものなので)これは非常に狭い範囲でしか適用できません。 私が確認している範囲では、 Visual C++ のコンパイラと Cygwin 上の gcc(3.3.1 (cygming special))では機能しましたが、 Vine Linux 2.1.5 の gcc(2.91.66)では使えませんでした。 Fedora Core 1 の gcc(3.3.2)では使えました。 gcc では 3.0 以降で使えるのかな?
まあ #pragma pack() ではパッキングを変更できても、エンディアンとかその他多くの環境依存の条件まで変更できませんので、 環境依存の要素を排除したりする目的には使えません。

蛇足: BMPFILEHEADER の場合、 これまで説明したように、 わざわざコンパイラが隙間を作ってまでアクセスを効率化しようとしているところを無理して詰めたわけですから、 パッキングを変更した状態では 4byte を必要とする変数のアクセスに負担が生じる可能性があります。
しかし、構造体をよく見ると、はじめの 2byte の問題さえ解決すれば後は問題なさそうです。
ってことは、 malloc で BMPFILEHEADER より 2byte 大きな領域を確保して、 先頭から 2byte ずらしたアドレスを BMPFILEHEADER のポインタとすれば、 アクセスの効率を落とさず裏技が使えそうです。(いずれにせよ 2byte 無駄になるんだし)

と、まあ、長々と解説してきましたが。 何度も何度もウザイくらい書いてますが、ここに書いていることは完全に処理系依存です。 言語仕様上、保証されている構造体の用法はドット演算子やアロー演算子を利用したアクセスだけで、 メモリ上の配置が宣言通りの順で格納されているのかとかそういう保証はいっさいありません。 訂正(誤りのご指摘をいただきました):宣言通りの順序で格納されていることは保証されています。 ただし、アライメントやバイトオーダーなどは処理系依存のためよく理解した上で利用する必要があります。
まあ、何が言いたいかというと、メリット・デメリットを理解した上で使ってくださいということです。



2504232 Today: 440 Yesterday: 479
Twitter
広告