编译原理学习笔记与实践(一)
编译器
编译器是一个将源代码转换到目标机器语言的一个程序,它是一个十分复杂的系统,概括的来说,可以以一张图来描述
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1h0seANa-1637422992349)(https://i.loli.net/2021/11/11/cRj47IJpqb3uPgM.png)]
又有另外一区分法,将编译器整个拆解为前端、中端、后端,每个部分完成相应的工作
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Y1z2rXEu-1637422992362)(https://i.loli.net/2021/11/11/FNCArIeOWZbRUvs.png)]
每一个部分都完成不同的工作,编译器像一条流水线一样,对于源代码在不同阶段进行不同的处理,有不同的形式,最终经过一整条流水线的处理最终变成平台语言,其中每一个部分又都可以拆解为自己的小流水线,都负责不同的加工处理
前端(front):词法分析、语法分析、抽象语法树生成
中端(mid): 一些优化处理,IR处理(优化处理是可以发生在任何阶段的)
后端(back):汇编、链接、指令重排
以GCC编译一个HelloWorld.c为例
//HelloWorld.c
#include<stdio.h>
int mian(int argc, char **argv){
printf("HelloWorld\n");
return 0;
}
# 在bash上输入命令
$>gcc HelloWorld.c -o hello
$>./hello
HelloWorld
从HelloWorld.c文件到hello可执行文件的过程看似只用了gcc的一条命令,但是他背后却经历了4个阶段
上述过程中,各个步骤的介绍为:
**预处理:**C代码首先由预处理器对==#define、#include==进行处理,读入头文件,将宏展开,英译(pre-process)
**狭义的编译:**紧接着对预处理器的输出进行编译,生成汇编语言代码
**汇编:**编译成汇编语言代码之后,通过汇编器将汇编语言汇编成目标机器语言,由于还引用了其他库,此时还没有用到其他引入的库
**链接:**在汇编完之后,将对应库中的代码插入到目标文件中,这个一个过程称之为链接,最后生成可执行文件
这就是一个编译的过程
一个源程序经过编译生成目标可执行程序是需要经过大致几个步骤的
前端
总览了解
前端主要包括词法分析,语法分析,语义分析,他同样是一个流水线结构,大致流程为
主要任务是对源代码进行关键字提取、语义分析、上下文分析、抽象语法树生成,前端的设计有两种,一种是分离式设计、一种是交互式设计
- 分离式:
源代码经过词法分析器,从字符流变成记号流,并组织成一种叫
Token
的数据结构,Token这个数据结构记录了词法分析出来的记号数据,然后对其进行语法分析和语义分析,如果分析通过,则会生成相应的抽象语法树(一种数据结构),如果在语法语义分析过程中出现异常,则会抛出sytanx error,得到的抽象语法树为了统一,进一步转化成中间代码IR(有一个SSA原则 static single assignment form),生成的IR将会传入中断进一步操作
- What is Token
// 源代码 if (x > 5) y = "hello"; else z = 1;
// 词法分析器分析字符,它内部有一个符号表,记录着关键字、标识符信息,扫描源代码,一些无关的字符会被去掉,如空格,变成单词流,上述源代码被扫描之后,会变成如下计号流 IF LPAREN IDENT(x) GT INT(5) RPAREN IDENT(y) ASSIGN STRINT("hello") SEMICOLOM ELSE IDENT(z) ASSIGN INT(1) SEMICOLOM EOF
而这些单词数据会被组织到一个Token的数据结构中,即一个符号表,符号表对应的字符
enum CharTable{IF,LPAREN,IDENT,GT,INT.....} struct Token{ enum CharTable style; char* value; }; // 而上述的字符流被组织成一个Token数组,传入下一个流程进行分析 Token{LF, ""}; Token{LPAREN,""}; Token{IDENT,"x"}; Token{GT,""}; Token{INT,"5"}; ......
- Why need IR?
比如GCC是支持多语言编译的,C、C++、Fortran都能编译,并且能生成相应的目标平台代码,而不同的语言其语法格式是有差别的,如果对每一个语言都去实现一个从前端到后端的编译器的话,将会有很大的代码量,所以IR帮助解决解决了问题,即在前端将这些源代码语言变成统一的中间代码格式IR,然后将IR变成目标机器代码,这样对于每一个语言,就只需要去写一份前端的代码,对于每一个CPU,只需要去写一份后端的代码就可以了,两张图可以说明IR的好处
图一卷死
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0kZhIeha-1637422992365)(https://i.loli.net/2021/11/11/WI69SyAZ21upmdH.png)]
图二井井有条
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YsU1nTrL-1637422992369)(https://i.loli.net/2021/11/11/aIfQoqVzH8RJLek.png)]
- ,what is SSA(static signal assigment form )?
SSA即静态单赋值范式,即在程序中,一个变量只出现一次,PS:
a = 1 a = a + 1 b = 0 b = a + b
其在IR中表现的形式为
a0 = 1 a1 = a0 + 1 b0 = 0 b1 = a1 + b0 .....
分离式的前端设计方式,优点在于能将设计模块化,上层处理完之后传给下层处理,不同的模块之间实现解耦,但是缺点在于这种打包式整体往下层传送的方法无法去分析那些复杂的语法结构,对上下文的分析也可能产生影响
- 交互式:
交互式的设计,使得模块之间的层次化没有分离式那么明显,它存在在分析期间并行进行的情况,即语法分析的过程又会调用词法分析,类似于一种边走边看的结构
相对于分离式的设计,交互式对源代码的分析能力更优秀,但是其模块之间的耦合度就更大一些,这也使得代码实现复杂一些,现在大部分的编译器都使用的这种交互式的设计,因为它分析更灵活
词法分析基本知识笔记
两种实现方法
- 手工编码
- 实现复杂,容易出错
- 容易控制细节,好做优化,目前很多主流编译器都是这样实现的
- GCC、LLVM
- 语法分析器的生成器(
编译器的编译器
)- 代码量少,只需要写一些声明式的规范
- 不易于控制细节,不好做优化
- lex、flex、jlex
状态转移图的概念
- 比如一个识别**>,>=,<,<=,=**运算符的状态转移图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JogYDMR6-1637422992372)(https://i.loli.net/2021/11/11/GhipbZrQ89H6ufI.png)]
两个圈圈的代表最终状态,一个字符串从入口进入,经过状态0,每进入下一个状态需要消耗一个字符,直到到最终状态或者字符消耗完毕才会返回
- C语言标识符的识别
C语言标识符的要求,必须是字母或下划线开头,后面由若干个字母、数字、下划线组成
其状态转移图为,假设菱形代表最终状态
// 代码实现,判断一个字符串是不是标识符
// 借助正则表达式库<regex>
#include<regex>
#include<string>
bool isTag(const string arg){
regex r("[a-zA-Z_][a-zA-Z\\d_]{0,}");
smatch result;
if(regex_match(arg,result,r)){
return true;
}
return false;
}
// 不借助正则表达式库
bool isAccordStartTag(char p, bool isstart = false){
if(p-'a'>=0 && p-'a'<26)
return true;
else if(p-'A'>=0 && p-'A'<26) {
return true;
}
else if(p == '_'){
return true;
}
if(!isstart){
if(p-'0'>=0 && p-'0'<10) {
return true;
}
}
return false;
}
// 判断是否是标识符
bool isTag(char *p){
char *q = p;
if(!isAccordStartTag(*q,true))
return false;
q++;
while(*q != '\0'){
if(!isAccordStartTag(*q))
return false;
q++;
}
return true;
}
正则表达式的概念
基本概念
正则表达式是词法分析的基础,他是乔姆斯基四型文法的第四种,他有一个字符集**Z **={c1,c2,c3,…},其概念为:
- 空串x是正则表达式
- 对于任意c属于字符集Z,c是正则表达式
- 如果M和N两个都是正则表达式,则以下组合也是正则表达式
连接
MN = {c|c输入M,c属于N}选择
M|N = {c|c属于M或c属于N}闭包
M*={s,M,MM,MMM,…}
用正则表达式表示C语言标识符
(a|b|c...|z|A|B...|Z|_)(a|...|z|A|...|Z|0|...|9|_)*
用正则表达式表示十进制整数
- 0
- 1-9然后后面跟n个0-9
(0)|((1|2...|9)(0|...|9)*)
语法糖 – 简化表达式构造
[c1-cn] == c1|c2…|cn
e+ == 一个或多个e
e? == 零个或一个e
“e*” == a*自身,不是闭包
e{i,j} == i到j的e个连接
. == 除换行符之外的任意一个字符
有限状态自动机的概念
对于任意一个输入的字符串,经过有限状态自动机之后,得到一个匹配结果
M = ( Σ , S , q 0 , F , δ ) Σ − − − 字 符 集 S − − − 状 态 集 q 0 − − − 初 始 状 态 F − − − 终 结 状 态 集 δ − − − 转 移 函 数 M = (\Sigma, S, q_0, F, \delta)\\ \Sigma ---字符集\\ S---状态集\\ q_0---初始状态\\ F---终结状态集\\ \delta---转移函数 M=(Σ,S,q0,F,δ)Σ−−−字符集S−−−状态集q0−−−初始状态F−−−终结状态集δ−−−转移函数
示例
字 符 集 Σ = { a , b } 状 态 集 S = { 0 , 1 , 2 } 初 始 状 态 q 0 = 0 最 终 状 态 F = 2 转 移 函 数 δ = { ( q 0 , a ) − > q 1 , ( q 0 , b ) − > q 0 ( q 1 , a ) − > q 2 , ( q 1 , b ) − > q 1 , ( q 2 , a ) − > q 2 , ( q 2 , b ) − > q 2 } 字符集\Sigma = \{a,b\}\\ 状态集S=\{0,1,2\}\\ 初始状态q_0=0\\ 最终状态F=2 转移函数\delta=\{(q_0,a)->q_1,(q_0,b)->q_0 (q_1,a)->q_2,(q_1,b)->q_1,\\ (q_2,a)->q_2,(q_2,b)->q_2\} 字符集Σ={a,b}状态集S={0,1,2}初始状态q0=0最终状态F=2转移函数δ={(q0,a)−>q1,(q0,b)−>q0(q1,a)−>q2,(q1,b)−>q1,(q2,a)−>q2,(q2,b)−>q2}
实际上状态函数就代表一个映射
- 如果一个串到一个自动机能达到最终状态,那么这个串就能被接受
- 如果一个串经过一个自动机没有达到最终状态,那么这个串就不能被接受
DFA和NFA
- DFA是一种类型的有限状态自动机,成为确定有限状态自动机,即对于任意一个字符,最多有一个状态可以转移
δ : S × Σ − − > S \delta: S\times\Sigma --> S δ:S×Σ−−>S
上面的一个例子就是一个DFA
- NFA叫非确定性有限状态自动机,对于任意一个字符,有多于一个状态可以控制,这就会出现判断的不确定性
δ : S × ( Σ U ϵ ) − − > S \delta:S\times(\Sigma U\epsilon) -->S δ:S×(ΣUϵ)−−>S
NFA其状态示例为:
即从一个状态到另一个状态可能有多种选择,或者不需要消耗字符,所以导致转移图出现了不确定性,故在处理NFA的时候,需要用等价的DFA来替代他
从NFA到DFA要解决两个问题
- 同状态、同输入、不同出边
- 解决空串输入问题