機械語とほぼ1対1で人間にとって読みやすい言語
objectdump
の逆アセンブルで、アセンブラが見れる。
$ objdump -d -M intel /bin/ls | head -n 9
/bin/ls: file format elf64-x86-64
Disassembly of section .init:
0000000000004000 <.init>: # 開始アドレスが表示
4000: f3 0f 1e fa endbr64 # セキュリティ要件
4004: 48 83 ec 08 sub rsp,0x8 # 解説
- 4004は機械語が入っているメモリアドレスで、プログラムカウンタが0x4004の時に実行される。
- RSPというレジスタから8を引く(substract)
ここで、C言語をコンパイルした後に期待するアセンブラを紹介する
// C言語
int main() {
return 42;
}
// 期待するアセンブリ
.intel_syntax noprefix // intel記法の採用の宣言
.globl main
main:
mov rax, 42 // RAXレジスタに42をコピー
ret
- Intel記法とUNIX等で広く使われるAT&T記法がある。
- 関数の返値はRAXレジスタにmov(コピー)する
【コラム】
gccやobjdumpはデフォルトではAT&T記法でアセンブリを出力する。
どちらの記法を使っても、生成される機械語命令列は同一となる。
mov rbp, rsp // Intel
mov %rsp, %rbp // AT&T
mov rax, 8 // Intel
mov $8, %rax // AT&T
mov [rbp + rcx * 4 - 8], rax // Intel
mov %rax, -8(rbp, rcx, 4) // AT&T
関数呼び出しの場合は下記
// C言語
int plus(int x, int y) {
return x + y;
}
int main() {
return plus(3, 4);
}
// 期待するアセンブリ
.intel_syntax noprefix
.globl plus, main
plus:
add rsi, rdi // RSIレジスタとRDIレジスタを足した結果がRSIレジスタに書き込む
mov rax, rsi // RAXレジスタにRSIレジスタをコピー
ret // スタックからアドレスを1つポップし、そのアドレスにジャンプ
main:
mov rdi, 3 // RDIレジスタに3をコピー
mov rsi, 4 // RDIレジスタに4をコピー
call plus
ret
- 関数コール時の第一引数はRDIレジスタにmov(コピー)する
- 関数コール時の第二引数はRDIレジスタにmov(コピー)する
- x86-64の整数演算命令(add)は通常2つのレジスタしか受け取らないため、第1引数のレジスタの値を上書きする形で結果が保存される
- retはスタックからアドレスを1つポップし、そのアドレスにジャンプする
step-by-stepで、最小構成で標準入力の値からアセンブリを出力させる。
下記は42をアセンブラが受け取った際の成果物例
.intel_syntax noprefix
.globl main
main:
mov rax, 42
ret
アセンブラの実装
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "引数の数が正しくありません\n");
return 1;
}
printf(".intel_syntax noprefix\n");
printf(".globl main\n");
printf("main:\n");
printf(" mov rax, %d\n", atoi(argv[1]));
printf(" ret\n");
return 0;
}
アセンブルと実行
cc -o 9cc 9cc.c
./9cc 42 > tmp.s
cc -o tmp tmp.s
./tmp
echo $? #42
- addとsubが加算と減算
.intel_syntax noprefix
.globl main
main:
mov rax, 5
add rax, 20
sub rax, 4
ret
加算/減算の式を言語として説明すると、
- 最初に数字
- 0以上の項(+もしくはーの後ろに文字がある)
となる。
これには、項を解析する関数としてstrtol
がリーズナブルである。
strtol関数は、文字列を長整数(long int)に変換する。
数値を含む文字列とその文字列内での数値の解析を開始するポインタ、数値の解析を終了させるポインタのアドレス、および基数を引数に取る。
char *p = argv[1];
printf(".intel_syntax noprefix\n");
printf(".globl main\n");
printf("main:\n");
printf(" mov rax, %ld\n", strtol(p, &p, 10));
// 文字列を1文字ずつ走査し、+や-に遭遇するたびに、
// それに続く数値をraxに加算または減算
while (*p) { // デリファレンスにより実際の文字を捜査
if (*p == '+') {
p++; // charサイズ分移動し、次の文字へ
// &pは、strtol関数が数値の解析を終了した後の位置を示すポインタを
// 格納するための変数のアドレスを指す
// 例えば123+456であればstrtolは123を返し、pは第二引数により「+」を指す
printf(" add rax, %ld\n", strtol(p, &p, 10));
continue;
}
if (*p == '-') {
p++;
printf(" sub rax, %ld\n", strtol(p, &p, 10));
continue;
}
fprintf(stderr, "予期しない文字です: '%c'\n", *p);
return 1;
}
printf(" ret\n");
step2では、空白文字等に対応できない。
そのため、意味のある単語に変換する必要がある。
5+20-4は5、+、20、-、4という5つの単語でできていると考えることができ、これをtoken
と呼ぶ。
tokenの間にある空白文字は、tokenを区切るために存在しているだけで、単語を構成する一部分ではないため、空白文字等は取り除く。
このように、文字列をtoken列に分割することをtokenize
という。
またtoken列の各tokenを分類して型をつけることができる利点もある。
単なる文字列に分割するだけではなく、各tokenを解釈することで、token列を消費するときに考えなければならないことが減る。
(現時点では+,-,数字の型がアセンブラとしてある。)
// トークンの種類
typedef enum {
TK_RESERVED, // 記号
TK_NUM, // 整数トークン
TK_EOF, // 入力の終わりを表すトークン
} TokenKind;
typedef struct Token Token;
// トークン型
struct Token {
TokenKind kind; // トークンの型
Token *next; // 次の入力トークン
int val; // kindがTK_NUMの場合、その数値
char *str; // トークン文字列
};
// 現在着目しているトークン
Token *token;
// エラーを報告するための関数
// printfと同じ引数を取る
void error(char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
vfprintf(stderr, fmt, ap);
fprintf(stderr, "\n");
exit(1);
}
// 次のトークンが期待している記号のときには、トークンを1つ読み進めて
// 真を返す。それ以外の場合には偽を返す。
bool consume(char op) {
if (token->kind != TK_RESERVED || token->str[0] != op) return false;
token = token->next;
return true;
}
// 次のトークンが期待している記号のときには、トークンを1つ読み進める。
// それ以外の場合にはエラーを報告する。
void expect(char op) {
if (token->kind != TK_RESERVED || token->str[0] != op)
error("'%c'ではありません", op);
token = token->next;
}
// 次のトークンが数値の場合、トークンを1つ読み進めてその数値を返す。
// それ以外の場合にはエラーを報告する。
int expect_number() {
if (token->kind != TK_NUM) error("数ではありません");
int val = token->val;
token = token->next;
return val;
}
bool at_eof() { return token->kind == TK_EOF; }
// 新しいトークンを作成してcurに繋げる
Token *new_token(TokenKind kind, Token *cur, char *str) {
Token *tok = calloc(1, sizeof(Token));
tok->kind = kind;
tok->str = str;
cur->next = tok;
return tok;
}
// 入力文字列pをトークナイズしてそれを返す
Token *tokenize(char *p) {
Token head; // 先頭のToken
head.next = NULL;
Token *cur = &head; // CurrentのToken
while (*p) {
// 空白文字をスキップ
if (isspace(*p)) {
p++;
continue;
}
// +や-があれば、それを記号(TK_RESERVED)とする
if (*p == '+' || *p == '-') {
// 第三引数のポストインクリメントの詳細
// new_tokenの中ではpとなり、
// 関数コールが終了後にpのポインタがインクリメントする。
cur = new_token(TK_RESERVED, cur, p++);
continue;
}
// 数字があれば、それを記号(TK_NUM)とする
if (isdigit(*p)) {
cur = new_token(TK_NUM, cur, p);
cur->val = strtol(p, &p, 10);
continue;
}
error("トークナイズできません");
}
new_token(TK_EOF, cur, p);
return head.next;
}
int main(int argc, char **argv) {
if (argc != 2) {
error("引数の個数が正しくありません");
return 1;
}
// トークナイズする
token = tokenize(argv[1]);
// アセンブリの前半部分を出力
printf(".intel_syntax noprefix\n");
printf(".globl main\n");
printf("main:\n");
// 式の最初は数でなければならないので、それをチェックして
// 最初のmov命令を出力
printf(" mov rax, %d\n", expect_number());
// `+ <数>`あるいは`- <数>`というトークンの並びを消費しつつ
// アセンブリを出力
while (!at_eof()) {
if (consume('+')) {
printf(" add rax, %d\n", expect_number());
continue;
}
expect('-');
printf(" sub rax, %d\n", expect_number());
}
printf(" ret\n");
return 0;
}