El modelo mental correcto: memoria, direcciones y tipos
La memoria como un array gigante de bytes direccionables, qué es exactamente una dirección y qué añade el tipo del puntero. El modelo que convierte los punteros en algo obvio en vez de en algo temible.
Casi todo el pánico a los punteros nace de un modelo mental defectuoso: se enseñan como una sintaxis rara con asteriscos en vez de como lo que son, una consecuencia inevitable de cómo funciona la máquina. Si interiorizas tres ideas —la memoria es un array de bytes, una dirección es el índice en ese array, y el tipo es la lente con la que se leen esos bytes— el resto del lenguaje deja de ser un catálogo de reglas y se convierte en deducción.
- Ver la memoria como un array gigante de bytes y entender el modelo de objetos de C.
- Distinguir la dirección como número de la dirección como referencia a un objeto.
- Explicar qué información añade el tipo del puntero: tamaño, alineación e interpretación.
- Entender el papel especial de
void *y deunsigned char *.
La memoria es un array gigante de bytes
El modelo abstracto de C es deliberadamente simple: el almacenamiento de un programa es una secuencia de unidades direccionables llamadas bytes, cada una identificada por un número único. Un int, un double o un struct no son entidades mágicas: son objetos, es decir, regiones contiguas de ese array de bytes con un tamaño fijo que sizeof te dice.
Puedes comprobar literalmente que un objeto es una secuencia de bytes leyéndolo a través de unsigned char *, el único tipo con licencia para inspeccionar la representación cruda de cualquier objeto:
#include <stdio.h>
#include <stddef.h>
int main(void)
{
int x = 0x01020304;
unsigned char *b = (unsigned char *)&x;
for (size_t i = 0; i < sizeof x; i++)
printf("byte %zu = %02x\n", i, b[i]);
// En x86-64 imprime 04 03 02 01: little-endian.
}
Esa salida revela dos verdades incómodas y liberadoras a la vez. Primera: el valor 0x01020304 no existe en la memoria; lo que existe son cuatro bytes cuya interpretación como entero depende del orden que fije la plataforma. Segunda: el objeto x ocupa sizeof x bytes consecutivos, y su dirección es la del primero de ellos, no la de una entidad indivisible.
Una dirección no es solo un número
La tentación es definir “dirección” como “un entero que identifica un byte”. Es la mitad de la historia, y la mitad que falta explica bugs que de otro modo parecen brujería.
En el modelo de C una dirección arrastra dos cosas: el número y una procedencia (la palabra técnica es provenance), es decir, a qué objeto pertenece esa posición. Dos punteros pueden tener el mismo valor numérico y no ser intercambiables si proceden de objetos distintos, porque el compilador razona sobre los límites del objeto del que salió cada uno.
int a = 1;
int b = 2;
int *pa = &a;
int *pb = &b;
// Aunque a y b esten contiguos en la pila, esto es
// comportamiento indefinido: pa solo referencia al objeto a.
// int valor = *(pa + 1); // no es "b": es UB
De ahí se deduce por qué convertir un puntero a entero y de vuelta es terreno resbaladizo. C23 estandariza uintptr_t en stdint.h para ese viaje de ida y vuelta, pero conviene tratarlo como una operación de bajo nivel, no como un atajo cotidiano:
#include <stdint.h>
uintptr_t n = (uintptr_t)&x; // la direccion como entero
int *q = (int *)n; // y de vuelta: q vuelve a apuntar a x
flowchart LR subgraph MEM [Memoria como array de bytes] B0[0x1000] --- B1[0x1001] --- B2[0x1002] --- B3[0x1003] end P[Puntero p vale 0x1000] --> B0 T[Tipo int dice: lee 4 bytes desde ahi] --> MEM style P fill:#89b4fa,color:#11111b style T fill:#a6e3a1,color:#11111b
El tipo es la lente que interpreta los bytes
Si una dirección solo nombra un byte, ¿de dónde sale que *p te devuelva un int completo? Del tipo del puntero. La dirección dice dónde; el tipo dice cuánto y cómo.
Concretamente, el tipo apuntado aporta tres piezas de información que el número por sí solo no tiene:
- Cuántos bytes leer o escribir al desreferenciar:
sizeofdel tipo apuntado. - Cómo interpretarlos: los mismos cuatro bytes son un entero con signo, un
floato cuatro caracteres según la lente. - Cuánto avanzar en aritmética de punteros, que es el tema de la lección siguiente.
La demostración es directa: la misma dirección, leída con dos lentes, produce dos valores distintos.
#include <stdio.h>
int main(void)
{
unsigned char bytes[4] = { 0x00, 0x00, 0x80, 0x3f };
unsigned int *como_entero = (unsigned int *)bytes;
float *como_float = (float *)bytes;
printf("%u\n", *como_entero); // 1065353216
printf("%f\n", *como_float); // 1.000000
}
Esto también explica por qué void * es especial: es una dirección sin lente. Sabe dónde, pero no cuánto ni cómo, así que el lenguaje prohíbe desreferenciarlo y prohíbe su aritmética. Es el tipo que usa malloc, precisamente porque asignar memoria es una operación que ignora deliberadamente para qué la vas a usar.
En el extremo opuesto está unsigned char *, la lente de aumento máximo: mira byte a byte, tiene tamaño y alineación mínimos, y el estándar le concede el privilegio explícito de poder inspeccionar la representación de cualquier objeto sin violar las reglas de aliasing. Entre ambos extremos vive todo lo demás.
El array de bytes
Todo objeto es una secuencia contigua de bytes. sizeof te dice cuántos, y siempre vale al menos uno.
La dirección
Identifica el primer byte del objeto y arrastra una procedencia: a qué objeto pertenece.
La lente
El tipo apuntado decide cuántos bytes leer, cómo interpretarlos y cuánto avanzar al sumar.
Sin lente
void * guarda posición sin interpretación: no se desreferencia ni admite aritmética estándar.
Alineación: la restricción que impone el hardware
El array de bytes no es del todo homogéneo desde el punto de vista del hardware. Cada tipo tiene un requisito de alineación: su dirección debe ser múltiplo de cierto valor, que puedes consultar con alignof (disponible como palabra clave en C23, sin necesidad de stdalign.h).
printf("%zu %zu\n", sizeof(int), alignof(int)); // 4 4
printf("%zu %zu\n", sizeof(double), alignof(double)); // 8 8
Colocar un int * en una dirección no alineada y desreferenciarlo es comportamiento indefinido, aunque en x86 suela “funcionar”. En ARM o RISC-V puede costarte una excepción o una penalización de rendimiento severa. Esta es también la razón profunda del relleno (padding) dentro de los struct: el compilador inserta bytes muertos para que cada campo caiga en una dirección legal.
struct mal_ordenado {
char a; // 1 byte, luego 7 de relleno
double b; // 8 bytes, debe caer en multiplo de 8
char c; // 1 byte, luego 7 de relleno final
}; // sizeof = 24 en x86-64
struct bien_ordenado {
double b; // 8
char a; // 1
char c; // 1, luego 6 de relleno final
}; // sizeof = 16
Los mismos tres campos, dos tamaños distintos según el orden de declaración. No es una curiosidad de examen: en estructuras replicadas millones de veces, ordenar los campos de mayor a menor alineación es una de las optimizaciones de memoria más baratas que existen.
La suma de los sizeof de los miembros casi nunca coincide con el sizeof del struct, porque el relleno es invisible en la declaración. Si necesitas el desplazamiento real de un campo, usa offsetof de stddef.h en vez de calcularlo a mano, y si necesitas serializar la estructura, escribe campo a campo en vez de volcar los bytes crudos: el relleno no tiene contenido definido y su tamaño cambia entre plataformas.
Reduce el tema a una ecuación y no volverás a perderte: un puntero es un par formado por una posición en el array de bytes y una lente con la que mirar desde esa posición. La posición es lo que la máquina entiende; la lente es lo que solo existe en tiempo de compilación y desaparece del binario. De esa asimetría se deduce, sin memorizar nada más, prácticamente todo el comportamiento de C. Se deduce por qué void * no puede desreferenciarse: tiene posición pero no lente. Se deduce por qué un cast entre tipos de puntero no mueve ni un solo bit en tiempo de ejecución: solo cambia la lente. Se deduce por qué avanzar un puntero salta sizeof bytes y no uno: la aritmética se expresa en unidades de la lente, no del array. Se deduce por qué el orden de los bytes importa al mirar la representación cruda y es invisible al mirar por la lente correcta. Y se deduce la asimetría de poder que define el lenguaje: cambiar la lente es gratis y siempre te lo permitirán, pero es una afirmación que tú firmas y que el compilador creerá a ciegas. El comportamiento indefinido no es un castigo arbitrario; es la factura de haber jurado que en esa posición había algo que no había. Programar bien en C consiste, casi por entero, en no mentir sobre la lente.
- Imprime los bytes crudos de un
int, unfloaty undoublea través deunsigned char *y deduce el orden de bytes de tu máquina. - Comprueba con
sizeofyalignoflos requisitos de cinco tipos distintos, incluido unstructpropio. - Define un
structcon unchar, undoubley otrochar; predice susizeofrazonando el relleno y verifica tu predicción. - Guarda una dirección en un
uintptr_t, imprímela en hexadecimal, conviértela de vuelta y comprueba que sigue funcionando. - Escribe cuatro bytes en un array y léelos con dos lentes distintas (
unsigned int *yfloat *); explica por escrito por qué el resultado difiere.