2 คะแนน โดย GN⁺ 2 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • หากค่าที่ตัดส่วนทศนิยมของ float ออกแล้วอยู่นอกช่วงของชนิดจำนวนเต็มปลายทาง จะเกิด พฤติกรรมที่ไม่กำหนด (UB) และมีผลทั้งกับการแปลงแบบปริยาย, functional cast และ static_cast
  • -Wall และ -Wextra ไม่เตือนเรื่องนี้ และ -Wconversion ก็ ตรวจจับได้เฉพาะการแปลงแบบปริยาย จึงพลาดได้ง่าย
  • แม้แต่ฟังก์ชันแปลงแบบ narrowing อย่างปลอดภัย gsl::narrow ของ Microsoft GSL ก็ยังก่อให้เกิด UB กับอินพุตแบบจุดลอยตัว→จำนวนเต็มบางส่วน ทำให้ไม่เป็นไปตามพฤติกรรมในเอกสารที่ระบุว่าจะโยนข้อยกเว้นเมื่อค่าไม่สามารถแทนได้
  • CVTTSS2SI ของ x86 จัดการค่าที่แทนไม่ได้เป็น INT_MIN แต่ FCVTZS ของ AArch64 ใช้การแปลงแบบอิ่มตัวและเปลี่ยน NaN เป็น 0 ดังนั้น ผลลัพธ์อาจต่างกันไปตามฮาร์ดแวร์
  • หากต้องการแปลงอย่างปลอดภัย ต้อง ตรวจสอบช่วงก่อนทำ cast และสามารถใช้ตัวเลือก UBSan ของ Clang·GCC -fsanitize=float-cast-overflow เพื่อตรวจจับปัญหาได้

กฎการแปลงและข้อจำกัดของการตรวจจับ

  • ตาม กฎการแปลงค่าจุดลอยตัว-จำนวนเต็มของ C++ หากหลังตัดส่วนทศนิยมแล้วค่ายังไม่อยู่ในชนิดจำนวนเต็มปลายทาง จะถือเป็น พฤติกรรมที่ไม่กำหนด
    • แม้ปลายทางจะเป็น unsigned ก็ไม่ได้ใช้เลขคณิตแบบโมดูลาร์
    • int i0 = f, int(f), static_cast<int>(f) ล้วนทำให้เกิด UB ได้กับอินพุตบางค่า
  • เป็นเรื่องยากที่จะค้นหาปัญหาทั้งหมดได้ด้วยคำเตือนของคอมไพเลอร์ทั่วไปเพียงอย่างเดียว
    • -Wall และ -Wextra ไม่เตือนทั้งสามรูปแบบการแปลง
    • -Wconversion เตือนเฉพาะการแปลงแบบปริยายเท่านั้น
  • แม้โปรแกรมจะยังทำงานต่อไปได้บนโปรเซสเซอร์และคอมไพเลอร์ปัจจุบัน แต่ผลลัพธ์อาจต่างกันไปในแต่ละแพลตฟอร์ม
    • CVTTSS2SI ของ x86 แมปอินพุตที่แทนค่าไม่ได้เป็น INT_MIN
    • FCVTZS ของ AArch64 จะจัดการแบบอิ่มตัวและแมป NaN เป็น 0
    • UB ที่เกิดขึ้นแล้วอาจทำให้โค้ดเริ่มทำงานผิดพลาดอย่างกะทันหันเมื่อคอมไพเลอร์ใช้การแปลงแบบอื่น

กรณีของ GSL และแนวทางรับมืออย่างปลอดภัย

  • gsl::narrow ของ Microsoft Guidelines Support Library ระบุว่าตนเป็นการแปลงแบบ narrowing ที่ปลอดภัย โดยจะโยนข้อยกเว้นเมื่อค่าไม่สามารถแทนได้ในชนิดปลายทาง
    • แต่ในการแปลงจุดลอยตัว→จำนวนเต็มจริง ๆ จะ เกิด UB ก่อน สำหรับอินพุตบางค่า จึงไม่ตรงกับเอกสาร
    • ฝั่ง GSL เห็นว่า UB ภายในนั้นไม่เป็นอันตราย เพราะไม่ได้ไปแตะ trap representation ของฮาร์ดแวร์บนแพลตฟอร์มเป้าหมาย และตรรกะนี้ก็สะท้อนอยู่ในโค้ด ทำให้ปัญหานี้ยังไม่ได้รับการแก้ไข
  • วิธีแก้ที่ถูกต้องคือ ตรวจสอบช่วงก่อนทำ cast
  • ใน Undefined Behavior Sanitizer ของ Clang และ GCC สามารถใช้ -fsanitize=float-cast-overflow เพื่อตรวจจับ UB นี้ได้
    • แนะนำให้ทดสอบโค้ด C++ ทั้งหมดด้วย UBSan

1 ความคิดเห็น

 
GN⁺ 2 시간 전
ความคิดเห็นจาก Lobste.rs
  • แม้จะรู้เรื่องพฤติกรรมที่ไม่ได้นิยามไว้อันละเอียดอ่อนของ C และ C++ มามากแล้ว แต่กรณีนี้ก็ยังน่าตกใจ
    Rust ก็สืบทอดกฎการแปลงจากจำนวนทศนิยม→จำนวนเต็มแบบเดียวกันใน LLVM IR มา ทำให้มีพฤติกรรมที่ไม่ได้นิยามไว้อยู่พักหนึ่ง และเพิ่งแก้ในปี 2020 ให้สร้าง IR ที่ซับซ้อนขึ้น เนื่องจากความต่างด้านประสิทธิภาพค่อนข้างมาก จึงมี การแปลงจากจำนวนทศนิยม→จำนวนเต็มแบบไม่ตรวจสอบ ให้ใช้สำหรับลูปประสิทธิภาพสูงที่รู้แน่ว่าค่าเป็น finite และอยู่ในช่วงของชนิดเป้าหมาย
    การที่ C++ Core Guidelines Library มองเรื่องนี้เป็นเรื่องเล็กน้อยนั้นเหลวไหลมาก LLVM ใช้ข้อมูลที่ได้จากการแปลงเพื่อตัดการตรวจสอบขอบเขตออก และผลก็คือมี กรณีที่หลุดออกนอกขอบเขตอาร์เรย์ทั้งที่มีการตรวจสอบขอบเขตอยู่ ถ้าให้ความสำคัญกับ ความปลอดภัยของหน่วยความจำ และการหลีกเลี่ยงพฤติกรรมที่ไม่ได้นิยามไว้อย่างจริงจัง ก็ไม่อาจมองข้ามเรื่องนี้ได้ และการที่ Herb Sutter พูดถึง “พฤติกรรมที่ไม่ได้นิยามไว้แบบไม่เป็นพิษเป็นภัย” ก็ชวนผิดหวัง

  • รู้ว่า C++ มีพฤติกรรมที่ไม่ได้นิยามไว้เยอะ แต่กรณีนี้น่าตกใจเป็นพิเศษ สงสัยว่าเป็นเพราะพยายามทำให้การแปลงเร็วที่สุด และแต่ละสถาปัตยกรรมมีคำสั่งที่จัดการค่าขอบสุดแบบนี้ต่างกัน เลยปล่อยไว้เป็น พฤติกรรมที่ไม่ได้นิยามไว้ หรือเปล่า ดูเหมือนเป็นเหตุผลหลักที่ทำให้มีกฎแบบนี้ คล้ายกับ integer overflow แบบมีเครื่องหมาย

    • ถ้าเป็นเหตุผลนั้น ก็ควรเป็น พฤติกรรมที่กำหนดโดย implementation ไม่ใช่พฤติกรรมที่ไม่ได้นิยามไว้ การหารด้วยศูนย์เข้าใจได้ว่าเป็นพฤติกรรมที่ไม่ได้นิยามไว้ เพราะบางสถาปัตยกรรมจะ trap
      พฤติกรรมที่ไม่ได้นิยามไว้ควรถูกจำกัดไว้กับกรณีที่ไม่สามารถรับประกันผลลัพธ์ที่สม่ำเสมอแม้บนแพลตฟอร์มเดียวกันได้ เพราะมีผลกระทบนอกเครื่องจักรนามธรรมของ C เช่น use-after-free หรือกรณีที่อาจ trap บนบาง target
      implementation ที่เป็นไปตามมาตรฐานสามารถนิยามพฤติกรรมที่ไม่ได้นิยามไว้เองได้ และ GCC ก็ทำแบบนั้นกับบางรายการ ถ้าสามารถรับประกันความหมายที่เสถียรได้โดยไม่มีต้นทุนด้านประสิทธิภาพ ก็อาจนิยามให้ตรงกับพฤติกรรมของคำสั่งแปลงจำนวนทศนิยม→จำนวนเต็มตามแต่ละ target ได้ แต่ใน implementation อื่นก็ยังคงเป็นพฤติกรรมที่ไม่ได้นิยามไว้อยู่
    • ก็คงเป็นอย่างนั้น ตระกูล fctiw ของ PowerPC ทำการแปลงแบบ saturating และเปลี่ยน NaN เป็น INT_MIN พร้อมตั้งค่าแฟล็ก FPSCR ด้วย ส่วน fctid ของ Power ISA 64 บิตก็จัดการแบบเดียวกันกับจำนวนเต็มที่ใหญ่กว่า แต่ทั้งสองแบบไม่ตรงกับพฤติกรรมของ AArch64 หรือ x86
  • กรณีแบบนี้ควรจัดเป็น พฤติกรรมที่กำหนดโดย implementation หรือค่าที่ไม่ได้ระบุจะเหมาะกว่า แค่แปลง infinity เป็น int ไม่ควรทำให้ทั้งโปรแกรมหลุดออกนอกการควบคุมของมาตรฐาน C++
    C++26 ได้ลบพฤติกรรมที่ไม่ได้นิยามไว้ที่ไม่สมเหตุสมผลออกไปบางส่วนแล้ว และกรณีนี้ก็เป็นผู้สมัครที่ชัดเจนว่าควรถูกลบด้วย จากการทดลอง ดูเหมือน GCC และ Clang ไม่ได้ใช้ประโยชน์จากพฤติกรรมที่ไม่ได้นิยามไว้นี้ในการ optimize ดังนั้นผลกระทบจริงน่าจะจำกัด

  • เป็นอีกกรณีน่าหงุดหงิดที่ไม่มีเหตุผลให้สเปกระบุ operation นี้เป็น พฤติกรรมที่ไม่ได้นิยามไว้

  • ได้เหตุผลเพิ่มอีกข้อให้ไม่ชอบ IEEE 754 ถ้าไม่ได้จำเป็นจริงๆ เพราะไลบรารีอื่นหรือประสิทธิภาพ ก็พยายามใช้จำนวนเต็มล้วนๆ, จำนวนตรรกยะที่ใช้เศษและส่วนเป็น big integer, หรือ fixed-point decimal แทน floating point ให้มากที่สุด

    • เรื่องนี้ ไม่เกี่ยวกับ IEEE 754 และเป็นความผิดของ C++ ล้วนๆ
  • ถ้าเคยใช้ C หรือ C++ ความแตกต่างระหว่าง float32 กับ int32 หรือแม้แต่ int64 ก็ไม่ควรน่าแปลกใจ float32 ที่มีเลขชี้กำลังใหญ่สามารถแทนค่าจำนวนเต็มที่ใหญ่กว่า int64 ได้มาก
    ระหว่างรูปแบบที่ไม่มีใครเป็น superset ของอีกฝ่าย และมีความสามารถในการแทนค่าต่างกัน ไม่มีเหตุผลให้สมมติว่า การแปลงจากจำนวนทศนิยม→จำนวนเต็ม ปลอดภัย ไม่ว่าจะเกี่ยวกับไวยากรณ์ภาษาหรือไม่ก็ตาม

    • กำลังพลาดประเด็นสำคัญ การที่ไม่สามารถรักษาค่าทั้งหมดของ floating point ได้ กับ พฤติกรรมที่ไม่ได้นิยามไว้ เป็นคนละเรื่องกัน
      uint32_t ก็ไม่สามารถแทนค่าทั้งหมดของ uint64_t ได้ แต่ความหมายของการแปลงถูกนิยามไว้เป็นการตัดทิ้ง ประเด็นในที่นี้คือสำหรับ input บางอย่าง compiler สามารถทำอะไรก็ได้ ซึ่งเป็นปัญหาที่ต่างกันโดยพื้นฐาน
    • ช่วงการแทนค่าที่ต่างกันไม่ได้แปลว่าจะนิยามการแปลงที่ปลอดภัยไม่ได้ Rust นิยามการแปลงจากจำนวนทศนิยม→จำนวนเต็มไว้อย่างชัดเจน และ Java 26 Language Specification ระบุขั้นตอนการแปลงอย่างละเอียดในข้อ 5.1.3
      ถ้าเป็นภาษาระดับต่ำ ก็อาจจับคู่กับคำสั่ง assembly อย่าง CVTTSS2SI หรือ FCVTZS ได้เช่นกัน ในเมื่อภาษาระดับต่ำมีความใกล้ชิดกับ assembly และพฤติกรรมที่ไม่ได้นิยามไว้แบบ “ไม่เป็นพิษเป็นภัย” อื่นๆ ก็เป็นที่ถกเถียงมากอยู่แล้ว ความจริงที่ว่าการแปลงนี้ยอมให้เป็นพฤติกรรมที่ไม่ได้นิยามไว้จึงยิ่งน่าตกใจ
    • การได้ค่าขยะตาม implementation บนฮาร์ดแวร์แต่ละแบบ หรือการที่ฮาร์ดแวร์/เครื่องมือตรวจสอบ trap แล้วหยุดโปรแกรม ไม่ใช่เรื่องน่าประหลาดใจมากนัก
      แต่การอนุญาตให้คอมไพล์โค้ดที่ไม่เกี่ยวข้องผิดเพี้ยนไปอย่างประหลาด, ฟอร์แมตฮาร์ดไดรฟ์, หรือทำให้ “ปีศาจบินออกมาจากจมูก” ได้นั้นน่าตกใจ
      สำหรับ double free หรือการเขียนออกนอกขอบเขตอาร์เรย์ แนวคิดของพฤติกรรมที่ไม่ได้นิยามไว้ว่าทุกอย่างอาจเกิดขึ้นได้นั้นสมเหตุสมผล แต่ C และ C++ ใช้มันพร่ำเพรื่อแม้ในจุดที่ควรมีกฎที่เข้มงวดกว่าได้ เช่น ค่าขยะที่กำหนดโดย implementation หรือการหยุดโปรแกรม Rust ทำให้ integer overflow บางกรณี trap ในโหมด debug และคืนค่าที่ไม่ได้ระบุในโหมด release แต่ไม่ปล่อยให้มันทำลายโค้ดที่ไม่เกี่ยวข้อง
      C++ ไม่จำเป็นต้องกลายเป็น Java หรือ Rust แต่ถ้า ลดพฤติกรรมที่ไม่ได้นิยามไว้ ที่ไม่จำเป็นลง ก็จะเป็นภาษาที่ดีขึ้นอย่างชัดเจน
    • สำหรับผมนี่เป็นเรื่องใหม่ เคยคิดว่าถ้าแปลง floating point ที่แทนไม่ได้เป็นจำนวนเต็ม ก็แค่ได้ค่ามั่วๆ อยู่ในจำนวนเต็มผลลัพธ์เท่านั้น ไม่ได้รู้เลยว่าทั้งโปรแกรมจะถูกปนเปื้อนไปตลอดและหลุดออกนอก การควบคุมของมาตรฐาน C++