Tracks
/
x86-64 Assembly
x86-64 Assembly
/
Ejercicios
/
El pastel de Piper
El pastel de Piper

El pastel de Piper

Ejercicio de aprendizaje

Introducción

Recursión

Una función es recursiva cuando se llama a sí misma.

Una diferencia clave entre una llamada a función y un bucle es que llamar a una función coloca en la pila la dirección de retorno. Esto significa que una función recursiva normalmente necesita más espacio en la pila que un bucle equivalente.

Como consecuencia, una función que no deja de llamarse a sí misma puede llegar a agotar todo el espacio de la pila. A esto se le llama desbordamiento de pila.

Por eso toda función recursiva debe tener al menos un caso base, que es una situación en la que la función devuelve un valor sin llamarse a sí misma. Toda llamada recursiva debe llegar tarde o temprano a un caso base.

Por ejemplo, la función factorial n! = n * (n - 1) * ... * 1 se puede definir de forma recursiva con 1 como caso base:

factorial:
    ; the argument `n` is passed on `rdi`
    ; the factorial will be returned on `rax`

    cmp rdi, 1
    jle .base_case     ; base case -> if rdi <= 1, return 1

    push rdi           ; save n
    dec rdi            ; rdi = n - 1
    call factorial     ; recursive call, rax = (n - 1)!
    pop rdi            ; restore n
    imul rax, rdi      ; rax = n * (n - 1)! = n!
    ret
.base_case:
    mov rax, 1
    ret

Fíjate que factorial debe hacer push rdi antes de la llamada recursiva y pop rdi después. Esto es porque todavía necesita n una vez que la llamada recursiva finaliza, para calcular n * (n-1)!.

Fíjate además que usar un registro preservado por la función llamada no resolvería este problema.

Aunque una función recursiva puede llamarse a sí misma, también es la función llamada por parte de otra función. Eso significa que la función también debe preservar los registros que le corresponde conservar como función llamada antes de usarlos, y restaurar su valor cuando termina de usarlos. Esto normalmente se hace con una secuencia de push/pop, como vimos en un concepto anterior.

Como cada marco de una función recursiva, con la excepción del caso base, también es quien llama y necesita preservar sus propias variables locales, esta secuencia de push/pop debe repetirse para cada marco. Incluso guardar la variable directamente en la pila, sin usar registros, seguiría costando los mismos 8 bytes por marco.

Esto significa que cada llamada recursiva añade 8 bytes a la pila por la dirección de retorno que coloca call, más 8 bytes por cada variable local que necesita guardar. La función seguirá añadiendo esos bytes a la pila en cada marco hasta que llegue a su caso base. Solo entonces empieza a desenrollarse en orden inverso, y cada llamada recursiva usa tantos pop como necesite y luego un ret.

Por ejemplo, si se llamara a factorial con el argumento 10, se llamaría a sí misma nueve veces antes de llegar al caso base de 1. En ese punto, se habrían usado 144 bytes para guardar la n (8 bytes) y la dirección de retorno (8 bytes) de cada marco anterior.

Llamada de cola

En algunas situaciones, una función no realiza más trabajo después de llamar a otra y antes de retornar.

Considera, por ejemplo:

times_three:
    imul rax, rdi, 3
    ret

triple_of_square:
    imul rdi, rdi
    call times_three
    ret

La función triple_of_square:

  • multiplica por sí mismo el argumento que recibe (en rdi), obteniendo su cuadrado;
  • luego llama a times_three, que devuelve tres multiplicado por el argumento que se le pasa.

Como resultado, triple_of_square devuelve 3*x², donde x es su argumento, que se pasa en rdi.

Fíjate que en triple_of_square no se hace ningún trabajo después de llamar a times_three: la función simplemente retorna. En una situación como esta, en lugar de usar call, una función puede usar jmp y transferir la ejecución a la función llamada:

times_three:
    imul rax, rdi, 3
    ret

triple_of_square:
    imul rdi, rdi
    jmp times_three

A esto se le llama llamada de cola.

La principal ventaja de una llamada de cola es que evita el costo extra de call. Un call coloca una dirección de retorno en la pila, y para que el control vuelva a ese punto debe haber un ret que le corresponda.

Una llamada de cola se salta ambas cosas: no hay dirección de retorno que colocar ni un ret adicional con el que emparejar, solo el ret propio de la función llamada.

Recursión de cola

Una llamada de cola es especialmente útil para funciones recursivas que pueden llamarse a sí mismas muchas veces antes de retornar.

Sin embargo, no toda llamada recursiva se puede convertir directamente en una llamada de cola. Como un jmp transfiere el control a la función llamada, quien llama ya no puede realizar más trabajo después de la llamada de cola.

Por ejemplo, la función factorial que vimos antes no es recursiva de cola. Después de la llamada recursiva, todavía necesita multiplicar el resultado por la n actual, usando imul rax, rdi.

En situaciones como esta, a veces es posible usar un acumulador que vaya recogiendo los cálculos parciales y se devuelva al final. Por ejemplo, podemos definir un factorial_helper que haga la mayor parte del trabajo, y luego factorial prepara un acumulador y transfiere el control a factorial_helper:

factorial_helper:
    ; the argument `n` is passed on `rdi`
    ; `rax` is used as an accumulator and will be returned at the end

    cmp rdi, 1
    jle .base_case

    imul rax, rdi        ; we accumulate the partial result on `rax`
    dec rdi              ; rdi = n - 1
    jmp factorial_helper ; tail call to accumulate (n - 1)!
.base_case:
    ret                  ; returns the factorial already accumulated on `rax`

factorial:
    mov rax, 1           ; initial value for the accumulator
    jmp factorial_helper ; tail call

Como ya no se hace más trabajo después de la llamada recursiva, tampoco necesitamos guardar rdi. No hay ningún call ni push rdi, así que cada iteración recursiva añade 0 bytes a la pila: no se usa espacio adicional en la pila. Esta versión puede manejar valores de n arbitrariamente grandes sin desbordar la pila. Es a la vez más eficiente y más segura.

En algunos casos, manipulando el orden de las funciones, se puede evitar incluso el jmp hacia la función auxiliar. Por ejemplo, factorial y triple_of_square se pueden reescribir de esta manera:

factorial:
    mov rax, 1
factorial_helper:
    cmp rdi, 1
    jle .base_case

    imul rax, rdi
    dec rdi
    jmp factorial_helper
.base_case:
    ret

triple_of_square:
    imul rdi, rdi
times_three:
    imul rax, rdi, 3
    ret

En el fragmento anterior, la ejecución de factorial continúa directamente en factorial_helper. Lo mismo ocurre con triple_of_square y times_three. En ambos casos, la ejecución continúa de forma secuencial y parece que la función de cola es solo una etiqueta local dentro de la función «main».

En realidad, no hay ninguna diferencia esencial entre cualquier etiqueta local y una función. El ensamblador de x86-64 no les da ningún trato especial a ninguna de ellas: solo son direcciones en una sección con código ejecutable, como section .text.

De esta forma, una función recursiva de cola se puede pensar esencialmente igual que un bucle en el que la llamada recursiva salta de vuelta al inicio y el caso base es la condición que termina el bucle.

Instrucciones

Piper es una apasionada de hornear tartas.

Nadie sabe si eligió hornear tartas por su nombre, o si cambió su nombre para que coincidiera con su pasatiempo. A simple vista, lo segundo no parece muy probable, pero verás, Piper está absolutamente fascinada por las tartas. Siempre está experimentando en la cocina, ajustando sus recetas, mejorando su técnica, para deleite absoluto de sus amigos. Nada escapa a su atención al detalle: ni la temperatura de su horno, ni el peso de cada bola de masa, y mucho menos la forma de la tarta misma.

¿Su interés más reciente? Hornear tartas lo más circulares posible, hasta alcanzar la perfección matemática, con la ayuda de su número favorito, adivinaste: π.

Piper encontró una fórmula encantadora para calcular π de forma iterativa, la transformación de convergencia de Newton/Euler:

π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!

Ayuda a Piper a poner su cocina en orden y a hornear su tarta matemáticamente perfecta.

1. Porciona la masa

Esta mañana, Piper estiró dos lotes de masa con pesos diferentes (en g). Para que sus tartas queden uniformes, quiere dividir ambos lotes en bolas del mismo peso. ¡Y, por supuesto, quiere que las porciones sean lo más grandes posible para desperdiciar la menor cantidad de masa!

El peso más grande que divide ambos lotes de manera exacta es su máximo común divisor. El algoritmo de Euclides lo calcula de forma recursiva:

  • gcd(a, 0) = a (caso base)
  • gcd(a, b) = gcd(b, a mod b)

Observa que la llamada recursiva está en posición de cola: no sucede nada después de ella. Define largest_portion de manera que el paso recursivo sea un jmp a la propia función, no un call.

largest_portion(252, 105);
// => 21

Ambos argumentos son enteros no negativos de 64 bits. El valor de retorno es un entero no negativo de 64 bits.

2. Doble factorial

Ya sabes cómo escribir el factorial ordinario de forma recursiva de cola a partir del concepto. La misma función está en tu archivo de plantilla.

Sin embargo, la fórmula de Newton/Euler también usa dobles factoriales, escritos !!. El operador de doble factorial se define así:

0!! = 1
n!! = 1 * 3 * 5 * ... * n // if n is odd
n!! = 2 * 4 * 6 * ... * n // if n is even

Observa que el doble factorial sigue el mismo patrón que el factorial, salvo que disminuye de 2 en 2 en cada paso en lugar de 1 en 1. Define la función double_factorial, que calculará el doble factorial de forma recursiva de cola.

double_factorial(5);
// => 15
double_factorial(6);
// => 48

El argumento es un entero sin signo de 32 bits. El valor de retorno es un entero sin signo de 64 bits.

3. Transformación de convergencia de Newton/Euler

Ahora Piper tiene todas las herramientas que necesita. Define la función pipers_pi, que aproxima π usando un número determinado de términos de la fórmula de transformación de convergencia de Newton/Euler:

π / 2 = sum for k from 0 to infinity of ( k! ) / ( 2 * k + 1 )!!

El numerador usa el factorial ordinario. ¡Puedes llamar a la función factorial que ya está definida para ti! El denominador usa el double_factorial que escribiste en la tarea 2.

Calculemos juntos el primer término. Para un límite superior de 0 (en lugar de infinito), obtenemos:

π / 2 ≈ sum for k from 0 to 0 of ( k! ) / ( 2 * k + 1 )!!
π / 2 ≈ (0!) / ( 2 * 0 + 1 )!!
π / 2 ≈ 0! / 1!!
π / 2 ≈ 1 / 1
π / 2 ≈ 1.0
π ≈ 2.0

Para un límite superior de 2, obtenemos en cambio:

π / 2 ≈ sum for k from 0 to 2 of ( k! ) / ( 2 * k + 1 )!!
π / 2 ≈ ((0!) / ( 2 * 0 + 1 )!!) + ((1!) / ( 2 * 1 + 1 )!!) + ((2!) / ( 2 * 2 + 1 )!!)
π / 2 ≈ 1 + (1! / 3!!) + (2! / 5!!)
π / 2 ≈ 1 + (1 / 3) + (2 / 15)
π / 2 ≈ 1.4666666
π ≈ 2.9333333

Cada término adicional mejorará la aproximación.

pipers_pi(0);
// => 2.0
pipers_pi(1);
// => 2.6666666
pipers_pi(2);
// => 2.9333333

El argumento es un entero no negativo de 32 bits. El valor de retorno es un número de punto flotante de 64 bits.

Editar en GitHub El enlace se abre en una ventana o una pestaña nuevas
x86-64 Assembly Exercism

¿Todo listo para empezar El pastel de Piper?

Regístrate en Exercism para aprender y dominar x86-64 Assembly con 22 conceptos130 ejercicios y mentoría humana real, todo gratis.