本文编译自 Brad Rodriguez 《 Moving Forth 》,原文首次在 The Computer Journal #59 (January/February 1993) 上发表,现在可在下面网站上取得
http://www.zetetics.com/bj/papers/index.html
本文对 Forth 语言在各种处理器上的各种实现方式进行了深入的探讨,尽管其中作为例子的处理器非常古老,但对于理解 Forth 系统仍有着很大的参考价值。译文按原文结构翻译,省略了各部分的参考文献,其内容和本文中所列出的源程序代码都可以在上述网站上得到。
目录
第一部分 Forth 内核的设计决策
第二部分 内核基准测试和个案研究
第三部分 解密 DOES>
第四部分 汇编器还是 META 编译器
第五部分 Z80 原语
第六部分 Z80 高级内核
第七部分 8051 的 CamelForth
第八部分 MC6809 CamelForth
每一个进入 Forth 圈里的人都说或者听说“把 Forth 移植到一个新 CPU 上是一件易如反掌的事情”。不过,就像其它许多“易如反掌”的事情一样,却没有多少书面的资料告诉我们如何去做!所以,当 Bill Kibler 建议这个论文题目时,我决定打破 Forth 编写者只说不练的传统,给出一个白纸黑字的 Forth 实现 , 包括为 MC6809 、 Intel 8051 和 Z80 实现的 Forth 系统。
在整个文档中,我准备为 MC6809 、 Intel 8051 和 Zilog Z80 实现 Forth 系统。我会用 MC6809 来解释一个简单的和传统的 Forth 模型,此外,我还将公布一个 MC6809 汇编器,把 6809 Forth 用于未来的 TCJ 计划,把 8051 作为一个大学项目,其中也解释了一些非常不同的决策。 Z80 Forth 是为所有的 TCJ CP/M 读者和许多 TRS-80 的老朋友而编写的。
首先,我们必须选择一个 CPU 。不过,我不想陷入“Forth 运行在这种 CPU 上比运行在那种 CPU 上更有效”的争论中,因为 CPU 的选择通常还需要考虑其它因素,并且这篇论文的目标之一就是想说明如何把 Forth 搬到任何一个 CPU 上。
通常, 16 位 Forth 内核需要 8K 字节的程序空间。对于一个能够真正用来编译 Forth 语言应用的完整内核来说,应该至少有 1K 字节的 RAM 。如果想使用 Forth 的磁盘存储器块管理功能,还应该再增加 3K 字节以上的 RAM 用于缓冲区。对于 32 位系统,这些数值都需要加倍。
以上这些是一个 Forth 内核能够运行的最小要求。为了在硬件上运行应用程序,你还得按实际需要另外增加 PROM 和 RAM 的大小。
实际的系统并不要求 Forth 的字长度与 CPU 的字长度一致。最小的、实际可用的 Forth 系统都使用 16 位模型,也就是说使用 16 位的整数和 16 位的地址。 Forth 的术语把这种尺寸称为单元(CELL)而不是我们常说的“字”,因为“字”在 Forth 中是指一个 Forth 定义(可以简单地理解成其它高级语言的子程序名)。
所有的 8 位 CPU 几乎都不加改变地支持 16 位的 Forth ,为此,要求编码进行双字节算术运算,尽管某些 8 位 CPU 也能够直接支持其中的一些操作。
有一些技术可以在一个16位的机器上写出 32 位的 Forth ,但是 16 位的 CPU 通常运行 16 位的 Forth ,虽然我们也看到 32 位的 Forth 可以运行在 Intel 8086/8088 上。
32 位的 CPU 通常运行 32 位的 Forth 。实际应用中,一个更小的模型几乎不能节省代码空间和处理器时间。但我也见到过为 MC68000 编写的 16 位 Forth 。这个系统的代码长度缩小了 2 倍,因为高级 Forth 定义变成了 16 位的地址串而不再使用 32 位的地址串。不过大多数的 MC68000 系统都有很多 RAM ,好象没有必要进行这样的努力。
本文描述的所有例子都是运行在 8 位 CPU 上的 16 位 Forth 系统。
“串线编码”是 Forth 的标志。一个 Forth “串线”就是被执行的子程序地址的列表。你可以把它们想象成一连串省略了 CALL 指令的子程序调用表。长期以来,人们发明了多种串线形式,为了作出选择,你必须理解所有这些串线形式是如何工作的,以及它们各自的优缺点。
间接串线编码(ITC)技术是一种经典的 Forth 串线编码技术,最早出现在 FIG-Forth 和 F83 系统中,并且在许多关于 Forth 的书中都有描述。后来的串线方式都是直接串线编码方式的“发展”,所以,你需要先理解这个技术。
让我们看一个 Forth 字 SQUARE 的定义 :
: SQUARE DUP * ;
在一个典型的 ITC Forth 中,该定义在存储器中的情况如图 1 所示(首部将在以后讨论,它保存编译信息,但并不在串线中访问)
图 1 ITC Forth 定义的存储器
假设在执行某个 Forth 字的时候遇到了字 SQUARE , Forth 的解释指针 IP 将指向存储器的一个单元,其中包含字 SQUARE 的地址,当然更严格地应该说,这个单元包含着 SQUARE 的“代码域地址”。解释器读出这个地址并用这个地址读出 SQUARE 的代码域内容。它还是一个地址 -- 这个地址是一个机器语言子程序,由这个子程序来执行定义 SQUARE 。
我们可以把上面的描述通过伪码表示如下:
(IP)-> W 读取 IP 指向的存储器内容到 W 寄存器, W 现在有代码域的地址;
IP+2->IP 增量 IP, 它就像一个程序计数器,而且假设串线中的地址是 2 个字节长;
(W) -> X 读取由 W 指向的存储器内容到 X 寄存器, X 现在指向机器码地址;
JP (X) 跳到 X 寄存器指向的地址执行;
这里解释了一个重要的、但却很少有人说明的原理:进入的 Forth 字的当前地址保存在 W 寄存器中。 CODE 字不需要这个信息,但是其它类型的 Forth 字确实需要其中的信息。
如果 SQUARE 是由机器代码写成的,事情也就结束了:这些机器代码被执行,然后跳回到 Forth 解释器 -- 由于 IP 已经增量,它将指向下一个将被执行的字。所以 Forth 解释器通常被称为 NEXT 。
可是, SQUARE 是一个高级的“冒号”定义 -- 它保持一个“串线”,或者说一个地址列表。为了执行这个定义, Forth 解释器必须在一个新的位置上重新启动,这个位置就是 SQUARE 的参数域。当然,解释器必须保存旧的位置,以便 SQUARE 结束之后能够恢复“另一个” Forth 字。这实际上与一个子程序调用没有任何区别!
SQUARE 机器语言的动作就是简单地把旧的 IP 值保存到堆栈上,并把 IP 指向新的位置,执行解释器,当 SQUARE 完成后弹出恢复 IP 。(正如你看到的, IP 就是 Forth 高级定义的“程序计数器”),这个过程在不同的 Forth 版本中可能被称为 DOCOLON 或者 ENTER :
PUSH IP 压入“返回地址栈” ;
W+2 -> IP W 已经指向代码域,所以 W+2 就是定义体的地址! ( 假设是 2 字节的地址,不同的 Forth 可能不同 )
JUMP NEXT 到解释器 ( “ NEXT ” )
这样的一段代码被用于所有的高级(串线) Forth 定义!于是我们回答了两个问题:
• 为什么在 Forth 定义中用一个指针指向代码段,而不是把代码段本身直接嵌入到定义中。因为如果有数以百计的定义,就可以节省大量的空间;
• 为什么这种方式被称为“间接串线编码”;
“从子程序返回”动作由字 EXIT 完成,它被 Forth 的分号“;”编译进定义中(有些 Forth 系统使用 ;S 替代 EXIT)。 EXIT 执行下列的机器语言:
POP IP 从“返回地址栈”弹出指针
JUMP interpreter 跳转到解释器
注意 ITC 的特点:每个 Forth 字都有一个单元的代码域,冒号定义给定义中的每一个字编译一个单元。 Forth 解释器为了执行机器代码,必须实际执行两次间接才能取得下一个机器码的地址(首先通过 IP ,然后通过 W)。
ITC 既不是代码尺寸最小的、也不是执行速度最快的串线技术。它可能只是最简单的技术,尽管下面讨论的另一种技术DTC 实际上也不是特别复杂。那么为什么有这么多的 Forth 系统都使用间接串线技术呢?主要是由于以前作为原始模型的 Forth 系统都使用间接串线技术,而现在, DTC 技术却用得最多。
那么什么时候应该使用 ITC 技术呢?很明显, ITC 形式能够产生最纯净和最一致的定义:其中只有一种类型,这种类型就是地址。如果你正好就有这样的需要,那 ITC 技术就是适合的。如果你的代码关注定义的内部, ITC 技术的简单性和单一性还能够增加可移植性。
此外, ITC 是经典的 Forth 模型,它可以非常好地用于教学。
最后,在某些缺少子程序调用指令的早期 CPU 上 -- 比如 1802 -- ITC 常常比 DTC 更有效。
直接串线编码(DTC)技术与 ITC 技术的差别只有一点:不像 ITC 在代码域中包含机器码的地址, DTC 的代码域中包含有实际的机器代码本身。
注意,我并不是说在每个冒号定义中都包含全部的 ENTER 代码。我的意思是:在“高级” Forth 字中,如图 2 所示,代码字段有一个子程序调用指令。例如,冒号定义中将包含一个对 ENTER 子程序的调用。
图 2 DTC Forth 定义的存储
直接串线的 NEXT 伪代码非常简单:
(IP) -> W 取 IP 指针指向的存储器内容到 W 寄存器中
IP+2 -> IP 增量 IP ( 假设 2 字节的地址 )
JP (W) 跳转到 W 寄存器指向的地址执行
DTC 的收益就是速度:解释程序现在只需要执行一次间接。在 Z80 上,这实际是把 NEXT 子程序 --Forth 内核中最常用的代码段 -- 从 11 个指令减少到了 7 个指令。
DTC 的成本是空间:在一个 Z80 Forth 中,每个高级定义都将增加一个字节的长度,因为 ITC 中 2 个字节的地址现在被 3 个字节的 CALL 调用指令所取代。当然这个结论也不是广泛适用的,在 32 位的 MC68000 Forth 中,可以用 4 字节的 BSR 指令代替 4 字节的地址,其中没有任何差异。而在 Zilog 的 SUPER8 中,有一个直接用于 Forth DTC 的指令,它用一个字节的 ENTER 指令代替 2 字节的地址,使得在 SUPER8 上, DTC Forth 比 ITC Forth 的代码还要小。
当然 DTC 的 CODE 定义也缩短了 2 个字节,因为它们不再需要指针。
我一直以为 DTC Forth 的高级定义字必须在代码域中使用子程序调用指令, Frank Sergeant的 Pygmy Forth [SER90] 提出可以使用更简单的跳转指令,这样更容易实现,通常也更快。
Guy Kelly 对 IBM PC 上实现的 Forth 系统进行了很好的总结,这也是我对所有 Forth 编写者的建议。
在他研究的 19 个 Forth 实现中,有 10 个使用了 DTC 技术, 7 个使用了 ITC 技术, 2 个使用了子程序串线技术(这种技术我们将在下面讨论)。所以,我认为所有新实现的 Forth 内核都应该使用直接串线技术,而不要再使用间接串线技术了。
Forth 的内层解释器 NEXT 是一个用于所有 CODE 定义的通用子程序。你可以编写一个子程序,然后让所有的 CODE 字跳转到这个子程序上执行(注意:跳转到 NEXT 而不必通过子程序调用到 NEXT )。
然而, NEXT 的速度对于整个 Forth 系统的速度来说是至关重要的,从这个角度考虑, NEXT 最好是内嵌代码,于是 NEXT 也可以被定义成一个汇编的宏。
这就是一个常见的速度 / 空间折衷问题:内嵌的 NEXT 总是更快,但也总是更大。全部增加的尺寸数量是内嵌扩展需要的字节数乘以系统中 CODE 字的数量。当然有时也根本不需要考虑折衷:在 MC6809 中,内嵌的 NEXT 总是比一个 JUMP 指令还要短!
一个高级的 Forth 定义字只不过是“要执行的子程序的列表”,并不一定要通过解释才能实现它们,你也可以通过简单地调用一系列子程序而得到同样的效果:
SQUARE:
CALL DUP
CALL * ; 或者是一个合适的名字,因为有些汇编器不支持把 * 作为子程序名
RET
图 3 为汇编程序员解释了 Forth 的 STC 串线技术。 [KOG82].
图 3 DTC Forth 定义的存储
STC 有一个统一的表示方式,冒号定义和 CODE 字没有区别,“定义字”(这是 Forth 的专用术语,像 VARIABLE 、 CONSTANT 这样一些可以用来定义新字的字被称为定义字)像 DTC一样处理 -- 代码域用一个跳转或者调用指令转到其它地方的机器码。
STC 的一个主要缺点是:子程序调用指令通常比简单的地址列表址大。比如在 Z80 上,冒号定义的尺寸将增大 50% -- 而你的应用中大部分都是冒号定义。相比在32位的 MC68000 上,如果使用 4 字节的 BSR 代替 4 字节地址,代码尺寸没有任何增加,不过,如果你的代码超过了64K ,一些地址就必须用 6 字节的 JSR 代替。
子程序串线可能比直接串线更快。在 STC 中节省了解释器执行的时间,但必须花费 Forth 字用于返回的 PUSH 、 POP 时间。而在 DTC Forth 中,只有高级定义才引起返回栈动作,在 MC6809 或者 Zilog SUPER8 中, DTC 比 STC 更快。
STC 还有一个优点:它不需要 IP 寄存器。有些处理器 -- 像 Intel8051 -- 缺少地址寄存器,没有虚拟机 IP 寄存器可以真正地简化内核并提高速度。
在一些古老的 8 位 CPU 上,几乎每个 Forth 原语都需要用几个机器指令才能实现,但是在更强大的 CPU 上,有时 Forth 原语只需要一个机器指令。例如,在一个 32 位的 MC68000 上, DROP 可以简化为:
ADDQ #4,An 这里 An 是 Forth 的 PSP 参数栈寄存器
在一个子程序串线的 Forth 中,冒号定义中使用 DROP 将产生这样的序列:
BSR ...
BSR DROP ...
DROP:
ADDQ #4,An
BSR ...
RTS
ADDQ 本来是一个 2 字节指令,我们为什么要写一个对这个 2 字节指令的 4 字节子程序调用呢?在这种情况下,不论有多少个 DROP ,通过子程序调用都不会产生任何的节省。而如果把 ADDQ 直接编码到 BSR 流中,产生的代码都会更小,运行得更快。有些 Forth 编译程序已经实现了这样的 CODE 字“内嵌扩展” [CUR93a] 。
内嵌扩展的缺点是:如果要把代码反编译回原始的代码就会非常困难。如果仅仅是使用子程序串线,我们依然可以得到指向 Forth 字的指针(子程序的地址)。通过字指针,就可以得到它们的名字。但是如果指令字扩展到内嵌编码中,所有的关于字来源的信息就全部丢失了。
除了速度和空间之外,内嵌扩展还有个优点:潜在的代码优化。例如: Forth 序列:
3 +
在 68000 STC 被编译成:
BSR LIT
.DW 3
BSR PLUS
但是,使用内嵌代码,就可以把它优化成一个机器指令。
Forth 编译器优化是一个广阔的领域,也是 Forth 语言研究中一个非常活跃的领域,这里不能完全讨论,可参见 [SCO89] 和 [CUR93b] 。优化 STC 的最终结果是能够产生“纯”机器代码的 Forth 编译器,就像 C 或者 Fortran 编译器一样。
DTC 和 STC 技术的目标是用一定的存储器消耗为代价来增加 Forth 程序的执行速度。现在让我们转向 ITC 的另一个方向:运行速度更慢、但代码尺寸更小。
Forth 串线的目的是指定一系列将要执行的 Forth 字(子程序)的地址。假设一个 16 位的 Forth 字最大只有 256 个 Forth 字,那么每个 Forth 字都可以用一个 8 位数来标识,我们就可以不使用 16 位的地址列表,而是用一系列的 8 位标识或者称为“标记( TOKEN )”来代替地址,这样冒号定义的代码尺寸就减少了一半。
在一个标记串线编码的 Forth 系统中,需要有一个记录所有 Forth 字的表格,如图 4 所示。标记值就是这个表项的索引,通过它来寻找一个指定标记对应的 Forth 字。这种方法为 Forth 解释器增加了一次间接访问,所以它比“地址串线”的 Forth 执行速度更慢。
图 4 DTC Forth 定义的存储
标记串线的基本优点是尺寸很小。 TTC 技术在手持计算机和其它对尺寸要求严格的应用中极为常见。同时,使用统一的 Forth 字“入口”表也简化了分开编译模块的链接。
TTC 的缺点是:速度慢。 TTC 的 Forth 系统速度是所有技术中最慢的, TTC 编译器也比其它技术的编译器更复杂一些。如果你的应用有多于 255 个 Forth 字定义,则还需要一些其它的编码方式来混合 8 位和更大的标记。
说到 TOKEN 串线,也许会想到的情况是 32 位的 Forth 系统通过 TOKEN 串线而使用 16 位的 Forth 代码,不过,实际上又有多少 32 位系统是存储器尺寸受限的呢?
由于曾经有许多的 Intel 8086 派生系统,我们也简单地提一下段串线技术。这种技术不再使用一个 64K 段内的“一般”字节地址,而是使用节地址(在 Intel 8086 中,一个节的大小是 16 个字节)。这样,解释器可以把这些地址装入段寄存器,而不是通常的地址寄存器。这就允许 16 位的 Forth 模型可以有效地访问 8086 的 1M 字节存储器。
段串线模型的基本缺点是 16 字节大小的存储器“粒度”,因为这种技术要求每个 Forth 字必须在 16 字节的边界上对齐,而一个 Forth 字又具有随机的长度,所以平均每个字要浪费 8 个字节。
在讨论了各种串线技术之后, CPU 寄存器的分配和使用就是至关重要的设计考虑了。这可能也是最困难的。 CPU 寄存器的可用性又会反过来决定我们使用哪种串线技术,甚至决定我们使用哪种方式的存储器映射。
经典的 Forth 虚拟机模型有 5 个“虚拟寄存器”。它们是 Forth 原语的抽象实体。 NEXT 、 ENTER 、 EXIT 就是用这些抽象寄存器定义的。
每个寄存器的宽度都是一个单元,也就是说,在 16 位 Forth 系统中,它们都是 16 位寄存器。(以后你会看到,也有一些特例)。它们不一定全部都是 CPU 寄存器,如果你的 CPU 没有足够的寄存器,其中一些可以保存在存储器中。本文将按照这些寄存器的重要性来描述,也就是说,在没有足够 CPU 物理寄存器的情况下,最后描述的寄存器应该最先考虑被放置到存储器中。
W 是工作寄存器 它可以被用来做很多事情。首先, W 寄存器应该是一个地址寄存器,应该能用 W 寄存器作为地址来读取和写入存储器;也需要用 W 寄存器做算术运算。在 DTC Forth 中,还要求能用 W 实现间接跳转。W 寄存器在每个 Forth 字中被解释器使用,如果 CPU 只有一个寄存器,那你也必须把这个唯一的寄存器用于W 寄存器 ,而把其它的寄存器放到存储器中,当然,这种实现会使整个系统慢得令人难以置信。
IP 是解释指针 它被每个 Forth 字使用(通过 NEXT 、 ENTER 、 EXIT )。 IP 必须是一个地址寄存器,你也需要增量 IP。子程序串线的 Forth 系统不需要这个寄存器。
PSP 是参数栈指针(或者叫数据栈指针) 有时也简称作 SP 。我使用 PSP 是由于“SP”通常都是 CPU 硬件寄存器的名字,而它们彼此是不能混淆的。大多数 CODE 字需要使用这个寄存器。 PSP 必须是一个堆栈指针,或者是能够增量和减量的地址寄存器。如果可以通过 PSP 进行索引寻址则会为系统带来有一些附加的好处。
RSP 是返回栈指针 有时也简称RP。在 ITC 和 DTC 的 Forth 系统中, RSP被冒号定义使用,在 STC 的 Forth 系统中,它被所有的字使用。 RSP 必须是一个堆栈指针,或者是能够增量和减量的地址寄存器。
如果可能,应该把 W 、 IP 、 PSP 、 RSP 都放到实际的 CPU 物理寄存器中,其它的虚拟寄存器可以保存在存储器中,当然,如果所有的寄存器都保持在 CPU 硬件寄存器中,将带来速度方面的好处。
X 寄存器是一个工作寄存器 不过这里并没有把它作为一个经典的 Forth 寄存器考虑,甚至在使用它作为二次间接的经典 ITC 实现中也没有被当做经典寄存器。在 ITC 中,必须能够使用 X 寄存器实现间接跳转。 X 寄存器也被几个 CODE 字作为算术运算操作的目的地址,在不能使用存储器作为操作数的处理器上是特别重要的。比如在 Z80 上,需要通过下面的方式来实现加法运算(用伪码表示):
POP W
POP X
X+W -> W
PUSH W
有时也定义另外一个寄存器 Y 。
UP 是用户指针 它保持当前任务的用户区基地址。 UP 通常的用法是加上一个偏移量后在高级 Forth 定义中使用它。如果 CPU 可以通过 UP 寄存器索引寻址, CODE 字就可以更简单和更快速地访问用户变量。如果你有多余的寄存器,可以用其中一个作为 UP 。单任务的 Forth 不需要 UP 。
如果需要 X ,则 X 应该优先于 UP 放入 CPU 物理寄存器。 UP 是 Forth 虚拟寄存器中最适合放入存储器的。
许多 CPU 把堆栈指针作为硬件的一部分用于中断和子程序调用。如果把堆栈指针作为 Forth 的一个虚拟寄存器将会怎么样呢?它应该是 PSP 还是 RSP 呢?
这要根据具体情况来考虑。一般认为在 ITC 和 DTC 的 Forth 中, PSP 的使用比 RSP 更加频繁,如果你的 CPU 只有不多的寄存器, PUSH 和 POP 就会比显式地引用存储器速度更快,所以我们可以使用硬件堆栈作为参数栈。
另一方面,如果你的 CPU 有丰富的寻址方式,特别是允许进行索引寻址,就应该为 PSP 分配一个通用的地址寄存器,在这种情况下,应该使用硬件堆栈作为返回栈。
这里的结论对下面的情况不合适。比如在 TMS320C25 中,硬件堆栈的深度只有 8 个单元,这对于 Forth 系统来说基本上没有什么用途,所以它的硬件堆栈只能用于中断, PSP 和 RSP 都必须是通用的地址寄存器。注意 ANS Forth 规范中指定最小的参数栈是 32 个单元,返回栈是 24 个单元,而我选择的数据栈和返回栈都是 64 个单元。
有时你可能会遇到教条的说法,比如硬件堆栈“必须是参数栈”或者“必须是返回栈”。在这种情况下,你可以编写几个 Forth 原语比如: SWAP 、 OVER 、 @ 、 ! 、 + 、 0= 来看看哪种情况代码更小、速度更快。
顺便说一下,如果要做这种测试,字 DUP 和 DROP 价值不高。
偶尔你也会得到有趣的结论! Gary Bergstrom 指出在 MC6809 的 DTC 实现中,用 MC6809 的用户堆栈指针作为 IP 可以快几个周期,这里 NEXT 变成了 POP 。他使用索引指针作为 Forth 的堆栈指针。
如果能把参数栈栈顶元素 TOS 放到寄存器中,则 Forth 的性能会得到明显改善。许多 Forth 字(比如 0= )将不再访问堆栈,其它的 Forth 字做同样的 PUSH 和 POP ,只不过在代码中的位置不同。只有不多的 Forth 字(比如 DROP 和 2DROP )变得比较复杂 -- 你必须同时更新 TOS 的内容。
把栈顶元素放到寄存器中之后,编写 CODE 字时需要遵循这样几个规则:
• 一个字从堆栈上移出一个项目时,必须弹出“新”的 TOS 到寄存器中;
• 一个字加入一个新的项目到堆栈上,必须把“旧”的 TOS 压入栈上 ( 当然,除非它被消耗掉 )
如果你的 CPU 至少有 6 个物理寄存器,我建议你保存 TOS 到其中一个寄存器中。我认为 TOS 比 UP 更重要,但它的重要性又次于 W 、 IP 、 PSP 、 和 RSP 寄存器。 TOS 寄存器执行了许多 X 寄存器的功能,如果这个寄存器可以实现存储器寻址就更加有用。 PDP-11 、 Z8 、 MC68000 处理器都是很好的例子。
Guy Kelly [KEL92] 研究了 19 个 IBM PC 上的 Forth 系统,其中有 9 个使用了 TOS 寄存器。
我认为, TOS 的想法没有广泛被接受的原因首先是下面一些错误的见解:
• 增加了指令;
• 栈顶元素必须通过存储器访问。
• 过分强调了PICK、ROLL 这些价值不高的字,说它们在 TOS 情况下必须进行重新编码。
如果把两个栈顶元素都放到寄存器中,结果会怎么样呢?当你这样做的时候,操作效率是相同的。一个 PUSH 仍然是一个 PUSH ,不论你在此前和以后进行了什么操作。另一方面,缓冲两个堆栈元素却增加了大量的代码:一个 PUSH 现在变成了一个 PUSH 后随一个 MOVE 。把两个元素缓冲到寄存器中,只有在 RTX2000 一类的 Forth 芯片上才有意义,其它的都是一些假想的、听起来似乎非常聪明、但在实际应用中没有什么意义的优化。
这里是一些不同的 CPU 寄存器分配实例,通过这个表,我们可以看出每个 Forth 系统作者的寄存器分配考虑。
[1] F83. [2] Pygmy Forth.
图 5 寄存器分配
“SP”指硬件堆栈指针。“Zpage”是指保存在 6502 存储器零页的值,零页几乎和寄存器一样有用,有时比寄存器更有用。比如,它们可以被用于存储器寻址。“Fixed” 指 Payne's 8051 Forth 有一个单一的、不可移动的用户区, UP 是硬编码的常数。
我们在上面的表格中注意到了什么奇怪的事情吗? 6502 Forth 是一个 16 位的模型,但是却使用了 8 位的栈指针。
在实际情况下,使 PSP 、 RSP 和 UP 的尺寸小于 Forth 的单元尺寸是可能的。这是因为堆栈和用户区相对于整个 CPU 可寻址存储器来说比较小。每个堆栈可以小到 64 个单元,而用户区很少超过 128 个单元。你只需要简单地相信:
• 这些数据区被限制在存储器的一个小的区域中,可以使用短的地址访问;
• 高地址位用其它的方式提供,比如,通过页面选择的方式来提供;
在 6502 CPU 中,硬件堆栈被 CPU 的设计者限定在 RAM 的一个页中(地址为 0x1xx )。8 位堆栈指针可以用作返回栈。参数栈保存在 RAM 的零页中,通过一个 8 位索引寄存器间接访问。
在 8051 中,你可以使用 8 位的寄存器 R0 和 R2 访问外部 RAM ,并显式地提供地址的高 8 位输出到 PORT 2 。这就允许对两个堆栈进行“页选择”。
UP 与 PSP 的 RSP 是有明显区别的:它只是简单地提供一个基地址,从来都不增量和减量。所以,它实际上只是提供这个虚拟寄存器的高位。低位必须借助某种索引技术来实现。例如,在 MC6809 中,你可以使用 DP 寄存器作为 UP 的高 8 位,然后使用直接页面寻址模式去访问这个页面中的 256 个位置。这就强制用户区域从 0x??00 开始,同时限制用户区域长度为 128 个单元, 这些都不是什么大问题。而在 Intel 8086 上,你还可以使用一个段寄存器作为用户区的基地址。
[CUR93a] Curley, Charles, "Life in the FastForth Lane," awaiting publication in Forth Dimensions. Description of a 68000 subroutine-threaded Forth.
[CUR93b] Curley, Charles, "Optimizing in a BSR/JSR Threaded Forth," awaiting publication in Forth Dimensions. Single-pass code optimization for FastForth, in only five screens of code! Includes listing.
[KEL92] Kelly, Guy M., "Forth Systems Comparisons," Forth Dimensions XIII:6 (Mar/Apr 1992). Also published in the 1991 FORML Conference Proceedings . Both available from the Forth Interest Group, P.O. Box 2154, Oakland, CA 94621. Illustrates design tradeoffs of many 8086 Forths with code fragments and benchmarks -- highly recommended!
[KOG82] Kogge, Peter M., "An Architectural Trail to Threaded- Code Systems," IEEE Computer, vol. 15 no. 3 (Mar 1982). Remains the definitive description of various threading techniques.
[ROD91] Rodriguez, B.J., "B.Y.O. Assembler," Part 1, The Computer Journal #52 (Sep/Oct 1991). General principles of writing Forth assemblers.
[ROD92] Rodriguez, B.J., "B.Y.O. Assembler," Part 2, The Computer Journal #54 (Jan/Feb 1992). A 6809 assembler in Forth.
[SCO89] Scott, Andrew, "An Extensible Optimizer for Compiling Forth," 1989 FORML Conference Proceedings , Forth Interest Group, P.O. Box 2154, Oakland, CA 94621. Good description of a 68000 optimizer; no code provided.
[CUR86] Curley, Charles, real-Forth for the 68000 , privately distributed (1986).
[JAM80] James, John S., fig-Forth for the PDP-11 , Forth Interest Group (1980).
[KUN81] Kuntze, Robert E., MVP-Forth for the Apple II , Mountain View Press (1981).
[LAX84] Laxen, H. and Perry, M., F83 for the IBM PC , version 2.1.0 (1984). Distributed by the authors, available from the Forth Interest Group or GEnie.
[LOE81] Loeliger, R. G., Threaded Interpretive Languages , BYTE Publications (1981), ISBN 0-07-038360-X. May be the only book ever written on the subject of creating a Forth-like kernel (the example used is the Z80). Worth it if you can find a copy.
[MPE92] MicroProcessor Engineering Ltd., MPE Z8/Super8 PowerForth Target , MPE Ltd., 133 Hill Lane, Shirley, Southampton, S01 5AF, U.K. (June 1992). A commercial product.
[PAY90] Payne, William H., Embedded Controller FORTH for the 8051 Family , Academic Press (1990), ISBN 0-12-547570-5. This is a complete "kit" for a 8051 Forth, including a metacompiler for the IBM PC. Hardcopy only; files can be downloaded from GEnie. Not for the novice!
[SER90] Sergeant, Frank, Pygmy Forth for the IBM PC , version 1.3 (1990). Distributed by the author, available from the Forth Interest Group. Version 1.4 is now available on GEnie, and worth the extra effort to obtain.
[TAL80] Talbot, R. J., fig-Forth for the 6809 , Forth Interest Group (1980).
我们已经回答了每个与 Forth 实现决策有关的问题,现在应该是“编码并查看结果”的时候了。不过,你肯定不想仅仅为了测试不同的方法就编写许多个完整的 Forth 内核。幸运的是,仅仅编写 Forth 内核的小子集就可以得到一些相当好的“感觉”。
Guy Kelly [KEL92] 研究了 19 个不同的 IBM PC 的下列一些代码样例:
• NEXT …… 是链接“串线”中一个字到另一个字的“内层解释器”。用于每一个 CODE 定义的结尾,是决定 Forth 执行速度的一个最重要的因素。你已经看到了它的 ITC 和 DTC 伪码;在 STC 中,它就是 CALL/RET 指令。
• ENTER …… 也称为 DOCOL 或者 DOCOLON ,高级“冒号”定义代码域动作。它对于速度也是至关重要的;用于每个冒号定义的开始,在 STC 中不需要。
• EXIT …… 在 FIG-Forth 中称为 S; 。结束一个冒号定义执行的代码。它在每个冒号定义的结束处出现,决定高级子程序的返回效率。在 STC 中它就是一个 RET 机器指令。
NEXT 、 ENTER 和 EXIT 表现了串线机制的性能。它们都应该通过实际的编码来评估实现性能。它们也反映了实现时 IP 、 W 和 RSP 寄存器分配策略是否正确。
• DOVAR …… “变量”,对于所有 Forth 变量 VARIABLE 的代码域动作。
• DOCON …… “常量”,对于所有 Forth 常量 CONSTANT 的代码域动作。
DOCON 、 DOVAR 和 ENTER 一起显示了你可以得到一个正在执行的字的参数域地址的效率。这反映了你对 W 寄存器的选择,在 DTC Forth 中,也指出应该在代码域中放一个 JUMP 指令还是一个 CALL 指令。
• LIT …… “文字量”。这个字从 Forth 的高级串线中取一个单元值。有几个字需要使用这样的内嵌参数,这很好地显示了它们的性能。它反映了你对 IP 寄存器的选择。
• @ …… Forth 的存储器读取操作,显示了从高级 Forth 中访问存储器可以有多快。这个字常常从堆栈的 TOS 中受益。
• ! …… Forth 的存储器存操作,从另一方面反映了存储器访问的能力。它消耗堆栈的两个项目,所以能反映参数栈的访问效率。它也很好地说明了我们把 TOS 放在存储器还是放在寄存器中的决策。
• + …… 加法操作,是所有 Forth 算术和逻辑操作的典型代表。
以上是一个非常好的代码样例。我还增加了几个附属的测试:
• DODEOS …… 是用 DOES> 构建字的代码域动作,尽管它没有反映 W 、 IP 和 RSP 的使用。我包含这个字是因为它是 Forth 内核中最费解的代码,如果你可以编码 DODOES 的逻辑,其它的任何东西就都不在话下了。 DODOES 的复杂性将在本文的后面描述。
• SWAP …… 是一个简单的堆栈操作符,但能说明问题。
• ROT …… 是一个更加复杂的堆栈操作符。它为你能简单地访问参数栈给出一个好主意。 ROT 好像需要一个外加的临时寄存器才能完成。如果你能够在不使用 X 寄存器的情况下实现 ROT ,则其它情况下也不会需要 X 寄存器。
• 0= …… 是不多的几个单目算术操作之一,是最有可能从“TOS 在寄存器中 ” 获益的字之一。
• +! …… 是最多被说明的操作,组合了堆栈访问、算术、存储器取和存储器存。这是一个非常理想的用于标准测试的字,尽管比上面所列出的其它字使用频率低。
以上所列的都是最常用的 Forth 字,努力优化它们是值得的。我将给出一个 MC6809 的伪码例子。对于其它的处理器,我将解释特别选择的代码片段。
在 8 位 CPU 世界中, MC6809 是 Forth 程序员的甜蜜之梦。它支持 2 个堆栈!还有另外 2 个地址寄存器和大量的只有 PDP-11 才有的正交寻址方式。正交的意思是指所有的地址寄存器有相同的选项和相同的工作方式,而两个 8 位累加器可以作为一个单一的 16 位累加器使用,并具有许多 16 位操作指令。
MC6809 的程序员模型是
A - 8 bit 累加器
B - 8 bit 累加器 大多数算术操作以累加作为目的寄存器。它们也可以连接在一起作为一个 16 位的累加器 D ( A 是高 8 位, B 是低 8 位)。
X - 16 位索引寄存器
Y - 16 位索寄存器
S - 16 位堆栈指针
U - 16 位堆栈指针 所有用于 X 和 Y 寄存器的寻址模式也可以用于 S 和 U 寄存器。
PC - 16 位程序计数器
CC - 8 位条件标志寄存器
DP - 8 位直接页访问寄存器
MC6800 系列的直接寻址模式可以使用一个 8 位寄存器访问零页存储器的任何位置。 MC6809 允许对任何页进行直接寻址。DP 寄存器提供高 8 位地址(页地址)。
有 2 个堆栈指针可供 Forth 使用,它们是等效的,但 CPU 设计者把 S 用于子程序调用和中断。为一致起见,我们把 S 作为返回栈, U 作为参数栈。
W 和 IP 都要求使用地址寄存器,它们逻辑上用于 X 和 Y 寄存器,我们可以任意指定:
X => W 而 Y => IP 。
现在来选择一个串线模型。我简单地舍弃 STC 和 TTC ,构造一个“传统”的 Forth 。性能上的限制因素是 NEXT 子程序。让我们先看看它的 ITC 和 DTC 实现:
ITC-NEXT:
LDX ,Y ++ (8) (IP) -> W, 增量 IP
JMP [,X] (6) (W) -> temp, 跳转到临时单元的地址
DTC-NEXT:
JMP [,Y++] (9) (IP)->temp, 增量 IP, 跳转到临时单元地址,临时单元在 MC6809 的内部。
NEXT 在 DTC 的 MC6809 中只有一条指令!这就意味着你可以用 2 个字节的内嵌编码,比 JMP NEXT 又快又好。作为比较,子程序串线是这样的:
RTS (5) ... 在 CODE 字的结尾
JSR nextword (8) ... 在串线中下一个 CODE 字的开始
STC 花费 13 个周期用于串线下的一个字,而 DTC 只需要 9 个周期。这是由于子程序串线需要将返回地址弹出和压栈,而 CODE 字却不需要。
决定了使用 DTC 之后,你还有两个选择:高级定义字在它的代码域中使用 JMP 还是 CALL ?决定的因素是我们如何能更快地得到后面的参数域地址。让我们注意一个冒号定义的 ENTER 编码:
如果使用 JSR (Call):
JSR ENTER (8)
...
ENTER:
PULS W (7) 得到 JSR 之后的地址到 W 中
PSHS IP (7) 保存旧的 IP 到返回栈
TFR W,IP (6) 参数域地址 -> IP
NEXT (9) JMP [,Y++] 的汇编语言智能
以上总计 37 个周期
如果使用 JMP:
JMP ENTER (4)
...
ENTER:
PSHS IP (7) 保旧的 IP 到返回栈上
LDX -2,IP (6) 重新得到代码域地址
LEAY 3,X (5) 加 3 存入 IP ( Y )寄存器中
NEXT (9)
以上总计 31 个周期
因为 MC6809 的寻址模式允许另外一级的间接,所以 6809 的 NEXT 不使用 W 寄存器。 ENTER 的 JMP 版本必须再次读取代码域的地址 -- NEXT 没有在任何寄存器中留下这个地址。 JSR 可以通过弹出返回栈直接得到参数域地址。所以, JMP 版本更快。
不论哪一种方式, EXIT 都是一样的:
EXIT:
PULS IP 从返回栈中弹出“保存的”IP
NEXT 继续 Forth 解释
有些寄存器尚未分配。你可以把用户指针放到存储器中,这样的 Forth 也运行得很好。不过 DP 寄存器就浪费了,而 DP 也没有什么其它的用处。让我们使用一个“技巧”来实现,我们把 UP 的高位搬到 DP 中(它的低字节是 0 )。
还有一个没有使用的寄存器 D 寄存器,许多算术操作需要这个寄存器。它应该自由地作为一个可随意使用的寄存器呢?还是应该作为栈顶元素呢? MC6809 使用存储器作为一个操作数,所以并不需要第二个工作寄存器。如果临时需要寄存器,把 D 压入和弹出也很容易。
所以我们只能对两种方式编写测试程序,看看哪个更快。
NEXT 、 ENTER 和 EXIT 不使用堆栈,在各种情况下的代码都是一样的。
DOVAR 、 DOCON 和 LIT 在两种情况下所用的时钟周期数相同。这就解释了我们以前谈到的把 TOS 放到寄存器中仅仅改变 PUSH 或者 POP 的位置:
SWAP 、 ROT 、 0= 、 @ 特别是 + 通过把 TOS 放到寄存器中而加快 :
但是, ! 和 +! 却由于 TOS 放到寄存器中而变慢 :
这些字变慢的原因是许多访问存储器的 Forth 字希望地址在栈顶,所以需要一个额外的 FTR 指令。这就是为什么 TOS 寄存器必须是一个地址寄存器。不幸的是, MC6809 的地址寄存器都用于更重要的 W 、 IP 、 PSP 和 RSP 了。不过,把 TOS 放到寄存器中对于 ! 和 !+ 的损失可以通过许多算术和堆栈操作运行速度的提高而得到弥补。
如果说 MC6809 是 Forth 系统实现者的美梦,那 Intel 8051 简直就是 Forth 实现者的恶梦了。它只有一个通用的地址寄存器,一种寻址模式,总是使用一个 8 位累加器。
所有的算术操作、许多的逻辑操作都必须使用累加器。一个唯一的 16 位操作是 INC DPTR 。硬件堆栈必须使用 128 字节的片内寄存器文件,这样的 CPU 简直就是一堆破铜烂铁!
有些 8051 Forth 实现了一个 16 位的 Forth ,但是它们太慢而不能满足我们的要求。让我们进行某些权衡,以产生一个更快的 8051 Forth 系统。
我们最初的想法是利用那个唯一的地址寄存器。所以我们用 8051 的程序计数器作为 IP -- 也就是说,我们构造一个子程序串线的 Forth 系统。如果编译器在所有可能的情况下都使用 2 字节的 ACALL 代替 3 字节的 LCALL ,多数的 STC 代码将和 ITC/STC 一样小。
子程序串线意味着返回栈指针就是硬件堆栈指针。片上寄存器文件共有 64 个单元空间,但是这些空间并不足以支持多任务堆栈。面对这种情况下你可以考虑以下几个策略:
• 限制这个 Forth 系统为单任务系统;
• 在所有的 Forth 定义入口处把返回地址保存到一个外部 RAM 软件堆栈中;
• 在任务切换的时候把全部返回栈的内容保存到外部 RAM 中。
第二种方法是最慢的!在每个任务切换的时候移动 128 个字节比在每个 Forth 字中移动两个字节要快得多。现在我选择 1 ,而将选择 3 留作以后扩充。
唯一一个真正的地址寄存器 DPTR 将要担负多种使命。它就是 W ,多用途的工作寄存器。
实际上,还有两个寄存器可以寻址外部存储器: R0 和 R1 。它们仅仅提供 8 位地址,高 8 位将显式地输出到口 2 上。但是对于堆栈,这是一个可以容忍的限制,因为我们可以把堆栈限制在 256 字节空间。所以我们使用 R0 作为 PSP 。
同样的 256 字节可以用于用户数据区,这使得 P2 (口 2 )成为用户指针的高字节,像 MC6809 一样,而低字节隐含是 0.
于是 8051 的程序员模型就变成了:
寄存器地址 8051 名字 Forth 使用
0 R0 PSP 的低字节
1 R1
2 R2
3 R3
4 R4
5 R5
6 R6
7 R7
8-7Fh 120 字节的返回栈
81h SP RSP 的低字节(高位字节 = 0 )
82-83h DPTR W 寄存器
A0h P2 UP 和 PSP 的高字节
E0h A
F0h B
注意我们仅仅使用了 BANK0 , 另外的 3 个寄存器 BANK 从 08H 到 1FH , 从 20H 到 2FH 的位寻址寄存器都没有被 Forth 使用。使用 BANK0 可以为返回栈得到最大的连续空间。如果需要,返回栈还可以缩小。
在子程序串线的 Forth 系统中,不需要 NEXT、ENTER 和 EXIT 。
如何处理栈顶元素呢?在 8051 中,有许多的寄存器,而存储器操作却非常昂贵。我们把 TOS 放到 R3:R2 中(按 INTEL 格式,R3 是高字节)。注意,我们不能使用 B:A 寄存器对 -- A 寄存器是一个漏斗,所有的寄存器引用都要通过它进行。
8051 采用了“哈佛”体系结构:程序和数据在分开的存储器中存放。(Z8 和 TMS320 是哈佛体系结构的另外两个例子)。但 8051 使用的是一种“野蛮”的退化形式:软件没有办法从物理上向程序存储器写,这就意味着 Forth 的开发者只能使用下述两个方式:
• 交叉编译全部程序,包括应用程序,放弃实现一个 8051 交互式 Forth 的努力;
• 使一部分或者全部的程序存储器在数据空间可见,最简单的办法就是使这两个空间完全覆盖。
相比 Z8 和 TMS320 就比较文明,它们允许向程序存储器写入。Forth 内核的具体实现将在以后讨论。
选择讨论 Z80 是因为它是非正交 CPU 的一个极端例子,它有 4 个不同种类的地址寄存器,有些操作使用寄存器 A 作为目的寄存器,有些则可以是任意的8位寄存器,有些是 HL 寄存器对,有些则可以是任意的16位寄存器,等等。有些操作(比如 EX DE, HL )却只允许一种寄存器组合。
在 Z80 这类的 CPU 中(或者同样在 8086 中), Forth 功能的指定必须仔细匹配 CPU 寄存器的能力。许多方案需要评估,而唯一的办法常常就是对不同的决策方案编写各种代码进行测试。为了避免本文变成为一堆“代码列表”,我选择了基于许多 Z80 编码经验的一种寄存器指定,它说明了这些选择可以合理地解释早期讨论的一般原理。
我希望得到一个传统的 Forth ,尽管我使用了直接串线技术。我需要全部的“经典”虚拟寄存器。
忽略其它的寄存器集, Z80 的6个地址寄存器具有下列能力:
• BC,DE - LD A 间接 , INC, DEC 也交换 DE/HL
• HL - LD r 间接 , ALU 间接 , INC, DEC, ADD, ADC, SBC, 交换 W/TOS, JP 间接
• IX,IY - LD r 间接 , ALU 间接 , INC, DEC, ADD, ADC,SBC, 交换 W/TOS, JP 间接 ( 全都很慢 )
• SP - PUSH/POP 16 位 , ADD/ADC/SUB to HL/IX/IY
BC, DE, 和 HL 也可以作为位寄存器对来处理。
8 位寄存器 A 必须留作临时寄存器,因为许多 ALU 操作和存储器引用操作都使用它作为目的。
HL 无疑是最通用的寄存器,可以逐个试着用它作为每个虚拟寄存器。然而,由于它的通用性 -- 它是唯一可以读取字格式和支持间接跳转的寄存器 -- HL 应该作为 Forth 的通用工作寄存器 W 。
由于 IX 、 IY 都有索引寻址模式并可用 ALU 操作,所以可以考虑用它们作为堆栈指针 。但是它们有两个主要的问题:通用的堆栈指针 SP 寄存器没有用,而 IX/IY 却特别慢!
在 Forth 的两个栈上都有许多 16 位的 PUSH / POP 类操作,对于 SP 来说,这些操作只需要一条指令,而 IX 或者 IY 操作却需要 4 条指令。所以两个堆栈之一应该用 SP 实现,这应该是参数栈,因为它的使用频率比返回栈要高。
如何考虑 Forth 的 IP 寄存器呢?在大多数情况下, IP 都是从存储器读取并且自动增量的,使用 IX/IY 作为 IP 不会比使用 BC/DE 有任何编程上的好处,考虑 IP 的速度,使用 BC/DE 对却更快。让我们把 IP 放到 DE 中:它可以与 HL 的内容交换,而后者是通用的。
需要第二个 Z80 寄存器对(不是 W )进行 16 位的算术运算。现在只有 BC 了,它可以用于寻址或者与 A 进行 ALU 操作。但是,我们是用 BC 作为第二个工作寄存器“X”、还是作为栈顶元素呢?只有编码才能得到结论。现在,让我们乐观地假定 BC = TOS 。
只剩下 RSP 和 UP 了,还有 IX 和 IY 寄存器没有分配。 IX 和 IY 是等效的,我们设 IX = RSP , IY = UP 。
于是, Z80 Forth 系统的寄存器分配如下
BC = TOS IX = RSP
DE = IP IY = UP
HL = W SP = PSP
现在让我们看看 DTC 的 Forth 系统 NEXT 编码:
DTC-NEXT:
LD A,(DE) (7) (IP)->W, 增量 IP
LD L,A (4)
INC DE (6)
LD A,(DE) (7)
LD H,A (4)
INC DE (6)
JP (HL) (4) 跳转到 W 中的地址
还可以有其它的版本(具有同样的时钟周期)
DTC-NEXT:
EX DE,HL (4) (IP)->W, 增量 IP
NEXT-HL:
LD E,(HL) (7)
INC HL (6)
LD D,(HL) (7)
INC HL (6)
EX DE,HL (4)
JP (HL) (4) 转到 W 中的地址
注意单元是以低位字节优先的方式存放在存储器中的。同样,尽管看起来把 IP 保存在 HL 寄存器中有许多好处,但实际上却没有。这是由于 Z80 不能进行 JP (DE) 。 NEXT-HL 进入将更短一些。
仅仅用于比较,让我们看一下 ITC NEXT 。以前给出的伪代码需要另一个临时寄存器“X”,它的内容用于间接跳转。令 DE = X, BC = IP, TOS 保存在存储器中。
ITC-NEXT:
LD A,(BC) (7) (IP)->W, 增量 IP
LD L,A (4)
INC BC (6)
LD A,(BC) (7)
LD H,A (4)
INC BC (6)
LD E,(HL) (7) (W)->X
INC HL (6)
LD D,(HL) (7)
EX DE,HL (4) 跳转到 X 中的地址
JP (HL) (4)
这就把“W”加 1 并放到在 DE 寄存器中了。只要这是一致的,就不会有任何问题 -- 代码知道在需要 W 的内容时如何去找到它,以及如何调整它。
ITC 的 NEXT 是 11 个同期, DTC 是 7 个同期。 ITC 没有将 TOS 保存在寄存器中的能力,所以我选择 DTC 。
如果使用内嵌编码, DTC NEXT 在每个 CODE 字中需要 7 个字节。一个直接跳转到 NEXT 的子程序只需要 3 个字节,但需要附加 10 个时钟周期,这是一个特别的例子,我们选择的是内嵌方式的 NEXT 。但有时 NEXT 特别大,或者存储器很小,更谨慎的决策可能是使用 JMP 到 NEXT 。
现在让我们来看 ENTER 的代码。使用一个 CALL ,可以弹出硬件堆栈以得到参数域地址:
CALL ENTER (17)
...
ENTER:
DEC IX (10) 把老的 IP 放到返回栈上
LD (IX+0),D (19)
DEC IX (10)
LD (IX+0),E (19)
POP DE (10) 参数域地址 -> IP
NEXT (38) 7 个机器指令的汇编语言宏
实际上这比 POP HL 快,然而使用最后的 6 个指令(不用 EXDE , HL ):
CALL ENTER (17)
...
ENTER:
DEC IX (10) 把老的 IP 放到返回栈上
LD (IX+0),D (19)
DEC IX (10)
LD (IX+0),E (19)
POP HL (10) 参数域地址 -> HL
NEXT-HL (34) 看上面的 DTC 的 NEXT 代码
总计 119 个周期
当使用 JP 时, W 寄存器( HL )依然指向代码域。代码域是其后的 3 个字节:
JP ENTER (10)
...
ENTER:
DEC IX (10) 把老的 IP 放到返回栈上 LD (IX+0),D (19)
DEC IX (10)
LD (IX+0),E (19)
INC HL ( 6) 参数域地址 -> IP
INC HL ( 6)
INC HL ( 6)
NEXT-HL (34)
总计 120 个周期
由于改变了 NEXT 的入口, IP 的新值就不必放入 DE 寄存器对了。
CALL 版本快了 1 个周期。在嵌入式系统应用 Z80 时,我们还可以使用单字节的 RST 指令来得到速度和空间的双重收益,但是在基于 Z80 的个人计算机上,这个策略并不可用(操作系统使用了这个特性,即操作系统的系统调用是通过这个接口进入的)。
Intel 的 8086 是另一个有教育意义的 CPU 。我们不再详细讨论设计过程,只是看一个新的用于 PC 的共享软件: Pygmy Forth [SER90].
Pygmy 是一个直接串线的 Forth 系统,栈顶元素保存在寄存器中。 8086 寄存器是这样安排的:
AX = W DI = scratch
BX = TOS SI = IP
CX = scratch BP = RSP
DX = scratch SP = PSP
许多 8086 Forth 系统的实现使用 SI 寄存器作为 IP ,所以 NEXT 可以通过 LODSW 指令实现。在 Pygmy 的 DTC 实现中, NEXT 是这样的:
NEXT:
LODSW
JMP AX
这已经小得足以嵌入到每个 CODE 字中了 。
高级“定义” Forth 字使用一个 JMP (相对)指令转向它们的机器码。 ENTER 子程序(在 Pygmy 中称为 'docol' )因此需要从 W 中得到参数域地址。
ENTER:
XCHG SP,BP
PUSH SI
XCHG SP,BP
ADD AX,3 参数域地址 -> IP
MOV SI,AX
NEXT
注意交换两个堆栈指针的 XCHG 用法,这允许对两个堆栈都使用 PUSH 和 POP 指令,这比使用基于 BP 的直接寻址指令要快。
EXIT:
XCHG SP,BP
POP SI
XCHG SP,BP
NEXT
Pygmy Forth 是一个单段的 Forth 系统,所有的代码和数据都在一个 64K 字节的段中, 这相当于 Turbo C 的紧缩模式。到目前为止,我们讨论的 Forth 标准都假设所有的东西全部包含在单一的存储器地址空间,使用同样的读写操作符。然而, IMP PC Forth 开始使用多个段来处理 5 种不同的数据,它们是:
CODE …… 机器代码
LIST …… 高级 Forth 串线 ( 所以这个段也称为 THREADS)
HEAD …… Forth 字的首部
STACK …… 参数和返回栈
DATA …… 变量和用户定义数据
这就允许 PC 机上的 Forth 突破 64K 字节的段限制,而又不需要在一个 16 位的 CPU 上实现一个 32 位的 Forth 系统。但是,实现一个多段的模型、分支到 Forth 核心等等内容已经远远超出了本文的讨论范围。
[KEL92] Kelly, Guy M., "Forth Systems Comparisons," Forth Dimensions XIII:6 (Mar/Apr 1992). Also published in the 1991 FORML Conference Proceedings . Both available from the Forth Interest Group, P.O. Box 2154, Oakland, CA 94621. Illustrates design tradeoffs of many 8086 Forths with code fragments and benchmarks -- highly recommended!
[MOT83] Motorola Inc., 8-Bit Microprocessor and Peripheral Data , Motorola data book (1983).
[SIG92] Signetics Inc., 80C51-Based 8-Bit Microcontrollers , Signetics data book (1992).
[PAY90] Payne, William H., Embedded Controller FORTH for the 8051 Family , Academic Press (1990), ISBN 0-12-547570-5. This is a complete "kit" for a 8051 Forth, including a metacompiler for the IBM PC. Hardcopy only; files can be downloaded from GEnie. Not for the novice!
[SER90] Sergeant, Frank, Pygmy Forth for the IBM PC , version 1.3 (1990). Distributed by the author, available from the Forth Interest Group. Version 1.4 is now available on GEnie, and worth the extra effort to obtain.
[SEY89] Seywerd, H., Elehew, W. R., and Caven, P., LOVE-83Forth for the IBM PC , version 1.20 (1989). A shareware Forth using a five-segment model. Contact Seywerd Associates, 265 Scarboro Cres., Scarborough, Ontario M1M 2J7 Canada.
上一部分的 MC6809 设计决策中存在一个很大的错误,在我编码 Forth 字 EXECUTE 的时候,它变得非常明显。
EXECUTE 引起一个 Forth 字的执行,它的地址在参数栈上。更精确地说:编译地址、或者说代码域地址在参数栈上给出。这可以是任何类型的 Forth 字: CODE 定义、冒号定义、 CONSTANT 、 VARIBLE 或者是定义字。与通常的 Forth 解释过程不同的是,执行字的地址在栈上给出,而不是通过“串线”给出(通过 IP 指定)。
在我们的直接串线 MC6809 中,这可以很容易地编码 :
EXECUTE:
TFR TOS,W 把字的地址放到 W 中
PULU TOS 弹出新的 TOS
JMP ,W 跳到 W 给定的地址
注意:应该是 JMP ,W 而不是 JMP [,W], 因为我们已经有了这个字的代码地址,不是从高级线程中读取的。如果 TOS 不在寄存器中, EXECUTE 可以更简单地实现 JMP [,PSP++] 。现在假设这个被执行的字是一个冒号定义, W 将要指向它的代码域,其中包含有 JMP ENTER 。 如下所示:
JMP ENTER
...
ENTER:
PSHS IP
LDX -2,IP 重新取得代码域地址
LEAY 3,X
NEXT
这就是错误所在!因为我们不是从串线中执行这个字,所以 IP 并没有指向代码域地址的一个拷贝。记住: EXECUTE 字的地址来自于堆栈。这种方式的 ENTER 不能与 EXECUTE 一同工作,因为没有办法得到将要执行的字的地址。
这也同时提出了 DTC Forth 的一个新规则:如果 NEXT 没有把将要执行的字的地址放到一个寄存器中,你就必须在代码域中使用 CALL 。
于是, MC6809 Forth 只好倒退回在代码域中使用 JSR 的方法。但是, ENTER 是 Forth 中使用最多的代码片断,为了避免速度的损失,我完成了上一章中的“学生练习”。注意当你交换 RSP 和 PSP 时发生了什么:
执行新版本需要 31 个周期,这与我前面使用的 JMP 版本的时间一样。其中的改进是由于 JSR 版本的 ENTER 同时使用 Forth 的返回栈和 MC6809 子程序返回栈( JSR 栈)。使用两个不同的堆栈指针意味着我们不必与IP“交换” TOS ,也就不需要任何的临时寄存器了。
这也解释了一个新 Forth 内核通常的开发过程:先做出一些设计决策,然后写出一些简单的代码,再找出一个 BUG 或者一个更好的方法做这件事情,改变某些设计策略,重新编写示例代码,重复这个过程直到满意为止。
这给了我们一个教训:把 EXECUTE 做为一个基准测试字。
Carey Bloodworth of Van Buren, AR 指出了上一版本 MC6809 中的一个小的、但是让我不好意思的错误:
对于 0= 的“ TOS 在存储器”版本,我应该这样编写代码:
LDD ,PSP
CMPD #0
这是为了测试 TOS 是否为 0 。可是在这种情况下, CMPD 指令完全是多余的,因为 LDD 指令在 D 寄存器为 0 时将设置 Zero 标志。 TOS 在 D 寄存器的版本还是需要 CMPD 指令的,但是比 TOS 在存储器版本执行速度更快。
现在让我们开始讨论主题
DOES 的概念看起来是 Forth 中最难懂和最神秘的一部分,不过 DOES 也是使 Forth 具有强大能力的一个原因 -- 在许多方面,它是先天面向对象的。 DOES 的行为和能力也与 Forth 最闪亮的方面有着联系:代码域。
回忆第一部分, Forth 的定义体由两个部分组成:代码域和参数域。你可以从不同的方面来考察这两个域:
• 代码域是这个 Forth 字的动作,参数域是与动作有关的数据;
• 代码域是一个子程序调用,参数域是调用后面的“内嵌”参数(汇编程序员观点);
• 代码域是字类的单个“方法”,参数域是某个特别字的“实例变量”(面向对象程序员的观点);
所有这些观点都有着共同点:
• 代码域子程序在调用时至少有一个参数,它就是这个要执行的 Forth 字的参数域地址,参数域可以包含有任何数目的参数 ;
• 只有几个相对不多的特殊动作,或者说,代码域只引用为数不多的几个特殊子程序(我们后面将会看到,这对于 CODE 例外)。我们可以回忆一下第 2 部分的 ENTER 子程序:这个通用的子程序被所有的 Forth 冒号定义引用 ;
• 对参数域的解释隐含地由代码域的内容去解释。或者说,每个代码域子程序希望参数域包含一定类型的数据 ;
一个典型的 Forth 内核有以下几个预定义的代码域子程序 .
Forth 之所以强大的原因在于 Forth 程序并不限于只能使用这些代码域子程序(或者只能使用你的 Forth 系统内核所提供的其它子程序集)。程序员可以定义新的代码域子程序,可以定义一个新的参数域类型与之匹配。用面向对象程序设计方法的“行话”来说,可以创建新的“类”和“方法”(尽管每个类只有一个方法)。同时,就像其它的 Forth 字一样 -- 代码域可以用汇编语言定义,也可以用高级 Forth 字来定义。
为了理解代码域的机制和参数是如何传递的,我们首先看看汇编语言(机器代码)的情况。我们先考察间接串线(ITC)的情况,它是最容易理解的,然后再看看如何修改这些逻辑到直接串线(DTC)和子程序串线的(STC)上。最后,再看如何使用高级 Forth 定义来描述代码域的动作。
Forth 的编写者在使用术语时有些混乱,所以,我使用我自己的术语来解释,如图 1 所示。首部包含有字典信息,与一个 Forth 字的执行没有关系。体是这个字的“工作”部分,包含有固定长度的代码域和可变长度的参数域。对于任何一个给定的字,这两个域在存储器中的位置分别被称为代码域地址(CFA)和参数域地址(PFA)。一个字的代码域地址就是这个字在存储器中的位置。不要把这个与代码域的内容相混淆,在 ITC 中,内容是另一个不同的地址。
需要明确的是:代码域的内容是另外一片存储器的地址,在那一片存储器中是机器代码。我把这个地址称为代码地址。最后,当讨论 DTC 和 STC Forth 时,我也引用“代码域内容”,它的含义比代码域地址更多。
图 1 一个 ITC Forth 字
Forth 的 CONSTANT 可能是最简单的机器代码例子。让我们考察一个法语的例子:
1 CONSTANT UN
2 CONSTANT DEUX
3 CONSTANT TROIS
执行 UN 会把值 1 压入堆栈,执行 DEUX 把 2 压入堆栈等等。(不要把参数栈和参数域混淆,它们是完全独立的)
在 Forth 内核中有一个字称为 CONSTANT 。这并不是一个常数类的字本身,它是一个高级 Forth 定义。 CONSTANT 是一个“定义字”:它在 Forth 字典中创建一个新字,通过它我们能够创建新的“常数类”字 UN 、 DEUX 和 TROIS 。你也可以把它们理解成常数“类”的一个个“实例”。这三个字都有自己的代码域,都指向同样的 COSNTANT 动作的机器代码片断。
这个代码片断应该执行什么动作呢?图 2 给出了这三个常数的存储器表示。所有这三个字都指向共同的动作子程序。这些字的区别在于它们的参数域,这里简单地包含有常数的值,或者用面向对象的说法是“实例变量”。所以,这三个字的动作都应该是读取参数域的内容,并把它们放到栈顶。这段代码也隐含地知道参数域包含一个单元大小的值。
图 2 三个常数
为了写出做这件事情的机器代码片断,我们需要知道怎样才能找到参数域的地址,之后 Forth 的解释器就可以跳转到机器代码。那么,PFA 是如何传递给机器代码子程序的呢?并且, Forth 解释器的 NEXT 是如何编码的呢?这依赖于不同的实现。为了写出机器代码动作,我们首先需要理解 NEXT 。
ITC 的 NEXT 在第一部分已经用伪码描述了,以下是 MC6809 的实现,使用 Y=IP,X=W:
NEXT: LDX ,Y++ ; (IP) -> W, IP+2 -> IP
JMP [,X] ; (W) -> temp, JMP (temp)
假设我们的高级串线中有这样的代码:
... SWAP DEUX + ...
当 NEXT 被执行时,使用 IP 解释指针指向 DEUX “指令”(紧接在 SWAP 之后),图 3 解释了发生的事情。 IP (寄存器 Y )指向高级串线内部的一个存储器单元,它包含有 Forth 字 DEUX 的地址。更精确地说,这个单元包含有字 DEUX 的代码域地址。于是,当我们使用 Y 读取一个单元时,自动增量 Y ,我们就得到了 DEUX 的代码域地址。把它写入 W (寄存器 X ), W 现在已经指向了代码域,是一个机器代码片断的地址。我们可以读取这个单元的内容,然后使用一条MC6809 指令跳转到相应的机器代码处执行。这个过程并没有改变寄存器 X ,所以 W 仍然指向 DEUX 的 CFA ,我们就可以得到参数域地址,它在代码域之后两个字节的位置。
图 3 ITC 在 NEXT 之前和之后的情况
所以,机器代码片断只需要把 W 加 2 ,读取这个地址的单元内容,把它压到栈上。这个代码片断通常被称为 DOCON
DOCON:
LDD 2,X ; 读取 W+2 处的单元
PSHU D ; 把它放到参数栈是
NEXT ; ( 宏 ) 跳转到下一个高级字
这个例子中, TOS 在存储器中。注意前面的 NEXT 已经把 IP 增加了 2 ,所以当 DOCON 做 NEXT 时,它已经指向了串线的下一个单元(“+” 的 CFA )。
通常, ITC Forth 会在 W 寄存器中留下参数域地址或者一些“邻近”的地址。在这种情况下, W 包含有 CFA ,它在这个 Forth 实现中总是 PFA - 2 。由于除了 CODE 之外的每类 Forth 字都需要使用参数域地址,许多 NEXT 实现方法都是增量 W 使它指向 PFA 。我们可以在 MC6809 上做一些小的改变:
NEXT:
LDX ,Y++ ; (IP) -> W, IP + 2 -> IP
JMP [,X++] ; (W) -> temp, JMP (temp), W+2 -> W
这使 NEXT 增加了 3 个周期,但是把参数域地址放入了 W 寄存器。对于代码域子程序它做了些什么呢?
W=CFA W=PFA
DOCON:
LDD 2,X (6) LDD ,X (5)
PSHU D PSHU D
NEXT NEXT
DOVAR:
LEAX 2,X (5) ; 没有操作
PSHU X PSHU X
NEXT NEXT
ENTER:
PSHS Y PSHS Y
LEAY 2,X (5) LEAY ,X (4, 比 TFR X,Y 快 )
NEXT NEXT
从 NEXT 增加 3 个周期的代价中我们得到了什么收益呢? DOCON 减少了 1 个周期, DOVAR 减少了 5 个周期, ENTER 减少了 1 个周期。 CODE 字不使用 W 中的值,所以它们没有从自动增量中受益。速度的增加或者损失要通过 Forth 字的混合执行来考察。通常的规则是执行最多的字是 CODE 字,这样,在 NEXT 中增量 W 会有一点点速度上的损失 -- 当然也节省了存储器 -- 不过 DOCON , DOVAR 和 ENTER 只出现一次,得到的收益并不明显。
说来说去,最好的结论还是依赖于具体的处理器。比如像 Z80 这样的处理器只能通过字节访问存储器,它没有自动增量指令,所以通常的情况下,最好是保留 W 指向 IP+1 (从代码域读取的最后一个字节)。而在有些机器上,自动增量是“免费的”,这时让 W 指向参数域就是最方便的。
注意:在一个系统中决策必须一致。如果 NEXT 让 W 在执行时指向 PFA ,则 EXECUTE 也必须这样做(这就是为什么我在本文的开头拼命更正的原因)。
直接串线和间接串线差不多,除了代码域的内容:它不再是一些机器代码的地址,而是 JUMP 或者 CALL 。这样做可能会使得代码域更大 -- 比如在 MC6809 上要大 1 个字节,但是,它省去了 NEXT 子程序中的一级间接。
在代码域中选择 JUMP 还是 CALL 指令依赖于机器码子程序如何得到参数域地址。为了跳转到代码域,许多 CPU 要求把它的地址放在一个寄存器中。例如, Intel 8086 的间接跳转指令是 JMP AX (或者其它的寄存器),在 Z80 上是 JP ( HL 或者 IX 或者 IY)。在这些处理器上, DTC 的 NEXT 包括两个操作,在 MC6809 上将变成:
NEXT:
LDX ,Y++ ; (IP) -> W, IP + 2 -> IP
JMP ,X ; JMP (W)
在 Intel 8086 上,这两条指令可以是 LODSW 和 JMP AX ,其中的影响可以通过图 4 的 CASE1 说明。 DEUX 的代码域地址是从高级串线中读取的, IP 被增量。然后,不再进行读取操作,而是用一个 JUMP 指令跳转到代码域。也就是说, CPU 直接跳转到代码域。 CFA 被留在 W 寄存器中,就像上面 ITC 的第一个例子。由于这个地址已经在寄存器中了,我们可以简单地把 JUMP 放到 DOCON 的代码域中, DOCON 的代码片断将和上面描述一样地工作。
图 4 DTC 中 NEXT 之前和之后的情况
不过,我们也许会注意到:在有些处理器上,比如 MC6809 和 PDP-11上,可以用一个指令来实现这个 DTC NEXT
NEXT:
JMP [,Y++] ; (IP) -> temp, IP+2 -> IP, JMP (temp)
这也能使 CPU 跳转到 DEUX 的代码域。但其中有一个巨大的差异:任何寄存器中都没有留下 CFA !那么机器代码片断如何得到参数域的地址呢?答案是:通过使用 CALL (或者 JSR )指令来替代 JUMP 。在许多 CPU 上, CALL 指令会把返回地址放到返回栈上 -- 这就是紧随在 CALL 指令之后的地址 。
如图 4 所示的 CASE2 ,这个地址就是我们所需要的参数域地址!所以 DOCON 要做的就是从返回栈得到地址 -- 满足代码域放置 JSR 的要求 -- 然后使用这个地址来读取常量,于是:
DOCON:
PULS X ; 从返回栈弹出 PFA
LDD ,X ; 读取参数域的单元
PSHU D ; 压入参数栈
NEXT ; ( 宏 ) 转到下一个高级字
把这个同 ITC 版本相比较。 DOCON 多了 1 个指令,但是 NEXT 少了 1 个指令。 DOVAR 和 NEXT 也多了 1 个指令:
DOVAR:
PULS X ; 弹出这个字的 PFA
PSHU X ; 把那个地址放到参数栈上
NEXT
ENTER:
PULS X ; 弹出这个字的 PFA
PSHS Y ; 压入老的 IP
TFR X,Y ; PFA 变成了新的 IP
NEXT
现在回到本文的开头,重新读一下我的“更正”,看一看为什么我们不能通过 IP 来重新读 CFA 。同时也要注意,把 Forth 的堆栈指针给 MC6809 的 U 寄存器而 S 保留的情况与这里讨论的不同。
子程序串线(STC)和 DTC 非常相似,都是 CPU 直接跳转到一个 Forth 字的代码域。但是现在不再有 NEXT 代码,不再有 IP 寄存器,也没有 W 寄存器。所以,只能在代码域中使用 JSR 而不可能有其它的选择,这是可以得到参数域地址的唯一办法。这个过程如图 5 所示。
图 5 STC 的串线编码
高级串线是被 CPU 执行的一系列子程序调用。当一个 JSR DEUX 被执行的时候,串线中下一个指令的地址被推进返回栈。接着,在字 DEUX 中的 JSR DOCON 被执行,它使得另一个返回地址 -- DEUX 的 PFA 被推入堆栈。 DOCON 可以弹出这个地址,使用它来读取常数,把常数保存在堆栈上,然后用一个 RTS 指令返回到串线:
DOCON:
PULS X ; 从返回栈弹出 PFA
LDD ,X ; 读取参数域单元
PSHU D ; 把它压入参数栈
RTS ; 执行下一个高级字
在子程序串线代码中,我们仍然可以沿用代码域和参数域这样的术语。除了 CODE 和冒号定义之外的每一个 Forth 字的类中,代码域是被 JSR 或者 CALL 占用的空间(就像 DTC )一样,而参数域就是它后面的空间。所以,在 MC6809 上, PFA 等于 CFA+3 。于是, CODE 和冒号定义的“参数域”含意变得有点儿模糊,在本文的后面可以看到这一点。
在以上所有的一般性讨论中,有一个明显的例外,这就是 CODE 定义 -- 用汇编码子程序定义的 Forth 字。用“汇编语言来定义一个字” -- 这个神奇的功能在 Forth 中很容易实现,因为每个 Forth 字都执行一段 Forth 代码。
包含 CODE 字的汇编代码总是包含在一个 Forth 字的体中,代码域必须包含有要执行的机器代码的地址。所以机器代码放在参数域中,代码域包含了参数域的地址,如图 6 所示。
图 6 CODE 字
在直接或者子程序串线的 Forth 中,我们可以通过类推,把一个 JUMP 放到代码域中。代码域也可以用 NOP 或者相同的结果填充。更好的是,机器代码可以直接从代码域开始,然后进入参数域。从这一点看,代码域和参数域就没有区别了。这不应该有任何疑问,因为我们并不需要对一个 CODE 字做这样的区分。但可能有一些反汇编器和一些聪明的编程技巧需要这一区分,我们在这里就不讨论它们了。
CODE 字 -- 不论是怎么实现的 -- 都是不需要向它传递参数域地址的机器代码动作。参数域不包含数据,只是需要执行的代码。只有 NEXT 需要知道这个地址(或者代码域地址),这样它就可以直接跳到机器代码。
现在还有三个问题没有回答:
• 我们如何创建一个 Forth 字,使得能在它的参数域中含有一些任意的数据?
• 我们如何改变一个字的代码域,以指向可选择的机器代码?
• 我们如何在代码片段与一个使用它的字隔离的情况下编译(汇编)这个代码片段?
对于第一个问题的回答是:写一个 Forth 字来做这一工作。在执行的时候,因为这个字将在 Forth 字典中定义一个新的字,所以它被称为“定义字”。
CONSTANT 就是一个定义字。一个定义字的所有“硬工作”都是由一个内核字 CREATE 来完成的,它从输入流中分析名字,为新字建立头和代码域,并把它链接到字典中。对程序员来说,剩下的工作就是构造参数域了。
第二个、第三个问题的答案包含在两个费解的 Forth 字中,它们分别是 (;CODE) 和 ;CODE 。为了理解它们是如何工作的,我们来看看定义字 CONSTANT 实际上是如何用 Forth 高级定义来写的。使用前面 MC6809 的例子:
: CONSTANT ( n -- )
CREATE / 创建一个新的字
, / 把 TOS 的值写入字典,作为参数域的第 1 个单元
;CODE / 结束高级定义,开始汇编代码
LDD 2,X / DOCON 的汇编代码片断
PSHU D
NEXT
END-CODE
这个 Forth 字包含了两个部分:从 CONSTANT 到 ;CODE 的任何事情都是在 COSNTANT 被访问时执行的高级 Forth 代码。而从 ;CODE 到 END-CODE 的事情都是常数的“子女” -- 常数类字比如 UN 和 DEUX -- 执行时要执行的机器代码。实际上也就是从字 ;CODE 到 END-CODE 的代码片段为常量类字将指向的机器代码片断。 ;CODE 表示一个高级定义的结束(;)和一个机器代码定义的开始 (CODE) 。但是,它并不在字典中建立两个分离的字,从 CONSTANT 到 END-CODE 的全部内容都保存在 CONSTANT 的参数域中,如图 7 所示。
图 7 ITC 的 ;CODE
Derick 和 Baker [DER82] 使用三个“时间阶段”来帮助理解定义字的行为:
时间阶段 1
是在 CONSTANT 被定义时的行为。这需要同时引用高级编译器(对于第一个部分)和 Forth 汇编器(对于第二个部分)。这就是定义 CONSTANT 被加入字典的过程,如图 7 所示。我们可以看到, ;CODE 这个编译指示器是在第一个阶段被执行的。
时间阶段 2
是字 CONSTANT 被执行时的行为,这时一些常数类字被定义,比如:
2 CONSTANT DEUX
这个阶段就是字 CONSTANT 被执行、字 DEUX 被加入字典的时候。在这个阶段 CONSTANT 的高级定义部分被执行,包括字 (;CODE).
时间阶段 3
是常数类执行时的行为。在我们的例子中,这个阶段就是 DEUX 被执行而把值 2 推入堆栈的时候。这时 CONSTANT 的机器代码被执行(回忆 DEUX 的代码域动作)
;CODE 在时间阶段 1 被执行,这是 CONSTANT 被编译的时候。它是一个 Forth 立即字 -- IMMEDIATE 字 -- 这个字在 Forth 编译时执行。
;CODE 做以下三件事情:
• 它把 Forth 字 (;CODE) 编译到 CONSTANT
• 它关闭 Forth 编译器,同时
• 它打开 Forth 汇编器
而 (;CODE) 是字 CONSTANT 的一部分,它在 CONSTANT 执行的时候才被执行(时间阶段 2 ),它执行以下动作:
• 它得到紧随其后的机器代码的地址,这可以通过从 Forth 返回栈中弹出 IP 而实现;
• 它把这个地址放到 CREATE 定义的字的代码域中,通过 Forth 字 LAST (有时也称为 LATEST )等到这个字的地址;
• 它完成 EXIT 的动作(也称为 ;S ),这样 Forth 的内部解释器就不会把后面的代码作为 Forth 串线来执行,这是结束 Forth 串线的高级“子程序返回”。
F83[LAX84] 解释了它们在 Forth 系统中的典型编码:
: ;CODE
COMPILE (;CODE) / 编译 (;CODE) 到定义中
?CSP [COMPILE] [ / 关闭 Forth 编译器
REVEAL / ( 与 ";" 的行为类同 )
ASSEMBLER / 打开汇编器
; IMMEDIATE / 把这个字设为立即字
: (;CODE)
> / 弹出机器代码地址
LAST @ NAME> / 得到最后一个字的 CA
! / 保存这个代码地址到代码域
; /
(;CODE) 字在两个字当中更加微妙。因为它是一个高级 Forth 定义,在 CONSTANT 中后随它的地址 -- 高级返回地址 -- 被压入 Forth 返回栈中,所以在 (;CODE) 中弹出返回栈能够得到后随的机器代码地址。同时,从返回栈中弹出这个值使得一级高级子程序被返回“旁路”,这样在 (;CODE) 退出的时候,它可以退到 CONSTANT 的调用者。这等效于返回到 COSNTANT ,并使得 CONSTANT 立即返回。通过图 7 并跟踪字 CONSTANT 和 (;CODE) 的执行可以更加清楚地看到这是如何工作的。
对于 DTC 和 STC ,;CODE 和 (;CODE) 的动作与 ITC 相同,但是也有一个重要的例外:它不再保存一个地址,而在代码域中放有 JUMP 或者 CALL 指令。对于一个绝对 JUMP 或 CALL ,可能唯一要做的事情就是把地址保存在代码域的最后,作为 JUMP 或者 CALL 指令的操作数。在 MC6809 的情况下,地址作为 3 字节 JSR 指令的最后 2 个字节保存。但是某些 Forth 系统比如 Intel 8086 的 Pygmy Forth ,它们在代码域中使用相对转移指令。在这种情况下,必须计算相对偏移量并把它们插入到分支指令中。
你已经看到了如何让 Forth 字执行一个指定的汇编语言代码片段,如何向这个片断传递字的参数域地址,但是我们如何用高级 Forth 定义“写出”子程序的行为呢?
每个 Forth 字必须 -- 通过 NEXT 的行为 -- 执行一些机器语言子程序。这就是代码域的全部。因此,一个机器子程序、或者一系列子程序需要解决如何访问高级行为的问题。我们称这个子程序为 DODOES 。
这里有三个问题需要解决:
• 我们如何找到与这个字相关联的高级行为子程序的地址?
• 我们如何从机器代码中为调用一个高级行为子程序而访问 Forth 解释器?
• 我们如何向那个子程序传递我们正在执行的字的参数域地址?
对于第三个问题的回答是:很容易,用我们为一个高级 Forth 子程序在参数栈上传递参数的方法。我们的机器语言子程序在访问高级串线之前必须把参数域地址推到堆栈上(从我们以前的工作看,我们知道机器语言如何能够得到 PFA )
第二个问题的答案有一点困难。基本上我们可以像 Forth 字 EXECUTE 那样做一些事情来访问一个 Forth 字;或者也可能是 ENTER ,它访问一个冒号定义。它们都是我们的“关键”核心字, DODOES 与此类似。
第一个问题好象有些难度。我们把高级子程序的地址放到哪里呢?记住:代码域并不指向高级代码,它必须指向机器代码。在 Forth 的历史上,人们曾经使用过以下两种方法。
FIG-Forth 用参数域的第一个单元来保存高级代码的地址。 DODOES 子程序通过这个单元得到了参数域的地址,并把实际数据的地址(典型地 PFA+2 )推到栈上,取得高级子程序的地址,然后调用 EXECUTE 。这种方法存在两个问题:
第一、参数域的结构因机器代码行为和高级代码行为而不同。例如一个使用机器代码的 CONSTANT 可以把它的代码保存到 PFA ,但是一个使用高级定义的 CONSTANT 行为却必须把它的数据保存在(典型地) PFA+2 。
第二、每个高级行为类的实例都增加了一个单元的开销。也就是说,如果 CONSTANT 用于一个高级行为,程序中的每个常数都要增大一个单元!幸运的是,聪明的 Forth 程序员很快就找到了解决这个问题的一种方法, FIG-Forth 方法就不再使用了。
大多数 Forth 程序员都为每个高级行为子程序配置了一个不同的机器语言代码片段。于是,一个高级常数就会有它自己的代码域,它指向一个机器语言片段,其核心功能就是访问 CONSTANT 的高级行为;一个高级变量的代码域将指向一个“ STARTUP ”子程序来实现高级的 VARIBLE 行为,等等。
这种方法会导致代码的大量重复吗?不会的。因为这些机器语言片断只是对通常的启动子程序 DODOES 的一个调用(不同于 FIG-Forth 的子程序),对 DODOES 高级代码的地址作为一个“内嵌”子程序参数传递。这就意味着,高级代码的地址被放到 JSR/CALL 指令之后。 DODOES 可以从 CPU 堆栈中弹出,然后通过一次读取来得到这个地址。
实际上,我们还可以做得更简单。高级代码自身是放在 JSR/CALL 指令之后的, DODOES 弹出 CPU 堆栈,直接得到这个地址。因为我们知道这是高级 Forth 代码,我们可以忽略代码域,而只编译高级串线……这就很方便地把 ENTER 的行为集成到了 DODOES 中。
现在每一个“定义”字都指向了一小部分机器代码 – 没有浪费任何的参数域空间。这一小部分机器代码是 JSR 或者 CALL 指令,后随一个高级行为子程序。在 MC6809 的例子中,我们已经使每个常数的两个字节用一个 3 字节的 JSR 替代,它只出现一次。
使用这些策略使得在 Forth 内核中包含了许多费解的程序逻辑。所以,让我们使用我们可信赖的 ITC MC6809 例子来看看实际上这是如何实现的:
图 8 显示了使用高级定义实现的 DEUX 常数。当 Forth 解释器遇到 DEUX -- 也就是说当 Forth 的 IP 寄存器在 IP(1)时 -- 它做通常的事情:它读取包含在 DEUX 代码域中的地址,跳转到那个地址。在那个地址上是一个 JSR DODOES 指令,于是立即发生第二个跳转 -- 这次是一个子程序调用 。
图 8 ITC DODOES
DODOES 接着必须执行下列动作:
• 把 DEUX 的参数域地址推到参数栈上,以备将来高级行为子程序使用。因为 JSR 指令并不改变任何寄存器,我们希望 DEUX 的参数域地址(或者“邻近”的地址)仍然保留在 W 寄存器中;
• 通过弹出 CPU 堆栈得到高级行为子程序的地址(回忆:弹出 CPU 堆栈可以得到紧随在 JSR 指令之后的不论什么的地址)。这是一个高级串线,冒号定义的参数域部分;
• 保存旧的 Forth 指令指针 -- IP(2) -- 到 Forth 返回栈上,因为 IP 寄存器要被用于执行高级代码。本质上,DODOES 必须“嵌套” IP ,就像 ENTER 一样。记住 Forth 的返回栈也许不同于 CPU 的子程序堆栈;
• 把高级串线的地址放到 IP 中,这是图 8 中的 IP(3) ;
• 在新的位置上执行 NEXT 以继续高级解释;
假设一个间接串线的 ITC MC6809 符合下列情况:
• W 没有被 NEXT 增量(也就是 W 将要包含 NEXT 进入字的 CFA )
• MC6809 的 S 寄存器是 Forth 的 PSP,U 寄存器是 Forth 的 RSP (也就是 CPU 的堆栈不是 Forth 的返回栈)
• MC6809 的 Y 寄存器是 Forth 的 IP,X 是 Forth 的 W
回忆在这些条件下的 NEXT 定义:
NEXT:
LDX ,Y++ ; (IP) -> W, and IP + 2 -> IP
JMP [,X] ; (W) -> temp, JMP (temp)
DODOES 可以这样写:
DODOES:
LEAX 2,X ; 使 W 指向参数域
PSHU Y ; 把旧的 IP 压入返回栈
PULS Y ; 从 CPU 堆栈上弹了新的 IP
PSHS X ; 压入参数域地址 W 到参数栈上
NEXT ; 访问高级解释器
这些操作并没有严格按顺序进行。当然,只要恰当的数据在恰当的时间内进入了恰当的堆栈(或者进入了恰当的寄存器),操作的顺序并不要紧。在这里,我们实际上是利用了这样一个事实:在新的 IP 从 CPU 堆栈中弹出之前,老的 IP 可以压入 Forth 的返回栈。
在某些处理器上, CPU 的堆栈被用于 Forth 的返回栈。对于这种情况,就需要一个临时存储器访问步骤。同样是上面的例子,如果我们必须选择 S=RSP和 U=PSP 则 DODOES 就成了:
DODOES:
LEAX 2,X ; 让 W 指向参数域
PSHU X ; 把参数域地址 W 压入参数栈
PULS X ; 从 CPU 堆栈中弹出串线的地址
PSHS Y ; 把旧的 IP 压入返回栈
TFR X,Y ; 把串线的地址放入 IP
NEXT ; 访问高级解释器
因为我们本质上是在交换 IP 和返回栈/CPU 堆栈的内容,所以我们就必须用 X 作为临时寄存器。于是,我们在重新使用 X 寄存器之前就必须把 PFA -- (A)压入堆栈。
我们就是要这样一步一步地研究这些 DODOES 例子,追踪两个堆栈和全部寄存器的内容。我自己就经常研究自己编写的 DODOES 子程序,以确信任何一个寄存器都没有在错误的时刻被乱用。
DODOES 的逻辑在 DTC 中是一样的。但是我的实现却是不同的,这依赖于 DTC Forth 在一个字的代码域中是使用 JMP 还是使用 CALL 。
在代码域中使用 JMP。如果将要被执行的字的地址可以在寄存器中得到,则一个 DTC Forth 就可以在代码域中使用 JMP,这就很像代码域地址。从 DODOES 的观点看,这与 ITC 是一样的。
在我们的例子中, DODOES 知道 Forth 解释器跳转到了与 DEUX 相关的机器代码,那个代码是JSR 到 DODOES 。现在每个跳转是使用直接跳转还是使用间接跳转并没有什么关系,寄存器和堆栈的内容是相同的。所以,DODOES 的代码与 ITC 是相同的(当然,NEXT 是不同的, W 也许要有不同的偏移量指向参数域)。
在 DTC 的 MC6809 中,我们从来就没有显式地读取将要执行字的 CFA ,所以 Forth 字必须在它的代码域中包含一个 JSR ,这样我们就可以通过堆栈得到这个字的参数域地址,而不是从堆栈中得到。这种情况下的 DEUX 例子显示在图 9 中。
图 9 DTC 的 DODOES
当 IP 在 IP(1)时, Forth 解释器跳转到 DEUX 的代码域(同时增量 IP)。在代码域中,是一个到 DEUX 机器代码片断的 JSR ,在那里是第二个 JSR ,到 DODOES 。于是两个地址进入了 CPU 堆栈。
第一个 JSR 的返回地址是 DEUX 的参数域地址,第二个 JSR 的返回地址 -- 在 CPU 堆栈的最上面 -- 是将要执行的高级串线地址。 DODOES 必须确保旧的 IP 已经压入到返回栈, DEUX 的 PFA 压入了参数堆栈,高级串线的地址被装入到 IP 中。这些对于堆栈分配是非常敏感的!对于 S=PSP(CPU 堆栈)和 U=RSP , NEXT 和 DODOES 的代码变成了:
NEXT:
LDX [,Y++] ; (IP) -> temp, IP+2 -> IP, JMP (temp)
DODOES:
PSHU Y ; 把旧的 IP 压入返回栈
PULS Y ; 从 CPU 堆栈中弹出新的 IP 。注意: CPU 堆栈是参数栈,最顶的元素现在正是我们需要的字的 PFA
NEXT ; 访问高级解释器
我们可以自己看一下 NEXT、DEUX、DODOES 压入一项目 -- DEUX 的 PFA-- 到参栈的全过程。
图 10 显示了一个 MC6809 STC 的 DEUX 高级行为的例子。在进入 DODOES 的时候,三个数据被压入了CPU/RETURN 的返回栈:“主串线”的返回地址、 DEUX 的 PFA、DEUX 的高级行为代码的地址。DODOES 必须弹出最后两个,把 PFA 压入参数栈,跳转到行为代码:
图 10 STC 的 DODOES
MC6809 的 DODOES 现在是一个 3 指令的子程序。它甚至可以通过“把 JSR DODOES 变成内嵌方法”来进一步简化。也就是说用等效的机器代码来代替 JSR DODOES 。由于简化了一个 JSR ,也就简化了堆栈的处理:
PULS X ; 从 CPU 堆栈中弹出 PFA
PSHU X ; 把它压入参数栈
…… ; DEUX 的其它高级串线
这里使用了 4 字节的显式代码代替了 3 字节的 JSR 指令,从而相当有效地提高了执行的速度。对于 MC6809 这也许是一个很好的选择,对于像 8051 这样的处理器, DODEOS 则显得太长了,大概应该还是作为一个子程序为好。
我们已经学习了使用 ;CODE 去创建一个 Forth 字,它的参数域中可以包含有任意的数据,以及如何使一个字的代码域指向新的机器代码片断。那么我们如何编译一个高级行为子程序并用一个新的字指向它呢?
答案依赖于两个 Forth 字 DOES> 和 (DOES>) ,它们是 ;CODE 和 (;CODE) 的高级定义等效。为了理解它们,让我们看一个使用它们的例子:
: CONSTANT ( n -- )
CREATE / 创建新的字
, / 把 TOS 值加入字典作为参数域的第 1 个单元
DOES> / 结束 " 创建部分 " 开始 " 行为 " 部分
@ / 给出 PFA ,得到它的内容
;
把这些与前面的 ;CODE 例子比较,可以看到 DOES> 执行的功能与 ;CODE 类似。从 : CONSTANT 到 DOES> 的每个行为都是在 CONSTANT 字执行时被访问的。这是构建一个“定义”字的参数域和代码。从 DOES> 到 ; 的代码是 COSNTANT 的“孩子”(比如 DEUX )被访问时执行的高级代码,也就是代码域将要指向的高级代码片断。(我们会看到 JSR DODOES 包含在这个高级代码片断之前)。
与 ;CODE 一样,“CREATE”和“ACTION”子句都在 Forth 字 CONSTANT 体中,如图 11 所示。
图 11 ITC 的 DODOES
回忆时间序列 1 、 2 、 3 ,字 DOES> 和 (DOES>) 做下例事情:
• 它把 Forth 字 (DOES>) 编译到 CONSTANT 中;
• 它把一个 JSR DODOES 编译到 CONSTANT 中;
注意 DOES> 保持 Forth 编译器一直运行,这样可以保证后面的高级代码片断继续得到编译。同样,尽管 JSR DODOES 本身不是 Forth 代码,但是像 DOES> 这样的立即字可以使它编译到 Forth 代码中。
(DOES>) 是字 CONSTANT 的一部分,所以在 CONSTANT 被执行的时候(时间序列 2 )执行,它做下例事情:
• 它通过从 Forth 的返回栈中弹出 IP 得到紧随其后的机器码的地址( JSR DODOES );
• 它把这个地址放到被 CREATE 刚刚定义的字的代码域中。
• 它执行 EXIT 行为,使得 CONSTANT 在这里中断而不再执行后面的代码片断。
(DOES>) 的行为和 (;CODE) 是一样的!所以 Forth 系统并不需要另外定义一个新的字。例如 F83 系统在 ;CODE 和 DOES> 中同时使用 (;CODE) 。我也从现在开始使用 (;CODE) 代替 (DOES>).
你已经看到了 (;CODE) 是如何工作的。 F83 是这样定义 DOES> 的
: DOES>
COMPILE (;CODE) / 编译 (;CODE) 到定义中
0E8 C, / CALL 指令的操作码字节
DODOES HERE 2+ - , / 把相对转移写入 DODOES
; IMMEDIATE
这里 DODOES 是一个常数,它保存有 DODOES 子程序的地址(实际使用的 F83 源代码和这里所说的有一点点儿不同,因为 F83 使用的 META 编译器有不同的要求)。
DOES> 不需要改变 CSP 或者 SMUDGE 位,因为 Forth 编译器的状态是 'on.' 。在 Intel 8086 的情况下, CALL 指令使用相对地址,因此,需要对 DODOES 和 HERE 做一个算术运算。在 MC6809 中, DOES> 看起来像这样的:
: DOES>
COMPILE (;CODE) / 把 (;CODE) 编译进定义
0BD C, / JSR 扩展操作码
DODOES , / 操作数: DODOES 的地址
; IMMEDIATE
你可以看到一个机器语言 JSR DODOES 是如何被编译到高级 (;CODE) 之后和高级行为之前的。
DTC 和 STC 中的唯一区别是代码域必须修改以指向新的子程序。这是由 (;CODE) 完成的,所要求的改变已经描述过了。 DOES> 没有任何影响,除非你在 STC 中把 JSR DODOES 扩展成为显式的机器代码。在这种情况下, DOES> 被修改成汇编“内嵌”的机器代码而不是 JSR DODOES 子程序。
我们可能从来就没有想到,这么几行代码会引出这么多的内容。这也是为什么我特别赞赏 ;CODE 和 DOES> ,说实在的,我从来也没有见到过用这么经济的方法就实现了这么复杂、强大和灵活的结构。
[DER82] Derick, Mitch and Baker, Linda, Forth Encyclopedia, Mountain View Press (1982). A word-by-word description of fig- Forth in minute detail. Still available from the Forth Interest Group, P.O. Box 2154, Oakland CA 94621.
[LAX84] Laxen, H. and Perry, M., F83 for the IBM PC, version 2.1.0 (1984). Distributed by the authors, available from the Forth Interest Group or GEnie.
撰写本文的过程贯穿着一个指导思想:“保持最短”。本着这个原则,我把源程序列表安排到另外的地方,对此我表示歉意。现在,我们主要试图讨论以下话题:
你现在已经知道了,主要的 Forth 程序代码是高级串线,它们通常被编译成一系列地址。在 FIG-Forth 时代和早期的 Forth 实践中,汇编语言是唯一可用的程序设计语言工具。汇编语言对于编写 Forth 的 CODE 字是非常好的,但是高级串线必须用一系列的 DW 伪指令来编写。例如, Forth 字:
: MAX ( n n - n) OVER OVER < IF SWAP THEN DROP ;
必须写成
DW OVER,OVER,LESS,ZBRAN
DW MAX2-$
DW SWAP
MAX2: DW DROP,SEMIS
后来,由于可以实际工作的 Forth 系统越来越普及, Forth 编写者开始把 Forth 编译器修改成为交叉编译器,通过运行在 CP/M (或者苹果 II ,或者其它任何什么微机系统)的 Forth 系统,你就可以为其它 CPU 编写 Forth 程序、更改 Forth 系统或者为那个 CPU 编写一个全新的 Forth 系统。
由于是从 Forth 内部创建一个全新的 Forth 系统,这种编译器被称为“META 编译器”。计算机科学的学究们反对这样的称谓,所以有些 Forth 编写者仍然使用“交叉编译”和“重新编译”的术语,这两个术语之间的差异是:“重新编译”只能为相同的 CPU 产生新的 Forth 系统。
现在大多数 PC 机上的 Forth 都是通过 META 编译产生的,但是,在嵌入式系统领域却产生了意见分歧。
使用汇编器编写 Forth 系统的观点认为:
• META 编译器神秘难懂,你必须完全理解一个 META 编译器然后才能使用它;
• 一般的程序员都懂得汇编器;
• 汇编器对于一个新的 CPU 总是可用的;
• 汇编器处理许多优化(比如长短调用格式);
• 汇编器处理前向引用和特殊的寻址模式,而许多 META 编译器通常不能做到;
• 汇编程序员可以使用熟悉的编辑器和调试工具;
• 代码的产生是完全可见的,没有向程序员“隐藏”任何东西;
• 改变 Forth 模型非常容易,而许多设计的考虑却影响 META 编译器的内部;
使用 META 编译器的观点认为:
• 你是在编写“正常”的 Forth 代码,它当然易于阅读和调试;
• 一但你理解了你的 META 编译器,你就可以很容易地把它移植到新的 CPU 上 ;
• 你需要的唯一工具就是你的计算机上的 Forth 系统;这一点对于没有 PC 的人特别实用,因为现在的许多交叉汇编器要求 PC 机或者工作站。
我用各种方式编写过几个 Forth 系统,所以要作出选择是很痛苦的。我倾向于使用 META 编译器:我发现 Forth 的 MAX 代码比等效的汇编代码易读、易懂。反对使用 META 编译器的观点许多已经被现代的“专业”编译器所克服,如果你使用 Forth 工作,我强烈建议你考虑一个商业化产品。唉,公共的 META 编译器(包括我自己的)依然落后于时代,笨拙同时神秘。
所以我准备为 Forth 程序员提供基本的材料,告诉你作出自己的选择。我将给出 META 形式的MC6809 代码,为 F83 ( IBM PC CP/M ST )提供 META 编译器。 Z80 代码将使用 CP/M 汇编器写成。 8051 代码使用公共的 PC 交叉汇编器编写。
如果不讨论“用C语言编写 Forth 系统”这个新的方法,本文就将是不完全的。 C 语言比汇编器具有更好的可移植性 -- 理论上,你所要作的全部工作就是为任何 CPU 重新编译相同的代码。
这种方法的缺点是:
• 在设计决策选择上更缺少灵活性;比如,不可能实现直接串线编码,也不可能优化寄存器的分配;
• 增加原语之后,你必须重新编译 C 源代码;
• 某些 C 语言实现的 Forth 使用了效率很低的串线技术,比如多个 CASE 语句;
• 大多数 C 编译器产生的代码比汇编语言程序员的代码低效;
但是对于那些 UNIX 系统和不支持汇编语言编程的 RISC 工作站来说,这却是 Forth 得以运行的唯一方法。最完全和广泛使用的公共域 C 语言 Forth 系统是 TILE 。如果你没有运行 UNIX 系统,你可以看一下文件 HENCE4TH_1.2.A 。
为了继续以前的比较,还是先来看看 HENCE4TH 的 MAX 定义。为了清楚起见,我略去了字典头:
_max()
{
OVER OVER LESS IF SWAP ENDIF DROP
}
不使用汇编器,用 C 语言编写核心 CODE 定义,比如,这是 HENCE4TH 的 SWAP 定义:
_swap()
{
register cell i = *(dsp);
*(dsp) = *(dsp + 1);
*(dsp + 1) = i;
}
请注意:用 C 编写 Forth 字有非常不同的技术,所以这些字在 CForth 和 TILE 中可能差异非常大。
在 MC68000 或者 SPARC 工作站上,这样的编码可以产生非常好的代码。不过,一但你计划用 C 来实现 Forth ,你也需要理解用汇编语言实现 Forth 是怎样工作的。所以请继续阅读本文。
[CAS80] Cassady, John J., METAForth: A Metacompiler for Fig- Forth , Forth Interest Group (1980).
[MIS90] HenceForth in C , Version 1.2, distributed by The Missing Link, 975 East Ave. Suite 112, Chico, CA 95926, USA (1990). This is a shareware product available from the GEnie Forth Roundtable.
[ROD91] Rodriguez, B.J., letter to the editor, Forth Dimensions XIII:3 (Sep/Oct 1991), p.5.
[ROD92] Rodriguez, B.J., "Principles of Metacompilation," Forth Dimensions XIV:3 (Sep/Oct 1992), XIV:4 (Nov/Dec 1992), and XIV:5 (Jan/Feb 1993). Note that the published code is for a fig-Forth variant and not F83. The F83 version is on GEnie as CHROMIUM.ZIP
[SER91] Sergeant, Frank, "Metacompilation Made Easy," Forth Dimensions XII:6 (Mar/Apr 1991).
[TAL80] Talbot, R.J., fig-Forth for 6809 , Forth Interest Group, P.O. Box 2154, Oakland, CA 94621 (1980).
[TIN91] Ting, C.H., "How Metacompilation Stops the Growth Rate of Forth Programmers," Forth Dimensions XIII:1 (May/Jun 1991), p.17.
最后,我准备展示一个(我希望是) ANSI 兼容的编译器 : CAMEL Forth ,包括它的全部源码。作为一个很好的练习 -- 也为了版权的原因 -- 我重新编写了全部的代码(你知道不看优秀的代码实例有多困难吗?!)。当然,我在不同的 Forth 系统上的经验无疑影响着所选择的设计策略。
由于空间所限,源代码分成四个部分安装(如果你已经等不及了,可以去 GENIE 下载全部的文件)
• Z80 Forth “原语”: 用汇编语言编写
• 8051 Forth “原语”: 也用汇编语言编写
• Z80/8051 高级内核 同样
• 完全的 6809 内核: 使用 META 编译器的源文件
我计划尽量使用公共软件来实现 CAMEL Forth :对于 Z80 ,使用 CP/M 下的 Z80R 汇编工具;对于 8051 ,使用 IBM PC 上的 A51 交叉汇编器,对于MC6809 ,使用我自己的 F83 For CP/M IBM PC ATARI ST 工具。
我这里的“核心”是指组成一个基本 Forth 系统的一系列字,包括编译和解释字。对于 CAMEL Forth 来说,这些就是 ANS Forth 规定的核心字再加上为了实现这些核心字所需要的非 ANSI 字。 Forth 核心通常由两部分组成:一部分是用机器代码写成的(即 CODE 字),另一部分是高级定义字,用机器代码写成的字称为“原语”,因为在最后的分析中,全部的 Forth 系统就是由这些字组成的。
严格地说,哪些字应该用机器代码来写呢?选择这些原语是一个有趣的任务。一个小原语集合可以简化移植,但性能肯定很糟。我听说过只用 13 个原语就能够定义 Forth 的情况 - 当然这是一个很慢的 Forth 系统。 eForth 是一个以可移植性作为设计目标的 Forth 系统,它有 31 个原语。
而我的原则是这样的:
• 基本的算术、逻辑运算以及存储器操作用 CODE 实现 ;
• 如果一个 Forth 字不能简单有效地用一系列的 Forth 字编写,则它应该用 CODE 实现(如 U<, RSHIFT 等) ;
• 如果一个简单的字频繁使用,则用 CODE 实现是值得的(如 NIP , TUCK );
• 如果一个字用 CODE 编写时并不需要多少字节,则用 CODE 实现;
• 如果一个处理器包含有实现一个字所需要的功能,则用 CODE 编写。比如,在 Z80 或者 8086 上,有 CMOVE 或者 SCAN 指令;
• 如果一个字主要是捣弄堆栈上的参数,但是逻辑非常简单,应该用 CODE 实现,这里参数可以放到寄存器中;
• 如果一个字的控制和逻辑功能复杂,则它最好用高级定义实现;
对于 Z80 的 CAMELForth ,我使用了大约 70 个原语(见表 1 )。
序号 | 名称 |
进入时 -- 时堆栈 |
描述 |
|
核心字:这些是 ANS Forth 文档要求的核心定义 |
||||
1 |
! |
x a-addr -- |
把一个单元数存入存储器 | |
2 |
+ |
1/u1 n2/u2 -- n3/u3 |
加法 n1+n2 | |
3 |
+! |
n/u a-addr -- |
加一个单元到存储器 | |
4 |
- |
n1/u1 n2/u2 -- n3/u3 |
减法 n1-n2 | |
5 |
< |
n1 n2 – flag |
测试 n1 | |
6 |
= |
x1 x2 – flag |
测试 x1=x2 | |
7 |
> |
n1 n2 – flag |
测试 n1>n2, 有符号数 | |
8 |
>R |
x -- R: -- x |
压入返回栈 | |
9 |
?DUP |
x -- 0 | x x |
如果栈顶元素非0则复制 | |
10 |
@ |
a-addr – x |
从存储器中读取一个单元 | |
11 |
0< |
n – flag |
如果 TOS 为负则为真 | |
12 |
0= |
n/u – flag |
如果 TOS=0 则为真 | |
13 |
1+ |
n1/u1 -- n2/u2 |
加 1 到 TOS | |
14 |
1- |
n1/u1 -- n2/u2 |
从 TOS 中减 1 | |
15 |
2* |
x1 -- x2 |
算术左移 | |
16 |
2/ |
x1 -- x2 |
算术右移 | |
17 |
AND |
x1 x2 -- x3 |
逻辑 AND | |
18 |
CONSTANT |
n -- |
定义一个 Forth 常数 | |
19 |
C! |
c c-addr -- |
把字符存入存储器 | |
20 |
C@ |
c-addr – c |
从存储器读取字符 | |
21 |
DROP |
x -- |
去除栈顶元素 | |
22 |
DUP |
x – x x |
复制栈顶元素 | |
23 |
EMIT |
c -- |
向控制台输出字符 | |
24 |
EXECUTE |
Forth word 'xt' |
执行栈顶的 Forth 字 | |
25 |
EXIT |
-- |
退出一个冒号字义 | |
26 |
FILL |
c-addr u c -- |
用字符填充存储器 | |
27 |
I |
-- n R: y1 y2 -- y1 y2 |
得到内层的循环计数 | |
28 |
INVERT |
x1-- x2 |
位反转 | |
29 |
J |
-- n R: 4*y -- 4*y |
得到第二个循环计数 | |
30 |
KEY |
-- c |
从键盘输入一个字符 | |
31 |
LSHIFT |
x1 u -- x2 |
逻辑左移 u 位 | |
32 |
NEGATE |
x1 -- x2 |
2 的补码 | |
33 |
OR |
x1 x2 -- x3 |
逻辑 OR | |
34 |
OVER |
x1 x2 -- x1 x2 x1 |
复制次栈项 | |
35 |
ROT |
x1 x2 x3 --x2 x3 x1 |
栈顶三元素旋转 | |
36 |
RSHIFT |
x1 u -- x2 |
逻辑右移 u 位 | |
37 |
R> |
-- x R: x -- |
从返回栈顶弹出 | |
38 |
R@ |
-- x R: x – x |
读取返回栈 | |
39 |
SWAP |
x1 x2 -- x2 x1 |
交换栈顶的两个项目 | |
40 |
UM* |
u1 u2 – ud |
无符号 16x16->32 乘法 | |
41 |
UM/MOD |
ud u1 -- u2 u3 |
无符号 32/16->16 除法 | |
42 |
UNLOOP |
-- R: sys1 sys2 -- |
退出循环参数 | |
43 |
U< |
u1 u2 – flag |
测试 u1 | |
44 |
VARIABLE |
-- |
定义一个 Forth 变量 | |
45 |
XOR |
x1 x2 -- x3 |
逻辑异或 | |
扩展字:这些可选择的字也是 ANS Forth 文档定义的 |
||||
46 |
<> |
x1 x2 – flag |
测试不相等 | |
47 |
BYE |
i*x -- |
返回到 CP/M 操作系统 | |
48 |
CMOVE |
c-addr1 c-addr2 u -- |
从底移动字节 | |
49 |
CMOVE> |
c-addr1 c-addr2 u -- |
从顶移动字节 | |
50 |
KEY? |
-- flag |
如果在键盘上按了键则返回真 | |
51 |
M+ |
d1 n -- d2 |
加无符号数到双精度数 | |
52 |
NIP |
x1 x2 -- x2 |
去除次栈顶 | |
53 |
TUCK |
x1 x2 -- x2 x1 x2 |
见堆栈图示 | |
54 |
U> |
u1 u2 – flag |
测试 u1>u2, 无符号 | |
个人扩展:这些字只属于 CamelForth 实现 |
||||
55 |
(do) |
n1|u1 n2|u2 -- R:-- y1 y2 | DO 的运行时间代码 | |
56 |
(loop) |
R: y1 y2 -- | y1 y2 |
LOOP 的运行时间代码 | |
57 |
(+loop) |
n -- R: y1 y2 -- | y1 y2 | +LOOP 的运行时间代码 | |
58 |
>< |
x1 -- x2 |
交换字节 | |
59 |
?branch |
x -- |
如果 TOS 为 0 则跳转 | |
60 |
BDOS |
DE C – A |
调用 CP/M BDOS 功能 | |
61 |
branch |
-- |
无条件分支 | |
62 |
Lit |
-- x |
内嵌文字常数放到堆栈上 | |
63 |
PC! |
c p-addr -- |
把字符输出到口上 | |
64 |
PC@ |
p-addr – c |
从口输入字符 | |
65 |
RP! |
a-addr -- |
设置返回栈指针 | |
66 |
RP@ |
-- a-addr |
得到返回栈指针 | |
67 |
SCAN |
ca1 u1 c -- ca2 u2 |
寻找匹配的字符 | |
68 |
SKIP |
ca1 u1 c -- ca2 u2 |
跳过匹配的字符 | |
69 |
SP! |
A-addr -- |
设置数据栈指针 | |
70 |
SP@ |
-- a-addr |
得到数据栈指针 | |
71 |
S= |
ca1 ca2 u – n |
串比较 n<0: s1 |
|
72 |
USER |
n -- |
定义用户变量 'n' |
堆栈解释
R: = 返回堆栈
c = 8位字符
flag = 布尔(0 或者 -1)
n = 有符号16位
u = 无符号16位
d = 无符号32位
ud = 无符号32位
+n = 无符号15位
x = 任何的单元值
i*x j*x = 任何的单元值
a-addr = 对齐的地址
ca = 字符地址
p-addr = I/O 口地址
y = 系统指定
在确定了 Forth 模型和它所使用的目标 CPU 之后,我依照下列过程进行开发:
• 选择一个 ANSI 核心字子集作为原语;
• 按照 ANSI 的描述,编写这些字的汇编定义,加入处理器的初始化代码;
• 运行汇编器,定位源程序的错误;
• 测试产生的汇编代码。我通常的做法是加上几行汇编代码,使得程序能够在初始化完成之后输出一个字符,这是一个非常关键的测试,它保证了你的硬件、汇编器、下载器( EPROM 编程器或者其它什么东西)、串行通讯口统统工作正常! (只对嵌入式系统)加入另外的汇编代码段以读取串口并回送,这样就可以测试双向通讯了;
• 写一个高级 Forth 代码片断以输出一个字符,这个代码段只使用 Forth 原语(通常是这样的: LIT 33h EMIT BYE ),这就可以测试 Forth 寄存器的初始化、堆栈和串线机制。这个阶段的问题可以通过追踪 NEXT 、初始化、数据堆栈的逻辑错误来定位,比如把一个堆栈设置到了 ROM 中;
• 写一个冒号定义输出一个字符,把这个定义包含在上面的高级定义片断中,比如定义 : BLIP LIT 34 EMIT EXIT ; 然后测试代码片段 LIT 33h EMIT BLIP BYE 。这个阶段的问题通常与 DOCOLON 、 EXIT 、返回栈有关。
• 现在可以编写一些工具来辅助开发,比如显示堆栈上的 16 进制数等等。列表 1 是一个简单的测试子程序,它运行一个永不停止的存储器 DUMP 动作(这个代码片断在用户的输入键盘不能工作时也可以使用)。这个程序测试原语 DUP 、 EMIT 、 EXIT 、 C@ 、 >< 、 LIT 、 1+ 和 BRANCH ,也测试了几级嵌套。但它不使用 DO …… LOOP ,因为这个结构要正确工作通常比较难。当这些代码可以运行后,你就会对自己的 Forth 模块是否有效树立一些信心。
接着测试其它的原语,其中 DO …… LOOP, UM/MOD, UM* 和 DODOES 必须严格,最后再加入高级定义。
如果你希望学习更多的 Forth 内核工作原理和它的编写方法,学习列表 2 。这个列表遵循了下面的一些 Forth 文档表示格式:
WORD-NAME stack in -- stack out description
其中 WORD-NAME 是 Forth 可以识别的字,由于这些字经常包含一些特殊的ASCII 字符,所以必须用一个近似的名字做为这个字的汇编语言标号,比如 OENPLUS 是字 1+ 的汇编语言标号。
stack in 是这个字希望输入的栈上参数,最右边总是栈顶元素, stack out 是这个字留在栈上的参数。
如果一个字影响返回栈,则会给出一个返回栈说明,用 R: 表示,比如:
stack in -- stack out R: stack in -- stack out
ANSI Forth 对数值堆栈参数给出了一个简化的表示,通常 n 是长度为一个单元 CELL 的有符号数, u 是长度为一个单元的无符号数, c 是一个字符,等等,见表 1 。
[1] Definition of a camel: a horse designed by committee.
[2] Ting, C. H., eForth Implementation Guide , July 1990, available from Offete Enterprises, 1306 South B Stret, San Mateo, CA 94402 USA.
[3] Z80MR, a Z80 Macro Assembler by Mike Rubenstein, is public-domain, available on the GEnie CP/M Roundtable as file Z80MR-A.LBR. Warning: do not use the supplied Z1.COM program, use only Z80MR and LOAD. Z1 has a problem with conditional jumps.
[4] A51, PseudoCorp's freeware Level 1 cross-assembler for the 8051, is available from the Realtime and Control Forth Board, (303) 278-0364, or on the GEnie Forth Roundtable as file A51.ZIP. PseudoCorp's commercial products are advertised here in TCJ.
Z80 CamelForth 的源代码在下列站点上可用 ftp://ftp.zetetics.com/pub/forth/camel/cam80-12.zip .
在 TCJ#67 上发表的 CAMEL80.AZM 文件有两个错误。一个主要的错误是 Forth 字 > 的宏定义名字头长度误为 2 ,实际上应该是 1 。另一个次要的错误是 CP/M 的控制台 I/O 。 KEY 必须返回所打的字符,所以使用了 BDOS 功能 6 。 KEY?不能返回字符,使用 BDOS 功能 11 以测试当前是否有键按下。不幸的是, BDOS 功能6不清除功能 11 检测是按下的键。我现在重新编写了 KEY? 以使用 BDOS 功能 6 。因为这是一个“破坏性”的测试,我就必须保持已经“消耗”的键,并在下一次的 KEY 调用中返回。这个新的逻辑可以用于任何硬件只提供“破坏性”测试的场合。
在上一次讨论中,我没有展开源代码。每一个“原语”执行一个小的、明确定义的功能。它几乎全部是 Z80 汇编代码,就算是我没有说清楚为什么原语中包含了一个特别的字,我也希望读者明白每一个字是做什么的。在这一部分里,我可就不能这样“奢华”了:我将要给出 Forth 语言的逻辑。许多书中描述了 Forth 内核,如果你希望完全掌握它,就请去买上一本。对于 TCJ 我将限制自己只给出编译器和解释器的关键字和给出清单 2.
文本解释器或者称为“外层解释器”是一些从键盘上接收输入并执行所要求 Forth 操作的 Forth 代码(这与地址或者“内层解释”器 NEXT 不同,后者执行编译之后的串线代码)。理解这些代码的最好方式是看 Forth 系统的启动。
CP/M 入口点(参看上一部分)检测可用内存的顶部,设置堆栈指针( PSP 、 RSP )和用户指针( UP ),建立如图 1 所示的存储器映象,然后设置“内层”解释器指针( IP )以执行 Forth 字 COLD 。
图 1 Z80 CP/M CAMELForth 储器映象
COLD 通过启动表初始化用户变量,然后执行字 ABORT 。(COLD 也试图从 CP/M 命令行执行 Forth 命令)。
ABORT 复位参数栈指针并执行 QUIT 。
QUIT 复位返回栈指针、 LOOP 栈指针、解释状态、然后开始执行命令(之所以要把名字这样进行区别是因为 QUIT 可以用于退出应用程序,并返回到 Forth 的顶层。不像 ABORT, QUIT 保留了参数栈的内容)。
QUIT 是一个无限循环,它从键盘 ACCEPT 一行输入,然后作为 Forth 命令调用 INTERPRET 。当没有编译错误时, QUIT 在每一行之后打印一个 Ok.
INTERPRET 几乎是 ANS Forth 文档 3.4 部分所给算法的逐字翻译。它分析一个由空格分开的输入串,试着用 FIND 把字串对应一个已经定义的 Forth 字。如果找到了一个字,则字或者被执行(如果这是一个 IMMEDIATE 立即字,或者是处于 STATE = 0 的“解释状态”)或者编译到字典(如果在编译状态, STATE<>0 )。如果没有找到, INTERPRET 就试着把字串编译成数字。如果成功, LITERAL 或者把它放到参数栈(如果在“解释状态”)或者编译成一个在线文字量(如果在编译状态)。如果这不是一个 Forth 字也不是一个合法的数,就显示一个错误信息,解释器执行 ABORT ,这个过程将一个字串一个字串地重复,直到输入行的结尾。
那么,解释器如何通过名字“找到”一个 Forth 字呢?答案是: Forth 维护一个含有所有 Forth 名字的字典。每个名字都通过某种方式与它的可执行代码相关联。
有许多办法可以保存用于查找的名字串:一个简单的数组,一个链表,多重链表, HASH 表等。几乎所有方法的都可以使用 -- Forth 的全部要求只是:如果你所查找的项目重名,那么最后定义的名字需要最先被找到。
我们也可以拥有几个名字的集合(在新的 ANSI Forth 中,把这种集合称为“词汇表”)。这就允许你在不丢失一个名字原来意义的情况下再用这个名字。例如,你可以有一个整数的 + ,一个浮点的 + ,甚至是一个字符串的 + 。这在面向对象的系统中被称为“运算符重载”。
每个字符串可以与它的可执行代码通过邻近的物理存储器相联系 -- 比如,名字在可执行代码之前,这通常被称为 Forth 字的首部。字符串也可以集中存放在不同的存储器区域中,与可执行代码通过指针连接(这种情况称为“分离的首部”)。
你甚至可以有无名的 Forth 代码片断,只要你永远不需要找到它们或者解释它们。 ANSI 只要求 ANS Forth 字是可以找到的。
关于字典的设计策略可以写成另一篇论文。 CAMEL Forth 使用的是最简单的策略:一个简单链表,定位在可执行代码之前。没有字汇表,也许我可以在以后的 TCJ 论文中加入这一能力。
这里还有一个问题需要讨论:在首部中需要什么数据?如何存储它们?
最少的数据是名字、优先位、(显式的或者隐式的)到可执行代码的指针。为了简单起见, CAMEL Forth 把名字作为一个“计数字符串”存储(一个字节的长度,后面是 N 个字符)。早期的 Forth Inc. 产品只存储名字串的长度和前 3 外字符。 FIG-Forth 使用不同的紧缩方法,用最后一个字符的 MSB 位置 1 来标识名字的最后一个字符,而不需要长度字节,其它的 Forth 系统也使用了紧缩字符串,我想甚至 C 风格的 NULL 字符串也是可以使用的。
“优先位”是一个标志,用于指示这个字是一个立即字( IMMEDIATE ),这种字在编译时也被执行,以实现 Forth 的编译指示和控制结构。也有其它方法实现编译指示,例如,可以把它们放在一个单独的字典中,等等。许多 Forth 系统直接把这个位保存在长度字节中。我使用了一个分离的字节,这样可以把一个“通常”的串操作符用于字串的名字操作(比如在 FIND 中的 S= 和 WORDS 中的 TYPE )。
如果把名字保存在一个链表中,就需要有一个链。通常最后的字是在链表的前面,而链指向前一个字。这符合 ANSI (和大多数系统)对重定义字的要求。 Charles Curley 研究了 LINK 域的放置位置,发现如果把这个域放置在名字之前(而不是像 FIG-Forth 那样在名字之后),由可以加快编译的速度。
图 2 是 CAMELForth 字首部结构,并与 FIG-Forth F83 Pygmy Forth 系统的首部做了比较。 F83 和 Pyhmy 的 "VIEW" 字段可以作为一个例子,它说明了如何把其它有用的信息保存在 Forth 首部中。
注意:把一个“头”(首部)和“体”(可执行代码部分)区别开来是非常重要的。它们并不需要存储在一起。首部只是在编译和解释时才需要,一个“纯的可执行”Forth 系统并不需要全部的首部。但是,对于一个合法的 ANSI Forth 系统来说,首部必须存在 -- 至少是 ANSI Forth 字集中的那些字必须有首部。
当从汇编代码“编译”一个 Forth 系统时,你可以定义宏来构建这个首部(参看 CAMELZ80.AZM 的 HEAD IMMED )。在 Forth 环境中,首部和代码域都是由 CREATE 构建的。
我们已经具备了理解 Forth 编译器的足够知识。字 :开始一个新的高级字定义,首先为字创建一个头(CREATE),改变它的代码域到“DOCOLON”(!COLON),然后转为编译状态。
回想一下,在编译状态下,文本解释器遇到的每一个字都编译进字典而不是立即执行。这个过程一直继续,直到文本解释器遇到了字“;”。这个字是一个立即字,它被立即执行,编译一个 EXIT 到定义的结尾,然后切换到解释状态([) 。
同时,“:”隐藏这个新字,而“;”把这个新字显示出来(通过清除首部或者名字中的 smudge 位),这就允许 Forth 字可以按“自我优先级”的方式重新定义。为了强制使这个被定义的字递归调用,需要使用字 RECURSE 。
我们可以看到, Forth“编译器”与 C 或者 PASCAL 编译器并没有区别。 Forth 编译器包含有不同Forth 字的动作,这就使得改变或者扩充编译器变得很容易,但如果没有一个“内建”的编译器,则创建一个 Forth 应用就会特别困难。
还有许多其它的 Forth 字,它们是:
实现编译器或者解释器的需要,或者
提供编程的方便性
但是有一个字集应该引起特别的重视,那就是我放入文件 CAMEL80D.AZM 的那些字。
ANSI Forth 标准的一个目标就是向应用程序员隐藏 CPU 和相关的实现模型(直接或者间接串线、 16 位还是 32 位)。为了实现这个目的,需要向标准增加了几个字。我把这个要求更向前推动了一步,努力把这些模型相关问题包装到内核中。在理想的情况下,在文件 CAMEL80H.AZM 中的高级 FORH 代码对于所有的 CAMEL Forth 目标应该是相同(尽管不同的汇编程序会有不同的语法)。
单元尺寸的差异和字的对齐要求由 ANS Forth 字 ALIGN ALIGNED CELL+ CELLS CHAR+ CHARS 和我自己附加的字 CELL ( 等效于 1 CELLS, 但编译之后会更小 ) 来管理。
字 COMPILE 、 !CF 、 CF 、 !COLON 和 EXIT 隐藏了串线模型的特性,比如:串线是如何表示的、代码域是如何实现的。
当你研究 Z80 直接串线和 8051 子程序串线时,这些字的值就变得非常明显:
按同样的风格,字 ,BRANCH 、 ,DEST 和 !DEST 隐藏了高级分支和循环操作符的实现。我试着发明 -- 不借用现有的 Forth 系统 -- 最少的操作符集合,它可以因子化实现的差异。只有时间、专家评判和许多 CAMEL Forth 才可以说明我在这方面取得了多少成功。
到目前为止,我并没有成功地把首部结构中的不同因子化到相似的字集。FIND 和 CREATE 是和首部内容紧密相连的,我还没有找到合适的子因子。我已经开始了这方面的努力,通过字 NFA>LFA NFA>CFA IMMED? HIDE REVEAL 和 ANS Forth 字 >BODY IMMEDIATE. ,我将继续这一工作。值得欣慰的是,现在可以把同样的首部结构用于所有的 CAMEL Forth 实现(因为它们都是字节方式寻址的16位 Forth 系统)
接下来我将要给出一个 8051 内核,并说明 Forth 编译器和解释器是如何用于 哈佛体系结构的(这种系统结构的计算机把存储器逻辑地分成代码和数据两个部分,比如 8051)。对于 8051 ,我会给出文件 CAMEL51 和 CAMEL51D ,但是没有 CAMEL51H ,因为除了汇编语言格式外,高级代码不会与我这里讨论的有什么差异,而本刊的编辑也需要发表其它的文章。好在所有的代码都是可以下载的。
Link – 在 CamelForth 和 Fig-Forth 中,指向前一个字的长度字节。在 Pygmy Forth 和 F83, 中,指向前一个字的 LINK 。
P – 优先位,如果是 1 则为立即字,在 Pygmy 中没有使用。
S - Smudge 位,阻止 FIND 找到这个字
1 – 在 Fig-Forth 和 F83 中,长度字节和名字和最后一个字符的最高有效位(位 7 )用 1 来标识
View – 在 Pygmy Forth 和 F83 中,是这个字所在源码块的编号
1. Derick, Mitch and Baker, Linda, Forth Encyclopedia , Mountain View Press, Route 2 Box 429, La Honda, CA 94020 USA (1982). Word-by-word description of Fig-Forth.
2. Ting, C. H., Systems Guide to fig-Forth , Offete Enterprises, 1306 South B Street, San Mateo, CA 94402 USA (1981).
3. Ting, C. H., Inside F83 , Offete Enterprises (1986).
4. Ewing, Martin S., The Caltech Forth Manual , a Technical Report of the Owens Valley Radio Observatory (1978). This PDP-11 Forth stored a length, four characters, and a link in two 16-bit words.
5. Sergeant, Frank, Pygmy Forth for the IBM PC , version 1.4 (1992). Distributed by the author, available from the Forth Interest Group (P.O. Box 2154, Oakland CA 94621 USA) or on GEnie.
6. J. E. Thomas examined this issue thoroughly when converting Pygmy Forth to an ANSI Forth. No matter what tricks you play with relinking words, strict ANSI compliance is violated. A regrettable decision on the part of the ANS Forth team.
7. In private communication.
The source code for Z80 CamelForth is now available on GEnie as CAMEL80.ARC in the CP/M and Forth Roundtables. Really. I just uploaded it. (Apologies to those who have been waiting.)
Z80 CamelForth 的源代码可以从下列站点上得到 ftp://ftp.zetetics.com/pub/forth/camel/cam80-12.zip .
在我们尊敬的编辑要求下,我给出了 8051 的 CAMEL Forth ,而用于 MC6809 的 Forth 也将很快完成。这个 8051 Forth 占用 6K 字节的程序存储器。不过,全部的源代码将占 TCJ 的 16 页,所以这篇文章只给出了核心移植过程中的主要变化。我们将解释高级代码是如何按 8051 汇编器格式要求和子程序串线技术而修改的。
在文件 CAMEL80H.AZM 中, DO 的定义是这样给出的
['] xdo ,BRANCH . . .
它应该是
['] xdo ,XT . . .
这是由于在 Z80 上没有 consequence ( 在那里, ,BRANCH 和 ,XT 是等价的 ), 但在 8051 上,它是明显的。
在 #60 论文中,我汇总了 8051 Forth 的设计方法。再次说明: 8051 反映迟钝的存储器寻址实际上要求使用子程序串线。这就意味着硬件堆栈(在 8051 的寄存器文件中)就是返回栈。参数栈(也就是数据栈)在 256 字节的外部 RAM 中,使用 R0 作为这个堆栈的指针。从那篇文章开始,我发现了把栈顶元素( TOS )放到 DPTR 中比放在 R3 : R2 中更好。于是就有了这样的程序员模型
这其中也包含了 Charles Curley [CUR93] 的思想。在像 8051 这样寄存器丰富的机器上,我们可以把内层循环索引放在寄存器中,以使得 LOOP 和 +LOOP 更快。 DO 必须向返回栈压入两个值:旧的循环索引和新的循环终值。 UNLOOP 当然需要从返回栈得到循环索引 -- ANSI 把 UNLLOP 做为一个单独的词。注意 R6:R7 不是返回栈的栈顶元素,它只是内层循环的索引。
P2 含有参数栈指针的高字节(允许 R0 寻址外部存储器),它也是用户指针的高字节 -- UP 的低字节假设为 00 。我费了很大的劲才明白当从外部 ROM 执行时, P2 是不能读的,所以我保存了一份 P2 的拷贝在寄存器 8 中。
对 BRANCH 和 ?BRANCH 我有一个非常好的实现方法。由于 8051 模型是子程序串线,高级 Forth 作为真正的机器代码来编译,所以 BRANCH 可以用一个 SJMP (或者 AJMP 或者 LJMP )指令实现。 ?BRANCH 可以用一个 JZ 指令实现,只要 TOS 的零 / 非零标志已经放到了累加器中( A 寄存器)。用一个子程序 ZEROSENSE 做这个工作,所以 BRANCH 和 ?BRANCH 就变成了:
BRANCH:
SJMP dest
?BRANCH:
LCALL ZEROSENSE JZ dest
与此相似, LOOPSENSE 和 PLUSLOOPSENSE 允许 JZ 指令使用 LOOP 和 +LOOP 。对于这些情况,在 JZ 之后应该调用 UNLOOP 以清除程序“FALLS OUT”循环时的返回栈。
在汇编语言源文件中的许多地方,我手工把 LCALL word RET 用更短更快的字 LJMP word替换 ,只要“字”不是一个返回栈操作符(比如 R> 或者 >R )。而只要有可能,字 LCALL 和 LJMP 就用 ACALL 和 AJMP 替代。
我用 Intel 字节顺序写了 8051 内核(低字节在先),之后我发现编译到 LJMP 和 LCALL 的地址是高字节在先。为了避免重写整个内核,我为这些编译 LCALLS 的字包含了一个字节交换的字: COMPILE, !CF 和 ,CF ( 它们都是Dependency 字汇集 ).
8051 使用哈佛体系结构,程序和数据保存在分开的存储器中。在嵌入式系统中,它们分别是 ROM 和 RAM 。 ANS Forth 是第一个能够适应哈佛体系结构限制的标准。简单地说, ANS Forth 规定:
• 应用程序只能够访问数据存储器,同时
• 所有访问存储器和构造数据结构的操作符必须在数据空间操作。
( 参看 ANS 文档 section 3.3.3 [ANS94].) 包括下列 Forth 字: @ ! C@ C! DP HERE ALLOT , C, COUNT TYPE WORD (S") S" CMOVE
然而, Forth 编译器还需要访问程序空间(也称为代码或者指令空间)。 Forth 需要为程序空间和数据空间维护一个字典指针。所以我增加以下这些新的字:
I@ I! IC@ IC! IDP IHERE IALLOT I, IC, ICOUNT ITYPE IWORD (IS") IS" D->I I->D
这里前缀“I”表示指令(因为 P 和 C 在 Forth 中已经有了其它的意义)。 ICOUNT 和 ITYPE 用于显示已经被编译到 ROM 中的串。 IWORD 从数据空间复制 WORD 留下的字到代码空间 -- 用于构造 Forth 字头和放到 ROM 中的串。 D->I 和 I->D 是与 CMOVE 等效的,它从 / 向代码空间复制。
VARIABLE 必须定位到数据空间。所以它们不能使用传统的办法把数据紧接着代码域存放。这里的方法是:在数据空间中数据的地址存放在代码域之后。基本上,一个 VARIABLE 就是一个 CONSTANT ,它的值就是数据空间的地址。 ( 当然,传统的 CONSTANT 依然有效 )
CREATE 字,以及使用 CREATE …… DOES> 创建的字,必须按同样的方式工作。以下是它们在程序空间看起来的样子:
CODE word: ...header... 8051 machine code
high-level: ...header... 8051 machine code
CONSTANT: ...header... LCALL-DOCON value
VARIABLE: ...header... LCALL-DOCON Data-adrs
CREATEd: ...header... LCALL-DOCON Data-adrs
注意 CONSTANT 必须替换 CREATE 存入的值,:必须 "un-allot" 所有这些值和 LCALL DOCON 。
S" 有特殊的问题。使用 S" 定义的串(“文本常数”)必须驻留在数据空间,在那里它们可以被 TYPE 和 EVALUATE 这样一些字使用。但是我们希望这些字是定义的一部分,并且在 ROM Forth 环境中能够驻留在 ROM 里。我们可以把字符串存储在程序空间,引用的时候复制到 HERE ,但是 ANS 文档不允许文本常数存在于这个“临时”的存储区域(参看 ANS 文档 sections 3.3.3.4 和 3.3.3.6 [ANS93]) 。同时,如果 WORD 把它的串地址在 HERE中返回 -- 就像 CAMEL Forth -- 则文本常数不能改变这个临时区域。
我的解决方案是 S" 存储串到代码空间,但是也在数据空间为它永久地保留位置,当引用时,把它从代码空间复制到这个数据空间。 ANS Forth 并没有解决 哈佛体系结构处理器的全部问题,有时像 C 一样的“初始化数据区”可能也是需要的。
因为 ." 从来也不能被程序员使用,它们可以存储在代码空间中,方法是使用字 (IS") 和 IS" 。 ( 它们是 " 老 " 的 (S") 和 S".) 。虽然内核增加了两个字,但是节省了许多数据空间。我计划把有关串常数的字集中到 Dependency 字汇集,或者建立一个新的“HARDVARD”字汇集。
8051 并不能真正地写入程序存储器,没有硬件信号,也没有硬件指令。在这种环境下, CAMELForth 解释器可以工作,但是不能编译新的字。我们可以设法让某些存储器同时出现在程序和数据空间。许多 8031 应用说明给出了同时访问数据和程序空间的方法,在硬件上通过组合一些信号实现。图 1 给出了我对电路板的修改,这个电路板是 Blue Ridge Micros (2505 Plymouth Road, Johnson City, TN, 37601, USA, telephone 615-335-6696, fax 615-929-3164) 的 MCB8031. U1A 和 U1B 产生一个新的选通信号,只要程序或者数据读一个有效时就可以 EPROM 在 A15 为低时被选择 ( 低 32K), RAM 在 A15 为高时有效 ( 高 32K) 。 当然,你不能写入 EPROM ,但是你可以从 RAM 中执行程序!有一个缺点: : 这使得 @ 和 I@ 等效,如果你在什么地方用错了它们,则并不能马上发现。
图 1 修改的 8051 电路图
这些高级定义字修改的目的是实现对 CAMEL Forth 在哈佛体系结构和冯诺曼 体系结构机器之间移植。对于后者,新的程序空间字可以简单地对应到数据空间字,比如对于 Z80
IFETCH EQU FETCH
ISTORE EQU STORE
ITYPE EQU TYPE
等等
在下一篇文章中,我将要修改 8051 源代码,使它能在 6809 上工作,这是一个通过不断改进而得到的真正可移植模型。
[ANS93] dpANS-6 draft proposed American National Standard for Information Systems - Programming Languages - Forth , June 30, 1993. "It is distributed solely for the purpose of review and comment and should not be used as a design document. It is inappropriate to claim compatibility with this draft standard." Nevertheless, for the last 16 months it's all we've had to go by.
[CUR93] Curley, Charles, Optimization Considerations , Forth Dimensions XIV:5 (Jan/Feb 1993), pp. 6-12.
8051 CamelForth 的源代码可以从下列站点上得到 ftp://ftp.zetetics.com/pub/forth/camel/cam51-15.zip
现在,我们将给出本文的最后部分,也就是许诺已久的 MOTOROLA 6809 ANSI CAMEL Forth 。这个实现是专门为 Scroungmaster II 处理器板而设计的。
与 Z80 和 8051 的 CAMELForth 不同, MC6809 Forth 是用我的“Chromium 2 Forth MATA 编译器”生成的。你可以看到两件事:
• 首先、 MATA 编译器在一个老的 Forth 系统上运行(F83),所以源代码中含有 16 x 64 的 Forth“SCREEN”。我试着把它转为 ASCII 文件,但是原始的痕迹还是很明显;
• 第二、用于 META 编译器的源代码看起来很像一般的 Forth 代码(我马上就要讨论,有一些小的变化),这样,关于 1+ 的定义就变成了:
CODE 1+ 1 # ADDD, NEXT ;C
汇编器使用的是我以前讨论过的 MC6809 汇编器。
我直接照着已经出版的列表打入高级源代码(转换到 Forth 语法)。不幸的是,由于这中间隔了很长时间,并且我有时参照 Z80 列表、有时又参照 8051 列表……结果是 HARVARD 体系结构构造字(比如 I@ IALLOC )没有坚持用在 MC6809 中。这对于非 HARVARD 结构的 MC6809 并不重要,但是如果要把 Forth 代码用于 HARVARD 结构,我就不得不再修改这些错误。
另外,由于我是在已经出版的列表基础上工作的,我常常忘了给高级字定义写上详细的说明,不过,你可以从原来的列表中知道它们是如何工作的,当然,我并不强制你这样做。
MC6809 CAMEL Forth 模型把 TOS 放到 D 寄存器中,把S 栈指针用于参数栈, U 指针用于返回栈, Y 是解释指针。 X 是 W 寄存器的临时寄存器。 MC6809 直接页指针 DPR 保存用户指针的高字节(低字节假设是 0 )。
Scroungemaster II 单板上的 8K RAM 和 8K EPROM 按以下地址映象:
6000-797Fh RAM 字典 ( 用于新定义 )
7980-79FFh 终端输入缓冲区
7A00-7A7Fh User 区 (USER 变量 )
7A80-7AFFh 参数栈 ( 向下增长 )
7B00-7B27h HOLD 区 ( 向下增长 )
7B28-7B7Fh PAD 区 ( 通用缓冲区 )
7B80-7BFFh 返回栈 (grows downward)
E000-FFFFh EPROM 中的 Forth 内核
所有的 RAM 数据区通过用户指针引用,它的开始地址是 UP-INIT :在我们这里是 7A00H (注意这个字的高字节和 UP-INIT-HI 的使用)。当 CAMEL Forth 开始的时候,它会把字典指针设置到 DP-INIT ,而且必须在 RAM 中,这样你就可以向 Forth 字典中加入一个新的定义。这些都是由 META 编译器的 EQU 指令指定的。这些 EQU 指令并不占用 MC6809 的核心空间,它们也不会出现在 MC6809 的 Forth 字典中。
DICTIONARY 告诉 MATA 编译器在哪里编译代码,在我们的情况下是 E000-FFFFH 的 8K EPROM ,新的字典命名为 ROM ,然后 ROM 被指定到所选定的字典。(如果你熟悉 Forth 的词汇表,你就会看到很强的相似性)。
字 AKA 定义一个 Forth 字的同义词。因为 MC6809 不是一个哈佛体系结构计算机,我们应该把所有在源代码中出现的 I@ 编译成 @ ,其它的“带 I 前缀”(指令空间)字也做同样的处理。 AKA 将完成这个工作。这些同义词像 EUQ 一样,它们不出现在 MC6809 的字典中。
MATA 编译器允许你使用前向引用,就是访问那些还没有定义的 Forth 字(你当然需要在全部完成之前定义它们!)。这通常是自动的,但是 AKA 要求你使用 PRESUME 明确地说明,比如:
PRESUME WORD AKA WORD IWORD
用于创建 IWORD 的同义词。 @ ! HERE ALLOT 是 META 编译器自动定义的,我们不需要对这些词使用 PRESUME 。
CODE 定义非常方便。注意你可以使用:
HERE EQU labelname
在 META 编译中产生一个标号,(这是 META 编译器的一个功能而不是汇编器的功能)。另外, ASM: 开始一个汇编代码片段(也就是说,这不是一个 CODE 字的一部分)。
下面的短语
HERE RESOLVES name
用于解决 META 编译器使用的特定的前向引用(例如, MEATA 编译器需要知道 DOCOLON 动作的代码在哪里)。你应该使这些独立。除此之外,你可以自由地在源代码中加入 CODE 定义。
定义字和控制结构(IMMEDIATE 立即字)的代码更加难懂,其中的原因是这些字在 META 编译期间也要执行一些动作。例如: MC6809 Forth 包含有标准字 COSNTANT 用于定义一个新的常数。但是许多 COSNTANT 定义也出现在 MC6809 内核中。我们在 META 编译中也许需要定义一个新的 CONSTANT 。 EMULATE: 短语用于指示不同的 CONSTANT 冲突时如何动作。这个短语是完全用 MEATA 编译器字写成的,所以看起来完全是含混不清。
与此类似, IF THEN 和其它同类的字包含 META 编译短语用于构造和解决 MC6809 映象的分歧。一些 META 编译器把这些字隐藏在编译器之中,这可以产生漂亮的目标代码,但是,如果你需要改变分支的方式,你就必须修改 META 编译器。
我倾向于使这些动作易于修改,所以我选择 Chromium 放到目标源代码中。(最恐怖的例子是 TENDLOOP 和 TS" 的定义,它们实际是在目标源代码当中扩展了 META 编译器的词汇表。
如果你是一个 Forth 和 META 编译器的新手,最好的方法是接受这一切。“普通”的冒号定义是很容易加入的,只需要参照 MC6809 其它部分源代码就可以了。你甚至可以写 CREATE …… DOES> 字义,只要你不在 META 编译器中使用它们。
在一个 1MHz 的 MC6809 上,一行文本输入需要明显长的时间去处理(粗略估计约为 1 秒钟)。这其中的部分原因是由于解释器的许多部分是使用高级 Forth 编码的,另一部分原因是 CAMEL Forth 使用了一个单链表结构的字典。这些只影响编译的速度而不会影响到执行速度。
不过,延迟总是烦人的,也许有一天我会写出一篇有关“加速 Forth”的论文。
现在,用户指针 UP 不会改变。我们拥有一个 UP 的目的是支持多任务 -- 每个任务有它自己的用户区、堆栈等等。我将很快针对这个问题开展工作。我也许会研究 SM II 的存储器管理,为每个任务提供 32K 的私有字典。当然,我会努力写出一个真正的使用共享总线的多处理器 Forth 内核。如果我活得足够长,理所当然地还应该写一个使用串行口的分布式 Forth 内核。
MC6809 的 CAMEL Forth 版本 1.0 的源代码在 GEnie 的 Forth Roundtable ,文件名称为 CAM09-10.ZIP ,这个文件包含了 Chromium 2 meta 编译器,是可以运行的。只要有 F83 ,你就可以输入:
F83 CHROMIUM.SCR
1 LOAD
BYE
这样就可以装入 META 编译器,编译MC6809 CAMEL Forth ,把结果写入 Intel 格式的6809.HEX 中。注意:如果你使用的是 CP/M 或者 Atari ST 版本的 F83 ,则必须编辑 LOAD 屏幕以删除 HEX 文件实用程序,因为这个程序只是为 MS-DOS 机而编写的。我没有测试 Chromium 2 使用的 CP/M 或者 Atari ST ,如果需要帮助,请与我联系。
[ROD91] Rodriguez, B. J., "B.Y.O. Assembler," The Computer Journal #52 (Sep/Oct 1991) and #54 (Jan/Feb 1992).
[ROD92] Rodriguez, B. J., "Principles of Metacompilation," Forth Dimensions XIV:3 (Sep/Oct 1992), XIV:4 (Nov/Dec 1992), and XIV:5 (Jan/Feb 1993). Describes the "Chromium 1" metacompiler.
MC6809 CamelForth的源代码可以从下列站点上得到 ftp://ftp.zetetics.com/pub/ forth /camel/cam09-10.zip .