Forth 语言简明教程 |
作者 Richard E. Haskell |
| 编译 赵宇 张文翠 |
第三课 Forth 是如何工作的
3.1 变量
Forth 字 VARIABLE 是一个定义变量名字的定义字,如果你打入
VARIABLE my.name
Forth 就会创建一个新字典项,它的名字是 my.name. 所有的字典项都有通用的格式,包含一个首部。
首部由不同的字段组成,包括 VIEW 字段、名字字段和链接字段。在 F-PC 中,首部和体物理上分别存储在不同的段中,也就是在x86的实模式中,1 M 字节的地址空间被分成为 64 K 字节的段,而一个物理地址由位的段地址 seg 和一个 16 位的偏移地址 off 组成,完整的地址形式是 seg:off 。段可以在 16 字节的边界开始,称为页。因此,为了寻址任何存储器字节,我们必须指定段地址和偏移量。
字 MY.NAME 的字典看起来是这样的

VIEW 字段是一个字符计数,它等于文件中冒号定义开始的偏移量。当使用 VIEW 命令时,这个字段可以在源程序文本中定位冒号定义。
链接字段包含一个指针,它指向前一个定义字的 LFA 。
名字字段包含名字,但第一个字节是名字的字符数,后面是 1-31 个字符,也就是定义的名字。
指向 CFA 的指针包含一个 CFA 在代码段的偏移量。代码段的地址在 F-PC 中通过 ?CS: 给出。
代码字段包含有代码,它在这个定义被解释时执行, F-PC 中使用直接串线编码,许多 Forth 系统使用间接串线编码,其中的代码字段含有一个指向执行代码的指针。对于 VARIABLE 来说,代码字段含有三个字节的指令,也就是 CALL NEXT ,这里的 NEXT 是 F-PC 的内层解释器,将在后面进行描述。 CALL 指令自动把下一指令的地址放到栈上,在我们的系统中,这却不是一个真正的指令地址,而是参数字段的地址。
对于不同种类的字,参数字段包含有不同的东西,对于 VARIABLE 字,参数字段包含这个变量的 16 位的值。初始值是 0.
如果你打入这个变量的名字,代码字段中的 CALL NEXT 指令将执行,它的作用是把参数字段的地址留在堆栈上。如果你打入
my.name .
my.name 的 PFA 将要被打印出来。可以试一下。
3.2 关于变量的更多内容 -- FETCH 和 STORE
Forth 字:
! ( n addr -- ) ( "store" )
把值 n 存入地址 addr , 6 my.name ! 将把值 6 存入 my.name 的 PFA.
@ ( addr -- n ) ( "fetch" )
读出 addr 位置的值放到堆栈上, my.name @ . 将打印 my.name 的值。
堆栈变量
系统变量 SP0 包含有一个空的堆栈的堆栈指针,这样
SP0 @
将返回堆栈没有任何内容时的堆栈指针的地址。
Forth 字 SP@ 返回最后一个元素压入堆栈后的地址,于是,它就是堆栈指针的当前值。
Forth 字 DEPTH 返回堆栈上元素的数量,它是这样定义的:
: DEPTH ( -- n )
SP@ SP0 @
SWAP - 2/ ;
注意由于堆栈上每个元素都包含两个字节,堆栈元素的数量必须除以 2
3.3 常数
Forth 字 CONSTANT 是一个用来定义常数的定义字,例如你输入
25 CONSTANT quarter
名字 quarter 将按以下的方式进行字典:

代码字段包含有三个字节,它对应指令 CALL DOCONSTANT 。而 DOCONSTANT 从堆栈上弹出 PFA (它是被 CALL 指令压入的),然后把 FPA 的值放到堆栈上,这样,如果你先输入
25 CONSTANT quarter
然后再输入 quarter . 则值 25 就打印出来。注意, CONSTANT 存储的数是 16 位的无符号数。
3.4 Forth 冒号字义
Forth 字 : (读作“冒号”) 也是一个定义字,它允许你定义新的 Forth 字,如果你输入:
: squared DUP * ;
冒号字:被执行,它在字典中创建一个 Forth 字 squared ,如下所示:

代码字段包含的 3 个字节对应于指令 JMP NEST. 在 NEST 位置的代码是内层解释器的一部分,我们将在本教程的后面描述。
参数字段包含有列表段(list segment)的偏移量, 称为 LSO, 它的值被加入到列表段地址,这个地址存储在变量 XSEG. 中,结果的段地址存储在寄存器 ES 中,组成 squared 定义的代码字段列表存储的开始地址 ES:0.
UNNEST 是另一个子程序的地址,它也是 Forth 内层解释器的一部分。
3.5 数组
如果你想创建一个有五个 16 位数的数组,如果你输入:
VARIABLE my.array
则 Forth 会创建字典输入项 my.array 它在参数字段含有一个 16 位值

这里我们没有给出首部,注意参数字段包含有 2 个字节的 16 位值
Forth 字 ALLOT 将加入 n 字节将在字典的代码字段,这里 n 是 ALLOT 执行时从堆栈上得到的值,于是
8 ALLOT
将加入 8 个字节或者是 4 个字到字典中, my.array 字典项的代码段部分看起来像这样:

为打印 my.array(3) 的值,你必须是这样做:
my.array 3 2* + @ .
3.6 返回栈
你输入一个数,它就被放到参数栈上。所有的算术操作和 DUP 、 ROT 、 DROP 、 SWAP, OVER 这一类字的操作数都在参数栈上。
Forth 还有第二个堆栈,称为返回栈。返回栈被 Forth 的内层解释器使用以存储冒号定义执行时下一个字的地址,它也被特定的 Forth 字使用,比如 DO.
如果非常你很细心,那你也可以使用返回栈,但是,需要再次强调:细心 。你可以从参数栈上临时地移出一个数到返回栈,前提是你要保证在冒号定义结束之前已经把它移开了,否则,由于正确的返回地址并没有放在栈项,内层解释器就不能找到适当的地址。
下列 Forth 字用于返回栈, R :
>R ( n -- ) ( "to-R" )
弹出参数栈的顶层元素,并把它压入返回栈
比如 3 >R 将把 3 移到返回栈,并留参数栈为空。
R> ( -- n ) ( "from-R" )
弹出返回栈顶元素,并把它压入参数栈。
R@ ( -- n ) ( "R-fetch" )
把返回栈栈顶元素复制到参数栈上。
这是一个可能的 ROT 字义:
: ROT ( n1 n2 n3 -- n2 n3 n1 )
>R \ n1 n2
SWAP \ n2 n1
R> \ n2 n1 n3
SWAP ; \ n2 n3 n1
3.7 CODE 字
Code 字是使用 8086 机器语言定义、而不是使用其它 Forth 字字义的字。当然,不论使用什么定义,最终都必须执行真正的 8086 机器代码,内部解释器是用机器码编写的,还有许多的 F-PC Forth 字为了提高执行的效率也使用机器码编写。在第七课中,我们将讨论如何编写自己的 Forth CODE 字。
由于 F-PC 使用直接串线技术,在一个 CODE 字中的机器码直接存储在代码段的 CFA 中。这里有几个 F-PC 原语定义的例子,每个字的首部与我们前面讨论的 VARIBLES , CONSTANT 和冒号定义一样,都存储在首部段中。

3.8 Forth 字典
Forth 字典用已经定义的所有字组成为一个链表。这些字可以是变量、常数、冒号定义或者 CODE 字。所有这些字的名字都存储在首部中并通过链接字段指针方式实现连接。每个字的代码字段由首部的代码字段指针指定。代码字段总是包含真正可执行的代码,所以它必须在 8086 的 CODE 段。在一个冒号定义中,定义里的每个字的 CFA 列表存储在一个分开的列表段中,并通过存放在代码段中的 PFA 指针来指向。
当我们使用冒号定义来定义一个新字时,就包括一个把这个字存入字典的过程。 F-PC把你所定义的名字链接到一个有 64 个入口项的线索中,再使用一个散列( HASHING)机制进行查找,以提高速度。
字典中代码段的下一个可用地址通过 HERE 指定。于是, HERE 就是一个 Forth 字,它在堆栈上返回字典空间下一个可用地址。变量 DP 称为字典指针,包含下一个可用的字典地址,字 HERE 是这样定义的:
: HERE ( -- n )
DP @ ;
(当然,F-PC 实际使用 CODE 字来定义 HERE)
引导一个 Forth 系统并出现 ok 提示符之后,你所执行的是外层解释程序。当你打入一个字并打入 <Enter> 之后,外层解释器用你输入的字查找字典。如果找到了这个字,它就执行代码字段。如果它没有找到这个字,就调用一个称为 NUMBER 的字试着把输入串转为一个数字。如果转换成功,就把这个数压入堆栈,否则,它就显示一个信息 <- What? 告诉你它不懂你输入的字。对于内层解释器的详细讨论见 3.13.
3.9 表
一个表就像是一个常数的数组。你可以创建一个数组然后使用!存储字来填充它。另一个创建表的方法是使用 Forth 字 CREATE ,它的工作方式与 VARIABLE 相同,但是不在参数字段保留空间。例如,如果你打入:
CREATE table
你就可以创建如下的字典项

和 VARIBLE 的情况一样,代码字段包含三个字节对应于指令 CALL NEXT 这里的 NEXT 是 F-PC 的内层解释器。当字 TABLE 被调用时, CALL 指令将把参数字段的地址留在堆栈上。
这里的字典指针 DP 包含表的 PFA 的值。 Forth 字 , 逗号将把堆栈上的值存储到字典指针指向的位置上,那就是字典的下一个可用位置。因此,如果你输入 CREATE table 5 , 8 , 23 , 将创建如下的字典项:

你现在可以定义一个新的字名为 @table
: @table ( ix -- n )
2* table \ 2*ix pfa
+ @ ; \ @(pfa + 2*ix)
例如, 2 @table 将返回 23 到栈顶。
3.10 字符和字节数据
字符 (ASCII码) 数据可以按一个字节存储。数据可以用下面的 Forth 字按单字节的方式存入和读出
C, ( c -- ) ("C-comma")
把栈顶值的低有效字节( LSB )存储到 HERE ( 字典的下一个可用位置 )
C! ( c addr -- ) ("C-store")
存储栈顶元素的 LSB 到 addr 位置。
C@ ( addr -- c ) ("C-fetch")
读取 addr 处的字节,把 LSB 放到堆栈上
你也可以通过下面的方式创建字节常数表而不是字常数表:
CREATE table 5 C, 8 C, 23 C,
然后你可以定义一个字 C@table
: C@table ( ix -- c )
table + C@ ;
2 C@table 将把 23 返回到栈顶
注意 C@table 和 3.9 节的 @table 定义之间的区别
3.11 查找字典地址
下面这些字可以用于定位和检查 Forth 字典项:
' ( -- cfa ) ("tick")
语句 ' table 将把 table 的 CFA 放到堆栈上。
>NAME ( cfa -- nfa ) ("to-name")
转换代码字段地址 CFA ( 在代码段中 ) 到名字字段 NFA ( 在首部段中 )
>LINK ( cfa -- lfa ) ("to-link")
转换代码字段地址 CFA ( 在代码段中 ) 到链接字段地址 LFA (在首部段中)
>BODY ( cfa -- pfa ) ("to-body")
转换代码字段地址 CFA ( 在代码段中 ) 到参数字段地址 PFA (在代码段中)
你也可以通过使用下面的字得到代码字段地址:
BODY> ( pfa -- cfa ) ("from-body")
NAME> ( nfa -- cfa ) ("from-name")
LINK> ( lfa -- cfa ) ("from-link")
你还可以从名字到链接或者从链接到名字
N>LINK ( nfa -- lfa ) ("name-to-link")
L>NAME ( lfa -- nfa ) ("link-to-name")
Forth 字 HEX 将改变用于打印输出的数基到 16 进制。字 DECIMAL 将改变数基到 10 进制,你还可以通过改变变量 BASE 的值到任何的数基。例如, HEX 是这样定义的
: HEX 16 BASE ! ;
注意,在 HEX 定义之中,数基必须是 10 进制。
Forth 字 U. 把栈顶的值作为 0 到 65535 的无符号数打,或者如果是 HEX 模式,则是 0000 到 FFFF 。作为一个例子,为了打印字 OVER 的名字字段地址,可以打入:
HEX ' OVER >NAME U. DECIMAL
Forth 字 LDUMP ( seg off #bytes -- ) 可以用于得到从 seg:off. 开始的 #bytes 个字节的存储器映像,打入
YSEG @ ' OVER >NAME 20 LDUMP
可以看到 OVER 的名字字段。 .
3.12 名字字段
如果你使用冒号来定义一个新的字,比如 TEST1 , 将会创建以下的名字字段:
如果优先位设为 1 ,这个字将被立即执行。立即字在第 9 课中讨论。
如果使用用位为 1 ,这个字在字典搜索中不可见。这个位在冒号定义编译时设置。
输入以下的空白冒号定义:
: TEST1 ;
然后这样来检查名字字段:
YSEG @ ' TEST1 >NAME 10 LDUMP
上图的 6 个 16 理进制数将显示出来。
注意名字字希段的第 1 个和最后一个字节的最高有效位都置为 1 ,实际存储在名字字段的字符的最大数量由变量 WIDTH. 确定,例如:
10 WIDTH !
将使得名字字段最大存储 10 个字符。 F-PC 设置 WIDTH 默认值为 31 – 这是它的最大可能值。
3.13 F-PC 内层解释器操作
下图说明了 F-PC 内层解释器操作。

NEXT
LODSW ES: \ Load AX with CFA at ES:SI & inc SI
JMP AX \ Execute the code at the CFA in AX
NEST
XCHG BP,SP \ Push IP = ES:SI
PUSH ES \ on the return stack
PUSH SI
XCHG BP,SP
MOV DI,AX \ AX = CFA of word to execute
MOV AX,3[DI] \ Get LSO at PFA
ADD AX,XSEG \ and add to XSEG
MOV ES,AX \ Put this sum in ES
SUB SI,SI \ Make new IP = ES:SI = ES:0
JMP >NEXT \ Go to >NEST
UNNEST
XCHG BP,SP \ Pop IP = ES:SI
POP SI \ from the return stack
POP ES
XCHG BP,SP
JMP >NEXT \ Go to >NEXT
内层解释器包含有三个子程序 NEXT、NEST和UNNEST 。一个解释指针或者叫指令指针 IP 指向 LIST 段的存储器位置,这里是下一个将要执行的字的代码段地址。在 F-PC 中,这个指令指针包含两个部分即 ES:SI 。
假设如上图所示,这个指针指向了 CUBED 定义的 SQUARED 的 CFA,子程序 NEXT 把这个 CFA 放到一个字寄存器 W 中( F-PC 中它是 AX ),并把 IP (SI)增量 2 使得它指向当前定义的下一个字( * ),然后执行 W 中的 CFA 处的代码。
这种情况下冒号定义的 CFA 处代码是一个跳转到子程序 NEST的指令,如上所示 NEST 将把 IP(ES 和 SI)压入返回堆栈使得程序在以后 UNNEST 执行时可以找到返回 CUBED 中下一个字的方法。
NEST 接着得到 LIST 段的偏移量 LSO 用于字 SQUARED ,把它加上 LIST 段的基地址 XSEG 然后把这个值存入 ES ,再把 SI 设为 0 以使得新的 IP 值为 ES:0, 它指向 SQUARED 定义的第一个字,接着再跳转到 NEXT 重复这个过程,这一次是执行 SQUARED 的第一个字 DUP 。
由于 DUP 是一个 CODE 字,它的实际的机器代码就在自己的 CFA位置,这个代码将在 NEXT 被执行的时候执行。 DUP 定义的最后一个指令是另一个跳转到 NEXT 的指令,但是现在 IP 将增量并指向了 * 的 CFA 。这又是一个 CODE 字,执行并再次跳转到 NEXT 。
冒号定义的最后一个字是 UNNEST 。当冒号字义中的分号;被执行时, UNNEST 的 CFA 被加到字典 LIST 段。 UNNEST 的代码段包含上面的机器代码,它从返回栈弹出 IP(SI 和 ES)并跳转到 NEXT 。因为这是在 SQUARED 执行时被 NEST 压入堆栈的,它指向 CUBED 定义的 SQUARED 的后一个字,这个字是 * ,就是下一个要被执行的字。
这就是 Forth 的工作方式。冒号定义作为 CFA 列表在 LIST 段中存储。当 CFA 要执行的是另一个冒号定义时, IP 被增量后压入堆栈并改变指针指向将要执行的新字定义中的第一个 CFA ,如果 CFA 是一个 CODE 字时, CFA 位置的实际机器代码被执行。这个过程在每个字结束时用一个跳转到 NEXT 的动作来持续执行。
3.14 练习
定义冒号字
: squared DUP * ;
和
: cubed DUP squared * ;
使用 F-PC 字 ' ("tick"), >LINK ("to-link"), 和 LDUMP (seg off #bytes -- ) 回答下列问题 :
1) 什么是代码段 ?CS:?
2) 什么是首部段 YSEG?
3) 什么是列表段 XSEG?
4) 什么是 squared 的 CFA?
5) 什么是 squared 的 LFA?
6) 什么是 squared 的 NFA?
7) 什么是 squared 的 PFA ?
8) 什么是 cubed 的 CFA?
9) 什么是 cubed 的 LFA?
10) 什么是 cubed 的 NFA?
11) 什么是 cubed 的 PFA?
12) 画出 squared 的首部图示并在所有的位置上标出 16 进制值。存放在 ^ CFA 位置的是什么值 ? 画出 squared 的 CFA 和 PFA 字段并给出字典的 list 段。给出字典中的所有地址值。
13) 什么是 CUBED 定义的字典的 LFA ?什么是字的名字?
14) 什么是 NEST 的地址?
15) 什么是 DUP 的 CFA?
16) 什么是 * 的 CFA?
17) 什么是 UNNEST 的地址 ?
|