我第一次真正把“解释”和“编译”这件事想通,不是在看编译原理教材的时候,而是在折腾 Lua 的 C API 时突然开窍的。那个瞬间我才意识到:一个用 C 语言写出来的 Lua 解释器,可以让我在 C 程序里执行 Lua 脚本;而 Python 的官方实现 CPython,本质上也是一个用 C 写的解释器,平时我们运行.py文件时,它早就在内部把源码编译成了字节码。所以那个说“你也可以解释 C 并编译 Python”的标题,并不是什么奇谈,它想说的其实是同一件事:语言之间的边界远没有想象中那么硬,只要你理解了“解释”和“编译”背后的机制,你既可以用 C 去解释一门语言,也可以把一门语言编译成另一种形式。
这篇文章我不会去复述编译原理课的完整内容,而是想从 Lua、C、Python 三者的真实运行过程出发,带你亲手验证几个关键节点。你会看到,嵌入式脚本、字节码、解释器、编译器这些概念,原本就是同一套思维在不同方向上的延伸。读完以后,你至少能回答三个问题:Lua 是怎么被 C 嵌入的?Python 的字节码长什么样?如果要自己写一个迷你解释器,第一步该做什么?
1. 先搞懂“解释”和“编译”不是语言的属性,而是执行策略
很多初学者会有一种根深蒂固的分类法:C 是编译型语言,Python 是解释型语言,Lua 也是解释型语言。这个说法在谈“典型实现”时勉强够用,但只要深入一点,它就会误导你。因为“解释”和“编译”描述的是语言实现时的执行策略,而不是语言本身天生的属性。同一门语言,既可以被解释执行,也可以被编译执行,甚至可以在同一套运行时里同时进行“编译”和“解释”。
1.1 一个编译型语言也可以被解释:C 的另一种执行方式
我们通常说 C 是编译型语言,是因为大多数 C 程序会经过预处理、编译、汇编、链接四个阶段,最终生成机器码,之后由操作系统直接加载执行。但这不代表 C 只能被编译。“解释 C”虽然听起来反直觉,实际上早在几十年前就有过专门研究。现在也有很多教学用途的小型 C 解释器项目,它们不一定覆盖完整的 C 标准,但能执行 C 的一个子集:支持变量、函数调用、循环、指针,甚至结构体的一部分。这类解释器做的事情,和解释 Python 脚本本质上是相同的:读入源码文本,解析成语法结构,然后一边遍历一边求值。
当然,完整解释 C 并不容易。因为 C 涉及指针运算、内存布局、类型强转、预处理等大量底层语义,要对这些语义逐一生效,工程量不亚于写一个完整的编译器。所以更实际的做法是:先做一个能解释“C 表达式子集”的玩具实现,用来理解核心原理。
1.2 Python 也会“编译”:从源码到字节码
Python 的运行时路径其实很清晰:当你写下python test.py时,解释器并不是直接逐行解释.py文件。它先把整个源码文件经过词法分析和语法分析,生成一棵抽象语法树,再把抽象语法树编译成 Python 字节码,最后交给 Python 虚拟机逐条执行。这个“编译”步骤是真实存在的,只是它生成的不是机器码,而是一种跟具体机器无关的字节码。你甚至可以用内置函数compile()手动触发这一步,再用dis模块看字节码。
所以,如果你说“Python 是解释型语言”,其实是忽略了它的编译阶段。更准确的说法是:CPython 是一个以“先编译成字节码,再用虚拟机执行字节码”为主要运行方式的实现,它的对外体验表现为“解释执行”。
1.3 两种执行策略的本质差异
我们可以用一个表格把几个典型实现放在一起看:
| 语言/实现 | 典型阶段 | 最终执行物 | 执行方式 |
|---|---|---|---|
| 传统 C 编译器 | 源码 → 汇编 → 机器码 | 可执行文件 | 操作系统直接执行机器码 |
| C 解释器(教学型) | 源码 → AST | AST | 解释器遍历 AST 求值 |
| Lua 官方解释器 | 源码 → Lua 字节码 | Lua 字节码 | Lua 虚拟机执行字节码 |
| CPython | 源码 → Python 字节码 | Python 字节码 | Python 虚拟机执行字节码 |
| PyPy | 源码 → 字节码 → 机器码 | 机器码 | JIT 编译器运行时生成机器码 |
这些路径的根本区别在于“谁来最终执行程序”。如果是一个程序去逐条读取另一种形式的指令并完成操作,那就是解释;如果是先翻译成机器能执行的指令并保存下来,那就是编译。理解了这一点,你就会发现“解释 C”和“编译 Python”并不是什么魔法,只是换了一条执行路径而已。
2. Lua 为什么是理解这一切的最佳切入点
Lua 是一门口子很小的脚本语言,但它的实现非常精致。和 Python 动辄几十万行源码不同,Lua 官方解释器的核心代码量很小,适合阅读。而它最让我觉得有价值的地方,是它无处不在的“C 与脚本”的双向关系:Lua 解释器本身用 ANSI C 编写,所以你可以用 C 写一个宿主程序,再嵌入 Lua 脚本;反过来,Lua 也可以调用 C 函数,因为 Lua 的运行时天然留好了 C API 的接口。
2.1 Lua 的设计目标:从实验室数据到可嵌入脚本
Lua 诞生于巴西的 PUC-Rio 大学,早期是为了给石油数据分析程序写配置文件。它的核心设计目标就是“可嵌入”。这意味着 Lua 不希望成为独立的应用程序,而是希望作为一个库,被别的语言(通常是 C/C++)链接进去,作为它们的脚本引擎。正是因为这种定位,Lua 解释器对资源占用非常克制,启动速度很快,运行时可以被控制得很小。
这个设计目标直接决定了 Lua 与 C 的关系:Lua 的每一次执行都必须有一个lua_State,也就是一个 Lua 的运行时状态。你可以把它理解成一个“独立的虚拟世界”,C 代码负责创建这个状态、向里面推入脚本、再从中取出结果,然后销毁这个状态。整个过程都由 C 代码主动控制。
2.2 Lua 解释器本身就是用 C 写的,这是天然的“C 与脚本”结合点
Lua 的官方实现源码主要分为几块:llex.c负责词法分析,lparser.c负责语法分析,lcode.c负责把语法树生成字节码,lvm.c负责执行字节码的虚拟机。这些文件全部用 C 编写。所以,当你运行一个 Lua 脚本时,实际上是这样一个链条:
Lua 脚本 → 词法分析 → 语法分析 → 生成 Lua 字节码 → Lua 虚拟机逐条执行这个链条并不神秘,但它的关键点在于:虚拟机是 C 写的。也就是说,Lua 字节码的执行,最终是 C 函数在操纵内存和寄存器。这其实已经是一种“C 解释 Lua”的典型实现。而标题里“你也可以解释 C”如果要找一个落点,就可以理解为:你可以用 C 写出一个解释器(比如 Lua 解释器的子集),或者更直白一点,你可以在 C 程序里“解释执行”另外一门脚本语言。
2.3 最小实践:第一次用 C 代码嵌入 Lua
我们做一个最简单但足够真实的实验:写一个 C 程序,它创建一个 Lua 运行时状态,然后直接执行一段 Lua 字符串。环境上,你需要先安装 Lua 开发库(比如 Debian/Ubuntu 上的liblua5.4-dev,或者从 Lua 官网下载源码自己编译)。下面是一个最小示例:
#include <stdio.h> #include "lua.h" #include "lauxlib.h" #include "lualib.h" int main(void) { // 创建一个新的 Lua 运行时状态 lua_State *L = luaL_newstate(); // 打开标准库,比如 print、string、table luaL_openlibs(L); // 在 Lua 虚拟机里执行一段脚本 const char *script = "local a, b = 2, 3\nprint('a + b =', a + b)"; if (luaL_dostring(L, script) != LUA_OK) { // 出错时,错误消息被压到栈顶,我们取出来打印 fprintf(stderr, "Lua error: %s\n", lua_tostring(L, -1)); lua_close(L); return 1; } lua_close(L); return 0; }在大多数 Linux 系统上,使用 gcc 编译时可能需要链接 Lua 库和数学库:
gcc -o lua_embed_demo lua_embed_demo.c -llua -lm -ldl运行./lua_embed_demo,你应该会看到:
a + b = 5这个例子看起来简单,但它包含了一个非常本质的过程:C 程序把 Lua 脚本作为文本数据交给 Lua 解释器,解释器内部完成词法、语法、编译、执行,再把结果返回给 C。这个过程里,C 是宿主,Lua 是脚本,边界就是中间那一层 C API。
这里的 C API 最让人困惑的是“栈”的概念。Lua 不直接让 C 访问 Lua 的变量,而是通过一个虚拟栈来交换数据。你调用lua_pushnumber把一个数字压入栈,调用lua_call让 Lua 调用函数,参数在栈上,返回值也在栈上。这种设计避免了 C 和 Lua 之间复杂的内存模型耦合。如果你第一次接触,很容易出现“栈不平衡”的报错。我的习惯是:每次写 C-API 交互,先想清楚“进入时栈上有几个元素,调用后栈上还剩几个”,再动手写代码。
注意:
luaL_dostring在出错时会返回非零值,并把错误信息留在栈顶。实际项目中,一定要检查返回值并取出错误信息,否则出问题时你只看到一个神秘的非零退出码。
3. 你也可以解释 C:一个递归下降表达式子集解释器
现在我们把“解释 C”这句话落到实处。完整实现一个 C 编译器或解释器当然不是一篇博客能完成的事,但理解“解释”的核心逻辑,完全可以从一个很小很小的表达式子集开始。我们可以用 Python 写一个解释器,它能解析类似2 + 3 * 4 - (1 + 2)这样的算术表达式,然后计算出结果。虽然这不是完整的 C 语法,但它已经覆盖了解释器的两个重要阶段:词法分析和语法分析。
3.1 别急着做完整 C,先做“C 表达式子集”
为什么从表达式开始?因为表达式是几乎所有编程语言里最核心的语法单元之一。C 语言的if、for、函数调用,最终都离不开对表达式的求值。如果你能做表达式解释器,再往上加变量、函数、语句,就会顺理成章。
我见过很多学习者的误区:一开始就想实现一个“能编译 C 的编译器”,结果在词法分析阶段就写了上千行代码,然后被各种边界情况淹没。更聪明的做法是先限定范围:只支持加减乘除、括号、整数和一元负号。先把这条狭窄但完整的小路走通,再逐步拓宽。
3.2 从一串字符到一棵树:词法分析和语法分析
解释器拿到源码文本后,第一步是把字符串切成一个个有意义的 token(记号)。比如"2 + 3 * 4"会被切成:
数字 2 加号 数字 3 乘号 数字 4这一步叫词法分析,每个 token 都带有类型和值。接下来,语法分析器会按照语言的规则把这些 token 组成一棵抽象语法树(AST)。表达式2 + 3 * 4的 AST 大致是这样:
+ / \ 2 * / \ 3 4注意,乘号这层的优先级比加号高,所以它在树中更靠近叶子。解释器之后只需要对这棵树做一次后序遍历,就能得到结果。
3.3 用 Python 写一个迷你解释器:解析并求值
下面是一个足够小的递归下降解析器实现。它支持加减乘除、括号、整数,以及一元负号。为了简化,我把词法分析和语法分析写在了一起,用递归函数之间的关系来体现运算优先级。
import re # 简单的 token 切分:匹配数字、四则运算、括号、一元负号 def tokenize(code): token_pattern = re.compile(r'\s*(?:(\d+)|([+*\-/( )]))') tokens = [] pos = 0 while pos < len(code): match = token_pattern.match(code, pos) if not match: raise SyntaxError(f"无法识别字符位置 {pos}: {code[pos]!r}") num, sym = match.groups() if num is not None: tokens.append(('NUM', int(num))) elif sym is not None: tokens.append(('SYM', sym)) pos = match.end() return tokens class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 def peek(self): if self.pos < len(self.tokens): return self.tokens[self.pos] return ('EOF', '') def next(self): token = self.peek() self.pos += 1 return token # expression := term { (+|-) term } def parse_expression(self): node = self.parse_term() while self.peek()[1] in ('+', '-'): op = self.next()[1] right = self.parse_term() node = (op, node, right) return node # term := factor { (*|/) factor } def parse_term(self): node = self.parse_factor() while self.peek()[1] in ('*', '/'): op = self.next()[1] right = self.parse_factor() node = (op, node, right) return node # factor := NUMBER | - factor | '(' expression ')' def parse_factor(self): token = self.next() k, v = token if k == 'NUM': return ('NUM', v) if v == '-': child = self.parse_factor() return ('NEG', child) if v == '(': node = self.parse_expression() self.next() # 跳过右括号 ),这里简单假设括号匹配 return node raise SyntaxError(f"意外的 token: {token}") def evaluate(node): if node[0] == 'NUM': return node[1] if node[0] == 'NEG': return -evaluate(node[1]) op, left, right = node lv, rv = evaluate(left), evaluate(right) if op == '+': return lv + rv if op == '-': return lv - rv if op == '*': return lv * rv if op == '/': return lv / rv raise ValueError(f"未知操作符: {op}") def interpret(code): tokens = tokenize(code) ast = Parser(tokens).parse_expression() return evaluate(ast) if __name__ == '__main__': test_cases = [ "2 + 3 * 4", "(2 + 3) * 4", "10 - 2 * 3 + 1", "-5 + 6", ] for code in test_cases: print(f"{code} = {interpret(code)}")运行这段代码,你会得到:
2 + 3 * 4 = 14 (2 + 3) * 4 = 20 10 - 2 * 3 + 1 = 5 -5 + 6 = 1这个实现没有做复杂的优化,但它已经是一个完整的“解释器”:把文本解析成树,再对树求值。如果你把这里的evaluate改成“生成汇编指令”或者“生成 Lua 字节码”,它就已经跨向了“编译”的方向。所谓“你也可以解释 C”,在这个小例子里,指的就是你已经具备了解释 C 语言表达式子集的能力。从这里继续扩展,加入变量存储、函数定义、条件语句,你就能慢慢接近一个真正的小型 C 解释器。
3.4 从解释器走向编译器:关键一步是“生成代码”
解释器拿到 AST 后直接计算,而编译器拿到 AST 后不直接计算,而是生成一种中间表示,比如字节码或汇编。以 Lua 为例,Lua 的解释器实际上也有一个编译阶段:它把源码解析成 AST,然后把 AST 转成 Lua 字节码,交给虚拟机。你可以用luac -l命令查看一个 Lua 脚本编译出来的字节码列表。这样你就看到了“解释”和“编译”之间并非对立:很多实现是“先编译成字节码,再解释执行字节码”。
4. 你也可以编译 Python:从源码到字节码再到 C 扩展
现在我们转向 Python。很多人说 Python 是“边解释边执行”,但如果你真的调试过程序,会发现它其实有一个非常明确的“编译”动作。理解这个动作,你就会明白标题的后半句:“你也可以编译 Python”不是一句口号,而是每个 Python 开发者每天都实际经历的过程。
4.1 Python 的编译过程:用 compile() 和 dis 看真实字节码
Python 内置的compile()函数可以把一段源码编译成一个 code 对象,然后你可以用dis模块查看它的字节码。下面是一个简单例子:
import dis source = "a = 1\nb = 2\nc = a + b" co = compile(source, "<string>", "exec") print(co) dis.dis(co)输出会是一串包含指令名的行,例如:
1 0 LOAD_CONST 0 (1) 2 STORE_NAME 0 (a) 2 4 LOAD_CONST 1 (2) 6 STORE_NAME 1 (b) 3 8 LOAD_NAME 0 (a) 10 LOAD_NAME 1 (b) 12 BINARY_OP 0 (+) 14 STORE_NAME 2 (c) 16 LOAD_CONST 2 (None) 18 RETURN_VALUE这段字节码已经非常接近 Lua 字节码的设计:它也是一种“虚拟机器指令”,由 CPython 虚拟机解释执行。你在终端里运行python -m py_compile test.py,就会在__pycache__目录下生成一个.pyc文件,这个文件里存的就是编译后的字节码,相当于“编译 Python”的产物。下一次再运行同一个脚本时,CPython 会优先检查.pyc文件是否存在以及源码时间戳是否变化,如果没变就跳过重新编译,直接加载字节码。理解了这一点,你就能回答“为什么 Python 第一次启动慢但后续运行有时更快”的部分原因了。
4.2 CPython 本身就满足“用 C 解释 Python”
“用 C 解释 Python”听起来很厉害,但其实 CPython 就是这样的存在。CPython 是用 C 编写的 Python 官方实现,它包含了一套完整的 C 代码,负责词法分析、语法分析、编译为字节码、执行字节码,以及维护对象系统、内存管理、垃圾回收。
所以,如果你说“我用 C 解释了 Python”,在抽象层面,你其实在做和 CPython 一样的事,只是工程量还差很远。真正要掌握的是:Python 对象在 C 层是什么?它怎么和 Lua 一样通过“栈”或者“引用计数”来让 C 函数和 Python 对象互操作?这是下一节我们要看的。
4.3 实操:让 Python 调用 C 函数,体验“跨语言编译”
Python 和 C 互操作有很多方式,最简单、最省事的是用标准库ctypes。它可以在运行时动态加载一个 C 库,并调用其中的函数。比如我先写一个 C 文件,编译成共享库:
#include <stdint.h> int64_t add_int64(int64_t a, int64_t b) { return a + b; }用 gcc 编译成共享库(Linux 上是.so,macOS 上是.dylib,Windows 上是.dll):
gcc -shared -fPIC -o libmyadd.so myadd.c然后在 Python 中加载并调用它:
import ctypes lib = ctypes.CDLL("./libmyadd.so") lib.add_int64.argtypes = [ctypes.c_int64, ctypes.c_int64] lib.add_int64.restype = ctypes.c_int64 result = lib.add_int64(100, 23) print(result) # 123这个例子里,Python 编译产生的字节码在执行到lib.add_int64(100, 23)时,会通过 ctypes 调用 C 共享库里的函数。这其实就是“Python 程序调用 C 代码”的典型路径之一。如果你深入下去,可以用 Python C API 写一个真正的扩展模块,让 Python 直接import一个 C 编译出的扩展。它的核心要素是:写一个 C 文件,包含Python.h,定义导出函数,注册方法表,然后调用PyArg_ParseTuple解析 Python 传入的参数,再返回PyLong持有 C 的整型结果。这个过程的复杂点在于 Python 对象的引用计数管理,稍不留神就会造成内存泄漏或崩溃。
注意:在 Python 的 C 扩展里,一定要正确处理引用计数。常见准则是:大多数函数返回“新引用”,你需要用
Py_DECREF在不需要时释放;有的函数返回“借用引用”,你则不能随意释放。这块比较绕,初学者最好先用ctypes或cffi这类高层封装,等理解对象模型后再接触底层的 CPython API。
4.4 打包成可执行文件并不是真正的“编译 Python”
很多人会用 PyInstaller、Nuitka 把 Python 程序打包成单个可执行文件,然后说“我把 Python 编译成了二进制”。这里要区分一下:PyInstaller 主要做的是把 Python 解释器、依赖库和你的脚本打包在一起,运行时仍然是解释执行,本质上是分发了一个“自带运行时”的压缩包。Nuitka 则更进一步,会把 Python 代码转换成 C 或 C++ 代码,再编译成机器码,它的产品形态更接近“编译 Python”。但无论哪种,都比直接用解释器执行复杂得多,而且不一定适合所有项目。如果你想深入了解,可以从 Nuitka 的原理入手,但不要指望它解决所有部署问题。
5. 跨语言集成的工程边界和避坑指南
从“Lua 嵌入 C”到“Python 调 C”,跨语言集成并不总是一帆风顺。我踩过很多坑,这里挑几个最典型的说。
5.1 嵌入 Lua 时最常见的 5 个坑
栈操作不平衡:Lua C API 使用虚拟栈交换数据。每调用一次
lua_push*,栈上多一个元素;每调用一次lua_pop,栈上少一个元素。如果函数返回时栈里残留了多余数据,轻则影响后续调用,重则导致无法预测的行为。调试时可以在关键位置打印lua_gettop(L)看栈深。链接库版本不匹配:编译时
#include "lua.h"找到的头文件和运行时-llua链接到的库版本不一致,可能会报出奇怪的符号错误。先用pkg-config --cflags --libs lua5.4之类的命令确认路径,再编译。错误信息没有取回:
lua_pcall调用失败后,错误对象留在栈顶。如果你不取出来,它就一直留在那里。正确做法是lua_tostring(L, -1)后立刻lua_pop(L, 1)。多个 Lua state 互相干扰:每个
lua_State *是独立的运行时状态。如果你在同一个程序里创建多个 state,它们之间默认不共享任何全局变量。如果你的业务里必须共享数据,要显式设计跨 state 的通信方案,比如通过 C 层的数据结构。没有设置安全边界:Lua 脚本其实可以调用
os.execute这样的函数。如果脚本来自外部不可信输入,嵌入时需要删掉危险库函数或设置 hook 限制执行时间。这不是理论问题,而是生产环境真正的安全风险。
5.2 Python 调用 C 时的常见问题
GIL 的影响:CPython 的全局解释器锁限制同一时刻只能有一个线程执行 Python 字节码。如果你的 C 扩展函数里执行了耗时计算,必须先正确释放 GIL,否则会影响其他 Python 线程。对这个机制不熟悉时,尽量不要在 C 扩展里做长任务。
对象生命周期:用 C 扩展处理 Python 对象时,要特别小心引用计数。一个函数从参数里拿到的 Python 对象可能是“借用引用”,你不该对它做
Py_DECREF;而从某些接口返回给你的可能是一个“新引用”,你必须负责释放。一旦搞混,轻则内存泄漏,重则段错误。ABI 兼容:不同的 Python 版本对应的 C API 有细微差别。在 Python 3.x 内部,小版本之间通常保持向后兼容,但如果你用了较新的 API,在旧版本 Python 中可能无法编译。编译扩展时,最好使用与目标 Python 版本匹配的头文件和编译器。
ctypes 的坑:
ctypes虽然简单,但它要求你知道 C 函数的参数和返回值类型。如果你漏写argtypes和restype,默认会按int处理,指针或 64 位整数就会出错,甚至导致进程崩溃。所以看 C 函数声明时,第一时间把它转换成argtypes/restype。
5.3 永远不要试图“完整解释所有 C”或“编译所有 Python”
我做这类实验时,会给自己设一个很明确的范围。要真正实现一个完整 C 解释器,你所面对的是 ISO C 标准中数不清的边角情况:位域、结构体对齐、未定义行为、预处理器的宏展开、头文件搜索路径……这些内容随便拿出一个都能写几十篇文章。同样,要“编译”所有 Python 代码,意味着你要处理动态类型、装饰器、元类、生成器、协程、动态导包等高度动态的特性,这也是为什么 JIT 编译器会那么复杂。
所以,作为个人学习和实验,最好的策略是:选一个极小的语言子集,把整条链路跑通。等你知道“整条链路”长什么样,再逐步增加特性,远比一上来就想“做大而全”要现实。
5.4 这种学习方式适合谁,不适合谁
如果你是一个应用开发者,主要工作是写业务逻辑,那么你不需要真的去实现一个解释器。但如果你经常遇到以下问题,就很值得花时间走一遍这条路:
- 脚本引擎报错时,你完全看不懂底层在干什么;
- 你想在 C/C++ 程序里嵌入脚本,却不知道如何封装;
- 你想给 Python 写性能关键型扩展,却不知道 C 扩展怎么组织;
- 你调式
RuntimeError之外的底层崩溃,比如段错误,却完全不知道从何入手。
反之,如果你的目标是快速落地业务功能,暂时不需要触碰底层,那这一块可以往后放。它不是“必学”,但确实是很多高级开发者的分水岭。
6. 从“你可以”到“你正在”:一条可复制的实践路径
最后,我想把这条学习路径收拢成一个可以照着执行的清单。我自己走完一遍后,最大的体会是:不要被“编译器”三个字吓住,把它拆成几个可以单独练习的阶段,你就能一步一步走下来。
6.1 第一步:先跑通一个最小嵌入示例
从本文的 C 嵌入 Lua 示例开始,或者从 Python 的compile()/dis.dis()开始。目标不是写出复杂代码,而是亲眼看到“脚本语言在另一种语言里被执行”的过程。这一步通常只需要两个小时,但它会让你对“解释器”突然有了直观概念。
记下你看到的结果,然后在脑海里画出一条线:文本 → token → AST → 中间表示 → 执行。这条线是所有语言实现的骨架。
6.2 第二步:阅读一小段真实源码
不要求读完整,只挑你最感兴趣的部分。Lua 源码里,可以先去读llex.c中关于 token 类型的定义,或者在lvm.c里找几条字节码指令的case。CPython 源码里,可以读Python/ceval.c中一条指令(比如BINARY_OP)的执行逻辑。你会发现,真实解释器的核心循环并不神秘,很多时候就是一个巨大的switch语句。
阅读时带着两个问题:这个指令从哪里取操作数?结果放在哪里?你很快就能理清寄存器和栈的概念。
6.3 第三步:写一个自己的玩具语言
不要叫它语言,叫它“小计算器”更合适。先写支持数字、变量、加减乘除的解释器,再往里面加if判断和循环。每加一个特性,你都会重新体会一次“语义到底由什么构成”。
我推荐用 Python 来写这个玩具解释器,因为 Python 写起来快,而且你不需要处理内存释放,可以把注意力集中在解析和求值逻辑上。当你觉得 Python 写解释器已经没意思了,再用 C 把同一个玩具重写一遍——这时你就会真正遭遇内存和字符串管理的挑战。
6.4 第四步:做一次真正的跨语言集成
你可以选一个真实的小工具:用 C 写一个函数,做成 Python 扩展,然后在 Python 里调用它。或者反过来,用 C 写一个小程序,嵌入 Lua 脚本,让 Lua 脚本控制程序行为。这一步会逼你去理解 ABI、引用计数、数据转换和错误处理,而不是停留在“玩具解释器”的舒适区里。
一个不错的项目是:用 C 写一个“求最大公约数”的函数,然后通过 Python 的ctypes调用它;再写一个 Lua 脚本,通过 Lua 的 C API 调用同一个 C 函数。这样你就会意识到,C 作为“桥梁”的价值,恰恰在于它既能被脚本语言调用,也能调用脚本语言。
6.5 这条路径的长期价值
我坚持认为,“理解解释器和编译器”的价值不在“能写出编译器”,而在于它改变了你看待程序的方式。当你遇到性能问题时,你会本能地想:这段代码是被解释执行的,还是被编译成机器码执行的?当你遇到脚本引擎报错时,你会去推想错误发生在解析阶段还是执行阶段。当你设计跨语言系统时,你会主动考虑边界上的数据格式、错误传播和资源管理。
这些能力不是靠某一天突然获得的,而是通过一次次“亲手跑通一条最小链路”积累出来的。标题里“你也可以解释 C 并编译 Python”,如果用一句话来落地,那就是:不要被语言的名字和实现形态限制住,理解执行策略之后,你可以在任意两种语言之间建立桥梁。今天从 Lua 入手,是因为它足够小;但掌握了方法,Python、C、JavaScript,甚至你自己设计的新语言,都会变成同一套思维下的不同入口。