← Programación en C para Sistemas Operativos Bloque 5 · Cadenas y arreglos · Tema 21 de 42

Tema 21

Cadenas en C

C no tiene un tipo para el texto. Lo que tiene es una convención: una cadena es una fila de bytes en un arreglo de char, y termina donde aparece el primer byte con valor cero. Esa sola regla, simple y económica, explica cómo funcionan printf, strlen y las cadenas que el núcleo le entrega a cada programa, y también explica una de las fuentes de fallos de seguridad más antiguas de la informática: las funciones que copian texto sin saber cuánto lugar hay. Este tema, que abre el bloque 5, recorre la representación de las cadenas, las funciones de string.h para medirlas, copiarlas, compararlas y buscar dentro de ellas, y lo que pasa, y quién lo detecta, cuando strcpy y strcat escriben más de lo que cabe.

1. Una cadena es un arreglo con un \0

En C, una cadena es una sucesión de caracteres seguida de un byte con valor cero, el terminador nulo, que se escribe '\0'. Se guarda en un arreglo de char, y se maneja a través de un puntero a su primer carácter. Su largo no está guardado en ningún lado: se averigua recorriéndola hasta el terminador.

Ya usamos cadenas desde el primer programa, y en los temas 17 y 18 vimos de dónde salen: el arreglo, el puntero al primer elemento, el recorrido hasta el \0. Este programa muestra todos los bytes de dos arreglos, incluidos los que vienen después del terminador:

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

static void mostrar_bytes(const char *nombre, const char *s, size_t n)
{
    printf("%-8s", nombre);
    for (size_t i = 0; i < n; i++) {
        if (s[i] == '\0')
            printf(" \\0");
        else
            printf("  %c", s[i]);
    }
    printf("\n");
}

int main(void)
{
    char saludo[] = "hola";
    char nombre[10] = "ana";

    printf("saludo: sizeof = %zu, strlen = %zu\n", sizeof saludo, strlen(saludo));
    printf("nombre: sizeof = %zu, strlen = %zu\n", sizeof nombre, strlen(nombre));
    mostrar_bytes("saludo", saludo, sizeof saludo);
    mostrar_bytes("nombre", nombre, sizeof nombre);

    saludo[2] = '\0';                     /* cortar la cadena */
    printf("después de saludo[2] = '\\0': \"%s\", strlen = %zu\n", saludo, strlen(saludo));
    mostrar_bytes("saludo", saludo, sizeof saludo);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o anatomia anatomia.c
$ ./anatomia
saludo: sizeof = 5, strlen = 4
nombre: sizeof = 10, strlen = 3
saludo    h  o  l  a \0
nombre    a  n  a \0 \0 \0 \0 \0 \0 \0
después de saludo[2] = '\0': "ho", strlen = 2
saludo    h  o \0  a \0
El arreglo nombre de 10 bytes con la cadena "ana": los bytes 0 a 2 contienen a, n y a; el byte 3 es el terminador nulo; los bytes 4 a 9 son ceros sin usar. strlen cuenta 3, los caracteres antes del terminador; sizeof vale 10, el tamaño del arreglo char nombre[10] = "ana"; a n a \0 \0 \0 \0 \0 \0 \0 0 1 2 3 4 5 6 7 8 9 strlen = 3 terminador sizeof = 10: el arreglo entero
La cadena termina en el primer \0. Lo que hay después es parte del arreglo, pero no de la cadena.

La salida muestra las tres ideas que sostienen todo el tema:

Esta representación tiene una ventaja y un costo. La ventaja es que una cadena no necesita nada más que sus bytes: no hay una cabecera con el largo, y un puntero a cualquier posición de una cadena es a su vez una cadena, su sufijo. El costo es que nadie sabe cuánto mide una cadena sin recorrerla, ni cuánto lugar queda en el arreglo que la contiene. Ese costo es el tema de casi todo lo que sigue.

2. Inicializar cadenas

Una cadena literal puede inicializar un arreglo de char de varias formas, y no todas producen una cadena:

DeclaraciónTamañoContenido
char s[] = "hola";5El compilador cuenta los caracteres y agrega el \0. La forma habitual.
char s[10] = "hola";10hola, el \0 y cinco ceros más: lo que no se inicializa queda en cero.
char s[5] = "hola";5Justo: los 4 caracteres y el \0.
char s[4] = "hola";4Los 4 caracteres sin terminador. Válido en C, no es una cadena.
char s[] = {'h', 'o'};2Dos caracteres sueltos, sin terminador: tampoco es una cadena.
char s[3] = "hola";3Error: la literal no cabe.
const char *p = "hola";8Un puntero a la literal, que está en memoria de solo lectura (tema 17).

El caso de char s[4] = "hola" es una regla curiosa de C: si el arreglo tiene exactamente el lugar para los caracteres, el terminador se descarta sin error (C++, en cambio, lo rechaza). El resultado es un arreglo de cuatro letras que cualquier función de cadenas va a seguir leyendo más allá de su final, buscando un \0 que no está:

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

int main(void)
{
    char numero[] = "12345";
    char letras[4] = "hola";              /* no queda lugar para el '\0' */
    char otra[] = {'s', 'o'};             /* tampoco: no es una cadena */

    printf("letras: \"%s\", strlen = %zu\n", letras, strlen(letras));
    printf("otra:   \"%s\", strlen = %zu\n", otra, strlen(otra));
    printf("numero: \"%s\"\n", numero);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o sinfin sinfin.c
sinfin.c: In function ‘main’:
sinfin.c:7:22: warning: initializer-string for array of ‘char’ truncates NUL terminator but destination lacks ‘nonstring’ attribute (5 chars into 4 available) [-Wunterminated-string-initialization]
    7 |     char letras[4] = "hola";              /* no queda lugar para el '\0' */
      |                      ^~~~~~
$ ./sinfin
letras: "hola12345", strlen = 9
otra:   "sohola12345", strlen = 11
numero: "12345"

Los tres arreglos quedaron uno al lado del otro en la pila, otra, después letras y después numero, y como los dos primeros no tienen terminador, printf y strlen siguieron leyendo hasta el \0 de numero. Es comportamiento indefinido, y aquí tuvo un resultado inofensivo solo porque había un cero cerca: con otra disposición, la lectura habría mostrado basura o llegado a memoria no asignada. gcc 15 advierte en el caso de la literal truncada, con -Wextra; en el caso de los caracteres sueltos no puede saber si la intención era formar una cadena, y no dice nada. El atributo nonstring que menciona el mensaje sirve para declarar que un arreglo guarda bytes de largo fijo a propósito, como algunos campos de formatos binarios, y no una cadena.

La regla práctica: para texto, se escribe char s[] = "…" y se deja que el compilador cuente, o se reserva un tamaño holgado, char s[64], sabiendo que caben 63 caracteres.

3. Medir: strlen y sizeof

strlen(s) cuenta los caracteres desde s hasta el primer \0, sin incluirlo, y devuelve un size_t. Es la función largo del tema 18. sizeof, en cambio, es un operador que el compilador resuelve sin mirar el contenido: con un arreglo da su tamaño total, y con un puntero, 8. La tabla resume cuándo usar cada uno:

sizeofstrlen
Qué mideEl lugar reservado, en bytes.La cadena guardada, en caracteres.
Cuándo se calculaAl compilar.Al ejecutar, recorriendo la cadena.
Con char s[10] = "ana"103
Con const char *p = "ana"8, el tamaño del puntero3
Con un parámetro char s[]8: el arreglo se convirtió en punteroEl largo de la cadena
Se usa paraSaber cuánto cabe en un arreglo: fgets(buf, sizeof buf, …)Saber cuánto ocupa el texto.

Tres consecuencias de que strlen recorra la cadena:

terminal · largos.c: if (strlen(a) - strlen(b) < 0) y if (strlen(a) - strlen(b) > 0), con a = "sh" y b = "bash"
$ gcc -std=c17 -Wall -Wextra -o largos largos.c
largos.c: In function ‘main’:
largos.c:10:31: warning: comparison of unsigned expression in ‘< 0’ is always false [-Wtype-limits]
   10 |     if (strlen(a) - strlen(b) < 0)
      |                               ^
$ ./largos sh bash
"sh" es más larga
strlen(a) - strlen(b) = 18446744073709551614

2 − 4 en size_t da la vuelta a 2⁶⁴ − 2, un número enorme y positivo, y el programa afirma que «sh» es más larga que «bash». La comparación correcta no resta: strlen(a) < strlen(b).

4. El panorama de string.h

El archivo de cabecera string.h declara dos familias de funciones. Las que empiezan con str trabajan con cadenas: se detienen en el \0. Las que empiezan con mem trabajan con bloques de bytes: reciben el tamaño como argumento y no le dan ningún significado al cero.

ParaCadenas (hasta el \0)Bloques (n bytes)Sección
Medirstrlen—3
Copiarstrcpy, stpcpy (POSIX)memcpy, memmove5, 10
Agregar al finalstrcat—5
Compararstrcmp, strncmp, strcasecmp (POSIX)memcmp8
Buscar un carácterstrchr, strrchrmemchr9
Buscar una subcadenastrstr—9
Medir tramosstrspn, strcspn, strpbrk—9
Rellenar—memset10
Con límite de tamañosnprintf, strncpy, strncat, strlcpy—Tema 22
Separar en partesstrtok, strtok_r—Tema 22
Duplicar en memoria nuevastrdup—Tema 28

Las funciones comparten algunas convenciones que conviene tener presentes. El destino va primero y el origen después, como en una asignación: strcpy(destino, origen) se lee «destino = origen». Los parámetros que la función solo lee son const char *, como vimos en el tema 19. Las que copian devuelven el destino, lo que permite encadenarlas aunque casi nunca convenga. Las que buscan devuelven un puntero a lo encontrado, o NULL. Y ninguna recibe el tamaño del destino: todas suponen que hay lugar.

5. Copiar y concatenar

Una cadena no se copia con =: el nombre de un arreglo no es asignable, y asignar punteros copia la dirección, no el texto, como vimos en el tema 17. Para copiar los caracteres está strcpy(destino, origen), que copia el origen con su \0; para agregar al final de una cadena está strcat(destino, origen), que busca el \0 del destino y copia ahí el origen. Este programa arma rutas de archivos de tres formas:

copiar.c
#define _POSIX_C_SOURCE 200809L          /* stpcpy es de POSIX */
#include <stdio.h>
#include <string.h>

int main(void)
{
    char ruta[64];

    /* con strcpy y strcat */
    strcpy(ruta, "/home/ana");
    strcat(ruta, "/");
    strcat(ruta, ".bashrc");
    printf("strcat: %s (%zu)\n", ruta, strlen(ruta));

    /* con stpcpy: cada llamada devuelve dónde quedó el '\0' */
    char *fin = ruta;
    fin = stpcpy(fin, "/etc");
    fin = stpcpy(fin, "/");
    fin = stpcpy(fin, "passwd");
    printf("stpcpy: %s (%td)\n", ruta, fin - ruta);

    /* con memcpy, cuando los largos ya se conocen */
    const char *dir = "/proc", *arch = "/self/status";
    size_t ld = strlen(dir), la = strlen(arch);
    memcpy(ruta, dir, ld);
    memcpy(ruta + ld, arch, la + 1);      /* + 1: copiar también el '\0' */
    printf("memcpy: %s (%zu)\n", ruta, ld + la);
    return 0;
}
terminal
$ ./copiar
strcat: /home/ana/.bashrc (17)
stpcpy: /etc/passwd (11)
memcpy: /proc/self/status (17)

Las tres versiones hacen lo mismo con distinto costo. strcat tiene que encontrar el final del destino antes de copiar, y lo hace recorriéndolo desde el principio en cada llamada: armar una cadena con k llamadas a strcat recorre una y otra vez lo ya copiado, el mismo problema de la sección 3. stpcpy, de POSIX, lo resuelve devolviendo un puntero al \0 que acaba de escribir, de modo que la llamada siguiente empieza justo ahí; la resta fin - ruta, del tema 18, da el largo sin medir. Y cuando los largos ya se conocen, memcpy copia exactamente los bytes indicados, sin buscar ningún terminador: por eso hay que acordarse de copiar el \0, con la + 1.

Las tres versiones tienen además algo en común: ninguna sabe que ruta mide 64 bytes. Si los textos fueran más largos, escribirían igual. En este programa todos los textos están escritos en el código y caben; el problema aparece cuando el texto viene de afuera.

6. El desbordamiento de strcpy

strcpy copia hasta encontrar el \0 del origen, sea cual sea el tamaño del destino. Si el origen lo controla quien usa el programa, como un argumento, una línea leída o un dato que llega por la red, quien usa el programa decide cuántos bytes se escriben. Este programa guarda un nombre y un permiso en una estructura, un tipo que agrupa variables y que estudiaremos en el tema 24; aquí basta saber que sus dos campos quedan uno a continuación del otro en la memoria:

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

struct usuario {
    char nombre[8];
    int es_admin;
};

int main(int argc, char *argv[])
{
    struct usuario u = {"", 0};

    if (argc != 2) {
        fprintf(stderr, "uso: %s nombre\n", argv[0]);
        return 1;
    }
    strcpy(u.nombre, argv[1]);            /* ¿cabe? strcpy no lo pregunta */
    printf("nombre: %s\n", u.nombre);
    printf("es_admin: %d (0x%08x)\n", u.es_admin, (unsigned) u.es_admin);
    if (u.es_admin)
        printf("acceso de administrador concedido\n");
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o desborde desborde.c
$ ./desborde ana
nombre: ana
es_admin: 0 (0x00000000)
$ ./desborde maximiliano
nombre: maximiliano
es_admin: 7302753 (0x006f6e61)
acceso de administrador concedido
La estructura u ocupa 12 bytes: 8 para nombre y 4 para es_admin. Antes de strcpy, nombre está vacío y es_admin vale 0. Después de copiar "maximiliano", que ocupa 12 bytes con el terminador, los 8 primeros caracteres quedan en nombre y los bytes a, n, o y el terminador ocupan es_admin, que pasa a valer 0x006f6e61 nombre[8] es_admin (int, 4 bytes) antes \0 0 después de strcpy(u.nombre, "maximiliano") m a x i m i l i a n o \0 0x61 0x6e 0x6f 0x00 es_admin = 0x006f6e61 12 bytes copiados en un arreglo de 8
Los 4 bytes que no cabían en nombre cayeron sobre es_admin. Leídos como un entero, en el orden little endian del tema 5, valen 0x006f6e61.

«maximiliano» tiene 11 letras y ocupa 12 bytes con el terminador. strcpy escribió los 8 primeros en nombre y los 4 restantes, a, n, o y el \0, en el campo siguiente, es_admin. El programa no falló, no mostró ningún mensaje, y le dio permisos de administrador a quien escribió un nombre largo. Esta es la forma esencial del desbordamiento de buffer (buffer overflow): un dato externo más largo que el lugar reservado pisa lo que hay al lado, y lo que hay al lado puede ser una decisión de seguridad, un puntero o, como vimos en el tema 12, la dirección de retorno de la función. Desde el gusano de Morris, en 1988, que entró a miles de máquinas desbordando un buffer de fingerd con gets, es una de las vulnerabilidades más explotadas de la historia.

La regla que strcpy impone es que quien llama garantice que el destino tiene al menos strlen(origen) + 1 bytes. Con datos del propio programa se puede verificar leyendo el código; con datos externos, hay que comprobarlo antes de copiar, o usar una función que reciba el tamaño del destino, que es el tema del tema 22.

7. strcat y las defensas del sistema

strcat tiene el mismo problema que strcpy, con una cuenta más difícil: el lugar que queda es el tamaño del destino menos lo que ya tiene. Esta función arma un saludo en un arreglo de 16 bytes:

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

static void saludar(const char *nombre)
{
    char mensaje[16] = "Hola, ";

    strcat(mensaje, nombre);              /* 6 bytes ocupados: caben 9 más */
    strcat(mensaje, "!");
    puts(mensaje);
}

int main(int argc, char *argv[])
{
    saludar(argc > 1 ? argv[1] : "mundo");
    return 0;
}
terminal · compilado sin optimizar
$ ./saludo Ana
Hola, Ana!
$ ./saludo Bartolomeo
Hola, Bartolomeo!
$ ./saludo Maximiliano_Rodriguez
*** stack smashing detected ***: terminated
Aborted

«Hola, Bartolomeo!» ocupa 18 bytes con el terminador, y el arreglo tiene 16: el programa escribió 2 bytes fuera de él y funcionó, aparentemente, bien. Los bytes cayeron en un hueco de la pila que nadie usaba, y no hubo consecuencia visible. Ese es el peor resultado posible para quien prueba el programa, porque la prueba pasa. Con el nombre más largo, la escritura llegó hasta el valor testigo que el protector de pila coloca antes de la dirección de retorno, y al volver de saludar el programa lo encontró alterado y abortó, como vimos en el tema 12.

Las mismas pruebas, compiladas de otras dos formas:

terminal · con AddressSanitizer y con optimización
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined -o saludo saludo.c
$ ./saludo Bartolomeo
=================================================================
==411==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x71c363fd0030 at pc 0x75c366f23257 bp 0x7ffdc0d2d230 sp 0x7ffdc0d2c9d8
WRITE of size 11 at 0x71c363fd0030 thread T0
    #0 0x75c366f23256 in strcat ../../../../src/libsanitizer/asan/asan_interceptors.cpp:527
    #1 0x5c289b8ac405 in saludar /home/ana/c/tema21/saludo.c:8
    #2 0x5c289b8ac581 in main /home/ana/c/tema21/saludo.c:15
…
  This frame has 1 object(s):
    [32, 48) 'mensaje' (line 6) <== Memory access at offset 48 overflows this variable
$ gcc -std=c17 -Wall -Wextra -g -O2 -o saludo saludo.c
$ ./saludo Bartolomeo
*** buffer overflow detected ***: terminated
Aborted

AddressSanitizer, del tema 1, señala la línea exacta: un strcat que escribió 11 bytes, «Bartolomeo» y su terminador, y cuyo acceso se salió de mensaje, que ocupa los bytes 32 a 48 del marco. Con -O2, Ubuntu activa _FORTIFY_SOURCE, del tema 4: el compilador sabe que mensaje mide 16 bytes y reemplaza strcat por __strcat_chk, que recibe ese tamaño y aborta antes de escribir de más. Ahora el error con «Bartolomeo» se detecta en todas las ejecuciones, y no solo cuando el desbordamiento es grande.

¿Y desborde.c? Ahí las defensas se comportan distinto, y vale la pena ver por qué:

terminal · desborde.c, con las mismas defensas
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined -o desborde desborde.c
$ ./desborde maximiliano
nombre: maximiliano
es_admin: 7302753 (0x006f6e61)
acceso de administrador concedido
$ gcc -std=c17 -Wall -Wextra -g -O2 -o desborde desborde.c
$ ./desborde maximiliano
*** buffer overflow detected ***: terminated
Aborted

AddressSanitizer no vio nada: protege los límites de cada objeto de la memoria, y la estructura u entera es un solo objeto de 12 bytes; «maximiliano» y su terminador caben exactamente en ella. El desbordamiento de un campo sobre el siguiente le es invisible. _FORTIFY_SOURCE, en cambio, conoce el tamaño del campo nombre y lo detectó. La tabla resume las defensas:

DefensaCómo se activaQué detectaCuándo
Protector de pilaPor defecto en Ubuntu (-fstack-protector-strong)Escrituras que alcanzan el valor testigo de un marco de pila.Al volver de la función, después del daño.
_FORTIFY_SOURCEPor defecto en Ubuntu con -O1 o másCopias a un destino cuyo tamaño conoce el compilador, incluso un campo.Antes de escribir.
AddressSanitizer-fsanitize=address, solo al desarrollarCualquier acceso fuera de un objeto: pila, montículo, globales.En el acceso mismo.
Ninguna—Desbordamientos de un campo a otro sin optimizar, o destinos de tamaño desconocido.Nunca.

Las defensas son una red de seguridad, no un permiso: _FORTIFY_SOURCE no puede hacer nada cuando el destino es un puntero cuyo tamaño el compilador no conoce, como un parámetro char * o memoria pedida con malloc, y el protector de pila solo actúa cuando el daño ya ocurrió. La única corrección es que el programa no escriba fuera del arreglo.

8. Comparar cadenas

Comparar dos cadenas con == compara dos direcciones, no dos textos, igual que con los arreglos del tema 17. Para comparar el contenido está strcmp(a, b), que recorre las dos cadenas hasta encontrar la primera diferencia o el final, y devuelve un número negativo, cero o positivo, la misma convención de los comparadores del tema 20. Este programa compara las dos palabras que recibe como argumentos:

comparar.c
#define _POSIX_C_SOURCE 200809L          /* strcasecmp es de POSIX */
#include <stdio.h>
#include <string.h>
#include <strings.h>

static const char *relacion(int r)
{
    return r < 0 ? "va antes que" : r > 0 ? "va después de" : "es igual a";
}

int main(int argc, char *argv[])
{
    if (argc != 3) {
        fprintf(stderr, "uso: %s palabra1 palabra2\n", argv[0]);
        return 1;
    }
    const char *a = argv[1];
    const char *b = argv[2];
    int r;

    r = strcmp(a, b);
    printf("strcmp      = %4d: \"%s\" %s \"%s\"\n", r, a, relacion(r), b);
    r = strcasecmp(a, b);
    printf("strcasecmp  = %4d: sin distinguir mayúsculas, %s\n", r, relacion(r));
    r = strncmp(a, b, 3);
    printf("strncmp(3)  = %4d: los 3 primeros %s\n", r, r == 0 ? "coinciden" : "difieren");
    printf("a == b      = %4d: ¿la misma dirección?\n", a == b);
    return 0;
}
terminal
$ ./comparar bash zsh
strcmp      =  -24: "bash" va antes que "zsh"
strcasecmp  =  -24: sin distinguir mayúsculas, va antes que
strncmp(3)  =  -24: los 3 primeros difieren
a == b      =    0: ¿la misma dirección?
$ ./comparar bash bash
strcmp      =    0: "bash" es igual a "bash"
strcasecmp  =    0: sin distinguir mayúsculas, es igual a
strncmp(3)  =    0: los 3 primeros coinciden
a == b      =    0: ¿la misma dirección?
$ ./comparar Zsh bash
strcmp      =   -8: "Zsh" va antes que "bash"
strcasecmp  =   24: sin distinguir mayúsculas, va después de
strncmp(3)  =   -8: los 3 primeros difieren
a == b      =    0: ¿la misma dirección?
$ ./comparar bash bashrc
strcmp      = -114: "bash" va antes que "bashrc"
strcasecmp  = -114: sin distinguir mayúsculas, va antes que
strncmp(3)  =    0: los 3 primeros coinciden
a == b      =    0: ¿la misma dirección?
$ ./comparar año ano
strcmp      =   85: "año" va después de "ano"
strcasecmp  =   85: sin distinguir mayúsculas, va después de
strncmp(3)  =   85: los 3 primeros difieren
a == b      =    0: ¿la misma dirección?

Los resultados enseñan varias cosas:

Comparar con == contra una literal es un error tan frecuente que gcc lo señala:

terminal · igual.c: if (orden == "salir"), con orden leída con fgets
$ gcc -std=c17 -Wall -Wextra -o igual igual.c
igual.c: In function ‘main’:
igual.c:11:15: warning: comparison with string literal results in unspecified behavior [-Waddress]
   11 |     if (orden == "salir")
      |               ^~

La comparación compila, y es siempre falsa: orden es un arreglo de la pila y "salir" está en .rodata. Lo correcto es strcmp(orden, "salir") == 0. Y la condición if (strcmp(a, b)), sin comparar con nada, es verdadera cuando las cadenas son distintas, lo contrario de lo que sugiere su lectura: conviene escribir siempre el == 0 o el != 0.

9. Buscar dentro de una cadena

Las funciones de búsqueda devuelven un puntero a lo que encontraron dentro de la cadena, o NULL. Como un puntero a cualquier posición de una cadena es otra cadena, el resultado se puede usar directamente: es el resto del texto desde ese punto. Este programa separa una ruta en sus partes, recorre los directorios de una variable como PATH y limpia una línea de configuración:

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

int main(void)
{
    const char *ruta = "/usr/lib/x86_64-linux-gnu/libc.so.6";

    const char *barra = strrchr(ruta, '/');        /* la última '/' */
    const char *nombre = barra != NULL ? barra + 1 : ruta;
    printf("nombre:     %s\n", nombre);
    printf("directorio: %.*s\n", (int) (barra - ruta), ruta);

    const char *punto = strchr(nombre, '.');        /* el primer '.' */
    if (punto != NULL)
        printf("sufijo:     %s\n", punto);
    printf("¿contiene \"linux\"? %s\n", strstr(ruta, "linux") != NULL ? "sí" : "no");

    /* recorrer los directorios de PATH, separados por ':' */
    const char *path = "/usr/local/bin:/usr/bin:/bin::/usr/games";
    const char *p = path;
    int i = 1;
    for (;;) {
        const char *dos_puntos = strchr(p, ':');
        size_t largo = dos_puntos != NULL ? (size_t) (dos_puntos - p) : strlen(p);

        printf("PATH %d: \"%.*s\"\n", i++, (int) largo, p);
        if (dos_puntos == NULL)
            break;
        p = dos_puntos + 1;
    }

    /* strspn y strcspn: largos de tramos */
    const char *linea = "   \tclave=valor # comentario";
    size_t blancos = strspn(linea, " \t");          /* cuántos son blancos */
    const char *dato = linea + blancos;
    size_t hasta = strcspn(dato, " #");              /* cuántos NO son ' ' ni '#' */
    printf("blancos: %zu, dato: \"%.*s\"\n", blancos, (int) hasta, dato);
    return 0;
}
terminal
$ ./buscar
nombre:     libc.so.6
directorio: /usr/lib/x86_64-linux-gnu
sufijo:     .so.6
¿contiene "linux"? sí
PATH 1: "/usr/local/bin"
PATH 2: "/usr/bin"
PATH 3: "/bin"
PATH 4: ""
PATH 5: "/usr/games"
blancos: 4, dato: "clave=valor"
FunciónDevuelveEn buscar.c
strchr(s, c)Puntero a la primera aparición de c, o NULL.El primer . del nombre; cada : de PATH.
strrchr(s, c)Puntero a la última aparición de c, o NULL.La última /: separa directorio y nombre.
strstr(s, sub)Puntero a la primera aparición de la subcadena, o NULL.¿Contiene «linux»?
strspn(s, acepta)Cuántos caracteres iniciales están en acepta.Los blancos del principio.
strcspn(s, rechaza)Cuántos caracteres iniciales no están en rechaza.El dato, hasta un espacio o #.
strpbrk(s, set)Puntero al primer carácter que está en set, o NULL.(Como strcspn, pero devuelve un puntero.)

Algunos detalles del programa. Los resultados de las búsquedas se combinan con la aritmética del tema 18: barra - ruta es la cantidad de caracteres antes de la barra, y barra + 1, el nombre que sigue. Para mostrar una parte de una cadena sin copiarla ni modificarla, printf acepta %.*s, del tema 10: la precisión indica cuántos caracteres mostrar como máximo, y se pasa como un int antes de la cadena. El recorrido de PATH tampoco modifica el texto, y por eso puede trabajar sobre una literal, y muestra el directorio vacío entre ::, que para el intérprete de órdenes significa el directorio actual; strtok, que veremos en el tema 22, se lo saltearía. Y strcspn ya lo usamos en el tema 10, en la línea linea[strcspn(linea, "\n")] = '\0', que corta una línea en el primer \n, o no hace nada si no lo tiene.

Hay un detalle sobre const, que vimos en el tema 19: strchr recibe un const char * y devuelve un char * sin const, porque C no tiene forma de devolver «el mismo tipo que recibió». Por eso el compilador permite escribir a través del resultado de una búsqueda sobre una literal; aquí guardamos todos los resultados en const char * para no perder la protección. C23 corrige esto: en ese estándar, las funciones de búsqueda devuelven un puntero const si reciben uno.

10. Las funciones mem y el solapamiento

Las funciones mem trabajan con bytes, no con cadenas: reciben siempre la cantidad, no se detienen en el \0, y sirven para cualquier tipo de dato, como usamos memcpy en los temas 5 y 17.

FunciónHace
memcpy(dst, src, n)Copia n bytes. Los dos bloques no pueden superponerse.
memmove(dst, src, n)Copia n bytes, correctamente aunque los bloques se superpongan.
memset(dst, c, n)Pone los n bytes en el valor c. Habitual con 0: memset(buf, 0, sizeof buf).
memcmp(a, b, n)Compara n bytes, como strcmp pero sin detenerse en el \0.
memchr(s, c, n)Busca el byte c en los primeros n, como strchr en un bloque.

La diferencia entre memcpy y memmove es el solapamiento. Copiar de una parte de un arreglo a otra parte del mismo arreglo, por ejemplo para quitar los primeros caracteres de una cadena, hace que origen y destino compartan bytes. memmove está obligada a funcionar en ese caso, copiando en el orden adecuado; memcpy, strcpy y strcat no, y usarlas así es comportamiento indefinido. Sus prototipos lo dicen con restrict, una promesa de que los punteros no apuntan a la misma memoria:

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

int main(void)
{
    char texto[] = "  sangría";
    char copia[sizeof texto];

    memcpy(copia, texto, sizeof texto);

    /* quitar los blancos iniciales, en el mismo arreglo */
    size_t blancos = strspn(texto, " ");
    memmove(texto, texto + blancos, strlen(texto + blancos) + 1);
    printf("memmove: \"%s\"\n", texto);

    /* lo mismo con strcpy: origen y destino se solapan */
    strcpy(copia, copia + blancos);
    printf("strcpy:  \"%s\"\n", copia);
    return 0;
}
terminal · sin optimizar, con -O2 y con AddressSanitizer
$ ./solapar
memmove: "sangría"
strcpy:  "sangría"
$ gcc -std=c17 -Wall -Wextra -g -O2 -o solapar solapar.c
In file included from /usr/include/string.h:580,
                 from solapar.c:2:
In function ‘strcpy’,
    inlined from ‘main’ at solapar.c:17:5:
/usr/include/x86_64-linux-gnu/bits/string_fortified.h:93:10: warning: ‘__builtin___strcpy_chk’ accessing 1 byte at offsets 0 and [0, 11] may overlap 1 byte at offset 0 [-Wrestrict]
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o solapar solapar.c && ./solapar
memmove: "sangría"
=================================================================
==543==ERROR: AddressSanitizer: strcpy-param-overlap: memory ranges [0x7102692d0040,0x7102692d0049) and [0x7102692d0042, 0x7102692d004b) overlap
    #0 0x75026b4bd0ed in strcpy ../../../../src/libsanitizer/asan/asan_interceptors.cpp:573
    #1 0x63d31855d4bc in main /home/ana/c/tema21/solapar.c:17

Las dos versiones dieron el mismo resultado, y eso es lo engañoso: la versión de strcpy que eligió glibc en este procesador copió en un orden que, por casualidad, funciona cuando el destino está antes del origen. Otra versión, otro procesador u otra optimización pueden copiar en otro orden y mezclar los bytes. Con -O2, gcc advierte que los bloques pueden superponerse, y AddressSanitizer lo confirma al ejecutar: strcpy-param-overlap, con los dos rangos de direcciones.

No es una preocupación teórica. Hacia fines de 2010, glibc incorporó una versión más rápida de memcpy que copiaba de atrás hacia adelante en algunos procesadores, y varios programas que usaban memcpy con bloques superpuestos, entre ellos el reproductor Flash de Adobe, empezaron a producir sonido distorsionado. El error estaba en esos programas, no en glibc; pero para no romper los ejecutables existentes, glibc conserva hasta hoy dos versiones del símbolo memcpy: los programas compilados con versiones antiguas reciben una que se comporta como memmove. Las veremos en la sección 13. La regla: si hay alguna posibilidad de que los bloques se superpongan, memmove.

11. Cadenas en la frontera con el núcleo

El terminador nulo no es solo una convención de C: es parte de la interfaz entre los programas y Linux. Las llamadas al sistema que reciben nombres, como open o execve, reciben cadenas terminadas en \0, y por eso un nombre de archivo no puede contener un byte nulo; tampoco una /, que separa los directorios. En cambio, las llamadas que mueven datos, read y write, trabajan con bloques de bytes y reciben el tamaño: no miran el \0 ni lo agregan. Un ejemplo que usa los dos mundos es /proc/PID/cmdline, el archivo donde Linux expone los argumentos de cada proceso:

cmdline.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    char ruta[64] = "/proc/self/cmdline";
    char buf[4096];

    if (argc == 2) {                      /* el PID de otro proceso */
        strcpy(ruta, "/proc/");
        strcat(ruta, argv[1]);            /* ¡sin comprobar el largo! ver tema 22 */
        strcat(ruta, "/cmdline");
    }
    int fd = open(ruta, O_RDONLY);
    if (fd < 0) {
        perror(ruta);
        return 1;
    }
    ssize_t n = read(fd, buf, sizeof buf - 1);
    close(fd);
    if (n < 0) {
        perror("read");
        return 1;
    }
    buf[n] = '\0';                        /* read no agrega el terminador */

    printf("%s: %zd bytes\n", ruta, n);
    int i = 0;
    for (const char *p = buf; p < buf + n; p += strlen(p) + 1)
        printf("  [%d] \"%s\"\n", i++, p);
    return 0;
}
terminal
$ ./cmdline uno "dos tres" ""
/proc/self/cmdline: 24 bytes
  [0] "./cmdline"
  [1] "uno"
  [2] "dos tres"
  [3] ""
$ ./cmdline 1
/proc/1/cmdline: 11 bytes
  [0] "/sbin/init"
$ od -c /proc/self/cmdline
0000000   o   d  \0   -   c  \0   /   p   r   o   c   /   s   e   l   f
0000020   /   c   m   d   l   i   n   e  \0
0000031
$ strace -e trace=openat,read ./cmdline uno "dos tres" 2>&1 >/dev/null | tail -3
openat(AT_FDCWD, "/proc/self/cmdline", O_RDONLY) = 3
read(3, "./cmdline\0uno\0dos tres\0", 4095) = 23
+++ exited with 0 +++

El archivo contiene los argumentos tal como el núcleo los guarda en la memoria del proceso, la zona de la pila que vimos en el tema 19: uno detrás de otro, cada uno con su \0. od y strace lo muestran byte a byte. Es un bloque con varias cadenas adentro, y el programa lo recorre aprovechando que cada una termina en \0: p += strlen(p) + 1 salta la cadena actual y su terminador, y deja p al comienzo de la siguiente. El argumento vacío "" aparece como un \0 solo, de largo cero. El mismo formato usa /proc/PID/environ para las variables de entorno.

Hay dos detalles que se repiten en todo programa que lee datos del sistema. El primero: read devuelve una cantidad de bytes y no agrega ningún terminador, porque no sabe si los datos son texto. Si el programa va a tratar el buffer como cadena, tiene que agregarlo él, y para eso lee como máximo sizeof buf - 1 bytes, dejando el último lugar libre. Sin ese buf[n] = '\0', el strlen de la última cadena seguiría por la basura del buffer. El segundo es la advertencia del comentario: el PID llega de la línea de órdenes y se concatena con strcat en un arreglo de 64 bytes, sin comprobar nada. Un argumento de 60 caracteres desborda ruta. Lo dejamos así a propósito, para corregirlo en el tema siguiente con snprintf.

12. Bytes, no caracteres

En el tema 3 vimos que la í de un texto en UTF-8 ocupa dos bytes, y quedó pendiente lo que eso significa para strlen. Para C, un char es un byte, y una cadena es una sucesión de bytes; las funciones de string.h no saben nada de codificaciones:

utf8.c
#include <stdio.h>
#include <string.h>
#include <ctype.h>

int main(void)
{
    char palabra[] = "año";

    printf("\"%s\": sizeof = %zu, strlen = %zu\n", palabra, sizeof palabra, strlen(palabra));
    for (size_t i = 0; palabra[i] != '\0'; i++)
        printf("  byte %zu: 0x%02x\n", i, (unsigned char) palabra[i]);

    for (char *p = palabra; *p != '\0'; p++)
        *p = (char) toupper((unsigned char) *p);
    printf("toupper: \"%s\"\n", palabra);
    return 0;
}
terminal
$ ./utf8
"año": sizeof = 5, strlen = 4
  byte 0: 0x61
  byte 1: 0xc3
  byte 2: 0xb1
  byte 3: 0x6f
toupper: "AñO"

«año» tiene tres letras y strlen dice 4, porque la ñ se codifica en UTF-8 con dos bytes, 0xc3 0xb1. toupper pasó a mayúsculas la a y la o, y dejó intactos los dos bytes de la ñ, que por separado no son letras. Las consecuencias prácticas:

Manejar texto como caracteres, y no como bytes, requiere configurar la región con setlocale y usar las funciones de caracteres anchos de wchar.h, o una biblioteca dedicada; en la programación de sistemas, donde casi todo son rutas, nombres y protocolos, trabajar con bytes es lo habitual.

13. Por dentro: gcc y glibc

Las funciones de cadenas se usan tanto que el compilador y la biblioteca les dan un trato especial. Del lado del compilador, gcc conoce su significado y, cuando puede, las calcula sin llamarlas:

terminal · constantes.c, con tres funciones: strlen("sistema"), strcpy(buf, "hola") y strlen(s) de un parámetro
$ gcc -std=c17 -O2 -S -fno-asynchronous-unwind-tables -o - constantes.c
largo_fijo:
	endbr64
	movl	$7, %eax
	ret
poner_hola:
	endbr64
	movl	$1634496360, (%rdi)
	movb	$0, 4(%rdi)
	ret
largo_variable:
	endbr64
	jmp	strlen@PLT

strlen("sistema") se volvió la constante 7. strcpy(buf, "hola") se volvió dos instrucciones: guardar el número 1634496360, que en hexadecimal es 0x616c6f68, los bytes de h, o, l y a en orden little endian, y después un byte 0. Solo cuando la cadena es desconocida, como el parámetro de largo_variable, hay una llamada real a strlen. Es el mismo trato que explica por qué strcmp con dos literales puede devolver −1 en lugar de la diferencia de bytes de la sección 8: el resultado lo calculó el compilador.

Del lado de la biblioteca, glibc tiene varias versiones de cada función, escritas en ensamblador para distintas generaciones de procesadores, y elige una al cargar el programa:

terminal · los símbolos de libc, y la versión de strlen que se ejecuta
$ nm -D /usr/lib/x86_64-linux-gnu/libc.so.6 | grep -E ' (strlen|strcpy|strcat|strcmp|memcpy|memmove)@'
00000000000c22f0 T memcpy@GLIBC_2.2.5
00000000000b8c80 i memcpy@@GLIBC_2.14
00000000000b9600 i memmove@@GLIBC_2.2.5
00000000000ba6d0 i strcat@@GLIBC_2.2.5
00000000000ba840 i strcmp@@GLIBC_2.2.5
00000000000bb8b0 i strcpy@@GLIBC_2.2.5
00000000000bc400 i strlen@@GLIBC_2.2.5
$ gdb -q -batch -ex 'break 21' -ex run -ex 'break __strlen_avx2' -ex 'break __strlen_evex' \
      -ex 'break __strlen_sse2' -ex continue -ex bt ./anatomia
Breakpoint 1, main () at anatomia.c:21
21	    printf("saludo: sizeof = %zu, strlen = %zu\n", sizeof saludo, strlen(saludo));
Breakpoint 2 at 0x7ffff7d9a780: file ../sysdeps/x86_64/multiarch/strlen-avx2.S, line 52.
Breakpoint 3 at 0x7ffff7daaa00: file ../sysdeps/x86_64/multiarch/strlen-evex-base.S, line 72.
Breakpoint 4 at 0x7ffff7cc93d0: file ../sysdeps/x86_64/multiarch/strlen-sse2.S, line 57.

Breakpoint 2, __strlen_avx2 () at ../sysdeps/x86_64/multiarch/strlen-avx2.S:52
#0  __strlen_avx2 () at ../sysdeps/x86_64/multiarch/strlen-avx2.S:52
#1  0x0000555555555291 in main () at anatomia.c:21

La letra i en la salida de nm marca una función indirecta (ifunc): en esa dirección no está strlen, sino una función que, al cargar el programa, consulta qué instrucciones tiene el procesador y devuelve la dirección de la mejor versión disponible. Es una aplicación de los punteros a funciones del tema 20. En este procesador, que tiene instrucciones AVX2 pero no AVX-512, la elegida fue __strlen_avx2: el punto de interrupción se detuvo ahí, llamado desde la línea 21 de main. Esa versión no recorre la cadena de a un byte, como nuestra largo del tema 18, sino de a 32 bytes por instrucción, comparándolos todos con cero a la vez. Por eso conviene usar las funciones de la biblioteca en lugar de reescribirlas: son correctas y mucho más rápidas.

La primera línea de nm es la de la sección 10: memcpy@GLIBC_2.2.5, la versión que reciben los programas compilados antes de glibc 2.14, y memcpy@@GLIBC_2.14, con dos @, la versión actual, que reciben los programas compilados hoy.

14. Errores frecuentes

ErrorQué pasaCorrección
Reservar strlen(s) bytes para copiar sFalta el lugar del \0: se escribe un byte fuera.strlen(s) + 1.
Un arreglo de caracteres sin terminadorprintf y strlen siguen leyendo fuera del arreglo.char s[] = "…", y respetar -Wunterminated-string-initialization.
strcpy o strcat con datos externosDesbordamiento: se pisan otras variables, a veces sin ningún síntoma.Comprobar el largo antes, o snprintf (tema 22).
Comparar cadenas con ==Compara direcciones: casi siempre falso.strcmp(a, b) == 0.
if (strcmp(a, b)) para «son iguales»Es verdadero cuando son distintas.Escribir siempre == 0 o != 0.
strcmp(a, b) == -1glibc devuelve la diferencia de bytes, no −1.strcmp(a, b) < 0.
strlen(a) - strlen(b) < 0Es size_t: nunca negativo.strlen(a) < strlen(b).
strlen en la condición de un bucleRecorre la cadena en cada vuelta.Calcular el largo una vez antes del bucle.
No comprobar el NULL de strchr o strstrSegmentation fault si no se encontró.Comprobar antes de usar el resultado.
strcpy o memcpy con bloques superpuestosComportamiento indefinido, que suele funcionar hasta que no.memmove.
Tratar como cadena lo que devolvió readread no agrega el \0.Leer sizeof buf - 1 y hacer buf[n] = '\0'.
Contar letras con strlenCuenta bytes: «año» mide 4.Decodificar UTF-8, o trabajar con bytes a sabiendas.

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Cuántos bytes ocupa char s[] = "sistema";, y cuánto devuelve strlen(s)?

Ocupa 8 bytes: los 7 caracteres y el terminador \0, que el compilador agrega al inicializar un arreglo con una literal. strlen(s) devuelve 7, porque cuenta los caracteres antes del terminador.

2. Después de char nombre[10] = "ana";, ¿qué valen sizeof nombre y strlen(nombre), y qué hay en los bytes 4 a 9?

sizeof nombre vale 10, el tamaño del arreglo, y strlen(nombre) vale 3, el largo de la cadena. Los bytes 3 a 9 son ceros: el 3 es el terminador, y los demás quedan en cero porque, al inicializar un arreglo, lo que no se inicializa explícitamente vale cero.

3. ¿Qué tiene de peligroso char s[4] = "hola";?

Es válido en C, pero el arreglo solo tiene lugar para los cuatro caracteres, y el terminador se descarta. El resultado no es una cadena: printf("%s") o strlen seguirán leyendo fuera del arreglo hasta encontrar un cero, lo que es comportamiento indefinido. gcc 15 lo advierte con -Wunterminated-string-initialization, incluida en -Wextra.

4. ¿Por qué strlen(a) - strlen(b) < 0 nunca es verdadero?

Porque strlen devuelve size_t, un tipo sin signo, y la resta de dos valores sin signo también es sin signo: si b es más larga, el resultado da la vuelta a un número enorme y positivo. La comparación correcta es strlen(a) < strlen(b).

5. ¿Qué ventaja tiene stpcpy sobre una serie de llamadas a strcat?

Que devuelve un puntero al \0 que acaba de escribir, de modo que la siguiente copia empieza ahí. strcat tiene que buscar el final del destino recorriéndolo desde el comienzo en cada llamada, así que concatenar muchas partes recorre una y otra vez lo ya copiado.

6. En desborde.c, ¿por qué el nombre «maximiliano» dio permisos de administrador?

Porque ocupa 12 bytes con el terminador y nombre tiene 8. strcpy no conoce el tamaño del destino y escribió los 4 bytes sobrantes, a, n, o y \0, en el campo siguiente de la estructura, es_admin, que pasó a valer 0x006f6e61, distinto de cero y por lo tanto verdadero.

7. ¿Por qué AddressSanitizer no detectó el desbordamiento de desborde.c, y _FORTIFY_SOURCE sí?

AddressSanitizer marca los límites de cada objeto de la memoria, y la estructura entera es un solo objeto de 12 bytes: la copia no salió de ella. _FORTIFY_SOURCE usa el tamaño que el compilador conoce del destino, en este caso el campo nombre de 8 bytes, y aborta antes de copiar si el origen no cabe.

8. «Hola, Bartolomeo!» desbordó un arreglo de 16 bytes y el programa funcionó. ¿Por qué eso es malo?

Porque el error existe igual, y las pruebas no lo muestran: los 2 bytes de más cayeron en un lugar de la pila que nadie usaba. Con otro compilador, otra optimización u otra función, esos bytes pueden pisar algo que sí importa. Un programa con comportamiento indefinido no está casi bien; AddressSanitizer y _FORTIFY_SOURCE lo detectan.

9. ¿Qué devuelve strcmp("Zsh", "bash") en glibc, y qué se puede suponer sobre ese valor?

glibc devolvió −8, la diferencia entre 'Z' (90) y 'b' (98): «Zsh» va antes porque las mayúsculas tienen códigos menores que las minúsculas. Lo único que garantiza el estándar es el signo, negativo; el valor exacto puede cambiar con otra biblioteca o si el compilador calcula la comparación, así que se compara siempre con 0.

10. ¿Cómo se obtienen el directorio y el nombre de /usr/lib/libc.so.6 sin copiar la cadena?

Con strrchr(ruta, '/'), que devuelve un puntero a la última barra. El nombre es la cadena que empieza en barra + 1, y el directorio son los primeros barra - ruta caracteres, que se pueden mostrar con printf("%.*s", (int) (barra - ruta), ruta). Hay que comprobar antes que strrchr no haya devuelto NULL.

11. ¿Cuándo hay que usar memmove en lugar de memcpy o strcpy?

Cuando el origen y el destino pueden superponerse, como al desplazar una cadena dentro de su propio arreglo para quitarle los primeros caracteres. memcpy, strcpy y strcat no garantizan nada en ese caso, y pueden funcionar en un procesador y fallar en otro; memmove está obligada a copiar correctamente.

12. ¿Por qué cmdline.c lee sizeof buf - 1 bytes y después hace buf[n] = '\0'?

Porque read trabaja con bloques de bytes y no agrega ningún terminador. Para tratar el buffer con funciones de cadenas, el programa tiene que agregar el \0 después del último byte leído, y para eso deja libre la última posición del arreglo. Sin el terminador, strlen sobre la última cadena seguiría por los bytes sin inicializar del buffer.

13. ¿Qué significa la i de strlen en la salida de nm -D sobre libc?

Que es una función indirecta (ifunc): el símbolo apunta a una función que, al cargar el programa, mira las capacidades del procesador y elige entre varias versiones de strlen, como __strlen_sse2, __strlen_avx2 o __strlen_evex. Las llamadas posteriores van directamente a la versión elegida.

Para practicar

  1. Escribe tus propias versiones de strcpy, strcat y strcmp con punteros, y compara sus resultados con los de la biblioteca sobre los mismos datos, incluidas cadenas vacías y cadenas con ñ.
  2. Corrige desborde.c para que rechace con un mensaje los nombres que no caben, comprobando el largo con strlen antes de copiar.
  3. Escribe size_t contar(const char *s, const char *sub), que cuente cuántas veces aparece sub en s usando strstr en un bucle. ¿Cuántas veces aparece «aa» en «aaaa»: 2 o 3? Decide y justifica.
  4. Modifica cmdline.c para que lea /proc/PID/environ y muestre solo las variables que empiezan con un prefijo recibido como argumento, usando strncmp.
  5. Escribe void recortar(char *s), que quite los espacios y tabuladores del principio y del final de una cadena, en el mismo arreglo, usando strspn y memmove. Pruébala con AddressSanitizer y con una cadena formada solo por blancos.
  6. Escribe basename y dirname propias con strrchr, y compara sus resultados con las órdenes del mismo nombre para /usr/lib/, libc.so.6 y /.
  7. Compila saludo.c con -O2 y busca en el ensamblador, con gcc -S, la llamada a __strcat_chk. ¿Qué tamaño recibe como último argumento?