From Opcodes to Algorithms - Day 7 and 8

วันนี้สร้าง assembler ของตัวเองได้แล้ว ได้เล่นอะไรหลายอย่างเลย

สองวันนี้เริ่มจากเขียน scanner กับ parser ให้อ่านไฟล์ assembly ของเรา แล้วให้ compile ออกมาเป็น bytecode เอาไปรันต่อได้ หลังจากอ่าน crafting interpreters มาก็ลองเขียนดู เลยเจอจุดที่น่าสนใจหลายจุด

ปัญหาแรกที่เจอ คือ type information ของ Rust มันจะหายไปตอน compile time เช่นเราอยากจะอ่านว่า fields ใน enum values ของเรามีกี่ช่อง เพื่อจะได้เอามานับ instruction arity ได้ เช่น Push(u16) มี 1 ช่อง แต่ Foo(u16, String) จะมี 2 ช่อง กลายเป็นว่า Rust ทำไม่ได้

ถ้าวิธีแก้แบบปกติ ก็คือต้องเขียน match arm ตามจำนวน enum ที่เรามี ซึ่งโค้ดมันจะเยอะแบบปวดหัวมาก ความเจ๋งคือ procedural macros สามารถรับ token stream ของโค้ดมา แล้วเอามา transform ได้ ทำให้เราเพิ่ม syntax อะไรเองก็ได้ ทำให้ metaprogramming ได้สุดมาก

Pasted image 20240104013937.png

ตอนนี้เลย parse AST ออกมา แล้วแกะ enum variant fields ออกมา แล้วก็เอามาสร้างเป็น function arity() ที่จะบอกว่า variant นี้มีกี่ช่อง ใช้ library ชื่อ proc_macro syn กับ quote แล้วเขียนไม่ยากเลย

Pasted image 20240104013921.png

อย่างที่สองคือตัว scanner กับ parser เอง เจอว่าส่วนมากมันตรงไปตรงมา เป็นการ transform character -> token -> instructions ที่ไม่ยากมาก แต่พาร์ทที่แก้บัคนานสุดๆ คือการ resolve labels

เพราะว่าเวลาเราเรียก label ใน assembly เช่น jmp start เนี่ย เราจะต้อง resolve ว่า jmp มันมาจากไหน ซึ่งวิธีที่เราใช้คือการทำ two-pass parser ให้มัน resolve labels ก่อนครั้งนึง ครั้งถัดไปก็ค่อย produce instructions ออกมา แต่ปัญหาไม่ได้อยู่ตรงนั้น

ปัญหาคือเราจะต้องคำนวณ offset ของ label เนี่ยแหละ ซึ่งฟังดูเหมือนง่าย แต่ถ้าวาง logic ตอนแรกมาไม่ดี มันจะเจอ off-by-one errors เยอะมาก ใช้เวลาแก้บัคอยู่ 3 ชั่วโมง ขนาดมี unit tests คลุมหมด ปรากฎว่า logic มันผิดหลายจุด สุดท้ายก็แก้ให้มันตรงไปตรงมาขึ้น

ซึ่งเราเจอว่า debugger ของ IntelliJ ทำออกมาดีมาก มันอยู่บน LLDB ที่มันอ่าน LLVM IR ออกมาได้ ทำให้เราแปะ breakpoint แล้วโดดไปโดดมา อ่านค่าได้ตลอด ก็ใช้วิธีนี้คู่กับ print debugging แล้วรู้สึกว่าสะดวกมาก

ตอนที่ทำ compiler ก็ใช้วิธีแยก logic ของการแปลง instruction to bytecode ออกมา เจอว่าพอจะเขียน bytecode ลงไฟล์เนี่ย มันจะต้องใช้ bitwise operations เพื่อแปลงระหว่าง u16 กับ u8 นิดหน่อย ซึ่งพอเขียนเสร็จก็ทำงานได้ตั้งแต่ first run นี่แหละทำให้เราชอบ Rust มาก ถ้า compile ผ่านก็มั่นใจได้เยอะมากว่ามันจะทำงานได้ตรงกับที่คิด

สุดท้ายก็เอามาสร้างเป็น command line program ที่ compile, run และ run from source ได้เลย ใช้ library ชื่อ clap มาอ่าน arguments แล้วก็ไปรันฟังก์ชั่นต่างๆ ชอบที่มันใช้วิธี derive macro ทำให้แทบไม่ต้องเขียนโค้ดเลย ใช้การแปะ attribute ไว้ใน data structure เอา

ตอนนี้รันโปรแกรมได้แล้ว มีฟีเจอร์ที่ค่อนข้างครบเลย ต่อไปเดี๋ยวจะเพิ่ม data section ลง assembler ทำให้ใส่พวก offsets & string symbols เข้าไปได้ จะได้ทำ hello world ใน assembly file ได้ตรงๆ

ต่อจากนี้คิดว่าอยากลองเขียน compiler มาสร้างภาษาของตัวเองที่ high-level ขึ้น ให้มันออกมาเป็น virtual machine bytecode ดู แล้วก็น่าจะสลับไปทำส่วนที่เป็น visual infinite canvas แล้วทำให้รันใน WebAssembly ได้ดู

-- Day 7 and 8 of From Opcodes to Algorithms in Rust.

October 6 and 7. Post on Facebook.