부기

부기

학습 연습 문제

소개

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 위에 세워져 있어요.

지침

여러분은 작은 마을 은행에서 회계 담당자로 일하고 있어요. 고객마다 계좌를 하나씩 가지고 있고, 그 잔액을 장부에 기록해요. 한 해 동안 이런 잔액에는 여러 거래가 적용돼요. 이자가 입금되고, 수수료가 차감되고, 보너스가 지급되고, 벌금이 부과돼요. 모든 거래는 잔액을 받아 새로운 잔액을 만들어요.

풀어야 할 과제가 네 개 있어요.

Note

이 연습 문제의 모든 thunk(거래와 가드)는 다음 조건을 만족하는 함수라고 가정해도 돼요:

  1. 64비트 음이 아닌 정수를 인자로 받고
  2. 64비트 음이 아닌 정수를 반환해요.

1. 거래 기억하기

창구 직원은 하루가 시작될 때 새로운 거래를 배워서, 나중에 고객이 왔을 때 적용할 수 있도록 적어 둬요.

두 개의 함수를 정의해요:

  • remember_transaction은 거래를 받아 메모리에 저장해요.
  • apply_remembered는 잔액을 받아 앞서 저장한 거래를 적용해요.

예를 들어, add_interest가 이자 다섯 단위를 입금하는 거래라고 해봐요:

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. 은행 매뉴얼

은행 매뉴얼에는 자주 쓰이는 거래 목록이 _디스패치 테이블_에 저장되어 있어요. 지점마다 이 목록의 사본을 따로 관리하고, 지역 정책에 따라 서로 다른 거래를 등록할 수 있어요.

호출자가 넘겨주는 디스패치 테이블을 다루는 두 개의 함수를 정의해요:

  • register_transaction은 디스패치 테이블의 메모리 주소, 인덱스, 거래를 받아요. 그리고 그 거래를 테이블의 주어진 인덱스에 저장해요.
  • select_transaction은 디스패치 테이블의 메모리 주소, 인덱스, 잔액을 받아요. 주어진 인덱스에 있는 거래를 찾아 잔액에 적용하고, 새로운 잔액을 반환해요.

select_transaction은 찾아낸 거래에 단일 간접 꼬리 호출로 도달해야 해요.

예를 들어, manual이 빈 슬롯 네 개를 가진 디스패치 테이블의 메모리 주소라고 해봐요:

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. 가드를 사용해 처리하기

은행 정책상 일부 거래는 확정되기 전에 검사를 거쳐야 해요. 가드는 제안된 잔액을 살펴보고 받아들일 만한지 판단하는 함수예요. 이 가드 함수는 승인하려면 0이 아닌 값을 반환하고, 거부하려면 0을 반환해요.

시작 잔액, 거래 배열의 메모리 주소, 배열에 있는 거래 개수, 가드 함수를 받는 process_with_guard를 정의해요. 각 거래를 순서대로 처리할 때는:

  1. 현재 잔액에 거래를 적용해서 임시 잔액을 계산해요.
  2. 임시 잔액으로 가드를 호출해요.
  3. 가드가 0이 아닌 값을 반환하면 확정해요. 현재 잔액이 임시 잔액이 돼요.
  4. 가드가 0을 반환하면 현재 잔액은 그대로 두고, 그 거래는 건너뛰어요.

모든 거래를 처리한 뒤에는 최종 잔액과 승인된 거래 개수를 함께 반환해요.

의사 코드로 나타내면, 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 이상일 때 0이 아닌 값을 반환하는 가드예요.

그러면:

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비트 음이 아닌 정수를 반환하는 가드 함수예요.
  • 반환 값은 두 개의 64비트 음이 아닌 정수예요. 최종 잔액은 rax에, 승인된 거래 개수는 rdx에 담겨요.
GitHub에서 편집 링크가 새 창이나 탭에서 열려요
x86-64 Assembly Exercism

부기 문제를 시작해 볼 준비가 됐나요?

Exercism에 가입하고 x86-64 Assembly 트랙을 개념 22개연습 문제 130개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.