购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第2章
预处理器的设计

为了不失一般性,对于一个软件项目来说,需要创建一个(项目)目录,这里命名为“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命令,即可使(目标)编译器处于可运行状态。

注:本章(乃至本书的各章节)将使用各自的目录不断地扩展、深化设计细节。

2.1 预处理器(C/C++版)

C语言源程序在进入正式编译之前,需要进行预处理。进行这种预处理的工具或软件被称为“C语言预处理器(C Pre-Processor)”,是一个独立运行的程序。预处理器可以是一个非常复杂的软件,旨在处理源程序中各种前缀字母为“#”的语句(比如,#include、#define、#if、#else等)。此外,预处理器可以被拆分成多个模块,分别担任不同语句的处理。本章介绍的预处理器(cpp1.exe)功能上单一,只处理源程序中的#include(引用)语句。

#include语句可以分为以下两种形式。

(1)#include"filename":引用用户定义的文件。

(2)#include<filename>:引用(编译)系统的文件。

之所以要对源程序进行预处理,是因为编译/解析过程中需要包含上述文件的具体内容(源代码)。因此,预处理实际就是对源程序文件的合并/扩展,并作为编译/解析处理的实际输入。

2.1.1 项目文件及其设置

工程项目路径:/cpp_source_1。

源程序文件:main.cpp、cpp1.cpp、cpp1.h、makefile。

目标结果:cpp1.exe。

作为一个独立完整的C/C++应用程序的开发设计,业内普遍的做法是为之建立对应的编译脚本文件(或工程文件)makefile。本节所使用工程文件如下。使用工程文件不但方便操作,而且可以避免一些不必要的失误。理论上它同属于软件设计中的源程序。

2.1.2 任务和算法

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.2 源程序预处理器(flex版)

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操作。

2.2.1 正规表达式简介

正规表达式是指用于描述字符(串)及其集合的一种表达法则。正规表达式除了直接使用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本身(也可用其他文件作为输入)。

2.2.2 预处理器设计实战

工程项目路径:/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 预处理器实验结果

2.3 本章小结

(1)源程序文件中除了注释、#include语句,其他内容均直接移送至输出文件。

(2)在输出文件中插入必要的源程序文件行号提示语句“#line n filename”。

(3)输出文件中可以多次出现同一个被引入的(头)文件。

(4)本章介绍的预处理器十分简单,基本上只处理源程序中的#include语句。有兴趣的读者可以以此为基础,增加更多的功能。flex提供有效的词法解析能力。用户可以使用正规表达式的规则,设计有针对性的词法解析器。

(5)预处理器cpp1.exe可执行文件被存放至编译器项目的“/bin”目录中,成为编译器的执行命令之一。 MpT4YjxuKcGWupoi3OHsp4iytDJ2xL4Pwuc9pyZSg+naRPHH8KMFU7pDiwrW6fnJ

点击中间区域
呼出菜单
上一章
目录
下一章
×