Punteros en C, explicados desde cero

Lección 5 de 8: punteros en C. Los punteros tienen fama de difíciles, pero la idea es sencilla: una variable que guarda dónde está otra. Son lo que permite a C ser tan rápido y trabajar tan cerca de la máquina, y también el origen de sus errores más graves. Esta lección los explica desde cero, con esquemas, y enseña a usarlos sin miedo y sin descuidos.

Qué es un puntero

Cada variable vive en algún lugar de la memoria, y ese lugar tiene un número: su dirección. Un puntero es una variable que guarda una dirección. No hay más misterio: es un número que dice dónde está otra cosa. Dos operadores lo hacen todo:

  • &x da la dirección de x.
  • *p da lo que hay en la dirección que guarda p. Se lee «lo apuntado por p».

La variable precio vale 250 y está en la dirección 1000. La variable p guarda el número 1000, así que apunta a precio. La expresión con ampersand precio vale 1000, p vale 1000 y asterisco p vale 250int precio = 250; int *p = &precio;precio250dirección 1000p1000dirección 2000apunta a&precio vale 1000: la dirección de precio. p vale 1000. *p vale 250.Las direcciones son de ejemplo; las reales son números de 64 bits que cambian en cada ejecución.

Un puntero guarda la dirección de otra variable. Con * se llega a su valor. Esquema: decodigo.com.
#include <stdio.h>

int main(void)
{
    int precio = 250;
    int *p = &precio;

    printf("precio vale %d\n", precio);
    printf("*p vale %d\n", *p);
    printf("¿p guarda la dirección de precio? %s\n", p == &precio ? "sí" : "no");
    printf("Un puntero ocupa %zu bytes\n", sizeof p);

    *p = 199;
    printf("Tras *p = 199, precio vale %d\n", precio);
    return 0;
}
salidaprecio vale 250
*p vale 250
¿p guarda la dirección de precio? sí
Un puntero ocupa 8 bytes
Tras *p = 199, precio vale 199

En la declaración int *p, el asterisco dice «p es un puntero a int»; en el resto del código, *p significa «ir a donde apunta p». Es el mismo símbolo con dos papeles, y es lo que más confunde al principio. Escribir *p = 199 cambia precio, porque p apunta ahí.

Para ver la dirección se usa el hueco %p de printf. Cambia en cada ejecución, porque Linux coloca los programas en posiciones aleatorias de la memoria para dificultar los ataques (se llama ASLR):

#include <stdio.h>

int main(void)
{
    int precio = 250;
    printf("%p\n", (void *) &precio);
    return 0;
}
salida (cambia en cada ejecución)0x7ffcea58934c

El puntero nulo

Un puntero que no apunta a nada vale nulo. En C23 se escribe nullptr; en código anterior verás NULL. Muchas funciones lo devuelven para decir «no hay resultado», como strchr o fopen, así que hay que comprobarlo antes de usar *:

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

int main(void)
{
    const char *correo = "ana.lopez@ejemplo.com";
    const char *arroba = strchr(correo, '@');

    if (arroba != nullptr) {
        printf("Dominio: %s\n", arroba + 1);
    }

    const char *espacio = strchr(correo, ' ');
    if (espacio == nullptr) {
        printf("No tiene espacios\n");
    }
    return 0;
}
salidaDominio: ejemplo.com
No tiene espacios

El error que te ahorra: usar un puntero nulo

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

int main(void)
{
    const char *correo = "sin-arroba.com";
    const char *arroba = strchr(correo, '@');
    printf("Primera letra del dominio: %c\n", *(arroba + 1));
    return 0;
}
salida con error==106==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000001 (pc 0x00000040125b bp 0x7ffcbf5499c0 sp 0x7ffcbf5499a0 T0)
==106==The signal is caused by a READ memory access.
==106==Hint: address points to the zero page.
    #0 0x00000040125b in main /curso/nulo_mal.c:8
…
SUMMARY: AddressSanitizer: SEGV /curso/nulo_mal.c:8 in main

Sin sanitizadores, el programa muere con un escueto Segmentation fault. Con ellos sabes la línea, que fue una lectura y que la dirección está en la «página cero», la zona de memoria reservada para atrapar los punteros nulos (aquí, nulo más 1). Cada vez que una función pueda devolver nulo, compruébalo.

Punteros como parámetros

En la lección 3 vimos que una función recibe copias y no puede cambiar las variables de quien la llama. Con punteros sí: se le pasa la dirección y la función escribe ahí. Así se arregla el intercambiar que no funcionaba, y así devuelven las funciones de C más de un resultado:

#include <stdio.h>

void intercambiar(int *a, int *b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

bool dividir(int total, int partes, int *cociente, int *resto)
{
    if (partes == 0) return false;
    *cociente = total / partes;
    *resto = total % partes;
    return true;
}

int main(void)
{
    int x = 1, y = 2;
    intercambiar(&x, &y);
    printf("x=%d y=%d\n", x, y);

    int c, r;
    if (dividir(17, 5, &c, &r)) {
        printf("17 = 5 * %d + %d\n", c, r);
    }
    if (!dividir(17, 0, &c, &r)) {
        printf("No se puede dividir entre 0\n");
    }
    return 0;
}
salidax=2 y=1
17 = 5 * 3 + 2
No se puede dividir entre 0

Es el patrón más común de C: la función devuelve si tuvo éxito y escribe los resultados a través de punteros. strtol(linea, &fin, 10), de la lección 1, hace exactamente eso con fin.

Arrays y punteros

El nombre de un array, usado en una expresión, se convierte en un puntero a su primer elemento. Por eso int *p = datos; funciona, por eso las funciones reciben arrays como punteros, y por eso p[i] y *(p + i) son lo mismo:

Un array de cuatro int empieza en el byte 1000. p apunta a datos 0; p más 1 apunta a datos 1, en el byte 1004, porque cada int ocupa 4 bytes. Asterisco de p más i es lo mismo que p de i y que datos de iint datos[4]; int *p = datos; p + 1 avanza 4 bytesdatos[0]pbyte 1000datos[1]p + 1byte 1004datos[2]p + 2byte 1008datos[3]p + 3byte 1012*(p + i) es lo mismo que p[i] y que datos[i]: el compilador multiplica por el tamaño del tipo.

Sumar 1 a un puntero avanza un elemento, no un byte: el compilador multiplica por el tamaño del tipo. Esquema: decodigo.com.
#include <stdio.h>

int main(void)
{
    int datos[4] = {10, 20, 30, 40};
    int *p = datos;

    printf("%d %d %d\n", *p, *(p + 1), p[2]);
    printf("Entre &datos[3] y &datos[0] hay %td elementos\n", &datos[3] - &datos[0]);
    printf("y %td bytes\n", (char *) &datos[3] - (char *) &datos[0]);

    for (int *q = datos; q < datos + 4; q++) {
        printf("%d ", *q);
    }
    printf("\n");
    return 0;
}
salida10 20 30
Entre &datos[3] y &datos[0] hay 3 elementos
y 12 bytes
10 20 30 40 

Restar dos punteros del mismo array da cuántos elementos hay entre ellos (%td es el hueco para ese tipo, ptrdiff_t). Recorrer un array con un puntero que avanza, como el último bucle, es muy habitual en C y en las bibliotecas estándar. Solo es válido mientras el puntero esté dentro del array o justo una posición después del final: más allá es comportamiento indefinido, aunque no lo leas.

La diferencia que queda entre array y puntero se ve con sizeof: el de un array es su tamaño completo; el de un puntero, 8 bytes, apunte a lo que apunte. Es lo que pasaba en la lección 4 al usar sizeof dentro de una función.

const y los punteros

#include <stdio.h>
#include <ctype.h>

size_t contar_mayusculas(const char *s)
{
    size_t n = 0;
    for (; *s != '\0'; s++) {
        if (isupper((unsigned char) *s)) n++;
    }
    return n;
}

int main(void)
{
    printf("%zu\n", contar_mayusculas("Hola Mundo, Desde C"));
    return 0;
}
salida4

const char *s es un puntero a caracteres que la función promete no modificar: puede mover s (el s++), pero no escribir *s = 'x'. Al revés, char *const s sería un puntero fijo a datos modificables. La regla para leerlo: const afecta a lo que tiene a su izquierda, o a lo de la derecha si está al principio. Marca con const todo puntero que una función solo lea: documenta la intención y el compilador la vigila.

void * y los punteros a función: qsort

Un void * es un puntero «a cualquier cosa»: guarda una dirección sin decir de qué tipo. No se puede usar * con él directamente; hay que convertirlo antes al tipo real. Y un puntero a función guarda la dirección de una función, para llamarla más tarde. Los dos se juntan en qsort, la función de ordenación de la biblioteca estándar, que ordena cualquier array si le das una función que compare dos elementos:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int por_numero(const void *a, const void *b)
{
    int x = *(const int *) a;
    int y = *(const int *) b;
    return (x > y) - (x < y);
}

int por_texto(const void *a, const void *b)
{
    const char *x = *(const char *const *) a;
    const char *y = *(const char *const *) b;
    return strcmp(x, y);
}

int main(void)
{
    int edades[] = {34, 7, 81, 19, 52};
    qsort(edades, 5, sizeof edades[0], por_numero);
    for (int i = 0; i < 5; i++) printf("%d ", edades[i]);
    printf("\n");

    const char *frutas[] = {"pera", "manzana", "uva", "kiwi"};
    qsort(frutas, 4, sizeof frutas[0], por_texto);
    for (int i = 0; i < 4; i++) printf("%s ", frutas[i]);
    printf("\n");

    int (*comparar)(const void *, const void *) = por_numero;
    int a = 3, b = 9;
    printf("%d\n", comparar(&a, &b));
    return 0;
}
salida7 19 34 52 81 
kiwi manzana pera uva 
-1

La función de comparación devuelve un negativo, cero o un positivo. El (x > y) - (x < y) parece rebuscado, pero es la forma correcta: el atajo habitual, return x - y, se desborda con números grandes de signo contrario. La declaración de un puntero a función, int (*comparar)(const void *, const void *), se lee «comparar es un puntero a una función que recibe dos const void * y devuelve int».

Ojo con la seguridad: los punteros colgantes

Un puntero no sabe si lo que apunta sigue existiendo. El caso típico: una función devuelve la dirección de una de sus variables locales, que deja de existir al volver. El puntero queda colgante y usarlo es comportamiento indefinido, de esos que a veces funcionan. GCC lo detecta en los casos sencillos:

#include <stdio.h>

int *crear_contador(void)
{
    int contador = 0;
    return &contador;
}

int main(void)
{
    int *c = crear_contador();
    printf("%d\n", *c);
    return 0;
}
gcc: avisocolgante.c: In function 'crear_contador':
colgante.c:6:12: warning: function returns address of local variable [-Wreturn-local-addr]
    6 |     return &contador;
      |            ^~~~~~~~~

Para que algo sobreviva a la función que lo crea hace falta memoria dinámica, con malloc, que es justo la lección 6. Y la misma trampa, en versión dinámica (usar memoria ya liberada), es uno de los fallos más explotados de C.

Más en Los fallos de seguridad típicos de C.

Resumen

  • ParaEscribeRecuerda
  • Dirección de x&ximprímela con %p y un cast a void *
  • Lo apuntado por p*pcomprueba antes que no sea nulo
  • Puntero que no apunta a nadanullptrNULL en código anterior a C23
  • Devolver varios resultadosparámetros int *saliday devolver si hubo éxito
  • Recorrer un arrayp[i] o *(p + i)sin pasar del final
  • Prometer no modificarconst char *sen todo lo que solo se lee
  • Ordenar cualquier cosaqsort(a, n, sizeof a[0], cmp)cmp sin restar

Retos

Reto 1. Escribe void min_max(const int *a, size_t n, int *min, int *max), que deje en *min y *max el menor y el mayor del array.

Ver la solución
#include <stdio.h>

void min_max(const int *a, size_t n, int *min, int *max)
{
    *min = *max = a[0];
    for (size_t i = 1; i < n; i++) {
        if (a[i] < *min) *min = a[i];
        if (a[i] > *max) *max = a[i];
    }
}

int main(void)
{
    int temperaturas[] = {18, 25, 9, 31, 22};
    int menor, mayor;
    min_max(temperaturas, 5, &menor, &mayor);
    printf("Mínima %d, máxima %d\n", menor, mayor);
    return 0;
}
salidaMínima 9, máxima 31

Una sola pasada por el array y dos resultados por punteros. Una versión más robusta comprobaría también que n no sea 0.

Reto 2. Escribe tu propia versión de strlen usando solo punteros: sin índices.

Ver la solución
#include <stdio.h>

size_t mi_strlen(const char *s)
{
    const char *p = s;
    while (*p != '\0') {
        p++;
    }
    return (size_t) (p - s);
}

int main(void)
{
    printf("%zu %zu %zu\n", mi_strlen("decodigo"), mi_strlen(""), mi_strlen("C23"));
    return 0;
}
salida8 0 3

Se avanza hasta el nulo y se resta el principio. Así es, a grandes rasgos, la strlen de los libros; la de glibc lee de 8 en 8 bytes para ir más rápido.

Reto 3. ¿Qué escribe? int a[] = {1, 2, 3}; int *p = a; *p++ = 10; printf("%d %d %d %d\n", a[0], a[1], *p, p[1]);

Ver la solución
#include <stdio.h>

int main(void)
{
    int a[] = {1, 2, 3};
    int *p = a;
    *p++ = 10;
    printf("%d %d %d %d\n", a[0], a[1], *p, p[1]);
    return 0;
}
salida10 2 2 3

*p++ escribe en lo apuntado y después avanza el puntero: el ++ se aplica a p, no al valor. Así que a[0] pasa a 10, y p queda apuntando a a[1], por lo que *p es 2 y p[1] es 3.

Todo el código de esta lección se compiló con GCC 15.3 (C23, -Wall -Wextra) y se ejecutó en un contenedor limpio antes de publicarla; los avisos del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con gcc --version: hace falta GCC 15 o Clang 19 para todo lo de C23.