Viết một Virtual Machine đơn giản



  • Virtual Machine

    Virtual Machine (VM) - Máy ảo là phần mềm tạo ra một môi trường giữa hệ nền máy tính và người dùng cuối trong đó người dùng cuối có thể thực thi phần mềm. Máy ảo trong bài này sẽ khác với các máy ảo sử dụng cho việc tạo ra các nền tảng/môi trường ảo như VMware, VirtualBox, QEMU...

    VM được ứng dụng rất nhiều trong việc viết ngôn ngữ lập trình kịch bản cần tốc độ thực thi nhanh, các ngôn ngữ này sẽ được biên dịch sang bytecode và được sử dụng để làm chỉ thị cho máy ảo.

    Bytecode

    Bytecode, p-code hay opcode là dãy các hướng dẫn chỉ thị dưới dạng số nguyên để hướng dẫn cho VM làm việc. Nói nôm na thì nó gần giống như mã máy (machine code / binary) để hướng dẫn cho máy tính thực hiện một cái gì đó.

    Ngôn ngữ Tệp mã nguồn Tệp biên dịch
    Lua .lua .luac
    Python .py .pyc
    Ruby .rb bytecode sẽ chạy trực tiếp trên RVM

    Đối với thằng Java, bản thân nó là một ngôn ngữ biên dịch nhưng luôn được gán mác ăn theo nên nó chắc chắn có thứ gọi là JVM giúp "code một nơi chạy muôn nơi" 😂

    Khi mở một file đã biên dịch sang bytecode của một ngôn ngữ lập trình (chẳng hạn .pyc) sẽ thấy chỉ toàn là số hoặc đôi khi có cả chữ nhưng lộn xộn cả lên (dùng Notepad++), trong trường hợp này có thể phần string trong source đã biến thành một dãy ASCII liền kề nhau.

    Code thôi!

    VM trong bài này sẽ rất đơn giản, chỉ có tác dụng tính toán các phép tính số nguyên theo một tuần tự nhất định thôi.

    Đầu tiên vẫn là tạo một file mới, mình đặt tên là main.go

    package main
    
    import "fmt"
    
    

    Tiếp đến mình cần một số constant:

    const (
        VM_OK     = 0
        VM_ERROR  = 1
        STACK_MAX = 50
    
        HLT = 0   // thoát
        PSH = 1   // push
        POP = 2   // pop
        PUT = 3   // in giá trị
        ADD = 4   // cộng
        SUB = 5   // trừ
        MUL = 6   // nhân
        DIV = 7   // chia
    )
    
    • VM_OK, VM_ERROR để miêu tả trạng thái trả lỗi của VM
    • STACK_MAX trở xuống mình sẽ nói sau

    Stack là gì?

    Stack (ngăn xếp) ở đây giống như register (thanh ghi) trong assembly, hay hiểu đơn giản hơn thì nó là một loạt các ô nhớ để chứa giá trị.

    Push là hành động đẩy một giá trị lên stack (gán giá trị vào một ô nhớ) và ngược lại, pop sẽ lấy giá trị từ stack xuống (lấy giá trị từ một ô nhớ).

    Tiếp tục, mình cần một số type:

    type (
        Value float64
        Code  []int
    
    
    • Value sẽ là các số, giá trị của phép tính, do có phép chia nên mình sẽ sử dụng float64
    • Code là dãy số nguyên (bytecode)

    Tiếp đến là struct VM:

    ....
        Code []int
        VM struct {
            code  Code
            stack [STACK_MAX]Value
            top   int
            ip    int
        }
    )
    
    • code sẽ là phần chỉ thị, dùng để nạp vào máy ảo
    • stack ở đây sẽ là một mảng Value, tối đa là 50 (STACK_MAX = 50)
    • top để cho máy ảo biết thằng ô stack nào đang ở trên cùng
    • ip là index cho code (code[ip]) giúp lấy mã từ mảng code

    Để tiện cho việc code (cũng như nó là một VM đơn giản) nên mình sẽ tạo một biến VM global

    var vm VM
    

    Khởi tạo máy ảo

    func initVM(code Code) {
        vm.top = 0
        vm.ip = 0
        vm.code = code
    }
    
    • vm.top, vm.ip gán bằng 0, đơn giản vì mảng đếm từ 0
    • vm.code = code để nạp code từ bên ngoài

    Đọc code

    func nextCode() int {
        code := vm.code[vm.ip]
        vm.ip++
        return code
    }
    
    • Lấy code từ mảng vm.code, sau đó tăng vm.ip lên 1

    Push giá trị lên stack và pop giá trị từ stack

    func pushValue(val Value) {
        vm.stack[vm.top] = val
        vm.top++
    }
    
    • Gán giá trị vào vm.stack tại index vm.top hiện tại
    • Sau đó tăng 1 để ô tiếp theo là top
    func popValue() Value {
        vm.top--
        val := vm.stack[vm.top]
        return val
    }
    
    • Ngược lại với push, do ô top không có giá trị gì nên giảm top xuống
    • Sau đó lấy giá trị ô hiện tại (ô top)

    Tiếp đến là hàm quan trọng nhất, giúp VM có thể chạy và đọc code:

    func runVM() int {
    
        for vm.ip < len(vm.code) {
    
            op := nextCode()
    
        }
    
        return VM_ERROR
    }
    
    • Vòng lặpfor... sẽ là vòng lặp vô hạn, dừng khi vm.ip (index) vượt quá tối đa số phần tử trong mảng vm.code
    • op là code đọc từ vm.code thông qua hàm nextCode()
    ...
            op := nextCode()
    
            switch op {
    
            case HLT:
                return VM_OK
    
            default:
                fmt.Printf("unknow opcode: %d\n", op)
                return VM_ERROR
            }
    ...
    
    • HLT sẽ dùng chương trình lại ngay
    • default, nếu opcode sai sẽ báo lỗi và return

    OK, đến đây ta có thể test ngay và luôn.
    Chỉ cần khởi tạo hàm main, viết các chỉ thị và nạp code vào

    func main() {
        code := []int{HLT}
        initVM(code)
        ret := runVM()
    
        if ret == VM_OK {
            fmt.Print("done!\n")
        } else {
            fmt.Print("error!\n")
        }
    }
    
    $ go run main.go
    done!
    

    Nếu như thay HLT thành một số bất kỳ (chẳng hạn 10) sẽ được kq như sau:

    $ go run main.go
    unknow opcode: 10
    error!
    

    Giờ tiếp tục phần switch-case cho các chỉ thị còn lại:

    ...
            case PSH:
                val := Value(nextCode())
                pushValue(val)
                break
    ...
    
    • Đọc giá trị tiếp theo trong code, sau đó push nó lên stack
    • Ví dụ: PSH, 15 sẽ là push 15 lên stack
            case POP:
                popValue()
                break
    
    • Pop giá trị từ stack
    • Ex: POP - số 15 đang ở stack 0 và stack top là 1, póp một cái thì top sẽ là 0
            case PUT:
                val := popValue()
                fmt.Printf("-> %g\n", val)
                break
    
    • Pop giá trị ở stack top và in nó ra console
    • %g trong fmt.Printf thường sử dụng cho số thực có tối đa 14 chữ số (bao gồm phần nguyên & thập phân), có thể thay bằng %llf
    • Ex: PSH, 20, PUT sẽ in ra console là -> 20
            case ADD:
                b, a := popValue(), popValue()
                pushValue(a + b)
                break
    
    • Lấy 2 giá trị từ stack xuống (do a push lên trước b nên khi pop sẽ ngược lại)
    • Cộng chúng lại và push lên stack

    OK, đến đây có thể tạo một ví dụ phép cộng ở main và chạy thử

    func main() {
        code := []int{PSH, 4, PSH, 5, ADD, PUT, HLT}
        ...
    
    $ go run main.go
    -> 9
    done!
    

    Mình giải thích code luôn:

    PSH, 4, PSH 5
    
    • Đầu tiên là push 4 lên stack, 4 sẽ gán vào ô 0 (do vm.top bằng 0 => top là 0, sau khi gán xong thì là top 1), tiếp theo push 5 lên stack, ô 1 chứa giá trị 5, ô 2 là top.
    ADD
    
    • Sau đó pop giá trị từ ô 1 và ô 0 xuống (ta được lần lượt là 54), cộng chúng lại (4 + 5 được 9) và đẩy giá trị này lên top, do top đang là 0, đẩy lên sẽ gán ô 0 bằng 9 và top là ô 1.
    PUT
    
    • Tiếp theo pop 9 từ stack tại ô 0, ô 1 đang là top tụt xuống 1, ô 0 là top, in 9 ra console.
    HLT
    
    • Thoát chương trình và trả về VM_OK.

    Đến đây chắc rối não lắm nhỉ 😂

    Và cuối cùng là các phép tính trừ, nhân, chia, nó tương tự như phép cộng ở trên:

    ...
            case SUB:
                b, a := popValue(), popValue()
                pushValue(a - b)
                break
    
            case MUL:
                b, a := popValue(), popValue()
                pushValue(a * b)
                break
    
            case DIV:
                b, a := popValue(), popValue()
                pushValue(a / b)
                break
    ...
    

    Xong rồi, test ngay thôi

    func main() {
        // 4 + 5
        code1 := []int{PSH, 4, PSH, 5, ADD, PUT, HLT}
        // 100 - 52 + 88 // 35 * 8 / 7
        code2 := []int{PSH, 100, PSH, 52, SUB, PSH, 88, ADD, PUT, PSH, 35, PSH, 8, MUL, PSH, 6, DIV, PUT, HLT}
    
        initVM(code1)
        if runVM() == VM_OK {
            fmt.Print("code1: OK\n\n")
        } else {
            fmt.Print("code1: ERROR\n\n")
        }
    
        initVM(code2)
        if runVM() == VM_OK {
            fmt.Print("code2: OK\n\n")
        } else {
            fmt.Print("code2: ERROR\n\n")
        }
    }
    
    $ go run main.go
    -> 9
    code1: OK
    
    -> 136
    -> 46.666666666666664
    code2: OK
    

    Vậy đã xong VM rồi, nhưng nhìn code nạp vào vẫn còn hơi mơ hồ, khó hiểu quá, phải làm sao đây 😞

    Cứ copy hàm sau vào...

    func debugVM(op int) {
        switch op {
    
        case HLT:
            fmt.Print(">> on halt program!\n")
            break
    
        case PSH:
            val := vm.code[vm.ip]
            fmt.Printf(">> push %d to stack\n", val)
            break
    
        case POP:
            val := vm.stack[vm.top-1]
            fmt.Printf(">> pop %g from stack\n", val)
            break
    
        case PUT:
            val := vm.stack[vm.top-1]
            fmt.Printf(">> pop %g from stack and print it\n", val)
            break
    
        case ADD:
            b, a := vm.stack[vm.top-1], vm.stack[vm.top-2]
            fmt.Printf(">> add %g to %g, push %g\n", a, b, a+b)
            break
    
        case SUB:
            b, a := vm.stack[vm.top-1], vm.stack[vm.top-2]
            fmt.Printf(">> sub %g to %g, push %g\n", a, b, a-b)
            break
    
        case MUL:
            b, a := vm.stack[vm.top-1], vm.stack[vm.top-2]
            fmt.Printf(">> mul %g to %g, push %g\n", a, b, a*b)
            break
    
        case DIV:
            b, a := vm.stack[vm.top-1], vm.stack[vm.top-2]
            fmt.Printf(">> div %g to %g, push %g\n", a, b, a/b)
            break
        }
    }
    

    ...và chạy nó ở phần switch-case trong hàm runVM

        for vm.ip < len(vm.code) {
    
            op := nextCode()
            debugVM(op)
    ...
    

    ...và output 😂

    $ go run main.go
    >> push 4 to stack
    >> push 5 to stack
    >> add 4 to 5, push 9
    >> pop 9 from stack and print it
    -> 9
    >> on halt program!
    code1: OK
    
    >> push 100 to stack
    >> push 52 to stack
    >> sub 100 to 52, push 48
    >> push 88 to stack
    >> add 48 to 88, push 136
    >> pop 136 from stack and print it
    -> 136
    >> push 35 to stack
    >> push 8 to stack
    >> mul 35 to 8, push 280
    >> push 6 to stack
    >> div 280 to 6, push 46.666666666666664
    >> pop 46.666666666666664 from stack and print it
    -> 46.666666666666664
    >> on halt program!
    code2: OK
    

    Máy ảo này là bản convert từ C sang Go trong repo calc.vm của mình. Hi vọng qua post này, bạn có thể hiểu thêm về cách máy ảo hoạt động cũng như máy thiệt làm việc với các thanh ghi.

    Mọi code trên, bạn có thể xem tại gist này

    Tham khảo thêm:


  • Trùm cuối

    @wuuyi cao nhân phương nào 🤗



  • đọc chả hiểu gì :))



  • @Trương-Công-Lộc học assembly rồi quay lại đọc bài này 😂

    Nếu thým rảnh đi cày source hoặc sử dụng API mấy ngôn ngữ kịch bản như Python, Lua thì sẽ thấy vô cùng dễ hiểu. Lúc ấy có thể viết luôn cả một ngôn ngữ mới.



  • @wuuyi dạ bác, em không phải dân chuyên nhưng thích nghịch ngợm. biết 1 chút về AutoIT rồi mới qua mò bên GO thôi ạ. Em cũng chỉ mong muốn biết 1 ít GO để tự code cái web làm vài tool vặt phục vụ cuộc sống thôi bác ạ.



  • hay quá bác đúng mấy cái em đang muốn nghiên cứu 🦆


 

Có thể bạn cũng quan tâm

DMCA.com Protection Status