Skip to content
Volver al blog
Tutoriales

Endianness: por qué los mismos bytes dan dos números distintos

Los mismos bytes 12 34 56 78 se leen como 0x12345678 o 0x78563412 según el lector. Endianness en JavaScript, Python, Go, PNG y GZIP, medida en línea.

13 min de lectura

Endianness: por qué los mismos bytes dan dos números distintos

Cuatro bytes en memoria: 12 34 56 78. Léelos con tres APIs de JavaScript y te devuelven dos números distintos. Eso es endianness, y el problema entero cabe en una tabla.

LecturaResultado
new DataView(buf).getUint32(0)0x12345678
new DataView(buf).getUint32(0, true)0x78563412
new Uint32Array(buf)[0]0x78563412

Nada está roto y nada lanza un error. Cada llamada aplica una regla distinta sobre qué extremo de un número multibyte va primero.

La pregunta útil no es «¿qué máquina tengo?», sino «¿bajo qué convención se escribieron estos bytes?». Un PNG en tu disco es big-endian. Un GZIP que esté al lado es little-endian. Tu CPU no vota en ninguno de los dos casos.

Todo lo que sigue se midió en Node v26.7.0 y Python 3.14.6 sobre macOS darwin arm64, donde os.endianness() devuelve LE y sys.byteorder devuelve little.

1. Qué son big-endian y little-endian

Toma el valor de 32 bits 0x12345678. Son cuatro bytes: 12 es el más significativo y 78 el menos. Endianness decide cuál de ellos cae en la dirección más baja.

DisposiciónDirección 0Dirección 1Dirección 2Dirección 3
Big-endian12345678
Little-endian78563412

Big-endian guarda primero el extremo grande, el mismo orden en que escribirías el número en papel. Little-endian guarda primero el extremo pequeño. Ninguno de los dos toca los bits dentro de un byte: 0x12 sigue siendo 0x12 en ambas disposiciones. Solo se mueven bytes enteros.

Si el paso de hexadecimal a binario es la parte que se te resiste, el conversor de bases coloca cada byte en binario junto a su forma hexadecimal, y la guía de conversión entre binario, hexadecimal y octal cubre la notación en sí.

1.1 Por qué hay dos

La división es histórica, no una cuestión de principios. Big-endian se lee igual que la gente escribe los números, y se impuso como convención de los protocolos de red lo bastante pronto como para quedarse. Little-endian ganó del lado de la CPU porque x86 lo usa y ARM lo trae por defecto. El argumento de eficiencia que repite casi cualquier artículo sobre el tema está medido en la sección 8, donde resulta valer alrededor de un 2%.

2. Endianness es una propiedad del formato, no de la plataforma

El orden de bytes lo fija quien escribió los bytes, no la máquina que los lee.

La demostración necesita una laptop y dos archivos. En la misma máquina arm64, dentro del mismo proceso, estos dos piden decodificaciones opuestas.

2.1 PNG es big-endian

El RFC 2083 exige enteros multibyte en orden de red, así que cada longitud, ancho y alto de un PNG es big-endian. La estructura de la cabecera es fija: ocho bytes de firma, luego una longitud de chunk de cuatro bytes, luego el tipo de chunk de cuatro caracteres, y después el ancho y el alto.

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
CampoBytesBig-endianLittle-endian
Longitud de IHDR00 00 00 0d13218,103,808
Ancho de la imagen00 00 04 b012002,953,052,160

2.2 GZIP es little-endian

El RFC 1952 §2.3.1 lo dice con todas las letras: primero el byte menos significativo. Los últimos cuatro bytes de un flujo gzip son ISIZE, el tamaño sin comprimir. Comprime 300 bytes de A y compruébalo:

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
CampoBytesLittle-endianBig-endian
ISIZE del tráiler2c 01 00 00300738,263,040

Misma máquina, mismo proceso, la misma primitiva de lectura de cuatro bytes. Si tu regla de trabajo es «mi máquina es little-endian, así que leo little-endian», uno de estos dos archivos se decodifica como basura. Guíate siempre por el formato.

3. JavaScript: dos APIs, dos valores por defecto opuestos

Las dos formas de ver un ArrayBuffer en el navegador y en Node se contradicen entre sí por defecto.

3.1 Endianness en DataView: decide el tercer argumento

Los métodos de DataView reciben un flag opcional littleEndian como último argumento. Si lo omites, obtienes big-endian. setUint32(0, x) y setUint32(0, x, false) son la misma llamada.

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

La escritura se comporta igual, pero al revés:

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 sigue a la plataforma, y no lo puedes cambiar

Uint32Array, Int16Array, Float64Array y los demás usan lo que use la CPU. No hay argumento, ni opción, ni flag en el constructor. En esta máquina arm64 eso significa little-endian, justo lo contrario del valor por defecto de DataView.

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

Así que un mismo ArrayBuffer leído con new DataView(buf).getUint32(0) y con new Uint32Array(buf)[0] da 0x12345678 y 0x78563412. Los dos son correctos. Están respondiendo preguntas distintas.

Las vistas de un byte son inmunes, porque el orden de bytes solo existe para unidades más anchas que un byte. Uint8Array e Int8Array nunca necesitan flag. Ensancha un paso y el problema vuelve:

const two = new ArrayBuffer(2);
new Uint16Array(two)[0] = 0x00ff;
hex(two); // 'ff 00'

3.3 Buffer de Node: el orden va en el nombre del método

Buffer se salta los valores por defecto y pone el orden en el nombre del método. Por eso el código de Node suele ser el más fácil de auditar.

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() invierte cada grupo de cuatro bytes y devuelve el mismo buffer, no una copia. Cómodo cuando tienes un array entero de enteros con el endianness equivocado; peligroso si olvidaste que ese buffer estaba compartido.

4. struct de Python: cinco prefijos, y lo que cuesta @

4.1 < > ! = @: los cinco prefijos de orden de bytes de struct pack

0x12345678 empaquetado como entero de 32 bits sin signo, una línea por prefijo:

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

! y > producen bytes idénticos porque el orden de bytes de red es big-endian. El desempaquetado lo refleja igual, y int.from_bytes da el mismo par:

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 @ y = se diferencian en el relleno, no en el orden de bytes

Ambos siguen a la plataforma, así que en esta máquina los dos escriben little-endian. La diferencia es la alineación, y cambia el tamaño de tu struct:

struct.calcsize('@ci')  # 8
struct.calcsize('=ci')  # 5
struct.calcsize('<ci')  # 5

Un char seguido de un int son cinco bytes de datos. Con @, el valor por defecto cuando no escribes ningún prefijo, Python inserta tres bytes de relleno para que el int empiece en un límite de cuatro bytes. Con = o con cualquier prefijo explícito de orden de bytes, el relleno desaparece.

Ese es el mecanismo detrás de un bug que se lee como imposible: alguien añade < para arreglar un problema de orden de bytes y la longitud del registro le cambia por debajo. El orden de bytes no tuvo nada que ver. Al abandonar @ también se apagó, en silencio, la alineación nativa.

5. El orden de bytes de red, y cómo lo escriben otros lenguajes

El orden de bytes de red es big-endian. Las cabeceras de TCP, UDP e IP llevan así todos sus campos multibyte, algo que viene de cuando el hardware big-endian era lo bastante común como para que alguien tuviera que elegir bando. C expone la conversión con htons, htonl, ntohs y ntohl: de host a red y de vuelta, para shorts y longs. En un host little-endian intercambian los bytes; en uno big-endian no hacen nada, y por eso el código que los omite funciona perfectamente hasta que se topa con otra máquina.

Go toma el camino contrario y se niega directamente a tener un valor por defecto:

import "encoding/binary"

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

binary.BigEndian y binary.LittleEndian son valores que nombras en el propio punto de llamada. No hay ninguna ruta que dependa de la plataforma ni ningún flag opcional que se te pueda olvidar, así que revisar el orden de bytes en Go se reduce a leer el identificador.

La misma disciplina vale para los datos en coma fija. Una muestra Q15 o Q31 es un entero corriente de 16 o 32 bits en cuanto sale de tu código, así que hereda la cuestión del orden de bytes junto con todo lo demás; el conversor de formato Q muestra el entero que hay detrás de la fracción, y ese entero es el que se ordena.

6. Los números en coma flotante también tienen orden de bytes

Un float no tiene nada de especial. IEEE 754 define el patrón de bits, y luego esos mismos cuatro u ocho bytes se colocan en el orden que exija el formato.

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'

El conversor IEEE 754 responde a la primera mitad de la pregunta: escribe 3.14159 con FP32 seleccionado y obtienes 0x40490FD0. Este artículo responde a la segunda mitad, que es en qué orden llegan esos cuatro bytes al archivo. La herramienta te da el valor; endianness te da la disposición.

La fila del double 0.1 es además la razón de que 0.1 + 0.2 se porte mal. Esos bytes 99 que se repiten son una expansión binaria que nunca termina, y la guía de precisión de coma flotante la desarma pieza a pieza.

6.1 El float 1.0 es 3f 80 00 00, o 00 00 80 3f

1.0 en FP32 es un canario útil porque su patrón de bytes es muy desigual. Big-endian escribe 3f 80 00 00; little-endian escribe 00 00 80 3f. Vuelca un formato binario que no conozcas, busca un campo que sepas que debería valer 1.0 y los dos ceros finales te dicen en qué extremo estás. Con double funciona igual: el mismo valor tiene seis bytes cero agrupados en un lado.

7. El orden de bytes en las codificaciones de texto: el BOM no es más que una declaración

UTF-16 y UTF-32 están hechos de unidades multibyte, así que chocan exactamente con el problema que describe este artículo. Su respuesta es dejar que el archivo anuncie su propio orden con una marca de orden de bytes (BOM):

Buffer.from('\uFEFF', 'utf16le').toString('hex'); // 'fffe' — U+FEFF is the BOM
Buffer.from('A', 'utf16le').toString('hex');      // '4100'

fffe al principio significa little-endian; feff significa big-endian. El BOM es el caso más habitual de un formato que declara su propio orden de bytes en vez de darlo por supuesto. La historia completa, incluido por qué UTF-8 no necesita BOM y qué te cuesta la marca cuando aparece sin invitación, está en la guía de codificación UTF-8, UTF-16 y Unicode.

8. ¿Es little-endian más rápido? Lo que dicen 40 millones de iteraciones

La afirmación de que little-endian es más eficiente aparece en los resúmenes de los buscadores y en la mayoría de las explicaciones introductorias sobre el tema. Se puede comprobar. Cuarenta millones de iteraciones de una sola lectura de 32 bits en esta máquina arm64:

Rutans/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
RelaciónFactor
DataView big-endian / little-endian1.021×
Buffer big-endian / little-endian3.354×

Esas dos relaciones miden cosas distintas, y quedarse solo con la segunda sería repetir el mismo error que cometen esos artículos.

El par de DataView es la medición honesta del costo del orden de bytes. Las dos llamadas compilan a la misma ruta inline en V8; la de big-endian arrastra una instrucción ARM REV extra para intercambiar los bytes. Eso es el 1.021×, alrededor de un 2%. Poco, pero no cero: no lo redondees a «gratis».

El par de Buffer mide algo completamente distinto. V8 tiene una ruta rápida dedicada para readUInt32LE que readUInt32BE no recibe, así que el 3.354× es una diferencia de implementación de un runtime concreto, no el precio de intercambiar bytes en una CPU. Citarlo como prueba de que big-endian es lento sería un error. Cambia de runtime y el número cambia con él.

La conclusión que respaldan los números: en hardware moderno, la conversión de orden de bytes es demasiado barata como para entrar en una discusión de diseño de formatos. El argumento histórico de eficiencia viene de una época anterior a las instrucciones de intercambio dedicadas. Elige el orden que ya usen tu protocolo o tus vecinos.

9. Cómo distinguir un bug de endianness de un bug corriente

«¿Cómo compruebo si mi máquina es big-endian o little-endian?» se resuelve con os.endianness() en Node y sys.byteorder en Python, una línea cada uno, y los buscadores ya imprimen la respuesta encima de los resultados. Pero es la pregunta equivocada en casi toda depuración real: cuando analizas un archivo o un paquete, decide el formato y tu CPU no interviene.

La habilidad útil es reconocer el síntoma.

9.1 Dos síntomas: el número absurdo y el número 256×

El escandaloso es fácil. Lee al revés el ancho del PNG de la sección 2 y obtienes 2,953,052,160 para una imagen de 1200 píxeles. Cualquier campo que debería ser una cuenta modesta y vuelve con miles de millones es un entero de 32 bits invertido mientras no se demuestre lo contrario.

El silencioso es el caro. Los bytes 00 00 01 00 se leen como 256 en big-endian y 65,536 en little-endian. Los dos parecen tamaños de buffer plausibles. Nada lanza un error, ninguna aserción salta, y el valor está equivocado por un factor de 256. Bugs así sobreviven a la revisión de código porque el número en pantalla parece razonable, lo que los pone en la misma familia que un BOM UTF-8 que rompe el parseo de un JSON: un detalle invisible a nivel de bytes con una superficie de error completamente engañosa, tratado en la guía del BOM UTF-8 y los errores de parseo de JSON.

Dos heurísticas para recordar. Los valores pequeños con tres bytes cero por delante son los que se dan la vuelta en silencio, porque ambas lecturas se quedan dentro de rango. Y si invertir los bytes a mano produce un número que tiene sentido, ya tienes tu respuesta sin tocar un depurador.

9.2 El orden en que conviene comprobar las cosas

  1. Comprueba primero la especificación del formato. El RFC 2083 dice que PNG es big-endian; el RFC 1952 §2.3.1 dice que GZIP es little-endian. Lo que haga tu máquina es irrelevante para ambos.
  2. Comprueba en segundo lugar el valor por defecto de tu lector. DataView.getUint32(0) es big-endian, Uint32Array usa el orden de la plataforma, struct.pack('@I', ...) usa el orden de la plataforma, y binary.BigEndian.Uint32 es lo que dice su nombre. La mayoría de los bugs de orden de bytes son un tercer argumento que falta o un prefijo que falta, no un malentendido profundo.
  3. Sospecha de la plataforma en último lugar. Importa cuando escribes un archivo con Uint32Array o con @ y lo mandas a otra arquitectura, e importa cuando comparas un volcado de memoria contra una especificación. Casi nunca importa cuando estás leyendo un formato bien definido que ya te dijo qué orden usa.

Preguntas frecuentes

¿Es mejor big-endian o little-endian?

Ni big-endian ni little-endian es mejor. El que especifique el formato es el correcto para ese formato, y rara vez puedes elegir. En rendimiento, una lectura DataView big-endian midió 1.021× la little-endian en esta máquina, alrededor de un 2%, demasiado poco para condicionar ninguna decisión de diseño.

¿Cómo sé si mi máquina es big-endian o little-endian?

os.endianness() en Node devuelve LE aquí, y sys.byteorder en Python devuelve little. Los dos son de una sola línea. Pero la pregunta importa menos de lo que parece: cuando analizas un archivo o un paquete, el formato dicta el orden de bytes y tu CPU no opina.

¿Es un bug que DataView y Uint32Array den números distintos del mismo buffer?

No, es el comportamiento documentado de DataView. DataView.getUint32(0) usa big-endian por defecto, mientras que Uint32Array sigue siempre a la plataforma, que es little-endian en x86 y en Apple Silicon. Los mismos bytes, dos convenciones. Pasa true como tercer argumento y DataView se pondrá de acuerdo.

¿Por qué cambió de tamaño mi struct al añadir <?

Porque abandonaste @, el valor por defecto, que añade relleno para la alineación nativa. struct.calcsize('@ci') es 8, mientras que struct.calcsize('=ci') y struct.calcsize('<ci') son ambos 5. Los tres bytes de relleno que iban antes del int se fueron junto con la alineación nativa.

¿El orden de bytes de red es big-endian o little-endian?

Big-endian. Es la convención que usan las cabeceras TCP/IP, y es la razón de que htons y htonl existan en C. En Python los prefijos ! y > producen bytes idénticos: struct.pack('!I', 0x12345678) y struct.pack('>I', 0x12345678) dan los dos 12 34 56 78.

¿El endianness afecta a UTF-8?

No. UTF-8 es un flujo de bytes, y cada punto de código se escribe como una secuencia ordenada de bytes individuales, así que no queda ninguna unidad multibyte que reordenar. UTF-16 y UTF-32 sí tienen ese problema, que es justamente por lo que llevan un BOM, como se ve en la sección 7.

¿Los arrays de un solo byte necesitan algún manejo del orden de bytes?

No. El orden de bytes solo existe para unidades más anchas que un byte, así que Uint8Array, Int8Array y los objetos bytes de Python son inmunes. Ensancha un paso y vuelve de inmediato: new Uint16Array(two)[0] = 0x00ff queda en memoria como ff 00 en esta máquina.

Etiquetas: endianness byte-order binary-data file-formats cross-platform

Artículos relacionados

Ver todos los artículos