エンディアンとデータアライメント ─ バイト順とアクセス境界
なぜ同じ整数がマシンによって逆順のバイト列になり、構造体に謎の隙間が空くのか。バイト順・自然アライメント・パディングを原理から押さえ、通信プロトコルや低レベルI/Oの落とし穴を避けられます。
- 1.エンディアンは多バイト値をメモリ/伝送路に並べる順序の規約で、最下位バイトを先頭に置くのがリトル(x86/Armの既定)、最上位を先頭に置くのがビッグ(ネットワークバイト順)。同一バイト幅でも順序が逆なので、境界をまたぐ時はバイトスワップが要る。
- 2.CPUはアクセス対象を自然アライメント(サイズの倍数アドレス)に置くことを前提に最適化され、非整列アクセスはアーキテクチャによって遅延ペナルティで済むか、バスエラー/SIGBUSのフォルトになる。
- 3.コンパイラは各メンバを自身のアライメント境界へ揃えるためパディングを挿入し、構造体全体も最大メンバ境界へ整える。メンバ順序やpacked指定でサイズと移植性が変わる。
バイト順という規約
CPU レジスタの中では 32bit 整数 0x0A0B0C0D は単一の数として扱われ、バイトの「順序」という概念はありません。順序が問題になるのは、その値をバイト単位でアドレス付けされたメモリや伝送路へ展開する瞬間です。1ワードを構成する複数バイトを、どのアドレスへどの並びで置くかの規約がエンディアンです。
- リトルエンディアン: 最下位バイト(LSB)を最小アドレスに置く。
0x0A0B0C0Dはアドレス昇順に0D 0C 0B 0A。x86/x86-64、Arm(既定)、RISC-V が採用。 - ビッグエンディアン: 最上位バイト(MSB)を最小アドレスに置く。同じ値が
0A 0B 0C 0D。SPARC や古い PowerPC、そして後述のネットワークバイト順がこれ。
値 0x0A0B0C0D を 4 バイトに展開
アドレス : +0 +1 +2 +3
little : 0D 0C 0B 0A ← LSB が先頭
big : 0A 0B 0C 0D ← MSB が先頭
重要なのは、同じマシン内で処理が完結する限りエンディアンは見えないことです。書いた順と同じ順で読めば値は保たれます。問題が顕在化するのは、バイト列が別のエンディアン世界へ渡る境界、すなわちネットワーク通信、ファイル/バイナリ形式の交換、異種プロセッサ間の共有メモリです。
エンディアンが規定するのは「バイトの並び順」です。1 バイト内のビットの並び(ビットエンディアン)は通常 CPU 命令から不可視で、シリアル通信路(UART, I2C など)の物理層が別途決めます。バイト順とビット順は独立に議論してください。
ネットワークバイト順とバイトスワップ
TCP/IP のヘッダ(ポート番号、IP アドレス、チェックサム等)はビッグエンディアンで符号化すると定められており、これをネットワークバイト順と呼びます。リトルエンディアンのホストはソケットに値を渡す前後で変換が必要で、htons/htonl(host to network)、ntohs/ntohl(network to host)がその橋渡しをします。リトルエンディアン機ではこれらは実質バイトスワップ、ビッグエンディアン機では恒等関数になります。
uint16_t swap16(uint16_t v) {
return (v >> 8) | (v << 8);
}
uint32_t swap32(uint32_t v) {
return (v >> 24)
| ((v >> 8) & 0x0000FF00u)
| ((v << 8) & 0x00FF0000u)
| (v << 24);
}
実機ではこのシフト演算ではなく、専用命令(x86 の BSWAP/MOVBE、Arm の REV)にコンパイルされ 1 命令で済みます。コンパイラ組込みの __builtin_bswap32 などを使うのが定石です。
共用体やポインタで「多バイト値の先頭バイトが LSB か MSB か」を覗けば判定できます。uint32_t x = 1; のとき、先頭バイト(最小アドレス)が 0x01 ならリトル、0x00(残りに 0x01)ならビッグです。ただし移植性のあるコードは実行時判定に頼らず、入出力の境界で常に明示的なバイト順変換を通すのが安全です。
ミドルエンディアン(PDP-11 の 0B 0A 0D 0C のような語内入れ替え)も歴史的に存在しますが、現代の汎用 CPU はリトルかビッグの二択と考えて差し支えありません。
自然アライメントとアクセス境界
アライメントは、データを置くアドレスがそのサイズの倍数になっている性質です。N バイトの値がアドレス addr % N == 0 に置かれている状態を自然アライメントといいます。4 バイト整数はアドレス 0,4,8,… に、8 バイト値は 0,8,16,… に置くのが自然です。
なぜ境界が要るのか。メモリやキャッシュはワード(またはキャッシュライン)単位で読み出されます。自然整列していれば、1 つの値は必ず単一のワード/ライン内に収まり、1 回のアクセスで取得できます。境界をまたいで配置されると、2 つのワードを読み、両者から必要部分を切り出して結合する追加処理が要ります。
4Bワード境界 : | w0:0-3 | w1:4-7 | w2:8-11 |
整列 addr=4 : [int ] ← 1ワードに収まる
非整列 addr=6 : [in][t ] ← w1とw2にまたがる
| 挙動 | 代表アーキテクチャ | 非整列アクセスの結果 |
|---|---|---|
| 許容(ペナルティ付き) | x86-64, 近年のArm | 余分なバスサイクルで遅くなるが完了する |
| フォルト(原則禁止) | 古いMIPS/SPARC, Arm一部状況 | バスエラー/SIGBUS で停止 |
| 命令で区別 | Arm/SSE等のベクタロード | 整列前提命令だけがフォルト |
x86-64 は歴史的経緯から通常のロード/ストアで非整列を許しますが、無料ではありません。値がキャッシュラインをまたぐ(split access)と内部で 2 アクセスに分割され、レイテンシとスループットが落ちます。一方、整列を前提に最適化された SIMD 命令(古い MOVAPS など)は非整列アドレスで #GP 例外を出します。Arm では多くの通常アクセスが非整列を許容する一方、排他ロードやアトミックは整列を要求し、違反すると SIGBUS になります。
char* バッファの途中アドレスを uint32_t* にキャストして直接デリファレンスするのは、たとえ x86 で動いても C/C++ 規格上は未定義動作です。整列を要求するアーキテクチャでクラッシュするうえ、コンパイラが「ポインタは整列済み」と仮定してベクタ化し、誤コードを生むことがあります。バイト列から多バイト値を取り出すときは memcpy でローカル変数へコピーするのが正解で、最適化後はゼロコストになります。アトミック変数の非整列配置も同様に厳禁で、原子性が壊れます(詳細はハードウェアアトミック操作)。
構造体パディングとパッキング
コンパイラは構造体の各メンバを自身の自然アライメント境界に置くため、必要に応じてメンバ間にパディング(詰め物)を挿入します。さらに、配列にしたとき各要素の先頭が整列するよう、構造体全体のサイズを最大メンバのアライメントの倍数へ切り上げます(末尾パディング)。
struct A { // 1+3+4+1+7+8 = 24 バイト
char a; // offset 0
// 3 バイトのパディング
int b; // offset 4 (4の倍数へ)
char c; // offset 8
double d; // offset 16 (8の倍数へ。9-15はパディング)
}; // 全体は8の倍数=24バイト
メンバの宣言順を変えるだけでサイズが変わります。大きい型から小さい型へ並べると隙間が詰まり、上の例も再配置すれば 24 → 16 バイトに縮みます。キャッシュ効率(フォルスシェアリングとライン競合)にも直結するため、ホットな構造体ではメンバ順序が性能を左右します。
| 指定 | 意味 | 副作用 |
|---|---|---|
| 既定(自然整列) | 各メンバを境界へ揃えパディング挿入 | 高速・移植的だがサイズ増 |
| __attribute__((packed)) | パディングを全廃し詰める | サイズ最小だがメンバが非整列化 |
| #pragma pack(n) | 境界を n バイトに制限 | サイズと速度の妥協点を指定 |
packed はワイヤフォーマットやハードレジスタのマッピングに便利ですが、メンバが非整列に置かれるため、その値へのアクセスはコンパイラがバイト単位の読み書きに展開し(フォルトを避けるため)、整列時より遅くなります。「サイズは縮むがアクセスは遅くなる」というトレードオフを理解して使うべきです。
packed でパディングを消しても、エンディアンの差は残ります。リトルエンディアン機が packed 構造体をそのまま送信し、ビッグエンディアン機が受け取れば、各多バイトメンバの値が壊れます。さらに packed メンバのアドレスを取って非整列ポインタとして渡すと、整列前提のアーキテクチャでフォルトします。プロトコルやファイル形式は構造体の生コピーで表現せず、フィールドごとに明示的にバイト順変換しながら直列化するのが鉄則です。
まとめ
- エンディアンは多バイト値をバイト列へ並べる規約で、LSB 先頭がリトル(x86/Arm 既定)、MSB 先頭がビッグ(ネットワークバイト順)。同一マシン内では不可視で、別エンディアンとの境界でだけバイトスワップが要る。
- 自然アライメント(サイズの倍数アドレス)はワード/ライン単位アクセスを 1 回で完結させる前提条件で、非整列はペナルティかフォルトを招く。
char*の非整列キャストは未定義動作なのでmemcpyで取り出す。 - パディングは各メンバと構造体全体を境界へ整えるために挿入され、メンバ順序やサイズを左右する。
packedはサイズを縮めるがアクセスを遅くし、エンディアン差は解決しない。
低レベルのデータ表現は上位の機構へ波及します。DMA や PCIe のディスクリプタ/レジスタは厳密なバイト順とアライメントを要求し(PCIeアーキテクチャ)、メモリコントローラのアクセス効率も境界配置に左右されます(メモリコントローラのスケジューリング)。ベクタ化された一括処理では整列ロードの可否がそのままスループットに効きます(SIMDとベクタ処理)。
CPU/メモリ/ディスク Article
エンディアンとデータアライメント ─ バイト順とアクセス境界を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
エンディアン
比較で見る軸
難易度: advanced / カテゴリ: CPU/メモリ/ディスク / タグ数: 5
導入後に効く点
CPUはアクセス対象を自然アライメント(サイズの倍数アドレス)に置くことを前提に最適化され、非整列アクセスはアーキテクチャによって遅延ペナルティで済むか、バスエラー/SIGBUSのフォルトになる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- CPU/メモリ/ディスク
- タグ数
- 5
判断チェックリスト
- 自社の用途が「エンディアン / アライメント」に近いか確認する。
- 強みである「エンディアンは多バイト値をメモリ/伝送路に並べる順序の規約で、最下位バイトを先頭に置くのがリトル(x86/Armの既定)、最上位を先頭に置くのがビッグ(ネットワークバイト順)。同一バイト幅でも順序が逆なので、境界をまたぐ時はバイトスワップが要る。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。