wandres.dev
NINJA · Punteros I: fundamentos

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.

⏱ 15 min

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.

🎯 Al terminar esta lección sabrás
  • 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 de unsigned 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: sizeof del tipo apuntado.
  • Cómo interpretarlos: los mismos cuatro bytes son un entero con signo, un float o 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.

💡
Nunca deduzcas el tamaño de un struct sumando sus campos

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.

El puntero es una dirección más una interpretación, y ahí está todo

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.

⚔️ Haz visible el modelo
  1. Imprime los bytes crudos de un int, un float y un double a través de unsigned char * y deduce el orden de bytes de tu máquina.
  2. Comprueba con sizeof y alignof los requisitos de cinco tipos distintos, incluido un struct propio.
  3. Define un struct con un char, un double y otro char; predice su sizeof razonando el relleno y verifica tu predicción.
  4. Guarda una dirección en un uintptr_t, imprímela en hexadecimal, conviértela de vuelta y comprueba que sigue funcionando.
  5. Escribe cuatro bytes en un array y léelos con dos lentes distintas (unsigned int * y float *); explica por escrito por qué el resultado difiere.