Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Punteros

Control directo de memoria en sistemas integrados

Universidad Nacional de Río Negro

Introducción

Para profundizar en cómo funcionan estructuras como los arreglos y para ganar un control más directo y eficiente sobre los recursos de tu programa, es fundamental entender los punteros. Los punteros son la herramienta que nos permite pasar de trabajar solo con los valores de las variables a trabajar con sus ubicaciones en la memoria.

Desarrollo

Introducción a los Punteros y la Memoria

¿Qué es una Dirección de Memoria y un Puntero?

Cada vez que declarás una variable, el entorno de ejecución y el sistema operativo le asignan un bloque de espacio en la memoria del programa. En los sistemas operativos modernos con soporte de hardware para paginación (a través de la MMU o Memory Management Unit), los programas de usuario no acceden directamente a las direcciones de la memoria RAM física. En su lugar, trabajan dentro de un espacio de direcciones virtuales (memoria lógica). El sistema operativo se encarga de mapear de forma transparente estas direcciones lógicas a direcciones físicas reales de la memoria RAM o incluso a disco (memoria de intercambio o swap). Este mecanismo de abstracción proporciona seguridad y aislamiento entre los diferentes procesos que se ejecutan simultáneamente en la máquina.

Desde la perspectiva del programador en C, esta memoria lógica se organiza como una secuencia de celdas contiguas de 1 byte (u octeto), donde cada celda posee una dirección única representada comúnmente en formato hexadecimal (por ejemplo, 0x7ffee390a1bc).

Un puntero es simplemente otra variable cuyo contenido es, precisamente, una de estas direcciones de memoria lógica.

Para visualizarlo, consideremos la siguiente organización en memoria de un entero numero (de 4 bytes) almacenado en la dirección 0x7ffd y un puntero ptr almacenado en la dirección 0x8004 que apunta a él:

Table 1:Representación de variables en celdas de memoria contiguas

Dirección de MemoriaNombre VariableTipoContenido (Valor)
0x7ffdnumeroint42
0x7ffe(contiguo)-(parte de numero)
0x7fff(contiguo)-(parte de numero)
0x8000(contiguo)-(parte de numero)
0x8004ptrint*0x7ffd

Como se observa en la tabla, el valor almacenado en ptr (0x7ffd) coincide exactamente con la dirección donde inicia la variable numero. Al desreferenciar ptr (usando *ptr), accedemos al valor 42.

Concepto básico de un puntero. La variable ptr almacena la dirección física de
numero en la RAM.

Figure 1:Concepto básico de un puntero. La variable ptr almacena la dirección física de numero en la RAM.

Declaración de punteros

Para declarar un puntero, debés especificar el tipo de dato al que va a apuntar, seguido de un asterisco (*) y el nombre de la variable. La regla de estilo Regla 0x0006h: El asterisco de los punteros debe declararse junto al identificador indica que el asterisco debe ir junto al nombre de la variable.

1
2
3
int *ptr_entero;
double *ptr_double;
char *ptr_char;

Una vez declarado, un puntero debe ser inicializado para que apunte a una dirección de memoria específica y válida. No hacerlo es una fuente común de errores graves. Tenés principalmente dos formas de inicializar un puntero:

Asignación a una dirección específica

Para que un puntero sea útil, generalmente lo hacés apuntar a una variable existente. Esto se logra utilizando el operador de dirección & (ampersand), el cual obtiene la dirección de memoria de dicha variable.

1
2
int numero = 42;
int *ptr_numero = № // ptr_numero ahora almacena la dirección de 'numero'

Inicializar a Nulo (NULL)

Si al momento de declarar un puntero no tenés una dirección de memoria válida para asignarle, es fundamental inicializarlo a un estado seguro y conocido. Para esto se utiliza la macro NULL.

NULL es una constante de preprocesador, que se encuentra definida en el encabezado <stddef.h> y representa la dirección a «ningún lado».

1
2
3
#include <stddef.h> // Necesario para NULL

int *puntero_seguro = NULL;

Es una práctica habitual en C que las funciones que devuelven punteros retornen NULL para indicar un error o la ausencia de un resultado. Siempre debés comprobar si un puntero es NULL antes de intentar desreferenciarlo (usar el operador * sobre él).

Representación de un puntero nulo y la verificación antes de desreferenciar.

Figure 2:Representación de un puntero nulo y la verificación antes de desreferenciar.

Regla de oro: Siempre inicializá tus punteros, ya sea con la dirección de una variable válida o con NULL.

Variable de Referencia (o Puntero)

(Para comprender el marco pedagógico de los roles de variables, podés consultar la introducción en la sección Roles de las Variables del capítulo el capítulo del lenguaje C).

Un puntero es una variable cuyo valor es una dirección de memoria física. En términos didácticos, asume el rol de variable de referencia: su propósito no es almacenar datos para operaciones directas, sino “apuntar” a otra variable, facilitando un acceso y una manipulación indirecta sobre la misma.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

int main() {
    int numero = 100;
    int *ptrNumero; // 'ptrNumero' es un puntero, su rol es referenciar a
    'numero'

    ptrNumero = &numero; // 'ptrNumero' ahora apunta a la dirección de 'numero'

    printf("Valor de numero: %d\n", numero);
    printf("Direccion de numero: %p\n", (void*)&numero);
    printf("Valor al que apunta ptrNumero: %d\n", *ptrNumero); // Desreferencia
    el puntero
    printf("Valor de ptrNumero (direccion): %p\n", (void*)ptrNumero);

    // Modificando 'numero' a traves del puntero
    *ptrNumero = 200;
    printf("Nuevo valor de numero (modificado via puntero): %d\n", numero);

    return 0;
}

El rol de ptrNumero es el de una variable de referencia, permitiendo interactuar con numero de forma indirecta.

Operadores de Punteros

El trabajo con punteros se basa principalmente en dos operadores fundamentales:

Funcionamiento de los operadores & (dirección de) y * (desreferencia).

Figure 3:Funcionamiento de los operadores & (dirección de) y * (desreferencia).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

int main() {
    int numero = 99;
    int *puntero = &numero; // '&numero' obtiene la dirección de memoria de la
    variable

    // Leemos el valor apuntado (lectura)
    // La expresión *puntero accede al valor contenido en 'numero'
    printf("El valor de 'numero' es: %d\n", *puntero); // Imprime 99

    // Modificamos el valor apuntado (escritura)
    // La expresión *puntero modifica el contenido en 'numero'
    *puntero = 150;
    printf("El nuevo valor de 'numero' es: %d\n", numero); // Imprime 150

    return 0;
}

Esta dualidad del operador de desreferencia es lo que hace a los punteros tan poderosos, ya que nos permiten tanto leer como modificar datos de forma indirecta.

Punteros y arreglos

El nombre de un arreglo no es un puntero, sino el identificador de un bloque de memoria contiguo. Sin embargo, como se analizó en el capítulo de Secuencias (ver decaimiento de arreglos), al evaluarse en la mayoría de las expresiones de C este decae (se degrada) automáticamente a un puntero al primer elemento de la secuencia (&arreglo[0]).

Esta relación nos permite usar punteros para acceder y manipular los elementos de la secuencia de forma indirecta, lo cual nos lleva directamente a la aritmética de punteros.


Aritmética de punteros

La aritmética de punteros permite realizar operaciones matemáticas sobre las direcciones de memoria. A diferencia de las operaciones aritméticas tradicionales, el compilador ajusta automáticamente los cálculos según el tamaño físico del tipo de dato al que se apunta.

Si tenés un puntero ptr a un tipo de dato T que ocupa sizeof(T) bytes, al hacer ptr + 1, la dirección de memoria física no se incrementa en 1, sino en sizeof(T). Este mecanismo es el fundamento del acceso indexado y el cálculo de desplazamientos bidimensionales en memoria contigua que estudiamos en Cálculo de Desplazamiento de Memoria. Esto permite “saltar” de un elemento a otro en un arreglo de forma eficiente.

Aritmética de punteros: el incremento de un puntero depende del tamaño en bytes
del tipo de dato apuntado.

Figure 4:Aritmética de punteros: el incremento de un puntero depende del tamaño en bytes del tipo de dato apuntado.

Incremento (++) y decremento (--)

Podés incrementar un puntero para que apunte al siguiente elemento de un arreglo o decrementarlo para que apunte al anterior.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *ptr = arr; // ptr apunta a arr[0]

    ptr++; // Ahora ptr apunta a arr[1]
    printf("El segundo elemento es: %d\n", *ptr); // Imprime 20

    ptr++; // Ahora ptr apunta a arr[2]
    printf("El tercer elemento es: %d\n", *ptr); // Imprime 30

    ptr--; // Vuelve a apuntar a arr[1]
    printf("El segundo elemento de nuevo: %d\n", *ptr); // Imprime 20

    return 0;
}

Suma (+) y resta (-)

Podés sumar o restar un valor entero a un puntero para desplazarte varias posiciones dentro de un arreglo.

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *ptr = arr; // ptr apunta a arr[0]

    // Acceder al cuarto elemento (índice 3)
    int *ptr_cuarto = ptr + 3;
    printf("El cuarto elemento es: %d\n", *ptr_cuarto); // Imprime 40
    printf("También se puede acceder así: %d\n", *(ptr + 3)); // Imprime 40

    return 0;
}

Un detalle sobre la resta en punteros

Podés restar dos punteros que apunten a elementos del mismo arreglo. El resultado no es una dirección de memoria, sino la cantidad de elementos que hay entre ellos.

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stddef.h> // Necesario para ptrdiff_t

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *ptr1 = &arr[1];
    int *ptr2 = &arr[4];

    ptrdiff_t diferencia = ptr2 - ptr1;
    printf("Hay %td elementos entre ptr1 y ptr2.\n", diferencia); // Imprime 3

    return 0;
}

Punteros en funciones y efectos secundarios

Esta simulación de pasaje por referencia también se aplica a los arreglos (ver El Mecanismo de Paso a Funciones: Decaimiento de Arreglos). Al pasar la dirección de memoria de una variable por valor, aunque la dirección en sí se copia en el registro de activación (stack frame), la desreferencia de este puntero permite interactuar directamente con la celda de memoria original del invocador (efecto secundario).

Justificación de Diseño: Eficiencia y Rendimiento en Sistemas

La simulación de referencia mediante indirección no es únicamente una herramienta para permitir la modificación de variables (efectos secundarios). En el desarrollo de software de sistemas, es un mecanismo indispensable por razones de rendimiento.

Cuando pasamos un dato por valor, todo su contenido debe copiarse en el marco de pila de la función invocada. Si el argumento es un tipo de dato básico (como un int de 4 bytes o un char de 1 byte), el costo de la copia es insignificante. Sin embargo, en C trabajamos frecuentemente con estructuras de datos complejas (struct) que pueden agrupar arreglos y múltiples miembros, ocupando cientos o miles de bytes. Copiar estructuras de gran tamaño de manera repetida consume tiempo de procesamiento de la CPU (operaciones de copia en memoria) y agota rápidamente el espacio limitado del stack del programa (pudiendo provocar un desbordamiento de pila o stack overflow).

Al pasar un puntero a dicha estructura:

  1. Consumo de memoria mínimo: Se copia únicamente la dirección de memoria, cuyo tamaño es fijo y pequeño (4 bytes en arquitecturas de 32 bits, 8 bytes en arquitecturas de 64 bits).

  2. Tiempo de ejecución constante: La transmisión de una dirección de memoria es una operación de bajo costo a nivel de registros de la CPU, independiente del tamaño real del objeto apuntado.

Para garantizar que esta optimización no vulnere la seguridad de los datos (es decir, evitar que la función modifique accidentalmente la estructura que solo queríamos leer), debemos calificar el parámetro con const. Esto crea un contrato inmutable: el compilador rechazará cualquier intento de escritura sobre la estructura, logrando la máxima eficiencia de rendimiento con la seguridad de la inmutabilidad del paso por valor clásico.

Diferencia entre el paso por valor y la simulación de referencia mediante
punteros.

Figure 5:Diferencia entre el paso por valor y la simulación de referencia mediante punteros.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>

// La función recibe dos punteros a enteros
void intercambiar(int *a, int *b) {
    int temporal = *a; // Guardamos el valor al que apunta 'a'
    *a = *b;           // Asignamos al valor de 'a' el valor de 'b'
    *b = temporal;     // Asignamos al valor de 'b' el valor guardado
}

int main() {
    int x = 10;
    int y = 20;

    printf("Valores originales: x = %d, y = %d\n", x, y);

    // Pasamos las direcciones de memoria de 'x' e 'y'
    intercambiar(&x, &y);

    printf("Valores intercambiados: x = %d, y = %d\n", x, y);

    return 0;
}

Parámetro de Salida (a través de Punteros)

(Este rol extiende el pasaje de datos entre ámbitos; para más contexto conceptual, podés revisar la sección Roles de las Variables de el capítulo del lenguaje C).

Dado que en C todo pasaje de argumentos es estrictamente por valor (la función trabaja sobre copias en su propio stack frame), para permitir que una función altere variables del llamador y “retorne” resultados de manera indirecta, debemos emplear punteros en el rol de parámetros de salida.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdio.h>

// 'resultado' es un parámetro de salida (puntero)
void dividir(int dividendo, int divisor, int *cociente, int *resto) {
    if (divisor != 0) {
        *cociente = dividendo / divisor; // Modifica el valor apuntado por
        'cociente'
        *resto = dividendo % divisor;   // Modifica el valor apuntado por
        'resto'
    } else {
        printf("Error: Division por cero.\n");
        // Podríamos asignar valores especiales o manejar el error de otra forma
        *cociente = 0;
        *resto = 0;
    }
}

int main() {
    int num1 = 17;
    int num2 = 5;
    int miCociente;
    int miResto;

    // Pasamos las direcciones de 'miCociente' y 'miResto'
    dividir(num1, num2, &miCociente, &miResto);

    printf("%d dividido por %d es: Cociente = %d, Resto = %d\n", num1, num2,
    miCociente, miResto);
    return 0;
}

Aquí, cociente y resto son parámetros de salida. La función dividir escribe directamente en las ubicaciones de memoria de miCociente y miResto en la función main a través de sus punteros, permitiendo que main acceda a los resultados de la división.

Parámetro de Entrada/Salida (a través de Punteros)

(Esta especialización semántica complementa los roles analizados en la sección Roles de las Variables de el capítulo del lenguaje C).

Similar al parámetro de salida, un parámetro de entrada/salida utiliza un puntero para permitir que la función lea un valor inicial proporcionado por el invocador (entrada) y, tras procesarlo, modifique ese mismo espacio de memoria física (salida), reintegrando el valor alterado al ámbito original.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>

// 'contador' es un parámetro de entrada/salida
void incrementarContador(int *contador) {
    printf("Valor del contador al entrar a la funcion: %d\n", *contador);
    (*contador)++; // Incrementa el valor apuntado por 'contador'
    printf("Valor del contador al salir de la funcion: %d\n", *contador);
}

int main() {
    int miVariableContador = 10;

    printf("Valor inicial de miVariableContador: %d\n", miVariableContador);

    // Pasamos la dirección de 'miVariableContador'
    incrementarContador(&miVariableContador);

    printf("Valor final de miVariableContador: %d\n", miVariableContador);
    return 0;
}

La variable contador de incrementarContador tiene el rol de parámetro de entrada/salida. La función lee el valor inicial de miVariableContador (10), lo incrementa a 11, y ese cambio se refleja directamente en miVariableContador en main.

El impacto en los efectos secundarios

Esta capacidad para modificar variables externas al ámbito de la función constituye un efecto secundario (side effect) análogo al que estudiamos con la modificación de arreglos en el capítulo de Secuencias.

Si bien los efectos secundarios son indispensables para la eficiencia y para permitir la salida de múltiples resultados, incrementan la complejidad del flujo lógico del programa. El programador debe rastrear minuciosamente qué variables del invocador pueden cambiar tras la ejecución de la función, razón por la cual es obligatorio documentar sus poscondiciones y restringir las mutaciones no deseadas mediante const (ver regla de estilo Regla 0x3007h: Los argumentos de tipo puntero deben ser const siempre que la función no los modifique).

El Calificador const: el ancla de seguridad con punteros

El calificador const es una de las herramientas más importantes en C para escribir código seguro, predecible y fácil de entender. Actúa como un “contrato” que le dice al compilador y a otros programadores qué se supone que no debe cambiar. Cuando lo usás con punteros, como lo exige la regla Regla 0x3007h: Los argumentos de tipo puntero deben ser const siempre que la función no los modifique, te permite “bloquear” o bien el dato apuntado, el puntero en sí, o ambos.

const nos permite poner reglas sobre qué se puede modificar, potencialmente, limitando los efectos secundarios productos de pasar el puntero a la función.

Modificador const aplicado a punteros: diferencia entre puntero constante y
datos apuntados constantes.

Figure 6:Modificador const aplicado a punteros: diferencia entre puntero constante y datos apuntados constantes.

1. Puntero a un Dato Constante (No podés cambiar el VALOR)

Esta es la forma más común. La nota adhesiva es normal (podés borrar el número y escribir otro), pero el casillero al que apunta está cerrado con llave. No podés cambiar su contenido a través de este puntero.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>

void imprimir(const char *mensaje) {
    // mensaje[0] = 'X'; // ERROR DE COMPILACIÓN: intentás modificar un dato
    constante.
    printf("El mensaje es: %s\n", mensaje);
}

int main() {
    char saludo[] = "Hola";
    char despedida[] = "Chau";

    const char *ptr = saludo;

    // *ptr = 'h'; // ERROR DE COMPILACIÓN: no se puede modificar el contenido.

    ptr = despedida; // VÁLIDO: el puntero puede apuntar a otra dirección.

    imprimir(ptr); // Imprime "Chau"
    return 0;
}

¿Cuándo usarlo?: Siempre que pases un puntero a una función que solo necesita leer los datos, pero no modificarlos. Esto previene efectos secundarios accidentales.

2. Puntero Constante a un Dato (No podés cambiar la dirección)

En este caso, la nota adhesiva está escrita con tinta imborrable: siempre apuntará al mismo casillero. Sin embargo, el casillero en sí no tiene llave, por lo que podés cambiar su contenido libremente.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

int main() {
    int valor_a = 10;
    int valor_b = 20;

    // El puntero debe inicializarse en la declaración, ya que no se puede
    cambiar después.
    int * const ptr = &valor_a;

    *ptr = 50; // VÁLIDO: podés modificar el valor en la dirección apuntada.
               // Ahora, 'valor_a' es 50.

    // ptr = &valor_b; // ERROR DE COMPILACIÓN: no se puede reasignar un puntero
    constante.

    printf("El valor de A es: %d\n", valor_a); // Imprime 50
    return 0;
}

Cuándo usarlo: Cuando necesitás que un puntero se refiera siempre a la misma ubicación de memoria, como un búfer fijo o una dirección de hardware específica.

3. Puntero Constante a un Dato Constante (No podés cambiar NADA)

Esta es la forma más restrictiva. La nota está escrita con tinta imborrable y el casillero está cerrado con llave. No podés cambiar ni a dónde apunta el puntero, ni el contenido del lugar al que apunta.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int main() {
    int valor_fijo = 100;
    int otro_valor = 200;

    const int * const ptr = &valor_fijo;

    // *ptr = 150;     // ERROR DE COMPILACIÓN: el valor es constante.
    // ptr = &otro_valor; // ERROR DE COMPILACIÓN: el puntero es constante.

    printf("El valor fijo es: %d\n", *ptr); // Imprime 100
    return 0;
}

Cuándo usarlo: Para definir una referencia totalmente inmutable a un dato, como un puntero a una tabla de configuración o a una constante almacenada en memoria de solo lectura.

Diferencia estricta: const int *p vs int *const p

Para evitar confusiones al leer declaraciones con el calificador const, se puede aplicar una regla de lectura de derecha a izquierda:

La diferencia en las restricciones del compilador se resume a continuación:

Table 2:Comparación de restricciones con const

Declaración¿Se puede reasignar el puntero (p = &y)?¿Se puede modificar el valor apuntado (*p = 10)?
const int *pNo
int *const pNo

Analizá el siguiente comportamiento con este fragmento de código:

1
2
3
4
5
6
7
8
9
10
11
12
13
int x = 5;
int y = 10;

// Puntero a constante (el valor apuntado no se puede modificar)
const int *p_a_const = &x;
p_a_const = &y;       // VÁLIDO: se cambia la dirección almacenada en el
puntero.
// *p_a_const = 20;   // ERROR DE COMPILACIÓN: el contenido es de solo lectura.

// Puntero constante (la dirección almacenada no se puede modificar)
int *const p_const = &x;
*p_const = 20;        // VÁLIDO: se modifica el entero al que apunta.
// p_const = &y;      // ERROR DE COMPILACIÓN: el puntero es de solo lectura.

Documentando funciones con punteros

Cuando una función utiliza punteros como parámetros, especialmente para modificar datos fuera de su propio ámbito (efectos secundarios), una documentación clara y precisa es fundamental. La documentación actúa como un contrato entre la función y quien la llama (el “cliente”). Este contrato establece las responsabilidades de cada parte para garantizar que la función opere de manera segura y predecible.

Usaremos la función intercambiar como ejemplo para ilustrar cómo documentar este contrato, definiendo el flujo de los datos, las precondiciones, las poscondiciones y los invariantes.

Dirección del Flujo de Información

Al trabajar con punteros, no solo es importante el tipo de dato, sino también la “dirección” en la que fluye la información. Se usa una convención simple para indicarlo:


Contratos II: Precondiciones, Poscondiciones e Invariantes con punteros

Recordemos que es cada uno de ellos

Su uso como metodología de documentación es una simplificación de su verdadero poder, pero nos ayuda a pensar que en términos de lo que entra, lo que sale y lo que no cambia.

Ejemplo de Documentación Completa

Aplicando estos conceptos, una documentación exhaustiva para la función intercambiar se vería así, siguiendo el estilo creado por la cátedra.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * Intercambia los valores de dos variables enteras a través de sus punteros.
 * @param[in, out] primero Puntero al primer valor. Su contenido será leído y
   luego
 * sobrescrito con el del segundo.
 * @param[in, out] segundo Puntero al segundo valor. Su contenido será leído y
   luego
 * sobrescrito con el contenido del primero.
 * @pre Ambos punteros no deben ser NULL y apuntar a direcciones de memoria
   válidas y modificables.
 * @post El valor almacenado en la dirección apuntada por 'primero' será el
   valor original
 * que se encontraba en la dirección de 'segundo' y viceversa. No se
   introducirán otros valores por fuera
 * de los que estén referenciados.
 */
void intercambiar(int *primero, int *segundo) {
    int temporal = *primero;
    *primero = *segundo;
    *segundo = temporal;
}

De esta forma, eliminamos las ambigüedades, y reducimos los potenciales errores.


La “degradación” de arreglos a punteros

Uno de los comportamientos más importantes —y a menudo confusos— en C es que los arreglos se “degradan” (decay) a punteros en la mayoría de los contextos. Este no es un truco, sino una regla de conversión fundamental del lenguaje que explica la íntima relación entre ambos conceptos.

¿Qué significa realmente la “degradación”?

La regla es simple: cuando usás el nombre de un arreglo en una expresión (por ejemplo, al asignarlo a un puntero o pasarlo a una función), el compilador no trabaja con el arreglo completo. En su lugar, lo convierte automáticamente en un puntero al primer elemento de ese arreglo.

Por lo tanto, las siguientes dos líneas de código son funcionalmente idénticas:

1
2
3
4
5
6
7
8
9
10
11
int numeros[5] = {10, 20, 30, 40, 50};

// La "degradación" ocurre aquí: 'numeros' se convierte en la dirección de
numeros[0]
int *p = numeros;

// Esta es la forma explícita y equivalente
int *p_explicito = &numeros[0];

printf("La dirección almacenada en p es: %p\n", (void*)p);
printf("La dirección del primer elemento es: %p\n", (void*)&numeros[0]);

Consecuencias Prácticas (y Cruciales) de la Degradación

Entender esta conversión es vital porque tiene implicaciones directas en cómo escribís tu código, especialmente con funciones y el operador sizeof.

Cuando pasás un arreglo a una función, lo que la función recibe en realidad es una copia del puntero a su primer elemento. La función nunca recibe la copia del arreglo.

Por eso, estas tres declaraciones de función son absolutamente equivalentes para el compilador:

1
2
3
4
5
void procesar_datos(int arr[10]); // El 10 es ignorado por el compilador
void procesar_datos(int arr[]);   // Notación más común para indicar que se
espera un arreglo
void procesar_datos(int *arr);    // La forma más honesta: la función recibe un
puntero

Debido a esto, la función pierde la información sobre el tamaño original del arreglo y el tamaño que obtendremos es únicamente el de la dirección de memoria.

sizeof(arreglo decaido)=sizeof(puntero)\text{sizeof}(\text{arreglo decaido}) = \text{sizeof}(\text{puntero})
1
2
3
4
5
6
7
8
#include <stdio.h>

// La función recibe un puntero, sin importar cómo se declare el parámetro.
void imprimir_tamano(int arr[]) {
    // ¡Peligro! Esto NO mide el tamaño del arreglo original.
    // Mide el tamaño de un puntero en tu sistema (usualmente 4 u 8 bytes).
    printf("Tamaño DENTRO de la función: %zu bytes\n", sizeof(arr));
}

sizeof funciona como es esperado solo cuando su uso se hace en el mismo alcance de la declaración del arreglo.

Por lo que hacer sizeof(arreglo) va a devolver el tamaño total en bytes del arreglo, de forma que sea (número de elementos * tamaño del tipo del arreglo).

sizeof(arreglo)=elementos×sizeof(T)\text{sizeof}(\text{arreglo}) = \text{elementos} \times \text{sizeof}(T)
1
2
3
4
5
6
7
8
9
10
11
int main() {
    int mi_arreglo[10] = {0};

    // Aquí 'sizeof' conoce el tamaño real del arreglo.
    printf("Tamaño FUERA de la función: %zu bytes\n", sizeof(mi_arreglo)); //
    Imprimirá 40 (10 * 4 bytes)

    imprimir_tamano(mi_arreglo); // Imprimirá 4 u 8

    return 0;
}

Punteros Dobles: La Indirección a un Nuevo Nivel

Un puntero doble es, literalmente, un puntero que apunta a otro puntero. Introduce un nivel adicional de indirección, lo que significa que necesitás seguir dos direcciones para llegar al dato final.

Si un puntero (int *p) es una nota con la dirección de un cofre que contiene un tesoro (un int), un puntero doble (int **pp) es una nota con la dirección de otra nota, que a su vez tiene la dirección del cofre del tesoro.

1
2
3
int valor = 100;
int *p = &valor;    // p apunta a 'valor'
int **pp = &p;      // pp apunta a 'p'

Podemos acceder a valor, desreferenciando dos veces el puntero pp;

1
printf("%d\n", **pp);

Esta capacidad de manipular un puntero a través de otro puntero es extremadamente poderosa y se usa principalmente en dos escenarios cruciales.

Uno de ellos lo veremos aquí, el segundo, lo haremos cuando veamos memoria dinámica.

Simulación de Referencia mediante Indirección para Punteros

Recordá que C siempre pasa los argumentos a las funciones por valor. Esto significa que la función recibe una copia del argumento. Si pasás un puntero int *p, la función recibe una copia de la dirección que p contiene. Podés usar esa copia para modificar el dato original (*p = 99), pero no podés cambiar a dónde apunta el puntero original.

Para poder modificar el puntero original desde dentro de una función, necesitás pasar la dirección de ese puntero, es decir, un puntero doble.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdio.h>

// Función para intercambiar el valor de dos punteros.
// Se utilizan punteros dobles (**), ya que necesitamos modificar
// las direcciones de memoria a las que apuntan los punteros originales.
void intercambiar_punteros(int **puntero1, int **puntero2) {
    int *temp = *puntero1;
    *puntero1 = *puntero2;
    *puntero2 = temp;
}

int main() {
    int a = 10;
    int b = 20;
    int *ptr_a = &a;
    int *ptr_b = &b;

    printf("Antes del intercambio:\n");
    printf("ptr_a apunta a %d (direccion: %p)\n", *ptr_a, (void*)ptr_a);
    printf("ptr_b apunta a %d (direccion: %p)\n", *ptr_b, (void*)ptr_b);

    // Llamamos a la función pasando las direcciones de los punteros
    intercambiar_punteros(&ptr_a, &ptr_b);

    printf("\nDespues del intercambio:\n");
    printf("ptr_a apunta a %d (direccion: %p)\n", *ptr_a, (void*)ptr_a);
    printf("ptr_b apunta a %d (direccion: %p)\n", *ptr_b, (void*)ptr_b);

    return 0;
}

Al desreferenciar puntero1 y puntero2, accedemos directamente a los punteros originales (ptr_a y ptr_b en main) y podemos modificar las direcciones de memoria que almacenan.

Manipulando arreglos con aritmética de punteros

La relación entre arreglos y punteros en C es tan estrecha que se pueden usar de forma intercambiable en muchos contextos.

1. Recorrido solo lectura

La tarea más básica es iterar sobre todos los elementos para leerlos o imprimirlos. La estrategia consiste en tener un puntero que avanza y un puntero “límite” que nos indica cuándo detenernos.

Método:

  1. Declará un puntero ptr que apunte al inicio del arreglo.

  2. Declará otro puntero fin que apunte a la dirección de memoria inmediatamente posterior al último elemento. Esto substituye el ‘conteo’ de posiciónes.

  3. El lazo while se ejecuta mientras ptr sea menor que fin.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stddef.h> // Para size_t

void imprimir_arreglo(const int *arr, size_t tamano) {
    const int *ptr = arr;
    const int *fin = arr + tamano; // Puntero al final del arreglo + 1

    printf("Contenido del arreglo: ");
    while (ptr < fin) {
        printf("%d ", *ptr); // 1. Leer el valor actual
        ptr++;               // 2. Mover el puntero al siguiente elemento
    }
    printf("\n");
}

int main() {
    int numeros[] = {10, 20, 30, 40, 50};
    imprimir_arreglo(numeros, 5);
    return 0;
}

2. Búsqueda de un elemento

Para buscar un valor, recorremos el arreglo y nos detenemos si encontramos una coincidencia. La función devolverá un puntero al elemento encontrado o NULL si no se encuentra. La comprobación explícita contra NULL sigue la regla Regla 0x2007h: Mantené el alcance de las variables al mínimo posible.

Método:

  1. Declará los punteros ptr y fin como en el ejemplo anterior.

  2. El lazo while se ejecuta mientras no lleguemos al final y no hayamos encontrado el valor.

  3. Después del lazo, si ptr != fin, significa que el lazo se detuvo porque encontramos el elemento. Si son iguales, es porque recorrimos todo sin éxito.

  4. Si el lazo termina sin encontrar el valor, devolver NULL.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>
#include <stddef.h> // Para NULL y size_t

// Devuelve un puntero al primer elemento que coincida con 'valor', o NULL si no
se encuentra.
const int* buscar_valor(const int *arr, size_t tamano, int valor) {
    const int *ptr = arr;
    const int *fin = arr + tamano;
    const int *resultado = NULL; // Inicializamos con NULL

    // El lazo continúa mientras no hayamos llegado al final
    // Y no hayamos encontrado el valor.
    while (ptr < fin && resultado == NULL) {
        if (*ptr == valor) {
            resultado = ptr; // Asignamos la dirección si se encuentra
        }
        ptr++;
    }

    return resultado; // Devolvemos el resultado final
}

int main() {
    int numeros[] = {10, 20, 30, 40, 50};
    int valor_a_buscar = 30;

    const int *encontrado = buscar_valor(numeros, 5, valor_a_buscar);

    if (encontrado != NULL) {
        printf("Valor %d encontrado en la dirección de memoria %p\n",
        *encontrado, (void*)encontrado);
    } else {
        printf("Valor %d no encontrado en el arreglo.\n", valor_a_buscar);
    }

    return 0;
}

3. Modificando el arreglo

Para modificar los datos, usamos el operador de desreferencia (*) en el lado izquierdo de una asignación. Esto modifica el valor en la memoria a la que apunta el puntero.

Método:

  1. El recorrido es idéntico al de la lectura.

  2. Dentro del lazo, en lugar de leer, realizamos una asignación: *ptr = nuevo_valor.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdio.h>
#include <stddef.h> // Para size_t

// Duplica el valor de cada elemento en el arreglo.
void duplicar_valores(int *arr, size_t tamano) {
    int *ptr = arr;
    int *fin = arr + tamano;

    while (ptr < fin) {
        *ptr = *ptr * 2; // Modifica el valor en la memoria apuntada
        ptr++;
    }
}

int main() {
    int numeros[] = {1, 2, 3, 4, 5};

    printf("Arreglo original: 1 2 3 4 5\n");
    // (Código para imprimirlo, podemos usar el de la primera sección)

    duplicar_valores(numeros, 5);

    printf("Arreglo modificado: %d %d %d %d %d\n", numeros[0], numeros[1],
    numeros[2], numeros[3], numeros[4]);
    // Salida esperada: 2 4 6 8 10

    return 0;
}

4. Copiando un Arreglo

Para copiar un arreglo, necesitamos dos punteros: uno para la fuente (de donde leemos) y otro para el destino (donde escribimos). Ambos deben avanzar en cada paso.

Método:

  1. Creá un puntero fuente para el arreglo original y un puntero destino para el nuevo.

  2. El lazo se ejecuta mientras el puntero fuente no llegue a su final.

  3. Dentro del lazo, copiá el valor y luego incrementá ambos punteros.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <stdio.h>
#include <stddef.h> // Para size_t

void copiar_arreglo(int *destino, const int *fuente, size_t tamano) {
    const int *ptr_fuente = fuente;
    int *ptr_destino = destino;
    const int *fin_fuente = fuente + tamano;

    // Lazo principal de copia
    while (ptr_fuente < fin_fuente) {
        *ptr_destino = *ptr_fuente;
        ptr_fuente++;
        ptr_destino++;
    }

    // Una forma más compacta pero potencialmente
    // menos legible de escribir lo de arriba:
    // while (ptr_fuente < fin_fuente) {
    //     *ptr_destino++ = *ptr_fuente++;
    // }
}

int main() {
    int arreglo_a[] = {100, 200, 300};
    int arreglo_b[3]; // Arreglo vacío para recibir la copia

    copiar_arreglo(arreglo_b, arreglo_a, 3);

    printf("Contenido del arreglo copiado: %d %d %d\n", arreglo_b[0],
    arreglo_b[1], arreglo_b[2]);
    // Salida esperada: 100 200 300

    return 0;
}

5. Versión alternativa

Si la función que necesitamos crear, necesita de la posición en la que nos encontramos, no vamos a escapar de ‘contar’ posiciones.

En ese caso, lo que podemos hacer, es ir sumando al puntero del arreglo la i-esima posición, en lugar de ir incrementando el puntero mismo.

1
2
3
for (size_t i = 0; i < 5; i++) {
    printf("%d ", *(p + i));
}

De esta forma, podemos obtener un código que es más similar al uso tradicional de arreglos

1
2
3
4
5
6
7
void imprimir_arreglo(const int *ptr, size_t tamano) {
    printf("Contenido del arreglo: ");
    for (size_t i = 0; i < tamano; i++) {
        printf("%zu:%d ", i, *(ptr + i));
    }
    printf("\n");
}

Prestá atención a los paréntesis en *(p + i), ya que son cruciales para el orden de las operaciones. Su presencia asegura que primero realicemos la aritmética de punteros (calculando la nueva dirección p + i) y después desreferenciemos esa dirección para obtener el valor que contiene.

Si omitieras los paréntesis y escribieras *p + i, el resultado sería completamente diferente. Debido a la precedencia de operadores, primero se desreferenciaría *p (obteniendo el valor en la dirección actual) y luego se le sumaría i a ese valor, lo cual es una operación matemática, no de punteros.

En resumen:


Próximos Pasos: Memoria Dinámica

Los punteros que estudiaste en este capítulo son fundamentales, pero hasta ahora solo trabajaste con memoria que el compilador gestiona automáticamente (variables locales y globales). El verdadero poder de los punteros se revela cuando aprendés a gestionar memoria dinámicamente durante la ejecución del programa.

En el Memoria Dinámica, vas a aprender sobre:

Estos conceptos amplían dramáticamente lo que podés hacer en C, permitiéndote crear programas que adaptan su uso de memoria a las necesidades del momento. Sin embargo, con este poder viene una gran responsabilidad: la gestión manual de memoria requiere disciplina y atención a los detalles.

Cuando te sientas cómodo con los conceptos de este capítulo, estás listo para dar el próximo paso hacia la memoria dinámica.

Conexión con el Siguiente Tema

Los punteros que estudiamos operan sobre memoria estática (conocida en compilación) o automática (stack, gestionada por el sistema). Pero la verdadera potencia de los punteros emerge cuando los combinamos con memoria dinámica: la capacidad de solicitar y liberar memoria durante la ejecución según las necesidades del programa.

El apunte Memoria Dinámica introduce la gestión explícita de memoria mediante:

La memoria dinámica permite construir estructuras que crecen y encogen según necesidad: una lista que se expande al agregar elementos, un grafo que se construye progresivamente. Sin embargo, introduce responsabilidad total sobre el ciclo de vida de la memoria: cada malloc() debe tener su free() correspondiente.

Los punteros son las herramientas; la memoria dinámica es el material sobre el que trabajan. Juntos, permiten implementar cualquier estructura de datos imaginable, desde simples listas hasta bases de datos completas.

Pregunta puente: Si declaramos int arr[1000000] en una función, el programa probablemente falle con stack overflow. ¿Por qué? ¿Cómo solicitamos memoria para estructuras arbitrariamente grandes? La respuesta está en la memoria dinámica y el heap.

Textos Fundamentales

Punteros y Arquitectura

Gestión de Memoria y Errores

Recursos en Línea

Visualización y Debugging

Artículos Clásicos

Ejercicios y Práctica

Ejercicios de Autoevaluación

Introducción y Operadores

Solution to Exercise 1
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main() {
    int var = 77;
    int *ptr = &var; // ptr almacena la dirección de var

    *ptr = 88; // Desreferencia y asigna un nuevo valor en esa celda

    printf("El valor de var es: %d\n", var); // Imprime 88
    return 0;
}
Solution to Exercise 2

La constante NULL representa la dirección de memoria 0x0. Los sistemas operativos modernos protegen esta página de direcciones virtuales reservándola como de acceso no permitido. Si un proceso intenta desreferenciar un puntero nulo, la MMU de la CPU detecta la infracción y aborta inmediatamente el proceso enviándole una señal de fallo de segmentación (Segmentation Fault). Para evitarlo, se debe realizar una validación explícita previa:

1
2
3
4
5
6
7
8
9
10
#include <stddef.h>
#include <stdio.h>

void modificar_seguro(int *p) {
    if (p != NULL) {
        *p = 10;
    } else {
        fprintf(stderr, "Error: Intento de desreferenciar un puntero NULL.\n");
    }
}
Solution to Exercise 3

Un puntero salvaje es un puntero declarado que no ha sido inicializado a una dirección válida ni a NULL. Al ser una variable automática del stack, contendrá un valor residual aleatorio (basura) que se interprete como una dirección de memoria arbitraria. Intentar desreferenciarlo para leer o escribir puede corromper datos de otras variables en el stack o hacer que el programa falle de forma impredecible. La regla de la cátedra Regla 0x0003h: Siempre debés inicializar las variables a un valor conocido exige la inicialización obligatoria de todas las variables en su punto de declaración (asignando una dirección válida o NULL), erradicando la existencia de punteros salvajes.

Aritmética de Punteros

Solution to Exercise 4

La aritmética de punteros escala los desplazamientos según el tamaño físico del tipo de dato al que apunta:

Direccioˊn Resultante=Direccioˊn Base+(Desplazamiento×sizeof(tipo))\text{Dirección Resultante} = \text{Dirección Base} + (\text{Desplazamiento} \times \text{sizeof(tipo)})

Sustituyendo los valores:

  • Dirección Base = 0x3000

  • Desplazamiento = 3

  • sizeof(double)=8\text{sizeof(double)} = 8 bytes

    Desplazamiento en bytes=3×8=24 bytes\text{Desplazamiento en bytes} = 3 \times 8 = 24\text{ bytes}

    Convertimos 24 a hexadecimal: 2410=181624_{10} = 18_{16} (0x18). Por lo tanto:

    Direccioˊn Resultante=0x3000+0x0018=0x3018\text{Dirección Resultante} = \text{0x3000} + \text{0x0018} = \text{0x3018}
Solution to Exercise 5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <stddef.h> // Necesario para ptrdiff_t

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    int *inicio = &arr[0];
    int *fin = &arr[4];

    // La resta de punteros devuelve la distancia en elementos de tipo int
    ptrdiff_t distancia = fin - inicio;

    printf("Distancia entre elementos: %td\n", distancia); // Imprime 4
    return 0;
}
Solution to Exercise 6
  1. *ptr++: Debido a que el operador de post-incremento ++ tiene mayor precedencia que el operador de desreferencia *, el incremento se aplica al puntero (la dirección). La expresión evalúa al valor almacenado originalmente en la dirección apuntada por ptr y, como efecto secundario, desplaza la dirección de ptr al siguiente casillero de memoria.

  2. (*ptr)++: El paréntesis altera el orden de precedencia forzando a que se evalúe primero la desreferencia *ptr. Por lo tanto, la expresión accede al valor numérico contenido en la variable a la que apunta ptr e incrementa ese valor en 1 en la memoria física, dejando la dirección de ptr inalterada.

Funciones, const y Contratos

Solution to Exercise 7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
 * @brief Procesa valores numéricos mediante punteros.
 * @param[in] x Valor entero constante a procesar.
 * @param[in, out] entrada_salida Puntero a la variable que acumulará el valor de x.
 * @param[out] salida Puntero donde se escribirá el doble del parámetro x.
 * @pre entrada_salida y salida no deben ser NULL y deben apuntar a memoria válida.
 * @post La variable apuntada por entrada_salida se incrementa en x. La variable apuntada por salida almacena x * 2.
 */
void procesar_datos(int x, int *entrada_salida, int *salida) {
    if (entrada_salida != NULL && salida != NULL) {
        *entrada_salida += x;
        *salida = x * 2;
    }
}
Solution to Exercise 8

Las firmas correspondientes son:

  1. void imprimir_mensaje(const char *mensaje); (Puntero a datos constantes)

  2. void configurar_puerto(int * const registro); (Puntero constante a datos variables)

  3. void comparar_tablas(const int * const tablaA, const int * const tablaB); (Puntero constante a datos constantes)

Solution to Exercise 9

El error de compilación ocurre en la línea *ptr = 30;. La declaración const int *ptr define a ptr como un “puntero a entero constante”. Esto significa que el compilador bloquea cualquier intento de escribir o modificar el valor apuntado a través de dicho puntero. La reasignación de la dirección del puntero (ptr = &y;) es totalmente válida porque el puntero en sí no es constante.

Degradación y Indirección Avanzada

Solution to Exercise 10

Esto se debe al fenómeno de degradación de arreglos (array decay):

  • En el ámbito donde se declara el arreglo (main), el compilador conoce el tipo de dato y su tamaño estático completo (10×sizeof(int)=4010 \times \text{sizeof(int)} = 40 bytes).

  • Al pasar el arreglo como parámetro a una función, este decae automáticamente a un puntero al primer elemento (int*). Por lo tanto, el parámetro formal int arr[] de la función es interpretado por el compilador exactamente como int *arr. Al evaluar sizeof(arr) dentro de la función, se está calculando el tamaño en bytes del tipo puntero, el cual es de 8 bytes en arquitecturas de 64 bits, perdiendo la dimensión del arreglo.

Solution to Exercise 11
1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main() {
    int numero = 500;
    int *ptr = &numero;
    int **ptr_ptr = &ptr; // Puntero doble apuntando al puntero simple

    **ptr_ptr = 999; // Doble desreferencia para llegar a la celda de 'numero'

    printf("El valor modificado es: %d\n", numero); // Imprime 999
    return 0;
}
Solution to Exercise 12
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stddef.h>

void invertir_arreglo(int *arr, size_t tamano) {
    if (arr == NULL || tamano <= 1) {
        return;
    }

    int *izq = arr;
    int *der = arr + tamano - 1; // Dirección del último elemento

    while (izq < der) {
        // Intercambio de valores desreferenciados
        int temporal = *izq;
        *izq = *der;
        *der = temporal;

        // Desplazamiento de los punteros hacia el centro
        izq++;
        der--;
    }
}

Glosario

Puntero
Variable que almacena la dirección de memoria de otra variable.
Desreferenciación
Operación (*) que permite acceder o modificar el valor almacenado en la dirección apuntada.
Degradación de arreglos (array decay)
Pérdida del tamaño de un arreglo al ser pasado a una función, convirtiéndose en un puntero simple a su primer elemento.
Efecto secundario (side effect)
Modificación de variables o estado fuera del ámbito local de una función.

Síntesis y Resumen

Este apunte desmitifica los punteros, el concepto más distintivo y poderoso de C, revelando su naturaleza como simples variables que almacenan direcciones de memoria.

Referencias y Lecturas Complementarias

References
  1. Kernighan, B. W., & Ritchie, D. M. (2014). C Programming Language, 2nd Edition.
  2. King, K. N. (2008). C Programming: A Modern Approach (2nd ed.). W. W. Norton & Company.
  3. Reek, K. A. (1997). Pointers on C. Addison-Wesley.
  4. Bryant, R. E., & O’Hallaron, D. R. (2015). Computer Systems: A Programmer’s Perspective (3rd ed.). Pearson.
  5. Patterson, D. A., & Hennessy, J. L. (2017). Computer Organization and Design: The Hardware/Software Interface (5th ed.). Morgan Kaufmann.
  6. Seacord, R. C. (2013). Secure Coding in C and C++ (2nd ed.). Addison-Wesley.
  7. van der Linden, P. (1994). Expert C Programming: Deep C Secrets. Prentice Hall.
  8. Ritchie, D. M. (1993). The Development of the C Language. ACM SIGPLAN Notices, 28(3), 201–208. 10.1145/155360.155580
  9. Gustedt, J. (2019). Modern C. Manning Publications. https://modernc.gforge.inria.fr/
  10. Sommers, J. (2025). jsommers/cbook. https://github.com/jsommers/cbook (Original work published 2017)