← Fundamentos de Sistemas Operativos Bloque 3 · El kernel y las llamadas al sistema · Tema 14 de 34

Tema 14

Llamadas al sistema en C

En el tema anterior hablamos con el kernel en ensamblador, cargando registros a mano. Nadie programa así. En este tema hacemos lo mismo desde C: usamos write, read y getpid, aprendemos a interpretar lo que devuelven y a tratar sus errores, y terminamos con la función syscall(), que permite invocar cualquier llamada por su número.

1. Del ensamblador a C

El programa hola.s del tema 13 necesitó cinco instrucciones para mostrar un texto: cuatro para cargar registros y una para entrar al kernel. Funcionaba, pero tenía tres inconvenientes serios. Había que recordar que write es la llamada número 1; había que saber en qué registro va cada argumento; y el programa solo servía para procesadores x86-64, porque en ARM cambian los números, los registros y la instrucción.

En C, la misma operación se escribe así:

la misma llamada, en C
write(1, "Hola, kernel!\n", 14);

Sin números de llamada, sin registros y válida en cualquier arquitectura. Esa línea no es una instrucción del lenguaje: C no sabe nada de llamadas al sistema. write es una función común, que alguien escribió y que está en la biblioteca de C. Como anticipamos en el tema 10, su único trabajo es hacer la llamada al sistema por nosotros.

Un envoltorio (wrapper) es una función de la biblioteca de C que tiene el mismo nombre y los mismos parámetros que una llamada al sistema, y que se limita a realizarla: coloca el número y los argumentos en los registros, ejecuta la instrucción de entrada al kernel y entrega el resultado al programa.

Las funciones de este tema son todas envoltorios. Se declaran, casi todas, en el archivo de cabecera unistd.h, cuyo nombre viene de «Unix standard».

2. Qué hace un envoltorio

La declaración de write que aparece en man 2 write es esta:

prototipo de write
#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

Cada parámetro corresponde a uno de los registros de la convención del tema 13, en el mismo orden. Al compilar la línea de la sección anterior, ocurre lo siguiente:

En el código CValorLo que hace el envoltorio
El nombre write—Pone el número 1 en rax
fd1Lo deja en rdi
bufdirección del textoLa deja en rsi
count14Lo deja en rdx
——Ejecuta syscall
El valor devuelto14Lo toma de rax y lo retorna

Es decir, el envoltorio contiene las mismas instrucciones que escribimos a mano en hola.s. La versión de la biblioteca para ARM contiene las equivalentes para ese procesador, y por eso el programa en C es portable: lo que cambia de una arquitectura a otra queda encerrado en la biblioteca.

Conviene tener presente también lo que el envoltorio no hace. No comprueba que el descriptor exista, ni que la dirección sea válida, ni que haya permiso para escribir. Todas las verificaciones las hace el kernel, como vimos en el tema 13. El envoltorio es un mensajero.

La declaración usa tres tipos de datos que aparecen en casi todas las llamadas al sistema:

TipoQué representaEn Linux de 64 bitsFormato en printf
size_tUna cantidad de bytes. No puede ser negativa.Entero sin signo de 64 bits%zu
ssize_tUna cantidad de bytes, o −1 para indicar error.Entero con signo de 64 bits%zd
pid_tUn identificador de proceso.Entero con signo de 32 bits%d

La diferencia entre los dos primeros es la letra s inicial, de signed. write recibe la cantidad en un size_t, porque no tiene sentido pedir que se escriban −5 bytes, y devuelve un ssize_t, porque necesita un valor negativo para avisar de un fallo.

3. write: enviar bytes

write entrega una secuencia de bytes al destino asociado a un descriptor de archivo, un número entero pequeño con el que el proceso identifica cada archivo o dispositivo que tiene abierto. Los estudiaremos en el tema 28. Por ahora basta con saber que todo proceso recibe tres ya abiertos al comenzar:

DescriptorNombreConstante en unistd.hConectado normalmente a
0Entrada estándarSTDIN_FILENOEl teclado
1Salida estándarSTDOUT_FILENOLa pantalla de la terminal
2Salida de erroresSTDERR_FILENOLa pantalla de la terminal

El siguiente programa escribe un mensaje en la salida estándar y, a diferencia de hola_write.c del tema 1, examina lo que write devuelve:

escribir.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(void)
{
    const char *mensaje = "Hola desde C\n";
    ssize_t n;

    n = write(STDOUT_FILENO, mensaje, strlen(mensaje));
    if (n == -1) {
        perror("write");
        return 1;
    }

    printf("write devolvio %zd\n", n);
    return 0;
}
terminal
$ gcc -Wall -Wextra -g escribir.c -o escribir
$ ./escribir
Hola desde C
write devolvio 13
$ ./escribir > salida.txt          # el descriptor 1 apunta ahora a un archivo
$ cat salida.txt
Hola desde C
write devolvio 13
$ ./escribir > /dev/full            # un dispositivo que simula un disco lleno
write: No space left on device
$ echo $?
1

Tres detalles del programa:

La tercera ejecución muestra por qué hay que comprobar el resultado. Un programa que ignora el valor de retorno de write habría terminado con código 0, como si todo hubiera ido bien, después de perder sus datos.

4. Cuando la llamada falla: errno

En el tema 13 vimos que el kernel informa un error dejando en rax un número negativo: −9 para un descriptor inválido, −13 para un permiso denegado. Pero el programa anterior comparó el resultado con −1, no con −9. La diferencia la introduce el envoltorio, que traduce la convención del kernel a la que usan los programas en C:

Lo que deja el kernel en raxLo que recibe el programa en C
ÉxitoUn valor mayor o igual que ceroEse mismo valor
ErrorEl código de error cambiado de signo: -9Siempre -1, y el código queda en la variable errno: 9

La lógica que añade el envoltorio después de la instrucción syscall es, en esencia, esta:

el final de un envoltorio, en pseudocódigo
long r = /* valor que el kernel dejó en rax */;

if (r < 0 && r >= -4095) {      /* el rango reservado para los errores */
    errno = -r;                 /* guardar el código, ya positivo */
    return -1;
}
return r;

errno es una variable entera declarada en errno.h. Ese mismo archivo define un nombre para cada código, los que ya conocemos: EBADF, EACCES, ENOENT. Hay además dos funciones que convierten el código en un texto legible:

errores.c
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(void)
{
    ssize_t n = write(99, "Hola\n", 5);     /* el descriptor 99 no está abierto */

    if (n == -1) {
        int codigo = errno;                 /* guardarlo antes de llamar a nada más */

        printf("write devolvio: %zd\n", n);
        printf("errno vale:     %d\n", codigo);
        printf("significa:      %s\n", strerror(codigo));

        if (codigo == EBADF)
            printf("El descriptor no es valido\n");
    }
    return 0;
}
terminal
$ gcc -Wall -Wextra -g errores.c -o errores
$ ./errores
write devolvio: -1
errno vale:     9
significa:      Bad file descriptor
El descriptor no es valido

Es la misma situación de hola_error.s, vista desde el otro lado del envoltorio: allí recibimos −9 directamente; aquí, −1 y un 9 en errno. Y es también lo que strace mostraba como -1 EBADF (Bad file descriptor).

El uso de errno tiene tres reglas, y las tres se olvidan con facilidad:

  1. Solo tiene sentido después de un fallo. Una llamada que termina bien no pone errno en cero: lo deja como estaba. Primero se comprueba el valor de retorno y, solo si indica error, se consulta errno.
  2. Hay que consultarlo de inmediato. Cualquier función llamada después puede modificarlo, incluida printf. Por eso el programa lo copia a una variable propia en la primera línea del if.
  3. Se compara con los nombres, no con los números. EBADF vale 9 en Linux sobre x86-64, pero el estándar solo garantiza el nombre.

En la práctica, la forma habitual de tratar un error es la que ya usamos varias veces en el curso, y que seguiremos usando:

el patrón de toda llamada al sistema
if (write(fd, datos, cantidad) == -1) {     /* 1. llamar y comprobar */
    perror("write");                        /* 2. informar el motivo */
    return 1;                               /* 3. decidir qué hacer  */
}
Una variable por hilo

Un programa puede tener varios hilos de ejecución haciendo llamadas a la vez. Si errno fuera una variable global corriente, el error de un hilo pisaría el de otro. La biblioteca lo evita dando a cada hilo su propio errno; el nombre es en realidad una macro que selecciona el del hilo actual.

5. read: recibir bytes

read es la operación inversa. Su declaración es casi idéntica a la de write:

prototipo de read
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

Pide al kernel que copie como máximo count bytes desde el origen asociado al descriptor hacia la zona de memoria que empieza en buf. Esa zona tiene que existir de antemano: la reserva el programa, y el tercer argumento le dice al kernel cuánto espacio hay. El valor devuelto tiene tres significados posibles:

Valor devueltoSignificado
Mayor que ceroLa cantidad de bytes que el kernel dejó en el búfer. Puede ser menor que la pedida.
0Fin de los datos: no queda nada por leer y no llegará nada más.
-1Error. El motivo está en errno.
leer.c
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    char bufer[64];
    ssize_t n;

    n = read(STDIN_FILENO, bufer, sizeof(bufer));
    if (n == -1) {
        perror("read");
        return 1;
    }

    printf("read devolvio %zd\n", n);
    printf("Contenido: [%.*s]\n", (int)n, bufer);   /* mostrar solo n bytes */
    return 0;
}
terminal
$ gcc -Wall -Wextra -g leer.c -o leer
$ ./leer
hola                               # lo escribimos nosotros, y pulsamos Enter
read devolvio 5
Contenido: [hola
]
$ echo -n "abc" | ./leer          # la entrada viene de otro programa
read devolvio 3
Contenido: [abc]
$ ./leer < /dev/null               # un origen sin datos
read devolvio 0
Contenido: []

Varias cosas que este programa pone a la vista:

6. Menos bytes de los pedidos

El argumento count es un límite, no un compromiso. Tanto read como write pueden transferir menos bytes de los solicitados y terminar con éxito. Es una de las fuentes de errores más comunes en la programación de sistemas, porque en las pruebas sencillas casi nunca ocurre.

LlamadaDevuelve menos de lo pedido cuando…
readel origen es la terminal: entrega una línea por vez.
el origen es una tubería o una conexión de red, y todavía no llegó todo.
quedan en el archivo menos bytes que los pedidos.
writeel destino es una tubería o una conexión de red, y su búfer interno se llenó.
el disco se queda sin espacio a mitad de la operación.

La consecuencia es que una sola llamada no alcanza cuando se necesita transferir una cantidad exacta. Hay que repetirla hasta completar, avanzando en el búfer lo que cada llamada haya logrado. Esta función lo hace para write:

escribir_todo: insistir hasta enviar todos los bytes
/* Escribe exactamente n bytes. Devuelve 0 si lo logra y -1 si hay un error. */
static int escribir_todo(int fd, const char *datos, size_t n)
{
    while (n > 0) {
        ssize_t escritos = write(fd, datos, n);

        if (escritos == -1)
            return -1;

        datos += escritos;              /* avanzar sobre lo ya enviado */
        n -= (size_t)escritos;          /* y descontar lo que falta    */
    }
    return 0;
}

Si el kernel acepta los 4096 bytes de una vez, el bucle da una sola vuelta. Si acepta 1000, la segunda vuelta pide los 3096 restantes a partir de donde quedó. Para read el esquema es el mismo, con una salida adicional: detenerse cuando devuelve cero.

Un error que no lo es

Una llamada bloqueada puede ser interrumpida por una señal antes de transferir nada. En ese caso devuelve −1 con errno igual a EINTR, que no indica un fallo sino «vuelve a intentarlo». Los programas de este curso no instalan manejadores de señales, así que no lo encontrarán, pero lo verás tratado en casi todo el código de sistemas. Las señales se estudian en el curso de procesos.

7. Un programa real: copiar la entrada en la salida

Con read, write y el tratamiento de errores ya se puede escribir un programa útil: uno que copia todo lo que recibe por la entrada estándar en la salida estándar. Es lo que hace el comando cat cuando se lo ejecuta sin argumentos.

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

#ifndef TAM
#define TAM 4096                        /* tamaño del búfer, en bytes */
#endif

/* Escribe exactamente n bytes. Devuelve 0 si lo logra y -1 si hay un error. */
static int escribir_todo(int fd, const char *datos, size_t n)
{
    while (n > 0) {
        ssize_t escritos = write(fd, datos, n);

        if (escritos == -1)
            return -1;

        datos += escritos;
        n -= (size_t)escritos;
    }
    return 0;
}

int main(void)
{
    char bufer[TAM];
    ssize_t leidos;

    while ((leidos = read(STDIN_FILENO, bufer, sizeof(bufer))) > 0) {
        if (escribir_todo(STDOUT_FILENO, bufer, (size_t)leidos) == -1) {
            perror("write");
            return 1;
        }
    }

    if (leidos == -1) {                 /* el bucle terminó por un error... */
        perror("read");
        return 1;
    }
    return 0;                           /* ...o porque read devolvió 0 */
}

La línea destacada es un modismo que encontrarás en todo programa que lee datos: asigna el resultado de read a una variable y, en la misma expresión, lo compara con cero. El bucle continúa mientras lleguen bytes y termina en los otros dos casos, que se distinguen después. Observa también que a escribir_todo se le pasa leidos, y no el tamaño del búfer: solo esa parte contiene datos válidos.

terminal
$ gcc -Wall -Wextra -g copiar.c -o copiar
$ echo "tres palabras juntas" | ./copiar
tres palabras juntas
$ ./copiar < copiar.c > copia.c            # copiar un archivo
$ cmp copiar.c copia.c && echo "son iguales"
son iguales
$ strace -e trace=read,write ./copiar < /etc/hostname > /dev/null
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0"..., 832) = 832
read(0, "equipo\n", 4096)               = 7
write(1, "equipo\n", 7)                 = 7
read(0, "", 4096)                       = 0
+++ exited with 0 +++

El programa no abre ningún archivo: trabaja con los descriptores 0 y 1 tal como los recibe, y es la shell la que decide a qué están conectados. Por eso sirve igual para el teclado, para un archivo y para la salida de otro comando.

La traza muestra el bucle completo. El primer read no es nuestro: es el enlazador dinámico leyendo la cabecera de la biblioteca de C, como vimos en el tema 9. Después vienen las tres llamadas del programa: un read que pide 4096 bytes y obtiene los 7 que tiene el archivo, el write de esos 7 bytes, y un último read que devuelve cero y hace terminar el bucle. Para saber que un archivo se terminó hace falta siempre una llamada más.

8. El tamaño del búfer importa

En los temas 11 y 13 afirmamos que conviene hacer pocas llamadas al sistema y pedir mucho en cada una. copiar.c permite medirlo, porque el tamaño del búfer se puede cambiar al compilar con la opción -D. Creamos un archivo de 10 MB y lo copiamos con dos versiones del programa:

terminal
$ head -c 10M /dev/zero > datos.bin             # 10 485 760 bytes
$ gcc -Wall -Wextra -g -DTAM=1 copiar.c -o copiar_1

$ time ./copiar < datos.bin > /dev/null         # búfer de 4096 bytes
real	0m0.004s
user	0m0.000s
sys	0m0.003s

$ time ./copiar_1 < datos.bin > /dev/null       # búfer de 1 byte
real	0m6.180s
user	0m1.412s
sys	0m4.761s

$ strace -c -o cuenta.txt ./copiar < datos.bin > /dev/null
$ grep -Ew 'read|write' cuenta.txt
 52.11    0.002214           0      2562           read
 46.30    0.001967           0      2560           write

Los dos programas hacen el mismo trabajo útil, mover diez millones de bytes, y uno tarda más de mil veces lo que el otro. Los tiempos varían de un equipo a otro; la proporción, no. La diferencia está toda en la cantidad de cruces al kernel:

BúferLlamadas readLlamadas writeTotal aproximado
4096 bytes2560, más una que devuelve 025605 mil
1 byte10 485 760, más una que devuelve 010 485 76021 millones

La cuenta de strace -c confirma la primera fila; el read que sobra es el del enlazador dinámico. En la segunda versión, veintiún millones de llamadas en seis segundos dan unos 300 nanosegundos por llamada, el mismo costo que medimos en el tema 11. Y casi todo ese tiempo figura en sys: el programa pasó la ejecución entrando y saliendo del kernel.

Agrandar el búfer deja de rendir pronto: entre 4096 y 65 536 bytes la mejora es pequeña, y más allá es inapreciable. Lo que hay que evitar es el extremo opuesto.

9. getpid: una llamada sin argumentos

No todas las llamadas mueven datos. Muchas solo consultan algo que el kernel sabe y el programa no. La más simple es getpid, que devuelve el identificador de proceso (PID), el número con el que el kernel distingue a cada programa en ejecución. Su compañera getppid devuelve el del proceso padre, es decir, el que lo creó.

quien.c
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    pid_t yo = getpid();
    pid_t padre = getppid();

    printf("Mi PID:          %d\n", yo);
    printf("PID de mi padre: %d\n", padre);
    return 0;
}
terminal
$ gcc -Wall -Wextra -g quien.c -o quien
$ ./quien
Mi PID:          9612
PID de mi padre: 4102
$ ./quien
Mi PID:          9613
PID de mi padre: 4102
$ echo $$                          # el PID de la shell
4102

Cada ejecución es un proceso nuevo, con un PID distinto. El padre, en cambio, es siempre el mismo: la shell desde la que lanzamos el programa, como confirma la variable $$ de Bash.

¿Por qué hace falta una llamada al sistema para algo tan simple? Porque el PID no está en la memoria del programa. Es un dato de la tabla de procesos, una de las estructuras que el kernel mantiene en su propio espacio, como vimos en el tema 11. El programa no puede leerla; solo puede preguntar.

getpid tiene además una particularidad: no puede fallar. No recibe argumentos que validar ni necesita permisos, de modo que su página del manual dice, en la sección de errores, que siempre tiene éxito. Es de las pocas llamadas cuyo resultado no hace falta comprobar.

10. La función syscall()

Los envoltorios son cómodos, pero hay uno por cada llamada, y los escribe quien mantiene la biblioteca. Cuando el kernel incorpora una llamada nueva, puede pasar un tiempo hasta que la biblioteca ofrezca su envoltorio; y para algunas llamadas de uso muy especializado no lo ofrece nunca. Para esos casos existe una función genérica:

prototipo de syscall
#include <sys/syscall.h>      /* las constantes SYS_... */
#include <unistd.h>

long syscall(long numero, ...);

Recibe el número de la llamada y, a continuación, sus argumentos. Hace lo mismo que cualquier envoltorio —cargar los registros, ejecutar la instrucción, traducir el error a −1 y errno—, pero sirve para todas. Los números no se escriben a mano: el archivo sys/syscall.h define una constante SYS_nombre para cada uno, con el valor que corresponde a la arquitectura para la que se compila. Son los __NR_ del archivo que consultamos en el tema 13.

generica.c
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>

int main(void)
{
    const char mensaje[] = "Hola desde syscall()\n";
    long r;

    r = syscall(SYS_write, 1, mensaje, sizeof(mensaje) - 1);
    printf("SYS_write vale %d; la llamada devolvio %ld\n", SYS_write, r);

    printf("getpid():            %d\n", getpid());
    printf("syscall(SYS_getpid): %ld\n", syscall(SYS_getpid));
    printf("syscall(SYS_gettid): %ld\n", syscall(SYS_gettid));

    if (syscall(9999) == -1)                    /* un número que no existe */
        perror("syscall(9999)");
    return 0;
}
terminal
$ gcc -Wall -Wextra -g generica.c -o generica
$ ./generica
Hola desde syscall()
SYS_write vale 1; la llamada devolvio 21
getpid():            9640
syscall(SYS_getpid): 9640
syscall(SYS_gettid): 9640
syscall(9999): Function not implemented

La salida confirma lo que sabíamos por otros medios. SYS_write vale 1 en este equipo; en uno con ARM, el mismo programa mostraría 64 sin cambiar el código. getpid() y syscall(SYS_getpid) devuelven lo mismo, porque son dos formas de hacer la misma llamada. Y el número 9999 produce el error ENOSYS, el que devolvía el despachador del kernel cuando el número quedaba fuera de la tabla.

La tercera llamada, gettid, devuelve el identificador del hilo, que en un programa de un solo hilo coincide con el PID. Es un buen ejemplo de para qué sirve syscall(): el kernel la ofrece desde 2001, pero glibc no incluyó un envoltorio hasta 2019. Durante casi dos décadas, la única forma de usarla fue la que muestra el programa.

La comodidad tiene un precio. Al ser una función de argumentos variables, el compilador no puede verificar nada:

Envoltorio: write(…)Genérica: syscall(SYS_write, …)
Comprobación de tiposEl compilador verifica cantidad y tipo de los argumentosNinguna: acepta cualquier cosa
Tipo del resultadoEl adecuado a cada llamadaSiempre long
PortabilidadCualquier sistema tipo UnixSolo Linux, y a veces solo una arquitectura
DisponibilidadSolo las llamadas con envoltorioTodas

La regla práctica es sencilla: se usa el envoltorio siempre que exista, y syscall() solo cuando no hay otro modo.

11. La instrucción syscall, desde C

Queda un escalón más abajo. Con una línea de ensamblador incrustado, como las del tema 12, se puede ejecutar la instrucción syscall desde un programa en C sin pasar por la biblioteca. No es una técnica para el trabajo diario, pero permite ver con precisión qué aporta el envoltorio: basta comparar lo que devuelve cada camino.

El programa siguiente escribe el mismo mensaje de tres maneras. Recibe como argumento opcional el descriptor en el que escribir, para poder provocar un error.

caminos.c
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <unistd.h>

/* Ejecuta la instrucción syscall con un número y tres argumentos. */
static long llamada_directa(long numero, long a1, long a2, long a3)
{
    long resultado;

    __asm__ volatile ("syscall"
        : "=a"(resultado)                            /* salida:   rax                */
        : "a"(numero), "D"(a1), "S"(a2), "d"(a3)     /* entradas: rax, rdi, rsi, rdx */
        : "rcx", "r11", "memory");                   /* lo que la instrucción altera */
    return resultado;
}

static void informar(const char *camino, long r)
{
    if (r == -1)
        fprintf(stderr, "%-10s devolvio %ld, errno = %d\n", camino, r, errno);
    else
        fprintf(stderr, "%-10s devolvio %ld\n", camino, r);
}

int main(int argc, char *argv[])
{
    const char mensaje[] = "Hola, kernel!\n";
    int fd = (argc == 2) ? atoi(argv[1]) : 1;

    informar("write()",   write(fd, mensaje, 14));
    informar("syscall()", syscall(SYS_write, fd, mensaje, 14));
    informar("directa",   llamada_directa(SYS_write, fd, (long)mensaje, 14));
    return 0;
}

La función llamada_directa es hola.s escrito en C. Las letras entre comillas indican al compilador en qué registro debe estar cada variable antes de la instrucción: "a" es rax, "D" es rdi, "S" es rsi y "d" es rdx. La última línea le avisa de que la instrucción modifica rcx y r11: son los dos registros donde la CPU guarda la dirección de retorno y el registro de estado al entrar al kernel, el paso 2 del recorrido del tema 13.

terminal
$ gcc -Wall -Wextra -g caminos.c -o caminos
$ ./caminos
Hola, kernel!
write()    devolvio 14
Hola, kernel!
syscall()  devolvio 14
Hola, kernel!
directa    devolvio 14

$ ./caminos 99                     # un descriptor que no está abierto
write()    devolvio -1, errno = 9
syscall()  devolvio -1, errno = 9
directa    devolvio -9

Cuando todo va bien, los tres caminos son indistinguibles. Cuando la llamada falla, aparece la diferencia: los dos que pasan por la biblioteca entregan −1 y dejan el código en errno; el directo entrega lo que el kernel dejó en rax, sin traducir. Es la conversión de la sección 4, observada desde los dos lados.

Solo en x86-64

llamada_directa usa los registros y la instrucción de los procesadores Intel y AMD. En un equipo ARM no compila. Las dos primeras formas, en cambio, funcionan en cualquier arquitectura, que es justamente el motivo por el que existen.

12. Tres caminos, una sola llamada

Desde el punto de vista del kernel, ¿se distinguen las tres formas? Lo comprobamos con strace, guardando la traza en un archivo y quedándonos con las escrituras en el descriptor 1:

terminal
$ strace -o traza.txt ./caminos > /dev/null 2>&1
$ grep 'write(1' traza.txt
write(1, "Hola, kernel!\n", 14)         = 14
write(1, "Hola, kernel!\n", 14)         = 14
write(1, "Hola, kernel!\n", 14)         = 14

Tres líneas idénticas. El kernel recibe un número en rax y unos valores en otros registros; no sabe, ni puede saber, quién los puso allí.

Tres formas de hacer una llamada desde C, el envoltorio write, la función syscall y el ensamblador incrustado, que confluyen en la misma instrucción syscall y llegan a la misma función del kernel write(fd, buf, n) syscall(SYS_write, …) __asm__("syscall") envoltorio de la biblioteca función genérica de la biblioteca sin biblioteca syscall la instrucción Kernel sys_write
Las tres formas ejecutan la misma instrucción con los mismos registros. La diferencia está en lo que cada una hace antes y después.

Esto tiene una consecuencia que va más allá de C. Un programa en Python, en Java o en Go no usa necesariamente estos envoltorios, pero para escribir en un archivo termina ejecutando la misma instrucción con el mismo número. Las llamadas al sistema son el idioma común de todos los programas que corren sobre un kernel, y por eso strace funciona con cualquiera de ellos.

En este tema nos quedamos deliberadamente con los envoltorios, las funciones que se corresponden una a una con las llamadas del kernel. La biblioteca de C ofrece además otra capa de funciones, como printf y fopen, que hacen trabajo propio antes de llamar al kernel. Es el contenido del tema 15.

13. Errores frecuentes

Casi todos los errores al usar llamadas al sistema desde C pertenecen a una lista corta. Conviene conocerla antes de cometerlos:

ErrorQué ocurreLo correcto
No comprobar el valor de retorno.El programa sigue como si la operación se hubiera hecho.Comparar siempre con -1.
Consultar errno sin que haya habido un fallo.Se lee un valor antiguo, de una llamada anterior.Mirar primero el valor de retorno.
Llamar a otra función antes de leer errno.El código se pierde.Copiarlo de inmediato a una variable.
Tratar el búfer de read como una cadena.strlen y %s leen más allá de los datos.Usar la cantidad que devolvió read.
Pasar a write el tamaño del búfer en lugar de los bytes leídos.Se escriben restos de lecturas anteriores.Escribir exactamente lo que se leyó.
Usar sizeof sobre un puntero.Devuelve 8, el tamaño del puntero, y no el del texto.strlen para cadenas; sizeof solo sobre arreglos.
Suponer que read y write transfieren todo.Se pierden datos con tuberías y conexiones de red.Repetir la llamada en un bucle.
Guardar el resultado en un size_t.El −1 se convierte en un número enorme y el error pasa inadvertido.Usar ssize_t.
Olvidar el caso en que read devuelve 0.Un bucle que no termina nunca.Tratar el cero como fin de los datos.
Mezclar printf y write

Los programas de este tema usan write para los datos y printf para comentarlos, y en la terminal el orden de las líneas es el esperado. Al redirigir la salida a un archivo, ese orden puede alterarse: lo escrito con write aparece antes que lo impreso con printf, aunque el código diga lo contrario. No es un fallo del kernel. write envía sus bytes en el acto, y printf los acumula en memoria para enviarlos más tarde. Lo explicamos en el tema 15.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué es un envoltorio y qué tres cosas hace?

Es una función de la biblioteca de C con el nombre y los parámetros de una llamada al sistema. Coloca el número y los argumentos en los registros, ejecuta la instrucción que entra al kernel y devuelve el resultado, traduciendo los errores a −1 y errno.

2. ¿Por qué un programa que usa write() funciona en x86-64 y en ARM, y hola.s no?

Porque los detalles que dependen del procesador —el número de la llamada, los registros y la instrucción— están dentro del envoltorio. Cada arquitectura tiene su versión de la biblioteca, y el código C que la usa no cambia.

3. El kernel deja −13 en rax. ¿Qué recibe el programa en C?

La función devuelve −1 y la variable errno queda con el valor 13, EACCES: permiso denegado.

4. ¿Por qué no se puede saber si una llamada falló mirando solo errno?

Porque una llamada exitosa no lo pone en cero. Puede conservar el código de un fallo anterior. Lo que indica el fallo es el valor de retorno; errno solo dice por qué.

5. ¿Qué diferencia hay entre que read devuelva 0 y que devuelva −1?

El 0 no es un error: indica que se terminaron los datos, por ejemplo al llegar al final de un archivo. El −1 indica que la llamada falló, y el motivo está en errno.

6. Un programa hace n = read(0, bufer, 64) y luego printf("%s", bufer). ¿Cuál es el problema?

read no añade el '\0' final, así que printf sigue leyendo memoria más allá de los n bytes recibidos hasta encontrar un cero por casualidad. Hay que limitar la salida a n bytes, con %.*s o con write.

7. ¿Por qué copiar.c usa una función que repite write en un bucle?

Porque write puede aceptar menos bytes de los pedidos, por ejemplo cuando el destino es una tubería con el búfer lleno. Si no se insistiera con el resto, esos bytes se perderían sin que ninguna llamada informara un error.

8. Con un búfer de 1 byte, copiar tarda mil veces más. ¿En qué se gasta ese tiempo?

En los cambios de modo. Cada byte requiere un read y un write, y cada llamada cuesta unos cientos de nanosegundos aunque mueva un solo byte. Con el búfer de 4096 bytes se hacen cuatro mil veces menos llamadas para el mismo trabajo.

9. ¿Cuándo está justificado usar syscall() en lugar del envoltorio?

Cuando la biblioteca no ofrece un envoltorio para esa llamada. En los demás casos se prefiere el envoltorio, porque el compilador verifica los argumentos y el código es portable.

10. En ./caminos 99, ¿por qué dos líneas muestran −1 y la tercera −9?

Las dos primeras pasan por la biblioteca, que convierte el −9 del kernel en −1 y guarda el 9 en errno. La tercera ejecuta la instrucción directamente y recibe el valor de rax tal como lo dejó el kernel.

Para practicar

  1. En escribir.c, reemplaza strlen(mensaje) por 5 y luego por 40. Explica lo que aparece en cada caso.
  2. Modifica errores.c para que escriba en el descriptor 0 con la salida redirigida: ./errores < /dev/null. ¿Qué código obtienes? Búscalo en man 2 write, sección ERRORS.
  3. Cambia el búfer de leer.c a 8 bytes y escribe una línea de veinte caracteres. ¿Cuántos devuelve read? ¿Qué ocurre con el resto cuando el programa termina? Pruébalo con una línea como 12345678echo hola.
  4. Ejecuta ./leer y, en lugar de escribir, pulsa Ctrl+D. Compara con ./leer < /dev/null.
  5. Compila copiar.c con -DTAM=16, -DTAM=256 y -DTAM=65536. Mide cada versión con time y cuenta sus llamadas con strace -c. ¿A partir de qué tamaño deja de notarse la mejora?
  6. Añade a quien.c una llamada a getuid, que devuelve el identificador del usuario. Ejecútalo con y sin sudo, y relaciona el resultado con la sección sobre root del tema 12.
  7. En generica.c, imprime el valor de SYS_getpid y de SYS_exit, y compáralos con los números que usamos en el tema 13.
  8. Ejecuta strace -e trace=getpid,gettid ./generica. ¿Cuántas llamadas getpid aparecen, y por qué?
  9. Añade a caminos.c una cuarta forma que llame a llamada_directa con el número 9999. ¿Qué valor devuelve? ¿A qué código de error corresponde?