Saltar a contenido

Syscalls

Aunque estamos hablando directamente con la CPU a través de instrucciones de máquina en Assembly, no tenemos que invocar cada tipo de comando utilizando solo instrucciones de máquina básicas. Los programas utilizan regularmente muchos tipos de operaciones. El Sistema Operativo puede ayudarnos a través de syscalls para no tener que ejecutar estas operaciones manualmente cada vez.

Por ejemplo, supongamos que necesitamos escribir algo en la pantalla sin syscalls. En ese caso, necesitaríamos comunicarnos con la Memoria de Video y la Entrada/Salida de Video, resolver cualquier codificación requerida, enviar nuestra entrada para que sea impresa y esperar la confirmación de que ha sido impresa. Como era de esperar, si tuviéramos que hacer todo esto para imprimir un solo carácter, los códigos en Assembly serían mucho más largos.


Linux Syscall

Un syscall es como una función globalmente disponible escrita en C, proporcionada por el Kernel del Sistema Operativo. Un syscall toma los argumentos necesarios en los registros y ejecuta la función con los argumentos proporcionados. Por ejemplo, si quisiéramos escribir algo en la pantalla, podríamos usar el syscall write, proporcionar la cadena que se va a imprimir y otros argumentos necesarios, y luego llamar al syscall para emitir la impresión.

Hay muchos syscalls disponibles proporcionados por el Kernel de Linux, y podemos encontrar una lista de ellos y el syscall number de cada uno leyendo el archivo del sistema unistd_64.h:

cat /usr/include/x86_64-linux-gnu/asm/unistd_64.h
#ifndef _ASM_X86_UNISTD_64_H
#define _ASM_X86_UNISTD_64_H 1

#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
#define __NR_stat 4
#define __NR_fstat 5

El archivo anterior establece el número de syscall para cada syscall para referirse a ese syscall utilizando este número.

Nota: Con procesadores 32-bit x86, los números de syscall están en el archivo unistd_32.h.

Practiquemos el uso de syscalls con el syscall write que imprime en pantalla. No imprimiremos los números de Fibonacci todavía, sino que comenzaremos imprimiendo un mensaje de introducción, Fibonacci Sequence, al principio de nuestro programa.


Syscall Function Arguments

Para usar el syscall write, primero debemos saber qué argumentos acepta. Para encontrar los argumentos aceptados por un syscall, podemos usar el comando man -s 2 con el nombre del syscall de la lista anterior:

man -s 2 write
...SNIP...
       ssize_t write(int fd, const void *buf, size_t count);

Como podemos ver en el resultado anterior, la función write tiene la siguiente sintaxis:

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

Vemos que la función syscall espera 3 argumentos:

  1. Descriptor de archivo fd al que se imprimirá (usualmente 1 para stdout)
  2. El puntero a la dirección de la cadena que se imprimirá
  3. La longitud que queremos imprimir

Una vez que proporcionemos estos argumentos, podemos usar la instrucción syscall para ejecutar la función e imprimir en pantalla. Además de estos métodos manuales para localizar syscalls y argumentos de funciones, existen recursos en línea que podemos utilizar para buscar rápidamente syscalls, sus números y los argumentos que esperan, como esta tabla. Además, siempre podemos referirnos al código fuente de Linux en Github.

Consejo: El flag -s 2 especifica las páginas man de syscall. Podemos consultar man man para ver varias secciones de cada página man.


Syscall Calling Convention

Ahora que entendemos cómo localizar varios syscalls y sus argumentos, comencemos a aprender cómo llamarlos. Para llamar a un syscall, debemos:

  1. Guardar los registros en la pila
  2. Establecer su número de syscall en rax
  3. Configurar sus argumentos en los registros
  4. Usar la instrucción de ensamblado syscall para llamarlo

Generalmente deberíamos guardar cualquier registro que utilicemos en la pila antes de cualquier llamada a función o syscall. Sin embargo, como estamos ejecutando este syscall al principio de nuestro programa antes de usar cualquier registro, no tenemos valores en los registros, por lo que no deberíamos preocuparnos por guardarlos. Discutiremos cómo guardar registros en la pila cuando lleguemos a Function Calls.

Syscall Number

Comencemos moviendo el número del syscall al registro rax. Como vimos antes, el syscall write tiene el número 1, por lo que podemos comenzar con el siguiente comando:

mov rax, 1

Ahora, si llegamos a la instrucción syscall, el Kernel sabría qué syscall estamos llamando.

Syscall Arguments

A continuación, debemos colocar cada uno de los argumentos de la función en su registro correspondiente. La convención de llamada de la arquitectura x86_64 especifica en qué registro debe colocarse cada argumento (por ejemplo, el primer argumento debe estar en rdi). Todas las funciones y syscalls deben seguir este estándar y tomar sus argumentos de los registros correspondientes. Hemos discutido la siguiente tabla en la sección Registers:

Descripción Registro 64-bit Registro 8-bit
Número de Syscall/Valor de retorno rax al
Guardado por el Callee rbx bl
1er argumento rdi dil
2do argumento rsi sil
3er argumento rdx dl
4to argumento rcx cl
5to argumento r8 r8b
6to argumento r9 r9b

Como podemos ver, tenemos un registro para cada uno de los primeros 6 argumentos. Cualquier argumento adicional puede almacenarse en la pila (aunque no muchos syscalls usan más de 6 argumentos).

Nota: rax también se utiliza para almacenar el valor de retorno de un syscall o función. Por lo tanto, si esperamos obtener un valor de un syscall/función, estará en rax.

Con eso, deberíamos conocer nuestros argumentos y en qué registro debemos almacenarlos. Volviendo a la función syscall write, debemos pasar: fd, pointer y length. Podemos hacerlo de la siguiente manera:

  1. rdi -> 1 (para stdout)
  2. rsi -> 'Fibonacci Sequence:\n' (puntero a nuestra cadena)
  3. rdx -> 20 (longitud de nuestra cadena)

Podemos usar mov rcx, 'string'. Sin embargo, solo podemos almacenar hasta 16 caracteres en un registro (es decir, 64 bits), por lo que nuestra cadena de introducción no cabría. En su lugar, creemos una variable con nuestra cadena (como aprendimos en la sección Assembly File Structure):

global  _start

section .data
    message db "Fibonacci Sequence:", 0x0a

Note cómo agregamos 0x0a después de nuestra cadena, para añadir un carácter de nueva línea.

La etiqueta message es un puntero a donde se almacenará nuestra cadena en la memoria. Por lo tanto, podemos usarla como nuestro segundo argumento. Así que, nuestro código final de syscall debería ser el siguiente:

mov rax, 1       ; rax: syscall number 1
mov rdi, 1      ; rdi: fd 1 for stdout
mov rsi,message ; rsi: pointer to message
mov rdx, 20      ; rdx: print length of 20 bytes

Consejo: Si alguna vez necesitamos crear un puntero a un valor almacenado en un registro, podemos simplemente empujarlo a la pila, y luego usar el puntero rsp para apuntar a él.

También podemos usar una variable length calculada dinámicamente usando equ, de manera similar a lo que hicimos con el programa Hello World.


Calling Syscall

Ahora que tenemos nuestro número de syscall y argumentos en su lugar, lo único que queda es ejecutar la instrucción syscall. Así que, agreguemos una instrucción syscall y agreguemos las instrucciones al comienzo de nuestro código fib.s, que debería verse así:

global  _start

section .data
    message db "Fibonacci Sequence:", 0x0a

section .text
_start:
    mov rax, 1       ; rax: syscall number 1
    mov rdi, 1      ; rdi: fd 1 for stdout
    mov rsi,message ; rsi: pointer to message
    mov rdx, 20      ; rdx: print length of 20 bytes
    syscall         ; call write syscall to the intro message
    xor rax, rax    ; initialize rax to 0
    xor rbx, rbx    ; initialize rbx to 0
    inc rbx         ; increment rbx to 1
loopFib:
    add rax, rbx    ; get the next number
    xchg rax, rbx   ; swap values
    cmp rbx, 10     ; do rbx - 10
    js loopFib      ; jump if result is <0

Ahora ensamblaremos nuestro código y lo ejecutaremos, para ver si nuestro mensaje introductorio se imprime:

./assembler.sh fib.s

Fibonacci Sequence:
[1]    107348 segmentation fault  ./fib

Vemos que, efectivamente, nuestra cadena se imprime en la pantalla. Ejecutémoslo a través de gdb, y hagamos un break en la syscall para ver cómo están configurados todos los argumentos antes de llamar a la syscall, de la siguiente manera:

$ gdb -q ./fib
gef  disas _start
Dump of assembler code for function _start:
..SNIP...
0x0000000000401011 <+17>:   syscall 
gef  b *_start+17
Breakpoint 1 at 0x401011
gef  r
───────────────────────────────────────────────────────────────────────────────────── registers ────
$rax   : 0x1               
$rbx   : 0x0               
$rcx   : 0x0               
$rdx   : 0x14              
$rsp   : 0x00007fffffffe410    0x0000000000000001
$rbp   : 0x0               
$rsi   : 0x0000000000402000    "Fibonacci Sequence:
"
$rdi   : 0x1 

gef  si

Secuencia de Fibonacci:

Vemos un par de cosas que esperábamos:

  1. Nuestros argumentos están correctamente configurados en los registros correspondientes antes de cada syscall.
  2. Un puntero a nuestro mensaje está cargado en rsi.

Ahora, hemos utilizado exitosamente la syscall write para imprimir nuestro mensaje introductorio.


Exit Syscall

Finalmente, dado que hemos entendido cómo funcionan las syscalls, repasemos otra syscall esencial utilizada en los programas: Exit syscall. Podemos haber notado que hasta ahora, cada vez que nuestro programa termina de ejecutarse, sale con un segmentation fault, como vimos al ejecutar ./fib. Esto se debe a que estamos finalizando nuestro programa abruptamente, sin pasar por el procedimiento adecuado de salida de programas en Linux, llamando a la exit syscall y pasando un código de salida.

Así que, agreguemos esto al final de nuestro código. Primero, necesitamos encontrar el número de exit syscall, de la siguiente manera:

grep exit /usr/include/x86_64-linux-gnu/asm/unistd_64.h

#define __NR_exit 60
#define __NR_exit_group 231

Necesitamos usar el primero, con un número de syscall 60. A continuación, veamos si la exit syscall necesita algún argumento:

man -s 2 exit

...SNIP...
void _exit(int status);

Vemos que solo necesita un argumento entero, status, que se explica como el código de salida. En Linux, cada vez que un programa termina sin errores, pasa un código de salida de 0. De lo contrario, el código de salida es un número diferente, usualmente 1. En nuestro caso, como todo salió según lo esperado, pasaremos el código de salida de 0. Nuestro código de exit syscall debería ser el siguiente:

    mov rax, 60
    mov rdi, 0
    syscall

Ahora, agreguémoslo al final de nuestro código:

global  _start

section .data
    message db "Fibonacci Sequence:", 0x0a

section .text
_start:
    mov rax, 1       ; rax: syscall number 1
    mov rdi, 1      ; rdi: fd 1 for stdout
    mov rsi,message ; rsi: pointer to message
    mov rdx, 20      ; rdx: print length of 20 bytes
    syscall         ; call write syscall to the intro message
    xor rax, rax    ; initialize rax to 0
    xor rbx, rbx    ; initialize rbx to 0
    inc rbx         ; increment rbx to 1
loopFib:
    add rax, rbx    ; get the next number
    xchg rax, rbx   ; swap values
    cmp rbx, 10     ; do rbx - 10
    js loopFib      ; jump if result is <0
    mov rax, 60
    mov rdi, 0
    syscall

Ahora podemos ensamblar nuestro código y ejecutarlo nuevamente:

./assembler.sh fib.s

Fibonacci Sequence:

¡Genial! Vemos que esta vez nuestro programa terminó correctamente sin un segmentation fault. Podemos verificar el código de salida que fue pasado de la siguiente manera:

echo $?

0

Como se esperaba, el código de salida fue 0, tal como especificamos en nuestra syscall.

Práctica: Para comprender completamente cómo funcionan las syscalls, intenta implementar la syscall write para imprimir el número de Fibonacci, y colócala después de 'xchg rax, rbx'.

Spoiler: No funcionará. Intenta descubrir por qué, y trata de arreglarlo para imprimir los primeros números de Fibonacci menores a 10 (pista: usa ASCII).