简单的C语言解释运行器实现(五)—— 语义分析

上一篇:语法分析
下一篇:生成指令

我们在语义分析阶段完成语法树节点类型的推导,并完成数组维度常量表达式的计算以及数组访问的修改。

定义类型

首先我们需要明确有哪些类型,我们简化的C语言只有如下类型:int, char, short, bool, long, double, float, void, pointer。还可以通过组合组合出很多类型出来,比如多维的指针,多维的数组

关于类型定义相关代码,请参照
https://github.com/huanghongxun/Compiler/blob/master/compiler/type_base.h
https://github.com/huanghongxun/Compiler/blob/master/compiler/type_primitive.h
https://github.com/huanghongxun/Compiler/blob/master/compiler/type_array.h
https://github.com/huanghongxun/Compiler/blob/master/compiler/type_pointer.h

我们对每个类型起一个标记符方便我们判断类型是否相等,比如int的助记符是idouble的助记符是dint[32][32]的助记符是A32_A32_iint const*const*的助记符是PKPKiP表示指针,K表示指向常量)等。这个助记符的由来可以使用c++的typeid(i).name()查看。

对于typedef,仅仅是一个alias而已,我们在记录类型的时候记录一下就好。

类型推导

C语言是强类型语言,我们在操作变量的时候必须确认运算符两侧的类型是否合法,比如指针不能和指针相加,但可以相减,结构体和其他类型的变量都不能运算,布尔类型以及无符号类型不能取负,整数与整数的除法和浮点数的除法的行为不同等。有这么多理由,我们必须要实现类型推导。
我们在初期可以限定类型必须为int来跳过类型推导以便迅速实现一个可用的解释器,不过要实现更高级的功能类型推导是不可缺少的。

首先我们一开始已经能知道语法树部分节点的类型,一般是常量和变量,还有函数调用的返回值的类型。我们根据这些已知的类型进行推导。

二元运算符的类型推导

对于一个语法树节点,如果我们知道节点子树的类型和运算符(这里只讨论二元运算符),那么会有2种情况,分别是类型相同和类型不同。对于类型不同的情况,我们需要做类型转换使得类型匹配,我们在匹配类型的时候先推导出两种类型的公共超类型,比如intdouble的公共超类型是doublecharbool的公共超类型是int(小于int的类型计算都规约到int再计算)(经过我的试验发现long long intfloat的超类型是float,也就是说浮点数的层级总体要更高一些)。已知超类型后,我们将两个孩子的类型都转换成公共的超类型,就完成了类型隐式转换。

为了方便生成中间代码,我们在语义分析推导出类型的时候就把隐式类型转换改为强制类型转换,即在原来子树和父节点之间插一个节点表示强制类型转换就行了。比如对于double+int有语法树:

  +
 / \
d   i

我们改了以后应该变成这样(C表示从intdouble的强制类型转换的节点):

  +
 / \
d   C
    |
    i

赋值运算符的类型推导

虽然赋值运算符是二元的运算符,但其类型推导的行为又不一样。赋值表达式的结果就是等号左侧的变量在赋值之后的值(而且是左值,比如我们可以这么写:(ans += 100) %= 7,还可以:*(a + 2) = 4,和a[2] = 4,并不是说等号左侧就一定是变量,还可以是其他可以修改的左值,左值的定义参见cppreference,种类还是比较多的),也就是说类型和等号左侧的左值的类型是一致的,而不是取超类型,因此我们还需要实现判断等号右侧的类型是否能直接隐式转换到左侧的类型。

同时不要忘了我们还需要推导函数调用的参数类型,如果我们要打算支持运算符重载,那么这个参数的类型将相当的有必要。这个参数的类型推导和赋值运算符的一样(相当于函数的参数被赋值了)。如果不能隐式转换就要报错。

最后,我们需要对所有基本类型、指针、数组和结构体之间是否能进行2种隐式类型转换列举哪些是可以的,规模是平方级。

关于基本类型的转换参考
https://github.com/huanghongxun/Compiler/blob/master/compiler/primitive_cast.cpp
https://github.com/huanghongxun/Compiler/blob/master/compiler/primitive_cast.h
指针的类型转换比较简单,只能从指向某个类型的指针隐式转换到指向某个类型的常量的指针,或者转换到void*,强制转换就随便转了。

左值类型推导

首先左值表示一个可修改的变量,类似1.0const int iint *const pint arr[10]都不是左值。而=+=++--等运算符要求操作数为左值,我们必须检查操作数是否是左值,否则将出现给不可修改的内存空间赋值的问题。
首先我们要确定哪些是左值,首先不是const的变量都是左值,注意const int *p是左值,因为这里的const表示p指向const int而不是p本身是const的。然后解引用后的非const值也是左值,因为我们知道这个值的地址(因为解引用),我们就可以修改它。比如*p = 1;是可以的。但是注意,如果我有int arr[10][10],那么*arr将不是左值,因为*arr此时指的是&arr[0],还是int[10]类型的数组,数组不是左值。其他的操作的结果都不会是左值。

数组索引

首先我们知道数组是可以当指针用的,比如a[0]实际上是*(a + 0);对于int b[3][3]b[1][2]实际上是*(*(b+1)+2)。数组在内存中是一段连续的空间,我们可以通过指针的移动实现对数组的访问。
对于*(*(b+1)+2),实际上b从某个地址,比如0x40,先转移到了0x4C,因为b的类型在这里相当于int (*)[3],表示指向数组int[3]的指针,那么+1将使b移动sizeof(int[3])=12=0xC的距离。然后取了一次地址,这里取地址并不会改变b的位置,数组比较特殊,如果连续存放的二维数组,那么内存中的存放将使一行一行横着接在一起连续摆放,我们移动了以后就直接到对应的那一行的首元素的地址了,所以取地址不应该改变b的值,只改变b的类型。第二次的+1将使b移动到0x54的位置,因为此处b的类型就是int*

因此我们对于表示调用变量的语法树节点,改造成多次二元运算符加号以及一元运算符解引用符。

具体请参见semantic_analyzer::convert_array_access
https://github.com/huanghongxun/Compiler/blob/master/compiler/semantic_analyzer.cpp

检查函数实现

如果我们只声明了某个函数,在链接了其他所有依赖库以后依然没有库去定义这个函数的话,编译阶段的最后一个环节ld链接器会报错。链接器在此的作用就是检查所有未定义的函数声明,找到对应的函数定义(可能不在同一个库中)并进行链接,这样只声明了那个函数的库就可以调用另一个库中定义好的函数实现库之间的相互访问。比如我们在引用C标准库的头文件stdio.h的时候,里面大多数函数都只进行了声明,因为实际的实现在操作系统中的libc.so/a中(可能又跑到其他地方去了?),连接器能解析我们调用的scanf函数并链接到libc.so中的scanflibstdc++.so同理。

由于我们只搞单文件无连接器的模式,我在语义分析中就确认被调用的函数是否被定义过一次,当然被多次声明是可以的,但不允许多次定义,这个我们也要检查。

上一篇:语法分析
下一篇:生成指令

猜你喜欢

转载自blog.csdn.net/huanghongxun/article/details/79776608