← Programación en C para Sistemas Operativos Bloque 2 · Tipos, operadores y control · Tema 10 de 42

Tema 10

Entrada y salida con formato

Desde el primer programa usamos printf, y con él pasaron varias cosas que dejamos para después: el %zu, el %%, los buffers que se perdían con _exit. Este tema cierra el bloque 2 ordenando todo eso. Veremos cómo se construye un especificador de formato, por qué los mensajes de stdout y stderr se desordenan al redirigirlos, cómo detectar que la escritura falló, y por qué pasar un texto del usuario como formato es una vulnerabilidad. Del lado de la entrada, veremos que scanf, la función que casi todos los cursos enseñan primero, tiene trampas graves, y aprenderemos la forma segura de leer: una línea completa con fgets, convertida después con cuidado.

1. Los flujos estándar

Todo proceso en Unix arranca con tres descriptores de archivo abiertos, que conoces del curso de Bash: el 0 es la entrada estándar, el 1 la salida estándar y el 2 la salida de errores. La biblioteca de C los envuelve en tres flujos (streams), declarados en stdio.h como punteros a una estructura FILE:

FlujoDescriptorParaFunciones típicasBuffer por omisión
stdin0Leer datosgetchar, fgets, scanfSí
stdout1Escribir resultadosprintf, puts, putcharPor líneas en una terminal; completo si no
stderr2Escribir errores y diagnósticosfprintf(stderr, …), perrorNinguno

Las funciones de stdio.h trabajan sobre flujos, no sobre descriptores, y agregan dos cosas a las llamadas al sistema del tema 1: el formato, que convierte números en texto y texto en números, y el buffer, que agrupa muchas escrituras pequeñas en pocas llamadas a write. Las dos tienen consecuencias que vale la pena conocer, empezando por la segunda.

2. Los buffers y el orden de la salida

Este programa alterna mensajes por stdout y por stderr:

orden.c
#include <stdio.h>

int main(void)
{
    printf("1. resultado a stdout\n");
    fprintf(stderr, "2. aviso a stderr\n");
    printf("3. otro resultado a stdout\n");
    fprintf(stderr, "4. otro aviso a stderr\n");
    return 0;
}
terminal · en la terminal, y con las dos salidas redirigidas a un archivo
$ ./orden
1. resultado a stdout
2. aviso a stderr
3. otro resultado a stdout
4. otro aviso a stderr
$ ./orden > todo.txt 2>&1; cat todo.txt
2. aviso a stderr
4. otro aviso a stderr
1. resultado a stdout
3. otro resultado a stdout
$ strace -e trace=write -o traza.txt ./orden > todo.txt 2>&1; grep write traza.txt
write(2, "2. aviso a stderr\n", 18)     = 18
write(2, "4. otro aviso a stderr\n", 23) = 23
write(1, "1. resultado a stdout\n3. otro re"..., 49) = 49

En la terminal, el orden es el del programa. Redirigido a un archivo, los avisos aparecen primero, y strace muestra por qué: los dos printf no produjeron ninguna llamada a write en su momento. Su texto quedó en el buffer de stdout, y se escribió todo junto, con una sola llamada de 49 bytes, al terminar el programa. stderr, en cambio, no tiene buffer, y cada fprintf fue un write inmediato.

El camino de la salida: printf deja el texto en el buffer de stdout, en la memoria del proceso, que se vacía con un write al núcleo cuando corresponde; fprintf a stderr no tiene buffer y cada llamada es un write inmediato. El núcleo lo entrega a la terminal, el archivo o la tubería printf stdout buffer de stdout en la memoria del proceso fprintf stderr núcleo write terminal, archivo o tubería se vacía: con \n si es una terminal, al llenarse, con fflush o al terminar sin buffer: cada llamada es un write
El buffer de stdout vive en el proceso, y el núcleo no sabe nada de él hasta que la biblioteca llama a write.

La biblioteca elige el modo de buffer de stdout al arrancar, según adónde apunte el descriptor 1:

Eso explica lo que vimos con _exit en el tema 3: el texto se perdía porque nunca había salido del buffer. Lo mismo le pasa a un programa que muere por una señal, como un Segmentation fault, con la salida redirigida: lo que printf «escribió» antes del error puede no aparecer nunca. Cuando el orden importa, o cuando el programa puede morir, fflush(stdout) vacía el buffer en el momento. También se puede cambiar el modo con setvbuf, pero casi nunca hace falta: lo normal es usar cada flujo para lo suyo y aceptar que, redirigidos al mismo lugar, pueden intercalarse distinto.

3. Anatomía de un especificador

La cadena de formato de printf es texto que se copia tal cual, salvo los especificadores de conversión, que empiezan con % y le indican cómo convertir cada argumento en texto. Un especificador tiene hasta seis partes, en un orden fijo, de las que solo la primera y la última son obligatorias:

Las partes del especificador %-08.3ld: el signo de porcentaje que lo inicia, las banderas menos y cero, el ancho 8, la precisión punto 3, el modificador de longitud l y la conversión d. Solo el porcentaje y la conversión son obligatorios % -0 8 .3 l d inicio banderas ancho precisión longitud conversión - + 0 # espacio mínimo dígitos o largo hh h l ll z j d u x f s … En verde, las dos partes obligatorias; el resto se puede omitir, pero respetando el orden.
Un especificador completo. %d es el caso mínimo: inicio y conversión.

La conversión dice qué tipo de argumento se espera y cómo mostrarlo. El ancho es la cantidad mínima de caracteres: si el valor ocupa menos, se rellena con espacios. La precisión significa cosas distintas según la conversión: decimales para %f, cantidad máxima de caracteres para %s, dígitos mínimos para los enteros. Las banderas ajustan el relleno y los signos. Y la longitud indica el tamaño del argumento entero, de la que depende que printf lea los bytes correctos. Para escribir un % literal se usa %%, como vimos en el tema 3.

4. Conversiones, ancho y precisión

Este programa muestra la mayoría de las combinaciones útiles, y los corchetes permiten ver el ancho exacto de cada resultado:

tabla.c
#include <stdio.h>

int main(void)
{
    const char *nombres[] = {"bash", "systemd", "sshd"};
    int pids[] = {1532, 1, 845};
    double memoria[] = {5.25, 12.5, 7.125};

    printf("%-10s %6s %10s\n", "PROCESO", "PID", "MEMORIA");
    for (int i = 0; i < 3; i++)
        printf("%-10s %6d %8.2f MB\n", nombres[i], pids[i], memoria[i]);

    printf("\n");
    printf("[%d] [%5d] [%-5d] [%05d] [%+d]\n", 42, 42, 42, 42, 42);
    printf("[%x] [%X] [%#x] [%08x] [%o] [%#o]\n", 255, 255, 255, 255, 8, 8);
    printf("[%f] [%.2f] [%10.3f] [%e] [%g] [%g]\n", 3.14159, 3.14159, 3.14159, 31415.9, 3.14159, 0.00001234);
    printf("[%s] [%10s] [%-10s] [%.3s]\n", "kernel", "kernel", "kernel", "kernel");
    printf("[%c] [%c] [%%] [%p]\n", 'A', 66, (void *) nombres);
    printf("[%*d] [%.*s]\n", 6, 42, 4, "sistema");
    return 0;
}
terminal
$ ./tabla
PROCESO       PID    MEMORIA
bash         1532     5.25 MB
systemd         1    12.50 MB
sshd          845     7.12 MB

[42] [   42] [42   ] [00042] [+42]
[ff] [FF] [0xff] [000000ff] [10] [010]
[3.141590] [3.14] [     3.142] [3.141590e+04] [3.14159] [1.234e-05]
[kernel] [    kernel] [kernel    ] [ker]
[A] [B] [%] [0x7ffde6660ab0]
[    42] [sist]
ConversiónArgumentoMuestra
%d, %iintEntero con signo, en decimal.
%u, %x, %X, %ounsigned intSin signo, en decimal, hexadecimal o octal.
%cintEl carácter con ese código.
%schar *La cadena, hasta el \0.
%f, %e, %gdoubleDecimal fijo, notación científica, o la más corta de las dos.
%pvoid *Una dirección, en hexadecimal.
%%(ninguno)Un signo %.
Bandera o campoEfectoEjemplo
ancho 5Mínimo de caracteres, alineado a la derecha.[%5d] → [ 42]
-Alinear a la izquierda.[%-5d] → [42 ]
0Rellenar con ceros en lugar de espacios.[%05d] → [00042]
+Mostrar siempre el signo.[%+d] → [+42]
#Forma alternativa: prefijo 0x u 0.[%#x] → [0xff]
.2 con %fDecimales.[%.2f] → [3.14]
.3 con %sMáximo de caracteres de la cadena.[%.3s] → [ker]
*Ancho o precisión tomados de un argumento int.[%*d] con 6 y 42 → [ 42]

La primera tabla del programa es el uso más común de todo esto: columnas alineadas, con los textos a la izquierda (%-10s) y los números a la derecha (%6d), como las de ps o ls -l. Un detalle curioso: 7,125 se mostró como 7,12 y no 7,13. Ese número tiene representación exacta en binario, y la biblioteca redondea los empates al dígito par. El ancho es un mínimo, no un máximo: un valor más largo se muestra completo y desalinea la columna. Para recortar texto está la precisión de %s, y %.*s permite mostrar los primeros n bytes de una cadena, algo útil con datos que no terminan en \0.

5. Los modificadores de longitud

printf no sabe qué tipos recibe: los argumentos llegan uno tras otro, y solo el formato le dice cuántos bytes leer para cada uno y cómo interpretarlos. Por eso el modificador de longitud tiene que coincidir exactamente con el tipo del argumento:

ModificadorCon d o iCon u, x, o
hhsigned charunsigned char
hshortunsigned short
(ninguno)intunsigned int
llongunsigned long
lllong longunsigned long long
zssize_tsize_t
jintmax_tuintmax_t
tptrdiff_t—

Los tipos de stdint.h usan las macros PRId64 y compañía de inttypes.h, como vimos en el tema 5. Para double no hace falta modificador, ni siquiera para float, que al pasar a una función con argumentos variables se convierte a double; L es para long double. Un desacuerdo entre formato y argumento es comportamiento indefinido, y es uno de los errores que -Wall mejor detecta:

terminal · mal.c: printf("%d %s\n", grande, 42) con long grande
$ gcc -std=c17 -Wall -Wextra -o mal mal.c 2>&1 | grep warning
mal.c:5:14: warning: format ‘%d’ expects argument of type ‘int’, but argument 2 has type ‘long int’ [-Wformat=]
mal.c:5:17: warning: format ‘%s’ expects argument of type ‘char *’, but argument 3 has type ‘int’ [-Wformat=]

El segundo es el más peligroso: %s trata el 42 como una dirección y va a leer la memoria en la dirección 42, lo que termina el programa con Segmentation fault. El compilador solo puede hacer esta comprobación cuando el formato es una cadena literal, lo que lleva a la sección 8.

6. La familia de printf

printf tiene varios parientes que comparten el mismo lenguaje de formato y solo cambian el destino:

FunciónEscribe enComentario
printf(fmt, …)stdoutEquivale a fprintf(stdout, fmt, …).
fprintf(flujo, fmt, …)Cualquier flujostderr, o un archivo abierto con fopen.
dprintf(fd, fmt, …)Un descriptorPOSIX: sin pasar por un FILE.
snprintf(buf, tam, fmt, …)Una cadena en memoriaNunca escribe más de tam bytes. La forma segura.
sprintf(buf, fmt, …)Una cadena en memoriaNo conoce el tamaño del destino: puede desbordarlo. No usar.

La diferencia entre las dos últimas se ve al intentar escribir un texto de 11 caracteres en un arreglo de 8:

terminal · sprintf y snprintf con el mismo destino de 8 bytes
$ gcc -std=c17 -Wall -Wextra -O2 -o spr spr.c
spr.c:5:25: warning: ‘%d’ directive writing 7 bytes into a region of size 4 [-Wformat-overflow=]
…
$ ./spr
*** buffer overflow detected ***: terminated
Aborted
$ ./snp
[pid=123] necesitaba 11

sprintf intentó escribir 12 bytes, con el \0 final, en 8. Esta vez lo salvó la protección _FORTIFY_SOURCE del tema 4, que con -O2 cambia sprintf por una versión que comprueba el tamaño y aborta; sin ella, habría pisado la memoria vecina en silencio. snprintf escribió lo que cabía, siempre con su \0, y devolvió 11, la cantidad de caracteres que habría necesitado: comparando ese valor con el tamaño se sabe si el texto se recortó. El tema 22 trata snprintf en detalle.

7. Cuando la escritura falla

printf devuelve la cantidad de caracteres que escribió, o un número negativo si falló. Casi nadie lo comprueba, y en una terminal casi nunca falla. Pero cuando la salida es un archivo, el disco puede llenarse, y un programa de sistemas que ignora eso puede terminar «bien» habiendo perdido datos. Linux tiene un dispositivo especial para probarlo, /dev/full, en el que toda escritura falla como si el disco estuviera lleno:

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

int main(void)
{
    int n = printf("hola, disco\n");

    fprintf(stderr, "printf devolvió %d\n", n);
    if (fclose(stdout) != 0) {
        perror("error al escribir la salida");
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}
terminal · lleno.c y una versión sin el fclose, escribiendo en /dev/full
$ ./lleno > /dev/full; echo $?
printf devolvió 12
error al escribir la salida: No space left on device
1
$ ./lleno2 > /dev/full; echo $?          # solo printf, sin comprobar nada
0

printf devolvió 12, éxito, porque solo puso el texto en el buffer; el error ocurrió después, al intentar vaciarlo. Por eso comprobar el valor de printf no alcanza, y la forma correcta es comprobar el resultado de fflush o fclose sobre stdout antes de terminar. lleno2, que no comprueba nada, terminó con código 0 aunque no escribió un solo byte: un script que lo usara creería que todo salió bien. Las herramientas de GNU, como cat o sort, hacen exactamente esta comprobación al final, y perror, que imprime el mensaje del último error del sistema, es parte del tema 37.

8. La entrada como formato: una vulnerabilidad

Hay un error con printf que no es una cuestión de prolijidad sino de seguridad: usar un texto que viene de afuera como cadena de formato. Parece inofensivo, porque con un texto normal funciona:

eco.c
#include <stdio.h>

int main(int argc, char *argv[])
{
    int secreto = 0x5ec2e70;

    if (argc < 2)
        return 1;
    printf(argv[1]);              /* error: la entrada usada como formato */
    printf("\n");
    printf("%s\n", argv[1]);      /* correcto */
    return secreto == 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -o eco eco.c
eco.c: In function ‘main’:
eco.c:9:5: warning: format not a string literal and no format arguments [-Wformat-security]
    9 |     printf(argv[1]);              /* error: la entrada usada como formato */
      |     ^~~~~~
$ ./eco hola
hola
hola
$ ./eco '%p %p %p %p %p %p'
0x7fffe0704838 0x7fffe0704850 0x653994c6ddb0 0x758ede412680 0x758ede413fa0 0x7fffe0704838
%p %p %p %p %p %p

Con un texto que contiene especificadores, printf los interpreta, y como no hay argumentos, toma lo que haya en los registros y en la pila donde deberían estar: direcciones de la pila (0x7fff…), del programa (0x6539…) y de las bibliotecas (0x758e…). Esa información es justamente la que ASLR esconde, y alcanza para que un atacante sepa dónde está cada cosa en la memoria. Con %s, printf lee la memoria apuntada por esos valores; y con %n, un especificador que escribe en la memoria la cantidad de caracteres impresos, se puede modificar el proceso. Es la vulnerabilidad de cadena de formato, que afectó a muchos programas reales.

La regla es absoluta: el formato es siempre una cadena literal escrita en el programa, y los datos van como argumentos, printf("%s", texto). Si solo hay que mostrar un texto, fputs(texto, stdout) lo hace sin interpretar nada. Ubuntu enciende -Wformat-security por omisión, y por eso la advertencia apareció aunque no la pedimos.

9. Leer con scanf

scanf es el espejo de printf: lee texto de stdin y lo convierte según un formato. Como tiene que guardar los valores en variables del llamador, recibe sus direcciones, con &; los arreglos de caracteres ya son direcciones, y van sin &. Devuelve la cantidad de conversiones que logró hacer, o EOF si la entrada terminó antes de la primera.

edad.c
#include <stdio.h>

int main(void)
{
    int edad;
    char nombre[8];

    printf("edad: ");
    int leidos = scanf("%d", &edad);
    printf("scanf devolvió %d, edad = %d\n", leidos, leidos == 1 ? edad : -1);

    printf("nombre: ");
    leidos = scanf("%s", nombre);
    printf("scanf devolvió %d, nombre = \"%s\"\n", leidos, nombre);
    return 0;
}
terminal · con una entrada correcta
$ printf '30\nana\n' | ./edad
edad: scanf devolvió 1, edad = 30
nombre: scanf devolvió 1, nombre = "ana"

Con datos correctos, funciona. El problema de scanf es todo lo demás. Sus reglas de lectura son sutiles: %d y %s saltan los espacios y saltos de línea iniciales, %s lee hasta el siguiente espacio, no hasta el final de la línea, y lo que no se pudo convertir queda en la entrada esperando a la próxima lectura. Cada una de esas reglas produce un error distinto.

10. Los peligros de scanf

Con el mismo edad.c y otras entradas, aparecen cuatro problemas:

terminal · cuatro entradas problemáticas
$ printf 'treinta\nana\n' | ./edad
edad: scanf devolvió 0, edad = -1
nombre: scanf devolvió 1, nombre = "treinta"
$ printf '99999999999\nana\n' | ./edad
edad: scanf devolvió 1, edad = 1215752191
nombre: scanf devolvió 1, nombre = "ana"
$ printf '30\nmaximiliano\n' | ./edad
*** stack smashing detected ***: terminated
Aborted
$ printf 'abc\n42\n' | ./bucle
ingrese un número: eso no es un número, otra vez: eso no es un número, otra vez: …
(cinco vueltas sin avanzar: abandono)

Hay un quinto problema, más sutil, al mezclar scanf con lecturas de líneas: scanf("%d") deja en la entrada el \n que sigue al número, y un fgets posterior lee esa línea vacía en lugar de la siguiente. El origen de todos estos problemas es el mismo: scanf mezcla en una sola operación leer y convertir, y cuando la conversión falla, no queda claro cuánto se leyó. La solución es separar las dos cosas.

11. Leer líneas con fgets

fgets(buf, tam, flujo) lee una línea, incluido su \n, y la guarda en buf como cadena, terminada en \0. Nunca escribe más de tam bytes: si la línea es más larga, guarda lo que cabe, tam - 1 caracteres más el \0, y el resto queda para la próxima llamada. Devuelve NULL cuando ya no hay nada que leer.

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

int main(void)
{
    char linea[16];
    int numero = 1;

    while (fgets(linea, sizeof linea, stdin) != NULL) {
        size_t largo = strlen(linea);
        int completa = largo > 0 && linea[largo - 1] == '\n';

        linea[strcspn(linea, "\n")] = '\0';     /* quitar el \n, si lo hay */
        printf("%2d: [%s]%s\n", numero++, linea, completa ? "" : "  (sigue...)");
    }
    return 0;
}
terminal · con un buffer de 16 bytes, a propósito pequeño
$ printf 'corta\nuna línea bastante más larga que el buffer\n\nfin' | ./lineas
 1: [corta]
 2: [una línea bast]  (sigue...)
 3: [ante más larga]  (sigue...)
 4: [ que el buffer]
 5: []
 6: [fin]  (sigue...)

La línea larga llegó en tres pedazos, sin desbordar nada: cada fgets tomó lo que cabía, y el siguiente siguió desde ahí. El programa sabe si una lectura completó la línea mirando si termina en \n; la última, «fin», no lo tiene porque el archivo no termina en salto de línea. Hay dos modismos que se repiten siempre con fgets:

Con un buffer de tamaño razonable, como 256 o 4096 bytes, una línea partida es rara, pero un programa robusto la contempla: puede tratarla como un error, o seguir leyendo hasta encontrar el \n y descartar el resto.

12. Convertir lo leído

Una vez leída la línea, convertirla en un número es otro paso, que se puede hacer con cuidado. La función adecuada es strtol, de stdlib.h, que informa dónde dejó de leer y si el valor se salió de rango. Su uso completo es tema del tema 22; aquí la usamos en una función que pregunta hasta obtener un entero válido:

numero.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <limits.h>

/* lee un entero de una línea completa; devuelve 1 si lo logró, 0 si no */
static int leer_entero(const char *pregunta, int *resultado)
{
    char linea[64];

    for (;;) {
        printf("%s", pregunta);
        fflush(stdout);
        if (fgets(linea, sizeof linea, stdin) == NULL)
            return 0;                               /* fin de la entrada */

        char *fin;
        errno = 0;
        long valor = strtol(linea, &fin, 10);       /* detalles en el tema 22 */

        while (*fin == ' ' || *fin == '\t' || *fin == '\n')
            fin++;
        if (fin == linea || *fin != '\0')
            fprintf(stderr, "  no es un número entero\n");
        else if (errno == ERANGE || valor < INT_MIN || valor > INT_MAX)
            fprintf(stderr, "  fuera de rango\n");
        else {
            *resultado = (int) valor;
            return 1;
        }
    }
}

int main(void)
{
    int edad;

    if (!leer_entero("edad: ", &edad)) {
        fprintf(stderr, "no se ingresó la edad\n");
        return EXIT_FAILURE;
    }
    printf("edad = %d\n", edad);
    return EXIT_SUCCESS;
}
terminal · las mismas entradas que hacían fallar a scanf
$ printf 'treinta\n30 años\n99999999999\n  42  \n' | ./numero
edad:   no es un número entero
edad:   no es un número entero
edad:   fuera de rango
edad: edad = 42
$ printf 'abc\n' | ./numero; echo $?
edad:   no es un número entero
edad: no se ingresó la edad
1

Cada problema de la sección 10 está resuelto. Una entrada inválida se descarta entera, porque se leyó la línea completa, y la pregunta se repite sin desfasarse ni quedar en un bucle infinito. «30 años» se rechaza, porque después del número queda texto que no es espacio; scanf lo habría aceptado como 30. El valor fuera de rango se detecta. Y si la entrada se termina, la función lo informa en lugar de repetir para siempre. El fflush(stdout) después de la pregunta asegura que se vea antes de esperar la respuesta, aunque la salida no esté conectada a una terminal.

Es más código que un scanf, y por eso se escribe una vez, en una función como leer_entero, y se reutiliza. Un programa de sistemas que lee configuración, argumentos o datos de otro programa no puede suponer que la entrada es correcta.

13. Líneas de cualquier largo y la terminal

fgets necesita un buffer de tamaño fijo. POSIX ofrece getline, que pide memoria y la agranda según haga falta, de modo que lee líneas de cualquier largo:

largas.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    char *linea = NULL;          /* getline la pide y la agranda */
    size_t capacidad = 0;
    ssize_t largo;

    while ((largo = getline(&linea, &capacidad, stdin)) != -1)
        printf("línea de %zd bytes (capacidad del buffer: %zu)\n", largo, capacidad);
    free(linea);
    return 0;
}
terminal · una línea corta, una de 5000 caracteres y otra corta
$ (echo corta; head -c 5000 /dev/zero | tr '\0' 'x'; echo; echo fin) | ./largas
línea de 6 bytes (capacidad del buffer: 120)
línea de 5001 bytes (capacidad del buffer: 8194)
línea de 4 bytes (capacidad del buffer: 8194)

getline empezó con un buffer de 120 bytes y lo agrandó al encontrar la línea larga; después lo reutilizó. Además devuelve el largo de lo leído, de modo que funciona incluso con líneas que contienen bytes nulos. A cambio, la memoria la pidió la biblioteca, y el programa tiene que devolverla con free al terminar: la memoria dinámica es el bloque 7.

Por último, un programa de sistemas a veces necesita saber si está hablando con una persona o con otro programa: mostrar una pregunta solo si hay alguien que la lea, usar colores solo en una terminal, como hace ls. La función isatty, de unistd.h, lo responde para cada descriptor:

terminal · terminal.c, ejecutado directamente y en una tubería
$ ./terminal
stdin  es una terminal
stdout es una terminal
stderr es una terminal
$ echo | ./terminal | cat
stdin  no es una terminal
stdout no es una terminal
stderr es una terminal

Es la misma pregunta que se hace la biblioteca de C al decidir el modo de buffer de stdout, en la sección 2, y la que se hacía Bash con [ -t 0 ].

14. Errores frecuentes

ErrorQué pasaCorrección
printf(texto)Vulnerabilidad de cadena de formato.printf("%s", texto) o fputs(texto, stdout).
Formato que no coincide con el tipoComportamiento indefinido; %s con un entero termina el programa.Respetar la tabla de la sección 5; compilar con -Wall.
%d para size_t o longSe leen los bytes equivocados.%zu, %ld.
sprintfDesborda el destino.snprintf con sizeof.
No comprobar la escrituraDatos perdidos con el disco lleno, y código de salida 0.Comprobar fflush o fclose(stdout).
Esperar el orden entre stdout y stderrRedirigidos, se intercalan distinto por el buffer.fflush(stdout) donde importe.
Olvidar el & en scanf("%d", n)scanf escribe en una dirección inválida.&n; -Wall lo advierte.
scanf("%s", buf)Desborda buf.fgets.
Repetir scanf hasta que funcioneBucle infinito: la entrada inválida no se consume.Leer la línea con fgets y convertirla.
fgets después de scanfLee el \n que dejó scanf: una línea vacía.No mezclar: leer todo por líneas.
No quitar el \n de fgetsComparaciones que fallan, nombres de archivo con \n.linea[strcspn(linea, "\n")] = '\0'.
Usar getsDesborda siempre que la línea sea larga; ya no existe en C11.fgets o getline.

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Por qué, al ejecutar ./orden > todo.txt 2>&1, los avisos de stderr aparecen antes que los resultados de stdout?

Porque al redirigir a un archivo, stdout usa un buffer completo: los printf guardaron el texto en la memoria del proceso y no lo escribieron hasta el final del programa. stderr no tiene buffer, y cada fprintf fue un write inmediato. strace muestra los dos write de stderr antes del único write de stdout.

2. ¿Qué significan las partes de %-08.3ld?

% inicia el especificador; - y 0 son banderas (alinear a la izquierda y rellenar con ceros, aunque con - el 0 se ignora); 8 es el ancho mínimo; .3 la precisión, que en un entero es la cantidad mínima de dígitos; l indica que el argumento es long; y d, que se muestra como entero con signo en decimal.

3. ¿Qué diferencia hay entre %10s y %.10s?

%10s es un ancho mínimo: si la cadena tiene menos de 10 caracteres se rellena con espacios a la izquierda, y si tiene más se muestra completa. %.10s es una precisión: muestra como máximo 10 caracteres de la cadena, recortándola si es más larga.

4. ¿Por qué printf("%d", sizeof x) es un error en Linux de 64 bits?

Porque sizeof devuelve un size_t, de 8 bytes, y %d le dice a printf que lea un int, de 4. printf no conoce los tipos de sus argumentos; el desacuerdo es comportamiento indefinido. El formato correcto es %zu.

5. En lleno.c, ¿por qué printf devolvió 12 si la escritura en /dev/full falló?

Porque printf solo copió el texto al buffer de stdout, y eso funcionó. El error ocurrió después, cuando la biblioteca llamó a write para vaciar el buffer. Por eso hay que comprobar el resultado de fflush o de fclose(stdout), que es cuando se descubre el fallo.

6. ¿Por qué printf(argv[1]) es una vulnerabilidad y no solo un descuido?

Porque si el texto contiene especificadores, printf los interpreta y toma «argumentos» de los registros y de la pila: con %p o %x revela direcciones y datos del proceso, con %s lee memoria arbitraria, y con %n escribe en ella. Un atacante que controle el texto controla parte del comportamiento del programa. Se usa siempre un formato literal: printf("%s", argv[1]).

7. ¿Por qué bucle.c no logra leer el 42 de la segunda línea?

Porque scanf("%d") falla con «abc» sin consumir los caracteres: la «a» queda en la entrada. Cada vuelta del bucle vuelve a intentar convertir la misma «a» y vuelve a fallar, de modo que el bucle nunca avanza hasta el 42. Sin un límite, sería un bucle infinito.

8. ¿Qué pasó con scanf("%s", nombre) y la entrada «maximiliano»?

%s no conoce el tamaño del arreglo y copió los 11 caracteres más el \0 en un arreglo de 8 bytes, escribiendo fuera de él en la pila. El protector de pila detectó que se había pisado su valor testigo al terminar main y abortó con stack smashing detected.

9. ¿Qué hace fgets con una línea más larga que su buffer, y cómo se sabe que ocurrió?

Guarda lo que cabe, tam - 1 caracteres y el \0, y deja el resto de la línea para la siguiente llamada. Se sabe que la línea no se completó porque lo leído no termina en \n (salvo la última línea de un archivo sin salto de línea final).

10. ¿Por qué la combinación de fgets y strtol es más segura que scanf("%d")?

Porque separa la lectura de la conversión. fgets consume la línea completa sin desbordar, de modo que una entrada inválida se descarta entera y no queda en la entrada. strtol indica dónde dejó de convertir, lo que permite rechazar texto sobrante como «30 años», e informa con errno si el valor está fuera de rango, algo que scanf no puede detectar.

11. ¿Qué ventaja tiene getline sobre fgets, y qué obligación agrega?

getline lee líneas de cualquier largo, pidiendo y agrandando la memoria según haga falta, y devuelve el largo leído. A cambio, esa memoria la pidió la biblioteca para el programa, y el programa tiene que liberarla con free cuando termina de usarla.

Para practicar

  1. Modifica orden.c para que, redirigido a un archivo, conserve el orden del programa. Prueba dos soluciones: fflush(stdout) después de cada printf, y setvbuf(stdout, NULL, _IOLBF, 0) al principio. Compara las llamadas a write con strace.
  2. Escribe un programa que muestre, a partir de un número leído con leer_entero, una tabla de sus representaciones en decimal, octal, hexadecimal con y sin prefijo, y con 8 dígitos rellenos de ceros.
  3. Imita la salida de ls -l para tres archivos de ejemplo con printf: permisos, cantidad de enlaces alineada a la derecha, dueño alineado a la izquierda, tamaño alineado a la derecha y nombre.
  4. Prueba ./tabla > /dev/full y ./lleno > /dev/full, y compara los códigos de salida. Agrega a tabla.c la comprobación de fclose(stdout).
  5. Escribe leer_linea(char *buf, size_t tam) que use fgets, quite el \n, y si la línea era más larga que el buffer, descarte el resto hasta el \n y devuelva un código que lo indique.
  6. Modifica numero.c para que leer_entero reciba un mínimo y un máximo, y rechace los valores fuera de ese rango con un mensaje que los indique.
  7. Usa getline para escribir un programa que muestre la línea más larga de su entrada y su número de línea. Pruébalo con ./masLarga < /etc/services.