Skip to content
ブログに戻る
チュートリアル

ビッグエンディアンとリトルエンディアン:同じバイト列が二つの数値になる理由

同じ 4 バイト 12 34 56 78 が 0x12345678 とも 0x78563412 とも読めます。JavaScript・Python・Go・PNG・GZIP のバイトオーダーを実測値でオンライン解説します。

13 分で読める

ビッグエンディアンとリトルエンディアン:同じバイト列が二つの数値になる理由

メモリ上に 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.byteorderlittle を返す。

1. big-endian と little-endian のバイトの並び

32 ビットの値 0x12345678 を例に取る。これは 4 バイトで、12 が最上位、78 が最下位だ。バイトオーダーは、そのどれが最も低いアドレスに置かれるかを決める。

配置アドレス 0アドレス 1アドレス 2アドレス 3
Big-endian12345678
Little-endian78563412

ビッグエンディアン(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-endianLittle-endian
IHDR の長さ00 00 00 0d13218,103,808
画像の幅00 00 04 b012002,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-endianBig-endian
トレーラの ISIZE2c 01 00 00300738,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 はプラットフォームに従い、変更できない

Uint32ArrayInt16ArrayFloat64Array などは、CPU が使っている順序をそのまま使う。引数もオプションもコンストラクタのフラグも存在しない。この arm64 マシンではそれが little-endian ということになり、DataView の既定値とちょうど正反対である。

new Uint32Array(buf)[0] = 0x12345678;
hex(buf); // '78 56 34 12'

したがって、ひとつの ArrayBuffernew DataView(buf).getUint32(0) で読むと 0x12345678new Uint32Array(buf)[0] で読むと 0x78563412 が返る。どちらも正しい。それぞれ別の質問に答えているだけだ。

1 バイト幅のビューはこの問題と無縁である。バイトオーダーは 1 バイトより広い単位にしか存在しないからだ。Uint8ArrayInt8Array にフラグは要らない。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 はこの変換を htonshtonlntohsntohl として提供する。ホストからネットワークへ、およびその逆を、short と long について行うものだ。little-endian のホストではバイトを入れ替え、big-endian のホストでは何もしない。だから、これらを省いたコードでも、別のマシンに出会うまでは平然と動く。

Go は逆の方針を取り、既定値というものを一切持たない。

import "encoding/binary"

v := binary.BigEndian.Uint32(b)
binary.LittleEndian.PutUint32(b, v)

binary.BigEndianbinary.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-endian1.021×
Buffer の big-endian / little-endian3.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 確認していく順序

  1. まずフォーマットの仕様を確認する。RFC 2083 は PNG が big-endian だと言い、RFC 1952 §2.3.1 は GZIP が little-endian だと言う。自分のマシンが何をしていようと、どちらにも関係がない。
  2. 次に、読み出す側の既定値を確認する。DataView.getUint32(0) は big-endian、Uint32Array はプラットフォームの順序、struct.pack('@I', ...) もプラットフォームの順序、binary.BigEndian.Uint32 は名前が言っているとおりだ。バイトオーダーのバグの大半は、第 3 引数の付け忘れかプレフィックスの付け忘れであって、深い誤解ではない。
  3. プラットフォームを疑うのは最後でいい。Uint32Array@ でファイルを書いて別のアーキテクチャに送るときには効いてくるし、メモリダンプを仕様と突き合わせるときにも効いてくる。だが、どの順序を使うかをすでに明言している、定義の明確なフォーマットを読んでいるかぎり、まず問題にならない。

よくある質問

big-endian と little-endian はどちらが優れているのか?

ビッグエンディアンもリトルエンディアンも優れてはいない。そのフォーマットが指定しているほうが、そのフォーマットにとって正しい順序であり、選ぶ余地があることはめったにない。性能について言えば、このマシンでは big-endian の DataView 読み出しが little-endian の 1.021×、およそ 2% だった。設計上の判断を左右するには、あまりに小さい。

自分のマシンが big-endian か little-endian かはどう調べるのか?

Node の os.endianness() はここでは LE を返し、Python の sys.byteorderlittle を返す。どちらも 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 に htonshtonl が存在する理由でもある。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 バイトより広い単位にしか存在しないので、Uint8ArrayInt8Array、そして Python の bytes オブジェクトは無縁である。1 段階広げれば、即座に戻ってくる。new Uint16Array(two)[0] = 0x00ff は、このマシンでは ff 00 としてメモリに落ちる。

タグ: endianness byte-order binary-data file-formats cross-platform