wandres.dev
VETERANO · Structs y unions

Campos de bits: empaquetar banderas sin fiarse del layout

Los campos de bits declaran la anchura exacta de un miembro y permiten meter varias banderas en un solo entero. Esta lección precisa su semántica en C23, enumera todo lo que el estándar deja en manos de la implementación, explica por qué son una trampa para describir formatos binarios y registros de hardware, y define el criterio para decidir cuándo compensan frente a máscaras explícitas.

⏱ 17 min

Un campo de bits declara que un miembro ocupará exactamente n bits, no los 32 que su tipo sugiere. La sintaxis es tan cómoda que invita a usarla en el peor sitio posible: describir el formato exacto de un paquete de red o de un registro de periférico. Lo que la comodidad esconde es que el estándar deja sin especificar casi todo lo que haría falta para que esa descripción fuera fiel. Esta lección separa lo que los campos de bits garantizan —una anchura lógica y un rango de valores— de lo que solo aparentan garantizar, que es una disposición física concreta.

🎯 Al terminar esta lección sabrás
  • Declarar campos de bits con la semántica correcta de tipo, anchura y rango.
  • Enumerar los aspectos del layout que el estándar deja a la implementación.
  • Explicar por qué los campos de bits son inadecuados para formatos binarios y registros MMIO.
  • Sustituirlos por máscaras y desplazamientos encapsulados cuando la portabilidad importa.

Sintaxis, tipos y rango

Un campo de bits es un miembro de struct seguido de dos puntos y una expresión constante que da su anchura en bits. C23 admite como tipo declarado bool, int, signed int, unsigned int, los tipos enteros de anchura precisa _BitInt(N) y, como extensión permitida, cualquier otro tipo entero que la implementación acepte. La anchura no puede superar la del tipo declarado.

#include <stdbool.h>

struct Estado {
    unsigned nivel     : 3;   // valores 0 a 7
    unsigned prioridad : 4;   // valores 0 a 15
    bool     activo    : 1;   // 0 o 1
    signed   ajuste    : 4;   // valores -8 a 7, complemento a dos
};

Dos sutilezas gobiernan el rango. Un campo unsigned de n bits representa el intervalo cerrado de cero a dos elevado a n menos uno; asignarle un valor mayor lo trunca de forma bien definida por el módulo. Un campo signed de n bits usa complemento a dos, obligatorio desde C23, y representa desde menos dos elevado a n menos uno hasta dos elevado a n menos uno menos uno. Un campo declarado con int a secas tiene signo definido por la implementación: puede comportarse como signed o como unsigned. Por eso la regla práctica es no escribir nunca int desnudo en un campo de bits, sino siempre unsigned o signed explícito.

Existe además un caso especial: un campo sin nombre y de anchura cero fuerza a que el siguiente campo empiece en una unidad de almacenamiento nueva. Es el único control de posicionamiento que el estándar ofrece, y sirve para separar grupos lógicos dentro del agregado.

struct Separado {
    unsigned a : 3;
    unsigned   : 0;   // cierra la unidad actual
    unsigned b : 3;   // empieza en la siguiente
};

Dos operaciones que darías por descontadas están prohibidas sobre un campo de bits, y por la misma razón: no tiene por qué empezar en una frontera de byte, de modo que no existe una dirección que lo designe. No puedes aplicarle el operador de dirección ni sizeof, ni pasarlo a una función que espere un puntero a su tipo. Un campo de bits solo se lee y se escribe como valor; el compilador genera por su cuenta los desplazamientos y las máscaras necesarias.

La novedad de C23 en este terreno es la admisión de los tipos enteros de anchura precisa. Declarar _BitInt(20) codigo : 20; fija el tipo y la anchura a la vez y hace que la aritmética sobre el campo se realice en 20 bits en lugar de promoverse a int, lo que elimina una fuente clásica de sorpresas en los desplazamientos y las comparaciones.

Lo que el estándar deliberadamente no fija

Aquí está el núcleo del problema. La lista de aspectos que C deja como definidos por la implementación o directamente no especificados es larga y toca exactamente lo que necesitarías para describir un formato externo.

🧭

Orden de asignación

Si los bits se asignan desde el extremo más significativo o desde el menos significativo de la unidad de almacenamiento. No es lo mismo en x86-64 que en un PowerPC big-endian.

📦

Unidad de almacenamiento

Su tamaño y su alineación son decisión de la implementación, y de ellas depende dónde acaba cada campo.

✂️

Cruce de fronteras

Si un campo puede repartirse entre dos unidades contiguas o debe empezar una nueva es, otra vez, decisión de la implementación.

🔣

Signo de int

Un campo declarado int puede ser con signo o sin él, y de ahí salen bugs que solo aparecen al cambiar de compilador.

flowchart TB
D[misma declaracion de tres campos de bits] --> A[ABI little endian asigna desde el bit menos significativo]
D --> B[ABI big endian asigna desde el bit mas significativo]
A --> R1[byte con valor 0x51]
B --> R2[byte con valor 0x8A]
R1 --> C[dos binarios incompatibles con el mismo codigo fuente]
R2 --> C
style D fill:#89b4fa,color:#11111b
style C fill:#f38ba8,color:#11111b

La conclusión es directa: dos compiladores conformes pueden producir disposiciones físicas distintas para la misma declaración, y ambos tener razón. Un struct de campos de bits describe una agrupación lógica, no un formato de bytes. Escribirlo y volcarlo a un socket es una apuesta sobre la ABI concreta con la que compilaste.

Empaquetar para ahorrar memoria si, para describir bytes no

El error conceptual que arruina más código de sistemas con campos de bits es confundir dos usos que se parecen y no lo son. El primero es una optimización interna: tienes diez millones de nodos y cada uno necesita tres banderas y un contador pequeño, así que los empaquetas en un unsigned y pasas de 16 bytes por nodo a 8. Ese uso es legítimo, medible y completamente portable, porque el layout nunca sale de tu proceso; solo te importa el rango de valores, que sí está garantizado. El segundo uso es descriptivo: intentas que la declaración sea el formato del paquete IP, del sector de disco o del registro de control del periférico. Ese uso descansa sobre garantías que el estándar nunca dio, y la prueba está en el propio código real que lo intenta: cualquier cabecera del kernel de Linux que declara campos de bits para un protocolo los envuelve en condicionales de endianness y declara los campos dos veces, en orden inverso. Ese ritual no es una excentricidad histórica: es la confesión de que el lenguaje no ofrece lo que el problema exige. Si el layout tiene que ser exacto, la respuesta es siempre la misma que en serialización: un arreglo de unsigned char, máscaras y desplazamientos escritos a mano. El código resultante es más largo y es el único que sigue siendo correcto al cambiar de arquitectura.

Registros de hardware: la trampa del acceso

En programación de periféricos el problema se agrava, porque además del layout importa la forma exacta del acceso. Un registro mapeado en memoria puede exigir que se lea o escriba con una única transacción de 32 bits; leer 8 bits del mismo registro puede provocar un fallo de bus o disparar un efecto lateral no deseado.

El estándar no especifica con qué anchura accede el compilador a un campo de bits, ni siquiera cuando lo declaras volatile. Es perfectamente conforme que traduzca la asignación a un campo de un byte en una lectura de 32 bits, la modificación del bit y la escritura de vuelta —el clásico ciclo de lectura, modificación y escritura—, o que use accesos de 8 bits. Sobre un registro de estado con bits que se limpian al leer, ambas cosas pueden ser catastróficas de formas distintas.

#include <stdint.h>

// Enfoque disciplinado: acceso de anchura conocida mas mascaras.
#define UART_HABILITADO   (1u << 0)
#define UART_PARIDAD_POS  1
#define UART_PARIDAD_MASC (0x3u << UART_PARIDAD_POS)

static inline uint32_t uart_paridad(uint32_t reg) {
    return (reg & UART_PARIDAD_MASC) >> UART_PARIDAD_POS;
}

static inline uint32_t uart_con_paridad(uint32_t reg, uint32_t v) {
    return (reg & ~UART_PARIDAD_MASC) | ((v << UART_PARIDAD_POS) & UART_PARIDAD_MASC);
}

El patrón cuesta unas líneas más y a cambio fija tres cosas que los campos de bits dejaban al azar: la posición exacta de cada bit, la anchura de cada acceso al registro y el momento en que ocurre. Es la razón por la que las capas de abstracción de hardware serias, desde CMSIS hasta los controladores del kernel, describen los registros con máscaras y no con campos de bits.

Cuándo compensan de verdad

Quedan dos escenarios en los que los campos de bits son la mejor herramienta disponible. El primero es el ahorro de memoria en estructuras internas replicadas millones de veces, donde reducir el tamaño mejora la densidad de caché y el layout nunca cruza la frontera del proceso. El segundo es la legibilidad de un conjunto de banderas de uso puramente interno, donde s.activo = true comunica mejor la intención que una máscara y el compilador genera exactamente el mismo código.

typedef struct {
    unsigned tipo      : 4;    // 16 categorias
    unsigned marcado   : 1;    // recolector de basura
    unsigned inmutable : 1;
    unsigned reservado : 26;
    uint32_t refs;
} Cabecera;

static_assert(sizeof(Cabecera) == 8, "el empaquetado cambio de forma");

Frente a la versión ingenua con cuatro unsigned independientes, el agregado pasa de 20 bytes a 8. Con diez millones de objetos vivos eso son 120 megabytes menos y, lo que más importa, ocho cabeceras por línea de caché en lugar de tres.

Hay una última consecuencia que casi nadie anticipa y que solo aparece en programas concurrentes. Para el modelo de memoria de C, un grupo de campos de bits adyacentes no separados por un campo de anchura cero constituye una única posición de memoria. Escribir dos campos distintos del mismo grupo desde dos hilos es, por tanto, una carrera de datos con todas las letras, aunque los campos no compartan ni un bit: el compilador implementa cada escritura como un ciclo de lectura, modificación y escritura sobre la unidad entera, y una de las dos actualizaciones se pierde.

📝
El campo de anchura cero como barrera de concurrencia

Si dos hilos deben escribir banderas distintas del mismo agregado sin sincronización, sepáralas con un campo sin nombre de anchura cero. Eso las coloca en unidades de almacenamiento distintas y, con ello, en posiciones de memoria distintas para el modelo de memoria, que es lo que elimina formalmente la carrera. La alternativa, cuando el acceso concurrente es intenso, es abandonar los campos de bits y usar atomic_uint con operaciones de máscara, o directamente atomic_flag por bandera.

⚠️
Tres reglas si decides usarlos

Declara siempre unsigned o signed explícito, nunca int a secas. No tomes nunca la dirección de un campo de bits: el lenguaje lo prohíbe, porque no tiene por qué empezar en una frontera de byte. Y añade un static_assert sobre el sizeof del agregado para que un cambio de compilador que altere el empaquetado rompa la compilación en vez de romper el programa.

⚔️ Mide el empaquetado y su precio
  1. Declara struct Estado del ejemplo, imprime su sizeof y comprueba cuántos bits libres quedan en la última unidad de almacenamiento.
  2. Asigna a un campo de 3 bits el valor 9 y comprueba experimentalmente el truncamiento; repite con un campo signed de 4 bits y el valor 12.
  3. Escribe la misma información con máscaras sobre un uint32_t, compila ambas versiones con -O2 -S y compara el ensamblador generado para una lectura y una escritura.
  4. Compila la versión con campos de bits para dos objetivos con endianness opuesto, si tienes un compilador cruzado disponible, y vuelca los bytes del agregado para observar la diferencia.
  5. Añade un static_assert que fije el sizeof esperado y otro que verifique que un valor conocido produce el patrón de bytes que esperas.