- Scylla เสนอเส้นทางที่ทำให้คอมไพล์เป็น Rust ที่ปลอดภัย ได้โดยตรง ด้วยการค่อย ๆ จัดโครงสร้าง C ต้นฉบับ แทนที่จะย้าย C เดิมไปเป็น unsafe Rust แล้วค่อยแก้
- เป้าหมายคือ ชุดย่อย applicative C ที่รวมถึงการประมวลผลข้อมูล การดำเนินการกับ pointer, control flow ที่มีโครงสร้าง และโค้ดที่พกพาได้ โดยไม่รวมโค้ดที่พึ่งพา
goto, การ cast ระหว่าง integer-pointer, เทคนิค pointer, bitfield และ untagged union - การแปลงมี 2 ขั้นตอน คือ ลดระดับ Clang AST ลงเป็น Mini-C แล้วจึงย้ายไปเป็น safe Rust พร้อมทำให้พฤติกรรมละเอียดอ่อนของ C ชัดเจนขึ้น เช่น integer promotion, implicit conversion, assignment expression และ prefix/postfix increment
- ส่วนที่ยากที่สุดคือ pointer arithmetic ซึ่งถูกเปลี่ยนเป็นการแบ่งส่วนโดยใช้ Rust slice และ
split_at_mut/split_atพร้อมจัดการ mutable inference, การ derive trait และการเลือกใช้Boxกับ borrow ด้วย - มีการประเมินกับ Windows SymCrypt, HACL★, อัลกอริทึมบีบอัดหลักของ bzip2, parser/serializer ของ EverParse CBOR และบางส่วนของ Microsoft FrodoKEM และยังพบ undefined behavior ใน bzip2 กับ FrodoKEM ระหว่างการแปลงด้วย
เส้นทางการย้ายจาก C→Rust ที่ Scylla เลือก
- แรงจูงใจในการย้ายโค้ด C เดิมไปเป็น Rust เริ่มจากปัญหา ความปลอดภัยของหน่วยความจำ
- งานวิจัยของ Google และ Microsoft ประเมินว่า 70% ของช่องโหว่ด้านความปลอดภัยเกี่ยวข้องกับการจัดการหน่วยความจำที่ผิดพลาด
- บริษัทและรัฐบาลกำลังแนะนำให้ใช้ภาษาที่ปลอดภัยด้านหน่วยความจำอย่าง Rust ในระบบที่มีความสำคัญด้านความปลอดภัย
- Rust มีข้อได้เปรียบชัดเจนสำหรับโค้ดใหม่ แต่การเขียนโค้ด C ระดับอุตสาหกรรมที่ผ่านการทดสอบและดีบักมาแล้วทั้งหมดใหม่อีกครั้งนั้นทำได้ยาก
- เครื่องมือแปลง C→Rust อัตโนมัติที่มีอยู่มักสร้าง unsafe Rust เพื่อรองรับ C ทั้งหมด
- อนุญาตสำนวนแบบ C เช่น unchecked pointer หรือ transmutation ของ Rust
- การรับประกันความปลอดภัยของหน่วยความจำแบบ static จึงหายไป ทำให้เป้าหมายของการใช้ภาษาที่ปลอดภัยอ่อนลง
- workflow ทั่วไปคือใช้ผลลัพธ์ unsafe Rust เป็นจุดเริ่มต้น แล้วค่อย refactor ซ้ำ ๆ ให้เป็น Rust ที่ปลอดภัย
- มีการเสนอ static analysis เพื่อเปลี่ยน raw pointer เป็น borrow ของ safe Rust หรือกู้คืน abstraction ของ Rust จาก representation ระดับต่ำ
- เครื่องมือ refactor กระจัดกระจาย และการรองรับ
c2rust refactorก็หยุดลงในปี 2022
- Scylla เลือกแนวทางที่ค่อย ๆ จัดโครงสร้างตัวโค้ด C ต้นฉบับเอง ให้คอมไพล์เป็น safe Rust ได้ แทนที่จะแก้ unsafe Rust ที่สร้างออกมา
ชุดย่อยของ C ที่รองรับและ pattern ที่ไม่รองรับ
- เป้าหมายของ Scylla คือการแปลงที่คาดเดาได้ และการสร้างโค้ด Rust ที่ใกล้เคียงกับ C ต้นฉบับ
- สิ่งที่รองรับคือ ชุดย่อย applicative C
- โค้ดที่จัดการและประมวลผลข้อมูล
- โค้ดที่ใช้ pointer arithmetic
- โค้ดที่มี control flow แบบมีโครงสร้าง
- โค้ดที่พกพาได้
- ไม่รองรับ codebase ที่พึ่งพา pattern ต่อไปนี้
goto- การใช้ representation ของ object ผ่านการ cast ระหว่าง integer-pointer
- เทคนิค pointer
- bitfield
- untagged union
- นักพัฒนาสามารถใส่ targeted rewrite และ annotation ในซอร์ส C เพื่อให้ Scylla เข้าใจได้
- สามารถเขียน pattern ของ aliasing ที่ไม่เข้ากับ borrow checker ของ Rust ใหม่ได้
- สามารถให้ข้อมูลกับ Scylla ว่า tagged union ควรถูกแปลเป็น ADT ระดับสูง
Mini-C: ภาษากลางที่ลดความกำกวมของ C
- Scylla เริ่มจาก AST ของ Clang frontend แล้วแปลงเป็นภาษาที่ชื่อ Mini-C ก่อน
- Mini-C จัดการ branch, loop, pointer, dereference และการนำ address มาใช้เหมือน C แต่มี semantics แบบ “no-surprises”
- integer ทั้งหมดมีความกว้างคงที่
- integer promotion และ integer conversion ของ C แสดงด้วย explicit cast
- ไม่อนุญาต untyped pointer อย่าง
void *
- Mini-C ต่างจาก C ตรงที่เป็น ภาษาแบบ expression
- assignment ไม่คืนค่า
- syntax ของ C เช่น
e1 = e2 = e3,p[i++]จะถูก desugar ใน Mini-C - expression ที่ใช้ทดสอบใน loop และ conditional statement ต้องมีชนิดเป็น
boolไม่ใช่intของ C
- ระหว่างย้าย typed AST ของ Clang ไปเป็น typed AST ของ Mini-C จะทำให้พฤติกรรม implicit ของ C ชัดเจนขึ้น
- ปรับ conditional expression ให้เป็น
bool - ปรับ array index ให้เป็น
size_t - เปลี่ยน implicit conversion ของ argument ในการเรียกฟังก์ชันและด้านขวาของ assignment เป็น explicit cast
- ใน arithmetic operation จะสะท้อน usual arithmetic conversions ตามมาตรฐาน C
- ปรับ conditional expression ให้เป็น
- การแปลงตั้งสมมติฐานว่าโค้ด C พกพาได้ และไม่พึ่งพา data model ของ C
- เช่น ไม่คาดหวังโค้ดที่พฤติกรรมเปลี่ยนตามว่า
longมีขนาด 4 ไบต์หรือ 8 ไบต์ - implementation จะตรวจจับ data model ของสถาปัตยกรรมเป้าหมายในช่วง configure-time แล้วแปลง
unsigned intเป็นชนิดความกว้างคงที่อย่างuint32_t
- เช่น ไม่คาดหวังโค้ดที่พฤติกรรมเปลี่ยนตามว่า
การสังเคราะห์ ADT และ tuple
- Mini-C มี ADT, tuple และ pattern matching ในระดับที่สูงกว่า C
- tagged union ถูกแปลเป็น ADT ผ่าน annotation
- รูปแบบเป้าหมายคือ struct แบบ
{ int tag; union { t0 case0; ...; tn caseN }} - สมมติว่า tag value อยู่ตั้งแต่ 0 ถึง N และ tag value ตรงกับลำดับของ union case
- รูปแบบเป้าหมายคือ struct แบบ
- Scylla แปลงชนิด annotated tagged union เป็นชนิด variant
- ตอนสร้างค่า จะตรวจสอบว่า
.tag = iและ.casej = eสอดคล้องกันหรือไม่ - หากตรงกัน จะแปลงเป็นค่าของ constructor นั้น
- หาก payload กับ tag ไม่ตรงกัน จะไม่แปลเป็น Mini-C
- ตอนสร้างค่า จะตรวจสอบว่า
- การเข้าถึง field ของ tagged union จะปลอดภัยได้ก็ต่อเมื่อรู้สถานะ tag ปัจจุบัน
- จดจำ pattern อย่าง
if (x.tag == i) { ... x.casei }หรือswitch - แปลงเป็นรูปแบบ
match x with | Ci v -> ... - การเข้าถึง union case อื่นถือว่า invalid และทำให้เกิดข้อผิดพลาดในการแปลง
- จดจำ pattern อย่าง
- tuple ก็สังเคราะห์ได้ด้วย annotation
- struct ที่มี
nfield จะถูกแปลงเป็นn-ary tuple - การเข้าถึง field จะเปลี่ยนเป็น tuple field access
- tuple มี type แบบ structural จึงได้ประโยชน์จาก mut-polymorphism
- struct ที่มี
การแปลงจาก Mini-C ไปเป็น safe Rust
- Mini-C ให้ representation ของโปรแกรม C ที่มี type annotation ครบถ้วน จากนั้นจึงถูกแปลงเป็น safe Rust
- ความยากหลักมีสามด้าน
- การกำจัด pointer arithmetic ของ C
- การทำให้ mutability และ aliasing ชัดเจน
- การจัดเตรียมโครงสร้างตามสำนวน Rust เช่น trait โดยอัตโนมัติ
- การแปลงชนิด pointer ซับซ้อนเพราะ Rust มี representation ของ pointer ที่ต่างกัน
- Rust แยก
Box<T>กับ&T - pointer ไปยัง element เดียวและหลาย element ก็แยกกัน เช่น
&Tกับ&[T] - array ของ Rust เป็นค่า และไม่ decay เป็น pointer โดยอัตโนมัติเหมือน C
- Rust แยก
- กลยุทธ์พื้นฐานคือคอมไพล์ pointer type ทั้งหมดของ C เป็น slice borrow ของ Rust อย่าง
&[T]- ทั้ง stack pointer และ heap pointer จะกลายเป็น slice borrow โดยพื้นฐาน
- pointer ของ element เดียวและหลาย element ก็จะเป็น slice borrow โดยพื้นฐานเช่นกัน
- mutability จะถูก infer อัตโนมัติในขั้นตอนแยกต่างหาก
- Scylla ใช้ heuristic และ annotation แบบ manual เพื่อแปล pointer บางส่วนเป็น
Box<T>- ฟังก์ชันอย่าง
T *create()ที่ไม่มี global reference และถูกตัดสินว่าเป็น fresh allocation อาจถูกแปลเป็นfn create() -> Box<T> - analysis นี้ถูกนำไปใช้แบบ recursive ภายใน definition ของ struct และ variant ด้วยวิธี fixed point
- struct ที่ยังมี borrow เหลืออยู่จะมี lifetime parameter
- ฟังก์ชันอย่าง
ข้อจำกัดของการแปลง Box, slice และ array
- ใน Rust ต้องแปลงระหว่าง array, slice borrow และ
Boxอย่างชัดเจน ดังนั้นการแปลง Rust ของ Scylla จึงทำงานแบบ type-directed ด้วย - กฎการแปลงจะใส่ coercion ที่เปลี่ยน array หรือ boxed slice เป็น slice borrow
- array จะกลายเป็น slice borrow ในรูปแบบอย่าง
&x[..] - boxed slice สามารถแปลงเป็น borrow ได้
- array จะกลายเป็น slice borrow ในรูปแบบอย่าง
- การแปลงย้อนทิศทางก็ทำได้เช่นกัน
- สามารถยกระดับ slice หรือ array เป็น heap allocation แล้วเปลี่ยนเป็น
Box<[T]>ได้
- สามารถยกระดับ slice หรือ array เป็น heap allocation แล้วเปลี่ยนเป็น
- การแปลงย้อนทิศทางนี้อาจสร้างความแตกต่างด้าน copy semantics
- ใน C array กับ pointer อาจชี้ไปยัง memory เดียวกัน
- ใน Rust,
Box::new(x)อาจสร้าง copy ของx - array ของ primitive type อย่าง integer array ไม่มีวิธี opt out จาก trait
Copyทำให้ Rust อาจทำ copy แบบเงียบ ๆ
- Scylla จะลบตัวแปรเดิมออกจาก environment เมื่อเกิดการแปลงลักษณะนี้ เพื่อห้ามใช้งานต่อ
- หากโปรแกรม C ต้นฉบับยังใช้ตัวแปรนั้นต่อ จะเกิดข้อผิดพลาดในการแปลง
- นักพัฒนาควรแก้ซอร์ส C ก่อนแปลง เพื่อทำให้เจตนาชัดเจนขึ้น
การเปลี่ยน pointer arithmetic เป็นการแบ่ง Rust slice
- โปรแกรม C มักไม่ได้เข้าถึง array ผ่าน base pointer เดียว แต่ใช้ pattern ที่แบ่ง array เป็น chunk หรือคง pointer ตำแหน่งปัจจุบันไว้ขณะวนผ่าน
- Rust ไม่อนุญาต pointer arithmetic แบบอิสระ และให้วิธีแบ่ง slice ด้วย
split_at_mutหรือsplit_atsplit_at_mutเป็น primitive ที่สละ ownership ของ slice เดิมแล้วได้ sub-slice สองส่วน- รักษา invariant ของ Rust ที่ว่า mutable data ต้องมี owner เพียงหนึ่งเดียว
- Scylla นำ split tree มาใช้เพื่อจับคู่ pointer arithmetic ของ C กับวิธีแบ่งของ Rust
- pointer ของ C แต่ละตัวถูก map ไปยัง split tree หนึ่งต้น
- split tree เปลี่ยนไปตาม flow
- ติดตามว่า ณ จุดใดของโปรแกรม การเข้าถึง pointer ของ C ควรถูกเปลี่ยนเป็นการเข้าถึง slice ของ Rust แบบใด
- เนื่องจาก pointer ของ C ไม่มีข้อมูลความยาว Scylla จึงสมมติว่า chunk ต่าง ๆ ไม่ซ้อนทับกัน
- หากตั้งใจให้ซ้อนทับกัน ก็จะไม่ผ่านการตรวจ type ของ Rust และนักพัฒนาต้องเขียนโค้ด C ใหม่
- เพื่อให้การแปลงคาดเดาได้ จึงหลีกเลี่ยง backtracking และทำแบบ forward
- ในตัวอย่าง มีการแบ่ง array 32 ไบต์
abcdเป็นพื้นที่ limb ขนาด 8 ไบต์สี่ส่วน- ใน C ใช้ pointer arithmetic ที่ไม่ได้เรียงจากซ้ายไปขวา เช่น
abcd + 0,abcd + 16,abcd + 8,abcd + 24 - การแปลงเป็น Rust จะเก็บประวัติของการเรียก
split_at_mutไว้ใน split tree เพื่อหา sub-slice ที่ถูกต้อง
- ใน C ใช้ pointer arithmetic ที่ไม่ได้เรียงจากซ้ายไปขวา เช่น
เป้าหมายที่ประเมินและ undefined behavior ที่พบ
- implementation ของ Scylla ใช้ Clang รับโค้ด C เดิมเป็น input และ output เป็น safe Rust
- เป้าหมายการประเมินรวมถึงบางส่วนของโปรเจกต์ C เดิมหลายตัว
- บางส่วนของ SymCrypt บน Windows
- บางส่วนของไลบรารีเข้ารหัส HACL★
- ส่วนหลักของอัลกอริทึมบีบอัด bzip2
- binary parser และ serializer ของ CBOR ในไลบรารี EverParse
- implementation ของ post-quantum cryptographic primitive FrodoKEM ของ Microsoft
- กรณีเหล่านี้แสดงให้เห็นว่าชุดย่อย applicative C ของ Scylla สามารถครอบคลุมแอปพลิเคชันที่อ่อนไหวด้านความปลอดภัยได้หลายประเภท
- ระหว่างการแปลง ยังระบุและรายงาน undefined behavior ที่มีอยู่ในโค้ด C ต้นฉบับของ bzip2 และ FrodoKEM ด้วย
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
สิ่งสำคัญคือ งานนี้มุ่งเป้าไปที่ “โค้ดเบส C ที่ผ่านการตรวจพิสูจน์เชิงรูปแบบแล้ว”
โค้ด C สำหรับระบบทั่วไปไม่ได้ผ่านการตรวจพิสูจน์เชิงรูปแบบ ดังนั้นจึงเป็นอีกเรื่องหนึ่งพอสมควร
ตัวอย่างเช่น พอยน์เตอร์ที่เคยชี้ไปยังอาร์เรย์ C บนสแตก อาจถูกแปลใน Rust เป็น
Box<[u8]>ซึ่งเหมือนพอยน์เตอร์ที่เป็นเจ้าของสำเนาใหม่บนฮีป หากโค้ดเดิมพึ่งพาข้อเท็จจริงว่าพอยน์เตอร์นั้นชี้ไปยังอาร์เรย์จริง โค้ดที่แปลแล้วอาจทำงานผิดอย่างเงียบ ๆ ได้ถ้าเป็นฟีเจอร์แปลอัตโนมัติสำหรับส่วนย่อย C++ ที่ปลอดภัยด้านหน่วยความจำในโปรเจกต์ของผมอย่าง scpptool ก็คงจัดการด้วยการย้ายอาร์เรย์ไปเป็นชนิดทดแทนและ iterator เพื่อคงความหมายเดิมไว้
โปรเจกต์ของ OP อาจรับมือเฉพาะ C ที่แปลงเป็น Rust ที่ปลอดภัยได้ง่าย แต่เมื่อคิดถึงความยากของปัญหาแล้ว ผลงานนี้ก็น่านับถือและดูมีประโยชน์ในระดับหนึ่ง
ตั้งแต่แรก พวกเขาไม่ได้แปล C จริง ๆ แต่เปลี่ยนให้โค้ดที่เขียนด้วย F* ปล่อย Rust ออกมาทางฝั่งคอมไพเลอร์ C แทน ไม่ได้เผชิญหน้ากับโค้ด C จริงที่ซับซ้อน และอย่างมากก็จัดการกับ Mini-C แบบจำกัดที่คอมไพเลอร์ของเล่นน่าจะปล่อยออกมาเท่านั้น
ในต้นฉบับยังบอกด้วยว่าถ้าโปรแกรม C เดิมพึ่งพา
xมากกว่านั้น การแปลจะเกิดข้อผิดพลาดและขอให้โปรแกรมเมอร์แก้ซอร์ส ซึ่งหมายความว่าพวกเขาหวังให้ C ถูกเขียนไว้แล้วในสไตล์ที่ตัวตรวจสอบการยืมของ Rust พอใจดูเหมือนถ้อยคำแบบวงวิชาการที่ว่า “รูปที่ 4 นำเสนอกฎที่งดงาม แต่การใช้งานจริงพึ่งพาทริกจำนวนมาก”
แย่กว่านั้นคือ บอกว่ากรณีการซ้อนทับที่แยกแยะได้แบบสแตติกจะทำให้คอมไพล์เออร์เรอร์ และถ้าไม่ใช่กรณีนั้น โค้ด Rust อาจ panic ระหว่างรันได้ การเปลี่ยนโปรแกรม C ที่ผ่านการตรวจพิสูจน์เชิงรูปแบบให้กลายเป็นโปรแกรม Rust ที่ “ตอนนี้อาจแครชได้” นั้นดูแปลก
การเรียก HACL* ว่าเป็นโค้ดเบส C ที่ผ่านการตรวจพิสูจน์เชิงรูปแบบอยู่แล้วก็ไม่แม่นยำ HACL* ถูกคอมไพล์เป็น C ได้ก็จริง แต่ไม่ใช่ไลบรารี C มันเขียนด้วยภาษาอื่นโดยสิ้นเชิง
ชื่อที่ซื่อตรงกว่าน่าจะเป็นประมาณ “การคอมไพล์ส่วนย่อยของ F* เป็น Rust ที่ปลอดภัยบางส่วน พร้อมการทำให้เป็นรูปแบบบางส่วน”
ในปี 2002 นักวิจัยได้ตีพิมพ์บทความเกี่ยวกับ Cyclone ซึ่งเป็นภาษาถิ่นของ C ที่ปลอดภัย และระหว่างพอร์ตโค้ด C ไปเป็น Cyclone ด้วยมือ ก็พบข้อบกพร่องด้านความปลอดภัยในโค้ด C ที่มีอยู่
การแปลง C แบบทำมือหรืออัตโนมัติทำนองนี้มีศักยภาพไม่เพียงในการเพิ่มการนำภาษาที่ปลอดภัยกว่าไปใช้ แต่ยังช่วยเผยบั๊กที่มีอยู่เดิมด้วย
[1] https://www.researchgate.net/profile/James-Cheney-2/publicat...
แนวคิดหลายอย่างของ Cyclone เข้าไปอยู่ใน Rust และโค้ดสามารถทำให้รันได้ถ้าพยายาม แต่บนแพลตฟอร์ม 64 บิตสมัยใหม่จะยังบิลด์ไม่ได้ทันที
http://cyclone.thelanguage.org
ผมเคยใช้ C2Rust เป็นขั้นตอนแรกในการพอร์ตโปรเจกต์บางตัว รวมถึงโปรเจกต์ C ไปเป็น Rust และได้ข้อสรุปบางอย่าง
unsafeอยู่ด้วย ข้อจำกัดที่เข้มแข็งของ Rust เช่น การตรวจขอบเขตและซิกเนเจอร์ที่เข้มงวด มักทำให้บั๊กปรากฏเร็วขึ้นถึงอย่างนั้น เครื่องมือก็จำเป็นต่อการพอร์ต และยิ่งเครื่องมือพัฒนาขึ้น กระบวนการก็จะยิ่งราบรื่นขึ้น
แค่แสดงหน่วยความจำเป็นอาร์เรย์ และมองพอยน์เตอร์เป็นดัชนีของอาร์เรย์นั้น ก็จะสามารถแสดงพฤติกรรมของ C อย่าง pointer arithmetic ที่ไม่มีการตรวจสอบหรือ union ได้โดยไม่ต้องสู้กับตัวตรวจสอบการยืม และยังรักษาความหมายไว้ได้ด้วย ใน C→Java ก็ใช้เทคนิคคล้ายกันมานานแล้ว
แน่นอนว่ามูลค่าของการแปลงแบบนี้ค่อนข้างคลุมเครือ โดยพื้นฐานมันคล้ายกับการคอมไพล์ C เป็น wasm แต่ช้ากว่า และแม้โค้ดผลลัพธ์จะ “ปลอดภัย” ในเชิงเทคนิค ปัญหาอย่างบัฟเฟอร์โอเวอร์โฟลว์ที่ทำให้เกิดสถานะผิดพลาด หรือพอยน์เตอร์ค้างที่ทำให้เข้าถึงข้อมูลในบริบทที่ไม่ควรอนุญาต ก็ยังคงอยู่
ผมเป็นผู้เขียนเอง คิดว่าน่าจะเป็นประโยชน์ถ้าสรุปประเด็นบางอย่างจากหลายเธรด
unsafe Rustสำหรับส่วนที่ไม่เข้าเงื่อนไขก็ได้สิ่งที่สงสัยจริง ๆ คือทำไมต้องทำแบบนี้
ถ้าเป็นเทคโนโลยีที่แปลงแอปอุตสาหกรรมจาก C เป็น Rust ได้จริง ก็น่าจะทำให้แอป C เดิม แข็งแกร่งเหมือนกันกระสุน ได้ง่ายขึ้นด้วย แค่สร้างการวิเคราะห์ที่จะใส่เข้าไปในเครื่องมือที่มีอยู่ เช่น static analyzer หรือตัวสร้างเทสต์
ในทำนองเดียวกัน อาจสร้าง wrapper ที่ปลอดภัยขึ้นมา เพื่อให้เขียนโค้ดใหม่เป็น Rust ข้าง ๆ C ที่ตรวจสอบแล้วได้ โค้ดใหม่ได้ประโยชน์จาก Rust โค้ดเดิมก็ได้รับการยืนยันว่าปลอดภัย และอินเทอร์เฟซก็ปลอดภัยขึ้นด้วย
ตัวแปลแบบสมบูรณ์อาจเป็นอุดมคติ เพราะระยะยาวแล้วการมีภาษาเดียวใน codebase ย่อมดีกว่า แต่สำหรับ C/C++ เดิม ความต้องการที่ใหญ่ที่สุดก็ยังเป็นการทำให้ปลอดภัยแบบกดปุ่มเดียวและมี false positive ต่ำ เช่นเดียวกับเครื่องมือคอมไพเลอร์ของ Google หรือ Mayhem ของ ForAllSecure การแก้โครงสร้างที่ไม่ดีใน C โดยอัตโนมัติก็อาจเป็นไปได้
อาจเป็นเพราะมันพึ่งพา undefined behavior หรือ unspecified behavior หรือเมื่อใส่การตรวจสอบความปลอดภัยที่เหมาะสมแล้ว ขอบเขตอินพุตที่ยอมรับได้จะหดเล็กลงจนใช้ไม่ได้
การแปลเป็นภาษาที่ปลอดภัยสามารถคงพลังการแสดงออกของอินพุตไว้ พร้อมรับประกันแบบ static ว่าจะทำงานถูกต้องตอนรันไทม์ ดังนั้นในกรณีแบบนี้จึงดีกว่าในเชิงวัตถุวิสัย
“C ที่พิสูจน์ตัวเองในภาคสนามแล้ว” ก็แทบพูดได้ว่าไม่มีอยู่จริง อย่างที่ช่องโหว่ร้ายแรงจำนวนมากแสดงให้เห็น สิ่งที่มีจริงคือ C ที่ทำงานได้ค่อนข้างดีบ่อยพอจนดูมีประโยชน์เท่านั้น
โค้ดเก่าถูกสมมติว่าโชคดีที่ปลอดภัย ไม่ใช่ถูกพิสูจน์แล้ว คำว่า “พิสูจน์” โดยเฉพาะในบริบทของบทความลักษณะนี้มีความหมายเฉพาะ และโค้ด C ส่วนใหญ่ท่วมท้นไม่ได้ถูกพิสูจน์ตามเกณฑ์ทางคณิตศาสตร์ที่เข้มงวด ในทางกลับกัน type system ของ Rust ได้รับการพิสูจน์ความถูกต้องทางคณิตศาสตร์แล้ว
ตัวแปลเต็มรูปแบบขึ้นอยู่กับว่ายอมสละอะไรได้บ้าง ถ้ายอมสละประสิทธิภาพ, ขอบเขตอินพุต, ขอบเขตเอาต์พุต, ความอ่านง่ายของโค้ด ฯลฯ ก็อาจเป็นไปได้ในระดับหนึ่ง แต่พอเริ่มต้องการตัวแปลที่ sound และ complete ในทุกด้านเหล่านี้ ปัญหาก็เกิดขึ้น
ถ้าแปลเป็น Rust แบบตรงไปตรงมา มันจะไม่กลายเป็นส่วนที่ปลอดภัยปนกับส่วนที่เป็น
unsafeหรือ? แบบนั้นงาน manual ก็น่าจะต้องตรวจแค่ความปลอดภัยของบริเวณunsafeเหมือนกับตอนเขียน Rust ตั้งแต่ต้นถ้าผลลัพธ์ 90% ไม่ใช่
unsafeก็ดูเหมือนจะได้ประโยชน์มากทีเดียวunsafe Rustระดับต่ำเป็นที่รู้กันว่า OpenJPEG เกิด segfault กับเทสต์เคสบางตัว และเมื่อรันเทสต์นั้นกับเวอร์ชัน Rust ก็เกิด segfault ที่โค้ด Rust ตำแหน่งที่สอดคล้องกัน อย่างน้อยก็ถือว่าเข้ากันได้
แต่แนวทางนั้นเป็นทางตัน ถ้าจะก้าวหน้า ตัวแปลต้องรู้จักสำนวนที่พบบ่อยใน C และยกระดับให้เป็นรูปแบบธรรมชาติของภาษาเป้าหมาย การ “คอมไพล์” เป็น Rust จะได้ Rust ที่น่ากลัว เต็มไปด้วยการเรียกฟังก์ชันจัดการพอยน์เตอร์สไตล์ C ที่ไม่ปลอดภัย
ปัญหาการยกระดับที่ใหญ่ที่สุดส่วนใหญ่เกี่ยวข้องกับพอยน์เตอร์ ผลลัพธ์ที่มีแนวโน้มดีที่สุดของบทความนี้คือการค้นพบ วิธีเปลี่ยน pointer arithmetic ของ C ให้เป็น slice ของ Rust slice ทำสิ่งส่วนใหญ่ที่ pointer arithmetic ของ C ทำได้ และตอนนี้ก็มีคนทำให้การแปลนั้นเป็นอัตโนมัติแล้ว pointer arithmetic ที่แปลไม่ได้ควรถูกมองอย่างน่าสงสัยมาก
การคิดว่า raw pointer ที่ชี้ไปยังอาร์เรย์ใน C มีความยาวแฝงติดอยู่ด้วยนั้นมีประโยชน์ ความยาวนั้นไม่ปรากฏในซอร์ส C แต่มีอยู่ที่ไหนสักแห่งในฐานะฟังก์ชันของสถานะโปรแกรม อาจเป็นค่าคงที่, ขนาดที่ขอจาก
malloc, หรือพารามิเตอร์ของฟังก์ชันก็ได้ โดยปกติแล้วโปรแกรมเมอร์ที่ดูแลโค้ดจะหาความยาวของอาร์เรย์ได้ไม่ยากนักนี่อาจเป็นปัญหาที่เหมาะกับ LLM อาจถามว่า “ดูโค้ดนี้แล้วบอกว่าความยาวของอาร์เรย์
fooคืออะไร” จากนั้นให้ตัวแปลที่ไม่ใช่ LLM ใช้คำตอบนั้นนำทางการแปลงเป็น Rust ถ้า LLM ผิด Rust ก็จะเกิด index error หรือมีอาร์เรย์ใหญ่เกินไป แต่จะไม่ไม่ปลอดภัย สำนวนการระบุขนาดอาร์เรย์ใน C มีรูปแบบค่อนข้างชัดเจน จึงน่าจะทายถูกได้เป็นส่วนใหญ่ โดยเฉพาะอย่างยิ่ง LLM ยังอ่านคอมเมนต์ได้ด้วยunsafeเพราะจะใช้ raw pointer แทน reference แทบทุกที่โค้ด C ไม่ได้เขียนโดยคำนึงถึง aliasing model และข้อจำกัดของ borrow checker ของ Rust ดังนั้นการแปลเป็น reference จึงทำได้ยาก
เป็นเพียงการคอมไพล์ ส่วนย่อยของ C ที่เล็กมากเท่านั้น จริง ๆ แล้วอาจเล็กจนแทบใช้งานไม่ได้ด้วยซ้ำ
ผมไม่ค่อยคาดหวังกับแนวทางแบบนี้มากนัก มันต้องชนกับข้อจำกัดของสิ่งที่การวิเคราะห์สถิตของโค้ด C ทำได้แน่นอน แถมการเลือก Rust เป็นเป้าหมายยังทำให้ปัญหายากขึ้นโดยไม่จำเป็น โมเดล ownership ของ Rust แตกต่างจากวิธีที่โปรแกรม C จริง ๆ ทำงานมากเกินไป
ตัวอย่างเช่น บัฟเฟอร์ใน C ย่อมมีความยาวอยู่แล้ว แต่ใน C ความยาวไม่ได้ผูกกับพอยน์เตอร์อย่างชัดเจน ดังนั้นตัวแปลต้องอนุมานว่าโปรแกรม C ติดตามความยาวอย่างไร แล้วแปลงเป็น slice เรื่องนี้ไม่ง่ายแม้ความยาวจะเป็นตัวแปรชัดเจน และจะยุ่งยากขึ้นอีกถ้าความยาวถูกคำนวณหรือเปลี่ยนรูปไปแสดงเป็น “พอยน์เตอร์หลังจุดสิ้นสุด”
แพตเทิร์นใน C อย่าง
bool should_free_this_pointerก็สามารถย้ายไปเป็น enumOwned/Borrowedของ Rust ได้ แต่ต้องอนุมานว่าการจัดสรรใดเชื่อมกับบูลีนใด และขอบเขตความปลอดภัยจริงของตัวแปรแบบยืมอยู่ตรงไหนถึงอย่างนั้นก็เห็นด้วยว่าการสร้าง Rust ที่เป็น idiomatic จาก C ใด ๆ นั้นยาก พูดง่าย ๆ คือคงได้แค่ระดับ “ถูกแบบคร่าว ๆ”
สงสัยว่าสิ่งนี้เทียบกับ ความสามารถในการแปลง C ของ Zig อย่างไร
Zig ดูเก่งในการสร้างสภาพแวดล้อมแบบผสมที่โค้ดใหม่เป็น Zig โค้ดเก่าเป็น C ทำการแปลงหรือทำงานร่วมกัน และยังทำหน้าที่เป็นคอมไพเลอร์ C ได้ด้วย
น่าจะมีเหตุผลที่ดีมากที่ผู้ดูแลเคอร์เนล Linux ไม่มอง Zig เป็นตัวแทน C แทน Rust ผมไม่ได้รู้ดีพอจะเดาได้ จึงอยากให้คนที่รู้มากกว่าช่วยอธิบาย
ผมไม่ใช่ผู้ดูแลเคอร์เนล แต่ถ้าให้เดาเหตุผลใหญ่สองข้อที่ Rust ถูกเลือกมากกว่า Zig ก็น่าจะเป็นเพราะ การรับประกันตอนคอมไพล์ ที่ภาษามอบให้นั้นดีกว่า และอัตราการนำไปใช้ก็เร็วกว่า
บริษัทใหญ่ ๆ ในอุตสาหกรรมกำลังทำงานกันมากเพื่อให้มีโค้ดเนทีฟ Rust สำหรับ API หรือ binding Rust ที่มีการดูแลต่อเนื่อง นักพัฒนา Windows เองก็กำลังเขียนบางส่วนของเคอร์เนลใหม่ด้วย Rust เช่นกัน มีความเคลื่อนไหวนี้ต่อเนื่องมาพอสมควร และหวังว่าจะไม่หยุดลง
ผู้ดูแลอาจรู้สึกว่า Zig ไม่ได้ให้ข้อได้เปรียบเหนือ C มากพอ หลายคนในกลุ่มนั้นยังคัดค้าน Rust อยู่ด้วยซ้ำ
Zig ทำงานร่วมกับ C ได้ดีกว่า Rust มาก แต่ไม่ ปลอดภัยด้านหน่วยความจำ และยังไม่เสถียร การนำ Zig ไปใช้ในโลก C น่าจะเพิ่มขึ้นไม่น้อย แต่คงมองว่าแข่งขันกับ Rust โดยตรงได้ยาก
ในพื้นที่ของผมไม่มีใครนำ Rust ไปใช้เลย คนสาย C++ ก็ยังอยู่กับ C++ ตอนแรกมีความสนใจ Rust อยู่บ้าง แต่ไม่ได้ตั้งหลักได้ในบริษัทไหนที่ผมรู้จักเลย อาจเป็นเหตุผลคล้ายกับที่ Go เติบโตมากในบริษัทอายุน้อย แต่เข้าไปในบริษัท Java/C# แบบดั้งเดิมได้ไม่ค่อยดีนัก เพราะแม้จะสมเหตุสมผลทางเทคนิค แต่มันเป็นโจทย์การจัดการความเปลี่ยนแปลงขนาดใหญ่
Zig กำลังได้แรงส่งในฝั่งโปรแกรมที่ไม่ต้องการการจัดสรรหน่วยความจำแบบไดนามิก แต่นอกเหนือจากนั้นยังไม่มากนัก
ยังมีการเปลี่ยนแปลงที่ทำให้ของเดิมพังอยู่เป็นประจำ ซึ่งตอนนี้เป็นเรื่องดีสำหรับ Zig แต่ไม่ดีสำหรับโค้ดเบสขนาดใหญ่และอายุยืนอย่าง Linux บั๊กของคอมไพเลอร์ก็ยังเกิดขึ้นได้
พูดแบบนี้ในฐานะคนที่โดยรวมชอบทิศทางของ Zig
สงสัยว่าเครื่องมืออย่าง
C2Rustจะใช้สิ่งนี้เพื่อสร้างโค้ดที่ถูกต้องตามรูปแบบพิสูจน์ได้หรือไม่และยังสงสัยว่าผู้เขียนทำงานด้วยมือมากแค่ไหน หรือรันอะไรบางอย่างเพื่อสร้างโค้ด Rust หรือเปล่า ถ้าเป็นอย่างนั้นก็ไม่รู้ว่าโค้ดที่สร้าง Rust อยู่ที่ไหน และก็ไม่เห็นลิงก์ไปยังซอร์สรีโพด้วย
ถ้าไลบรารี C ทำงานได้ กล่าวคือไม่ได้พิสูจน์อย่างเป็นทางการว่าไม่มีปัญหา แต่ส่วนใหญ่ทำงานได้ดี ทำไมไม่แปลโดยใช้
unsafe Rustกันนะโดยรวมแล้ว Rust ยังขาดไลบรารีอยู่มาก ผมจึงคิดว่ามีคุณค่า สุดท้ายก็ไม่ได้ต่างจากการใช้ dll/so ที่เขียนด้วย C ซึ่งอาจไม่ปลอดภัยในบางสถานการณ์มากนัก