← 第49章· AST / IR / SSA

首次编译 .cpp 请先看保存、编译和运行教程;本页在此基础上观察 IR 与链接。

LOCAL TOOL LAB / EXECUTED ON CPU

一次真实的 Clang → LLVM IR → 可执行文件实验

先备:基础17编译链接IR-45 I1–I5完整读法。本页只安排实验与复现;语言构造、IR记号及其边界由章节讲授。用现有 CPU 和 Clang 即可;不需要 GPU、opt 或新设备。本包独立编写,实验对象是普通 C++ 函数,没有实现 LLVM pass,也没有运行 MLIR。

先定义行为,再谈优化

本实验接口:transform(x) 计算 x * 2 + 3 + x * 0,源码实际使用 unsigned,测试先确认它具有32个值位。按 I4的固定宽度合同先填写这五个输入:0→3,1→5,7→17,2147483648→3,4294967295→1;保留全部边界检查。

沿用 I5的宽整数oracle与掩码读法,核对 check.cpp 的独立期望值;被测接口和输入约定保持一致,不能仅换成 int 后沿用原证明。不要调用被测函数来生成期望值。

1 · 先运行整个实验

在解压后的 compiler-lab 目录运行:

clang++ --version
python3 run.py

脚本使用临时目录编译,不改动源文件;把可阅读产物写到 output/。可以显式选择编译器:CXX=clang++ python3 run.py。本书记录的环境是 Apple Clang 17、arm64 macOS。Linux 上安装了 Clang 和相应 C++ 标准库也可尝试;指令名称与汇编应以你的机器为准,不能拿本书截图式输出代替自己的记录。

2 · 停在 LLVM IR 层

clang++ -std=c++20 -O0 -S -emit-llvm arithmetic.cpp -o O0.ll
clang++ -std=c++20 -O2 -S -emit-llvm arithmetic.cpp -o O2.ll

选项与完整函数头读法见 I5。打开自己生成的 O0.ll,按 I1的记号表I3的SSA值/内存双表,从 define ... @transform 追踪输入7到返回17。记录文件中的实际名字,不重命名原始产物。

再按 I4的移位与边界表读 O2.ll,记录这次实际留下的操作。本书保留产物是左移一位、加3、返回;未来Clang输出以实际文件为准,不能把行数写死为正确性条件。

应用 I5的等价性推导:用本实验接口说明 dead 消失后为何仍应满足五个期望。把推导与实际执行结果分开记录。

3 · IR 确实能继续编译

clang++ -c O2.ll -o from-ir.o
clang++ -std=c++20 check.cpp from-ir.o -o check-ir
./check-ir

这一步把刚才生成的 IR 重新送进工具链,再与测试入口链接。run.py 对 O0、O2 都执行这个回路,并与直接从 C++ 编译的结果对比。它证明这些 .ll 是实际可处理的中间产物,不是为了画图手写的伪 IR。

4 · 找到链接器的责任边界

clang++ -std=c++20 -O2 -c arithmetic.cpp -o arithmetic.o
clang++ -std=c++20 check.cpp arithmetic.o -o check
./check
clang++ -std=c++20 check.cpp -o missing-definition

最后一条应失败:check.cpp 里有 transform 的声明,所以编译器能检查调用方式;链接时却没有找到函数定义。修复是把正确目标文件交给链接器。随意补一个永远返回 0 的函数会让链接通过,却破坏行为。再跑原有边界测试才能发现这个区别。

extern "C" 的语言链接及平台/类型边界由 I5讲授;在这里核对声明、目标文件与当前工具链是否匹配。

5 · 从本机产物连接到 runtime

clang++ -O2 -S arithmetic.cpp -o O2.s 查看汇编。当前 arm64 产物与 x86 机器可能很不一样;汇编是面向目标架构的表达,IR 则还包含较多跨目标的结构。最终执行程序时,操作系统的加载机制让代码和数据可被进程使用;这与厂商 runtime 管理队列、设备缓冲、事件不是同一个层次。

本实验完成了 CPU 编译、链接和执行;它没有测试设备代码打包、GPU module 加载、tensor lowering 或设备内存规划。把这条真实的小链路弄清楚后,再回第 47–48 章比较 accelerator runtime 多出来的责任。

独立改写与检查

  1. 将接口改成 x * 4 + 1,先写出上述五个输入的新期望值,再修改实现和独立 oracle;同时把 run.py 的 expected 输出夹具更新为这五行:0→1、1→5、7→29、2147483648→1、4294967295→4294967293。脚本仍保留旧输出时会按设计拒绝新行为,不能只删除检查。预测 O2 IR 会如何变化,最后用真实产物验证。不要只修改测试使失败消失。
  2. 新增一个 square 函数,在不同目标文件中定义与调用;先故意漏掉定义,记录链接失败,再补齐并测试 0、1、65536、UINT_MAX。乘法结果按约定回绕,oracle 要用足够宽的中间类型。
  3. 面试口述:源码行、IR 值、目标指令、链接符号分别是什么?为什么 O0/O2 输出相同不等于已经证明所有输入都正确?为什么这个实验不能当作 GPU profiling 经历?

参考与验证范围

资料核对:2026-09-05。脚本检查 O0/O2 的直接执行、IR 回编译执行、ASan/UBSan 运行、缺定义链接拒绝。五个边界值不穷尽 2^32 个输入;测试结果与语言层面的模运算推导互相补充。原始 IR、汇编和验证记录由你运行脚本后生成,生成文件不应混成手写源码。

文档迁移:2026-09-08将先备讲解收束到IR-45;本次README修改没有重新运行旧实验,也没有更新旧执行收据的时间。旧源码、runner与O0/O2产物按原身份保留。

唯一源码:arithmetic.cpp

真实编译输入
// Unsigned arithmetic has defined modular wraparound; this contract matters to optimization.
extern "C" unsigned transform(unsigned x) {
    const unsigned doubled = x * 2u;
    const unsigned offset = doubled + 3u;
    const unsigned dead = x * 0u;
    return offset + dead;
}

保留的真实编译产物:两个函数体

整读下面的函数声明与属性前,先完成IR-45 I5:编译器输出与属性。这里复用已有执行记录及原始IR文件,不代表本轮重新生成或运行。

Apple clang version 17.0.0 (clang-1700.0.13.5)。以下直接读取保留的原始工具产物;省略函数体之外的 target/attributes/module metadata。

O0.ll

下载原始O0.ll(含属性与模块元数据)

O0.ll
define i32 @transform(i32 noundef %0) #0 {
  %2 = alloca i32, align 4
  %3 = alloca i32, align 4
  %4 = alloca i32, align 4
  %5 = alloca i32, align 4
  store i32 %0, ptr %2, align 4
  %6 = load i32, ptr %2, align 4
  %7 = mul i32 %6, 2
  store i32 %7, ptr %3, align 4
  %8 = load i32, ptr %3, align 4
  %9 = add i32 %8, 3
  store i32 %9, ptr %4, align 4
  %10 = load i32, ptr %2, align 4
  %11 = mul i32 %10, 0
  store i32 %11, ptr %5, align 4
  %12 = load i32, ptr %4, align 4
  %13 = load i32, ptr %5, align 4
  %14 = add i32 %12, %13
  ret i32 %14
}

O2.ll

下载原始O2.ll(含属性与模块元数据)

O2.ll
define noundef i32 @transform(i32 noundef %0) local_unnamed_addr #0 {
  %2 = shl i32 %0, 1
  %3 = add i32 %2, 3
  ret i32 %3
}

实际标准输出

0 -> 3
1 -> 5
7 -> 17
2147483648 -> 3
4294967295 -> 1

这些是 CPU 工具链结果,没有运行 GPU、MLIR 或自定义 LLVM pass。