【TIS-100 攻略】第 8~9 关:信号突变检测器、中断处理器

本文首发于 B 站《TIS-100》文集(https://www.bilibili.com/read/readlist/rl626023)。原创不易,转载请注明出处。
第 8 关《信号突变检测器》(Signal Edge Detector)关卡展示

本关需要不断从 IN 中读取数据,仅当本次数字和上次数字的绝对差值大于等于 10 时,才向 OUT 输出 1,否则向 OUT 输出 0。
本关和上一关类似,同样需要将每一次的输入值读取两遍,一遍用来计算和上次数字的差值,另一遍用来记住本次的值,用于在下一次任务里计算和本次的差值。本关的代码如下:

上方节点和中央节点全部只用来传话(mov up down)。
左下角的节点收到 IN 传来的值后,将它存入 acc(mov up acc),并将同样的值向右发送两遍(mov acc right, mov acc right)。接下来是右边的节点:
首先将 acc 和左边发来的值做差值运算(sub left)。
然后检查差值是否小于 -9,即差值加上 9(add 9)后是否小于 0。
若差值小于 0,则跳到第 8 行执行(jlz 8),否则按顺序执行。代码里 add 9 和 sub left 谁先谁后不影响结果的正确性,截图里之所以先 add 9 再 sub left,是因为 sub left 这条指令需要等待数据从左侧节点传来。这样的话,与其干等,不如先把 add 9 这一步做了,后面就不用做了,这样可以节省总体的运行时间。
上面我们已经判断了差值是否小于 -9,而且如果满足条件的话就已经跳到了第 8 行了。如果还停留在第 4 行的话,说明差值是不小于 -9 的,那么我们还要进一步判定差值是否大于 9,即差值 -9 是否大于 0。现在 acc 里的值是差值 +9,我们现在要令 acc 减去 18,令它变成差值 -9(sub 18),然后判断此时的 acc 是否大于 0。
若大于 0,则同样跳到第 8 行执行(jgz 8)。
两条有条件跳转指令都指向了第 8 行这同一个跳转点,说明这是一个【或】逻辑,两个条件中任意一个成立,都跳转到这个位置去执行。如果此时还停留在第 6 行,就说明【差值 < -9】和【差值 > 9】这两个条件都不满足,此时直接向 OUT 口输出 0(mov 0 down),
并强制跳到第 9 行,无视第 8 行代码(jmp 9)。
接下来是第 8 行,只要满足【差值 < -9】和【差值 > 9】中的任意一条,就会来到这里,向 OUT 口输出 1(mov 1 down)。
输出完成后,我们读取左边节点第二次发来的 IN 值,并放入 acc 中,用作下一次计算差值的依据(mov left acc)。
点击左下角的【RUN】,稍等片刻,便会弹出结算界面:


第 9 关《中断处理器》(Interrupt Handler)关卡展示

这是我们遇到的第一个难度比较大的关卡。这一关一共有 4 路输入,每一路输入信号都只会是 0 或 1。本关要求,任何一路由 0 跳变成 1 时,视为这一路发生了中断,我们需要将发生中断的路数发送给 OUT。当没有任何中断出现时,向 OUT 发送 0。题目保证不会有多路信号同时发生跳变。
看到这个题目,你的第一反应可能还是沿袭上一关的思路:将本次输入信号发送两遍给邻居节点,由邻居节点计算差值决定要往输出口输出多少……
但是本关是四路输入信号,信号如何汇总是个大问题,更何况左下角还有一个损坏的节点,我们根本没法用那么多辅助节点,把每一路的输入信号都存下来,然后往下发两遍……
不过,有个条件我们可以利用一下,就是输入信号只有 0 和 1 两种。因此,我们可以把代码分成两部分:前一次是 0 时要干的事,以及前一次是 1 时要干的事。这两部分完全独立,互不干扰。这样就不需要用辅助节点把每次的输入量存下来然后发两遍了,我们直接用跳转指令定位到对应的代码块上就完事了。本关的代码如下:

前一次的信号是 1 时,不论本次收到的是什么数字,都无脑往下发 0;
前一次的信号是 0 时,如果本次收到的还是 0,那么仍然往下发 0;
仅当前一次的信号是 0,同时本次信号是 1 时,才往下发自己的路数。
上方的 4 个节点,除了最后一行发的路数分别是 1、2、3、4 外,其余代码都完全一致。我们挑最左边这个典型讲。
第 1~3 行代码是【前一次信号为 1】时要执行的操作,第 4~6 行代码是【前一次信号为 0】时要执行的操作。由于第一次收到任何数字都需要往下发 0,所以我们不妨设第一次之前不存在的“前一次信号”为 1。
前一次信号为 1 时,我们先把本次信号给收了(mov up acc)。
然后不论本次信号是啥,都无脑往下传 0(mov 0 down)。
到了这里,我们就需要根据本次收到的值来决定接下来停留在哪个代码块了。如果本次收到的值是 1,那么就跳回第 1 行,把程序牢牢固定在第 1~3 行的代码块中(jgz 1)。如果本次收到的值是 0,那么就放任程序流淌到第 4~6 行的代码块中。
前一次信号为 0 时,我们同样把本次信号收了(mov up acc),然后:
若本次信号仍是 0,则跳到第 2 行,往下传 0(jez 2, mov 0 down),然后由于本次信号是 0,所以接下来的 jgz 1 判定是必然不成立的,程序短暂逃离后,又被吸回了第 4~6 行的代码块中。
若本次信号为 1,则因为信号由 0 变成了 1,我们需要把当前的路数往下发(mov 1 down),发完后,程序会跳到第 1 行,也就是自动来到了【前一次信号为 1】的代码块中。
第 2~4 路的代码同理,只是发生中断时,输出的路数分别变成了 2、3、4。
下方的一排节点用于把上方这一排节点发的四个数字汇总到中间的 3 路节点上。1 路节点只会收到从上方发来的信号,无脑往右传(mov up right);4 路节点同样只会收到上方发来的信号,无脑往左传(mov up left);2 路节点会收到上方和左方发来的信号,依次传给右边的 3 路节点(mov up right, mov left right)。3 路节点是所有信号的汇合点,它会从上方收到一个信号,从左方收到两个信号,从右方收到一个信号。因为不会有多路中断同时发生,所以这些信号中至多只有一个非 0 信号。我们只需要把这些信号加起来(mov up acc, add left, add right, add left),就能发现是哪路发生了中断。我们直接将累加后的值往下发(mov acc down),委托下方的节点传话给 OUT(mov up down),就完成了任务。
这里要注意一下我汇总各路信号的过程,先读一次左路(add left),接着读一次右路(add right),最后再读一次左路(add left)。为什么我要使用这样反复横跳的读取方式,而不用看起来更舒服的【读完一边再读另一边】的方式呢?因为左路的两组信号,一组是自己的,一组是从它的左边传来的。左边的节点只有传完了第一个信号,才会再从它的左边读取第二个信号传给右边。也就是,左路的两个信号间有一定的时间差。为了效率最大化,我们选择在这个时间差里读取早已等候多时的右路信号,等到读完右路信号后,左路的第二个信号正好也传过来了。这样我们的时间就一点都没浪费。
点击左下角的【RUN】,稍等片刻,便会弹出结算界面:

要不是看到我的智乃酱比我的行数还少,我真的就以为我的行数是全球最优的了。毕竟在直方图最左端嘛。真不知道她的 23 行代码是怎么做到的,比我还厉害。
优化代码行数
智乃酱出来了,告诉了我怎么才能写出最省行数的代码。最省行数的方法跟上一关的套路是一样的,检测本次发生了中断的路数,和上一次的状态值进行比较,得出结论。

第 1 路直接将 IN.1 的信号往右传(mov up right)。
第 2 路将 IN.2 的信号乘以 2(mov up acc, add acc),由 0/1 变成 0/2 后往右传(mov acc right)。接着把左边传来的 1 路信号也往右传(mov left right)。
第 4 路将 IN.4 的信号乘以 4(mov up acc, add acc, add acc),由 0/1 变成 0/4 后往右传(mov acc left)。
最麻烦的是第 3 路。首先第 3 路需要将信号乘以 3,而我们使用 add acc 这样的指令只能将原数乘以各种 2 的幂,3 不是 2 的幂,我们无法用这样的方法得到原数乘以 3 的值。但由于输入信号只有 0/1 两种,所以我们使用按条件跳转指令将原信号转换成 0/3。具体做法如下:
首先我们将 IN.3 的信号存入 acc(mov up acc),
然后判断该信号值是否为 0。该信号为 0 时,直接跳到第 4 行执行,跳过第 3 行代码(jez 4)。
仅当原信号不为 0 时,我们才在原信号的基础上 +2(add 2)。这样我们就把 3 路信号由原始的 0/1 转换成了 0/3。
从第 4 行开始,我们把其他几路传来的信号加在了一起(add left),
(add right)
(add left)
并将累加后的信号往下传两遍(mov acc down, mov acc down)。和上一关的方案是同样的道理,一遍用来计算和上次数字的差值,另一遍用来记住本次的值,用于在下一次任务里计算和本次的差值。
中央节点纯粹带路党(mov up down)。下方节点的代码跟上一关是差不多的,简单讲一下:
首先将上一次的信号值和本次的信号值做差(sub up)。
若为负数,则跳到第 4 行执行(jlz 4),否则按顺序执行。
上一次的信号值和本次的信号值之差为 0 或正数时,说明本回合里没有信号突变,或者本次的信号值更小,有一路信号从 1 变成了 0。此时我们只需简单清空 acc 即可(sub acc)。
上一次的信号值和本次的信号值之差为负数时,说明本次的信号值更大,本回合里有一路信号从 0 变成了 1。此时我们对该负数取绝对值,得到由 0 跳变成了 1 的路数(neg)。注意:差值为非负数时,这条 neg 指令一样会被执行到,不过因为 0 的相反数仍是 0,所以这时候 neg 就相当于空操作指令,两条分支就得以共用代码。
将 acc 值向下输出(mov acc down)。
输出完成后,我们读取上方第二次发来的信号值,并放入 acc 中,用作下一次计算差值的依据(mov up acc)。
点击左下角的【RUN】,稍等片刻,便会弹出结算界面:

至此我们减少到了 24 行代码。
能去掉 NEG 指令吗?
我们注意到以上方案中,最后一行芯片输出非 0 值前还需要给 acc 取个反再输出。那么这个步骤能去掉吗?答案是可以的,只要把上排节点输出的值改一下就行了。之前输出的信号值分别是 0/1、0/2、0/3、0/4,现在改成 0/-1、0/-2、0/-3、0/-4 就 OK 了。这样做相当于把取反的过程交给了上排节点。与此同时,下方节点的判断也要改成【差值为负时输出 0,差值为正或 0 时输出 acc】,跟原来的判定条件正好反过来。

注意我圈出来了的做了改动的地方。下方节点只是把判断改成了正跳转(jgz 4),并去掉了取反指令 neg。关键是上方节点。上方节点先读 3 路信号(mov up acc),该信号值为 0 时直接跳到第 4 行(jez 4),信号值不为 0 时将该信号减去 4(sub 4)。由于 1 - 4 = -3,我们就这样成功把 3 路的 0/1 信号处理成了 0/-3 信号。接下来,由于其他几路传来的仍然是 0/1、0/2、0/4 这样的信号,所以我们要减去这些信号(sub left, sub right, sub left),相当于把这些信号转换成 0/-1、0/-2、0/-4 并累加。累加完成后,把累加后的信号向下传两遍(mov acc down, mov acc down)。

这样改动了一下以后,我们的代码行数由 24 行减少到了 23 行,运行时长也由原先的 376 周期减少到了 375 周期。既然完爆了上一版方案,那上一版方案就没有留着的必要了。