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

Programming

C言語の メモリアライメント とはどういったものか、その意味について理解していただけるように全力で説明します。

理解するにあたって、C言語のポインタの概念とメモリエンディアンの概念についてある程度理解しておいた方がよいです。こちらも全力で説明しておりますので、以下の記事を参考にしていただければと思います。

社会人になるまで、C言語が全く分からなかった自分としては、アライメントなんぞ知る由もなく、最初はかなり苦しみました。そして理解しようにもC言語から理解していくのはなかなか難解でした。

なぜかコードを1行追加するだけで、CPUが例外に飛んでしまうようになった・・・そんな経験はないでしょうか?

アライメントやエンディアンを理解せず、糞みたいなコードを書いていた頃の自分は、そういった事態によく遭遇しました。




CPUの好きなメモリアクセスを理解する

まず、C言語のアライメントの話は置いておいてCPUの話をします。なぜならアライメントはCPUのメモリアクセスに密接に関係しているので、そこから理解する方が早いからです。

CPUはメモリアクセスする際、メモリのアドレス番地とデータサイズの組み合わせに得意、不得意があります。

32bit CPUを例にCPUが好きなアドレスとデータの組み合わせ順位を発表します。

32bit CPUが好きなメモリアクセス 第一位

CPUビット数単位のアドレスとデータ
アドレス番地:0x00000000、0x0000004、0x00000008・・・(4単位)
データサイズ:32bit(4byte)書き込み、読み出し

例)アドレス0x80000010から4byteのデータを読み出す
  アドレス0xC0000008に4byteのデータを書き込む

32bit CPUが好きなメモリアクセス 第二位

CPUビット数の半分(16bit)単位のアドレスとデータ
アドレス番地:0x00000000、0x0000002、0x00000004・・・(2単位)
データサイズ:16bit(2byte)書き込み、読み出し

例)アドレス0x80000012から2byteのデータを読み出す
  アドレス0xC000000Aに2byteのデータを書き込む

32bit CPUが好きなメモリアクセス 第三位

CPUビット数の半分の半分(8bit)単位のアドレスとデータ
アドレス番地:0x00000000、0x0000001、0x00000002・・・(1単位)
データサイズ:8bit(1byte)書き込み、読み出し

例)アドレス0x80000011から1byteのデータを読み出す
  アドレス0xC000000Bに1byteのデータを書き込む

好きなメモリアクセスってなんだ?と思われるかもしれませんが、ここではイメージ程度にとらえていただければいいです。あくまでメモリアライメントをイメージいただくための準備であることをご承知おきください。

さて、この順位が全ての32Bit CPUで共通しているとすると、プログラマーはこの好きなメモリアクセス方法だけを使うようなプログラムを書いてあげれば、どのCPUでも調子よく動くプログラムになるということになりますよね?

逆に、これ以外のメモリアクセスをすると、あるCPUだと許してくれるけど、あるCPUだと許してあげないという事態も起こりえます。

これらを踏まえてメモリアライメントとは何かを下にまとめます。

メモリアライメントとは

メモリアドレス番地の位置のこと

以下を守って実装するということがアライメントを守るという意味になる

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

これらが守れていない場合、アライメントに違反するという

守るためにアドレス番地を調整することをアライメントを調整するという

CPUのメモリの話から、まずは「アライメントとは何か?」が理解いただけましたでしょうか?

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

ではC言語に話を戻します。プログラムの観点からアライメントを考えていきます。

まず、C言語は適当に実装してもある程度アライメントをうまいこと調整してくれます。コンパイラがCPUの得意なメモリアクセスを理解して適切にメモリへのデータ配置を調整してくれます。

例えば、4byteの変数(longとか)はメモリに配置する時、自動的に4byte単位のアドレス番地に配置(4byteアラインといいます)されますし、2byteの変数(shortとか)は2byteにアラインされます。コードで例を示すと以下のようになります。

unsigned long  a; /* 変数aは意識しなくても勝手に 4byte単位のアドレスに配置される */
unsigned short b; /* 変数bは意識しなくても勝手に 2byte単位のアドレスに配置される */
unsigned char c; /* 変数cは意識しなくても勝手に 1byte単位のアドレスに配置される */

a = 1; /* 変数aには4byteのデータ 0x00000001で書き込みが行われる */
b = 2; /* 変数bには2byteのデータ 0x0002 で書き込みが行われる */
c = 3; /* 変数cには1byteのデータ 0x03 で書き込みが行われる */

ならアライメントを意識することは不要?と思うかもしれませんが、

アライメントを調整するC言語コンパイラ 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が詰めてメモリ上に配置されています。

2byteの変数にデータを書き込む際は2byteデータのアクセスが発生するので、2byte単位のアドレスに配置される必要があります。この配置だとアライメントを守れているのでコンパイラはこのようにメモリに配置します。。

次に構造体hoge2のメモリ配置を見てみます。

hoge2のメモリ配置

アドレス
オフセット

1 2 3 4

0

long a

4

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

8

long c

12

short d
   

hoge2はメンバcがbの横には入ることができません。なぜなら、アライメントを守れなくなってしまうからです。

無理やりここにcを配置してしまうと、CPUがcを読み書きに行く時、4byte単位でないアドレスに対して、4byteのデータを読み書きすることになります。アライメントが守れていない状況となってしまいます。

これでは都合が悪いということをコンパイラが意識して回避してくれて、このようなメモリ配置になっているのです。コンパイラはしょうがなく、2byteの無駄なスペースを作って、long cを配置しています。

しかし、このまま構造体のメンバにプログラムで値を読み書きするだけであれば、何も問題は起きません。しかし、この構造体hoge2を以下のような使い方をするとき、問題となる可能性があります。

sizeof()で構造体のサイズを参照する時

sizeof(hoge2)はいくつになるでしょう。環境にもよりますが、16という値が返ってきます。

メンバの型は4+2+4+2=12なので、プログラマがサイズ12を期待してプログラムを書いてしまうと、バグの原因になりかねません。アライメントを意識してメンバを配置するかしないかでサイズが異なることに注意が必要です。

メモリ上のデータをバイナリ列として使用する時

例えば、通信データなどを構造体で表現して定義し、その構造体データをバイナリとして扱うような時、その通信データフォーマット通りに構造体を定義しても必ずしも正しいデータ列になるとは限らない場合があります。

通信データはメモリを詰めて定義してあるにも関わらず、前述hoge2のメモリ配列のように構造体の定義の仕方によってはメモリ上に隙間が空いてしま可能性があるからです。

例えば、以下のように、バイナリとしてデータ送信する時に正しいデータとなりません。

    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単位のアドレスにこの配列を配置しても大丈夫だと判断します。

それは、bytearrayのアドレスが奇数の可能性もあれば、ハーフワードアラインの可能性もあるということを意味しています。

一方、long*にキャストして参照する時、実装者はこの配列を4byteのデータとみなして参照したいと思っています。

キャストにより、bytearrayの先頭アドレスにCPUがワードアクセスすることとなるので、bytearrayの先頭アドレスはワードアラインである必要があります。

ここで、コンパイラと実装者の意識の乖離が起きています。bytearrayがたまたま4byteアラインに配置されていれば何も問題ないでしょう。

ただ、何か上下に変数を追加したりしてスタックへの変数割り当てアドレス状態が変わった時、bytearrayのアドレスが4byteアライン以外の配置となってしまった時、問題が発生します。

冒頭で述べた「コードを1行追加するだけで、CPUが例外に飛んでしまうようになった・・・」は、まさにこういったことが原因で発生しうるのです。

共用体も同じ

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

ここまでくるとどういった共用体の使い方がダメなのか?何となく想像していただけるのではないでしょうか。

おわりに

アライメントとはなにか?実装時にどのようなことに注意すればよいのか?について説明しました。

どのようにメモリ配置されるかを常に意識しながら実装することで、おのずとアライメントに対する対策はできるものです。

また、現場では初心者がやらかさないようにコーディングルールに盛り込んだり、コードにコメントを残したり、アライメントを知らない人に対しても事故を防ぐような工夫が大事になってきます。

特に、不用意なポインタ型のキャストとそれによるポインタ参照はメモリを理解できていない人は極力やらない方がよいです。

少しでもアライメントの理解について、この記事が一助になれれば幸いです。

コメント

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