命令是怎么进入代码的
本节阅读量:
上一节我们已经知道怎样运行:
1
2
|
./mini run examples/add.lang
./mini compile examples/add.lang -o out.s
|
这一节只看一眼 mini 的入口代码。先不用理解 parser、解释器、IR 和汇编生成的细节,只要知道:命令行参数进入 C++ 程序以后,是怎样被拆开、判断、再交给不同模块的。
这部分代码在:
1
|
code/01_numbers/src/cli/main.cpp
|
后面每一章也都有自己的 src/cli/main.cpp。它们的形状基本一致:读输入文件,解析成 AST,然后按命令选择要做的事。
main 收到什么
C++ 程序从 main 开始运行。本课程里的入口长这样:
1
2
3
|
int main(int argc, char** argv) {
// ...
}
|
可以先把这两个参数读成:
1
2
|
argc 命令行参数的数量
argv 命令行参数的内容
|
例如运行:
1
|
./mini run examples/add.lang
|
程序看到的参数大致是:
1
2
3
|
argv[0] ./mini
argv[1] run
argv[2] examples/add.lang
|
所以代码一开始会检查参数数量:
1
2
3
4
|
if (argc < 3) {
usage();
return 1;
}
|
这里的意思是:如果连命令和输入文件都没有,就打印用法说明,然后用退出码 1 表示失败。
先拆出命令和文件名
接下来,入口代码会把最重要的两个参数取出来:
1
2
|
std::string command = argv[1];
std::string input_path = argv[2];
|
对于这条命令:
1
|
./mini run examples/add.lang
|
它们的值就是:
1
2
|
command run
input_path examples/add.lang
|
对于这条命令:
1
|
./mini compile examples/add.lang -o out.s
|
它们的值就是:
1
2
|
command compile
input_path examples/add.lang
|
也就是说,mini 先不关心后面有没有 -o out.s。它先确认自己要执行的是哪种模式,以及输入程序放在哪个文件里。
读文件,得到 AST
拿到输入文件名以后,代码会读取文件内容:
1
|
std::string source = read_file(input_path);
|
如果 examples/add.lang 的内容是:
那么 source 里保存的就是这段文本。
然后这段文本会交给 parser:
1
|
auto expr = mini::parse(source);
|
这里的 expr 就是 AST,也就是语法树。第一章会正式讲 AST。现在可以先把它理解成:
后面的解释器和编译器都不直接处理原始文本,而是处理这棵 AST。
run 做什么
如果命令是 run:
1
2
3
4
|
if (command == "run") {
std::cout << mini::eval(*expr) << "\n";
return 0;
}
|
这条路会调用解释器:
所以运行:
1
|
./mini run examples/add.lang
|
会直接打印:
这是一条“读懂程序,然后立刻算出结果”的路线。
ast 和 ir 做什么
ast 和 ir 是教学辅助命令。它们不是语言本身必须有的功能,而是为了让我们看见程序在不同阶段的样子。
如果命令是 ast:
1
2
3
4
|
if (command == "ast") {
std::cout << expr->dump() << "\n";
return 0;
}
|
它会打印 AST。
如果命令是 ir:
1
2
3
4
|
if (command == "ir") {
std::cout << mini::lower_to_ir(*expr).dump();
return 0;
}
|
它会把 AST 转成 IR,再把 IR 打印出来。
这两个命令的作用是观察中间结果。后面读第一章时,如果某一步感觉抽象,可以先运行:
1
2
|
./mini ast examples/add.lang
./mini ir examples/add.lang
|
看见输出以后,再回头读代码会轻松很多。
compile 做什么
compile 比前面几个命令多一个输出文件参数:
1
|
./mini compile examples/add.lang -o out.s
|
所以入口代码会继续扫描后面的参数:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
std::string output_path;
mini::Target target = host_target();
for (int i = 3; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "-o" && i + 1 < argc) {
output_path = argv[++i];
} else if (arg == "--target" && i + 1 < argc) {
target = parse_target(argv[++i]);
} else {
throw std::runtime_error("unknown compile option: " + arg);
}
}
|
这里先看懂 -o 就够了:
1
|
-o out.s 把生成的汇编写到 out.s
|
如果忘了写 -o,代码会报错:
1
2
3
|
if (output_path.empty()) {
throw std::runtime_error("compile requires -o <file.s>");
}
|
真正生成汇编的是最后两行:
1
2
|
auto ir = mini::lower_to_ir(*expr);
write_file(output_path, mini::compile_to_assembly(ir, target));
|
这条路可以先读成:
1
|
AST -> IR -> assembly -> 写入 out.s
|
注意,compile 到这里就结束了。它只生成汇编文本,不会调用 cc,也不会帮你链接出可执行文件。上一节里手动运行:
就是在完成 mini compile 之外的那一步。
报错会去哪里
整个 main 外面包了一层:
1
2
3
4
5
6
|
try {
// ...
} catch (const std::exception& error) {
std::cerr << "error: " << error.what() << "\n";
return 1;
}
|
这表示:如果读文件失败、命令写错、少了 -o,程序会把错误打印到标准错误输出,并返回失败退出码。
例如:
1
|
./mini compile examples/add.lang
|
因为少了 -o out.s,就会进入报错路径。
小结
第一次读 src/cli/main.cpp,不需要追进每个函数。先记住这一条线就够了:
1
2
3
4
5
|
命令行参数
-> main
-> 读输入文件
-> parse 得到 AST
-> 按 run / ast / ir / compile 分发
|
cli 这一层不负责实现语言语义,也不负责真正生成每一行汇编。它像一个入口,把命令行、文件和后面的各个模块串起来。