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

Tema 17

Arreglos y punteros

En C, los arreglos y los punteros están unidos de una forma que no tiene equivalente en otros lenguajes. El nombre de un arreglo se convierte en un puntero en casi cualquier expresión; los corchetes son en realidad una operación con punteros; y una función nunca recibe un arreglo, sino un puntero a su primer elemento. Esa relación explica varias cosas que vimos sin explicar: por qué sizeof daba 8 dentro de una función, por qué b[i] funcionaba sobre un puntero, por qué un arreglo no se puede asignar. Pero un arreglo no es un puntero, y confundirlos produce errores clásicos. Este tema pone en claro qué tienen en común, en qué se diferencian, y qué pasa cuando un índice se sale de los límites.

1. Un arreglo en la memoria

Un arreglo es una secuencia de elementos del mismo tipo, guardados uno a continuación del otro en la memoria, sin huecos. Se declara con el tamaño entre corchetes, y sus elementos se numeran desde 0:

contiguo.c
#include <stdio.h>

int main(void)
{
    int puertos[5] = {22, 80, 443, 8080, 9090};
    int parcial[5] = {1, 2};                  /* el resto queda en 0 */
    int designado[8] = {[2] = 7, [6] = 3};    /* C99: por posición */
    int deducido[] = {10, 20, 30};            /* el tamaño sale de los valores */

    for (int i = 0; i < 5; i++)
        printf("puertos[%d] = %4d  en %p\n", i, puertos[i], (void *) &puertos[i]);

    printf("sizeof puertos = %zu, elementos = %zu\n",
           sizeof puertos, sizeof puertos / sizeof puertos[0]);
    printf("parcial:   %d %d %d %d %d\n", parcial[0], parcial[1], parcial[2], parcial[3], parcial[4]);
    printf("designado:");
    for (int i = 0; i < 8; i++)
        printf(" %d", designado[i]);
    printf("\ndeducido tiene %zu elementos\n", sizeof deducido / sizeof deducido[0]);
    return 0;
}
terminal
$ ./contiguo
puertos[0] =   22  en 0x7ffd0ac9f110
puertos[1] =   80  en 0x7ffd0ac9f114
puertos[2] =  443  en 0x7ffd0ac9f118
puertos[3] = 8080  en 0x7ffd0ac9f11c
puertos[4] = 9090  en 0x7ffd0ac9f120
sizeof puertos = 20, elementos = 5
parcial:   1 2 0 0 0
designado: 0 0 7 0 0 0 3 0
deducido tiene 3 elementos

Las direcciones avanzan de a 4, el tamaño de un int: los cinco elementos ocupan 20 bytes seguidos. Esa contigüidad es la propiedad fundamental de los arreglos. Gracias a ella, conociendo la dirección del primer elemento y el tamaño de cada uno, se puede calcular la dirección de cualquier otro sin buscarla: el elemento i está en inicio + i × tamaño. Es lo que hace el procesador en cada acceso con corchetes, y por eso acceder a cualquier elemento cuesta lo mismo. Y por eso también, como el elemento i está a i posiciones del primero, la numeración empieza en 0.

2. Inicializar un arreglo

contiguo.c muestra las cuatro formas de dar valores iniciales a un arreglo:

Un arreglo local sin inicializar, en cambio, contiene basura, como cualquier variable automática del tema 6; uno global o static empieza en cero. Y el modismo sizeof arreglo / sizeof arreglo[0], el tamaño total dividido por el de un elemento, da la cantidad de elementos: es la forma de no repetir el número 5 en el bucle, y de que el código siga siendo correcto si el arreglo cambia de tamaño. Pero solo funciona donde el arreglo es un arreglo de verdad, como veremos en la sección 7.

3. El nombre se convierte en puntero

Esta es la regla central del tema: en casi cualquier expresión, el nombre de un arreglo se convierte automáticamente en un puntero a su primer elemento. A esta conversión se la suele llamar decaimiento (array decay). Es lo que permite escribir int *p = a; sin &:

decae.c
#include <stdio.h>

int main(void)
{
    int a[5] = {10, 20, 30, 40, 50};
    int *p = a;                      /* el arreglo se convierte en &a[0] */

    printf("a      = %p\n", (void *) a);
    printf("&a[0]  = %p\n", (void *) &a[0]);
    printf("p      = %p\n", (void *) p);
    printf("&a     = %p\n", (void *) &a);

    printf("sizeof a = %zu, sizeof p = %zu, sizeof &a = %zu\n", sizeof a, sizeof p, sizeof &a);
    printf("sizeof *&a = %zu\n", sizeof *&a);

    printf("a + 1  = %p   (avanza un int)\n", (void *) (a + 1));
    printf("&a + 1 = %p   (avanza el arreglo entero)\n", (void *) (&a + 1));

    printf("a[3] = %d, *(a + 3) = %d, p[3] = %d, *(p + 3) = %d, 3[a] = %d\n",
           a[3], *(a + 3), p[3], *(p + 3), 3[a]);
    return 0;
}
terminal
$ ./decae
a      = 0x7fff99437460
&a[0]  = 0x7fff99437460
p      = 0x7fff99437460
&a     = 0x7fff99437460
sizeof a = 20, sizeof p = 8, sizeof &a = 8
sizeof *&a = 20
a + 1  = 0x7fff99437464   (avanza un int)
&a + 1 = 0x7fff99437474   (avanza el arreglo entero)
a[3] = 40, *(a + 3) = 40, p[3] = 40, *(p + 3) = 40, 3[a] = 40
El arreglo a de cinco int, con los valores 10, 20, 30, 40 y 50, desde la dirección 0x...460. a, &a[0], p y &a señalan todos el primer elemento; a + 1 señala el segundo, en 0x...464; &a + 1 señala la posición justo después del último elemento, en 0x...474 a · &a[0] · p · &a a + 1 &a + 1 10 20 30 40 50 a[0] · …460 a[1] · …464 a[2] · …468 a[3] · …46c a[4] · …470 …474 sizeof a = 20 bytes: el arreglo entero, que es el objeto al que apunta &a La posición punteada, justo después del final, se puede señalar pero no leer.
Cuatro expresiones distintas dan la misma dirección: la del primer elemento. Lo que cambia es el tipo, y con él cuánto avanza un + 1.

a, &a[0] y p son la misma dirección, del mismo tipo, int *: el nombre del arreglo, usado en una expresión, se convirtió en un puntero al primer elemento. Por eso int *p = a funciona, y por eso, en el tema 15, la función volcar recibía un arreglo de caracteres sin &. Es también lo que pasa al llamar a una función con un arreglo, como veremos en la sección 7.

La conversión es automática, pero no cambia el arreglo: no se crea ninguna variable puntero, ni se copia nada. El compilador simplemente usa la dirección del arreglo donde la expresión necesita un valor, porque un arreglo entero no es un valor que se pueda manejar de una vez: no cabe en un registro, ni se puede copiar con =, como veremos.

4. Las excepciones: sizeof y &

La conversión ocurre «en casi cualquier expresión». Las excepciones son pocas y conviene saberlas de memoria:

ExpresiónEl arreglo…Resultado con int a[5]
sizeof aNo se convierte: es el arreglo entero.20, el tamaño de los cinco elementos.
&aNo se convierte: se toma la dirección del arreglo entero.Un puntero de tipo int (*)[5], «puntero a arreglo de 5 int».
char s[] = "hola";Una cadena literal que inicializa un arreglo se copia en él.Un arreglo propio de 5 bytes (sección 9).
Cualquier otraSe convierte en &a[0], de tipo int *.a + 1, a[i], f(a), p = a…

decae.c muestra las dos primeras. sizeof a vale 20, mientras que sizeof p vale 8: el arreglo conoce su tamaño; el puntero, solo una dirección. &a tiene el mismo valor numérico que a, pero es de otro tipo: apunta al arreglo como un todo, y *&a es el arreglo entero, de 20 bytes. La diferencia se ve al sumar 1: a + 1 avanza un int, 4 bytes, hasta el segundo elemento; &a + 1 avanza un arreglo de cinco int, 20 bytes, y queda justo después del último elemento. Sumar a un puntero avanza tantos bytes como mide el tipo apuntado: es la aritmética de punteros del tema 18.

5. a[i] es *(a + i)

El estándar de C no define el acceso a un arreglo como una operación especial. Define a[i] como exactamente igual a *(a + i): tomar la dirección del primer elemento, avanzar i elementos, y desreferenciar. La última línea de decae.c lo muestra con todas las combinaciones posibles, y todas dan 40:

Esta equivalencia es la base de toda la relación entre arreglos y punteros: indexar un arreglo es aritmética de punteros con otra sintaxis. Y explica por qué C no comprueba los índices: a[7] en un arreglo de 5 es *(a + 7), una dirección como cualquier otra, sin información sobre dónde termina el arreglo.

6. Un arreglo no es un puntero

Que el nombre de un arreglo se convierta en un puntero no significa que sea un puntero. Un arreglo contiene sus elementos; un puntero contiene una dirección. Las diferencias se notan en cuanto se intenta tratar al arreglo como una variable puntero:

terminal · diferencias.c: p = b; p++; funcionan; a = b; a++; no
$ gcc -std=c17 -Wall -Wextra -c diferencias.c
diferencias.c: In function ‘main’:
diferencias.c:9:7: error: assignment to expression with array type
    9 |     a = b;          /* un arreglo, no */
      |       ^
diferencias.c:10:6: error: lvalue required as increment operand
   10 |     a++;
      |      ^~
Arreglo int a[5]Puntero int *p
ContieneLos 5 elementos.Una dirección, 8 bytes.
sizeof208
Ocupa memoria paraLos datos.La dirección; los datos están en otro lado.
Se le puede asignarNo: a = b es un error.Sí: p = b cambia a qué apunta.
Se puede incrementarNo: a++ es un error.Sí: p++ pasa al siguiente elemento.
& daUn puntero al arreglo entero.La dirección de la variable puntero.
Puede ser NULLNo: siempre tiene sus elementos.Sí.

Una forma de pensarlo: el nombre de un arreglo es como una constante, la dirección fija donde están sus elementos. No hay ninguna variable que la guarde, y por eso no se puede cambiar. Un puntero, en cambio, es una variable, con su propio lugar en la memoria, y su valor se puede reemplazar.

7. Arreglos como parámetros

Al pasar un arreglo a una función, se aplica la conversión: la función recibe un puntero al primer elemento. Y como el estándar sabe que eso es lo que llega, cualquier parámetro declarado como arreglo es en realidad un puntero: int datos[], int datos[10] e int *datos significan exactamente lo mismo en una lista de parámetros. Es lo que vimos en el tema 11 con sumar_mal, donde sizeof datos valía 8.

Eso vuelve peligroso el modismo para contar elementos, sobre todo escondido en una macro como las del tema 14:

terminal · cant.c: #define CANTIDAD(a) (sizeof (a) / sizeof (a)[0]), usada sobre un arreglo, un parámetro y un puntero
$ gcc -std=c17 -Wall -Wextra -o cant cant.c
cant.c: In function ‘contar’:
cant.c:2:29: warning: ‘sizeof’ on array function parameter ‘datos’ will return size of ‘int *’ [-Wsizeof-array-argument]
…
cant.c: In function ‘main’:
cant.c:2:33: warning: division ‘sizeof (int *) / sizeof (int)’ does not compute the number of array elements [-Wsizeof-pointer-div]
…
$ ./cant                         # CANTIDAD(v), contar(v), CANTIDAD(p)
10 2 2

Sobre el arreglo de verdad, la macro da 10. Sobre el parámetro y sobre un puntero, da 2: los 8 bytes de un puntero divididos por los 4 de un int. gcc reconoce el modismo y advierte en los dos casos, pero solo con -Wall. La regla, otra vez, es pasar siempre la cantidad como un parámetro aparte, calculada donde el arreglo todavía es un arreglo.

C99 agregó una forma de documentar el tamaño mínimo esperado, con static dentro de los corchetes de un parámetro:

terminal · parametro.c: int suma4(const int datos[static 4]), llamada con 4 elementos, con 2 y con NULL
$ gcc -std=c17 -Wall -Wextra -o parametro parametro.c
parametro.c: In function ‘main’:
parametro.c:16:20: warning: argument 1 null where non-null expected [-Wnonnull]
   16 |     printf("%d\n", suma4(NULL));
…
parametro.c:15:5: warning: ‘suma4’ reading 16 bytes from a region of size 8 [-Wstringop-overread]
   15 |     printf("%d\n", suma4(pocos));
…

datos[static 4] promete que el puntero no es nulo y apunta a por lo menos 4 elementos, y gcc advierte si ve una llamada que rompe la promesa: con NULL, y con un arreglo de 2 elementos, 8 bytes, del que la función leería 16. Sigue siendo un puntero, y la comprobación solo funciona cuando el compilador conoce el arreglo pasado, pero es una documentación útil, y una de las pocas formas de expresar un tamaño en el prototipo.

8. Copiar y comparar arreglos

Como un arreglo no se puede asignar, copiarlo exige copiar sus bytes, y como su nombre se convierte en un puntero, compararlo con == compara direcciones:

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

int main(void)
{
    int original[4] = {1, 2, 3, 4};
    int copia[4];
    int otra[4] = {1, 2, 3, 4};

    memcpy(copia, original, sizeof original);
    copia[0] = 99;
    printf("original[0] = %d, copia[0] = %d\n", original[0], copia[0]);

    printf("original == otra:          %d\n", original == otra);
    printf("memcmp(original, otra, …): %d\n", memcmp(original, otra, sizeof original));
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o copiar copiar.c && ./copiar
copiar.c: In function ‘main’:
copiar.c:14:56: warning: comparison between two arrays [-Warray-compare]
   14 |     printf("original == otra:          %d\n", original == otra);
      |                                                        ^~
copiar.c:14:56: note: use ‘&original[0] == &otra[0]’ to compare the addresses
original[0] = 1, copia[0] = 99
original == otra:          0
memcmp(original, otra, …): 0

memcpy, del tema 16, copia los 16 bytes, y la copia es independiente: cambiar copia[0] no toca el original. original == otra da 0, falso, aunque los dos tengan exactamente los mismos valores, porque compara las direcciones de dos arreglos distintos; gcc lo advierte. memcmp compara los bytes y devuelve 0 cuando son iguales, la convención de las funciones de comparación de C, que también sigue strcmp. (Comparar los bytes de estructuras con memcmp tiene sus trampas, por el relleno del tema 25; con arreglos de enteros es seguro.)

9. char[] frente a char *

La diferencia entre arreglo y puntero tiene su caso más importante en las cadenas. Estas dos declaraciones se parecen mucho y son completamente distintas:

cadenas.c
#include <stdio.h>

int main(void)
{
    char arreglo[] = "hola";          /* un arreglo propio, con una copia */
    const char *puntero = "hola";     /* un puntero a la cadena literal */

    printf("arreglo: sizeof = %zu, dirección %p\n", sizeof arreglo, (void *) arreglo);
    printf("puntero: sizeof = %zu, apunta a  %p\n", sizeof puntero, (void *) puntero);

    arreglo[0] = 'H';                 /* permitido: es nuestro */
    printf("arreglo modificado: %s\n", arreglo);

    char *trampa = (char *) puntero;  /* quitar el const no la hace modificable */
    printf("modificando la literal...\n");
    fflush(stdout);
    trampa[0] = 'H';
    printf("%s\n", puntero);
    return 0;
}
terminal
$ ./cadenas
arreglo: sizeof = 5, dirección 0x7ffffcd9a083
puntero: sizeof = 8, apunta a  0x5d2ab8da5008
arreglo modificado: Hola
modificando la literal...
Segmentation fault
Dos formas de guardar "hola". char arreglo[] = "hola" es un arreglo de 5 bytes en la pila, con h, o, l, a y el byte nulo, que se puede modificar. const char *puntero = "hola" es una variable de 8 bytes en la pila que guarda la dirección de la cadena literal, que está en la memoria de solo lectura del ejecutable char arreglo[] = "hola"; en la pila · 5 bytes · modificable h o l a \0 const char *puntero = "hola"; en la pila · 8 bytes · una dirección 0x5d2ab8da5008 la cadena literal en .rodata · r--p · solo lectura h o l a \0
El arreglo es una copia propia de la cadena; el puntero solo guarda dónde está la original, que no se puede modificar.

char arreglo[] = "hola" crea un arreglo local de 5 bytes, en la pila, y copia en él los caracteres de la cadena literal y su \0: es una de las excepciones de la sección 4. El arreglo es del programa, y modificarlo es correcto. const char *puntero = "hola" crea solo una variable de 8 bytes con la dirección de la cadena literal, que vive en .rodata, la memoria de solo lectura del tema 15. Intentar modificarla, aun quitando el const a la fuerza, termina el programa. Por eso, en el curso, todo puntero a una cadena literal se declara const char *: el compilador rechaza entonces cualquier intento de escribir a través de él. Las cadenas son el bloque 5, a partir del tema 21.

10. Arreglos de punteros y arreglos de arreglos

Una lista de cadenas se puede guardar de dos formas, que también parecen iguales y no lo son:

nombres.c
#include <stdio.h>

int main(void)
{
    const char *punteros[] = {"SIGHUP", "SIGINT", "SIGQUIT", "SIGKILL"};
    char matriz[][8] = {"SIGHUP", "SIGINT", "SIGQUIT", "SIGKILL"};

    printf("arreglo de punteros: sizeof = %zu (%zu punteros)\n",
           sizeof punteros, sizeof punteros / sizeof punteros[0]);
    printf("arreglo de arreglos: sizeof = %zu (%zu filas de %zu bytes)\n",
           sizeof matriz, sizeof matriz / sizeof matriz[0], sizeof matriz[0]);
    for (int i = 0; i < 4; i++)
        printf("  %-8s en %p    %-8s en %p\n", punteros[i], (void *) punteros[i],
               matriz[i], (void *) matriz[i]);
    return 0;
}
terminal
$ ./nombres
arreglo de punteros: sizeof = 32 (4 punteros)
arreglo de arreglos: sizeof = 32 (4 filas de 8 bytes)
  SIGHUP   en 0x64ee2bb38008    SIGHUP   en 0x7ffc31101810
  SIGINT   en 0x64ee2bb3800f    SIGINT   en 0x7ffc31101818
  SIGQUIT  en 0x64ee2bb38016    SIGQUIT  en 0x7ffc31101820
  SIGKILL  en 0x64ee2bb3801e    SIGKILL  en 0x7ffc31101828

Los dos ocupan 32 bytes, por coincidencia, y guardan cosas distintas. punteros es un arreglo de 4 punteros de 8 bytes: los textos están en .rodata, uno detrás de otro y cada uno de su largo justo, 7 u 8 bytes con su \0, como muestran sus direcciones. matriz es un arreglo de 4 arreglos de 8 caracteres: los textos están dentro de él, en la pila, cada uno en una fila de 8 bytes aunque sea más corto. El arreglo de punteros es la forma más común, la que se usa para los mensajes de senal.c en el tema 9 o para argv, que es un arreglo de punteros a las cadenas de los argumentos. Los arreglos de arreglos, las matrices, son el tema del tema 23, y los punteros a punteros que aparecen al recorrer un arreglo de punteros, del tema 19.

11. Salirse de los límites

Como a[i] es solo *(a + i), nada impide calcular una dirección fuera del arreglo y leer o escribir ahí. Ya lo vimos en el tema 1 con fuera.c, que leía un elemento de más. Escribir es peor, porque modifica lo que haya en esa memoria. Para que el efecto sea predecible, este programa pone una variable inmediatamente después del arreglo, dentro de una estructura, un tipo que veremos en el bloque 6:

limites.c
#include <stdio.h>

struct registro {
    int valores[4];
    int clave;                        /* justo después del arreglo */
};

int main(void)
{
    struct registro r = {{1, 2, 3, 4}, 1234};

    printf("antes:   clave = %d\n", r.clave);
    for (int i = 0; i <= 4; i++)      /* error: i llega a 4 */
        r.valores[i] = 0;
    printf("después: clave = %d\n", r.clave);
    return 0;
}
terminal · sin optimizar, con -O2 y con -fsanitize=bounds
$ gcc -std=c17 -Wall -Wextra -o limites limites.c && ./limites
antes:   clave = 1234
después: clave = 0
$ gcc -std=c17 -Wall -Wextra -O2 -o limites limites.c
limites.c: In function ‘main’:
limites.c:14:22: warning: iteration 4 invokes undefined behavior [-Waggressive-loop-optimizations]
…
$ ./limites
antes:   clave = 1234
después: clave = 1234
$ gcc -std=c17 -g -fsanitize=bounds -o limites limites.c && ./limites
limites.c:14:18: runtime error: index 4 out of bounds for type 'int [4]'
antes:   clave = 1234
después: clave = 0

Sin optimizar, el bucle escribió un elemento de más, valores[4], que es exactamente donde está clave: la clave se perdió sin ningún mensaje. Con -O2, el compilador advirtió el comportamiento indefinido y produjo un programa distinto, que no tocó la clave; ninguno de los dos es «el correcto». Y aquí aparece un límite de las herramientas: AddressSanitizer, que detectó los desbordes del tema 1, no detecta este, porque la escritura cae dentro de memoria válida, la de la propia estructura. -fsanitize=bounds, en cambio, conoce el tamaño declarado de valores y señala el índice 4. Usar los dos durante el desarrollo cubre más casos que cualquiera solo.

La regla de siempre, que ningún compilador puede imponer: todo índice se compara con el tamaño antes de usarse, y los bucles van de 0 a < n. Los desbordes de arreglos son, junto con los de cadenas, la fuente más común de vulnerabilidades en programas de C, como vimos en el tema 12 con la dirección de retorno.

12. Tamaños variables y arreglos devueltos

Dos cosas más sobre los arreglos, que completan el cuadro. La primera: el tamaño de un arreglo normal se fija al compilar, pero los arreglos de tamaño variable de C99, que vimos en el tema 12 con grande.c, deciden su tamaño al ejecutar, y para ellos sizeof deja de ser una constante:

terminal · vla.c: double datos[n], con n tomado del argumento
$ ./vla 3
24
$ ./vla 100
800

Con un arreglo de tamaño variable, sizeof se calcula al ejecutar: es la única excepción a la regla del tema 5 de que sizeof no evalúa nada. Los riesgos de estos arreglos, que pueden agotar la pila, son los del tema 12.

La segunda: una función no puede devolver un arreglo. El compilador lo rechaza directamente:

terminal · dev.c: int f(void)[3];
$ gcc -std=c17 -Wall -Wextra -c dev.c
dev.c:1:5: error: ‘f’ declared as function returning an array
    1 | int f(void)[3];
      |     ^

Como un arreglo no es un valor que se pueda copiar, tampoco se puede devolver. Las alternativas son las del tema 12: que quien llama pase el arreglo a llenar, con su tamaño; devolver un puntero a memoria dinámica; o envolver el arreglo en una estructura, que sí se puede devolver y copiar, como veremos en el tema 24.

13. Arreglos en gdb

gdb conoce la diferencia entre un arreglo y un puntero, y la muestra:

terminal · gdb sobre decae, detenido después de inicializar a y p
$ gdb -q -batch -ex 'break 7' -ex run -ex 'p a' -ex 'ptype a' -ex 'p sizeof a' -ex 'ptype p' -ex 'p *p@5' -ex 'p &a' -ex 'p a[2]@3' ./decae
Breakpoint 1, main () at decae.c:8
8	    printf("a      = %p\n", (void *) a);
$1 = {10, 20, 30, 40, 50}
type = int [5]
$2 = 20
type = int *
$3 = {10, 20, 30, 40, 50}
$4 = (int (*)[5]) 0x7fffffffd7b0
$5 = {30, 40, 50}

p a muestra el arreglo entero, porque gdb sabe su tamaño, y ptype lo confirma: int [5] para el arreglo, int * para el puntero. Un puntero, en cambio, no dice cuántos elementos hay a partir de su dirección, y hay que indicarlo con el operador @ de gdb: p *p@5 muestra 5 elementos a partir de *p, y p a[2]@3, 3 a partir de a[2]. p &a muestra el tipo «puntero a arreglo de 5 int» de la sección 4. Es la herramienta para inspeccionar un arreglo que una función recibió como puntero.

14. Errores frecuentes

ErrorQué pasaCorrección
Índices de 1 a n, o i <= nSe lee o escribe un elemento fuera del arreglo.De 0 a < n.
Un arreglo local sin inicializarContiene basura.= {0}, o asignar antes de leer.
sizeof de un parámetro o de un puntero para contar elementosDa el tamaño de un puntero: 8.Pasar la cantidad como parámetro.
a = b con arreglosError: un arreglo no se asigna.memcpy(a, b, sizeof a).
a == b con arreglosCompara direcciones: casi siempre falso.memcmp; strcmp para cadenas.
Modificar una cadena a través de char *p = "…"Segmentation fault: es memoria de solo lectura.char s[] = "…" si hay que modificarla; const char * si no.
Devolver un arreglo localNo se puede devolver un arreglo; su dirección queda colgante.Buffer del llamador, memoria dinámica o una estructura.
Confiar solo en AddressSanitizerNo ve desbordes dentro de una estructura.Sumar -fsanitize=bounds.
Arreglos de tamaño variable con tamaños de los datosPueden agotar la pila.Memoria dinámica (bloque 7).

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Por qué acceder a cualquier elemento de un arreglo cuesta lo mismo?

Porque los elementos están contiguos y todos tienen el mismo tamaño: la dirección del elemento i se calcula directamente como la dirección del primero más i veces el tamaño de un elemento, sin recorrer los anteriores.

2. ¿Qué contiene int v[6] = {5};?

Un 5 en v[0] y ceros en los otros cinco elementos. En una inicialización parcial, los elementos sin valor explícito quedan en cero, también en un arreglo local.

3. ¿Qué significa que el nombre de un arreglo «se convierte en puntero»?

Que cuando el nombre de un arreglo aparece en una expresión, salvo en sizeof y con &, el compilador lo reemplaza por la dirección de su primer elemento, con tipo «puntero al tipo de los elementos». No se crea ninguna variable ni se copia nada: es solo la dirección donde empieza el arreglo.

4. Con int a[5], ¿por qué a y &a muestran la misma dirección, pero a + 1 y &a + 1 no?

Porque las dos son la dirección del principio del arreglo, pero de tipos distintos: a se convierte en int *, un puntero al primer elemento, y &a es un int (*)[5], un puntero al arreglo entero. Sumar 1 avanza el tamaño del tipo apuntado: 4 bytes en el primer caso, 20 en el segundo.

5. ¿Por qué 3[a] es válido y vale lo mismo que a[3]?

Porque el estándar define x[y] como *(x + y). a[3] es *(a + 3) y 3[a] es *(3 + a), y como la suma es conmutativa, las dos expresiones son la misma dirección.

6. ¿Por qué a = b es un error con arreglos pero p = b no?

Porque un arreglo no es una variable que guarde una dirección: su nombre designa la ubicación fija de sus elementos, y no hay nada que cambiar. Un puntero sí es una variable, y asignarle b, que se convierte en la dirección de su primer elemento, cambia a qué apunta.

7. ¿Por qué la macro CANTIDAD(datos) dio 2 dentro de contar?

Porque datos, aunque declarado como int datos[], es un parámetro, y un parámetro arreglo es en realidad un int *. sizeof datos da 8, el tamaño de un puntero, y 8 dividido por los 4 bytes de un int da 2. El tamaño del arreglo original se perdió al pasarlo.

8. ¿Por qué original == otra dio 0 si los dos arreglos tenían los mismos valores?

Porque en la comparación los dos nombres se convierten en punteros a sus primeros elementos, y == compara esas direcciones, que son distintas. Para comparar los contenidos hay que comparar los bytes, con memcmp.

9. ¿Qué diferencia hay entre char s[] = "hola"; y const char *p = "hola";?

s es un arreglo de 5 bytes, del programa, en el que se copiaron los caracteres de la cadena y su \0; se puede modificar, y sizeof s es 5. p es una variable de 8 bytes con la dirección de la cadena literal, que está en memoria de solo lectura; modificarla termina el programa, y sizeof p es 8.

10. ¿Qué diferencia hay entre const char *punteros[4] y char matriz[4][8] con los mismos textos?

punteros es un arreglo de 4 direcciones; los textos están en otro lugar, en la memoria de solo lectura, cada uno con su largo justo. matriz es un arreglo de 4 arreglos de 8 caracteres; los textos están dentro de él, cada uno en una fila de 8 bytes, y se pueden modificar.

11. En limites.c, ¿por qué AddressSanitizer no detectó la escritura en valores[4]?

Porque esa escritura cae sobre clave, dentro de la misma estructura: es memoria válida y asignada, y AddressSanitizer solo marca como prohibida la memoria fuera de los objetos. -fsanitize=bounds sí la detecta, porque comprueba el índice contra el tamaño declarado del arreglo, int [4].

12. ¿Cómo se ve en gdb los primeros 10 elementos de un arreglo que una función recibió como puntero?

Con el operador @: p *datos@10 muestra 10 elementos a partir de la dirección guardada en datos. Hace falta indicarlo porque un puntero no sabe cuántos elementos hay a partir de su dirección, y p datos solo mostraría la dirección.

Para practicar

  1. Declara un arreglo de 10 long y muestra la dirección de cada elemento. Calcula a mano la diferencia entre la del primero y la del último, y compárala con sizeof.
  2. Crea con inicialización designada un arreglo de 32 punteros a cadena que dé nombre a las señales 1, 2, 9, 11 y 15, dejando el resto en NULL, y escribe una función que devuelva el nombre o "desconocida".
  3. Escribe void invertir(int datos[], size_t n), que invierta el orden de los elementos en el lugar, usando solo índices. Luego reescríbela usando solo la notación *(datos + i).
  4. Escribe int iguales(const int a[], const int b[], size_t n) sin usar memcmp, y comprueba que coincide con memcmp en varios casos.
  5. Declara char a[] = "sistema"; y const char *p = "sistema";, muestra sizeof de cada uno, y ubica sus direcciones con el programa regiones.c del tema 15.
  6. Modifica limites.c para que el bucle escriba hasta valores[5], y compila con -fsanitize=address y con -fsanitize=bounds. ¿Cuál detecta ahora el problema, y por qué?
  7. En gdb, detén nombres en el bucle y usa p punteros, p matriz, ptype punteros, ptype matriz y x/8cb matriz[2] para ver la diferencia entre las dos formas.