ข้ามไปยังเนื้อหา

ตัวแปร Local และ Stack

WebAssembly เป็น stack machine ไม่มี register file เหมือนใน x86 หรือ ARM — แทนที่จะเป็นแบบนั้น คำสั่งสื่อสารกันด้วยการ push และ pop ค่าบน value stack แบบโดยปริยาย การเข้าใจแบบจำลองนี้คือกุญแจสำคัญในการอ่าน WAT ได้อย่างคล่องแคล่ว

ทุกคำสั่งทำงานบนยอดสุดของ stack คำสั่งหนึ่งจะ pop อินพุตของตัวเองและ push เอาต์พุตของตัวเอง ตัวอย่างเช่น i32.add จะ pop ค่า i32 สองค่าและ push ผลลัพธ์ i32 หนึ่งค่า

flowchart LR
  A["Stack: []\ni32.const 10"] --> B["Stack: [10]\ni32.const 3"]
  B --> C["Stack: [10, 3]\ni32.add"]
  C --> D["Stack: [13]"]
Value stack ระหว่าง i32.const 10 / i32.const 3 / i32.add

เมื่อสิ้นสุดฟังก์ชัน สิ่งที่เหลืออยู่บน stack ต้องตรงกับชนิด result ที่ประกาศไว้อย่างแน่ชัด หากเหลือมากเกินไปหรือน้อยเกินไป module จะ validation ไม่ผ่าน

นอกจาก stack แล้ว ฟังก์ชันสามารถประกาศตัวแปร local ได้โดยใช้ (local $name type) สิ่งเหล่านี้เป็นช่องเก็บค่าที่เปลี่ยนแปลงได้ ซึ่งมีชีวิตอยู่ตลอดระยะเวลาที่เรียกฟังก์ชัน

(module
(func (export "example") (param $x i32) (result i32)
(local $tmp i32)
local.get $x ;; push $x
i32.const 1
i32.add ;; pop two, push sum
local.set $tmp ;; pop and store in $tmp
local.get $tmp)) ;; push $tmp back

พารามิเตอร์ก็เป็น local เช่นกัน — คุณอ่านค่าด้วย local.get ได้เหมือน local ที่ประกาศไว้ แต่ถูกกำหนดค่าเริ่มต้นด้วยอาร์กิวเมนต์ที่ถูกเรียก

local.tee คล้ายกับ local.set แต่ทิ้งสำเนาของค่าไว้บน stack หลังจากเก็บแล้ว มีประโยชน์เมื่อคุณต้องการทั้งบันทึกค่าและใช้ค่านั้นทันที:

(module
(func (export "storeAndDouble") (param $n i32) (result i32)
(local $saved i32)
local.get $n
local.tee $saved ;; stores $n in $saved AND leaves the value on stack
i32.const 2
i32.mul)) ;; doubles the value that tee left behind

หากไม่มี local.tee คุณจะต้องใช้ local.set ตามด้วย local.get ที่เป็นสองคำสั่งแทนที่จะเป็นหนึ่ง

WebAssembly
ตัวเลือกBenefitCost
เขียน WAT เองบังคับให้เข้าใจ value stack และ local จริง ๆ เพราะไม่มี syntax sugar มาบัง เหมาะมากสำหรับเรียนรู้ต้อง track stack depth เองตลอด แค่ลืม local.set หนึ่งบรรทัดก็ทำให้ validation พังได้
ให้ compiler generate Wasmcompiler จัดการ stack allocation และ register-to-local mapping ให้อัตโนมัติ productive กว่ามากสำหรับแอปจริงควบคุม lifetime ของ local แบบละเอียดเองไม่ได้ ต้องเชื่อใจ compiler
  • จบ block หรือฟังก์ชันโดยมีค่าค้างอยู่บน stack มากกว่าที่ signature ประกาศไว้ เช่นลืม pop ค่าที่ push ไว้ระหว่างทาง ทำให้ module validation ไม่ผ่าน
  • ใช้ local.tee ทั้งที่ตั้งใจจะใช้ local.set ทำให้ค่าที่ไม่ต้องการค้างอยู่บน stack เกินมาโดยไม่รู้ตัว
  • ลืมว่า branch อย่าง br/br_if ที่กระโดดออกจาก block ต้องทิ้ง stack ไว้ในสภาพที่ target ของ branch คาดหวังพอดี ไม่ใช่แค่ปล่อยให้ไหลไปตามลำดับปกติ

💡 ตัวอย่างจากของจริง

วิศวกรที่ debug production .wasm blob ที่บริษัทอย่าง Cloudflare Workers มักใช้ wasm2wat แปลง binary กลับเป็น WAT ที่อ่านได้ เพื่อไล่ดู stack ทีละคำสั่งตอนหา bug ที่ compiler สร้างโค้ดผิดพลาด

`local.tee $x` ทำอะไร?
ใน stack machine จะเกิดอะไรขึ้นเมื่อ `i32.add` ทำงาน?
`(local $x i32)` ต้องปรากฏที่ใดในฟังก์ชัน?