阅读 chibicc 笔记

从 chibicc 中学习编译原理和指令集。

1写在前面
2Week 1
3Week 2
4Week 3
5未完待续......

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.cchibicc.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)*
# ...

其中前三行为今天需要支持的。即我们引入了 programstmtexpr-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_STMT node 构成的链表;
  • 每一个 ND_EXPR_STMT node 都指向一个表达式。

在代码生成上,每个表达式语句和之前的表达式一样,都会负责在栈顶上给出计算后的结果,不过我们只会返回最后一个表达式的值。

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; 为例,我们有:

AST-for-assign-stmts

在从 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。

汇编生成阶段

最有意思的就是汇编生成阶段了。我们知道变量的值应该存储在内存中,问题是我们怎么在汇编中对其进行表示呢?

简单起见,今天我们在这里的实现是预先分配一块内存,从而作为变量 az 对应的内存。给出 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,那么我们能算出 offset8,对应汇编代码如下:

  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 指令让 %rdia 的地址,然后通过 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_ident1is_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。有两点需要注意到:

  1. 首先我们原来使用 char name 字段来表示变量的名字,但是现在我们不能这么表示。因为 char 只能支持长度为 1 的标识符。
  2. 原来我们可以预先生成 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 的时候,我们需要:

  1. 检查之前是否已经有为变量分配一个 Obj 对象了?
  2. 如果没有的话,那么创建一个 Obj 对象。
  3. 根据变量对应的 Obj 对象,生成 ND_VAR node。

即有:

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 表示它使用的栈空间大小(回想下,昨天我们在汇编生成阶段分配栈,然后我们会分配了 26×826 \times 8 个字节,现在可以根据需要来进行分配了,我们只需要在生成前计算下栈空间大小即可)。

这样,我们的 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 字段没有被初始化。我们将在 “汇编生成阶段”,和 Objoffset 字段一起初始化。

汇编生成阶段

终于来到了生成汇编这一步了。我们刚刚具有从 token 流中生成 Function 的能力,接下来我们的 codegen 就不再接受 Node,而改为接受 Function 对象了:

void codegen(Function *prog) {
  assign_lvar_offsets(prog);

  // ...
}

我们需要意识到,prog 中有一些字段还没有被有效地初始化,一个是变量到底在栈中偏移多少(Objoffset 字段),另外一个是这个函数一开始需要分配多少空间来作为栈(Functionstack_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 约定。

这样,我们就不用一开始分配 26×826 \times 8 个字节了,相反,我们只用分配 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(原来的 programstmt*,这里注意到我们加入了一个新的元素 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 子树所构成的列表。

对于汇编生成环节,我们基本上没有改动相关逻辑,而我们只是:

  • 认为 Functionbody 只有一个元素,而不再是之前的那个链表了,所以这里我们只会调用 gen_stmt 一次。
  • 在我们通过 gen_stmt 来遍历 AST,同时生成汇编代码的时候,我们支持了 ND_BLOCK node,对于这种 node,我们会遍历它的 body,为每一个元素都递归调用一下 gen_stmt

DAY14 - 支持空语句

对应提交 ff8912c。

今天我们尝试支持空语句,即:

{ ;;; return 5; }

这里空语句的含义是什么都不做。也就是什么汇编代码都不生成的意思。我们可以让其对应一个 new_node(ND_BLOCK)。我们知道通过这样生成的 node,它的 body 必然是空链表,对应的,它就会什么汇编代码都不生成。对应的,BNF 有:

expr-stmt = expr? ";"

实现非常简单。