ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Python手写C语言编译器:从词法分析到解释执行

用Python手写C语言编译器:从词法分析到解释执行 简介用Python实现的C语言编译器项目面向编译原理学习者和Python开发者。项目采用LL1文法完成语法分析利用C语言空语句巧妙解决左递归问题文法规则完整覆盖词法分析、语法分析、语义分析与代码生成等核心阶段可解析变量声明、函数定义、控制流程语句等常见语法结构。压缩包共21个文件体积仅42KB内含Python源码、编译缓存、文法规则文本、C语言源码和说明文档模块划分清晰便于逐层对照阅读。已有274人学习下载。通过研读源码与规则文件可直观掌握词法分析器与LL1解析表构建、抽象语法树生成、语义检查及汇编输出流程多个源码模块分别对应词法、文法、四元式与汇编生成方便分模块学习同时提升Python编程能力适合课程设计或编译原理自学。1. 用 Python 写一个 C 语言编译器这事靠谱吗很多人第一次听到「用 Python 写 C 语言编译器」这个想法第一反应是「图啥」——Python 慢、动态类型、还要解释执行怎么看都不像能当编译器的料。但如果你真想搞懂编译器是怎么工作的Python 反而是最合适的语言你不用先跟内存管理和指针搏斗就能把词法分析、语法分析、符号表这些核心概念一个个亲手实现出来。这个项目标题描述的东西本质上是一个用 Python 写的 C 语言子集编译器或解释器它能读懂 .c 源码输出可执行结果甚至能编译一段能跑的小程序。它能解决的实际问题有三个第一帮 Python 开发者跨进编译原理的门从「会用 C」变成「知道 C 是怎么被机器理解的」第二给需要做代码分析、DSL领域专用语言设计、静态检查工具的人一个可改的骨架第三对于计算机专业的学生这就是一个毕业设计或课程设计的完整选题方案。适合的人群是会一点 Python、写过 C、但从来没碰过编译原理的人。不需要你先啃完龙书跟着这篇文章的思路走你就能得到一个能跑的词法分析器、递归下降语法分析器和执行器。这个方向值不值得做如果你对「语言是怎么被翻译成机器码」这件事有好奇那它值得你投入一周的业余时间。2. 从源码到 Token 流手写词法分析器的设计与实现2.1 为什么要手写词法分析器而不是直接上正则库词法分析的任务是把 .c 源码字符串切割成一个个有意义的 Token——关键字、标识符、数字、运算符、括号等等。第一次做这个项目的人最容易陷入的误区是直接用 Python 的 re 库一顿 findall把所有模式全用正则写出来。这个方案在小 demo 里能跑但一旦遇到字符串字面量、转义字符、注释嵌套这些边界情况正则表达式会膨胀到你根本维护不动而且排错全靠瞎猜。我一般推荐手写一个逐字符扫描的状态机。原因很简单词法分析器的逻辑本质上是「读一个字符决定下一个状态」这是最直观的有限状态机模型。手写代码量不大但每一个分支都在你的掌控之中后面要加注释跳过、加预处理指令都是加一两个 case 的事。而且手写出来的性能在 Python 里反而比正则好——re 库匹配大量短模式时Python 的字符循环并不慢多少但模式匹配的分发开销是实打实的。2.2 核心 Token 定义与扫描循环先定数据结构再写逻辑写词法分析器之前先把 Token 的数据结构定下来。一个 Token 至少要有三样东西类型type、字面量value、位置linecol。位置信息不是可选项——后面语法报错时没有行号你会疯掉的。下面是初始的 Token 类型定义和扫描主循环的骨架。# token_types.py —— 定义 Token 类型常量 class TokenType: # 字面量 IDENTIFIER IDENTIFIER NUMBER NUMBER STRING STRING # 关键字一部分按需扩充 INT INT RETURN RETURN IF IF ELSE ELSE WHILE WHILE # 运算符与分隔符 PLUS PLUS MINUS MINUS STAR STAR SLASH SLASH ASSIGN ASSIGN # EQ EQ # NEQ NEQ # ! LT LT # GT GT # LPAREN LPAREN # ( RPAREN RPAREN # ) LBRACE LBRACE # { RBRACE RBRACE # } SEMICOLON SEMICOLON# ; EOF EOF # 关键字查找表把字符串映射到 TokenType KEYWORDS { int: TokenType.INT, return: TokenType.RETURN, if: TokenType.IF, else: TokenType.ELSE, while: TokenType.WHILE, }这里把关键字和标识符统一处理先按标识符规则读出一个完整的字母数字串然后查 KEYWORDS 表判断它是关键字还是普通名字。这个「先读后查」的策略比在扫描循环里逐字符匹配关键字要简单得多也是大多数小型编译器采用的方案。KEYWORDS 表是字典查找是 O(1)你后面加for、break、char这些关键字只需要往这里添一行。# lexer.py —— 词法分析器主扫描循环简化核心版 import sys from token_types import TokenType, KEYWORDS class Lexer: def __init__(self, source: str): self.source source self.pos 0 self.line 1 self.col 1 self.tokens [] def peek(self, offset: int 0) - str: 向前看 offset 个字符不推进位置 i self.pos offset if i len(self.source): return return self.source[i] def advance(self) - str: 读入当前字符并推进位置同时维护行号列号 ch self.source[self.pos] self.pos 1 if ch \n: self.line 1 self.col 1 else: self.col 1 return ch def add_token(self, tok_type: str, value: str): self.tokens.append({ type: tok_type, value: value, line: self.line, col: self.col - len(value), }) def tokenize(self) - list: while self.pos len(self.source): ch self.peek() # 跳过空白字符 if ch in \t\r\n: self.advance() continue # 跳过单行注释 // if ch / and self.peek(1) /: while self.pos len(self.source) and self.peek() ! \n: self.advance() continue # 跳过块注释 /* */ if ch / and self.peek(1) *: self.advance() self.advance() while self.pos len(self.source): if self.peek() * and self.peek(1) /: self.advance() self.advance() break self.advance() continue # 标识符与关键字 if ch.isalpha() or ch _: start self.pos while self.peek().isalnum() or self.peek() _: self.advance() text self.source[start:self.pos] tok_type KEYWORDS.get(text, TokenType.IDENTIFIER) self.add_token(tok_type, text) continue # 数字字面量先支持整数后续可扩展浮点 if ch.isdigit(): start self.pos while self.peek().isdigit(): self.advance() text self.source[start:self.pos] self.add_token(TokenType.NUMBER, text) continue # 运算符与分隔符——一个集中分发 two_char self.source[self.pos:self.pos2] if two_char : self.advance(); self.advance() self.add_token(TokenType.EQ, ) elif two_char !: self.advance(); self.advance() self.add_token(TokenType.NEQ, !) elif ch : self.advance() self.add_token(TokenType.ASSIGN, ) elif ch : self.advance() self.add_token(TokenType.PLUS, ) # ... 其余运算符类似不再一一列出 else: raise SyntaxError(f第{self.line}行第{self.col}列无法识别的字符 {ch}) self.add_token(TokenType.EOF, ) return self.tokens这段代码里需要注意三个细节。第一个是peek(offset)和advance()的分工peek 只负责「看」advance 才负责「走」。所有需要向前看两个字符才能决定 Token 类型的场合比如/和//、和都靠这个组合完成。第二个是行号列号的维护放在 advance 里统一做这样任何一个 Token 的位置信息都是可靠的后面语法报错直接打印 line 和 col。第三个是注释的处理放在「跳过」逻辑里而不是产出一个 COMMENT Token——大多数编译器的词法阶段直接丢弃注释因为语法分析用不到。2.3 数字、字符串与常见翻车点ASCII 偏移和转义序列数字字面量看起来简单但有一个坑当你把数字字符串转成整数时用int(text)没问题但如果在语法分析阶段把A这种字符字面量也混进来很容易忘记字符在内存里存的是 ASCII 码而不是人类可读的数字。0的 ASCII 是 48直接拿去做运算会得到莫名其妙的结果。我的做法是在词法阶段就把字符字面量单独作为一个 Token 类型处理用ord()转成整数存起来。字符串字面量是另一个踩坑高发区。C 语言里\n、\t、\、\\这些转义序列必须在词法阶段就被翻译成真正的字符不能原样存进 Token。不然语法分析器拿到一个字符串里面的引号还没结束整个 Token 流就断掉了。处理方式是扫描到字符串起始引号后遇到\就看下一个字符做一个查表转换。# 字符串转义的查表转换 ESCAPE_MAP { n: \n, t: \t, r: \r, 0: \0, \\: \\, : , : , } def read_string(self): 调用此函数前当前位置已经读掉了字符串的起始引号 self.advance() # 跳过 chars [] while self.pos len(self.source) and self.peek() ! : ch self.advance() if ch \\: nxt self.advance() if nxt in ESCAPE_MAP: chars.append(ESCAPE_MAP[nxt]) else: raise SyntaxError(f第{self.line}行未知转义序列 \\{nxt}) else: chars.append(ch) if self.pos len(self.source): raise SyntaxError(f第{self.line}行字符串未闭合) self.advance() # 吃掉收尾的 return .join(chars)这里有个参数值得你注意ESCAPE_MAP是只管查表不做特殊判断。你后面想支持\x41这种十六进制转义得在这个函数里加一个分支先读两个字符再int(hex_str, 16)。新手最容易在这里翻车的情况是忘了处理\\本身——如果你先把\消费掉了后面跟了一个普通字符就会误报「未知转义序列」。另外一个容易出错的地方是字符串里带了换行C 标准里普通字符串字面量是不能跨行的遇到这种情况直接报错而不是偷偷放行。3. 递归下降构建 AST表达式优先级与语句解析3.1 从 Token 流到语法树为什么递归下降是首选词法分析做完Token 流有了接下来要做的就是把int a 1 2 * 3;这种线性序列变成一棵能表达运算优先级的抽象语法树AST。AST 的根节点是语句或表达式叶子节点是数字、标识符。1 2 * 3被解析后乘号应该比加号更靠近叶子层这样求值时先算乘法。实现语法分析有两种主流路子用 Yacc/Bison 这类解析器生成器或者手写递归下降。递归下降的思路很朴素为每一种语法结构写一个函数函数内部按语法规则调用其他函数。它的代码量比生成器多但好处是报错信息完全可控、调试可以加断点、逻辑透明。用 Python 写编译器我强烈建议递归下降——因为你可以一步步看函数调用栈清清楚楚地知道当前正在解析哪个语法单元。生成器方案会引入额外一层抽象读代码的人还得先学会 Yacc 语法文件怎么写对教学项目来说不划算。3.2 表达式优先级用函数层级代替运算符优先级表C 语言的表达式优先级从低到高大致是赋值→ 逻辑或||→ 逻辑与→ 相等 !→ 关系 → 加减 → 乘除 → 一元负号 → 括号和函数调用。递归下降做优先级不是维护一张优先级数字表而是靠函数的调用层级来表达——每个优先级对应一个函数。低优先级的函数调用高优先级的函数。# parser.py —— 递归下降语法分析器核心摘录 from token_types import TokenType class Parser: def __init__(self, tokens: list): self.tokens tokens self.pos 0 def peek(self) - dict: return self.tokens[self.pos] def advance(self) - dict: tok self.tokens[self.pos] self.pos 1 return tok def expect(self, tok_type: str): 消费一个指定类型的 Token不匹配就报语法错误 tok self.peek() if tok[type] ! tok_type: raise SyntaxError( f第{tok[line]}行期望 {tok_type}实际得到 {tok[value]} ) return self.advance() # 最低优先级赋值表达式 # 解析 a expr 或单独的 expr def parse_assignment(self): left self.parse_or() if self.peek()[type] TokenType.ASSIGN: self.advance() right self.parse_assignment() # 右结合 return {op: , left: left, right: right} return left # 逻辑或低优先级二元运算 def parse_or(self): node self.parse_and() while self.peek()[type] TokenType.OR: # 需要新增 OR TokenType self.advance() right self.parse_and() node {op: ||, left: node, right: right} return node # 逻辑与 def parse_and(self): node self.parse_equality() while self.peek()[type] TokenType.AND: self.advance() right self.parse_equality() node {op: , left: node, right: right} return node # 相等比较 ! def parse_equality(self): node self.parse_relational() while self.peek()[type] in (TokenType.EQ, TokenType.NEQ): op self.advance()[value] right self.parse_relational() node {op: op, left: node, right: right} return node # 关系比较 def parse_relational(self): node self.parse_additive() while self.peek()[type] in (TokenType.LT, TokenType.GT): op self.advance()[value] right self.parse_additive() node {op: op, left: node, right: right} return node # 加减 def parse_additive(self): node self.parse_multiplicative() while self.peek()[type] in (TokenType.PLUS, TokenType.MINUS): op self.advance()[value] right self.parse_multiplicative() node {op: op, left: node, right: right} return node # 乘除 def parse_multiplicative(self): node self.parse_unary() while self.peek()[type] in (TokenType.STAR, TokenType.SLASH): op self.advance()[value] right self.parse_unary() node {op: op, left: node, right: right} return node # 一元运算负号、逻辑非 def parse_unary(self): if self.peek()[type] TokenType.MINUS: self.advance() operand self.parse_unary() return {op: -, operand: operand} return self.parse_primary() # 原子表达式数字、标识符、括号表达式 def parse_primary(self): tok self.advance() if tok[type] TokenType.NUMBER: return {kind: number, value: int(tok[value])} if tok[type] TokenType.IDENTIFIER: return {kind: var, name: tok[value]} if tok[type] TokenType.LPAREN: node self.parse_assignment() self.expect(TokenType.RPAREN) return node raise SyntaxError(f第{tok[line]}行无法解析的表达式起始 {tok[value]})这段代码里有几个关键设计。第一个是parse_assignment里解析完右值后递归调用自己而不是去调parse_or——这是为了实现赋值运算符的右结合性。C 语言里a b c等价于a (b c)如果你写成调用parse_or左结合会让这个链式赋值解析成(a b) c语义就错了。第二个是每个二元运算符解析函数都用 while 循环而不是递归来处理同一优先级的连续运算——a b c如果写成递归下降会产生深的左递归链Python 的函数调用栈顶不住几百层。while 循环把同层节点直接做成左结合的树既稳又省栈。第三个是 AST 的节点结构统一用字典{op: , left: ..., right: ...}。字典在 Python 里天然适合做异构的树节点序列化、打印调试都方便不用定义一堆类。3.3 语句解析if/while/return 和块作用域怎么进 AST表达式是语言的「骨头」语句才是「肉」。一个 C 函数体是由语句组成的常见的语句类型有表达式语句后面带分号、赋值语句、if 语句、while 语句、return 语句、块语句花括号包裹的一组语句。解析语句的函数通常叫parse_statement它会向前看一个 Token根据 Token 类型决定走哪个分支。# parser.py —— 语句解析与函数定义 def parse_statement(self): tok self.peek() t tok[type] if t TokenType.INT: return self.parse_local_declaration() # int a; / int a 10; if t TokenType.RETURN: self.advance() expr self.parse_assignment() self.expect(TokenType.SEMICOLON) return {kind: return, expr: expr} if t TokenType.IF: self.advance() self.expect(TokenType.LPAREN) cond self.parse_assignment() self.expect(TokenType.RPAREN) then_stmt self.parse_statement() else_stmt None if self.peek()[type] TokenType.ELSE: self.advance() else_stmt self.parse_statement() return {kind: if, cond: cond, then: then_stmt, else: else_stmt} if t TokenType.WHILE: self.advance() self.expect(TokenType.LPAREN) cond self.parse_assignment() self.expect(TokenType.RPAREN) body self.parse_statement() return {kind: while, cond: cond, body: body} if t TokenType.LBRACE: return self.parse_block() # { stmt stmt ... } # 默认走表达式语句 expr self.parse_assignment() self.expect(TokenType.SEMICOLON) return {kind: expr_stmt, expr: expr} def parse_block(self): self.expect(TokenType.LBRACE) stmts [] while self.peek()[type] ! TokenType.RBRACE: stmts.append(self.parse_statement()) self.expect(TokenType.RBRACE) return {kind: block, stmts: stmts} def parse_function(self): 解析函数定义int 函数名(参数表) { 函数体 } self.expect(TokenType.INT) name_tok self.expect(TokenType.IDENTIFIER) self.expect(TokenType.LPAREN) params [] while self.peek()[type] ! TokenType.RPAREN: self.expect(TokenType.INT) param_tok self.expect(TokenType.IDENTIFIER) params.append(param_tok[value]) if self.peek()[type] TokenType.COMMA: self.advance() self.expect(TokenType.RPAREN) body self.parse_block() return {kind: function, name: name_tok[value], params: params, body: body}这里的一个关键细节是局部变量声明解析parse_local_declaration要能处理两种形态int a;和int a 10;。前者在符号表里登记变量但值为 0或者未初始化标记后者走一遍赋值表达式。区块节点block很关键——它天然形成了作用域边界后面做语义分析时进入 block 就要压一个新的作用域栈。另外注意parse_statement里 if 和 while 的 condition 用的是parse_assignment这样能让if (a 2)这种赋值作为条件合法——虽然正常 C 代码会写if (a 2)但编译器不应该在语法层面禁止前者那是语义检查比如 with 警告的事。4. 解释执行与 C 语言子集选择先让代码跑起来4.1 选解释执行还是生成机器码教学项目的最优解编译器写完 AST 之后有两种落地方式一是继续做代码生成把 AST 变成汇编语言或 LLVM IR最后变成可执行文件二是在 AST 上直接做解释执行evaluation遍历树的节点边遍历边算结果。对于用 Python 写的 C 语言编译器我建议第一步做解释执行——就写一个eval函数接受 AST 节点和环境变量表返回节点计算结果。原因很实际生成真正的机器码涉及寄存器分配、指令选择、栈帧管理这些内容叠加进来项目的复杂度会瞬间暴涨你很可能在理解完语法树之前就被汇编细节耗光了热情。而且解释执行能得到一个额外的红利调试极其方便。Python 的栈上你直接能看到 AST 节点长什么样、当前变量的值是多少。很多商业编译器早期原型也是这么搞的——先把语义搞清楚再做优化和代码生成。等你把解释器跑通了再回头加一个「遍历 AST 输出 LLVM IR」的模块那是在已有骨架上长肉难度完全不同。4.2 符号表与作用域链变量重名不串台的秘密解释器需要一个符号表来存变量值。C 语言是块级作用域内层 block 声明的变量不能在外层访问内层可以遮蔽shadow外层同名变量。用 Python 实现这个我用一个栈来维护作用域链——每个作用域是字典新进入一个 block 就 push 一个空字典离开就 pop。# interpreter.py —— AST 解释器核心 class Interpreter: def __init__(self): # 作用域链列表里每一项是一个字典 self.scope_stack [{}] def push_scope(self): self.scope_stack.append({}) def pop_scope(self): self.scope_stack.pop() def lookup_var(self, name: str): # 从内到外查找变量 for scope in reversed(self.scope_stack): if name in scope: return scope[name] raise NameError(f未定义的变量 {name}) def set_var(self, name: str, value): # 赋值时从左到右找第一个命中作用域 # 如果全部没命中就存在最内层宽松策略 for scope in reversed(self.scope_stack): if name in scope: scope[name] value return self.scope_stack[-1][name] value def eval_block(self, node): self.push_scope() result None for stmt in node[stmts]: result self.eval(stmt) # return 语句的结果要一直向上传递 if isinstance(result, ReturnSignal): self.pop_scope() return result self.pop_scope() return result这里要特别解释lookup_var和set_var的区别。查变量必须从最内层作用域往外找这是 C 语言遮蔽规则。赋值时也类似——但有一个微妙的陷阱如果你在函数体内写x 1而外层没有x的定义C 语言会报编译错误变量未声明但 Python 解释器如果直接给最内层作用域加一个新变量就会悄悄把错误掩盖掉。我的做法是在set_var里做了宽松处理这对教学原型还算友好。如果你想严格对齐 C 的语义应该改成查不到就抛NameError并且把int x 10;这种声明语句的赋值单独走一个declare_var路径。ReturnSignal是一个哨兵类专门用来传递 return 的返回值。为什么不用普通函数返回值因为一个 return 语句可能出现在嵌套很深的条件分支里eval函数递归调用时每一层都要判断「这个结果是正常值还是要提前返回的信号」。用哨兵类或者专门包装类比用特殊的数字值比如 None、0安全得多——因为一个合法的 C 函数本来就可以返回 0用 0 当信号会混淆。4.3 表达式求值与运行时类型一元负号、比较运算的细节表达式求值是解释器最核心的执行逻辑。AST 节点有几种类型数字节点直接返回值、变量节点查符号表、二元运算节点先递归求左右子树再算、赋值节点先求右值再写左变量。下面给出eval方法的骨架和具体实现。def eval(self, node): kind node[kind] if kind number: return node[value] if kind var: return self.lookup_var(node[name]) if kind expr_stmt: return self.eval(node[expr]) if kind assign: val self.eval(node[right]) # 现在只支持赋值给变量 self.set_var(node[left][name], val) return val if kind binary: left self.eval(node[left]) right self.eval(node[right]) op node[op] if op : return left right if op -: return left - right if op *: return left * right if op /: if right 0: raise RuntimeError(除零错误) return left // right if isinstance(left, int) else left / right if op %: return left % right if op : return int(left right) # C 没有 bool 类型用 0/1 if op !: return int(left ! right) if op : return int(left right) if op : return int(left right) if op : return int(left and right) if op ||: return int(left or right) raise NotImplementedError(f不支持的二元运算 {op}) if kind unary: val self.eval(node[operand]) if node[op] -: return -val if node[op] !: return int(not val) raise NotImplementedError(f不支持的一元运算 {node[op]}) if kind if: cond self.eval(node[cond]) if cond ! 0: return self.eval(node[then]) elif node[else] is not None: return self.eval(node[else]) return None if kind while: count 0 while self.eval(node[cond]) ! 0: result self.eval(node[body]) count 1 if count 100000: # 防止真死循环卡死解释器 raise RuntimeError(循环次数超过 100000疑似死循环) return None if kind return: val self.eval(node[expr]) raise ReturnSignal(val) raise NotImplementedError(f未知节点类型 {kind})几个参数和策略值得说明。第一个是除法/的处理C 语言里整数相除是整除丢弃小数所以当两个操作数都是int时用//如果后面扩展了浮点类型这一行要改成根据操作数类型做不同运算。这也是为什么一开始只支持int是最优选择——先跑通链路再补类型。第二个是逻辑比较的返回值C 语言没有布尔类型、这些运算的结果是1或0int所以int(left right)这个转换是必须的。第三个是 while 循环的保险丝——100000次上限。这个是血泪教训手写解释器跑一个没写自增的while循环Python 进程会直接卡死加个上限能让你在演示现场保住体面。4.4 函数调用参数传递与帧栈的最小实现没有函数的 C 子集没灵魂。实现函数调用核心是环境隔离——每个函数执行时要有一份独立的局部变量表参数在调用时绑定进去。下面的call_function实现把参数绑定做到一个新作用域里并压入全局作用域链之上。class ReturnSignal(Exception): def __init__(self, value): self.value value def call_function(self, func_node, args): if len(args) ! len(func_node[params]): raise TypeError(f函数 {func_node[name]} 需要 {len(func_node[params])} 个参数 f实际给了 {len(args)} 个) # 建立调用栈帧压入一个新的作用域 self.push_scope() for param, val in zip(func_node[params], args): self.scope_stack[-1][param] val try: result self.eval(func_node[body]) # 函数没有 return 时返回 0C 语言默认行为 return 0 if result is None else result except ReturnSignal as sig: return sig.value finally: self.pop_scope()这个实现的精妙之处在于用 Python 的异常机制做非局部跳转它把 return 信号的传递从「每层递归都检查返回值」的负担里解放了。finally: self.pop_scope()保证了无论函数是正常结束还是被 return 打断作用域都能正确弹出。不过这里有个边界函数体的 block 本身在eval时还会 push 一次作用域所以函数执行时实际上是两个新作用域叠加——一个放参数一个放函数体局部变量。这不会引起问题因为参数保存在外层函数体局部变量保存在内层两个作用域临时共存。只是如果你在后面做静态分析时按「函数帧」来建模要记得这两层的关系。5. 避坑指南用 Python 写 C 编译器最容易翻车的 5 个地方5.1 左递归写法导致 RecursionError程序瞬间崩溃现象解析a b c时Python 抛出RecursionError: maximum recursion depth exceeded。原因parse_additive的实现写成了「先递归调用自己再去读运算符」而不是先读一个操作数再用 while 循环消费后续运算符。左递归在递归下降里是死路——每次调用自己不消费任何 Token栈就永远不回来。解决所有二元运算的解析统一改成「先解析一个最低优先级单元然后 while 循环看下一个运算符是否属于本级优先级」。这个问题的本质是「用递归表达循环」识别的标志是函数的第一行调用它自己。5.2 赋值与相等判断混淆a b被当成条件现象if (a 2)这种代码在执行时把 2 赋给了 a而且条件永远为真。原因expression 解析时把和放在同一优先级处理了甚至parse_equality比parse_assignment更早拦截了等号。解决按照 C 语言优先级表赋值是优先级最低的运算parse_assignment必须在调用链的最顶层同时的处理要走右结合分支。我建议你在写解析器时单独打一个优先级函数调用关系表贴在代码注释里改代码时对照着看就不会再犯。5.3 符号表作用域没恢复函数内部变量「泄漏」到全局现象函数里声明了int i;函数执行完回到主流程发现外面也莫名其妙多了个i。原因call_function里的push_scope和finally: pop_scope没有配对或者 block 求值遇异常时没有执行作用域弹出。解决给作用域个入栈出栈做一个规范的配对管理。我用的是一个笨但有效的办法在每个能 push_scope 的函数入口加检查——「如果这个函数存在多个 return 路径必须用 try/finally 包住整个函数体」。Python 的异常传播不会自动帮你做作用域清理这一点和 C 的栈帧不同必须手动兜底。5.4 字符字面量与整数的混用A被当成字符串现象执行int a A;时a 得到的不是 65而是字符串A后续所有算术运算全部 TypeError。原因词法分析器没有把单引号字符字面量单独处理而是走了一般字符串分支。解决在词法扫描循环里单独加一个ch 的分支读一个字符后用ord()转 ASCII 码Token 类型设为NUMBER或专门的CHAR。这里给个提示字符字面量支持转义\n处理方式与字符串转义完全一致只是结果只取一个字符。5.5 注释处理位置错误/*里的内容被当代码解析现象注释里写了a 1;而这一行也被执行了。原因注释跳过逻辑放在运算符分发之后/先被匹配成了除号运算符。解决注释的跳过必须放在运算符分发之前——实际上应该放在扫描循环的最前面紧跟空白字符跳过之后。词法分析器的「跳过」优先级应该是空白 → 注释 → 预处理指令 → 字面量 → 运算符。这个顺序本身就是一个常见的隐式约定打乱它必然出鬼。6. 验证与进阶让编译器跑起来只是开始怎么验证它对不对解释器写完了第一件事不是写更多功能而是建立一套最小回归测试。我的做法是写一个 100 行的测试脚本里面放几十个 C 语言小程序片段每个片段断言输出值。比如算1 2 * 3应该得 710 / 4应该得 2C 整数除法if (2 1) return 5; else return 6;应该得 5。每加一个新语法特性就把对应用例加进测试列表跑一遍全量。这比你在命令行里手敲代码验证要可靠一个量级——手敲你只会测你记得的场景测试脚本能覆盖你改挂的旧功能。# test_compiler.py —— 最小回归测试框架 import sys from lexer import Lexer from parser import Parser from interpreter import Interpreter TEST_CASES [ (int main() { return 1 2 * 3; }, 7), (int main() { int a 10; int b 4; return a / b; }, 2), (int main() { if (2 1) return 5; else return 6; }, 5), (int main() { int i 0; int s 0; while (i 5) { s s i; i i 1; } return s; }, 10), (int add(int x, int y) { return x y; } int main() { return add(2, 3); }, 5), (int main() { int a 3; int b 2; a a * b; return a; }, 6), ] def run_one(source: str): tokens Lexer(source).tokenize() ast Parser(tokens).parse() return Interpreter().execute(ast) def main(): passed 0 for src, expected in TEST_CASES: try: result run_one(src) status PASS if result expected else FAIL if status FAIL: print(f[{status}] 源码: {src[:40]}... 期望 {expected}实际 {result}) else: passed 1 except Exception as e: print(f[ERROR] 源码: {src[:40]}... 异常: {e}) print(f通过 {passed}/{len(TEST_CASES)} 个用例) if __name__ __main__: main()测试通过后进阶方向我按难度排一条路线先是给语言加for循环和break、continue——这需要你动解释器的控制流逻辑但语法层面只是加几个 Token然后是加double浮点类型这会逼你处理「运行时类型判断」和隐式转换再往后是指针和数组这是 C 语言的灵魂但你的符号表要从「名字→值」升级成「名字→内存对象」这标志着你开始做真正的内存模型。你也可以跳过解释执行直接做代码生成——把 AST 转成 LLVM IR用llvmlite这个 Python 库在本地编译出真正的机器码那是另一座山峰。我个人最喜欢的调试技巧是把 AST 用 JSON 打印出来缩进两格一目了然——写个dump_ast函数递归打印比 gdb 还好用。做这个项目最大的教训是一开始别贪多就支持int、return、if/else、while和一个main函数跑通全链路后再加功能。我见过太多人一上来就想支持struct、switch、位运算结果词法分析器写了一千行语法分析还卡在表达式优先级上最后全盘放弃。保持子集足够小是保证你能走到解释器这一步的唯一策略。希望帮到你。本文还有配套的精品资源点击获取
返回列表