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:
#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;
}
$ 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:
- 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
- La estructura entera tiene la alineación de su campo más exigente. Una estructura que contiene un
doublese alinea a 8; una que solo tienecharyshort, a 2. alinear - 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:
#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;
}
$ ./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
Cada estructura ilustra una de las reglas:
struct a(12 bytes).cocupa el byte 0.inecesita un múltiplo de 4, y el primero libre es el 4: la regla 1 deja 3 bytes de relleno.dva en el 8. La estructura se alinea a 4 (regla 2), y el tamaño se redondea de 9 a 12 (regla 3).struct b(8 bytes). Los mismos campos, ordenados del más grande al más chico:cydcaben pegados detrás deisin relleno interior, y solo se rellena el final, de 6 a 8.struct c(24) ystruct d(16). Con undoublede alineación 8, el efecto es mayor: tres campos que suman 10 bytes ocupan 24 si elcharva primero, y 16 si va después.struct e(16). Unlongy uncharsuman 9 bytes, pero el tamaño se redondea a 16 para respetar la alineación 8 de la estructura. No hay relleno interior; todo el relleno está al final.struct f(4). Unchary unshortsuman 3 bytes; elshortse coloca en el 2, y la estructura se alinea a 2 y se redondea a 4.
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:
- En
struct procesodel tema 24,offsetof(struct proceso, memoria_kib)vale 24:pidocupa los bytes 0 a 3,nombrelos 4 a 19, y los 20 a 23 son relleno. - El desplazamiento de un campo es siempre un múltiplo de su alineación, y el del primer campo siempre es 0: no puede haber relleno antes del primero.
- Se puede usar para calcular direcciones: la dirección de un campo es la de la estructura más su desplazamiento,
(char *) &p + offsetof(struct proceso, nombre)es lo mismo quep.nombre. La sección 11 usa esto al revés.
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:
- Un arreglo de n estructuras ocupa n veces
sizeof, relleno incluido; un relleno de 4 bytes en una estructura de 32 desperdicia una octava parte de cada elemento. En un millón de elementos son 4 MB. - Se puede copiar un elemento con
memcpy(&v[i], &origen, sizeof v[i])y mover bloques de estructuras conmemmoveoqsort, que usansizeofcomo paso. - El relleno final no es de nadie: el último campo ocupa el byte 8, pero los 7 bytes siguientes forman parte de la estructura. Escribir en ellos con un puntero a
chares posible, pero no tiene nombre y no se debe.
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:
#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;
}
$ ./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:
$ 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:
#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;
}
$ ./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:
#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;
}
$ ./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:
#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;
}
$ ./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:
$ 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:
#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;
}
$ ./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:
$ 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, ®istro, 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:
- El relleno viaja. Los bytes sin dueño se escriben con lo que tengan, lo que puede revelar información y hace que el mismo registro produzca archivos distintos (sección 6).
- El diseño depende de la plataforma. Mismo código, distinto resultado:
$ 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
- Los tipos cambian de tamaño. Un
longmide 8 bytes en Linux de 64 bits, 4 en Windows de 64 bits y en Linux de 32. Un campolongescrito por un programa no lo lee otro compilado para otra plataforma. - El orden de los bytes. Un entero se guarda con el byte menos significativo primero en x86-64 y ARM habituales, y con el más significativo primero en los formatos de red. Un archivo escrito por una arquitectura puede leerlo mal otra.
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:
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];
}
$ 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:
#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;
}
$ ./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:
$ 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:
$ 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
| Error | Qué pasa | Corrección |
|---|---|---|
Suponer sizeof igual a la suma de los campos | Los tamaños son mayores por el relleno: 12 en lugar de 6. | Usar sizeof(struct …) siempre. |
| Reservar memoria con una suma a mano | Menos de lo necesario: desbordamiento. | malloc(n * sizeof(struct x)). |
| Declarar los campos en cualquier orden | Hasta 40 % de memoria desperdiciada en estructuras que se repiten. | Del más grande al más chico. |
Comparar estructuras con memcmp | El relleno tiene basura: «distintas» con los mismos campos. | Comparar campo por campo. |
| Calcular un hash o una suma de los bytes de una estructura | Incluye 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 cero | El estándar no lo promete. | memset(&s, 0, sizeof s) cuando los bytes importan. |
Tomar la dirección de un campo de una estructura packed | Puntero desalineado: SIGBUS en otros procesadores. | Copiar con memcpy a una variable. |
Usar int o long en un formato de archivo | El tamaño depende de la plataforma. | uint32_t y compañía, de stdint.h. |
| Cambiar una estructura sin revisar quien la usa en un formato | Cambia el diseño en silencio. | static_assert sobre sizeof y offsetof. |
| Calcular un desplazamiento con una cuenta propia | No incluye el relleno. | offsetof(tipo, campo). |
Guardar un int o un double en un arreglo de char con un cast | El arreglo tiene alineación 1: el valor puede quedar desalineado. | memcpy a una variable del tipo, alignas, o memoria de malloc. |
14. Resumen
- Cada tipo tiene una alineación: sus valores deben estar en direcciones múltiplos de ella. En x86-64 los tipos básicos se alinean a su tamaño, hasta 16;
alignofla consulta ymax_align_tes la máxima, que respetamalloc. - El compilador coloca cada campo en el primer desplazamiento que sea múltiplo de su alineación, no reordena nunca, alinea la estructura a su campo más exigente y redondea su tamaño a un múltiplo de esa alineación.
- El relleno puede ser interior, un hueco entre campos, o final. El tamaño total garantiza que cada elemento de un arreglo de estructuras quede alineado.
offsetof(tipo, campo)da el desplazamiento de un campo, constante al compilar. El primer campo está siempre en 0.- El orden de los campos cambia el tamaño: del más grande al más chico, el relleno se reduce o desaparece.
-Wpaddedyptype /odegdblo muestran. - El relleno contiene basura:
memcmp, los hash de los bytes ywritede una estructura entera son poco fiables y pueden filtrar información. Cuando importan los bytes,memsetantes de llenar; el estándar no garantiza que los demás métodos pongan el relleno en cero. __attribute__((packed))elimina el relleno y la alineación, para formatos fijos; no se debe tomar la dirección de sus campos, y los accesos son más lentos.alignasaumenta la alineación, por ejemplo a 64 para separar datos en líneas de caché;static_assertverifica tamaños y desplazamientos al compilar.- El diseño depende de la plataforma y del tamaño de los tipos: los formatos de archivos y redes se definen aparte, con tipos de tamaño exacto y campo por campo.
- Acceder a un campo es sumar una constante a la dirección de la estructura; indexar un arreglo de estructuras multiplica por
sizeof, más barato si es potencia de 2. - Con
offsetofse pasa de un campo a su estructura:container_of, la base de las listas del núcleo Linux.
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
- 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 conoffsetofysizeof. Reordénala para minimizar el tamaño. - Compila
registro.ccon-Wpaddedy congdbptype /o, y compara los informes. Agrega un camposhortaregistro_by predice dónde queda y cuál es el nuevo tamaño antes de ejecutar. - Escribe una función
iguales(const struct par *, const struct par *)que compare campo por campo, y otra conmemcmp. 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. - Escribe un programa que reciba por
stdinsiete bytes en hexadecimal y los interprete como la cabecera depacked.c, conmemcpyy sinpacked. Valida constatic_assertlos desplazamientos esperados. - Compila
direccion.ccon-fsanitize=undefined,alignmenty observa qué detecta y qué no. Cambia la funciónduplicarpara que reciba el valor y no un puntero, y comprueba que la advertencia desaparece. - Mide cuánto tarda recorrer 10 millones de elementos de
struct registro_ay destruct registro_b, sumando el campocuenta, conclock_gettime. ¿Se nota la diferencia de 40 contra 24 bytes por elemento? - Agrega a
contenedor.cuna segunda estructura,struct archivo, con otro campostruct enlaceen una posición distinta, y comprueba que la misma funciónrecorrer, con otrocontenedor_de, recorre una lista de archivos. - Con
gcc -m32 -Scompara el tamaño y la alineación destruct { char c; long l; double d; }para 32 y 64 bits, y explica cada número.