1. Qué es una llamada al sistema
El tema 12 dejó una situación incómoda. En modo usuario, un programa no puede ejecutar las instrucciones que manejan los dispositivos ni acceder a la memoria del kernel. Y tampoco puede pasar a modo kernel por su cuenta para hacerlo. Sin embargo, todos los programas útiles necesitan operaciones de ese tipo.
La salida es que el programa no haga esas operaciones, sino que las pida:
Una llamada al sistema (system call o syscall) es el mecanismo por el que un programa en modo usuario solicita un servicio al kernel. El programa indica qué servicio quiere y con qué datos, cede el control, y el kernel decide si lo realiza.
Es la única puerta entre el espacio de usuario y el espacio de kernel que un programa puede abrir voluntariamente. Todo lo que un programa consigue más allá de calcular con su propia memoria —leer un archivo, crear un proceso, enviar datos por la red, saber qué hora es, terminar— pasa por ella. El conjunto de llamadas disponibles define, en la práctica, qué es lo que el sistema operativo ofrece.
2. No es una llamada a función
En C, una llamada al sistema tiene el aspecto de una llamada a función: write(1, texto, 5). Esa apariencia es obra de la biblioteca, y oculta diferencias profundas. Conviene tenerlas claras antes de seguir:
| Llamada a función | Llamada al sistema | |
|---|---|---|
| Quién ejecuta el código | El propio programa | El kernel |
| Modo de la CPU | Usuario, sin cambios | Pasa a kernel y vuelve |
| Cómo se indica el destino | Con una dirección de memoria | Con un número |
| Instrucción de máquina | call | syscall |
| Pila utilizada | La del programa | La pila de kernel del proceso |
| ¿Puede ser rechazada? | No | Sí: el kernel verifica permisos y argumentos |
| Costo | Unos pocos nanosegundos | Cientos de nanosegundos |
La fila central es la clave de todo el diseño: una función se alcanza saltando a su dirección, y una llamada al sistema se solicita por su número. Enseguida veremos por qué.
3. Anatomía de una llamada
Toda llamada al sistema, en cualquier arquitectura, consta de cuatro elementos:
- Un número que identifica el servicio solicitado.
- Los argumentos: los datos que el servicio necesita.
- Una instrucción especial que transfiere el control al kernel.
- Un valor de retorno con el que el kernel informa el resultado.
El número y los argumentos no pueden pasarse por la pila, como en una llamada a función, porque al entrar al kernel se cambia de pila. Se colocan en registros de la CPU, siguiendo una convención fija que el kernel y los programas respetan. En Linux sobre x86-64 es esta:
| Elemento | Registro |
|---|---|
| Número de la llamada | rax |
| Argumentos 1 a 6, en orden | rdi, rsi, rdx, r10, r8, r9 |
| Instrucción de entrada | syscall |
| Valor de retorno | rax |
Una llamada al sistema admite como máximo seis argumentos, y cada uno cabe en un registro: es un número o una dirección de memoria. Cuando hay que pasar algo más grande, como un texto o una estructura, lo que se pasa es su dirección, y el kernel va a buscar los datos a la memoria del programa.
4. Por qué un número
Imagina que una llamada al sistema funcionara como una función: el programa indicaría la dirección del código del kernel al que quiere saltar, y la CPU cambiaría a modo kernel y saltaría allí. Un programa malintencionado no elegiría el comienzo de una rutina, sino un punto intermedio: justo después de la comprobación de permisos, por ejemplo. Tendría privilegio total ejecutando el fragmento de kernel que más le conviniera.
El número lo impide. Con él, el programa solo puede decir qué servicio quiere, eligiendo de un catálogo cerrado. Dónde está el código que lo atiende, y por dónde se entra en él, lo decide exclusivamente el kernel. Es la aplicación concreta de la regla del tema 12: un programa puede provocar el paso a modo kernel, pero no elegir lo que se ejecuta después.
Es la diferencia entre entrar a la cocina de un restaurante y pedir de la carta. El cliente no manipula los fogones: elige un plato por su número, lo comunica al camarero y espera. La cocina decide cómo prepararlo y puede responder que no queda. La carta es la tabla de llamadas al sistema.
5. La tabla de llamadas
Dentro del kernel hay un arreglo, la tabla de llamadas al sistema, en el que la posición n contiene la dirección de la función que atiende la llamada número n. Cuando llega una llamada, el kernel usa el contenido de rax como índice de ese arreglo.
rax selecciona una fila de la tabla. El programa nunca ve las direcciones de la derecha.La lógica del kernel al recibir una llamada es sorprendentemente breve. En pseudocódigo:
long atender_llamada(long numero, long a1, long a2, long a3, long a4, long a5, long a6)
{
if (numero < 0 || numero >= CANTIDAD_DE_LLAMADAS)
return -ENOSYS; /* esa llamada no existe */
return tabla_de_llamadas[numero](a1, a2, a3, a4, a5, a6);
}
Los números están publicados en un archivo de cabecera que se instala junto con el compilador:
$ cd /usr/include/x86_64-linux-gnu/asm
$ grep -E '__NR_(read|write|open|close|getpid|execve|exit) ' unistd_64.h
#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
#define __NR_getpid 39
#define __NR_execve 59
#define __NR_exit 60
$ grep -c '^#define __NR_' unistd_64.h # cuántas llamadas existen
373
En Fedora el archivo está en /usr/include/asm. Tres observaciones sobre esta tabla:
- Es pequeña. Todo lo que hace un sistema Linux, desde un navegador hasta una base de datos, se construye con menos de cuatrocientas operaciones, y la mayoría de los programas usa unas pocas decenas.
- Depende de la arquitectura.
writees la llamada 1 en x86-64 y la 64 en ARM. Es un motivo más por el que un ejecutable no sirve para otro procesador. - No cambia nunca. Una vez asignado, un número conserva su significado para siempre; se añaden llamadas nuevas al final, pero no se modifican ni se retiran las existentes. Es la garantía de estabilidad que mencionamos en el tema 10, y lo que permite que un ejecutable antiguo funcione sobre un kernel actual.
6. Hablar con el kernel sin intermediarios
Con lo anterior es posible escribir un programa que haga llamadas al sistema directamente, sin biblioteca de C, sin main y sin nada que no hayamos escrito nosotros. Tiene que estar en ensamblador, porque C no ofrece una forma de cargar registros concretos y ejecutar la instrucción syscall. El programa hace dos llamadas: write, para mostrar un mensaje, y exit, para terminar.
# Escribe un mensaje y termina, sin biblioteca de C.
.global _start
.text
_start:
mov $1, %rax # numero de llamada: 1 = write
mov $1, %rdi # argumento 1: descriptor 1, la salida estandar
lea mensaje(%rip), %rsi # argumento 2: direccion del texto
mov $14, %rdx # argumento 3: cantidad de bytes
syscall # entrar al kernel
mov $60, %rax # numero de llamada: 60 = exit
mov $0, %rdi # argumento 1: codigo de salida
syscall
.section .rodata
mensaje:
.ascii "Hola, kernel!\n"
El programa empieza en _start, el punto de entrada que conocimos en el tema 9, y sigue al pie de la letra la convención de la sección 3: el número en rax, los argumentos en rdi, rsi y rdx, y la instrucción syscall. La instrucción lea carga en rsi la dirección de la etiqueta mensaje. Lo construimos con las dos últimas etapas de la compilación, invocadas a mano:
$ as hola.s -o hola.o # ensamblar
$ ld hola.o -o hola_asm # enlazar, sin biblioteca de C
$ ./hola_asm
Hola, kernel!
$ echo $?
0
$ strace ./hola_asm
execve("./hola_asm", ["./hola_asm"], 0x7ffd1e4a7b20 /* 36 vars */) = 0
write(1, "Hola, kernel!\n", 14) = 14
exit(0) = ?
+++ exited with 0 +++
La traza completa tiene tres llamadas: la carga del programa, que hace la shell, y las dos nuestras. Compáralo con las decenas de llamadas que mostraba cualquier programa en C: aquellas eran la carga de la biblioteca y su inicialización. Aquí no hay nada entre el programa y el kernel.
El programa enseña además por qué la llamada exit es imprescindible. Sin las tres últimas instrucciones, la CPU seguiría ejecutando lo que hubiera en memoria después del primer syscall, y el programa terminaría con un fallo. Es lo que advertimos en el tema 9: un programa no termina solo; le pide al kernel que lo finalice.
Versión para procesadores ARM de 64 bits
En aarch64 cambian los registros, la instrucción y los números. Se ensambla y se enlaza con los mismos comandos.
.global _start
.text
_start:
mov x8, #64 // numero de llamada: 64 = write
mov x0, #1 // argumento 1: descriptor
adr x1, mensaje // argumento 2: direccion del texto
mov x2, #14 // argumento 3: cantidad de bytes
svc #0 // entrar al kernel
mov x8, #93 // numero de llamada: 93 = exit
mov x0, #0
svc #0
mensaje:
.ascii "Hola, kernel!\n"
7. El recorrido completo
Sigamos ahora la llamada write de hola.s desde que el programa prepara los registros hasta que recupera el control. Es el detalle de lo que en el tema 1 resumimos en una línea.
- El programa carga los registros. Cuatro instrucciones
movyleacomunes dejan el número 1 enraxy los tres argumentos enrdi,rsiyrdx. modo usuario - Ejecuta la instrucción
syscall. La CPU guarda la dirección de la instrucción siguiente y el registro de estado, pasa al anillo 0 y carga en el contador de programa la dirección de entrada que el kernel registró durante el arranque. cambio de modo - El kernel se instala. Su rutina de entrada cambia a la pila de kernel del proceso y guarda en ella todos los registros del programa. modo kernel
- Busca la llamada. Comprueba que el número de
raxsea válido y obtiene de la tabla la función correspondiente. modo kernel - La función valida los argumentos. ¿El descriptor 1 está abierto en este proceso? ¿Admite escritura? ¿El rango de 14 bytes a partir de la dirección indicada pertenece al espacio de usuario del proceso y es legible? modo kernel
- Hace el trabajo. Copia los 14 bytes desde la memoria del programa y los entrega al destino asociado al descriptor: en este caso, la terminal. modo kernel
- Deja el resultado en
rax. El valor 14, la cantidad de bytes escritos, sustituye al número de llamada. modo kernel - Vuelve. Restaura los registros guardados y ejecuta la instrucción de retorno, que regresa al anillo 3 y a la instrucción siguiente al
syscall. cambio de modo - El programa continúa. Para él,
syscallfue una instrucción que tardó más de lo normal y dejó un valor enrax. modo usuario
Entre los pasos 6 y 7 el kernel puede, si lo considera oportuno, entregar la CPU a otro proceso antes de volver. El programa no lo percibiría: simplemente, su syscall tardaría un poco más.
8. El kernel no confía en nadie
El paso 5 del recorrido merece atención aparte, porque es donde se juega la seguridad del sistema. Los argumentos de una llamada los elige el programa, y el programa puede ser defectuoso o malicioso. El kernel los trata, por principio, como datos no confiables.
El caso más delicado son los punteros. Supón que un programa llama a read y, como dirección donde dejar los datos leídos, pasa una dirección del espacio de kernel. Si el kernel escribiera allí sin comprobarlo, el programa habría conseguido sobrescribir memoria del kernel usando al propio kernel como instrumento. Para evitarlo, toda dirección recibida se verifica: debe pertenecer al espacio de usuario del proceso que llama y admitir la operación pedida. Dentro de Linux, la copia de datos entre los dos espacios se hace siempre mediante dos funciones dedicadas a ello, copy_from_user y copy_to_user.
El resto de los argumentos recibe el mismo trato:
- Descriptores: deben corresponder a un archivo abierto por ese proceso.
- Tamaños e índices: deben estar dentro de los límites admitidos.
- Permisos: el usuario del proceso debe estar autorizado para la operación.
Si una comprobación falla, la llamada no se ejecuta y el kernel devuelve un error. No finaliza el programa: pedir algo indebido es una situación normal, que el programa puede manejar.
9. El valor de retorno y los errores
El kernel solo dispone de un registro, rax, para comunicar el resultado, y debe servir tanto para el éxito como para el fracaso. La convención de Linux es la siguiente:
- Un valor mayor o igual que cero indica éxito, y su significado depende de la llamada: los bytes escritos, un descriptor nuevo, un identificador de proceso.
- Un valor negativo, entre −1 y −4095, indica error, y su valor absoluto es un código de error que dice cuál.
| Código | Nombre | Significado |
|---|---|---|
| 2 | ENOENT | El archivo o directorio no existe. |
| 9 | EBADF | El descriptor no es válido. |
| 13 | EACCES | Permiso denegado. |
| 14 | EFAULT | Una dirección pasada como argumento no es válida. |
| 22 | EINVAL | Un argumento tiene un valor no admitido. |
| 38 | ENOSYS | La llamada solicitada no existe. |
Podemos provocar uno. Cambiamos dos cosas en hola.s: escribimos en el descriptor 99, que no está abierto, y usamos el valor devuelto por write, cambiado de signo, como código de salida del programa.
mov $1, %rax
mov $99, %rdi # un descriptor que no existe
lea mensaje(%rip), %rsi
mov $14, %rdx
syscall # rax = resultado de write
mov %rax, %rdi # usar ese resultado...
neg %rdi # ...cambiado de signo, como codigo de salida
mov $60, %rax
syscall
$ as hola_error.s -o hola_error.o && ld hola_error.o -o hola_error
$ ./hola_error
$ echo $?
9
$ strace ./hola_error 2>&1 | tail -3
write(99, "Hola, kernel!\n", 14) = -1 EBADF (Bad file descriptor)
exit(9) = ?
+++ exited with 9 +++
El programa no mostró nada y no falló: el kernel rechazó la llamada y dejó −9 en rax. El 9 que devuelve la shell es el código EBADF de la tabla. strace lo presenta como -1 EBADF porque esa es la forma en que la biblioteca de C entrega los errores a los programas, como veremos en el tema 14.
10. Las familias de llamadas
Las llamadas al sistema se agrupan de manera natural según el subsistema del kernel que las atiende. Estas son las familias principales, con las llamadas que encontrarás una y otra vez en las trazas de strace:
| Familia | Llamadas representativas | Para qué sirven |
|---|---|---|
| Procesos | fork, clone, execve, exit, wait4, getpid, kill | Crear, reemplazar, finalizar y controlar procesos. |
| Archivos | openat, read, write, close, lseek, fstat | Acceder al contenido y a los datos de los archivos. |
| Directorios | mkdir, unlink, rename, getdents64, chdir | Organizar el árbol de archivos. |
| Memoria | mmap, munmap, brk, mprotect | Ampliar, reducir y proteger el espacio de direcciones. |
| Comunicación | pipe, socket, connect, sendto, recvfrom | Intercambiar datos entre procesos y por la red. |
| Tiempo | clock_gettime, nanosleep | Consultar el reloj y esperar. |
| Sistema | uname, ioctl, mount, reboot | Consultar y administrar el sistema y los dispositivos. |
Cada fila es una vista distinta del kernel, y coincide con los subsistemas del tema 11. Los cursos siguientes de la ruta estudian estas familias una por una.
11. Llamadas que hacen esperar
Algunas llamadas no pueden completarse de inmediato. Un read sobre el teclado no tiene nada que entregar hasta que alguien pulse una tecla; un read sobre un archivo cuyos datos no están en caché debe esperar al disco. Se dice que esas llamadas bloquean al proceso.
El kernel no se queda esperando dentro de la llamada con la CPU ocupada. Anota que el proceso espera ese acontecimiento, lo marca como dormido y entrega el procesador a otro. Cuando llega la interrupción del dispositivo, completa la llamada pendiente y vuelve a marcar el proceso como listo. Lo vimos en el recorrido del tema 8, y explica por qué en el tema 11 la shell aparecía en estado sleeping: estaba bloqueada en una llamada, esperando a que terminara el comando lanzado.
Las llamadas al sistema son, por lo tanto, momentos en que el kernel puede reorganizar quién usa la CPU. Un programa que hace entrada y salida la cede con frecuencia sin proponérselo.
12. El costo, y un atajo
Una llamada al sistema cuesta entre cien y unos cientos de nanosegundos, según el procesador y las protecciones activas. Lo medimos en el tema 11 y vimos su origen en el tema 12: dos cambios de modo, el cambio de pila, guardar y restaurar los registros y validar los argumentos. Comparado con una llamada a función, es de cincuenta a cien veces más.
De ahí viene una regla de diseño que ya hemos encontrado dos veces: hacer pocas llamadas y pedir mucho en cada una. Leer un archivo en bloques de varios kilobytes, y no de a un byte, reduce miles de veces la cantidad de cruces.
Para unas pocas llamadas muy frecuentes y que solo consultan información, Linux ofrece un atajo. El kernel coloca en el espacio de direcciones de cada proceso una pequeña zona con código y datos de solo lectura, llamada vDSO. Allí mantiene actualizada, entre otras cosas, la hora. Cuando un programa pregunta la hora, la biblioteca de C ejecuta ese código, que lee el dato directamente, sin entrar al kernel.
$ ldd /bin/date | head -1
linux-vdso.so.1 (0x00007ffd0b5f2000)
$ strace -c date 2>&1 | grep -c clock_gettime
0
La primera línea es la biblioteca que apareció en la salida de ldd del tema 9 sin que supiéramos qué era: no existe como archivo en el disco; la aporta el kernel. La segunda comprueba el efecto: el comando date obtiene la hora, y sin embargo en su traza no figura ninguna llamada clock_gettime.
13. La instrucción, según la arquitectura
El mecanismo es el mismo en todos los procesadores; lo que cambia es el nombre de la instrucción y los registros de la convención. El término general para esta entrada deliberada al kernel es trap.
| Arquitectura | Instrucción | Número en | Argumentos en | Retorno en |
|---|---|---|---|---|
| x86-64 | syscall | rax | rdi, rsi, rdx, r10, r8, r9 | rax |
| x86 de 32 bits | int $0x80 | eax | ebx, ecx, edx, esi, edi, ebp | eax |
| ARM (aarch64) | svc #0 | x8 | x0 a x5 | x0 |
| RISC-V | ecall | a7 | a0 a a5 | a0 |
La segunda fila tiene interés histórico. En los procesadores x86 de 32 bits, las llamadas al sistema se hacían provocando deliberadamente una interrupción, la número 0x80, con la instrucción int. Funcionaba, pero el mecanismo general de interrupciones es lento para un uso tan frecuente, y los procesadores posteriores añadieron instrucciones dedicadas, mucho más rápidas. Encontrarás int 0x80 en muchos textos y tutoriales antiguos.
Todos tienen llamadas al sistema, pero no todos las ofrecen como interfaz pública. En Windows y en macOS, los números pueden cambiar de una versión a otra, y los programas están obligados a usar las bibliotecas del sistema, que son las que el fabricante mantiene estables. Linux es singular en garantizar la estabilidad de la interfaz del kernel mismo, y es lo que hace posible un programa como hola.s.
14. Resumen
- Una llamada al sistema es la forma en que un programa de usuario solicita un servicio al kernel. Es la única entrada voluntaria al modo kernel.
- Se diferencia de una llamada a función en que cambia de modo y de pila, la ejecuta el kernel, puede ser rechazada y cuesta mucho más.
- Consta de un número, hasta seis argumentos, una instrucción especial y un valor de retorno. En x86-64 el número va en
raxy la instrucción essyscall. - Se identifica por número y no por dirección para que el programa solo pueda elegir entre los servicios que el kernel ofrece.
- El kernel usa el número como índice de su tabla de llamadas. Linux tiene menos de cuatrocientas, y sus números no cambian nunca.
- El kernel valida todos los argumentos, en especial las direcciones, antes de actuar.
- Un resultado negativo indica error, y su valor absoluto es el código que lo identifica.
- Algunas llamadas bloquean al proceso; el kernel aprovecha para dar la CPU a otro.
- Por su costo conviene hacer pocas llamadas. El vDSO permite resolver algunas consultas sin entrar al kernel.
15. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Cuáles son los cuatro elementos de toda llamada al sistema?
Un número que identifica el servicio, los argumentos, una instrucción especial que transfiere el control al kernel y un valor de retorno con el resultado.
2. ¿Por qué los argumentos se pasan en registros y no en la pila?
Porque al entrar al kernel se cambia a la pila de kernel del proceso. Los registros, en cambio, conservan su contenido durante el cambio de modo, y el kernel los encuentra tal como los dejó el programa.
3. ¿Qué problema de seguridad habría si una llamada al sistema indicara la dirección del código del kernel a ejecutar?
El programa podría saltar a cualquier punto del kernel, por ejemplo después de una comprobación de permisos, y ejecutarlo con privilegio total. Al usar un número, solo puede elegir un servicio del catálogo, y el kernel decide por dónde se entra en su código.
4. En hola.s, ¿qué valor contiene rax antes y después del primer syscall?
Antes, el 1, el número de la llamada write. Después, el 14, la cantidad de bytes escritos, que es el valor de retorno.
5. ¿Qué ocurriría si se quitaran las tres últimas instrucciones de hola.s?
Tras escribir el mensaje, la CPU seguiría ejecutando como instrucciones lo que hubiera a continuación en memoria. El programa terminaría con un fallo, porque nadie le habría pedido al kernel que lo finalizara.
6. Un programa pasa a read una dirección del espacio de kernel como lugar donde dejar los datos. ¿Qué hace el kernel?
Comprueba que la dirección no pertenece al espacio de usuario del proceso y rechaza la llamada con el error EFAULT, sin escribir nada. El programa no es finalizado: recibe el código de error.
7. Tras una llamada, rax contiene −13. ¿Qué significa?
Que la llamada falló con el código de error 13, EACCES: permiso denegado.
8. ¿Por qué la traza de hola_asm tiene tres llamadas y la de un «Hola, mundo» en C tiene decenas?
El programa en C está enlazado dinámicamente con la biblioteca de C. Antes de llegar a main, el enlazador dinámico abre y mapea la biblioteca, y esta se inicializa, todo mediante llamadas al sistema. El programa en ensamblador no usa ninguna biblioteca.
9. ¿Qué es el vDSO y qué problema resuelve?
Es una zona de código y datos que el kernel mapea en cada proceso. Permite responder consultas muy frecuentes, como la hora, leyendo datos que el kernel mantiene actualizados, sin el costo de un cambio de modo.
Para practicar
- Ensambla y ejecuta
hola.s. Cambia el código de salida por 7 y compruébalo conecho $?. - Modifica el mensaje y ajusta la cantidad de bytes. ¿Qué pasa si pones una cantidad mayor que la longitud real? ¿Y una menor?
- Haz que el programa llame a
getpid(número 39, sin argumentos) y use el resultado como código de salida. Ten en cuenta que la shell solo conserva los 8 bits inferiores: compara con el PID que muestrastrace. - En
hola_error.s, restaura el descriptor 1 y pasa como dirección del texto el valor0. ¿Qué código de error obtienes? - Pon en
raxel número 9999 antes del primersyscall. ¿Qué devuelve el kernel? - Busca en
unistd_64.hlos números deopenat,mmapyclock_gettime, y clasifícalos en las familias de la sección 10.