返回入门教程首页 主页 | 开发工具 | 应用芯片 | 核心模块  
 
   
 

Forth 系统实现

作者 Brad Rodriguez
编译 赵宇 张文翠

第三部分 解密 DOES>

更正

上一部分的 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 字

在以上所有的一般性讨论中,有一个明显的例外,这就是 CODE 定义 -- 用汇编码子程序定义的 Forth 字。用“汇编语言来定义一个字” -- 这个神奇的功能在 Forth 中很容易实现,因为每个 Forth 字都执行一段 Forth 代码。

包含 CODE 字的汇编代码总是包含在一个 Forth 字的体中,代码域必须包含有要执行的机器代码的地址。所以机器代码放在参数域中,代码域包含了参数域的地址,如图 6 所示。

图 6 CODE 字

在直接或者子程序串线的 Forth 中,我们可以通过类推,把一个 JUMP 放到代码域中。代码域也可以用 NOP 或者相同的结果填充。更好的是,机器代码可以直接从代码域开始,然后进入参数域。从这一点看,代码域和参数域就没有区别了。这不应该有任何疑问,因为我们并不需要对一个 CODE 字做这样的区分。但可能有一些反汇编器和一些聪明的编程技巧需要这一区分,我们在这里就不讨论它们了。

CODE 字 -- 不论是怎么实现的 -- 都是不需要向它传递参数域地址的机器代码动作。参数域不包含数据,只是需要执行的代码。只有 NEXT 需要知道这个地址(或者代码域地址),这样它就可以直接跳到机器代码。

使用 ;CODE

现在还有三个问题没有回答:

•  我们如何创建一个 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 和 (;CODE) 的工作

;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 定义“写出”子程序的行为呢?

每个 Forth 字必须 -- 通过 NEXT 的行为 -- 执行一些机器语言子程序。这就是代码域的全部。因此,一个机器子程序、或者一系列子程序需要解决如何访问高级行为的问题。我们称这个子程序为 DODOES 。

这里有三个问题需要解决:

•  我们如何找到与这个字相关联的高级行为子程序的地址?

•  我们如何从机器代码中为调用一个高级行为子程序而访问 Forth 解释器?

•  我们如何向那个子程序传递我们正在执行的字的参数域地址?

对于第三个问题的回答是:很容易,用我们为一个高级 Forth 子程序在参数栈上传递参数的方法。我们的机器语言子程序在访问高级串线之前必须把参数域地址推到堆栈上(从我们以前的工作看,我们知道机器语言如何能够得到 PFA )

第二个问题的答案有一点困难。基本上我们可以像 Forth 字 EXECUTE 那样做一些事情来访问一个 Forth 字;或者也可能是 ENTER ,它访问一个冒号定义。它们都是我们的“关键”核心字, DODOES 与此类似。

第一个问题好象有些难度。我们把高级子程序的地址放到哪里呢?记住:代码域并不指向高级代码,它必须指向机器代码。在 Forth 的历史上,人们曾经使用过以下两种方法。

FIG-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 则显得太长了,大概应该还是作为一个子程序为好。

使用 DOES>

我们已经学习了使用 ;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.

 

 

 

   

(C) ForthChina.com 版权所有 2004-2010
Email:forthchina@163.com