Padding, alineación y optimización de memoria en structs

Cuando defines un struct en C, el tamaño que devuelve sizeof no es necesariamente la suma de los tamaños de sus miembros. El compilador inserta bytes invisibles llamados relleno (padding) para asegurar que cada miembro comience en una dirección de memoria que sea un múltiplo de su propio tamaño o de su requerimiento de alineación. Esto ocurre porque la arquitectura de la CPU (el ABI o Application Binary Interface) está diseñada para leer la memoria en bloques (palabras de 4, 8 o más bytes) y acceder a datos que “cruzan” estos límites requiere múltiples ciclos de reloj y operaciones adicionales. Si ignoras estas reglas, tus estructuras consumirán más memoria de la necesaria; si intentas forzarlas mediante técnicas no estándar, podrías provocar fallos de segmentación o un rendimiento degradado en arquitecturas estrictas como ARM o SPARC.

Para entender esto, debemos hablar de la alineación. Un tipo T tiene una alineación que indica que sus direcciones de memoria deben ser múltiplos de alignof(T). Por ejemplo, en la mayoría de las arquitecturas de 64 bits, un int de 4 bytes requiere estar alineado a 4, y un double de 8 bytes requiere estar alineado a 8. El compilador garantiza que el tamaño total del struct sea también un múltiplo del mayor requerimiento de alineación de sus miembros, permitiendo que los elementos en un array de dicho tipo mantengan su alineación.

Para inspeccionar estas posiciones, la biblioteca <stddef.h> proporciona la macro offsetof(tipo, miembro), que devuelve el desplazamiento en bytes desde el inicio de la estructura hasta el miembro indicado. Además, a partir de [C11], puedes controlar la alineación de un tipo usando el especificador _Alignas.

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

/* 
 * Estructura mal diseñada: genera padding excesivo.
 * El compilador debe rellenar espacios para que 'i1' y 'i2' 
 * estén en direcciones múltiplos de 4.
 */
struct MalOrdenado {
    char c1;      // 1 byte
    // 3 bytes de padding para alinear i1
    int i1;       // 4 bytes
    char c2;      // 1 byte
    // 3 bytes de padding para que el struct total sea múltiplo de 4
    int i2;       // 4 bytes
};

/* 
 * Estructura optimizada: el reordenamiento de miembros 
 * reduce el padding necesario.
 */
struct BuenOrdenado {
    int i2;       // 4 bytes
    int i1;       // 4 bytes
    char c1;      // 1 byte
    char c2;      // 1 byte
    // 2 bytes de padding al final para cumplir la alineación de 4
};

/* 
 * Estructura empaquetada (non-standard): 
 * GCC y Clang permiten __attribute__((packed)) para eliminar
 * el padding, ideal para protocolos de red o hardware.
 */
struct Empaquetado {
    char c1;
    int i1;
    char c2;
} __attribute__((packed));

/* 
 * Uso de alineación explícita [C11].
 */
struct _Alignas(16) AlineadoEspecial {
    int x;
};

int main(void) {
    printf("--- Análisis de tamaño y alineación ---\n");
    printf("sizeof(MalOrdenado):    %zu bytes\n", sizeof(struct MalOrdenado));
    printf("offsetof(i1) en Mal:   %zu\n", offsetof(struct MalOrdenado, i1));

    printf("\nsizeof(BuenOrdenado):   %zu bytes\n", sizeof(struct BuenOrdenado));
    printf("offsetof(c1) en Buen:   %zu\n", offsetof(struct BuenOrdenado, c1));

    printf("\nsizeof(Empaquetado):    %zu bytes (sin padding)\n", sizeof(struct Empaquetado));
    printf("offsetof(i1) en Empaquetado: %zu\n", offsetof(struct Empaquetado, i1));

    printf("\nsizeof(AlineadoEspecial): %zu bytes (alineado a 16)\n", sizeof(struct AlineadoEspecial));

    /* 
     * Uso de aligned_alloc [C11]:
     * Requiere que el tamaño sea múltiplo de la alineación.
     */
    size_t tam_alig = 32;
    size_t alig = 64;
    void *ptr = aligned_alloc(alig, tam_alig);
    if (ptr) {
        printf("\nMemoria dinámica alineada a %zu bytes: %p\n", alig, ptr);
        free(ptr);
    }

    return 0;
}

Desglose del ejemplo

Al ejecutar este código, notarás que sizeof(struct MalOrdenado) devuelve 16 bytes, a pesar de que la suma de sus miembros (char, int, char, int) es de 10 bytes. Esto se debe a que el compilador ha insertado 3 bytes de relleno justo después de c1 para que i1 empiece en el byte 4, y otros 3 bytes después de c2 para que i2 sea accesible y para que el tamaño total sea múltiplo de la alineación máxima (4).

En contraste, struct BuenOrdenado es mucho más eficiente. Al colocar los miembros más grandes (int) primero, los miembros más pequeños (char) ocupan el espacio sobrante de forma natural. El tamaño total es de 12 bytes (o 8 en algunos casos según el compilador, pero aquí es 12 debido al padding final para mantener la alineación de 4 del struct).

La estructura Empaquetado utiliza la extensión __attribute__((packed)) de GCC/Clang. Como puedes ver en el offsetof, i1 empieza inmediatamente en el byte 1. Esto ahorra memoria, pero es peligroso: si intentas acceder a i1 en una arquitectura que no permite accesos no alineados, el programa podría colapsar o volverse extremadamente lento.

Por último, struct AlineadoEspecial demuestra _Alignas [C11]. Aunque solo contiene un int de 4 bytes, el compilador le asigna un tamaño y una alineación de 16 bytes, forzando que su dirección de memoria sea siempre un múltiplo de 16, algo crucial cuando trabajas con instrucciones SIMD o buffers de DMA.

El error frecuente

El error más peligroso ocurre al usar __attribute__((packed)) en estructuras que se pasan a funciones que esperan punteros a tipos estándar.

struct __attribute__((packed)) DatosRed {
    char id;
    int valor; // El compilador pone este int en el offset 1
};

void procesar(int *p) {
    int x = *p; // Acceso alineado
}

struct DatosRed d;
processar(&d.valor); // ¡ERROR! d.valor no está alineado a 4 bytes

Si d.valor está en una dirección que no es múltiplo de 4 (por ejemplo, 0x...01), al pasar ese puntero a procesar, la CPU intentará realizar una carga de palabra desalineada. En x86 esto es costoso pero funcional; en arquitecturas como ARM, esto puede disparar un SIGBUS (Bus Error) y terminar el proceso inmediatamente. AddressSanitizer (ASan) detectará desalineaciones en ciertos contextos, pero lo ideal es evitar packed a menos que sea estrictamente necesario para mapear hardware o protocolos de red, y en esos casos, siempre copiar los datos a una estructura normalizada antes de procesarlos.

61

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio