Operaciones binarias y bit a bit en PHP



Recientemente noté que en diferentes proyectos tengo que escribir activamente operaciones bit a bit en PHP. Esta es una habilidad muy interesante y útil que resulta útil desde la lectura de binarios hasta la emulación de procesadores.



PHP tiene muchas herramientas para ayudarlo a manipular datos binarios, pero quiero advertirle de inmediato: si desea una eficiencia de nivel súper bajo, entonces este lenguaje no es para usted.



¡Y ahora a los negocios! En este artículo te contaré muchas cosas interesantes sobre las operaciones bit a bit, el procesamiento binario y hexadecimal, que te serán útiles en CUALQUIER idioma.





PHP



Me encanta PHP, no me malinterpretes. Y estoy seguro de que este lenguaje funcionará bien en la mayoría de los casos. Pero si necesita la máxima eficiencia en el procesamiento de datos binarios, PHP no lo hará.



Déjame explicarte: no me refiero al hecho de que la aplicación pueda consumir cinco o diez megabytes más, sino a asignar una cantidad específica de memoria para almacenar datos de cierto tipo.



Según la documentación oficial para enteros , PHP representa valores decimales, hexadecimales, octales y binarios utilizando un tipo de entero. Así que no importa qué datos pongas allí, siempre serán números enteros.



Probablemente ya conozca ZVAL: es una estructura C que representa cada variable de PHP. Tiene un campo zend_long para representar todos los números . Este campo tiene un tipo lval



, cuyo tamaño depende de la plataforma: en plataformas de 64 bits, el campo se representará como un número de 64 bits , y en plataformas de 32 bits, como un número de 32 bits .



# zval stores every integer as a lval
typedef union _zend_value {
  zend_long lval;
  // ...
} zend_value;

# lval is a 32 or 64-bit integer
#ifdef ZEND_ENABLE_ZVAL_LONG64
 typedef int64_t zend_long;
 // ...
#else
 typedef int32_t zend_long;
 // ...
#endif

      
      





La conclusión es la siguiente: no importa si necesita almacenar 0xff, 0xffff, 0xffffff u otra cosa. En PHP, todos estos valores se almacenarán como long ( lval ) con una longitud de 32 o 64 bits.



Por ejemplo, recientemente experimenté con la emulación de microcontroladores. Y aunque era necesario manejar correctamente el contenido y las operaciones de la memoria, no necesitaba demasiada eficiencia de la memoria porque mi máquina de alojamiento compensaba los costos de órdenes de magnitud.



Por supuesto, todo cambia cuando hablamos de extensiones C o FFI, pero ese tampoco es mi objetivo. Estoy hablando de PHP puro.



Así que recuerde: funciona y puede comportarse de la manera que usted desee, pero en la mayoría de los casos, los tipos desperdiciarán memoria de manera ineficiente.



Una introducción rápida a la representación binaria y hexadecimal de datos



Antes de hablar sobre cómo PHP maneja los datos binarios, primero debe hablar sobre qué es el binario. Si cree que ya sabe todo sobre esto, vaya al capítulo Números binarios y cadenas en PHP .



En matemáticas, existe el concepto de "fundamento". Define cómo podemos representar cantidades en diferentes formatos. La gente suele utilizar la base decimal (base 10), que nos permite representar cualquier número con los dígitos 0, 1, 2, 3, 4, 5, 6, 7, 8 y 9.



Para aclarar el siguiente ejemplo, me referiré al número 20 como "Decimal 20".



Los números binarios (base 2) pueden representar cualquier número, pero solo con dos dígitos: 0 y 1.



El decimal 20 en binario se ve así: 0b000 10100 . No necesita convertirlo usted mismo a su formato familiar, deje que las computadoras lo hagan. ;)



Los números hexadecimales (base 16) pueden representar cualquier número usando diez dígitos 0, 1, 2, 3, 4, 5, 6, 7, 8 y 9, así como seis caracteres adicionales del alfabeto latino: a, b, c , d, ey f.



El decimal 20 en forma hexadecimal se ve así: 0x14. Deje la transformación a las computadoras, ¡son expertos en esto!



Es importante entender que los números se pueden representar en diferentes bases: binaria (base 2), octal (base 8), decimal (base 10, nuestra habitual) y hexadecimal (base 16).



En PHP y muchos otros lenguajes, los números binarios se escriben como cualquier otro, pero con el prefijo 0b : el decimal 20 parece 0b 00010100. Los números hexadecimales tienen el prefijo 0x : el decimal 20 parece 0x 14.



Como ya sabrá, las computadoras no almacenan datos literales ... Todos están representados en forma de números binarios, ceros y unos. Símbolos, números, letras, instrucciones: todo se presenta en base 2. Las letras son solo una convención de secuencias numéricas. Por ejemplo, la letra "a" tiene el número 97 en la tabla ASCII.



Pero mientras todo se almacena en binario, los programadores se sienten más cómodos leyendo los datos en formato hexadecimal. Se ven mejor de esa manera. Solo mira:



# string "abc"
'abc'

# binary form (bleh)
0b01100001 0b01100010 0b01100011

# hexadecimal form (such wow)
0x61 0x62 0x63

      
      





Aunque el formato binario ocupa visualmente mucho espacio, los datos hexadecimales son muy similares a la representación binaria. Por lo tanto, los usamos generalmente en programación de bajo nivel.



Operaciones de transferencia



Ya estás familiarizado con el concepto de carry, pero tengo que prestarle atención para que podamos usarlo por diferentes motivos.



En el conjunto decimal, tenemos diez dígitos separados para representar números, del 0 al 9. Pero cuando tratamos de representar un número mayor que nueve, ¡perdemos los dígitos! Y aquí se aplica la operación de acarreo: anteponemos el número con el dígito 1 y restablecemos el dígito derecho a 0.



# decimal (base 10)
1 + 1 = 2
2 + 2 = 4
9 + 1 = 10 // <- Carry

      
      





La base binaria se comporta de la misma manera, solo que está limitada a los dígitos 0 y 1.



# binary (base 2)
0 + 0  = 0
0 + 1  = 1
1 + 1  = 10 // <- Carry
1 + 10 = 11

      
      





Lo mismo ocurre con la base hexadecimal, solo que tiene un rango mucho más amplio.



# hexadecimal (base 16)
1 + 9  = a // no carry, a is in range
1 + a  = b
1 + f  = 10 // <- Carry
1 + 10 = 11

      
      





Como entendió, la operación de acarreo requiere más dígitos para representar ciertos números. Esto nos permite comprender cuán limitados son ciertos tipos de datos y, dado que están almacenados en computadoras, cuán limitada es su representación binaria.



Representación de datos en la memoria de la computadora



Como mencioné anteriormente, las computadoras almacenan todo en formato binario. Es decir, solo contienen ceros y unos en la memoria.



Es más fácil visualizar este concepto como una tabla grande con una fila y muchas columnas (tanto como lo permita la capacidad de memoria. Cada columna es un número binario (bit). La



representación de nuestro decimal 20 en una tabla de este tipo usando 8 bits se ve así:



Posición (dirección) 0 uno 2 3 4 5 6 7
Poco 0 0 0 uno 0 uno 0 0


Un entero de 8 bits sin signo es un número que se puede representar con un máximo de 8 números binarios. Es decir, 0b11111111 (255 decimal) será el número de 8 bits sin signo más grande. Agregar 1 requerirá el uso de una operación de acarreo, que ya no se puede representar con el mismo número de dígitos.



Sabiendo esto, podemos averiguar fácilmente por qué hay tantas representaciones en la memoria para números y cuáles son: uint8 son enteros de 8 bits sin signo (decimal 0-255), uint16 son enteros de 16 bits sin signo (decimal 0-65535) ). También hay uint32, uint64 y, en teoría, superiores.



Los enteros con signo, que pueden representar valores negativos, suelen utilizar el último bit para determinar si son positivos (último bit = 0) o negativos (último bit = 1). Como puede imaginar, le permiten almacenar valores más pequeños en la misma cantidad de memoria. El entero de 8 bits con signo va desde -128 al decimal 127.



Aquí está el decimal -20, representado como un entero de 8 bits con signo. Tenga en cuenta que el primer bit está establecido (dirección 0, valor 1), esto significa un número negativo.



Posición (dirección) 0 uno 2 3 4 5 6 7
Poco uno 0 0 uno 0 uno 0 0


Espero que todo esté claro hasta ahora. Esta introducción es esencial para comprender el funcionamiento interno de las computadoras. Tenga esto en cuenta, y entonces siempre entenderá cómo funciona PHP bajo el capó.



Desbordamientos aritméticos



La representación numérica seleccionada (8 bits, 16 bits) determina el valor mínimo y máximo del rango. Se trata de cómo se almacenan los números en la memoria: agregar 1 al dígito binario 1 da como resultado una operación de acarreo, es decir, necesita otro bit como prefijo para el número actual. Dado que el formato de entero se define con mucho cuidado, no podemos confiar en operaciones de acarreo fuera de los límites (en realidad es posible, pero bastante loco).



Posición (dirección) 0 uno 2 3 4 5 6 7
Poco uno uno uno uno uno uno uno 0


Aquí estamos muy cerca del límite de 8 bits (255 decimal). Si sumamos uno, obtenemos 255 decimal en binario:



Posición (dirección) 0 uno 2 3 4 5 6 7
Poco uno uno uno uno uno uno uno uno


¡Todos los bits están asignados! Agregar 1 requerirá una operación de acarreo que no será posible porque nos estamos quedando sin bits, ¡los 8 ya están asignados! Esta situación se llama desbordamiento , vamos más allá de cierto límite. La operación binaria 255 + 2 debería dar un resultado de 8 bits de 1.



Posición (dirección) 0 uno 2 3 4 5 6 7
Poco 0 0 0 0 0 0 0 uno


Este comportamiento no es accidental, el nuevo valor se calcula usando ciertas reglas, que no consideraremos aquí.



Números binarios y cadenas en PHP



¡De vuelta a PHP! Perdón por esta gran digresión, pero creo que es importante.



Espero que ya tengas piezas de un rompecabezas en tu cabeza: números binarios, cómo se almacenan, qué es el desbordamiento, cómo representa PHP los números ... El



decimal 20, representado en PHP como un valor entero, puede tener dos representaciones diferentes dependiendo de la plataforma ... En la plataforma x86 será una representación de 32 bits, en la x64 será de 64 bits, pero en ambos casos habrá un signo (es decir, el valor puede ser negativo). Sabemos que el decimal 20 puede caber en un espacio de 8 bits, pero PHP trata cualquier número decimal como 32 o 64 bits.



PHP también tiene cadenas binarias que se pueden convertir una y otra vez usando las funciones empaquetar () y desempaquetar () .



En PHP, la principal diferencia entre cadenas binarias y números es que las cadenas simplemente contienen datos, como un búfer. Los valores enteros (binarios y no solo) le permiten realizar operaciones aritméticas con ellos mismos, pero también valores binarios (bit a bit) como AND, OR, XOR y NOT.



Binario: ¿que se debe usar en PHP, números o cadenas?



Usualmente usamos cadenas binarias para transportar datos. Por lo tanto, leer un archivo binario o una red requiere empaquetar y desempaquetar cadenas binarias.



Sin embargo, las operaciones reales como OR y XOR no se pueden realizar de manera confiable con cadenas, por lo que debe usar números.



Depurar valores binarios en PHP



¡Ahora divirtámonos un poco y juguemos con algo de código PHP!



Primero, te mostraré cómo visualizar datos. Después de todo, debemos entender a qué nos enfrentamos.



Depurar enteros es muy, muy fácil, podemos usar la función sprintf () . Tiene un formato muy poderoso y nos ayudará a comprender rápidamente con qué valores estamos trabajando.



Representemos el decimal 20 en binario de 8 bits y hexadecimal de 1 byte:



<?php
// Decimal 20
$n = 20;

echo sprintf('%08b', $n) . "\n";
echo sprintf('%02X', $n) . "\n";

// Output:
00010100
14

      
      





El formato %08b



genera una variable en $n



representación binaria ( b



) con ocho dígitos ( 08



).



El formato %02X



muestra la variable $n



en notación hexadecimal ( X



) con dos dígitos ( 02



).



Visualización de cadenas binarias



Aunque en PHP los enteros siempre tienen una longitud de 32 o 64 bits, la longitud de las cadenas es igual a la longitud de su contenido. Para decodificar sus valores binarios y representarlos, necesitamos examinar y transformar cada byte.



Afortunadamente, en PHP, las cadenas no se nombran como matrices, y cada posición apunta a un carácter de 1 byte. A continuación, se muestra un ejemplo de cómo acceder a los símbolos:



<?php
$str = 'thephp.website';

echo $str[3];
echo $str[4];
echo $str[5];

// Outputs:
php

      
      





Suponiendo que un carácter es de 1 byte, podemos llamar a ord () para convertirlo en un entero de 1 byte:



<?php
$str = 'thephp.website';

$f = ord($str[3]);
$s = ord($str[4]);
$t = ord($str[5]);

echo sprintf(
  '%02X %02X %02X',
  $f,
  $s,
  $t,
);
// Outputs:
70 68 70

      
      





Ahora puede verificar con la aplicación de línea de comando hexdump:



$ echo 'php' | hexdump
// Outputs
0000000 70 68 70 ...

      
      





La primera columna contiene sólo la dirección, y en la segunda columna vemos los valores hexadecimales que representan los personajes p



, h



y p



.



Además, cuando manejamos cadenas binarias, podemos usar las funciones pack () y unpack () , ¡y tengo un gran ejemplo para ti! Supongamos que necesita leer un archivo JPEG para extraer algunos datos (como EXIF). Con el modo de lectura binaria, puede abrir un controlador de archivos e inmediatamente leer los dos primeros bytes:



<?php

$h = fopen('file.jpeg', 'rb');

// Read 2 bytes
$soi = fread($h, 2);

      
      





Para extraer valores en una matriz de enteros, simplemente puede descomprimirlos:



$ints = unpack('C*', $soi);

var_dump($ints);
// Outputs
array(2) {
  [1] => int(-1)
  [2] => int(-40)
}

echo sprintf('%02X', $ints[1]);
echo sprintf('%02X', $ints[2]);
// Outputs
FFD8

      
      





Tenga en cuenta que el formato C de la función unpack()



convierte el carácter en una cadena $soi



como números de 8 bits sin signo. El modificador *



descomprime toda la línea.



Operaciones bit a bit



PHP implementa todas las operaciones bit a bit que pueda necesitar. Están integradas como expresiones y el resultado de su trabajo se describe a continuación:



Código php Nombre Descripción
$ x | $ y O inclusivo A $ x y $ y se les asigna un valor con todos los bits dados.
$ x ^ $ y Exclusivo o A $ xo $ y se le asigna un valor con los bits dados.
$ x & $ y Y A $ x y $ y se les asigna simultáneamente un valor con los bits dados.
~ $ x NO Cambie los valores de todos los bits en $ x.
$ x << $ y Shift izquierdo Desplaza los bits de $ x que quedan en las posiciones $ y.
$ x >> $ y Giro a la derecha Desplaza los bits de $ x a la derecha en posiciones $ y.


¡Te explicaré cómo funciona cada uno!



Deja $x = 0x20



y $y = 0x30



. A continuación, mostraré ejemplos que utilizan notación binaria.



Cómo funciona Inclusive Or ($ x | $ y)



La operación OR inclusiva toma todos los bits de ambas entradas. Es decir, $x | $y



debería volver 0x30



. Echar un vistazo:



// 1 | 1 = 1
// 1 | 0 = 1
// 0 | 0 = 0

0b00100000 // $x = 0x20
0b00110000 // $y = 0x30
OR ------- // $x | $y
0b00110000 // 0x30

      
      





Nota: De derecha a izquierda, $x



se ha especificado el sexto bit (1), así como el quinto y sexto bits $y



. Los datos se agruparon y valor generado dado el quinto y sexto bits de: 0x30



.



Cómo funciona Exclusive Or ($ x ^ $ y)



La operación OR exclusiva (también conocida como XOR) toma bits de un solo lado. Es decir, el resultado del cálculo $x ^ $y



será 0x10



:



// 1 ^ 1 = 0
// 1 ^ 0 = 1
// 0 ^ 0 = 0

0b00100000 // $x = 0x20
0b00110000 // $y = 0x30
XOR ------ // $x ^ $y
0b00010000 // 0x10

      
      





Cómo funciona AND ($ x & $ y)



El operador AND es mucho más fácil de entender. Aplica una operación AND a cada bit, por lo que solo se recuperarán aquellos valores que sean iguales entre sí en ambos lados. El resultado del cálculo $x & $y



será 0x20



:



// 1 & 1 = 1
// 1 & 0 = 0
// 0 & 0 = 0

0b00100000 // $x = 0x20
0b00110000 // $y = 0x30
AND ------ // $x & $y
0b00100000 // 0x20

      
      





Cómo funciona NOT (~ $ x)



La operación NOT requiere un parámetro, simplemente cambia los valores de todos los bits transmitidos. Convierte todos los 0 en 1 y todos los 1 en 0.:



// ~1 = 0
// ~0 = 1

0b00100000 // $x = 0x20
NOT ------ // ~$x
0b11011111 // 0xDF

      
      





Si realizó esta operación en PHP y decidió depurar con sprintf()



, ¿probablemente notó números más amplios? En el capítulo sobre Normalización de números, explicaré qué está pasando aquí y cómo solucionarlo.



Cómo funcionan SHIFT a la izquierda y SHIFT a la derecha ($ x << $ ny $ x >> $ n)



El cambio de bits es similar a multiplicar o dividir números por una potencia de dos. Todos los bits van a las $n



posiciones izquierda o derecha.



Tomemos un pequeño número binario para que sea más fácil de mostrar, por ejemplo $x = 0b0010



. Si nos movemos a la $x



izquierda una vez , ese bit debería moverse una posición a la izquierda:



$x = 0b0010;
$x = $x << 1;
// 0b0100

      
      





Lo mismo con el desplazamiento a la derecha:



$x = 0b0100;
$x = $x >> 2;
// 0b0001

      
      





Es decir, desplazar el número de $n



veces a la izquierda equivale a multiplicar $n



dos veces y desplazar el número de $n



veces a la derecha equivale a dividir por dos $n



.



¿Qué es la máscara de bits?



Se pueden hacer muchas cosas interesantes con estas operaciones y otras técnicas. Por ejemplo, aplique una máscara de bits. Este es un número binario arbitrario de su elección, creado para extraer información muy específica.



Por ejemplo, tome la idea de que un número con signo de 8 bits es positivo si no se especifica el octavo bit (0) y negativo si se especifica un bit. ¿El número es positivo o negativo 0x20



? ¿Qué hay de 0x81



?



Para responder a esto, podemos crear un byte muy conveniente con un solo bit negativo especificado ( 0b10000000



, equivalente 0x80



) y 0x20



Y. Si el resultado es 0x80



( 0b10000000



, nuestra máscara), entonces este es un número negativo, de lo contrario es positivo:



// 0x80 === 0b10000000 (bitmask)
// 0x20 === 0b00100000
// 0x81 === 0b10000001

0x20 & 0x80 === 0x80 // false
0x81 & 0x80 === 0x80 // true

      
      





Esto a menudo es necesario cuando se trabaja con banderas. Incluso puede encontrar ejemplos de uso en PHP mismo, como banderas de mensajes de error .



Puede elegir qué tipo de errores se generarán:



error_reporting(E_WARNING | E_NOTICE);

      
      





¿Que está pasando aqui? Solo mira tu significado:



0b00000010 (0x02) E_WARNING
0b00001000 (0x08) E_NOTICE
OR -------
0b00001010 (0x0A)

      
      





Cuando PHP ve una notificación que se puede enviar, busca algo como esto:



// error reporting we set before
$e_level = 0x0A;

// Needs to throw a notice
if ($e_level & E_NOTICE === E_NOTICE)
 // Flag is set: throws notice

      
      





¡Y lo verás por todas partes! ¡Binarios, procesadores, todo tipo de cosas de bajo nivel!



Normalizando números



PHP tiene una peculiaridad relacionada con el manejo de números binarios: los enteros tienen un tamaño de 32 o 64 bits. Esto significa que a menudo necesitamos normalizarlos para poder confiar en nuestros cálculos.



Por ejemplo, ejecutar esta operación en una máquina de 64 bits dará un resultado extraño (pero esperado):



echo sprintf(
  '0b%08b',
  ~0x20
);

// Expected
0b11011111
// Actual
0b1111111111111111111111111111111111111111111111111111111111011111

      
      





¿Que pasó aquí? La operación NOT en un entero de 8 bits ( 0x20



) convirtió todos los bits cero en unos. ¿Adivina qué teníamos ceros? Así es, todos los otros 56 bits de la izquierda, ¡que anteriormente se ignoraron!



Nuevamente, la razón es que en PHP la longitud de los enteros es de 32 o 64 bits, independientemente de su valor.



Sin embargo, el código funciona como se esperaba. Por ejemplo, el resultado de la operación ~ 0x20 & 0b11011111 === 0b11011111



será un valor booleano (verdadero). Pero no olvide que estos bits de la izquierda no van a ninguna parte, de lo contrario obtendrá un comportamiento extraño en el código.



Para resolver este problema, puede normalizar los números aplicando una máscara de bits que borre todos los ceros. Por ejemplo, para normalizar ~0x20



un entero de 8 bits debe estar asociado con 0xFF



( 0b11111111



) para que todos los 56 bits anteriores se conviertan en ceros.



~0x20 & 0xFF
-> 0b11011111

      
      





¡Atención! No te olvides de lo que hay en tus variables, de lo contrario obtendrás un comportamiento inesperado. Por ejemplo, echemos un vistazo a lo que sucede cuando cambiamos el valor anterior a la derecha sin una máscara de 8 bits:



~0x20 & 0xFF
-> 0b11011111

0b11011111 >> 2
-> 0b00110111 // expected

(~0x20 & 0xFF) >> 2
-> 0b00110111 // expected

(~0x20 >> 2) & 0xFF
-> 0b11110111 // expected?

      
      





Permítanme explicarles: desde el punto de vista de PHP, esto es lo esperado, porque está procesando explícitamente un número de 64 bits. Debe comprender lo que espera SU programa.



Consejo: evite estos errores tontos programando en el paradigma TDD .



Conclusión: Binary y PHP son geniales



Una vez armado con tales herramientas, todo lo demás se convierte en encontrar la documentación correcta sobre el comportamiento de binarios o protocolos. Después de todo, todo son secuencias binarias.



Recomiendo encarecidamente leer las especificaciones PDF o EXIF. Es posible que incluso desee experimentar con su propia implementación del formato de serialización MessagePack , o Avro, Protobuf ... ¡Las posibilidades son infinitas!



All Articles