二十一点

二十一点

学习练习

简介

条件判断

RFLAGs

有一个特殊的寄存器叫rflags。 它的各个位就像各种条件的标志位。

下面列出了其中一些:

名称 符号 位
进位 CF 0
零 ZF 6
符号 SF 7
溢出 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

说明

在本练习中,你将实现二十一点的一些规则,比如游戏的玩法和计分方式。

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. 计算一张牌的点数

在二十一点中,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,取决于手牌。 二十一点的规则要求选择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. 判断手牌是否为二十一点

如果玩家拿到的前两张牌是一张CA和一张十点牌(C10、CJ、CQ 或 CK),那么该玩家的点数就是 21。 这种手牌称为二十一点。

定义is_blackjack函数,形参为card_one和card_two,它们是两个数字,表示两张牌。 如果这两张牌构成二十一点,函数必须返回 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

准备好开始 二十一点 了吗?

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