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.
- Por qué PHP puede no ser el mejor candidato
- Una introducción rápida a la representación binaria y hexadecimal de datos
- Operaciones de transferencia
- Representación de datos en la memoria de la computadora
- Desbordamientos aritméticos
- PHP
- : PHP, ?
- PHP
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!