chibicc ソースコードリーディング
2020/12/07のものを読んでいく。
$ g rev-parse @
90d1f7f199cc55b13c7fdb5839d1409806633fdb
$ wc -l *.{c,h}
1595 codegen.c
165 hashmap.c
791 main.c
3368 parse.c
1208 preprocess.c
31 strings.c
805 tokenize.c
307 type.c
189 unicode.c
457 chibicc.h
8916 total
Rust上でのコンパイラ実装のための学習を目的としているので、いくつかのファイルはさらっと流していく...というよりmain.cとcodegen.c以外はさらっと流していく。
hashmap.c
typedef struct {
char *key;
int keylen;
void *val;
} HashEntry;
typedef struct {
HashEntry *buckets;
int capacity;
int used;
} HashMap;
void *hashmap_get(HashMap *map, char *key);
void *hashmap_get2(HashMap *map, char *key, int keylen);
void hashmap_put(HashMap *map, char *key, void *val);
void hashmap_put2(HashMap *map, char *key, int keylen, void *val);
void hashmap_delete(HashMap *map, char *key);
void hashmap_delete2(HashMap *map, char *key, int keylen);
void hashmap_test(void);
ハッシュマップの実装。ハッシュ関数にFNVを使っていてシンプル。
strings.c
typedef struct {
char **data;
int capacity;
int len;
} StringArray;
void strarray_push(StringArray *arr, char *s);
char *format(char *fmt, ...) __attribute__((format(printf, 1, 2)));
growableな文字列の配列。イテレートは直接forループで行ってるようだ。
for (int i = 0; i < include_paths.len; i++) {
char *data = include_paths.data[i];
...
}
unicode.c
int encode_utf8(char *buf, uint32_t c);
uint32_t decode_utf8(char **new_pos, char *p);
bool is_ident1(uint32_t c);
bool is_ident2(uint32_t c);
int display_width(char *p, int len);
C11は識別子に特定の範囲のユニコード文字を許容するので、そのための実装がある。ソースコードはUTF-8で記述される (これは最近のプログラミング言語で最も一般的だろう)。
preprocess.c
CPPについては本筋から外れるので飛ばした
token.c
// Token
typedef enum {
TK_IDENT, // Identifiers
...
TK_EOF, // End-of-file markers
} TokenKind;
typedef struct {
char *name;
int file_no;
char *contents;
...
} File;
// Token type
typedef struct Token Token;
struct Token {
TokenKind kind; // Token kind
Token *next; // Next token
int64_t val; // If kind is TK_NUM, its value
long double fval; // If kind is TK_NUM, its value
char *loc; // Token location
int len; // Token length
Type *ty; // Used if TK_NUM or TK_STR
...
};
...
bool equal(Token *tok, char *op);
Token *skip(Token *tok, char *op);
bool consume(Token **rest, Token *tok, char *str);
void convert_pp_tokens(Token *tok);
File **get_input_files(void);
File *new_file(char *name, int file_no, char *contents);
Token *tokenize_string_literal(Token *tok, Type *basety);
Token *tokenize(File *file);
...
READMEにもある通りunionを使ってないのが少し特徴的。
chibiccでは T *next;
の形のリンクリストを用いたデータ構造が各所で使われているようで、 struct Token
も Token *next
をフィールドに持ち、 tokenize(File *file)
も返値は単に Token*
である。
type.c
typedef enum {
TY_VOID,
TY_BOOL,
...
TY_STRUCT,
TY_UNION,
} TypeKind;
struct Type {
TypeKind kind;
int size; // sizeof() value
int align; // alignment
...
// Pointer-to or array-of type. We intentionally use the same member
// to represent pointer/array duality in C.
// ...
Type *base;
// Declaration
Token *name;
Token *name_pos;
// Array
int array_len;
...
// Struct
Member *members;
bool is_flexible;
bool is_packed;
// Function type
Type *return_ty;
Type *params;
bool is_variadic;
Type *next;
};
// Struct member
struct Member {
Member *next;
Type *ty;
Token *tok; // for error message
Token *name;
...
// Bitfield
...
};
extern Type *ty_void;
extern Type *ty_bool;
...
bool is_integer(Type *ty);
bool is_flonum(Type *ty);
bool is_numeric(Type *ty);
bool is_compatible(Type *t1, Type *t2);
Type *copy_type(Type *ty);
Type *pointer_to(Type *base);
Type *func_type(Type *return_ty);
Type *array_of(Type *base, int size);
Type *vla_of(Type *base, Node *expr);
Type *enum_type(void);
Type *struct_type(void);
void add_type(Node *node);
型。 Type
という大きな構造体が一つ。型の全ての情報はこの構造体からアクセス可能なようだ。
例えば struct_type
が以下のように実装されていて、ユニークなIDを振ったりする実装が無いことから、型の同一性はポインタ Type*
の一致で判定される。
Type *struct_type(void) {
return new_type(TY_STRUCT, 0, 1);
}
Cの仕様で compatible
がどういう意味なのかは調べてないが、二つの型が互換かどうかの判定は bool is_compatible(Type *t1, Type *t2)
で実装されている。この関数は、
- まず同一性をチェック (同一ならtrue)
-
origin
(次に追ってみる) ポインタを辿る -
t1->kind != t2->kind
ならfalse -
t1->kind
に基づいて、base
return_ty
params
といった複合型の構成要素をis_compatible
で再帰的に判定- なお、Cのstruct等に構造的型付けはないため、この時点でstruct同士の場合はfalseとなる
Type *copy_type(Type *ty)
は、calloc、Type構造体のフィールドを完全にコピーしたのち origin
に引数の型を指すように設定した新しい型を返している。 struct T { .. }; struct T a; struct T b;
のようなプログラムで呼ばれるのかと思うので覚えておく (ネタバレするとparse.cで呼ばれている)。
add_type
は Node
型をまだ追ってないのでTODO。
parse.c
chibiccで一番大きい。手書きの再帰下降型パーサ。ASTの構築に加え、各ASTノードに型情報の付与を行っている (READMEより)。
// Variable or function
typedef struct Obj Obj;
struct Obj {
Obj *next;
char *name; // Variable name
Type *ty; // Type
Token *tok; // representative token
bool is_local; // local or global/function
int align; // alignment
// Local variable
int offset;
// Global variable or function
bool is_function;
bool is_definition;
bool is_static;
// Global variable
bool is_tentative;
bool is_tls;
char *init_data;
Relocation *rel;
// Function
bool is_inline;
Obj *params;
Node *body;
Obj *locals;
Obj *va_area;
Obj *alloca_bottom;
int stack_size;
...
};
// Global variable can be initialized either by a constant expression
// or a pointer to another global variable. This struct represents the
// latter.
typedef struct Relocation Relocation;
struct Relocation {
Relocation *next;
int offset;
char **label;
long addend;
};
グローバル/ローカル問わず、変数または関数を表現する型。 Obj *next
とあるようにリンクリストであり、 parse(Token *tok)
の返値も Obj*
となっていた。Functionの Node *body
についてはすぐ下に。Global variableの char *init_data
には面食らったが、グローバル変数の定数式は単にバイナリデータの形まで parse.c
で落としてしまうらしい。
// AST node
typedef enum {
ND_NULL_EXPR, // Do nothing
ND_ADD, // +
ND_SUB, // -
...
} NodeKind;
// AST node type
struct Node {
NodeKind kind; // Node kind
Node *next; // Next node
Type *ty; // Type, e.g. int or pointer to int
Token *tok; // Representative token
Node *lhs; // Left-hand side
Node *rhs; // Right-hand side
// "if" or "for" statement
Node *cond;
Node *then;
Node *els;
Node *init;
Node *inc;
// "break" and "continue" labels
char *brk_label;
char *cont_label;
// Block or statement expression
Node *body;
// Struct member access
Member *member;
// Function call
Type *func_ty;
Node *args;
bool pass_by_stack;
Obj *ret_buffer;
...
// Variable
Obj *var;
// Numeric literal
int64_t val;
long double fval;
};
Node *new_cast(Node *expr, Type *ty);
int64_t const_expr(Token **rest, Token *tok);
Obj *parse(Token *tok);
Obj構造体でFunctionのbodyが Node *body
と表現されているように、関数の本体などのプログラムはNode構造体のツリー(AST)で表現される。そのため NodeKind
は +
や return
といったCの式や文の構成単位が列挙されている。
各フィールドの使われ方についてはparse.c本体をもう少し追ってみる。
parse.c (実装本体)
static Obj *globals
static Obj *locals
といったグローバル変数があり、これをテンポラリとして用い、 parse(Token *tok)
も最後に return globals;
してる辺りは普段プログラミングしていて守っている領域から見ると驚いた。リエントラントでないので、もちろん並行に呼ぶことはできない。例えば、関数のパース中 (function
内) に呼ばれる new_gvar
で Obj
を生成して globals
のリンクリストに加えている:
static Obj *new_var(char *name, Type *ty) {
Obj *var = calloc(1, sizeof(Obj));
var->name = name;
var->ty = ty;
var->align = ty->align;
push_scope(name)->var = var;
return var;
}
...
static Obj *new_gvar(char *name, Type *ty) {
Obj *var = new_var(name, ty);
var->next = globals;
var->is_static = true;
var->is_definition = true;
globals = var;
return var;
}
var->ty
が設定されているように、型はパースと同時に Obj
や Node
に設定されていくようだ。
static Type *declspec(Token **rest, Token *tok, VarAttr *attr)
にて:
switch (counter) {
...
case LONG:
case LONG + INT:
case LONG + LONG:
case LONG + LONG + INT:
case SIGNED + LONG:
case SIGNED + LONG + INT:
case SIGNED + LONG + LONG:
case SIGNED + LONG + LONG + INT:
ty = ty_long;
break;
...
}
このswitch-caseはなるほどなあ。
if (is_atomic) {
ty = copy_type(ty);
ty->is_atomic = true;
}
前述の copy_type
を使ってる例。Cの _Atomic
はよく知らないが型修飾子というらしく、確かに _Atomic
関係ないところでは修飾無しの型と互換っぽい...
ただ、単に copy_type
しているところなど copy_type
の勘所はいまいちわかっていない。
fn->body = compound_stmt(&tok, tok);
関数本体のルートは compound_stmt
。 { stmt stmt }
みたいのをCでは複合ステートメントと言うんですね...と初めて知った。
static Node *compound_stmt(Token **rest, Token *tok) {
Node *node = new_node(ND_BLOCK, tok);
Node head = {};
Node *cur = &head;
enter_scope();
while (!equal(tok, "}")) {
if (is_typename(tok) && !equal(tok->next, ":")) {
Type *basety = declspec(&tok, tok, &attr);
...
cur = cur->next = declaration(&tok, tok, basety, &attr);
} else {
cur = cur->next = stmt(&tok, tok);
}
add_type(cur);
}
leave_scope();
node->body = head.next;
*rest = tok->next;
return node;
}
ND_BLOCK
ノードの body
フィールドに、 next
チェインによるリンクリストの形で宣言(declaration)または文(stmt)からなるNodeの列が構築されている。一連の実装を見ても、字句的に隣り合わせのノードを next
にセット... みたいな使われ方はしないと考えてよいだろう。同様に例えば Node *lhs
Node *rhs
なども、2項演算子のときに用いられるフィールドのようだ:
static Node *new_binary(NodeKind kind, Node *lhs, Node *rhs, Token *tok) {
Node *node = new_node(kind, tok);
node->lhs = lhs;
node->rhs = rhs;
return node;
}
少し戻って、 compound_stmt
で宣言または文のパース後に add_type(Node *node)
が呼ばれている。ここの他、 add_type
はparse.cのあちこちで呼ばれている。例えば new_add(Node *lhs, Node *rhs, Token *tok)
のような関数の冒頭で呼ばれているので、(C言語の+演算子はオーバーロードされているため) 型が確定していて欲しい/確定できるタイミングで呼ばれているのだろうか? type.cの add_type
の実装に入ろう。
void add_type(Node *node) {
if (!node || node->ty) return;
add_type(node->lhs);
add_type(node->rhs);
add_type(node->cond);
add_type(node->then);
add_type(node->els);
add_type(node->init);
add_type(node->inc);
for (Node *n = node->body; n; n = n->next) add_type(n);
for (Node *n = node->args; n; n = n->next) add_type(n);
switch (node->kind) {
case ND_NUM:
node->ty = ty_int;
return;
case ND_ADD:
case ND_SUB:
case ND_MUL:
case ND_DIV:
case ND_MOD:
case ND_BITAND:
case ND_BITOR:
case ND_BITXOR:
usual_arith_conv(&node->lhs, &node->rhs);
node->ty = node->lhs->ty;
return;
...
case ND_ASSIGN:
if (node->lhs->ty->kind == TY_ARRAY) error_tok(node->lhs->tok, "not an lvalue");
if (node->lhs->ty->kind != TY_STRUCT) node->rhs = new_cast(node->rhs, node->lhs->ty);
node->ty = node->lhs->ty;
return;
...
case ND_ADDR: {
Type *ty = node->lhs->ty;
if (ty->kind == TY_ARRAY) node->ty = pointer_to(ty->base);
else node->ty = pointer_to(ty);
return;
}
case ND_DEREF:
if (!node->lhs->ty->base) error_tok(node->tok, "invalid pointer dereference");
if (node->lhs->ty->base->kind == TY_VOID) error_tok(node->tok, "dereferencing a void pointer");
node->ty = node->lhs->ty->base;
return;
...
}
}
switchは長いのでいくつかのcaseのみ例として残した。あるノードの子ノードを再帰的に add_type
して型を決定した上で、 node->kind
に基づいてlhs, rhs等との関係からこのノード自身の型を決定しているようだ。 ND_ASSIGN
の例やここから呼び出している usual_arith_conv
の実装を見ると、暗黙のキャストによるツリー構造の書き換えも起きうる。
main.c
エントリポイントからコードを追ってみる。
int main(int argc, char **argv) {
...
parse_args(argc, argv);
if (opt_cc1) {
add_default_include_paths(argv[0]);
cc1();
return 0;
}
...
StringArray ld_args = {};
for (int i = 0; i < input_paths.len; i++) {
char *input = input_paths.data[i];
if (!strncmp(input, "-l", 2)) {
strarray_push(&ld_args, input);
continue;
}
...
char *output = opt_o ? opt_o : replace_extn(input, ".o");
FileType type = get_file_type(input);
// Handle .o or .a
if (type == FILE_OBJ || type == FILE_AR || type == FILE_DSO) {
strarray_push(&ld_args, input);
continue;
}
// Handle .s
if (type == FILE_ASM) {
assemble(input, output);
continue;
}
assert(type == FILE_C);
...
// Compile, assemble and link
char *tmp1 = create_tmpfile();
char *tmp2 = create_tmpfile();
run_cc1(argc, argv, input, tmp1);
assemble(tmp1, tmp2);
strarray_push(&ld_args, tmp2);
continue;
}
if (ld_args.len > 0) run_linker(&ld_args, opt_o ? opt_o : "a.out");
return 0;
}
-
parse_args
によってグローバル変数opt_XXX
が引数次第でセットされる。 -
opt_cc1
が面白いところ。cc1
オプション及びcc1()
関数があると覚えておこう。後述。 - リンカに渡す引数群を
ld_args
に集める。ここで、ファイルの拡張子に応じてアセンブル (assemble()
) やコンパイル (run_cc1()
) が行われる。 - 最後に
run_linker
でリンクする。
主要な関数を assemble
run_linker
run_cc1
cc1
の順に見ていく。
static void assemble(char *input, char *output) {
char *cmd[] = {"as", "-c", input, "-o", output, NULL};
run_subprocess(cmd);
}
assemble
はシンプルにGNU asを呼び出す。 run_subprocess
はfork-execを行う。
static void run_linker(StringArray *inputs, char *output) {
StringArray arr = {};
strarray_push(&arr, "ld");
strarray_push(&arr, "-o");
strarray_push(&arr, output);
strarray_push(&arr, "-m");
strarray_push(&arr, "elf_x86_64");
if (opt_shared) {
strarray_push(&arr, format("%s/crti.o", libpath));
strarray_push(&arr, format("%s/crtbeginS.o", gcc_libpath));
} else {
strarray_push(&arr, format("%s/crt1.o", libpath));
strarray_push(&arr, format("%s/crti.o", libpath));
strarray_push(&arr, format("%s/crtbegin.o", gcc_libpath));
}
...
for (int i = 0; i < inputs->len; i++) strarray_push(&arr, inputs->data[i]);
...
if (opt_shared)
strarray_push(&arr, format("%s/crtendS.o", gcc_libpath));
else
strarray_push(&arr, format("%s/crtend.o", gcc_libpath));
strarray_push(&arr, format("%s/crtn.o", libpath));
strarray_push(&arr, NULL);
run_subprocess(arr.data);
}
run_linker
も同様にGNU ldを呼び出す。ldを直に呼び出した経験に乏しく、この辺を自分で無から実装するとgccのverbose outputとかの見様見真似になりそうだ。
static void run_cc1(int argc, char **argv, char *input, char *output) {
char **args = calloc(argc + 10, sizeof(char *));
memcpy(args, argv, argc * sizeof(char *));
args[argc++] = "-cc1";
if (input) {
args[argc++] = "-cc1-input";
args[argc++] = input;
}
if (output) {
args[argc++] = "-cc1-output";
args[argc++] = output;
}
run_subprocess(args);
}
run_cc1
は面白い。カレントプロセスのコマンドライン引数を引数に取り、その配列に -cc1 -cc1-input .. -cc1-output ..
を加えた形で自分自身を呼び出す。gccの場合は裏でサブプログラム cc1
を呼び出すとされているが、chibiccでは自分自身が -cc1
オプションを処理することで (opt_cc1
) cc1
相当の動作に切り替えている。エントリポイントの該当部分を再掲。
if (opt_cc1) {
add_default_include_paths(argv[0]);
cc1();
return 0;
}
static void cc1(void) {
Token *tok = NULL;
// Process -include option
for (int i = 0; i < opt_include.len; i++) {
...
}
// Tokenize and parse.
Token *tok2 = must_tokenize_file(base_file);
tok = append_tokens(tok, tok2);
tok = preprocess(tok);
...
Obj *prog = parse(tok);
// Open a temporary output buffer.
char *buf;
size_t buflen;
FILE *output_buf = open_memstream(&buf, &buflen);
// Traverse the AST to emit assembly.
codegen(prog, output_buf);
fclose(output_buf);
// Write the asembly text to a file.
FILE *out = open_file(output_file);
fwrite(buf, buflen, 1, out);
fclose(out);
}
void codegen(Obj *prog, FILE *out);
コンパイル処理本体。入力のプログラムを Token
のリンクリストに変換し、 preprocess
でCプリプロセス、 parse
で Obj
のリンクリストを得、 codegen
する。ということで本題のコード生成まで辿り着いた。
codegen.c
codegen.c はソースコードリーディング中のところ1600行。とはいえコード生成には定型処理も沢山あるかと思うので気張らず読んでいく。
void codegen(Obj *prog, FILE *out) {
output_file = out;
File **files = get_input_files();
for (int i = 0; files[i]; i++)
println(" .file %d \"%s\"", files[i]->file_no, files[i]->name);
assign_lvar_offsets(prog);
emit_data(prog);
emit_text(prog);
}
codegenは3ステップ。 assign_lvar_offsets
, emit_data
, emit_text
。
実装を読む前に、コンパイラが準拠するSystem V AMD64 ABIのFunction Calling Sequenceを読んでおいたほうが良さそうだ。この資料何度か読んでいるのに頭に全く残っていないのでいい加減まとめよう...
System V AMD64 ABI メモ
レジスタ
- レジスタ
rbx
,rbp
,rsp
,r12..r15
はcallee-saved (non-volatile): 関数は呼び出された後にこれらが保持されていることを保証しなければらない - それ以外のレジスタはcaller-saved (volatile): 関数が呼び出された後にこれらが保持されていることは保証されない
スタックフレーム
- スタックは 高位のアドレスから低位のアドレスへと延びる
-
rsp
は常に一番上 (メモリアドレス上は低位) のスタックフレームの終端を指す - 関数のエントリポイントに制御が移るとき、
-
rsp
にはcall
によってpushされたreturn addressがあり -
rsp + 8
は常に16 bytes-aligned (つまりcall
されるときのrsp
は常に16 bytes-aligned)
-
- 各関数は、基本的に以下のように
rbp
を用いる (See also:-fomit-frame-pointer
)- prologueで
-
push %rbp
(rbp
をスタックに退避) -
mov %rsp, %rbp
(rsp
(現在のスタックフレームの始点 - 16) をrbp
にセット):
このようにすると、図のようにrbp
からの相対位置で前回のスタックフレーム上の引数や現在のスタックフレーム上のローカル変数にアクセスできる
-
- epilogueで
-
mov %rbp, %rsp
(rsp
をprologueのpush %rbp
直後の状態に復元) -
pop %rbp
(rbp
を復元) ret
-
- prologueで
引数の受け渡し
以下、仕様書には書かれている以下の要素について考慮していない:
- 分類
SSEUP
,X87
,X87UP
,COMPLEX_X87
と関連する型や振る舞い - Decimal型
-
__m256
,__m512
,__int128
, ... - C++ object
- 可変超引数
引数は大雑把には以下のような方針に基づいて渡される。
- 引数が適するレジスタがあるならレジスタを使って渡す。 (left-to-right)
- 引数が適するレジスタが無かったりレジスタが埋まっている場合はスタック経由で渡す。 (right-to-left)
「引数が適するレジスタ」といったように、値の分類(classification)がある。
-
INTEGER
- 数値型の分類。汎用レジスタが適合する。
_Bool
やchar
, 64bitまでの整数型やポインタがこれに分類される。 - この分類の値の引数はレジスタ
rdi
rsi
... を順に使用して渡される。
- 数値型の分類。汎用レジスタが適合する。
-
SSE
-
SSEに適合する型の分類。
float
やdouble
がこれ。 - この分類の値の引数はレジスタ
xmm0
からxmm7
を順に使用して渡される。
-
SSEに適合する型の分類。
-
NO_CLASS
- パディングや空の構造体など。有意な値がないため、渡す処理が必要ない。
-
MEMORY
- 適切なレジスタが無い。スタックメモリ領域を経由して渡される。
なお、引数のサイズは8バイト単位にround-upされるため、スタックは常に8バイト単位でアラインされる。
structやunionはどのように渡されるだろうか? 以下の手順で決定される:
- サイズが16バイトを超える場合やunalignedなフィールドを持つ場合、全体がメモリ渡しされる (
MEMORY
)。 - データを8バイト単位 (eightbyte) に分割し、それぞれを以下のように分類し、その分類に基づいた上述の渡し方渡す。
- eightbyteの位置に含まれるstructやunionの各フィールドについて、再帰的に分類を計算する
- eightbyteの位置に含まれるstructやunionの各フィールドの分類から、最も優先度の高い分類をそのeightbyteの分類として決定する:
優先度はINTEGER > SSE > NO_CLASS (default)
- ただし、以下の場合はeightbyte単位では渡さず、やはり全体がメモリ渡しされる。
- いずれかのフィールドの分類が
MEMORY
- eightbyteのいずれかがレジスタに収まらない (このとき、アサインされたレジスタは予約済みとなる)
- いずれかのフィールドの分類が
具体例をいくつか見てみる。
struct struct_a {
int a;
float b;
};
int a;
float b;
int test_struct_a(struct struct_a p) {
// movq %rdi, -8(%rbp)
a = p.a; // movl -8(%rbp), %eax
// movl %eax, a(%rip)
b = p.b; // movss -4(%rbp), %xmm0
// movss %xmm0, b(%rip)
}
struct_a
は全体で8バイトなので、1 eightbyte。このeightbyteはintとfloatをフィールドに持つので優先度から INTEGER
に分類され、レジスタ rdi
で渡されている。
struct struct_b {
struct {
int x;
short y, z;
} a;
struct {
float x, y;
} b;
};
int a;
float b;
int test_struct_b(struct struct_b p) {
// movq %rdi, %rax
// movq %xmm0, %rcx
// movq %rcx, %rdx
// movq %rax, -16(%rbp) // rdi -> rax -> [rbp-16]
// movq %rdx, -8(%rbp) // xmm0 -> rcx -> rdx -> [rbp-8]
a = p.a.x; // movl -16(%rbp), %eax
// movl %eax, a(%rip)
a = p.a.y; // movzwl -12(%rbp), %eax
// cwtl
// movl %eax, a(%rip)
a = p.a.z; // movzwl -10(%rbp), %eax
// cwtl
// movl %eax, a(%rip)
b = p.b.x; // movss -8(%rbp), %xmm0
// movss %xmm0, b(%rip)
b = p.b.y; // movss -4(%rbp), %xmm0
// movss %xmm0, b(%rip)
}
struct_b
は全体で16バイトなので、2 eightbyte。 struct { .. } a
部分は rdi
で、 struct { .. } b
部分は xmm0
で渡されている。
struct struct_c {
double x, y, z;
};
double a;
int test_struct_c(struct struct_c p) {
a = p.x; // movsd 16(%rbp), %xmm0
// movsd %xmm0, a(%rip)
a = p.y; // movsd 24(%rbp), %xmm0
// movsd %xmm0, a(%rip)
a = p.z; // movsd 32(%rbp), %xmm0
// movsd %xmm0, a(%rip)
}
引数全体がスタックメモリ経由で渡されている。引数 p
内の各eightbyteもright-to-leftでpushされている (右端の z
が一番最初にpushされている) ことがわかる。
返値の受け渡し
- 引数と同様に分類を行う。
-
MEMORY
の場合、呼び出し側は返値のための空間を確保し、そのアドレスを隠し引数のようにrdi
にセットする。返却時はそのアドレスをrax
にセットする。- 以下のステップは、structやunionで
MEMORY
でない場合それぞれのeightbyteについて適用できる。
- 以下のステップは、structやunionで
-
INTEGER
の場合、返値をrax
,rdx
に順にセットする。 -
SSE
の場合、返値をxmm0
,xmm1
に順にセットする。
(改めて) codegen.c
assign_lvar_offsets
static void assign_lvar_offsets(Obj *prog) {
for (Obj *fn = prog; fn; fn = fn->next) {
if (!fn->is_function) continue;
...
}
}
以下の処理は各関数について。
// If a function has many parameters, some parameters are
// inevitably passed by stack rather than by register.
// The first passed-by-stack parameter resides at RBP+16.
int top = 16;
int bottom = 0;
int gp = 0, fp = 0;
// Assign offsets to pass-by-stack parameters.
for (Obj *var = fn->params; var; var = var->next) {
Type *ty = var->ty;
switch (ty->kind) {
case TY_STRUCT:
case TY_UNION:
if (ty->size <= 16) {
bool fp1 = has_flonum(ty, 0, 8, 0);
bool fp2 = has_flonum(ty, 8, 16, 8);
if (fp + fp1 + fp2 < FP_MAX && gp + !fp1 + !fp2 < GP_MAX) {
fp = fp + fp1 + fp2;
gp = gp + !fp1 + !fp2;
continue;
}
}
break;
case TY_FLOAT:
case TY_DOUBLE:
if (fp++ < FP_MAX) continue;
break;
case TY_LDOUBLE:
break;
default:
if (gp++ < GP_MAX) continue;
}
top = align_to(top, 8);
var->offset = top;
top += var->ty->size;
}
ABI仕様を見た後だとわかりやすい。 GP_MAX
は 6
で FP_MAX
は 8
、それぞれレジスタ渡しできるGeneral-purposeレジスタの数と浮動小数点数を格納できるレジスタの数だろう。変数 fp
gp
に使用済みレジスタの数を記録していき、レジスタから溢れた引数はスタックから渡されるのでそのオフセットを var->offset
にセットする。
// Assign offsets to pass-by-register parameters and local variables.
for (Obj *var = fn->locals; var; var = var->next) {
if (var->offset) continue;
// AMD64 System V ABI has a special alignment rule for an array of
// length at least 16 bytes. We need to align such array to at least
// 16-byte boundaries. See p.14 of
// https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-draft.pdf.
int align = (var->ty->kind == TY_ARRAY && var->ty->size >= 16)
? MAX(16, var->align) : var->align;
bottom += var->ty->size;
bottom = align_to(bottom, align);
var->offset = -bottom;
}
fn->stack_size = align_to(bottom, 16);
レジスタ渡しされたパラメータ及びローカル変数は現在のスタックフレームに領域を確保しておく。16バイトでアラインした全長を fn->stack_size
に記録しておく。
emit_data
static void emit_data(Obj *prog) {
for (Obj *var = prog; var; var = var->next) {
if (var->is_function || !var->is_definition) continue;
...
}
}
以下の処理は is_definition
な変数について。
if (var->is_static)
println(" .local %s", var->name);
else
println(" .globl %s", var->name);
int align = (var->ty->kind == TY_ARRAY && var->ty->size >= 16)
? MAX(16, var->align) : var->align;
// Common symbol
if (opt_fcommon && var->is_tentative) {
println(" .comm %s, %d, %d", var->name, var->ty->size, align);
continue;
}
// .data or .tdata
if (var->init_data) {
... // (後述)
continue;
}
// .bss or .tbss
if (var->is_tls)
println(" .section .tbss,\"awT\",@nobits");
else
println(" .bss");
println(" .align %d", align);
println("%s:", var->name);
println(" .zero %d", var->ty->size);
LLVMのLinkage Typesで見たような用語が並ぶなあ...という感じ。今更だがこの辺のディレクティブが全然わからない。
注意して見ておきたいのは初期化データがある場合のコード。
// .data or .tdata
if (var->init_data) {
if (var->is_tls) println(" .section .tdata,\"awT\",@progbits");
else println(" .data");
println(" .type %s, @object", var->name);
println(" .size %s, %d", var->name, var->ty->size);
println(" .align %d", align);
println("%s:", var->name);
Relocation *rel = var->rel;
int pos = 0;
while (pos < var->ty->size) {
if (rel && rel->offset == pos) {
println(" .quad %s%+ld", *rel->label, rel->addend);
rel = rel->next;
pos += 8;
} else {
println(" .byte %d", var->init_data[pos++]);
}
}
continue;
}
var->init_data
を走査して .byte
を書き込みつつ、別のグローバル変数への参照がある場所では .quad
でそれを書き込む必要がある。これら var->init_data
と var->rel
の構築についてはparse.cの gvar_initializer
あたりを参照。このあたりは自作言語でネイティブバックエンドを追加する事前準備として行った変更で加えた以下のようなトレイトと似ていて面白みを感じる:
pub trait EeData {
fn direct_data(&self) -> &[u8];
fn traverse_indirect_data(&self, f: &mut dyn FnMut(usize, &dyn EeData));
...
}
emit_text
プログラムのセクションが配置される .text
セクションの生成。つまりコンパイラのコード生成の核。
static void emit_text(Obj *prog) {
for (Obj *fn = prog; fn; fn = fn->next) {
if (!fn->is_function || !fn->is_definition) continue;
// No code is emitted for "static inline" functions
// if no one is referencing them.
if (!fn->is_live) continue;
...
}
}
以下の処理は is_definition
な関数について。
if (fn->is_static) println(" .local %s", fn->name);
else println(" .globl %s", fn->name);
println(" .text");
println(" .type %s, @function", fn->name);
println("%s:", fn->name);
current_fn = fn;
// Prologue
println(" push %%rbp");
println(" mov %%rsp, %%rbp");
println(" sub $%d, %%rsp", fn->stack_size);
println(" mov %%rsp, %d(%%rbp)", fn->alloca_bottom->offset);
// Save arg registers if function is variadic
if (fn->va_area) { ... } // 興味が無いので省略
関数のprologue。ABIで見た一般的な形と同様に push %rbp
して mov %rsp, %rbp
する。その後、あらかじめ計算しておいた stack_size
を rsp
から引き、その時点の rsp
を alloca_bottom
という内部で常に確保されているローカル変数にストアする (この変数は alloca
のサポートに使われているようだ)。
// Save passed-by-register arguments to the stack
int gp = 0, fp = 0;
for (Obj *var = fn->params; var; var = var->next) {
if (var->offset > 0) continue;
Type *ty = var->ty;
switch (ty->kind) {
case TY_STRUCT:
case TY_UNION:
assert(ty->size <= 16);
if (has_flonum(ty, 0, 8, 0)) store_fp(fp++, var->offset, MIN(8, ty->size));
else store_gp(gp++, var->offset, MIN(8, ty->size));
if (ty->size > 8) {
if (has_flonum(ty, 8, 16, 0)) store_fp(fp++, var->offset + 8, ty->size - 8);
else store_gp(gp++, var->offset + 8, ty->size - 8);
}
break;
case TY_FLOAT:
case TY_DOUBLE:
store_fp(fp++, var->offset, ty->size);
break;
default:
store_gp(gp++, var->offset, ty->size);
}
}
レジスタ渡しされた引数だが、chibiccではいったんスタックにストアする。 store_fp(r, offset, size)
store_gp(r, offset, size)
はレジスタ渡しされたr番目のレジスタの値を {offset}(%rbp)
にmovする。
// Emit code
gen_stmt(fn->body);
assert(depth == 0);
// [https://www.sigbus.info/n1570#5.1.2.2.3p1] The C spec defines
// a special rule for the main function. Reaching the end of the
// main function is equivalent to returning 0, even though the
// behavior is undefined for the other functions.
if (strcmp(fn->name, "main") == 0)
println(" mov $0, %%rax");
// Epilogue
println(".L.return.%s:", fn->name);
println(" mov %%rbp, %%rsp");
println(" pop %%rbp");
println(" ret");
そうして関数本体のコード生成 (gen_stmt
) に入る。epilogueはABIで見た一般的な形と同様となっている。
以下はおおまかな構成を追ってみる。
static void gen_stmt(Node *node) {
println(" .loc %d %d", node->tok->file->file_no, node->tok->line_no);
switch (node->kind) {
case ND_IF: {
int c = count();
gen_expr(node->cond);
cmp_zero(node->cond->ty);
println(" je .L.else.%d", c);
gen_stmt(node->then);
println(" jmp .L.end.%d", c);
println(".L.else.%d:", c);
if (node->els) gen_stmt(node->els);
println(".L.end.%d:", c);
return;
}
...
}
error_tok(node->tok, "invalid statement");
}
gen_stmt
は文字通りCの文に対応するコード生成。コードを見るといずれも返値が無く、スタックマシンっぽいコード生成をしている予想が付く。条件式を gen_expr
して cmp_zero
している。
static void cmp_zero(Type *ty) {
switch (ty->kind) { ... } // TY_FLOAT, TY_DOUBLE, TY_LDOUBLEの場合
if (is_integer(ty) && ty->size <= 4) println(" cmp $0, %%eax");
else println(" cmp $0, %%rax");
}
cmp_zero
を見ると rax
に値がある前提となっている。大昔にやった
An Incremental Approach to Compiler Construction (yubrot/iatcc) の 3.4 Conditional Expressions
っぽい。とするとバイナリ演算やレジスタに収まらない値はどうするのか...? gen_expr
に進む。
gen_expr
のおおまかな構造は以下の通り。
static void gen_expr(Node *node) {
println(" .loc %d %d", node->tok->file->file_no, node->tok->line_no);
switch (node->kind) {
...
case ND_NUM: {
switch (node->ty->kind) {
case TY_FLOAT: { ... }
case TY_DOUBLE: { ... }
case TY_LDOUBLE: { ... }
}
println(" mov $%ld, %%rax", node->val);
return;
}
...
}
switch (node->lhs->ty->kind) {
case TY_FLOAT:
case TY_DOUBLE: {
gen_expr(node->rhs);
pushf();
gen_expr(node->lhs);
popf(1);
switch (node->kind) {
case ND_ADD:
println(" add%s %%xmm1, %%xmm0", sz);
return;
...
}
error_tok(node->tok, "invalid expression");
}
case TY_LDOUBLE: { ... }
}
gen_expr(node->rhs);
push();
gen_expr(node->lhs);
pop("%rdi");
char *ax, *di, *dx;
if (node->lhs->ty->kind == TY_LONG || node->lhs->ty->base) {
ax = "%rax";
di = "%rdi";
dx = "%rdx";
} else {
ax = "%eax";
di = "%edi";
dx = "%edx";
}
switch (node->kind) {
case ND_ADD:
println(" add %s, %s", di, ax);
return;
...
}
error_tok(node->tok, "invalid expression");
}
最初の switch (node->kind) { .. }
ではバイナリ演算はスルーされ (各caseはearly returnしている)、switchの下で両端の gen_expr(..)
からバイナリ演算のための switch (node->kind) { .. }
が行われている。また、 switch (node->kind)
の分岐と node->ty->kind
または node->lhs->ty->kind
の分岐がサンドイッチされている。
static void push(void) {
println(" push %%rax");
depth++;
}
static void pop(char *arg) {
println(" pop %s", arg);
depth--;
}
gen_expr(node->rhs);
push();
gen_expr(node->lhs);
pop("%rdi");
gen_expr
の結果は rax
レジスタにストアされていると考えられるのだった。バイナリ演算の場合は右辺の実行結果を push
し、左辺を実行 (これも rax
レジスタにストアされる)、右辺の結果を rdi
レジスタにpopすることで rax <> rdi
(<>
はバイナリ演算子) の形にしている。これもAn Incremental Approach to Compiler Constructionの 3.4 Binary Primitives
っぽい。 TY_DOUBLE
の場合も同様に xmm0
xmm1
を使用している。
static void pushf(void) {
println(" sub $8, %%rsp");
println(" movsd %%xmm0, (%%rsp)");
depth++;
}
static void popf(int reg) {
println(" movsd (%%rsp), %%xmm%d", reg);
println(" add $8, %%rsp");
depth--;
}
chibiccはスタックの上端を rax
または xmm0
としたスタックマシン型のコードを生成しているようだ。
左辺値やstruct/unionはどうなっているか。
case ND_VAR:
gen_addr(node);
load(node->ty);
return;
変数の読み込み。左辺値はそのままアドレスとして扱うようにしているようだ。ということは...?
case ND_ASSIGN:
gen_addr(node->lhs);
push();
gen_expr(node->rhs);
if (node->lhs->kind == ND_MEMBER && node->lhs->member->is_bitfield) { ... }
store(node->ty);
return;
代入も左辺を gen_addr
して、それから右辺を gen_expr
して store
を呼んでいる。いずれも構造体の値が結果となる場合の gen_expr
についての分岐が無いなあと思ったら、 load
と store
側に分岐があった。
// Load a value from where %rax is pointing to.
static void load(Type *ty) {
switch (ty->kind) {
case TY_ARRAY:
case TY_STRUCT:
case TY_UNION:
case TY_FUNC:
case TY_VLA:
// If it is an array, do not attempt to load a value to the
// register because in general we can't load an entire array to a
// register. As a result, the result of an evaluation of an array
// becomes not the array itself but the address of the array.
// This is where "array is automatically converted to a pointer to
// the first element of the array in C" occurs.
return;
case TY_FLOAT: ...
case TY_DOUBLE: ...
case TY_LDOUBLE: ...
}
char *insn = ty->is_unsigned ? "movz" : "movs";
if (ty->size == 1) println(" %sbl (%%rax), %%eax", insn);
else if (ty->size == 2) println(" %swl (%%rax), %%eax", insn);
else if (ty->size == 4) println(" movsxd (%%rax), %%rax");
else println(" mov (%%rax), %%rax");
}
static void store(Type *ty) {
pop("%rdi");
switch (ty->kind) {
case TY_STRUCT:
case TY_UNION:
for (int i = 0; i < ty->size; i++) {
println(" mov %d(%%rax), %%r8b", i);
println(" mov %%r8b, %d(%%rdi)", i);
}
return;
...
}
...
}
構造体は関数の実行中でアドレスとして扱われていた。
関数呼び出しと返却。簡単な返却の方から。Cでは return
は文なのでこのcaseは gen_stmt
にある:
case ND_RETURN:
if (node->lhs) {
gen_expr(node->lhs);
Type *ty = node->lhs->ty;
switch (ty->kind) {
case TY_STRUCT:
case TY_UNION:
if (ty->size <= 16) copy_struct_reg();
else copy_struct_mem();
break;
}
}
println(" jmp .L.return.%s", current_fn->name);
return;
struct, unionはABIの通りサイズが16バイトを超えるか否かによって返値の返却方法が異なる。 copy_struct_reg
はレジスタにデータの各eightbyteをコピーし、 copy_struct_mem
は隠れ第一引数のオフセットからアドレスを読んで、そのアドレスの指す領域にデータをコピーする。
関数呼び出し。こちらは式なので gen_expr
にある:
case ND_FUNCALL:
if (node->lhs->kind == ND_VAR && !strcmp(node->lhs->var->name, "alloca")) {
gen_expr(node->args);
println(" mov %%rax, %%rdi");
builtin_alloca();
return;
}
まず alloca
のサポートがある。プログラムの評価にスタックを使ってるがallocaはどう実装しているのか?:
static void builtin_alloca(void) {
// Align size to 16 bytes.
println(" add $15, %%rdi");
println(" and $0xfffffff0, %%edi");
// Shift the temporary area by %rdi.
println(" mov %d(%%rbp), %%rcx", current_fn->alloca_bottom->offset);
println(" sub %%rsp, %%rcx");
println(" mov %%rsp, %%rax");
println(" sub %%rdi, %%rsp");
println(" mov %%rsp, %%rdx");
println("1:");
println(" cmp $0, %%rcx");
println(" je 2f");
println(" mov (%%rax), %%r8b");
println(" mov %%r8b, (%%rdx)");
println(" inc %%rdx");
println(" inc %%rax");
println(" dec %%rcx");
println(" jmp 1b");
println("2:");
// Move alloca_bottom pointer.
println(" mov %d(%%rbp), %%rax", current_fn->alloca_bottom->offset);
println(" sub %%rdi, %%rax");
println(" mov %%rax, %d(%%rbp)", current_fn->alloca_bottom->offset);
}
スタックの先頭を伸ばし (sub %rdi, %rsp
)、変数 alloca_bottom
の値からスタックの先頭までのデータをシンプルに順にmovして移動している。最後に alloca_bottom
を更新して伸ばした分を記録する。
関数呼び出しに戻る。
case ND_FUNCALL:
if (node->lhs->kind == ND_VAR && !strcmp(node->lhs->var->name, "alloca")) { ... }
int stack_args = push_args(node); // (1)
gen_expr(node->lhs); // (2)
int gp = 0, fp = 0;
// If the return type is a large struct/union, the caller passes
// a pointer to a buffer as if it were the first argument.
if (node->ret_buffer && node->ty->size > 16) pop(argreg64[gp++]);
// (3)
for (Node *arg = node->args; arg; arg = arg->next) {
Type *ty = arg->ty;
switch (ty->kind) {
case TY_STRUCT:
case TY_UNION:
if (ty->size > 16) continue;
bool fp1 = has_flonum1(ty);
bool fp2 = has_flonum2(ty);
if (fp + fp1 + fp2 < FP_MAX && gp + !fp1 + !fp2 < GP_MAX) {
if (fp1) popf(fp++);
else pop(argreg64[gp++]);
if (ty->size > 8) {
if (fp2) popf(fp++);
else pop(argreg64[gp++]);
}
}
break;
case TY_FLOAT:
case TY_DOUBLE:
if (fp < FP_MAX) popf(fp++);
break;
...
default:
if (gp < GP_MAX) pop(argreg64[gp++]);
}
}
// (4)
println(" mov %%rax, %%r10");
println(" mov $%d, %%rax", fp);
println(" call *%%r10");
関数呼び出しは、ABIに従って引数をレジスタとスタックとに設定して call
する必要があるが、chibiccでは
- 引数群を実行してスタックに積む
- calleeを実行して得る
- スタックの引数群のうち、レジスタ渡しの分を必要なだけpopする
- 呼び出し。
mov ${fp}, %rax
は可変長引数関数のための暗黙の引数
という形のコードを生成している。 (3) を行いやすいように、 push_args
はスタックに積む順番を調整している:
static int push_args(Node *node) {
int stack = 0, gp = 0, fp = 0;
// If the return type is a large struct/union, the caller passes
// a pointer to a buffer as if it were the first argument.
if (node->ret_buffer && node->ty->size > 16) gp++;
// Load as many arguments to the registers as possible.
for (Node *arg = node->args; arg; arg = arg->next) {
Type *ty = arg->ty;
switch (ty->kind) {
case TY_STRUCT:
case TY_UNION:
if (ty->size > 16) {
arg->pass_by_stack = true;
stack += align_to(ty->size, 8) / 8;
} else {
bool fp1 = has_flonum1(ty);
bool fp2 = has_flonum2(ty);
if (fp + fp1 + fp2 < FP_MAX && gp + !fp1 + !fp2 < GP_MAX) {
fp = fp + fp1 + fp2;
gp = gp + !fp1 + !fp2;
} else {
arg->pass_by_stack = true;
stack += align_to(ty->size, 8) / 8;
}
}
break;
case TY_FLOAT:
case TY_DOUBLE:
if (fp++ >= FP_MAX) {
arg->pass_by_stack = true;
stack++;
}
break;
case TY_LDOUBLE:
arg->pass_by_stack = true;
stack += 2;
break;
default:
if (gp++ >= GP_MAX) {
arg->pass_by_stack = true;
stack++;
}
}
}
if ((depth + stack) % 2 == 1) {
println(" sub $8, %%rsp");
depth++;
stack++;
}
push_args2(node->args, true);
push_args2(node->args, false);
// If the return type is a large struct/union, the caller passes
// a pointer to a buffer as if it were the first argument.
if (node->ret_buffer && node->ty->size > 16) {
println(" lea %d(%%rbp), %%rax", node->ret_buffer->offset);
push();
}
return stack;
}
static void push_args2(Node *args, bool first_pass) {
if (!args) return;
push_args2(args->next, first_pass);
if ((first_pass && !args->pass_by_stack) || (!first_pass && args->pass_by_stack)) return;
gen_expr(args);
switch (args->ty->kind) {
case TY_STRUCT:
case TY_UNION:
push_struct(args->ty);
break;
case TY_FLOAT:
case TY_DOUBLE:
pushf();
break;
...
default:
push();
}
}
- 引数がスタックに積まれるかレジスタ渡しかを計算する (
assign_lvar_offsets
と同様) とともに、関数呼び出しのためのスタック長を計算する -
push_args2(node->args, true)
でスタック渡しの引数をスタックに積む -
push_args2(node->args, false)
でレジスタ渡しの引数をスタックに積む (レジスタ渡しの引数を後から積むことで呼び出し元で順番にpopできる)
関数呼び出しに戻る。 call
から返った後はいくつかの後始末を行う:
println(" add $%d, %%rsp", stack_args * 8);
depth -= stack_args;
// It looks like the most significant 48 or 56 bits in RAX may
// contain garbage if a function return type is short or bool/char,
// respectively. We clear the upper bits here.
switch (node->ty->kind) {
case TY_BOOL:
println(" movzx %%al, %%eax");
return;
case TY_CHAR:
if (node->ty->is_unsigned)
println(" movzbl %%al, %%eax");
else
println(" movsbl %%al, %%eax");
return;
case TY_SHORT:
if (node->ty->is_unsigned)
println(" movzwl %%ax, %%eax");
else
println(" movswl %%ax, %%eax");
return;
}
// If the return type is a small struct, a value is returned
// using up to two registers.
if (node->ret_buffer && node->ty->size <= 16) {
copy_ret_buffer(node->ret_buffer);
println(" lea %d(%%rbp), %%rax", node->ret_buffer->offset);
}
だいたい codegen
を辿り終わった。基本的に複雑っぽいなあというところは往々にしてABIが関わっており、コード生成もわかりやすかった。次は虎本がどういう方針で進めてたか等を見て行こうと思う。