轨道
/
x86-64 Assembly
x86-64 Assembly
/
练习
/
派珀的馅饼
派珀的馅饼

派珀的馅饼

学习练习

简介

递归

当一个函数调用自身时,它就是递归的。

函数调用和循环的一个关键区别在于:调用函数会把返回地址压入栈。这意味着递归函数通常比等价的循环需要更多的栈空间。

因此,一个不断调用自身的函数最终可能会耗尽所有栈空间。这称为栈溢出。

这就是为什么每个递归函数都必须至少有一个基本情况:函数不再调用自身、直接返回的那种情况。任何递归调用最终都必须到达某个基本情况。

例如,阶乘函数 n! = n * (n - 1) * ... * 1 可以递归地定义,把 1 作为基本情况:

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

注意,factorial 必须在递归调用前 push rdi,在递归调用之后 pop rdi。因为递归调用返回之后,它为了计算 n * (n-1)! 仍然需要 n。

另请注意,使用被调用者保存寄存器并不能解决这个问题。

尽管递归函数可能是自身的调用者,但它同时也是其他某个函数的被调用者。这意味着该函数在使用被调用者保存寄存器之前,也必须先保存它们,并在使用之后恢复它们的值。这通常通过 push/pop 序列来完成,我们在之前的概念中已经见过。

递归函数的每一帧(基本情况除外)也都是一个调用者,需要保存自己的局部变量,因此这个 push/pop 序列必须在每一帧重复。即使不通过寄存器、而是直接把变量存到栈上,每一帧仍然要花费同样的 8 字节。

这意味着每次递归调用都会为 call 压入的返回地址向栈上添加 8 字节,再为它需要保存的每个局部变量添加 8 字节。函数会在每一帧不断向栈上添加这些字节,直到到达基本情况。只有到了那时,它才开始按相反的顺序展开,每个递归调用按需执行若干次 pop,然后执行一次 ret。

例如,如果以实参 10 调用 factorial,它会在到达 1 这个基本情况之前调用自身九次。到那时,已经用了 144 字节来为之前每一帧保存 n(8 字节)和返回地址(8 字节)。

尾调用

在某些情况下,函数在调用另一个函数之后、返回之前,不再执行任何其他工作。

例如:

times_three:
    imul rax, rdi, 3
    ret

triple_of_square:
    imul rdi, rdi
    call times_three
    ret

函数 triple_of_square 会:

  • 把传入的实参(在 rdi 中)与自身相乘,得到它的平方;
  • 然后调用 times_three,它返回传入实参的三倍。

结果是,triple_of_square 返回 3*x²,其中 x 是它的实参,通过 rdi 传入。

注意,triple_of_square 在调用 times_three 之后不再做任何工作,函数直接返回。在这种情况下,函数可以不用 call,而是用 jmp 把执行转移给被调用的函数:

times_three:
    imul rax, rdi, 3
    ret

triple_of_square:
    imul rdi, rdi
    jmp times_three

这称为尾调用。

尾调用的主要好处是避免了 call 的额外开销。call 会把返回地址压入栈,而要让控制权回到那个位置,就必须有与之匹配的 ret。

尾调用把两者都省掉了:没有要压入的返回地址,也没有要配对的额外 ret,只有被调用函数自己的 ret。

尾递归

对于可能在返回前多次调用自身的递归函数,尾调用特别有用。

然而,并非每个递归调用都能直接转写成尾调用。由于 jmp 会把控制权转移给被调用的函数,调用者在尾调用之后无法再做任何工作。

例如,前面的 factorial 函数不是尾递归的。在递归调用之后,它仍然需要用 imul rax, rdi 把结果与当前的 n 相乘。

在这种情况下,有时可以使用一个累加器,把部分计算结果收集起来,最后返回。例如,我们可以定义一个 factorial_helper 来完成大部分工作,然后让 factorial 设置好累加器,并把控制权转移给 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

由于递归调用之后不再做任何工作,我们也不再需要保存 rdi。这里没有 call 或 push rdi,因此每次递归迭代向栈上添加 0 字节:不会占用额外的栈空间。这个版本可以处理任意大的 n 而不会导致栈溢出。它既更高效,也更安全。

在某些情况下,通过调整函数的顺序,甚至可以省去跳转到辅助函数的 jmp。例如,factorial 和 triple_of_square 可以这样改写:

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

在上面的代码片段中,factorial 的执行会自然落入 factorial_helper。triple_of_square 和 times_three 也是如此。两种情况中,执行都顺序进行下去,尾函数看起来就像是“主”函数内部的一个局部标签。

实际上,任何局部标签和函数之间都没有本质区别。x86-64 汇编不会对它们中的任何一个给予特殊对待,它们只是像 section .text 那样包含可执行代码的段中的地址。

这样看来,尾递归函数基本上可以看作是一个循环:递归调用跳回顶部,而基本情况则是结束循环的条件。

说明

Piper 是一位热爱做派的烘焙师。

没人知道她选择做派是因为自己的名字,还是为了让名字配得上爱好而改了名字。 乍看之下,后一种可能似乎不太大,但你要知道,Piper 对派简直着了迷。 她总是在厨房里捣鼓,调整配方,精进手艺,把朋友们乐坏了。 没有什么能逃过她对细节的关注,烤箱的温度如此,每块面团的重量如此,派本身的形状更是如此。

她最近迷上了什么? 把派烤得尽可能圆,达到数学上的完美,而这一切都要借助她最喜欢的那个数字。你猜对了:π。

Piper 找到了一个巧妙的方法,可以迭代地计算 π,那就是牛顿/欧拉收敛变换:

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

帮 Piper 把厨房打理得井井有条,烤出她那数学上完美的派吧。

1. 分好面团

今天早上,Piper 擀出了两批面团,重量各不相同(单位为g)。 为了让每个派大小一致,她想把两批面团都分成重量相同的小球。 当然,她希望每一份尽可能大,这样浪费的面团才能尽可能少!

能同时整除两批面团重量的最大重量,就是它们的最大公约数。 欧几里得算法可以递归地求出它:

  • gcd(a, 0) = a(基准情况)
  • gcd(a, b) = gcd(b, a mod b)

注意,这个递归调用处于尾位置:它之后什么也不做。 定义largest_portion,让递归的一步用jmp跳转到函数自身,而不是用call。

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

两个实参都是 64 位非负整数。 返回值是一个 64 位非负整数。

2. 双阶乘

在概念里,你已经学过如何用尾递归的方式写出普通的阶乘。 同一个函数已经在你的存根文件里了。

不过,牛顿/欧拉公式还用到了双阶乘,写作!!。 双阶乘运算符的定义如下:

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

注意,双阶乘的模式和阶乘相同,只是每一步减 2,而不是减 1。 定义double_factorial函数,用尾递归的方式计算双阶乘。

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

实参是一个 32 位无符号整数。 返回值是一个 64 位无符号整数。

3. 牛顿/欧拉收敛变换

现在 Piper 拥有了所需的全部工具。 定义pipers_pi函数,它用牛顿/欧拉收敛变换公式中给定数量的项来近似 π:

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

分子用的是普通阶乘。 你可以直接调用已经为你定义好的factorial函数! 分母用的是你在任务 2 里写的double_factorial。

我们一起来算第一项。 上限取0(而不是无穷大)时,我们得到:

π / 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

上限取2时,我们得到:

π / 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

每多一项,近似值都会更精确。

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

实参是一个 32 位非负整数。 返回值是一个 64 位浮点数。

通过 GitHub 编辑 链接将在新窗口或新标签页中打开
x86-64 Assembly Exercism

准备好开始 派珀的馅饼 了吗?

注册 Exercism,借助 22 个概念130 个练习 和真人导师指导,学习并掌握 x86-64 Assembly,全部免费。