欢迎光临散文网 会员登陆 & 注册

【TIS-100 攻略】第 4~5 关:信号比较器、信号叠加器

2022-10-18 22:38 作者:ココアお姉ちゃん  | 我要投稿

本文首发于 B 站《TIS-100》文集(https://www.bilibili.com/read/readlist/rl626023)。原创不易,转载请注明出处。

第 4 关《信号比较器》(Signal Comparator)关卡展示

本关要求从 IN 收到正数时,向 OUT.G 写 1;从 IN 收到 0 时,向 OUT.E 写 1;从 IN 收到负数时,向 OUT.L 写 1。因为以上三个条件,任何时候都必然有且仅有一条成立,所以同一时刻,三个输出口只能有一个被写入 1,其余两个条件不成立的端口需要同时被写入 0。

本关我们需要接触 5 条用于控制流程的跳转指令,其中 1 条是无条件跳转,另外 4 条都是有条件跳转。

无条件跳转指令:jmp <label>,无视原先的执行顺序,强制跳转到标记为 label 的代码行处执行。

零跳转指令:jez <label> (Jump if Equal to Zero),当 acc 为零时,跳转到标记为 label 的代码行处执行,否则按顺序继续执行。

正跳转指令:jgz <label> (Jump if Greater than Zero),当 acc 大于零时,跳转到标记为 label 的代码行处执行,否则按顺序继续执行。

负跳转指令:jlz <label> (Jump if Less than Zero),当 acc 小于零时,跳转到标记为 label 的代码行处执行,否则按顺序继续执行。

非零跳转指令:jnz <label> (Jump if Non Zero),当 acc 不为零时,跳转到标记为 label 的代码行处执行,否则按顺序继续执行。

本关的输出和输入是否【大于 0】、【等于 0】、【小于 0】相关,是练习各大跳转指令的入门关卡。本关的代码如下:

左边的三个节点全部是用于传话的(mov up down, mov up down, mov up right),一笔带过。

最先收到 IN 口值的是位于 OUT.G 上方的节点。它从左边收到数字后,把这个数字存入自己的 acc,为接下来的按条件跳转做准备(mov left acc)。存好后,我们要将这个值再传一份给右边的节点,因为右边的节点也需要根据这个值向 OUT.E 口写输出值(mov acc right)。

接下来是重头戏:我们按照题目规则,判定输入的值是否大于 0,并根据判定结果跳转到不同的代码块去执行代码。第 3 行代码 jgz 6 的作用是:如果 acc 大于 0,则跳转到第 6 行去执行。这就导致了一个现象:如果 acc 大于 0,按照要求要跳过第 4、5 行代码,所以第 4、5 行代码是仅当 acc <= 0 时才会执行到的。当 acc <= 0 时,我们需要给 OUT.G 口输出 0 信号(mov 0 down)。与此同时,第 6 行代码是仅当 acc > 0 时才能执行的,所以当 acc <= 0 时,第 4 行代码执行完毕后,我们还需要强制跳回第 1 行,避免执行第 6 行的代码(jmp 1)。接下来第 6 行,acc > 0 时,给 OUT.G 口输出 1 信号(mov 1 down)。第 6 行代码执行完毕后,根据游戏设定,会自动跳回第 1 行,所以到这里就不需要再写一行多余的 jmp 1 了。

我们现在画一个流程图来解释 OUT.G 上方的节点究竟在做什么:

然后看右边的位于 OUT.E 上方的节点。该节点从左边接收传来的输入(mov left acc),并将该输入量再发送一份给右边的节点,让它处理 OUT.L 口的输出(mov acc right)。该节点用了非零跳转(jnz 6),导致的结果就是:acc = 0 时,执行第 4、5 行代码,向 OUT.E 输出 1(mov 1 down, jmp 1);acc ≠ 0 时,执行第 6 行代码,向 OUT.E 输出 0(mov 0 down)。

最后是右下角的位于 OUT.L 上方的节点。该节点同样从左边接收传来的输入(mov left acc),但由于它已经位于右边缘,它的右侧已经没有更多节点等待它传话了,所以这个节点里的 mov acc right 这条指令被注释掉了。该节点用了负跳转,导致的结果就是:acc >= 0 时,执行第 4、5 行代码,向 OUT.L 输出 0(mov 0 down, jmp 1);acc < 0 时,执行第 6 行代码,向 OUT.L 输出 1(mov 1 down)。

汇编里的“按条件跳转”本质上和高级语言的 if...else... 是一回事。C 语言的

等价于这样的汇编代码:

汇编在语序上没有高级语言的 if...else... 直观,因为是满足条件后跳转执行,所以先写的其实是不满足条件时的代码块,也就是 else 部分在前,then 部分在后。

那么,我们现在尝试将 OUT.G 上方的汇编代码转写成 C 语言代码。该节点的汇编代码如下:

转写成的 C 语言代码如下:

语义很明显:acc > 0 时给下方输出 1,否则给下方输出 0。另外两个节点大同小异,我就不再细说了。

点击左下角的【RUN】,稍等片刻,便会弹出结算界面:

不错,三项指标都在直方图的最左端,所以本方案应该是这道题的十全十美方案了。

第 5 关《信号叠加器》(Signal Multiplexer)关卡展示

本关要求从 IN.S 读入 -1 时,输出 IN.A 的值,舍弃掉 IN.B 的值;从 IN.S 读入 1 时,输出 IN.B 的值,舍弃掉 IN.A 的值;从 IN.S 读入 0 时,输出 IN.A + IN.B 的值。

这一关其实不算太难,就是将 IN.S 读入 acc,然后判断其是否小于/等于/大于 0,并根据判定结果选择三个代码块中的一个执行。代码如下:

IN.A 下方的节点,以及 IN.B 下方的节点,做的事情都是把读到的值汇总到中央节点,取舍均由中央节点自行决定(mov up right, mov up left)。

IN.S 下方的节点(中央节点)按照计划,从 IN.S 读入一个数字(mov up acc),并根据它的符号决定接下来要执行的代码块:若为正数,则跳转到第 11 行执行(jgz b);若为 0,则跳转到第 7 行执行(jez 7);若为负数,则按顺序继续往下执行。那么我们很自然地就把这些代码分成了三部分:当 acc < 0 时,执行第 4~6 行代码;当 acc = 0 时,执行第 7~10 行代码;当 acc > 0 时,执行第 11~12 行代码。

那么我们先看第 4~6 行代码:当 acc < 0 时,根据题目要求,要输出 IN.A 的值,舍弃掉 IN.B 的值。那么我们在代码里,自然就是从左边节点拿到 IN.A 后往下传(mov left down),从右边节点拿到 IN.B 后自己吞掉,不传下去(mov right acc, jmp 1)。

接下来是第 7~10 行代码:当 acc = 0 时,根据题目要求,要输出 IN.A + IN.B 的值。那我们在代码里,自然是先从左边节点拿到 IN.A 的值放到 acc 里(mov left acc),然后从右边节点拿到 IN.B 的值累加到 acc 里(add right),计算完毕后,将算好的值往下传(mov acc down, jmp 1)。

最后是第 11~12 行代码:当 acc > 0 时,根据题目要求,要输出 IN.B 的值,舍弃掉 IN.A 的值。此时我们的行为跟第一部分的代码正好相反,从右边节点拿到 IN.B 后往下传(mov right down),而从左边节点拿到 IN.A 后自己吞掉(mov left acc)。

将以上过程画成流程图,如下:

以上汇编代码转换成 C 语言代码后,是这个样子的:

有读者可能要问了:为什么用不到的值非得读一下然后再自己吞掉,不能不读它吗?这就不得不提一下这个游戏里的设定了:任何时候,当一个节点使用 mov 指令给相邻节点传值时,对应的相邻节点必须要接收这个值。若不接收,传值的节点就会一直阻塞下去,直到对应的节点接收掉相应的值为止。也就是说,传话的节点自己是不知道该不该丢弃掉这个值的。如果中央节点不去主动读那个需要丢弃的值,传话的节点就会一直等待下去,下次中央节点再来读的时候,读到的反而是前若干个回合里早就该丢弃掉的废值,这样我们反而会得到错误的结果。所以,即使对于不需要的值,接收方也是需要去读一下然后丢弃掉的,这样可以释放掉传话方的阻塞信号,以便传话方继续准备下一次要传递的内容。

中央节点下方的两个节点也没啥好说的,就是给最终输出口 OUT 传话的(mov up down, mov up down)。

点击左下角的【RUN】,稍等片刻,便会弹出结算界面:

解锁成就 UNCONDITIONAL

该成就的说明是 Solve SIGNAL COMPARATOR without using the JGZ, JLZ, JEZ, or JNZ instructions,不使用四大有条件跳转指令完成第 4 关。

一开始看到这个成就的时候,你可能觉得这是无解的。但是如果你仔细阅读了手册,你会发现手册上介绍了一个魔法般的跳转指令:jro 指令。

jro 即 Jump Relative Offset,按相对偏移量跳转。它的用法是:jro <src>,从数据源处读入一个值,并跳转到当前行后的这么多行处执行。如果 src 为负数,则改为往前跳转 |src| 行。如果 src 为 0,则本次会停留在这一行,直到从数据源处读入的数不为 0 为止。

手册里给 jro 指令的用法举了这么几个例子:

前三种用法因为跳转到的位置是固定的,所以本质上和 jmp 指令没什么两样。jro 指令真正厉害的地方在于第四种用法:jro acc,根据 acc 里的值动态决定要往哪跳。以及由此衍生出的 jro up/down/left/right,由邻居节点来控制你往哪跳!这是无条件跳转的 jmp 指令,以及之前介绍的四大条件跳转指令做不到的点。

介绍完了 jro 指令后,我们回到题目。

这题的输入只有 -2、-1、0、1、2 这五种,每种输入都对应着唯一一种输出。因此我们可以将节点里的代码分成五部分,并对输入量做一定的处理,使之变成 jro 指令里的偏移量,将程序引导到相应的代码块处执行。由于 jro 0 会将程序阻塞在当前行不动,所以我们需要将原始的输入量 +3,使其变成 1、2、3、4、5 后,再使用 jro acc 做相对跳转。程序代码如下:

左上方和左侧居中的节点跟之前一样纯传话,但是左下角的节点有了改变,将输入量 +3 后再传给右边(mov 3 acc, add up, mov acc right)。这样 -2、-1、0、1、2 的输入量就映射成了 1、2、3、4、5。

现在我们来看 OUT.G 上方的节点有什么变化。题目要求 IN 为正数时,OUT.G 输出 1,否则输出 0。由于输入量固定在 -2 ~ +2 范围内,所以题目事实上变成了:当 IN 为 -2、-1 或 0 时,OUT.G 输出 0;当 IN 为 1、2 时,OUT.G 输出 1。我们从左侧节点收到并存入 acc 的是 IN + 3 的值,所以我们需要做的事就是:当 acc 分别为 1、2、3、4、5 时,分别对 acc 做一定的运算,将其变为 0、0、0、1、1 并输出。

首先我们从左侧节点接收 IN + 3 的值存入 acc(mov left acc),同时复制一份发给右边的节点(mov acc right)。接下来就是关键的 jro 指令。由于 acc 只可能是 1~5 中的一个,所以 jro acc 的含义是:

  • acc 为 1(IN 为 -2)时,向下移动 1 行执行;

  • acc 为 2(IN 为 -1)时,向下移动 2 行执行;

  • acc 为 3(IN 为 0)时,向下移动 3 行执行;

  • acc 为 4(IN 为 1)时,向下移动 4 行执行;

  • acc 为 5(IN 为 2)时,向下移动 5 行执行。

我们依次验证:

  • 当 acc 为 5 时,向下移动 5 行,依次执行 sub 4, mov acc down。由于 5 - 4 = 1,所以输出的值是 1。

  • 当 acc 为 4 时,向下移动 4 行,依次执行 add 1, sub 4, mov acc down。由于 4 + 1 - 4 = 1,所以输出的值是 1。

  • 当 acc 为 3 时,向下移动 3 行,依次执行 nop, add 1, sub 4, mov acc down。由于 3 + 1 - 4 = 0,所以输出的值是 0。

  • 当 acc 为 2 时,向下移动 2 行,依次执行 add 1, nop, add 1, sub 4, mov acc down。由于 2 + 1 + 1 - 4 = 0,所以输出的值是 0。

  • 当 acc 为 1 时,向下移动 1 行,依次执行 add 1, add 1, nop, add 1, sub 4, mov acc down。由于 1 + 1 + 1 + 1 - 4 = 0,所以输出的值是 0。

以上我们接触到了一条新指令:空操作指令 nop。这条指令本身除了占用一个机器周期,以及占用一行宝贵的代码空间外,不产生任何效果。那么我们为什么还需要写这么一条空操作指令呢?因为,以上代码里,由于 acc 为 3 和 4 时都只需要执行 +1 和 -4 两条运算,但 acc 为 3 和 4 时,jro 的跳转点又不同。所以向下跳转 3 行时,我们只能在这个地方加一条 nop 空操作指令占位,让两个跳转点事实上执行的操作完全一致。它可以被等效替换成 add 0, sub 0, jro 1 等指令,毕竟将 acc 加上 0 或减去 0,或者只是正常跳转到下一行去执行,事实上也等效于本行代码没有执行任何操作。

现在我们看 OUT.E 上方的节点。它会从左边的节点收到一份 IN + 3(mov left acc),同样复制一份传给右边的节点(mov acc right)。OUT.E 要求 IN 为 0 时输出 1,IN 为 -2、-1、1、2 时输出 0。这相当于 acc 为 3 时输出 1,为 1、2、4、5 时输出 0。那我们看看本节点执行 jro acc 后的效果:

  • 当 acc 为 5 时,向下移动 5 行,依次执行 sub 5, mov acc down。由于 5 - 5 = 0,所以输出的值是 0。

  • 当 acc 为 4 时,向下移动 4 行,依次执行 add 1, sub 5, mov acc down。由于 4 + 1 - 5 = 0,所以输出的值是 0。

  • 当 acc 为 3 时,向下移动 3 行,依次执行 add 2, add 1, sub 5, mov acc down。由于 3 + 2 + 1 - 5 = 1,所以输出的值是 1。

  • 当 acc 为 2 时,向下移动 2 行,依次执行 nop, add 2, add 1, sub 5, mov acc down。由于 2 + 2 + 1 - 5 = 0,所以输出的值是 0。

  • 当 acc 为 1 时,向下移动 1 行,依次执行 add 1, nop, add 2, add 1, sub 5, mov acc down。由于 1 + 1 + 2 + 1 - 5 = 0,所以输出的值是 0。

最后我们来看 OUT.L 上方的节点。它会从左边的节点收到一份 IN + 3(mov left acc),但是它右边没有节点了,所以不需要再传给右边了,我在原先第二行的代码 mov acc right 前加上了 # 号,让它变成了注释。OUT.L 要求 IN 为 -2、-1 时输出 1,IN 为 0、1、2 时输出 0。这相当于 acc 为 1、2 时输出 1,为 3、4、5 时输出 0。那我们看看本节点执行 jro acc 后的效果:

  • 当 acc 为 5 时,向下移动 5 行,依次执行 sub 5, mov acc down。由于 5 - 5 = 0,所以输出的值是 0。

  • 当 acc 为 4 时,向下移动 4 行,依次执行 add 1, sub 5, mov acc down。由于 4 + 1 - 5 = 0,所以输出的值是 0。

  • 当 acc 为 3 时,向下移动 3 行,依次执行 add 1, add 1, sub 5, mov acc down。由于 3 + 1 + 1 - 5 = 0,所以输出的值是 0。

  • 当 acc 为 2 时,向下移动 2 行,依次执行 add 2, add 1, add 1, sub 5, mov acc down。由于 2 + 2 + 1 + 1 - 5 = 1,所以输出的值是 1。

  • 当 acc 为 1 时,向下移动 1 行,依次执行 add 1, add 2, add 1, add 1, sub 5, mov acc down。由于 1 + 1 + 2 + 1 + 1 - 5 = 1,所以输出的值是 1。

点击左下角的【RUN】,稍等片刻,弹出结算界面后,便可解锁 UNCONDITIONAL 成就。

当然,你目前只接触到了 jro 指令的这种初步的用法。后续的关卡里,节点间的分工合作会越来越复杂,有部分关卡离开了 jro 指令就直接无解。到了相应的关卡里时,我会详细解说 jro 指令的高级用法。

【TIS-100 攻略】第 4~5 关:信号比较器、信号叠加器的评论 (共 条)

分享到微博请遵守国家法律