【C言語】メモリアライメントを全力でわかりやすく説明する

Programming
苦しんで覚えるC言語 | MMGames |本 | 通販 | Amazon
AmazonでMMGamesの苦しんで覚えるC言語。アマゾンならポイント還元本が多数。MMGames作品ほか、お急ぎ便対象商品は当日お届けも可能。また苦しんで覚えるC言語もアマゾン配送商品なら通常配送無料。

 以前、C言語のポインタの概念とメモリエンディアンの概念について全力で説明しました。今回はその続き、メモリアライメントについて説明します。C言語とありますが、CPUとメモリの話も理解しておくとわかりやすいかと思いますので、以下の記事も参考にしていただければと思います。

 社会人になるまで、C言語が全く分からなかった自分としては、アライメントなんぞ知る由もなく、最初はかなり苦しみました。なぜか、コードを1行追加するだけで、CPUが例外に飛んでしまうようになった・・・そんな経験はないでしょうか?私の場合、アライメントやエンディアンを理解せず、糞みたいなコードを書いている自分が犯人でした
 CPUの気持ちを理解すれば、おのずとコードもきれいになり、バグも減るということを実感していただいです。

Goal

実装に困らないレベルでメモリアライメントを正しく理解する!




CPUが得意なメモリアクセスとは?

 ソフト実装におけるアライメントという概念はCPU都合によるものだということです。決してC言語だからとかC++だからとかいうわけではありません。まず最初にCPUが得意なアドレスとアクセスデータサイズの組み合わせを知ることから始めます。32bit CPUを例に説明します。

 得意なアドレスとデータの組み合わせ順位発表!

 第一位はCPUのビット数単位のアドレスとデータです。0x00000000、0x0000004、0x00000008・・・0xFFFFFFFCのアドレスへの32bit(4byte)書き込み、読み出しが最も得意です。
 第二位はCPUのビット数半分の単位のアドレスとデータです。0x00000000、0x0000002、0x00000004・・・0xFFFFFFFEのアドレスへの16bit(2byte)書き込み、読み出しが2番目に得意です。
 第三位は8ビット単位のアドレスとデータです。0x00000000、0x0000001、0x00000002・・・0xFFFFFFFFのアドレスへの8bit(1byte)書き込み、読み出しが3番目に得意です。

 この順位はおおよそのCPUで共通している順位です。その他のアドレスとデータの組み合わせはCPUによってできるかもしれません。けれども、全てのCPUが最低限この得意な順位であるなら、これを守ってあげれば、どんなCPUでも動くということになりますよね?
 逆に、この順位にないメモリアクセスをして、調子よく動いていたとしても、ほかのCPUで同じコードを動かしたとき、動かない!なんてことも起こりえます。

メモリアライメントとは

CPUが許可するメモリアドレス、データアクセスの単位

以下のルールを守って実装するということが
アライメントを意識して実装するということ

  • CPUビット数単位のアドレスにCPUビット数単位のデータアクセス(ワードアクセス)
  • CPUビット数半分単位のアドレスにCPUビット数半分単位のデータアクセス(ハーフワードアクセス)
  • 1byte単位のアドレスアラインに1byte単位のデータアクセス(バイトアクセス)

C言語ではアライメントを勝手にやってくれるの?

 やってくれます。コンパイラがCPUの得意なメモリアクセスを理解して適切にメモリへのデータ配置を調整してくれます。4byteの変数(longとか)はメモリに配置する時、自動的に4byte単位のアドレスに配置(4byteアラインといいます)されますし、2byteの変数(shortとか)は2byteにアラインされます。

ならアライメントを意識することは不要では?

 と思っちゃいますよね。しかし、

アライメントを調整するコンパイラ VS アライメントを知らない実装者

 これにより、コンパイラがよろしくやったことを実装者が知らないがゆえに、予想外の動きになり、バグにつながることが起こりえます。これが起きないように実装者はアライメントを理解して実装しましょうという話です。

アライメントを意識した実装

 C言語でアライメントを意識する時、気を付けるシーンはたった以下の2つです。

  • 構造体定義に気を付ける
  • ポインタ型へのキャストに気を付ける

 これらを守るだけで、アライメントはほぼカバーできます。ひとつづつケーススタディしていきます。

構造体定義とアライメント

 早速ですが、以下の構造体の違いはわかるでしょうか?

typedef struct {
    unsigned long  a;
    unsigned short b;
    unsigned short c;
    unsigned long  d;
} hoge1;

typedef struct {
    unsigned long  a;
    unsigned short b;
    unsigned long  d;
    unsigned short c;
} hoge2;

 共に4byteのメンバを2つと2byteのメンバを2つ持っています。違うのはメンバの並びです。この構造体変数を宣言した時、どのようにメモリに確保されるでしょうか?構造体のメンバは宣言順からメモリに配置されるものとします。

hoge1のメモリ配置

アドレス
オフセット

1 2 3 4
0 long a
4 short b short c
8 long d

hoge1はこのように、bとcが詰めて配置されています。ハーフワードのデータアクセスはハーフワードアドレス単位でなければならないCPUの仕様を満たせるのでこの順番で配置することが可能です。

hoge2のメモリ配置

アドレス
オフセット

1 2 3 4

0

long a

4

short b
ここにはcは入れられない

8

long c

12

short d
   

 hoge2はメンバcがbの横には入ることができません。なぜなら、前で述べたルールCPUビット数単位のアドレスにCPUビット数単位のデータアクセス(ワードアクセス)のアライメントのルールに抵触するからです。コンパイラはしょうがなく、2byteの無駄なスペースを作って、long cを配置しています。

 このまま構造体の変数として使うのであればなにも問題がないかもしれません。この構造体hoge2を以下のような使い方をすると、問題になります。

sizeof()の値が違う

 sizeof(hoge2)はいくつになるでしょう。環境にもよりますが、16という値が返ってきます。メンバの型は4+2+4+2=12なので、12を期待してしまうと、それで演算結果が異なります。16が返ってくるのはこの構造体変数を宣言すると、これだけメモリを使用しますよということを表しています。決して構造体のメンバのサイズの合計ではないので、アライメントを意識してメンバを配置する必要があります。

フォーマットが期待と異なる

 構造体が通信データのように規定されているフォーマットでそのまま構造体を定義した時、アライメントを理解していないと期待するデータ列と異なるものになります。以下のように、バイナリとしてデータ送信すると、正しいデータとなりません。

    hoge2 hogetmp;
    char *buf;

    hogetmp.a = 1;
    hogetmp.b = 2;
    hogetmp.d = 3;
    hogetmp.c = 4;

    buf = (char*)&hogetmp;

    senddata(buf);

ポインタ型へのキャスト

 キャストには気をつけろとよく言われますが、何を気を付けたらいいのかわからないと、アライメントに足をすくわれます。特に、バイト配列のアドレスをワード、ハーフワードにキャストして参照はアライメント的にもエンディアン的にも絶対にやってはいけない実装の一つです。なぜだめなのか、実装例を見ながら説明します。

バイト配列へのキャスト

 以下のコードのようにスタック上に4byteのバイト配列を宣言し、配列の先頭をキャストして参照します。

int main(int argc, char* argv[])
{
    char bytearray[] = { 0,1,2,3 };
    long* ptr;

    ptr = (long*)bytearray;
    pirntf("%x", *ptr);

    return 0;
}

 どこがよくないでしょうか?bytearray[]はは1byteのデータ列で1byteでアクセスされることを想定しているため、コンパイラ君としてはbyte単位のアドレスに配列を配置します。すなわち、アドレスが奇数の可能性もあれば、ハーフワードアラインの可能性もあります。
 一方、long*にキャストして参照する時、実装者はこの配列を4byteのデータとみなして参照したいと思っていると思います。CPUはワードアクセスするので、アドレスはワードアラインである必要があります。
 ここで、コンパイラと実装者の意識の乖離が起きています。bytearrayがたまたま4byteアラインに配置されていれば何も問題ないでしょう。ただ、何か上下に変数を追加したり、コールスタックが変わった時などbytearrayのアドレス配置が変わったとき、問題が発生します。
 冒頭で述べたコードを1行追加するだけで、CPUが例外に飛んでしまうようになった・・・は、まさにこれが原因で発生しうるのです。

共用体も同じ

 共用体はあるメモリ領域に対して、メンバ変数や型、配置が異なる構造体定義をかぶせて値を読みにいく手法ですが、これもアライメントを理解していないと、CPUのメモリアクセスルールに違反してしまいます。
 ここまでくるとどういった共用体の使い方がダメなのか?容易に想像できると思います。

まとめ

 アライメントを意識した実装について説明しました。どのようにメモリ配置されるかを常に意識しながら、構造体を定義し、不用意なポインタ型のキャストとそれによるポインタ参照はやらない。これを意識するだけで、アライメントに対する対策はある程度できることになると思います。
 これを繰り返しやっているうちに、アライメントの本質に迫れる日が来ると思っています。私も最初は意味も分からず、やってはダメな実装とやるべき実装だけを言われてやっていました。いつか、「そういうことか、はいはいはい」と理解できる日が来るのが組み込みの世界だと思っています。この記事がその一助になれれば幸いです。

苦しんで覚えるC言語 | MMGames |本 | 通販 | Amazon
AmazonでMMGamesの苦しんで覚えるC言語。アマゾンならポイント還元本が多数。MMGames作品ほか、お急ぎ便対象商品は当日お届けも可能。また苦しんで覚えるC言語もアマゾン配送商品なら通常配送無料。

コメント

タイトルとURLをコピーしました