ビッグエンディアンとリトルエンディアン:同じバイト列が二つの数値になる理由
メモリ上に 4 バイトが並んでいる。12 34 56 78 だ。これを 3 つの異なる JavaScript API で読むと、返ってくる数値は 2 種類になる。その食い違いがエンディアン(バイトオーダー)で、話の全体はこの 3 行に収まっている。
| 読み方 | 結果 |
|---|---|
new DataView(buf).getUint32(0) | 0x12345678 |
new DataView(buf).getUint32(0, true) | 0x78563412 |
new Uint32Array(buf)[0] | 0x78563412 |
壊れているものは何もないし、例外も飛ばない。どの呼び出しも、マルチバイトの数値の「どちらの端が先に来るか」について別々のルールを当てているだけである。
問いの立て方を変えると、たいていの時間が浮く。訊くべきは「自分のマシンはどちらか」ではなく「このバイト列は誰の規約で書かれたか」だ。ディスク上の PNG は big-endian、その隣にある GZIP は little-endian で、どちらも CPU の事情とは無関係にそう決まっている。
以下の内容はすべて、macOS darwin arm64 上の Node v26.7.0 と Python 3.14.6 で実測した。この環境では os.endianness() が LE を、sys.byteorder が little を返す。
1. big-endian と little-endian のバイトの並び
32 ビットの値 0x12345678 を例に取る。これは 4 バイトで、12 が最上位、78 が最下位だ。バイトオーダーは、そのどれが最も低いアドレスに置かれるかを決める。
| 配置 | アドレス 0 | アドレス 1 | アドレス 2 | アドレス 3 |
|---|---|---|---|---|
| Big-endian | 12 | 34 | 56 | 78 |
| Little-endian | 78 | 56 | 34 | 12 |
ビッグエンディアン(big-endian)は大きい端を先に置く。紙に数値を書くときと同じ並びだ。リトルエンディアン(little-endian)は小さい端を先に置く。どちらもバイトの内部のビットには手を触れない。0x12 はどちらの配置でも 0x12 のままで、動くのはバイト単位だけである。
16 進から 2 進への変換のところが怪しいなら、基数変換ツール が各バイトを 16 進表記と並べて 2 進で見せてくれる。記法そのものについては 2 進数・16 進数・8 進数の変換ガイド にまとめてある。
1.1 なぜ二つあるのか
分かれた理由は原理ではなく経緯だ。big-endian は人間が数値を書く順序と一致していて、ネットワークプロトコルの規約として早くに定着したので、そのまま残った。little-endian は CPU 側で勝った。x86 が採用し、ARM も既定でこちらを使う。どちらが効率的かという議論は昔から繰り返されているので、第 8 節で実際に測った。差はおよそ 2% だった。
2. エンディアンはプラットフォームではなくフォーマットの性質
バイトオーダーを決めるのは、そのバイト列を書いた側であって、読む側のマシンではない。証明にはノート PC が 1 台とファイルが 2 つあれば足りる。同じ arm64 マシンの、同じプロセスの中で、この 2 つは正反対のデコードを要求する。
2.1 PNG は big-endian
RFC 2083 はマルチバイト整数をネットワークバイトオーダーで書くことを要求している。つまり PNG の中の長さ・幅・高さは、すべて big-endian だ。ヘッダの配置は固定されている。8 バイトのシグネチャ、4 バイトのチャンク長、4 文字のチャンク型、そして幅と高さの順である。
const fs = require('node:fs');
const png = fs.readFileSync('public/og/base-converter.png');
png.subarray(0, 8).toString('hex'); // '89504e470d0a1a0a' — PNG signature
png.readUInt32BE(8); // 13 — IHDR chunk length
png.readUInt32BE(16); // 1200 — image width
png.readUInt32BE(20); // 630 — image height
png.readUInt32LE(16); // the same four bytes, read the wrong way
| フィールド | バイト列 | Big-endian | Little-endian |
|---|---|---|---|
| IHDR の長さ | 00 00 00 0d | 13 | 218,103,808 |
| 画像の幅 | 00 00 04 b0 | 1200 | 2,953,052,160 |
2.2 GZIP は little-endian
RFC 1952 §2.3.1 が明記している。最下位バイトが先だ。gzip ストリームの末尾 4 バイトは ISIZE、つまり非圧縮時のサイズである。A を 300 バイト分圧縮して確かめてみる。
python3 -c "import gzip,sys; sys.stdout.buffer.write(gzip.compress(b'A'*300))" > a.gz
const gz = fs.readFileSync('a.gz');
gz.subarray(-4).toString('hex'); // '2c010000'
gz.readUInt32LE(gz.length - 4); // 300 — correct
gz.readUInt32BE(gz.length - 4); // the same four bytes, read the wrong way
| フィールド | バイト列 | Little-endian | Big-endian |
|---|---|---|---|
| トレーラの ISIZE | 2c 01 00 00 | 300 | 738,263,040 |
同じマシン、同じプロセス、同じ 4 バイト読み出しのプリミティブだ。「自分のマシンは little-endian だから little-endian で読む」という運用ルールでいくと、この 2 つのファイルのどちらかは必ずゴミにデコードされる。決めるのは、いつでもフォーマットのほうである。
3. JavaScript:二つの API、正反対の既定値
ブラウザと Node で事故が起きるのはここだ。ArrayBuffer を覗くための 2 つの方法は、既定の状態で互いに食い違う。
3.1 DataView のバイトオーダー:第 3 引数が決める
DataView のメソッドは、最後の引数として省略可能な littleEndian フラグを取る。省略すれば big-endian になる。setUint32(0, x) と setUint32(0, x, false) は同じ呼び出しだ。
const buf = new ArrayBuffer(4);
new Uint8Array(buf).set([0x12, 0x34, 0x56, 0x78]);
const dv = new DataView(buf);
dv.getUint32(0).toString(16); // '12345678' — big-endian, the default
dv.getUint32(0, true).toString(16); // '78563412' — littleEndian: true
書き込み側でも、同じ規則が逆向きに働く。
const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join(' ');
dv.setUint32(0, 0x12345678);
hex(buf); // '12 34 56 78'
dv.setUint32(0, 0x12345678, true);
hex(buf); // '78 56 34 12'
3.2 TypedArray はプラットフォームに従い、変更できない
Uint32Array、Int16Array、Float64Array などは、CPU が使っている順序をそのまま使う。引数もオプションもコンストラクタのフラグも存在しない。この arm64 マシンではそれが little-endian ということになり、DataView の既定値とちょうど正反対である。
new Uint32Array(buf)[0] = 0x12345678;
hex(buf); // '78 56 34 12'
したがって、ひとつの ArrayBuffer を new DataView(buf).getUint32(0) で読むと 0x12345678、new Uint32Array(buf)[0] で読むと 0x78563412 が返る。どちらも正しい。それぞれ別の質問に答えているだけだ。
1 バイト幅のビューはこの問題と無縁である。バイトオーダーは 1 バイトより広い単位にしか存在しないからだ。Uint8Array と Int8Array にフラグは要らない。1 段階だけ広げれば、問題は戻ってくる。
const two = new ArrayBuffer(2);
new Uint16Array(two)[0] = 0x00ff;
hex(two); // 'ff 00'
3.3 Node の Buffer:順序をメソッド名で名指しする
Buffer は既定値を持たない。順序はメソッド名に書いてある。おかげで Node のコードは、目で追うだけで監査できる。
const b = Buffer.from([0x12, 0x34, 0x56, 0x78]);
b.readUInt32BE(0).toString(16); // '12345678'
b.readUInt32LE(0).toString(16); // '78563412'
b.swap32().toString('hex'); // '78563412' — mutates b in place
swap32() は 4 バイトごとに並びを反転し、コピーではなく同じバッファを返す。逆のバイトオーダーの整数が配列まるごとある場面では便利だが、そのバッファが共有されていることを忘れていると危険だ。
4. Python の struct:五つのプレフィックスと、@ が足すパディング
4.1 < > ! = @:struct のバイトオーダープレフィックス五種
0x12345678 を符号なし 32 ビット整数としてパックする。プレフィックスごとに 1 行ずつ並べてみる。
import struct
struct.pack('<I', 0x12345678).hex(' ') # '78 56 34 12' little-endian
struct.pack('>I', 0x12345678).hex(' ') # '12 34 56 78' big-endian
struct.pack('!I', 0x12345678).hex(' ') # '12 34 56 78' network order
struct.pack('=I', 0x12345678).hex(' ') # '78 56 34 12' native order, standard sizes
struct.pack('@I', 0x12345678).hex(' ') # '78 56 34 12' native order, native alignment
ネットワークバイトオーダーは big-endian なので、! と > は同じバイト列を吐く。アンパックも鏡写しになり、int.from_bytes でも同じ 2 通りが得られる。
hex(struct.unpack('>I', b'\x12\x34\x56\x78')[0]) # '0x12345678'
hex(struct.unpack('<I', b'\x12\x34\x56\x78')[0]) # '0x78563412'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'big')) # '0x12345678'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'little')) # '0x78563412'
4.2 @ と = が違うのはパディングであって、バイトオーダーではない
どちらもプラットフォームに従うので、このマシンではどちらも little-endian で書き出す。違うのはアラインメントで、これは構造体のサイズを変えてしまう。
struct.calcsize('@ci') # 8
struct.calcsize('=ci') # 5
struct.calcsize('<ci') # 5
char の後ろに int が続く構造は、データとしては 5 バイトだ。プレフィックスを何も書かなかったときの既定である @ では、int が 4 バイト境界から始まるように Python が 3 バイトのパディングを挿入する。= あるいは明示的なバイトオーダープレフィックスを付ければ、そのパディングは消える。
ここから、つながりの見えないバグが出る。バイトオーダーを直すつもりで < を足したら、足元でレコード長のほうが変わってしまう。犯人はバイトオーダーではない。@ から離れた時点で、ネイティブアラインメントも黙って切れている。
5. ネットワークバイトオーダーと、他の言語での書き表し方
ネットワークバイトオーダーは big-endian である。TCP・UDP・IP のヘッダは、マルチバイトのフィールドをすべてその順序で運ぶ。big-endian のハードウェアがまだ十分に多く、誰かが片方を選ばざるをえなかった時代まで遡る決定だ。C はこの変換を htons、htonl、ntohs、ntohl として提供する。ホストからネットワークへ、およびその逆を、short と long について行うものだ。little-endian のホストではバイトを入れ替え、big-endian のホストでは何もしない。だから、これらを省いたコードでも、別のマシンに出会うまでは平然と動く。
Go は逆の方針を取り、既定値というものを一切持たない。
import "encoding/binary"
v := binary.BigEndian.Uint32(b)
binary.LittleEndian.PutUint32(b, v)
binary.BigEndian と binary.LittleEndian は、呼び出す側で名指しする値だ。プラットフォーム依存の経路もなければ、付け忘れる省略可能なフラグもない。Go のバイトオーダーは、識別子を読めばレビューが終わる。
同じ規律は固定小数点のデータにも当てはまる。Q15 や Q31 のサンプルは、コードの外へ出てしまえばただの 16 ビットまたは 32 ビットの整数であり、他のすべてと同じようにバイトオーダーの問いを引き継ぐ。Q フォーマット変換ツール を使うと、小数の裏にいる整数が見える。順序が付くのは、その整数のほうだ。
6. 浮動小数点数にもエンディアンがある
浮動小数点数が特別なわけではない。IEEE 754 がビットパターンを定め、その 4 バイトないし 8 バイトが、フォーマットの要求する順序で並べられる。
struct.pack('>f', 1.0).hex(' ') # '3f 80 00 00'
struct.pack('<f', 1.0).hex(' ') # '00 00 80 3f'
struct.pack('>d', 0.1).hex(' ') # '3f b9 99 99 99 99 99 9a'
struct.pack('<d', 0.1).hex(' ') # '9a 99 99 99 99 99 b9 3f'
IEEE 754 変換ツール が答えるのは問いの前半だ。FP32 を選んで 3.14159 と入力すれば 0x40490FD0 が出る。残る後半、つまりその 4 バイトがどの順序でファイルに届くかを決めるのがバイトオーダーである。
double 0.1 の行は、0.1 + 0.2 がおかしな振る舞いをする理由でもある。繰り返される 99 のバイトは終わることのない 2 進展開であり、それを分解しているのが 浮動小数点数の精度ガイド だ。
6.1 float の 1.0 は 3f 80 00 00、あるいは 00 00 80 3f
FP32 の 1.0 はバイトパターンが極端に偏っているので、カナリアとして使い勝手がいい。big-endian なら 3f 80 00 00、little-endian なら 00 00 80 3f と書かれる。見慣れないバイナリフォーマットをダンプし、1.0 が入っているはずのフィールドを見つければ、末尾に並ぶ 2 つのゼロがどちらの端にいるかを教えてくれる。double でも同じ手が使える。この場合は、同じ値のゼロバイト 6 個が片側に固まる。
7. テキストエンコーディングのエンディアン:BOM は宣言にすぎない
UTF-16 と UTF-32 はマルチバイトの単位でできているので、この記事が説明している問題にそのままぶつかる。その答えが、バイトオーダーマーク(BOM)でファイル自身に順序を名乗らせることだった。
Buffer.from('\uFEFF', 'utf16le').toString('hex'); // 'fffe' — U+FEFF is the BOM
Buffer.from('A', 'utf16le').toString('hex'); // '4100'
先頭の fffe は little-endian を、feff は big-endian を意味する。つまり BOM は、フォーマットがバイトオーダーを前提にせず自分で宣言している例だ。UTF-8 に BOM が要らない理由や、招かれざる BOM が紛れ込んだときに何を失うかも含めた全体像は、UTF-8・UTF-16 と Unicode エンコーディングのガイド にある。
8. little-endian は速いのか:40,000,000 回のイテレーションが言うこと
little-endian のほうが効率的だという主張は、検索エンジンの要約にも入門的な解説記事にも出てくる。測れば済む話なので測った。この arm64 マシンで、32 ビットの読み出し 1 回を 40,000,000 回繰り返した結果がこれである。
| 経路 | ns/op |
|---|---|
DataView.getUint32(4, true)(little-endian) | 4.4267 |
DataView.getUint32(4)(big-endian) | 4.5200 |
Buffer.readUInt32LE(4) | 1.5173 |
Buffer.readUInt32BE(4) | 5.0893 |
| 比率 | 倍率 |
|---|---|
| DataView の big-endian / little-endian | 1.021× |
| Buffer の big-endian / little-endian | 3.354× |
この 2 つの比は、測っているものが違う。後者だけを報告するのは、それらの記事が犯しているのと同じ誤りだ。
バイトオーダーのコストを正直に測っているのは DataView の組み合わせのほうである。どちらの呼び出しも V8 では同じインライン経路にコンパイルされ、big-endian 側だけがバイトを入れ替える ARM の REV 命令を 1 つ余分に持つ。それが 1.021×、およそ 2% だ。小さいが、ゼロではない。「タダ」に丸めてはいけない。
Buffer の組み合わせが測っているのは、まったく別のものだ。V8 にはリトルエンディアン側の readUInt32LE 専用の高速経路があり、ビッグエンディアン側の readUInt32BE にはそれがない。したがって 3.354× は、ひとつのランタイムにおける実装の差であって、CPU 上でバイトを入れ替える代価ではない。これを big-endian が遅い証拠として引くのは誤りである。ランタイムを変えれば、数値も一緒に変わる。
現代のハードウェアでは、バイトオーダーの変換はフォーマット設計の議論に持ち込むには安すぎる。歴史的な効率の議論は、専用のスワップ命令が存在しなかった時代から来ている。自分のプロトコルや、周囲がすでに使っている順序を選べばいい。
9. エンディアンのバグを普通のバグと見分ける
「自分のマシンが big-endian か little-endian かをどう調べるか」なら、Node の os.endianness() と Python の sys.byteorder が 1 行ずつで答える。検索エンジンも結果の上にもう答えを出している。ただし実際のデバッグでは、たいていこれが誤った問いだ。ファイルやパケットを解析している場面で順序を決めるのはフォーマットのほうで、CPU は関与していない。
先に身につくと効くのは、症状を見分けるほうの技能だ。
9.1 二つの症状:ありえない数値と、256 倍の数値
派手なほうは簡単だ。第 2 節の PNG の幅を逆向きに読めば、1200 ピクセルの画像に対して 2,953,052,160 が返ってくる。ほどほどの個数であるはずのフィールドが十億の単位で返ってきたら、反証が出るまではエンディアンが反転した 32 ビット整数だと思っていい。
静かなほうが、高くつく。バイト列 00 00 01 00 は big-endian で 256、little-endian で 65,536 と読める。どちらもバッファサイズとしてもっともらしく見える。例外は飛ばず、アサーションも発火せず、値だけが 256 倍ずれている。この種のバグがコードレビューを生き延びるのは、画面に出た数値が妥当に見えるからだ。その点では、UTF-8 の BOM が JSON のパースを壊すのと同じ家族に属している。目に見えないバイトレベルの詳細なのに、エラーの見え方が完全にミスリードするタイプだ。そちらは UTF-8 BOM で JSON.parse が失敗する原因と削除方法 で扱っている。
覚えておく価値のある経験則が 2 つある。先頭にゼロバイトが 3 つ並ぶ小さな値は、どちらの読み方でも範囲に収まってしまうので、静かに化ける。そして、手でバイトを反転させて意味の通る数値になったなら、デバッガに触れる前に答えは出ている。
9.2 確認していく順序
- まずフォーマットの仕様を確認する。RFC 2083 は PNG が big-endian だと言い、RFC 1952 §2.3.1 は GZIP が little-endian だと言う。自分のマシンが何をしていようと、どちらにも関係がない。
- 次に、読み出す側の既定値を確認する。
DataView.getUint32(0)は big-endian、Uint32Arrayはプラットフォームの順序、struct.pack('@I', ...)もプラットフォームの順序、binary.BigEndian.Uint32は名前が言っているとおりだ。バイトオーダーのバグの大半は、第 3 引数の付け忘れかプレフィックスの付け忘れであって、深い誤解ではない。 - プラットフォームを疑うのは最後でいい。
Uint32Arrayや@でファイルを書いて別のアーキテクチャに送るときには効いてくるし、メモリダンプを仕様と突き合わせるときにも効いてくる。だが、どの順序を使うかをすでに明言している、定義の明確なフォーマットを読んでいるかぎり、まず問題にならない。
よくある質問
big-endian と little-endian はどちらが優れているのか?
ビッグエンディアンもリトルエンディアンも優れてはいない。そのフォーマットが指定しているほうが、そのフォーマットにとって正しい順序であり、選ぶ余地があることはめったにない。性能について言えば、このマシンでは big-endian の DataView 読み出しが little-endian の 1.021×、およそ 2% だった。設計上の判断を左右するには、あまりに小さい。
自分のマシンが big-endian か little-endian かはどう調べるのか?
Node の os.endianness() はここでは LE を返し、Python の sys.byteorder は little を返す。どちらも 1 行で済む。ただしこの問いは、見かけほど重要ではない。ファイルやパケットを解析するとき、エンディアンを指示するのはフォーマットであって、CPU に発言権はない。
DataView と Uint32Array が同じバッファから違う数値を返す。これはバグか?
バグではなく、DataView の仕様どおりの挙動だ。DataView.getUint32(0) は既定で big-endian、Uint32Array は常にプラットフォームに従い、x86 と Apple Silicon ではそれが little-endian になる。同じバイト列に、2 つの規約があるだけである。第 3 引数に true を渡せば、DataView のほうが合わせてくれる。
< を足したら struct のレイアウトのサイズが変わったのはなぜか?
既定である @ から離れたからだ。@ はネイティブアラインメントのためにパディングを入れる。struct.calcsize('@ci') は 8 で、struct.calcsize('=ci') と struct.calcsize('<ci') はどちらも 5 になる。int の前にあった 3 バイトのパディングが、ネイティブアラインメントと一緒に去っていったのである。
ネットワークバイトオーダーは big-endian か little-endian か?
big-endian だ。TCP/IP のヘッダが使っている規約であり、C に htons と htonl が存在する理由でもある。Python では ! と > のプレフィックスが同一のバイト列を生む。struct.pack('!I', 0x12345678) と struct.pack('>I', 0x12345678) は、どちらも 12 34 56 78 になる。
エンディアンは UTF-8 に影響するか?
しない。UTF-8 はバイトストリームであり、各コードポイントは個々のバイトが順序付けられた列として書かれる。並べ替える余地のあるマルチバイトの単位が残っていないのだ。UTF-16 と UTF-32 にはその問題があり、だからこそ BOM を持ち歩いている。第 7 節で扱ったとおりである。
1 バイトの配列にエンディアンの処理は必要か?
不要だ。バイトオーダーは 1 バイトより広い単位にしか存在しないので、Uint8Array、Int8Array、そして Python の bytes オブジェクトは無縁である。1 段階広げれば、即座に戻ってくる。new Uint16Array(two)[0] = 0x00ff は、このマシンでは ff 00 としてメモリに落ちる。