命令是怎么进入代码的

本节阅读量:

上一节我们已经知道怎样运行:

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 的内容是:

1
(+ 40 2)

那么 source 里保存的就是这段文本。

然后这段文本会交给 parser:

1
auto expr = mini::parse(source);

这里的 expr 就是 AST,也就是语法树。第一章会正式讲 AST。现在可以先把它理解成:

1
程序文本已经被读懂后的结构。

后面的解释器和编译器都不直接处理原始文本,而是处理这棵 AST。

run 做什么

如果命令是 run

1
2
3
4
if (command == "run") {
    std::cout << mini::eval(*expr) << "\n";
    return 0;
}

这条路会调用解释器:

1
AST -> eval -> 结果

所以运行:

1
./mini run examples/add.lang

会直接打印:

1
42

这是一条“读懂程序,然后立刻算出结果”的路线。

ast 和 ir 做什么

astir 是教学辅助命令。它们不是语言本身必须有的功能,而是为了让我们看见程序在不同阶段的样子。

如果命令是 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,也不会帮你链接出可执行文件。上一节里手动运行:

1
cc out.s -o out

就是在完成 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 这一层不负责实现语言语义,也不负责真正生成每一行汇编。它像一个入口,把命令行、文件和后面的各个模块串起来。


0.1 运行代码

上一节

0.3 make 简介

下一节