これだけは覚えよう


原因と原理

アライメントとかワード境界とか言う話の原点は、プロセッサとメモリをどうつないでいるかという点からスタートしています。ですからまず、ソフトウェアの話ではなくハードウェアの話から入ります。

プロセッサの話をするとよく出るのが「ビット幅」の話です。あれは32ビットプロセッサだとか、これは128ビットプロセッサだとかいう表現をされ、性能指標のひとつとしてよく登場します。大体ビット幅は「レジスタ長」か「データバス幅」かのどっちかです。こっから先では「レジスタ長 = データバス幅」で話をします。

同じクロックの8ビットプロセッサと32ビットプロセッサを比較すると、大体32ビットプロセッサのほうが高速に動作します。これは、32ビットプロセッサのほうが一度に読めるデータの量が多いことに起因します。8ビットプロセッサ用の8ビットRAM/ROMはひとつの箱が8ビットでできているのに対し、32ビットプロセッサ用の32ビットRAM/ROMはひとつの箱が32ビットでできています。ひとつの箱は一回の転送命令(リード/ライトストローブ)で転送できるので、箱が大きければ大きいほど性能が上がります。ちなみに、8ビットプロセッサに32ビットRAM/ROMをつけると必然的に箱の大きさが8ビットになり、またその逆は一回の転送で8ビットしか取り出せないものになります。

しかしここには大きな落とし穴があります。現在あるプロセッサのほとんどは8ビットを最小の処理単位として扱っているので、8ビットRAM/ROMはひとつの箱にひとつのデータが入っていることになります。一方、32ビットプロセッサのほうは、ひとつの箱に4つのデータが同居してることになります。たとえば、"ABCDEFGHIJKLMN..."という文字列があるとき、8ビットROM/RAMは一文字がひとつの箱に入っていますが、32ビットでは「ABCDでひとつ」「EFGHでひとつ」と入っています。ここで、何が問題かというと、「4つでひとつだ」ということです。この問題は、次のような要求があると明確になります。

「BからEまでの4つを取り出したい」

8ビットプロセッサの場合、この要求は簡単で、B,C,D,Eと4つ読み出してあげれば完了です。一方、32ビットプロセッサの場合は簡単にいきません。それは、B,C,Dの入っている箱と、Eの入っている箱が異なるからです。さらに、B,C,D,Eの4つで箱ひとつに詰めなくてはいけないという大変な仕事が待っています。具体的には、A,B,C,Dの箱を取ってきて左に一個分寄せ、E,F,G,Hの入っている箱を右に三個分寄せ、二つを組み合わせてひとつの箱に収めるという作業になります。この作業は意外と厄介な作業です。

8ビットメモリの場合32ビットメモリの場合

ここで敢えて書き込むときの話もしましょう。書き込みは読み込みの逆手順です。ですから、8ビットメモリは箱を4つ書き直して終わりです。一方、32ビットメモリでは、B,C,Dの3つはA,B,C,Dの箱に入れないといけないので、一度A,B,C,Dを読み込み、B,C,Dを空にして、新しいB,C,Dを書き込みます。そしてE,F,G,Hの箱を読み込み、Eだけを空にした後、新しいEを書き込みます。書き込むために読み込む必要があるため、普通の4倍もの時間がかかってしまいます。

世の中にはこの大変な仕事をこなしてくれる賢いプロセッサもありますが、このような装置をつけると当然プロセッサの値段が上がってしまいます。また、さっきのような面倒な仕事を勝手にやってしまうので、予測可能性(リアルタイム性)という性能が低くなります。変数を一個追加しただけで異常に遅いプログラムになってしまうこともありえます (さらに追加するといきなり高速になる)

そのため、ハードウェア設計者はプロセッサを作る段階で「2つの箱をまたぐような要求をしてはいけない」という制限をつけることにしました。これがアライメント制約です。アライメントは箱の大きさによって「nバイトアライメント」とか「ワード(ハーフワード)アライメント」とか言います。読み出すデータをこのように揃えることを「アラインする」とも言います。箱と箱の境目を境界と呼び、そのサイズによって「ワード(ハーフワード)境界」と言ったりもします。

アライメントは読み出すデータの長さによって左右され、4バイトのデータを読み出すときには4で割ることのできる番地にデータをおかなければいけません。なぜなら、4バイトの長さの箱の開始番地が4で割れる数になってるからです。同じように2バイトのデータを読み出すときにも2で割れる番地にデータをおかなければなりません。しかし、3バイトのデータを読み出すときには3で割れる番地に置かなければいけないかというと、実はそうではありません。コンピュータは2のべき乗を計算の単位としているため、3バイトの時には3を超える最小の2べき数である4バイトとして扱います。ですから、3バイトのデータは4で割れる番地におかないといけません。

余談ですが、このような問題は「バイトアドレッシングCPUと、16ビット以上のワードアドレッシングROM/RAM」という組み合わせのときだけ発生します。バイトアドレッシングとは1バイト単位に番地をつけることで、ワードアドレッシングとは箱単位で番地をつけるやり方です。先ほどの例で、「Bから」という指定はバイトアドレッシングに相当します。ワードアドレッシングの場合は、「この箱」という言い方しかできないので、箱をまたぐことができないからです。

実験 - コンパイラで遊ぶ

アライメント制約は、普通プログラムを書いているときに気にすることはありません。これはコンパイラが勝手にやってくれるからです (しかし組み込み技術者としては必須の知識です)

これは、たとえば次のようなコードを書くと簡単にわかります。

#include <stdio.h>
#define CHECK(x) \
  if( (long)(&x) % sizeof(x) != 0) \
    printf(#x "はアライメントがあってない\n");

int main(void)
{
int first;
char second;
short third;
char dummy;
int forth;

CHECK(first);
CHECK(second);
CHECK(third);
CHECK(forth);
return 0;
}

このプログラムは、変数が配置されたアドレスがそのサイズで割り切れない場合、変数名を表示してアライメントがあっていないことを教えてくれます。ためしに実行してみるとわかると思いますが、このプログラムは何も表示することなく終わると思います (gcc-2.95.3にて確認)。 これはコンパイラが勝手にアライメントをあわせてくれているからです。

もうひとつ。構造体の各要素もコンパイラが勝手にアライメントをあわせてくれます。たとえば、char型の変数とint型の変数を持つ構造体を作ってサイズを調べます。charは1バイト、intは普通4バイトなので、足したら5バイトになります。

#include <stdio.h>

int main(void)
{
printf("%d\n", sizeof(struct { char a; int b; }));
return 0;
}

このプログラムを実行すると、8と表示されるはずです。これは、int型の変数を4で割れる場所におくために、char型の変数の後に3バイト隙間をあけたためです。本当にあけたのか調べるには、こうするとわかります。

#include <stdio.h>

struct tagTest
{
char a;
int b;
} work;

int main(void)
{
int i;

bzero(&work, sizeof(work));
work.a = -1;
work.b = -1;

for(i=0;i<sizeof(work);i++)
printf("%02x",*((unsigned char *)&work + i));
return 0;
}

このプログラムを実行すると、"ff000000ffffffff"と表示されます。これで、2バイト目から4バイト目までが使われていないことがわかります。

そうなると、「宣言順を逆にすると5バイトで済むかも」と思いがちですが、実際には逆にしても8バイトになります (疑問に思ったのであれば実際にやってみるとよいでしょう)。これは、「構造体のサイズは、最大の長さを持つ要素の整数倍に等しくしなさい」というアライメントから起こる制約があるからです。なぜこんな制約があるかと言うと、こうしないと配列にしたときに後続する構造体のアライメントが滅茶苦茶になってしまうからです。

ちなみにgccやVisual C++にはこの隙間をわざと埋めるオプションがあります (パッキング)。この場合、要素のバイト数の合計が構造体のバイト数になります。

処理系普通の宣言詰めた場合
gccstruct { ... };struct { ... } __attribute__ ((packed));
Visual C++struct { ... };#pragma pack(1)
struct { ... };

実験 - シミュレータで遊ぼう

では実際にアライメント制約のあるプロセッサで試してみましょう。今回使用するのは、日立のSuperHプロセッサです。とはいっても本物を用意するのは大変なので、GNU Debugger(gdb)についてくるシミュレータを使います (作り方等に関しては、他のサイトまたはここの"初めてのC言語"を参考にしてください)

単純に、次のようなイヤがらせっぽいコードを書きます。

int main(void)
{
char buffer[8];
return *(int *)(buffer + 1);
}

このプログラムをsh-hitachi-elf-runに放り込むと、"program stopped with signal 7."と言って停止します。Signal 7の正体は、/usr/include/bits/signum.hを見るとわかります。その正体はSIGBUSです。SIGBUSは、やってはいけないバス操作を行ったときに起こるエラーです。このように、アライメントを無視したアクセスを行うことで、動くプログラムも動かなくなってしまいます。