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í:
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:
#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 C | Valor | Lo que hace el envoltorio |
|---|---|---|
El nombre write | — | Pone el número 1 en rax |
fd | 1 | Lo deja en rdi |
buf | dirección del texto | La deja en rsi |
count | 14 | Lo deja en rdx |
| — | — | Ejecuta syscall |
| El valor devuelto | 14 | Lo 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:
| Tipo | Qué representa | En Linux de 64 bits | Formato en printf |
|---|---|---|---|
size_t | Una cantidad de bytes. No puede ser negativa. | Entero sin signo de 64 bits | %zu |
ssize_t | Una cantidad de bytes, o −1 para indicar error. | Entero con signo de 64 bits | %zd |
pid_t | Un 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:
| Descriptor | Nombre | Constante en unistd.h | Conectado normalmente a |
|---|---|---|---|
0 | Entrada estándar | STDIN_FILENO | El teclado |
1 | Salida estándar | STDOUT_FILENO | La pantalla de la terminal |
2 | Salida de errores | STDERR_FILENO | La 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:
#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;
}
$ 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:
writeenvía bytes, no cadenas. No sabe nada del carácter'\0'con que terminan las cadenas de C: escribe exactamente la cantidad indicada. Por eso hay que calcularla, aquí constrlen. Si la cantidad fuera mayor que el texto, saldrían también los bytes que hubiera a continuación en memoria.- El valor devuelto es la cantidad de bytes aceptados. Coincide con el 13 que pedimos: doce caracteres y el salto de línea.
- El programa no sabe adónde escribe. En la segunda ejecución la shell conectó el descriptor 1 a un archivo antes de iniciar el programa, y el mismo
writeterminó en el disco. En la tercera, el destino rechazó los datos y la llamada falló. El código es idéntico en los tres casos.
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 rax | Lo que recibe el programa en C | |
|---|---|---|
| Éxito | Un valor mayor o igual que cero | Ese mismo valor |
| Error | El código de error cambiado de signo: -9 | Siempre -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:
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:
strerror(codigo)devuelve la descripción como cadena.perror("texto")escribe en la salida de errores el texto indicado, dos puntos y la descripción del valor actual deerrno.
#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;
}
$ 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:
- Solo tiene sentido después de un fallo. Una llamada que termina bien no pone
errnoen cero: lo deja como estaba. Primero se comprueba el valor de retorno y, solo si indica error, se consultaerrno. - 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 delif. - Se compara con los nombres, no con los números.
EBADFvale 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:
if (write(fd, datos, cantidad) == -1) { /* 1. llamar y comprobar */
perror("write"); /* 2. informar el motivo */
return 1; /* 3. decidir qué hacer */
}
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:
#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 devuelto | Significado |
|---|---|
| Mayor que cero | La cantidad de bytes que el kernel dejó en el búfer. Puede ser menor que la pedida. |
0 | Fin de los datos: no queda nada por leer y no llegará nada más. |
-1 | Error. El motivo está en errno. |
#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;
}
$ 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:
- En la primera ejecución, el programa se detuvo. No había nada que leer, y
readbloqueó al proceso hasta que pulsamos Enter. Es el bloqueo que describimos en el tema 13: mientras esperaba, el programa no consumió CPU. - Devolvió 5, no 4. El salto de línea de la tecla Enter es un byte más, y por eso el corchete de cierre aparece en la línea siguiente.
- Pedimos 64 bytes y recibimos 5. No es un error: el kernel entrega lo que hay disponible.
readno añade el'\0'final. Lo que deja en el búfer son bytes, no una cadena de C. Usarstrleno%ssobre ese búfer es un error, porque leerían más allá de los datos recibidos. El formato%.*slimita la salida a losnbytes válidos.- El cero significa «se acabó». En la terminal se provoca pulsando Ctrl+D al principio de una línea.
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.
| Llamada | Devuelve menos de lo pedido cuando… |
|---|---|
read | el 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. | |
write | el 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:
/* 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.
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.
#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.
$ 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:
$ 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úfer | Llamadas read | Llamadas write | Total aproximado |
|---|---|---|---|
| 4096 bytes | 2560, más una que devuelve 0 | 2560 | 5 mil |
| 1 byte | 10 485 760, más una que devuelve 0 | 10 485 760 | 21 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ó.
#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;
}
$ 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:
#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.
#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;
}
$ 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 tipos | El compilador verifica cantidad y tipo de los argumentos | Ninguna: acepta cualquier cosa |
| Tipo del resultado | El adecuado a cada llamada | Siempre long |
| Portabilidad | Cualquier sistema tipo Unix | Solo Linux, y a veces solo una arquitectura |
| Disponibilidad | Solo las llamadas con envoltorio | Todas |
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.
#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.
$ 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.
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:
$ 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í.
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:
| Error | Qué ocurre | Lo 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. |
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
- En C, las llamadas al sistema se hacen mediante envoltorios: funciones de la biblioteca que cargan los registros, ejecutan la instrucción de entrada al kernel y devuelven el resultado.
- Los envoltorios hacen portable al programa y no validan nada: las comprobaciones son del kernel.
writeenvía una cantidad de bytes a un descriptor y devuelve cuántos fueron aceptados. No sabe de cadenas ni de'\0'.- Ante un error, el envoltorio devuelve −1 y deja el código en
errno, que solo es válido inmediatamente después de un fallo.perrorystrerrorlo traducen a texto. readdevuelve los bytes recibidos, 0 al terminarse los datos y −1 si hay error. No añade'\0'.readywritepueden transferir menos bytes de los pedidos; para una cantidad exacta hay que repetirlas en un bucle.- El tamaño del búfer determina la cantidad de llamadas y, con ella, el rendimiento.
getpidygetppidconsultan datos que solo el kernel tiene, y nunca fallan.syscall()invoca cualquier llamada por su número, sin verificación de tipos. Se reserva para las que no tienen envoltorio.- El envoltorio,
syscall()y la instrucción directa producen exactamente la misma llamada: el kernel no las distingue.
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
- En
escribir.c, reemplazastrlen(mensaje)por 5 y luego por 40. Explica lo que aparece en cada caso. - Modifica
errores.cpara que escriba en el descriptor 0 con la salida redirigida:./errores < /dev/null. ¿Qué código obtienes? Búscalo enman 2 write, sección ERRORS. - Cambia el búfer de
leer.ca 8 bytes y escribe una línea de veinte caracteres. ¿Cuántos devuelveread? ¿Qué ocurre con el resto cuando el programa termina? Pruébalo con una línea como12345678echo hola. - Ejecuta
./leery, en lugar de escribir, pulsa Ctrl+D. Compara con./leer < /dev/null. - Compila
copiar.ccon-DTAM=16,-DTAM=256y-DTAM=65536. Mide cada versión contimey cuenta sus llamadas constrace -c. ¿A partir de qué tamaño deja de notarse la mejora? - Añade a
quien.cuna llamada agetuid, que devuelve el identificador del usuario. Ejecútalo con y sinsudo, y relaciona el resultado con la sección sobrerootdel tema 12. - En
generica.c, imprime el valor deSYS_getpidy deSYS_exit, y compáralos con los números que usamos en el tema 13. - Ejecuta
strace -e trace=getpid,gettid ./generica. ¿Cuántas llamadasgetpidaparecen, y por qué? - Añade a
caminos.cuna cuarta forma que llame allamada_directacon el número 9999. ¿Qué valor devuelve? ¿A qué código de error corresponde?