三、嵌入式之虚拟机分析 (1) rt-thread的shell


1 简介

1.1 说明

little c 解释器
http://blog.csdn.net/redraiment/article/details/4693952
编译器:https://www.amobbs.com/thread-5536737-1-1.html

这里面所说的虚拟机,其实也算是元编程的一种实现方式。

目前的虚拟机有以下几种:
CLR, JVM, Parrot, LLVM。

要在小型单片机上运行编译器,我暂时想到两个办法:
(1)如果程序空间足够装下编译器代码,但内存不够,可以利用SD卡,将asmcode[]、mcode[]等存入SD卡(或其它存储设备)。
(2)如果程序空间只能装下虚拟机的代码,则可以将这个编译器的C代码用“简单C” 的语法重新写一下,(在电脑上)编译成机器码存入SD卡中,让单片机运行虚拟机,运行SD卡中的代码,就是说在虚拟机中运行编译器;

比较值得有参考价值的虚拟机分析:参考链接

而标准规范的java虚拟机文档:链接

1.2 名词

1.2.1 前缀表达式

也称为前缀记法、波兰式。称作波兰式是为了纪念其发明者波兰数学家Jan Lukasiewicz。

1.2.2 中缀表达式

同类型的还有前缀表达式,后缀表达式。

中缀表达式是指运算符在运算数的中间,计算中缀表达式时需要用到两个栈:数字栈和运算符栈。在整个中缀表达式求值的过程中主要涉及到的模块有:栈的相关操作、优先级表的确立、输入的待计算字符串拆分为数字和运算符以及运算处理等

有一个比较不错的写法,链接

1.2.3 后缀表达式

后缀表达式(又称为逆波兰reverse polish)就是不需要括号就可以实现调整运算顺序的一种技巧。

1.2.3 二叉树

可以通过不同遍历方式组合成中辍表达式和后缀表达式。
二叉树定义:每个节点最多有两个子树的树结构。

2 rt-thread的finsh shell

先从rt-thead的finsh shell开始研究。
rt-thread的github链接地址:下载链接

整体代码逻辑:
在finsh_run_line中调用以下三个主要函数,完成大体功能。

  • finsh_parser_run
    完成xxxx
  • finsh_compiler_run
  • finsh_vm_run

这样的流程,在我的理解来看,就是一个 解析器(语法解析器),编译器(解释器),虚拟机执行的过程。

对finsh_parser_run进行分析:


  • 若在shell中输入表达式: 1 + 1
  • a.执行: proc_expr_statement
  • b.执行: proc_expr
  • c.执行:proc_assign_expr
  • d.执行:proc_inclusive_or_expr,带有后续处理
  • e.执行:proc_exclusive_or_expr,带有后续处理
  • f.执行:proc_and_expr,有后续处理
  • g.执行:proc_shift_expr,有后续处理
  • h.执行:proc_additive_expr,有后续处理
  • i.执行:proc_multiplicative_expr,有后续处理
  • j.执行:proc_cast_expr,有后续处理
  • k.执行:proc_unary_expr
  • l.执行:proc_postfix_expr,调用该函数之前调用了finsh_token_replay。
  • m.执行:proc_primary_expr,最终执行到底。

执行到proc_primary_expr后,能识别出1。也就是说,执行到分支:finsh_token_type_value_int。同时往数组global_node_table添加一个finsh_node。

然后:

  1. 返回到proc_postfix_expr,其中将该函数返回值赋值给struct finsh_node* postfix;该值应该就是前面识别出来的结点1。随后调用next_token,经过一系列处理,token的值为:finsh_token_type_add。然后该函数仍旧执行了finsh_token_replay,返回了上面提到的postfix。
  2. 返回到proc_unary_expr,仍旧继续返回。返回到,proc_cast_expr。
  3. proc_cast_expr后,返回的仍然是1的结点。
  4. 后面一直返回到proc_additive_expr,其调用完成proc_multiplicative_expr后,执行next_token,而token的值为finsh_token_type_add。mul是1的结点。mul_new返回值为 proc_multiplicative_expr的结果。这一次调用proc_multiplicative_expr,执行到proc_cast_expr,搜寻next_token,这个时候找到另外一个1结点。最终mul_new就是另外一个1结点了。这个结点就是global_node_table创建的第二个值了。
  5. 返回到proc_additive_expr,执行make_sys_node,创建FINSH_NODE_SYS_ADD结点。后执行赋值,node->child(+)=先找到的结点1,结点1->sibling=结点1。最后返回FINSH_NODE_SYS_ADD结点。
  6. 最后返回到self->root = node;那么self->root就是前面提到的FINSH_NODE_SYS_ADD结点。

  • 对finsh_parser_run进行分析:

  • a.执行finsh_type_check
  • b.清除text_segment,finsh_vm_stack。设置finsh_compile_sp,finsh_compile_pc。
  • c.获取sibling node,调用finsh_compile,传参数为root node,如果有sibling,则弹出栈sibling node。

重点在分析finsh_compile上:

  • a.判断node->child是否为空,否则编译child结点。这里使用的递归的操作方法。
  • b.判断node->node_type,最先判断的child的node_type,为FINSH_NODE_VALUE_INT。接着执行,
finsh_code_byte(FINSH_OP_LD_DWORD);
finsh_code_dword(node->value.long_value);
  • c.递归获取到child->sibling结点。编译sibling结点。
finsh_code_byte(FINSH_OP_LD_DWORD);
finsh_code_dword(node->value.long_value);
  • d.判断的是root->node_type,是FINSH_NODE_SYS_ADD。执行:
finsh_code_byte(FINSH_OP_ADD_BYTE);

从这里看,仍然是一种后缀表达式的形式。

  • 对finsh_vm_run进行分析:

    finsh_sp = &finsh_vm_stack[0];

    /* set pc to the beginning of text segment */
    finsh_pc = &text_segment[0];

    while ((finsh_pc - &text_segment[0] >= 0) &&
        (finsh_pc - &text_segment[0] < FINSH_TEXT_MAX))
    {
        /* get op */
        op = *finsh_pc++;

        /* call op function */
        op_table[op]();
    }

在前面说的compile中,数据存储的内容为:
0x24
4字节数值,低字节在前,高字节在后。
0x24
4字节数值,低字节在前,高字节在后。
0x03

所以,虚拟机执行的为:

  • a.执行OP_ld_dword,获取4字节数据,然后放到栈上。同时pc指针前进。
  • b.继续执行a步骤操作。
  • c.执行OP_add_dword,最终调用OP_BIN_DWORD,将栈中数据进行处理,同时出栈指针递减,也就是出栈数据。

自此,按照例子 1 + 1的整个路径分析完成。

那么,三个函数做的事情,总结如下:

  • finsh_parser_run主要是将字符串,一个个分拆,也就是编译原理中会说的词法分析。然后形成一棵树。
  • finsh_compiler_run主要是将树中的数据提取出来,放到虚拟机中的代码段中。这里并没有区分数据区和代码区,一股脑的放在一起。
  • finsh_vm_run主要是将代码段中的数据,根据操作码,提取出数据,放到栈中。这里面用到了后缀表达式,先提取出两个数,然后第三个数是+,-,*,/等等的操作码,计算完成后,可以出一个栈的数据,然后继续处理。

接着再来看看输入一个函数,是怎样的执行流程。比如hello():
我先说明一点,输入函数,函数的参数个数,rt-thread的shell并没有做检测,这应该是一个失误,是一个很不好的设计。

    1. finsh_parser_run中调用token_run中,获取到current_token为finsh_token_type_identifier。后执行到proc_primary_expr,执行finsh_node_new_id(),会先调用finsh_var_lookup(),没找到就调用finsh_sysvar_lookup(),仍没找到,就调用finsh_syscall_lookup(),当然按照例子上来看,最终是找到了。最后会调用finsh_node_allocate(),分配结点,node_type为FINSH_NODE_ID。也就是下面这几个:
global_node_table[i].node_type = FINSH_NODE_ID;
node->id.syscall = xxx;
node->idtype = FINSH_IDTYPE_SYSCALL;
  1. 执行到函数proc_postfix_expr(),调用完proc_primary_expr()后,执行next_token(),进入分支finsh_token_type_left_paren,接着就去寻找右括号。我们不看找不到右括号或者带参数情况,只看找到了右括号后的执行路径。
    3.调用make_sys_node,这个时候,添加结点FINSH_NODE_SYS_FUNC,并把上面的FINSH_NODE_ID添加进来。也就是说,root为FINSH_NODE_SYS_FUNC结点,child为FINSH_NODE_ID,sibling为空。
    4.调用finsh_compile,进入分支FINSH_NODE_ID,判断child是否为空,若不为空,则调用
                    finsh_code_byte(FINSH_OP_LD_DWORD);
                    finsh_code_dword((long)node->id.syscall->func);

在代码段中形成的结构为:
0x24
函数地址
接着执行FINSH_NODE_SYS_FUNC,调用:

                    finsh_code_byte(FINSH_OP_SYSCALL);
                    finsh_code_byte(parameters);

其中参数个数为0
在代码段中存储的数据为:
0x2C
0

  • 执行虚拟机函数finsh_vm_run(),调用OP_ld_dword(),将函数地址入栈,finsh_sp->long_value=函数地址。然后调用OP_call(),获取参数个数,当然为0。后面就是相关的函数调用了。

好了,rt-thread的finsh shell就讲到这里。实话说,其调用关系比较层叠。看起来每个函数调用关系分开,写得很好。但是,目前很多的特性,支持力度还是有限的。

后想了想,对于我们常见的说法,函数参数列表先入栈的是最后一个参数,这里可以验证一下:

找到函数proc_param_list,这个就是处理参数列表的。
最终调用到proc_cast_expr() -->proc_type()---> 找到第一个参数(我们先分析参数全为数字),则token为finsh_token_type_value_int---->递归调用proc_cast_expr()--->proc_unary_expr()--->proc_postfix_expr()--->proc_primary_expr()--->finsh_node_new_int()。
然后递归返回,node->data_type = finsh_type_unknown;node->node_type = FINSH_NODE_VALUE_INT;
当然在proc_postfix_expr()中调用的next_token,会设置token==finsh_token_type_comma。--->proc_cast_expr()--->next_token()--->FINSH_NODE_VALUE_INT。etc

    while (token == finsh_token_type_comma )
    {
        finsh_node_sibling(assign) = proc_assign_expr(self);

        if (finsh_node_sibling(assign) != NULL) assign = finsh_node_sibling(assign);
        else finsh_error_set(FINSH_ERROR_EXPECT_OPERATOR);

        next_token(token, &(self->token));
    }

从这个循环代码中,可以发现是一直在建立siblings。后面finsh_compile被调用,然后递归生效,最后一个参数被加入到栈中。

所以,很明显咯,是递归导致的栈序改变。

还可以看看rt-thread的shell的函数调用,一层一层的,其实是按照操作符的优先级来的。

2.1 一些扩展知识

在写完一些内容之后,发现finsh shell有一些命名技巧,这就涉及到一些知识。我也是边看就搜咯。

2.1.1 siblings

这个在二叉树上有介绍,表示的是兄弟结点。二叉树有完美二叉树、完全二叉树和完满二叉树。

写到这里,我只好表示,我的树知识欠缺,只好另外开一个栏,学习树了。
链接:链接地址

2.2 分析开始

分析之初,这几个结构体比较重要:

3 picoC

源码的下载地址为:下载链接

从其README.md上看,其说明了picoC是一个小的C解释器脚本。它最初是作为一个无人机的飞行系统板上的脚本语言。核心的C源代码是大约3500行。并不打算成为一个完整的ISO C实现。picoc已经在x86-32,x86-64,PowerPC,arm,UltraSPARC,HP-PA和Blackfin处理器上测试过,并很容易地移植到新的目标。

picoc代码上看,基本有如下几块:lex词法解析,table一个基本数据结构(用于存放变量),是个字符串hash表,heap管理内存分配(包括了stack frame的实现), type做类型管理(基本型别和程序自定义的struct,typedef等),expression做表达式解析,variable变量管理分配释放栈帧。

2.1 编译

在NIX/Linux/POSIX主机上:

  • make
  • make test

之后,可以执行./picoc。
可以有三种执行方式:

三、嵌入式之虚拟机分析 (1) rt-thread的shell_第1张图片
图1

2.2 移植

  1. platform_XXX.c 包含一些支持性的函数。编译器这样才能在你的平台上运行。例如如何向控制台输出字符。
  2. platform_library.c包含一些函数库,这些库是提供给用户程序的。
  3. 有clibrary.c,包含用户库函数,比如printf,这些是平台无关的。

移植系统,需要在platform.h中设置相应的includes和defines。在platform_XXX.c中编写I/o函数。在platform_library.c 中编写用户函数。在picoc.c中编写一些main函数内容。

platform.h默认设置为unix主机(UNIX_HOST)。

2.3 协议

picoc遵循"New BSD License".

你可能感兴趣的:(三、嵌入式之虚拟机分析 (1) rt-thread的shell)