wandres.dev
VETERANO · Structs y unions

Disposición en memoria: alineación, padding y empaquetado

El tamaño de un struct no es la suma de sus campos, sino el resultado de un algoritmo determinista. Esta lección deduce el layout a partir de la alineación de cada miembro, distingue el relleno interno del relleno de cola, audita el resultado con offsetof y alignof, y examina el coste real de los structs empaquetados: la pérdida de la garantía de alineación y el comportamiento indefinido que se cuela por la puerta de atrás.

⏱ 18 min

El tamaño de un struct no es una curiosidad aritmética ni un capricho del compilador: es la consecuencia deducible de dos reglas que se aplican sin margen de negociación. Primera, todo objeto debe residir en una dirección múltiplo de su alineación. Segunda, los miembros aparecen en memoria en el mismo orden en que los declaraste, con desplazamientos estrictamente crecientes. De la tensión entre ambas nace el relleno, y del relleno nacen los sizeof que sorprenden. Esta lección convierte esa sorpresa en un cálculo que puedes hacer mentalmente y verificar con offsetof, y termina justo donde el estándar deja de protegerte: los structs empaquetados.

🎯 Al terminar esta lección sabrás
  • Deducir el sizeof y el alignof de un struct a partir de sus miembros.
  • Distinguir el relleno interno del relleno de cola y justificar la existencia del segundo.
  • Auditar un layout real con offsetof, alignof, -Wpadded y pahole.
  • Evaluar el coste del empaquetado y sustituirlo por serialización explícita con memcpy.

La alineación como invariante del tipo

Todo tipo completo lleva asociada una alineación: una potencia de dos que divide a su tamaño y que expresa la restricción de direccionamiento del hardware. En C23, alignof y alignas son palabras clave del lenguaje, no macros heredadas de una cabecera; ya no necesitas incluir stdalign.h para usarlas. La regla que gobierna todo lo demás es simple: la dirección de un objeto de tipo T es siempre múltiplo de alignof(T), y el compilador tiene la obligación de garantizarlo.

La alineación de un struct es el máximo de las alineaciones de sus miembros, porque solo así puede satisfacer simultáneamente la restricción de todos ellos. Esto explica por qué un único double en un agregado eleva la alineación del conjunto a 8 en las ABI habituales de 64 bits.

#include <stddef.h>
#include <stdio.h>

struct Registro {
    char   etiqueta;   // alignof 1
    int    contador;   // alignof 4
    double peso;       // alignof 8
};

int main(void) {
    printf("%zu %zu\n", sizeof(struct Registro), alignof(struct Registro));
    // 16 8   en x86-64 System V y en AArch64 AAPCS64
    printf("%zu %zu %zu\n",
           offsetof(struct Registro, etiqueta),
           offsetof(struct Registro, contador),
           offsetof(struct Registro, peso));
    // 0 4 8
}

El algoritmo del layout, paso a paso

El compilador recorre los miembros en orden de declaración manteniendo un desplazamiento acumulado. Para cada miembro redondea ese desplazamiento hacia arriba hasta el múltiplo de su alineación, coloca el miembro y avanza tantos bytes como ocupe. Al terminar, redondea el total hasta un múltiplo de la alineación del struct completo. Los bytes saltados por el primer redondeo son relleno interno; los añadidos por el último son relleno de cola.

flowchart LR
A[byte 0 etiqueta] --> B[bytes 1 a 3 relleno interno]
B --> C[bytes 4 a 7 contador]
C --> D[bytes 8 a 15 peso]
D --> E[sizeof 16 con alineacion 8]
style B fill:#f38ba8,color:#11111b
style E fill:#a6e3a1,color:#11111b

El relleno de cola no es un desperdicio gratuito: es la condición para que los arreglos funcionen. Si sizeof fuera menor que el múltiplo de la alineación, el segundo elemento de un arreglo empezaría en una dirección desalineada. Por eso sizeof es también el paso entre elementos consecutivos, y por eso el estándar obliga a que la alineación divida al tamaño.

struct Cola {
    double peso;    // desplazamiento 0, ocupa 0 a 7
    char   marca;   // desplazamiento 8
    // bytes 9 a 15: relleno de cola
};                  // sizeof 16, alignof 8

Un detalle que arruina muchos programas: los bytes de relleno tienen valor indeterminado. Comparar dos structs con memcmp es incorrecto aunque todos sus miembros coincidan, y volcar un struct a disco con fwrite filtra memoria no inicializada. La comparación se escribe campo a campo; la serialización, byte a byte.

Reordenar campos: el ahorro es medible

Como el orden de los miembros en memoria está fijado por el orden de declaración, tú controlas cuánto relleno se genera. La heurística que casi siempre acierta es declarar de mayor a menor alineación: así cada desplazamiento ya llega alineado y el relleno interno desaparece, quedando a lo sumo unos pocos bytes de cola.

struct Disperso {   // char, double, char, int
    char   a;       // 0
    double b;       // 8   (relleno en 1 a 7)
    char   c;       // 16
    int    d;       // 20  (relleno en 17 a 19)
};                  // sizeof 24

struct Compacto {
    double b;       // 0
    int    d;       // 8
    char   a;       // 12
    char   c;       // 13
};                  // sizeof 16  (relleno de cola en 14 y 15)
El layout no ahorra memoria: ahorra líneas de caché

Un tercio menos de bytes suena modesto hasta que recuerdas contra qué compites. Un acceso a caché L1 cuesta unos pocos ciclos; una falta que baja hasta memoria principal cuesta cientos. La línea de caché mide 64 bytes en prácticamente todas las microarquitecturas actuales, de modo que pasar de 24 a 16 bytes convierte dos elementos por línea en cuatro: la mitad de faltas al recorrer un arreglo grande. Esa es la razón por la que reordenar campos aparece una y otra vez en los parches de rendimiento del kernel de Linux, y por la que existe pahole, una herramienta que lee la información DWARF de tu binario y te dibuja los agujeros. El paso siguiente en esa misma dirección es abandonar el arreglo de structs por el struct de arreglos, que elimina el relleno por completo y permite vectorizar. Antes de optimizar un bucle, mira el layout de lo que recorre: casi siempre es ahí donde está el problema.

Para auditar sin adivinar tienes cuatro instrumentos complementarios, y conviene usarlos juntos porque cada uno responde a una pregunta distinta.

📐

offsetof y alignof

Declarados en stddef.h el primero y en el propio lenguaje el segundo desde C23. Te dan el desplazamiento y la alineación exactos, y son lo que verificas con static_assert.

⚠️

La opción -Wpadded

GCC y Clang avisan de cada byte de relleno insertado. Es un aviso valioso en una compilación puntual de auditoría e insoportable como aviso permanente.

🔬

La herramienta pahole

Lee la información DWARF de un binario compilado con -g e imprime el mapa completo del agregado, con los huecos y el reparto por líneas de caché.

🔒

static_assert

Congela el layout que auditaste. Si un cambio de compilador o de campo altera el tamaño esperado, la compilación falla en vez de fallar el programa.

El último punto merece énfasis. Auditar una vez no sirve de nada si el resultado no queda fijado en el código: un static_assert sobre el sizeof y sobre el offsetof de los miembros críticos convierte tu deducción en una comprobación que el compilador repite en cada construcción.

static_assert(sizeof(struct Compacto) == 16, "el layout cambio");
static_assert(offsetof(struct Compacto, d) == 8, "desplazamiento inesperado");

Structs empaquetados: qué pierdes exactamente

El atributo packed de GCC y Clang, escrito como __attribute__((packed)), elimina todo el relleno y deja la alineación del agregado en 1. No es C estándar, pero está disponible en los dos compiladores que importan y aparece en todo código que toca formatos binarios heredados.

#include <stdint.h>

struct __attribute__((packed)) Cabecera {
    uint8_t  tipo;
    uint32_t longitud;
};   // sizeof 5, alignof 1

El precio es preciso y grave: la dirección de longitud ya no cumple la alineación de uint32_t. En cuanto tomas &cab.longitud obtienes un uint32_t * que apunta a una dirección potencialmente desalineada, y desreferenciarlo es comportamiento indefinido. En x86-64 el acceso desalineado funciona y solo pagas latencia; en un microcontrolador ARM sin soporte de accesos desalineados el programa aborta. Por eso Clang emite -Waddress-of-packed-member: no es un aviso pedante, es la señal de que has fabricado un puntero cuya validez el lenguaje no reconoce.

⚠️
Empaquetar describe el formato, no lo lee

Leer el miembro directamente, sin tomar su dirección, sí es correcto: el compilador conoce el empaquetado y genera los accesos parciales necesarios. Lo prohibido es exportar un puntero a un miembro desalineado. Y aunque el acceso sea válido, packed sigue sin resolver el orden de bytes: un uint32_t empaquetado se lee con el endianness de la máquina, no con el del protocolo.

La alternativa portable prescinde del atributo y trata el mensaje como lo que es, una secuencia de bytes sin tipo. Se copia con memcpy hacia una variable correctamente alineada, o se ensambla campo a campo con desplazamientos, lo que además fija el orden de bytes de forma explícita. Ningún compilador moderno penaliza ese estilo: reconoce el patrón y lo reduce a una única carga.

#include <string.h>

static uint32_t leer_u32_le(const unsigned char *p) {
    return (uint32_t)p[0]
         | (uint32_t)p[1] << 8
         | (uint32_t)p[2] << 16
         | (uint32_t)p[3] << 24;
}

static void escribir_u32_le(unsigned char *p, uint32_t v) {
    p[0] = (unsigned char)(v);
    p[1] = (unsigned char)(v >> 8);
    p[2] = (unsigned char)(v >> 16);
    p[3] = (unsigned char)(v >> 24);
}

Queda una tercera vía, alignas, para el problema inverso: cuando quieres más alineación de la natural. Marcar un struct con alignas(64) lo coloca al principio de una línea de caché, técnica habitual para evitar el falso compartimiento entre hilos.

⚔️ Levanta el mapa de bytes
  1. Declara struct Disperso y struct Compacto del ejemplo, imprime sizeof, alignof y el offsetof de cada miembro, y comprueba que coinciden con la deducción manual.
  2. Escribe una función que reciba un struct genérico como const unsigned char * y su tamaño, e imprima los bytes en hexadecimal; identifica visualmente los rellenos tras inicializar el objeto a un patrón conocido.
  3. Compila con -Wpadded y con -g seguido de pahole sobre el ejecutable, y contrasta ambos informes con tu mapa.
  4. Define la cabecera empaquetada, activa -Waddress-of-packed-member y provoca el aviso pasando &cab.longitud a una función; después reescribe el acceso con leer_u32_le.
  5. Compila las dos versiones con -O2 -S y compara el ensamblador: verifica que la versión portable genera una sola instrucción de carga en x86-64.