为了不失一般性,对于一个软件项目来说,需要创建一个(项目)目录,这里命名为“p16ecc”,并在此目录内,创建若干(子)目录,用于存放项目中不同类别的文件,如图2-1所示。
图2-1 项目目录的创建
其中,下述的目录(以及路径)的命名对于以后的项目运行十分重要。
/include:用于存放项目的系统头文件。
/lib:用于存放项目的系统库文件。
/bin:用于存放项目的系统可执行文件。
这些目录名及路径名必须固定,并将其添加到系统环境的设置中,作为以后编译器运行时搜索特定文件时的关键字。
项目中各执行文件/工具的设计,实际上属于项目分支管理。因此,有必要为其各自设置目录。比如,对于预处理器(cpp1.exe)的设计,可以设置专属目录“cpp1_source_1”。
编译器作为系统工具,其各个可执行命令文件应该可在任何文件路径/环境下启动运行。因此,比较常用的方法是将本项目的目录的路径名添加到 Windows 系统环境的设置中(方法参见第1章)。
此外,还有一种方法,即编制一个批处理文件(比如,p16.bat),并将其存放在某个已经处于系统路径的固定目录(比如,C:/tool)中。假设目标编译器设计的文件存放在“F:\”硬盘的“F:\p16ecc”目录中,那么p16.bat批处理文件的内容如下。
此后每次开启Windows的控制台(Command Prompt)时,只需输入p16命令,即可使(目标)编译器处于可运行状态。
注:本章(乃至本书的各章节)将使用各自的目录不断地扩展、深化设计细节。
C语言源程序在进入正式编译之前,需要进行预处理。进行这种预处理的工具或软件被称为“C语言预处理器(C Pre-Processor)”,是一个独立运行的程序。预处理器可以是一个非常复杂的软件,旨在处理源程序中各种前缀字母为“#”的语句(比如,#include、#define、#if、#else等)。此外,预处理器可以被拆分成多个模块,分别担任不同语句的处理。本章介绍的预处理器(cpp1.exe)功能上单一,只处理源程序中的#include(引用)语句。
#include语句可以分为以下两种形式。
(1)#include"filename":引用用户定义的文件。
(2)#include<filename>:引用(编译)系统的文件。
之所以要对源程序进行预处理,是因为编译/解析过程中需要包含上述文件的具体内容(源代码)。因此,预处理实际就是对源程序文件的合并/扩展,并作为编译/解析处理的实际输入。
工程项目路径:/cpp_source_1。
源程序文件:main.cpp、cpp1.cpp、cpp1.h、makefile。
目标结果:cpp1.exe。
作为一个独立完整的C/C++应用程序的开发设计,业内普遍的做法是为之建立对应的编译脚本文件(或工程文件)makefile。本节所使用工程文件如下。使用工程文件不但方便操作,而且可以避免一些不必要的失误。理论上它同属于软件设计中的源程序。
cpp1.exe 文件旨在对目标源程序的#include 语句进行源代码替代/扩展,即清除#include语句并将其指定的文件本身导入、替换,最终输出一个扩展后的新文件。这个过程有两个关键需要关注:(1)#include语句的嵌套;(2)#include语句的交叉互调。为此,我们必须建立一个文件索引栈,追踪当前正在处理的文件,以避免交叉、嵌套而陷入死循环。本节程序设计的简单预处理程序的算法流程,如图2-2所示。
图2-2 简单预处理程序的算法流程
由于input_file.c_输出文件可能是多个源程序文件拼接而成的,因此诸文件在拼接处需要添加如下语句。
该语句可以提示下一行语句对应的源程序文件(filename),以及所处的行号(n),这对以后的编译出错或对照查询具有重要意义。
本设计的main.cpp源程序文件如下。
·第5行:标准C/C++启动函数。参数char*env[]是系统环境参数表(指针),可以用来获得Windows环境的各种参数。
·第13~14行:创建输出文件名。
·第16行:调用cpp1()函数,其入口参数分别为环境参数、输入文件名、输出文件名。
main()函数中调用的cpp1()函数位于cpp1.cpp源程序文件中,其起始部分如下。
·第6~12行:文件栈数据结构定义(文件名、文件指针、文件源,以及链接)。
·第14~18行:各局部变量,包括文件栈指针、文件输入行缓冲等。
cpp1()函数前半部分如下。
·第30~31行:建立/打开输出文件,供以后的输出。
·第33~49行:对环境路径表进行搜索,寻找编译器的系统头文件的存放路径“/p16ecc/include/”。
·第51~53行:检测/避免同一文件的重复引入。
cpp1()函数后半部分如下。
·第55~59行:为新源程序文件创建栈节点。
·第67~68行:保存当前文件的处理状态。新源程序文件成为当前的输入(暂停对先前输入文件的扫描)。
·第70行:启动对本(新)文件的扫描分析,即调用parse()函数。
·第71~74行:扫描结束后关闭文件,同时将该文件的信息从栈顶清除,将先前输入文件恢复为当前输入文件。
·第76~77行:如果栈内已经清空,则说明操作结束,关闭输出文件。
parse()函数将对输入文件进行扫描分析,其前半部分如下。
·第84行:line_mark标识,需要插入文件行标识。
·第87~88行:从当前文件中读入一行内容,并送入行缓冲lineBuffer中。如果读至文件尾,则结束返回。
·第100~108行:当前行为包容语句#include"filename"(用户头文件),用来(递归)调用cpp1()函数,即中断当前文件的扫描处理,开始对新文件进行扫描。
·第109~119行:当前行为包容语句#include<filename>(系统头文件),用来(递归)调用cpp1()函数,即中断当前文件的扫描处理,开始对新文件进行扫描。
parse()函数的最后部分将生成输出文件,具体如下。
·第124~135行:对于其他类型的语句,写入输出文件(第134行)。必要时,嵌入文件的拼接标识(第125~131行)。
实验(通过控制台命令方式进行编译和运行):
编译命令,即生成cpp1.exe执行文件。
运行命令,即运行cpp1.exe执行文件。
实验运行后,预处理运行结果,如表2-1所示。
表2-1 预处理运行结果
2.1节所介绍的预处理器虽然简洁明了、效率高,但是其操作机制是基于字符行级别的,而不是基于语法的,因而存在一些缺陷,甚至严重的问题。例如,输入文件中出现如下内容。
其中,#include"f2.h"语句已经被注释记号屏蔽,不应该再对f2.h文件进行引入、扫描。同样,源程序中随处可见注释,例如:
这些注释对后期编译是没有意义的,可以去除。再有,多行宏定义语句,例如:
上述语句可以合并为单行语句,以便降低以后解析/编译处理的难度,具体如下。
鉴于上述理由,本节将介绍另一种方法——词法解析器构造器flex生成/设计预处理器。flex的前身是UNIX 系统下的lex。它作为一个系统命令或可执行文件,可用于设计、生成词法解析应用工具,通常和语法解析工具bison(其前身是UNIX系统下的yacc)相互配合,成为设计编译工具的专用命令/工具。flex可以单独使用(如本节所述)。
flex是一个奇特的工具,有助于高效地设计、生成对输入文件字符流进行词(或关键字)一级的扫描/解析器。它的功效是对用户定义的词法解析器(lexer)文本进行重构转译,从而生成相应的词法解析(器)C语言代码。
flex的功能、特点及使用,可以归纳为如下内容。
(1)lexer文本以“常规表达式(regular expression)”为描述手法,设立词法识别规则,由用户使用编辑器(editor)编辑、生成。lexer 文本通常以“.l”或“.lex”为后缀,以有别于C/C++源程序文件。
(2)lexer文本不仅包含词法识别规则,通常还包含词法识别后处理的对应操作,如各种C语言函数、数据变量等。
(3)flex是一个可执行命令(文件),也是GNU工具包中的一员,用于构造、生成词法解析器的C语言文件。所输出的C语言文件默认名为lex.yy.c,与其他C语言文件一起,经过gcc编译器编译后,生成可运行词法解析程序,图3-2给出了示意。
(4)flex生成的解析器以“自动状态机(auto state machine)”方式运行,一经启动(调用yylex()函数),将自动重复对输入文件的字符流进行读入、扫描、识别。用户可以在lexer文本中定义、改变自动机状态,用于扫描过程中切换不同的识别规则。
(5)flex生成的解析器C语言代码中的函数和变量名将“yy”作为前缀字符,具体如下。
yylex():启动词法解析函数。
yyerror():出错处理函数。
yytext:被匹配字符串缓冲区(字符数组)。
yyleng:被匹配字符串长度。
lexer文本不同于C/C++的源程序格式,整个文本自始至终以特殊的字符划分为4个区域。
区域1/区域4容纳的完全是C语言格式的代码,会被原封不动地复制到yy.lex.c输出文件。区域2/区域3可以生成词法解析脚本,经flex处理后,生成相应的C语言格式的函数和变量等。其中,区域3是一系列的匹配规则(表),以及嵌入的相关(C语言)操作。需要理解的是:(1)状态机将按这些规则的先后顺序依次进行扫描、匹配;(2)规则匹配类似于C语言switch语句中case语句的操作。一旦输入字符(片段)与某规则匹配,就将该字符(片段)复制到特定的yytext缓冲区(其长度存放在yyleng中);继而,重新对后继的输入进行扫描,即再次运行switch操作。
正规表达式是指用于描述字符(串)及其集合的一种表达法则。正规表达式除了直接使用ASCII字符(串)描述,还可以借助某些特殊字符作为记号和描述法则,以便完成对较复杂字符(串)集合类型的描述。这些字符记号本身也是ASCII字符,会因为它们出现的位置而具有特殊的含义。正规表达式的常用字符记号如表2-2所示。
表2-2 正规表达式的常用字符记号
其中,转义(起始)字符“\”是为表达特殊的ASCII字符而约定的特定字符,并提示将紧随其后的字符(可称为被转义字符)进行转义,两者结合表示某特殊的ASCII字符。如此,即可表示任何ASCII字符。例如,\n为换行符,\t为TAB符,\015为八进制值表示的字符,即\r。
需要注意的是,能被转义的字符只是ASCII字符中一个子集。若被转义字符超出该子集范围,则转义字符“\”无效。例如,\L为无效转义,等同于L字符本身。
例如,使用上述的特殊记录可以描述所需匹配的字符串。
[0-9]+ 匹配任意长度数字字符串
[a-zA-Z]+ 匹配任意长度字母字符串
[_a-zA-Z][_a-zA-Z0-9]* 匹配C语言中的变量名、函数名标识符
有关正规表达式的详细介绍,可参见书籍《lex与yacc》。
本节最后给出如下的一个简单词法解析小程序test.l作为终结。
编译命令如下。
flex test.l:生成lex.yy.c文件。
gcc lex.yy.c-o test:生成test.exe文件。
运行命令如下。
text test.l:扫描、解析test.l本身(也可用其他文件作为输入)。
工程项目路径:/cpp1_source_2。
源程序文件:cpp.l、makefile。
目标结果:cpp1.exe。
本节设计如下makefile工程文件,其中全部运行代码均包含在cpp.l文件内,先由flex生成lex.yy.c文件,再由gcc编译得到cpp1.exe运行文件。
cpp1.l文件(lexer文本)“区域1”(包括程序中所使用的宏定义和变量等)的内容如下。
·第9~13行:(用户)C语言宏定义,用于描述某类字符。
·第15~21行:(用户)文件栈节点定义,与之前的类似。
·第23~28行:(用户)C语言级的数据、函数的定义和声明。
注:这些代码/数据将被复制并输入至lex.yy.c输出文件。
cpp1.l文件(lexer文本)“区域2”(包括正规表达式宏定义、状态命名)的内容如下。
·第42行:正规表达式宏定义中的转义字符集合。
·第43行:正规表达式宏定义中的转义字符。
·第45行:正规表达式宏定义中的(普通)C语言字符(不包括“"”“\”“\n”)。
·第46行:正规表达式宏定义中的(单字符或多字符)字符序列。
·第47行:正规表达式宏定义中的字符串定义(字符序列前后均由“"”包裹)。注:字符序列部分可默认为空字符串。
·第48行:正规表达式宏定义中的系统库文件名(字符序列前后由“<>”包裹)。
·第50行:正规表达式宏定义中的空格符(有5个字符均被视为空格)。
·第51行:正规表达式宏定义中的换行符。
·第53~56行:为状态机新增4个状态,分别命名为COMMENT、INCLUDE、DEFINE和DEFINE_COMMENT。
默认起始状态=INITIAL(由flex内定)。
cpp1.l文件(lexer文本)“区域3”(包括正规表达式词扫描/匹配规则)的内容如下。
·第60行:<INITIAL>状态,匹配C++语言形式的行末注释,将其舍弃。
·第61行:<INITIAL>是默认的初始状态,与C语言的注释前缀“/*”匹配后,进入定义的COMMENT状态。
·第62~64行:在<COMMENT>状态下,具体操作如下。
➢ 第62行:C语言注释结尾,退出COMMENT状态,回归INITIAL状态,并提示随后的输出需加注释。
➢ 第63行:换行符需要调整行号。
➢ 第64行:舍弃注释部分。
·第66行:在<INITIAL>状态下,行首匹配#include语句,进入INCLUDE状态。
·第67~72行:在<INCLUDE>状态下,识别#include filename语句(包括两种类型的语句),调整行号并调用cpp1()函数,读取并处理filename文件。结束后回归INITIAL状态。
·第74~75行:在<INITIAL>状态下,识别#define或#pragma语句,输出(匹配字符序列)并进入DEFINE状态。
·第77~95行:在<DEFINE>状态下,去除文件中的注释部分,对于跨行的语句进行合并(第88行)。
·第97~100行:在<INITIAL>状态下,识别换行符,输出并调整行号。
·第101~102行:在<INITIAL>状态下,识别各种字符及字符串,并输出。
·第104~128行:在特殊状态下,输入文件终结,关闭当前输入文件。如果文件栈已空,则结束操作;否则恢复栈顶文件节点内容,继续先前文件的处理。
cpp1.l文件(lexer文本)“区域4”中含有两个重要的函数:main()和cpp()。其中,main()函数的主要作用和目的在于打开输入文件,确定系统库文件的目录,并启动词法解析器。
·第142~144行:建立输出文件。
·第146~165行:从系统环境设置(表)中获取编译器系统库目录的路径。
·第167~168行:启动解析自动状态机,即调用yylex()函数。
·第170行:关闭输出文件,结束程序。
注:main()函数是整个执行程序(词法解析器)的起始,与常规C语言含义相同。
cpp1.l文件(lexer文本)“区域4”(C语言部分的cpp1()函数)的内容如下。cpp1()函数在解析器运行过程中调用,用于#include语句的嵌套处理。
·第188~191行:输入文件形式,即#include"filename"用户文件。
·第193~196行:输入文件形式,即#include<filename>系统文件。
·第199~200行:输入文件形式,即由命令行指定的输入文件。
·第214~228行:遇到上述两种新的输入文件的引入,需中断当前文件的处理,并将当前处理状态送入文件栈内进行保存。
·第230~233行:将由yyin文件指针指定的新文件设置为当前处理文件。
本节设计的预处理器实验结果,如表2-3所示。
表2-3 预处理器实验结果
(1)源程序文件中除了注释、#include语句,其他内容均直接移送至输出文件。
(2)在输出文件中插入必要的源程序文件行号提示语句“#line n filename”。
(3)输出文件中可以多次出现同一个被引入的(头)文件。
(4)本章介绍的预处理器十分简单,基本上只处理源程序中的#include语句。有兴趣的读者可以以此为基础,增加更多的功能。flex提供有效的词法解析能力。用户可以使用正规表达式的规则,设计有针对性的词法解析器。
(5)预处理器cpp1.exe可执行文件被存放至编译器项目的“/bin”目录中,成为编译器的执行命令之一。