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。
然后:
- 返回到proc_postfix_expr,其中将该函数返回值赋值给struct finsh_node* postfix;该值应该就是前面识别出来的结点1。随后调用next_token,经过一系列处理,token的值为:finsh_token_type_add。然后该函数仍旧执行了finsh_token_replay,返回了上面提到的postfix。
- 返回到proc_unary_expr,仍旧继续返回。返回到,proc_cast_expr。
- proc_cast_expr后,返回的仍然是1的结点。
- 后面一直返回到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创建的第二个值了。
- 返回到proc_additive_expr,执行make_sys_node,创建FINSH_NODE_SYS_ADD结点。后执行赋值,node->child(+)=先找到的结点1,结点1->sibling=结点1。最后返回FINSH_NODE_SYS_ADD结点。
- 最后返回到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并没有做检测,这应该是一个失误,是一个很不好的设计。
- 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;
- 执行到函数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。
可以有三种执行方式:
2.2 移植
- platform_XXX.c 包含一些支持性的函数。编译器这样才能在你的平台上运行。例如如何向控制台输出字符。
- platform_library.c包含一些函数库,这些库是提供给用户程序的。
- 有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".
网友评论