Nasal-Interpreter/doc/dev_zh.md

22 KiB
Raw Blame History

开发日志

buringship

目录

语法分析

有特殊语法检查的LL(1)语法分析器。

(var a,b,c)=[{b:nil},[1,2],func return 0;];
(a.b,b[0],c)=(1,2,3);

这两个表达式有同一个first集所以纯粹的LL(1)很难实现这个语言的语法分析。所以我们为其添加了特殊语法检查机制。本质上还是LL(1)的内核。

上面这个问题已经解决很久了,不过我最近发现了一个新的语法问题:

var f=func(x,y,z){return x+y+z}
(a,b,c)=(0,1,2);

这种写法会被错误识别合并成下面这种:

var f=func(x,y,z){return x+y+z}(a,b,c)
=(0,1,2);

语法分析器会认为这是个严重的语法错误。我在Flightgear中也测试了这个代码它内置的语法分析器也认为这是错误语法。当然我认为这是语法设计中的一个比较严重的缺漏。为了避免这个语法问题只需要添加一个分号就可以了:

var f=func(x,y,z){return x+y+z};
                               ^ 就是这里
(a,b,c)=(0,1,2);

version 1.0 parser (last update 2019/10/14)

第一版功能完备的nasal语法分析器完成了。

在version 1.0之前,我多次尝试构建一个正确的语法分析器但总是存在一些问题。

最终我学习了LL(1)LL(k)文法并且在version 0.16(last update 2019/9/14)中完成了一个能识别数学算式的语法分析器。

在version 0.17(2019/9/15) 0.18(2019/9/18) 0.19(2019/10/1)中我只是抱着玩的心态在测试语法分析器不过在那之后我还是完成了version 1.0的语法分析器。

该项目于2019/7/25正式开始

抽象语法树

version 1.2 ast (last update 2019/10/31)

抽象语法树在这个版本初步完成。

version 2.0 ast (last update 2020/8/31)

在这个版本我们基于抽象语法树实现了一个树解释器,并且完成了部分内置函数。

version 3.0 ast (last update 2020/10/23)

我们重构了抽象语法树的代码,现在可以更容易地读懂代码并进行维护。

这个版本的树解释器用了新的优化方式,所以可以更高效地执行代码。

在这个版本用户已经可以自行添加内置函数。

我想在v4.0发布之后仍然保留这个树解释器,毕竟花了很长时间才写完这坨屎。

version 5.0 ast (last update 2021/3/7)

我改变想法了,树解释器给维护带来了太大的麻烦。如果想继续保留这个解释器,那么为了兼容性,字节码虚拟机的优化工作会更难推进。

version 11.0 ast (latest)

改变了语法树的设计模式,采用访问者模式。

字节码虚拟机

op

version 4.0 vm (last update 2020/12/17)

我在这个版本实现了第一版字节码虚拟机。不过这个虚拟机仍然在测试中在这次测试结束之后我会发布v4.0发行版。

现在我在找一些隐藏很深的bug。如果有人想帮忙的话非常欢迎:)

下面是生成的字节码的样例:

for(var i=0;i<4000000;i+=1);
.number 0
.number 4e+006
.number 1
.symbol i
0x00000000: pzero  0x00000000
0x00000001: loadg  0x00000000 (i)
0x00000002: callg  0x00000000 (i)
0x00000003: pnum   0x00000001 (4e+006)
0x00000004: less   0x00000000
0x00000005: jf     0x0000000b
0x00000006: pone   0x00000000
0x00000007: mcallg 0x00000000 (i)
0x00000008: addeq  0x00000000
0x00000009: pop    0x00000000
0x0000000a: jmp    0x00000002
0x0000000b: nop    0x00000000

version 5.0 vm (last update 2021/3/7)

从这个版本起,我决定持续优化字节码虚拟机。

毕竟现在这玩意从0数到4000000-1要花费1.5秒。这效率完全不能忍。

2021/1/23 update: 现在它确实可以在1.5秒内从0数到4000000-1了。

version 6.0 vm (last update 2021/6/1)

使用loadg/loadl/callg/calll/mcallg/mcalll指令来减少分支语句的调用。

删除了vm_scop类型。

添加作为常量的vm_num来减少内存分配的开销。

将垃圾收集器从引用计数改为了标记清理。

vappnewf开始使用先前未被使用的.num段来压缩字节码生成数量减少生成的exec_code的大小。

2021/4/3 update: 从0数到4e6-1只需要不到0.8秒了。

2021/4/19 update: 从0数到4e6-1只需要不到0.4秒了。

在这次的更新中,我把全局变量和局部变量的存储结构从unordered_map变为了vector,从而提升执行效率。所以现在生成的字节码大变样了。

for(var i=0;i<4000000;i+=1);
.number 4e+006
0x00000000: intg   0x00000001
0x00000001: pzero  0x00000000
0x00000002: loadg  0x00000000
0x00000003: callg  0x00000000
0x00000004: pnum   0x00000000 (4e+006)
0x00000005: less   0x00000000
0x00000006: jf     0x0000000c
0x00000007: pone   0x00000000
0x00000008: mcallg 0x00000000
0x00000009: addeq  0x00000000
0x0000000a: pop    0x00000000
0x0000000b: jmp    0x00000003
0x0000000c: nop    0x00000000

version 6.5 vm (last update 2021/6/24)

2021/5/31 update:

现在垃圾收集器不会错误地重复收集未使用变量了。

添加了builtin_alloc以防止在运行内置函数的时候错误触发标记清除。

建议在获取大空间数组的时候尽量使用setsize因为append在被频繁调用时可能会频繁触发垃圾收集器。

2021/6/3 update:

修复了垃圾收集器还是他妈的会重复收集的bug这次我设计了三个标记状态来保证垃圾是被正确收集了。

callf指令拆分为callfvcallfh。并且callfv将直接从val_stack获取传参,而不是先通过一个vm_vec把参数收集起来再传入,后者是非常低效的做法。

建议更多使用callfv而不是callfh,因为callfh只能从栈上获取参数并整合为vm_hash之后才能传给该指令进行处理,拖慢执行速度。

var f=func(x,y){return x+y;}
f(1024,2048);
.number 1024
.number 2048
.symbol x   
.symbol y
0x00000000: intg   0x00000001
0x00000001: newf   0x00000007
0x00000002: intl   0x00000003
0x00000003: offset 0x00000001
0x00000004: para   0x00000000 (x)
0x00000005: para   0x00000001 (y)
0x00000006: jmp    0x0000000b
0x00000007: calll  0x00000001
0x00000008: calll  0x00000002
0x00000009: add    0x00000000
0x0000000a: ret    0x00000000
0x0000000b: loadg  0x00000000
0x0000000c: callg  0x00000000
0x0000000d: pnum   0x00000000 (1024)
0x0000000e: pnum   0x00000001 (2048)
0x0000000f: callfv 0x00000002
0x00000010: pop    0x00000000
0x00000011: nop    0x00000000

2021/6/21 update:

现在垃圾收集器不会收集空指针了。并且调用链中含有函数调用的赋值语句现在也可以执行了,下面这些赋值方式是合法的:

var f=func()
{
    var _=[{_:0},{_:1}];
    return func(x)
    {
        return _[x];
    }
}
var m=f();
m(0)._=m(1)._=10;

[0,1,2][1:2][0]=0;

在老版本中,语法分析器会检查左值,并且在检测到有特别调用的情况下直接告知用户这种左值是不被接受的(bad lvalue)。但是现在它可以正常运作了。为了保证这种赋值语句能正常执行codegen模块会优先使用codegen::call_gen()生成前面调用链的字节码而不是全部使用 codegen::mcall_gen(),在最后一个调用处才会使用codegen::mcall_gen()

所以现在生成的相关字节码也完全不同了:

.number 10
.number 2
.symbol _
.symbol x
0x00000000: intg   0x00000002
0x00000001: newf   0x00000005
0x00000002: intl   0x00000002
0x00000003: offset 0x00000001
0x00000004: jmp    0x00000017
0x00000005: newh   0x00000000
0x00000006: pzero  0x00000000
0x00000007: happ   0x00000000 (_)
0x00000008: newh   0x00000000
0x00000009: pone   0x00000000
0x0000000a: happ   0x00000000 (_)
0x0000000b: newv   0x00000002
0x0000000c: loadl  0x00000001
0x0000000d: newf   0x00000012
0x0000000e: intl   0x00000003
0x0000000f: offset 0x00000002
0x00000010: para   0x00000001 (x)
0x00000011: jmp    0x00000016
0x00000012: calll  0x00000001
0x00000013: calll  0x00000002
0x00000014: callv  0x00000000
0x00000015: ret    0x00000000
0x00000016: ret    0x00000000
0x00000017: loadg  0x00000000
0x00000018: callg  0x00000000
0x00000019: callfv 0x00000000
0x0000001a: loadg  0x00000001
0x0000001b: pnum   0x00000000 (10.000000)
0x0000001c: callg  0x00000001
0x0000001d: pone   0x00000000
0x0000001e: callfv 0x00000001
0x0000001f: mcallh 0x00000000 (_)
0x00000020: meq    0x00000000
0x00000021: callg  0x00000001
0x00000022: pzero  0x00000000
0x00000023: callfv 0x00000001
0x00000024: mcallh 0x00000000 (_)
0x00000025: meq    0x00000000
0x00000026: pop    0x00000000
0x00000027: pzero  0x00000000
0x00000028: pzero  0x00000000
0x00000029: pone   0x00000000
0x0000002a: pnum   0x00000001 (2.000000)
0x0000002b: newv   0x00000003
0x0000002c: slcbeg 0x00000000
0x0000002d: pone   0x00000000
0x0000002e: pnum   0x00000001 (2.000000)
0x0000002f: slc2   0x00000000
0x00000030: slcend 0x00000000
0x00000031: pzero  0x00000000
0x00000032: mcallv 0x00000000
0x00000033: meq    0x00000000
0x00000034: pop    0x00000000
0x00000035: nop    0x00000000

从上面这些字节码可以看出,mcall/mcallv/mcallh指令的使用频率比以前减小了一些,而call/callv/callh/callfv/callfh则相反。

并且因为新的数据结构,mcall指令以及addr_stack,一个曾用来存储指针的栈,从vm中被移除。现在vm使用nas_val** mem_addr来暂存获取的内存地址。这不会导致严重的问题,因为内存空间是 获取即使用 的。

version 7.0 vm (last update 2021/10/8)

2021/6/26 update:

指令分派方式从call-threading改为了computed-goto。在更改了指令分派方式之后vm的执行效率有了非常巨大的提升。现在虚拟机可以在0.2秒内执行完test/biglooptest/pi并且在linux平台虚拟机可以在0.8秒内执行完test/fib。你可以在下面的测试数据部分看到测试的结果。

这个分派方式使用了g++扩展"labels as values"clang++目前也支持这种指令分派的实现方式。(不过MSVC支不支持就不得而知了哈哈)

gc中也有部分改动: 全局变量不再用std::vector存储,而是全部存在操作数栈上(从val_stack+0val_stack+intg-1)。

2021/6/29 update:

添加了一些直接用常量进行运算的指令: op_addc,op_subc,op_mulc,op_divc,op_lnkc,op_addeqc,op_subeqc,op_muleqc,op_diveqc,op_lnkeqc

现在test/bigloop.nas的字节码是这样的:

.number 4e+006
.number 1
0x00000000: intg   0x00000001
0x00000001: pzero  0x00000000
0x00000002: loadg  0x00000000
0x00000003: callg  0x00000000
0x00000004: pnum   0x00000000 (4000000)
0x00000005: less   0x00000000
0x00000006: jf     0x0000000b
0x00000007: mcallg 0x00000000
0x00000008: addeqc 0x00000001 (1)
0x00000009: pop    0x00000000
0x0000000a: jmp    0x00000003
0x0000000b: nop    0x00000000

在这次更新之后这个测试文件可以在0.1秒内运行结束。大多数的运算操作速度都有提升。

并且赋值相关的字节码也有一些改动。现在赋值语句只包含一个标识符时,会优先调用op_load来赋值,而不是使用op_meqop_pop

var (a,b)=(1,2);
a=b=0;
.number 2
0x00000000: intg   0x00000002
0x00000001: pone   0x00000000
0x00000002: loadg  0x00000000
0x00000003: pnum   0x00000000 (2)
0x00000004: loadg  0x00000001
0x00000005: pzero  0x00000000
0x00000006: mcallg 0x00000001
0x00000007: meq    0x00000000 (b=2 use meq,pop->a)
0x00000008: loadg  0x00000000 (a=b use loadg)
0x00000009: nop    0x00000000

version 8.0 vm (last update 2022/2/12)

2021/10/8 update:

从这个版本开始vm_nilvm_num不再由gc管理,这会大幅度降低gc::alloc的调用并且会大幅度提升执行效率。

添加了新的数据类型: vm_obj。这个类型是留给用户定义他们想要的数据类型的。相关的API会在未来加入。

功能完备的闭包:添加了读写闭包数据的指令。删除了老的指令op_offset

2021/10/13 update:

字节码信息输出格式修改为如下形式:

0x000002f2: newf   0x2f6
0x000002f3: intl   0x2
0x000002f4: para   0x3e ("x")
0x000002f5: jmp    0x309
0x000002f6: calll  0x1
0x000002f7: lessc  0x0 (2)
0x000002f8: jf     0x2fb
0x000002f9: calll  0x1
0x000002fa: ret
0x000002fb: upval  0x0[0x1]
0x000002fc: upval  0x0[0x1]
0x000002fd: callfv 0x1
0x000002fe: calll  0x1
0x000002ff: subc   0x1d (1)
0x00000300: callfv 0x1
0x00000301: upval  0x0[0x1]
0x00000302: upval  0x0[0x1]
0x00000303: callfv 0x1
0x00000304: calll  0x1
0x00000305: subc   0x0 (2)
0x00000306: callfv 0x1
0x00000307: add
0x00000308: ret
0x00000309: ret
0x0000030a: callfv 0x1
0x0000030b: loadg  0x32

2022/1/22 update:

删除op_poneop_pzero。这两个指令在目前已经没有实际意义,并且已经被op_pnum替代。

version 9.0 vm (last update 2022/5/18)

2022/2/12 update:

局部变量现在也被 存储在栈上。 所以函数调用比以前也会快速很多。 在v8.0如果你想调用一个函数, 新的vm_vec将被分配出来用于模拟局部作用域,这个操作会导致标记清除过程会被频繁触发并且浪费太多的执行时间。 在测试文件test/bf.nas中,这种调用方式使得大部分时间都被浪费了,因为这个测试文件包含大量且频繁的函数调用(详细数据请看测试数据一节中version 8.0 (R9-5900HX ubuntu-WSL 2022/1/23))。

现在闭包会在第一次在局部作用域创建新函数的时候产生,使用vm_vec。 在那之后如果再创建新的函数,则他们会共享同一个闭包,这些闭包会在每次于局部作用域创建新函数时同步。

2022/3/27 update:

在这个月的更新中我们把闭包的数据结构从vm_vec换成了一个新的对象vm_upval,这种类型有着和另外一款编程语言 Lua 中闭包相类似的结构。

同时我们也修改了字节码的输出格式。新的格式看起来像是 objdump:

  0x0000029b:       0a 00 00 00 00        newh

func <0x29c>:
  0x0000029c:       0b 00 00 02 a0        newf    0x2a0
  0x0000029d:       02 00 00 00 02        intl    0x2
  0x0000029e:       0d 00 00 00 66        para    0x66 ("libname")
  0x0000029f:       32 00 00 02 a2        jmp     0x2a2
  0x000002a0:       40 00 00 00 42        callb   0x42 <__dlopen@0x41dc40>
  0x000002a1:       4a 00 00 00 00        ret
<0x29c>;

  0x000002a2:       0c 00 00 00 67        happ    0x67 ("dlopen")

func <0x2a3>:
  0x000002a3:       0b 00 00 02 a8        newf    0x2a8
  0x000002a4:       02 00 00 00 03        intl    0x3
  0x000002a5:       0d 00 00 00 68        para    0x68 ("lib")
  0x000002a6:       0d 00 00 00 69        para    0x69 ("sym")
  0x000002a7:       32 00 00 02 aa        jmp     0x2aa
  0x000002a8:       40 00 00 00 43        callb   0x43 <__dlsym@0x41df00>
  0x000002a9:       4a 00 00 00 00        ret
<0x2a3>;

  0x000002aa:       0c 00 00 00 6a        happ    0x6a ("dlsym")

version 10.0 vm (last update 2022/8/16)

2022/5/19 update:

在这个版本中我们给nasal加入了协程:

var coroutine={
    create: func(function){return __cocreate;},
    resume: func(co)      {return __coresume;},
    yield:  func(args...) {return __coyield; },
    status: func(co)      {return __costatus;},
    running:func()        {return __corun;   }
};

coroutine.create用于创建新的协程对象。不过创建之后协程并不会直接运行。

coroutine.resume用于继续运行一个协程。

coroutine.yield用于中断一个协程的运行过程并且抛出一些数据。这些数据会被coroutine.resume接收并返回。而在协程函数中coroutine.yield本身只返回vm_nil

coroutine.status用于查看协程的状态。协程有三种不同的状态:suspended挂起,running运行中,dead结束运行。

coroutine.running用于判断当前是否有协程正在运行。

注意: 协程不能在其他正在运行的协程中创建。

接下来我们解释这个协程的运行原理:

op_callb被执行时,栈帧如下所示:

+----------------------+(主操作数栈)
| old pc(vm_ret)       | <- top[0]
+----------------------+
| old localr(vm_addr)  | <- top[-1]
+----------------------+
| old upvalr(vm_upval) | <- top[-2]
+----------------------+
| local scope(var)     |
| ...                  |
+----------------------+ <- local pointer stored in localr
| old funcr(vm_func)   | <- old function stored in funcr
+----------------------+

op_callb执行过程中,下一步的栈帧如下:

+----------------------+(主操作数栈)
| nil(vm_nil)          | <- push nil
+----------------------+
| old pc(vm_ret)       |
+----------------------+
| old localr(vm_addr)  |
+----------------------+
| old upvalr(vm_upval) |
+----------------------+
| local scope(var)     |
| ...                  |
+----------------------+ <- local pointer stored in localr
| old funcr(vm_func)   | <- old function stored in funcr
+----------------------+

接着我们调用resume,这个函数会替换操作数栈。我们会看到,协程的操作数栈上已经保存了一些数据,但是我们首次进入协程执行时,这个操作数栈的栈顶将会是vm_ret,并且返回的pc值是0

首次调用时,为了保证栈顶的数据不会被破坏,resume会返回gc.top[0]op_callb将会执行top[0]=resume(),所以栈顶的数据虽然被覆盖了一次,但是实际上还是原来的数据。

+----------------------+(协程操作数栈)
| pc:0(vm_ret)         | <- now gc.top[0]
+----------------------+

当我们调用yield的时候,该函数会执行出这个情况,我们发现op_callb 已经把nil放在的栈顶。但是应该返回的local[1]到底发送到哪里去了?

+----------------------+(协程操作数栈)
| nil(vm_nil)          | <- push nil
+----------------------+
| old pc(vm_ret)       |
+----------------------+
| old localr(vm_addr)  |
+----------------------+
| old upvalr(vm_upval) |
+----------------------+
| local scope(var)     |
| ...                  |
+----------------------+ <- local pointer stored in localr
| old funcr(vm_func)   | <- old function stored in funcr
+----------------------+

builtin_coyield执行完毕之后,栈又切换到了主操作数栈上,这时可以看到返回的local[1]实际上被op_callb放在了这里的栈顶:

+----------------------+(主操作数栈)
| return_value(var)    |
+----------------------+
| old pc(vm_ret)       |
+----------------------+
| old localr(vm_addr)  |
+----------------------+
| old upvalr(vm_upval) |
+----------------------+
| local scope(var)     |
| ...                  |
+----------------------+ <- local pointer stored in localr
| old funcr(vm_func)   | <- old function stored in funcr
+----------------------+

所以主程序会认为顶部这个返回值好像是resume返回的。而实际上resume的返回值在协程的操作数栈顶。综上所述:

resume (main->coroutine) return coroutine.top[0]. coroutine.top[0] = coroutine.top[0];
yield  (coroutine->main) return a vector.         main.top[0]      = vector;

发行日志

version 8.0 release

这个版本的发行版有个 严重的问题:

in nasal_dbg.h:215: auto canary=gc.stack+STACK_MAX_DEPTH-1;

这个会导致不正确的stackoverflow报错。因为它覆盖了原有的变量。 请修改为:

canary=gc.stack+STACK_MAX_DEPTH-1;

如果不修改这一行,调试器运行肯定是不正常的。在v9.0第一个commit中我们修复了这个问题。

另外一个bug在 nasal_err.h:class nasal_err这边,要给这个类添加一个构造函数来进行初始化,否则会出问题:

    nasal_err(): error(0) {}

同样这个也在v9.0中修复了。所以我们建议不要使用v8.0

version 11.0 release

  1. 使用C++标准 std=c++17

  2. 改变语法树设计模式,采用访问者模式。

  3. 全新的语法树结构输出格式。

  4. 改变了导出模块的方式,把主要的库分成了多个模块。以_开头的变量不会被导出。

  5. 文件夹stl更名为std

  6. 添加交互式解释器 (REPL)。

  7. 优化虚拟机结构, 将全局数据栈 (存储全局变量的数据) 和操作数据栈 (用于运算) 分离。

  8. 删除op_intg指令,添加op_repl指令。

  9. 添加CMakeLists.txt (可在Visual Studio中使用)。

  10. 全新的自定义类型注册流程。

version 11.1 release

  1. Bug 修复: 修复 v11.0 的 debugger 无法启动的问题。

  2. Bug 修复: symbol_finder 不检查 foreach/forindex 中的迭代变量声明的问题。

  3. 扩展语法 import.xx.xx 改为 use xx.xx