wandres.dev
COMPETENTE · Operadores y expresiones

Operadores de bits: máscaras, desplazamientos y campos

El entero deja de ser un número y pasa a ser un vector de bits: máscaras con AND, OR, XOR y NOT, desplazamientos y sus fronteras indefinidas, campos de bits en struct, y los trucos clásicos de manipulación binaria que C23 por fin estandariza en stdbit.h.

⏱ 18 min

Un unsigned de 32 bits es, según cómo lo mires, un número entre cero y cuatro mil millones o un vector de treinta y dos booleanos empaquetados. La programación de sistemas vive casi siempre en la segunda lectura: registros de hardware, permisos, cabeceras de protocolo, mapas de asignación, conjuntos densos. Los operadores de bits son el idioma de esa lectura, y sus fronteras —la promoción entera, el signo, el contador fuera de rango— son el lugar donde el código en C se rompe con más silencio.

🎯 Al terminar esta lección sabrás
  • Construir, aplicar y componer máscaras con &, |, ^ y ~.
  • Dominar << y >>, incluido el comportamiento indefinido de los desplazamientos.
  • Usar campos de bits en struct sabiendo qué garantiza el estándar y qué no.
  • Reconocer los trucos binarios clásicos y sus equivalentes en <stdbit.h> de C23.

El álgebra de las máscaras

Los cuatro operadores lógicos de bits no son cuatro herramientas sueltas: son un álgebra completa sobre conjuntos. Un entero sin signo es un conjunto de posiciones encendidas, y cada operador tiene una lectura conjuntista exacta.

#include <stdint.h>
#include <stdbool.h>

/* Un bit por capacidad: el idioma canonico */
#define CAP_LEER      (1u << 0)   /* 0b0001 */
#define CAP_ESCRIBIR  (1u << 1)   /* 0b0010 */
#define CAP_EJECUTAR  (1u << 2)   /* 0b0100 */
#define CAP_TODAS     (CAP_LEER | CAP_ESCRIBIR | CAP_EJECUTAR)

unsigned caps = CAP_LEER | CAP_ESCRIBIR;

caps |=  CAP_EJECUTAR;                      /* union: activar        */
caps &= ~CAP_ESCRIBIR;                      /* diferencia: apagar    */
caps ^=  CAP_LEER;                          /* conmutar              */
bool puede = (caps & CAP_EJECUTAR) != 0;    /* interseccion: probar  */

Fíjate en el paréntesis de (caps & CAP_EJECUTAR) != 0. No es adorno defensivo: & tiene menos precedencia que != en C, así que sin él la expresión se leería como caps & (CAP_EJECUTAR != 0). Es la trampa histórica que diseccionaremos en la lección siguiente; aquí basta con interiorizar el hábito.

⚙️

AND para consultar y recortar

x & m conserva solo los bits que m deja pasar. Es la intersección, y también la forma canónica de tomar el resto de una potencia de dos: x & (n - 1) equivale a x % n cuando n es potencia de dos.

🔗

OR para activar

x | m enciende bits sin tocar los demás. Idempotente: aplicarlo dos veces da lo mismo. Es la unión de conjuntos y el modo natural de componer banderas.

🔀

XOR para conmutar y comparar

x ^ m invierte los bits marcados. Su propiedad clave es que es su propia inversa, y a ^ b vale cero exactamente cuando a y b son iguales bit a bit.

🚫

NOT para complementar

~x invierte todos los bits del tipo promocionado. Casi siempre aparece como x &= ~m, la única forma limpia de apagar un subconjunto.

⚠️
El complemento promociona antes de invertir

Este es el fallo más común con ~. En uint8_t m = 0x0F; uint8_t r = ~m; el operando se promociona a int antes de invertirse, de modo que ~m vale 0xFFFFFFF0 y solo al asignar se trunca a 0xF0. Con ese ancho intermedio el resultado final coincide, pero en cuanto la expresión se usa en una comparación o en un desplazamiento el ancho oculto cambia el significado: ~m == 0xF0 es falso. La disciplina profesional es doble: usa tipos sin signo de al menos el ancho de int en la aritmética de máscaras, y cuando trabajes con tipos estrechos vuelve a acotar de forma explícita con (uint8_t)(~m).

Desplazamientos y sus fronteras

<< y >> parecen multiplicación y división por potencias de dos, y esa analogía es justo lo que oculta sus reglas reales. Tres hechos gobiernan su semántica en C23.

Primero, el tipo del resultado es el del operando izquierdo promocionado; el derecho no participa en las conversiones aritméticas usuales, solo se promociona por su cuenta. Por eso 1u << 40 no se ensancha mágicamente a 64 bits: el literal es de 32 y el resultado es indefinido.

Segundo, el contador tiene un rango cerrado. Si es negativo o mayor o igual que el ancho del tipo promocionado del operando izquierdo, el comportamiento es indefinido, sin excepciones y sin envoltura garantizada, por mucho que el hardware x86 enmascare el contador a cinco bits.

Tercero, el signo importa en cada dirección de forma distinta. En el desplazamiento a la izquierda con operando con signo, si el valor es negativo o el resultado no cabe, hay comportamiento indefinido. En el desplazamiento a la derecha, C23 —que ya exige complemento a dos— fija que un valor negativo propaga el bit de signo, cerrando una ambigüedad que durante décadas fue definida por la implementación.

uint32_t x = 1u;

x << 31          /* correcto: 0x80000000, porque x no tiene signo */
1  << 31         /* UB en int de 32 bits: desborda hacia el bit de signo */
x << 32          /* UB SIEMPRE: contador igual al ancho del tipo */
1ull << 40       /* correcto: el literal ya es de 64 bits */

/* Rotacion segura: la mascara evita el contador igual al ancho */
static inline uint32_t rotl32(uint32_t v, unsigned n) {
	return (v << (n & 31)) | (v >> ((-n) & 31));
}

La negación -n sobre un unsigned es aritmética modular perfectamente definida, y (-n) & 31 convierte el caso n igual a cero en un desplazamiento de cero en vez de un desplazamiento de treinta y dos. Sin ese detalle, la rotación clásica es indefinida exactamente en su caso trivial. Los compiladores modernos reconocen este idioma y lo compilan a una única instrucción de rotación.

flowchart TD
A[Desplazar x un contador n] --> B[Se promociona x a int o a un tipo mayor]
B --> C[Contador negativo o mayor o igual al ancho da UB]
C --> D[Izquierda sobre valor con signo negativo da UB]
D --> E[Izquierda con desbordamiento del tipo con signo da UB]
E --> F[Usa tipos sin signo y acota el contador con una mascara]
style F fill:#a6e3a1,color:#11111b

Campos de bits: cuando el compilador empaqueta

Un campo de bits delega en el compilador el trabajo de máscara y desplazamiento. Ganas legibilidad y pierdes control sobre el diseño físico, y esa pérdida es mucho mayor de lo que la sintaxis sugiere.

struct control {
	bool         activo   : 1;   /* C23 admite bool como tipo de campo */
	unsigned     modo     : 3;   /* 0..7                               */
	unsigned     prioridad: 4;
	unsigned              : 0;   /* campo anonimo de ancho cero:
	                                fuerza el salto a la siguiente
	                                unidad de almacenamiento           */
	unsigned     reservado: 8;
};

El estándar deja definido por la implementación casi todo lo que importaría para un formato binario: el orden de asignación dentro de la unidad de almacenamiento, si un campo puede cruzar la frontera entre unidades, la alineación de esa unidad y —detalle célebre— si un campo declarado simplemente int tiene signo o no. Por eso el kernel de Linux jamás define una cabecera de red con campos de bits sin envolverla en variantes condicionadas por el orden de bits de la máquina.

⚠️
Un campo de bits no es un formato de cable

Si el dato cruza un socket, un bus o un fichero, no lo describas con campos de bits: descríbelo con enteros de ancho fijo y máscaras explícitas. La razón es que el diseño físico de un campo de bits no es portable entre compiladores, arquitecturas ni siquiera entre versiones del mismo compilador con opciones distintas. Añade además dos limitaciones estructurales: no puedes aplicar & a un campo de bits para obtener su dirección, ni sizeof sobre él, lo que los excluye de cualquier interfaz genérica. Declara siempre el tipo con unsigned o bool, nunca int a secas, y reserva los campos de bits para estado interno del programa donde solo tu propio código los interpreta.

Trucos clásicos y su forma estandarizada

La manipulación binaria acumuló durante medio siglo un repertorio de idiomas que todo programador de sistemas reconoce de vista. Merece la pena entender por qué funcionan, no solo memorizarlos.

x & (x - 1)          /* apaga el bit encendido mas bajo             */
x & (-x)             /* aisla el bit encendido mas bajo             */
x | (x + 1)          /* enciende el bit apagado mas bajo            */
(x & (x - 1)) == 0   /* x es potencia de dos, o bien x es cero      */

/* Recuento de bits de Kernighan: una vuelta por bit encendido */
int poblacion(uint32_t x) {
	int n = 0;
	while (x) { x &= x - 1; n++; }
	return n;
}

El motivo común es que restar uno invierte el sufijo de ceros hasta el primer uno inclusive, de modo que x - 1 y x coinciden en el prefijo alto y difieren exactamente en ese sufijo. Todo lo demás se deduce de ahí. Ojo con x & (-x): sobre un tipo con signo, negar el valor mínimo desborda y es indefinido, así que el idioma solo es correcto sobre tipos sin signo.

C23 pone fin a esta artesanía con <stdbit.h>, que estandariza lo que antes eran intrínsecos propietarios como __builtin_popcount:

#include <stdbit.h>

stdc_count_ones(x);         /* bits a uno                          */
stdc_leading_zeros(x);      /* ceros a la izquierda                */
stdc_trailing_zeros(x);     /* ceros a la derecha                  */
stdc_bit_width(x);          /* bits necesarios para representar x  */
stdc_bit_ceil(x);           /* menor potencia de dos que es >= x   */
stdc_has_single_bit(x);     /* exactamente un bit encendido        */

Son macros genéricas: aceptan cualquier tipo entero sin signo y despachan al tipo correcto, algo que antes exigía _Generic a mano. Y <stdbit.h> aporta además las macros __STDC_ENDIAN_NATIVE__, __STDC_ENDIAN_LITTLE__ y __STDC_ENDIAN_BIG__, que por primera vez permiten consultar el orden de bytes de la máquina de forma portable en tiempo de preprocesado.

El bit es la unidad de significado, no de almacenamiento

Aquí ocurre un cambio de perspectiva que separa a quien usa C de quien lo entiende. Cuando escribes caps & CAP_LEER no estás haciendo aritmética: estás preguntando si un elemento pertenece a un conjunto, y lo estás haciendo en una sola instrucción sobre una sola palabra de máquina. Un uint64_t es un conjunto de sesenta y cuatro elementos con unión, intersección, diferencia y pertenencia en tiempo constante y sin una sola asignación de memoria. Esa es la razón por la que los planificadores usan mapas de bits para las CPU disponibles, los asignadores para los bloques libres, los recolectores para las marcas, los motores de bases de datos para los índices comprimidos. No es una micro optimización heredada de los años setenta: es la estructura de datos con mejor relación entre densidad y velocidad que existe, y solo es accesible si dominas el álgebra de este nivel. La lección más profunda, sin embargo, es la de las fronteras. Los desplazamientos fuera de rango, el complemento sobre tipos estrechos y los campos de bits no portables comparten un rasgo: no fallan, mienten. Producen un valor plausible en tu máquina y otro distinto en la del cliente, o el mismo durante dos años hasta que un cambio de bandera de optimización desplaza el equilibrio. Por eso la disciplina de este nivel no es aprender trucos, sino aprender dónde el estándar deja de garantizar y empezar a escribir de forma que nunca dependas de esa zona.

⚔️ Piensa en bits, no en números
  1. Implementa un conjunto de banderas con activar, apagar, conmutar y consultar, y comprueba con -Wall -Wextra que ninguna expresión te reclama paréntesis.
  2. Demuestra en tu máquina que (uint8_t)0x0F invertido con ~ no es igual a 0xF0 como expresión, y explica por qué.
  3. Escribe la rotación a la izquierda sin la máscara (-n) & 31 y razona qué ocurre exactamente cuando el contador es cero.
  4. Implementa el recuento de bits de Kernighan y compáralo con stdc_count_ones, mirando el ensamblador generado con -O2.
  5. Define una cabecera de protocolo con campos de bits y luego con máscaras explícitas; compara el resultado de sizeof y de volcar los bytes en dos arquitecturas distintas.