章节目录

可选收尾:从 mini 调用 C++ runtime

本节阅读量:

前面的编译示例都让生成的 main 返回计算结果,再用 $? 观察进程退出状态。这样能检查 42,却不能完整显示超出 0 到 255 的结果。

第四章已经实现了函数调用,正好可以再向外走一步:让生成的汇编调用由 C++ 提供的函数。这个小实验增加两个固定 primitive:

1
2
(cin)
(cout expression)

cin 从标准输入读取一个整数。cout 先求值参数、打印得到的整数,再返回同一个整数。因此下面的程序读入一个数,把它交给本章的 mini 函数 inc,再输出调用结果:

1
2
(let inc (lambda x (+ x 1))
    (cout (inc (cin))))

它已经保存在 examples/io.lang。

这只是第四章末尾的可选 runtime 联调,不是通用 FFI,也不把输入输出加入后续主线。普通 lambda 和普通调用仍然只有一个参数;(cin) 是 parser 单独识别的固定形式,不是新增的零参数函数调用。

与本章的 +、eq? 一样,cout 只接受整数。解释器会检查这个条件;无标签编译器仍只承诺实际执行到的 cout 收到整数的程序。

两个固定 AST 节点

parser 在普通调用的兜底分支之前识别 cin 和 cout:

1
2
(cin)          -> Cin()
(cout expr)    -> Cout(expr)

它们对应显式的 ExprKind::input 和 ExprKind::output。因此贯穿这个实验的程序会得到:

1
Let(inc, Lambda(x, Add(Var(x), Int(1))), Cout(Call(Var(inc), Cin())))

解释器和编译器都通过自己的 switch 处理这两个 kind,没有在汇编生成器里偷偷检查某个字符串。

解释器和生成代码共用 runtime

runtime 的 C 接口位于 src/runtime/io.h:

1
2
extern "C" long mini_read_int();
extern "C" long mini_write_int(long value);

实现位于 src/runtime/io.cpp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include "runtime/io.h"

#include <cstdlib>
#include <iostream>

extern "C" long mini_read_int() {
    long value = 0;
    if (!(std::cin >> value)) {
        std::cerr << "error: cin expected an integer\n";
        std::exit(1);
    }
    return value;
}

extern "C" long mini_write_int(long value) {
    std::cout << value << '\n';
    return value;
}

extern "C" 关闭 C++ 的名字改写,让生成的汇编能够用确定的符号名找到这两个函数。mini_write_int 返回传入的值,使 (cout expr) 仍然是一个有整数结果的表达式。

解释器遇到 CinExpr 和 CoutExpr 时也调用这两个函数。这样 run 和编译路径对合法整数输入保持同一套“读取、打印并返回”的行为。

可以先运行:

1
2
3
cd code/04_functions
make
./mini run examples/io.lang

输入 41 后会看到两行 42:第一行是 mini 程序里的 cout 效果,第二行是 mini run 原本就会显示的顶层表达式结果。两行并不表示 cout 执行了两次。

IR 记录 runtime 操作

lowering 不把这两个固定 primitive 伪装成普通函数值,而是产生显式的 input、output 操作:

1
./mini ir examples/io.lang

输出是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
f.0 = function lambda0
inc0 = f.0
t.1 = cin
t.2 = call inc0 t.1
t.3 = cout t.2
return t.3
lambda0(x):
  t.0 = x + 1
  return t.0
end lambda0

input 没有源操作数,runtime 的返回值成为 t.1,随后作为普通 mini 调用的实参。output 的源操作数是 inc 的调用结果,runtime 返回值保存为 t.3。两个操作都有目的临时变量,因此仍然沿用本章已有的栈槽分配方式。

生成对外部符号的直接 call

普通 mini 调用的 callee 是运行时求出的函数值,所以使用间接调用:

1
2
3
movq callee_slot, %rcx
movq argument, %rdi
call *%rcx

cin 和 cout 的实现名字已经固定,可以生成链接器稍后解析的直接调用:

1
2
3
4
5
6
call mini_read_int
movq %rax, input_slot

movq value_slot, %rdi
call mini_write_int
movq %rax, output_slot

两种调用遵守同一套 System V ABI:有整数实参的普通 mini 调用和 mini_write_int 把它放进 %rdi,三个调用都通过 %rax 返回结果。区别只在目标怎样确定:

调用 汇编 目标何时确定
普通 mini 函数值 call *%rcx 程序运行时从函数值取得地址
固定 C++ runtime call mini_write_int 链接时按外部符号找到地址

macOS 的 C 符号带前导下划线,因此 emitter 会输出 _mini_read_int 和 _mini_write_int;Linux、WSL 使用没有下划线的名字。

与 C++ runtime 一起链接

先生成汇编:

1
./mini compile examples/io.lang -o out.s

在 x86-64 Linux、WSL 或 Intel Mac 上,把汇编和 runtime 一起交给 c++:

1
2
c++ -std=c++17 out.s src/runtime/io.cpp -Isrc -o io_demo
./io_demo

这里使用 c++ 而不是 cc,因为 io.cpp 使用了 iostream,最终链接需要 C++ 标准库。

Apple Silicon Mac 上,汇编和 runtime 必须使用同一种目标架构:

1
2
c++ -arch x86_64 -std=c++17 out.s src/runtime/io.cpp -Isrc -o io_demo
./io_demo

输入:

1
1000

程序会直接输出完整结果:

1
1001

这次不需要用 $? 猜计算结果。若随后运行 echo $?,看到的仍只是 main 返回值折叠后的进程退出状态;mini 的 cout 已经把真正结果完整写到标准输出。

这次实验停在哪里

这一小节只建立了两条固定映射:

1
2
(cin)         -> call mini_read_int
(cout value)  -> %rdi = value; call mini_write_int

它没有实现任意 C++ 函数声明、多参数外部调用、字符串、文件 I/O 或动态库加载,mini compile 也仍然只输出汇编,不负责调用 c++。

第五章开始会回到语言主线,不继承这两个 primitive 和 runtime 文件。这个一次性实验的目的,是让读者亲手看到:只要双方遵守同一套 ABI,我们生成的汇编就能调用由另一种语言实现的函数。


4.9 练习

上一节

5.0 函数怎样调用自己

下一节