← Programación en C para Sistemas Operativos Bloque 6 · Estructuras y tipos propios · Tema 25 de 42

Tema 25

Las estructuras en la memoria

En el tema anterior apareció un dato que no cerraba: struct proceso mide 32 bytes, aunque sus campos suman 28. Los 4 bytes que faltan son relleno, espacio vacío que el compilador agrega para que cada campo quede en una dirección que el procesador pueda leer bien. Este tema explica las reglas, que son pocas y se pueden aplicar a mano, y sus consecuencias, que no son pocas: el orden de los campos cambia el tamaño, memcmp deja de servir para comparar estructuras, escribir una estructura entera en un archivo o en una red copia basura, y el mismo código no produce el mismo diseño en todas las plataformas. Veremos también las herramientas para controlarlo, offsetof, packed, alignas y static_assert, y cómo las usa el núcleo Linux.

1. Qué es la alineación

Se dice que un tipo tiene alineación n si sus valores deben guardarse en direcciones que sean múltiplos de n. Un int, de alineación 4, va en direcciones terminadas en 0, 4, 8 o c; un double, de alineación 8, en direcciones terminadas en 0 u 8. El compilador respeta esa regla al colocar variables, y el programador la respeta al calcular direcciones, como vimos en el tema 18 con -fsanitize=alignment.

La razón es el hardware. La memoria se lee en bloques, y un procesador resuelve con una sola operación un acceso de 8 bytes cuando empieza en un múltiplo de 8. Si el dato empieza en otra dirección puede quedar partido entre dos bloques, entre dos líneas de caché de 64 bytes o incluso entre dos páginas de memoria, y el acceso cuesta más, o no puede hacerse de forma atómica, o no se permite. x86-64 tolera la mayoría de los accesos desalineados; otros procesadores terminan el programa con SIGBUS, y hasta en x86-64 algunas instrucciones vectoriales exigen 16 bytes de alineación. Para el estándar de C, acceder a un valor desalineado es comportamiento indefinido en cualquier caso.

El operador alignof (de stdalign.h en C11 y C17; una palabra clave en C23; la forma original es _Alignof) da la alineación de un tipo. Este programa la muestra para los tipos básicos, con su tamaño, y también para direcciones reales:

alineacion.c
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <stdalign.h>                     /* alignof (en C23 es una palabra clave) */
#include <stdint.h>

#define MOSTRAR(T) \
    printf("%-14s tamaño %2zu  alineación %2zu\n", #T, sizeof(T), alignof(T))

int main(void)
{
    MOSTRAR(char);
    MOSTRAR(short);
    MOSTRAR(int);
    MOSTRAR(long);
    MOSTRAR(long long);
    MOSTRAR(float);
    MOSTRAR(double);
    MOSTRAR(long double);
    MOSTRAR(void *);
    MOSTRAR(max_align_t);

    int i;
    double d;
    char c;
    printf("\n&i %p  (mod 4 = %ju)\n", (void *) &i, (uintmax_t) ((uintptr_t) &i % 4));
    printf("&d %p  (mod 8 = %ju)\n", (void *) &d, (uintmax_t) ((uintptr_t) &d % 8));
    printf("&c %p\n", (void *) &c);

    void *a = malloc(1), *b = malloc(24), *e = malloc(100);
    printf("malloc(1)   %p  (mod 16 = %ju)\n", a, (uintmax_t) ((uintptr_t) a % 16));
    printf("malloc(24)  %p  (mod 16 = %ju)\n", b, (uintmax_t) ((uintptr_t) b % 16));
    printf("malloc(100) %p  (mod 16 = %ju)\n", e, (uintmax_t) ((uintptr_t) e % 16));
    free(a);
    free(b);
    free(e);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o alineacion alineacion.c
$ ./alineacion
char           tamaño  1  alineación  1
short          tamaño  2  alineación  2
int            tamaño  4  alineación  4
long           tamaño  8  alineación  8
long long      tamaño  8  alineación  8
float          tamaño  4  alineación  4
double         tamaño  8  alineación  8
long double    tamaño 16  alineación 16
void *         tamaño  8  alineación  8
max_align_t    tamaño 32  alineación 16

&i 0x7ffdf9890ad4  (mod 4 = 0)
&d 0x7ffdf9890ad8  (mod 8 = 0)
&c 0x7ffdf9890ad3
malloc(1)   0x62179a040020  (mod 16 = 0)
malloc(24)  0x62179a040040  (mod 16 = 0)
malloc(100) 0x62179a040060  (mod 16 = 0)

En x86-64 con Linux, los tipos básicos están alineados a su propio tamaño: la alineación natural. Los dos últimos tipos merecen un comentario. max_align_t es un tipo definido para tener la mayor alineación que necesita cualquier tipo común: 16 en esta plataforma. Y es el valor que respeta malloc: las tres direcciones que devolvió son múltiplos de 16, aunque se pidiera 1 solo byte, de modo que se puede guardar cualquier tipo en la memoria que devuelve sin preocuparse por la alineación. Es la idea de redondear a un múltiplo que vimos con la expresión (n + 7) & ~7 del tema 8, aplicada a direcciones. Y las variables locales i y d quedaron en direcciones múltiplos de 4 y de 8 sin que el programa hiciera nada: el compilador las colocó así.

Las alineaciones de la tabla son las de la interfaz binaria (ABI) de x86-64, el documento que fija las convenciones de llamada que ya conocemos y también estas. No son leyes de C: otro procesador, o el mismo en otro modo, tiene otras. El mismo compilador en modo de 32 bits alinea el double a 4 bytes dentro de las estructuras, y una estructura que en 64 bits ocupa 16 bytes ocupa 12 (lo veremos en la sección 9). El estándar solo promete que el tamaño y la alineación de un tipo son valores constantes en cada plataforma, y que se pueden consultar con sizeof y alignof.

2. Las reglas del relleno

Para una estructura, el compilador aplica tres reglas, en este orden:

  1. Cada campo se coloca en el primer desplazamiento libre que sea múltiplo de su alineación. Los campos van en el orden de la declaración, nunca se reordenan, y si el desplazamiento siguiente al campo anterior no es un múltiplo adecuado, se saltea espacio. Esos bytes salteados son el relleno interior. colocar
  2. La estructura entera tiene la alineación de su campo más exigente. Una estructura que contiene un double se alinea a 8; una que solo tiene char y short, a 2. alinear
  3. El tamaño total se redondea hacia arriba hasta un múltiplo de esa alineación. Es el relleno final, y garantiza que en un arreglo de estructuras cada elemento también quede bien alineado. redondear

Este programa imprime el tamaño y los desplazamientos de seis estructuras, con distintas combinaciones de campos:

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

struct a { char c; int i; char d; };
struct b { int i; char c; char d; };
struct c { char c; double d; char e; };
struct d { double d; char c; char e; };
struct e { long l; char c; };
struct f { char c; short s; };

#define CAMPO(S, m) printf("    %-2s desplazamiento %2zu\n", #m, offsetof(struct S, m))
#define TOTAL(S) printf("struct %s: tamaño %zu\n", #S, sizeof(struct S))

int main(void)
{
    TOTAL(a); CAMPO(a, c); CAMPO(a, i); CAMPO(a, d);
    TOTAL(b); CAMPO(b, i); CAMPO(b, c); CAMPO(b, d);
    TOTAL(c); CAMPO(c, c); CAMPO(c, d); CAMPO(c, e);
    TOTAL(d); CAMPO(d, d); CAMPO(d, c); CAMPO(d, e);
    TOTAL(e); CAMPO(e, l); CAMPO(e, c);
    TOTAL(f); CAMPO(f, c); CAMPO(f, s);
    return 0;
}
terminal
$ ./relleno
struct a: tamaño 12
    c  desplazamiento  0
    i  desplazamiento  4
    d  desplazamiento  8
struct b: tamaño 8
    i  desplazamiento  0
    c  desplazamiento  4
    d  desplazamiento  5
struct c: tamaño 24
    c  desplazamiento  0
    d  desplazamiento  8
    e  desplazamiento 16
struct d: tamaño 16
    d  desplazamiento  0
    c  desplazamiento  8
    e  desplazamiento  9
struct e: tamaño 16
    l  desplazamiento  0
    c  desplazamiento  8
struct f: tamaño 4
    c  desplazamiento  0
    s  desplazamiento  2
Dos estructuras con los mismos campos en distinto orden. struct a, con char c, int i y char d, ocupa 12 bytes: c en el byte 0, tres bytes de relleno, i en los bytes 4 a 7, d en el byte 8 y tres bytes de relleno final. struct b, con int i, char c y char d, ocupa 8 bytes: i en los bytes 0 a 3, c en el 4, d en el 5 y dos bytes de relleno final struct a { char c; int i; char d; } · 12 bytes c relleno · 3 i · 4 bytes d relleno · 3 0 1 4 8 9 12 struct b { int i; char c; char d; } · 8 bytes i · 4 bytes c d relleno · 2 0 4 5 6 8 33 % menos con solo cambiar el orden
Los mismos tres campos ocupan 12 o 8 bytes según el orden. En ambos casos el tamaño es múltiplo de 4, la alineación de la estructura.

Cada estructura ilustra una de las reglas:

Las mismas reglas se aplican a las estructuras anidadas, que se tratan como un solo campo con su propio tamaño y alineación, y a los arreglos, cuya alineación es la de su elemento: un char nombre[16] no necesita alineación especial, y por eso struct proceso del tema 24 tenía el relleno después del arreglo, para alinear el long siguiente en el byte 24.

3. offsetof: dónde está cada campo

La macro offsetof(tipo, campo), de stddef.h, devuelve como size_t el desplazamiento en bytes del campo desde el comienzo de la estructura. Es una constante que se conoce al compilar, y es lo que usó relleno.c. Con ella se puede comprobar el diseño de cualquier estructura, incluida una del sistema:

No se puede escribir offsetof a mano de forma portable. El truco clásico, (size_t) &((struct s *) 0)->campo, calcula la dirección de un campo en una estructura imaginaria que empieza en la dirección 0, y funciona en la práctica porque nunca se lee nada; pero el estándar no lo garantiza. gcc implementa offsetof con una función interna, __builtin_offsetof, que sí es correcta. Se puede comprobar con gdb: p (long) &((struct par *) 0)->valor da 4.

4. El relleno final y los arreglos de estructuras

La regla 3, redondear el tamaño total, parece un detalle y es la que mantiene todo en pie. Si struct e, con el long en el byte 0 y el char en el 8, midiera 9 bytes, en un arreglo struct e v[2] el segundo elemento empezaría en el byte 9, y su long, en el 9: desalineado. Con el tamaño redondeado a 16, todos los elementos del arreglo empiezan en múltiplos de 16, y todos sus campos quedan bien alineados. Por eso el tamaño de una estructura es siempre múltiplo de su alineación, y por eso sizeof da el paso exacto del arreglo: &v[1] - &v[0] es sizeof(struct e), como vimos en el tema 18.

Hay consecuencias prácticas:

5. Reordenar los campos

Como el compilador no reordena los campos, el orden de la declaración es una decisión del programador, y tiene un efecto directo en la memoria. La regla práctica: declarar los campos del más grande al más chico, o por lo menos agrupar los de la misma alineación. Este programa define los mismos seis campos en dos órdenes:

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

/* campos en el orden en que se "ocurrieron" */
struct registro_a {
    char tipo;
    double valor;
    char marca;
    int cuenta;
    char flag;
    long id;
};

/* los mismos campos, del más grande al más chico */
struct registro_b {
    double valor;
    long id;
    int cuenta;
    char tipo;
    char marca;
    char flag;
};

#define CAMPO(S, m) printf("  %-7s %2zu", #m, offsetof(struct S, m))

int main(void)
{
    printf("registro_a: %zu bytes\n", sizeof(struct registro_a));
    CAMPO(registro_a, tipo); CAMPO(registro_a, valor); CAMPO(registro_a, marca);
    CAMPO(registro_a, cuenta); CAMPO(registro_a, flag); CAMPO(registro_a, id);
    printf("\nregistro_b: %zu bytes\n", sizeof(struct registro_b));
    CAMPO(registro_b, valor); CAMPO(registro_b, id); CAMPO(registro_b, cuenta);
    CAMPO(registro_b, tipo); CAMPO(registro_b, marca); CAMPO(registro_b, flag);
    printf("\n1 000 000 de registros: %zu MB contra %zu MB\n",
           sizeof(struct registro_a), sizeof(struct registro_b));
    return 0;
}
terminal
$ ./registro
registro_a: 40 bytes
  tipo     0  valor    8  marca   16  cuenta  20  flag    24  id      32
registro_b: 24 bytes
  valor    0  id       8  cuenta  16  tipo    20  marca   21  flag    22

1 000 000 de registros: 40 MB contra 24 MB

Los campos suman 23 bytes en los dos casos. En registro_a, cada char aislado entre dos campos grandes cuesta 3 o 7 bytes de relleno, y la estructura ocupa 40 bytes; en registro_b, los tres char juntos se acomodan al final, y la estructura ocupa 24: un 40 % menos. Con un millón de registros, la diferencia es de 16 MB, y de caché: caben más registros en cada línea de 64 bytes, y un recorrido secuencial lee menos memoria. El orden no cambia lo que hace el programa, pero hay una condición: si algún código inicializa la estructura por posición, o la lee desde un archivo con un formato fijo, cambiar el orden lo rompe. Es otra razón para inicializar por nombre, como vimos en el tema 24.

Para encontrar el relleno sin calcularlo a mano, gcc tiene una advertencia pensada para eso, -Wpadded, que no está en -Wall ni en -Wextra porque casi todos los programas tienen relleno:

terminal
$ gcc -std=c17 -Wall -Wextra -Wpadded -c registro.c
registro.c:7:12: warning: padding struct to align ‘valor’ [-Wpadded]
    7 |     double valor;
      |            ^~~~~~
registro.c:9:9: warning: padding struct to align ‘cuenta’ [-Wpadded]
    9 |     int cuenta;
      |         ^~~~~~
registro.c:11:10: warning: padding struct to align ‘id’ [-Wpadded]
   11 |     long id;
      |          ^~
registro.c:22:1: warning: padding struct size to alignment boundary with 1 bytes [-Wpadded]
   22 | };
      | ^

Cada aviso señala un lugar con relleno interior (padding struct to align) o final (padding struct size to alignment boundary). Se usa de vez en cuando, sobre una estructura nueva o una que se quiere ajustar, y no como configuración permanente. Las advertencias de registro_a aparecen en tres campos, y solo una, el relleno de 1 byte al final, en registro_b, que es la que no se puede evitar con campos de estos tamaños. La sección 12 muestra una herramienta que da el mismo informe con todos los detalles.

Optimizar el orden no vale la pena en todas las estructuras. Una que existe en pocas copias, como una configuración, importa por su claridad, no por sus bytes; el orden lógico, con los campos relacionados juntos, es mejor. Conviene optimizar las estructuras que se repiten por miles o millones, como los nodos de una lista o los registros de una tabla, y las que se copian o se escriben a menudo.

6. El relleno que se cuela: comparar y copiar

El relleno son bytes que pertenecen a la estructura pero a ningún campo. Nadie los escribe, y por eso contienen lo que haya habido antes en esa memoria: ceros, o restos de otra función en la pila. En el tema 24 vimos que las estructuras no se pueden comparar con ==, y dejamos pendiente por qué memcmp no es la solución. Este programa compara dos estructuras con los mismos valores en todos los campos, pero con distinto contenido en el relleno:

comparar.c
#include <stdio.h>
#include <string.h>

struct par {
    char tag;
    int valor;
};

static void volcar(const char *etiqueta, const struct par *p)
{
    const unsigned char *b = (const unsigned char *) p;

    printf("%-18s", etiqueta);
    for (size_t i = 0; i < sizeof *p; i++)
        printf(" %02x", b[i]);
    printf("\n");
}

static int iguales_campos(const struct par *a, const struct par *b)
{
    return a->tag == b->tag && a->valor == b->valor;
}

static int iguales_bytes(const struct par *a, const struct par *b)
{
    return memcmp(a, b, sizeof *a) == 0;
}

int main(void)
{
    struct par a, b;

    memset(&a, 0x00, sizeof a);           /* el relleno de a queda en cero */
    memset(&b, 0xFF, sizeof b);           /* el relleno de b queda en 0xff */
    a.tag = 'x';
    a.valor = 7;
    b.tag = 'x';
    b.valor = 7;

    volcar("a:", &a);
    volcar("b:", &b);
    printf("campo por campo: %s\n", iguales_campos(&a, &b) ? "iguales" : "distintas");
    printf("memcmp:          %s\n", iguales_bytes(&a, &b) ? "iguales" : "distintas");
    return 0;
}
terminal
$ ./comparar
a:                 78 00 00 00 07 00 00 00
b:                 78 ff ff ff 07 00 00 00
campo por campo: iguales
memcmp:          distintas

Los bytes 1 a 3 son relleno: ceros en a, 0xff en b. Los campos coinciden, y memcmp responde «distintas». En un programa real el relleno no se rellena a propósito con 0xff, pero pasa lo mismo cuando una estructura se construye en la pila, sobre restos de otras llamadas, y otra en memoria recién pedida: el resultado de memcmp depende de basura. Es un error intermitente, que no se reproduce en las pruebas pequeñas y aparece en producción. La misma trampa afecta a cualquier uso que trate la estructura como un bloque de bytes: calcular su suma de verificación o un hash, usarla como clave de una tabla comparándola con memcmp, o buscarla con bsearch y un comparador que haga memcmp. En todos los casos hay que comparar campo por campo, como hace iguales_campos.

Entonces, ¿quién deja el relleno en cero? Depende de cómo se escribió la estructura. Este programa prueba siete formas, con la estructura previamente llena de 0xAA para ver qué toca cada una:

ceros.c
#include <stdio.h>
#include <string.h>

struct par {
    char tag;
    int valor;
};

static void volcar(const char *etiqueta, const struct par *p)
{
    const unsigned char *b = (const unsigned char *) p;

    printf("%-26s", etiqueta);
    for (size_t i = 0; i < sizeof *p; i++)
        printf(" %02x", b[i]);
    printf("\n");
}

int main(void)
{
    struct par p;

    memset(&p, 0xAA, sizeof p);
    p.tag = 'x';
    p.valor = 7;
    volcar("campo por campo:", &p);

    memset(&p, 0xAA, sizeof p);
    p = (struct par){'x', 7};
    volcar("= literal compuesto:", &p);

    memset(&p, 0xAA, sizeof p);
    p = (struct par){0};
    volcar("= {0}:", &p);

    struct par q = {'x', 7};
    volcar("inicializada con {...}:", &q);

    struct par r = {0};
    r.tag = 'x';
    r.valor = 7;
    volcar("{0} y luego campos:", &r);

    memset(&p, 0xAA, sizeof p);
    p.tag = 'x';
    p.valor = 7;
    struct par copia = p;
    volcar("copia = p:", &copia);

    memset(&p, 0, sizeof p);
    p.tag = 'x';
    p.valor = 7;
    volcar("memset(0) y campos:", &p);
    return 0;
}
terminal · igual con -O0 y con -O2
$ ./ceros
campo por campo:           78 aa aa aa 07 00 00 00
= literal compuesto:       78 00 00 00 07 00 00 00
= {0}:                     00 00 00 00 00 00 00 00
inicializada con {...}:    78 00 00 00 07 00 00 00
{0} y luego campos:        78 00 00 00 07 00 00 00
copia = p:                 78 aa aa aa 07 00 00 00
memset(0) y campos:        78 00 00 00 07 00 00 00

Con gcc 15, la asignación campo por campo no toca el relleno, y la copia de una estructura entera lo copia tal cual. Las inicializaciones y los literales compuestos lo dejaron en cero. Esa última conducta es una cortesía del compilador, no una promesa del lenguaje: el estándar de C dice que el valor de los bytes de relleno no está especificado, y otro compilador, otra versión o una optimización distinta pueden dejar basura. Cuando los bytes importan, la regla es poner la estructura en cero con memset antes de llenarla, como en la última línea.

«Cuando los bytes importan» incluye comparar con memcmp, y también un caso de seguridad. Si un programa escribe una estructura entera con write o send, el relleno viaja con ella, y puede contener datos que estaban antes en esa memoria: restos de una contraseña, un fragmento de otro usuario, o una dirección de la pila, que anula la protección de ASLR del tema 15. Las fugas de información por relleno son una clase conocida de vulnerabilidades, también del núcleo, donde las estructuras que se copian al espacio de usuario se ponen en cero antes de llenarlas por esta razón.

7. Estructuras empaquetadas

A veces el relleno estorba. Un formato de archivo o de red define una cabecera de, por ejemplo, 7 bytes consecutivos, y declarar la estructura con el mismo orden de campos no produce el mismo diseño. GCC y Clang ofrecen el atributo packed, que le dice al compilador que no agregue relleno y que trate la alineación de la estructura como 1:

packed.c
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>

/* una cabecera con un formato binario fijo: 1 + 4 + 2 bytes */
struct normal {
    uint8_t tipo;
    uint32_t longitud;
    uint16_t crc;
};

struct __attribute__((packed)) compacta {
    uint8_t tipo;
    uint32_t longitud;
    uint16_t crc;
};

int main(void)
{
    printf("normal:   tamaño %zu  alineación %zu  longitud en %zu, crc en %zu\n",
           sizeof(struct normal), _Alignof(struct normal),
           offsetof(struct normal, longitud), offsetof(struct normal, crc));
    printf("compacta: tamaño %zu  alineación %zu  longitud en %zu, crc en %zu\n",
           sizeof(struct compacta), _Alignof(struct compacta),
           offsetof(struct compacta, longitud), offsetof(struct compacta, crc));

    /* siete bytes recibidos de afuera, en orden little endian */
    const unsigned char cable[7] = {0x02, 0x10, 0x27, 0x00, 0x00, 0xcd, 0xab};
    struct compacta c;

    memcpy(&c, cable, sizeof c);
    printf("tipo=%u longitud=%u crc=0x%04x\n", c.tipo, c.longitud, c.crc);
    return 0;
}
terminal
$ ./packed
normal:   tamaño 12  alineación 4  longitud en 4, crc en 8
compacta: tamaño 7  alineación 1  longitud en 1, crc en 5
tipo=2 longitud=10000 crc=0xabcd

La estructura normal ocupa 12 bytes, con 3 de relleno antes de longitud y 2 al final; la compacta, exactamente 7, con longitud en el byte 1. Con memcpy de siete bytes recibidos, los campos aparecen donde deben: 0x00002710 es 10 000. (Los enteros están guardados con el byte menos significativo primero, el orden little endian del tema 5; un formato de red usa el orden contrario, y exige convertir cada campo.) Se usan los tipos uint8_t, uint16_t y uint32_t de stdint.h, de tamaño exacto, y no int ni long, cuyo tamaño cambia con la plataforma.

El precio de packed es que los campos ya no están alineados, y hay que tener cuidado con lo que se hace con ellos:

terminal · direccion.c: duplicar(&c.longitud), con un campo uint32_t de una estructura packed
$ gcc -std=c17 -Wall -Wextra -g -o direccion direccion.c
direccion.c: In function ‘main’:
direccion.c:18:29: warning: taking address of packed member of ‘struct compacta’ may result in an unaligned pointer value [-Waddress-of-packed-member]
   18 |     printf("%u\n", duplicar(&c.longitud));
      |                             ^~~~~~~~~~~

Leer o escribir c.longitud directamente es seguro: el compilador sabe que el campo puede estar desalineado y genera el acceso adecuado, byte a byte o con una instrucción que tolera la desalineación. Lo que no es seguro es tomar su dirección. Un uint32_t * promete una dirección múltiplo de 4, y el campo está en el byte 1: la función duplicar lo desreferencia con instrucciones para datos alineados, y en un procesador estricto termina con SIGBUS. gcc lo advierte, por defecto, con -Waddress-of-packed-member. En x86-64 el programa funcionó (imprimió 10000), por la tolerancia del procesador, no por ser correcto. Y las estructuras compactas son más lentas de acceder. Por eso packed se reserva para el borde entre el programa y el mundo exterior, donde un formato lo exige, y los datos se copian a estructuras normales para trabajar con ellos.

La forma alternativa, que no depende de extensiones, es no declarar la estructura compacta y leer cada campo de un buffer con memcpy a una variable del tipo correcto, como hicimos en el tema 5 y en el tema 18. Es más código, pero portable.

8. Controlar la alineación y el tamaño

La alineación también se puede aumentar. Las razones habituales son que una instrucción vectorial lo exija, o que dos datos usados por hilos distintos no caigan en la misma línea de caché: si dos contadores que dos procesadores modifican comparten una línea de 64 bytes, cada escritura de uno obliga al otro a recargarla, un efecto llamado falso compartimiento, que veremos en el curso Procesos e Hilos. El especificador alignas (de stdalign.h; _Alignas en C11) pide una alineación mayor para una variable o un campo, y static_assert (de assert.h; _Static_assert en C11) comprueba una condición al compilar:

alinear.c
#include <stdio.h>
#include <stdint.h>
#include <stdalign.h>
#include <assert.h>                       /* static_assert */

struct contador {
    alignas(64) long valor;               /* cada contador empieza en una línea de caché */
};

struct trio {
    char a;
    int b;
};

static_assert(sizeof(struct contador) == 64, "struct contador debe ocupar una línea de caché");
static_assert(sizeof(struct trio) == 8, "struct trio cambió de tamaño");

int main(void)
{
    struct contador c[3];

    printf("sizeof(struct contador) = %zu, alignof = %zu\n",
           sizeof(struct contador), alignof(struct contador));
    for (int i = 0; i < 3; i++)
        printf("c[%d] en %p  (mod 64 = %ju)\n", i, (void *) &c[i],
               (uintmax_t) ((uintptr_t) &c[i] % 64));
    alignas(32) char buffer[100];
    printf("buffer en %p  (mod 32 = %ju)\n", (void *) buffer,
           (uintmax_t) ((uintptr_t) buffer % 32));
    return 0;
}
terminal
$ ./alinear
sizeof(struct contador) = 64, alignof = 64
c[0] en 0x7ffef43a1d40  (mod 64 = 0)
c[1] en 0x7ffef43a1d80  (mod 64 = 0)
c[2] en 0x7ffef43a1dc0  (mod 64 = 0)
buffer en 0x7ffef43a1e00  (mod 32 = 0)

Un solo campo con alignas(64) alinea a 64 la estructura entera (regla 2) y la hace ocupar 64 bytes (regla 3): los tres contadores quedan en líneas de caché distintas. Para pedir esa alineación a toda una estructura, sin tocar los campos, gcc ofrece __attribute__((aligned(64))). El mismo especificador se aplica a variables sueltas, como buffer. Para memoria dinámica, aligned_alloc y posix_memalign piden una alineación mayor que la de malloc; las veremos en el tema 28.

static_assert es la defensa contra el cambio silencioso del diseño. Documenta una suposición, «esta estructura mide 64 bytes», y el compilador la verifica en cada compilación. Si alguien agrega un campo, reordena o compila en otra plataforma, el error aparece antes de ejecutar nada:

terminal · fallo.c: una cabecera de 1 + 4 + 1 bytes que debía medir 6
$ gcc -std=c17 -Wall -Wextra -g -o fallo fallo.c
In file included from fallo.c:1:
fallo.c:11:1: error: static assertion failed: "la cabecera debe medir 6 bytes"
   11 | static_assert(sizeof(struct cabecera) == 6, "la cabecera debe medir 6 bytes");
      | ^~~~~~~~~~~~~

Es el equivalente en tiempo de compilación del assert de siempre, y no cuesta nada en la ejecución. Se usa para el tamaño de las estructuras que se escriben en archivos o redes, para la posición de campos con offsetof, y para las suposiciones sobre los tipos, como sizeof(long) == 8.

9. Estructuras en archivos y en la red

La tentación es obvia: si una estructura tiene exactamente los datos que quiero guardar, write(fd, &registro, sizeof registro) los guarda de una vez, y read los recupera igual de fácil. Funciona mientras el mismo programa, compilado de la misma forma, escriba y lea en la misma máquina. Para cualquier otro caso tiene cuatro problemas, y todos vienen de este tema o de los anteriores:

terminal · i386.c: struct s { char c; double d; }, compilada para 32 y 64 bits (se muestra el ensamblador del tamaño y la alineación)
$ gcc -m32 -S -o - i386.c | grep -A1 -E '^(tamano|alineacion):'
tamano:
	.long	12
alineacion:
	.long	4
$ gcc -S -o - i386.c | grep -A1 -E '^(tamano|alineacion):'
tamano:
	.long	16
alineacion:
	.long	8

La práctica correcta es definir el formato por separado de la estructura: tipos de tamaño exacto (uint32_t), orden de bytes fijo, y funciones que escriban y lean cada campo en su posición, por ejemplo con memcpy a un buffer de bytes o con una estructura packed en el borde. Se acompaña de static_assert sobre los tamaños y los desplazamientos que el formato exige. Es más código que un write de la estructura, y es lo que se hace en los programas que sobreviven a un cambio de compilador o de máquina. Las estructuras en la memoria pueden tener el diseño que más convenga al procesador; las de los archivos y los protocolos, el que dice su especificación.

10. Por dentro: un campo es un desplazamiento

Todo lo anterior se ve en el código de máquina. El acceso a un campo de una estructura no tiene ningún mecanismo especial: es una lectura o escritura en la dirección de la estructura más una constante, el desplazamiento que calculó el compilador:

acceso.c
struct proceso {
    int pid;
    char nombre[16];
    long memoria_kib;
};

struct de40 {
    char x[40];
};

long memoria(const struct proceso *p)
{
    return p->memoria_kib;
}

int pid_de(const struct proceso *t, long i)
{
    return t[i].pid;
}

int primero(const struct de40 *t, long i)
{
    return t[i].x[0];
}
terminal
$ gcc -std=c17 -O2 -S -fno-asynchronous-unwind-tables -fcf-protection=none -o - acceso.c
memoria:
	movq	24(%rdi), %rax
	ret
pid_de:
	salq	$5, %rsi
	movl	(%rdi,%rsi), %eax
	ret
primero:
	leaq	(%rsi,%rsi,4), %rax
	movsbl	(%rdi,%rax,8), %eax
	ret

p->memoria_kib es movq 24(%rdi), %rax: leer 8 bytes en la dirección %rdi, la de la estructura, más 24, el desplazamiento que calculamos antes, incluido el relleno. Un acceso a un campo cuesta lo mismo que un acceso a una variable. Y t[i].pid necesita además saltar i elementos de 32 bytes: salq $5 desplaza i cinco bits a la izquierda, es decir, multiplica por 32, y se lee en (%rdi,%rsi). Con la estructura de 40 bytes no alcanza un desplazamiento de bits: la multiplicación por 40 se hace como 5 × 8, con una instrucción de dirección más, leaq (%rsi,%rsi,4) para 5 × i, y el factor 8 en el direccionamiento. Es una instrucción más, un costo mínimo, pero explica por qué los programadores de núcleos y bases de datos prefieren tamaños que son potencias de 2, 16, 32 o 64: indexar es más barato, y los elementos nunca cruzan una línea de caché.

11. container_of: de un campo a su estructura

El desplazamiento permite una técnica que usa el núcleo Linux en todas sus estructuras de datos. Para encadenar objetos de cualquier tipo en una lista, no se define una lista de struct tarea, otra de struct archivo, y así: se define un pequeño enlace genérico, y cada objeto lo lleva como un campo propio. La lista maneja solo enlaces; cuando necesita el objeto completo, lo recupera restando al puntero del enlace el desplazamiento del campo dentro de la estructura. Es la macro container_of:

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

/* dado un puntero a un campo, calcular el puntero a la estructura que lo contiene */
#define contenedor_de(ptr, tipo, campo) \
    ((tipo *) ((char *) (ptr) - offsetof(tipo, campo)))

struct enlace {
    struct enlace *siguiente;
};

struct tarea {
    int id;
    char nombre[12];
    struct enlace en_cola;                /* para encadenar tareas, sin saber qué son */
};

static void recorrer(const struct enlace *e)
{
    for (; e != NULL; e = e->siguiente) {
        const struct tarea *t = contenedor_de(e, const struct tarea, en_cola);

        printf("  tarea %d (%s)\n", t->id, t->nombre);
    }
}

int main(void)
{
    struct tarea c = {3, "limpiar", {NULL}};
    struct tarea b = {2, "compilar", {&c.en_cola}};
    struct tarea a = {1, "descargar", {&b.en_cola}};

    printf("offsetof(struct tarea, en_cola) = %zu\n", offsetof(struct tarea, en_cola));
    printf("&a = %p, &a.en_cola = %p\n", (void *) &a, (void *) &a.en_cola);
    recorrer(&a.en_cola);
    return 0;
}
terminal
$ ./contenedor
offsetof(struct tarea, en_cola) = 16
&a = 0x7fff67e2c350, &a.en_cola = 0x7fff67e2c360
  tarea 1 (descargar)
  tarea 2 (compilar)
  tarea 3 (limpiar)

La función recorrer trabaja solo con enlaces, y no sabe qué contienen: la misma función serviría para recorrer cualquier estructura que tenga un struct enlace. Cuando necesita los datos de cada elemento, contenedor_de hace la cuenta: la dirección del enlace, …360, menos el desplazamiento del campo, 16, da la dirección de la tarea, …350. Se cumple lo que dijimos en la sección 3, al revés. La macro del núcleo, container_of, tiene el mismo cuerpo, con una comprobación adicional de que ptr sea realmente del tipo del campo. Es el mecanismo con el que se construyen las listas del núcleo, struct list_head: una estructura de dos punteros que se incluye en cada objeto que se quiere listar. Las listas enlazadas con memoria dinámica, en la versión de este curso, son el tema 30.

12. El diseño en gdb

gdb tiene un modo de ptype hecho para este tema: con /o muestra, para cada campo, el desplazamiento y el tamaño, y señala cada hueco de relleno:

terminal · gdb sobre relleno y registro (compilados con -g -fno-eliminate-unused-debug-types, para que gcc conserve los tipos que solo se usan en sizeof y offsetof)
$ gdb -q -batch -ex 'ptype /o struct a' -ex 'ptype /o struct e' ./relleno
/* offset      |    size */  type = struct a {
/*      0      |       1 */    char c;
/* XXX  3-byte hole      */
/*      4      |       4 */    int i;
/*      8      |       1 */    char d;
/* XXX  3-byte padding   */

                               /* total size (bytes):   12 */
                             }
/* offset      |    size */  type = struct e {
/*      0      |       8 */    long l;
/*      8      |       1 */    char c;
/* XXX  7-byte padding   */

                               /* total size (bytes):   16 */
                             }
$ gdb -q -batch -ex 'ptype /o struct registro_a' ./registro
/* offset      |    size */  type = struct registro_a {
/*      0      |       1 */    char tipo;
/* XXX  7-byte hole      */
/*      8      |       8 */    double valor;
/*     16      |       1 */    char marca;
/* XXX  3-byte hole      */
/*     20      |       4 */    int cuenta;
/*     24      |       1 */    char flag;
/* XXX  7-byte hole      */
/*     32      |       8 */    long id;

                               /* total size (bytes):   40 */
                             }

La salida distingue dos tipos de relleno: hole es un hueco entre dos campos, y padding el relleno final. En registro_a hay tres huecos que suman 17 bytes, y los 23 restantes son datos, 40 en total; con el orden de registro_b desaparecen. Existe una herramienta que hace lo mismo para ejecutables enteros, pahole, del paquete dwarves, que no está instalada en esta máquina. Para ver el contenido de una variable, incluido el relleno, x/8xb &a muestra los bytes en hexadecimal:

terminal · gdb sobre comparar, con las dos estructuras ya llenas
$ gdb -q -batch -ex 'break 41' -ex run -ex 'x/8xb &a' -ex 'x/8xb &b' -ex 'p sizeof(a)' \
      -ex 'p (long) &((struct par *) 0)->valor' ./comparar
Breakpoint 1, main () at comparar.c:41
41	    volcar("b:", &b);
0x7fffffffd7c8:	0x78	0x00	0x00	0x00	0x07	0x00	0x00	0x00
0x7fffffffd7d0:	0x78	0xff	0xff	0xff	0x07	0x00	0x00	0x00
$1 = 8
$2 = 4

Los bytes 1 a 3 de cada estructura son el relleno que vimos en comparar.c, ahora desde el depurador. La última orden es el truco de offsetof escrito a mano: la dirección de valor en una estructura que empezara en 0 es el desplazamiento del campo, 4.

13. Errores frecuentes

ErrorQué pasaCorrección
Suponer sizeof igual a la suma de los camposLos tamaños son mayores por el relleno: 12 en lugar de 6.Usar sizeof(struct …) siempre.
Reservar memoria con una suma a manoMenos de lo necesario: desbordamiento.malloc(n * sizeof(struct x)).
Declarar los campos en cualquier ordenHasta 40 % de memoria desperdiciada en estructuras que se repiten.Del más grande al más chico.
Comparar estructuras con memcmpEl relleno tiene basura: «distintas» con los mismos campos.Comparar campo por campo.
Calcular un hash o una suma de los bytes de una estructuraIncluye el relleno: resultados que cambian.Procesar cada campo.
write(fd, &s, sizeof s)Escribe el relleno (fugas de información) y depende de la plataforma.Un formato propio, con tipos de tamaño exacto, campo por campo.
Suponer que ={0} pone el relleno en ceroEl estándar no lo promete.memset(&s, 0, sizeof s) cuando los bytes importan.
Tomar la dirección de un campo de una estructura packedPuntero desalineado: SIGBUS en otros procesadores.Copiar con memcpy a una variable.
Usar int o long en un formato de archivoEl tamaño depende de la plataforma.uint32_t y compañía, de stdint.h.
Cambiar una estructura sin revisar quien la usa en un formatoCambia el diseño en silencio.static_assert sobre sizeof y offsetof.
Calcular un desplazamiento con una cuenta propiaNo incluye el relleno.offsetof(tipo, campo).
Guardar un int o un double en un arreglo de char con un castEl arreglo tiene alineación 1: el valor puede quedar desalineado.memcpy a una variable del tipo, alignas, o memoria de malloc.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué significa que un double tenga alineación 8?

Que sus valores deben guardarse en direcciones múltiplos de 8. El compilador lo respeta al colocar variables y campos, y el programador, al calcular direcciones. Un acceso desalineado es más lento o imposible en el hardware, y comportamiento indefinido en C.

2. ¿Cuánto mide struct { char a; int b; char c; }, y en qué desplazamientos está cada campo?

Mide 12 bytes: a en 0, b en 4 (después de 3 bytes de relleno, para que sea múltiplo de 4), y c en 8. El tamaño se redondea de 9 a 12, múltiplo de la alineación 4 de la estructura.

3. ¿Cuánto mide la misma estructura con los campos en el orden b, a, c?

Mide 8 bytes: b en 0, a en 4, c en 5, y 2 bytes de relleno final. Los mismos tres campos ocupan un tercio menos, solo por el orden.

4. ¿Por qué el tamaño de una estructura es siempre múltiplo de su alineación?

Para que en un arreglo de estructuras cada elemento empiece en una dirección bien alineada. Si el tamaño no fuera múltiplo, el segundo elemento empezaría en una dirección que no cumpliría la alineación de sus campos. El relleno final garantiza que todos los elementos queden alineados.

5. ¿Qué regla práctica reduce el relleno al declarar una estructura?

Declarar los campos del más grande al más chico, o al menos agrupar los de igual alineación. Así los campos chicos se acomodan juntos al final y no hace falta saltear bytes para alinear los grandes. No tiene sentido forzarla en estructuras pocas o cuyo orden lógico importa más que unos bytes.

6. ¿Para qué sirve offsetof, y por qué no se escribe a mano?

Da el desplazamiento en bytes de un campo dentro de su estructura, como constante al compilar, incluido el relleno. No se escribe a mano porque el truco de la dirección nula no está garantizado por el estándar; gcc usa una función interna que sí lo está.

7. ¿Por qué memcmp puede decir que dos estructuras con los mismos valores son distintas?

Porque compara todos los bytes, incluido el relleno entre campos y al final, que no pertenece a ningún campo y contiene lo que hubiera antes en esa memoria. Dos estructuras con campos iguales pueden diferir en el relleno. Hay que comparar campo por campo.

8. ¿Se puede contar con que struct s x = {0}; deje el relleno en cero?

No. En las pruebas con gcc 15 lo dejó en cero, pero el estándar no lo garantiza, y otra versión u otro compilador pueden dejar basura. Cuando los bytes del relleno importan, como al comparar o enviar la estructura, se usa memset(&x, 0, sizeof x) antes de llenar los campos.

9. ¿Qué hace __attribute__((packed)), y qué riesgo tiene?

Elimina el relleno y trata la estructura como de alineación 1, de modo que sus campos queden pegados, como exige un formato binario. Los campos quedan desalineados: acceder a ellos directamente es seguro, pero tomar su dirección produce un puntero desalineado, que gcc advierte con -Waddress-of-packed-member y que en otros procesadores termina con SIGBUS.

10. ¿Por qué no es buena idea guardar una estructura en un archivo con write(fd, &s, sizeof s)?

Porque escribe también el relleno, con posible basura o información sensible; porque el diseño y el tamaño de los tipos dependen de la plataforma y el compilador, y otra máquina no lo leerá bien; y porque el orden de los bytes de los enteros puede variar. Se define el formato aparte, con tipos de tamaño exacto, y se escribe campo por campo.

11. ¿Para qué sirve static_assert(sizeof(struct x) == 64, "…")?

Para que el compilador verifique una suposición sobre el diseño en cada compilación. Si alguien agrega un campo, reordena o compila en otra plataforma y el tamaño cambia, la compilación falla con el mensaje, antes de que el programa corra con un formato roto.

12. ¿Cómo recupera container_of la estructura a partir de un puntero a uno de sus campos?

Resta a la dirección del campo su desplazamiento dentro de la estructura, (char *) ptr - offsetof(tipo, campo), y convierte el resultado a un puntero al tipo. Permite que estructuras de cualquier tipo lleven un campo enlace genérico para formar listas, y recuperar el objeto completo cuando hace falta.

13. ¿Qué hace la instrucción movq 24(%rdi), %rax en p->memoria_kib?

Lee 8 bytes de la dirección que está en %rdi, la de la estructura, más 24, el desplazamiento del campo memoria_kib, y los guarda en %rax. Un acceso a un campo es una lectura con desplazamiento constante, tan barata como la de una variable; el relleno está incluido en el 24.

Para practicar

  1. Calcula a mano el tamaño y los desplazamientos de struct { char a; short b; int c; char d; long e; } con las tres reglas, y después compruébalos con offsetof y sizeof. Reordénala para minimizar el tamaño.
  2. Compila registro.c con -Wpadded y con gdb ptype /o, y compara los informes. Agrega un campo short a registro_b y predice dónde queda y cuál es el nuevo tamaño antes de ejecutar.
  3. Escribe una función iguales(const struct par *, const struct par *) que compare campo por campo, y otra con memcmp. Haz que la estructura se construya en funciones distintas, sobre una pila ensuciada a propósito con una llamada anterior, y encuentra un caso en que difieran.
  4. Escribe un programa que reciba por stdin siete bytes en hexadecimal y los interprete como la cabecera de packed.c, con memcpy y sin packed. Valida con static_assert los desplazamientos esperados.
  5. Compila direccion.c con -fsanitize=undefined,alignment y observa qué detecta y qué no. Cambia la función duplicar para que reciba el valor y no un puntero, y comprueba que la advertencia desaparece.
  6. Mide cuánto tarda recorrer 10 millones de elementos de struct registro_a y de struct registro_b, sumando el campo cuenta, con clock_gettime. ¿Se nota la diferencia de 40 contra 24 bytes por elemento?
  7. Agrega a contenedor.c una segunda estructura, struct archivo, con otro campo struct enlace en una posición distinta, y comprueba que la misma función recorrer, con otro contenedor_de, recorre una lista de archivos.
  8. Con gcc -m32 -S compara el tamaño y la alineación de struct { char c; long l; double d; } para 32 y 64 bits, y explica cada número.