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:
#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;
}
$ 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
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:
$ 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í:
| Operador | Nombre | Se aplica a | Produce | Ejemplo |
|---|---|---|---|---|
&x | Dirección de | Una variable, o cualquier cosa con dirección | Un puntero a x | &edad es un int * |
*p | Indirección, o desreferencia | Un puntero | El 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:
#include <stdio.h>
#include <stdint.h>
int main(void)
{
uint32_t numero = 0x12345678;
uint32_t *entero = №
unsigned char *byte = (unsigned char *) №
uint16_t *mitad = (uint16_t *) №
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;
}
$ ./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
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:
$ 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:
#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;
}
$ ./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
*p = *qcopia el valor. Lee el dato al que apuntaqy lo escribe donde apuntap:apasa a valer 20. Los punteros no cambian.p = qcopia la dirección. Ahorapapunta a lo mismo queq, yaya no tiene ningún puntero que la alcance. Ningún dato cambia.
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:
$ ./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:
#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;
}
$ ./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:
$ 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:
#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;
}
$ 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:
$ 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:
#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;
}
$ ./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:
$ ./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ón | Permisos | Contiene | Vive |
|---|---|---|---|
[stack] | rw- | Variables locales y parámetros. | Lo que dura la llamada (tema 12). |
| Datos del ejecutable | rw- | Variables globales y static: .data y .bss. | Todo el programa (tema 6). |
| Datos de solo lectura | r-- | 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ódigo | r-x | Las 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:
$ 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 llamador | Los argumentos se copian; una dirección permite llegar al original. | 16 |
| Recorrer arreglos y cadenas | Un arreglo se maneja a través de la dirección de su primer elemento. | 17, 18, 21 |
| Pasar datos grandes sin copiarlos | Copiar 8 bytes de dirección es más barato que copiar una estructura entera. | 24 |
| Usar memoria pedida al ejecutar | malloc devuelve una dirección: esa memoria no tiene nombre. | 28 |
| Construir estructuras enlazadas | Cada nodo de una lista guarda la dirección del siguiente. | 30 |
| Elegir el código a ejecutar | Un puntero puede apuntar a una función. | 20 |
| Hablar con el sistema | Las 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
| Error | Qué pasa | Corrección |
|---|---|---|
int* p, q; | Solo p es un puntero. | int *p; y int *q;, en líneas separadas. |
| Usar un puntero sin inicializar | Escribe en una dirección cualquiera: falla o corrompe datos en silencio. | Inicializar siempre, con una dirección o con NULL. |
No comprobar NULL | Segmentation fault al desreferenciar. | if (p != NULL) antes de usarlo. |
Confundir p = q con *p = *q | Se 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 == q | Compara direcciones, no contenidos. | *p == *q; para cadenas, strcmp. |
| Mezclar tipos de punteros sin cast | Error: incompatible pointer type. | Revisar el diseño; para bytes, unsigned char *. |
| Leer un dato con un puntero de otro tipo | Viola 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 literal | Segmentation fault: está en memoria de solo lectura. | Copiarla a un arreglo (tema 21). |
15. Resumen
- Cada byte de la memoria del proceso tiene una dirección virtual; la dirección de una variable es la de su primer byte.
- Un puntero es una variable que guarda una dirección. Ocupa 8 bytes en Linux de 64 bits, apunte a lo que apunte.
int *pdeclara un puntero aint; el asterisco pertenece al nombre, y enint* p, qsolopes puntero.&xda la dirección dex;*pes el dato al que apuntap, que se puede leer y escribir.- El tipo del puntero dice cuántos bytes leer en la dirección y cómo interpretarlos; mezclar tipos exige un cast, y solo
unsigned char *puede leer cualquier dato con seguridad. p = qcopia la dirección y crea un alias;*p = *qcopia el dato.NULLes el puntero que no apunta a nada; muchas funciones lo devuelven para indicar «sin resultado», y hay que comprobarlo antes de usar el puntero.- Desreferenciar
NULLtermina el programa conSIGSEGVporque Linux nunca asigna los primeros 64 KiB de memoria. - Un puntero sin inicializar contiene basura y puede corromper datos en silencio; todo puntero se inicializa al declararlo.
void *es un puntero sin tipo, que se convierte solo a y desde cualquier puntero a datos; no se puede desreferenciar.- Un puntero puede apuntar a la pila, a los datos globales, a la memoria de solo lectura, al montículo o al código, y cada región tiene sus permisos.
- En
gdb,pmuestra punteros y lo que apuntan, yxmuestra la memoria cruda.
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
- Declara un
double, unchary unlong, 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. - 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.
- Usa un
unsigned char *para mostrar los bytes de unlongcon valor0x0102030405060708, y luego cambia solo su byte más significativo a través del puntero. Comprueba el resultado con%lx. - Modifica
nulo.cpara que, si la variable no existe, use un valor por omisión en lugar de terminar. Pruébalo conenv -u USER ./nulo. - Agrega a
regiones.cla dirección de un parámetro de una función, de una variable de entorno obtenida congetenvy del arregloargv. ¿En qué región cae cada una? - Escribe
char *s = "hola"; s[0] = 'H';, compílalo y ejecútalo. Luego cambia la declaración achar s[] = "hola";y explica la diferencia con lo visto en la sección 11. - En
gdb, deténaliasen la línea de*p = 99y usap p,p q,p &a,p &byx/2dw ppara comprobar a qué apunta cada puntero.