记账

记账

学习练习

简介

thunk

在前面的某个概念里我们提到过,局部标签和函数都只是可执行代码段中的地址,比如section .text。

事实上,函数可以像任何内存地址一样被操作:可以加载到寄存器里、到处传递,也可以存进内存。还可以用call或jmp,把执行权转移到一个存放在寄存器或内存中的函数:

section .text
sum_op:
    lea rax, [rdi + rsi] ; loads the sum rdi + rsi into rax
    ret

apply_sum:
    lea rax, [rel sum_op]
    jmp rax   ; tail call

像值一样被到处传递的函数地址,叫做 thunk。thunk 是汇编中高阶编程的基本构件:让代码去操作其他代码。

代码即数据

函数地址也可以存进内存,之后再取出来使用:

section .bss
    cached_fn resq 1

section .text
save_op:
    mov qword [rel cached_fn], rdi
    ret

apply_op:
    ; arguments are already set up according to the ABI
    jmp qword [rel cached_fn] ; tail call

save_op把收到的函数地址写入cached_fn。这个值在save_op返回后依然存在,因此之后任何一次对apply_op的调用,都会尾跳转到最近一次存入的那个地址。这样一来,就能在运行时改变apply_op调用的到底是哪个函数。

分派表

把函数地址存进数组,就可以根据某个下标选择不同的函数,而这个下标可能取决于运行时的条件。这叫做分派表:

section .data
    dispatch_table dq add_op, sub_op, mul_op

section .text
dispatch:
    ; this function takes two arguments in rdi and rsi, and an index in rdx
    ; it then applies the function corresponding to the index in rdx to the arguments
    lea rax, [rel dispatch_table]
    jmp qword [rax + 8*rdx]   ; tail-call the function address for the index

有状态的 thunk

如果一个 thunk 在多次调用之间会读取或更新某块持久的内存,它的行为就可能因为之前发生过什么而不同。它的结果可能不只取决于自己的实参。

例如一个_计数器_,它接收一个函数,用当前的计数调用它,并且每次都把计数递增:

section .data
    count dq 0

section .text
tick:
    mov rax, rdi               ; saves the function address
    mov rdi, [rel count]       ; loads the current count as the function's argument
    inc qword [rel count]      ; advances the count
    jmp rax                    ; tail-calls the function

tick用当前计数作为实参调用给定的函数,然后递增计数。所以第一次调用tick(square)会执行square(0),下一次调用tick(square)会执行square(1),再下一次是square(2),依此类推。

另一个例子是_延迟计算_:

section .bss
    captured_fn resq 1
    argument resq 1

section .text
delay:
    mov qword [rel captured_fn], rdi ; saves the function
    mov qword [rel argument], rsi    ; saves the argument
    lea rax, [rel invoke]            ; returns the `invoke` function
    ret

invoke:
    mov rdi, qword [rel argument]    ; loads the saved argument into `rdi`
    jmp qword [rel captured_fn]      ; tail-calls the saved function

delay接收一个函数和一个值,把它们存起来,然后返回invoke。调用invoke时,它会用保存下来的实参运行那个被捕获的函数。

高级语言中常见的许多模式,比如回调、虚方法、生成器、柯里化、函数组合等等,都建立在 thunk 与持久状态配对的基础上。

说明

你是一家小村庄银行的簿记员。 每位客户都持有一个账户,你把它的余额记在账簿里。 一年之中,各种交易会作用到这些余额上:利息被计入,手续费被扣除,奖金被发放,罚金被收取。 每一笔交易都接收一个余额,并产生一个新的余额。

你有 4 个任务。

Note

你可以假设本练习中的每个 thunk(交易和守卫)都是这样的函数:

  1. 接收一个 64 位非负整数作为实参
  2. 并且也返回一个 64 位非负整数。

1. 记住一笔交易

出纳员在一天开始时得知一笔新交易,把它记下来,以便之后客户到来时可以应用。

定义 2 个函数:

  • remember_transaction接收一笔交易,并将它存储在内存中。
  • apply_remembered接收一个余额,并把之前存储的交易应用到它上面。

示例,假设add_interest是一笔计入 5 个单位利息的交易:

remember_transaction(add_interest);
apply_remembered(100);
// => 105

remember_transaction(service_fee);
apply_remembered(100);
// => 98   (assuming service_fee deducts 2)

对于remember_transaction:

  • 实参是一笔留待以后使用的交易。
  • 没有返回值。

对于apply_remembered:

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

2. 银行的手册

银行的手册里有一份常用交易列表,存储在一张_分派表_中。 每个分行都维护着自己的一份列表副本,并且可以根据当地政策登记不同的交易。

定义 2 个函数,它们操作由调用方提供的分派表:

  • register_transaction接收一张分派表的内存地址、一个下标和一笔交易。 它把这笔交易存储在表中的指定下标处。
  • select_transaction接收一张分派表的内存地址、一个下标和一个余额。 它查找指定下标处的交易,把它应用到余额上,并返回新的余额。

select_transaction应当通过一次间接尾调用抵达所查找到的交易。

示例,假设manual是一张有 4 个空槽的分派表的内存地址:

register_transaction(manual, 0, monthly_interest);
register_transaction(manual, 1, service_fee);

select_transaction(manual, 0, 100);
// applies monthly_interest to 100

select_transaction(manual, 1, 100);
// applies service_fee to 100

对于register_transaction:

  • 第一个实参是一张分派表的内存地址。
  • 第二个实参是一个 64 位非负整数(下标)。
  • 第三个实参是一笔交易。
  • 没有返回值。

对于select_transaction:

  • 第一个实参是一张分派表的内存地址。
  • 第二个实参是一个 64 位非负整数(下标)。
  • 第三个实参是一个 64 位非负整数(余额)。
  • 返回值是一个 64 位非负整数。

3. 处理月度对账单

月末,会对客户的账户进行对账。 这个月里发生的每一笔交易都会依次作用到起始余额上,结果就是新的余额。

定义一个函数process_statement,它接收一个起始余额、一个交易数组的内存地址,以及数组中交易的数量。 对于序列中的每一笔交易,它应当把该交易应用到当前的余额上,然后把结果作为下一笔交易的余额。 最后返回最终的余额。

用伪代码表示,process_statement(balance, transactions, n)计算的是:

for each transaction in transactions:
    balance = transaction(balance)
return balance

示例,假设transactions是一个数组的内存地址,数组中按此顺序包含交易add_interest、service_fee和add_interest,其中add_interest加 5,service_fee减 2:

process_statement(100, transactions, 3);
// add_interest(100) = 105
// service_fee(105)  = 103
// add_interest(103) = 108
// => 108

第一个实参是一个 64 位非负整数。 第二个实参是一个交易数组的内存地址。 第三个实参是一个 64 位非负整数(数组长度)。 返回值是一个 64 位非负整数。

4. 使用守卫处理

银行的政策要求某些交易在被提交之前先接受检查。 守卫是一个函数,它检查一个拟定中的余额,并判断它是否可以接受。 这个守卫函数返回非零值表示批准,返回零表示拒绝。

定义process_with_guard,它接收一个起始余额、一个交易数组的内存地址、数组中交易的数量,以及一个守卫函数。 对序列中的每一笔交易:

  1. 把该交易应用到当前余额上,计算出一个暂定的新余额。
  2. 用暂定余额调用守卫。
  3. 如果守卫返回非零值,就提交:当前余额变为暂定余额。
  4. 如果守卫返回零,当前余额保持不变,并跳过该交易。

处理完所有交易之后,返回最终余额以及已批准交易的数量。

用伪代码表示,process_with_guard(balance, transactions, n, guard)计算的是:

approved = 0
for each transaction in transactions:
    tentative = transaction(balance)
    if guard(tentative) is non-zero:
        balance = tentative
        approved = approved + 1
return balance, approved

例如,假设:

  1. add_interest是一笔加 5 的交易,service_fee是另一笔减 2 的交易
  2. at_least_10是一个守卫,当余额 >= 10 时返回非零值

那么:

process_with_guard(5, {add_interest, service_fee, add_interest}, 3, at_least_10);
// add_interest(5) = 10; at_least_10(10) != 0;
// => balance = 10, approved = 1
//
// service_fee(10) = 8; at_least_10(8) = 0;
// => balance = 10, approved = 1
//
// add_interest(10) = 15; at_least_10(15) != 0;
// => balance = 15, approved = 2
//
// final balance (15) is returned in rax
// number of approved transactions (2) is returned in rdx

对于process_with_guard:

  • 第一个实参是一个 64 位非负整数(起始余额)。
  • 第二个实参是一个交易数组的内存地址。
  • 第三个实参是一个 64 位非负整数(数组长度)。
  • 第四个实参是一个守卫函数,它接收一个 64 位非负整数并返回一个 64 位非负整数。
  • 返回值是 2 个 64 位非负整数:最终余额在rax中,已批准交易的数量在rdx中。
通过 GitHub 编辑 链接将在新窗口或新标签页中打开
x86-64 Assembly Exercism

准备好开始 记账 了吗?

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