0%

Chapter 5 Windows PE病毒

5.1 基本概念

PE病毒:以Windows PE程序为载体,能寄生于PE文件,或Windows系统的病毒程序。
感染:在尽量不影响目标程序(系统)正常功能的前提下,使其具有病毒自己的功能(感染模块、触发模块、破坏模块等)。

5.2 分类

按照感染目标的类型分类:

  • 文件感染:将代码寄生在PE文件中。(传统感染型和捆绑释放型感染)
    • 传统感染型:在PE文件中添加病毒代码段与数据段,修改节表等控制结构使程序能够首先执行病毒代码。主体是目标程序。优点:被感染后的程序主体依然是目标程序,不影响目标程序图标,隐蔽性稍好。缺点:对病毒代码的编写要求较高,通常是汇编语言编写,难以成功感染自校验程序。
    • 捆绑释放型:将目标程序和病毒程序捆在一起,将目标程序作为数据存储在病毒体内。主体是病毒程序。编写较容易,可使用高级语言编写。
  • 系统感染:将代码或程序寄生在Windows操作系统,不针对特定的PE文件。感染途径有:
    • 即时通信软件
    • U盘和光盘
    • 电子邮件
    • 网络共享等

5.3 传统文件感染

使用技术

  • 重定位:病毒代码目标寄生位置不固定
  • API函数自获取:在没有引入函数表的情况下获取需要使用的API函数内存地址
  • 目标程序遍历搜索:全盘查找,或者部分盘符查找以感染其他文件
  • 感染模块:病毒代码插入位置选择与写入、病毒执行完毕后将控制权移交给正常的程序执行流程

重定位

  • 在编译时,有些基于Image Base的指令会将地址固定写死在指令之中,如push 0x401215,这时修改Image Base会使得这条指令的意义丢失,因此需要重定位。在病毒代码编译后而没有植入时,其起始地址很可能不是我们想要病毒代码在HOST文件中的起始地址,需要进行移动。
  • 其本质是修正实际地址与预期地址的差异
  • 解决方案:
    • 逐一硬编码(较为繁琐)
    • 病毒代码运行过程中自我重定位
      • call指令可以将下一条要执行的指令的地址压入栈,配合pop即可得到下一条指令的地址,以此病毒就可以知道自己的地址是什么。

API函数自获取

  • 找到DLL文件的引入函数节,在其中进行遍历查找即可。
  • kernel32.dll中的关键API函数:GetProcAddress和LoadLibraryA
  • 需要首先获得kernel32.dll文件的基地址,可以硬编码但是很难兼容,主要通过kernel32模块中的相应结构和特征定位
  • 获取kernel32.dll中的地址的方法:
    • 程序入口代码执行时,栈顶存储的地址
      系统打开一个可执行文件时,它会调用Kernel32.dll中的CreateProcess函数,CreateProcess函数在完成应用程序装载后,会先将返回地址压入到堆栈顶端。当该应用程序结束后,会将返回地址弹出放到EIP中,继续执行。这个返回地址显然位于kernel32.dll之中。在此基础上按照内存对齐(一般为0x10000)的值向前遍历直至检测到kernel32.dll的文件头 (搜索较费时且容易产生异常情况)
    • SEH链末端处理函数
      SEH:Structured Exception Handler,异常处理模块,以链表形式存在。在链中查找prev成员等于0xFFFFFFFF(表示已经遍历到链表尾)EXCEPTION_REGISTER结构,该结构中handler值指向系统异常处理例程,它总是位于KERNEL32模块中。根据这一特性,然后进行向前搜索就可以查找KERNEL32.DLL在内存中的基地址。
    • PEB相关数据结构指向各模块地址
      TEB:Thread Environment Block,线程环境块,该结构体包含进程中运行线程的各种信息,进程中的每个线程都对应一个TEB结构体。
      PEB:Process Environment Block,进程环境块,存放进程信息,每个进程都有自己的PEB信息。位于用户地址空间。
      • fs:[0]指向TEB结构,TEB结构中偏移0x30位置保存的是PEB的地址,因此可以从fs:[30h]获得PEB地址。
      • 然后通过PEB[0x0c]获得PEB_LDR_DATA数据结构地址(即下面的VOID *DllList指针)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      		typedef struct _PEB { // Size: 0x1D8
      /*000*/ UCHAR InheritedAddressSpace;
      /*001*/ UCHAR ReadImageFileExecOptions;
      /*002*/ UCHAR BeingDebugged;
      /*003*/ UCHAR SpareBool; // Allocation size
      /*004*/ HANDLE Mutant;
      /*008*/ HINSTANCE ImageBaseAddress; // Instance
      /*00C*/ VOID *DllList;
      /*010*/ PPROCESS_PARAMETERS *ProcessParameters;
      ...
      • 然后通过从PEB_LDR_DATA[0x1c]获取InInitializationOrderModuleList.Flink地址
      1
      2
      3
      4
      5
      6
      7
      8
      9
      typedef struct _PEB_LDR_DATA
      {
       ULONG Length; // +0x00
       BOOLEAN Initialized; // +0x04
       PVOID SsHandle; // +0x08
       LIST_ENTRY InLoadOrderModuleList; // +0x0c
       LIST_ENTRY InMemoryOrderModuleList; // +0x14
       LIST_ENTRY InInitializationOrderModuleList;// +0x1c
      } PEB_LDR_DATA,*PPEB_LDR_DATA; // +0x24
      • 最后在Flink[0x08]中得到KERNEL32.DLL模块的基地址。
    • 栈区特定高端地址的数据
      • 这种方法只适用于Windows NT操作系统,但这种方法的代码量最小,只有25B。
      • 每个执行的线程都有它自己的TEB(线程环境块),该块中存储线程的栈顶的地址,从该地址向下偏移0X1C处的地址肯定位于Kernel32.dll中。则可以通过该地址向低地址以64KB为单位来查找Kernel32.dll的基地址。
  • 获取指定函数内存地址的方法
    • 通过Address of Names数组查找函数名,记录索引值
    • 在Address of Name Ordinals编号数组中找到这个索引值对应的编号
    • 在Address of Functions数组中以编号为索引即可找到指定函数的内存地址

目标程序遍历搜索

  • 通常以PE文件的格式(EXE、SCR、DLL等)作为感染目标
  • 对目标进行搜索通常使用FindFirstFile和FindNextFile两个API函数
  • 可进行递归或非递归遍历

文件感染

  • 感染的关键在于:
    • 病毒代码能够运行
      • 选择位置放入病毒代码并将控制权交由病毒代码
    • 原有的正常功能不能被破坏
      • 记录原始的程序控制点位置,当病毒代码执行完毕后交回控制权
      • 设置感染标记,避免重复感染
  • 代码插入位置选择
    • 添加新节:在新节中专门存放病毒代码,需要检查节表空间是否足够
      • 判断该文件是否是可执行文件(检查MZ和PE标识)
      • 判断该文件是否已经被感染(避免重复感染)
      • 获取数据目录的个数,经过计算得到节表的起始地址
      • 得到最后一个节表的偏移,并在其后写入新节的属性等控制信息
      • 在病毒节中写入病毒代码和数据
      • 修正文件头信息(节的数量等)
    • 碎片式感染:将病毒代码分散插入到节之间的填充部分
    • 插入式感染:将病毒代码插入到HOST代码节的中间或前后(可能会导致程序无法运行)
    • 伴随式感染:备份HOST程序并用自己的程序替换HOST程序,自己的代码执行完之后再去执行HOST备份程序

5.4 捆绑式感染

HOST作为数据存放在病毒程序中,执行病毒程序时还原并执行HOST文件。熊猫烧香即属于此类病毒。

优点:编写简单、效率高。可感染自校验程序。
缺点:被感染后的程序主体是病毒程序,易被发现(程序叠加+释放执行),程序图标问题。(需要处理好资源节,熊猫烧香就没有处理好导致暴露)

5.5 系统感染

此类病毒通常作为单独个体,不感染系统中的其他文件。

需要通过自启动获得控制权

  • 于计算机启动时启动:BIOS-MBR-DBR-系统内部
  • 于系统内部启动:修改注册表键值、于系统中特定位置启动、以配置文件形式启动、修改特定文件以启动
  • 利用系统自动播放机制(Autorun.inf)
    • inf文件是Winodws操作系统下用来描述设备或文件等数据信息的文件。autorun.inf是一个文本形式的配置文件,我们可以用文本编辑软件进行编辑,它只能位于驱动器的根目录下。这个文件包含了需要自动运行的命令,如改变的驱动器图标、运行的程序文件、可选快捷菜单等内容。相关资料
  • 在其他可执行文件中嵌入少量病毒代码
  • 替换DLL文件

传播方式:可移动磁盘存储与网络传播

5.6 实验内容

链接命令——设定代码节可写:

link /subsystem:windows /section:.text,rwe mype1.obj
其中的/section:.text,rwe表示.text节可读可写可执行。

手动修改入口点,使两个弹窗变成一个弹窗:

将Address of Entry Point进行修改,跳过弹出第一个弹窗的指令(在实验中应为+0x16)

代码重定位写法

1
2
3
4
call delta ;这条语句执行之后,堆栈顶端为delta在内存中的真正地址
delta:
pop ebp ;这条语句将delta在内存中的真正地址存放在ebp寄存器中
sub ebp,offset delta

1
2
3
4
call @F
@@:
pop ebp
sub ebp,offset @B

(这里的@F指的是前面最近的一个@@标号,@B指后面最近的一个@@标号)

kernel32.dll基地址获取代码理解

1
2
3
4
5
6
7
8
9
10
11
mov eax,[esp]  ;from stack
xor edx,edx
getK32Base:
dec eax
mov dx,word ptr[eax+IMAGE_DOS_HEADER.e_lfanew] ;3ch
test dx,0f000h ;check f0
jnz getK32Base
cmp eax,dword ptr[eax+edx+IMAGE_NT_HEADERS.OptionalHeader.ImageBase]
;double check ImageBase value with eax
jnz getK32Base
mov [ebp+k32Base],eax ;save ImageBase into k32Base

这里第一条语句为从栈中获取kernel32.dll中的地址保存到eax中。
之后将dx保存为PE文件中new EXE Header的偏移位置(0x3C),检查dx的值是否小于0x1000。
如果小于,再检查Image Base的值是否等于eax(如果eax指向dll文件头,那么Image Base的值应该等于eax)。若等于,则查找完毕,eax即为kernel32.dll的起始地址。
注意上面代码对eax是逐次减1比较,由于内存对齐机制,这里可以直接按照对齐去查找,能够减少很多循环的次数。

kernel32.dll中函数内存地址的获取

API’s Address = ( API’s Ordinal * 4 ) + AddressOfFunctions’ VA + Kernel32 imagebase

Chapter 4 PE文件结构

4.1 PE文件及其表示形式

可移植的可执行文件(Portable Executable File)
PE文件主要包括:.exe,.dll,.sys等,.dll是动态加载库,不能直接执行

在DOS系统中,可执行文件格式为MZ

4.2 PE文件格式与恶意软件的关系

文件感染:

  • 使PE文件具备病毒功能
  • 而又不破坏PE文件原有的功能和外在形态
  • 感染与控制权获取

方法:

  • 代码植入
  • 控制权获取
  • 图标更改等(如熊猫烧香)

注意:PE文件感染实质上就是修改文件的内容,与修改文本文件中的内容性质相同。PE文件与文本文件同是文件,但不同的是windows系统内核已经被设计好能够识别PE文件的结构并执行其中的代码,所以PE文件与文本文件有差异。

4.3 PE文件总体结构

1. MS_DOS HEADER

PE文件开头位置,用于PE文件开头位置定位与合法性检测。长度为0x40。开头两个字符为’MZ’。,结尾4字节(0x3C)为新exe HEADER的地址。设计MS_DOS HEADER是为了向下兼容DOS系统(如果不是为了这个目的完全可以将NT HEADER作为PE文件头),在DOS系统执行此文件时会显示该文件无法在DOS模式下运行。

2. MS_DOS Stub

在MS_DOS下运行的程序代码与数据,一般为提醒用户’This Program cannot be run in DOS mode’

3. NT HEADER

分为3个部分:开头字符串’PE\0\0’、映像文件头、可选文件头

(1) 开头字符串:用于表示该文件是否为可执行文件
(2) File Header:映像文件头,包含可执行文件的一些必要信息,一般紧跟在开头字符串后面,包含的项有:

  • Machine:机器类型,2字节,0x14C表示x86架构
  • Number of Sections:节数量,2字节,PE文件中代码、数据等分别存放于不同的节中。
  • TimeDataStamp:4字节,生成该文件的时间,指从1970年1月1日开始计算经过的秒数。
  • Pointer to Symbol Table:4字节,COFF符号表偏移(COFF文件:通用对象文件格式,PE文件便是基于COFF文件设计,不要求掌握)
  • Number of Symbols:4字节,符号数量(指COFF符号表中的符号)
  • Size of Optional Header:2字节,可选头大小
  • Characteristics:4字节,表示该文件是exe文件还是dll文件

(3) Optional Header:可选文件头,包含可执行文件的其他必要信息,长度由节数量等因素确定

  • Address of Entry Point (PE_HEADER+0x28, 4 bytes):准备运行的文件的第一条指令的RVA(RVA:相对虚拟地址,相对于内存中Image Base的地址)
  • Image Base (PE_HEADER+0x34, 4 bytes):内存镜像加载地址,PE文件在内存中的优先装载地址。
    • 病毒不能只靠修改Image Base执行自己的代码,因为原有代码中可能存在如call 0x401010的代码,其中0x401010是硬编码在指令中的,一旦Image Base发生变化,这些硬编码地址可能就将无效,程序原有的代码也将无法执行。
  • Section Alignment (PE_HEADER+0x38, 4 bytes):内存中节对齐的粒度
  • File Alignment (PE_HEADER+0x3C, 4 bytes):文件中节对齐的粒度
    • 注意这里内存节对齐和文件节对齐粒度的理解。内存中节与节之间的地址之差与文件中的很可能不一样,在文件中,为了减少存储空间的浪费,通常不会将节对齐粒度设置得太大,一般为0x200,而内存中节对齐一般取0x1000作为粒度。
  • Data Directory (PE_HEADER+0x78, 8n bytes):开头记录Data Directory中节属性数量,每一条属性长度均为8字节,记录这些节的RVA和size。这些节与常用的代码节、数据节等不同,多为辅助节,如导入表、导出表、引入地址表(IAT)等,保存程序运行的关键控制信息。

4. Section Tables

节表,每个节表保存了该节的长度、在文件和内存中的开始地址、节名、节属性(RWX属性等)

  • 节名:8字节
  • Virtual Size:4字节,实际长度
  • RVA:该节的RVA
  • Size of Raw Data:文件中该节所占的大小
  • Pointer to Raw Data:文件中该节的起始地址
  • Characteristics:节属性,由几个比特异或得到
    • bit 5:表明这个节中是否存放代码
    • bit 6:表明这个节中是否为已初始化数据
    • bit 7:表明这个节中是否为未初始化数据(bss段)
    • bit 9:表明这个节中是否包含注释或其他信息
    • bit 11:表明这个节中的内容是否应该被放入最终的exe文件中
    • bit 25:表明这个块是否可以丢弃(通常为重定位节.reloc)
    • bit 28:表明这个块是否可以共享
    • bit 29:表明这个块是否可执行
    • bit 30:表明这个块是否可读
    • bit 31:表明这个块是否可写

5. Sections

  • .text / CODE:代码节,保存全部代码。每个PE文件均存在
  • .data / DATA:数据节,保存已初始化数据(编译时已经确定的数据)
  • .bbs:数据节,保存未初始化数据(未初始化的全局和静态变量)
  • .rdata:引入函数节,保留引入函数的信息(函数名及所属dll文件名等)这些函数位于一个或多个dll文件中。
    • Import Address Table (IAT):一系列Image_Thunk_Data结构数组(4字节),每一个结构都定义了一个指向导入函数的Hint和名字的指针或Hint或其他值,而导入函数的Hint和名字实际保存于Import Hints/Names & DLL Names中。在磁盘文件中,IAT与INT保存的内容相同;在内存中,这里保存所有引入函数在内存中的地址。
      1
      2
      3
      4
      5
      6
      7
      8
      IMAGE_THUNK_DATA STRUCT
      union u1
      ForwarderString DWORD ? ; 转向者RVA
      Function DWORD ? ; 被引入的函数的内存地址(IAT表)
      Ordinal DWORD ? ; 被引入API的函数序号(INT表)
      AddressOfData DWORD ? ; 被引入API的hint/name RVA(INT表)
      ends
      IMAGE_THUNK_DATA ENDS
    • Import Directory Table (IDT):引入目录表,其地址存放在NT HEADER可选头的第二个属性之中便于获取,由IMAGE_IMPORT_DESCRIPTOR结构体(长度20字节)数组组成,其数量取决于使用的DLL文件的数量,每一个结构对应一个DLL文件。所有DLL结构体后有一个全0的结构体用于表示这部分结束。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      IMAGE_IMPORT_DESCRIPTOR STRUCT
      union
      Characteristics dd ?
      OriginalFirstThunk dd ? ; 指向INT中对应DLL的RVA
      Ends
      TimeDateStamp dd ?
      ForwarderChain dd ?
      Name1 dd ? ; 指向dll文件名字符串的RVA
      FirstThunk dd ? ; 指向IAT中对应DLL的RVA
      IMAGE_IMPORT_DESCRIPTOR ENDS
      OriginalFirstThunk是指向Import Name(lookup) Table中的指针
      FirstThunk是指向Import Address Table中的指针
    • Import Name table:一系列Image_Thunk_Data结构数组,在Import Name Table中,data最高位为0时表示通过函数名引入,为1表示通过序号引入。不同DLL文件的结构体之间通过一个全0的DWORD分隔。
    • Import Hints/Names & DLL Names:保存每个函数的Hint和名字,Hint为2字节,名字紧跟在Hint之后。一个DLL文件的所有引入函数列举完毕后在后面附上DLL的名字,下一个DLL文件的函数信息写在后面,需要4字节对齐。
  • .edata:导出函数节,本文件向其他程序提供调用函数的列表、函数所在地址和具体代码实现,多见于DLL文件。
    • Image Export Directory:导出目录表,其起始地址保存在NT HEADER可选头的第一个属性之中便于获取
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      IMAGE_EXPORT_DIRECTORY STRUCT
      {
      DWORD Characteristics
      DWORD TimeDateStamp ; 文件生成时间
      WORD MajorVersion
      WORD MinorVersion
      DWORD Name ; 指向DLL的名字(RVA)
      DWORD Base ; ExportAddress开始序号,一般为1
      DWORD NumberOfFunctions ; 函数的数量
      DWORD NumberOfNames
      DWORD AddressOfFunctions ; Address Table RVA, 函数地址数组
      DWORD AddressOfNames ; Name Pointer RVA, 函数名所在地址数组
      DWORD AddressOfNameOrdinals ; Ordinal RVA, 函数索引序列号数组
      };IMAGE_EXPORT_DIRECTORY ENDS
    • Export Address Table:导出地址表,多用于保存导出函数地址
      1
      2
      3
      4
      5
      6
      7
      Typedef struct _image_Export_address_Table
      {
      union{
      DWORD dwExportRVA; // 指向导出地址
      DWORD dwForwarderRVA; // 指向另外DLL中某个API函数名
      };
      }IMAGE_Export_Address_Table, *pIMAGE_Export_Address_Table
    • Export Name Pointer Table:导出名字指针表,保存导出函数名字字符串的地址
      1
      2
      3
      4
      Typedef struct _IMAGE_Export_Name_Pointer_Table_
      {
      DWORD dwPointer;
      }IMAGE_Export_Name_Pointer_Table;
    • Export Ordinal Table:导出符号表,保存导出函数的编号。
      1
      2
      3
      4
      Typedef struct _IMAGE_Export_Ordinal_Table_
      {
      DWORD dwOrdinal;
      }_IMAGE_Export_Ordinal_Table_;
      注意:由于代码段中可能存在无名函数、重载函数等,因此导出函数的编号与导出函数名可能并不是一一对应,因此需要导出函数编号为每一个函数进行编号,唯一确定一个函数。
      根据导出函数表定位函数内存地址的方法:
      • 从AddressOfNames中获取到需要定位的函数的名字(记下函数名的索引)
      • 从AddressOfNameOrdinals中获取到该函数的编号(以索引定位)
      • 从AddressOfFunctions中获取该编号对应函数的地址(编号值就是数组索引值)
  • .rsrc:资源节,存放图标、对话框等程序需要用到的资源。树形结构,有一个主目录,下有嵌套子目录或数据。Windows通常有3层目录(资源类型、资源标识符、资源语言),第4层是具体的资源。具体结构不做要求。
  • .reloc:重定位节,存放了一个重定位表。若装载器不是把程序装到程序编译时默认的基地址时,就需要这个重定位表来做一些调整。

练习题
由于本章内容多为记忆内容,这里只给出少数需要计算的例题供参考。
1. 一个exe文件的Image Base=0x400000,Address of Entry Point=0x1000,那么该程序的第一条指令在内存中的地址为________。
2. 在导入名称表(INT)中有一个指针的值为0x20A4,这个指针在________(填“内存”或“磁盘”)中的有效,已知IAT的Raw Data Address=0x800,RVA=0x1800,则0x20A4指向的内存地址在磁盘中的原像为________。
3. exe文件本身也属于文件,要想找到一个exe文件的某个导入函数的内存地址,首先应该在exe文件中找到可选头中存放的_______________,通过这个来定位到_________的地址,在这里可以通过遍历所有结构的________________字段来获取到这个函数的索引,接着在_____________________中找到这个索引下的地址值,即为目标函数在内存中的地址。
4. 下面是一个PE文件的头部数据,据此回答下列问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
0x000 4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00
0x010 B8 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00
0x020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x030 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00
0x040 0E 1F BA 0E 00 B4 09 CD 21 B8 01 4C CD 21 54 68
0x050 69 73 20 70 72 6F 67 72 61 6D 20 63 61 6E 6E 6F
0x060 74 20 62 65 20 72 75 6E 20 69 6E 20 44 4F 53 20
0x070 6D 6F 64 65 2E 0D 0D 0A 24 00 00 00 00 00 00 00
0x080 9F 32 0F AA DB 53 61 F9 DB 53 61 F9 DB 53 61 F9
0x090 D2 2B F2 F9 D7 53 61 F9 CF 38 60 F8 D3 53 61 F9
0x0A0 B7 27 65 F8 D6 53 61 F9 B7 27 62 F8 DF 53 61 F9
0x0B0 B7 27 64 F8 FB 53 61 F9 DB 53 60 F9 FF 52 61 F9
0x0C0 B7 27 60 F8 DC 53 61 F9 0D 27 64 F8 DF 53 61 F9
0x0D0 0D 27 65 F8 DA 53 61 F9 0D 27 9E F9 DA 53 61 F9
0x0E0 DB 53 F6 F9 DA 53 61 F9 0D 27 63 F8 DA 53 61 F9
0x0F0 52 69 63 68 DB 53 61 F9 00 00 00 00 00 00 00 00
0x100 50 45 00 00 4C 01 05 00 9B C0 42 62 00 00 00 00
0x110 00 00 00 00 E0 00 02 01 0B 01 0E 1C 00 8C 00 00
0x120 00 98 00 00 00 00 00 00 A2 80 00 00 00 10 00 00
0x130 00 A0 00 00 00 00 40 00 00 10 00 00 00 02 00 00
0x140 06 00 00 00 00 00 00 00 06 00 00 00 00 00 00 00
0x150 00 60 01 00 00 04 00 00 00 00 00 00 02 00 40 81
0x160 00 00 10 00 00 10 00 00 00 00 10 00 00 10 00 00
0x170 00 00 00 00 10 00 00 00 00 00 00 00 00 00 00 00
0x180 70 ED 00 00 18 01 00 00 00 20 01 00 B8 13 00 00
0x190 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x1A0 00 40 01 00 50 13 00 00 CC DD 00 00 54 00 00 00
0x1B0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x1C0 08 DF 00 00 18 00 00 00 20 DE 00 00 40 00 00 00
0x1D0 00 00 00 00 00 00 00 00 00 A0 00 00 C4 04 00 00
0x1E0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x1F0 00 00 00 00 00 00 00 00 2E 74 65 78 74 00 00 00

(1) 最开头两个字节表示的字符是_______,这是PE文件的____________结构。(4分)
(2) NT HEADER的起始地址为_______,你是通过__________(填16进制地址)的值得到的。(4分)
(3) PE文件头的标志在_______(填16进制地址)处,代表的字符为________。(4分)
(4) 这个PE文件有______个节,通过______(填16进制地址)处的值可以知道。(4分)
(5) 这个PE文件的Image Base为___________,通过______(填16进制地址)处的值可以知道。(4分)
(6) 这个PE文件的Address of Entry Point为___________,通过______(填16进制地址)处的值可以知道。(4分)
(7) 导入表的RVA是__________,通过______(填16进制地址)处的值可以知道。(4分)
5. 阅读某PE文件中.rdata节的IMAGE_SECTION_HEADER,回答下列问题:

1
2
3
4
IMAGE_SECTION_HEADER .rdata
0x220 2E 72 64 61 74 61 00 00 A2 5C 00 00 00 A0 00 00
0x230 00 5E 00 00 00 90 00 00 00 00 00 00 00 00 00 00
0x240 00 00 00 00 40 00 00 40

已知该PE文件的Image Base=0x400000,导入表的RVA=0xED70
(1) .rdata节的RVA=,通过(填16进制地址)处的值可以知道。(4分)
(2) .rdata节在磁盘中的文件偏移为
______,通过______(填16进制地址)处的值可以知道。(4分)
(3) 导入表在磁盘中的文件偏移应为_________。(3分)

答案:

  1. 0x401000

  2. 内存;0x10A4

  3. IDT的RVA;IDT;OriginalFirstThunk;IAT

  4. (1) MZ;DOS头
    (2) 0x100;0x3C
    (3) 0x100;PE
    (4) 5;0x106
    (5) 0x400000;0x134
    (6) 0x80A2;0x128
    (7) 0xED70;0x180

  5. (1) 0xA000;0x22C
    (2) 0x9000;0x234
    (3) 0xDD70(.rdata节的VA=0x400000+0xA000=0x40A000,IDT的VA-IDT的磁盘偏移=.rdata的VA-.rdata的磁盘偏移,故IDT的磁盘偏移=IDT的VA-(0x40A000-0x900)=0x400000+0xED70-0x401000=0xDD70)

Chapter 3 恶意代码及其分类

3.1 定义

恶意代码,指为达到恶意目的而专门设计的程序或代码
注意:正常的软件也会引发安全问题,但大多数情况下都并非是作者有意为之。
分类:病毒、蠕虫、木马、后门、Rootkit、流氓软件、僵尸、Exploit等。

练习题
1. 某公司开发了一款社交软件,为了在该软件产生bug时能够更加及时反馈信息,该公司在这款软件中设计了一个后门,并同时开发另一款配套软件用于将bug信息(程序产生bug时的内存状态等)上报至该公司的服务器。软件的后门就是为这个配套软件准备的。这款配套软件______(填是或不是)恶意代码,理由是________________。某黑客组织通过逆向分析发现了这款社交软件的漏洞并编写程序用于远程控制计算机,这个程序_______(填是或不是)恶意代码。

答案:不是;该配套软件的目的并非恶意;是

3.2 功能

恶意代码的攻击目的:

  • 恶作剧,炫耀自己的技术(如熊猫烧香病毒)
  • 经济利益(如WannaCry病毒)
  • 商业竞争
  • 政治目的
  • 军事目的等

练习题
2. 上世纪末,美国在塞尔维亚进行军事行动时轰炸我南联盟大使馆,令无数国人愤慨。消息传出,中国红客联盟应声出动,对美国政府网站发动了DDoS攻击,并成功在政府网页上贴上了中国国旗的图片。这个过程中涉及的恶意代码的攻击目的是_________________。

答案:政治目的

攻击目标:

  • 个人计算机
  • 服务器
  • 移动智能终端(如手机平板等)
  • 智能设备(如车联网、智能家居、手环等)
  • 通信设备(路由器、交换机等)
  • 安全设备等(如防火墙、IDS、IPS、VDS等)
    • IDS:intrusion detection system,入侵检测系统,是一种对网络传输进行即时监视,在发现可疑传输时发出警报或者采取主动反应措施的网络安全设备。
    • IPS:Intrusion Prevention System,入侵防御系统,能够监视网络或网络设备的网络资料传输行为的计算机网络安全设备,能够及时的中断、调整或隔离一些不正常或是具有伤害性的网络资料传输行为。
    • VDS:Virus Detection System,病毒检测系统,能够对网络传输中的数据进行计算机病毒相关检测的设备型产品形态的总称。

攻击目标范围:

  • 定点攻击(指定邮件、域名、IP、QQ等,或服务器列表等)
  • 群体攻击(如可传播的病毒、木马、蠕虫,钓鱼攻击等)

练习题
3. 某黑客组织攻破了某公司的服务器,获取了其产品所有用户的用户名与密码信息。由于很多人习惯在多个平台的账户使用同一个密码,黑客于是拿着这些密码在QQ、网易等平台上尝试登录这些用户的其他账户,这被称为撞库攻击。这种攻击属于___________(填“定点攻击”或“群体攻击”)

答案:定点攻击
注意:定点攻击和群体攻击的区分不是通过攻击的个体数量来区分,而是通过被攻击的个体是否能够被提前确定来区分。群体攻击中,无论是会传播的病毒木马还是钓鱼攻击,黑客在病毒、木马等被制作出来时不知道被攻击的对象是谁。

恶意代码的攻击功能:

  • 获取数据
    • 静态数据,如文件、数据库数据等
    • 动态数据,如口令、内存、计算机网络流量、通信网络数据、可移动存储介质、隔离电脑等
  • 破坏系统
    • 删除或修改数据
    • 破坏系统服务,如通用Web服务系统、数据库系统、特定行业服务系统(如工控系统)
    • 破坏支撑设备,如网络设备,线路等
  • 动态控制与渗透拓展攻击路径等
    • 中间系统
    • 相关人员

静态数据和动态数据的区别:静态数据是指在运行过程中主要作为控制或参考用的数据,它们在很长的一段时间内不会变化,一般不随运行而变。动态数据包括所有在运行中发生变化的数据以及在运行中需要输入、输出的数据及在联机操作中要改变的数据。(来源:百度百科)

3.3 恶意代码的分类

1.计算机病毒

一组能够进行自我传播需要用户干预来触发执行的破坏性程序或代码。

例:CIH(破坏BIOS系统)、熊猫烧香等

2. 网络蠕虫

一组能够进行自我传播不需要用户干预即可触发执行的破坏性程序或代码。

例:SQL蠕虫王、震网病毒(攻击工控系统)、Stuxnet等

病毒和蠕虫最大的区别就是是否需要用户干预才能执行。“计算机蠕虫可以独立运行,并能把自身的一个包含所有功能的版本传播到另外的计算机上”,“计算机病毒是一段代码,能把自身加到其他程序包括操作系统上;它不能独立运行,需要由它的宿主程序运行来激活它”,可以将二者与生物界的蠕虫和病毒联系起来理解。

3. 木马(特洛伊木马)

是指一类看起来具有正常功能,但实际上隐藏着很多用户不希望功能的程序。通常由控制端和被控制端两端组成。

例:灰鸽子(能够监控摄像头、桌面、键盘输入等)、冰河等

木马和前两种恶意代码的主要区别在于表面的伪装能力。实际上破坏的能力与前两种相当。百度百科给出的定义是:木马病毒是指隐藏在正常程序中的一段具有特殊功能的恶意代码,是具备破坏和删除文件、发送密码、记录键盘和攻击Dos等特殊功能的后门程序。

4. 后门

使得攻击者可以对系统进行非授权访问的一类程序。

例:Bits、WinEggDrop、Tini等

木马和后门的区别在于隐蔽性和欺骗性上。后门存在本来就是要隐藏自身,便于攻击者随时访问;而木马有时甚至会诱惑用户去运行某些程序。

5.RootKit

通过修改现有的操作系统软件,使攻击者获得访问权并隐藏在计算机中的程序。

例:RootKit、Hkdef、ByShell等

6. 僵尸程序,恶意网页,拒绝服务程序,黑客工具,广告软件,间谍软件等其他恶意代码

  • 僵尸程序:是指恶意控制功能的程序代码,能够自动执行预定义的功能、可以被预定义的命令控制
  • 间谍软件:以主动收集用户个人信息、相关机密文件或隐私数据为主,搜集到的数据会主动传送到指定服务器。
  • 广告软件:未经用户允许,下载并安装或与其他软件捆绑通过弹出式广告或以其他形式进行商业广告宣传的程序。
  • 流氓软件:具有一定的实用价值但具备电脑病毒和黑客软件的部分特征的软件(特别是难以卸载),处在合法软件和电脑病毒之间的灰色地带。
  • Exploit:精心设计的用于利用特定漏洞以对目标系统进行控制的程序。
  • 黑客工具:各类直接或间接用于网络和主机渗透的软件,如各类扫描器、后门植入工具、密码嗅探器、权限提升工具等

注意诸如黑客工具、广告软件等也属于恶意代码。很多广告软件强行向用户推送广告,以此来获得收益,在用户眼中算是可见的比较“流氓”的一种行为。而黑客工具也是恶意代码的原因是其“恶意”的对象可能不是本机而是被攻击的对象。要注意广告软件和流氓软件的区别:流氓软件——虽然流氓,但你还是需要,即存在一定价值;广告软件:可能没有价值,而且还随便下东西弹窗。

练习题
4. 根据下列对于恶意代码的描述,判断其属于哪种恶意代码:
(1) 某同学将U盘插入学校工程实训中心的电脑后,发现U盘中所有文件夹后标注文件类型为“exe可执行文件”,点击后电脑中关键数据被窃取。__________
(2) 某人在手机上通过QQ邮箱下载apk文件并安装后,QQ号被盗。__________
(3) 专门用于向指定IP地址发动DDoS攻击的程序__________
(4) 各大高校频频爆出的“O泡果奶事件”中涉及的程序__________
(5) 能够在后台下载各类广告游戏软件的某款盗版软件__________
(6) 某人打开电脑后发现鼠标与键盘无法控制电脑,似乎有人远程控制电脑进行各种违法操作。经过紧急处理后发现有一个恶意代码程序将自身设置为开机自启动。__________
(7) 某公司企业员工运行某个常用程序后发现服务器突然遭受大量DDoS攻击,电脑中弹出索要钱财的对话框。__________
(8) 某同学在靶场攻击服务器靶机的某个含有漏洞的服务时专门编写的程序__________
(9) 由某国政府开发的用于窃听电脑语音通话的软件__________

答案:木马、木马、黑客工具、木马、广告软件、蠕虫、病毒、Exploit、间谍软件

3.4 相关法律条文

第二百八十五条 违反国家规定,侵入国家事务、国防建设、尖端科学技术领域的计算机信息系统的,处三年以下有期徒刑或者拘役。

第二百八十六条 违反国家规定,对计算机信息系统功能进行删除、修改、增加、干扰,造成计算机信息系统不能正常运行,后果严重的,处五年以下有期徒刑或者拘役;后果特别严重的,处五年以上有期徒刑。

违反国家规定,对计算机信息系统中存储、处理或者传输的数据和应用程序进行删除、修改、增加的操作,后果严重的,依照前款的规定处罚。

故意制作、传播计算机病毒等破坏性程序,影响计算机系统正常运行,后果严重的,依照第一款的规定处罚。

刑法修正案(七):在刑法第二百八十五条中增加两款作为第二款、第三款:“违反国家规定,侵入前款规定以外的计算机信息系统或者采用其他技术手段,获取该计算机信息系统中存储、处理或者传输的数据,或者对该计算机信息系统实施非法控制,情节严重的,处三年以下有期徒刑或者拘役,并处或者单处罚金;情节特别严重的,处三年以上七年以下有期徒刑,并处罚金。

Chapter 2 软件安全基础知识

2.1 系统引导与控制权

BIOS

Basic Input & Output System:基本输入输出系统,存储在主板BIOS Flash或ROM芯片中,其为计算机提供最为底层和直接的硬件设置和控制。

在启动计算机时,BIOS会进行自检工作:检测系统中的一些关键设备是否存在以及是否能够正常工作,进行初始化并将控制权交给后续引导程序

  • 显卡及其他相关设备初始化
  • 显示系统BIOS启动画面,含系统BIOS版本号、类型、序列号等
  • 检测CPU类型和工作频率,内存容量,将结果显示在屏幕之上
  • 检测系统中安装的一些标准硬件设备和即插即用设备(硬盘、光盘、软驱、串并行接口等)
  • 根据用户指定顺序从硬盘/软盘/光驱启动,若从硬盘启动,则将控制权交由硬盘主引导程序。

MBR

Master Boot Record:硬盘主引导程序,位于硬盘的第一个扇区

  • 用于从主分区表中定位活动分区
  • 装载活动分区的引导程序,并移交控制权

DBR

DOS Boot Record(OBR/PBR):活动分区引导程序,位于分区的第一个扇区

  • 用于加载操作系统引导程序,准备将控制权移交给操作系统。Windows XP的NTLDR和Windows 10的bootmgr

操作系统引导

  • 将处理器以16位内存模式扩展为32(64)位内存模式
  • 启动小型文件系统驱动,以识别FAT32和NTFS系统
  • 读取boot.ini,进行多操作系统选择(或hiberfil.sys恢复休眠)
  • 检测和配置硬件(NT或XP系统,则运行:-NTDETECT.COM,将硬件信息提交给NTLDR,写入HKEY_LOCAL_MACHINE中Hardware中)

系统内核加载

  • NTLDR加载内核程序NTOSKRNL.EXE以及硬件抽象层HAL.dll等
  • 读取并加载HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet下面指定的驱动程序
  • NTLDR将把控制权传递给NTOSKRNL.EXE,至此引导过程结束

Windows系统装载

  • 创建系统环境变量
  • 启动win32.sys(windows子系统内核模式)
  • 启动csrss.exe(windows子系统用户模式)
  • 启动winlogon.exe等一系列程序,显示图标等
  • 启动需要自启动的windows服务
  • 启动本地安全认证Lsass.exe
  • 显示登录界面等
  • 登录后启动当前用户环境下的自启动程序
  • 用户自行触发执行各种应用程序

恶意代码获取控制权的方式

  • 在计算机系统引导阶段获取控制权
  • 在操作系统启动阶段获取控制权
  • 在用户应用程序执行阶段获取控制权

2.2 80x86处理器工作模式

实地址模式、保护模式、虚拟8086模式
其中除保护模式外均为向下兼容8086处理器而设计。

实地址模式

  • 80x86处理器于复位和加电时使用
  • 寻址方式:段地址+偏移地址,可寻址220=1MB空间
  • 不能进行内存分页管理
  • 没有优先级的定义,所有指令均在ring0状态工作
  • 如何切换到保护模式:在实地址模式下初始化控制寄存器,GDTR、LDTR等管理寄存器以及页表,之后置位CR0寄存器的保护模式使能位(第0位)

保护模式

  • 80x86的常态工作模式
  • 32位的处理器支持32位寻址,可寻址空间:4GB
  • 支持内存分页机制,能够支持虚拟内存
  • 支持优先级机制,能够进行任务环境隔离
  • 如何切换到实模式:修改CR0寄存器的保护模式使能位即可

虚拟8086模式

  • 在保护模式下兼容80x86模式
  • 作为任务在保护模式下运行,相当于开启了一个8086模式的虚拟机
  • 能够使用内存分页机制为每一个虚拟8086模式任务分配1MB的内存

2.3 Windows内存结构和管理

DOS实模式内存管理

Windows虚拟地址空间布局

windows的32位系统共4GB内存中用户空间和内核空间各占2GB,其中用户空间在低地址处(Linux用户空间为3GB)

Windows程序在内存中的映像

CPU特权级别与内存访问

操作系统将处理器存取模式划分为用户模式(ring3)和内核模式(ring0)。用户应用程序仅能访问用户区地址,内核程序可以访问所有内存地址、硬件,使用所有处理器指令。

用户区内存的内容

  • 应用程序代码
  • dll文件代码
  • 全局变量
  • 线程栈

不同程序的内存相互隔离,能够从一个程序影响另一个程序很难。

内核区内存的内容

内核区数据为所有进程共享,含操作系统内核、线程调度、内存管理、文件系统、网络系统等支持代码。用户态代码无法访问。

虚拟地址和物理地址的转换

x86 windows使用二级页表的方式进行转换,可将虚拟地址转译为物理地址。
32位地址由:页目录索引(最高10位)、页表索引(中间10位)、字节索引(最低12位)构成。x86系统中默认分页大小为4KB,故页内字节索引为最低12位
页目录基地址通过CR3寄存器获取,通过页目录索引找到页目录项(PDE),转到相应的页表索引。页目录是一个长度为1024的数组。【注意:页目录中存放的是每一个页表的起始地址,页表的存放可能是离散的】
在页表中通过页表索引找到页表项(PTE),指向虚拟地址所映射的物理地址。每个页表也是一个长度为1024的数组。【页表中存放的就是物理地址】
再加上字节索引,就能够映射到具体的物理地址中的某一个字节。
存放PDE和PTE的地址称为页帧号(PFN

思考题

  1. windows编程时malloc实际上能支持的内存大小不大于2GB,因为用户能够分配的空间只有2GB,但这2GB中还包含代码、其他数据、控制信息等,因此这2GB无法全部被分配。
  2. 不断增加物理内存,不能增加malloc分配的最大内存大小,因为机器字长的限制,每个程序的内存大小均为4GB,与物理地址的大小无关。
  3. 增加物理内存能够让系统运行更加流畅,因为减少了换页的次数。(换页:在物理内存空间紧张时,操作系统会将目前访问次数不多或正在挂起的进程的内存空间暂时转移到外存中,以提升当前正在运行进程的速度。

2.4 磁盘的物理和逻辑结构

物理结构

硬盘:控制代码的静态存储仓库,内含系统引导代码和用户存储的各类代码和数据等。同时硬盘也是恶意代码争夺控制权的中心

外部结构:接口(电源接口+数据接口)、硬盘控制电路、固定面板

  • 接口:PATA并行接口、SATA串行接口,并行接口传输速度比串行慢
  • 固定面板:保证盘片和机构的稳定运行,包括产品基本信息,如版号、生产日期等
    内部结构:盘片、磁头、盘片主轴、控制电机、磁头控制器、数据转换器。

逻辑结构

寻址方式

  • CHS寻址:柱面、磁头、扇区。
    • 磁头有256个,柱面(磁道)有1024环,扇区有63个,每扇区存储512字节数据,故可以寻址最多8064MB空间。
  • 对于老式硬盘而言,每个磁道的扇区数量相等,这样位于外圈的磁道会产生空间浪费,相比内圈的磁道存储密度低得多。因此当前硬盘采用等密度结构,寻址方式采用线性逻辑块寻址:以扇区为单位进行线性寻址(有地址翻译器使两种寻址方式能够兼容)

CHS寻址与线性寻址(LBA)的转换关系
LBA实际上就是给每一个扇区进行编号,使得通过编号能够唯一确定一个扇区的位置。转换时扇区为最小单位,其上为磁头,最高为柱面。相邻扇区的LBA之差为1,相同扇区、相邻磁头的LBA之差为63;相同扇区、相同磁头、相邻柱面的LBA之差为63*255。

硬盘的分区结构

  • MBR分区
    • 主引导扇区:位于柱面0,磁头0,扇区1(第一个扇区),这个扇区中包含:
      • MBR引导程序:前446字节
      • DPT(硬盘分区表):之后64字节
      • 结束标志:最后两个字节“55AA”
    • 基本分区
    • 扩展分区
  • GPT分区
    • 描述各个分区的基本信息:分区开始位置、总的扇区数、分区类型等,每个分区信息占16字节

2.5 FAT32文件系统

FAT32文件系统由三个部分组成:引导扇区、FAT、数据存储区(以簇为单位,每一簇含多个扇区,存储目录项和文件数据)

将磁盘空间以一定数目(2的整数次方)的扇区为单位进行划分,这样的单位即为簇。是文件空间分配的最小单位。

簇既不能太大,也不能太小。如果太大,存储很小的文件时也需要一整个簇,浪费空间;如果太小,则容易产生磁盘碎片。

FAT表

文件分配表:在FAT文件系统中用于磁盘数据索引和定位而引进的一种单向链表式结构。

FAT表存储所有簇的占用情况,若表项的值为0,则说明这个簇处于空闲状态。

对于FAT32文件系统,32位共可表示4GB大小的簇号空间。

簇链

一个文件占用簇号形成的单向链表。

在文件占用簇对应簇号的FAT项下填写下一个簇的簇号即可实现。若为最后一个簇,则填“0xFFFFFF0F”

文件的存储过程

  • 定位足够的空闲簇
  • 创建文件目录(文件目录:记录系统中所有文件的名字及其存放地址的目录表,可以理解为需要另外记录下文件的首簇
  • FAT中构建簇链
  • 写入文件数据

被删除文件如何恢复

  • 文件删除并非将簇中的文件内容全部销毁,而是将文件名首字节修改为E5后删除FAT表项与簇链,实际内容还在,但文件系统已无法索引。
  • 即使通过文件恢复程序将删除文件恢复,也不可能得到文件名的第一个字节。
  • 若要彻底删除文件,需要将原来文件占有的簇重新覆写,有时需要覆写几次才能够实现真正删除。

2.6 PE文件格式

参见第4章

VA = Image Base + RVA

2.7 ELF文件格式

elf文件是Linux可执行文件,含可执行文件、动态链接库文件.so等。

有段头部表用于将不同段映射到内存中的不同地址。

代码段有:.init、.text、.rodata等,均为只读。其中.rodata存储在C代码中直接定义的数据,如printf(“Hello”)中的字符串"Hello"即存储在.rodata中,不可修改。
数据段有:.data、.bss等,其中.data存放已经初始化的全局变量和静态变量,.bss存放未初始化的。
此外还有如.symtab、.debug、.line等不加载到存储器的符号表和调试信息。


练习题
1. 某程序内存中页目录索引为0x3f的值为0x74465000;则在_______到_______范围内的虚拟内存需要通过0x74465000指向的页表来查找其对应的物理地址;已知0x74465AB8地址处DWORD的值为0x83BF4000,那么物理地址为0x83BF4212对应的虚拟地址为_______(6分)

分析:虚拟地址最高10位对应的是页目录的索引,故最高10位应为0x3f,对应的虚拟内存地址应该为0x0FC00000~0x0FFFFFFF共0x400000字节的空间(一个页4KB,即0x1000,这里是0x400(1024)页)。(0x74465AB8-0x74465000)/4=0x2AE,则其对应的应该是0x0FC00000+0x2AE*0x1000~0x0FC00000+0x2AF*0x1000-1的地址空间,即0x0FEAE000~0x0FEAEFFF这4KB空间,对应的应该是物理地址0x83BF4000~0x83BF4FFF,故物理地址为0x83BF4212在内存中的映射应该为0x0FEAE212。

2. 在一个FAT文件系统中,一个簇的大小为4KB。现在向该文件系统中存入若干个大小在4~16KB之间的文件,每个文件的大小在这个范围内呈均匀分布。当存入文件足够多时,求该FAT文件系统中被浪费的空间占被占用的所有簇的总空间的比例。(4分)

分析:由于文件大小可能为4~16KB中的任何数,因此其大小在4~8KB的概率为1/3,8~12KB的概率为1/3,12~16KB的概率为1/3。即一个文件需要分配2、3、4个簇的概率均为1/3,对于每一个文件,其最后一个簇中被浪费的空间的期望值应该为簇大小的一半,即1KB,故分配2、3、4个簇时空间利用率的期望值分别为3/4,5/6,7/8。因此总的空间利用率期望值为13×(34+56+78)=5972\frac{1}{3}\times(\frac{3}{4}+\frac{5}{6}+\frac{7}{8})=\frac{59}{72},被浪费的空间占总占用空间的1372\frac{13}{72},超过1/6。

Chapter 1 软件安全概述

1.1 概念

网络空间有两个子空间:代码空间和数据空间

Safety和Security的区别:Safety强调相对于环境的安全,而Security强调相对于其他人的安全。

1.2 任何软件都是不安全的

软件测试无法绝对保证软件安全性的原因:软件规模的增加、开发进度的要求提升使得开发人员难以考虑到所有的安全问题。通常测试案例构成的空间巨大,无法全部进行测试,只能抽取其中的一小部分进行测试。

为尽量减少软件安全问题,一方面应该在开发时开发者尽量多考虑,另一方面也需要一定的测试工作。几乎所有的软件都是带着安全隐患投入运行。任何软件都是不安全的。

1.3 软件不安全的外部表现

  • 软件运行时不稳定,产生错误输出、异常现象、直接崩溃
  • 敌方利用各种手段进行攻击,窃取信息破坏系统等

通常这类软件安全问题的存在需要软件开发方投入大量人力和资金进行软件的维护工作。

1.4 软件安全问题产生原因

安全隐患可分为错误和缺陷两类。错误是软件开发过程中出现的问题,如线程处理不当等,容易发现与修复;缺陷产生于设计阶段,在代码中实例化且难以发现。

从开发者的角度看软件不安全的原因:

  • 软件生产没有严格遵守软件工程流程。在设计之初没有对软件的功能进行完整的考虑,随意改动软件需求规格说明书等。
  • 大多数商业软件的结构复杂,使得维护软件困难。
  • 没有采用科学编码方案,可能产生由编码不一致引起的问题。如乱码等
  • 测试不到位,没有覆盖所有可能的用户输入类型等

从软件工程客观角度看软件不安全的原因:

  • 软件复杂性和工程进度的平衡:工程进度仅按照软件规模进行适度延长,很多问题来不及解决。
  • 安全问题的不可预见性:仅通过对运行状态的简单假设无法覆盖所有运行情况。
  • 软件需求的变动:在变动过程中对安全问题的忽略。
  • 软件组件之间交互的不可预见性:与客户自行安装的第三方组件可能有不兼容的问题。

1.5 软件安全防护手段

  • 安全设计与开发
    • 将安全思想融入到软件开发管理流程之中,在开发阶段就尽可能减少漏洞和缺陷的存在。
    • 优秀范例:微软的SDL-IT(信息技术安全开发生命周期流程)
  • 保障运行环境
    • 保证软件自身的运行环境,加强系统自身的数据完整性校验
    • 含软件完整性校验和系统完整性校验。软件完整性校验指安全软件安装时对系统的重要文件进行校验并保存校验值。系统完整性校验则从更加底层的方面校验。
  • 加强软件自身行为认证
    • 确保软件自身总是向着预期的方式运行。
  • 恶意软件检测与查杀
    • 反病毒软件的安装与使用
  • 黑客攻击防护
    • 防火墙、IDS、IPS、EMET(基于主机的漏洞攻击阻断技术)
  • 系统还原
    • 将关键系统文件进行镜像备份,因此可以在受到攻击时能够还原到原来的状态。如Ghost还原软件
  • 虚拟隔离
    • 在虚拟机中进行风险较大的操作,防止风险转移到主机上。
    • 沙箱技术:可用于隔离风险行为与分析恶意软件

练习题
1. 阅读下面的代码,回答下列问题:(23分)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 这个函数的功能是对输入进行移位加密。如输入为helloworld
// 将其竖着写,每一列写3个字符,再逐行拼接,得到密文为hlodeorlwl
// hlod
// eor
// lwl
char* foo(char* plaintext){
int size = strlen(plaintext);
char* cipher = (char*)malloc(size);
int index = 0;
for(int i=0; i<3; i++){
for(int j=0; j<size / 3; j++)
cipher[index++] = plaintext[j*3+i];
}
return cipher;
}

(1) 本函数使用了malloc库函数,但是没有__________________,这可能会导致________________________。另外,函数也没有检查__________________。(6分)
(2) 当输入的字符串_____________________时,函数的输出可能会出错,原因是___________________________________________________。(5分)
(3) 除了上面提到的问题之外,这个函数还存在什么问题?(4分)
(4) 请对上述第(1)问和第(2)问提到的问题进行修复,写出完整代码。(8分)

答案:
(1) 检查是否分配成功;程序尝试访问空指针导致崩溃;输入字符串指针plaintext是否为空
(2) 长度不是3的倍数;在for循环中内循环次数为size/3,结果是一个整数,总的遍历次数为(size / 3)*3 < size,导致最后的两个字符无法被加密,从而结果错误
(3) 该函数不能加密中文字符串,因为中文字符至少占2字节,程序逐字节进行移位会导致中文字符被拆分,从而出现乱码。
(4) 具体代码略,注意当明文长度不是3的倍数时的处理方式。

解题技巧:常见的安全隐患

  1. malloc没有检查是否成功分配
  2. 缓冲区溢出
  3. 整数溢出
  4. 没有释放空间(不是所有情况)
  5. 线程死锁(考的少)
  6. 逻辑错误(需要对代码进行具体分析

Chapter 5 语法制导翻译技术与中间代码生成

5.1 概述

对语法分析后的语法单位进行,首先编译程序审查每个语法结构的静态语义,如果静态语义正确,再生成中间代码,也有的编译程序不生成中间代码直接生成实际的目标代码。

5.2 属性文法

属性文法:带有属性的文法,每一个文法符号都有对应的属性值代表其实际意义。属性分为继承属性和综合属性两种。

非终结符可以有继承属性和综合属性,而终结符只可能有综合属性。

  • 综合属性:指自下而上的属性,即在一个文法中,左式非终结符的属性值根据右式语句中符号的属性确定。
  • 继承属性:指自上而下的属性,即在一个文法中,右式非终结符的属性值根据左式非终结符的属性决定。

属性文法:一种上下文无关文法,除此之外还有一系列语义规则,为文法的每一个规则配备的计算属性的规则,称为语义规则,含属性计算、静态语义检查、符号表操作、代码生成等。

由上面的定义可知,文法产生式的左式非终结符的综合属性和右式非终结符的继承属性一定需要一个语义规则定义出来,否则将无法从现有规则中推导出这些语义。

5.3 语法制导翻译概述

相关概念
依赖图:一棵语法树中属性示例之间的信息流,它和语法树的形状相同,箭头自下而上指向。

S-属性文法:仅含有综合属性的属性文法。

L-属性文法:在产生式AX1X2...XnA\rightarrow X_1X_2...X_n中属性可以为继承属性也可以为综合属性且右侧某符号XjX_j的继承属性仅依赖于其左边X1X2...Xj1X_1X_2...X_{j-1}某些符号的属性或AA的继承属性的文法。它是S-属性文法的超集。

文法表示方法
与文法符号相关的属性和规则使用大括号括起来,插入在产生式右部的任何位置,它显式给出了计算的顺序。

EE(1)+E(2){E.val=E(1).val+E(2).val}E\rightarrow E^{(1)}+E^{(2)}\{E.\operatorname{val}=E^{(1)}.\operatorname{val}+E^{(2)}.\operatorname{val}\}

根据语法树进行语义分析时,只需进行先根遍历逐一计算即可。

语法树遍历
对于继承属性,由于子节点的继承属性值由其父节点决定,直接由上而下进行计算即可。

对于综合属性,由于父节点的综合属性值由其子节点决定,需要由下而上进行计算。

由于S-属性文法不存在继承属性,因此直接由下而上遍历一次即可,计算同时也可以分析语法。对于L-属性文法,由于产生式右式偏后面的符号不会影响偏前面的符号的值,而由上而下规约在一个产生式中是以从左到右的顺序进行。因此每一次遍历到的符号的属性值无论是继承属性还是综合属性都可以由前面已经遍历过的符号的值决定,因此只需要由上而下进行分析即可。

注意:S-属性文法的自下而上分析需要使用栈,除文法分析使用到的状态栈和文法符号栈之外还需要一个语义值栈。由于S-属性文法使用LL文法的分析方式进行分析,因此不能产生无限左递归,需要参考LL文法的左递归消除方式消除文法的左递归。

例:(第五章作业第1题)给定文法G[S]: S→L.L | L,L→ LB | B, B→ 0 | 1,设计一个S-属性文法将该文法描述的二进制数转换成十进制数,比如10.11翻译之后得到2.75。(提示:设计综合属性分别表示值以及二进制数的位数)
解答:
原文法:
G[S]:SL.LLLLBBB01G[S]: \\ S→L.L | L\\L→ LB | B\\B→ 0 | 1
设计非终结符的两个属性:十进制值(val)和位数(bits)
容易得到文法规则B01B\rightarrow 0|1的语义规则为:
B.val=Lex.digit,B.bits=1B.\operatorname{val}=\operatorname{Lex.digit},B.\operatorname{bits}=1
对于文法规则LBL\rightarrow B,可得其语义规则为:
L.val=B.val,L.bits=1L.\operatorname{val}=B.\operatorname{val},L.\operatorname{bits}=1
对于文法规则L(1)L(2)BL^{(1)}\rightarrow L^{(2)}B,不考虑值有可能成为小数,其语义规则为:
L(1).val=L(2)×2+B.val,L.bits=L(1).bits+1L^{(1)}.\operatorname{val}=L^{(2)}\times2+B.\operatorname{val},L.\operatorname{bits}=L^{(1)}.\operatorname{bits}+1
对于文法规则SLS\rightarrow L,语义规则为:
S.val=L.val,S.bits=L.bitsS.\operatorname{val}=L.\operatorname{val},S.\operatorname{bits}=L.\operatorname{bits}
对于文法规则SL(1).L(2)S\rightarrow L^{(1)}.L^{(2)},其语义规则为:
S.val=L(1).val+L(2).val×2L(2).bitsS.\operatorname{val}=L^{(1)}.\operatorname{val}+L^{(2)}.\operatorname{val}\times2^{-L^{(2)}.\operatorname{bits}},由此得到SS表示的十进制数。
则设计出来的S-属性文法为:
G[S]:SL(1).L(2):S.val=L(1).val+L(2).val×2L(2).bitsSL:S.val=L.val,S.bits=L.bitsL(1)L(2)B:L(1).val=L(2)×2+B.val,L.bits=L(1).bits+1LB:L.val=B.val,L.bits=1B0:B.val=0B1:B.val=1G[S]: \\S→L^{(1)}.L^{(2)}:S.\operatorname{val}=L^{(1)}.\operatorname{val}+L^{(2)}.\operatorname{val}\times2^{-L^{(2)}.\operatorname{bits}} \\S→L:S.\operatorname{val}=L.\operatorname{val},S.\operatorname{bits}=L.\operatorname{bits} \\L^{(1)}→ L^{(2)}B:L^{(1)}.\operatorname{val}=L^{(2)}\times2+B.\operatorname{val},L.\operatorname{bits}=L^{(1)}.\operatorname{bits}+1 \\L→B:L.\operatorname{val}=B.\operatorname{val},L.\operatorname{bits}=1 \\B→ 0:B.\operatorname{val}=0 \\B→1:B.\operatorname{val}=1

原文法:
G[S]:SL.LLLLBBB01G[S]: \\S→L.L | L\\L→ LB | B\\B→ 0 | 1
设计非终结符的两个属性:十进制值(val)和位数(bits)
容易得到文法规则B01B\rightarrow 0|1的语义规则为:
B.val=Lex.digit,B.bits=1B.\operatorname{val}=\operatorname{Lex.digit},B.\operatorname{bits}=1
对于文法规则LBL\rightarrow B,可得其语义规则为:
L.val=B.val,L.bits=1L.\operatorname{val}=B.\operatorname{val},L.\operatorname{bits}=1
对于文法规则L(1)L(2)BL^{(1)}\rightarrow L^{(2)}B,不考虑值有可能成为小数,其语义规则为:
L(1).val=L(2)×2+B.val,L.bits=L(1).bits+1L^{(1)}.\operatorname{val}=L^{(2)}\times2+B.\operatorname{val},L.\operatorname{bits}=L^{(1)}.\operatorname{bits}+1
对于文法规则SLS\rightarrow L,语义规则为:
S.val=L.val,S.bits=L.bitsS.\operatorname{val}=L.\operatorname{val},S.\operatorname{bits}=L.\operatorname{bits}
对于文法规则SL(1).L(2)S\rightarrow L^{(1)}.L^{(2)},其语义规则为:
S.val=L(1).val+L(2).val×2L(2).bitsS.\operatorname{val}=L^{(1)}.\operatorname{val}+L^{(2)}.\operatorname{val}\times2^{-L^{(2)}.\operatorname{bits}},由此得到SS表示的十进制数。
则设计出来的S-属性文法为:
G[S]:SL(1).L(2):S.val=L(1).val+L(2).val×2L(2).bitsSL:S.val=L.val,S.bits=L.bitsL(1)L(2)B:L(1).val=L(2)×2+B.val,L.bits=L(1).bits+1LB:L.val=B.val,L.bits=1B0:B.val=0B1:B.val=1G[S]: \\S→L^{(1)}.L^{(2)}:S.\operatorname{val}=L^{(1)}.\operatorname{val}+L^{(2)}.\operatorname{val}\times2^{-L^{(2)}.\operatorname{bits}} \\S→L:S.\operatorname{val}=L.\operatorname{val},S.\operatorname{bits}=L.\operatorname{bits} \\L^{(1)}→ L^{(2)}B:L^{(1)}.\operatorname{val}=L^{(2)}\times2+B.\operatorname{val},L.\operatorname{bits}=L^{(1)}.\operatorname{bits}+1 \\L→B:L.\operatorname{val}=B.\operatorname{val},L.\operatorname{bits}=1 \\B→ 0:B.\operatorname{val}=0 \\B→1:B.\operatorname{val}=1

5.4 中间语言

5.4.1 逆波兰式

逆波兰式又称为后缀式,不存在因为运算符优先级问题而产生的冲突。
逆波兰式的计算较为简单,只需要使用一个栈即可完成计算。

5.4.2 三元式和树形表示

三元式:(op, arg1, arg2),其中op为运算符,arg1和arg2分别为操作数。
特点:

  • 三元式出来的顺序和语法成分的计值顺序相一致。其运算结果由每一个三元式之前的序号指示(称为指示器)
  • 三元式之间的联系是通过指示器实现的。

间接三元式:为减少三元式在优化时的顺序改动,需要增加一个间接码表,以这种方式进行处理的称为间接三元式。间接三元式的理解也不难,对于传统三元式,如果需要转换的表达式中出现了重复的计算,那么就会出现重复的三元式,在优化时为了消除重复,势必会修改一些三元式的序号名,这样就需要修改所有引用的序号名。为了避免这种情况发生,干脆将所有三元式放入一个类似于三元式池的地方,实际的计算顺序只使用序号表示(保存在间接码表之中),这样在优化时只需要修改间接码表即可,而不需要对三元式进行直接的修改。

树形表示:三元式的另一种表示形式,即将三元式的计算以树形结构进行表示,类似于语法树。

5.4.3 四元式

四元式:(op, arg1, arg2, result)
特点:

  • 出现的顺序和语法成分的计值顺序相一致。
  • 易于调整和变动四元式,不会出现三元式的问题。
  • 便于优化处理。

三地址代码:result=arg1 op arg2

5.5 自上而下语法制导翻译

5.5.1 简单算数表达式和赋值语句的翻译

简单算数表达式和赋值语句到四元式的翻译步骤:

  • 分析文法特点。
  • 设置一系列语义变量,定义语义过程、语义函数。
  • 修改文法,写出每一规则式的语义子程序。
  • 扩充LR分析栈,构造LR分析表。

一般需要定义下列函数:

  • newtemp函数:产生一个新的变量名,一般会送入符号表中。
  • emit函数:根据一个三地址代码产生一个四元式,并填入四元式表中。
  • lookup函数:传入一个变量名,判断这个变量名是否在符号表中,如果在则返回其指针,否则返回NULL。
  • 语义变量place:表示存放该非终结符的变量名在符号表中的入口地址或临时变量名的整数码(可以参与运算)。

书中第119页给出了对加法和乘法的文法的语法制导翻译。注意右边的函数中引号的作用是将运算符引用起来,并没有其他的意思。

5.5.2 布尔表达式的翻译

布尔表达式用于计算逻辑值,可作为控制语句的条件式。在翻译时仿照算数表达式进行计值。考虑到布尔表达式中有短路情况存在,因此还需要一定的控制流,转移到某个位置表示其求值结果。

布尔表达式的文法:
EEEEE¬E(E)iropiiE\rightarrow E\land E|E\vee E|\lnot E|(E)|i \operatorname{rop}i|i

较为固定的翻译方法:

  • 对于ABA\vee B,可翻译为:if A then true else B
  • 对于ABA\land B,可翻译为:if A then B else false
  • 对于¬A\lnot A,可翻译为:if A then false else true
  • 在书本第121页

还需要定义下面的控制流转换四元式:

  • if E goto L1:如果E为真,则跳转到L1
  • if E(1) rop E(2) goto L1:如果E(1) rop E(2),则跳转到L1
  • goto L2:无条件跳转到L2

定义了上面的四元式后,将布尔表达式转换为四元式的过程就类似于将C语言中的条件判断式转换为汇编语言。

转换的相关技巧:由于条件判断和赋值不能在一个四元式中进行,因此如果需要翻译包含有E(1) rop E(2)的布尔表达式,需要进行一定的转换。转换方式如下:

  • if E(1) rop E(2) goto L2
  • L1: t1=0
  • goto L3
  • L2: t1=1
  • L3: …

注意:if四元式的跳转是向距离自己较远的一条赋值语句跳转,而距离自己较近的赋值语句需要通过无条件跳转最终到达其他四元式。实际上这也是编译器处理布尔表达式条件跳转时常用的方法。在不考虑使用控制流优化的情况下,上述方法已经完全够用。

控制语句中布尔表达式的翻译
对于控制语句而言,存在一个短路现象。两个表达式相与,第一个为假则整个语句一定为假;两个表达式相或,第一个为真则整个语句一定为真。因此利用此特性简化对布尔表达式求值的判断。

真出口、假出口:布尔表达式为真/假时控制流向的转移目标

为规范化针对短路现象的优化,定义以下三个非终结符属性和两个函数:

  • tr:记录表达式E所对应的四元式需要回填真出口的四元式的地址所构成的链。
  • fa:记录表达式E所对应的四元式需要回填假出口的四元式的地址所都成的链。
  • code:记录表达式E所对应第一条四元式的序号。
  • merg(p1, p2):链接函数,用于链接真链或假链。其对应的情况是二者相与前者为假或二者相或前者为真的情况,即不需要判断第二个表达式的真假。如在二者相或表达式中,最后一条语句为E.tr=merg(E(1).tr, E(2).tr),这是在让第二个表达式为真时跳转到第一个表达式为真时需要执行的跳转四元式。并且将两个表达式相或作为整体指明其为真时应该执行的四元式的编号,赋值给E。需要注意的是:相或时真出口应该是第一个表达式的真出口,因为第一个表达式为真时会直接从真出口出去。该函数实际上就是合并两条链,并返回链首。
  • bp(p, t):回填函数,用于回填真链与假链。其对应的情况是二者相与前者为真或二者相或前者为假的情况,即需要判断第二个表达式的真假。如在二者相或表达式中,第一条语句即为bp(E(1).fa, E(2).code),指的是让表达式1为假时跳转到表达式2。在二者相与表达式中,则对应为bp(E(1).tr, E(2).code)。该函数实际上就是将一条链上链接的所有四元式的第四个字段都填写为一个值。

翻译过程理解:

  • 两者相或:回填是将表达式1的假出口设置为表达式2,链接是将表达式2的真出口设置为表达式1的真出口,同时将表达式整体的真出口设置为表达式2的真出口,最后将表达式整体的假出口设置为表达式2的假出口。
  • 两者相与:回填是将表达式1的真出口设置为表达式2,链接是将表达式2的假出口设置为表达式1的假出口,同时将表达式整体的假出口设置为表达式2的假出口,最后将表达式整体的真出口设置为表达式2的真出口。

例:翻译布尔表达式E1E2E3E_1\lor E_2\land E_3
分析:首先列出四元式:

100: if E1 goto ?
101: goto ?
102: if E2 goto ?
103: goto ?
104: if E3 goto ?
105: goto ?

然后按照语法树顺序进行句子的翻译(语法树略)
根据算符优先级,应该首先翻译E2E3E_2\land E_3,使用规则EE(1)E(2)E\rightarrow E^{(1)}\land E^{(2)}
第一条语句: bp(E(1).tr, E(2).code),执行后:

100: if E1 goto ?
101: goto ?
102: if E2 goto 104
103: goto ?
104: if E3 goto ?
105: goto ?

第二条语句: E.code=E(1).code,执行后,E.code=102
第三条语句: E.tr=E(2).tr,执行后,E.tr=104
第四条语句: E.fa=merg(E(1).fa, E(2).fa),执行后,E.fa=105

100: if E1 goto ?
101: goto ?
102: if E2 goto 104
103: goto 105
104: if E3 goto ?
105: goto ?

然后翻译或语句:
第一条语句: bp(E(1).fa, E(2).code),执行后:

100: if E1 goto ?
101: goto 102
102: if E2 goto 104
103: goto 105
104: if E3 goto ?
105: goto ?

第二条语句: E.code=E(1).code,执行后,E.code=100
第三条语句: E.fa=E(2).fa,执行后,E.tr=105
第四条语句: E.fa=merg(E(1).tr, E(2).tr),执行后,E.fa=104

100: if E1 goto ? // 真出口
101: goto 102
102: if E2 goto 104
103: goto ? // 假出口
104: if E3 goto 100
105: goto 103

5.5.3 控制语句的翻译

if语句和if-else语句的翻译

对于if语句和if-else语句,由于可能存在嵌套现象,因此在文法规则SifEthenS(1)elseS(2)S\rightarrow \operatorname{if} E \operatorname{then} S^{(1)} \operatorname{else} S^{(2)}中,E也有可能是控制语句,在S(2)没有分析完之前,S(1)执行完之后跳转到哪里实际上是不知道的,而内部的嵌套语句也是如此。因此定义一个语义变量chain记忆所有待填信息的四元式链,便于在翻译完成后回填。

将原有文法扩展为:
S→if E then M S
S→if E then M S N else M S
M→ε
N→ε

语法规则定义如下:

以此规则尝试翻译下列语句:
if(A) then (if(B) then C else D) else E

首先列出四元式(也要按照文法分析顺序进行):

100: if A goto ?
101: goto ?
102: if B goto ?
103: goto ?
104: C
105: goto ?
106: D
107: E
108: goto ?

其中102~106为内部if-else语句翻译出来的四元式。

首先肯定是翻译内部的if-else语句:
第一条语句: bp(B.tr, M.s)

100: if A goto ?
101: goto ?
102: if B goto 104
103: goto ?
104: C
105: goto ?
106: D
107: E
108: goto ?

第二条语句: bp(B.fa, M(1).s)

100: if A goto ?
101: goto ?
102: if B goto 104
103: goto 106
104: C
105: goto ?
106: D
107: E
108: goto ?

第三条语句: tmp=merg(C.chain, N.s)(修改了N.s)
第四条语句: B.chain=merg(tmp, D.chain)(让B.chain链接到了C.chain和D.chain)

然后翻译外层的if-else语句:
第一条语句: bp(A.tr, M.s)

100: if A goto 102
101: goto ?
102: if B goto 104
103: goto 106
104: C
105: goto ?
106: D
107: E
108: goto ?

第二条语句: bp(A.fa, M(1).s)

100: if A goto 102
101: goto 107
102: if B goto 104
103: goto 106
104: C
105: goto ?
106: D
107: E
108: goto ?

第三条语句: tmp=merg(X.chain, N.s)(修改了N.s)
第四条语句: A.chain=merg(tmp, E.chain)(让A.chain链接到了X.chain和E.chain)

从上面的例子可以看出,对于if-else语句的分析是由内而外的。关键在于内部不能确定的跳转到底是哪些。定义chain的目的正是为了记录这些不能确定的四元式。

5.5.4 循环语句的翻译

while语句的翻译
同样进行扩展:
(1) S→while E do M S(1)
(2) M→ε

相较于if-else语句要简单些,在内部四元式执行完成之后无条件跳转到开头的判断即可。

5.5.5 简单说明语句的翻译

定义函数FILL:将名字id和属性A登记在符号表中。
定义语义变量ATT:该非终结符的属性。

题型总结

1. LL(1)文法分析

该类题型主要包括左递归消除、回溯消除、构建预测分析表、还原递归分析过程、还原预测分析过程等。

例-1(课后习题4-3改)设文法G[S]G[S]
SAS\rightarrow A
ABAiBA\rightarrow B|AiB
BCB+CB\rightarrow C|B+C
C)A(C\rightarrow )A*|(

(1) 将文法G[S]G[S]改写为LL(1)文法。

分析:改写LL(1)文法第一步——消除左递归。

首先发现AAiBA\rightarrow AiB存在左递归,于是将第2条规则改写为:
ABAA\rightarrow BA'
AiBAεA'\rightarrow iBA'|\varepsilon(注意这里不能没有ε\varepsilon!)

然后第3条规则显然也有左递归,继续改写:
BCBB\rightarrow CB'
B+CBεB'\rightarrow +CB'|\varepsilon

至此左递归消除完成。文法变为:
SAS\rightarrow A
ABAA\rightarrow BA'
AiBAεA'\rightarrow iBA'|\varepsilon
BCBB\rightarrow CB'
B+CBεB'\rightarrow +CB'|\varepsilon
C)A(C\rightarrow )A*|(

下面查看这其中是否有回溯。对于左部相同的两条规则:

  • AiBAεA'\rightarrow iBA'|\varepsilon,其SELECT\operatorname{SELECT}集分别为{i}\{i\}{,\{*, $ }\},无交集(注意这里的$是由FOLLOW\operatorname{FOLLOW}集产生的,如果一个非终结符后面可以不跟任何终结符,那么就需要将符号串的结束符$包含到SELECT\operatorname{SELECT}集中。)
  • B+CBεB'\rightarrow +CB'|\varepsilon,其SELECT\operatorname{SELECT}集分别为{+}\{+\}{i,,\{i,*, $ }\},无交集。
  • C)A(C\rightarrow )A*|(,其SELECT\operatorname{SELECT}集分别为{)}\{)\}{(}\{(\},无交集。

注意这里的FOLLOW\operatorname{FOLLOW}集计算过程。盲目去找很容易漏掉几个。找FOLLOW\operatorname{FOLLOW}集不应该只是去找某一个非终结符的FOLLOW\operatorname{FOLLOW}集,而是应该从头到尾将所有非终结符的FOLLOW\operatorname{FOLLOW}集全部找到,因为一些非终结符的FOLLOW\operatorname{FOLLOW}集可以直接加载其他非终结符的FOLLOW\operatorname{FOLLOW}集之中。
对于上例中的文法。我们来使用规范化的方式寻找FOLLOW\operatorname{FOLLOW}集:

  • 对于SS,其为开始符号,后面本来就可以不跟什么符号,因此$ FOLLOW(S)\in\operatorname{FOLLOW}(S)。又有SAS\rightarrow A,因此AAFOLLOW\operatorname{FOLLOW}集中一定包含SSFOLLOW\operatorname{FOLLOW}集中的所有元素。注意此时我们并不确定SSFOLLOW\operatorname{FOLLOW}集中是否只有一个$。
FOLLOW集
SS {\{ $ }\}
AA {\{ $ }\}
  • 对于文法ABAA\rightarrow BA',有FOLLOW(A)FOLLOW(A)\operatorname{FOLLOW}(A)\subseteq\operatorname{FOLLOW}(A')FIRST(A)FOLLOW(B)\operatorname{FIRST}(A')\subseteq\operatorname{FOLLOW}(B),其中FIRST(A)={i,ε}\operatorname{FIRST}(A')=\{i,\varepsilon\}
FOLLOW集
SS {\{ $ }\}
AA {\{ $ }\}
AA' {\{ $ }\}
BB {i,\{i, $ }\}
  • 对于文法BCBB\rightarrow CB',有FIRST(B)FOLLOW(C)\operatorname{FIRST}(B')\subseteq\operatorname{FOLLOW}(C),其中FIRST(B)={+,ε}\operatorname{FIRST}(B')=\{+,\varepsilon\},且有FOLLOW(B)FOLLOW(B)\operatorname{FOLLOW}(B)\subseteq\operatorname{FOLLOW}(B')FOLLOW(B)FOLLOW(C)\operatorname{FOLLOW}(B')\in\operatorname{FOLLOW}(C)
FOLLOW集
SS {\{ $ }\}
AA {\{ $ }\}
AA' {\{ $ }\}
BB {i,\{i, $ }\}
BB' {i,\{i, $ }\}
CC {+,\{+, $ }\}
  • 对于文法C)A(C\rightarrow )A*|(,有FOLLOW(A)*\in\operatorname{FOLLOW}(A)
FOLLOW集
SS {\{ $ }\}
AA {,\{*, $ }\}
AA' {\{ $ }\}
BB {i,\{i, $ }\}
BB' {i,\{i, $ }\}
CC {+,\{+, $ }\}
  • 扩充被AA影响的FOLLOW\operatorname{FOLLOW}集:
FOLLOW集
SS {\{ $ }\}
AA {,\{*, $ }\}
AA' {,\{*, $ }\}
BB {i,\{i, $ }\}
BB' {i,\{i, $ }\}
CC {+,\{+, $ }\}
  • 对于文法AiBAεA'\rightarrow iBA'|\varepsilon,有FOLLOW(A)FOLLOW(B)\operatorname{FOLLOW}(A')\in\operatorname{FOLLOW}(B)
FOLLOW集
SS {\{ $ }\}
AA {,\{*, $ }\}
AA' {,\{*, $ }\}
BB {i,,\{i,*, $ }\}
BB' {i,,\{i,*, $ }\}
CC {i,,+,\{i,*,+, $ }\}

故不存在回溯问题。

最终修改完成的文法为:
SAS\rightarrow A
ABAA\rightarrow BA'
AiBAεA'\rightarrow iBA'|\varepsilon
BCBB\rightarrow CB'
B+CBεB'\rightarrow +CB'|\varepsilon
C)A(C\rightarrow )A*|(

(2) 求经过改写后的每个非终结符的FIRST\operatorname{FIRST}集和FOLLOW\operatorname{FOLLOW}集,以及每一条规则的SELECT\operatorname{SELECT}集。

解:FIRST\operatorname{FIRST}集和FOLLOW\operatorname{FOLLOW}集如下:

FIRST集 FOLLOW集
SS {),(}\{),(\} {\{ $ }\}
AA {),(}\{),(\} {,\{*, $ }\}
AA' {i,ε}\{i,\varepsilon\} {,\{*, $ }\}
BB {),(}\{),(\} {i,,\{i,*, $ }\}
BB' {+,ε}\{+,\varepsilon\} {i,,\{i,*, $ }\}
CC {),(}\{),(\} {i,,+,\{i,*,+, $ }\}

SELECT\operatorname{SELECT}集如下:

SELECT集
SAS\rightarrow A {),(}\{),(\}
ABAA\rightarrow BA' {),(}\{),(\}
AiBAA'\rightarrow iBA' {i}\{i\}
AεA'\rightarrow\varepsilon {,\{*, $ }\}
BCBB\rightarrow CB' {),(}\{),(\}
B+CBB'\rightarrow +CB' {+}\{+\}
BεB'\rightarrow \varepsilon {i,,\{i,*, $ }\}
C)AC\rightarrow )A* {)}\{)\}
C(C\rightarrow ( {(}\{(\}

(3) 写出该文法的递归下降分析程序的AA'函数、BB'函数和CC函数:

解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
A'(){
if(sym == 'i'){
scanner(); // 输入符号串读位置前移一位
B();
A'();
}else if(sym != '*' && sym != '$')
error();
else
scanner();
}

B'(){
if(sym == '+'){
C();
B'();
}else if(sym != 'i' && sym != '*' && sym != '$')
error();
}

C(){
if(sym == '('){
scanner();
A();
scanner();
if(sym != '*')
error();
}else if(sym != ')')
error();
scanner();
}

递归下降分析函数需要保证:在任何一个非终结符识别函数刚刚开始执行时,读位置始终指向该非终结符应该识别的第一个字符。因此调整指针的操作应该在上一级函数中实现,这也是AA函数和CC函数最后一句scanner()的含义。

(4) 画出预测分析图。

解:可以根据SELECT\operatorname{SELECT}集直接画出预测分析图:

( ) * + i $
SS SAS\rightarrow A SAS\rightarrow A
AA ABAA\rightarrow BA' ABAA\rightarrow BA'
AA' AεA'\rightarrow\varepsilon AiBAA'\rightarrow iBA' AεA'\rightarrow\varepsilon
BB BCBB\rightarrow CB' BCBB\rightarrow CB'
BB' BεB'\rightarrow \varepsilon B+CBB'\rightarrow +CB' BεB'\rightarrow \varepsilon BεB'\rightarrow \varepsilon
CC C(C\rightarrow ( C)AC\rightarrow )A*

(5) 使用上面的预测分析图分析输入串)(i(+(*

解:画图——

分析栈 输入串 所用规则
$ )(i(+(*$
$A )(i(+(*$ SAS\rightarrow A
$A'B )(i(+(*$ ABAA\rightarrow BA'
$A'B'C )(i(+(*$ BCBB\rightarrow CB'
$A'B'*A) )(i(+(*$ C)AC\rightarrow )A*
$A'B'*A'B (i(+(*$ ABAA\rightarrow BA'
$A'B'*A'B'C (i(+(*$ BCBB\rightarrow CB'
$A'B'*A'B'( (i(+(*$ C(C\rightarrow (
$A'B'*A'B' i(+(*$
$A'B'*A' i(+(*$ BεB'\rightarrow \varepsilon
$A'B'*A'Bi i(+(*$ AiBAA'\rightarrow iBA'
$A'B'*A'B (+(*$
$A'B'*A'B'C (+(*$ BCBB\rightarrow CB'
$A'B'*A'B'( (+(*$ C(C\rightarrow (
$A'B'*A'B' +(*$
$A'B'*A'B'C+ +(*$ B+CBB'\rightarrow +CB'
$A'B'*A'B'C (*$
$A'B'*A'B'( (*$ C(C\rightarrow (
$A'B'*A'B' *$
$A'B'*A' *$ BεB'\rightarrow \varepsilon
$A'B'* *$ AεA'\rightarrow\varepsilon
$A'B' $
$A' $ BεB'\rightarrow \varepsilon
$ $ AεA'\rightarrow\varepsilon

成功。

上面的分析流程:如果栈中最上面一个是非终结符,下一步就要根据输入串最前面的字符选择候选式。如果栈中最上面一个是终结符,就和输入串最前面的字符比较,如果相等就相消,如果不等就输出错误。

2. OG文法分析

OG文法没有讲完,后面的素短语等内容没有要求,因此只需要掌握两个集合的求解以及算符优先表的构建即可。

例-2:对于例-1中的文法,求所有非终结符的FIRSTVT\operatorname{FIRSTVT}集和LASTVT\operatorname{LASTVT}集,并画出算符优先表。

SAS\rightarrow A
ABAiBA\rightarrow B|AiB
BCB+CB\rightarrow C|B+C
C)A(C\rightarrow )A*|(

分析:按照构建两个集合的方法求出所有非终结符的FIRSTVT\operatorname{FIRSTVT}集和LASTVT\operatorname{LASTVT}集:

FIRSTVT LASTVT
SS {i,(,),+}\{i,(,),+\} {i,,(,+}\{i,*,(,+\}
AA {i,(,),+}\{i,(,),+\} {i,,(,+}\{i,*,(,+\}
BB {(,),+}\{(,),+\} {,(,+}\{*,(,+\}
CC {(,)}\{(,)\} {,(}\{*,(\}

构建算符优先表:

i ( ) + * $
i \gtrdot \lessdot \lessdot \lessdot \gtrdot \gtrdot
( \gtrdot \gtrdot \gtrdot \gtrdot
) ,\gtrdot,\lessdot \lessdot \lessdot \lessdot \eqcirc
+ \gtrdot \lessdot \lessdot \gtrdot \gtrdot \gtrdot
* \gtrdot \gtrdot \gtrdot
$ \lessdot \lessdot \lessdot \lessdot \eqcirc

3. LR(0)文法

该部分包含LR(0)文法的分析表、活前缀DFA的构建。

例-3:画出例-1中文法的规范句型活前缀DFA以及LR(0)分析表,如果有冲突说明冲突来源,没有冲突说明理由。

SAS\rightarrow A
ABAiBA\rightarrow B|AiB
BCB+CB\rightarrow C|B+C
C)A(C\rightarrow )A*|(

分析:首先拓广,加一条规则:
SSS'\rightarrow S
SAS\rightarrow A
ABAiBA\rightarrow B|AiB
BCB+CB\rightarrow C|B+C
C)A(C\rightarrow )A*|(

然后递归地构建DFA:

  • 求出CLOSURE(SS)={SS,SA,AB,AAiB,BC,BB+C,C)A,C(}\operatorname{CLOSURE}(S'\rightarrow\cdot S)=\{S'\rightarrow\cdot S,S\rightarrow\cdot A,A\rightarrow\cdot B,A\rightarrow\cdot AiB,B\rightarrow\cdot C,B\rightarrow\cdot B+C,C\rightarrow\cdot)A*,C\rightarrow\cdot(\},作为项目集I0I_0
  • 求出GOTO(I0,S)=CLOSURE({SS})={SS}\operatorname{GOTO}(I_0,S)=\operatorname{CLOSURE}(\{S'\rightarrow S\cdot\})=\{S'\rightarrow S\cdot\},作为项目集I1I_1,其没有GOTO\operatorname{GOTO}函数。
  • 求出GOTO(I0,A)=CLOSURE({SA,AAiB})={SA,AAiB}\operatorname{GOTO}(I_0,A)=\operatorname{CLOSURE}(\{S\rightarrow A\cdot,A\rightarrow A\cdot iB\})=\{S\rightarrow A\cdot,A\rightarrow A\cdot iB\},作为项目集I2I_2
  • 求出GOTO(I0,B)=CLOSURE{AB,BB+C}={AB,BB+C}\operatorname{GOTO}(I_0,B)=\operatorname{CLOSURE}\{A\rightarrow B\cdot,B\rightarrow B\cdot +C\}=\{A\rightarrow B\cdot,B\rightarrow B\cdot +C\},作为项目集I3I_3
  • 求出GOTO(I0,C)=CLOSURE{BC}={BC}\operatorname{GOTO}(I_0,C)=\operatorname{CLOSURE}\{B\rightarrow C\cdot\}=\{B\rightarrow C\cdot\},作为项目集I4I_4,其没有GOTO\operatorname{GOTO}函数。
  • 求出GOTO(I0,")")=CLOSURE{C)A}={C)A,AB,AAiB,BC,BB+C,C)A,C(}\operatorname{GOTO}(I_0,")")=\operatorname{CLOSURE}\{C\rightarrow )\cdot A*\}=\{C\rightarrow )\cdot A*,A\rightarrow\cdot B,A\rightarrow\cdot AiB,B\rightarrow\cdot C,B\rightarrow\cdot B+C,C\rightarrow\cdot)A*,C\rightarrow\cdot(\},作为项目集I5I_5
  • 求出GOTO(I0,"(")=CLOSURE{C(}={C(}\operatorname{GOTO}(I_0,"(")=\operatorname{CLOSURE}\{C\rightarrow (\cdot\}=\{C\rightarrow (\cdot\},作为项目集I6I_6,其没有GOTO\operatorname{GOTO}函数。
  • 求出GOTO(I2,i)=CLOSURE({AAiB})={AAiB}\operatorname{GOTO}(I_2,i)=\operatorname{CLOSURE}(\{A\rightarrow Ai\cdot B\})=\{A\rightarrow Ai\cdot B\},作为项目集I7I_7
  • 求出GOTO(I3,+)=CLOSURE({BB+C})={BB+C,C)A,C(}\operatorname{GOTO}(I_3,+)=\operatorname{CLOSURE}(\{B\rightarrow B+\cdot C\})=\{B\rightarrow B+\cdot C,C\rightarrow\cdot)A*,C\rightarrow\cdot(\},作为项目集I8I_8
  • 求出GOTO(I5,A)=CLOSURE({C)A,AAiB})={C)A,AAiB}\operatorname{GOTO}(I_5, A)=\operatorname{CLOSURE}(\{C\rightarrow)A\cdot*,A\rightarrow A\cdot iB\})=\{C\rightarrow)A\cdot*,A\rightarrow A\cdot iB\},作为项目集I9I_9
  • 求出GOTO(I5,B)=CLOSURE({AB,BB+C})=I3\operatorname{GOTO}(I_5, B)=\operatorname{CLOSURE}(\{A\rightarrow B\cdot, B\rightarrow B\cdot+C\})=I_3
  • 求出GOTO(I5,C)=CLOSURE{BC}=I4\operatorname{GOTO}(I_5, C)=\operatorname{CLOSURE}\{B\rightarrow C\cdot\}=I_4
  • 求出GOTO(I5,"(")=CLOSURE({C(})=I6\operatorname{GOTO}(I_5, "(")=\operatorname{CLOSURE}(\{C\rightarrow (\cdot\})=I_6
  • 求出GOTO(I5,")")=CLOSURE({C)A})=I5\operatorname{GOTO}(I_5,")")=\operatorname{CLOSURE}(\{C\rightarrow )\cdot A*\})=I_5
  • 求出GOTO(I7,B)=CLOSURE({AAiB})={AAiB}\operatorname{GOTO}(I_7, B)=\operatorname{CLOSURE}(\{A\rightarrow AiB\cdot\})=\{A\rightarrow AiB\cdot\},作为项目集I10I_{10},其没有GOTO\operatorname{GOTO}函数。
  • 求出GOTO(I8,C)=CLOSURE({BB+C})={BB+C}\operatorname{GOTO}(I_8, C)=\operatorname{CLOSURE}(\{B\rightarrow B+C\cdot\})=\{B\rightarrow B+C\cdot\},作为项目集I11I_{11},其没有GOTO\operatorname{GOTO}函数。
  • 求出GOTO(I8,")")=CLOSURE({C)A})=I5\operatorname{GOTO}(I_8, ")")=\operatorname{CLOSURE}(\{C\rightarrow)\cdot A*\})=I_5
  • 求出GOTO(I8,"(")=CLOSURE{C(}=I6\operatorname{GOTO}(I_8,"(")=\operatorname{CLOSURE}\{C\rightarrow (\cdot\}=I_6
  • 求出GOTO(I9,i)=CLOSURE({AAiB})={AAiB,BC,BB+C,C)A,C(}\operatorname{GOTO}(I_9, i)=\operatorname{CLOSURE}(\{A\rightarrow Ai\cdot B\})=\{A\rightarrow Ai\cdot B,B\rightarrow\cdot C,B\rightarrow\cdot B+C,C\rightarrow\cdot)A*,C\rightarrow\cdot(\},作为项目集I12I_{12}
  • 求出GOTO(I9,)=CLOSURE({C)A})={C)A}\operatorname{GOTO}(I_9, *)=\operatorname{CLOSURE}(\{C\rightarrow)A*\cdot\})=\{C\rightarrow)A*\cdot\},作为项目集I13I_{13},其没有GOTO\operatorname{GOTO}函数。
  • 求出GOTO(I12,B)={AAiB,BB+C}\operatorname{GOTO}(I_{12},B)=\{A\rightarrow AiB\cdot,B\rightarrow B\cdot+C\},作为项目集I14I_{14},有GOTO(I14,+)=I8\operatorname{GOTO}(I_{14},+)=I_8
  • 求出GOTO(I12,C)={BC}=I4\operatorname{GOTO}(I_{12},C)=\{B\rightarrow C\cdot\}=I_4
  • 求出GOTO(I12,")")=I5\operatorname{GOTO}(I_{12},")")=I_5
  • 求出GOTO(I12,"(")=I6\operatorname{GOTO}(I_{12},"(")=I_6

由上面的推导画出DFA(图略,太复杂)
根据上面的推导画出分析表如下:

可以看见其中有多处移进-归约冲突。

4. SLR(1)文法

例-4:使用SLR(1)文法尝试改写上一题的分析表,说明改写后是否存在冲突。如果不存在,试分析符号串)(i(+(*

分析:上图中一共有3处移进-规约冲突,下面逐一进行处理:

  • 对于状态2移入符号ii,状态2的两个LR(0)项目中只有{AAiB}\{A\rightarrow A\cdot iB\}可以接受符号ii,而另一个状态{SA}\{S\rightarrow A\cdot\}无法接收符号iiFOLLOW\operatorname{FOLLOW}集中不包含ii)。因此此处冲突可以解决。
  • 对于状态3移入符号++,状态3的两个LR(0)项目中只有{BB+C}\{B\rightarrow B\cdot +C\}可以接受,而另一个无法接受。因此此处状态可以解决。
  • 对于状态14移入符号++,状态14的两个LR(0)项目中只有{BB+C}\{B\rightarrow B\cdot +C\}可以接受,而另一个无法接受。因此此处状态可以解决。

至此冲突已经完全解决,可以进行分析了。分析过程如下:

Chapter 4 语法分析

4.1 语法分析程序的功能

语法分析程序的功能是以词法分析器生成的单词符号序列作为输入,根据语言的语法规则(描述程序语言语法结构的上下文无关文法),识别出各种语法成分(表达式、语句、程序段乃至整个语法等),并在分析过程中进行语法检查,检查所给单词符号序列是否是该语言的文法的一个句子。若是则以该句子的某种形式的语法树作为输出,若不是则表明有错误,并指出错误的性质和位置。

4.2 自上而下分析法

4.2.1 非确定的自上而下分析法的思想

自上而下分析法的基本思想:对于任何输入串WW试图用一切可能的方法,从文法的开始符号出发,自上而下地为它建立一棵语法树。或者说为语法树寻找一个最左推导。

4.2.2 文法的左递归性和回溯的消除

文法左递归:如果文法中存在非终结符AA有推导A+AαA\stackrel{+}\Rightarrow A\alpha,那么在自上而下进行分析的过程中对于非终结符AA,其下就会构建一棵子树,子树中包含有以AA为根结点的子树和bb结点,而AA下面的AA还会分出一个以AA为根的子树,这就形成了无限递归,这是不允许的。因此在自上而下分析法中对文法的基本要求是:不能存在左递归。

消除左递归的方法:

1. 引入新的非终结符,将含有左递归的规则改写为右递归

这种方法与上一章词法分析中提到的一个技巧异曲同工。

技巧:左/右线性正规文法对abab^*aba^*b的处理
对于左线性正规文法而言

  • 处理abab^*AAbaA\rightarrow Ab|a
  • 处理aba^*b:必须使用两条语句:ABb,BBaεA\rightarrow Bb,B\rightarrow Ba|\varepsilon

对于右线性正规文法而言

  • 处理abab^*:必须使用两条语句:AaB,BbBεA\rightarrow aB,B\rightarrow bB|\varepsilon
  • 处理aba^*bAaAbA\rightarrow aA|b

为了消除诸如AAbaA\rightarrow Ab|a这样的左递归,可以将其转化为:AaB,BbBεA\rightarrow aB,B\rightarrow bB|\varepsilon来解决。更一般的情况如下:
AAα1Aα2...Aαmβ1β2...βnA\rightarrow A\alpha_1|A\alpha_2|...|A\alpha_m|\beta_1|\beta_2|...|\beta_n改写为:
Aβ1Aβ2A...βnAA\rightarrow\beta_1A'|\beta_2A'|...|\beta_nA'
Aα1Aα2A...αmAεA'\rightarrow\alpha_1A'|\alpha_2A'|...|\alpha_mA'|\varepsilon

2. 使用扩充BNF表示法改写含有直接左递归的规则

BNF表示法中:

  • 可以使用{α}\{\alpha\}表示符号串α\alpha可以出现0次或多次(即α\alpha^*
  • 可以使用[α][\alpha]表示符号串α\alpha出现可有可无,表示可供选择的符号串
  • 使用(α)(\alpha)可以在规则中提取因子,类似于分配律,如AabacA\rightarrow ab|ac可以写成Aa(bc)A\rightarrow a(b|c)

如此进行表示就可以很方便地消除左递归。公式如下:
AAα1Aα2...Aαmβ1β2...βnA\rightarrow A\alpha_1|A\alpha_2|...|A\alpha_m|\beta_1|\beta_2|...|\beta_n改写为:
A(β1β2...βn){α1α2...αm}A\rightarrow(\beta_1|\beta_2|...|\beta_n)\{\alpha_1|\alpha_2|...|\alpha_m\}

回溯:当存在如Aα1α2...A\rightarrow \alpha_1|\alpha_2|...这样的规则时,对于一次匹配需要对所有候选式进行试配,如果试配错误需要回溯到一开始配对的位置重新匹配。这样会增加程序执行的时间,且让程序编写更加繁琐。当同一个左部的规则的右部串有相同前缀或为ε\varepsilon时可能会产生回溯。如对于规则SaAb,AdeeεS\rightarrow aAb, A\rightarrow de|e|\varepsilon而言,输入串为abab时选择的是第3个候选式,输入串为adebadeb时选择的是第1个候选式,输入为adbadb时选择的是第2个候选式。这其中涉及对候选式的选择。

为避免分析时出现回溯,需要保证文法为LL(1)型文法。对于LL(1)型文法需要首先说明下面的定义才能给出定义:

FIRST\operatorname{FIRST}

FIRST\operatorname{FIRST}集是针对任一符号串而言的。对于符号串α\alphaFIRST(α)\operatorname{FIRST}(\alpha)定义为:

FIRST(α)={aαa...&aVT}\operatorname{FIRST}(\alpha)=\{a|\alpha\stackrel{*}\Rightarrow a...\&a\in V_T\}

即一个符号串的FIRST\operatorname{FIRST}集中的元素全为终结符,且等于该符号串可以推导出的所有第一个符号为终结符的符号串的第一个终结符构成的集合

FOLLOW\operatorname{FOLLOW}

FOLLOW\operatorname{FOLLOW}集针对任一非终结符,对于非终结符AAFOLLOW(A)\operatorname{FOLLOW}(A)定义为:

FOLLOW(A)={aS...Aa...&aVT}\operatorname{FOLLOW}(A)=\{a|S\stackrel{*}\Rightarrow ...Aa...\&a\in V_T\}

如果有S...AS\stackrel{*}\Rightarrow ...A,则规定$ FOLLOW(A)\in\operatorname{FOLLOW}(A),这里的$表示输入终结符号。即一个非终结符的FOLLOW\operatorname{FOLLOW}集就是该文法表示语言的所有符号串中所有跟在AA后面的终结符构成的集合

SELECT\operatorname{SELECT}

SELECT\operatorname{SELECT}集针对任一规则,对于规则AαA\rightarrow\alphaSELECT(Aα)\operatorname{SELECT}(A\rightarrow\alpha)定义为:

\begin{equation} \operatorname{SELECT}(A\rightarrow\alpha)=\left\{ \begin{array}{l} \operatorname{FIRST}(\alpha)\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:\:,\alpha\stackrel{*}\nRightarrow\varepsilon\\ \operatorname{FIRST}(\alpha)\backslash\{\varepsilon\}\cup\operatorname{FOLLOW}(A),\alpha\stackrel{*}\Rightarrow\varepsilon \end{array} \right. \end{equation}

SELECT\operatorname{SELECT}集的含义最难理解。当α\alpha不可能推导出空串时,说明AA一定能够推导出非空串,这时SELECT(Aα)\operatorname{SELECT}(A\rightarrow\alpha)就等于由AA推导出的所有第一个符号为终结符的符号串的第一个终结符。如果α\alpha可能推导出空串,那么除了FIRST(α)\{ε}\operatorname{FIRST}(\alpha)\backslash\{\varepsilon\}外还有一个FOLLOW(A)\operatorname{FOLLOW}(A),这就是当AA推导出空串时AA后面推导出来的内容中紧跟在AA后面的终结符。也就是AA及其后面所有符号串能够推导出来的所有的第一个符号为终结符的符号串的第一个终结符所构成的集合,也即当发现规则AαA\rightarrow \alpha时,所有下一个可能的符号构成的集合。更进一步地理解:

如果一条规则右部可以推导出ε\varepsilon,那么右部要么是ε\varepsilon,要么是由若干非终结符构成,且这些终结符均可以推导出ε\varepsilon,绝不可能包含终结符。设α=A1A2...An\alpha=A_1A_2...A_n,根据上面的对于SELECT\operatorname{SELECT}集的含义的理解,当A1A_1推导出ε\varepsilon时,第一个终结符就可能出现在A2A_2推导出来的符号串中。当A1A_1A2A_2都推导为ε\varepsilon时,第一个终结符就可能出现在A3A_3推导出来的符号串中……这些都包含在FIRST(α)\operatorname{FIRST}(\alpha)集中。而当AA推导为ε\varepsilon时,SELECT(Aα)\operatorname{SELECT}(A\rightarrow\alpha)的值就不再由AA推导而来,而是由AA后面的符号串推导而来。

LL(1)型文法定义:

一个2型文法为LL(1)型文法当且仅当其中每一个非终结符AA的任何两个不同规则AαβA\rightarrow \alpha|\beta都满足SELECT(Aα)SELECT(Aβ)=\operatorname{SELECT}(A\rightarrow\alpha)\cap\operatorname{SELECT}(A\rightarrow\beta)=\empty,且两条规则右部不能同时推出ε\varepsilon

这条规则能够保证在每一次将一个非终结符向下扩展语法树时,总是能够根据下一个符号准确判断出应该使用哪一个候选式,这样也就不会产生回溯。如果文法不是LL(1)型文法,那么一次扩展语法树读取后面一个符号时很可能不能唯一确定候选式。

实际上LL(1)文法中第一个L指的是left to right,即从左向右扫描输入串,第二个L指的是leftmost,即左递归。1指的是只需要向右看一个字符就知道应该如何进行规约。这个条件是相当严格的。

4.2.3 某些非LL(1)文法到LL(1)文法的转换

在前面已经给出了消除左递归的方法,现在只需要消除移进一个符号后选择候选式的歧义即可,这可以通过不断提取公共左因子实现。公式:
Aαβ1αβ2...αβnA\rightarrow\alpha\beta_1|\alpha\beta_2|...|\alpha\beta_n替换为
AαAA\rightarrow \alpha A'
Aβ1β2...βnA'\rightarrow \beta_1|\beta_2|...|\beta_n

需要注意的是,不是所有文法都能够转换为LL(1)文法。最简单的判断方式就是消除左递归和提取所有公共左因子之后再求每一条规则的SELECT\operatorname{SELECT}集。

4.2.4 递归下降分析法

递归下降分析法是一种确定的自上而下分析法,需要文法是LL(1)文法。基本思想是对文法中的每一个非终结符编写一个函数或子程序,每个函数的功能是识别由该非终结符所表示的语法成分。这些函数常常以递归的形式被调用,因此被称为递归下降分析法。

对于普通形式表示的文法,如果其中有规则AbBA\rightarrow bB,则定义一个函数AABB,函数声明如下:

1
2
3
4
5
6
7
8
9
10
A(){
if(sym == 'b'){
B();
}
else
error();
}
B(){
...
}

即每一个函数处理好这个函数对应的非终结符的识别工作。

对于扩充的BNF形式表示的文法,需要对循环、条件等规则中的符号串进行另外的判断。具体的实例在本文最后详细说明。

4.2.5 预测分析法与预测分析表的构造

预测分析法又称为LL(1)分析法,是确定的自上而下的一种分析法,采用这种方法要求语义分析的文法是LL(1)文法。

一个预测分析器由一张预测分析表(LL(1)分析表)、一个先进后出分析栈和一个总控程序3部分组成。输入缓冲区中保存有待分析的输入符号串,以右界符$作为结束。分析栈中保存替换当前非终结符的某规则右部符号串,句子左界符$放入栈底。预测分析表是一个M[A,a]形式的矩阵,其中AA为非终结符,aa为终结符,M[A,a]表示当AA面临输入符号aa时当前推导应该采用的候选式。当元素内容为出错标志时则表明AA不应该面临符号aa

构造预测分析表的方法:

  • 求出所有非终结符的FIRST\operatorname{FIRST}集和FOLLOW\operatorname{FOLLOW}
  • 对于文法每一条规则AαA\rightarrow\alpha,如果aFIRST(α)a\in\operatorname{FIRST}(\alpha),则M[A,a]=AαM[A,a]=A\rightarrow\alpha
  • 如果εFIRST(α)\varepsilon\in\operatorname{FIRST}(\alpha),则对于任何bFOLLOW(α)b\in\operatorname{FOLLOW}(\alpha)M[A,b]=AαM[A,b]=A\rightarrow\alpha
  • 将表中没有定义的元素全部标上error符号。

4.3 自下而上分析法的一般原理

所有的自下而上分析法都是按照“移进-规约”法的原理建立起来的语法分析方法。基本思想是用一个寄存文法符号的先进后出栈,将输入符号一个个按从左到右顺序扫描入栈,边移入边分析。当栈中符号串形成某条规则右部时就进行一次归约。将栈顶被归约的这一串符号称为可归约串。重复这一个过程直到分析完毕。如果栈中最终只剩下右界符则输入句子是一个正确的句子,否则报错。

4.4 算符优先分析法

4.4.1 方法概述

按照算数表达式的四则运算过程而设计的一种语法分析方法。这种方法首先需要规定运算符之间的优先关系和结合性质,然后借助这种关系比较相邻运算符的优先级来确定句型的可规约串并进行规约。

定义任意两个终结符号aabb之间的优先关系(共3种)

  • aba\lessdot b表示aa的优先级低于bb
  • aba\eqcirc b表示aa的优先级等于bb
  • aba\gtrdot b表示aa的优先级高于bb

注意这个优先关系可能并不具备交换律,其与出现的左右次序有关。即aba\lessdot b不一定有bab\gtrdot a

使用算符优先分析法需要保证描述语言的文法为算符优先文法:若文法GG中不存在形如U...VW...U\rightarrow ...VW...的规则,其中VVWW为非终结符,则称GG为算符文法(OG文法)。也即任何一个右式都不存在两个非终结符相邻的情况。

两个终结符优先级的定义

  • aba\eqcirc b当且仅当GG中含有形如P...ab...P\rightarrow ...ab...P...aQb...P\rightarrow ...aQb...的规则。
  • aba\lessdot b当且仅当GG中含有形如P...aR...P\rightarrow ...aR...的规则且R+b...R\stackrel{+}\Rightarrow b...R+Qb...R\stackrel{+}\Rightarrow Qb...
  • aba\gtrdot b当且仅当GG中含有形如P...Rb...P\rightarrow ...Rb...的规则且R+...aR\stackrel{+}\Rightarrow ...aR+...aQR\stackrel{+}\Rightarrow ...aQ

理解:联想在前面提到的对于四则运算语言进行定义的文法,其中加减是位于第一条规则,乘除位于第二条规则,而括号位于第三条规则,可见运算符优先级越低,其需要进行归约的次数就越多。如对于上面提到的aba\lessdot b而言,aa的归约次数比bb少,因此aa的优先级低。

如果对于任意两个终结符号对(a,b)(a,b)在上述的3种关系中只有一种成立,则称该文法为OPG文法(算符优先文法)。如四则运算就不属于算符优先文法,因为加和减可能满足两种关系:加的优先级低于减、加的优先级高于减,这取决于加和减这两个符号哪一个在前面。

4.4.3 算符优先关系表的构造

首先需要定义下面两个概念:

FIRSTVT集和LASTVT集

两个集合均针对于非终结符而言:

FIRSTVT(A)={bA+b...A+Bb...,bVT,BVN}LASTVT(A)={aA+...aA+...aB,aVT,BVN}\operatorname{FIRSTVT}(A)=\{b|A\stackrel{+}\Rightarrow b...||A\stackrel{+}\Rightarrow Bb...,b\in V_T,B\in V_N\}\\ \operatorname{LASTVT}(A)=\{a|A\stackrel{+}\Rightarrow ...a||A\stackrel{+}\Rightarrow ...aB,a\in V_T,B\in V_N\}

可以以字面意思来理解这两个集合的含义:FIRSTVT\operatorname{FIRSTVT}集合指的就是一个非终结符可能推导出来的所有符号串中的第一个终结符构成的集合。而LASTVT\operatorname{LASTVT}集合指的就是一个非终结符可能推导出来的所有符号串中的最后一个终结符构成的集合。

根据上面的两个集合的定义以及优先级运算符的定义来构造算符优先关系表:

  • 找到所有形如P...ab...P\rightarrow ...ab...P...aQb...P\rightarrow ...aQb...的规则,然后为其赋值为相等运算优先级。
  • 找到终结符在左边,非终结符在右边的符号对aBaB,有aFIRSTVT(B)a\lessdot\operatorname{FIRSTVT}(B)
  • 找到终结符在右边,非终结符在左边的符号对AbAb,有LASTVT(A)b\operatorname{LASTVT}(A)\gtrdot b(不能写bLASTVT(A)b\gtrdot \operatorname{LASTVT}(A)!!!)
  • 定义$ \eqcirc $以及$ FIRSTVT(S)\lessdot\operatorname{FIRSTVT}(S)LASTVT(S)\operatorname{LASTVT}(S)\gtrdot $,其中SS为开始符号。

4.5 LR分析法

这是一类自下而上进行规范规约的语法分析方法。L指from left to right从左到右,而R指rightmost构造最右推导的逆过程。

LR分析法的关键在于如何确定分析栈顶符号串是否能够形成句柄。根据事先定义好的文法,可以通过输入的前面一部分来预测输入的后面一部分,并在读入后续输入时进行进一步的操作。

4.5.1 工作原理和流程

LR分析法需要一个分析栈和分析表。分析栈用于存放分析过程中的历史和展望信息,而分析表则规定了状态机的转换方式。分析表由分析动作表(ACTION)和转换表(GOTO)组成,均为二维数组。其中状态转换表规定了某个状态遇到文法符号X时应该转移到的下一个状态,分析动作表规定了某个状态遇到输入符号时应该执行的动作。需要注意的是这里的文法符号和输入符号的区别。文法符号表示的是所有符号,而输入符号只可能是终结符。

ACTION表可能会有4种动作执行:

  • 移进:将最近一个即将输入的符号移入栈中,并转移到相应的步骤
  • 规约:若栈中符号串形成句柄α\alpha且有文法规则AαA\rightarrow\alpha,则需要从栈中代表α\alpha的所有文法符号和状态全部移除,并将A移入栈中,同时进行状态转移。
  • 接受:分析成功,此时分析栈中只应有文法开始符号和输入结束符号“$”。
  • 报错:输入串中含有错误,遇到的不应该输入的符号。

下面所介绍到的4种LR分析法都是在计算机层面对上述步骤进行规范化和程序化处理。

4.5.2 LR(0)分析法

需要理解这里0的含义:它表示需要向前查看输入符号的数量,即在LR(0)分析法中不需要向前提前查看输入符号,后面的LR(1)分析法中需要向前看一个输入符号。

活前缀

活前缀指的是不含句柄右边任何符号的前缀,这是所有LR分析法中至关重要的一个概念。一般使用下面的形式进行表示:

  • 如果一个活前缀含有一个句柄中的所有符号,此时该活前缀就是该语句的最长的活前缀,表示为AαA\rightarrow\alpha\cdot这里的“·”可以理解为此时分析到了什么地方。如果已经识别出句柄的所有成分,那么必然有一条规则AαA\rightarrow\alpha中的右部符号串是该活前缀的后缀,此时该右部符号串应该全部入栈,故分析位置就在该右部符号串的后面,即在α\alpha后面加上一个点。
  • 如果一个活前缀含有一个句柄的部分符号,那么必然存在一条规则Aα1α2A\rightarrow\alpha_1\alpha_2,此时有α1\alpha_1已经全部入栈而α2\alpha_2还未入栈,因此表示为Aα1α2A\rightarrow\alpha_1\cdot\alpha_2
  • 如果一个活前缀不含有句柄的任何符号,那么此时期望从剩余的符号串中获取到规则AαA\rightarrow\alpha中右部所有符号,因此表示为AαA\rightarrow\cdot\alpha
  • 特别地,对于空规则AεA\rightarrow\varepsilon,其对应的带点的规则只有AA\rightarrow\cdot

LR(0)项目

这个名字起的很让人迷惑,可能是直译过来的,很别扭。它表示文法G右部标有点的规则称为G的一个LR(0)项目。注意:这个点只是用来表示规则中的符号串有多少已经被识别。

一个LR(0)项目指明了对文法规范句型活前缀的不同识别状态,文法G的全部LR(0)项目是构造识别文法所有规范句型活前缀的DFA的基础。

LR(0)项目分类

根据点的位置和点后面是终结符还是非终结符,可以将项目分为4类:

  • 规约项目:即应该根据对应规则进行规约的项目,形如Aα,α(VNVT)A\rightarrow\alpha\cdot, \alpha\in(V_N\cup V_T),点在最右端,说明已经完全读取了一个规则的右部符号串,可以将其进行规约产生左部非终结符。
  • 移进项目:即下一步应该继续移进符号的项目,形如Aαaβ;α,β(VNVT),aVTA\rightarrow\alpha\cdot a\beta; \alpha,\beta\in(V_N\cup V_T), a\in V_T,说明已经识别了一个规则中的一部分,而且规则中的下一个符号是一个终结符,所以期待移进一个终结符进行继续匹配。
  • 待约项目:即期待读入其他符号规约成B好进行A的规约,形如AαBβA\rightarrow \alpha\cdot B\beta,只有读取后面的符号并规约成B才有可能使A规约成功。
  • 接收项目:整个符号串识别完成,形如SSS'\rightarrow S,其中SS'为文法开始符号。

闭包函数CLOSURE

CLOSURE闭包函数是构造DFA的关键函数,需要理解。
首先需要拓广原文法,实际上就是添加一个新的开始符号SS'
闭包函数的定义如下:
II为拓广文法GG'的一个LR(0)项目集,定义和构造II的闭包CLOSURE(I)\operatorname{CLOSURE}(I)如下:

  1. II中的任何一个项目都属于CLOSURE(I)\operatorname{CLOSURE}(I)
  2. AαBβA\rightarrow \alpha\cdot B\beta属于CLOSURE(I)\operatorname{CLOSURE}(I),那么每一个形如BrB\rightarrow \cdot r的项目也属于CLOSURE(I)\operatorname{CLOSURE}(I)
  3. 重复第2步直到CLOSURE(I)\operatorname{CLOSURE}(I)不再增加为止。

理解:与第3章DFA的闭包类似,CLOSURE(I)\operatorname{CLOSURE}(I)实际上定义了一个在文法分析过程中的某一步或某几步不需要进行其他操作就可以到达的步骤。如对于文法SS;SAS'\rightarrow S; S\rightarrow A,那么在初始状态,即没有任何符号移进时,表示的状态应该是SSS'\rightarrow\cdot S,但由于SAS\rightarrow A,因此这个状态就等价于SAS\rightarrow \cdot A,此时,“等待移进以期待规约SS”就等价于“等待移进以期待规约AA”。CLOSURE函数能够让我们求出规范句型活前缀的DFA的一个结点内都有哪些项目。

例:文法GG如下,求CLOSURE({AaAb})\operatorname{CLOSURE}(\{A\rightarrow a\cdot Ab\})
0.SS1.SA2.SB3.AaAb4.Ac5.BaBb6.Bd0. S'\rightarrow S\\1. S\rightarrow A\\2.S\rightarrow B\\ 3.A\rightarrow aAb\\4.A\rightarrow c\\5.B\rightarrow aBb\\ 6.B\rightarrow d
解析:根据规则1可得(AaAb)CLOSURE({AaAb})(A\rightarrow a\cdot Ab) \in\operatorname{CLOSURE}(\{A\rightarrow a\cdot Ab\})
根据规则2可得(Ac)CLOSURE({AaAb})(A\rightarrow\cdot c) \in\operatorname{CLOSURE}(\{A\rightarrow a\cdot Ab\})
CLOSURE({AaAb})={AaAb,Ac}\operatorname{CLOSURE}(\{A\rightarrow a\cdot Ab\})=\{A\rightarrow a\cdot Ab, A\rightarrow\cdot c\}

状态转移函数GO

这个函数定义了DFA中不同结点之间的状态转移关系,与CLOSURE函数进行结合使用就可以求出一个文法的状态转移DFA。
定义:
GO(I,X)=CLOSURE(J)J={AaXβAαXβI}\operatorname{GO}(I,X)=\operatorname{CLOSURE(J)}\\J=\{A\rightarrow aX\cdot \beta|A\rightarrow\alpha\cdot X\beta\in I\}
这里的XX是任意一个文法符号。

理解:这里实际上就是指移进了一个符号或成功规约了一个非终结符,这样点的位置就能够向后移动一位。根据CLOSURE函数和GO函数,我们可以构造出一个文法的LR(0)分析表。

LR(0)分析表构造

  1. 将DFA中的每一个结点均赋给一个序号,分析表的行数等于结点的数量,代表有多少种不同的状态。
  2. 若从状态IkI_k到状态IjI_j有转换函数GO(Ik,x)=Ij\operatorname{GO}(I_k, x)=I_jxx为终结符),那么ACTION[k,x]=Sj\operatorname{ACTION}[k, x]=S_j。这里的SjS_j就表示状态转换,ACTION[k,x]\operatorname{ACTION}[k, x]表示行索引为kk列索引为xx的项,注意ACTION\operatorname{ACTION}表的行是状态编号,列是终结符,在表中查到之后就应该更换聚焦的行。
  3. 若从状态IkI_k到状态IjI_j有转换函数GO(Ik,A)=Ij\operatorname{GO}(I_k, A)=I_jAA为终结符),则在GOTO\operatorname{GOTO}表中行索引为kk列索引为AA的项设为jj,表示成功规约了一个项目,规约之后应该转移到哪一个状态。
  4. 若某状态中包含规约项目,找到该项目对应的文法编号ii,并在该状态对应的ACTION\operatorname{ACTION}表的行的所有项都添加rir_i,表示到达该状态后期待进行规约。注意:到达该状态后应立即进行规约,将文法右部符号串及其对应的状态号全部出栈,然后将左部非终结符入栈,看此时状态栈的顶端的状态编号,再查询GOTO\operatorname{GOTO}表顶端状态编号对应行,非终结符对应列的项跳转到对应状态,然后将该状态压栈。
  5. 如果接收项目包含于某状态,则该状态对应行中列索引为终结标志“$”的值为“acc”,表示接受项目。

4.5.3 SLR(1)分析法

LR(0)分析法对文法的要求较为严格,分析过程中很容易出现移进-规约冲突和规约-规约冲突。产生冲突的项目集为:

Ik={XδbB,Aα,Br}I_k=\{X\rightarrow \delta\cdot bB,A\rightarrow\alpha\cdot,B\rightarrow r\cdot\}

其中第1/2和第1/3个项目构成移进-规约冲突,在不知道下一个符号的情况下,分析器不知道这里到底应该等待下一个符号移入还是直接规约产生A或B。第2/3个项目构成规约-规约冲突,在需要规约的时候分析器不知道到底应该规约成A还是B。

因此SLR(1)分析法通过FOLLOW集,等同于是向前看了一个符号,以尝试消除冲突。

如对于上面的项目集,求出FOLLOW(A)和FOLLOW(B),在这两个集合无交集且均不包含下一个移进的符号b时,可以通过下列判断消除冲突:

  • 若下个符号a=ba=b,则移进,参考第1个项目。
  • 若下个符号aFOLLOW(A)a\in\operatorname{FOLLOW(A)},则按照第2个项目规约。
  • 若下个符号aFOLLOW(B)a\in\operatorname{FOLLOW(B)},则按照第3个项目规约。
  • 此外报错。

4.5.4 LR(1)分析法

上面的SLR(0)分析法在当FOLLOW(A)和FOLLOW(B)不满足条件时,一样无法分析。如有项目集{XaBb,YaBbC}\{X\rightarrow aB\cdot b, Y\rightarrow aB\cdot bC\},如果仅使用第一个项目进行规约很可能会产生无效规约情况,导致后面无法匹配。因此引入LR(1)分析法。

LR(1)项目为二元组[Aαβ,a][A\rightarrow \alpha\cdot\beta,a],其中a为终结符,称为搜索符。βε\beta\ne\varepsilon时搜索符无意义,否则明确指出当[Aα,a][A\rightarrow \alpha\cdot,a]为栈顶的LR(1)项目时,仅在输入符号为a时才以此项目进行规约。

LR(1)的CLOSURE和GO函数

求项目集I的CLOSURE函数步骤:

  1. II中的任何一个项目都属于CLOSURE(I)\operatorname{CLOSURE}(I)
  2. [AαBβ,a][A\rightarrow \alpha\cdot B\beta, a]属于CLOSURE(I)\operatorname{CLOSURE}(I),那么每一个形如[Br,b][B\rightarrow \cdot r, b]的项目也属于CLOSURE(I)\operatorname{CLOSURE}(I),其中bFIRST(βa)b\in\operatorname{FIRST}(\beta a)
  3. 重复第2步直到CLOSURE(I)\operatorname{CLOSURE}(I)不再增加为止。

求GO函数:
GO(I,X)=CLOSURE(J)J={[AaXβ,a][AαXβ,a]I}\operatorname{GO}(I,X)=\operatorname{CLOSURE(J)}\\J=\{[A\rightarrow aX\cdot \beta, a]|[A\rightarrow\alpha\cdot X\beta,a]\in I\}

4.5.5 LALR(1)分析法

LALR(1)分析法相比LR(1)分析法只修改了一个内容:合并。

同心LR(1)项目集

两个LR(1)项目集除搜索符不同外其他部分相同,则称两个项目集同心。

LALR(1)将所有同心的LR(1)项目集进行合并,减少了DFA的状态数。合并后,搜索符也进行合并,以’/'分隔。

不存在冲突的以上述方式生成的项目集组为基础的分析方法称为LALR(1)分析法。

Chapter 3 词法分析与有穷自动机

3.1 词法分析程序的功能

词法分析程序用于将输入的字符转化为单词符号,之后与语法分析器进行互动,逐词输入到语法分析器中用于语法分析器进行语法分析。

3.2 单词符号即输出单词的形式

3.2.1 语言的单词符号

语言的单词符号指语言中具有独立意义的最小语法单位,即单词符号是程序语言的基本语法单位。程序语言的单词符号一般可以分为下面5种:

  • 关键字:表示语义的词语,如if、while等
  • 标识符:各种名字,如变量名、常量名等
  • 常数:整数、小数、布尔型等
  • 运算符:加减乘除
  • 界符:分号、括号等

3.2.2 词法分析程序输出单词的形式

通常表示为二元式:(单词种别,单词自身的值)。单词种别指单词的种类,通常给一个单词对应一个整数码,目的是最大限度地把各个单词区别开。单词自身的值是编译中其他阶段需要的信息,如果一个种别只对应一个单词符号,那么这个单词的种类编码就能够完全代表其自身的值,如if关键字只需要一个种别码就可以知道读取到了if,而不需要额外指定值。如果一个种别对应多个单词符号,则需要给出单词自身的值。如定义整数的种类对应的编码为10,则词法分析程序遇到整数5时则会记录一个二元式:(10, 5)。

3.3 语言单词符号的两种定义方式

3.3.1 正规式和正规集

正规式实际上类似于多种编程语言中都在使用的正则表达式。其正式定义采用递归定义的形式:设有字母表Σ=a1,a2,,anΣ={a1, a2,…, an} ,在字母表ΣΣ上的正规式和它所表示的正规集用规则1-4定义:

    1. ΦΦΣΣ上的正规式,它所表示的正规集是ΦΦ, 即空集{}\{\}
    1. εεΣΣ上的正规式,正规集:空符号串集合,{ε}\{ε\}
    1. aia_iΣΣ上的一个正规式,它所表示的正规集是由单个符号aia_i所组成,即{ai}\{a_i\}
    1. 如果e1e_1e2e_2ΣΣ上的正规式,它们所表示的正规集为L(e1)L(e_1)L(e2)L(e_2) ,则有:
    • (1) e1e2e_1|e_2ΣΣ上的一个正规式,它所表示的正规集为:L(e1e2)=L(e1)L(e2)L(e_1|e_2)=L(e_1)∪L(e_2)
    • (2) e1e2e_1e_2ΣΣ上的一个正规式, 它所表示的正规集为:L(e1e2)=L(e1)L(e2)L(e_1e_2)=L(e_1)L(e_2)
    • (3) (e1)(e_1)^*ΣΣ上的一个正规式, 它所表示的正规集为:L((e1))=(L(e1))L((e_1)^*)=(L(e_1))^*

注意:虽然(ab)(a|b)^*是正规式,对应正规集为{a,b}\{a,b\}^*,但对于{a,b}\{a,b\}^*的任一子集不能认为是一个正规集。这句话说的很容易让人误解,它指的是不是所有的子集都是正规集。如{anbnn1}\{a^nb^n|n\ge 1\}就不是一个正规集,因为它不能由正规文法进行表示。凡是不能使用正规文法表示的符号串集合都不能被称为正规集。

如果两个正规式描述的正规集相同,称这两个正规式等价,可以使用等号连接。

正规式的性质:令A , B 和 C 均为正规式,则

  • AB=BAA | B = B | A(交换律)
  • A(BC)=(AB)CA | ( B | C) = (A | B) | C(结合律)
  • A(BC)=(AB)CA(BC) = (AB)C(结合律)
  • A(BC)=ABACA(B | C) = AB | AC(分配律)
  • (AB)C=ACBC(A | B)C = AC | BC(分配律)
  • AεεA=AAε | εA = A
  • A=AAε=AA=(Aε)A^* = AA^* | ε = A | A^* = (A | ε )^*
  • (A)=A(A^* )^* = A^*

3.3.2 正规文法与正规式

正规文法与正规式之间可以进行转换。

正规文法转换为正规式

固定方法:

  • 将正规文法中的每一个非终结符表示成关于它的一个正规式方程,获得一个联立方程组。
  • 按照求解规则:
    • x=αxβx=\alpha x|\beta,则解为x=αβx=\alpha^*\beta
    • x=xαβx=x\alpha|\beta,则解为x=βαx=\beta\alpha^*

正规式转换为正规文法

固定方法:

  • VT=ΣV_T=\Sigma
  • 对任意正规式RR选择一个非终结符ZZ生成规则ZRZ\rightarrow R,并令S=ZS=Z
  • aabb都是正规式,对于形如AabA\rightarrow ab的规则转换为AaBA\rightarrow aBBbB\rightarrow b两个规则,其中新增非终结符BB
  • 在已经转换的文法中,将形如AabA\rightarrow a^*b的规则进一步转换为AaAbA\rightarrow aA|b
  • 不断使用规则3和4进行转换直到每一条规则最多含有一个非终结符为止。

3.4 正规式和有穷自动机

有穷自动机是具有离散输入和离散输出系统的一种抽象数学模型。

3.4.1 确定有穷自动机(DFA)

确定有穷自动机MM是一个五元组M=(Q,Σ,f,S,Z)M=(Q,\Sigma,f,S,Z),其中

  • QQ: 是有穷状态集合,每一个元素称为一个状态;
  • ΣΣ: 是有穷输入字母表,每个元素称为一个输入字符;
  • ff: 是一个从Q×ΣQ×ΣQQ的单值映射;f(qi,a)=qjf(q_i,a)=q_j表示当前状态为qiq_i,接受字符aa之后转换到状态qjq_jqjq_j称为qiq_i的一个后继状态。
  • SS: SQS∈Q ,是唯一的一个初态;
  • ZZ: ZQZ⊆Q ,是一个终态集。

一个DFA可以使用矩阵表示,行表示状态,列表示输入符号,矩阵元素表示f(q,a)f(q,a)的值。该矩阵称为状态转换矩阵或转换表。一个DFA也可以表示为一个状态转换图。对于Σ\Sigma^*中的任何符号串β\beta,如果存在一条从初态到终态的道路,且这条路上所有弧的标记连接称的符号串等于β\beta,则称β\beta被DFA MM所接受。DFA所识别的符号串的全体记为L(M)L(M),称为DFA MM所识别的语言

3.4.2 非确定有穷自动机(NFA)

NFA与DFA不同之处在ffSS
状态转移函数不是单值函数,而成为多值函数。
SQS\subset Q为非空初态集。即初态可能不止一个结点。

对于任意一个NFA MM都存在一个DFA MM'使得L(M)=L(M)L(M)=L(M'),因此可以将NFA转化为DFA便于计算机编程编写词法分析程序。

3.4.3 由正规表达式R构造NFA

方法如下:

  • 首先引进初始结点X和终止结点Y,将R表示为拓广转换图:
  • 分析RR的语法结构,使用如下规则对RR中每一个基本符号构造NFA:
    • R=R=\empty,则构造:
    • R=εR=\varepsilon,则构造:
    • R=a(aΣ)R=a(a\in\Sigma),则构造:
    • RR为复合正规式,则按照下面的规则进行分裂和加入新结点,直到每一条边上只有一个符号或εε为止:
    • 整个分裂过程中,所有新结点均采用不同名字,保留X和Y为全图唯一初态结点和终态结点。

3.4.4 NFA确定化为DFA的方法

ε\varepsilon闭包(ε-CLOSURE(I)\operatorname{\varepsilon-CLOSURE}(I)

II是NFA NN的一个状态子集,ε-CLOSURE(I)\operatorname{\varepsilon-CLOSURE}(I)定义如下:

  • sIs\in I,则sε-CLOSURE(I)s\in\operatorname{\varepsilon-CLOSURE}(I)
  • sIs\in I,则从s出发经过任意条ε\varepsilon弧能够到达的任何状态ss'。均属于ε-CLOSURE(I)\operatorname{\varepsilon-CLOSURE}(I)

II中所有元素出发经过ε\varepsilon道路能够到达的NFA中所有状态组成的集合。

NFA NN转换为DFA MM算法

  • 置DFA MM中的状态集QQ'ZZ'为空集。
  • 给出MM的初态S=ε-CLOSURE(S)S'=\operatorname{\varepsilon-CLOSURE}(|S|),并将SS'置为未标记状态后加入到QQ'中(未标记状态表示新状态)
  • 如果QQ'中存在未标记状态T={q1,...,qn},qiQT=\{q_1,...,q_n\},q_i\in Q,则进行如下变换求f(T,a)f'(T,a)的后继状态:
    • 对于每一个aΣa\in\Sigma,置J=f({q1,...,qn},a)=f(q1,a)...f(qn,a),U=ε-CLOSURE(J)J=f(\{q_1,...,q_n\},a)=f(q_1,a)\cup...\cup f(q_n,a),U=\operatorname{\varepsilon-CLOSURE}(J)。如果UU不在QQ'中,将UU置为无标记状态添加到QQ'中,并将状态转移f(T,a)=Uf'(T,a)=U添加到MM中,如果UU中至少含有1个元素是NN的终态,则将UU设置为MM的终态添加到ZZ'中。
    • TT置标记(表示TT不再是新加入状态)
  • 重复步骤3直到QQ'中不再含有未标记的状态为止。
  • 重新命名QQ'中状态,获得等价DFA MM

说人话:就是根据闭包确定原NFA的结点集合应该如何划分(注意这些集合可能会有重叠),首先求起始点的闭包,然后看闭包中的元素接收某一个符号aa之后能到哪些结点,对这些结点再求一个闭包构成DFA的另外一个结点,以此类推可以找到所有DFA的结点与NFA中结点的对应关系。

3.4.5 DFA的化简

DFA的化简的目标是找到一个状态数量更少的等价的DFA。化简后DFA中没有多余状态,状态集中没有两个状态是相互等价的。

有穷自动机的多余状态指无论如何也无法到达的状态。

等价状态指在有穷自动机中两个状态s,ts,t对于任意的相同字符输入都能到达等价的状态。且s,ts,t一定均为终态或均为非终态。即{Q,Σ,f,S0,F}\{Q,\Sigma,f,S_0,F\}αΣ,f(s,α)Ff(t,α)F\forall\alpha\in\Sigma^*,f(s,\alpha)\in F\Leftrightarrow f(t,\alpha)\in F

化简算法

  • 将DFA MM的状态集QQ分为两个子集:终态集FF和非终态集¬F\lnot F,形成原始分划Π\Pi
  • Π\Pi建立新分划Πnew\Pi_{new},对Π\Pi的每个状态子集GG进行如下变换:
    • GG分划为新的子集,使得GG的两个状态s,ts,t属于同一子集,当且仅当对任何输入符号aa,状态s,ts,t转换到的状态都属于Π\Pi的同一个子集。
    • GG分划出的所有新子集替换GG,形成新的分划Πnew\Pi_{new}
  • 如果Πnew=Π\Pi_{new}=\Pi,则执行下一步,否则令Π=Πnew\Pi=\Pi_{new}重复上一步
  • 分划结束后对分划中每一个子集选出一个状态为代表,删去其他所有等价状态,并将指向其他状态的箭头改为指向这个代表状态。算法结束。

说人话:首先把终态集和非终态集分开,然后一个集合一个集合验证,如果这个集合中所有状态对于一个相同的输入都能到达相同的集合,那么这个集合中所有元素都等价;如果不能,那么看哪些到达这个集合,哪些到达那个集合,按照到达的集合来对这个集合进行划分。重复上面的操作直到没有办法划分为止。最终每一个划分只留一个状态,调整一下箭头走向。算法结束。

3.4.6 有穷自动机到正规式的转换

也就是从正规式到有穷自动机转换的逆过程,是一个去结点合并表达式的过程。

3.5 正规文法与有穷自动机

3.5.1 右线性正规文法到有穷自动机的转换方法

对于右线性正规文法G=(VN,VT,P,S)G=(V_N,V_T,P,S),对应的有穷自动机M=(Q,Σ,f,q0,Z)M=(Q,\Sigma,f,q_0,Z)。注意右线性正规文法指右式中非终结符在最右边。

  • VNV_N中每一个非终结符视为MM中一个状态,并增加一个状态DD使得DVND\notin V_N,令Q=VN{D},Z={D},Σ=VT,q0=SQ=V_N\cup\{D\},Z=\{D\},\Sigma=V_T,q_0=S
  • 对于GG中每一个形如AaBA\rightarrow aB的产生式(A,BVN,aVT{ε})(A,B\in V_N,a\in V_T\cup\{\varepsilon\}),令f(A,a)=Bf(A,a)=B
  • 对于GG中每一个形如AaA\rightarrow a的产生式(AVN,aVT)(A\in V_N,a\in V_T),令f(A,a)=Df(A,a)=D
  • 对于GG中每一个形如AεA\rightarrow\varepsilon的产生式(AVN)(A\in V_N),令AA为接受状态或f(A,ε)=Df(A,\varepsilon)=D

按照上述方法可以构造一个右线性正规文法的NFA,能够识别这个文法的语言。

说人话:将文法中所有非终结符看成一个状态,如果规则右部有非终结符,就从规则左部画箭头标右部的终结符指向右部的非终结符。如果规则右部没有非终结符,就从规则左部画箭头标右部的终结符指向终结状态,这个终结状态不在非终结符中,是另外创建的唯一一个状态。如果规则右部是空字符,可以让规则左部非终结符变成终态或指一个空字符的箭头到终态。

3.5.2 左线性正规文法到有穷自动机的转换方法

对于左线性正规文法G=(VN,VT,P,S)G=(V_N,V_T,P,S),对应的有穷自动机M=(Q,Σ,f,q0,Z)M=(Q,\Sigma,f,q_0,Z)。注意左线性正规文法指右式中非终结符在最左边。

  • VNV_N中每一个非终结符视为MM中一个状态,并增加一个初始状态q0q_0使得q0VNq_0\notin V_N,令Q=VN{q0},Z={S},Σ=VTQ=V_N\cup\{q_0\},Z=\{S\},\Sigma=V_T
  • 对于GG中每一个形如ABaA\rightarrow Ba的产生式(A,BVN,aVT{ε})(A,B\in V_N,a\in V_T\cup\{\varepsilon\}),令f(B,a)=Af(B,a)=A
  • 对于GG中每一个形如AaA\rightarrow a的产生式(AVN,aVT)(A\in V_N,a\in V_T),令f(q0,a)=Af(q_0,a)=A

说人话:将文法中所有非终结符看成一个状态,文法开始符号是终态。如果规则右部有非终结符,就从规则右部非终结符画箭头标右部的终结符指向左部。如果规则右部没有非终结符,就从初始状态画箭头标右部的终结符指向规则左部,这个初始状态不在非终结符中,是另外创建的唯一一个状态。箭头指向和右线性文法到有穷自动机是反着来的。

3.5.3 有穷自动机到正规文法的转换方法

对于有穷自动机M=(Q,Σ,f,q0,Z)M=(Q,\Sigma,f,q_0,Z),构造正规文法G=(VN,VT,P,S)G=(V_N,V_T,P,S)的方式:

  • VN=Q,VT=Σ,S=q0V_N=Q,V_T=\Sigma,S=q_0
  • f(A,a)=Bf(A,a)=BBZB\notin Z时,将规则AaBA\rightarrow aB加入到PP中。
  • f(A,a)=Bf(A,a)=BBZB\in Z时,将规则(AaBaA\rightarrow aB|aAaBA\rightarrow aB),BεB\rightarrow\varepsilon加入到PP中。
  • 若文法的开始符号SS是一个终态,则将产生式SεS\rightarrow\varepsilon加入到PP中。

上述只考虑了右线性正规文法,因为构造为右线性正规文法符合我们的习惯,容易理解不易出错。

说人话:右线性正规文法到有穷自动机反着来。

3.6 词法分析程序的编写方法

这部分与实验有关,极其重要!需要掌握Flex词法分析程序的原理。

关键字处理

将所有用户不得用作标识符的关键字定义为一类特殊的标识符来处理,并将它们事先安排在一个表格之中,称为关键字表。利用状态转换图识别一个标识符时需要查询关键字表中是否有这个标识符。

空白符

关键字、标识符与常数之间如果没有确定运算符作为界符分隔,则必须需要至少一个空白符作为填充。此时这里的空白符就有了意义。

所需函数与变量

  • ch:字符变量保存当前读入的源程序字符
  • token:字符数组,保存当前单词符号的字符串
  • getch():读一个字符,函数将缓冲区中读入源程序的下一个字符放在ch中,并更新读指针
  • getbc():读空白符,每一次调用检查ch中字符是否是空白符,如果是则反复调用getbc()直到遇到一个非空白字符为止。
  • concat():拼接函数,将当前读取的字符与token相连接。
  • letter(ch):检查ch中字符是否为字母
  • digit(ch):检查ch中字符是否为数字
  • reserve():返回当前保存的token的种别编码,标识符的种别码为10。该函数会查询关键字表。
  • retract():读字符指针回退一个字符
  • return():收集并携带必要信息返回调用程序,返回语法分析程序
  • dtb()+进制转换函数:将token中的字符串转换为数字并返回

实验对接——flex文件(*.l)格式与示例

1
2
3
4
5
6
7
8
9
/*  预定义段  */  
%{
/* 在此添加头文件包含 */
%}
/* 在此添加正规式,定义正规式 */
%%
/* 规则段 */
%%
/* 用户子程序段,定义main函数等函数 */

示例:(编译原理实验lab-1,task-2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
%{
//Add a head file here
%}
DIGIT [0-9] // 使用DIGIT定义数字,避免下面重复书写
ID [a-z][a-z0-9]* // 标识符
%%
{DIGIT}+ {printf( "An integer: %s (%d)\n", yytext,atoi( yytext ) );}
{DIGIT}+"."{DIGIT}? {printf( "A float: %s (%g)\n", yytext,atof( yytext ) );
}
if|then|begin|end|procedure|function {printf( "A keyword: %s\n", yytext );}
{ID} printf( "An identifier: %s\n", yytext );
"+"|"-"|"*"|"/" printf( "An operator: %s\n", yytext );
"{"[^}\n]*"}" /* eat up one-line comments */
[ \t\n]+ /* eat up whitespace */
. printf( "Unrecognized character: %s\n", yytext );
%%
int main( argc, argv )
int argc;
char **argv;
{
++argv, --argc; /* skip over program name */
if ( argc > 0 )
yyin = fopen( argv[0], "r" );
else
yyin = stdin;
yylex();
}

注意:

  • 规则段中越靠上的规则优先级越大,如上面例子中一众关键字就放在ID的上面一行,是因为它们需要首先进行处理,能够决定整个程序的语法结构。
  • 规则定义部分上方的正规式定义部分互相不存在优先级,也与定义的先后无关。但正规式的匹配采用贪心的策略,即尽量多地进行匹配,如果两个正规式的匹配长度相同,则选择定义靠前的那一个

下面是更加全面的例子:(编译原理试验lab-1,task-4)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
/* PL词法分析器 */
/* 功能:能够识别出PL支持的所有单词符号并给出种别值 */
/* 说明:在下面的begin和end之间添加代码,已经实现了标识符和整常量的识别,你需要完成剩下的部分,加油吧! */
/* 提示:因为是顺序匹配,即从上至下依次匹配规则,所以需要合理安排顺序~ */
%{
#include <stdio.h>
%} /*** begin ****/
INTCON [\-]?[1-9][0-9]*|0
IDENT [A-Za-z][A-Za-z0-9]*
CHARCON '([^\']|\n)*'
PLUS \+
MINUS -
TIMES \*
DIVSYM \/
EQL =
NEQ <>
LSS <
LEQ <=
GTR >
GEQ >=
OFSYM of
ARRAYSYM array
PROGRAMSYM program
MODSYM mod
ANDSYM and
ORSYM or
NOTSYM not
LBRACK \[
RBRACK \]
LPAREN \(
RPAREN \)
COMMA ,
SEMICOLON ;
PERIOD \.
BECOME :=
COLON :
BEGINSYM begin
ENDSYM end
IFSYM if
THENSYM then
ELSESYM else
WHILESYM while
DOSYM do
CALLSYM call
CONSTSYM const
TYPESYM type
VARSYM var
PROCSYM procedure
%%
{OFSYM} {printf("%s: OFSYM\n", yytext);}
{ARRAYSYM} {printf("%s: ARRAYSYM\n", yytext);}
{PROGRAMSYM} {printf("%s: PROGRAMSYM\n", yytext);}
{MODSYM} {printf("%s: MODSYM\n", yytext);}
{ANDSYM} {printf("%s: ANDSYM\n", yytext);}
{ORSYM} {printf("%s: ORSYM\n", yytext);}
{NOTSYM} {printf("%s: NOTSYM\n", yytext);}
{BEGINSYM} {printf("%s: BEGINSYM\n", yytext);}
{ENDSYM} {printf("%s: ENDSYM\n", yytext);}
{IFSYM} {printf("%s: IFSYM\n", yytext);}
{THENSYM} {printf("%s: THENSYM\n", yytext);}
{ELSESYM} {printf("%s: ELSESYM\n", yytext);}
{WHILESYM} {printf("%s: WHILESYM\n", yytext);}
{DOSYM} {printf("%s: DOSYM\n", yytext);}
{CALLSYM} {printf("%s: CALLSYM\n", yytext);}
{CONSTSYM} {printf("%s: CONSTSYM\n", yytext);}
{TYPESYM} {printf("%s: TYPESYM\n", yytext);}
{VARSYM} {printf("%s: VARSYM\n", yytext);}
{PROCSYM} {printf("%s: PROCSYM\n", yytext);}

{INTCON} {printf("%s: INTCON\n", yytext);}
{CHARCON} {printf("%s: CHARCON\n", yytext);}
{IDENT} {printf("%s: IDENT\n", yytext);}
{PLUS} {printf("%s: PLUS\n", yytext);}
{MINUS} {printf("%s: MINUS\n", yytext);}
{TIMES} {printf("%s: TIMES\n", yytext);}
{DIVSYM} {printf("%s: DIVSYM\n", yytext);}
{EQL} {printf("%s: EQL\n", yytext);}
{NEQ} {printf("%s: NEQ\n", yytext);}
{LSS} {printf("%s: LSS\n", yytext);}
{LEQ} {printf("%s: LEQ\n", yytext);}
{GTR} {printf("%s: GTR\n", yytext);}
{GEQ} {printf("%s: GEQ\n", yytext);}
{LBRACK} {printf("%s: LBRACK\n", yytext);}
{RBRACK} {printf("%s: RBRACK\n", yytext);}
{LPAREN} {printf("%s: LPAREN\n", yytext);}
{RPAREN} {printf("%s: RPAREN\n", yytext);}
{COMMA} {printf("%s: COMMA\n", yytext);}
{SEMICOLON} {printf("%s: SEMICOLON\n", yytext);}
{PERIOD} {printf("%s: PERIOD\n", yytext);}
{BECOME} {printf("%s: BECOME\n", yytext);}
{COLON} {printf("%s: COLON\n", yytext);}
[ \n\t]+ {}
. {printf("%s: ERROR\n", yytext);}
%% /*** end ***/
int yywrap() { return 1; }
int main(int argc, char **argv)
{
if (argc > 1) {
if (!(yyin = fopen(argv[1], "r"))) {
perror(argv[1]);
return 1;
}
}
while (yylex());
return 0;
}

在上面的例子中,如果识别到一个字符串while,从正规式的定义可以发现,这个字符串可以与两个正规式匹配,分别是代表标识符的CHARCON和代表while关键字的WHILESYM。此时flex分析器会根据这两个匹配结果再规则中逐一查找,首先找到的是WHILESYM,因此这个字符串被解释为关键字while而不是标识符while。

实验对接——bison文件(*.y)格式与示例

1
2
3
4
5
6
7
8
%{  
Prologue 定义段
%}
Bison declarations Bison声明区
%%
Grammar rules 规则段
%%
Epilogue 用户子程序段

示例:(编译原理试验lab-1,task-5)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
 %{
#include <ctype.h>
#include <stdio.h>
#include <math.h>
int yylex (void);
void yyerror (char const *);
%}

%token NUM
%define api.value.type {double}
%left '+' '-'
%left '*' '/'
%right 'n'
%right '^'

%%
/* Grammar rules and actions follow. */
input:
%empty
| input line
;

line:
'\n'
| exp '\n' { printf ("%.10g\n", $1); }
;

exp:
NUM {$$ = $1;}
|exp exp '+' {$$=$1+$2;}
|exp exp '-' {$$=$1-$2;}
|exp exp '*' {$$=$1*$2;}
|exp exp '/' {$$=$1/$2;}
|exp exp '^' {$$=pow($1, $2);}
|exp 'n' {$$=-$1;}
;

%%

/* The lexical analyzer returns a double floating point
number on the stack and the token NUM, or the numeric code
of the character read if not a number. It skips all blanks
and tabs, and returns 0 for end-of-input. */

int yylex (void)
{
int c;
/* Skip white space. */
while ((c = getchar ()) == ' ' || c == '\t')
continue;

/* Process numbers. */
if (c == '.' || isdigit (c))
{
ungetc (c, stdin);
scanf ("%lf", &yylval);
return NUM;
}

/* Return end-of-input. */
if (c == EOF)
return 0;
if (c == '!')
return 0;
/* Return a single char. */
return c;
}

int main (int argc, char** argv)
{
yyparse();
return 0;
}


/* Called by yyparse on error. */
void yyerror (char const *s)
{
fprintf (stderr, "%s\n", s);
}

各个段的用途:

  • 定义段:包含C和C++头文件、全局文件、全局变量、类型定义、词法分析器yylex和错误打印函数声明。
  • 声明区:定义之后需要用到的终结符、非终结符和操作符优先级,以%开头表示类型属性:
    • %token NUM /*定义终结符NUM*/
    • %nonassoc ‘<’ /*表示该终结符无结合性,不能出现a<b<c*/
    • %left ‘+’ ‘-’ /*左结合,a+b+c=(a+b)+c*/
    • %left ‘*’ ‘/’ /*规则4在3下面,操作符比其上的优先级高*/
    • %right NEG /*NEG表示非*/
    • %right ‘^’ /*幂运算右结合;最下面,优先级最高*/
  • 语法规则段:定义了文法的规则。如上面的实例所示,一条规则的格式如下:
    • 左式:左式名:
    • 右式:候选式1 {语义} | 候选式2 {语义} | ...;
    • 其中语义内$$表示这条规则对应的语义输出结果,$n(n为正整数)表示这条规则右式第几个符号的值。如上例中有一条规则是exp:exp exp + {$$=$1+$2}表示这条规则表示的语义是第一个符号和第二个符号(均为exp)的和。
  • 用户代码段:定义前面定义段中声明的函数,或者也可以定义其他代码。

下面是更加全面的例子:(编译原理实验lab-1,task-7)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
%{
/*
Filename:lab107.y
Author:
Date:
Makefile:
______________
scanner:lab107.l lab107.y
bison -v -d lab107.y
flex lab107.l
gcc -o scanner 406.tab.c lex.yy.c -lm -lfl
.PHONY:clean
clean:
rm scanner lab107.tab.c lex.yy.c lab107.tab.h
_______________
Description:

*/
// Notice: '-' using as -5+2=-3 ;or 5-2, need something special. By LM. 2021 using
// with %precedence NEG used as the highest token, higher than '^', then we can get -2^2=4; without %prec NEG in the rule, SUB is lower than ^, then -2^2=-4
#include <stdio.h>
#include <math.h>
extern int yylineno;
int yylex();
void yyerror(const char *s);
%}

%define api.value.type {double}
%token NUM
%token EOL
%token ADD
%token SUB
%token MUL
%token DIV
%token EXPO
%token LP
%token RP

%%
calclist:
%empty
|calclist exp EOL {printf("=%.10g\n",$2);}

exp:term {$$=$1;}
|exp ADD term {$$=$1+$3;}
|exp SUB term {$$=$1-$3;}
|error {}
;

term:term2 {$$=$1;}
|term MUL term2 {$$=$1*$3;}
|term DIV term2 {$$=$1/$3;}
;

term2: term3 {$$=$1;}
|term3 EXPO term2 {$$=pow($1, $3);}

term3: NUM {$$=$1;}
|SUB term3 {$$=-1*$2;}
%%

int main(int args,char **argv){
yyparse();
return 0;
}
void yyerror(char const *s){
fprintf(stderr,"MyError:%s yylineno:%d\n",s,yylineno);
}

注意上面例子中yyerror函数内的yylineno指当前所在行数。

题型总结

1. 正规文法与正规式的相互转化

例-1:(课本课后习题3-5)给出下列文法对应的正规式:
SaAS\rightarrow aA
AbAaBbA\rightarrow bA|aB|b
BaAB\rightarrow aA

解:
正规文法到正规式的转换,最为便捷的方法就是:先代换再套公式。
将上述文法中第二条规则的BB带换掉:
AbAaaAbA\rightarrow bA|aaA|b,即A(baa)AbA\rightarrow (b|aa)A|b,代入公式可得A=(baa)bA=(b|aa)^*b
那么易得S=a(baa)bS=a(b|aa)^*b

例-2:将例-1中求出的正规式转化为正规文法。
解:在这里第一步,我们既可以将a(baa)a(b|aa)^*看做整体处理,也可以将
(baa)b(b|aa)^*b看做整体处理。

如果处理a(baa)a(b|aa)^*,那么将上面的正规式分解为两条规则:
SAbS\rightarrow Ab
Aa(baa)A\rightarrow a(b|aa)^*
然后改写第二条规则:
AAbAaaaA\rightarrow Ab|Aaa|a
由此可得第一种答案:
SAbS\rightarrow Ab
AAbAaaaA\rightarrow Ab|Aaa|a
所求的即为左线性正规文法。

一个正规式可以用多个等价的正规文法进行表示,正规文法之间只有是否简约的区别,表达的含义相同。

如果处理(baa)b(b|aa)^*b,那么将上面的正规式分解为两条规则:
SaAS\rightarrow aA
AbAaaAbA\rightarrow bA|aaA|b
所求的即为右线性正规文法。

技巧:左/右线性正规文法对abab^*aba^*b的处理

对于左线性正规文法而言

  • 处理abab^*AAbaA\rightarrow Ab|a
  • 处理aba^*b:必须使用两条语句:ABb,BBaεA\rightarrow Bb,B\rightarrow Ba|\varepsilon

对于右线性正规文法而言

  • 处理abab^*:必须使用两条语句:AaB,BbBεA\rightarrow aB,B\rightarrow bB|\varepsilon
  • 处理aba^*bAaAbA\rightarrow aA|b

由此可见,要想让最终获得的文法的规则数量尽可能少,对于左线性正规文法要避免产生aba^*b这样的右式,对于右线性正规文法要避免产生abab^*这样的右式。

2. 正规式与NFA的相互转化

由于NFA形式自由,所以正规式与NFA的相互转化比较易于理解和操作,将正规式转化为NFA只需要无脑怼箭头加状态即可,将NFA转化为正规式只需要无脑删状态组表达式即可。只此3条规则就是记不住也可以很容易推导出来。

例-3:将例-1中求出的正规式转化为NFA。
解:首先定义初态为SS,终态为TT
然后添加路径并拓广:

最后一张图即为求得的NFA。

例-4:将例-3中求得的NFA还原回正规式。
上面的过程逆着来就行,可以尝试一下不同的顺序合并。

3. NFA转化为DFA

这部分涉及对ε\varepsilon-闭包的理解,需要记住其中的操作原理与步骤,画表能够让我们的思路更加清晰。

例-5:将例-3中求得的NFA转化为DFA。

画表构造DFA的状态:

DFA状态名 对应NFA状态集 输入a转到状态集闭包 输入b转到状态集闭包
SS {S}\{S\} {A,B,C}\{A,B,C\} 不存在
AA {A,B,C}\{A,B,C\} {D}\{D\} {B,C}\{B,C\}
BB {D}\{D\} {B,C}\{B,C\} 不存在
CC {B,C}\{B,C\} {D}\{D\} {B,C,T}\{B,C,T\}
DD {B,C,T}\{B,C,T\} {D}\{D\} {B,C,T}\{B,C,T\}

注意这里画表的流程:画完一行之后如果发现后面两行生成的状态集闭包还有没有被DFA收为状态集的,在DFA中定义新的状态,对应NFA状态集就是这个状态集。在第一行画完后发现输入a转到状态集闭包{A,B,C}\{A,B,C\}在DFA当前定义的状态中不存在,于是新增一个DFA状态对应NFA状态集{A,B,C}\{A,B,C\}。第二行画完后发现多了{D}\{D\}{B,C}\{B,C\}这两个状态集,于是下面定义的两行就是这两个状态集。以此类推。最终包含NFA终态的所有DFA状态都是DFA的终态。

接下来画出该NFA等价的DFA:

4. DFA化简

DFA化简实际上就是一个不断划分子集的过程。

例-6:将例-5中求得的DFA化简为最简DFA。

分析:化简DFA的第一步就是将终态和非终态划分为两个集合,在上图中就应该是{S,A,B,C}\{S,A,B,C\}{D}\{D\}这两个子集。对于终态集{D}\{D\},由于其只有一个状态不能在接着划分,因此无需对其进行处理。下面我们来看一下对非终态集的划分过程。

首先看非终态集4个状态接受符号aa后会转入哪里:
SaAS\stackrel{a}\rightarrow A
AaBA\stackrel{a}\rightarrow B
BaCB\stackrel{a}\rightarrow C
CaBC\stackrel{a}\rightarrow B
转入集合{A,B,C}{S,A,B,C}\{A,B,C\}\subset\{S,A,B,C\}。记作{S,A,B,C}a={A,B,C}\{S,A,B,C\}a=\{A,B,C\}
然后看接受符号bb后会转入哪里:
AbCA\stackrel{b}\rightarrow C
CaDC\stackrel{a}\rightarrow D
记作{S,A,B,C}b={C,D}\{S,A,B,C\}b=\{C,D\}
可以看到其中DD状态并不在{S,A,B,C}\{S,A,B,C\}中,因此需要对非终态集进行划分。按照符号bb的转入状态可以将AACC归为不同集合。既然AACC已经确定分属不同集合,那么再回过头看一下接收符号aa时的情况,SaAS\stackrel{a}\rightarrow ABaCB\stackrel{a}\rightarrow C,因此SSBB属于不同集合。有时在DFA中,某些状态可能不能接受一些符号的输入,如本题中状态BB不能接收输入bb,虽然{B,C}a={B,C}\{B,C\}a=\{B,C\},但不能将BBCC划为一个集合,因为不允许状态AA接受字符aa之后接受bb在本题中,已经确定AACC不在一个集合,SSBB不在一个集合,而且SSBB都不能接受bb输入,因此SSBB也一定不能和AACC中任何一个属于同一个集合。所以本题的DFA无法化简,已经是最简形式。

注意状态等价的定义:一个集合中任意两个状态等价当且仅当对于任意输入,任意两个状态到达的下一个状态都相等。一个不接受某个符号输入的状态和一个接受该符号输入的状态一定不在一个状态集中!

5. 正规文法和有穷自动机的相互转换

前面已经通过例题介绍了正规文法到正规式、正规式到有穷自动机的转换,如果不嫌麻烦可以用两次转换实现本问题。但更好的方法是直接进行转换。

例-7:将例-1中的正规文法转换为NFA。
SaAS\rightarrow aA
AbAaBbA\rightarrow bA|aB|b
BaAB\rightarrow aA
分析:这是一个右线性正规文法,在画图的之后要注意箭头指向哪里,不要指反了。首先还是定义状态。第一步需要明确开始符号SS应该是初态还是终态。如果存在规则SaAS\rightarrow aA,那么SS应该作为初态,因为它必须接受一个符号aa才能转到状态AA,有状态转换SaAS\stackrel{a}\rightarrow A。如果存在规则SAaS\rightarrow Aa,那么SS应该作为终态,因为一个句子必须以aa结尾才能结束,有状态转换AaSA\stackrel{a}\rightarrow S由此可以推断出本题的SS应该作为初态。另外定义状态AABB以及一个终态TT

凡是AaBA\rightarrow aB一律添加AaBA\stackrel{a}\rightarrow B,凡是ABaA\rightarrow Ba一律添加BaAB\stackrel{a}\rightarrow A可以画出下面的NFA:

与例-3中画出的NFA等价。

例-8:将例-7中求出的NFA转换为正规文法。
分析:遍历NFA中所有的路径,添加规则。

SaAS\stackrel{a}\rightarrow A可添加规则SaAS\rightarrow aA
AaBA\stackrel{a}\rightarrow B可添加规则AaBA\rightarrow aB
AbAA\stackrel{b}\rightarrow A可添加规则AbAA\rightarrow bA
BaAB\stackrel{a}\rightarrow A可添加规则BaAB\rightarrow aA
AbTA\stackrel{b}\rightarrow T可添加规则AbA\rightarrow b

求得正规文法:
SaAS\rightarrow aA
AbAaBbA\rightarrow bA|aB|b
BaAB\rightarrow aA

6. 实验对接

在考试中,很可能会结合实验的内容进行设计,如标识符识别、关键字识别等实用的例子。

例-9(综合例题):在编译原理实验1中,我们完成了对一门小语言的词法分析程序,有关其中对于数字(含正负数、浮点数)的识别,回答下列问题:
(1)设初态为SS,终态为Ti,TfT_i,T_f,分别表示识别到整数和浮点数,错误状态为EE,所有数字(0-9)统一使用符号dd表示,小数点以符号".“表示,加号以”+“表示,减号以”-"表示,即整个文法的终结符集合为{d,.,+,}\{d,.,+,-\}。画出能够识别所有数字(正数(如+21、42)、负数(如-12)、浮点数(如0.5、-12.43、000.12000、23.))的NFA。注:只要出现小数点就识别为浮点数。小数点右边允许没有数字但左边不允许没有数字。

解:要做本题,首先要搞清楚字符出现的顺序与规律。这里面正负号或者不出现,或者出现在第一位,小数点最多只会出现1次,且只要出现小数点,就必然要识别为浮点数,否则识别为整数。

整数的格式:正负号(可选)+若干位数字
浮点数的格式:正负号(可选)+若干位数字+小数点+若干位数字(可选)

注意到两种识别前面有相同的一部分,所以可以合并前面的识别部分。

将识别正负号作为一个状态,识别整数部分作为一个状态,识别小数点作为一个状态,识别小数部分作为一个状态,画出如下NFA:

注意这里的画图技巧:当识别到一个状态发现前面的句子已经能够识别出来,就将这个状态设置为终态。这样可以减少一些状态的设置。另外,最好让任何一个状态接收任何一个输入都有意义,如这里的终态EE表示错误状态,不加下面的箭头看上去可以,但实际上这就让识别过程终止了。如果识别如12345.+12355这样的错误字符串,小数点后遇到加号会立即报错,如果不加下面的箭头就会从12355开始新一轮识别,最终识别出来一个数字12355,这显然是错误的,应该让错误状态接收完所有非空字符之后再结束。

(2)你第1题画出来的NFA是否是DFA?若是,说明理由并化简DFA,若不是,将其转换为DFA并化简。画出最简DFA。化简之后想一想使用最简DFA去识别数字是否合适?为什么?

分析:图中并没有一个状态对于一个输入有多个输出的情况,因此这是一个DFA。下面来判断一下这个DFA是否可以化简。

首先划分终态集{E,Ti,Tf}\{E,T_i,T_f\}和非终态集{S,A}\{S,A\}。注意到终态集中状态的所有输入对应的下一个状态都属于终态集,因此终态集整个集合中3个状态等价。对于非终态集而言,状态SS输入正负号转到状态AA,状态AA输入正负号却转到终态,因此这两个状态并不等价,需要拆分,因此最后划分的状态集为{{S},{A},{E,Ti,Tf}}\{\{S\},\{A\},\{E,T_i,T_f\}\}。最简DFA:

这个最简DFA识别数字并不实用,因为它将所有数字以及错误情况全部识别为一个终态,这样无法将整数、浮点数与错误这三种情况区分开,因此在实际应用中还是应该使用第1题中求出的DFA。

(3)根据前面两题的结果,补全下面的bison代码,用于识别上面的数字。(方括号内为答案,后接题目序号)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
%{
#include <stdio.h>
#include <math.h>
extern int yylineno;
int yylex();
void yyerror(const char *s);
%}

%token num // 0-9这10个数字字符
%token plus // 加号
%token minus // 减号
%token dot // 小数点

%%

unsigned_integer: num {$$ = $2.value;} // .value属性为字符对应的数值
| unsigned_integer num {[$$ = $1 * 10 + $2;]<1>}

number: [plus number]<2> {$$=$2;}
| [minus number]<3> {$$=-$2;}
| unsigned_integer dot unsigned_integer {$$=$1 + $3 * 1.0 / pow(10, $3.length);} // $2.length表示[小数位的总位数]<4>
%%
......

Chapter 2 文法和语言的基础知识

2.1 概述

语法:对语言结构的定义,即这条语句是由什么构成的
语义:描述语言的含义,即这条语句是干什么的
语用:从使用的角度去描述语言,即这个语句对应的一类语句有什么用

2.2 字母表和符号串的基本概念

文法:描述语言的语法结构的形式规则
字母表:元素的非空有穷集合,记为Σ\Sigma
符号(字符):字母表中每个元素称为字符
字符串(字):Σ\Sigma中的字符构成的一个有穷序列
空字:不包含任何字符的序列,记为ε\varepsilon
所有字的全体:包含空字,记为Σ\Sigma^*,实际上就是Σ\Sigma的闭包。

Σ={a,b}\Sigma=\{a, b\},那么Σ={ε,a,b,ab,ba,aa,ab,aaa,...}\Sigma^*=\{\varepsilon,a,b,ab,ba,aa,ab,aaa,...\}

连接:x和y两个字符串的连接xy。Σ\Sigma^*的子集的连接(乘积)定义为:AB={xyxA&yB}AB=\{xy|x\in A \& y\in B\}
符号串的幂运算:设x为符号串,则其幂运算定义为:x0=ε,x1=x,x2=xx,...x^0=\varepsilon,x^1=x,x^2=xx,...。集合的幂运算与之类似。
正闭包A+A^+和闭包AA^*A的正闭包和闭包定义为:A+=A1A2...An...,A={ε}A+A^+=A^1\cup A^2\cup ...\cup A^n...,A^*=\{\varepsilon\}\cup A^+

2.3 文法和语言的形式定义

2.3.1 形式语言

序列的集合称为形式语言。形式语言有两种表示方式。
一种方式是将语言所有可能以枚举的形式写到一个集合中去。
另一种方式针对无法枚举的情况,称为产生式。

2.3.2 文法的形式定义

规则

规则又称产生式,是一个符号与一个符号串的有序对(A,β)(A,\beta),通常写作AβA\rightarrow \beta,其中AA规则左部,是一个符号,β\beta规则右部,是一个符号串。\rightarrow表示“生成”、“定义为”。规则的作用是告诉我们如何使用规则中的符号生成语言中的序列,即一组规则定义了一个语言的语法结构。

文法

文法是规则的有穷集合,通常用四元组G=(VN,VT,P,S)G=(V_N,V_T,P,S)表示,其中:

  • VNV_N是规则中非终结符号的集合
  • VTV_T是规则中终结符号的集合,VNVT=V_N\cap V_T=\empty。通常使用VV表示VNVTV_N\cup V_T,称为文法中的词汇表
  • PP文法规则的集合
  • SS是一个非终结符号,称为文法的开始符号或文法的识别符号,至少要在一条规则中出现,由它开始识别定义的语言

文法是对语言结构的定义和描述。文法四大要素中,关键是规则的集合。

为书写方便,对于若干左部相同的规则可以缩写为Aα1α2...A\rightarrow \alpha_1|\alpha_2|...,其中每一个αi\alpha_i称为AA的一个候选式。约定第一条规则的左部符号为识别符号,对文法GG不使用四元式显式表示,而是只写出规则。

2.3.3 语言的形式定义

直接推导

GG为一文法,从xAyxAy直接推出xαyx\alpha y,即xAyxαyxAy\Rightarrow x\alpha y,仅AαA\rightarrow \alphaGG的一个规则且x,y(VNVT)x,y\in (V_N\cup V_T)^*。即这一次直接推导只使用了一次规则。注意推导是\Rightarrow而规则是\rightarrow,二者千万不要搞混。

推导

如果存在一个推导序列α0α1...αn\alpha_0\Rightarrow\alpha_1\Rightarrow...\Rightarrow\alpha_n,则称这个序列是一个从α0\alpha_0αn\alpha_n的长度为n的推导,记为α0+αn\alpha_0\stackrel{+}\Rightarrow\alpha_n,其表示从α0\alpha_0出发可以经过1步或若干步或使用若干次规则推导出αn\alpha_n

广义推导

α0αn\alpha_0\stackrel{*}\Rightarrow\alpha_n表示从α0\alpha_0出发可以经过0步或若干步或使用若干次规则推导出αn\alpha_n。0步指的是α0=αn\alpha_0=\alpha_n的情况。

句型和句子

设有文法G[S]G[S],如果Sx,x(VNVT)S\stackrel{*}\Rightarrow x,x\in(V_N\cup V_T)^*,则称符号串x为文法G[S]G[S]句型Sx,x(VT)S\stackrel{*}\Rightarrow x,x\in(V_T)^*,称x为文法G[S]G[S]句子。即句子只含有终结符,是一个确定的字符串序列,而矩形可能含有非终结符,可能是一个字符串范式,表示一类字符串的格式。

语言

文法G[S]G[S]产生的所有句子的集合称为该文法GG所定义的语言,记作L(G[S])L(G[S])L(G[S])={xS+x&xVT}L(G[S])=\{x|S\stackrel{+}{\Rightarrow}x\&x\in V_T^*\}。当文法确定时,语言也就确定。注意L(G)L(G)VTV_T^*的一个子集,属于VTV_T^*的符号串xx不一定属于L(G)L(G),因为VTV_T^*中不是所有的符号串都满足文法规定的句型格式

2.3.4 规范推导和规范规约

最左(最右)推导:对于一个推导序列中的每一步直接推导αβ\alpha\Rightarrow\beta,都是对α\alpha中的最左(最右)非终结符进行替换。最右推导又称规范推导,由规范推导推导出的句型称为规范句型

规范规约:规范推导的逆过程,即从左向右进行规约,也称为最左规约。

2.4 短语、直接短语和句柄

2.4.1 短语和直接短语

GG为一个文法,SS为文法开始符号,假定αβδ\alpha\beta\delta是文法GG的一个句型,如果有SαAδS\stackrel{*}\Rightarrow\alpha A\deltaA+βA\stackrel{+}\Rightarrow\beta,则称β\beta是相对于非终结符AA的句型αβδ\alpha\beta\delta短语。特别地如果有SαAδS\stackrel{*}\Rightarrow\alpha A\deltaAβA\Rightarrow\beta,则称β\beta直接短语。一个句型的最左直接短语称为该句型的句柄

2.5 语法树与文法的二义性

2.5.1 推导和语法树

语法树:使用一张图表示一个句型的推导,称为语法树。一棵语法树是不同推导过程的共性抽象。
语法树的子树:由某一个非末端结点连同所有分支组成的部分。
简单子树:只有单层分支的子树。

可以根据语法树快速确定一个句子的短语、直接短语和句柄。方法如下:
短语——子树的末端结点形成的符号串是相对于子树根的短语。
直接短语——简单子树的末端结点形成的符号串是相对于简单子树根的短语。
句柄——最左简单子树的末端结点形成的符号串是句柄。

2.5.2 文法的二义性

当一个句型有多个最左(最右)推导时,表示该文法存在某个句子对应两棵不同的语法树,该文法具有二义性。一个语言是二义的,如果对其不存在没有二义性的文法。

消除二义性文法可以通过添加限制性规定、调整运算符的优先顺序和结合规则等方式实现。

2.6 文法和语言的分类

文法和语言分为4大类:0型~3型。

  • 0型:无限制文法。即每条规则的左式和右式都为(VNVT)(V_N\cup V_T)^*,且左式中至少含有1个非终结符。
  • 1型:上下文有关文法。每一条规则形式为αAβαuβ\alpha A\beta\rightarrow \alpha u\beta,其中AVN,α,β(VNVT),u(VNVT)+A\in V_N,\alpha,\beta\in(V_N\cup V_T)^*,u\in(V_N\cup V_T)^+,即在替换非终结符AA时需要考虑上下文。
  • 2型:上下文无关文法。每一条规则的形式为AβA\rightarrow\beta,其中AVN,β(VNVT)A\in V_N,\beta\in(V_N\cup V_T)^*。即使用该规则时无需考虑AA的上下文结构而可以直接将其转换。
  • 3型:正规文法。每一条规则的形式为AαBA\rightarrow \alpha BAαA\rightarrow \alpha,其中A,BVN,αVTA,B\in V_N,\alpha\in V_T^*。右线性文法和左线性文法都为3型文法。

上面4种文法可以表示的句型范围从大到小,规范性从弱到强。一些句型可能无法使用高规范文法进行表示但可以用低规范性文法表示。对于现今程序设计语言,在编译程序中,仍然采用上下文无关文法来表述其语言结构。

2.7 有关文法的实用限制和变换

对文法的实用限制有两点:

  • 文法中不能包含如AAA\rightarrow A的规则,这被称为有害规则。
  • 文法中不能包含多余规则。即文法中出现以下两种规则:
    • 某条规则左部符号不在所属文法的任何规则右部出现,这样这个规则就永远不可能用得上。
    • 对文法中某个非终结符不能从其推导出任何终结符号串,这意味着如果使用了这个规则,那么它将一直推导下去永远无法到达终点,这显然是不允许的。

题型总结

1. 基本概念的理解

例-1:给出文法G[S]G[S]如下:
SBdS\rightarrow Bd
BAeB\rightarrow Ae
AAddA\rightarrow Ad|d
求句子ddeddded的句柄、所有短语和直接短语。
方法1:画出语法树。这是最为保险,也最不容易错的一种方法。此处语法树略。
方法2:进行推导。
SBdAedAdedddedS\Rightarrow Bd\Rightarrow Aed\Rightarrow Aded\Rightarrow dded。其中有B+dedB\stackrel{+}\Rightarrow ded,因此dedded是一个短语。同理第一个ddddddddeddded也是短语。
直接短语是直接推导而来,因此只有第一个dd是直接短语。
句柄是最左直接短语,即第一个dd

一定要搞清楚短语、直接短语和句柄的定义,不要混淆。

2. 根据字符串描述写出文法

例-2:使用文法表示下面描述的语言。该语言由0、1、左右括号组成,且由01开头,10结尾。中间的值可以为0、1、括号扩充的整体。由左右括号扩充的整体中,以00开头,11结尾,中间的值可以为0、1、括号扩充的整体。如01(00111)(0011011)011(0011)10是该语言的一个句子。

分析:求解写文法题时需要将问题分解,采用分治的思想逐个击破。如本题中可明显地将“括号扩充的整体”进行首先处理,然后再去处理其他的内容。将这部分定义为由非终结符TT生成,则有
T(00A11)T\rightarrow(00A11),其中AA应该表示的是0或1构成的序列:
AA0A101A\rightarrow A0|A1|0|1
外层的内容:
S01B10S\rightarrow 01B10,其中BB应该是TTAA构成的字符串
BBTBATAB\rightarrow BT|BA|T|A
所求文法:
S01B10S\rightarrow 01B10
BBTBATAB\rightarrow BT|BA|T|A
T(00A11)T\rightarrow (00A11)
AA0A101A\rightarrow A0|A1|0|1

技巧:对于一些常见的序列组合,有固定的模式可以套用——

  • 由终结符{a1,a2,...,an}\{a_1,a_2,...,a_n\}构成的所有字符串可以使用下面的文法规则(如不包含空串则删去最后的ε\varepsilon即可):
    • SSa1Sa2...Sana1a2...anεS\rightarrow Sa_1|Sa_2|...|Sa_n|a_1|a_2|...|a_n|\varepsilon
  • 运算符优先级控制,对于运算符{1,2,...,n}\{*_1,*_2,...,*_n\},下标越大优先级越高,则可以使用下面的文法规则表示(这只是一种表示,还有其他表示方式):
    • A1A11A2A2A_1\rightarrow A_1*_1A_2|A_2
    • A2A22A3A3A_2\rightarrow A_2*_2A_3|A_3
    • ......
    • AnAnnAn+1An+1A_n\rightarrow A_n*_nA_{n+1}|A_{n+1}
    • 若存在优先级相同的运算符,则作为一个规则的不同候选式。

实际上有了上面这两条规则模板,已经可以解决很多的问题了,但有时还可以通过一些更加巧妙的组合来简化文法的书写:

  • 长度相等子串对:在句子中包含有类似于αnβn(n0)\alpha^n\beta^n(n\ge 0)的形式,如果采用上面的方式需要两个规则,但实际上可以简化为一条规则:
    • SαSβαβεS\rightarrow\alpha S\beta|\alpha\beta|\varepsilon
  • 长度相关子串对:两个重复子串的长度不再相等,而是具有一定的关系:
    • 大小关系:αnβm(n>morn<m,m,n0)\alpha^n\beta^m(n>m\operatorname{or}n<m,m,n\ge 0),以n>mn>m为例,可以写为一条规则:
      • SαSβαSαβS\rightarrow\alpha S\beta|\alpha S|\alpha|\beta(此时可以不加ε\varepsilon,因为此时mmnn必有一个不为0,因此串不可能为空串)
    • 倍数关系:αanβn\alpha^{an}\beta^n,可以将αa\alpha^a看做一个整体:
      • SαaSβαaβεS\rightarrow \alpha^aS\beta|\alpha^a\beta|\varepsilon
    • 差数关系:两串长度的差值固定,将差的那一部分分离出来看做前后缀即可。
  • 注意:形如anbncna^nb^nc^n的句子无法使用2型文法规则定义,因为bb子串长度取决于其左右aa子串和cc子串的长度。换句话说,我们无法对这个句子用分治法进行规则解释。但a2nb2nc2na^{2n}b^{2n}c^{2n}可以解释,其文法为:
    • SASBABεS\rightarrow ASB|AB|\varepsilon
    • Aa2Aba2bA\rightarrow a^2Ab|a^2b
    • BbBc2bc2B\rightarrow bBc^2|bc^2
    • 这是将这个句子拆分成了a2nbna^{2n}b^nbnc2nb^nc^{2n}两份求解的,这两份的nn的关系由第一条规则可以定义。但anbncna^nb^nc^n不能这样分解是因为nn无法确定是否是偶数。当nn为偶数或奇数时相应的2型文法都可以写出来,但二者合并是无法实现的。(思考:当nn为奇数时,文法应该如何书写?)
  • 长度相关子串对,之间夹有其他字符
    • SS的第二个候选式αβ\alpha\beta变换为αTβ\alpha T\beta,其他内容由TT规则负责解释。

3. 根据文法写出语言描述

也即第2种题型的逆推。

例-3:根据下列文法说出其定义的语言
S0SSDDS\rightarrow0S|SD|D
DaSbD\rightarrow aS|b

分析:其定义的语言可以通过画树状图的方式来寻找规律。每一次对候选式进行分支,可以据此获取所有该语言的句子。如上述文法定义的语言的最短的几个句子应该是:bb0b0babab0ab0ab,可以发现所有这些句子的最后一个字符都是bb。有推导:SSD0SDS\Rightarrow SD\Rightarrow0^*SDSSDDDnanSnanDnanbnS\Rightarrow SD^*D\Rightarrow D^n\Rightarrow a^nS^n\Rightarrow a^nD^n\Rightarrow a^nb^n。再往下分析,就比较难了,因为没有对这个文法进行化简。

对该文法尝试进行化简:
S0SSaSSbaSbS\rightarrow 0S|SaS|Sb|aS|b
注意到其中有S0SaSS\rightarrow0S|aS,因此前缀应是0和aa组成的任意长度字符串,而后缀只能是bb组成的字符串。中间有一个规则是SSaSS\rightarrow SaS,表示两个SS中间有一个aa分隔。去掉这条规则后,就只剩下对前缀后缀的规则定义了,因此这个文法描述的语言是:

一个类型的字符串A以一种方式连接起来形成的总字符串。字符串A由两个部分组成,前面是以0或a构成的任意长度字符串(含空串),后面是以b构成的任意长度字符串(不含空串)。总字符串中有1个或多个这样的字符串,这些字符串中间以a连接。

4. 二义性检查与反例

检查文法的二义性主要有判断性问题和举反例问题,多出现在运算符优先级设置不合理的文法中。二义性的直接影响是在文法分析过程中会产生移进-规约冲突和规约-规约冲突,前者出现于一个右部串是另一个右部串的子串时,后者出现在一个右部串对应多个左部串。有时对于移进-规约冲突的情形,需要进行推导才能发现右部符号串有包含情况。