La syscall: cruzar la frontera hacia el kernel
Tu proceso no puede abrir un archivo, leer un byte del disco ni mandar un paquete. Solo
puede computar sobre memoria que ya posee. Todo lo demás pertenece al kernel, y la
syscall (llamada al sistema) es la única puerta: tu código carga un número de syscall
y los argumentos en registros acordados, ejecuta una instrucción especial, y la CPU pasa
a modo kernel en un entry point que el kernel instaló al bootear. read(), malloc
agrandando el heap, printf flusheando un buffer — todo efecto que sale de tu proceso
termina acá abajo.
El reset: una syscall no es una llamada a función dentro del kernel. Es un trap controlado — un cambio de modo de la CPU hacia un único entry point fijo del kernel, con argumentos pasados en registros según un contrato que el kernel publica.
La frontera: anillos de privilegio
Las CPUs x86-64 corren código en niveles de privilegio; en la práctica importan dos: ring 0 (kernel) y ring 3 (user). El código en ring 3 no puede ejecutar instrucciones privilegiadas, hablar con dispositivos ni tocar memoria del kernel — las page tables mapean las páginas del kernel como inaccesibles desde modo usuario. Esto es lo que hace que un OS sea un OS: un proceso buggy u hostil queda contenido porque la frontera la impone el hardware, no una convención.
La consecuencia: el código de usuario no puede "llamar" código del kernel, porque un
call común a una dirección del kernel faultearía. En cambio, la CPU provee
instrucciones de trap (syscall en x86-64, svc en ARM64) que atómicamente suben el
nivel de privilegio y transfieren el control a una única dirección elegida por el
kernel. El kernel decide qué hacer mirando el número de syscall que dejaste en un
registro — nunca elegís el destino, solo el pedido.
Cómo funciona realmente: de read() a syscall
Cuando llamás read(fd, buf, len), estás llamando a un wrapper chico de libc. En x86-64
Linux hace aproximadamente esto:
- Mueve el número de syscall de
read(0) arax. - Los argumentos ya están casi en su lugar: la calling convention de System V para
funciones los pasa en
rdi,rsi,rdx,rcx,r8,r9— la convención de syscall es igual salvo que el 4º argumento va enr10, porque la instrucciónsyscallpisarcx(guarda ahí elripde retorno) yr11(losrflags). - Ejecuta
syscall. La CPU salta al entry point del kernel en ring 0. - El kernel despacha según
rax, hace el trabajo, deja el resultado enraxy vuelve a modo usuario consysret.
El contrato de registros en x86-64 Linux:
| Rol | Registro |
|---|---|
| número de syscall | rax |
| args 1–6 | rdi, rsi, rdx, r10, r8, r9 |
| valor de retorno | rax |
| pisados por la instrucción | rcx (rip de retorno), r11 (rflags) |
La convención -errno. El kernel no conoce la variable errno — esa es una ficción
de libc. En Linux, una syscall que falla devuelve un negativo chico en rax: read
sobre un file descriptor inválido devuelve -9, que es -EBADF. El wrapper de libc
chequea si el resultado cae en [-4095, -1]; si sí, lo niega, lo guarda en el errno
thread-local y te devuelve -1. Ese es todo el mecanismo detrás de "devuelve -1 y setea
errno". XNU (macOS) señala los errores distinto: el kernel devuelve el valor de errno
positivo y prende el carry flag, y el wrapper de libSystem hace la traducción —
misma ficción, contrato distinto.
Wrappers, syscall(2) y el vDSO
Hay tres profundidades a las que podés hacer una syscall, más un caso en el que no cruzás nada:
- El wrapper de libc (
read,write,mmap…) — lo que deberías usar. Sabe los números, hace la traducción de errno y a veces agrega lógica real (p. ej. wrappers que llaman una syscall nueva y caen a una vieja si no está). syscall(2), el wrapper genérico — vos ponés el número (SYS_gettid, …) y los argumentos crudos; libc igual hace el trap y la traducción de errno. Útil para syscalls de Linux que todavía no tienen wrapper.- La instrucción cruda — inline asm cargando los registros vos mismo. Legítimo dentro de implementaciones de libc, código freestanding y demos didácticos como el de abajo; en cualquier otro lado es un bug de portabilidad esperando su momento.
- El vDSO — Linux mapea un shared object chico (el virtual dynamic shared object)
en cada proceso, con implementaciones en user space de syscalls calientes y de lectura
frecuente:
gettimeofday,clock_gettime,time,getcpuen x86-64. El kernel mantiene actualizada una página de datos; la función del vDSO solo la lee. Por eso un loop apretado degettimeofdayno muestra nada enstrace— no hay cruce. macOS hace el truco análogo con la commpage que lee libSystem.
¿Por qué evitar el cruce? Una syscall es mucho más cara que una llamada a función:
cambio de modo, barreras de especulación (las mitigaciones post-Spectre/Meltdown lo
empeoraron), contaminación de caché y TLB, y al volver el kernel puede reschedulearte.
Decenas a cientos de nanosegundos contra ~1 ns de un call — exactamente por eso existe
el I/O bufferizado de libc: fwrite agrupa muchas escrituras chicas en un solo cruce de
write.
Artefacto ejecutable: un write(), tres formas de bajar
El ejemplo vive en examples/systems-programming/the-syscall-crossing-into-the-kernel/.
Hace el mismo write a través del wrapper de libc, de syscall(2) y de la instrucción
de trap cruda en inline asm (variantes x86-64 y ARM64, Linux y macOS), y después hace
fallar read a propósito para exponer qué devuelve el kernel de verdad antes de que
libc lo disfrace de errno. El corazón:
// Raw 3-argument syscall, no libc: load the registers the kernel contract
// names, execute the trap instruction.
static long raw_syscall3(long number, long arg1, long arg2, long arg3,
int *is_error) {
#if defined(__x86_64__)
long result;
char carry;
__asm__ volatile(
"syscall\n\t"
"setc %1" /* XNU reports errors in the carry flag */
: "=a"(result), "=r"(carry)
: "a"(number), "D"(arg1), "S"(arg2), "d"(arg3)
: "rcx", "r11", "memory");
#if defined(__APPLE__)
*is_error = carry;
#else
(void)carry; /* Linux: error iff result is a small negative (-errno) */
*is_error = (unsigned long)result >= (unsigned long)-4095L;
#endif
return result;
#endif
}
Compilá y corré:
gcc -Wall -Wextra demo.c -o demo && ./demo
Salida real de esta máquina (una Mac x86-64, así que los números son los de XNU):
1: libc wrapper write()
write() returned 24
2: generic syscall(2) wrapper
syscall(SYS_write=4, ...) returned 30
3: raw trap instruction, no libc
raw syscall 0x2000004 returned 33 (is_error=0)
errno: libc read(-1,..) -> ret=-1, errno=9 (Bad file descriptor)
errno: raw read(-1,..) -> ret=9, is_error=1 (EBADF is 9)
Leé las últimas dos líneas juntas: libc reportó la ficción educada (-1, errno = 9),
mientras que la syscall cruda muestra lo que XNU realmente entregó — un 9 positivo más
el carry flag. En Linux la misma llamada cruda devuelve -9, y la maquinaria de la
primera línea (negar, guardar, devolver -1) es toda la historia de errno. Fijate
también que SYS_write acá es 4 y el número crudo es 0x2000004 (la clase de
syscalls BSD de XNU); en x86-64 Linux es 1. Mismo source, contrato de kernel distinto.
Mirar el cruce: strace y compañía
La frontera es observable, porque el kernel puede loguear cada trap que hace un proceso:
- Linux:
strace ./demoimprime cada syscall con argumentos decodificados y valores de retorno —read(3, "abc", 128) = 3.strace -cagrega conteos y tiempo por syscall;ltracemuestra llamadas a bibliotecas, que es exactamente la distinción wrapper-vs-syscall de esta nota, en vivo. - macOS:
dtruss ./demo(un script de DTrace) es el equivalente aproximado, pero SIP lo bloquea para la mayoría de los binarios salvo que lo deshabilites parcialmente;dtrace/Instruments son los caminos soportados. - Un trace de syscalls suele ser la forma más rápida de responder "¿qué está haciendo
realmente este programa?" — sin necesidad del source. Si un proceso está lento,
strace -cdiciéndote que hizo dos millones dewritede 1 byte es un diagnóstico completo.
macOS/BSD vs Linux: misma idea, contrato distinto
- Los números difieren por kernel.
writees 1 en x86-64 Linux, 4 en la clase BSD de XNU, 64 en ARM64 Linux. Un número de syscall solo tiene sentido relativo a un kernel — no existe numeración portable. - La tabla de syscalls de Linux es ABI estable. Los números nunca se reusan ni se renumeran; los binarios estáticos que hacen syscalls crudas siguen funcionando tras actualizar el kernel. Es una promesa deliberada del kernel, y carga peso.
- macOS promete lo contrario. La interfaz estable es libSystem, no la tabla de
syscalls; Apple renumera y cambia interfaces del kernel entre releases, y
syscall(2)está oficialmente deprecada. Go tuvo que migrar de syscalls crudas a llamadas a libSystem en macOS exactamente por esto. Shippeá syscalls crudas en macOS y un update rutinario del OS te puede romper. - La señalización de errores difiere (
-errnoen el valor de retorno vs errno positivo + carry flag), como muestra el demo.
Modos de falla y trade-offs
- Toda syscall puede fallar; un retorno sin chequear es un bug latente.
writepuede devolver un conteo corto aun en éxito — chequear solo!= -1no alcanza. errnosolo tiene sentido después de una falla. Las llamadas exitosas pueden dejar valores viejos; primero testeá el valor de retorno, después leéerrno.- Las syscalls crudas saltean la contabilidad de libc. Bypassear los wrappers puede
desincronizar estado de libc (stdio bufferizado, contabilidad de pthreads, la vista
del runtime sobre
brk);forkvía syscall cruda saltea los handlers depthread_atfork, un clásico. - El costo de la syscall es una restricción de diseño. Cruces charlatanes (I/O byte
por byte) son una falla de performance clásica; agrupar (stdio bufferizado,
writev, buffers más grandes) es el fix estándar. - La frontera también es el perímetro de seguridad. Los filtros seccomp, los sandboxes y los runtimes de contenedores razonan sobre procesos en términos de qué syscalls pueden hacer — otra razón para saber responder "¿qué syscalls hace este programa?".
En la práctica
- Usá los wrappers de libc. Agarrá
syscall(2)solo para syscalls de Linux sin wrapper, y asm crudo solo en código freestanding — donde la libc sos vos. straceprimero, debugger después cuando un programa se porta mal en la frontera con el OS: path incorrecto, permiso que falta, unENOENTsorpresivo — el trace lo muestra al instante.- Aprendé a leer las man pages de sección 2. La sección 2 es el contrato de la
syscall: argumentos, valor de retorno y los valores exactos de
errnoque cada llamada puede producir. - Contá cruces en los hot paths. Si el profiling muestra tiempo en kernel, preguntá "¿qué syscalls, cuán seguido, cuán chicas?" antes de optimizar código de user space.
- Esta frontera es la que vas a construir en el proyecto del OS. Instalar el entry point, despachar según el número, volver a modo usuario — el otro lado de esta nota.
Apéndice ARM64
Misma forma, otros nombres. En ARM64 Linux la instrucción de trap es svc #0
(supervisor call): número de syscall en x8, argumentos en x0–x5, valor de
retorno en x0, con la misma convención -errno. Los números difieren de x86-64
(write es 64, no 1) porque el port ARM64 arrancó con una tabla limpia y unificada. En
Apple Silicon, XNU toma el número en x16 y el trap es svc #0x80, con la convención
de error por carry flag — las ramas __aarch64__ del demo muestran ambos lado a lado:
#if defined(__APPLE__)
register long x16 __asm__("x16") = number; /* XNU: number in x16 */
__asm__ volatile("svc #0x80\n\t"
"cset %1, cs" ...);
#else
register long x8 __asm__("x8") = number; /* Linux: number in x8 */
__asm__ volatile("svc #0" ...);
#endif
Conecta con: Systems Programming · El address space del proceso y memoria virtual · Registros y la ISA · Calling convention System V AMD64 · La biblioteca estándar mínima · OS desde Cero
Sources
- Michael Kerrisk — The Linux Programming Interface, cap. 3 "System Programming Concepts" — la biblia de las syscalls; el mecanismo de trap, los wrappers y el manejo de errores en profundidad. https://man7.org/tlpi/
syscall(2)— man7.org — el wrapper genérico, más la tabla autoritativa por arquitectura de instrucciones de trap y convenciones de registros (número, args, retorno). https://man7.org/linux/man-pages/man2/syscall.2.htmlsyscalls(2)— man7.org — el catálogo de syscalls de Linux y la promesa de estabilidad de ABI del kernel. https://man7.org/linux/man-pages/man2/syscalls.2.htmlvdso(7)— man7.org — por qué algunas "syscalls" nunca entran al kernel, y cuáles según la arquitectura. https://man7.org/linux/man-pages/man7/vdso.7.html- System V AMD64 ABI — el apéndice A documenta la calling convention de syscalls de Linux x86-64 junto a la convención de funciones que espeja. https://gitlab.com/x86-psABIs/x86-64-ABI
- Stevens & Rago — Advanced Programming in the UNIX Environment (APUE) — el tratamiento clásico de la distinción syscall/función de biblioteca y la disciplina de errno. http://www.apuebook.com/
- XNU
syscalls.master— la tabla real de syscalls BSD de Apple, de donde salen los números de macOS del demo. https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/syscalls.master