[C言語] エンディアン に強いコードを書く

Programming

組み込みエンジニアの方なら一度は エンディアン という言葉を聞いたことがあるかと思います。エンディアンを正しく理解しプログラムすることはコードの移植性や実行性能に影響を与えます。

今回は エンディアン を正しく理解して、プログラミングの際にどのようなことに気を付けて実装するのがよいのかについて解説します。




エンディアンとは何か?

 エンディアンとはある特定のサイズのデータをバイト順に並べる時、その並び順のことを言います。ある特定のサイズとはC言語の型でいうと、char(1byte)、short(2byte)、long(4byte)とかです。charは1byteなので、並び順という概念は当たりませんので、2byte以上のサイズのデータについて適用できる概念となります。
 エンディアンは並びによって、リトルとビッグがあります。リトルは値の下位byteから順番にメモリに配置するもので、ビッグはその逆の上位byteから順番にメモリに配置するものです。

CPUのエンディアン

 いまいちピントこないかもしれませんので、CPUの世界を例にC言語コードで説明します。以下のコードはunsigned long(4byte)の変数に0x01234567という値を格納して、unsigned char(1byte)のポインタにキャストして、index 0から順番に出力するコードです。この時、printでどのような値が出力されるでしょうか?

unsigned long hoge = 0x01234567;
unsigned char *array;

array = (unsigned char*)&hoge;
print("%02x,%02x,%02x,%02x",array[0],array[1],array[2],array[3]);

 もし、67 45 12 01 で出力された場合、そのCPUはリトルエンディアンのCPUです。メモリにはアドレスの小さい順に67から順に配置されていたことがわかります。
 一方、01 23 45 67 で出力された場合、そのCPUはビッグエンディアンのCPUです。メモリにはアドレスの小さい順に01から順に配置されていたことがわかります。
 すなわち、CPUにおけるエンディアンとは

メモリにデータを格納するとき、どのようなbyte順で格納するか?
そして、メモリ上のデータを解釈する時、どの並びで解釈するか

ということになります。
 人間が解釈する時は直感的に理解しやすいのはビッグですね。でもCPUは近年リトルエンディアンの方が主流ですので、リトルの並びに慣れておいた方がよいです。

様々なデータにおけるエンディアン

 例えばネットワークを流れるデータ、USBでやり取りされるデータ、製品独自で規定されているデータ、それらすべてのデータは解釈される時、エンディアンの概念が櫃ようになってきます。以下の通信データを例に説明します。よくありそうな通信パケットデータです。

Preamble Sequence No Data Check Sum
4byte 4byte 16byte 2byte

 Dataはbyteオーダーとして、エンディアンは考えないとします。その他のパラメータはエンディアンの定義が必要とします。例えば、Sequence Noには0x00000000~0xFFFFFFFFの範囲のデータが入り、今0x11223344というデータが入っているとする時、エンディアンが規定されていないと、0x44332211と0x11223344の二通りに解釈できることとなってしまいます。
 ですので、このようなデータ構造を示す時、エンディアン表記は必ず必要となります。「断りがない限りはXXXエンディアン」といったように冒頭に記述しておいてもよいでしょう。

エンディアンを意識した実装とは?

 では、本題です。エンディアンを意識して実装するということは、どのようなことに気を付けれて実装するということでしょうか?注意点をまとめると以下になります。

  • CPUから外部に出力するデータを扱う時
  • CPUには依存しないデータ仕様を実装する時
  • memcpy、memcmpへの配慮

 一番大事なのはCPUのエンディアン外部仕様のエンディアン結びつけないことです。ソフトウェアはCPUがどちらのエンディアンでも同一コードで同じように外部仕様を実現できるよう、実装するのが望ましいです。(バイエンディアン)。

CPUから外部へのデータ出力を扱う時のエンディアン考慮

 CPUからデータを出力する、例えば、先ほどのコードを例に挙げます。

unsigned long hoge = 0x01234567;
unsigned char *array;

array = (unsigned char*)&hoge;
print("%02x,%02x,%02x,%02x",array[0],array[1],array[2],array[3]);

 もし、このコードを実装していて、printで出力されるデータをテスト結果としてVerifyするような、テスト環境があった時、CPUのエンディアンが変わると、異なる結果になってしまうこととなります。このように気軽なprint文もどこかでそれを使ってシステムが動いている可能性があります。バイエンディアンでの実装例は以下になります。

unsigned long hoge = 0x01234567;
// キャストは使わない
//unsigned char *array;
//array = (unsigned char*)&hoge;
unsigned char array[4];

array[0] = (unsigned char)((hoge & 0x000000FF) >> 0);
array[1] = (unsigned char)((hoge & 0x0000FF00) >> 8);
array[2] = (unsigned char)((hoge & 0x00FF0000) >> 16);
array[3] = (unsigned char)((hoge & 0xFF000000) >> 24);

print("%02x,%02x,%02x,%02x",array[0],array[1],array[2],array[3]);

このように実装することで、CPUがビッグでもリトルでも必ず出力はリトルエンディアンとなります。わかりやすく冗長に書いていますが、以下のようにマクロを使ってデバッグコードとして埋め込んでもいいかもしれません。

#define DEBUG_OUT_LONG(val) \
print("%02x,%02x,%02x,%02x",\
    (unsigned char)((val & 0x000000FF) >> 0),\
    (unsigned char)((val & 0x0000FF00) >> 8),\
    (unsigned char)((val & 0x00FF0000) >> 16),\
    (unsigned char)((val & 0xFF000000) >> 24));\
}

CPUのエンディアンに依存しないデータ仕様を扱う時

 CPUのエンディアンとは全く関係のない、別のデータ仕様でエンディアン定義されたデータを扱う際の実装について考えます。例えば、先ほどの例で述べた通信データを扱うことを考えます。

Preamble Sequence No Data Check Sum
4byte(リトルエンディアン) 4byte(リトルエンディアン) 16byte 2byte(リトルエンディアン)

 これを外部から受信してCPUで解釈して同様のフォーマットで外部へデータ送信するような例を考えます。仮にCPUはリトルエンディアンとし、データは1byteづつHWから受信することとします。
 以下にまずエンディアンに依存しているコードの例を示します。

typedef struct pkt_data {
    unsigned long preambel;
    unsigned long seqnno;
    unsigned char data[16];
    unsigned short checksum;
}; PKT_DATA

PKT_DATA data;

// データ受信
receive_data(&data);

// preambleチェック
if(data.preamble != 0xabcdabcd) {
    //不正なデータ
    return ERR;
}
// シーケンス番号をインクリメント
data.seqnno += 1;

// チェックサム計算
calc_checksum(&data);

// レスポンス送信
send_data(&data);

 どこがエンディアンに依存しているかわかるでしょうか?CPUがリトルエンディアンという環境では、上記コードは問題なく動作します。ではCPUがビッグになったら、上記コードはどうなってしまうでしょうか?

 まず、preambleはunsigned long型でかぶせてあるので、受信データの先頭4byteをunsigned long型にキャストして読み出すことと同意になります。通信データのエンディアンとCPUのエンディアンが異なりますので、正しいpreamble値を指定されていてもCPUは0xcdabcdabと誤判断するでしょう。
 同様にほかの構造体メンバのunsigned long seqnnoやunsigned short checksumもビッグで演算されてしまい、結果的に通信データの仕様を満たせなくなってしまいます。以下のように、データをバイトオーダーで扱うことで、バイエンディアン対応できます。

typedef struct pkt_data {
    unsigned char preambel[4];
    unsigned char seqnno[4];
    unsigned char data[16];
    unsigned char checksum[2];
}; PKT_DATA

PKT_DATA data;
unsigned long preamble;
unsigned long seqnno;

// データ受信
receive_data(&data);

// preambleチェック
// リトルで格納されているデータを取得
preamble = (unsigned long)((data.preamble[0] << 0) +
                           (data.preamble[1] << 8) +
                           (data.preamble[2] << 16) +
                           (data.preamble[3] << 24));
if(preamble  != 0xabcdabcd) {
    //不正なデータ
    return ERR;
}

// シーケンス番号をインクリメント
// リトルで格納されているデータを取得
seqnno= (unsigned long)((data.seqnno[0] << 0) +
                        (data.seqnno[1] << 8) +
                        (data.seqnno[2] << 16) +
                        (data.seqnno[3] << 24));
seqnno += 1;
data.seqnno[0] = (unsigned char)((seqnno& 0x000000FF) >> 0);
data.seqnno[1] = (unsigned char)((seqnno& 0x0000FF00) >> 8);
data.seqnno[2] = (unsigned char)((seqnno& 0x00FF0000) >> 16);
data.seqnno[3] = (unsigned char)((seqnno& 0xFF000000) >> 24);

// チェックサム計算
calc_checksum(&data);

// レスポンス送信
send_data(&data);

 多少冗長な記述かもしれませんが、このように対応することでCPUのエンディアンがビッグでもリトル依存することなく、リトルの通信データを扱うことができます。

memcpy、memcmpなどへの配慮

 4byteデータをmemcpyでバイト列にコピーしたり、バイト列を4byteデータにコピーする場合も注意が必要です。先ほどのコード例を考えます。

// シーケンス番号をインクリメント
// リトルで格納されているデータを取得
seqnno= (unsigned long)((data.seqnno[0] << 0) +
                        (data.seqnno[1] << 8) +
                        (data.seqnno[2] << 16) +
                        (data.seqnno[3] << 24));
seqnno += 1;
data.seqnno[0] = (unsigned char)((seqnno& 0x000000FF) >> 0);
data.seqnno[1] = (unsigned char)((seqnno& 0x0000FF00) >> 8);
data.seqnno[2] = (unsigned char)((seqnno& 0x00FF0000) >> 16);
data.seqnno[3] = (unsigned char)((seqnno& 0xFF000000) >> 24);

この処理のデータを格納する処理を

// シーケンス番号をインクリメント
// リトルで格納されているデータを取得
seqnno= (unsigned long)((data.seqnno[0] << 0) +
                        (data.seqnno[1] << 8) +
                        (data.seqnno[2] << 16) +
                        (data.seqnno[3] << 24));
seqnno += 1;
memcpy(data.seqnno, (void*)&seqnno, sizeof(data.seqnno));

このようにmemcpyで実装してまった場合、どうなるでしょうか?seqnoは4byteですが、このデータの並びはCPUのエンディアンに依存しているので、data.seqnnoの並びはCPUに依存してしまうことになります。これではCPUがビッグになると、通信データのエンディアンも変わってしまいます。

エンディアンを意識したコーディングへの工夫

 これまで説明したバイエンディアン実装は全て動的なものでした。動的というのはリトル向けでもビッグ向けでもコンパイルスイッチなどでCPUのエンディアン毎に処理を切り分けていないことを言っています。これはビルドの際に特別なコンパイルスイッチが必要ない利便性もありますが、処理が増えるため、性能悪化やROMサイズのひっ迫にもつながる可能性もあります。

 そこで、コンパイルスイッチを使用して、エンディアン毎に処理を変える方法もあります。例えば、先ほどの例で以下の処理を考えてみます。

// シーケンス番号をインクリメント
// リトルで格納されているデータを取得
seqnno= (unsigned long)((data.seqnno[0] << 0) +
                        (data.seqnno[1] << 8) +
                        (data.seqnno[2] << 16) +
                        (data.seqnno[3] << 24));

 ビットシフトして変数に値を入れていますが、CPUがリトルであれば、以下のようにキャストして入れる方が、早くて可読性も高くなります。(もちろんアライメントは考慮する必要はありますが)

// シーケンス番号をインクリメント
// リトルで格納されているデータを取得
seqnno= *(unsigned long*)(data.seqnno);

 CPU変更に伴い、エンディアンの変更はないと、あると程度決め打てるような環境であれば、以下の用にコンパイルスイッチで切り替えておいてもよいかもしれません。(個人的にはあまり使いませんが・・)

// シーケンス番号をインクリメント
// リトルで格納されているデータを取得
#ifdef CPU_IS_LITTE
seqnno= *(unsigned long*)(data.seqnno);
#else
seqnno= (unsigned long)((data.seqnno[0] << 0) +
                        (data.seqnno[1] << 8) +
                        (data.seqnno[2] << 16) +
                        (data.seqnno[3] << 24));
#endif

まとめ

 今回はエンディアンを意識した実装について説明しました。バイエンディアンで実装することが望ましいですが、昨今、リトルのCPUが主流になってきていますので、エンディアンはこういう風に意識するんだ というのを頭に置いておくだけでもいいのかもしれません。
 ただ、用途が特殊なCPUはいまだに16bitビッグエンディアンなんてのも現役であったりします。移植性の考慮が必要か不要かは、要件定義段階で議論しておく方が、後々ハッピーになるかもしれません。

 

コメント