第4部分:QFTASM和Cogol
架构概述
简而言之,我们的计算机具有16位异步RISC哈佛体系结构。手动构建处理器时,实际上需要RISC(精简指令集计算机)体系结构。在我们的情况下,这意味着操作码的数量很少,更重要的是,所有指令的处理方式都非常相似。
作为参考,Wireworld计算机使用了传输触发的体系结构,其中唯一的指令就是MOV
通过写入/读取特殊寄存器来执行计算。尽管这种范例导致了非常易于实现的体系结构,但结果也是无法使用的边界:所有算术/逻辑/条件运算都需要三个指令。对我们来说很明显,我们希望创建一个不那么神秘的架构。
为了在增加可用性的同时保持处理器简单,我们做出了一些重要的设计决策:
- 没有寄存器。RAM中的每个地址均被平等对待,并且可用作任何操作的任何参数。从某种意义上讲,这意味着所有RAM都可以像寄存器一样对待。这意味着没有特殊的加载/存储说明。
- 同样,记忆映射。可以写入或读取的所有内容都共享一个统一的寻址方案。这意味着程序计数器(PC)为地址0,而常规指令和控制流指令之间的唯一区别是控制流指令使用地址0。
- 数据以串行方式传输,以并行方式存储。由于我们计算机的基于“电子”的性质,当数据以串行小字节序(最低有效位在前)的形式传输时,加减法非常容易实现。此外,串行数据消除了对繁琐的数据总线的需要,而繁琐的数据总线对于正确地计时来说确实很宽且繁琐(为了使数据保持在一起,总线的所有“通道”必须经历相同的传输延迟)。
- 哈佛架构,意味着程序存储器(ROM)和数据存储器(RAM)之间的划分。尽管这确实降低了处理器的灵活性,但这有助于优化大小:程序的长度比我们所需的RAM量大得多,因此我们可以将程序拆分为ROM,然后集中精力压缩ROM ,这是只读的时要容易得多。
- 16位数据宽度。这是最小的两个电源,比标准的俄罗斯方块板(10块)宽。这使我们的数据范围为-32768至+32767,最大程序长度为65536个指令。(2 ^ 8 = 256条指令足以完成我们可能希望玩具处理器执行的大多数简单操作,而不是俄罗斯方块。)
- 异步设计。并非由中央时钟(或等效地,几个时钟)来决定计算机的时序,而是所有数据都带有“时钟信号”,该时钟信号与数据在计算机周围流动时并行传输。某些路径可能比其他路径短,尽管这会给中央时钟设计带来困难,但异步设计可以轻松处理可变时间操作。
- 所有指令大小相等。我们认为,其中每条指令具有1个操作码和3个操作数(值目标)的体系结构是最灵活的选择。这包括二进制数据操作以及条件移动。
- 简单的寻址模式系统。具有多种寻址模式对于支持诸如数组或递归之类的功能非常有用。我们设法用一个相对简单的系统实现了几种重要的寻址模式。
概述文章中包含我们架构的说明。
功能和ALU运作
从这里开始,就可以确定处理器应该具有的功能。特别注意了易于执行以及每个命令的多功能性。
有条件的举动
有条件的移动非常重要,既可作为小型控制流程,也可作为大型控制流程。“小规模”是指其控制特定数据移动执行的能力,而“大规模”是指其用作将控制流转移到任意代码段的条件跳转操作。没有专用的跳转操作,因为由于内存映射,有条件的移动既可以将数据复制到常规RAM,又可以将目标地址复制到PC。由于类似的原因,我们还选择了无条件移动和无条件跳转:两者都可以作为条件移动实现,且条件已硬编码为TRUE。
我们选择了两种不同类型的条件移动:“如果不为零则移动”(MNZ
)和“ 如果不为零则移动”(MLZ
)。从功能上讲,它MNZ
等于检查数据中的任何位是否为1,而MLZ
等于检查符号位是否为1。它们分别对等值和比较有用。我们之所以选择这两个而不是诸如“如果零则移动”(MEZ
)或“如果大于零则移动”(MGZ
)之类的原因是,这MEZ
将需要从一个空信号中创建一个TRUE信号,而这MGZ
是一个更复杂的检查,需要符号位为0,而其他至少一位为1。
算术
就指导处理器设计而言,第二重要的指令是基本的算术运算。正如我前面提到的,我们使用的是低字节序的串行数据,其字节序的选择取决于加/减运算的难易程度。通过使最低有效位先到达,算术单元可以轻松跟踪进位。
我们选择对负数使用2的补码表示法,因为这会使加法和减法更加一致。值得注意的是,Wireworld计算机使用了1的补码。
加法和减法是我们计算机对本机算术支持的范围(除移位外,稍后将进行讨论)。其他运算(例如乘法)过于复杂,无法由我们的体系结构处理,因此必须以软件实现。
按位运算
我们的处理器具有AND
,OR
和XOR
可以执行您期望的指令。NOT
我们选择了一条“非”(ANT
)指令,而不是一条指令。NOT
指令的困难再次在于,它必须从缺乏信号的情况下产生信号,这对于细胞自动机来说是困难的。ANT
仅当第一个参数位为1且第二个参数位为0时,该指令才返回1。因此,NOT x
它等效于ANT -1 x
(以及XOR -1 x
)。此外,ANT
它具有通用性,并且在屏蔽方面具有主要优势:在“俄罗斯方块”程序中,我们可以使用它来擦除四聚体。
移位
移位操作是ALU处理的最复杂的操作。它们接受两个数据输入:要移动的值和要移动的值。尽管它们很复杂(由于移动量可变),但是这些操作对于许多重要任务至关重要,包括俄罗斯方块中涉及的许多“图形”操作。位移也将成为高效乘法/除法算法的基础。
我们的处理器具有三个移位操作,“左移”(SL
),“逻辑右移”(SRL
)和“算术右移”(SRA
)。前两个移位(SL
和SRL
)将所有零填充为新的比特(这意味着向右移位的负数将不再为负)。如果移位的第二个参数超出了0到15的范围,则结果将全为零,这可能与您期望的一样。对于最后一个移位,,SRA
该移位保留输入的符号,因此用作真正的二分之一。
指令流水线
现在是时候讨论该体系结构的一些具体细节了。每个CPU周期包括以下五个步骤:
1.从ROM获取当前指令
PC的当前值用于从ROM中获取相应的指令。每条指令具有一个操作码和三个操作数。每个操作数由一个数据字和一种寻址模式组成。从ROM读取这些部分时,它们会彼此分开。
操作码是4位,可支持16个唯一的操作码,其中分配了11个:
0000 MNZ Move if Not Zero
0001 MLZ Move if Less than Zero
0010 ADD ADDition
0011 SUB SUBtraction
0100 AND bitwise AND
0101 OR bitwise OR
0110 XOR bitwise eXclusive OR
0111 ANT bitwise And-NoT
1000 SL Shift Left
1001 SRL Shift Right Logical
1010 SRA Shift Right Arithmetic
1011 unassigned
1100 unassigned
1101 unassigned
1110 unassigned
1111 unassigned
2.将上一条指令的结果(如有必要)写入RAM
根据前一条指令的条件(例如,条件移动的第一个参数的值),执行写操作。写入的地址由上一条指令的第三个操作数确定。
重要的是要注意,在取指令之后进行写操作。这导致创建分支延迟时隙,在该分支延迟时隙中,紧接分支指令之后执行的指令(写入PC的任何操作)将代替分支目标处的第一条指令执行。
在某些情况下(如无条件跳转),可以优化分支延迟时隙。在其他情况下则不能,并且分支后的指令必须保留为空。此外,这种类型的延迟时隙意味着分支必须使用比实际目标指令少1个地址的分支目标,以解决发生的PC增量。
简而言之,由于上一条指令的输出是在提取下一条指令之后写入RAM的,因此条件跳转必须在它们之后有一个空白指令,否则PC不会针对该跳转进行适当的更新。
3.从RAM中读取当前指令参数的数据
如前所述,这三个操作数中的每一个均由数据字和寻址模式组成。数据字为16位,与RAM相同。寻址模式为2位。
由于许多现实世界中的寻址模式都涉及多步计算(例如增加偏移量),因此寻址模式可能是导致此类处理器复杂性很高的原因。同时,通用寻址模式在处理器的可用性中起着重要作用。
我们试图统一使用硬编码数字作为操作数和使用数据地址作为操作数的概念。这导致了基于计数器的寻址模式的创建:操作数的寻址模式只是一个数字,表示应该在RAM读取循环中发送多少次数据。这包括立即,直接,间接和双重间接寻址。
00 Immediate: A hard-coded value. (no RAM reads)
01 Direct: Read data from this RAM address. (one RAM read)
10 Indirect: Read data from the address given at this address. (two RAM reads)
11 Double-indirect: Read data from the address given at the address given by this address. (three RAM reads)
执行此取消引用后,指令的三个操作数具有不同的作用。第一个操作数通常是二进制运算符的第一个参数,但是当当前指令是有条件的移动时,它也用作条件。第二个操作数用作二进制运算符的第二个参数。第三个操作数用作指令结果的目标地址。
由于前两个指令用作数据,而第三个指令用作地址,因此寻址模式根据它们在哪个位置使用而略有不同。例如,直接模式用于从固定RAM地址读取数据(因为需要读取一个RAM),但是立即模式用于将数据写入固定的RAM地址(因为无需读取RAM)。
4.计算结果
操作码和前两个操作数被发送到ALU以执行二进制运算。对于算术,按位和移位运算,这意味着执行相关运算。对于条件移动,这意味着只需返回第二个操作数。
操作码和第一个操作数用于计算条件,该条件确定是否将结果写入内存。在有条件移动的情况下,这意味着要么确定操作数中的任何位是否为1(用于MNZ
),要么确定符号位是否为1(针对MLZ
)。如果操作码不是条件移动,则始终执行写入操作(条件始终为true)。
5.增加程序计数器
最后,读取,递增和写入程序计数器。
由于PC增量在读指令和写指令之间的位置,这意味着将PC增量1的指令是空操作。将PC复制到自身的指令将使下一条指令连续执行两次。但是请注意,如果您不注意指令流水线,则连续多个PC指令会导致复杂的效果,包括无限循环。
寻求俄罗斯方块大会
我们为处理器创建了一种名为QFTASM的新汇编语言。该汇编语言与计算机ROM中的机器代码一对一对应。
任何QFTASM程序都是按一系列指令编写的,每行一条。每行的格式如下:
[line numbering] [opcode] [arg1] [arg2] [arg3]; [optional comment]
操作码列表
如前所述,计算机支持11个操作码,每个操作码都有3个操作数:
MNZ [test] [value] [dest] – Move if Not Zero; sets [dest] to [value] if [test] is not zero.
MLZ [test] [value] [dest] – Move if Less than Zero; sets [dest] to [value] if [test] is less than zero.
ADD [val1] [val2] [dest] – ADDition; store [val1] + [val2] in [dest].
SUB [val1] [val2] [dest] – SUBtraction; store [val1] - [val2] in [dest].
AND [val1] [val2] [dest] – bitwise AND; store [val1] & [val2] in [dest].
OR [val1] [val2] [dest] – bitwise OR; store [val1] | [val2] in [dest].
XOR [val1] [val2] [dest] – bitwise XOR; store [val1] ^ [val2] in [dest].
ANT [val1] [val2] [dest] – bitwise And-NoT; store [val1] & (![val2]) in [dest].
SL [val1] [val2] [dest] – Shift Left; store [val1] << [val2] in [dest].
SRL [val1] [val2] [dest] – Shift Right Logical; store [val1] >>> [val2] in [dest]. Doesn't preserve sign.
SRA [val1] [val2] [dest] – Shift Right Arithmetic; store [val1] >> [val2] in [dest], while preserving sign.
寻址方式
每个操作数都包含一个数据值和一个寻址移动。数据值由-32768到32767范围内的十进制数字描述。寻址模式由数据值的一个字母前缀描述。
mode name prefix
0 immediate (none)
1 direct A
2 indirect B
3 double-indirect C
范例程式码
斐波那契数列在五行中:
0. MLZ -1 1 1; initial value
1. MLZ -1 A2 3; start loop, shift data
2. MLZ -1 A1 2; shift data
3. MLZ -1 0 0; end loop
4. ADD A2 A3 1; branch delay slot, compute next term
此代码计算斐波那契数列,其中RAM地址1包含当前项。它在28657之后迅速溢出。
格雷码:
0. MLZ -1 5 1; initial value for RAM address to write to
1. SUB A1 5 2; start loop, determine what binary number to covert to Gray code
2. SRL A2 1 3; shift right by 1
3. XOR A2 A3 A1; XOR and store Gray code in destination address
4. SUB B1 42 4; take the Gray code and subtract 42 (101010)
5. MNZ A4 0 0; if the result is not zero (Gray code != 101010) repeat loop
6. ADD A1 1 1; branch delay slot, increment destination address
该程序计算格雷码,并将代码存储在从地址5开始的成功地址中。该程序利用了多个重要功能,例如间接寻址和条件跳转。一旦生成的格雷码为101010
,它就会停止,这发生在地址56处的输入51处。
在线口译员
El'endia Starman在这里创建了一个非常有用的在线翻译。您可以单步执行代码,设置断点,对RAM执行手动写入以及将RAM可视化为显示。
古柯
定义了架构和汇编语言后,项目“软件”方面的下一步是创建一种高级语言,该语言适用于Tetris。因此,我创建了Cogol。该名称既是“ COBOL”的双关语,也是“生命游戏的C”的首字母缩写,尽管值得注意的是,Cogol代表的是C代表我们的计算机代表实际的计算机。
Cogol的存在水平仅高于汇编语言。通常,Cogol程序中的大多数行都对应于单个汇编行,但是该语言有一些重要功能:
- 基本功能包括具有赋值的命名变量和具有更易读语法的运算符。例如,
ADD A1 A2 3
变为z = x + y;
,编译器将变量映射到地址上。
- 循环结构(例如
if(){}
,)while(){}
,do{}while();
因此编译器处理分支。
- 一维数组(带有指针算法),用于俄罗斯方块板。
- 子例程和调用堆栈。这些对于防止大量代码重复和支持递归很有用。
编译器(我从头开始编写)非常基础/天真,但是我尝试手动优化几种语言结构以实现较短的编译程序长度。
以下是各种语言功能如何工作的简短概述:
代币化
使用关于允许哪些字符在令牌中相邻的简单规则,对源代码进行线性令牌化(单次通过)。当遇到不能与当前令牌的最后一个字符相邻的字符时,当前令牌被视为已完成,并且新字符开始一个新令牌。某些字符(例如{
或,
)不能与其他任何字符相邻,因此它们是它们自己的标记。其他(如>
或=
)只允许以邻接于它们的类内的其它字符,并且因此可以形成像令牌>>>
,==
或者>=
,但不喜欢=2
。空格字符会在标记之间建立边界,但本身不会包含在结果中。标记最困难的字符是-
因为它既可以表示减法,也可以表示一元求反,因此需要一些特殊的框。
解析中
解析也以单遍方式完成。编译器具有用于处理每种不同语言构造的方法,并且当各种编译器方法使用它们时,会将令牌从全局令牌列表中弹出。如果编译器看到了它不期望的令牌,则会引发语法错误。
全局内存分配
编译器为每个全局变量(字或数组)分配自己的指定RAM地址。必须使用关键字声明所有变量,my
以便编译器知道为其分配空间。临时地址内存管理比命名全局变量要酷得多。许多指令(特别是条件指令和许多数组访问指令)需要临时的“临时”地址来存储中间计算。在编译过程中,编译器会根据需要分配和取消分配暂存地址。如果编译器需要更多的暂存地址,它将把更多的RAM用作暂存地址。我相信程序通常只需要几个暂存地址,尽管每个暂存地址将被使用很多次。
IF-ELSE
陈述
if-else
语句的语法是标准的C形式:
other code
if (cond) {
first body
} else {
second body
}
other code
转换为QFTASM时,代码的排列方式如下:
other code
condition test
conditional jump
first body
unconditional jump
second body (conditional jump target)
other code (unconditional jump target)
如果执行第一个主体,则跳过第二个主体。如果跳过了第一个主体,则执行第二个主体。
在装配中,条件测试通常只是减法,结果的符号决定是进行跳跃还是执行车身。一个MLZ
指令被用来处理不等式如>
或<=
。的MNZ
指令是用来处理==
,因为它跃过体当所述差不为零(并且因此当参数不相等)。当前不支持多表达式条件。
如果else
省略该语句,则也将无条件跳转,并且QFTASM代码如下所示:
other code
condition test
conditional jump
body
other code (conditional jump target)
WHILE
陈述
while
语句的语法也是标准的C形式:
other code
while (cond) {
body
}
other code
转换为QFTASM时,代码的排列方式如下:
other code
unconditional jump
body (conditional jump target)
condition test (unconditional jump target)
conditional jump
other code
条件测试和条件跳转位于该块的末尾,这意味着它们在每次执行该块后都会重新执行。当条件返回false时,主体不再重复,循环结束。在循环执行开始期间,控制流会跳到循环主体上,并跳转到条件代码,因此,如果条件第一次为假,则主体将永远不会执行。
一个MLZ
指令被用来处理不等式如>
或<=
。与指令期间if
语句不同,MNZ
指令用于处理!=
,因为当差异不为零时(因此参数不相等时),指令会跳转到主体。
DO-WHILE
陈述
while
和之间的唯一区别do-while
是,do-while
循环主体最初不会被跳过,因此它总是至少执行一次。do-while
当我知道循环永远不需要完全跳过时,我通常使用语句来保存几行汇编代码。
数组
一维数组被实现为连续的内存块。所有数组都基于其声明为固定长度。数组声明如下:
my alpha[3]; # empty array
my beta[11] = {3,2,7,8}; # first four elements are pre-loaded with those values
对于阵列,这是一个可能的RAM映射,显示如何为阵列保留地址15-18:
15: alpha
16: alpha[0]
17: alpha[1]
18: alpha[2]
标记的地址alpha
用指向的位置的指针填充alpha[0]
,因此在这种情况下,地址15包含值16。该alpha
变量可以在Cogol代码内部使用,如果您想将此数组用作堆栈,则可以用作堆栈指针。 。
使用标准array[index]
符号完成对数组元素的访问。如果的值index
是一个常数,则该引用将自动用该元素的绝对地址填充。否则,它将执行一些指针算术(仅加法)以找到所需的绝对地址。也可以嵌套索引,例如alpha[beta[1]]
。
子程序和调用
子例程是可以从多个上下文中调用的代码块,从而防止代码重复并允许创建递归程序。这是一个带有递归子程序的程序,用于生成斐波那契数(基本上是最慢的算法):
# recursively calculate the 10th Fibonacci number
call display = fib(10).sum;
sub fib(cur,sum) {
if (cur <= 2) {
sum = 1;
return;
}
cur--;
call sum = fib(cur).sum;
cur--;
call sum += fib(cur).sum;
}
子例程用关键字声明,sub
子例程可以放在程序内的任何位置。每个子例程可以具有多个局部变量,这些局部变量被声明为其参数列表的一部分。也可以为这些参数指定默认值。
为了处理递归调用,子例程的局部变量存储在堆栈中。RAM中的最后一个静态变量是调用堆栈指针,其后的所有内存都用作调用堆栈。子程序被调用时,它在调用堆栈上创建了一个新帧,其中包括所有局部变量以及返回(ROM)地址。程序中的每个子例程都被赋予一个静态RAM地址,以用作指针。该指针给出了子例程的“当前”调用在调用堆栈中的位置。使用此静态指针的值加上一个偏移量来引用该局部变量,以给出该特定局部变量的地址。调用堆栈中还包含静态指针的先前值。这里'
RAM map:
0: pc
1: display
2: scratch0
3: fib
4: scratch1
5: scratch2
6: scratch3
7: call
fib map:
0: return
1: previous_call
2: cur
3: sum
子例程有趣的一件事是它们不返回任何特定值。而是,子例程执行后可以读取子例程的所有局部变量,因此可以从子例程调用中提取各种数据。这是通过存储该子例程的特定调用的指针来实现的,然后可以使用该指针从(最近已解除分配的)堆栈帧中恢复任何局部变量。
调用子例程有多种方法,所有方法都使用call
关键字:
call fib(10); # subroutine is executed, no return vaue is stored
call pointer = fib(10); # execute subroutine and return a pointer
display = pointer.sum; # access a local variable and assign it to a global variable
call display = fib(10).sum; # immediately store a return value
call display += fib(10).sum; # other types of assignment operators can also be used with a return value
可以将任意数量的值作为子例程调用的参数。未提供的任何参数将使用其默认值(如果有)填充。未清除没有提供且没有默认值的参数(以节省指令/时间),因此在子例程开始时可能会采用任何值。
指针是访问子例程的多个局部变量的一种方式,尽管要特别注意的是,该指针只是临时的:指针所指向的数据在进行另一个子例程调用时将被破坏。
调试标签
{...}
Cogol程序中的任何代码块都可以带有多词描述性标签。此标签作为注释附加在已编译的汇编代码中,并且对于调试非常有用,因为它使查找特定代码块更加容易。
分支延迟时隙优化
为了提高编译代码的速度,Cogol编译器执行了一些真正基本的延迟时隙优化,作为对QFTASM代码的最后一次传递。对于具有空的分支延迟槽的任何无条件跳转,该延迟槽可以由跳转目标处的第一条指令填充,并且跳转目标增加一个以指向下一条指令。每次执行无条件跳转时,通常可以节省一个周期。
用Cogol编写俄罗斯方块代码
最终的Tetris程序是用Cogol编写的,其源代码在此处。此处提供了已编译的QFTASM代码。为方便起见,此处提供了一个永久链接:QFTASM中的Tetris。由于目标是打高尔夫球的汇编代码(而不是Cogol代码),因此生成的Cogol代码不方便使用。程序的许多部分通常都位于子例程中,但是这些子例程实际上足够短,以至于将代码保存的指令复制到了子例程中。call
陈述。最终代码除主代码外仅具有一个子例程。此外,删除了许多数组,并用等长的单个变量列表或程序中的许多硬编码数字替换了它们。最终编译的QFTASM代码在300条指令下,尽管它仅比Cogol源本身稍长。