C 编译器制作入门

函数与局部变量

函数与局部变量

本章把语言推进到更像 C 的阶段:实现局部变量、函数、返回语句,以及基本控制结构。完成后,编译器就能够处理下面这种带循环和函数调用的程序。

// 对 m 到 n 求和
sum(m, n) {
    acc = 0;
    for (i = m; i <= n; i = i + 1)
        acc = acc + i;
    return acc;
}

main() {
    return sum(1, 10); // 返回 55
}

它还不是完整的 C,但已经具备了“真正程序”的形态:有变量、有循环、有函数调用,也有返回值。

步骤 9:单字符局部变量

到上一章,我们已经做出了能够处理四则运算的语言。本节要继续给这门语言添加变量。具体目标是能编译如下包含变量和多条语句的程序。

a = 3; b = 5 * 6 - 8; a + b / 2;

我们把最后一个表达式的结果作为整个程序的计算结果。与只能计算四则运算的语言相比,这门语言已经开始有“真正语言”的味道了。

本章先说明变量应当如何实现,然后再以增量方式逐步实现变量。

栈上的变量区域

在 C 中,变量存在于内存中。也可以说,变量就是给某个内存地址起了名字。这样我们就不必说“访问内存的 0x6080 地址”,而可以说“访问变量 a”。

不过,函数的局部变量必须在每次函数调用时分别存在。单从实现方便看,似乎可以把“函数 f 的局部变量 a 放在 0x6080 地址”这样写死,但这样在递归调用 f 时就无法工作。为了让每次函数调用都有各自的局部变量,C 会把局部变量放在栈上。

用一个具体例子来看栈的内容。假设函数 f 有局部变量 ab,并且某个其他函数调用了 f。函数调用的 call 指令会把返回地址压入栈,因此在 f 刚被调用时,栈顶保存的就是这个返回地址。除此之外,栈上还可能已有其他值;这里具体内容并不重要,用“⋯⋯”表示。图示如下。

⋯⋯
返回地址← RSP

这里用“← RSP”表示当前 RSP 寄存器的值指向该地址。假设 ab 的大小都是 8 字节。

栈向低地址方向增长。从这个状态开始,为了给 ab 分配区域,需要为两个变量合计把 RSP 下移 16 字节。执行后会变成下面这样。

⋯⋯
返回地址
a
b← RSP

采用这种布局时,用 RSP+8 可以访问 a,用 RSP 可以访问 b。这种为每次函数调用分配的内存区域称为“函数栈帧”或“活动记录”。

RSP 要移动多少字节、分配出的区域里变量按什么顺序摆放,这些都不会被其他函数看到,因此可以按编译器实现的需要自行决定。

基本上,局部变量就是以这种简单方式实现的。

不过,这种方法有一个问题,实际实现时还需要再使用一个寄存器。请回忆一下,在我们的编译器中(其他编译器也类似),函数执行过程中 RSP 可能会变化。9cc 会用 RSP 所指的栈来保存表达式的中间结果,因此 RSP 的值会频繁变化。这样一来,就不能用相对于 RSP 的固定偏移访问 ab

常见的解决方法是:除 RSP 之外,再准备一个始终指向当前函数栈帧起点的寄存器。这样的寄存器称为“基址寄存器”,其中保存的值称为“基址指针”。在 x86-64 中,惯例上使用 RBP 作为基址寄存器。

函数执行期间,基址指针不能改变;这正是引入基址指针的理由。函数内部可能再调用其他函数,返回后基址指针不能变成别的值。因此每次函数调用都需要保存原来的基址指针,并在返回前恢复。

下面的图展示了使用基址指针时函数调用中的栈状态。假设有一个带局部变量 xy 的函数 g,它调用函数 f。在 g 执行期间,栈如下所示。

⋯⋯
g 的返回地址
g 的调用时点的 RBP← RBP
x
y← RSP

此时如果调用 f,栈会进入下面的状态。

⋯⋯
g 的返回地址
g 的调用时点的 RBP
x
y
f 的返回地址
f 的调用时点的 RBP← RBP
a
b← RSP

这样,a 总是可以通过 RBP-8 访问,b 总是可以通过 RBP-16 访问。为了构造这样的栈状态,编译器应当在函数开头输出如下汇编。

  push rbp
  mov rbp, rsp
  sub rsp, 16

这种由编译器在函数开头固定输出的指令序列称为“序言”(prologue)。这里的 16 只是示例;实际值应当根据每个函数需要的变量数量和大小决定。

从 RSP 指向返回地址的状态开始执行上面的代码,就会得到预期的函数栈帧。下面按每条指令展示栈状态。

  1. 执行 call f 后的栈:⋯⋯、g 的返回地址、调用 g 时的 RBP、xyf 的返回地址(← RSP)。
  2. 执行 push rbp 后的栈:保存调用 f 时的 RBP,RSP 指向该保存值。
  3. 执行 mov rbp, rsp 后的栈:RBP 和 RSP 都指向调用 f 时保存的 RBP。
  4. 执行 sub rsp, 16 后的栈:在 RBP 之下分配出 ab 的空间,RSP 指向最下方。

函数返回时,要把 RBP 恢复为原来的值,并让 RSP 重新指向返回地址,然后执行 retret 会从栈中弹出地址并跳转到那里)。代码可以简洁地写成如下形式。

  mov rsp, rbp
  pop rbp
  ret

这种由编译器在函数末尾固定输出的指令序列称为“尾声”(epilogue)。

下面展示执行尾声时的栈状态。RSP 以下的区域可以看作无效数据,图中省略。

  1. 执行 mov rsp, rbp 前的栈。
  2. 执行 mov rsp, rbp 后,RSP 回到当前栈帧起点。
  3. 执行 pop rbp 后,调用方的 RBP 被恢复。
  4. 执行 ret 后,控制流回到调用方,RSP 也回到调用前的状态。

这样,通过执行尾声,调用方函数 g 的栈状态就被恢复了。call 指令会把下一条指令的地址压入栈;尾声中的 ret 会把该地址弹出并跳转过去,于是从 call 的下一条指令重新开始执行。这正是我们熟悉的函数调用行为。

函数调用和函数局部变量就是这样实现的。

专栏:栈的增长方向

如上所述,x86-64 的栈从高地址向低地址增长。反过来看,也就是栈“向下”增长。也许你会觉得向上增长更自然,那么为什么会设计成向下增长呢?

实际上,栈向下增长并没有技术上的必然性。很多 CPU 和 ABI 的确采用高地址作为栈起点并向下增长,但也存在少数反方向增长的体系结构,例如 8051 微控制器、PA-RISC 的某些 ABI、Multics 等。

不过,栈向下增长也并不是特别不自然。

CPU 上电后会从某个规定地址开始执行程序。许多设计会从地址 0 这样的低地址处开始执行,程序代码也通常放在低地址。为了避免栈增长时碰到程序代码,可以把栈放在高地址,让它朝地址空间中间增长。这样设计时,栈自然就是向下增长的。

当然,也可以设计成相反布局,使栈向上增长更自然。这里没有绝对答案;现实中机器栈向下增长只是业界事实上的主流。

修改词法分析器

既然已经知道变量应当如何实现,接下来就实现它。不过立刻支持任意数量的变量会比较难。本步骤先把变量限制为一个小写字母:变量 a 放在 RBP-8b 放在 RBP-16c 放在 RBP-24,也就是说所有单字符变量都始终存在。字母表有 26 个小写字母,因此每次函数调用只要预留 26×8,也就是 208 字节,就能放下全部单字符变量。

先修改词法分析器。除了目前已有的语法元素之外,还要能把一个字符的变量识别出来。为此需要增加一种新的标记类型。变量名可以从 str 成员读取,因此 Token 类型不需要增加新成员。标记类型最终变成如下形式。

enum {
    TK_RESERVED, // 记号
    TK_IDENT, // 标识符
    TK_NUM, // 整数标记
    TK_EOF, // 表示输入结束的标记
} TokenKind;

修改词法分析器时,如果遇到小写英文字母,就生成 TK_IDENT 类型的标记。把下面的 if 语句加入词法分析器即可。

if ('a' <= *p && *p <= 'z') {
    cur = new_token(TK_IDENT, cur, p++);
    cur->len = 1;
    continue;
}

修改语法分析器

递归下降语法分析可以把语法机械地映射为函数调用。因此,在修改语法分析器前,需要先思考“加入变量名(标识符)后的语法应当是什么样”。

标识符记为 ident,它和 num 一样是终结符。变量可以出现在数值能出现的地方,因此原来的 num 可以改为 num | ident。也就是说,变量和数值可以出现在相同位置。

除此之外,还需要加入赋值表达式。变量如果不能赋值就没法使用,所以语法要允许 a=1 这样的表达式。这里也先允许 C 语言中的 a=b=1 这种写法。

再加上用分号分隔的多条语句,新语法如下。

program = stmt*
stmt = expr ";"
expr = assign
assign = equality ("=" assign)?
equality = relational ("==" relational | "!=" relational)*
relational = add ("<" add | "<=" add | ">" add | ">=" add)*
add = mul ("+" mul | "-" mul)*
mul = unary ("*" unary | "/" unary)*
unary = ("+" | "-")? primary
primary = num | ident | "(" expr ")"

请先确认 42;a=b=2;a+b; 这样的程序都符合这个语法。然后把目前的语法分析器改成能分析上述语法。这个阶段即使 a+1=5 这样的表达式也能被解析,但无法正确执行;这种语义上不正确的表达式会在下一遍处理中排除。语法分析器的改造没有特别技巧性的新点,和之前一样按语法元素映射到函数即可。

由于可以有多条用分号分隔的表达式,解析结果也需要保存多个节点。这里准备一个全局数组,按顺序保存解析出的节点,最后用 NULL 作为结尾标记。新增代码的一部分如下。

Node *code[100];

Node *assign() {
    Node *node = equality();
    if (consume("="))
        node = new_node(ND_ASSIGN, node, assign());
    return node;
}

Node *expr() {
    return assign();
}

Node *stmt() {
    Node *node = expr();
    expect(";");
    return node;
}

void program() {
    int i = 0;
    while (!at_eof())
        code[i++] = stmt();
    code[i] = NULL;
}

还需要在抽象语法树中表示“局部变量节点”。为此新增局部变量节点类型,并给节点增加必要成员。例如可以这样定义。语法分析器在遇到标识符标记时,会创建并返回 ND_LVAR 类型的节点。

typedef enum {
    ND_ADD, // +
    ND_SUB, // -
    ND_MUL, // *
    ND_DIV, // /
    ND_ASSIGN, // =
    ND_LVAR, // 局部变量
    ND_NUM, // 整数
} NodeKind;

typedef struct Node Node;

// 抽象语法树的节点
struct Node {
    NodeKind kind; // 节点类型
    Node *lhs; // 左边
    Node *rhs; // 右边
    int val; // 仅在 kind 为 ND_NUM 时使用
    int offset; // 仅在 kind 为 ND_LVAR 时使用
};

offset 成员表示局部变量相对于基址指针的偏移量。当前阶段中,变量 a 固定为 RBP-8b 固定为 RBP-16,因此在语法分析阶段就能根据变量名决定偏移量。下面是读入标识符并返回 ND_LVAR 节点的代码。

Node *primary() {
    ...

    Token *tok = consume_ident();
    if (tok) {
        Node *node = calloc(1, sizeof(Node));
        node->kind = ND_LVAR;
        node->offset = (tok->str[0] -'a'+1) *8;
        return node;
    }

    ...

专栏:ASCII 码

ASCII 码把 0~127 的数字分配给字符。下面给出 ASCII 码中的字符分配表。

0NULSOHSTXETXEOTENQACKBEL
8BSHTNLVTNPCRSOSI
16DLEDC1DC2DC3DC4NAKSYNETB
24CANEMSUBESCFSGSRSUS
32sp!"#$%&'
40()*+,-./
4801234567
5689:;<=>?
64@ABCDEFG
72HIJKLMNO
80PQRSTUVW
88XYZ[\]^_
96`abcdefg
104hijklmno
112pqrstuvw
120xyz{|}~DEL

0~31 是控制字符。现在除了 NUL 字符和换行符等少数几个之外,这些控制字符很少直接使用。但在 1963 年制定 ASCII 标准时,它们确实有实际用途。当时也曾有人提议用小写字母替代许多控制字符。

48~57 分配给数字,65~90 分配给大写字母,97~122 分配给小写字母。请注意这些字符都被连续分配了编码。也就是说,0123456789abcdefg... 在字符编码上是连续的。现在这看起来理所当然,但当时主流的 EBCDIC 等字符编码受穿孔卡影响,字母并不是连续排列的。

在 C 中,字符其实是最小的整数类型,写字符字面量与写它所对应的字符编码数值含义相同。例如在 ASCII 前提下,'a' 等于 97,'0' 等于 48。上面的代码通过从字符中减去 'a' 来计算它与 a 的距离,正是因为 ASCII 中字母连续排列。

左值与右值

与赋值以外的二元运算符不同,赋值表达式的左边需要特殊处理。这里先说明这一点。

赋值表达式的左边并不是任何表达式都可以。例如 1=2 不成立,(a+1)=2 也不是合法语句。a=2 这样的赋值可以;以后即使实现了指针和结构体,*p=2 这样的向指针所指位置赋值、a.b=2 这样的向结构体成员赋值,也会被认为是合法的。那么,怎样区分合法与非法的左边表达式呢?

答案很简单:C 中能出现在赋值表达式左边的,基本上是能指定某个内存地址的表达式。

变量存在于内存中,并有地址,因此可以写在赋值左边。类似地,*p 表示指针 p 的值所指向的地址,也可以写在左边。a.b 这样的结构体成员访问,也是在结构体 a 的起始地址上加上成员 b 的偏移量得到一个内存地址,因此也可以写在左边。

另一方面,a+1 这样的表达式结果不是变量,也不能用来指定内存地址。这种临时值实际可能只存在于寄存器中,不一定在内存里;即使曾经出现在内存里,也通常不能通过某个已知变量的固定偏移访问。因此,像 &(a+1) 这样的写法也不允许,编译器会报错。这样的表达式不能作为赋值语句的左边。

能写在左边的值称为“左值”(lvalue,left value),不能写在左边的值称为“右值”(rvalue,right value)。在当前语言中,变量是左值,除此之外的值都是右值。

变量的代码生成可以从左值出发考虑。当变量出现在赋值左边时,先把左边作为左值求值,也就是计算该变量的地址,然后把右边的求值结果存储到该地址。这样就能实现赋值表达式。当变量出现在其他语句中时,同样先计算变量地址,再从该地址加载值,把左值转换为右值。这样就能取得变量的值。

从任意地址加载值的方法

到目前为止,代码生成只访问过栈顶附近的内存;现在需要访问栈上的任意局部变量位置。这里说明一般的内存访问方法。

CPU 不仅能访问栈顶,也能从内存中的任意地址加载值或向任意地址存储值。

从内存加载值使用 mov dst, [src] 这种语法。这条指令表示“把 src 寄存器的值当作地址,从该地址加载值并保存到 dst”。例如 mov rdi, [rax] 会从 RAX 中保存的地址读取值,并设置到 RDI。

向内存存储值使用 mov [dst], src 这种语法。这条指令表示“把 dst 寄存器的值当作地址,把 src 寄存器的值写入那里”。例如 mov [rdi], rax 会把 RAX 的值写入 RDI 所指向的地址。

pushpop 是隐式使用 RSP 地址进行内存访问的指令,因此实际上也可以用普通内存访问指令组合来重写。比如 pop rax 等价于下面两条指令。

mov rax, [rsp]
add rsp, 8

push rax 等价于下面两条指令。

sub rsp, 8
mov [rsp], rax

它们本质上都是修改 RSP 并读写 RSP 所指向的内存。

修改代码生成器

利用迄今为止的知识,可以修改代码生成器,让它支持包含变量的表达式。本次修改会新增一个“把表达式作为左值求值”的函数。下面代码中的 gen_lval 就是该函数。gen_lval 在节点表示变量时计算变量地址并压入栈;其他情况则报错。这样就能排除 (a+1)=2 这样的表达式。

变量作为右值使用时,先作为左值求值,栈顶会得到地址;然后从该地址加载值。代码如下。

void gen_lval(Node *node) {
    if (node->kind != ND_LVAR)
        error("赋值的左边不是变量");

    printf(" mov rax, rbp\n");
    printf(" sub rax, %d\n", node->offset);
    printf(" push rax\n");
}

void gen(Node *node) {
    switch (node->kind) {
    case ND_NUM:
        printf(" push %d\n", node->val);
        return;
    case ND_LVAR:
        gen_lval(node);
        printf(" pop rax\n");
        printf(" mov rax, [rax]\n");
        printf(" push rax\n");
        return;
    case ND_ASSIGN:
        gen_lval(node->lhs);
        gen(node->rhs);

        printf(" pop rdi\n");
        printf(" pop rax\n");
        printf(" mov [rax], rdi\n");
        printf(" push rdi\n");
        return;
    }

    gen(node->lhs);
    gen(node->rhs);

    printf(" pop rdi\n");
    printf(" pop rax\n");

    switch (node->kind) {
    case'+':
        printf(" add rax, rdi\n");
        break;
    case'-':
        printf(" sub rax, rdi\n");
        break;
    case'*':
        printf(" imul rax, rdi\n");
        break;
    case'/':
        printf(" cqo\n");
        printf(" idiv rdi\n");
    }

    printf(" push rax\n");
}

修改 main 函数

到这里所有部件已经齐备。接下来修改 main 函数,让编译器实际执行起来。

int main(int argc, char **argv) {
    if (argc != 2) {
        error("参数个数不正确");
        return 1;
    }

    // 进行词法分析和语法分析
    // 结果保存在 code 中
    user_input = argv[1];
    tokenize();
    program();

    // 输出汇编代码的前半部分
    printf(".intel_syntax noprefix\n");
    printf(".globl main\n");
    printf("main:\n");

    // 函数序言
    // 为 26 个变量预留空间
    printf(" push rbp\n");
    printf(" mov rbp, rsp\n");
    printf(" sub rsp, 208\n");

    // 从头开始依次生成表达式代码
    for (int i = 0; code[i]; i++) {
        gen(code[i]);

        // 每个表达式的求值结果会留下一个值
        // 不再需要该值,先弹出丢弃
        printf(" pop rax\n");
    }

    // 函数尾声
    // 最后一个表达式的结果会留在 RAX 中,作为返回值
    printf(" mov rsp, rbp\n");
    printf(" pop rbp\n");
    printf(" ret\n");
    return 0;
}

步骤 10:多字符局部变量

上一节中,我们把变量名写死为 1 个字符,并假定从 az 的 26 个局部变量始终存在。本节支持长度超过 1 个字符的标识符,使下面这样的代码可以编译。

foo = 1;
bar = 2 + 3;
return foo + bar; // 返回 6

变量需要在第一次出现时自动定义。因此,语法分析器在看到每个标识符时,都要判断它是否已经出现过;如果是新的标识符,就要自动在栈上为它分配变量区域。

首先修改词法分析器,让由多个字符组成的标识符也能作为 TK_IDENT 类型的标记读入。

变量用链表表示。每个 LVar 结构体表示一个变量,链表头由 locals 指针保存。代码如下。

typedef struct LVar LVar;

// 局部变量的类型
struct LVar {
    LVar *next; // 下一个变量;末尾为 NULL
    char *name; // 变量名
    int len; // 名称长度
    int offset; // 相对于 RBP 的偏移量
};

// 局部变量
LVar *locals;

语法分析器看到 TK_IDENT 类型标记时,检查该标识符此前是否已经出现。沿着 locals 查找变量名;如果已存在,就使用已有变量的 offset。如果是新变量,就新建一个 LVar,设置新的偏移量,并使用该偏移量。

下面是按变量名查找变量的函数。

// 按变量名查找变量;未找到时返回 NULL。
LVar *find_lvar(Token *tok) {
    for (LVar *var = locals; var; var = var->next)
        if (var->len == tok->len && !memcmp(tok->str, var->name, var->len))
        return var;
    return NULL;
}

把下面的代码加入语法分析器即可。

Token *tok = consume_ident();
if (tok) {
    Node *node = calloc(1, sizeof(Node));
    node->kind = ND_LVAR;

    LVar *lvar = find_lvar(tok);
    if (lvar) {
        node->offset = lvar->offset;
    } else {
        lvar = calloc(1, sizeof(LVar));
        lvar->next = locals;
        lvar->name = tok->str;
        lvar->len = tok->len;
        lvar->offset = locals->offset + 8;
        node->offset = lvar->offset;
        locals = lvar;
    }
    return node;
}

专栏:机器指令的出现频度

观察 9cc 输出的汇编,会发现 movpush 等数据移动指令很多,而 addmul 这类“真正计算”的指令相对较少。这部分原因是 9cc 没有做优化,会输出一些无用的数据移动指令。不过,即使是优化编译器,最常输出的通常也仍然是数据移动指令。作者在自己的环境中反汇编 /bin 下的全部可执行文件并统计指令数,结果如下图所示。

教程示意图
命令的出现頻度

可以看到,mov 指令实际上占了全部指令的约三成。计算机是处理数据的机器,而数据处理最频繁做的事情之一就是移动数据。把数据移动到合适的位置,是数据处理的本质之一;从这个角度看,mov 指令如此之多其实很自然。

步骤 11:return 语句

本节添加 return 语句,使下面这样的代码可以编译。

a = 3;
b = 5*6-8;
return a + b /2;

return 语句也可以写在程序中途。和普通 C 一样,程序执行到第一个 return 时就会中断并从函数返回。例如下面的程序会返回第一个 return 的值,也就是 5。

return 5;
return 8;

为了实现这个功能,先考虑加入 return 后的语法应当是什么样。到目前为止,语句只是表达式;新语法要允许 return <表达式>; 这种形式。因此新语法如下。

program = stmt*
stmt    = expr ";"
        | "return" expr ";"
        | ...

为实现它,词法分析器、语法分析器和代码生成器都需要稍作修改。

首先,词法分析器要能识别 return 这个标记,并用 TK_RETURN 类型表示。returnwhileint 等在语法上具有特殊意义的标记称为关键字。关键字数量有限,因此为每个关键字分配单独类型会比较简单。

只检查剩余输入是否以 return 开头是不够的,否则 returnx 会被错误地切成 returnx。因此还必须确认 return 后面的字符不是构成标识符的字符。

下面是判断某个字符是否能构成标识符的函数。

int is_alnum(char c) {
    return('a' <= c && c <= 'z') ||
    ('A' <= c && c <= 'Z') ||
    ('0' <= c && c <= '9') ||
    (c == '_');
}

使用这个函数,在 tokenize 中加入如下代码,就能把 return 词法分析为 TK_RETURN

if (strncmp(p, "return", 6) == 0 && !is_alnum(p[6])) {
    tokens[i].ty = TK_RETURN;
    tokens[i].str = p;
    i++;
    p += 6;
    continue;
}

接下来修改语法分析器,使它能解析包含 TK_RETURN 的标记列。首先增加表示 return 语句的节点类型 ND_RETURN。然后修改读取语句的函数,使它可以解析 return 语句。和之前一样,语法可以机械地映射为函数调用。新的 stmt 函数如下。

Node *stmt() {
    Node *node;

    if (consume(TK_RETURN)) {
        node = calloc(1, sizeof(Node));
        node->kind = ND_RETURN;
        node->lhs = expr();
    } else {
        node = expr();
    }

    if (!consume(';'))
        error_at(tokens[pos].str, "';'不是标记是");
    return node;
}

这里没有专门写一个构造 ND_RETURN 节点的新函数,而是在现场 malloc 并设置成员。

最后修改代码生成器,让 ND_RETURN 类型节点输出合适的汇编。下面是新的 gen 函数的一部分。

void gen(Node *node) {
    if (node->kind == ND_RETURN) {
        gen(node->lhs);
        printf(" pop rax\n");
        printf(" mov rsp, rbp\n");
        printf(" pop rbp\n");
        printf(" ret\n");
        return;
    }
    ...

上面代码中的 gen(node->lhs) 会输出 return 返回值表达式的代码。该代码会在栈顶留下一个值。后续汇编把这个值弹出到 RAX,然后从函数返回。

到上一章,我们总是在函数末尾输出一条 ret 指令。用本节的方法实现 return 后,每个 return 语句都会输出额外的 return 序列。多输出几条可能执行不到的指令并不会造成问题。此处为了实现简单,允许输出多条 ret。在当前阶段,不必拘泥于这些细节;优先保持实现简单更重要。写复杂代码是一项技能,但“在该简单的时候不把代码写复杂”同样是一项有用技能。

专栏:语法的层级

为了判断输入是否符合某种规则,可以使用“正则表达式”。但更复杂的语法无法用正则表达式表示。例如,判断字符串中括号是否成对匹配,原则上无法用正则表达式描述。

上下文无关语法(可以用 BNF 表示的语法)比正则表达式更强,例如可以表示括号正确匹配的字符串(BNF 可写为 S → SS | "(" S ")" | ε)。但上下文无关语法也有极限,普通编程语言中的复杂规则并不能全部表示。例如“变量必须先声明后使用”是 C 语法的一部分,但这种规则不能用上下文无关语法表达。

C 编译器如果没有缺陷,就可以说“接受的输入都是合法 C 程序,不接受的输入都不是合法 C 程序”。也就是说,用普通计算机的能力可以判断“是否符合 C 语法”。从整体看,编译器是比上下文无关语法更强的语法判定器。这种总能回答 YES/NO 的语法称为可判定(decidable)语法。

还可以考虑不可判定的语法。例如“把一个计算机程序作为输入并执行,它最终会调用 exit 结束,还是会无限执行下去?”这个问题一般无法通过程序判定。也就是说,对于会停止的程序可以回答 YES,但对于不会停止的程序,判定器可能永远执行下去,无法回答 NO。

因此,语法能力大致存在这样的层级:正则表达式 < 上下文无关语法 < 可判定 < 图灵可识别。这些语法层级是计算机科学中的一个重要研究对象。著名的未解决问题 P≟NP 也属于与这种层级有关的问题。

1973 年的 C 编译器

到目前为止,我们一直以增量方式制作编译器。从某种意义上说,这个开发过程也可以说是对 C 历史的复现。

从现代 C 的角度看,有些地方会显得不清楚或不必要地复杂。脱离历史背景,就很难理解这些设计。阅读早期 C 的代码,并了解 C 及其编译器之后的发展,可以帮助理解现代 C 中一些看似费解的地方。

C 最初是在 1972 年作为 Unix 的系统编程语言开始开发的。1972~1973 年,也就是 C 历史极早期的源代码磁带被保留下来,并从中读出了文件后公开在互联网上。下面稍微看一下当时 C 编译器的代码。以下是一个接收 printf 格式字符串并把它显示为编译错误消息的函数。

error(s, p1, p2) {
    extern printf, line, fout, flush, putchar, nerror;
    int f;

    nerror++;
    flush();
    f = fout;
    fout = 1;
    printf("%d: ", line);
    printf(s, p1, p2);
    putchar('\n');
    fout = f;
}

这段代码看起来有些奇怪,像 C 又不像现代 C。阅读这段当时的 C 代码,首先会注意到:和我们编译器的早期阶段一样,函数没有返回值和参数类型。这里的 s 是指向字符串的指针,p1p2 是整数;在当时的机器上它们大小相同,因此可以这样处理。

第 2 行是 error 所引用的全局变量和函数的声明。当时的 C 编译器还没有头文件,也没有 C 预处理器,程序员需要这样手动告诉编译器变量和函数的存在。

和当前的 9cc 一样,当时不会检查函数名是否存在,也不会检查参数类型和个数是否一致。只要按预期个数把参数压到栈上,再跳到函数本体,函数调用就能成立。

fout 是一个全局变量,保存输出目标文件描述符编号。当时还没有 fprintf,为了把字符串输出到标准错误而非标准输出,需要通过全局变量切换输出目标。

error 内部调用了两次 printf。第二次 printf 传入了格式字符串之外的两个值。那么如果错误消息只使用一个值,会怎样呢?

实际上,即使这个 error 函数以少于预期的参数调用,它也能执行。请回想当时还没有参数检查。sp1p2 等参数只是栈指针后第 1、第 2、第 3 个字的位置;是否真的传入了对应值,编译器并不知道。printf 只会按照格式字符串中的 %d%s 个数访问额外参数。如果格式字符串只含一个格式指定符,p2 不会被访问,因此参数数量不一致也不会出问题。

可以看到,早期 C 编译器与当前阶段的 9cc 有一些相似之处。

再看另一个代码例子。下面的代码把传入字符串复制到静态分配区域,并返回该区域开头的指针。也就是说,这是一个使用静态区域的 strdup

copy(s)
char s[]; {
    extern tsp;
    char tsp[], otsp[];

    otsp = tsp;
    while (*tsp++ = *s++);
    return(otsp);
}

当时还没有 int *p 这种声明语法。指针类型被写成 int p[]。函数参数列表和函数体之间插入了变量定义,用来把 s 声明为指针类型。

这个早期 C 编译器还有其他值得注意的地方。

如上所述,20 世纪 70 年代初的 C 缺少很多功能。尽管如此,这个 C 编译器从源代码可知是用 C 写的。也就是说,在连结构体都没有的时代,C 已经完成了自举。

观察旧源代码,也能推测 C 的某些语法为什么会成为现在这样。externautointchar 后面总是变量名,这样的变量定义语法便于解析。指针用 [] 表示,也只是紧跟在变量名之后,解析起来很简单。但也能看出,这种早期编译器所采用的方向一路发展下来,形成了现代 C 中一些不必要复杂的形式。

也就是说,Dennis Ritchie 在 1973 年前后与 Unix 和 C 的共同开发中采用了增量式开发。他一边发展 C,一边用 C 编写它的编译器。现代 C 并不是从一开始就有一个特别明确的最终形态;它只是 Dennis Ritchie 在某个时间点觉得“语言功能已经足够”之后逐步定型的结果。

我们的编译器也不需要一开始就追求最终形态。所谓 C 的完成形并没有特别神圣的意义,没有必要一开始就奔着那里去。每个阶段都让语言拥有合理的功能集合,然后继续开发,最终到达 C。这正是原始 C 编译器曾经采用过的正统开发方式。可以有信心地继续推进。

专栏:Rob Pike 的五条编程规则

9cc 受 Rob Pike 编程观影响很大。Rob Pike 是 C 作者 Dennis Ritchie 的前同事、Go 语言作者之一,也与 Unix 作者 Ken Thompson 一起开发了 Unicode 的 UTF-8。

下面引用 Rob Pike 的“五条编程规则”。

  1. 你无法预先准确预测程序的哪一部分会耗费时间。瓶颈常常出现在令人意外的地方。因此,在弄清瓶颈位置之前,不要凭猜测加入性能技巧。
  2. 测量。在测量之前不要优化。即使测量了,也只优化代码中真正极慢的部分。
  3. 复杂算法在 n 很小时往往很慢,而 n 通常很小。复杂算法的常数项通常较大。除非已经确认 n 很大,否则不要使用复杂算法;即使 n 很大,也要先应用第 2 条。
  4. 复杂算法比简单算法更容易有缺陷,也更难实现。请使用简单算法和简单数据结构。
  5. 数据很重要。选择正确的数据结构并把数据组织好,算法通常会变得显而易见。编程的中心应当是数据结构,而不是算法。

步骤 12:添加控制语句

从这里开始的章节仍处于写作中。前面的章节写得相对细致,但从这里往后还没有达到完全公开成品的水平。尽管如此,能读到这里的读者应当已经可以自行补全很多细节;也有人希望获得后续推进的路线图,因此这里仍然公开。

本节把 ifif ... elsewhilefor 等控制结构加入语言。这些控制结构乍看复杂,但如果直接编译为汇编,实现并不难。

汇编本身没有专门支持 C 控制结构的机制。C 的控制结构在汇编中会被表示为分支指令和标签。这含义着控制结构可以改写为使用 goto 的代码。既然人类可以手工把控制结构改写成 goto,编译器也就可以机械地做同样的转换。

除此之外还有 do ... whilegotocontinuebreak 等控制语句,但当前阶段不需要实现。

加入 ifwhilefor 后的新语法如下。

program = stmt*
stmt    = expr ";"
        | "if" "(" expr ")" stmt ("else" stmt)?
        | "while" "(" expr ")" stmt
        | "for" "(" expr? ";" expr? ";" expr? ")" stmt
        | ...

读取 expr? ";" 时,只要向前看一个标记:如果下一个标记是 ;,就说明表达式不存在;否则读取表达式即可。

if (A) B 可以编译成下面这样的汇编。

编译 A 得到的代码  // 结果应在栈顶
pop rax
cmp rax, 0
je .LendXXX
编译 B 得到的代码
.LendXXX:

也就是说,if (A) B 等价于下面的形式。

if (A == 0)
    goto end;
B;
end:

其中 XXX 可以是连续编号等,用来保证所有标签唯一。

if (A) B else C 可以编译成下面这样的汇编。

编译 A 得到的代码  // 结果应在栈顶
pop rax
cmp rax, 0
je .LelseXXX
编译 B 得到的代码
jmp .LendXXX
.LelseXXX:
编译 C 得到的代码
.LendXXX:

也就是说,if (A) B else C 等价于下面的形式。

if (A == 0)
    goto els;
B;
goto end;
els:
C;
end:

读取 if 语句时,向前看一个标记,检查是否有 else。如果有,就按 if ... else 编译;否则按不带 elseif 编译。

while (A) B 可以编译成下面这样。

.LbeginXXX:
编译 A 得到的代码
pop rax
cmp rax, 0
je .LendXXX
编译 B 得到的代码
jmp .LbeginXXX
.LendXXX:

也就是说,while (A) B 等价于下面的代码。

begin:
if (A == 0)
    goto end;
B;
goto begin;
end:

for (A; B; C) D 可以编译成下面这样。

编译 A 得到的代码
.LbeginXXX:
编译 B 得到的代码
pop rax
cmp rax, 0
je .LendXXX
编译 D 得到的代码
编译 C 得到的代码
jmp .LbeginXXX
.LendXXX:

支持 for (A; B; C) D 的 C 代码如下。

A;
begin:
if (B == 0)
    goto end;
D;
C;
goto begin;
end:

另外,以 .L 开头的标签会被汇编器特殊识别为自动的文件作用域标签。文件作用域标签只能从同一个文件中引用,不能从其他文件引用。因此,编译器为 iffor 生成以 .L 开头的标签时,不必担心与其他文件中的标签冲突。

可以用 cc 编译小循环,参考它输出的汇编来实现。

专栏:编译器检测执行时错误

用 C 写程序时,数组越界写入或指针错误可能破坏无关的数据结构。这类缺陷也会成为安全漏洞。借助编译器主动在执行时检测错误,是一种重要思路。

例如给 GCC 传入 -fstack-protector 选项时,编译器会在函数序言中把一个称为“金丝雀值”(canary)的指针大小随机整数写入函数栈帧,并在尾声中确认该值没有变化。这样,如果数组缓冲区溢出覆盖了栈内容,金丝雀值也会被破坏,函数返回时就能检测到错误。检测到错误时,程序通常会立即终止。

LLVM 的 TSan(ThreadSanitizer)可以输出执行时检查代码,用来检测多个线程是否在没有适当加锁的情况下访问共享数据结构。LLVM 的 UBSan(UndefinedBehaviorSanitizer)则可以输出代码,在执行时检测是否触发 C 的未定义行为。例如有符号整数溢出在 C 中是未定义行为,UBSan 可以在发生时报告错误。

TSan 这类工具会让程序慢数倍,因此不适合作为所有程序的常规编译选项;而栈金丝雀这类执行时成本较低的功能,有些环境会默认启用。

这种借助编译器完成的执行时错误检测,近年来研究很活跃,对使用 C、C++ 这类非内存安全语言编写安全程序非常有帮助。

步骤 13:代码块

本步骤支持在 { ... } 之间写多条语句的“块”(block)。块的正式名称是“复合语句”(compound statement),但名称较长,通常直接称为块。

块可以把多条语句当作一条语句使用。在上一步实现的 ifwhile 中,条件成立时只能执行一条语句;实现块之后,就能像 C 一样在后面写 {} 并放入多条语句。

函数体其实也是块。从语法上讲,函数体必须是块。函数定义中的 { ... },与写在 ifwhile 后面的 { ... } 语法相同。

加入块后的语法如下。

program = stmt*
stmt    = expr ";"
        | "{" stmt* "}"
        | ...

这个语法表示:当 stmt{ 开始时,在出现 } 之前可以读取 0 个或多个 stmt。为了分析 stmt* "}",可以在看到 } 前反复调用 stmt,并把结果作为向量返回。

为实现块,需要增加表示块的节点类型 ND_BLOCK。表示节点的结构体 Node 也要增加用于保存块内表达式/语句向量的成员。代码生成器遇到 ND_BLOCK 时,按顺序生成其中所有语句的代码。另外,每条语句会留下一个值,别忘了每次都把它弹出。

步骤 14:支持函数调用

本步骤的目标是识别 foo() 这样的无参数函数调用,并把它编译为 call foo

加入函数调用后的语法如下。

...
primary = num
| ident ("(" ")")?
| "(" expr ")"

读到 ident 后,需要向前看一个标记,判断该标识符是变量名还是函数名。

测试时可以准备一个包含 int foo() { printf("OK\n"); } 之类内容的 C 文件,用 cc -c 编译为目标文件,再与自己编译器的输出链接。这样可以确认自己的代码能调用外部函数。

能执行后,再支持 foo(3, 4) 这样的函数调用。此时不检查参数个数和类型,只要按顺序求值参数,把参数通过栈临时保存,再按 x86-64 ABI 规定的顺序复制到寄存器中并执行 call。暂时不支持超过 6 个参数。

测试方式与上面类似,可以准备 int foo(int x, int y) { printf("%d\n", x + y); } 这样的函数,链接后执行确认。

x86-64 函数调用 ABI 还有一个注意点:调用函数前,RSP 必须是 16 的倍数。pushpop 以 8 字节为单位修改 RSP,因此执行 call 前 RSP 不一定是 16 的倍数。如果不满足这个约束,某些假定 RSP 为 16 倍数的函数可能以约一半概率崩溃。调用函数前需要调整 RSP,使其成为 16 的倍数。

步骤 15:支持函数定义

到这里终于可以实现函数定义了。C 的完整函数定义语法比较麻烦,因此这里不全部实现。当前语言还没有 int 类型,所以不实现 int foo(int x, int y) { ... },而是实现省略类型名的 foo(x, y) { ... }

在被调用方内部,需要能通过 xy 等名称访问参数;但按现状,值只是通过寄存器传入,无法用名称访问。解决办法是把 xy 当作局部变量存在,在函数序言中把寄存器中的值写入这些局部变量对应的栈上区域。之后就不需要特别区分参数和局部变量。

到目前为止,我们相当于隐式地把整个输入包在 main() { ... } 中执行。现在废除这一点,要求所有代码都写在某个函数中。解析顶层时,先读取函数名,接着读取参数列表,然后读取函数体;按这个顺序简单读取即可。

本步骤完成后,就可以用递归计算并显示斐波那契数列,这会很有趣。

二进制层面的接口

C 语言规范规定的是源代码层面的规格。例如哪些写法可以定义函数、哪个文件包含哪些函数声明等。一份符合标准的源代码怎样转换为机器码,并不由 C 语言标准规定。C 标准并不以特定指令集为前提,这是自然的。

因此乍看似乎还需要另一个机器码层面的规范。实际上,各个平台会在一定程度上规定这样的规范,这种规范称为 ABI(Application Binary Interface,应用二进制接口)。

本书迄今为止已经说明过:函数调用方要按特定顺序把参数放入寄存器,返回值放入 RAX。这类函数调用方规则称为“函数调用规约”(function calling convention)。函数调用规约是 ABI 的一部分。

C 语言 ABI 除了参数和返回值的传递方式,还包括下面这些内容。

ABI 不是软件层面的任意约定,而是为了让不同编译器生成的代码能互相调用而必须遵守的规则。通常 CPU 厂商和 OS 厂商会为平台定义标准 ABI。x86-64 上广泛使用的 ABI 主要有两种:Unix/macOS 使用的 System V ABI,以及 Windows 使用的 Microsoft ABI。这两种调用规约没有技术上的必然性差异,只是由不同的人分别制定。

到目前为止,我们已经让自制编译器调用了由其他编译器编译的函数。这之所以可行,是因为我们的 C 编译器遵守了与其他编译器相同的 ABI。