wandres.dev
COMPETENTE · Operadores y expresiones

sizeof, alignof y la representación en memoria

Qué mide exactamente sizeof y por qué no evalúa su operando, cómo la alineación impone huecos, por qué el tamaño de un struct no es la suma de sus miembros, y qué consecuencias tiene el relleno sobre comparar, hashear y serializar datos.

⏱ 18 min

Un tipo en C no es una etiqueta: es un contrato sobre memoria que fija cuántos bytes ocupa un objeto, en qué direcciones puede empezar y cómo se interpretan sus bits. sizeof y alignof son las dos ventanas a ese contrato, y el hueco entre lo que miden y lo que la gente supone que miden es donde viven algunos de los errores más caros de C: comparaciones que fallan sin motivo aparente, estructuras que ocupan el doble de lo necesario y datos serializados que no se pueden leer en otra máquina.

🎯 Al terminar esta lección sabrás
  • Precisar qué cuenta sizeof, en qué unidad, y por qué no evalúa su operando.
  • Usar alignof y alignas de C23 y entender de dónde viene el requisito de alineación.
  • Calcular a mano el relleno de un struct y reordenar miembros para eliminarlo.
  • Razonar sobre la representación en bytes: offsetof, comparación, serialización y reinterpretación de tipos.

Qué mide realmente sizeof

sizeof devuelve el número de bytes que ocupa un objeto o un tipo, con tres precisiones que cambian por completo su significado.

La primera es la unidad. Un byte en C es, por definición, lo que ocupa un char, de modo que sizeof(char) vale 1 siempre, en toda plataforma conforme. Eso no significa ocho bits: significa CHAR_BIT bits, una macro de limits.h que el estándar obliga a que sea al menos ocho y que en la práctica lo es en todo hardware relevante, pero que no puedes dar por hecha en un DSP.

La segunda es el tipo del resultado, que es size_t, sin signo y de anchura definida por la implementación. De ahí que el especificador correcto sea %zu y no %d, y de ahí también que restar dos tamaños pueda dar un número enorme en vez de un negativo.

La tercera, y la que más sorprende, es que sizeof no evalúa su operando: es un operador de tiempo de compilación, y la expresión que le das solo se usa para deducir un tipo.

#include <stdio.h>

int i = 5;
size_t n = sizeof(i++);      /* i sigue valiendo 5: nunca se incremento */
printf("%zu %d\n", n, i);    /* imprime el tamano de int y luego 5      */

int *p = NULL;
sizeof(*p);                  /* correcto: no hay desreferencia real     */

La única excepción es el array de longitud variable, cuyo tamaño sí debe calcularse en ejecución y donde el operando sí se evalúa. Es la única grieta en la naturaleza estática del operador.

⚠️
El array que dejó de serlo

El error más frecuente con sizeof no está en el operador sino en el operando. El idioma sizeof arr / sizeof arr[0] cuenta los elementos de un array solo mientras el array siga siendo un array. Al pasarlo a una función, el parámetro decae a puntero y sizeof pasa a medir el puntero, con lo que la cuenta devuelve uno, dos u ocho según la plataforma, y siempre en silencio. Ni siquiera declarar el parámetro como int arr[10] ayuda: el estándar lo reescribe a int *arr. La consecuencia práctica es una regla dura: el número de elementos viaja como parámetro aparte, siempre. C23 no cambia esto, pero sí ofrece defensa parcial con la sintaxis int arr[static 10], que documenta la expectativa y permite al compilador avisar.

Alineación: alignof y alignas

La alineación es el requisito de que la dirección de un objeto sea múltiplo de cierto número. No es un capricho del compilador: procede del hardware, donde una carga de cuatro bytes desde una dirección no múltiplo de cuatro cuesta dos accesos a memoria en el mejor caso y provoca una excepción en arquitecturas que no toleran accesos desalineados.

En C23, alignof y alignas son palabras clave del lenguaje, no macros. Las formas antiguas con guion bajo siguen existiendo por compatibilidad y la cabecera stdalign.h queda marcada como obsolescente.

#include <stdint.h>

alignof(char)       /* 1  */
alignof(int)        /* 4 en la mayoria de ABI de 64 bits */
alignof(double)     /* 8 */
alignof(max_align_t)  /* la alineacion mas restrictiva de un tipo escalar */

/* Sobrealinear a la linea de cache para evitar el falso compartido */
alignas(64) _Atomic long contador_a;
alignas(64) _Atomic long contador_b;

/* Un buffer con alineacion suficiente para cualquier tipo */
alignas(max_align_t) unsigned char arena[4096];

Para memoria dinámica, malloc garantiza una alineación válida para cualquier tipo de tamaño menor o igual al solicitado, pero no más. Si necesitas alineación de línea de caché o de página, la herramienta es aligned_alloc, que exige que el tamaño sea múltiplo de la alineación pedida.

Entre sizeof y alignof existe una relación invariante que conviene tener presente: el tamaño de un tipo es siempre múltiplo de su alineación. Es lo que permite que la aritmética de punteros funcione, y lo que obliga al relleno de cola que veremos enseguida. Dos restricciones más: alignas solo puede aumentar la alineación, nunca reducirla por debajo de la natural del tipo, y su argumento debe ser una potencia de dos. Y ni sizeof ni alignof aceptan tipos incompletos, tipos función ni campos de bits, porque en esos casos la pregunta simplemente no tiene respuesta.

/* Alinear el tipo entero, no solo una instancia */
struct alignas(64) nodo_cache {
	long clave;
	void *valor;
};

_Static_assert(sizeof(struct nodo_cache) % alignof(struct nodo_cache) == 0,
               "el tamano siempre es multiplo de la alineacion");

El relleno: el todo no es la suma de las partes

Las reglas de disposición de un struct son cuatro y se aplican en orden. Cada miembro se coloca en el primer offset disponible que sea múltiplo de su propia alineación, insertando relleno si hace falta. Los miembros se disponen en el orden de declaración, sin permiso para reordenarlos. La alineación del struct completo es la mayor de las de sus miembros. Y el tamaño total se redondea al alza hasta un múltiplo de esa alineación, añadiendo relleno de cola.

struct disperso {
	char  a;      /* offset 0, mas 3 bytes de relleno */
	int   b;      /* offset 4                         */
	char  c;      /* offset 8, mas 3 de relleno final */
};                    /* sizeof 12, alignof 4             */

struct compacto {
	int   b;      /* offset 0 */
	char  a;      /* offset 4 */
	char  c;      /* offset 5, mas 2 de relleno final */
};                    /* sizeof 8, alignof 4              */

Mismos tres miembros, un tercio menos de memoria, cero coste en rendimiento. En una estructura que se instancia un millón de veces la diferencia son cuatro megabytes y, lo que suele importar más, un número distinto de líneas de caché tocadas por recorrido.

flowchart TD
S[Reglas de disposicion de un struct] --> M1[Cada miembro va en un offset multiple de su alineacion]
M1 --> M2[El compilador inserta relleno donde haga falta]
M2 --> M3[La alineacion del struct es la mayor de sus miembros]
M3 --> M4[El tamano se redondea a un multiplo de esa alineacion]
M4 --> M5[Ese relleno de cola mantiene alineado un array de structs]
style M5 fill:#a6e3a1,color:#11111b

La regla práctica que elimina casi todo el relleno es ordenar los miembros de mayor a menor alineación: primero los de ocho bytes, luego los de cuatro, luego los de dos, luego los de uno. No es óptimo en todos los casos, pero se acerca mucho y es trivial de aplicar. Para inspeccionar el resultado sin adivinar, offsetof de stddef.h da la posición exacta de cada miembro, y pahole sobre el binario con información de depuración imprime el mapa completo con los huecos marcados.

💡
El relleno de cola explica por qué los arrays funcionan

Mucha gente entiende el relleno entre miembros pero no el del final. La razón es exactamente esta: en un array de estructuras, los elementos son contiguos, así que el elemento uno empieza en la dirección del elemento cero más sizeof. Si el tamaño no fuese múltiplo de la alineación de la estructura, el segundo elemento quedaría desalineado y todos los siguientes irían acumulando desfase. El relleno de cola no es desperdicio: es la condición que hace que la aritmética de punteros sobre arrays sea correcta.

Representación y sus consecuencias

Los bytes de relleno tienen valor indeterminado. El estándar no obliga a inicializarlos, y aunque una inicialización con = {0} los pone a cero en la práctica de los compiladores actuales, una asignación posterior miembro a miembro puede volver a dejarlos con basura. De ahí salen tres consecuencias que hay que tener grabadas.

Comparar estructuras con memcmp es incorrecto: dos objetos con miembros idénticos pueden diferir en el relleno y devolver distinto de cero. Hashear una estructura tratándola como bytes es incorrecto por el mismo motivo, y además introduce una fuga de información, porque esos bytes pueden contener restos de datos anteriores de la pila. Y escribir una estructura tal cual a un fichero o un socket es incorrecto por triplicado: relleno indeterminado, disposición dependiente del ABI y orden de bytes dependiente de la arquitectura.

#include <string.h>
#include <stddef.h>

/* Incorrecto: el relleno puede diferir */
if (memcmp(&x, &y, sizeof x) == 0) { }

/* Correcto: compara los miembros que definen la identidad */
if (x.b == y.b && x.a == y.a && x.c == y.c) { }

/* La posicion exacta de un miembro, sin adivinar */
size_t off = offsetof(struct compacto, c);

Para reinterpretar los bytes de un objeto como otro tipo, C ofrece dos vías legítimas. La primera es memcpy, siempre correcta y que todo compilador moderno reduce a una simple carga cuando el tamaño es constante. La segunda es leer un miembro distinto del que se escribió en una union, que en C —a diferencia de C++— está explícitamente permitido. Lo que nunca es correcto es tomar la dirección de un objeto, moldearla a un puntero de otro tipo y desreferenciarla: eso viola las reglas de aliasing y el optimizador tiene permiso para reordenar los accesos como si nunca se solapasen. La única excepción es unsigned char, que por diseño puede aliasar cualquier objeto y es la vía canónica para inspeccionar la representación byte a byte.

El tipo es un contrato sobre memoria, y esa es toda la diferencia

Aquí se cierra el arco conceptual de este nivel entero. En la mayoría de lenguajes, un tipo es una promesa sobre qué operaciones son válidas: una etiqueta que el compilador comprueba y luego descarta. En C un tipo es algo mucho más concreto y más comprometedor: es una descripción física de una región de memoria. Dice cuántos bytes ocupa, en qué direcciones puede empezar, dónde cae cada miembro, qué bits significan qué. Por eso sizeof existe como operador del lenguaje y no como función de biblioteca: la pregunta cuánto ocupa esto es tan fundamental en C como la pregunta cuánto vale esto. Y por eso el relleno, que en otro lenguaje sería un detalle invisible del tiempo de ejecución, aquí se te aparece como bytes reales que puedes leer, que tienen valores indeterminados y que rompen tu comparación de estructuras un martes por la tarde. La lección profunda es que en C no existe la abstracción sin representación. Un struct no es una tupla matemática: son bytes consecutivos con huecos calculados por una regla que puedes reproducir a mano. Un puntero no es una referencia opaca: es una dirección con requisitos de alineación que el hardware impone. Reordenar tres miembros no es un cambio cosmético: reduce la huella un treinta por ciento y cambia cuántas líneas de caché toca cada iteración de tu bucle. Esa transparencia es exactamente lo que hace a C incómodo para escribir lógica de negocio y insustituible para escribir un sistema operativo, un motor de base de datos o una pila de red. Cuando dejas de ver los tipos como etiquetas y empiezas a verlos como planos de memoria, has cruzado la frontera que separa a quien escribe C de quien piensa en C.

⚔️ Mide, reordena y demuestra
  1. Imprime sizeof y alignof de char, int, double, void * y max_align_t en tu máquina y explica cada relación.
  2. Demuestra que sizeof(i++) no incrementa i y razona por qué el operando no se evalúa.
  3. Declara la estructura dispersa y la compacta de esta lección, imprime sizeof y offsetof de cada miembro, y calcula a mano los bytes de relleno.
  4. Construye dos instancias con miembros idénticos cuyo memcmp devuelva distinto de cero manipulando el relleno a través de un puntero a unsigned char.
  5. Reinterpreta un double como sus ocho bytes usando memcpy y de nuevo con una union, e imprime la representación en hexadecimal.