Skip to content

CLI 使用说明

这篇文档为什么要写得比“命令列表”更深

很多项目的 CLI 文档只会告诉你:

  • 这个参数怎么写
  • 那个参数会输出什么

但对编译器项目来说,这种写法不太够,因为你最后记住的只会是一串参数名,还是不知道自己到底在看哪一层。

因为这里的命令行工具不是单纯的“程序启动器”,而是整条编译流水线的观察窗口。

如果只是记住:

  • --emit-tokens 会输出 Token
  • --emit-ast 会输出 AST
  • --emit-ir 会输出 IR

你还是可能不知道:

  • 为什么要先看 Token,而不是先看最终输出
  • AST 看错了说明的是语法问题还是语义问题
  • IR 输出为空或走 fallback 到底意味着什么
  • --emit-c 看到的内容是在验证前端,还是在验证后端

所以这篇文档不只是在说“参数怎么敲”,更想把下面这些事说清楚:

  • CLI 在编译原理里对应什么
  • 每个输出模式分别在观察哪一层
  • 如何利用 CLI 逐层定位问题

CLI 在整个编译器里的位置

从代码结构上看,CLI 不是编译器本体,它更像前台窗口。你敲进去一条命令,它决定这次是带你看后台哪一层,还是直接一路走到底把程序编出来。

它的职责大致是:

  1. 读取输入源码文件
  2. 根据命令行参数决定停在哪一层
  3. 调用统一的编译流水线
  4. 输出某个中间结果,或者继续生成最终可执行文件

在当前项目中,这件事主要由 src/c_core_compiler/cli.pysrc/c_core_compiler/pipeline.py 配合完成。

你把它想成下面这个分工就行:

  • pipeline.py 管真正的流水线,负责一步一步往下走
  • cli.py 管你这次想看什么,决定在哪一层停下来看

最基本的完整编译命令

最普通的使用方式是:

python3 -m c_core_compiler examples/hello.c -o build/hello

这条命令看起来就一行,但它做的事并不含糊:

  1. 读取 examples/hello.c
  2. 走完整编译流程
  3. 生成后端 C
  4. 调用系统编译器
  5. 输出最终可执行文件到 build/hello

这里的 -o 很关键,因为它等于是在告诉 CLI:别停在中间给我看过程了,这次我是来拿最终产物的。

为什么 -o 是一个分界点

在这个项目里,CLI 有两种主要使用模式:

1. 观察模式

例如:

  • --emit-tokens
  • --emit-ast
  • --emit-ir
  • --emit-ir-dot
  • --emit-c
  • --emit-asm

这类模式的目标是“停在某一层输出”,不继续生成二进制。

2. 构建模式

例如:

python3 -m c_core_compiler input.c -o build/program

这类模式的目标是走完整条链路并生成最终程序。

所以 -o 真就是一条分界线。带上它,你是在让编译器把整条路走完;不带它,你更多是在把它当成一台能开盖观察的机器。

--emit-tokens 在原理上是在看什么

命令:

python3 -m c_core_compiler input.c --emit-tokens

这一层看的就是:词法分析第一刀切得准不准。

它回答的问题不是“程序能不能运行”,而是:

  • 关键字有没有被识别成关键字
  • 标识符有没有被切对
  • 整数字面量有没有被识别
  • 注释有没有被跳过
  • 双字符运算符有没有被错误拆分
  • 字符和字符串字面量有没有被正确扫描

这是最前面的一层。如果 Token 已经切歪了,后面所有阶段都只能在歪地基上继续往上搭。

什么时候优先看 Token

如果你遇到这些问题,第一反应应该是 --emit-tokens

  • 明明写了 ==,后面像是按两个 = 处理
  • 字符串或字符字面量行为异常
  • 注释像是“漏进了代码”
  • 某个报错位置看起来特别早、特别奇怪

这一层不能回答什么

Token 正确,不代表语法正确。

例如:

  • 括号是不是匹配
  • if 的条件范围对不对
  • 表达式优先级是否正确

这些不是词法层能回答的问题,必须往下一层看 AST。

--emit-ast 在原理上是在看什么

命令:

python3 -m c_core_compiler input.c --emit-ast

这一层在看的是:Parser 有没有把 Token 串整理成一棵像样的树,而不是表面看着对、里面挂得乱七八糟。

它关注的是:

  • 顶层声明怎么分
  • 函数定义怎么挂接参数和函数体
  • if / while / for 的层次是否正确
  • 表达式优先级是否正确
  • 赋值、调用、下标访问这些结构有没有被正确识别

从编译原理上讲,这一步是在验证“语法树是否符合预期”。

为什么 AST 是核心观察点

因为从这一步开始,后面的阶段基本都不再直接依赖原始 Token 序列,而是依赖 AST。

也就是说,一旦 AST 歪了:

  • 语义分析看到的是歪结构
  • IR Builder 降的是歪结构
  • 后端最终输出也会跟着歪

所以 AST 是整条链路里最关键的中间观察点之一。

什么时候应该优先看 AST

例如这些情况:

  • 你怀疑优先级不对,比如 a + b * c
  • else 像是绑错了 if
  • for 的初始化、条件、更新看起来行为怪
  • 函数调用和数组下标像是被解析错了

这时候先看 AST,通常比你盯着最终错误信息瞎猜有效得多。

--emit-ir 在原理上是在看什么

命令:

python3 -m c_core_compiler input.c --emit-ir

这一层在看的是:高层语法有没有被顺利拆成更低层、更像执行步骤的东西。

这里不是再看“代码长什么样”,而是在看:

  • 临时变量怎么分配
  • 控制流标签怎么生成
  • 条件分支怎么展开
  • 循环怎么改写成跳转结构
  • 函数调用和返回怎么显式化

说人话就是:你在看编译器有没有把高层代码拆成一份后端真能接得住的步骤表。

为什么 IR 这么重要

因为 AST 仍然是高层结构,而 IR 更接近后端真正想要的形式。

例如:

  • AST 里的 if 还是一个语法节点
  • IR 里的 if 已经变成条件跳转、标签和显式控制流

一旦这一步对了,后端就不必重新猜测高层结构语义。

什么时候 IR 最值得看

如果你怀疑问题出在 lowering 或控制流展开,例如:

  • 循环跳转行为怪
  • 短路逻辑 && || 表现不对
  • return 路径异常
  • 某段控制流像是被展开错了

那么 IR 是最关键的排查点。

--emit-ir-dot 在原理上是在看什么

命令:

python3 -m c_core_compiler input.c --emit-ir-dot

这一层和 --emit-ir 看的是同一份东西,只不过它把线性的 IR 文本摊成了图,适合你专门盯分支和循环这些容易绕人的地方。

它的价值在于:

  • 更适合看分支图
  • 更适合看循环结构
  • 更适合检查标签之间的跳转关系

如果线性 IR 让你不容易一眼看出控制流形状,那么 DOT 版本更适合。

--emit-c 在原理上是在看什么

命令:

python3 -m c_core_compiler input.c --emit-c

这一层看的就是:项目自己最后到底生成了一份什么代码,准备交给系统工具链去编。

它回答的问题包括:

  • 后端是不是把 IR 正确翻译成了规范化 C
  • 当前程序到底走的是 IR Backend 还是 AST Backend
  • 输出结构是否足够规则、稳定、可编译

这一步离最终可执行文件已经很近了,但还没到系统编译器接手之后,所以它特别适合拿来区分“是我们自己生成错了”,还是“外部工具链后面才出事”。

为什么 --emit-c 特别关键

因为它刚好是项目内部逻辑和外部系统工具链之间的边界。

如果最终构建失败,你通常会问两个问题:

  1. 是项目前面的编译阶段就已经生成错了?
  2. 还是系统编译器在处理生成 C 时失败了?

--emit-c 和中间 .generated.c 文件正是用来回答这个问题的。

--emit-asm 为什么现在等价于 --emit-c

从名字看,很多人会以为:

  • --emit-c 输出 C
  • --emit-asm 输出汇编

但当前第一代实现里,--emit-asm 被保留成兼容入口,实际输出的仍然是后端生成的规范化 C。

这样做不是故意把概念搅乱,而是先把调试入口固定住:

  • 现在先输出 C
  • 以后如果后端切换到原生汇编,仍然可以沿用同一个调试习惯

--target 在原理和工程上是什么意思

命令行里还有一个参数:

--target

它的意义不是“切换前端语法”,而是告诉后端和工具链:你想按哪个目标平台配置来生成结果。

当前项目主要提供的是目标平台相关的后端元信息和组织方式,而不是完整的原生机器码后端切换。

所以这里更适合理解成:

  • 后端目标配置选择
  • 面向不同平台的生成与组织准备

而不是“像工业编译器那样完整切换到另一套机器代码后端”。

CLI 和两条编译路径之间的关系

这件事别轻轻带过,它会直接影响你后面怎么读输出。

当前项目不是所有程序都走统一路径:

  • 基础 int 子集:AST -> IR -> 优化 -> Backend C
  • 扩展能力如 char、数组、指针、字符串:AST -> AST Backend

CLI 本身并不强迫你选路径,而是通过程序内容自然触发不同路径。

它带来的实际影响很现实:

  • --emit-ir 对某些高级特性程序,看到的不会是普通完整 IR 路径结果
  • --emit-c 更能反映当前程序最终到底走了哪条后端路线

所以 CLI 不只是“显示结果”,它还是理解编译路径差异的窗口。

一套最实用的排错顺序

如果你遇到“编译结果不对”或“行为异常”,非常推荐按这个顺序排查:

  1. 先看 --emit-tokens
  2. 再看 --emit-ast
  3. 如果是基础 int 子集,再看 --emit-ir
  4. 必要时看 --emit-ir-dot
  5. 最后看 --emit-c

这套顺序背后的想法其实很土,但非常好用:

  • 从最靠前、最基础的表示开始排
  • 每次只验证一层的职责
  • 不把词法、语法、语义、后端问题混在一起

编译器调试说到底就是这个套路:别一头扑到最后一层去哭,先一层一层把范围缩小,很多问题其实很快就能看出来。

如果你是为了学习编译原理,CLI 最该怎么用

很多人会把 CLI 当成“跑一下示例”的工具,但对学习来说,更好的方式是:

  1. 选一个很小的示例
  2. 先看 Token
  3. 再看 AST
  4. 再看 IR
  5. 再看后端 C
  6. 最后再运行二进制

这样你会真的看到一段源码是怎么一步步变样的,而不是只知道“它最后编出来了”。

这比只看最终结果更能真正理解编译器。

一句话总结

这个项目的 CLI 不是单纯的“命令入口”,而是把编译流水线每一层暴露给使用者的观察接口。真正会用它的人,不只是会运行命令,而是知道每个参数在验证哪一层原理。