블랙잭

블랙잭

학습 연습 문제

소개

조건문

RFLAGs

rflags라는 특별한 레지스터가 있어요. 이 레지스터의 각 비트는 여러 조건을 나타내는 플래그처럼 동작해요.

그중 일부는 아래와 같아요:

이름 기호 비트
캐리 CF 0
제로 ZF 6
부호 SF 7
오버플로 OF 11

비교 명령어

rflags의 플래그는 직접 수정되지 않아요. 대신, 여러 가지 명령어에 의해 설정돼요.

예를 들어, ZF는 산술 연산이나 비트 연산의 결과가 0일 때 설정돼요.

조건을 검사할 때 가장 흔히 쓰이는 명령어 중 하나는 cmp예요. cmp는 두 개의 피연산자를 받아 플래그를 갱신하지만 피연산자 자체는 수정하지 않아요.

CMP 명령어

cmp 명령어는 첫 번째 피연산자에서 두 번째 피연산자를 빼고, 그 결과에 따라 플래그를 설정해요.

A가 첫 번째 피연산자이고 B가 두 번째 피연산자라면:

플래그 설정되는 조건
CF A < B (부호 없음)
ZF A == B
SF A < B (부호 있음, 오버플로 없음)
OF 부호 있는 뺄셈에서 오버플로

분기

기본적으로 x86-64의 코드는 위에서 아래로 순차적으로 실행돼요.

하지만 이 동작을 바꿔야 하는 상황이 많아요. 예를 들어, 어떤 조건에 따라 다른 명령어 집합을 실행해야 할 때가 그렇죠.

고급 언어에서는 보통 if...else 같은 추상화를 통해 이걸 해요. 하지만 x86-64에는 그런 게 없어요.

대신 x86-64는 실행 흐름을 코드의 다른 위치로 옮기는 명령어를 제공해요. 이걸 branching이라고 해요.

Note

이미 그런 명령어 두 개를 봤어요: call과 ret.

함수가 호출되면 실행 흐름이 호출한 함수에서 호출된 함수로 넘어가요. 그리고 반환할 때는 실행 흐름이 다시 호출한 함수로 돌아와요.

ret이 없으면 실행 흐름이 한 함수에서 다음 함수로 그대로 이어져요. 이런 성질은 때때로 코드 흐름을 최적화하는 데 활용되기도 해요.

무조건 분기

jmp 명령어는 프로그램의 실행을 코드의 다른 지점으로 무조건 옮겨요. 이 명령어의 유일한 피연산자는 레이블이고, 이 레이블은 실행이 이어질 지점의 주소를 담고 있어요.

예를 들어 다음 함수를 봐요:

fn:
    mov rax, 5
    jmp end

    add rax, 10
end:
    ret

fn이 호출되면 실행은 mov rax, 5에서 시작해요. 이 시점에서 rax의 값이 5가 돼요.

다음 명령어는 jmp end이고, 실행을 end 레이블로 옮겨요.

end 다음 명령어는 ret이고, 실행을 호출한 함수로 되돌려요.

add rax, 10은 jmp end 뒤에 있고 end 앞에 있으므로 절대 실행되지 않아요. fn이 반환될 때 rax의 값은 5예요.

조건부 분기

jcc 명령어군은 특정 조건이 충족될 때만 프로그램의 실행을 다른 지점으로 옮겨요. 그렇지 않으면 실행은 순차적으로 이어져요.

각 조건은 rflags의 하나 이상의 플래그에 대응해요. 어떤 jcc 변형은 플래그가 설정됐는지 검사하고, 또 다른 변형은 플래그가 해제됐는지 검사해요.

jcc의 cc는 문자 그대로의 의미가 아니라, 검사하는 플래그에 따라 붙는 특정 접미사를 가리켜요.

접미사는 아주 많고, 그중 상당수는 다른 접미사와 같은 조건을 검사해요. 그중 일부는 플래그를 직접 가리켜서, 특정 플래그가 설정되면 해당 레이블로 점프해요:

접미사 점프하는 조건
z ZF == 1
c CF == 1
s SF == 1
o OF == 1

그 밖의 많은 접미사는 cmp 명령어에서의 의미를 나타내기 위해 선택됐어요. 예를 들어:

명령어 접미사 점프하는 조건
cmp A, B e A == B
cmp A, B l A < B (부호 있음)
cmp A, B b A < B (부호 없음)
cmp A, B g A > B (부호 있음)
cmp A, B a A > B (부호 없음)

l, b, g, a 뒤에 e를 붙이면 조건에 등호를 포함할 수 있어요:

cmp rcx, r8
jge two      ; this jumps to 'two' if rcx is greater than, or equal to, r8 in a signed comparison
jbe two      ; this jumps to 'two' if rcx is lesser than, or equal to, r8 in an unsigned comparison

모든 접미사에는 반대 동작을 검사하는 변형이 있어요. 문법은 같지만 접미사 앞에 n이 붙어요.

예를 들어, jnz는 ZF가 설정되지 않았을 때 점프해요. 마찬가지로, jnae는 A가 B보다 크거나 같지 않을 때 점프해요 (A와 B를 부호 없는 정수로 해석한 경우예요).

Note

일부 접미사는 같은 조건을 가리키는 별칭이에요. 예를 들어, jz(접미사 z, ZF를 위한 것)와 je(접미사 e, 같음을 위한 것)는 둘 다 ZF가 설정됐을 때 점프해요. cmp에서는 뺄셈 결과가 0일 때 ZF가 설정되는데, 이는 두 피연산자가 같다는 뜻이기 때문이에요.

하지만 다른 접미사들은 여러 플래그의 조합을 검사하기 때문에 단일 플래그 접미사로 곧바로 대체할 수 없어요.

비교의 의미를 더 잘 설명하는 접미사를 선택하는 게 좋아요.

지역 레이블

레이블은 소스 파일 전체에서 보여요. 함수 안에서만 유효한 게 아니에요. 따라서 같은 레이블 이름을 다시 쓸 수 없어요.

지역 레이블처럼 동작하게 하려고, NASM에는 이름 앞에 마침표(.)를 붙여 선언하는 특별한 표기법이 있어요. 이 표기법은 앞에 나온 마침표 없는 레이블의 이름을 암묵적으로 포함하는 레이블을 정의해요:

section .text
fn1:
    ...
.example: ; this is fn1.example
    ...
    ret

fn2:
    ...
.example: ; this is fn2.example
    ...
    ret

코드 어디에서든 전체 레이블 이름을 써서 이 레이블로 점프할 수 있어요. 예를 들어 jmp fn1.example처럼요.

하지만 마침표부터 시작하는 레이블의 일부를 사용하는 점프는 상위 함수 _내부_의 레이블로 이루어져요. 예를 들어, .example은 마치 그 함수에만 지역적인 것처럼 동작해요:

section .text
fn1:
    ...
.example:
    ...
    jmp .example ; this jumps to fn1.example

fn2:
    ...
.example:
    ...
    jmp .example ; this jumps to fn2.example

지침

이 연습 문제에서는 게임을 진행하고 점수를 계산하는 방식 같은 블랙잭의 규칙 몇 가지를 구현해 볼 거예요.

Note

이 연습 문제에서 카드는 숫자로 표현해요. 숫자 카드는 각각의 숫자 값으로 나타내고, 잭, 퀸, 킹, 에이스는 각각 11, 12, 13, 14로 나타내요(조커는 게임에서 사용하지 않아요).

이 표현을 더 쉽게 다루기 위해 파일 맨 위에 몇 가지 상수를 정의해 두었어요. C2부터 C10까지는 숫자 카드, CJ는 잭, CQ는 퀸, CK는 킹, CA는 에이스를 나타내요.

프렌치 수트의 표준 52장 카드 덱을 가정하지만, 대부분의 버전에서는 여러 덱을 함께 섞어서 게임을 해요.

Note

다음은 이 개념에서 언급된 명령어예요.

명령어 설명
cmp a, b a - b에 따라 플래그를 설정해요
jmp a 코드 실행을 여기서 멈추고 레이블 a에서 계속해요
jcc a cc의 조건이 충족되면 레이블 a에서 코드가 계속돼요

다음은 cmp a, b 뒤에 오는 jcc에서 확인하는 조건들이에요.

명령어 점프하는 조건
je a == b
jl a < b (signed)
jg a > b (signed)
jb a < b (unsigned)
ja a > b (unsigned)
jle a <= b (signed)
jge a >= b (unsigned)
jbe a <= b (unsigned)
jae a >= b (unsigned)
jne a != b
jnl !(a < b) (signed)
jng !(a > b) (signed)
jnb !(a < b) (unsigned)
jna !(a > b) (unsigned)
jnle !(a <= b) (signed)
jnge !(a >= b) (signed)
jnbe !(a <= b) (unsigned)
jnae !(a >= b) (unsigned)

1. 카드의 값 계산하기

블랙잭에서 CA의 값은 패에 따라 1 또는 11이에요(이건 나중에 더 자세히 다뤄요). 그림 카드(CJ, CQ, CK)는 10점으로 계산하고, 그 밖의 카드는 숫자 값을 그대로 가져요.

카드를 나타내는 숫자인 card를 매개변수로 가지는 value_of_card 함수를 정의해요. 이 함수는 전달받은 카드의 _숫자 값_을 반환해야 해요. CA는 여러 값(1 또는 11)을 가질 수 있으므로, 우선 CA의 값을 1로 고정해요. 나중에는 기존 패를 바탕으로 CA의 값을 결정하는 함수를 구현하게 될 거예요.

value_of_card(13)
// => 10

value_of_card(4)
// => 4

value_of_card(14)
// => 1

2. 값이 더 높은 카드 판별하기

각각 카드를 나타내는 두 숫자인 card_one과 card_two를 매개변수로 가지는 higher_card 함수를 정의해요. 점수 계산에서 CJ, CQ, CK의 값은 모두 10이에요. 이 함수는 점수 계산 기준으로 값이 더 높은 카드를 반환해야 해요. 두 카드의 값이 같으면 둘 다 반환해요.

CA는 여러 값을 가질 수 있으므로, 이 작업에서는 그 값을 1로 고정해요.

higher_card(13, 11)
// => {13, 11}

higher_card(4, 6)
// => 6

>>> higher_card(13, 14)
// => 13
Note

함수에서 정수 두 개를 반환하려면 rax와 rdx 레지스터를 모두 사용해야 해요.

returning_two_values:
    mov rax, rdi
    mov rdx, rsi
    ret

카드를 하나만 반환하는 경우에는 rdx를 0으로 설정해야 해요.

3. 에이스의 값 계산하기

앞서 언급했듯이, CA는 패에 따라 1점 또는 11점의 값을 가져요. 블랙잭 규칙에 따르면 CA의 값은 21을 넘지 않으면서(버스트가 되지 않으면서) 패의 점수를 최대한 높이도록 선택해야 해요.

마지막 CA를 받기 전에 이미 패에 있는 두 카드를 나타내는 숫자인 card_one과 card_two를 매개변수로 가지는 value_of_ace 함수를 정의해요. 이 함수는 곧 받을 CA에 1과 11 중 어떤 값이 배정될지를 반환해야 해요. 기억해요. (CA가 포함된) 새 패의 값은 21을 넘지 않으면서 가능한 한 높아야 해요.

힌트: 패에 이미 CA가 있다면 곧 받을 CA의 값은 1이 돼요.

value_of_ace(6, 13)
// => 1

value_of_ace(7, 3)
// => 11

4. 패가 블랙잭인지 판별하기

플레이어가 처음 두 장으로 CA와 10 카드(C10, CJ, CQ, CK)를 받으면 점수가 21이 돼요. 이런 패를 블랙잭이라고 해요.

두 카드를 나타내는 숫자인 card_one과 card_two를 매개변수로 가지는 is_blackjack 함수를 정의해요. 이 함수는 두 장짜리 패가 블랙잭이면 1을, 아니면 0을 반환해야 해요. 값을 더 쉽게 다루기 위해 파일 맨 위에 1과 0에 각각 해당하는 상수 TRUE와 FALSE가 정의되어 있어요.

참고: 점수 _계산_은 여러 방식으로 할 수 있어요. 하지만 가능하다면 카드 값을 더하는 대신 패 안에 CA와 10 카드가 있는지 확인해 보면 좋겠어요.

is_blackjack(14, 13)
// => 1

is_blackjack(10, 9)
// => 0

5. 페어 나누기

플레이어의 처음 두 카드가 같은 값이면(예를 들어 C6 두 장으로 된 패, 또는 CQ와 CK로 된 패), 두 카드를 서로 별개의 패로 취급하도록 선택할 수 있어요. 이를 "페어 나누기"라고 해요.

두 카드를 나타내는 숫자인 card_one과 card_two를 매개변수로 가지는 can_split_pairs 함수를 정의해요. 이 함수는 두 장짜리 패를 두 개의 패로 나눌 수 있으면 1을, 아니면 0을 반환해야 해요. 값을 더 쉽게 다루기 위해 파일 맨 위에 1과 0에 각각 해당하는 상수 TRUE와 FALSE가 정의되어 있어요.

can_split_pair(12, 13)
// => 1

can_split_pair(10, 14)
// => 0

6. 더블 다운하기

처음 받은 두 카드의 합이 9, 10, 11점이면, 플레이어는 원래 배팅한 금액과 같은 금액을 추가로 걸 수 있어요. 이를 "더블 다운"이라고 해요.

두 카드를 나타내는 숫자인 card_one과 card_two를 매개변수로 가지는 can_double_down 함수를 정의해요. 이 함수는 두 장짜리 패로 "더블 다운"을 할 수 있으면 1을, 아니면 0을 반환해야 해요. 값을 더 쉽게 다루기 위해 파일 맨 위에 1과 0에 각각 해당하는 상수 TRUE와 FALSE가 정의되어 있어요.

can_double_down(14, 9)
// => 1

can_double_down(10, 2)
// => 0
GitHub에서 편집 링크가 새 창이나 탭에서 열려요
x86-64 Assembly Exercism

블랙잭 문제를 시작해 볼 준비가 됐나요?

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