21 點

21 點

學習練習

簡介

條件判斷

RFLAGs

有個特別的暫存器叫做rflags。 它的各個位元會像旗標一樣,對應各種不同的條件。

底下列出其中幾個:

名稱 符號 位元
carry CF 0
zero ZF 6
sign SF 7
overflow OF 11

比較指令

rflags裡的旗標並_不會_被直接修改。 它們是由許多不同的指令所設定的。

舉例來說,當運算結果為零時,許多算術或位元運算都會設定ZF。

測試條件時最常用的指令之一就是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。

舉例來說,當ZF沒有被設定時,jnz就會跳躍。 同樣地,當 A 不 >= B 時,jnae就會跳躍(A 和 B 以無號整數解讀)。

Note

有些後綴是相同條件的別名。 舉例來說,jz(後綴z,代表ZF)和je(後綴e,代表相等)在ZF被設定時都會跳躍。 這是因為在使用cmp時,當減法結果為零,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

說明

在這個練習中,你將實作 Blackjack 的一些規則,例如遊戲進行的方式和計分方式。

Note

在這個練習中,牌是以數字表示;每張數字牌以其數值表示,而 J、Q、K 和 A 則分別以 11、12、13、14 表示(遊戲中不使用鬼牌)。

為了讓這種表示方式更容易使用,檔案開頭定義了一些常數;C2 到 C10 代表數字牌,CJ 代表 J,CQ 代表 Q,CK 代表 K,CA 代表 A。

這裡假設使用的是一副標準法式 52 張牌組,但在大多數玩法中,會把好幾副牌洗在一起使用。

Note

以下是這個概念中提到的指令:

指令 說明
cmp a, b 依 a - b 設定旗標
jmp a 程式碼在此停止執行,並從標籤 a 繼續
jcc a 若符合 cc 中的條件,程式碼會從標籤 a 繼續

以下是 cmp a, b 之後,jcc 會檢查的條件:

指令 跳躍時機
je a == b
jl a < b(有號)
jg a > b(有號)
jb a < b(無號)
ja a > b(無號)
jle a <= b(有號)
jge a >= b(無號)
jbe a <= b(無號)
jae a >= b(無號)
jne a != b
jnl !(a < b)(有號)
jng !(a > b)(有號)
jnb !(a < b)(無號)
jna !(a > b)(無號)
jnle !(a <= b)(有號)
jnge !(a >= b)(有號)
jnbe !(a <= b)(無號)
jnae !(a >= b)(無號)

1. 計算一張牌的值

在Blackjack中,CA的值要依手牌而定,可能是 1 或 11(這點稍後會再說明)。 人頭牌(CJ、CQ 和 CK)計為 10 點,其他任何牌則按其_數值_計分。

定義 value_of_card 函式,參數為 card,也就是代表一張牌的數字。 這個函式必須回傳傳入牌張的_數值_。 由於 CA 可以有多種值(1 或 11),我們暫時把 CA 的值固定為 1。 之後你會實作一個函式,在給定現有手牌的情況下判斷 CA 的值。

value_of_card(13)
// => 10

value_of_card(4)
// => 4

value_of_card(14)
// => 1

2. 判斷哪張牌的值較高

定義 higher_card 函式,參數為 card_one 和 card_two,這兩個數字各代表一張牌。 就計分而言,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. 計算 A 的值

如前所述,CA 的值_可以_是 1 或 11 點,取決於手牌。 Blackjack 的規則要求 CA 的取值要讓手牌分數最大化,但不能超過 21(也就是「爆牌」)。

定義 value_of_ace 函式,參數為 card_one 和 card_two,這兩個數字代表在拿到最後一張 CA_之前_手牌中已有的兩張牌。 你的函式必須回傳即將加入的 CA 會被指定為 1 還是 11。 記住:加入 CA 後的新手牌分數要盡可能高,但_不能_超過 21。

提示:如果手牌裡已經有一張 CA,那麼接下來這張 CA 的值就會是 1。

value_of_ace(6, 13)
// => 1

value_of_ace(7, 3)
// => 11

4. 判斷手牌是否為 Blackjack

如果玩家拿到的前兩張牌是 CA 和一張十點牌(C10、CJ、CQ 或 CK),那麼這位玩家的分數就是 21。 這樣的手牌稱為 Blackjack。

定義 is_blackjack 函式,參數為 card_one 和 card_two,這兩個數字代表一對牌。 如果這兩張牌是 Blackjack,函式必須回傳 1,否則回傳 0。 為了讓這些值更容易使用,檔案開頭定義了常數 TRUE 和 FALSE,分別等同於 1 和 0。

注意:分數的_計算_方式有很多種。 但如果可以,我們希望你檢查手牌中**有沒有** CA 和一張十點牌,而不是把牌的數值_加總_起來。

is_blackjack(14, 13)
// => 1

is_blackjack(10, 9)
// => 0

5. 分牌

如果玩家的前兩張牌值相同(例如手牌是兩張 C6,或是 CQ 加 CK),玩家可以選擇把它們當成兩組獨立的手牌。 這就叫做「分牌」。

定義 can_split_pairs 函式,參數為 card_one 和 card_two,這兩個數字代表一對牌。 如果這兩張牌可以分成兩組手牌,函式必須回傳 1,否則回傳 0。 為了讓這些值更容易使用,檔案開頭定義了常數 TRUE 和 FALSE,分別等同於 1 和 0。

can_split_pair(12, 13)
// => 1

can_split_pair(10, 14)
// => 0

6. 加倍下注

當最初發的兩張牌合計為 9、10 或 11 點時,玩家可以再下一個與原注相同金額的賭注。 這就叫做「加倍下注」。

定義 can_double_down 函式,參數為 card_one 和 card_two,這兩個數字代表一對牌。 如果這兩張牌可以讓玩家「加倍下注」,函式必須回傳 1,否則回傳 0。 為了讓這些值更容易使用,檔案開頭定義了常數 TRUE 和 FALSE,分別等同於 1 和 0。

can_double_down(14, 9)
// => 1

can_double_down(10, 2)
// => 0
透過 GitHub 編輯 連結會在新視窗或分頁中開啟
x86-64 Assembly Exercism

準備好開始 21 點 了嗎?

註冊 Exercism,透過 22 個概念130 個練習 和真人引導來學習並精通 x86-64 Assembly,全部免費。