Saltar a contenido

Functions

Ya deberíamos entender las diferentes instrucciones de bifurcación y control utilizadas para manejar el flujo de ejecución del programa. También deberíamos tener un buen conocimiento sobre procedimientos y llamadas, y cómo utilizarlos para la bifurcación. Así que ahora enfoquémonos en llamar funciones.


Functions Calling Convention

Las funciones son una forma de procedures. Sin embargo, las funciones tienden a ser más complejas y se espera que utilicen completamente la pila y todos los registros. Por lo tanto, no podemos simplemente llamar a una función como lo hicimos con los procedimientos. En su lugar, las funciones tienen un Calling Convention para configurarse correctamente antes de ser llamadas.

Hay cuatro cosas principales que debemos considerar antes de llamar a una función:

  1. Save Registers en la pila (Caller Saved)
  2. Pasar Function Arguments (como syscalls)
  3. Ajustar el Stack Alignment
  4. Obtener el Return Value de la función (en rax)

Esto es relativamente similar a llamar a un syscall, y la única diferencia con los syscalls es que tenemos que almacenar el número de syscall en rax, mientras que podemos llamar funciones directamente con call function. Además, con syscall no tenemos que preocuparnos por el Stack Alignment.

Writing Functions

Todos los puntos anteriores son desde el punto de vista del caller, es decir, quien llama a una función. Cuando se trata de escribir una función, hay diferentes puntos a considerar, que son:

  1. Guardar los registros Callee Saved (rbx y rbp)
  2. Obtener argumentos desde los registros
  3. Alinear la pila
  4. Devolver el valor en rax

Como podemos ver, estos puntos son relativamente similares a los puntos del caller. El caller está configurando las cosas, y luego el callee (es decir, el receptor) debe recuperarlas y utilizarlas. Estos puntos generalmente se realizan al principio y al final de la función y se llaman prologue y epilogue de una función. Permiten que las funciones sean llamadas sin preocuparse por el estado actual de la pila o los registros.

En este módulo, solo estaremos llamando a otras funciones, por lo que solo tenemos que centrarnos en configurar una llamada de función y no entraremos en escribir funciones.


Using External Functions

Queremos imprimir el número actual de Fibonacci en cada iteración del bucle loopFib. Anteriormente, no pudimos usar un syscall write ya que solo acepta caracteres ASCII. Habríamos tenido que convertir nuestro número de Fibonacci a ASCII, lo cual es un poco complicado.

Afortunadamente, hay funciones externas que podemos usar para imprimir el número actual sin tener que convertirlo. La biblioteca de funciones libc utilizada para programas en C proporciona muchas funcionalidades que podemos utilizar sin reescribir todo desde cero. La función printf en libc acepta el formato de impresión, por lo que podemos pasarle el número actual de Fibonacci y decirle que lo imprima como un entero, y hará la conversión automáticamente. Antes de poder usar una función de libc, primero tenemos que importarla y luego especificar la biblioteca libc para el enlace dinámico al enlazar nuestro código con ld.


Importing libc Functions

Primero, para importar una función externa de libc, podemos usar la instrucción extern al comienzo de nuestro código, de la siguiente manera:

global  _start
extern  printf

Una vez hecho esto, deberíamos poder llamar a la función printf. Así que podemos proceder con el Functions Calling Convention que discutimos anteriormente.


Saving Registers

Definamos un nuevo procedimiento, printFib, para contener nuestras instrucciones de llamada a función. El primer paso es guardar en la pila cualquier registro que estemos utilizando, que son rax y rbx, de la siguiente manera:

printFib:
    push rax        ; push registers to stack
    push rbx
    ; function call
    pop rbx         ; restore registers from stack
    pop rax
    ret

Así que podemos proceder con el segundo punto, y pasar los argumentos requeridos a printf.


Function Arguments

Ya hemos discutido cómo pasar argumentos de función en la sección de syscalls. El mismo proceso se aplica a los argumentos de función.

Primero, necesitamos averiguar qué argumentos acepta la función printf usando man -s 3 para library functions manual (como podemos ver en man man):

man -s 3 printf

...SNIP...
       int printf(const char *format, ...);

Como podemos ver, la función toma un puntero al formato de impresión (mostrado con un *), y luego las cadenas a imprimir.

Primero, podemos crear una variable que contenga el formato de salida para pasarla como primer argumento. La página del manual de printf también detalla varios formatos de impresión. Queremos imprimir un entero, por lo que podemos usar el formato %d, de la siguiente manera:

global  _start
extern  printf

section .data
    message db "Fibonacci Sequence:", 0x0a
    outFormat db  "%d", 0x0a, 0x00

Nota: Terminamos el formato con un carácter nulo 0x00, ya que este es el terminador de cadena en printf, y debemos terminar cualquier cadena con él.

Este puede ser nuestro primer argumento, y rbx como nuestro segundo argumento, que printf colocará como %d. Así que movamos ambos argumentos a sus respectivos registros, de la siguiente manera:

printFib:
    push rax            ; push registers to stack
    push rbx
    mov rdi, outFormat  ; set 1st argument (Print Format)
    mov rsi, rbx        ; set 2nd argument (Fib Number)
    pop rbx             ; restore registers from stack
    pop rax
    ret

Stack Alignment

Siempre que queramos hacer un call a una función, debemos asegurarnos de que el Top Stack Pointer (rsp) esté alineado en un límite de 16-byte desde la pila de la función _start.

Esto significa que debemos agregar al menos 16 bytes (o un múltiplo de 16 bytes) a la pila antes de hacer una llamada para garantizar que las funciones tengan suficiente espacio en la pila para ejecutarse correctamente. Este requisito existe principalmente para la eficiencia del rendimiento del procesador. Algunas funciones (como en libc) están programadas para fallar si este límite no está corregido, con el fin de garantizar la eficiencia en el rendimiento. Si ensamblamos nuestro código y hacemos un break justo después del segundo push, esto es lo que veremos:

───────────────────────────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffe3a0+0x0000: 0x0000000000000001    $rsp
0x00007fffffffe3a8+0x0008: 0x0000000000000000
0x00007fffffffe3b0+0x0010: 0x00000000004010ad    <loopFib+5> add rax, rbx
0x00007fffffffe3b8+0x0018: 0x0000000000401044    <_start+20> call 0x4010bd <Exit>
0x00007fffffffe3c0+0x0020: 0x0000000000000001    $r13
─────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
   0x401090 <initFib+9>      ret    
     0x401091 <printFib+0>     push   rax
     0x401092 <printFib+1>     push   rbx
    0x40100e <printFib+2>     movabs rdi, 0x403039

Vemos que tenemos cuatro bloques de 8 bytes empujados a la pila, creando un límite total de 32 bytes. Esto se debe a dos cosas:

  1. Cada procedimiento call agrega una dirección de 8 bytes a la pila, la cual luego es eliminada con ret.
  2. Cada push también agrega 8 bytes a la pila.

Así que estamos dentro de printFib y dentro de loopFib, y hemos empujado rax y rbx, para un total de un límite de 32 bytes. Dado que el límite es un múltiplo de 16, nuestra pila ya está alineada y no tenemos que corregir nada.

Si estuviéramos en un caso donde quisiéramos aumentar el límite a 16, podemos restar bytes de rsp, de la siguiente manera:

    sub rsp, 16
    call function
    add rsp, 16

De esta forma, estamos agregando 16 bytes adicionales en la parte superior de la pila y luego eliminándolos después de la llamada. Si tuviéramos 8 bytes empujados, podemos aumentar el límite a 16 restando 8 de rsp.

Esto puede ser un poco confuso, pero lo más importante que debemos recordar es que debemos tener 16 bytes (o un múltiplo de 16) en la parte superior de la pila antes de hacer una llamada. Podemos contar el número de instrucciones push (no poped) y las instrucciones call (no retornadas), y obtendremos cuántos bloques de 8 bytes se han empujado a la pila.


Function Call

Finalmente, podemos emitir un call printf, y debería imprimir el número Fibonacci actual en el formato especificado, de la siguiente manera:

printFib:
    push rax            ; push registers to stack
    push rbx
    mov rdi, outFormat  ; set 1st argument (Print Format)
    mov rsi, rbx        ; set 2nd argument (Fib Number)
    call printf         ; printf(outFormat, rbx)
    pop rbx             ; restore registers from stack
    pop rax
    ret

Ahora deberíamos tener nuestro procedimiento printFib listo. Así que podemos agregarlo al inicio de loopFib, de modo que imprima el número Fibonacci actual al comienzo de cada bucle:

loopFib:
    call printFib   ; print current Fib number
    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
    ret

Nuestro código final fib.s debería ser el siguiente:

global  _start
extern  printf

section .data
    message db "Fibonacci Sequence:", 0x0a
    outFormat db  "%d", 0x0a, 0x00

section .text
_start:
    call printMessage   ; print intro message
    call initFib        ; set initial Fib values
    call loopFib        ; calculate Fib numbers
    call Exit           ; Exit the program

printMessage:
    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
    ret

initFib:
    xor rax, rax        ; initialize rax to 0
    xor rbx, rbx        ; initialize rbx to 0
    inc rbx             ; increment rbx to 1
    ret

printFib:
    push rax            ; push registers to stack
    push rbx
    mov rdi, outFormat  ; set 1st argument (Print Format)
    mov rsi, rbx        ; set 2nd argument (Fib Number)
    call printf         ; printf(outFormat, rbx)
    pop rbx             ; restore registers from stack
    pop rax
    ret

loopFib:
    call printFib       ; print current Fib number
    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
    ret

Exit:
    mov rax, 60
    mov rdi, 0
    syscall

Dynamic Linker

Ahora podemos ensamblar nuestro código con nasm. Cuando enlacemos nuestro código con ld, debemos indicarle que realice un enlace dinámico con la biblioteca libc. De lo contrario, no sabría cómo obtener la función printf importada. Podemos hacerlo con las flags -lc --dynamic-linker /lib64/ld-linux-x86-64.so.2, de la siguiente manera:

nasm -f elf64 fib.s &&  ld fib.o -o fib -lc --dynamic-linker /lib64/ld-linux-x86-64.so.2 && ./fib

1
1
2
3
5
8

Como podemos ver, printf facilitó mucho la impresión de nuestro número Fibonacci sin preocuparnos por convertirlo al formato adecuado, como teníamos que hacerlo con la syscall write. A continuación, necesitamos revisar otro ejemplo sobre el uso de funciones externas de libc para entender cómo llamar a funciones externas correctamente.