編譯原理實戰入門:用 JavaScript 寫一個簡單的四則運算編譯器(修訂版)

譚光志發表於2020-11-10

編譯器是一個程式,作用是將一門語言翻譯成另一門語言

例如 babel 就是一個編譯器,它將 es6 版本的 js 翻譯成 es5 版本的 js。從這個角度來看,將英語翻譯成中文的翻譯軟體也屬於編譯器。

一般的程式,CPU 是無法直接執行的,因為 CPU 只能識別機器指令。所以要想執行一個程式,首先要將高階語言編寫的程式翻譯為彙編程式碼(Java 還多了一個步驟,將高階語言翻譯成位元組碼),再將彙編程式碼翻譯為機器指令,這樣 CPU 才能識別並執行。

由於組合語言和機器語言一一對應,並且組合語言更具有可讀性。所以計算機原理的教材在講解機器指令時一般會用匯編語言來代替機器語言講解。

本文所要寫的四則運算編譯器需要將 1 + 1 這樣的四則運算表示式翻譯成機器指令並執行。具體過程請看示例:

// CPU 無法識別
10 + 5

// 翻譯成組合語言
push 10
push 5
add

// 最後翻譯為機器指令,彙編程式碼和機器指令一一對應
// 機器指令由 1 和 0 組成,以下指令非真實指令,只做演示用
0011101001010101
1101010011100101
0010100111100001

四則運算編譯器,雖然說功能很簡單,只能編譯四則運算表示式。但是編譯原理的前端部分幾乎都有涉及:詞法分析、語法分析。另外還有編譯原理後端部分的程式碼生成。不管是簡單的、複雜的編譯器,編譯步驟是差不多的,只是複雜的編譯器實現上會更困難。

可能有人會問,學會編譯原理有什麼好處

我認為對編譯過程內部原理的掌握將會使你成為更好的高階程式設計師。另外在這引用一下知乎網友-隨心所往的回答,更加具體:

  1. 可以更加容易的理解在一個語言種哪些寫法是等價的,哪些是有差異的
  2. 可以更加客觀的比較不同語言的差異
  3. 更不容易被某個特定語言的宣揚者忽悠
  4. 學習新的語言是效率也會更高
  5. 其實從語言a轉換到語言b是一個通用的需求,學好編譯原理處理此類需求時會更加遊刃有餘

好了,下面讓我們看一下如何寫一個四則運算編譯器。

詞法分析

程式其實就是儲存在文字檔案中的一系列字元,詞法分析的作用是將這一系列字元按照某種規則分解成一個個字元(token,也稱為終結符),忽略空格和註釋。

示例:

// 程式程式碼
10 + 5 + 6

// 詞法分析後得到的 token
10
+
5
+
6

終結符

終結符就是語言中用到的基本元素,它不能再被分解。

四則運算中的終結符包括符號和整數常量(暫不支援一元操作符和浮點運算)。

  1. 符號+ - * / ( )
  2. 整數常量:12、1000、111...

詞法分析程式碼實現

function lexicalAnalysis(expression) {
    const symbol = ['(', ')', '+', '-', '*', '/']
    const re = /\d/
    const tokens = []
    const chars = expression.trim().split('')
    let token = ''
    chars.forEach(c => {
        if (re.test(c)) {
            token += c
        } else if (c == ' ' && token) {
            tokens.push(token)
            token = ''
        } else if (symbol.includes(c)) {
            if (token) {
                tokens.push(token)
                token = ''
            } 

            tokens.push(c)
        }
    })

    if (token) {
        tokens.push(token)
    }

    return tokens
}

console.log(lexicalAnalysis('100    +   23   +    34 * 10 / 2')) 
// ["100", "+", "23", "+", "34", "*", "10", "/", "2"]

四則運算的語法規則(語法規則是分層的)

  1. x*, 表示 x 出現零次或多次
  2. x | y, 表示 x 或 y 將出現
  3. ( ) 圓括號,用於語言構詞的分組

以下規則從左往右看,表示左邊的表示式還能繼續往下細分成右邊的表示式,一直細分到不可再分為止。

  • expression: addExpression
  • addExpression: mulExpression (op mulExpression)*
  • mulExpression: term (op term)*
  • term: '(' expression ')' | integerConstant
  • op: + - * /

addExpression 對應 + - 表示式,mulExpression 對應 * / 表示式。

如果你看不太懂以上的規則,那就先放下,繼續往下看。看看怎麼用程式碼實現語法分析。

語法分析

對輸入的文字按照語法規則進行分析並確定其語法結構的一種過程,稱為語法分析。

一般語法分析的輸出為抽象語法樹(AST)或語法分析樹(parse tree)。但由於四則運算比較簡單,所以這裡採取的方案是即時地進行程式碼生成和錯誤報告,這樣就不需要在記憶體中儲存整個程式結構。

先來看看怎麼分析一個四則運算表示式 1 + 2 * 3

首先匹配的是 expression,由於目前 expression 往下分只有一種可能,即 addExpression,所以分解為 addExpression
依次類推,接下來的順序為 mulExpressionterm1(integerConstant)、+(op)、mulExpressionterm2(integerConstant)、*(op)、mulExpressionterm3(integerConstant)。

如下圖所示:

這裡可能會有人有疑問,為什麼一個表示式搞得這麼複雜,expression 下面有 addExpressionaddExpression 下面還有 mulExpression
其實這裡是為了考慮運算子優先順序而設的,mulExpraddExpr 表示式運算級要高。

1 + 2 * 3
compileExpression
   | compileAddExpr
   |  | compileMultExpr
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 1
   |  |  |_
   |  | matches '+'
   |  | compileMultExpr
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 2
   |  |  | matches '*'
   |  |  | compileTerm
   |  |  |  |_ matches integerConstant        push 3
   |  |  |_ compileOp('*')                      *
   |  |_ compileOp('+')                         +
   |_

有很多演算法可用來構建語法分析樹,這裡只講兩種演算法。

遞迴下降分析法

遞迴下降分析法,也稱為自頂向下分析法。按照語法規則一步步遞迴地分析 token 流,如果遇到非終結符,則繼續往下分析,直到終結符為止。

LL(0)分析法

遞迴下降分析法是簡單高效的演算法,LL(0)在此基礎上多了一個步驟,當第一個 token 不足以確定元素型別時,對下一個字元採取“提前檢視”,有可能會解決這種不確定性。

以上是對這兩種演算法的簡介,具體實現請看下方的程式碼實現。

表示式程式碼生成

我們通常用的四則運算表示式是中綴表示式,但是對於計算機來說中綴表示式不便於計算。所以在程式碼生成階段,要將中綴表示式轉換為字尾表示式。

字尾表示式

字尾表示式,又稱逆波蘭式,指的是不包含括號,運算子放在兩個運算物件的後面,所有的計算按運算子出現的順序,嚴格從左向右進行(不再考慮運算子的優先規則)。

示例:

中綴表示式: 5 + 5 轉換為字尾表示式:5 5 +,然後再根據字尾表示式生成程式碼。

// 5 + 5 轉換為 5 5 + 再生成程式碼
push 5
push 5
add

程式碼實現

編譯原理的理論知識像天書,經常讓人看得雲裡霧裡,但真正動手做起來,你會發現,其實還挺簡單的。

如果上面的理論知識看不太懂,沒關係,先看程式碼實現,然後再和理論知識結合起來看。

注意:這裡需要引入剛才的詞法分析程式碼。

// 彙編程式碼生成器
function AssemblyWriter() {
    this.output = ''
}

AssemblyWriter.prototype = {
    writePush(digit) {
        this.output += `push ${digit}\r\n`
    },

    writeOP(op) {
        this.output += op + '\r\n'
    },

    //輸出彙編程式碼
    outputStr() {
        return this.output
    }
}

// 語法分析器
function Parser(tokens, writer) {
    this.writer = writer
    this.tokens = tokens
    // tokens 陣列索引
    this.i = -1
    this.opMap1 = {
        '+': 'add',
        '-': 'sub',
    }

    this.opMap2 = {
        '/': 'div',
        '*': 'mul'
    }

    this.init()
}

Parser.prototype = {
    init() {
        this.compileExpression()
    },

    compileExpression() {
        this.compileAddExpr()
    },

    compileAddExpr() {
        this.compileMultExpr()
        while (true) {
            this.getNextToken()
            if (this.opMap1[this.token]) {
                let op = this.opMap1[this.token]
                this.compileMultExpr()
                this.writer.writeOP(op)
            } else {
                // 沒有匹配上相應的操作符 這裡為沒有匹配上 + - 
                // 將 token 索引後退一位
                this.i--
                break
            }
        }
    },

    compileMultExpr() {
        this.compileTerm()
        while (true) {
            this.getNextToken()
            if (this.opMap2[this.token]) {
                let op = this.opMap2[this.token]
                this.compileTerm()
                this.writer.writeOP(op)
            } else {
                // 沒有匹配上相應的操作符 這裡為沒有匹配上 * / 
                // 將 token 索引後退一位
                this.i--
                break
            }
        }
    },

    compileTerm() {
        this.getNextToken()
        if (this.token == '(') {
            this.compileExpression()
            this.getNextToken()
            if (this.token != ')') {
                throw '缺少右括號:)'
            }
        } else if (/^\d+$/.test(this.token)) {
            this.writer.writePush(this.token)
        } else {
            throw '錯誤的 token:第 ' + (this.i + 1) + ' 個 token (' + this.token + ')'
        }
    },

    getNextToken() {
        this.token = this.tokens[++this.i]
    },

    getInstructions() {
        return this.writer.outputStr()
    }
}

const tokens = lexicalAnalysis('100+10*10')
const writer = new AssemblyWriter()
const parser = new Parser(tokens, writer)
const instructions = parser.getInstructions()
console.log(instructions) // 輸出生成的彙編程式碼
/*
push 100
push 10
push 10
mul
add
*/

模擬執行

現在來模擬一下 CPU 執行機器指令的情況,由於彙編程式碼和機器指令一一對應,所以我們可以建立一個直接執行彙編程式碼的模擬器。
在建立模擬器前,先來講解一下相關指令的操作。

在記憶體中,棧的特點是隻能在同一端進行插入和刪除的操作,即只有 push 和 pop 兩種操作。

push

push 指令的作用是將一個運算元推入棧中。

pop

pop 指令的作用是將一個運算元彈出棧。

add

add 指令的作用是執行兩次 pop 操作,彈出兩個運算元 a 和 b,然後執行 a + b,再將結果 push 到棧中。

sub

sub 指令的作用是執行兩次 pop 操作,彈出兩個運算元 a 和 b,然後執行 a - b,再將結果 push 到棧中。

mul

mul 指令的作用是執行兩次 pop 操作,彈出兩個運算元 a 和 b,然後執行 a * b,再將結果 push 到棧中。

div

sub 指令的作用是執行兩次 pop 操作,彈出兩個運算元 a 和 b,然後執行 a / b,再將結果 push 到棧中。

四則運算的所有指令已經講解完畢了,是不是覺得很簡單?

程式碼實現

注意:需要引入詞法分析和語法分析的程式碼

function CpuEmulator(instructions) {
    this.ins = instructions.split('\r\n')
    this.memory = []
    this.re = /^(push)\s\w+/
    this.execute()
}

CpuEmulator.prototype = {
    execute() {
        this.ins.forEach(i => {
            switch (i) {
                case 'add':
                    this.add()
                    break
                case 'sub':
                    this.sub()
                    break
                case 'mul':
                    this.mul()
                    break
                case 'div':
                    this.div()
                    break                
                default:
                    if (this.re.test(i)) {
                        this.push(i.split(' ')[1])
                    }
            }
        })
    },

    add() {
        const b = this.pop()
        const a = this.pop()
        this.memory.push(a + b)
    },

    sub() {
        const b = this.pop()
        const a = this.pop()
        this.memory.push(a - b)
    },

    mul() {
        const b = this.pop()
        const a = this.pop()
        this.memory.push(a * b)
    },

    div() {
        const b = this.pop()
        const a = this.pop()
        // 不支援浮點運算,所以在這要取整
        this.memory.push(Math.floor(a / b))
    },

    push(x) {
        this.memory.push(parseInt(x))
    },

    pop() {
        return this.memory.pop()
    },

    getResult() {
        return this.memory[0]
    }
}

const tokens = lexicalAnalysis('(100+  10)*  10-100/  10      +8*  (4+2)')
const writer = new AssemblyWriter()
const parser = new Parser(tokens, writer)
const instructions = parser.getInstructions()
const emulator = new CpuEmulator(instructions)
console.log(emulator.getResult()) // 1138

一個簡單的四則運算編譯器已經實現了。我們再來寫一個測試函式跑一跑,看看執行結果是否和我們期待的一樣:

function assert(expression, result) {
    const tokens = lexicalAnalysis(expression)
    const writer = new AssemblyWriter()
    const parser = new Parser(tokens, writer)
    const instructions = parser.getInstructions()
    const emulator = new CpuEmulator(instructions)
    return emulator.getResult() == result
}

console.log(assert('1 + 2 + 3', 6)) // true
console.log(assert('1 + 2 * 3', 7)) // true
console.log(assert('10 / 2 * 3', 15)) // true
console.log(assert('(10 + 10) / 2', 10)) // true

測試全部正確。另外附上完整的原始碼,建議沒看懂的同學再看多兩遍。

更上一層樓

對於工業級編譯器來說,這個四則運算編譯器屬於玩具中的玩具。但是人不可能一口吃成個胖子,所以學習編譯原理最好採取循序漸進的方式去學習。下面來介紹一個高階一點的編譯器,這個編譯器可以編譯一個 Jack 語言(類 Java 語言),它的語法大概是這樣的:

class Generate {
    field String str;
    static String str1;
    constructor Generate new(String s) {
        let str = s;
        return this;
    }

    method String getString() {
        return str;
    }
}

class Main {
    function void main() {
        var Generate str;
        let str = Generate.new("this is a test");
        do Output.printString(str.getString());
        return;
    }
}

上面程式碼的輸出結果為:this is a test

想不想實現這樣的一個編譯器?

這個編譯器出自一本書《計算機系統要素》,它從第 6 章開始,一直到第 11 章講解了彙編編譯器(將組合語言轉換為機器語言)、VM 編譯器(將類似於位元組碼的 VM 語言翻譯成組合語言)、Jack 語言編譯器(將高階語言 Jack 翻譯成 VM 語言)。每一章都有詳細的知識點講解和實驗,只要你一步一步跟著做實驗,就能最終實現這樣的一個編譯器。

如果編譯器寫完了,最後機器語言在哪執行呢?

這本書已經為你考慮好了,它從第 1 章到第 5 章,一共五章的內容。教你從邏輯閘開始,逐步組建出算術邏輯單元 ALU、CPU、記憶體,最終搭建出一個現代計算機。然後讓你用編譯器編譯出來的程式執行在這臺計算機之上。

另外,這本書的第 12 章會教你寫作業系統的各種庫函式,例如 Math 庫(包含各種數學運算)、Keyboard 庫(按下鍵盤是怎麼輸出到螢幕上的)、記憶體管理等等。

想看一看全書共 12 章的實驗做完之後是怎麼樣的嗎?我這裡提供幾張這臺模擬計算機執行程式的 DEMO GIF,供大家參考參考。

這幾張圖中的右上角是“計算機”的螢幕,其他部分是“計算機”的堆疊區和指令區。

這本書的所有實驗我都已經做完了(每天花 3 小時,兩個月就能做完),答案放在我的 github 上,有興趣的話可以看看。

參考資料

相關文章