ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

lex/yacc手搓SQL编译器:从词法分析到语法树避坑指南

lex/yacc手搓SQL编译器:从词法分析到语法树避坑指南

简介:这份编译原理上机报告来自西安电子科技大学计算机科学与技术专业,面向正在学习编译原理、需要完成课程设计或上机实践的高校学生。报告以“DBMS的设计与实现”为题,完整记录了从词法分析、语法分析到数据库管理系统物理结构与核心功能的实现过程,适合作为课程报告的参考范例与项目思路借鉴。资源包内含1个doc文档,压缩包约82KB,文档结构清晰,涵盖项目概况、人员分工、编译部分实现方案、DBMS部分设计以及上机心得与建议等章节,其中词法分析部分给出了基于lex的正规式定义与代码片段,语法分析部分涉及yacc的使用与抽象语法树构建思路。目前已有1330人浏览学习,读者可从中了解如何将编译原理理论落地为具体系统,掌握词法语法分析器的构建流程、DBMS基本功能模块的划分方式,以及独立完成上机项目时的文档组织与排错思路。

1. 从一份 2011 年的上机报告说起:lex/yacc 手搓 SQL 编译器到底能跑通什么

翻到一份 2011 年 5 月的编译原理上机报告,标题写着《DBMS 的设计与实现》,正文里却塞满了mylexer.l和myparser.y的片段。第一眼看上去像是“数据库课设”,细读才发现真正的主角是词法分析和语法分析——用 lex 写正规式识别 SQL 关键字,用 yacc 写产生式把 token 流拼成语法树,最后挂一个极简的 DBMS 执行壳。这份报告最值钱的地方不是它完成了多少功能,而是它把“编译原理怎么落到一个能跑的 SQL 解析器上”这条链路完整暴露了出来:从%token声明到%left AND OR的优先级处理,从CREATE TABLE Name '(' create_field ')'的产生式到sts sts st的闭包结构,每一步都有对应的代码和踩坑记录。适合谁看?正在做编译原理课设、想用 lex/yacc 快速搭一个 SQL 前端、或者单纯想搞明白“词法分析器怎么把SELECT和Name区分开”的人。这份报告里的代码不完整,但骨架清晰,照着补全能跑出一个能识别建库、建表、查询、插入、删除、更新的解析器。

2. 词法分析器拆解:从mylexer.l看 SQL token 怎么切

2.1 正规式怎么写才不把关键字和标识符搅在一起

报告里的mylexer.l用的是 ParserWizard 生成的 lex 文件结构,%name mylexer定义了词法分析器类名,%%之后是规则区。核心规则长这样:

"CREATE" {return CREATE;} "DATABASE" {return DATABASE;} "TABLE" {return TABLE;} "SELECT" {return SELECT;} "INSERT" {return INSERT;} "VALUES" {return VALUES;} "UPDATE" {return UPDATE;} "DELETE" {return DELETE;} "DROP" {return DROP;} "USE" {return USE;} "SHOW" {return SHOW;} "EXIT" {return EXIT;} NULLX {return NULLX;} "INT" {return INT;} "CHAR" {return CHAR;} "AND" {return AND;} "OR" {return OR;} "WHERE" {return WHERE;} "INTO" {return INTO;} "SET" {return SET;} "FROM" {return FROM;} "(" {return '(';} ")" {return ')';} ";" {return ';';} "," {return ',';} "*" {return '*';} ">" {return '>';} "<" {return '<';} "=" {return '=';} "!=" {return NE;} [0-9]+ {return NUMBER;} [a-zA-Z][_0-9a-zA-Z()\.\*]* {printf("%s",yytext);return Name;} [a-zA-Z][_a-zA-Z0-9]+ {return ID;}

这里有个关键顺序问题:lex 是按规则出现顺序匹配的,最长匹配优先,但同长度时先出现的规则赢。CREATE、TABLE这些关键字必须放在[a-zA-Z][_a-zA-Z0-9]+前面,否则CREATE会被当成普通标识符ID吃掉。报告里还留了一个Name规则,允许标识符里带()、.、*,这明显是为了处理table.field或COUNT(*)这类写法,但代价是Name和ID的边界变得模糊——后面语法分析部分果然在这里翻了车。

NULLX这个 token 名有点怪,看规则是直接匹配字面量NULLX,应该是想表达空值但写错了,正确写法应该是"NULL"或者NULL。这种命名不一致在课设里很常见,不影响跑通,但读代码的人容易愣一下。

2.2 单引号识别翻车:一个斜线引发的血案

报告里明确记了一条:“在识别单引号时由于没有使用斜线标示,导致无法识别”。这是 lex 的经典坑。如果你想匹配一个单引号字符,规则里必须写"'"或者\',直接写'''会被 lex 解析成“空规则 + 单引号 + 空规则”,行为完全不可预测。正确写法:

"'" {return '\'';}

或者更常见的字符串字面量处理:

'([^'\\]|\\.)*' {return STRING;}

报告里只留了''' {}这一条,既没有 return 也没有正确转义,说明当时只做到“能跳过单引号”的程度,没有真正把字符串常量纳入 token 流。如果你的课设要求支持INSERT INTO t VALUES ('abc'),这一块必须补全,否则语法分析阶段遇到字符串会直接报 unexpected token。

2.3 空白和换行的处理策略

"\n" {} ''' {} [ \t] {/* do nothing */ }

换行和空格直接吞掉不返回 token,这是标准做法。但注意"\n"没有做行号计数,如果后面语法报错想提示“第几行出错”,就得在 lexer 里加一个全局行号变量,在"\n"规则里自增。报告里没做这个,所以它的报错只能定位到 token 级别,没法给出行号。对于课设演示够用,但调试阶段会多花不少时间。

3. 语法分析器拆解:yacc 产生式怎么把 token 拼成 SQL 语法树

3.1 从sts sts st看闭包结构的设计

myparser.y的规则区第一条:

sts: sts st {printf("Operation is ok\n");} | /* empty */ ;

这是典型的左递归闭包写法,sts表示“语句序列”,可以不断拼接st,最终归约为空。每成功归约一条语句就打印一次Operation is ok,相当于一个最简陋的“执行成功”反馈。这种写法在 yacc 里很稳,不会像右递归那样把栈撑爆。但报告里在| /* empty */后面跟了一个问号?,这应该是笔误,yacc 里空产生式直接写/* empty */就行,多一个问号会导致语法文件解析失败。

st展开成各种语句:

st: create_st ';' | show_st ';' | drop_st ';' | use_st ';' | select_st ';' | delete_st ';' | update_st ';' | insert_st ';' ;

每条语句都以分号结尾,这是 SQL 的硬性要求,也方便 yacc 在归约时确认语句边界。如果你想让解析器支持多语句一行,这个结构天然支持,因为sts会不断吃st直到输入结束。

3.2 建表语句的产生式展开与字段列表处理

create_st : CREATE DATABASE Name | CREATE TABLE Name '(' create_field ')' ; create_field: create_field ',' field_type | field_type ; field_type: field type ; type : CHAR '(' NUMBER ')' | INT ;

这里create_field用了左递归来支持逗号分隔的字段列表,field_type把字段名和类型绑在一起,type再区分CHAR(n)和INT。整个链路是清晰的,但有一个隐患:Name在词法阶段允许包含()和*,所以CHAR(20)里的CHAR可能被Name规则先匹配走,导致type规则永远等不到CHARtoken。报告里没提这个问题,但实际跑的时候如果字段类型解析失败,大概率就是这里被截胡了。

修正思路有两种:一是收紧Name的正规式,去掉()和*,让CHAR和(各自独立成 token;二是在语法里用ID代替Name做字段名,把Name限制在表名和库名上。报告里选了前者但没改彻底,所以留了坑。

3.3 查询语句的语法树构造与 WHERE 条件递归

select_st: SELECT select_name FROM tables | SELECT select_name FROM tables WHERE conditions ; select_name: table_fields | '*' ; conditions: condition | '(' conditions ')' | conditions AND conditions | conditions OR conditions ; condition: comp_left comp_op comp_right ; comp_left: table_field ; comp_right: table_field | NUMBER ; comp_op: '<' | '>' | '=' | NE ;

conditions的递归定义支持括号嵌套和AND/OR组合,配合%left AND OR声明,优先级和结合性都交给 yacc 处理。comp_left和comp_right分开定义是为了限制比较运算左边必须是字段、右边可以是字段或数字,这比直接写expr comp_op expr更严格,也更贴近 SQL 的实际语法。但table_field只支持field或table'.'field,不支持表达式,所以WHERE age + 1 > 10这种写法会被拒掉。课设够用,真实 DBMS 不够。

3.4 语法树数据结构与Name截获NUMBER的坑

报告里提到“开始时没有考虑 Name 会将 NUMBER 截获,导致数字对应的位置无法识别”。看词法规则:

[0-9]+ {return NUMBER;} [a-zA-Z][_0-9a-zA-Z()\.\*]* {printf("%s",yytext);return Name;}

Name规则要求首字符是字母,所以纯数字不会被Name截获。但Name允许后续字符包含0-9,所以abc123会整体被识别为Name而不是ID加NUMBER。如果语法里期望field type中的type是INT或CHAR,而字段名恰好叫int1,那int1会被Name吃掉,INT关键字反而匹配不上。报告里说的“Name 截获 NUMBER”可能是指CHAR(20)里的20被Name规则里的()和数字组合吞掉,导致NUMBERtoken 丢失。解决办法是把Name的正规式改成[a-zA-Z][_a-zA-Z0-9]*,去掉()和*,让括号和星号单独成 token。

4. 避坑与排查:lex/yacc 课设里最容易翻车的五个点

4.1 现象:关键字被识别成标识符,语法报错 unexpected ID

原因:lex 规则顺序不对,[a-zA-Z][_a-zA-Z0-9]+写在"CREATE"前面,或者关键字规则用了"create"小写而输入是大写。lex 默认区分大小写,SQL 关键字通常要求大写,但用户输入可能大小写混用。

解决:把所有关键字规则放在标识符规则之前,并在 lexer 里加一条[a-zA-Z]+的兜底规则,用strcasecmp做大小写不敏感匹配,匹配到关键字就返回对应 token,否则返回ID。

4.2 现象:CHAR(20)解析失败,报 syntax error

原因:Name规则允许()和数字,导致CHAR(20)整体被当成一个Nametoken,语法分析器等不到CHAR、(、NUMBER、)这四个独立 token。

解决:收紧Name为[a-zA-Z][_a-zA-Z0-9]*,把(、)、*、.都单独定义 token。如果确实需要支持table.field,在语法里用table '.' field组合,而不是在词法里硬吞。

4.3 现象:单引号字符串无法识别,lexer 直接卡死或跳过

原因:规则写成'''没有转义,lex 把三个单引号解析成空规则加单引号加空规则,行为未定义。

解决:用"'"匹配单个单引号,用'([^'\\]|\\.)*'匹配完整字符串常量,并在 action 里去掉首尾引号后存入yylval。

4.4 现象:AND/OR优先级不对,a = 1 AND b = 2 OR c = 3解析成a = (1 AND b) = (2 OR c) = 3

原因:没有声明%left AND OR,或者声明顺序反了。yacc 的优先级按声明顺序从低到高,%left OR写在%left AND前面才能让AND优先级更高。

解决:在 declarations 区按%left OR然后%left AND的顺序声明,比较运算符'<' '>' '=' NE再放在AND后面,确保比较优先于逻辑运算。

4.5 现象:归约到sts时栈溢出或死循环

原因:sts: sts st | /* empty */是左递归,正常不会溢出。但如果st的产生式里有一条能推导出空串,就会导致无限归约。

解决:检查所有st的子规则,确保没有空产生式。create_st、select_st等都必须至少消耗一个 token 才能归约,不能出现st: /* empty */这种写法。

5. 从能解析到能执行:给 DBMS 壳补上最小执行逻辑

报告里 DBMS 部分只写了“物理结构”和“主要功能”的标题,没有展开代码。如果你想把这份课设从“能识别 SQL”推到“能执行 SQL”,最小可行方案是在 yacc 的 action 里直接做副作用,而不是先建 AST 再遍历。比如:

create_st : CREATE DATABASE Name { db_create($3); printf("Database %s created\n", $3); } | CREATE TABLE Name '(' create_field ')' { table_create($3, $5); printf("Table %s created\n", $3); } ;

db_create和table_create用简单的文件或内存结构实现,比如每个数据库一个目录,每个表一个.tbl文件,字段定义写进文件头。查询语句可以在select_st的 action 里遍历表文件,按WHERE条件过滤后打印结果。插入语句直接把VALUES里的字面量追加到表文件末尾。删除和更新需要重写文件,课设级别可以用全量读入内存、修改后再写回的方式,不用考虑并发和事务。

关键是把yylval的类型从int改成能携带字符串和数字的联合体:

%union { int ival; char *sval; } %token <sval> ID Name %token <ival> NUMBER %type <sval> field table

然后在 lexer 里给yylval.sval分配内存并拷贝yytext。注意内存释放,课设里不释放也能跑,但跑久了会漏。

验证方法很简单:准备一个test.sql,内容覆盖建库、建表、插入、查询、更新、删除、删表、删库,用管道喂给解析器:

./myparser < test.sql

如果每条语句都打印Operation is ok并且执行结果符合预期,说明链路通了。如果某条语句报 syntax error,打开 yacc 的-v选项生成y.output,看状态机在哪个 token 上卡住,对照产生式排查。

从那以后我每次写 lex/yacc 都会先把 token 列表和产生式在纸上画一遍,确认关键字规则在标识符前面、空产生式不存在、优先级声明顺序正确,再动手写代码。这份 2011 年的报告虽然只完成了词法和语法部分,但它的骨架足够清晰,补上执行逻辑就是一个能演示的 SQL 解析器。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进