プログラミング言語の作り方

javascript/C言語/アセンブラを用い、 字句解析、構文解析、インタプリタ、コンパイラのプログラムをスクラッチから作りながら、 「プログラミング言語の作り方」を解説する。

抽象構文木対応
javascript版プログラミング言語の作り方(コンパイラ開発)

構文解析と実行を分離する。

ページメニュー

構文解析のアウトプットとして、抽象構文木(AST)として出力するように変更する。 実行部分ASTを渡すと、実行するrun.jsを実装することにする。

抽象構文木とは

実装の前に、構文解析のアウトプットとなる、抽象構文木(AST)について、先にイメージをしておきたい。

javascriptでは、木構造は、オブジェクトで表す。

printという関数名をleftに、 関数呼び出しを表す演算子"()"をopに、 引数として渡す文字列"hello world"をrightにセットすると、以下のようになる。

//print("hello world")の場合の、ASTの例
{
    left: "print",
    op: "()",
    right: "hello world"
}

このオブジェクト(tree構造のノード)を生成するコードは以下のように書けばよい。

//すでに変数に値が入っているとする
var left = "print";
var op = "()";
var right = "hello world";

//オブジェクトが生成される。
var obj1 =  {left:left,op:op,right:right};

//変数名とkey名が同じであれば省略できる
var obj2 = {left,op,right};

コンパイラのファイル構成

コンパイラを作るにあたり、以下のファイル構成で実装した。

compiler.jsの実装

parserがastを作る処理は同じなので、コンパイラから、 インタプリタのparser.jsをそのまま利用している。

astをgenasmに渡し、アセンブラコードを受け取り、ファイルに書き出している。

外部asコマンド、ldコマンドで、実行ファイルexecにする。

利便性のため、実行ファイルexecも実行してすぐに結果が出るようにしている。

$ cat compiler.js
#!/usr/bin/node

//別ファイルに記述している処理を読み込む
var {exec,write,read,show,error}  = require("./utils.js");
var lexer = require("./lexer.js");
var parser = require("./parser.js");
var genasm = require("./genasm.js");

//source.3から読み込む
var source = read("source.3");

var tokens = lexer(source);
//filter後、処理前のtokensを表示
show("処理前tokens =",tokens);

//構文解析し、抽象構文木astを取得
var ast = parser(tokens);
show("抽象構文木ast=",ast);

console.log("-------------");
//astにしたがってコンパイル
var asm = genasm(ast);

//コンパイル結果(アセンブラ)をファイルに保存。
write("source.s",asm);

//ここまででコンパイラ自体の処理は終わり。
//ここからは外部コマンドを実行する。

//asコマンドで、機械語(オブジェクトファイル)にする。
exec("as source.s -o source.o");

//ldコマンドで、libcを動的リンク、ローダーを指定して、実行ファイルexecにする。
exec("ld -lc --dynamic-linker /lib64/ld-linux-x86-64.so.2  -o exec source.o");

//execコマンドを実行し、結果を表示。
console.log(exec("./exec"));

genasm.jsの実装

astからアセンブラを出力するgenasm.jsを実装した。

インタプリタでのrun.jsと同じ構成で、astをたどっていき、 run.jsでは実行したが、genasm.jsではアセンブラを出力するようにしている。

また、「13-5*2」などの場合の「おあずけ」をインタプリタでは関数の途中で別の関数を 呼び出すことで実現したが、アセンブラでは、スタックを用いて表現する。

スタックでは、push/popを利用するが、 保留するため、13をいったん覚えておくためpushする。

それから、5*2を計算し、結果の10をpushする。

popすれば10が先に取れ、もういちどpopすると13が取れる。

popはpushした順番と逆になることに注意する必要があるが、 13と10を引き算して、2という結果を得る。

話は変わるが、libcのprintfでは、16バイトアライメントを 守らないとエラーになるため、push/popの前後で調整する処理が入っている。

printfにはもう一つ落とし穴があり、文字列を表示するときは、raxレジスタが0でないといけない。 逆に、浮動小数点を表示するときは、raxが1でないといけない。

$ cat genasm.js

var {error}  = require("./utils.js");

//リテラル文字列の定義
var strs = [];

//アセンブラコードの配列
//コードだけでなく、アセンブラ上の「#コメント」を出力。
var codes = [];

//astからアセンブラへ変換
module.exports = function genasm(ast){
    //intel表記
    codes.push("\n#intel表記を使う");
    codes.push(".intel_syntax noprefix");

    //エントリポイント
    codes.push("\n#エントリポイント");
    codes.push(".global _start");
    codes.push("_start:");

    //変換開始
    gen(ast);

    //終了ステータス0でexit
    codes.push("\n#exit(0)で終了");
    codes.push("end:");
    codes.push("mov rdi,0");
    codes.push("call exit");

    //printfで使う文字列リテラル
    codes.push("\n#printfで使う文字列リテラルを定義");
    codes.push('.newline: .string "\\n"');
    codes.push('.fmtg: .string "%g"');

    //文字列リテラル
    codes.push("\n#文字列リテラル.s連番で定義");
    strs.forEach((s,i)=>{
        //.s連番: .string "文字列"
        codes.push(".s"+(i+1)+": .string "+ s);
    });

    //最後に改行がないと、asが文句言う。
    return codes.join("\n")+"\n";
}


//「おあずけ」をアセンブラでは、スタックを用いて表現する。
//スタック操作のpush/popのラッパ関数群。

//通常レジスタには、push/popはあるが、アライメント調整があるので関数化。
//printfは、アライメントを16バイトに揃えないとエラーになる。
//push(通常レジスタ名)
(@function push(r){
    //pushでアライメントが8バイトずれる
    codes.push("push "+r);

    //スタックポインタrspをさらに-8バイト=伸ばしてずれを16にする。pop時に戻す。
    codes.push("\n#アライメント8バイト伸ばす");
    codes.push("sub rsp,8");
}

//pop(レジスタ名)
function pop(r){
    //push時に伸ばしておいたスタックポインタrspを、先に+8バイト=縮めて戻しておく。
    codes.push("\n#アライメント8バイト戻し");
    codes.push("add rsp,8");

    //さらに、popでアライメントが8バイトずれ戻り、16バイトにそろう。
    codes.push("pop "+r);
}
@)
//astの階層をたどりながらアセンブラを出力。
//アセンブラコードは、codesに格納
function gen(a){
    if(!a) return;
    if(!a.op){

        if(a[0] == '"'){
            //文字列リテラルとして定義
            strs.push(a);
            //「.s連番(strs.length)」という名で定義されたリテラル文字列を取得する
            codes.push("\n#.s"+strs.length+"で定義されたリテラル文字列取得");
            //文字列本体ではなく、アドレスを計算
            codes.push("lea r10,[.s"+strs.length+"]");
            //push r10(文字列) スタックに保管
            push("r10");
            return;
        }

        //それ以外(変数名、関数名などのシンボル)ならそのまま返す
        return a;
    }else if(a.op == "()"){
        var func = gen(a.left);
        if( func == "print"){
            //スタックは逆転するため、reverseしておく。
            [gen(a.right)].flat().reverse().forEach(m=>{
                //文字列
                //スタックから、printf("文字列")の第一引数rdiへセット
                pop("rdi");

                //文字列をprintfするときはrax=0でないとエラー
                codes.push("\n#文字列をprintfするときは、rax=0でないとエラー");
                codes.push("mov rax,0");

                codes.push("\n#文字列表示");
                codes.push("call printf");
            });
            //最後に改行コードを表示する
            //.newlineは"\n"
            codes.push("\n#改行コード.newline表示");
            codes.push("lea rdi,[.newline]");
            codes.push("call printf");
        }else{
            error("未実装の関数呼び出し func=",func);
        }
    }else{
        error("未実装の演算子 op=",a.op);
    }
}

コンパイラの実行

compiler.jsを実行する。

まず、tokensとASTが意図どおりに分割されているか、念のため表示しているので、確認しよう。

「hello world」と表示されており、問題ない。

$ ./compiler.js
処理前tokens =[ 'print', '(', '"hello world"', ')' ]
抽象構文木ast={ left: 'print', op: '()', right: '"hello world"' }

hello world

コンパイラが出力したアセンブラ

コンパイラが出力したアセンブラ(source.s)を載せておく。

$ cat source.s

#intel表記を使う
.intel_syntax noprefix

#エントリポイント
.global _start
_start:

#.s1で定義されたリテラル文字列取得
lea r10,[.s1]
push r10

#アライメント8バイト伸ばす
sub rsp,8

#アライメント8バイト戻し
add rsp,8
pop rdi

#文字列をprintfするときは、rax=0でないとエラー
mov rax,0

#文字列表示
call printf

#改行コード.newline表示
lea rdi,[.newline]
call printf

#exit(0)で終了
end:
mov rdi,0
call exit

#printfで使う文字列リテラルを定義
.newline: .string "\n"
.fmtg: .string "%g"

#文字列リテラル.s連番で定義
.s1: .string "hello world"

このページの目次へ戻るサイトの最上位へ戻る