一. 开发目的. 一直以来, 都有一个梦想…想找到第一个编译器的源代码. 不用怀疑, 第一个编译器 一定是使用机器语言编写. 究竟它与现实的编译器开发有什么不同呢?....曾想过, 用汇编写编译器, 因为如果能用汇编写, 掌握了这个技术也意味着掌握了使用机器语言开发编译器的技术… 可是, 在实现中, 你会发现就连基本的数据结构如链表, 树, 散列都好像无法下手…因为当你使用汇编时, 也就意味着不存在指针(虽然高级汇编语言存在着指针, 但我更愿意说是地址). 于是, 我就在网上找呀找呀, 看所有资料…(我只能说之前自己关于汇编程序设计掌握得太少了) 当一切都准备好了, 于是我决定完成它---使用MASM32位汇编实现…我的编译课程的最后一个实验…. 二. 指导思想 1. 开发平台 操作系统: WINDOWS XP sp2 编译工具: MASM v9.0 调试工具: ODBG v1.08B 编辑器 : Editplus 2. 开发要求 TINY语言的扩充 扩充的语法规则有:实现 while、do while语句和求余计算式子,具体文法规则自行构造。
While-stmt --> while exp do stmt-sequence end Dowhile-stmt-->do stmt-sequence while exp
3. 编码规范 l szMsg前缀代表输出的提示信息.(chr$宏本可以产生字符串定义,观世界并返回串地址, 这样也利于可读性, 但后面发现这会导致代码会重复定义字符串导致代码空间膨胀, 所以前面使用了chr$, 后面改为在数据段自定义). l szFmtStr前缀代表格式化字符串. l h前缀代表句柄. l 枚举类型定义为可读采用自定义宏enum定义.(请见mymacros.asm文件) l ecx在词法分析中用作保存当前记号值 l eax, ebx, edx在后面阶段用于保存语法树结点的指针. 其中eax保存的是语法的根 l 一般函数返回值都放在eax中(除了一个函数) 4. 开发原则 l 采用高级的32位汇编, 可以使用一些高级特性, 如.if .while, 因为这些编码, 编译器可以做得比我们更好, 但在适当的时候, 为了代码高效放弃高级特性采用手写. l 采用宏提高编码速度和可读性, 但不滥用宏. l 把所有代码都放入一个自编译的.bat源代码文件中.(参考KmdTutEn文档, 一个用WIN32汇编开发内核驱动的资料) l 代码布局:
三. 开发实现 1. 词法分析 当然, 我们可以直接采用.if来实现.其主要工作其实就是翻译原TINY编译器的源C代 码. 但使用汇编时, 我们其实可以更用效!想想, 如果都是一大堆的条件判别语句, 编码是多么的无聊呀… 所以, 采用下面的方法, 那我们就可以把DFA转换成汇编代码更直接, 更有效, 虽然它会浪费一些空间. 对了, 我们可以采用表驱动的方法, 而且就如你将所看到的, 这在汇编中是多么的自然, 多么有效, 而且我们完全可以让这个工作自动化. 二维表的选择:我们完全可以直接定义一张记录状态转移的二维数组, 但是这样, 每当我们找到状态时, 还要进行判断, 然后执行相代码. 这和之前直接用条件判别语句其实没有多大差别, 同时它执行会很慢.因此, 有没有更好的方法, 使得执行代码与状态转移可以相结合呢?当我一得到状态,就可以跳到相关代码执行.解决方法是采用一个一维数组记录原二维表的列(就是词汇表), 而对于原来的二维表的行(状态), 我们可以在过程(getToken)中定义跳跃表使得它与代码相关联.而对于代码的选择我们也可以不用比较了!我们可以使用一条换码指令(xlat)完成!
上面是TINY的DFA表, 首先我们必须把列(词汇表)在数据段定义. 在数据段中开辟一个一维数组, 长度为256(ASCII码表长)把ACSII码与列值相对应起来,如代码中: .data ;define the DFA table col Classify char 256 dup(17) 本来我们可以直接定义, 可是为了方便, 我选择了使用过程填写, 当然真正编写中, 应该直接定义.填写的过程在fillTab中: 部分代码如下, 详见源文件: ;::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: ; fillTab ; fill the DFA tab's col ,may be you will ask how about DFA tab's row, you will see it define ; in DFA produre, here i use a assembly tip. The jump table.Jump table's always see in assembly ; code. This is hight language could not to give us! ;::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: FillTab proc uses ebx ecx lea ebx, Classify ;取得表的地址 mov esi, 48 assume ebx: ptr byte digit_: mov [ebx+esi], 0 ;如果是数字,则与列值0对应 inc esi cmp esi, 57 jbe digit_ mov esi, 65 ualpha_: mov [esi+ebx], 1 ;如果是字母,则与列值1对应, 其余类似 inc esi cmp esi, 90 jbe ualpha_ mov esi, 97 lalpha_: mov [esi+ebx], 1 inc esi cmp esi, 122 jbe lalpha_ …………. 定义了一维词汇表后, 那么接下来我们编写getToken函数, 词法分析的核心程序, 部分代码如下: getToken proc uses ebx eax xor eax, eax xor ecx, ecx mov edi, offset tokenString lea ebx, Classify State0: invoke getNextChar xlat Classify mov cl, TokenTab[eax] cmp al,3 jb S0next cmp al,5 ja S0next dec edi S0next: jmp State0Tab[eax*4] TokenTab TType TNONE, TNONE, TNONE, TNONE, TNONE, TENDFILE, TEQ, TLT, / TPLUS, TMINUS, TTIMES, TOVER, TMOD, TLPAREN,TRPAREN,TSEMI, / TERROR, TERROR State0Tab dword State3, State4, State2, State0, State1, State5, State5, State5,/ State5, State5, State5, State5, State5, State5, State5, State5,/ State5, State5 ………………… 注意到了吗 State0 后面定义的与DFAS转换的是一致的, 同时它们也是代码的标识, 也就是代码段的地址, 在一些书中称为跳跃表. 那么我们就可以把状态转换与代码相关联起来了.当换码后, 我们就可以立即进入下一个状态的代码.当然, 为了方便操作, 我还是加入了一些条件判断, 用于完成一些细微工作.通过这样, 我们就可以提高编码的可读性和效率, 同时也使得代码运行更为有效.当然, 更深远的, 这样的编码完全可以通过自动生成, 当然这并不在本开发的范围之内. 2. 词法分析的小改进. 在原TINY程序中,对于查找保留字是通过线性搜索保留字表实现.而通过比较各种保留字我们是有可能设计出最小完美哈希函数的.通过比较各个关键字,关键字表如下所示:
我们可以发现只要利用串中的第二,第三位我们就可以唯一确定出串是否属于一个保留字.当然这里我们为了能够唯一散列,我们需要一些空间”浪费”,对于10个字符,我们需要19的空间长度.哈希函数如下: Key = (s[1]+s[2]) %19 现在,在查找时,我们把原来的O(n)的时间复杂度降到O(1).当然对于TINY编译器来说,10个保留字可能并不意味着什么.而实际中,对于一个真正编译器,通常有30至60的关键字,这样做就有意义了.毕竟词法扫描在整个编译过程中是最耗时的. 最后,我们看看在汇编中如果实现,核心代码如下所示: ;::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: ; hashLookup ; edx save the hash valus (s[1]+s[2])%19 ; then we use the cmpsb to find the token is realy a token.Sure if the hash value is 0 ; that mean it can't be the keyword, see the hashtab define ;::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: hashLookup proc uses eax edx edi esi xor eax, eax xor edx, edx mov ecx, edi mov edi, offset tokenString sub ecx, edi mov al, [edi+1] add al, [edi+2] mov ebx, 19 div ebx mov esi, reservedWordTab[edx*4] cmp esi, 0 je hid cld repe cmpsb jne hid mov cl, hashTab[edx] ret hid: mov cl, TID ret hashLookup endp 还记得吗?esi指向的是输入源文件缓冲,edi是读入的记号串,我们通过除法命令得到%运算的结果,并把哈希值放入al中.然后改变esi的串指向,使其指向保留字表相应的”桶”,通过使用串比较命令比较,如果串相等则为保留字,否则为ID. 3. 语法分析. 在语法分析中, 我们依然使用递归子程序的方法,构造语法树.首先,我们规范几点.在程序中eax保存的都是当前的语法树结点指针,而ecx中保存的是由词法分析中传来的token值,同时,对于有时要对当前子树的孩子操作时我们都用ebx,edx使用,这样,我们相对于使用高级语言来说就少了很多临时变量,而实践起来说,只用寄存器保存变量也工作得很好.为了可读,且减少开发的复杂度,我还是倾向于使用WIN32汇编的高级特性.if,.while因为编译器对这些条件生成的汇编代码生成做得很好,并不降低汇编原有的精巧.所以,如果你看到相关代码,你就会发现代码与高级语言写起来是很相近的,这也是WIN32汇编的优越,有高级语言的语法,及汇编语言的自由,精巧,高效率.因此,我并不打算在这里解释相关递归子程序,相反我们讨论如何在汇编中使用动态数据结构. 如何在汇编语言中使用链表,树,散列表等数据结构呢?这是我自学习数据结构和汇编以来的疑问.难以想像,没有这些数据结构是如何做出最原始的操作系统,编译器….等系统.于是,我一直找相关资料,但是没有在一本数据结构,汇编书籍中提及.只有一些书上稍有提及,如<<Intel x86汇编程序设计>>和罗云彬<<WINDOWS环境下32位汇编语言程序设计>>,但是这里都是讲使用操作系统的API调用来动态申请堆空间实现.那么如果操作系统不支持呢?我们怎么办?或者说,在第一个操作系统还没诞生时,这些结构又应该怎么做呢?最后,我终于找到了相关外文资料.其实,真正实现这些支持,还是要通过堆,不过有两种方法:1就是上面说到的通过系统调用,使用操作系统提供的堆管理支持.2就是在自己的程序自己设计一个堆,并对其管理.最后,我终于明白了,其实动态特性也只是人为提供,在计算机本身只能是静态的…. 好了,明白这一点之后,我们就讲讲如何设计语法树.当然,操作系统都为我们提供了,所以我们不必再自己设计堆,只要简单地申请,释放就可以了.我们定义的语法树结构如下: ;define the treenode structure treeNode struct child dword MAXCHILDREN dup(NULL) sibling dword NULL lineno dword ? nodekind NKind ? kind byte ? ;StmtKind or Expkind attr dword ? ;include the TokenType, val, or name exptype EType ETNONE treeNode ends 由于,没有联合体,所以我们就要计算联合体的空间,并用一个域代替联合体的内容.就如attr属性所示,它本身包含了TokenType, val, name的含义,在不同的时候有不同的含义.怎么树的结点没有指针定义呢?是的, 在汇编中指针是概念上的, 也就是说真正是地址,因此,无论对于什么类型的指针, 在平坦模式下, 我们统一声明为dowrd就可以了.这也是使用汇编的自由, 所以Randall Hyde(The Art of Assembly Language Programming的作者) 说:”对于以前的程序员来说,编写数据结构是很简单的,数据结构是随着高级语言的出现而出现的.”对于学过编译原理的人来说,语法分析的递归子程序不难读,这里就省了. 4. 语义分析. TINY的语义分析是生成符号表,并作简单的类型检测.这里,我们把刚由语法分析生成的语法树(根结点保存在eax中),及使用一个寄存器edx用于记录内存分配的位置.,而ebx,ecx用于当对子树操作时保存其结点指针,这样我们也尽可能把所有运算放入到寄存器中.减少变量数.这里也是很简单.与原TINY代码差不多不作介绍. 5. 代码生成 2.0版的TINY编译器汇编版,支持生成TM代码生成及WIN32汇编代码生成.使用WIN32汇编代码作为编译器的目标代码,是为了更好的了解整个编译器的生成过程,且能使代码更可读,更易于理解.这里我们只讲关于WIN32汇编代码生成的一些步骤. 首先, 我们把WIN32汇编源代码分成四部分. l 头文件声明 l 数据段定义 l 代码段代码生成 l Make指令 由于我们采用生成可自编译的WIN32源代码文件,所以我们比一般汇编代码多了一部分,就是Make指令的生成.我们可以知道,关于头文件声明,及Make指令其实很多程序都是一致的,所以我们只要简单的把相关的串输出到目标文件(*.bat)中就可以了. 对于,数据段定义,我们是通过遍历整个符号表输出目标文件完成的,就是按以下格式输出: varName dword ? 由于TINY只有整型类型,所以只要全定义为varName就可以. 为了支持read,从控制台读入数据,我们还要提供长度为10的consoleBuffer consoleBuffer dd 10 dup(?) 当然,这里我们可以有改进的余地,就是不遍符号,而是通过在代码生成的时候,遇到ID结点就判断是否定义,如果没有定义就把其输出到代码文件中. 好,现在我们主要把精力放到代码段的生成中.首先,我们要解决的一大难题是如何解决回填?回填在原TINY程序中只是简单的用行标号显示,因此,代码是乱的, 这样的代码自然难读.那么我们如何生成漂亮的汇编代码呢?1种方法是用一大块内存缓冲区保存以易于频繁回填.但是这会导致编译代码空间相当大.2是通过临时文件,先在回填的地方预留一行,然后通过文件指针定位回填,当然这样的生成代码会东歪西斜的,我们可以在完成后,通过简单的把空白符删除完成.3是通过在栈中保存或在递归中局部保存.我在程序中使用第二种方法.而在生成代码中, 我们就要通过两个变量或寄存器保存当前的生成代码的位置及高端位置,用于回填后返回.我选用了esi, edi分别保存,因为这两个寄存器在代码生成中没用修改. 具体地,每当要跳过一行,因预计每行命令字符长度不超过100个,所以我选择每跳一行就输出100个空格,用于回填预留. 由于使用了汇编,我们不需要计算跳转地址,而相应地我们要生成标号,用于指示跳转.所以在程序中我用Lable计数,生成_Lable%d:格式的标号,以下划线开头是为了与变量声明相区分. 使生成代码支持read,write:为了支持这两条命令,我在程序中调用MASM库函数STDIN及STDOUT来支持控制台输入输出.从而基本完成TINY的指令要求. 对于运算表达式的翻译,我们把操作数1放入EAX中,把操作数2放入EBX中.这里并没有考虑寄存器分配,有待改善. 对于逻辑表达式,我们先计算出逻辑表达式的结果,通过对进位标志C设置来实现, 如果结果为真,清标志位C,如果为假则置进位标志,因此,当翻译在条件,循环的跳转语句时就通过判断C位实现. 最后,生成的代码是可自编译的,就是把文件放入存在的MASM编译器盘中任何一个位置,双击就可以生成EXE文件. 经测试,编译出的代码对所以语法结构都是正确的. 6. 感想 通过使用WIN32汇编开发TINY编译器,一方面掌握了不使用高级语言开发编译器的技巧,同时掌握了汇编中如何使用动态数据,也了解不使用虚拟机开发的代码生成的过程,及相关的一些小技巧(如回填) |
|