← Programación en C para Sistemas Operativos Bloque 4 · Punteros · Tema 15 de 42

Tema 15

Qué es un puntero

Llegamos al corazón del curso. Desde el primer tema usamos punteros sin nombrarlos: el &edad que mostraba una dirección, el &n de scanf, el NULL que devolvía getenv, el *a = *b de intercambiar. Un puntero es una idea sencilla, una variable que guarda una dirección de memoria, pero es la que da a C su poder sobre la máquina y la mayoría de sus errores. En este tema, que abre el bloque 4, construimos la idea desde cero: qué es una dirección, cómo se declara un puntero, qué hacen los operadores & y *, por qué un puntero tiene tipo, qué es el puntero nulo y por qué leerlo termina el programa, y a qué lugares de la memoria del proceso puede apuntar un puntero.

1. Las direcciones de memoria

La memoria de un proceso, como vimos en el tema 5, es una larga fila de bytes, cada uno con un número que lo identifica: su dirección. Toda variable ocupa uno o más bytes consecutivos de esa fila, y su dirección es la de su primer byte. El operador &, que usamos desde el tema 1, la obtiene, y %p la muestra en hexadecimal.

Esas direcciones no son posiciones en los chips de memoria de la computadora. Son direcciones virtuales: cada proceso tiene su propio espacio de direcciones, y el núcleo, con ayuda del procesador, traduce cada una a la memoria física real. Por eso dos programas pueden tener una variable en la misma dirección sin pisarse, y por eso una dirección solo tiene sentido dentro del proceso que la obtuvo. Esa traducción es tema del curso de memoria; aquí nos alcanza con saber que, para el programa, la memoria es una fila de bytes numerados, y que no todos los números corresponden a memoria que el proceso pueda usar.

2. Qué es un puntero

Un puntero es una variable cuyo valor es una dirección de memoria. Se dice que el puntero apunta al dato que está en esa dirección. Como toda variable, tiene un tipo, un nombre, su propia dirección y un tamaño; su tipo indica qué clase de dato hay en la dirección que guarda.

No hay nada mágico en un puntero: es un número, como un long, que se usa de una forma especial. Este primer programa lo muestra con todas sus piezas:

primero.c
#include <stdio.h>

int main(void)
{
    int edad = 30;
    int *p = &edad;              /* p guarda la dirección de edad */

    printf("edad = %d, y vive en %p\n", edad, (void *) &edad);
    printf("p    = %p, y vive en %p\n", (void *) p, (void *) &p);
    printf("*p   = %d\n", *p);

    *p = 31;                     /* escribir en edad a través de p */
    printf("después de *p = 31: edad = %d\n", edad);

    edad = 40;                   /* cambiar edad directamente */
    printf("después de edad = 40: *p = %d\n", *p);

    printf("sizeof edad = %zu, sizeof p = %zu\n", sizeof edad, sizeof p);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o primero primero.c && ./primero
edad = 30, y vive en 0x7ffea36ea9dc
p    = 0x7ffea36ea9dc, y vive en 0x7ffea36ea9e0
*p   = 30
después de *p = 31: edad = 31
después de edad = 40: *p = 40
sizeof edad = 4, sizeof p = 8
Un puntero y su destino: la variable p, en la dirección 0x7ffea36ea9e0, contiene el valor 0x7ffea36ea9dc, que es la dirección de la variable edad, que contiene 30. p apunta a edad, y *p es edad p en 0x7ffea36ea9e0 · 8 bytes 0x7ffea36ea9dc edad en 0x7ffea36ea9dc · 4 bytes 30 apunta a El valor de p es la dirección de edad. p es la dirección; *p es lo que hay en ella: la propia variable edad.
Dos variables, cada una con su dirección. La flecha no existe en la memoria: es solo que el número guardado en p coincide con la dirección de edad.

La salida muestra cada idea. El valor de p es exactamente la dirección de edad. p es otra variable, con su propia dirección, 4 bytes más arriba en la pila. *p es edad: leerlo da 30, escribir en él cambia edad, y cambiar edad se ve a través de *p. Y p ocupa 8 bytes, aunque apunte a un dato de 4: en Linux de 64 bits, toda dirección ocupa 8 bytes, como vimos con sizeof(void *) en el tema 5.

3. Declarar un puntero

Un puntero se declara con un asterisco entre el tipo y el nombre: int *p se lee «p es un puntero a int». Hay una forma de leer las declaraciones que ayuda mucho: int *p dice que *p es un int. El asterisco pertenece al nombre, no al tipo, y eso tiene una consecuencia que anticipamos en el tema 6:

terminal · declarar.c: int* p, q; y luego q = &x;
$ gcc -std=c17 -Wall -Wextra -o declarar declarar.c
declarar.c: In function ‘main’:
declarar.c:9:7: error: assignment to ‘int’ from ‘int *’ makes integer from pointer without a cast [-Wint-conversion]
    9 |     q = &x;
      |       ^

En int* p, q;, solo p es un puntero: q es un int común, como dice el mensaje. El espacio pegado a int engaña al lector, no al compilador. Desde gcc 14, asignar una dirección a un entero sin conversión es un error; antes era solo una advertencia, y el programa compilaba. Por eso la convención del curso, y la del núcleo Linux, es pegar el asterisco al nombre, int *p, y declarar un puntero por línea.

Un puntero puede apuntar a cualquier tipo: char *, double *, struct stat *, e incluso a otro puntero, int **, que veremos en el tema 19. Todos ocupan lo mismo, 8 bytes, porque todos guardan una dirección; lo que cambia es qué hay en ella.

4. Los operadores & y *

Dos operadores unarios hacen todo el trabajo con punteros, y son inversos entre sí:

OperadorNombreSe aplica aProduceEjemplo
&xDirección deUna variable, o cualquier cosa con direcciónUn puntero a x&edad es un int *
*pIndirección, o desreferenciaUn punteroEl dato al que apunta, que se puede leer o escribir*p es un int

Así, *&edad es edad, y si p apunta a algo, &*p es p. El asterisco tiene tres significados según dónde aparezca, y conviene no confundirlos: en una declaración, int *p, indica que p es un puntero; en una expresión con un operando, *p, es la indirección; y con dos operandos, a * b, es la multiplicación.

Desreferenciar un puntero es «ir a la dirección». *p no es una copia de edad: es edad misma, en el sentido de que es un nombre para esos mismos 4 bytes. Por eso *p = 31 modifica edad. Y el operador & no se puede aplicar a cualquier cosa: necesita algo que tenga dirección, una variable o un elemento de un arreglo. &42 o &(a + b) no tienen sentido, porque un valor calculado no vive en ningún lugar de la memoria.

5. Por qué un puntero tiene tipo

Si un puntero es solo una dirección, ¿para qué el tipo? Porque una dirección dice dónde empieza un dato, pero no cuántos bytes ocupa ni cómo interpretarlos, que es justamente lo que dice un tipo, como vimos en el tema 5. Este programa apunta a la misma dirección con tres punteros de tipos distintos:

tipos.c
#include <stdio.h>
#include <stdint.h>

int main(void)
{
    uint32_t numero = 0x12345678;
    uint32_t *entero = &numero;
    unsigned char *byte = (unsigned char *) &numero;
    uint16_t *mitad = (uint16_t *) &numero;

    printf("las tres direcciones: %p %p %p\n", (void *) entero, (void *) byte, (void *) mitad);
    printf("*entero = 0x%08x  (lee %zu bytes)\n", *entero, sizeof *entero);
    printf("*mitad  = 0x%04x      (lee %zu bytes)\n", *mitad, sizeof *mitad);
    printf("*byte   = 0x%02x        (lee %zu byte)\n", *byte, sizeof *byte);

    *byte = 0xff;                /* modificar solo el primer byte */
    printf("después de *byte = 0xff: numero = 0x%08x\n", numero);
    return 0;
}
terminal
$ ./tipos
las tres direcciones: 0x7ffdc9d90bfc 0x7ffdc9d90bfc 0x7ffdc9d90bfc
*entero = 0x12345678  (lee 4 bytes)
*mitad  = 0x5678      (lee 2 bytes)
*byte   = 0x78        (lee 1 byte)
después de *byte = 0xff: numero = 0x123456ff
Los cuatro bytes de numero en memoria, 78 56 34 12 por ser little-endian. Un puntero a unsigned char lee solo el primero, 0x78; un puntero a uint16_t lee los dos primeros, 0x5678; un puntero a uint32_t lee los cuatro, 0x12345678 +0 +1 +2 +3 78 56 34 12 unsigned char * → 0x78 uint16_t * → 0x5678 uint32_t * → 0x12345678
La misma dirección, tres tipos de puntero: cada uno lee la cantidad de bytes de su tipo y los interpreta a su manera.

Las tres direcciones son idénticas, y sin embargo cada desreferencia lee una cantidad distinta de bytes: 4, 2 o 1. Por el orden little-endian del tema 5, el primer byte es el menos significativo, 0x78, y por eso escribir 0xff a través de byte cambió solo los dos últimos dígitos del número. Recorrer los bytes de cualquier dato con un unsigned char * es una técnica fundamental de la programación de sistemas, y el estándar la permite expresamente para cualquier tipo.

Para hacerlo, hubo que convertir la dirección con un cast. Sin él, el compilador rechaza mezclar tipos de punteros, porque casi siempre es un error:

terminal · sincast.c: asignar &n, un uint32_t *, a un unsigned char * y a un double *
$ gcc -std=c17 -Wall -Wextra -c sincast.c
sincast.c:5:24: error: initialization of ‘unsigned char *’ from incompatible pointer type ‘uint32_t *’ {aka ‘unsigned int *’} [-Wincompatible-pointer-types]
…
sincast.c:6:17: error: initialization of ‘double *’ from incompatible pointer type ‘uint32_t *’ {aka ‘unsigned int *’} [-Wincompatible-pointer-types]

El cast le dice al compilador que la mezcla es intencional, pero no la vuelve correcta en todos los casos. Leer un dato con un puntero de otro tipo, salvo unsigned char *, viola una regla del estándar llamada strict aliasing, y el optimizador puede producir resultados inesperados. La lectura a través de mitad en tipos.c es justamente un caso así: en un ejemplo tan simple funciona, pero no es código para imitar. Para reinterpretar los bytes de un tipo como otro, como en mismosbytes.c del tema 5, la forma correcta es copiarlos con memcpy.

6. Copiar la dirección o copiar el valor

Con dos punteros, hay dos asignaciones posibles que se parecen mucho y hacen cosas muy distintas:

alias.c
#include <stdio.h>

int main(void)
{
    int a = 10, b = 20;
    int *p = &a;
    int *q = &b;

    printf("al principio:   a = %d, b = %d, *p = %d, *q = %d\n", a, b, *p, *q);

    *p = *q;                     /* copia el valor: a pasa a valer 20 */
    printf("tras *p = *q:   a = %d, b = %d, *p = %d, *q = %d\n", a, b, *p, *q);

    a = 10;
    p = q;                       /* copia la dirección: p apunta ahora a b */
    printf("tras p = q:     a = %d, b = %d, *p = %d, *q = %d\n", a, b, *p, *q);

    *p = 99;                     /* p y q son alias de b */
    printf("tras *p = 99:   a = %d, b = %d, *p = %d, *q = %d\n", a, b, *p, *q);
    printf("p == q es %d, p == &a es %d\n", p == q, p == &a);
    return 0;
}
terminal
$ ./alias
al principio:   a = 10, b = 20, *p = 10, *q = 20
tras *p = *q:   a = 20, b = 20, *p = 20, *q = 20
tras p = q:     a = 10, b = 20, *p = 20, *q = 20
tras *p = 99:   a = 10, b = 99, *p = 99, *q = 99
p == q es 1, p == &a es 0

Después de p = q, los dos punteros son alias de b: dos nombres distintos para la misma memoria. Escribir a través de uno se ve a través del otro, como muestra la última línea. Los alias son útiles, porque permiten que varias partes del programa trabajen sobre los mismos datos, y peligrosos, porque un cambio hecho por una parte sorprende a otra que no lo esperaba. Comparar dos punteros con == compara las direcciones: p == q es verdadero cuando apuntan al mismo lugar, no cuando los datos son iguales.

7. El puntero nulo

A veces un puntero no debe apuntar a nada: una búsqueda que no encontró lo buscado, una variable de entorno que no existe, un archivo que no se pudo abrir. Para eso existe el puntero nulo, un valor especial que se garantiza distinto de la dirección de cualquier dato válido. En el código se escribe NULL, una macro de stddef.h que también definen stdio.h, stdlib.h y string.h:

terminal · nd.c: el valor y la definición de NULL
$ ./nd                                 # printf("%p %d %zu", (void *) p, p == 0, sizeof NULL) con p = NULL
(nil) 1 8
$ gcc -E -dD nd.c | grep -m1 'define NULL'
#define NULL ((void *)0)

En C, NULL es el número 0 convertido a puntero, con el tamaño de un puntero, 8 bytes, y glibc lo muestra con %p como (nil). Comparar un puntero con 0 equivale a compararlo con NULL, y por eso un puntero se puede usar como condición: if (p) es verdadero si p no es nulo. En el curso preferimos la forma explícita, if (p != NULL), que dice mejor qué se comprueba. C23 agregó nullptr, una constante que solo puede ser un puntero, pero NULL sigue siendo lo habitual.

Muchas funciones de la biblioteca devuelven NULL para indicar que no hay resultado, la convención de puntero del tema 11. Este programa consulta dos variables de entorno con getenv, una que existe y otra que no:

nulo.c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const char *usuario = getenv("USER");
    const char *inexistente = getenv("NO_EXISTE_ESTA_VARIABLE");

    printf("usuario     = %p -> \"%s\"\n", (void *) usuario, usuario != NULL ? usuario : "(nada)");
    printf("inexistente = %p\n", (void *) inexistente);

    if (inexistente == NULL)
        printf("getenv devolvió NULL: la variable no existe\n");

    printf("leyendo a través del puntero nulo...\n");
    fflush(stdout);
    printf("primer carácter: %c\n", inexistente[0]);
    return 0;
}
terminal · la ejecución, y la misma en gdb
$ ./nulo
usuario     = 0x7ffd502cec9e -> "ana"
inexistente = (nil)
getenv devolvió NULL: la variable no existe
leyendo a través del puntero nulo...
Segmentation fault
$ gcc -std=c17 -g -o nulo nulo.c && gdb -q -batch -ex run -ex 'p inexistente' ./nulo
Program received signal SIGSEGV, Segmentation fault.
0x000055555555525d in main () at nulo.c:17
17	    printf("primer carácter: %c\n", inexistente[0]);
$1 = 0x0

El valor de USER está en una dirección de la pila, porque las variables de entorno se copian ahí al arrancar el proceso. La variable inexistente da NULL, y el programa lo detecta. Pero la última línea lee inexistente[0], el primer carácter de la cadena a la que apunta, sin haberlo comprobado, y el programa muere con SIGSEGV. gdb señala la línea exacta y muestra que el puntero vale 0x0. Es el error más común en programas de C: usar un puntero que puede ser nulo sin comprobarlo. La regla es que todo puntero que una función puede devolver como NULL se comprueba antes de usarlo, y el patrón del tema 7, p != NULL && p[0] == 'x', lo hace en una sola condición gracias al cortocircuito.

8. Por qué NULL termina el programa

Desreferenciar un puntero nulo es, para el estándar de C, comportamiento indefinido. Que en Linux termine el programa de inmediato no es casualidad, sino una decisión del sistema operativo:

terminal · la dirección más baja que un proceso puede usar
$ cat /proc/sys/vm/mmap_min_addr
65536
$ gcc -std=c17 -g -fsanitize=address -o nulo nulo.c && ./nulo 2>&1 | grep -E 'ERROR|Hint|#0'
==554==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000 (pc 0x5abafd06b375 …)
==554==Hint: address points to the zero page.
    #0 0x5abafd06b375 in main /home/ana/c/tema15/nulo.c:17

Linux nunca asigna memoria a un proceso en los primeros 64 KiB de su espacio de direcciones: la «página cero» y las que la siguen no existen. Cuando el programa lee la dirección 0, o cualquier dirección cercana, el procesador no encuentra esa página, avisa al núcleo, y el núcleo envía SIGSEGV, la violación de segmento. Es una protección deliberada: un puntero nulo, o un acceso con un desplazamiento pequeño desde él, como el campo de una estructura apuntada por NULL, termina el programa en el lugar exacto del error, en lugar de leer o escribir algo silenciosamente. AddressSanitizer lo explica con su pista: address points to the zero page.

Ese fallo inmediato es lo mejor que puede pasar con un puntero inválido. Los punteros de la sección siguiente pueden ser mucho peores.

9. Punteros sin inicializar

Un puntero local sin inicializar contiene, como cualquier variable automática, lo que haya quedado en la pila: una dirección cualquiera. Este programa deja a propósito un valor reconocible en la pila antes de declarar el puntero:

salvaje.c
#include <stdio.h>

static void dejar_basura(void)
{
    volatile long basura = 0x4141414141414141;   /* queda en la pila */
    (void) basura;
}

static void usar_puntero(void)
{
    int *p;                       /* sin inicializar */

    printf("p vale %p\n", (void *) p);
    fflush(stdout);
    *p = 5;                       /* escribe en una dirección cualquiera */
    printf("escribí en *p\n");
}

int main(void)
{
    dejar_basura();
    usar_puntero();
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o salvaje salvaje.c
salvaje.c: In function ‘usar_puntero’:
salvaje.c:13:5: warning: ‘p’ is used uninitialized [-Wuninitialized]
…
$ ./salvaje
p vale 0x4141414141414141
Segmentation fault

El puntero heredó el valor que dejó dejar_basura, el mismo mecanismo de basura.c del tema 6, y *p = 5 intentó escribir en esa dirección. Esta vez hubo suerte: 0x4141414141414141 no es una dirección válida, y el programa murió en el acto. Pero si la basura hubiera sido una dirección válida, por ejemplo la de una variable de otra función que quedó en la pila, la escritura habría modificado esa variable en silencio, y el error se manifestaría mucho después, en otro lugar del programa. A un puntero así se lo llama salvaje (wild pointer), y es primo del puntero colgante del tema 12, que apunta a algo que ya no existe.

De ahí sale una regla simple: todo puntero se inicializa al declararlo, con una dirección válida o con NULL. Un puntero nulo, si se usa por error, falla de inmediato y en el lugar correcto; uno salvaje puede fallar en cualquier momento o no fallar nunca.

10. El puntero genérico: void *

Un puntero a void es un puntero sin tipo: guarda una dirección pero no dice qué hay en ella. Por eso no se puede desreferenciar:

terminal · vd.c: return *v; con void *v
$ gcc -std=c17 -Wall -Wextra -c vd.c
vd.c:5:12: warning: dereferencing ‘void *’ pointer
…
vd.c:5:12: error: void value not ignored as it ought to be

Su utilidad está en otra parte: en C, cualquier puntero a datos se convierte a void * y de vuelta sin necesidad de cast. Por eso es el tipo de las funciones que trabajan con memoria sin importar qué contiene: memcpy(void *destino, const void *origen, size_t n), malloc, que devuelve un void *, o write y read, que reciben un void * con los bytes. Este programa escribe su propia función de ese estilo:

generico.c
#include <stdio.h>

/* muestra los bytes de cualquier dato, sin importar su tipo */
static void volcar(const char *nombre, const void *datos, size_t n)
{
    const unsigned char *b = datos;          /* void * se convierte solo */

    printf("%-8s", nombre);
    for (size_t i = 0; i < n; i++)
        printf(" %02x", b[i]);
    printf("\n");
}

int main(void)
{
    int entero = 1000;
    double real = 1.5;
    char texto[] = "C";

    volcar("int", &entero, sizeof entero);
    volcar("double", &real, sizeof real);
    volcar("char[]", texto, sizeof texto);
    return 0;
}
terminal
$ ./generico
int      e8 03 00 00
double   00 00 00 00 00 00 f8 3f
char[]   43 00

volcar acepta la dirección de cualquier dato, sin casts en las llamadas, y adentro la convierte a const unsigned char * para recorrer los bytes. 1000 es 0x3e8, guardado como e8 03 00 00 en orden little-endian; 1,5 en formato IEEE 754; y la cadena "C" es la letra 0x43 seguida del \0 final. El acceso b[i], con corchetes sobre un puntero, es la relación entre punteros y arreglos del tema 17. Y ahora se entiende por qué todos los ejemplos del curso escribían (void *) al usar %p: printf espera exactamente un void * con ese formato, y como recibe argumentos variables, no hace la conversión sola.

11. A dónde puede apuntar un puntero

Un puntero puede apuntar a cualquier lugar del espacio de direcciones del proceso. Este programa toma direcciones de distintos tipos de datos y busca en /proc/self/maps, el mapa de memoria del tema 6, en qué región cae cada una:

terminal · regiones.c, que ubica cada dirección en el mapa del proceso
$ ./regiones
variable local   0x7fff04814df4  rw-p  [stack]
variable global  0x574bd472d010  rw-p  regiones
static local     0x574bd472d014  rw-p  regiones
cadena literal   0x574bd472b040  r--p  regiones
memoria malloc   0x574bdbd1a010  rw-p  [heap]
función main    0x574bd472a495  r-xp  regiones
función printf  0x73542f864080  r-xp  libc.so.6
RegiónPermisosContieneVive
[stack]rw-Variables locales y parámetros.Lo que dura la llamada (tema 12).
Datos del ejecutablerw-Variables globales y static: .data y .bss.Todo el programa (tema 6).
Datos de solo lecturar--Cadenas literales y constantes: .rodata.Todo el programa; escribirlas termina el programa.
[heap]rw-La memoria pedida con malloc.Hasta que se libera con free (bloque 7).
Códigor-xLas funciones del programa y de las bibliotecas.Todo el programa (tema 20).

Para el lenguaje, todos son punteros iguales; para el sistema, cada región tiene sus permisos, y una escritura en una región sin w termina el programa, como vimos con la constante global del tema 6. La cadena literal está en una región r--p: por eso modificar una cadena literal, como char *s = "hola"; s[0] = 'H';, era uno de los comportamientos indefinidos de la tabla del tema 1, y en Linux produce Segmentation fault. Los punteros a funciones, que apuntan a código, son el tema 20. (Las dos últimas líneas aparecen corridas un carácter porque %-16s cuenta bytes, y la ó de «función» ocupa dos, como vimos en el tema 3.)

12. Punteros en gdb

gdb es la mejor herramienta para ver punteros por dentro. Detenido en la línea 12 de primero.c, antes de *p = 31:

terminal · gdb sobre primero, detenido en la línea 12
$ gdb -q -batch -ex 'break 12' -ex run -ex 'p edad' -ex 'p &edad' -ex 'p p' -ex 'p *p' -ex 'x/4xb p' -ex 'p sizeof p' ./primero
Breakpoint 1, main () at primero.c:12
12	    *p = 31;                     /* escribir en edad a través de p */
$1 = 30
$2 = (int *) 0x7fffffffd7ac
$3 = (int *) 0x7fffffffd7ac
$4 = 30
0x7fffffffd7ac:	0x1e	0x00	0x00	0x00
$5 = 8

print, abreviado p, entiende las expresiones de C: p &edad y p p muestran la misma dirección, con su tipo (int *), y p *p desreferencia. La orden x (examine) muestra la memoria cruda a partir de una dirección: x/4xb p pide 4 unidades, en hexadecimal (x), de un byte cada una (b), y muestra 0x1e 0x00 0x00 0x00, que es 30 en little-endian. Es la versión interactiva de volcar, y la usaremos mucho en los temas siguientes.

13. Para qué sirven los punteros

Con lo visto hasta aquí, un puntero parece una forma complicada de acceder a una variable que ya tenía nombre. Su valor aparece cuando el nombre no alcanza, y los próximos temas recorren cada caso:

Para…Porque…Tema
Que una función modifique variables del llamadorLos argumentos se copian; una dirección permite llegar al original.16
Recorrer arreglos y cadenasUn arreglo se maneja a través de la dirección de su primer elemento.17, 18, 21
Pasar datos grandes sin copiarlosCopiar 8 bytes de dirección es más barato que copiar una estructura entera.24
Usar memoria pedida al ejecutarmalloc devuelve una dirección: esa memoria no tiene nombre.28
Construir estructuras enlazadasCada nodo de una lista guarda la dirección del siguiente.30
Elegir el código a ejecutarUn puntero puede apuntar a una función.20
Hablar con el sistemaLas llamadas reciben y devuelven datos por dirección: read, stat, getenv.Todo el curso

La última fila es la razón de que este curso dedique un bloque entero a los punteros. La interfaz entre un programa y el sistema operativo está hecha de direcciones: el núcleo lee y escribe en la memoria del proceso a través de los punteros que le pasan las llamadas al sistema.

14. Errores frecuentes

ErrorQué pasaCorrección
int* p, q;Solo p es un puntero.int *p; y int *q;, en líneas separadas.
Usar un puntero sin inicializarEscribe en una dirección cualquiera: falla o corrompe datos en silencio.Inicializar siempre, con una dirección o con NULL.
No comprobar NULLSegmentation fault al desreferenciar.if (p != NULL) antes de usarlo.
Confundir p = q con *p = *qSe cambia la dirección en lugar del dato, o al revés.Leer la asignación: con * se copia el dato.
Comparar datos con p == qCompara direcciones, no contenidos.*p == *q; para cadenas, strcmp.
Mezclar tipos de punteros sin castError: incompatible pointer type.Revisar el diseño; para bytes, unsigned char *.
Leer un dato con un puntero de otro tipoViola el strict aliasing: resultados raros al optimizar.memcpy, o unsigned char *.
Desreferenciar un void *Error: no tiene tipo.Convertirlo al tipo correcto antes.
%p sin (void *)Formato y argumento no coinciden, en rigor.printf("%p", (void *) p).
Modificar una cadena literalSegmentation fault: está en memoria de solo lectura.Copiarla a un arreglo (tema 21).

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué es un puntero, y qué tiene en común con cualquier otra variable?

Es una variable cuyo valor es una dirección de memoria. Como cualquier variable, tiene nombre, tipo, una dirección propia donde está guardada, un tamaño (8 bytes en Linux de 64 bits) y un valor, que se puede cambiar. Lo especial es cómo se usa ese valor: como el lugar donde está otro dato.

2. En primero.c, ¿por qué *p = 31 cambia el valor de edad?

Porque p contiene la dirección de edad, y *p no es una copia sino el dato que está en esa dirección: los mismos 4 bytes que forman edad. Escribir en *p es escribir en edad.

3. ¿Por qué int* p, q; no declara dos punteros?

Porque en una declaración el asterisco se aplica al nombre que lo sigue, no al tipo: la línea declara p como int * y q como int. Por eso la convención es escribir int *p, con el asterisco pegado al nombre, y un puntero por declaración.

4. En tipos.c, tres punteros tienen la misma dirección. ¿Por qué *entero, *mitad y *byte dan valores distintos?

Porque el tipo de cada puntero determina cuántos bytes se leen a partir de la dirección y cómo se interpretan: un uint32_t * lee 4 bytes, un uint16_t * lee 2, y un unsigned char * lee 1. Como x86-64 es little-endian, los primeros bytes son los menos significativos: 0x78, 0x5678 y 0x12345678.

5. ¿Qué diferencia hay entre p = q y *p = *q?

p = q copia la dirección: p pasa a apuntar al mismo lugar que q, y ningún dato cambia. *p = *q copia el dato: lee lo que hay donde apunta q y lo escribe donde apunta p; los punteros no cambian.

6. ¿Qué es un alias, y por qué puede ser peligroso?

Es un segundo nombre para la misma memoria, como dos punteros que apuntan a la misma variable. Es peligroso porque una parte del programa puede modificar el dato a través de un nombre sin que otra parte, que usa el otro nombre, lo espere.

7. ¿Qué es NULL, y cómo lo usan las funciones de la biblioteca?

Es el puntero nulo: un valor, el 0 convertido a puntero, que se garantiza distinto de la dirección de cualquier dato. Las funciones que devuelven un puntero, como getenv, fopen o malloc, devuelven NULL para indicar que no hay resultado o que hubo un error, y quien llama debe comprobarlo antes de usar el puntero.

8. ¿Por qué en Linux leer a través de NULL termina siempre el programa?

Porque Linux nunca asigna memoria en los primeros 64 KiB del espacio de direcciones de un proceso (/proc/sys/vm/mmap_min_addr). Al leer la dirección 0, el procesador no encuentra la página, avisa al núcleo, y este envía SIGSEGV. Es una protección deliberada, para que el error se manifieste de inmediato.

9. ¿Por qué un puntero sin inicializar es más peligroso que uno nulo?

Porque contiene basura, que puede ser una dirección válida, por ejemplo de otra variable: escribir a través de él la modifica en silencio, y el error aparece más tarde, lejos de su causa. Un puntero nulo, en cambio, falla de inmediato y en la línea correcta. Por eso todo puntero se inicializa al declararlo, con NULL si todavía no hay a qué apuntar.

10. ¿Por qué memcpy y write reciben void *?

Porque trabajan con bytes, sin importar qué tipo de dato contienen. Un void * acepta la dirección de cualquier dato sin cast, de modo que se les puede pasar un int *, un double * o la dirección de una estructura. Adentro, la función trata la memoria como bytes, con un unsigned char *.

11. ¿Por qué modificar una cadena literal termina el programa en Linux?

Porque las cadenas literales se guardan en la sección .rodata, que se carga en páginas de memoria sin permiso de escritura, como muestra la región r--p en /proc/self/maps. Al intentar escribir, el procesador lo impide y el núcleo envía SIGSEGV.

12. ¿Qué muestra x/4xb p en gdb?

Los 4 bytes de memoria a partir de la dirección guardada en p, en hexadecimal y de a un byte. Con p apuntando a edad, que vale 30, muestra 0x1e 0x00 0x00 0x00, el 30 en orden little-endian.

Para practicar

  1. Declara un double, un char y un long, un puntero a cada uno, y muestra para cada par la dirección, el valor, el tamaño de la variable y el tamaño del puntero.
  2. Escribe un programa con dos variables y un solo puntero. Haz que el puntero apunte primero a una y después a la otra, modificando cada una a través de él, y muestra los valores después de cada paso.
  3. Usa un unsigned char * para mostrar los bytes de un long con valor 0x0102030405060708, y luego cambia solo su byte más significativo a través del puntero. Comprueba el resultado con %lx.
  4. Modifica nulo.c para que, si la variable no existe, use un valor por omisión en lugar de terminar. Pruébalo con env -u USER ./nulo.
  5. Agrega a regiones.c la dirección de un parámetro de una función, de una variable de entorno obtenida con getenv y del arreglo argv. ¿En qué región cae cada una?
  6. Escribe char *s = "hola"; s[0] = 'H';, compílalo y ejecútalo. Luego cambia la declaración a char s[] = "hola"; y explica la diferencia con lo visto en la sección 11.
  7. En gdb, detén alias en la línea de *p = 99 y usa p p, p q, p &a, p &b y x/2dw p para comprobar a qué apunta cada puntero.