阅读 chibicc 笔记
从 chibicc 中学习编译原理和指令集。
DAY8 - 拆分 main.c
对应提交 725badf。
在上一周中,我们的 main.c 越来越大了,是时候对其进行拆分了。
拆分后有结构如下:
+- chibicc.h # 公共头文件
+- codegen.c # 用于生成汇编
+- main.c # 入口
+- parse.c # 用于生成 AST
`- tokenize.c # 用于生成 token 流
为此我们也变更了我们的 Makefile:
CFLAGS=-std=c11 -g -fno-common
SRCS=$(wildcard *.c)
OBJS=$(SRCS:.c=.o)
chibicc: $(OBJS)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
$(OBJS): chibicc.h
test: chibicc
./test.sh
这里可以看到我们不再是编译成一个对象文件再链接了,现在我们会编译成多个对象文件。
我们使用 SRCS 变量来引用所有 .c 文件。可以看到我们使用到了 wildcard 函数,这个函数接收一个带有通配符的参数并负责展开。在这里它会展开会 codegen.c main.c parse.c tokenize.c。而 OBJS 则表示了所有对象文件,这里使用了一种叫替换引用的高级用法,它会将 SRCS 变量中所有以 .c 结尾的单词替换为以 .o 解决,并返回,它对应着 codegen.o main.o parse.o tokenize.o。
chibicc 规则的配方中有一些 Makefile 的特殊变量,它们被称为自动变量:
$@。这个表示目标文件,在这里它会被扩展为chibicc。$^。这个表示所有的依赖文件,在这里它会首先被扩展为$(OBJS)(然后又接着被扩展为一系列对应的*.o文件)。
这个规则描述了如何从一堆对象文件中生成 chibicc 二进制文件。
然后 $(OBJS) 对应的规则描述了如何生成对象文件。对象文件对应的内置规则在上周中已经指出了,我们会使用 $(CC) $(CPPFLAGS) $(CFLAGS) -c 来编译对应的 .c 文件。这里的含义就是,对于 codegen.o 而言,它会依赖文件 codegen.c 和 chibicc.h 文件,并使用这个内置的命令完成对象文件的生成。
DAY9 - 支持使用分号隔开的语句
对应提交 76cae0a。
我们现在打算再更改下 BNF,使得我们能支持多个语句构成的程序:
1; 2 + 5; 3;
我们希望生成的汇编程序返回的是最后一个表达式求的值(即 3)。原来的 BNF 如下:
expr = equality
equality = relational ("==" relational | "!=" relational)*
relational = add ("<" add | "<=" add | ">" add | ">=" add)*
# ...
现在我们变更为如下的 BNF:
program = stmt*
stmt = expr-stmt
expr-stmt = expr ";"
expr = equality
equality = relational ("==" relational | "!=" relational)*
relational = add ("<" add | "<=" add | ">" add | ">=" add)*
# ...
其中前三行为今天需要支持的。即我们引入了 program、stmt 和 expr-stmt,语义分别是一个完整的程序,语句,和表达式语句(即内容为一个简单表示式的语句)。
为了能支持这个,AST 需要支持 ND_EXPR_STMT node,这个节点被用来表示一个表达式语句。它只有一个子树(即表达式对应的子树)。同时 Node 需要额外由一个字段 next 来表示链表:
// AST node type
typedef struct Node Node;
struct Node {
NodeKind kind; // Node kind
Node *next; // Next node
Node *lhs; // Left-hand side
Node *rhs; // Right-hand side
int val; // Used if kind == ND_NUM
};
对于 1; 2 + 5; 3; 而言,我们可以将其表示为一个由三个 ND_EXPR_STMT nodes 构成的链表,分别对应表达式 1、表达式 2 + 5 和表达式 3。
即,这里我们设计上整个程序的 AST 被表示为:
- 一个
ND_EXPR_STMTnode 构成的链表; - 每一个
ND_EXPR_STMTnode 都指向一个表达式。
在代码生成上,每个表达式语句和之前的表达式一样,都会负责在栈顶上给出计算后的结果,不过我们只会返回最后一个表达式的值。
DAY10 - 支持单个字符的本地变量
对应提交 1f9f3ad。
今天我们打算支持 a = 3; z = 5; a + z; 这种程序,我们预期其生成的汇编程序能够正确返回 8。
Token 流阶段
为此我们需要支持一个新的 TK_IDENT token。当 'a' <= *p && *p <= 'z' 的时候,我们会生成一个 TK_IDENT token。
这使得对于 a = 3 而言,它会生成三个 token,分别是标识符 token a、标点符号 token = 和数字字面量 token 3。
AST 阶段
注意到,我们不仅仅支持了变量,我们还支持了对变量的赋值。赋值也是一个表达式,一个有副作用的表达式。赋值本身返回右侧表达式计算后得出的值,同时它还会令符号对应的内存变更为该值。所以原来的 BNF:
# ...
stmt = expr-stmt
expr-stmt = expr ";"
expr = equality
equality = relational ("==" relational | "!=" relational)*
# ...
primary = "(" expr ")" | num
现在变更为了:
# ...
stmt = expr-stmt
expr-stmt = expr ";"
expr = assign
assign = equality ("=" assign)?
equality = relational ("==" relational | "!=" relational)*
# ...
primary = "(" expr ")" | ident | num
注意变化为:
- 一个是我们引入了一个优先级更低的 赋值表达式,即这里的
assign; - 一个是我们的符号能够作为
primary。我们对符号求值,得到的就是它所持有的值。
为此我们引入了两种新的 node,一个是 ND_ASSIGN node,被用来表示赋值操作,另外一个是 ND_VAR node,被用来表示一个变量。Node 也因此多了一个字段 char name:
// AST node type
typedef struct Node Node;
struct Node {
NodeKind kind; // Node kind
Node *next; // Next node
Node *lhs; // Left-hand side
Node *rhs; // Right-hand side
char name; // Used if kind == ND_VAR
int val; // Used if kind == ND_NUM
};
以 a = 3; z = 5; a + z; 为例,我们有:

在从 token 流中解析出 AST 的过程中,在 primary 函数中如果我们遇到了 TK_IDENT token,那么我们需要按照 primary = ident 来对其规约,所以我们需要将其转换为一个 ND_VAR node:
static Node *primary(Token **rest, Token *tok) {
if (equal(tok, "(")) {
// Handle `"(" expr ")"` and return...
}
if (tok->kind == TK_IDENT) {
Node *node = new_var_node(*tok->loc);
*rest = tok->next;
return node;
}
// ...
}
这里的 new_var_node 函数会返回一个 ND_VAR node。
汇编生成阶段
最有意思的就是汇编生成阶段了。我们知道变量的值应该存储在内存中,问题是我们怎么在汇编中对其进行表示呢?
简单起见,今天我们在这里的实现是预先分配一块内存,从而作为变量 a 到 z 对应的内存。给出 gen_addr(获取变量内存地址)代码如下:
// Compute the absolute address of a given node.
// It's an error if a given node does not reside in memory.
static void gen_addr(Node *node) {
if (node->kind == ND_VAR) {
int offset = (node->name - 'a' + 1) * 8;
printf(" lea %d(%%rbp), %%rax\n", -offset);
return;
}
error("not an lvalue");
}
比如给定一个 ND_VAR node,其表示变量 a,那么我们能算出 offset 为 8,对应汇编代码如下:
lea -8(%rbp), %rax
我们知道 %rbp 指向的是栈基位置,所以 -8(%rbp) 就是当前栈的第一个元素(我们假设所有元素的 size 都为 8 字节的话)对应的地址。值得注意的是,栈是从高地址长到低地址的,这也是为什么我们需要使用负数。
这里和之前简单地使用立即数 $100 或者使用寄存器 %rax 不同,这里我们引入了一个稍微复杂的表示方法 -8(%rbp),这背后是一整套 “寻址模式”。我们分为:
- 立即数寻址。类似
$100。它就表示一个常数。 - 寄存器寻址。类似
%rax。它表示对应寄存器的值。 - 内存寻址。它有着最丰富的寻址模式。
- 绝对/直接寻址。我们可以直接使用一个固定的内存地址。比如
movq 0x104, %rax这里我们直接将内存中0x104对应的值写入到寄存器%rax中。 - 间接寻址。如果地址存放在寄存器中,那么我们可以使用
(%rax)这种语法,例如movq (%rax), %rbx,这里我们假设%rax为地址,那么我们会将%rax指向的内存中的值写入到%rbx中。 - 基址+偏移量寻址。即在间接寻址的基础上加一个偏移量。我们可以使用
4(%rax)这种语法。例如movq 4(%rax), %rbx。对于那些使用到数组的代码而言我们很容易看到这种寻址方式,比如字节数组的a[7]这种。 - 变址寻址。如果我们在编译期间无法知道偏移量呢?比如字节数组的
a[i]这种?如果在访问内存之前还需要显式执行add指令来计算内存地址可能对性能而言不是很友好。我们使用(%rax, %rbx)这种语法,然后我们会在一条指令中计算%rax + %rbx的值,将其结果作为地址来表示对应的内存位置。 - 比例变址寻址。我们可以将变址寄存器乘一个比例因子,例如数组每个元素长度都是 8 个字节,那么
a[i]对应的地址实际上是a的地址加上i * 8。我们可以使用(%rax,%rbx,8)这种语法。我们可以省略基址寄存器(这样认为其值为 0),那么有(,%rbx,8),或者可以带上偏移量,有8(,%rbx,8)。 - RIP 相对寻址。我们可以使用
symbol(%rip)这种语法,从而实现位置无关代码。这个寻址方式不在本周讨论范围内。
- 绝对/直接寻址。我们可以直接使用一个固定的内存地址。比如
这里我们有一个新指令 LEA:
- LEA
它是 load effective address 的首字母缩写,译为加载有效地址。
例如,对于
lea (%rax), %rbx而言,我们会将(%rax)这个内存对应的地址写入到%rbx。但是,(%rax)对应的地址不正是%rax的值吗?所以lea (%rax), %rbx在效果上和mov %rax, %rbx上是一样的。因为我们有多种寻址方式,这使得我们可以使用 LEA 指令来完成一些算数操作。例如
lea -8(%rbp), %rax会将-8(%rbp)的地址写入到%rax之中,而它的地址就是%rbp - 8得到的值。
实际上这里的 LEA 指令我们可以用伪代码表示为:
%rax = %rbp - 8
这样,我们的 %rax 就可以指向 a 对应的内存地址了。
虽然 lea 这个指令的含义为加载某个地址,但是实际中用它来做一些算术操作不仅挺方便,而且还非常常见。比如下面就是 CSAPP [2] 给出的一个例子:
scale: ; long scale(long x, long y, long z)
leaq (%rdi,%rsi,4), %rax ; %rax = x + 4 * y
leaq (%rdx,%rdx,2), %rdx ; %rdx = z + 2 * z = 3 * z
leaq (%rax,%rdx,4), %rax ; %rax = (x + 4 * y) + (3 * z) * 4 = x + 4 * y + 12 * z
ret
这里我们只用了三个 LEA 指令,就能够计算出 x + 4 * y + 12 * z 的值。在生产级别的编译器中我们也经常有这种优化。
总之,对于 ND_VAR node 而言,我们会生成代码如下:
lea -8(%rbp), %rax
mov (%rax), %rax
第一个指令就是刚刚我们讨论的 gen_addr 生成的,它负责将对应变量的地址写入到 %rax。而第二行负责将内存中的值读入到 %rax 中。这两个指令负责将变量 a 对应的值加载到 %rax 寄存器中。
而对于 ND_ASSIGN node 而言(比如 a = 5; 这个赋值语句),我们需要将右子树的值求出来,写入到 %rax 之后,将对应的值写入到指定内存中,有:
; (1)
lea -8(%rbp), %rax
push %rax
; (2)
mov $5, %rax
; (3)
pop %rdi
mov %rax, (%rdi)
对应三步:
- 将
a的地址推入到栈中。 - 将
5对应的 AST 求值并写入到%rax中。 - 将
%rax的值写入到a中(先通过 POP 指令让%rdi为a的地址,然后通过 MOV 指令来将值写入到内存中)。
不过为了支持在内存中保存变量,我们的代码也需要一些额外的工作。这里是 chibicc 生成出来的一个汇编:
.globl main
main:
push %rbp ; Move the %rbp into the stack. (We will recover it later)
mov %rsp, %rbp ; Let the %rbp point to the new frame's bottom.
sub $208, %rsp ; Let the stack (which is pointed by the %rsp) grow.
; Here $208 means ('z' - 'a' + 1) * 8 -- @ksco.
; Yes, we ask stack space for those 26 variables.
lea -8(%rbp), %rax
push %rax
mov $5, %rax
pop %rdi
mov %rax, (%rdi)
mov %rbp, %rsp ; Let the stack shrink.
pop %rbp ; Recover the %rbp.
ret
这里可以看到我们多了一些指令,这些指令是用来帮助我们分配栈资源的。我们分配了 208 个字节的空间,这用于放置 26 的字母对应的变量。
值得注意的是,main 不是程序的入口,是程序的入口(由 libc 提供,比如 glibc 提供的入口)最终会调用 main 程序,所以它有责任释放申请的栈资源,这里可以看到我们最终释放掉了分配的 208 个字节的空间。
DAY11 - 支持多个字符的本地变量
对应提交 482c26b。
我们在今天尝试支持多个本地变量,比如 foo123 = 3; bar = 5; foo123 + bar 这种程序,我们希望它返回的是 8。
Token 流阶段
原来我们的逻辑只支持单个字符的变量,所以下面的逻辑绰绰有余:
if ('a' <= *p && *p <= 'z') {
cur = cur->next = new_token(TK_IDENT, p, p + 1);
p++;
continue;
}
现在我们要支持多个字符的本地变量,那么我们定义两个辅助函数 is_ident1 和 is_ident2,前者返回给定字符 char c 是否满足 /[a-zA-Z_]/,而后者支持数字:/[a-zA-Z0-9_]/,那么将其变更为如下逻辑即可:
if (is_ident1(*p)) {
char *start = p;
do {
p++;
} while (is_ident2(*p));
cur = cur->next = new_token(TK_IDENT, start, p);
continue;
}
可以看到,我们现在能匹配类似 foo123 等的 token 了。
AST 阶段
我们需要修改 AST。有两点需要注意到:
- 首先我们原来使用
char name字段来表示变量的名字,但是现在我们不能这么表示。因为char只能支持长度为 1 的标识符。 - 原来我们可以预先生成 26 个字符对应的内存空间。但是一旦我们需要支持多个字符的变量,我们就没办法预先分配了。我们需要在代码生成之前确认下我们会用到几个变量。
对于这两个问题,我们定义了 Obj 结构体,我们使用它来表示变量。有:
typedef struct Obj Obj;
struct Obj {
Obj *next;
char *name;
int offset;
};
我们使用 Obj 链表来表示所有本地变量。其中 name 只想名字,而 offset 则表示其在内存中的偏移情况。
在昨天,我们如果遇到了 TK_IDENT token,那么我们会通过 new_var_node(*tok->loc) 来创建对应的变量 node,而今天,我们如果遇到了 TK_IDENT token 的时候,我们需要:
- 检查之前是否已经有为变量分配一个
Obj对象了? - 如果没有的话,那么创建一个
Obj对象。 - 根据变量对应的
Obj对象,生成ND_VARnode。
即有:
if (tok->kind == TK_IDENT) {
Obj *var = find_var(tok); // Get the Obj struct by token's name.
if (!var)
// If it is not existing, create a new Obj struct.
var = new_lvar(strndup(tok->loc, tok->len));
*rest = tok->next; // Let the rest know this token is consumed.
return new_var_node(var); // Return the ND_VAR node.
}
C 语言小课堂:
strndup接受一个char *str指针和一个size_t size用来表示长度,它会从str指针指向的字符串中截取size个字符,并在末尾加上'\0'字符,最后分配内存并返回给调用者。调用者需要使用free来完成释放(不过如 Rui314 所述,这个项目中我们不会释放内存,这个工作被交给了操作系统)。
我们使用新的 Node 结构体来表示 ND_VAR node:
struct Node {
NodeKind kind; // Node kind
Node *next; // Next node
Node *lhs; // Left-hand side
Node *rhs; // Right-hand side
Obj *var; // Used if kind == ND_VAR
int val; // Used if kind == ND_NUM
};
可以看到如果是 ND_VAR node,我们会使用 var 来指向对应的变量信息。
其中,我们是这样通过 new_lvar 函数来生成 Obj 对象并返回的:
// All local variable instances created during parsing are
// accumulated to this list.
Obj *locals;
static Obj *new_lvar(char *name) {
Obj *var = calloc(1, sizeof(Obj));
var->name = name;
var->next = locals;
locals = var;
return var;
}
我们使用全局变量 locals 来保存所有的 Obj 对象。在调用 new_lvar 的时候就会将新的 Obj 对象注册进去。我们只初始化了 name,而没有初始化 offset,后者我们将在 “汇编生成阶段” 进行计算。
寻找 Obj 对象的函数 find_var 比较简单,我们遍历 locals 并看看是否有相同名称的 Obj 对象:
// Find a local variable by name.
static Obj *find_var(Token *tok) {
for (Obj *var = locals; var; var = var->next)
if (strlen(var->name) == tok->len && !strncmp(tok->loc, var->name, tok->len))
return var;
return NULL;
}
最后,我们定义了 Function 结构体,如下:
typedef struct Function Function;
struct Function {
Node *body;
Obj *local;
int stack_size;
};
相比于之前直接使用 Node * 而言(准确来说,是一串 ND_EXPR_STMT nodes 链表),然后生成汇编而言,我们使用了 Function 结构体,除了使用 Node *body 来表示之前的 nodes 链表之外,我们还有 Obj *local 表示所有本地变量,以及一个 stack_size 表示它使用的栈空间大小(回想下,昨天我们在汇编生成阶段分配栈,然后我们会分配了 个字节,现在可以根据需要来进行分配了,我们只需要在生成前计算下栈空间大小即可)。
这样,我们的 parse 函数不再仅仅返回 Node * 了,而是返回一个 Function 对象。有:
Function *parse(Token *tok) {
// ... Build the AST, and let head.next points it.
Function *prog = calloc(1, sizeof(Function));
prog->body = head.next;
prog->locals = locals;
return prog;
}
stack_size 字段没有被初始化。我们将在 “汇编生成阶段”,和 Obj 的 offset 字段一起初始化。
汇编生成阶段
终于来到了生成汇编这一步了。我们刚刚具有从 token 流中生成 Function 的能力,接下来我们的 codegen 就不再接受 Node,而改为接受 Function 对象了:
void codegen(Function *prog) {
assign_lvar_offsets(prog);
// ...
}
我们需要意识到,prog 中有一些字段还没有被有效地初始化,一个是变量到底在栈中偏移多少(Obj 的 offset 字段),另外一个是这个函数一开始需要分配多少空间来作为栈(Function 的 stack_size 字段)。这里 assign_lvar_offsets 函数负责先对其初始化:
// Round up `n` to the nearest multiple of `align`. For instance,
// align_to(5, 8) returns 8 and align_to(11, 8) returns 16.
static int align_to(int n, int align) {
return (n + align - 1) / align * align;
}
// Assign offsets to local variables.
static void assign_lvar_offsets(Function *prog) {
int offset = 0;
for (Obj *var = prog->locals; var; var = var->next) {
offset += 8;
var->offset = -offset;
}
prog->stack_size = align_to(offset, 16);
}
我们遍历 prog->locals,为每一个变量(即 Obj 对象对应的变量)分配 8 字节的空间,并妥善设置好 offset 字段。而最终栈的大小为 align_to(offset, 16),即一个能容纳所有变量并按照 16 字节来对齐的空间。
注意,这里之所以按照 16 字节对齐是为了遵守 System V ABI 约定。
这样,我们就不用一开始分配 个字节了,相反,我们只用分配 prog->stack_size 个字节即可:
void codegen(Function *prog) {
// ...
// Alloc the space for stack. The original code is:
//
// printf(" sub $208, %%rsp\n");
printf(" sub $%d, %%rsp\n", prog->stack_size);
// ...
}
对应的,当我们访问一个变量的时候,我们需要将这个变量的地址(昨天这还只是一个特殊的映射,将 /[a-z]/ 的变量映射到一个地址)加载到 %rax 中的,现在我们不需要知道具体的变量名字,相反,我们可以直接访问 node->var->offset 即可:
// Compute the absolute address of a given node.
// It's an error if a given node does not reside in memory.
static void gen_addr(Node *node) {
if (node->kind == ND_VAR) {
printf(" lea %d(%%rbp), %%rax\n", node->var->offset);
return;
}
error("not an lvalue");
}
DAY12 - 支持 return
对应提交 6cc1c1f。
我们让最后一个表达式带上 return 关键字来表示我们想要返回的值。比如原来我们可以这么写:foo = 5; bar = 3; foo + bar;,而现在我们需要 foo = 5; bar = 3; return foo + bar;。我们还可以提前返回:1; return 2; 3; 这个表达式返回的应该是 2。
这也是我们的第一个关键字。关键字和标识符的匹配逻辑是一样的。在 Rui314 的实现中,chibicc 在返回 token 流前会调用下面这个 convert_keywords 函数:
static void convert_keywords(Token *tok) {
for (Token *t = tok; t->kind != TK_EOF; t = t->next)
if (equal(t, "return"))
t->kind = TK_KEYWORD;
}
即它会遍历整个 token 流,然后检查 t 这个 token 对应的字符串是否等于 return,而如果等于,我们将认为它是 TK_KEYWORD token。
然后我们接下来支持新的 AST node --- 即 ND_RETURN node。现在一个语句可能是一个表达式语句,也可能是一个 return 语句了:
// stmt = "return" expr ";"
// | expr-stmt
static Node *stmt(Token **rest, Token *tok) {
if (equal(tok, "return")) {
Node *node = new_unary(ND_RETURN, expr(&tok, tok->next));
*rest = skip(tok, ";");
return node;
}
return expr_stmt(rest, tok);
}
最后我们看到汇编生成部分,我们新加了一个标签和一个 jmp 指令(jmp 指令能够帮助我们跳转到指定位置)。例如 1; return 2; 3; 会被生成为:
.globl main
main:
push %rbp
mov %rsp, %rbp
sub $0, %rsp
mov $1, %rax
mov $2, %rax
jmp .L.return
mov $3, %rax
.L.return:
mov %rbp, %rsp
pop %rbp
ret
可以看到,函数的返回处由 .L.return 标签指出。而 return 语句会在生成完对应表达式的语句之后,立马生成一个 jmp .L.return 语句,从而结束程序。有:
static void gen_stmt(Node *node) {
switch (node->kind) {
case ND_RETURN:
gen_expr(node->lhs);
printf(" jmp .L.return\n");
return;
case ND_EXPR_STMT:
gen_expr(node->lhs);
return;
}
error("invalid statement");
}
DAY13 - 支持花括号
对应提交 18ac283。
今天我们尝试让我们的程序通过一个花括号来括起来。即:
{ 1; 2; return 3; }
我们甚至可以支持花括号的嵌套:
{ { 1; { 2; } return 3; } }
这里我们更改下我们的 BNF(原来的 program 是 stmt*,这里注意到我们加入了一个新的元素 compound_stmt,被用来表示多个 stmt 和一个随后的 "}" 字符):
program = "{" compound_stmt
compound_stmt = stmt* "}"
stmt = "return" expr ";"
| "{" compound_stmt
| expr-stmt
而 compound_stmt 函数则会返回一种新类型的 ND_BLOCK node。这个 node 的 body(这个字段是我们在今天为 Node 新加的一个字段)即为多个 stmt AST 子树所构成的列表。
对于汇编生成环节,我们基本上没有改动相关逻辑,而我们只是:
- 认为
Function的body只有一个元素,而不再是之前的那个链表了,所以这里我们只会调用gen_stmt一次。 - 在我们通过
gen_stmt来遍历 AST,同时生成汇编代码的时候,我们支持了ND_BLOCKnode,对于这种 node,我们会遍历它的body,为每一个元素都递归调用一下gen_stmt。
DAY14 - 支持空语句
对应提交 ff8912c。
今天我们尝试支持空语句,即:
{ ;;; return 5; }
这里空语句的含义是什么都不做。也就是什么汇编代码都不生成的意思。我们可以让其对应一个 new_node(ND_BLOCK)。我们知道通过这样生成的 node,它的 body 必然是空链表,对应的,它就会什么汇编代码都不生成。对应的,BNF 有:
expr-stmt = expr? ";"
实现非常简单。