conceptProgramación de Sistemas~9 min de lecturaActualizado 2026-07-01#systems#syscalls#kernel#abi#errno#strace

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) a rax.
  • 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 en r10, porque la instrucción syscall pisa rcx (guarda ahí el rip de retorno) y r11 (los rflags).
  • 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 en rax y vuelve a modo usuario con sysret.

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, getcpu en x86-64. El kernel mantiene actualizada una página de datos; la función del vDSO solo la lee. Por eso un loop apretado de gettimeofday no muestra nada en strace — 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 ./demo imprime cada syscall con argumentos decodificados y valores de retorno — read(3, "abc", 128) = 3. strace -c agrega conteos y tiempo por syscall; ltrace muestra 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 -c diciéndote que hizo dos millones de write de 1 byte es un diagnóstico completo.

macOS/BSD vs Linux: misma idea, contrato distinto

  • Los números difieren por kernel. write es 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 (-errno en 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. write puede devolver un conteo corto aun en éxito — chequear solo != -1 no alcanza.
  • errno solo 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); fork vía syscall cruda saltea los handlers de pthread_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.
  • strace primero, debugger después cuando un programa se porta mal en la frontera con el OS: path incorrecto, permiso que falta, un ENOENT sorpresivo — 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 errno que 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 x0x5, 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.html
  • syscalls(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.html
  • vdso(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