Fomos: ระบบปฏิบัติการทดลองที่สร้างด้วย Rust
(github.com/Ruddle)- Fomos เป็น OS เชิงทดลองที่สร้างด้วย Rust และเป็นโปรเจกต์เพื่อทำความเข้าใจแนวคิดของ Non-Unix OS รวมถึงโจทย์ของแพตเทิร์น exo-kernel
- รองรับการแสดงผลกราฟิก, การจัดสรรหน่วยความจำแบบไดนามิก, การโหลดและรันแอปพร้อมกัน, การรองรับเมาส์·คีย์บอร์ด Virtio และ cooperative scheduling
- แอปถูกจัดการเป็น ฟังก์ชันเดียว ในรูปแบบ
pub extern "C" fn _start(ctx: &mut Context) -> i32โดยไม่มีไลบรารีมาตรฐาน และรับฟังก์ชันของ OS ผ่านContext Contextเป็น struct ที่เก็บฟังก์ชันและสถานะ เช่น log, PID, framebuffer,calloc,cdalloc,storeและ input โดยรักษาความเข้ากันได้กับแอปเก่าด้วยการเพิ่มฟีเจอร์ใหม่ไว้ท้ายสุด- ไม่มี system call และเป็น โมเดลการรันแบบ cooperative ที่แอปคืนการควบคุมให้ OS ด้วย
returnจากนั้นฟังก์ชันstartจะถูกเรียกอีกครั้ง - สถานะของแอปสามารถบันทึกไว้ใน
Context.storeได้ และ kernel loop มีโครงสร้างแบบเรียบง่ายที่วนผ่านรายการแอป แล้วเรียก_start(Context::new(...))ของแต่ละแอป - เนื่องจากฟังก์ชันทั้งหมดและ side effect ทั้งหมดถูกส่งผ่าน
Contextจึงตั้งอยู่บนสมมติฐานว่าสามารถทำ sandboxing, instrumentation และ debugging ได้ด้วยการแทนที่หรือ wrap ฟังก์ชันของContext - ปัจจุบันยังไม่ได้ implement ด้านความปลอดภัย และแอปยังสามารถดู RAM ของแอปอื่นได้ โดยมีแผนจะทำความปลอดภัยของข้อมูลโดยไม่ใช้ context switch และ virtual memory stack แยกต่อแอป
- สิ่งที่ยังขาดอยู่คือ persistent storage, การรองรับ GPU, networking และ abstraction สำหรับแชร์ข้อมูลกับฟังก์ชันระหว่างแอป ส่วน Virgl ยังอยู่ระหว่างดำเนินงาน
- การ build ทำได้ด้วย
./build.shและอาจต้องใช้rust nightly,gcc, รวมถึงqemuที่มีแฟล็ก Virgl และ SDL
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
สิ่งที่ไม่ค่อยชอบคือ ใน ระบบแบบ preemptive นั้น
while (true)อาจทำให้ระบบช้าลงได้ก็จริง แต่ในระบบแบบ cooperative ทันทีที่ไม่ยอมคืนการควบคุม เครื่องก็แทบจะหยุดนิ่งไปเลยจากมุมมองด้านความปลอดภัย ระบบแบบนี้ยังทำให้การโจมตีแบบปฏิเสธการให้บริการทำได้ง่ายเกินไป และบั๊กเพียงจุดเดียวในแอปใดแอปหนึ่งก็อาจลามไปทั้งระบบได้
ผมไม่ใช่นักพัฒนาระบบปฏิบัติการ ถ้าผิดก็ช่วยแก้ให้ด้วย
เหตุผลที่ระบบปฏิบัติการเลิกใช้ cooperative multitasking ไม่ใช่เพราะมันแก้ปัญหา “การใช้ทรัพยากรเกินควบคุม” ทั้งหมดได้ตลอดไป แต่เพราะข้อผิดพลาดระดับแอปแบบง่าย ๆ อย่าง UI thread ถูกบล็อก หรือ loop ไม่รู้จบโดยไม่ตั้งใจ สามารถทำให้สถานะของทั้งระบบพังได้
สำหรับระบบที่ออกแบบมาให้รันโปรแกรมใดก็ได้ ถือว่าค่อนข้างร้ายแรง
สามารถอนุญาตให้แอปถูก schedule แบบ cooperative ได้ ขณะเดียวกันก็ทำให้
while(true){}ไม่สามารถยึดระบบไว้ได้ไม่รู้จบเช่น กำหนด time limit ต่อแอป หรือถ้าทดลองกว่านั้นก็อาจตรวจจับ loop แล้วตั้ง time limit แบบเผื่อเหลือเผื่อขาดไว้
สำหรับโปรแกรมเมอร์ จะสะดวกเมื่อการแยกระหว่างโปรแกรมที่ไม่เกี่ยวข้องกันมีมากที่สุด และการแยกระหว่างโปรแกรมที่เกี่ยวข้องกันมีน้อยที่สุด แต่สำหรับผู้ใช้ จะสะดวกเมื่อทรัพยากรคำนวณถูกแยกอย่างเข้มงวด ส่วนสิ่งอย่าง storage หรือ permission ถูกกั้นน้อยกว่า
ในทางปฏิบัติต้องมี scheduling ที่ซับซ้อน ซึ่งใกล้เคียง preemptive มากกว่า cooperative แต่ก็มีความเป็น cooperative อยู่เล็กน้อย
แม้ใน Linux โปรแกรมอันตรายอย่าง fork bomb ก็ทำให้ระบบหยุดได้ไม่ยาก โดยเฉพาะเมื่อเปิด swap ไว้ และแม้จะมี preemptive scheduler ถ้าโปรแกรมหนึ่งกิน system thread ไป 99% โดยพฤตินัยแล้วส่วนใหญ่ก็จะเป็นโปรแกรมนั้นที่ได้รัน
Scheduling เป็นสเปกตรัม และระบบปฏิบัติการปัจจุบันแม้จะเป็น preemptive แต่ก็มีความเป็น cooperative อยู่ระดับหนึ่งด้วย
แอปสามารถตัดสินใจได้ว่าจะยอมคืนการควบคุมหรือไม่
ในทางกลับกัน ระบบปฏิบัติการอาจปล่อยให้เป็น cooperative แต่เมื่อเกิด threshold การใช้ทรัพยากรหรือ timer interrupt ก็สลับ context ในโหมดล้มเหลวเป็นครั้งคราว กลายเป็น preemptive ปิดแอป แล้วกลับไปเป็น cooperative อีกครั้งได้
อาจเรียกสิ่งนี้ว่า optimistic cooperative และ pessimistic preemptive ได้
loop
while(true)ทำให้ระบบคอร์เดียวล่มได้ แต่ไม่ได้จำเป็นต้องเป็นแบบนั้นในระบบหลายคอร์จุดประนีประนอมในยุคที่โครงสร้างพื้นฐานของระบบปฏิบัติการที่เราใช้กันอยู่ตอนนี้ถูกสร้างขึ้น อาจแตกต่างจากปัจจุบันแล้ว
ชอบเป็นพิเศษตรงที่ว่า “ใน Fomos แอปก็เป็นแค่ฟังก์ชัน”
ไฟล์ executable ของ Unix หรือ Windows ซับซ้อนมากเมื่อเทียบกับฟังก์ชันอิสระ จึงแทบจินตนาการไม่ออกเลยว่า kernel ที่เขียนแบบนี้จะเจ๋งแค่ไหน
สงสัยว่า Smalltalk/Squeak ก็เป็นแนวทางแบบนี้ไหม และหวังว่าผู้เขียนจะทำต่อไปถึง file system, task manager, memory stack ที่ปลอดภัย และการแชร์ทรัพยากร
แน่นอนว่า requirement ขั้นต่ำของ proof of concept ยังต้องรัน DOOM ได้ด้วย
ระบบปฏิบัติการของ Lisp machine ใกล้เคียงกว่า โดยช่วงแรกไม่มี object system มีแต่ฟังก์ชันอิสระเรียกกันเอง และภายหลังกลายเป็น generic function ที่ specialize ตาม class ของ argument
ดูเหมือนผู้ออกแบบยังค้นไม่พบว่าทำไมระบบปฏิบัติการอื่น ๆ ถึงจำเป็นต้องมีสิ่งที่ตอนนี้พวกเขายังละไว้
int main() { … }ตรงไหนตัวอย่างอื่นที่ทำด้วย Rust คือ https://github.com/hermit-os/hermit-rs
ไอเดียก็ดี แต่การเพิ่มฟังก์ชันใหม่เข้าไปใน struct Context ต่อไปเรื่อย ๆ เพื่อให้ compatible กับรายการเก่า เป็นหนทางสู่ นรกของ backward compatibility
เท่ากับขังตัวเองไม่ให้ลบรายการเก่าหรือรายการที่เลิกใช้แล้วออกจาก struct Context ได้
วิธีที่ดีกว่าน่าจะเป็นการนำ semantic versioning มาใช้ระหว่างระบบปฏิบัติการกับแอป
ถ้าแอปประกาศว่าถูก build สำหรับหรือพึ่งพาเวอร์ชันใดของระบบปฏิบัติการ ระบบปฏิบัติการก็ตรวจสอบความเข้ากันได้ แล้วส่ง struct Context เวอร์ชันที่เหมาะสมให้ได้
ปัญหา backward compatibility ส่วนใหญ่ยังคงอยู่ แต่สามารถรักษา struct Context ให้สะอาดได้ด้วยการมี struct หลายชุดตาม major/minor version ภายใน kernel
เป็นไอเดียที่ดี แต่ผมก็ชอบความเรียบง่ายที่มี runtime interface เพียงอันเดียว
ในเมื่อระบบปฏิบัติการต้องจัดการทุกเวอร์ชันอยู่แล้ว แอปในอนาคตอาจใช้ padding เพื่อให้รู้สึกเหมือน “สะอาด” ก็ได้
struct Context{ padding: [u8;256], // old stuff ctx: ContextV42 }แต่พอเขียนแบบนี้แล้วก็รู้สึกเหมือนมีอะไรไม่ค่อยถูกอยู่เหมือนกัน
การที่แอปประกาศเวอร์ชันของตัวเองรู้สึกเหมือนเป็นปัญหาที่รูปแบบไฟล์ executable อย่าง ELF แก้ไว้แล้ว จึงกำลังลองทางเลือกอื่นอยู่
ส่วนที่ว่า “จะ sleep หรือรอแบบ async อย่างไร? ก็แค่ return” ฟังดูแปลกนิดหน่อย
asynchronous I/O แบบ
io_uringน่าจะยอดเยี่ยม แต่โมเดลนี้ดูเหมือนตัดสิ่งนั้นออกไป จึงอาจทำให้ได้ performance ที่เหมาะสมได้ยากการไม่รองรับ async ก็แปลกเหมือนกัน เพราะมันเชื่อมกับจุดหยุดพักตามธรรมชาติได้
อย่างไรก็ตาม ถ้าจะทำแบบนั้นก็คงต้องยอมทิ้งดีไซน์จำนวนมากที่ให้ application state ถูกบันทึกลงดิสก์และโหลดกลับมาอย่างชัดเจน ซึ่งดูมีต้นทุนสูง
networking ก็น่าจะทำได้ยากขึ้นด้วยเหตุผลคล้ายกัน อย่างน้อยก็ถ้าต้องการให้มีประสิทธิภาพ
ฟังก์ชันนี้ดูเหมือน ปลายทางของ event loop ที่รับ state ใด ๆ เป็น parameter ดังนั้นสิ่งที่ event loop ทำโดยทั่วไปน่าจะ generalize ได้
เพียงแต่จะต้องยอมทิ้ง coroutine ระดับภาษาและการรองรับ async ไป
ตัวอย่างที่ยกมาดูจงใจเกินไป
ในระบบปฏิบัติการแบบ preemptive แอปมักจะค้างด้วยวิธีที่ไม่ได้ทำให้ทั้งระบบกลายเป็น cooperative เช่น thread deadlock หรือ infinite loop
อีกอย่าง ระบบแบบ preemptive สามารถยุติแอปได้ตั้งแต่นานก่อนที่มันจะทำให้ระบบกลายเป็น cooperative ในทางปฏิบัติ หากแอปสร้าง thread หรือไฟล์มากเกินไป หรือใช้หน่วยความจำมากเกินไป
ระบบของเราก็แค่ค่อนข้างใจกว้างกว่า
ยิ่งกว่านั้น ทั้งที่บอกว่า “sandboxing ได้มาฟรีถ้ายอมรับสมมติฐานตั้งต้น” แต่ก็ยังบอกว่า “แอปใด ๆ สามารถตรวจดู RAM ของแอปอื่นได้ง่าย ๆ และนี่เป็นปัญหาที่แก้ยาก” ดังนั้น sandboxing ไม่ได้มาฟรี
ถึงอย่างนั้นก็เป็นไอเดียที่เท่ และหวังว่าผู้เขียนจะไปได้ดี
ทางแก้ของปัญหานี้อาจเป็นการสร้างฟังก์ชัน หรือก็คือ closure ที่ห่อหุ้มแอปพลิเคชันไว้ ให้ทำงานเหมือน Context ของตัวแอปเอง
น่าคิดว่าจะเป็นอย่างไรถ้าแอปสามารถเปิดแอปได้ หรือถ้าแอปสามารถเป็นระบบปฏิบัติการให้แอปอื่นได้
ถ้าเป็นระบบที่ออกแบบมาอย่างเหมาะสมเพื่อ sandboxing ที่แข็งแรง ก็จะจำกัดทรัพยากรทุกอย่าง และเมื่อถึงขีดจำกัดก็จะปฏิเสธคำขอ
สงสัยว่า Fomos แยกความต่างระหว่าง process กับ executable file อย่างไร
ใน Linux process คือข้อมูลภายในเคอร์เนล เช่น pointer ของ argv/envp, stack, heap, signal mask, ตาราง file handle, signal handler, virtual address space ที่รวมหน่วยความจำที่ executable ได้ และ uid, gid
ส่วน executable file คือไฟล์ที่มีบิตเพียงพอให้ loader เติม address space นั้นตอนเรียก system call
execveสามารถสร้าง process ได้ด้วย
clone3หรือforkแม้ไม่มี executable file และแม้เคอร์เนลจะใช้ ELF ส่วน user space ส่วนใหญ่ใช้ RTLD loader ของ GLIBC แต่ก็ไม่ได้จำเป็นต้องมีทั้งสองอย่างเพื่อสร้าง process จากรูปแบบ executable file เฉพาะexecutable แบบ static link ที่ไม่มี position-independent code นั้นจากมุมมองของ assembler ใกล้เคียงกับ “แค่ฟังก์ชัน” แต่ถ้า resolve runtime symbol โดยไม่มี ASLR ก็จะเสี่ยงต่อการโจมตีแบบ buffer overflow เมื่อรู้ address ของฟังก์ชันที่พึ่งพา
ผมอยากได้ทางเลือกแทนข้อบกพร่องของ glibc และโมเดล process ของ Posix แต่คิดว่าความซับซ้อนจำนวนมากของ executable file ใน Unix นั้นเป็นเรื่องโดยเนื้อแท้
การ resolve symbol ตอน runtime นั้นยากแต่มีประโยชน์ การอนุญาต arbitrary interpreter แม้จะน่ารำคาญก็เป็นจุดที่ Linux แข็งกว่า Windows และ MacOS และการให้ interface กับเคอร์เนลผ่าน system call ที่เสถียรคือจุดแข็งของ Linux
เช่น Mac ใช้ Mach-O, Windows ใช้ PE, Linux ใช้ ELF แต่ก็ไม่มีเหตุผลที่เราจะมี ecosystem ที่หลากหลายของรูปแบบการ execute/link ไม่ได้
ระบบปฏิบัติการที่มีโมเดลการโหลดโค้ดเรียบง่ายมากน่าจะเป็นที่ที่ดีสำหรับการทดลองแบบนั้น
ABI ที่เสถียรก็ไม่ใช่สิ่งเฉพาะตัวของ Linux และความมีประโยชน์ของการตัดสินใจแบบนั้นก็น่าสงสัยพอสมควร แต่การรองรับ driver นั้นยอดเยี่ยมและปฏิเสธได้ยาก
ค่อนข้างน่าสนใจ
ไม่แน่ใจว่าจะทำให้ได้ security และ safety ระดับหนึ่งด้วยแอปแบบ cooperative ที่ไม่น่าเชื่อถือได้อย่างไร
แอปใด ๆ สามารถยึด CPU ไว้ไม่มีกำหนด ทำให้เคอร์เนลและแอปอื่นหยุดชะงักได้
เหตุผลที่เราใช้ระบบปฏิบัติการที่มี preemptive scheduling ก็เพราะสามารถหยุดแอปที่ทำงานผิดปกติได้โดยไม่ทำให้ส่วนที่เหลือของระบบพัง
ใช้ preemptive multitasking แต่ไม่ได้บังคับใช้ memory protection แต่เหมือนจะให้ compiler เป็น system service แทน เพื่อให้รันได้เฉพาะ executable ที่ system compiler สร้างและเซ็นชื่อแล้วเท่านั้น
เพราะ compiler รับประกัน memory protection ตั้งแต่ตอน build ทำให้ system call และการสื่อสารระหว่าง process มีต้นทุนต่ำมาก
ถ้า compiler ซับซ้อนขึ้นอีกหน่อย ก็อาจบังคับ cooperative multitasking ได้คล้ายกัน โดยแทรกการเรียก
yieldในตำแหน่งที่จำเป็นแก้ halting problem ทั่วไปไม่ได้ก็จริง แต่ยังมีคลาสของโปรแกรมที่พิสูจน์ได้ด้วย static analysis ว่าจะจบการทำงานหรือยอมคืนสิทธิ์
แค่ต้องแยกจัดการเฉพาะโปรแกรมที่พิสูจน์ไม่ได้ และอาจใช้ watchdog timer เพื่อหยุดโปรแกรมที่ทำงานผิดปกติโดยอัตโนมัติได้
โค้ดที่ไม่น่าเชื่อถือจะไม่ถูกรันใน image “หลัก” แต่จะรันใน VM ที่ทิ้งได้
ตรงนี้ก็น่าจะใช้ hypervisor เพื่อใช้โมเดลเดียวกันได้ แต่ไม่มีใครใช้ระบบ Smalltalk อย่างเดียวล้วน ๆ และยังต้องมี infrastructure อยู่บ้าง
สงสัยว่าจะสามารถทำให้ระบบปฏิบัติการนี้รองรับ ความปลอดภัย ได้หรือไม่ โดยไม่ต้องออกแบบใหม่ทั้งหมด และไม่ต้องทำซ้ำสิ่งที่ระบบปฏิบัติการเดิม ๆ แทบจะทำกันไปแล้ว
เท่าที่รู้ มีสองวิธีในการบังคับใช้ความปลอดภัยของแอปพลิเคชันที่รันบนฮาร์ดแวร์เดียวกัน
วิธีหนึ่งคือแยกโปรเซสด้วยหน่วยความจำเสมือนในขณะรันไทม์ อีกวิธีคือให้ loader ตรวจสอบตอนโหลดว่าโค้ดมีการเข้าถึงหน่วยความจำแบบตามอำเภอใจหรือไม่
วิธีหลังมักบังคับใช้ผ่าน virtual machine ที่อนุญาตเฉพาะ bytecode ของชุดคำสั่งที่จำกัดและไม่มีการคำนวณ pointer เช่น JVM หรือ Smalltalk
ผู้เขียน Fomos ไม่ต้องการ context switch และการแยกหน่วยความจำ ฯลฯ ส่วน Rust compiler ก็ไม่ได้สร้าง bytecode แล้วมีวิธีอื่นไหม?
ตามที่เข้าใจคือใช้ certified compiler บังคับกฎอย่างการห้ามใช้
unsafeดังนั้นในกรณีนี้ source code จึงเทียบได้กับ bytecode ในทางปฏิบัติมองเผิน ๆ คล้าย Midori มาก แต่รายละเอียดการติดตั้งใช้งานต่างกันพอสมควร
ใน Theseus ไดรเวอร์และแอปพลิเคชันต่าง ๆ เป็น ELF object และทั้งหมดถูกลิงก์แบบไดนามิกเข้าเป็นไฟล์ executable เดียว หรือก็คือ kernel พร้อมมีเทคนิคที่น่าสนใจอย่าง hot upgrade ด้วย
https://github.com/theseus-os/Theseus
https://www.theseus-os.com/
เช่น ถ้าเกิด segmentation fault ตอนรันไทม์ ก็ตรวจสอบ security token อะไรทำนองนั้นว่าผู้เรียกมีสิทธิ์เข้าถึงและเรียก page นั้นหรือไม่
ไม่แน่ใจว่าในทางปฏิบัติจะใช้งานได้จริงแค่ไหน
แม้จะเป็น cooperative multitasking แต่ตอนนี้ มีคอร์จำนวนมากมาก จึงไม่น่าจะเหมือนยุค Classic MacOS
โปรเซสหนึ่งหรือสองตัวที่ไม่ยอม yield อาจไม่ได้จับทั้งระบบไว้เสมอไป
ถ้าฟังก์ชันทำงานผิดพลาดและไม่ return ระบบก็อาจ terminate มันได้เมื่อใช้คอร์จนหมด
cooperative multitasking ไม่ได้แปลว่าประสิทธิภาพต้องแย่เสมอไป
เดิมที time-sharing เป็นวิธีแบ่ง CPU เดี่ยวขนาดใหญ่ให้ผู้ใช้หลายคน แต่ตอนนี้ CPU แบบหลายคอร์สำหรับผู้ใช้คนเดียวเป็นเรื่องทั่วไปแล้ว จึงน่าจะถึงเวลาคิดวิธีใช้คอร์แบบอื่น
แค่มีโปรเจกต์นี้อยู่ก็ทำให้คาดหวังมากจริง ๆ
ในโมเดลนี้ไม่มี context switch จึงมีโอกาสที่ประสิทธิภาพจะดีขึ้นด้วยซ้ำ
เลยทำให้อยากรู้ว่าจะเกิดอะไรขึ้นถ้าปรับ timeslice ของ Linux ให้สูงเป็นค่าประหลาด ๆ อย่าง 10 วินาที
อยากฟังแผนด้านความปลอดภัยให้ละเอียดกว่านี้
โดยรวมแล้วคิดว่าการทดลองแบบนี้แสดงให้เห็นว่าระบบปฏิบัติการสามารถพัฒนาให้ดีขึ้นได้ด้วย การออกแบบแบบ greenfield
นึกถึง Mirage OS ขึ้นมานิดหน่อย: https://mirage.io/