Forth 语言基础(一)

目的

本文的目的是以 My4TH Forth 为例,用尽量少的篇幅尽可能全面地介绍 Forth 语言。

本文拟分为若干部分。本部分为第一部分,完整介绍了 Forth 语言本身,Forth 2012 标准中 Core、Core Extension、Block、Double-Number 等几个最基本的词集的内容,以及 My4TH Forth 部分扩展词的内容。这一部分的例子都可实际运行,在 My4TH Forth 上验证。

本文按 CC BY-NC-SA 4.0 协议发布。

需要的基础知识

阅读本文前,需要有一点关于算法与数据结构的基本常识(文中不会再去解释什么叫栈、什么叫链表),对计算机的体系结构有最基本的理解,最好用至少一种汇编语言编写过程序。

读者不必须使用过其他高级语言。但是如果了解传统的编译型高级语言的实现细节,对理解 Forth 系统的底层会大有帮助(推荐阅读《C/C++ 深层探索》和《链接器和加载器(Linkers and Loaders)》)。

读者有一点(自然语言的)语言学常识当然更好。不过这也不是必需的。

Forth 的基本结构

形式化地说,Forth 是一个拥有 两个栈1 和 一片可动态分配的内存区域 的虚拟机。两个栈中,一个用来暂存计算中的数据,一般称作数据栈(data stack)、参数栈(parameter stack),或直接称作(stack);另一个用来暂存返回地址及某些数据(如循环中的循环变量),一般称作返回栈(return stack;某些文献中简写为 rstack)。栈中的一个单元(cell)的大小一般至少为 16 位(2 个字节)。

可动态分配的内存区域用来存储变量及程序等,称作词典(dictionary)。词典中的项目(如变量和程序)称作定义(definition),定义的名称称作2(word)。 大部分 Forth 解释器中,词不分大小写(但标准对大小写敏感性未进行强制规定)。

需要注意的是,为了方便实现,实际的 Forth 解释器可能会在物理上用更多的栈来实现相同的逻辑功能。如 My4TH Forth 中,有两个单独的栈来处理控制流和循环,R>>R 访问的“返回栈”和存储循环变量(如 IJ )的“返回栈”,在物理上也不是同一个(详见这里)。

表达式的计算

Forth 使用后缀表达式(逆波兰表达式)进行计算。例如,计算 1+2 时,需要使用如下指令:

1 2 + .

解释器以空格为分隔符,从左向右解释输入序列。遇到一个记号(token)时,解释器先在词典中查找它,如果找到则执行它;如果词典中没找到这个记号,则解释器试图将其作为数字解释,成功时将数字压入栈内,不成功时报错。

执行上面的指令时,解释器首先将 1 压入栈内,然后将 2 压入栈内。+ 是一个词,其作用是弹出栈顶的两个元素,并将其和压入栈内。. 也是一个词,用于弹出并打印栈顶的内容。因此,上面指令的输出是:

3  ok

ok 是 My4TH Forth 解释器的提示符。

.S 用于打印栈内的全部内容(最右侧内容是栈顶)。如:

1 2 3 .s↵ <3> 1 2 3  ok

输出的 <3> 是栈深(栈内元素的总个数)。注意 .S 不会改变栈的内容,而 . 在打印的同时会弹出栈顶元素。

CR 用于换行。如:

3 4 + 5 2 - * 3 3 1 + + / cr .
3  ok

这段程序计算的表达式是 (3+4)*(5-2)/(3+1+3)

数字的输入方式

单精度整数与双精度整数

My4TH Forth 原生支持单精度整数(在栈中占 1 个单元)和双精度整数(在栈中占 2 个单元)。输入单精度整数时,直接输入数字本身即可:

1 .s↵ <1> 1  ok

输入双精度整数时,需要在数字后面加 .,如 100000.

100000. .s↵ <2> -31072 1  ok

(-3107210=86a016,186a016=10000010。)

打印双精度整数使用 D.

100000. d.↵ 100000  ok

将单精度整数转换为双精度整数使用 S>D

10000 s>d d.↵ 10000  ok

将双精度整数转换为单精度整数使用 D>S。注意此时双精度整数的值应在单精度整数可表示的范围内。

10000. d>s . 10000  ok

无符号整数与有符号整数

在 Forth 中,数据类型由执行的程序决定,解释器本身同等对待无符号整数和有符号整数:

-1 . -1  ok
65535 . -1  ok
-1 .s↵ <1> -1  ok
65535 .s↵ <2> -1 -1  ok

打印无符号整数时,需要使用 U. 而不是 .

60000 . -5536  ok
60000 u.↵ 60000  ok

显然,无符号整数和有符号整数使用同一套算术运算词(除少数词以外,如两个单精度整数相乘得一个双精度整数的 M*UM*,以及双精度整数除以单精度整数的 FM/MODSM/REMUM/MOD)。

不同进制的整数

Forth 解释器可处理不同进制的整数。在缺省状态下,数字为十进制。

具有前缀 #$% 的数字分别为十进制、十六进制、二进制:

#10 . 10  ok
$10 . 16  ok
%10 . 2  ok

为增加可读性,数字中可自由插入 _

10_00_0 . 10000  ok
$0f_ff . 4095  ok
%0_01_0_1_0_1_1 . 43  ok

HEX 可将解释器缺省的数制切换至十六进制;DECIMAL 可将缺省的数制切换回十进制:

10  ok		\ 栈内压入十进制的 10
hex↵  ok		\ 将解释器切换至十六进制
. A  ok		\ . 打印出的数字为十六进制
a . A  ok		\ 此时输入和打印的均为十六进制的整数
#11 . B  ok	\ 前缀 #、$、% 此时仍可用
10  ok		\ 栈内压入十六进制的 10
decimal↵  ok	\ 切换至十进制
. 16  ok		\ . 打印出的数字为十进制

注释的写法,及文档中栈的表示法

Forth 的注释有两种写法:\()。解释器遇到 \ 时,会忽略本行后面的所有内容;遇到 ( 时,会忽略 ( 和下一个 ) 之间的所有内容。注意 \( 都是词,它们的前后都必须有空格()前不必须有空格)。例如:

1 2 + cr . cr \ Calculate 1+2.↵
3
 ok
1 2 + ( Calculate 1+2.) cr . cr↵
3
 ok

文档中说明某个词对栈的内容产生的作用时,一般使用如下的栈表示法(stack notation):

( before -- after )

before 表示执行这一词对应的操作前栈的内容,after 表示执行这一词对应的操作后栈的内容。其中的 -- 在有的文档中写作 ——--- 等。如:

+ ( n1 n2 -- sum ) 	\ 将 n1 与 n2 从栈内弹出,求和,并将和压入栈内。
. ( n -- )			\ 从栈内弹出一个数,并打印,然后打印一个空格。

由于“弹出”和“压入”在栈表示法中是显然的(如果一项包含在 -- 前面的内容中,但不包含在 -- 后面的内容中,那么它就被弹出了),所以注释中通常不包含弹出和压入的内容(压入的内容有时称作返回值)。如:

+ ( n1 n2 -- sum ) 	\ 返回 n1 与 n2 之和。
. ( n -- ) 			\ 打印一个数,然后打印一个空格。

要注意,在所有的 Forth 文档中,当将栈的内容横写时,最右侧内容总是栈顶。如:

- ( n1 n2 -- n3 )	\ n3=n1-n2.

上例中,n2 是操作 - 进行前的栈顶内容。所以计算 32-23 使用的指令为:

32 23 - . 9  ok

下面给出一个比较复杂的例子。 词 /MOD 的作用是计算商和余数:

/MOD ( n1 n2 -- n3 n4 )	\ n3=n1 mod n2, n4=n1/n2.

指令 3 4 + 5 2 - * 2 5 * /mod cr . . 的执行过程可表示如下(为清晰起见,省略掉 before--):

命令行成员	
3			( 3 )
4			( 3 4 )
+			( 7 )
5			( 7 5 )
2			( 7 5 2 )
-			( 7 3 )	
*			( 21 )
2			( 21 2 )
5			( 21 2 5 )
*			( 21 10 )	
/mod		( 1 2 )
cr			( 1 2 )
.			( 1 )
.			( )

故其执行结果为:

3 4 + 5 2 - * 2 5 * /mod cr . .
2 1  ok

本文档中会广泛使用栈表示法来描述一个词及其执行过程。

栈表示法中元素的符号,一般采用如下约定:

符号 意义
n 有符号整数;一般的占 1 个单元的元素(有时用 x 表示)
+n 正整数(大于 0 的整数)
u 无符号整数
c,char 字符
f,flag 标志,取值为真(true,非零)/假(false,零)
addr 地址
a-addr 按单元对齐的地址
c-addr 按字符对齐的地址
xt 执行标记(execution token),在大部分 Forth 系统中占 1 个单元,对应一个词执行时的行为
d 双精度有符号整数;一般的占 2 个单元的元素
ud 双精度无符号整数

基本的栈操作

基本的栈操作用到的词如下:

单精度元素的栈操作:
DUP		( n -- n n )		\ 复制栈顶元素。
DROP	( n -- )			\ 抛弃栈顶元素。
SWAP	( a b -- b a )		\ 交换栈顶的两个元素。
OVER	( a b -- a b a )	\ 将栈内第二项元素复制到栈顶。
ROT		( a b c -- b c a )	\ 将栈内第三项元素移动到栈顶。
-ROT	( a b c -- c a b )	\ 将栈顶元素移动到栈内第三项(ROT 的逆操作)。
PICK	( .. n2 n1 n0 index -- .. n0 n_index )	\ 复制栈内第 index 项元素到栈顶。
ROLL	( .. n2 n1 n0 index -- .. n0 n_index )	\ 将栈内第 index 项元素移动到栈顶。
NIP		( a b -- b )		\ 抛弃栈内第二项元素。
TUCK	( a b -- b a b )	\ 将栈顶元素复制为栈内第三项。

双精度元素的栈操作:
2DUP	( d -- d d )
2DROP	( d -- )
2SWAP	( da db -- db da )
2OVER	( da db -- da db da )
2ROT	( da db dc -- db dc da )

下面两个词除在调试程序时之外,很少使用:
DEPTH	( a1 a2 ... an -- a1 a2 .. an n )	\ 获得栈深。
.S	( [s] -- [s] )			\ 打印栈内所有元素(不弹出)。

显然,这些词中有些词可用其他的词代替,如 NIPSWAP DROP 等价, TUCKSWAP OVER 等价。如果一个 Forth 系统没有其中的某些词,可以——

定义新词

冒号定义

定义新词最常使用的词是 :;。这种定义方式称作冒号定义 (colon definition):

: w		( -- ) 	\ 开始对新词 w 的定义。
;		( -- )	\ 结束一个词的定义。

\ 下面三个词用在冒号定义中,用于在运行定义的词时提前退出当前词对应的程序,
\ 常与分支结构结合使用。
EXIT	( -- )	\ 退出当前程序。
				\ 该词在 DO...LOOP 循环里使用时,
				\ 需要先用词 UNLOOP 将循环变量抛弃。
ABORT	( -- )	\ 中止当前程序,清空栈和返回栈,退回到解释器。
ABORT" string"	( f -- )	\ 如果 f 为 true(非零),
							\ 则打印 string,然后执行 ABORT 中止当前程序;
							\ 如果 f 为 false(零),则不进行任何操作。

\ 下面三个词用于维护词典。
FORGET w	( -- )	\ 删除词 w 及在其之后定义的所有词。
					\ 如果词典中有多个名为 w 的词,
					\ 则删除最后一个 w 及在其之后定义的所有词。
MARKER mw	( -- )	\ 定义一个名为 mw 的标记词。
					\ 执行 mw 时,删除 mw 本身及在其之后定义的所有词。
WORDS		( -- )	\ 查看目前定义的词

冒号定义中,新词的名称 w 紧接着 : 出现;定义结束后,执行 w 的效果等同于执行定义时 w 后面的词序列的效果。例如:

: nip2  swap drop ;↵  ok
: tuck2  swap over ;↵  ok

\ 定义之后,这两个词可以使用,与标准的 NIP 和 TUCK 完全相同:
1 2 .s↵ <2> 1 2  ok
nip .s↵ <1> 2  ok
. 2  ok	\ 清空栈
1 2 .s↵ <2> 1 2  ok
nip2 .s↵ <1> 2  ok	\ nip2 与 nip 作用完全相同
. 2  ok	\ 清空栈

1 2 3 .s↵ <3> 1 2 3  ok
tuck .s↵ <4> 1 3 2 3  ok
. . . . 3 2 3 1  ok
1 2 3 .s↵ <3> 1 2 3  ok
tuck2 .s↵ <4> 1 3 2 3  ok	\ tuck2 与 tuck 作用完全相同
. . . . 3 2 3 1  ok

定义词时可以任意换行。在定义状态下换行时,提示符 ok 会变为 compiled,以提示用户正在定义词:

: *.↵  compiled
*↵  compiled
.  compiled
;↵  ok
3 2 *. 6  ok

正如上例中所显示的,词的名称可以包含符号。词可以重名,执行时,解释器会执行最新定义的词。例如:

test↵  ? unknown word test
: test  ." Test1" cr ;↵  ok
test↵ Test1
 ok
: test  ." Test2" cr ;↵  ok
test↵ Test2
 ok
: test  ." Test3" cr ;↵  ok
test↵ Test3
 ok
words↵
[ ] ' @ ! < > = + - * / . ? , i j k l # : ; immediate 2constant
... 
i2c-recv ch453-wr ctrloutp terminal test test test
 ok
forget test↵  ok			\ 删除最后定义的那个 test
test↵ Test2
 ok
forget test test↵ Test1	\ forget test 和 test 之间没有回车也可以。
							\ 回车的作用只是告诉解释器解释输入的一行,
							\ 并无空白之外的语法作用。
 ok
forget test test↵  ? unknown word test

其中,词 ." 的作用是打印跟在后面,直到下一个 " 的字符。

可以使用数字作为词的名称。此时解释器遵循前面已经介绍过的行为:先在词典中查找,找不到时再试图解释数字。

: 1  1 . ;↵  ok		\ 冒号定义中的 “1” 仍然是数字
1 1  ok				\ 此时的 “1” 已经是一个词
1 2 + . 1  ? stack	\ 解释器将 “1” 作为词而不是数字使用,因此打印出了一个 1;
						\ 到 + 时,栈内元素数不够了,因此报错。
forget 1  ok			\ 删除词 “1”
1 2 + . 3  ok			\ “1” 恢复成了数字。

有时常常需要递归定义(在一个冒号定义的词里面调用它自己)。然而,从上面数字的例子可以看出。冒号定义尚未结束时无法用词名引用正在定义的词本身。此时需要用 RECURSE 引用词本身3

RECURSE	( ... -- ... )	\ 引用正在定义的词本身。

例如最经典的计算阶乘的例子:

: factorial ( +n1 -- +n2 )
   dup 2 < if 			\ 如果 +n1<2(=1)
      drop 1 exit		\ 则返回值为 1
   then					\ (THEN 可理解为 END IF)
   dup 1- recurse *	;	\ 否则返回值为:+n1 乘以 (+n1-1) 的阶乘

常量与变量的定义和使用

常量、变量和值也是词。定义常量使用的词是 CONSTANT4

CONSTANT name	( n -- )	\ 定义一个名为 name 的常量。
				\ 执行 name 时,会在栈中压入数字 n。

如:

1000 constant thousand↵  ok
thousand 1 + . 1001  ok

定义变量使用词 VARIABLE,给变量赋值使用词 !,取变量的值使用词 @

VARIABLE name	( -- )	\ 定义一个名为 name 的变量。
						\ 系统分配 1 个单元的空间用于存储变量的值。
						\ Forth 系统也许会将变量的初始值置为 0。
						\ 执行 name 时,会将存储变量值的单元的地址压入栈。
!	( n a-addr -- )		\ 将 n 存储到地址 a-addr。
@	( a-addr -- n )		\ 取出地址 a-addr 中存储的值。

下列词有时也会使用:

+!	( n a-addr -- )		\ 将地址 a-addr 中存储的值加 n。
?	( a-addr -- )		\ 取出地址 a-addr 中存储的值,并将其打印出来。
						\ 其定义相当于 : ?  @ . ;

例:

variable a↵  ok
1000 a !↵  ok
a ?↵ 1000  ok
a @ 1 + . 1001  ok
a @ a @ + a !↵  ok
a ?↵ 2000  ok

提示:分析这一程序片段时,可将 a @ 看作一个整体(取变量 a 的值);将 a ! 看作一个整体(将栈顶元素存入变量 a)。

系统变量 BASE 存储解释器当前的数制。

BASE	( -- a-addr )	\ a-addr 中存储解释器当前的数制(2...36)。

显然,HEXDECIMAL 可以这样定义:

base ?↵ 10  ok	\ 注意定义时解释器当前的数制应为 10。
				\ 也可用 #10 和 #16 指定定义中数字的数制。
: hex  16 base ! ;↵  ok
: decimal  10 base ! ;↵  ok

UNUSED 可用来获得剩余内存的数量:

unused . 30928  ok
variable a↵  ok
unused . 30913  ok	\ 变量 a 的名称、代码、数据共占用 16 字节

值的定义与使用

操作变量与操作内存任意地址的方式完全相同,这提供了最大的灵活性。然而有时,只需要能够更改的单个的值而不需要其地址,亦即操作方式类似 CONSTANT 但可以更改。此时可使用 VALUE 来定义这种值。

VALUE name	( n -- )	\ 定义一个名为 name 的值,且将栈顶元素 n 存入其中。
						\ 执行 name 时,会将 n 压入栈。
TO name		( x -- )	\ 修改 name 的值:将栈顶元素 x 存入 name。

例:

1 value va↵  ok
va . 1  ok
100 to va↵  ok
va . 100  ok

双精度数的常量、变量与值

双精度数的常量、变量与值使用下面的词定义:

2CONSTANT name	( d -- )	\ 定义一个名为 name 的双精度常量。
							\ 执行 x 时,会在栈中压入双精度数字 d。
2VARIABLE name	( -- )		\ 定义一个名为 name 的双精度变量。
2VALUE name		( d -- )	\ 定义一个名为 name 的双精度值,将双精度元素 d 存入其中。

使用下面的词操作双精度的变量:

2!		( d a-addr -- )		\ 将双精度数 d 存储到地址 a-addr。
2@		( a-addr -- d )		\ 取出地址 a-addr 中存储的双精度数。

改变双精度值仍然使用词 TO。此时 TO 要求栈中有一个双精度数(所以前一节中 TO 的栈表示法中,执行前栈的内容用 x 表示)。

内存分配(数组)与操作

前面提到了 VARIABLE 会定义一个变量,并为其分配 1 个单元的空间。使用 CREATEALLOT 两个词,可以分配多于 1 个单元的空间:

CREATE name	( -- )			\ 定义一个名为 name 的词,但不分配空间。
							\ 执行 name 的缺省行为是将其后面紧跟的数据区域的地址压入栈。
ALLOT		( n -- )		\ 分配 n 个寻址单位(字节)的存储空间(不改变其值)。
							\ 大部分系统中的寻址单位为字节,My4TH Forth 也是如此。
							\ 以下描述中混用“寻址单位”和“字节”,除非需要区分。
CELLS		( n1 -- n2 )	\ 返回 n1 个单元对应的字节数 n2。
CELL		( -- n )		\ 返回 1 个单元对应的字节数。
CELL+		( a-addr1 -- a-addr2 )	\ 返回 a-addr1 的下一个单元的地址 a-addr2
ERASE		( c-addr u -- )	\ 将 c-addr 开始的 u 个字节的值设置为 0。
DUMP		( addr u -- )	\ 打印内存中从 addr 开始 u 个字节的内容。

显然,VARIABLE 可以像这样定义(没有将初始值设置为 0):

: variable \ name ( -- )
   create cell allot ;	\ 执行 variable x 时,执行到 create 会在输入流中取得 x

使用这一方式,可以定义数组。如

create weekrain  7 cells allot

可以定义一个 7 个单元的存储空间。正如其名称所示,weekrain 可用来存储一周的降水量。如果我们定义 7 个常数来代表一周七天——

0 cells constant sun
1 cells constant mon
2 cells constant tue
3 cells constant wed
4 cells constant thu
5 cells constant fri
6 cells constant sat

就可以方便地将存储空间当作数组使用。如

weekrain 7 cells erase		\ 初始值清零
3 weekrain tue + !			\ 周二下了 3mm 雨
4 weekrain tue + +!			\ 周二又下了 4mm 雨

weekrain tue + ?↵ 7  ok	\ 周二的总降水量是 7mm

进一步地,如果想拥有更清晰的语义,可以进行如下定义:

variable today						\ 记录当天在数组中的位移
: is-today ( offset -- ) today ! ;	\ is-today 用于将当天的位移存入 today
: today-here ( -- addr )			\ today-here 用于获得数组中存储当天的元素的地址
   weekrain today @ + ;
: clear-rain ( -- ) 0 today-here ! ;	\ clear-rain 用于清除当天降水记录
: fall ( n -- ) today-here +! ;		\ fall 用来将一场雨的降水量添加到当天记录中
: today-all ( -- n ) today-here @ ;	\ today-all 返回当天总降水量

有了这些定义,就可以用下面的方式来记录降水量:

fri is-today
clear-rain
2 fall
3 fall
1 fall
2 fall

today-all . 8  ok

如果想在定义存储空间的同时给其赋初值,可以使用词 ,5

,	( n -- )	\ 分配 1 个单元的空间,并将这一空间初始化为 n。

设置初始值为 0 的 variable 可以这样定义:

: variable \ name ( -- )
   create 0 , ;

如使用顺丰快递从北京向上海邮寄快件,1000g 以内邮费为 23 元,超过 1000g,每 500g 邮费增加 5 元,则 9500g 以内,500g 为单位的顺丰快递邮费表可定义如下:

create post-table
   23 , 23 , 23 , 28 , 33 , 	\ 0 ~ 2000g 
   38 , 43 , 48 , 53 , 58 ,  	\ 2500 ~ 4500g
   63 , 68 , 73 , 78 , 83 ,		\ 5000 ~ 7000g
   88 , 93 , 98 , 103 , 108 ,	\ 7500 ~ 9500g

有了这一表格,计算以 g 为单位的顺丰快件的邮费的程序如下:

: grams ( weight -- )
   1- 500 / 1+ 		\ 重量为 500g 的整数倍。 词 1- 将栈顶元素减一, 1+ 将栈顶元素加一。 
   cells			\ 重量在表格中的位移。
   post-table +		\ 求出重量的地址
   @				\ 取邮费的值
   . ." yuan" cr ;	\ 打印邮费

这一程序的执行效果如下:

100 grams↵ 23 yuan
 ok
1700 grams↵ 33 yuan
 ok
4800 grams↵ 63 yuan
 ok

字符操作

8 位或 16 位机上一般的 Forth 系统中,一个单元的大小往往为 16 位(2 个字节),而一个字符的大小往往为 8 位(1 个字节)。以字符(1 个字节)为单位对内存进行操作的词如下:

C!		( c c-addr -- )			\ 将一个字符的值 c 存储到 c-addr。
C@		( c-addr -- c )			\ 从地址 c-addr 取出一个字符的值。
C,		( c -- )				\ 分配一个字符的空间,并将这一空间初始化为 c。
CHARS	( n1 -- n2 )			\ 返回 n1 个字符对应的寻址单位数 n2。
CHAR+	( a-addr1 -- a-addr2 )	\ 返回 a-addr1 的下一个字符的地址 a-addr2。

显然,在 My4TH Forth 中,CHARS 不做任何操作,CHAR+1+ 等同。

内存的分配,堆指针与对齐

CREATEALLOT,C,在一段连续的内存空间(一般称作(heap))6中连续地分配内存。因此,CREATE 后面才能用ALLOT,C, 继续分配内存。

堆中内存从低地址向高地址分配,堆指针指向空闲的第一个地址:

HERE	( -- addr )	\ 返回堆指针的地址。
UNUSED	( -- u )	\ 返回堆中可分配内存的字节数。

某些体系结构要求单元在内存中对齐(为机器字长的整倍数)。标准规定使用 CREATE 定义词时,内存地址会对齐。下列词用于人工对齐,往往在需要处理字符的程序中用到:

ALIGN	( -- ) \ 如果堆指针未对齐,则保留足够多的空间使之对齐。
ALIGNED ( addr -- a-addr ) \ 返回大于等于 addr 的第一个对齐指针。

My4TH 的体系结构不要求对齐。因此,My4TH Forth 中的 ALIGNALIGNED 两词不进行任何改变状态的操作。

设置初始值为 0 的 variable 也可以这样定义:

: variable \ name ( -- )
   align here 0 , constant ;

BUFFER: 可以用来分配一段缓冲区:

BUFFER: name ( u -- )		\ 建立一个名为 name 的缓冲区,
							\ 并分配 u 个字节的空间给这个缓冲区。

BUFFER: 分配的缓冲区可以存在于其他空间中。当然如果用单一、连续的堆空间管理内存的话,BUFFER: 可以定义为

: buffer:  create allot ;

定义词,定义时行为与实例行为,CREATEDOES>

从上面可以看出,有些词(如 :CREATEVARIABLE 等)用来定义其他的词。这些词称作定义词 (defining word)。使用定义词定义其他词时执行的操作称作定义时行为 (defining behavior),执行被定义的词时执行的操作称作实例行为 (instance behavior)。如词 VARIABLE 的定义时行为是分配 1 个单元的内存并将其清零,实例行为是将定义时分配的内存单元的地址压入栈。

使用下面的格式,可以定义一个定义词:

: name  CREATE	( 定义时行为 )
        DOES>	( 实例行为 ) ;

执行实例行为前,name 对应的数据区域地址会压入栈内(这也是 没有 DOES>CREATE 的缺省行为)。

CONSTANT 可以这样定义:

: constant
   create ,		\ 定义时行为:分配 1 个单元的内存,将栈顶元素存入其中
   DOES> @ ;	\ 实例行为:取出中存储的值

CREATEDOES>7 是 Forth 中非常灵活的一项工具,可以用来定义复杂的数据结构。下面是一个字符型二维数组的例子:

: array ( #rows #cols -- )
   create dup , * allot		\ 定义时行为:将 #cols 存入内存,然后分配 #rows * #cols 的空间
   does> ( member: row col -- c-addr )	
   rot over @ * + + cell+ ;

array 的运行时行为如下:

			( row col addr )
	rot		( col addr row )
	over	( col addr row addr )
	@		( col addr row #cols )
	*		( col addr row*#cols )
	+		( col addr+row*#cols )
	+		( addr+row*#cols+col )
	cell+	( addr+row*#cols+col+cell )	\ 加的 cell 为 #cols 的存储单元

这样,定义一个 4*4 字节的数组 board 可以用8

4 4 array  board

操作数组的某个元素的方式为:

2 1 board c@

算术、关系及位运算

常用的算术运算词

(单精度)
+		( n1 n2 -- n3 )		\ n3=n1+n2
-		( n1 n2 -- n3 )		\ n3=n1-n2
*		( n1 n2 -- n3 )		\ n3=n1*n2
/		( n1 n2 -- n3 )		\ n3=n1/n2
*/		( n1 n2 n3 -- n4 )	\ n4=n1*n2/n3
MOD		( n1 n2 -- n3 )		\ n3=n1 mod n2
/MOD	( n1 n2 -- n3 n4 )	\ n3=n1 mod n2, n4=n1/n2
*/MOD	( n1 n2 n3 -- n4 n5)	\ n4=(n1*n2) mod n3, n5=n1*n2/n3
NEGATE	( n1 -- n2 )		\ n2=-n1
ABS		( n1 -- n2 )		\ n2=|n1|
1+		( n1 -- n2 )		\ n2=n1+1
1-		( n1 -- n2 )		\ n2=n1-1
2*		( n1 -- n2 )		\ n2=n1*2
2/		( n1 -- n2 )		\ n2=n1/2
MAX		( n1 n2 -- n3 )		\ n3=max(n1, n2)
MIN		( n1 n2 -- n3 )		\ n3=min(n1, n2)

(混合及双精度)
S>D		( n -- d )			\ d=n
D>S		( d -- n )			\ n=d,d 必须在 n 能表示的范围内
M+		( d1 n -- d2 )		\ d2=d1+n
D+		( d1 d2 -- d3 )		\ d3=d1+d2
D-		( d1 d2 -- d3 )		\ d3=d1-d2
M*		( n1 n2 -- d )		\ d=n1*n2
UM*		( u1 u2 -- ud )		\ ud=u1*u2
M*/		( d1 n1 +n2 -- d2 )	\ d2=d1*n1/(+n2)
FM/MOD	( d1 n1 -- n2 n3 )	\ n2=d1 mod n1, n3=d1/n1	(下取整除法(floored division))
SM/REM	( d1 n1 -- n2 n3 )	\ n2=d1 mod n1, n3=d1/n1	(对称除法(symmetric division)) 
UM/MOD	( ud u1 -- u2 u3 )	\ u2=ud mod u1, u3=ud/u1
DNEGATE	( d1 -- d2 )		\ d2=-d1
DABS	( d1 -- d2 )		\ d2=|d1|
DMAX	( d1 d2 -- d3 )		\ d3=max(d1, d2)
DMIN	( d1 d2 -- d3 )		\ d3=min(d1, d2)
D2*		( d1 -- d2 )		\ d2=d1*2
D2/		( d1 -- d2 )		\ d2=d1/2

*//MOD 的中间结果是双精度整数;M*/ 的中间结果占据 3 个单元9

下取整除法和对称除法在除数和被除数之一为负数且有余数时有区别,例子见下表:

被除数 a 除数 b 余数 r(下取整除法) q(下取整除法) 余数 r(对称除法) q(对称除法)
10 7 3 1 3 1
-10 7 4 -2 -3 -1
10 -7 -4 -2 3 -1
-10 -7 -3 1 -3 1

两种除法都满足 b × q + r = a。进一步的解释参见 Forth 2012 标准 3.2.2.1 节

常用的位运算词

INVERT	( n1 -- n2 )		\ 对 n1 按位取反。
AND		( n1 n2 -- n3 )		\ 返回 n1 和 n2 按位与的结果。
OR		( n1 n2 -- n3 )		\ 返回 n1 和 n2 按位或的结果。
XOR		( n1 n2 -- n3 )		\ 返回 n1 和 n2 按位异或的结果。
LSHIFT	( n1 u -- n2 )		\ 返回 n1 左移 u 位的结果。
RSHIFT	( n1 u -- n2 )		\ 返回 n1 右移 u 位的结果。

常用的关系运算词

<		( n1 n2 -- f )	\ 若 n1<n2, 返回真;否则返回假。
<=		( n1 n2 -- f )	\ 若 n1<=n2, 返回真;否则返回假。
<>		( n1 n2 -- f )	\ 若 n1不等于n2, 返回真;否则返回假。
=		( n1 n2 -- f )	\ 若 n1=n2,返回真;否则返回假。
>		( n1 n2 -- f )	\ 若 n1>n2, 返回真;否则返回假。
>=		( n1 n2 -- f )	\ 若 n1>=n2, 返回真;否则返回假。
0<		( n -- f )		\ 若 n<0, 返回真;否则返回假。
0<>		( n -- f )		\ 若 n不等于0, 返回真;否则返回假。
0=		( n -- f )		\ 若 n=0, 返回真;否则返回假。
0>		( n -- f )		\ 若 n>0, 返回真;否则返回假。
U<		( u1 u2 -- f )	\ 若 u1<u2, 返回真;否则返回假。
U>		( u1 u2 -- f )	\ 若 u1>u2, 返回真;否则返回假。
WITHIN	( n1 n2 n3 -- f)	\ 若 n1≥n2且n2<n3,返回真,否则返回假。
							\ n1 n2 n3 必须同为有符号数或同为无符号数。
TRUE	( -- f )		\ 返回真。
FALSE	( -- f )		\ 返回假。

D0< 	( d -- f ) 		\ 若 d<0, 返回真;否则返回假。
D0=		( d -- f ) 		\ 若 d=0, 返回真;否则返回假。
D<		( d1 d2 -- f )	\ 若 d1<d2, 返回真;否则返回假。
D=		( d1 d2 -- f )	\ 若 d1=d2, 返回真;否则返回假。
DU<		( ud1 ud2 -- f )	\ 若 ud1<ud2, 返回真;否则返回假。

标志的取值为。一般地,假用值为 0 的单元表示,真用值不为 0 的单元表示。因此,0= 执行的运算也就是逻辑非。

如果真用所有位均为 1(也就是有符号的值为 -1)的单元表示,假用所有位均为 0(也就是值为 0)的单元表示,则称这一标志为合式的(well-formed),否则称这一标志为不合式的(ill-formed)。常用的关系运算词均返回合式标志。

显然,合式标志可用位运算词 ANDORXOR 进行运算。某些词返回不合式标志,这类标志要参与位运算,需要使用 0<>0= 将其转换为合式标志。

分支与循环

条件分支 (IFELSETHEN)

条件分支使用的词如下10

IF		( rt: f -- )	\ 如果 f 为真,则执行 IF 后面紧跟着的指令
						\ 直到遇到 ELSE 或 THEN 为止。
	                    \ 此时如果有 ELSE,则跳过 ELSE 后面的指令直到 THEN。
	                    \ 如果 f 为假,执行 ELSE 或 THEN 后面的指令。
ELSE	( rt: -- )		\ 用在 IF...ELSE...THEN 结构中,后跟 f 为假时执行的语句。
THEN	( rt: -- )		\ 用于结束一个 IF...ELSE...THEN 结构。

11

: cond ( f -- )
   if 
      ." True." 
   else
      ." False."
   then  cr ;

可以判断栈顶的标志是不是 0,并打印出 True 或 False 。

前面提到的关系运算词,在条件分支中有广泛的应用。上面程序的执行效果如下:

1 2 > cond↵ False.	\ 1 大于 2 么?否。
 ok
2 1 > cond↵ True.	\ 2 大于 1 么?是。
 ok

需要注意的是,IF 会用掉栈顶的元素,关系运算词会用掉栈顶的一个或两个元素。故在条件分支嵌套时,DUP 大有用武之地。如下列检查收缩压是否正常的程序:

: ?normal ( n -- )
   dup 70 < if
      ." Too low." 
   else
      dup 90 < if
         ." Low."
      else
         dup 130 < if
            ." Normal."
         else
            dup 150 < if
               ." Higher than normal."
            else
               ." Should see doctor."
            then
         then
      then
   then  drop cr ;

上述程序的执行效果如下:

95 ?normal↵ Normal.
 ok
70 ?normal↵ Low.
 ok
145 ?normal↵ Higher than normal.
 ok
160 ?normal↵ Should see doctor.
 ok

要注意的是,一开始 DUP 的值,最后不用的时候,应该 DROP 掉。否则栈内元素会越来越多,导致溢出。调试程序时,应多用 DEPTH ..S 来检查。

在这种多层判断的场合下,用 EXIT 比用 ELSE 能让程序更加清晰:

: (?normal) ( n -- n )
   dup 70 < if  ." Too low." exit then
   dup 90 < if  ." Low." exit then
   dup 130 < if ." Normal." exit then
   dup 150 < if ." Higher than normal." exit then
                ." Should see doctor." ;
: ?normal ( n -- ) (?normal) drop cr ;

有时会遇到栈顶元素非零时程序要做某项工作,否则不做工作的情形。如:

: /check
   dup if
      /
   else
      drop 
   then ;

由于所有的非零数字都被认为是真,所以可以直接用栈顶元素当作 IF 的标志。需要注意的是,如果要使用栈顶元素,那么首先要 DUP。Forth 提供词 ?DUP12 来简化这一问题:

?DUP	( n -- n n | 0 )		\ 如果栈顶元素非 0, 则 DUP; 否则保持栈顶的 0 不变。

因此,上面的程序可写作

: /check
   ?dup if
      /
   then ;

多重分支(CASEOFENDOFENDCASE

多重分支使用的词如下:

CASE	( rt: -- )			\ 用于开始一个 CASE...OF...ENDOF...ENDCASE 结构。
ENDCASE	( rt: n -- )		\ 抛弃栈顶元素 n (一般为多重分支的选择子)并继续执行。
							\ 用于结束一个 CASE...OF...ENDOF...ENDCASE 结构。
ENDOF	( rt: -- )			\ 跳转到 ENDCASE 后面的指令。
OF		( rt: n1 n2 -- n1 )	\ 如果 n2<>n1,则从栈中抛弃 n2,
							\ 跳转到对应的 ENDOF 之后继续执行;
							\ 如果 n2=n1,则从栈中抛弃 n1 和 n2,执行 OF 后面的指令。

多重分支的格式如下:

n CASE
   ( n ) some-forth-code ( n )
   n1 OF forth-code1 ENDOF
   n2 OF forth-code2 ENDOF
   ...
   ( n ) default-forth-code ( n )
ENDCASE ( )

注意 some-forth-codedefault-forth-code 均可为空。但是如果它们不为空,则它们前后需要保留栈中的元素 n

以下是多重分支的一个例子:

: test ( n -- ) case ." Value is "
   1 of ." One" endof
   2 of ." Two" endof
   3 of ." Three" endof
   dup .
endcase ;

运行效果如下:

1 test↵ Value is One ok
2 test↵ Value is Two ok
3 test↵ Value is Three ok
4 test↵ Value is 4  ok

有限循环 (DOLOOP) 与返回栈

最基本的有限循环使用 DOLOOP 及几个类似的词:

DO		( rt: lim n -- )	\ 设循环变量初值为 n,然后执行执行下面的程序,
							\ 直到遇到对应的 LOOP 或 +LOOP。
                        	\ 此时判断循环变量的值,若没有越过 lim-1 与 lim 的边界,
                        	\ 则返回 DO 后面第一个词继续执行;
                        	\ 若越过了 lim-1 与 lim 的边界(循环变量>=lim),
                        	\ 则从 LOOP 或 +LOOP 后第一个词继续执行。
                        	\ 注意 DO 至少执行后面的循环体一次。
                        	\ 如果要在循环体执行前判断 n 和 lim 的关系,需要使用 ?DO。
?DO	 	( rt: lim n -- )	\ 先判断 n 和 lim 的关系。
							\ 若 lim=n,则直接跳到对应的 LOOP 或 +LOOP 后面;
							\ 否则与 DO 相同。
LOOP	( rt: -- )			\ 循环变量加 1,同时用于标记循环的结束位置。
+LOOP	( rt: m -- )		\ 循环变量加 m,同时用于标记循环的结束位置。
I		( rt: -- i )		\ 返回本层循环变量的值。 
J		( rt: -- j )		\ 返回上一层循环变量的值。
K		( rt: -- k )		\ 返回上两层循环变量的值。 
L		( rt: -- l )		\ 返回上三层循环变量的值。
LEAVE	( rt: -- )			\ 退出当前层循环。 
UNLOOP  ( rt: -- )			\ 抛弃当前层循环的状态
							\ (用于在循环中 EXIT 退出当前词的程序之前)。

如下列程序可计算 1…100 的和:

: sum ( -- )
   0 
   101 1 do
      i +
   loop
   . cr ;

运行结果如下:

sum↵ 5050
 ok

下列程序可打印三角形:

: triangle ( lines -- )
   dup 1 < if
      ." Lines cannot be less than 1." cr
      drop exit 
   then
   cr 1 +
   1 do
      i . 9 emit \ 词 EMIT 的作用是打印栈顶元素对应 ASCII 码的字符。TAB 的 ASCII 码为 9.
      i 0 do
         ." *"
      loop
      cr
   loop ;

运行结果如下:

3 triangle↵
1       *
2       **
3       ***
 ok
0 triangle↵ Lines cannot be less than 1.
 ok
5 triangle↵
1       *
2       **
3       ***
4       ****
5       *****
 ok

在 Forth 中,循环变量和循环界限都存储在返回栈中。IJKL 的作用就是从返回栈中取得本层、上一层、大上一层、再大上一层的循环变量的值。

下列词可以直接对返回栈进行操作13

>R		( n -- ) ( R: -- n )	\ 将参数栈栈顶元素弹出,并压入返回栈。 “去返回栈”
R>		( -- n ) ( R: n -- )	\ 将返回栈栈顶元素弹出,并压入参数栈。“从返回栈来”
R@		( -- n ) ( R: n -- n )	\ 将返回栈栈顶元素复制到参数栈。

2>R		( d -- ) ( R: -- d )	\ 将参数栈栈顶的双精度整数(两个元素)弹出,并压入返回栈。
2R> 	( -- d ) ( R: d -- )	\ 将返回栈栈顶的双精度整数(两个元素)弹出,并压入参数栈。
2R@		( -- d ) ( R: d -- d )	\ 将返回栈栈顶的双精度整数(两个元素)复制到参数栈。

RDROP	( R: n -- )				\ 抛弃返回栈栈顶元素。

如果需要利用返回栈暂存结果,需注意如下几点14::

  1. >RR> 应当成对出现;
  2. 在使用 EXIT 或分号离开一个定义之前,返回栈应当恢复成它原来的状态;
  3. 任何在进入有限循环之前压入返回栈的数据在循环中是接触不到的;
  4. 任何在进入有限循环之后压入返回栈的数据会使循环变量 IJKL 变得无法接触。

不定循环

Forth 中的不定循环结构有 BEGINUNTILBEGINAGAINBEGINWHILEREPEAT 三种:

BEGIN 	( -- )		\ 开始一个不定循环。不定循环的结束位置由 UNTIL、AGAIN 或 REPEAT 标记。
UNTIL	( f -- )	\ 若 f 为假,则返回对应的 BEGIN 后面的第一个词继续执行;
					\ 若 f 为真,则退出循环,从 UNTIL 后面的第一个词继续执行。
AGAIN	( -- )		\ 返回对应的 BEGIN 后面的第一个词继续执行(无限循环)。
WHILE	( f -- )	\ 若 f 为假,则退出循环,从对应的 REPEAT 后面的第一个词继续执行;
					\ 若 f 为真,则从 WHILE 后面的第一个词继续执行。
REPEAT	( -- ) 		\ 返回对应的 BEGIN 后面的第一个词继续执行。

需要说明的是 BEGIN condition WHILE body REPEAT 这种循环形式。在这种结构中,WHILE 检查(并弹出)栈顶标志;若标志为真,则执行 body 后重新返回循环的开头执行 condition;若标志为假,则退出循环15

正如上面的命名所示,一般地,程序段 condition 生成需检查的标志,body 中进行实际的操作。如清空栈内所有元素的 CLEAR 可以这样定义16

: clear ( [s] -- )
   begin  depth  while	\ 用栈内元素数作为标志
      drop				\ 若栈内元素数不为 0,则弹出一个...
   repeat ;				\ ...并循环上述操作。

执行标记,立即模式与编译模式,编译时行为与执行时行为

Forth 系统中每个词对应一个执行标记(execution token)。执行标记是一个值,在大部分 Forth 系统中大小为 1 个单元,栈表示法中简写为 xt。执行标记对应词的执行时行为,用下面即将介绍的 EXECUTE 执行一个词的 xt 和在 Forth 解释器中运行一个词的行为完全相同。在 My4TH Forth (以及大部分 Forth 系统)中,一个词的执行标记就是该词的机器码程序的入口地址。

读者也许已经发现,分支与循环使用的词必须在 :; 之间使用。这引出了编译模式与立即模式的概念。

Forth 解释器默认处于立即模式(immediate mode)。此时解释器按“表达式的计算”一节中描述的行为工作,执行输入流中的每一个词。

: 使解释器切换到编译模式(compile mode)。在编译模式下,解释器遇到输入流中的数字和(普通的)词时,会在堆中分配空间,并将对应的代码(code,这里指机器语言的程序)写入该空间(这一过程称作生成(emit)代码。)。具体来说,在 My4TH Forth 中,写入的是数字压栈的代码或转子程序到词对应的执行标记的代码(My4TH 汇编中写作JSR xt)。

有些特殊的词在编译模式下会立即执行,这些词称作立即词(immediate word)。分支与循环使用的词都是立即词,它们在编译模式下生成分支与循环需要的代码17." 也是立即词,在编译模式下,它会将输入流后面到 " 为止的字符串存入堆中,并生成打印它们的代码。

这引入了词的编译时行为(compilation semantics)和执行时行为(execution semantics)的概念。编译时行为就是词在冒号定义中使用时的行为:普通词具有缺省的编译时行为,即在堆中分配空间,并将 JSR xt 写入该空间;立即词具有非缺省的编译时行为,因词而异。执行时行为是执行通过冒号定义新造的词的时候,其中用到的已定义的词对应的行为。词的执行时行为因词而异(对于普通词,就是执行 JSR xt,也就是转子程序到词对应的执行标记,与在立即模式下运行这个词的行为相同)。

这样,在编译模式下,解释器18会现场生成 :; 之间的 Forth 程序对应的代码。与其他解释型语言的解释器不同,编译完成后,在立即模式下执行一个词时,不再有将程序现场翻译成机器码的环节,而是直接执行机器语言的程序。这样,Forth 语言用解释型语言的交互性,实现了编译型语言的运行速度

显然,Forth 词的代码,往往由一系列转子程序(JSR)的指令组成。这种组织代码的方式像用一条线(thread) 将一系列子程序连接起来,称作穿线式代码(threaded code)。穿线式代码有很高的代码密度,十分节约内存。因此 Forth 程序往往比其他高级语言编写的程序更小,非常适用于嵌入式系统。

穿线式代码有不止一种实现方式。严格来讲,上面提到的实现方式称作子例程穿线式代码(subroutine threaded code)。常见的其他方式尚有间接穿线式代码(indirect threaded code)和直接穿线式代码(direct threaded code)。这两种实现方式下,定义的程序的代码部分由一系列地址而非直接由 JSR 指令组成,Forth 系统中使用专门的机器语言子程序(一般称作 ENTER)来执行代码部分。这几种实现方式各有优缺点,可参考这里

My4TH Forth 中,词典在内存中的格式如下:

		  +----------------------------------+
		  |                   xt             ↓                     xt
		+----+------+---------+----+----+    +----+------+---------+----+----+
content	|link|length|word_name|code|data|    |link|length|word_name|code|data|
length	| 2  |  1   | length  | x1 |0~x2|    +----+------+---------+----+----+
		+----+------+---------+----+----+
				word n									word n-1

其中 link 是指向上一个词开始地址的指针。length 的最高位标识一个词是否为立即词。每一个词的 xt 是它的代码部分的起始地址。data 部分由 ALLOT,C, 等词分配19。词在词典中形成链表,解释器可以方便地对词典进行遍历。为加快查找词的速度,My4TH Forth 中的词典有 8 个入口点(entry point),并将词按名称长度分配到每个入口点。

控制解释器模式常用的词如下:

: name		( -- )			\ 定义一个词 name,定义以 ; 结束。
:NONAME		( -- xt )		\ 定义一个无名词,将其 xt 压入栈中。定义以 ; 结束。
							\ : 和 :NONAME 会让解释器进入编译模式。
;			( -- )			\ 结束一个定义。将解释器切换回立即模式。
[			( -- )			\ 将解释器切换到立即模式。
]			( -- )			\ 将解释器切换到编译模式。
LITERAL		( x -- ) 	\ 将栈顶元素作为字面量(也就是定义中直接出现的数字)编译进程序。
2LITERAL	( x1 x2 -- )	\ 将栈顶的两个元素作为字面量编译进程序。
IMMEDIATE	( -- )			\ 将最近定义的词标记为立即词。
STATE		( -- a-addr )	\ a-addr 中存储解释器的当前模式,真为编译模式,假为立即模式。
							\ 能够改变 STATE 的值的词只有:
							\ `:`、`;`、`ABORT`、`QUIT`、`:NONAME`、`[` 和 `]`。

[] 常在编译模式下使用,用于将解释器临时切换到立即模式。

很多时候,为了执行效率,要将某些多次运行中结果不变的操作(尤其是常数运算)在编译时执行。此时可用 [] 将常数运算部分包裹起来(注意它们都是词,所以前后必须都有空格)。

此时常用的方法是:[] 部分的运算将 1 个结果推入栈,然后用 LITERAL20 将栈顶元素编译进程序。如:

: test1  [ 3 4 * ] literal ;

: test2  12 ;

生成的代码完全一样。甚至

12 : test3  literal ;

或者

3 4 * : test4  literal ; 

也可以生成完全一样的代码,都是将常数 12 直接压栈。而

: test-slow  3 4 * ;

的效果也是将 12 压栈,但是每执行一次就要计算一次乘法,所以其速度大大慢于上述四个例子。

注意 :NONAME 可能在编译一开始(而不是遇到 ; 时)就将 xt 压栈。所以上述 LITERAL 的例子中,test3test4 不能用 :NONAME 代替 :

对执行标记的基本操作

执行标记的基本用途当然是执行:

EXECUTE		( xt -- )	\ 执行 xt 标记的代码。

My4TH Forth 中,执行标记是一段机器码程序的入口地址。My4TH Forth 允许执行从内存任意地址开始的代码。例如,在 My4TH 中,复位程序的入口是 $2D00。所以 $2d00 execute 可以触发系统复位:

$2d00 execute↵ EEPROMs: xxxxxx
My4TH(xxx) v1.4 / xx MHz

获取词的执行标记可以使用词 '

' name		( -- xt ) 	\ 获取词 name 的执行标记。
[']	name				\ 在编译时使用,作用与 [ ' name ] LITERAL 相同。

显然,在立即模式下,' xyz EXECUTExyz 的作用相同。

下面是 'EXECUTE 的几个简单例子:

: greet  ." Hello, I speak Forth." ;↵  ok     
greet↵ Hello, I speak Forth. ok
' greet u.↵ 33848  ok
' greet execute↵ Hello, I speak Forth. ok 
' greet 32 dump↵	
8438: 1E 18 52 84 48 65 6C 6C 6F 2C 20 49 20 73 70 65  ..R.Hello, I spe         
8448: 61 6B 20 46 6F 72 74 68 2E 00 01 0D 84 01 0C 3C  ak Forth.......<         
 ok    
 ' greet 16 - 48 dump↵
8428: 70 CF B6 39 1C 45 76 80 3A 63 05 67 72 65 65 74  p..9.Ev.:c.greet         
8438: 1E 18 52 84 48 65 6C 6C 6F 2C 20 49 20 73 70 65  ..R.Hello, I spe         
8448: 61 6B 20 46 6F 72 74 68 2E 00 01 0D 84 01 0C 3C  ak Forth.......<         
 ok    

下面的例子可以更直观地说明普通词 ' 和立即词 ['] 的区别:

: test  ." Test." ;↵  ok 
: test'  ' test ;↵  ok 
: test[']  ['] test ;↵  ok 
test'↵  ? syntax		\ test 执行到 ' 时,需要输入流中的 name. 
						\ 此时输入流中没有 name,因此报错
test' test↵ Test. ok	\ 输入流中有 test,此时执行的效果相当于 " ' test test ".
						\ ' test 获取 test 的 xt, 第二个 test 在屏幕上打印 "Test."
.s↵ <1> -31188  ok		\ 栈内的内容是 ' test 留下的 test 的 xt.
. -31188  ok
test[']↵  ok 			\ ['] test 在编译时就取得了 test 的 xt. 运行时将其压栈。
.s <1> -31188  ok

与将数据放进内存的 , 相对应,进行对执行标记“编译”这一操作(在使用子例程穿线式代码的 My4TH Forth 中,也就是将执行标记对应的 JSR xt 放在 HERE 处并分配相应内存)的词是 COMPILE,21

COMPILE,	( xt -- ) 	\ 将运行执行标记 xt 的代码片段编译进程序。

CREATE 创建的词也有执行标记。可以用 >BODY22 来获得 CREATE 创建的词的数据区域的地址:

>BODY	( xt -- a-addr )	\ 从 xt 获得 CREATE 创建的词(后面紧跟)的数据区域的地址,
							\ 也就是刚 CREATE 该词之后 HERE 的地址。

推迟执行(POSTPONE)

立即词在编译时立即执行。然而有时需要将立即词的编译时行为编译进程序,此时应使用 POSTPONE

POSTPONE word	( -- )	\ 将词 word 的编译时行为编译进正在定义的程序,
						\ 使其推迟到程序执行时执行。

例如,想给前面提到的 THEN 起一个别名 ENDIF,可以这样做:

: endif  postpone then ; immediate↵  ok	\ then 本身是立即词,因此 endif 也应该是立即词
: test  0> if ." Greater than zero." else ." Not greater than zero." endif ;↵  ok
100 test↵ Greater than zero. ok
-100 test↵ Not greater than zero. ok	\ endif 与 then 起的作用完全相同

注意在这个例子中 ENDIF 是立即词,(在冒号定义中)使用 ENDIF 时执行的是 then 的编译时行为。

如果 POSTPONE 一个普通词,那么推迟执行的是这个普通词缺省的编译时行为(在堆中分配空间,并将 JSR xt 写入该空间)。

例如,Gforth 系统提供了 +DO

+DO	 	( lim n -- )	\ 先判断 n 和 lim 的关系。
						\ 若 lim<=n,则直接跳到对应的 LOOP 或 +LOOP 后面;否则与 DO 相同。

如果想在 My4TH Forth 中使用这一词,可以这样定义:

: +do ( lim n -- ) POSTPONE over  POSTPONE min  POSTPONE ?do  ; immediate

其中 overmin 是普通词,?do 是立即词,+do 本身是立即词。在冒号定义中使用 +DO 这个立即词时,会将 overmin 编译进程序(执行它们缺省的编译时行为),然后执行 ?do 的编译时行为。

因此,下面的两个词的作用完全相同:

: test1 ( lim n -- ) +do  i . loop ;
: test2 ( lim n -- ) over min ?do  i . loop ;

前面提到的 COMPILE, 可以这么定义23

: compile, ( xt -- ) postpone literal  postpone execute ;

[COMPILE]24 只推迟执行立即词:

[COMPILE] word	( -- )	\ 如果词 word 是普通词(具有缺省的编译时行为),
						\ 则相当于 word 本身;
						\ 如果词 word 是立即词(具有非缺省的编译时行为),
						\ 则相当于 POSTPONE word。

向量执行

可以使用 ' 取得执行标记并将其保存在变量中,后续使用 EXECUTE 执行。这一技巧称作向量执行(vectored execution)。例如:

variable v↵  ok                                                                  
' greet v !↵  ok	\ greet 是“对执行标记的基本操作”一节中定义的
v @ execute↵ Hello, I speak Forth. ok 

下面的例子中,词 aloha 的行为由变量 'aloha 的内容决定:

: hello  ." Hello" ;
: goodbye  ." Goodbye" ;
variable 'aloha   ' hello 'aloha !
: aloha  'aloha @ execute ;

此时执行 aloha,由于变量 'aloha 中存储的内容为 hello 的执行标记,故其作用与直接执行 hello 相同:

aloha↵ Hello ok

say 的作用为将输入流中下一个词的执行标记存入 'aloha

: say  ' 'aloha ! ;

这样,我们可以用容易理解的方式改变 aloha 的行为:

say goodbye↵  ok
aloha↵ Goodbye ok
say hello↵  ok
aloha↵ Hello ok

这里有两点需要指出:

  1. 前面已多次提到,在定义中使用 ' 这种需要从输入流中取得下一个记号 name 的词,需要的记号会在执行时而不是定义时取得。CREATEDOES> 的行为也符合这一点。

  2. Forth 的词的名称可以包含任何除分隔符外的字符,如变量 'aloha。注意变量 'aloha' 后面没有空格,这里的 ' 只是用来提示程序的读者,变量中存储的内容是执行标记。

向量执行更典型的用法是将一系列词的执行标记存储在表格中,根据某一数值选择性执行。例如:

: range ( n min max -- n1 ) \ if min<=n<max, n1=n; else n1=0
   2 pick -rot within 0= if  drop 0  then ;
: button? ( -- button )     
   key 48 -	\ 词 KEY 等待键盘输入,并返回按下按键的 ASCII 码。
			\ ASCII 码减去 48,将 0~9 的字符转换为数字。
   1 6 range ;	\ 如果按下的按键在 1~5 之间,则返回按键代号;否则返回 0。

\ 为按键 1~5 执行的操作以及按下其他按键执行的操作定义词。
: btn1 ." Button 1 Pressed." ;
: btn2 ." Button 2 Pressed." ;
: btn3 ." Button 3 Pressed." ;
: btn4 ." Button 4 Pressed." ;
: btn5 ." Button 5 Pressed." ;
: btn0 ." Others Pressed." ;  

\ 创建数组 buttons,将上述词的 xt 存入数组
create buttons  ' btn0 , ' btn1 , ' btn2 , ' btn3 , ' btn4 ,
   ' btn5 ,     
    
\ 词 button 首先取得按键编号(0 或 1~5),然后从数组中取得对应词的 xt 并执行。
: button ( -- ) button? cells buttons + @ execute ;  

按下 1~5 的按键时,上述程序会执行 btn1~btn5 中对应的词;按下其他按键时,会执行 btn0

显然,这一程序也可用 CASE 实现25。不过向量执行更加灵活:上述例子中数组 buttons 中的元素在运行时可随意更改,而 CASE 想要实现随意更改,必须再套一层向量执行,或使用下面将要介绍的 DEFER。这增加了程序的复杂程度,不是最佳的实现方式。

上述例子中,如果词 btn1btn5 不在别处使用,也可用 :NONAME 对其进行定义:

\ 为按键 1~5 执行的操作以及按下其他按键执行的操作定义无名词。
:noname  ." Button 5 Pressed." ;
:noname  ." Button 4 Pressed." ;
:noname  ." Button 3 Pressed." ;
:noname  ." Button 2 Pressed." ;
:noname  ." Button 1 Pressed." ;
:noname  ." Others Pressed." ;  
\ 注意 : 和 noname 之间没有空格,否则会定义一个名为 noname 的词
( btn5 btn4 btn3 btn2 btn1 btn0 )
\ 创建数组 buttons,将栈内的 xt 存入数组
create buttons  , , , , , ,	\ 注意之前 :noname 的顺序以及 , 的数量都应当正确

可执行变量,DEFER

有时需要处理单个的执行向量。此时使用 DEFER 更加清晰简洁:

DEFER defer-name	( -- )	\ 定义一个名为 defer-name 的可执行变量。
							\ 可执行变量可以像其他词一样执行。
							\ 在 My4TH Forth 中,其缺省行为为空操作。
IS defer-name	( xt -- )	\ 将 xt 存入可执行变量 defer-name。
							\ 执行 defer-name 时,执行 xt 开始的代码。
ACTION-OF defer-name ( -- xt )	\ 取得可执行变量 defer-name 的执行行为的 xt。
DEFER@		( xt1 -- xt2 )	\ xt1 是可执行变量本身的 xt,可通过 ' defer-name 取得。
							\ xt2 是可执行变量的执行行为的 xt。
DEFER!		( xt2 xt1 -- )	\ 将 xt1 对应的可执行变量的执行行为设置为 xt2 开始的代码。

使用 DEFER 的一个最典型的场合,是在排序和查找的程序中,由于数据类型的不同,比较两个数据使用的词也不同。下面是一个二分查找的例子(修改自 https://rosettacode.org/wiki/Binary_search#Forth ):

defer (compare)
' - is (compare) \ 对于数字,设置 (compare) 的行为为 -
: mid ( u l -- mid ) tuck - 2/ cell negate and + ;

\ 每个 item 占 1 个单元。
\ item 可以是数字,也可以是指向其他数据类型的地址。
\ 如果是指向其他数据类型的地址,那么需要按需设置 (compare) 的行为。
\ 被查找的数组必须按 (compare) 排好序。
: bsearch ( item end start -- pos found? )
   rot >r ( end start ) ( R:item )
   begin 2dup > while 	\ while end > start
      2dup ( end start end start )
      mid ( end start mid )
      dup @ ( end start mid mid-item )
	  r@ ( end start mid mid-item item ) 
      (compare) 
      0< if ( end start mid )
         nip cell+ ( end mid+1 )
      else 
         rot drop swap ( mid start )
      then 
   repeat 
   ( pos pos ) @ ( pos found-item ) r> ( pos found-item item )
   (compare) if false else true then ( pos found? ) ;
  
测试:
  
create test  2 , 4 , 6 , 9 , 11 , 99 ,
: probe ( n -- ) test 5 cells bounds bsearch . @ . ;
\ BOUNDS ( addr u –- addr+u addr ) 将首地址和长度转换为适合 do 或 ?do 使用的格式。

1 probe↵ 0 2  ok
2 probe↵ -1 2  ok
3 probe↵ 0 4  ok
4 probe↵ -1 4  ok
10 probe↵ 0 11  ok
11 probe↵ -1 11  ok
12 probe↵ 0 99  ok
100 probe↵ 0 99  ok

到这里,读者也许已经自行总结出 Forth 语言最重要的两大特点(来自 Thinking Forth 第一章):

  • 隐式调用:任何一个词从底层来看其实都是子程序调用,这里不需要像汇编语言一样书写出 JSR 或者 CALL 之类。与其他高级语言的关键字、运算符与函数调用相比,Forth 的词也具有更统一的行为:, 这种在其他语言中是分隔符的记号是一个词,+ 这种在其他语言中是运算符的记号是一个词,IF 这种在其他语言中是关键字的记号是一个词,上面定义的 bsearch 这种在其他语言中是函数的记号是一个词,在其他语言中是常量或变量的记号也是一个词。词的实例行为及编译时行为有缺省值,也可以由编程者指定,这提供了最大的灵活性。

  • 隐式数据传递:输入流或者定义中的词之间的数据通过栈传递,不需要显式写出 PUSHPOP,以及其他语言中的参数列表或 return。只要词对栈的作用定义良好,那么程序天然就是模块化的。显然,词之间可以嵌套调用(定义一个词时,可以自由使用其他的词)。

Forth 在语义的明确执行的效率实现的简单三者之间达到了一个较好的平衡。当然,这也让写出可读的程序更大程度上成为了编程者的责任。协调这三者之间的关系,也是编写 Forth 程序的一项重要原则。

字符串操作

字符的编码

My4TH Forth 的字符编码是 ASCII 码。词 CHAR 用于取得字符的编码。词 BL 用于取得空格的编码。

PAD 用来获取一段缓冲区的地址,这段缓冲区可以作为字符与字符串操作的临时存储区使用。Forth 2012 标准规定,缓冲区 PAD 的长度至少为 84 字节(My4TH Forth 中,缓冲区 PAD 的长度为 96 字节)。

CHAR c		( -- n )	\ 取得字符 c 的编码 n。
[CHAR] c				\ 在编译时使用,相当于 [ CHAR c ] LITERAL
BL			( -- n )	\ 取得空格的编码 n (ASCII 码 32)。
PAD			( -- addr )	\ 取得一段缓冲区的地址 addr。缓冲区的长度至少为 84 字节。

字符串的格式

C" 用于编译一个带长度的字符串(counted string)。其格式如下26

				    |←------- n bytes ------→|
				+---+---+---+---+---+......+---+
				| n |   |   |   |   |      |   |
				+---+---+---+---+---+......+---+
result of COUNT: c-addr u=n

显而易见,存储一个长度为 n 的带长度的字符串需要 n+1 个字节的空间,而字符串最长的长度为 255。

Forth 中处理字符串的词往往要求栈中的首地址和长度二元组 ( c-addr u )。这一二元组中,c-addr 指向字符串的第一个字符,u 为字符串长度。从带长度的字符串获得这一二元组使用词 COUNT

C" string"	( -- c-addr )	\ 在编译时,将跟在后面直到下一个 `"` 的字符
							\ 编译成一个带长度的字符串;运行时将其地址 c-addr 压栈。
COUNT		( c-addr1 -- c-addr2 u )	\ 从带长度的字符串 c-addr1 获得二元组 c-addr2 u,
										\ 其中 c-addr2 指向字符串的第一个字符,
										\ u 为字符串长度。

显然,COUNT 可以这样定义:

: count ( addr1 -- addr2 u ) 
   dup	( addr1 addr1 )
   1+	( addr1 addr2 )
   swap	( addr2 addr1 )
   c@ ;	( addr2 u=length )

S"S\" 可直接获得字符串的 ( c-addr u ) 二元组27

S" string"	( -- c-addr u )	\ 编译时将跟在后面直到下一个`"`的字符编译成一个字符串;
							\ 运行时将其首地址和长度 c-addr u 压栈。
S\" string"	( -- c-addr u )	\ 作用与 S" 相同,允许转义字符`\`的使用。

S\" 使用的转义字符如下:

转义字符 名称 作用 ASCII码
\a BEL 响铃 7
\b BS 退格 8
\e ESC 转义 27
\f FF 另起一页 12
\l LF 另起一行 10
\m CR/LF 换行加回车 13, 10
\n newline 换行 与实现相关,可能是CR/LF, CR, LF, LF/CR 等
\q double-quote 双引号 34
\r CR 回车 13
\t HT 横向制表符(TAB) 8
\v VT 纵向制表符 11
\z NUL 空字符 0
\" double-quote 双引号 34
\xaf 任意字符。af 是一个 16 进制数字(00…ff) ASCII 十六进制为 af 的字符
\\ backslash 反斜线 92

使用 TYPE 可打印 ( c-addr u ) 二元组对应的字符串。

TYPE	( c-addr u -- )		\ 打印 ( c-addr u ) 二元组对应的字符串。

打印带长度的字符串可以使用 COUNT TYPE

基本的字符串操作

最基本的对字符串(也就是一段内存)操作的词有:

MOVE	( addr1 addr2 u -- )	\ 从 addr1 向 addr2 搬移 u 字节的内容
FILL	( c-addr u char -- ) 	\ 将 c-addr 开始的 u 个字节的值设置为 char。
ERASE	( c-addr u -- )			\ 将 c-addr 开始的 u 个字节的值设置为 0。

ERASE 在前面已经介绍过。MOVE 保证搬移之后 addr2 开始的 u 字节的内容与搬移之前 addr1 开始的 u 字节的内容相同。

依次处理字符串中的每一个字符时,可以用 BOUNDS 来将 ( c-addr u ) 二元组转换成适合 DO?DO 使用的格式。

BOUNDS	( addr u –- addr+u addr )	\ 将首地址和长度转换为适合 do 或 ?do 使用的格式。
									\ bounds 可以这么定义:: bounds  over + swap ;

Forth 2012 标准中扩展的 String 词集还提供了诸如字符串比较(COMPARE)和查找(SEARCH)的词。它们都要求 ( c-addr u ) 二元组作为输入。详情请参见标准原文Forth Programmer’s Handbook 3rd ed 第三章。

对输入流的操作

诸如 CREATECONSTANTVARIABLE': 等以及 S"S\"." 等词从输入流中获取内容。第一组词获取输入流中的下一个记号(以空白字符分隔),第二组词获取从词后面的(单个)空白字符之后的第一个字符开始,直至下一个 " 的内容。

对输入流进行基本操作的词如下:

PARSE string<char>	( char -- c-addr u )	\ 获取 PARSE 之后(间隔一个空格)的 string 
											\ 的内容,直到出现一个 char 字符。
											\ 将 string 对应的 ( c-addr u ) 二元组
											\ (一般直接指向输入流缓冲区内的位置)压栈。
PARSE-NAME string	( -- c-addr u )			\ 首先忽略 string 前导的所有连续空白;
											\ 然后获取 string 的内容,直到出现一个空白。
											\ 将 string 对应的 ( c-addr u ) 二元组
											\ (一般直接指向输入流缓冲区内的位置)压栈。
WORD string<char>	( char -- c-addr )		\ 首先忽略 string 前导的所有连续的 char 字符;
											\ 然后获取第一个非 char 字符开始直至
											\ 下一个 char 字符之前的内容,
											\ 生成一个地址为 c-addr 的带长度的字符串
											\ (一般放在固定的缓冲区中)。

PARSEPARSE-NAMEWORD28 的具体行为可以参考下面的例子29

char " parse test" type↵ test ok						\ 冒号定义中需要用 [char] "
char " parse      test" type↵      test ok				\ 保留了前面的 5 个空格
char " parse   test   " type↵   test    ok				\ 保留了前面的 2 个空格
														\ 和后面的 3 个空格
char " parse """"test" type↵  ? unknown word """test"	\ 遇到第一个 " 就停止解析了,
														\ 后面的内容被当作词处理
char " parse  """test" type↵  ? unknown word ""test"	\ 遇到第一个 " 就停止解析了,
														\ 后面的内容被当作词处理

bl parse        test     type↵  ? unknown word test	\ BL PARSE 不会忽略前导连续空白
parse-name     test  type↵ test ok			\ PARSE-NAME 会忽略前导和后面的连续空白
bl word        test      count type↵ test ok	\ BL WORD 也会忽略前导和后面的连续空白

char " word test" count type↵ test ok
char " word   test" count type↵   test ok				
char " word        test" count type↵        test ok	\ CHAR " WORD 没有忽略前导空白
char " word """""test" count type↵ test ok				\ 但忽略了前导的多个 "
char " word  """"test" count type↵  ? unknown word """test"	\ 遇到第一个 " 
																\ 就停止解析了,
																\ 后面的内容被当作词处理

可以看出, PARSE-NAME 忽略前导空白的行为和 BL WORD 一致,适用于本节开始举例的第一组词(取得下一个记号的场合);PARSE 适用于本节开始举例的第二组词(取得以 " 等字符结尾的字符串的场合)。

下面例子中定义的词 ," 将一个带长度的字符串存入当前堆地址。

: ," ( -- )	\ Compile the following string terminated by " as a counted string at HERE.
   [char] " parse		\ 获得以 " 结尾的字符串的二元组 ( c-addr u )
   dup c,				\ 将字符串长度 u 存入堆中
   here	( c-addr u here ) swap ( c-addr here u ) 
   dup ( c-addr here u u ) allot \ allocate u bytes ( c-addr here u ) 
   move			\ 将 u 个字符从 c-addr 复制到 allot 之前的 here
;				\ (也就是字串第一个字符的地址)。

将字符串转换为数字

>NUMBER 用于将字符串转换为数字。

>NUMBER	( ud1 c-addr1 u1 -- ud2 c-addr2 u2 )	
		\ 将一个包含数字的字符串 ( c-addr1 u1 ) 转换为数字。
		\ c-addr1 指向要转换的数字的最左边一位(最高位)。
		\ >NUMBER 循环执行下列运算:
		\   ud1=ud1*BASE+当前位的数字; c-addr1=c-addr1+1; u1=u1-1
		\   直到遇到第一个非数字的字符或 u1=0。
		\ 转换完成后,c-addr2 指向转换完成的数字右边第一个非数字的字符,
		\ u2 包含字符串中尚未转换的字符数目。
		\ 如果整个字符串都转换了,则 c-addr2 指向整个字符串右边第一个字符,u2=0。
		\ >NUMBER 只能转换无符号数字;ud1 在转换前一般设置为 0。		

例:

0. s" 1234" >number cr ." u2:" . ." c-addr2:" u. ." num:" d.↵
u2:0 c-addr2:33847 num:1234  ok		\ 字符串完全转换的情况
0. s" 123456abc" >number cr ." u2:" . ." c-addr2:" u. ." num:" d.↵
u2:3 c-addr2:33849 num:123456  ok	\ 字符串未完全转换的情况
33849 3 type↵ abc ok				\ 剩余未转换的部分

输入与输出

常用的与标准输入输出有关的词如下。其中部分词在前面已经出现过。

.	( n -- )		\ 按单精度有符号整数打印栈顶的单元
?	( a-addr -- )	\ 按单精度有符号整数打印 a-addr 内存地址存储的数据
					\ ? 可这样定义:: ?  @ . ; 
U.	( u -- )		\ 按单精度无符号整数打印栈顶的单元
D.	( d -- )		\ 按双精度有符号整数打印栈顶的2个单元
.R	( n1 n2 -- )	\ 打印单精度有符号整数 n1,宽度为 n2,右对齐
U.R	( u n -- )		\ .R 的无符号版
D.R ( d n -- )		\ .R 的双精度版
EMIT ( n -- )		\ 打印栈顶元素对应 ASCII 码的字符。
SPACE ( -- )		\ 打印一个空格。
SPACES	( n -- )	\ 打印 n 个空格。
CR	( -- )			\ 打印一个回车。
PAGE	( -- )		\ 清屏。
AT-XY	( x y -- )	\ 将光标定位到 (col=x,row=y) 位置。
." string"	( -- )	\ 打印跟在后面,直到下一个 `"` 的字符
.( string)	( -- )	\ 打印跟在后面,直到下一个 `)` 的字符。
                    \ `.(` 为立即词,在编译时使用也会立刻打印出后面的字符,
                    \ 常用于需要将程序中间的注释打印出来的场合。
KEY ( -- n )		\ 等待标准输入,并返回按下的按键的 ASCII 码
ACCEPT ( c-addr +n1 -- +n2 )	\ 从标准输入获得最多 +n1 个字符并回显
								\ (可提供行编辑功能),以回车结束。
								\ 获得的字符存储在 c-addr 开始的缓冲区,
								\ 实际获得的字符数为 +n2。

数字的格式化输出(pictured numeric output)

使用下面的词按一定的格式输出数字:

<#	( ud -- ud ) | ( n ud -- n ud )	\ 初始化输出缓冲区。ud 为要输出的数字;
									\ 如果要输出正负号,则需要在栈内保留含有符号的值 n。
#	( ud1 -- ud2 )	\ 将 ud1 除以 BASE,商为 ud2,余数放入输出缓冲区。
#S	( ud1 -- ud2 )	\ 连续执行 #,直到商 ud2 为 0。
SIGN	( n -- )	\ 如果 n 为负数,则将一个负号 `-` 放入输出缓冲区。
HOLD	( c -- )	\ 将字符 c 放入输出缓冲区。
HOLDS	( c-addr u --)	\ 将 ( c-addr u ) 二元组指示的字符串放入输出缓冲区。
#>	( ud -- c-addr u )	\ 结束格式化输出。丢掉栈内的 ud,
						\ 将输出缓冲区的 ( c-addr u ) 二元组放入栈中。

注意 ##SSIGNHOLDHOLDS 只能在 <##> 中间使用。

一般情况下,可以这样为格式化输出准备栈的内容:

需要输出的数字 准备方法
无符号单精度整数 0# 等词需要双精度整数,用 0 补齐高 16 位)
有符号单精度整数 DUP ABS 0
无符号双精度整数 (不需要特别准备)
有符号双精度整数 SWAP OVER DABS

输出单精度有符号整数的 . 可这样定义:

: . ( n -- )
   dup abs 0	( n ud=|n| -- )
   <#
      #s		\ 将数字放入输出缓冲区
      rot sign	\ 将 n 转移到栈顶,然后获取 n 的符号,如果 n 为负,将 `-` 放入输出缓冲区
   #>
   type space ;	\ 打印输出缓冲区的内容,并打印一个空格

注意输出的内容是从右往左依次处理的,而 <##> 中间的处理词是从左往右依次排列的。

如果要至少输出 3 位数字,则可用下面的例子:

: .nnn ( n -- ) dup abs 0  <# # # #s rot sign #>  type space ;

注意 #S 至少会输出一位数字,所以此处只需要两个 #。输出的效果如下:

0 .nnn↵ 000  ok
-1 .nnn↵ -001  ok
1000 .nnn↵ 1000  ok
-1000 .nnn↵ -1000  ok

使用格式化输入输出的方法,可以方便地输出定点数

: d.$ ( d -- ) swap over dabs 
   <# # # [char] . hold #s rot sign [char] $ hold #>  type space ;

其中,# # 输出两位小数,[char] . hold 输出小数点,#s 输出整数部分,rot sign 输出负号(如有),[char] $ hold 输出货币符号。

实际的输出例子如下:

-123456. d.$↵ $-1234.56  ok
78900. d.$↵ $789.00  ok

与 C 语言的 printf() 的格式化输出字符串不同,<##> 中间可以使用其他的词进行运算、逻辑判断和分支循环等,这提供了最大的灵活性。如下面的程序实现了每三位数字加一个逗号:

: ','  [char] , hold ;	\ 输出一个逗号
: d.eng ( d -- ) swap over dabs 0 >r	\ 用返回栈暂存处理到哪一位
   <# begin
      # r> 1+ >r						\ 每输出 1 位,加 1
      2dup d0= 0= while					\ 如果 # 留下的数字不为 0,则继续循环
      r@ 3 mod 0= if ',' then			\ 每 3 位输出一个逗号
   repeat rot sign #> 					\ 输出负号(如有)
   rdrop type space ;	\ 最后不要忘了丢掉返回栈中的暂存数据   
10000. d.eng↵ 10,000  ok
-1000001. d.eng↵ -1,000,001  ok

如果想在数字大于等于 0 的时候输出正号,只需使用:

: +-sign ( n -- )
   0< if
      [char] - hold
   else
      [char] + hold
   then ;  

注意上面定义的 +-SIGNSIGN 对栈的作用完全相同,因此可直接替换 SIGN

: +-.  dup abs 0 <# #s rot +-sign #> type space ;↵  ok	\ 与前面定义的 `.` 几乎完全相同,
														\ 除了把 sign 换为 +-sign 之外
100 +-.↵ +100  ok
-100 +-.↵ -100  ok
0 +-.↵ +0  ok

对输入的处理

下面的词用于对输入的处理,常用于解释器自身30

>IN		( -- a-addr ) 	\ a-addr 中存储输入缓冲区当前处理的位置。
SOURCE	( -- c-addr u )	\ 返回输入缓冲区的地址 c-addr 和其中的字符数 u。
SOURCE-ID	( -- n )	\ 返回 0 表示输入流是标准输入;1 表示输入流是 EVALUATE 的字符串。
EVALUATE	( c-addr u --)	\ 将字符串 ( c-addr u ) 作为输入流进行解释。
QUIT		( ... -- )		\ 停止当前程序的执行,清空栈和返回栈,设置输入为标准输入,
							\ 回到立即模式,等待用户输入。QUIT 不打印提示符。
FIND		( c-addr -- c-addr 0 | xt 1 | xt -1 ) \ 从字典中查找词。
			\ 被查找的词是地址为 c-addr 的带长度的字符串。
			\ 如果没有找到,则返回 c-addr 0;
			\ 如果找到了,则返回对应词的 xt 和 1/-1。1 代表词为立即词,-1 代表词为普通词。

块操作

运行在操作系统之上的 Forth 系统可以借助操作系统的强大威力进行文件操作;但对于 My4TH Forth 这种运行在裸机上的简单 Forth 系统,一般使用(block)的方式来组织非挥发性的外部存储器。

块是 1024 字节大小的外部存储区域,用块号寻址。My4TH 最多挂载 512kB 的 I2C EEPROM 存储器,因此块号为 0…511。

块可以用来存储文本格式的程序31

LOAD	( u -- )		\ 将块 u 的内容做为程序喂给解释器。
THRU	( u1 u2 -- )	\ 将块 u1..u2 的内容做为程序喂给解释器。
LIST	( u -- )		\ 列出块 u 的内容。
EDIT	( u -- )		\ 启动文本编辑器,编辑块 u 的内容。
REFILL	( -- f )		\ 在块中使用时,将下一块指定为输入源。
						\ 如果成功,则 f=true,否则 f=false。
SCR		( -- a-addr )	\ a-addr 中存储上一次用 LIST 列出的块号
BLK		( -- a-addr )	\ a-addr 中存储当前用作解释器输入的块号。
						\ 如果解释器的输入不是块,则 BLK=0。

存储文本时,一般将块组织为 16 行,每行 64 个字节32。这样的一块文本也称作一(screen)。变量 SCR 存储上一次列出的屏号。

由于 BLK=0 用于指示解释器的输入流不来自块,块 0 不能用作解释器的输入,也就是不能 LOADTHRU。这一块一般用于存储简要的文档信息(比如后面的块的分配情况或者叫“目录”)。My4TH Forth 中,块 0 的最前面一行的开头如果有一个数字,那么系统复位时,会 LOAD 数字对应的块的内容。

Forth 系统可在内存中开辟若干个大小为 1 个块的块缓冲区(block buffer),用于临时存储正在处理的块。有些系统中采用多个块缓冲区;My4TH Forth 采用最简单的实现方案:单个块缓冲区,在需要时分配33

对块缓冲区进行操作的词如下:

BLOCK			( u -- a-addr )	\ 将块 u 的内容读入 a-addr 开始的块缓冲区。
BUFFER			( u -- a-addr )	\ 设定块号 u,需要时分配块缓冲区,但不读入内容。
UPDATE			( -- )			\ 将正在处理的块标记为“已改动”。
FLUSH			( -- )			\ 将已改动的块写入非挥发性存储器,可能时释放块缓冲区。
SAVE-BUFFERS	( -- )			\ 将已改动的块写入非挥发性存储器,
								\ 清除 UPDATE 使用的标志,不释放块缓冲区。
EMPTY-BUFFERS	( -- )			\ 放弃已改动的块,可能时释放块缓冲区。

若当前正在处理的块标记为已改动,BLOCKBUFFER 在进行下一步操作前,会将当前块写入非挥发性存储器,然后再处理新块的内容。

下面是块操作的两个例子。

My4TH Forth 只会分配一个块缓冲区。因此将一个块复制到另一个块的程序特别简单:

: cp ( src dst -- )
   swap 		( dst src -- )
   block drop 	( dst -- )	\ 将块 src 的内容读入块缓冲区
   buffer drop	( -- )		\ 将块号设置为 dst。
							\ 由于块缓冲区只有一个,现在其内容是块 src 的内容。
   update 		\ 将块 dst 标记为已改动
   flush ;		\ 将块 dst 写入非挥发性存储器

如果需要标明一屏(或连续几屏)程序的用途,可以使用一屏的最前面一行来书写注释。下面的程序可以列出所有位于某屏的最前面一行并以 \ C 开头的注释:

: dcat ( blk -- f )
   \ 如果块 blk 的最前面一行以 `\ C` 开头,
   \ 打印块号和最前面一行中 `\ C` 后面跟着的内容,返回真;
   \ 否则不打印任何内容,返回假。
   dup ( blk blk )
   block ( blk a-addr )
   dup c@ 92 = if		\ 如果块 blk 的第一个字符为 `\`
      dup 2 + c@ 67 = if	\ 如果块 blk 的第三个字符为 `C`
         swap ( a-addr blk )
         4 .r	\ 打印块号 blk,右对齐,占 4 个位置宽
         3 + 61 type	\ 打印最前面一行中 `\ C` 后面跟着的内容
         true exit		\ 设置 f 为 true,退出
      then 
   then 
   2drop false ;		\ 如果最前面一行不以 `\ C` 开头,
						\ 抛弃 blk 和 a-addr,设置 f 为 false,退出

: cat ( from to -- ) \ 从 from 到 to 遍历块,
   1+ swap \ 为 do 准备栈
   cr do 
      13 emit i dcat 		\ 对块 i(从 from 到 to) 执行 dcat
      if cr else i . then	\ 如果块 i 的最前面一行以 `\ C` 开头,另起一新行,
      						\ 否则回到行首,打印 i。
    						\ 这样可以看到当前处理的块号。
   loop
   13 emit ;	\ 回到行首

获取系统环境信息

ENVIRONMENT? 可用来获取系统环境信息:

ENVIRONMENT?	( c-addr u -- false | i*x true )	
					\ 输入一个字符串的 ( c-addr u ) 二元组。
					\ 输入字符串跟某个系统环境字符串匹配时,
					\ 返回对应的返回值和 true;否则返回 false。

例如,系统环境字符串 MAX-N 返回最大的单精度有符号整数:

s" max-n" environment? .s↵ <2> 32767 -1  ok

标准的系统环境字符串有下面一些:

字符串 返回值类型 意义 My4TH Forth 中的值
/COUNTED-STRING n 带长度的字符串的最长长度 255
/HOLD n 数字格式化输出使用的缓冲区的大小(字符数) 112
/PAD n 缓冲区 PAD 的大小(字符数) 96
ADDRESS-UNIT-BITS n 一个寻址单位的位数34 8
FLOORED flag /MOD 使用下取整除法时为真,使用对称除法时为假 false
MAX-CHAR u 最大的字符型量值 255
MAX-D d 最大的双精度有符号整数 2147483647
MAX-N n 最大的单精度有符号整数 32767
MAX-U u 最大的单精度无符号整数 65535
MAX-UD ud 最大的双精度无符号整数 4294967295
RETURN-STACK-CELLS n 返回栈的最大深度(以单元数为单位) 50
STACK-CELLS n 栈的最大深度(以单元数为单位) 128

进一步的读物及参考文献

下面是 Forth 2012 的参考资料。

Forth Application Techniques 6th ed:篇幅很短的入门教程。

Forth Programmer’s Handbook 3rd ed:比较全面的参考手册。

Forth 2012 Standard:标准全文。

t4th:使用 Python 实现的简单 Forth 系统。完整实现了 Core 和 Core Extension 词集,能够通过标准的测试用例。

下面是 Forth 2012 标准制定之前的资料。

FORTH: a Text and Reference:Forth-79/83 时代的一本非常全面的参考书。

C. H. Ting’s Forth Notebook vol.1 vol.2: 很多实际的例子。

Library of Forth routines and utilities:更多实际的例子。

A Beginner’s Guide to Forth (J. V. Noble):一篇非常紧凑的教程(以 Win32Forth 为例)。

Forth 语言教程(张怀宁等):一本全面介绍 Forth-83 的中文参考书。

Forth Interest Group:FIG(Forth Interest Group)的网站。FIG 作为一个组织已经不再活动,但是他们的网站保留了下来。


  1. 致体系结构爱好者:这里的栈只要能后进先出就可以,不必须在系统内存中。事实上很多体系结构里栈都用硬件实现。 ↩︎

  2. a. 大部分情况下,“词”这个术语可以指定义的名称,也可以指定义本身。比如可以说“执行一个词(execute a word)”或“词的名称(name of a word)”。请根据上下文判断具体所指。

    b. 有一些中文的 Forth 文献将 word 称为“字”。为避免与CPU的机器字(英文也称为 word)混淆,本文一律将其称为“词”。在 16、32 及 64 位机的 Forth 系统中,栈中单元的大小往往与机器字长一致。

    c. Forth 2012 标准中称一组具有类似特征的词为词集(word set)。最基本的词集有 Core 和 Core Extension 两个。标准中规定的附加词集尚有 Block、Double-Number、String、Facility、File-Access、Floating-Point 等。一个完整的 Forth 系统至少应实现 Core 词集,可以选择性实现其他词集。My4TH Forth 在 ROM 中实现了 Core、Core Extension、Block 和 Double-Number 四个基本的词集,并提供了若干实现了其他词集的模块。

    d. 有些 Forth 文献将程序设计语言的术语“记号(token)”,也就是 Forth 解释器处理的基本单位——用空白字符分隔的、内部不含空白字符的字符串,也称作“词”。这符合“词”的日常定义。但为了减少“词”这个词的语义负担,本文使用“记号”这一说法。Forth 文献中尚有 execution token 这一说法,指代表某词运行行为的数值(具体实现中,一般也就是这个词对应的机器码程序的入口地址)。这里的 token 语义与“记号”略有不同,本文将 execution token 称作“执行标记”。 ↩︎

  3. 注意:Forth 不支持冒号定义嵌套,也就是说,一个冒号定义的中间不能包含另一个冒号定义。在看过本文第二部分或任何一个 Forth 系统的源代码后,读者将会理解其原因。 ↩︎

  4. CONSTANT(以及前面提到的 :FORGETMARKER 等词)要求输入流中的下一个记号 name。本文中描述这类词时,用词名称后面加 name 的方式。文献中有其他的表示方式,如在栈表示中用 ( x "<spaces>name" -- ) 。 ↩︎

  5. , 的语义是显然的,其他更复杂的语言往往也用 , 分隔数据项。当然,与其他语言不同的是,Forth 中的 , 本身是一个词(会执行操作)而不是一个分隔符,因此,最后一项数据后面也需要有一个 , 。 ↩︎

  6. a. Forth 标准只保证 CREATEALLOT,C,ALIGN 操作的内存空间是连续的。其他有内存分配作用的词(如 VARIABLE)可以在其他空间中分配内存。因此,不要使用形如 variable x 1 cells allot 的方式分配内存,此时 x1 cells allot 分配的内存不一定连续。当然,使用单一、连续的堆空间存储所有(可以修改的)词是最简单的实现方式。My4TH Forth 使用这一实现方式。

    b. 在 My4TH Forth 中,分配内存的过程也就是简单地使 HERE 增加相应字节数的过程。当然,在分配内存之前会判断剩余内存容量是否充足。 ↩︎

  7. 读者可能会好奇 DOES>> 是怎么来的。fig-forth 中相当于 CREATE 的词是 <BUILDS(细节不完全一样)。<BUILDSDOES> 是不是顺眼很多? ↩︎

  8. 执行包含 CREATE 的词时,CREATE 会要求输入流中的下一个记号 name。忽略 does> 部分,4 4 array BOARD 相当于 4 4 create BOARD dup , * allot。 ↩︎

  9. My4TH Forth 1.4 原始版本中,M*/ 的中间结果为双精度整数。本站提供的固件对其进行了更改以符合标准。 ↩︎

  10. 注意分支与循环使用的词(IFELSETHENCASEOFENDOFENDCASEDOLOOP 等)只能在冒号定义中使用,它们对栈的作用发生在运行冒号定义时(也就是下面将要提到的执行时行为)。因此在这些词的栈表示法中标出了 rt:。 ↩︎

  11. Forth 中,THEN 出现在整个条件分支的最后,与 BASIC 或 Lua 等使用中缀表达式的语言不同。将 Forth 的 THEN 理解为 END IF 也许会有助于阅读程序。 ↩︎

  12. ?DUP 可以这样定义:

    : ?dup	( n -- n n | 0 )
       dup					( -- n n ) 
       if					( -- n )
          dup				\ 若 n<>0, 则 ( -- n n )
       then					\ 若 n=0,  则 ( -- n ), 即 ( -- 0 )
    
     ↩︎
  13. a. 显然,对返回栈进行操作的词只能在冒号定义中使用。为简洁起见此处未单独标出 rt:

    b. 助记法:>R 代表“数据去到返回栈(to-R)”,R> 代表“数据来自返回栈(R-from)”。 ↩︎

  14. a. My4TH Forth 中,>RR> 操作的栈、调用的返回栈和循环使用的栈在物理上是三个栈。因此,Thinking Forth 介绍的用 R> DROP 改变控制流的技巧在 My4TH Forth 中不适用。当然为了可移植性,编写程序时仍需遵守这 4 条注意事项。

    b. 有一些 Forth 系统支持局部变量。它们中有一些将局部变量存储在数据栈中,另一些将局部变量存储在返回栈中,还有一些将局部变量存储在第三个栈中。一般它们还会利用一个单独的指针来存储执行一个定义之前压入的参数和局部变量的分界线的位置(类似 8086 下 C 语言的调用约定中使用 BP 指针的方法)。My4TH Forth 不支持局部变量。

    c. 程序中多定义几个简单的词,可读性往往大大优于定义一个复杂的词并使用返回栈暂存结果。当然,单纯为了把词拆分开而拆分词,会适得其反。 ↩︎

  15. 用于退出有限循环的词 LEAVE 不能用来退出不定循环。一个常见的场景是想要根据条件在任意一处退出 BEGINAGAIN 无限循环,此时可用 BEGINWHILEREPEAT 循环代替。但 BEGINWHILEREPEAT 不能与 IFELSETHEN 交错:

    : test ( n -- )
       begin
          dup if
             1 while	\ 举例而已,正常写程序时不会这么写
             dup .
          else
             ." else" space .
          then
       repeat ." exit" ;
    

    该词预期的行为是,n<>0 时不停循环打印 n 的值,n=0 时打印 “else”、n 的值和 “exit”。但实际执行结果为:

    0 test↵ exit ok
    .s↵ <1> 0  ok		\ else 分支没有执行,“exit” 也没有打印
         
    1 test↵ 1 1 1 1 ...	\ 无限循环行为正确
    

    如果想实现预期的行为,该词可以用 BEGINWHILEREPEAT 循环这样编写:

    : test ( n -- )
       begin
          dup while
             dup .
       repeat 
       ." else" space . ." exit" ;
        
    0 test↵ else 0 exit ok
    .s <0>  ok
    1 test↵ 1 1 1 1 ...	
    

    也可以用 BEGINAGAIN 无限循环嵌套 IFELSETHEN 结构,在需要退出的时候用 EXIT 直接退出词(多定义几个简单的词的原则又适用了):

    : test ( n -- )
       begin
          dup if
             dup .
          else
             ." else" space . ." exit" exit
          then
       again ;
    

    注意 My4TH Forth 目前的版本(v1.4)不支持 Forth 2012 标准 A.3.2.3.2 节描述的包含多于一个 WHILEBEGINWHILEREPEAT 循环。 ↩︎

  16. 在 My4TH Forth 以及其他大部分 Forth 系统中,解释器报错时会清空栈内所有元素。实际上不需要定义 CLEAR 一词,只需输入一个词典中没有的词,让解释器报错,即可清空栈内所有元素:

    1 2 3 4 5 .s↵ <5> 1 2 3 4 5  ok
    abc↵  ? unknown word abc
    .s↵ <0>  ok
    
     ↩︎
  17. IF 为例:IF 会生成处理栈顶标志的代码;然而此时 IF 并不知道后面的 ELSETHEN 在哪里,因此 IF 会将它生成的向后跳转指令的地址域的地址(称为未解决的向后引用(unresolved forward reference))压栈。ELSE 或者 THEN解决(resolve)它:将这个未解决的向后引用从栈中弹出,把要跳转到的地址填到那儿。例如:

    : test if ." true." then ;
    

    生成的代码如下:

    9257 : 1E         PHL
    9258 : 19 0E 4A   JSR 4A0E				; 此时遇到 if
    										; $4A0E 是 if_check 子程序:判断栈顶标志真假
    925B : 16 70 92   JPF 9270     (+21)	; 执行 if 时还不知道跳转到哪里,
    										; 生成的代码可记作 JPF ????
    										; 将 ???? 的地址 $925C 压栈
    925E : 18 67 92   JMP 9267     (+9)
    9261 : 74         DB 116 ;'t'
    9262 : 72         DB 114 ;'r'
    9263 : 75         DB 117 ;'u'
    9264 : 65         DB 101 ;'e'
    9265 : 2E         DB  46 ;'.'
    9266 : 00         DB   0
    9267 : 01 0D 92   LD  r13  #146
    926A : 01 0C 61   LD  r12  #97
    926D : 19 3E 31   JSR 313E				; ." 的打印子程序
    										; 此时遇到 then,知道了向后跳转要跳到 $9270
    										; 从栈内弹出未解决的向后跳转地址 $925C,
    										; 将 $9270 写入地址 $925C 
    										; $925B 处的 JPF 9270 这条指令现在才生成完整
    9270 : 1F         RTS
    

    用栈解决向后引用的方法天然地可以处理多层嵌套。需要指出的是,解决向后引用用的栈可以是数据栈本身,也可以是一个单独的栈。标准中称这个栈为控制流栈(control-flow stack)。 ↩︎

  18. 由于解释和编译工作都是由读取—求值—打印循环(read–evaluate–print loop, REPL)完成的,Forth 用户不太区分“解释器(interpreter)”与“编译器(compiler)”两个说法,它们往往指同一个东西。通常侧重交互性时倾向于说“解释器”,侧重在堆中生成内容的行为时倾向于说“编译器”。刻意避免这一区分时也可以直接说“Forth 系统(Forth system)”。 ↩︎

  19. 稍老的 Forth 文献中常见 lfanfacfapfa 等提法。它们分别是连接域地址(link field address)、名称域地址(name field address)、代码域地址(code field address)和参数域地址(parameter field address)的简称。在 My4TH Forth 中,lfa 对应图中 link 的地址, nfa 对应 length 的地址,cfa 对应 code 的地址(也就是 xt),pfa 对应 data 的地址。 ↩︎

  20. LITERAL2LITERALCOMPILE, 等词只在编译模式下有确定的语义。在立即模式下使用这些词会创造出模糊情境(ambiguous condition)。Forth 系统处于模糊情境时的行为因具体实现而不同。在 My4TH Forth 中,这几个词在立即模式下使用时,会在 HERE迳行编译出相应的代码并分配内存空间。 ↩︎

  21. 但是与 LITERAL2LITERAL 不同,COMPILE, 不是一个立即词。它的主要作用是用 Forth 编写 Forth 解释器的读取—求值—打印循环(read–evaluate–print loop, REPL)本身。因此精确地说,COMPILE, 拥有“确定语义”的条件又多套了一层:使用了 COMPILE, 的程序(一般是 Forth 解释器本身)在处理一个冒号定义的中间。 ↩︎

  22. a. 如果 xt 对应的词不是用 CREATE 定义的(如用系统的 CONSTANTVARIABLEVALUE 或冒号定义的),那么 >BODY 返回的结果无意义。

    b. 由于 CREATE 创建的词的缺省行为就是返回后面紧跟的数据区域的地址,很多系统中,>BODY 不做任何操作。当然如果要正确处理有 DOES> 行为的 CREATE 创建的词,>BODY 必须进行特殊处理。

    c. My4TH Forth 中, CREATE 创建的词的程序区域固定为 13 个字节:如果没有 DOES> 行为,这段程序将数据区域的地址装入 R4 寄存器(下面的反汇编结果中写作 r20 r21),然后将 R4 压入数据栈:

    9255 : 1E         PHL
    9256 : 01 14 62   LD  r20  #98	; $9262 是后面紧跟的数据区域的地址(xt+13)
    9259 : 01 15 92   LD  r21  #146
    925C : 19 0E 41   JSR 410E		; $410E 的子程序将 r20 r21 包含的 16 位数据压入数据栈
    925F : 1F         RTS
    ; 此时 here 的值为 $9262。$9260 $9261 两个字节占而不用。
    

    这段程序共占用 11 字节,后面的 2 个字节占而不用。

    如果有 DOES> 行为,那么最后的 RTS 将替换成跳转到 DOES> 程序段的 JMP,13 个字节全被占用:

    : constp  create ,   does> @ . cr ;↵  ok	\ 类似 CONSTANT,
    											\ 但 DOES> 行为是打印出值。
    1000 constp cc↵  ok
    cc↵ 1000
     ok
    

    ccconstp 对应的代码为:

    cc
    9286 : 1E         PHL
    9287 : 01 14 93   LD  r20  #147			; $9293 是后面紧跟的数据区域的地址(xt+13)
    928A : 01 15 92   LD  r21  #146
    928D : 19 0E 41   JSR 410E
    9290 : 18 75 92   JMP 9275     (-27)	; $9275 是 constp 的 DOES> 程序段的入口
    
    constp
    926B : 1E         PHL
    926C : 19 72 69   JSR 6972 --> create
    926F : 19 6B 4C   JSR 4C6B --> ,
    9272 : 19 D2 63   JSR 63D2 --> does>
    9275 : 19 8C 4B   JSR 4B8C --> @		; DOES> 程序段的入口
    9278 : 19 54 4C   JSR 4C54 --> .
    927B : 19 CF 4E   JSR 4ECF --> cr
    927E : 1F         RTS 
    

    因此,My4TH Forth 的 >BODY 的实际实现是:固定将 xt 加 13。 ↩︎

  23. 使用系统自带的 COMPILE,$2d00 compile, 生成的代码是简单的 JSR xt 指令:

    9250 : 19 00 2D   JSR 2D00
    

    使用这一定义生成的代码如下:

    9266 : 01 0F 2D   LD  r15  #45			; $2d 装入寄存器 r15
    										; (借用 FLAG 寄存器存储数据高八位)
    9269 : 03 00      LDA #0				; $00 装入寄存器 r14 (ACCU)
    926B : 74 0E      PHD r14				; r14 r15 压入数据栈
    										; 上面的代码对应 postpone literal
    926D : 19 58 6D   JSR 6D58 --> execute	; 调用 execute
    

    显然,这一定义生成的代码要比系统生成的 JSR xt 复杂很多,但仍然是正确的。 ↩︎

  24. [COMPILE] 是早期 Forth 系统的遗迹,不要在新的程序中使用。详见 Forth 2012 标准 A.6.1.2033 节。 ↩︎

  25. 在 Forth 的发展历史上,向量执行先于 CASE 出现。很多较早或较简单的 Forth 系统中不提供 CASE。 ↩︎

  26. a. 读者也许已经意识到,词典中存储词的名称使用的就是带长度的字符串(当然长度字段的最高位被复用了,所以 COUNT 完之后也许还需要 127 AND)。

    b. 带长度的字符串有时也称作“Pascal 风格的”字符串。熟悉 Pascal 语言的读者可能会回想起 ShortString 数据类型。 ↩︎

  27. a. Forth 2012 标准的 Core 和 Core Extension 词集只规定了 C"S"S\" 在编译时的行为。My4TH Forth 中,在立即模式下直接使用时,这三个词会在堆指针地址 HERE 处迳行编译字符串但不保留内存空间(生成的内容包括 3 个字节的跳转到字符串后的指令,以及字符串本身),然后将字符串地址或 ( c-addr u ) 二元组压栈。例如:

    hex↵  ok
    here↵ u. 8F18  ok
    \ 字符串的地址是 HERE+3
    c" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"↵ u. 8F1B  ok	
    c" 123" u.↵ 8F1B  ok	\ 内存没有被保留
    here 10 dump↵	\ 注意这儿的 10 是十六进制的
    \ 可见带长度的字符串[3]123覆盖了第一次的一串 a。
    \ 起头的 18 1F 8F是跳转到 $8F1F 处(也就是字符串结尾)的指令。
    8F18: 18 1F 8F 03 31 32 33 61 61 61 61 61 61 61 61 61  ....123aaaaaaaaa
    \      8  9  a  b  c  d  e  ↑$8F1F
    s" bcd" u. u.↵ 3 8F1B  ok	\ 内存同样没有被保留
    here 10 dump↵
    \ s" 生成的字符串 bcd 覆盖了上一次的字符串。长度并没有编译入内存,而是只压栈。
    8F18: 18 1E 8F 62 63 64 33 61 61 61 61 61 61 61 61 61  ...bcd3aaaaaaaaa
    decimal↵  ok
    

    My4TH Forth 的这一行为方便了使用,且符合标准中 File-Access 词集对 S"S\" 的扩展(参见标准 A.11.6.1.2165 节)。但使用时必须要注意这一覆盖行为。如:

    c" 123" count type↵ 123 ok	\ 打印出字符串
    s" 456" type↵ 456 ok		\ 同样打印出字符串
    c" 123" c" 4567" count type count type↵ 456456 ok	\ 第二次的字符串覆盖了第一次
    s" 123" s" 4567" type type↵ 4567456 ok	\ 同样的覆盖行为
        
    \ 如果在编译时使用 C" 或 S",则字符串不会覆盖:
    : test1  c" 123" c" 4567" count type count type ;↵  ok
    test1 4567123 ok	\ 符合预期的行为
    : test2  s" 123" s" 4567" type type ;↵  ok
    test2↵ 4567123 ok	\ 符合预期的行为
    

    b. S"S\" 以及其他名称中带双引号的词(如.")的编译产物在内存中的存储格式未必是带长度的字符串。在 My4TH Forth 中, S"S\" 的编译产物中不带长度(长度已存储在( c-addr u ) 二元组的 u 中),." 的编译产物的存储格式是以 ASCII 0 结尾的(“C 风格的”)字符串。 ↩︎

  28. WORD 是 Forth 早期历史的遗迹。WORD 处理输入流需要进行低效的串拷贝工作,而且带长度的字符串的长度受到限制(最长 255),固定缓冲区的复用也需要加以特别的注意。编写新的程序时,请尽量避免使用 WORD(也许唯一一个需要使用 WORD 的场合是与 FIND 一起使用,常用来编写 Forth 解释器本身),而尽量使用以 ( c-addr u ) 二元组描述输入流缓冲区内字符串的 PARSEPARSE-NAME。 ↩︎

  29. My4TH Forth 1.4 原始版本中 PARSEWORD 忽略前导字符的行为与标准相反。本站提供的固件对其进行了更改以符合标准。 ↩︎

  30. a. 由于 My4TH 系统使用串行运算器,计算加法较慢,故 My4TH Forth 的 >INSOURCE 实现与标准不同:标准中使用“基地址加偏移量”表示法描述,系统变量 >IN 中存储的内容是相对缓冲区起始地址的偏移量;而 My4TH Forth 中认为基地址总是 0,这样 >IN 中存储的内容就是当前处理的字符本身的地址,SOURCE 返回的 c-addr 也是 0。这样可以保证行为与标准实现一致:SOURCE 返回的“缓冲区字符数” u 减去 >IN 存储的内容,仍然是缓冲区中尚未处理的字符数。

    b. QUIT 对应解释器等待终端输入的缺省行为(在符合标准的实现中,QUIT 就是解释器本身)。标准中只要求 QUIT 清空返回栈。 ↩︎

  31. My4TH Forth 中的 LOADTHRU 处理从块来的输入流时,会逐行(每 64 字节)而非逐块读取并处理非挥发性存储器中的内容。所以 >INSOURCE 返回的不是块缓冲区的地址(Thinking Forth 中讲的跳过一屏程序的后半部分的技巧不适用)。 ↩︎

  32. 与现代操作系统中的文本文件不同,一屏文本永远只包含 16 行。每行右边不足 64 个字节的部分用 BL 补齐。 ↩︎

  33. 在 My4TH Forth 中,块缓冲区在堆内分配。如果分配块缓冲区之后堆内没有进行新的内存分配,则 FLUSHEMPTY-BUFFERS 会释放块缓冲区;否则它们不会释放块缓冲区(因为此时释放块缓冲区会同时释放掉之后分配的元素)。 ↩︎

  34. ADDRESS-UNIT-BITS 应返回一个寻址单位(字节)的位数(8)。My4TH Forth 1.4 原始版本返回一个单元的位数(16),本站提供的固件对其进行了更改以符合标准。 ↩︎